@sigrun/ext-mcp 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,45 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.2.0] - 2026-10-22
11
+ ### Added
12
+ - Remote MCP servers: a `servers` entry may be `{url, headers?, exposure?,
13
+ toolExposure?, timeout?}` instead of a stdio `{command, args}`. Streamable
14
+ HTTP is the transport (POST per frame, JSON or SSE reply,
15
+ `mcp-session-id` captured and echoed); a legacy HTTP+SSE server is adopted
16
+ automatically — whether it 404/405s the POST or (crawl4ai's shape) answers
17
+ it with an `endpoint` event — so no supergateway-style adapter is needed.
18
+ - `mcp.json` now resolves from the sigrun config dir
19
+ (`SIGRUN_CONFIG_DIR/mcp.json`) before the home fallback, matching every
20
+ other sigrun store.
21
+
22
+ ### Fixed
23
+ - SSE parsing handles CRLF line endings (the spec allows them; live servers
24
+ emit them).
25
+ - Streamable replies stop at the id-matching frame — servers legitimately
26
+ hold SSE streams open past the reply.
27
+
28
+ ## [0.1.0] - 2026-10-13
29
+ ### Added
30
+ - Renamed to `@sigrun/ext-mcp` for the 0.1.0 release (repo stays
31
+ `sigrun-ai/sigrun-ext-mcp`): published package metadata
32
+ (`publishConfig` access/provenance), host modules moved to
33
+ `peerDependencies` mirrored in `devDependencies` at exact pins
34
+ (`typebox` now declared — it was an undeclared host import),
35
+ CHANGELOG.md joined the files whitelist, and a manifest hygiene check
36
+ wired into `npm test`.
37
+ - MCP client over stdio: server discovery, tool registration (`<server>/<tool>`
38
+ naming), OAuth (PKCE paste-the-URL flow) with a durable token store.
39
+ - The MCP exposure model on pi-durable 1.2.0: per-server
40
+ `exposure: codemode|deferred|direct|hidden` (default `codemode`),
41
+ per-tool `toolExposure` overrides (exact over patterns, first wins),
42
+ `enabled`, and per-request `timeout` with progress-notification reset.
43
+ - The `mcp_servers` prompt section: live-rendered, 4KB whole-line-truncated,
44
+ gated on the discovery tool being model-offered, with an honest
45
+ enable-notice line for unreachable servers.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Michael MacLeod
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,109 @@
1
- # Temporary Holding Version
1
+ # @sigrun/ext-mcp
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Register MCP server tools as pi-durable tools, named `<server>/<tool>`.
4
+
5
+ Config: the sigrun config dir's `mcp.json` (`SIGRUN_CONFIG_DIR/mcp.json`; a
6
+ bare user install falls back to `~/.config/sigrun/mcp.json`; the
7
+ `SIGRUN_MCP_CONFIG` env override points at any path). Each entry under
8
+ `"servers"` names an MCP server — stdio or remote:
9
+
10
+ ```json
11
+ {
12
+ "servers": {
13
+ "github": {
14
+ "command": "npx",
15
+ "args": ["-y", "@modelcontextprotocol/server-github"],
16
+ "exposure": "codemode",
17
+ "timeout": 120
18
+ },
19
+ "search": {
20
+ "url": "https://mcp.example.test/mcp",
21
+ "headers": { "Authorization": "Bearer <token>" },
22
+ "exposure": "direct"
23
+ }
24
+ }
25
+ }
26
+ ```
27
+
28
+ ## Server fields
29
+
30
+ - `command`, `args` (stdio) — how to spawn the server.
31
+ - `url` (remote) — the server endpoint (http/https). Streamable HTTP is the
32
+ transport; a legacy HTTP+SSE server (2024-11-05 shape — GET stream +
33
+ `endpoint` event + message POSTs) is adopted automatically, so no adapter
34
+ process is needed.
35
+ - `headers` (remote) — extra request headers (auth etc.), sent on every
36
+ request.
37
+ - `env` (stdio) — extra environment variables for the child process.
38
+ - `enabled` (default `true`) / `disabled` — skip the server entirely. Set one,
39
+ never both: a server that is simultaneously on and off is a config error and
40
+ fails loudly.
41
+ - `timeout` — per-request wire timeout in seconds (default `60`). A request
42
+ that gets no reply by its deadline rejects. A server that keeps sending
43
+ `notifications/progress` for a pending request never dies at the deadline
44
+ while it keeps reporting work — progress resets the deadline.
45
+ - `exposure` — how the server's tools register (see below). Default `"codemode"`.
46
+ - `toolExposure` — per-tool overrides of `exposure`: an object mapping tool
47
+ names (exact) or `*`-globs (e.g. `"get-*"`) to exposure words. An exact name
48
+ always wins; among matching patterns the FIRST one in config order wins.
49
+ - `oauth` (stdio) — the authorization-code (PKCE) flow block; see
50
+ `sigrun-ext-mcp oauth <server>` (the CLI mints and stores a token; the spawn
51
+ injects it).
52
+
53
+ ## Exposure: how tools reach callers
54
+
55
+ On pi-durable 1.2.0 semantics, `"exposure"` decides who can call a server's
56
+ tools:
57
+
58
+ | exposure | registration | who can call |
59
+ | ----------- | ------------------------- | ------------------------------------- |
60
+ | `codemode` | `callers: ["tools"]` | scripts only (never model-offered) |
61
+ | `deferred` | `deferred: true` | the model, once `tool_search` loads it |
62
+ | `direct` | normal | the model (offered when in the set) |
63
+ | `hidden` | not registered | nobody (config entry kept) |
64
+
65
+ The `codemode-deferred` word is kept as an alias for `codemode` (pi compat).
66
+
67
+ ### The default flip — a breaking change
68
+
69
+ **Before 1.2.0, an `mcp.json` server entry with no `exposure` field had its
70
+ tools offered to the model. Now the default is `"codemode"`:** a
71
+ default-exposure server's tools are callable by scripts (codemode), never
72
+ offered to the model directly. To restore the old behavior, set the server
73
+ (or the tool, via `toolExposure`) to `"exposure": "direct"`:
74
+
75
+ ```json
76
+ { "exposure": "direct" }
77
+ ```
78
+
79
+ The `mcp_servers` system-prompt section lists codemode/deferred servers when
80
+ their discovery tool (`codemode`, `tool_search`) is offered in the request;
81
+ when such servers exist but their tools are unreachable, it appends one
82
+ notice line naming the count and the Settings → default tools fix.
83
+
84
+ ## Tool naming: `<server>/<tool>` (decision)
85
+
86
+ Tools keep the existing `<server>/<tool>` shape rather than upstream pi's
87
+ `mcp__<server>__<tool>`:
88
+
89
+ - The upstream codemode section groups tools by `CodemodeNamespace.prefix`
90
+ (longest match wins), and the namespaces are **config-side** — the host
91
+ passes the groups, so any naming can namespace trivially: ours groups as
92
+ `prefix: "<server>/"` with no rename at all.
93
+ - The name-addressed surface makes per-tool replacement and `toolExposure`
94
+ patterns readable (`"github/*"`), and the existing wire name is already
95
+ deployed in configs and scripts.
96
+
97
+ No section-rendering compat requires the upstream spelling; the decision is
98
+ recorded here per the spec's open item.
99
+
100
+ ## Replacement semantics
101
+
102
+ Replacement works by not installing ours and installing yours: the
103
+ name-addressed tool surface (`<server>/<tool>`) makes per-name replacement
104
+ trivial. Co-installing this package with another that registers the same
105
+ tool names shadows silently (pi-durable resolves cross-extension duplicates
106
+ last-selected-wins) — **do not co-install**. Sigrun's extension loader
107
+ (made the shadowing visible at boot: it `console.warn`s once per duplicate
108
+ tool name, naming the extensions and the last-wins resolution) but the
109
+ warning changes nothing: last-wins stands. Replace, don't co-install.
package/dist/bin.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ /**
2
+ * The package bin entry (`sigrun-ext-mcp` → dist/bin.js): parse-and-run the
3
+ * CLI, mapping the result to the process exit code. The body lives in
4
+ * ./cli.ts so tests can exercise it without spawning a process.
5
+ */
6
+ export {};
package/dist/bin.js ADDED
@@ -0,0 +1,9 @@
1
+ /**
2
+ * The package bin entry (`sigrun-ext-mcp` → dist/bin.js): parse-and-run the
3
+ * CLI, mapping the result to the process exit code. The body lives in
4
+ * ./cli.ts so tests can exercise it without spawning a process.
5
+ */
6
+ import { runCli } from "./cli.js";
7
+ void runCli(process.argv.slice(2)).then((code) => {
8
+ process.exitCode = code;
9
+ });
package/dist/cli.d.ts ADDED
@@ -0,0 +1,29 @@
1
+ /**
2
+ * The `sigrun-ext-mcp` CLI bin (ruling 4): `sigrun-ext-mcp oauth <server>`
3
+ * runs the paste-the-URL OAuth flow for one configured server.
4
+ *
5
+ * argv surface (parsed minimally — no new deps):
6
+ * sigrun-ext-mcp oauth <server> [--paste]
7
+ *
8
+ * Default mode: the loopback listener runs AND stdin is read (paste
9
+ * fallback) — first arrival wins. `--paste` is accepted for hosts that want
10
+ * the semantics explicit; the flow is identical either way.
11
+ *
12
+ * Prints to stdout:
13
+ * - the authorize URL (open this in a browser)
14
+ * - the redirect_uri (what the AS will redirect to)
15
+ * - a paste prompt
16
+ * Exits 0 with the token stored, or 1 with a clear error (usage / config /
17
+ * flow / 5-minute timeout).
18
+ */
19
+ /** Parse argv minimally: `oauth <server> [--paste]`. */
20
+ export declare function parseCliArgs(argv: readonly string[]): {
21
+ readonly command: "oauth";
22
+ readonly server: string;
23
+ readonly paste: boolean;
24
+ };
25
+ /** The CLI body (exported for tests); returns the process exit code. */
26
+ export declare function runCli(argv: readonly string[], io?: {
27
+ readonly stdout: NodeJS.WriteStream;
28
+ readonly stderr: NodeJS.WriteStream;
29
+ }): Promise<number>;
package/dist/cli.js ADDED
@@ -0,0 +1,109 @@
1
+ /**
2
+ * The `sigrun-ext-mcp` CLI bin (ruling 4): `sigrun-ext-mcp oauth <server>`
3
+ * runs the paste-the-URL OAuth flow for one configured server.
4
+ *
5
+ * argv surface (parsed minimally — no new deps):
6
+ * sigrun-ext-mcp oauth <server> [--paste]
7
+ *
8
+ * Default mode: the loopback listener runs AND stdin is read (paste
9
+ * fallback) — first arrival wins. `--paste` is accepted for hosts that want
10
+ * the semantics explicit; the flow is identical either way.
11
+ *
12
+ * Prints to stdout:
13
+ * - the authorize URL (open this in a browser)
14
+ * - the redirect_uri (what the AS will redirect to)
15
+ * - a paste prompt
16
+ * Exits 0 with the token stored, or 1 with a clear error (usage / config /
17
+ * flow / 5-minute timeout).
18
+ */
19
+ import { createInterface } from "node:readline";
20
+ import { isStdioServer, loadMcpConfig, MCP_CONFIG_ENV, } from "./config.js";
21
+ import { startOauth } from "./oauth.js";
22
+ import { resolveMcpTokenPath } from "./tokens.js";
23
+ /** The flow timeout (matches startOauth's default; explicit so the user's
24
+ * error message is deterministic). */
25
+ const TIMEOUT_MS = 5 * 60_000;
26
+ /** Print the usage banner. */
27
+ function usage(out) {
28
+ out.write("usage: sigrun-ext-mcp oauth <server> [--paste]\n");
29
+ out.write(`reads the MCP config (env ${MCP_CONFIG_ENV} or ~/.config/sigrun/mcp.json);\n` +
30
+ `the named server needs an oauth block; the token is stored beside the\n` +
31
+ `config as mcp-tokens.json (0600)\n`);
32
+ }
33
+ /** Parse argv minimally: `oauth <server> [--paste]`. */
34
+ export function parseCliArgs(argv) {
35
+ const args = [...argv];
36
+ const head = args.shift();
37
+ if (head !== "oauth") {
38
+ throw new Error(`unknown command: ${String(head)}`);
39
+ }
40
+ const paste = args.includes("--paste");
41
+ const positional = args.filter((arg) => arg !== "--paste");
42
+ if (positional.length !== 1) {
43
+ throw new Error("expected exactly one server name");
44
+ }
45
+ return { command: "oauth", server: positional[0], paste };
46
+ }
47
+ /** Load the config, turning a malformed config into exit-1 (not a crash). */
48
+ function loadMcpConfigSafe(stderr) {
49
+ try {
50
+ return loadMcpConfig();
51
+ }
52
+ catch (cause) {
53
+ stderr.write(`error: ${cause instanceof Error ? cause.message : String(cause)}\n`);
54
+ process.exit(1);
55
+ }
56
+ }
57
+ /** The CLI body (exported for tests); returns the process exit code. */
58
+ export async function runCli(argv, io = { stdout: process.stdout, stderr: process.stderr }) {
59
+ let parsed;
60
+ try {
61
+ parsed = parseCliArgs(argv);
62
+ }
63
+ catch (cause) {
64
+ usage(io.stderr);
65
+ io.stderr.write(`error: ${cause instanceof Error ? cause.message : String(cause)}\n`);
66
+ return 1;
67
+ }
68
+ const config = loadMcpConfigSafe(io.stderr);
69
+ const server = config.servers[parsed.server];
70
+ if (server === undefined) {
71
+ io.stderr.write(`error: no server named "${parsed.server}" in the MCP config\n`);
72
+ return 1;
73
+ }
74
+ if (!isStdioServer(server) || server.oauth === undefined) {
75
+ io.stderr.write(`error: server "${parsed.server}" has no oauth block in the MCP config\n`);
76
+ return 1;
77
+ }
78
+ const flow = await startOauth(parsed.server, server.oauth, {
79
+ timeoutMs: TIMEOUT_MS,
80
+ });
81
+ io.stdout.write(`Authorize URL:\n ${flow.authorizeUrl}\n`);
82
+ io.stdout.write(`Redirect URI:\n ${flow.redirectUri}\n`);
83
+ io.stdout.write("Open the Authorize URL in a browser and approve. If the redirect does\n" +
84
+ "not reach this machine, paste the FULL redirected URL here instead:\n");
85
+ // The paste fallback: every stdin line is fed to the flow; first arrival
86
+ // wins (the loopback listener or the paste settles the code). pasteUrl
87
+ // never throws (lines with no code are ignored), so the readline handler
88
+ // cannot crash the bin.
89
+ const lines = createInterface({ input: process.stdin });
90
+ lines.on("line", (line) => {
91
+ const trimmed = line.trim();
92
+ if (trimmed !== "")
93
+ flow.pasteUrl(trimmed);
94
+ });
95
+ try {
96
+ await flow.done;
97
+ lines.close();
98
+ io.stdout.write(`Token stored for "${parsed.server}" in:\n ${resolveMcpTokenPath()}\n`);
99
+ return 0;
100
+ }
101
+ catch (cause) {
102
+ lines.close();
103
+ flow.stop();
104
+ io.stderr.write(`error: ${cause instanceof Error ? cause.message : String(cause)}\n`);
105
+ return 1;
106
+ }
107
+ }
108
+ // This module is the CLI body only (pure exports; importable for tests).
109
+ // The bin entry lives in ./bin.ts — dist/bin.js is the package.json "bin".
@@ -0,0 +1,42 @@
1
+ /**
2
+ * The MCP client: a transport-neutral JSON-RPC 2.0 engine plus two
3
+ * transports.
4
+ *
5
+ * stdio (the original transport): newline-delimited JSON-RPC over a spawned
6
+ * child's pipes. Remote (streamable HTTP): every request POSTs to the
7
+ * configured URL; the reply is a JSON body OR an SSE stream. A server that
8
+ * 404/405s the POST (the pre-2025-03-26 "HTTP+SSE" transport — still common,
9
+ * e.g. many self-hosted servers) falls back to the legacy shape: a GET
10
+ * stream for server→client frames and a discovered message endpoint for
11
+ * client→server POSTs.
12
+ *
13
+ * Lifecycle (both transports): `initialize` handshake (protocolVersion
14
+ * "2025-06-18", clientInfo, capabilities) → `notifications/initialized` →
15
+ * `tools/list` (discovery) → `tools/call` (execution). Requests correlate
16
+ * by id; the response frame's `id` must match a pending request (a
17
+ * notification is never a reply). A connect failure rejects `connect`,
18
+ * never affecting any other server (per-server isolation).
19
+ *
20
+ * Per-request timeout (codemode parity, spec slice 3): every request REJECTS
21
+ * at its deadline (default 60s; config `timeout` per server) instead of
22
+ * awaiting a hung server forever. A `notifications/progress` frame naming a
23
+ * pending request's id (sent as the request's `progressToken`) RESETS that
24
+ * request's deadline — a server that keeps reporting progress stays alive
25
+ * as long as it works. A timeout rejects the call but never kills the
26
+ * transport (per-server isolation: the next request gets a fresh chance).
27
+ */
28
+ import type { McpServerConfig, McpToolDefinition, McpToolResult } from "./types.js";
29
+ /** The public client surface (what the extension consumes). */
30
+ export interface McpClient {
31
+ listTools(): Promise<McpToolDefinition[]>;
32
+ callTool(name: string, args: Record<string, unknown>, timeoutSeconds?: number): Promise<McpToolResult>;
33
+ /** Tear the connection down (idempotent). */
34
+ close(): void;
35
+ }
36
+ /**
37
+ * Spawn/connect one configured MCP server (stdio or remote by config
38
+ * shape). Throws on spawn/connect failure or a failed handshake —
39
+ * per-server isolation is the CALLER's policy (the extension connects all
40
+ * servers with `.catch` per server).
41
+ */
42
+ export declare function connectMcpServer(name: string, config: McpServerConfig): Promise<McpClient>;