@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
package/docs/tools.md
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# Tools
|
|
2
|
+
|
|
3
|
+
- `registerTools(server, tools, { env?, enabled?, prefix?, serverName?, mapError? })` registers every
|
|
4
|
+
definition through `McpServer.registerTool`: the SDK builds `tools/list`, validates input (a failure is
|
|
5
|
+
an isError result `Input validation error: ... <field>: <message>`), validates `structuredContent`
|
|
6
|
+
against `outputSchema`, and answers an unknown tool with ProtocolError `-32602`. Returns the SDK handles.
|
|
7
|
+
- Write each definition with `defineTool({...})` so the handler's arguments are typed from its own
|
|
8
|
+
`inputSchema` (a zod 4 object, or `fromJsonSchema(...)`); `annotations` is required. Every SDK `Tool`
|
|
9
|
+
field reaches the wire: `name`, `title`, `description`, `inputSchema`, `outputSchema`, `annotations`,
|
|
10
|
+
`icons`, `_meta`. Results go through the SDK's output validation and era projection (nothing bypasses
|
|
11
|
+
`registerTool`); an unknown or `disable()`d tool is the SDK's own ProtocolError `-32602`, unwrapped.
|
|
12
|
+
- Per call: `logToolCall` / `logToolResult` with the duration (shape only, never values). A thrown error
|
|
13
|
+
becomes `toolError(mapError(err).message)` (default: the message only, secret parameters redacted, never
|
|
14
|
+
a stack or a raw error object; with a `code`, `<code>: <message>`); a thrown zod / Standard Schema error
|
|
15
|
+
becomes `Invalid arguments: <path>: <message>; ...`. A returned legacy `{ success: false, error }`
|
|
16
|
+
envelope becomes `isError: true` (text = the envelope JSON); `{ success: true, data }` keeps its text,
|
|
17
|
+
with `data` as `structuredContent` when the tool has an `outputSchema`.
|
|
18
|
+
- Degraded mode: a tool whose `requires` keys are not configured is still listed, and answers
|
|
19
|
+
`<serverName> is not configured: set <KEYS>`. `requires` keys must be declared in the env schema.
|
|
20
|
+
- `ToolContext`: `signal` (pass it to outbound calls so cancellation stops upstream work), `authInfo`,
|
|
21
|
+
`cursor` (the `cursor` argument when it is a non-empty string), `era` (`modern` | `legacy`),
|
|
22
|
+
`clientCapabilities`, `logger`, `progress(progress, total?, message?)`, `elicit`, `elicitUrl`,
|
|
23
|
+
`sample`, `listRoots` (see [Protocol features](protocol-features.md)), `credentials` (the call's credential scope, see
|
|
24
|
+
[Credential health](credentials.md)), `tool`, `sdk` (the raw SDK context).
|
|
25
|
+
- `registerTools` option `credentials` picks the registry (default the process registry); every result
|
|
26
|
+
that touched a credential carries the credential report.
|
|
27
|
+
- `toolResult(data, { text? })`: a string is the text; an object is pretty JSON plus `structuredContent`.
|
|
28
|
+
`toolError(message, details?)`: `isError: true`, details as a second JSON text block.
|
|
29
|
+
- Client-bound error text is masked like the log files: `toolError`, the `mapError` output, thrown
|
|
30
|
+
errors, schema-issue text, the degraded-mode message and any isError result a handler returns go
|
|
31
|
+
through `maskClientText` (secret query/userinfo/Basic parameters redacted, every registered secret
|
|
32
|
+
value as `[SECRET]`).
|
|
33
|
+
- SDK protocol errors keep their code: a handler throwing `UrlElicitationRequiredError` (`-32042`, the
|
|
34
|
+
2025 URL-elicitation path) reaches the client as that JSON-RPC error, exactly as the SDK's own
|
|
35
|
+
`tools/call` rethrows it; every other throw becomes an isError result (the SDK does the same). An SDK
|
|
36
|
+
input-required result (`inputRequired(...)`, `resultType: 'input_required'`) is passed through
|
|
37
|
+
untouched.
|
|
38
|
+
- `scopeChallenge` (for example `requireScopes('write')`) is forwarded to `registerTool`.
|
|
39
|
+
|
|
40
|
+
## Response limits and pagination
|
|
41
|
+
|
|
42
|
+
Every tool result is capped centrally (defaults; env in `CORE_ENV_SCHEMA`; `RegisterToolsOptions.limits`
|
|
43
|
+
for the server; `ToolDefinition.limits` per tool, which wins):
|
|
44
|
+
|
|
45
|
+
| Cap | Env | Default |
|
|
46
|
+
|-----|-----|---------|
|
|
47
|
+
| Items in a list-shaped result | `MCP_MAX_RESULT_ITEMS` | 100 |
|
|
48
|
+
| Serialized bytes per result | `MCP_MAX_RESULT_BYTES` | 262144 (256 KiB) |
|
|
49
|
+
|
|
50
|
+
- **List-shaped** = `structuredContent` of the `PagedResult` shape `{ items, nextCursor?, total? }`
|
|
51
|
+
(`toolResult(page)`, `pagedResultSchema(z, Item)`). A result with more items than the cap (or than the
|
|
52
|
+
client's `limit`, itself capped) is cut and carries an opaque `nextCursor`; the text block is
|
|
53
|
+
regenerated from the cut page, so it never carries the full dump. The cursor resumes where the page
|
|
54
|
+
stopped: it records the tool, a fingerprint of the other arguments, the offset inside the handler's
|
|
55
|
+
page and the handler's own cursor for that page. The next call re-runs the handler with its own
|
|
56
|
+
cursor (as `args.cursor` and `ctx.cursor`; `args.limit` arrives capped) and mcp-core serves the next
|
|
57
|
+
slice, or wraps the handler's own `nextCursor` once its page is used up. Stateless: nothing is stored.
|
|
58
|
+
- **Never trusted:** a cursor for another tool or other arguments, a malformed one, or an out-of-range
|
|
59
|
+
offset is refused with `Invalid cursor: ...` (isError). An edited offset can only address the caller's
|
|
60
|
+
own result set. `paginated: true` on a definition makes cursor handling strict (only mcp-core cursors)
|
|
61
|
+
and requires `cursor` and `limit` in its input schema (checked at registration):
|
|
62
|
+
`inputSchema: z.object({ ..., cursor: z.string().optional(), limit: z.number().int().positive().optional() })`.
|
|
63
|
+
A tool returning a `PagedResult` without `paginated` is paged too and passes foreign cursors (its own,
|
|
64
|
+
from a non-paged result) through to the handler.
|
|
65
|
+
- **`limit` is clamped on every tool.** An argument named `limit` that is a number is clamped to
|
|
66
|
+
`1..MCP_MAX_RESULT_ITEMS` before the handler sees it, on every registered tool, paginated or not; a tool
|
|
67
|
+
whose own `limit` means something else must name it differently. The limits are read from the `env`
|
|
68
|
+
passed to `registerTools` (its loaded env, file and defaults included) and fall back to `process.env`
|
|
69
|
+
only when no env is passed.
|
|
70
|
+
- **Foreign cursors arrive raw.** On a tool that is not `paginated`, a cursor mcp-core did not issue is
|
|
71
|
+
passed to the handler untouched as `args.cursor`; the handler must validate it as untrusted input
|
|
72
|
+
(digits only for a page number, origin and path checks for a URL).
|
|
73
|
+
- **Byte cap:** a list-shaped result is first paged smaller (halving until it fits); anything still over
|
|
74
|
+
the cap, and any other result, becomes an actionable isError (`<tool>: the result is N bytes, over the M
|
|
75
|
+
byte limit per response; narrow the request ... or use cursor/limit`). Never a truncated body, invalid
|
|
76
|
+
JSON or schema-breaking `structuredContent`. `RegisterToolsOptions.onOversize(info)` may return a
|
|
77
|
+
replacement instead (the seam for a resource link once resources exist).
|
|
78
|
+
- **Envelope tools:** `envelope: { key: 'data', hint: '...' }` on a definition says the payload sits under
|
|
79
|
+
`structuredContent.data`. The item cap then applies to that payload (an array, or a PagedResult) and
|
|
80
|
+
refuses rather than pages (`<tool>: the result has N items, over the M item limit per response; <hint>`),
|
|
81
|
+
since such a tool has no mcp-core cursor; a text block that only repeats the payload is counted once in
|
|
82
|
+
the byte cap; `hint` names the caller's real lever (for example its own `limit`/`offset` arguments).
|
|
83
|
+
- Enforcement is logged by shape only: `Tool result paged: <tool>` with `{ tool, items, served, limit }`,
|
|
84
|
+
`Tool result over the byte limit: <tool>` with `{ tool, bytes, maxBytes }`; never content.
|
|
85
|
+
- Handlers must be deterministic for a given cursor (list reads are). `paginate().collect()` in the HTTP
|
|
86
|
+
client is capped at the same `MCP_MAX_RESULT_ITEMS` by default (an explicit `maxItems` up to
|
|
87
|
+
`HARD_MAX_COLLECT_ITEMS`, 100000), so a tool cannot pull an unbounded upstream list into memory;
|
|
88
|
+
`collect()` reports `truncated`.
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# Transport and Express adapter
|
|
2
|
+
|
|
3
|
+
## Transport
|
|
4
|
+
|
|
5
|
+
Unified transport that selects between stdio (`serveStdio`) and stateless streamable HTTP (`createMcpHandler` + `toNodeHandler`) based on the `MCP_TRANSPORT` environment variable. HTTP mode is fail-closed (refuses to start without auth unless explicitly disabled) and supports Bearer-token auth, body-size caps, and opt-in DNS-rebinding protection.
|
|
6
|
+
|
|
7
|
+
```typescript
|
|
8
|
+
import { startMcpServer } from '@novorb/mcp-core/transport';
|
|
9
|
+
|
|
10
|
+
// Factory (required for HTTP, recommended for stdio):
|
|
11
|
+
await startMcpServer(() => createServer(services));
|
|
12
|
+
|
|
13
|
+
// Server instance (stdio only; HTTP mode throws):
|
|
14
|
+
await startMcpServer(server);
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
| Variable | Default | Description |
|
|
18
|
+
|----------|---------|-------------|
|
|
19
|
+
| `MCP_TRANSPORT` | `stdio` | Transport mode: `stdio` or `http` |
|
|
20
|
+
| `PORT` | `8080` | HTTP listen port |
|
|
21
|
+
| `MCP_HTTP_HOST` | `0.0.0.0` | Bind address (`127.0.0.1` for local-only) |
|
|
22
|
+
| `MCP_API_KEY` | none | Bearer token for HTTP auth (required in HTTP mode unless disabled) |
|
|
23
|
+
| `MCP_AUTH_DISABLED` | `false` | Set `true` to run HTTP without auth (only behind a fronting gateway) |
|
|
24
|
+
| `MCP_MAX_BODY_BYTES` | 4 MiB | Max accepted POST body size |
|
|
25
|
+
| `MCP_ALLOWED_HOSTS` | none | Comma-separated Host allowlist, matched by hostname (port ignored); enables DNS-rebinding protection |
|
|
26
|
+
| `MCP_ALLOWED_ORIGINS` | none | Comma-separated Origin allowlist, matched by hostname (port ignored; a full-origin entry's scheme is ignored); a lowercase `<scheme>://*` entry admits that whole scheme, `http://*` / `https://*` are never honoured; enables DNS-rebinding protection |
|
|
27
|
+
|
|
28
|
+
An unknown `MCP_TRANSPORT` value throws. Explicit options (`mode`, `port`, `host`, `apiKey`, `auth`,
|
|
29
|
+
`maxBodyBytes`, `allowedHosts`, `allowedOrigins`) win over the env; `runMcpServer` passes the values of
|
|
30
|
+
the loaded env (`transportFromEnv`). The handle's `closed` promise resolves when the transport ends
|
|
31
|
+
(`close()`, or a stdio client closing stdin).
|
|
32
|
+
|
|
33
|
+
### HTTP auth
|
|
34
|
+
|
|
35
|
+
The Bearer gate is the SDK's resource-server path: `verifyBearerToken` (header parse, verifier,
|
|
36
|
+
`expectedResource`, `requiredScopes`, expiry) and `bearerAuthChallengeResponse` (401 `invalid_token` /
|
|
37
|
+
403 `insufficient_scope` with `WWW-Authenticate: Bearer ...`, `resource_metadata` when configured). The
|
|
38
|
+
verified `AuthInfo` is set as `req.auth`; the SDK forwards it to the factory (`RequestContext.authInfo`)
|
|
39
|
+
and to handlers (`ctx.http.authInfo`, `ToolContext.authInfo`), so per-tool `scopeChallenge:
|
|
40
|
+
requireScopes('write')` works.
|
|
41
|
+
|
|
42
|
+
- Static key: `MCP_API_KEY` (or `apiKey`) becomes `apiKeyVerifier(key, { clientId?, scopes?, ttlSeconds? })`:
|
|
43
|
+
a constant-time compare (SHA-256 of both sides, `timingSafeEqual`) answering
|
|
44
|
+
`{ token: '[api-key]', clientId: 'api-key', scopes: [], expiresAt: now + 3600 }`. The real key never
|
|
45
|
+
reaches `AuthInfo`. Set its scopes with `auth: { apiKey: { clientId, scopes } }`.
|
|
46
|
+
- Pluggable: `auth: { verifier }` takes any `OAuthTokenVerifier` (for example an OAuth JWT
|
|
47
|
+
verifier); it replaces the static key and always enforces auth. `requiredScopes`,
|
|
48
|
+
`resourceMetadataUrl` and `expectedResource` pass through to the SDK.
|
|
49
|
+
- Fail-closed is unchanged: no key, no verifier and no explicit disable refuses to start. Guard order
|
|
50
|
+
is unchanged: `/health`, Bearer (401/403), path (404), Host/Origin (403), content type (415, the SDK's
|
|
51
|
+
media-type essence compare: `application/json` with parameters, any case; `application/json-seq` is
|
|
52
|
+
refused), body size (413), parse (400 `-32700`), dispatch.
|
|
53
|
+
|
|
54
|
+
## Express adapter (`@novorb/mcp-core/express`)
|
|
55
|
+
|
|
56
|
+
For hosts that already run Express: the same stateless handler, factory and
|
|
57
|
+
`RequestContext` semantics as `startMcpServer`'s HTTP mode, built on `@modelcontextprotocol/express`
|
|
58
|
+
(`requireBearerAuth`, `hostHeaderValidation`, `originValidation`). Install `express` (`^4.18 || ^5`) and
|
|
59
|
+
`@modelcontextprotocol/express` (`~2.0.2`); without them the import throws `ExpressAdapterUnavailableError`.
|
|
60
|
+
|
|
61
|
+
```typescript
|
|
62
|
+
import express from 'express';
|
|
63
|
+
import { mountMcpServer } from '@novorb/mcp-core/express';
|
|
64
|
+
|
|
65
|
+
const app = express();
|
|
66
|
+
const mcp = mountMcpServer(app, (req) => buildServer(req.authInfo), {
|
|
67
|
+
auth: { verifier: keycloakVerifier, requiredScopes: ['mcp'] }, // or apiKey / MCP_API_KEY
|
|
68
|
+
allowedHosts: ['gateway.internal'],
|
|
69
|
+
});
|
|
70
|
+
app.listen(8080);
|
|
71
|
+
// shutdown: await mcp.close();
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Options: `path` (`/mcp`), `health` (`/health`; `false` disables, a string moves it), `apiKey` (default
|
|
75
|
+
`MCP_API_KEY`; `null` runs open), `auth` (as for the transport), `maxBodyBytes`, `allowedHosts`,
|
|
76
|
+
`allowedOrigins`, `env`, `bus` (the change-event bus, default the process bus), `onError` (receives every
|
|
77
|
+
SDK and adapter request error, including an authenticator fault that answers 500; default the mcp-core
|
|
78
|
+
logger), `authenticateRequest`
|
|
79
|
+
(for a host whose own middleware already authenticated the request: `(req) => AuthInfo` replaces the
|
|
80
|
+
Bearer gate, `apiKey` and `auth.verifier`, since a token verifier never sees `req`; it must throw for an
|
|
81
|
+
unauthenticated request, and a throw answers the SDK challenge, an `OAuthError` its 401/403 and anything
|
|
82
|
+
else 500; combining it with `apiKey` or `auth.verifier` throws at construction). Guard order: health > Bearer (401/403 SDK challenge, `req.auth` set) >
|
|
83
|
+
Host/Origin (403) > 415 > 413 > 400 `-32700` > dispatch; fail-closed like the transport. A body already
|
|
84
|
+
parsed by an app-level `express.json()` is used as is (then that parser's limit applies).
|
|
85
|
+
`createMcpRouter(factory, options)` returns the router without mounting it.
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Example INTEGRATION suite for an MCP server built on @novorb/mcp-core.
|
|
3
|
+
*
|
|
4
|
+
* Copy to `tests/integration/tools.integration.test.ts` in your server and edit ONLY the
|
|
5
|
+
* "SERVER UNDER TEST" block and the case table. The suite starts the BUILT server (`node dist/index.js`)
|
|
6
|
+
* as a child process, in stdio AND http mode, with an explicit env and per-run temp dirs, and drives it
|
|
7
|
+
* with the real v2 client. No mocks: real server process, real downstream API, real client.
|
|
8
|
+
*
|
|
9
|
+
* Gating (environment of the test run, never of the server):
|
|
10
|
+
* MCP_IT_TARGET=test|live default test. LIVE runs read cases only; write/destructive need TEST.
|
|
11
|
+
* MCP_IT_ALLOW_COSTLY=true costly cases (they spend quota or money) run only with this opt-in.
|
|
12
|
+
* A case whose credentials are absent is skipped with a printed `[SKIP]` line: a skipped case protects
|
|
13
|
+
* nothing, so read the skip list in the output.
|
|
14
|
+
*
|
|
15
|
+
* mcp-core runs this exact file against its own echo fixture, so it cannot drift from the harness.
|
|
16
|
+
* Jest is used here (`@jest/globals`); the harness itself is runner-agnostic, so vitest works the same.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { createRequire } from 'node:module';
|
|
20
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
21
|
+
import { afterAll, beforeAll, describe, expect, it } from '@jest/globals';
|
|
22
|
+
import {
|
|
23
|
+
assertToolCoverage,
|
|
24
|
+
type BuiltServer,
|
|
25
|
+
callToolJson,
|
|
26
|
+
collectStrings,
|
|
27
|
+
createIsolatedDirs,
|
|
28
|
+
expectSchemaRejection,
|
|
29
|
+
expectToolError,
|
|
30
|
+
expectUnknownTool,
|
|
31
|
+
findLeaks,
|
|
32
|
+
type HarnessMode,
|
|
33
|
+
type IsolatedDirs,
|
|
34
|
+
initializeRequestBody,
|
|
35
|
+
listToolNames,
|
|
36
|
+
logLevels,
|
|
37
|
+
mcpPostHeaders,
|
|
38
|
+
rawHttp,
|
|
39
|
+
readLogFiles,
|
|
40
|
+
type StartBuiltServerOptions,
|
|
41
|
+
startBuiltServer,
|
|
42
|
+
type ToolJsonResult,
|
|
43
|
+
} from '@novorb/mcp-core/testing';
|
|
44
|
+
|
|
45
|
+
// ---- SERVER UNDER TEST -----------------------------------------------------------------------------
|
|
46
|
+
/** The name your server passes to initLogger (the main log file is `<name>.log`). */
|
|
47
|
+
const SERVER_NAME = 'echo-fixture';
|
|
48
|
+
/** Text of the INFO line your server logs at startup. */
|
|
49
|
+
const STARTUP_LINE = 'echo-fixture started';
|
|
50
|
+
/** Your built entry: `fileURLToPath(new URL('../../dist/index.js', import.meta.url))`. */
|
|
51
|
+
const ENTRY = fileURLToPath(new URL('../../tests/fixtures/echo-server.ts', import.meta.url));
|
|
52
|
+
/** Node arguments before the entry: `[]` for a built `dist/index.js` (the fixture is TypeScript). */
|
|
53
|
+
const NODE_ARGS = ['--import', pathToFileURL(createRequire(import.meta.url).resolve('tsx')).href];
|
|
54
|
+
/**
|
|
55
|
+
* EVERY provider/credential variable the server reads, '' when not supplied, so a developer `.env` or a
|
|
56
|
+
* dotenv load can never fill a gap. Use exactly the names your credentials spec gives the operator.
|
|
57
|
+
*/
|
|
58
|
+
const CREDENTIALS: Record<string, string> = { ECHO_UPSTREAM_TOKEN: process.env.ECHO_UPSTREAM_TOKEN ?? '' };
|
|
59
|
+
/** A deliberately wrong credential set, for the "downstream auth error surfaces as an error" case. */
|
|
60
|
+
const WRONG_CREDENTIALS: Record<string, string> = { ECHO_UPSTREAM_TOKEN: 'bad-token-mcpit-adverse' };
|
|
61
|
+
/** Extra per-run dirs the server needs, by env var name (DATA_PATH is mcp-core's data store directory). */
|
|
62
|
+
const stateEnv = (dirs: IsolatedDirs): Record<string, string> => ({ DATA_PATH: dirs.state });
|
|
63
|
+
// ---------------------------------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
const TARGET = process.env.MCP_IT_TARGET === 'live' ? 'live' : 'test';
|
|
66
|
+
const ALLOW_COSTLY = process.env.MCP_IT_ALLOW_COSTLY === 'true';
|
|
67
|
+
const RUN_ID = `mcpit-${Date.now().toString(36)}`;
|
|
68
|
+
/** A PII fixture sent as an argument value: it must never reach a log file unmasked. */
|
|
69
|
+
const PII_EMAIL = 'mcpit.person@example.com';
|
|
70
|
+
|
|
71
|
+
type Kind = 'read' | 'write' | 'destructive' | 'costly';
|
|
72
|
+
|
|
73
|
+
/** Everything a case touches, so the log assertions can scan for it afterwards. */
|
|
74
|
+
interface RunContext {
|
|
75
|
+
client: BuiltServer['client'];
|
|
76
|
+
/** Every string VALUE sent as a tool argument. */
|
|
77
|
+
sent: string[];
|
|
78
|
+
/** Every result text received. */
|
|
79
|
+
received: string[];
|
|
80
|
+
/** Tools called at least once. */
|
|
81
|
+
called: Set<string>;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function call<T = unknown>(
|
|
85
|
+
ctx: RunContext,
|
|
86
|
+
name: string,
|
|
87
|
+
args: Record<string, unknown> = {},
|
|
88
|
+
): Promise<ToolJsonResult<T>> {
|
|
89
|
+
collectStrings(args, ctx.sent);
|
|
90
|
+
ctx.called.add(name);
|
|
91
|
+
const result = await callToolJson<T>(ctx.client, name, args);
|
|
92
|
+
if (result.text) ctx.received.push(result.text);
|
|
93
|
+
return result;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
interface Case {
|
|
97
|
+
tool: string;
|
|
98
|
+
kind: Kind;
|
|
99
|
+
/** Credential variables the case needs (skipped with [SKIP] when any is empty). */
|
|
100
|
+
needs?: string[];
|
|
101
|
+
run(ctx: RunContext): Promise<void>;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** One case per registered tool, real call, assert the real response shape and a meaningful value. */
|
|
105
|
+
const CASES: Case[] = [
|
|
106
|
+
{
|
|
107
|
+
tool: 'echo',
|
|
108
|
+
kind: 'read',
|
|
109
|
+
run: async (ctx) => {
|
|
110
|
+
const r = await call(ctx, 'echo', { text: `${RUN_ID} ${PII_EMAIL}` });
|
|
111
|
+
expect(r.isError).toBe(false);
|
|
112
|
+
expect(r.text).toBe(`echo: ${RUN_ID} ${PII_EMAIL}`);
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
tool: 'echo_json',
|
|
117
|
+
kind: 'read',
|
|
118
|
+
run: async (ctx) => {
|
|
119
|
+
const r = await call<{ text: string; length: number }>(ctx, 'echo_json', { text: `${RUN_ID}-json` });
|
|
120
|
+
expect(r.body).toEqual({ text: `${RUN_ID}-json`, length: `${RUN_ID}-json`.length });
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
tool: 'boom',
|
|
125
|
+
kind: 'read',
|
|
126
|
+
// Pins the CURRENT error envelope: a tool-level failure is `isError: true`, not a thrown error.
|
|
127
|
+
run: async (ctx) => {
|
|
128
|
+
expectToolError(await call(ctx, 'boom'), 'boom: failed');
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
tool: 'upstream_status',
|
|
133
|
+
kind: 'read',
|
|
134
|
+
needs: ['ECHO_UPSTREAM_TOKEN'],
|
|
135
|
+
run: async (ctx) => {
|
|
136
|
+
expect((await call(ctx, 'upstream_status')).body).toEqual({ authenticated: true });
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
tool: 'paid_lookup',
|
|
141
|
+
kind: 'costly',
|
|
142
|
+
run: async (ctx) => {
|
|
143
|
+
expect((await call(ctx, 'paid_lookup', { query: RUN_ID })).body).toEqual({ query: RUN_ID, charged: 1 });
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
tool: 'note_create',
|
|
148
|
+
kind: 'write',
|
|
149
|
+
// Writes create their own uniquely named objects and clean them up.
|
|
150
|
+
run: async (ctx) => {
|
|
151
|
+
const name = `${RUN_ID}-create`;
|
|
152
|
+
expect((await call(ctx, 'note_create', { name, body: `${RUN_ID} body` })).body).toEqual({ name, created: true });
|
|
153
|
+
expect((await call(ctx, 'note_delete', { name })).isError).toBe(false);
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
tool: 'note_list',
|
|
158
|
+
kind: 'read',
|
|
159
|
+
run: async (ctx) => {
|
|
160
|
+
const r = await call<{ names: string[] }>(ctx, 'note_list', { prefix: `${RUN_ID}-none` });
|
|
161
|
+
expect(r.body).toEqual({ names: [] });
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
tool: 'note_delete',
|
|
166
|
+
kind: 'destructive',
|
|
167
|
+
// Destructive tools only act on objects the test created.
|
|
168
|
+
run: async (ctx) => {
|
|
169
|
+
const name = `${RUN_ID}-delete`;
|
|
170
|
+
await call(ctx, 'note_create', { name, body: 'to delete' });
|
|
171
|
+
expect((await call(ctx, 'note_delete', { name })).body).toEqual({ name, deleted: true });
|
|
172
|
+
const after = await call<{ names: string[] }>(ctx, 'note_list', { prefix: name });
|
|
173
|
+
expect(after.body).toEqual({ names: [] });
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
];
|
|
177
|
+
|
|
178
|
+
function skipReason(c: Case): string | undefined {
|
|
179
|
+
if (c.kind === 'costly' && !ALLOW_COSTLY) return 'costly; set MCP_IT_ALLOW_COSTLY=true to run it';
|
|
180
|
+
if ((c.kind === 'write' || c.kind === 'destructive' || c.kind === 'costly') && TARGET === 'live') {
|
|
181
|
+
return `${c.kind} cases run only with MCP_IT_TARGET=test`;
|
|
182
|
+
}
|
|
183
|
+
const missing = (c.needs ?? []).filter((v) => !CREDENTIALS[v]);
|
|
184
|
+
if (missing.length > 0) return `missing credential ${missing.join(', ')}`;
|
|
185
|
+
return undefined;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function serverOptions(
|
|
189
|
+
mode: HarnessMode,
|
|
190
|
+
dirs: IsolatedDirs,
|
|
191
|
+
extra: Record<string, string> = {},
|
|
192
|
+
): StartBuiltServerOptions {
|
|
193
|
+
return {
|
|
194
|
+
entry: ENTRY,
|
|
195
|
+
nodeArgs: NODE_ARGS,
|
|
196
|
+
mode,
|
|
197
|
+
cwd: dirs.cwd,
|
|
198
|
+
env: { ...dirs.env(), ...stateEnv(dirs), ...CREDENTIALS, LOG_LEVEL: 'DEBUG', LOG_CONSOLE: 'false', ...extra },
|
|
199
|
+
// MCP_ENV_FILE=none: the server's env loader never reads a developer `.env`.
|
|
200
|
+
envFile: 'none',
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
describe.each<HarnessMode>(['stdio', 'http'])('tools over %s (integration)', (mode) => {
|
|
205
|
+
let dirs: IsolatedDirs;
|
|
206
|
+
let srv: BuiltServer;
|
|
207
|
+
let ctx: RunContext;
|
|
208
|
+
|
|
209
|
+
beforeAll(async () => {
|
|
210
|
+
dirs = createIsolatedDirs(`mcpit-${mode}`);
|
|
211
|
+
srv = await startBuiltServer(serverOptions(mode, dirs));
|
|
212
|
+
ctx = { client: srv.client, sent: [], received: [], called: new Set() };
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
afterAll(async () => {
|
|
216
|
+
// Prefix sweep: remove anything this run created that a failed case left behind (TEST only).
|
|
217
|
+
if (srv && TARGET === 'test') {
|
|
218
|
+
try {
|
|
219
|
+
const left = await callToolJson<{ names: string[] }>(srv.client, 'note_list', { prefix: RUN_ID });
|
|
220
|
+
for (const name of left.body?.names ?? []) await callToolJson(srv.client, 'note_delete', { name });
|
|
221
|
+
} catch {
|
|
222
|
+
// the server may already be stopped by the log test
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
await srv?.stop();
|
|
226
|
+
dirs?.cleanup();
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('has exactly one case per registered tool (both directions)', async () => {
|
|
230
|
+
assertToolCoverage(
|
|
231
|
+
await listToolNames(srv.client),
|
|
232
|
+
CASES.map((c) => c.tool),
|
|
233
|
+
);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
for (const c of CASES) {
|
|
237
|
+
const reason = skipReason(c);
|
|
238
|
+
if (reason) console.log(`[SKIP] ${mode} ${c.tool}: ${reason} (a skipped case protects nothing)`);
|
|
239
|
+
(reason ? it.skip : it)(`${c.tool} (${c.kind})`, async () => {
|
|
240
|
+
await c.run(ctx);
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
it('adverse: invalid arguments are rejected by the schema', async () => {
|
|
245
|
+
ctx.called.add('echo');
|
|
246
|
+
await expectSchemaRejection(srv.client, 'echo', { text: 42 });
|
|
247
|
+
await expectSchemaRejection(srv.client, 'note_delete', { name: '../../etc' });
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('adverse: an unknown tool is refused', async () => {
|
|
251
|
+
const refused = await expectUnknownTool(srv.client, `${RUN_ID}_no_such_tool`);
|
|
252
|
+
expect(refused.via).toBe('protocol-error');
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('adverse: a not-found id is an error result', async () => {
|
|
256
|
+
expectToolError(await call(ctx, 'note_delete', { name: `${RUN_ID}-missing` }), 'note not found');
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it('adverse: a wrong credential surfaces the downstream auth error as an error result', async () => {
|
|
260
|
+
const wrongDirs = createIsolatedDirs(`mcpit-${mode}-wrong`);
|
|
261
|
+
const wrong = await startBuiltServer(serverOptions(mode, wrongDirs, WRONG_CREDENTIALS));
|
|
262
|
+
try {
|
|
263
|
+
const r = await callToolJson(wrong.client, 'upstream_status');
|
|
264
|
+
expectToolError(r, /401/);
|
|
265
|
+
} finally {
|
|
266
|
+
await wrong.stop();
|
|
267
|
+
wrongDirs.cleanup();
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
if (mode === 'http') {
|
|
272
|
+
it('adverse http: 401 without the key, 413 oversized body, 415 wrong content type', async () => {
|
|
273
|
+
const url = srv.url ?? '';
|
|
274
|
+
expect((await rawHttp(url, { headers: mcpPostHeaders(), body: initializeRequestBody() })).status).toBe(401);
|
|
275
|
+
const big = await rawHttp(url, { headers: mcpPostHeaders(srv.apiKey), body: 'x'.repeat(5 * 1024 * 1024) });
|
|
276
|
+
expect(big.status).toBe(413);
|
|
277
|
+
const wrongType = { ...mcpPostHeaders(srv.apiKey), 'content-type': 'text/plain' };
|
|
278
|
+
expect((await rawHttp(url, { headers: wrongType, body: initializeRequestBody() })).status).toBe(415);
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
it('logs: startup + per-tool lines in the main log, level-pure side files, no leaked values', async () => {
|
|
283
|
+
await srv.stop();
|
|
284
|
+
const logs = readLogFiles(dirs.logs, SERVER_NAME);
|
|
285
|
+
expect(logs.main).toContain(STARTUP_LINE);
|
|
286
|
+
for (const tool of ctx.called) {
|
|
287
|
+
expect(logs.main).toContain(`Tool call: ${tool}`);
|
|
288
|
+
expect(logs.main).toContain(`Tool result: ${tool}`);
|
|
289
|
+
}
|
|
290
|
+
expect(logLevels(logs.exceptions).length).toBeGreaterThan(0);
|
|
291
|
+
expect(new Set(logLevels(logs.exceptions))).toEqual(new Set(['ERROR']));
|
|
292
|
+
expect(new Set(logLevels(logs.debug))).toEqual(new Set(['DEBUG']));
|
|
293
|
+
const needles = [...Object.values(CREDENTIALS), srv.apiKey ?? '', ...ctx.sent, ...ctx.received, PII_EMAIL];
|
|
294
|
+
const leaks = findLeaks([logs.main, logs.exceptions, logs.debug, srv.stdout()], needles);
|
|
295
|
+
expect(leaks).toEqual([]);
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
describe('log switches (integration)', () => {
|
|
300
|
+
it('a sink switched off writes no file', async () => {
|
|
301
|
+
const dirs = createIsolatedDirs('mcpit-switch');
|
|
302
|
+
const srv = await startBuiltServer(serverOptions('stdio', dirs, { LOG_FILE_DEBUG: 'false' }));
|
|
303
|
+
try {
|
|
304
|
+
await callToolJson(srv.client, 'echo', { text: RUN_ID });
|
|
305
|
+
} finally {
|
|
306
|
+
await srv.stop();
|
|
307
|
+
}
|
|
308
|
+
const logs = readLogFiles(dirs.logs, SERVER_NAME);
|
|
309
|
+
expect(logs.exists).toEqual({ main: true, exceptions: false, debug: false });
|
|
310
|
+
dirs.cleanup();
|
|
311
|
+
});
|
|
312
|
+
});
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Example UNIT suite for an MCP server built on @novorb/mcp-core.
|
|
3
|
+
*
|
|
4
|
+
* Copy to `tests/unit/tools.unit.test.ts` in your server and edit ONLY the "SERVER UNDER TEST" block.
|
|
5
|
+
* Everything runs in process: the real server factory over the SDK's in-memory transport (no child
|
|
6
|
+
* process, no network), plus direct handler calls through a recording server. Unit tests MAY mock
|
|
7
|
+
* your downstream clients; the integration example must not.
|
|
8
|
+
*
|
|
9
|
+
* mcp-core runs this exact file against its own echo fixture, so it cannot drift from the harness.
|
|
10
|
+
* Jest is used here (`@jest/globals`); the harness itself is runner-agnostic, so vitest works the same.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { afterAll, beforeAll, describe, expect, it } from '@jest/globals';
|
|
14
|
+
import type { McpServer } from '@novorb/mcp-core/server';
|
|
15
|
+
import {
|
|
16
|
+
assertToolCoverage,
|
|
17
|
+
callToolInMemory,
|
|
18
|
+
callToolJson,
|
|
19
|
+
connectInMemory,
|
|
20
|
+
expectSchemaRejection,
|
|
21
|
+
expectToolError,
|
|
22
|
+
expectToolSuccess,
|
|
23
|
+
expectUnknownTool,
|
|
24
|
+
type InMemoryConnection,
|
|
25
|
+
listToolNames,
|
|
26
|
+
makeRecordingServer,
|
|
27
|
+
withInMemoryClient,
|
|
28
|
+
} from '@novorb/mcp-core/testing';
|
|
29
|
+
|
|
30
|
+
// ---- SERVER UNDER TEST: replace with your server's factory and register function -----------------
|
|
31
|
+
import { buildEchoServer, registerEchoTools } from '../../tests/fixtures/echo-tools.js';
|
|
32
|
+
|
|
33
|
+
const buildServer: () => McpServer = buildEchoServer;
|
|
34
|
+
const registerTools: (server: McpServer) => void = registerEchoTools;
|
|
35
|
+
// ---------------------------------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
const RUN_ID = `mcpunit-${Date.now().toString(36)}`;
|
|
38
|
+
|
|
39
|
+
/** One case per registered tool: call it in memory and assert a meaningful value, not just "no error". */
|
|
40
|
+
interface UnitCase {
|
|
41
|
+
tool: string;
|
|
42
|
+
args: Record<string, unknown>;
|
|
43
|
+
check(body: unknown, text: string): void;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const CASES: UnitCase[] = [
|
|
47
|
+
{ tool: 'echo', args: { text: 'hello' }, check: (_b, text) => expect(text).toBe('echo: hello') },
|
|
48
|
+
{
|
|
49
|
+
tool: 'echo_json',
|
|
50
|
+
args: { text: 'abcd' },
|
|
51
|
+
check: (body) => expect(body).toEqual({ text: 'abcd', length: 4 }),
|
|
52
|
+
},
|
|
53
|
+
{ tool: 'boom', args: {}, check: (_b, text) => expect(text).toBe('boom: failed') },
|
|
54
|
+
{ tool: 'upstream_status', args: {}, check: (_b, text) => expect(text).toMatch(/authenticated|401/) },
|
|
55
|
+
{ tool: 'paid_lookup', args: { query: 'q' }, check: (body) => expect(body).toEqual({ query: 'q', charged: 1 }) },
|
|
56
|
+
{
|
|
57
|
+
tool: 'note_create',
|
|
58
|
+
args: { name: `${RUN_ID}-a`, body: 'note body' },
|
|
59
|
+
check: (body) => expect(body).toEqual({ name: `${RUN_ID}-a`, created: true }),
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
tool: 'note_list',
|
|
63
|
+
args: { prefix: RUN_ID },
|
|
64
|
+
check: (body) => expect((body as { names: string[] }).names).toContain(`${RUN_ID}-a`),
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
tool: 'note_delete',
|
|
68
|
+
args: { name: `${RUN_ID}-a` },
|
|
69
|
+
check: (body) => expect(body).toEqual({ name: `${RUN_ID}-a`, deleted: true }),
|
|
70
|
+
},
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
describe('tools (unit, in memory)', () => {
|
|
74
|
+
// One connection for the ordered cases (create -> list -> delete share the server's state).
|
|
75
|
+
let conn: InMemoryConnection;
|
|
76
|
+
|
|
77
|
+
beforeAll(async () => {
|
|
78
|
+
conn = await connectInMemory(buildServer);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
afterAll(async () => {
|
|
82
|
+
await conn?.close();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('has exactly one case per registered tool (both directions)', async () => {
|
|
86
|
+
const names = await listToolNames(conn.client);
|
|
87
|
+
expect(
|
|
88
|
+
assertToolCoverage(
|
|
89
|
+
names,
|
|
90
|
+
CASES.map((c) => c.tool),
|
|
91
|
+
).ok,
|
|
92
|
+
).toBe(true);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it.each(CASES.map((c) => [c.tool, c] as const))('%s', async (_name, c) => {
|
|
96
|
+
const result = await callToolJson(conn.client, c.tool, c.args);
|
|
97
|
+
c.check(result.body, result.text);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe('tools (unit, adverse)', () => {
|
|
102
|
+
it('a one-shot call decodes success and error results', async () => {
|
|
103
|
+
const ok = await callToolInMemory<{ text: string; length: number }>(buildServer, 'echo_json', { text: 'xy' });
|
|
104
|
+
expect(expectToolSuccess(ok)).toEqual({ text: 'xy', length: 2 });
|
|
105
|
+
const failed = await callToolInMemory(buildServer, 'boom');
|
|
106
|
+
expect(expectToolError(failed, /failed/)).toBe('boom: failed');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('rejects arguments that break the input schema', async () => {
|
|
110
|
+
await withInMemoryClient(buildServer, async (client) => {
|
|
111
|
+
const wrongType = await expectSchemaRejection(client, 'echo', { text: 5 });
|
|
112
|
+
expect(wrongType.message).toMatch(/Input validation error/);
|
|
113
|
+
await expectSchemaRejection(client, 'echo', {});
|
|
114
|
+
await expectSchemaRejection(client, 'note_create', { name: '../escape', body: 'x' });
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('refuses an unknown tool', async () => {
|
|
119
|
+
await withInMemoryClient(buildServer, async (client) => {
|
|
120
|
+
const refused = await expectUnknownTool(client, 'no_such_tool');
|
|
121
|
+
expect(refused).toEqual({ via: 'protocol-error', message: expect.stringMatching(/not found/) });
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('answers a not-found id with an error result', async () => {
|
|
126
|
+
const missing = await callToolInMemory(buildServer, 'note_delete', { name: `${RUN_ID}-missing` });
|
|
127
|
+
expectToolError(missing, 'note not found');
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe('tools (unit, recording server: handlers called directly)', () => {
|
|
132
|
+
const rec = makeRecordingServer();
|
|
133
|
+
registerTools(rec.asMcpServer());
|
|
134
|
+
|
|
135
|
+
it('registers every tool with a description and an input schema', () => {
|
|
136
|
+
expect([...rec.tools.keys()].sort()).toEqual(CASES.map((c) => c.tool).sort());
|
|
137
|
+
for (const tool of rec.tools.values()) {
|
|
138
|
+
expect(typeof tool.config.description).toBe('string');
|
|
139
|
+
expect(tool.config.inputSchema).toBeDefined();
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('invokes a handler without the protocol (no schema validation on this path)', async () => {
|
|
144
|
+
const echo = rec.tools.get('echo');
|
|
145
|
+
if (!echo) throw new Error('echo was not registered');
|
|
146
|
+
const result = await echo.handler({ text: 'direct' });
|
|
147
|
+
expect(result.content).toEqual([{ type: 'text', text: 'echo: direct' }]);
|
|
148
|
+
});
|
|
149
|
+
});
|