mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-09 03:21:23 +00:00
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:
1 parent
d62dec5a04
commit
e0729b19e5
7 files changed
+331
-4
No files matched your search
@@ -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 }}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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 },
|
||||
|
||||
@@ -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 .",
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
Reference in new issue
Block a user