@fouradata/mcp 0.2.11

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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 FourA
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,258 @@
1
+ # @fouradata/mcp
2
+
3
+ [FourA Web Scraping API](https://foura.ai/) as three [Model Context Protocol](https://modelcontextprotocol.io) tools plus five built-in workflow prompts. Plug it into Claude Desktop, Claude Code, Cursor, Windsurf, or any other MCP client and fetch arbitrary public web pages, bypass anti-bot challenges, and render JavaScript-heavy sites — without writing a line of integration code.
4
+
5
+ Three tools, five prompts, one API key.
6
+
7
+ ## Quick Start — local stdio (recommended for Claude Desktop)
8
+
9
+ Grab a key at [foura.ai/dashboard#api-keys](https://foura.ai/dashboard#api-keys) (one click, shown once on creation, format `pk_live_...`). Then drop this into your MCP client's config:
10
+
11
+ ```json
12
+ {
13
+ "mcpServers": {
14
+ "foura": {
15
+ "command": "npx",
16
+ "args": ["-y", "@fouradata/mcp"],
17
+ "env": {
18
+ "FOURA_API_KEY": "pk_live_..."
19
+ }
20
+ }
21
+ }
22
+ }
23
+ ```
24
+
25
+ > **Claude Desktop gotcha:** fully quit Claude Desktop (`Cmd+Q` on macOS) **before** editing the config file. If the app is still running, it will overwrite your edits with its in-memory config on exit.
26
+
27
+ The npx command downloads the package on first launch (~10s) and runs it as a subprocess of your MCP client. No global install needed. Same JSON works in every major client — just point it at the right file:
28
+
29
+ | Client | Where the config lives |
30
+ |---|---|
31
+ | Claude Desktop (macOS) | `~/Library/Application Support/Claude/claude_desktop_config.json` |
32
+ | Claude Desktop (Windows) | `%APPDATA%\Claude\claude_desktop_config.json` |
33
+ | Claude Code | `claude mcp add foura -- npx -y @fouradata/mcp` (set `FOURA_API_KEY` in env first) |
34
+ | Cursor | `~/.cursor/mcp.json` |
35
+ | Windsurf | `~/.codeium/windsurf/mcp_config.json` |
36
+ | VS Code (MCP extension) | `.vscode/mcp.json` in your workspace |
37
+
38
+ Restart the client and `foura_single`, `foura_proxy`, `foura_browser` show up in your tool list, plus five prompts under `/prompts`.
39
+
40
+ ## Quick Start — hosted (Streamable HTTP)
41
+
42
+ For clients that support the Streamable HTTP transport (Cursor, Windsurf, VS Code, Claude Code with `--transport http`), point them at the hosted endpoint instead of running a local subprocess:
43
+
44
+ ```json
45
+ {
46
+ "mcpServers": {
47
+ "foura": {
48
+ "url": "https://mcp.foura.ai/mcp",
49
+ "headers": {
50
+ "Authorization": "Bearer pk_live_..."
51
+ }
52
+ }
53
+ }
54
+ }
55
+ ```
56
+
57
+ Current Claude Desktop builds reject the bare `url` form — use the stdio config above for Claude Desktop, or bridge through `mcp-remote`:
58
+
59
+ ```json
60
+ {
61
+ "mcpServers": {
62
+ "foura": {
63
+ "command": "npx",
64
+ "args": ["-y", "mcp-remote", "https://mcp.foura.ai/mcp", "--header", "Authorization: Bearer pk_live_..."]
65
+ }
66
+ }
67
+ }
68
+ ```
69
+
70
+ ## The Three Tools
71
+
72
+ All three are marked `readOnlyHint: true` and `openWorldHint: true` per the [MCP spec](https://modelcontextprotocol.io/specification/2025-06-18/server/tools) — clients that auto-approve trusted read-only tools (Claude Desktop, Cursor in 2026) call them without a per-request confirmation modal.
73
+
74
+ Every response carries both human-readable text (`content`) and a typed `structuredContent` JSON object validated against the tool's `outputSchema`. Clients pass `structuredContent` to your LLM natively, skipping the re-tokenization tax on stringified JSON.
75
+
76
+ ### `foura_single` — fast HTTP
77
+
78
+ One HTTP request, response back. Typically 200ms–2s. Use it for static pages, JSON APIs, server-rendered HTML — the bread and butter of scraping. Set `unblocker: true` if the target is picky about wire-level signals.
79
+
80
+ ```jsonc
81
+ {
82
+ "method": "GET",
83
+ "url": "https://example.com",
84
+ "unblocker": true
85
+ }
86
+ ```
87
+
88
+ Supports custom headers, a body, per-stage timeouts, redirect controls, JSON auto-parse, a binary-buffer mode, and built-in response validation (`validate.status.accept`, `validate.data.fail`, and so on). If `foura_single` comes back blocked — status 403/429, captcha page, OR response headers `x-vercel-mitigated: challenge` / `cf-mitigated: challenge`, OR body title matches `Vercel Security Checkpoint` / `Just a moment` / `Attention Required` — escalate to `foura_proxy` with `maxTries: 25-30` for these tier-1 WAFs. If the page also needs JavaScript to render, chain `foura_proxy`'s returned `proxy` ID into `foura_browser.proxy`.
89
+
90
+ `structuredContent` shape: `{status, headers, data, total_time, ...}`.
91
+
92
+ ### `foura_proxy` — rotating proxies with retry
93
+
94
+ Same target shape as `foura_single`, but routed through a pool of proxies with automatic retry on failure. Per-host scoring picks the proxies most likely to succeed against this particular target, so you're not burning attempts on known-bad routes.
95
+
96
+ ```jsonc
97
+ {
98
+ "maxTries": 5,
99
+ "request": {
100
+ "method": "GET",
101
+ "url": "https://example.com/pricing",
102
+ "unblocker": true
103
+ }
104
+ }
105
+ ```
106
+
107
+ Typical latency 1–5s. `structuredContent` adds `proxy` (the encoded ID of the proxy that succeeded — pass it to `ignoreProxies` next time if it later goes bad) and `total` (outer timing including selection + retries). For tier-1 WAF challenges (Vercel Security Checkpoint, Cloudflare 'Just a moment', Akamai Bot Manager) use `maxTries: 25-30` — the default 5 is sized for lightly-blocked sites. If still blocked after 30 attempts the gate is likely country / ASN allowlist (not solvable by rotation) — pivot strategy. If the target needs JavaScript render, chain the returned `proxy` ID into `foura_browser.proxy` — the browser then exits through the IP that already cleared the challenge for this target.
108
+
109
+ ### `foura_browser` — full browser session
110
+
111
+ A real browser session. JavaScript runs, the DOM finishes rendering, cookies come back with the response. Use it when the page is a single-page app, when content lazy-loads after first paint, or when there's an anti-bot challenge that needs a real browser to clear.
112
+
113
+ ```jsonc
114
+ {
115
+ "url": "https://example.com/spa",
116
+ "timeout_ms": 15000,
117
+ "checkText": "data-table"
118
+ }
119
+ ```
120
+
121
+ Slowest of the three (2–10s) but the only tool that handles JavaScript end-to-end. `checkText` is a one-shot post-render validator (substring search on the rendered HTML AFTER navigation completes — not a waiter, does not poll): if the substring is missing, the call fails with an error envelope. Useful when a page returns 200 but the actual content is missing.
122
+
123
+ `structuredContent` shape is intentionally different from single/proxy: `{status, headers (object, not array), body (not data), cookies (full browser cookie shape), userAgent}`.
124
+
125
+ ## Built-in Prompts
126
+
127
+ Five workflow templates surfaced under `/prompts` in your MCP client. They orchestrate one or more tools without you spelling out the steps.
128
+
129
+ | Prompt | Arguments | What it does |
130
+ |---|---|---|
131
+ | `scrape_product_page` | `url` | Browser fetch → extract title, price, image, stock, SKU as JSON |
132
+ | `extract_article` | `url` | Single → fallback to proxy → strip nav/ads → return clean article JSON |
133
+ | `monitor_pricing` | `url, target_price?` | Proxy fetch → extract price → compare to target |
134
+ | `check_endpoint_health` | `url, expected_text?` | Single with strict validation → reachable/status/timing report |
135
+ | `bulk_fetch_urls` | `urls` (comma-separated) | Parallel single → auto-fallback to proxy per URL → metadata only |
136
+
137
+ Each prompt arrives as a templated user message your LLM executes with the right tools. They cost zero tokens at idle — only invoked prompts enter the context window.
138
+
139
+ Full recipe text + manual fallback prompts: [foura.ai/docs/mcp/recipes](https://foura.ai/docs/mcp/recipes). For the full error code list, see [foura.ai/docs/mcp/errors](https://foura.ai/docs/mcp/errors).
140
+
141
+ ## Authentication
142
+
143
+ Your `Bearer` token (or the `FOURA_API_KEY` env var in stdio mode) forwards to the FourA API as `X-API-Key`. One key, all three tools.
144
+
145
+ Keys are managed in the [dashboard](https://foura.ai/dashboard#api-keys) — shown once on creation, rotate or deactivate any time. See [foura.ai/docs/getting-started/authentication](https://foura.ai/docs/getting-started/authentication) for the full key-management walkthrough.
146
+
147
+ ## Error envelope — typed contract for agent retries
148
+
149
+ Every error (`isError: true`) carries a `structuredContent` envelope with at minimum these three fields:
150
+
151
+ ```jsonc
152
+ {
153
+ "service": "single" | "proxy" | "browser",
154
+ "code": "ssrf_blocked" | "auth_failed" | "rate_limited" | ...,
155
+ "error": "Human-readable message"
156
+ }
157
+ ```
158
+
159
+ Where the upstream returned a status, you also get `status` (HTTP code) and on rate-limit / capacity errors the FourA API envelope adds `retryAfter`, `current.{concurrency, rpm}`, `limits.{maxConcurrency, maxRpm}`.
160
+
161
+ | `code` | When | Retry safe? |
162
+ |---|---|---|
163
+ | `ssrf_blocked` | Target IP in a private / reserved range (RFC 5735+6598+IPv6 reserved) | No — change the URL |
164
+ | `upstream_non_json` | Upstream returned malformed body | Maybe — investigate |
165
+ | `bad_request` (400) | Input shape rejected by FourA | No — fix arguments |
166
+ | `auth_failed` (401) | Key missing, invalid, or deactivated | No — fix the key |
167
+ | `forbidden` (403) | Authenticated but not allowed | No |
168
+ | `not_found` (404) | Target / endpoint doesn't exist | No |
169
+ | `rate_limited` (429) | RPM cap hit | Yes — wait `retryAfter` |
170
+ | `at_capacity` (503) | Concurrency cap hit | Yes — wait `retryAfter` |
171
+ | `service_disabled` (503) | Maintenance window | Yes — wait `retryAfter` |
172
+ | `service_unavailable` (503) | Generic 503 | Yes — short backoff |
173
+ | `upstream_error` (≥500) | Upstream 5xx | Yes — exponential backoff |
174
+ | `upstream_client_error` (4xx) | Other 4xx | Usually no |
175
+
176
+ LLM agents can read `code` directly for retry logic without parsing prose. Spec reference: [foura.ai/docs/api/errors](https://foura.ai/docs/api/errors).
177
+
178
+ ## Combining the tools — sticky exit IPs
179
+
180
+ The three tools compose. `foura_proxy` returns the base36 ID of the exit it used. Pass that ID back into `foura_single.proxy` or `foura_browser.proxy` and the next call exits through the **same IP** — same session, same fingerprint, same geo.
181
+
182
+ ```jsonc
183
+ // 1. Find a working exit for the target — use maxTries:25-30 for tier-1 WAFs
184
+ const r = await foura_proxy({
185
+ maxTries: 30,
186
+ request: { method: "GET", url: "https://probe.example.com", unblocker: true }
187
+ });
188
+ // → { status: 200, proxy: "4DZ3VE", ... }
189
+
190
+ // 2. Reuse it for follow-up HTTP (cookies, multi-step flows)
191
+ await foura_single({ method: "GET", url: "https://target/api", proxy: r.proxy });
192
+
193
+ // 3. Or render JS through the same egress — exits through the IP that already
194
+ // cleared the challenge for this target, so the snapshot captures the real
195
+ // post-challenge content instead of a challenge page.
196
+ await foura_browser({ url: "https://target/spa", proxy: r.proxy });
197
+ ```
198
+
199
+ This chain is the canonical pattern for **tier-1 WAF + JavaScript-rendered targets** (Vercel Security Checkpoint, Cloudflare 'Just a moment', Akamai Bot Manager protecting SPAs). Calling `foura_browser` directly against a WAF target usually captures the challenge page — the snapshot fires before the challenge's deferred reload completes. Solve via `foura_proxy` first, then chain.
200
+
201
+ To rotate AWAY from a known-bad proxy on the next `foura_proxy` call, pass it as `ignoreProxies: ["4DZ3VE"]`. The `proxy` field on `foura_single` and `foura_browser` also accepts raw URLs (`http://host:port`, `socks5://...`) if you have your own list.
202
+
203
+ ## Large responses — `offload_large` (default: inline)
204
+
205
+ By default (since v0.2.0), full response bodies are returned inline in `structuredContent` regardless of size. This works in every MCP client.
206
+
207
+ If your client supports MCP `resources/read` (and you want to save tokens on big pages), pass `offload_large: true` per tool call. Responses ≥ 50 KB are then written to disk, returned as a `resource_link`, and your client fetches the body only when it actually needs it. Cached payloads expire after 1 hour.
208
+
209
+ ```jsonc
210
+ {
211
+ "method": "GET",
212
+ "url": "https://en.wikipedia.org/wiki/Web_scraping",
213
+ "offload_large": true // opt in for token savings
214
+ }
215
+ ```
216
+
217
+ | Client | `offload_large: true` |
218
+ |---|---|
219
+ | Claude Desktop | not yet — leave default `false` |
220
+ | Claude Code, Cursor, Windsurf | supported |
221
+ | VS Code MCP extension | supported |
222
+
223
+ Tenant-isolated: only the API key that stored a payload can read it back.
224
+
225
+ ## Other limits
226
+
227
+ - **Private targets are refused.** Requests to private or reserved IP ranges (RFC 5735, 6598, IPv6 reserved blocks) are blocked at the MCP layer. Only public-internet hosts are forwarded.
228
+ - **Rate limits** are enforced by the FourA API per service. Concurrency + RPM. Details at [foura.ai/docs/api/rate-limits](https://foura.ai/docs/api/rate-limits).
229
+ - **Body size cap** of 256 KB on incoming `/mcp` requests (real MCP payloads are < 4 KB).
230
+ - **DNS-rebinding defense:** the hosted server validates `Origin` and `Host` headers. Browser-based callers must originate from an allowlisted origin. Server-to-server callers (curl, MCP clients in stdio bridge mode) are unaffected.
231
+
232
+ ## Self-Hosting
233
+
234
+ The MCP server runs in one container, statelessly — each request brings its own key, so there's no session state, no sticky load balancing, nothing to coordinate. Scale horizontally behind any load balancer.
235
+
236
+ Configurable environment:
237
+
238
+ | Variable | Default | Purpose |
239
+ |---|---|---|
240
+ | `PORT` | `3076` | HTTP listen port |
241
+ | `FOURA_API_BASE` | `https://api.foura.ai/api` | Upstream FourA REST base URL |
242
+ | `FOURA_MCP_PAYLOADS_DIR` | `/data/payloads` | Where ≥50 KB responses are cached on disk |
243
+
244
+ A public GitHub mirror lands with `v1.0`; until then the source lives in a private repo. Ping `support@foura.ai` if you need early access to the container image.
245
+
246
+ ## License
247
+
248
+ MIT. See [`LICENSE`](./LICENSE).
249
+
250
+ ## Links
251
+
252
+ - API documentation: <https://foura.ai/docs>
253
+ - MCP server reference: <https://foura.ai/docs/mcp/server>
254
+ - MCP error codes: <https://foura.ai/docs/mcp/errors>
255
+ - MCP recipes: <https://foura.ai/docs/mcp/recipes>
256
+ - REST API errors: <https://foura.ai/docs/api/errors>
257
+ - MCP specification: <https://modelcontextprotocol.io>
258
+ - Get a key: <https://foura.ai/dashboard#api-keys>
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ import "../dist/stdio.js";
package/dist/auth.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ export declare function withApiKey<T>(key: string, fn: () => Promise<T>): Promise<T>;
2
+ export declare function getApiKey(): string;
package/dist/auth.js ADDED
@@ -0,0 +1,32 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ /**
3
+ * Shared FourA auth.
4
+ *
5
+ * The API key authenticates the CALLER, not the endpoint — one key opens
6
+ * /single/, /proxy/, and /browser/. So this lives in one place and is
7
+ * imported by every tool. (Schemas, paths, and per-endpoint behavior remain
8
+ * fully duplicated across tool files — see
9
+ * feedback_foura_endpoints_independent_schemas.)
10
+ *
11
+ * Dual-mode:
12
+ * - stdio: the user sets FOURA_API_KEY in env (e.g. via claude_desktop_config).
13
+ * - HTTP: each incoming /mcp request supplies its own key via
14
+ * Authorization: Bearer pk_live_..., which the transport scopes into
15
+ * AsyncLocalStorage before invoking the tool handler.
16
+ */
17
+ const apiKeyContext = new AsyncLocalStorage();
18
+ export function withApiKey(key, fn) {
19
+ return apiKeyContext.run(key, fn);
20
+ }
21
+ export function getApiKey() {
22
+ const fromContext = apiKeyContext.getStore();
23
+ if (fromContext)
24
+ return fromContext;
25
+ const fromEnv = process.env.FOURA_API_KEY;
26
+ if (fromEnv)
27
+ return fromEnv;
28
+ throw new Error("FOURA_API_KEY not provided. In stdio mode set the FOURA_API_KEY env var. " +
29
+ "In HTTP mode send Authorization: Bearer pk_live_... Get a key at " +
30
+ "https://foura.ai/dashboard#api-keys");
31
+ }
32
+ //# sourceMappingURL=auth.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"auth.js","sourceRoot":"","sources":["../src/auth.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;AAErD;;;;;;;;;;;;;;GAcG;AACH,MAAM,aAAa,GAAG,IAAI,iBAAiB,EAAU,CAAC;AAEtD,MAAM,UAAU,UAAU,CAAI,GAAW,EAAE,EAAoB;IAC7D,OAAO,aAAa,CAAC,GAAG,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC;AACpC,CAAC;AAED,MAAM,UAAU,SAAS;IACvB,MAAM,WAAW,GAAG,aAAa,CAAC,QAAQ,EAAE,CAAC;IAC7C,IAAI,WAAW;QAAE,OAAO,WAAW,CAAC;IACpC,MAAM,OAAO,GAAG,OAAO,CAAC,GAAG,CAAC,aAAa,CAAC;IAC1C,IAAI,OAAO;QAAE,OAAO,OAAO,CAAC;IAC5B,MAAM,IAAI,KAAK,CACb,2EAA2E;QACzE,mEAAmE;QACnE,qCAAqC,CACxC,CAAC;AACJ,CAAC"}
package/dist/http.d.ts ADDED
@@ -0,0 +1 @@
1
+ export {};
package/dist/http.js ADDED
@@ -0,0 +1,182 @@
1
+ import express from "express";
2
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
3
+ import { SUPPORTED_PROTOCOL_VERSIONS } from "@modelcontextprotocol/sdk/types.js";
4
+ import { createServer } from "./server.js";
5
+ import { withApiKey } from "./auth.js";
6
+ const PORT = Number(process.env.PORT ?? 3076);
7
+ const SERVER_VERSION = "0.2.11";
8
+ // Spec MUSTs covered in this file:
9
+ // audit 1.1 — Origin + Host validation (CVE-2025-66414 DNS rebinding)
10
+ // audit 1.2 — WWW-Authenticate on 401
11
+ // audit 1.3 — MCP-Protocol-Version validation (delegated to SDK's list)
12
+ // audit 1.7 — body size cap (256 KB)
13
+ // audit 1.8 — server + request timeout
14
+ // audit 3.1 — SIGTERM graceful shutdown
15
+ // MCP-Protocol-Version allowlist is DERIVED from the SDK at runtime, not
16
+ // hardcoded here. Reason: hardcoding froze us at 2025-06-18 / 2025-03-26
17
+ // and broke every newer client (Claude Code 2.1.141 sends 2025-11-25)
18
+ // until we shipped a release. By reading the SDK's exported authoritative
19
+ // list, every `npm update @modelcontextprotocol/sdk` automatically widens
20
+ // our supported set — no source-code change, no release coupling.
21
+ //
22
+ // SUPPORTED_PROTOCOL_VERSIONS for @modelcontextprotocol/sdk@1.29.0:
23
+ // ['2025-11-25', '2025-06-18', '2025-03-26', '2024-11-05', '2024-10-07']
24
+ const RESOURCE_METADATA_URL = process.env.FOURA_MCP_RESOURCE_METADATA_URL ??
25
+ "https://foura.ai/docs/api/mcp#auth";
26
+ function parseList(env, defaults) {
27
+ const raw = (env ?? "").trim();
28
+ if (!raw)
29
+ return defaults;
30
+ return raw.split(",").map((s) => s.trim()).filter(Boolean);
31
+ }
32
+ // Default allowlist matches production deployment (mcp.foura.ai) +
33
+ // local development. Override with FOURA_MCP_ALLOWED_HOSTS / _ORIGINS for
34
+ // self-hosters or staging environments.
35
+ const ALLOWED_HOSTS = new Set(parseList(process.env.FOURA_MCP_ALLOWED_HOSTS, ["mcp.foura.ai", "localhost", "127.0.0.1", "[::1]"]));
36
+ const ALLOWED_ORIGINS = new Set(parseList(process.env.FOURA_MCP_ALLOWED_ORIGINS, [
37
+ "https://mcp.foura.ai",
38
+ "https://claude.ai",
39
+ "https://app.cursor.sh",
40
+ "https://app.cursor.com",
41
+ ]));
42
+ const app = express();
43
+ // Audit 1.7 — cap body size at 256 KB. Real MCP request payloads are <4 KB.
44
+ // Helps mitigate slow-body DoS + memory-exhaustion attacks.
45
+ app.use(express.json({ limit: "256kb" }));
46
+ // Audit 1.1 — Origin + Host validation BEFORE the body is parsed for the MCP
47
+ // path. /healthz stays open so probes can hit it from any source.
48
+ function jsonRpcError(res, status, code, message, extraHeaders) {
49
+ if (extraHeaders)
50
+ for (const [k, v] of Object.entries(extraHeaders))
51
+ res.setHeader(k, v);
52
+ res.status(status).json({
53
+ jsonrpc: "2.0",
54
+ error: { code, message },
55
+ id: null,
56
+ });
57
+ }
58
+ function validateOriginAndHost(req, res, next) {
59
+ // Host header — defends against DNS-rebinding (attacker's DNS resolves
60
+ // their hostname to a loopback IP, but Host header carries their hostname).
61
+ const hostHeader = (req.headers.host ?? "").toString();
62
+ if (!hostHeader) {
63
+ jsonRpcError(res, 403, -32000, "Missing Host header");
64
+ return;
65
+ }
66
+ let hostname;
67
+ try {
68
+ hostname = new URL(`http://${hostHeader}`).hostname;
69
+ }
70
+ catch {
71
+ jsonRpcError(res, 403, -32000, `Invalid Host header: ${hostHeader}`);
72
+ return;
73
+ }
74
+ // For IPv6 the URL parser strips brackets; restore for the allowlist match.
75
+ const normalizedHost = hostname.includes(":") ? `[${hostname}]` : hostname;
76
+ if (!ALLOWED_HOSTS.has(hostname) && !ALLOWED_HOSTS.has(normalizedHost)) {
77
+ jsonRpcError(res, 403, -32000, `Host ${hostname} is not in the allowlist`);
78
+ return;
79
+ }
80
+ // Origin — browser-only; server-to-server callers (curl, MCP clients in
81
+ // stdio bridge mode) omit it, which is per-spec acceptable. When PRESENT,
82
+ // it MUST match the allowlist (prevents cross-origin JS from a malicious
83
+ // page driving an authenticated MCP session).
84
+ const origin = req.headers.origin;
85
+ if (typeof origin === "string" && origin.length > 0) {
86
+ if (!ALLOWED_ORIGINS.has(origin)) {
87
+ jsonRpcError(res, 403, -32000, `Origin ${origin} is not in the allowlist`);
88
+ return;
89
+ }
90
+ }
91
+ next();
92
+ }
93
+ // Audit 1.3 — MCP-Protocol-Version header validation. Allowlist comes from
94
+ // the SDK's authoritative `SUPPORTED_PROTOCOL_VERSIONS` export so we track
95
+ // upstream automatically. Per spec: when the header is absent, accept
96
+ // (backwards-compat). When present and unknown → 400 with the supported list
97
+ // in the error message so client implementations can self-diagnose.
98
+ function validateProtocolVersion(req, res, next) {
99
+ const raw = req.header("mcp-protocol-version");
100
+ if (!raw) {
101
+ next();
102
+ return;
103
+ }
104
+ if (!SUPPORTED_PROTOCOL_VERSIONS.includes(raw)) {
105
+ jsonRpcError(res, 400, -32602, `Unsupported MCP-Protocol-Version: ${raw}. Supported (from @modelcontextprotocol/sdk): ${SUPPORTED_PROTOCOL_VERSIONS.join(", ")}. Upgrade foura-mcp's SDK pin to extend.`);
106
+ return;
107
+ }
108
+ next();
109
+ }
110
+ app.get("/healthz", (_req, res) => {
111
+ res.json({ ok: true, name: "foura-mcp", version: SERVER_VERSION });
112
+ });
113
+ function extractBearer(req) {
114
+ const auth = req.header("authorization");
115
+ if (auth?.toLowerCase().startsWith("bearer "))
116
+ return auth.slice(7).trim();
117
+ const xKey = req.header("x-api-key");
118
+ if (xKey)
119
+ return xKey.trim();
120
+ return null;
121
+ }
122
+ // Audit 1.2 — emit WWW-Authenticate on 401 so clients can negotiate auth.
123
+ const WWW_AUTHENTICATE = `Bearer realm="foura-mcp", resource_metadata="${RESOURCE_METADATA_URL}"`;
124
+ app.post("/mcp", validateOriginAndHost, validateProtocolVersion, async (req, res) => {
125
+ const apiKey = extractBearer(req);
126
+ if (!apiKey) {
127
+ jsonRpcError(res, 401, -32001, "Missing API key. Send 'Authorization: Bearer pk_live_...' with each request. " +
128
+ "Get a key at https://foura.ai/dashboard#api-keys", { "WWW-Authenticate": WWW_AUTHENTICATE });
129
+ return;
130
+ }
131
+ try {
132
+ const mcp = createServer();
133
+ const transport = new StreamableHTTPServerTransport({
134
+ sessionIdGenerator: undefined,
135
+ });
136
+ res.on("close", () => {
137
+ transport.close();
138
+ mcp.close();
139
+ });
140
+ await mcp.connect(transport);
141
+ await withApiKey(apiKey, () => transport.handleRequest(req, res, req.body));
142
+ }
143
+ catch (err) {
144
+ console.error("[foura-mcp] /mcp handler error:", err);
145
+ if (!res.headersSent) {
146
+ jsonRpcError(res, 500, -32603, "Internal server error");
147
+ }
148
+ }
149
+ });
150
+ const methodNotAllowed = (_req, res) => {
151
+ jsonRpcError(res, 405, -32000, "Method not allowed in stateless mode. Use POST /mcp.");
152
+ };
153
+ app.get("/mcp", methodNotAllowed);
154
+ app.delete("/mcp", methodNotAllowed);
155
+ const server = app.listen(PORT, "0.0.0.0", () => {
156
+ console.error(`[foura-mcp] HTTP listening on :${PORT}`);
157
+ });
158
+ // Audit 1.8 — bound how long an incoming HTTP request can hold a socket open.
159
+ // Defends against slowloris-style attacks (open POST that never finishes
160
+ // sending the body).
161
+ server.setTimeout(60_000);
162
+ server.requestTimeout = 30_000;
163
+ // Audit 3.1 — graceful shutdown. On SIGTERM, stop accepting new connections,
164
+ // let in-flight requests finish (up to 30s), then exit. docker-compose's
165
+ // stop_grace_period must be >= this hard cap.
166
+ function shutdown(signal) {
167
+ console.error(`[foura-mcp] received ${signal}, draining...`);
168
+ server.close((err) => {
169
+ if (err) {
170
+ console.error("[foura-mcp] error during shutdown:", err);
171
+ process.exit(1);
172
+ }
173
+ process.exit(0);
174
+ });
175
+ setTimeout(() => {
176
+ console.error("[foura-mcp] shutdown grace period exceeded, forcing exit");
177
+ process.exit(1);
178
+ }, 30_000).unref();
179
+ }
180
+ process.on("SIGTERM", () => shutdown("SIGTERM"));
181
+ process.on("SIGINT", () => shutdown("SIGINT"));
182
+ //# sourceMappingURL=http.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"http.js","sourceRoot":"","sources":["../src/http.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,11 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ /**
3
+ * MCP Prompts — pre-written workflow templates the user can invoke from the
4
+ * MCP client UI (Claude Desktop / Cursor / etc) instead of figuring out the
5
+ * tool orchestration themselves.
6
+ *
7
+ * Prompts are LAZY context — they only enter the LLM's window when invoked,
8
+ * unlike tool descriptions which are loaded on every turn. So we can be more
9
+ * verbose here.
10
+ */
11
+ export declare function registerPrompts(server: McpServer): void;