MCP server at POST /mcp (official SDK, stateless Streamable HTTP) (#11)
CI / build + unit (push) Successful in 1m6s
CI / build + unit (push) Successful in 1m6s
This commit was merged in pull request #11.
This commit is contained in:
@@ -0,0 +1,263 @@
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
|
||||
import type * as net from 'node:net';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { BROADCAST_ADDR } from './codec.js';
|
||||
import {
|
||||
KAMIL_NUM,
|
||||
kamilNodeInfo,
|
||||
nodeInfoFrame,
|
||||
startBridge,
|
||||
waitFor,
|
||||
} from './fake-bridge.js';
|
||||
import { createMeshMcpServer } from './mcp.controller.js';
|
||||
import { MeshService } from './mesh.service.js';
|
||||
|
||||
// callTool returns CallToolResult | a legacy compatibility shape; we only ever get the former
|
||||
type ToolOutcome = { content: { type: string; text?: string }[] };
|
||||
|
||||
function toolText(r: Awaited<ReturnType<Client['callTool']>>): string {
|
||||
const c = (r as ToolOutcome).content[0];
|
||||
if (!c || c.type !== 'text') throw new Error('expected a text content block');
|
||||
return c.text ?? '';
|
||||
}
|
||||
|
||||
async function connectedService(): Promise<{
|
||||
svc: MeshService;
|
||||
sockets: net.Socket[];
|
||||
close: () => void;
|
||||
}> {
|
||||
const bridge = await startBridge();
|
||||
process.env.MESH_HOST = '127.0.0.1';
|
||||
process.env.MESH_PORT = String(bridge.port);
|
||||
const svc = new MeshService();
|
||||
svc.onModuleInit();
|
||||
await waitFor(() => bridge.sockets.length === 1 && svc.status().connected);
|
||||
return {
|
||||
svc,
|
||||
sockets: bridge.sockets,
|
||||
close: () => {
|
||||
svc.onModuleDestroy();
|
||||
bridge.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Drive the MCP server over an in-memory transport pair (no HTTP involved)
|
||||
async function connectMcp(svc: MeshService): Promise<{
|
||||
client: Client;
|
||||
close: () => Promise<void>;
|
||||
}> {
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
const server = createMeshMcpServer(svc);
|
||||
await server.connect(serverTransport);
|
||||
const client = new Client({ name: 'mesh-bot-test', version: '0.0.0' });
|
||||
await client.connect(clientTransport);
|
||||
return {
|
||||
client,
|
||||
close: async () => {
|
||||
await client.close();
|
||||
await server.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('MCP server tools', () => {
|
||||
it('lists all six tools with descriptions and input schemas', async () => {
|
||||
const env = await connectedService();
|
||||
try {
|
||||
const mcp = await connectMcp(env.svc);
|
||||
try {
|
||||
const { tools } = await mcp.client.listTools();
|
||||
expect(tools.map((t) => t.name).sort()).toEqual([
|
||||
'get_messages',
|
||||
'get_node',
|
||||
'list_channels',
|
||||
'list_nodes',
|
||||
'mesh_status',
|
||||
'send_message',
|
||||
]);
|
||||
for (const t of tools) expect(t.description?.length).toBeGreaterThan(0);
|
||||
|
||||
const send = tools.find((t) => t.name === 'send_message')!;
|
||||
expect(send.inputSchema.required).toEqual(['text']);
|
||||
expect(Object.keys(send.inputSchema.properties ?? {})).toEqual([
|
||||
'text',
|
||||
'to',
|
||||
]);
|
||||
} finally {
|
||||
await mcp.close();
|
||||
}
|
||||
} finally {
|
||||
env.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('mesh_status returns the bridge connection state', async () => {
|
||||
const env = await connectedService();
|
||||
try {
|
||||
const mcp = await connectMcp(env.svc);
|
||||
try {
|
||||
const r = await mcp.client.callTool({
|
||||
name: 'mesh_status',
|
||||
arguments: {},
|
||||
});
|
||||
expect(r.isError).not.toBe(true);
|
||||
expect(JSON.parse(toolText(r))).toEqual({
|
||||
connected: true,
|
||||
myNodeNum: null,
|
||||
host: '127.0.0.1',
|
||||
port: Number(process.env.MESH_PORT),
|
||||
});
|
||||
} finally {
|
||||
await mcp.close();
|
||||
}
|
||||
} finally {
|
||||
env.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('send_message broadcasts by default and resolves names to node numbers', async () => {
|
||||
const env = await connectedService();
|
||||
try {
|
||||
env.sockets[0].write(nodeInfoFrame(KAMIL_NUM, kamilNodeInfo()));
|
||||
await waitFor(async () => (await env.svc.nodes()).length === 1);
|
||||
|
||||
const mcp = await connectMcp(env.svc);
|
||||
try {
|
||||
const r1 = await mcp.client.callTool({
|
||||
name: 'send_message',
|
||||
arguments: { text: 'hello' },
|
||||
});
|
||||
expect(r1.isError).not.toBe(true);
|
||||
const bcast = JSON.parse(toolText(r1)) as { id: number };
|
||||
expect(bcast.id).toBeGreaterThan(0);
|
||||
expect(env.svc.messages()[0]).toMatchObject({
|
||||
dir: 'tx',
|
||||
to: BROADCAST_ADDR,
|
||||
});
|
||||
|
||||
// name lookup via the node DB (unique substring)
|
||||
const r2 = await mcp.client.callTool({
|
||||
name: 'send_message',
|
||||
arguments: { text: 'dm', to: 'kamil' },
|
||||
});
|
||||
expect(r2.isError).not.toBe(true);
|
||||
expect(env.svc.messages()[0]).toMatchObject({
|
||||
dir: 'tx',
|
||||
to: KAMIL_NUM,
|
||||
});
|
||||
} finally {
|
||||
await mcp.close();
|
||||
}
|
||||
} finally {
|
||||
env.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('send_message surfaces resolution errors as tool errors with the REST messages', async () => {
|
||||
const env = await connectedService();
|
||||
try {
|
||||
const mcp = await connectMcp(env.svc);
|
||||
try {
|
||||
const r1 = await mcp.client.callTool({
|
||||
name: 'send_message',
|
||||
arguments: { text: 'x', to: 'zzz-nope' },
|
||||
});
|
||||
expect(r1.isError).toBe(true);
|
||||
expect(toolText(r1)).toContain("no node matching 'zzz-nope'");
|
||||
|
||||
const r2 = await mcp.client.callTool({
|
||||
name: 'send_message',
|
||||
arguments: { text: 'x', to: '0' },
|
||||
});
|
||||
expect(r2.isError).toBe(true);
|
||||
expect(toolText(r2)).toContain('invalid node number');
|
||||
} finally {
|
||||
await mcp.close();
|
||||
}
|
||||
} finally {
|
||||
env.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('get_node resolves by name and errors when missing', async () => {
|
||||
const env = await connectedService();
|
||||
try {
|
||||
env.sockets[0].write(nodeInfoFrame(KAMIL_NUM, kamilNodeInfo()));
|
||||
await waitFor(async () => (await env.svc.nodes()).length === 1);
|
||||
|
||||
const mcp = await connectMcp(env.svc);
|
||||
try {
|
||||
const r1 = await mcp.client.callTool({
|
||||
name: 'get_node',
|
||||
arguments: { id: 'kamil' },
|
||||
});
|
||||
expect(r1.isError).not.toBe(true);
|
||||
expect(JSON.parse(toolText(r1))).toMatchObject({
|
||||
num: KAMIL_NUM,
|
||||
longName: 'Kamil SP8MK/P',
|
||||
shortName: 'KK',
|
||||
});
|
||||
|
||||
const r2 = await mcp.client.callTool({
|
||||
name: 'get_node',
|
||||
arguments: { id: 'zzz' },
|
||||
});
|
||||
expect(r2.isError).toBe(true);
|
||||
expect(toolText(r2)).toContain("no node matching 'zzz'");
|
||||
} finally {
|
||||
await mcp.close();
|
||||
}
|
||||
} finally {
|
||||
env.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('list_channels, list_nodes and get_messages return the service data', async () => {
|
||||
const env = await connectedService();
|
||||
try {
|
||||
env.sockets[0].write(nodeInfoFrame(KAMIL_NUM, kamilNodeInfo()));
|
||||
await waitFor(async () => (await env.svc.nodes()).length === 1);
|
||||
|
||||
const mcp = await connectMcp(env.svc);
|
||||
try {
|
||||
expect(
|
||||
JSON.parse(
|
||||
toolText(
|
||||
await mcp.client.callTool({
|
||||
name: 'list_channels',
|
||||
arguments: {},
|
||||
}),
|
||||
),
|
||||
),
|
||||
).toEqual([]); // no channel frames fed yet
|
||||
|
||||
const nodes = JSON.parse(
|
||||
toolText(
|
||||
await mcp.client.callTool({ name: 'list_nodes', arguments: {} }),
|
||||
),
|
||||
) as { num: number; longName?: string }[];
|
||||
expect(nodes).toHaveLength(1);
|
||||
expect(nodes[0].num).toBe(KAMIL_NUM);
|
||||
expect(nodes[0].longName).toBe('Kamil SP8MK/P');
|
||||
|
||||
await mcp.client.callTool({
|
||||
name: 'send_message',
|
||||
arguments: { text: 'hi' },
|
||||
});
|
||||
const msgs = JSON.parse(
|
||||
toolText(
|
||||
await mcp.client.callTool({ name: 'get_messages', arguments: {} }),
|
||||
),
|
||||
) as { dir: string; to?: number }[];
|
||||
expect(msgs[0]).toMatchObject({ dir: 'tx', to: BROADCAST_ADDR }); // newest first
|
||||
} finally {
|
||||
await mcp.close();
|
||||
}
|
||||
} finally {
|
||||
env.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user