mirror of
https://github.com/Sea-Haven-Industries/sh-mcp.git
synced 2026-10-01 18:43:18 +00:00
116 lines
4.1 KiB
TypeScript
116 lines
4.1 KiB
TypeScript
|
|
/**
|
||
|
|
* MCP conformance + tool-hiding over the protocol (build-plan §5).
|
||
|
|
*
|
||
|
|
* Uses the SDK's in-memory linked transport to drive a real Client against our
|
||
|
|
* `createMcpServer`: handshake, scope-filtered `tools/list`, a successful
|
||
|
|
* `tools/call` round-trip, server-side scope enforcement on a forced hidden
|
||
|
|
* call, and validation that every advertised tool carries a JSON-Schema input.
|
||
|
|
*/
|
||
|
|
|
||
|
|
import { describe, it, expect } from 'vitest';
|
||
|
|
|
||
|
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||
|
|
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
|
||
|
|
|
||
|
|
import { ToolRegistry, defineTool } from './registry.js';
|
||
|
|
import { createMcpServer } from './mcp.js';
|
||
|
|
import { NoopAuditLogger } from './audit.js';
|
||
|
|
import { NoopRateLimiter } from './rate-limit.js';
|
||
|
|
import type { AuthContext, Scope } from './types.js';
|
||
|
|
import type { DispatchDeps } from './dispatch.js';
|
||
|
|
|
||
|
|
const deps: DispatchDeps = {
|
||
|
|
auditLogger: new NoopAuditLogger(),
|
||
|
|
rateLimiter: new NoopRateLimiter(),
|
||
|
|
};
|
||
|
|
|
||
|
|
function buildRegistry(): ToolRegistry {
|
||
|
|
const r = new ToolRegistry();
|
||
|
|
r.register(
|
||
|
|
defineTool<{ id: string }, { id: string; ok: boolean }>({
|
||
|
|
name: 'lookup_thing',
|
||
|
|
description: 'look up a thing',
|
||
|
|
tier: 'ops',
|
||
|
|
requiredScope: 'ops:read',
|
||
|
|
inputSchema: {
|
||
|
|
type: 'object',
|
||
|
|
required: ['id'],
|
||
|
|
properties: { id: { type: 'string' } },
|
||
|
|
},
|
||
|
|
handler: async (input) => ({ id: input.id, ok: true }),
|
||
|
|
}),
|
||
|
|
);
|
||
|
|
r.register(
|
||
|
|
defineTool<{ vendor: string }, unknown>({
|
||
|
|
name: 'lookup_payment',
|
||
|
|
description: 'finance only',
|
||
|
|
tier: 'finance',
|
||
|
|
requiredScope: 'finance:read',
|
||
|
|
inputSchema: { type: 'object', properties: { vendor: { type: 'string' } } },
|
||
|
|
handler: async () => ({ secret: true }),
|
||
|
|
}),
|
||
|
|
);
|
||
|
|
return r;
|
||
|
|
}
|
||
|
|
|
||
|
|
async function connect(scopes: Scope[]): Promise<Client> {
|
||
|
|
const ctx: AuthContext = { sub: 'u@seahavenind.com', scopes, aud: 'sh-mcp-ops' };
|
||
|
|
const server = createMcpServer(buildRegistry(), ctx, deps, {
|
||
|
|
name: 'sh-mcp-test',
|
||
|
|
version: '0.0.1',
|
||
|
|
});
|
||
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||
|
|
await server.connect(serverTransport);
|
||
|
|
const client = new Client({ name: 'test-client', version: '0.0.1' });
|
||
|
|
await client.connect(clientTransport);
|
||
|
|
return client;
|
||
|
|
}
|
||
|
|
|
||
|
|
describe('MCP conformance', () => {
|
||
|
|
it('completes the handshake and lists scope-permitted tools', async () => {
|
||
|
|
const client = await connect(['ops:read']);
|
||
|
|
const { tools } = await client.listTools();
|
||
|
|
const names = tools.map((t) => t.name);
|
||
|
|
expect(names).toContain('lookup_thing');
|
||
|
|
// finance tool is hidden from an ops-only caller.
|
||
|
|
expect(names).not.toContain('lookup_payment');
|
||
|
|
// every advertised tool carries a JSON-Schema input.
|
||
|
|
for (const t of tools) {
|
||
|
|
expect(t.inputSchema).toBeDefined();
|
||
|
|
expect((t.inputSchema as { type?: string }).type).toBe('object');
|
||
|
|
}
|
||
|
|
await client.close();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('round-trips a successful tools/call', async () => {
|
||
|
|
const client = await connect(['ops:read']);
|
||
|
|
const res = await client.callTool({ name: 'lookup_thing', arguments: { id: 'WO-1' } });
|
||
|
|
expect(res.structuredContent).toEqual({ id: 'WO-1', ok: true });
|
||
|
|
await client.close();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('hides finance tools but STILL enforces scope on a forced call (hiding is not the boundary)', async () => {
|
||
|
|
const client = await connect(['ops:read']);
|
||
|
|
await expect(
|
||
|
|
client.callTool({ name: 'lookup_payment', arguments: { vendor: 'x' } }),
|
||
|
|
).rejects.toThrow();
|
||
|
|
await client.close();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('rejects malformed input through the protocol', async () => {
|
||
|
|
const client = await connect(['ops:read']);
|
||
|
|
await expect(
|
||
|
|
client.callTool({ name: 'lookup_thing', arguments: { wrong: 'field' } }),
|
||
|
|
).rejects.toThrow();
|
||
|
|
await client.close();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('reveals finance tools to a finance caller', async () => {
|
||
|
|
const client = await connect(['finance:read']);
|
||
|
|
const names = (await client.listTools()).tools.map((t) => t.name);
|
||
|
|
expect(names).toContain('lookup_payment');
|
||
|
|
expect(names).not.toContain('lookup_thing');
|
||
|
|
await client.close();
|
||
|
|
});
|
||
|
|
});
|