diff --git a/packages/mcp-server/src/server.test.ts b/packages/mcp-server/src/server.test.ts new file mode 100644 index 0000000..ba2e1a9 --- /dev/null +++ b/packages/mcp-server/src/server.test.ts @@ -0,0 +1,60 @@ +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; +import { describe, expect, it } from 'vitest'; +import { ANONYMOUS_IDENTITY } from './auth.ts'; +import { buildServer } from './server.ts'; + +const data = { + merchants: [], + categoryLabels: { marketplace: 'Marketplace' }, + railsManifest: { rails: [{ id: 'lightning' as const, label: 'Lightning Network' }] }, +}; + +async function connected() { + const server = buildServer(data, { identity: ANONYMOUS_IDENTITY, credential: null }); + const client = new Client({ name: 'mcp-contract-test', version: '1.0.0' }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]); + return { client, server }; +} + +describe('MCP contracts', () => { + it('publishes titles for every tool and strict schemas only for modeled outputs', async () => { + const { client, server } = await connected(); + try { + const listed = await client.listTools(); + expect(listed.tools).toHaveLength(6); + expect(listed.tools.every((tool) => typeof tool.title === 'string')).toBe(true); + expect( + listed.tools + .filter((tool) => tool.outputSchema) + .map((tool) => tool.name) + .sort(), + ).toEqual(['list_categories', 'list_rails', 'whoami']); + for (const tool of listed.tools.filter((entry) => entry.outputSchema)) { + expect(tool.outputSchema?.type).toBe('object'); + expect(tool.outputSchema?.additionalProperties).toBe(false); + } + } finally { + await client.close(); + await server.close(); + } + }); + + it('returns schema-valid structured content alongside the text result', async () => { + const { client, server } = await connected(); + try { + const result = await client.callTool({ name: 'whoami', arguments: {} }); + expect(result.structuredContent).toEqual({ + authenticated: false, + tier_cap: 'anonymous', + limits: { result_cap: 100, requests_per_minute: 30 }, + }); + const content = result.content as Array<{ type: string; text?: string }>; + expect(JSON.parse(content[0]?.text ?? '')).toEqual(result.structuredContent); + } finally { + await client.close(); + await server.close(); + } + }); +}); diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts index ba62211..ae240cd 100644 --- a/packages/mcp-server/src/server.ts +++ b/packages/mcp-server/src/server.ts @@ -1,4 +1,5 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; import type { AgentIdentity } from '@at-directory/core'; import type { DirectoryData } from './bootstrap.ts'; import { toIdentity, type VerifiedCredential } from './auth.ts'; @@ -18,10 +19,90 @@ export interface SessionAuth { credential: VerifiedCredential | null; } -function asJson(value: unknown) { - return { content: [{ type: 'text' as const, text: JSON.stringify(value, null, 2) }] }; +function asJson(value: unknown, structured = false) { + return { + content: [{ type: 'text' as const, text: JSON.stringify(value, null, 2) }], + ...(structured && typeof value === 'object' && value !== null && !Array.isArray(value) + ? { structuredContent: value as Record } + : {}), + }; } +const AgentIdentityOutput = z + .object({ + authenticated: z.boolean(), + tier_cap: z.enum(['anonymous', 'basic', 'elevated', 'premium']), + }) + .strict(); + +const ListCategoriesOutput = z + .object({ + categories: z.array( + z + .object({ + id: z.string(), + label: z.string(), + merchant_count: z.number().int().nonnegative(), + }) + .strict(), + ), + }) + .strict(); + +const ListRailsOutput = z + .object({ + rails: z.array( + z + .object({ + rail: z.enum(['lightning', 'bolt12', 'l402', 'usdt', 'usdc', 'btc', 'fiat']), + label: z.string(), + merchant_count: z.number().int().nonnegative(), + chains: z + .array( + z + .object({ + chain: z.enum([ + 'tron', + 'ethereum', + 'solana', + 'bsc', + 'polygon', + 'arbitrum', + 'base', + ]), + label: z.string(), + merchant_count: z.number().int().nonnegative(), + }) + .strict(), + ) + .optional(), + }) + .strict(), + ), + }) + .strict(); + +const WhoamiOutput = z + .object({ + authenticated: z.boolean(), + tier_cap: AgentIdentityOutput.shape.tier_cap, + limits: z + .object({ + result_cap: z.number().int().nonnegative(), + requests_per_minute: z.number().int().positive(), + }) + .strict(), + credential: z + .object({ + subject_did: z.string(), + issuer: z.string(), + valid_until: z.string().optional(), + }) + .strict() + .optional(), + }) + .strict(); + export function buildServer(data: DirectoryData, auth: SessionAuth): McpServer { const server = new McpServer({ name: 'at-directory', version: '0.0.1' }); @@ -30,43 +111,70 @@ export function buildServer(data: DirectoryData, auth: SessionAuth): McpServer { identity: auth.identity, }; - server.tool( + server.registerTool( 'search_merchants', - 'Search OP-verified merchants by rail, chain, category, agent-callable tier, trust tier, and free text. Ranked by trust tier then verification recency.', - SearchMerchantsArgs.shape, + { + title: 'Search Agent Commerce Merchants', + description: + 'Search OP-verified merchants by rail, chain, category, agent-callable tier, trust tier, and free text. Ranked by trust tier then verification recency.', + inputSchema: SearchMerchantsArgs, + }, async (args) => asJson(searchMerchantsTool(SearchMerchantsArgs.parse(args), ctx)), ); - server.tool( + server.registerTool( 'get_merchant', - 'Get the full record for one merchant including all rails, payment endpoints, and OP attestation. Tier 2+ requires an AT credential.', - GetMerchantArgs.shape, + { + title: 'Get Agent Commerce Merchant', + description: + 'Get the full record for one merchant including all rails, payment endpoints, and OP attestation. Anonymous and credentialed callers receive the same read result; credentials affect rate limits, not merchant visibility.', + inputSchema: GetMerchantArgs, + }, async (args) => asJson(getMerchantTool(GetMerchantArgs.parse(args), ctx)), ); - server.tool( + server.registerTool( 'verify_payment_endpoint', - "Run a live check against a merchant's declared payment endpoint for a rail. Returns health, detail, and rail-specific evidence.", - VerifyPaymentEndpointArgs.shape, + { + title: 'Verify Merchant Payment Endpoint', + description: + "Run a live check against a merchant's declared payment endpoint for a rail. Returns health, detail, and rail-specific evidence.", + inputSchema: VerifyPaymentEndpointArgs, + }, async (args) => asJson(await verifyPaymentEndpointTool(VerifyPaymentEndpointArgs.parse(args), ctx)), ); - server.tool('list_categories', 'List the category taxonomy with merchant counts.', {}, async () => - asJson(listCategoriesTool({}, ctx, data.categoryLabels)), + server.registerTool( + 'list_categories', + { + title: 'List Merchant Categories', + description: 'List the category taxonomy with merchant counts.', + inputSchema: z.object({}).strict(), + outputSchema: ListCategoriesOutput, + }, + async () => asJson(listCategoriesTool({}, ctx, data.categoryLabels), true), ); - server.tool( + server.registerTool( 'list_rails', - 'List supported payment rails and their current merchant counts.', - {}, - async () => asJson(listRailsTool({}, ctx, data.railsManifest)), + { + title: 'List Supported Payment Rails', + description: 'List supported payment rails and their current merchant counts.', + inputSchema: z.object({}).strict(), + outputSchema: ListRailsOutput, + }, + async () => asJson(listRailsTool({}, ctx, data.railsManifest), true), ); - server.tool( + server.registerTool( 'whoami', - 'Report the resolved credential state and rate limits for the calling agent.', - {}, + { + title: 'Inspect Agent Directory Session', + description: 'Report the resolved credential state and rate limits for the calling agent.', + inputSchema: z.object({}).strict(), + outputSchema: WhoamiOutput, + }, async () => { const wctx: WhoamiContext = { ...ctx }; if (auth.credential) { @@ -76,7 +184,7 @@ export function buildServer(data: DirectoryData, auth: SessionAuth): McpServer { ...(auth.credential.validUntil ? { valid_until: auth.credential.validUntil } : {}), }; } - return asJson(whoamiTool({}, wctx)); + return asJson(whoamiTool({}, wctx), true); }, );