Model Context Protocol servers exist to give AI assistants a standard way to call out to real tools and real data. The moment you move one off your own machine and onto a host that other people's assistants can reach, you inherit the usual trade-off: keep it running and pay for idle compute between calls, or scale it to zero and eat a multi-second cold start the moment someone actually needs it.
Unikraft builds and boots your app as a unikernel: a minimal, purpose-built kernel containing only the code the app actually needs, rather than a general-purpose Linux kernel underneath a container. That's what gets boot times down to milliseconds, and deployment still starts from a Dockerfile, Unikraft just boots it as a microVM instead of running it as a container.
This post builds a Go MCP server backed by live GitHub data, then deploys it on Unikraft Cloud as a microVM that boots in around 24 milliseconds and costs nothing while idle, which changes that trade-off rather than just picking a side of it.
Prerequisites
Before starting, you will need:
- Go 1.25 or later.
- Docker with BuildKit enabled.
- The Unikraft CLI, installed.
- A free Unikraft Cloud account, with
unikraft logincompleted in your terminal. - A GitHub fine-grained personal access token, scoped to read-only access on Contents, Issues, and Pull requests.
- VS Code with GitHub Copilot Chat, or Claude Code, to actually exercise the deployed server as an MCP client rather than just curling it.
Why this is a good fit for microVMs specifically
Tool calls from an AI assistant are bursty by nature. A session might call a tool a handful of times in quick succession, then go quiet for hours while the person reads, thinks, or does something else entirely.
That access pattern punishes both of the usual hosting choices: an always-on container burns cost through all the quiet hours, and a container that scales to zero burns time on every burst, since a typical container cold start runs anywhere from several hundred milliseconds to a few seconds once you count image pull, runtime init, and application startup.
That's specifically what Unikraft is built for. In a published benchmark on a single AMD EPYC 7402P server, cold starts ran about 4ms for the first instance and stayed under 14ms even by the 5,000th concurrent VM on that same machine, and more recent platform work has pushed past a million scaled-to-zero microVMs on a single server while keeping cold starts and resumes in single-digit milliseconds.
Setting up the project
The server lives in a single Go module. The layout ends up simple on purpose: a main.go entrypoint, a tools package holding the three tool implementations plus shared helpers, and a github package wrapping an authenticated GitHub client.
mcp-github-insights/
├── main.go
├── tools/
│ ├── repo_stats.go
│ ├── open_prs.go
│ ├── search_code.go
│ └── tools.go
├── github/
│ └── client.go
├── Dockerfile
├── Kraftfile
└── go.mod
Two dependencies do almost all the work: mark3labs/mcp-go for the MCP server and transport, and google/go-github for talking to GitHub's REST API. Running go get -u github.com/mark3labs/mcp-go resolved to v0.55.1, and go get github.com/google/go-github/v68 resolved to v68.0.0, alongside golang.org/x/oauth2 at v0.36.0 for authenticating the GitHub client.
Wiring up the MCP server
The mcp-go API is small enough that the entire entrypoint fits in about 30 lines. It creates a server, registers the tools, and starts a Streamable HTTP listener:
package main
import (
"log"
"os"
"github.com/mark3labs/mcp-go/server"
"mcp-github-insights/tools"
)
func main() {
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
addr := "0.0.0.0:" + port
s := server.NewMCPServer(
"github-insights",
"0.1.0",
server.WithToolCapabilities(true),
server.WithRecovery(),
)
tools.Register(s)
httpServer := server.NewStreamableHTTPServer(s)
log.Printf("MCP GitHub Insights server listening on %s (endpoint: /mcp)", addr)
if err := httpServer.Start(addr); err != nil {
log.Fatalf("server error: %v", err)
}
}
WithRecovery() is worth calling out specifically. It catches panics inside tool handlers and turns them into a normal MCP error response instead of taking the whole process down, which matters once real GitHub API responses, rate limits, missing repositories, malformed input, start hitting the server instead of whatever you tested with locally.
MCP's protocol has moved through more than one transport over its short life: stdio for local-only tools, then SSE, and now Streamable HTTP as the current standard. Read the installed version's source before writing this part of your own server, since guessing at a method name here is the single most common way to burn an afternoon.
server.NewStreamableHTTPServeris whatmcp-gov0.55.1 uses, and it is the right choice regardless, since Unikraft Cloud routes traffic to a TLS-terminated FQDN, which only works with a real network transport, not stdio.
The three tools
Each tool follows the same shape: register it with s.AddTool(mcp.NewTool(...), handler), pull arguments out of the request with req.RequireString or req.GetInt, do the actual GitHub API call, and return the result with mcp.NewToolResultText on success or mcp.NewToolResultError on failure.
get_repo_stats takes an owner and a repo, and returns stars, forks, open issue count, the default branch, and the SHA and date of the most recent commit. Here is the tool exactly as it ships in the repo:
package tools
import (
"context"
"time"
"github.com/google/go-github/v68/github"
"github.com/mark3labs/mcp-go/mcp"
)
type repoStats struct {
Stars int `json:"stars"`
Forks int `json:"forks"`
OpenIssues int `json:"open_issues"`
DefaultBranch string `json:"default_branch"`
LastCommitSHA string `json:"last_commit_sha"`
LastCommitDate string `json:"last_commit_date"`
}
func repoStatsTool() mcp.Tool {
return mcp.NewTool("get_repo_stats",
mcp.WithDescription("Get star, fork and open-issue counts plus the last commit for a GitHub repository."),
mcp.WithString("owner", mcp.Required(), mcp.Description("Repository owner (user or organization).")),
mcp.WithString("repo", mcp.Required(), mcp.Description("Repository name.")),
)
}
func repoStatsHandler(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
owner, err := req.RequireString("owner")
if err != nil {
return mcp.NewToolResultError("owner is required"), nil
}
repo, err := req.RequireString("repo")
if err != nil {
return mcp.NewToolResultError("repo is required"), nil
}
client, err := newClient(ctx)
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
r, _, err := client.Repositories.Get(ctx, owner, repo)
if err != nil {
return mcp.NewToolResultError(ghError("fetch repository", err)), nil
}
out := repoStats{
Stars: r.GetStargazersCount(),
Forks: r.GetForksCount(),
OpenIssues: r.GetOpenIssuesCount(),
DefaultBranch: r.GetDefaultBranch(),
}
commits, _, err := client.Repositories.ListCommits(ctx, owner, repo, &github.CommitsListOptions{
ListOptions: github.ListOptions{PerPage: 1},
})
if err != nil {
return mcp.NewToolResultError(ghError("fetch last commit", err)), nil
}
if len(commits) > 0 {
out.LastCommitSHA = commits[0].GetSHA()
out.LastCommitDate = commits[0].GetCommit().GetCommitter().GetDate().Format(time.RFC3339)
}
return jsonResult(out)
}
Two things are worth pointing out about this handler beyond what it obviously does. First, every failure path returns through mcp.NewToolResultError, never a bare Go error as the second return value, which is what keeps a GitHub outage or a typo'd repository name from ever reaching the client as a raw exception. Second, ghError is what turns a generic go-github error into the specific, readable message a model can actually reason about, rather than a wrapped stack trace. Running this against a real repository returns exactly what you would expect:
{
"stars": 3762,
"forks": 1467,
"open_issues": 348,
"default_branch": "staging",
"last_commit_sha": "be744898b6947824e367e01765703401e08ce3c5",
"last_commit_date": "2026-06-05T11:42:40Z"
}
That is real output from the deployed server, querying unikraft/unikraft.
list_open_prs takes an owner, a repo, and an optional min_age_days, and returns every open pull request older than that threshold, with its title, author, age, and a best-effort review decision:
package tools
import (
"context"
"time"
"github.com/google/go-github/v68/github"
"github.com/mark3labs/mcp-go/mcp"
)
type openPR struct {
Number int `json:"number"`
Title string `json:"title"`
Author string `json:"author"`
CreatedAt string `json:"created_at"`
AgeDays int `json:"age_days"`
ReviewDecision string `json:"review_decision"`
}
func openPRsTool() mcp.Tool {
return mcp.NewTool("list_open_prs",
mcp.WithDescription("List open pull requests for a GitHub repository, optionally filtered by minimum age in days."),
mcp.WithString("owner", mcp.Required(), mcp.Description("Repository owner (user or organization).")),
mcp.WithString("repo", mcp.Required(), mcp.Description("Repository name.")),
mcp.WithNumber("min_age_days", mcp.Description("Only include PRs at least this many days old. Defaults to 0.")),
)
}
func openPRsHandler(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
owner, err := req.RequireString("owner")
if err != nil {
return mcp.NewToolResultError("owner is required"), nil
}
repo, err := req.RequireString("repo")
if err != nil {
return mcp.NewToolResultError("repo is required"), nil
}
minAge := req.GetInt("min_age_days", 0)
client, err := newClient(ctx)
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
prs, _, err := client.PullRequests.List(ctx, owner, repo, &github.PullRequestListOptions{
State: "open",
ListOptions: github.ListOptions{PerPage: 100},
})
if err != nil {
return mcp.NewToolResultError(ghError("list open pull requests", err)), nil
}
out := make([]openPR, 0, len(prs))
for _, pr := range prs {
age := int(time.Since(pr.GetCreatedAt().Time).Hours() / 24)
if age < minAge {
continue
}
out = append(out, openPR{
Number: pr.GetNumber(),
Title: pr.GetTitle(),
Author: pr.GetUser().GetLogin(),
CreatedAt: pr.GetCreatedAt().Format(time.RFC3339),
AgeDays: age,
ReviewDecision: reviewDecision(ctx, client, owner, repo, pr.GetNumber()),
})
}
return jsonResult(out)
}
// reviewDecision derives an aggregate review state from a PR's reviews.
// Best-effort: on any error it returns "unknown" rather than failing the call.
func reviewDecision(ctx context.Context, client *github.Client, owner, repo string, number int) string {
reviews, _, err := client.PullRequests.ListReviews(ctx, owner, repo, number, &github.ListOptions{PerPage: 100})
if err != nil || len(reviews) == 0 {
return "unknown"
}
latest := map[string]string{}
for _, r := range reviews {
switch r.GetState() {
case "APPROVED", "CHANGES_REQUESTED":
latest[r.GetUser().GetLogin()] = r.GetState()
}
}
if len(latest) == 0 {
return "pending"
}
for _, state := range latest {
if state == "CHANGES_REQUESTED" {
return "changes_requested"
}
}
return "approved"
}
The GitHub API has no native "minimum age" filter on pull requests, so minAge is applied client-side, after fetching up to 100 open PRs per page. reviewDecision is its own function for a reason: reviews are a separate API call per pull request, so it is isolated on purpose, both to keep openPRsHandler readable and so that a failure fetching reviews for one PR degrades to "unknown" instead of failing the entire tool call. The filtering matters more in practice than it looks on paper. Against unikraft/kraftkit, the tool returned 6 open pull requests with min_age_days at 0, and 2 with min_age_days at 180, which is the difference between everything that is open and the pull requests that are actually stuck.
search_code takes an owner, a repo, and a query, and searches GitHub's code search API scoped to that repository, returning matching file paths with line snippets:
package tools
import (
"context"
"fmt"
"strings"
"github.com/google/go-github/v68/github"
"github.com/mark3labs/mcp-go/mcp"
)
type codeMatch struct {
Path string `json:"path"`
LineSnippet string `json:"line_snippet"`
}
func searchCodeTool() mcp.Tool {
return mcp.NewTool("search_code",
mcp.WithDescription("Search code within a single GitHub repository and return matching file paths with snippets."),
mcp.WithString("owner", mcp.Required(), mcp.Description("Repository owner (user or organization).")),
mcp.WithString("repo", mcp.Required(), mcp.Description("Repository name.")),
mcp.WithString("query", mcp.Required(), mcp.Description("Code search query.")),
)
}
func searchCodeHandler(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
owner, err := req.RequireString("owner")
if err != nil {
return mcp.NewToolResultError("owner is required"), nil
}
repo, err := req.RequireString("repo")
if err != nil {
return mcp.NewToolResultError("repo is required"), nil
}
query, err := req.RequireString("query")
if err != nil {
return mcp.NewToolResultError("query is required"), nil
}
client, err := newClient(ctx)
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
q := fmt.Sprintf("%s repo:%s/%s", query, owner, repo)
res, _, err := client.Search.Code(ctx, q, &github.SearchOptions{
TextMatch: true,
ListOptions: github.ListOptions{PerPage: 30},
})
if err != nil {
// Code search has a stricter rate-limit tier; ghError reports it cleanly.
return mcp.NewToolResultError(ghError("search code", err)), nil
}
out := make([]codeMatch, 0, len(res.CodeResults))
budget := 10 // cap fallback content fetches to bound latency/rate-limit cost
for _, cr := range res.CodeResults {
snippet := ""
if len(cr.TextMatches) > 0 {
snippet = cr.TextMatches[0].GetFragment()
}
if snippet == "" && budget > 0 {
budget--
snippet = matchedLine(ctx, client, owner, repo, cr.GetPath(), query)
}
out = append(out, codeMatch{Path: cr.GetPath(), LineSnippet: snippet})
}
return jsonResult(out)
}
// matchedLine fetches a file and returns the first line containing the query,
// used as a fallback when GitHub's code search omits the text-match fragment.
func matchedLine(ctx context.Context, client *github.Client, owner, repo, path, query string) string {
file, _, _, err := client.Repositories.GetContents(ctx, owner, repo, path, nil)
if err != nil || file == nil {
return ""
}
content, err := file.GetContent()
if err != nil || content == "" {
return ""
}
needle := strings.ToLower(query)
for _, line := range strings.Split(content, "\n") {
if strings.Contains(strings.ToLower(line), needle) {
return strings.TrimSpace(line)
}
}
return ""
}
This one needed the most defensive handling of the three. GitHub's code search API sometimes omits the text-match fragment it is supposed to return alongside a hit, so matchedLine is a fallback that fetches the actual file content and finds the matching line itself, but that fallback costs a full API call per file, which is why budget caps it at 10 fetches per search rather than letting a broad query trigger dozens of extra requests. And code search has its own, tighter rate-limit tier than the rest of the REST API, so a 403 from client.Search.Code needs to come back through ghError as a clean, specific MCP error rather than crash the tool call.
Building the container image
Unikraft's base runtime loads a plain ELF binary directly, without a container runtime underneath it, and its loader requires that binary to be position-independent. A default Go build does not produce one, so the build needs -buildmode=pie at minimum. The detail that matters beyond that is how the PIE gets linked. Go's internal PIE linker still emits a PT_INTERP header, which marks the binary as dynamically linked and expects a real loader to be present at boot, which does not exist on an empty FROM scratch image. The way around that is external linking with -static-pie, which needs a real C toolchain (CGO_ENABLED=1) and -tags netgo so DNS resolution stays in pure Go instead of reaching for libc. The result is a binary with no PT_INTERP header at all, no loader to find, and nothing left for scratch to be missing:
FROM --platform=linux/amd64 golang:1.25-bookworm AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build \
-buildmode=pie \
-ldflags "-linkmode external -extldflags -static-pie -s -w" \
-tags netgo \
-o /mcp-server .
FROM scratch
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=build /mcp-server /mcp-server
ENTRYPOINT ["/mcp-server"]
Two details in that final stage are not optional. The CA certificate bundle is copied in explicitly, since the server makes outbound HTTPS calls to the GitHub API and there is no CA trust store on a bare image otherwise. And GOARCH=amd64 has to be explicit if you are building from an Apple Silicon machine, since Unikraft Cloud's metros run on x86_64: the build cross-compiles and runs under emulation on an arm64 dev machine, which makes the build itself slower, but the metro needs amd64 regardless of what you build on.
The payoff for the static-PIE approach over a more conventional Alpine-based build is that there is no userspace left to load at all, just the binary and a certificate file, which shows up directly in the boot time (more on that in the testing section below). With this Dockerfile, the boot log is clean:
Powered by Unikraft Ijiraq (0.21.0~a653e50)
2026/07/05 00:18:59 MCP GitHub Insights server listening on 0.0.0.0:8080 (endpoint: /mcp)
Writing the Kraftfile
The Kraftfile that ties the Dockerfile to a deployable Unikraft Cloud image is small:
spec: v0.6
runtime: base:latest
rootfs: ./Dockerfile
cmd: ['/mcp-server']
env:
PORT: '8080'
Notice GITHUB_TOKEN is not in there. Secrets should not live in a file that gets committed to version control, so it is passed at deploy time as a runtime environment variable instead, which the Unikraft CLI masks as asterisks in its own instance summary output.
Deploying to Unikraft Cloud
With the Dockerfile and Kraftfile in place, building and running the instance is two commands:
unikraft build . --output <your-org>/mcp-github-insights:latest
unikraft run \
--metro fra \
--image <your-org>/mcp-github-insights:latest \
-p 443:8080/http+tls \
-e GITHUB_TOKEN=<your-github-token> \
--scale-to-zero policy=on,cooldown-time=300000 \
--follow
--metro fra pins the instance to Unikraft Cloud's Frankfurt metro. Other metros available at the time of writing are dal, sin, was, and sfo. --scale-to-zero policy=on,cooldown-time=300000 tells the instance to suspend itself after 300 seconds of no traffic (cooldown-time is specified in milliseconds), which is the whole point of running an MCP server this way instead of on a container that has to stay warm. The command's output includes the instance's assigned FQDN, which for this deployment came back as broken-snowflake-r572gbfj.fra.unikraft.app.
Connecting a real MCP client
A running server is not proof of anything on its own, so the last step is pointing an actual MCP client at it. For VS Code with GitHub Copilot Chat, that is a .vscode/mcp.json file:
{
"servers": {
"github-insights": {
"type": "http",
"url": "https://<your-fqdn>.fra.unikraft.app/mcp"
}
}
}
Claude Code's MCP configuration follows the same shape with its own config file. Once connected, a prompt like "using the github insight mcp, how many open PRs on unikraft are older than 90 days" triggers a live list_open_prs call against the deployed instance.
Testing and troubleshooting
Before wiring in a client, it is worth confirming the server itself over curl, since MCP's Streamable HTTP transport requires an initialize handshake before any other call will work:
# 1. initialize — capture the Mcp-Session-Id response header
curl -si -X POST https://<your-fqdn>.fra.unikraft.app/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"curl","version":"0"}}}'
# 2. list tools — pass the session id from step 1
curl -s -X POST https://<your-fqdn>.fra.unikraft.app/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H 'Mcp-Session-Id: <id-from-step-1>' \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'
A missing session ID on the second call is the most common mistake here, and it fails with a plain 400 rather than anything that points you toward the actual cause.
The platform-reported boot time for this deployment lands in 24 milliseconds — a fraction of a second, and still orders of magnitude below a typical container cold start measured in whole seconds. Scale-to-zero resume behaves the same way: a non-stateful wake is just a fresh boot, so it comes up in that same range after the instance has gone fully idle. Treat the exact figure as a ballpark rather than a fixed number, since it varies run to run with conditions in the metro.
The end-to-end wall clock for that first request after waking, measured from outside the Frankfurt metro, was 0.512 seconds, with the TLS handshake alone accounting for 0.143 seconds, so the request is dominated by network and TLS overhead, not the VM coming up.
Conclusion
What is running now is a Go MCP server that answers real questions about real GitHub repositories, packaged as a microVM that costs nothing while nobody is asking it anything, and answers in about 24 milliseconds the moment someone does. The full source, including the Dockerfile, the Kraftfile, and the three tool implementations, is available at this repository.
From here, the natural next step is wiring in more tools against a real internal API instead of GitHub's, or looking at how the same pattern applies to an MCP server that needs persistent state between calls, which is where Unikraft Cloud's volume support comes in.
If you want to try this yourself, Unikraft Cloud offers a free account to deploy your own microVMs at unikraft.com, and its docs cover the Kraftfile spec, scale-to-zero, and other integrations in more depth than this post has room for.



Top comments (0)