Documentation 33
Tutorial · MCP servers · 15 min
Test your MCP server against a GitHub that never changes.
A tool server is a translator between an agent and somebody else's API, and you cannot test a translator without both sides. MCP Inspector gives you the agent side: it starts your server, lists its tools and shows you the JSON-RPC. Nothing gives you the other side, so the upstream under your tests is a real account, which rate limits you, changes overnight and cannot be asserted against twice. This page points a real, unmodified GitHub MCP server at a pinned replica instead, runs a client against it, and asserts what the tools returned.
01 / The problem
Inspector drives your server. Nothing feeds it.
Everything a tool server can get wrong lives in the seam it owns: which endpoint a tool calls, which arguments it forwards, which fields it keeps, whether two tools describing one object still agree. Every one of those is a claim about data that came back, so every one of them needs data that came back. Point the server at a real account and each of those checks becomes a moving target, because the pull request you asserted on gets another commit, the token gets rate limited halfway through the suite, and the one path you most want to exercise is the write you must never send.
- The same answer every time.
gh-2026-03-g12is a pinned snapshot, content addressed atc27f762cc1d2. Every byte it serves today is a byte it served yesterday, so a tool result is something you can write anassertagainst instead of a screenshot you compare by eye. - References that resolve. The rows are one simulated company,
olympus-labs. A pull request's author is a fetchable user, its head branch is a real ref at the same commit, its reviewer belongs to a real team. That is what lets a test catch the bug a tool server actually ships: two tools that disagree about one object. - No credential, and nothing to clean up. Any token is accepted and every host is read-only, so there is no account to provision, no fixture to write and no state to reset between runs. The worst an over-eager tool can do is produce the response in section 05.
02 / The server
GitHub's own server, unmodified.
The server under test is github/github-mcp-server 1.12.2, the release published on 2026-09-16 (85598ba6e125, MIT). It is the server most agent hosts install when somebody asks for GitHub tools, and it repoints with one setting because it already supports GitHub Enterprise Server: give GITHUB_HOST a hostname that is not github.com and it speaks the Enterprise layout, which puts every REST call under /api/v3 and GraphQL at /api/graphql. The pinned host serves both layouts, so nothing has to be patched or forked.
V=1.12.2; B=https://github.com/github/github-mcp-server/releases/download/v$V
curl -sLO $B/github-mcp-server_Darwin_x86_64.tar.gz
curl -sL $B/github-mcp-server_${V}_checksums.txt -o checksums.txt
shasum -a 256 -c checksums.txt --ignore-missing
tar xzf github-mcp-server_Darwin_x86_64.tar.gz # unpacks ./github-mcp-server
npm install @modelcontextprotocol/sdk # the client half of this pagegithub-mcp-server_Darwin_x86_64.tar.gz: OKBefore writing any client, check that the pin answers the path the server is about to use. This is the Enterprise-layout path, prefix and all, sent with no key and no account:
curl -s https://gh-2026-03-g12.snap.sandboxapis.dev/api/v3/repos/olympus-labs/parthenon/pulls/53 \
| python3 -c "import json,sys; p = json.load(sys.stdin); print(p['number'], p['title'], '| merged:', p['merged'], '|', p['head']['ref'])"53 Fix the backfill job OOMing | merged: True | hotfix/backfill-job-oomingAnonymous callers get 60 req/hour, shared across every sandbox host rather than one bucket per host, which is enough to work through this page. A free key raises that to 600 req/hour and rides GitHub's own auth slot, so a suite that runs on every commit does not run out halfway. Keys and rate limits.
If your host reads a config file rather than an environment, the same setting goes in the same place your server entry already lives:
{
"mcpServers": {
"github": {
"command": "./github-mcp-server",
"args": ["stdio"],
"env": {
"GITHUB_HOST": "https://gh-2026-03-g12.snap.sandboxapis.dev",
"GITHUB_PERSONAL_ACCESS_TOKEN": "any-token"
}
}
}
}03 / The client
Does the server's own tool set agree with itself about a pull request that shipped?
Save this as mcp-server-test.mjs beside the binary and run it with node. It is the MCP TypeScript SDK's stdio client, which is the same transport your agent host uses, so what it sees is what the host will see. It lists the tools, calls two of them, decides something, and asserts the decision:
// Agent task: did the fix for the incident ship, and does the server's own tool
// set agree with itself about it?
//
// The server under test is github/github-mcp-server, unmodified. GITHUB_HOST
// points it at a pinned SandboxAPIs snapshot instead of api.github.com, so every
// tool result below is the same result tomorrow, in CI tonight, and in a year.
import assert from "node:assert/strict";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
const PIN = "https://gh-2026-03-g12.snap.sandboxapis.dev"; // the only line that changes
const REPO = { owner: "olympus-labs", repo: "parthenon" };
const client = new Client({ name: "mcp-under-test", version: "0.0.0" });
await client.connect(
new StdioClientTransport({
command: "./github-mcp-server",
args: ["stdio"],
env: {
PATH: process.env.PATH,
GITHUB_HOST: PIN, // the whole integration is this line
GITHUB_PERSONAL_ACCESS_TOKEN: "any-token", // any token is accepted
},
}),
);
/** Call one tool and parse its text result, failing loudly on a tool error. */
const call = async (name, args) => {
const res = await client.callTool({ name, arguments: args });
const text = res.content.map((c) => c.text).join("");
assert.ok(!res.isError, `${name}: ${text}`);
return JSON.parse(text);
};
// 1. DISCOVER. The handshake an agent host runs before it can call anything.
const { tools } = await client.listTools();
assert.ok(tools.some((t) => t.name === "pull_request_read"), "no pull_request_read tool");
// 2. FETCH. Two tools, the calls an agent reading a git host actually makes.
const pull = await call("pull_request_read", { ...REPO, method: "get", pullNumber: 53 });
const reviews = await call("pull_request_read", { ...REPO, method: "get_reviews", pullNumber: 53 });
const branches = await call("list_branches", { ...REPO, perPage: 100 });
// 3. REASON. A pure function of the tool results, so the verdict is the same
// every run. Swap in a model call here and the assertions below become an eval.
const signoffs = reviews.filter((r) => r.state === "APPROVED" && r.user.login !== pull.user.login);
const answer = { shipped: pull.merged, approved_by: signoffs[0]?.user.login ?? null };
console.log(`${tools.length} tools | PR #${pull.number} ${pull.title} -> ${JSON.stringify(answer)}`);
// 4. ASSERT. Facts of the olympus-labs data set, held still by this pin.
assert.equal(pull.user.login, "tiresias");
assert.equal(pull.merged_by, "tiresias");
assert.equal(pull.head.ref, "hotfix/backfill-job-ooming");
assert.equal(pull.head.sha, "94dd1fdf3b96e96c242a869801e448edeca9fd85");
assert.deepEqual(answer, { shipped: true, approved_by: "athena" });
// The branch the pull request names is a real ref at that same commit, read
// through a different tool. Every reference in this data set resolves like this.
assert.equal(branches.find((b) => b.name === pull.head.ref).sha, pull.head.sha);
console.log("ok");
await client.close();
45 tools | PR #53 Fix the backfill job OOMing -> {"shipped":true,"approved_by":"athena"}
okThe 45 tools are the server's default toolset at this version, printed rather than asserted so that a server upgrade which adds or removes one shows up as a visible diff instead of a red test. The reasoning step is a pure function of the two tool results on purpose: replace it with a model call and the assertions underneath it become an eval, with the tool results held still so a failure tells you whether the tools returned the wrong rows or the model read the right ones wrongly.
04 / In CI
Five lines that drop into a suite.
The same claim, as a test the runner you already have will collect. No fixtures to check in, no recorded traffic to refresh, no credential in the job's environment:
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import { expect, test } from "vitest";
const PIN = "https://gh-2026-03-g12.snap.sandboxapis.dev";
const ENV = { PATH: process.env["PATH"] ?? "", GITHUB_HOST: PIN, GITHUB_PERSONAL_ACCESS_TOKEN: "any-token" };
const PR = { owner: "olympus-labs", repo: "parthenon", method: "get", pullNumber: 53 };
test("the pinned host still says the fix shipped", async () => {
const client = new Client({ name: "ci", version: "0.0.0" });
await client.connect(new StdioClientTransport({ command: "./github-mcp-server", args: ["stdio"], env: ENV }));
const res = await client.callTool({ name: "pull_request_read", arguments: PR });
expect(JSON.parse(res.content[0].text).head.sha).toBe("94dd1fdf3b96e96c242a869801e448edeca9fd85");
});
Because the host is pinned rather than live, this test has no expiry date. The live host at https://gh.sandboxapis.dev re-rolls its data daily, which is what you want while you are building the server and the one thing you must not point a test at. Versioning and pinning covers the registry and how to choose a pin.
05 / When a tool writes
The refusal reaches the agent as a tool error.
A server under test will eventually call one of its own write tools, either because you asked it to or because a model did. Here is what that costs, captured from a real session rather than described. The call:
tools/call issue_write {"owner":"olympus-labs","repo":"parthenon","method":"create","title":"opened by an agent under test"}{
"content": [
{
"type": "text",
"text": "failed to create issue: POST https://gh-2026-03-g12.snap.sandboxapis.dev/api/v3/repos/olympus-labs/parthenon/issues: 403 This universe is read-only. See what's covered at https://sandboxapis.dev/roadmap. (You tried to POST /repos/olympus-labs/parthenon/issues.) Tell us what you needed to write: https://sandboxapis.dev/feedback []"
}
],
"isError": true
}Three things are worth noticing. The transport is fine, so your client does not crash: this is a normal MCP result with isError: true, which is exactly what an agent host renders back to the model. The text is the server's own wrapper around GitHub's 403 envelope, so the server's error handling is being exercised for real rather than mocked. And the message names the request that was refused, which is how you find out which tool tried to write without reading a stack trace. Nothing is mutated, and the next run reads exactly the same rows.
06 / What breaks
Asking the upstream to fail, and why not through this server.
The other half of a tool server's error handling is the upstream failing: a 500, a 429 with the rate limit headers set, a response that never arrives. Every sandbox host can be asked for those by name, with a request header, X-SandboxAPIs-Simulate, and the reply is the provider's own error envelope rather than an invented one. Keys and rate limits has the grammar.
You cannot ask for one through this server today, and the reason is worth stating plainly: github/github-mcp-server 1.12.2 has no setting for an extra request header. Its flags and its environment variables were read for this page, and the only ones that reach the HTTP layer are the host and the credential. The header also needs a key: an anonymous request carrying it is answered exactly as if it had not, which was confirmed against this pin while writing this page, and it is deliberate, so that a link somebody shares cannot make a working host look broken.
The smallest honest way through is a forwarder: a few lines of Node that listen on localhost, add your key and the fault header, and pass everything else to the pin. Point GITHUB_HOST at http://127.0.0.1:<port> instead of at the pin. The server accepts a plain HTTP host and a port, and a tool call still resolves through it, which was checked while writing this page. What was not checked here is the keyed fault itself, so this page does not show you an output for it. If you want a worked example that does not need a forwarder, the three agent tutorials call the hosts directly.
Next
Where to go from here.
The three agent tutorials read the same incident from the other side of the tools, in Pinned GitHub, Pinned Jira and Pinned Slack. The story is one incident: pull request 53 on the GitHub host is FATE-51 on the Jira host and a thread on the Slack host.
All tutorials · MCP setup · SandboxAPIs for agents · GitHub quickstart · Versioning and pinning · Coverage manifest