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
696 lines
27 KiB
TypeScript
696 lines
27 KiB
TypeScript
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();
|
|
});
|
|
});
|