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

This commit is contained in:
TanTan committed 2026-11-13 09:02:00 +08:00
commit fa9c73363d
1367 files changed
+355735

No files matched your search

+245
View File
@@ -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');
});
});
+275
View File
@@ -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');
});
});
+124
View File
@@ -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
+585
View File
@@ -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 }),
);
});
});
+474
View 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(
[],
);
},
);
});
+228
View File
@@ -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',
);
});
});
+155
View File
@@ -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();
});
});
+462
View File
@@ -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 });
});
});
+695
View File
@@ -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();
});
});
+339
View File
@@ -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);
});
});
+274
View File
@@ -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);
}
},
);
});