Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions packages/mcp-server/src/server.test.ts
Original file line number Diff line number Diff line change
@@ -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();
}
});
});
150 changes: 129 additions & 21 deletions packages/mcp-server/src/server.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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<string, unknown> }
: {}),
};
}

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' });

Expand All @@ -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) {
Expand All @@ -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);
},
);

Expand Down