Files
SnowLuma/packages/mcp/tests/protocol-versions.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

638 lines
25 KiB
TypeScript

import { spawnSync } from 'node:child_process';
import { copyFileSync, mkdtempSync, readFileSync, readdirSync, rmSync, symlinkSync } from 'node:fs';
import { createServer } from 'node:http';
import { once } from 'node:events';
import { createRequire } from 'node:module';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
import { StdioClientTransport } from '@modelcontextprotocol/client/stdio';
import { createMcpHandler } from '@modelcontextprotocol/server';
import { Client as LegacyClient } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport as LegacyStdioTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
import { createMcpServer } from '../src/server-factory';
import type { ActionClient } from '../src/client';
import type { Mode } from '../src/tools';
import { buildMessageApp } from '../scripts/build-message-app.mjs';
const REVISION = '2026-07-28';
const URI = 'snowluma://onebot/actions';
const root = fileURLToPath(new URL('..', import.meta.url));
const cleanup: Array<() => Promise<void>> = [];
let build: string;
beforeAll(async () => {
build = mkdtempSync(path.join(tmpdir(), 'snowluma-mcp-protocol-'));
copyFileSync(path.join(root, 'package.json'), path.join(build, 'package.json'));
symlinkSync(path.join(root, 'node_modules'), path.join(build, 'node_modules'), 'junction');
const compiler = createRequire(import.meta.url).resolve('typescript/bin/tsc');
const result = spawnSync(
process.execPath,
[
compiler,
'--project',
path.join(root, 'tsconfig.json'),
'--outDir',
path.join(build, 'dist'),
],
{ encoding: 'utf8', timeout: 30_000 },
);
if (result.error) {
throw result.error;
}
if (result.status !== 0) {
throw new Error(`MCP compilation failed:\n${result.stdout}\n${result.stderr}`);
}
await buildMessageApp(path.join(build, 'dist'));
}, 30_000);
afterEach(async () => {
for (const close of cleanup.splice(0).reverse()) {
await close();
}
vi.restoreAllMocks();
});
afterAll(() => {
if (build) {
rmSync(build, { recursive: true, force: true });
}
});
function fakeOneBot(): ActionClient {
return {
call: vi.fn(async () => ({ status: 'ok', retcode: 0, data: { message_id: -42 } })),
callStream: vi.fn(async (_action, _params, options) => {
await options?.onProgress?.({ progress: 0, message: 'start' });
await options?.onProgress?.({ progress: 5, total: 5, message: 'done' });
return { frame_count: 1, frames: [], terminal: { status: 'ok', retcode: 0 } };
}),
};
}
function handler(mode: Mode = 'docs', onebot?: ActionClient) {
const instance = createMcpHandler(() => createMcpServer({ mode, client: onebot }));
cleanup.push(() => instance.close());
return instance;
}
async function request(
instance: ReturnType<typeof handler>,
method: string,
params: Record<string, unknown> = {},
version = REVISION,
) {
const response = await instance.fetch(
new Request('http://mcp.invalid/mcp', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream',
'Mcp-Method': method,
'Mcp-Protocol-Version': version,
...(typeof params.name === 'string'
? { 'Mcp-Name': params.name }
: typeof params.uri === 'string'
? { 'Mcp-Name': params.uri }
: {}),
},
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method,
params: {
...params,
_meta: {
'io.modelcontextprotocol/protocolVersion': version,
'io.modelcontextprotocol/clientCapabilities': {},
'io.modelcontextprotocol/clientInfo': {
name: 'protocol-test',
version: '1.0.0',
},
},
},
}),
}),
);
return { status: response.status, body: await response.json() };
}
async function connect(mode: Mode = 'write', onebot = fakeOneBot()) {
const instance = handler(mode, onebot);
const client = new Client(
{ name: 'protocol-test', version: '1.0.0' },
{
versionNegotiation: { mode: { pin: REVISION } },
},
);
const transport = new StreamableHTTPClientTransport(new URL('http://mcp.invalid/mcp'), {
fetch: (input, init) => instance.fetch(new Request(input, init)),
});
cleanup.push(() => client.close());
await client.connect(transport);
return client;
}
describe('modern protocol and cache policy', () => {
it('discovers and calls tools without initialize, with versioned results', async () => {
const instance = handler();
const discover = await request(instance, 'server/discover');
expect(discover.status).toBe(200);
expect(discover.body).toMatchObject({
result: {
resultType: 'complete',
capabilities: { tools: {}, resources: {} },
},
});
expect(JSON.stringify(discover.body.result)).toContain(REVISION);
const result = await request(instance, 'tools/call', {
name: 'get_action',
arguments: { name: 'get_status' },
});
expect(result.body.result).toMatchObject({
resultType: 'complete',
structuredContent: { diagnostics: { outcome: 'success' } },
});
expect(JSON.parse(result.body.result.content[0].text).name).toBe('get_status');
expect(result.body.result._meta['io.modelcontextprotocol/serverInfo'].name).toBe(
'snowluma-mcp',
);
});
it('rejects unsupported versions before dispatching any action', async () => {
const onebot = fakeOneBot();
const result = await request(
handler('write', onebot),
'tools/call',
{
name: 'invoke_action',
arguments: { action: 'send_group_msg', params: { group_id: 1, message: 'test' } },
},
'2999-01-01',
);
expect(result.body.error.code).toBe(-32022);
expect(onebot.call).not.toHaveBeenCalled();
});
it('caches public catalog resources but keeps mode-dependent tools private', async () => {
const instance = handler();
for (const [method, params, scope] of [
['tools/list', {}, 'private'],
['resources/list', {}, 'private'],
['resources/read', { uri: URI }, 'public'],
['resources/templates/list', {}, 'public'],
] as const) {
const { body } = await request(instance, method, params);
expect(body.result).toMatchObject({
resultType: 'complete',
ttlMs: 300_000,
cacheScope: scope,
});
}
const { body } = await request(instance, 'tools/call', { name: 'get_runtime_info' });
expect(body.result).not.toHaveProperty('ttlMs');
expect(body.result).not.toHaveProperty('cacheScope');
expect(body.result.structuredContent.result.connection_checked).toBe(false);
});
it.each(['docs', 'read', 'write'] as const)(
'keeps execution gates in %s mode',
async (mode) => {
const onebot = fakeOneBot();
const client = await connect(mode, onebot);
expect(client.getProtocolEra()).toBe('modern');
const listed = (await client.listTools()).tools.map((tool) => tool.name);
expect(listed.includes('invoke_action')).toBe(mode === 'write');
const result = await client.callTool({
name: 'invoke_action',
arguments: { action: 'send_group_msg', params: { group_id: 1, message: 'test' } },
});
expect(result.isError === true).toBe(mode !== 'write');
expect(onebot.call).toHaveBeenCalledTimes(mode === 'write' ? 1 : 0);
if (mode === 'write') {
expect(result.structuredContent).toMatchObject({
result: { data: { message_id: -42 } },
});
}
},
);
it('preserves business-error envelopes and unknown-tool errors', async () => {
const onebot = fakeOneBot();
const failure = { status: 'failed', retcode: 1400, data: null, wording: 'rejected' };
onebot.call = vi.fn(async () => failure);
const client = await connect('write', onebot);
const result = await client.callTool({
name: 'query_action',
arguments: { action: 'get_status' },
});
expect(result.isError).toBeUndefined();
expect(result.structuredContent).toMatchObject({
result: failure,
diagnostics: { outcome: 'failed', kind: 'business' },
});
expect(
JSON.parse(result.content[0]!.type === 'text' ? result.content[0]!.text : ''),
).toEqual(failure);
const missing = await client.callTool({ name: 'not_a_tool' });
expect(missing).toMatchObject({
isError: true,
structuredContent: { diagnostics: { kind: 'not_found' } },
});
});
it('correlates modern calls without exposing caller content in diagnostics', async () => {
const log = vi.spyOn(console, 'error').mockImplementation(() => undefined);
const onebot = fakeOneBot();
const client = await connect('write', onebot);
const parent = '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01';
const result = await client.callTool({
name: 'invoke_action',
arguments: {
action: 'send_group_msg',
params: { group_id: 1, message: 'private-body' },
},
_meta: {
traceparent: parent,
tracestate: 'vendor=private-state',
baggage: 'private-baggage',
},
});
const span = result._meta?.traceparent;
expect(span).toMatch(/^00-4bf92f3577b34da6a3ce929d0e0e4736-[0-9a-f]{16}-01$/);
expect(span).not.toBe(parent);
expect(onebot.call).toHaveBeenCalledWith(
'send_group_msg',
{ group_id: 1, message: 'private-body' },
expect.objectContaining({
trace: { traceparent: span, tracestate: 'vendor=private-state' },
}),
);
const entries = log.mock.calls.map(([, entry]) => JSON.parse(String(entry)));
expect(entries).toEqual([
{ event: 'call_started', tool: 'invoke_action', traceparent: span },
{
event: 'call_finished',
tool: 'invoke_action',
traceparent: span,
outcome: 'success',
duration_ms: expect.any(Number),
},
]);
expect(JSON.stringify(log.mock.calls)).not.toMatch(
/private-body|private-state|private-baggage/,
);
});
it('reports stream progress', async () => {
const onebot = fakeOneBot();
const client = await connect('write', onebot);
const progress: number[] = [];
await client.callTool(
{ name: 'invoke_action', arguments: { action: 'test_download_stream' } },
{
onprogress: (update) => {
progress.push(update.progress);
},
},
);
expect(progress).toEqual([0, 5]);
});
it.each(['cancel', 'disconnect'] as const)(
'propagates %s without retrying and records the cancellation',
async (termination) => {
const onebot = fakeOneBot();
const client = await connect('write', onebot);
const started = Promise.withResolvers<void>();
const aborted = Promise.withResolvers<void>();
const diagnosed = Promise.withResolvers<void>();
vi.spyOn(console, 'error').mockImplementation((_prefix, serialized) => {
const entry = JSON.parse(String(serialized));
if (entry.event === 'call_cancelled') {
diagnosed.resolve();
}
});
onebot.call = vi.fn(
async (_action, _params, options) =>
new Promise((_resolve, reject) => {
options?.signal?.addEventListener(
'abort',
() => {
aborted.resolve();
reject(options.signal?.reason);
},
{ once: true },
);
started.resolve();
}),
);
const controller = new AbortController();
const outcome = client
.callTool(
{ name: 'query_action', arguments: { action: 'get_status' } },
{ signal: controller.signal },
)
.catch((error) => error);
await started.promise;
if (termination === 'cancel') {
controller.abort();
} else {
await client.close();
}
expect(await outcome).toBeInstanceOf(Error);
await aborted.promise;
await diagnosed.promise;
expect(onebot.call).toHaveBeenCalledOnce();
},
);
});
describe('published stdio entry', () => {
// These subprocesses execute freshly compiled output, never a workspace dist
// file or a live account. Explicit docs mode ignores any inherited endpoint.
const parameters = () => ({
command: process.execPath,
args: [path.join(build, 'dist/server.js')],
env: { SNOWLUMA_MCP_MODE: 'docs' },
stderr: 'pipe' as const,
});
it('rejects invalid event configuration before reporting ready or acquiring a task lease', () => {
const directory = mkdtempSync(path.join(tmpdir(), 'snowluma-mcp-invalid-config-'));
cleanup.push(async () => rmSync(directory, { recursive: true, force: true }));
const result = spawnSync(process.execPath, parameters().args, {
encoding: 'utf8',
timeout: 5000,
env: {
...process.env,
SNOWLUMA_MCP_MODE: 'write',
SNOWLUMA_MCP_ENDPOINT: 'http://127.0.0.1:1/',
SNOWLUMA_MCP_EVENTS_URL: 'https://invalid.example/',
SNOWLUMA_MCP_TASK_DIR: path.join(directory, 'tasks'),
},
});
expect(result.error).toBeUndefined();
expect(result.status).toBe(1);
expect(result.stderr).toContain('SNOWLUMA_MCP_EVENTS_URL must use ws/wss');
expect(result.stderr).not.toContain('ready');
expect(readdirSync(directory)).toEqual([]);
});
it('still serves the v1 client using its existing initialize handshake', async () => {
const client = new LegacyClient({ name: 'legacy-test', version: '1.0.0' });
const transport = new LegacyStdioTransport(parameters());
const send = vi.spyOn(transport, 'send');
cleanup.push(() => client.close());
await client.connect(transport);
expect(
send.mock.calls.some(
([message]) => 'method' in message && message.method === 'initialize',
),
).toBe(true);
const tools = await client.listTools();
expect(tools.tools.map((tool) => tool.name)).toContain('get_action');
expect(tools).not.toHaveProperty('ttlMs');
expect(tools).not.toHaveProperty('cacheScope');
expect(tools).not.toHaveProperty('resultType');
const result = await client.callTool({
name: 'get_action',
arguments: { name: 'get_status' },
});
expect(result.isError).toBeUndefined();
expect(result).not.toHaveProperty('resultType');
expect(result).not.toHaveProperty('ttlMs');
});
it('serves a modern client from the same command without initialize', async () => {
const client = new Client(
{ name: 'modern-test', version: '1.0.0' },
{
versionNegotiation: { mode: { pin: REVISION } },
capabilities: {
extensions: {
'io.modelcontextprotocol/ui': { mimeTypes: ['text/html;profile=mcp-app'] },
},
},
},
);
const transport = new StdioClientTransport(parameters());
const send = vi.spyOn(transport, 'send');
cleanup.push(() => client.close());
await client.connect(transport);
expect(client.getProtocolEra()).toBe('modern');
expect(
send.mock.calls.some(
([message]) => 'method' in message && message.method === 'initialize',
),
).toBe(false);
const result = await client.callTool({
name: 'get_action',
arguments: { name: 'get_status' },
});
expect(result.isError).toBeUndefined();
expect(result.structuredContent).toMatchObject({ diagnostics: { outcome: 'success' } });
expect((await client.readResource({ uri: URI })).contents).toHaveLength(1);
const app = await client.readResource({ uri: 'ui://snowluma/message-preview.html' });
expect(app.contents[0]).toMatchObject({
mimeType: 'text/html;profile=mcp-app',
text: expect.stringContaining('消息草稿预览'),
});
});
it.each(['eof', 'SIGTERM'] as const)(
'persists tasks across CLI restart after %s without replaying transfers',
async (shutdown) => {
const directory = mkdtempSync(path.join(tmpdir(), 'snowluma-mcp-cli-tasks-'));
cleanup.push(async () => rmSync(directory, { recursive: true, force: true }));
const requests: unknown[] = [];
const pendingStarted = Promise.withResolvers<void>();
const endpoint = createServer(async (req, res) => {
const chunks: Buffer[] = [];
for await (const chunk of req) {
chunks.push(Buffer.from(chunk));
}
const body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
requests.push(body);
res.setHeader('content-type', 'application/json');
if (body.params.pending) {
res.write(
`${JSON.stringify({ status: 'ok', retcode: 0, data: { type: 'stream', data: 'started' } })}\r\n\r\n`,
);
pendingStarted.resolve();
} else {
res.end(
`${JSON.stringify({ status: 'ok', retcode: 0, data: { type: 'response', data: 'complete' } })}\r\n\r\n`,
);
}
});
endpoint.listen(0, '127.0.0.1');
await once(endpoint, 'listening');
cleanup.push(async () => {
endpoint.closeAllConnections();
await new Promise<void>((resolve, reject) =>
endpoint.close((error) => (error ? reject(error) : resolve())),
);
});
const address = endpoint.address();
if (!address || typeof address === 'string') {
throw new Error('Missing isolated HTTP address');
}
const taskDirectory = path.join(directory, 'tasks');
const open = async () => {
const transport = new StdioClientTransport({
...parameters(),
env: {
SNOWLUMA_MCP_MODE: 'write',
SNOWLUMA_MCP_ENDPOINT: `http://127.0.0.1:${address.port}/`,
SNOWLUMA_MCP_TOKEN: 'private-test-token',
SNOWLUMA_MCP_TASK_DIR: taskDirectory,
SNOWLUMA_MCP_STREAM_DIR: path.join(directory, 'downloads'),
},
});
cleanup.push(() => transport.close());
const waiting = new Map<
number,
ReturnType<typeof Promise.withResolvers<Record<string, unknown>>>
>();
let id = 0;
const closed = Promise.withResolvers<void>();
let diagnostic = '';
transport.stderr?.on('data', (chunk) => {
diagnostic += String(chunk);
});
transport.onmessage = (message) => {
if ('id' in message && typeof message.id === 'number') {
const result = waiting.get(message.id);
waiting.delete(message.id);
if ('error' in message) {
result?.reject(new Error(JSON.stringify(message.error)));
} else if ('result' in message) {
result?.resolve(message.result);
}
}
};
transport.onclose = () => {
for (const promise of waiting.values()) {
promise.reject(new Error(`CLI closed: ${diagnostic}`));
}
waiting.clear();
closed.resolve();
};
await transport.start();
const request = async (method: string, params: Record<string, unknown> = {}) => {
const result = Promise.withResolvers<Record<string, unknown>>();
const requestId = ++id;
waiting.set(requestId, result);
await transport.send({
jsonrpc: '2.0',
id: requestId,
method,
params: {
...params,
_meta: {
'io.modelcontextprotocol/protocolVersion': REVISION,
'io.modelcontextprotocol/clientCapabilities': {
extensions: { 'io.modelcontextprotocol/tasks': {} },
},
},
},
});
return result.promise;
};
await request('server/discover');
return {
request,
close: async (signal?: 'SIGTERM') => {
if (signal) {
if (transport.pid === null) {
throw new Error('Missing CLI process');
}
process.kill(transport.pid, signal);
await closed.promise;
} else {
await transport.close();
}
},
};
};
const first = await open();
const handle = await first.request('tools/call', {
name: 'invoke_action_background',
arguments: { action: 'test_download_stream' },
});
expect(handle).toMatchObject({ resultType: 'task', status: 'working', ttlMs: null });
const completedId = handle.taskId;
if (typeof completedId !== 'string') {
throw new Error('Missing task identifier');
}
await vi.waitFor(async () => {
expect(await first.request('tasks/get', { taskId: completedId })).toMatchObject({
status: 'completed',
result: {
resultType: 'complete',
structuredContent: { diagnostics: { outcome: 'success' } },
},
});
});
const interrupted = await first.request('tools/call', {
name: 'invoke_action_background',
arguments: { action: 'test_download_stream', params: { pending: true } },
});
await pendingStarted.promise;
await first.close(shutdown === 'SIGTERM' ? shutdown : undefined);
expect(readdirSync(taskDirectory).filter((file) => file.startsWith('.lease-'))).toEqual(
[],
);
const second = await open();
expect(await second.request('tasks/get', { taskId: completedId })).toMatchObject({
status: 'completed',
});
expect(await second.request('tasks/get', { taskId: interrupted.taskId })).toMatchObject(
{
status: 'failed',
statusMessage: expect.stringContaining('unknown'),
},
);
expect(requests).toHaveLength(2);
const persisted = readdirSync(taskDirectory)
.map((file) => readFileSync(path.join(taskDirectory, file), 'utf8'))
.join('\n');
expect(persisted).not.toContain('private-test-token');
await second.close();
expect(readdirSync(taskDirectory).filter((file) => file.startsWith('.lease-'))).toEqual(
[],
);
},
);
});