@novorb/mcp-core 2.1.2 → 3.0.1
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/README.md +77 -163
- package/bin/mcp-core-jest.mjs +112 -0
- package/dist/auth.d.ts +57 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +74 -0
- package/dist/auth.js.map +1 -0
- package/dist/bootstrap.d.ts +129 -0
- package/dist/bootstrap.d.ts.map +1 -0
- package/dist/bootstrap.js +206 -0
- package/dist/bootstrap.js.map +1 -0
- package/dist/client-input.d.ts +72 -0
- package/dist/client-input.d.ts.map +1 -0
- package/dist/client-input.js +109 -0
- package/dist/client-input.js.map +1 -0
- package/dist/credential-checks.d.ts +80 -0
- package/dist/credential-checks.d.ts.map +1 -0
- package/dist/credential-checks.js +189 -0
- package/dist/credential-checks.js.map +1 -0
- package/dist/credential-health.d.ts +12 -0
- package/dist/credential-health.d.ts.map +1 -0
- package/dist/credential-health.js +12 -0
- package/dist/credential-health.js.map +1 -0
- package/dist/credential-report.d.ts +46 -0
- package/dist/credential-report.d.ts.map +1 -0
- package/dist/credential-report.js +135 -0
- package/dist/credential-report.js.map +1 -0
- package/dist/credential-status-tool.d.ts +20 -0
- package/dist/credential-status-tool.d.ts.map +1 -0
- package/dist/credential-status-tool.js +33 -0
- package/dist/credential-status-tool.js.map +1 -0
- package/dist/credentials.d.ts +288 -0
- package/dist/credentials.d.ts.map +1 -0
- package/dist/credentials.js +652 -0
- package/dist/credentials.js.map +1 -0
- package/dist/express-peers.d.ts +29 -0
- package/dist/express-peers.d.ts.map +1 -0
- package/dist/express-peers.js +36 -0
- package/dist/express-peers.js.map +1 -0
- package/dist/express.d.ts +74 -0
- package/dist/express.d.ts.map +1 -0
- package/dist/express.js +164 -0
- package/dist/express.js.map +1 -0
- package/dist/http-client.d.ts +277 -42
- package/dist/http-client.d.ts.map +1 -1
- package/dist/http-client.js +620 -180
- package/dist/http-client.js.map +1 -1
- package/dist/index.d.ts +27 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +47 -8
- package/dist/index.js.map +1 -1
- package/dist/limits.d.ts +28 -0
- package/dist/limits.d.ts.map +1 -0
- package/dist/limits.js +37 -0
- package/dist/limits.js.map +1 -0
- package/dist/list-pagination.d.ts +41 -0
- package/dist/list-pagination.d.ts.map +1 -0
- package/dist/list-pagination.js +104 -0
- package/dist/list-pagination.js.map +1 -0
- package/dist/net.d.ts +112 -0
- package/dist/net.d.ts.map +1 -0
- package/dist/net.js +490 -0
- package/dist/net.js.map +1 -0
- package/dist/notifications.d.ts +78 -0
- package/dist/notifications.d.ts.map +1 -0
- package/dist/notifications.js +173 -0
- package/dist/notifications.js.map +1 -0
- package/dist/pagination.d.ts +236 -5
- package/dist/pagination.d.ts.map +1 -1
- package/dist/pagination.js +393 -6
- package/dist/pagination.js.map +1 -1
- package/dist/prompts.d.ts +70 -0
- package/dist/prompts.d.ts.map +1 -0
- package/dist/prompts.js +109 -0
- package/dist/prompts.js.map +1 -0
- package/dist/protocol-errors.d.ts +15 -0
- package/dist/protocol-errors.d.ts.map +1 -0
- package/dist/protocol-errors.js +28 -0
- package/dist/protocol-errors.js.map +1 -0
- package/dist/protocol-logging.d.ts +37 -0
- package/dist/protocol-logging.d.ts.map +1 -0
- package/dist/protocol-logging.js +77 -0
- package/dist/protocol-logging.js.map +1 -0
- package/dist/rate-limiter.d.ts +36 -0
- package/dist/rate-limiter.d.ts.map +1 -0
- package/dist/rate-limiter.js +79 -0
- package/dist/rate-limiter.js.map +1 -0
- package/dist/request-state.d.ts +74 -0
- package/dist/request-state.d.ts.map +1 -0
- package/dist/request-state.js +190 -0
- package/dist/request-state.js.map +1 -0
- package/dist/resources.d.ts +181 -0
- package/dist/resources.d.ts.map +1 -0
- package/dist/resources.js +302 -0
- package/dist/resources.js.map +1 -0
- package/dist/result-limits.d.ts +80 -0
- package/dist/result-limits.d.ts.map +1 -0
- package/dist/result-limits.js +248 -0
- package/dist/result-limits.js.map +1 -0
- package/dist/retry.d.ts +79 -23
- package/dist/retry.d.ts.map +1 -1
- package/dist/retry.js +170 -57
- package/dist/retry.js.map +1 -1
- package/dist/sdk/client-stdio.d.ts +6 -0
- package/dist/sdk/client-stdio.d.ts.map +1 -0
- package/dist/sdk/client-stdio.js +6 -0
- package/dist/sdk/client-stdio.js.map +1 -0
- package/dist/sdk/client.d.ts +6 -0
- package/dist/sdk/client.d.ts.map +1 -0
- package/dist/sdk/client.js +6 -0
- package/dist/sdk/client.js.map +1 -0
- package/dist/sdk/express.d.ts +3 -0
- package/dist/sdk/express.d.ts.map +1 -0
- package/dist/sdk/express.js +11 -0
- package/dist/sdk/express.js.map +1 -0
- package/dist/sdk/node.d.ts +6 -0
- package/dist/sdk/node.d.ts.map +1 -0
- package/dist/sdk/node.js +6 -0
- package/dist/sdk/node.js.map +1 -0
- package/dist/sdk/server-stdio.d.ts +6 -0
- package/dist/sdk/server-stdio.d.ts.map +1 -0
- package/dist/sdk/server-stdio.js +6 -0
- package/dist/sdk/server-stdio.js.map +1 -0
- package/dist/sdk/server.d.ts +6 -0
- package/dist/sdk/server.d.ts.map +1 -0
- package/dist/sdk/server.js +6 -0
- package/dist/sdk/server.js.map +1 -0
- package/dist/server.d.ts +88 -35
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +98 -44
- package/dist/server.js.map +1 -1
- package/dist/task-request.d.ts +20 -0
- package/dist/task-request.d.ts.map +1 -0
- package/dist/task-request.js +43 -0
- package/dist/task-request.js.map +1 -0
- package/dist/task-store.d.ts +230 -0
- package/dist/task-store.d.ts.map +1 -0
- package/dist/task-store.js +459 -0
- package/dist/task-store.js.map +1 -0
- package/dist/task-tools.d.ts +115 -0
- package/dist/task-tools.d.ts.map +1 -0
- package/dist/task-tools.js +397 -0
- package/dist/task-tools.js.map +1 -0
- package/dist/testing/client.d.ts +25 -0
- package/dist/testing/client.d.ts.map +1 -0
- package/dist/testing/client.js +11 -0
- package/dist/testing/client.js.map +1 -0
- package/dist/testing/in-memory.d.ts +26 -0
- package/dist/testing/in-memory.d.ts.map +1 -0
- package/dist/testing/in-memory.js +41 -0
- package/dist/testing/in-memory.js.map +1 -0
- package/dist/testing/index.d.ts +20 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +20 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/testing/isolated-dirs.d.ts +27 -0
- package/dist/testing/isolated-dirs.d.ts.map +1 -0
- package/dist/testing/isolated-dirs.js +67 -0
- package/dist/testing/isolated-dirs.js.map +1 -0
- package/dist/testing/logs.d.ts +61 -0
- package/dist/testing/logs.d.ts.map +1 -0
- package/dist/testing/logs.js +113 -0
- package/dist/testing/logs.js.map +1 -0
- package/dist/testing/raw-http.d.ts +25 -0
- package/dist/testing/raw-http.d.ts.map +1 -0
- package/dist/testing/raw-http.js +41 -0
- package/dist/testing/raw-http.js.map +1 -0
- package/dist/testing/raw-peer.d.ts +43 -0
- package/dist/testing/raw-peer.d.ts.map +1 -0
- package/dist/testing/raw-peer.js +101 -0
- package/dist/testing/raw-peer.js.map +1 -0
- package/dist/testing/recording-server.d.ts +38 -0
- package/dist/testing/recording-server.d.ts.map +1 -0
- package/dist/testing/recording-server.js +38 -0
- package/dist/testing/recording-server.js.map +1 -0
- package/dist/testing/server-process.d.ts +76 -0
- package/dist/testing/server-process.d.ts.map +1 -0
- package/dist/testing/server-process.js +241 -0
- package/dist/testing/server-process.js.map +1 -0
- package/dist/testing/tools.d.ts +53 -0
- package/dist/testing/tools.d.ts.map +1 -0
- package/dist/testing/tools.js +98 -0
- package/dist/testing/tools.js.map +1 -0
- package/dist/testing/unit.d.ts +52 -0
- package/dist/testing/unit.d.ts.map +1 -0
- package/dist/testing/unit.js +95 -0
- package/dist/testing/unit.js.map +1 -0
- package/dist/tools.d.ts +205 -0
- package/dist/tools.d.ts.map +1 -0
- package/dist/tools.js +370 -0
- package/dist/tools.js.map +1 -0
- package/dist/transport.d.ts +143 -52
- package/dist/transport.d.ts.map +1 -1
- package/dist/transport.js +292 -123
- package/dist/transport.js.map +1 -1
- package/dist/url-path.d.ts +49 -0
- package/dist/url-path.d.ts.map +1 -0
- package/dist/url-path.js +165 -0
- package/dist/url-path.js.map +1 -0
- package/dist/utils/dataStore.d.ts +143 -0
- package/dist/utils/dataStore.d.ts.map +1 -0
- package/dist/utils/dataStore.js +429 -0
- package/dist/utils/dataStore.js.map +1 -0
- package/dist/utils/envLoader.d.ts +129 -0
- package/dist/utils/envLoader.d.ts.map +1 -0
- package/dist/utils/envLoader.js +399 -0
- package/dist/utils/envLoader.js.map +1 -0
- package/dist/utils/logger.d.ts +181 -0
- package/dist/utils/logger.d.ts.map +1 -0
- package/dist/utils/logger.js +664 -0
- package/dist/utils/logger.js.map +1 -0
- package/dist/utils/redact.d.ts +50 -0
- package/dist/utils/redact.d.ts.map +1 -0
- package/dist/utils/redact.js +318 -0
- package/dist/utils/redact.js.map +1 -0
- package/docs/api-reference.md +29 -0
- package/docs/building-a-server.md +63 -0
- package/docs/credentials.md +131 -0
- package/docs/data-store.md +37 -0
- package/docs/env.md +38 -0
- package/docs/features.md +28 -0
- package/docs/http-client.md +116 -0
- package/docs/logger.md +100 -0
- package/docs/migrating-to-v3.md +45 -0
- package/docs/outbound-urls.md +104 -0
- package/docs/pagination.md +45 -0
- package/docs/protocol-features.md +62 -0
- package/docs/resources-prompts-tasks.md +95 -0
- package/docs/sdk.md +33 -0
- package/docs/server.md +68 -0
- package/docs/testing-harness.md +139 -0
- package/docs/testing.md +151 -0
- package/docs/tools.md +88 -0
- package/docs/transport.md +85 -0
- package/examples/testing/tools.integration.test.example.ts +312 -0
- package/examples/testing/tools.unit.test.example.ts +149 -0
- package/package.json +130 -17
- package/dist/logger.d.ts +0 -80
- package/dist/logger.d.ts.map +0 -1
- package/dist/logger.js +0 -237
- package/dist/logger.js.map +0 -1
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# Credential health
|
|
2
|
+
|
|
3
|
+
A credential is checked once when the env is loaded, and that is enough. While the server runs a key
|
|
4
|
+
can degrade (quota, rate limit), become invalid or expire: that never crashes the server and never turns
|
|
5
|
+
a tool call into an error just because one key went bad, and no key is lost for good. Every tool result
|
|
6
|
+
that touched a credential tells the model which keys it used and which are degraded, invalid or expired.
|
|
7
|
+
Only declared key NAMES ever appear in logs, results and the status tool; never a value.
|
|
8
|
+
|
|
9
|
+
Statuses: `valid`, `invalid` (the credential was rejected), `expired`, `degraded` (quota or rate limit,
|
|
10
|
+
back to `unknown` once the retry-after elapses), `unknown` (no check, timeout, network error).
|
|
11
|
+
|
|
12
|
+
**Declare** a `check` on the env spec (each member of a `multi` pool is its own credential, named by its
|
|
13
|
+
key). A spec with a `check` is always treated as `secret: true` (masked in every log line and error, never
|
|
14
|
+
rendered), whether or not it says so. A check returns a status, `{ status, reason?, retryAfterMs? }`, or
|
|
15
|
+
throws (a thrown `HttpError` is classified like any HTTP outcome). `ctx.http(config)` builds a hardened
|
|
16
|
+
client with retries off and the check timeout. A multi-part credential (client id + secret + user +
|
|
17
|
+
password, key + secret) puts the `check` on one key and reads the others with `ctx.get('SIBLING_KEY')`:
|
|
18
|
+
declared keys only (an undeclared key throws, the check reads `unknown`), values never logged; declare
|
|
19
|
+
the siblings `secret: true` so they are masked too, and name the rejected part in the reason
|
|
20
|
+
(`{ status: 'invalid', reason: 'CONTABO_API_PASSWORD rejected' }`). List the siblings in the spec's
|
|
21
|
+
`covers: ['CONTABO_CLIENT_SECRET', 'CONTABO_API_USER', 'CONTABO_API_PASSWORD']` (declared keys; a base key
|
|
22
|
+
of a `multi` family covers its members) and the registry applies the check's status and reason, every
|
|
23
|
+
re-check and every runtime outcome recorded on the primary key to each covered part, so no part stays
|
|
24
|
+
`unknown` in `credential_status` or the report. Without `covers` only the key carrying the check changes.
|
|
25
|
+
|
|
26
|
+
```typescript
|
|
27
|
+
import type { EnvSchema } from '@novorb/mcp-core/env';
|
|
28
|
+
|
|
29
|
+
const ENV: EnvSchema = [
|
|
30
|
+
{
|
|
31
|
+
key: 'SERPER_DEV_API_KEY', secret: true, description: 'Serper.dev key',
|
|
32
|
+
check: async (value, { http, signal }) => {
|
|
33
|
+
await http({ baseURL: 'https://google.serper.dev', headers: { 'X-API-KEY': value } })
|
|
34
|
+
.post('/account', {}, { signal });
|
|
35
|
+
return 'valid'; // a 401 throws -> invalid, 402/429 -> degraded
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
key: 'VALUE_SERP_API_KEY', secret: true, multi: true, description: 'ValueSERP keys (_1, _2, ...)',
|
|
40
|
+
check: async (value, { http, signal }) => {
|
|
41
|
+
const acct = await http({ baseURL: 'https://api.valueserp.com' })
|
|
42
|
+
.get<{ account_info?: { credits_remaining?: number } }>('/account', { query: { api_key: value }, signal });
|
|
43
|
+
return (acct.account_info?.credits_remaining ?? 1) > 0 ? 'valid' : { status: 'degraded', reason: 'quota' };
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
];
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
**Startup** (`runMcpServer`): after the env load every server credential is registered (a server's
|
|
50
|
+
own `secret` keys and every key with a `check`; mcp-core's own secrets such as `MCP_API_KEY` are not) and
|
|
51
|
+
the checks run ONCE, at most 4 at a time (so a large pool does not trip its provider's rate limit), each
|
|
52
|
+
bounded by `MCP_CREDENTIAL_CHECK_TIMEOUT_MS` (default 5000; a timeout reads `unknown`).
|
|
53
|
+
`MCP_CREDENTIAL_CHECKS=false` skips them for offline or stateless starts. The server ALWAYS starts (even
|
|
54
|
+
when the registration itself fails: one WARN); one summary line is logged (`credential checks: KEY valid,
|
|
55
|
+
KEY_2 invalid (auth rejected)`), WARN when any key is not valid. The results live in the process
|
|
56
|
+
`CredentialRegistry` (`processCredentials()`, also `ctx.credentials` in `setup`/`create` and
|
|
57
|
+
`running.credentials`).
|
|
58
|
+
|
|
59
|
+
**Recovery:** an invalid or expired key is never dropped. It stays a last-resort candidate (tried when
|
|
60
|
+
nothing healthier is left, so a single-key server keeps trying its key and a success restores it), and
|
|
61
|
+
after `MCP_CREDENTIAL_RECHECK_MS` (default 300000) its startup `check` runs again in the background the
|
|
62
|
+
next time its pool is consulted; a pass makes it `valid`. A degraded key goes back to `unknown` once its
|
|
63
|
+
retry-after elapses.
|
|
64
|
+
|
|
65
|
+
**At call time** every handler gets `ctx.credentials` (a per-call scope over the registry):
|
|
66
|
+
|
|
67
|
+
```typescript
|
|
68
|
+
const search = defineTool({
|
|
69
|
+
name: 'search', description: 'Web search', annotations: TOOL_ANNOTATIONS.readOnlyOpenWorld,
|
|
70
|
+
inputSchema: z.object({ q: z.string() }),
|
|
71
|
+
outputSchema: z.object({ results: z.array(z.string()) }), // gets an optional `credentials` field
|
|
72
|
+
handler: async ({ q }, ctx) =>
|
|
73
|
+
// Tries the pool members in order (valid, unknown, degraded, then invalid/expired last); a credential
|
|
74
|
+
// failure (401, 402, 429, a credential-specific 403) is recorded and the next key is tried; every
|
|
75
|
+
// member failing -> an actionable isError with the report.
|
|
76
|
+
ctx.credentials.withFallback(['SERPER_DEV_API_KEY', ...env.members('VALUE_SERP_API_KEY')], (key, value) =>
|
|
77
|
+
key === 'SERPER_DEV_API_KEY'
|
|
78
|
+
? serper.post('/search', { q }, { credential: key, headers: { 'X-API-KEY': value ?? '' }, signal: ctx.signal })
|
|
79
|
+
: valueSerp.get('/search', { credential: key, query: { api_key: value ?? '', q }, signal: ctx.signal }),
|
|
80
|
+
).then((data) => toolResult({ results: extract(data) })),
|
|
81
|
+
});
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
- `pick(pool)` returns the best key NAME: valid first, then unknown, then degraded, then invalid and
|
|
85
|
+
expired as the last resort; it rotates among equally healthy keys. It is undefined only for a pool with
|
|
86
|
+
no member, and `require(pool)` throws `NoUsableCredentialError` in that case. `candidates(pool)` lists
|
|
87
|
+
the members in that order. `record(key, outcome, { reason?, retryAfterMs? })` reports what a use
|
|
88
|
+
showed: `ok` / `recovered` -> valid, `used` = no change, or `degraded` / `invalid` / `expired`.
|
|
89
|
+
`use(key)` marks a key used. A pool is a multi family's base key, a single key, or a list of keys.
|
|
90
|
+
- **Names only:** every identifier must be a declared env key or pool member. Anything else (most often a
|
|
91
|
+
value passed where the name belongs) throws `UnknownCredentialError` before it is recorded, reported
|
|
92
|
+
or sent; the message never contains the identifier.
|
|
93
|
+
- The http-client records automatically: pass `credential: '<KEY NAME>'` on the request. Mapping
|
|
94
|
+
(`credentialOutcomeFromHttp`, status-gated): 2xx ok. 401 -> invalid, or expired when the error code,
|
|
95
|
+
message, OAuth `error_description` or the `WWW-Authenticate` challenge's `error` / `error_description`
|
|
96
|
+
parameters name an expired credential (GitLab answers an expired token with `{"error":"invalid_token",
|
|
97
|
+
"error_description":"Token is expired. ..."}` and `WWW-Authenticate: Bearer error="invalid_token",
|
|
98
|
+
error_description="Token is expired. ..."`: that is `expired`; a revoked token stays `invalid`; the
|
|
99
|
+
realm and other challenge parameters are never read: the header is tokenised, so a quoted value cannot
|
|
100
|
+
inject one; the fields are joined with ` ; ` so no pattern spans two of them). A thrown `HttpError`
|
|
101
|
+
carries the same facts (`description`, `challenge: { error?, description? }`, masked, never the raw
|
|
102
|
+
header), so `credentialOutcomeFromError` (`withFallback`, a startup check that throws) reaches the same
|
|
103
|
+
verdict the client recorded. 402 -> degraded (quota). 429 -> degraded (rate limit, or quota). A 403 changes the
|
|
104
|
+
key only when its body or `WWW-Authenticate` says the credential itself expired, hit a quota or rate
|
|
105
|
+
limit, or was rejected (`bad credentials`, `invalid api key`, `revoked`, `invalid_token`); a plain 403
|
|
106
|
+
is about access to one resource and leaves the key alone. Every other status (400, 404, 413, 422, 5xx)
|
|
107
|
+
and network errors leave the status alone, whatever the body says. Degraded outcomes carry the
|
|
108
|
+
`Retry-After`. Inside a tool call it records into the call's scope (so the result reports it); outside
|
|
109
|
+
one into `HttpClientConfig.credentials` or the process registry. A shared in-flight request (a dedup
|
|
110
|
+
cache or a coalesced call) reports its key on the call that started it only.
|
|
111
|
+
- Transitions are logged once per change: `Credential VALUE_SERP_API_KEY_2: valid -> degraded (quota)`.
|
|
112
|
+
- **The report**, appended to every result that touched a credential (successes and isErrors alike):
|
|
113
|
+
a text block `Credentials: SERPER_DEV_API_KEY used; VALUE_SERP_API_KEY_2 degraded (quota, retry in
|
|
114
|
+
3600s); SERPAPI_API_KEY invalid`, the same entries (`{ key, status, used, reason?, retryInSeconds? }`)
|
|
115
|
+
under `_meta["io.novorb/credentials"]` (`CREDENTIALS_META_KEY`), and as `structuredContent.credentials`
|
|
116
|
+
when the listed outputSchema declares that field. registerTools adds it (`withCredentialsField`) to an
|
|
117
|
+
object-root schema with `properties`; a schema that declares its own `credentials`, a record root or an
|
|
118
|
+
`allOf`/`$ref` root is left unchanged and gets the text and `_meta` report only. Every copy passes the
|
|
119
|
+
client masking. The report's bytes count against `MCP_MAX_RESULT_BYTES`. An input-required result
|
|
120
|
+
keeps the report in `_meta`. Reported: the keys the call used plus the considered pool members that
|
|
121
|
+
are not healthy.
|
|
122
|
+
- A call where every member was tried and failed on its credential answers `<server> cannot serve
|
|
123
|
+
<tool>: No usable credential for <POOL>: every candidate failed (...) (KEYS). Replace or renew the
|
|
124
|
+
key(s), or wait for a degraded one to recover.` as an isError with the report. A call that succeeds
|
|
125
|
+
with a fallback key is a success with the report.
|
|
126
|
+
- **Status tool:** `credentialStatusTool({ name?, registry? })` is a ready read-only tool
|
|
127
|
+
(`credential_status`) listing every credential's status (names, pools, statuses, reasons, retry-after);
|
|
128
|
+
integration tests read the startup results through it.
|
|
129
|
+
- A custom registry: `runMcpServer({ credentials })`, then `registerTools(server, tools, { credentials:
|
|
130
|
+
ctx.credentials })` and `credentialStatusTool({ registry: ctx.credentials })`. A unit test that builds
|
|
131
|
+
its own `CredentialRegistry` must `register(...)` the names it uses.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# Data store
|
|
2
|
+
|
|
3
|
+
```typescript
|
|
4
|
+
const store = createDataStore({ rootDir, env, logger });
|
|
5
|
+
await store.writeFile(`downloads/${safeName(remoteName)}`, bytes); // atomic, via resolveInside
|
|
6
|
+
const cache = store.jsonCache<Discovery>({ name: 'discovery', ttlMs: 3_600_000 });
|
|
7
|
+
const dl = await store.downloadToFile(url, 'files/report.pdf', { maxBytes: 10_000_000, timeoutMs: 30_000, signal: ctx.signal });
|
|
8
|
+
const refusal = store.requireEnabled('the SQLite cache'); // a toolError when DATA_STORAGE=false
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
- Directory: `DATA_PATH` (absolute, or relative to rootDir), default `<rootDir>/.data`, created lazily.
|
|
12
|
+
`DATA_STORAGE=false` runs stateless: no directory is ever created, writes throw
|
|
13
|
+
`DataStorageDisabledError`, `readFile` is null, `jsonCache` keeps values in memory for the process, and
|
|
14
|
+
`downloadToFile` returns `{ path: null, data }` so a tool can return the content inline. An invalid
|
|
15
|
+
`DATA_STORAGE` value (not a boolean) also disables writes, with one WARN. The logger's file sinks are
|
|
16
|
+
separate: for a fully stateless host also set `LOG_FILE_MAIN=false`, `LOG_FILE_EXCEPTIONS=false` and
|
|
17
|
+
`LOG_FILE_DEBUG=false` (or `LOG_PATH` to a writable volume).
|
|
18
|
+
- Writes are atomic (a temp file, then a rename). On Windows a rename onto a file another handle has open
|
|
19
|
+
(a reader polling it, AV, the indexer) fails with EPERM/EACCES/EBUSY; `renameWithRetry` retries those
|
|
20
|
+
with a short growing backoff for up to about 2 s before rethrowing (other platforms and errors fail at
|
|
21
|
+
once).
|
|
22
|
+
- `resolveInside` refuses `..`, absolute segments, drive letters (`C:\x`, `C:x`), UNC and root paths, NUL,
|
|
23
|
+
and symlink/junction escapes (`DataPathError`). Pass untrusted names (tool arguments, remote file names)
|
|
24
|
+
through `safeName` first.
|
|
25
|
+
- `downloadToFile(source, relPath, { maxBytes = 50 MiB, timeoutMs = 60 s, signal, maxRedirects = 3, headers,
|
|
26
|
+
allowPrivate = false, allowedOrigins, allowedContentTypes })`: `source` is a URL or any client as
|
|
27
|
+
`({ signal }) => Promise<Response>`. A URL source goes through `guardedFetch` (see
|
|
28
|
+
[Outbound URLs](outbound-urls.md)): http/https only, no userinfo, public addresses only unless `allowPrivate: true` (checked in the
|
|
29
|
+
socket lookup, own agent per request), every redirect hop re-validated and the count bounded, caller
|
|
30
|
+
headers dropped on a cross-origin redirect. A function source is the caller's own client and is not
|
|
31
|
+
guarded (the caller owns its URL). Size is checked on `Content-Length` and while streaming, and the body
|
|
32
|
+
read is bounded by the timeout and signal even when a fetcher ignores them. The path is validated in
|
|
33
|
+
both modes. Failures are `DownloadError` with `reason` `status | too-large | timeout | redirects |
|
|
34
|
+
protocol | aborted | network | blocked | content-type` (`blocked`: the guard refused the URL or a hop).
|
|
35
|
+
A server that must download from a private host (a self-hosted upstream on the LAN) passes
|
|
36
|
+
`allowPrivate: true` explicitly, ideally together with `allowedOrigins`.
|
|
37
|
+
- Consumers gitignore `.data/` and `.logs/`.
|
package/docs/env.md
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# Env
|
|
2
|
+
|
|
3
|
+
```typescript
|
|
4
|
+
import { loadEnv, renderEnvExample, renderEnvTable } from '@novorb/mcp-core/env';
|
|
5
|
+
|
|
6
|
+
const env = loadEnv(ENV, { rootDir }); // runMcpServer does this for you
|
|
7
|
+
env.get('ACME_URL'); env.int('ACME_PAGE_SIZE'); env.has('ACME_TOKEN'); env.isConfigured(); env.missing;
|
|
8
|
+
env.getAll('SERPER_API_KEY'); // multi: KEY, KEY_1, KEY_2, ..., then KEY_<name> A-Z
|
|
9
|
+
env.describe(); // names, set/unset, source; never values
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
- Precedence: real process env > `<rootDir>/.env` > the spec `value`. `MCP_ENV_FILE` (from the real env)
|
|
13
|
+
points at another file (absolute or relative to rootDir); `MCP_ENV_FILE=none` disables the file (use it
|
|
14
|
+
in integration tests: the harness `envFile: 'none'`). Parsed with `util.parseEnv`; no dotenv.
|
|
15
|
+
- Keys resolved from the file or a default are written into `process.env` (never over a set variable), so
|
|
16
|
+
legacy `process.env.X` reads keep working. File keys no schema declares are not loaded and produce one
|
|
17
|
+
WARN listing their names (never values).
|
|
18
|
+
- `CORE_ENV_SCHEMA` (merged automatically): `MCP_TRANSPORT`, `PORT`, `MCP_HTTP_HOST`, `MCP_API_KEY`
|
|
19
|
+
(secret), `MCP_AUTH_DISABLED`, `MCP_MAX_BODY_BYTES`, `MCP_ALLOWED_HOSTS`, `MCP_ALLOWED_ORIGINS`,
|
|
20
|
+
`MCP_ENV_FILE`, the `LOG_*` switches, `LOG_PROTOCOL`, `DATA_PATH`, `DATA_STORAGE`, `MCP_LIST_PAGE_SIZE`,
|
|
21
|
+
`MCP_MAX_RESULT_ITEMS`, `MCP_MAX_RESULT_BYTES`, `MCP_MAX_TOOL_INPUT_ELEMENTS`, `MCP_REQUEST_STATE_KEY`
|
|
22
|
+
(secret), `MCP_REQUEST_STATE_PREVIOUS_KEY` (secret, multi), `MCP_REQUEST_STATE_TTL_SECONDS`,
|
|
23
|
+
`MCP_CREDENTIAL_CHECKS` (default true), `MCP_CREDENTIAL_CHECK_TIMEOUT_MS` (default 5000),
|
|
24
|
+
`MCP_CREDENTIAL_RECHECK_MS` (default 300000).
|
|
25
|
+
- `env.members(KEY)` returns the NAMES of the set members of a key (a multi family in `getAll` order).
|
|
26
|
+
- A spec may carry a `check` (an async credential probe, see [Credential health](credentials.md)); a spec with a `check`
|
|
27
|
+
is a secret (`isSecret(spec)`) whether or not it sets `secret: true`.
|
|
28
|
+
- An env variable set to an empty string counts as unset (the file or the default then applies and is
|
|
29
|
+
written into `process.env`).
|
|
30
|
+
- Validation (`int`, `bool`, `url`, `enum`) errors are collected in `env.errors` by key name (no values);
|
|
31
|
+
`runMcpServer` logs each as a WARN. `strict: true` throws instead.
|
|
32
|
+
- Every set secret value (`secret: true`, or any spec with a `check`) is passed to the logger's `registerSecretValues`, which masks it as
|
|
33
|
+
`[SECRET]` wherever it appears in any log entry and in client-bound error text (raw and JSON-escaped).
|
|
34
|
+
A secret shorter than 6 characters cannot be masked; the loader logs one WARN naming the key.
|
|
35
|
+
- Generated docs: `renderEnvTable(ENV)` returns the README table (key, type, required, multi, secret,
|
|
36
|
+
default, description; never a secret default) and `renderEnvExample(ENV)` the `.env.example` text
|
|
37
|
+
(secrets always empty). Pass `{ includeCore: true }` to include mcp-core's keys. A test can assert the
|
|
38
|
+
committed `.env.example` equals `renderEnvExample(ENV)`.
|
package/docs/features.md
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Features
|
|
2
|
+
|
|
3
|
+
The full feature list. Each area has its own page, linked from the [README](../README.md).
|
|
4
|
+
|
|
5
|
+
- **Structured logging**: stderr console plus main/exceptions/debug log files in the server's own `.logs`, rotation, text or JSON, secret redaction, PII masking, shape-only tool-call logging, opt-in crash capture
|
|
6
|
+
- **HTTP client**: one hardened client for every upstream: auth strategies (bearer, basic, custom header, query key, refreshing token provider), per-request options (signal, timeout, responseType incl. streams, size cap, validateStatus, retry), idempotent-only retries honouring `Retry-After`, a token-bucket rate limiter, TLS options, a `mapError` hook, and route-shape-only logging
|
|
7
|
+
- **Retry with backoff**: one shared retry policy, full-jitter exponential backoff, `Retry-After` (seconds or date) with a cap, bounded and abort-aware
|
|
8
|
+
- **Pagination**: upstream page walkers (page/per_page, `X-Next-Page`, offset/limit, `Link: rel="next"`, OData `@odata.nextLink`, continuation tokens) with stop conditions and hard caps; opaque MCP cursors and one paged result contract for list tools
|
|
9
|
+
- **The SDK through mcp-core**: the curated `./server` entry plus full re-exports of every SDK package (`./sdk/server`, `./sdk/node`, `./sdk/client`, `./sdk/express`, ...), so consumers never depend on `@modelcontextprotocol/*`
|
|
10
|
+
- **Unified transport**: stdio and stateless streamable-HTTP with the SDK's Bearer gate (static key or a pluggable verifier, `AuthInfo` for scope challenges), DNS-rebinding protection, and progress/log streaming (`responseMode: 'auto'`)
|
|
11
|
+
- **Express adapter** (`./express`): the same handler and guards on an existing Express app
|
|
12
|
+
- **Server builder**: `McpServer` factory taking the full `Implementation` and `McpServerOptions` (instructions, cache hints, input caps), annotation presets, list pagination on by default
|
|
13
|
+
- **Response limits**: list-shaped tool results paged with stateless, tamper-checked cursors; a byte cap per result answered with an actionable error; bounded upstream collectors
|
|
14
|
+
- **Protocol features**: opt-in MCP protocol logging, era-blind elicitation / sampling / roots helpers over the SDK's multi round-trip flow, progress and cancellation
|
|
15
|
+
- **Resources and prompts** (`registerResources`, `registerPrompts`): definitions onto the SDK's `registerResource` / `registerPrompt` with the same logging, error masking and degraded mode as tools, a byte cap per read, URI templates with completion, resource links, and a result spill that turns an oversized tool result into a resource link
|
|
16
|
+
- **Change notifications** (`createNotifier`, `createRuntimeSurface`): one process-wide event bus behind every transport, so a list-changed or resource-updated event reaches every connection, and runtime enable/disable of tools, prompts and resources
|
|
17
|
+
- **Tasks** (`registerTaskTools`, `createTaskStore`): long-running tools on MCP Tasks for both protocol eras, with an in-memory or file-backed store, per-caller isolation, cancellation and mid-task input
|
|
18
|
+
- **Sealed requestState** (`requestStateCodecFromEnv`): multi-step flows with AES-256-GCM sealed, expiring, caller-bound state and key rotation
|
|
19
|
+
- **Bootstrap** (`runMcpServer`): one entry point shape for every server: logger, env, shared state created once per process, transport, one "started" line after bind, owned signal handling, name and version from `package.json`
|
|
20
|
+
- **Tool registration** (`registerTools`): tool definitions onto `McpServer.registerTool` with central call/result logging, safe error mapping, legacy `{ success: false }` envelopes as `isError`, uniform degraded mode for unconfigured credentials, and an `enabled` filter
|
|
21
|
+
- **Credential health** (`@novorb/mcp-core/credentials`): every credential checked once at startup (parallel, bounded, never fatal), key pools rotated away from invalid/expired keys, outcomes learned from use (the http-client records them), and a report of the keys a call used and the ones that are degraded, invalid or expired on every tool result that touched one
|
|
22
|
+
- **Env loader** (`loadEnv`): every server declares its env schema; one loader (no dotenv) with process env > `.env` > defaults, multi-key families, typed validation by key name, secrets masked verbatim in logs, and generated README tables / `.env.example`
|
|
23
|
+
- **Data store** (`createDataStore`): the one way to write to disk: `DATA_PATH` / `DATA_STORAGE=false` (stateless), traversal-safe paths, atomic writes, TTL JSON caches, bounded downloads (URL sources SSRF-guarded by default)
|
|
24
|
+
- **Guarded outbound fetch** (`@novorb/mcp-core/net`): `guardedFetch` for any caller- or upstream-supplied URL: http(s) only, no userinfo, optional origin allow-list, public addresses only (checked inside the socket lookup on every resolved address), its own agent per request so an env proxy can never bypass it, redirects refused or capped and re-validated per hop, size, time and content-type caps
|
|
25
|
+
- **Upstream path segments** (`@novorb/mcp-core/url-path`): `pathSegment` / `buildPath` / `joinPath` encode a caller id or name as exactly one URL path segment and refuse `.`, `..` (also percent-encoded), empty and control characters with an actionable isError naming the argument
|
|
26
|
+
- **Testing harness** (`@novorb/mcp-core/testing`): unit helpers (in-memory client, recording server, outcome assertions) and an integration harness (start the built server over stdio or HTTP with an explicit env and temp dirs, log-file and leak checks, raw HTTP adverse checks), plus two example suites to copy
|
|
27
|
+
- **Cross-platform jest runner** (`mcp-core-jest` bin): runs the consumer's own jest with `--experimental-vm-modules` on Windows, macOS and Linux alike
|
|
28
|
+
- **ESM native** — built as ES modules with full TypeScript declarations
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# HTTP client and retry
|
|
2
|
+
|
|
3
|
+
## HTTP Client
|
|
4
|
+
|
|
5
|
+
One hardened client per upstream. Build it once per process (module scope or the server's setup), not inside the per-request server factory, so the rate limiter and the connection pool are shared.
|
|
6
|
+
|
|
7
|
+
```typescript
|
|
8
|
+
import { createHttpClient } from '@novorb/mcp-core/http-client';
|
|
9
|
+
|
|
10
|
+
const client = createHttpClient({
|
|
11
|
+
baseURL: 'https://api.example.com/v1', // relative paths are joined onto this exactly once
|
|
12
|
+
auth: { type: 'bearer', token: process.env.API_TOKEN ?? '' },
|
|
13
|
+
timeout: 30_000, // default; per request: { timeout }
|
|
14
|
+
retry: { retries: 3, maxRetryAfterMs: 30_000 },
|
|
15
|
+
mapError: (e) => ({ message: `Example API error (${e.status ?? e.code}): ${e.upstreamMessage ?? 'request failed'}` }),
|
|
16
|
+
logContext: 'ExampleClient',
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const item = await client.get<Item>('/items/42', { query: { expand: 'owner' }, signal: ctx.signal });
|
|
20
|
+
await client.post('/items', { name: 'new' }, { idempotencyKey: crypto.randomUUID() }); // replayable POST
|
|
21
|
+
const raw = await client.requestRaw({ path: '/items', validateStatus: () => true }); // { status, headers, data }
|
|
22
|
+
const headers = await client.head('/items');
|
|
23
|
+
const file = await client.get<Readable>('/files/1', { responseType: 'stream', maxResponseBytes: 50 * 1024 * 1024 });
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
- **Request options** (every verb, plus `request`/`requestRaw`): `query` (null/undefined skipped, arrays per `arrayFormat`: `repeat` default, `brackets`, `comma`), `headers`, `timeout`, `signal` (cancels the request and any retry wait), `responseType` (`json` | `text` | `arraybuffer` | `stream`), `maxResponseBytes`, `validateStatus`, `retry` (overrides, or `false`), `idempotencyKey` (sent as `Idempotency-Key`, configurable via `idempotencyKeyHeader`), `logPath` (the route template to log, e.g. `/repos/:owner/:repo`), `credential` (the key NAME this request authenticates with; its outcome is recorded, see "Credential health").
|
|
27
|
+
- **Auth** (`auth`): `bearer`, `basic`, `header` (`PRIVATE-TOKEN`, `X-Api-Key`, `sso-key ...`), `query` (Trello `key`/`token`), `tokenProvider` (`getToken({ forceRefresh, signal })`, called per request; a 401 triggers one forced refresh and one replay). Every configured secret value, and every token a provider returned, is masked as `[REDACTED]` in error messages.
|
|
28
|
+
- **URLs:** `joinUrl` puts exactly one `/` between base and path and touches nothing else. An absolute URL (an OData next-link that already contains `/v1.0`) is used as-is, but only toward the base URL's origin or an `allowedOrigins` entry; anything else throws `ERR_ORIGIN_NOT_ALLOWED` before sending, so credentials never leave.
|
|
29
|
+
- **Retry:** only GET/HEAD/OPTIONS/PUT/DELETE, unless the request has an `idempotencyKey` or `retry: { unsafe: true }`. Statuses 408, 425, 429, 500, 502, 503, 504 and network errors ECONNRESET, ETIMEDOUT (incl. timeouts), EAI_AGAIN, ECONNREFUSED on connect. `Retry-After` (seconds or date) is honoured up to `maxRetryAfterMs`; otherwise full-jitter backoff. `onRetry` observes each wait.
|
|
30
|
+
- **TLS:** `tls: { ca }` for a private CA, `tls: { rejectUnauthorized: false }` for a self-signed test instance (an ESM `node:https` Agent).
|
|
31
|
+
- **Rate limit:** `rateLimit: { capacity, refillPerSecond }` (or a shared `TokenBucket`) gates every attempt.
|
|
32
|
+
- **Errors:** every failure is an `HttpError` with only `message`, `status`, `statusText`, `code`, `method`, `path`, `retryAfterMs`. The default message is the upstream message (GitLab `message`, Graph `error.message`, Jira `errorMessages`, `errors`, plain text; HTML ignored; 300 chars max) or `HTTP <status> <text>` / `Network error (<code>)`. `mapError(info)` receives `{ method, host, path, status, statusText, code, upstreamMessage, upstreamCode, retryAfterMs }` and returns `{ message, code }`.
|
|
33
|
+
- **Security:** `maxRedirects: 0`; response (streams included) and request size caps (25 MiB default, `ERR_RESPONSE_TOO_LARGE` / `ERR_REQUEST_TOO_LARGE`); errors never carry the raw response, config or headers; a broken stream surfaces a sanitized `HttpError`.
|
|
34
|
+
- **Logging** (mcp-core logger only): `HTTP <METHOD> <host><path template>` with `{ method, host, path, status, durationMs, attempt }`; DEBUG on success, WARN on retry and 4xx, ERROR on 5xx and network failures. The path is a route TEMPLATE (`templatePath`), also in `HttpError.path`, and fails closed: without a template every request-path segment becomes `:id` except a leading API prefix or version (`api`, `rest`, `v4`); the base URL path keeps its API words and versions (`/api/v4`) and masks any other segment (a token in the base URL). So `GET /api/v4/projects/7/repository/tags/stable` logs `/api/v4/:id/:id/:id/:id/:id`. For readable lines declare the client's routes (`routes: ['/projects/:id/repository/tags/:tag']`, matched segment for segment, `:name` = any one segment) or pass a per-request `logPath`; their literal words are logged. Names, keys, tags and file names never reach a log line unless a route template spells them out. Query values, headers and bodies are never logged.
|
|
35
|
+
|
|
36
|
+
**Adoption per server:**
|
|
37
|
+
|
|
38
|
+
```typescript
|
|
39
|
+
// GitLab: PRIVATE-TOKEN header, X-Next-Page walking, service wording.
|
|
40
|
+
const gitlab = createHttpClient({
|
|
41
|
+
baseURL: `${process.env.GITLAB_URL}/api/v4`,
|
|
42
|
+
auth: { type: 'header', name: 'PRIVATE-TOKEN', value: process.env.GITLAB_TOKEN ?? '' },
|
|
43
|
+
mapError: (e) => ({ message: `GitLab API error (${e.status ?? e.code}): ${e.upstreamMessage ?? 'request failed'}` }),
|
|
44
|
+
});
|
|
45
|
+
const { items, total } = await gitlab
|
|
46
|
+
.paginate<Project>({ path: '/projects', query: { search: 'core' } }, nextPageHeader({ pageSize: 100 }))
|
|
47
|
+
.collect();
|
|
48
|
+
|
|
49
|
+
// Microsoft Graph: refreshing token, absolute @odata.nextLink followed as-is (no /v1.0/v1.0).
|
|
50
|
+
const graph = createHttpClient({
|
|
51
|
+
baseURL: 'https://graph.microsoft.com/v1.0',
|
|
52
|
+
auth: { type: 'tokenProvider', getToken: ({ forceRefresh }) => tokens.get(forceRefresh) },
|
|
53
|
+
});
|
|
54
|
+
for await (const site of graph.paginate<Site>({ path: '/sites', query: { search: '*' } }, nextLinkField()).items()) {
|
|
55
|
+
// ...
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Trello: key/token query auth (masked), 100 requests per 10 s per token, `before` cursor.
|
|
59
|
+
const trello = createHttpClient({
|
|
60
|
+
baseURL: 'https://api.trello.com/1',
|
|
61
|
+
auth: { type: 'query', params: { key: process.env.TRELLO_API_KEY ?? '', token: process.env.TRELLO_TOKEN ?? '' } },
|
|
62
|
+
rateLimit: { capacity: 100, refillPerSecond: 10 },
|
|
63
|
+
});
|
|
64
|
+
const actions = await trello
|
|
65
|
+
.paginate<Action>(
|
|
66
|
+
{ path: `/boards/${boardId}/actions`, query: { limit: 50 } },
|
|
67
|
+
continuationToken({ requestParam: 'before', items: (b) => (Array.isArray(b) ? b : []),
|
|
68
|
+
token: (_b, page) => (page.length === 50 ? page[page.length - 1].id : undefined) }),
|
|
69
|
+
)
|
|
70
|
+
.collect();
|
|
71
|
+
|
|
72
|
+
// Nexus: basic auth, self-signed test instance, continuationToken, raw status handling.
|
|
73
|
+
const nexus = createHttpClient({
|
|
74
|
+
baseURL: `${process.env.NEXUS_URL}/service/rest`,
|
|
75
|
+
auth: { type: 'basic', username: process.env.NEXUS_USERNAME ?? '', password: process.env.NEXUS_PASSWORD ?? '' },
|
|
76
|
+
tls: process.env.NEXUS_VALIDATE_SSL === 'false' ? { rejectUnauthorized: false } : undefined,
|
|
77
|
+
});
|
|
78
|
+
const components = await nexus
|
|
79
|
+
.paginate<Component>(
|
|
80
|
+
{ path: '/v1/components', query: { repository: 'maven-releases' } },
|
|
81
|
+
continuationToken(),
|
|
82
|
+
{ maxItems: 500 },
|
|
83
|
+
)
|
|
84
|
+
.collect();
|
|
85
|
+
const res = await nexus.requestRaw({ path: '/v1/status', validateStatus: () => true });
|
|
86
|
+
|
|
87
|
+
// Dependency-Track: X-Api-Key header, pageNumber/pageSize + X-Total-Count.
|
|
88
|
+
const dt = createHttpClient({
|
|
89
|
+
baseURL: `${process.env.DEPTRACK_BASE_URL}/api`,
|
|
90
|
+
auth: { type: 'header', name: 'X-Api-Key', value: process.env.DEPTRACK_API_KEY ?? '' },
|
|
91
|
+
});
|
|
92
|
+
const projects = await dt
|
|
93
|
+
.paginate<DtProject>({ path: '/v1/project' },
|
|
94
|
+
pagePerPage({ pageParam: 'pageNumber', sizeParam: 'pageSize', pageSize: 100, totalHeader: 'X-Total-Count' }))
|
|
95
|
+
.collect();
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## Retry
|
|
99
|
+
|
|
100
|
+
The one retry policy, shared by the HTTP client and `retryWithBackoff`: statuses 408, 425, 429, 500, 502, 503, 504 (never 501, never another 4xx) and network errors ECONNRESET, ETIMEDOUT, EAI_AGAIN, ECONNREFUSED on connect (the code and syscall are read from the error or its `cause`). A loop, never recursion; `Retry-After` honoured with a cap; otherwise full-jitter exponential backoff.
|
|
101
|
+
|
|
102
|
+
```typescript
|
|
103
|
+
import { retryWithBackoff } from '@novorb/mcp-core/retry';
|
|
104
|
+
|
|
105
|
+
const result = await retryWithBackoff(async (ctx) => someUnreliableOperation(ctx.attempt), {
|
|
106
|
+
maxRetries: 3, // attempts = 1 + maxRetries
|
|
107
|
+
initialDelayMs: 1000, // backoff ceiling = min(maxDelayMs, initial * multiplier^attempt), then a uniform draw
|
|
108
|
+
maxDelayMs: 10_000,
|
|
109
|
+
maxRetryAfterMs: 30_000, // cap on a Retry-After read from error.retryAfterMs or error.response.headers
|
|
110
|
+
signal: ctx.signal, // stop retrying (reject with the abort reason) once aborted
|
|
111
|
+
shouldRetry: (err) => isRetryableError(err), // the default
|
|
112
|
+
onRetry: ({ attempt, delayMs, fromRetryAfter }) => {},
|
|
113
|
+
});
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
`isIdempotentMethod(method)` is exported for callers that wrap their own transport: never replay a POST/PATCH without an idempotency key.
|
package/docs/logger.md
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# Logger
|
|
2
|
+
|
|
3
|
+
One logging utility for every MCP server: a console sink on stderr and three log files in the server's own repo, with rotation, text or JSON lines, secret redaction and PII masking on every entry. stdout is never written (it is the stdio protocol channel). The http-client and transport log through it too.
|
|
4
|
+
|
|
5
|
+
```typescript
|
|
6
|
+
import { initLogger, packageRootFrom } from '@novorb/mcp-core/logger';
|
|
7
|
+
|
|
8
|
+
// Once, at startup. rootDir = this server's package root (from src/ or dist/ alike).
|
|
9
|
+
const logger = initLogger({
|
|
10
|
+
name: 'my-mcp',
|
|
11
|
+
rootDir: packageRootFrom(import.meta.url),
|
|
12
|
+
captureProcessErrors: true, // optional: log crashes to exceptions.log, then exit(1)
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
logger.info('Server started', 'Server');
|
|
16
|
+
logger.error('Request failed', 'HttpClient', { status: 500, path: '/api/items' });
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
**Sinks and routing.** The console writes to `process.stderr`. The files live in the log directory:
|
|
20
|
+
|
|
21
|
+
| File | Receives |
|
|
22
|
+
|------|----------|
|
|
23
|
+
| `<name>.log` | every entry at or above the level (the main log) |
|
|
24
|
+
| `exceptions.log` | ERROR entries only |
|
|
25
|
+
| `debug.log` | DEBUG entries only (so only when the level is DEBUG) |
|
|
26
|
+
|
|
27
|
+
INFO and WARN go to the main log; ERROR to main and exceptions; DEBUG to main and debug. Each file rotates on its own (10 MB, 5 generations) and a failing file never affects the other sinks.
|
|
28
|
+
|
|
29
|
+
**Log directory.** `<rootDir>/.logs` by default. `LOG_PATH` in the server's `.env` (or environment) overrides it, absolute or relative to `rootDir`. Without `rootDir` and without an absolute `LOG_PATH` the file sinks stay off and `initLogger` prints one console warning; nothing is ever written relative to the current working directory. `packageRootFrom(import.meta.url)` returns the nearest ancestor holding a `package.json`, using `fileURLToPath` so Windows paths are correct.
|
|
30
|
+
|
|
31
|
+
**Switches.** An explicit `initLogger` option beats the environment, which beats the default:
|
|
32
|
+
|
|
33
|
+
| Env | Option | Default | Meaning |
|
|
34
|
+
|-----|--------|---------|---------|
|
|
35
|
+
| `LOG_LEVEL` | `level` | `INFO` | minimum level: `DEBUG`, `INFO`, `WARN`, `ERROR` |
|
|
36
|
+
| `LOG_FORMAT` | `format` | `text` | `text` or `json` |
|
|
37
|
+
| `LOG_CONSOLE` | `console` | `true` | stderr sink |
|
|
38
|
+
| `LOG_FILE_MAIN` | `fileMain` | `true` | `<name>.log` (needs a log directory) |
|
|
39
|
+
| `LOG_FILE_EXCEPTIONS` | `fileExceptions` | `true` | `exceptions.log` (needs a log directory) |
|
|
40
|
+
| `LOG_FILE_DEBUG` | `fileDebug` | `true` | `debug.log` (needs a log directory) |
|
|
41
|
+
| `LOG_PATH` | `logDir` | `<rootDir>/.logs` | log directory |
|
|
42
|
+
| `LOG_FILE` | `logFile` | `<name>.log` | main file name; an absolute path is used as-is |
|
|
43
|
+
|
|
44
|
+
Booleans accept `true`/`false`, `1`/`0`, `yes`/`no`, `on`/`off`; anything else is ignored. Other options: `name` (sanitised to `[A-Za-z0-9._-]`; `initLogger` throws if the main file is on, a directory exists and there is neither `name` nor `LOG_FILE`), `rootDir`, `maxLogSize`, `maxLogFiles`, `captureProcessErrors`. `logger.sinks()` returns the resolved console flag and file paths.
|
|
45
|
+
|
|
46
|
+
**Line format.**
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
2026-10-23T17:50:10.764Z | INFO | Server | Server started
|
|
50
|
+
2026-10-23T17:50:10.764Z | ERROR | HttpClient | Request failed | {"status":500,"path":"/api/items"}
|
|
51
|
+
{"level":"ERROR","timestamp":"2026-10-23T17:50:10.764Z","component":"HttpClient","message":"Request failed","status":500,"path":"/api/items"}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
**Process errors.** With `captureProcessErrors: true`, uncaught exceptions and unhandled rejections are logged at ERROR with context `Process` (`Uncaught exception: <Name>: <message>` or `Unhandled rejection: <reason>`, meta `{"stack":...}`) to the main log and `exceptions.log`, then the process exits with code 1. The handler is re-entrancy guarded, and an EPIPE (a crash caused by a dead stderr pipe, a synchronous EPIPE from `stderr.write`, or an EPIPE `error` event on stderr) switches the console sink off, so it is never logged back to the broken pipe in a loop.
|
|
55
|
+
|
|
56
|
+
**Reconfigure in place.** `initLogger(options)` reconfigures the existing singleton without replacing it, recomputing every setting from defaults, env and `options`. A logger obtained with `getLogger()` at module load (before init) therefore writes to the configured sinks after init. Calling `initLogger` again recomputes from scratch; `resetLogger()` drops the singleton, its process handlers and the console state (tests only).
|
|
57
|
+
|
|
58
|
+
**Tool-call helpers.** Never log tool inputs or outputs. Log tool calls through these instead (CI variable values, base64 payloads and file contents are data that key-based redaction cannot recognise):
|
|
59
|
+
|
|
60
|
+
```typescript
|
|
61
|
+
import { logToolCall, logToolResult } from '@novorb/mcp-core/logger';
|
|
62
|
+
|
|
63
|
+
const started = Date.now();
|
|
64
|
+
logToolCall('create_variable', args);
|
|
65
|
+
// ... run the tool ...
|
|
66
|
+
logToolResult('create_variable', { isError: false, durationMs: Date.now() - started });
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Both go through the singleton and default to context `Tools` (pass a third argument to override). The text lines are stable and pinned by tests:
|
|
70
|
+
|
|
71
|
+
```
|
|
72
|
+
<ISO> | INFO | Tools | Tool call: create_variable | {"tool":"create_variable","args":{"key":"[REDACTED]","value":"string(6)"}}
|
|
73
|
+
<ISO> | INFO | Tools | Tool result: create_variable | {"tool":"create_variable","isError":false,"durationMs":42}
|
|
74
|
+
<ISO> | WARN | Tools | Tool result: create_variable | {"tool":"create_variable","isError":true,"durationMs":7}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
`durationMs` is rounded to an integer. `summarizeArgs(args)` produces the shape-only `args` summary and never contains a value: per top-level key `string(<len>)`, `array(<len>)`, `object{<key names, one level, max 20 then ,...+N>}`, `number`, `boolean`, `null`, `undefined`, `bigint` or `function`. Non-object args are summarised under `_args`; `null`/`undefined` give `{}`. The summary still passes through redaction, so a sensitive key name such as `key` shows `[REDACTED]` instead of its shape.
|
|
78
|
+
|
|
79
|
+
**Redaction.** Before serialising, `meta` is copied with these fields replaced by `[REDACTED]` (case-insensitive):
|
|
80
|
+
|
|
81
|
+
- Keys are matched by name segment (camelCase, snake_case, kebab-case). A key is redacted when it names a secret (`password`, `passwd`, `pwd`, `passphrase`, `secret`, `token`, `cookie(s)`, `authorization`, `bearer`, `signature`, `sig`, `credential(s)`, `tempauth`, `api`+`key`, `access`+`key`, `private`/`secret`/`signing`/`encryption`+`key`) and nothing but a value-like suffix follows (`passwordHash` and `tokenValue` are redacted; `maxTokens`, `tokenExpiry`, `signatureAlgorithm`, `credentialType`, `primaryKey` are not).
|
|
82
|
+
- Key is exactly `key` (so `monkey` or `keyboard` are left alone).
|
|
83
|
+
- Inside any string value, the message and the context: the value of a query or form parameter (after `?`, `&`, `;`, whitespace or at the start) named `key`, `api_key`, `apikey`, `access_key`, `token`, `access_token`, `refresh_token`, `id_token`, `signature`, `sig`, `password`, `secret`, `tempauth`, `client_secret` or `X-Amz-Signature` / `X-Amz-Credential` / `X-Amz-Security-Token` becomes `name=[REDACTED]`; a connection-string password becomes `scheme://user:[REDACTED]@host`; HTTP Basic credentials become `Basic [TOKEN]`.
|
|
84
|
+
- Limits: strings over 32768 characters are cut at a delimiter with `...[TRUNCATED <n> chars]`, subtrees deeper than 8 levels become `[DEPTH]`, cycles `[CIRCULAR]`, arrays and objects keep 200 entries, and a line over 256 KiB is cut (text) or drops its meta (json). Errors keep name, message, stack, code and cause; Date, URL, Map, Set and binary data get a readable form. In json format meta never overwrites `level`, `timestamp`, `component` or `message` (colliding keys go under `meta`). Logging never throws into the caller.
|
|
85
|
+
|
|
86
|
+
Generic keys such as `value` and `content` are deliberately not redacted (DNS record values and file contents are legitimate data). Text mode also strips CR/LF and other control characters so a logged value cannot forge a log line.
|
|
87
|
+
|
|
88
|
+
**PII masking.** After redaction, the message and every string in `meta` (recursively) are masked with typed placeholders:
|
|
89
|
+
|
|
90
|
+
| Placeholder | Matches |
|
|
91
|
+
|-------------|---------|
|
|
92
|
+
| `[EMAIL]` | email addresses |
|
|
93
|
+
| `[PHONE]` | international `+31 6 12345678`, `+44 (20) 7946 0958`; NANP `(555) 123-4567`, `555-123-4567`; national `06-12345678`, `020 123 4567` |
|
|
94
|
+
| `[CARD]` | 13 to 19 digits with optional spaces or dashes, a card issuer prefix and a valid Luhn checksum |
|
|
95
|
+
| `[IBAN]` | IBANs, compact or in groups of four, with a valid mod-97 checksum |
|
|
96
|
+
| `[JWT]` | three base64url segments starting `eyJ` |
|
|
97
|
+
| `Bearer [TOKEN]` | `Bearer <token>`, unless the token is a short all-lowercase word (so "Bearer authentication" stays) |
|
|
98
|
+
| `[IP]` | IPv4 and IPv6 (full and compressed) addresses |
|
|
99
|
+
|
|
100
|
+
ISO timestamps, clock times, version strings (`1.2.3`, `v1.2.3.4`, `3.0.0-rc.1`), plain integers, epoch milliseconds, Luhn-invalid card-shaped numbers, bad-checksum IBANs, `std::map` and MAC addresses are left alone; both the positives and these negatives are pinned by tests.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# Migrating to 3.0
|
|
2
|
+
|
|
3
|
+
## Breaking changes in 3.0.0
|
|
4
|
+
|
|
5
|
+
- MCP SDK v1 (`@modelcontextprotocol/sdk`) replaced by `@modelcontextprotocol/server` and `@modelcontextprotocol/node`.
|
|
6
|
+
- Node 20 or newer; zod `^4.2` for consumers.
|
|
7
|
+
- HTTP transport accepts a server factory only; an instance throws.
|
|
8
|
+
- `startMcpServer` resolves with a `TransportHandle` (`{ mode, host, port, url, close() }`) once serving: in HTTP mode once the listener is bound, no longer after shutdown. Code placed after `await startMcpServer(...)` now runs at startup (a "started" log there is now correct). `handleSignals: false` leaves SIGINT/SIGTERM to the caller. `ServerFactory` receives the SDK's per-request `RequestContext` (`era`, `authInfo?`, `requestInfo?`); zero-argument factories still fit.
|
|
9
|
+
- Node 20.12 or newer (`util.parseEnv`).
|
|
10
|
+
- The `session` and `enableJsonResponse` options are removed (HTTP is stateless and JSON-first by construction).
|
|
11
|
+
- `MCP_ALLOWED_HOSTS` and `MCP_ALLOWED_ORIGINS` now use the SDK's node validators: matching is by hostname only (the port is ignored; a full-origin entry is normalised to its hostname, so its scheme is ignored). A lowercase `<scheme>://*` entry (for example `chrome-extension://*`) is passed through and admits every origin of that scheme; `http://*` and `https://*` are not honoured by the SDK. A rejection is a 403 with a JSON-RPC error body (`-32000`).
|
|
12
|
+
- HTTP auth runs on the SDK's `verifyBearerToken`: a refused token is a 401 OAuth body (`{"error":"invalid_token",...}`, previously `{"error":"Unauthorized"}`) with a `WWW-Authenticate: Bearer` challenge; `req.auth` carries an `AuthInfo` (token placeholder `[api-key]`, never the key).
|
|
13
|
+
- HTTP `responseMode` is the SDK default `'auto'` (was `'json'`, which dropped progress and log notifications); the stderr warning is gone.
|
|
14
|
+
- `MCP_TRANSPORT` other than `stdio` / `http` throws (it used to select HTTP silently); `runMcpServer` reads the transport settings from the loaded env, exits 1 when close or dispose fails, and disposes and exits when a stdio client closes stdin.
|
|
15
|
+
- Defaults that cap responses: `tools/list` is paged at 100 (`MCP_LIST_PAGE_SIZE`), list-shaped tool results at 100 items (`MCP_MAX_RESULT_ITEMS`), tool results at 256 KiB (`MCP_MAX_RESULT_BYTES`), tool arguments at 10000 elements (`MCP_MAX_TOOL_INPUT_ELEMENTS`), and `paginate().collect()` at `MCP_MAX_RESULT_ITEMS` items unless `maxItems` is passed.
|
|
16
|
+
- `DATA_STORAGE` with an invalid value disables disk writes (it used to enable them).
|
|
17
|
+
- Error text sent to clients (`toolError`, `mapError` output, thrown errors, returned isError results) masks registered secret values like the logger.
|
|
18
|
+
- An empty or malformed POST body is answered with a 400 JSON-RPC parse error (`-32700`).
|
|
19
|
+
- Removed dependency overrides that only served the v1 SDK's express stack.
|
|
20
|
+
- `@modelcontextprotocol/express` moved from a dependency to an optional peer: an Express host adds `express` and `@modelcontextprotocol/express` itself.
|
|
21
|
+
- HTTP client rewritten: the `axios-retry` dependency is gone (one in-house retry loop); `retries` is now `retry: { retries }`; verbs take a per-request options object (`get(path, { query })` instead of `get(path, params)`); `getPaginated` and `PaginatedResponse` are replaced by `paginate(request, strategy)`; no default `Content-Type` header (axios sets it per body, so multipart works); failures throw `HttpError`; network failures read `Network error (<code>)`.
|
|
22
|
+
- Retry policy unified: retryable statuses are 408, 425, 429, 500, 502, 503, 504 (501 never); `ENOTFOUND` is no longer retried and `ECONNREFUSED` only on connect; backoff is full jitter (the `jitterFactor` option is gone); `retryWithBackoff` honours `Retry-After`.
|
|
23
|
+
- Logger: the `output` option (`'file' | 'stderr'`) is gone. Outputs are the console switch plus the three file switches, and the log directory defaults to `<rootDir>/.logs` instead of `./logs`; pass `initLogger({ name, rootDir })`. `initLogger` now reconfigures the singleton in place instead of replacing it. Meta keys named exactly `key`, or containing `passwd`, `access_key`, `credential` or `tempauth`, are now redacted, and PII is masked on every entry.
|
|
24
|
+
|
|
25
|
+
## Migrating a low-level `Server` consumer
|
|
26
|
+
|
|
27
|
+
1. Replace `index.ts` with `runMcpServer({ rootDir: packageRootFrom(import.meta.url), env: ENV, setup,
|
|
28
|
+
create, dispose })`. Delete dotenv, `initLogger`, the "started" log, any `process.on('SIGINT'...)`, and
|
|
29
|
+
hard-coded name/version (`version.ts`): they come from `package.json`.
|
|
30
|
+
2. Declare `ENV` (every variable the server reads, with `required`/`secret`/`type`/`description`); move
|
|
31
|
+
`config.ts` reads to `env.get(...)`. Generate the README env table and `.env.example` from it.
|
|
32
|
+
3. Move process-level resources (API clients, rate limiters, DB handles, caches) into `setup`; release
|
|
33
|
+
them in `dispose`. `create` only builds the `McpServer` per connection/request.
|
|
34
|
+
4. Replace `new Server(...)` + the `tools/list` / `tools/call` handlers with `createMcpServer(info.name,
|
|
35
|
+
info.version)` + `registerTools(server, TOOLS, { env, serverName: info.name })`. Each tool becomes
|
|
36
|
+
`defineTool({ name, description, inputSchema: <its zod object>, annotations, requires?, handler })`;
|
|
37
|
+
delete `schema.ts` (`toInputSchema`), the per-handler try/parse/catch blocks, and the raw-argument
|
|
38
|
+
logging. Handlers may keep returning `{ success, data, error }` (now mapped to `isError` correctly) and
|
|
39
|
+
move to `toolResult` / `toolError` over time. A tool with a hand-written JSON Schema can use
|
|
40
|
+
`fromJsonSchema(schema)` as its `inputSchema`.
|
|
41
|
+
5. Replace the "unconfigured" branches with `requires: [...]` (tools stay listed; one uniform error).
|
|
42
|
+
6. Replace state-dir heuristics and file writes with `createDataStore` (`DATA_PATH`; set `DATA_STORAGE=false`
|
|
43
|
+
for stateless hosts). Integration tests pass `envFile: 'none'` and `DATA_PATH: dirs.state`.
|
|
44
|
+
7. Wire changes: error wording becomes the SDK's for schema failures (`Input validation error: ...`), an
|
|
45
|
+
unknown tool becomes ProtocolError `-32602`, and tool names and wire `inputSchema`s stay the same.
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# Outbound URLs
|
|
2
|
+
|
|
3
|
+
## Guarded outbound fetch (`@novorb/mcp-core/net`)
|
|
4
|
+
|
|
5
|
+
Use it for every URL a server did not configure itself: a tool argument (an avatar or image URL), or a
|
|
6
|
+
URL an upstream handed back (a download link, a thumbnail).
|
|
7
|
+
|
|
8
|
+
```typescript
|
|
9
|
+
import { guardedFetch, GuardedFetchError, normalizeOrigins } from '@novorb/mcp-core/net';
|
|
10
|
+
|
|
11
|
+
const res = await guardedFetch(url, {
|
|
12
|
+
allowedOrigins: normalizeOrigins(env.get('MY_ALLOWED_ORIGINS') ?? '', 'MY_ALLOWED_ORIGINS'), // optional
|
|
13
|
+
redirects: 'refuse', // default; or a number: follow at most n, every hop re-validated
|
|
14
|
+
maxBytes: 5_000_000, // default 50 MiB, declared and streamed
|
|
15
|
+
timeoutMs: 30_000, // default 30 s, head and body together
|
|
16
|
+
signal: ctx.signal,
|
|
17
|
+
allowedContentTypes: ['image/*'], // optional, lower-cased essence; `allowMissingContentType` to pass none
|
|
18
|
+
});
|
|
19
|
+
if (!res.ok) throw new Error(`download failed (${res.status})`);
|
|
20
|
+
const bytes = Buffer.from(await res.arrayBuffer()); // a GuardedFetchError if the body breaks a cap
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
- **Guarantees:** http(s) only; no userinfo; `allowedOrigins` (WHATWG origins, normalised); public
|
|
24
|
+
addresses only (`isPublicAddress`): IPv4 this-network, private, CGNAT, loopback, link-local and the
|
|
25
|
+
cloud metadata address, IETF assignments, documentation, benchmarking, multicast, reserved, broadcast;
|
|
26
|
+
IPv6 unspecified, loopback, link-local, unique local, site-local, multicast, documentation, Teredo /
|
|
27
|
+
ORCHID, everything outside 2000::/3, and the forms embedding a refused IPv4 address (mapped,
|
|
28
|
+
compatible, NAT64, 6to4). IP literals (every spelling the URL parser normalises: decimal, hex, octal,
|
|
29
|
+
short) are checked before sending; DNS names are checked inside the socket lookup on every resolved
|
|
30
|
+
address (`all: true`, an empty answer refused), so there is no check-then-connect gap and a rebinding
|
|
31
|
+
name cannot slip through. Each request has its own agent (keep-alive off, destroyed when the body
|
|
32
|
+
settles), never the global agent: with `NODE_USE_ENV_PROXY` + `HTTP(S)_PROXY` the global agent hands
|
|
33
|
+
the request to the proxy, which resolves the name itself.
|
|
34
|
+
- **Options:** `allowPrivate` (explicit opt-in, default false), `isAllowedAddress` (a custom policy; also
|
|
35
|
+
the test seam), `lookup` (the DNS seam), `method` `GET | HEAD`, `headers` (dropped when a redirect
|
|
36
|
+
changes origin), `redirects`, `maxBytes`, `timeoutMs`, `signal`, `allowedContentTypes` (applied to a
|
|
37
|
+
2xx), `allowMissingContentType`.
|
|
38
|
+
- **Caps are validated:** a non-finite or negative `redirects`, `maxBytes` or `timeoutMs` (which would
|
|
39
|
+
silently disable the cap) is a `TypeError` before anything is sent; `downloadToFile` validates its
|
|
40
|
+
`maxBytes`, `maxRedirects` and `timeoutMs` the same way.
|
|
41
|
+
- **Result:** a fetch `Response` for any final status (the caller decides what a 404 means), with `url`
|
|
42
|
+
(the final URL) and `redirected`. With `redirects: 'refuse'` every 3xx (300 to 399, with or without
|
|
43
|
+
`Location`) is refused; when following, a 3xx without `Location` (or a 300/304/305) is returned. Refusals and failures are `GuardedFetchError` with `reason` `url |
|
|
44
|
+
protocol | credentials | origin | address | redirect | redirects | content-type | too-large | timeout |
|
|
45
|
+
aborted | network`, `code` (`ERR_ADDRESS_BLOCKED` for the address policy, the socket code otherwise)
|
|
46
|
+
and `status` for a refused redirect. Messages never contain the URL.
|
|
47
|
+
- **Lower level:** `checkUrl(url, opts)` (the URL rules, throws), `createGuardedAgent('http:' | 'https:',
|
|
48
|
+
opts)` (an agent whose sockets resolve through the guard: pass it as `agent` to `http.request`, after
|
|
49
|
+
`checkUrl`, since Node never calls the lookup for an IP literal, and destroy it when done),
|
|
50
|
+
`guardedLookup(base, policy)`, `addressPolicy(opts)`, `normalizeOrigins(entries, label)`.
|
|
51
|
+
|
|
52
|
+
**Example: downloading an image a tool caller named.**
|
|
53
|
+
|
|
54
|
+
```typescript
|
|
55
|
+
import { guardedFetch, GuardedFetchError, normalizeOrigins } from '@novorb/mcp-core/net';
|
|
56
|
+
|
|
57
|
+
const IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'];
|
|
58
|
+
|
|
59
|
+
export async function downloadImage(url: string, o: ImageDownloadOptions): Promise<Blob> {
|
|
60
|
+
try {
|
|
61
|
+
const res = await guardedFetch(url, {
|
|
62
|
+
allowedOrigins: o.allowedOrigins, maxBytes: o.maxBytes, timeoutMs: o.timeoutMs, signal: o.signal,
|
|
63
|
+
allowedContentTypes: IMAGE_TYPES, allowMissingContentType: true, headers: { Accept: 'image/*' },
|
|
64
|
+
});
|
|
65
|
+
if (!res.ok) throw new Error(`Failed to download image from ${url}: ${res.status} ${res.statusText}`);
|
|
66
|
+
const type = (res.headers.get('content-type') ?? 'image/png').split(';')[0].trim().toLowerCase();
|
|
67
|
+
return new Blob([await res.arrayBuffer()], { type });
|
|
68
|
+
} catch (e) {
|
|
69
|
+
if (e instanceof GuardedFetchError) throw new Error(`Failed to download image from ${url}: ${e.message}`);
|
|
70
|
+
throw e;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Build the origin list with `[new URL(apiUrl).origin, ...normalizeOrigins(extra ?? '', 'MY_ALLOWED_ORIGINS')]`.
|
|
76
|
+
`store.downloadToFile(url, ...)` is guarded by default; pass `allowedContentTypes` (for example `['image/*',
|
|
77
|
+
'video/*']`) to tighten it, and never `allowPrivate`.
|
|
78
|
+
|
|
79
|
+
## Upstream URL path segments (`@novorb/mcp-core/url-path`)
|
|
80
|
+
|
|
81
|
+
`encodeURIComponent` leaves `.` and `..` intact and the URL parser collapses them
|
|
82
|
+
(`/repositories/..` addresses the parent). Put every caller id or name into an upstream path through:
|
|
83
|
+
|
|
84
|
+
```typescript
|
|
85
|
+
import { buildPath, joinPath, pathSegment } from '@novorb/mcp-core/url-path';
|
|
86
|
+
|
|
87
|
+
const path = buildPath`/service/rest/v1/repositories/${{ name: 'repository', value: args.repository }}`;
|
|
88
|
+
const same = joinPath('service', 'rest', 'v1', 'repositories', { name: 'repository', value: args.repository });
|
|
89
|
+
const one = pathSegment(args.itemId, { name: 'itemId' });
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
- One segment, encoded with `encodeURIComponent`. Refused (`InvalidPathSegmentError`, `argument` and
|
|
93
|
+
`problem` `empty | dot-segment | control | invalid`): empty, `.`, `..`, their percent-encoded and
|
|
94
|
+
double-encoded forms (`%2e`, `%2E%2e`, `.%2e`, `%252e`), overlong UTF-8 dots decoded leniently
|
|
95
|
+
(`%c0%ae`, `%e0%80%ae`), unicode dot forms after NFKC normalisation (U+FF0E fullwidth, U+FE52 small,
|
|
96
|
+
U+2024 / U+2025 leaders, U+3002 / U+FF61 ideographic full stops), C0 and C1 control characters, DEL,
|
|
97
|
+
U+2028 and U+2029 (raw or encoded, `%00`, `%0A`, `%C2%85`, `%c0%80`), and non-finite numbers. `...`,
|
|
98
|
+
`.hidden`, `a..b` and a name with a fullwidth dot inside are ordinary names. Each value is checked as
|
|
99
|
+
the most lenient upstream would read it (up to three decoding rounds, each also NFKC-normalised).
|
|
100
|
+
- The message is `Invalid <name>: must not be "." or ".." (or an encoded form of them)` (and the like): it
|
|
101
|
+
names the argument and never echoes the value. `registerTools` returns it as the isError text
|
|
102
|
+
whatever the server's `mapError` does.
|
|
103
|
+
- Literal parts of `buildPath` are kept as written; `joinPath` encodes every part and adds one leading
|
|
104
|
+
slash.
|