Files
summercms/scripts/check-phase8-mcp-client.mjs
Jakub Zych e87346f9e3 feat(08-09): complete the fail-closed Phase 8 final unchanged-MCP gate
Fills in every scripts/check-phase8.sh stage skeleton with real logic:
disposable Postgres (docker run + pg_isready), the assembled Go app built
and served against it with a throwaway onboarding-seeded gate account,
the real unchanged fonoteka-mcp process started with all three required
environment variables, and the full scripted SDK lifecycle -- discovery
(MCP's own RFC 9728 401 hint, verified separately from authorization
server metadata), DCR, PKCE authorize, JWT login/consent, token, an MCP
tool call, refresh, replay of the spent refresh token, revoke, and a
post-revoke refresh failure -- delegated to the new
scripts/check-phase8-mcp-client.mjs driver, which resolves the MCP SDK's
auth helpers from fonoteka-mcp's own node_modules (no new dependency,
same pattern as parity/capture_clients.mjs). Both repositories'
vet/test/race, the full parity/corpus/secret-scan gate, the existing
check-phase8-ui.mjs --final-gate UI harness, an unchanged-client git-diff
check for both MCP_ROOT and NUXT_ROOT, and a 08-SECURITY-REVIEW.md
status:verified gate close out the stage list.

--contract-self-test validates structure only (stage names/order,
cleanup trap, loopback-only binding, the three MCP env vars, the
redaction helper, no pre-final full-run flag, read-only unchanged-client
references) in well under 30 seconds -- it boots no services. The
--red-contract self-test from Task 1 is preserved unchanged. run_full_gate
(the no-flag invocation) is 08-10 Task 3's sole execution site; 08-09
never invokes it.
2026-09-23 23:18:15 +02:00

318 lines
12 KiB
JavaScript
Executable File

#!/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, '<redacted-inv>')
.replace(/eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g, '<redacted-jwt>')
.replace(/("access_token"|"refresh_token"|"code_verifier"|"client_secret")\s*:\s*"[^"]*"/g, '$1:"<redacted>"')
}
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 <name>')
}
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)))