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:
co-authored by
Claude Fable 5
parent
3bbf2a051b
commit
dc5aea4a35
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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> {
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
}))
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user