#!/usr/bin/env node /** * check-phase8-mcp-client.mjs -- the scripted MCP client D-14 requires: * discovery, DCR, PKCE authorize, JWT login/consent, token, an MCP tool * call, refresh, replay, revoke, and post-revoke failure, driven against * the REAL unchanged fonoteka-mcp process and the assembled Go backend. * Never modifies fonoteka-mcp or Nuxt source; resolves the MCP SDK's auth * helpers from fonoteka-mcp's own node_modules exactly like * parity/capture_clients.mjs does (no new dependency in either repo). * * Invoked once per named stage by scripts/check-phase8.sh's stage_* * functions, each stage reading/writing a small JSON state file so later * stages (refresh, revoke) can reuse earlier captures (tokens, request * ids) without re-running the whole sequence. Only 08-10 Task 3 invokes * this end to end; 08-09 never runs it. * * Required env: * FONOTEKA_API_URL Go app origin (personal-token API, and the * same origin's JWT-group user/session API). * FONOTEKA_MCP_PUBLIC_URL The MCP resource server's own base URL. * FONOTEKA_MCP_AUTH_SERVER The Go authorization server's base URL * (normally identical to FONOTEKA_API_URL). * PHASE8_GATE_STATE Path to the JSON state file. * PHASE8_GATE_EMAIL/PASSWORD Credentials for the JWT login/consent * steps, seeded by the app-boot stage. * * Every raw secret/token/code/verifier this script would otherwise print * is redacted before it reaches stdout/stderr (T-08-REQUEST-LEAK). */ import { createRequire } from 'node:module' import { pathToFileURL } from 'node:url' import { readFileSync, writeFileSync, existsSync } from 'node:fs' const MCP_PKG = '/media/nvme/dev/golem15/fonoteka/fonoteka-mcp/package.json' function redact(s) { return String(s) .replace(/inv_[A-Za-z0-9_-]{8,}/g, '') .replace(/eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g, '') .replace(/("access_token"|"refresh_token"|"code_verifier"|"client_secret")\s*:\s*"[^"]*"/g, '$1:""') } function log(msg) { process.stderr.write(`[check-phase8-mcp-client] ${redact(msg)}\n`) } function fail(msg) { log(`FAIL: ${msg}`) process.exit(1) } function env(name, required = true) { const v = process.env[name] if (required && (!v || !v.trim())) fail(`missing required env ${name}`) return v } function loadState(path) { if (!existsSync(path)) return {} return JSON.parse(readFileSync(path, 'utf8')) } function saveState(path, state) { writeFileSync(path, JSON.stringify(state, null, 2), { mode: 0o600 }) } async function loadAuthHelpers() { const req = createRequire(MCP_PKG) const mod = await import(pathToFileURL(req.resolve('@modelcontextprotocol/sdk/client/auth.js')).href) return mod } async function loadClientTransport() { const req = createRequire(MCP_PKG) const clientMod = await import(pathToFileURL(req.resolve('@modelcontextprotocol/sdk/client/index.js')).href) const httpMod = await import(pathToFileURL(req.resolve('@modelcontextprotocol/sdk/client/streamableHttp.js')).href) return { Client: clientMod.Client, StreamableHTTPClientTransport: httpMod.StreamableHTTPClientTransport } } const REDIRECT_URI = 'http://127.0.0.1:8424/oauth/callback' async function stageDiscovery(state) { const mcpPublic = env('FONOTEKA_MCP_PUBLIC_URL') const authServer = env('FONOTEKA_MCP_AUTH_SERVER') const auth = await loadAuthHelpers() // The resource server's own 401 hint (RFC 9728), owned by fonoteka-mcp, // not the backend (D-12) -- verified separately from the backend's own // exact Basic/no-challenge responses (stage_token below). const probe = await fetch(new URL('/mcp', mcpPublic), { method: 'GET' }) if (probe.status !== 401) fail(`expected 401 from unauthenticated MCP endpoint, got ${probe.status}`) const params = auth.extractWWWAuthenticateParams ? auth.extractWWWAuthenticateParams(probe) : null if (!params || !params.resourceMetadataUrl) { fail('MCP 401 response is missing a resource_metadata WWW-Authenticate hint') } const resourceMetadata = await auth.discoverOAuthProtectedResourceMetadata(mcpPublic) const metadata = await auth.discoverAuthorizationServerMetadata(authServer) if (!metadata) fail('authorization server metadata discovery failed') state.resourceMetadata = resourceMetadata state.metadata = metadata log('discovery OK') } async function stageDCR(state) { const authServer = env('FONOTEKA_MCP_AUTH_SERVER') const auth = await loadAuthHelpers() const clientInformation = await auth.registerClient(authServer, { metadata: state.metadata, clientMetadata: { client_name: 'Phase 8 final gate client', redirect_uris: [REDIRECT_URI], grant_types: ['authorization_code', 'refresh_token'], response_types: ['code'], token_endpoint_auth_method: 'none', }, }) state.clientInformation = clientInformation log('dcr OK') } async function stagePKCEAuthorize(state) { const authServer = env('FONOTEKA_MCP_AUTH_SERVER') const auth = await loadAuthHelpers() const { authorizationUrl, codeVerifier } = await auth.startAuthorization(authServer, { metadata: state.metadata, clientInformation: state.clientInformation, redirectUrl: REDIRECT_URI, scope: 'read write', state: 'phase8-gate', }) const res = await fetch(authorizationUrl, { redirect: 'manual' }) if (res.status !== 302 && res.status !== 303 && res.status !== 307) { fail(`authorize did not redirect (status ${res.status})`) } const location = res.headers.get('location') if (!location) fail('authorize redirect has no Location header') const requestId = new URL(location).searchParams.get('request') if (!requestId) fail('authorize redirect is missing ?request=') state.codeVerifier = codeVerifier state.requestId = requestId log('pkce-authorize OK') } async function stageJWTLoginConsent(state) { const apiURL = env('FONOTEKA_API_URL') const email = env('PHASE8_GATE_EMAIL') const password = env('PHASE8_GATE_PASSWORD') const loginRes = await fetch(new URL('/_user/api/v1/login', apiURL), { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify({ email, password }), }) if (loginRes.status !== 200) fail(`JWT login failed (status ${loginRes.status})`) const { token } = await loginRes.json() if (!token) fail('JWT login response has no token') const showRes = await fetch(new URL(`/_fonoteka/api/v1/oauth/request/${state.requestId}`, apiURL), { headers: { Accept: 'application/json', Authorization: `Bearer ${token}` }, }) if (showRes.status !== 200) fail(`consent request lookup failed (status ${showRes.status})`) const consentRes = await fetch(new URL('/_fonoteka/api/v1/oauth/consent', apiURL), { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json', Authorization: `Bearer ${token}` }, body: JSON.stringify({ request_id: state.requestId, scopes: ['read', 'write'] }), }) if (consentRes.status !== 200) fail(`consent failed (status ${consentRes.status})`) const consentBody = await consentRes.json() const redirectTo = consentBody?.data?.redirect_to if (!redirectTo) fail('consent response has no redirect_to') const code = new URL(redirectTo).searchParams.get('code') if (!code) fail('consent redirect_to has no code') state.jwt = token state.code = code log('jwt-login-consent OK') } async function stageToken(state) { const authServer = env('FONOTEKA_MCP_AUTH_SERVER') const auth = await loadAuthHelpers() const tokens = await auth.exchangeAuthorization(authServer, { metadata: state.metadata, clientInformation: state.clientInformation, authorizationCode: state.code, codeVerifier: state.codeVerifier, redirectUri: REDIRECT_URI, }) if (!tokens.access_token || !tokens.refresh_token) fail('token exchange did not return both tokens') state.accessToken = tokens.access_token state.refreshToken = tokens.refresh_token state.spentRefreshToken = tokens.refresh_token log('token OK') } async function stageToolCall(state) { const mcpPublic = env('FONOTEKA_MCP_PUBLIC_URL') const { Client, StreamableHTTPClientTransport } = await loadClientTransport() const transport = new StreamableHTTPClientTransport(new URL('/mcp', mcpPublic), { requestInit: { headers: { Authorization: `Bearer ${state.accessToken}` } }, }) const client = new Client({ name: 'phase8-gate', version: '0.0.1' }) await client.connect(transport) try { const tools = await client.listTools() if (!Array.isArray(tools.tools) || tools.tools.length === 0) fail('MCP tool list is empty') const listGenres = tools.tools.find((t) => t.name === 'list_genres') if (!listGenres) fail('list_genres tool not found') const result = await client.callTool({ name: 'list_genres', arguments: {} }) if (result.isError) fail(`list_genres tool call reported an error: ${JSON.stringify(result)}`) } finally { await client.close().catch(() => {}) } log('tool-call OK') } async function stageRefresh(state) { const authServer = env('FONOTEKA_MCP_AUTH_SERVER') const auth = await loadAuthHelpers() const tokens = await auth.refreshAuthorization(authServer, { metadata: state.metadata, clientInformation: state.clientInformation, refreshToken: state.refreshToken, }) if (!tokens.access_token) fail('refresh did not return a new access token') state.spentRefreshToken = state.refreshToken state.accessToken = tokens.access_token state.refreshToken = tokens.refresh_token || state.refreshToken log('refresh OK') } async function stageReplay(state) { const authServer = env('FONOTEKA_MCP_AUTH_SERVER') const auth = await loadAuthHelpers() let threw = false try { await auth.refreshAuthorization(authServer, { metadata: state.metadata, clientInformation: state.clientInformation, refreshToken: state.spentRefreshToken, }) } catch { threw = true } if (!threw) fail('replaying the spent refresh token unexpectedly succeeded') log('replay OK (spent refresh token correctly rejected)') } async function stageRevoke(state) { const apiURL = env('FONOTEKA_API_URL') const listRes = await fetch(new URL('/_fonoteka/api/v1/oauth/connected-apps', apiURL), { headers: { Accept: 'application/json', Authorization: `Bearer ${state.jwt}` }, }) if (listRes.status !== 200) fail(`connected-apps list failed (status ${listRes.status})`) const listBody = await listRes.json() const app = (listBody.data || []).find((a) => a.name === 'Phase 8 final gate client') if (!app) fail('connected-apps list does not show this gate client') const delRes = await fetch(new URL(`/_fonoteka/api/v1/oauth/connected-apps/${app.id}`, apiURL), { method: 'DELETE', headers: { Accept: 'application/json', Authorization: `Bearer ${state.jwt}` }, }) if (delRes.status !== 200) fail(`connected-apps revoke failed (status ${delRes.status})`) state.revokedAppId = app.id log('revoke OK') } async function stagePostRevokeFailure(state) { const authServer = env('FONOTEKA_MCP_AUTH_SERVER') const auth = await loadAuthHelpers() let threw = false try { await auth.refreshAuthorization(authServer, { metadata: state.metadata, clientInformation: state.clientInformation, refreshToken: state.refreshToken, }) } catch { threw = true } if (!threw) fail('refreshing after revoke unexpectedly succeeded') log('post-revoke-failure OK') } const STAGES = { discovery: stageDiscovery, dcr: stageDCR, 'pkce-authorize': stagePKCEAuthorize, 'jwt-login-consent': stageJWTLoginConsent, token: stageToken, 'tool-call': stageToolCall, refresh: stageRefresh, replay: stageReplay, revoke: stageRevoke, 'post-revoke-failure': stagePostRevokeFailure, } async function main() { const stageArgIdx = process.argv.indexOf('--stage') if (stageArgIdx === -1 || !process.argv[stageArgIdx + 1]) { fail('usage: check-phase8-mcp-client.mjs --stage ') } const stageName = process.argv[stageArgIdx + 1] const fn = STAGES[stageName] if (!fn) fail(`unknown stage ${stageName}`) const statePath = env('PHASE8_GATE_STATE') const state = loadState(statePath) await fn(state) saveState(statePath, state) } main().catch((err) => fail(err?.stack || String(err)))