@novorb/mcp-core 2.1.1 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (240) hide show
  1. package/README.md +82 -161
  2. package/bin/mcp-core-jest.mjs +112 -0
  3. package/dist/auth.d.ts +57 -0
  4. package/dist/auth.d.ts.map +1 -0
  5. package/dist/auth.js +74 -0
  6. package/dist/auth.js.map +1 -0
  7. package/dist/bootstrap.d.ts +129 -0
  8. package/dist/bootstrap.d.ts.map +1 -0
  9. package/dist/bootstrap.js +206 -0
  10. package/dist/bootstrap.js.map +1 -0
  11. package/dist/client-input.d.ts +72 -0
  12. package/dist/client-input.d.ts.map +1 -0
  13. package/dist/client-input.js +109 -0
  14. package/dist/client-input.js.map +1 -0
  15. package/dist/credential-checks.d.ts +80 -0
  16. package/dist/credential-checks.d.ts.map +1 -0
  17. package/dist/credential-checks.js +189 -0
  18. package/dist/credential-checks.js.map +1 -0
  19. package/dist/credential-health.d.ts +12 -0
  20. package/dist/credential-health.d.ts.map +1 -0
  21. package/dist/credential-health.js +12 -0
  22. package/dist/credential-health.js.map +1 -0
  23. package/dist/credential-report.d.ts +46 -0
  24. package/dist/credential-report.d.ts.map +1 -0
  25. package/dist/credential-report.js +135 -0
  26. package/dist/credential-report.js.map +1 -0
  27. package/dist/credential-status-tool.d.ts +20 -0
  28. package/dist/credential-status-tool.d.ts.map +1 -0
  29. package/dist/credential-status-tool.js +33 -0
  30. package/dist/credential-status-tool.js.map +1 -0
  31. package/dist/credentials.d.ts +288 -0
  32. package/dist/credentials.d.ts.map +1 -0
  33. package/dist/credentials.js +652 -0
  34. package/dist/credentials.js.map +1 -0
  35. package/dist/express-peers.d.ts +29 -0
  36. package/dist/express-peers.d.ts.map +1 -0
  37. package/dist/express-peers.js +36 -0
  38. package/dist/express-peers.js.map +1 -0
  39. package/dist/express.d.ts +74 -0
  40. package/dist/express.d.ts.map +1 -0
  41. package/dist/express.js +164 -0
  42. package/dist/express.js.map +1 -0
  43. package/dist/http-client.d.ts +278 -43
  44. package/dist/http-client.d.ts.map +1 -1
  45. package/dist/http-client.js +620 -182
  46. package/dist/http-client.js.map +1 -1
  47. package/dist/index.d.ts +27 -6
  48. package/dist/index.d.ts.map +1 -1
  49. package/dist/index.js +49 -10
  50. package/dist/index.js.map +1 -1
  51. package/dist/limits.d.ts +28 -0
  52. package/dist/limits.d.ts.map +1 -0
  53. package/dist/limits.js +37 -0
  54. package/dist/limits.js.map +1 -0
  55. package/dist/list-pagination.d.ts +41 -0
  56. package/dist/list-pagination.d.ts.map +1 -0
  57. package/dist/list-pagination.js +104 -0
  58. package/dist/list-pagination.js.map +1 -0
  59. package/dist/net.d.ts +112 -0
  60. package/dist/net.d.ts.map +1 -0
  61. package/dist/net.js +490 -0
  62. package/dist/net.js.map +1 -0
  63. package/dist/notifications.d.ts +78 -0
  64. package/dist/notifications.d.ts.map +1 -0
  65. package/dist/notifications.js +173 -0
  66. package/dist/notifications.js.map +1 -0
  67. package/dist/pagination.d.ts +236 -5
  68. package/dist/pagination.d.ts.map +1 -1
  69. package/dist/pagination.js +393 -6
  70. package/dist/pagination.js.map +1 -1
  71. package/dist/prompts.d.ts +70 -0
  72. package/dist/prompts.d.ts.map +1 -0
  73. package/dist/prompts.js +109 -0
  74. package/dist/prompts.js.map +1 -0
  75. package/dist/protocol-errors.d.ts +15 -0
  76. package/dist/protocol-errors.d.ts.map +1 -0
  77. package/dist/protocol-errors.js +28 -0
  78. package/dist/protocol-errors.js.map +1 -0
  79. package/dist/protocol-logging.d.ts +37 -0
  80. package/dist/protocol-logging.d.ts.map +1 -0
  81. package/dist/protocol-logging.js +77 -0
  82. package/dist/protocol-logging.js.map +1 -0
  83. package/dist/rate-limiter.d.ts +36 -0
  84. package/dist/rate-limiter.d.ts.map +1 -0
  85. package/dist/rate-limiter.js +79 -0
  86. package/dist/rate-limiter.js.map +1 -0
  87. package/dist/request-state.d.ts +74 -0
  88. package/dist/request-state.d.ts.map +1 -0
  89. package/dist/request-state.js +190 -0
  90. package/dist/request-state.js.map +1 -0
  91. package/dist/resources.d.ts +181 -0
  92. package/dist/resources.d.ts.map +1 -0
  93. package/dist/resources.js +302 -0
  94. package/dist/resources.js.map +1 -0
  95. package/dist/result-limits.d.ts +80 -0
  96. package/dist/result-limits.d.ts.map +1 -0
  97. package/dist/result-limits.js +248 -0
  98. package/dist/result-limits.js.map +1 -0
  99. package/dist/retry.d.ts +79 -23
  100. package/dist/retry.d.ts.map +1 -1
  101. package/dist/retry.js +170 -57
  102. package/dist/retry.js.map +1 -1
  103. package/dist/sdk/client-stdio.d.ts +6 -0
  104. package/dist/sdk/client-stdio.d.ts.map +1 -0
  105. package/dist/sdk/client-stdio.js +6 -0
  106. package/dist/sdk/client-stdio.js.map +1 -0
  107. package/dist/sdk/client.d.ts +6 -0
  108. package/dist/sdk/client.d.ts.map +1 -0
  109. package/dist/sdk/client.js +6 -0
  110. package/dist/sdk/client.js.map +1 -0
  111. package/dist/sdk/express.d.ts +3 -0
  112. package/dist/sdk/express.d.ts.map +1 -0
  113. package/dist/sdk/express.js +11 -0
  114. package/dist/sdk/express.js.map +1 -0
  115. package/dist/sdk/node.d.ts +6 -0
  116. package/dist/sdk/node.d.ts.map +1 -0
  117. package/dist/sdk/node.js +6 -0
  118. package/dist/sdk/node.js.map +1 -0
  119. package/dist/sdk/server-stdio.d.ts +6 -0
  120. package/dist/sdk/server-stdio.d.ts.map +1 -0
  121. package/dist/sdk/server-stdio.js +6 -0
  122. package/dist/sdk/server-stdio.js.map +1 -0
  123. package/dist/sdk/server.d.ts +6 -0
  124. package/dist/sdk/server.d.ts.map +1 -0
  125. package/dist/sdk/server.js +6 -0
  126. package/dist/sdk/server.js.map +1 -0
  127. package/dist/server.d.ts +88 -36
  128. package/dist/server.d.ts.map +1 -1
  129. package/dist/server.js +98 -44
  130. package/dist/server.js.map +1 -1
  131. package/dist/task-request.d.ts +20 -0
  132. package/dist/task-request.d.ts.map +1 -0
  133. package/dist/task-request.js +43 -0
  134. package/dist/task-request.js.map +1 -0
  135. package/dist/task-store.d.ts +230 -0
  136. package/dist/task-store.d.ts.map +1 -0
  137. package/dist/task-store.js +459 -0
  138. package/dist/task-store.js.map +1 -0
  139. package/dist/task-tools.d.ts +115 -0
  140. package/dist/task-tools.d.ts.map +1 -0
  141. package/dist/task-tools.js +397 -0
  142. package/dist/task-tools.js.map +1 -0
  143. package/dist/testing/client.d.ts +25 -0
  144. package/dist/testing/client.d.ts.map +1 -0
  145. package/dist/testing/client.js +11 -0
  146. package/dist/testing/client.js.map +1 -0
  147. package/dist/testing/in-memory.d.ts +26 -0
  148. package/dist/testing/in-memory.d.ts.map +1 -0
  149. package/dist/testing/in-memory.js +41 -0
  150. package/dist/testing/in-memory.js.map +1 -0
  151. package/dist/testing/index.d.ts +20 -0
  152. package/dist/testing/index.d.ts.map +1 -0
  153. package/dist/testing/index.js +20 -0
  154. package/dist/testing/index.js.map +1 -0
  155. package/dist/testing/isolated-dirs.d.ts +27 -0
  156. package/dist/testing/isolated-dirs.d.ts.map +1 -0
  157. package/dist/testing/isolated-dirs.js +67 -0
  158. package/dist/testing/isolated-dirs.js.map +1 -0
  159. package/dist/testing/logs.d.ts +61 -0
  160. package/dist/testing/logs.d.ts.map +1 -0
  161. package/dist/testing/logs.js +113 -0
  162. package/dist/testing/logs.js.map +1 -0
  163. package/dist/testing/raw-http.d.ts +25 -0
  164. package/dist/testing/raw-http.d.ts.map +1 -0
  165. package/dist/testing/raw-http.js +41 -0
  166. package/dist/testing/raw-http.js.map +1 -0
  167. package/dist/testing/raw-peer.d.ts +43 -0
  168. package/dist/testing/raw-peer.d.ts.map +1 -0
  169. package/dist/testing/raw-peer.js +101 -0
  170. package/dist/testing/raw-peer.js.map +1 -0
  171. package/dist/testing/recording-server.d.ts +38 -0
  172. package/dist/testing/recording-server.d.ts.map +1 -0
  173. package/dist/testing/recording-server.js +38 -0
  174. package/dist/testing/recording-server.js.map +1 -0
  175. package/dist/testing/server-process.d.ts +76 -0
  176. package/dist/testing/server-process.d.ts.map +1 -0
  177. package/dist/testing/server-process.js +241 -0
  178. package/dist/testing/server-process.js.map +1 -0
  179. package/dist/testing/tools.d.ts +53 -0
  180. package/dist/testing/tools.d.ts.map +1 -0
  181. package/dist/testing/tools.js +98 -0
  182. package/dist/testing/tools.js.map +1 -0
  183. package/dist/testing/unit.d.ts +52 -0
  184. package/dist/testing/unit.d.ts.map +1 -0
  185. package/dist/testing/unit.js +95 -0
  186. package/dist/testing/unit.js.map +1 -0
  187. package/dist/tools.d.ts +205 -0
  188. package/dist/tools.d.ts.map +1 -0
  189. package/dist/tools.js +370 -0
  190. package/dist/tools.js.map +1 -0
  191. package/dist/transport.d.ts +144 -53
  192. package/dist/transport.d.ts.map +1 -1
  193. package/dist/transport.js +305 -122
  194. package/dist/transport.js.map +1 -1
  195. package/dist/url-path.d.ts +49 -0
  196. package/dist/url-path.d.ts.map +1 -0
  197. package/dist/url-path.js +165 -0
  198. package/dist/url-path.js.map +1 -0
  199. package/dist/utils/dataStore.d.ts +143 -0
  200. package/dist/utils/dataStore.d.ts.map +1 -0
  201. package/dist/utils/dataStore.js +429 -0
  202. package/dist/utils/dataStore.js.map +1 -0
  203. package/dist/utils/envLoader.d.ts +129 -0
  204. package/dist/utils/envLoader.d.ts.map +1 -0
  205. package/dist/utils/envLoader.js +399 -0
  206. package/dist/utils/envLoader.js.map +1 -0
  207. package/dist/utils/logger.d.ts +181 -0
  208. package/dist/utils/logger.d.ts.map +1 -0
  209. package/dist/utils/logger.js +664 -0
  210. package/dist/utils/logger.js.map +1 -0
  211. package/dist/utils/redact.d.ts +50 -0
  212. package/dist/utils/redact.d.ts.map +1 -0
  213. package/dist/utils/redact.js +318 -0
  214. package/dist/utils/redact.js.map +1 -0
  215. package/docs/api-reference.md +29 -0
  216. package/docs/building-a-server.md +63 -0
  217. package/docs/credentials.md +131 -0
  218. package/docs/data-store.md +37 -0
  219. package/docs/env.md +38 -0
  220. package/docs/features.md +28 -0
  221. package/docs/http-client.md +116 -0
  222. package/docs/logger.md +100 -0
  223. package/docs/migrating-to-v3.md +45 -0
  224. package/docs/outbound-urls.md +104 -0
  225. package/docs/pagination.md +45 -0
  226. package/docs/protocol-features.md +62 -0
  227. package/docs/resources-prompts-tasks.md +95 -0
  228. package/docs/sdk.md +33 -0
  229. package/docs/server.md +68 -0
  230. package/docs/testing-harness.md +139 -0
  231. package/docs/testing.md +151 -0
  232. package/docs/tools.md +88 -0
  233. package/docs/transport.md +85 -0
  234. package/examples/testing/tools.integration.test.example.ts +312 -0
  235. package/examples/testing/tools.unit.test.example.ts +149 -0
  236. package/package.json +140 -18
  237. package/dist/logger.d.ts +0 -80
  238. package/dist/logger.d.ts.map +0 -1
  239. package/dist/logger.js +0 -236
  240. 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)`.
@@ -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.