diff --git a/agent/mcpTools.ts b/agent/mcpTools.ts index 44cdc8f055..d381482f67 100644 --- a/agent/mcpTools.ts +++ b/agent/mcpTools.ts @@ -20,6 +20,7 @@ import { addTool, canRoleInvokeOperation, type AuthedUser } from '../components/ import { makeOperationToolHandler } from '../components/mcp/tools/operations.ts'; import harperLogger from '../utility/logging/harper_logger.ts'; import type { OperationDefinition } from '../server/serverHelpers/serverUtilities.ts'; +import { AGENT_OPERATION_INPUT_SCHEMAS } from './operationInputSchemas.ts'; const log = harperLogger.loggerWithTag('agent'); @@ -36,55 +37,29 @@ const AGENT_MCP_TOOLS: Record = { agent_prompt: { description: 'Send a prompt to the built-in Harper agent. Starts a new session, or continues one when session_id is given. Returns { session_id, status }; poll get_agent_session for the transcript and result.', - inputSchema: { - type: 'object', - properties: { - message: { type: 'string', description: 'The instruction/prompt for the agent.' }, - session_id: { type: 'string', description: 'Optional existing session id to continue the conversation.' }, - }, - required: ['message'], - }, + inputSchema: AGENT_OPERATION_INPUT_SCHEMAS.agent_prompt, destructive: true, // the agent may take actions in response }, get_agent_session: { description: 'Read a built-in-agent session: its status, full transcript (messages, tool calls and results), and any pending approvals.', - inputSchema: { - type: 'object', - properties: { session_id: { type: 'string' } }, - required: ['session_id'], - }, + inputSchema: AGENT_OPERATION_INPUT_SCHEMAS.get_agent_session, readOnly: true, }, list_agent_sessions: { description: 'List built-in-agent sessions, most recently updated first.', - inputSchema: { - type: 'object', - properties: { limit: { type: 'integer', minimum: 1, description: 'Max sessions to return (default 100).' } }, - }, + inputSchema: AGENT_OPERATION_INPUT_SCHEMAS.list_agent_sessions, readOnly: true, }, approve_agent_action: { description: 'Approve or deny a pending agent tool call (when autoApprove is off), then resume the run. Get the approval_id from get_agent_session.pendingApprovals.', - inputSchema: { - type: 'object', - properties: { - session_id: { type: 'string' }, - approval_id: { type: 'string' }, - approved: { type: 'boolean', description: 'true to approve (default), false to deny.' }, - }, - required: ['session_id', 'approval_id'], - }, + inputSchema: AGENT_OPERATION_INPUT_SCHEMAS.approve_agent_action, destructive: true, }, cancel_agent_run: { description: 'Cancel an in-progress agent run for a session.', - inputSchema: { - type: 'object', - properties: { session_id: { type: 'string' } }, - required: ['session_id'], - }, + inputSchema: AGENT_OPERATION_INPUT_SCHEMAS.cancel_agent_run, destructive: true, }, }; diff --git a/agent/operationInputSchemas.ts b/agent/operationInputSchemas.ts new file mode 100644 index 0000000000..3312e72adb --- /dev/null +++ b/agent/operationInputSchemas.ts @@ -0,0 +1,46 @@ +export const AGENT_OPERATION_INPUT_SCHEMAS = { + agent_prompt: { + type: 'object', + properties: { + message: { type: 'string', description: 'The instruction/prompt for the agent.' }, + session_id: { type: 'string', description: 'Optional existing session id to continue the conversation.' }, + }, + required: ['message'], + }, + get_agent_session: { + type: 'object', + properties: { session_id: { type: 'string' } }, + required: ['session_id'], + }, + list_agent_sessions: { + type: 'object', + properties: { limit: { type: 'integer', minimum: 1, description: 'Max sessions to return (default 100).' } }, + }, + approve_agent_action: { + type: 'object', + properties: { + session_id: { type: 'string' }, + approval_id: { type: 'string' }, + approved: { type: 'boolean', description: 'true to approve (default), false to deny.' }, + }, + required: ['session_id', 'approval_id'], + }, + cancel_agent_run: { + type: 'object', + properties: { session_id: { type: 'string' } }, + required: ['session_id'], + }, + set_agent_config: { + type: 'object', + properties: { + enabled: { type: 'boolean' }, + provider: { type: 'string' }, + model: { type: 'string' }, + maxTurns: { type: 'integer', minimum: 1 }, + maxCostUsd: { type: 'number', minimum: 0 }, + autoApprove: { type: 'boolean' }, + allowDestructive: { type: 'boolean' }, + systemPromptAppend: { type: 'string' }, + }, + }, +} as const satisfies Record; diff --git a/agent/operations.ts b/agent/operations.ts index 8c74f44e07..9a238c1c10 100644 --- a/agent/operations.ts +++ b/agent/operations.ts @@ -15,6 +15,7 @@ import { OPERATIONS_ENUM } from '../utility/hdbTerms.ts'; import { ClientError } from '../utility/errors/hdbError.ts'; import { createSession, getSession, listSessions, appendMessage, resolveApproval, setStatus } from './session.ts'; import type { AgentConfig, AgentMessage, AgentRunStatus } from './types.ts'; +import { AGENT_OPERATION_INPUT_SCHEMAS } from './operationInputSchemas.ts'; export interface OperationDeps { getConfig: () => AgentConfig; @@ -28,31 +29,37 @@ export function buildOperations(deps: OperationDeps): OperationDefinition[] { { name: OPERATIONS_ENUM.AGENT_PROMPT, execute: async (op) => agentPrompt(op, deps), + inputSchema: AGENT_OPERATION_INPUT_SCHEMAS.agent_prompt, requiresSuperUser: true, }, { name: OPERATIONS_ENUM.GET_AGENT_SESSION, execute: async (op) => getAgentSession(op), + inputSchema: AGENT_OPERATION_INPUT_SCHEMAS.get_agent_session, requiresSuperUser: true, }, { name: OPERATIONS_ENUM.LIST_AGENT_SESSIONS, execute: async (op) => listAgentSessions(op), + inputSchema: AGENT_OPERATION_INPUT_SCHEMAS.list_agent_sessions, requiresSuperUser: true, }, { name: OPERATIONS_ENUM.CANCEL_AGENT_RUN, execute: async (op) => cancelAgentRun(op, deps), + inputSchema: AGENT_OPERATION_INPUT_SCHEMAS.cancel_agent_run, requiresSuperUser: true, }, { name: OPERATIONS_ENUM.APPROVE_AGENT_ACTION, execute: async (op) => approveAgentAction(op, deps), + inputSchema: AGENT_OPERATION_INPUT_SCHEMAS.approve_agent_action, requiresSuperUser: true, }, { name: OPERATIONS_ENUM.SET_AGENT_CONFIG, execute: async (op) => setAgentConfig(op, deps), + inputSchema: AGENT_OPERATION_INPUT_SCHEMAS.set_agent_config, requiresSuperUser: true, }, ]; diff --git a/components/mcp/tools/operations.ts b/components/mcp/tools/operations.ts index e6d8ea04ed..3948f85e4b 100644 --- a/components/mcp/tools/operations.ts +++ b/components/mcp/tools/operations.ts @@ -45,7 +45,6 @@ import { type ToolDef, type ToolResult, } from '../toolRegistry.ts'; -import { OPERATION_INPUT_SCHEMAS, PERMISSIVE_SCHEMA } from './schemas/operations.ts'; import { OPERATION_DESCRIPTIONS } from './schemas/operationDescriptions.ts'; // Resolved from Harper's server-helpers graph on demand. The map is built at @@ -53,24 +52,35 @@ import { OPERATION_DESCRIPTIONS } from './schemas/operationDescriptions.ts'; // `startOnMainThread` — the provider below re-reads it per request rather than // snapshotting (#1562). type OperationFunction = (json: object) => unknown | Promise; -type OperationFunctionMap = Map; +type OperationFunctionEntry = { inputSchema?: object }; +type OperationFunctionMap = Map; type ChooseOperation = (body: object) => OperationFunction; type ProcessLocalTransaction = (req: { body: object }, fn: OperationFunction) => Promise; interface OperationsConfig { allow?: readonly string[]; + allowSchemaless?: readonly string[]; deny?: readonly string[]; } +type RemoteOperationSchema = { inputSchema?: object; issue?: 'missing' | 'inconsistent' }; // Test seams. Avoids importing Harper's heavy server-helpers graph from unit // tests that only want to exercise the registration logic. let _opMapOverride: OperationFunctionMap | undefined; +let _remoteSchemasOverride: Array<[string, RemoteOperationSchema]> | undefined; let _chooseOperationOverride: ChooseOperation | undefined; let _processLocalTransactionOverride: ProcessLocalTransaction | undefined; +const warnedMissingSchemas = new Set(); export function _setOperationFunctionMapForTest(m: OperationFunctionMap | undefined): void { _opMapOverride = m; + warnedMissingSchemas.clear(); +} +export function _setRemoteOperationInputSchemasForTest( + schemas: Array<[string, RemoteOperationSchema]> | undefined +): void { + _remoteSchemasOverride = schemas; } export function _setChooseOperationForTest(fn: ChooseOperation | undefined): void { _chooseOperationOverride = fn; @@ -83,6 +93,8 @@ function loadServerUtilities(): | { OPERATION_FUNCTION_MAP?: OperationFunctionMap; chooseOperation?: ChooseOperation; + getRemoteOperationInputSchema?: (name: string) => RemoteOperationSchema | undefined; + getRemoteOperationInputSchemas?: () => Array<[string, RemoteOperationSchema]>; processLocalTransaction?: ProcessLocalTransaction; } | undefined { @@ -104,6 +116,18 @@ function getOperationFunctionMap(): OperationFunctionMap | undefined { return utils?.OPERATION_FUNCTION_MAP; } +function getRemoteOperationInputSchemas(): Array<[string, RemoteOperationSchema]> { + if (_remoteSchemasOverride) return _remoteSchemasOverride; + if (_opMapOverride) return []; + return loadServerUtilities()?.getRemoteOperationInputSchemas?.() ?? []; +} + +function getRemoteOperationInputSchema(name: string): RemoteOperationSchema | undefined { + if (_remoteSchemasOverride) return _remoteSchemasOverride.find(([operationName]) => operationName === name)?.[1]; + if (_opMapOverride) return undefined; + return loadServerUtilities()?.getRemoteOperationInputSchema?.(name); +} + function getChooseOperation(): ChooseOperation | undefined { if (_chooseOperationOverride) return _chooseOperationOverride; return loadServerUtilities()?.chooseOperation; @@ -268,7 +292,7 @@ function matchesAny(operation: string, patterns: readonly string[] | undefined): return false; } -function isOperationAllowed(operation: string, config: OperationsConfig): boolean { +export function isOperationAllowed(operation: string, config: OperationsConfig): boolean { const usingDefaultAllow = !(config.allow && config.allow.length > 0); if (usingDefaultAllow && DEFAULT_EXCLUDED.has(operation)) return false; const allowList = usingDefaultAllow ? DEFAULT_ALLOW : config.allow; @@ -279,21 +303,21 @@ function isOperationAllowed(operation: string, config: OperationsConfig): boolea function getOperationsConfig(): OperationsConfig { const allow = env.get(CONFIG_PARAMS.MCP_OPERATIONS_ALLOW); + const allowSchemaless = env.get(CONFIG_PARAMS.MCP_OPERATIONS_ALLOWSCHEMALESS); const deny = env.get(CONFIG_PARAMS.MCP_OPERATIONS_DENY); return { allow: Array.isArray(allow) ? (allow as readonly string[]) : undefined, + allowSchemaless: Array.isArray(allowSchemaless) ? (allowSchemaless as readonly string[]) : undefined, deny: Array.isArray(deny) ? (deny as readonly string[]) : undefined, }; } -function buildDescription(operationName: string, hasCuratedSchema: boolean): string { - const curated = OPERATION_DESCRIPTIONS[operationName]; +function buildDescription(operationName: string): string { + const curated = Object.hasOwn(OPERATION_DESCRIPTIONS, operationName) + ? OPERATION_DESCRIPTIONS[operationName] + : undefined; if (curated) return curated; - const base = `Harper operation '${operationName}'.`; - const schemaNote = hasCuratedSchema - ? ' Arguments validated against the curated schema below.' - : ' Arguments forwarded as-is; the server validates and returns a structured error on rejection.'; - return base + schemaNote; + return `Harper operation '${operationName}'. Arguments are described by its registered schema and validated by the operation handler.`; } /** @@ -385,18 +409,16 @@ export function makeOperationToolHandler(operationName: string) { * function of the operation name (its schema, description, annotations, RBAC * predicate, and handler don't depend on the allow/deny config, which only * decides *whether* the op is exposed, checked per request in the provider). - * `tools/list` isn't a hot path, so the provider rebuilds defs per call rather - * than caching (no module-level state to leak or stale-cache across tests). + * `tools/list` isn't a hot path, so the provider rebuilds defs per call. */ -function buildOperationToolDef(operationName: string): ToolDef { - const inputSchema = OPERATION_INPUT_SCHEMAS[operationName] ?? PERMISSIVE_SCHEMA; +function buildOperationToolDef(operationName: string, inputSchema: object): ToolDef { const annotations: { readOnlyHint?: boolean; destructiveHint?: boolean; idempotentHint?: boolean } = {}; if (isReadOnly(operationName)) annotations.readOnlyHint = true; if (isDestructive(operationName)) annotations.destructiveHint = true; if (isIdempotent(operationName)) annotations.idempotentHint = true; return { name: operationName, - description: buildDescription(operationName, operationName in OPERATION_INPUT_SCHEMAS), + description: buildDescription(operationName), inputSchema, profile: 'operations', ...(Object.keys(annotations).length > 0 ? { annotations } : {}), @@ -405,6 +427,33 @@ function buildOperationToolDef(operationName: string): ToolDef { }; } +function buildRegisteredOperationToolDef( + operationName: string, + operation: OperationFunctionEntry, + config: OperationsConfig, + issue: RemoteOperationSchema['issue'] = 'missing' +): ToolDef | undefined { + if (!operation.inputSchema) { + if (issue !== 'inconsistent' && matchesAny(operationName, config.allowSchemaless)) { + return buildOperationToolDef(operationName, { type: 'object' }); + } + const warningKey = `${operationName}:${issue}`; + if (!warnedMissingSchemas.has(warningKey)) { + warnedMissingSchemas.add(warningKey); + const source = config.allow?.length ? ' is named by mcp.operations.allow but' : ''; + const message = + issue === 'inconsistent' + ? `MCP operations profile: '${operationName}' is registered by workers with different inputSchema values; withholding it until they agree` + : `MCP operations profile: '${operationName}'${source} was registered without inputSchema; pass inputSchema to server.registerOperation() or add it to mcp.operations.allowSchemaless`; + harperLogger.warn(message); + } + return undefined; + } + warnedMissingSchemas.delete(`${operationName}:missing`); + warnedMissingSchemas.delete(`${operationName}:inconsistent`); + return buildOperationToolDef(operationName, operation.inputSchema); +} + /** * Lazy operations-profile tool provider. Consulted by the registry on every * `tools/list` / `tools/call`, so it reflects the live `OPERATION_FUNCTION_MAP` @@ -419,17 +468,39 @@ const operationsToolProvider: ProfileToolProvider = { } const config = getOperationsConfig(); const defs: ToolDef[] = []; - for (const operationName of opMap.keys()) { + for (const [operationName, operation] of opMap) { if (!isOperationAllowed(operationName, config)) continue; - defs.push(buildOperationToolDef(operationName)); + const def = buildRegisteredOperationToolDef(operationName, operation, config); + if (def) defs.push(def); + } + for (const [operationName, remoteSchema] of getRemoteOperationInputSchemas()) { + if (opMap.has(operationName) || !isOperationAllowed(operationName, config)) continue; + const def = buildRegisteredOperationToolDef( + operationName, + { inputSchema: remoteSchema.inputSchema }, + config, + remoteSchema.issue + ); + if (def) defs.push(def); } return defs; }, get(operationName: string): ToolDef | undefined { const opMap = getOperationFunctionMap(); - if (!opMap || !opMap.has(operationName)) return undefined; - if (!isOperationAllowed(operationName, getOperationsConfig())) return undefined; - return buildOperationToolDef(operationName); + if (!opMap) return undefined; + let operation = opMap.get(operationName); + let issue: RemoteOperationSchema['issue']; + if (!operation) { + const remote = getRemoteOperationInputSchema(operationName); + if (remote) { + operation = { inputSchema: remote.inputSchema }; + issue = remote.issue; + } + } + if (!operation) return undefined; + const config = getOperationsConfig(); + if (!isOperationAllowed(operationName, config)) return undefined; + return buildRegisteredOperationToolDef(operationName, operation, config, issue); }, }; diff --git a/dependencies.md b/dependencies.md index 566ef59c29..5c8fc34a19 100644 --- a/dependencies.md +++ b/dependencies.md @@ -22,6 +22,14 @@ In reviewing the third party package or dependency, the following questions shou Generally, dependencies are added by simply adding them to the dependencies list in package.json. If the dependency is not necessary for the actual execution of the application (testing or building), it can be placed in devDependencies, or in optionalDependencies (we have done that with packages with binary compilations). +## ajv + +- Need for usage: Validates JSON Schema metadata supplied when operations are registered, before that metadata can be exposed to MCP clients. This keeps a malformed component schema from breaking `tools/list` while preserving the operation handler. +- Size and overlap: Ajv is already present transitively through Fastify; declaring Fastify's existing 8.18.0 resolution directly makes Harper's runtime use explicit without changing its schema compiler version or adding a package family. Fastify's internal compiler is not a public standalone schema-validation API. Future upgrades must check both consumers. +- Performance and memory: Loaded lazily when `server.registerOperation()` supplies or inherits schema metadata and never on the operation dispatch hot path. Built-in schemas are checked in CI; their boot-time attachment skips runtime validation. +- Security and environment: Pure JavaScript, no native build or global mutation. Component schemas larger than 64 KiB are rejected before parsing and meta-validation, and are not passed through Ajv's code generator. +- Eventual removal: Remove the direct dependency if operation contracts move to a schema facility already owned by Harper or a stable public validator supplied by the runtime. + ## react-native-fs (removed from the published tree, not a dependency) This is the inverse of the entries below — a dependency we take deliberate steps to _not_ ship. diff --git a/integrationTests/components/fixtures/registered-operation/resources.js b/integrationTests/components/fixtures/registered-operation/resources.js index f9e9796873..a08bcb0c9d 100644 --- a/integrationTests/components/fixtures/registered-operation/resources.js +++ b/integrationTests/components/fixtures/registered-operation/resources.js @@ -10,6 +10,11 @@ import { Readable } from 'node:stream'; server.registerOperation({ name: 'component_registered_echo', + inputSchema: { + type: 'object', + properties: { value: { type: 'string' } }, + required: ['value'], + }, // Named function expression so the handler's `.name` is deterministic for the // verifyPerms/requiredPermissions lookup (not inferred as "execute"). execute: async function componentRegisteredEcho(op) { diff --git a/integrationTests/components/registered-operation.test.ts b/integrationTests/components/registered-operation.test.ts index 34d2e4e486..6c7f14ab79 100644 --- a/integrationTests/components/registered-operation.test.ts +++ b/integrationTests/components/registered-operation.test.ts @@ -13,7 +13,7 @@ * thread, so the topology, and therefore the gap, is invisible to them. */ import { suite, test, before, after } from 'node:test'; -import { strictEqual, ok } from 'node:assert'; +import { deepStrictEqual, strictEqual, ok } from 'node:assert'; import { resolve } from 'node:path'; import { setupHarperWithFixture, teardownHarper, type ContextWithHarper } from '@harperfast/integration-testing'; @@ -64,12 +64,29 @@ suite('Component: registered-operation (#1736)', (ctx: ContextWithHarper) => { return { status: response.status, body: await response.json() }; } + async function mcp(body: object, sessionId?: string): Promise { + const { username, password } = ctx.harper.admin; + return fetch(`${ctx.harper.operationsAPIURL}/mcp`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json, text/event-stream', + 'Authorization': `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`, + ...(sessionId ? { 'Mcp-Session-Id': sessionId, 'MCP-Protocol-Version': '2025-06-18' } : {}), + }, + body: JSON.stringify(body), + }); + } + before(async () => { // Multiple HTTP workers so the forward actually has a choice of registering threads. await setupHarperWithFixture(ctx, FIXTURE_PATH, { config: { threads: { count: 2 }, logging: { console: true, level: 'error' }, + mcp: { + operations: { mountPath: '/mcp', allow: ['component_registered_echo', 'component_registered_stream'] }, + }, }, }); }); @@ -89,6 +106,37 @@ suite('Component: registered-operation (#1736)', (ctx: ContextWithHarper) => { strictEqual(body.username, ctx.harper.admin.username); }); + test('registered inputSchema is exposed through MCP tools/list', async () => { + const init = await mcp({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'test', version: '0' } }, + }); + strictEqual(init.status, 200); + const sessionId = init.headers.get('mcp-session-id'); + ok(sessionId); + await init.body?.cancel(); + + const response = await mcp({ jsonrpc: '2.0', id: 2, method: 'tools/list' }, sessionId); + strictEqual(response.status, 200); + const body = (await response.json()) as { + result: { tools: Array<{ name: string; inputSchema: object }> }; + }; + const tool = body.result.tools.find(({ name }) => name === 'component_registered_echo'); + ok(tool); + deepStrictEqual(tool.inputSchema, { + type: 'object', + properties: { value: { type: 'string' } }, + required: ['value'], + }); + strictEqual( + body.result.tools.some(({ name }) => name === 'component_registered_stream'), + false, + 'schema-less operations must not be advertised' + ); + }); + test('repeated calls keep working (round-robin across registering workers)', async () => { const threadIds = new Set(); for (let i = 0; i < 6; i++) { diff --git a/package-lock.json b/package-lock.json index b24510e024..25df1b1fee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "@turf/distance": "6.5.0", "@turf/helpers": "6.5.0", "@turf/length": "6.5.0", + "ajv": "8.18.0", "alasql": "4.19.0", "amaro": "^1.1.8", "argon2": "0.45.1", @@ -1697,6 +1698,23 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@eslint/eslintrc/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/@eslint/eslintrc/node_modules/balanced-match": { "version": "1.0.2", "dev": true, @@ -1722,6 +1740,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, "node_modules/@eslint/eslintrc/node_modules/minimatch": { "version": "3.1.5", "dev": true, @@ -1797,24 +1822,6 @@ "fast-uri": "^3.0.0" } }, - "node_modules/@fastify/ajv-compiler/node_modules/ajv": { - "version": "8.18.0", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/@fastify/ajv-compiler/node_modules/json-schema-traverse": { - "version": "1.0.0", - "license": "MIT" - }, "node_modules/@fastify/autoload": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/@fastify/autoload/-/autoload-6.5.0.tgz", @@ -3292,23 +3299,6 @@ } } }, - "node_modules/@modelcontextprotocol/sdk/node_modules/ajv": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", - "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, "node_modules/@modelcontextprotocol/sdk/node_modules/eventsource": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", @@ -3322,13 +3312,6 @@ "node": ">=18.0.0" } }, - "node_modules/@modelcontextprotocol/sdk/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, - "license": "MIT" - }, "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz", @@ -5358,14 +5341,15 @@ } }, "node_modules/ajv": { - "version": "6.14.0", - "dev": true, + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" }, "funding": { "type": "github", @@ -5387,24 +5371,6 @@ } } }, - "node_modules/ajv-formats/node_modules/ajv": { - "version": "8.18.0", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ajv-formats/node_modules/json-schema-traverse": { - "version": "1.0.0", - "license": "MIT" - }, "node_modules/alasql": { "version": "4.19.0", "resolved": "https://registry.npmjs.org/alasql/-/alasql-4.19.0.tgz", @@ -7222,6 +7188,23 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/eslint/node_modules/balanced-match": { "version": "1.0.2", "dev": true, @@ -7247,6 +7230,13 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, "node_modules/eslint/node_modules/minimatch": { "version": "3.1.5", "dev": true, @@ -7626,24 +7616,6 @@ "rfdc": "^1.2.0" } }, - "node_modules/fast-json-stringify/node_modules/ajv": { - "version": "8.18.0", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/fast-json-stringify/node_modules/json-schema-traverse": { - "version": "1.0.0", - "license": "MIT" - }, "node_modules/fast-levenshtein": { "version": "2.0.6", "dev": true, @@ -7743,22 +7715,6 @@ ], "license": "MIT" }, - "node_modules/fastify/node_modules/ajv": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", - "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, "node_modules/fastify/node_modules/fast-json-stringify": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-7.0.1.tgz", @@ -7799,12 +7755,6 @@ ], "license": "BSD-3-Clause" }, - "node_modules/fastify/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT" - }, "node_modules/fastify/node_modules/pino": { "version": "10.3.1", "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", @@ -10233,8 +10183,9 @@ } }, "node_modules/json-schema-traverse": { - "version": "0.4.1", - "dev": true, + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, "node_modules/json-schema-typed": { @@ -12858,6 +12809,8 @@ }, "node_modules/punycode": { "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "dev": true, "license": "MIT", "engines": { @@ -14887,6 +14840,8 @@ }, "node_modules/uri-js": { "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", "dev": true, "license": "BSD-2-Clause", "dependencies": { diff --git a/package.json b/package.json index c8f8435a37..9c46915c44 100644 --- a/package.json +++ b/package.json @@ -191,6 +191,7 @@ "@turf/distance": "6.5.0", "@turf/helpers": "6.5.0", "@turf/length": "6.5.0", + "ajv": "8.18.0", "alasql": "4.19.0", "amaro": "^1.1.8", "argon2": "0.45.1", diff --git a/server/DESIGN.md b/server/DESIGN.md index 9dc439e3bb..91d9b85858 100644 --- a/server/DESIGN.md +++ b/server/DESIGN.md @@ -196,13 +196,27 @@ payload. `server.registerOperation()` runs per-worker, so anything the **main** thread must later know about a registered op has to ride the OPERATION_REGISTERED announcement — a module-local registry populated -during registration exists only in the worker that registered. The bridge carries two such facts -today: name→thread routing (for execution forwarding) and `grantable` (so `validateOperations` on -main will accept the name in a role's `operations` allowlist, for add_role/alter_role, impersonation, -and OIDC trust policies). Adding a third main-thread consumer of a worker-registered fact means -extending that message, not reading a registry that main never populated. Grantability is safe to -mirror because it only widens what an allowlist may _name_; enforcement stays on the worker's -`chooseOperation`. +during registration exists only in the worker that registered. The bridge carries name→thread +routing (for execution forwarding), `grantable` (so `validateOperations` on main will accept the +name in a role's `operations` allowlist), and normalized `inputSchema` metadata for main-thread MCP +introspection. Grantability only widens what an allowlist may _name_; enforcement stays on the +worker's `chooseOperation`. MCP exposes a worker-registered operation only while every live worker +advertises the same schema, so a rolling deploy cannot pair one generation's schema with another +generation's handler. + +An operation intended for protocol introspection should pass a JSON Schema object as +`inputSchema` to `server.registerOperation()`. When the caller omits it, registration uses the schema +shipped for that operation name, if one exists. MCP does not advertise an allowed operation without +either source of metadata, and direct calls by name are unavailable through MCP as well; the +operation remains available through the operations API. Registration clones and validates component +schema metadata without changing the handler's own input validation. `parametersSchema` is legacy +REST metadata and is not used for MCP tools. + +Before v5.3, an operation named by `mcp.operations.allow` was exposed with a permissive object schema +when no curated schema existed. Existing deployments that need that behavior can name the affected +operations in `mcp.operations.allowSchemaless`; the explicit opt-in advertises `{ type: 'object' }` +and leaves validation to the operation handler. It never overrides conflicting schemas announced by +live workers during a rolling deploy. ## Resource ↔ HTTP boundary diff --git a/server/serverHelpers/OperationFunctionObject.ts b/server/serverHelpers/OperationFunctionObject.ts index a4fa5acc47..540901003c 100644 --- a/server/serverHelpers/OperationFunctionObject.ts +++ b/server/serverHelpers/OperationFunctionObject.ts @@ -4,10 +4,16 @@ export class OperationFunctionObject { operation_function: Function; job_operation_function: Function | undefined; + inputSchema: object | undefined; httpMethod?: string; - constructor(operation_function: Function, job_operation_function: Function = undefined) { + constructor( + operation_function: Function, + job_operation_function: Function = undefined, + inputSchema: object | undefined = undefined + ) { this.operation_function = operation_function; this.job_operation_function = job_operation_function; + this.inputSchema = inputSchema; } } diff --git a/components/mcp/tools/schemas/operations.ts b/server/serverHelpers/operationInputSchemas.ts similarity index 88% rename from components/mcp/tools/schemas/operations.ts rename to server/serverHelpers/operationInputSchemas.ts index d59bb29235..8125845c07 100644 --- a/components/mcp/tools/schemas/operations.ts +++ b/server/serverHelpers/operationInputSchemas.ts @@ -1,34 +1,18 @@ /** - * Hand-curated JSON Schemas for the operations-profile MCP tools. + * JSON Schemas attached to built-in operation registrations. * - * Why hand-curated: Harper's server-side validators are Joi, which doesn't - * round-trip cleanly to JSON Schema. The MCP spec requires draft-07-ish - * JSON Schema for tool inputSchema. Authoring these directly keeps the - * schemas readable, easy to tweak per the LLM ergonomics we want, and - * decoupled from server-side validation evolution. + * Operation validation is distributed across Joi and legacy validators and + * cannot currently be projected mechanically to JSON Schema. These schemas + * are hand-authored for protocol introspection. * * Each schema follows MCP convention: `type: 'object'` at the top, with * `properties` declared and a small `required` list when applicable. * Optional fields are listed but not required, so an LLM can call the * minimum-viable form. - * - * Coverage matches the v1 conservative `allow` default in the design - * (#465 → Operations MCP). When operators expand `mcp.operations.allow` - * beyond this list, ops without an entry here fall back to a permissive - * `{ type: 'object' }` schema and a runtime-validates-as-it-goes posture - * — better than silently dropping them from the tool surface. */ -/** Permissive default for any opted-in operation that doesn't have a hand-curated schema yet. */ -export const PERMISSIVE_SCHEMA: object = { - type: 'object', - additionalProperties: true, - description: 'Free-form arguments — Harper validates server-side and returns a structured error if invalid.', -}; - /** - * Map of operation name → input schema. Lookup misses fall back to - * `PERMISSIVE_SCHEMA`. Keys mirror `OPERATIONS_ENUM` values. + * Map of built-in operation name → input schema. Keys mirror `OPERATIONS_ENUM` values. */ export const OPERATION_INPUT_SCHEMAS: Record = { // ─── describe_* ─────────────────────────────────────────────────────── @@ -312,12 +296,6 @@ export const OPERATION_INPUT_SCHEMAS: Record = { }, required: ['metric'], }, - list_agent_sessions: { - type: 'object', - properties: { - limit: { type: 'integer', minimum: 1, description: 'Max sessions to return.' }, - }, - }, get_metrics: { type: 'object', properties: { diff --git a/server/serverHelpers/registeredOperations.ts b/server/serverHelpers/registeredOperations.ts index e62ed51124..577626d969 100644 --- a/server/serverHelpers/registeredOperations.ts +++ b/server/serverHelpers/registeredOperations.ts @@ -10,8 +10,8 @@ * itc/serverHandlers.js), with one deliberate difference: executing an operation is side-effecting, * so a request is sent to exactly ONE registering worker (never broadcast-first-wins). * - * - Worker: `registerOperation()` announces the name (OPERATION_REGISTERED) to all threads; - * only the main thread records it, as name -> Set, plus `grantable`. + * - Worker: `registerOperation()` sends the name, schema, and `grantable` flag directly to main + * (OPERATION_REGISTERED). * - Main: on an OPERATION_FUNCTION_MAP miss, `getRemoteOperationFunction()` supplies a forwarding * function that sends the request body (OPERATION_EXECUTE_REQUEST) to one live registering * worker and awaits the correlated OPERATION_EXECUTE_RESPONSE. @@ -25,7 +25,6 @@ import * as terms from '../../utility/hdbTerms.ts'; import * as env from '../../utility/environment/environmentManager.ts'; import harperLogger from '../../utility/logging/harper_logger.ts'; import { ServerError } from '../../utility/errors/hdbError.ts'; -import { sendItcEvent } from '../threads/itc.js'; import { hasThreadExited, onMessageByType, onThreadExit } from '../threads/manageThreads.js'; import { registerWorkerGrantableOperation, @@ -63,6 +62,9 @@ export function setLocalOperationDispatch(dispatch: typeof localDispatch) { /** name -> threadIds of workers that registered it (main thread only) */ const registeredByWorker = new Map>(); +type WorkerSchema = { inputSchema?: object; canonical?: string }; +export type RemoteOperationSchema = { inputSchema?: object; issue?: 'missing' | 'inconsistent' }; +const inputSchemaByWorker = new Map>(); // Per originator, not per name: a rolling deploy whose new generation drops `requiresSuperUser` // must keep routing the name while retracting grantability, which a name-level flag cannot express. const grantableByWorker = new Map>(); @@ -74,26 +76,30 @@ let nextRequestId = 1; let mainListenersAttached = false; /** - * Worker side: announce a registration so the main thread can forward calls here. Fire-and-forget — - * a lost announcement just means the op stays unreachable (the pre-#1736 status quo), and the - * broadcast has its own ack timeout. + * Worker side: send a registration directly to main so calls can be forwarded here. A missing main + * port is logged and leaves the operation unreachable there (the pre-#1736 status quo). */ -export function announceRegisteredOperation(name: string, grantable = false) { +export function announceRegisteredOperation(name: string, grantable = false, inputSchema?: object) { if (isMainThread) return; - sendItcEvent({ - type: terms.ITC_EVENT_TYPES.OPERATION_REGISTERED, - message: { name, grantable }, - }).catch((error) => operationLog.error(`Failed to announce registered operation '${name}'`, error)); + try { + const sent = threads.sendToThread(0, { + type: terms.ITC_EVENT_TYPES.OPERATION_REGISTERED, + message: { name, grantable, inputSchema, originator: threadId }, + }); + if (!sent) operationLog.error(`Failed to announce registered operation '${name}' to the main thread`); + } catch (error) { + operationLog.error(`Failed to announce registered operation '${name}' to the main thread`, error); + } } /** - * ITC handler (all threads receive the broadcast; only main records it). + * Main-thread ITC handler for direct worker registration announcements. */ export function operationRegisteredHandler(event: { - message?: { name?: string; grantable?: boolean; originator?: number }; + message?: { name?: string; grantable?: boolean; inputSchema?: object; originator?: number }; }) { if (!isMainThread) return; - const { name, grantable, originator } = event?.message ?? {}; + const { name, grantable, inputSchema, originator } = event?.message ?? {}; if (typeof name !== 'string' || typeof originator !== 'number') return; // An announcement can lose the race with its own thread's exit, and exit notification fires once // per thread, so without this the entry would never be cleaned up. Reads manageThreads' tombstone @@ -106,6 +112,17 @@ export function operationRegisteredHandler(event: { let workerIds = registeredByWorker.get(name); if (!workerIds) registeredByWorker.set(name, (workerIds = new Set())); workerIds.add(originator); + let workerSchemas = inputSchemaByWorker.get(name); + if (!workerSchemas) inputSchemaByWorker.set(name, (workerSchemas = new Map())); + let workerSchema: WorkerSchema = {}; + if (inputSchema) { + try { + workerSchema = { inputSchema, canonical: canonicalJson(inputSchema) }; + } catch (error) { + operationLog.warn(`Ignoring invalid inputSchema announced for '${name}' by thread ${originator}`, error); + } + } + workerSchemas.set(originator, workerSchema); // Mirroring only widens what an allowlist may name; enforcement stays on the worker's // chooseOperation. A re-announcement that drops the permission retracts this thread's claim. setWorkerGrantable(name, originator, grantable === true); @@ -119,6 +136,7 @@ export function operationRegisteredHandler(event: { function handleThreadExit(deadThreadId: number) { for (const [name, workerIds] of registeredByWorker) { if (!workerIds.delete(deadThreadId)) continue; + inputSchemaByWorker.get(name)?.delete(deadThreadId); // A surviving worker that never declared a permission must not keep the name admissible. if (workerIds.size === 0) dropRegistration(name); else setWorkerGrantable(name, deadThreadId, false); @@ -150,10 +168,46 @@ function setWorkerGrantable(name: string, threadId: number, grantable: boolean) */ function dropRegistration(name: string) { registeredByWorker.delete(name); + inputSchemaByWorker.delete(name); grantableByWorker.delete(name); unregisterWorkerGrantableOperation(name); } +export function getRemoteOperationInputSchemas(): Array<[string, RemoteOperationSchema]> { + if (!isMainThread) return []; + return [...registeredByWorker.keys()].map((name) => [name, getRemoteOperationInputSchema(name)!]); +} + +export function getRemoteOperationInputSchema(name: string): RemoteOperationSchema | undefined { + if (!isMainThread || !registeredByWorker.has(name)) return undefined; + const schemas = inputSchemaByWorker.get(name); + if (!schemas?.size) return { issue: 'missing' }; + let firstSchema: object | undefined; + let canonical: string | undefined; + for (const workerSchema of schemas.values()) { + if (!workerSchema.inputSchema) return { issue: 'missing' }; + if (canonical === undefined) { + firstSchema = workerSchema.inputSchema; + canonical = workerSchema.canonical; + } else if (workerSchema.canonical !== canonical) { + return { issue: 'inconsistent' }; + } + } + return { inputSchema: firstSchema }; +} + +function canonicalJson(value: object): string { + return JSON.stringify(value, (_key, nested) => + nested && typeof nested === 'object' && !Array.isArray(nested) + ? Object.fromEntries( + Object.keys(nested) + .sort() + .map((key) => [key, nested[key]]) + ) + : nested + ); +} + let rotation = 0; /** * Main-thread dispatch fallback: if a worker registered `name`, return a forwarding operation @@ -225,6 +279,7 @@ async function executeRemoteOperation(name: string, body: any, bypassAuth: boole // The port is gone, so this thread's claims go with it — grantability included, which // handleThreadExit would otherwise not retract while other workers still route the name. workerIds.delete(targetThreadId); + inputSchemaByWorker.get(name)?.delete(targetThreadId); setWorkerGrantable(name, targetThreadId, false); continue; } diff --git a/server/serverHelpers/serverUtilities.ts b/server/serverHelpers/serverUtilities.ts index 5b0fd92c6d..d9480fc89a 100644 --- a/server/serverHelpers/serverUtilities.ts +++ b/server/serverHelpers/serverUtilities.ts @@ -47,11 +47,16 @@ import { contextStorage } from '../../resources/transaction.ts'; import { isMainThread } from 'node:worker_threads'; import { announceRegisteredOperation, + getRemoteOperationInputSchema, + getRemoteOperationInputSchemas, getRemoteOperationFunction, setLocalOperationDispatch, } from './registeredOperations.ts'; +export { getRemoteOperationInputSchema, getRemoteOperationInputSchemas }; import { runWithOperationAuthorizationBypass } from './operationAuthorizationState.ts'; import { stripSuppliedParsedSqlObject } from './requestSanitization.ts'; +import { OPERATION_INPUT_SCHEMAS } from './operationInputSchemas.ts'; +import { normalizeOperationInputSchema } from './validateOperationInputSchema.ts'; const pSearchSearch = util.promisify(search.search); let pEvaluateSql: (sql: string) => Promise; @@ -168,6 +173,7 @@ export async function processLocalTransaction(req: OperationRequest, operationFu } export const OPERATION_FUNCTION_MAP = initializeOperationFunctionMap(); +const BUILT_IN_OPERATION_NAMES = new Set(OPERATION_FUNCTION_MAP.keys()); server.operation = operation; export type OperationDefinition = { @@ -175,6 +181,8 @@ export type OperationDefinition = { execute: (operation: any) => any | Promise; httpMethod?: 'DELETE' | 'GET' | 'HEAD' | 'OPTIONS' | 'PATCH' | 'POST' | 'PUT' | 'TRACE'; // method to use for REST isJob?: boolean; + /** JSON Schema for protocol introspection; use this instead of legacy REST parametersSchema for MCP tools. */ + inputSchema?: object; parametersSchema?: any[]; // When set, the operation declares its authorization requirement to the central verifyPerms // system so it participates in the role `operations` allowlist (grantable to a scoped role) @@ -198,7 +206,7 @@ const declaredPermissionNames = new Set(); server.registerOperation = (operationDefinition: OperationDefinition) => { // A throwaway deploy-validation load must not register (or announce) operations onto the live worker. if (isDeployValidating()) return; - const { name, execute, requiresSuperUser } = operationDefinition; + const { name, execute, inputSchema, requiresSuperUser } = operationDefinition; let handler = execute; if (requiresSuperUser === undefined) { // A re-registration that drops the flag must also drop the entry the earlier one installed, or @@ -219,12 +227,21 @@ server.registerOperation = (operationDefinition: OperationDefinition) => { opAuth.registerOperationPermission(name, { requiresSu: requiresSuperUser }); declaredPermissionNames.add(name); } - OPERATION_FUNCTION_MAP.set(name as any, new OperationFunctionObject(handler)); + const schemaMetadata = + inputSchema ?? + (!BUILT_IN_OPERATION_NAMES.has(name as any) && Object.hasOwn(OPERATION_INPUT_SCHEMAS, name) + ? OPERATION_INPUT_SCHEMAS[name] + : undefined); + const normalizedSchema = normalizeOperationInputSchema(schemaMetadata); + if (normalizedSchema.error) { + operationLog.warn(`Operation '${name}' inputSchema ignored: ${normalizedSchema.error}`); + } + OPERATION_FUNCTION_MAP.set(name as any, new OperationFunctionObject(handler, undefined, normalizedSchema.schema)); // Components load per-worker, so a registration made there is invisible to the main-thread // ops-API dispatcher (each thread has its own OPERATION_FUNCTION_MAP instance). Announce it // so the main thread can forward calls here (#1736), and can mirror the role-allowlist mark that // registerOperationPermission above made only in this thread's scope. - if (!isMainThread) announceRegisteredOperation(name, requiresSuperUser !== undefined); + if (!isMainThread) announceRegisteredOperation(name, requiresSuperUser !== undefined, normalizedSchema.schema); }; // Register the durable MCP quota policy as a function (see components/mcp/quota.ts). Worker-local, @@ -690,5 +707,9 @@ function initializeOperationFunctionMap(): Map(); + +function getDialect(schemaId: unknown): { key: string; module: string; schemaId?: string } | { error: string } { + if (schemaId === undefined) return { key: 'draft-07', module: 'ajv' }; + if (typeof schemaId !== 'string') return { error: '$schema must be a string' }; + if (/^https?:\/\/json-schema\.org\/draft-0?6\/schema#?$/.test(schemaId)) { + return { key: 'draft-06', module: 'ajv', schemaId: 'http://json-schema.org/draft-06/schema#' }; + } + if (/^https?:\/\/json-schema\.org\/draft-0?7\/schema#?$/.test(schemaId)) { + return { key: 'draft-07', module: 'ajv', schemaId: 'http://json-schema.org/draft-07/schema#' }; + } + if (/^https?:\/\/json-schema\.org\/draft\/2019-09\/schema#?$/.test(schemaId)) { + return { key: '2019-09', module: 'ajv/dist/2019', schemaId: 'https://json-schema.org/draft/2019-09/schema' }; + } + if (/^https?:\/\/json-schema\.org\/draft\/2020-12\/schema#?$/.test(schemaId)) { + return { key: '2020-12', module: 'ajv/dist/2020', schemaId: 'https://json-schema.org/draft/2020-12/schema' }; + } + return { error: `unsupported JSON Schema dialect '${schemaId}'` }; +} + +function getValidator(dialect: { key: string; module: string }): any { + let validator = validators.get(dialect.key); + if (validator) return validator; + const AjvModule = require(dialect.module); + const Ajv = AjvModule.default ?? AjvModule; + validator = new Ajv({ addUsedSchema: false, strict: false, validateSchema: true }); + if (dialect.key === 'draft-06') { + validator.addMetaSchema(require('ajv/dist/refs/json-schema-draft-06.json')); + } + validators.set(dialect.key, validator); + return validator; +} + +export function normalizeOperationInputSchema(inputSchema: unknown): { schema?: object; error?: string } { + if (inputSchema === undefined) return {}; + if (!inputSchema || typeof inputSchema !== 'object' || Array.isArray(inputSchema)) { + return { error: 'inputSchema must be a JSON object' }; + } + + try { + const serialized = JSON.stringify(inputSchema); + if (!serialized) return { error: 'inputSchema must be a JSON object' }; + if (Buffer.byteLength(serialized) > MAX_OPERATION_INPUT_SCHEMA_BYTES) { + return { error: `inputSchema exceeds ${MAX_OPERATION_INPUT_SCHEMA_BYTES} bytes` }; + } + const schema = JSON.parse(serialized); + if (!schema || typeof schema !== 'object' || Array.isArray(schema)) { + return { error: 'inputSchema must be a JSON object' }; + } + if (schema.type !== 'object') return { error: "inputSchema must declare type: 'object'" }; + const dialect = getDialect(schema.$schema); + if ('error' in dialect) return dialect; + const validator = getValidator(dialect); + const schemaToValidate = dialect.schemaId ? { ...schema, $schema: dialect.schemaId } : schema; + if (!validator.validateSchema(schemaToValidate)) { + return { error: validator.errorsText(validator.errors) }; + } + return { schema: schemaToValidate }; + } catch (error) { + return { error: error instanceof Error ? error.message : String(error) }; + } +} diff --git a/unitTests/components/mcp/tools/operations.test.js b/unitTests/components/mcp/tools/operations.test.js index 7bde76b3ca..3cce9b20ab 100644 --- a/unitTests/components/mcp/tools/operations.test.js +++ b/unitTests/components/mcp/tools/operations.test.js @@ -5,6 +5,7 @@ const { DEFAULT_ALLOW, DEFAULT_EXCLUDED, _setOperationFunctionMapForTest, + _setRemoteOperationInputSchemasForTest, _setChooseOperationForTest, _setProcessLocalTransactionForTest, } = require('#src/components/mcp/tools/operations'); @@ -13,8 +14,11 @@ const env = require('#src/utility/environment/environmentManager'); function makeOpMap(entries) { const m = new Map(); - for (const [name, fn] of entries) { - m.set(name, { operation_function: fn ?? (async () => ({ ok: true })) }); + for (const [name, fn, inputSchema = { type: 'object' }] of entries) { + m.set(name, { + operation_function: fn ?? (async () => ({ ok: true })), + inputSchema: inputSchema === null ? undefined : inputSchema, + }); } return m; } @@ -35,6 +39,7 @@ describe('mcp/tools/operations — registration', () => { afterEach(() => { _resetRegistryForTest(); _setOperationFunctionMapForTest(undefined); + _setRemoteOperationInputSchemasForTest(undefined); _setChooseOperationForTest(undefined); _setProcessLocalTransactionForTest(undefined); env.get = originalEnvGet; @@ -243,17 +248,61 @@ describe('mcp/tools/operations — registration', () => { assert.equal(dropTable.annotations?.readOnlyHint, undefined); }); - it('falls back to a permissive schema for ops with no hand-curated entry', () => { + it('does not expose an allowed operation without registered schema metadata', () => { envOverrides.mcp_operations_allow = ['nonstandard_op']; - _setOperationFunctionMapForTest(makeOpMap([['nonstandard_op', null]])); + _setOperationFunctionMapForTest(makeOpMap([['nonstandard_op', null, null]])); registerOperationsTools(); - const tool = getTool('nonstandard_op'); - assert.equal(tool.inputSchema.type, 'object'); - assert.equal(tool.inputSchema.additionalProperties, true); + assert.equal(getTool('nonstandard_op'), undefined); + }); + + it('exposes an allowed custom operation with its registered schema', () => { + const inputSchema = { + type: 'object', + properties: { message: { type: 'string' } }, + required: ['message'], + }; + envOverrides.mcp_operations_allow = ['nonstandard_op']; + _setOperationFunctionMapForTest(makeOpMap([['nonstandard_op', null, inputSchema]])); + registerOperationsTools(); + assert.deepEqual(getTool('nonstandard_op').inputSchema, inputSchema); + }); + + it('exposes an allowed worker-registered operation with its mirrored schema', () => { + const inputSchema = { type: 'object', properties: { message: { type: 'string' } } }; + envOverrides.mcp_operations_allow = ['worker_op']; + _setOperationFunctionMapForTest(new Map()); + _setRemoteOperationInputSchemasForTest([['worker_op', { inputSchema }]]); + registerOperationsTools(); + + assert.deepEqual(getTool('worker_op').inputSchema, inputSchema); + assert.deepEqual( + listTools({ user: SUPER, profile: 'operations', sessionId: 's', limit: 200 }).tools.map(({ name }) => name), + ['worker_op'] + ); + }); + + it('allows an operator to opt a named schema-less operation into the permissive contract', () => { + envOverrides.mcp_operations_allow = ['insert']; + envOverrides.mcp_operations_allowSchemaless = ['insert']; + _setOperationFunctionMapForTest(makeOpMap([['insert', null, null]])); + registerOperationsTools(); + + assert.deepEqual(getTool('insert').inputSchema, { type: 'object' }); + }); + + it('does not let allowSchemaless override conflicting worker schemas', () => { + envOverrides.mcp_operations_allow = ['worker_op']; + envOverrides.mcp_operations_allowSchemaless = ['worker_op']; + _setOperationFunctionMapForTest(new Map()); + _setRemoteOperationInputSchemasForTest([['worker_op', { issue: 'inconsistent' }]]); + registerOperationsTools(); + + assert.equal(getTool('worker_op'), undefined); }); it('exposes hand-curated schemas with required fields', () => { - _setOperationFunctionMapForTest(makeOpMap([['describe_table', null]])); + const { OPERATION_INPUT_SCHEMAS } = require('#src/server/serverHelpers/operationInputSchemas'); + _setOperationFunctionMapForTest(makeOpMap([['describe_table', null, OPERATION_INPUT_SCHEMAS.describe_table]])); registerOperationsTools(); const tool = getTool('describe_table'); assert.equal(tool.inputSchema.type, 'object'); @@ -315,8 +364,14 @@ describe('mcp/tools/operations — registration', () => { // A component registers new ops after registration ran. `list_agents` is // allow-listed by default via `list_*`; `agent_prompt` is opted in explicitly. - opMap.set('list_agents', { operation_function: async () => ({ agents: [] }) }); - opMap.set('agent_prompt', { operation_function: async () => ({ ok: true }) }); + opMap.set('list_agents', { + operation_function: async () => ({ agents: [] }), + inputSchema: { type: 'object' }, + }); + opMap.set('agent_prompt', { + operation_function: async () => ({ ok: true }), + inputSchema: { type: 'object' }, + }); envOverrides.mcp_operations_allow = ['describe_*', 'list_*', 'agent_prompt']; names = listTools({ user: SUPER, profile: 'operations', sessionId: 's', limit: 200 }).tools.map((t) => t.name); @@ -332,7 +387,10 @@ describe('mcp/tools/operations — registration', () => { assert.equal(getTool('agent_prompt'), undefined); // Component registers it and the operator opts it in. - opMap.set('agent_prompt', { operation_function: async () => ({ ok: true }) }); + opMap.set('agent_prompt', { + operation_function: async () => ({ ok: true }), + inputSchema: { type: 'object' }, + }); envOverrides.mcp_operations_allow = ['describe_*', 'agent_prompt']; const tool = getTool('agent_prompt'); @@ -359,7 +417,10 @@ describe('mcp/tools/operations — registration', () => { }); describe('mcp/tools/operations — catalog coverage lint', () => { - const { OPERATION_INPUT_SCHEMAS } = require('#src/components/mcp/tools/schemas/operations'); + const AjvModule = require('ajv'); + const Ajv = AjvModule.default ?? AjvModule; + const { OPERATION_INPUT_SCHEMAS } = require('#src/server/serverHelpers/operationInputSchemas'); + const { AGENT_OPERATION_INPUT_SCHEMAS } = require('#src/agent/operationInputSchemas'); const { OPERATION_DESCRIPTIONS } = require('#src/components/mcp/tools/schemas/operationDescriptions'); const { OPERATIONS_ENUM } = require('#src/utility/hdbTerms'); @@ -382,10 +443,19 @@ describe('mcp/tools/operations — catalog coverage lint', () => { }); it('every DEFAULT_ALLOW operation has an entry in OPERATION_INPUT_SCHEMAS', () => { - const missing = expandedAllow.filter((name) => !(name in OPERATION_INPUT_SCHEMAS)); + const registeredSchemas = { ...OPERATION_INPUT_SCHEMAS, ...AGENT_OPERATION_INPUT_SCHEMAS }; + const missing = expandedAllow.filter((name) => !Object.hasOwn(registeredSchemas, name)); assert.deepEqual(missing, [], `Operations on DEFAULT_ALLOW without an input schema: ${missing.join(', ')}`); }); + it('every built-in operation input schema is valid JSON Schema', () => { + const ajv = new Ajv({ addUsedSchema: false, strict: false, validateSchema: true }); + for (const [name, schema] of Object.entries(OPERATION_INPUT_SCHEMAS)) { + assert.equal(schema.type, 'object', `${name} must declare an object input schema`); + assert.doesNotThrow(() => ajv.compile(schema), `${name} must have a valid JSON Schema`); + } + }); + it('every DEFAULT_ALLOW operation has an entry in OPERATION_DESCRIPTIONS', () => { const missing = expandedAllow.filter((name) => !(name in OPERATION_DESCRIPTIONS)); assert.deepEqual(missing, [], `Operations on DEFAULT_ALLOW without a description: ${missing.join(', ')}`); diff --git a/unitTests/server/serverHelpers/serverUtilities.test.js b/unitTests/server/serverHelpers/serverUtilities.test.js index cb920fae57..185ee0ce33 100644 --- a/unitTests/server/serverHelpers/serverUtilities.test.js +++ b/unitTests/server/serverHelpers/serverUtilities.test.js @@ -33,6 +33,14 @@ describe('Test serverUtilities.js module ', () => { sandbox.restore(); }); + it('attaches every built-in operation schema to its live registry entry', function () { + const { isOperationAllowed } = require('#src/components/mcp/tools/operations'); + for (const [name, operation] of serverUtilities.OPERATION_FUNCTION_MAP) { + if (!isOperationAllowed(name, {})) continue; + assert.ok(operation.inputSchema, `default-allowed operation '${name}' must carry an input schema`); + } + }); + describe(`Test chooseOperation`, function () { it('Nominal path with insert operation.', function () { let test_result; @@ -257,18 +265,87 @@ describe('Test serverUtilities.js module ', () => { const RETRACTED = 'test_cross_thread_retracted_op'; const ZOMBIE = 'test_cross_thread_zombie_op'; const FAILED_SEND = 'test_cross_thread_failed_send_op'; + const SCHEMA = 'test_cross_thread_schema_op'; // Thread-exit tombstones are permanent and process-global, so synthetic ids must be ones the // runtime will never assign to a real worker. const DECLARING_THREAD = 9_000_061; const ROUTING_THREAD = 9_000_062; const DEAD_THREAD = 9_000_071; const SENDER_THREAD = 9_000_081; + const SCHEMA_THREAD = 9_000_091; + const ROLLING_SCHEMA_THREAD = 9_000_092; + const MISSING_SCHEMA_THREAD = 9_000_093; + const REORDERED_SCHEMA_THREAD = 9_000_094; + const INVALID_SCHEMA_THREAD = 9_000_095; after(function () { - for (const op of [GRANTABLE, PLAIN, SHARED, ROLLED, RETRACTED, ZOMBIE, FAILED_SEND]) { + for (const op of [GRANTABLE, PLAIN, SHARED, ROLLED, RETRACTED, ZOMBIE, FAILED_SEND, SCHEMA]) { unregisterWorkerGrantableOperation(op); unregisterGrantableOperation(op); } + manageThreads.notifyThreadExit(SCHEMA_THREAD); + }); + + it('mirrors a worker operation input schema to the main thread', function () { + const inputSchema = { type: 'object', properties: { value: { type: 'string' } } }; + registeredOperations.operationRegisteredHandler({ + message: { name: SCHEMA, inputSchema, originator: SCHEMA_THREAD }, + }); + + assert.deepEqual( + registeredOperations.getRemoteOperationInputSchemas().find(([name]) => name === SCHEMA), + [SCHEMA, { inputSchema }] + ); + }); + + it('withholds a schema while live workers disagree during a rolling deploy', function () { + registeredOperations.operationRegisteredHandler({ + message: { + name: SCHEMA, + inputSchema: { type: 'object', properties: { changed: { type: 'boolean' } } }, + originator: REORDERED_SCHEMA_THREAD, + }, + }); + assert.deepEqual(registeredOperations.getRemoteOperationInputSchema(SCHEMA), { issue: 'inconsistent' }); + + manageThreads.notifyThreadExit(REORDERED_SCHEMA_THREAD); + assert.deepEqual(registeredOperations.getRemoteOperationInputSchema(SCHEMA), { + inputSchema: { type: 'object', properties: { value: { type: 'string' } } }, + }); + }); + + it('treats schemas with different key order as equivalent', function () { + registeredOperations.operationRegisteredHandler({ + message: { + name: SCHEMA, + inputSchema: { properties: { value: { type: 'string' } }, type: 'object' }, + originator: ROLLING_SCHEMA_THREAD, + }, + }); + assert.deepEqual(registeredOperations.getRemoteOperationInputSchema(SCHEMA), { + inputSchema: { type: 'object', properties: { value: { type: 'string' } } }, + }); + manageThreads.notifyThreadExit(ROLLING_SCHEMA_THREAD); + }); + + it('withholds a schema when one live worker has none', function () { + registeredOperations.operationRegisteredHandler({ + message: { name: SCHEMA, originator: MISSING_SCHEMA_THREAD }, + }); + assert.deepEqual(registeredOperations.getRemoteOperationInputSchema(SCHEMA), { issue: 'missing' }); + manageThreads.notifyThreadExit(MISSING_SCHEMA_THREAD); + }); + + it('does not throw when a worker announces an uncanonicalizable schema', function () { + const circular = { type: 'object' }; + circular.self = circular; + assert.doesNotThrow(() => + registeredOperations.operationRegisteredHandler({ + message: { name: SCHEMA, inputSchema: circular, originator: INVALID_SCHEMA_THREAD }, + }) + ); + assert.deepEqual(registeredOperations.getRemoteOperationInputSchema(SCHEMA), { issue: 'missing' }); + manageThreads.notifyThreadExit(INVALID_SCHEMA_THREAD); }); it('makes a worker-announced declared op grantable on the main thread', function () { @@ -1019,6 +1096,110 @@ describe('Test serverUtilities.js module ', () => { assert.notEqual(original.name, 'test_name_pinning_op'); }); + it('stores a validated clone of registered operation inputSchema metadata', function () { + const name = 'test_schema_metadata_op'; + const inputSchema = { type: 'object', properties: { value: { type: 'string' } } }; + server.registerOperation({ name, execute: async () => ({}), inputSchema }); + inputSchema.properties.value.type = 'number'; + + assert.deepEqual(serverUtilities.OPERATION_FUNCTION_MAP.get(name).inputSchema, { + type: 'object', + properties: { value: { type: 'string' } }, + }); + serverUtilities.OPERATION_FUNCTION_MAP.delete(name); + }); + + it('uses the shipped schema when a named operation omits inputSchema', function () { + const { OPERATION_INPUT_SCHEMAS } = require('#src/server/serverHelpers/operationInputSchemas'); + const name = 'get_metrics'; + server.registerOperation({ name, execute: async () => ({}) }); + + assert.deepEqual(serverUtilities.OPERATION_FUNCTION_MAP.get(name).inputSchema, OPERATION_INPUT_SCHEMAS[name]); + assert.notEqual(serverUtilities.OPERATION_FUNCTION_MAP.get(name).inputSchema, OPERATION_INPUT_SCHEMAS[name]); + server.registerOperation({ name, execute: async () => ({}) }); + assert.deepEqual(serverUtilities.OPERATION_FUNCTION_MAP.get(name).inputSchema, OPERATION_INPUT_SCHEMAS[name]); + serverUtilities.OPERATION_FUNCTION_MAP.delete(name); + }); + + it('prefers an explicit inputSchema over the shipped schema', function () { + const name = 'get_metrics'; + const inputSchema = { type: 'object', properties: { custom: { type: 'string' } } }; + server.registerOperation({ name, execute: async () => ({}), inputSchema }); + + assert.deepEqual(serverUtilities.OPERATION_FUNCTION_MAP.get(name).inputSchema, inputSchema); + serverUtilities.OPERATION_FUNCTION_MAP.delete(name); + }); + + it('does not inherit a built-in schema when a component replaces that operation', function () { + const name = 'search_by_value'; + const original = serverUtilities.OPERATION_FUNCTION_MAP.get(name); + server.registerOperation({ name, execute: async () => ({}) }); + + assert.equal(serverUtilities.OPERATION_FUNCTION_MAP.get(name).inputSchema, undefined); + serverUtilities.OPERATION_FUNCTION_MAP.set(name, original); + }); + + it('accepts common JSON Schema dialects on registered operations', function () { + for (const [name, schemaId, canonicalSchemaId] of [ + [ + 'test_draft_06_schema_op', + 'http://json-schema.org/draft-06/schema#', + 'http://json-schema.org/draft-06/schema#', + ], + [ + 'test_draft_07_schema_op', + 'https://json-schema.org/draft-07/schema', + 'http://json-schema.org/draft-07/schema#', + ], + [ + 'test_draft_2019_schema_op', + 'https://json-schema.org/draft/2019-09/schema', + 'https://json-schema.org/draft/2019-09/schema', + ], + [ + 'test_draft_2020_schema_op', + 'https://json-schema.org/draft/2020-12/schema', + 'https://json-schema.org/draft/2020-12/schema', + ], + ]) { + server.registerOperation({ + name, + execute: async () => ({}), + inputSchema: { $schema: schemaId, type: 'object' }, + }); + assert.equal(serverUtilities.OPERATION_FUNCTION_MAP.get(name).inputSchema.$schema, canonicalSchemaId); + serverUtilities.OPERATION_FUNCTION_MAP.delete(name); + } + }); + + it('keeps the operation registered when inputSchema metadata is invalid', function () { + for (const [name, inputSchema] of [ + ['test_invalid_schema_metadata_op', { type: 'not-a-json-schema-type' }], + ['test_missing_object_type_schema_op', { properties: { value: { type: 'string' } } }], + ['test_array_schema_op', { type: 'array', items: { type: 'string' } }], + [ + 'test_untrusted_schema_uri_op', + { type: 'object', $schema: 'https://example.com/json-schema.org/draft-07/schema' }, + ], + ['test_meta_invalid_schema_op', { type: 'object', required: 'value' }], + ['test_oversized_schema_metadata_op', { type: 'object', description: 'x'.repeat(64 * 1024) }], + [ + 'test_circular_schema_metadata_op', + (() => { + const schema = { type: 'object' }; + schema.self = schema; + return schema; + })(), + ], + ]) { + assert.doesNotThrow(() => server.registerOperation({ name, execute: async () => ({}), inputSchema })); + const registered = serverUtilities.OPERATION_FUNCTION_MAP.get(name); + assert.ok(registered); + assert.equal(registered.inputSchema, undefined); + serverUtilities.OPERATION_FUNCTION_MAP.delete(name); + } + }); + it('does not corrupt authz when one handler function is shared across two op names', function () { const shared = async () => ({}); server.registerOperation({ name: 'shared_op_a', execute: shared, requiresSuperUser: true }); diff --git a/unitTests/validation/configValidator.test.js b/unitTests/validation/configValidator.test.js index 2ff8b8d470..9e0749bb9d 100644 --- a/unitTests/validation/configValidator.test.js +++ b/unitTests/validation/configValidator.test.js @@ -786,6 +786,7 @@ describe('Test configValidator module', () => { operations: { mountPath: '/mcp', allow: ['describe_*', 'list_*'], + allowSchemaless: ['insert'], deny: [], maxTools: 200, rateLimit: { diff --git a/utility/hdbTerms.ts b/utility/hdbTerms.ts index 2a1429b13c..e789bbec2c 100644 --- a/utility/hdbTerms.ts +++ b/utility/hdbTerms.ts @@ -604,6 +604,7 @@ export const CONFIG_PARAMS = { OPERATIONSAPI_SYSINFO_DISK: 'operationsApi_sysInfo_disk', MCP_OPERATIONS_MOUNTPATH: 'mcp_operations_mountPath', MCP_OPERATIONS_ALLOW: 'mcp_operations_allow', + MCP_OPERATIONS_ALLOWSCHEMALESS: 'mcp_operations_allowSchemaless', MCP_OPERATIONS_DENY: 'mcp_operations_deny', MCP_OPERATIONS_MAXTOOLS: 'mcp_operations_maxTools', MCP_OPERATIONS_RATELIMIT_PERTOOLPERSECOND: 'mcp_operations_rateLimit_perToolPerSecond', diff --git a/validation/configValidator.ts b/validation/configValidator.ts index 727da8b991..986fd32fb4 100644 --- a/validation/configValidator.ts +++ b/validation/configValidator.ts @@ -145,6 +145,7 @@ export function configValidator(configJson, skipFsValidation = false) { const mcpOperationsSchema = Joi.object({ mountPath: string.optional().default('/mcp'), allow: array.items(string).optional(), + allowSchemaless: array.items(string).optional(), deny: array.items(string).optional(), maxTools: number.min(1).optional(), rateLimit: mcpRateLimitSchema.optional(),