diff --git a/app/.well-known/oauth-authorization-server/route.ts b/app/.well-known/oauth-authorization-server/route.ts index c2cc2a72..c475238c 100644 --- a/app/.well-known/oauth-authorization-server/route.ts +++ b/app/.well-known/oauth-authorization-server/route.ts @@ -23,6 +23,9 @@ export async function GET(request: Request) { grant_types_supported: ['authorization_code', 'refresh_token'], code_challenge_methods_supported: ['S256'], token_endpoint_auth_methods_supported: ['none', 'client_secret_post'], + // RFC 9207: the authorize endpoint includes `iss` in every authorization + // response (success and error) so clients can detect mix-up attacks. + authorization_response_iss_parameter_supported: true, // Advertise only the safe read-only default scopes plus the coarse // `mcp` marker. Destructive scopes (*:write, pending_operations:approve, // bookkeeping:write) are still accepted by /authorize when requested diff --git a/app/api/mcp-oauth/authorize/__tests__/route.test.ts b/app/api/mcp-oauth/authorize/__tests__/route.test.ts index 5283e47c..df74616c 100644 --- a/app/api/mcp-oauth/authorize/__tests__/route.test.ts +++ b/app/api/mcp-oauth/authorize/__tests__/route.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import crypto from 'crypto' const mocks = vi.hoisted(() => ({ createClient: vi.fn(), @@ -7,6 +8,10 @@ const mocks = vi.hoisted(() => ({ getBranding: vi.fn(), })) +vi.mock('@/lib/auth/oauth-codes', () => ({ + createAuthCode: vi.fn(() => 'test-auth-code'), +})) + vi.mock('@/lib/supabase/server', () => ({ createClient: () => mocks.createClient(), })) @@ -321,3 +326,66 @@ describe('MFA step-up on /api/mcp-oauth/authorize', () => { expect(response.status).toBe(200) }) }) + +describe('RFC 9207 iss parameter on authorization responses', () => { + const authorizeParams = { + response_type: 'code', + redirect_uri: 'https://claude.ai/api/mcp/auth_callback', + code_challenge: 'abc', + code_challenge_method: 'S256', + scope: 'mcp', + state: 'xyz', + } + + // Mirrors getScopeSigningKey/signScopeBinding in the route so the POST can + // present a scope binding that verifies against the test service key. + function signScope(scopeParam: string): string { + const key = crypto.createHash('sha256').update('oauth-scope:test-service-key').digest() + return crypto.createHmac('sha256', key).update(scopeParam).digest('base64url') + } + + beforeEach(() => { + vi.clearAllMocks() + process.env.SUPABASE_SERVICE_ROLE_KEY = 'test-service-key' + vi.stubEnv('NEXT_PUBLIC_APP_URL', 'https://app.test.example') + mocks.createClient.mockResolvedValue(buildSupabase({ id: 'user-1' })) + mocks.isAllowedRedirectUri.mockResolvedValue(true) + mocks.requireCompanyId.mockResolvedValue('company-1') + mocks.getBranding.mockReturnValue({ appName: 'gnubok' }) + }) + + afterEach(() => { + vi.unstubAllEnvs() + }) + + it('includes iss alongside code and state on the success redirect', async () => { + const formData = new FormData() + formData.set('consent', 'allow') + formData.set('scope_binding', 'mcp') + formData.set('scope_binding_sig', signScope('mcp')) + + const response = await POST( + new Request(buildAuthorizeUrl(authorizeParams), { method: 'POST', body: formData }), + ) + + expect(response.status).toBe(303) + const location = new URL(response.headers.get('location')!) + expect(location.searchParams.get('code')).toBe('test-auth-code') + expect(location.searchParams.get('state')).toBe('xyz') + expect(location.searchParams.get('iss')).toBe('https://app.test.example') + }) + + it('includes iss on error redirects (access_denied)', async () => { + const formData = new FormData() + formData.set('consent', 'deny') + + const response = await POST( + new Request(buildAuthorizeUrl(authorizeParams), { method: 'POST', body: formData }), + ) + + expect(response.status).toBe(303) + const location = new URL(response.headers.get('location')!) + expect(location.searchParams.get('error')).toBe('access_denied') + expect(location.searchParams.get('iss')).toBe('https://app.test.example') + }) +}) diff --git a/app/api/mcp-oauth/authorize/route.ts b/app/api/mcp-oauth/authorize/route.ts index d65666ed..10c62295 100644 --- a/app/api/mcp-oauth/authorize/route.ts +++ b/app/api/mcp-oauth/authorize/route.ts @@ -7,6 +7,7 @@ import { shouldEnforceMfa } from '@/lib/auth/mfa' import { requireCompanyId } from '@/lib/company/context' import { getBranding } from '@/lib/branding/service' import { isAllowedRedirectUri } from '@/lib/auth/oauth-allowlist' +import { resolveDiscoveryBaseUrl } from '@/lib/api/v1/base-url' import { ALL_SCOPES, API_KEY_SCOPES, @@ -132,11 +133,15 @@ async function requireAal2( return null } -function errorRedirect(redirectUri: string, state: string | null, error: string, desc: string): Response { +function errorRedirect(request: Request, redirectUri: string, state: string | null, error: string, desc: string): Response { const url = new URL(redirectUri) url.searchParams.set('error', error) url.searchParams.set('error_description', desc) if (state) url.searchParams.set('state', state) + // RFC 9207: identify the issuer in every authorization response so clients + // can detect mix-up attacks. Must equal the issuer that discovery + // advertised for the host the client connected through. + url.searchParams.set('iss', resolveDiscoveryBaseUrl(request)) return NextResponse.redirect(url.toString(), 303) } @@ -683,7 +688,7 @@ export async function POST(request: Request) { const consent = formData.get('consent') if (consent !== 'allow') { - return errorRedirect(redirectUri, state, 'access_denied', 'User denied the request') + return errorRedirect(request, redirectUri, state, 'access_denied', 'User denied the request') } // Verify the scope binding signed at consent display matches what was @@ -702,6 +707,7 @@ export async function POST(request: Request) { !verifyScopeBinding(presentedScopeStr, presentedSigStr) ) { return errorRedirect( + request, redirectUri, state, 'invalid_request', @@ -714,7 +720,7 @@ export async function POST(request: Request) { // selection below, not from this querystring. const parsed = parseRequestedScopes(querystringScopeParam) if (parsed.kind === 'invalid_scope') { - return errorRedirect(redirectUri, state, 'invalid_scope', parsed.description) + return errorRedirect(request, redirectUri, state, 'invalid_scope', parsed.description) } // The user selects scopes via checkboxes on the consent page. Two upper @@ -755,6 +761,9 @@ export async function POST(request: Request) { const callbackUrl = new URL(redirectUri) callbackUrl.searchParams.set('code', code) if (state) callbackUrl.searchParams.set('state', state) + // RFC 9207: issuer identification in the authorization response. Must match + // the issuer discovery advertises for the host the client connected through. + callbackUrl.searchParams.set('iss', resolveDiscoveryBaseUrl(request)) // 303 See Other: forces browser to GET the callback URL, even though this // handler was reached via POST. NextResponse.redirect() defaults to 307, diff --git a/extensions/general/mcp-server/__tests__/protocol-2026-07-28.test.ts b/extensions/general/mcp-server/__tests__/protocol-2026-07-28.test.ts new file mode 100644 index 00000000..ba5add9b --- /dev/null +++ b/extensions/general/mcp-server/__tests__/protocol-2026-07-28.test.ts @@ -0,0 +1,334 @@ +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() + 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 = {} + 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, + headers?: Record +): 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 + 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 + expect(capabilities.tools).toEqual({ listChanged: false }) + expect(capabilities.extensions).toEqual({ 'io.modelcontextprotocol/ui': {} }) + expect(typeof result?.ttlMs).toBe('number') + expect(result?.cacheScope).toBe('private') + const meta = result?._meta as Record> + 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> + 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> + 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).name).toBe('gnubok') + const capabilities = result?.capabilities as Record + expect(capabilities.extensions).toEqual({ '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') + }) + }) +}) diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts index 041021a1..219ba98a 100644 --- a/extensions/general/mcp-server/server.ts +++ b/extensions/general/mcp-server/server.ts @@ -15426,6 +15426,57 @@ const SERVER_INFO_BY_NAMESPACE = { const PROTOCOL_VERSION = '2025-06-18' +// ── Spec revision 2026-07-28 (stateless core) ──────────────── +// New-style clients skip the initialize handshake and instead carry their +// protocol version and capabilities in _meta on every request. The handshake +// path keeps serving 2025-06-18-and-earlier clients unchanged: their +// responses stay byte-identical. +const STATELESS_PROTOCOL_VERSION = '2026-07-28' +const SUPPORTED_PROTOCOL_VERSIONS = [ + STATELESS_PROTOCOL_VERSION, + '2025-06-18', + '2025-03-26', + '2024-11-05', +] +const META_PROTOCOL_VERSION = 'io.modelcontextprotocol/protocolVersion' +const META_SERVER_INFO = 'io.modelcontextprotocol/serverInfo' +// 2026-07-28 reserves -32020..-32099 for spec-defined errors. +const JSONRPC_HEADER_MISMATCH = -32020 +const JSONRPC_UNSUPPORTED_PROTOCOL_VERSION = -32022 +// CacheableResult freshness hints. The tool/prompt catalog and widget HTML +// change only on deploy; skills live in the DB and can change between +// deploys; data resources are live ledger state and must never be cached. +// Everything is served behind Authorization, so cacheScope stays private. +const CACHE_STATIC = { ttlMs: 3_600_000, cacheScope: 'private' } as const +const CACHE_SKILLS = { ttlMs: 300_000, cacheScope: 'private' } as const +const CACHE_LIVE = { ttlMs: 0, cacheScope: 'private' } as const + +const SERVER_CAPABILITIES = { + tools: { listChanged: false }, + resources: { listChanged: false }, + prompts: { listChanged: false }, + // MCP Apps (ratified extension): widgets are served as ui:// resources and + // referenced from tool _meta.ui.resourceUri (see widgets/). + extensions: { 'io.modelcontextprotocol/ui': {} }, +} + +/** + * Decode a standard-header value per the 2026-07-28 Value Encoding rules: + * values outside plain ASCII arrive as =?base64??= and MUST be decoded + * before comparing against the request body. Returns null for an absent + * header so callers can distinguish "not sent" from "sent empty". + */ +function decodeMcpHeaderValue(value: string | null): string | null { + if (value === null) return null + const match = /^=\?base64\?(.*)\?=$/.exec(value) + if (!match) return value + try { + return Buffer.from(match[1], 'base64').toString('utf8') + } catch { + return value + } +} + function jsonRpc(id: string | number | null, result: unknown): JsonRpcResponse { return { jsonrpc: '2.0', id, result } } @@ -15779,25 +15830,108 @@ export async function handleMcpRequest(request: Request): Promise { ) } + // ── Stateless core (spec 2026-07-28) ── + // New-style clients carry their protocol version in _meta on every request + // instead of an initialize handshake. Requests without the key come from + // handshake-era clients and keep byte-identical responses. + const requestMeta = (body.params?._meta ?? {}) as Record + const metaVersion = requestMeta[META_PROTOCOL_VERSION] + if (typeof metaVersion === 'string' && !SUPPORTED_PROTOCOL_VERSIONS.includes(metaVersion)) { + return NextResponse.json( + jsonRpcError( + body.id ?? null, + JSONRPC_UNSUPPORTED_PROTOCOL_VERSION, + `Unsupported protocol version: "${metaVersion}"`, + { supported: SUPPORTED_PROTOCOL_VERSIONS } + ), + { status: 400 } + ) + } + // Revisions are ISO dates, so string comparison orders them correctly. + const statelessClient = + typeof metaVersion === 'string' && metaVersion >= STATELESS_PROTOCOL_VERSION + + // Standard request headers (2026-07-28): when present they must agree with + // the JSON-RPC body. Absence stays accepted: this server supports + // handshake-era clients (the spec sanctions that leniency), and the stdio + // bridges do not send the headers. + const headerProtocolVersion = request.headers.get('mcp-protocol-version') + if ( + headerProtocolVersion && + typeof metaVersion === 'string' && + headerProtocolVersion !== metaVersion + ) { + return NextResponse.json( + jsonRpcError( + body.id ?? null, + JSONRPC_HEADER_MISMATCH, + `Header mismatch: MCP-Protocol-Version "${headerProtocolVersion}" does not match _meta protocol version "${metaVersion}"` + ), + { status: 400 } + ) + } + const headerMethod = request.headers.get('mcp-method') + if (headerMethod && headerMethod !== body.method) { + return NextResponse.json( + jsonRpcError( + body.id ?? null, + JSONRPC_HEADER_MISMATCH, + `Header mismatch: Mcp-Method "${headerMethod}" does not match body method "${body.method}"` + ), + { status: 400 } + ) + } + // Mcp-Name mirrors params.name (tools/call, prompts/get) or params.uri + // (resources/read); non-ASCII values arrive base64-wrapped and are decoded + // before comparison. + const headerName = decodeMcpHeaderValue(request.headers.get('mcp-name')) + const bodyParamName = body.params?.name ?? body.params?.uri + if (headerName !== null && typeof bodyParamName === 'string' && headerName !== bodyParamName) { + return NextResponse.json( + jsonRpcError( + body.id ?? null, + JSONRPC_HEADER_MISMATCH, + `Header mismatch: Mcp-Name "${headerName}" does not match the request body name/uri "${bodyParamName}"` + ), + { status: 400 } + ) + } + + /** + * Decorate a result for stateless-core clients: required resultType, + * serverInfo identification, and CacheableResult freshness hints. A no-op + * for handshake-era clients so existing connections see unchanged payloads. + */ + const decorate = ( + result: Record, + cache?: { ttlMs: number; cacheScope: 'public' | 'private' } + ): Record => { + if (!statelessClient) return result + const decorated: Record = { resultType: 'complete', ...result } + if (cache) { + decorated.ttlMs = cache.ttlMs + decorated.cacheScope = cache.cacheScope + } + decorated._meta = { + ...((result._meta as Record | undefined) ?? {}), + [META_SERVER_INFO]: SERVER_INFO_BY_NAMESPACE[toolNamespace], + } + return decorated + } + // ── Dispatch ── const { method, id, params } = body switch (method) { + case 'server/discover': case 'initialize': { - const SUPPORTED_VERSIONS = new Set(['2025-06-18', '2025-03-26', '2024-11-05']) + // Handshake-era set: a 2026-07-28 stateless client never sends + // initialize; one that does anyway negotiates down to 2025-06-18. + const HANDSHAKE_VERSIONS = new Set(['2025-06-18', '2025-03-26', '2024-11-05']) const clientVersion = (params as Record)?.protocolVersion as string | undefined const negotiatedVersion = - clientVersion && SUPPORTED_VERSIONS.has(clientVersion) ? clientVersion : PROTOCOL_VERSION - return NextResponse.json( - jsonRpc(id ?? null, { - protocolVersion: negotiatedVersion, - capabilities: { - tools: { listChanged: false }, - resources: { listChanged: false }, - prompts: { listChanged: false }, - }, - serverInfo: SERVER_INFO_BY_NAMESPACE[toolNamespace], - instructions: projectToolReferencesInText([ + clientVersion && HANDSHAKE_VERSIONS.has(clientVersion) ? clientVersion : PROTOCOL_VERSION + const instructions = projectToolReferencesInText([ 'Accounted: Swedish double-entry bookkeeping via conversation.', '', 'Discovery:', @@ -15827,7 +15961,30 @@ export async function handleMcpRequest(request: Request): Promise { toolNamespace === 'gnubok' ? 'Tool names carry the legacy gnubok_ prefix (a stable identifier kept across the rebrand); the server and app are "Accounted". Same product: the prefix is not a different system.' : 'Tool names use the accounted_ prefix. Legacy gnubok_ aliases remain accepted for existing integrations.', - ].join('\n'), toolNamespace, getCanonicalToolNames()), + ].join('\n'), toolNamespace, getCanonicalToolNames()) + // 2026-07-28 MUST: server/discover advertises supported revisions, + // capabilities, and identity so stateless clients can select a version + // up front or use it as a compatibility probe. Always answers in the + // stateless result shape regardless of the request's _meta. + if (method === 'server/discover') { + return NextResponse.json( + jsonRpc(id ?? null, { + resultType: 'complete', + supportedVersions: SUPPORTED_PROTOCOL_VERSIONS, + capabilities: SERVER_CAPABILITIES, + instructions, + ttlMs: CACHE_STATIC.ttlMs, + cacheScope: CACHE_STATIC.cacheScope, + _meta: { [META_SERVER_INFO]: SERVER_INFO_BY_NAMESPACE[toolNamespace] }, + }) + ) + } + return NextResponse.json( + jsonRpc(id ?? null, { + protocolVersion: negotiatedVersion, + capabilities: SERVER_CAPABILITIES, + serverInfo: SERVER_INFO_BY_NAMESPACE[toolNamespace], + instructions, }) ) } @@ -15837,7 +15994,7 @@ export async function handleMcpRequest(request: Request): Promise { return new Response(null, { status: 202 }) case 'ping': - return NextResponse.json(jsonRpc(id ?? null, {})) + return NextResponse.json(jsonRpc(id ?? null, decorate({}))) case 'tools/list': { const listStartedAt = Date.now() @@ -15855,7 +16012,7 @@ export async function handleMcpRequest(request: Request): Promise { companyId, }) return NextResponse.json( - jsonRpc(id ?? null, { + jsonRpc(id ?? null, decorate({ tools: allowedTools.map((t) => { // Merge derived staging metadata with any literal _meta (e.g. UI // widget hints). Literal _meta wins on key collision so explicit @@ -15877,7 +16034,7 @@ export async function handleMcpRequest(request: Request): Promise { toolNamespace ) }), - }) + }, CACHE_STATIC)) ) } @@ -15945,10 +16102,10 @@ export async function handleMcpRequest(request: Request): Promise { }) const publicScopeError = projectMcpPayload(scopeError, toolNamespace) return NextResponse.json( - jsonRpc(id ?? null, { + jsonRpc(id ?? null, decorate({ content: [{ type: 'text', text: JSON.stringify(publicScopeError, null, 2) }], isError: true, - }) + })) ) } @@ -15989,10 +16146,10 @@ export async function handleMcpRequest(request: Request): Promise { companyId, }) return NextResponse.json( - jsonRpc(id ?? null, { + jsonRpc(id ?? null, decorate({ content: [{ type: 'text', text: JSON.stringify(publicStructured, null, 2) }], isError: true, - }) + })) ) } @@ -16019,10 +16176,10 @@ export async function handleMcpRequest(request: Request): Promise { companyId: effectiveCompanyId, }) return NextResponse.json( - jsonRpc(id ?? null, { + jsonRpc(id ?? null, decorate({ content: [{ type: 'text', text: JSON.stringify(publicCapError, null, 2) }], isError: true, - }) + })) ) } @@ -16060,10 +16217,10 @@ export async function handleMcpRequest(request: Request): Promise { companyId: effectiveCompanyId, }) return NextResponse.json( - jsonRpc(id ?? null, { + jsonRpc(id ?? null, decorate({ content: [{ type: 'text', text: JSON.stringify(publicBlocked, null, 2) }], isError: true, - }) + })) ) } } @@ -16130,7 +16287,7 @@ export async function handleMcpRequest(request: Request): Promise { userId, companyId: effectiveCompanyId, }) - return NextResponse.json(jsonRpc(id ?? null, response)) + return NextResponse.json(jsonRpc(id ?? null, decorate(response))) } catch (err) { const latencyMs = Date.now() - callStartedAt const structured = toToolError(err, { toolName }) @@ -16153,10 +16310,10 @@ export async function handleMcpRequest(request: Request): Promise { companyId: effectiveCompanyId, }) return NextResponse.json( - jsonRpc(id ?? null, { + jsonRpc(id ?? null, decorate({ content: [{ type: 'text', text: JSON.stringify(publicStructured, null, 2) }], isError: true, - }) + })) ) } } @@ -16164,7 +16321,7 @@ export async function handleMcpRequest(request: Request): Promise { case 'resources/list': { const allSkills = await loadAllSkills(supabase) return NextResponse.json( - jsonRpc(id ?? null, projectMcpPayload({ + jsonRpc(id ?? null, decorate(projectMcpPayload({ resources: [ ...uiWidgets.map((w) => ({ uri: w.uri, @@ -16185,7 +16342,7 @@ export async function handleMcpRequest(request: Request): Promise { mimeType: r.mimeType, })), ], - }, toolNamespace)) + }, toolNamespace), CACHE_SKILLS)) ) } @@ -16207,7 +16364,7 @@ export async function handleMcpRequest(request: Request): Promise { companyId, }) return NextResponse.json( - jsonRpc(id ?? null, { + jsonRpc(id ?? null, decorate({ contents: [ { uri, @@ -16219,7 +16376,7 @@ export async function handleMcpRequest(request: Request): Promise { ), }, ], - }) + }, CACHE_STATIC)) ) } @@ -16242,7 +16399,7 @@ export async function handleMcpRequest(request: Request): Promise { companyId, }) return NextResponse.json( - jsonRpc(id ?? null, { + jsonRpc(id ?? null, decorate({ contents: [ { uri, @@ -16254,7 +16411,7 @@ export async function handleMcpRequest(request: Request): Promise { ), }, ], - }) + }, CACHE_SKILLS)) ) } } @@ -16281,7 +16438,7 @@ export async function handleMcpRequest(request: Request): Promise { companyId, }) return NextResponse.json( - jsonRpc(id ?? null, { + jsonRpc(id ?? null, decorate({ contents: [ { uri, @@ -16289,7 +16446,7 @@ export async function handleMcpRequest(request: Request): Promise { text: JSON.stringify(projectMcpPayload(result, toolNamespace), null, 2), }, ], - }) + }, CACHE_LIVE)) ) } catch (err) { const message = err instanceof Error ? err.message : 'Resource read failed' @@ -16336,12 +16493,12 @@ export async function handleMcpRequest(request: Request): Promise { case 'prompts/list': return NextResponse.json( - jsonRpc(id ?? null, projectMcpPayload({ + jsonRpc(id ?? null, decorate(projectMcpPayload({ prompts: prompts.map((p) => ({ name: p.name, description: p.description, })), - }, toolNamespace)) + }, toolNamespace), CACHE_STATIC)) ) case 'prompts/get': { @@ -16353,7 +16510,7 @@ export async function handleMcpRequest(request: Request): Promise { ) } return NextResponse.json( - jsonRpc(id ?? null, { + jsonRpc(id ?? null, decorate({ description: prompt.description, messages: [ { @@ -16368,7 +16525,7 @@ export async function handleMcpRequest(request: Request): Promise { }, }, ], - }) + })) ) }