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>
122 lines
4.2 KiB
TypeScript
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 } : {}),
|
|
}
|
|
}
|