@nullplatform/mcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +252 -0
- package/dist/config.js +26 -0
- package/dist/git.js +27 -0
- package/dist/http.js +330 -0
- package/dist/i18n.js +595 -0
- package/dist/index.js +72 -0
- package/dist/md.js +110 -0
- package/dist/np/auth.js +130 -0
- package/dist/np/client.js +72 -0
- package/dist/np/context.js +201 -0
- package/dist/np/journey.js +403 -0
- package/dist/prompts.js +64 -0
- package/dist/render.js +236 -0
- package/dist/server.js +91 -0
- package/dist/skills.js +84 -0
- package/dist/surfaces/developer.js +29 -0
- package/dist/surfaces/index.js +17 -0
- package/dist/surfaces/surface.js +1 -0
- package/dist/tool-names.js +25 -0
- package/dist/tool.js +92 -0
- package/dist/tools/approvals.js +80 -0
- package/dist/tools/builds.js +94 -0
- package/dist/tools/create-app.js +187 -0
- package/dist/tools/create-release.js +52 -0
- package/dist/tools/create-scope.js +82 -0
- package/dist/tools/deploy.js +178 -0
- package/dist/tools/find-apps.js +36 -0
- package/dist/tools/index.js +39 -0
- package/dist/tools/logs.js +83 -0
- package/dist/tools/metrics.js +83 -0
- package/dist/tools/overview.js +110 -0
- package/dist/tools/params.js +58 -0
- package/dist/tools/playbook.js +39 -0
- package/dist/tools/services.js +58 -0
- package/dist/tools/set-params.js +58 -0
- package/dist/tools/shared.js +141 -0
- package/dist/tools/status.js +70 -0
- package/dist/tools/traffic.js +74 -0
- package/dist/ui.js +76 -0
- package/package.json +65 -0
- package/skills/deploying-safely/SKILL.md +54 -0
- package/skills/incident-response/SKILL.md +52 -0
- package/skills/platform-conventions/SKILL.md +61 -0
- package/widgets-dist/create-app.html +830 -0
- package/widgets-dist/find-apps.html +831 -0
- package/widgets-dist/logs.html +830 -0
- package/widgets-dist/manifest.json +8 -0
- package/widgets-dist/metrics.html +829 -0
- package/widgets-dist/np-panel.html +831 -0
- package/widgets-dist/params.html +829 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 nullplatform
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
# ai-mcp — nullplatform from your code assistant
|
|
2
|
+
|
|
3
|
+
An MCP server that turns your code assistant (Claude Code, Cursor, Windsurf, …) into the
|
|
4
|
+
**frontend for nullplatform**. Status, deploys, traffic, rollbacks, logs and config — from
|
|
5
|
+
the place you already work, aware of the repo you're sitting in.
|
|
6
|
+
|
|
7
|
+
```text
|
|
8
|
+
you › deploy this
|
|
9
|
+
claude › 🚀 Deploying #4312 on dev — ⏳ waiting for instances
|
|
10
|
+
Created release 1.4.2 from build #991 (main @ab12cd34)
|
|
11
|
+
→ Next: traffic percent:25 … traffic action:"finalize"
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Why
|
|
15
|
+
|
|
16
|
+
The web dashboard walks you through create → build → release → scope → deploy → observe.
|
|
17
|
+
Your assistant already knows your repo, your branch and what you just changed — so the same
|
|
18
|
+
journey collapses into a sentence: most tools work with **zero arguments** inside a linked repo.
|
|
19
|
+
|
|
20
|
+
## 60-second setup
|
|
21
|
+
|
|
22
|
+
You need a nullplatform API key. The server runs locally via `npx` — no install, no clone.
|
|
23
|
+
|
|
24
|
+
**Claude Code**
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
claude mcp add nullplatform -e NP_API_KEY=<your-key> -- npx -y @nullplatform/mcp
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
**Claude Desktop** — Settings → Developer → Edit Config (`claude_desktop_config.json`), then fully restart:
|
|
31
|
+
|
|
32
|
+
```json
|
|
33
|
+
{
|
|
34
|
+
"mcpServers": {
|
|
35
|
+
"nullplatform": {
|
|
36
|
+
"command": "npx",
|
|
37
|
+
"args": ["-y", "@nullplatform/mcp"],
|
|
38
|
+
"env": { "NP_API_KEY": "<your-key>" }
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
**Cursor / Windsurf / others** — same shape in their `mcp.json` (`command: "npx"`, `args: ["-y", "@nullplatform/mcp"]`).
|
|
45
|
+
|
|
46
|
+
**Remote/HTTP mode — bring your own key.** `npx -y @nullplatform/mcp --http 8080` → `http://host:8080/mcp`.
|
|
47
|
+
The server holds **no credential** (it refuses to start if `NP_API_KEY`/`NP_BEARER` are in its
|
|
48
|
+
environment). Every caller authenticates each request with their *own* key, so nullplatform's
|
|
49
|
+
RBAC applies to each user individually — you can only see and do what your platform user can:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
claude mcp add --transport http nullplatform https://host/mcp \
|
|
53
|
+
--header "Authorization: Bearer <your-NP_API_KEY>"
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
A pre-issued JWT works in place of the api key, and hosts that reserve `Authorization` can send
|
|
57
|
+
`X-NP-API-Key: <key>` instead. Unauthenticated requests get a `401` + `WWW-Authenticate` hint.
|
|
58
|
+
`GET /healthz` is the unauthenticated liveness probe. Requests carrying a browser `Origin` are
|
|
59
|
+
rejected unless allow-listed (DNS-rebinding guard), there's a 1 MiB body cap, and a generous
|
|
60
|
+
per-IP rate limit (default 600 req/min, tunable) so a key-rotating caller can't amplify load
|
|
61
|
+
against the platform's `/token`.
|
|
62
|
+
|
|
63
|
+
**Deploy it behind a TLS-terminating reverse proxy on a trusted network** — every request
|
|
64
|
+
carries a bearer credential, so plain HTTP must never be exposed. The proxy should also enforce
|
|
65
|
+
its own per-IP rate limits and connection caps; the in-process limiter is a backstop, not a
|
|
66
|
+
replacement. `X-Forwarded-For` is only trusted when `NP_TRUST_PROXY` is set.
|
|
67
|
+
|
|
68
|
+
The api_key → access-token **exchange is the trust boundary** and is treated like one. The
|
|
69
|
+
customer's key is **never retained**: it arrives with each request, exists only in that
|
|
70
|
+
request's async scope, and is used at most once per expiry window to (re)exchange — what's
|
|
71
|
+
cached per user (keyed by the key's SHA-256, never the key) is only the platform-issued
|
|
72
|
+
short-lived access token. Every credential is verified with the platform *before* it reaches
|
|
73
|
+
any tool (invalid ones stop at the edge as `401 invalid_token`), verification and exchange are
|
|
74
|
+
single-flighted per credential across concurrent requests, rejected keys are negative-cached
|
|
75
|
+
for a minute (they can't hammer the platform's `/token` or evict verified users), and secrets
|
|
76
|
+
never appear in logs or error bodies.
|
|
77
|
+
|
|
78
|
+
| Env | Meaning |
|
|
79
|
+
| --- | --- |
|
|
80
|
+
| `NP_API_KEY` | **stdio only** — your API key (exchanged for a bearer automatically) |
|
|
81
|
+
| `NP_BEARER` | **stdio only** — a pre-issued token instead (expires; for quick tests) |
|
|
82
|
+
| `NP_API_BASE` | Override the API host (default `https://api.nullplatform.com`) |
|
|
83
|
+
| `NP_BFF_BASE` | Override the dashboard BFF host (metrics) |
|
|
84
|
+
| `NP_ALLOWED_ORIGINS` | `--http` only: comma-separated browser origins allowed to call (server-to-server MCP clients send no Origin and always pass) |
|
|
85
|
+
| `NP_RATE_LIMIT_RPM` | `--http` only: per-IP requests/minute (default `600`; `0` disables — rely on the fronting proxy then) |
|
|
86
|
+
| `NP_TRUST_PROXY` | `--http` only: set to `1`/`true` to key the rate limit off `X-Forwarded-For` (only behind a proxy that sets it) |
|
|
87
|
+
| `NP_LANG` | Fallback answer language (`en`/`es`). The real driver is the **conversation**: every tool takes a `language` argument the assistant sets to the language the user is speaking, which wins over `Accept-Language` (HTTP) and `NP_LANG`/`LANG` (stdio). |
|
|
88
|
+
|
|
89
|
+
## The tools (15)
|
|
90
|
+
|
|
91
|
+
Every answer is scannable markdown with one **→ Next:** hint, plus structured JSON for the model.
|
|
92
|
+
Write tools are **convergent under retries** — an agent that re-issues a call after a timeout
|
|
93
|
+
converges instead of duplicating (see "Built for agents, not browsers" below).
|
|
94
|
+
|
|
95
|
+
| Tool | What it does |
|
|
96
|
+
| --- | --- |
|
|
97
|
+
| `application_get` | **Start here.** Scopes × what's live (release + traffic), latest build/release, next action. Zero-arg in a linked repo; `deployment:<id>` watches one rollout. |
|
|
98
|
+
| `organization_get` | Org-wide digest with no web equivalent: what's mid-rollout and what last failed, across all apps. Answers "is anything broken?" without naming an app. |
|
|
99
|
+
| `application_list` | Org-wide app search (parallel + cached). |
|
|
100
|
+
| `application_build_list` | Recent CI builds — status, branch, commit, age, whether released. Closes the push→CI→deploy gap; `build:<id>` shows its assets. |
|
|
101
|
+
| `application_log_list` | Recent log lines for the app/scope. |
|
|
102
|
+
| `application_parameter_list` | List configuration parameters (secrets masked). |
|
|
103
|
+
| `application_metric_list` | Golden signals per scope — throughput, response time, error rate, CPU, memory — with sparkline trends (1h/3h/24h/7d). |
|
|
104
|
+
| `application_deployment_create` | Ship: picks the latest successful build, cuts the release for you (semver bump), targets the only scope — all overridable. Reuses an in-flight rollout on retry. |
|
|
105
|
+
| `application_deployment_update` | Move traffic (snaps to 1/5/10/25/50/75/90/95/99/100), `finalize`, or `rollback`. Finds the active rollout itself. |
|
|
106
|
+
| `application_release_create` | Cut a release from a build explicitly (reuses an existing one for the same build). |
|
|
107
|
+
| `application_parameter_create` | Create/update env vars & file params (mark `secret`) — upserts, never duplicates. Apply on next deploy. |
|
|
108
|
+
| `application_create` | Link the current repo as a new application (name/URL inferred from the git remote). Returns the existing app if already linked. |
|
|
109
|
+
| `application_scope_create` | Create a deploy target (lists the org's scope types when ambiguous). Returns the existing scope if the name is taken. |
|
|
110
|
+
| `application_approval_list` | List the approvals gating an app (e.g. a deploy stuck on a policy) and `approve`/`cancel` one — using your own permissions, so the platform denies what you can't do. |
|
|
111
|
+
| `application_service_list` | List an app's dependency services (DBs, queues…) and the provisionable catalog; deep-links into the dashboard to create. |
|
|
112
|
+
|
|
113
|
+
Plus three slash-command prompts: **/ship** (deploy + walk traffic to 100% with health checks),
|
|
114
|
+
**/setup** (link this repo), **/rollback** (get me out, now).
|
|
115
|
+
|
|
116
|
+
## Built for agents, not browsers
|
|
117
|
+
|
|
118
|
+
MCP usage differs from web usage, and the tools are shaped for it:
|
|
119
|
+
|
|
120
|
+
- **Convergent writes.** Agents retry on timeout; a web user doesn't double-submit a form. So
|
|
121
|
+
every write reconciles against current state instead of blindly POSTing: `application_parameter_create` reuses
|
|
122
|
+
an existing parameter definition, `application_release_create` reuses a release already cut from the same
|
|
123
|
+
build, `application_create`/`application_scope_create` return the existing entity, and `application_deployment_create` returns the
|
|
124
|
+
in-flight rollout for the same release. Re-running a tool is safe.
|
|
125
|
+
- **Org-wide reads.** `organization_get` answers cross-application questions ("what's broken?") that the
|
|
126
|
+
dashboard only answers one entity-page at a time.
|
|
127
|
+
- **Approvals as a loop, not a notification.** When a deploy blocks on a policy, `application_approval_list`
|
|
128
|
+
lets the agent see and (if permitted) clear the gate rather than dead-ending.
|
|
129
|
+
- **Honest async + permissions.** Long operations say so and hand back an id to re-query;
|
|
130
|
+
`401`/`403` surface in plain language because every call carries the caller's own token.
|
|
131
|
+
|
|
132
|
+
## Interactive UI (MCP Apps)
|
|
133
|
+
|
|
134
|
+
On hosts that render MCP Apps (claude.ai web/desktop, ChatGPT), the journey is interactive —
|
|
135
|
+
text-only hosts (terminals) keep the markdown answers automatically:
|
|
136
|
+
|
|
137
|
+
| Widget | Bound to | What you can do in it |
|
|
138
|
+
| --- | --- | --- |
|
|
139
|
+
| **Application panel** | `application_get`, `application_deployment_create`, `application_deployment_update` | Scope cards with live release/traffic/domain, release chips with one-click **ship**, **Deploy latest**, create-scope when empty — and it morphs into the live rollout: traffic slider (snapped marks), **Finalize**/**Rollback** with confirm, deployment log, self-refreshing. |
|
|
140
|
+
| **Create application** | `application_create` | Name + repo + namespace form (opens automatically when no git remote is inferable), creates and reports provisioning. |
|
|
141
|
+
| **Parameters** | `application_parameter_list` | Editable table — add env vars/files, mark secrets, save via `application_parameter_create`. |
|
|
142
|
+
| **Logs** | `application_log_list` | Terminal pane with filter, refresh and auto-tail. |
|
|
143
|
+
| **Metrics** | `application_metric_list` | Golden-signal cards with live canvas charts, window selector (1h/3h/24h/7d), auto-refresh. |
|
|
144
|
+
| **Applications** | `application_list` | Filterable picker — click an app to open its panel. |
|
|
145
|
+
|
|
146
|
+
Widgets are single self-contained HTML files (~330KB: Preact-compat runtime + the ext-apps
|
|
147
|
+
SDK, whose embedded zod accounts for most of it) built inline with esbuild — the sandbox
|
|
148
|
+
blocks CDN fetches. They speak MCP Apps protocol `2026-01-26`. They **adopt the host's
|
|
149
|
+
design tokens** (`hostContext.styles`: colors, border radii, fonts) so the UI looks native —
|
|
150
|
+
Claude-styled in Claude, ChatGPT-styled in ChatGPT — with dark/light handled live and our own
|
|
151
|
+
palette only as the fallback for hosts that don't send tokens.
|
|
152
|
+
|
|
153
|
+
## Skills ship with the server
|
|
154
|
+
|
|
155
|
+
The connector also ships **operating playbooks** — the methodology travels with the tools:
|
|
156
|
+
|
|
157
|
+
| Playbook | Teaches |
|
|
158
|
+
| --- | --- |
|
|
159
|
+
| `deploying-safely` | Pre-flight checks, canary traffic steps gated on metrics, finalize/rollback criteria |
|
|
160
|
+
| `incident-response` | Mitigate-first triage: rollback, then read logs/metrics/params for the cause |
|
|
161
|
+
| `platform-conventions` | Entity chain semantics, dimensions, versioning, parameters, traffic lifecycle, known gotchas |
|
|
162
|
+
|
|
163
|
+
They're plain markdown under `skills/` — platform teams change agent behavior by editing
|
|
164
|
+
text, no server redeploy. The model reads them through the **`playbook_get` tool**: a tool is
|
|
165
|
+
the one MCP primitive every coding assistant (Claude Code, Cursor, Claude Desktop, …) exposes
|
|
166
|
+
to the model, so this works everywhere and on demand. The server instructions carry the
|
|
167
|
+
catalog (name + when-to-use) so the model knows which to read before which task; they're also
|
|
168
|
+
listed as passive `playbook://nullplatform/<name>` resources. MCP has no "skills" primitive —
|
|
169
|
+
standard Agent Skills load client-side — and the earlier `skill://` scheme made Claude Desktop
|
|
170
|
+
route to its native Agent Skills executor ("Unknown skill") instead, which is why this is a
|
|
171
|
+
tool.
|
|
172
|
+
|
|
173
|
+
## How it knows your app
|
|
174
|
+
|
|
175
|
+
1. Your client's MCP **roots** (workspace folders) → `git remote get-url origin`
|
|
176
|
+
2. Fallback: the server's working directory
|
|
177
|
+
3. The remote URL is matched against applications' `repository_url` (ssh/https equivalent), then by repo name
|
|
178
|
+
4. No match? Tools say exactly that and offer `application_create` / `application_list`
|
|
179
|
+
|
|
180
|
+
## Design principles
|
|
181
|
+
|
|
182
|
+
- **Journey-shaped, not endpoint-shaped** — one tool per developer intent; the API chain
|
|
183
|
+
(build → asset → release → deployment) is the server's problem.
|
|
184
|
+
- **Defaults that match what you meant** — `application_deployment_create` does what the dashboard needs five screens for.
|
|
185
|
+
- **Markdown is the UI** — tables, status glyphs, a traffic bar, one next-step per answer.
|
|
186
|
+
- **Honest writes** — provisioning/asynchronous things say so and tell you how to watch;
|
|
187
|
+
errors carry the platform's message, never a stack trace.
|
|
188
|
+
- **The dashboard stays one click away** — entities link to `https://<org>.app.nullplatform.io/nrn/<nrn>`.
|
|
189
|
+
|
|
190
|
+
## Develop
|
|
191
|
+
|
|
192
|
+
```bash
|
|
193
|
+
npm install
|
|
194
|
+
npm test # unit + full in-memory MCP round-trips over a fake API (builds widgets first)
|
|
195
|
+
npm run lint # Biome (lint + format)
|
|
196
|
+
npm run typecheck # server + widgets, strict (noUncheckedIndexedAccess on)
|
|
197
|
+
npm run dev # stdio server (tsx)
|
|
198
|
+
npm run build # dist/ + minified widgets (NP_WIDGET_DEBUG=1 keeps identifiers)
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
### Testing (the MCP way)
|
|
202
|
+
|
|
203
|
+
The suite covers every level a real client exercises, without ever touching the live platform:
|
|
204
|
+
|
|
205
|
+
| Layer | File | What it proves |
|
|
206
|
+
| --- | --- | --- |
|
|
207
|
+
| Unit | `test/unit.test.ts` | pure logic: semver, traffic snapping, locale matching, credential parsing, the presenter |
|
|
208
|
+
| Protocol round-trip | `test/tools.test.ts` | the **SDK pattern**: a real MCP `Client` over `InMemoryTransport` against the real server — tool surface, text replies, structured contracts, widget bindings, skills, UI negotiation |
|
|
209
|
+
| Tool scenarios | `test/scenarios.test.ts` | the behavior matrix: ask-backs (which scope?), dimension targeting, action side effects asserted on the recorded platform calls, 403→permission mapping, partial failures, `#id`/git-remote resolution, prompt rendering per language |
|
|
210
|
+
| Widget DOM | `test/widgets.test.tsx` | **ui-mode**: widgets render each tool's `structuredContent` and user actions go back through the (mocked) host bridge as `tools/call` — ship chips, traffic slider snapping, confirm gates, form submission |
|
|
211
|
+
| Transport boundary | `test/http.test.ts` | multi-user HTTP: per-request auth, token-exchange trust boundary, isolation, guards, per-request language |
|
|
212
|
+
| Black box | `test/stdio.test.ts` | the **raw pattern**: spawns `src/index.ts` as a child process and speaks MCP over stdio like an installed client — entry point, env credential policy, negotiated text-only surface |
|
|
213
|
+
|
|
214
|
+
Everything runs against `test/fake-api.ts`, an in-memory fake of the nullplatform API faithful
|
|
215
|
+
to the live-verified contract (query scoping, snake_case bodies, async statuses, the multi-asset
|
|
216
|
+
deploy trap) — fast, deterministic, and it records every call so action tests assert exactly
|
|
217
|
+
what hit the platform.
|
|
218
|
+
|
|
219
|
+
### Layers
|
|
220
|
+
|
|
221
|
+
| Layer | Where | What it owns |
|
|
222
|
+
| --- | --- | --- |
|
|
223
|
+
| Platform API | `src/np/` | auth + token exchange, HTTP client, org context/caches, the typed journey API (builds → releases → scopes → deployments → metrics/logs). Tools never call the raw client — they go through `journey.ts`. |
|
|
224
|
+
| Tool framework | `src/tool.ts` | `ToolSpec`/`ToolReply`, the presenter (one place replies become wire results), the per-tool error net |
|
|
225
|
+
| Tools | `src/tools/` | one file per tool + `index.ts` registry + `shared.ts` resolution helpers |
|
|
226
|
+
| Prompts | `src/prompts.ts` | declarative slash-command prompts |
|
|
227
|
+
| Presentation (text) | `src/md.ts`, `src/render.ts` | the markdown design language and views |
|
|
228
|
+
| Presentation (ui) | `src/ui.ts`, `src/widgets-react/` | widget registry, MCP Apps glue, the React widgets |
|
|
229
|
+
| i18n | `src/i18n.ts` | `en`/`es` catalogs (compile-checked), locale scoping |
|
|
230
|
+
| Transport | `src/http.ts`, `src/index.ts` | multi-user HTTP boundary (per-request auth, per-user backends, guards) and stdio entry |
|
|
231
|
+
| Assembly | `src/server.ts` | Config → Deps → McpServer, wired from the registries |
|
|
232
|
+
|
|
233
|
+
### Dual-mode replies
|
|
234
|
+
|
|
235
|
+
Every tool returns one `ToolReply { markdown, data }`: the markdown is the complete answer
|
|
236
|
+
for text hosts, the data feeds both the model (structuredContent) and the bound widget on
|
|
237
|
+
ui hosts. In stdio sessions widgets are only registered once the client actually negotiates
|
|
238
|
+
the MCP Apps extension; in stateless HTTP they're always offered and hosts that don't speak
|
|
239
|
+
the extension simply ignore them (that's the spec's graceful degradation).
|
|
240
|
+
|
|
241
|
+
### Extend
|
|
242
|
+
|
|
243
|
+
- **New tool**: create `src/tools/<name>.ts` exporting `defineTool({...})` (schema, handler
|
|
244
|
+
returning a `ToolReply`, optional `widget` binding), add it to `src/tools/index.ts`. Its
|
|
245
|
+
widget auto-registers; the framework supplies error handling and presentation.
|
|
246
|
+
- **New widget**: drop `src/widgets-react/widgets/<name>.tsx`, add it to the `WIDGETS` map in
|
|
247
|
+
`src/ui.ts`, bind it from a tool spec. The build picks it up.
|
|
248
|
+
- **New prompt**: append a spec to `src/prompts.ts`.
|
|
249
|
+
- **New playbook**: drop `skills/<name>/SKILL.md` — picked up automatically by the `playbook_get`
|
|
250
|
+
tool and the instruction catalog.
|
|
251
|
+
- **New language**: add one catalog object in `src/i18n.ts` — completeness is compile-checked.
|
|
252
|
+
- **New platform call**: add a typed function to `src/np/journey.ts`; tools never touch the raw client.
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export function loadConfig(env = process.env, policy = "require") {
|
|
2
|
+
const apiKey = env.NP_API_KEY?.trim() || undefined;
|
|
3
|
+
const bearer = env.NP_BEARER?.trim() || undefined;
|
|
4
|
+
const base = {
|
|
5
|
+
apiBase: env.NP_API_BASE?.trim() || "https://api.nullplatform.com",
|
|
6
|
+
bffBase: env.NP_BFF_BASE?.trim() || "https://bff-dashboard.nullplatform.io",
|
|
7
|
+
};
|
|
8
|
+
if (policy === "forbid") {
|
|
9
|
+
if (apiKey || bearer) {
|
|
10
|
+
throw new Error("HTTP mode is multi-user: the server must not hold credentials. Remove NP_API_KEY/NP_BEARER " +
|
|
11
|
+
"from the environment — each caller sends their own key per request:\n" +
|
|
12
|
+
" Authorization: Bearer <NP_API_KEY> (or a pre-issued JWT, or the X-NP-API-Key header)");
|
|
13
|
+
}
|
|
14
|
+
if (env.NP_HTTP_TOKEN?.trim()) {
|
|
15
|
+
throw new Error("NP_HTTP_TOKEN was removed: the shared-secret gate is superseded by per-user authentication. " +
|
|
16
|
+
"Callers now send their own nullplatform credential (Authorization: Bearer <NP_API_KEY>).");
|
|
17
|
+
}
|
|
18
|
+
return base;
|
|
19
|
+
}
|
|
20
|
+
if (!apiKey && !bearer) {
|
|
21
|
+
throw new Error("nullplatform credentials missing. Set NP_API_KEY (recommended) or NP_BEARER.\n" +
|
|
22
|
+
" Claude Code: claude mcp add nullplatform -e NP_API_KEY=<key> -- npx -y @nullplatform/mcp\n" +
|
|
23
|
+
' Cursor: add "env": {"NP_API_KEY": "<key>"} to the server entry in mcp.json');
|
|
24
|
+
}
|
|
25
|
+
return { ...base, apiKey, bearer };
|
|
26
|
+
}
|
package/dist/git.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
/**
|
|
3
|
+
* Normalize a git remote URL so dashboard-style https URLs and ssh remotes compare equal:
|
|
4
|
+
* git@github.com:org/repo.git -> github.com/org/repo
|
|
5
|
+
* https://github.com/org/repo -> github.com/org/repo
|
|
6
|
+
*/
|
|
7
|
+
export function normalizeRepoUrl(url) {
|
|
8
|
+
let normalized = url.trim().toLowerCase();
|
|
9
|
+
normalized = normalized.replace(/^git@([^:]+):/, "$1/");
|
|
10
|
+
normalized = normalized.replace(/^[a-z+]+:\/\//, "");
|
|
11
|
+
normalized = normalized.replace(/^[^@]+@/, "");
|
|
12
|
+
normalized = normalized.replace(/\/+$/, "").replace(/\.git$/, "");
|
|
13
|
+
return normalized;
|
|
14
|
+
}
|
|
15
|
+
export function repoName(url) {
|
|
16
|
+
const parts = normalizeRepoUrl(url).split("/");
|
|
17
|
+
return parts[parts.length - 1] ?? "";
|
|
18
|
+
}
|
|
19
|
+
function git(args, cwd) {
|
|
20
|
+
return new Promise((resolve) => {
|
|
21
|
+
execFile("git", args, { cwd, timeout: 3_000 }, (error, stdout) => resolve(error ? undefined : stdout.trim() || undefined));
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
/** The origin remote of the repo at `dir` (or the repo containing it), if any. */
|
|
25
|
+
export async function detectRepoUrl(dir) {
|
|
26
|
+
return git(["config", "--get", "remote.origin.url"], dir);
|
|
27
|
+
}
|
package/dist/http.js
ADDED
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
4
|
+
import { resolveLocale, translate, withLocale } from "./i18n.js";
|
|
5
|
+
import { CredentialRejectedError, exchangeApiKey, orgIdFromJwt } from "./np/auth.js";
|
|
6
|
+
import { buildDeps, buildServer } from "./server.js";
|
|
7
|
+
/**
|
|
8
|
+
* Multi-user streamable HTTP. The server holds no credential; every request carries the
|
|
9
|
+
* caller's own nullplatform key and the platform's RBAC decides what that user can do.
|
|
10
|
+
*
|
|
11
|
+
* The api_key -> access-token exchange is the trust boundary, treated like one:
|
|
12
|
+
* - the customer's key is NEVER retained: it lives only in the request's async scope,
|
|
13
|
+
* and is used at most once per expiry window to (re)exchange — what gets cached per
|
|
14
|
+
* user is exclusively the platform-issued short-lived access token
|
|
15
|
+
* - a credential is VERIFIED with the platform before it is ever handed a tool
|
|
16
|
+
* - each credential gets an isolated backend (token, org, caches) — never shared
|
|
17
|
+
* - verification and exchange are single-flighted per credential, across requests
|
|
18
|
+
* - rejected credentials are negative-cached, so bad keys can't hammer /token
|
|
19
|
+
* or evict verified users from the cache
|
|
20
|
+
* - raw secrets are never logged and never used as map keys (their hash is)
|
|
21
|
+
*/
|
|
22
|
+
const MAX_BODY_BYTES = 1024 * 1024; // tool args are small; anything bigger is abuse
|
|
23
|
+
const USER_CACHE_MAX = 500;
|
|
24
|
+
const USER_IDLE_MS = 15 * 60_000;
|
|
25
|
+
const REJECTED_TTL_MS = 60_000;
|
|
26
|
+
const REJECTED_MAX = 10_000;
|
|
27
|
+
const RATE_WINDOW_MS = 60_000;
|
|
28
|
+
const RATE_LIMITER_MAX_IPS = 10_000;
|
|
29
|
+
/** Generous per-IP ceiling: a chatty single user (polling widgets + actions) stays well
|
|
30
|
+
* under it, but a key-rotating attacker can't amplify unbounded /token load. Tune with
|
|
31
|
+
* NP_RATE_LIMIT_RPM (0 disables); behind a proxy, set NP_TRUST_PROXY to key off XFF. */
|
|
32
|
+
const DEFAULT_RATE_LIMIT_RPM = 600;
|
|
33
|
+
/**
|
|
34
|
+
* Slowloris / connection-exhaustion guard. Node's defaults (requestTimeout 5 min,
|
|
35
|
+
* headersTimeout 1 min) let a trickle client hold a socket — and the verified backend it
|
|
36
|
+
* cost a /token round-trip to build — open for minutes. The rate limiter counts requests,
|
|
37
|
+
* not slow in-flight reads, so bounding how long a client may take to deliver its headers
|
|
38
|
+
* and body is the complementary defense. Tool args are small (< 1 MiB); an honest client
|
|
39
|
+
* sends them well inside these windows.
|
|
40
|
+
*/
|
|
41
|
+
export const SERVER_TIMEOUTS_MS = {
|
|
42
|
+
/** Whole request (headers + body) must arrive within this. */
|
|
43
|
+
request: 30_000,
|
|
44
|
+
/** Headers alone must arrive within this — Node requires it be <= request. */
|
|
45
|
+
headers: 15_000,
|
|
46
|
+
/** Idle keep-alive socket reclaimed after this. */
|
|
47
|
+
keepAlive: 5_000,
|
|
48
|
+
};
|
|
49
|
+
/** Apply the slowloris timeouts to the HTTP server that fronts this handler. */
|
|
50
|
+
export function hardenServerTimeouts(server) {
|
|
51
|
+
server.requestTimeout = SERVER_TIMEOUTS_MS.request;
|
|
52
|
+
server.headersTimeout = SERVER_TIMEOUTS_MS.headers;
|
|
53
|
+
server.keepAliveTimeout = SERVER_TIMEOUTS_MS.keepAlive;
|
|
54
|
+
}
|
|
55
|
+
/** Fixed-window per-key request limiter, bounded so the key set can't grow unboundedly. */
|
|
56
|
+
function makeRateLimiter(limitPerWindow) {
|
|
57
|
+
const windows = new Map();
|
|
58
|
+
return (key) => {
|
|
59
|
+
if (limitPerWindow <= 0)
|
|
60
|
+
return true; // disabled
|
|
61
|
+
const now = Date.now();
|
|
62
|
+
const existing = windows.get(key);
|
|
63
|
+
if (!existing || now - existing.windowStart >= RATE_WINDOW_MS) {
|
|
64
|
+
if (windows.size >= RATE_LIMITER_MAX_IPS) {
|
|
65
|
+
for (const [knownKey, window] of windows) {
|
|
66
|
+
if (now - window.windowStart >= RATE_WINDOW_MS)
|
|
67
|
+
windows.delete(knownKey);
|
|
68
|
+
}
|
|
69
|
+
while (windows.size >= RATE_LIMITER_MAX_IPS) {
|
|
70
|
+
const oldest = windows.keys().next().value;
|
|
71
|
+
if (oldest === undefined)
|
|
72
|
+
break;
|
|
73
|
+
windows.delete(oldest);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
windows.set(key, { count: 1, windowStart: now });
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
existing.count++;
|
|
80
|
+
return existing.count <= limitPerWindow;
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
function clientIp(request, trustProxy) {
|
|
84
|
+
if (trustProxy) {
|
|
85
|
+
const forwarded = request.headers["x-forwarded-for"];
|
|
86
|
+
const first = (Array.isArray(forwarded) ? forwarded[0] : forwarded)?.split(",")[0]?.trim();
|
|
87
|
+
if (first)
|
|
88
|
+
return first;
|
|
89
|
+
}
|
|
90
|
+
return request.socket.remoteAddress ?? "unknown";
|
|
91
|
+
}
|
|
92
|
+
/** Three base64url segments starting with a JSON header — a pre-issued JWT, not an api key. */
|
|
93
|
+
function looksLikeJwt(value) {
|
|
94
|
+
return /^eyJ[A-Za-z0-9_-]*\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(value);
|
|
95
|
+
}
|
|
96
|
+
/** `Authorization: Bearer <NP_API_KEY | JWT>`, or `X-NP-API-Key` for hosts that reserve Authorization. */
|
|
97
|
+
export function credentialFrom(headers) {
|
|
98
|
+
const authorization = headers.authorization;
|
|
99
|
+
if (authorization) {
|
|
100
|
+
const bearerMatch = /^Bearer\s+(.+)$/i.exec(authorization.trim());
|
|
101
|
+
const value = bearerMatch?.[1]?.trim();
|
|
102
|
+
if (!value)
|
|
103
|
+
return undefined; // wrong scheme (Basic, …) — not ours
|
|
104
|
+
return looksLikeJwt(value) ? { raw: value, bearer: value } : { raw: value, apiKey: value };
|
|
105
|
+
}
|
|
106
|
+
const headerKey = headers["x-np-api-key"];
|
|
107
|
+
const value = (Array.isArray(headerKey) ? headerKey[0] : headerKey)?.trim();
|
|
108
|
+
return value ? { raw: value, apiKey: value } : undefined;
|
|
109
|
+
}
|
|
110
|
+
/** The in-flight request's credential — exists only inside that request's async scope. */
|
|
111
|
+
const requestCredential = new AsyncLocalStorage();
|
|
112
|
+
/**
|
|
113
|
+
* A TokenSource that takes the api key from the live request, never from storage.
|
|
114
|
+
* Expiry mid-conversation is fine: whichever request is current re-exchanges with
|
|
115
|
+
* the key it carried; concurrent callers share one exchange via the slot.
|
|
116
|
+
*/
|
|
117
|
+
function tokenSourceFor(slot, apiBase, fetchImpl) {
|
|
118
|
+
return {
|
|
119
|
+
get organizationId() {
|
|
120
|
+
return slot.organizationId;
|
|
121
|
+
},
|
|
122
|
+
invalidate() {
|
|
123
|
+
slot.accessToken = undefined;
|
|
124
|
+
slot.expiresAt = 0;
|
|
125
|
+
},
|
|
126
|
+
async getToken() {
|
|
127
|
+
const credential = requestCredential.getStore();
|
|
128
|
+
if (credential?.bearer)
|
|
129
|
+
return credential.bearer; // pre-issued token: pure pass-through, cache nothing
|
|
130
|
+
if (slot.accessToken && Date.now() < slot.expiresAt - 60_000)
|
|
131
|
+
return slot.accessToken;
|
|
132
|
+
const apiKey = credential?.apiKey;
|
|
133
|
+
if (!apiKey)
|
|
134
|
+
throw new CredentialRejectedError();
|
|
135
|
+
slot.inflight ??= exchangeApiKey(apiBase, apiKey, fetchImpl)
|
|
136
|
+
.then((exchanged) => {
|
|
137
|
+
slot.accessToken = exchanged.accessToken;
|
|
138
|
+
slot.expiresAt = exchanged.expiresAt;
|
|
139
|
+
slot.organizationId = exchanged.organizationId;
|
|
140
|
+
return exchanged.accessToken;
|
|
141
|
+
})
|
|
142
|
+
.finally(() => {
|
|
143
|
+
slot.inflight = undefined;
|
|
144
|
+
});
|
|
145
|
+
return slot.inflight;
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
export function createMcpHttpHandler(config, options = {}) {
|
|
150
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
151
|
+
const allowedOrigins = new Set(options.allowedOrigins ?? []);
|
|
152
|
+
const surface = options.surface;
|
|
153
|
+
const rateLimit = makeRateLimiter(options.rateLimitRpm ?? DEFAULT_RATE_LIMIT_RPM);
|
|
154
|
+
const trustProxy = options.trustProxy ?? false;
|
|
155
|
+
// Verified per-user backends, LRU + idle eviction. Only credentials the platform
|
|
156
|
+
// accepted ever enter this map — unverified input cannot displace verified users.
|
|
157
|
+
const users = new Map();
|
|
158
|
+
// First sight of a credential, in flight: concurrent requests share one verification.
|
|
159
|
+
const creating = new Map();
|
|
160
|
+
// Credentials the platform rejected: answered 401 locally for a while.
|
|
161
|
+
const rejected = new Map();
|
|
162
|
+
/** Prove the credential to the platform before serving it: throws CredentialRejectedError. */
|
|
163
|
+
const verify = async (credential) => {
|
|
164
|
+
// The backend keeps the slot (platform-issued state) — the credential stays out of it.
|
|
165
|
+
const slot = {
|
|
166
|
+
expiresAt: 0,
|
|
167
|
+
organizationId: credential.bearer ? orgIdFromJwt(credential.bearer) : undefined,
|
|
168
|
+
};
|
|
169
|
+
const deps = buildDeps({ apiBase: config.apiBase, bffBase: config.bffBase }, fetchImpl, tokenSourceFor(slot, config.apiBase, fetchImpl));
|
|
170
|
+
if (credential.apiKey) {
|
|
171
|
+
await deps.tokens.getToken(); // the exchange itself is the validation
|
|
172
|
+
}
|
|
173
|
+
else {
|
|
174
|
+
// A platform-issued JWT always carries the org claim; prove it with a harmless read.
|
|
175
|
+
const organizationId = deps.tokens.organizationId;
|
|
176
|
+
if (!organizationId) {
|
|
177
|
+
throw new CredentialRejectedError(translate("error.bearerNoOrg"));
|
|
178
|
+
}
|
|
179
|
+
try {
|
|
180
|
+
await deps.np.get(`/organization/${organizationId}`);
|
|
181
|
+
}
|
|
182
|
+
catch (caught) {
|
|
183
|
+
const status = caught.status;
|
|
184
|
+
if (status === 401 || status === 403)
|
|
185
|
+
throw new CredentialRejectedError();
|
|
186
|
+
throw caught;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return deps;
|
|
190
|
+
};
|
|
191
|
+
const depsFor = (credential) => {
|
|
192
|
+
const credentialHash = createHash("sha256").update(credential.raw).digest("hex");
|
|
193
|
+
const now = Date.now();
|
|
194
|
+
const cached = users.get(credentialHash);
|
|
195
|
+
if (cached) {
|
|
196
|
+
users.delete(credentialHash); // re-insert: Map order doubles as the LRU order
|
|
197
|
+
cached.lastUsed = now;
|
|
198
|
+
users.set(credentialHash, cached);
|
|
199
|
+
return Promise.resolve(cached.deps);
|
|
200
|
+
}
|
|
201
|
+
const rejectedUntil = rejected.get(credentialHash);
|
|
202
|
+
if (rejectedUntil !== undefined) {
|
|
203
|
+
if (now < rejectedUntil)
|
|
204
|
+
return Promise.reject(new CredentialRejectedError());
|
|
205
|
+
rejected.delete(credentialHash); // window over — let it try the platform again
|
|
206
|
+
}
|
|
207
|
+
const pending = creating.get(credentialHash);
|
|
208
|
+
if (pending)
|
|
209
|
+
return pending;
|
|
210
|
+
const create = (async () => {
|
|
211
|
+
const deps = await verify(credential);
|
|
212
|
+
for (const [userKey, entry] of users) {
|
|
213
|
+
if (Date.now() - entry.lastUsed > USER_IDLE_MS)
|
|
214
|
+
users.delete(userKey);
|
|
215
|
+
}
|
|
216
|
+
while (users.size >= USER_CACHE_MAX) {
|
|
217
|
+
const oldest = users.keys().next().value;
|
|
218
|
+
if (oldest === undefined)
|
|
219
|
+
break;
|
|
220
|
+
users.delete(oldest);
|
|
221
|
+
}
|
|
222
|
+
users.set(credentialHash, { deps, lastUsed: Date.now() });
|
|
223
|
+
return deps;
|
|
224
|
+
})();
|
|
225
|
+
creating.set(credentialHash, create);
|
|
226
|
+
void create
|
|
227
|
+
.catch((caught) => {
|
|
228
|
+
if (caught instanceof CredentialRejectedError) {
|
|
229
|
+
// Evict the oldest entries (Map insertion order = LRU), never flush the whole
|
|
230
|
+
// cache — a key-rotating attacker must not be able to re-open the platform's
|
|
231
|
+
// /token to previously-rejected keys by overflowing this map.
|
|
232
|
+
while (rejected.size >= REJECTED_MAX) {
|
|
233
|
+
const oldest = rejected.keys().next().value;
|
|
234
|
+
if (oldest === undefined)
|
|
235
|
+
break;
|
|
236
|
+
rejected.delete(oldest);
|
|
237
|
+
}
|
|
238
|
+
rejected.set(credentialHash, Date.now() + REJECTED_TTL_MS);
|
|
239
|
+
}
|
|
240
|
+
})
|
|
241
|
+
.finally(() => creating.delete(credentialHash));
|
|
242
|
+
return create;
|
|
243
|
+
};
|
|
244
|
+
const reply = (response, status, body, headers = {}) => response.writeHead(status, { "content-type": "application/json", ...headers }).end(JSON.stringify(body));
|
|
245
|
+
const handle = async (request, response) => {
|
|
246
|
+
try {
|
|
247
|
+
const url = request.url ?? "";
|
|
248
|
+
if (request.method === "GET" && (url === "/healthz" || url.startsWith("/healthz?"))) {
|
|
249
|
+
reply(response, 200, { status: "ok" });
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
if (!url.startsWith("/mcp")) {
|
|
253
|
+
response.writeHead(404).end();
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
// Per-IP rate limit before any auth/verify work, so a key-rotating attacker can't
|
|
257
|
+
// amplify /token load or pin the event loop. Generous by default; a proxy should too.
|
|
258
|
+
if (!rateLimit(clientIp(request, trustProxy))) {
|
|
259
|
+
reply(response, 429, { error: translate("http.rateLimited") }, { "Retry-After": String(Math.ceil(RATE_WINDOW_MS / 1000)) });
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
// DNS-rebinding/browser guard: MCP clients call server-to-server and send no Origin.
|
|
263
|
+
const origin = request.headers.origin;
|
|
264
|
+
if (origin && !allowedOrigins.has(origin)) {
|
|
265
|
+
reply(response, 403, { error: translate("http.originDenied") });
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
if (request.method !== "POST") {
|
|
269
|
+
// Stateless mode: no SSE stream, no sessions — only POST is meaningful.
|
|
270
|
+
response.writeHead(405, { Allow: "POST" }).end();
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
const credential = credentialFrom(request.headers);
|
|
274
|
+
if (!credential) {
|
|
275
|
+
reply(response, 401, { error: translate("http.authRequired"), hint: translate("http.authHint") }, { "WWW-Authenticate": 'Bearer realm="nullplatform"' });
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
// Everything from verification to tool execution runs inside the request's async
|
|
279
|
+
// scope: the key is reachable there when an exchange is due, and nowhere else.
|
|
280
|
+
await requestCredential.run(credential, async () => {
|
|
281
|
+
// Verify before reading the body or touching MCP: invalid keys stop here, as a 401.
|
|
282
|
+
let deps;
|
|
283
|
+
try {
|
|
284
|
+
deps = await depsFor(credential);
|
|
285
|
+
}
|
|
286
|
+
catch (caught) {
|
|
287
|
+
if (caught instanceof CredentialRejectedError) {
|
|
288
|
+
reply(response, 401, { error: caught.message }, { "WWW-Authenticate": 'Bearer realm="nullplatform", error="invalid_token"' });
|
|
289
|
+
}
|
|
290
|
+
else {
|
|
291
|
+
console.error("[ai-mcp] credential verification unavailable:", caught instanceof Error ? caught.message : caught);
|
|
292
|
+
reply(response, 502, { error: translate("http.verifyUnavailable") });
|
|
293
|
+
}
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
let body;
|
|
297
|
+
try {
|
|
298
|
+
const chunks = [];
|
|
299
|
+
let size = 0;
|
|
300
|
+
for await (const chunk of request) {
|
|
301
|
+
size += chunk.length;
|
|
302
|
+
if (size > MAX_BODY_BYTES) {
|
|
303
|
+
reply(response, 413, { error: translate("http.bodyTooLarge") });
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
chunks.push(chunk);
|
|
307
|
+
}
|
|
308
|
+
body = chunks.length ? JSON.parse(Buffer.concat(chunks).toString("utf8")) : undefined;
|
|
309
|
+
}
|
|
310
|
+
catch {
|
|
311
|
+
reply(response, 400, { error: translate("http.invalidJson") });
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
// Fresh server+transport per request (stateless), wired to this caller's backend.
|
|
315
|
+
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
|
|
316
|
+
response.on("close", () => void transport.close());
|
|
317
|
+
const server = buildServer(deps, surface ? { surface } : {});
|
|
318
|
+
await server.connect(transport);
|
|
319
|
+
await transport.handleRequest(request, response, body);
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
catch (caught) {
|
|
323
|
+
console.error("[ai-mcp] request failed:", caught instanceof Error ? caught.message : caught);
|
|
324
|
+
if (!response.headersSent)
|
|
325
|
+
response.writeHead(500).end();
|
|
326
|
+
}
|
|
327
|
+
};
|
|
328
|
+
// The caller's language travels with the request, exactly like their credential.
|
|
329
|
+
return (request, response) => withLocale(resolveLocale(request.headers["accept-language"]), () => handle(request, response));
|
|
330
|
+
}
|