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>
110 lines
4.2 KiB
TypeScript
110 lines
4.2 KiB
TypeScript
import { randomUUID } from 'node:crypto'
|
|
import { describe, expect, it } from 'vitest'
|
|
import { getPool, withUserContext } from './setup'
|
|
import { seedCompany, insertAuthUser, insertCompanyMember } from './fixtures'
|
|
|
|
// pg-real coverage for 20260729094000_create_mcp_tasks.sql: the status CHECK,
|
|
// creator-only SELECT RLS, the deliberate absence of authenticated write
|
|
// policies (writes are service-role only), and the updated_at trigger.
|
|
|
|
async function insertTask(params: {
|
|
companyId: string
|
|
userId: string
|
|
status?: string
|
|
toolName?: string
|
|
}): Promise<string> {
|
|
const id = randomUUID()
|
|
await getPool().query(
|
|
`INSERT INTO public.mcp_tasks (id, company_id, user_id, tool_name, status)
|
|
VALUES ($1, $2, $3, $4, $5)`,
|
|
[id, params.companyId, params.userId, params.toolName ?? 'gnubok_audit_package', params.status ?? 'working'],
|
|
)
|
|
return id
|
|
}
|
|
|
|
describe('mcp_tasks', () => {
|
|
it('rejects statuses outside the task lifecycle', async () => {
|
|
const { companyId, userId } = await seedCompany()
|
|
await expect(
|
|
insertTask({ companyId, userId, status: 'running' }),
|
|
).rejects.toThrow(/mcp_tasks_status_check/)
|
|
})
|
|
|
|
it('only the creating user can read a task (not other members, not other companies)', async () => {
|
|
const a = await seedCompany()
|
|
const b = await seedCompany()
|
|
const taskId = await insertTask({ companyId: a.companyId, userId: a.userId })
|
|
|
|
const mine = await withUserContext(a.userId, (client) =>
|
|
client.query('SELECT id, status FROM public.mcp_tasks WHERE id = $1', [taskId]),
|
|
)
|
|
expect(mine.rows).toHaveLength(1)
|
|
expect(mine.rows[0].status).toBe('working')
|
|
|
|
// A second member of the SAME company must not see the row: task results
|
|
// carry raw tool output, so the grant is creator-only (Art. 5(1)(c)).
|
|
const colleagueId = await insertAuthUser()
|
|
await insertCompanyMember({ companyId: a.companyId, userId: colleagueId, role: 'member' })
|
|
const colleague = await withUserContext(colleagueId, (client) =>
|
|
client.query('SELECT id FROM public.mcp_tasks WHERE id = $1', [taskId]),
|
|
)
|
|
expect(colleague.rows).toHaveLength(0)
|
|
|
|
const theirs = await withUserContext(b.userId, (client) =>
|
|
client.query('SELECT id FROM public.mcp_tasks WHERE id = $1', [taskId]),
|
|
)
|
|
expect(theirs.rows).toHaveLength(0)
|
|
})
|
|
|
|
it('authenticated users cannot insert, update, or delete tasks (service-role only)', async () => {
|
|
const { companyId, userId } = await seedCompany()
|
|
const taskId = await insertTask({ companyId, userId })
|
|
|
|
await expect(
|
|
withUserContext(userId, (client) =>
|
|
client.query(
|
|
`INSERT INTO public.mcp_tasks (company_id, user_id, tool_name)
|
|
VALUES ($1, $2, 'gnubok_audit_package')`,
|
|
[companyId, userId],
|
|
),
|
|
),
|
|
).rejects.toThrow(/row-level security/)
|
|
|
|
// UPDATE and DELETE have no policies: RLS silently filters all rows,
|
|
// so the statements succeed but affect nothing.
|
|
const upd = await withUserContext(userId, (client) =>
|
|
client.query(`UPDATE public.mcp_tasks SET status = 'cancelled' WHERE id = $1`, [taskId]),
|
|
)
|
|
expect(upd.rowCount).toBe(0)
|
|
|
|
const del = await withUserContext(userId, (client) =>
|
|
client.query('DELETE FROM public.mcp_tasks WHERE id = $1', [taskId]),
|
|
)
|
|
expect(del.rowCount).toBe(0)
|
|
})
|
|
|
|
it('bumps updated_at on status transitions', async () => {
|
|
const { companyId, userId } = await seedCompany()
|
|
const taskId = await insertTask({ companyId, userId })
|
|
|
|
const before = await getPool().query(
|
|
'SELECT updated_at FROM public.mcp_tasks WHERE id = $1',
|
|
[taskId],
|
|
)
|
|
// clock_timestamp()-based trigger: force a measurable gap.
|
|
await getPool().query('SELECT pg_sleep(0.05)')
|
|
await getPool().query(
|
|
`UPDATE public.mcp_tasks SET status = 'completed', result = '{"ok":true}'::jsonb WHERE id = $1`,
|
|
[taskId],
|
|
)
|
|
const after = await getPool().query(
|
|
'SELECT updated_at, status FROM public.mcp_tasks WHERE id = $1',
|
|
[taskId],
|
|
)
|
|
expect(after.rows[0].status).toBe('completed')
|
|
expect(new Date(after.rows[0].updated_at).getTime()).toBeGreaterThanOrEqual(
|
|
new Date(before.rows[0].updated_at).getTime(),
|
|
)
|
|
})
|
|
})
|