* 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>
335 lines
12 KiB
TypeScript
335 lines
12 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { eventBus } from '@/lib/events/bus'
|
|
|
|
const mocks = vi.hoisted(() => ({
|
|
scopes: [] as string[],
|
|
serviceClient: {} as unknown,
|
|
}))
|
|
|
|
vi.mock('@/lib/auth/api-keys', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('@/lib/auth/api-keys')>()
|
|
mocks.scopes = [...actual.ALL_SCOPES]
|
|
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(() => mocks.serviceClient),
|
|
}
|
|
})
|
|
|
|
/**
|
|
* Chainable query builder resolving to empty rows: enough for the skills
|
|
* registry load behind resources/list. The default service client stays {}
|
|
* so tool-execution tests keep failing the way the isError test expects.
|
|
*/
|
|
function emptyRegistryClient(): unknown {
|
|
const builder: Record<string, unknown> = {}
|
|
for (const m of ['select', 'eq', 'is', 'order']) {
|
|
builder[m] = () => builder
|
|
}
|
|
builder.then = (onFulfilled: (v: unknown) => unknown) =>
|
|
Promise.resolve({ data: [], error: null }).then(onFulfilled)
|
|
return { from: () => builder }
|
|
}
|
|
|
|
import { handleMcpRequest } from '../server'
|
|
|
|
const STATELESS_META = {
|
|
'io.modelcontextprotocol/protocolVersion': '2026-07-28',
|
|
'io.modelcontextprotocol/clientCapabilities': {
|
|
extensions: { 'io.modelcontextprotocol/ui': {} },
|
|
},
|
|
'io.modelcontextprotocol/clientInfo': { name: 'test-client', version: '1.0.0' },
|
|
}
|
|
|
|
function mcpRequest(
|
|
method: string,
|
|
params?: Record<string, unknown>,
|
|
headers?: Record<string, string>
|
|
): Request {
|
|
const url = new URL('http://localhost:3000/api/extensions/ext/mcp-server/mcp')
|
|
return new Request(url, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Authorization: 'Bearer test-token',
|
|
...(headers ?? {}),
|
|
},
|
|
body: JSON.stringify({
|
|
jsonrpc: '2.0',
|
|
id: 1,
|
|
method,
|
|
...(params ? { params } : {}),
|
|
}),
|
|
})
|
|
}
|
|
|
|
async function readBody(request: Request): Promise<{
|
|
status: number
|
|
result?: Record<string, unknown>
|
|
error?: { code: number; message: string; data?: unknown }
|
|
}> {
|
|
const response = await handleMcpRequest(request)
|
|
const body = await response.json()
|
|
return { status: response.status, result: body.result, error: body.error }
|
|
}
|
|
|
|
describe('MCP spec revision 2026-07-28', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
eventBus.clear()
|
|
mocks.serviceClient = {}
|
|
})
|
|
|
|
describe('server/discover', () => {
|
|
it('advertises supported versions, capabilities, identity, and freshness', async () => {
|
|
const { result } = await readBody(mcpRequest('server/discover'))
|
|
expect(result?.resultType).toBe('complete')
|
|
expect(result?.supportedVersions).toEqual([
|
|
'2026-07-28',
|
|
'2025-06-18',
|
|
'2025-03-26',
|
|
'2024-11-05',
|
|
])
|
|
const capabilities = result?.capabilities as Record<string, unknown>
|
|
expect(capabilities.tools).toEqual({ listChanged: false })
|
|
expect(capabilities.extensions).toMatchObject({ 'io.modelcontextprotocol/ui': {} })
|
|
expect(typeof result?.ttlMs).toBe('number')
|
|
expect(result?.cacheScope).toBe('private')
|
|
const meta = result?._meta as Record<string, Record<string, unknown>>
|
|
expect(meta['io.modelcontextprotocol/serverInfo'].name).toBe('gnubok')
|
|
expect(result?.instructions).toContain('gnubok_search_tools')
|
|
})
|
|
})
|
|
|
|
describe('per-request _meta version negotiation', () => {
|
|
it('rejects an unsupported protocol version with UnsupportedProtocolVersionError', async () => {
|
|
const { status, error } = await readBody(
|
|
mcpRequest('tools/list', {
|
|
_meta: { 'io.modelcontextprotocol/protocolVersion': '2031-01-01' },
|
|
})
|
|
)
|
|
expect(status).toBe(400)
|
|
expect(error?.code).toBe(-32022)
|
|
expect((error?.data as { supported: string[] }).supported).toContain('2026-07-28')
|
|
})
|
|
|
|
it('decorates results for stateless clients: resultType, serverInfo, freshness', async () => {
|
|
const { result } = await readBody(mcpRequest('tools/list', { _meta: STATELESS_META }))
|
|
expect(result?.resultType).toBe('complete')
|
|
expect(result?.ttlMs).toBe(3_600_000)
|
|
expect(result?.cacheScope).toBe('private')
|
|
const meta = result?._meta as Record<string, Record<string, unknown>>
|
|
expect(meta['io.modelcontextprotocol/serverInfo'].name).toBe('gnubok')
|
|
expect((result?.tools as unknown[]).length).toBeGreaterThan(0)
|
|
})
|
|
|
|
it('keeps handshake-era responses byte-identical (no new fields)', async () => {
|
|
const { result } = await readBody(mcpRequest('tools/list'))
|
|
expect(result?.resultType).toBeUndefined()
|
|
expect(result?.ttlMs).toBeUndefined()
|
|
expect(result?.cacheScope).toBeUndefined()
|
|
expect(result?._meta).toBeUndefined()
|
|
})
|
|
|
|
it('returns deterministic tools/list ordering across calls', async () => {
|
|
const first = await readBody(mcpRequest('tools/list'))
|
|
const second = await readBody(mcpRequest('tools/list'))
|
|
const names = (r: typeof first) => (r.result?.tools as Array<{ name: string }>).map((t) => t.name)
|
|
expect(names(first)).toEqual(names(second))
|
|
})
|
|
})
|
|
|
|
describe('standard request headers', () => {
|
|
it('rejects an Mcp-Method header that disagrees with the body', async () => {
|
|
const { status, error } = await readBody(
|
|
mcpRequest('tools/list', undefined, { 'Mcp-Method': 'tools/call' })
|
|
)
|
|
expect(status).toBe(400)
|
|
expect(error?.code).toBe(-32020)
|
|
})
|
|
|
|
it('accepts a matching Mcp-Method and Mcp-Name pair', async () => {
|
|
const { result } = await readBody(
|
|
mcpRequest(
|
|
'tools/call',
|
|
{
|
|
name: 'gnubok_search_tools',
|
|
arguments: { query: 'list companies', detail: 'name', limit: 5 },
|
|
},
|
|
{ 'Mcp-Method': 'tools/call', 'Mcp-Name': 'gnubok_search_tools' }
|
|
)
|
|
)
|
|
expect(result?.structuredContent).toBeDefined()
|
|
})
|
|
|
|
it('rejects an Mcp-Name header that disagrees with params.name', async () => {
|
|
const { status, error } = await readBody(
|
|
mcpRequest(
|
|
'tools/call',
|
|
{
|
|
name: 'gnubok_search_tools',
|
|
arguments: { query: 'x', detail: 'name', limit: 5 },
|
|
},
|
|
{ 'Mcp-Name': 'gnubok_create_invoice' }
|
|
)
|
|
)
|
|
expect(status).toBe(400)
|
|
expect(error?.code).toBe(-32020)
|
|
})
|
|
|
|
it('accepts requests without the standard headers (handshake-era clients)', async () => {
|
|
const { result } = await readBody(mcpRequest('tools/list'))
|
|
expect((result?.tools as unknown[]).length).toBeGreaterThan(0)
|
|
})
|
|
|
|
it('validates Mcp-Name against params.uri on resources/read', async () => {
|
|
const mismatch = await readBody(
|
|
mcpRequest(
|
|
'resources/read',
|
|
{ uri: 'ui://receipt-matcher/app.html' },
|
|
{ 'Mcp-Name': 'ui://vat-review/app.html' }
|
|
)
|
|
)
|
|
expect(mismatch.status).toBe(400)
|
|
expect(mismatch.error?.code).toBe(-32020)
|
|
|
|
const match = await readBody(
|
|
mcpRequest(
|
|
'resources/read',
|
|
{ uri: 'ui://receipt-matcher/app.html' },
|
|
{ 'Mcp-Name': 'ui://receipt-matcher/app.html' }
|
|
)
|
|
)
|
|
expect(match.error).toBeUndefined()
|
|
expect(match.result?.contents).toBeDefined()
|
|
})
|
|
|
|
it('decodes the base64 sentinel form of Mcp-Name before comparing', async () => {
|
|
const encoded = `=?base64?${Buffer.from('gnubok_search_tools', 'utf8').toString('base64')}?=`
|
|
const { result } = await readBody(
|
|
mcpRequest(
|
|
'tools/call',
|
|
{
|
|
name: 'gnubok_search_tools',
|
|
arguments: { query: 'list companies', detail: 'name', limit: 5 },
|
|
},
|
|
{ 'Mcp-Name': encoded }
|
|
)
|
|
)
|
|
expect(result?.structuredContent).toBeDefined()
|
|
})
|
|
|
|
it('rejects an MCP-Protocol-Version header that disagrees with _meta', async () => {
|
|
const { status, error } = await readBody(
|
|
mcpRequest(
|
|
'tools/list',
|
|
{ _meta: { 'io.modelcontextprotocol/protocolVersion': '2026-07-28' } },
|
|
{ 'MCP-Protocol-Version': '2025-06-18' }
|
|
)
|
|
)
|
|
expect(status).toBe(400)
|
|
expect(error?.code).toBe(-32020)
|
|
})
|
|
})
|
|
|
|
describe('stateless tool results', () => {
|
|
it('carries resultType on successful tools/call results', async () => {
|
|
const { result } = await readBody(
|
|
mcpRequest('tools/call', {
|
|
name: 'gnubok_search_tools',
|
|
arguments: { query: 'list companies', detail: 'name', limit: 5 },
|
|
_meta: STATELESS_META,
|
|
})
|
|
)
|
|
expect(result?.resultType).toBe('complete')
|
|
const meta = result?._meta as Record<string, Record<string, unknown>>
|
|
expect(meta['io.modelcontextprotocol/serverInfo'].name).toBe('gnubok')
|
|
})
|
|
|
|
it('carries resultType on isError tool results too', async () => {
|
|
// The empty supabase mock makes any company-dependent tool fail during
|
|
// execution, which produces an isError TOOL RESULT (not a JSON-RPC
|
|
// error): exactly the shape that must also carry resultType.
|
|
const { result } = await readBody(
|
|
mcpRequest('tools/call', {
|
|
name: 'gnubok_get_trial_balance',
|
|
arguments: {},
|
|
_meta: STATELESS_META,
|
|
})
|
|
)
|
|
expect(result?.isError).toBe(true)
|
|
expect(result?.resultType).toBe('complete')
|
|
})
|
|
|
|
it('keeps unknown tools on the standard invalid-params JSON-RPC error', async () => {
|
|
const { error } = await readBody(
|
|
mcpRequest('tools/call', {
|
|
name: 'gnubok_nonexistent_tool_xyz',
|
|
arguments: {},
|
|
_meta: STATELESS_META,
|
|
})
|
|
)
|
|
expect(error?.code).toBe(-32602)
|
|
})
|
|
|
|
it('adds freshness hints to resources/list for stateless clients', async () => {
|
|
mocks.serviceClient = emptyRegistryClient()
|
|
const { result } = await readBody(mcpRequest('resources/list', { _meta: STATELESS_META }))
|
|
expect(result?.resultType).toBe('complete')
|
|
expect(result?.ttlMs).toBe(300_000)
|
|
expect(result?.cacheScope).toBe('private')
|
|
})
|
|
|
|
it('adds freshness hints to prompts/list for stateless clients', async () => {
|
|
const { result } = await readBody(mcpRequest('prompts/list', { _meta: STATELESS_META }))
|
|
expect(result?.resultType).toBe('complete')
|
|
expect(result?.ttlMs).toBe(3_600_000)
|
|
})
|
|
|
|
it('keeps resource-not-found on -32602 (invalid params)', async () => {
|
|
const { error } = await readBody(
|
|
mcpRequest('resources/read', {
|
|
uri: 'ui://does-not-exist/app.html',
|
|
_meta: STATELESS_META,
|
|
})
|
|
)
|
|
expect(error?.code).toBe(-32602)
|
|
})
|
|
})
|
|
|
|
describe('legacy handshake unchanged', () => {
|
|
it('still negotiates initialize for handshake-era versions', async () => {
|
|
const { result } = await readBody(
|
|
mcpRequest('initialize', {
|
|
protocolVersion: '2025-06-18',
|
|
capabilities: {},
|
|
clientInfo: { name: 'test', version: '1.0.0' },
|
|
})
|
|
)
|
|
expect(result?.protocolVersion).toBe('2025-06-18')
|
|
expect((result?.serverInfo as Record<string, unknown>).name).toBe('gnubok')
|
|
const capabilities = result?.capabilities as Record<string, unknown>
|
|
expect(capabilities.extensions).toMatchObject({ 'io.modelcontextprotocol/ui': {} })
|
|
})
|
|
|
|
it('negotiates an initialize requesting 2026-07-28 down to the handshake default', async () => {
|
|
const { result } = await readBody(
|
|
mcpRequest('initialize', {
|
|
protocolVersion: '2026-07-28',
|
|
capabilities: {},
|
|
clientInfo: { name: 'test', version: '1.0.0' },
|
|
})
|
|
)
|
|
expect(result?.protocolVersion).toBe('2025-06-18')
|
|
})
|
|
})
|
|
})
|