Files
TREK/server/tests/unit/plugins/mcp-tool-enforcement.test.ts
T
Renzo Beux 937e32924d fix(plugins): close six gaps in the MCP tool boundary
Found in review of the branch. Each one is a case where the host told the
model or the admin something it did not enforce.

Advertised schemas are now enforced. buildToolInputSchema built a loose
object that checked only which keys were present, so a tool advertising
an integer, an enum and additionalProperties: false accepted a string, an
unlisted value and an extra property. .meta() still advertises the
declaration verbatim, and the schema underneath it is now built from that
same declaration, so the two cannot disagree. Anything the builder cannot
enforce (oneOf, unknown formats, non-primitive enum members) is refused by
parseManifest rather than advertised, and the failure lands at install
time where an admin sees it.

Writing the test for that turned up a live bug: z.custom() cannot be
represented in JSON Schema, so building enums that way would have thrown
inside tools/list for the whole session. Enums and consts are real literal
types now.

readOnlyHint classification fails closed. The side-effect list spelled out
db:write: and missed db:create:trips entirely, letting a plugin that can
create trips advertise a tool as read-only and idempotent, which is what
clients gate auto-approval on. It is an allowlist of read-only grants now,
and mcp-tool-annotations.test.ts walks the whole catalog so a new
permission fails a test rather than producing a wrong hint.

The result cap covers the whole result. It bounded each text block, so a
hundred blocks cost a hundred times the limit: 6.5M characters in a probe,
now 65K. Large objects are serialised with a budget instead of stringified
whole and then sliced, and the block count is capped.

The admin tool list was dead code. The registry preview carries only
widget and tripPage, so the section added for it never rendered, and the
UI hid the description behind the title when both existed. The preview
carries sanitised tools now (previewManifest is exported and tested, which
is how the gap survived), and both fields render, description always.

plugins:use is out of the DCR default. Adding it to ALL_SCOPES meant a
registration that names no scopes got it and had it pre-selected at
consent. Defaults come from DEFAULT_CLIENT_SCOPES now; asking for it by
name still works.

The public ManifestCapabilities gained mcpTools, so a typed manifest can
express what the runtime already validates.

MCPANN-001..005, MCPENF-001..010, MCPPREV-001..005, MCPTOOLS-020..022,
SDKP-015b/c.
2026-08-29 12:22:59 +02:00

99 lines
4.3 KiB
TypeScript

/**
* The advertised schema is the enforced schema — MCPENF-001 to MCPENF-010.
*
* `.meta()` shows the model the plugin's declaration verbatim. The SDK contract,
* and every client, read that as "arguments are validated before the handler
* runs". An earlier version advertised types, enums, ranges and
* `additionalProperties: false` while accepting literally anything, which is
* the worst of both: the model formats its call to a contract nothing checks.
*/
import {
McpToolSchemaError,
buildToolInputSchema,
normaliseToolSchema,
} from '../../../src/nest/plugins/mcp-tool-schema';
import { describe, expect, it } from 'vitest';
import { z } from 'zod';
const build = (declared: unknown) =>
buildToolInputSchema(normaliseToolSchema(declared)) as unknown as z.ZodType;
const advertised = (declared: unknown) =>
z.toJSONSchema(buildToolInputSchema(normaliseToolSchema(declared)) as unknown as z.ZodType, {
io: 'input',
}) as Record<string, unknown>;
const strictSchema = {
type: 'object',
properties: {
days: { type: 'integer', minimum: 1, maximum: 14 },
mode: { type: 'string', enum: ['fast', 'slow'] },
email: { type: 'string', format: 'email' },
tags: { type: 'array', items: { type: 'string' }, maxItems: 3 },
},
required: ['days'],
additionalProperties: false,
};
describe('enforcement matches the advertisement', () => {
it('MCPENF-001: rejects a value of the wrong type', () => {
expect(build(strictSchema).safeParse({ days: 'seven' }).success).toBe(false);
});
it('MCPENF-002: rejects a value outside the declared range', () => {
expect(build(strictSchema).safeParse({ days: 999 }).success).toBe(false);
expect(build(strictSchema).safeParse({ days: 7 }).success).toBe(true);
});
it('MCPENF-003: rejects a value outside the declared enum', () => {
expect(build(strictSchema).safeParse({ days: 3, mode: 'teleport' }).success).toBe(false);
expect(build(strictSchema).safeParse({ days: 3, mode: 'fast' }).success).toBe(true);
});
it('MCPENF-004: honours additionalProperties: false', () => {
expect(build(strictSchema).safeParse({ days: 3, surprise: true }).success).toBe(false);
});
it('MCPENF-005: still enforces required', () => {
expect(build(strictSchema).safeParse({ mode: 'fast' }).success).toBe(false);
});
it('MCPENF-006: enforces string formats and array bounds', () => {
expect(build(strictSchema).safeParse({ days: 1, email: 'nope' }).success).toBe(false);
expect(build(strictSchema).safeParse({ days: 1, email: 'a@b.co' }).success).toBe(true);
expect(build(strictSchema).safeParse({ days: 1, tags: ['a', 'b', 'c', 'd'] }).success).toBe(false);
});
it('MCPENF-007: an open object still accepts extra keys', () => {
// additionalProperties is absent, so the plugin may accept more than it
// advertises. That is a choice the declaration makes, not an accident.
const open = { type: 'object', properties: { a: { type: 'string' } } };
expect(build(open).safeParse({ a: 'x', extra: 1 }).success).toBe(true);
});
it('MCPENF-008: the advertisement is still the plugin declaration verbatim', () => {
// Enforcement must not have cost us the reason we advertise .meta() at all.
const out = advertised(strictSchema);
expect(out.properties).toEqual(strictSchema.properties);
expect(out.required).toEqual(['days']);
expect(out.additionalProperties).toBe(false);
});
});
describe('unenforceable declarations are refused, not advertised', () => {
it('MCPENF-009: rejects keywords the validator cannot express', () => {
// oneOf/anyOf/allOf/not would otherwise reach the model and bind nothing.
for (const keyword of ['oneOf', 'anyOf', 'allOf', 'not', 'patternProperties']) {
const schema = { type: 'object', properties: { a: { [keyword]: [{ type: 'string' }] } } };
expect(() => build(schema), keyword).toThrow(McpToolSchemaError);
}
});
it('MCPENF-010: rejects an unsupported type, format, or broken pattern', () => {
expect(() => build({ type: 'object', properties: { a: { type: 'bigint' } } })).toThrow(/unsupported type/);
expect(() => build({ type: 'object', properties: { a: { type: 'string', format: 'ipv6' } } })).toThrow(/unsupported format/);
expect(() => build({ type: 'object', properties: { a: { type: 'string', pattern: '([' } } })).toThrow(/regular expression/);
});
});