Skip to content
Open
57 changes: 46 additions & 11 deletions docs/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -569,17 +569,52 @@ or without a trailing empty `#` fragment):
- 2019-09: `https://json-schema.org/draft/2019-09/schema`
- 2020-12: `https://json-schema.org/draft/2020-12/schema`

Without a declaration, the existing 2020-12 default applies. Unknown or malformed
declarations are refused; Volli never downloads a meta-schema or resolves a
server's `$ref` during discovery. Each recognized draft is checked against its
own meta-schema: draft-07 and 2019-09 tuple `items` are valid, for instance, but
2020-12 uses `prefixItems` instead. The published schema, including `$schema`,
is kept unchanged in the catalog, frozen Session definition and Pi parameters;
it is not rebuilt through TypeBox. Discovery's meta-schema check is distinct
from Pi's TypeBox argument validation at call time.

A tool's **output schema** is checked against the same schema limits, but a
failing one does not make the tool unusable: the schema is left off and the
Without a declaration, the existing 2020-12 default applies. The entire
pre-VC-621 `Ajv2020.validateSchema` acceptance path is preserved, including an
empty `$schema` and bundled 2020-12 `/meta/*` URIs. Those declarations can exist
in older frozen Sessions; refusing them after an upgrade would prevent replay.
Unknown or malformed declarations still fail validation. Volli never downloads
a meta-schema, resolves a server's `$ref`, or registers server schemas during
discovery.

Newly admitted draft-07 and 2019-09 **input schemas** must pass both their own
meta-schema and the 2020-12 meta-schema applied to a validation-only view without
the root `$schema`. Valid old-draft tuple `items: [...]` arrays are therefore
**unusable**: model providers reject them, potentially failing every turn in a
Session. The catalog tells the server to publish 2020-12 `prefixItems` instead;
Volli does not translate tuples or silently change the tool's meaning.

Dual meta-validation alone is not a provider-compatibility guarantee. Legacy
`dependencies` and recursive-reference keywords can lose semantics under
2020-12. Combining `prefixItems` with old-draft `items` can also weaken constraints:
old drafts ignore `prefixItems` and apply `items` to every element, whereas
2020-12 applies `items` only after the prefix. Newly admitted old-draft inputs
therefore cannot use `prefixItems`; the server must declare 2020-12 to use it.
Pi's non-strict Anthropic adapter retains only root `type`,
`properties`, and `required`. For newly admitted old-draft inputs, Volli also
refuses references (`$ref`/`$dynamicRef`, including references into `definitions`
or `$defs`) and root assertions the adapter drops, such as `additionalProperties`,
`allOf`, and property dependencies. The reason names the keyword and asks the
server to inline references, use 2020-12 dependencies, or put supported
constraints inside `properties`. Literal examples/defaults are not schemas;
unreferenced definitions and inert `additionalItems` beside non-tuple `items`
do not add provider constraints. Plain old-draft object/property schemas remain
usable. An old-draft object with root `additionalProperties: false` is outside
this new admission subset, even without tuples.

The published schema, including `$schema`, is kept unchanged in the catalog,
frozen Session definition and Pi parameters; it is not rebuilt through TypeBox.
Native compaction also receives that unchanged schema. These additional guards
apply only to newly admitted input dialects, not schemas accepted by the original
2020-12 check. Pre-existing provider projection limitations are not repaired by
this change, and full provider/runtime dialect conformance is not promised.
Discovery's meta-schema check is distinct from Pi's TypeBox argument validation
at call time.

A tool's **output schema** is checked against the same schema limits and its own
bundled dialect's meta-schema, but not the provider input subset (output schemas
are never sent to the model). A failing one does not make the tool unusable: the
schema is left off and the
tool works without it, and the main-process log says why. An output schema only
describes the structured half of a result (see
[What a call returns](#what-a-call-returns)); the model never sees it.
Expand Down
183 changes: 163 additions & 20 deletions packages/agent-runtime/src/pi/mcp-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,23 @@ import {
MCP_RESULT_MAX_BYTES,
MCP_RESULT_MAX_IMAGES,
mcpProviderToolName,
sanitizeMcpToolDefinition,
type McpToolDefinition,
type RuntimeMcpPort,
} from "@volli/shared";

import { validateToolArguments } from "@earendil-works/pi-ai";
import {
normalizeContext,
validateToolArguments,
type Api,
type Model,
} from "@earendil-works/pi-ai";
import { stream as streamAnthropic } from "@earendil-works/pi-ai/api/anthropic-messages";
import { build } from "esbuild";
import sharp from "sharp";
import { MAX_READ_IMAGE_BASE64_BYTES } from "./read-image-processor";
import { ToolOutputStore } from "./tool-output";
import { compactProviderNative } from "./provider-compaction";
import { createSessionTools, MCP_UNTRUSTED_DATA_WARNING, SAVED_TOOL_OUTPUT_WARNING } from "./tools";

function definition(overrides: Partial<McpToolDefinition> = {}): McpToolDefinition {
Expand Down Expand Up @@ -71,7 +79,8 @@ describe("MCP Pi tool wrapper", () => {
});
const context = createContext({}, { codeGeneration: { strings: false, wasm: false } });
// Match the renderer's script-src 'self': importing shared vocabulary must
// not compile Ajv validators. Catalog validation runs on the host later.
// not compile Ajv validators on import. Discovery and durable host replay
// validate later; renderer presentation checks only the validated wire shape.
expect(() => runInContext(bundled.outputFiles[0]!.text, context)).not.toThrow();
expect(runInContext("typeof McpSchema.sanitizeMcpToolDefinition", context)).toBe("function");
});
Expand Down Expand Up @@ -125,26 +134,14 @@ describe("MCP Pi tool wrapper", () => {
});

it.each([
[
"http://json-schema.org/draft-07/schema#",
{ items: [{ type: "string" }], additionalItems: false },
],
[
"https://json-schema.org/draft/2019-09/schema",
{ items: [{ type: "string" }], additionalItems: false },
],
["http://json-schema.org/draft-07/schema#", { items: { type: "string" }, maxItems: 1 }],
["https://json-schema.org/draft/2019-09/schema", { items: { type: "string" }, maxItems: 1 }],
[
"https://json-schema.org/draft/2020-12/schema",
{ prefixItems: [{ type: "string" }], items: false },
],
[
"https://json-schema.org/draft-07/schema#",
{ items: [{ type: "string" }], additionalItems: false },
],
[
"http://json-schema.org/draft/2019-09/schema",
{ items: [{ type: "string" }], additionalItems: false },
],
["https://json-schema.org/draft-07/schema#", { items: { type: "string" }, maxItems: 1 }],
["http://json-schema.org/draft/2019-09/schema", { items: { type: "string" }, maxItems: 1 }],
[
"http://json-schema.org/draft/2020-12/schema",
{ prefixItems: [{ type: "string" }], items: false },
Expand All @@ -157,10 +154,13 @@ describe("MCP Pi tool wrapper", () => {
type: "object",
properties: { tuple: { type: "array", ...tupleSchema, minItems: 1 } },
required: ["tuple"],
additionalProperties: false,
} as const;
const candidate = definition({ inputSchema });
const sanitized = sanitizeMcpToolDefinition({ ...candidate, serverName: "Fixture" });
expect(sanitized.ok).toBe(true);
if (!sanitized.ok) throw new Error(sanitized.reason);
const call = vi.fn<RuntimeMcpPort["call"]>(async () => ({ content: [], isError: false }));
const registered = tool({ call }, { tool: definition({ inputSchema }) });
const registered = tool({ call }, { tool: sanitized.definition });
expect(registered.parameters).toBe(inputSchema);
const valid = {
type: "toolCall" as const,
Expand All @@ -183,6 +183,149 @@ describe("MCP Pi tool wrapper", () => {
},
);

it.each([
"http://json-schema.org/draft-07/schema#",
"https://json-schema.org/draft/2019-09/schema",
"https://json-schema.org/draft-07/schema#",
"http://json-schema.org/draft/2019-09/schema",
])(
"keeps %s tuples out of provider declarations, even though Pi can validate their arguments",
($schema) => {
const inputSchema = {
$schema,
type: "object",
properties: {
pair: { type: "array", items: [{ type: "string" }], additionalItems: false },
},
};
const candidate = definition({ inputSchema });
expect(sanitizeMcpToolDefinition({ ...candidate, serverName: "Fixture" })).toEqual({
ok: false,
reason:
"input schema uses tuple `items` arrays, which model providers reject; the server should use 2020-12 `prefixItems`",
});
},
);

it.each([
"http://json-schema.org/draft-07/schema#",
"https://json-schema.org/draft/2019-09/schema",
"https://json-schema.org/draft/2020-12/schema",
])(
"emits a 2020-12-valid %s body through the actual Anthropic adapter and native compaction",
async ($schema) => {
const inputSchema = {
$schema,
type: "object",
properties: {
query: { type: "string", minLength: 1 },
filter: {
type: "object",
properties: { limit: { type: "integer", minimum: 1 } },
additionalProperties: false,
},
},
required: ["query"],
};
const candidate = definition({ inputSchema });
const sanitized = sanitizeMcpToolDefinition({ ...candidate, serverName: "Fixture" });
expect(sanitized.ok).toBe(true);
if (!sanitized.ok) throw new Error(sanitized.reason);
const registered = tool(
{ call: async () => ({ content: [], isError: false }) },
{ tool: sanitized.definition },
);
const model: Model<Api> = {
id: "claude-opus-5",
name: "Fixture",
api: "anthropic-messages",
provider: "anthropic",
baseUrl: "https://api.anthropic.com",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200_000,
maxTokens: 1_024,
};
const messages = [{ role: "user" as const, content: "fixture", timestamp: 1 }];
const sent: Record<string, unknown>[] = [];
// No real credentials or network: exercise Pi's request conversion, then
// end its stream with a synthetic error response after capturing the body.
const fetch = vi.fn(async (_url: URL | RequestInfo, init?: RequestInit) => {
sent.push(JSON.parse(init!.body as string));
return new Response(
JSON.stringify({
type: "error",
error: { type: "invalid_request_error", message: "fixture" },
}),
{
status: 400,
headers: { "content-type": "application/json" },
},
);
});
await streamAnthropic(
model as Model<"anthropic-messages">,
normalizeContext({ messages, tools: [registered] }),
{
apiKey: "fixture-only",
fetch,
maxRetries: 0,
},
).result();
expect(fetch).toHaveBeenCalledOnce();
const emitted = (sent[0]!["tools"] as { input_schema: Record<string, unknown> }[])[0]!
.input_schema;
expect(emitted).toEqual({
type: "object",
properties: inputSchema.properties,
required: inputSchema.required,
});
// With no declaration this runs the original 2020-12 meta-schema check.
const checkBody = (bodySchema: unknown) =>
sanitizeMcpToolDefinition({ ...candidate, serverName: "Fixture", inputSchema: bodySchema });
expect(checkBody(emitted).ok).toBe(true);
expect(registered.parameters).toBe(inputSchema);

const compactFetch = vi.fn(async (url: URL | RequestInfo, init?: RequestInit) => {
sent.push(JSON.parse(init!.body as string));
return new Response(
JSON.stringify(
String(url).endsWith("/count_tokens")
? { input_tokens: 60_000 }
: {
stop_reason: "compaction",
content: [{ type: "compaction", content: "fixture summary" }],
},
),
{
headers: { "content-type": "application/json" },
},
);
});
const outcome = await compactProviderNative({
model,
messages,
tools: [registered],
enabled: true,
fetch: compactFetch,
models: {
getAuth: async () => ({ auth: { apiKey: "fixture-only" }, source: "test" }),
} as never,
});
expect(outcome.kind).toBe("compacted");
expect(compactFetch).toHaveBeenCalledTimes(2);
for (const request of sent.slice(1)) {
const raw = (request["tools"] as { input_schema: Record<string, unknown> }[])[0]!
.input_schema;
expect(raw).toEqual(inputSchema);
const { $schema: _declaredDialect, ...body } = raw;
expect(checkBody(body).ok).toBe(true);
}
expect(registered.parameters).toBe(inputSchema);
},
);

it("declares the frozen output schema, which the model is never sent", () => {
const outputSchema = { type: "object", properties: { z: { type: "number" } } } as const;
const registered = tool(
Expand Down
44 changes: 44 additions & 0 deletions packages/host-core/src/mcp/discovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,50 @@ describe("discoverMcpServer", () => {
});
});

it("marks old-draft provider hazards unusable while preserving safe plain definitions", async () => {
const plain = {
$schema: "http://json-schema.org/draft-07/schema#",
type: "object",
properties: { query: { type: "string" } },
};
const tuple = {
...plain,
properties: { pair: { type: "array", items: [{ type: "string" }], additionalItems: false } },
};
const connection = client([
{ name: "plain", inputSchema: plain },
{ name: "tuple", inputSchema: tuple },
{ name: "dependencies", inputSchema: { ...plain, dependencies: { query: ["limit"] } } },
{ name: "unknown", inputSchema: { ...plain, $schema: "https://example.invalid/dialect" } },
]);
const catalog = await discoverMcpServer({
server,
enabledToolNames: ["plain", "tuple", "dependencies", "unknown"],
signal: new AbortController().signal,
open: async () => connection,
workspacePath: "/repo",
});
expect(catalog[0]).toMatchObject({ enabled: true, error: null });
expect(catalog[0]!.definition!.inputSchema).toBe(plain);
expect(catalog[1]).toMatchObject({
enabled: false,
definition: null,
error:
"input schema uses tuple `items` arrays, which model providers reject; the server should use 2020-12 `prefixItems`",
});
expect(catalog[2]).toMatchObject({
enabled: false,
definition: null,
error: expect.stringContaining("dependentRequired"),
});
expect(catalog[3]).toMatchObject({
enabled: false,
definition: null,
error: "input schema must be valid JSON Schema",
});
expect(connection.close).toHaveBeenCalledOnce();
});

it("fails duplicate names and tool-count overflow clearly and always closes the connection", async () => {
const duplicate = client([
{ name: "echo", inputSchema: { type: "object" } },
Expand Down
Loading
Loading