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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user