@ikuma.cloud/pix-mcp 0.0.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 +175 -0
- package/package.json +36 -0
- package/src/catalog.ts +85 -0
- package/src/client.ts +343 -0
- package/src/config.ts +181 -0
- package/src/index.ts +420 -0
- package/src/output.ts +96 -0
- package/src/schema.ts +97 -0
package/README.md
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# @ikuma.cloud/pix-mcp
|
|
2
|
+
|
|
3
|
+
A small Pi MCP adapter: discover tools, load their schemas on demand, then call
|
|
4
|
+
those tools natively. No scripting engine or model-provider-specific API is
|
|
5
|
+
required.
|
|
6
|
+
|
|
7
|
+
**Read [CONTRIBUTING.md](../../CONTRIBUTING.md) before making changes.**
|
|
8
|
+
|
|
9
|
+
## Development
|
|
10
|
+
|
|
11
|
+
From the repository root:
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
mise run mcp:dev
|
|
15
|
+
mise run test --project pix-mcp
|
|
16
|
+
mise run check
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
The development task runs from `packages/mcp` and disables other extensions so
|
|
20
|
+
another MCP adapter cannot collide with the `mcp` tool or flags. Pass arguments
|
|
21
|
+
through the task, for example:
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
mise run mcp:dev --mcp-config /absolute/path/to/mcp.json
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
For an existing Pi installation, load this package with
|
|
28
|
+
`pi --no-extensions -e /absolute/path/to/pix/packages/mcp`.
|
|
29
|
+
|
|
30
|
+
## Configuration and trust
|
|
31
|
+
|
|
32
|
+
By default, read only `.mcp.json` in Pi's working directory. There is no ancestor
|
|
33
|
+
search, global config merge, automatic import, or persistent metadata cache.
|
|
34
|
+
Relative `--mcp-config` paths resolve from that working directory; a stdio
|
|
35
|
+
server's `cwd` resolves from the config directory and defaults to that directory.
|
|
36
|
+
|
|
37
|
+
A bare `.mcp.json` is not protected by Pi's project-trust mechanism. This adapter
|
|
38
|
+
asks before using the default file. In a headless session, it remains disabled
|
|
39
|
+
unless explicitly trusted. Either of these authorizes the file for one session:
|
|
40
|
+
|
|
41
|
+
- `--mcp-config <path>`: select **and trust** a file.
|
|
42
|
+
- `--mcp-trust-config`: trust the default `.mcp.json`.
|
|
43
|
+
|
|
44
|
+
Review the file first: trusting it can launch arbitrary local programs and
|
|
45
|
+
contact remote services. Configuration trust is not an OS sandbox.
|
|
46
|
+
|
|
47
|
+
```json
|
|
48
|
+
{
|
|
49
|
+
"mcpServers": {
|
|
50
|
+
"local": {
|
|
51
|
+
"command": "node",
|
|
52
|
+
"args": ["server.js"],
|
|
53
|
+
"cwd": "./service",
|
|
54
|
+
"env": { "SERVICE_TOKEN": "${SERVICE_TOKEN}" },
|
|
55
|
+
"description": "Local project documentation tools"
|
|
56
|
+
},
|
|
57
|
+
"remote": {
|
|
58
|
+
"type": "http",
|
|
59
|
+
"url": "https://mcp.example.com/mcp",
|
|
60
|
+
"headers": { "Authorization": "Bearer ${SERVICE_TOKEN}" },
|
|
61
|
+
"description": "Issue tracking tools",
|
|
62
|
+
"timeoutMs": 30000,
|
|
63
|
+
"approve": true
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Supported fields:
|
|
70
|
+
|
|
71
|
+
| Field | Behavior |
|
|
72
|
+
| --- | --- |
|
|
73
|
+
| `type` | `stdio` or `http`; inferred from `command` or `url` when omitted |
|
|
74
|
+
| `command`, `args`, `env`, `cwd` | Stdio only; executable and argument array, not a shell command |
|
|
75
|
+
| `url`, `headers` | Streamable HTTP only; no SSE fallback or redirect following |
|
|
76
|
+
| `description` | Optional short capability summary for discovery |
|
|
77
|
+
| `timeoutMs` | Request/discovery timeout, default 30000; allowed range 100–120000 |
|
|
78
|
+
| `approve` | Require confirmation for every invocation, default `true` |
|
|
79
|
+
| `disabled` | Skip this server when `true` |
|
|
80
|
+
|
|
81
|
+
Only `env` and `headers` values expand `${VARIABLE}` references. Missing
|
|
82
|
+
variables fail without echoing their values. Stdio inherits the SDK's minimal
|
|
83
|
+
platform environment plus explicit `env`, not the entire Pi environment.
|
|
84
|
+
Credentials in HTTP URLs are rejected; use headers instead. Unknown fields fail
|
|
85
|
+
rather than silently accepting unsupported configuration.
|
|
86
|
+
|
|
87
|
+
Tool invocation requires confirmation independently of config trust. In headless
|
|
88
|
+
mode, calls fail closed unless the reviewed configuration explicitly sets
|
|
89
|
+
`"approve": false` for that server. Native calls still pass through Pi's normal
|
|
90
|
+
tool hooks. Server annotations never grant permission. Child stderr and raw SDK
|
|
91
|
+
errors are not printed because they can contain credentials; debug a failing
|
|
92
|
+
server separately in a trusted environment.
|
|
93
|
+
|
|
94
|
+
## Discovery and execution
|
|
95
|
+
|
|
96
|
+
At session startup, the adapter connects to trusted servers and fetches their
|
|
97
|
+
paginated tool catalogs. One failure does not hide tools from other servers.
|
|
98
|
+
Full schemas stay out of model context until selected. **Schema exposure is lazy;
|
|
99
|
+
initial connections and metadata discovery are not.**
|
|
100
|
+
|
|
101
|
+
The agent uses the `mcp` tool:
|
|
102
|
+
|
|
103
|
+
```js
|
|
104
|
+
mcp({ action: "list", server: "local", limit: 20, offset: 0 })
|
|
105
|
+
mcp({ action: "search", query: "documentation", limit: 5 })
|
|
106
|
+
mcp({ action: "load", names: ["exact_name_from_discovery"] })
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
`list` returns paginated summaries without loading tools. `search` uses local,
|
|
110
|
+
deterministic name/description matching and activates up to 10 matches. `load`
|
|
111
|
+
activates up to 10 exact names. A result's `active` field reports whether Pi
|
|
112
|
+
allowed activation. No separate describe call is required: the full tool schema
|
|
113
|
+
is available on the next model request. Loading never executes the tool.
|
|
114
|
+
Selection is tied to the current schema fingerprint: use `search`/`load`, not
|
|
115
|
+
Pi's generic tool-name toggles, to enable a native MCP tool.
|
|
116
|
+
|
|
117
|
+
Native names include a readable server/tool prefix and a deterministic hash to
|
|
118
|
+
avoid normalization collisions. Independent, preapproved native calls can run
|
|
119
|
+
concurrently. Calls requiring confirmation run sequentially to avoid overlapping
|
|
120
|
+
approval dialogs. Discovered tools stay active until session shutdown or a server
|
|
121
|
+
catalog change.
|
|
122
|
+
Changed and removed definitions are withdrawn; changed tools require loading
|
|
123
|
+
again. Unsupported input schemas or metadata, name collisions, and task-only
|
|
124
|
+
tools are counted as `unsupportedTools` in discovery results. An unsupported
|
|
125
|
+
output schema fails that server's discovery. Losing an established HTTP
|
|
126
|
+
notification stream also withdraws tools rather than silently keeping a stale
|
|
127
|
+
catalog; servers that decline the optional stream with HTTP 405 remain usable.
|
|
128
|
+
Reload Pi to reconnect a failed server or reread configuration.
|
|
129
|
+
|
|
130
|
+
Pi handles provider compatibility. Some providers support transcript-anchored
|
|
131
|
+
schema additions; others rebuild the tool set and may invalidate prompt caches.
|
|
132
|
+
For very small catalogs, eager loading would avoid a discovery round trip, but
|
|
133
|
+
v1 intentionally offers only the deferred mode.
|
|
134
|
+
|
|
135
|
+
## Output and limits
|
|
136
|
+
|
|
137
|
+
Text, supported images, and structured content are retained. Long text gets a
|
|
138
|
+
24 KiB / 1000-line preview; structured details are bounded to 16 KiB. At most four
|
|
139
|
+
PNG/JPEG/GIF/WebP images of up to 4 MiB each are shown. Unsupported or oversized
|
|
140
|
+
content is explicitly omitted from the preview, not silently discarded. Pi's
|
|
141
|
+
error-result path is text-only, so images in MCP errors are preserved in a
|
|
142
|
+
full-result artifact rather than displayed inline.
|
|
143
|
+
|
|
144
|
+
When necessary, the full MCP result is written to `pix-mcp-*/result.json` under
|
|
145
|
+
the system temp directory (directory mode 0700, file mode 0600). Pi can inspect it
|
|
146
|
+
with `read`. These artifacts may contain sensitive data and are **not deleted at
|
|
147
|
+
session shutdown**; remove them when no longer needed. Output limits are not a
|
|
148
|
+
complete memory or security sandbox.
|
|
149
|
+
|
|
150
|
+
Configuration is limited to 256 KiB and 32 servers. Startup connects at most four
|
|
151
|
+
servers concurrently. Each catalog is limited to 1000 tools, 100 pagination
|
|
152
|
+
cursors, and 2 MiB of metadata; individual input/output schemas are limited to
|
|
153
|
+
64 KiB. Tool names must use 1–128 ASCII letters, digits, underscores, hyphens, or
|
|
154
|
+
periods; descriptions are limited to 16 KiB. Stdio messages are limited to 16 MiB.
|
|
155
|
+
Schemas are syntax-checked before compilation. Only the default MCP dialect,
|
|
156
|
+
JSON Schema 2020-12, is supported; explicit legacy dialects (including embedded
|
|
157
|
+
resources) are rejected rather than interpreted with incorrect reference
|
|
158
|
+
semantics. External schema references are unsupported, but literal `$ref` fields
|
|
159
|
+
inside instance data are allowed. Requests and the entire initialization
|
|
160
|
+
handshake use fixed deadlines, not progress-extended timeouts.
|
|
161
|
+
|
|
162
|
+
The adapter never automatically retries `tools/call`: a timeout or lost response
|
|
163
|
+
may occur after a mutating operation took effect. Successful output is validated
|
|
164
|
+
against the schema captured when the call began; MCP error results are exempt
|
|
165
|
+
from that success schema. Cancellation is best-effort at the server and does not
|
|
166
|
+
roll back effects.
|
|
167
|
+
|
|
168
|
+
## Deliberately out of scope
|
|
169
|
+
|
|
170
|
+
OAuth, legacy SSE transport, MCP prompts/resources APIs, sampling, elicitation,
|
|
171
|
+
MCP apps, task execution, semantic search, scripting, config UI, and persistent
|
|
172
|
+
catalog caching. Use a fuller adapter when those capabilities are required.
|
|
173
|
+
|
|
174
|
+
Tests use local stdio/HTTP fixture servers and isolated Pi configuration, without
|
|
175
|
+
model requests or personal credentials.
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ikuma.cloud/pix-mcp",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"publishConfig": {
|
|
5
|
+
"access": "public"
|
|
6
|
+
},
|
|
7
|
+
"type": "module",
|
|
8
|
+
"description": "MCP discovery and lazily activated native tools for Pi Coding Agent",
|
|
9
|
+
"keywords": [
|
|
10
|
+
"pi-package"
|
|
11
|
+
],
|
|
12
|
+
"files": [
|
|
13
|
+
"src",
|
|
14
|
+
"!src/**/*.test.ts",
|
|
15
|
+
"!src/fixtures"
|
|
16
|
+
],
|
|
17
|
+
"pi": {
|
|
18
|
+
"extensions": [
|
|
19
|
+
"./src/index.ts"
|
|
20
|
+
]
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"@modelcontextprotocol/sdk": "1.30.0",
|
|
24
|
+
"ajv": "8.20.0"
|
|
25
|
+
},
|
|
26
|
+
"peerDependencies": {
|
|
27
|
+
"@earendil-works/pi-ai": "*",
|
|
28
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
29
|
+
"typebox": "*"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"@earendil-works/pi-ai": "0.87.1",
|
|
33
|
+
"@earendil-works/pi-coding-agent": "0.87.1",
|
|
34
|
+
"typebox": "1.3.27"
|
|
35
|
+
}
|
|
36
|
+
}
|
package/src/catalog.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
|
|
3
|
+
|
|
4
|
+
export interface Entry {
|
|
5
|
+
name: string;
|
|
6
|
+
server: string;
|
|
7
|
+
tool: Tool;
|
|
8
|
+
fingerprint: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function toolName(server: string, original: string): string {
|
|
12
|
+
const suffix = createHash("sha256")
|
|
13
|
+
.update(JSON.stringify([server, original]))
|
|
14
|
+
.digest("hex")
|
|
15
|
+
.slice(0, 12);
|
|
16
|
+
const readable = `${server}_${original}`
|
|
17
|
+
.replace(/[^A-Za-z0-9_]/g, "_")
|
|
18
|
+
.slice(0, 47);
|
|
19
|
+
return `mcp_${readable}_${suffix}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function entry(server: string, tool: Tool): Entry {
|
|
23
|
+
return {
|
|
24
|
+
name: toolName(server, tool.name),
|
|
25
|
+
server,
|
|
26
|
+
tool,
|
|
27
|
+
fingerprint: createHash("sha256")
|
|
28
|
+
.update(JSON.stringify(tool))
|
|
29
|
+
.digest("hex"),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function search(
|
|
34
|
+
entries: Entry[],
|
|
35
|
+
query: string,
|
|
36
|
+
server?: string,
|
|
37
|
+
): Entry[] {
|
|
38
|
+
const words = [
|
|
39
|
+
...new Set(
|
|
40
|
+
query
|
|
41
|
+
.toLowerCase()
|
|
42
|
+
.split(/[^\p{L}\p{N}_-]+/u)
|
|
43
|
+
.filter(Boolean),
|
|
44
|
+
),
|
|
45
|
+
];
|
|
46
|
+
if (!words.length) return [];
|
|
47
|
+
return entries
|
|
48
|
+
.filter((item) => !server || item.server === server)
|
|
49
|
+
.map((item) => {
|
|
50
|
+
const name =
|
|
51
|
+
`${item.server} ${item.tool.name} ${item.name}`.toLowerCase();
|
|
52
|
+
const description = (item.tool.description ?? "").toLowerCase();
|
|
53
|
+
const score = words.reduce(
|
|
54
|
+
(sum, word) =>
|
|
55
|
+
sum +
|
|
56
|
+
(name.includes(word) ? 4 : 0) +
|
|
57
|
+
(description.includes(word) ? 1 : 0),
|
|
58
|
+
0,
|
|
59
|
+
);
|
|
60
|
+
return { item, score };
|
|
61
|
+
})
|
|
62
|
+
.filter((match) => match.score > 0)
|
|
63
|
+
.sort(
|
|
64
|
+
(a, b) =>
|
|
65
|
+
b.score - a.score || a.item.name.localeCompare(b.item.name, "en"),
|
|
66
|
+
)
|
|
67
|
+
.map((match) => match.item);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function summary(item: Entry) {
|
|
71
|
+
return {
|
|
72
|
+
name: item.name,
|
|
73
|
+
server: item.server,
|
|
74
|
+
tool: item.tool.name,
|
|
75
|
+
description: compact(item.tool.description ?? "", 200),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function compact(value: string, limit: number): string {
|
|
80
|
+
const text = value
|
|
81
|
+
.replace(/\p{Cc}/gu, " ")
|
|
82
|
+
.replace(/\s+/g, " ")
|
|
83
|
+
.trim();
|
|
84
|
+
return text.length <= limit ? text : `${text.slice(0, limit)}…`;
|
|
85
|
+
}
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
2
|
+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
3
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
4
|
+
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
|
|
5
|
+
import { mediaTypeEssence } from "@modelcontextprotocol/sdk/shared/mediaType.js";
|
|
6
|
+
import {
|
|
7
|
+
CallToolResultSchema,
|
|
8
|
+
ListToolsResultSchema,
|
|
9
|
+
ToolSchema,
|
|
10
|
+
ToolListChangedNotificationSchema,
|
|
11
|
+
type Tool,
|
|
12
|
+
} from "@modelcontextprotocol/sdk/types.js";
|
|
13
|
+
import type { ServerConfig } from "./config.ts";
|
|
14
|
+
import { compileSchema, schemaValidator } from "./schema.ts";
|
|
15
|
+
|
|
16
|
+
// SDK 1.30's property decoder incorrectly requires every property schema to be
|
|
17
|
+
// an object. JSON Schema 2020-12 also permits true/false. Preserve those values
|
|
18
|
+
// through the wire decoder; our meta-schema checks validate their actual syntax.
|
|
19
|
+
const catalogResultSchema = ListToolsResultSchema.extend({
|
|
20
|
+
tools: ToolSchema.extend({
|
|
21
|
+
inputSchema: ToolSchema.shape.inputSchema.omit({ properties: true }),
|
|
22
|
+
outputSchema: ToolSchema.shape.outputSchema
|
|
23
|
+
.unwrap()
|
|
24
|
+
.omit({ properties: true })
|
|
25
|
+
.optional(),
|
|
26
|
+
}).array(),
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
export class Connection {
|
|
30
|
+
readonly client: Client;
|
|
31
|
+
readonly config: ServerConfig;
|
|
32
|
+
status = "Not connected";
|
|
33
|
+
instructions = "";
|
|
34
|
+
#transport: StdioClientTransport | StreamableHTTPClientTransport;
|
|
35
|
+
#lifetime = new AbortController();
|
|
36
|
+
#initializing: Promise<void> | undefined;
|
|
37
|
+
#refreshing: Promise<void> | undefined;
|
|
38
|
+
#closing: Promise<void> | undefined;
|
|
39
|
+
#dirty = false;
|
|
40
|
+
#stopped = false;
|
|
41
|
+
#changed: (tools: Tool[]) => void;
|
|
42
|
+
#outputValidators = new Map<string, ReturnType<typeof compileSchema>>();
|
|
43
|
+
|
|
44
|
+
constructor(config: ServerConfig, changed: (tools: Tool[]) => void) {
|
|
45
|
+
this.config = config;
|
|
46
|
+
this.#changed = changed;
|
|
47
|
+
this.client = new Client(
|
|
48
|
+
{ name: "pix-mcp", version: "0.0.0" },
|
|
49
|
+
{ capabilities: {}, jsonSchemaValidator: schemaValidator },
|
|
50
|
+
);
|
|
51
|
+
this.#transport =
|
|
52
|
+
config.type === "stdio"
|
|
53
|
+
? new StdioClientTransport({
|
|
54
|
+
command: config.command,
|
|
55
|
+
args: config.args,
|
|
56
|
+
cwd: config.cwd,
|
|
57
|
+
env: config.env,
|
|
58
|
+
stderr: "ignore",
|
|
59
|
+
maxBufferSize: 16 * 1024 * 1024,
|
|
60
|
+
})
|
|
61
|
+
: new StreamableHTTPClientTransport(new URL(config.url), {
|
|
62
|
+
requestInit: { headers: config.headers, redirect: "error" },
|
|
63
|
+
reconnectionOptions: {
|
|
64
|
+
maxRetries: 0,
|
|
65
|
+
initialReconnectionDelay: 1000,
|
|
66
|
+
maxReconnectionDelay: 1000,
|
|
67
|
+
reconnectionDelayGrowFactor: 1,
|
|
68
|
+
},
|
|
69
|
+
fetch: (url, init) => this.#fetch(url, init),
|
|
70
|
+
});
|
|
71
|
+
this.client.onerror = () => {
|
|
72
|
+
/* Request failures are reported without leaking SDK error payloads. */
|
|
73
|
+
};
|
|
74
|
+
this.client.onclose = () => this.#disconnect();
|
|
75
|
+
this.client.setNotificationHandler(
|
|
76
|
+
ToolListChangedNotificationSchema,
|
|
77
|
+
async () => {
|
|
78
|
+
if (this.#stopped) return;
|
|
79
|
+
try {
|
|
80
|
+
await this.refresh();
|
|
81
|
+
} catch {
|
|
82
|
+
/* refresh already clears the stale catalog */
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
#disconnect() {
|
|
89
|
+
if (this.#stopped || this.#lifetime.signal.aborted) return;
|
|
90
|
+
this.status = "Disconnected; reload Pi to reconnect";
|
|
91
|
+
this.#lifetime.abort(
|
|
92
|
+
new Error("MCP connection is unavailable; reload Pi to reconnect."),
|
|
93
|
+
);
|
|
94
|
+
this.#outputValidators.clear();
|
|
95
|
+
this.#changed([]);
|
|
96
|
+
void this.client.close().catch(() => {});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async #fetch(
|
|
100
|
+
url: Parameters<typeof fetch>[0],
|
|
101
|
+
init: Parameters<typeof fetch>[1],
|
|
102
|
+
) {
|
|
103
|
+
// Teardown must still send DELETE after the connection lifetime is aborted.
|
|
104
|
+
if (init?.method === "DELETE")
|
|
105
|
+
return globalThis.fetch(url, {
|
|
106
|
+
...init,
|
|
107
|
+
redirect: "error",
|
|
108
|
+
signal: AbortSignal.timeout(2000),
|
|
109
|
+
});
|
|
110
|
+
const signals = [
|
|
111
|
+
this.#lifetime.signal,
|
|
112
|
+
...(init?.signal ? [init.signal] : []),
|
|
113
|
+
];
|
|
114
|
+
if (init?.method !== "GET") {
|
|
115
|
+
return globalThis.fetch(url, {
|
|
116
|
+
...init,
|
|
117
|
+
redirect: "error",
|
|
118
|
+
signal: AbortSignal.any([
|
|
119
|
+
...signals,
|
|
120
|
+
AbortSignal.timeout(this.config.timeoutMs),
|
|
121
|
+
]),
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
// GET is optional (405 is valid), but an established notification stream
|
|
125
|
+
// cannot disappear silently: with retries disabled its catalog is stale.
|
|
126
|
+
const headersDeadline = new AbortController();
|
|
127
|
+
const timer = setTimeout(
|
|
128
|
+
() => headersDeadline.abort(),
|
|
129
|
+
this.config.timeoutMs,
|
|
130
|
+
);
|
|
131
|
+
try {
|
|
132
|
+
const response = await globalThis.fetch(url, {
|
|
133
|
+
...init,
|
|
134
|
+
redirect: "error",
|
|
135
|
+
signal: AbortSignal.any([...signals, headersDeadline.signal]),
|
|
136
|
+
});
|
|
137
|
+
if (response.status === 405) return response;
|
|
138
|
+
if (
|
|
139
|
+
!response.ok ||
|
|
140
|
+
!response.body ||
|
|
141
|
+
mediaTypeEssence(response.headers.get("content-type")) !==
|
|
142
|
+
"text/event-stream"
|
|
143
|
+
) {
|
|
144
|
+
await response.body?.cancel();
|
|
145
|
+
throw new Error("MCP notification stream unavailable.");
|
|
146
|
+
}
|
|
147
|
+
const stream = new TransformStream<Uint8Array, Uint8Array>();
|
|
148
|
+
void response.body.pipeTo(stream.writable).then(
|
|
149
|
+
() => this.#disconnect(),
|
|
150
|
+
() => this.#disconnect(),
|
|
151
|
+
);
|
|
152
|
+
return new Response(stream.readable, {
|
|
153
|
+
status: response.status,
|
|
154
|
+
statusText: response.statusText,
|
|
155
|
+
headers: response.headers,
|
|
156
|
+
});
|
|
157
|
+
} catch (error) {
|
|
158
|
+
this.#disconnect();
|
|
159
|
+
throw error;
|
|
160
|
+
} finally {
|
|
161
|
+
clearTimeout(timer);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
start(): Promise<void> {
|
|
166
|
+
this.#initializing ??= this.#start();
|
|
167
|
+
return this.#initializing;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async #start(): Promise<void> {
|
|
171
|
+
try {
|
|
172
|
+
this.#lifetime.signal.throwIfAborted();
|
|
173
|
+
this.status = "Connecting";
|
|
174
|
+
// SDK transport getters include undefined while its optional Transport field
|
|
175
|
+
// does not under exactOptionalPropertyTypes; the runtime contract is identical.
|
|
176
|
+
// SDK request timeouts do not cover the awaited notifications/initialized
|
|
177
|
+
// send. Bound the whole handshake and close the transport to release it.
|
|
178
|
+
const handshakeTimer = setTimeout(
|
|
179
|
+
() => this.#disconnect(),
|
|
180
|
+
this.config.timeoutMs,
|
|
181
|
+
);
|
|
182
|
+
try {
|
|
183
|
+
await this.client.connect(this.#transport as Transport, {
|
|
184
|
+
signal: this.#lifetime.signal,
|
|
185
|
+
timeout: this.config.timeoutMs,
|
|
186
|
+
});
|
|
187
|
+
} finally {
|
|
188
|
+
clearTimeout(handshakeTimer);
|
|
189
|
+
}
|
|
190
|
+
this.#lifetime.signal.throwIfAborted();
|
|
191
|
+
this.instructions = this.client.getInstructions() ?? "";
|
|
192
|
+
await this.refresh();
|
|
193
|
+
} catch {
|
|
194
|
+
if (!this.#stopped) {
|
|
195
|
+
this.status =
|
|
196
|
+
"Connection or discovery failed; check configuration/authentication and reload Pi";
|
|
197
|
+
this.#changed([]);
|
|
198
|
+
}
|
|
199
|
+
await this.client.close().catch(() => {});
|
|
200
|
+
throw new Error(
|
|
201
|
+
`MCP server ${this.config.name}: connection or discovery failed.`,
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
refresh(): Promise<void> {
|
|
207
|
+
this.#dirty = true;
|
|
208
|
+
this.#refreshing ??= this.#refresh().finally(() => {
|
|
209
|
+
this.#refreshing = undefined;
|
|
210
|
+
});
|
|
211
|
+
return this.#refreshing;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async #refresh(): Promise<void> {
|
|
215
|
+
try {
|
|
216
|
+
while (this.#dirty && !this.#stopped) {
|
|
217
|
+
this.#dirty = false;
|
|
218
|
+
const tools: Tool[] = [];
|
|
219
|
+
const cursors = new Set<string>();
|
|
220
|
+
const names = new Set<string>();
|
|
221
|
+
let cursor: string | undefined;
|
|
222
|
+
let bytes = 0;
|
|
223
|
+
const signal = AbortSignal.any([
|
|
224
|
+
this.#lifetime.signal,
|
|
225
|
+
AbortSignal.timeout(this.config.timeoutMs),
|
|
226
|
+
]);
|
|
227
|
+
if (this.client.getServerCapabilities()?.tools) {
|
|
228
|
+
do {
|
|
229
|
+
const page = await this.client.request(
|
|
230
|
+
{
|
|
231
|
+
method: "tools/list",
|
|
232
|
+
params: cursor === undefined ? {} : { cursor },
|
|
233
|
+
},
|
|
234
|
+
catalogResultSchema,
|
|
235
|
+
{
|
|
236
|
+
signal,
|
|
237
|
+
timeout: this.config.timeoutMs,
|
|
238
|
+
},
|
|
239
|
+
);
|
|
240
|
+
bytes += Buffer.byteLength(JSON.stringify(page));
|
|
241
|
+
if (
|
|
242
|
+
bytes > 2 * 1024 * 1024 ||
|
|
243
|
+
tools.length + page.tools.length > 1000
|
|
244
|
+
)
|
|
245
|
+
throw new Error("Catalog limit");
|
|
246
|
+
for (const tool of page.tools) {
|
|
247
|
+
if (names.has(tool.name)) throw new Error("Duplicate tool name");
|
|
248
|
+
names.add(tool.name);
|
|
249
|
+
tools.push(tool);
|
|
250
|
+
}
|
|
251
|
+
cursor = page.nextCursor;
|
|
252
|
+
if (cursor !== undefined) {
|
|
253
|
+
if (cursors.has(cursor) || cursors.size >= 100)
|
|
254
|
+
throw new Error("Invalid pagination");
|
|
255
|
+
cursors.add(cursor);
|
|
256
|
+
}
|
|
257
|
+
} while (cursor !== undefined);
|
|
258
|
+
}
|
|
259
|
+
// Keep a complete, atomic validator snapshot rather than the SDK's
|
|
260
|
+
// per-page mutable cache, including when a catalog changes during a call.
|
|
261
|
+
const validators = new Map<string, ReturnType<typeof compileSchema>>();
|
|
262
|
+
for (const tool of tools) {
|
|
263
|
+
if (tool.outputSchema)
|
|
264
|
+
validators.set(tool.name, compileSchema(tool.outputSchema));
|
|
265
|
+
}
|
|
266
|
+
signal.throwIfAborted();
|
|
267
|
+
if (!this.#stopped) {
|
|
268
|
+
this.#outputValidators = validators;
|
|
269
|
+
this.status = "Connected";
|
|
270
|
+
this.#changed(tools);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
} catch {
|
|
274
|
+
if (!this.#stopped) {
|
|
275
|
+
this.status = "Discovery failed; reload Pi to retry";
|
|
276
|
+
this.#changed([]);
|
|
277
|
+
}
|
|
278
|
+
throw new Error(`MCP server ${this.config.name}: discovery failed.`);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async call(
|
|
283
|
+
name: string,
|
|
284
|
+
args: Record<string, unknown>,
|
|
285
|
+
signal?: AbortSignal,
|
|
286
|
+
) {
|
|
287
|
+
await this.start();
|
|
288
|
+
const combined = signal
|
|
289
|
+
? AbortSignal.any([signal, this.#lifetime.signal])
|
|
290
|
+
: this.#lifetime.signal;
|
|
291
|
+
combined.throwIfAborted();
|
|
292
|
+
const outputValidator = this.#outputValidators.get(name);
|
|
293
|
+
try {
|
|
294
|
+
// SDK callTool consults a mutable per-page validator after the response and
|
|
295
|
+
// validates error payloads against success schemas. Use one typed request,
|
|
296
|
+
// then our captured validator; task-only tools are excluded during discovery.
|
|
297
|
+
// Never retry: a lost response does not prove the operation did not run.
|
|
298
|
+
const result = await this.client.request(
|
|
299
|
+
{ method: "tools/call", params: { name, arguments: args } },
|
|
300
|
+
CallToolResultSchema,
|
|
301
|
+
{
|
|
302
|
+
signal: combined,
|
|
303
|
+
timeout: this.config.timeoutMs,
|
|
304
|
+
resetTimeoutOnProgress: false,
|
|
305
|
+
},
|
|
306
|
+
);
|
|
307
|
+
const parsed = CallToolResultSchema.parse(result);
|
|
308
|
+
if (
|
|
309
|
+
!parsed.isError &&
|
|
310
|
+
outputValidator &&
|
|
311
|
+
!outputValidator.Check(parsed.structuredContent)
|
|
312
|
+
) {
|
|
313
|
+
throw new Error("MCP output does not match the advertised schema.");
|
|
314
|
+
}
|
|
315
|
+
return parsed;
|
|
316
|
+
} catch {
|
|
317
|
+
combined.throwIfAborted();
|
|
318
|
+
throw new Error(
|
|
319
|
+
`MCP server ${this.config.name}: call failed or timed out. It may already have taken effect; do not retry blindly.`,
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
close(): Promise<void> {
|
|
325
|
+
this.#closing ??= this.#close();
|
|
326
|
+
return this.#closing;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
async #close(): Promise<void> {
|
|
330
|
+
this.#stopped = true;
|
|
331
|
+
this.#lifetime.abort();
|
|
332
|
+
this.status = "Closed";
|
|
333
|
+
if (
|
|
334
|
+
this.#transport instanceof StreamableHTTPClientTransport &&
|
|
335
|
+
this.#transport.sessionId
|
|
336
|
+
) {
|
|
337
|
+
await this.#transport.terminateSession().catch(() => {});
|
|
338
|
+
}
|
|
339
|
+
await this.client.close().catch(() => {});
|
|
340
|
+
await this.#initializing?.catch(() => {});
|
|
341
|
+
await this.#refreshing?.catch(() => {});
|
|
342
|
+
}
|
|
343
|
+
}
|