@spicrawl/mcp 0.1.1 → 0.1.3
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/CHANGELOG.md +12 -0
- package/README.md +47 -3
- package/dist/chatgpt/http.d.ts +22 -0
- package/dist/chatgpt/http.js +127 -0
- package/dist/chatgpt/http.js.map +1 -0
- package/dist/chatgpt/oauth.d.ts +100 -0
- package/dist/chatgpt/oauth.js +199 -0
- package/dist/chatgpt/oauth.js.map +1 -0
- package/dist/chatgpt/tools.d.ts +45 -0
- package/dist/chatgpt/tools.js +363 -0
- package/dist/chatgpt/tools.js.map +1 -0
- package/dist/client.d.ts +6 -1
- package/dist/client.js +3 -2
- package/dist/client.js.map +1 -1
- package/dist/http.d.ts +7 -0
- package/dist/http.js +38 -26
- package/dist/http.js.map +1 -1
- package/dist/httputil.d.ts +11 -0
- package/dist/httputil.js +33 -0
- package/dist/httputil.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/server.d.ts +48 -2
- package/dist/server.js +20 -7
- package/dist/server.js.map +1 -1
- package/dist/tools/batch.d.ts +11 -0
- package/dist/tools/batch.js +44 -38
- package/dist/tools/batch.js.map +1 -1
- package/dist/tools/browser.js +7 -2
- package/dist/tools/browser.js.map +1 -1
- package/dist/tools/common.d.ts +21 -2
- package/dist/tools/common.js +14 -0
- package/dist/tools/common.js.map +1 -1
- package/dist/tools/docs.js +5 -5
- package/dist/tools/docs.js.map +1 -1
- package/dist/tools/scrape.js +23 -8
- package/dist/tools/scrape.js.map +1 -1
- package/dist/tools/sessions.js +13 -11
- package/dist/tools/sessions.js.map +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,18 @@
|
|
|
3
3
|
All notable changes to `@spicrawl/mcp` are documented here. This project follows
|
|
4
4
|
[Semantic Versioning](https://semver.org/).
|
|
5
5
|
|
|
6
|
+
## 0.1.3 — 2026-10-01
|
|
7
|
+
|
|
8
|
+
- `spicrawl_scrape` on the ChatGPT endpoint (`/chatgpt/mcp`) is now annotated `readOnlyHint: true`. That surface only ever sends an HTTP GET with no method, body, headers or actions, so it cannot change anything on a site. On `/mcp` it stays not read-only, because `method` and `actions` can submit forms.
|
|
9
|
+
- `spicrawl_batch_results` on the ChatGPT endpoint now returns page content even when it is called before the job is `completed`. The API inlines `result.content` only once the whole job is terminal, while each item reports `succeeded` as soon as it finishes, so a model that read results early got metadata only. A succeeded item without content is now filled from `GET /v1/batch/{id}/tasks/{seq}/content` (at most 25 items and 2 MiB per page). An item that can't be read yet is marked `unavailable`, and one past the size cap is marked `omitted`.
|
|
10
|
+
|
|
11
|
+
## 0.1.2 — 2026-10-01
|
|
12
|
+
|
|
13
|
+
- Every tool's MCP annotations are now accurate, and a tool without a `title` and explicit `readOnlyHint`, `destructiveHint` and `openWorldHint` no longer compiles. `spicrawl_scrape` is no longer read-only, because `method` and `actions` can submit forms. `spicrawl_session_release` is destructive, because it purges cookies. Tools confined to your own account are no longer open-world. `test/annotations.test.mjs` pins each value.
|
|
14
|
+
- `SPICRAWL_MCP_HIDE_UNAVAILABLE` (off by default) leaves out what is announced but not available yet: `spicrawl_browser_connect_url` is not registered and every "Coming soon" argument is dropped from the schemas.
|
|
15
|
+
- Tool descriptions no longer name another product. The scrape description states billing and side effects. A printed PDF's JSON no longer carries the request id.
|
|
16
|
+
- A ChatGPT endpoint on the HTTP server, off unless `SPICRAWL_CHATGPT_ENABLED` is on: stateless `POST /chatgpt/mcp` that takes OAuth access tokens only (introspected at `<SPICRAWL_OAUTH_ISSUER>/api/oauth/introspect`, positive answers cached at most 30 s), protected-resource metadata at `/.well-known/oauth-protected-resource/chatgpt/mcp` (and at the root with `SPICRAWL_CHATGPT_ROOT_PRM`), the OpenAI domain challenge at `/.well-known/openai-apps-challenge`, and a restricted six-tool profile with OAuth `securitySchemes` and sanitized results. `/mcp` is unchanged.
|
|
17
|
+
|
|
6
18
|
## 0.1.1 — 2026-10-01
|
|
7
19
|
|
|
8
20
|
Documentation and package metadata only; the server and its 25 tools are unchanged.
|
package/README.md
CHANGED
|
@@ -61,7 +61,7 @@ Both transports serve the same 25 `spicrawl_*` tools, and every call runs under
|
|
|
61
61
|
|
|
62
62
|
## Tools
|
|
63
63
|
|
|
64
|
-
25 tools, grouped by task. Tools reject unknown arguments, so a misspelt field fails with an error naming it instead of being silently dropped. Full argument reference: [docs.spicrawl.com/agents/mcp](https://docs.spicrawl.com/agents/mcp#tools).
|
|
64
|
+
25 tools, grouped by task. Tools reject unknown arguments, so a misspelt field fails with an error naming it instead of being silently dropped. Every tool sets a `title` and explicit `readOnlyHint`, `destructiveHint` and `openWorldHint` annotations, so a client knows which calls to confirm: `spicrawl_scrape` is not read-only (its `method` and `actions` can submit forms), and `spicrawl_batch_cancel`, `spicrawl_session_release` and `spicrawl_session_delete` are destructive. Full argument reference: [docs.spicrawl.com/agents/mcp](https://docs.spicrawl.com/agents/mcp#tools).
|
|
65
65
|
|
|
66
66
|
### Scrape a website to Markdown
|
|
67
67
|
|
|
@@ -114,7 +114,7 @@ Both transports serve the same 25 `spicrawl_*` tools, and every call runs under
|
|
|
114
114
|
|
|
115
115
|
### Coming soon
|
|
116
116
|
|
|
117
|
-
`spicrawl_browser_connect_url` will mint a single-use CDP WebSocket URL for driving a Spicrawl-hosted browser from Puppeteer or Playwright; remote browsers are not available yet. The schemas also accept `ai_extract`, `stealth`, `extract_preset`, `premium_proxy`, `proxy_country` and `sticky_key` ahead of launch. Agents should not send them yet.
|
|
117
|
+
`spicrawl_browser_connect_url` will mint a single-use CDP WebSocket URL for driving a Spicrawl-hosted browser from Puppeteer or Playwright; remote browsers are not available yet. The schemas also accept `ai_extract`, `stealth`, `extract_preset`, `premium_proxy`, `proxy_country` and `sticky_key` ahead of launch. Agents should not send them yet. To list only what works today, set `SPICRAWL_MCP_HIDE_UNAVAILABLE=1` (see [Environment variables](https://github.com/Spicrawl/mcp#environment-variables)).
|
|
118
118
|
|
|
119
119
|
## Example prompts
|
|
120
120
|
|
|
@@ -335,6 +335,14 @@ A stdio-only client runs `npx -y @spicrawl/mcp` with `SPICRAWL_API_KEY` in its e
|
|
|
335
335
|
| `SPICRAWL_DOCS_HOST` | none | both | Legacy: an origin whose docs are at `/docs`. Read only when `SPICRAWL_DOCS_URL` is unset. |
|
|
336
336
|
| `SPICRAWL_MCP_ADDR` | `127.0.0.1:8090` | HTTP | Listen address, `host:port`. |
|
|
337
337
|
| `SPICRAWL_MCP_SESSION_IDLE_MS` | `1800000` (30 min) | HTTP | Idle MCP sessions are dropped after this long. |
|
|
338
|
+
| `SPICRAWL_MCP_HIDE_UNAVAILABLE` | off | both | `1`, `true`, `yes` or `on` leaves out what is announced but not available yet: `spicrawl_browser_connect_url` is not registered (24 tools) and every argument marked "Coming soon" is dropped from the schemas (`ai_extract`, `stealth`, `extract_preset`, `premium_proxy`, `proxy_country`, `sticky_key`, and the session `rotate_ip`, `region_pool` and `fingerprint`). The `camoufox` engine value stays in the `engine` enums. Read when the server starts a session, so restart the process after changing it. |
|
|
339
|
+
| `SPICRAWL_CHATGPT_ENABLED` | off | HTTP | `1`, `true`, `yes` or `on` turns on the [ChatGPT endpoint](#chatgpt-endpoint-oauth). Off, `/chatgpt/mcp`, its metadata and the OpenAI challenge all answer `404`. |
|
|
340
|
+
| `SPICRAWL_CHATGPT_RESOURCE` | `https://mcp.spicrawl.com/chatgpt/mcp` | HTTP | The OAuth resource identifier. Tokens must be issued for exactly this `aud`; the metadata is published at `/.well-known/oauth-protected-resource` + its path. |
|
|
341
|
+
| `SPICRAWL_OAUTH_ISSUER` | `https://app.spicrawl.com` | HTTP | The authorization server, listed in the metadata. Tokens are introspected at `<issuer>/api/oauth/introspect`. |
|
|
342
|
+
| `SPICRAWL_OAUTH_INTROSPECT_SECRET` | none | HTTP | This server's bearer secret at the introspection endpoint. Unset, every token is answered `503`. |
|
|
343
|
+
| `SPICRAWL_CHATGPT_ROOT_PRM` | off | HTTP | Also serve the metadata at the root `/.well-known/oauth-protected-resource`. Off by default because `/mcp` takes API keys and must not advertise OAuth. |
|
|
344
|
+
| `SPICRAWL_OPENAI_APPS_CHALLENGE` | none | HTTP | The OpenAI domain-verification token served at `/.well-known/openai-apps-challenge`. |
|
|
345
|
+
| `SPICRAWL_OPENAI_APPS_CHALLENGE_FILE` | none | HTTP | A file holding that token (surrounding whitespace and the trailing newline are dropped). Read when `SPICRAWL_OPENAI_APPS_CHALLENGE` is unset. |
|
|
338
346
|
|
|
339
347
|
With no docs variable set, a server pointed at a self-hosted API reads that API's own docs at `<SPICRAWL_PUBLIC_BASE_URL or SPICRAWL_BASE_URL>/docs`.
|
|
340
348
|
|
|
@@ -353,6 +361,42 @@ npx -y @spicrawl/mcp --http
|
|
|
353
361
|
- Each caller sends their own key as `Authorization: Bearer spicrawl_live_...`. The key is checked against the API before a session opens (`401` if refused, `503` with `Retry-After` if the API cannot be reached), and a session is pinned to the key that opened it.
|
|
354
362
|
- The server speaks plain HTTP. Put it behind TLS before sending keys across a network.
|
|
355
363
|
|
|
364
|
+
### ChatGPT endpoint (OAuth)
|
|
365
|
+
|
|
366
|
+
The same process can serve a second, restricted MCP endpoint for ChatGPT apps. It is off unless `SPICRAWL_CHATGPT_ENABLED` is on, and `/mcp` is unchanged either way.
|
|
367
|
+
|
|
368
|
+
| Route | What it does |
|
|
369
|
+
|---|---|
|
|
370
|
+
| `POST /chatgpt/mcp` | MCP Streamable HTTP, **stateless**: no `Mcp-Session-Id`, a fresh server per request, JSON responses. Other methods answer `405`. |
|
|
371
|
+
| `GET /.well-known/oauth-protected-resource/chatgpt/mcp` | Protected-resource metadata (RFC 9728): `resource`, `authorization_servers` (the issuer), `scopes_supported: ["scrape","batch"]`, `resource_documentation`. |
|
|
372
|
+
| `GET /.well-known/oauth-protected-resource` | The same document, only with `SPICRAWL_CHATGPT_ROOT_PRM`. |
|
|
373
|
+
| `GET /.well-known/openai-apps-challenge` | The verification token as `text/plain`, exact bytes, no trailing newline; `404` when none is configured. |
|
|
374
|
+
|
|
375
|
+
Authentication takes OAuth access tokens (`spicrawl_oat_…`) only:
|
|
376
|
+
|
|
377
|
+
- No token: `401` with `WWW-Authenticate: Bearer resource_metadata="https://mcp.spicrawl.com/.well-known/oauth-protected-resource/chatgpt/mcp", scope="scrape batch"`.
|
|
378
|
+
- A bad, inactive, expired or wrong-audience token, or an API key (`spicrawl_live_…`/`spicrawl_test_…`, never accepted here): the same `401` plus `error="invalid_token", error_description="…"`.
|
|
379
|
+
- A tool call the token's scopes do not cover: `403` with `error="insufficient_scope"`. `spicrawl_scrape` needs `scrape`, the batch tools need `batch`, the docs tools need only a valid token.
|
|
380
|
+
- The authorization server unreachable or refusing this server's secret: `503` with `Retry-After`.
|
|
381
|
+
|
|
382
|
+
Each token is checked with `POST <issuer>/api/oauth/introspect` (`Authorization: Bearer <SPICRAWL_OAUTH_INTROSPECT_SECRET>`, form body `token=…`). Only `active: true` with `aud` equal to the resource is accepted. A positive answer is cached by the token's SHA-256 for at most 30 seconds and never past its `exp`. Tools run upstream under the `api_key` the introspection returns; neither it nor the token is logged. If the API refuses that key mid-call, the tool returns an error whose `_meta["mcp/www_authenticate"]` carries a fresh `invalid_token` challenge, and the token is dropped from the cache.
|
|
383
|
+
|
|
384
|
+
The endpoint lists six tools, each with explicit `readOnlyHint`/`destructiveHint`/`openWorldHint`, a title, and `securitySchemes: [{type: "oauth2", scopes: [...]}]` (also in `_meta.securitySchemes`):
|
|
385
|
+
|
|
386
|
+
| Tool | Scopes | Arguments |
|
|
387
|
+
|---|---|---|
|
|
388
|
+
| `spicrawl_scrape` | `scrape` | `url` (http/https only), `format` (`markdown`/`text`/`html`/`json`), `render`, `main_content_only`, `include_tags`, `exclude_tags`, `links`, `extract` (CSS selectors), `autoparse`, `wait_for`, `cache`, `cache_ttl`, `max_cost` (default 5). Always an HTTP GET. |
|
|
389
|
+
| `spicrawl_batch_submit` | `batch` | `urls` (1 to 25), `format`, `render`, `main_content_only`, `max_cost`, `credit_budget` (default 50). |
|
|
390
|
+
| `spicrawl_batch_status` | `batch` | `job_id`. |
|
|
391
|
+
| `spicrawl_batch_results` | `batch` | `job_id`, `status`, `limit` (default 25, max 100), `cursor`. |
|
|
392
|
+
| `spicrawl_docs_search`, `spicrawl_docs_read` | none | As on `/mcp`. |
|
|
393
|
+
|
|
394
|
+
`spicrawl_scrape` is read-only here, unlike on `/mcp`: this endpoint only sends a GET, with no `method`, body, headers or `actions`. `spicrawl_batch_submit` is not read-only, because it creates a job.
|
|
395
|
+
|
|
396
|
+
`spicrawl_batch_results` fills in the content of a succeeded item that the results page lists without it, which happens while the job is still finishing, from `GET /v1/batch/{id}/tasks/{seq}/content`. It fetches at most 25 items and 2 MiB per page. An item that can't be read yet comes back `unavailable`, and one past the size cap `omitted`.
|
|
397
|
+
|
|
398
|
+
Every other argument is refused, not dropped. Results keep the content, the site's status and the credits charged; request ids, timestamps, engine and proxy details, storage references and project ids are left out.
|
|
399
|
+
|
|
356
400
|
Installed globally (`npm install -g @spicrawl/mcp`), the command is `spicrawl-mcp` (alias `spicrawl-mcp-server`), with `--http`, `--version` and `--help`.
|
|
357
401
|
|
|
358
402
|
## FAQ
|
|
@@ -373,7 +417,7 @@ Yes, and with Claude Desktop, Windsurf, Gemini CLI, Codex and any client that sp
|
|
|
373
417
|
There is no single crawl tool. Scrape a page with `links: true` to get its links, then feed them to an open batch job (`open: true` and `spicrawl_batch_add_items`).
|
|
374
418
|
|
|
375
419
|
### Does it support OAuth?
|
|
376
|
-
|
|
420
|
+
Not on `/mcp`: it accepts Spicrawl API keys only, so turn OAuth off for it in clients that try it on a `401`. The separate [ChatGPT endpoint](#chatgpt-endpoint-oauth) at `/chatgpt/mcp` takes OAuth access tokens only, when the operator turns it on.
|
|
377
421
|
|
|
378
422
|
### Should I use the MCP server, the SDK or the CLI?
|
|
379
423
|
Use the MCP server when an AI agent should call Spicrawl as tools. Use [`@spicrawl/sdk`](https://github.com/Spicrawl/sdk) in your own TypeScript or JavaScript code, [`@spicrawl/cli`](https://github.com/Spicrawl/cli) in a terminal or shell script, and the [REST API](https://docs.spicrawl.com/quickstart) from any other language.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ChatGPT surface's HTTP routes, mounted by http.ts when
|
|
3
|
+
* SPICRAWL_CHATGPT_ENABLED is on (all of them answer 404 otherwise):
|
|
4
|
+
*
|
|
5
|
+
* POST /chatgpt/mcp stateless MCP, OAuth access tokens only
|
|
6
|
+
* GET /.well-known/oauth-protected-resource/chatgpt/mcp protected-resource metadata (RFC 9728)
|
|
7
|
+
* GET /.well-known/oauth-protected-resource the same, only with SPICRAWL_CHATGPT_ROOT_PRM
|
|
8
|
+
* GET /.well-known/openai-apps-challenge OpenAI domain verification token
|
|
9
|
+
*
|
|
10
|
+
* Stateless: every POST is verified on its own and served by a fresh MCP server
|
|
11
|
+
* and transport, so no session can outlive or outrank the token that made it.
|
|
12
|
+
*/
|
|
13
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
14
|
+
import { type ChatgptConfig } from "./oauth.js";
|
|
15
|
+
export declare const CHATGPT_MCP_PATH = "/chatgpt/mcp";
|
|
16
|
+
export interface ChatgptDeps {
|
|
17
|
+
baseURL: string;
|
|
18
|
+
publicBaseURL: string;
|
|
19
|
+
log: (...a: unknown[]) => void;
|
|
20
|
+
}
|
|
21
|
+
/** Returns a router: true when it answered the request, false to let the caller 404 it. */
|
|
22
|
+
export declare function chatgptRoutes(config: ChatgptConfig, deps: ChatgptDeps): (req: IncomingMessage, res: ServerResponse, path: string) => boolean;
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ChatGPT surface's HTTP routes, mounted by http.ts when
|
|
3
|
+
* SPICRAWL_CHATGPT_ENABLED is on (all of them answer 404 otherwise):
|
|
4
|
+
*
|
|
5
|
+
* POST /chatgpt/mcp stateless MCP, OAuth access tokens only
|
|
6
|
+
* GET /.well-known/oauth-protected-resource/chatgpt/mcp protected-resource metadata (RFC 9728)
|
|
7
|
+
* GET /.well-known/oauth-protected-resource the same, only with SPICRAWL_CHATGPT_ROOT_PRM
|
|
8
|
+
* GET /.well-known/openai-apps-challenge OpenAI domain verification token
|
|
9
|
+
*
|
|
10
|
+
* Stateless: every POST is verified on its own and served by a fresh MCP server
|
|
11
|
+
* and transport, so no session can outlive or outrank the token that made it.
|
|
12
|
+
*/
|
|
13
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
14
|
+
import { ILayerClient } from "../client.js";
|
|
15
|
+
import { KEY_RE, bearerToken, readJSON, rpcError } from "../httputil.js";
|
|
16
|
+
import { ACCESS_TOKEN_RE, Introspector, SCOPES, bearerChallenge, prmPath, protectedResourceMetadata, } from "./oauth.js";
|
|
17
|
+
import { TOOL_SCOPES, buildChatgptServer } from "./tools.js";
|
|
18
|
+
export const CHATGPT_MCP_PATH = "/chatgpt/mcp";
|
|
19
|
+
const ROOT_PRM_PATH = "/.well-known/oauth-protected-resource";
|
|
20
|
+
const CHALLENGE_PATH = "/.well-known/openai-apps-challenge";
|
|
21
|
+
/** Scopes a JSON-RPC message (or batch of them) needs: those of every tool it calls. */
|
|
22
|
+
function requiredScopes(body) {
|
|
23
|
+
const out = new Set();
|
|
24
|
+
for (const msg of Array.isArray(body) ? body : [body]) {
|
|
25
|
+
if (!msg || typeof msg !== "object" || msg.method !== "tools/call")
|
|
26
|
+
continue;
|
|
27
|
+
const name = msg.params?.name;
|
|
28
|
+
for (const s of (typeof name === "string" && TOOL_SCOPES[name]) || [])
|
|
29
|
+
out.add(s);
|
|
30
|
+
}
|
|
31
|
+
return SCOPES.filter((s) => out.has(s));
|
|
32
|
+
}
|
|
33
|
+
/** Returns a router: true when it answered the request, false to let the caller 404 it. */
|
|
34
|
+
export function chatgptRoutes(config, deps) {
|
|
35
|
+
const introspector = new Introspector(config, { log: deps.log });
|
|
36
|
+
const prm = prmPath(config.resource);
|
|
37
|
+
const metadata = JSON.stringify(protectedResourceMetadata(config));
|
|
38
|
+
const unauthorized = (res, description) => rpcError(res, 401, -32001, `Unauthorized: ${description ?? "connect with OAuth to use this endpoint"}`, {
|
|
39
|
+
"WWW-Authenticate": bearerChallenge(config.resource, description ? { error: "invalid_token", description } : {}),
|
|
40
|
+
});
|
|
41
|
+
async function handleMcp(req, res) {
|
|
42
|
+
const token = bearerToken(req);
|
|
43
|
+
if (!token)
|
|
44
|
+
return unauthorized(res);
|
|
45
|
+
if (KEY_RE.test(token)) {
|
|
46
|
+
return unauthorized(res, "API keys are not accepted on this endpoint; connect the Spicrawl app with OAuth");
|
|
47
|
+
}
|
|
48
|
+
if (!ACCESS_TOKEN_RE.test(token))
|
|
49
|
+
return unauthorized(res, "The access token is malformed");
|
|
50
|
+
const check = await introspector.introspect(token);
|
|
51
|
+
if (!check.ok) {
|
|
52
|
+
return check.status === 401
|
|
53
|
+
? unauthorized(res, check.description)
|
|
54
|
+
: rpcError(res, 503, -32000, `Service Unavailable: ${check.description}; retry shortly`, { "Retry-After": "5" });
|
|
55
|
+
}
|
|
56
|
+
const grant = check.grant;
|
|
57
|
+
if (req.method !== "POST") {
|
|
58
|
+
return rpcError(res, 405, -32000, "Method not allowed: this endpoint is stateless; POST JSON-RPC requests", { Allow: "POST" });
|
|
59
|
+
}
|
|
60
|
+
let body;
|
|
61
|
+
try {
|
|
62
|
+
body = await readJSON(req);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return rpcError(res, 400, -32700, "Parse error: body must be JSON (max 4 MiB)");
|
|
66
|
+
}
|
|
67
|
+
const missing = requiredScopes(body).filter((s) => !grant.scopes.has(s));
|
|
68
|
+
if (missing.length) {
|
|
69
|
+
const description = `This tool needs the ${missing.join(" and ")} permission; reconnect the Spicrawl app to grant it`;
|
|
70
|
+
return rpcError(res, 403, -32003, `Forbidden: ${description}`, {
|
|
71
|
+
"WWW-Authenticate": bearerChallenge(config.resource, {
|
|
72
|
+
scope: SCOPES.filter((s) => grant.scopes.has(s) || missing.includes(s)).join(" "),
|
|
73
|
+
error: "insufficient_scope",
|
|
74
|
+
description,
|
|
75
|
+
}),
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
const client = new ILayerClient({ apiKey: grant.apiKey, baseURL: deps.baseURL, publicBaseURL: deps.publicBaseURL });
|
|
79
|
+
const server = buildChatgptServer(client, {
|
|
80
|
+
upstreamChallenge: (scopes) => bearerChallenge(config.resource, {
|
|
81
|
+
scope: (scopes.length ? scopes : SCOPES).join(" "),
|
|
82
|
+
error: "invalid_token",
|
|
83
|
+
description: "The Spicrawl credential behind this access token was refused; reconnect the Spicrawl app",
|
|
84
|
+
}),
|
|
85
|
+
onUpstreamUnauthorized: () => introspector.forget(token),
|
|
86
|
+
});
|
|
87
|
+
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true });
|
|
88
|
+
res.on("close", () => {
|
|
89
|
+
void transport.close().catch(() => { });
|
|
90
|
+
void server.close().catch(() => { });
|
|
91
|
+
});
|
|
92
|
+
await server.connect(transport);
|
|
93
|
+
await transport.handleRequest(req, res, body);
|
|
94
|
+
}
|
|
95
|
+
const sendMetadata = (req, res) => {
|
|
96
|
+
res.writeHead(200, {
|
|
97
|
+
"Content-Type": "application/json",
|
|
98
|
+
"Cache-Control": "public, max-age=300",
|
|
99
|
+
"Access-Control-Allow-Origin": "*",
|
|
100
|
+
});
|
|
101
|
+
res.end(req.method === "HEAD" ? undefined : metadata);
|
|
102
|
+
};
|
|
103
|
+
return (req, res, path) => {
|
|
104
|
+
if (!config.enabled)
|
|
105
|
+
return false;
|
|
106
|
+
const read = req.method === "GET" || req.method === "HEAD";
|
|
107
|
+
if (path === CHATGPT_MCP_PATH) {
|
|
108
|
+
handleMcp(req, res).catch((err) => {
|
|
109
|
+
deps.log("chatgpt request failed:", err instanceof Error ? err.message : String(err));
|
|
110
|
+
rpcError(res, 500, -32603, "Internal error");
|
|
111
|
+
});
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
if (read && (path === prm || (config.rootPrm && path === ROOT_PRM_PATH))) {
|
|
115
|
+
sendMetadata(req, res);
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
if (read && path === CHALLENGE_PATH && config.appsChallenge) {
|
|
119
|
+
const bytes = Buffer.from(config.appsChallenge, "utf8");
|
|
120
|
+
res.writeHead(200, { "Content-Type": "text/plain", "Content-Length": String(bytes.length) });
|
|
121
|
+
res.end(req.method === "HEAD" ? undefined : bytes);
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
return false;
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
//# sourceMappingURL=http.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"http.js","sourceRoot":"","sources":["../../src/chatgpt/http.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth for the ChatGPT surface (`/chatgpt/mcp`): its configuration, the
|
|
3
|
+
* protected-resource metadata it publishes (RFC 9728), the WWW-Authenticate
|
|
4
|
+
* challenges it answers with, and access-token introspection (RFC 7662) against
|
|
5
|
+
* the Spicrawl authorization server.
|
|
6
|
+
*
|
|
7
|
+
* The surface never takes an API key. A caller presents an OAuth access token
|
|
8
|
+
* (`spicrawl_oat_…`); the authorization server says which project it belongs to
|
|
9
|
+
* and hands back the API key to run that project's calls under. Neither the
|
|
10
|
+
* token nor that key is ever logged.
|
|
11
|
+
*/
|
|
12
|
+
/** Every scope the surface knows, in the order the challenge lists them. */
|
|
13
|
+
export declare const SCOPES: readonly ["scrape", "batch"];
|
|
14
|
+
export type Scope = (typeof SCOPES)[number];
|
|
15
|
+
export declare const DEFAULT_RESOURCE = "https://mcp.spicrawl.com/chatgpt/mcp";
|
|
16
|
+
export declare const DEFAULT_ISSUER = "https://app.spicrawl.com";
|
|
17
|
+
export declare const RESOURCE_DOCUMENTATION = "https://docs.spicrawl.com/agents/mcp";
|
|
18
|
+
/** OAuth access tokens the Spicrawl authorization server issues. */
|
|
19
|
+
export declare const ACCESS_TOKEN_RE: RegExp;
|
|
20
|
+
/** A positive introspection is reused for at most this long, and never past the token's `exp`. */
|
|
21
|
+
export declare const CACHE_MAX_MS = 30000;
|
|
22
|
+
export interface ChatgptConfig {
|
|
23
|
+
/** SPICRAWL_CHATGPT_ENABLED: the kill switch. Off: every route below answers 404. */
|
|
24
|
+
enabled: boolean;
|
|
25
|
+
/** SPICRAWL_CHATGPT_RESOURCE: the resource identifier tokens must be issued for (`aud`). */
|
|
26
|
+
resource: string;
|
|
27
|
+
/** SPICRAWL_OAUTH_ISSUER: the authorization server, no trailing slash. */
|
|
28
|
+
issuer: string;
|
|
29
|
+
/** SPICRAWL_OAUTH_INTROSPECT_SECRET: this server's credential at the introspection endpoint. */
|
|
30
|
+
introspectSecret: string;
|
|
31
|
+
/** SPICRAWL_CHATGPT_ROOT_PRM: also publish the metadata at the root well-known path. */
|
|
32
|
+
rootPrm: boolean;
|
|
33
|
+
/** SPICRAWL_OPENAI_APPS_CHALLENGE (or `…_FILE`): the domain-verification token, or "" for none. */
|
|
34
|
+
appsChallenge: string;
|
|
35
|
+
}
|
|
36
|
+
/** The configuration from the environment. Throws when a set value is unusable. */
|
|
37
|
+
export declare function chatgptConfigFromEnv(env?: NodeJS.ProcessEnv): ChatgptConfig;
|
|
38
|
+
/** Where the resource's metadata lives: `/.well-known/oauth-protected-resource` + the resource's path (RFC 9728 §3.1). */
|
|
39
|
+
export declare function prmPath(resource: string): string;
|
|
40
|
+
/** The metadata's absolute URL, as the challenge names it. */
|
|
41
|
+
export declare function prmURL(resource: string): string;
|
|
42
|
+
/** The protected-resource metadata document (RFC 9728). */
|
|
43
|
+
export declare function protectedResourceMetadata(config: ChatgptConfig): {
|
|
44
|
+
resource: string;
|
|
45
|
+
authorization_servers: string[];
|
|
46
|
+
scopes_supported: ("scrape" | "batch")[];
|
|
47
|
+
resource_documentation: string;
|
|
48
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* A `Bearer` challenge (RFC 6750 §3, RFC 9728 §5.1). Without `error` it is the
|
|
51
|
+
* plain "authenticate here" answer to a request that sent no token.
|
|
52
|
+
*/
|
|
53
|
+
export declare function bearerChallenge(resource: string, opts?: {
|
|
54
|
+
scope?: string;
|
|
55
|
+
error?: "invalid_token" | "insufficient_scope";
|
|
56
|
+
description?: string;
|
|
57
|
+
}): string;
|
|
58
|
+
/** What an accepted token grants. */
|
|
59
|
+
export interface Grant {
|
|
60
|
+
scopes: Set<string>;
|
|
61
|
+
/** The project's API key the token stands for; the upstream calls run under it. Never logged. */
|
|
62
|
+
apiKey: string;
|
|
63
|
+
clientId?: string;
|
|
64
|
+
/** Milliseconds since the epoch; undefined when the server sent no `exp`. */
|
|
65
|
+
expiresAt?: number;
|
|
66
|
+
}
|
|
67
|
+
export type Introspection = {
|
|
68
|
+
ok: true;
|
|
69
|
+
grant: Grant;
|
|
70
|
+
} | {
|
|
71
|
+
ok: false;
|
|
72
|
+
status: 401;
|
|
73
|
+
description: string;
|
|
74
|
+
} | {
|
|
75
|
+
ok: false;
|
|
76
|
+
status: 503;
|
|
77
|
+
description: string;
|
|
78
|
+
};
|
|
79
|
+
/**
|
|
80
|
+
* Introspects access tokens at `<issuer>/api/oauth/introspect`, caching
|
|
81
|
+
* positive answers by the token's SHA-256 for at most 30 s and never past the
|
|
82
|
+
* token's expiry. A negative answer is never cached, so a token issued a moment
|
|
83
|
+
* later is not refused from memory.
|
|
84
|
+
*/
|
|
85
|
+
export declare class Introspector {
|
|
86
|
+
private readonly config;
|
|
87
|
+
private readonly cache;
|
|
88
|
+
private readonly log;
|
|
89
|
+
constructor(config: Pick<ChatgptConfig, "issuer" | "resource" | "introspectSecret">, options?: {
|
|
90
|
+
log?: (...a: unknown[]) => void;
|
|
91
|
+
now?: () => number;
|
|
92
|
+
});
|
|
93
|
+
private now;
|
|
94
|
+
get endpoint(): string;
|
|
95
|
+
/** Forgets a token, e.g. once the API refused the key it stood for. */
|
|
96
|
+
forget(token: string): void;
|
|
97
|
+
introspect(token: string): Promise<Introspection>;
|
|
98
|
+
/** Drops expired entries, then the oldest ones, to stay under the cap. */
|
|
99
|
+
private evict;
|
|
100
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth for the ChatGPT surface (`/chatgpt/mcp`): its configuration, the
|
|
3
|
+
* protected-resource metadata it publishes (RFC 9728), the WWW-Authenticate
|
|
4
|
+
* challenges it answers with, and access-token introspection (RFC 7662) against
|
|
5
|
+
* the Spicrawl authorization server.
|
|
6
|
+
*
|
|
7
|
+
* The surface never takes an API key. A caller presents an OAuth access token
|
|
8
|
+
* (`spicrawl_oat_…`); the authorization server says which project it belongs to
|
|
9
|
+
* and hands back the API key to run that project's calls under. Neither the
|
|
10
|
+
* token nor that key is ever logged.
|
|
11
|
+
*/
|
|
12
|
+
import { createHash } from "node:crypto";
|
|
13
|
+
import { readFileSync } from "node:fs";
|
|
14
|
+
import { USER_AGENT } from "../client.js";
|
|
15
|
+
import { isTruthy } from "../server.js";
|
|
16
|
+
import { KEY_RE } from "../httputil.js";
|
|
17
|
+
/** Every scope the surface knows, in the order the challenge lists them. */
|
|
18
|
+
export const SCOPES = ["scrape", "batch"];
|
|
19
|
+
export const DEFAULT_RESOURCE = "https://mcp.spicrawl.com/chatgpt/mcp";
|
|
20
|
+
export const DEFAULT_ISSUER = "https://app.spicrawl.com";
|
|
21
|
+
export const RESOURCE_DOCUMENTATION = "https://docs.spicrawl.com/agents/mcp";
|
|
22
|
+
/** OAuth access tokens the Spicrawl authorization server issues. */
|
|
23
|
+
export const ACCESS_TOKEN_RE = /^spicrawl_oat_[A-Za-z0-9_-]{8,}$/;
|
|
24
|
+
const INTROSPECT_TIMEOUT_MS = 5_000;
|
|
25
|
+
/** A positive introspection is reused for at most this long, and never past the token's `exp`. */
|
|
26
|
+
export const CACHE_MAX_MS = 30_000;
|
|
27
|
+
const CACHE_MAX_ENTRIES = 10_000;
|
|
28
|
+
const trimURL = (s) => (s ?? "").trim().replace(/\/+$/, "");
|
|
29
|
+
/** The configuration from the environment. Throws when a set value is unusable. */
|
|
30
|
+
export function chatgptConfigFromEnv(env = process.env) {
|
|
31
|
+
const resource = trimURL(env.SPICRAWL_CHATGPT_RESOURCE) || DEFAULT_RESOURCE;
|
|
32
|
+
const issuer = trimURL(env.SPICRAWL_OAUTH_ISSUER) || DEFAULT_ISSUER;
|
|
33
|
+
for (const [name, v] of [["SPICRAWL_CHATGPT_RESOURCE", resource], ["SPICRAWL_OAUTH_ISSUER", issuer]]) {
|
|
34
|
+
let u;
|
|
35
|
+
try {
|
|
36
|
+
u = new URL(v);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
throw new Error(`${name} '${v}' is not an absolute URL`);
|
|
40
|
+
}
|
|
41
|
+
if (u.protocol !== "https:" && u.protocol !== "http:")
|
|
42
|
+
throw new Error(`${name} '${v}' must be http(s)`);
|
|
43
|
+
}
|
|
44
|
+
let appsChallenge = (env.SPICRAWL_OPENAI_APPS_CHALLENGE ?? "").trim();
|
|
45
|
+
const file = (env.SPICRAWL_OPENAI_APPS_CHALLENGE_FILE ?? "").trim();
|
|
46
|
+
if (!appsChallenge && file)
|
|
47
|
+
appsChallenge = readFileSync(file, "utf8").trim();
|
|
48
|
+
return {
|
|
49
|
+
enabled: isTruthy(env.SPICRAWL_CHATGPT_ENABLED),
|
|
50
|
+
resource,
|
|
51
|
+
issuer,
|
|
52
|
+
introspectSecret: (env.SPICRAWL_OAUTH_INTROSPECT_SECRET ?? "").trim(),
|
|
53
|
+
rootPrm: isTruthy(env.SPICRAWL_CHATGPT_ROOT_PRM),
|
|
54
|
+
appsChallenge,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
/** Where the resource's metadata lives: `/.well-known/oauth-protected-resource` + the resource's path (RFC 9728 §3.1). */
|
|
58
|
+
export function prmPath(resource) {
|
|
59
|
+
const path = new URL(resource).pathname.replace(/\/+$/, "");
|
|
60
|
+
return `/.well-known/oauth-protected-resource${path === "/" ? "" : path}`;
|
|
61
|
+
}
|
|
62
|
+
/** The metadata's absolute URL, as the challenge names it. */
|
|
63
|
+
export function prmURL(resource) {
|
|
64
|
+
return new URL(resource).origin + prmPath(resource);
|
|
65
|
+
}
|
|
66
|
+
/** The protected-resource metadata document (RFC 9728). */
|
|
67
|
+
export function protectedResourceMetadata(config) {
|
|
68
|
+
return {
|
|
69
|
+
resource: config.resource,
|
|
70
|
+
authorization_servers: [config.issuer],
|
|
71
|
+
scopes_supported: [...SCOPES],
|
|
72
|
+
resource_documentation: RESOURCE_DOCUMENTATION,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
/** A quoted-string parameter value: no quotes, backslashes or control characters. */
|
|
76
|
+
const quotable = (s) => s.replace(/["\\\u0000-\u001f\u007f]/g, " ");
|
|
77
|
+
/**
|
|
78
|
+
* A `Bearer` challenge (RFC 6750 §3, RFC 9728 §5.1). Without `error` it is the
|
|
79
|
+
* plain "authenticate here" answer to a request that sent no token.
|
|
80
|
+
*/
|
|
81
|
+
export function bearerChallenge(resource, opts = {}) {
|
|
82
|
+
const parts = [`resource_metadata="${prmURL(resource)}"`, `scope="${quotable(opts.scope ?? SCOPES.join(" "))}"`];
|
|
83
|
+
if (opts.error)
|
|
84
|
+
parts.push(`error="${opts.error}"`);
|
|
85
|
+
if (opts.error && opts.description)
|
|
86
|
+
parts.push(`error_description="${quotable(opts.description)}"`);
|
|
87
|
+
return `Bearer ${parts.join(", ")}`;
|
|
88
|
+
}
|
|
89
|
+
const tokenHash = (token) => createHash("sha256").update(token).digest("hex");
|
|
90
|
+
/**
|
|
91
|
+
* Introspects access tokens at `<issuer>/api/oauth/introspect`, caching
|
|
92
|
+
* positive answers by the token's SHA-256 for at most 30 s and never past the
|
|
93
|
+
* token's expiry. A negative answer is never cached, so a token issued a moment
|
|
94
|
+
* later is not refused from memory.
|
|
95
|
+
*/
|
|
96
|
+
export class Introspector {
|
|
97
|
+
config;
|
|
98
|
+
cache = new Map();
|
|
99
|
+
log;
|
|
100
|
+
constructor(config, options = {}) {
|
|
101
|
+
this.config = config;
|
|
102
|
+
this.log = options.log ?? (() => { });
|
|
103
|
+
if (options.now)
|
|
104
|
+
this.now = options.now;
|
|
105
|
+
}
|
|
106
|
+
now = () => Date.now();
|
|
107
|
+
get endpoint() {
|
|
108
|
+
return `${this.config.issuer}/api/oauth/introspect`;
|
|
109
|
+
}
|
|
110
|
+
/** Forgets a token, e.g. once the API refused the key it stood for. */
|
|
111
|
+
forget(token) {
|
|
112
|
+
this.cache.delete(tokenHash(token));
|
|
113
|
+
}
|
|
114
|
+
async introspect(token) {
|
|
115
|
+
const key = tokenHash(token);
|
|
116
|
+
const now = this.now();
|
|
117
|
+
const hit = this.cache.get(key);
|
|
118
|
+
if (hit) {
|
|
119
|
+
if (hit.until > now)
|
|
120
|
+
return { ok: true, grant: hit.grant };
|
|
121
|
+
this.cache.delete(key);
|
|
122
|
+
}
|
|
123
|
+
if (!this.config.introspectSecret) {
|
|
124
|
+
this.log("chatgpt: SPICRAWL_OAUTH_INTROSPECT_SECRET is not set; cannot verify access tokens");
|
|
125
|
+
return { ok: false, status: 503, description: "token verification is not configured on this server" };
|
|
126
|
+
}
|
|
127
|
+
let res;
|
|
128
|
+
try {
|
|
129
|
+
res = await fetch(this.endpoint, {
|
|
130
|
+
method: "POST",
|
|
131
|
+
headers: {
|
|
132
|
+
Authorization: `Bearer ${this.config.introspectSecret}`,
|
|
133
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
134
|
+
Accept: "application/json",
|
|
135
|
+
"User-Agent": USER_AGENT,
|
|
136
|
+
},
|
|
137
|
+
body: new URLSearchParams({ token }).toString(),
|
|
138
|
+
signal: AbortSignal.timeout(INTROSPECT_TIMEOUT_MS),
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
catch (err) {
|
|
142
|
+
this.log("chatgpt: introspection unreachable:", err instanceof Error ? err.message : String(err));
|
|
143
|
+
return { ok: false, status: 503, description: "could not reach the authorization server to verify the token" };
|
|
144
|
+
}
|
|
145
|
+
const text = await res.text().catch(() => "");
|
|
146
|
+
if (!res.ok) {
|
|
147
|
+
// A refusal of OUR credential (401/403) or a server error says nothing about the caller's token.
|
|
148
|
+
this.log(`chatgpt: introspection answered HTTP ${res.status}`);
|
|
149
|
+
return { ok: false, status: 503, description: "the authorization server could not verify the token" };
|
|
150
|
+
}
|
|
151
|
+
let body;
|
|
152
|
+
try {
|
|
153
|
+
const parsed = JSON.parse(text);
|
|
154
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
155
|
+
throw new Error("not an object");
|
|
156
|
+
body = parsed;
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
this.log("chatgpt: introspection answered a body that is not a JSON object");
|
|
160
|
+
return { ok: false, status: 503, description: "the authorization server could not verify the token" };
|
|
161
|
+
}
|
|
162
|
+
if (body.active !== true)
|
|
163
|
+
return { ok: false, status: 401, description: "The access token is invalid, expired or revoked" };
|
|
164
|
+
if (body.aud !== this.config.resource) {
|
|
165
|
+
return { ok: false, status: 401, description: "The access token was not issued for this resource" };
|
|
166
|
+
}
|
|
167
|
+
const exp = typeof body.exp === "number" && Number.isFinite(body.exp) ? body.exp * 1000 : undefined;
|
|
168
|
+
if (exp !== undefined && exp <= now)
|
|
169
|
+
return { ok: false, status: 401, description: "The access token has expired" };
|
|
170
|
+
const apiKey = typeof body.api_key === "string" ? body.api_key : "";
|
|
171
|
+
if (!KEY_RE.test(apiKey)) {
|
|
172
|
+
this.log("chatgpt: an active token came back without a usable API credential");
|
|
173
|
+
return { ok: false, status: 401, description: "The access token is not linked to a Spicrawl project" };
|
|
174
|
+
}
|
|
175
|
+
const grant = {
|
|
176
|
+
scopes: new Set(typeof body.scope === "string" ? body.scope.split(/\s+/).filter(Boolean) : []),
|
|
177
|
+
apiKey,
|
|
178
|
+
clientId: typeof body.client_id === "string" ? body.client_id : undefined,
|
|
179
|
+
expiresAt: exp,
|
|
180
|
+
};
|
|
181
|
+
const until = Math.min(now + CACHE_MAX_MS, exp ?? Infinity);
|
|
182
|
+
if (this.cache.size >= CACHE_MAX_ENTRIES)
|
|
183
|
+
this.evict(now);
|
|
184
|
+
this.cache.set(key, { grant, until });
|
|
185
|
+
return { ok: true, grant };
|
|
186
|
+
}
|
|
187
|
+
/** Drops expired entries, then the oldest ones, to stay under the cap. */
|
|
188
|
+
evict(now) {
|
|
189
|
+
for (const [k, e] of this.cache)
|
|
190
|
+
if (e.until <= now)
|
|
191
|
+
this.cache.delete(k);
|
|
192
|
+
for (const k of this.cache.keys()) {
|
|
193
|
+
if (this.cache.size < CACHE_MAX_ENTRIES)
|
|
194
|
+
break;
|
|
195
|
+
this.cache.delete(k);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
//# sourceMappingURL=oauth.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"oauth.js","sourceRoot":"","sources":["../../src/chatgpt/oauth.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The restricted tool profile served on `/chatgpt/mcp`.
|
|
3
|
+
*
|
|
4
|
+
* A smaller, read-mostly set than `/mcp`, shaped by OpenAI's app guidelines:
|
|
5
|
+
* a scrape is always a GET with no browser actions, custom headers, proxies,
|
|
6
|
+
* sessions or engine pins; a batch takes a short list of URLs with a credit
|
|
7
|
+
* budget; results come back without request ids, timestamps or internal ids.
|
|
8
|
+
* Every tool states its OAuth scopes (`securitySchemes`, top level and in
|
|
9
|
+
* `_meta`) so the client can ask for the right consent.
|
|
10
|
+
*/
|
|
11
|
+
import { ILayerClient } from "../client.js";
|
|
12
|
+
import { SpicrawlMcpServer } from "../server.js";
|
|
13
|
+
import type { Scope } from "./oauth.js";
|
|
14
|
+
export type SecurityScheme = {
|
|
15
|
+
type: "oauth2";
|
|
16
|
+
scopes: Scope[];
|
|
17
|
+
};
|
|
18
|
+
/** Each tool on this surface with the scopes a call to it needs. Anything else registered is dropped. */
|
|
19
|
+
export declare const TOOL_SCOPES: Record<string, Scope[]>;
|
|
20
|
+
export declare function sanitizeScrape(data: unknown): unknown;
|
|
21
|
+
export declare function sanitizeJob(data: unknown): unknown;
|
|
22
|
+
export declare function sanitizeResults(data: unknown): unknown;
|
|
23
|
+
export interface ChatgptContext {
|
|
24
|
+
/**
|
|
25
|
+
* The WWW-Authenticate challenge for a tool error when the API refused the
|
|
26
|
+
* credential behind the token mid-call (`_meta["mcp/www_authenticate"]`).
|
|
27
|
+
*/
|
|
28
|
+
upstreamChallenge: (scopes: Scope[]) => string;
|
|
29
|
+
/** Called when the API refused that credential, so the token is not trusted from cache. */
|
|
30
|
+
onUpstreamUnauthorized: () => void;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* The results endpoint inlines `result.content` only once the whole job is
|
|
34
|
+
* terminal (it reads the stitched result file), but items report `succeeded`
|
|
35
|
+
* as each finishes. A model that sees every item succeeded and reads results
|
|
36
|
+
* before the job turns `completed` would get no content at all, so a succeeded
|
|
37
|
+
* item without a `result` is filled from GET /v1/batch/{id}/tasks/{seq}/content,
|
|
38
|
+
* which serves a finished item's payload on its own. An item it cannot serve
|
|
39
|
+
* is marked `unavailable` instead of failing the page, and past FILL_MAX_BYTES
|
|
40
|
+
* an item is marked `omitted`. A 401 still propagates,
|
|
41
|
+
* so `call` can answer with a fresh OAuth challenge.
|
|
42
|
+
*/
|
|
43
|
+
export declare function fillMissingContent(client: ILayerClient, job_id: string, page: Record<string, unknown>): Promise<void>;
|
|
44
|
+
/** One MCP server for one ChatGPT request, running under the API key the token stands for. */
|
|
45
|
+
export declare function buildChatgptServer(client: ILayerClient, ctx: ChatgptContext): SpicrawlMcpServer;
|