@sigrun/ext-mcp 0.1.0 → 0.2.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/CHANGELOG.md CHANGED
@@ -6,6 +6,85 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
8
  ## [Unreleased]
9
+ ### Security
10
+ - **stdio MCP servers receive an allowlisted environment instead of sigrun's
11
+ process environment.** `SIGRUN_API_TOKEN` and provider keys were inherited
12
+ by every spawned stdio server; they now see only the variables their
13
+ declaration names plus a minimal base. Previously published `0.2.0` is
14
+ affected; `0.2.1` is the first release with the fix.
15
+
16
+ ### Changed
17
+ - **Upgrade behavior:** catalog servers now default to `credentials: "user"`.
18
+ Existing installs that rely on one system-wide server must explicitly
19
+ select `credentials: "shared"` and provide `sharedAuthority`. Shared MCP
20
+ servers hand every permitted user's agent the admin's credential for that
21
+ service.
22
+
23
+ ### Fixed
24
+ - Per-user tool publication is declared even with an empty or shared-only
25
+ catalog, so lifecycle activation and prompt rendering no longer fail when
26
+ there are no personal catalog servers. Because the declaration is
27
+ unconditional, core's boot sweep now writes `sigrun.mcp@<owner>` into every
28
+ owned conversation's selection on the first boot after upgrading, even with
29
+ no `mcp.json` or `userServers: "none"`. That is a one-time durable write:
30
+ the name resolves to nothing while no tools are published, so nothing
31
+ changes for the conversation, and later boots report zero.
32
+ - Catalog enablement now mounts through `surfaces.settings` and uses core's
33
+ actual flat settings shape: each toggle and changed key is the bare server
34
+ name. Shared-authority help is punctuated and guarded against missing
35
+ programmatic input.
36
+ - Prompt sections resolve the conversation owner through the harness's
37
+ committed-read surface instead of the server-bound identity bridge, so
38
+ harness-only boots cannot hang while preparing a request.
39
+
40
+ ### Added
41
+ - User-supplied MCP servers in per-user settings, gated by `userServers:
42
+ "none" | "remote" | "all"` and validated with the operator config parser.
43
+ Tools publish as `u_<server>/<tool>`; live policy changes reconcile every
44
+ active user. The `all` mode also permits URL servers, but reports
45
+ user-supplied stdio as a named configuration error because the host's
46
+ static user-service contract cannot launch it safely.
47
+ - Per-user OAuth start/complete extension routes with ten-minute in-memory
48
+ PKCE state scoped by user and server. Completion stores the raw access
49
+ token in the caller's scoped secrets; the CLI refuses per-user servers.
50
+ - Token storage retires and reconnects the caller's catalog connection.
51
+ Core owns the process lifecycle: a still-live child retains its original
52
+ environment until idle-stop or exit because the host contract cannot force
53
+ a relaunch.
54
+ - MCP catalog entries default to per-user credentials, with lazy per-user
55
+ stdio services, scoped token injection, remote connections, and tools
56
+ published only for their owner. Opt-in availability and per-user settings
57
+ control enablement; disabling removes tools and drains active calls.
58
+ - Shared entries require `sharedAuthority`, retain system-tool behavior,
59
+ and enforce their optional user allowlist and enablement at call time.
60
+ Prompt sections list only servers available to the calling user.
61
+
62
+ ### Security
63
+ - Stdio servers no longer inherit sigrun's environment. They are spawned
64
+ with an allowlist (`PATH`, `HOME`, `TMPDIR`, `LANG`, `LC_*`, `TZ`,
65
+ `TERM`, `USER`, `LOGNAME`, `SHELL`, `NPM_CONFIG_CACHE`,
66
+ `SSH_AUTH_SOCK`) plus their configured `env` and stored token.
67
+ Previously every server could read `SIGRUN_API_TOKEN`,
68
+ `SIGRUN_PROXY_SECRET` and provider keys. A server that relied on an
69
+ inherited variable must now list it in its `env`.
70
+
71
+ ## [0.2.0] - 2026-10-22
72
+ ### Added
73
+ - Remote MCP servers: a `servers` entry may be `{url, headers?, exposure?,
74
+ toolExposure?, timeout?}` instead of a stdio `{command, args}`. Streamable
75
+ HTTP is the transport (POST per frame, JSON or SSE reply,
76
+ `mcp-session-id` captured and echoed); a legacy HTTP+SSE server is adopted
77
+ automatically — whether it 404/405s the POST or (crawl4ai's shape) answers
78
+ it with an `endpoint` event — so no supergateway-style adapter is needed.
79
+ - `mcp.json` now resolves from the sigrun config dir
80
+ (`SIGRUN_CONFIG_DIR/mcp.json`) before the home fallback, matching every
81
+ other sigrun store.
82
+
83
+ ### Fixed
84
+ - SSE parsing handles CRLF line endings (the spec allows them; live servers
85
+ emit them).
86
+ - Streamable replies stop at the id-matching frame — servers legitimately
87
+ hold SSE streams open past the reply.
9
88
 
10
89
  ## [0.1.0] - 2026-10-13
11
90
  ### Added
package/README.md CHANGED
@@ -2,8 +2,10 @@
2
2
 
3
3
  Register MCP server tools as pi-durable tools, named `<server>/<tool>`.
4
4
 
5
- Config: `~/.config/sigrun/mcp.json` (or the `SIGRUN_MCP_CONFIG` env override
6
- pointing at any path). Each entry under `"servers"` names a stdio MCP server:
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:
7
9
 
8
10
  ```json
9
11
  {
@@ -13,6 +15,11 @@ pointing at any path). Each entry under `"servers"` names a stdio MCP server:
13
15
  "args": ["-y", "@modelcontextprotocol/server-github"],
14
16
  "exposure": "codemode",
15
17
  "timeout": 120
18
+ },
19
+ "search": {
20
+ "url": "https://mcp.example.test/mcp",
21
+ "headers": { "Authorization": "Bearer <token>" },
22
+ "exposure": "direct"
16
23
  }
17
24
  }
18
25
  }
@@ -20,8 +27,31 @@ pointing at any path). Each entry under `"servers"` names a stdio MCP server:
20
27
 
21
28
  ## Server fields
22
29
 
23
- - `command`, `args` (required) — how to spawn the server.
24
- - `env` — extra environment variables for the child process.
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. For per-user servers, configured `Authorization` is removed;
37
+ only that user's scoped token supplies it.
38
+ - `env` (stdio) — extra environment variables for the child process. For
39
+ per-user servers, configured `MCP_ACCESS_TOKEN` is removed; only that
40
+ user's scoped token supplies it.
41
+ - `credentials` — `"user"` (default) or `"shared"`. User-credential servers
42
+ start per user in the host's service jail; remote user servers connect
43
+ with the user's scoped token. Existing installs needing one system-wide
44
+ server must explicitly select `"shared"`.
45
+ - `sharedAuthority` — required, non-empty one-line reason when
46
+ `credentials` is `"shared"`. Shared MCP servers hand every permitted
47
+ user's agent the admin's credential for that service; the settings
48
+ section displays this reason and warning beside the server.
49
+ - `users` — optional string allowlist for a shared server. Only those
50
+ users may call its tools; this field is invalid for user-credential
51
+ servers. Ownerless calls fail closed.
52
+ - `availability` — `"all"` (default) or `"opt-in"`. The per-user settings
53
+ section stores each server's enablement as a flat boolean keyed by its
54
+ bare server name. `"all"` starts enabled; `"opt-in"` starts disabled.
25
55
  - `enabled` (default `true`) / `disabled` — skip the server entirely. Set one,
26
56
  never both: a server that is simultaneously on and off is a config error and
27
57
  fails loudly.
@@ -33,8 +63,47 @@ pointing at any path). Each entry under `"servers"` names a stdio MCP server:
33
63
  - `toolExposure` — per-tool overrides of `exposure`: an object mapping tool
34
64
  names (exact) or `*`-globs (e.g. `"get-*"`) to exposure words. An exact name
35
65
  always wins; among matching patterns the FIRST one in config order wins.
36
- - `oauth` — the authorization-code (PKCE) flow block; see `sigrun-ext-mcp oauth
37
- <server>` (the CLI mints and stores a token; the spawn injects it).
66
+ - `oauth` (stdio) — the authorization-code (PKCE) flow block. Shared servers
67
+ use `sigrun-ext-mcp oauth <server>` and the file token store. Per-user
68
+ servers use `POST /v1/ext/sigrun-mcp/oauth/<server>/start`, then
69
+ `POST /v1/ext/sigrun-mcp/oauth/<server>/complete` with
70
+ `{ "redirectedUrl": "<full redirected URL>" }`; the CLI refuses these
71
+ servers so their credentials cannot enter the shared store.
72
+
73
+ Per-user PKCE state stays in memory for ten minutes and is scoped to the
74
+ authenticated user and server. Completion stores only the raw access token in
75
+ that user's `token:<server>` secret. It then retires and reconnects that
76
+ user's catalog connection. Core owns the process lifecycle: releasing the last
77
+ handle arms idle-stop, so a still-live child keeps its original environment
78
+ until that timer fires or it crashes; the extension cannot force a relaunch
79
+ through the host contract. An already idle-stopped server launches fresh on
80
+ the next activation and reads the new token immediately.
81
+
82
+ `userServers` is the separate admin policy for user-supplied servers:
83
+ `"none"` (default), `"remote"`, or `"all"`. It is distinct from the admin
84
+ catalog's `credentials` and `availability` fields above. Catalog names
85
+ starting `u_` are reserved for those user-supplied servers and rejected.
86
+
87
+ When enabled, the **Your MCP servers** settings section accepts a JSON object
88
+ under the `servers` key. Entries use the same validation rules as `mcp.json`;
89
+ a user server name may not start with `u_`. Its tools publish only to that
90
+ user as `u_<server>/<tool>`. `"remote"` permits URL servers and ignores stdio;
91
+ `"all"` permits URL servers too. A live policy change reconciles every active
92
+ user, retiring disallowed connections and removing their tools without a
93
+ restart. Lifecycle events re-read the policy from `mcp.json`, since this
94
+ operator file is not part of core's settings document. The contributed
95
+ settings sections are selected at extension load: changing `none` to an
96
+ enabled policy needs a restart to add the editor, and changing to `none`
97
+ needs a restart to remove it; connection gating itself takes effect at the
98
+ next lifecycle event. A catalog server literally named `servers` is rejected
99
+ because it would shadow this settings namespace.
100
+
101
+ **Named limitation — `all` and user-supplied stdio:** sigrun declares per-user
102
+ services statically at load and passes no server name to `UserService.start`.
103
+ Consequently a dynamic, named user-supplied stdio service cannot be launched.
104
+ Under `"all"`, such an entry is reported as a configuration error naming the
105
+ server; its command is never spawned in sigrun's process, and no tools are
106
+ published. `"all"` does **not** make user-supplied stdio functional.
38
107
 
39
108
  ## Exposure: how tools reach callers
40
109
 
package/dist/cli.js CHANGED
@@ -17,7 +17,7 @@
17
17
  * flow / 5-minute timeout).
18
18
  */
19
19
  import { createInterface } from "node:readline";
20
- import { loadMcpConfig, MCP_CONFIG_ENV } from "./config.js";
20
+ import { isStdioServer, loadMcpConfig, MCP_CONFIG_ENV, } from "./config.js";
21
21
  import { startOauth } from "./oauth.js";
22
22
  import { resolveMcpTokenPath } from "./tokens.js";
23
23
  /** The flow timeout (matches startOauth's default; explicit so the user's
@@ -71,7 +71,11 @@ export async function runCli(argv, io = { stdout: process.stdout, stderr: proces
71
71
  io.stderr.write(`error: no server named "${parsed.server}" in the MCP config\n`);
72
72
  return 1;
73
73
  }
74
- if (server.oauth === undefined) {
74
+ if (server.credentials !== "shared") {
75
+ io.stderr.write(`error: server "${parsed.server}" uses per-user credentials; use POST /v1/ext/sigrun-mcp/oauth/${parsed.server}/start instead\n`);
76
+ return 1;
77
+ }
78
+ if (!isStdioServer(server) || server.oauth === undefined) {
75
79
  io.stderr.write(`error: server "${parsed.server}" has no oauth block in the MCP config\n`);
76
80
  return 1;
77
81
  }
package/dist/client.d.ts CHANGED
@@ -1,12 +1,20 @@
1
1
  /**
2
- * Minimal hand-rolled MCP stdio client (ruling 3): JSON-RPC 2.0 over
3
- * newline-delimited stdio, no pi-mcp dependency.
2
+ * The MCP client: a transport-neutral JSON-RPC 2.0 engine plus two
3
+ * transports.
4
4
  *
5
- * Lifecycle: spawn → `initialize` handshake (protocolVersion "2025-06-18",
6
- * clientInfo, capabilities) → `notifications/initialized` → `tools/list`
7
- * (discovery) → `tools/call` (execution). Requests correlate by id; the
8
- * response frame's `id` must match a pending request (a notification is
9
- * never a reply). A spawn failure or handshake failure rejects `connect`,
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`,
10
18
  * never affecting any other server (per-server isolation).
11
19
  *
12
20
  * Per-request timeout (codemode parity, spec slice 3): every request REJECTS
@@ -14,35 +22,31 @@
14
22
  * awaiting a hung server forever. A `notifications/progress` frame naming a
15
23
  * pending request's id (sent as the request's `progressToken`) RESETS that
16
24
  * request's deadline — a server that keeps reporting progress stays alive
17
- * as long as it works. A timeout rejects the call but never kills the child
18
- * (per-server isolation: the next request gets a fresh chance).
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).
19
27
  */
20
- import { type ChildProcess } from "node:child_process";
21
28
  import type { McpServerConfig, McpToolDefinition, McpToolResult } from "./types.js";
22
- /** One connected MCP server: a live child process speaking JSON-RPC. */
23
- export declare class McpClient {
24
- #private;
25
- /** @internal constructed by {@link connectMcpServer}. */
26
- constructor(name: string, child: ChildProcess);
27
- /**
28
- * The MCP handshake: `initialize` then `notifications/initialized`.
29
- * The initialize reply is validated to carry the protocol version.
30
- */
31
- handshake(): Promise<void>;
32
- /** Discover the server's tools (`tools/list`). */
29
+ /** The public client surface (what the extension consumes). */
30
+ export interface McpClient {
33
31
  listTools(): Promise<McpToolDefinition[]>;
34
- /** Execute one tool (`tools/call`); an `isError` result resolves (it is a
35
- * valid tool outcome, not a protocol failure). `timeoutSeconds` caps the
36
- * wait per request (default 60; progress notifications reset it). */
37
32
  callTool(name: string, args: Record<string, unknown>, timeoutSeconds?: number): Promise<McpToolResult>;
38
- /** Wait for the child to exit (used by tests / shutdown checks). */
39
- exited(): Promise<Error | undefined>;
40
- /** Kill the child and tear down (idempotent). */
33
+ /** Tear the connection down (idempotent). */
41
34
  close(): void;
42
35
  }
36
+ /** The byte streams of a core-launched per-user service. */
37
+ export interface McpServiceHandle {
38
+ readonly stdin: WritableStream<Uint8Array>;
39
+ readonly stdout: ReadableStream<Uint8Array>;
40
+ readonly exited: Promise<{
41
+ code: number | null;
42
+ signal: string | null;
43
+ }>;
44
+ release(): void;
45
+ }
43
46
  /**
44
- * Spawn and connect one configured MCP server. Throws on spawn failure or a
45
- * failed handshake — per-server isolation is the CALLER's policy (the
46
- * extension connects all servers with `.catch` per server).
47
+ * Spawn/connect one configured MCP server (stdio or remote by config
48
+ * shape). Throws on spawn/connect failure or a failed handshake —
49
+ * per-server isolation is the CALLER's policy (the extension connects all
50
+ * servers with `.catch` per server).
47
51
  */
48
- export declare function connectMcpServer(name: string, config: McpServerConfig): Promise<McpClient>;
52
+ export declare function connectMcpServer(name: string, config: McpServerConfig, handle?: McpServiceHandle): Promise<McpClient>;