diff --git a/.secretscanignore b/.secretscanignore index c319e17ec8..bb08b7fdc1 100644 --- a/.secretscanignore +++ b/.secretscanignore @@ -114,6 +114,9 @@ src/tests/mcp-client-oauth.test.ts:(test-secret|test-token) # Milestone-status observation-turn test fixtures use dummy pump/turn tokens # (not credentials) to exercise scavenging and TTL logic. packages/mcp-server/src/cli-runner.test.ts:GSD_MILESTONE_STATUS_OBSERVATION_TOKEN:[ ]*'opaque-pump-token' +# HTTP-mode CLI test fixtures use dummy auth tokens (not credentials) to assert +# --auth-token parsing and the GSD_MCP_AUTH_TOKEN fallback. +packages/mcp-server/src/cli-runner.test.ts:(authToken|GSD_MCP_AUTH_TOKEN):[ ]*'(secret-token|env-token)' src/resources/extensions/gsd/tests/semantic-shadow-mode-matrix.test.ts:token:[ ]*"(first|second|live|expired|new)-token" src/resources/extensions/gsd/tests/semantic-shadow-soak.test.ts:(expiredToken|replacementToken)[ ]*=[ ]*"semantic-shadow-(expired-crash|replacement)-token" diff --git a/docs/user-docs/cloud-mcp-gateway.md b/docs/user-docs/cloud-mcp-gateway.md index c6744a9ec5..cd88a19c43 100644 --- a/docs/user-docs/cloud-mcp-gateway.md +++ b/docs/user-docs/cloud-mcp-gateway.md @@ -2,33 +2,118 @@ The Cloud MCP Gateway lets a hosted MCP client call GSD workflow tools through a local runtime. Use it when the MCP client cannot reach your workstation directly, but your workstation can open an outbound WebSocket connection to a gateway. -The reader for this guide is an operator setting up a gateway and a local runtime. After reading it, they should be able to start the gateway, pair one runtime, connect it, and confirm that MCP tool calls can reach local projects. +The reader for this guide is an operator setting up a gateway, accounts, usage limits, and one or more local runtimes. After reading it, they should be able to start the gateway, issue MCP tokens, pair a runtime, connect it, and confirm that remote MCP tool calls can reach local projects and runtime-advertised MCP tools. ## Architecture -The gateway exposes two HTTP surfaces: +The gateway exposes these HTTP surfaces: -- An authenticated MCP endpoint for remote MCP clients. -- A pairing endpoint that issues one-time runtime device tokens. +- `/mcp`: authenticated Streamable HTTP MCP endpoint for remote MCP clients. +- `/runtime/connect`: outbound WebSocket target for paired local runtimes. +- `/pairing-codes` and `/pairing/exchange`: pairing-code issuance and exchange for local runtime device tokens. +- `/admin`: operator UI for user management, pairing codes, connected runtimes, and usage. +- `/account`: optional Clerk-backed self-service account UI for end users. +- `/register`: optional public self-registration endpoint when explicitly enabled. -The local runtime runs under `gsd-daemon`. After pairing, it stores the gateway URL, runtime ID, and device token in the daemon config. When `gsd-daemon cloud connect` starts, it connects back to the gateway with the device token, advertises local projects, and forwards tool calls to the local GSD runtime. +The gateway is a routing layer. It does not host workspaces, clone source code, store `.gsd` artifacts, or run GSD workflows itself. The local runtime runs under `gsd-daemon cloud` or the `gsd-mcp-runtime` alias. After pairing, it stores the gateway URL, runtime ID, and encrypted device token in the daemon config. When it connects, it advertises local projects and optional local MCP tools, then forwards tool calls to the local GSD runtime. ## Gateway Requirements Run the gateway with Node 22 or newer. The gateway listens on port `8787` by default. -Set `GSD_CLOUD_USER_TOKEN` before starting the gateway. Remote MCP clients and pairing-code requests use this value as a bearer token. +`GSD_CLOUD_USER_TOKEN` is required at startup. It seeds the initial gateway user as an `admin` user with the `unlimited` plan. Use a long random value and treat it as a secret. + +For production, also configure persistent auth and usage stores. Without these paths, users, tokens, pairing codes, and usage counters are in memory only. + +```bash +export GSD_CLOUD_USER_TOKEN="$(openssl rand -hex 32)" +export GSD_CLOUD_ADMIN_TOKEN="$(openssl rand -hex 32)" + +gsd-cloud-mcp-gateway \ + --port 8787 \ + --auth-store /secure/path/gsd-cloud-auth.json \ + --usage-store /secure/path/gsd-cloud-usage.json +``` + +The process prints the listen URL and admin UI URL on startup. In local development, the default URL is `http://localhost:8787`. In production, put TLS and any public routing in front of the gateway, then give clients the public HTTPS URL. + +Equivalent environment variables are available for persistent stores: + +```bash +export GSD_CLOUD_AUTH_STORE_PATH=/secure/path/gsd-cloud-auth.json +export GSD_CLOUD_USAGE_STORE_PATH=/secure/path/gsd-cloud-usage.json +``` + +The auth store persists users, user tokens, device tokens, and pairing codes as salted scrypt-derived hashes. Raw bearer tokens and device tokens are not written to disk. + +## Admin Access + +Open `/admin` on the gateway and enter a bearer token. + +When `GSD_CLOUD_ADMIN_TOKEN` is set, `/admin/api/*` accepts only that dedicated operator token. When it is not set, `/admin/api/*` accepts bearer tokens for gateway users with the `admin` role. The startup seed user created from `GSD_CLOUD_USER_TOKEN` is an admin. + +The admin UI lets an operator: + +- create `member` and `admin` users +- assign `free`, `paid`, or `unlimited` plans +- set per-user quota overrides +- issue user bearer tokens and pairing codes +- revoke user tokens +- disable or re-enable users +- view connected runtimes and their advertised projects/tools +- inspect aggregate MCP usage and recent tool calls + +Use admin-issued user bearer tokens for `/mcp` clients and pairing-code creation. Do not expose `GSD_CLOUD_ADMIN_TOKEN` to MCP clients. + +## Register Users + +Public self-registration is disabled by default. To allow anonymous `POST /register` calls that create `member` users on the `free` plan and return a bearer token that is shown once, start the gateway with: + +```bash +gsd-cloud-mcp-gateway --allow-registration +# or +GSD_CLOUD_ALLOW_REGISTRATION=1 gsd-cloud-mcp-gateway +``` + +Registration requires an email in the request body: + +```bash +curl -sS -X POST "https://gateway.example.com/register" \ + -H "Content-Type: application/json" \ + -d '{"email":"user@example.com","name":"Example User"}' +``` + +Use the returned `userToken` as the bearer token for `/mcp` and `/pairing-codes`. + +## Clerk Account UI + +For public sign-up and sign-in, enable Clerk and send users to `/account`. Clerk authenticates the human user. The gateway still creates local gateway users and manages MCP bearer tokens, pairing codes, plans, quota overrides, and usage locally. ```bash -export GSD_CLOUD_USER_TOKEN="replace-with-a-long-random-token" -gsd-cloud-mcp-gateway --port 8787 +export CLERK_SECRET_KEY=sk_live_... +export CLERK_PUBLISHABLE_KEY=pk_live_... +# Optional: networkless JWT verification. +export CLERK_JWT_KEY='-----BEGIN PUBLIC KEY-----...' + +gsd-cloud-mcp-gateway \ + --auth-store /secure/path/gsd-cloud-auth.json \ + --usage-store /secure/path/gsd-cloud-usage.json ``` -The process prints the listen URL on startup. In local development, the default URL is `http://localhost:8787`. In production, put TLS and any public routing in front of the gateway, then give clients the public HTTPS URL. +If `CLERK_FRONTEND_API_URL` is not set, the gateway derives the ClerkJS script origin from `CLERK_PUBLISHABLE_KEY`. + +The `/account` page loads ClerkJS. Signed-in users can: + +- create MCP bearer tokens +- revoke their own MCP bearer tokens +- create local runtime pairing codes +- view their plan, billable usage, throttled attempts, and quota status + +On first authenticated Clerk access, the gateway creates a local `free` user linked by `clerkUserId`. MCP token verification remains local, so normal tool calls do not require a Clerk round trip. ## Pair a Local Runtime -First create a pairing code with the user token: +Create a pairing code with a gateway user token. This can be the seeded `GSD_CLOUD_USER_TOKEN`, an admin-created user token, a self-registration token, or a token created from `/account`. ```bash curl -sS -X POST "https://gateway.example.com/pairing-codes" \ @@ -44,7 +129,16 @@ gsd-daemon cloud pair \ --runtime-name "Laptop" ``` -Pairing saves the cloud runtime fields in the daemon config and enables cloud runtime mode. The stored device token is secret. Use the status command when you need to inspect the config safely: +You can use the standalone alias for the same runtime flow: + +```bash +gsd-mcp-runtime pair \ + --gateway "https://gateway.example.com" \ + --code "PAIRING_CODE" \ + --runtime-name "Laptop" +``` + +Pairing saves the cloud runtime fields in the daemon config and enables cloud runtime mode. The stored device token is secret and is written encrypted in current configs. Use the status command when you need to inspect the config safely: ```bash gsd-daemon cloud status @@ -66,25 +160,91 @@ gsd-daemon cloud connect --verbose The runtime connects to `/runtime/connect` on the gateway with the saved device token. HTTPS gateway URLs become secure WebSocket URLs automatically. If the connection drops, the runtime retries periodically. -The runtime advertises projects discovered by the daemon. Remote MCP callers can list the advertised projects with `gsd_cloud_projects`, then pass `projectAlias` or `runtimeId` when calling a forwarded GSD tool. +The runtime advertises projects discovered by the daemon. Remote MCP callers can list the advertised projects with `gsd_cloud_projects`, then pass `projectAlias` or `runtimeId` when calling a forwarded GSD tool. When more than one runtime is connected for the same user, callers must provide `runtimeId` or an unambiguous `projectAlias`. + +## Runtime-Advertised MCP Tools + +The gateway always lists `gsd_cloud_projects`, GSD session tools, and GSD workflow tools on `/mcp`. It also includes MCP tools advertised by connected local runtimes. + +By default, the local runtime tries to advertise `gsd-browser mcp` when `gsd-browser` is available on `PATH`: + +```bash +npm install -g @opengsd/gsd-browser +gsd-daemon cloud connect --verbose +``` + +Configure the browser MCP command explicitly: + +```bash +export GSD_CLOUD_BROWSER_MCP_COMMAND=gsd-browser +export GSD_CLOUD_BROWSER_MCP_ARGS=mcp +gsd-daemon cloud connect --verbose +``` + +Disable browser MCP advertisement: + +```bash +export GSD_CLOUD_BROWSER_MCP=0 +gsd-daemon cloud connect +``` + +Advertise additional stdio MCP servers from the same runtime with `GSD_CLOUD_MCP_SERVERS`: + +```bash +export GSD_CLOUD_MCP_SERVERS='[ + { "id": "gsd-browser", "command": "gsd-browser", "args": ["mcp"] } +]' +gsd-daemon cloud connect --verbose +``` + +Runtime-advertised tools are merged into the `/mcp` `tools/list` response. The gateway adds routing fields where needed and forwards calls to the connected runtime that advertised the project or matches the requested `runtimeId`. ## Configure a Remote MCP Client -Point the client at the gateway MCP endpoint and pass the user token as a bearer token: +Point the client at the gateway MCP endpoint and pass a gateway user token as a bearer token: ```text URL: https://gateway.example.com/mcp -Authorization: Bearer +Authorization: Bearer +``` + +The gateway forwards GSD session tools, GSD workflow tools, and runtime-advertised MCP tools to an online local runtime owned by the authenticated user. + +## Usage Store and Quotas + +The gateway records every `/mcp` `tools/call`, including forwarded GSD tools and runtime-advertised MCP tools. Usage records include user ID, tool name, optional runtime/project routing fields, status, duration, billable status, throttle status, and timestamp. + +Accepted MCP tool calls are billable and count toward user quotas. Throttled attempts are recorded as non-billable so a retry loop cannot keep increasing a user's quota counter after enforcement starts. + +Default plan limits are: + +- `free`: 12 calls/minute, 100 billable calls/day, 1,000 billable calls/month +- `paid`: 60 calls/minute, 2,000 billable calls/day, 50,000 billable calls/month +- `unlimited`: no quota checks + +Override plan defaults with environment variables. Set a value to `0` to make that dimension unlimited. + +```bash +export GSD_CLOUD_FREE_CALLS_PER_MINUTE=12 +export GSD_CLOUD_FREE_CALLS_PER_DAY=100 +export GSD_CLOUD_FREE_CALLS_PER_MONTH=1000 + +export GSD_CLOUD_PAID_CALLS_PER_MINUTE=60 +export GSD_CLOUD_PAID_CALLS_PER_DAY=2000 +export GSD_CLOUD_PAID_CALLS_PER_MONTH=50000 ``` -The gateway forwards GSD session tools and workflow tools to an online local runtime. When multiple runtimes are connected, provide `runtimeId` or `projectAlias` so the gateway can route the call. +When a user exceeds quota, `/mcp` returns a tool error such as `Usage limit exceeded`, the runtime tool call is not forwarded, and the denied attempt appears in the admin usage view as `Throttled`. ## Failure Expectations -- `401 Unauthorized`: the user token or device token is missing, invalid, or revoked. -- `400 Pairing code is invalid or expired`: the code was mistyped, already used, or expired. -- `No Local GSD Runtime is connected`: the gateway is running, but no paired runtime is online. +- `401 Unauthorized`: the user token, admin token, or device token is missing, invalid, revoked, or disabled. +- `403 Registration is disabled`: anonymous `POST /register` was attempted without registration enabled. +- `503 Clerk authentication is not configured`: `/account/api/*` was called without Clerk environment variables. +- `400 Pairing code is invalid or expired`: the code was mistyped, already used, superseded, or expired. +- `No Local GSD Runtime is connected`: the gateway is running, but no paired runtime is online for the authenticated user. - `runtimeId or projectAlias is required`: more than one runtime is online and the call did not identify a target. +- `Usage limit exceeded`: the user's minute, daily, or monthly quota denied the tool call before forwarding. - Tool call timeout: the runtime accepted the call but did not answer before the gateway timeout. -Treat user tokens and device tokens like passwords. Do not commit them to project files or paste them into issue trackers. +Treat user tokens, admin tokens, and device tokens like passwords. Do not commit them to project files or paste them into issue trackers. diff --git a/docs/user-docs/commands.md b/docs/user-docs/commands.md index 693b74a430..c1616b7adf 100644 --- a/docs/user-docs/commands.md +++ b/docs/user-docs/commands.md @@ -549,17 +549,23 @@ For an auto-mode run, call `gsd_execute` first with an absolute `projectDir`. It ## Cloud MCP Gateway Runtime -`gsd-cloud-mcp-gateway` starts an HTTP gateway for remote MCP clients. `gsd-daemon cloud` pairs and connects a local runtime to that gateway. +`gsd-cloud-mcp-gateway` starts an HTTP gateway for remote MCP clients. `gsd-daemon cloud` pairs and connects a local runtime to that gateway. The gateway exposes `/mcp` for MCP clients, `/admin` for operators, and optional `/account` self-service accounts when Clerk is configured. ```bash -GSD_CLOUD_USER_TOKEN="replace-with-a-long-random-token" gsd-cloud-mcp-gateway --port 8787 +GSD_CLOUD_USER_TOKEN="$(openssl rand -hex 32)" \ +GSD_CLOUD_ADMIN_TOKEN="$(openssl rand -hex 32)" \ +gsd-cloud-mcp-gateway \ + --port 8787 \ + --auth-store /secure/path/gsd-cloud-auth.json \ + --usage-store /secure/path/gsd-cloud-usage.json + gsd-daemon cloud status gsd-daemon cloud pair --gateway "https://gateway.example.com" --code "PAIRING_CODE" --runtime-name "Laptop" gsd-daemon cloud connect --verbose gsd-daemon cloud disconnect ``` -See [Cloud MCP Gateway](./cloud-mcp-gateway.md) for the full operator setup flow, token model, ports, and failure modes. +See [Cloud MCP Gateway](./cloud-mcp-gateway.md) for the full operator setup flow, token model, account registration, usage quotas, runtime-advertised MCP tools, ports, and failure modes. ## In-Session Update diff --git a/package.json b/package.json index 7efed04084..f6f8a9bd6e 100644 --- a/package.json +++ b/package.json @@ -159,6 +159,7 @@ "@anthropic-ai/vertex-sdk": "^0.14.4", "@aws-sdk/client-bedrock-runtime": "^3.983.0", "@clack/prompts": "^1.1.0", + "@clerk/backend": "^3.4.14", "@google/genai": "^1.40.0", "@mariozechner/jiti": "^2.6.2", "@mistralai/mistralai": "2.2.1", diff --git a/packages/cloud-mcp-gateway/README.md b/packages/cloud-mcp-gateway/README.md index 6d3249d09c..68d83148fb 100644 --- a/packages/cloud-mcp-gateway/README.md +++ b/packages/cloud-mcp-gateway/README.md @@ -4,18 +4,59 @@ Cloud-hosted MCP gateway for brokering remote MCP clients to a paired Local GSD The gateway is a live routing layer. It does not host workspaces, clone source code, store `.gsd` artifacts, or run GSD workflows itself. +## Hosting GSD + Browser MCPs + +Run the gateway on a public HTTPS host and pair a Local GSD Runtime from the machine that owns the workspaces and browser profile. Remote MCP clients connect to the gateway's Streamable HTTP endpoint at `/mcp`; tool calls are forwarded to the paired local runtime. + +The local runtime always advertises the GSD MCP tools. It also advertises `gsd-browser mcp` tools when `gsd-browser` is installed on `PATH`: + +```bash +npm install -g @opengsd/gsd-browser +gsd-mcp-runtime connect --verbose +``` + +To configure the browser MCP command explicitly: + +```bash +export GSD_CLOUD_BROWSER_MCP_COMMAND=gsd-browser +export GSD_CLOUD_BROWSER_MCP_ARGS=mcp +gsd-mcp-runtime connect --verbose +``` + +To disable browser MCP advertisement: + +```bash +export GSD_CLOUD_BROWSER_MCP=0 +gsd-mcp-runtime connect +``` + +To advertise additional stdio MCP servers from the same runtime: + +```bash +export GSD_CLOUD_MCP_SERVERS='[ + { "id": "gsd-browser", "command": "gsd-browser", "args": ["mcp"] } +]' +gsd-mcp-runtime connect --verbose +``` + +Keep `GSD_CLOUD_USER_TOKEN` private and require it as a bearer token for `/mcp`. Do not expose the local runtime websocket directly; it should only dial out to the gateway with its paired device token. + ## Local Smoke Test Build and start the gateway with persistent auth storage: ```bash export GSD_CLOUD_USER_TOKEN="$(openssl rand -hex 32)" +export GSD_CLOUD_ADMIN_TOKEN="$(openssl rand -hex 32)" npm run build -w @opengsd/cloud-mcp-gateway node packages/cloud-mcp-gateway/dist/cli.js \ --port 8787 \ - --auth-store ./.tmp/gsd-cloud-auth.json + --auth-store ./.tmp/gsd-cloud-auth.json \ + --usage-store ./.tmp/gsd-cloud-usage.json ``` +Open `http://localhost:8787/admin` and connect with `GSD_CLOUD_ADMIN_TOKEN`. If `GSD_CLOUD_ADMIN_TOKEN` is not set, the seeded admin user token from `GSD_CLOUD_USER_TOKEN` can access the admin API. + Create a pairing code: ```bash @@ -70,3 +111,96 @@ GSD_CLOUD_AUTH_STORE_PATH=/secure/path/gsd-cloud-auth.json node packages/cloud-m The auth store persists user tokens, device tokens, and pairing codes as salted scrypt-derived hashes. Raw bearer tokens and device tokens are not written to disk. `GSD_CLOUD_USER_TOKEN` seeds the initial user bearer token and is required at startup. + +## User Management + +The gateway serves a built-in management frontend at `/admin`. The UI lets an operator: + +- create users with `member` or `admin` roles +- assign `free`, `paid`, or `unlimited` usage plans +- issue user bearer tokens and pairing codes +- revoke user tokens +- disable or re-enable users +- view connected runtimes +- inspect aggregate MCP usage and recent tool calls + +Admin API routes live under `/admin/api/*` and require a bearer token. Set `GSD_CLOUD_ADMIN_TOKEN` for a dedicated operator secret: + +```bash +export GSD_CLOUD_ADMIN_TOKEN="$(openssl rand -hex 32)" +node packages/cloud-mcp-gateway/dist/cli.js --auth-store /secure/path/gsd-cloud-auth.json +``` + +When `GSD_CLOUD_ADMIN_TOKEN` is not set, only users with the `admin` role can call the admin API. The startup seed user is created as an admin. + +Public self-registration is disabled by default. To allow anonymous `POST /register` calls that create `member` users and return a one-time bearer token: + +```bash +node packages/cloud-mcp-gateway/dist/cli.js --allow-registration +# or +GSD_CLOUD_ALLOW_REGISTRATION=1 node packages/cloud-mcp-gateway/dist/cli.js +``` + +## Clerk User Accounts + +For public sign-up/sign-in, enable Clerk and send users to `/account`. Clerk authenticates the human user; the gateway still creates, hashes, revokes, throttles, and tallies MCP bearer tokens. + +```bash +export CLERK_SECRET_KEY=sk_live_... +export CLERK_PUBLISHABLE_KEY=pk_live_... +# Optional: networkless JWT verification. +export CLERK_JWT_KEY='-----BEGIN PUBLIC KEY-----...' + +node packages/cloud-mcp-gateway/dist/cli.js \ + --auth-store /secure/path/gsd-cloud-auth.json \ + --usage-store /secure/path/gsd-cloud-usage.json +``` + +The `/account` page loads ClerkJS, renders Clerk sign-in when the user is signed out, and renders a self-service token console when signed in. Users can: + +- create MCP bearer tokens +- revoke their own MCP bearer tokens +- create local runtime pairing codes +- view their plan, billable usage, throttled attempts, and quota status + +On first authenticated Clerk access, the gateway creates a local `free` user linked by `clerkUserId`. The local gateway user remains the source of truth for MCP tokens, pairing codes, plans, quota overrides, and usage. This keeps MCP token verification local and avoids checking every tool call against Clerk. + +If `CLERK_FRONTEND_API_URL` is not set, the gateway derives the ClerkJS script origin from `CLERK_PUBLISHABLE_KEY`. + +## Usage Tracking + +The gateway records every MCP `tools/call` request handled by `/mcp`, including forwarded GSD tools and runtime-advertised tools such as `gsd-browser mcp` tools. Usage records include user ID, tool name, optional runtime/project routing fields, status, duration, billable status, throttle status, and timestamp. + +By default, usage is in-memory. Persist aggregate daily counters and the bounded recent-call list with: + +```bash +node packages/cloud-mcp-gateway/dist/cli.js --usage-store /secure/path/gsd-cloud-usage.json +# or +GSD_CLOUD_USAGE_STORE_PATH=/secure/path/gsd-cloud-usage.json node packages/cloud-mcp-gateway/dist/cli.js +``` + +Accepted MCP tool calls are billable and count toward user quotas. Throttled attempts are still tallied, but they are recorded as non-billable so a client that keeps retrying does not make the user's quota counter climb after enforcement has started. + +## Free Account Throttling + +New self-registered users and users created with the default plan are `free`. The startup seed user is `unlimited` so operators do not lock themselves out while setting up the gateway. + +Default limits: + +- `free`: 12 calls/minute, 100 billable calls/day, 1,000 billable calls/month +- `paid`: 60 calls/minute, 2,000 billable calls/day, 50,000 billable calls/month +- `unlimited`: no quota checks + +Override the defaults with environment variables. Set a value to `0` to make that dimension unlimited: + +```bash +export GSD_CLOUD_FREE_CALLS_PER_MINUTE=12 +export GSD_CLOUD_FREE_CALLS_PER_DAY=100 +export GSD_CLOUD_FREE_CALLS_PER_MONTH=1000 + +export GSD_CLOUD_PAID_CALLS_PER_MINUTE=60 +export GSD_CLOUD_PAID_CALLS_PER_DAY=2000 +export GSD_CLOUD_PAID_CALLS_PER_MONTH=50000 +``` + +When a user exceeds quota, `/mcp` returns a tool error such as `Usage limit exceeded`, the runtime tool call is not forwarded, and the denied attempt appears in the admin usage view as `Throttled`. diff --git a/packages/cloud-mcp-gateway/package.json b/packages/cloud-mcp-gateway/package.json index 7946475250..10840aa0e1 100644 --- a/packages/cloud-mcp-gateway/package.json +++ b/packages/cloud-mcp-gateway/package.json @@ -28,6 +28,7 @@ "test": "pnpm run build && node --test dist/*.test.js" }, "dependencies": { + "@clerk/backend": "^3.4.14", "@modelcontextprotocol/sdk": "^1.27.1", "@opengsd/mcp-server": "workspace:*", "ws": "^8.20.0", diff --git a/packages/cloud-mcp-gateway/src/account-ui.ts b/packages/cloud-mcp-gateway/src/account-ui.ts new file mode 100644 index 0000000000..b902b56680 --- /dev/null +++ b/packages/cloud-mcp-gateway/src/account-ui.ts @@ -0,0 +1,583 @@ +import type { ClerkPublicConfig } from "./clerk-auth.js"; + +export function renderAccountUi(config: ClerkPublicConfig | undefined): string { + // Escape sequences that are legal in a JSON string but not in JS source inside + // an inline + ` + : ""; + return ` + + + + + GSD MCP Account + + ${clerkScripts} + + +
+
+
+
+
GSD Cloud MCP
+

My MCP Access

+
+
+
+ +
Loading
+ +
+
+
+ + + +
+ + +
+ +
+
+

Tokens

+ +
+
+ + + +
+
+
+
+
+ + + +`; +} + +function escapeHtml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} diff --git a/packages/cloud-mcp-gateway/src/admin-ui.ts b/packages/cloud-mcp-gateway/src/admin-ui.ts new file mode 100644 index 0000000000..017b70df2a --- /dev/null +++ b/packages/cloud-mcp-gateway/src/admin-ui.ts @@ -0,0 +1,810 @@ +export function renderAdminUi(): string { + return ` + + + + + GSD Cloud MCP Admin + + + +
+
+
+
+
GSD Cloud MCP
+

Users and Usage

+
+
+ + + + +
+
+ +
Disconnected
+ +
+ + + +
+ + +
+ +
+
+
+

User Registry

+ +
+
+ + + + + +
+
+
+ + + + +
+
+
+ + + +`; +} diff --git a/packages/cloud-mcp-gateway/src/auth-store.test.ts b/packages/cloud-mcp-gateway/src/auth-store.test.ts index 35f790f85b..9dc76706fd 100644 --- a/packages/cloud-mcp-gateway/src/auth-store.test.ts +++ b/packages/cloud-mcp-gateway/src/auth-store.test.ts @@ -6,10 +6,10 @@ import { test } from "node:test"; import { FileAuthStore, InMemoryAuthStore, extractBearerToken } from "./auth-store.js"; test("auth rejects missing, invalid, and revoked device tokens", () => { - const auth = new InMemoryAuthStore({ token: "user-token", userId: "u1" }); + const auth = new InMemoryAuthStore({ token: "u-cred", userId: "u1" }); assert.equal(auth.authenticateUser(undefined), null); assert.equal(auth.authenticateUser("bad"), null); - assert.equal(auth.authenticateUser("user-token"), "u1"); + assert.equal(auth.authenticateUser("u-cred"), "u1"); const { code } = auth.createPairingCode("u1"); const issued = auth.exchangePairingCode(code, "MacBook"); @@ -19,8 +19,41 @@ test("auth rejects missing, invalid, and revoked device tokens", () => { assert.equal(auth.authenticateDevice(issued.deviceToken), null); }); +test("auth creates managed users and revokable user tokens", () => { + const auth = new InMemoryAuthStore({ token: "adm", userId: "admin" }); + const user = auth.createUser({ name: "Ada Lovelace", email: "ada@example.com" }); + const issued = auth.issueUserToken(user.userId, { label: "cli" }); + + assert.equal(auth.getUser("admin")?.plan, "unlimited"); + assert.equal(user.plan, "free"); + assert.match(issued.userToken, /^gsd_usr_/); + assert.equal(auth.authenticateUser(issued.userToken), user.userId); + assert.deepEqual(auth.listUserTokens(user.userId).map((token) => ({ + tokenId: token.tokenId, + userId: token.userId, + label: token.label, + revoked: token.revoked, + })), [{ + tokenId: issued.tokenId, + userId: user.userId, + label: "cli", + revoked: undefined, + }]); + + assert.equal(auth.revokeUserTokenById(issued.tokenId), true); + assert.equal(auth.authenticateUser(issued.userToken), null); +}); + +test("disabled users cannot authenticate or pair runtimes", () => { + const auth = new InMemoryAuthStore({ token: "u-cred", userId: "u1" }); + auth.updateUser("u1", { disabled: true }); + + assert.equal(auth.authenticateUser("u-cred"), null); + assert.throws(() => auth.createPairingCode("u1"), /disabled user/); +}); + test("pairing code is one-time use", () => { - const auth = new InMemoryAuthStore({ token: "user-token", userId: "u1" }); + const auth = new InMemoryAuthStore({ token: "u-cred", userId: "u1" }); const { code } = auth.createPairingCode("u1"); auth.exchangePairingCode(code); assert.throws(() => auth.exchangePairingCode(code), /invalid or expired/); @@ -75,15 +108,15 @@ test("extractBearerToken parses bearer auth header", () => { test("file auth store persists user and device auth without raw tokens", () => { const dir = mkdtempSync(join(tmpdir(), "gsd-cloud-auth-")); const storePath = join(dir, "auth.json"); - const first = new FileAuthStore(storePath, { token: "user-token", userId: "u1" }); - assert.equal(first.authenticateUser("user-token"), "u1"); + const first = new FileAuthStore(storePath, { token: "u-cred", userId: "u1" }); + assert.equal(first.authenticateUser("u-cred"), "u1"); const { code } = first.createPairingCode("u1"); const issued = first.exchangePairingCode(code, "Laptop"); assert.equal(first.authenticateDevice(issued.deviceToken)?.runtimeName, "Laptop"); const raw = readFileSync(storePath, "utf8"); - assert.doesNotMatch(raw, /user-token/); + assert.doesNotMatch(raw, /u-cred/); assert.doesNotMatch(raw, new RegExp(issued.deviceToken)); assert.doesNotMatch(raw, new RegExp(code)); const snapshot = JSON.parse(raw) as { userTokens: Array> }; @@ -92,7 +125,7 @@ test("file auth store persists user and device auth without raw tokens", () => { assert.equal(snapshot.userTokens[0]?.tokenHash, undefined); const second = new FileAuthStore(storePath); - assert.equal(second.authenticateUser("user-token"), "u1"); + assert.equal(second.authenticateUser("u-cred"), "u1"); assert.equal(second.authenticateDevice(issued.deviceToken)?.runtimeId, issued.runtimeId); assert.equal(second.revokeDeviceToken(issued.deviceToken), true); @@ -103,7 +136,7 @@ test("file auth store persists user and device auth without raw tokens", () => { test("file auth store preserves unexchanged pairing codes across restart", () => { const dir = mkdtempSync(join(tmpdir(), "gsd-cloud-pairing-")); const storePath = join(dir, "auth.json"); - const first = new FileAuthStore(storePath, { token: "user-token", userId: "u1" }); + const first = new FileAuthStore(storePath, { token: "u-cred", userId: "u1" }); const { code } = first.createPairingCode("u1"); const second = new FileAuthStore(storePath); diff --git a/packages/cloud-mcp-gateway/src/auth-store.ts b/packages/cloud-mcp-gateway/src/auth-store.ts index 7847ac1112..aac2deb571 100644 --- a/packages/cloud-mcp-gateway/src/auth-store.ts +++ b/packages/cloud-mcp-gateway/src/auth-store.ts @@ -1,9 +1,54 @@ -import { randomBytes, randomUUID, scryptSync, timingSafeEqual } from "node:crypto"; +import { createHash, randomBytes, randomUUID, scryptSync, timingSafeEqual } from "node:crypto"; import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; import { dirname } from "node:path"; +export type UserRole = "admin" | "member"; +export type UserPlan = "free" | "paid" | "unlimited"; + +export interface UserQuotaOverrides { + callsPerMinute?: number; + callsPerDay?: number; + callsPerMonth?: number; +} + +export interface UserRecord { + userId: string; + clerkUserId?: string; + email?: string; + name?: string; + role: UserRole; + plan: UserPlan; + quotaOverrides?: UserQuotaOverrides; + createdAt: number; + lastSeenAt?: number; + disabled?: boolean; +} + +export interface CreateUserInput { + userId?: string; + clerkUserId?: string; + email?: string; + name?: string; + role?: UserRole; + plan?: UserPlan; + quotaOverrides?: UserQuotaOverrides; +} + export interface UserTokenRecord { userId: string; + tokenId: string; + label?: string; + createdAt: number; + lastUsedAt?: number; + revoked?: boolean; +} + +export interface PublicUserTokenRecord { + tokenId: string; + userId: string; + label?: string; + createdAt: number; + lastUsedAt?: number; revoked?: boolean; } @@ -11,6 +56,8 @@ export interface DeviceTokenRecord { userId: string; runtimeId: string; runtimeName?: string; + createdAt?: number; + lastUsedAt?: number; revoked?: boolean; } @@ -19,6 +66,12 @@ export interface PairingCodeRecord { expiresAt: number; } +export interface UserTokenIssue { + userId: string; + tokenId: string; + userToken: string; +} + export interface DeviceTokenIssue { userId: string; runtimeId: string; @@ -27,6 +80,7 @@ export interface DeviceTokenIssue { export interface AuthStoreSnapshot { version: 1; + users: UserRecord[]; userTokens: Array; deviceTokens: Array; pairingCodes: Array; @@ -37,39 +91,199 @@ export interface SecretHashRecord { secretSalt: string; } +interface SeedUserToken { + token: string; + userId: string; + email?: string; + name?: string; + role?: UserRole; + plan?: UserPlan; + label?: string; +} + +const USER_TOKEN_PREFIX = "gsd_usr_"; +const ACCESS_PERSIST_INTERVAL_MS = 60 * 1000; +const ACCESS_FLUSH_DELAY_MS = 5 * 1000; + export class InMemoryAuthStore { + protected readonly users = new Map(); protected readonly userTokens = new Map(); protected readonly deviceTokens = new Map(); protected readonly pairingCodes = new Map(); + private readonly lastAccessPersistedAt = new Map(); + // O(1) lookup acceleration for the hot auth paths. Maps a deterministic hash of + // the raw (high-entropy, random) token to the record's map key so authentication + // does not linear-scan and recompute salted scrypt for every stored token. The + // salted scrypt hash remains the actual verification; these indexes only narrow + // the candidate set, live in memory only (never persisted), and self-heal for + // snapshot-loaded tokens on their first authentication. + private readonly userTokenIndex = new Map(); + private readonly deviceTokenIndex = new Map(); - constructor(seedUserToken?: { token: string; userId: string }, snapshot?: AuthStoreSnapshot) { + constructor(seedUserToken?: SeedUserToken, snapshot?: AuthStoreSnapshot) { if (snapshot) this.loadSnapshot(snapshot); - if (seedUserToken) this.addUserToken(seedUserToken.token, seedUserToken.userId); + if (seedUserToken) { + this.upsertUser({ + userId: seedUserToken.userId, + email: seedUserToken.email, + name: seedUserToken.name, + role: seedUserToken.role ?? "admin", + plan: seedUserToken.plan ?? "unlimited", + }); + this.addUserToken(seedUserToken.token, seedUserToken.userId, { + label: seedUserToken.label ?? "seed", + }); + } + } + + createUser(input: CreateUserInput = {}): UserRecord { + const user = this.upsertUser({ + ...input, + userId: input.userId ?? `usr_${randomUUID()}`, + role: input.role ?? "member", + }); + this.afterMutation(); + return { ...user }; + } + + updateUser( + userId: string, + input: Partial>, + ): UserRecord { + const existing = this.users.get(userId); + if (!existing) throw new Error(`Unknown user: ${userId}`); + const next: UserRecord = { + ...existing, + ...(input.email !== undefined ? { email: cleanOptionalString(input.email) } : {}), + ...(input.name !== undefined ? { name: cleanOptionalString(input.name) } : {}), + ...(input.role !== undefined ? { role: normalizeRole(input.role) } : {}), + ...(input.plan !== undefined ? { plan: normalizePlan(input.plan) } : {}), + ...(input.quotaOverrides !== undefined + ? { quotaOverrides: optionalQuotaOverrides(input.quotaOverrides) } + : {}), + ...(input.disabled !== undefined ? { disabled: input.disabled } : {}), + }; + this.users.set(userId, next); + this.afterMutation(); + return { ...next }; + } + + listUsers(): UserRecord[] { + return Array.from(this.users.values()) + .map((user) => ({ ...user })) + .sort((a, b) => { + const aName = a.email ?? a.name ?? a.userId; + const bName = b.email ?? b.name ?? b.userId; + return aName.localeCompare(bName); + }); + } + + getUser(userId: string): UserRecord | undefined { + const user = this.users.get(userId); + return user ? { ...user } : undefined; + } + + getUserByClerkUserId(clerkUserId: string): UserRecord | undefined { + for (const user of this.users.values()) { + if (user.clerkUserId === clerkUserId) return { ...user }; + } + return undefined; + } + + listUserTokens(userId?: string): PublicUserTokenRecord[] { + return Array.from(this.userTokens.values()) + .filter((record) => !userId || record.userId === userId) + .map(({ tokenId, userId: recordUserId, label, createdAt, lastUsedAt, revoked }) => ({ + tokenId, + userId: recordUserId, + ...(label ? { label } : {}), + createdAt, + ...(lastUsedAt ? { lastUsedAt } : {}), + ...(revoked ? { revoked } : {}), + })) + .sort((a, b) => b.createdAt - a.createdAt); + } + + issueUserToken(userId: string, options: { label?: string } = {}): UserTokenIssue { + // Do not mint tokens for a disabled user: authenticateUser() rejects them, + // so the token could never be used, only confuse operators and bloat the + // persisted snapshot. Mirrors the createPairingCode() gate below. + const user = this.users.get(userId); + if (!user || user.disabled) throw new Error(`Unknown or disabled user: ${userId}`); + const userToken = `${USER_TOKEN_PREFIX}${randomBytes(32).toString("hex")}`; + const record = this.addUserToken(userToken, userId, options); + return { userId, tokenId: record.tokenId, userToken }; } - addUserToken(token: string, userId: string): void { - const existing = findSecretRecord(this.userTokens, token); - if (existing?.userId === userId && !existing.revoked) return; + addUserToken(token: string, userId: string, options: { label?: string } = {}): UserTokenRecord { + this.ensureUser(userId); + const existing = this.findIndexedEntry(this.userTokens, this.userTokenIndex, token)?.[1]; + if (existing) { + // The same raw token must never map to two users, and re-adding it must be + // idempotent: inserting a fresh salted hash would bloat the snapshot with a + // duplicate record that findSecretEntry() can never reach (auth matches the + // older entry first). Reuse the existing record instead. + if (existing.userId !== userId) { + throw new Error("User token is already assigned to a different user"); + } + let mutated = false; + if (existing.revoked) { + delete existing.revoked; + mutated = true; + } + const label = cleanOptionalString(options.label); + if (label && existing.label !== label) { + existing.label = label; + mutated = true; + } + if (mutated) this.afterMutation(); + return publicTokenRecord(existing); + } const key = deriveSecretHash(token); - this.userTokens.set(key.secretHash, { ...key, userId }); + const record: UserTokenRecord & SecretHashRecord = { + ...key, + userId, + tokenId: `tok_${randomUUID()}`, + ...(cleanOptionalString(options.label) ? { label: cleanOptionalString(options.label) } : {}), + createdAt: Date.now(), + }; + this.userTokens.set(key.secretHash, record); + this.userTokenIndex.set(lookupKeyFor(token), key.secretHash); this.afterMutation(); + return publicTokenRecord(record); } authenticateUser(token: string | undefined): string | null { if (!token) return null; - const record = findSecretRecord(this.userTokens, token); - if (!record || record.revoked) return null; + const entry = this.findIndexedEntry(this.userTokens, this.userTokenIndex, token); + if (!entry) return null; + const [secretHash, record] = entry; + const user = this.users.get(record.userId); + if (record.revoked || user?.disabled) return null; + const now = Date.now(); + record.lastUsedAt = now; + if (user) user.lastSeenAt = now; + this.persistAccessIfDue(secretHash, now); return record.userId; } authenticateDevice(token: string | undefined): DeviceTokenRecord | null { if (!token) return null; - const record = findSecretRecord(this.deviceTokens, token); - if (!record || record.revoked) return null; - return record; + const entry = this.findIndexedEntry(this.deviceTokens, this.deviceTokenIndex, token); + if (!entry) return null; + const [secretHash, record] = entry; + const user = this.users.get(record.userId); + if (record.revoked || user?.disabled) return null; + const now = Date.now(); + record.lastUsedAt = now; + if (user) user.lastSeenAt = now; + this.persistAccessIfDue(secretHash, now); + return publicDeviceTokenRecord(record); } createPairingCode(userId: string, ttlMs = 10 * 60 * 1000): { code: string; expiresAt: number } { + const user = this.users.get(userId); + if (!user || user.disabled) throw new Error(`Unknown or disabled user: ${userId}`); this.sweepExpiredPairingCodes(); // One live pairing code per user: a new code invalidates any prior un-redeemed // one, so the guessable set never grows beyond a single code per user (and the @@ -97,17 +311,40 @@ export class InMemoryAuthStore { throw new Error("Pairing code is invalid or expired"); } const [codeHash, record] = codeEntry; + const user = this.users.get(record.userId); + if (!user || user.disabled) { + this.pairingCodes.delete(codeHash); + this.afterMutation(); + throw new Error("Pairing code is invalid or expired"); + } this.pairingCodes.delete(codeHash); const runtimeId = `rt_${randomUUID()}`; const deviceToken = `gsd_dev_${randomBytes(32).toString("hex")}`; const key = deriveSecretHash(deviceToken); - this.deviceTokens.set(key.secretHash, { ...key, userId: record.userId, runtimeId, runtimeName }); + this.deviceTokens.set(key.secretHash, { + ...key, + userId: record.userId, + runtimeId, + runtimeName, + createdAt: Date.now(), + }); + this.deviceTokenIndex.set(lookupKeyFor(deviceToken), key.secretHash); this.afterMutation(); return { userId: record.userId, runtimeId, deviceToken }; } + revokeUserTokenById(tokenId: string): boolean { + for (const record of this.userTokens.values()) { + if (record.tokenId !== tokenId) continue; + record.revoked = true; + this.afterMutation(); + return true; + } + return false; + } + revokeDeviceToken(deviceToken: string): boolean { - const record = findSecretRecord(this.deviceTokens, deviceToken); + const record = this.findIndexedEntry(this.deviceTokens, this.deviceTokenIndex, deviceToken)?.[1]; if (!record) return false; record.revoked = true; this.afterMutation(); @@ -115,11 +352,19 @@ export class InMemoryAuthStore { } snapshot(): AuthStoreSnapshot { + // Return shallow copies so a caller can't mutate live in-memory auth records + // (and thus what gets persisted) by editing the returned snapshot. return { version: 1, - userTokens: Array.from(this.userTokens.values()), - deviceTokens: Array.from(this.deviceTokens.values()), - pairingCodes: Array.from(this.pairingCodes.values()), + // Deep-copy quotaOverrides so a caller can't mutate the nested override + // object on a live user record through the returned snapshot. + users: Array.from(this.users.values(), (record) => ({ + ...record, + ...(record.quotaOverrides ? { quotaOverrides: { ...record.quotaOverrides } } : {}), + })), + userTokens: Array.from(this.userTokens.values(), (record) => ({ ...record })), + deviceTokens: Array.from(this.deviceTokens.values(), (record) => ({ ...record })), + pairingCodes: Array.from(this.pairingCodes.values(), (record) => ({ ...record })), }; } @@ -127,6 +372,18 @@ export class InMemoryAuthStore { // Extension point for persistent stores. } + // Access-timestamp updates (lastUsedAt/lastSeenAt) are best-effort telemetry on + // the hot auth path. Persistent stores debounce these instead of doing a full + // synchronous snapshot write per authenticated request; the default falls back + // to afterMutation() so the in-memory store keeps its no-op behavior. + protected afterAccessMutation(): void { + this.afterMutation(); + } + + close(): void { + // No persistence to flush for the in-memory store. + } + // Drop expired pairing codes so a long-lived gateway does not accumulate codes // that were generated but never redeemed. Best-effort: callers persist via their // own afterMutation() after the create/exchange that triggered the sweep. @@ -136,15 +393,107 @@ export class InMemoryAuthStore { } } + private ensureUser(userId: string): void { + if (this.users.has(userId)) return; + this.users.set(userId, { + userId, + role: "member", + plan: "free", + createdAt: Date.now(), + }); + } + + private upsertUser(input: CreateUserInput & { userId: string }): UserRecord { + const existing = this.users.get(input.userId); + const role = normalizeRole(input.role ?? existing?.role ?? "member"); + const user: UserRecord = { + userId: input.userId, + ...(cleanOptionalString(input.clerkUserId ?? existing?.clerkUserId) + ? { clerkUserId: cleanOptionalString(input.clerkUserId ?? existing?.clerkUserId) } + : {}), + role, + plan: normalizePlan(input.plan ?? existing?.plan ?? (role === "admin" ? "unlimited" : "free")), + createdAt: existing?.createdAt ?? Date.now(), + ...(existing?.lastSeenAt ? { lastSeenAt: existing.lastSeenAt } : {}), + ...(existing?.disabled ? { disabled: existing.disabled } : {}), + ...(optionalQuotaOverrides(input.quotaOverrides ?? existing?.quotaOverrides) + ? { quotaOverrides: optionalQuotaOverrides(input.quotaOverrides ?? existing?.quotaOverrides) } + : {}), + ...(cleanOptionalString(input.email ?? existing?.email) ? { email: cleanOptionalString(input.email ?? existing?.email) } : {}), + ...(cleanOptionalString(input.name ?? existing?.name) ? { name: cleanOptionalString(input.name ?? existing?.name) } : {}), + }; + this.users.set(user.userId, user); + return user; + } + + // Resolve a token via its in-memory lookup index (O(1)) before falling back to a + // full scan. The salted scrypt hash is still the authority: an index hit is only + // trusted after secretMatches() verifies it. A miss (e.g. a snapshot-loaded token + // whose index entry was never built) scans once and back-fills the index so the + // next authentication is O(1). + private findIndexedEntry( + records: Map, + index: Map, + secret: string, + ): [string, T] | undefined { + const lookupKey = lookupKeyFor(secret); + const mappedKey = index.get(lookupKey); + if (mappedKey !== undefined) { + const record = records.get(mappedKey); + if (record && secretMatches(record, secret)) return [mappedKey, record]; + // Stale index entry (record removed or rotated): fall through to a scan. + } + const entry = findSecretEntry(records, secret); + if (entry) index.set(lookupKey, entry[0]); + return entry; + } + + private persistAccessIfDue(secretHash: string, now: number): void { + const lastPersistedAt = this.lastAccessPersistedAt.get(secretHash) ?? 0; + if (now - lastPersistedAt < ACCESS_PERSIST_INTERVAL_MS) return; + this.lastAccessPersistedAt.set(secretHash, now); + this.afterAccessMutation(); + } + private loadSnapshot(snapshot: AuthStoreSnapshot): void { + for (const record of snapshot.users ?? []) { + if (!record.userId) continue; + this.users.set(record.userId, { + userId: record.userId, + ...(typeof record.clerkUserId === "string" && record.clerkUserId.trim() + ? { clerkUserId: record.clerkUserId.trim() } + : {}), + role: normalizeRole(record.role), + plan: normalizePlan(record.plan ?? (normalizeRole(record.role) === "admin" ? "unlimited" : "free")), + createdAt: typeof record.createdAt === "number" ? record.createdAt : Date.now(), + ...(typeof record.email === "string" && record.email.trim() ? { email: record.email.trim() } : {}), + ...(typeof record.name === "string" && record.name.trim() ? { name: record.name.trim() } : {}), + ...(optionalQuotaOverrides(record.quotaOverrides) ? { quotaOverrides: optionalQuotaOverrides(record.quotaOverrides) } : {}), + ...(typeof record.lastSeenAt === "number" ? { lastSeenAt: record.lastSeenAt } : {}), + ...(record.disabled ? { disabled: true } : {}), + }); + } for (const record of snapshot.userTokens ?? []) { - this.userTokens.set(record.secretHash, record); + if (!record.userId) continue; + this.ensureUser(record.userId); + this.userTokens.set(record.secretHash, { + ...record, + tokenId: typeof record.tokenId === "string" ? record.tokenId : `tok_${randomUUID()}`, + createdAt: typeof record.createdAt === "number" ? record.createdAt : Date.now(), + ...(typeof record.label === "string" && record.label.trim() ? { label: record.label.trim() } : {}), + }); } for (const record of snapshot.deviceTokens ?? []) { - this.deviceTokens.set(record.secretHash, record); + if (!record.userId) continue; + this.ensureUser(record.userId); + this.deviceTokens.set(record.secretHash, { + ...record, + createdAt: typeof record.createdAt === "number" ? record.createdAt : Date.now(), + }); } for (const record of snapshot.pairingCodes ?? []) { if (record.expiresAt >= Date.now()) { + this.ensureUser(record.userId); this.pairingCodes.set(record.secretHash, record); } } @@ -153,21 +502,71 @@ export class InMemoryAuthStore { export class FileAuthStore extends InMemoryAuthStore { private readonly filePath: string; + private readonly accessFlushDelayMs: number; + private accessFlushTimer: ReturnType | undefined; constructor( filePath: string, - seedUserToken?: { token: string; userId: string }, + seedUserToken?: SeedUserToken, + options: { accessFlushDelayMs?: number } = {}, ) { super(undefined, readSnapshot(filePath)); this.filePath = filePath; - if (seedUserToken) this.addUserToken(seedUserToken.token, seedUserToken.userId); + this.accessFlushDelayMs = Math.max(0, options.accessFlushDelayMs ?? ACCESS_FLUSH_DELAY_MS); + if (seedUserToken) { + this.createUser({ + userId: seedUserToken.userId, + email: seedUserToken.email, + name: seedUserToken.name, + role: seedUserToken.role ?? "admin", + plan: seedUserToken.plan ?? "unlimited", + }); + this.addUserToken(seedUserToken.token, seedUserToken.userId, { + label: seedUserToken.label ?? "seed", + }); + } this.persist(); } protected override afterMutation(): void { + // A structural mutation writes the full snapshot (which already includes the + // latest access timestamps), so drop any pending access-only flush. + this.clearAccessFlush(); this.persist(); } + protected override afterAccessMutation(): void { + // Coalesce per-request access-timestamp writes into a single debounced flush + // so the hot auth path isn't blocked by a synchronous write+rename each time. + if (this.accessFlushTimer) return; + this.accessFlushTimer = setTimeout(() => { + this.accessFlushTimer = undefined; + // Access timestamps are best-effort telemetry. Swallow persist failures + // (disk full, permissions, etc.) here so they can't surface as an + // unhandled exception on the event loop and take down the gateway; a + // later structural mutation or close() re-persists the full snapshot, + // which already carries the latest access timestamps. + try { + this.persist(); + } catch { + // best-effort access-timestamp flush; ignore + } + }, this.accessFlushDelayMs); + this.accessFlushTimer.unref?.(); + } + + override close(): void { + if (!this.accessFlushTimer) return; + this.clearAccessFlush(); + this.persist(); + } + + private clearAccessFlush(): void { + if (!this.accessFlushTimer) return; + clearTimeout(this.accessFlushTimer); + this.accessFlushTimer = undefined; + } + private persist(): void { mkdirSync(dirname(this.filePath), { recursive: true }); const tmp = `${this.filePath}.${process.pid}.${Date.now()}.tmp`; @@ -201,8 +600,70 @@ export function deriveSecretHash(secret: string, secretSalt = randomBytes(16).to }; } -function findSecretRecord(records: Map, secret: string): T | undefined { - return findSecretEntry(records, secret)?.[1]; +function publicDeviceTokenRecord(record: DeviceTokenRecord & SecretHashRecord): DeviceTokenRecord { + // Never spread the stored record: it carries secretHash/secretSalt, and a raw + // spread makes it trivial to log or serialize hash material downstream. + return { + userId: record.userId, + runtimeId: record.runtimeId, + ...(record.runtimeName ? { runtimeName: record.runtimeName } : {}), + ...(record.createdAt !== undefined ? { createdAt: record.createdAt } : {}), + ...(record.lastUsedAt !== undefined ? { lastUsedAt: record.lastUsedAt } : {}), + ...(record.revoked ? { revoked: true } : {}), + }; +} + +function publicTokenRecord(record: UserTokenRecord): UserTokenRecord { + return { + userId: record.userId, + tokenId: record.tokenId, + ...(record.label ? { label: record.label } : {}), + createdAt: record.createdAt, + ...(record.lastUsedAt ? { lastUsedAt: record.lastUsedAt } : {}), + ...(record.revoked ? { revoked: true } : {}), + }; +} + +function normalizeRole(value: unknown): UserRole { + return value === "admin" ? "admin" : "member"; +} + +function normalizePlan(value: unknown): UserPlan { + if (value === "paid" || value === "unlimited") return value; + return "free"; +} + +function optionalQuotaOverrides(value: unknown): UserQuotaOverrides | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + const input = value as Partial>; + const overrides: UserQuotaOverrides = {}; + for (const key of ["callsPerMinute", "callsPerDay", "callsPerMonth"] as const) { + const normalized = normalizeLimit(input[key]); + if (normalized !== undefined) overrides[key] = normalized; + } + return Object.keys(overrides).length ? overrides : undefined; +} + +function normalizeLimit(value: unknown): number | undefined { + if (value === null || value === undefined || value === "") return undefined; + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed < 0) return undefined; + // 0 is the explicit "unlimited" sentinel; any positive value clamps to a + // minimum of 1 so fractional inputs (e.g. 0.5) don't floor to 0 and + // accidentally disable the quota, matching readLimit() in usage-limits.ts. + if (parsed === 0) return 0; + return Math.max(1, Math.floor(parsed)); +} + +function cleanOptionalString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +// Deterministic, fast index key over the raw (high-entropy, random) token. Used +// only to narrow the candidate set for an O(1) lookup; the per-record salted +// scrypt hash remains the actual verification, so this never weakens auth. +function lookupKeyFor(secret: string): string { + return createHash("sha256").update(secret).digest("hex"); } function findSecretEntry(records: Map, secret: string): [string, T] | undefined { @@ -224,6 +685,7 @@ function readSnapshot(filePath: string): AuthStoreSnapshot | undefined { if (parsed.version !== 1) return undefined; return { version: 1, + users: Array.isArray(parsed.users) ? parsed.users as UserRecord[] : [], userTokens: Array.isArray(parsed.userTokens) ? parsed.userTokens as AuthStoreSnapshot["userTokens"] : [], deviceTokens: Array.isArray(parsed.deviceTokens) ? parsed.deviceTokens as AuthStoreSnapshot["deviceTokens"] : [], pairingCodes: Array.isArray(parsed.pairingCodes) ? parsed.pairingCodes as AuthStoreSnapshot["pairingCodes"] : [], diff --git a/packages/cloud-mcp-gateway/src/clerk-auth.test.ts b/packages/cloud-mcp-gateway/src/clerk-auth.test.ts new file mode 100644 index 0000000000..769c07c7d7 --- /dev/null +++ b/packages/cloud-mcp-gateway/src/clerk-auth.test.ts @@ -0,0 +1,15 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { decodeClerkFrontendApiUrl } from "./clerk-auth.js"; + +test("decodes Clerk frontend API URL from publishable key", () => { + const encoded = Buffer.from("example.clerk.accounts.dev").toString("base64"); + assert.equal( + decodeClerkFrontendApiUrl(`pk_test_${encoded}$`), + "https://example.clerk.accounts.dev", + ); +}); + +test("rejects invalid Clerk publishable keys", () => { + assert.equal(decodeClerkFrontendApiUrl("not-a-key"), undefined); +}); diff --git a/packages/cloud-mcp-gateway/src/clerk-auth.ts b/packages/cloud-mcp-gateway/src/clerk-auth.ts new file mode 100644 index 0000000000..3daf9ed0c9 --- /dev/null +++ b/packages/cloud-mcp-gateway/src/clerk-auth.ts @@ -0,0 +1,80 @@ +import type { IncomingMessage } from "node:http"; +import { createClerkClient } from "@clerk/backend"; + +export interface ClerkPublicConfig { + publishableKey: string; + frontendApiUrl: string; +} + +export interface ClerkAuthenticatedUser { + clerkUserId: string; + sessionId?: string; +} + +export interface ClerkAuthenticator { + publicConfig: ClerkPublicConfig; + authenticate(req: IncomingMessage): Promise; +} + +export function createClerkAuthenticatorFromEnv( + env: Record = process.env, +): ClerkAuthenticator | undefined { + const secretKey = env.CLERK_SECRET_KEY; + const publishableKey = env.CLERK_PUBLISHABLE_KEY ?? env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY; + if (!secretKey || !publishableKey) return undefined; + const frontendApiUrl = env.CLERK_FRONTEND_API_URL ?? decodeClerkFrontendApiUrl(publishableKey); + if (!frontendApiUrl) return undefined; + + const client = createClerkClient({ + secretKey, + publishableKey, + }); + const jwtKey = env.CLERK_JWT_KEY; + + return { + publicConfig: { publishableKey, frontendApiUrl }, + async authenticate(req) { + const state = await client.authenticateRequest(toWebRequest(req), { + ...(jwtKey ? { jwtKey } : {}), + }); + if (!state.isAuthenticated) return null; + const auth = state.toAuth(); + return auth.userId + ? { + clerkUserId: auth.userId, + ...(auth.sessionId ? { sessionId: auth.sessionId } : {}), + } + : null; + }, + }; +} + +export function decodeClerkFrontendApiUrl(publishableKey: string): string | undefined { + const match = /^(pk_(?:test|live))_([^$]+)\$?$/.exec(publishableKey); + if (!match) return undefined; + try { + const decoded = Buffer.from(match[2]!, "base64").toString("utf8").replace(/\0/g, "").trim(); + if (!decoded) return undefined; + return /^https?:\/\//.test(decoded) ? decoded : `https://${decoded}`; + } catch { + return undefined; + } +} + +function toWebRequest(req: IncomingMessage): Request { + const host = Array.isArray(req.headers.host) ? req.headers.host[0] : req.headers.host; + const url = new URL(req.url ?? "/", `http://${host ?? "localhost"}`); + const headers = new Headers(); + for (const [name, value] of Object.entries(req.headers)) { + if (value === undefined) continue; + if (Array.isArray(value)) { + for (const item of value) headers.append(name, item); + } else { + headers.set(name, value); + } + } + return new Request(url, { + method: req.method ?? "GET", + headers, + }); +} diff --git a/packages/cloud-mcp-gateway/src/cli.ts b/packages/cloud-mcp-gateway/src/cli.ts index a823886a30..f4031d8d72 100644 --- a/packages/cloud-mcp-gateway/src/cli.ts +++ b/packages/cloud-mcp-gateway/src/cli.ts @@ -1,13 +1,84 @@ #!/usr/bin/env node +import { parseArgs } from "node:util"; import { listenGateway } from "./server.js"; -const portArg = process.argv.indexOf("--port"); -const authStoreArg = process.argv.indexOf("--auth-store"); -const port = portArg >= 0 ? Number(process.argv[portArg + 1]) : undefined; -const authStorePath = authStoreArg >= 0 ? process.argv[authStoreArg + 1] : undefined; +function parseCliArgs() { + return parseArgs({ + options: { + port: { type: "string" }, + host: { type: "string" }, + "auth-store": { type: "string" }, + "usage-store": { type: "string" }, + "allow-registration": { type: "boolean" }, + help: { type: "boolean", short: "h" }, + }, + allowPositionals: false, + strict: true, + }).values; +} -listenGateway({ port, authStorePath }).then(({ url }) => { +let values: ReturnType; +try { + values = parseCliArgs(); +} catch (err) { + // parseArgs throws on unknown flags or disallowed positionals; surface the + // gateway's standard fatal formatting instead of a raw Node stack trace. + process.stderr.write( + `[gsd-cloud-mcp-gateway] fatal: ${err instanceof Error ? err.message : String(err)}\n`, + ); + process.exit(1); +} + +if (values.help) { + process.stdout.write(`Usage: gsd-cloud-mcp-gateway [options] + +Options: + --host Host to bind. Defaults to 0.0.0.0. + --port Port to bind. Defaults to PORT or 8787. + --auth-store Persist users, hashed tokens, and pairing codes. + --usage-store Persist aggregate usage metrics and recent calls. + --allow-registration Enable public POST /register self-registration. + -h, --help Show this help. + +Environment: + GSD_CLOUD_USER_TOKEN Required seed admin bearer token. + GSD_CLOUD_ADMIN_TOKEN Optional separate admin UI/API bearer token. + GSD_CLOUD_AUTH_STORE_PATH Default auth store path. + GSD_CLOUD_USAGE_STORE_PATH Default usage store path. + CLERK_SECRET_KEY Enables Clerk-backed /account user auth. + CLERK_PUBLISHABLE_KEY Clerk publishable key for /account. + CLERK_JWT_KEY Optional Clerk JWT public key for networkless verification. + CLERK_FRONTEND_API_URL Optional override for ClerkJS script origin. + GSD_CLOUD_FREE_CALLS_PER_MINUTE Free-plan minute throttle. Default 12. + GSD_CLOUD_FREE_CALLS_PER_DAY Free-plan daily quota. Default 100. + GSD_CLOUD_FREE_CALLS_PER_MONTH Free-plan monthly quota. Default 1000. + GSD_CLOUD_PAID_CALLS_PER_MINUTE Paid-plan minute throttle. Default 60. + GSD_CLOUD_PAID_CALLS_PER_DAY Paid-plan daily quota. Default 2000. + GSD_CLOUD_PAID_CALLS_PER_MONTH Paid-plan monthly quota. Default 50000. +`); + process.exit(0); +} + +let port: number | undefined; +if (values.port) { + port = Number(values.port); + if (!Number.isInteger(port) || port < 1 || port > 65535) { + process.stderr.write( + `[gsd-cloud-mcp-gateway] fatal: invalid --port: ${JSON.stringify(values.port)}\n`, + ); + process.exit(1); + } +} + +listenGateway({ + port, + host: values.host, + authStorePath: values["auth-store"], + usageStorePath: values["usage-store"], + allowRegistration: values["allow-registration"], +}).then(({ url }) => { process.stderr.write(`[gsd-cloud-mcp-gateway] listening on ${url}\n`); + process.stderr.write(`[gsd-cloud-mcp-gateway] admin UI available at ${url}/admin\n`); }).catch((err) => { process.stderr.write(`[gsd-cloud-mcp-gateway] fatal: ${err instanceof Error ? err.message : String(err)}\n`); process.exit(1); diff --git a/packages/cloud-mcp-gateway/src/index.ts b/packages/cloud-mcp-gateway/src/index.ts index b0a209144b..62c2e3277c 100644 --- a/packages/cloud-mcp-gateway/src/index.ts +++ b/packages/cloud-mcp-gateway/src/index.ts @@ -1,10 +1,38 @@ export { FileAuthStore, InMemoryAuthStore, deriveSecretHash, extractBearerToken } from "./auth-store.js"; +export { createClerkAuthenticatorFromEnv, decodeClerkFrontendApiUrl } from "./clerk-auth.js"; +export { UsageLimiter, formatQuotaExceeded, parseUsageLimitConfig } from "./usage-limits.js"; +export { FileUsageStore, InMemoryUsageStore } from "./usage-store.js"; export { RuntimeRegistry } from "./runtime-registry.js"; export { CLOUD_GATEWAY_TOOL_NAMES, createGatewayMcpServer } from "./mcp.js"; export { createGatewayServer, listenGateway } from "./server.js"; +export type { + PublicUserTokenRecord, + UserPlan, + UserQuotaOverrides, + UserRecord, + UserRole, + UserTokenIssue, + UserTokenRecord, +} from "./auth-store.js"; +export type { + ClerkAuthenticatedUser, + ClerkAuthenticator, + ClerkPublicConfig, +} from "./clerk-auth.js"; +export type { + UsageLimitConfig, + UsageLimits, + UsageQuotaStatus, +} from "./usage-limits.js"; export type { CloudProjectRecord, GatewayToRuntimeMessage, RuntimeProject, RuntimeToGatewayMessage, } from "./protocol.js"; +export type { + UsageBucketRecord, + UsageEventRecord, + UsageSummary, + UsageToolCallInput, +} from "./usage-store.js"; diff --git a/packages/cloud-mcp-gateway/src/mcp.test.ts b/packages/cloud-mcp-gateway/src/mcp.test.ts index 7ca2728022..766a3e7014 100644 --- a/packages/cloud-mcp-gateway/src/mcp.test.ts +++ b/packages/cloud-mcp-gateway/src/mcp.test.ts @@ -1,8 +1,85 @@ import assert from "node:assert/strict"; import { test } from "node:test"; -import { CLOUD_GATEWAY_TOOL_NAMES } from "./mcp.js"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { buildGatewayToolList, CLOUD_GATEWAY_TOOL_NAMES, createGatewayMcpServer } from "./mcp.js"; +import { RuntimeRegistry } from "./runtime-registry.js"; +import { UsageLimiter } from "./usage-limits.js"; +import { InMemoryUsageStore } from "./usage-store.js"; test("gateway advertises the unified project graph MCP tool", () => { assert.ok(CLOUD_GATEWAY_TOOL_NAMES.includes("gsd_graph")); assert.equal(CLOUD_GATEWAY_TOOL_NAMES.some((name) => name.startsWith("gsd_graph_")), false); }); + +test("gateway includes runtime-advertised tools with routing fields", () => { + const tools = buildGatewayToolList([{ + name: "browser_navigate", + description: "Navigate", + inputSchema: { + type: "object", + properties: { url: { type: "string" } }, + required: ["url"], + }, + }]); + + const browserTool = tools.find((tool) => tool.name === "browser_navigate"); + assert.ok(browserTool); + assert.equal(browserTool.description, "Navigate"); + assert.deepEqual(browserTool.inputSchema.required, ["url"]); + assert.deepEqual(Object.keys(browserTool.inputSchema.properties ?? {}).sort(), [ + "projectAlias", + "runtimeId", + "url", + ]); +}); + +test("gateway does not let runtime tools shadow built-in GSD tools", () => { + const tools = buildGatewayToolList([{ + name: "gsd_status", + inputSchema: { type: "object", properties: { fake: { type: "boolean" } } }, + }]); + + const matches = tools.filter((tool) => tool.name === "gsd_status"); + assert.equal(matches.length, 1); + assert.equal("fake" in (matches[0]!.inputSchema.properties ?? {}), false); +}); + +test("gateway throttles MCP calls when a user exceeds quota", async () => { + const usage = new InMemoryUsageStore(); + const usageLimiter = new UsageLimiter({ + free: { callsPerMinute: 1 }, + paid: {}, + unlimited: {}, + }); + const server = createGatewayMcpServer({ + userId: "u1", + registry: new RuntimeRegistry(), + usage, + usageLimiter, + getUser: () => ({ + userId: "u1", + role: "member", + plan: "free", + createdAt: Date.now(), + }), + }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const client = new Client({ name: "quota-test", version: "0.0.1" }); + await server.connect(serverTransport); + await client.connect(clientTransport); + + const first = await client.callTool({ name: "gsd_cloud_projects", arguments: {} }); + assert.equal(first.isError, undefined); + + const second = await client.callTool({ name: "gsd_cloud_projects", arguments: {} }); + assert.equal(second.isError, true); + const text = (second.content as Array<{ type: string; text?: string }>)[0]?.text ?? ""; + assert.match(text, /Usage limit exceeded/); + + const summary = usage.getSummary(); + assert.equal(summary.billableCalls, 1); + assert.equal(summary.throttledCalls, 1); + await client.close(); + await server.close(); +}); diff --git a/packages/cloud-mcp-gateway/src/mcp.ts b/packages/cloud-mcp-gateway/src/mcp.ts index 4e22088be7..0f5e310170 100644 --- a/packages/cloud-mcp-gateway/src/mcp.ts +++ b/packages/cloud-mcp-gateway/src/mcp.ts @@ -1,6 +1,16 @@ -import { z } from "zod"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { + CallToolRequestSchema, + CallToolResultSchema, + ListToolsRequestSchema, + type CallToolResult, + type Tool, +} from "@modelcontextprotocol/sdk/types.js"; import type { RuntimeRegistry } from "./runtime-registry.js"; +import type { RuntimeToolDefinition, RuntimeToolInputSchema } from "./protocol.js"; +import type { UserRecord } from "./auth-store.js"; +import { formatQuotaExceeded, type UsageLimiter } from "./usage-limits.js"; +import type { InMemoryUsageStore } from "./usage-store.js"; import { WORKFLOW_TOOL_NAMES } from "@opengsd/mcp-server"; const SERVER_NAME = "gsd-cloud-gateway"; @@ -30,84 +40,250 @@ export const CLOUD_GATEWAY_TOOL_NAMES = [ ...WORKFLOW_TOOL_NAMES, ] as const; -const passthroughSchema = z.object({ - runtimeId: z.string().optional().describe("Connected Local GSD Runtime ID"), - projectAlias: z.string().optional().describe("Gateway project alias advertised by the Local GSD Runtime"), -}).passthrough(); +const BUILTIN_TOOL_NAMES = new Set(CLOUD_GATEWAY_TOOL_NAMES); + +const EMPTY_INPUT_SCHEMA: RuntimeToolInputSchema = { + type: "object", + properties: {}, +}; + +const ROUTING_PROPERTIES = { + runtimeId: { + type: "string", + description: "Connected Local GSD Runtime ID. Optional when only one runtime is connected.", + }, + projectAlias: { + type: "string", + description: "Gateway project alias advertised by the Local GSD Runtime.", + }, +} satisfies Record; + +const PASSTHROUGH_INPUT_SCHEMA: RuntimeToolInputSchema = { + type: "object", + properties: ROUTING_PROPERTIES, + additionalProperties: true, +}; export function createGatewayMcpServer(params: { userId: string; registry: RuntimeRegistry; -}): McpServer { - const server = new McpServer( + usage?: InMemoryUsageStore; + usageLimiter?: UsageLimiter; + getUser?: (userId: string) => UserRecord | undefined; +}): Server { + const server = new Server( { name: SERVER_NAME, version: SERVER_VERSION }, - { capabilities: { tools: {} } }, + { capabilities: { tools: { listChanged: true } } }, ); - server.registerTool( - CLOUD_PROJECTS_TOOL, + server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: buildGatewayToolList(params.registry.listTools(params.userId)), + })); + + server.setRequestHandler(CallToolRequestSchema, async (request, extra): Promise => { + const toolName = request.params.name; + const args = request.params.arguments ?? {}; + const startedAt = Date.now(); + + // A tool is "known" when it is a gateway built-in or a runtime-advertised + // tool. Built-ins short-circuit before the runtime tool-name lookup + // (listTools + per-call scan) to keep the hot path cheap. + const knownTool = BUILTIN_TOOL_NAMES.has(toolName) + || params.registry.listTools(params.userId).some((tool) => tool.name === toolName); + + // Enforce quota before dispatching so all tools/call traffic (including + // spammed, arbitrary tool names) is minute-throttled. Only known tools + // consume and reserve billable day/month quota; an unknown tool is + // rate-limited but must not deny legitimate calls near the day/month + // boundary. A billable pass reserves quota that must be released once the + // call settles. + const quota = enforceQuota(params, toolName, args, startedAt, knownTool); + if (quota.rejected) return quota.rejected; + + try { + if (toolName === CLOUD_PROJECTS_TOOL) { + const result = jsonToolResult({ projects: params.registry.listProjects(params.userId) }); + recordUsage(params.usage, params.userId, toolName, args, startedAt, true); + return result; + } + + if (!knownTool) { + recordUsage(params.usage, params.userId, toolName, args, startedAt, false, { + error: "unknown tool", + billable: false, + }); + return errorToolResult(`Unknown Cloud MCP Gateway tool: ${toolName}`); + } + + try { + const result = await params.registry.callTool({ + userId: params.userId, + toolName, + args, + // extra can be undefined in MCP request handlers, so read the abort + // signal optionally to avoid throwing (which would turn the call into a 500). + signal: extra?.signal, + }); + const coerced = coerceToolResult(result); + recordUsage(params.usage, params.userId, toolName, args, startedAt, coerced.isError !== true); + return coerced; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + recordUsage(params.usage, params.userId, toolName, args, startedAt, false, { error: message }); + return errorToolResult(message); + } + } finally { + if (quota.reserved) params.usageLimiter?.releaseBillable(params.userId, startedAt); + } + }); + + return server; +} + +export function buildGatewayToolList(runtimeTools: RuntimeToolDefinition[]): Tool[] { + const tools: Tool[] = [ { + name: CLOUD_PROJECTS_TOOL, description: "List projects currently advertised by connected Local GSD Runtimes.", - inputSchema: {}, + inputSchema: EMPTY_INPUT_SCHEMA, + annotations: { readOnlyHint: true, idempotentHint: true }, }, - async () => ({ - content: [{ - type: "text" as const, - text: JSON.stringify({ projects: params.registry.listProjects(params.userId) }, null, 2), - }], - }), - ); - + ]; const seen = new Set([CLOUD_PROJECTS_TOOL]); + for (const toolName of [...SESSION_TOOL_NAMES, ...WORKFLOW_TOOL_NAMES]) { if (seen.has(toolName)) continue; seen.add(toolName); - server.registerTool( - toolName, - { - description: `Forward ${toolName} to a connected Local GSD Runtime through the Cloud MCP Gateway.`, - inputSchema: passthroughSchema, - }, - async (args, extra) => { - try { - const result = await params.registry.callTool({ - userId: params.userId, - toolName, - args: args as Record, - signal: extra.signal, - }); - if (isMcpToolResult(result)) return result as never; - return { - content: [{ - type: "text" as const, - text: typeof result === "string" ? result : JSON.stringify(result, null, 2), - }], - }; - } catch (err) { - return { - isError: true, - content: [{ - type: "text" as const, - text: err instanceof Error ? err.message : String(err), - }], - }; - } + tools.push({ + name: toolName, + description: `Forward ${toolName} to a connected Local GSD Runtime through the Cloud MCP Gateway.`, + inputSchema: PASSTHROUGH_INPUT_SCHEMA, + }); + } + + for (const tool of runtimeTools) { + if (seen.has(tool.name)) continue; + seen.add(tool.name); + tools.push({ + ...tool, + description: tool.description + ?? `Forward ${tool.name} to a runtime-advertised MCP server through the Cloud MCP Gateway.`, + inputSchema: addRoutingFields(tool.inputSchema), + _meta: { + ...(tool._meta ?? {}), + "opengsd.forwarded": true, }, - ); + }); } - return server; + return tools; +} + +function addRoutingFields(schema: RuntimeToolInputSchema): RuntimeToolInputSchema { + const existingProperties = isRecord(schema.properties) ? schema.properties : {}; + const required = Array.isArray(schema.required) + ? schema.required.filter((item): item is string => typeof item === "string") + : undefined; + + return { + ...schema, + type: "object", + properties: { + ...existingProperties, + ...Object.fromEntries( + Object.entries(ROUTING_PROPERTIES).filter(([name]) => !Object.hasOwn(existingProperties, name)), + ), + }, + ...(required ? { required } : {}), + }; +} + +function coerceToolResult(value: unknown): CallToolResult { + const parsed = CallToolResultSchema.safeParse(value); + if (parsed.success) return parsed.data; + const serialized = typeof value === "string" ? value : JSON.stringify(value, null, 2); + return { + content: [{ + type: "text", + text: typeof serialized === "string" ? serialized : String(value), + }], + }; +} + +function jsonToolResult(value: unknown): CallToolResult { + return { content: [{ type: "text", text: JSON.stringify(value, null, 2) }] }; +} + +function errorToolResult(message: string): CallToolResult { + return { isError: true, content: [{ type: "text", text: message }] }; +} + +function recordUsage( + usage: InMemoryUsageStore | undefined, + userId: string, + toolName: string, + args: Record, + startedAt: number, + ok: boolean, + options: { + error?: string; + billable?: boolean; + throttled?: boolean; + } = {}, +): void { + if (!usage) return; + usage.recordToolCall({ + userId, + toolName, + startedAt, + durationMs: Date.now() - startedAt, + ok, + billable: options.billable, + throttled: options.throttled, + ...(typeof args.runtimeId === "string" ? { runtimeId: args.runtimeId } : {}), + // Only record an explicit alias. Falling back to args.projectDir would + // persist and display absolute local filesystem paths in the usage store. + ...(typeof args.projectAlias === "string" ? { projectAlias: args.projectAlias } : {}), + ...(options.error ? { error: options.error } : {}), + }); +} + +interface QuotaGate { + // Set when the request must be rejected (quota exceeded or unknown user). + rejected?: CallToolResult; + // True when check() accepted the call and reserved billable quota, so the + // caller must call usageLimiter.releaseBillable() once the call settles. + reserved: boolean; +} + +function enforceQuota( + params: { + userId: string; + usage?: InMemoryUsageStore; + usageLimiter?: UsageLimiter; + getUser?: (userId: string) => UserRecord | undefined; + }, + toolName: string, + args: Record, + startedAt: number, + // Whether this call is billable (a known tool). Non-billable calls are still + // minute-throttled by check() but skip the day/month billable gate/reservation. + billable: boolean, +): QuotaGate { + if (!params.usage || !params.usageLimiter || !params.getUser) return { reserved: false }; + const user = params.getUser(params.userId); + if (!user) return { rejected: errorToolResult(`Unknown user: ${params.userId}`), reserved: false }; + const status = params.usageLimiter.check(user, params.usage, startedAt, billable); + if (status.allowed) return { reserved: billable }; + const message = formatQuotaExceeded(status); + recordUsage(params.usage, params.userId, toolName, args, startedAt, false, { + error: message, + billable: false, + throttled: true, + }); + return { rejected: errorToolResult(message), reserved: false }; } -function isMcpToolResult(value: unknown): value is { - content: Array<{ type: "text"; text: string }>; - isError?: boolean; - structuredContent?: unknown; -} { - return !!value - && typeof value === "object" - && Array.isArray((value as { content?: unknown }).content) - && (value as { content: Array<{ type?: unknown; text?: unknown }> }).content.every( - (item) => item.type === "text" && typeof item.text === "string", - ); +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); } diff --git a/packages/cloud-mcp-gateway/src/protocol.ts b/packages/cloud-mcp-gateway/src/protocol.ts index 123e2ebcfb..3bbdffb335 100644 --- a/packages/cloud-mcp-gateway/src/protocol.ts +++ b/packages/cloud-mcp-gateway/src/protocol.ts @@ -6,11 +6,35 @@ export interface RuntimeProject { markers?: string[]; } +export interface RuntimeToolInputSchema { + type: "object"; + properties?: Record; + required?: string[]; + [key: string]: unknown; +} + +export interface RuntimeToolDefinition { + name: string; + title?: string; + description?: string; + inputSchema: RuntimeToolInputSchema; + outputSchema?: RuntimeToolInputSchema; + annotations?: { + title?: string; + readOnlyHint?: boolean; + destructiveHint?: boolean; + idempotentHint?: boolean; + openWorldHint?: boolean; + }; + _meta?: Record; +} + export interface RuntimeHelloMessage { type: "hello"; runtimeId: string; runtimeName?: string; projects: RuntimeProject[]; + tools?: RuntimeToolDefinition[]; } export interface RuntimeProjectsMessage { @@ -19,6 +43,12 @@ export interface RuntimeProjectsMessage { projects: RuntimeProject[]; } +export interface RuntimeToolsMessage { + type: "tools"; + runtimeId?: string; + tools: RuntimeToolDefinition[]; +} + export interface RuntimeHeartbeatMessage { type: "heartbeat"; runtimeId?: string; @@ -50,6 +80,7 @@ export type GatewayToRuntimeMessage = RuntimeToolCallMessage | RuntimeCancelMess export type RuntimeToGatewayMessage = | RuntimeHelloMessage | RuntimeProjectsMessage + | RuntimeToolsMessage | RuntimeHeartbeatMessage | RuntimeToolResultMessage; diff --git a/packages/cloud-mcp-gateway/src/runtime-registry.test.ts b/packages/cloud-mcp-gateway/src/runtime-registry.test.ts index 65da3ffd2f..7a5b96d99e 100644 --- a/packages/cloud-mcp-gateway/src/runtime-registry.test.ts +++ b/packages/cloud-mcp-gateway/src/runtime-registry.test.ts @@ -23,14 +23,28 @@ test("runtime registry tracks project advertisements and disconnects", () => { type: "hello", runtimeId: "rt1", projects: [{ alias: "app", repoIdentity: "abc123" }], + tools: [{ + name: "browser_navigate", + description: "Navigate the browser", + inputSchema: { type: "object", properties: { url: { type: "string" } }, required: ["url"] }, + }], })); assert.deepEqual(registry.listProjects("u1").map((p) => ({ alias: p.alias, runtimeId: p.runtimeId, online: p.online, })), [{ alias: "app", runtimeId: "rt1", online: true }]); + assert.deepEqual(registry.listTools("u1").map((tool) => tool.name), ["browser_navigate"]); + assert.deepEqual(registry.listRuntimeSummaries("u1").map((runtime) => ({ + runtimeId: runtime.runtimeId, + projectCount: runtime.projectCount, + toolCount: runtime.toolCount, + online: runtime.online, + })), [{ runtimeId: "rt1", projectCount: 1, toolCount: 1, online: true }]); socket.close(); assert.deepEqual(registry.listProjects("u1"), []); + assert.deepEqual(registry.listTools("u1"), []); + assert.deepEqual(registry.listRuntimeSummaries("u1"), []); }); test("runtime registry fails fast when no runtime is online", async () => { @@ -163,3 +177,27 @@ test("socket close event calls onOffline callback", () => { assert.deepEqual(offlineCalls, ["rt1"], "onOffline should be called on socket close"); }); + +test("runtime registry accepts later tool advertisements", () => { + const registry = new RuntimeRegistry(); + const socket = new FakeSocket(); + registry.attachRuntime({ userId: "u1", runtimeId: "rt1", socket: socket as never }); + + socket.emit("message", JSON.stringify({ + type: "tools", + runtimeId: "rt1", + tools: [ + { name: "browser_snapshot", inputSchema: { type: "object" } }, + { name: "", inputSchema: { type: "object" } }, + { name: "broken_schema", inputSchema: { type: "string" } }, + ], + })); + + assert.deepEqual(registry.listTools("u1").map((tool) => ({ + name: tool.name, + inputSchema: tool.inputSchema, + })), [ + { name: "broken_schema", inputSchema: { type: "object", properties: {} } }, + { name: "browser_snapshot", inputSchema: { type: "object" } }, + ]); +}); diff --git a/packages/cloud-mcp-gateway/src/runtime-registry.ts b/packages/cloud-mcp-gateway/src/runtime-registry.ts index fbc0aa82a9..0e41c4de49 100644 --- a/packages/cloud-mcp-gateway/src/runtime-registry.ts +++ b/packages/cloud-mcp-gateway/src/runtime-registry.ts @@ -4,6 +4,8 @@ import type { CloudProjectRecord, GatewayToRuntimeMessage, RuntimeProject, + RuntimeToolDefinition, + RuntimeToolInputSchema, RuntimeToGatewayMessage, } from "./protocol.js"; import { isRecord } from "./protocol.js"; @@ -14,6 +16,19 @@ interface RuntimeConnection { runtimeName?: string; socket: WebSocket; projects: RuntimeProject[]; + tools: RuntimeToolDefinition[]; + lastSeenAt: number; +} + +export interface RuntimeSummary { + runtimeId: string; + userId: string; + runtimeName?: string; + online: boolean; + projectCount: number; + toolCount: number; + projects: RuntimeProject[]; + tools: string[]; lastSeenAt: number; } @@ -64,6 +79,7 @@ export class RuntimeRegistry { runtimeName: params.runtimeName, socket: params.socket, projects: [], + tools: [], lastSeenAt: Date.now(), }; this.runtimes.set(params.runtimeId, runtime); @@ -104,6 +120,37 @@ export class RuntimeRegistry { return rows.sort((a, b) => a.alias.localeCompare(b.alias)); } + listTools(userId: string): RuntimeToolDefinition[] { + const tools: RuntimeToolDefinition[] = []; + const seen = new Set(); + for (const runtime of this.runtimes.values()) { + if (runtime.userId !== userId) continue; + for (const tool of runtime.tools) { + if (seen.has(tool.name)) continue; + seen.add(tool.name); + tools.push(tool); + } + } + return tools.sort((a, b) => a.name.localeCompare(b.name)); + } + + listRuntimeSummaries(userId?: string): RuntimeSummary[] { + return Array.from(this.runtimes.values()) + .filter((runtime) => !userId || runtime.userId === userId) + .map((runtime) => ({ + runtimeId: runtime.runtimeId, + userId: runtime.userId, + ...(runtime.runtimeName ? { runtimeName: runtime.runtimeName } : {}), + online: true, + projectCount: runtime.projects.length, + toolCount: runtime.tools.length, + projects: runtime.projects.map((project) => ({ ...project })), + tools: runtime.tools.map((tool) => tool.name).sort((a, b) => a.localeCompare(b)), + lastSeenAt: runtime.lastSeenAt, + })) + .sort((a, b) => b.lastSeenAt - a.lastSeenAt); + } + async callTool(call: GatewayToolCall): Promise { const target = this.resolveTarget(call.userId, call.args); const projectKey = `${target.runtime.runtimeId}:${target.projectAlias ?? "__runtime__"}`; @@ -226,12 +273,17 @@ export class RuntimeRegistry { if (message.type === "hello" && runtime) { runtime.runtimeName = message.runtimeName ?? runtime.runtimeName; runtime.projects = message.projects; + runtime.tools = normalizeRuntimeTools(message.tools); return; } if (message.type === "projects" && runtime) { runtime.projects = message.projects; return; } + if (message.type === "tools" && runtime) { + runtime.tools = normalizeRuntimeTools(message.tools); + return; + } if (message.type === "tool_result") { const pending = this.pending.get(message.requestId); if (!pending) return; @@ -266,3 +318,63 @@ export class RuntimeRegistry { this.options.onOffline?.(runtimeId); } } + +function normalizeRuntimeTools(value: unknown): RuntimeToolDefinition[] { + if (!Array.isArray(value)) return []; + const tools: RuntimeToolDefinition[] = []; + const seen = new Set(); + for (const item of value) { + if (!isRecord(item) || typeof item.name !== "string" || !item.name.trim()) continue; + const name = item.name.trim(); + if (seen.has(name)) continue; + seen.add(name); + tools.push({ + name, + ...(typeof item.title === "string" ? { title: item.title } : {}), + ...(typeof item.description === "string" ? { description: item.description } : {}), + inputSchema: normalizeInputSchema(item.inputSchema), + ...(isInputSchema(item.outputSchema) ? { outputSchema: normalizeInputSchema(item.outputSchema) } : {}), + ...(isRecord(item.annotations) ? { annotations: normalizeAnnotations(item.annotations) } : {}), + ...(isRecord(item._meta) ? { _meta: item._meta } : {}), + }); + } + return tools; +} + +function normalizeInputSchema(value: unknown): RuntimeToolInputSchema { + if (isInputSchema(value)) { + // Strip properties/required from the spread so malformed values from an + // untrusted runtime (e.g. `properties: true`, `required: "x"`) can't leak + // into the normalized schema or throw; always re-derive them as safe shapes. + const { properties, required, ...rest } = value; + return { + ...rest, + type: "object", + ...(isRecord(properties) ? { properties: normalizeProperties(properties) } : {}), + ...(Array.isArray(required) + ? { required: required.filter((item): item is string => typeof item === "string") } + : {}), + }; + } + return { type: "object", properties: {} }; +} + +function isInputSchema(value: unknown): value is RuntimeToolInputSchema { + return isRecord(value) && value.type === "object"; +} + +function normalizeProperties(value: Record): Record { + return Object.fromEntries( + Object.entries(value).filter((entry): entry is [string, object] => isRecord(entry[1])), + ); +} + +function normalizeAnnotations(value: Record): NonNullable { + return { + ...(typeof value.title === "string" ? { title: value.title } : {}), + ...(typeof value.readOnlyHint === "boolean" ? { readOnlyHint: value.readOnlyHint } : {}), + ...(typeof value.destructiveHint === "boolean" ? { destructiveHint: value.destructiveHint } : {}), + ...(typeof value.idempotentHint === "boolean" ? { idempotentHint: value.idempotentHint } : {}), + ...(typeof value.openWorldHint === "boolean" ? { openWorldHint: value.openWorldHint } : {}), + }; +} diff --git a/packages/cloud-mcp-gateway/src/server.test.ts b/packages/cloud-mcp-gateway/src/server.test.ts index a7d5ba07ca..9a24306e9f 100644 --- a/packages/cloud-mcp-gateway/src/server.test.ts +++ b/packages/cloud-mcp-gateway/src/server.test.ts @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; import { EventEmitter } from "node:events"; import { test } from "node:test"; +import type { ClerkAuthenticator } from "./clerk-auth.js"; import { createGatewayServer } from "./server.js"; test("gateway requires an explicit user bearer token", () => { @@ -15,7 +16,7 @@ test("gateway requires an explicit user bearer token", () => { }); test("gateway does not expose unexpected error details in HTTP responses", async () => { - const { server, auth } = createGatewayServer({ userToken: "user-token" }); + const { server, auth } = createGatewayServer({ userToken: "u-cred" }); auth.authenticateUser = () => { throw new Error("stack detail: secret-token"); }; @@ -23,18 +24,18 @@ test("gateway does not expose unexpected error details in HTTP responses", async const response = await dispatch(server, { method: "POST", url: "/pairing-codes", - headers: { authorization: "Bearer user-token" }, + headers: { authorization: "Bearer u-cred" }, }); assert.equal(response.status, 500); assert.deepEqual(JSON.parse(response.body) as unknown, { error: "Internal server error" }); }); test("gateway reports invalid JSON as a client error", async () => { - const { server } = createGatewayServer({ userToken: "user-token" }); + const { server } = createGatewayServer({ userToken: "u-cred" }); const response = await dispatch(server, { method: "POST", url: "/mcp", - headers: { authorization: "Bearer user-token" }, + headers: { authorization: "Bearer u-cred" }, chunks: ["{"], }); assert.equal(response.status, 400); @@ -42,19 +43,233 @@ test("gateway reports invalid JSON as a client error", async () => { }); test("gateway rejects oversized JSON request bodies", async () => { - const { server } = createGatewayServer({ userToken: "user-token" }); + const { server } = createGatewayServer({ userToken: "u-cred" }); const response = await dispatch(server, { method: "POST", url: "/mcp", - headers: { authorization: "Bearer user-token" }, + headers: { authorization: "Bearer u-cred" }, chunks: [`{"value":"${"a".repeat(1024 * 1024)}"}`], }); assert.equal(response.status, 400); assert.deepEqual(JSON.parse(response.body) as unknown, { error: "Request body too large" }); }); +test("gateway serves the management frontend", async () => { + const { server } = createGatewayServer({ userToken: "u-cred" }); + const response = await dispatch(server, { + method: "GET", + url: "/admin", + headers: {}, + }); + + assert.equal(response.status, 200); + assert.match(response.body, /Users and Usage/); + assert.match(response.body, /admin\/api\/users/); +}); + +test("gateway serves the Clerk account frontend", async () => { + const { server } = createGatewayServer({ + userToken: "u-cred", + clerkAuth: fakeClerkAuth(), + }); + const response = await dispatch(server, { + method: "GET", + url: "/account", + headers: {}, + }); + + assert.equal(response.status, 200); + assert.match(response.body, /My MCP Access/); + assert.match(response.body, /clerk-js@6/); +}); + +test("account API requires Clerk authentication", async () => { + const { server } = createGatewayServer({ + userToken: "u-cred", + clerkAuth: fakeClerkAuth(), + }); + const response = await dispatch(server, { + method: "GET", + url: "/account/api/me", + headers: {}, + }); + + assert.equal(response.status, 401); +}); + +test("account API syncs Clerk users and manages their tokens", async () => { + const { server, auth } = createGatewayServer({ + userToken: "adm", + clerkAuth: fakeClerkAuth(), + }); + + const me = await dispatch(server, { + method: "GET", + url: "/account/api/me", + headers: { authorization: "Bearer clerk-session-u1" }, + }); + assert.equal(me.status, 200); + const meBody = JSON.parse(me.body) as { user: { userId: string; clerkUserId: string; plan: string }; tokens: unknown[] }; + assert.equal(meBody.user.clerkUserId, "clerk_u1"); + assert.equal(meBody.user.plan, "free"); + assert.deepEqual(meBody.tokens, []); + + const created = await dispatch(server, { + method: "POST", + url: "/account/api/tokens", + headers: { authorization: "Bearer clerk-session-u1" }, + chunks: [JSON.stringify({ label: "Claude" })], + }); + assert.equal(created.status, 201); + const createdBody = JSON.parse(created.body) as { tokenId: string; userToken: string }; + assert.match(createdBody.userToken, /^gsd_usr_/); + assert.equal(auth.authenticateUser(createdBody.userToken), meBody.user.userId); + + const revoked = await dispatch(server, { + method: "POST", + url: `/account/api/tokens/${encodeURIComponent(createdBody.tokenId)}/revoke`, + headers: { authorization: "Bearer clerk-session-u1" }, + }); + assert.equal(revoked.status, 200); + assert.equal(auth.authenticateUser(createdBody.userToken), null); +}); + +test("account API cannot revoke another Clerk user's token", async () => { + const { server } = createGatewayServer({ + userToken: "adm", + clerkAuth: fakeClerkAuth(), + }); + const created = await dispatch(server, { + method: "POST", + url: "/account/api/tokens", + headers: { authorization: "Bearer clerk-session-u1" }, + chunks: [JSON.stringify({ label: "Mine" })], + }); + const createdBody = JSON.parse(created.body) as { tokenId: string }; + + const blocked = await dispatch(server, { + method: "POST", + url: `/account/api/tokens/${encodeURIComponent(createdBody.tokenId)}/revoke`, + headers: { authorization: "Bearer clerk-session-u2" }, + }); + assert.equal(blocked.status, 404); +}); + +test("account API creates pairing codes for the Clerk user", async () => { + const { server, auth } = createGatewayServer({ + userToken: "adm", + clerkAuth: fakeClerkAuth(), + }); + const response = await dispatch(server, { + method: "POST", + url: "/account/api/pairing-codes", + headers: { authorization: "Bearer clerk-session-u1" }, + }); + assert.equal(response.status, 201); + const body = JSON.parse(response.body) as { code: string }; + const issued = auth.exchangePairingCode(body.code); + assert.equal(issued.userId, "clerk_clerk_u1"); +}); + +test("admin API requires admin bearer token when configured", async () => { + const { server } = createGatewayServer({ userToken: "u-cred", adminToken: "adm" }); + + const rejected = await dispatch(server, { + method: "GET", + url: "/admin/api/overview", + headers: { authorization: "Bearer u-cred" }, + }); + assert.equal(rejected.status, 401); + + const accepted = await dispatch(server, { + method: "GET", + url: "/admin/api/overview", + headers: { authorization: "Bearer adm" }, + }); + assert.equal(accepted.status, 200); + assert.equal((JSON.parse(accepted.body) as { totalUsers: number }).totalUsers, 1); +}); + +test("admin API creates users and returns raw user tokens once", async () => { + const { server, auth } = createGatewayServer({ userToken: "adm" }); + const response = await dispatch(server, { + method: "POST", + url: "/admin/api/users", + headers: { authorization: "Bearer adm" }, + chunks: [JSON.stringify({ name: "Ada Lovelace", email: "ada@example.com", issueToken: true })], + }); + + assert.equal(response.status, 201); + const body = JSON.parse(response.body) as { user: { userId: string; plan: string }; userToken: string; tokenId: string }; + assert.equal(body.user.plan, "free"); + assert.equal(auth.authenticateUser(body.userToken), body.user.userId); + assert.match(body.userToken, /^gsd_usr_/); + assert.match(body.tokenId, /^tok_/); + + const users = await dispatch(server, { + method: "GET", + url: "/admin/api/users", + headers: { authorization: "Bearer adm" }, + }); + assert.equal(users.status, 200); + assert.equal((JSON.parse(users.body) as { users: unknown[] }).users.length, 2); +}); + +test("public registration is opt-in", async () => { + const disabled = createGatewayServer({ userToken: "adm" }); + const rejected = await dispatch(disabled.server, { + method: "POST", + url: "/register", + headers: {}, + chunks: [JSON.stringify({ email: "new@example.com" })], + }); + assert.equal(rejected.status, 403); + + const enabled = createGatewayServer({ userToken: "adm", allowRegistration: true }); + const accepted = await dispatch(enabled.server, { + method: "POST", + url: "/register", + headers: {}, + chunks: [JSON.stringify({ email: "new@example.com", name: "New User" })], + }); + assert.equal(accepted.status, 201); + const body = JSON.parse(accepted.body) as { user: { userId: string; plan: string }; userToken: string }; + assert.equal(body.user.plan, "free"); + assert.equal(enabled.auth.authenticateUser(body.userToken), body.user.userId); +}); + +test("admin overview includes usage totals", async () => { + const { server, usage } = createGatewayServer({ userToken: "adm" }); + usage.recordToolCall({ + userId: "local-user", + toolName: "gsd_status", + durationMs: 15, + ok: true, + }); + usage.recordToolCall({ + userId: "local-user", + toolName: "gsd_status", + durationMs: 1, + ok: false, + billable: false, + throttled: true, + }); + + const response = await dispatch(server, { + method: "GET", + url: "/admin/api/overview", + headers: { authorization: "Bearer adm" }, + }); + assert.equal(response.status, 200); + assert.deepEqual(projectOverview(JSON.parse(response.body)), { + totalCalls: 2, + billableCalls: 1, + throttledCalls: 1, + }); +}); + test("runtime websocket rejects device tokens supplied in the URL query", () => { - const { server, auth } = createGatewayServer({ userToken: "user-token" }); + const { server, auth } = createGatewayServer({ userToken: "u-cred" }); const { code } = auth.createPairingCode("local-user"); const issued = auth.exchangePairingCode(code, "Laptop"); const socket = new MockUpgradeSocket(); @@ -137,3 +352,27 @@ class MockUpgradeSocket extends EventEmitter { this.destroyed = true; } } + +function fakeClerkAuth(): ClerkAuthenticator { + return { + publicConfig: { + publishableKey: "pk_test_ZXhhbXBsZS5jbGVyay5hY2NvdW50cy5kZXYk", + frontendApiUrl: "https://example.clerk.accounts.dev", + }, + authenticate: async (req) => { + const token = req.headers.authorization?.replace(/^Bearer\s+/i, ""); + if (token === "clerk-session-u1") return { clerkUserId: "clerk_u1", sessionId: "sess_u1" }; + if (token === "clerk-session-u2") return { clerkUserId: "clerk_u2", sessionId: "sess_u2" }; + return null; + }, + }; +} + +function projectOverview(value: unknown): { totalCalls: number; billableCalls: number; throttledCalls: number } { + const body = value as { totalCalls: number; billableCalls: number; throttledCalls: number }; + return { + totalCalls: body.totalCalls, + billableCalls: body.billableCalls, + throttledCalls: body.throttledCalls, + }; +} diff --git a/packages/cloud-mcp-gateway/src/server.ts b/packages/cloud-mcp-gateway/src/server.ts index 80ee66db1a..dddd71c333 100644 --- a/packages/cloud-mcp-gateway/src/server.ts +++ b/packages/cloud-mcp-gateway/src/server.ts @@ -1,10 +1,15 @@ +import { randomUUID, timingSafeEqual } from "node:crypto"; import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; -import { randomUUID } from "node:crypto"; import { WebSocketServer } from "ws"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import { renderAccountUi } from "./account-ui.js"; +import { renderAdminUi } from "./admin-ui.js"; +import { createClerkAuthenticatorFromEnv, type ClerkAuthenticatedUser, type ClerkAuthenticator } from "./clerk-auth.js"; import { createGatewayMcpServer } from "./mcp.js"; -import { extractBearerToken, FileAuthStore, InMemoryAuthStore } from "./auth-store.js"; +import { extractBearerToken, FileAuthStore, InMemoryAuthStore, type UserRecord } from "./auth-store.js"; import { RuntimeRegistry } from "./runtime-registry.js"; +import { parseUsageLimitConfig, UsageLimiter, type UsageQuotaStatus } from "./usage-limits.js"; +import { FileUsageStore, InMemoryUsageStore, type UsageSummaryRow } from "./usage-store.js"; const MAX_JSON_BODY_BYTES = 1024 * 1024; @@ -14,34 +19,111 @@ export interface GatewayServerOptions { userToken?: string; userId?: string; authStorePath?: string; + usageStorePath?: string; + adminToken?: string; + allowRegistration?: boolean; + usageLimiter?: UsageLimiter; + clerkAuth?: ClerkAuthenticator; } export function createGatewayServer(options: GatewayServerOptions = {}) { const userId = options.userId ?? "local-user"; - const userToken = options.userToken ?? process.env.GSD_CLOUD_USER_TOKEN; + // Trim so a copy/paste or templating artifact (e.g. a trailing newline or a + // whitespace-only value) cannot seed the store with the wrong token and lock + // out intended clients; a whitespace-only value is treated as unset. + const userToken = normalizeToken(options.userToken ?? process.env.GSD_CLOUD_USER_TOKEN); if (!userToken) { throw new Error("GSD_CLOUD_USER_TOKEN is required"); } const authStorePath = options.authStorePath ?? process.env.GSD_CLOUD_AUTH_STORE_PATH; + const usageStorePath = options.usageStorePath ?? process.env.GSD_CLOUD_USAGE_STORE_PATH; + // Trim for the same reason: a whitespace-only admin token would otherwise + // enable admin-token auth with an unusable value and lock operators out of + // /admin/api/*, so treat empty-after-trim as unset (admin-token auth off). + const adminToken = normalizeToken(options.adminToken ?? process.env.GSD_CLOUD_ADMIN_TOKEN); + const allowRegistration = options.allowRegistration ?? parseBoolean(process.env.GSD_CLOUD_ALLOW_REGISTRATION); const auth = authStorePath - ? new FileAuthStore(authStorePath, { token: userToken, userId }) - : new InMemoryAuthStore({ token: userToken, userId }); + ? new FileAuthStore(authStorePath, { token: userToken, userId, role: "admin" }) + : new InMemoryAuthStore({ token: userToken, userId, role: "admin" }); + const usage = usageStorePath ? new FileUsageStore(usageStorePath) : new InMemoryUsageStore(); + const usageLimiter = options.usageLimiter ?? new UsageLimiter(parseUsageLimitConfig()); + const clerkAuth = options.clerkAuth ?? createClerkAuthenticatorFromEnv(); const registry = new RuntimeRegistry(); const wss = new WebSocketServer({ noServer: true }); const server = createServer(async (req, res) => { try { - if (req.method === "GET" && req.url === "/healthz") { + const url = new URL(req.url ?? "/", "http://localhost"); + + if (req.method === "GET" && url.pathname === "/healthz") { return sendJson(res, 200, { ok: true }); } - if (req.method === "POST" && req.url === "/pairing-codes") { + if (req.method === "GET" && (url.pathname === "/admin" || url.pathname === "/admin/")) { + return sendHtml(res, 200, renderAdminUi()); + } + + if (req.method === "GET" && (url.pathname === "/account" || url.pathname === "/account/")) { + return sendHtml(res, 200, renderAccountUi(clerkAuth?.publicConfig)); + } + + if (url.pathname.startsWith("/admin/api/")) { + const adminUser = requireAdmin(req, auth, adminToken); + if (!adminUser) return sendJson(res, 401, { error: "Unauthorized" }); + return handleAdminApi({ + req, + res, + pathname: url.pathname, + auth, + registry, + usage, + usageLimiter, + }); + } + + if (url.pathname.startsWith("/account/api/")) { + if (!clerkAuth) return sendJson(res, 503, { error: "Clerk authentication is not configured" }); + const clerkUser = await clerkAuth.authenticate(req); + if (!clerkUser) return sendJson(res, 401, { error: "Unauthorized" }); + return handleAccountApi({ + req, + res, + pathname: url.pathname, + auth, + usage, + usageLimiter, + clerkUser, + }); + } + + if (req.method === "POST" && url.pathname === "/register") { + if (!allowRegistration) return sendJson(res, 403, { error: "Registration is disabled" }); + const body = await readJson(req); + const email = optionalString(body.email); + if (!email) return sendJson(res, 400, { error: "Email is required" }); + const existing = auth.listUsers().find((user) => user.email?.toLowerCase() === email.toLowerCase()); + if (existing) return sendJson(res, 409, { error: "User already exists" }); + const user = auth.createUser({ + email, + name: optionalString(body.name), + role: "member", + plan: "free", + }); + const issued = auth.issueUserToken(user.userId, { label: "registration" }); + return sendJson(res, 201, { user, userToken: issued.userToken, tokenId: issued.tokenId }); + } + + if (req.method === "POST" && url.pathname === "/pairing-codes") { const authedUser = requireUser(req, auth); if (!authedUser) return sendJson(res, 401, { error: "Unauthorized" }); - return sendJson(res, 200, auth.createPairingCode(authedUser)); + try { + return sendJson(res, 200, auth.createPairingCode(authedUser)); + } catch (err) { + return sendJson(res, 400, { error: err instanceof Error ? err.message : "Unable to create pairing code" }); + } } - if (req.method === "POST" && req.url === "/pairing/exchange") { + if (req.method === "POST" && url.pathname === "/pairing/exchange") { const body = await readJson(req); const code = typeof body.code === "string" ? body.code : ""; const runtimeName = typeof body.runtimeName === "string" ? body.runtimeName : undefined; @@ -52,14 +134,32 @@ export function createGatewayServer(options: GatewayServerOptions = {}) { } } - if (req.url?.startsWith("/mcp")) { + if (url.pathname === "/mcp" || url.pathname === "/mcp/") { const authedUser = requireUser(req, auth); if (!authedUser) return sendJson(res, 401, { error: "Unauthorized" }); const body = req.method === "POST" ? await readJson(req) : undefined; const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, }); - const mcp = createGatewayMcpServer({ userId: authedUser, registry }); + const mcp = createGatewayMcpServer({ + userId: authedUser, + registry, + usage, + usageLimiter, + getUser: (id) => auth.getUser(id), + }); + // The MCP server is created per request. Close it on finish (normal + // completion) and on close (client disconnected before finish fires) so + // its transport/listeners can't leak under load; the guard keeps it a + // single close. + let mcpClosed = false; + const closeMcp = () => { + if (mcpClosed) return; + mcpClosed = true; + void mcp.close().catch(() => undefined); + }; + res.on("finish", closeMcp); + res.on("close", closeMcp); await mcp.connect(transport); await transport.handleRequest(req, res, body); return; @@ -102,27 +202,290 @@ export function createGatewayServer(options: GatewayServerOptions = {}) { } }); - return { server, auth, registry }; + return { server, auth, registry, usage, usageLimiter }; } export async function listenGateway(options: GatewayServerOptions = {}): Promise<{ close: () => Promise; url: string; }> { - const { server } = createGatewayServer(options); + const { server, auth, usage } = createGatewayServer(options); const port = options.port ?? Number(process.env.PORT ?? 8787); const host = options.host ?? "0.0.0.0"; - await new Promise((resolve) => server.listen(port, host, resolve)); + await new Promise((resolve, reject) => { + // A listen error (e.g. EADDRINUSE) fires the server's 'error' event, not the + // listen callback, so tie it into the promise to fail startup fast instead of + // awaiting forever. + const onError = (err: Error) => reject(err); + server.once("error", onError); + server.listen(port, host, () => { + server.removeListener("error", onError); + resolve(); + }); + }); return { url: `http://${host === "0.0.0.0" ? "localhost" : host}:${port}`, - close: () => new Promise((resolve, reject) => server.close((err) => err ? reject(err) : resolve())), + close: () => new Promise((resolve, reject) => server.close((err) => { + // File-backed stores flush synchronously on close and can throw (e.g. an + // fs write error); reject rather than leaving the promise unsettled. + try { + usage.close(); + auth.close(); + } catch (closeErr) { + reject(closeErr); + return; + } + if (err) reject(err); else resolve(); + })), }; } +function decodePathSegments(pathname: string): string[] | undefined { + try { + return pathname.split("/").filter(Boolean).map(decodeURIComponent); + } catch { + // Malformed percent-encoding (URIError): treat as a client error, not a 500. + return undefined; + } +} + +async function handleAdminApi(params: { + req: IncomingMessage; + res: ServerResponse; + pathname: string; + auth: InMemoryAuthStore; + registry: RuntimeRegistry; + usage: InMemoryUsageStore; + usageLimiter: UsageLimiter; +}): Promise { + const { req, res, pathname, auth, registry, usage, usageLimiter } = params; + const segments = decodePathSegments(pathname); + if (!segments) return sendJson(res, 400, { error: "Invalid path encoding" }); + + if (req.method === "GET" && pathname === "/admin/api/overview") { + const users = auth.listUsers(); + const summary = usage.getSummary(); + return sendJson(res, 200, { + totalUsers: users.length, + activeUsers: users.filter((user) => !user.disabled).length, + disabledUsers: users.filter((user) => user.disabled).length, + onlineRuntimes: registry.listRuntimeSummaries().length, + totalCalls: summary.totalCalls, + billableCalls: summary.billableCalls, + failedCalls: summary.failedCalls, + throttledCalls: summary.throttledCalls, + averageDurationMs: summary.averageDurationMs, + }); + } + + if (req.method === "GET" && pathname === "/admin/api/users") { + return sendJson(res, 200, { + users: buildAdminUsers(auth, usage, usageLimiter), + }); + } + + if (req.method === "POST" && pathname === "/admin/api/users") { + const body = await readJson(req); + const user = auth.createUser({ + email: optionalString(body.email), + name: optionalString(body.name), + role: body.role === "admin" ? "admin" : "member", + plan: normalizePlan(body.plan), + quotaOverrides: parseQuotaOverrides(body), + }); + const issueToken = body.issueToken !== false; + const issued = issueToken ? auth.issueUserToken(user.userId, { label: optionalString(body.tokenLabel) ?? "initial" }) : undefined; + return sendJson(res, 201, { + user, + ...(issued ? { userToken: issued.userToken, tokenId: issued.tokenId } : {}), + }); + } + + if (req.method === "POST" && segments.length === 5 && segments[2] === "users" && segments[3] && segments[4] === "tokens") { + const user = auth.getUser(segments[3]); + if (!user) return sendJson(res, 404, { error: "User not found" }); + const body = await readJson(req); + const issued = auth.issueUserToken(user.userId, { label: optionalString(body.label) ?? "manual" }); + return sendJson(res, 201, { + userId: user.userId, + tokenId: issued.tokenId, + userToken: issued.userToken, + }); + } + + if (req.method === "POST" && segments.length === 5 && segments[2] === "users" && segments[3] && segments[4] === "disabled") { + const user = auth.getUser(segments[3]); + if (!user) return sendJson(res, 404, { error: "User not found" }); + const body = await readJson(req); + const disabled = body.disabled === true; + if (disabled && isLastActiveAdmin(auth, user)) { + return sendJson(res, 400, { error: "Cannot disable the last active admin user" }); + } + return sendJson(res, 200, { user: auth.updateUser(user.userId, { disabled }) }); + } + + if (req.method === "POST" && segments.length === 5 && segments[2] === "users" && segments[3] && segments[4] === "pairing-codes") { + const user = auth.getUser(segments[3]); + if (!user) return sendJson(res, 404, { error: "User not found" }); + try { + return sendJson(res, 201, auth.createPairingCode(user.userId)); + } catch (err) { + return sendJson(res, 400, { error: err instanceof Error ? err.message : "Unable to create pairing code" }); + } + } + + if (req.method === "POST" && segments.length === 5 && segments[2] === "tokens" && segments[3] && segments[4] === "revoke") { + const revoked = auth.revokeUserTokenById(segments[3]); + return sendJson(res, revoked ? 200 : 404, revoked ? { revoked: true } : { error: "Token not found" }); + } + + if (req.method === "GET" && pathname === "/admin/api/runtimes") { + return sendJson(res, 200, { runtimes: registry.listRuntimeSummaries() }); + } + + if (req.method === "GET" && pathname === "/admin/api/usage") { + return sendJson(res, 200, usage.getSummary()); + } + + sendJson(res, 404, { error: "Not found" }); +} + +async function handleAccountApi(params: { + req: IncomingMessage; + res: ServerResponse; + pathname: string; + auth: InMemoryAuthStore; + usage: InMemoryUsageStore; + usageLimiter: UsageLimiter; + clerkUser: ClerkAuthenticatedUser; +}): Promise { + const { req, res, pathname, auth, usage, usageLimiter, clerkUser } = params; + const segments = decodePathSegments(pathname); + if (!segments) return sendJson(res, 400, { error: "Invalid path encoding" }); + const user = syncClerkGatewayUser(auth, clerkUser); + + if (req.method === "GET" && pathname === "/account/api/me") { + return sendJson(res, 200, buildAccountResponse(auth, usage, usageLimiter, user.userId)); + } + + if (req.method === "POST" && pathname === "/account/api/tokens") { + const body = await readJson(req); + const issued = auth.issueUserToken(user.userId, { label: optionalString(body.label) ?? "manual" }); + return sendJson(res, 201, { + tokenId: issued.tokenId, + userToken: issued.userToken, + }); + } + + if (req.method === "POST" && segments.length === 5 && segments[2] === "tokens" && segments[3] && segments[4] === "revoke") { + const token = auth.listUserTokens(user.userId).find((record) => record.tokenId === segments[3]); + if (!token) return sendJson(res, 404, { error: "Token not found" }); + const revoked = auth.revokeUserTokenById(token.tokenId); + return sendJson(res, revoked ? 200 : 404, revoked ? { revoked: true } : { error: "Token not found" }); + } + + if (req.method === "POST" && pathname === "/account/api/pairing-codes") { + try { + return sendJson(res, 201, auth.createPairingCode(user.userId)); + } catch (err) { + return sendJson(res, 400, { error: err instanceof Error ? err.message : "Unable to create pairing code" }); + } + } + + sendJson(res, 404, { error: "Not found" }); +} + +function syncClerkGatewayUser(auth: InMemoryAuthStore, clerkUser: ClerkAuthenticatedUser): UserRecord { + const existing = auth.getUserByClerkUserId(clerkUser.clerkUserId); + if (existing) return existing; + return auth.createUser({ + userId: `clerk_${clerkUser.clerkUserId}`, + clerkUserId: clerkUser.clerkUserId, + role: "member", + plan: "free", + }); +} + +function buildAccountResponse( + auth: InMemoryAuthStore, + usage: InMemoryUsageStore, + usageLimiter: UsageLimiter, + userId: string, +): { + user: UserRecord; + tokens: ReturnType; + usage: UsageSummaryRow; + quota: UsageQuotaStatus; +} { + const user = auth.getUser(userId); + if (!user) throw new Error(`Unknown user: ${userId}`); + const usageRow = usage.getSummary().byUser.find((row) => row.userId === userId) ?? { + userId, + calls: 0, + billableCalls: 0, + failures: 0, + throttled: 0, + totalDurationMs: 0, + averageDurationMs: 0, + }; + return { + user, + tokens: auth.listUserTokens(userId), + usage: usageRow, + quota: usageLimiter.inspect(user, usage), + }; +} + +function buildAdminUsers(auth: InMemoryAuthStore, usage: InMemoryUsageStore, usageLimiter: UsageLimiter): Array; + usage: UsageSummaryRow; + quota: UsageQuotaStatus; +}> { + const usageRows = new Map(); + for (const row of usage.getSummary().byUser) { + if (row.userId) usageRows.set(row.userId, row); + } + return auth.listUsers().map((user) => ({ + ...user, + tokens: auth.listUserTokens(user.userId), + usage: usageRows.get(user.userId) ?? { + userId: user.userId, + calls: 0, + billableCalls: 0, + failures: 0, + throttled: 0, + totalDurationMs: 0, + averageDurationMs: 0, + }, + quota: usageLimiter.inspect(user, usage), + })); +} + function requireUser(req: IncomingMessage, auth: InMemoryAuthStore): string | null { return auth.authenticateUser(extractBearerToken(req.headers.authorization)); } +function requireAdmin(req: IncomingMessage, auth: InMemoryAuthStore, adminToken: string | undefined): string | null { + const token = extractBearerToken(req.headers.authorization); + if (adminToken) return tokenMatches(token, adminToken) ? "admin-token" : null; + const userId = auth.authenticateUser(token); + if (!userId) return null; + return auth.getUser(userId)?.role === "admin" ? userId : null; +} + +function tokenMatches(actual: string | undefined, expected: string): boolean { + if (!actual) return false; + const actualBuffer = Buffer.from(actual); + const expectedBuffer = Buffer.from(expected); + return actualBuffer.length === expectedBuffer.length && timingSafeEqual(actualBuffer, expectedBuffer); +} + +function isLastActiveAdmin(auth: InMemoryAuthStore, user: UserRecord): boolean { + if (user.role !== "admin" || user.disabled) return false; + const activeAdmins = auth.listUsers().filter((candidate) => candidate.role === "admin" && !candidate.disabled); + return activeAdmins.length <= 1; +} + async function readJson(req: IncomingMessage): Promise> { const chunks: Buffer[] = []; let totalBytes = 0; @@ -151,4 +514,58 @@ function sendJson(res: ServerResponse, status: number, body: unknown): void { res.end(JSON.stringify(body)); } +function sendHtml(res: ServerResponse, status: number, body: string): void { + if (res.headersSent) return; + res.writeHead(status, { + "content-type": "text/html; charset=utf-8", + "cache-control": "no-store", + }); + res.end(body); +} + +function optionalString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function normalizePlan(value: unknown): UserRecord["plan"] { + if (value === "paid" || value === "unlimited") return value; + return "free"; +} + +function parseQuotaOverrides(body: Record): UserRecord["quotaOverrides"] { + const overrides: NonNullable = {}; + for (const [bodyKey, overrideKey] of [ + ["callsPerMinute", "callsPerMinute"], + ["callsPerDay", "callsPerDay"], + ["callsPerMonth", "callsPerMonth"], + ] as const) { + const parsed = optionalLimit(body[bodyKey]); + if (parsed !== undefined) overrides[overrideKey] = parsed; + } + return Object.keys(overrides).length ? overrides : undefined; +} + +function optionalLimit(value: unknown): number | undefined { + if (value === undefined || value === null || value === "") return undefined; + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed < 0) return undefined; + // 0 is the explicit "unlimited" sentinel; any positive value clamps to a + // minimum of 1 so fractional inputs (e.g. 0.5) don't floor to 0 and + // accidentally disable the quota, matching readLimit() in usage-limits.ts. + if (parsed === 0) return 0; + return Math.max(1, Math.floor(parsed)); +} + +function parseBoolean(value: string | undefined): boolean { + const normalized = value?.trim().toLowerCase(); + return normalized === "1" || normalized === "true" || normalized === "yes"; +} + +// Normalize a bearer token from options/env: trim surrounding whitespace and +// treat an empty-after-trim value as unset (undefined). +function normalizeToken(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +} + class BadRequestError extends Error {} diff --git a/packages/cloud-mcp-gateway/src/usage-limits.test.ts b/packages/cloud-mcp-gateway/src/usage-limits.test.ts new file mode 100644 index 0000000000..a1cdb47b8b --- /dev/null +++ b/packages/cloud-mcp-gateway/src/usage-limits.test.ts @@ -0,0 +1,185 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import type { UserRecord } from "./auth-store.js"; +import { UsageLimiter, parseUsageLimitConfig } from "./usage-limits.js"; +import { InMemoryUsageStore } from "./usage-store.js"; + +test("usage limiter enforces per-minute free limits", () => { + const user = makeUser("u1"); + const usage = new InMemoryUsageStore(); + const limiter = new UsageLimiter({ + free: { callsPerMinute: 2 }, + paid: {}, + unlimited: {}, + }); + const now = Date.parse("2026-06-01T12:00:00.000Z"); + + assert.equal(limiter.check(user, usage, now).allowed, true); + assert.equal(limiter.check(user, usage, now + 1).allowed, true); + const denied = limiter.check(user, usage, now + 2); + assert.equal(denied.allowed, false); + assert.equal(denied.reason, "minute"); +}); + +test("usage limiter enforces billable day and month limits", () => { + const user = makeUser("u1"); + const usage = new InMemoryUsageStore(); + usage.recordToolCall({ + userId: "u1", + toolName: "gsd_status", + startedAt: Date.parse("2026-06-01T12:00:00.000Z"), + durationMs: 1, + ok: true, + }); + usage.recordToolCall({ + userId: "u1", + toolName: "gsd_status", + startedAt: Date.parse("2026-06-01T12:00:01.000Z"), + durationMs: 1, + ok: false, + billable: false, + throttled: true, + }); + const limiter = new UsageLimiter({ + free: { callsPerDay: 1, callsPerMonth: 1 }, + paid: {}, + unlimited: {}, + }); + + const denied = limiter.check(user, usage, Date.parse("2026-06-01T12:00:02.000Z")); + assert.equal(denied.allowed, false); + assert.equal(denied.reason, "day"); + assert.equal(denied.usage.day, 1); + assert.equal(denied.usage.month, 1); +}); + +test("usage limiter reserves billable day quota for in-flight calls until released", () => { + const user = makeUser("u1"); + const usage = new InMemoryUsageStore(); + const limiter = new UsageLimiter({ + free: { callsPerDay: 1 }, + paid: {}, + unlimited: {}, + }); + const now = Date.parse("2026-06-01T12:00:00.000Z"); + + // First call is accepted and reserves the single billable day slot before any + // usage is recorded to the store. + assert.equal(limiter.check(user, usage, now).allowed, true); + // A concurrent second call (store still empty, first not yet recorded) is + // denied because the in-flight reservation already consumed the day quota. + const denied = limiter.check(user, usage, now + 1); + assert.equal(denied.allowed, false); + assert.equal(denied.reason, "day"); + + // Releasing the in-flight reservation frees the slot again. + limiter.releaseBillable(user.userId, now + 2); + assert.equal(limiter.check(user, usage, now + 3).allowed, true); +}); + +test("non-billable calls are minute-throttled but skip the day/month billable gate", () => { + const user = makeUser("u1"); + const usage = new InMemoryUsageStore(); + const limiter = new UsageLimiter({ + free: { callsPerMinute: 2, callsPerDay: 1, callsPerMonth: 1 }, + paid: {}, + unlimited: {}, + }); + const now = Date.parse("2026-06-01T12:00:00.000Z"); + + // A non-billable call (e.g. an unknown tool) is accepted without consuming or + // reserving the day/month billable quota. + const first = limiter.check(user, usage, now, false); + assert.equal(first.allowed, true); + assert.equal(first.usage.day, 0, "non-billable call must not count against day usage"); + assert.equal(first.usage.month, 0, "non-billable call must not count against month usage"); + + // Because the non-billable call reserved nothing, a concurrent billable call + // near the single day/month slot is still allowed. + assert.equal(limiter.check(user, usage, now + 1, true).allowed, true); + + // Non-billable calls are still minute-throttled: the third call in the window + // (limit 2) is rejected on the minute reason, not the day/month reason. + const throttled = limiter.check(user, usage, now + 2, false); + assert.equal(throttled.allowed, false); + assert.equal(throttled.reason, "minute"); +}); + +test("non-billable calls are not rejected by an exhausted day quota", () => { + const user = makeUser("u1"); + const usage = new InMemoryUsageStore(); + usage.recordToolCall({ + userId: "u1", + toolName: "gsd_status", + startedAt: Date.parse("2026-06-01T12:00:00.000Z"), + durationMs: 1, + ok: true, + }); + const limiter = new UsageLimiter({ + free: { callsPerDay: 1, callsPerMonth: 1 }, + paid: {}, + unlimited: {}, + }); + const at = Date.parse("2026-06-01T12:00:01.000Z"); + + // A billable call is denied because the day quota is exhausted. + assert.equal(limiter.check(user, usage, at, true).reason, "day"); + // A non-billable call at the same moment is allowed because it skips the + // day/month billable gate. + assert.equal(limiter.check(user, usage, at + 1, false).allowed, true); +}); + +test("unlimited plan bypasses configured free limits", () => { + const user = makeUser("u1", "unlimited"); + const usage = new InMemoryUsageStore(); + const limiter = new UsageLimiter({ + free: { callsPerMinute: 0, callsPerDay: 1, callsPerMonth: 1 }, + paid: {}, + unlimited: {}, + }); + + assert.equal(limiter.check(user, usage).allowed, true); + assert.equal(limiter.check(user, usage).allowed, true); +}); + +test("readLimit treats fractional values as limit of 1, not unlimited", () => { + const config = parseUsageLimitConfig({ + GSD_CLOUD_FREE_CALLS_PER_MINUTE: "0.5", + GSD_CLOUD_FREE_CALLS_PER_DAY: "0.9", + GSD_CLOUD_FREE_CALLS_PER_MONTH: "0", + }); + assert.equal(config.free.callsPerMinute, 1, "0.5 should floor to 1, not unlimited"); + assert.equal(config.free.callsPerDay, 1, "0.9 should floor to 1, not unlimited"); + assert.equal(config.free.callsPerMonth, undefined, "explicit 0 should remain unlimited"); +}); + +test("usage limit config parses environment values", () => { + const config = parseUsageLimitConfig({ + GSD_CLOUD_FREE_CALLS_PER_MINUTE: "3", + GSD_CLOUD_FREE_CALLS_PER_DAY: "4", + GSD_CLOUD_FREE_CALLS_PER_MONTH: "5", + GSD_CLOUD_PAID_CALLS_PER_MINUTE: "0", + GSD_CLOUD_PAID_CALLS_PER_DAY: "not-a-number", + GSD_CLOUD_PAID_CALLS_PER_MONTH: "7", + }); + + assert.deepEqual(config.free, { + callsPerMinute: 3, + callsPerDay: 4, + callsPerMonth: 5, + }); + assert.deepEqual(config.paid, { + callsPerMinute: undefined, + callsPerDay: 2000, + callsPerMonth: 7, + }); +}); + +function makeUser(userId: string, plan: UserRecord["plan"] = "free"): UserRecord { + return { + userId, + role: "member", + plan, + createdAt: Date.now(), + }; +} diff --git a/packages/cloud-mcp-gateway/src/usage-limits.ts b/packages/cloud-mcp-gateway/src/usage-limits.ts new file mode 100644 index 0000000000..e63cc7e092 --- /dev/null +++ b/packages/cloud-mcp-gateway/src/usage-limits.ts @@ -0,0 +1,329 @@ +import type { UserPlan, UserQuotaOverrides, UserRecord } from "./auth-store.js"; +import type { InMemoryUsageStore } from "./usage-store.js"; + +export interface UsageLimits { + callsPerMinute?: number; + callsPerDay?: number; + callsPerMonth?: number; +} + +export interface UsageLimitConfig { + free: UsageLimits; + paid: UsageLimits; + unlimited: UsageLimits; +} + +export interface UsageQuotaStatus { + userId: string; + plan: UserPlan; + limits: UsageLimits; + usage: { + minute: number; + day: number; + month: number; + }; + remaining: { + minute?: number; + day?: number; + month?: number; + }; + resetAt: { + minute?: number; + day: number; + month: number; + }; + allowed: boolean; + reason?: string; + retryAfterSeconds?: number; +} + +const WINDOW_MS = 60 * 1000; + +export class UsageLimiter { + private readonly minuteCalls = new Map(); + // In-flight billable reservations per user, keyed by UTC day / month window. + // Day and month quotas are otherwise derived from the usage store, which is + // only updated after a tool call finishes; reserving at acceptance stops + // concurrent calls from all passing check() and overshooting the quota. + private readonly dayReservations = new Map(); + private readonly monthReservations = new Map(); + + constructor(private readonly config: UsageLimitConfig) {} + + /** + * Check whether a tool call may proceed and, on acceptance, record it. + * + * Minute throttling applies to every call so spammed, arbitrary tool names + * are still rate-limited. Day/month billable quota is only checked and + * reserved for billable calls: passing `billable: false` (e.g. an unknown + * tool that will be recorded non-billable) skips the day/month gate and never + * holds a day/month reservation, so spam/typos cannot deny concurrent + * legitimate calls near a day/month quota boundary. + */ + check(user: UserRecord, usage: InMemoryUsageStore, now = Date.now(), billable = true): UsageQuotaStatus { + const limits = resolveLimits(user, this.config); + const calls = this.prune(user.userId, now); + const minute = calls.length; + const billableUsage = usage.getUserBillableUsage(user.userId, now); + const reserved = this.reservedBillable(user.userId, now); + const status = buildStatus(user, limits, { + minute, + day: billableUsage.day + reserved.day, + month: billableUsage.month + reserved.month, + }, now, calls[0] ? calls[0] + WINDOW_MS : undefined, billable); + if (!status.allowed) return status; + // Only track per-minute timestamps when a minute limit actually applies. For + // unlimited plans (or when the minute limit is disabled via env) throttling + // never fires, so recording timestamps would only grow the per-user + // minuteCalls array under load and waste CPU in prune()/filter without ever + // changing a decision. + const trackMinute = isLimited(limits.callsPerMinute); + if (trackMinute) this.noteAccepted(user.userId, now); + if (billable) this.reserveBillable(user.userId, now); + // Reflect the accepted call's own reservation in the returned status so it is + // consistent with the limiter's internal state: the minute window counted it + // (noteAccepted) and, for billable calls, the day/month billable reservation + // counted it (reserveBillable). Otherwise callers surfacing this status + // under-report day/month usage by 1 until the call is later recorded in the + // usage store. + return { + ...status, + usage: { + ...status.usage, + minute: trackMinute ? minute + 1 : status.usage.minute, + day: status.usage.day + (billable ? 1 : 0), + month: status.usage.month + (billable ? 1 : 0), + }, + remaining: { + ...status.remaining, + ...(status.remaining.minute !== undefined ? { minute: Math.max(0, status.remaining.minute - 1) } : {}), + ...(billable && status.remaining.day !== undefined ? { day: Math.max(0, status.remaining.day - 1) } : {}), + ...(billable && status.remaining.month !== undefined ? { month: Math.max(0, status.remaining.month - 1) } : {}), + }, + resetAt: { + ...status.resetAt, + // noteAccepted() just recorded this call, so the minute window is now + // non-empty even if it was empty pre-acceptance; surface its reset time + // (oldest call in the window + WINDOW_MS) instead of the stale undefined. + // Only when a minute limit applies: unlimited plans have no minute window. + ...(trackMinute ? { minute: (calls[0] ?? now) + WINDOW_MS } : {}), + }, + }; + } + + inspect(user: UserRecord, usage: InMemoryUsageStore, now = Date.now()): UsageQuotaStatus { + const limits = resolveLimits(user, this.config); + const billable = usage.getUserBillableUsage(user.userId, now); + const calls = this.prune(user.userId, now); + return buildStatus(user, limits, { + minute: calls.length, + day: billable.day, + month: billable.month, + }, now, calls[0] ? calls[0] + WINDOW_MS : undefined); + } + + /** + * Release a billable reservation held for an in-flight tool call. A caller + * that passed check() (status.allowed === true) must call this exactly once + * when the call settles, so the reservation does not outlive the request. + */ + releaseBillable(userId: string, now = Date.now()): void { + adjustReservation(this.dayReservations, userId, utcDayKey(now), -1); + adjustReservation(this.monthReservations, userId, utcMonthKey(now), -1); + } + + private noteAccepted(userId: string, now: number): void { + const calls = this.prune(userId, now); + calls.push(now); + this.minuteCalls.set(userId, calls); + } + + private reserveBillable(userId: string, now: number): void { + adjustReservation(this.dayReservations, userId, utcDayKey(now), 1); + adjustReservation(this.monthReservations, userId, utcMonthKey(now), 1); + } + + private reservedBillable(userId: string, now: number): { day: number; month: number } { + return { + day: currentReservation(this.dayReservations, userId, utcDayKey(now)), + month: currentReservation(this.monthReservations, userId, utcMonthKey(now)), + }; + } + + private prune(userId: string, now: number): number[] { + const cutoff = now - WINDOW_MS; + const calls = (this.minuteCalls.get(userId) ?? []).filter((timestamp) => timestamp > cutoff); + if (calls.length) this.minuteCalls.set(userId, calls); + else this.minuteCalls.delete(userId); + return calls; + } +} + +export function parseUsageLimitConfig(env: Record = process.env): UsageLimitConfig { + return { + free: { + callsPerMinute: readLimit(env.GSD_CLOUD_FREE_CALLS_PER_MINUTE, 12), + callsPerDay: readLimit(env.GSD_CLOUD_FREE_CALLS_PER_DAY, 100), + callsPerMonth: readLimit(env.GSD_CLOUD_FREE_CALLS_PER_MONTH, 1000), + }, + paid: { + callsPerMinute: readLimit(env.GSD_CLOUD_PAID_CALLS_PER_MINUTE, 60), + callsPerDay: readLimit(env.GSD_CLOUD_PAID_CALLS_PER_DAY, 2000), + callsPerMonth: readLimit(env.GSD_CLOUD_PAID_CALLS_PER_MONTH, 50000), + }, + unlimited: {}, + }; +} + +export function formatQuotaExceeded(status: UsageQuotaStatus): string { + if (status.reason === "minute") { + return `Usage limit exceeded: ${status.limits.callsPerMinute} tool calls per minute. Try again in ${status.retryAfterSeconds ?? 60}s.`; + } + if (status.reason === "day") { + return `Usage limit exceeded: ${status.limits.callsPerDay} billable tool calls per day.`; + } + if (status.reason === "month") { + return `Usage limit exceeded: ${status.limits.callsPerMonth} billable tool calls per month.`; + } + return "Usage limit exceeded."; +} + +function resolveLimits(user: UserRecord, config: UsageLimitConfig): UsageLimits { + return { + ...config[user.plan], + ...normalizeOverrides(user.quotaOverrides), + }; +} + +function buildStatus( + user: UserRecord, + limits: UsageLimits, + usage: UsageQuotaStatus["usage"], + now: number, + minuteResetAt: number | undefined, + // Minute throttling always applies; day/month quotas are only enforced for + // billable calls so non-billable calls (e.g. unknown tools) are rate-limited + // without consuming or being rejected by the day/month billable budget. + enforceBillable = true, +): UsageQuotaStatus { + const resetAt = { + minute: minuteResetAt, + day: nextUtcDay(now), + month: nextUtcMonth(now), + }; + const remaining = { + ...(isLimited(limits.callsPerMinute) ? { minute: Math.max(0, limits.callsPerMinute - usage.minute) } : {}), + ...(isLimited(limits.callsPerDay) ? { day: Math.max(0, limits.callsPerDay - usage.day) } : {}), + ...(isLimited(limits.callsPerMonth) ? { month: Math.max(0, limits.callsPerMonth - usage.month) } : {}), + }; + if (isLimited(limits.callsPerMinute) && usage.minute >= limits.callsPerMinute) { + return { + userId: user.userId, + plan: user.plan, + limits, + usage, + remaining, + resetAt, + allowed: false, + reason: "minute", + retryAfterSeconds: Math.max(1, Math.ceil(((resetAt.minute ?? now + WINDOW_MS) - now) / 1000)), + }; + } + if (enforceBillable && isLimited(limits.callsPerDay) && usage.day >= limits.callsPerDay) { + return { + userId: user.userId, + plan: user.plan, + limits, + usage, + remaining, + resetAt, + allowed: false, + reason: "day", + retryAfterSeconds: Math.max(1, Math.ceil((resetAt.day - now) / 1000)), + }; + } + if (enforceBillable && isLimited(limits.callsPerMonth) && usage.month >= limits.callsPerMonth) { + return { + userId: user.userId, + plan: user.plan, + limits, + usage, + remaining, + resetAt, + allowed: false, + reason: "month", + retryAfterSeconds: Math.max(1, Math.ceil((resetAt.month - now) / 1000)), + }; + } + return { + userId: user.userId, + plan: user.plan, + limits, + usage, + remaining, + resetAt, + allowed: true, + }; +} + +function normalizeOverrides(value: UserQuotaOverrides | undefined): UsageLimits { + if (!value) return {}; + return { + ...(value.callsPerMinute !== undefined ? { callsPerMinute: value.callsPerMinute } : {}), + ...(value.callsPerDay !== undefined ? { callsPerDay: value.callsPerDay } : {}), + ...(value.callsPerMonth !== undefined ? { callsPerMonth: value.callsPerMonth } : {}), + }; +} + +function readLimit(value: string | undefined, fallback: number): number | undefined { + if (value === undefined || value.trim() === "") return fallback; + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed < 0) return fallback; + if (parsed === 0) return undefined; + return Math.max(1, Math.floor(parsed)); +} + +function isLimited(limit: number | undefined): limit is number { + return typeof limit === "number" && limit > 0; +} + +type Reservation = { key: string; count: number }; + +function adjustReservation(map: Map, userId: string, key: string, delta: number): void { + const entry = map.get(userId); + // A release (negative delta) whose window has already rolled over must not + // touch the current window's reservation. The stale window's count was + // discarded when the new window's first reserve reset the entry, so applying + // the release here would wrongly delete or undercount the live entry and let + // concurrent calls bypass day/month quotas around midnight/month boundaries. + if (delta < 0 && entry !== undefined && entry.key !== key) return; + // A stale window (entry.key !== key) on a reserve has rolled over, so start + // fresh instead of carrying an old day/month's count into the new window. + const base = entry && entry.key === key ? entry.count : 0; + const count = Math.max(0, base + delta); + if (count > 0) map.set(userId, { key, count }); + else map.delete(userId); +} + +function currentReservation(map: Map, userId: string, key: string): number { + const entry = map.get(userId); + return entry && entry.key === key ? entry.count : 0; +} + +function utcDayKey(now: number): string { + return new Date(now).toISOString().slice(0, 10); +} + +function utcMonthKey(now: number): string { + return new Date(now).toISOString().slice(0, 7); +} + +function nextUtcDay(now: number): number { + const date = new Date(now); + return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate() + 1); +} + +function nextUtcMonth(now: number): number { + const date = new Date(now); + return Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 1); +} diff --git a/packages/cloud-mcp-gateway/src/usage-store.test.ts b/packages/cloud-mcp-gateway/src/usage-store.test.ts new file mode 100644 index 0000000000..0181045581 --- /dev/null +++ b/packages/cloud-mcp-gateway/src/usage-store.test.ts @@ -0,0 +1,67 @@ +import assert from "node:assert/strict"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test } from "node:test"; +import { FileUsageStore, InMemoryUsageStore } from "./usage-store.js"; + +test("usage store aggregates calls by user, tool, and day", () => { + const usage = new InMemoryUsageStore(); + usage.recordToolCall({ + userId: "u1", + toolName: "gsd_status", + startedAt: Date.parse("2026-06-01T12:00:00.000Z"), + durationMs: 10, + ok: true, + }); + usage.recordToolCall({ + userId: "u1", + toolName: "browser_navigate", + startedAt: Date.parse("2026-06-01T12:01:00.000Z"), + durationMs: 30, + ok: false, + billable: false, + throttled: true, + error: "offline", + }); + + const summary = usage.getSummary(); + assert.equal(summary.totalCalls, 2); + assert.equal(summary.billableCalls, 1); + assert.equal(summary.failedCalls, 1); + assert.equal(summary.throttledCalls, 1); + assert.equal(summary.averageDurationMs, 20); + assert.deepEqual(summary.byUser.map((row) => ({ + userId: row.userId, + calls: row.calls, + billableCalls: row.billableCalls, + failures: row.failures, + throttled: row.throttled, + })), [{ userId: "u1", calls: 2, billableCalls: 1, failures: 1, throttled: 1 }]); + assert.deepEqual(usage.getUserBillableUsage("u1", Date.parse("2026-06-01T12:05:00.000Z")), { + day: 1, + month: 1, + }); + assert.deepEqual(summary.byTool.map((row) => row.toolName).sort(), ["browser_navigate", "gsd_status"]); + assert.equal(summary.byDay[0]?.day, "2026-06-01"); + assert.equal(summary.recentEvents[0]?.toolName, "browser_navigate"); +}); + +test("file usage store persists aggregate usage", () => { + const dir = mkdtempSync(join(tmpdir(), "gsd-cloud-usage-")); + const storePath = join(dir, "usage.json"); + const first = new FileUsageStore(storePath); + first.recordToolCall({ + userId: "u1", + toolName: "gsd_status", + startedAt: 1000, + durationMs: 12, + ok: true, + }); + first.close(); + + const second = new FileUsageStore(storePath); + const summary = second.getSummary(); + assert.equal(summary.totalCalls, 1); + assert.equal(summary.byTool[0]?.toolName, "gsd_status"); +}); diff --git a/packages/cloud-mcp-gateway/src/usage-store.ts b/packages/cloud-mcp-gateway/src/usage-store.ts new file mode 100644 index 0000000000..1e7cb8cb18 --- /dev/null +++ b/packages/cloud-mcp-gateway/src/usage-store.ts @@ -0,0 +1,310 @@ +import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; +import { dirname } from "node:path"; + +export interface UsageToolCallInput { + userId: string; + toolName: string; + runtimeId?: string; + projectAlias?: string; + startedAt?: number; + durationMs: number; + ok: boolean; + billable?: boolean; + throttled?: boolean; + error?: string; +} + +export interface UsageEventRecord { + eventId: string; + userId: string; + toolName: string; + runtimeId?: string; + projectAlias?: string; + startedAt: number; + durationMs: number; + ok: boolean; + billable: boolean; + throttled?: boolean; + error?: string; +} + +export interface UsageBucketRecord { + userId: string; + toolName: string; + day: string; + calls: number; + billableCalls: number; + failures: number; + throttled: number; + totalDurationMs: number; + lastCallAt: number; +} + +export interface UsageSummaryRow { + userId?: string; + toolName?: string; + calls: number; + billableCalls: number; + failures: number; + throttled: number; + totalDurationMs: number; + averageDurationMs: number; + lastCallAt?: number; +} + +export interface UsageSummary { + generatedAt: number; + totalCalls: number; + billableCalls: number; + failedCalls: number; + throttledCalls: number; + totalDurationMs: number; + averageDurationMs: number; + byUser: UsageSummaryRow[]; + byTool: UsageSummaryRow[]; + byDay: UsageBucketRecord[]; + recentEvents: UsageEventRecord[]; +} + +export interface UsageStoreSnapshot { + version: 1; + buckets: UsageBucketRecord[]; + recentEvents: UsageEventRecord[]; +} + +const RECENT_EVENT_LIMIT = 200; + +export class InMemoryUsageStore { + protected readonly buckets = new Map(); + protected readonly recentEvents: UsageEventRecord[] = []; + + constructor(snapshot?: UsageStoreSnapshot) { + if (snapshot) this.loadSnapshot(snapshot); + } + + recordToolCall(input: UsageToolCallInput): UsageEventRecord { + const startedAt = input.startedAt ?? Date.now(); + const durationMs = Math.max(0, Math.round(input.durationMs)); + const billable = input.billable !== false; + const event: UsageEventRecord = { + eventId: `evt_${startedAt}_${Math.random().toString(16).slice(2)}`, + userId: input.userId, + toolName: input.toolName, + ...(input.runtimeId ? { runtimeId: input.runtimeId } : {}), + ...(input.projectAlias ? { projectAlias: input.projectAlias } : {}), + startedAt, + durationMs, + ok: input.ok, + billable, + ...(input.throttled ? { throttled: true } : {}), + ...(input.error ? { error: input.error } : {}), + }; + const day = new Date(startedAt).toISOString().slice(0, 10); + const bucketKey = `${input.userId}\u0000${input.toolName}\u0000${day}`; + const bucket = this.buckets.get(bucketKey) ?? { + userId: input.userId, + toolName: input.toolName, + day, + calls: 0, + billableCalls: 0, + failures: 0, + throttled: 0, + totalDurationMs: 0, + lastCallAt: startedAt, + }; + bucket.calls += 1; + bucket.billableCalls += billable ? 1 : 0; + bucket.failures += input.ok ? 0 : 1; + bucket.throttled += input.throttled ? 1 : 0; + bucket.totalDurationMs += durationMs; + bucket.lastCallAt = Math.max(bucket.lastCallAt, startedAt); + this.buckets.set(bucketKey, bucket); + + this.recentEvents.unshift(event); + if (this.recentEvents.length > RECENT_EVENT_LIMIT) { + this.recentEvents.length = RECENT_EVENT_LIMIT; + } + this.afterMutation(); + return event; + } + + getSummary(): UsageSummary { + const buckets = Array.from(this.buckets.values()); + const totalCalls = sum(buckets, "calls"); + const billableCalls = sum(buckets, "billableCalls"); + const failedCalls = sum(buckets, "failures"); + const throttledCalls = sum(buckets, "throttled"); + const totalDurationMs = sum(buckets, "totalDurationMs"); + return { + generatedAt: Date.now(), + totalCalls, + billableCalls, + failedCalls, + throttledCalls, + totalDurationMs, + averageDurationMs: totalCalls ? Math.round(totalDurationMs / totalCalls) : 0, + byUser: summarize(buckets, "userId"), + byTool: summarize(buckets, "toolName"), + byDay: buckets + .map((bucket) => ({ ...bucket })) + .sort((a, b) => b.day.localeCompare(a.day) || b.lastCallAt - a.lastCallAt), + recentEvents: this.recentEvents.map((event) => ({ ...event })), + }; + } + + getUserBillableUsage(userId: string, now = Date.now()): { day: number; month: number } { + const day = new Date(now).toISOString().slice(0, 10); + const month = day.slice(0, 7); + let dayTotal = 0; + let monthTotal = 0; + for (const bucket of this.buckets.values()) { + if (bucket.userId !== userId) continue; + if (bucket.day === day) dayTotal += bucket.billableCalls; + if (bucket.day.startsWith(month)) monthTotal += bucket.billableCalls; + } + return { day: dayTotal, month: monthTotal }; + } + + snapshot(): UsageStoreSnapshot { + // Return copies (like getSummary()) so a caller can't mutate the live buckets + // or recentEvents and corrupt internal state or persisted output. + return { + version: 1, + buckets: Array.from(this.buckets.values(), (bucket) => ({ ...bucket })), + recentEvents: this.recentEvents.map((event) => ({ ...event })), + }; + } + + protected afterMutation(): void { + // Extension point for persistent stores. + } + + close(): void { + // No persistence to flush for the in-memory store. + } + + private loadSnapshot(snapshot: UsageStoreSnapshot): void { + for (const bucket of snapshot.buckets ?? []) { + if (!bucket.userId || !bucket.toolName || !bucket.day) continue; + this.buckets.set(`${bucket.userId}\u0000${bucket.toolName}\u0000${bucket.day}`, { + userId: bucket.userId, + toolName: bucket.toolName, + day: bucket.day, + calls: Math.max(0, Number(bucket.calls) || 0), + billableCalls: Math.max(0, Number(bucket.billableCalls ?? bucket.calls) || 0), + failures: Math.max(0, Number(bucket.failures) || 0), + throttled: Math.max(0, Number(bucket.throttled) || 0), + totalDurationMs: Math.max(0, Number(bucket.totalDurationMs) || 0), + lastCallAt: Math.max(0, Number(bucket.lastCallAt) || 0), + }); + } + for (const event of snapshot.recentEvents ?? []) { + if (!event.userId || !event.toolName || typeof event.startedAt !== "number") continue; + this.recentEvents.push({ ...event, billable: event.billable !== false }); + if (this.recentEvents.length >= RECENT_EVENT_LIMIT) break; + } + } +} + +export class FileUsageStore extends InMemoryUsageStore { + private readonly filePath: string; + private readonly flushDelayMs: number; + private flushTimer: ReturnType | undefined; + private pending = false; + + constructor(filePath: string, options: { flushDelayMs?: number } = {}) { + super(readUsageSnapshot(filePath)); + this.filePath = filePath; + this.flushDelayMs = Math.max(0, options.flushDelayMs ?? 250); + this.persist(); + } + + protected override afterMutation(): void { + // Coalesce bursty per-tool-call writes into a single debounced flush so the + // hot /mcp path isn't blocked by a synchronous write+rename on every call. + this.pending = true; + if (this.flushTimer) return; + this.flushTimer = setTimeout(() => { + this.flushTimer = undefined; + this.flush(); + }, this.flushDelayMs); + this.flushTimer.unref?.(); + } + + /** Flush any pending mutation and cancel the debounce timer. */ + override close(): void { + if (this.flushTimer) { + clearTimeout(this.flushTimer); + this.flushTimer = undefined; + } + this.flush(); + } + + private flush(): void { + if (!this.pending) return; + this.pending = false; + try { + this.persist(); + } catch { + // Persistence is best-effort. Restore the pending flag so the next + // mutation (or close()) retries the write, and swallow the error so a + // transient fs failure (disk full, permissions) can't surface as an + // unhandled exception from the debounced timer and crash the gateway. + this.pending = true; + } + } + + private persist(): void { + mkdirSync(dirname(this.filePath), { recursive: true }); + const tmp = `${this.filePath}.${process.pid}.${Date.now()}.tmp`; + writeFileSync(tmp, `${JSON.stringify(this.snapshot(), null, 2)}\n`, { encoding: "utf8", mode: 0o600 }); + renameSync(tmp, this.filePath); + } +} + +function summarize(buckets: UsageBucketRecord[], key: "userId" | "toolName"): UsageSummaryRow[] { + const rows = new Map(); + for (const bucket of buckets) { + const rowKey = bucket[key]; + const row = rows.get(rowKey) ?? { + [key]: rowKey, + calls: 0, + billableCalls: 0, + failures: 0, + throttled: 0, + totalDurationMs: 0, + averageDurationMs: 0, + lastCallAt: undefined, + }; + row.calls += bucket.calls; + row.billableCalls += bucket.billableCalls; + row.failures += bucket.failures; + row.throttled += bucket.throttled; + row.totalDurationMs += bucket.totalDurationMs; + row.averageDurationMs = row.calls ? Math.round(row.totalDurationMs / row.calls) : 0; + row.lastCallAt = Math.max(row.lastCallAt ?? 0, bucket.lastCallAt); + rows.set(rowKey, row); + } + return Array.from(rows.values()).sort((a, b) => b.calls - a.calls); +} + +function sum( + buckets: UsageBucketRecord[], + key: "calls" | "billableCalls" | "failures" | "throttled" | "totalDurationMs", +): number { + return buckets.reduce((total, bucket) => total + bucket[key], 0); +} + +function readUsageSnapshot(filePath: string): UsageStoreSnapshot | undefined { + try { + const parsed = JSON.parse(readFileSync(filePath, "utf8")) as Partial; + if (parsed.version !== 1) return undefined; + return { + version: 1, + buckets: Array.isArray(parsed.buckets) ? parsed.buckets as UsageBucketRecord[] : [], + recentEvents: Array.isArray(parsed.recentEvents) ? parsed.recentEvents as UsageEventRecord[] : [], + }; + } catch { + return undefined; + } +} diff --git a/packages/daemon/package.json b/packages/daemon/package.json index 1bd5dd7548..6af06ce63e 100644 --- a/packages/daemon/package.json +++ b/packages/daemon/package.json @@ -31,6 +31,7 @@ }, "dependencies": { "@anthropic-ai/sdk": "^0.91.1", + "@modelcontextprotocol/sdk": "^1.27.1", "@opengsd/contracts": "workspace:*", "@opengsd/mcp-server": "workspace:*", "@opengsd/rpc-client": "workspace:*", diff --git a/packages/daemon/src/cloud-runtime.ts b/packages/daemon/src/cloud-runtime.ts index ac24ef59e6..214bdd660d 100644 --- a/packages/daemon/src/cloud-runtime.ts +++ b/packages/daemon/src/cloud-runtime.ts @@ -12,6 +12,13 @@ interface GatewayMessage { projectAlias?: string; } +interface CloudRuntimeExecutor { + execute(toolName: string, rawArgs: Record, projectAlias?: string): Promise; + advertisedProjects(): Promise; + advertisedTools?(): Promise; + close?(): Promise; +} + export class CloudRuntime { private static readonly MAX_OUTBOX = 200; private socket: WebSocket | undefined; @@ -23,7 +30,7 @@ export class CloudRuntime { constructor( private readonly cloud: NonNullable, - private readonly executor: LocalToolExecutor, + private readonly executor: LocalToolExecutor | CloudRuntimeExecutor, private readonly logger: Logger, ) {} @@ -43,6 +50,11 @@ export class CloudRuntime { const socket = this.socket; this.socket = undefined; socket?.close(); + void Promise.resolve(this.executor.close?.()).catch((err) => { + this.logger.warn("cloud runtime executor close failed", { + error: err instanceof Error ? err.message : String(err), + }); + }); } private connect(): void { @@ -96,11 +108,14 @@ export class CloudRuntime { private handleSocketOpen(socket: WebSocket): void { if (socket !== this.socket) return; this.logger.info("cloud runtime connected", { gateway_url: this.cloud.gateway_url, runtime_id: this.cloud.runtime_id }); - // Re-advertise projects (async: the hello is sent on a later microtask), then - // drain any messages buffered while disconnected. tool_results route by - // requestId on the authenticated connection, so drain order vs the hello is - // not significant. - void this.advertiseProjects(); + // Re-advertise projects/tools (async: hello is sent on a later microtask), then + // drain messages buffered while disconnected. tool_results route by requestId + // on the authenticated connection, so drain order vs hello is not significant. + void this.advertiseState().catch((err) => { + this.logger.warn("cloud runtime advertise state failed", { + error: err instanceof Error ? err.message : String(err), + }); + }); const pending = this.outbox; this.outbox = []; for (const text of pending) { @@ -133,13 +148,22 @@ export class CloudRuntime { this.logger.warn("cloud runtime socket error", { error: err.message }); } - private async advertiseProjects(): Promise { - const projects = await this.executor.advertisedProjects(); + private async advertiseState(): Promise { + const [projects, tools] = await Promise.all([ + this.executor.advertisedProjects(), + this.executor.advertisedTools?.().catch((err: unknown) => { + this.logger.warn("cloud runtime external MCP tool advertisement failed", { + error: err instanceof Error ? err.message : String(err), + }); + return []; + }) ?? Promise.resolve([]), + ]); this.send({ type: "hello", runtimeId: this.cloud.runtime_id, runtimeName: this.cloud.runtime_name, projects, + tools, }); } diff --git a/packages/daemon/src/external-mcp-tools.ts b/packages/daemon/src/external-mcp-tools.ts new file mode 100644 index 0000000000..6f1b2c735d --- /dev/null +++ b/packages/daemon/src/external-mcp-tools.ts @@ -0,0 +1,245 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport, getDefaultEnvironment } from "@modelcontextprotocol/sdk/client/stdio.js"; +import { CallToolResultSchema, type Tool } from "@modelcontextprotocol/sdk/types.js"; + +export interface ExternalMcpToolConfig { + id: string; + command: string; + args?: string[]; + cwd?: string; + env?: Record; +} + +export interface ExternalMcpToolExecution { + handled: boolean; + result?: unknown; +} + +interface ExternalMcpConnection { + client: Client; + transport: StdioClientTransport; +} + +const DEFAULT_BROWSER_MCP_ID = "gsd-browser"; + +// Minimum interval between route refreshes triggered by an unknown tool name in +// executeIfAvailable(). Bounds how often remote-supplied names can drive +// listTools()/spawn work while still letting a newly advertised tool be +// discovered within the window. +const ROUTE_REFRESH_TTL_MS = 60_000; + +export class ExternalMcpToolBridge { + private readonly connections = new Map(); + private readonly connecting = new Map>(); + private readonly toolRoutes = new Map(); + private routesRefreshedAt = 0; + + constructor(private readonly configs: ExternalMcpToolConfig[]) {} + + static fromEnvironment(env: NodeJS.ProcessEnv = process.env): ExternalMcpToolBridge { + return new ExternalMcpToolBridge(readExternalMcpToolConfigs(env)); + } + + async advertisedTools(): Promise { + const tools: Tool[] = []; + const seen = new Set(); + this.toolRoutes.clear(); + for (const config of this.configs) { + try { + const connection = await this.connectionFor(config); + const result = await connection.client.listTools(undefined, { timeout: 10_000 }); + for (const tool of result.tools) { + if (seen.has(tool.name)) continue; + seen.add(tool.name); + this.toolRoutes.set(tool.name, config.id); + tools.push(tool); + } + } catch { + await this.closeConnection(config.id); + } + } + // Record that a full refresh attempt completed so executeIfAvailable() can + // rate-limit refreshes driven by unknown tool names. + this.routesRefreshedAt = Date.now(); + return tools.sort((a, b) => a.name.localeCompare(b.name)); + } + + async executeIfAvailable(toolName: string, args: Record): Promise { + let configId = this.toolRoutes.get(toolName); + if (!configId && Date.now() - this.routesRefreshedAt >= ROUTE_REFRESH_TTL_MS) { + // Routes are only populated by advertisedTools(); a call can arrive before + // that has run (or after the routes were cleared). Refresh at most once per + // ROUTE_REFRESH_TTL_MS before deciding the tool is not ours, so a valid + // forwarded tool isn't rejected while a stream of unknown names from remote + // callers cannot force unbounded listTools()/spawn work (a DoS vector). + await this.advertisedTools().catch(() => undefined); + configId = this.toolRoutes.get(toolName); + } + if (!configId) return { handled: false }; + + const config = this.configs.find((candidate) => candidate.id === configId); + if (!config) return { handled: false }; + + try { + const connection = await this.connectionFor(config); + const result = await connection.client.callTool( + { name: toolName, arguments: args }, + CallToolResultSchema, + { timeout: 10 * 60 * 1000, resetTimeoutOnProgress: true }, + ); + return { handled: true, result }; + } catch (err) { + await this.closeConnection(config.id); + throw err; + } + } + + async close(): Promise { + // Drain in-flight connect attempts first. A successful attempt moves its + // connection into this.connections (see connectionFor), so awaiting the + // connecting map before closing established connections ensures a child + // process / stdio transport that finished connecting during shutdown is + // still closed rather than leaked. Failures already cleaned up after + // themselves in openConnection(). + await Promise.all( + Array.from(this.connecting.values()).map((attempt) => attempt.catch(() => undefined)), + ); + await Promise.all(Array.from(this.connections.keys()).map((id) => this.closeConnection(id))); + } + + private async connectionFor(config: ExternalMcpToolConfig): Promise { + const existing = this.connections.get(config.id); + if (existing) return existing; + + // Share one connect attempt across concurrent callers for the same config so + // two simultaneous tool calls cannot each spawn a child process (and leak the + // loser's transport). The in-flight entry is cleared on success and failure. + const inFlight = this.connecting.get(config.id); + if (inFlight) return inFlight; + + const attempt = this.openConnection(config).then( + (connection) => { + this.connecting.delete(config.id); + this.connections.set(config.id, connection); + return connection; + }, + (err) => { + this.connecting.delete(config.id); + throw err; + }, + ); + this.connecting.set(config.id, attempt); + return attempt; + } + + private async openConnection(config: ExternalMcpToolConfig): Promise { + const transport = new StdioClientTransport({ + command: config.command, + args: config.args ?? [], + ...(config.cwd ? { cwd: config.cwd } : {}), + // Merge the config's env over the SDK's safe default environment + // (getDefaultEnvironment: PATH, HOME, and other required vars) instead of + // replacing it. Passing config.env alone would drop PATH and cause the + // child MCP server to fail to spawn. This mirrors the transport's own + // default (getDefaultEnvironment() when env is unset) while adding the + // configured overrides, and avoids leaking the full process.env. + ...(config.env ? { env: { ...getDefaultEnvironment(), ...config.env } } : {}), + stderr: "pipe", + }); + transport.stderr?.on("data", () => { + // Drain child stderr so a noisy MCP server cannot block on a full pipe. + }); + const client = new Client({ name: `gsd-cloud-runtime-${config.id}`, version: "1.0.0" }); + try { + await client.connect(transport, { timeout: 10_000 }); + } catch (err) { + await transport.close().catch(() => undefined); + throw err; + } + return { client, transport }; + } + + private async closeConnection(id: string): Promise { + const connection = this.connections.get(id); + this.connections.delete(id); + if (!connection) return; + try { + await connection.client.close(); + } catch { + // Fall through: the transport is still closed below so a failed client + // close cannot leak the stdio child process / pipes. + } finally { + // Always close the transport, not just on the client-close error path. + // Client.close() usually closes the transport too, but that is not + // guaranteed, and this close is idempotent, so on the success path this + // ensures the stdio child process / pipes are released. + await connection.transport.close().catch(() => undefined); + } + } +} + +function readExternalMcpToolConfigs(env: NodeJS.ProcessEnv): ExternalMcpToolConfig[] { + const explicit = parseExplicitConfigs(env.GSD_CLOUD_MCP_SERVERS); + if (explicit) return explicit; + + const browserFlag = env.GSD_CLOUD_BROWSER_MCP?.trim().toLowerCase(); + if (browserFlag === "0" || browserFlag === "false" || browserFlag === "off") return []; + + return [{ + id: DEFAULT_BROWSER_MCP_ID, + command: env.GSD_CLOUD_BROWSER_MCP_COMMAND || env.GSD_BROWSER_MCP_COMMAND || "gsd-browser", + args: parseArgsValue(env.GSD_CLOUD_BROWSER_MCP_ARGS || env.GSD_BROWSER_MCP_ARGS, ["mcp"]), + }]; +} + +function parseExplicitConfigs(value: string | undefined): ExternalMcpToolConfig[] | undefined { + if (!value?.trim()) return undefined; + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch (err) { + throw new Error( + `GSD_CLOUD_MCP_SERVERS must be valid JSON: ${err instanceof Error ? err.message : String(err)}`, + ); + } + if (!Array.isArray(parsed)) throw new Error("GSD_CLOUD_MCP_SERVERS must be a JSON array"); + return parsed.map((item, index) => { + if (!isRecord(item) || typeof item.command !== "string" || !item.command.trim()) { + throw new Error(`GSD_CLOUD_MCP_SERVERS[${index}] must include command`); + } + return { + id: typeof item.id === "string" && item.id.trim() ? item.id.trim() : `external-${index + 1}`, + command: item.command.trim(), + args: parseArgsValue(item.args, []), + ...(typeof item.cwd === "string" ? { cwd: item.cwd } : {}), + ...(isStringRecord(item.env) ? { env: item.env } : {}), + }; + }); +} + +function parseArgsValue(value: unknown, fallback: string[]): string[] { + if (value === undefined) return fallback; + if (Array.isArray(value)) return value.filter((item): item is string => typeof item === "string"); + if (typeof value !== "string") return fallback; + const trimmed = value.trim(); + if (!trimmed) return fallback; + if (trimmed.startsWith("[")) { + try { + const parsed = JSON.parse(trimmed) as unknown; + if (Array.isArray(parsed)) return parsed.filter((item): item is string => typeof item === "string"); + } catch { + // Malformed JSON array (e.g. a partially copied env value): fall back to + // whitespace splitting below instead of throwing and crashing the daemon. + } + } + return trimmed.split(/\s+/).filter(Boolean); +} + +function isStringRecord(value: unknown): value is Record { + if (!isRecord(value)) return false; + return Object.values(value).every((item) => typeof item === "string"); +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} diff --git a/packages/daemon/src/local-tool-executor.test.ts b/packages/daemon/src/local-tool-executor.test.ts index f0a1d92b01..3f691bc847 100644 --- a/packages/daemon/src/local-tool-executor.test.ts +++ b/packages/daemon/src/local-tool-executor.test.ts @@ -8,7 +8,13 @@ import type { SessionManager } from "./session-manager.js"; import type { ProjectInfo } from "./types.js"; test("local tool executor rejects unsupported user-controlled tool names", async () => { - const executor = new LocalToolExecutor({} as SessionManager, async () => []); + // Inject an inert external MCP bridge so the unknown-tool path stays hermetic: + // the default bridge would spawn the configured external MCP server (gsd-browser) + // and, left unclosed, leak that child process and hang the test runner. + const executor = new LocalToolExecutor({} as SessionManager, async () => [], { + advertisedTools: async () => [], + executeIfAvailable: async () => ({ handled: false }), + }); await assert.rejects( executor.execute("constructor", {}), @@ -16,6 +22,35 @@ test("local tool executor rejects unsupported user-controlled tool names", async ); }); +test("local tool executor forwards runtime-advertised external MCP tools", async () => { + let forwarded: { toolName: string; args: Record } | undefined; + const executor = new LocalToolExecutor({} as SessionManager, async () => [], { + advertisedTools: async () => [{ + name: "browser_navigate", + inputSchema: { type: "object", properties: { url: { type: "string" } } }, + }], + executeIfAvailable: async (toolName, args) => { + forwarded = { toolName, args }; + return { + handled: true, + result: { content: [{ type: "text", text: "navigated" }] }, + }; + }, + }); + + assert.deepEqual(await executor.advertisedTools(), [{ + name: "browser_navigate", + inputSchema: { type: "object", properties: { url: { type: "string" } } }, + }]); + assert.deepEqual(await executor.execute("browser_navigate", { url: "https://example.com" }), { + content: [{ type: "text", text: "navigated" }], + }); + assert.deepEqual(forwarded, { + toolName: "browser_navigate", + args: { url: "https://example.com" }, + }); +}); + test("local tool executor rejects unadvertised project paths", async () => { const executor = new LocalToolExecutor({} as SessionManager, async () => []); diff --git a/packages/daemon/src/local-tool-executor.ts b/packages/daemon/src/local-tool-executor.ts index 3b92333b5c..d87dc0944f 100644 --- a/packages/daemon/src/local-tool-executor.ts +++ b/packages/daemon/src/local-tool-executor.ts @@ -19,11 +19,17 @@ import { writeSnapshot, WORKFLOW_TOOL_NAMES, } from "@opengsd/mcp-server"; +import { ExternalMcpToolBridge, type ExternalMcpToolExecution } from "./external-mcp-tools.js"; import type { SessionManager } from "./session-manager.js"; import type { ProjectInfo } from "./types.js"; type ToolHandler = (args: Record, extra?: Record) => Promise; const workflowRequestId = Symbol("workflowRequestId"); +interface ExternalMcpTools { + advertisedTools(): Promise; + executeIfAvailable(toolName: string, args: Record): Promise; + close?(): Promise; +} const WORKFLOW_TOOL_NAME_SET = new Set(WORKFLOW_TOOL_NAMES); const QUERY_FIELDS = { all: ["state", "project", "requirements", "milestones"], @@ -43,6 +49,7 @@ export class LocalToolExecutor { constructor( private readonly sessionManager: SessionManager, private readonly scanProjects: () => Promise, + private readonly externalMcpTools: ExternalMcpTools = ExternalMcpToolBridge.fromEnvironment(), ) { registerWorkflowTools({ tool: (name: string, _description: string, _params: Record, handler: ToolHandler) => { @@ -135,10 +142,27 @@ export class LocalToolExecutor { case "gsd_graph": return { content: [{ type: "text", text: JSON.stringify(await this.executeGraph(args), null, 2) }] }; default: + { + const external = await this.externalMcpTools.executeIfAvailable(toolName, args); + if (external.handled) { + if (external.result === undefined) { + throw new Error(`Forwarded external MCP tool returned no result: ${toolName}`); + } + return external.result; + } + } throw new Error(`Unsupported forwarded GSD MCP tool: ${toolName}`); } } + async advertisedTools(): Promise { + return this.externalMcpTools.advertisedTools(); + } + + async close(): Promise { + await this.externalMcpTools.close?.(); + } + async advertisedProjects(): Promise +``` + +Loopback HTTP for local development can run without auth: + +```bash +gsd-mcp-server --http --host 127.0.0.1 --port 8787 +``` + +Non-loopback hosts refuse unauthenticated startup by default. To override intentionally, pass `--no-auth`. + ### Claude Code Add to your project's `.mcp.json`: diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json index 36ff7b6ff9..5cc90ab21c 100644 --- a/packages/mcp-server/package.json +++ b/packages/mcp-server/package.json @@ -47,7 +47,7 @@ "scripts": { "build": "node ../../scripts/clean-package-dist.cjs && tsc --incremental false", "build:test": "tsc -p tsconfig.test.json", - "test": "pnpm run build:test && node --test dist/mcp-server.test.js dist/remote-questions.test.js dist/moonshot-tool-schema.test.js dist/pid-registry.test.js dist/probe-mode.test.js dist/stdio-watchdog.test.js dist/cli-runner.test.js dist/readers/graph.test.js dist/readers/paths.test.js dist/readers/readers.test.js" + "test": "pnpm run build:test && node --test dist/mcp-server.test.js dist/remote-questions.test.js dist/http.test.js dist/moonshot-tool-schema.test.js dist/pid-registry.test.js dist/probe-mode.test.js dist/stdio-watchdog.test.js dist/cli-runner.test.js dist/readers/graph.test.js dist/readers/paths.test.js dist/readers/readers.test.js" }, "dependencies": { "@gsd/pi-ai": "workspace:*", diff --git a/packages/mcp-server/src/cli-runner.test.ts b/packages/mcp-server/src/cli-runner.test.ts index 71f8c54ae0..6f3f8bd813 100644 --- a/packages/mcp-server/src/cli-runner.test.ts +++ b/packages/mcp-server/src/cli-runner.test.ts @@ -6,7 +6,8 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { PassThrough, type Readable, Writable } from 'node:stream'; -import { runMcpServerCli } from './cli-runner.js'; +import { parseMcpServerCliArgs, runMcpServerCli } from './cli-runner.js'; +import type { HttpMcpServerOptions } from './http.js'; class ExitError extends Error { constructor(readonly code: number) { @@ -1331,4 +1332,172 @@ describe('runMcpServerCli', () => { rmSync(gsdHome, { recursive: true, force: true }); } }); + + test('starts the HTTP transport with parsed flags instead of stdio', async () => { + const calls: string[] = []; + let received: HttpMcpServerOptions | undefined; + const stderr = new Writable({ write(_chunk, _encoding, callback) { callback(); } }); + + await runMcpServerCli({ + argv: ['--http', '--host', '0.0.0.0', '--port', '9911', '--auth-token', 'secret-token'], + env: {}, + exit(code) { + throw new ExitError(code); + }, + loadStoredCredentialEnvKeys() { + calls.push('load-env'); + }, + registerMcpInstance() { + throw new Error('http mode must not touch the PID registry'); + }, + sweepProjectOrphanMcpServers() { + throw new Error('http mode must not sweep orphans'); + }, + createSessionManager() { + calls.push('create-session-manager'); + return { async cleanup() { calls.push('cleanup-session-manager'); } }; + }, + async importStdioServerTransport() { + throw new Error('http mode must not import the stdio transport'); + }, + warmWorkflowToolBridges() { + throw new Error('http mode must not warm stdio bridges'); + }, + async listenHttpMcpServer(_manager, httpOptions) { + received = httpOptions; + calls.push('listen-http'); + return { url: 'http://0.0.0.0:9911/mcp', async close() { calls.push('close-http'); } }; + }, + stderr, + onSignal() {}, + }); + + assert.deepEqual(received, { host: '0.0.0.0', port: 9911, authToken: 'secret-token', allowNoAuth: false }); + assert.deepEqual(calls, ['load-env', 'create-session-manager', 'listen-http']); + }); + + test('falls back to GSD_MCP_AUTH_TOKEN, defaults host/port, and honors --no-auth', async () => { + let received: HttpMcpServerOptions | undefined; + const stderr = new Writable({ write(_chunk, _encoding, callback) { callback(); } }); + + await runMcpServerCli({ + argv: ['--http', '--no-auth'], + env: { GSD_MCP_AUTH_TOKEN: 'env-token' }, + exit(code) { + throw new ExitError(code); + }, + loadStoredCredentialEnvKeys() {}, + createSessionManager() { + return { async cleanup() {} }; + }, + async listenHttpMcpServer(_manager, httpOptions) { + received = httpOptions; + return { url: 'http://127.0.0.1:8787/mcp', async close() {} }; + }, + stderr, + onSignal() {}, + }); + + assert.deepEqual(received, { host: '127.0.0.1', port: 8787, authToken: 'env-token', allowNoAuth: true }); + }); + + test('exits non-zero and cleans up the session manager when the HTTP listener fails', async () => { + const calls: string[] = []; + const stderrChunks: string[] = []; + const stderr = new Writable({ + write(chunk, _encoding, callback) { + stderrChunks.push(String(chunk)); + callback(); + }, + }); + + await assert.rejects( + runMcpServerCli({ + argv: ['--http', '--host', '0.0.0.0'], + env: {}, + exit(code) { + throw new ExitError(code); + }, + loadStoredCredentialEnvKeys() {}, + createSessionManager() { + return { async cleanup() { calls.push('cleanup'); } }; + }, + async listenHttpMcpServer() { + calls.push('listen'); + throw new Error('EADDRINUSE'); + }, + stderr, + onSignal() {}, + }), + (error) => error instanceof ExitError && error.code === 1, + ); + + assert.deepEqual(calls, ['listen', 'cleanup']); + assert.match(stderrChunks.join(''), /failed to start HTTP server/); + }); + + test('rejects an invalid --port before attempting to listen', async () => { + const calls: string[] = []; + const stderrChunks: string[] = []; + const stderr = new Writable({ + write(chunk, _encoding, callback) { + stderrChunks.push(String(chunk)); + callback(); + }, + }); + + await assert.rejects( + runMcpServerCli({ + argv: ['--http', '--port', 'not-a-number'], + env: {}, + exit(code) { + throw new ExitError(code); + }, + loadStoredCredentialEnvKeys() {}, + createSessionManager() { + return { async cleanup() { calls.push('cleanup'); } }; + }, + async listenHttpMcpServer() { + calls.push('listen'); + return { url: 'http://unused/mcp', async close() {} }; + }, + stderr, + onSignal() {}, + }), + (error) => error instanceof ExitError && error.code === 1, + ); + + assert.deepEqual(calls, ['cleanup']); + // The error preserves the user's raw input rather than reporting "NaN". + assert.match(stderrChunks.join(''), /invalid --port: "not-a-number"/); + }); +}); + +describe('parseMcpServerCliArgs', () => { + test('defaults to stdio mode with no flags', () => { + assert.deepEqual(parseMcpServerCliArgs([]), { http: false, noAuth: false }); + }); + + test('parses both --flag value and --flag=value forms', () => { + assert.deepEqual( + parseMcpServerCliArgs(['--http', '--host', '0.0.0.0', '--port=8080', '--auth-token', 'abc', '--no-auth']), + { http: true, host: '0.0.0.0', port: 8080, portRaw: '8080', authToken: 'abc', noAuth: true }, + ); + }); + + test('throws on an unknown option', () => { + assert.throws(() => parseMcpServerCliArgs(['--bogus']), /unknown option: --bogus/); + }); + + test('throws when a value-taking flag is missing its value', () => { + assert.throws(() => parseMcpServerCliArgs(['--host']), /missing value for --host/); + }); + + test('does not consume a following flag as a value', () => { + assert.throws(() => parseMcpServerCliArgs(['--host', '--http']), /missing value for --host/); + }); + + test('accepts a --flag=value whose value starts with dashes', () => { + assert.equal(parseMcpServerCliArgs(['--auth-token=--literal']).authToken, '--literal'); + }); }); diff --git a/packages/mcp-server/src/cli-runner.ts b/packages/mcp-server/src/cli-runner.ts index 98562d5747..6d5db47c3f 100644 --- a/packages/mcp-server/src/cli-runner.ts +++ b/packages/mcp-server/src/cli-runner.ts @@ -2,6 +2,7 @@ import type { Readable, Writable } from 'node:stream'; import { Worker } from 'node:worker_threads'; import { SessionManager } from './session-manager.js'; +import type { HttpMcpServerOptions } from './http.js'; import { createMcpServer } from './server.js'; import { loadStoredCredentialEnvKeys } from './tool-credentials.js'; import { @@ -23,6 +24,69 @@ const STDIN_IDLE_TIMEOUT_MS = 5 * 60 * 1000; const STDIN_IDLE_CHECK_INTERVAL_MS = 60 * 1000; const CLEANUP_STEP_TIMEOUT_MS = 2 * 1000; +const DEFAULT_HTTP_HOST = '127.0.0.1'; +const DEFAULT_HTTP_PORT = 8787; + +export interface McpServerCliArgs { + http: boolean; + host?: string; + port?: number; + /** The raw `--port` string as the user passed it, retained for error messages. */ + portRaw?: string; + authToken?: string; + noAuth: boolean; +} + +/** + * Parse the flags documented in the package README for the Streamable HTTP + * transport: `--http`, `--host`, `--port`, `--auth-token`, and `--no-auth`. + * Supports both `--flag value` and `--flag=value` forms and throws on unknown + * options or missing values so typos surface instead of being silently ignored. + */ +export function parseMcpServerCliArgs(argv: readonly string[]): McpServerCliArgs { + const args: McpServerCliArgs = { http: false, noAuth: false }; + for (let i = 0; i < argv.length; i++) { + const raw = argv[i]; + const eq = raw.indexOf('='); + const flag = eq === -1 ? raw : raw.slice(0, eq); + const inlineValue = eq === -1 ? undefined : raw.slice(eq + 1); + const takeValue = (): string => { + if (inlineValue !== undefined) return inlineValue; + const next = argv[i + 1]; + // Treat a following flag-looking token as a missing value instead of + // silently consuming it (e.g. `--host --http` must not set host to + // "--http"); use the `--flag=value` form for a value that must start + // with "--". + if (next === undefined || next.startsWith('--')) throw new Error(`missing value for ${flag}`); + i += 1; + return next; + }; + switch (flag) { + case '--http': + args.http = true; + break; + case '--no-auth': + args.noAuth = true; + break; + case '--host': + args.host = takeValue(); + break; + case '--port': { + const rawPort = takeValue(); + args.portRaw = rawPort; + args.port = Number(rawPort); + break; + } + case '--auth-token': + args.authToken = takeValue(); + break; + default: + throw new Error(`unknown option: ${raw}`); + } + } + return args; +} + /** * Cadence for the worker-thread parent-liveness monitor. * @@ -94,9 +158,14 @@ interface OrphanMonitorHandle { } export interface RunMcpServerCliOptions { + argv?: readonly string[]; cwd?: () => string; env?: NodeJS.ProcessEnv; exit?: (code: number) => never; + listenHttpMcpServer?: ( + sessionManager: SessionManagerLike, + options: HttpMcpServerOptions, + ) => Promise<{ close: () => Promise; url: string }>; loadStoredCredentialEnvKeys?: () => void; registerMcpInstance?: (projectDir: string) => boolean | void; sweepProjectOrphanMcpServers?: (projectDir: string) => void; @@ -197,6 +266,17 @@ async function importDefaultStdioServerTransport(): Promise<{ StdioServerTranspo return import(`${MCP_PKG}/server/stdio.js`) as Promise<{ StdioServerTransport: StdioTransportConstructor }>; } +// Imported lazily so the common stdio path never loads the Streamable HTTP +// transport or its SDK dependency. +async function importDefaultListenHttpMcpServer(): Promise< + ( + sessionManager: SessionManager, + options: HttpMcpServerOptions, + ) => Promise<{ close: () => Promise; url: string }> +> { + return (await import('./http.js')).listenHttpMcpServer; +} + export async function runMcpServerCli(options: RunMcpServerCliOptions = {}): Promise { const cwd = options.cwd ?? (() => process.cwd()); const env = options.env ?? process.env; @@ -226,6 +306,57 @@ export async function runMcpServerCli(options: RunMcpServerCliOptions = {}): Pro const warmBridges = options.warmWorkflowToolBridges ?? warmWorkflowToolBridges; const resolveObservationTokenState = options.resolveMilestoneStatusObservationTokenState ?? resolveMilestoneStatusObservationTokenState; + const listenHttp = options.listenHttpMcpServer ?? (async ( + manager: SessionManagerLike, + httpOptions: HttpMcpServerOptions, + ) => (await importDefaultListenHttpMcpServer())(manager as SessionManager, httpOptions)); + + const cliArgs = parseMcpServerCliArgs(options.argv ?? process.argv.slice(2)); + + if (cliArgs.http) { + // Streamable HTTP transport (see README "Cloud / Remote HTTP"). This is a + // long-running standalone server, so it skips the stdio-only machinery + // (PID registry, stdin idle watchdog, orphan monitors) and just listens + // until a signal arrives. + loadEnv(); + const host = cliArgs.host ?? DEFAULT_HTTP_HOST; + const port = cliArgs.port ?? DEFAULT_HTTP_PORT; + const authToken = cliArgs.authToken ?? env.GSD_MCP_AUTH_TOKEN; + const sessionManager = createSessionManager(); + let httpHandle: { close: () => Promise; url: string } | undefined; + let httpCleaningUp = false; + const cleanupHttp = async (code = 0): Promise => { + if (httpCleaningUp) return; + httpCleaningUp = true; + stderr.write('[gsd-mcp-server] Shutting down...\n'); + try { + await httpHandle?.close(); + } catch { + // best-effort shutdown + } + try { + await sessionManager.cleanup(); + } catch { + // best-effort shutdown + } + exit(code); + }; + onSignal('SIGTERM', () => void cleanupHttp()); + onSignal('SIGINT', () => void cleanupHttp()); + try { + if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw new Error(`invalid --port: ${JSON.stringify(cliArgs.portRaw ?? String(cliArgs.port))}`); + } + httpHandle = await listenHttp(sessionManager, { host, port, authToken, allowNoAuth: cliArgs.noAuth }); + stderr.write(`[gsd-mcp-server] MCP server listening on ${httpHandle.url}\n`); + } catch (err) { + stderr.write( + `[gsd-mcp-server] Fatal: failed to start HTTP server: ${err instanceof Error ? err.message : String(err)}\n`, + ); + await cleanupHttp(1); + } + return; + } loadEnv(); diff --git a/packages/mcp-server/src/cli.ts b/packages/mcp-server/src/cli.ts index f147ac5bfd..c8edfd5eae 100644 --- a/packages/mcp-server/src/cli.ts +++ b/packages/mcp-server/src/cli.ts @@ -1,9 +1,10 @@ #!/usr/bin/env node /** - * @opengsd/mcp-server CLI — stdio transport entry point. + * @opengsd/mcp-server CLI entry point. * - * Connects the MCP server to stdin/stdout for use by Claude Code, - * Cursor, and other MCP-compatible clients. + * Defaults to the stdio transport for local MCP clients (Claude Code, Cursor, + * etc.) that spawn the server process. Passing `--http` instead starts the + * authenticated Streamable HTTP transport documented in the package README. */ import { installGlobalErrorHandlers } from './cli-errors.js'; @@ -13,7 +14,7 @@ installGlobalErrorHandlers(); runMcpServerCli().catch((err) => { process.stderr.write( - `[gsd-mcp-server] Fatal: ${err instanceof Error ? err.message : String(err)}\n` + `[gsd-mcp-server] Fatal: ${err instanceof Error ? err.message : String(err)}\n`, ); process.exit(1); }); diff --git a/packages/mcp-server/src/http.test.ts b/packages/mcp-server/src/http.test.ts new file mode 100644 index 0000000000..0bfab30994 --- /dev/null +++ b/packages/mcp-server/src/http.test.ts @@ -0,0 +1,33 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { formatUrlHost, isLoopbackHost, validateHttpMcpOptions } from './http.js'; + +test('HTTP MCP refuses unauthenticated public bind by default', () => { + assert.throws( + () => validateHttpMcpOptions({ host: '0.0.0.0', port: 8787 }), + /refusing to expose unauthenticated/, + ); +}); + +test('HTTP MCP allows loopback development without auth', () => { + assert.doesNotThrow(() => validateHttpMcpOptions({ host: '127.0.0.1', port: 8787 })); + assert.doesNotThrow(() => validateHttpMcpOptions({ host: 'localhost', port: 8787 })); + assert.equal(isLoopbackHost('::1'), true); +}); + +test('HTTP MCP allows public bind with bearer token', () => { + assert.doesNotThrow(() => + validateHttpMcpOptions({ host: '0.0.0.0', port: 8787, authToken: 'secret' }), + ); +}); + +test('formatUrlHost brackets IPv6 literals for a valid URL authority', () => { + assert.equal(formatUrlHost('::1'), '[::1]'); + assert.equal(formatUrlHost('::'), '[::]'); + assert.equal(formatUrlHost('2001:db8::1'), '[2001:db8::1]'); + // Already-bracketed input must not be double-wrapped. + assert.equal(formatUrlHost('[::1]'), '[::1]'); + // IPv4 and hostnames pass through unchanged. + assert.equal(formatUrlHost('127.0.0.1'), '127.0.0.1'); + assert.equal(formatUrlHost('localhost'), 'localhost'); +}); diff --git a/packages/mcp-server/src/http.ts b/packages/mcp-server/src/http.ts new file mode 100644 index 0000000000..9db1117c16 --- /dev/null +++ b/packages/mcp-server/src/http.ts @@ -0,0 +1,188 @@ +import { timingSafeEqual } from 'node:crypto'; +import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import { createMcpServer } from './server.js'; +import type { SessionManager } from './session-manager.js'; + +const MAX_JSON_BODY_BYTES = 1024 * 1024; + +export interface HttpMcpServerOptions { + host: string; + port: number; + authToken?: string; + allowNoAuth?: boolean; +} + +export async function listenHttpMcpServer( + sessionManager: SessionManager, + options: HttpMcpServerOptions, +): Promise<{ close: () => Promise; url: string }> { + validateHttpMcpOptions(options); + + const server = createServer(async (req, res) => { + try { + const url = new URL(req.url ?? '/', 'http://localhost'); + + if (req.method === 'GET' && url.pathname === '/healthz') { + return sendJson(res, 200, { ok: true, server: 'gsd-mcp-server', mcpPath: '/mcp' }); + } + + // Accept both '/mcp' and '/mcp/': some proxies and clients normalize or + // append a trailing slash, and the cloud gateway already accepts both. + if (url.pathname !== '/mcp' && url.pathname !== '/mcp/') { + return sendJson(res, 404, { error: 'Not found' }); + } + + if (!authorize(req, options)) { + res.writeHead(401, { + 'content-type': 'application/json', + 'www-authenticate': 'Bearer', + }); + return res.end(JSON.stringify({ error: 'missing or invalid bearer token' })); + } + + const body = req.method === 'POST' ? await readJson(req) : undefined; + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + }); + const { server: mcpServer } = await createMcpServer(sessionManager); + let mcpClosed = false; + const closeMcp = () => { + if (mcpClosed) return; + mcpClosed = true; + void mcpServer.close().catch(() => undefined); + }; + // Close on finish (normal completion) and on close (client aborted or + // disconnected before finish fires) so the per-request server/transport + // can't leak under load. The guard keeps it a single close. + res.on('finish', closeMcp); + res.on('close', closeMcp); + await mcpServer.connect(transport); + await transport.handleRequest(req, res, body); + } catch (err) { + if (err instanceof BadRequestError) { + return sendJson(res, 400, { error: err.message }); + } + return sendJson(res, 500, { error: 'Internal server error' }); + } + }); + + // Node's server.listen expects a bare IPv6 literal (e.g. `::1`), not a + // bracketed one; strip any brackets that formatUrlHost() re-adds for the URL. + const listenHost = options.host.replace(/^\[/, '').replace(/\]$/, ''); + await new Promise((resolve, reject) => { + // A listen error (e.g. EADDRINUSE) fires the server's 'error' event, not the + // listen callback, so tie it into the promise to fail startup fast instead of + // awaiting forever. + const onError = (err: Error) => reject(err); + server.once('error', onError); + server.listen(options.port, listenHost, () => { + server.removeListener('error', onError); + resolve(); + }); + }); + const displayHost = listenHost === '0.0.0.0' ? 'localhost' : listenHost; + return { + url: `http://${formatUrlHost(displayHost)}:${options.port}/mcp`, + close: () => new Promise((resolve, reject) => server.close((err) => err ? reject(err) : resolve())), + }; +} + +/** + * Format a listen host for use in a URL authority. IPv6 literals must be + * bracketed (e.g. `[::1]`) or the resulting URL is invalid; tolerate an + * already-bracketed value so we never double-wrap. + */ +export function formatUrlHost(host: string): string { + const bare = host.replace(/^\[/, '').replace(/\]$/, ''); + return bare.includes(':') ? `[${bare}]` : bare; +} + +export function validateHttpMcpOptions(options: HttpMcpServerOptions): void { + if (options.allowNoAuth || options.authToken?.trim() || isLoopbackHost(options.host)) return; + throw new Error( + 'refusing to expose unauthenticated gsd-mcp-server on a non-loopback host; set GSD_MCP_AUTH_TOKEN, pass --auth-token, or explicitly pass --no-auth', + ); +} + +export function isLoopbackHost(host: string): boolean { + const normalized = host.trim().replace(/^\[/, '').replace(/\]$/, '').toLowerCase(); + // Reduce an IPv4-mapped IPv6 loopback (::ffff:127.x.x.x) to its IPv4 form so the + // 127.0.0.0/8 check below also covers the mapped representation. + const v4 = normalized.startsWith('::ffff:') ? normalized.slice('::ffff:'.length) : normalized; + return normalized === 'localhost' + || normalized === '::1' + // Full (uncompressed) IPv6 loopback form. + || normalized === '0:0:0:0:0:0:0:1' + || v4 === '127.0.0.1' + || v4.startsWith('127.'); +} + +function authorize(req: IncomingMessage, options: HttpMcpServerOptions): boolean { + // Match validateHttpMcpOptions(): a configured token that is empty after trim + // counts as "no token configured", so we don't require callers to send a blank + // token. This does not make a blank token a way to disable auth on a + // non-loopback host: validateHttpMcpOptions() already refuses to start there + // unless the host is loopback or --no-auth was passed explicitly. + const expected = options.authToken?.trim(); + if (options.allowNoAuth || !expected) return true; + const provided = extractBearerToken(req.headers.authorization); + if (!provided) return false; + const a = Buffer.from(provided); + const b = Buffer.from(expected); + return a.length === b.length && timingSafeEqual(a, b); +} + +function extractBearerToken(value: string | string[] | undefined): string | undefined { + const header = Array.isArray(value) ? value[0] : value; + if (!header) return undefined; + const trimmed = header.trim(); + const schemeEnd = findFirstWhitespaceIndex(trimmed); + if (schemeEnd <= 0) return undefined; + if (trimmed.slice(0, schemeEnd).toLowerCase() !== 'bearer') return undefined; + const token = trimmed.slice(schemeEnd).trimStart(); + return token || undefined; +} + +function findFirstWhitespaceIndex(value: string): number { + for (let i = 0; i < value.length; i++) { + const code = value.charCodeAt(i); + if (code === 9 || code === 10 || code === 12 || code === 13 || code === 32) return i; + } + return -1; +} + +async function readJson(req: IncomingMessage): Promise> { + const chunks: Buffer[] = []; + let totalBytes = 0; + for await (const chunk of req) { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + totalBytes += buffer.byteLength; + if (totalBytes > MAX_JSON_BODY_BYTES) { + throw new BadRequestError('Request body too large'); + } + chunks.push(buffer); + } + if (chunks.length === 0) return {}; + let parsed: unknown; + try { + parsed = JSON.parse(Buffer.concat(chunks).toString('utf8')); + } catch { + throw new BadRequestError('Invalid JSON request body'); + } + // Reject non-object JSON (arrays, strings, numbers, null) with a 400 instead of + // silently coercing it to {}, so a malformed request surfaces a clear error + // rather than being reshaped into a different (empty) request. + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new BadRequestError('Request body must be a JSON object'); + } + return parsed as Record; +} + +function sendJson(res: ServerResponse, status: number, body: unknown): void { + if (res.headersSent) return; + res.writeHead(status, { 'content-type': 'application/json' }); + res.end(JSON.stringify(body)); +} + +class BadRequestError extends Error {} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 426827c1e1..a030ba00f7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -34,6 +34,9 @@ importers: '@clack/prompts': specifier: ^1.1.0 version: 1.4.0 + '@clerk/backend': + specifier: ^3.4.14 + version: 3.4.14(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@google/genai': specifier: ^1.40.0 version: 1.52.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)) @@ -240,6 +243,9 @@ importers: packages/cloud-mcp-gateway: dependencies: + '@clerk/backend': + specifier: ^3.4.14 + version: 3.4.14(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@modelcontextprotocol/sdk': specifier: ^1.27.1 version: 1.29.0(zod@4.4.3) @@ -270,6 +276,9 @@ importers: '@anthropic-ai/sdk': specifier: ^0.91.1 version: 0.91.1(zod@3.25.76) + '@modelcontextprotocol/sdk': + specifier: ^1.27.1 + version: 1.29.0(zod@3.25.76) '@opengsd/contracts': specifier: workspace:* version: link:../contracts @@ -1056,6 +1065,22 @@ packages: resolution: {integrity: sha512-S0My7XPGIgpRWMDG8uRqalbgT+a6FmCUdOW+HaIOVVpUPHOb7RrpvjTjiODadKp06fsrVDJZlIzc6yCTp4AnxA==} engines: {node: '>= 20.12.0'} + '@clerk/backend@3.4.14': + resolution: {integrity: sha512-0iaMT7k4wDk31QVC3HMaoeVFttblwsCECTHKNQpbRzIyD8j2gHdKEw/FNjffoyqyBqPw869IQlk1YokUlwVAqQ==} + engines: {node: '>=20.9.0'} + + '@clerk/shared@4.14.0': + resolution: {integrity: sha512-StZCFJ2rg0ITE3fYjIN9CKuKq8ZACW6l2+5qGCFPPYd4hZphA+VDselmh/lHPsF1ex/WRVUSHvquykAHR8cQbQ==} + engines: {node: '>=20.9.0'} + peerDependencies: + react: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0 + react-dom: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0 + peerDependenciesMeta: + react: + optional: true + react-dom: + optional: true + '@codemirror/autocomplete@6.20.2': resolution: {integrity: sha512-G5FPkgIiLjOgZMjqVjvuKQ1rGPtHogLldJr33eFJdVLtmwY+giGrlv/ewljLz6b9BSQLkjxuwBc6g6omDM+YxQ==} @@ -3088,6 +3113,9 @@ packages: resolution: {integrity: sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==} engines: {node: '>=14.0.0'} + '@stablelib/base64@1.0.1': + resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -3182,6 +3210,9 @@ packages: '@tailwindcss/postcss@4.3.0': resolution: {integrity: sha512-Jm05Tjx+9yCLGv5qw1c+84Psds8MnyrEQYCB+FFk2lgGiUjlRqdxke4mVTuYrj2xnVZqKim2Apr5ySuQRYAw/w==} + '@tanstack/query-core@5.100.14': + resolution: {integrity: sha512-5X41dGpxgeaHISCRW2oYwcSycZeULZzAunaudXT9ov1KOTj9xwt0CH6hbwqP1/z74ZWF7rYFnDpyYH07XFcZew==} + '@tokenizer/inflate@0.4.1': resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==} engines: {node: '>=18'} @@ -4307,6 +4338,9 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-sha256@1.3.0: + resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==} + fast-string-truncated-width@3.0.3: resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} @@ -4502,6 +4536,9 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} + glob-to-regexp@0.4.1: + resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} + glob@10.5.0: resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me @@ -4844,6 +4881,10 @@ packages: jose@6.2.3: resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} + js-cookie@3.0.7: + resolution: {integrity: sha512-z/wZZgDrkNV1eA0ULjM/F9/50Ya8fbzgKneSpoPsXSGd0KnpdtHfOZWK+GcwLk+EZbS4F9RBhU+K2RgzuDaItw==} + engines: {node: '>=20'} + js-tokens@10.0.0: resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} @@ -5919,6 +5960,9 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + standardwebhooks@1.0.0: + resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==} + statuses@2.0.2: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} @@ -6868,6 +6912,26 @@ snapshots: fast-wrap-ansi: 0.2.2 sisteransi: 1.0.5 + '@clerk/backend@3.4.14(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@clerk/shared': 4.14.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + standardwebhooks: 1.0.0 + tslib: 2.8.1 + transitivePeerDependencies: + - react + - react-dom + + '@clerk/shared@4.14.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@tanstack/query-core': 5.100.14 + dequal: 2.0.3 + glob-to-regexp: 0.4.1 + js-cookie: 3.0.7 + std-env: 3.10.0 + optionalDependencies: + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + '@codemirror/autocomplete@6.20.2': dependencies: '@codemirror/language': 6.12.3 @@ -7869,7 +7933,6 @@ snapshots: zod-to-json-schema: 3.25.2(zod@3.25.76) transitivePeerDependencies: - supports-color - optional: true '@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)': dependencies: @@ -8890,6 +8953,8 @@ snapshots: '@smithy/util-buffer-from': 2.2.0 tslib: 2.8.1 + '@stablelib/base64@1.0.1': {} + '@standard-schema/spec@1.1.0': {} '@swc/helpers@0.5.15': @@ -8965,6 +9030,8 @@ snapshots: postcss: 8.5.18 tailwindcss: 4.3.0 + '@tanstack/query-core@5.100.14': {} + '@tokenizer/inflate@0.4.1': dependencies: debug: 4.4.3 @@ -10390,6 +10457,8 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-sha256@1.3.0: {} + fast-string-truncated-width@3.0.3: {} fast-string-width@3.0.2: @@ -10612,6 +10681,8 @@ snapshots: dependencies: is-glob: 4.0.3 + glob-to-regexp@0.4.1: {} + glob@10.5.0: dependencies: foreground-child: 3.3.1 @@ -10990,6 +11061,8 @@ snapshots: jose@6.2.3: {} + js-cookie@3.0.7: {} + js-tokens@10.0.0: {} js-tokens@4.0.0: {} @@ -12406,6 +12479,11 @@ snapshots: stackback@0.0.2: {} + standardwebhooks@1.0.0: + dependencies: + '@stablelib/base64': 1.0.1 + fast-sha256: 1.3.0 + statuses@2.0.2: {} std-env@3.10.0: {} diff --git a/tsconfig.json b/tsconfig.json index 32a9e9c7fb..9de4f2eff1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,10 +9,7 @@ "declaration": true, "incremental": true, "esModuleInterop": true, - "skipLibCheck": true, - "paths": { - "sharp": ["./node_modules/sharp/lib/index.d.ts"] - } + "skipLibCheck": true }, "include": ["src"], "exclude": ["src/resources/extensions", "src/resources/skills", "src/resources/agents", "src/tests", "src/web"]