From e0729b19e56e8035652011cca7505dec04dc64c8 Mon Sep 17 00:00:00 2001 From: Tanish Manku Date: Tue, 25 Aug 2026 00:01:52 -0700 Subject: [PATCH] mcp: demo-account smoke script, weekly smoke workflow, post-deploy smoke MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scripts/smoke.mjs drives a deployment the way a client does — PRM, AS metadata, DCR, consent + CSRF, password sign-in, PKCE exchange, initialize, tools/list, every read-only tool, revoke, revoked-token 401 — printing step outcomes only. Wired as `pnpm smoke`, as a post-deploy step in mcp.yml, and as a weekly/dispatchable mcp-smoke.yml; both skip with a notice until the SMOKE_USERNAME / SMOKE_PASSWORD repo secrets exist. Verified end to end against wrangler dev + the fake, including the failure path (exit 1, no credential echoed). Claude-Session: https://claude.ai/code/session_01H3gfN2NCx3hBXSrZXYHUQ3 --- .github/workflows/mcp-smoke.yml | 43 ++++++ .github/workflows/mcp.yml | 8 ++ mcp/CLAUDE.md | 18 ++- mcp/README.md | 12 ++ mcp/eslint.config.js | 12 +- mcp/package.json | 1 + mcp/scripts/smoke.mjs | 241 ++++++++++++++++++++++++++++++++ 7 files changed, 331 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/mcp-smoke.yml create mode 100644 mcp/scripts/smoke.mjs diff --git a/.github/workflows/mcp-smoke.yml b/.github/workflows/mcp-smoke.yml new file mode 100644 index 0000000000..2409e1e26c --- /dev/null +++ b/.github/workflows/mcp-smoke.yml @@ -0,0 +1,43 @@ +name: mcp smoke + +# The only place this project touches the real Artcraft backend: a read-only pass with the +# demo account against production (docs/infra-request.md §2 and §4). Weekly, so a change on +# the Claude/ChatGPT side or upstream shows up as a red badge rather than a support ticket. +on: + schedule: + - cron: "0 9 * * 1" # Mondays 09:00 UTC + workflow_dispatch: + inputs: + base_url: + description: "MCP server origin to smoke (defaults to production)" + required: false + default: "https://mcp.getartcraft.com" + +jobs: + smoke: + runs-on: ubuntu-latest + defaults: + run: + working-directory: mcp + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + - id: creds + run: | + if [ -n "$SMOKE_USERNAME" ] && [ -n "$SMOKE_PASSWORD" ]; then + echo "present=true" >> "$GITHUB_OUTPUT" + else + echo "::notice::SMOKE_USERNAME / SMOKE_PASSWORD not configured; skipping." + echo "present=false" >> "$GITHUB_OUTPUT" + fi + env: + SMOKE_USERNAME: ${{ secrets.SMOKE_USERNAME }} + SMOKE_PASSWORD: ${{ secrets.SMOKE_PASSWORD }} + - if: steps.creds.outputs.present == 'true' + run: node scripts/smoke.mjs + env: + SMOKE_BASE_URL: ${{ inputs.base_url || 'https://mcp.getartcraft.com' }} + SMOKE_USERNAME: ${{ secrets.SMOKE_USERNAME }} + SMOKE_PASSWORD: ${{ secrets.SMOKE_PASSWORD }} diff --git a/.github/workflows/mcp.yml b/.github/workflows/mcp.yml index 42aeb3e8f9..9a8f338e18 100644 --- a/.github/workflows/mcp.yml +++ b/.github/workflows/mcp.yml @@ -69,3 +69,11 @@ jobs: env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + # Read-only smoke with the demo account, right after the deploy (skipped until the + # demo-account secrets exist; docs/infra-request.md §2 and §4). + - if: steps.creds.outputs.present == 'true' && env.SMOKE_USERNAME != '' + run: node scripts/smoke.mjs + env: + SMOKE_BASE_URL: https://mcp.getartcraft.com + SMOKE_USERNAME: ${{ secrets.SMOKE_USERNAME }} + SMOKE_PASSWORD: ${{ secrets.SMOKE_PASSWORD }} diff --git a/mcp/CLAUDE.md b/mcp/CLAUDE.md index 5ebd5c5d23..87acf4126b 100644 --- a/mcp/CLAUDE.md +++ b/mcp/CLAUDE.md @@ -356,8 +356,18 @@ Four layers; all run offline in CI (`pnpm test`), inside the Workers runtime via 4. **End-to-end** — the real `@modelcontextprotocol/sdk` client with an `OAuthClientProvider` that completes our consent page, against the Worker in miniflare. -Manual: MCP Inspector against `wrangler dev`. Post-deploy: `pnpm smoke` with a dedicated test -account against production. Weekly scheduled e2e run against production. +Manual: MCP Inspector against `wrangler dev`. + +**Smoke (`scripts/smoke.mjs`, `pnpm smoke`)** — the one thing that touches a real backend. +Dependency-free Node script that does exactly what a client does: PRM → AS metadata (CIMD flag) +→ DCR → consent page + CSRF → password sign-in → code → PKCE exchange → initialize → tools/list +(must equal the 7 tool names) → one call per read-only tool → revoke → revoked token is 401. +Prints step outcomes only, never tokens/bodies/credentials; exit 1 on the first failure. Runs +after every production deploy (`mcp.yml`) and weekly (`mcp-smoke.yml`, Mondays 09:00 UTC, also +`workflow_dispatch` with a `base_url`), both skipped with a notice until `SMOKE_USERNAME` / +`SMOKE_PASSWORD` exist. It is verified locally against `wrangler dev` + the fake +(`SMOKE_BASE_URL=http://localhost:8787`, `localdev1` / `localdev1pass`) — add a step there when a +tool or flow changes, and keep the fake honest so the local run stays a real rehearsal. ## Code style @@ -403,11 +413,12 @@ mcp/ │ └── src/ state.ts (seeded localdev1, credits, jobs), session.ts, catalogue.ts (real model ids), │ pricing.ts, jobs.ts, routes/{session,billing,omni_gen,jobs}.ts — all spec-validated in tests ├── scripts/gen-api.mjs # fetch api.json → trim to allowlist → test/fixtures/api.json + src/upstream/schema.d.ts +├── scripts/smoke.mjs # read-only smoke of a deployment with the demo account (see Testing); post-deploy + weekly ├── src/ │ ├── index.ts # Worker entry: environment invariant first, then the OAuthProvider │ ├── runtime.ts # memoized Config + Hono app per isolate (failure memoized too) │ ├── oauth-env.d.ts # adds env.OAUTH_PROVIDER (OAuthHelpers) to the generated Env -│ ├── app.ts # unprotected Hono routes: /healthz, /authorize, pages +│ ├── app.ts # unprotected Hono routes: /healthz, / (landing), /authorize, /connections, /sse (405 pointer) │ ├── auth/ │ │ ├── oauth.ts # provider options: endpoints, scopes, TTLs, CIMD+DCR, error logging │ │ ├── authorize.ts # /authorize GET (render) + POST (CSRF → re-validate → deny | sign in → finish) @@ -432,6 +443,7 @@ mcp/ │ │ └── tools/ # types.ts (ToolDefinition, unwrapUpstream); get-account, get-credit-balance, │ │ # list-models, estimate-cost, jobs (get_job_status, get_jobs_status, list_recent_jobs) │ └── pages/ +│ ├── landing.tsx # / — what this is, how to connect (per-client URLs), link to /connections │ ├── connections.ts # /connections routes: sign-in → KV page session → list/revoke grants │ ├── connections-page.tsx # pure render │ └── ui-session.ts # 15-min page session in KV (`mcpui:session:` prefix), no credential held diff --git a/mcp/README.md b/mcp/README.md index cc961b2b08..17f1992638 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -34,6 +34,18 @@ pnpm deploy:preview # workers.dev, talks to the deployed fake pnpm deploy:production # mcp.getartcraft.com, talks to api.storyteller.ai ``` +## Smoke against a real deployment + +The only thing here that touches a real Artcraft backend. Read-only; uses the demo account. + +```sh +SMOKE_BASE_URL=https://mcp.getartcraft.com SMOKE_USERNAME=… SMOKE_PASSWORD=… pnpm smoke +``` + +Runs after every production deploy and weekly (`.github/workflows/mcp-smoke.yml`) once the +`SMOKE_USERNAME` / `SMOKE_PASSWORD` repo secrets exist. Locally it also works against +`wrangler dev` + the fake (`SMOKE_BASE_URL=http://localhost:8787`, `localdev1` / `localdev1pass`). + ## Layout See `CLAUDE.md` → Layout. diff --git a/mcp/eslint.config.js b/mcp/eslint.config.js index 93838b3bc1..d1b06bd8b6 100644 --- a/mcp/eslint.config.js +++ b/mcp/eslint.config.js @@ -62,7 +62,17 @@ export default tseslint.config( ...tseslint.configs.disableTypeChecked, languageOptions: { ...tseslint.configs.disableTypeChecked.languageOptions, - globals: { console: "readonly", fetch: "readonly", process: "readonly", URL: "readonly" }, + globals: { + AbortController: "readonly", + Buffer: "readonly", + clearTimeout: "readonly", + console: "readonly", + fetch: "readonly", + process: "readonly", + setTimeout: "readonly", + URL: "readonly", + URLSearchParams: "readonly", + }, }, }, { files: ["src/**/*.ts", "src/**/*.tsx"], rules: SERVER_MUST_NOT_IMPORT_FAKE }, diff --git a/mcp/package.json b/mcp/package.json index 957616ede5..48e210df5e 100644 --- a/mcp/package.json +++ b/mcp/package.json @@ -13,6 +13,7 @@ "deploy:preview": "wrangler deploy --env preview", "deploy:production": "wrangler deploy --env production", "gen:api": "node scripts/gen-api.mjs", + "smoke": "node scripts/smoke.mjs", "types": "wrangler types", "typecheck": "pnpm types && tsc --noEmit -p tsconfig.json", "lint": "eslint .", diff --git a/mcp/scripts/smoke.mjs b/mcp/scripts/smoke.mjs new file mode 100644 index 0000000000..06351fadf3 --- /dev/null +++ b/mcp/scripts/smoke.mjs @@ -0,0 +1,241 @@ +// Read-only smoke test against a deployed MCP server — the one place this project touches a +// real Artcraft backend. Drives exactly what a client does: register, consent (password), +// exchange the code, initialize, list tools, call every read-only tool, then revoke the grant. +// +// SMOKE_BASE_URL=https://mcp.getartcraft.com SMOKE_USERNAME=… SMOKE_PASSWORD=… node scripts/smoke.mjs +// +// Exit code 0 on success, 1 on any failure. Prints step outcomes only — never tokens, +// sessions, credentials, or response bodies. + +import { createHash, randomBytes } from "node:crypto"; + +const BASE_URL = (process.env.SMOKE_BASE_URL ?? "https://mcp.getartcraft.com").replace(/\/$/, ""); +const USERNAME = process.env.SMOKE_USERNAME ?? ""; +const PASSWORD = process.env.SMOKE_PASSWORD ?? ""; +const REDIRECT_URI = "https://smoke.invalid/callback"; +const TIMEOUT_MS = 20_000; + +const EXPECTED_TOOLS = [ + "estimate_cost", + "get_account", + "get_credit_balance", + "get_job_status", + "get_jobs_status", + "list_models", + "list_recent_jobs", +]; + +async function main() { + if (!USERNAME || !PASSWORD) fail("SMOKE_USERNAME and SMOKE_PASSWORD must be set"); + step(`target ${BASE_URL}`); + + const health = await get("/healthz"); + check(health.status === 200, "healthz answers 200"); + + const prm = await getJson("/.well-known/oauth-protected-resource/mcp"); + check(prm.resource === `${BASE_URL}/mcp`, "protected resource metadata names this endpoint"); + const as = await getJson("/.well-known/oauth-authorization-server"); + check(as.client_id_metadata_document_supported === true, "CIMD is advertised"); + + const client = await postJson("/register", { + client_name: "artcraft-mcp smoke", + redirect_uris: [REDIRECT_URI], + token_endpoint_auth_method: "none", + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + }); + check(typeof client.client_id === "string", "dynamic client registration"); + + const verifier = base64Url(randomBytes(32)); + const challenge = base64Url(createHash("sha256").update(verifier).digest()); + const authQuery = new URLSearchParams({ + response_type: "code", + client_id: client.client_id, + redirect_uri: REDIRECT_URI, + scope: "read:account read:jobs read:catalog", + state: "smoke", + code_challenge: challenge, + code_challenge_method: "S256", + resource: `${BASE_URL}/mcp`, + }).toString(); + + const page = await fetchWithTimeout(`${BASE_URL}/authorize?${authQuery}`); + check(page.status === 200, "consent page renders"); + const html = await page.text(); + const csrf = /name="csrf" value="([^"]+)"/.exec(html)?.[1] ?? ""; + const csrfCookie = + /artcraft_consent=([^;]+)/.exec(page.headers.get("set-cookie") ?? "")?.[1] ?? ""; + check(csrf.length === 43 && csrf === csrfCookie, "CSRF token issued"); + + const consent = await fetchWithTimeout(`${BASE_URL}/authorize`, { + method: "POST", + redirect: "manual", + headers: { + "content-type": "application/x-www-form-urlencoded", + cookie: `artcraft_consent=${csrfCookie}`, + }, + body: new URLSearchParams({ + auth_request: authQuery, + csrf, + method: "password", + action: "allow", + username_or_email: USERNAME, + password: PASSWORD, + }).toString(), + }); + check(consent.status === 302, `sign-in + consent redirects (got ${consent.status})`); + const location = new URL(consent.headers.get("location") ?? ""); + const code = location.searchParams.get("code") ?? ""; + check( + code.length > 0 && location.searchParams.get("state") === "smoke", + "authorization code issued", + ); + + const tokens = await postForm("/token", { + grant_type: "authorization_code", + code, + redirect_uri: REDIRECT_URI, + client_id: client.client_id, + code_verifier: verifier, + resource: `${BASE_URL}/mcp`, + }); + check(typeof tokens.access_token === "string" && tokens.expires_in === 3600, "token exchange"); + const bearer = tokens.access_token; + + const init = await rpc(bearer, 1, "initialize", { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { name: "artcraft-mcp-smoke", version: "0" }, + }); + check(init.result?.serverInfo?.name === "artcraft", "MCP initialize"); + + const list = await rpc(bearer, 2, "tools/list", {}); + const names = (list.result?.tools ?? []).map((t) => t.name).sort(); + check( + JSON.stringify(names) === JSON.stringify(EXPECTED_TOOLS), + `tools/list = ${names.join(", ")}`, + ); + + await tool(bearer, 3, "get_account", {}, (s) => typeof s.username === "string"); + await tool(bearer, 4, "get_credit_balance", {}, (s) => Number.isInteger(s.total_credits)); + await tool( + bearer, + 5, + "list_models", + { kind: "image" }, + (s) => Array.isArray(s.models) && s.models.length > 0, + ); + await tool( + bearer, + 6, + "estimate_cost", + { kind: "image", model: "flux_1_schnell" }, + (s) => "cost_in_credits" in s, + ); + await tool(bearer, 7, "list_recent_jobs", {}, (s) => Array.isArray(s.jobs)); + await tool(bearer, 8, "get_jobs_status", { job_tokens: ["jinf_smoke_does_not_exist"] }, (s) => + Array.isArray(s.jobs), + ); + + const revoke = await fetchWithTimeout(new URL(as.revocation_endpoint, BASE_URL).href, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ token: bearer, client_id: client.client_id }).toString(), + }); + check(revoke.status === 200, "grant revoked"); + const after = await fetchWithTimeout(`${BASE_URL}/mcp`, { + method: "POST", + headers: { authorization: `Bearer ${bearer}` }, + }); + check(after.status === 401, "revoked token is refused"); + + console.log("smoke: OK"); +} + +async function tool(bearer, id, name, args, predicate) { + const response = await rpc(bearer, id, "tools/call", { name, arguments: args }); + const result = response.result; + const ok = + result && !result.isError && result.structuredContent && predicate(result.structuredContent); + check(ok, `tools/call ${name}`); +} + +async function rpc(bearer, id, method, params) { + const response = await fetchWithTimeout(`${BASE_URL}/mcp`, { + method: "POST", + headers: { + authorization: `Bearer ${bearer}`, + "content-type": "application/json", + accept: "application/json, text/event-stream", + }, + body: JSON.stringify({ jsonrpc: "2.0", id, method, params }), + }); + check(response.status === 200, `${method} answers 200 (got ${response.status})`); + return response.json(); +} + +async function get(path) { + return fetchWithTimeout(`${BASE_URL}${path}`); +} + +async function getJson(path) { + const response = await get(path); + check(response.status === 200, `GET ${path}`); + return response.json(); +} + +async function postJson(path, body) { + const response = await fetchWithTimeout(`${BASE_URL}${path}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + check( + response.status === 201 || response.status === 200, + `POST ${path} (got ${response.status})`, + ); + return response.json(); +} + +async function postForm(path, fields) { + const response = await fetchWithTimeout(`${BASE_URL}${path}`, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams(fields).toString(), + }); + check(response.status === 200, `POST ${path} (got ${response.status})`); + return response.json(); +} + +async function fetchWithTimeout(url, init = {}) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), TIMEOUT_MS); + try { + return await fetch(url, { ...init, signal: controller.signal }); + } finally { + clearTimeout(timer); + } +} + +function check(condition, description) { + if (!condition) fail(description); + step(description); +} + +function step(message) { + console.log(`smoke: ${message}`); +} + +function fail(message) { + console.error(`smoke: FAILED — ${message}`); + process.exit(1); +} + +function base64Url(bytes) { + return Buffer.from(bytes).toString("base64url"); +} + +main().catch((error) => { + // Error messages could carry URLs but never credentials; bodies are not logged. + fail(error instanceof Error ? error.message : String(error)); +});