SnowLuma v2.4.2 source snapshot
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
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
This commit is contained in:
commit
fa9c73363d
1367 files changed
+355735
No files matched your search
@@ -0,0 +1,245 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { AjvJsonSchemaValidator } from '@modelcontextprotocol/sdk/validation/ajv-provider.js';
|
||||
import { collectActionDocs } from '@snowluma/onebot/action-docs';
|
||||
import { ApiHandler, type ApiActionContext } from '@snowluma/onebot/api-handler';
|
||||
import type { JsonObject, JsonValue } from '@snowluma/onebot/types';
|
||||
import { ACTIONS } from '../src/generated/catalog';
|
||||
import { callTool } from '../src/tools';
|
||||
|
||||
const validator = new AjvJsonSchemaValidator();
|
||||
|
||||
function resultValidator(name: string) {
|
||||
const action = ACTIONS.find((entry) => entry.name === name);
|
||||
|
||||
if (!action?.returnsSchema) {
|
||||
throw new Error(`missing result schema: ${name}`);
|
||||
}
|
||||
|
||||
return validator.getValidator(action.returnsSchema);
|
||||
}
|
||||
|
||||
function matchesResult(name: string, data: unknown): void {
|
||||
const result = resultValidator(name)(data);
|
||||
expect(result.valid, `${name}: ${result.errorMessage}`).toBe(true);
|
||||
}
|
||||
|
||||
/** Unexpected dependencies fail rather than contacting an account or a network. */
|
||||
function context(values: Record<string, unknown> = {}): ApiActionContext {
|
||||
return new Proxy(values, {
|
||||
get(target, key) {
|
||||
if (typeof key === 'string' && Object.hasOwn(target, key)) {
|
||||
return target[key];
|
||||
}
|
||||
|
||||
throw new Error(`unstubbed action context: ${String(key)}`);
|
||||
},
|
||||
}) as unknown as ApiActionContext;
|
||||
}
|
||||
|
||||
describe('bundled action contracts', () => {
|
||||
it('matches the owning specs, without relying on a second hand-authored registry', () => {
|
||||
expect(ACTIONS).toEqual(JSON.parse(JSON.stringify(collectActionDocs())));
|
||||
});
|
||||
|
||||
it.each(ACTIONS)('$name publishes compilable schemas and schema-valid examples', (action) => {
|
||||
expect(() => resultValidator(action.name)).not.toThrow();
|
||||
const validateInput = validator.getValidator(action.inputSchema);
|
||||
expect(action.examples?.length).toBeGreaterThan(0);
|
||||
|
||||
for (const example of action.examples ?? []) {
|
||||
const result = validateInput(example.params);
|
||||
expect(result.valid, `${action.name}: ${result.errorMessage}`).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('exposes full contracts through get_action and aliases without executing anything', async () => {
|
||||
const client = { call: vi.fn(), callStream: vi.fn() };
|
||||
|
||||
for (const action of ACTIONS) {
|
||||
for (const name of [action.name, ...action.aliases]) {
|
||||
const result = await callTool('get_action', { name }, { mode: 'docs', client });
|
||||
expect(result.structuredContent.result).toEqual(action);
|
||||
expect(result.content[0]).toEqual({
|
||||
type: 'text',
|
||||
text: JSON.stringify(action, null, 2),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
expect(client.call).not.toHaveBeenCalled();
|
||||
expect(client.callStream).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['list_actions', 'search_actions'])('keeps %s compact', async (name) => {
|
||||
const result = await callTool(name, { query: 'send_group_msg' }, { mode: 'docs' });
|
||||
const block = result.content[0];
|
||||
expect(block.type).toBe('text');
|
||||
|
||||
if (block.type !== 'text') {
|
||||
throw new Error('expected text result');
|
||||
}
|
||||
|
||||
const payload = JSON.parse(block.text);
|
||||
expect(payload.actions.length).toBeGreaterThan(0);
|
||||
|
||||
for (const action of payload.actions) {
|
||||
expect(action).not.toHaveProperty('examples');
|
||||
expect(action).not.toHaveProperty('returnsSchema');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('result schemas against actual OneBot handlers', () => {
|
||||
it('preserves signed message IDs and documents the upload-only sentinel separately', async () => {
|
||||
const api = new ApiHandler(
|
||||
context({
|
||||
sendGroupMessage: async () => ({ messageId: -123 }),
|
||||
sendForwardMsg: async () => ({ forwardId: 'example-forward' }),
|
||||
}),
|
||||
);
|
||||
const send = await api.handle('send_group_msg', { group_id: 20001, message: 'hello' });
|
||||
expect(send).toEqual({ status: 'ok', retcode: 0, data: { message_id: -123 } });
|
||||
matchesResult('send_group_msg', send.data);
|
||||
expect(resultValidator('send_group_msg')({ message_id: 0 }).valid).toBe(false);
|
||||
|
||||
for (const name of ['upload_forward_msg', 'upload_foward_msg']) {
|
||||
const uploaded = await api.handle(name, {
|
||||
group_id: 20001,
|
||||
messages: [
|
||||
{
|
||||
type: 'node',
|
||||
data: { user_id: 10001, nickname: 'example', content: 'hello' },
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(uploaded.status).toBe('ok');
|
||||
expect(uploaded.data).toMatchObject({ message_id: 0, res_id: 'example-forward' });
|
||||
matchesResult(name, uploaded.data);
|
||||
|
||||
if (name === 'upload_forward_msg') {
|
||||
expect(uploaded.data).toHaveProperty('group_id', 20001);
|
||||
} else {
|
||||
expect(uploaded.data).not.toHaveProperty('group_id');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('distinguishes null, empty objects, and failed unsupported calls', async () => {
|
||||
const api = new ApiHandler(
|
||||
context({
|
||||
bridge: {
|
||||
apis: {
|
||||
friend: { setRemark: async () => {} },
|
||||
profile: { setInputStatus: async () => {} },
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
const empty = await api.handle('set_friend_remark', { user_id: 10001, remark: 'example' });
|
||||
expect(empty.data).toBeNull();
|
||||
matchesResult('set_friend_remark', empty.data);
|
||||
expect(resultValidator('set_friend_remark')({}).valid).toBe(false);
|
||||
const object = await api.handle('set_input_status', { user_id: 10001 });
|
||||
expect(object.data).toEqual({});
|
||||
matchesResult('set_input_status', object.data);
|
||||
expect(resultValidator('set_input_status')(null).valid).toBe(false);
|
||||
|
||||
for (const name of ['set_restart', 'create_collection', '.get_word_slices']) {
|
||||
const failure = await api.handle(name, {});
|
||||
expect(failure.status).toBe('failed');
|
||||
expect(resultValidator(name)(failure.data).valid).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('documents actual media string sizes and optional converted record fields', async () => {
|
||||
const media = {
|
||||
file: 'https://example.com/media',
|
||||
url: 'https://example.com/media',
|
||||
file_size: '42',
|
||||
file_name: 'voice.amr',
|
||||
};
|
||||
const api = new ApiHandler(
|
||||
context({
|
||||
getImageInfo: async () => media,
|
||||
getRecordInfo: async () => media,
|
||||
bridge: {
|
||||
apis: {
|
||||
extras: { convertRecord: async () => ({ base64: 'aGVsbG8=', size: 5 }) },
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
for (const name of ['get_image', 'get_file', 'get_record']) {
|
||||
const result = await api.handle(name, { file: 'cached-media' });
|
||||
expect(result.status).toBe('ok');
|
||||
matchesResult(name, result.data);
|
||||
expect(resultValidator(name)({ ...media, file_size: 42 }).valid).toBe(false);
|
||||
}
|
||||
|
||||
const converted = await api.handle('get_record', {
|
||||
file: 'cached-media',
|
||||
out_format: 'mp3',
|
||||
});
|
||||
expect(converted.data).toEqual({ ...media, out_format: 'mp3', base64: 'aGVsbG8=' });
|
||||
matchesResult('get_record', converted.data);
|
||||
});
|
||||
|
||||
it('describes get_msg data without the event envelope for both conversation types', async () => {
|
||||
for (const messageType of ['group', 'private']) {
|
||||
const event: JsonObject = {
|
||||
post_type: 'message',
|
||||
self_id: 10002,
|
||||
message_type: messageType,
|
||||
message_id: -123,
|
||||
user_id: 10001,
|
||||
time: 1700000000,
|
||||
message: [{ type: 'text', data: { text: 'hello' } }],
|
||||
raw_message: 'hello',
|
||||
...(messageType === 'group' ? { group_id: 20001 } : { target_id: 10002 }),
|
||||
};
|
||||
const api = new ApiHandler(
|
||||
context({
|
||||
getMessage: async () => event,
|
||||
refreshMessageMedia: async () => {},
|
||||
}),
|
||||
);
|
||||
const result = await api.handle('get_msg', { message_id: -123 });
|
||||
expect(result.status).toBe('ok');
|
||||
expect(result.data).toMatchObject({ real_id: -123, message_id: -123 });
|
||||
expect(result.data).not.toHaveProperty('post_type');
|
||||
expect(result.data).not.toHaveProperty('self_id');
|
||||
matchesResult('get_msg', result.data);
|
||||
}
|
||||
});
|
||||
|
||||
it('describes successful stream frame data, not MCP aggregate results or failed frames', async () => {
|
||||
const api = new ApiHandler(context());
|
||||
const frames: JsonValue[] = [];
|
||||
const response = await api.handle(
|
||||
'test_download_stream',
|
||||
{ error: false },
|
||||
{
|
||||
send: async (frame) => {
|
||||
frames.push(frame);
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(frames).toHaveLength(10);
|
||||
expect(response.status).toBe('ok');
|
||||
|
||||
for (const frame of [...frames, response.data]) {
|
||||
matchesResult('test_download_stream', frame);
|
||||
}
|
||||
|
||||
expect(
|
||||
resultValidator('test_download_stream')({ frame_count: 11, frames, terminal: response })
|
||||
.valid,
|
||||
).toBe(false);
|
||||
expect(
|
||||
resultValidator('test_download_stream')({ type: 'error', data_type: 'error' }).valid,
|
||||
).toBe(false);
|
||||
const failure = await api.handle('test_download_stream', { error: true });
|
||||
expect(failure.status).toBe('failed');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,275 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { callTool, computeTools } from '../src/tools';
|
||||
import { ACTIONS } from '../src/generated/catalog';
|
||||
import type { ActionClient, OneBotEnvelope } from '../src/client';
|
||||
import { ActionCallError } from '../src/results';
|
||||
|
||||
const docs = { mode: 'docs' as const };
|
||||
|
||||
function client(
|
||||
reply: OneBotEnvelope = { status: 'ok', retcode: 0, data: { message_id: -123 } },
|
||||
): ActionClient {
|
||||
return {
|
||||
call: vi.fn(async () => reply),
|
||||
callStream: vi.fn(async () => ({ frame_count: 1, frames: [], terminal: reply })),
|
||||
};
|
||||
}
|
||||
|
||||
function text(result: Awaited<ReturnType<typeof callTool>>) {
|
||||
expect(result.content[0].type).toBe('text');
|
||||
return result.content[0].type === 'text' ? result.content[0].text : '';
|
||||
}
|
||||
|
||||
describe('additive structured results', () => {
|
||||
it.each(['list_actions', 'list_categories', 'get_action', 'search_actions'])(
|
||||
'keeps the original text payload for %s',
|
||||
async (name) => {
|
||||
const result = await callTool(name, { name: 'get_status', query: 'get_status' }, docs);
|
||||
expect(result.structuredContent.result).toEqual(JSON.parse(text(result)));
|
||||
expect(result.structuredContent.diagnostics).toEqual({ outcome: 'success' });
|
||||
expect(
|
||||
computeTools('docs').find((tool) => tool.name === name)?.outputSchema,
|
||||
).toBeDefined();
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ status: 'ok', retcode: 0, data: { message_id: -123 } },
|
||||
{ status: 'failed', retcode: 1404, wording: 'not found' },
|
||||
{ status: 'async', retcode: 1 },
|
||||
])('preserves the OneBot envelope and isError contract ($status)', async (reply) => {
|
||||
const result = await callTool(
|
||||
'invoke_action',
|
||||
{ action: 'send_group_msg' },
|
||||
{ mode: 'write', client: client(reply) },
|
||||
);
|
||||
expect(text(result)).toBe(JSON.stringify(reply, null, 2));
|
||||
expect(result.isError).toBeUndefined();
|
||||
expect(result.structuredContent.result).toEqual(reply);
|
||||
expect(result.structuredContent.diagnostics.outcome).toBe(
|
||||
reply.status === 'ok' ? 'success' : reply.status === 'failed' ? 'failed' : 'unknown',
|
||||
);
|
||||
});
|
||||
|
||||
it('reports permission failures without contacting OneBot', async () => {
|
||||
const onebot = client();
|
||||
const result = await callTool(
|
||||
'query_action',
|
||||
{ action: 'send_group_msg' },
|
||||
{ mode: 'read', client: onebot },
|
||||
);
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.structuredContent.diagnostics).toMatchObject({
|
||||
outcome: 'failed',
|
||||
kind: 'permission',
|
||||
});
|
||||
expect(onebot.call).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['timeout', 'cancelled', 'authentication', 'transport', 'invalid_response'] as const)(
|
||||
'makes %s observable without claiming a write was rolled back',
|
||||
async (kind) => {
|
||||
const onebot = client();
|
||||
onebot.call = vi.fn(async () => {
|
||||
throw new ActionCallError('request failed', kind);
|
||||
});
|
||||
const result = await callTool(
|
||||
'invoke_action',
|
||||
{ action: 'send_group_msg' },
|
||||
{ mode: 'write', client: onebot },
|
||||
);
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.structuredContent.diagnostics).toMatchObject({
|
||||
outcome: 'unknown',
|
||||
kind,
|
||||
});
|
||||
expect(result.structuredContent.diagnostics.next_step).toContain('不代表撤销');
|
||||
expect(onebot.call).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
);
|
||||
|
||||
it('does not report an incomplete response as successful', async () => {
|
||||
const onebot = client();
|
||||
onebot.call = async () => JSON.parse('{"data":null}');
|
||||
const result = await callTool(
|
||||
'query_action',
|
||||
{ action: 'get_status' },
|
||||
{ mode: 'read', client: onebot },
|
||||
);
|
||||
expect(result.structuredContent.diagnostics).toMatchObject({
|
||||
outcome: 'unknown',
|
||||
kind: 'invalid_response',
|
||||
});
|
||||
expect(text(result)).toBe('{\n "data": null\n}');
|
||||
});
|
||||
|
||||
it('keeps malformed peer fields in the original payload without breaking the diagnostics schema', async () => {
|
||||
const onebot = client();
|
||||
onebot.call = async () =>
|
||||
JSON.parse('{"status":"failed","retcode":1400,"wording":{"unexpected":true}}');
|
||||
const result = await callTool(
|
||||
'query_action',
|
||||
{ action: 'get_status' },
|
||||
{ mode: 'read', client: onebot },
|
||||
);
|
||||
expect(result.structuredContent.diagnostics.message).toBe('retcode=1400');
|
||||
expect(JSON.parse(text(result)).wording).toEqual({ unexpected: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('optional discovery controls', () => {
|
||||
it('keeps legacy matching, ordering and response fields by default', async () => {
|
||||
const result = await callTool('search_actions', { query: '群 禁言' }, docs);
|
||||
expect(JSON.parse(text(result))).toEqual({ count: 0, actions: [] });
|
||||
const all = JSON.parse(text(await callTool('list_actions', {}, docs)));
|
||||
expect(Object.keys(all)).toEqual(['count', 'actions']);
|
||||
expect(all.actions.map((action: { name: string }) => action.name)).toEqual(
|
||||
ACTIONS.map((action) => action.name),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['群 禁言', 'set_group_ban'],
|
||||
['历史消息', 'get_group_msg_history'],
|
||||
['私聊 history', 'get_friend_msg_history'],
|
||||
['group mute', 'set_group_ban'],
|
||||
])('finds %s with optional keyword matching', async (query, expected) => {
|
||||
const data = JSON.parse(
|
||||
text(await callTool('search_actions', { query, match: 'keywords' }, docs)),
|
||||
);
|
||||
expect(data.actions.map((action: { name: string }) => action.name)).toContain(expected);
|
||||
});
|
||||
|
||||
it('ranks an exact alias first and combines filters', async () => {
|
||||
const exact = JSON.parse(
|
||||
text(
|
||||
await callTool('search_actions', { query: 'nc_get_rkey', match: 'keywords' }, docs),
|
||||
),
|
||||
);
|
||||
expect(exact.actions[0].name).toBe('get_rkey');
|
||||
const filtered = JSON.parse(
|
||||
text(
|
||||
await callTool(
|
||||
'search_actions',
|
||||
{
|
||||
query: '群',
|
||||
match: 'keywords',
|
||||
category: '群管理',
|
||||
read_only: false,
|
||||
stream: false,
|
||||
},
|
||||
docs,
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(filtered.count).toBeGreaterThan(0);
|
||||
expect(
|
||||
filtered.actions.every(
|
||||
(action: { category: string; readOnly: boolean; stream: boolean }) =>
|
||||
action.category === '群管理' && !action.readOnly && !action.stream,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('paginates deterministically with explicit totals and termination', async () => {
|
||||
const names: string[] = [];
|
||||
let offset: number | null = 0;
|
||||
|
||||
while (offset !== null) {
|
||||
const data = JSON.parse(
|
||||
text(await callTool('list_actions', { limit: 37, offset }, docs)),
|
||||
);
|
||||
expect(data.total).toBe(ACTIONS.length);
|
||||
expect(data.count).toBe(data.actions.length);
|
||||
names.push(...data.actions.map((action: { name: string }) => action.name));
|
||||
offset = data.next_offset;
|
||||
}
|
||||
|
||||
expect(names).toEqual(ACTIONS.map((action) => action.name));
|
||||
const beyond = JSON.parse(text(await callTool('list_actions', { offset: 9999 }, docs)));
|
||||
expect(beyond).toEqual({ count: 0, actions: [], total: ACTIONS.length, next_offset: null });
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ limit: 0 },
|
||||
{ limit: 201 },
|
||||
{ offset: -1 },
|
||||
{ offset: 0.5 },
|
||||
{ stream: 'true' },
|
||||
{ read_only: 1 },
|
||||
{ match: 'typo' },
|
||||
])('rejects invalid new options (%j)', async (options) => {
|
||||
const result = await callTool('search_actions', { query: '', ...options }, docs);
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.structuredContent.diagnostics.kind).toBe('invalid_arguments');
|
||||
});
|
||||
});
|
||||
|
||||
describe('runtime overview and caller cancellation', () => {
|
||||
it('does not contact OneBot for a local overview, even if a client exists in docs mode', async () => {
|
||||
const onebot = client();
|
||||
const result = await callTool('get_runtime_info', {}, { ...docs, client: onebot });
|
||||
expect(result.structuredContent.result).toMatchObject({
|
||||
mode: 'docs',
|
||||
execution_enabled: false,
|
||||
write_enabled: false,
|
||||
transport: null,
|
||||
connection_checked: false,
|
||||
catalog: { action_count: ACTIONS.length, server_support: 'unknown' },
|
||||
});
|
||||
expect(onebot.call).not.toHaveBeenCalled();
|
||||
const denied = await callTool(
|
||||
'get_runtime_info',
|
||||
{ check_connection: true },
|
||||
{ ...docs, client: onebot },
|
||||
);
|
||||
expect(denied.isError).toBe(true);
|
||||
expect(onebot.call).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('only performs the three explicit read-only checks and preserves individual failures', async () => {
|
||||
const onebot = client();
|
||||
const controller = new AbortController();
|
||||
onebot.call = vi.fn(async (action, _params, options) => {
|
||||
expect(options?.signal).toBe(controller.signal);
|
||||
return action === 'get_status'
|
||||
? { status: 'failed', retcode: 1401, wording: 'unauthorized' }
|
||||
: { status: 'ok', retcode: 0, data: { action } };
|
||||
});
|
||||
const result = await callTool(
|
||||
'get_runtime_info',
|
||||
{ check_connection: true },
|
||||
{ mode: 'read', client: onebot, signal: controller.signal },
|
||||
);
|
||||
expect(vi.mocked(onebot.call).mock.calls.map(([action]) => action)).toEqual([
|
||||
'get_login_info',
|
||||
'get_status',
|
||||
'get_version_info',
|
||||
]);
|
||||
expect(result.structuredContent.diagnostics.outcome).toBe('failed');
|
||||
expect(result.structuredContent.result).toMatchObject({
|
||||
checks: {
|
||||
get_status: { result: { retcode: 1401 }, diagnostics: { outcome: 'failed' } },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('forwards ordinary request cancellation and does not start pre-cancelled calls', async () => {
|
||||
const controller = new AbortController();
|
||||
const onebot = client();
|
||||
await callTool(
|
||||
'query_action',
|
||||
{ action: 'get_status' },
|
||||
{ mode: 'read', client: onebot, signal: controller.signal },
|
||||
);
|
||||
expect(onebot.call).toHaveBeenCalledWith('get_status', {}, { signal: controller.signal });
|
||||
controller.abort();
|
||||
const result = await callTool(
|
||||
'query_action',
|
||||
{ action: 'get_status' },
|
||||
{ mode: 'read', client: onebot, signal: controller.signal },
|
||||
);
|
||||
expect(onebot.call).toHaveBeenCalledTimes(1);
|
||||
expect(result.structuredContent.diagnostics.kind).toBe('cancelled');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createServer, type Server } from 'node:http';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { makeHttpClient } from '../src/client';
|
||||
|
||||
const dirs: string[] = [];
|
||||
const servers: Server[] = [];
|
||||
afterEach(async () => {
|
||||
for (const server of servers.splice(0)) {
|
||||
server.closeAllConnections();
|
||||
await new Promise<void>((resolve, reject) =>
|
||||
server.close((error) => (error ? reject(error) : resolve())),
|
||||
);
|
||||
}
|
||||
|
||||
for (const dir of dirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function directory() {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'mcp-cancel-'));
|
||||
dirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
describe('ordinary HTTP cancellation', () => {
|
||||
it.each([false, true])(
|
||||
'aborts the actual HTTP request (headers received: %s)',
|
||||
async (sendHeaders) => {
|
||||
const started = Promise.withResolvers<void>();
|
||||
const disconnected = Promise.withResolvers<void>();
|
||||
let count = 0;
|
||||
const server = createServer((_request, response) => {
|
||||
count++;
|
||||
response.on('close', () => disconnected.resolve());
|
||||
|
||||
if (sendHeaders) {
|
||||
response.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
response.write('{"status":');
|
||||
}
|
||||
|
||||
started.resolve();
|
||||
});
|
||||
servers.push(server);
|
||||
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
const address = server.address();
|
||||
|
||||
if (!address || typeof address === 'string') {
|
||||
throw new Error('missing test server port');
|
||||
}
|
||||
|
||||
const client = makeHttpClient({
|
||||
endpoint: `http://127.0.0.1:${address.port}`,
|
||||
streamDir: directory(),
|
||||
timeoutMs: 5000,
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const outcome = client
|
||||
.call('get_status', {}, { signal: controller.signal })
|
||||
.catch((error) => error);
|
||||
await started.promise;
|
||||
controller.abort(new Error('caller cancelled'));
|
||||
expect(await outcome).toMatchObject({ kind: 'cancelled' });
|
||||
await disconnected.promise;
|
||||
expect(count).toBe(1);
|
||||
},
|
||||
);
|
||||
|
||||
it('does not issue an HTTP request when already cancelled', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>();
|
||||
const client = makeHttpClient({
|
||||
endpoint: 'http://unused.invalid',
|
||||
streamDir: directory(),
|
||||
fetch: fetcher,
|
||||
});
|
||||
await expect(
|
||||
client.call('get_status', {}, { signal: AbortSignal.abort() }),
|
||||
).rejects.toMatchObject({ kind: 'cancelled' });
|
||||
expect(fetcher).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('retains the ordinary timeout and exposes its cause', async () => {
|
||||
const fetcher: typeof fetch = vi.fn(
|
||||
async (_input, init) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
init?.signal?.addEventListener('abort', () => reject(init.signal?.reason), {
|
||||
once: true,
|
||||
});
|
||||
}),
|
||||
);
|
||||
const client = makeHttpClient({
|
||||
endpoint: 'http://unused.invalid',
|
||||
streamDir: directory(),
|
||||
fetch: fetcher,
|
||||
timeoutMs: 20,
|
||||
});
|
||||
await expect(client.call('get_status', {})).rejects.toMatchObject({ kind: 'timeout' });
|
||||
expect(fetcher).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('reports resolved limits and paths without endpoint credentials', () => {
|
||||
const dir = directory();
|
||||
const client = makeHttpClient({
|
||||
endpoint: 'https://user:secret@example.invalid/path?token=secret',
|
||||
accessToken: 'secret',
|
||||
streamDir: dir,
|
||||
uploadRoot: dir,
|
||||
maxStreamBytes: 123,
|
||||
timeoutMs: 456,
|
||||
});
|
||||
expect(client.info).toMatchObject({
|
||||
timeout_ms: 456,
|
||||
max_stream_bytes: 123,
|
||||
download_quota_bytes: 246,
|
||||
max_active_streams: 4,
|
||||
upload_enabled: true,
|
||||
});
|
||||
expect(client.info?.upload_root).toBe(client.info?.download_directory);
|
||||
expect(JSON.stringify(client.info)).not.toMatch(/secret|example\.invalid/);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,585 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
|
||||
import { createMcpHandler, Server, type ClientCapabilities } from '@modelcontextprotocol/server';
|
||||
import { Client as LegacyClient } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
|
||||
import { ElicitRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
||||
import {
|
||||
createInputCompletion,
|
||||
inputCompletionBaseTool,
|
||||
inputCompletionTools,
|
||||
isInputCompletionTool,
|
||||
} from '../src/elicitation';
|
||||
import { asError, asText } from '../src/results';
|
||||
import { computeTools, prepareActionCall, type Mode } from '../src/tools';
|
||||
import type { ActionClient } from '../src/client';
|
||||
import { createMcpServer } from '../src/server-factory';
|
||||
|
||||
const cleanup: Array<() => void | Promise<void>> = [];
|
||||
const completeCapabilities: ClientCapabilities = { elicitation: { form: {} } };
|
||||
const ORIGINAL = { action: 'send_private_msg', params: { message: 'private original message' } };
|
||||
|
||||
afterEach(async () => {
|
||||
for (const close of cleanup.splice(0).reverse()) {
|
||||
await close();
|
||||
}
|
||||
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function harness(options: Parameters<typeof createInputCompletion>[0] = {}, mode: Mode = 'write') {
|
||||
const completion = createInputCompletion(options);
|
||||
const execute = vi.fn(async (args: Record<string, unknown>) => asText(args.params));
|
||||
const client: ActionClient = {
|
||||
call: vi.fn(async () => ({ status: 'ok', retcode: 0 })),
|
||||
callStream: vi.fn(async () => ({
|
||||
frame_count: 1,
|
||||
frames: [],
|
||||
terminal: { status: 'ok', retcode: 0 },
|
||||
})),
|
||||
};
|
||||
const preflight = vi.fn((args: Record<string, unknown>, kind: 'read' | 'write') => {
|
||||
const result = prepareActionCall(args, { mode, client }, kind);
|
||||
return 'structuredContent' in result
|
||||
? result
|
||||
: {
|
||||
action: result.action,
|
||||
params: result.params,
|
||||
inputSchema: result.inputSchema,
|
||||
};
|
||||
});
|
||||
|
||||
const factory = () => {
|
||||
const server = new Server(
|
||||
{ name: 'input-test', version: '1' },
|
||||
{
|
||||
capabilities: { tools: {} },
|
||||
requestState: { verify: completion.verifyRequestState },
|
||||
},
|
||||
);
|
||||
server.setRequestHandler('tools/list', async () => ({
|
||||
tools: inputCompletionTools(computeTools('write')),
|
||||
}));
|
||||
server.setRequestHandler('tools/call', (request, context) => {
|
||||
if (!isInputCompletionTool(request.params.name)) {
|
||||
return asError('unknown tool');
|
||||
}
|
||||
|
||||
const name = request.params.name;
|
||||
return completion.handle(name, request.params.arguments ?? {}, context, {
|
||||
capabilities:
|
||||
context.mcpReq.envelope?.['io.modelcontextprotocol/clientCapabilities'] ??
|
||||
server.getClientCapabilities(),
|
||||
preflight: (args) =>
|
||||
preflight(
|
||||
args,
|
||||
inputCompletionBaseTool(name) === 'query_action' ? 'read' : 'write',
|
||||
),
|
||||
execute,
|
||||
});
|
||||
});
|
||||
return server;
|
||||
};
|
||||
|
||||
const handler = createMcpHandler(factory);
|
||||
cleanup.push(
|
||||
() => completion.close(),
|
||||
() => handler.close(),
|
||||
);
|
||||
|
||||
async function raw(
|
||||
arguments_: Record<string, unknown> = ORIGINAL,
|
||||
retry: Record<string, unknown> = {},
|
||||
capabilities: ClientCapabilities = completeCapabilities,
|
||||
name = 'invoke_action_with_input',
|
||||
token?: string,
|
||||
) {
|
||||
const response = await handler.fetch(
|
||||
new Request('http://input.invalid/mcp', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json, text/event-stream',
|
||||
'Mcp-Method': 'tools/call',
|
||||
'Mcp-Name': name,
|
||||
'Mcp-Protocol-Version': '2026-07-28',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name,
|
||||
arguments: arguments_,
|
||||
...retry,
|
||||
_meta: {
|
||||
'io.modelcontextprotocol/protocolVersion': '2026-07-28',
|
||||
'io.modelcontextprotocol/clientCapabilities': capabilities,
|
||||
'io.modelcontextprotocol/clientInfo': {
|
||||
name: 'input-test',
|
||||
version: '1',
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
token ? { authInfo: { token, clientId: 'test-app', scopes: [] } } : undefined,
|
||||
);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
return { completion, handler, factory, execute, preflight, raw };
|
||||
}
|
||||
|
||||
function answer(requestState: string, content: unknown = { params_json: '{"user_id":123}' }) {
|
||||
return { requestState, inputResponses: { missing_parameters: { action: 'accept', content } } };
|
||||
}
|
||||
|
||||
describe('optional action input completion', () => {
|
||||
it('integrates with the production factory and preserves the original action result contract', async () => {
|
||||
const completion = createInputCompletion();
|
||||
const onebot: ActionClient = {
|
||||
call: vi.fn(async () => ({ status: 'ok', retcode: 0, data: { message_id: -42 } })),
|
||||
callStream: vi.fn(),
|
||||
};
|
||||
const log = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const handler = createMcpHandler(() =>
|
||||
createMcpServer({ mode: 'write', client: onebot, inputCompletion: completion }),
|
||||
);
|
||||
const client = new Client(
|
||||
{ name: 'production-input-test', version: '1' },
|
||||
{
|
||||
capabilities: completeCapabilities,
|
||||
versionNegotiation: { mode: { pin: '2026-07-28' } },
|
||||
},
|
||||
);
|
||||
client.setRequestHandler('elicitation/create', async () => {
|
||||
expect(onebot.call).not.toHaveBeenCalled();
|
||||
return { action: 'accept', content: { params_json: '{"user_id":123}' } };
|
||||
});
|
||||
cleanup.push(
|
||||
() => completion.close(),
|
||||
() => handler.close(),
|
||||
() => client.close(),
|
||||
);
|
||||
await client.connect(
|
||||
new StreamableHTTPClientTransport(new URL('http://input.invalid/mcp'), {
|
||||
fetch: (input, init) => handler.fetch(new Request(input, init)),
|
||||
}),
|
||||
);
|
||||
const result = await client.callTool({
|
||||
name: 'invoke_action_with_input',
|
||||
arguments: ORIGINAL,
|
||||
});
|
||||
expect(result.structuredContent).toEqual({
|
||||
result: { status: 'ok', retcode: 0, data: { message_id: -42 } },
|
||||
diagnostics: { outcome: 'success' },
|
||||
});
|
||||
expect(onebot.call).toHaveBeenCalledExactlyOnceWith(
|
||||
'send_private_msg',
|
||||
{ user_id: 123, message: ORIGINAL.params.message },
|
||||
expect.objectContaining({ signal: expect.any(AbortSignal) }),
|
||||
);
|
||||
expect(JSON.stringify(log.mock.calls)).not.toContain(ORIGINAL.params.message);
|
||||
expect(log.mock.calls.map(([, value]) => JSON.parse(String(value)).event)).toEqual([
|
||||
'call_started',
|
||||
'call_input_required',
|
||||
'call_started',
|
||||
'call_finished',
|
||||
]);
|
||||
});
|
||||
|
||||
it.each(['docs', 'read'] as const)(
|
||||
'keeps the production factory mode gate before input collection in %s mode',
|
||||
async (mode) => {
|
||||
const onebot: ActionClient = { call: vi.fn(), callStream: vi.fn() };
|
||||
const log = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const handler = createMcpHandler(() => createMcpServer({ mode, client: onebot }));
|
||||
const client = new Client(
|
||||
{ name: 'permission-input-test', version: '1' },
|
||||
{
|
||||
capabilities: completeCapabilities,
|
||||
versionNegotiation: { mode: { pin: '2026-07-28' } },
|
||||
},
|
||||
);
|
||||
const ask = vi.fn(async () => ({
|
||||
action: 'accept' as const,
|
||||
content: { params_json: '{"user_id":123}' },
|
||||
}));
|
||||
client.setRequestHandler('elicitation/create', ask);
|
||||
cleanup.push(
|
||||
() => handler.close(),
|
||||
() => client.close(),
|
||||
);
|
||||
await client.connect(
|
||||
new StreamableHTTPClientTransport(new URL('http://input.invalid/mcp'), {
|
||||
fetch: (input, init) => handler.fetch(new Request(input, init)),
|
||||
}),
|
||||
);
|
||||
const result = await client.callTool({
|
||||
name: 'invoke_action_with_input',
|
||||
arguments: ORIGINAL,
|
||||
});
|
||||
expect(result.structuredContent).toMatchObject({ diagnostics: { kind: 'permission' } });
|
||||
expect(ask).not.toHaveBeenCalled();
|
||||
expect(onebot.call).not.toHaveBeenCalled();
|
||||
expect(log.mock.calls.map(([, value]) => JSON.parse(String(value)).event)).toEqual([
|
||||
'call_started',
|
||||
'call_finished',
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
it('inherits tool visibility and existing schemas without altering the original list', () => {
|
||||
expect(inputCompletionTools(computeTools('docs'))).toEqual([]);
|
||||
expect(inputCompletionTools(computeTools('read')).map((tool) => tool.name)).toEqual([
|
||||
'query_action_with_input',
|
||||
]);
|
||||
const tools = computeTools('write');
|
||||
expect(inputCompletionTools(tools).map((tool) => tool.name)).toEqual([
|
||||
'query_action_with_input',
|
||||
'invoke_action_with_input',
|
||||
]);
|
||||
expect(tools.some((tool) => tool.name.endsWith('_with_input'))).toBe(false);
|
||||
expect(inputCompletionTools(tools)[1].inputSchema).toEqual(
|
||||
tools.find((tool) => tool.name === 'invoke_action')?.inputSchema,
|
||||
);
|
||||
});
|
||||
|
||||
it('asks before executing, never discloses existing values, and merges only validated missing fields', async () => {
|
||||
const { raw, execute } = harness();
|
||||
const first = await raw();
|
||||
expect(first.result.resultType).toBe('input_required');
|
||||
expect(execute).not.toHaveBeenCalled();
|
||||
expect(first.result.inputRequests.missing_parameters).toMatchObject({
|
||||
method: 'elicitation/create',
|
||||
});
|
||||
expect(JSON.stringify(first)).not.toContain(ORIGINAL.params.message);
|
||||
const decodedToken = Buffer.from(
|
||||
first.result.requestState.split('.')[1],
|
||||
'base64url',
|
||||
).toString();
|
||||
expect(decodedToken).not.toContain(ORIGINAL.params.message);
|
||||
const result = await raw(ORIGINAL, answer(first.result.requestState));
|
||||
expect(result.result).toMatchObject({
|
||||
resultType: 'complete',
|
||||
structuredContent: { result: { user_id: 123, message: ORIGINAL.params.message } },
|
||||
});
|
||||
expect(execute).toHaveBeenCalledExactlyOnceWith({
|
||||
action: ORIGINAL.action,
|
||||
params: { ...ORIGINAL.params, user_id: 123 },
|
||||
});
|
||||
});
|
||||
|
||||
it('executes complete arguments without requiring elicitation capability or prompting', async () => {
|
||||
const { raw, execute } = harness();
|
||||
const result = await raw({ action: 'get_msg', params: { message_id: -123 } }, {}, {});
|
||||
expect(result.result.structuredContent.result).toEqual({ message_id: -123 });
|
||||
expect(execute).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('honors permission failure before asking for input or executing', async () => {
|
||||
const { raw, execute, preflight } = harness();
|
||||
preflight.mockReturnValue(asError('write mode required', 'permission'));
|
||||
const result = await raw();
|
||||
expect(result.result.structuredContent.diagnostics.kind).toBe('permission');
|
||||
expect(result.result.inputRequests).toBeUndefined();
|
||||
expect(execute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['docs', 'read'] as const)(
|
||||
'enforces actual write permission in %s mode',
|
||||
async (mode) => {
|
||||
const { raw, execute } = harness({}, mode);
|
||||
const result = await raw();
|
||||
expect(result.result.structuredContent.diagnostics.kind).toBe('permission');
|
||||
expect(result.result.inputRequests).toBeUndefined();
|
||||
expect(execute).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it('does not turn a write action into a read action through the input helper', async () => {
|
||||
const { raw, execute } = harness();
|
||||
const result = await raw(ORIGINAL, {}, completeCapabilities, 'query_action_with_input');
|
||||
expect(result.result.structuredContent.diagnostics.kind).toBe('permission');
|
||||
expect(result.result.inputRequests).toBeUndefined();
|
||||
expect(execute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fails visibly when the client has no form capability', async () => {
|
||||
const { raw, execute } = harness();
|
||||
|
||||
for (const capabilities of [{}, { elicitation: { url: {} } }]) {
|
||||
const result = await raw(ORIGINAL, {}, capabilities);
|
||||
expect(result.result.structuredContent.diagnostics.kind).toBe('configuration');
|
||||
}
|
||||
|
||||
expect(execute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['decline', 'cancel'])(
|
||||
'stops on %s instead of repeatedly prompting',
|
||||
async (action) => {
|
||||
const { raw, execute } = harness();
|
||||
const first = await raw();
|
||||
const result = await raw(ORIGINAL, {
|
||||
requestState: first.result.requestState,
|
||||
inputResponses: { missing_parameters: { action } },
|
||||
});
|
||||
expect(result.result.structuredContent.diagnostics.kind).toBe('cancelled');
|
||||
expect(result.result.inputRequests).toBeUndefined();
|
||||
expect(execute).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ params_json: '{' },
|
||||
{ params_json: '{}' },
|
||||
{ params_json: '{"user_id":0}' },
|
||||
{ params_json: '{"user_id":"123"}' },
|
||||
{ params_json: '{"user_id":123,"message":"replacement"}' },
|
||||
{ params_json: '{"user_id":123,"auto_escape":true}' },
|
||||
{ params_json: '[]' },
|
||||
{ params_json: '{"user_id":123}', unexpected: 'value' },
|
||||
])('rejects invalid or extraneous answers without executing: %j', async (content) => {
|
||||
const { raw, execute } = harness();
|
||||
const first = await raw();
|
||||
const result = await raw(ORIGINAL, answer(first.result.requestState, content));
|
||||
expect(result.result.isError).toBe(true);
|
||||
expect(execute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('accepts complex missing message data without guessing or normalizing it', async () => {
|
||||
const { raw, execute } = harness();
|
||||
const args = { action: 'send_private_msg', params: { user_id: 123 } };
|
||||
const message = [
|
||||
{ type: 'reply', data: { id: -42 } },
|
||||
{ type: 'text', data: { text: 'hello' } },
|
||||
];
|
||||
const first = await raw(args);
|
||||
await raw(
|
||||
args,
|
||||
answer(first.result.requestState, { params_json: JSON.stringify({ message }) }),
|
||||
);
|
||||
expect(execute).toHaveBeenCalledExactlyOnceWith({
|
||||
action: args.action,
|
||||
params: { user_id: 123, message },
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects tampered, mismatched and state-less retries', async () => {
|
||||
const { raw, execute } = harness();
|
||||
const first = await raw();
|
||||
const tampered = await raw(ORIGINAL, answer(`${first.result.requestState}x`));
|
||||
expect(tampered.error.code).toBe(-32602);
|
||||
const changed = await raw(
|
||||
{ ...ORIGINAL, params: { message: 'different message' } },
|
||||
answer(first.result.requestState),
|
||||
);
|
||||
expect(changed.result.isError).toBe(true);
|
||||
const crossed = await raw(
|
||||
ORIGINAL,
|
||||
answer(first.result.requestState),
|
||||
completeCapabilities,
|
||||
'query_action_with_input',
|
||||
);
|
||||
expect(crossed.result.isError).toBe(true);
|
||||
const noState = await raw(ORIGINAL, { inputResponses: answer('').inputResponses });
|
||||
expect(noState.result.isError).toBe(true);
|
||||
expect(execute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('binds retries to the validated caller, including users sharing the same OAuth client', async () => {
|
||||
const { raw, execute } = harness();
|
||||
const first = await raw(
|
||||
ORIGINAL,
|
||||
{},
|
||||
completeCapabilities,
|
||||
'invoke_action_with_input',
|
||||
'user-a-token',
|
||||
);
|
||||
const result = await raw(
|
||||
ORIGINAL,
|
||||
answer(first.result.requestState),
|
||||
completeCapabilities,
|
||||
'invoke_action_with_input',
|
||||
'user-b-token',
|
||||
);
|
||||
expect(result.error.code).toBe(-32602);
|
||||
expect(execute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows exactly one concurrent completion and refuses later replay', async () => {
|
||||
const { raw, execute } = harness();
|
||||
const first = await raw();
|
||||
const results = await Promise.all([
|
||||
raw(ORIGINAL, answer(first.result.requestState)),
|
||||
raw(ORIGINAL, answer(first.result.requestState)),
|
||||
]);
|
||||
expect(execute).toHaveBeenCalledOnce();
|
||||
expect(results.filter((result) => result.result.isError)).toHaveLength(1);
|
||||
expect((await raw(ORIGINAL, answer(first.result.requestState))).result.isError).toBe(true);
|
||||
expect(execute).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('expires pending input and bounds outstanding prompts without evicting live ones', async () => {
|
||||
vi.useFakeTimers({ toFake: ['Date'] });
|
||||
const { raw, execute } = harness({ ttlSeconds: 1, maxPending: 1 });
|
||||
const first = await raw();
|
||||
expect((await raw()).result.structuredContent.diagnostics.kind).toBe('execution');
|
||||
vi.setSystemTime(Date.now() + 2_000);
|
||||
expect((await raw(ORIGINAL, answer(first.result.requestState))).error.code).toBe(-32602);
|
||||
expect((await raw()).result.resultType).toBe('input_required');
|
||||
expect(execute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not resume an unfinished prompt after closing the runtime', async () => {
|
||||
const { raw, execute, completion } = harness();
|
||||
const first = await raw();
|
||||
completion.close();
|
||||
expect((await raw(ORIGINAL, answer(first.result.requestState))).error.code).toBe(-32602);
|
||||
expect(execute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('lets a modern client drive the standard MRTR cycle', async () => {
|
||||
const { handler, execute } = harness();
|
||||
const client = new Client(
|
||||
{ name: 'modern-input-test', version: '1' },
|
||||
{
|
||||
capabilities: completeCapabilities,
|
||||
versionNegotiation: { mode: { pin: '2026-07-28' } },
|
||||
},
|
||||
);
|
||||
client.setRequestHandler('elicitation/create', async () => ({
|
||||
action: 'accept',
|
||||
content: { params_json: '{"user_id":123}' },
|
||||
}));
|
||||
cleanup.push(() => client.close());
|
||||
await client.connect(
|
||||
new StreamableHTTPClientTransport(new URL('http://input.invalid/mcp'), {
|
||||
fetch: (input, init) => handler.fetch(new Request(input, init)),
|
||||
}),
|
||||
);
|
||||
const result = await client.callTool({
|
||||
name: 'invoke_action_with_input',
|
||||
arguments: ORIGINAL,
|
||||
});
|
||||
expect(result.structuredContent).toMatchObject({
|
||||
result: { user_id: 123, message: ORIGINAL.params.message },
|
||||
});
|
||||
expect(execute).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('does not execute when the caller aborts while the input form is open', async () => {
|
||||
const { handler, execute } = harness();
|
||||
const entered = Promise.withResolvers<void>();
|
||||
const inputAborted = Promise.withResolvers<void>();
|
||||
const client = new Client(
|
||||
{ name: 'cancel-input-test', version: '1' },
|
||||
{
|
||||
capabilities: completeCapabilities,
|
||||
versionNegotiation: { mode: { pin: '2026-07-28' } },
|
||||
},
|
||||
);
|
||||
client.setRequestHandler('elicitation/create', async (_request, context) => {
|
||||
context.mcpReq.signal.addEventListener('abort', () => inputAborted.resolve(), {
|
||||
once: true,
|
||||
});
|
||||
entered.resolve();
|
||||
await inputAborted.promise;
|
||||
return { action: 'cancel' };
|
||||
});
|
||||
cleanup.push(() => client.close());
|
||||
await client.connect(
|
||||
new StreamableHTTPClientTransport(new URL('http://input.invalid/mcp'), {
|
||||
fetch: (input, init) => handler.fetch(new Request(input, init)),
|
||||
}),
|
||||
);
|
||||
const controller = new AbortController();
|
||||
const result = client
|
||||
.callTool(
|
||||
{ name: 'invoke_action_with_input', arguments: ORIGINAL },
|
||||
{ signal: controller.signal },
|
||||
)
|
||||
.catch((error: unknown) => error);
|
||||
await entered.promise;
|
||||
controller.abort(new Error('cancel test'));
|
||||
await inputAborted.promise;
|
||||
expect(await result).toBeInstanceOf(Error);
|
||||
expect(execute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('integrates the production factory through the legacy elicitation shim', async () => {
|
||||
const onebot: ActionClient = {
|
||||
call: vi.fn(async () => ({ status: 'ok', retcode: 0, data: { message_id: -42 } })),
|
||||
callStream: vi.fn(),
|
||||
};
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const server = createMcpServer({ mode: 'write', client: onebot });
|
||||
const client = new LegacyClient(
|
||||
{ name: 'legacy-input-test', version: '1' },
|
||||
{ capabilities: { elicitation: {} } },
|
||||
);
|
||||
client.setRequestHandler(ElicitRequestSchema, async () => {
|
||||
expect(onebot.call).not.toHaveBeenCalled();
|
||||
return { action: 'accept', content: { params_json: '{"user_id":123}' } };
|
||||
});
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
cleanup.push(
|
||||
() => server.close(),
|
||||
() => client.close(),
|
||||
);
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
const result = await client.callTool({
|
||||
name: 'invoke_action_with_input',
|
||||
arguments: ORIGINAL,
|
||||
});
|
||||
expect(result.structuredContent).toMatchObject({
|
||||
result: { status: 'ok', retcode: 0, data: { message_id: -42 } },
|
||||
});
|
||||
expect(result.resultType).toBeUndefined();
|
||||
expect(onebot.call).toHaveBeenCalledExactlyOnceWith(
|
||||
'send_private_msg',
|
||||
{ user_id: 123, message: ORIGINAL.params.message },
|
||||
expect.objectContaining({ signal: expect.any(AbortSignal) }),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps automatic local uploads complete without asking for runtime-owned transfer fields', async () => {
|
||||
const streamResult = {
|
||||
frame_count: 1,
|
||||
frames: [],
|
||||
terminal: { status: 'ok', retcode: 0, data: { file: 'uploaded-file' } },
|
||||
};
|
||||
const onebot: ActionClient = { call: vi.fn(), callStream: vi.fn(async () => streamResult) };
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const server = createMcpServer({ mode: 'write', client: onebot });
|
||||
const client = new LegacyClient({ name: 'auto-upload-input-test', version: '1' });
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
cleanup.push(
|
||||
() => server.close(),
|
||||
() => client.close(),
|
||||
);
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
const args = {
|
||||
action: 'upload_file_stream',
|
||||
execution: { input_file: '/fixture/media.bin' },
|
||||
};
|
||||
const original = await client.callTool({ name: 'invoke_action', arguments: args });
|
||||
const extended = await client.callTool({
|
||||
name: 'invoke_action_with_input',
|
||||
arguments: args,
|
||||
});
|
||||
expect(extended.structuredContent).toEqual(original.structuredContent);
|
||||
expect(extended.isError).toBeUndefined();
|
||||
expect(onebot.callStream).toHaveBeenCalledTimes(2);
|
||||
expect(onebot.callStream).toHaveBeenLastCalledWith(
|
||||
'upload_file_stream',
|
||||
{},
|
||||
expect.objectContaining({ inputFile: args.execution.input_file }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,474 @@
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { App } from '@modelcontextprotocol/ext-apps';
|
||||
import { AppBridge } from '@modelcontextprotocol/ext-apps/app-bridge';
|
||||
import { InMemoryTransport } from '@modelcontextprotocol/client';
|
||||
import { Client as LegacyClient } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { createMcpHandler } from '@modelcontextprotocol/server';
|
||||
import { createMcpServer } from '../src/server-factory.js';
|
||||
import type { ActionClient } from '../src/client.js';
|
||||
import type { Mode } from '../src/tools.js';
|
||||
import {
|
||||
MESSAGE_APP_MIME_TYPE,
|
||||
MESSAGE_APP_URI,
|
||||
messageAppResources,
|
||||
messagePreviewTool,
|
||||
previewMessage,
|
||||
readMessageAppResource,
|
||||
supportsMessageApp,
|
||||
} from '../src/message-app.js';
|
||||
import { mountMessagePreview, type PreviewApp } from '../apps/preview-view.js';
|
||||
import { buildMessageApp } from '../scripts/build-message-app.mjs';
|
||||
|
||||
const temporary: string[] = [];
|
||||
const close: Array<() => Promise<void>> = [];
|
||||
afterEach(async () => {
|
||||
for (const cleanup of close.splice(0).reverse()) {
|
||||
await cleanup();
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
temporary.splice(0).map((path) => rm(path, { recursive: true, force: true })),
|
||||
);
|
||||
});
|
||||
|
||||
const draft = { message_type: 'group', target_id: 12345, text: '你好' };
|
||||
|
||||
function fakeOneBot(): ActionClient {
|
||||
return {
|
||||
call: vi.fn(async () => ({ status: 'ok', retcode: 0, data: { message_id: -42 } })),
|
||||
callStream: vi.fn(async () => ({
|
||||
frame_count: 0,
|
||||
frames: [],
|
||||
terminal: { status: 'ok', retcode: 0 },
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
const appCapabilities = {
|
||||
extensions: { 'io.modelcontextprotocol/ui': { mimeTypes: [MESSAGE_APP_MIME_TYPE] } },
|
||||
};
|
||||
|
||||
describe('message preview server integration', () => {
|
||||
it.each(['docs', 'read', 'write'] as Mode[])(
|
||||
'keeps legacy preview side-effect free and applies %s mode and negotiated UI capability',
|
||||
async (mode) => {
|
||||
for (const ui of [false, true]) {
|
||||
const onebot = fakeOneBot();
|
||||
const server = createMcpServer({ mode, client: onebot });
|
||||
const client = new LegacyClient(
|
||||
{ name: 'preview-legacy-test', version: '1.0.0' },
|
||||
{ capabilities: ui ? appCapabilities : {} },
|
||||
);
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
close.push(
|
||||
() => server.close(),
|
||||
() => client.close(),
|
||||
);
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
const tools = await client.listTools();
|
||||
const preview = tools.tools.find((tool) => tool.name === 'preview_message');
|
||||
expect(preview).toBeDefined();
|
||||
expect(preview?._meta).toEqual(
|
||||
ui
|
||||
? { ui: { resourceUri: MESSAGE_APP_URI, visibility: ['model', 'app'] } }
|
||||
: undefined,
|
||||
);
|
||||
const resources = await client.listResources();
|
||||
expect(
|
||||
resources.resources.some((resource) => resource.uri === MESSAGE_APP_URI),
|
||||
).toBe(ui);
|
||||
const result = await client.callTool({ name: 'preview_message', arguments: draft });
|
||||
expect(result.isError).toBeUndefined();
|
||||
expect(result.structuredContent).toMatchObject({
|
||||
result: { can_send: mode === 'write', draft },
|
||||
});
|
||||
expect(onebot.call).not.toHaveBeenCalled();
|
||||
expect(onebot.callStream).not.toHaveBeenCalled();
|
||||
|
||||
if (mode !== 'write') {
|
||||
expect(
|
||||
await client.callTool({
|
||||
name: 'invoke_action',
|
||||
arguments: {
|
||||
action: 'send_group_msg',
|
||||
params: { group_id: 12345, message: '你好' },
|
||||
},
|
||||
}),
|
||||
).toMatchObject({ isError: true });
|
||||
expect(onebot.call).not.toHaveBeenCalled();
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it('negotiates UI metadata independently for each modern request and never sends a preview', async () => {
|
||||
const onebot = fakeOneBot();
|
||||
const handler = createMcpHandler(() => createMcpServer({ mode: 'write', client: onebot }));
|
||||
close.push(() => handler.close());
|
||||
|
||||
const request = async (method: string, params: Record<string, unknown>, ui: boolean) => {
|
||||
const response = await handler.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': '2026-07-28',
|
||||
...(typeof params.name === 'string' ? { 'Mcp-Name': params.name } : {}),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method,
|
||||
params: {
|
||||
...params,
|
||||
_meta: {
|
||||
'io.modelcontextprotocol/protocolVersion': '2026-07-28',
|
||||
'io.modelcontextprotocol/clientCapabilities': ui
|
||||
? appCapabilities
|
||||
: {},
|
||||
'io.modelcontextprotocol/clientInfo': {
|
||||
name: 'preview-modern-test',
|
||||
version: '1.0.0',
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
return await response.json();
|
||||
};
|
||||
|
||||
for (const ui of [true, false, true]) {
|
||||
const tools = await request('tools/list', {}, ui);
|
||||
expect(
|
||||
tools.result.tools.find((tool: { name: string }) => tool.name === 'preview_message')
|
||||
._meta,
|
||||
).toEqual(
|
||||
ui
|
||||
? { ui: { resourceUri: MESSAGE_APP_URI, visibility: ['model', 'app'] } }
|
||||
: undefined,
|
||||
);
|
||||
const resources = await request('resources/list', {}, ui);
|
||||
expect(
|
||||
resources.result.resources.some(
|
||||
(resource: { uri: string }) => resource.uri === MESSAGE_APP_URI,
|
||||
),
|
||||
).toBe(ui);
|
||||
expect(resources.result.cacheScope).toBe('private');
|
||||
const preview = await request(
|
||||
'tools/call',
|
||||
{ name: 'preview_message', arguments: draft },
|
||||
ui,
|
||||
);
|
||||
expect(preview.result.structuredContent).toMatchObject({
|
||||
result: { can_send: true, draft },
|
||||
});
|
||||
}
|
||||
|
||||
expect(onebot.call).not.toHaveBeenCalled();
|
||||
expect(onebot.callStream).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('message preview tool', () => {
|
||||
it('negotiates optional HTML support and retains text-only tool discovery', () => {
|
||||
for (const capabilities of [
|
||||
undefined,
|
||||
{},
|
||||
{ extensions: {} },
|
||||
{ extensions: { 'io.modelcontextprotocol/ui': {} } },
|
||||
]) {
|
||||
expect(supportsMessageApp(capabilities)).toBe(false);
|
||||
}
|
||||
|
||||
expect(
|
||||
supportsMessageApp({
|
||||
extensions: {
|
||||
'io.modelcontextprotocol/ui': { mimeTypes: [MESSAGE_APP_MIME_TYPE] },
|
||||
},
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(messagePreviewTool(false)._meta).toBeUndefined();
|
||||
expect(messagePreviewTool(true)._meta).toEqual({
|
||||
ui: { resourceUri: MESSAGE_APP_URI, visibility: ['model', 'app'] },
|
||||
});
|
||||
expect(messageAppResources(false)).toEqual([]);
|
||||
expect(messageAppResources(true)).toEqual([
|
||||
expect.objectContaining({ uri: MESSAGE_APP_URI, mimeType: MESSAGE_APP_MIME_TYPE }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns a literal text draft and preserves negative quoted-message identifiers without executing', () => {
|
||||
const result = previewMessage(
|
||||
{ ...draft, text: '[CQ:at,qq=all]<script>bad()</script>', reply_to: -42 },
|
||||
true,
|
||||
);
|
||||
expect(result.isError).toBeUndefined();
|
||||
expect(result.structuredContent.result).toEqual({
|
||||
draft: { ...draft, text: '[CQ:at,qq=all]<script>bad()</script>', reply_to: -42 },
|
||||
can_send: true,
|
||||
notice: '此为待发送草稿,尚未发送。',
|
||||
invocation: {
|
||||
name: 'invoke_action',
|
||||
arguments: {
|
||||
action: 'send_group_msg',
|
||||
params: {
|
||||
group_id: 12345,
|
||||
message: [
|
||||
{ type: 'reply', data: { id: '-42' } },
|
||||
{
|
||||
type: 'text',
|
||||
data: { text: '[CQ:at,qq=all]<script>bad()</script>' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result.content).toEqual([
|
||||
{ type: 'text', text: JSON.stringify(result.structuredContent.result, null, 2) },
|
||||
]);
|
||||
expect(
|
||||
previewMessage({ ...draft, message_type: 'private' }, false).structuredContent.result,
|
||||
).toMatchObject({
|
||||
can_send: false,
|
||||
invocation: { arguments: { action: 'send_private_msg', params: { user_id: 12345 } } },
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ ...draft, message_type: 'other' },
|
||||
{ ...draft, target_id: 0 },
|
||||
{ ...draft, target_id: '12345' },
|
||||
{ ...draft, target_id: Number.MAX_SAFE_INTEGER + 1 },
|
||||
{ ...draft, text: ' \n' },
|
||||
{ ...draft, reply_to: 0 },
|
||||
{ ...draft, reply_to: 2147483648 },
|
||||
{ ...draft, reply_to: -2147483649 },
|
||||
{ ...draft, message: [] },
|
||||
])('rejects an invalid draft without discarding fields: %j', (args) => {
|
||||
expect(previewMessage(args, true)).toMatchObject({
|
||||
isError: true,
|
||||
structuredContent: { diagnostics: { kind: 'invalid_arguments' } },
|
||||
});
|
||||
});
|
||||
|
||||
it('does not hide a missing packaged panel', async () => {
|
||||
await expect(readMessageAppResource('unrelated://resource')).resolves.toBeUndefined();
|
||||
// Tests run from source; only an actual build supplies the sibling HTML.
|
||||
await expect(readMessageAppResource(MESSAGE_APP_URI)).rejects.toMatchObject({
|
||||
code: 'ENOENT',
|
||||
});
|
||||
});
|
||||
|
||||
it('builds a self-contained panel with exact CSP hashes and no external assets', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'snowluma-message-app-'));
|
||||
temporary.push(directory);
|
||||
const path = await buildMessageApp(directory);
|
||||
const html = await readFile(path, 'utf8');
|
||||
const script = html.match(/<script>([\s\S]*?)<\/script>/)?.[1];
|
||||
const style = html.match(/<style>([\s\S]*?)<\/style>/)?.[1];
|
||||
expect(script).toBeTruthy();
|
||||
expect(style).toBeTruthy();
|
||||
expect(html).toContain(
|
||||
`script-src 'sha256-${createHash('sha256').update(script!).digest('base64')}'`,
|
||||
);
|
||||
expect(html).toContain(
|
||||
`style-src 'sha256-${createHash('sha256').update(style!).digest('base64')}'`,
|
||||
);
|
||||
expect(html).toContain("connect-src 'none'");
|
||||
expect(html).not.toMatch(/<(?:script|img|iframe)\b[^>]*\bsrc\s*=/i);
|
||||
expect(html).not.toContain('unsafe-inline');
|
||||
expect(html).not.toContain('unsafe-eval');
|
||||
expect(html).toContain('ui/initialize');
|
||||
expect(html).toContain('发送这条消息');
|
||||
});
|
||||
});
|
||||
|
||||
class TestElement {
|
||||
textContent = '';
|
||||
disabled = true;
|
||||
private readonly listeners = new Map<string, () => void | Promise<void>>();
|
||||
|
||||
set innerHTML(_value: string) {
|
||||
throw new Error('Untrusted markup must never be rendered');
|
||||
}
|
||||
|
||||
addEventListener(type: string, callback: () => void | Promise<void>): void {
|
||||
this.listeners.set(type, callback);
|
||||
}
|
||||
|
||||
async click(): Promise<void> {
|
||||
await this.listeners.get('click')?.();
|
||||
}
|
||||
}
|
||||
|
||||
async function panel(options: { writable?: boolean; hostTools?: boolean } = {}) {
|
||||
const elements = Object.fromEntries(
|
||||
['target', 'message', 'reply', 'status', 'send', 'cancel'].map((id) => [
|
||||
id,
|
||||
new TestElement(),
|
||||
]),
|
||||
);
|
||||
const app: PreviewApp = {
|
||||
ontoolresult: undefined,
|
||||
ontoolcancelled: undefined,
|
||||
onerror: undefined,
|
||||
onclose: undefined,
|
||||
connect: vi.fn(async () => {}),
|
||||
getHostCapabilities: () => (options.hostTools === false ? {} : { serverTools: {} }),
|
||||
callServerTool: vi.fn(async () => ({
|
||||
content: [],
|
||||
structuredContent: {
|
||||
result: { status: 'ok', retcode: 0, data: { message_id: -42 } },
|
||||
diagnostics: { outcome: 'success' },
|
||||
},
|
||||
})),
|
||||
};
|
||||
mountMessagePreview(app, {
|
||||
getElementById: (id: string) => elements[id],
|
||||
} as unknown as Document);
|
||||
await Promise.resolve();
|
||||
app.ontoolresult?.(previewMessage({ ...draft, reply_to: -42 }, options.writable !== false));
|
||||
return { elements, app };
|
||||
}
|
||||
|
||||
describe('message preview interaction', () => {
|
||||
it('uses the official app handshake, tool-result notification and host-proxied send', async () => {
|
||||
const app = new App(
|
||||
{ name: 'preview-test', version: '1.0.0' },
|
||||
{},
|
||||
{ autoResize: false, strict: true },
|
||||
);
|
||||
const bridge = new AppBridge(
|
||||
null,
|
||||
{ name: 'isolated-host', version: '1.0.0' },
|
||||
{ serverTools: {} },
|
||||
);
|
||||
bridge.oncalltool = vi.fn(async () => ({
|
||||
content: [],
|
||||
structuredContent: { result: { status: 'ok', retcode: 0, data: { message_id: -99 } } },
|
||||
}));
|
||||
const [appTransport, hostTransport] = InMemoryTransport.createLinkedPair();
|
||||
close.push(
|
||||
() => app.close(),
|
||||
() => bridge.close(),
|
||||
);
|
||||
await bridge.connect(hostTransport);
|
||||
const originalConnect = app.connect.bind(app);
|
||||
let connected: Promise<void> | undefined;
|
||||
vi.spyOn(app, 'connect').mockImplementation(() => {
|
||||
connected = originalConnect(appTransport);
|
||||
return connected;
|
||||
});
|
||||
const elements = Object.fromEntries(
|
||||
['target', 'message', 'reply', 'status', 'send', 'cancel'].map((id) => [
|
||||
id,
|
||||
new TestElement(),
|
||||
]),
|
||||
);
|
||||
mountMessagePreview(app, {
|
||||
getElementById: (id: string) => elements[id],
|
||||
} as unknown as Document);
|
||||
await connected;
|
||||
await bridge.sendToolInput({ arguments: draft });
|
||||
await bridge.sendToolResult(previewMessage(draft, true));
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
expect(bridge.oncalltool).not.toHaveBeenCalled();
|
||||
expect(elements.target.textContent).toBe('群聊 · 12345');
|
||||
await elements.send.click();
|
||||
expect(bridge.oncalltool).toHaveBeenCalledTimes(1);
|
||||
expect(elements.status.textContent).toBe('服务端已接受消息。消息编号:-99');
|
||||
});
|
||||
|
||||
it('renders untrusted text without markup and never sends on load or repeated results', async () => {
|
||||
const { app, elements } = await panel();
|
||||
app.ontoolresult?.(
|
||||
previewMessage({ ...draft, text: '<img src=x onerror=alert(1)>' }, true),
|
||||
);
|
||||
expect(elements.message.textContent).toBe('<img src=x onerror=alert(1)>');
|
||||
expect(app.callServerTool).not.toHaveBeenCalled();
|
||||
expect(elements.send.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('sends once only on click through the existing invocation and displays a signed result', async () => {
|
||||
const { app, elements } = await panel();
|
||||
await Promise.all([elements.send.click(), elements.send.click()]);
|
||||
app.ontoolresult?.(previewMessage(draft, true));
|
||||
await elements.send.click();
|
||||
expect(app.callServerTool).toHaveBeenCalledExactlyOnceWith({
|
||||
name: 'invoke_action',
|
||||
arguments: {
|
||||
action: 'send_group_msg',
|
||||
params: {
|
||||
group_id: 12345,
|
||||
message: [
|
||||
{ type: 'reply', data: { id: '-42' } },
|
||||
{ type: 'text', data: { text: '你好' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(elements.status.textContent).toBe('服务端已接受消息。消息编号:-42');
|
||||
expect(elements.send.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it.each([{ writable: false }, { hostTools: false }])(
|
||||
'keeps unsupported or read-only panels non-sending: %j',
|
||||
async (options) => {
|
||||
const { app, elements } = await panel(options);
|
||||
await elements.send.click();
|
||||
expect(elements.send.disabled).toBe(true);
|
||||
expect(app.callServerTool).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it('cancels drafts locally and does not re-arm after another notification', async () => {
|
||||
const { app, elements } = await panel();
|
||||
await elements.cancel.click();
|
||||
app.ontoolresult?.(previewMessage(draft, true));
|
||||
await elements.send.click();
|
||||
expect(app.callServerTool).not.toHaveBeenCalled();
|
||||
expect(elements.status.textContent).toContain('未发送消息');
|
||||
});
|
||||
|
||||
it('does not retry or claim success after a transport failure', async () => {
|
||||
const { app, elements } = await panel();
|
||||
vi.mocked(app.callServerTool).mockRejectedValueOnce(new Error('connection lost'));
|
||||
await elements.send.click();
|
||||
await elements.send.click();
|
||||
expect(app.callServerTool).toHaveBeenCalledTimes(1);
|
||||
expect(elements.status.textContent).toContain('尚未确认');
|
||||
expect(elements.send.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('recognizes business failures even when MCP isError is false', async () => {
|
||||
const { app, elements } = await panel();
|
||||
vi.mocked(app.callServerTool).mockResolvedValueOnce({
|
||||
content: [],
|
||||
structuredContent: {
|
||||
result: { status: 'failed', retcode: 1400 },
|
||||
diagnostics: { outcome: 'failed', message: '<b>拒绝</b>' },
|
||||
},
|
||||
});
|
||||
await elements.send.click();
|
||||
expect(elements.status.textContent).toContain('发送失败:<b>拒绝</b>');
|
||||
expect(elements.send.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('stops sending when the host disconnects', async () => {
|
||||
const { app, elements } = await panel();
|
||||
app.onclose?.();
|
||||
await elements.send.click();
|
||||
expect(app.callServerTool).not.toHaveBeenCalled();
|
||||
expect(elements.status.textContent).toContain('连接已断开');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,637 @@
|
||||
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(
|
||||
[],
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,228 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
|
||||
import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js';
|
||||
import { createMcpServer } from '../src/server-factory';
|
||||
import { createProgressReporter } from '../src/progress';
|
||||
import type { ActionClient, TransferProgress } from '../src/client';
|
||||
|
||||
const close: Array<() => Promise<void>> = [];
|
||||
afterEach(async () => {
|
||||
for (const cleanup of close.splice(0).reverse()) {
|
||||
await cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
async function connect(onebot?: ActionClient) {
|
||||
const server = createMcpServer({ mode: onebot ? 'write' : 'docs', client: onebot });
|
||||
const client = new Client({ name: 'test-agent', version: '1.0.0' });
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
close.push(
|
||||
() => server.close(),
|
||||
() => client.close(),
|
||||
);
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
return { client, serverTransport };
|
||||
}
|
||||
|
||||
function fakeOneBot(): ActionClient {
|
||||
return {
|
||||
call: vi.fn(async () => ({ status: 'ok', retcode: 0, data: { online: true } })),
|
||||
callStream: vi.fn(async (_action, _params, options) => {
|
||||
for (const progress of [0, 3, 7]) {
|
||||
await options?.onProgress?.({ progress, total: 7, message: 'transferred bytes' });
|
||||
}
|
||||
|
||||
return { frame_count: 2, frames: [], terminal: { status: 'ok', retcode: 0 } };
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
describe('MCP protocol compatibility', () => {
|
||||
it('accepts legacy trace metadata and returns its correlation without changing the tool result', async () => {
|
||||
const onebot = fakeOneBot();
|
||||
const { client } = await connect(onebot);
|
||||
const parent = '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01';
|
||||
const result = await client.callTool({
|
||||
name: 'query_action',
|
||||
arguments: { action: 'get_status' },
|
||||
_meta: { traceparent: parent },
|
||||
});
|
||||
expect(result._meta?.traceparent).toMatch(
|
||||
/^00-4bf92f3577b34da6a3ce929d0e0e4736-[0-9a-f]{16}-01$/,
|
||||
);
|
||||
expect(onebot.call).toHaveBeenCalledWith(
|
||||
'get_status',
|
||||
{},
|
||||
expect.objectContaining({
|
||||
trace: { traceparent: result._meta?.traceparent },
|
||||
}),
|
||||
);
|
||||
expect(result.content).toEqual([
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify({ status: 'ok', retcode: 0, data: { online: true } }, null, 2),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('publishes output schemas that a real MCP client can validate for every tool', async () => {
|
||||
const { client } = await connect(fakeOneBot());
|
||||
const tools = await client.listTools();
|
||||
|
||||
for (const tool of tools.tools) {
|
||||
expect(tool.outputSchema).toBeDefined();
|
||||
const result = await client.callTool({
|
||||
name: tool.name,
|
||||
arguments:
|
||||
tool.name === 'preview_message'
|
||||
? { message_type: 'group', target_id: 1, text: 'preview' }
|
||||
: { name: 'get_status', query: 'history', action: 'get_status' },
|
||||
});
|
||||
expect(result.isError).toBeUndefined();
|
||||
expect(result.structuredContent).toMatchObject({ diagnostics: { outcome: 'success' } });
|
||||
}
|
||||
|
||||
const failure = await client.callTool({
|
||||
name: 'get_action',
|
||||
arguments: { name: 'missing_action' },
|
||||
});
|
||||
expect(failure.isError).toBe(true);
|
||||
expect(failure.structuredContent).toMatchObject({ diagnostics: { kind: 'not_found' } });
|
||||
const resources = await client.listResources();
|
||||
expect(resources.resources.map((resource) => resource.uri)).toEqual([
|
||||
'snowluma://onebot/actions',
|
||||
]);
|
||||
const catalog = await client.readResource({ uri: 'snowluma://onebot/actions' });
|
||||
expect(catalog.contents).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('sends bounded progress only when requested and flushes the final update before the response', async () => {
|
||||
const { client, serverTransport } = await connect(fakeOneBot());
|
||||
const send = vi.spyOn(serverTransport, 'send');
|
||||
await client.callTool({
|
||||
name: 'invoke_action',
|
||||
arguments: { action: 'test_download_stream' },
|
||||
});
|
||||
expect(
|
||||
send.mock.calls.some(
|
||||
([message]) => 'method' in message && message.method === 'notifications/progress',
|
||||
),
|
||||
).toBe(false);
|
||||
const progress: number[] = [];
|
||||
const result = await client.callTool(
|
||||
{ name: 'invoke_action', arguments: { action: 'test_download_stream' } },
|
||||
undefined,
|
||||
{
|
||||
onprogress: (update) => {
|
||||
progress.push(update.progress);
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(progress).toEqual([0, 7]);
|
||||
expect(result.isError).toBeUndefined();
|
||||
const count = progress.length;
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
expect(progress).toHaveLength(count);
|
||||
});
|
||||
|
||||
it.each([0, 'upload-token'])('preserves progress token %j', async (token) => {
|
||||
const { client, serverTransport } = await connect(fakeOneBot());
|
||||
const send = vi.spyOn(serverTransport, 'send');
|
||||
await client.request(
|
||||
{
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'invoke_action',
|
||||
arguments: { action: 'test_download_stream' },
|
||||
_meta: { progressToken: token },
|
||||
},
|
||||
},
|
||||
CallToolResultSchema,
|
||||
);
|
||||
const notifications = send.mock.calls
|
||||
.map(([message]) => message)
|
||||
.filter(
|
||||
(message) => 'method' in message && message.method === 'notifications/progress',
|
||||
);
|
||||
expect(notifications).toHaveLength(2);
|
||||
|
||||
for (const notification of notifications) {
|
||||
expect(notification).toMatchObject({ params: { progressToken: token } });
|
||||
}
|
||||
});
|
||||
|
||||
it('propagates a real MCP cancellation notification to ordinary execution', async () => {
|
||||
const started = Promise.withResolvers<void>();
|
||||
const aborted = Promise.withResolvers<void>();
|
||||
const onebot = fakeOneBot();
|
||||
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 { client } = await connect(onebot);
|
||||
const controller = new AbortController();
|
||||
const outcome = client
|
||||
.callTool({ name: 'query_action', arguments: { action: 'get_status' } }, undefined, {
|
||||
signal: controller.signal,
|
||||
})
|
||||
.catch((error) => error);
|
||||
await started.promise;
|
||||
controller.abort(new Error('cancel wait'));
|
||||
expect(await outcome).toBeInstanceOf(Error);
|
||||
await aborted.promise;
|
||||
expect(onebot.call).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('per-request progress', () => {
|
||||
it('rate limits, enforces increasing values, and stops on cancellation', async () => {
|
||||
let time = 0;
|
||||
const controller = new AbortController();
|
||||
const updates: TransferProgress[] = [];
|
||||
const reporter = createProgressReporter(
|
||||
async (value) => {
|
||||
updates.push(value);
|
||||
},
|
||||
controller.signal,
|
||||
() => time,
|
||||
);
|
||||
await reporter.report({ progress: 0, message: 'start' });
|
||||
await reporter.report({ progress: 1, message: 'one' });
|
||||
await reporter.report({ progress: 2, message: 'two' });
|
||||
expect(updates.map((value) => value.progress)).toEqual([0]);
|
||||
time = 250;
|
||||
await reporter.report({ progress: 3, message: 'three' });
|
||||
await reporter.report({ progress: 3, message: 'duplicate' });
|
||||
await reporter.finish();
|
||||
expect(updates.map((value) => value.progress)).toEqual([0, 3]);
|
||||
await expect(reporter.report({ progress: 2, message: 'backwards' })).rejects.toThrow(
|
||||
'Invalid transfer progress',
|
||||
);
|
||||
controller.abort();
|
||||
time = 500;
|
||||
await reporter.report({ progress: 4, message: 'cancelled' });
|
||||
await reporter.finish();
|
||||
expect(updates).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('surfaces notification delivery failure', async () => {
|
||||
const reporter = createProgressReporter(async () => {
|
||||
throw new Error('transport closed');
|
||||
}, new AbortController().signal);
|
||||
await expect(reporter.report({ progress: 1, message: 'one' })).rejects.toThrow(
|
||||
'transport closed',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
import { PassThrough } from 'node:stream';
|
||||
import { Server } from '@modelcontextprotocol/server';
|
||||
import { serveStdio, StdioServerTransport } from '@modelcontextprotocol/server/stdio';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { shutdownRuntime } from '../src/shutdown';
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
describe('runtime shutdown', () => {
|
||||
it('finishes durable cleanup before closing the protocol normally', async () => {
|
||||
const durable = Promise.withResolvers<void>();
|
||||
const closeProtocol = vi.fn(async () => undefined);
|
||||
const forceClose = vi.fn(async () => undefined);
|
||||
const onTimeout = vi.fn();
|
||||
const closing = shutdownRuntime({
|
||||
closeFeatures: () => durable.promise,
|
||||
closeProtocol,
|
||||
forceClose,
|
||||
onTimeout,
|
||||
});
|
||||
await Promise.resolve();
|
||||
expect(closeProtocol).not.toHaveBeenCalled();
|
||||
durable.resolve();
|
||||
await closing;
|
||||
expect(closeProtocol).toHaveBeenCalledOnce();
|
||||
expect(forceClose).not.toHaveBeenCalled();
|
||||
expect(onTimeout).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('releases features and forcibly closes real stdio when a subscriber stops reading', async () => {
|
||||
const input = new PassThrough();
|
||||
const output = new PassThrough({ highWaterMark: 1 });
|
||||
const transport = new StdioServerTransport(input, output);
|
||||
const handle = serveStdio(
|
||||
() =>
|
||||
new Server(
|
||||
{ name: 'shutdown-test', version: '1' },
|
||||
{ capabilities: { resources: { subscribe: true, listChanged: true } } },
|
||||
),
|
||||
{ transport },
|
||||
);
|
||||
const order: string[] = [];
|
||||
const onTimeout = vi.fn(() => order.push('timeout'));
|
||||
const forceClose = vi.fn(async () => {
|
||||
order.push('force');
|
||||
await transport.close();
|
||||
output.destroy();
|
||||
});
|
||||
|
||||
try {
|
||||
input.write(
|
||||
`${JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'subscriptions/listen',
|
||||
params: {
|
||||
notifications: { resourcesUpdated: ['test://events'] },
|
||||
_meta: {
|
||||
'io.modelcontextprotocol/protocolVersion': '2026-07-28',
|
||||
'io.modelcontextprotocol/clientCapabilities': {},
|
||||
},
|
||||
},
|
||||
})}\n`,
|
||||
);
|
||||
await vi.waitFor(() => expect(output.writableLength).toBeGreaterThan(0));
|
||||
await shutdownRuntime({
|
||||
closeFeatures: async () => {
|
||||
order.push('features');
|
||||
},
|
||||
closeProtocol: () => {
|
||||
order.push('protocol');
|
||||
return handle.close();
|
||||
},
|
||||
forceClose,
|
||||
graceMs: 10,
|
||||
onTimeout,
|
||||
});
|
||||
expect(order).toEqual(['features', 'protocol', 'timeout', 'force']);
|
||||
expect(forceClose).toHaveBeenCalledOnce();
|
||||
expect(output.destroyed).toBe(true);
|
||||
expect(input.listenerCount('data')).toBe(0);
|
||||
} finally {
|
||||
await transport.close();
|
||||
input.destroy();
|
||||
output.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
it('still closes the protocol after a feature failure and preserves that error', async () => {
|
||||
const failure = new Error('feature shutdown failed');
|
||||
const closeProtocol = vi.fn(async () => undefined);
|
||||
const forceClose = vi.fn(async () => undefined);
|
||||
await expect(
|
||||
shutdownRuntime({
|
||||
closeFeatures: async () => {
|
||||
throw failure;
|
||||
},
|
||||
closeProtocol,
|
||||
forceClose,
|
||||
}),
|
||||
).rejects.toBe(failure);
|
||||
expect(closeProtocol).toHaveBeenCalledOnce();
|
||||
expect(forceClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('preserves all cleanup errors and forces closure after a protocol failure', async () => {
|
||||
const failures = [new Error('features'), new Error('protocol'), new Error('force')];
|
||||
await expect(
|
||||
shutdownRuntime({
|
||||
closeFeatures: async () => {
|
||||
throw failures[0];
|
||||
},
|
||||
closeProtocol: async () => {
|
||||
throw failures[1];
|
||||
},
|
||||
forceClose: async () => {
|
||||
throw failures[2];
|
||||
},
|
||||
}),
|
||||
).rejects.toMatchObject({ errors: failures });
|
||||
});
|
||||
|
||||
it('does not skip forced closure when timeout reporting fails', async () => {
|
||||
const reportingError = new Error('reporting');
|
||||
const forceClose = vi.fn(async () => undefined);
|
||||
await expect(
|
||||
shutdownRuntime({
|
||||
closeFeatures: async () => undefined,
|
||||
closeProtocol: () => new Promise<void>(() => undefined),
|
||||
forceClose,
|
||||
graceMs: 1,
|
||||
onTimeout: () => {
|
||||
throw reportingError;
|
||||
},
|
||||
}),
|
||||
).rejects.toBe(reportingError);
|
||||
expect(forceClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('reports a protocol rejection that arrives after the shutdown deadline', async () => {
|
||||
const protocol = Promise.withResolvers<void>();
|
||||
const onLateProtocolError = vi.fn();
|
||||
await shutdownRuntime({
|
||||
closeFeatures: async () => undefined,
|
||||
closeProtocol: () => protocol.promise,
|
||||
forceClose: async () => undefined,
|
||||
graceMs: 1,
|
||||
onTimeout: vi.fn(),
|
||||
onLateProtocolError,
|
||||
});
|
||||
const failure = new Error('late protocol failure');
|
||||
protocol.reject(failure);
|
||||
await vi.waitFor(() => expect(onLateProtocolError).toHaveBeenCalledWith(failure));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,233 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,462 @@
|
||||
import { once } from 'node:events';
|
||||
import type { IncomingMessage } from 'node:http';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import WebSocket, { WebSocketServer } from 'ws';
|
||||
import { EventSubscriptions, normalizeEventSubscriptionOptions } from '../src/subscriptions';
|
||||
import type { StructuredResult } from '../src/results';
|
||||
|
||||
const SELF = 10001;
|
||||
const GROUP = 20001;
|
||||
const USER = 30001;
|
||||
const cleanup: Array<() => void | Promise<void>> = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const close of cleanup.splice(0).reverse()) {
|
||||
await close();
|
||||
}
|
||||
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
async function source(
|
||||
options: { hello?: boolean; token?: string; selfId?: number; autoPong?: boolean } = {},
|
||||
) {
|
||||
const server = new WebSocketServer({
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
autoPong: options.autoPong ?? true,
|
||||
});
|
||||
const sockets = new Set<WebSocket>();
|
||||
const requests: IncomingMessage[] = [];
|
||||
const received: string[] = [];
|
||||
server.on('connection', (socket, request) => {
|
||||
sockets.add(socket);
|
||||
requests.push(request);
|
||||
socket.on('close', () => sockets.delete(socket));
|
||||
socket.on('message', (bytes) => received.push(bytes.toString()));
|
||||
|
||||
if (options.hello !== false) {
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
self_id: options.selfId ?? SELF,
|
||||
post_type: 'meta_event',
|
||||
meta_event_type: 'lifecycle',
|
||||
sub_type: 'connect',
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
await once(server, 'listening');
|
||||
cleanup.push(async () => {
|
||||
for (const socket of sockets) {
|
||||
socket.terminate();
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
});
|
||||
const address = server.address();
|
||||
|
||||
if (typeof address !== 'object' || !address) {
|
||||
throw new Error('Test WebSocket listener unavailable');
|
||||
}
|
||||
|
||||
const url = `ws://127.0.0.1:${address.port}/event`;
|
||||
const manager = new EventSubscriptions({ url, accessToken: options.token, timeoutMs: 2000 });
|
||||
cleanup.push(() => manager.close());
|
||||
return {
|
||||
manager,
|
||||
requests,
|
||||
received,
|
||||
sockets,
|
||||
url,
|
||||
send(event: unknown) {
|
||||
for (const socket of sockets) {
|
||||
socket.send(JSON.stringify(event));
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function result(value: StructuredResult): Record<string, unknown> {
|
||||
expect(value.isError).toBeUndefined();
|
||||
return value.structuredContent.result as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function snapshot(manager: EventSubscriptions, uri: string) {
|
||||
const entry = manager.readResource(uri).contents[0]!;
|
||||
|
||||
if (!('text' in entry)) {
|
||||
throw new Error('Expected JSON resource');
|
||||
}
|
||||
|
||||
return JSON.parse(entry.text);
|
||||
}
|
||||
|
||||
async function subscribe(manager: EventSubscriptions, args: Record<string, unknown> = {}) {
|
||||
const value = result(await manager.callTool('subscribe_events', { self_id: SELF, ...args }));
|
||||
expect(value.status).toBe('connected');
|
||||
return value.uri as string;
|
||||
}
|
||||
|
||||
function message(fields: Record<string, unknown> = {}) {
|
||||
return {
|
||||
self_id: SELF,
|
||||
post_type: 'message',
|
||||
message_type: 'group',
|
||||
group_id: GROUP,
|
||||
user_id: USER,
|
||||
message_id: -42,
|
||||
message: [{ type: 'text', data: { text: 'fixture' } }],
|
||||
...fields,
|
||||
};
|
||||
}
|
||||
|
||||
describe('isolated OneBot event resources', () => {
|
||||
it('validates startup configuration before constructing an event manager or connecting', async () => {
|
||||
const test = await source();
|
||||
expect(
|
||||
normalizeEventSubscriptionOptions({ url: test.url, accessToken: 'fixture-token' }),
|
||||
).toEqual({ url: test.url, accessToken: 'fixture-token', timeoutMs: 30_000 });
|
||||
expect(
|
||||
normalizeEventSubscriptionOptions({ url: 'wss://EXAMPLE.invalid', timeoutMs: 1234 }),
|
||||
).toEqual({ url: 'wss://example.invalid/', timeoutMs: 1234 });
|
||||
|
||||
for (const url of [
|
||||
'not-a-url',
|
||||
'https://example.invalid',
|
||||
'ws://user:secret@example.invalid',
|
||||
'ws://example.invalid?token=secret',
|
||||
'ws://example.invalid/#secret',
|
||||
]) {
|
||||
expect(() => normalizeEventSubscriptionOptions({ url })).toThrow(
|
||||
'SNOWLUMA_MCP_EVENTS_URL',
|
||||
);
|
||||
}
|
||||
|
||||
for (const timeoutMs of [0, -1, 0.5, NaN, Infinity]) {
|
||||
expect(() => normalizeEventSubscriptionOptions({ url: test.url, timeoutMs })).toThrow(
|
||||
'timeout',
|
||||
);
|
||||
}
|
||||
|
||||
expect(test.requests).toEqual([]);
|
||||
});
|
||||
|
||||
it('connects only on explicit subscription and uses an authorization header without logging credentials', async () => {
|
||||
const test = await source({ token: 'secret-event-token' });
|
||||
expect(test.requests).toHaveLength(0);
|
||||
expect(test.manager.resources()).toEqual([]);
|
||||
const uri = await subscribe(test.manager);
|
||||
expect(test.requests).toHaveLength(1);
|
||||
expect(test.requests[0]!.headers.authorization).toBe('Bearer secret-event-token');
|
||||
expect(test.requests[0]!.url).toBe('/event');
|
||||
expect(test.manager.resources()).toMatchObject([{ uri }]);
|
||||
expect(snapshot(test.manager, uri)).toMatchObject({
|
||||
self_id: SELF,
|
||||
status: 'connected',
|
||||
events: [],
|
||||
next_cursor: 0,
|
||||
gap: false,
|
||||
});
|
||||
expect(test.received).toEqual([]);
|
||||
});
|
||||
|
||||
it('filters event type, group and user before buffering and preserves signed message IDs', async () => {
|
||||
const test = await source();
|
||||
const uri = await subscribe(test.manager, {
|
||||
types: ['message'],
|
||||
group_ids: [GROUP],
|
||||
user_ids: [USER],
|
||||
});
|
||||
test.send(message({ post_type: 'notice' }));
|
||||
test.send(message({ group_id: GROUP + 1 }));
|
||||
test.send(message({ user_id: USER + 1 }));
|
||||
test.send(message({ group_id: undefined, message_type: 'private' }));
|
||||
test.send(message());
|
||||
test.send(message({ post_type: 'message_sent', message_id: -43 }));
|
||||
await vi.waitFor(() => expect(snapshot(test.manager, uri).next_cursor).toBe(2));
|
||||
const read = snapshot(test.manager, `${uri}?after=1`);
|
||||
expect(read.events).toMatchObject([
|
||||
{ cursor: 2, event: { message_id: -43, post_type: 'message_sent' } },
|
||||
]);
|
||||
expect(read.gap).toBe(false);
|
||||
expect(test.received).toEqual([]);
|
||||
});
|
||||
|
||||
it('retains bounded event and byte windows with explicit cursor gaps', async () => {
|
||||
const test = await source();
|
||||
const uri = await subscribe(test.manager);
|
||||
|
||||
for (let index = 0; index < 300; index++) {
|
||||
test.send(message({ message_id: -index - 1 }));
|
||||
}
|
||||
|
||||
await vi.waitFor(() => expect(snapshot(test.manager, uri).next_cursor).toBe(300));
|
||||
const read = snapshot(test.manager, uri);
|
||||
expect(read.events).toHaveLength(256);
|
||||
expect(read).toMatchObject({ oldest_cursor: 45, dropped_events: 44, gap: true });
|
||||
expect(snapshot(test.manager, `${uri}?after=44`).gap).toBe(false);
|
||||
expect(snapshot(test.manager, `${uri}?after=300`).events).toEqual([]);
|
||||
expect(() => snapshot(test.manager, `${uri}?after=301`)).toThrow('cursor');
|
||||
|
||||
for (let index = 0; index < 8; index++) {
|
||||
test.send(message({ raw_message: 'x'.repeat(200_000) }));
|
||||
}
|
||||
|
||||
await vi.waitFor(() => expect(snapshot(test.manager, uri).next_cursor).toBe(308));
|
||||
const large = snapshot(test.manager, uri);
|
||||
expect(large.events).toHaveLength(5);
|
||||
expect(large.dropped_events).toBe(303);
|
||||
expect(large.gap).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects another account at connection validation and never exposes its events', async () => {
|
||||
const log = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const test = await source({ selfId: SELF + 1 });
|
||||
const reply = await test.manager.callTool('subscribe_events', { self_id: SELF });
|
||||
expect(reply).toMatchObject({
|
||||
isError: true,
|
||||
structuredContent: { diagnostics: { kind: 'transport' } },
|
||||
});
|
||||
expect(test.manager.resources()).toEqual([]);
|
||||
expect(log.mock.calls.flat().join(' ')).toContain('account_mismatch');
|
||||
expect(log.mock.calls.flat().join(' ')).not.toContain(String(SELF + 1));
|
||||
await vi.waitFor(() => expect(test.sockets.size).toBe(0));
|
||||
});
|
||||
|
||||
it('makes account changes and disconnections visible without automatic reconnection', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const test = await source();
|
||||
const uri = await subscribe(test.manager);
|
||||
test.send(message());
|
||||
test.send(message({ self_id: SELF + 1, raw_message: 'other-account-private-data' }));
|
||||
await vi.waitFor(() => expect(snapshot(test.manager, uri).status).toBe('disconnected'));
|
||||
const read = snapshot(test.manager, uri);
|
||||
expect(read).toMatchObject({ gap: true, problem: 'account_mismatch', next_cursor: 1 });
|
||||
expect(JSON.stringify(read)).not.toContain('other-account-private-data');
|
||||
expect(test.requests).toHaveLength(1);
|
||||
const retry = await test.manager.callTool('subscribe_events', { self_id: SELF });
|
||||
expect(retry.isError).toBe(true);
|
||||
expect(test.requests).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('shares one source for matching accounts and releases it after the last subscription', async () => {
|
||||
const test = await source();
|
||||
const first = await subscribe(test.manager);
|
||||
const second = await subscribe(test.manager, { types: ['notice', 'request'] });
|
||||
expect(test.requests).toHaveLength(1);
|
||||
const mismatch = await test.manager.callTool('subscribe_events', { self_id: SELF + 1 });
|
||||
expect(mismatch).toMatchObject({
|
||||
isError: true,
|
||||
structuredContent: { diagnostics: { kind: 'invalid_arguments' } },
|
||||
});
|
||||
result(await test.manager.callTool('unsubscribe_events', { uri: first }));
|
||||
expect(test.sockets.size).toBe(1);
|
||||
test.send(message({ post_type: 'request', request_type: 'friend' }));
|
||||
await vi.waitFor(() => expect(snapshot(test.manager, second).next_cursor).toBe(1));
|
||||
test.manager.remove(second);
|
||||
await vi.waitFor(() => expect(test.sockets.size).toBe(0));
|
||||
expect(test.manager.resources()).toEqual([]);
|
||||
expect(() => snapshot(test.manager, first)).toThrow('not found');
|
||||
await subscribe(test.manager, { self_id: SELF });
|
||||
expect(test.requests).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('rejects malformed and oversized frames with a visible gap instead of swallowing them', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const test = await source();
|
||||
const uri = await subscribe(test.manager);
|
||||
test.send({ post_type: 'message', self_id: 'wrong-type' });
|
||||
await vi.waitFor(() => expect(snapshot(test.manager, uri).problem).toBe('invalid_event'));
|
||||
expect(snapshot(test.manager, uri).gap).toBe(true);
|
||||
test.manager.remove(uri);
|
||||
const large = await subscribe(test.manager);
|
||||
test.send(message({ raw_message: 'x'.repeat(300_000) }));
|
||||
await vi.waitFor(() =>
|
||||
expect(snapshot(test.manager, large).problem).toBe('frame_too_large'),
|
||||
);
|
||||
expect(snapshot(test.manager, large).events).toEqual([]);
|
||||
});
|
||||
|
||||
it('handles transport closure, cancellation and explicit close without leaking connections', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const test = await source();
|
||||
const uri = await subscribe(test.manager);
|
||||
|
||||
for (const socket of test.sockets) {
|
||||
socket.close();
|
||||
}
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(snapshot(test.manager, uri).problem).toBe('connection_closed'),
|
||||
);
|
||||
test.manager.remove(uri);
|
||||
const pending = await source({ hello: false });
|
||||
const controller = new AbortController();
|
||||
const subscription = pending.manager.callTool(
|
||||
'subscribe_events',
|
||||
{ self_id: SELF },
|
||||
controller.signal,
|
||||
);
|
||||
await vi.waitFor(() => expect(pending.sockets.size).toBe(1));
|
||||
controller.abort();
|
||||
expect(await subscription).toMatchObject({
|
||||
isError: true,
|
||||
structuredContent: { diagnostics: { kind: 'cancelled' } },
|
||||
});
|
||||
await vi.waitFor(() => expect(pending.sockets.size).toBe(0));
|
||||
expect(pending.manager.resources()).toEqual([]);
|
||||
await subscribe(test.manager);
|
||||
test.manager.close();
|
||||
await vi.waitFor(() => expect(test.sockets.size).toBe(0));
|
||||
});
|
||||
|
||||
it('keeps notification backlog bounded and coalesces updates behind a slow reader', async () => {
|
||||
const test = await source();
|
||||
const uri = await subscribe(test.manager);
|
||||
let release!: () => void;
|
||||
const blocked = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const notify = vi.fn(async () => blocked);
|
||||
test.manager.onUpdate(notify);
|
||||
test.send(message());
|
||||
await vi.waitFor(() => expect(notify).toHaveBeenCalledTimes(1));
|
||||
|
||||
for (let index = 0; index < 500; index++) {
|
||||
test.send(message());
|
||||
}
|
||||
|
||||
await vi.waitFor(() => expect(snapshot(test.manager, uri).next_cursor).toBe(501));
|
||||
expect(notify).toHaveBeenCalledTimes(1);
|
||||
expect(snapshot(test.manager, uri).events).toHaveLength(256);
|
||||
release();
|
||||
await vi.waitFor(() => expect(notify).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
|
||||
it('makes notification delivery failures observable without logging sensitive input', async () => {
|
||||
const log = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const test = await source({ token: 'private-token' });
|
||||
const uri = await subscribe(test.manager);
|
||||
const detach = test.manager.onUpdate(async () => {
|
||||
throw new Error('sensitive-event-or-token');
|
||||
});
|
||||
test.send(message());
|
||||
await vi.waitFor(() => expect(snapshot(test.manager, uri).notification_failed).toBe(true));
|
||||
expect(log.mock.calls.flat().join(' ')).toContain('notification failed');
|
||||
expect(log.mock.calls.flat().join(' ')).not.toContain('sensitive-event-or-token');
|
||||
expect(log.mock.calls.flat().join(' ')).not.toContain('private-token');
|
||||
detach();
|
||||
});
|
||||
|
||||
it('validates filter and resource inputs and caps resource count', async () => {
|
||||
const test = await source();
|
||||
|
||||
for (const args of [
|
||||
{ self_id: -1 },
|
||||
{ self_id: SELF, types: [] },
|
||||
{ self_id: SELF, types: ['other'] },
|
||||
{ self_id: SELF, group_ids: [GROUP, GROUP] },
|
||||
{ self_id: SELF, user_ids: ['30001'] },
|
||||
{ self_id: SELF, unexpected: true },
|
||||
]) {
|
||||
expect(await test.manager.callTool('subscribe_events', args)).toMatchObject({
|
||||
isError: true,
|
||||
structuredContent: { diagnostics: { kind: 'invalid_arguments' } },
|
||||
});
|
||||
}
|
||||
|
||||
expect(test.requests).toHaveLength(0);
|
||||
|
||||
for (let index = 0; index < 16; index++) {
|
||||
await subscribe(test.manager);
|
||||
}
|
||||
|
||||
expect((await test.manager.callTool('subscribe_events', { self_id: SELF })).isError).toBe(
|
||||
true,
|
||||
);
|
||||
expect(test.requests).toHaveLength(1);
|
||||
const uri = test.manager.resources()[0]!.uri;
|
||||
expect(() => test.manager.remove(`${uri}?after=0`)).toThrow('canonical');
|
||||
expect(() => snapshot(test.manager, `${uri}?after=-1`)).toThrow('Invalid event');
|
||||
expect(() => snapshot(test.manager, `${uri}?after=9007199254740992`)).toThrow('cursor');
|
||||
expect(() => new EventSubscriptions({ url: 'http://example.invalid' })).toThrow('ws/wss');
|
||||
expect(
|
||||
() => new EventSubscriptions({ url: 'ws://example.invalid?access_token=secret' }),
|
||||
).toThrow('without credentials');
|
||||
});
|
||||
|
||||
it('isolates resource identifiers and buffers between MCP sessions', async () => {
|
||||
const first = await source();
|
||||
const second = await source();
|
||||
const uri = await subscribe(first.manager);
|
||||
const other = await subscribe(second.manager);
|
||||
first.send(message({ raw_message: 'first-session-private-event' }));
|
||||
await vi.waitFor(() => expect(snapshot(first.manager, uri).next_cursor).toBe(1));
|
||||
expect(snapshot(second.manager, other).events).toEqual([]);
|
||||
expect(() => snapshot(second.manager, uri)).toThrow('not found');
|
||||
expect(other).not.toBe(uri);
|
||||
});
|
||||
|
||||
it('cleans up resources when constructing the WebSocket request fails synchronously', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const test = await source({ token: 'bad\nheader' });
|
||||
const reply = await test.manager.callTool('subscribe_events', { self_id: SELF });
|
||||
expect(reply.isError).toBe(true);
|
||||
expect(test.manager.resources()).toEqual([]);
|
||||
expect(test.requests).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not report a removed pending subscription as successfully connected', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const test = await source({ hello: false });
|
||||
const first = test.manager.callTool('subscribe_events', { self_id: SELF });
|
||||
const second = test.manager.callTool('subscribe_events', { self_id: SELF });
|
||||
await vi.waitFor(() => expect(test.sockets.size).toBe(1));
|
||||
const firstUri = test.manager.resources()[0]!.uri;
|
||||
test.manager.remove(firstUri);
|
||||
expect((await first).isError).toBe(true);
|
||||
test.send({ self_id: SELF, post_type: 'meta_event', meta_event_type: 'lifecycle' });
|
||||
expect((await second).isError).toBeUndefined();
|
||||
expect(test.manager.resources()).toHaveLength(1);
|
||||
expect(() => snapshot(test.manager, firstUri)).toThrow('not found');
|
||||
});
|
||||
|
||||
it('reports a half-open connection when the WebSocket peer stops answering pings', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const timer = vi.spyOn(globalThis, 'setInterval');
|
||||
const test = await source({ autoPong: false });
|
||||
const uri = await subscribe(test.manager);
|
||||
const heartbeat = timer.mock.calls.find((call) => call[1] === 30_000)?.[0];
|
||||
|
||||
if (typeof heartbeat !== 'function') {
|
||||
throw new Error('Expected a heartbeat callback');
|
||||
}
|
||||
|
||||
const socket = Array.from(test.sockets)[0]!;
|
||||
const ping = once(socket, 'ping');
|
||||
heartbeat();
|
||||
await ping;
|
||||
heartbeat();
|
||||
expect(snapshot(test.manager, uri)).toMatchObject({
|
||||
status: 'disconnected',
|
||||
problem: 'connection_timeout',
|
||||
gap: true,
|
||||
});
|
||||
await vi.waitFor(() => expect(test.sockets.size).toBe(0));
|
||||
});
|
||||
|
||||
it('rejects excessively nested JSON before it can crash resource serialization', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const test = await source();
|
||||
const uri = await subscribe(test.manager);
|
||||
const raw = `{"self_id":${SELF},"post_type":"message","nested":${'['.repeat(10_000)}0${']'.repeat(10_000)}}`;
|
||||
|
||||
for (const socket of test.sockets) {
|
||||
socket.send(raw);
|
||||
}
|
||||
|
||||
await vi.waitFor(() => expect(snapshot(test.manager, uri).problem).toBe('invalid_event'));
|
||||
expect(snapshot(test.manager, uri)).toMatchObject({ events: [], gap: true });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,695 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import {
|
||||
chmod,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
readdir,
|
||||
rename,
|
||||
rm,
|
||||
stat,
|
||||
symlink,
|
||||
writeFile,
|
||||
} from 'node:fs/promises';
|
||||
import { tmpdir, hostname } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
createMcpHandler,
|
||||
InMemoryTransport,
|
||||
Server,
|
||||
type CallToolResult,
|
||||
} from '@modelcontextprotocol/server';
|
||||
import { PersistentTasks, installTaskHandlers, supportsTasks, TASK_EXTENSION } from '../src/tasks';
|
||||
import { makeHttpClient, type ActionClient } from '../src/client';
|
||||
import { callTool } from '../src/tools';
|
||||
import { createMcpServer } from '../src/server-factory';
|
||||
|
||||
const scopeId = 'a'.repeat(64);
|
||||
const cleanup: Array<() => Promise<void>> = [];
|
||||
const success: CallToolResult = { content: [{ type: 'text', text: 'completed' }] };
|
||||
const input = {
|
||||
method: 'elicitation/create' as const,
|
||||
params: {
|
||||
mode: 'form' as const,
|
||||
message: 'Choose a value',
|
||||
requestedSchema: {
|
||||
type: 'object' as const,
|
||||
properties: { value: { type: 'string' as const } },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
afterEach(async () => {
|
||||
for (const close of cleanup.splice(0).reverse()) {
|
||||
await close();
|
||||
}
|
||||
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
async function directory() {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'snowluma-tasks-'));
|
||||
cleanup.push(() => rm(dir, { recursive: true, force: true }));
|
||||
return dir;
|
||||
}
|
||||
|
||||
async function openTasks(
|
||||
dir?: string,
|
||||
overrides: Partial<Parameters<typeof PersistentTasks.open>[1]> = {},
|
||||
) {
|
||||
const tasks = await PersistentTasks.open(dir ?? (await directory()), {
|
||||
scopeId,
|
||||
onError: vi.fn(),
|
||||
...overrides,
|
||||
});
|
||||
cleanup.push(() => tasks.close());
|
||||
return tasks;
|
||||
}
|
||||
|
||||
async function finished(tasks: PersistentTasks, id: string) {
|
||||
await vi.waitFor(() =>
|
||||
expect(['completed', 'failed', 'cancelled']).toContain(tasks.get(id).status),
|
||||
);
|
||||
return tasks.get(id);
|
||||
}
|
||||
|
||||
describe('durable task execution', () => {
|
||||
it('does not begin a side effect after shutdown was already queued', async () => {
|
||||
const tasks = await openTasks();
|
||||
const execute = vi.fn(async () => success);
|
||||
const started = tasks.start(execute);
|
||||
const closing = tasks.close();
|
||||
const [handle] = await Promise.all([started, closing]);
|
||||
expect(execute).not.toHaveBeenCalled();
|
||||
expect(tasks.get(handle.taskId)).toMatchObject({
|
||||
status: 'failed',
|
||||
statusMessage: expect.stringContaining('unknown'),
|
||||
});
|
||||
});
|
||||
|
||||
it('persists before executing, keeps terminal results across restart, and stores no callback arguments', async () => {
|
||||
const dir = await directory();
|
||||
const tasks = await openTasks(dir);
|
||||
const handle = await tasks.start(async ({ taskId }) => {
|
||||
const persisted = JSON.parse(
|
||||
await readFile(path.join(dir, `${taskId}.task.json`), 'utf8'),
|
||||
);
|
||||
expect(persisted.task.status).toBe('working');
|
||||
return success;
|
||||
});
|
||||
expect(handle).toMatchObject({ resultType: 'task', status: 'working', ttlMs: null });
|
||||
expect(await finished(tasks, handle.taskId)).toMatchObject({
|
||||
status: 'completed',
|
||||
result: { ...success, resultType: 'complete' },
|
||||
});
|
||||
await tasks.close();
|
||||
const reopened = await openTasks(dir);
|
||||
expect(reopened.get(handle.taskId)).toMatchObject({ status: 'completed', result: success });
|
||||
const record = JSON.parse(
|
||||
await readFile(path.join(dir, `${handle.taskId}.task.json`), 'utf8'),
|
||||
);
|
||||
expect(Object.keys(record).sort()).toEqual(['task', 'version']);
|
||||
|
||||
if (process.platform !== 'win32') {
|
||||
expect((await stat(dir)).mode & 0o777).toBe(0o700);
|
||||
expect((await stat(path.join(dir, `${handle.taskId}.task.json`))).mode & 0o777).toBe(
|
||||
0o600,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('reports tool business failures as completed results rather than protocol failure', async () => {
|
||||
const tasks = await openTasks();
|
||||
const handle = await tasks.start(async () => ({ content: [], isError: true }));
|
||||
expect(await finished(tasks, handle.taskId)).toMatchObject({
|
||||
status: 'completed',
|
||||
result: { isError: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('does not persist raw exceptions or credentials', async () => {
|
||||
const dir = await directory();
|
||||
const tasks = await openTasks(dir);
|
||||
const handle = await tasks.start(async () => {
|
||||
throw new Error('Bearer private-secret');
|
||||
});
|
||||
expect(await finished(tasks, handle.taskId)).toMatchObject({
|
||||
status: 'failed',
|
||||
error: { code: -32603 },
|
||||
});
|
||||
expect(await readFile(path.join(dir, `${handle.taskId}.task.json`), 'utf8')).not.toContain(
|
||||
'private-secret',
|
||||
);
|
||||
});
|
||||
|
||||
it('marks crash-interrupted records unknown without replaying work', async () => {
|
||||
const dir = await directory();
|
||||
const first = await openTasks(dir);
|
||||
await first.close();
|
||||
const id = randomUUID();
|
||||
const createdAt = new Date().toISOString();
|
||||
await writeFile(
|
||||
path.join(dir, `${id}.task.json`),
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
task: {
|
||||
taskId: id,
|
||||
status: 'working',
|
||||
createdAt,
|
||||
lastUpdatedAt: createdAt,
|
||||
ttlMs: null,
|
||||
pollIntervalMs: 1000,
|
||||
},
|
||||
}),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
const reopened = await openTasks(dir);
|
||||
expect(reopened.get(id)).toMatchObject({
|
||||
status: 'failed',
|
||||
error: { code: -32603, message: expect.stringContaining('not replayed') },
|
||||
});
|
||||
});
|
||||
|
||||
it('propagates cancellation to the execution and preserves a completed result in a race', async () => {
|
||||
const tasks = await openTasks();
|
||||
let observed!: AbortSignal;
|
||||
const handle = await tasks.start(async ({ signal }) => {
|
||||
observed = signal;
|
||||
await new Promise<void>((resolve) =>
|
||||
signal.addEventListener('abort', () => resolve(), { once: true }),
|
||||
);
|
||||
return success;
|
||||
});
|
||||
await tasks.cancel(handle.taskId);
|
||||
expect(observed.aborted).toBe(true);
|
||||
expect(await finished(tasks, handle.taskId)).toMatchObject({ status: 'completed' });
|
||||
await tasks.cancel(handle.taskId);
|
||||
expect(tasks.get(handle.taskId).status).toBe('completed');
|
||||
});
|
||||
|
||||
it('settles abort-rejecting operations as cancelled and never re-executes', async () => {
|
||||
const tasks = await openTasks();
|
||||
const execute = vi.fn(async ({ signal }: { signal: AbortSignal }) => {
|
||||
await new Promise((_, reject) =>
|
||||
signal.addEventListener('abort', () => reject(signal.reason), { once: true }),
|
||||
);
|
||||
return success;
|
||||
});
|
||||
const handle = await tasks.start(execute);
|
||||
await tasks.cancel(handle.taskId);
|
||||
expect(await finished(tasks, handle.taskId)).toMatchObject({
|
||||
status: 'cancelled',
|
||||
statusMessage: expect.stringContaining('does not undo'),
|
||||
});
|
||||
expect(execute).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('closes all running operations and leaves unknown outcomes readable after restart', async () => {
|
||||
const dir = await directory();
|
||||
const tasks = await openTasks(dir);
|
||||
let signal!: AbortSignal;
|
||||
const handle = await tasks.start(async (ctx) => {
|
||||
signal = ctx.signal;
|
||||
return new Promise<CallToolResult>(() => undefined);
|
||||
});
|
||||
await tasks.close();
|
||||
expect(signal.aborted).toBe(true);
|
||||
const reopened = await openTasks(dir);
|
||||
expect(reopened.get(handle.taskId)).toMatchObject({
|
||||
status: 'failed',
|
||||
statusMessage: expect.stringContaining('unknown'),
|
||||
});
|
||||
await expect(tasks.start(async () => success)).rejects.toThrow('closed');
|
||||
});
|
||||
|
||||
it('marks every aborted runner terminal even when callbacks settle during close', async () => {
|
||||
const tasks = await openTasks();
|
||||
const handles = await Promise.all(
|
||||
[1, 2, 3].map(() =>
|
||||
tasks.start(async ({ signal }) => {
|
||||
await new Promise((_, reject) =>
|
||||
signal.addEventListener('abort', () => reject(signal.reason), {
|
||||
once: true,
|
||||
}),
|
||||
);
|
||||
return success;
|
||||
}),
|
||||
),
|
||||
);
|
||||
await tasks.close();
|
||||
|
||||
for (const handle of handles) {
|
||||
expect(tasks.get(handle.taskId).status).toBe('failed');
|
||||
}
|
||||
});
|
||||
|
||||
it('enforces active concurrency without deleting completed history', async () => {
|
||||
const tasks = await openTasks(undefined, { maxConcurrent: 1 });
|
||||
let resolve!: (value: CallToolResult) => void;
|
||||
const first = await tasks.start(
|
||||
() =>
|
||||
new Promise<CallToolResult>((yes) => {
|
||||
resolve = yes;
|
||||
}),
|
||||
);
|
||||
await expect(tasks.start(async () => success)).rejects.toThrow('concurrency');
|
||||
resolve(success);
|
||||
await finished(tasks, first.taskId);
|
||||
const second = await tasks.start(async () => success);
|
||||
await finished(tasks, second.taskId);
|
||||
const page = tasks.list({ limit: 1 });
|
||||
expect(page.tasks).toHaveLength(1);
|
||||
expect(page.tasks[0]).not.toHaveProperty('result');
|
||||
expect(tasks.list({ cursor: page.nextCursor, limit: 1 }).tasks[0].taskId).toBe(
|
||||
second.taskId,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('task input lifecycle', () => {
|
||||
it('accepts partial input, ignores repeated keys, and resumes the same callback once', async () => {
|
||||
const tasks = await openTasks();
|
||||
const execute = vi.fn(async ({ requestInput }) => {
|
||||
const responses = await requestInput({ first: input, second: input });
|
||||
expect(responses).toMatchObject({
|
||||
first: { action: 'accept', content: { value: 'one' } },
|
||||
second: { action: 'decline' },
|
||||
});
|
||||
return success;
|
||||
});
|
||||
const handle = await tasks.start(execute);
|
||||
await vi.waitFor(() => expect(tasks.get(handle.taskId).status).toBe('input_required'));
|
||||
await tasks.update(handle.taskId, {
|
||||
unknown: {},
|
||||
first: { action: 'accept', content: { value: 'one' } },
|
||||
});
|
||||
expect(tasks.get(handle.taskId)).toMatchObject({
|
||||
status: 'input_required',
|
||||
inputRequests: { second: input },
|
||||
});
|
||||
expect(tasks.get(handle.taskId)).not.toHaveProperty('inputRequests.first');
|
||||
await tasks.update(handle.taskId, {
|
||||
first: { action: 'cancel' },
|
||||
second: { action: 'decline' },
|
||||
});
|
||||
expect(await finished(tasks, handle.taskId)).toMatchObject({ status: 'completed' });
|
||||
expect(execute).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('rejects malformed matching input without losing the pending request', async () => {
|
||||
const tasks = await openTasks();
|
||||
const handle = await tasks.start(async ({ requestInput }) => {
|
||||
await requestInput({ value: input });
|
||||
return success;
|
||||
});
|
||||
await vi.waitFor(() => expect(tasks.get(handle.taskId).status).toBe('input_required'));
|
||||
await expect(tasks.update(handle.taskId, { value: { action: 'wrong' } })).rejects.toThrow(
|
||||
'Invalid task input response',
|
||||
);
|
||||
expect(tasks.get(handle.taskId).status).toBe('input_required');
|
||||
await tasks.cancel(handle.taskId);
|
||||
expect((await finished(tasks, handle.taskId)).status).toBe('cancelled');
|
||||
});
|
||||
|
||||
it('never reuses input keys across rounds', async () => {
|
||||
const tasks = await openTasks();
|
||||
const handle = await tasks.start(async ({ requestInput }) => {
|
||||
await requestInput({ value: input });
|
||||
await requestInput({ value: input });
|
||||
return success;
|
||||
});
|
||||
await vi.waitFor(() => expect(tasks.get(handle.taskId).status).toBe('input_required'));
|
||||
await tasks.update(handle.taskId, { value: { action: 'decline' } });
|
||||
expect((await finished(tasks, handle.taskId)).status).toBe('failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('task storage boundary', () => {
|
||||
it('recovers a dead-process lease with an explicit diagnostic', async () => {
|
||||
const dir = await directory();
|
||||
const previous = spawnSync(process.execPath, ['-e', 'process.exit(0)']);
|
||||
expect(previous.status).toBe(0);
|
||||
const lease = path.join(dir, `.lease-${randomUUID()}.json`);
|
||||
await writeFile(lease, JSON.stringify({ pid: previous.pid, host: hostname() }), {
|
||||
mode: 0o600,
|
||||
});
|
||||
const onError = vi.fn();
|
||||
await openTasks(dir, { onError });
|
||||
expect(onError).toHaveBeenCalledWith(expect.any(Error), { event: 'recovered_stale_lock' });
|
||||
await expect(stat(lease)).rejects.toMatchObject({ code: 'ENOENT' });
|
||||
});
|
||||
|
||||
it('makes persistence failures visible and still aborts all work during shutdown', async () => {
|
||||
const dir = await directory();
|
||||
const tasks = await openTasks(dir);
|
||||
let resolve!: (value: CallToolResult) => void;
|
||||
const first = await tasks.start(
|
||||
() =>
|
||||
new Promise<CallToolResult>((yes) => {
|
||||
resolve = yes;
|
||||
}),
|
||||
);
|
||||
let pending!: AbortSignal;
|
||||
const second = await tasks.start(async ({ signal }) => {
|
||||
pending = signal;
|
||||
return new Promise<CallToolResult>(() => undefined);
|
||||
});
|
||||
const file = path.join(dir, `${first.taskId}.task.json`);
|
||||
await rename(file, `${file}.saved`);
|
||||
await mkdir(file);
|
||||
resolve(success);
|
||||
await vi.waitFor(() => expect(() => tasks.get(first.taskId)).toThrow('persistence failed'));
|
||||
expect(() => tasks.get(second.taskId)).toThrow('persistence failed');
|
||||
await tasks.close();
|
||||
expect(pending.aborted).toBe(true);
|
||||
expect((await readdir(dir)).some((entry) => entry.startsWith('.lease-'))).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses concurrent owners and scope changes', async () => {
|
||||
const dir = await directory();
|
||||
const owner = await openTasks(dir);
|
||||
await expect(PersistentTasks.open(dir, { scopeId })).rejects.toThrow('locked');
|
||||
await owner.close();
|
||||
await expect(PersistentTasks.open(dir, { scopeId: 'b'.repeat(64) })).rejects.toThrow(
|
||||
'different account',
|
||||
);
|
||||
});
|
||||
|
||||
it('fails clearly on corrupt records and symlinks', async () => {
|
||||
const dir = await directory();
|
||||
const tasks = await openTasks(dir);
|
||||
await tasks.close();
|
||||
const file = path.join(dir, `${randomUUID()}.task.json`);
|
||||
await writeFile(file, '{broken', { mode: 0o600 });
|
||||
await expect(PersistentTasks.open(dir, { scopeId })).rejects.toThrow();
|
||||
await rm(file);
|
||||
await symlink(path.join(dir, 'scope.json'), file);
|
||||
await expect(PersistentTasks.open(dir, { scopeId })).rejects.toThrow(
|
||||
'private regular file',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects traversal and unknown identifiers without touching other files', async () => {
|
||||
const tasks = await openTasks();
|
||||
expect(() => tasks.get('../scope')).toThrow('Invalid task identifier');
|
||||
expect(() => tasks.get(randomUUID())).toThrow('Unknown task identifier');
|
||||
await expect(tasks.cancel('../scope')).rejects.toThrow('Invalid task identifier');
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === 'win32')('rejects group-readable directories', async () => {
|
||||
const dir = await directory();
|
||||
await chmod(dir, 0o755);
|
||||
await expect(PersistentTasks.open(dir, { scopeId })).rejects.toThrow('0700');
|
||||
});
|
||||
|
||||
it('reports notification failures without changing a completed result', async () => {
|
||||
const onError = vi.fn();
|
||||
const tasks = await openTasks(undefined, {
|
||||
onError,
|
||||
onChange: () => {
|
||||
throw new Error('subscriber unavailable');
|
||||
},
|
||||
});
|
||||
const handle = await tasks.start(async () => success);
|
||||
expect((await finished(tasks, handle.taskId)).status).toBe('completed');
|
||||
expect(onError).toHaveBeenCalledWith(
|
||||
expect.any(Error),
|
||||
expect.objectContaining({ event: 'task_notification_failed' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not let a stalled subscriber block task execution or cancellation', async () => {
|
||||
const tasks = await openTasks(undefined, {
|
||||
onChange: () => new Promise<void>(() => undefined),
|
||||
});
|
||||
const handle = await tasks.start(async ({ signal }) => {
|
||||
await new Promise((_, reject) =>
|
||||
signal.addEventListener('abort', () => reject(signal.reason), { once: true }),
|
||||
);
|
||||
return success;
|
||||
});
|
||||
await tasks.cancel(handle.taskId);
|
||||
expect((await finished(tasks, handle.taskId)).status).toBe('cancelled');
|
||||
});
|
||||
});
|
||||
|
||||
describe('task cancellation reaches HTTP and upload cleanup', () => {
|
||||
it.each([false, true])(
|
||||
'aborts the active HTTP operation and finishes cleanup (upload: %s)',
|
||||
async (upload) => {
|
||||
const dir = await directory();
|
||||
const inputFile = path.join(dir, 'media.bin');
|
||||
await writeFile(inputFile, 'media');
|
||||
const tasks = await openTasks();
|
||||
const signals: AbortSignal[] = [];
|
||||
const reset = vi.fn();
|
||||
const client = makeHttpClient({
|
||||
endpoint: 'https://unused.invalid',
|
||||
streamDir: dir,
|
||||
uploadRoot: dir,
|
||||
fetch: async (_url, init) => {
|
||||
const body = JSON.parse(String(init?.body));
|
||||
|
||||
if (body.params.reset) {
|
||||
reset();
|
||||
expect(init?.signal?.aborted).toBe(false);
|
||||
return new Response(
|
||||
`${JSON.stringify({ status: 'ok', retcode: 0, data: { type: 'response' } })}\r\n\r\n`,
|
||||
);
|
||||
}
|
||||
|
||||
const signal = init?.signal;
|
||||
|
||||
if (!signal) {
|
||||
throw new Error('Expected cancellation signal');
|
||||
}
|
||||
|
||||
signals.push(signal);
|
||||
return new Promise<Response>((_resolve, reject) => {
|
||||
signal.addEventListener('abort', () => reject(signal.reason), {
|
||||
once: true,
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
const handle = await tasks.start(({ signal }) =>
|
||||
callTool(
|
||||
upload ? 'invoke_action' : 'query_action',
|
||||
upload
|
||||
? { action: 'upload_file_stream', execution: { input_file: inputFile } }
|
||||
: { action: 'get_status' },
|
||||
{ mode: 'write', client, signal },
|
||||
),
|
||||
);
|
||||
await vi.waitFor(() => expect(signals).toHaveLength(1));
|
||||
await tasks.cancel(handle.taskId);
|
||||
const result = await finished(tasks, handle.taskId);
|
||||
expect(signals[0].aborted).toBe(true);
|
||||
expect(result).toMatchObject({
|
||||
status: 'completed',
|
||||
result: {
|
||||
isError: true,
|
||||
structuredContent: { diagnostics: { kind: 'cancelled', outcome: 'unknown' } },
|
||||
},
|
||||
});
|
||||
expect(reset).toHaveBeenCalledTimes(upload ? 1 : 0);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('2026 Tasks wire handlers', () => {
|
||||
it('does not activate modern tasks on a legacy connection with copied capability metadata', async () => {
|
||||
const tasks = await openTasks();
|
||||
const task = await tasks.start(async () => success);
|
||||
const client: ActionClient = {
|
||||
call: vi.fn(async () => ({ status: 'ok', retcode: 0 })),
|
||||
callStream: vi.fn(async () => ({
|
||||
frame_count: 0,
|
||||
frames: [],
|
||||
terminal: { status: 'ok', retcode: 0 },
|
||||
})),
|
||||
};
|
||||
const server = createMcpServer({ mode: 'write', tasks, client });
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
cleanup.push(() => server.close());
|
||||
await server.connect(serverTransport);
|
||||
await clientTransport.start();
|
||||
let id = 0;
|
||||
|
||||
const request = async (method: string, params: Record<string, unknown> = {}) => {
|
||||
const response = new Promise<unknown>((resolve) => {
|
||||
clientTransport.onmessage = resolve;
|
||||
});
|
||||
await clientTransport.send({
|
||||
jsonrpc: '2.0',
|
||||
id: ++id,
|
||||
method,
|
||||
params: {
|
||||
...params,
|
||||
_meta: {
|
||||
'io.modelcontextprotocol/clientCapabilities': {
|
||||
extensions: { [TASK_EXTENSION]: {} },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
return response;
|
||||
};
|
||||
|
||||
expect(await request('tools/list')).toMatchObject({
|
||||
result: {
|
||||
tools: expect.not.arrayContaining([
|
||||
expect.objectContaining({ name: 'invoke_action_background' }),
|
||||
]),
|
||||
},
|
||||
});
|
||||
expect(
|
||||
await request('tools/call', {
|
||||
name: 'invoke_action_background',
|
||||
arguments: { action: 'test_download_stream' },
|
||||
}),
|
||||
).toMatchObject({
|
||||
result: {
|
||||
isError: true,
|
||||
structuredContent: { diagnostics: { kind: 'configuration' } },
|
||||
},
|
||||
});
|
||||
expect(client.call).not.toHaveBeenCalled();
|
||||
expect(client.callStream).not.toHaveBeenCalled();
|
||||
expect(tasks.list().tasks).toHaveLength(1);
|
||||
expect(await request('tasks/get', { taskId: task.taskId })).toMatchObject({
|
||||
error: { code: -32021 },
|
||||
});
|
||||
});
|
||||
|
||||
async function harness() {
|
||||
const tasks = await openTasks();
|
||||
const handler = createMcpHandler(() => {
|
||||
const server = new Server(
|
||||
{ name: 'task-test', version: '1' },
|
||||
{ capabilities: { tools: {} } },
|
||||
);
|
||||
installTaskHandlers(server, tasks);
|
||||
server.setRequestHandler('tools/call', (request, ctx) => {
|
||||
expect(supportsTasks(ctx.mcpReq.envelope)).toBe(true);
|
||||
return tasks.start(async ({ requestInput }) => {
|
||||
if (request.params.name === 'input') {
|
||||
await requestInput({ answer: input });
|
||||
}
|
||||
|
||||
return success;
|
||||
});
|
||||
});
|
||||
return server;
|
||||
});
|
||||
cleanup.push(() => handler.close());
|
||||
|
||||
const request = async (
|
||||
method: string,
|
||||
params: Record<string, unknown> = {},
|
||||
capable = true,
|
||||
) => {
|
||||
const response = await handler.fetch(
|
||||
new Request('http://mcp.invalid/', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json, text/event-stream',
|
||||
'Mcp-Method': method,
|
||||
'Mcp-Protocol-Version': '2026-07-28',
|
||||
...(typeof params.name === 'string'
|
||||
? { 'Mcp-Name': params.name }
|
||||
: typeof params.taskId === 'string'
|
||||
? { 'Mcp-Name': params.taskId }
|
||||
: {}),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method,
|
||||
params: {
|
||||
...params,
|
||||
_meta: {
|
||||
'io.modelcontextprotocol/protocolVersion': '2026-07-28',
|
||||
'io.modelcontextprotocol/clientCapabilities': capable
|
||||
? { extensions: { [TASK_EXTENSION]: {} } }
|
||||
: {},
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
return response.json();
|
||||
};
|
||||
|
||||
return { tasks, request };
|
||||
}
|
||||
|
||||
it('creates a real task response and fetches durable terminal results', async () => {
|
||||
const { tasks, request } = await harness();
|
||||
const created = await request('tools/call', { name: 'work' });
|
||||
expect(created.result).toMatchObject({ resultType: 'task', status: 'working' });
|
||||
await finished(tasks, created.result.taskId);
|
||||
expect(
|
||||
(await request('tasks/get', { taskId: created.result.taskId })).result,
|
||||
).toMatchObject({
|
||||
resultType: 'complete',
|
||||
status: 'completed',
|
||||
result: { ...success, resultType: 'complete' },
|
||||
});
|
||||
expect(
|
||||
(await request('tasks/cancel', { taskId: created.result.taskId })).result,
|
||||
).toMatchObject({ resultType: 'complete' });
|
||||
});
|
||||
|
||||
it('restores SDK-lifted input responses and acknowledges updates', async () => {
|
||||
const { tasks, request } = await harness();
|
||||
const created = await request('tools/call', { name: 'input' });
|
||||
const taskId = created.result.taskId;
|
||||
await vi.waitFor(() => expect(tasks.get(taskId).status).toBe('input_required'));
|
||||
expect(
|
||||
(
|
||||
await request('tasks/update', {
|
||||
taskId,
|
||||
inputResponses: { irrelevant: { invalid: true } },
|
||||
})
|
||||
).result,
|
||||
).toMatchObject({ resultType: 'complete' });
|
||||
expect(
|
||||
(
|
||||
await request('tasks/update', {
|
||||
taskId,
|
||||
inputResponses: { answer: { invalid: true } },
|
||||
})
|
||||
).error.code,
|
||||
).toBe(-32602);
|
||||
expect(
|
||||
(
|
||||
await request('tasks/update', {
|
||||
taskId,
|
||||
inputResponses: { answer: { action: 'accept', content: { value: 'yes' } } },
|
||||
})
|
||||
).result,
|
||||
).toMatchObject({ resultType: 'complete' });
|
||||
expect((await finished(tasks, taskId)).status).toBe('completed');
|
||||
});
|
||||
|
||||
it('checks the capability on every lifecycle call, and does not expose obsolete methods', async () => {
|
||||
const { request } = await harness();
|
||||
const created = await request('tools/call', { name: 'work' });
|
||||
const taskId = created.result.taskId;
|
||||
|
||||
for (const method of ['tasks/get', 'tasks/update', 'tasks/cancel']) {
|
||||
expect((await request(method, { taskId }, false)).error).toMatchObject({
|
||||
code: -32021,
|
||||
data: { requiredCapabilities: { extensions: { [TASK_EXTENSION]: {} } } },
|
||||
});
|
||||
}
|
||||
|
||||
expect((await request('tasks/get', { taskId: randomUUID() })).error.code).toBe(-32602);
|
||||
expect((await request('tasks/list')).error).toBeDefined();
|
||||
expect((await request('tasks/result', { taskId })).error).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,339 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { computeTools, callTool, parseMode } from '../src/tools';
|
||||
import type {
|
||||
ActionClient,
|
||||
OneBotEnvelope,
|
||||
StreamCallOptions,
|
||||
StreamCallResult,
|
||||
} from '../src/client';
|
||||
|
||||
type Res = Awaited<ReturnType<typeof callTool>>;
|
||||
|
||||
function text(res: Res): string {
|
||||
const block = res.content?.[0];
|
||||
return block && block.type === 'text' ? block.text : '';
|
||||
}
|
||||
|
||||
function parsed(res: Res): any {
|
||||
return JSON.parse(text(res));
|
||||
}
|
||||
|
||||
/** A fake ActionClient — no transport. Records the last call and returns a
|
||||
* canned envelope (overridable per test). */
|
||||
function fakeClient(
|
||||
impl?: (action: string, params: Record<string, unknown>) => OneBotEnvelope,
|
||||
streamImpl?: (
|
||||
action: string,
|
||||
params: Record<string, unknown>,
|
||||
options?: StreamCallOptions,
|
||||
) => StreamCallResult,
|
||||
): ActionClient {
|
||||
return {
|
||||
call: async (action, params) =>
|
||||
impl?.(action, params) ?? { status: 'ok', retcode: 0, data: { action, params } },
|
||||
callStream: async (action, params, options) =>
|
||||
streamImpl?.(action, params, options) ?? {
|
||||
frame_count: 1,
|
||||
frames: [],
|
||||
terminal: { status: 'ok', retcode: 0, data: { type: 'response', action, params } },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('computeTools — mode gating', () => {
|
||||
it('fails fast on an invalid SNOWLUMA_MCP_MODE', () => {
|
||||
expect(parseMode(undefined)).toBeUndefined();
|
||||
expect(parseMode('WRITE')).toBe('write');
|
||||
|
||||
for (const invalid of ['', 'unsafe', 'reader']) {
|
||||
expect(() => parseMode(invalid), invalid).toThrow(/SNOWLUMA_MCP_MODE/);
|
||||
}
|
||||
});
|
||||
it('docs mode exposes catalog tools and local runtime information', () => {
|
||||
expect(computeTools('docs').map((t) => t.name)).toEqual([
|
||||
'list_actions',
|
||||
'get_action',
|
||||
'search_actions',
|
||||
'list_categories',
|
||||
'get_runtime_info',
|
||||
]);
|
||||
});
|
||||
it('read mode adds query_action only (no invoke_action)', () => {
|
||||
const names = computeTools('read').map((t) => t.name);
|
||||
expect(names).toContain('query_action');
|
||||
expect(names).not.toContain('invoke_action');
|
||||
});
|
||||
it('write mode adds both query_action and invoke_action', () => {
|
||||
const names = computeTools('write').map((t) => t.name);
|
||||
expect(names).toContain('query_action');
|
||||
expect(names).toContain('invoke_action');
|
||||
});
|
||||
it('annotations are honest: query=readOnly, invoke=destructive', () => {
|
||||
const q = computeTools('write').find((t) => t.name === 'query_action');
|
||||
const inv = computeTools('write').find((t) => t.name === 'invoke_action');
|
||||
expect(q?.annotations?.readOnlyHint).toBe(true);
|
||||
expect(inv?.annotations?.readOnlyHint).toBe(false);
|
||||
expect(inv?.annotations?.destructiveHint).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('callTool — docs tools (no client needed)', () => {
|
||||
it('list_actions returns entries that carry readOnly', async () => {
|
||||
const data = parsed(await callTool('list_actions', {}, { mode: 'docs' }));
|
||||
expect(data.count).toBeGreaterThan(100);
|
||||
expect(typeof data.actions[0].readOnly).toBe('boolean');
|
||||
});
|
||||
it('get_action resolves a known read-only action', async () => {
|
||||
const a = parsed(await callTool('get_action', { name: 'get_status' }, { mode: 'docs' }));
|
||||
expect(a.name).toBe('get_status');
|
||||
expect(a.readOnly).toBe(true);
|
||||
});
|
||||
it('get_action on a write action reports readOnly=false', async () => {
|
||||
expect(
|
||||
parsed(await callTool('get_action', { name: 'send_group_msg' }, { mode: 'docs' }))
|
||||
.readOnly,
|
||||
).toBe(false);
|
||||
});
|
||||
it('get_action accepts an alias', async () => {
|
||||
// nc_get_rkey is an alias of get_rkey (read-only).
|
||||
const a = parsed(await callTool('get_action', { name: 'nc_get_rkey' }, { mode: 'docs' }));
|
||||
expect(a.name).toBe('get_rkey');
|
||||
});
|
||||
});
|
||||
|
||||
describe('callTool — query_action (read)', () => {
|
||||
it.each([null, [], 'invalid', 42, false].map((params) => ({ params })))(
|
||||
'rejects invalid params without invoking the client ($params)',
|
||||
async ({ params }) => {
|
||||
let called = false;
|
||||
const client = fakeClient(() => {
|
||||
called = true;
|
||||
return { status: 'ok', retcode: 0, data: null };
|
||||
});
|
||||
const res = await callTool(
|
||||
'query_action',
|
||||
{ action: 'get_status', params },
|
||||
{ mode: 'read', client },
|
||||
);
|
||||
expect(res.isError).toBe(true);
|
||||
expect(text(res)).toContain('params');
|
||||
expect(called).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it('executes a read-only action and passes the envelope through', async () => {
|
||||
const res = await callTool(
|
||||
'query_action',
|
||||
{ action: 'get_status', params: {} },
|
||||
{ mode: 'read', client: fakeClient() },
|
||||
);
|
||||
expect(res.isError).toBeFalsy();
|
||||
expect(parsed(res).retcode).toBe(0);
|
||||
});
|
||||
it('refuses a write action and points to invoke_action', async () => {
|
||||
const res = await callTool(
|
||||
'query_action',
|
||||
{ action: 'send_group_msg', params: {} },
|
||||
{ mode: 'read', client: fakeClient() },
|
||||
);
|
||||
expect(res.isError).toBe(true);
|
||||
expect(text(res)).toContain('invoke_action');
|
||||
});
|
||||
it('refuses an unknown action', async () => {
|
||||
const res = await callTool(
|
||||
'query_action',
|
||||
{ action: 'no_such_action' },
|
||||
{ mode: 'read', client: fakeClient() },
|
||||
);
|
||||
expect(res.isError).toBe(true);
|
||||
expect(text(res)).toContain('未知 action');
|
||||
});
|
||||
it('is blocked in docs mode even when called directly (defense in depth)', async () => {
|
||||
const res = await callTool('query_action', { action: 'get_status' }, { mode: 'docs' });
|
||||
expect(res.isError).toBe(true);
|
||||
});
|
||||
it('errors clearly when no client is wired', async () => {
|
||||
const res = await callTool('query_action', { action: 'get_status' }, { mode: 'read' });
|
||||
expect(res.isError).toBe(true);
|
||||
expect(text(res)).toContain('未配置');
|
||||
});
|
||||
});
|
||||
|
||||
describe('callTool — invoke_action (write)', () => {
|
||||
it('executes a write action in write mode and forwards action+params', async () => {
|
||||
let seen: { action: string; params: Record<string, unknown> } | null = null;
|
||||
const client: ActionClient = {
|
||||
call: async (action, params) => {
|
||||
seen = { action, params };
|
||||
return { status: 'ok', retcode: 0, data: { message_id: 42 } };
|
||||
},
|
||||
callStream: async () => {
|
||||
throw new Error('unexpected stream call');
|
||||
},
|
||||
};
|
||||
const res = await callTool(
|
||||
'invoke_action',
|
||||
{ action: 'send_group_msg', params: { group_id: 1, message: 'hi' } },
|
||||
{ mode: 'write', client },
|
||||
);
|
||||
expect(res.isError).toBeFalsy();
|
||||
expect(seen!.action).toBe('send_group_msg');
|
||||
expect(seen!.params).toEqual({ group_id: 1, message: 'hi' });
|
||||
expect(parsed(res).data.message_id).toBe(42);
|
||||
});
|
||||
it('is blocked in read mode (write gate cannot be bypassed)', async () => {
|
||||
const res = await callTool(
|
||||
'invoke_action',
|
||||
{ action: 'send_group_msg', params: {} },
|
||||
{ mode: 'read', client: fakeClient() },
|
||||
);
|
||||
expect(res.isError).toBe(true);
|
||||
expect(text(res)).toContain('写操作未启用');
|
||||
});
|
||||
it('passes a retcode≠0 envelope through as data, NOT isError', async () => {
|
||||
const client = fakeClient(() => ({
|
||||
status: 'failed',
|
||||
retcode: 1404,
|
||||
message: 'not found',
|
||||
wording: '未找到',
|
||||
}));
|
||||
const res = await callTool(
|
||||
'invoke_action',
|
||||
{ action: 'send_group_msg', params: {} },
|
||||
{ mode: 'write', client },
|
||||
);
|
||||
expect(res.isError).toBeFalsy();
|
||||
expect(parsed(res).retcode).toBe(1404);
|
||||
expect(parsed(res).wording).toBe('未找到');
|
||||
});
|
||||
it('maps a transport-level throw to isError', async () => {
|
||||
const client: ActionClient = {
|
||||
call: async () => {
|
||||
throw new Error('connection refused');
|
||||
},
|
||||
callStream: async () => {
|
||||
throw new Error('unexpected stream call');
|
||||
},
|
||||
};
|
||||
const res = await callTool(
|
||||
'invoke_action',
|
||||
{ action: 'send_group_msg', params: {} },
|
||||
{ mode: 'write', client },
|
||||
);
|
||||
expect(res.isError).toBe(true);
|
||||
expect(text(res)).toContain('调用失败');
|
||||
expect(text(res)).toContain('connection refused');
|
||||
});
|
||||
it('accepts a read-only action too (invoke is a superset)', async () => {
|
||||
const res = await callTool(
|
||||
'invoke_action',
|
||||
{ action: 'get_status', params: {} },
|
||||
{ mode: 'write', client: fakeClient() },
|
||||
);
|
||||
expect(res.isError).toBeFalsy();
|
||||
});
|
||||
it('refuses an unknown action (typo safety)', async () => {
|
||||
const res = await callTool(
|
||||
'invoke_action',
|
||||
{ action: 'frobnicate' },
|
||||
{ mode: 'write', client: fakeClient() },
|
||||
);
|
||||
expect(res.isError).toBe(true);
|
||||
expect(text(res)).toContain('未知 action');
|
||||
});
|
||||
|
||||
it('routes catalog Stream Actions through callStream and returns the bounded summary', async () => {
|
||||
let ordinaryCalls = 0;
|
||||
let streamCalls = 0;
|
||||
const terminal = {
|
||||
status: 'ok',
|
||||
retcode: 0,
|
||||
data: { type: 'response', data_type: 'data_complete' },
|
||||
};
|
||||
const client: ActionClient = {
|
||||
call: async () => {
|
||||
ordinaryCalls++;
|
||||
return { status: 'ok', retcode: 0 };
|
||||
},
|
||||
callStream: async (action, params) => {
|
||||
streamCalls++;
|
||||
expect(action).toBe('test_download_stream');
|
||||
expect(params).toEqual({ error: false });
|
||||
return { frame_count: 1, frames: [], terminal };
|
||||
},
|
||||
};
|
||||
|
||||
const res = await callTool(
|
||||
'invoke_action',
|
||||
{ action: 'test_download_stream', params: { error: false } },
|
||||
{ mode: 'write', client },
|
||||
);
|
||||
|
||||
expect(res.isError).toBeFalsy();
|
||||
expect(parsed(res)).toEqual({ frame_count: 1, frames: [], terminal });
|
||||
expect(streamCalls).toBe(1);
|
||||
expect(ordinaryCalls).toBe(0);
|
||||
});
|
||||
|
||||
it('passes execution.input_file only as an MCP upload option, never as OneBot params', async () => {
|
||||
let seen: { params: Record<string, unknown>; inputFile?: string } | undefined;
|
||||
const client = fakeClient(undefined, (_action, params, options) => {
|
||||
seen = { params, inputFile: options?.inputFile };
|
||||
return {
|
||||
file_path: '/remote/local.txt',
|
||||
file_size: 5,
|
||||
sha256: 'abc',
|
||||
chunk_count: 1,
|
||||
stream_id: 'id',
|
||||
terminal: {
|
||||
status: 'ok',
|
||||
retcode: 0,
|
||||
data: { type: 'response', status: 'file_complete' },
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const res = await callTool(
|
||||
'invoke_action',
|
||||
{
|
||||
action: 'upload_file_stream',
|
||||
params: { file_retention: 0 },
|
||||
execution: { input_file: '/allowed/local.txt' },
|
||||
},
|
||||
{ mode: 'write', client },
|
||||
);
|
||||
|
||||
expect(res.isError).toBeFalsy();
|
||||
expect(seen).toEqual({ params: { file_retention: 0 }, inputFile: '/allowed/local.txt' });
|
||||
});
|
||||
|
||||
it('forwards MCP request cancellation to Stream Action execution', async () => {
|
||||
const controller = new AbortController();
|
||||
let seenSignal: AbortSignal | undefined;
|
||||
const client = fakeClient(undefined, (_action, _params, options) => {
|
||||
seenSignal = options?.signal;
|
||||
return {
|
||||
frame_count: 1,
|
||||
frames: [],
|
||||
terminal: {
|
||||
status: 'ok',
|
||||
retcode: 0,
|
||||
data: { type: 'response', data_type: 'data_complete' },
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const res = await callTool(
|
||||
'invoke_action',
|
||||
{ action: 'test_download_stream', params: {} },
|
||||
{
|
||||
mode: 'write',
|
||||
client,
|
||||
signal: controller.signal,
|
||||
},
|
||||
);
|
||||
|
||||
expect(res.isError).toBeFalsy();
|
||||
expect(seenSignal).toBe(controller.signal);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,274 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { createCallTrace } from '../src/tracing';
|
||||
import { makeHttpClient } from '../src/client';
|
||||
import { callTool } from '../src/tools';
|
||||
|
||||
const parent = '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01';
|
||||
const directories: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
|
||||
for (const directory of directories.splice(0)) {
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function directory(): string {
|
||||
const value = fs.mkdtempSync(path.join(os.tmpdir(), 'snowluma-mcp-trace-'));
|
||||
directories.push(value);
|
||||
return value;
|
||||
}
|
||||
|
||||
function streamResponse(data: unknown, failed = false): Response {
|
||||
return new Response(
|
||||
`${JSON.stringify({ status: failed ? 'failed' : 'ok', retcode: failed ? 100 : 0, data })}\r\n\r\n`,
|
||||
);
|
||||
}
|
||||
|
||||
describe('tool call trace context', () => {
|
||||
it('preserves a valid trace ID and sampling flag with a fresh span for every call', () => {
|
||||
const first = createCallTrace({ traceparent: parent, tracestate: 'vendor=opaque' });
|
||||
const second = createCallTrace({ traceparent: parent });
|
||||
expect(first.traceparent).toMatch(/^00-4bf92f3577b34da6a3ce929d0e0e4736-[0-9a-f]{16}-01$/);
|
||||
expect(first.traceparent).not.toBe(parent);
|
||||
expect(second.traceparent).not.toBe(first.traceparent);
|
||||
expect(first.tracestate).toBe('vendor=opaque');
|
||||
});
|
||||
|
||||
it('creates independent nonzero IDs without inbound context and does not copy baggage', () => {
|
||||
const first = createCallTrace({ baggage: 'private=value' });
|
||||
const second = createCallTrace();
|
||||
expect(first.traceparent).toMatch(/^00-[0-9a-f]{32}-[0-9a-f]{16}-00$/);
|
||||
expect(first.traceparent.slice(3, 35)).not.toBe('0'.repeat(32));
|
||||
expect(first.traceparent.slice(36, 52)).not.toBe('0'.repeat(16));
|
||||
expect(second.traceparent.slice(3, 35)).not.toBe(first.traceparent.slice(3, 35));
|
||||
expect(first).not.toHaveProperty('baggage');
|
||||
});
|
||||
|
||||
it.each([
|
||||
null,
|
||||
123,
|
||||
parent.toUpperCase(),
|
||||
parent.replace(/^00/, 'ff'),
|
||||
parent.replace(parent.slice(3, 35), '0'.repeat(32)),
|
||||
parent.replace(parent.slice(36, 52), '0'.repeat(16)),
|
||||
`${parent}-extra`,
|
||||
`${parent}\n`,
|
||||
`${parent}\nprivate-secret`,
|
||||
`${parent},${parent}`,
|
||||
])('ignores malformed traceparent %j and drops its tracestate', (traceparent) => {
|
||||
const diagnostic = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
const result = createCallTrace({ traceparent, tracestate: 'vendor=private-secret' });
|
||||
expect(result.traceparent.slice(3, 35)).not.toBe(parent.slice(3, 35));
|
||||
expect(result.tracestate).toBeUndefined();
|
||||
expect(diagnostic).toHaveBeenCalledWith(
|
||||
'[snowluma-mcp] trace metadata ignored: invalid traceparent',
|
||||
);
|
||||
expect(JSON.stringify(diagnostic.mock.calls)).not.toContain('private-secret');
|
||||
});
|
||||
|
||||
it('parses future versions using known fields and clears unknown flags', () => {
|
||||
const result = createCallTrace({ traceparent: `01-${parent.slice(3, 53)}ff-future` });
|
||||
expect(result.traceparent).toMatch(/^00-4bf92f3577b34da6a3ce929d0e0e4736-[0-9a-f]{16}-01$/);
|
||||
});
|
||||
|
||||
it('accepts W3C tracestate members and normalizes optional whitespace and empty members', () => {
|
||||
const result = createCallTrace({
|
||||
traceparent: parent,
|
||||
tracestate: ' vendor=opaque , , 1tenant@system=value , other=leading space',
|
||||
});
|
||||
expect(result.tracestate).toBe('vendor=opaque,1tenant@system=value,other=leading space');
|
||||
expect(
|
||||
createCallTrace({ traceparent: parent, tracestate: ' , ' }).tracestate,
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
123,
|
||||
'vendor=a,vendor=b',
|
||||
'Vendor=a',
|
||||
'1vendor=a',
|
||||
'vendor=',
|
||||
'vendor=a=b',
|
||||
'vendor=private\r\nsecret',
|
||||
'vendor=private\n',
|
||||
`vendor=${'a'.repeat(257)}`,
|
||||
Array.from({ length: 33 }, (_, i) => `key${i}=a`).join(','),
|
||||
`a=${'x'.repeat(256)},b=${'x'.repeat(256)}`,
|
||||
])('drops invalid tracestate %j without breaking the trace', (tracestate) => {
|
||||
const diagnostic = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
const result = createCallTrace({ traceparent: parent, tracestate });
|
||||
expect(result.traceparent.slice(3, 35)).toBe(parent.slice(3, 35));
|
||||
expect(result.tracestate).toBeUndefined();
|
||||
expect(diagnostic.mock.calls).toEqual([
|
||||
['[snowluma-mcp] trace metadata ignored: invalid tracestate'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('drops tracestate without a parent and reports only a fixed diagnosis', () => {
|
||||
const diagnostic = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
expect(createCallTrace({ tracestate: 'vendor=secret' }).tracestate).toBeUndefined();
|
||||
expect(diagnostic.mock.calls).toEqual([
|
||||
['[snowluma-mcp] trace metadata ignored: tracestate without valid traceparent'],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('trace propagation through tool execution', () => {
|
||||
it('isolates concurrent ordinary and stream headers without changing payloads or authentication', async () => {
|
||||
const calls: Array<{ headers: Headers; body: { action: string; params: unknown } }> = [];
|
||||
const allStarted = Promise.withResolvers<void>();
|
||||
|
||||
const fetcher: typeof fetch = async (_input, init) => {
|
||||
const body = JSON.parse(String(init?.body));
|
||||
calls.push({ headers: new Headers(init?.headers), body });
|
||||
|
||||
if (calls.length === 3) {
|
||||
allStarted.resolve();
|
||||
}
|
||||
|
||||
await allStarted.promise;
|
||||
return body.action === 'test_download_stream'
|
||||
? streamResponse({ type: 'response', data: 'complete' })
|
||||
: Response.json({ status: 'ok', retcode: 0, data: { online: true } });
|
||||
};
|
||||
|
||||
const client = makeHttpClient({
|
||||
endpoint: 'https://unused.invalid',
|
||||
accessToken: 'credential',
|
||||
streamDir: directory(),
|
||||
fetch: fetcher,
|
||||
});
|
||||
const first = createCallTrace({ traceparent: parent, tracestate: 'vendor=one' });
|
||||
const second = createCallTrace();
|
||||
const outcomes = await Promise.all([
|
||||
callTool(
|
||||
'query_action',
|
||||
{ action: 'get_status', params: { marker: 'first' } },
|
||||
{ mode: 'read', client, trace: first },
|
||||
),
|
||||
callTool(
|
||||
'invoke_action',
|
||||
{ action: 'test_download_stream', params: { marker: 'second' } },
|
||||
{ mode: 'write', client, trace: second },
|
||||
),
|
||||
client.call('get_status', { marker: 'untraced' }),
|
||||
]);
|
||||
expect(outcomes).toHaveLength(3);
|
||||
expect(calls[0]?.headers.get('traceparent')).toBe(first.traceparent);
|
||||
expect(calls[0]?.headers.get('tracestate')).toBe('vendor=one');
|
||||
expect(calls[1]?.headers.get('traceparent')).toBe(second.traceparent);
|
||||
expect(calls[1]?.headers.has('tracestate')).toBe(false);
|
||||
expect(calls[2]?.headers.has('traceparent')).toBe(false);
|
||||
|
||||
for (const call of calls) {
|
||||
expect(call.headers.get('authorization')).toBe('Bearer credential');
|
||||
expect(call.headers.has('baggage')).toBe(false);
|
||||
expect(Object.keys(call.body)).toEqual(['action', 'params']);
|
||||
expect(Object.keys(call.body.params as object)).toEqual(['marker']);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps trace metadata outside action permissions and ordinary result envelopes', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>(async () =>
|
||||
Response.json({ status: 'failed', retcode: 123, wording: 'denied', data: null }),
|
||||
);
|
||||
const client = makeHttpClient({
|
||||
endpoint: 'https://unused.invalid',
|
||||
streamDir: directory(),
|
||||
fetch: fetcher,
|
||||
});
|
||||
const trace = createCallTrace();
|
||||
const denied = await callTool(
|
||||
'invoke_action',
|
||||
{ action: 'send_group_msg' },
|
||||
{ mode: 'read', client, trace },
|
||||
);
|
||||
expect(denied.structuredContent.diagnostics.kind).toBe('permission');
|
||||
expect(fetcher).not.toHaveBeenCalled();
|
||||
const result = await callTool(
|
||||
'query_action',
|
||||
{ action: 'get_status' },
|
||||
{ mode: 'read', client, trace },
|
||||
);
|
||||
expect(result.isError).toBeUndefined();
|
||||
expect(result.structuredContent.result).toEqual({
|
||||
status: 'failed',
|
||||
retcode: 123,
|
||||
wording: 'denied',
|
||||
data: null,
|
||||
});
|
||||
expect(result.content[0]).toEqual({
|
||||
type: 'text',
|
||||
text: JSON.stringify(result.structuredContent.result, null, 2),
|
||||
});
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
'uses one call trace for every upload chunk and completion or cleanup (failure: %s)',
|
||||
async (fail) => {
|
||||
const root = directory();
|
||||
const input = path.join(root, 'upload.bin');
|
||||
const bytes = Buffer.alloc(512 * 1024 + 1, 97);
|
||||
fs.writeFileSync(input, bytes);
|
||||
const trace = createCallTrace({ traceparent: parent, tracestate: 'vendor=transfer' });
|
||||
const calls: Array<{ headers: Headers; params: Record<string, unknown> }> = [];
|
||||
|
||||
const fetcher: typeof fetch = async (_input, init) => {
|
||||
const { params } = JSON.parse(String(init?.body));
|
||||
calls.push({ headers: new Headers(init?.headers), params });
|
||||
|
||||
if (params.reset) {
|
||||
return streamResponse({ type: 'response' });
|
||||
}
|
||||
|
||||
if (fail && params.chunk_index === 1) {
|
||||
return streamResponse({ type: 'error' }, true);
|
||||
}
|
||||
|
||||
return streamResponse(
|
||||
params.is_complete
|
||||
? {
|
||||
type: 'response',
|
||||
status: 'file_complete',
|
||||
file_path: '/server/upload.bin',
|
||||
file_size: bytes.length,
|
||||
sha256: createHash('sha256').update(bytes).digest('hex'),
|
||||
}
|
||||
: { type: 'stream', status: 'chunk_received' },
|
||||
);
|
||||
};
|
||||
|
||||
const client = makeHttpClient({
|
||||
endpoint: 'https://unused.invalid',
|
||||
streamDir: root,
|
||||
uploadRoot: root,
|
||||
fetch: fetcher,
|
||||
});
|
||||
const result = await callTool(
|
||||
'invoke_action',
|
||||
{
|
||||
action: 'upload_file_stream',
|
||||
execution: { input_file: input },
|
||||
},
|
||||
{ mode: 'write', client, trace },
|
||||
);
|
||||
expect(result.isError === true).toBe(fail);
|
||||
expect(calls).toHaveLength(3);
|
||||
expect(calls[2]?.params[fail ? 'reset' : 'is_complete']).toBe(true);
|
||||
|
||||
for (const call of calls) {
|
||||
expect(call.headers.get('traceparent')).toBe(trace.traceparent);
|
||||
expect(call.headers.get('tracestate')).toBe(trace.tracestate);
|
||||
expect(call.params).not.toHaveProperty('traceparent');
|
||||
expect(call.params).not.toHaveProperty('tracestate');
|
||||
expect(call.headers.has('baggage')).toBe(false);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user