From 3aac1ff336416a68162a0f10d57926da67dc8c47 Mon Sep 17 00:00:00 2001 From: "@mrubens" <2600+mrubens@users.noreply.github.com> Date: Sat, 5 Sep 2026 20:23:15 +0000 Subject: [PATCH] test: verify on-demand integration argument schemas and dispatch --- ...n-demand-integrations-registration.test.ts | 55 +++++++++++++++++++ .../fast-agent-native-tool-schemas.test.ts | 33 +++++++++-- 2 files changed, 83 insertions(+), 5 deletions(-) diff --git a/apps/worker/src/mcp/roomote-mcp-server/__tests__/on-demand-integrations-registration.test.ts b/apps/worker/src/mcp/roomote-mcp-server/__tests__/on-demand-integrations-registration.test.ts index fa668cbef..392f2ac0c 100644 --- a/apps/worker/src/mcp/roomote-mcp-server/__tests__/on-demand-integrations-registration.test.ts +++ b/apps/worker/src/mcp/roomote-mcp-server/__tests__/on-demand-integrations-registration.test.ts @@ -1,5 +1,6 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; +import { AjvJsonSchemaValidator } from '@modelcontextprotocol/sdk/validation/ajv'; describe('roomote MCP on-demand integration tool registration', () => { const originalEnv = { ...process.env }; @@ -45,6 +46,22 @@ describe('roomote MCP on-demand integration tool registration', () => { it('exposes integration call args as an object with arbitrary JSON values', async () => { process.env.ROOMOTE_ON_DEMAND_MCP_CATALOG_PATH = '/tmp/catalog.json'; + const integrations = await import('../on-demand-integrations.js'); + const catalog = { + servers: [ + { + name: 'example', + displayName: 'Example', + url: 'https://example.com/mcp', + }, + ], + }; + vi.spyOn(integrations, 'loadOnDemandMcpCatalog').mockReturnValue(catalog); + const dispatch = vi.fn().mockResolvedValue({ content: [] }); + const call = integrations.callOnDemandIntegrationTool; + vi.spyOn(integrations, 'callOnDemandIntegrationTool').mockImplementation( + (servers, params) => call(servers, params, dispatch), + ); const { roomoteMcpServer } = await import('../index.js'); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); @@ -76,6 +93,44 @@ describe('roomote MCP on-demand integration tool registration', () => { expect.objectContaining({ type: 'array' }), ]), ); + + // Compile the complete wire schema so broken recursive refs fail too. + expect(callTool).toBeDefined(); + const validate = new AjvJsonSchemaValidator().getValidator( + callTool!.inputSchema, + ); + const target = { integrationId: 'example', toolName: 'lookup' }; + const nestedArgs = { + url: 'https://example.com/issues/123', + organizationSlug: 'example', + filter: { values: [null, false, 0, '', { nested: [{ value: 'ok' }] }] }, + }; + for (const args of [nestedArgs, {}, null, undefined]) { + const input = { ...target, ...(args === undefined ? {} : { args }) }; + expect(validate(input).valid).toBe(true); + const result = await client.callTool({ + name: 'call_integration_tool', + arguments: input, + }); + expect(result.isError).not.toBe(true); + expect(dispatch).toHaveBeenLastCalledWith( + catalog.servers[0], + target.toolName, + args ?? {}, + ); + } + + dispatch.mockClear(); + for (const args of ['not an object', [], 42, true]) { + const input = { ...target, args }; + expect(validate(input).valid).toBe(false); + const result = await client.callTool({ + name: 'call_integration_tool', + arguments: input, + }); + expect(result.isError).toBe(true); + } + expect(dispatch).not.toHaveBeenCalled(); } finally { await client.close(); await roomoteMcpServer.close(); diff --git a/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-native-tool-schemas.test.ts b/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-native-tool-schemas.test.ts index bf644133a..6fe8ddc86 100644 --- a/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-native-tool-schemas.test.ts +++ b/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-native-tool-schemas.test.ts @@ -4,12 +4,14 @@ import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { pathToFileURL } from 'node:url'; +import type { JsonSchemaType } from '@modelcontextprotocol/sdk/validation'; +import { AjvJsonSchemaValidator } from '@modelcontextprotocol/sdk/validation/ajv'; import { FAST_AGENT_NATIVE_TOOL_NAMES } from '@roomote/types'; import { getFastAgentNativeToolRuntime } from '../fast-agent-native-tool-bridge'; /** - * Guards the JSON schema OpenAI receives for every Fast native tool. + * Guards the generated Fast native tool schemas before provider conversion. * * OpenCode loads each generated tool module with its own zod 4, treats * `args` as a record of field schemas (wrapping it in `z.object`), and ships @@ -17,8 +19,8 @@ import { getFastAgentNativeToolRuntime } from '../fast-agent-native-tool-bridge' * bare schema instead of a record (a `z.union`, say) turns into a schema * carrying zod internals, which OpenAI rejects with * `invalid_function_parameters` on every request, taking down every Fast turn - * on its models. This test mirrors OpenCode's loading so that shape, and any - * other construct OpenAI's validator refuses, fails here first. + * on its models. This test mirrors OpenCode's Zod loading, not provider-side + * normalization or acceptance by a live model endpoint. */ // The JSON Schema vocabulary OpenAI's function-parameter validator accepts @@ -197,7 +199,7 @@ function toOpenCodeJsonSchema(zod: ZodV4, args: unknown) { }); } -describe('Fast native tool schemas as OpenAI receives them', () => { +describe('Fast native tool schemas before provider conversion', () => { let workDir: string; let zod: ZodV4; let tools: LoadedTool[]; @@ -260,7 +262,7 @@ describe('Fast native tool schemas as OpenAI receives them', () => { } }); - it('produces a JSON schema OpenAI accepts for every tool', () => { + it('produces a JSON schema with supported keywords for every tool', () => { const failures: string[] = []; for (const tool of tools) { let schema: unknown; @@ -334,6 +336,27 @@ describe('Fast native tool schemas as OpenAI receives them', () => { expect.objectContaining({ type: 'array' }), ]), ); + + const validate = new AjvJsonSchemaValidator().getValidator( + schema as JsonSchemaType, + ); + const inputSchema = zod.z.object(callTool?.args as Record); + const target = { integrationId: 'example', toolName: 'lookup' }; + const nestedArgs = { + url: 'https://example.com/issues/123', + organizationSlug: 'example', + filter: { values: [null, false, 0, '', { nested: [{ value: 'ok' }] }] }, + }; + for (const args of [nestedArgs, {}, undefined]) { + const input = { ...target, ...(args === undefined ? {} : { args }) }; + expect(validate(input).valid).toBe(true); + expect(inputSchema.parse(input)).toEqual(input); + } + for (const args of ['not an object', [], 42, true, null]) { + const input = { ...target, args }; + expect(validate(input).valid).toBe(false); + expect(inputSchema.safeParse(input).success).toBe(false); + } }); it('rejects a bare union or object as args, the shape that broke OpenAI models', () => {