ef25a87d75
* feat(mcp): speak spec revision 2026-07-28 (stateless core) Adopt the 2026-07-28 MCP spec revision on the connector endpoint while keeping every handshake-era client (2025-06-18 and earlier) byte-identical: - Accept per-request _meta protocol negotiation (io.modelcontextprotocol/protocolVersion); unsupported versions return UnsupportedProtocolVersionError (-32022) with the supported list. - Implement server/discover (spec MUST): supported revisions, capabilities including the extensions field, identity, instructions, freshness hints. - Decorate results for stateless clients: required resultType, serverInfo in _meta, and CacheableResult ttlMs/cacheScope on tools/list, prompts/list, resources/list, resources/read. - Validate the standard Mcp-Method/Mcp-Name request headers when present (HeaderMismatchError -32020); absence stays accepted. - Declare the ratified MCP Apps extension (io.modelcontextprotocol/ui) in capabilities; the widgets already use the ratified mime type and _meta.ui.resourceUri shape, so no widget changes are needed. - OAuth: include the RFC 9207 iss parameter on every authorization response (success and error) and advertise authorization_response_iss_parameter_supported in RFC 8414 metadata. Resource-not-found already used -32602 and tools/list ordering was already deterministic; both are covered by the new test file. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(mcp): Tasks extension (io.modelcontextprotocol/tasks) Durable handles for long-running MCP tool calls, per the official Tasks extension. A client that declares the extension in its per-request capabilities gets a CreateTaskResult (resultType: "task") immediately; the work completes after the response via after() and lands in the new mcp_tasks table for tasks/get polling. Clients that did not declare the extension are never handed a task (spec MUST). - New mcp_tasks table (migration 20260729094000): company-scoped SELECT RLS, service-role-only writes (mirrors pending_operations), 1-hour expiry, status lifecycle CHECK. pg-real coverage included; triaged as excluded in the full-archive backup contract (transient state). - tasks/get (creator-scoped), tasks/cancel (cooperative, working-only flip), tasks/update (ack no-op: no input_required flows yet). - Tool opt-in via shouldRunAsTask predicate; first producer is gnubok_audit_package, the one genuinely long-running blocking call (multi-minute ZIP generation). estimate_only stays synchronous. - Tool failures complete the task with the standard isError envelope, exactly what the synchronous call would have returned; the failed status stays reserved for infrastructure errors. - server/discover and initialize now advertise the tasks extension. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(mcp): creator-only task RLS, enforced expiry sweep, RoPA entry Compliance-swarm follow-ups on the mcp_tasks migration (editing the migration is safe: it has not shipped beyond the ephemeral PR preview): - SELECT RLS tightened from company-wide to auth.uid() = user_id so the DB grant matches the creator-scoped tasks/get contract; task results carry raw tool output (Art. 5(1)(c)). pg test now proves a same-company colleague cannot read the row. - The 1-hour retention is now enforced, not aspirational: createMcpTask opportunistically deletes expired rows on every creation (idx_mcp_tasks_expires), best-effort (Art. 5(1)(e)). - RoPA entry mcp.async_task_handles added to .compliance/ropa.yaml (Art. 30). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(mcp): literal terminal-update payload for the phantom-column guard The conditional spreads in resolveMcpTask made the payload unresolvable for the no-phantom-columns guard (362 > 360 ceiling). A literal payload writing null for absent terminal fields is equivalent here: the terminal transition sets the complete terminal state. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
301 lines
10 KiB
TypeScript
301 lines
10 KiB
TypeScript
/**
|
|
* Tests for the MCP Tasks extension (io.modelcontextprotocol/tasks):
|
|
* capability gating, CreateTaskResult, post-response completion writes,
|
|
* and the tasks/get / tasks/update / tasks/cancel methods. Does NOT re-test
|
|
* audit-package generation itself (covered by audit-package.test.ts).
|
|
*/
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
import { eventBus } from '@/lib/events/bus'
|
|
import { isTaskCapableClient, TASKS_EXTENSION_ID } from '../tasks'
|
|
|
|
const mocks = vi.hoisted(() => ({
|
|
scopes: [] as string[],
|
|
taskInserts: [] as Record<string, unknown>[],
|
|
taskUpdates: [] as Record<string, unknown>[],
|
|
taskRow: null as Record<string, unknown> | null,
|
|
}))
|
|
|
|
vi.mock('@/lib/auth/api-keys', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('@/lib/auth/api-keys')>()
|
|
mocks.scopes = [...actual.ALL_SCOPES]
|
|
|
|
const membershipChain: unknown = new Proxy(
|
|
{},
|
|
{
|
|
get(_t, prop) {
|
|
if (prop === 'then') {
|
|
return (resolve: (v: unknown) => void) =>
|
|
resolve({
|
|
data: { company_id: '11111111-1111-4111-8111-111111111111', role: 'owner' },
|
|
error: null,
|
|
})
|
|
}
|
|
return () => membershipChain
|
|
},
|
|
}
|
|
)
|
|
|
|
const makeChain = (result: unknown): unknown =>
|
|
new Proxy(
|
|
{},
|
|
{
|
|
get(_t, prop) {
|
|
if (prop === 'then') {
|
|
return (resolve: (v: unknown) => void) => resolve(result)
|
|
}
|
|
if (prop === 'single') {
|
|
return () => Promise.resolve(result)
|
|
}
|
|
return () => makeChain(result)
|
|
},
|
|
}
|
|
)
|
|
|
|
const tasksBuilder = {
|
|
delete: () => makeChain({ data: null, error: null }),
|
|
insert: (row: Record<string, unknown>) => {
|
|
mocks.taskInserts.push(row)
|
|
return {
|
|
select: () => ({
|
|
single: async () => ({
|
|
data: {
|
|
id: 'task-1',
|
|
company_id: row.company_id,
|
|
user_id: row.user_id,
|
|
tool_name: row.tool_name,
|
|
status: 'working',
|
|
status_message: null,
|
|
result: null,
|
|
error: null,
|
|
poll_interval_ms: 2000,
|
|
ttl_ms: 3600000,
|
|
created_at: '2026-07-29T09:00:00.000Z',
|
|
},
|
|
error: null,
|
|
}),
|
|
}),
|
|
}
|
|
},
|
|
update: (patch: Record<string, unknown>) => {
|
|
mocks.taskUpdates.push(patch)
|
|
return makeChain({ data: null, error: null })
|
|
},
|
|
select: () =>
|
|
makeChain(
|
|
mocks.taskRow
|
|
? { data: mocks.taskRow, error: null }
|
|
: { data: null, error: { message: 'not found' } }
|
|
),
|
|
}
|
|
|
|
return {
|
|
...actual,
|
|
extractBearerToken: vi.fn().mockReturnValue('test-token'),
|
|
validateApiKey: vi.fn().mockResolvedValue({
|
|
userId: 'user-1',
|
|
companyId: '11111111-1111-4111-8111-111111111111',
|
|
scopes: mocks.scopes,
|
|
apiKeyId: 'key-1',
|
|
apiKeyName: 'Test key',
|
|
mode: 'live',
|
|
}),
|
|
createServiceClientNoCookies: vi.fn(() => ({
|
|
from: (table: string) => {
|
|
if (table === 'mcp_tasks') return tasksBuilder
|
|
if (table === 'company_members') return membershipChain
|
|
// fiscal_periods (and everything else) resolves to no rows, so the
|
|
// audit-package execution fails fast with "Fiscal period not found".
|
|
return makeChain({ data: null, error: { message: 'not found' }, count: 0 })
|
|
},
|
|
})),
|
|
}
|
|
})
|
|
|
|
import { handleMcpRequest } from '../server'
|
|
|
|
const TASK_META = {
|
|
'io.modelcontextprotocol/protocolVersion': '2026-07-28',
|
|
'io.modelcontextprotocol/clientCapabilities': {
|
|
extensions: { [TASKS_EXTENSION_ID]: {} },
|
|
},
|
|
}
|
|
|
|
function mcpRequest(method: string, params?: Record<string, unknown>): Request {
|
|
return new Request('http://localhost:3000/api/extensions/ext/mcp-server/mcp', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer test-token' },
|
|
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, ...(params ? { params } : {}) }),
|
|
})
|
|
}
|
|
|
|
async function readBody(request: Request): Promise<{
|
|
result?: Record<string, unknown>
|
|
error?: { code: number; message: string }
|
|
}> {
|
|
const response = await handleMcpRequest(request)
|
|
const body = await response.json()
|
|
return { result: body.result, error: body.error }
|
|
}
|
|
|
|
/** The after() fallback runs the job as a floating promise: let it settle. */
|
|
async function settleBackgroundWork(): Promise<void> {
|
|
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
}
|
|
|
|
describe('MCP Tasks extension', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
eventBus.clear()
|
|
mocks.taskInserts.length = 0
|
|
mocks.taskUpdates.length = 0
|
|
mocks.taskRow = null
|
|
})
|
|
|
|
describe('capability detection', () => {
|
|
it('detects the tasks extension in per-request capabilities', () => {
|
|
expect(isTaskCapableClient(TASK_META as Record<string, unknown>)).toBe(true)
|
|
expect(isTaskCapableClient({})).toBe(false)
|
|
expect(
|
|
isTaskCapableClient({
|
|
'io.modelcontextprotocol/clientCapabilities': { extensions: {} },
|
|
})
|
|
).toBe(false)
|
|
})
|
|
|
|
it('advertises the tasks extension in server/discover', async () => {
|
|
const { result } = await readBody(mcpRequest('server/discover'))
|
|
const capabilities = result?.capabilities as { extensions: Record<string, unknown> }
|
|
expect(capabilities.extensions[TASKS_EXTENSION_ID]).toEqual({})
|
|
})
|
|
})
|
|
|
|
describe('CreateTaskResult', () => {
|
|
it('returns a task handle to a task-capable client and completes after the response', async () => {
|
|
const { result } = await readBody(
|
|
mcpRequest('tools/call', {
|
|
name: 'gnubok_audit_package',
|
|
arguments: { fiscal_period_id: '22222222-2222-4222-8222-222222222222' },
|
|
_meta: TASK_META,
|
|
})
|
|
)
|
|
expect(result?.resultType).toBe('task')
|
|
const task = result?.task as Record<string, unknown>
|
|
expect(task.taskId).toBe('task-1')
|
|
expect(task.status).toBe('working')
|
|
expect(typeof task.pollIntervalMs).toBe('number')
|
|
expect(typeof task.ttlMs).toBe('number')
|
|
expect(mocks.taskInserts).toHaveLength(1)
|
|
|
|
await settleBackgroundWork()
|
|
// The mocked DB has no fiscal period, so the execution fails and the
|
|
// task completes with the standard isError envelope.
|
|
expect(mocks.taskUpdates).toHaveLength(1)
|
|
expect(mocks.taskUpdates[0].status).toBe('completed')
|
|
const stored = mocks.taskUpdates[0].result as Record<string, unknown>
|
|
expect(stored.isError).toBe(true)
|
|
expect(stored.resultType).toBe('complete')
|
|
})
|
|
|
|
it('never returns a task to a client that did not declare the extension', async () => {
|
|
const { result } = await readBody(
|
|
mcpRequest('tools/call', {
|
|
name: 'gnubok_audit_package',
|
|
arguments: { fiscal_period_id: '22222222-2222-4222-8222-222222222222' },
|
|
_meta: { 'io.modelcontextprotocol/protocolVersion': '2026-07-28' },
|
|
})
|
|
)
|
|
expect(result?.resultType).not.toBe('task')
|
|
expect(result?.isError).toBe(true)
|
|
expect(mocks.taskInserts).toHaveLength(0)
|
|
})
|
|
|
|
it('keeps estimate_only synchronous even for task-capable clients', async () => {
|
|
const { result } = await readBody(
|
|
mcpRequest('tools/call', {
|
|
name: 'gnubok_audit_package',
|
|
arguments: {
|
|
fiscal_period_id: '22222222-2222-4222-8222-222222222222',
|
|
estimate_only: true,
|
|
},
|
|
_meta: TASK_META,
|
|
})
|
|
)
|
|
expect(result?.resultType).not.toBe('task')
|
|
expect(mocks.taskInserts).toHaveLength(0)
|
|
})
|
|
})
|
|
|
|
describe('tasks/get', () => {
|
|
it('returns the working state while the task runs', async () => {
|
|
mocks.taskRow = {
|
|
id: 'task-1',
|
|
company_id: '11111111-1111-4111-8111-111111111111',
|
|
user_id: 'user-1',
|
|
tool_name: 'gnubok_audit_package',
|
|
status: 'working',
|
|
status_message: null,
|
|
result: null,
|
|
error: null,
|
|
poll_interval_ms: 2000,
|
|
ttl_ms: 3600000,
|
|
created_at: '2026-07-29T09:00:00.000Z',
|
|
}
|
|
const { result } = await readBody(mcpRequest('tasks/get', { taskId: 'task-1' }))
|
|
expect(result?.status).toBe('working')
|
|
expect(result?.result).toBeUndefined()
|
|
expect(result?.resultType).toBe('complete')
|
|
})
|
|
|
|
it('returns the stored tool result on completion', async () => {
|
|
const storedResult = {
|
|
resultType: 'complete',
|
|
content: [{ type: 'text', text: '{"file_name":"arkiv.zip"}' }],
|
|
structuredContent: { file_name: 'arkiv.zip' },
|
|
}
|
|
mocks.taskRow = {
|
|
id: 'task-1',
|
|
company_id: '11111111-1111-4111-8111-111111111111',
|
|
user_id: 'user-1',
|
|
tool_name: 'gnubok_audit_package',
|
|
status: 'completed',
|
|
status_message: null,
|
|
result: storedResult,
|
|
error: null,
|
|
poll_interval_ms: 2000,
|
|
ttl_ms: 3600000,
|
|
created_at: '2026-07-29T09:00:00.000Z',
|
|
}
|
|
const { result } = await readBody(mcpRequest('tasks/get', { taskId: 'task-1' }))
|
|
expect(result?.status).toBe('completed')
|
|
expect(result?.result).toEqual(storedResult)
|
|
})
|
|
|
|
it('rejects unknown task ids with invalid params', async () => {
|
|
const { error } = await readBody(mcpRequest('tasks/get', { taskId: 'nope' }))
|
|
expect(error?.code).toBe(-32602)
|
|
})
|
|
|
|
it('requires taskId', async () => {
|
|
const { error } = await readBody(mcpRequest('tasks/get', {}))
|
|
expect(error?.code).toBe(-32602)
|
|
})
|
|
})
|
|
|
|
describe('tasks/cancel and tasks/update', () => {
|
|
it('acknowledges cancellation and flips a working row', async () => {
|
|
const { result } = await readBody(mcpRequest('tasks/cancel', { taskId: 'task-1' }))
|
|
expect(result?.resultType).toBe('complete')
|
|
expect(mocks.taskUpdates).toHaveLength(1)
|
|
expect(mocks.taskUpdates[0].status).toBe('cancelled')
|
|
})
|
|
|
|
it('acknowledges tasks/update as a no-op (no input_required flows yet)', async () => {
|
|
const { result } = await readBody(
|
|
mcpRequest('tasks/update', { taskId: 'task-1', inputResponses: {} })
|
|
)
|
|
expect(result?.resultType).toBe('complete')
|
|
})
|
|
})
|
|
})
|