mcp: demo-account smoke script, weekly smoke workflow, post-deploy smoke

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
This commit is contained in:
Tanish Manku committed 2026-08-25 00:01:52 -07:00
1 parent d62dec5a04
commit e0729b19e5
7 files changed
+331 -4

No files matched your search

+43
View File
@@ -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 }}
+8
View File
@@ -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 }}
+15 -3
View File
@@ -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
+12
View File
@@ -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.
+11 -1
View File
@@ -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 },
+1
View File
@@ -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 .",
+241
View File
@@ -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));
});