@animalabs/agent-framework 0.22.4 → 0.23.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +105 -3
- package/dist/src/agent.d.ts +36 -10
- package/dist/src/agent.d.ts.map +1 -1
- package/dist/src/agent.js +76 -32
- package/dist/src/agent.js.map +1 -1
- package/dist/src/code-execution/index.d.ts +1 -1
- package/dist/src/code-execution/index.d.ts.map +1 -1
- package/dist/src/code-execution/index.js.map +1 -1
- package/dist/src/code-execution/py-runner.d.ts +34 -2
- package/dist/src/code-execution/py-runner.d.ts.map +1 -1
- package/dist/src/code-execution/py-runner.js +72 -16
- package/dist/src/code-execution/py-runner.js.map +1 -1
- package/dist/src/code-execution/runtime-py.d.ts +3 -2
- package/dist/src/code-execution/runtime-py.d.ts.map +1 -1
- package/dist/src/code-execution/runtime-py.js +47 -7
- package/dist/src/code-execution/runtime-py.js.map +1 -1
- package/dist/src/code-execution/tool-definition.d.ts +12 -7
- package/dist/src/code-execution/tool-definition.d.ts.map +1 -1
- package/dist/src/code-execution/tool-definition.js +59 -11
- package/dist/src/code-execution/tool-definition.js.map +1 -1
- package/dist/src/config-checks.d.ts +2 -2
- package/dist/src/config-checks.d.ts.map +1 -1
- package/dist/src/config-checks.js +6 -2
- package/dist/src/config-checks.js.map +1 -1
- package/dist/src/framework.d.ts +744 -120
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +3546 -906
- package/dist/src/framework.js.map +1 -1
- package/dist/src/gate/event-gate.d.ts.map +1 -1
- package/dist/src/gate/event-gate.js +13 -6
- package/dist/src/gate/event-gate.js.map +1 -1
- package/dist/src/held-writes.d.ts +16 -0
- package/dist/src/held-writes.d.ts.map +1 -0
- package/dist/src/held-writes.js +56 -0
- package/dist/src/held-writes.js.map +1 -0
- package/dist/src/index.d.ts +7 -3
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +8 -4
- package/dist/src/index.js.map +1 -1
- package/dist/src/mcpl/capability-grant.d.ts +4 -0
- package/dist/src/mcpl/capability-grant.d.ts.map +1 -1
- package/dist/src/mcpl/capability-grant.js +1 -1
- package/dist/src/mcpl/capability-grant.js.map +1 -1
- package/dist/src/mcpl/channel-registry.d.ts +95 -18
- package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
- package/dist/src/mcpl/channel-registry.js +578 -150
- package/dist/src/mcpl/channel-registry.js.map +1 -1
- package/dist/src/mcpl/convert-block.d.ts +2 -2
- package/dist/src/mcpl/convert-block.d.ts.map +1 -1
- package/dist/src/mcpl/convert-block.js +6 -7
- package/dist/src/mcpl/convert-block.js.map +1 -1
- package/dist/src/mcpl/index.d.ts +5 -1
- package/dist/src/mcpl/index.d.ts.map +1 -1
- package/dist/src/mcpl/index.js +3 -1
- package/dist/src/mcpl/index.js.map +1 -1
- package/dist/src/mcpl/inference-router.d.ts +4 -2
- package/dist/src/mcpl/inference-router.d.ts.map +1 -1
- package/dist/src/mcpl/inference-router.js +6 -7
- package/dist/src/mcpl/inference-router.js.map +1 -1
- package/dist/src/mcpl/log-value.d.ts +7 -0
- package/dist/src/mcpl/log-value.d.ts.map +1 -1
- package/dist/src/mcpl/log-value.js +9 -0
- package/dist/src/mcpl/log-value.js.map +1 -1
- package/dist/src/mcpl/modern-connection.d.ts +227 -0
- package/dist/src/mcpl/modern-connection.d.ts.map +1 -0
- package/dist/src/mcpl/modern-connection.js +1088 -0
- package/dist/src/mcpl/modern-connection.js.map +1 -0
- package/dist/src/mcpl/prefix-rule.d.ts +18 -0
- package/dist/src/mcpl/prefix-rule.d.ts.map +1 -0
- package/dist/src/mcpl/prefix-rule.js +25 -0
- package/dist/src/mcpl/prefix-rule.js.map +1 -0
- package/dist/src/mcpl/protocol-family.d.ts +179 -0
- package/dist/src/mcpl/protocol-family.d.ts.map +1 -0
- package/dist/src/mcpl/protocol-family.js +697 -0
- package/dist/src/mcpl/protocol-family.js.map +1 -0
- package/dist/src/mcpl/push-coalescer.d.ts +38 -19
- package/dist/src/mcpl/push-coalescer.d.ts.map +1 -1
- package/dist/src/mcpl/push-coalescer.js +52 -23
- package/dist/src/mcpl/push-coalescer.js.map +1 -1
- package/dist/src/mcpl/reference-fetcher.d.ts +66 -15
- package/dist/src/mcpl/reference-fetcher.d.ts.map +1 -1
- package/dist/src/mcpl/reference-fetcher.js +243 -76
- package/dist/src/mcpl/reference-fetcher.js.map +1 -1
- package/dist/src/mcpl/references.d.ts +63 -18
- package/dist/src/mcpl/references.d.ts.map +1 -1
- package/dist/src/mcpl/references.js +117 -32
- package/dist/src/mcpl/references.js.map +1 -1
- package/dist/src/mcpl/server-connection.d.ts +326 -30
- package/dist/src/mcpl/server-connection.d.ts.map +1 -1
- package/dist/src/mcpl/server-connection.js +995 -212
- package/dist/src/mcpl/server-connection.js.map +1 -1
- package/dist/src/mcpl/server-registry.d.ts +25 -5
- package/dist/src/mcpl/server-registry.d.ts.map +1 -1
- package/dist/src/mcpl/server-registry.js +51 -12
- package/dist/src/mcpl/server-registry.js.map +1 -1
- package/dist/src/mcpl/tool-result-normalize.d.ts +54 -0
- package/dist/src/mcpl/tool-result-normalize.d.ts.map +1 -0
- package/dist/src/mcpl/tool-result-normalize.js +232 -0
- package/dist/src/mcpl/tool-result-normalize.js.map +1 -0
- package/dist/src/mcpl/transport.d.ts +56 -13
- package/dist/src/mcpl/transport.d.ts.map +1 -1
- package/dist/src/mcpl/transport.js +206 -70
- package/dist/src/mcpl/transport.js.map +1 -1
- package/dist/src/mcpl/types.d.ts +66 -20
- package/dist/src/mcpl/types.d.ts.map +1 -1
- package/dist/src/mcpl/types.js.map +1 -1
- package/dist/src/mcpl/visible-content.d.ts +2 -1
- package/dist/src/mcpl/visible-content.d.ts.map +1 -1
- package/dist/src/mcpl/visible-content.js +2 -1
- package/dist/src/mcpl/visible-content.js.map +1 -1
- package/dist/src/module-registry.d.ts +16 -6
- package/dist/src/module-registry.d.ts.map +1 -1
- package/dist/src/module-registry.js +65 -6
- package/dist/src/module-registry.js.map +1 -1
- package/dist/src/modules/api/index.d.ts +1 -1
- package/dist/src/modules/api/index.d.ts.map +1 -1
- package/dist/src/modules/api/index.js +3 -2
- package/dist/src/modules/api/index.js.map +1 -1
- package/dist/src/modules/discord/discord-js-client.d.ts +42 -2
- package/dist/src/modules/discord/discord-js-client.d.ts.map +1 -1
- package/dist/src/modules/discord/discord-js-client.js +268 -65
- package/dist/src/modules/discord/discord-js-client.js.map +1 -1
- package/dist/src/modules/discord/index.d.ts +27 -5
- package/dist/src/modules/discord/index.d.ts.map +1 -1
- package/dist/src/modules/discord/index.js +366 -76
- package/dist/src/modules/discord/index.js.map +1 -1
- package/dist/src/modules/discord/refusal.d.ts +14 -0
- package/dist/src/modules/discord/refusal.d.ts.map +1 -0
- package/dist/src/modules/discord/refusal.js +14 -0
- package/dist/src/modules/discord/refusal.js.map +1 -0
- package/dist/src/modules/discord/types.d.ts +17 -7
- package/dist/src/modules/discord/types.d.ts.map +1 -1
- package/dist/src/modules/health/index.d.ts.map +1 -1
- package/dist/src/modules/health/index.js +6 -2
- package/dist/src/modules/health/index.js.map +1 -1
- package/dist/src/modules/index.d.ts +1 -1
- package/dist/src/modules/index.d.ts.map +1 -1
- package/dist/src/modules/index.js +1 -1
- package/dist/src/modules/index.js.map +1 -1
- package/dist/src/modules/workspace/disk-agreement.d.ts +25 -2
- package/dist/src/modules/workspace/disk-agreement.d.ts.map +1 -1
- package/dist/src/modules/workspace/disk-agreement.js +45 -5
- package/dist/src/modules/workspace/disk-agreement.js.map +1 -1
- package/dist/src/modules/workspace/effects.d.ts +2 -1
- package/dist/src/modules/workspace/effects.d.ts.map +1 -1
- package/dist/src/modules/workspace/effects.js +19 -9
- package/dist/src/modules/workspace/effects.js.map +1 -1
- package/dist/src/modules/workspace/index.d.ts +214 -42
- package/dist/src/modules/workspace/index.d.ts.map +1 -1
- package/dist/src/modules/workspace/index.js +667 -113
- package/dist/src/modules/workspace/index.js.map +1 -1
- package/dist/src/modules/workspace/observe.d.ts +240 -8
- package/dist/src/modules/workspace/observe.d.ts.map +1 -1
- package/dist/src/modules/workspace/observe.js +554 -24
- package/dist/src/modules/workspace/observe.js.map +1 -1
- package/dist/src/modules/workspace/types.d.ts +10 -1
- package/dist/src/modules/workspace/types.d.ts.map +1 -1
- package/dist/src/modules/workspace/types.js.map +1 -1
- package/dist/src/modules/workspace/watcher.d.ts +42 -0
- package/dist/src/modules/workspace/watcher.d.ts.map +1 -1
- package/dist/src/modules/workspace/watcher.js +130 -25
- package/dist/src/modules/workspace/watcher.js.map +1 -1
- package/dist/src/silent-heartbeat.d.ts +112 -39
- package/dist/src/silent-heartbeat.d.ts.map +1 -1
- package/dist/src/silent-heartbeat.js +100 -29
- package/dist/src/silent-heartbeat.js.map +1 -1
- package/dist/src/tool-args.d.ts +40 -0
- package/dist/src/tool-args.d.ts.map +1 -0
- package/dist/src/tool-args.js +207 -0
- package/dist/src/tool-args.js.map +1 -0
- package/dist/src/tool-presentation.d.ts.map +1 -1
- package/dist/src/tool-presentation.js +9 -2
- package/dist/src/tool-presentation.js.map +1 -1
- package/dist/src/types/agent.d.ts +27 -17
- package/dist/src/types/agent.d.ts.map +1 -1
- package/dist/src/types/events.d.ts +8 -0
- package/dist/src/types/events.d.ts.map +1 -1
- package/dist/src/types/framework.d.ts +17 -2
- package/dist/src/types/framework.d.ts.map +1 -1
- package/dist/src/types/module.d.ts +54 -10
- package/dist/src/types/module.d.ts.map +1 -1
- package/dist/src/types/trace.d.ts +2 -2
- package/dist/src/types/trace.d.ts.map +1 -1
- package/package.json +3 -4
- package/src/agent.ts +99 -36
- package/src/code-execution/index.ts +1 -0
- package/src/code-execution/py-runner.ts +104 -17
- package/src/code-execution/runtime-py.ts +47 -7
- package/src/code-execution/tool-definition.ts +65 -11
- package/src/config-checks.ts +4 -2
- package/src/framework.ts +3633 -956
- package/src/gate/event-gate.ts +14 -6
- package/src/held-writes.ts +59 -0
- package/src/index.ts +19 -4
- package/src/mcpl/capability-grant.ts +1 -1
- package/src/mcpl/channel-registry.ts +613 -160
- package/src/mcpl/convert-block.ts +15 -7
- package/src/mcpl/index.ts +14 -1
- package/src/mcpl/inference-router.ts +8 -7
- package/src/mcpl/log-value.ts +10 -0
- package/src/mcpl/modern-connection.ts +1182 -0
- package/src/mcpl/prefix-rule.ts +32 -0
- package/src/mcpl/protocol-family.ts +691 -0
- package/src/mcpl/push-coalescer.ts +67 -29
- package/src/mcpl/reference-fetcher.ts +288 -82
- package/src/mcpl/references.ts +123 -33
- package/src/mcpl/server-connection.ts +1050 -221
- package/src/mcpl/server-registry.ts +50 -12
- package/src/mcpl/tool-result-normalize.ts +294 -0
- package/src/mcpl/transport.ts +204 -62
- package/src/mcpl/types.ts +67 -20
- package/src/mcpl/visible-content.ts +2 -1
- package/src/module-registry.ts +68 -13
- package/src/modules/api/index.ts +3 -2
- package/src/modules/discord/discord-js-client.ts +288 -72
- package/src/modules/discord/index.ts +380 -77
- package/src/modules/discord/refusal.ts +13 -0
- package/src/modules/discord/types.ts +17 -9
- package/src/modules/health/index.ts +6 -2
- package/src/modules/index.ts +1 -1
- package/src/modules/workspace/disk-agreement.ts +46 -5
- package/src/modules/workspace/effects.ts +18 -7
- package/src/modules/workspace/index.ts +722 -124
- package/src/modules/workspace/observe.ts +591 -22
- package/src/modules/workspace/types.ts +10 -1
- package/src/modules/workspace/watcher.ts +126 -25
- package/src/silent-heartbeat.ts +184 -59
- package/src/tool-args.ts +213 -0
- package/src/tool-presentation.ts +9 -2
- package/src/types/agent.ts +27 -17
- package/src/types/events.ts +8 -0
- package/src/types/framework.ts +17 -2
- package/src/types/module.ts +55 -10
- package/src/types/trace.ts +2 -2
|
@@ -0,0 +1,691 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which MCP protocol family a configured server speaks, and over which
|
|
3
|
+
* transport. Both are decided from the configuration alone, before anything
|
|
4
|
+
* is spawned or dialed: there is no cross-family probing and no fallback.
|
|
5
|
+
*
|
|
6
|
+
* - **Legacy**: MCP 2024-11-05, with MCPL riding on it as an experimental
|
|
7
|
+
* capability. `McplServerConnection` speaks it, over stdio or MCPL's
|
|
8
|
+
* WebSocket binding.
|
|
9
|
+
* - **Modern**: MCP 2026-07-28, which is stateless. It has no `initialize`;
|
|
10
|
+
* every request carries its version and client capabilities in `_meta`, and
|
|
11
|
+
* server-to-client requests are replaced by in-band continuation.
|
|
12
|
+
* `ModernMcpConnection` speaks it, over stdio or Streamable HTTP.
|
|
13
|
+
*
|
|
14
|
+
* A URL's scheme decides a network server's family: `ws://`/`wss://` is
|
|
15
|
+
* legacy, `http://`/`https://` is modern, because neither family has the
|
|
16
|
+
* other's binding. A stdio (`command`) server can be either. It is legacy
|
|
17
|
+
* unless its config says `protocol: 'modern'`, so every existing stdio
|
|
18
|
+
* configuration opens exactly as before.
|
|
19
|
+
*
|
|
20
|
+
* Why no auto-detection: the modern spec's discover-first probe makes some
|
|
21
|
+
* real MCPL servers exit (heartbeat-mcpl closes on any first message that
|
|
22
|
+
* isn't `initialize`), the SDK's stdio probe launches the server twice, and a
|
|
23
|
+
* server's era is a stable fact its configuration can state.
|
|
24
|
+
*/
|
|
25
|
+
import { DENY_BY_DEFAULT, capabilityPatternMatches } from './capability-grant.js';
|
|
26
|
+
import { DEFAULT_EAGER_FETCH_MAX_BYTES, DEFAULT_MAX_TOTAL_BYTES } from './reference-fetcher.js';
|
|
27
|
+
import { DEFAULT_MAX_INPUT_BYTES } from './tool-lifecycle.js';
|
|
28
|
+
import type { McplServerConfig, ScopeConfig } from './types.js';
|
|
29
|
+
|
|
30
|
+
/** The one revision the legacy engine offers and accepts. */
|
|
31
|
+
export const LEGACY_MCP_PROTOCOL_VERSION = '2024-11-05';
|
|
32
|
+
/** The revision the modern engine pins. */
|
|
33
|
+
export const MODERN_MCP_PROTOCOL_VERSION = '2026-07-28';
|
|
34
|
+
|
|
35
|
+
export type McpProtocolFamily = 'legacy' | 'modern';
|
|
36
|
+
export type McpTransportKind = 'stdio' | 'websocket' | 'http';
|
|
37
|
+
|
|
38
|
+
export interface ServerBinding {
|
|
39
|
+
family: McpProtocolFamily;
|
|
40
|
+
transport: McpTransportKind;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The largest delay a Node/Bun timer honors (2³¹−1 ms, about 24.8 days). */
|
|
44
|
+
export const MAX_TIMER_MS = 2_147_483_647;
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
type BindingFields = Pick<McplServerConfig, 'id' | 'command' | 'url' | 'transport' | 'protocol'>;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A path segment that may be a credential. Some hosted servers put a
|
|
51
|
+
* per-user secret in the path (`…/mcp/s/<secret>/mcp`). A random token
|
|
52
|
+
* (base64url, hex, a uuid) is long and carries a digit; anything very long
|
|
53
|
+
* is treated as one too. A short or plain-word segment can't be told apart
|
|
54
|
+
* from a name, so it stays.
|
|
55
|
+
*/
|
|
56
|
+
function credentialLikeSegment(segment: string): boolean {
|
|
57
|
+
return (segment.length >= 16 && /[0-9]/.test(segment)) || segment.length >= 32;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Query parameter names that say they carry a credential. */
|
|
61
|
+
const CREDENTIAL_PARAM = /token|key|secret|password|auth|signature/i;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* A query parameter name that may itself be a credential, which
|
|
65
|
+
* {@link redactUrl} replaces and a warning never quotes: one shaped like a
|
|
66
|
+
* token, or one with no value whose name says it carries a credential, since
|
|
67
|
+
* a bare token rides as a name alone (`?<token>`).
|
|
68
|
+
*/
|
|
69
|
+
function credentialLikeQueryName(name: string, value: string): boolean {
|
|
70
|
+
return credentialLikeSegment(name) || (value === '' && CREDENTIAL_PARAM.test(name));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* A url as text a person or a log reads it: its scheme, host and port, and
|
|
75
|
+
* its path with any credential-like segment replaced. The userinfo is
|
|
76
|
+
* removed and each query parameter's value replaced, since a credential
|
|
77
|
+
* rides there too, and so is a parameter name that may itself be one
|
|
78
|
+
* ({@link credentialLikeQueryName}). A url that doesn't parse can't be
|
|
79
|
+
* redacted, so it isn't shown at all.
|
|
80
|
+
*/
|
|
81
|
+
export function redactUrl(url: string | undefined): string {
|
|
82
|
+
let parsed: URL;
|
|
83
|
+
try {
|
|
84
|
+
parsed = new URL(url ?? '');
|
|
85
|
+
} catch {
|
|
86
|
+
return '(an invalid url, not shown)';
|
|
87
|
+
}
|
|
88
|
+
parsed.username = '';
|
|
89
|
+
parsed.password = '';
|
|
90
|
+
parsed.hash = '';
|
|
91
|
+
parsed.pathname = parsed.pathname.split('/').map((segment) => (credentialLikeSegment(segment) ? 'redacted' : segment)).join('/');
|
|
92
|
+
parsed.search = new URLSearchParams([...parsed.searchParams].map(([name, value]): [string, string] => [credentialLikeQueryName(name, value) ? 'redacted' : name, 'redacted'])).toString();
|
|
93
|
+
return parsed.toString();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Server and transport text as the framework records it, with the strings a
|
|
98
|
+
* server's configuration knows to be credentials replaced, and those its
|
|
99
|
+
* dials sent:
|
|
100
|
+
* - from the url as each dial sends it ({@link urlAsDialed}), and decoded:
|
|
101
|
+
* the configured one, and each one dialed with an access provider's token
|
|
102
|
+
* ({@link noteDialSent}): credential-like path segments and
|
|
103
|
+
* query names, credential-shaped or credential-named query values, every
|
|
104
|
+
* other non-empty parameter as its whole name=value, and a WebSocket url's
|
|
105
|
+
* username and password, its userinfo as one unit before its @ (the
|
|
106
|
+
* username alone when there's no password), with no length floor, so a
|
|
107
|
+
* short password goes with its username and a short username alone goes
|
|
108
|
+
* too, and the Basic credential a WebSocket client sends for that userinfo;
|
|
109
|
+
* - the static token, and each token an access provider handed a dial for
|
|
110
|
+
* a header, raw, and percent- or form-encoded with either case of hex;
|
|
111
|
+
* - a stdio server's environment values whose names say they're
|
|
112
|
+
* credentials, raw, and percent-encoded with either case of hex.
|
|
113
|
+
* Each of these strings, from every source, the name=value pairs and
|
|
114
|
+
* userinfo units too, is also replaced as JSON encoders write it:
|
|
115
|
+
* JSON.stringify's escapes (a quote, a backslash, a control character), and
|
|
116
|
+
* with its slashes escaped too, as some encoders write them.
|
|
117
|
+
* A string replaced on its own has at least 8 characters, since a shorter
|
|
118
|
+
* one can't be told from ordinary text, and it is replaced wherever it
|
|
119
|
+
* occurs, after an escape or inside a longer token too. A query parameter is
|
|
120
|
+
* also replaced as its whole name=value, only where no letter or digit
|
|
121
|
+
* adjoins the pair, so v=2 leaves dev=2 alone (a JSON escape before it, such
|
|
122
|
+
* as \n, isn't one); a short value goes only that way. Every place one of
|
|
123
|
+
* these matches is replaced, overlapping places included, and the
|
|
124
|
+
* replacement is never scanned again; text that may come
|
|
125
|
+
* from several servers takes one pass over all their configurations'
|
|
126
|
+
* strings (serverCredentialPatterns), so strings of different servers that
|
|
127
|
+
* overlap are both replaced whole. A credential neither the configuration
|
|
128
|
+
* holds nor a dial of it sent, such as another origin's, is outside it, and
|
|
129
|
+
* so are other encodings of the ones it holds. JSON-RPC answers, and the
|
|
130
|
+
* error a host's own connect raises, pass as they are.
|
|
131
|
+
*
|
|
132
|
+
* A configuration that can't be read, such as one whose `env` is a getter
|
|
133
|
+
* that throws, says nothing of which strings to replace, so the whole text
|
|
134
|
+
* is withheld ({@link WITHHELD_TEXT}) rather than thrown on: a redaction
|
|
135
|
+
* runs where a throw has nowhere to go, such as inside a transport's 'error'
|
|
136
|
+
* listener.
|
|
137
|
+
*/
|
|
138
|
+
export function redactServerText(text: unknown, config: Pick<McplServerConfig, 'url' | 'token' | 'env'>): string {
|
|
139
|
+
return serverTextRedactor(config)(text);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* {@link redactServerText} for one configuration, its strings found and its
|
|
144
|
+
* pattern built once, and again once a dial has sent something new
|
|
145
|
+
* ({@link noteDialSent}): for a connection's records, such as each stderr
|
|
146
|
+
* line. A build that can't read the configuration withholds that call's
|
|
147
|
+
* text, and the next call builds again.
|
|
148
|
+
*/
|
|
149
|
+
export function serverTextRedactor(config: Pick<McplServerConfig, 'url' | 'token' | 'env'>): (text: unknown) => string {
|
|
150
|
+
let built = -1;
|
|
151
|
+
let redact!: (text: unknown) => string;
|
|
152
|
+
return (text) => {
|
|
153
|
+
const version = dialsSent.get(config)?.version ?? 0;
|
|
154
|
+
if (version !== built) {
|
|
155
|
+
let patterns: CredentialPattern[];
|
|
156
|
+
try {
|
|
157
|
+
patterns = serverCredentialPatterns(config);
|
|
158
|
+
} catch {
|
|
159
|
+
return WITHHELD_TEXT;
|
|
160
|
+
}
|
|
161
|
+
redact = credentialRedactor(patterns);
|
|
162
|
+
built = version;
|
|
163
|
+
}
|
|
164
|
+
return redact(text);
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** What a redaction gives in place of text when the configuration it
|
|
169
|
+
* redacts with can't be read ({@link redactServerText}). */
|
|
170
|
+
export const WITHHELD_TEXT = "[withheld: the server's configuration couldn't be read to redact this]";
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* What a configuration's dials sent that the configuration doesn't hold: the
|
|
174
|
+
* urls dialed with an access provider's token, and the provider's tokens,
|
|
175
|
+
* sent in a url or a header. Kept by the configuration object the dial read.
|
|
176
|
+
* For the dials that note anything, a WebSocket dial and an HTTP bearer, that
|
|
177
|
+
* is the object the framework records the server's text with. A provider can
|
|
178
|
+
* hand each dial a new token, so only the newest sixteen of each are kept,
|
|
179
|
+
* newest by when a dial last sent it.
|
|
180
|
+
*/
|
|
181
|
+
const dialsSent = new WeakMap<object, { readonly urls: string[]; readonly tokens: string[]; version: number }>();
|
|
182
|
+
const DIAL_SENT_KEPT = 16;
|
|
183
|
+
|
|
184
|
+
/** Note what one dial sent: the url it dialed, or a token it sent in a
|
|
185
|
+
* header. That configuration's patterns cover it from then on. */
|
|
186
|
+
export function noteDialSent(config: object, sent: { readonly url?: string; readonly token?: string }): void {
|
|
187
|
+
let entry = dialsSent.get(config);
|
|
188
|
+
if (!entry) {
|
|
189
|
+
entry = { urls: [], tokens: [], version: 0 };
|
|
190
|
+
dialsSent.set(config, entry);
|
|
191
|
+
}
|
|
192
|
+
const kept = entry;
|
|
193
|
+
const keep = (list: string[], value: string | undefined): void => {
|
|
194
|
+
if (!value) return;
|
|
195
|
+
const at = list.indexOf(value);
|
|
196
|
+
if (at >= 0) {
|
|
197
|
+
// Sent again: it moves to the newest place, and the set is unchanged.
|
|
198
|
+
list.splice(at, 1);
|
|
199
|
+
list.push(value);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
list.push(value);
|
|
203
|
+
if (list.length > DIAL_SENT_KEPT) list.shift();
|
|
204
|
+
kept.version += 1;
|
|
205
|
+
};
|
|
206
|
+
keep(kept.urls, sent.url);
|
|
207
|
+
keep(kept.tokens, sent.token);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* The url a dial sends. A WebSocket dial sets the token as the `token`
|
|
212
|
+
* parameter, and setting it makes URLSearchParams write the whole query
|
|
213
|
+
* again, form-encoded (a space as `+`), so the url as sent can differ from
|
|
214
|
+
* the url as configured. An http(s) dial sends the url as configured.
|
|
215
|
+
*/
|
|
216
|
+
export function urlAsDialed(url: URL, token: string | null | undefined): URL {
|
|
217
|
+
const dialed = new URL(url);
|
|
218
|
+
if (token && (dialed.protocol === 'ws:' || dialed.protocol === 'wss:')) dialed.searchParams.set('token', token);
|
|
219
|
+
return dialed;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** A string {@link redactServerText} replaces, and the pattern that finds it. */
|
|
223
|
+
export interface CredentialPattern {
|
|
224
|
+
readonly value: string;
|
|
225
|
+
readonly source: string;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* The strings {@link redactServerText} replaces for one configuration, as
|
|
230
|
+
* patterns for {@link credentialRedactor}. Text that may come from several
|
|
231
|
+
* servers goes through one redactor over all their patterns, so a secret
|
|
232
|
+
* that begins inside another configuration's, or shares its start, can't
|
|
233
|
+
* leave any of that one in place.
|
|
234
|
+
*/
|
|
235
|
+
export function serverCredentialPatterns(config: Pick<McplServerConfig, 'url' | 'token' | 'env'>): CredentialPattern[] {
|
|
236
|
+
// Strings replaced wherever they occur (after an escape or inside a longer
|
|
237
|
+
// token too), and name=value pairs, replaced only where no letter or digit
|
|
238
|
+
// adjoins them, so a pair such as v=2 leaves dev=2 alone. A pair's value is
|
|
239
|
+
// also replaced on its own when it's a credential, wherever it occurs.
|
|
240
|
+
const standalones = new Set<string>();
|
|
241
|
+
const pairs = new Set<string>();
|
|
242
|
+
const userinfo = new Set<string>();
|
|
243
|
+
const standalone = (value: string): void => { standalones.add(value); };
|
|
244
|
+
const decode = (value: string, query = false): string => {
|
|
245
|
+
try { return decodeURIComponent(query ? value.replace(/\+/g, ' ') : value); }
|
|
246
|
+
catch { return value; }
|
|
247
|
+
};
|
|
248
|
+
// Percent-escapes in lowercase hex, as some encoders write them.
|
|
249
|
+
const lowerEscapes = (value: string): string => value.replace(/%[0-9A-F]{2}/g, (escape) => escape.toLowerCase());
|
|
250
|
+
// A string as written, and percent-encoded in either case of hex (a string
|
|
251
|
+
// encodeURIComponent refuses, such as one with an unpaired surrogate, has
|
|
252
|
+
// no encoded form). Its JSON forms come with every other string's, below.
|
|
253
|
+
const withEncoded = (value: string): void => {
|
|
254
|
+
standalone(value);
|
|
255
|
+
let encoded: string;
|
|
256
|
+
try { encoded = encodeURIComponent(value); } catch { return; /* no percent-encoded form */ }
|
|
257
|
+
standalone(encoded);
|
|
258
|
+
standalone(lowerEscapes(encoded));
|
|
259
|
+
};
|
|
260
|
+
// A token as sent: raw in a header, and percent- or form-encoded where a
|
|
261
|
+
// transport puts it in a url.
|
|
262
|
+
const addToken = (token: string): void => {
|
|
263
|
+
withEncoded(token);
|
|
264
|
+
const formEncoded = new URLSearchParams({ t: token }).toString().slice(2);
|
|
265
|
+
standalone(formEncoded);
|
|
266
|
+
standalone(lowerEscapes(formEncoded));
|
|
267
|
+
};
|
|
268
|
+
addToken(config.token ?? '');
|
|
269
|
+
// A stdio server's environment values whose names say they carry a
|
|
270
|
+
// credential: a part of the name that ends with a credential word
|
|
271
|
+
// (GITHUB_TOKEN, API_KEY, PGPASSWORD, ACCESSTOKEN), and not one where the
|
|
272
|
+
// word only begins or sits inside a part (GIT_AUTHOR_NAME, KEYBOARD_LAYOUT,
|
|
273
|
+
// TOKENIZERS_PARALLELISM).
|
|
274
|
+
for (const [name, value] of Object.entries(config.env ?? {})) {
|
|
275
|
+
// A JavaScript host can pass a value that isn't a string; only strings are derived.
|
|
276
|
+
if (typeof value === 'string' && name.split(/[_\-.]/).some((part) => CREDENTIAL_NAME_PART.test(part))) withEncoded(value);
|
|
277
|
+
}
|
|
278
|
+
// A url as a dial sends it. Nothing quotes the configured url before a
|
|
279
|
+
// dial, so its form as configured needs no strings of its own.
|
|
280
|
+
const addUrl = (url: URL): void => {
|
|
281
|
+
for (const segment of url.pathname.split('/')) {
|
|
282
|
+
if (credentialLikeSegment(segment)) {
|
|
283
|
+
standalone(segment);
|
|
284
|
+
standalone(decode(segment));
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
for (const parameter of url.search.slice(1).split('&')) {
|
|
288
|
+
const equals = parameter.indexOf('=');
|
|
289
|
+
const encodedName = equals < 0 ? parameter : parameter.slice(0, equals);
|
|
290
|
+
const encodedValue = equals < 0 ? '' : parameter.slice(equals + 1);
|
|
291
|
+
const name = decode(encodedName, true);
|
|
292
|
+
const value = decode(encodedValue, true);
|
|
293
|
+
if (credentialLikeQueryName(name, value)) {
|
|
294
|
+
standalone(encodedName);
|
|
295
|
+
standalone(name);
|
|
296
|
+
}
|
|
297
|
+
if (CREDENTIAL_PARAM.test(name) || credentialLikeSegment(value)) {
|
|
298
|
+
standalone(encodedValue);
|
|
299
|
+
standalone(value);
|
|
300
|
+
}
|
|
301
|
+
if (value !== '') {
|
|
302
|
+
pairs.add(`${encodedName}=${encodedValue}`);
|
|
303
|
+
pairs.add(`${name}=${value}`);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
if (url.protocol === 'ws:' || url.protocol === 'wss:') {
|
|
307
|
+
for (const value of [url.username, url.password]) {
|
|
308
|
+
standalone(value);
|
|
309
|
+
standalone(decode(value));
|
|
310
|
+
}
|
|
311
|
+
// The userinfo itself as one unit wherever it stands before an @ with
|
|
312
|
+
// no letter or digit just before it: username:password, or the
|
|
313
|
+
// username alone when there's no password, as a token written as the
|
|
314
|
+
// username is. So a password too short to replace on its own still
|
|
315
|
+
// goes with its username, and a short username alone goes too. The @
|
|
316
|
+
// and that boundary anchor the unit as a pair's boundaries do, so it
|
|
317
|
+
// has no length floor. And the Basic credential a WebSocket client
|
|
318
|
+
// sends for it. Each from the userinfo as written and as decoded.
|
|
319
|
+
for (const [username, password] of [[url.username, url.password], [decode(url.username), decode(url.password)]]) {
|
|
320
|
+
if (!username && !password) continue;
|
|
321
|
+
userinfo.add(password ? `${username}:${password}` : username);
|
|
322
|
+
standalone(Buffer.from(`${username}:${password}`).toString('base64'));
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
};
|
|
326
|
+
// Read before the parse's try: a configuration that can't be read throws,
|
|
327
|
+
// so its redaction withholds the text rather than redact it without the
|
|
328
|
+
// url's strings.
|
|
329
|
+
const configuredUrl = config.url ?? '';
|
|
330
|
+
let url: URL | undefined;
|
|
331
|
+
try { url = new URL(configuredUrl); } catch { /* Still remove the static token. */ }
|
|
332
|
+
if (url) addUrl(urlAsDialed(url, config.token));
|
|
333
|
+
// What the dials sent beyond that: urls dialed with an access provider's
|
|
334
|
+
// token, and its tokens sent in a header.
|
|
335
|
+
const sent = dialsSent.get(config);
|
|
336
|
+
for (const dialed of sent?.urls ?? []) {
|
|
337
|
+
try { addUrl(new URL(dialed)); } catch { /* a dialed url always parses */ }
|
|
338
|
+
}
|
|
339
|
+
for (const token of sent?.tokens ?? []) addToken(token);
|
|
340
|
+
// Every string above, from every source, as written and as JSON encoders
|
|
341
|
+
// write it: with JSON.stringify's escapes (a quote, a backslash, a control
|
|
342
|
+
// character), and with its slashes escaped too, as some encoders write
|
|
343
|
+
// them. So a url's credential goes in JSON's forms as a token's does: in a
|
|
344
|
+
// log line that prints a server's JSON value as its JSON text, or in a
|
|
345
|
+
// server's JSON that echoes a decoded url secret. A string replaced on its
|
|
346
|
+
// own has at least 8 characters in each form it's replaced in.
|
|
347
|
+
const withJsonForms = (values: Iterable<string>, floor: number): Set<string> => {
|
|
348
|
+
const forms = new Set<string>();
|
|
349
|
+
for (const value of values) {
|
|
350
|
+
const json = JSON.stringify(value).slice(1, -1);
|
|
351
|
+
for (const written of [value, json]) {
|
|
352
|
+
for (const form of [written, written.replace(/\//g, '\\/')]) if (form.length >= floor) forms.add(form);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
return forms;
|
|
356
|
+
};
|
|
357
|
+
const escape = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
358
|
+
// Where a pair or userinfo unit may start: where no letter or digit
|
|
359
|
+
// adjoins it, at the text's start or after any other character, or after a
|
|
360
|
+
// JSON escape that ends in one (\b \f \n \r \t, \uXXXX), as a value's JSON
|
|
361
|
+
// text writes a control character before it.
|
|
362
|
+
const start = String.raw`(?<=^|[^A-Za-z0-9]|\\[bfnrt]|\\u[0-9A-Fa-f]{4})`;
|
|
363
|
+
return [
|
|
364
|
+
...[...withJsonForms(standalones, 8)].map(value => ({ value, source: escape(value) })),
|
|
365
|
+
...[...withJsonForms(pairs, 0)].map(value => ({ value, source: `${start}${escape(value)}(?![A-Za-z0-9])` })),
|
|
366
|
+
...[...withJsonForms(userinfo, 0)].map(value => ({ value, source: `${start}${escape(value)}(?=@)` })),
|
|
367
|
+
];
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Every place one of the given patterns matches becomes `redacted`,
|
|
372
|
+
* overlapping places included: the matches are found in the text as given,
|
|
373
|
+
* a match may begin inside another, and each run of overlapping matches is
|
|
374
|
+
* replaced as one. So two strings that overlap, one's end being the other's
|
|
375
|
+
* start, are both replaced whole, and the replacement is never scanned.
|
|
376
|
+
*/
|
|
377
|
+
export function credentialRedactor(patterns: readonly CredentialPattern[]): (text: unknown) => string {
|
|
378
|
+
if (patterns.length === 0) return asText;
|
|
379
|
+
// Longest first, so at each place the longest string that matches there is
|
|
380
|
+
// the match.
|
|
381
|
+
const sources = [...patterns].sort((a, b) => b.value.length - a.value.length).map(({ source }) => source);
|
|
382
|
+
const pattern = new RegExp([...new Set(sources)].join('|'), 'g');
|
|
383
|
+
return (text) => {
|
|
384
|
+
const input = asText(text);
|
|
385
|
+
const spans: Array<[start: number, end: number]> = [];
|
|
386
|
+
pattern.lastIndex = 0;
|
|
387
|
+
for (let match = pattern.exec(input); match; match = pattern.exec(input)) {
|
|
388
|
+
const start = match.index;
|
|
389
|
+
const end = start + match[0].length;
|
|
390
|
+
const last = spans[spans.length - 1];
|
|
391
|
+
if (last && start < last[1]) last[1] = Math.max(last[1], end);
|
|
392
|
+
else spans.push([start, end]);
|
|
393
|
+
// The next search starts one place on, not after this match, so a
|
|
394
|
+
// match that begins inside this one is found too.
|
|
395
|
+
pattern.lastIndex = start + 1;
|
|
396
|
+
}
|
|
397
|
+
let redacted = '';
|
|
398
|
+
let from = 0;
|
|
399
|
+
for (const [start, end] of spans) {
|
|
400
|
+
redacted += `${input.slice(from, start)}redacted`;
|
|
401
|
+
from = end;
|
|
402
|
+
}
|
|
403
|
+
return redacted + input.slice(from);
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** Any value as text: a value that can't be printed reads as a placeholder. */
|
|
408
|
+
export function asText(text: unknown): string {
|
|
409
|
+
if (typeof text === 'string') return text;
|
|
410
|
+
try { return String(text); } catch { return '[a value that cannot be printed]'; }
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** A part of an environment variable's name that ends with a credential word, in the singular or plural, with any digits after it. */
|
|
414
|
+
const CREDENTIAL_NAME_PART = /(?:tokens?|keys?|secrets?|passwords?|passwd|pass|passphrases?|passcodes?|pat|auth|authorization|authentication|bearer|cookies?|creds|signatures?|credentials?)\d*$/i;
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* A credential provider's failure as a log line may show it: the error's
|
|
418
|
+
* class and its message's length, never the text, which may quote the
|
|
419
|
+
* credential it failed to refresh.
|
|
420
|
+
*/
|
|
421
|
+
export function describeCredentialError(error: unknown): string {
|
|
422
|
+
if (!(error instanceof Error)) return `a thrown ${typeof error}`;
|
|
423
|
+
const length = error.message.length;
|
|
424
|
+
return `${error.constructor?.name || error.name || 'Error'}, ${length} character${length === 1 ? '' : 's'}`;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function urlScheme(config: BindingFields): string {
|
|
428
|
+
let parsed: URL;
|
|
429
|
+
try {
|
|
430
|
+
parsed = new URL(config.url!);
|
|
431
|
+
} catch {
|
|
432
|
+
// Not echoed: a url that doesn't parse can't be redacted.
|
|
433
|
+
throw new Error(`MCP server "${config.id}": invalid url (it isn't shown, since it may carry a credential)`);
|
|
434
|
+
}
|
|
435
|
+
return parsed.protocol;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Resolve a server's family and transport. Throws on a configuration that
|
|
440
|
+
* names no usable transport (for example an `ftp://` URL, or
|
|
441
|
+
* `transport: 'http'` with a WebSocket URL), or names two without saying
|
|
442
|
+
* which: `command` and `url` together need a `transport`, since one of them
|
|
443
|
+
* goes unused. (A host may layer a url and its transport over a file's
|
|
444
|
+
* command, so both with a `transport` stay valid.)
|
|
445
|
+
*/
|
|
446
|
+
export function resolveServerBinding(config: BindingFields): ServerBinding {
|
|
447
|
+
if (config.command && config.url && config.transport === undefined) {
|
|
448
|
+
throw new Error(
|
|
449
|
+
`MCP server "${config.id}": "command" and "url" are both set, and no "transport" says which one to use: ` +
|
|
450
|
+
`a server is either launched (command) or dialed (url)`,
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
const networkKind = (): McpTransportKind => {
|
|
454
|
+
const scheme = urlScheme(config);
|
|
455
|
+
if (scheme === 'ws:' || scheme === 'wss:') return 'websocket';
|
|
456
|
+
if (scheme === 'http:' || scheme === 'https:') return 'http';
|
|
457
|
+
throw new Error(
|
|
458
|
+
`MCP server "${config.id}": url must be ws:// or wss:// (legacy MCPL) or http:// or https:// (modern MCP), got "${scheme}"`,
|
|
459
|
+
);
|
|
460
|
+
};
|
|
461
|
+
|
|
462
|
+
let transport: McpTransportKind;
|
|
463
|
+
if (config.transport === 'stdio' || (config.transport === undefined && config.command)) {
|
|
464
|
+
if (!config.command) {
|
|
465
|
+
throw new Error(`MCP server "${config.id}": the stdio transport requires "command"`);
|
|
466
|
+
}
|
|
467
|
+
transport = 'stdio';
|
|
468
|
+
} else if (config.url) {
|
|
469
|
+
transport = networkKind();
|
|
470
|
+
if (config.transport !== undefined && config.transport !== transport) {
|
|
471
|
+
throw new Error(
|
|
472
|
+
`MCP server "${config.id}": transport "${config.transport}" does not match url ${redactUrl(config.url)}`,
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
} else if (config.transport !== undefined) {
|
|
476
|
+
throw new Error(`MCP server "${config.id}": transport "${config.transport}" requires "url"`);
|
|
477
|
+
} else {
|
|
478
|
+
throw new Error(`MCP server "${config.id}": needs "command" (stdio) or "url" (WebSocket or HTTP)`);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
if (transport === 'stdio') return { family: config.protocol ?? 'legacy', transport };
|
|
482
|
+
return { family: transport === 'http' ? 'modern' : 'legacy', transport };
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/** A configured value that would take effect, as opposed to an empty default. */
|
|
486
|
+
function isEffective(value: unknown): boolean {
|
|
487
|
+
if (value === undefined || value === null || value === false) return false;
|
|
488
|
+
if (Array.isArray(value)) return value.length > 0;
|
|
489
|
+
if (typeof value === 'object') return Object.keys(value as object).length > 0;
|
|
490
|
+
return true;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
|
494
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* The parts of a configuration's MCPL policy whose values grant something,
|
|
499
|
+
* by path, such as `allowHostCommands`, `scopes.<set>.whitelist`,
|
|
500
|
+
* `enabledCapabilities: toolLifecycle.observe`, `toolLifecycle.maxInputBytes`
|
|
501
|
+
* or `autofetch.maxBytes`: each
|
|
502
|
+
* asks for behaviour only an MCPL server can have. A modern server has none,
|
|
503
|
+
* so such a value can't apply there, and is refused rather than ignored, so
|
|
504
|
+
* that an operator who chose it learns so.
|
|
505
|
+
*
|
|
506
|
+
* A value that only takes something away, or repeats a default, is already
|
|
507
|
+
* satisfied on a modern server and isn't listed: `enabledFeatureSets`, an
|
|
508
|
+
* allow-list over every declared set (`feature-set-manager.ts`);
|
|
509
|
+
* `enabledCapabilities` patterns that match no path denied by default, since
|
|
510
|
+
* elsewhere it is an allow-list too; `disabledFeatureSets`,
|
|
511
|
+
* `disabledCapabilities`, a scope's `blacklist`, `channelSubscription:
|
|
512
|
+
* 'manual'` (the default), `toolLifecycle` with only a `maxInputBytes` at
|
|
513
|
+
* or below its default or with an `observe` or `inputs` that isn't a policy
|
|
514
|
+
* object (which grants nothing), an `autofetch` bound at or below its
|
|
515
|
+
* default, and
|
|
516
|
+
* `shouldTriggerInference`, which only narrows which channel messages
|
|
517
|
+
* trigger inference. So a host that stamps a deny-list on every server it
|
|
518
|
+
* deploys can deploy a modern one. Empty values grant nothing either.
|
|
519
|
+
*/
|
|
520
|
+
export function mcplPolicyGrants(config: McplServerConfig): string[] {
|
|
521
|
+
const grants: string[] = [];
|
|
522
|
+
if (isEffective(config.allowHostCommands)) grants.push('allowHostCommands');
|
|
523
|
+
// An allow-list, except that a pattern matching a path denied by default
|
|
524
|
+
// grants it (computeGrant's explicitlyEnabled), named by the path.
|
|
525
|
+
const enabled = config.enabledCapabilities as unknown;
|
|
526
|
+
if (Array.isArray(enabled)) {
|
|
527
|
+
for (const path of DENY_BY_DEFAULT) {
|
|
528
|
+
if (enabled.some((pattern) => typeof pattern !== 'string' || capabilityPatternMatches(pattern, path))) {
|
|
529
|
+
grants.push(`enabledCapabilities: ${path}`);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
} else if (isEffective(enabled)) {
|
|
533
|
+
grants.push('enabledCapabilities');
|
|
534
|
+
}
|
|
535
|
+
if (config.channelSubscription !== 'manual' && isEffective(config.channelSubscription)) grants.push('channelSubscription');
|
|
536
|
+
if (isPlainRecord(config.scopes)) {
|
|
537
|
+
for (const [set, scope] of Object.entries(config.scopes)) {
|
|
538
|
+
if (isEffective((scope as ScopeConfig | null | undefined)?.whitelist)) grants.push(`scopes.${set}.whitelist`);
|
|
539
|
+
}
|
|
540
|
+
} else if (isEffective(config.scopes)) {
|
|
541
|
+
grants.push('scopes');
|
|
542
|
+
}
|
|
543
|
+
for (const key of ['observe', 'inputs'] as const) {
|
|
544
|
+
if (isPlainRecord((config.toolLifecycle as Record<string, unknown> | undefined)?.[key])) grants.push(`toolLifecycle.${key}`);
|
|
545
|
+
}
|
|
546
|
+
// A larger input bound than the default asks for more than MCPL gives
|
|
547
|
+
// by default, as an autofetch bound above its default does.
|
|
548
|
+
const maxInputBytes = (config.toolLifecycle as Record<string, unknown> | undefined)?.maxInputBytes;
|
|
549
|
+
if (maxInputBytes !== undefined && maxInputBytes !== null && !(typeof maxInputBytes === 'number' && maxInputBytes <= DEFAULT_MAX_INPUT_BYTES)) {
|
|
550
|
+
grants.push('toolLifecycle.maxInputBytes');
|
|
551
|
+
}
|
|
552
|
+
const bounds = [['maxBytes', DEFAULT_EAGER_FETCH_MAX_BYTES], ['maxTotalBytes', DEFAULT_MAX_TOTAL_BYTES]] as const;
|
|
553
|
+
for (const [key, ceiling] of bounds) {
|
|
554
|
+
const value = (config.autofetch as Record<string, unknown> | undefined)?.[key];
|
|
555
|
+
if (value !== undefined && value !== null && !(typeof value === 'number' && value <= ceiling)) grants.push(`autofetch.${key}`);
|
|
556
|
+
}
|
|
557
|
+
return grants;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* Problems with a server configuration's protocol settings, as messages; empty
|
|
562
|
+
* when it is usable. Legacy configurations are checked only for what this
|
|
563
|
+
* design added, so every configuration that worked before still passes but
|
|
564
|
+
* one: `command` and `url` with no `transport`, which was launched as stdio
|
|
565
|
+
* with its url ignored, and is now refused as naming no transport to use.
|
|
566
|
+
*/
|
|
567
|
+
export function serverConfigProblems(config: McplServerConfig): string[] {
|
|
568
|
+
let binding: ServerBinding;
|
|
569
|
+
try {
|
|
570
|
+
binding = resolveServerBinding(config);
|
|
571
|
+
} catch (error) {
|
|
572
|
+
return [(error as Error).message];
|
|
573
|
+
}
|
|
574
|
+
const problems: string[] = [];
|
|
575
|
+
if (binding.transport === 'http') {
|
|
576
|
+
// fetch refuses a url with userinfo, and its error quotes the whole url,
|
|
577
|
+
// password included, into the connect failure's log line, trace and
|
|
578
|
+
// ops alert. A setting nothing could send is refused, not ignored.
|
|
579
|
+
const url = new URL(config.url!);
|
|
580
|
+
if (url.username || url.password) {
|
|
581
|
+
problems.push(
|
|
582
|
+
`MCP server "${config.id}": an http(s):// url can't carry userinfo (user:password@), since the HTTP client refuses such a url; give the credential as "token" or "accessProvider"`,
|
|
583
|
+
);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
if (config.protocol !== undefined) {
|
|
587
|
+
if (config.protocol !== 'legacy' && config.protocol !== 'modern') {
|
|
588
|
+
problems.push(`MCP server "${config.id}": protocol must be 'legacy' or 'modern', got ${JSON.stringify(config.protocol)}`);
|
|
589
|
+
} else if (binding.transport !== 'stdio') {
|
|
590
|
+
problems.push(
|
|
591
|
+
`MCP server "${config.id}": "protocol" applies only to stdio (command) servers; the url's scheme decides a network server's family`,
|
|
592
|
+
);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
const timeout = config.requestTimeoutMs;
|
|
596
|
+
if (binding.family === 'modern') {
|
|
597
|
+
if (timeout !== undefined && !(Number.isInteger(timeout) && timeout >= 1 && timeout <= MAX_TIMER_MS)) {
|
|
598
|
+
problems.push(
|
|
599
|
+
`MCP server "${config.id}": requestTimeoutMs must be an integer from 1 to ${MAX_TIMER_MS} for a modern server (got ${String(timeout)}); every modern call has a deadline`,
|
|
600
|
+
);
|
|
601
|
+
}
|
|
602
|
+
for (const path of mcplPolicyGrants(config)) {
|
|
603
|
+
problems.push(`MCP server "${config.id}": "${path}" asks for MCPL behaviour, which a modern MCP server cannot negotiate`);
|
|
604
|
+
}
|
|
605
|
+
// A modern stdio server takes no credential: nothing would carry it.
|
|
606
|
+
if (binding.transport === 'stdio') {
|
|
607
|
+
for (const field of ['token', 'accessProvider'] as const) {
|
|
608
|
+
if (config[field]) {
|
|
609
|
+
problems.push(`MCP server "${config.id}": "${field}" applies only to a network (url) server; a modern stdio server would ignore it`);
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
} else if (typeof timeout === 'number' && timeout > MAX_TIMER_MS) {
|
|
614
|
+
// Never usable: Node runs a longer timer after about 1 ms, so every
|
|
615
|
+
// request timed out at once. Every value that worked (0 included) passes.
|
|
616
|
+
problems.push(
|
|
617
|
+
`MCP server "${config.id}": requestTimeoutMs must be at most ${MAX_TIMER_MS} (got ${String(timeout)}), or 0 to disable the request timeout; a larger value times every request out at once`,
|
|
618
|
+
);
|
|
619
|
+
}
|
|
620
|
+
return problems;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/** A host a connection to which never leaves this machine, as the WHATWG URL
|
|
624
|
+
* parser gives it. The unspecified addresses count: a connection to 0.0.0.0
|
|
625
|
+
* or [::] reaches this host. So do 127.0.0.0/8 written as an IPv4-mapped
|
|
626
|
+
* IPv6 address ([::ffff:127.0.0.1] parses as [::ffff:7f00:1]) and a name
|
|
627
|
+
* with its root's trailing dot (localhost.). */
|
|
628
|
+
function isLoopbackHost(hostname: string): boolean {
|
|
629
|
+
const host = hostname.toLowerCase().replace(/\.$/, '');
|
|
630
|
+
return host === 'localhost' || host.endsWith('.localhost') || host === '[::1]' || /^127(\.\d{1,3}){3}$/.test(host) ||
|
|
631
|
+
/^\[::ffff:7f[0-9a-f]{2}:[0-9a-f]{1,4}\]$/.test(host) || host === '0.0.0.0' || host === '[::]';
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/**
|
|
635
|
+
* What is usable in a server configuration but worth an operator's attention,
|
|
636
|
+
* as messages; empty when there is nothing. A credential for an `http://` or
|
|
637
|
+
* `ws://` url whose host isn't loopback crosses the network in cleartext:
|
|
638
|
+
* `token` or `accessProvider` (in the `Authorization` header, or the
|
|
639
|
+
* WebSocket `?token=` query), and a secret the url carries itself (a ws://
|
|
640
|
+
* url's userinfo, a query parameter whose name says it's a credential, or
|
|
641
|
+
* whose value is shaped like one under a plain name, a query parameter name
|
|
642
|
+
* that may itself be one, or a path segment that looks like one, as
|
|
643
|
+
* {@link redactUrl} judges it). A name that may itself be a credential is
|
|
644
|
+
* counted, never quoted. That can be deliberate, on a private network or
|
|
645
|
+
* behind a TLS-terminating proxy, so it is a warning and never a refusal.
|
|
646
|
+
*/
|
|
647
|
+
export function serverConfigWarnings(config: McplServerConfig): string[] {
|
|
648
|
+
let transport: McpTransportKind;
|
|
649
|
+
try {
|
|
650
|
+
transport = resolveServerBinding(config).transport;
|
|
651
|
+
} catch {
|
|
652
|
+
return []; // an unusable configuration is serverConfigProblems' to report
|
|
653
|
+
}
|
|
654
|
+
if (transport === 'stdio') return [];
|
|
655
|
+
const url = new URL(config.url!);
|
|
656
|
+
if ((url.protocol !== 'http:' && url.protocol !== 'ws:') || isLoopbackHost(url.hostname)) return [];
|
|
657
|
+
const carried: string[] = [];
|
|
658
|
+
if (config.token || config.accessProvider) carried.push('its credential');
|
|
659
|
+
// An http:// url's userinfo is refused (serverConfigProblems); a ws://
|
|
660
|
+
// url sends it.
|
|
661
|
+
if (url.protocol === 'ws:' && (url.username || url.password)) carried.push("its url's userinfo");
|
|
662
|
+
if (url.pathname.split('/').some(credentialLikeSegment)) carried.push("a credential-like segment of its url's path");
|
|
663
|
+
const entries = [...url.searchParams];
|
|
664
|
+
const hidden = entries.filter(([name, value]) => credentialLikeQueryName(name, value)).length;
|
|
665
|
+
// A parameter carries a credential by its name, or by its value's shape,
|
|
666
|
+
// as redaction reads it; the name is shown only when it isn't one itself.
|
|
667
|
+
const params = [...new Set(entries.filter(([name, value]) => !credentialLikeQueryName(name, value) && (CREDENTIAL_PARAM.test(name) || credentialLikeSegment(value))).map(([name]) => name))];
|
|
668
|
+
if (params.length > 0) {
|
|
669
|
+
carried.push(`its url's query ${params.length === 1 ? 'parameter' : 'parameters'} ${params.map((name) => JSON.stringify(name)).join(', ')}`);
|
|
670
|
+
}
|
|
671
|
+
if (hidden > 0) {
|
|
672
|
+
carried.push(hidden === 1 ? 'a query parameter whose name looks like a credential' : `${hidden} query parameters whose names look like credentials`);
|
|
673
|
+
}
|
|
674
|
+
if (carried.length === 0) return [];
|
|
675
|
+
const what = carried.length === 1 ? carried[0] : `${carried.slice(0, -1).join(', ')} and ${carried[carried.length - 1]}`;
|
|
676
|
+
const one = carried.length === 1 && params.length <= 1 && hidden <= 1;
|
|
677
|
+
const secure = url.protocol === 'http:' ? 'https' : 'wss';
|
|
678
|
+
return [
|
|
679
|
+
`MCP server "${config.id}": ${what} ${one ? 'goes' : 'go'} to ${url.host} unencrypted (${url.protocol}//), ` +
|
|
680
|
+
`where anything on the network path can read ${one ? 'it' : 'them'}; use ${secure}:// unless that network is trusted`,
|
|
681
|
+
];
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/** Throw one error listing every problem. Otherwise log each warning and
|
|
685
|
+
* return the resolved binding. Both engines admit a configuration here. */
|
|
686
|
+
export function checkServerConfig(config: McplServerConfig): ServerBinding {
|
|
687
|
+
const problems = serverConfigProblems(config);
|
|
688
|
+
if (problems.length > 0) throw new Error(problems.join('; '));
|
|
689
|
+
for (const warning of serverConfigWarnings(config)) console.warn(`[mcp] ${warning}`);
|
|
690
|
+
return resolveServerBinding(config);
|
|
691
|
+
}
|