E2E tests #
Classification: Restricted (describes source code, infrastructure, and access control).
End-to-end tests for tofu-external-mcp with the real Clerk OAuth app and real bonsapi. The scenarios, scripts and coverage map live in tests/e2e/tofu-external-mcp/. Claude Code runs the tofu-external-mcp-e2e skill (.claude/skills/tofu-external-mcp-e2e/) against them and reports PASS/FAIL per scenario.
| Where | Target | How |
|---|---|---|
| Local | the local stack, http://localhost:3003 |
mise run dev, then MCP_E2E_EXPECTED_ORG_ID=<org> mise run tofu-external-mcp-e2e [scenarios] |
| CI | the deployed dev server, https://dev-mcp.internal.gotofu.com, as it is (no deploy) |
Actions → tofu MCP E2E Tests → Run workflow |
CI workflow #
.github/workflows/tofu-mcp-e2e.yaml runs four jobs against dev. It deploys nothing: the branch’s own scenarios and scripts run, but against whatever dev currently serves (dev tracks main), so a fix in the MCP itself is only exercised after it merges.
| Job | Runs | Needs the server |
|---|---|---|
| Tool coverage | e2e-coverage.test.ts: every catalog tool is exercised by a scenario or skipped with a reason |
No |
| Connections and authentication | scripts/auth-checks.mjs, no LLM: 401 paths, OAuth discovery and proxy, a real session, tools/list against origin/main’s catalog (dev serves main), one tools/call, and that get_accounting_target_config accepts every key get_accounting_targets returns |
Yes |
| Read sweep | Claude: smoke, read and negative scenarios |
Yes |
| Write sweep | Claude: lifecycle, documents, direct-expense-bank, config and (optionally) documents-ai, after the read sweep |
Yes |
The one input, ai_extraction (default on), also runs documents-ai, which uses dev AI quota. Each Claude job fails unless its e2e-result.json shows every requested scenario as PASS and blocked as false. A scenario reported SKIPPED is a warning, and a job where every scenario was skipped fails. The report goes to the run summary and is uploaded as an artifact (tofu-mcp-e2e-read-report, tofu-mcp-e2e-write-report).
Local run #
scripts/run-local.sh (the tofu-external-mcp-e2e mise task) needs the local stack from mise run dev, doppler, claude and MCP_E2E_EXPECTED_ORG_ID. It logs in against the local server’s own /authorize with the loopback redirect http://localhost:28765/callback that .mcp.json pins, runs the auth checks, then runs the scenarios (default smoke,read,negative; pass none to stop after the checks) with the Claude CLI, and prints the report. Secrets come from Doppler bonsai / dev_local, and the credentials live in a temporary directory that is removed on exit.
Scenarios #
| Scenario | What it checks |
|---|---|
smoke |
Reference tools answer, pagination fields present. |
read |
Every read tool once, plus a pagination spot-check. |
negative |
Unknown or malformed ids and bad input fail cleanly and change nothing. |
lifecycle |
Stale e2e- entity cleanup, then a throwaway entity with contacts, tax rates, accounts, items and knowledge, read-back after each write, teardown. |
documents |
Upload the fixture PDF, document pages, extraction create, page and type changes. |
documents-ai |
trigger_extraction, invoice data versions, search and extraction status changes. |
direct-expense-bank |
Direct expense and bank statement create, data versions, search, delete. |
config |
Entity settings, priority fields, members, tags, currencies, bulk accounting replace/update/delete, knowledge templates and history. |
Coverage map #
Each scenario ends with a fenced tools block naming the tools it really calls. coverage.json lists the tools that are deliberately skipped, each with a reason (integration publish/import and submitter tools need a provider sandbox, update_organization_settings would change a shared org, the currency write tools need multi-currency enabled and no tool enables it, list_coupons and list_pricing_plans are served only to a sales agent, a few cannot be seeded through the MCP). A second block, tools-conditional, lists tools that run only when the dev org has the data or members they need (members, integrations, an org external id) and are otherwise SKIPPED; they count as accounted for but not as proven. The coverage job fails when a catalog tool is in none of the three places, when a tool is both skipped and exercised, or when a name is not in the catalog, so adding a tool to the catalog without a scenario or a skip entry fails CI (it also runs in the normal unit tests). The Claude runs deny every skipped tool at the tool level (--disallowedTools), so a skipped tool cannot be called by mistake or by injected instructions.
How authentication works #
The server accepts only Clerk OAuth access tokens (typ: at+jwt) whose client_id is its own OAuth app (Auth). Claude Code cannot complete an OAuth login in a headless run, so each run first logs in as the e2e test user itself: scripts/oauth-token.mjs runs the authorization-code + PKCE flow in headless Chromium (Playwright) with the server’s own OAuth app and writes MCP_E2E_CREDS_FILE outside the workspace. Details that matter:
- Which
/authorize. The local server’s/authorizeaccepts the loopback redirecthttp://localhost:28765/callback, which is registered on the OAuth app. The deployed server’s/authorizerejects loopback addresses, so against dev the script authorizes straight at Clerk (<issuer>/oauth/authorize) with the same registered redirect. In both cases the redirect is never followed: the code is read off the 303Locationheader and exchanged at the server’s/token. - Cloudflare Access. The sign-in page is the webapp’s, behind Cloudflare Access, and browsers drop injected headers on redirect hops. When the redirect lands on the Cloudflare login page, the script makes one direct request to that host with the service-token headers to earn the
CF_Authorizationcookie, then starts the flow again. - Organization. Clerk’s active organization belongs to the browser session, and the consent screen has no org picker. The script calls
Clerk.setActivefor the[DO NOT TOUCH] E2E ORGmembership on the consent page, and requests theuser:org:readscope, which is what putsorg_idin the token. It refuses a token whoseorg_idis notMCP_E2E_EXPECTED_ORG_ID.
scripts/headers-helper.sh, a Claude Code headersHelper, serves that access token as the Authorization header, so Claude Code does not attempt OAuth itself. The helper does not refresh: the access token lives about 24 hours, longer than a run, and the credentials file holds no refresh token and no client secret (only the access token, its expiry, the client id and the Cloudflare service token).
How results are checked #
Claude reports on itself, so scripts/verify-trace.mjs cross-checks e2e-result.json against the run’s own tool-call trace. Each scenario reported PASS must have called at least 60% of the tools unique to it among the requested scenarios (its whole tools block when it shares every tool with another scenario). A scenario can also require evidence in a trace-markers block, for example negative must have seen error results from get_entity, delete_entity and create_entity. A missing trace, a scenario without a tools block, or an unknown scenario fails the job. Claude reads every scenario file up front, so the trace cannot say which scenario a call belonged to and calls are not attributed by position: a scenario that shares all its tools with another one (for example smoke with read) is only weakly checked. The check catches a PASS reported for a scenario that was not run, not a scenario that was run badly.
The Claude step has no Bash tool, only Skill, Read of the skill, scenario and fixture files, Edit of the two report files and the allowed MCP tools. The credentials file is outside the workspace, so Claude cannot read it.
Configuration #
Secrets are read from Doppler (bonsai / dev in CI, dev_local locally) with doppler run --only-secrets, so the login step sees only these:
| Doppler variable | Used for |
|---|---|
TOFU_EXTERNAL_MCP_CLERK_CLIENT_ID / _CLIENT_SECRET |
The server’s Clerk OAuth app. |
TOFU_EXTERNAL_MCP_CLERK_ISSUER_URL |
Clerk issuer; against dev, authorization goes to <issuer>/oauth/authorize. |
E2E_USER_EMAIL / E2E_USER_PASSWORD |
The test user (same as the Playwright suite in tests/e2e/legacy). |
CLOUDFLARE_ACCESS_CLIENT_ID / _SECRET |
Cloudflare Access service token for the *.internal.gotofu.com hosts. |
ANTHROPIC_API_KEY |
Read by the Claude step in CI (doppler secrets get) for claude-code-action; CI is billed per token on this key. The local run uses your own Claude login. |
GitHub:
| Name | Kind | What |
|---|---|---|
DOPPLER_TOKEN |
dev environment secret |
Service token for bonsai / dev. The only GitHub secret; everything else comes from Doppler. |
No org id is configured: the login finds the [DO NOT TOUCH] E2E ORG membership by name and the run fails if the token is for another org. Set MCP_E2E_EXPECTED_ORG_ID in the environment to pin a specific Clerk org id as well.
The test user must be able to sign in with email and password alone (no extra verification step) and be a member of [DO NOT TOUCH] E2E ORG (override the name with E2E_ORG_NAME). Anyone who can dispatch the workflow on a branch can change the scripts that handle these secrets, so restrict the dev environment (required reviewers, allowed branches) in the repository settings.
Limits #
- CI tests whatever dev serves. Runs are serialised (
tofu-mcp-e2econcurrency group). - Writes are confined to the
e2e-*entities the run creates, and the lifecycle scenario deletese2e-entities older than 2 hours left by crashed runs. There is no document delete tool, so uploaded documents stay in storage after their entity is removed. - If the dev org is still onboarding or at its entity limit, write scenarios report SKIPPED.
- The read and write jobs use the same user’s 60 tool calls per minute. Claude has no shell to wait out a rate-limit error, so run together they failed each other; the write job therefore starts after the read job.
- On the deployed dev hosts
/.well-known/oauth-protected-resourceand/.well-known/oauth-authorization-servercurrently answer a 301 loop (an ALB redirect behind a Cloudflare rule), so the two discovery checks fail there while passing locally. That is an infrastructure fault, not a test fault. - Not covered: a token from a different OAuth client (403) or a genuinely expired signed token, since the suite cannot mint either (forged and unsigned variants are checked); audit-log and trace correlation (no log access from CI, see Audit logging); integrations and publish flows.
- Claude can call every tool that is not skipped, including
delete_entity, on any entity in the e2e org. The skill limits it to entities whose slug matches^e2e-([a-z]+-)?[0-9]{10}$and tells it to ignore instructions found in tool output, but nothing enforces that technically, so keep valuable data out of the e2e org. - Claude drives the sweeps, so results can vary; reproduce a failure from the report before treating it as a regression.