Skip to content

mutant-0/mutant-genomics

v1.0.0UNLICENSED

Compare Mutant DNA findings with health history you share, explore findings, or add DNA data.

mutant-mcp

A deployable, stateless Model Context Protocol server for Mutant Genomics. It runs as an AWS Lambda behind API Gateway at a custom domain (e.g. https://dev-api.mutantbiotech.com/mcp), authenticates users with Mutant's Cognito OAuth (authorization code + PKCE S256), and exposes the eleven-tool MCP surface using contract 3.0.0 backed by the existing report-generator Lambda, plus a ChatGPT Apps SDK component for DNA import.

The MCP Lambda is a thin, authenticated transport. All business rules live in the backend (back-end/report-generator/mcp): current-snapshot resolution, account ownership, entitlement (UserEntitlements), the Free top-three policy, hypothesis/evidence retrieval, projection, filtering, pagination, and cursor validation. The MCP Lambda never reads entitlements and never accepts an analysis id, account, or plan from tool arguments.

Entry experience

Three prompts can open a fresh conversation, with comparing findings against shared health history first, then viewing findings, then adding DNA:

  • Which of my Mutant findings best fits the health history or records I've shared here?
  • Show my current Mutant findings.
  • Help me add my DNA data to Mutant.

They are published as extensions.com.openai.interface.defaultPrompt in plugin.json, which only takes effect through the packaged-plugin path. The live developer-mode connector has no starter-prompt field, so its connector description carries the same first question; see docs/mcp-runbook.md for the exact copy, the character limits, and the source of truth. Every entry prompt calls get_analysis_status first and then follows the reported experience_state. The ready card renders the comparison action as its visually primary action and uses only the health history actually shared in the conversation; the observed model-selected routing is captured in tests/golden-prompt-routing-traces.json.

DNA import

show_dna_import renders a self-contained Apps SDK component that parses the user's raw DNA file locally in the iframe. Only the variants present in the Mutant catalog ever leave the browser, are submitted through create_report, and are persisted by the backend. The raw file is never uploaded.

The component owns the entire asynchronous lifecycle. It reads poll_analysis_status on mount (so a rerender or a reopened panel resumes an in-flight analysis instead of starting a new one), polls that app-only tool itself after create_report until the analysis is ready or failed, shows an elapsed timer rather than a countdown or a simulated percentage, and transforms the same card in place into the completion view. The user never has to ask ChatGPT whether processing finished. Because poll_analysis_status is advertised with app visibility only, the model cannot see or call it, so it cannot start its own polling loop or duplicate the card's narration. show_dna_import returns only { ui_rendered: true, mode } (plus widget-only _meta.mutant.mode), so there is no stale status for the model to narrate.

show_analysis_overview opens the same component under analysis.read for a ready analysis, bound to one immutable snapshot: it calls the internal resolve_analysis_snapshot operation and returns the exact displayed_analysis_version plus the visible hypothesis identities, which the card renders and passes back on follow-ups. The completion view also renders state-aware suggested_prompts as chips (from get_analysis_context), and — when experience_state is READY_REFRESH_AVAILABLE — an optional refresh banner. That status result carries the UI descriptor so the card opens directly; its refresh button starts DNA resubmission inside the card. A required refresh (PROCESSING_FAILED) is handled by the recovery card instead.

Two scopes gate the surface: the seven analysis tools require analysis.read, and show_dna_import / get_snp_catalog / create_report require dna.import. Because importing DNA creates user data, a read-only grant can never trigger it. A dna.import-only grant still imports, but the completion card explains that ChatGPT reports the result because the panel cannot read the status itself.

Architecture

ChatGPT connector
    20|   |  OAuth 2.1 PKCE S256 + Bearer token (analysis.read + dna.import)
   |  (PRM + AS discovery served by this Lambda)
   v
API Gateway HTTP API  $default (catch-all)
   |
   v
Mutant MCP Lambda (Node HTTP server on :8080)
   |-- OAuth discovery + token validation (jose / OIDC JWKS)
   |-- Eleven MCP tools (schemas, envelope, deterministic content, per-tool scopes)
   |-- Apps SDK resource ui://mutant/dna-import/v1.html
   `-- Versioned internal contract 3.0.0 (direct InvokeCommand, IAM-scoped)
          |
          v
Report-generator Lambda  (mcp package)
    35|   |-- Snapshot + entitlement resolution
   |-- Free top-three / Full scope
   |-- v3 projection, pagination, cursors
   |-- get_snp_catalog / create_report (DNA import)
   `-- ToolResponse envelope (ok/data/error, analysis_version, next_cursor)

The DNA import component runs entirely inside the host iframe and declares an empty CSP (no connectDomains, no resourceDomains): it has no document origin to load assets from and reaches the server only through the host bridge (tools/call). It reads the raw DNA file in a Web Worker started from a blob: URL, falling back to the main thread when the host blocks that worker — which is possible precisely because _meta.ui.csp has no worker-src directive.

Layout

mutant-mcp/
├── src/
│   ├── handler.ts                 # HTTP server entry (Lambda Web Adapter :8080)
│   ├── http-handler.ts            # routing, OAuth discovery, 401 challenges
│   ├── server.ts                  # McpServer + instructions + UI resource
│   ├── logger.ts                  # pino with genotype redaction
│   ├── config.ts                  # env loading/validation (Zod)
│   ├── contract.ts                # contract 3.0.0 constants + types
│   ├── auth/
│   │   ├── token-validator.ts     # JWT/JWKS + client/scope/resource checks
│   │   ├── oauth-metadata.ts      # RFC 9728 PRM + AS metadata mirror
│   │   └── user-context.ts        # MutantUserContext (userId + scopes)
│   ├── tools/                     # eleven tool definitions + registration
│   │   └── scope-guard.ts         # per-tool scope enforcement
│   ├── schemas/                   # Zod input schemas + per-tool typed output schemas
│   ├── presentation/              # deterministic content builders + prompts
│   ├── clients/
│   │   └── mutant-lambda-client.ts# versioned internal contract + envelope parse
│   ├── ui/
│   │   ├── dna-import/
│   │   │   ├── main.tsx           # browser entry: mounts the component
│   │   │   ├── app.tsx            # Apps SDK component (React)
│   │   │   ├── parseFile.js       # worker-preferred parse entry + fallback
│   │   │   ├── parseCore.js       # shared parse core (both entries call it)
│   │   │   ├── workerEntry.js     # parser worker script (bundled separately)
│   │   │   ├── worker-protocol.js # worker <-> client message types
│   │   │   ├── resource.ts        # registers ui://mutant/dna-import/v1.html
│   │   │   └── generated/html.ts  # GENERATED: bundled component document
│   │   ├── genomics/              # GENERATED: vendored portal DNA processor
│   │   └── api.js                 # GENERATED: portal-free fetchSnpCatalog shim
│   └── responses/
│       ├── errors.ts              # JSON-RPC + WWW-Authenticate challenges
│       └── tool-result.ts         # envelope -> CallToolResult + deterministic content
├── scripts/
│   ├── build-ui.mjs               # esbuild -> generated/html.ts
│   └── sync-genomics.mjs          # vendor front-end-web/src/genomics (+ --check)
├── plugin.json                    # packaged-plugin interface copy + entry prompts
├── docs/
│   ├── mcp-contract.md            # implemented schemas, semantics, error codes
│   └── mcp-runbook.md             # Cognito/OAuth setup, linking, monitoring
├── tests/                         # Vitest
├── infrastructure/                # AWS CDK v2 (TypeScript)
├── Dockerfile
├── package.json
└── tsconfig.json

Tool catalog

ToolScopePurpose
get_analysis_statusanalysis.readRouting gate: dna_status, the canonical experience_state, active_analysis/pending_analysis, entitlement/capabilities, and the single next_action. Model-facing; in the two no-usable-analysis processing states it returns one short sentence and no suggested prompts.
poll_analysis_statusanalysis.readApp-only status channel (app visibility) used by the DNA import component while it owns the processing experience. Same payload as get_analysis_status, without the polling next_action or suggested prompts.
show_analysis_overviewanalysis.readResolves one immutable analysis snapshot (resolve_analysis_snapshot) and renders the ready-analysis card bound to that revision. No status echoed.
get_analysis_contextanalysis.readThe versioned interpretation contract, coverage, access scope, a compact hypothesis preview, and suggested prompts for specific questions.
list_health_hypothesesanalysis.readList/search hypotheses (items + next_cursor; Free: fixed top three, Full: whole set).
explain_health_hypothesisanalysis.readExplanation-ready projection: scores, why-ranked, contributing patterns, clinical context, confirmation plan, guardrails.
get_supporting_evidenceanalysis.readStored patterns, deduped variant contributions, cited sources, or full test guidance (kind: "tests").
get_genetic_contextanalysis.readMarkers aggregated by rsID with pattern memberships (Free) or module/gene/rsIDs (Full); optional modules.
show_dna_importdna.importRenders the DNA import component, which owns import, submission, polling, and the completion UI. No backend call, no echoed status.
get_snp_catalogdna.importReturns the SNP catalog to the component (app visibility only).
create_reportdna.importCreates an analysis from locally processed variants (app visibility only).

The same tool definitions are exposed to Free and Full accounts; access limits are enforced in the backend and returned as structured errors (PLAN_REQUIRED, SCOPE_REQUIRED). Calling a tool without its scope returns INSUFFICIENT_SCOPE with the missing scope, which makes ChatGPT re-consent instead of failing opaquely.

create_report also accepts an optional, request-only analysis_context (sex_chromosome_pattern / sex_chromosome_confidence), derived locally from the raw file and sent only for a high-confidence XX/XY detection. The backend uses it in memory to evaluate sex-specific perfect-storm conditions and never persists, caches, queues, logs, traces, or echoes it; it is excluded from every log record.

Observability

The registration wrapper writes one tool-call audit record per call (event: "tool_call", see src/tools/audit.ts): the tool name, the request id and account, the argument names received, the audited argument values, the outcome (ok / error / scope_denied), and the duration. Because it lives in the wrapper, a new tool is audited by construction.

Values are limited to what a routing trace needs: genotypes (snps, wgs_variant_calls), upload_meta, analysis_context, and rsID lists are recorded by key only, and a list_health_hypotheses query is written as [redacted] unless it looks like the catalog keyword the tool description asks for. That keeps the audit trail out of the genotype-and-health-data class the logger redacts, and makes a withheld query a visible routing finding. The routing evaluation reads these records through npm run record:trace.

The shared DNA processor

src/ui/genomics/* is generated: scripts/sync-genomics.mjs copies it verbatim from front-end-web/src/genomics/, so the portal and the ChatGPT component parse 23andMe / Ancestry / VCF / gzipped VCF input with the same code. The vendored set is deliberately nine modules — the loader and the format parsers, catalog.js (indexes only), sexChromosome.js (parse.js imports it), and stream.js. The portal's own parseInWorker.js / parse.worker.js are not vendored: they fetch a catalog over the portal's HTTP session and return a different result shape, whereas this component injects the catalog get_snp_catalog gave it and runs everything through workerEntry.js. npm run check:genomics fails the build if a vendored module was edited by hand or drifted from upstream, and tests/genomics-parity.test.ts pins the same behaviour on golden fixtures. Never edit those files directly — change the upstream module and re-run npm run sync:genomics.

Worker parsing in the DNA import component

tests/parse-parity.test.ts is the guard on the one rule that matters here: the worker path and the main-thread fallback must be the same parser. parseFile.js calls parseDnaFileCore with cooperative: false inside the worker and with cooperative: true on the main thread, and the two results must be identical.

SituationBehaviour
Host allows the blob: workerWorker runs workerEntry.js; object URL revoked after the handshake, worker terminated on completion or abort
Host blocks it, no Worker, or the file cannot be clonedSame parser on the main thread, yielding roughly every 24 ms so the iframe keeps painting
Worker dies before producing anythingSilent fallback — nothing has been parsed, so no progress is lost
Worker dies after producing outputSurfaced as an error; re-parsing would restart progress from zero and hide the cause

Readiness is a handshake, not new Worker() succeeding: the worker posts {type:"ready"} when its script starts executing, and the client waits at most 1.5 s for that message. A blob: script blocked by the host's composed script-src constructs fine and then never runs, which is exactly the case the timeout catches.

Local development

Dev mode accepts dev-free, dev-paid, or dev bearer tokens instead of a live OIDC provider:

npm install
MUTANT_DEV_MODE=true npm run dev

The server listens on port 8080.

Smoke test

MUTANT_DEV_MODE=true npm run dev
# in another terminal
npx @modelcontextprotocol/inspector

Connect to http://localhost:8080/mcp with Bearer dev-paid or Bearer dev-free. All eleven tools are discoverable. The dev-* tokens carry different scopes so the per-tool authorization boundary can be exercised locally:

TokenScopes granted
dev, dev-free, dev-paidanalysis.read + dna.import
dev-readonlyanalysis.read only — the DNA tools return INSUFFICIENT_SCOPE
dev-dnadna.import only — the analysis tools return INSUFFICIENT_SCOPE

See docs/mcp-runbook.md for linking the real ChatGPT dev connector.

Testing

npm test              # builds the UI, then vitest run
npm run typecheck     # tsc --noEmit
npm run lint          # eslint
npm run build:ui      # esbuild -> src/ui/dna-import/generated/html.ts
npm run check:genomics# fails on any drift from front-end-web/src/genomics
npm run record:trace  # writes a routing trace from a captured tool-call log

The entry-prompt routing evaluation replays observed, recorded tool traces rather than hand-authored sequences. It runs as part of npm test; set the release gate once a real ChatGPT capture has replaced the fixtures:

GOLDEN_TRACES_REQUIRED=1 npx vitest run tests/golden-prompt-routing.test.ts

Record a trace from a deployment log instead of transcribing it by hand: every tool call writes one event: "tool_call" audit record (src/tools/audit.ts). The capture procedure is in docs/mcp-runbook.md, including what the audit log withholds.

build:ui runs two esbuild passes: the parser worker (workerEntry.js) as its own IIFE, then the component (main.tsx) with that script inlined as a string. The resulting document is ~650 KB, of which ~14 KB is the worker — it has to be one self-contained file, because an MCP Apps resource is served inline and has no origin to fetch sibling assets from.

Windows note: Vitest can fail to find its runner when the working directory uses a different drive-letter case than Node's canonical path (cd /d c:\... vs C:\...). Run the commands from PowerShell (or a canonical C:\... path). CI runs on Linux and is unaffected.

Environment variables

VariablePurpose
MUTANT_SERVICE_LAMBDA_ARNreport-generator Lambda alias invoked for all tools. Empty uses a mock client.
MUTANT_OAUTH_ISSUEROIDC issuer / Cognito user-pool URL.
MUTANT_OAUTH_AUDIENCEOptional. Expected aud claim; leave empty for Cognito without a resource server.
MUTANT_OAUTH_CLIENT_IDPredefined Cognito app client authorized for the ChatGPT redirect URI.
MUTANT_OAUTH_SCOPERequired access-token scope for the seven analysis tools. Empty (default) derives <MUTANT_MCP_RESOURCE_URI>/analysis.read, e.g. https://dev-api.mutantbiotech.com/mcp/analysis.read. Set explicitly only to override.
MUTANT_OAUTH_SCOPE_DNA_IMPORTScope required by show_dna_import, get_snp_catalog, and create_report. Empty (default) derives <MUTANT_MCP_RESOURCE_URI>/dna.import.
MUTANT_MCP_RESOURCE_URICanonical RFC 9728 resource id (used in PRM + challenges, and as the scope's resource-server identifier).
MUTANT_CORS_ORIGINSComma-separated browser origin allowlist.
MUTANT_PLAN_INFO_URLInformational plan page for the Free-plan notice's learn-more link (default /plans; must be an https URL on mutantgenomics.com).
MUTANT_ONBOARDING_URLOnboarding URL.
MUTANT_OPENAI_DOMAIN_VERIFICATION_TOKENOpenAI Apps SDK domain-verification token. Unset (default) makes the challenge paths 404; set it to serve the token verbatim as unauthenticated plain text on GET /.well-known/openai-apps-challenge.
MUTANT_REQUEST_TIMEOUT_MSBackend invocation deadline (default 20000).
MUTANT_MAX_RESPONSE_BYTESSerialized response cap (default 512000).
MUTANT_SNP_CATALOG_MAX_BYTESPer-tool response cap for get_snp_catalog (default 2000000).
MUTANT_MAX_REQUEST_BYTESSerialized request cap for create_report (default 5242880, below the 6 MiB synchronous lambda:InvokeFunction limit).
MUTANT_DEV_MODEtrue accepts dev-free / dev-paid tokens.
LOG_LEVELpino log level.
PORTHTTP port (default 8080).

Build & deploy

npm run bundle            # esbuild -> dist/index.mjs
docker build -t mutant-mcp .
docker run --rm -p 8080:8080 -e MUTANT_DEV_MODE=true mutant-mcp

npm run cdk:synth
npm run cdk:deploy

The CDK stack provisions:

  • a Docker-based Lambda named mutant-mcp-<env> (Lambda Web Adapter, buffered invoke mode) logging to the conventional /aws/lambda/mutant-mcp-<env> group;
  • IAM scoped to lambda:InvokeFunction on the specific report-generator alias (the MCP role intentionally has no UserEntitlements read permission);
  • an API Gateway HTTP API with a $default catch-all route;
  • two custom-domain mappings to an existing API Gateway custom domain when one is configured: the MCP mount (MUTANT_API_MAPPING_KEY, e.g. /mcp) and .well-known, which serves OAuth discovery at the host root so RFC 8414 / 9728 clients find it at the issuer origin;
  • error and latency CloudWatch alarms.

MUTANT_MCP_RESOURCE_URI defaults to https://<domain>/<apiMappingKey|mcp> when a domain is configured. That host's origin is advertised as the authorization server: the authorization-server metadata uses it as issuer (the origin actually serving the document), and protected-resource metadata lists it in authorization_servers. The authorization_endpoint / token_endpoint still point at Cognito's custom domain, and token iss claims are still validated against MUTANT_OAUTH_ISSUER.

CI/CD (GitHub Actions)

The workflow lives at the repo root (../.github/workflows/deploy.yml). It runs on push to main touching mutant-mcp/** (targeting dev) and on manual workflow_dispatch (dev / staging / prod):

  1. test job: npm ci, typecheck, lint, npm run build:ui, npm run check:genomics, vitest.
  2. build-and-deploy job: OIDC to AWS, CDK bootstrap, cdk deploy.

Required GitHub secrets: AWS_ROLE_ARN, MUTANT_SERVICE_LAMBDA_ARN, MUTANT_OAUTH_ISSUER, MUTANT_OAUTH_AUDIENCE, MUTANT_OAUTH_CLIENT_ID, MUTANT_DEV_MODE, MUTANT_DOMAIN_NAME, MUTANT_API_MAPPING_KEY.

Required GitHub variables: MUTANT_MCP_RESOURCE_URI, MUTANT_CORS_ORIGINS, MUTANT_PLAN_INFO_URL, MUTANT_REQUEST_TIMEOUT_MS, MUTANT_MAX_RESPONSE_BYTES (optional: MUTANT_SNP_CATALOG_MAX_BYTES, MUTANT_MAX_REQUEST_BYTES). MUTANT_OAUTH_SCOPE and MUTANT_OAUTH_SCOPE_DNA_IMPORT are optional: leave them unset (or blank) to derive <MUTANT_MCP_RESOURCE_URI>/analysis.read and <MUTANT_MCP_RESOURCE_URI>/dna.import; if either exists in the environment's variables, it must contain the full scope (e.g. https://dev-api.mutantbiotech.com/mcp/analysis.read) or it will win over the derived default.

Documentation