@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.
- package/README.md +82 -161
- 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 +278 -43
- package/dist/http-client.d.ts.map +1 -1
- package/dist/http-client.js +620 -182
- 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 +49 -10
- 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 -36
- 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 +144 -53
- package/dist/transport.d.ts.map +1 -1
- package/dist/transport.js +305 -122
- 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 +140 -18
- package/dist/logger.d.ts +0 -80
- package/dist/logger.d.ts.map +0 -1
- package/dist/logger.js +0 -236
- package/dist/logger.js.map +0 -1
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# Pagination
|
|
2
|
+
|
|
3
|
+
**Page-number helpers** for list tools: `normalizePagination` (clamps by default; `normalizePagination(params, { maxPerPage, defaultPerPage, onInvalid: 'throw' })` throws a `RangeError` instead), `createPaginationMetadata`, `pageToOffset`, `paginateArray`.
|
|
4
|
+
|
|
5
|
+
```typescript
|
|
6
|
+
const params = normalizePagination({ page: 3, per_page: 200 }); // { page: 3, per_page: 100 }
|
|
7
|
+
const { data, metadata } = paginateArray(allItems, params);
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
**Upstream strategies** for `client.paginate(request, strategy, { maxPages, maxItems })` (or `paginateRequests(fetchPage, request, strategy, options)`). The result is an async iterator of pages `{ items, pageIndex, total }` with `items()` and `collect()` (`{ items, total, pages, stopReason, truncated }`, `stopReason` = `end` | `maxPages` | `maxItems` | `repeated`). `maxPages` defaults to 100 and is never above `HARD_MAX_PAGES` (10000); `maxItems` defaults to `MCP_MAX_RESULT_ITEMS` (100) and is never above `HARD_MAX_COLLECT_ITEMS` (100000), so a walk never pulls an unbounded list into memory; a next request identical to an earlier one stops the walk (`repeated`), so a misbehaving API cannot loop forever. Set `pageSize` / `limit` at or below the API's own maximum: a server that silently caps the page (asked for 100, sends 50) makes the first page look short, and the size-based strategies then stop early (`stopReason: 'end'`) unless a total header or field is configured. A relative next link is joined onto the client's `baseURL` (root-relative links, the common case, resolve correctly).
|
|
11
|
+
|
|
12
|
+
| Strategy | Upstream shape | Stops when |
|
|
13
|
+
|----------|----------------|------------|
|
|
14
|
+
| `pagePerPage({ pageParam, sizeParam, pageSize, startPage, totalHeader, totalField, items })` | `page`/`per_page` (Dependency-Track `pageNumber`/`pageSize` + `X-Total-Count`) | empty or short page, or page * size >= total |
|
|
15
|
+
| `nextPageHeader({ header, pageParam, sizeParam, pageSize, totalHeader, items })` | GitLab `X-Next-Page` + `X-Total` | the header is empty or absent, or the page is empty |
|
|
16
|
+
| `offsetLimit({ offsetParam, limitParam, limit, startOffset, totalHeader, totalField, items })` | offset/limit | empty or short page, or offset + items >= total |
|
|
17
|
+
| `linkHeader({ rel, totalHeader, items })` | RFC 5988 `Link: <url>; rel="next"` | no `next` link |
|
|
18
|
+
| `nextLinkField({ field, totalField, items })` | Graph `@odata.nextLink` / `@odata.count` / `value` | no next link |
|
|
19
|
+
| `continuationToken({ requestParam, token, items })` | Nexus `continuationToken`; Trello `before` via a token function | token absent/empty, or the page is empty |
|
|
20
|
+
|
|
21
|
+
`items` is a body field name or an extractor; the default takes an array body or the first array under `value`/`items`/`data`/`results`.
|
|
22
|
+
|
|
23
|
+
**MCP-side cursors and the paged result contract** for list tools:
|
|
24
|
+
|
|
25
|
+
```typescript
|
|
26
|
+
import { z } from 'zod';
|
|
27
|
+
import { decodeCursor, encodeCursor, invalidCursorResult, pagedResultSchema } from '@novorb/mcp-core/pagination';
|
|
28
|
+
|
|
29
|
+
server.registerTool('project_list', {
|
|
30
|
+
inputSchema: z.object({ cursor: z.string().optional() }),
|
|
31
|
+
outputSchema: pagedResultSchema(z, Project), // { items, nextCursor?, total? }
|
|
32
|
+
}, async ({ cursor }) => {
|
|
33
|
+
let page = 1;
|
|
34
|
+
try {
|
|
35
|
+
if (cursor) page = decodeCursor(cursor, { validate: isPageState }).page;
|
|
36
|
+
} catch (err) {
|
|
37
|
+
return invalidCursorResult(err); // isError, "Invalid cursor: <reason>"
|
|
38
|
+
}
|
|
39
|
+
const { items, total, hasMore } = await fetchPage(page);
|
|
40
|
+
const result = { items, total, ...(hasMore ? { nextCursor: encodeCursor({ page: page + 1 }) } : {}) };
|
|
41
|
+
return { content: [{ type: 'text', text: JSON.stringify(result) }], structuredContent: result };
|
|
42
|
+
});
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Cursors are base64url JSON `{ v, d }`, versioned (`CURSOR_VERSION`), at most `MAX_CURSOR_LENGTH` (2048) chars, and validated on decode (`InvalidCursorError`). `paginateArrayByCursor(items, { cursor, limit, defaultLimit, maxLimit })` returns a `PagedResult` over an in-memory list.
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# Protocol features (logging, sampling, roots, elicitation, progress, cancellation)
|
|
2
|
+
|
|
3
|
+
The 2026-07-28 revision deprecates protocol logging, sampling and roots (SEP-2577: functional for at
|
|
4
|
+
least twelve months, marked `@deprecated` in the SDK types) and removes server-to-client push requests
|
|
5
|
+
in favour of multi round-trip requests (MRTR, `inputRequired(...)`). 2025 clients still use all of
|
|
6
|
+
them, so mcp-core keeps them available:
|
|
7
|
+
|
|
8
|
+
| Feature | 2025-era stdio | 2025-era stateless HTTP | 2026-07-28 (stdio and HTTP) | SDK mechanism |
|
|
9
|
+
|---------|----------------|-------------------------|-----------------------------|---------------|
|
|
10
|
+
| Protocol logging | yes, `logging/setLevel` kept per connection | yes, every level (each request is a fresh instance, so a set level is not kept) | only when the request carries `_meta['io.modelcontextprotocol/logLevel']` (the v2 client's `setLoggingLevel` throws on this era) | `ctx.mcpReq.log` |
|
|
11
|
+
| Elicitation (form, URL) | yes (legacy shim: real server-to-client request + re-entry) | no: the instance never saw `initialize`, so no client capability is known (`unsupported`) | yes (client fulfils, retries) | `inputRequired.elicit` / `.elicitUrl` |
|
|
12
|
+
| Sampling | yes (shim) | no (`unsupported`) | yes | `inputRequired.createMessage` |
|
|
13
|
+
| Roots | yes (shim) | no (`unsupported`) | yes | `inputRequired.listRoots` |
|
|
14
|
+
| Progress | yes | yes (`responseMode: 'auto'` upgrades to SSE) | yes | `notifications/progress` |
|
|
15
|
+
| Cancellation | `ctx.signal` | `ctx.signal` | `ctx.signal` | `ctx.mcpReq.signal` |
|
|
16
|
+
|
|
17
|
+
**Protocol logging** (`LOG_PROTOCOL=true` or `createMcpServer(..., { protocolLogging: true })`, default
|
|
18
|
+
off): advertises the `logging` capability and forwards every mcp-core logger entry produced while a
|
|
19
|
+
`registerTools` handler runs (an AsyncLocalStorage scope; `runWithRequestContext(ctx, fn)` for
|
|
20
|
+
hand-registered handlers) as `notifications/message` with `{ message, context, meta? }` and the logger
|
|
21
|
+
name. The entries are exactly what the log files get (redacted, PII-masked, registered secrets as
|
|
22
|
+
`[SECRET]`), and the logger never carries tool inputs or outputs (`logToolCall` is shape only). Levels map
|
|
23
|
+
DEBUG/INFO/WARN/ERROR to `debug`/`info`/`warning`/`error`; an entry below the logger's own `LOG_LEVEL` is
|
|
24
|
+
never produced.
|
|
25
|
+
|
|
26
|
+
**Elicitation, sampling, roots** on `ToolContext`, era-blind over MRTR; each returns a typed
|
|
27
|
+
`InputOutcome` and never throws:
|
|
28
|
+
|
|
29
|
+
```typescript
|
|
30
|
+
handler: async ({ domain }, ctx) => {
|
|
31
|
+
const answer = ctx.elicit('confirm', {
|
|
32
|
+
message: `Buy ${domain}?`,
|
|
33
|
+
requestedSchema: z.object({ confirm: z.boolean() }), // the accepted content is validated against it
|
|
34
|
+
});
|
|
35
|
+
if (answer.status === 'pending') return answer.result; // re-entered with the user's answer
|
|
36
|
+
if (answer.status === 'unsupported') return toolError(`cannot confirm: ${answer.reason}`);
|
|
37
|
+
if (answer.status !== 'accepted' || !answer.value.confirm) return toolResult('not purchased');
|
|
38
|
+
return toolResult(await buy(domain, { signal: ctx.signal }));
|
|
39
|
+
}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Statuses: `accepted` (`value`), `declined`, `cancelled`, `invalid` (accepted content failing the schema),
|
|
43
|
+
`pending` (`result`: return it), `unsupported` (`reason`: the client did not declare the capability, or no
|
|
44
|
+
capabilities are known for the request). `ctx.sample(key, params)` answers the `CreateMessageResult`,
|
|
45
|
+
`ctx.listRoots(key = 'roots')` the `Root[]`, `ctx.elicitUrl(key, params)` `true` once accepted. For
|
|
46
|
+
several requests at once return `inputRequired({ inputRequests: { ... } })` yourself (from
|
|
47
|
+
`@novorb/mcp-core/server`) and read `ctx.sdk.mcpReq.inputResponses`.
|
|
48
|
+
|
|
49
|
+
**Progress and cancellation:** `await ctx.progress(current, total?, message?)` sends
|
|
50
|
+
`notifications/progress` when the client passed a `progressToken`; it is a no-op without one, once the
|
|
51
|
+
request is cancelled, and when the send fails. Pass `ctx.signal` to every outbound call:
|
|
52
|
+
|
|
53
|
+
```typescript
|
|
54
|
+
handler: async ({ projectIds }, ctx) => {
|
|
55
|
+
for (const [i, id] of projectIds.entries()) {
|
|
56
|
+
if (ctx.signal.aborted) break; // the client cancelled or disconnected
|
|
57
|
+
await sync(id, { signal: ctx.signal });
|
|
58
|
+
await ctx.progress(i + 1, projectIds.length, `synced ${id}`);
|
|
59
|
+
}
|
|
60
|
+
return toolResult({ synced: projectIds.length });
|
|
61
|
+
}
|
|
62
|
+
```
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# Resources, prompts, notifications, tasks, requestState
|
|
2
|
+
|
|
3
|
+
**Resources** (`@novorb/mcp-core/resources`) go onto the SDK's `registerResource`, so listing, template
|
|
4
|
+
matching, completion and `ResourceNotFoundError` stay the SDK's. mcp-core adds a log line per read (name and
|
|
5
|
+
size, never the content or the filled-in URI), masked JSON-RPC errors through `mapError`, the uniform
|
|
6
|
+
degraded-mode error for missing `requires`, and a byte cap per read (`maxBytes`, default
|
|
7
|
+
`MCP_MAX_RESULT_BYTES`): an oversized read is an actionable error, never a truncated document. A handler
|
|
8
|
+
may return a string (text) or bytes (blob); `mimeType` fills in.
|
|
9
|
+
|
|
10
|
+
```typescript
|
|
11
|
+
registerResources(server, [
|
|
12
|
+
defineResource({ name: 'readme', uri: 'repo://readme', mimeType: 'text/markdown', read: async () => readme }),
|
|
13
|
+
defineResourceTemplate({
|
|
14
|
+
name: 'file', uriTemplate: 'repo://files/{path}', mimeType: 'text/plain',
|
|
15
|
+
complete: { path: (prefix) => paths.filter((p) => p.startsWith(prefix)) },
|
|
16
|
+
read: async ({ variables }) => readFile(String(variables.path)),
|
|
17
|
+
}),
|
|
18
|
+
], { env, serverName: 'repo-mcp', subscribe: true, request: req });
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
`subscribe: true` serves the 2025-era `resources/subscribe` / `unsubscribe` (not advertised on 2025-era
|
|
22
|
+
stateless HTTP, where no update can be delivered). Tools point at resources with `resourceLink(...)` or
|
|
23
|
+
`embeddedResource(...)`. `createResultSpill()` connects the tool result byte cap to resource links: pass
|
|
24
|
+
`spill.onOversize` to `registerTools` and register `spill.resource`; an oversized result is kept in memory
|
|
25
|
+
(bound to the caller's `clientId`, expiring after `ttlMs`, at most `maxEntries` and `maxTotalBytes`, oldest
|
|
26
|
+
dropped first) and the tool answers a short text plus a `resource_link`. It is per process, so a
|
|
27
|
+
multi-instance HTTP deployment needs sticky routing for the read.
|
|
28
|
+
|
|
29
|
+
**Prompts** (`@novorb/mcp-core/prompts`): `registerPrompts(server, [definePrompt({ name, argsSchema,
|
|
30
|
+
handler })], opts)` on the SDK's `registerPrompt`, with the same logging (argument shape only), masking,
|
|
31
|
+
degraded mode, `enabled` and `prefix`. A handler may return a string (one user message).
|
|
32
|
+
|
|
33
|
+
**Change notifications** (`@novorb/mcp-core/notifications`): the SDK serves 2026-07-28
|
|
34
|
+
`subscriptions/listen` streams from a `ServerEventBus`. mcp-core makes one bus process-wide
|
|
35
|
+
(`processBus()`) and wires it into every transport, so one publish reaches every connection:
|
|
36
|
+
|
|
37
|
+
```typescript
|
|
38
|
+
const notify = createNotifier(); // or ctx.notify in runMcpServer's setup, or handle.notify
|
|
39
|
+
notify.toolsChanged();
|
|
40
|
+
notify.resourceUpdated('repo://files/notes.txt');
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
| Connection | Delivery |
|
|
44
|
+
|---|---|
|
|
45
|
+
| HTTP, 2026-07-28 | `subscriptions/listen` SSE streams on this process |
|
|
46
|
+
| stdio, 2026-07-28 | the connection's open subscriptions |
|
|
47
|
+
| stdio, 2025-era | unsolicited notifications; `resources/updated` only for subscribed URIs |
|
|
48
|
+
| HTTP, 2025-era stateless | none (no channel) |
|
|
49
|
+
|
|
50
|
+
Multi-instance hosts pass a shared bus (`setProcessBus`, or `bus` on `startMcpServer`, `runMcpServer`,
|
|
51
|
+
`mountMcpServer`); the contract a shared implementation must meet is documented in `src/notifications.ts`.
|
|
52
|
+
`createRuntimeSurface(notify)` passed as `surface` to `registerTools` / `registerResources` /
|
|
53
|
+
`registerPrompts` enables and disables primitives at runtime: per-request HTTP instances read it when built,
|
|
54
|
+
live stdio instances are toggled in place, and one list-changed event goes out.
|
|
55
|
+
|
|
56
|
+
**Tasks** (`@novorb/mcp-core/task-store`, `/task-tools`): a task tool answers a task handle and runs its
|
|
57
|
+
work in this process; the client polls, waits or cancels. `registerTaskTools` serves the task methods for
|
|
58
|
+
the request's era:
|
|
59
|
+
|
|
60
|
+
| | 2025-11-25 | 2026-07-28 (`io.modelcontextprotocol/tasks` extension) |
|
|
61
|
+
|---|---|---|
|
|
62
|
+
| start | `tools/call` with `params.task` | `tools/call` from a client declaring the extension |
|
|
63
|
+
| poll | `tasks/get` | `tasks/get` (result, error or input requests inlined) |
|
|
64
|
+
| result | `tasks/result` (blocks until terminal) | inlined in `tasks/get` |
|
|
65
|
+
| list | `tasks/list` | none |
|
|
66
|
+
| cancel | `tasks/cancel` | `tasks/cancel` (idempotent) |
|
|
67
|
+
| input | not supported | `task.requireInput(...)`, answered with `tasks/update` |
|
|
68
|
+
|
|
69
|
+
```typescript
|
|
70
|
+
const store = createTaskStore(data); // in setup: FileTaskStore under DATA_PATH, memory when DATA_STORAGE=false
|
|
71
|
+
registerTaskTools(server, [
|
|
72
|
+
defineTaskTool({
|
|
73
|
+
name: 'export_report', description: 'Export a report', inputSchema: z.object({ report: z.string() }),
|
|
74
|
+
annotations: TOOL_ANNOTATIONS.mutableWriteOpenWorld,
|
|
75
|
+
run: async ({ report }, task) => {
|
|
76
|
+
for (let i = 1; i <= 10; i++) { task.signal.throwIfAborted(); await step(); await task.progress(i, 10); }
|
|
77
|
+
return toolResult({ report, rows: 42 });
|
|
78
|
+
},
|
|
79
|
+
}),
|
|
80
|
+
], { store, request: req });
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
A client without task support gets the tool run synchronously (with progress), unless `taskSupport:
|
|
84
|
+
'required'`. Tasks are bound to the creating caller's `clientId`: another caller sees "not found". The file
|
|
85
|
+
store shares tasks between processes on one host: a cancel or a `tasks/update` written by one process
|
|
86
|
+
reaches the work running in another through a status poll (`cancelPollMs`).
|
|
87
|
+
|
|
88
|
+
**Sealed requestState** (`@novorb/mcp-core/request-state`): a multi-step tool returns
|
|
89
|
+
`await ctx.continueWith(state, inputRequests?)` and reads `ctx.requestState()` on the retry. The echoed
|
|
90
|
+
state is attacker-controlled, so mcp-core seals it: AES-256-GCM under an HKDF sub-key, an expiry
|
|
91
|
+
(`MCP_REQUEST_STATE_TTL_SECONDS`, default 600), binding to the method and the caller's `clientId`, and key
|
|
92
|
+
rotation (`MCP_REQUEST_STATE_PREVIOUS_KEY_1`, ...). Pass `requestStateCodec: requestStateCodecFromEnv(env)`
|
|
93
|
+
to `createMcpServer`; it throws without `MCP_REQUEST_STATE_KEY` (at least 32 bytes, the same on every
|
|
94
|
+
instance). A refused state answers the SDK's `-32602 Invalid or expired requestState`; the reason goes to
|
|
95
|
+
the server log. Without a codec `ctx.requestState()` is always undefined and `continueWith` throws.
|
package/docs/sdk.md
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# The MCP SDK
|
|
2
|
+
|
|
3
|
+
## The SDK through mcp-core
|
|
4
|
+
|
|
5
|
+
Consumers depend on `@novorb/mcp-core` only, never on `@modelcontextprotocol/*`:
|
|
6
|
+
|
|
7
|
+
- **Recommended:** the curated `@novorb/mcp-core/server` (plus `/tools`, `/bootstrap`, `/transport`, `/testing`).
|
|
8
|
+
- **Everything else the SDK offers:** `@novorb/mcp-core/sdk/server` (= `@modelcontextprotocol/server`),
|
|
9
|
+
`/sdk/server/stdio`, `/sdk/node`, `/sdk/client` (with its HTTP, SSE and in-memory transports),
|
|
10
|
+
`/sdk/client/stdio`, `/sdk/express`. Each is a full `export *` (values and types), pinned to the SDK
|
|
11
|
+
versions mcp-core ships (`server ~2.3.1`, `node ~2.1.1`, `client ~2.3.1`, `express ~2.0.2`). There is no
|
|
12
|
+
`/sdk/core`: `@modelcontextprotocol/server` and `/client` already re-export every spec type, and
|
|
13
|
+
`@modelcontextprotocol/core` is the SDK's internal schema package.
|
|
14
|
+
- **Migration:** in consumer tests replace `import ... from '@modelcontextprotocol/client'` with
|
|
15
|
+
`'@novorb/mcp-core/sdk/client'` (and `/client/stdio` with `/sdk/client/stdio`), remove
|
|
16
|
+
`@modelcontextprotocol/*` from the consumer's `package.json`, and keep importing server symbols from
|
|
17
|
+
`@novorb/mcp-core/server`.
|
|
18
|
+
|
|
19
|
+
## SDK v2
|
|
20
|
+
|
|
21
|
+
Version 3.0.0 moves from `@modelcontextprotocol/sdk` (v1) to the SDK v2 packages.
|
|
22
|
+
|
|
23
|
+
- **Packages:** `@modelcontextprotocol/server` `~2.3.1`, `@modelcontextprotocol/node` `~2.1.1` and `@modelcontextprotocol/client` `~2.3.1` are runtime dependencies (patch-level ranges: a minor SDK release is adopted deliberately). `express` (`^4.18 || ^5`) and `@modelcontextprotocol/express` (`~2.0.2`) are optional peers, needed only for `@novorb/mcp-core/express` and `/sdk/express`; a stdio-only or `node:http` consumer installs neither. Those two subpaths load them lazily and fail at import with `ExpressAdapterUnavailableError` when either is missing. `@modelcontextprotocol/sdk` (v1) is gone.
|
|
24
|
+
- **Runtime:** Node 20.12 or newer (the env loader uses `util.parseEnv`).
|
|
25
|
+
- **zod:** consumers bring `zod` `^4.2`. mcp-core has no runtime zod dependency and never loads it; `pagedResultSchema(z, item)` takes the consumer's `z` and never imports zod: its parameter is a structural contract, so any zod 4.x namespace typechecks (no shared zod identity or tsconfig `paths` entry needed).
|
|
26
|
+
- **Import only through mcp-core:** see "The SDK through mcp-core" above.
|
|
27
|
+
- **HTTP is factory-only:** `startMcpServer` in HTTP mode requires a factory (`() => McpServer`). In SDK v2 one server instance serves one connection, so passing an instance throws. stdio accepts either form; a factory is still recommended.
|
|
28
|
+
- **Protocol eras:** the HTTP handler is `createMcpHandler(factory, { legacy: 'stateless' })` with the SDK default `responseMode: 'auto'`, adapted with `toNodeHandler`. A 2026-07-28 request gets one JSON body unless the handler emits progress or log notifications before its result, in which case the SDK upgrades that response to SSE. 2025-era clients are served statelessly (no session id) and receive SSE responses (the SDK's legacy path always streams). SDK clients handle both; a hand-rolled caller must accept `text/event-stream`.
|
|
29
|
+
- **`completable` nesting:** wrap the string first, then make it optional: `completable(z.string(), cb).optional()` returns completions. `completable(z.string().optional(), cb)` silently returns an empty list.
|
|
30
|
+
|
|
31
|
+
## Known advisories
|
|
32
|
+
|
|
33
|
+
`pnpm audit --prod` reports one moderate advisory, [GHSA-rmxm-3fg6-px4f](https://github.com/advisories/GHSA-rmxm-3fg6-px4f) in `@hono/node-server` 1.x, a dependency of `@modelcontextprotocol/node`. It concerns `serveStatic`. The SDK imports only the package's main entry and mcp-core does not use the package directly, so `serveStatic` is never loaded. The fixed release is 2.x, a major version outside the SDK's `^1.19.9` range, so it stays until the SDK moves.
|
package/docs/server.md
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# Server
|
|
2
|
+
|
|
3
|
+
Import MCP server-building primitives from here instead of the SDK directly, to centralise the MCP dependency.
|
|
4
|
+
|
|
5
|
+
```typescript
|
|
6
|
+
import { z } from 'zod';
|
|
7
|
+
import { createMcpServer, TOOL_ANNOTATIONS, completable } from '@novorb/mcp-core/server';
|
|
8
|
+
import type { McpToolAnnotations, McpCallToolResult } from '@novorb/mcp-core/server';
|
|
9
|
+
import { startMcpServer } from '@novorb/mcp-core/transport';
|
|
10
|
+
|
|
11
|
+
function buildServer() {
|
|
12
|
+
const server = createMcpServer('my-mcp', '1.0.0');
|
|
13
|
+
|
|
14
|
+
server.registerTool('lookup', {
|
|
15
|
+
description: 'Look up a value',
|
|
16
|
+
inputSchema: z.object({ key: z.string() }),
|
|
17
|
+
outputSchema: z.object({ value: z.string(), found: z.boolean() }),
|
|
18
|
+
annotations: TOOL_ANNOTATIONS.readOnly,
|
|
19
|
+
}, async ({ key }) => ({
|
|
20
|
+
content: [{ type: 'text', text: key }],
|
|
21
|
+
structuredContent: { value: key, found: true },
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
return server;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
await startMcpServer(buildServer);
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Prompt arguments that offer completions nest `completable` inside the optional: `completable(z.string(), cb).optional()`.
|
|
31
|
+
|
|
32
|
+
`createMcpServer(info, options?)` takes the full `Implementation` (`name`, `version`, `title`,
|
|
33
|
+
`description`, `websiteUrl`, `icons`) or the `(name, version, options?)` shorthand, and the SDK's
|
|
34
|
+
`McpServerOptions` (`instructions`, `capabilities`, `cacheHints`, `maxToolInputElements`,
|
|
35
|
+
`inputRequired`, `requestState`, ...) passed through, plus mcp-core's own:
|
|
36
|
+
|
|
37
|
+
| Option | Default | Effect |
|
|
38
|
+
|--------|---------|--------|
|
|
39
|
+
| `cacheHints` | `{ 'tools/list': { ttlMs: 300000, cacheScope: 'private' } }` (`DEFAULT_CACHE_HINTS`), merged under yours | 2026-07-28 clients may cache the tool list for 5 minutes (private: lists may differ per caller) |
|
|
40
|
+
| `maxToolInputElements` | `MCP_MAX_TOOL_INPUT_ELEMENTS`, else 10000 | Array elements + object members per `tools/call` arguments payload (SDK check, isError beyond it) |
|
|
41
|
+
| `listPageSize` | `MCP_LIST_PAGE_SIZE`, else 100; 0 disables | Page size of `tools/list`, `prompts/list`, `resources/list`, `resources/templates/list` |
|
|
42
|
+
| `protocolLogging` | `LOG_PROTOCOL`, else false | Advertise `logging` and forward log entries as MCP notifications |
|
|
43
|
+
| `requestStateCodec` | none | The codec for multi-step `requestState` (`requestStateCodecFromEnv()`); installed as `requestState.verify` and used by `ToolContext.continueWith` / `requestState()`. Pass this or `requestState.verify`, not both |
|
|
44
|
+
|
|
45
|
+
`runMcpServer({ server: { title, websiteUrl, icons, instructions, ... } })` hands the same options to
|
|
46
|
+
`ctx.createServer()` (with the package `name`, `version` and `description`).
|
|
47
|
+
|
|
48
|
+
## List pagination
|
|
49
|
+
|
|
50
|
+
SDK v2's `McpServer` answers `tools/list` in one result with no cursor, and has no extension point for
|
|
51
|
+
it (its list handlers are installed by private methods). mcp-core pages it on the documented low-level
|
|
52
|
+
hook instead of forking McpServer: `paginateLists(server.server, pageSize)` decorates the instance's
|
|
53
|
+
public `Server.setRequestHandler`, so the list handler the SDK installs (lazily, on the first
|
|
54
|
+
registration) is wrapped; the wrapper calls it unchanged and returns one page plus `nextCursor`. The
|
|
55
|
+
list content stays the SDK's.
|
|
56
|
+
|
|
57
|
+
- On by default at 100 per page, so a typical server fits on one page and nothing changes for it; raise
|
|
58
|
+
or lower it for very large surfaces.
|
|
59
|
+
- Cursors are bound to the method (`{ l: 'tools/list', o: offset }`, brief B's `encodeCursor`); a
|
|
60
|
+
malformed, foreign-method or out-of-range cursor answers JSON-RPC `-32602`.
|
|
61
|
+
- Clients: the SDK v2 client's `listTools()` (no cursor) follows `nextCursor` up to `listMaxPages`
|
|
62
|
+
(default 64) in both eras (tested in memory and over HTTP); `listTools({ cursor })` returns one page.
|
|
63
|
+
2025-era clients are expected to follow `nextCursor` by the spec (every list method has had
|
|
64
|
+
`cursor` / `nextCursor` since 2024-11-05); a client that ignores it sees only the first 100.
|
|
65
|
+
- A low-level `Server` can call `paginateLists(server, n)` before its `setRequestHandler('tools/list', ...)`.
|
|
66
|
+
A handler that pages a list itself must not be combined with it (`listPageSize: 0`).
|
|
67
|
+
|
|
68
|
+
**`TOOL_ANNOTATIONS` presets:** `readOnly` (lookups/searches), `idempotentWrite` (upserts), `mutableWrite` (non-idempotent writes), `destructive` (deletes/overwrites), and the same four with `openWorldHint: true` (`readOnlyOpenWorld`, `idempotentWriteOpenWorld`, `mutableWriteOpenWorld`, `destructiveOpenWorld`) for tools that reach an external API.
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# Testing your server
|
|
2
|
+
|
|
3
|
+
## Testing harness (`@novorb/mcp-core/testing`)
|
|
4
|
+
|
|
5
|
+
A separate subpath for consumer test suites. Nothing in the runtime entry points imports it. It uses
|
|
6
|
+
`@modelcontextprotocol/client`, now a regular dependency of mcp-core: consumers need no SDK package of
|
|
7
|
+
their own and import client symbols from `@novorb/mcp-core/sdk/client` (drop a direct
|
|
8
|
+
`@modelcontextprotocol/client` devDependency when migrating). It is test-runner agnostic: helpers
|
|
9
|
+
return objects and throw `Error`s, nothing imports jest or vitest. Pure Node, Windows-safe (no shell,
|
|
10
|
+
`node` spawned directly via `process.execPath`, kill-and-await teardown).
|
|
11
|
+
|
|
12
|
+
Two example suites ship in the package under `examples/testing/` (in `files`), and mcp-core runs both
|
|
13
|
+
against its own echo fixture in its own test run, so they cannot rot:
|
|
14
|
+
|
|
15
|
+
- `tools.unit.test.example.ts`: in-process unit suite (case table over the in-memory client, coverage
|
|
16
|
+
assertion, schema rejection, unknown tool, not-found, recording-server handler calls).
|
|
17
|
+
- `tools.integration.test.example.ts`: integration suite over the BUILT server, `describe.each` over
|
|
18
|
+
stdio and http, TEST vs LIVE gating with `[SKIP]`, costly opt-in, create-then-cleanup with a prefix
|
|
19
|
+
sweep, log-file assertions and the adverse cases (invalid args, unknown tool, not-found, wrong
|
|
20
|
+
credential, 401, 413, 415).
|
|
21
|
+
|
|
22
|
+
Copy one to your server's `tests/` (drop `.example`), edit its "SERVER UNDER TEST" block and its case
|
|
23
|
+
table.
|
|
24
|
+
|
|
25
|
+
### Integration API
|
|
26
|
+
|
|
27
|
+
| Function | Returns | Notes |
|
|
28
|
+
|---|---|---|
|
|
29
|
+
| `startBuiltServer({ entry, mode, env?, cwd?, apiKey?, readyTimeoutMs?, inheritEnv?, nodeArgs?, port?, envFile?, era?, clientInfo?, clientOptions? })` | `{ client, mode, url?, baseUrl?, apiKey?, env, pid?, stderr(), stdout(), stop() }` | Spawns `node [nodeArgs] <entry>`. The child env is EXPLICIT: the SDK's minimal OS list (PATH, SystemRoot, TEMP, ...), then `env`, then the harness vars (`inheritEnv: true` passes the parent env instead). http sets `MCP_TRANSPORT=http`, `MCP_HTTP_HOST=127.0.0.1`, a free `PORT`, `MCP_API_KEY` (= `apiKey`, else `env.MCP_API_KEY`, else random hex; `apiKey: null` starts open with `MCP_AUTH_DISABLED=true`), polls `GET /health` (no fixed sleeps), connects `StreamableHTTPClientTransport` with the Bearer header. stdio uses `StdioClientTransport`. `envFile` sets `MCP_ENV_FILE` for servers that load env through mcp-core's env loader. Startup failures throw with the child's stderr tail. `stop()` closes the client and kills and awaits the child; idempotent, never throws |
|
|
30
|
+
| `createIsolatedDirs(prefix?)` | `{ root, cwd, home, logs, state, tmp, dir(name), env(), cleanup() }` | One temp root; `env()` gives HOME, USERPROFILE, APPDATA, LOCALAPPDATA, XDG_*, TMPDIR/TEMP/TMP, LOG_PATH. `cleanup()` retries on Windows locks, idempotent |
|
|
31
|
+
| `readLogFiles(logDir, serverName, { mainFile? })` | `{ main, exceptions, debug, exists, paths }` | `<name>.log` (sanitised like the logger), `exceptions.log`, `debug.log`; absent = `''` + `exists: false` |
|
|
32
|
+
| `logLevels(text)` | `string[]` | Level of each line, text or JSON format (`UNKNOWN` otherwise) |
|
|
33
|
+
| `findLeaks(texts, needles, { minLength? })` | `Array<{ index, preview, foundIn }>` | Needles found verbatim; the preview is masked (`ab***(len=12)`), never the value. Needles under 6 chars (`MIN_LEAK_NEEDLE_LENGTH`) and duplicates are ignored |
|
|
34
|
+
| `collectStrings(value)` | `string[]` | Every nested string value (tool arguments as leak needles) |
|
|
35
|
+
| `callToolJson(client, name, args?)` | `{ isError, body, text, raw }` | `body` = `structuredContent`, else the first text block parsed as JSON, else that text |
|
|
36
|
+
| `listToolNames(client)` | `string[]` | All pages |
|
|
37
|
+
| `captureToolWire(client)` | `ToolWire[]` | Every `tools/list` page reduced to name, title, description, inputSchema, outputSchema, annotations, execution; sorted by name; plain JSON for fixture snapshots |
|
|
38
|
+
| `toolCoverage(actual, cases)` / `assertToolCoverage(actual, cases)` | `{ missing, stale, duplicates, ok }` | The assert form throws listing tools without a case and stale cases |
|
|
39
|
+
| `rawHttp(url, { method?, headers?, body?, timeoutMs? })` | `{ status, headers, text }` | For 401/404/413/415 checks; `mcpPostHeaders(apiKey?)` and `initializeRequestBody()` build a valid POST |
|
|
40
|
+
|
|
41
|
+
### Unit API
|
|
42
|
+
|
|
43
|
+
| Function | Returns | Notes |
|
|
44
|
+
|---|---|---|
|
|
45
|
+
| `connectInMemory(factory, { era?, onServerError? })` | `{ client, close() }` | SDK in-memory transport pair; the server side is the SDK's `serveStdio` connection owner, so both protocol eras work |
|
|
46
|
+
| `withInMemoryClient(factory, fn)` / `callToolInMemory(factory, name, args?)` | `fn`'s result / `ToolJsonResult` | Connect, run, always close |
|
|
47
|
+
| `captureToolCall(client, name, args?)` | `{ kind: 'result', result }` or `{ kind: 'protocol-error', code, message }` | A thrown protocol error as data |
|
|
48
|
+
| `expectToolSuccess(result)` / `expectToolError(result, pattern?)` | body / error text | Throw on the wrong outcome |
|
|
49
|
+
| `expectSchemaRejection(client, name, args)` | `{ via, message }` | McpServer answers bad args with an isError `Input validation error:` result; an InvalidParams (-32602) protocol error also counts |
|
|
50
|
+
| `expectUnknownTool(client, name)` | `{ via, message }` | McpServer throws -32602 `Tool <name> not found`; an isError "unknown/not found" result also counts |
|
|
51
|
+
| `makeRecordingServer()` | `{ tools, prompts, asMcpServer() }` | Records `registerTool` / `registerPrompt` so handlers can be called directly (no protocol, no schema validation) |
|
|
52
|
+
| `createTestClient({ era?, clientInfo?, clientOptions? })` | `Client` | `era: 'modern'` negotiates 2026-07-28 |
|
|
53
|
+
|
|
54
|
+
### Example: a consumer per-tool integration suite over both modes
|
|
55
|
+
|
|
56
|
+
```typescript
|
|
57
|
+
import { fileURLToPath } from 'node:url';
|
|
58
|
+
import { afterAll, beforeAll, describe, expect, it } from '@jest/globals';
|
|
59
|
+
import {
|
|
60
|
+
assertToolCoverage, type BuiltServer, callToolJson, collectStrings, createIsolatedDirs, expectSchemaRejection,
|
|
61
|
+
expectToolError, expectUnknownTool, findLeaks, type HarnessMode, type IsolatedDirs, initializeRequestBody,
|
|
62
|
+
listToolNames, logLevels, mcpPostHeaders, rawHttp, readLogFiles, startBuiltServer,
|
|
63
|
+
} from '@novorb/mcp-core/testing';
|
|
64
|
+
|
|
65
|
+
const ENTRY = fileURLToPath(new URL('../../dist/index.js', import.meta.url));
|
|
66
|
+
const CREDS = { MY_API_TOKEN: process.env.MY_API_TOKEN ?? '' }; // every provider var, '' when absent
|
|
67
|
+
const RUN_ID = `mcpit-${Date.now().toString(36)}`;
|
|
68
|
+
|
|
69
|
+
describe.each<HarnessMode>(['stdio', 'http'])('my-mcp over %s', (mode) => {
|
|
70
|
+
let dirs: IsolatedDirs;
|
|
71
|
+
let srv: BuiltServer;
|
|
72
|
+
const sent: string[] = [];
|
|
73
|
+
|
|
74
|
+
beforeAll(async () => {
|
|
75
|
+
dirs = createIsolatedDirs(`my-mcp-${mode}`);
|
|
76
|
+
srv = await startBuiltServer({
|
|
77
|
+
entry: ENTRY, mode, cwd: dirs.cwd, envFile: 'none',
|
|
78
|
+
env: { ...dirs.env(), ...CREDS, DATA_PATH: dirs.state, LOG_LEVEL: 'DEBUG', LOG_CONSOLE: 'false' },
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
afterAll(async () => { await srv?.stop(); dirs?.cleanup(); });
|
|
82
|
+
|
|
83
|
+
const cases: Record<string, () => Promise<void>> = {
|
|
84
|
+
my_lookup: async () => {
|
|
85
|
+
const args = { key: `${RUN_ID}-k` };
|
|
86
|
+
collectStrings(args, sent);
|
|
87
|
+
const r = await callToolJson<{ found: boolean }>(srv.client, 'my_lookup', args);
|
|
88
|
+
expect(r.isError).toBe(false);
|
|
89
|
+
expect(r.body.found).toBe(false);
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
it('one case per tool', async () => {
|
|
94
|
+
assertToolCoverage(await listToolNames(srv.client), Object.keys(cases));
|
|
95
|
+
});
|
|
96
|
+
for (const [tool, run] of Object.entries(cases)) it(tool, run);
|
|
97
|
+
|
|
98
|
+
it('adverse', async () => {
|
|
99
|
+
await expectSchemaRejection(srv.client, 'my_lookup', { key: 42 });
|
|
100
|
+
await expectUnknownTool(srv.client, 'no_such_tool');
|
|
101
|
+
if (mode === 'http' && srv.url) {
|
|
102
|
+
expect((await rawHttp(srv.url, { headers: mcpPostHeaders(), body: initializeRequestBody() })).status).toBe(401);
|
|
103
|
+
const big = await rawHttp(srv.url, { headers: mcpPostHeaders(srv.apiKey), body: 'x'.repeat(5 * 1024 * 1024) });
|
|
104
|
+
expect(big.status).toBe(413);
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('logs', async () => {
|
|
109
|
+
await srv.stop();
|
|
110
|
+
const logs = readLogFiles(dirs.logs, 'my-mcp');
|
|
111
|
+
expect(logs.main).toContain('Tool call: my_lookup');
|
|
112
|
+
expect(new Set(logLevels(logs.debug))).toEqual(new Set(['DEBUG']));
|
|
113
|
+
expect(findLeaks([logs.main, logs.exceptions, logs.debug], [...Object.values(CREDS), srv.apiKey ?? '', ...sent])).toEqual([]);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
The full version (gating, `[SKIP]`, create-then-cleanup, wrong credential, 415, switched-off sinks) is
|
|
119
|
+
`examples/testing/tools.integration.test.example.ts`.
|
|
120
|
+
|
|
121
|
+
## `mcp-core-jest` for consumers
|
|
122
|
+
|
|
123
|
+
ESM jest suites need node started with `--experimental-vm-modules`; the usual
|
|
124
|
+
`NODE_OPTIONS=... jest` script is POSIX-only. mcp-core ships a bin that does it portably:
|
|
125
|
+
|
|
126
|
+
```json
|
|
127
|
+
{
|
|
128
|
+
"scripts": {
|
|
129
|
+
"test:unit": "mcp-core-jest tests/unit",
|
|
130
|
+
"test:integration": "mcp-core-jest tests/integration --runInBand --coverage=false"
|
|
131
|
+
},
|
|
132
|
+
"devDependencies": { "jest": "^30.3.0", "ts-jest": "^29.4.9" }
|
|
133
|
+
}
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
It resolves the CONSUMER's own jest from the current directory (walking up, so a hoisted jest works),
|
|
137
|
+
passes every argument through unchanged, exits with jest's exit code, forwards SIGINT/SIGTERM/SIGHUP
|
|
138
|
+
to jest and ends with the signal that ended jest. mcp-core has no runtime dependency on jest: jest stays
|
|
139
|
+
the consumer's devDependency (without one the bin exits 1 with a message).
|
package/docs/testing.md
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# Testing
|
|
2
|
+
|
|
3
|
+
## Commands
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pnpm test # all suites, including examples/testing/*.example.ts against the echo fixture
|
|
7
|
+
pnpm run test:watch # watch mode
|
|
8
|
+
pnpm run test:coverage
|
|
9
|
+
pnpm run docs:check # documentation freshness
|
|
10
|
+
pnpm run qa:all # full QA matrix
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
The scripts run jest through the package's own `mcp-core-jest` bin, so they work in PowerShell, cmd and
|
|
14
|
+
POSIX shells alike.
|
|
15
|
+
|
|
16
|
+
## Overview
|
|
17
|
+
|
|
18
|
+
mcp-core uses Jest with ESM support. Each source module has a corresponding test file:
|
|
19
|
+
|
|
20
|
+
| Source | Test | Focus |
|
|
21
|
+
|--------|------|-------|
|
|
22
|
+
| `src/utils/logger.ts` | `tests/utils/logger.test.ts` | every env switch + config override, level-to-file routing, file names, `.logs` / `LOG_PATH` / `LOG_FILE` resolution, the no-directory warning (never cwd), required `name`, reconfigure-in-place, per-file rotation + sink isolation, sync/async EPIPE, `captureProcessErrors` (re-entrancy, EPIPE no-loop), formats, masking end to end, tool-helper line text + levels, `summarizeArgs` carries no values, `packageRootFrom`. Asserts after EVERY test that stdout was never written |
|
|
23
|
+
| `src/utils/redact.ts` | `tests/utils/redact.test.ts` | **secret redaction** (every key rule, every query-param rule, `value`/`content` kept) and **PII masking** (every pattern positive, pinned negatives: timestamps, versions, plain integers, ...) |
|
|
24
|
+
| `src/http-client.ts` | `tests/http-client.test.ts` | against a real `node:http` server on 127.0.0.1 (no axios mock): the fail-closed route-template path logging (the review examples, tag names, variable keys and file names never in a log line or `HttpError.path`; client `routes`), the `credential` option recording (status-gated, undeclared names refused before sending), verbs, responseTypes, `validateStatus`, every auth strategy + masking, URL joining + origin guard, idempotent-only retry, Retry-After + cap, timeout, ECONNREFUSED, abort, no redirect follow, size caps, errors without response/config/headers, `mapError`, log meta, rate limit, `paginate` |
|
|
25
|
+
| `src/rate-limiter.ts` | `tests/rate-limiter.test.ts` | burst + refill (injected clock), FIFO waiting, aborted waiters |
|
|
26
|
+
| `src/transport.ts` | `tests/transport.test.ts` | stdio mode/options; **fail-closed** auth + **constant-time** Bearer compare; `createRequestHandler` routing (health/401/415/413/404/500 + /mcp dispatch); mocks `createMcpHandler` (the rest of `@modelcontextprotocol/server` is its real CommonJS build, loaded with `createRequire`), `@modelcontextprotocol/server/stdio` and `@modelcontextprotocol/node` |
|
|
27
|
+
| `src/transport.ts` | `tests/transport.roundtrip.test.ts` | real `@modelcontextprotocol/client` round-trips over stdio (child process) and HTTP, in both protocol eras; real Host/Origin allow-list semantics |
|
|
28
|
+
| `src/server.ts` | `tests/server.test.ts` | `createMcpServer` factory, `TOOL_ANNOTATIONS` presets, SDK re-exports, `completable` nesting pins |
|
|
29
|
+
| `src/tools.ts` | `tests/tools.test.ts` | `registerTools` over the real SDK in memory: listing, central call/result lines, SDK schema rejection + unknown tool, thrown errors / schema issues / `mapError`, legacy `{ success: false }` as isError, degraded mode, `enabled`/`prefix`, `ToolContext` (signal, cursor, authInfo, progress) |
|
|
30
|
+
| `src/bootstrap.ts` | `tests/bootstrap.test.ts` | `runMcpServer` in-process over HTTP: order + one started line after bind, shared state once across many requests, close/dispose order, signal close + exit, failed start disposes, `.env` loading, `readPackageInfo` |
|
|
31
|
+
| `src/credentials.ts` | `tests/credentials.test.ts` | registry: pools, rotation (valid, unknown, degraded, invalid/expired last), degraded back to unknown after the retry-after (injected clock), background re-check probes restoring invalid keys, undeclared names refused without echoing them, outcomes, transitions logged once, masked bounded reasons, snapshot; `credentialOutcomeFromHttp` (every status-gated mapping incl. plain 403 and 400/413/422 left alone, GitLab's expired token via `error_description` and the `WWW-Authenticate` parameters) and `credentialOutcomeFromError`; the call scope (report, `require`, `withFallback`, the AsyncLocalStorage scope) |
|
|
32
|
+
| `src/credential-checks.ts` | `tests/credential-checks.test.ts` | which env keys are credentials, multi members checked on their own in parallel, valid/invalid/degraded results, thrown `HttpError` classified, timeout -> unknown (signal aborted), the env timeout, `MCP_CREDENTIAL_CHECKS=false`, a real `ctx.http` probe, values never logged |
|
|
33
|
+
| `src/credential-report.ts`, `src/credential-status-tool.ts` | `tests/credential-report.test.ts`, `tests/tools.test.ts`, `tests/bootstrap.test.ts` | the output schema wrapper (strict schemas, async validators, own `credentials` field), the report in text + `_meta` + `structuredContent`, the all-invalid isError, automatic recording through the http-client inside a call, the server starting when every check fails, the status tool |
|
|
34
|
+
| `bin/mcp-core-jest.mjs` | `tests/jest-runner.test.ts` | the flag, argument passthrough, exit code, hoisted jest, missing jest; signal exit, SIGTERM forwarded, SIGINT not forwarded (POSIX only) |
|
|
35
|
+
| `src/utils/envLoader.ts` | `tests/utils/envLoader.test.ts` | precedence, `MCP_ENV_FILE` (relative/absolute/none/missing), multi ordering, validation by key, strict, `describe()` without values, unknown-key WARN, secrets masked in logs, rendered table / `.env.example` |
|
|
36
|
+
| `src/utils/dataStore.ts` | `tests/utils/dataStore.test.ts` | `renameWithRetry` (win32 EPERM/EACCES/EBUSY retried within the budget, other platforms and codes not), default/`DATA_PATH` dir, lazy creation, `DATA_STORAGE=false` writes nothing, in-memory cache, every traversal form (incl. Windows `..\`, drive letters, junction escape), `safeName`, download caps against a local server (`allowPrivate`), inline bytes when disabled, the SSRF guard on URL sources by default (private literal / name refused without connecting, a redirect hop re-validated, origins, content types) |
|
|
37
|
+
| `src/net.ts` | `tests/net.test.ts` (+ child `tests/fixtures/net-proxy-child.ts`, run with `node --import tsx`) | **SSRF guard**: every refused / allowed address range, the URL policy, the DNS seam, redirects refused / capped / re-validated / headers dropped across origins, size, time and abort (head and body), content types, https lookup, `createGuardedAgent`, and NODE_USE_ENV_PROXY + HTTP(S)_PROXY with a recording proxy (refused, the proxy sees nothing) |
|
|
38
|
+
| `src/url-path.ts` | `tests/url-path.test.ts`, `tests/tools.test.ts` | one-segment encoding, empty / `.` / `..` and every percent-encoded, overlong (`%c0%ae`) and NFKC unicode dot form, C0/C1 controls and U+2028/U+2029 raw and encoded, the message naming the argument and never the value; the isError through `registerTools` whatever `mapError` does |
|
|
39
|
+
| `src/retry.ts` | `tests/retry.test.ts` | pinned retryable statuses + network codes, full-jitter bounds, Retry-After seconds/date/cap, bounded attempts, abort |
|
|
40
|
+
| `src/pagination.ts` | `tests/pagination.test.ts` | page-number helpers, every strategy against recorded shapes + stop conditions, page/item caps, loop guard, cursors + bad-cursor errors, `pagedResultSchema` |
|
|
41
|
+
| `src/testing/server-process.ts` | `tests/testing/server-process.test.ts` | real stdio + http starts of the fixture in isolated dirs: explicit env (parent-only vars absent, `inheritEnv`), apiKey given / from env / random / `null` (open), 401/404/413 (custom and default cap)/415, stop idempotency, startup failures carrying stderr (exit, timeout, bad node flag), log files written by the real logger and scanned with `findLeaks`, a switched-off sink, `killAndWait`, `freePort`, `minimalOsEnv` |
|
|
42
|
+
| `src/testing/logs.ts` | `tests/testing/logs.test.ts` | `readLogFiles` with the real logger (routing, JSON format, absent files, `mainFile`), `logFileName`, `logLevels`, `findLeaks` (masking, min length, duplicates), `collectStrings` |
|
|
43
|
+
| `src/testing/tools.ts`, `src/testing/unit.ts` | `tests/testing/tools.test.ts` | `callToolJson` body decoding, `listToolNames`, coverage helpers, every unit helper, the pinned SDK v2 outcomes (schema rejection = isError result, unknown tool = thrown -32602), a low-level `Server` answering with protocol errors |
|
|
44
|
+
| `src/testing/{isolated-dirs,raw-http,recording-server,in-memory,client}.ts` | `tests/testing/harness-misc.test.ts` | temp-dir layout + env + cleanup, `rawHttp` against a local server, recording server, in-memory connect in both eras |
|
|
45
|
+
| `src/auth.ts`, `src/transport.ts` | `tests/transport.test.ts`, `tests/protocol-features.test.ts` | the SDK Bearer gate (401/403 challenge, `req.auth` placeholder, verifier, scopes), `requireScopes` end to end over HTTP in both eras |
|
|
46
|
+
| `src/express.ts` | `tests/express.test.ts` | a real Express app: round trip in both eras, app-level `express.json()`, health/401/403/405/413/415/400, Host/Origin, verifier + required scopes, fail-closed, custom path |
|
|
47
|
+
| `src/list-pagination.ts`, `src/server.ts` | `tests/list-pagination.test.ts` | `tools/list` paging both eras in memory and over HTTP, cursor errors -32602, defaults and env, low-level `Server`, `createMcpServer` Implementation/instructions/capabilities/cache hints/`maxToolInputElements` |
|
|
48
|
+
| `src/result-limits.ts`, `src/limits.ts` | `tests/result-limits.test.ts` | item and byte caps both eras, client `limit`, self-paging handlers, cursor round trip + tampering (other args, other tool, garbage, negative offset), byte-cap isError, `onOversize`, overrides |
|
|
49
|
+
| `src/client-input.ts` | `tests/client-input.test.ts` | elicit/sample/listRoots through MRTR (2026) and the legacy shim (2025), decline/cancel/invalid, unsupported clients, stateless HTTP (no capabilities on 2025) |
|
|
50
|
+
| `src/protocol-logging.ts` | `tests/protocol-features.test.ts` | protocol logging per era (setLevel on 2025, `_meta` log level on 2026, stateless HTTP), masking, off by default, `LOG_PROTOCOL`; progress over HTTP in both eras |
|
|
51
|
+
| `src/sdk/*` | `tests/sdk-subpaths.test.ts` | the SDK re-export subpaths reach their entry points |
|
|
52
|
+
| `src/resources.ts`, `src/notifications.ts` | `tests/resources.test.ts` | static resources and templates (completion, list), the read cap, degraded mode, masked errors, shorthand results, subscriptions per era, `subscriptions/listen` over HTTP, the runtime surface (no subscription outlives its HTTP request, both eras), the result spill (owner binding, expiry, entry and total byte budgets) |
|
|
53
|
+
| `src/prompts.ts`, `src/protocol-errors.ts` | `tests/prompts.test.ts` | listing, get, completion, error mapping, degraded mode, the runtime surface |
|
|
54
|
+
| `src/request-state.ts` | `tests/request-state.test.ts` | seal/open, tamper, expiry, caller and method binding, key rotation, missing or short keys, `continueWith` / `requestState()` round trip |
|
|
55
|
+
| `src/task-store.ts` | `tests/task-store.test.ts` | both stores: create/settle, principal isolation, cooperative cancel, input rounds, TTL and paging; file store across instances (cross-process cancel and input answers, never a previous round's) |
|
|
56
|
+
| `src/task-tools.ts`, `src/task-request.ts`, `src/testing/raw-peer.ts` | `tests/tasks.test.ts` | the 2026-07-28 extension over raw JSON-RPC (run, cancel, `input_required` + `tasks/update`, errors), the 2025-11-25 methods (`tasks/result`, list, status notifications), sync fallback, per-caller isolation over HTTP, a failing store write while settling (logged, no unhandled rejection) |
|
|
57
|
+
| (feature round trip) | `tests/features.roundtrip.test.ts` | resources, prompts and tasks end to end through the `@modelcontextprotocol/ext-tasks` requester |
|
|
58
|
+
| (consumer examples) | `examples/testing/tools.unit.test.example.ts`, `examples/testing/tools.integration.test.example.ts` | the shipped example suites, run against the echo fixture so they cannot rot |
|
|
59
|
+
|
|
60
|
+
`src/index.ts` and `src/testing/index.ts` are pure re-exports (no logic) and are excluded from coverage.
|
|
61
|
+
|
|
62
|
+
`tests/fixtures/echo-tools.ts` builds the fixture server (`echo`, `echo_json`, `boom`, `upstream_status`,
|
|
63
|
+
`paid_lookup`, `note_create`, `note_list`, `note_delete`; defined with `defineTool` and registered with
|
|
64
|
+
`registerTools`, notes in a data-store `jsonCache`) shared by the round-trip, harness and example suites;
|
|
65
|
+
`tests/fixtures/echo-server.ts` runs it on `runMcpServer` (package root `tests/fixtures/`, its
|
|
66
|
+
`package.json` names it `echo-fixture`) and is spawned as a child process with the tsx loader
|
|
67
|
+
(`--import <file URL of tsx>`). `tests/fixtures/crash-server.ts` exits at startup.
|
|
68
|
+
`tests/fixtures/feature-tools.ts` builds the resources/prompts/tasks fixture (`buildFeatureServer` over a
|
|
69
|
+
`FeatureShared` state), `tests/fixtures/job-tools.ts` the task tools, and `tests/fixtures/feature-server.ts`
|
|
70
|
+
runs that fixture on `runMcpServer`. `tests/setup-env.ts`
|
|
71
|
+
(jest `setupFiles`) stands in for the credential the integration example reads.
|
|
72
|
+
|
|
73
|
+
The example suites import `@novorb/mcp-core/testing` by package name; jest maps it to `src/`
|
|
74
|
+
(`moduleNameMapper`), and `tsconfig.examples.json` (`paths`) typechecks them with the harness tests and
|
|
75
|
+
fixtures (`pnpm run typecheck`).
|
|
76
|
+
|
|
77
|
+
## Coverage
|
|
78
|
+
|
|
79
|
+
Coverage is enforced at **85%** (statements / branches / functions / lines) globally via
|
|
80
|
+
`coverageThreshold` in `jest.config.js`; `collectCoverageFrom` instruments every `src/**` module so an
|
|
81
|
+
untested file counts against the threshold (except the pure re-exports: `src/index.ts`,
|
|
82
|
+
`src/testing/index.ts`, `src/sdk/*`). `jest --coverage` fails the run if coverage drops below it.
|
|
83
|
+
|
|
84
|
+
Current coverage is 96.75 / 92.23 / 94.69 / 98.04 (statements / branches / functions / lines).
|
|
85
|
+
`transport.ts`'s `startHttp` is exercised by the round-trip suite, which binds a free loopback port
|
|
86
|
+
and shuts down through the server's own SIGINT listener; the request logic is also directly testable
|
|
87
|
+
through the exported `createRequestHandler`, driven with mock `req`/`res`.
|
|
88
|
+
|
|
89
|
+
## Running tests
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
# All tests (the same command in PowerShell, cmd and POSIX shells)
|
|
93
|
+
node bin/mcp-core-jest.mjs
|
|
94
|
+
|
|
95
|
+
# Single module
|
|
96
|
+
node bin/mcp-core-jest.mjs tests/utils/logger.test.ts
|
|
97
|
+
|
|
98
|
+
# Watch mode
|
|
99
|
+
node bin/mcp-core-jest.mjs --watch
|
|
100
|
+
|
|
101
|
+
# Coverage (the 85% gate)
|
|
102
|
+
node bin/mcp-core-jest.mjs --runInBand --coverage
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
`pnpm test` / `pnpm run test:coverage` run the same launcher. `bin/mcp-core-jest.mjs` starts node with
|
|
106
|
+
`--experimental-vm-modules` and runs the jest resolved from the current directory, so it works without
|
|
107
|
+
the POSIX-only `NODE_OPTIONS=... jest` syntax.
|
|
108
|
+
|
|
109
|
+
## The `mcp-core-jest` runner for consumers
|
|
110
|
+
|
|
111
|
+
The launcher ships as the package bin `mcp-core-jest`. A consumer calls it from its own scripts:
|
|
112
|
+
|
|
113
|
+
```json
|
|
114
|
+
"test:unit": "mcp-core-jest tests/unit",
|
|
115
|
+
"test:integration": "mcp-core-jest tests/integration --runInBand --coverage=false"
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
It resolves the consumer's jest from the consumer's cwd (jest stays the consumer's devDependency;
|
|
119
|
+
mcp-core never depends on it at runtime), passes every argument through, exits with jest's exit code,
|
|
120
|
+
forwards SIGINT/SIGTERM/SIGHUP and ends with the signal that ended jest. Without a jest it exits 1 with
|
|
121
|
+
a message. Pinned by `tests/jest-runner.test.ts`.
|
|
122
|
+
|
|
123
|
+
## Testing harness for consumers
|
|
124
|
+
|
|
125
|
+
`@novorb/mcp-core/testing` is the harness the MCP servers build their own suites on (API in
|
|
126
|
+
[testing-harness.md](testing-harness.md), rules in CLAUDE.md "Testing harness"). Start from the two shipped examples:
|
|
127
|
+
|
|
128
|
+
- `examples/testing/tools.unit.test.example.ts`: in memory, no child process, no network.
|
|
129
|
+
- `examples/testing/tools.integration.test.example.ts`: the built server over stdio and http, explicit
|
|
130
|
+
env, temp dirs, `MCP_IT_TARGET=test|live`, `MCP_IT_ALLOW_COSTLY=true`, `[SKIP]` lines, log and leak
|
|
131
|
+
assertions, adverse cases.
|
|
132
|
+
|
|
133
|
+
Copy, drop `.example`, edit the "SERVER UNDER TEST" block and the case table.
|
|
134
|
+
|
|
135
|
+
## Conventions
|
|
136
|
+
|
|
137
|
+
- Import jest functions explicitly: `import { describe, it, expect, jest, beforeEach } from '@jest/globals'`
|
|
138
|
+
- Use `jest.fn<any>()` for mocks to avoid TS2345 under strict mode
|
|
139
|
+
- Tests must not depend on network access beyond loopback: mock outbound HTTP; the harness tests bind
|
|
140
|
+
`127.0.0.1` only
|
|
141
|
+
- Temporary files (e.g. log rotation tests) go in `tests/.tmp-logs/` (gitignored)
|
|
142
|
+
- **Mock ESM dependencies with `jest.unstable_mockModule(...)` + a dynamic `await import(...)` of the
|
|
143
|
+
module under test** (the classic `jest.mock` + static import does NOT intercept native ESM; the
|
|
144
|
+
real module loads instead). See `transport.test.ts` for the pattern. Prefer a real loopback server
|
|
145
|
+
over a mock when the behaviour lives in the dependency (`http-client.test.ts` drives real axios).
|
|
146
|
+
|
|
147
|
+
## Adding tests for new modules
|
|
148
|
+
|
|
149
|
+
1. Create `tests/<module-name>.test.ts`
|
|
150
|
+
2. Import from the source (not from `dist/`)
|
|
151
|
+
3. Cover the public API: every exported function needs at least one happy-path and one error-path test
|