Files
accounted/extensions/general/mcp-server/tasks.ts
T
Jakob Wennberg ef25a87d75 feat(mcp): Tasks extension (io.modelcontextprotocol/tasks) (#1283)
* 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>
2026-07-29 18:15:06 +02:00

122 lines
4.2 KiB
TypeScript

import type { SupabaseClient } from '@supabase/supabase-js'
/**
* MCP Tasks extension (io.modelcontextprotocol/tasks).
*
* Durable handles for long-running tool calls: a task-capable client gets a
* CreateTaskResult (resultType: "task") immediately and polls tasks/get until
* a terminal status. Rows live in mcp_tasks (service-role writes only) so
* handles survive disconnects and serverless instance turnover.
*
* Failure mapping: a tool execution failure is stored as a COMPLETED task
* whose result carries the standard isError envelope, because that is exactly
* what the synchronous call would have returned. The `failed` status (and the
* `error` column) is reserved for infrastructure failures where no tool
* result exists.
*/
export const TASKS_EXTENSION_ID = 'io.modelcontextprotocol/tasks'
const DEFAULT_POLL_INTERVAL_MS = 2000
const DEFAULT_TTL_MS = 3_600_000
export interface McpTaskRow {
id: string
company_id: string
user_id: string
tool_name: string
status: 'working' | 'input_required' | 'completed' | 'failed' | 'cancelled'
status_message: string | null
result: Record<string, unknown> | null
error: Record<string, unknown> | null
poll_interval_ms: number
ttl_ms: number
created_at: string
}
/**
* Per the extension spec, a server must never return a task to a client that
* did not declare the extension in this request's capabilities.
*/
export function isTaskCapableClient(requestMeta: Record<string, unknown>): boolean {
const caps = requestMeta['io.modelcontextprotocol/clientCapabilities']
if (!caps || typeof caps !== 'object') return false
const extensions = (caps as Record<string, unknown>).extensions
if (!extensions || typeof extensions !== 'object') return false
return TASKS_EXTENSION_ID in (extensions as Record<string, unknown>)
}
export async function createMcpTask(
supabase: SupabaseClient,
params: { companyId: string; userId: string; apiKeyId?: string | null; toolName: string }
): Promise<McpTaskRow> {
// Storage limitation (GDPR Art. 5(1)(e)): opportunistically purge expired
// rows on every creation so the 1-hour retention is enforced without
// dedicated cron infrastructure (cheap via idx_mcp_tasks_expires).
// Best-effort: a failed sweep must never block the new task.
try {
await supabase.from('mcp_tasks').delete().lt('expires_at', new Date().toISOString())
} catch {
// Ignore: the next creation retries the sweep.
}
const { data, error } = await supabase
.from('mcp_tasks')
.insert({
company_id: params.companyId,
user_id: params.userId,
api_key_id: params.apiKeyId ?? null,
tool_name: params.toolName,
status: 'working',
poll_interval_ms: DEFAULT_POLL_INTERVAL_MS,
ttl_ms: DEFAULT_TTL_MS,
})
.select('*')
.single()
if (error || !data) {
throw new Error(`Failed to create MCP task: ${error?.message ?? 'no row returned'}`)
}
return data as McpTaskRow
}
/**
* Move a still-working task to a terminal state. The status='working' guard
* makes terminal states immutable (spec) and lets a tasks/cancel that raced
* the execution win: the late completion becomes a no-op.
*/
export async function resolveMcpTask(
supabase: SupabaseClient,
taskId: string,
terminal: {
status: 'completed' | 'failed' | 'cancelled'
result?: Record<string, unknown>
error?: Record<string, unknown>
statusMessage?: string
}
): Promise<void> {
// Literal payload (no conditional spreads) so the phantom-column guard can
// resolve every column. Writing null for absent terminal fields is correct:
// the transition sets the complete terminal state.
await supabase
.from('mcp_tasks')
.update({
status: terminal.status,
result: terminal.result ?? null,
error: terminal.error ?? null,
status_message: terminal.statusMessage ?? null,
})
.eq('id', taskId)
.eq('status', 'working')
}
/** Map a row to the wire Task object shared by CreateTaskResult and tasks/get. */
export function taskToWire(row: McpTaskRow): Record<string, unknown> {
return {
taskId: row.id,
status: row.status,
createdAt: row.created_at,
ttlMs: Number(row.ttl_ms),
pollIntervalMs: row.poll_interval_ms,
...(row.status_message ? { statusMessage: row.status_message } : {}),
}
}