@ikuma.cloud/pix-mcp 0.0.1 → 0.0.2
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 +86 -20
- package/mcp.schema.json +126 -0
- package/package.json +4 -2
- package/src/client.ts +158 -80
- package/src/config.ts +234 -122
- package/src/index.ts +31 -8
package/README.md
CHANGED
|
@@ -59,30 +59,94 @@ contact remote services. Configuration trust is not an OS sandbox.
|
|
|
59
59
|
"url": "https://mcp.example.com/mcp",
|
|
60
60
|
"headers": { "Authorization": "Bearer ${SERVICE_TOKEN}" },
|
|
61
61
|
"description": "Issue tracking tools",
|
|
62
|
-
"
|
|
62
|
+
"timeout": 960000,
|
|
63
|
+
"startupTimeoutMs": 30000,
|
|
64
|
+
"catalogTimeoutMs": 30000,
|
|
63
65
|
"approve": true
|
|
64
66
|
}
|
|
65
67
|
}
|
|
66
68
|
}
|
|
67
69
|
```
|
|
68
70
|
|
|
69
|
-
Supported
|
|
71
|
+
### Supported configuration
|
|
72
|
+
|
|
73
|
+
MCP standardizes the protocol, not a universal configuration file. This adapter
|
|
74
|
+
supports a [Claude Code-style](https://code.claude.com/docs/en/mcp) connection
|
|
75
|
+
subset, not every client-specific field or transport. OpenCode configuration,
|
|
76
|
+
Cursor's `${env:VAR}` syntax, and other clients' approval policies are not imported
|
|
77
|
+
or translated.
|
|
78
|
+
|
|
79
|
+
The published [JSON Schema](mcp.schema.json) provides editor validation. An
|
|
80
|
+
optional root `$schema` string can reference your installed copy; the adapter
|
|
81
|
+
never fetches that URL. Runtime validation additionally checks expanded strings,
|
|
82
|
+
URLs, and HTTP headers.
|
|
70
83
|
|
|
71
84
|
| Field | Behavior |
|
|
72
85
|
| --- | --- |
|
|
73
|
-
| `type` | `stdio` or `http
|
|
74
|
-
| `command`, `args`, `env
|
|
75
|
-
| `url`, `headers` | Streamable HTTP only; no SSE fallback or redirect following |
|
|
76
|
-
| `
|
|
77
|
-
| `
|
|
78
|
-
| `
|
|
79
|
-
| `
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
+
| `type` | `stdio`, `http`, or `streamable-http` (alias of `http`); only stdio is inferred, when `command` is present |
|
|
87
|
+
| `command`, `args`, `env` | Stdio only; executable and argument array, not a shell command |
|
|
88
|
+
| `url`, `headers` | Streamable HTTP only; explicit `type` required; no legacy SSE fallback or redirect following |
|
|
89
|
+
| `timeout` | Hard deadline for each tool invocation, in milliseconds; default 30000 |
|
|
90
|
+
| `cwd` | Stdio extension: working directory relative to the config directory |
|
|
91
|
+
| `description` | Discovery extension: optional summary, truncated to 500 characters |
|
|
92
|
+
| `startupTimeoutMs` | pix extension: complete connection/initialization handshake deadline; default 30000 |
|
|
93
|
+
| `catalogTimeoutMs` | pix extension: complete catalog snapshot deadline, including all pages; default 30000 |
|
|
94
|
+
| `approve` | pix policy: require confirmation for every invocation, default `true` |
|
|
95
|
+
| `disabled` | Skip the server's value validation and environment expansion when `true`; unknown fields are still errors |
|
|
96
|
+
|
|
97
|
+
`command`, `args`, `cwd`, `env` values, `url`, and `headers` values expand `${VAR}`
|
|
98
|
+
and `${VAR:-default}`. A default applies only when the variable is unset, not when
|
|
99
|
+
it is an empty string. Expansion is single-pass against Pi's environment; `env`
|
|
100
|
+
entries do not define variables for other entries. Missing variables and invalid
|
|
101
|
+
expanded values fail without echoing credentials. Stdio inherits the SDK's
|
|
102
|
+
minimal platform environment plus explicit `env`, not the entire Pi environment.
|
|
103
|
+
Credentials and fragments in HTTP URLs are rejected; use headers for credentials.
|
|
104
|
+
Unknown fields invalidate their server entry rather than being silently ignored.
|
|
105
|
+
|
|
106
|
+
### Configuration errors
|
|
107
|
+
|
|
108
|
+
An invalid server entry is skipped without hiding healthy servers. Discovery's
|
|
109
|
+
`servers` list includes a safe diagnostic for each skipped entry; a valid server
|
|
110
|
+
name can still be used with `mcp({ action: "list", server: "name" })` to inspect
|
|
111
|
+
its status. Invalid names are replaced with their one-based entry positions.
|
|
112
|
+
Diagnostics identify supported fields or migration steps without echoing URLs,
|
|
113
|
+
commands, headers, argument values, or environment-variable names.
|
|
114
|
+
|
|
115
|
+
Malformed JSON, invalid root structure, unknown root fields, and the file/server
|
|
116
|
+
count limits remain fatal for the whole file. An invalid-only configuration
|
|
117
|
+
reports that no valid servers remain. Disabled entries are omitted, not reported
|
|
118
|
+
as failed connections. Correct the file and reload Pi to retry.
|
|
119
|
+
|
|
120
|
+
Configuration trust still applies before any valid server is started or its
|
|
121
|
+
metadata exposed. Per-call approval remains independent of configuration errors.
|
|
122
|
+
|
|
123
|
+
### Deadlines and migration
|
|
124
|
+
|
|
125
|
+
All three deadline fields accept integers from 1 through 2,147,483,647 milliseconds
|
|
126
|
+
(Node's timer-safe maximum). Each defaults independently to 30 seconds. Setting
|
|
127
|
+
`"timeout": 960000` permits a 16-minute tool call without lengthening startup or
|
|
128
|
+
discovery. The call clock starts after invocation approval and connection startup;
|
|
129
|
+
progress does not reset it. Catalog deadlines cover all pages of one snapshot;
|
|
130
|
+
a subsequent list-change refresh starts a new deadline.
|
|
131
|
+
|
|
132
|
+
HTTP deadlines cover response headers and bodies, including JSON and SSE. MCP
|
|
133
|
+
requests borrow the host's HTTP/proxy routing but override its header/body idle
|
|
134
|
+
limits per request; Pi's global settings and unrelated requests are unchanged.
|
|
135
|
+
Notification GET streams have a bounded header wait but no body-idle deadline.
|
|
136
|
+
Upstream proxies and servers may still impose their own limits.
|
|
137
|
+
|
|
138
|
+
**Breaking changes from 0.0.1:**
|
|
139
|
+
|
|
140
|
+
- Replace `timeoutMs` with `timeout` for tool calls. Set `startupTimeoutMs` and
|
|
141
|
+
`catalogTimeoutMs` separately if their defaults are unsuitable. The removed
|
|
142
|
+
field produces a migration diagnostic; it is not an alias.
|
|
143
|
+
- Add `"type": "http"` to remote entries that previously specified only `url`.
|
|
144
|
+
- Connection strings now expand environment references beyond `env` and `headers`.
|
|
145
|
+
|
|
146
|
+
The 120-second call ceiling is removed. This is a configuration migration, not a
|
|
147
|
+
promise to finish a remote operation within its deadline.
|
|
148
|
+
|
|
149
|
+
### Invocation approval
|
|
86
150
|
|
|
87
151
|
Tool invocation requires confirmation independently of config trust. In headless
|
|
88
152
|
mode, calls fail closed unless the reviewed configuration explicitly sets
|
|
@@ -94,7 +158,8 @@ server separately in a trusted environment.
|
|
|
94
158
|
## Discovery and execution
|
|
95
159
|
|
|
96
160
|
At session startup, the adapter connects to trusted servers and fetches their
|
|
97
|
-
paginated tool catalogs.
|
|
161
|
+
paginated tool catalogs. A server's configuration, connection, or discovery
|
|
162
|
+
failure does not hide tools from other servers.
|
|
98
163
|
Full schemas stay out of model context until selected. **Schema exposure is lazy;
|
|
99
164
|
initial connections and metadata discovery are not.**
|
|
100
165
|
|
|
@@ -156,12 +221,13 @@ Schemas are syntax-checked before compilation. Only the default MCP dialect,
|
|
|
156
221
|
JSON Schema 2020-12, is supported; explicit legacy dialects (including embedded
|
|
157
222
|
resources) are rejected rather than interpreted with incorrect reference
|
|
158
223
|
semantics. External schema references are unsupported, but literal `$ref` fields
|
|
159
|
-
inside instance data are allowed.
|
|
160
|
-
handshake use fixed deadlines, not progress-extended timeouts.
|
|
224
|
+
inside instance data are allowed.
|
|
161
225
|
|
|
162
226
|
The adapter never automatically retries `tools/call`: a timeout or lost response
|
|
163
|
-
may occur after a mutating operation took effect.
|
|
164
|
-
|
|
227
|
+
may occur after a mutating operation took effect. Cancellation or expiration
|
|
228
|
+
aborts only the affected HTTP request and sends a best-effort MCP cancellation
|
|
229
|
+
notification; concurrent sibling calls remain usable. Successful output is
|
|
230
|
+
validated against the schema captured when the call began; MCP error results are exempt
|
|
165
231
|
from that success schema. Cancellation is best-effort at the server and does not
|
|
166
232
|
roll back effects.
|
|
167
233
|
|
package/mcp.schema.json
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"title": "pix-mcp configuration",
|
|
4
|
+
"description": "A Claude-style connection subset with pix-specific approval and lifecycle options. Environment expansion and HTTP URL/header validation also occur at runtime.",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"required": ["mcpServers"],
|
|
7
|
+
"additionalProperties": false,
|
|
8
|
+
"properties": {
|
|
9
|
+
"$schema": {
|
|
10
|
+
"$ref": "#/$defs/text",
|
|
11
|
+
"description": "Editor hint only; never fetched by pix-mcp."
|
|
12
|
+
},
|
|
13
|
+
"mcpServers": {
|
|
14
|
+
"type": "object",
|
|
15
|
+
"maxProperties": 32,
|
|
16
|
+
"propertyNames": { "pattern": "^[A-Za-z0-9_-]{1,48}$" },
|
|
17
|
+
"additionalProperties": { "$ref": "#/$defs/server" }
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"$defs": {
|
|
21
|
+
"text": { "type": "string", "minLength": 1, "pattern": "^[^\u0000]*$" },
|
|
22
|
+
"string": { "type": "string", "pattern": "^[^\u0000]*$" },
|
|
23
|
+
"timeout": {
|
|
24
|
+
"type": "integer",
|
|
25
|
+
"minimum": 1,
|
|
26
|
+
"maximum": 2147483647,
|
|
27
|
+
"default": 30000,
|
|
28
|
+
"description": "Fixed deadline in milliseconds; progress does not extend it."
|
|
29
|
+
},
|
|
30
|
+
"common": {
|
|
31
|
+
"type": "object",
|
|
32
|
+
"properties": {
|
|
33
|
+
"description": { "$ref": "#/$defs/text" },
|
|
34
|
+
"timeout": {
|
|
35
|
+
"$ref": "#/$defs/timeout",
|
|
36
|
+
"description": "Hard deadline for one tool invocation."
|
|
37
|
+
},
|
|
38
|
+
"startupTimeoutMs": {
|
|
39
|
+
"$ref": "#/$defs/timeout",
|
|
40
|
+
"description": "pix extension: complete transport connection and initialization handshake."
|
|
41
|
+
},
|
|
42
|
+
"catalogTimeoutMs": {
|
|
43
|
+
"$ref": "#/$defs/timeout",
|
|
44
|
+
"description": "pix extension: one complete catalog snapshot, including all pages."
|
|
45
|
+
},
|
|
46
|
+
"approve": {
|
|
47
|
+
"type": "boolean",
|
|
48
|
+
"default": true,
|
|
49
|
+
"description": "pix extension: require confirmation for every tool invocation."
|
|
50
|
+
},
|
|
51
|
+
"disabled": { "const": false }
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
"stdio": {
|
|
55
|
+
"allOf": [{ "$ref": "#/$defs/common" }],
|
|
56
|
+
"type": "object",
|
|
57
|
+
"required": ["command"],
|
|
58
|
+
"properties": {
|
|
59
|
+
"type": { "const": "stdio" },
|
|
60
|
+
"command": { "$ref": "#/$defs/text" },
|
|
61
|
+
"args": { "type": "array", "items": { "$ref": "#/$defs/string" } },
|
|
62
|
+
"cwd": {
|
|
63
|
+
"$ref": "#/$defs/text",
|
|
64
|
+
"description": "Resolved relative to the configuration directory."
|
|
65
|
+
},
|
|
66
|
+
"env": {
|
|
67
|
+
"type": "object",
|
|
68
|
+
"propertyNames": { "pattern": "^[A-Za-z_][A-Za-z0-9_-]*$" },
|
|
69
|
+
"additionalProperties": { "$ref": "#/$defs/string" }
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
"not": { "anyOf": [{ "required": ["url"] }, { "required": ["headers"] }] }
|
|
73
|
+
},
|
|
74
|
+
"http": {
|
|
75
|
+
"allOf": [{ "$ref": "#/$defs/common" }],
|
|
76
|
+
"type": "object",
|
|
77
|
+
"required": ["type", "url"],
|
|
78
|
+
"properties": {
|
|
79
|
+
"type": { "enum": ["http", "streamable-http"] },
|
|
80
|
+
"url": { "$ref": "#/$defs/text" },
|
|
81
|
+
"headers": {
|
|
82
|
+
"type": "object",
|
|
83
|
+
"propertyNames": { "pattern": "^[!#$%&'*+.^_`|~0-9A-Za-z-]+$" },
|
|
84
|
+
"additionalProperties": { "$ref": "#/$defs/string" }
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
"not": {
|
|
88
|
+
"anyOf": [
|
|
89
|
+
{ "required": ["command"] },
|
|
90
|
+
{ "required": ["args"] },
|
|
91
|
+
{ "required": ["env"] },
|
|
92
|
+
{ "required": ["cwd"] }
|
|
93
|
+
]
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
"server": {
|
|
97
|
+
"type": "object",
|
|
98
|
+
"additionalProperties": false,
|
|
99
|
+
"properties": {
|
|
100
|
+
"type": true,
|
|
101
|
+
"command": true,
|
|
102
|
+
"args": true,
|
|
103
|
+
"env": true,
|
|
104
|
+
"cwd": true,
|
|
105
|
+
"url": true,
|
|
106
|
+
"headers": true,
|
|
107
|
+
"description": true,
|
|
108
|
+
"timeout": true,
|
|
109
|
+
"startupTimeoutMs": true,
|
|
110
|
+
"catalogTimeoutMs": true,
|
|
111
|
+
"approve": true,
|
|
112
|
+
"disabled": { "type": "boolean" }
|
|
113
|
+
},
|
|
114
|
+
"if": {
|
|
115
|
+
"required": ["disabled"],
|
|
116
|
+
"properties": { "disabled": { "const": true } }
|
|
117
|
+
},
|
|
118
|
+
"then": {
|
|
119
|
+
"description": "Disabled entries skip value validation and environment expansion, but unknown fields remain errors."
|
|
120
|
+
},
|
|
121
|
+
"else": {
|
|
122
|
+
"oneOf": [{ "$ref": "#/$defs/stdio" }, { "$ref": "#/$defs/http" }]
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ikuma.cloud/pix-mcp",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.2",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public"
|
|
6
6
|
},
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
],
|
|
12
12
|
"files": [
|
|
13
13
|
"src",
|
|
14
|
+
"mcp.schema.json",
|
|
14
15
|
"!src/**/*.test.ts",
|
|
15
16
|
"!src/fixtures"
|
|
16
17
|
],
|
|
@@ -21,7 +22,8 @@
|
|
|
21
22
|
},
|
|
22
23
|
"dependencies": {
|
|
23
24
|
"@modelcontextprotocol/sdk": "1.30.0",
|
|
24
|
-
"ajv": "8.20.0"
|
|
25
|
+
"ajv": "8.20.0",
|
|
26
|
+
"undici": "8.10.2"
|
|
25
27
|
},
|
|
26
28
|
"peerDependencies": {
|
|
27
29
|
"@earendil-works/pi-ai": "*",
|
package/src/client.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import { fetch as undiciFetch, getGlobalDispatcher } from "undici";
|
|
1
3
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
2
4
|
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
3
5
|
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
@@ -40,6 +42,7 @@ export class Connection {
|
|
|
40
42
|
#stopped = false;
|
|
41
43
|
#changed: (tools: Tool[]) => void;
|
|
42
44
|
#outputValidators = new Map<string, ReturnType<typeof compileSchema>>();
|
|
45
|
+
#operation = new AsyncLocalStorage<AbortSignal>();
|
|
43
46
|
|
|
44
47
|
constructor(config: ServerConfig, changed: (tools: Tool[]) => void) {
|
|
45
48
|
this.config = config;
|
|
@@ -96,44 +99,98 @@ export class Connection {
|
|
|
96
99
|
void this.client.close().catch(() => {});
|
|
97
100
|
}
|
|
98
101
|
|
|
102
|
+
async #deadline<T>(
|
|
103
|
+
timeout: number,
|
|
104
|
+
signal: AbortSignal | undefined,
|
|
105
|
+
action: (signal: AbortSignal) => Promise<T>,
|
|
106
|
+
): Promise<T> {
|
|
107
|
+
const outer = signal
|
|
108
|
+
? AbortSignal.any([signal, this.#lifetime.signal])
|
|
109
|
+
: this.#lifetime.signal;
|
|
110
|
+
outer.throwIfAborted();
|
|
111
|
+
const deadline = new AbortController();
|
|
112
|
+
const transport = new AbortController();
|
|
113
|
+
const abort = () => deadline.abort(outer.reason);
|
|
114
|
+
outer.addEventListener("abort", abort, { once: true });
|
|
115
|
+
const timer = setTimeout(
|
|
116
|
+
() => deadline.abort(new Error("MCP operation timed out.")),
|
|
117
|
+
timeout,
|
|
118
|
+
);
|
|
119
|
+
try {
|
|
120
|
+
// SDK 1.30 does not forward request signals to HTTP fetch. Async context
|
|
121
|
+
// keeps concurrent requests isolated without relying on private SDK IDs.
|
|
122
|
+
return await this.#operation.run(
|
|
123
|
+
AbortSignal.any([deadline.signal, transport.signal]),
|
|
124
|
+
() => action(deadline.signal),
|
|
125
|
+
);
|
|
126
|
+
} finally {
|
|
127
|
+
clearTimeout(timer);
|
|
128
|
+
outer.removeEventListener("abort", abort);
|
|
129
|
+
// Use a separate signal: aborting the SDK signal after success would send
|
|
130
|
+
// a spurious cancellation for an already-completed request in SDK 1.30.
|
|
131
|
+
transport.abort();
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
99
135
|
async #fetch(
|
|
100
136
|
url: Parameters<typeof fetch>[0],
|
|
101
137
|
init: Parameters<typeof fetch>[1],
|
|
102
138
|
) {
|
|
103
|
-
//
|
|
104
|
-
|
|
105
|
-
|
|
139
|
+
// Borrow the host's routing/proxy dispatcher, but override idle limits only
|
|
140
|
+
// for this request. Our AbortSignals bound headers AND bodies. Use the same
|
|
141
|
+
// Undici implementation as the dispatcher to preserve decompression behavior.
|
|
142
|
+
const dispatcher = getGlobalDispatcher().compose(
|
|
143
|
+
(dispatch) => (options, handler) =>
|
|
144
|
+
dispatch({ ...options, headersTimeout: 0, bodyTimeout: 0 }, handler),
|
|
145
|
+
);
|
|
146
|
+
const fetch = (signal: AbortSignal) => {
|
|
147
|
+
const request = {
|
|
106
148
|
...init,
|
|
107
|
-
redirect: "error",
|
|
108
|
-
signal
|
|
109
|
-
|
|
149
|
+
redirect: "error" as const,
|
|
150
|
+
signal,
|
|
151
|
+
dispatcher,
|
|
152
|
+
};
|
|
153
|
+
// SDK/global fetch and npm Undici expose different DOM type declarations;
|
|
154
|
+
// the SDK sends a URL and a serialized JSON body, compatible with both.
|
|
155
|
+
return undiciFetch(
|
|
156
|
+
url as Parameters<typeof undiciFetch>[0],
|
|
157
|
+
request as Parameters<typeof undiciFetch>[1],
|
|
158
|
+
) as unknown as Promise<Response>;
|
|
159
|
+
};
|
|
160
|
+
// Teardown must still send DELETE after the connection lifetime is aborted.
|
|
161
|
+
if (init?.method === "DELETE") return fetch(AbortSignal.timeout(2000));
|
|
110
162
|
const signals = [
|
|
111
163
|
this.#lifetime.signal,
|
|
112
164
|
...(init?.signal ? [init.signal] : []),
|
|
113
165
|
];
|
|
114
166
|
if (init?.method !== "GET") {
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
167
|
+
// This body is generated by the SDK. Only our requests and the initialized
|
|
168
|
+
// handshake notification belong to an operation. Incoming stream callbacks
|
|
169
|
+
// retain their creator's async context: replies to server pings must not
|
|
170
|
+
// inherit the already-finished handshake's abort signal. Cancellation and
|
|
171
|
+
// other control messages also need their own short, lifetime-bound budget.
|
|
172
|
+
const message =
|
|
173
|
+
typeof init?.body === "string" ? JSON.parse(init.body) : undefined;
|
|
174
|
+
const scoped =
|
|
175
|
+
message &&
|
|
176
|
+
(("method" in message && "id" in message) ||
|
|
177
|
+
message.method === "notifications/initialized");
|
|
178
|
+
const operation = scoped ? this.#operation.getStore() : undefined;
|
|
179
|
+
return fetch(
|
|
180
|
+
AbortSignal.any([...signals, operation ?? AbortSignal.timeout(2000)]),
|
|
181
|
+
);
|
|
123
182
|
}
|
|
124
183
|
// GET is optional (405 is valid), but an established notification stream
|
|
125
184
|
// cannot disappear silently: with retries disabled its catalog is stale.
|
|
126
185
|
const headersDeadline = new AbortController();
|
|
127
186
|
const timer = setTimeout(
|
|
128
187
|
() => headersDeadline.abort(),
|
|
129
|
-
this.config.
|
|
188
|
+
this.config.startupTimeoutMs,
|
|
130
189
|
);
|
|
131
190
|
try {
|
|
132
|
-
const response = await
|
|
133
|
-
...
|
|
134
|
-
|
|
135
|
-
signal: AbortSignal.any([...signals, headersDeadline.signal]),
|
|
136
|
-
});
|
|
191
|
+
const response = await fetch(
|
|
192
|
+
AbortSignal.any([...signals, headersDeadline.signal]),
|
|
193
|
+
);
|
|
137
194
|
if (response.status === 405) return response;
|
|
138
195
|
if (
|
|
139
196
|
!response.ok ||
|
|
@@ -177,13 +234,18 @@ export class Connection {
|
|
|
177
234
|
// send. Bound the whole handshake and close the transport to release it.
|
|
178
235
|
const handshakeTimer = setTimeout(
|
|
179
236
|
() => this.#disconnect(),
|
|
180
|
-
this.config.
|
|
237
|
+
this.config.startupTimeoutMs,
|
|
181
238
|
);
|
|
182
239
|
try {
|
|
183
|
-
await this
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
240
|
+
await this.#deadline(
|
|
241
|
+
this.config.startupTimeoutMs,
|
|
242
|
+
undefined,
|
|
243
|
+
(signal) =>
|
|
244
|
+
this.client.connect(this.#transport as Transport, {
|
|
245
|
+
signal,
|
|
246
|
+
timeout: this.config.startupTimeoutMs,
|
|
247
|
+
}),
|
|
248
|
+
);
|
|
187
249
|
} finally {
|
|
188
250
|
clearTimeout(handshakeTimer);
|
|
189
251
|
}
|
|
@@ -220,55 +282,66 @@ export class Connection {
|
|
|
220
282
|
const names = new Set<string>();
|
|
221
283
|
let cursor: string | undefined;
|
|
222
284
|
let bytes = 0;
|
|
223
|
-
|
|
224
|
-
this
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
285
|
+
await this.#deadline(
|
|
286
|
+
this.config.catalogTimeoutMs,
|
|
287
|
+
undefined,
|
|
288
|
+
async (signal) => {
|
|
289
|
+
if (this.client.getServerCapabilities()?.tools) {
|
|
290
|
+
do {
|
|
291
|
+
const page = await this.#deadline(
|
|
292
|
+
this.config.catalogTimeoutMs,
|
|
293
|
+
signal,
|
|
294
|
+
(requestSignal) =>
|
|
295
|
+
this.client.request(
|
|
296
|
+
{
|
|
297
|
+
method: "tools/list",
|
|
298
|
+
params: cursor === undefined ? {} : { cursor },
|
|
299
|
+
},
|
|
300
|
+
catalogResultSchema,
|
|
301
|
+
{
|
|
302
|
+
signal: requestSignal,
|
|
303
|
+
timeout: this.config.catalogTimeoutMs,
|
|
304
|
+
},
|
|
305
|
+
),
|
|
306
|
+
);
|
|
307
|
+
bytes += Buffer.byteLength(JSON.stringify(page));
|
|
308
|
+
if (
|
|
309
|
+
bytes > 2 * 1024 * 1024 ||
|
|
310
|
+
tools.length + page.tools.length > 1000
|
|
311
|
+
)
|
|
312
|
+
throw new Error("Catalog limit");
|
|
313
|
+
for (const tool of page.tools) {
|
|
314
|
+
if (names.has(tool.name))
|
|
315
|
+
throw new Error("Duplicate tool name");
|
|
316
|
+
names.add(tool.name);
|
|
317
|
+
tools.push(tool);
|
|
318
|
+
}
|
|
319
|
+
cursor = page.nextCursor;
|
|
320
|
+
if (cursor !== undefined) {
|
|
321
|
+
if (cursors.has(cursor) || cursors.size >= 100)
|
|
322
|
+
throw new Error("Invalid pagination");
|
|
323
|
+
cursors.add(cursor);
|
|
324
|
+
}
|
|
325
|
+
} while (cursor !== undefined);
|
|
250
326
|
}
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
327
|
+
// Keep a complete, atomic validator snapshot rather than the SDK's
|
|
328
|
+
// per-page mutable cache, including when a catalog changes during a call.
|
|
329
|
+
const validators = new Map<
|
|
330
|
+
string,
|
|
331
|
+
ReturnType<typeof compileSchema>
|
|
332
|
+
>();
|
|
333
|
+
for (const tool of tools) {
|
|
334
|
+
if (tool.outputSchema)
|
|
335
|
+
validators.set(tool.name, compileSchema(tool.outputSchema));
|
|
256
336
|
}
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
}
|
|
266
|
-
signal.throwIfAborted();
|
|
267
|
-
if (!this.#stopped) {
|
|
268
|
-
this.#outputValidators = validators;
|
|
269
|
-
this.status = "Connected";
|
|
270
|
-
this.#changed(tools);
|
|
271
|
-
}
|
|
337
|
+
signal.throwIfAborted();
|
|
338
|
+
if (!this.#stopped) {
|
|
339
|
+
this.#outputValidators = validators;
|
|
340
|
+
this.status = "Connected";
|
|
341
|
+
this.#changed(tools);
|
|
342
|
+
}
|
|
343
|
+
},
|
|
344
|
+
);
|
|
272
345
|
}
|
|
273
346
|
} catch {
|
|
274
347
|
if (!this.#stopped) {
|
|
@@ -295,14 +368,19 @@ export class Connection {
|
|
|
295
368
|
// validates error payloads against success schemas. Use one typed request,
|
|
296
369
|
// then our captured validator; task-only tools are excluded during discovery.
|
|
297
370
|
// Never retry: a lost response does not prove the operation did not run.
|
|
298
|
-
const result = await this
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
371
|
+
const result = await this.#deadline(
|
|
372
|
+
this.config.timeout,
|
|
373
|
+
combined,
|
|
374
|
+
(requestSignal) =>
|
|
375
|
+
this.client.request(
|
|
376
|
+
{ method: "tools/call", params: { name, arguments: args } },
|
|
377
|
+
CallToolResultSchema,
|
|
378
|
+
{
|
|
379
|
+
signal: requestSignal,
|
|
380
|
+
timeout: this.config.timeout,
|
|
381
|
+
resetTimeoutOnProgress: false,
|
|
382
|
+
},
|
|
383
|
+
),
|
|
306
384
|
);
|
|
307
385
|
const parsed = CallToolResultSchema.parse(result);
|
|
308
386
|
if (
|
package/src/config.ts
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
2
|
import { dirname, resolve } from "node:path";
|
|
3
3
|
|
|
4
|
+
export const MAX_TIMEOUT_MS = 2_147_483_647;
|
|
5
|
+
|
|
4
6
|
export interface CommonServer {
|
|
5
7
|
name: string;
|
|
6
8
|
description: string;
|
|
7
|
-
|
|
9
|
+
timeout: number;
|
|
10
|
+
startupTimeoutMs: number;
|
|
11
|
+
catalogTimeoutMs: number;
|
|
8
12
|
approve: boolean;
|
|
9
13
|
}
|
|
10
14
|
export type ServerConfig = CommonServer &
|
|
@@ -18,51 +22,217 @@ export type ServerConfig = CommonServer &
|
|
|
18
22
|
}
|
|
19
23
|
| { type: "http"; url: string; headers: Record<string, string> }
|
|
20
24
|
);
|
|
25
|
+
export interface ConfigIssue {
|
|
26
|
+
name: string;
|
|
27
|
+
field?: string;
|
|
28
|
+
message: string;
|
|
29
|
+
}
|
|
21
30
|
export interface Config {
|
|
22
31
|
path: string;
|
|
23
32
|
servers: ServerConfig[];
|
|
33
|
+
issues: ConfigIssue[];
|
|
24
34
|
}
|
|
25
35
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
36
|
+
class ConfigError extends Error {
|
|
37
|
+
readonly field: string | undefined;
|
|
38
|
+
constructor(message: string, field?: string) {
|
|
39
|
+
// Only fixed messages and known field names belong here, never input values.
|
|
40
|
+
super(`Invalid MCP configuration. ${field ? `${field}: ` : ""}${message}`);
|
|
41
|
+
this.field = field;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function invalid(
|
|
45
|
+
field?: string,
|
|
46
|
+
message = "See the pix-mcp README for supported fields.",
|
|
47
|
+
): never {
|
|
48
|
+
throw new ConfigError(message, field);
|
|
31
49
|
}
|
|
32
|
-
function object(value: unknown): Record<string, unknown> {
|
|
33
|
-
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
50
|
+
function object(value: unknown, field?: string): Record<string, unknown> {
|
|
51
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
52
|
+
invalid(field, "Expected an object.");
|
|
34
53
|
return value as Record<string, unknown>;
|
|
35
54
|
}
|
|
36
|
-
function text(value: unknown): string {
|
|
37
|
-
if (
|
|
38
|
-
|
|
55
|
+
function text(value: unknown, field: string, empty = false): string {
|
|
56
|
+
if (
|
|
57
|
+
typeof value !== "string" ||
|
|
58
|
+
(!empty && value.length === 0) ||
|
|
59
|
+
value.includes("\0")
|
|
60
|
+
)
|
|
61
|
+
invalid(field, "Expected a valid string.");
|
|
39
62
|
return value;
|
|
40
63
|
}
|
|
64
|
+
function expand(
|
|
65
|
+
value: unknown,
|
|
66
|
+
env: NodeJS.ProcessEnv,
|
|
67
|
+
field: string,
|
|
68
|
+
empty = false,
|
|
69
|
+
): string {
|
|
70
|
+
const expanded = text(value, field, empty).replace(
|
|
71
|
+
/\$\{([^}]*)\}|\$\{/g,
|
|
72
|
+
(_match, expression: string | undefined) => {
|
|
73
|
+
const match = expression?.match(
|
|
74
|
+
/^([A-Za-z_][A-Za-z0-9_]*)(?::-([\s\S]*))?$/,
|
|
75
|
+
);
|
|
76
|
+
if (!match?.[1])
|
|
77
|
+
invalid(
|
|
78
|
+
field,
|
|
79
|
+
`Use \${VAR} or \${VAR:-default} environment references.`,
|
|
80
|
+
);
|
|
81
|
+
const replacement =
|
|
82
|
+
(Object.hasOwn(env, match[1]) ? env[match[1]] : undefined) ?? match[2];
|
|
83
|
+
if (replacement === undefined)
|
|
84
|
+
invalid(field, "References an unset environment variable.");
|
|
85
|
+
return replacement;
|
|
86
|
+
},
|
|
87
|
+
);
|
|
88
|
+
return text(expanded, field, empty);
|
|
89
|
+
}
|
|
41
90
|
function strings(
|
|
42
91
|
value: unknown,
|
|
43
92
|
env: NodeJS.ProcessEnv,
|
|
44
|
-
|
|
93
|
+
field: "env" | "headers",
|
|
45
94
|
): Record<string, string> {
|
|
46
95
|
const result: Record<string, string> = Object.create(null);
|
|
47
|
-
for (const [key, entry] of Object.entries(object(value))) {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
if (typeof entry !== "string" || entry.includes("\0")) invalid();
|
|
52
|
-
result[key] = entry.replace(
|
|
53
|
-
/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g,
|
|
54
|
-
(_match, name: string) => {
|
|
55
|
-
const replacement = env[name];
|
|
56
|
-
if (replacement === undefined)
|
|
57
|
-
throw new Error(
|
|
58
|
-
"MCP configuration references an unset environment variable.",
|
|
59
|
-
);
|
|
60
|
-
return replacement;
|
|
61
|
-
},
|
|
62
|
-
);
|
|
96
|
+
for (const [key, entry] of Object.entries(object(value, field))) {
|
|
97
|
+
if (field === "env" && !/^[A-Za-z_][A-Za-z0-9_-]*$/.test(key))
|
|
98
|
+
invalid(field, "Invalid environment variable name.");
|
|
99
|
+
result[key] = expand(entry, env, field, true);
|
|
63
100
|
}
|
|
64
101
|
return result;
|
|
65
102
|
}
|
|
103
|
+
function timeout(value: unknown, field: string): number {
|
|
104
|
+
if (value === undefined) return 30_000;
|
|
105
|
+
// Node timers overflow above this bound and can fire almost immediately.
|
|
106
|
+
if (
|
|
107
|
+
typeof value !== "number" ||
|
|
108
|
+
!Number.isInteger(value) ||
|
|
109
|
+
value < 1 ||
|
|
110
|
+
value > MAX_TIMEOUT_MS
|
|
111
|
+
)
|
|
112
|
+
invalid(
|
|
113
|
+
field,
|
|
114
|
+
`Expected an integer from 1 through ${MAX_TIMEOUT_MS} milliseconds.`,
|
|
115
|
+
);
|
|
116
|
+
return value;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function parseServer(
|
|
120
|
+
name: string,
|
|
121
|
+
raw: unknown,
|
|
122
|
+
path: string,
|
|
123
|
+
env: NodeJS.ProcessEnv,
|
|
124
|
+
): ServerConfig | undefined {
|
|
125
|
+
if (!/^[A-Za-z0-9_-]{1,48}$/.test(name))
|
|
126
|
+
invalid(
|
|
127
|
+
undefined,
|
|
128
|
+
"Server names must contain 1–48 ASCII letters, digits, underscores, or hyphens.",
|
|
129
|
+
);
|
|
130
|
+
const server = object(raw);
|
|
131
|
+
if (Object.hasOwn(server, "timeoutMs"))
|
|
132
|
+
invalid(
|
|
133
|
+
"timeoutMs",
|
|
134
|
+
"Removed; use timeout for tool calls, startupTimeoutMs for initialization, and catalogTimeoutMs for discovery (milliseconds).",
|
|
135
|
+
);
|
|
136
|
+
const allowed = new Set([
|
|
137
|
+
"type",
|
|
138
|
+
"command",
|
|
139
|
+
"args",
|
|
140
|
+
"env",
|
|
141
|
+
"cwd",
|
|
142
|
+
"url",
|
|
143
|
+
"headers",
|
|
144
|
+
"description",
|
|
145
|
+
"timeout",
|
|
146
|
+
"startupTimeoutMs",
|
|
147
|
+
"catalogTimeoutMs",
|
|
148
|
+
"approve",
|
|
149
|
+
"disabled",
|
|
150
|
+
]);
|
|
151
|
+
if (Object.keys(server).some((key) => !allowed.has(key)))
|
|
152
|
+
invalid(
|
|
153
|
+
undefined,
|
|
154
|
+
"Unsupported server field. See the pix-mcp README for supported fields.",
|
|
155
|
+
);
|
|
156
|
+
if (server.disabled !== undefined && typeof server.disabled !== "boolean")
|
|
157
|
+
invalid("disabled", "Expected a boolean.");
|
|
158
|
+
if (server.disabled === true) return undefined;
|
|
159
|
+
if (server.approve !== undefined && typeof server.approve !== "boolean")
|
|
160
|
+
invalid("approve", "Expected a boolean.");
|
|
161
|
+
const common: CommonServer = {
|
|
162
|
+
name,
|
|
163
|
+
description:
|
|
164
|
+
server.description === undefined
|
|
165
|
+
? ""
|
|
166
|
+
: text(server.description, "description").slice(0, 500),
|
|
167
|
+
timeout: timeout(server.timeout, "timeout"),
|
|
168
|
+
startupTimeoutMs: timeout(server.startupTimeoutMs, "startupTimeoutMs"),
|
|
169
|
+
catalogTimeoutMs: timeout(server.catalogTimeoutMs, "catalogTimeoutMs"),
|
|
170
|
+
approve: server.approve !== false,
|
|
171
|
+
};
|
|
172
|
+
const type =
|
|
173
|
+
server.type === undefined && server.command !== undefined
|
|
174
|
+
? "stdio"
|
|
175
|
+
: server.type;
|
|
176
|
+
if (type === "stdio") {
|
|
177
|
+
if (server.url !== undefined || server.headers !== undefined)
|
|
178
|
+
invalid("type", "Stdio servers cannot use url or headers.");
|
|
179
|
+
const args = server.args === undefined ? [] : server.args;
|
|
180
|
+
if (!Array.isArray(args)) invalid("args", "Expected an array of strings.");
|
|
181
|
+
return {
|
|
182
|
+
...common,
|
|
183
|
+
type,
|
|
184
|
+
command: expand(server.command, env, "command"),
|
|
185
|
+
args: args.map((arg) => expand(arg, env, "args", true)),
|
|
186
|
+
env: strings(server.env === undefined ? {} : server.env, env, "env"),
|
|
187
|
+
cwd:
|
|
188
|
+
server.cwd === undefined
|
|
189
|
+
? dirname(path)
|
|
190
|
+
: resolve(dirname(path), expand(server.cwd, env, "cwd")),
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
if (type !== "http" && type !== "streamable-http")
|
|
194
|
+
invalid(
|
|
195
|
+
"type",
|
|
196
|
+
"Use stdio, http, or streamable-http. Remote servers require an explicit type.",
|
|
197
|
+
);
|
|
198
|
+
if (
|
|
199
|
+
[server.command, server.args, server.env, server.cwd].some(
|
|
200
|
+
(value) => value !== undefined,
|
|
201
|
+
)
|
|
202
|
+
)
|
|
203
|
+
invalid("type", "HTTP servers cannot use command, args, env, or cwd.");
|
|
204
|
+
const url = expand(server.url, env, "url");
|
|
205
|
+
let parsed: URL;
|
|
206
|
+
try {
|
|
207
|
+
parsed = new URL(url);
|
|
208
|
+
} catch {
|
|
209
|
+
invalid(
|
|
210
|
+
"url",
|
|
211
|
+
"Expected an HTTP(S) URL without credentials or a fragment.",
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
if (
|
|
215
|
+
!["http:", "https:"].includes(parsed.protocol) ||
|
|
216
|
+
parsed.username ||
|
|
217
|
+
parsed.password ||
|
|
218
|
+
parsed.hash
|
|
219
|
+
)
|
|
220
|
+
invalid(
|
|
221
|
+
"url",
|
|
222
|
+
"Expected an HTTP(S) URL without credentials or a fragment.",
|
|
223
|
+
);
|
|
224
|
+
const headers = strings(
|
|
225
|
+
server.headers === undefined ? {} : server.headers,
|
|
226
|
+
env,
|
|
227
|
+
"headers",
|
|
228
|
+
);
|
|
229
|
+
try {
|
|
230
|
+
new Headers(headers);
|
|
231
|
+
} catch {
|
|
232
|
+
invalid("headers", "Invalid HTTP header name or value.");
|
|
233
|
+
}
|
|
234
|
+
return { ...common, type: "http", url, headers };
|
|
235
|
+
}
|
|
66
236
|
|
|
67
237
|
export async function readConfig(
|
|
68
238
|
path: string,
|
|
@@ -75,107 +245,49 @@ export async function readConfig(
|
|
|
75
245
|
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
76
246
|
throw new Error("Cannot read MCP configuration.");
|
|
77
247
|
}
|
|
78
|
-
if (Buffer.byteLength(source) > 256 * 1024)
|
|
248
|
+
if (Buffer.byteLength(source) > 256 * 1024)
|
|
249
|
+
invalid(undefined, "Configuration exceeds 256 KiB.");
|
|
79
250
|
let value: unknown;
|
|
80
251
|
try {
|
|
81
252
|
value = JSON.parse(source);
|
|
82
253
|
} catch {
|
|
83
|
-
invalid();
|
|
254
|
+
invalid(undefined, "Malformed JSON.");
|
|
84
255
|
}
|
|
85
256
|
const root = object(value);
|
|
86
|
-
if (
|
|
87
|
-
|
|
88
|
-
|
|
257
|
+
if (
|
|
258
|
+
Object.keys(root).some((key) => key !== "mcpServers" && key !== "$schema")
|
|
259
|
+
)
|
|
260
|
+
invalid(
|
|
261
|
+
undefined,
|
|
262
|
+
"Unsupported root field; use mcpServers and optional $schema.",
|
|
263
|
+
);
|
|
264
|
+
// Editor metadata only: never fetch a configuration-provided schema URL.
|
|
265
|
+
if (root.$schema !== undefined) text(root.$schema, "$schema");
|
|
266
|
+
const entries = Object.entries(object(root.mcpServers, "mcpServers"));
|
|
267
|
+
if (entries.length > 32)
|
|
268
|
+
invalid("mcpServers", "At most 32 servers are supported.");
|
|
89
269
|
const servers: ServerConfig[] = [];
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
if (server.disabled === true) continue;
|
|
110
|
-
const type =
|
|
111
|
-
server.type ?? (server.command !== undefined ? "stdio" : "http");
|
|
112
|
-
const timeoutMs = server.timeoutMs ?? 30_000;
|
|
113
|
-
if (
|
|
114
|
-
!Number.isInteger(timeoutMs) ||
|
|
115
|
-
typeof timeoutMs !== "number" ||
|
|
116
|
-
timeoutMs < 100 ||
|
|
117
|
-
timeoutMs > 120_000
|
|
118
|
-
)
|
|
119
|
-
invalid();
|
|
120
|
-
if (server.approve !== undefined && typeof server.approve !== "boolean")
|
|
121
|
-
invalid();
|
|
122
|
-
const common: CommonServer = {
|
|
123
|
-
name,
|
|
124
|
-
description:
|
|
125
|
-
server.description === undefined
|
|
126
|
-
? ""
|
|
127
|
-
: text(server.description).slice(0, 500),
|
|
128
|
-
timeoutMs,
|
|
129
|
-
approve: server.approve !== false,
|
|
130
|
-
};
|
|
131
|
-
if (type === "stdio") {
|
|
132
|
-
if (server.url !== undefined || server.headers !== undefined) invalid();
|
|
133
|
-
const args = server.args ?? [];
|
|
134
|
-
if (
|
|
135
|
-
!Array.isArray(args) ||
|
|
136
|
-
args.some((arg) => typeof arg !== "string" || arg.includes("\0"))
|
|
137
|
-
)
|
|
138
|
-
invalid();
|
|
139
|
-
servers.push({
|
|
140
|
-
...common,
|
|
141
|
-
type,
|
|
142
|
-
command: text(server.command),
|
|
143
|
-
args,
|
|
144
|
-
env: strings(server.env ?? {}, env),
|
|
145
|
-
cwd:
|
|
146
|
-
server.cwd === undefined
|
|
147
|
-
? dirname(path)
|
|
148
|
-
: resolve(dirname(path), text(server.cwd)),
|
|
270
|
+
const issues: ConfigIssue[] = [];
|
|
271
|
+
for (const [index, [name, raw]] of entries.entries()) {
|
|
272
|
+
try {
|
|
273
|
+
const server = parseServer(name, raw, path, env);
|
|
274
|
+
if (server) servers.push(server);
|
|
275
|
+
} catch (error) {
|
|
276
|
+
// Invalid names and unknown keys can themselves contain credentials. Only
|
|
277
|
+
// validated names and our fixed validation messages reach discovery.
|
|
278
|
+
issues.push({
|
|
279
|
+
name: /^[A-Za-z0-9_-]{1,48}$/.test(name)
|
|
280
|
+
? name
|
|
281
|
+
: `Invalid server #${index + 1}`,
|
|
282
|
+
...(error instanceof ConfigError && error.field
|
|
283
|
+
? { field: error.field }
|
|
284
|
+
: {}),
|
|
285
|
+
message:
|
|
286
|
+
error instanceof ConfigError
|
|
287
|
+
? error.message
|
|
288
|
+
: "Invalid MCP configuration. Server could not be validated.",
|
|
149
289
|
});
|
|
150
|
-
}
|
|
151
|
-
if (
|
|
152
|
-
[server.command, server.args, server.env, server.cwd].some(
|
|
153
|
-
(value) => value !== undefined,
|
|
154
|
-
)
|
|
155
|
-
)
|
|
156
|
-
invalid();
|
|
157
|
-
const url = text(server.url);
|
|
158
|
-
let parsed: URL;
|
|
159
|
-
try {
|
|
160
|
-
parsed = new URL(url);
|
|
161
|
-
} catch {
|
|
162
|
-
invalid();
|
|
163
|
-
}
|
|
164
|
-
if (
|
|
165
|
-
!["http:", "https:"].includes(parsed.protocol) ||
|
|
166
|
-
parsed.username ||
|
|
167
|
-
parsed.password ||
|
|
168
|
-
parsed.hash
|
|
169
|
-
)
|
|
170
|
-
invalid();
|
|
171
|
-
const headers = strings(server.headers ?? {}, env, "headers");
|
|
172
|
-
try {
|
|
173
|
-
new Headers(headers);
|
|
174
|
-
} catch {
|
|
175
|
-
invalid();
|
|
176
|
-
}
|
|
177
|
-
servers.push({ ...common, type, url, headers });
|
|
178
|
-
} else invalid();
|
|
290
|
+
}
|
|
179
291
|
}
|
|
180
|
-
return { path, servers };
|
|
292
|
+
return { path, servers, issues };
|
|
181
293
|
}
|
package/src/index.ts
CHANGED
|
@@ -7,7 +7,7 @@ import type {
|
|
|
7
7
|
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
|
|
8
8
|
import { compact, entry, search, summary, type Entry } from "./catalog.ts";
|
|
9
9
|
import { Connection } from "./client.ts";
|
|
10
|
-
import { readConfig, type ServerConfig } from "./config.ts";
|
|
10
|
+
import { readConfig, type ConfigIssue, type ServerConfig } from "./config.ts";
|
|
11
11
|
import { formatResult } from "./output.ts";
|
|
12
12
|
import { compileSchema } from "./schema.ts";
|
|
13
13
|
|
|
@@ -18,6 +18,7 @@ interface State {
|
|
|
18
18
|
loaded: Map<string, string>;
|
|
19
19
|
connections: Map<string, Connection>;
|
|
20
20
|
rejected: Map<string, number>;
|
|
21
|
+
configIssues: ConfigIssue[];
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
const discoveryParameters = Type.Object(
|
|
@@ -81,6 +82,8 @@ export default function mcp(pi: ExtensionAPI) {
|
|
|
81
82
|
function catalog(owner: State | undefined): string {
|
|
82
83
|
if (!owner) return "Catalog is initialized when the Pi session starts.";
|
|
83
84
|
const lines = [owner.status];
|
|
85
|
+
for (const issue of owner.configIssues)
|
|
86
|
+
lines.push(`${issue.name}: ${issue.message}`);
|
|
84
87
|
for (const connection of owner.connections.values()) {
|
|
85
88
|
const config = connection.config;
|
|
86
89
|
const tools = [...owner.entries.values()].filter(
|
|
@@ -124,7 +127,11 @@ export default function mcp(pi: ExtensionAPI) {
|
|
|
124
127
|
if (!owner) throw new Error("MCP session has not started.");
|
|
125
128
|
current(owner);
|
|
126
129
|
deactivate(hiddenTools());
|
|
127
|
-
if (
|
|
130
|
+
if (
|
|
131
|
+
args.server &&
|
|
132
|
+
!owner.connections.has(args.server) &&
|
|
133
|
+
!owner.configIssues.some((issue) => issue.name === args.server)
|
|
134
|
+
)
|
|
128
135
|
throw new Error("Unknown MCP server.");
|
|
129
136
|
const all = [...owner.entries.values()].sort((a, b) =>
|
|
130
137
|
a.name.localeCompare(b.name, "en"),
|
|
@@ -178,11 +185,18 @@ export default function mcp(pi: ExtensionAPI) {
|
|
|
178
185
|
}
|
|
179
186
|
const data = {
|
|
180
187
|
status: owner.status,
|
|
181
|
-
servers: [
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
188
|
+
servers: [
|
|
189
|
+
...owner.configIssues.map((issue) => ({
|
|
190
|
+
name: issue.name,
|
|
191
|
+
status: issue.message,
|
|
192
|
+
unsupportedTools: 0,
|
|
193
|
+
})),
|
|
194
|
+
...[...owner.connections.values()].map((connection) => ({
|
|
195
|
+
name: connection.config.name,
|
|
196
|
+
status: connection.status,
|
|
197
|
+
unsupportedTools: owner.rejected.get(connection.config.name) ?? 0,
|
|
198
|
+
})),
|
|
199
|
+
],
|
|
186
200
|
items: selected.map((item) => ({
|
|
187
201
|
...summary(item),
|
|
188
202
|
active: active.has(item.name),
|
|
@@ -335,6 +349,7 @@ export default function mcp(pi: ExtensionAPI) {
|
|
|
335
349
|
loaded: new Map(),
|
|
336
350
|
connections: new Map(),
|
|
337
351
|
rejected: new Map(),
|
|
352
|
+
configIssues: [],
|
|
338
353
|
};
|
|
339
354
|
state = owner;
|
|
340
355
|
try {
|
|
@@ -363,7 +378,15 @@ export default function mcp(pi: ExtensionAPI) {
|
|
|
363
378
|
"MCP configuration not trusted. Review it, then restart with --mcp-trust-config or --mcp-config <path>.";
|
|
364
379
|
return;
|
|
365
380
|
}
|
|
366
|
-
owner.
|
|
381
|
+
owner.configIssues = config.issues;
|
|
382
|
+
owner.status =
|
|
383
|
+
config.issues.length > 0
|
|
384
|
+
? config.servers.length > 0
|
|
385
|
+
? "Some MCP servers have invalid configuration; valid servers remain available. Use list/search/load to discover and activate tools."
|
|
386
|
+
: "No valid MCP servers configured. Correct configuration errors and reload Pi."
|
|
387
|
+
: config.servers.length > 0
|
|
388
|
+
? "Use list/search/load to discover and activate tools."
|
|
389
|
+
: "No enabled MCP servers configured.";
|
|
367
390
|
for (const server of config.servers) {
|
|
368
391
|
owner.connections.set(
|
|
369
392
|
server.name,
|