MCPMock
Mock MCP (Model Context Protocol) server for testing tool integrations. Implements the Streamable HTTP transport with JSON-RPC dispatch, session management, and full tools/resources/prompts support.
Quick Start
import { MCPMock } from "@copilotkit/aimock";
const mcp = new MCPMock();
mcp.addTool({ name: "search", description: "Search the web" });
mcp.onToolCall("search", (args) => {
return `Results for: ${(args as { query: string }).query}`;
});
const url = await mcp.start();
// Point your MCP client at `url`
Mounted Mode
Mount MCPMock onto an LLMock server to share a single port with LLM mocking and other services:
import { LLMock, MCPMock } from "@copilotkit/aimock";
const llm = new LLMock({ port: 5555 });
const mcp = new MCPMock();
mcp.addTool({ name: "calc", description: "Calculator" });
mcp.onToolCall("calc", (args) => "42");
llm.mount("/mcp", mcp);
await llm.start();
// MCP available at http://127.0.0.1:5555/mcp
Subpath Import
MCPMock is also available via a dedicated subpath import for tree-shaking:
import { MCPMock } from "@copilotkit/aimock/mcp";
Tools
Register tools and their handlers:
// Register a tool definition
mcp.addTool({
name: "search",
description: "Search the web",
inputSchema: { type: "object", properties: { query: { type: "string" } } },
});
// Register a handler (returns string or MCPContent[])
mcp.onToolCall("search", (args) => {
const { query } = args as { query: string };
return `Found 3 results for "${query}"`;
});
// Or return rich content
mcp.onToolCall("rich-tool", () => [
{ type: "text", text: "Hello" },
{ type: "image", data: "base64...", mimeType: "image/png" },
]);
Scenario fakes (fixtures)
A fixture file can hold an mcpFakes key next to its fixtures
array. It scripts the MCP tool answers for one test scenario: an ordered list of answers
per tool, exact or any-argument matching, and an optional closed world that fails on any
tool the scenario does not declare. The LLM turns and the tool answers of a scenario live
in one file. Fakes work at the wire, so any MCP client that speaks Streamable HTTP can use
them, in any language or agent framework.
{
"fixtures": [
{
"match": { "userMessage": "open a refund ticket", "hasToolResult": false },
"response": {
"toolCalls": [{ "name": "create_ticket", "arguments": { "title": "Refund" } }]
}
}
],
"mcpFakes": {
"scope": { "testId": "tickets › retry on timeout" },
"tools": [
{
"name": "create_ticket",
"calls": [
{ "id": "first-try", "args": { "title": "Refund" }, "error": "upstream timeout" },
{ "id": "retry", "args": { "title": "Refund" }, "result": "TICKET-42" }
]
}
]
}
}
In the test tickets › retry on timeout, the first
create_ticket call with {"title":"Refund"} gets a tool error
with the text upstream timeout, which the model sees and can retry. The
second call gets TICKET-42. A third call fails with
MCP_FAKE_EXHAUSTED.
Minimum version: mcpFakes needs the aimock release that
includes MCP fakes. An older aimock does not serve your fakes. It ignores the
mcpFakes key without a warning in a file that also has a
fixtures array. A file with only mcpFakes loads no fixtures
and logs Missing or invalid "fixtures" array. If the run then has no
fixtures at all, llmock starts with a warning, or exits under
--validate-on-load or --strict.
Format
mcpFakes is one block or an array of blocks. Use an array when an agent talks
to several MCP servers (one block per mount) or when one file holds several
scopes. A block has these keys:
| Key | Meaning |
|---|---|
scope |
Required. { "testId": "..." }, { "context": "..." }, both,
or the string "shared".
|
mount |
Optional. The MCP mount path, starting with /. Default
/mcp.
|
undeclaredTools |
Optional. "allow" (default) or "deny". See
Closed world.
|
tools |
Required array. Each tool has a name (exact, case-sensitive), an
optional description and inputSchema (used for
tools/list), and a non-empty calls array of answers. Only
a deny block with a test id or context scope may have an empty
tools array.
|
Each item of calls has these keys:
| Key | Meaning |
|---|---|
args |
A JSON object. The call matches only when its arguments are equal to it. |
anyArgs |
true. The call matches whatever its arguments are. |
result |
The success answer: a string, a content array or an object (see below). |
error |
A string. The answer is a tool execution error,
{ content: [{ type: "text", text }], isError: true }. The model sees it
and can retry.
|
id |
Optional name for the entry, used in errors, the journal and inspection. |
Each entry has exactly one of args or anyArgs, and exactly one
of result or error. A result becomes a
tools/call result like this:
-
A string becomes
{ content: [{ type: "text", text }], isError: false }, the same as a string fromonToolCall. - An array is used as
content. -
An object with any of the keys
content,isError,structuredContentor_metais the full result. It must have acontentarray and no other keys. -
Any other object is sent as
structuredContent, with its JSON text in a text content block, so clients that do not readstructuredContentstill get the data.
A single answer with an object result. In the test
weather › seattle, the model first asks for get_weather. The
call with {"city":"Seattle"} gets structuredContent
{"tempF":60,"conditions":"rain"} and the same JSON as a text block. After the
tool result, the model answers It is 60F and raining in Seattle.
{
"fixtures": [
{
"match": { "userMessage": "weather in Seattle", "hasToolResult": false },
"response": { "toolCalls": [{ "name": "get_weather", "arguments": { "city": "Seattle" } }] }
},
{
"match": { "userMessage": "weather in Seattle", "hasToolResult": true },
"response": { "content": "It is 60F and raining in Seattle." }
}
],
"mcpFakes": {
"scope": { "testId": "weather › seattle" },
"tools": [
{
"name": "get_weather",
"calls": [
{ "args": { "city": "Seattle" }, "result": { "tempF": 60, "conditions": "rain" } }
]
}
]
}
}
An entry without an id gets one from its file, tool and position: for
example, if the first entry above had no id, its id would be
tickets/retry.json:create_ticket#0. A file in a fixtures directory is named
by its path relative to that directory. An entry with an id uses it in place
of the tool and position, as in tickets/retry.json:first-try. Entry ids are
unique on each mount.
The part before the colon is the block id. In the array form, it ends with the
block’s position, as in multi/travel.json[0]:search_flights#0. When the
same file is loaded again, its blocks are added again, with @2,
@3 and so on after the file name
(weather/seattle.json@2:get_weather#0). Blocks added from code get the block
id code#<n>, and blocks added through the control API get
control-api#<n>. Each mount numbers these calls from 1.
An array of blocks, one per MCP server. aimock mounts an MCPMock at
/mcp/flights and one at /mcp/hotels. In the test
travel › book trip, search_flights on
/mcp/flights answers UA 100 departs 09:00 for any arguments, and
book_hotel on /mcp/hotels answers HOTEL-7 for
{"city":"Paris"}. Each mount serves only its own block: a
book_hotel call on /mcp/flights gets
Unknown tool: book_hotel.
{
"mcpFakes": [
{
"scope": { "testId": "travel › book trip" },
"mount": "/mcp/flights",
"tools": [
{
"name": "search_flights",
"calls": [{ "anyArgs": true, "result": "UA 100 departs 09:00" }]
}
]
},
{
"scope": { "testId": "travel › book trip" },
"mount": "/mcp/hotels",
"tools": [
{ "name": "book_hotel", "calls": [{ "args": { "city": "Paris" }, "result": "HOTEL-7" }] }
]
}
]
}
Why scope is required
All fixture files load into one server. Without a scope, a fake written for one test would
answer every other test. So a block with no scope fails the load. Use
"shared" only for answers that every test may get.
How a request picks blocks
Each MCP request resolves a test id and a context, each on its own:
-
Test id: the
X-Test-Idheader, else the?testId=query parameter on the MCP URL, else the value the session got atinitialize. -
Context: the
X-AIMock-Contextheader, else the?context=query parameter, else the session value.
On initialize, the test id, context and undeclared override that the request
sends are stored on the session, so later requests in that session need not send them
again. A value sent on a later request wins over the session value for that field only.
A testId scope matches only a request whose test id is exactly equal to it
(case-sensitive, no normalization). A context scope works the same way. A
scope with both needs both. "shared" matches every request. A request with no
test id and no context sees only shared blocks.
When more than one block declares a tool, the most specific level wins: test id and
context, then test id only, then context only, then shared. Only the entries of that level
are used for that tool, so a scenario that declares get_weather replaces a
shared get_weather, and two scenarios never see each other’s entries.
A field sent twice on one path (two X-Test-Id headers, or
?testId= twice) is rejected with HTTP 400 and code
MCP_DUPLICATE_IDENTITY. When the header and the query both carry a value, the
header is used. The body of such a 400 is
{ "error": "<message>", "code": "MCP_DUPLICATE_IDENTITY" }, for example
Duplicate ?testId= query parameter: 2 values sent, expected one. A bad
undeclared override (see Closed world) gets the same shape
with MCP_INVALID_UNDECLARED.
Encode test ids on the MCP client
Test ids often contain characters such as ›, which fetch and
Node’s http.request refuse to put in a header. The MCP SDK transports
use fetch. So send encodeURIComponent(id) in
X-Test-Id and X-AIMock-Context, or in the
?testId= and ?context= query parameters, on the MCP client.
aimock decodes both headers on MCP requests. A value that does not decode is used as sent,
and the request is not rejected. An id that contains a literal %XX sequence
must be sent as %25XX.
Encode on the MCP client only. LLM routes compare these headers as sent. If a shared request interceptor encodes them for LLM calls too, LLM context fixtures that contain a space stop matching.
Argument matching
| Value | Rule |
|---|---|
| Objects | Same keys, each value equal. Key order does not matter. An extra or missing key is a mismatch. |
| Arrays | Same length, equal element by element. Order matters. |
Strings, booleans, null |
Strict equality. No type conversion: "1" is not 1, and
null is not a missing key.
|
| Numbers | Equal as parsed JSON numbers (1 equals 1.0). |
No arguments |
Treated as {}. |
| Arguments that are not an object | Match only an anyArgs entry. |
These rules follow the argument rules that Mastra documents for its tool mocks: object key order is ignored, array order matters, and values are not converted to other types. Fixtures written for one carry over to the other.
For each call, aimock takes the first entry that is not used yet and whose arguments
match, and marks it used. Entries for the same tool and arguments are used in the order
you declare them. Calls with different arguments each find their own entry, whatever the
call order, because models make parallel tool calls in an order a test cannot control. Put
args entries before anyArgs entries for the same tool; an
args entry after an anyArgs entry gets a validation warning,
because it cannot match until the anyArgs entry is used.
Any arguments, scoped by context. Every request that sends
X-AIMock-Context: docs-search uses this block, whatever its test id. The
first search_docs call gets Doc A: refunds take 5 days and the
second gets Doc B: contact support, whatever the query text. A third call
fails with MCP_FAKE_EXHAUSTED. A request without that context does not see
the block, so with no registered search_docs tool it gets
Unknown tool: search_docs.
{
"mcpFakes": {
"scope": { "context": "docs-search" },
"tools": [
{
"name": "search_docs",
"calls": [
{ "anyArgs": true, "result": [{ "type": "text", "text": "Doc A: refunds take 5 days" }] },
{ "anyArgs": true, "result": [{ "type": "text", "text": "Doc B: contact support" }] }
]
}
]
}
}
Precedence
For a tools/call of a tool:
- If the request’s blocks declare fakes for the tool, a fake answers. A mismatch or an exhausted tool returns an error; the call never falls through to a code handler.
-
Otherwise, under
deny, the call fails withMCP_FAKE_NOT_DECLARED, even if anonToolCallhandler or a configresultexists for the tool. - Otherwise the tool is answered as it is without fakes.
tools/list returns the registered tools plus the tools named in the
request’s blocks. A fake-only tool is listed with its description and
inputSchema, or with { "type": "object" } as its schema. When a
tool is both registered and faked, the registered definition is listed. Fakes never remove
a registered tool from the list.
Errors
A fake failure means the test scenario is wrong, not that the tool failed. So aimock
answers it with a JSON-RPC protocol error (over HTTP 200), which MCP client SDKs raise as
an exception. If it were a tool result with isError: true, the model could
read it, recover, and let the test pass by accident. Use an entry’s
error key for a tool failure that the model is meant to see.
error.data.aimock.code |
JSON-RPC code | When |
|---|---|---|
MCP_FAKE_MISMATCH |
-32602 |
The tool is faked, but no entry matches the arguments. |
MCP_FAKE_EXHAUSTED |
-31010 |
Entries match the arguments, but all of them are used. |
MCP_FAKE_NOT_DECLARED |
-32602 |
The scenario denies undeclared tools and no block declares this tool. The message
starts with Unknown tool:, as for an unknown tool without fakes.
|
MCP_FAKE_EVICTED |
-31011 |
The state of this test id was dropped by the per-mount test-id cap. See Parallel tests. |
MCP_FAKE_INTERNAL_ERROR |
-32603 |
An aimock defect: a matching entry’s answer could not be built. The message
starts with aimock MCP fake internal error:. Please report it.
|
Key your test code on error.data.aimock.code; the JSON-RPC code is shared
with other errors. Every data.aimock has code,
tool, testId, context (each null when
not supplied) and mount. A mismatch adds received,
declared (each entry’s id, args or
anyArgs, and consumed) and firstDifference, the
path to the first value that differs from the closest declared entry. An exhausted error
adds received, matchingDeclared,
matchingConsumed and matchingIds. A not-declared error adds
declaredTools. An evicted error adds cap. An internal error adds
error, the text of the error that aimock caught. The message names the tool,
the arguments received and what was declared.
The bodies below are what aimock sends. id is the JSON-RPC id of the
tools/call request, which the MCP client picks. A
get_weather call with {"city":"seattle"} in the test
weather › seattle (the weather/seattle.json example above):
{
"jsonrpc": "2.0",
"id": 1,
"error": {
"code": -32602,
"message": "aimock MCP fake mismatch: tool \"get_weather\" was called with arguments that match no declared fake (testId \"weather › seattle\"). Received {\"city\":\"seattle\"}. Declared: weather/seattle.json:get_weather#0 {\"city\":\"Seattle\"}.",
"data": {
"aimock": {
"code": "MCP_FAKE_MISMATCH",
"tool": "get_weather",
"testId": "weather › seattle",
"context": null,
"mount": "/mcp",
"received": {
"city": "seattle"
},
"declared": [
{
"id": "weather/seattle.json:get_weather#0",
"args": {
"city": "Seattle"
},
"consumed": false
}
],
"firstDifference": "$.city: expected \"Seattle\", received \"seattle\""
}
}
}
}
The third create_ticket call in the test
tickets › retry on timeout (the tickets/retry.json example
above):
{
"jsonrpc": "2.0",
"id": 3,
"error": {
"code": -31010,
"message": "aimock MCP fake exhausted: tool \"create_ticket\" with {\"title\":\"Refund\"} was called again, but all 2 matching fakes are already used (testId \"tickets › retry on timeout\").",
"data": {
"aimock": {
"code": "MCP_FAKE_EXHAUSTED",
"tool": "create_ticket",
"testId": "tickets › retry on timeout",
"context": null,
"mount": "/mcp",
"received": {
"title": "Refund"
},
"matchingDeclared": 2,
"matchingConsumed": 2,
"matchingIds": ["tickets/retry.json:first-try", "tickets/retry.json:retry"]
}
}
}
}
A delete_account call in the test account › read only (the
account/read-only.json example in Closed world):
{
"jsonrpc": "2.0",
"id": 2,
"error": {
"code": -32602,
"message": "Unknown tool: delete_account (aimock MCP fake not declared: this scenario denies undeclared tools; testId \"account › read only\").",
"data": {
"aimock": {
"code": "MCP_FAKE_NOT_DECLARED",
"tool": "delete_account",
"testId": "account › read only",
"context": null,
"mount": "/mcp",
"declaredTools": ["get_account"]
}
}
}
}
MCP 2025-03-26, the version aimock advertises, lists invalid arguments as a protocol error. From MCP 2025-11-25, input validation errors are tool execution errors. aimock still answers a mismatch with a protocol error, on purpose, for the reason above.
Agents sometimes catch tool exceptions. So every fake failure is also logged at
error level (a line starting with MCP-FAKE:), stored in the
journal with response.mcpFake.outcome set to mismatch,
exhausted, not_declared or evicted, and counted in
the aimock_mcp_fake_failures_total{code} metric. A fake answer is stored with
outcome answered and the entry id. MCP_FAKE_INTERNAL_ERROR is
logged and counted the same way, but its journal entry has no
response.mcpFake; the message is in response.error. To check
that a test had no fake failures, read
GET /__aimock/journal?testId=<id>&service=mcp and fail on any entry
that has a response.mcpFake.outcome other than answered, or that
has response.error. Entries for other methods, and calls that no fake
answered, have no response.mcpFake. A standalone MCPMock (not
mounted on an LLMock server) has no journal and no metrics, and logs only
after setLogger() (see API for fakes), so the
JSON-RPC error may be the only trace.
Closed world (undeclaredTools: "deny")
With "undeclaredTools": "deny" in a scoped block, a call to a tool that no
block of the request declares fails with MCP_FAKE_NOT_DECLARED. Tools that
any of the request’s blocks declare, shared ones included, are still answered by
their fakes. If any scoped block of the request says deny, the request is
under deny.
In the test account › read only, get_account with
{"id":"u1"} answers {"plan":"pro"}. A call to any other tool,
such as delete_account, fails with MCP_FAKE_NOT_DECLARED.
{
"mcpFakes": {
"scope": { "testId": "account › read only" },
"undeclaredTools": "deny",
"tools": [
{
"name": "get_account",
"calls": [{ "args": { "id": "u1" }, "result": "{\"plan\":\"pro\"}" }]
}
]
}
}
-
A
"shared"block withdenyfails the load, so one scenario’s closed world never reaches another. - A block scoped by context only closes the world for every test that sends that context.
-
The
X-AIMock-MCP-Undeclared: allow|denyheader or the?undeclared=allow|denyquery parameter overrides the policy for a request (or for a session, when sent oninitialize). The value is trimmed and compared without case. Any other value is rejected with HTTP 400 and codeMCP_INVALID_UNDECLARED, so a typo never turnsdenyintoallow. - Strict mode (
--strict) does not turn ondeny.
Parallel tests and consumption state
Which entries are used is tracked per mount and per test id. Requests that send only a context, or nothing, share one default state. Tests that run in parallel must each send their own test id, or they use up each other’s entries.
Each mount keeps the state of at most as many test ids as the fixture match-count cap
(--fixture-counts-max, default 500; 0 means no cap). When the
cap drops the oldest test id, every later fake call with that test id fails with
MCP_FAKE_EVICTED until a reset, so used entries are never served again.
Reset
| Call | Effect on fakes |
|---|---|
POST /__aimock/reset, DELETE /__aimock/fixtures,
LLMock.reset(), LLMock.clearFixtures()
|
Unload all fakes, with the LLM fixtures. |
LLMock.resetMatchCounts(testId?) |
Keep the fakes; mark the entries of that test id (or of every test id) as unused. The Vitest and Jest plugins call it before each test. |
MCPMock.reset() |
Unload this mount’s fakes, with its tools, resources, prompts and sessions. |
POST /__aimock/reset/journal |
No effect on fakes. |
--watch reload |
Fakes are not reloaded. A reload whose mcpFakes changed is rejected
with an error on stderr, and the previous fixtures stay loaded. Restart the server
to load changed fakes.
|
Session bindings survive every reset except MCPMock.reset().
Loading fakes
-
llmock --fixtures <path>andLLMock.loadFixtureFile()/loadFixtureDir()loadmcpFakeswith the LLM fixtures. A file may hold onlymcpFakes. So dollm.fixturesin anaimock --configfile, which is resolved to an absolute path (so the entry ids hold that path), and thefixturesoption of the Vitest and Jest plugins. -
If no mount serves a block’s
mountpath, aimock mounts anMCPMockthere, at start or later. Sonpx -p @copilotkit/aimock llmock --fixtures ./fixturesserves fakes with no other setup. mcp.loadFakes(blocks)adds blocks to oneMCPMock.-
POST /__aimock/fixturesaccepts anmcpFakeskey. See the Control API. -
The free functions
loadFixtureFile()andloadFixturesFromDir()return LLM fixtures only, so they throw on a file withmcpFakes. UseloadFixtureFileWithServices()orloadFixturesFromDirWithServices(), or theLLMockmethods.
Both ways in code. loadFixtureFile() takes a file path, relative to the
working directory. In this example, /mcp (mounted by aimock for the
file’s block) answers get_weather in the test
weather › seattle, and /billing answers refund with
{"amount":10} in the test billing › refund with
refunded 10.
import { LLMock, MCPMock } from "@copilotkit/aimock";
export async function startMock() {
const llm = new LLMock({ port: 0 });
// A fixture file: its LLM fixtures and its mcpFakes load together.
llm.loadFixtureFile("weather/seattle.json");
// Fakes from code, on a mount you create.
const billing = new MCPMock();
billing.loadFakes({
scope: { testId: "billing › refund" },
tools: [{ name: "refund", calls: [{ args: { amount: 10 }, result: "refunded 10" }] }],
});
llm.mount("/billing", billing);
await llm.start();
return llm;
}
Fakes fail loud. A block that aimock cannot honor fails the whole load, in every mode,
strict or not: the load call throws a FixtureLoadError,
start() rejects, the CLI prints the error and exits non-zero, and the control
API answers 400 and adds nothing. This covers a missing or malformed scope, a
"shared" block with deny, a malformed entry, the same tool twice
in one block, an entry id that is already used on the mount, an unknown key (for example a
misspelled undeclaredTool), and a mount path held by a mount
that is not an MCPMock. The error’s rule field (for
example mcp-fakes/bad-block:a or mcp-fakes/mount-conflict) names
the check, and its message names the file and block. aimock validate reports
the same errors per file, except mount conflicts, which depend on the running server.
When one input has more than one problem, the error is a McpFakesAddError, a
subclass of FixtureLoadError. Its errors array holds one
FixtureLoadError per problem, and its warnings array holds the
shadowed-entry warnings of the same input. Each FixtureLoadError has
rule, file, blockId, entryId and
message, and toJSON() returns those fields with
name.
A load that succeeds can still warn. An args entry after an
anyArgs entry for the same tool is a shadowed-entry warning: the CLI and
LLMock log it, and mcp.loadFakes() returns it as
{ warnings: [{ file, blockId, entryId, message }] }. If you call
llm.mount(path, mcp) after start at a path where aimock already mounted an
MCPMock for fakes, the auto-mounted mock keeps those requests, and aimock
logs a warning that the new mount is shadowed.
API for fakes
These MCPMock methods work on the fakes of one mount:
| Method | Effect |
|---|---|
loadFakes(blocks) |
Adds one mcpFakes value (a block or an array of blocks), in the fixture
file form. Adds all or nothing. Returns { warnings }; throws a
McpFakesAddError on a bad block.
|
addMcpFakes(sources, origin) |
The lower-level add that the loaders use. sources is an array of
{ source, blockIndex, raw }, as the
…WithServices loaders return them, and origin is
{ kind: "file" }, { kind: "code" } or
{ kind: "control-api" }. With "file", the entry ids use
each source; with the other two, they use
code#<n> or control-api#<n>.
|
fakesSnapshot(testId?, context?) |
The blocks on this mount that apply to that test id and context, with each
entry’s id and consumed state for the test id: the blocks of one
mount in GET /__aimock/mcp/fakes. Omit an
argument, or pass null, for “not sent”. The result is
frozen.
|
resetScenarioState(testId?) |
Marks the entries of that test id (or of every test id) as unused. The fakes stay
loaded. LLMock.resetMatchCounts() calls it on every mount.
|
clearMcpFakes() |
Unloads every fake on this mount, with its consumption state. Tools, resources, prompts and sessions stay. |
setLogger(logger) |
Sets the logger for MCP-FAKE: lines and for warnings about a test id or
context that does not decode. LLMock calls it when it mounts the mock,
so you need it only for a mock that you use without LLMock.
|
loadFixtureFileWithServices(path) and
loadFixturesFromDirWithServices(dir) read files the way
LLMock does and return
{ fixtures, mcpFakes, mcpFakeWarnings, unreadable }:
fixtures: the LLM fixtures.-
mcpFakes: one{ source, blockIndex, raw }per block, ready foraddMcpFakes(sources, { kind: "file" }).blockIndexisnullfor the single-object form. mcpFakeWarnings: the shadowed-entry warnings.-
unreadable: the source names of files that could not be read or were not valid JSON. Such a file is skipped with a warning, as before, and adds nothing.
A bad block makes these loaders throw, after they have checked every file. The main entry
@copilotkit/aimock exports the two loaders, FixtureLoadError,
McpFakesAddError, MCP_FAKE_ERROR_CODES and the fake types
(McpFakeBlock, McpFakeTool, McpFakeCall,
McpFakeResult, McpFakeScope, McpFakeBlockSnapshot,
McpFakeErrorCode and others). The @copilotkit/aimock/mcp subpath
exports MCPMock, McpFakesAddError,
MCP_FAKE_ERROR_CODES and the same types, but not the loaders.
MCP_FAKE_ERROR_CODES maps each code to its JSON-RPC code:
MCP_FAKE_NOT_DECLARED and MCP_FAKE_MISMATCH to
-32602, MCP_FAKE_EXHAUSTED to -31010 and
MCP_FAKE_EVICTED to -31011.
MCP_FAKE_INTERNAL_ERROR is not in the map.
Inspecting fakes
GET
/__aimock/mcp/fakes?testId=<id>&context=<ctx>&mount=<path>
lists, per mount, the blocks that apply to that test id and context, with each
entry’s id and whether it is used. Use it at the end of a test to check that every
declared fake was used. See the Control API.
What aimock cannot fake: in-process tools
aimock sees only what crosses the network. A tool that runs inside your agent process and
never makes an MCP call cannot be faked here. Only an in-process mock, such as
Mastra’s tool mocks for Mastra agents, can fake those. MCP over stdio is not
supported either; MCPMock serves HTTP only.
Known limitations
-
After start, an
LLMock.loadFixtureFile()orloadFixtureDir()call whose blocks go to more than one mount can leave blocks on the earlier mounts when a later mount rejects its blocks for an entry id that is already loaded there. The call throws, and its LLM fixtures are not added, but the blocks already added to the earlier mounts stay until a reset. Loads that go to one mount, and loads before start, add all or nothing. -
Only
tools/callis faked per scenario;resources/readandprompts/getare not. - Partial argument matching (subset, regular expression, JSON Schema) is not supported.
- Recording real MCP tool results into fixtures is not supported yet.
Resources
Register static resources that clients can read:
mcp.addResource(
{ uri: "file:///readme.md", name: "README", mimeType: "text/markdown" },
{ text: "# My Project\nWelcome!" },
);
Prompts
Register prompt templates with optional handlers:
mcp.addPrompt(
{ name: "summarize", arguments: [{ name: "text", required: true }] },
(args) => ({
messages: [
{ role: "user", content: { type: "text", text: `Summarize: ${(args as { text: string }).text}` } },
],
}),
);
Config File
MCPMock can be configured via the aimock JSON config file:
{
"mcp": {
"path": "/mcp",
"tools": [
{ "name": "search", "description": "Search", "result": "Found it" }
],
"resources": [
{ "uri": "file:///data.json", "name": "Data", "text": "{\"key\": \"value\"}" }
]
}
}
A config result is one fixed answer per tool for the whole server process. To
script answers per test, with ordered answers and argument checks, use
scenario fakes in fixture files.
Session Management
MCPMock implements full session management per the MCP Streamable HTTP spec. Each
initialize request creates a new session, and the session ID is returned via
the Mcp-Session-Id header. All subsequent requests must include this header.
| Method | Description |
|---|---|
initialize |
Creates session, returns capabilities and session ID |
tools/list |
Lists the registered tools, plus the tools named in the request’s fake blocks |
tools/call |
Calls a tool by name with arguments |
resources/list |
Lists all registered resources |
resources/read |
Reads a resource by URI |
prompts/list |
Lists all registered prompts |
prompts/get |
Gets a prompt by name with arguments |
ping |
Returns empty object (health check) |
DELETE / |
Destroys a session |
GET / |
Answers 405 with Allow: POST, DELETE; the mock offers no server-sent
event stream
|
Inspection
mcp.health(); // { status: "ok", tools: 2, resources: 1, prompts: 0, sessions: 1 }
mcp.getSessions(); // Map of active sessions
mcp.getRequests(); // Journal entries (when mounted with shared journal)
mcp.reset(); // Clears all tools, resources, prompts, sessions, and MCP fakes