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
@@ -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,