From c9f471100f633ac691c5e6eb673f4b5429d90d79 Mon Sep 17 00:00:00 2001 From: Alex Suarez Date: Sun, 23 Aug 2026 18:27:10 -0400 Subject: [PATCH] feat(RFC-090): official stdio MCP server Thin TypeScript MCP package wrapping existing gateway routes: infer, dry-run validate, deploy, quarantine, list. No validation-engine changes. Stdio only. Auth via CONTRACTGATE_API_KEY. Docs at docs/mcp-reference.md, served raw at /mcp-reference.md. Drift gate covers cited routes. --- .github/workflows/ci.yml | 34 ++ .gitignore | 3 + README.md | 18 + dashboard/app/docs/page.tsx | 12 + dashboard/app/layout.tsx | 1 + dashboard/next.config.ts | 2 +- dashboard/proxy.ts | 1 + dashboard/scripts/sync-llm-docs.mjs | 4 +- docs/STATUS.md | 3 + docs/agent-rules/AGENT.md | 4 + docs/agent-rules/README.md | 8 +- docs/agent-rules/copilot-instructions.md | 2 + docs/agent-rules/cursor.mdc | 5 +- docs/agent-rules/windsurf.md | 6 +- docs/llm-integration.md | 5 + docs/llms.txt | 1 + docs/mcp-reference.md | 139 ++++++ docs/rfcs/090-mcp-server.md | 98 ++++ mcp/.gitignore | 2 + mcp/README.md | 28 ++ mcp/package-lock.json | 611 +++++++++++++++++++++++ mcp/package.json | 43 ++ mcp/src/gateway.ts | 132 +++++ mcp/src/index.ts | 6 + mcp/src/server.ts | 195 ++++++++ mcp/src/tools.ts | 84 ++++ mcp/test/gateway.test.ts | 213 ++++++++ mcp/tsconfig.json | 15 + tests/llm_docs_test.rs | 23 +- 29 files changed, 1681 insertions(+), 17 deletions(-) create mode 100644 docs/mcp-reference.md create mode 100644 docs/rfcs/090-mcp-server.md create mode 100644 mcp/.gitignore create mode 100644 mcp/README.md create mode 100644 mcp/package-lock.json create mode 100644 mcp/package.json create mode 100644 mcp/src/gateway.ts create mode 100644 mcp/src/index.ts create mode 100644 mcp/src/server.ts create mode 100644 mcp/src/tools.ts create mode 100644 mcp/test/gateway.test.ts create mode 100644 mcp/tsconfig.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 36be72f..35d12f8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,6 +3,7 @@ # Jobs: # rust-check — cargo fmt/check/clippy/test (no DB needed) # dashboard — Next.js type-check + lint + build +# mcp — MCP server typecheck + unit tests + tsc (RFC-090) # docker — Dockerfile builds (buildx + GHA layer cache, no push) # migrations-check — apply migrations to Postgres 16, sqlx prepare --check # compose-smoke — default compose stack end-to-end smoke @@ -185,6 +186,39 @@ jobs: # Point at localhost during CI; no real backend needed for static build NEXT_PUBLIC_API_URL: "http://localhost:3001" + # ── MCP server (RFC-090) ────────────────────────────────── + mcp: + name: MCP — typecheck / test + needs: changes + if: ${{ needs.changes.outputs.heavy == 'true' }} + runs-on: ubuntu-latest + permissions: + contents: read + defaults: + run: + working-directory: mcp + steps: + - uses: actions/checkout@v4 + + - name: Setup Node 22 + uses: actions/setup-node@v4 + with: + node-version: "22" + cache: "npm" + cache-dependency-path: mcp/package-lock.json + + - name: npm install + run: npm install + + - name: TypeScript type-check + run: npm run typecheck + + - name: Unit tests + run: npm test + + - name: Build + run: npm run build + # ── Docker build smoke-test ─────────────────────────────── docker: name: Docker — build smoke-test diff --git a/.gitignore b/.gitignore index 4fc0d34..e3ee700 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,8 @@ target/ .env.local # Node / Next.js +mcp/node_modules/ +mcp/dist/ dashboard/node_modules/ dashboard/.next/ dashboard/.next-verify*/ @@ -30,6 +32,7 @@ dashboard/tsconfig.tsbuildinfo dashboard/public/llms.txt dashboard/public/llm-integration.md dashboard/public/llms-full.txt +dashboard/public/mcp-reference.md # Added by code-review-graph .code-review-graph/ diff --git a/README.md b/README.md index ca6b03c..19418f0 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,24 @@ from real samples, deploys it, wires your producer to `/v1/ingest`, and verifies with a dry run before anything is written. Machine index: [`/llms.txt`](https://app.datacontractgate.com/llms.txt). +MCP (Cursor, Claude Desktop, Windsurf, Copilot, Codex): + +```json +{ + "mcpServers": { + "contractgate": { + "command": "npx", + "args": ["-y", "@contractgate/mcp-server"], + "env": { + "CONTRACTGATE_API_KEY": "${CONTRACTGATE_API_KEY}" + } + } + } +} +``` + +Tools and auth: [`docs/mcp-reference.md`](docs/mcp-reference.md). + --- ## Try it in 10 minutes (Self-Hosted Free) diff --git a/dashboard/app/docs/page.tsx b/dashboard/app/docs/page.tsx index 41b14ed..59de3c6 100644 --- a/dashboard/app/docs/page.tsx +++ b/dashboard/app/docs/page.tsx @@ -15,6 +15,18 @@ const DOCS = [ pills: ["copy-paste", "curl + TS + Python", "dry-run verified", "no signup to read"], cta: "Open the raw playbook →", }, + { + external: true, + href: "/mcp-reference.md", + icon: "🔌", + title: "MCP server", + badge: "RFC-090", + badgeColor: "text-cyan-400 bg-cyan-900/30 border-cyan-700/40", + description: + "Official Model Context Protocol server. Add npx -y @contractgate/mcp-server to Cursor, Claude Desktop, Windsurf, or Copilot and the agent gets typed tools for infer, dry-run, deploy, and quarantine — no curl.", + pills: ["stdio", "npx", "API-key auth", "dry-run default"], + cta: "Open the MCP reference →", + }, { external: false, href: "/docs/python-sdk", diff --git a/dashboard/app/layout.tsx b/dashboard/app/layout.tsx index 02cf5ac..a38bd2a 100644 --- a/dashboard/app/layout.tsx +++ b/dashboard/app/layout.tsx @@ -43,6 +43,7 @@ export const metadata: Metadata = { "text/markdown": [ { url: "/llms.txt", title: "LLM Index" }, { url: "/llms-full.txt", title: "Full LLM Documentation" }, + { url: "/mcp-reference.md", title: "MCP Server" }, ], }, }, diff --git a/dashboard/next.config.ts b/dashboard/next.config.ts index 181bf69..4124f0d 100644 --- a/dashboard/next.config.ts +++ b/dashboard/next.config.ts @@ -19,7 +19,7 @@ const nextConfig: NextConfig = { async headers() { return [ { - source: "/:path(llms.txt|llms-full.txt|llm-integration.md)", + source: "/:path(llms.txt|llms-full.txt|llm-integration.md|mcp-reference.md)", headers: [ { key: "Content-Type", value: "text/plain; charset=utf-8" }, { key: "Cache-Control", value: "public, max-age=300" }, diff --git a/dashboard/proxy.ts b/dashboard/proxy.ts index 05f7e06..db91dfb 100644 --- a/dashboard/proxy.ts +++ b/dashboard/proxy.ts @@ -25,6 +25,7 @@ const PUBLIC_ROUTES = [ // but a future matcher edit shouldn't silently re-gate them. "/llms.txt", "/llm-integration.md", + "/mcp-reference.md", ]; function isPublic(pathname: string) { diff --git a/dashboard/scripts/sync-llm-docs.mjs b/dashboard/scripts/sync-llm-docs.mjs index 6777bf8..f5efff0 100644 --- a/dashboard/scripts/sync-llm-docs.mjs +++ b/dashboard/scripts/sync-llm-docs.mjs @@ -2,6 +2,7 @@ * RFC-089 — copy the agent-facing docs into public/ so they are served raw at * https://app.datacontractgate.com/llms.txt * https://app.datacontractgate.com/llm-integration.md + * https://app.datacontractgate.com/mcp-reference.md * https://app.datacontractgate.com/llms-full.txt * * Canonical source is docs/ at the repo root. The copies in public/ are @@ -23,12 +24,13 @@ const here = dirname(fileURLToPath(import.meta.url)); const docsDir = join(here, "..", "..", "docs"); const publicDir = join(here, "..", "public"); -const FILES = ["llms.txt", "llm-integration.md"]; +const FILES = ["llms.txt", "llm-integration.md", "mcp-reference.md"]; // Order matches the "Reference" section of llms.txt. Playbook first so agents // hit the executable flow before the deep reference material. const FULL_BUNDLE = [ "llm-integration.md", + "mcp-reference.md", "v1-ingest-reference.md", "deploy-contract-reference.md", "csv-inference-reference.md", diff --git a/docs/STATUS.md b/docs/STATUS.md index 6aca41b..dbea495 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -97,6 +97,7 @@ signed off (may be planning docs or UI-only); **Draft** = under review; | 084 | [Slack Lead-Intake Bot](rfcs/084-slack-lead-bot.md) | Shipped | `nightly-maintenance-2026-07-15-rfc084-slack-bot` | | 085 | [Org Admin / Team Management](rfcs/085-org-admin-team-management.md) | Shipped | `nightly-maintenance-2026-07-16-rfc085-team-admin` | | 089 | [LLM-Pasteable Onboarding (`/llms.txt` + agent playbook)](rfcs/089-llm-agent-onboarding.md) | Shipped | `nightly-maintenance-2026-07-22-bot-signup-cleanup` | +| 090 | [Official MCP Server (stdio)](rfcs/090-mcp-server.md) | Accepted | `nightly-maintenance-2026-08-23-rfc090` | --- @@ -114,6 +115,8 @@ signed off (may be planning docs or UI-only); **Draft** = under review; --- +*2026-08-23 — RFC-090: official stdio MCP server at `mcp/` (`npx -y @contractgate/mcp-server`). Tools wrap existing gateway routes (infer, dry-run ingest / playground, deploy, quarantine, list). No engine changes. Reference: `docs/mcp-reference.md`, served raw at `/mcp-reference.md`. + *2026-08-13 — RFC-089 shipped: `docs/llm-integration.md` (agent-executable integration playbook) and `docs/llms.txt`, copied into `dashboard/public/` by a prebuild step and served raw at `app.datacontractgate.com/llm-integration.md` + `/llms.txt`; `datacontractgate.com` 307s to both, and the marketing site carries a copy-the-prompt block. Drift gate `tests/llm_docs_test.rs` asserts every endpoint the playbook cites exists in the router and that its example contract compiles. Same branch also replaced the CI migration file-count sentinel with a filename-contract check (contiguous NNN_ prefixes, no duplicates) plus `.github/workflows/migration-drift.yml`, which compares the prod ledger to `supabase/migrations/` daily — the check that would have caught 033/034 being applied-but-untracked. Migration 035 is held (listed in `supabase/unapplied-migrations.txt`) pending a re-audit against current signups.* *Last updated: 2026-07-16 — RFC-078/079/080 implemented on `feature-RFCs_deferred`: RFC-079 unifies contract inference on the Rust engine (Generate-from-Sample now routes through `POST /contracts/infer`, nested objects infer correctly, JS inferrer removed); RFC-080 adds Visual Builder nested-object support; RFC-078 adds the cross-surface walkthrough spine + API/CSV/Kafka/Kinesis walkthroughs with cg-validated runnable examples. RFC-077 RAG profile moved to Accepted — contract, examples, and reference doc are shipped and engine-validated, but GA promotion is deferred until a RAG prospect is active.* diff --git a/docs/agent-rules/AGENT.md b/docs/agent-rules/AGENT.md index 3a17e6c..8c0bcbc 100644 --- a/docs/agent-rules/AGENT.md +++ b/docs/agent-rules/AGENT.md @@ -23,4 +23,8 @@ a producer must satisfy a YAML contract in the `contracts/` directory. Never edit a deployed version in place — bump `version:` in the YAML and redeploy. +If the ContractGate MCP server is connected, prefer `infer_contract`, +`validate_events`, `deploy_contract`, and `get_quarantine` over constructing +shell pipelines. Setup: . + Full playbook: . diff --git a/docs/agent-rules/README.md b/docs/agent-rules/README.md index 9000d01..bec7c56 100644 --- a/docs/agent-rules/README.md +++ b/docs/agent-rules/README.md @@ -16,6 +16,8 @@ presets carry the same core rule — pick the one your team uses. | Claude Code, Codex, others | `AGENT.md` | Paste into your project's agent instructions file | Every preset points the agent at the canonical playbook: -. Nothing here duplicates -that content — the presets are short by design so they can be dropped into -a repo without becoming a maintenance burden. +. If the host has the +ContractGate MCP server connected, the presets tell the agent to use those +tools instead of curl. Nothing here duplicates the playbook — the presets +are short by design so they can be dropped into a repo without becoming a +maintenance burden. diff --git a/docs/agent-rules/copilot-instructions.md b/docs/agent-rules/copilot-instructions.md index 5bb1ef1..6934a13 100644 --- a/docs/agent-rules/copilot-instructions.md +++ b/docs/agent-rules/copilot-instructions.md @@ -18,4 +18,6 @@ one of these: contracts/.yaml --json`. Never edit a deployed version in place — bump `version:` in the YAML and redeploy. +If the ContractGate MCP server is connected, prefer its tools over curl. +MCP: . Full playbook: . diff --git a/docs/agent-rules/cursor.mdc b/docs/agent-rules/cursor.mdc index 02467fc..800ec48 100644 --- a/docs/agent-rules/cursor.mdc +++ b/docs/agent-rules/cursor.mdc @@ -32,5 +32,6 @@ loosening the contract. Use the CLI: `contractgate deploy-contract contracts/.yaml --json`. Never edit a deployed version in place — bump `version:` in the YAML and -redeploy. See for the -full playbook. +redeploy. If the ContractGate MCP server is connected, prefer its tools +over curl. See and +. diff --git a/docs/agent-rules/windsurf.md b/docs/agent-rules/windsurf.md index a626219..9f3980f 100644 --- a/docs/agent-rules/windsurf.md +++ b/docs/agent-rules/windsurf.md @@ -23,5 +23,7 @@ loosening the contract. ## Deploying a contract change Use `contractgate deploy-contract contracts/.yaml --json`. Never edit a -deployed version in place — bump `version:` in the YAML and redeploy. Full -playbook: . +deployed version in place — bump `version:` in the YAML and redeploy. If the +ContractGate MCP server is connected, prefer its tools over curl. Full +playbook: . MCP: +. diff --git a/docs/llm-integration.md b/docs/llm-integration.md index 9de36d7..a85b6c9 100644 --- a/docs/llm-integration.md +++ b/docs/llm-integration.md @@ -38,6 +38,11 @@ gateway — verified with a dry run before anything writes. Every request below sends the key as the `X-Api-Key` header. +If the ContractGate MCP server is connected, prefer its tools +(`infer_contract`, `validate_events`, `deploy_contract`, `get_quarantine`, +`list_contracts`) over constructing `curl` calls. Setup: +. + --- ## §1 — Find the event shape in the user's repo diff --git a/docs/llms.txt b/docs/llms.txt index 5a1598e..e0dcecb 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -12,6 +12,7 @@ Source: https://github.com/nightmoose/contractgate ## Start here - [Integration playbook for coding agents](https://app.datacontractgate.com/llm-integration.md): Paste this into Claude, Cursor, or Codex. End-to-end, executable: get a key, infer a contract from real samples, write and deploy the contract YAML, wire the producer to POST /v1/ingest, verify with a dry run. +- [MCP server](https://app.datacontractgate.com/mcp-reference.md): Official stdio MCP package (`npx -y @contractgate/mcp-server`) — infer, dry-run, deploy, and list quarantine as typed tools. - [Full documentation bundle](https://app.datacontractgate.com/llms-full.txt): One-shot ingestion for large-context agents — the playbook and every reference doc below, concatenated. Skip the per-link round-trips. ## Reference diff --git a/docs/mcp-reference.md b/docs/mcp-reference.md new file mode 100644 index 0000000..6e55536 --- /dev/null +++ b/docs/mcp-reference.md @@ -0,0 +1,139 @@ +# ContractGate MCP Server + +**RFC-090.** Official Model Context Protocol server for Cursor, Claude Desktop, +Windsurf, VS Code Copilot, Codex, and any other MCP host. + +The server is a thin stdio client of the existing gateway. It does not run +validation itself. Auth is the same API key the CLI and playbook already use. + +## Install + +Add this to the host's MCP config (`~/.cursor/mcp.json`, Claude Desktop +`claude_desktop_config.json`, etc.). + +Once `@contractgate/mcp-server` is on npm: + +```json +{ + "mcpServers": { + "contractgate": { + "command": "npx", + "args": ["-y", "@contractgate/mcp-server"], + "env": { + "CONTRACTGATE_API_KEY": "${CONTRACTGATE_API_KEY}" + } + } + } +} +``` + +Until then, from a clone (`cd mcp && npm install && npm run build`): + +```json +{ + "mcpServers": { + "contractgate": { + "command": "node", + "args": ["/mcp/dist/index.js"], + "env": { + "CONTRACTGATE_API_KEY": "${CONTRACTGATE_API_KEY}" + } + } + } +} +``` + +Restart the host after editing the config. + +## Environment + +| Variable | Required | Default | +|---|---|---| +| `CONTRACTGATE_API_KEY` | yes | — | +| `CONTRACTGATE_BASE_URL` | no | `https://app.datacontractgate.com` | + +Never put the raw key in the config file. Reference the environment variable +the way your host supports (`${CONTRACTGATE_API_KEY}` in Cursor; Claude Desktop +reads the process environment). + +Get a key at . + +## Tools + +### `infer_contract` + +`POST /contracts/infer`. Draft YAML from real sample events. Does not persist. + +| Argument | Type | Required | +|---|---|---| +| `name` | string | yes | +| `samples` | object[] | yes, ≥1 | +| `description` | string | no | + +Write the returned `yaml_content` to `contracts/.yaml` and review it +before deploying. Inference is a starting point. + +### `validate_events` + +Validate events against a deployed contract or against in-flight YAML. + +| Argument | Type | Required | +|---|---|---| +| `events` | object[] | yes, ≥1 | +| `contract_id` | uuid | exactly one of `contract_id` / `yaml_content` | +| `yaml_content` | string | exactly one of `contract_id` / `yaml_content` | +| `dry_run` | boolean | no, default `true` | + +- `contract_id` → `POST /v1/ingest/{contract_id}`. Default `dry_run=true` (no + audit row, no quarantine, no metered usage). Set `dry_run=false` only after a + dry run has passed. +- `yaml_content` → `POST /playground/validate` per event. Never persists. + `dry_run` is ignored. + +`200` / `207` / `422` all return the body. Read `results[].violations` — +entries may include `received`, `expected`, and `suggestion` so you can fix +the producer or the YAML without guessing. + +### `deploy_contract` + +`POST /contracts/deploy`. Finds-or-creates the contract by `name`, inserts the +YAML as `stable`, deprecates prior stable versions. Refused while quarantine +is pending. `409` if that `(name, version)` already exists — bump `version:` +in the YAML and retry. + +| Argument | Type | Required | +|---|---|---| +| `name` | string | yes | +| `yaml_content` | string | yes | +| `source` | string | no | +| `deployed_by` | string | no (defaults to `mcp`) | + +Save the returned `contract_id`. It is not a secret. + +### `get_quarantine` + +`GET /quarantine`. Source quarantine rows for the caller's org, newest first. + +| Argument | Type | Required | +|---|---|---| +| `contract_id` | uuid | no | +| `limit` | int | no, default 100, max 500 | +| `offset` | int | no | + +### `list_contracts` + +`GET /contracts`. Identities the key can see. + +## Prompt + +`integrate-contractgate` — loads the agent playbook URL +() as the instruction to +follow. Use it when wiring ContractGate into a repo for the first time. + +## What this server will not do + +- Live ingest by default (`validate_events` defaults to dry-run). +- Kafka / Kinesis / billing / collaborator management. +- Invent contract fields that were not in the samples. + +Full executable flow without MCP: . diff --git a/docs/rfcs/090-mcp-server.md b/docs/rfcs/090-mcp-server.md new file mode 100644 index 0000000..a38fe31 --- /dev/null +++ b/docs/rfcs/090-mcp-server.md @@ -0,0 +1,98 @@ +# RFC-090 — Official MCP server (stdio) + +**Status:** Accepted +**Date:** 2026-08-23 +**Branch:** nightly-maintenance-2026-08-23-rfc090 +**Depends on:** RFC-089 (agent playbook), RFC-028 (deploy), RFC-076 (local test), RFC-081 (quarantine list) + +--- + +## Problem + +RFC-089 made ContractGate paste-installable: an agent that fetches +`/llm-integration.md` can infer, deploy, and wire ingest over HTTP. That still +asks the agent to construct `curl`/`jq` pipelines. Hosts that speak MCP +(Cursor, Claude Desktop, Windsurf, VS Code Copilot, Codex) prefer typed tools. + +RFC-089 listed this as a follow-up. The playbook and `llms-full.txt` are the +prerequisite content; this RFC wraps them. + +## Goal + +A stdio MCP server an agent host can launch with: + +```json +{ + "mcpServers": { + "contractgate": { + "command": "npx", + "args": ["-y", "@contractgate/mcp-server"], + "env": { + "CONTRACTGATE_API_KEY": "${CONTRACTGATE_API_KEY}" + } + } + } +} +``` + +Done means: with `CONTRACTGATE_API_KEY` set, an agent can infer a contract from +samples, dry-run events against it, deploy YAML to stable, and list quarantine +— without writing a shell pipeline. + +## Non-goals + +- **Remote Streamable HTTP / OAuth.** Local stdio + API-key env. Hosted MCP is + a later RFC once SSO exists. +- **Embedding MCP in `contractgate-server`.** Agent-protocol churn stays off + the validation hot path. +- **Mirroring `/openapi.json` as tools.** Agents get a short workflow set, not + every route. +- **`cg mcp` Rust subcommand.** TypeScript package first (`npx` discovery). +- **Claude marketplace plugin.** Distribution follows the package. +- **Changing the validation engine.** Tools are a thin HTTP client. + +## Design + +### Package + +`mcp/` — `@contractgate/mcp-server`, official TypeScript SDK +(`@modelcontextprotocol/server`, 2026-07-28 spec), stdio via `serveStdio`. + +Auth: `CONTRACTGATE_API_KEY` (required) and optional `CONTRACTGATE_BASE_URL` +(default `https://app.datacontractgate.com`). Same key and `X-Api-Key` header +as the CLI and playbook. Per-key `allowed_contract_ids` (RFC-065) still scopes +writes. + +### Tools + +| Tool | Gateway | Notes | +|---|---|---| +| `infer_contract` | `POST /contracts/infer` | Draft YAML. Does not persist. | +| `validate_events` | `POST /v1/ingest/{id}` or `POST /playground/validate` | `dry_run` defaults **true**. YAML path is playground (never persists). `207`/`422` return the body so agents can self-heal from `suggestion`. | +| `deploy_contract` | `POST /contracts/deploy` | Promotes to `stable`. `destructiveHint`. | +| `get_quarantine` | `GET /quarantine` | Read-only. | +| `list_contracts` | `GET /contracts` | Read-only. Agents need ids after infer. | + +Prompt: `integrate-contractgate` — points at the playbook URL. + +No Kafka, Kinesis, billing, collab, or live-ingest-by-default tools. + +### Docs + drift + +- `docs/mcp-reference.md` — user-facing; served raw at `/mcp-reference.md`. +- `docs/llms.txt` + `llms-full.txt` bundle include it. +- `tests/llm_docs_test.rs` asserts every `METHOD /path` in the reference exists + in `src/main.rs`. + +## Implementation checklist + +1. `mcp/` package: gateway client, tools, stdio entry, unit tests with mocked fetch. +2. `docs/mcp-reference.md` + RFC. +3. Wire `sync-llm-docs.mjs`, `llms.txt`, playbook §0 note, README snippet, docs card, agent-rule one-liners. +4. CI job: `npm test` + `tsc` in `mcp/`. +5. `cargo test` (drift gate) + `mcp` tests. + +## Success + +An agent host with the snippet above can complete the RFC-089 flow using only +the five tools. No engine regression. diff --git a/mcp/.gitignore b/mcp/.gitignore new file mode 100644 index 0000000..b947077 --- /dev/null +++ b/mcp/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/mcp/README.md b/mcp/README.md new file mode 100644 index 0000000..480dc19 --- /dev/null +++ b/mcp/README.md @@ -0,0 +1,28 @@ +# @contractgate/mcp-server + +Official [Model Context Protocol](https://modelcontextprotocol.io) server for +ContractGate. Thin stdio wrapper over the existing HTTP API. + +See [`docs/mcp-reference.md`](../docs/mcp-reference.md) for tools, auth, and +the host config snippet. + +```json +{ + "mcpServers": { + "contractgate": { + "command": "npx", + "args": ["-y", "@contractgate/mcp-server"], + "env": { + "CONTRACTGATE_API_KEY": "${CONTRACTGATE_API_KEY}" + } + } + } +} +``` + +```bash +cd mcp +npm install +npm test +npm run build +``` diff --git a/mcp/package-lock.json b/mcp/package-lock.json new file mode 100644 index 0000000..11ba57a --- /dev/null +++ b/mcp/package-lock.json @@ -0,0 +1,611 @@ +{ + "name": "@contractgate/mcp-server", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@contractgate/mcp-server", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/server": "^2.0.0", + "zod": "^4.0.0" + }, + "bin": { + "contractgate-mcp": "dist/index.js" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "tsx": "^4.19.0", + "typescript": "^5.8.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@modelcontextprotocol/core": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.0.0.tgz", + "integrity": "sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA==", + "license": "MIT", + "dependencies": { + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@modelcontextprotocol/server": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.0.0.tgz", + "integrity": "sha512-YhHWdHfpFMQfd0prsEnxKeS3Qz3ytIGmsS0sth4KDjnacIT7hxk6hXHkJ9KysxlkvTM+WZAtQbbcUhdoP4Hvtw==", + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/core": "2.0.0", + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/tsx": { + "version": "4.23.12", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.12.tgz", + "integrity": "sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/mcp/package.json b/mcp/package.json new file mode 100644 index 0000000..35c5338 --- /dev/null +++ b/mcp/package.json @@ -0,0 +1,43 @@ +{ + "name": "@contractgate/mcp-server", + "version": "0.1.0", + "description": "Official Model Context Protocol server for ContractGate — infer, dry-run, deploy, and inspect quarantine.", + "license": "MIT", + "type": "module", + "bin": { + "contractgate-mcp": "./dist/index.js" + }, + "files": [ + "dist", + "README.md" + ], + "scripts": { + "build": "tsc", + "start": "tsx src/index.ts", + "test": "tsx --test test/*.test.ts", + "typecheck": "tsc --noEmit" + }, + "engines": { + "node": ">=20" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/nightmoose/contractgate.git", + "directory": "mcp" + }, + "keywords": [ + "mcp", + "contractgate", + "data-contracts", + "validation" + ], + "dependencies": { + "@modelcontextprotocol/server": "^2.0.0", + "zod": "^4.0.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "tsx": "^4.19.0", + "typescript": "^5.8.0" + } +} diff --git a/mcp/src/gateway.ts b/mcp/src/gateway.ts new file mode 100644 index 0000000..ba1a14a --- /dev/null +++ b/mcp/src/gateway.ts @@ -0,0 +1,132 @@ +/** Thin HTTP client for the ContractGate gateway. No validation logic. */ + +export const DEFAULT_BASE_URL = "https://app.datacontractgate.com"; +const USER_AGENT = "contractgate-mcp/0.1.0"; + +export class GatewayError extends Error { + readonly status: number; + readonly body: string; + + constructor(status: number, body: string) { + super(`ContractGate HTTP ${status}: ${body}`); + this.name = "GatewayError"; + this.status = status; + this.body = body; + } +} + +export class ConfigError extends Error { + constructor(message: string) { + super(message); + this.name = "ConfigError"; + } +} + +export type FetchFn = typeof fetch; + +export class Gateway { + readonly baseUrl: string; + readonly apiKey: string; + private readonly fetchFn: FetchFn; + + constructor(opts: { baseUrl?: string; apiKey: string; fetch?: FetchFn }) { + this.baseUrl = (opts.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, ""); + this.apiKey = opts.apiKey; + this.fetchFn = opts.fetch ?? globalThis.fetch; + } + + static fromEnv(env: NodeJS.ProcessEnv = process.env, fetchFn?: FetchFn): Gateway { + const apiKey = env.CONTRACTGATE_API_KEY?.trim(); + if (!apiKey) { + throw new ConfigError( + "CONTRACTGATE_API_KEY is not set. Create a key at https://app.datacontractgate.com/account and export it. Never inline the key in MCP config.", + ); + } + return new Gateway({ + baseUrl: env.CONTRACTGATE_BASE_URL?.trim() || DEFAULT_BASE_URL, + apiKey, + fetch: fetchFn, + }); + } + + infer(body: { name: string; samples: unknown[]; description?: string }) { + return this.request("POST", "/contracts/infer", { body }); + } + + ingest( + contractId: string, + events: unknown, + opts: { dryRun: boolean }, + ) { + const q = opts.dryRun ? "?dry_run=true" : ""; + // 207 mixed / 422 all-failed are data results, not transport errors. + return this.request("POST", `/v1/ingest/${encodeURIComponent(contractId)}${q}`, { + body: events, + ok: [200, 207, 422], + }); + } + + playground(yamlContent: string, event: unknown) { + return this.request("POST", "/playground/validate", { + body: { yaml_content: yamlContent, event }, + }); + } + + deploy(body: { + name: string; + yaml_content: string; + source?: string; + deployed_by?: string; + }) { + return this.request("POST", "/contracts/deploy", { body }); + } + + quarantine(opts: { contractId?: string; limit?: number; offset?: number }) { + const q = new URLSearchParams(); + if (opts.contractId) q.set("contract_id", opts.contractId); + if (opts.limit != null) q.set("limit", String(opts.limit)); + if (opts.offset != null) q.set("offset", String(opts.offset)); + const qs = q.toString(); + return this.request("GET", `/quarantine${qs ? `?${qs}` : ""}`); + } + + listContracts() { + return this.request("GET", "/contracts"); + } + + private async request( + method: string, + path: string, + opts: { body?: unknown; ok?: number[] } = {}, + ): Promise { + const headers: Record = { + Accept: "application/json", + "X-Api-Key": this.apiKey, + "User-Agent": USER_AGENT, + }; + const init: RequestInit = { method, headers }; + if (opts.body !== undefined) { + headers["Content-Type"] = "application/json"; + init.body = JSON.stringify(opts.body); + } + const res = await this.fetchFn(`${this.baseUrl}${path}`, init); + const text = await res.text(); + const allowed = opts.ok; + const success = allowed + ? allowed.includes(res.status) + : res.status >= 200 && res.status < 300; + if (!success) { + throw new GatewayError(res.status, text || res.statusText); + } + return decode(text); + } +} + +function decode(text: string): unknown { + if (!text) return null; + try { + return JSON.parse(text); + } catch { + return text; + } +} diff --git a/mcp/src/index.ts b/mcp/src/index.ts new file mode 100644 index 0000000..e175952 --- /dev/null +++ b/mcp/src/index.ts @@ -0,0 +1,6 @@ +#!/usr/bin/env node +import { serveStdio } from "@modelcontextprotocol/server/stdio"; +import { createServer } from "./server.js"; + +void serveStdio(() => createServer()); +console.error("contractgate MCP server running on stdio"); diff --git a/mcp/src/server.ts b/mcp/src/server.ts new file mode 100644 index 0000000..3e3d372 --- /dev/null +++ b/mcp/src/server.ts @@ -0,0 +1,195 @@ +import { McpServer } from "@modelcontextprotocol/server"; +import * as z from "zod/v4"; +import { ConfigError, Gateway, GatewayError } from "./gateway.js"; +import { + deployContract, + getQuarantine, + inferContract, + listContracts, + validateEvents, +} from "./tools.js"; + +const sample = z.record(z.string(), z.unknown()); + +export type ServerOpts = { + gateway?: Gateway; + env?: NodeJS.ProcessEnv; +}; + +function jsonResult(data: unknown) { + return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; +} + +function errorResult(err: unknown) { + const text = + err instanceof ConfigError || err instanceof GatewayError || err instanceof Error + ? err.message + : String(err); + return { content: [{ type: "text" as const, text }], isError: true as const }; +} + +function gateway(opts: ServerOpts): Gateway { + return opts.gateway ?? Gateway.fromEnv(opts.env); +} + +export function createServer(opts: ServerOpts = {}): McpServer { + const server = new McpServer({ + name: "contractgate", + version: "0.1.0", + }); + + server.registerTool( + "infer_contract", + { + title: "Infer a contract from sample events", + description: + "Generate draft ContractGate YAML from real JSON sample events via POST /contracts/infer. Does not persist. Review and write the YAML to contracts/.yaml before deploying.", + inputSchema: z.object({ + name: z.string().min(1).describe("Contract name, snake_case, e.g. user_events"), + samples: z + .array(sample) + .min(1) + .describe("1–20 real event objects. Do not invent them."), + description: z.string().optional().describe("Optional contract description"), + }), + annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true }, + }, + async (args) => { + try { + return jsonResult(await inferContract(gateway(opts), args)); + } catch (err) { + return errorResult(err); + } + }, + ); + + server.registerTool( + "validate_events", + { + title: "Validate events against a contract", + description: + "Validate events. Pass contract_id to hit POST /v1/ingest/{id} (dry_run defaults true — no audit, quarantine, or usage). Pass yaml_content to hit POST /playground/validate per event (never persists). Exactly one of contract_id or yaml_content. 207/422 responses are returned as data so you can apply each violation's suggestion.", + inputSchema: z + .object({ + events: z.array(sample).min(1).describe("Event objects to validate"), + contract_id: z + .string() + .uuid() + .optional() + .describe("Deployed contract UUID from deploy_contract"), + yaml_content: z + .string() + .optional() + .describe("In-flight contract YAML; uses the playground, never persists"), + dry_run: z + .boolean() + .optional() + .describe("Ingest only. Default true. Set false only after a successful dry run."), + }) + .refine((v) => Boolean(v.contract_id) !== Boolean(v.yaml_content), { + message: + "Pass exactly one of contract_id (deployed) or yaml_content (playground).", + }), + annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true }, + }, + async (args) => { + try { + return jsonResult(await validateEvents(gateway(opts), args)); + } catch (err) { + return errorResult(err); + } + }, + ); + + server.registerTool( + "deploy_contract", + { + title: "Deploy a contract to stable", + description: + "POST /contracts/deploy — find-or-create by name, insert YAML as stable, deprecate prior stable. 409 if that (name, version) exists: bump version in the YAML. Refused while quarantine is pending. Save the returned contract_id.", + inputSchema: z.object({ + name: z.string().min(1).describe("Contract name matching the YAML name: field"), + yaml_content: z.string().min(1).describe("Full contract YAML"), + source: z.string().optional().describe("Logical feed name, e.g. app-backend"), + deployed_by: z.string().optional().describe("Actor recorded on the version row"), + }), + annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true, idempotentHint: false }, + }, + async (args) => { + try { + return jsonResult(await deployContract(gateway(opts), args)); + } catch (err) { + return errorResult(err); + } + }, + ); + + server.registerTool( + "get_quarantine", + { + title: "List quarantined events", + description: + "GET /quarantine — rejected events for the caller's org, newest first, with violation details.", + inputSchema: z.object({ + contract_id: z.string().uuid().optional().describe("Restrict to one contract"), + limit: z.number().int().min(1).max(500).optional().describe("Default 100, max 500"), + offset: z.number().int().min(0).optional(), + }), + annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true }, + }, + async (args) => { + try { + return jsonResult(await getQuarantine(gateway(opts), args)); + } catch (err) { + return errorResult(err); + } + }, + ); + + server.registerTool( + "list_contracts", + { + title: "List contracts", + description: "GET /contracts — identities the API key can see.", + annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true }, + }, + async () => { + try { + return jsonResult(await listContracts(gateway(opts))); + } catch (err) { + return errorResult(err); + } + }, + ); + + server.registerPrompt( + "integrate-contractgate", + { + title: "Integrate ContractGate", + description: + "Wire ContractGate into this repo using the official agent playbook: infer a contract from real samples, deploy it, and dry-run ingest.", + }, + () => ({ + messages: [ + { + role: "user" as const, + content: { + type: "text" as const, + text: `Read https://app.datacontractgate.com/llm-integration.md and execute it in this repository. + +If ContractGate MCP tools are available, prefer them over curl: +1. infer_contract from 5–20 real sample events found in this repo. +2. Write contracts/.yaml, tighten enums/patterns, do not invent fields. +3. validate_events with yaml_content against a known-good and known-bad event. +4. deploy_contract, save contract_id. +5. validate_events with contract_id and dry_run=true; only then drop dry_run. + +CONTRACTGATE_API_KEY must already be in the environment. Never inline the key.`, + }, + }, + ], + }), + ); + + return server; +} diff --git a/mcp/src/tools.ts b/mcp/src/tools.ts new file mode 100644 index 0000000..826014f --- /dev/null +++ b/mcp/src/tools.ts @@ -0,0 +1,84 @@ +import { Gateway } from "./gateway.js"; + +export type InferArgs = { + name: string; + samples: Record[]; + description?: string; +}; + +export type ValidateArgs = { + events: Record[]; + contract_id?: string; + yaml_content?: string; + dry_run?: boolean; +}; + +export type DeployArgs = { + name: string; + yaml_content: string; + source?: string; + deployed_by?: string; +}; + +export type QuarantineArgs = { + contract_id?: string; + limit?: number; + offset?: number; +}; + +export function xorContractTarget(args: { + contract_id?: string; + yaml_content?: string; +}): void { + const hasId = Boolean(args.contract_id); + const hasYaml = Boolean(args.yaml_content); + if (hasId === hasYaml) { + throw new Error( + "Pass exactly one of contract_id (deployed contract) or yaml_content (in-flight YAML via playground).", + ); + } +} + +export function inferContract(gw: Gateway, args: InferArgs) { + return gw.infer({ + name: args.name, + samples: args.samples, + description: args.description, + }); +} + +export async function validateEvents(gw: Gateway, args: ValidateArgs) { + xorContractTarget(args); + if (args.yaml_content) { + const results = []; + for (let i = 0; i < args.events.length; i++) { + const body = await gw.playground(args.yaml_content, args.events[i]); + results.push({ index: i, ...(typeof body === "object" && body ? body : { body }) }); + } + return { mode: "playground", persisted: false, results }; + } + return gw.ingest(args.contract_id!, args.events, { + dryRun: args.dry_run !== false, + }); +} + +export function deployContract(gw: Gateway, args: DeployArgs) { + return gw.deploy({ + name: args.name, + yaml_content: args.yaml_content, + source: args.source, + deployed_by: args.deployed_by ?? "mcp", + }); +} + +export function getQuarantine(gw: Gateway, args: QuarantineArgs) { + return gw.quarantine({ + contractId: args.contract_id, + limit: args.limit, + offset: args.offset, + }); +} + +export function listContracts(gw: Gateway) { + return gw.listContracts(); +} diff --git a/mcp/test/gateway.test.ts b/mcp/test/gateway.test.ts new file mode 100644 index 0000000..8f938e1 --- /dev/null +++ b/mcp/test/gateway.test.ts @@ -0,0 +1,213 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { ConfigError, Gateway, GatewayError } from "../src/gateway.ts"; +import { createServer } from "../src/server.ts"; +import { + deployContract, + inferContract, + listContracts, + getQuarantine, + validateEvents, + xorContractTarget, +} from "../src/tools.ts"; + +type Handler = (req: Request) => Response | Promise; + +function mockFetch(routes: Record): typeof fetch { + return async (input, init) => { + const req = new Request(input, init); + const url = new URL(req.url); + const key = `${req.method} ${url.pathname}`; + const handler = routes[key]; + if (!handler) { + return new Response(`unexpected ${key}`, { status: 599 }); + } + return handler(req); + }; +} + +function gw(routes: Record): Gateway { + return new Gateway({ + baseUrl: "https://gw.test", + apiKey: "cg_live_test", + fetch: mockFetch(routes), + }); +} + +test("fromEnv requires CONTRACTGATE_API_KEY", () => { + assert.throws( + () => Gateway.fromEnv({}), + (err: unknown) => err instanceof ConfigError, + ); +}); + +test("fromEnv reads key and optional base URL", () => { + const g = Gateway.fromEnv({ + CONTRACTGATE_API_KEY: " cg_live_abc ", + CONTRACTGATE_BASE_URL: "https://custom.example/", + }); + assert.equal(g.apiKey, "cg_live_abc"); + assert.equal(g.baseUrl, "https://custom.example"); +}); + +test("infer_contract posts samples and returns yaml", async () => { + const g = gw({ + "POST /contracts/infer": async (req) => { + assert.equal(req.headers.get("x-api-key"), "cg_live_test"); + const body = await req.json(); + assert.equal(body.name, "user_events"); + assert.equal(body.samples.length, 1); + return Response.json({ yaml_content: "name: user_events\n", field_count: 1, sample_count: 1 }); + }, + }); + const out = (await inferContract(g, { + name: "user_events", + samples: [{ user_id: "u_1" }], + })) as { yaml_content: string }; + assert.match(out.yaml_content, /user_events/); +}); + +test("validate_events ingest defaults to dry_run", async () => { + const g = gw({ + "POST /v1/ingest/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa": async (req) => { + const url = new URL(req.url); + assert.equal(url.searchParams.get("dry_run"), "true"); + return new Response( + JSON.stringify({ total: 1, passed: 1, failed: 0, dry_run: true, results: [] }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + }, + }); + const out = (await validateEvents(g, { + contract_id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + events: [{ user_id: "u_1" }], + })) as { dry_run: boolean }; + assert.equal(out.dry_run, true); +}); + +test("validate_events treats 422 as data, not transport error", async () => { + const g = gw({ + "POST /v1/ingest/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa": async () => + new Response( + JSON.stringify({ + total: 1, + passed: 0, + failed: 1, + results: [ + { + index: 0, + passed: false, + violations: [ + { + field: "timestamp", + kind: "type_mismatch", + suggestion: "emit integer epoch", + }, + ], + }, + ], + }), + { status: 422, headers: { "Content-Type": "application/json" } }, + ), + }); + const out = (await validateEvents(g, { + contract_id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + events: [{ timestamp: "now" }], + dry_run: true, + })) as { failed: number; results: { violations: { suggestion: string }[] }[] }; + assert.equal(out.failed, 1); + assert.equal(out.results[0].violations[0].suggestion, "emit integer epoch"); +}); + +test("validate_events yaml path uses playground per event", async () => { + let n = 0; + const g = gw({ + "POST /playground/validate": async (req) => { + n += 1; + const body = await req.json(); + assert.ok(typeof body.yaml_content === "string"); + return Response.json({ passed: true, violations: [], validation_us: 1 }); + }, + }); + const out = (await validateEvents(g, { + yaml_content: "name: t\nontology:\n entities: []\n", + events: [{ a: 1 }, { a: 2 }], + })) as { mode: string; persisted: boolean; results: unknown[] }; + assert.equal(out.mode, "playground"); + assert.equal(out.persisted, false); + assert.equal(out.results.length, 2); + assert.equal(n, 2); +}); + +test("validate_events rejects both or neither target", () => { + assert.throws(() => xorContractTarget({})); + assert.throws(() => + xorContractTarget({ + contract_id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + yaml_content: "x", + }), + ); + xorContractTarget({ contract_id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" }); + xorContractTarget({ yaml_content: "x" }); +}); + +test("deploy_contract posts yaml and defaults deployed_by", async () => { + const g = gw({ + "POST /contracts/deploy": async (req) => { + const body = await req.json(); + assert.equal(body.name, "user_events"); + assert.equal(body.deployed_by, "mcp"); + assert.ok(body.yaml_content.includes("user_events")); + return Response.json({ + contract_id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + version: "1.0", + }); + }, + }); + const out = (await deployContract(g, { + name: "user_events", + yaml_content: "name: user_events\nversion: \"1.0\"\n", + })) as { contract_id: string }; + assert.equal(out.contract_id, "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); +}); + +test("get_quarantine forwards query params", async () => { + const g = gw({ + "GET /quarantine": async (req) => { + const url = new URL(req.url); + assert.equal(url.searchParams.get("contract_id"), "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); + assert.equal(url.searchParams.get("limit"), "10"); + return Response.json([]); + }, + }); + const out = await getQuarantine(g, { + contract_id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + limit: 10, + }); + assert.deepEqual(out, []); +}); + +test("list_contracts GET /contracts", async () => { + const g = gw({ + "GET /contracts": async () => Response.json([{ id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" }]), + }); + const out = (await listContracts(g)) as { id: string }[]; + assert.equal(out[0].id, "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); +}); + +test("createServer registers without throwing", () => { + const server = createServer({ + env: { CONTRACTGATE_API_KEY: "cg_live_test" }, + }); + assert.ok(server); +}); + +test("401 becomes GatewayError", async () => { + const g = gw({ + "GET /contracts": async () => new Response("nope", { status: 401 }), + }); + await assert.rejects( + () => listContracts(g), + (err: unknown) => err instanceof GatewayError && err.status === 401, + ); +}); diff --git a/mcp/tsconfig.json b/mcp/tsconfig.json new file mode 100644 index 0000000..2ced5a9 --- /dev/null +++ b/mcp/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "skipLibCheck": true, + "declaration": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src"] +} diff --git a/tests/llm_docs_test.rs b/tests/llm_docs_test.rs index 6b18b55..14794ba 100644 --- a/tests/llm_docs_test.rs +++ b/tests/llm_docs_test.rs @@ -66,17 +66,14 @@ fn cited_endpoints(doc: &str) -> Vec<(String, String)> { out } -#[test] -fn playbook_only_cites_routes_that_exist() { - let doc = fs::read_to_string(repo_path("docs/llm-integration.md")) - .expect("read docs/llm-integration.md"); +fn assert_doc_only_cites_existing_routes(rel: &str, min_cited: usize) { + let doc = fs::read_to_string(repo_path(rel)).unwrap_or_else(|_| panic!("read {rel}")); let declared = declared_paths(); let cited = cited_endpoints(&doc); assert!( - cited.len() >= 5, - "extracted only {} endpoints from the playbook — the extractor is broken, \ - not the doc", + cited.len() >= min_cited, + "extracted only {} endpoints from {rel} — the extractor is broken, not the doc", cited.len() ); @@ -88,11 +85,21 @@ fn playbook_only_cites_routes_that_exist() { assert!( missing.is_empty(), - "docs/llm-integration.md cites routes that do not exist in src/main.rs: {missing:?}\n\ + "{rel} cites routes that do not exist in src/main.rs: {missing:?}\n\ Fix the doc (or restore the route) — agents execute this file verbatim." ); } +#[test] +fn playbook_only_cites_routes_that_exist() { + assert_doc_only_cites_existing_routes("docs/llm-integration.md", 5); +} + +#[test] +fn mcp_reference_only_cites_routes_that_exist() { + assert_doc_only_cites_existing_routes("docs/mcp-reference.md", 3); +} + #[test] fn playbook_example_contract_compiles() { let doc = fs::read_to_string(repo_path("docs/llm-integration.md"))