@jini-ai/mcp 0.4.1 → 0.4.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.
@@ -0,0 +1,72 @@
1
+ /**
2
+ * @module @jini-ai/mcp/core/secure-write
3
+ * Shared atomic, owner-only-permission (0600) secret-file writer used by
4
+ * every secret-bearing on-disk store in this package: the server config
5
+ * store (env vars / `Authorization` headers), the OAuth dynamic-client-
6
+ * registration cache (`clientSecret`), and the OAuth token store (bearer /
7
+ * refresh tokens, confidential-client secrets). CR-006 / SEC-RB-002.
8
+ *
9
+ * The previous pattern in each of those stores wrote the temp file with
10
+ * default permissions and either never restricted it (config, OAuth client
11
+ * cache) or `chmod(0600)`'d it *after* the atomic rename and silently
12
+ * continued when the chmod failed (tokens) — both leave a window where the
13
+ * file exists world/group-readable, and the latter makes the "protection"
14
+ * advisory rather than enforced. This writer instead:
15
+ *
16
+ * 1. Creates the temp file *exclusively* (`wx`) with mode 0600 supplied to
17
+ * the same `open()`/`writeFile()` call that creates it — POSIX applies
18
+ * the requested mode atomically at creation time, so there is no
19
+ * window where the file exists with a broader mode. This repo already
20
+ * relies on `writeFile`'s `mode` option alone for a secret file with no
21
+ * separate chmod step (see `packages/agent-runtime/src/prompt-file.ts`);
22
+ * this module follows that same precedent rather than inventing a new one.
23
+ * 2. On POSIX platforms, verifies the on-disk mode actually landed as
24
+ * owner-only before renaming into place, and fails closed — removing
25
+ * the temp file and throwing — if it did not (e.g. an unusual umask or
26
+ * a filesystem that doesn't honor `mode`).
27
+ * 3. Skips that verification on `win32`: Windows has no POSIX permission-
28
+ * bit model (`mode` there only toggles the read-only attribute), so
29
+ * there is nothing meaningful to enforce or fail closed on — matching
30
+ * this repo's only other cross-platform file-mode precedent, which
31
+ * also does not branch on Windows.
32
+ *
33
+ * Part of the MCP `core` kernel; not part of the package's public barrel —
34
+ * imported directly by sibling `core/*.ts` files.
35
+ */
36
+ import { mkdir, rename, rm, stat, writeFile } from 'node:fs/promises';
37
+ import { randomBytes } from 'node:crypto';
38
+ import path from 'node:path';
39
+ const SECRET_FILE_MODE = 0o600;
40
+ /**
41
+ * Atomically write `contents` to `finalPath` via a same-directory temp file
42
+ * created exclusively with mode 0600. On POSIX platforms, verifies no
43
+ * group/other permission bit made it onto disk before renaming into place;
44
+ * throws (after removing the temp file) if verification fails or the write
45
+ * itself fails, so a caller never observes a partially-written or
46
+ * insufficiently-restricted secret file.
47
+ * @param finalPath Absolute path the secret file should end up at.
48
+ * @param contents UTF-8 text to write.
49
+ * @param options `platform` override for tests; defaults to `process.platform`.
50
+ */
51
+ export async function writeSecretFileAtomic(finalPath, contents, options = {}) {
52
+ const platform = options.platform ?? process.platform;
53
+ await mkdir(path.dirname(finalPath), { recursive: true });
54
+ const tmp = `${finalPath}.${randomBytes(4).toString('hex')}.tmp`;
55
+ try {
56
+ await writeFile(tmp, contents, { encoding: 'utf8', mode: SECRET_FILE_MODE, flag: 'wx' });
57
+ if (platform !== 'win32') {
58
+ const info = await stat(tmp);
59
+ const bits = info.mode & 0o777;
60
+ if ((bits & 0o077) !== 0) {
61
+ throw new Error(`refusing to persist secret-bearing file with non-owner-only permissions ` +
62
+ `(mode 0${bits.toString(8)}): ${finalPath}`);
63
+ }
64
+ }
65
+ await rename(tmp, finalPath);
66
+ }
67
+ catch (err) {
68
+ await rm(tmp, { force: true }).catch(() => undefined);
69
+ throw err;
70
+ }
71
+ }
72
+ //# sourceMappingURL=secure-write.js.map
@@ -1,3 +1,28 @@
1
+ /**
2
+ * @file The typed boundary for OUTBOUND MCP federation: the host daemon acting as a client of
3
+ * third-party servers. Inbound MCP injection hands an agent access to the host's own catalog;
4
+ * outbound federation accepts another party's tool surface, so its trust question is different.
5
+ *
6
+ * Why a declared port rather than `@modelcontextprotocol/sdk`: the SDK is not a dependency and
7
+ * would add a transitive tree to the boot path for a small JSON-RPC surface. A real adapter and
8
+ * a memory double share one interface instead.
9
+ *
10
+ * Layered seams are deliberate. McpSessionPort is the outer seam for trust and registration tests
11
+ * without protocol I/O. McpStdioChannel and McpHttpExchange are the inner seams, so the real
12
+ * client's framing, handshake, pagination, cancellation and timeouts run against scripted bytes.
13
+ * A fake at the outer seam alone would test the fake, which would not satisfy the constraint of
14
+ * exercising protocol behavior without a live third-party server. Both transports share protocol
15
+ * logic above those seams; adding a hosted transport does not redesign the federation boundary.
16
+ *
17
+ * All remote names, descriptions, schemas, annotations and results are untrusted input. These
18
+ * declarations type them; trust.ts vets them before they reach a registry or model.
19
+ */
20
+ /**
21
+ * Remote behavior hints are self-declared by the party being classified. A host's native risk
22
+ * catalog refuses to let a tool downgrade its own risk by editing one word; letting a remote
23
+ * server change its annotations would make the same mistake with a party the host cannot control.
24
+ * Hints are carried and logged, but trust.ts only lets them make a tool LESS available, never more.
25
+ */
1
26
  export interface RemoteToolAnnotations {
2
27
  readonly title?: string | undefined;
3
28
  readonly readOnlyHint?: boolean | undefined;
@@ -1,3 +1,22 @@
1
+ /**
2
+ * @file The typed boundary for OUTBOUND MCP federation: the host daemon acting as a client of
3
+ * third-party servers. Inbound MCP injection hands an agent access to the host's own catalog;
4
+ * outbound federation accepts another party's tool surface, so its trust question is different.
5
+ *
6
+ * Why a declared port rather than `@modelcontextprotocol/sdk`: the SDK is not a dependency and
7
+ * would add a transitive tree to the boot path for a small JSON-RPC surface. A real adapter and
8
+ * a memory double share one interface instead.
9
+ *
10
+ * Layered seams are deliberate. McpSessionPort is the outer seam for trust and registration tests
11
+ * without protocol I/O. McpStdioChannel and McpHttpExchange are the inner seams, so the real
12
+ * client's framing, handshake, pagination, cancellation and timeouts run against scripted bytes.
13
+ * A fake at the outer seam alone would test the fake, which would not satisfy the constraint of
14
+ * exercising protocol behavior without a live third-party server. Both transports share protocol
15
+ * logic above those seams; adding a hosted transport does not redesign the federation boundary.
16
+ *
17
+ * All remote names, descriptions, schemas, annotations and results are untrusted input. These
18
+ * declarations type them; trust.ts vets them before they reach a registry or model.
19
+ */
1
20
  /** Narrows a {@link McpLaunchSpec} to its hosted arm. */
2
21
  export function isHttpLaunchSpec(input) {
3
22
  return "url" in input.spec;
@@ -50,16 +50,17 @@ export declare class InMemoryMcpSession implements McpSessionPort {
50
50
  constructor({ tools }: {
51
51
  tools: readonly RemoteToolDescriptor[];
52
52
  }, options?: {
53
- /** Defaults to echoing the call back, which is enough for most wiring assertions. */
53
+ /** Defaults to echoing the call back, which is enough for most wiring assertions.
54
+ * The injected remote receives the same optional cancellation object as a real session. */
54
55
  onCall?: ((required: {
55
56
  name: string;
56
57
  args: Record<string, unknown>;
57
- }) => RemoteToolResult | Promise<RemoteToolResult>) | undefined;
58
+ }, options?: McpToolCallOptions) => RemoteToolResult | Promise<RemoteToolResult>) | undefined;
58
59
  /** Overrides `listTools` wholesale, for the "the remote is down at connect time" cases. */
59
60
  onListTools?: (() => Promise<RemoteToolDescriptor[]>) | undefined;
60
61
  });
61
62
  listTools(): Promise<RemoteToolDescriptor[]>;
62
- callTool(request: McpToolCallRequiredArgs, _options?: McpToolCallOptions): Promise<RemoteToolResult>;
63
+ callTool(request: McpToolCallRequiredArgs, options?: McpToolCallOptions): Promise<RemoteToolResult>;
63
64
  close(_required: Record<string, never>): Promise<void>;
64
65
  }
65
66
  /** One JSON-RPC message the fake channel received from the client under test. */
@@ -51,9 +51,10 @@ export class InMemoryMcpSession {
51
51
  return this.listToolsBehaviour();
52
52
  return [...this.tools];
53
53
  }
54
- async callTool(request, _options = {}) {
54
+ async callTool(request, options = {}) {
55
55
  this.calls.push({ name: request.name, arguments: request.arguments });
56
- return this.handler({ name: request.name, args: request.arguments });
56
+ // The injected remote must see cancellation too, so wiring tests can exercise the real ABI.
57
+ return this.handler({ name: request.name, args: request.arguments }, options);
57
58
  }
58
59
  async close(_required) {
59
60
  this.closed = true;
@@ -30,6 +30,14 @@ export function createInMemoryConversationToolApprovalStore(_required) {
30
30
  async has(key) {
31
31
  return rows.get(keyOf(key)) === key.fingerprint;
32
32
  },
33
+ async hasIdentity(key) {
34
+ for (const [encoded, fingerprint] of rows) {
35
+ const [, principalId, connectionId, toolName] = JSON.parse(encoded);
36
+ if (principalId === key.principalId && connectionId === key.connectionId && toolName === key.toolName && fingerprint === key.fingerprint)
37
+ return true;
38
+ }
39
+ return false;
40
+ },
33
41
  async grant({ key }) {
34
42
  rows.set(keyOf(key), key.fingerprint);
35
43
  },
@@ -89,6 +89,9 @@ export interface ConversationToolApprovalKey {
89
89
  export interface ConversationToolApprovalStore {
90
90
  /** True only for this conversation AND this person AND this exact fingerprint. */
91
91
  has(key: ConversationToolApprovalKey): Promise<boolean>;
92
+ /** Native escalation consent can outlive the invoking chat, while remaining person/identity bound.
93
+ * Hosts namespace connectionId by workspace and behavior; deletion of all granting chats revokes it. */
94
+ hasIdentity?(key: Omit<ConversationToolApprovalKey, "conversationId">): Promise<boolean>;
92
95
  grant(input: {
93
96
  key: ConversationToolApprovalKey;
94
97
  grantedAt: string;
package/dist/index.d.ts CHANGED
@@ -1,180 +1,9 @@
1
1
  /**
2
2
  * @module @jini-ai/mcp
3
- * Public barrel for the MCP (Model Context Protocol) domain. External code
4
- * imports MCP capabilities only from here — never from a subdirectory — so the
5
- * internal split (`core` kernel + `client` / `agent-install` concerns) can move
6
- * without touching consumers.
7
- *
8
- * Re-exports are explicit and named (no `export *`) so the public surface is
9
- * visible here and name collisions surface at build time.
10
-
11
- * Archived provenance rationale:
12
- * ## What was dropped (and why)
13
- *
14
- * - **`live-artifacts/` (entire subdir)** — the OD per-run "live artifacts" MCP
15
- * tool surface. An OD-product feature, out of scope per the port brief.
16
- * - **The `od mcp` stdio server** in `client/client.ts` — `runMcpStdio`,
17
- * `TOOL_DEFS`, `handleMcpToolCall`, and every tool handler (`get_artifact`,
18
- * `create_project`, `start_run`, project/active-context resolution, studio deep
19
- * links, run polling, …). This is not a generic MCP client: it is an
20
- * OD-product MCP **server** hardwired to Open Design's REST API
21
- * (`/api/projects`, `/api/skills`, `/api/design-systems`, `/api/runs`, …) and
22
- * concepts (design systems, skills, plugins, artifacts, runs, studio, the
23
- * `od://` resource scheme). It also imports the OD-only `@open-design/contracts`
24
- * package (`buildProjectRawFileUrl`), the OD daemon-internal
25
- * `../../artifacts/create.js`, and the `@modelcontextprotocol/sdk` — none of
26
- * which exist in this repo. Faithfully porting it would mean importing OD
27
- * product surface, so it was dropped and only its generic primitives salvaged.
28
- * - **`MCP_TEMPLATES` catalog + template types + `templateId`** in `config.ts` —
29
- * a large, design-tool-tilted curated catalog of third-party MCP servers with
30
- * OD marketing prose. Zero functional coupling; dropped for neutrality (same
31
- * rationale `@jini/sqlite` used to drop the OD "templates" table).
32
- *
33
- * ### The `list_agents` decision
34
- *
35
- * Investigated before building anything, per the task brief. `@jini/agent-runtime`'s
36
- * barrel (`packages/agent-runtime/src/index.ts` → `export * from './registry.js'`)
37
- * already exports `AGENT_DEFS: RuntimeAgentDef[]` — the exact static, in-memory
38
- * list of every registered agent def (`registry.ts`'s `BASE_AGENT_DEFS`, 24
39
- * entries). `RuntimeAgentDef` has plain `id`/`name` fields (plus a large surface
40
- * of CLI-spawn internals: `bin`, `buildArgs`, `env`, `listModels`, etc. — no
41
- * `available`/live-detection field at all). No HTTP projection of this list
42
- * existed anywhere in `packages/http/src/*.ts` before this pass. Building
43
- * `GET /api/agents` returning `{agents: [{id, name}]}` needed no design judgment
44
- * call beyond "project the two safe fields" — no timeout/caching/probing
45
- * decisions, unlike OD's own `list_agents` (`listAgents`, mcp.ts:1034-1057),
46
- * which filters on a *live-detected* `available: boolean` field (OD's
47
- * `/api/agents` route actually probes each agent binary for installation/version
48
- * at request time — a real design decision this pass deliberately did not need
49
- * to make, since Jini has no such probing route or field to project). So it was
50
- * built: `packages/http/src/agents.ts`'s `agentListRoute` (`GET /api/agents`,
51
- * `AgentsHttpDeps.listAgents: () => readonly {id, name}[]` injected — DI,
52
- * matching `daemon-status.ts`/`active-context.ts`'s convention, so `@jini/http`
53
- * does not take on a new `@jini/agent-runtime` package dependency for a
54
- * two-field projection) plus `@jini/mcp`'s `list_agents` tool
55
- * (`server/tools/run-tools.ts`) proxying it. OD's `includeUnavailable` flag and
56
- * per-agent `installUrl`/`modelsCount` enrichment were **not** ported — those
57
- * exist only because OD's route does live probing, which this pass's route does
58
- * not attempt.
59
- *
60
- * ### What was explicitly excluded (and why)
61
- *
62
- * Per the task brief, only the mechanism plus tools mapping to real kernel
63
- * primitives were ported. The other 13 of OD's 18 `TOOL_DEFS` were **not**
64
- * ported — all are OD's project/file/artifact/skill/plugin model, none of which
65
- * exists in the Jini kernel (extraction-plan §2.1's "no Project, Conversation,
66
- * Brand, DesignSystem, Plugin/marketplace, or Automation noun" holds here
67
- * exactly as it did for `@jini/cli`'s own `mcp` row):
68
- * `list_projects`, `get_artifact`, `get_project`, `get_file`, `search_files`,
69
- * `list_files`, `create_artifact`, `write_file`, `delete_file`, `delete_project`,
70
- * `create_project`, `list_skills`, `list_plugins`. Also not ported: the
71
- * `ListResourcesRequestSchema`/`ReadResourceRequestSchema` resource surface
72
- * (`od://focus/active`, `od://skills/...`, `od://design-systems/...` — resources
73
- * require a skill/design-system store this kernel doesn't have), the studio
74
- * deep-link builder (`buildStudioUrl`, `getDefaultConversationId`,
75
- * `getWebBaseUrl`), the project-name-resolution cache (`resolveProjectId`,
76
- * `fetchProjectList`, `resolveProjectArg`), and the SSE-transcript reassembly
77
- * (`fetchRunAgentMessage`) — every one of these exists only in service of a
78
- * dropped tool.
79
- *
80
- * ### Design decision: the hosting-mechanism shape
81
- *
82
- * `createMcpToolServer({name, version, tools, resolveBaseUrl, instructions?,
83
- * idleMs?, fetchImpl?, stdin?, stdout?, createServer?, createTransport?})`
84
- * returns `{run(): Promise<void>}`. Construction (tool-name-uniqueness
85
- * validation via `buildToolIndex`) is synchronous and side-effect-free; all I/O
86
- * — resolving the daemon URL, connecting the transport, serving requests —
87
- * happens in `run()`. This is genuinely new kernel-adjacent surface (a new
88
- * package capability, not a file port), justified per extraction-plan §7's
89
- * two-consumer rule by the user's explicit request plus the stated expectation
90
- * that Zana/Open-Marketing will each want their own product-specific MCP
91
- * server built on the same hosting mechanism later — so the mechanism accepts
92
- * an arbitrary, caller-registered `McpToolDef[]`, not the 3-5 tools this pass
93
- * ships as its first real, working example.
94
- *
95
- * `McpServerLike`/`McpTransportLike` (in `tool-server.ts`) are narrow structural
96
- * interfaces covering only what this module calls on a `Server` instance
97
- * (`setRequestHandler`, `connect`) and a transport (`onmessage`, `onclose`,
98
- * `close`) — not literal re-exports of the SDK's own types, which can't be
99
- * satisfied by a plain object (the SDK's `Server` class has private fields, so
100
- * it's nominally, not structurally, typed). The default `createServer`/
101
- * `createTransport` factories construct the real SDK classes and cast through
102
- * `unknown` to these narrower interfaces (the same `as unknown as X` pattern
103
- * `core/oauth.ts` already uses to bridge undici's types); a caller may override
104
- * either factory to inject a test double. This is what makes `run()`'s full
105
- * orchestration — activity wrapping, message/close composition, the stdin-close
106
- * race, idle-triggered shutdown — deterministically unit-testable without a
107
- * real subprocess or real timers (see `tool-server.test.ts`), while one
108
- * dedicated test (`'wires the real @modelcontextprotocol/sdk Server +
109
- * StdioServerTransport when no factories are injected'`) still exercises the
110
- * true default path end-to-end with real `node:stream` `PassThrough` streams,
111
- * proving the DI seam isn't hiding a real-SDK integration bug.
112
- *
113
- * **Why `daemon-client.ts` is not `@jini/cli`'s `getJsonFromDaemon`/
114
- * `postJsonToDaemon`.** The architectural brief called for reusing
115
- * `@jini/cli`'s already-hardened daemon-fetch helpers "the way
116
- * `packages/cli/src/run-command.ts` already does." Checked directly: those two
117
- * functions map every failure — network-unreachable, non-2xx, oversized
118
- * response — onto `process.exit()` (via `exitWithStructuredError`), which is
119
- * correct for a one-shot CLI invocation but fatal for a long-lived stdio MCP
120
- * server, where a single failed tool call must return an `{isError:true}` MCP
121
- * result and the process must keep serving the next call. Routing around this
122
- * by injecting an `exit` callback that throws instead of exiting was considered
123
- * and rejected: it would require also capturing/re-parsing whatever
124
- * `postJsonToDaemon` had already written through its `write` callback to
125
- * reconstruct an error message, which is more convoluted than porting OD's own
126
- * already-throw-based `getJson`/`postJson` mechanism with the same bounded-I/O
127
- * hardening added. What **is** reused directly from `@jini/cli` (both pure,
128
- * non-`process.exit` functions): `sanitizeUntrustedText` (redacts daemon-
129
- * supplied error text before it reaches an MCP result) and, per the
130
- * architecture note about `daemon-url.ts`, `resolveDaemonUrl`/
131
- * `sanitizeDaemonUrlForDisplay` are available for a caller's `resolveBaseUrl`
132
- * implementation to use directly (this package does not re-wrap them — a
133
- * caller passes `() => resolveDaemonUrl({...})` straight into
134
- * `createMcpToolServer`'s `resolveBaseUrl` option; no `packages/mcp`-local
135
- * daemon-URL-resolution equivalent was built, since one already exists and nothing
136
- * about MCP's use case differs from a CLI command's). This cross-import
137
- * (`@jini/mcp` -> `@jini/cli`) is allowed by `scripts/check-engine-boundaries.ts`'s
138
- * R7 rule: R7 only restricts a **locked** package (the extraction-plan §3
139
- * fourteen) importing an **unlocked** one below `"stable"` status; `@jini/mcp`
140
- * is itself unlocked (`UNLOCKED.md`), so it importing the locked, stable
141
- * `@jini/cli` package is unrestricted. Confirmed via `pnpm guard` after wiring
142
- * the dependency (see below) — zero violations.
143
- *
144
- * No SSRF hardening (`assertSafePublicUrl`/`createValidatingLookup`, the
145
- * pattern `core/oauth.ts` uses for its own outbound fetches) was added to
146
- * `daemon-client.ts` — deliberately: `assertSafePublicUrl` actively *rejects*
147
- * loopback/private addresses, which is exactly where a real daemon lives. The
148
- * daemon URL here is a caller-resolved, typically-loopback target the user
149
- * already trusts enough to run (the same trust boundary `@jini/cli/http.ts`
150
- * already accepts for the identical "fetch my own daemon" concern), not an
151
- * attacker- or remote-metadata-controlled URL the way an external MCP server's
152
- * OAuth endpoints are.
153
- *
154
- * ### `daemonCallOptions(ctx)` — why a helper for two fields
155
- *
156
- * Nine call sites across `tools/` and `resources/` each wrote `{ fetchImpl: ctx.fetchImpl }` inline. Adding
157
- * a second field that must travel with the first creates a failure mode that is *not* a compile error: miss
158
- * one site and that single tool 401s while its neighbours keep working, which reaches a user as "some of
159
- * these tools just don't do anything". Every site now routes through one mapping, so a newly added tool is
160
- * authenticated by construction.
161
- *
162
- * The guarantee is a test rather than a convention. `server/__tests__/auth-propagation.test.ts` enumerates
163
- * the **real** registries (`RUN_TOOLS`, `TOOL_CATALOG_TOOLS`, `createExecuteDelegatedToolTool`,
164
- * `KERNEL_RESOURCES` — 8 tools + 1 resource), drives each handler through a spy `fetch`, and asserts the
165
- * header on every request each one makes; a matching pass asserts **no** `Authorization` key at all when no
166
- * credential was issued. It also asserts the surface list itself, so emptying or renaming a registry cannot
167
- * make the loops vacuously pass.
168
- *
169
- * That test was verified to actually catch the bug it exists for: reverting one call site to the inline form
170
- * fails it, naming the offending tool and URL.
171
- *
172
- * No transport change was needed — `daemon-client.ts`'s `DaemonRequestOptions` already carried `headers`.
173
- *
174
- * **Verified, personally, this session**: `pnpm --filter @jini-ai/mcp run typecheck` clean; full suite
175
- * **332/332 passing** (18 files), including 21 new propagation tests and 4 new `serve()` credential tests.
176
- * The pre-existing tool/resource suites, which assert the exact options object passed to the daemon client,
177
- * pass **unmodified** — the evidence that the no-credential path is byte-identical.
3
+ * Import capabilities through the public barrel or declared stable subpaths; internal source
4
+ * paths remain private so the core/client/install/hosting split can move without consumer churn.
5
+ * Explicit named exports expose the public surface and catch collisions. Core binding, hosting,
6
+ * daemon I/O and auth propagation are documented at their respective owners below.
178
7
  */
179
8
  export { inferMcpAuthModeForUrl, sanitizeMcpServer, sanitizeMcpConfig, readMcpConfig, writeMcpConfig, isManagedProjectCwd, buildClaudeMcpJson, buildAcpMcpServers, buildOpenCodeMcpConfigContent, } from './core/index.js';
180
9
  export type { McpTransport, McpAuthMode, McpServerConfig, McpConfig, AcpMcpServer, OpenCodeConfigBuildOptions, } from './core/index.js';
package/dist/index.js CHANGED
@@ -1,184 +1,10 @@
1
1
  /**
2
2
  * @module @jini-ai/mcp
3
- * Public barrel for the MCP (Model Context Protocol) domain. External code
4
- * imports MCP capabilities only from here — never from a subdirectory — so the
5
- * internal split (`core` kernel + `client` / `agent-install` concerns) can move
6
- * without touching consumers.
7
- *
8
- * Re-exports are explicit and named (no `export *`) so the public surface is
9
- * visible here and name collisions surface at build time.
10
-
11
- * Archived provenance rationale:
12
- * ## What was dropped (and why)
13
- *
14
- * - **`live-artifacts/` (entire subdir)** — the OD per-run "live artifacts" MCP
15
- * tool surface. An OD-product feature, out of scope per the port brief.
16
- * - **The `od mcp` stdio server** in `client/client.ts` — `runMcpStdio`,
17
- * `TOOL_DEFS`, `handleMcpToolCall`, and every tool handler (`get_artifact`,
18
- * `create_project`, `start_run`, project/active-context resolution, studio deep
19
- * links, run polling, …). This is not a generic MCP client: it is an
20
- * OD-product MCP **server** hardwired to Open Design's REST API
21
- * (`/api/projects`, `/api/skills`, `/api/design-systems`, `/api/runs`, …) and
22
- * concepts (design systems, skills, plugins, artifacts, runs, studio, the
23
- * `od://` resource scheme). It also imports the OD-only `@open-design/contracts`
24
- * package (`buildProjectRawFileUrl`), the OD daemon-internal
25
- * `../../artifacts/create.js`, and the `@modelcontextprotocol/sdk` — none of
26
- * which exist in this repo. Faithfully porting it would mean importing OD
27
- * product surface, so it was dropped and only its generic primitives salvaged.
28
- * - **`MCP_TEMPLATES` catalog + template types + `templateId`** in `config.ts` —
29
- * a large, design-tool-tilted curated catalog of third-party MCP servers with
30
- * OD marketing prose. Zero functional coupling; dropped for neutrality (same
31
- * rationale `@jini/sqlite` used to drop the OD "templates" table).
32
- *
33
- * ### The `list_agents` decision
34
- *
35
- * Investigated before building anything, per the task brief. `@jini/agent-runtime`'s
36
- * barrel (`packages/agent-runtime/src/index.ts` → `export * from './registry.js'`)
37
- * already exports `AGENT_DEFS: RuntimeAgentDef[]` — the exact static, in-memory
38
- * list of every registered agent def (`registry.ts`'s `BASE_AGENT_DEFS`, 24
39
- * entries). `RuntimeAgentDef` has plain `id`/`name` fields (plus a large surface
40
- * of CLI-spawn internals: `bin`, `buildArgs`, `env`, `listModels`, etc. — no
41
- * `available`/live-detection field at all). No HTTP projection of this list
42
- * existed anywhere in `packages/http/src/*.ts` before this pass. Building
43
- * `GET /api/agents` returning `{agents: [{id, name}]}` needed no design judgment
44
- * call beyond "project the two safe fields" — no timeout/caching/probing
45
- * decisions, unlike OD's own `list_agents` (`listAgents`, mcp.ts:1034-1057),
46
- * which filters on a *live-detected* `available: boolean` field (OD's
47
- * `/api/agents` route actually probes each agent binary for installation/version
48
- * at request time — a real design decision this pass deliberately did not need
49
- * to make, since Jini has no such probing route or field to project). So it was
50
- * built: `packages/http/src/agents.ts`'s `agentListRoute` (`GET /api/agents`,
51
- * `AgentsHttpDeps.listAgents: () => readonly {id, name}[]` injected — DI,
52
- * matching `daemon-status.ts`/`active-context.ts`'s convention, so `@jini/http`
53
- * does not take on a new `@jini/agent-runtime` package dependency for a
54
- * two-field projection) plus `@jini/mcp`'s `list_agents` tool
55
- * (`server/tools/run-tools.ts`) proxying it. OD's `includeUnavailable` flag and
56
- * per-agent `installUrl`/`modelsCount` enrichment were **not** ported — those
57
- * exist only because OD's route does live probing, which this pass's route does
58
- * not attempt.
59
- *
60
- * ### What was explicitly excluded (and why)
61
- *
62
- * Per the task brief, only the mechanism plus tools mapping to real kernel
63
- * primitives were ported. The other 13 of OD's 18 `TOOL_DEFS` were **not**
64
- * ported — all are OD's project/file/artifact/skill/plugin model, none of which
65
- * exists in the Jini kernel (extraction-plan §2.1's "no Project, Conversation,
66
- * Brand, DesignSystem, Plugin/marketplace, or Automation noun" holds here
67
- * exactly as it did for `@jini/cli`'s own `mcp` row):
68
- * `list_projects`, `get_artifact`, `get_project`, `get_file`, `search_files`,
69
- * `list_files`, `create_artifact`, `write_file`, `delete_file`, `delete_project`,
70
- * `create_project`, `list_skills`, `list_plugins`. Also not ported: the
71
- * `ListResourcesRequestSchema`/`ReadResourceRequestSchema` resource surface
72
- * (`od://focus/active`, `od://skills/...`, `od://design-systems/...` — resources
73
- * require a skill/design-system store this kernel doesn't have), the studio
74
- * deep-link builder (`buildStudioUrl`, `getDefaultConversationId`,
75
- * `getWebBaseUrl`), the project-name-resolution cache (`resolveProjectId`,
76
- * `fetchProjectList`, `resolveProjectArg`), and the SSE-transcript reassembly
77
- * (`fetchRunAgentMessage`) — every one of these exists only in service of a
78
- * dropped tool.
79
- *
80
- * ### Design decision: the hosting-mechanism shape
81
- *
82
- * `createMcpToolServer({name, version, tools, resolveBaseUrl, instructions?,
83
- * idleMs?, fetchImpl?, stdin?, stdout?, createServer?, createTransport?})`
84
- * returns `{run(): Promise<void>}`. Construction (tool-name-uniqueness
85
- * validation via `buildToolIndex`) is synchronous and side-effect-free; all I/O
86
- * — resolving the daemon URL, connecting the transport, serving requests —
87
- * happens in `run()`. This is genuinely new kernel-adjacent surface (a new
88
- * package capability, not a file port), justified per extraction-plan §7's
89
- * two-consumer rule by the user's explicit request plus the stated expectation
90
- * that Zana/Open-Marketing will each want their own product-specific MCP
91
- * server built on the same hosting mechanism later — so the mechanism accepts
92
- * an arbitrary, caller-registered `McpToolDef[]`, not the 3-5 tools this pass
93
- * ships as its first real, working example.
94
- *
95
- * `McpServerLike`/`McpTransportLike` (in `tool-server.ts`) are narrow structural
96
- * interfaces covering only what this module calls on a `Server` instance
97
- * (`setRequestHandler`, `connect`) and a transport (`onmessage`, `onclose`,
98
- * `close`) — not literal re-exports of the SDK's own types, which can't be
99
- * satisfied by a plain object (the SDK's `Server` class has private fields, so
100
- * it's nominally, not structurally, typed). The default `createServer`/
101
- * `createTransport` factories construct the real SDK classes and cast through
102
- * `unknown` to these narrower interfaces (the same `as unknown as X` pattern
103
- * `core/oauth.ts` already uses to bridge undici's types); a caller may override
104
- * either factory to inject a test double. This is what makes `run()`'s full
105
- * orchestration — activity wrapping, message/close composition, the stdin-close
106
- * race, idle-triggered shutdown — deterministically unit-testable without a
107
- * real subprocess or real timers (see `tool-server.test.ts`), while one
108
- * dedicated test (`'wires the real @modelcontextprotocol/sdk Server +
109
- * StdioServerTransport when no factories are injected'`) still exercises the
110
- * true default path end-to-end with real `node:stream` `PassThrough` streams,
111
- * proving the DI seam isn't hiding a real-SDK integration bug.
112
- *
113
- * **Why `daemon-client.ts` is not `@jini/cli`'s `getJsonFromDaemon`/
114
- * `postJsonToDaemon`.** The architectural brief called for reusing
115
- * `@jini/cli`'s already-hardened daemon-fetch helpers "the way
116
- * `packages/cli/src/run-command.ts` already does." Checked directly: those two
117
- * functions map every failure — network-unreachable, non-2xx, oversized
118
- * response — onto `process.exit()` (via `exitWithStructuredError`), which is
119
- * correct for a one-shot CLI invocation but fatal for a long-lived stdio MCP
120
- * server, where a single failed tool call must return an `{isError:true}` MCP
121
- * result and the process must keep serving the next call. Routing around this
122
- * by injecting an `exit` callback that throws instead of exiting was considered
123
- * and rejected: it would require also capturing/re-parsing whatever
124
- * `postJsonToDaemon` had already written through its `write` callback to
125
- * reconstruct an error message, which is more convoluted than porting OD's own
126
- * already-throw-based `getJson`/`postJson` mechanism with the same bounded-I/O
127
- * hardening added. What **is** reused directly from `@jini/cli` (both pure,
128
- * non-`process.exit` functions): `sanitizeUntrustedText` (redacts daemon-
129
- * supplied error text before it reaches an MCP result) and, per the
130
- * architecture note about `daemon-url.ts`, `resolveDaemonUrl`/
131
- * `sanitizeDaemonUrlForDisplay` are available for a caller's `resolveBaseUrl`
132
- * implementation to use directly (this package does not re-wrap them — a
133
- * caller passes `() => resolveDaemonUrl({...})` straight into
134
- * `createMcpToolServer`'s `resolveBaseUrl` option; no `packages/mcp`-local
135
- * daemon-URL-resolution equivalent was built, since one already exists and nothing
136
- * about MCP's use case differs from a CLI command's). This cross-import
137
- * (`@jini/mcp` -> `@jini/cli`) is allowed by `scripts/check-engine-boundaries.ts`'s
138
- * R7 rule: R7 only restricts a **locked** package (the extraction-plan §3
139
- * fourteen) importing an **unlocked** one below `"stable"` status; `@jini/mcp`
140
- * is itself unlocked (`UNLOCKED.md`), so it importing the locked, stable
141
- * `@jini/cli` package is unrestricted. Confirmed via `pnpm guard` after wiring
142
- * the dependency (see below) — zero violations.
143
- *
144
- * No SSRF hardening (`assertSafePublicUrl`/`createValidatingLookup`, the
145
- * pattern `core/oauth.ts` uses for its own outbound fetches) was added to
146
- * `daemon-client.ts` — deliberately: `assertSafePublicUrl` actively *rejects*
147
- * loopback/private addresses, which is exactly where a real daemon lives. The
148
- * daemon URL here is a caller-resolved, typically-loopback target the user
149
- * already trusts enough to run (the same trust boundary `@jini/cli/http.ts`
150
- * already accepts for the identical "fetch my own daemon" concern), not an
151
- * attacker- or remote-metadata-controlled URL the way an external MCP server's
152
- * OAuth endpoints are.
153
- *
154
- * ### `daemonCallOptions(ctx)` — why a helper for two fields
155
- *
156
- * Nine call sites across `tools/` and `resources/` each wrote `{ fetchImpl: ctx.fetchImpl }` inline. Adding
157
- * a second field that must travel with the first creates a failure mode that is *not* a compile error: miss
158
- * one site and that single tool 401s while its neighbours keep working, which reaches a user as "some of
159
- * these tools just don't do anything". Every site now routes through one mapping, so a newly added tool is
160
- * authenticated by construction.
161
- *
162
- * The guarantee is a test rather than a convention. `server/__tests__/auth-propagation.test.ts` enumerates
163
- * the **real** registries (`RUN_TOOLS`, `TOOL_CATALOG_TOOLS`, `createExecuteDelegatedToolTool`,
164
- * `KERNEL_RESOURCES` — 8 tools + 1 resource), drives each handler through a spy `fetch`, and asserts the
165
- * header on every request each one makes; a matching pass asserts **no** `Authorization` key at all when no
166
- * credential was issued. It also asserts the surface list itself, so emptying or renaming a registry cannot
167
- * make the loops vacuously pass.
168
- *
169
- * That test was verified to actually catch the bug it exists for: reverting one call site to the inline form
170
- * fails it, naming the offending tool and URL.
171
- *
172
- * No transport change was needed — `daemon-client.ts`'s `DaemonRequestOptions` already carried `headers`.
173
- *
174
- * **Verified, personally, this session**: `pnpm --filter @jini-ai/mcp run typecheck` clean; full suite
175
- * **332/332 passing** (18 files), including 21 new propagation tests and 4 new `serve()` credential tests.
176
- * The pre-existing tool/resource suites, which assert the exact options object passed to the daemon client,
177
- * pass **unmodified** — the evidence that the no-credential path is byte-identical.
3
+ * Import capabilities through the public barrel or declared stable subpaths; internal source
4
+ * paths remain private so the core/client/install/hosting split can move without consumer churn.
5
+ * Explicit named exports expose the public surface and catch collisions. Core binding, hosting,
6
+ * daemon I/O and auth propagation are documented at their respective owners below.
178
7
  */
179
- // The original root-only boundary above describes the initial core/client/server split.
180
- // Stable public subpaths now also host federation, approvals, stdio, testing, and ask-choice;
181
- // internal source paths remain private so consumers can follow their declared runtime entry.
182
8
  // ── core: config schema + IO ────────────────────────────────────────────────
183
9
  export { inferMcpAuthModeForUrl, sanitizeMcpServer, sanitizeMcpConfig, readMcpConfig, writeMcpConfig, isManagedProjectCwd, buildClaudeMcpJson, buildAcpMcpServers, buildOpenCodeMcpConfigContent, } from './core/index.js';
184
10
  // ── core: token store ───────────────────────────────────────────────────────
@@ -1,7 +1,6 @@
1
1
  /**
2
- * Thrown for a non-2xx daemon response. The message is unchanged from the plain `Error` this used to
3
- * be; `status` is what lets a tool tell "this daemon does not serve the route" (404) apart from any
4
- * other failure instead of pattern-matching the message.
2
+ * Thrown for a non-2xx daemon response. `status` lets a tool distinguish an unsupported route (404)
3
+ * from other failures without pattern-matching the public error message.
5
4
  */
6
5
  export declare class DaemonHttpError extends Error {
7
6
  readonly status: number;
@@ -1,30 +1,12 @@
1
1
  /**
2
2
  * @module @jini-ai/mcp/server/daemon-client
3
+ * Bounded-I/O JSON GET/POST against a caller-resolved trusted daemon base URL.
4
+ * A long-lived MCP server must report a failed call and keep serving, so this transport throws
5
+ * redacted errors for handleToolCall to convert rather than using the CLI transport's process.exit.
6
+ * Bounded reads, timeouts and redaction follow CR-004/SEC-RB-009.
3
7
  *
4
- * Bounded-I/O JSON GET/POST against a trusted daemon base URL — the transport
5
- * primitive every proxy tool `createMcpToolServer` hosts is built on
6
- * (`../client/client.js`'s `createMcpIdleExitController` is the other half of
7
- * the mechanism; this module is the network half).
8
- *
9
- * Deliberately NOT the CLI transport's `getJsonFromDaemon`/`postJsonToDaemon`
10
- * (`packages/cli/src/http.ts`): those map a failure onto `process.exit`,
11
- * which is the right contract for a one-shot CLI invocation but wrong here —
12
- * a stdio MCP server is a long-lived process serving many tool calls, and one
13
- * failed call must return an MCP `{isError:true}` result and keep serving the
14
- * next call, not terminate the process. This module ports the same
15
- * bounded-read / timeout / redaction posture `http.ts` established
16
- * (CR-004/SEC-RB-009 — see `ADS-memory/reports/code-review/CR-remaining-backend-audit-2026-07-21.md`,
17
- * `ADS-memory/reports/security/SEC-remaining-backend-audit-2026-07-21.md`) as
18
- * a throw-based primitive instead: a failed request throws a plain,
19
- * already-redacted `Error` whose message a tool handler's caller
20
- * (`handleToolCall` in `./tool-protocol.js`) turns into that MCP error result.
21
- *
22
- * No SSRF hardening here: the
23
- * target is a caller-resolved, typically-loopback daemon the user already
24
- * trusts enough to run — not an attacker- or server-metadata-controlled
25
- * remote URL the way a configured external MCP server's OAuth endpoints are.
26
- * This mirrors the CLI HTTP transport's own posture for the identical "fetch my
27
- * own daemon" concern (no `assertSafePublicUrl` there either).
8
+ * This is a trusted, typically-loopback daemon URL, not attacker-controlled server metadata or
9
+ * an external MCP OAuth endpoint; public-URL SSRF validation is therefore outside this contract.
28
10
  */
29
11
  import { sanitizeUntrustedText } from '@jini-ai/core/text';
30
12
  /** Request deadline. Generous enough for a slow tool call, short enough that a stalled daemon doesn't hang a stdio server turn forever. */
@@ -32,9 +14,8 @@ const DEFAULT_TIMEOUT_MS = 15_000;
32
14
  /** Cap on a daemon response body. Comfortably larger than any real status/run envelope, small enough to bound worst-case memory from a hostile/broken daemon. */
33
15
  const DEFAULT_MAX_RESPONSE_BYTES = 10 * 1024 * 1024;
34
16
  /**
35
- * Thrown for a non-2xx daemon response. The message is unchanged from the plain `Error` this used to
36
- * be; `status` is what lets a tool tell "this daemon does not serve the route" (404) apart from any
37
- * other failure instead of pattern-matching the message.
17
+ * Thrown for a non-2xx daemon response. `status` lets a tool distinguish an unsupported route (404)
18
+ * from other failures without pattern-matching the public error message.
38
19
  */
39
20
  export class DaemonHttpError extends Error {
40
21
  status;