feat(mcp): speak spec revision 2026-07-28 (stateless core) (#1277)

* 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>

* fix(mcp): Mcp-Name covers params.uri, base64 sentinel, version-header consistency

Review follow-ups against the transport spec text: Mcp-Name mirrors
params.name OR params.uri (resources/read), values arrive base64-wrapped
in the =?base64?...?= sentinel and must be decoded before comparison, and
an MCP-Protocol-Version header that disagrees with the _meta protocol
version is a HeaderMismatch. Absence of any header stays accepted since
this server supports handshake-era clients (spec-sanctioned leniency).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-29 18:02:05 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 3bbf2a051b
commit dc5aea4a35
5 changed files with 613 additions and 42 deletions
@@ -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
@@ -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')
})
})
+12 -3
View File
@@ -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,
@@ -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<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).toEqual({ '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).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')
})
})
})
+196 -39
View File
@@ -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?<data>?= 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<Response> {
)
}
// ── 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<string, unknown>
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<string, unknown>,
cache?: { ttlMs: number; cacheScope: 'public' | 'private' }
): Record<string, unknown> => {
if (!statelessClient) return result
const decorated: Record<string, unknown> = { resultType: 'complete', ...result }
if (cache) {
decorated.ttlMs = cache.ttlMs
decorated.cacheScope = cache.cacheScope
}
decorated._meta = {
...((result._meta as Record<string, unknown> | 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<string, unknown>)?.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<Response> {
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<Response> {
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<Response> {
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<Response> {
toolNamespace
)
}),
})
}, CACHE_STATIC))
)
}
@@ -15945,10 +16102,10 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
})
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<Response> {
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<Response> {
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<Response> {
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<Response> {
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<Response> {
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<Response> {
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<Response> {
mimeType: r.mimeType,
})),
],
}, toolNamespace))
}, toolNamespace), CACHE_SKILLS))
)
}
@@ -16207,7 +16364,7 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
companyId,
})
return NextResponse.json(
jsonRpc(id ?? null, {
jsonRpc(id ?? null, decorate({
contents: [
{
uri,
@@ -16219,7 +16376,7 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
),
},
],
})
}, CACHE_STATIC))
)
}
@@ -16242,7 +16399,7 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
companyId,
})
return NextResponse.json(
jsonRpc(id ?? null, {
jsonRpc(id ?? null, decorate({
contents: [
{
uri,
@@ -16254,7 +16411,7 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
),
},
],
})
}, CACHE_SKILLS))
)
}
}
@@ -16281,7 +16438,7 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
companyId,
})
return NextResponse.json(
jsonRpc(id ?? null, {
jsonRpc(id ?? null, decorate({
contents: [
{
uri,
@@ -16289,7 +16446,7 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
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<Response> {
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<Response> {
)
}
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<Response> {
},
},
],
})
}))
)
}