Files
SnowLuma/packages/mcp/tests/subscription-protocol.test.ts
T
TanTan fa9c73363d
Dev Build / cleanup (push) Waiting to run
Dev Build / typecheck (push) Waiting to run
Publish MCP to npm / Build, test, and publish MCP (push) Waiting to run
Publish SDK to npm / Build, test, and publish SDK (push) Waiting to run
Sync action catalog to docs / sync (push) Waiting to run
Publish UI to npm / Build and publish UI (push) Waiting to run
Build and Release / preflight (push) Waiting to run
SnowLuma v2.4.2 source snapshot
2026-11-13 09:02:00 +08:00

234 lines
9.2 KiB
TypeScript

import { once } from 'node:events';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { Client, InMemoryTransport } from '@modelcontextprotocol/client';
import { serveStdio } from '@modelcontextprotocol/server/stdio';
import { Client as LegacyClient } from '@modelcontextprotocol/sdk/client/index.js';
import { ResourceUpdatedNotificationSchema } from '@modelcontextprotocol/sdk/types.js';
import { WebSocketServer, type WebSocket } from 'ws';
import { createMcpServer } from '../src/server-factory';
import type { ActionClient } from '../src/client';
const cleanup: Array<() => void | Promise<void>> = [];
const SELF = 10001;
afterEach(async () => {
for (const close of cleanup.splice(0).reverse()) {
await close();
}
vi.restoreAllMocks();
});
async function onebot() {
const wss = new WebSocketServer({ host: '127.0.0.1', port: 0 });
const sockets = new Set<WebSocket>();
wss.on('connection', (socket) => {
sockets.add(socket);
socket.on('close', () => sockets.delete(socket));
socket.send(
JSON.stringify({
self_id: SELF,
post_type: 'meta_event',
meta_event_type: 'lifecycle',
}),
);
});
await once(wss, 'listening');
cleanup.push(async () => {
for (const socket of sockets) {
socket.terminate();
}
await new Promise<void>((resolve) => wss.close(() => resolve()));
});
const address = wss.address();
if (!address || typeof address === 'string') {
throw new Error('Test listener failed');
}
return {
url: `ws://127.0.0.1:${address.port}/event`,
sockets,
send(user = 30001) {
for (const socket of sockets) {
socket.send(
JSON.stringify({
self_id: SELF,
post_type: 'message',
message_type: 'private',
user_id: user,
message_id: -42,
message: [{ type: 'text', data: { text: 'fixture' } }],
}),
);
}
},
};
}
async function connect<T extends Client | LegacyClient>(
client: T,
url: string,
mode: 'read' | 'docs' = 'read',
) {
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
const http: ActionClient = {
call: vi.fn(async () => ({ status: 'ok', retcode: 0 })),
callStream: vi.fn(),
};
const handle = serveStdio(
() => createMcpServer({ mode, client: http, events: { url, timeoutMs: 2000 } }),
{ transport: serverTransport },
);
cleanup.push(
() => handle.close(),
() => client.close(),
);
await client.connect(clientTransport);
return { client, serverTransport, http };
}
function resourceData(result: { contents: Array<{ text?: unknown }> }) {
const text = result.contents[0]?.text;
if (typeof text !== 'string') {
throw new Error('Expected JSON resource data');
}
return JSON.parse(text);
}
describe('event resources over actual MCP subscription routing', () => {
it('uses modern listen filters, private uncached reads, explicit resource cleanup, and session teardown', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
const source = await onebot();
const { client, http } = await connect(
new Client(
{ name: 'events-test', version: '1' },
{ versionNegotiation: { mode: { pin: '2026-07-28' } } },
),
source.url,
);
const available = await client.listTools();
expect(available.tools.map((tool) => tool.name)).toContain('subscribe_events');
const first = await client.callTool({
name: 'subscribe_events',
arguments: { self_id: SELF, user_ids: [30001] },
});
const second = await client.callTool({
name: 'subscribe_events',
arguments: { self_id: SELF, user_ids: [30002] },
});
expect(first.isError).toBeUndefined();
expect(second.isError).toBeUndefined();
const firstUri = (first.structuredContent as { result: { uri: string } }).result.uri;
const secondUri = (second.structuredContent as { result: { uri: string } }).result.uri;
const updates: string[] = [];
client.setNotificationHandler('notifications/resources/updated', (notification) => {
updates.push(notification.params.uri);
});
const listening = await client.listen({ resourceSubscriptions: [firstUri] });
expect(listening.honoredFilter.resourceSubscriptions).toEqual([firstUri]);
source.send(30001);
source.send(30002);
await vi.waitFor(() => expect(updates).toContain(firstUri));
expect(updates).not.toContain(secondUri);
const firstRead = await client.readResource({ uri: firstUri });
expect(firstRead).toMatchObject({ ttlMs: 0, cacheScope: 'private' });
expect(resourceData(firstRead).events).toMatchObject([
{ event: { message_id: -42, user_id: 30001 } },
]);
source.send(30001);
await vi.waitFor(async () =>
expect(
resourceData(await client.readResource({ uri: `${firstUri}?after=1` })).next_cursor,
).toBe(2),
);
const list = await client.listResources();
expect(list).toMatchObject({ ttlMs: 0, cacheScope: 'private' });
expect(list.resources.map((resource) => resource.uri)).toContain(secondUri);
const catalog = await client.readResource({ uri: 'snowluma://onebot/actions' });
expect(catalog).toMatchObject({ ttlMs: 300_000, cacheScope: 'public' });
await listening.close();
expect(await listening.closed).toBe('local');
const before = updates.length;
source.send(30001);
await vi.waitFor(async () =>
expect(resourceData(await client.readResource({ uri: firstUri })).next_cursor).toBe(3),
);
expect(updates).toHaveLength(before);
const stopped = await client.callTool({
name: 'unsubscribe_events',
arguments: { uri: firstUri },
});
expect(stopped.isError).toBeUndefined();
await expect(client.readResource({ uri: firstUri })).rejects.toThrow('not found');
expect(source.sockets.size).toBe(1);
expect(http.call).not.toHaveBeenCalled();
await client.close();
await vi.waitFor(() => expect(source.sockets.size).toBe(0));
});
it('requires legacy per-resource subscription before sending updates and releases on unsubscribe', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
const source = await onebot();
const { client } = await connect(
new LegacyClient({ name: 'legacy-events-test', version: '1' }),
source.url,
);
const updates: string[] = [];
client.setNotificationHandler(ResourceUpdatedNotificationSchema, (notification) => {
updates.push(notification.params.uri);
});
const created = await client.callTool({
name: 'subscribe_events',
arguments: { self_id: SELF },
});
expect(created.isError).toBeUndefined();
const uri = (created.structuredContent as { result: { uri: string } }).result.uri;
source.send();
await vi.waitFor(async () =>
expect(resourceData(await client.readResource({ uri })).next_cursor).toBe(1),
);
expect(updates).toEqual([]);
await expect(client.subscribeResource({ uri: `${uri}?after=0` })).rejects.toThrow(
'canonical',
);
await client.subscribeResource({ uri });
source.send();
await vi.waitFor(() => expect(updates).toContain(uri));
const read = await client.readResource({ uri: `${uri}?after=1` });
expect(resourceData(read).events).toHaveLength(1);
expect(read).not.toHaveProperty('ttlMs');
expect(read).not.toHaveProperty('resultType');
await client.unsubscribeResource({ uri });
await vi.waitFor(() => expect(source.sockets.size).toBe(0));
await expect(client.readResource({ uri })).rejects.toThrow('not found');
});
it('keeps docs mode offline even when an event endpoint was supplied', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
const source = await onebot();
const { client, http } = await connect(
new Client(
{ name: 'docs-events-test', version: '1' },
{ versionNegotiation: { mode: { pin: '2026-07-28' } } },
),
source.url,
'docs',
);
expect((await client.listTools()).tools.map((tool) => tool.name)).not.toContain(
'subscribe_events',
);
const called = await client.callTool({
name: 'subscribe_events',
arguments: { self_id: SELF },
});
expect(called.isError).toBe(true);
expect(source.sockets.size).toBe(0);
expect(http.call).not.toHaveBeenCalled();
expect(client.getServerCapabilities()?.resources?.subscribe).toBeUndefined();
});
});