appduct 0.10.0 → 0.11.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.
Files changed (230) hide show
  1. package/README.md +122 -52
  2. package/dist/artifact-inspect.d.ts +0 -1
  3. package/dist/bin.d.ts +0 -1
  4. package/dist/bin.js +1250 -2
  5. package/dist/cli/command-options.d.ts +8 -1
  6. package/dist/cli/create-cli.d.ts +0 -1
  7. package/dist/cli/dispatch.d.ts +10 -1
  8. package/dist/cli/envelope.d.ts +19 -0
  9. package/dist/cli/global-flags.d.ts +30 -0
  10. package/dist/cli/open-target.d.ts +25 -8
  11. package/dist/cli/result-types.d.ts +31 -5
  12. package/dist/cli/router.d.ts +78 -0
  13. package/dist/cli/routes/daemon/index.d.ts +9 -0
  14. package/dist/cli/routes/daemon/index.js +848 -0
  15. package/dist/cli/routes/daemon/run.d.ts +3 -0
  16. package/dist/cli/routes/daemon/run.js +3246 -0
  17. package/dist/cli/routes/daemon/start.d.ts +3 -0
  18. package/dist/cli/routes/daemon/start.js +1677 -0
  19. package/dist/cli/routes/daemon/status.d.ts +4 -0
  20. package/dist/cli/routes/daemon/status.js +1685 -0
  21. package/dist/cli/routes/daemon/stop.d.ts +4 -0
  22. package/dist/cli/routes/daemon/stop.js +1655 -0
  23. package/dist/cli/routes/doctor.d.ts +3 -0
  24. package/dist/cli/routes/doctor.js +1075 -0
  25. package/dist/cli/routes/events.d.ts +3 -0
  26. package/dist/cli/routes/events.js +1915 -0
  27. package/dist/cli/routes/init.d.ts +3 -0
  28. package/dist/cli/routes/init.js +1486 -0
  29. package/dist/cli/routes/invoke.d.ts +3 -0
  30. package/dist/cli/routes/invoke.js +1893 -0
  31. package/dist/cli/routes/keygen.d.ts +3 -0
  32. package/dist/cli/routes/keygen.js +928 -0
  33. package/dist/cli/routes/link.d.ts +3 -0
  34. package/dist/cli/routes/link.js +2714 -0
  35. package/dist/cli/routes/ls.d.ts +3 -0
  36. package/dist/cli/routes/ls.js +1668 -0
  37. package/dist/cli/routes/mcp.d.ts +5 -0
  38. package/dist/cli/routes/mcp.js +3929 -0
  39. package/dist/cli/routes/revoke.d.ts +3 -0
  40. package/dist/cli/routes/revoke.js +1684 -0
  41. package/dist/cli/routes/tools.d.ts +3 -0
  42. package/dist/cli/routes/tools.js +1860 -0
  43. package/dist/cli/runner.d.ts +3 -11
  44. package/dist/cli/types.d.ts +9 -6
  45. package/dist/cli/version-guard.d.ts +20 -0
  46. package/dist/cli.d.ts +0 -1
  47. package/dist/client/app-client.d.ts +4 -4
  48. package/dist/client/bootstrap.d.ts +4 -4
  49. package/dist/client/connect.d.ts +0 -1
  50. package/dist/client/errors.d.ts +0 -1
  51. package/dist/client/index.d.ts +1 -2
  52. package/dist/client/index.js +2389 -16
  53. package/dist/commands/daemon/context.d.ts +14 -0
  54. package/dist/commands/daemon/run.d.ts +11 -0
  55. package/dist/commands/daemon/start.d.ts +5 -0
  56. package/dist/commands/daemon/status.d.ts +5 -0
  57. package/dist/commands/daemon/stop.d.ts +5 -0
  58. package/dist/commands/doctor.d.ts +0 -1
  59. package/dist/commands/events.d.ts +0 -1
  60. package/dist/commands/init.d.ts +17 -4
  61. package/dist/commands/invoke.d.ts +0 -1
  62. package/dist/commands/keygen.d.ts +0 -1
  63. package/dist/commands/link.d.ts +1 -2
  64. package/dist/commands/ls.d.ts +0 -1
  65. package/dist/commands/mcp.d.ts +2 -4
  66. package/dist/commands/revoke.d.ts +0 -1
  67. package/dist/commands/tools.d.ts +19 -3
  68. package/dist/daemon/address.d.ts +0 -1
  69. package/dist/daemon/audit.d.ts +6 -11
  70. package/dist/daemon/calls.d.ts +0 -1
  71. package/dist/daemon/config.d.ts +13 -16
  72. package/dist/daemon/daemon.d.ts +0 -1
  73. package/dist/daemon/event-bus.d.ts +0 -1
  74. package/dist/daemon/links.d.ts +0 -1
  75. package/dist/daemon/listener.d.ts +0 -1
  76. package/dist/daemon/log-rotation.d.ts +0 -1
  77. package/dist/daemon/pidfile.d.ts +14 -8
  78. package/dist/daemon/policy.d.ts +0 -1
  79. package/dist/daemon/registry.d.ts +0 -1
  80. package/dist/daemon/rpc-errors.d.ts +16 -0
  81. package/dist/daemon/rpc-server.d.ts +0 -11
  82. package/dist/daemon/sessions.d.ts +0 -1
  83. package/dist/daemon/socket-probe.d.ts +0 -1
  84. package/dist/daemon/state-dir.d.ts +0 -1
  85. package/dist/daemon/timers.d.ts +0 -1
  86. package/dist/daemon/tls.d.ts +0 -1
  87. package/dist/errors.d.ts +0 -1
  88. package/dist/host-certificate.d.ts +0 -1
  89. package/dist/index.d.ts +0 -1
  90. package/dist/index.js +1307 -3
  91. package/dist/key-material.d.ts +0 -1
  92. package/dist/link.d.ts +8 -7
  93. package/dist/mcp/app-tools.d.ts +153 -0
  94. package/dist/mcp/connect-tool.d.ts +14 -8
  95. package/dist/mcp/daemon-tools.d.ts +2 -11
  96. package/dist/mcp/events-tool.d.ts +0 -1
  97. package/dist/mcp/server.d.ts +33 -14
  98. package/dist/native-scheme.d.ts +0 -1
  99. package/dist/output.d.ts +8 -11
  100. package/dist/package-root.d.ts +11 -0
  101. package/dist/package-version.d.ts +0 -1
  102. package/dist/qr-terminal.d.ts +0 -1
  103. package/dist/rpc/client.d.ts +3 -3
  104. package/dist/scheme.d.ts +60 -2
  105. package/dist/spki-pin.d.ts +0 -1
  106. package/package.json +6 -3
  107. package/dist/artifact-inspect.d.ts.map +0 -1
  108. package/dist/artifact-inspect.js +0 -334
  109. package/dist/bin.d.ts.map +0 -1
  110. package/dist/cli/command-options.d.ts.map +0 -1
  111. package/dist/cli/command-options.js +0 -95
  112. package/dist/cli/create-cli.d.ts.map +0 -1
  113. package/dist/cli/create-cli.js +0 -56
  114. package/dist/cli/dispatch.d.ts.map +0 -1
  115. package/dist/cli/dispatch.js +0 -275
  116. package/dist/cli/open-target.d.ts.map +0 -1
  117. package/dist/cli/open-target.js +0 -438
  118. package/dist/cli/result-types.d.ts.map +0 -1
  119. package/dist/cli/result-types.js +0 -6
  120. package/dist/cli/runner.d.ts.map +0 -1
  121. package/dist/cli/runner.js +0 -111
  122. package/dist/cli/types.d.ts.map +0 -1
  123. package/dist/cli/types.js +0 -10
  124. package/dist/cli.d.ts.map +0 -1
  125. package/dist/cli.js +0 -2
  126. package/dist/client/app-client.d.ts.map +0 -1
  127. package/dist/client/app-client.js +0 -223
  128. package/dist/client/bootstrap.d.ts.map +0 -1
  129. package/dist/client/bootstrap.js +0 -108
  130. package/dist/client/connect.d.ts.map +0 -1
  131. package/dist/client/connect.js +0 -38
  132. package/dist/client/errors.d.ts.map +0 -1
  133. package/dist/client/errors.js +0 -40
  134. package/dist/client/index.d.ts.map +0 -1
  135. package/dist/commands/daemon.d.ts +0 -22
  136. package/dist/commands/daemon.d.ts.map +0 -1
  137. package/dist/commands/daemon.js +0 -128
  138. package/dist/commands/doctor.d.ts.map +0 -1
  139. package/dist/commands/doctor.js +0 -42
  140. package/dist/commands/events.d.ts.map +0 -1
  141. package/dist/commands/events.js +0 -76
  142. package/dist/commands/init.d.ts.map +0 -1
  143. package/dist/commands/init.js +0 -231
  144. package/dist/commands/invoke.d.ts.map +0 -1
  145. package/dist/commands/invoke.js +0 -54
  146. package/dist/commands/keygen.d.ts.map +0 -1
  147. package/dist/commands/keygen.js +0 -46
  148. package/dist/commands/link.d.ts.map +0 -1
  149. package/dist/commands/link.js +0 -43
  150. package/dist/commands/ls.d.ts.map +0 -1
  151. package/dist/commands/ls.js +0 -7
  152. package/dist/commands/mcp.d.ts.map +0 -1
  153. package/dist/commands/mcp.js +0 -98
  154. package/dist/commands/revoke.d.ts.map +0 -1
  155. package/dist/commands/revoke.js +0 -7
  156. package/dist/commands/tools.d.ts.map +0 -1
  157. package/dist/commands/tools.js +0 -52
  158. package/dist/daemon/address.d.ts.map +0 -1
  159. package/dist/daemon/address.js +0 -71
  160. package/dist/daemon/audit.d.ts.map +0 -1
  161. package/dist/daemon/audit.js +0 -237
  162. package/dist/daemon/calls.d.ts.map +0 -1
  163. package/dist/daemon/calls.js +0 -158
  164. package/dist/daemon/config.d.ts.map +0 -1
  165. package/dist/daemon/config.js +0 -170
  166. package/dist/daemon/daemon.d.ts.map +0 -1
  167. package/dist/daemon/daemon.js +0 -560
  168. package/dist/daemon/event-bus.d.ts.map +0 -1
  169. package/dist/daemon/event-bus.js +0 -111
  170. package/dist/daemon/links.d.ts.map +0 -1
  171. package/dist/daemon/links.js +0 -118
  172. package/dist/daemon/listener.d.ts.map +0 -1
  173. package/dist/daemon/listener.js +0 -132
  174. package/dist/daemon/log-rotation.d.ts.map +0 -1
  175. package/dist/daemon/log-rotation.js +0 -136
  176. package/dist/daemon/pidfile.d.ts.map +0 -1
  177. package/dist/daemon/pidfile.js +0 -90
  178. package/dist/daemon/policy.d.ts.map +0 -1
  179. package/dist/daemon/policy.js +0 -24
  180. package/dist/daemon/registry.d.ts.map +0 -1
  181. package/dist/daemon/registry.js +0 -31
  182. package/dist/daemon/rpc-server.d.ts.map +0 -1
  183. package/dist/daemon/rpc-server.js +0 -203
  184. package/dist/daemon/sessions.d.ts.map +0 -1
  185. package/dist/daemon/sessions.js +0 -429
  186. package/dist/daemon/socket-probe.d.ts.map +0 -1
  187. package/dist/daemon/socket-probe.js +0 -28
  188. package/dist/daemon/state-dir.d.ts.map +0 -1
  189. package/dist/daemon/state-dir.js +0 -43
  190. package/dist/daemon/timers.d.ts.map +0 -1
  191. package/dist/daemon/timers.js +0 -12
  192. package/dist/daemon/tls.d.ts.map +0 -1
  193. package/dist/daemon/tls.js +0 -79
  194. package/dist/errors.d.ts.map +0 -1
  195. package/dist/errors.js +0 -162
  196. package/dist/host-certificate.d.ts.map +0 -1
  197. package/dist/host-certificate.js +0 -111
  198. package/dist/index.d.ts.map +0 -1
  199. package/dist/key-material.d.ts.map +0 -1
  200. package/dist/key-material.js +0 -42
  201. package/dist/link.d.ts.map +0 -1
  202. package/dist/link.js +0 -117
  203. package/dist/mcp/connect-tool.d.ts.map +0 -1
  204. package/dist/mcp/connect-tool.js +0 -396
  205. package/dist/mcp/daemon-tools.d.ts.map +0 -1
  206. package/dist/mcp/daemon-tools.js +0 -29
  207. package/dist/mcp/events-tool.d.ts.map +0 -1
  208. package/dist/mcp/events-tool.js +0 -322
  209. package/dist/mcp/server.d.ts.map +0 -1
  210. package/dist/mcp/server.js +0 -538
  211. package/dist/mcp/tool-mapping.d.ts +0 -58
  212. package/dist/mcp/tool-mapping.d.ts.map +0 -1
  213. package/dist/mcp/tool-mapping.js +0 -143
  214. package/dist/mcp/tool-namespace.d.ts +0 -40
  215. package/dist/mcp/tool-namespace.d.ts.map +0 -1
  216. package/dist/mcp/tool-namespace.js +0 -65
  217. package/dist/native-scheme.d.ts.map +0 -1
  218. package/dist/native-scheme.js +0 -465
  219. package/dist/output.d.ts.map +0 -1
  220. package/dist/output.js +0 -342
  221. package/dist/package-version.d.ts.map +0 -1
  222. package/dist/package-version.js +0 -47
  223. package/dist/qr-terminal.d.ts.map +0 -1
  224. package/dist/qr-terminal.js +0 -45
  225. package/dist/rpc/client.d.ts.map +0 -1
  226. package/dist/rpc/client.js +0 -819
  227. package/dist/scheme.d.ts.map +0 -1
  228. package/dist/scheme.js +0 -321
  229. package/dist/spki-pin.d.ts.map +0 -1
  230. package/dist/spki-pin.js +0 -14
@@ -1,538 +0,0 @@
1
- /**
2
- * The `appduct mcp` stdio MCP server (ARCHITECTURE.md §9): a thin proxy that maps the daemon RPC
3
- * surface (`rpc/client.ts`, same auto-spawning client the CLI uses) onto MCP's `tools/list`,
4
- * `tools/call`, `notifications/tools/list_changed`, progress notifications, and one resource
5
- * (`appduct://sessions`). All logging here goes to stderr only — stdout is reserved for the MCP
6
- * transport's protocol frames.
7
- *
8
- * One persistent daemon connection (`stream`) is used for everything except progress-correlated
9
- * `tools.call`s, which get their own short-lived connection (see `callProxiedTool` below) so the
10
- * `tool_call_started` event that reveals the call's `callId` is unambiguous.
11
- *
12
- * This is also where both `"prompt"`-policy consent channels live (ARCHITECTURE.md §12):
13
- * `resolveToolCallConsent` prefers `elicitation/create` (issue #10, any client that declares the
14
- * `elicitation` capability) over the `_meta["anthropic/requiresUserInteraction"]` flag (issue #14,
15
- * Claude Code ≥ v2.1.199 only), and the two never arm for the same call — see
16
- * `clientSupportsElicitation` and the `tools/list` handler below.
17
- */
18
- import { Server } from "@modelcontextprotocol/sdk/server/index.js";
19
- import { CallToolRequestSchema, ErrorCode, ListResourcesRequestSchema, ListToolsRequestSchema, McpError, ReadResourceRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
20
- import { RPC_METHODS } from "@appduct/shared";
21
- import { clampTimeout, deriveCallTransportTimeoutMs } from "../daemon/calls.js";
22
- import { getPackageVersion } from "../package-version.js";
23
- import { DaemonRpcError, DaemonVersionMismatchError, openDaemonStream, } from "../rpc/client.js";
24
- import { CONNECT_TOOL_DESCRIPTOR, CONNECT_TOOL_NAME, handleConnectTool, handleWaitForSessionTool, McpBuiltinToolError, WAIT_FOR_SESSION_TOOL_DESCRIPTOR, WAIT_FOR_SESSION_TOOL_NAME, } from "./connect-tool.js";
25
- import { fetchEffectiveTools } from "./daemon-tools.js";
26
- import { EVENTS_TOOL_DESCRIPTOR, EVENTS_TOOL_NAME, handleEventsTool, handleWaitForEventTool, WAIT_FOR_EVENT_TOOL_DESCRIPTOR, WAIT_FOR_EVENT_TOOL_NAME, } from "./events-tool.js";
27
- import { createMcpToolMapper, emitsMcpOutputSchema } from "./tool-mapping.js";
28
- import { findNamespacedTool, namespacedToolsSnapshotKey } from "./tool-namespace.js";
29
- const packageVersion = getPackageVersion();
30
- export const SESSIONS_RESOURCE_URI = "appduct://sessions";
31
- /** Event kinds whose arrival can change the effective (namespaced or not) tool list — including
32
- * the single↔multi namespacing flip itself, which is driven by session count, not tool count. */
33
- const LIST_CHANGE_EVENT_KINDS = [
34
- "tools_changed",
35
- "session_claimed",
36
- "session_revoked",
37
- "session_expired",
38
- "session_suspended",
39
- "session_resumed",
40
- ];
41
- const isPlainObject = (value) => {
42
- return typeof value === "object" && value !== null && !Array.isArray(value);
43
- };
44
- /** The *shape* of a rejected tool result, for the error message only — never the value itself,
45
- * which may be large or carry app data that does not belong in an agent-visible error string. */
46
- const describeJsonValue = (value) => {
47
- if (value === null) {
48
- return "null";
49
- }
50
- // Defensive: the daemon rejects a `tool_result` frame with no `result`, so nothing on today's
51
- // wire path yields `undefined` here. Spelled out anyway so a future one reads as "returned no
52
- // value" rather than the `typeof` wording, "returned a undefined".
53
- if (value === undefined) {
54
- return "no value";
55
- }
56
- if (Array.isArray(value)) {
57
- return "an array";
58
- }
59
- return `a ${typeof value}`;
60
- };
61
- /** The minimum Claude Code version documented (ARCHITECTURE.md §12 / issue #14) to enforce
62
- * `_meta["anthropic/requiresUserInteraction"]` on every call, in every permission mode, with no
63
- * "don't ask again" option. Older Claude Code and every other client ignore the flag silently. */
64
- const REQUIRES_USER_INTERACTION_MIN_VERSION = [2, 1, 199];
65
- /** Strict `\d+` per dot-separated part — rejects a pre-release/build suffix (`"2.1.199-beta.1"`,
66
- * `"2.1.199rc"`) rather than letting `Number.parseInt`'s leading-digits-only parsing treat it as
67
- * `2.1.199` and wrongly report a pre-release as version-compliant. */
68
- const parseVersionParts = (version) => {
69
- const rawParts = version.split(".");
70
- if (!rawParts.every((part) => /^\d+$/u.test(part))) {
71
- return undefined;
72
- }
73
- return rawParts.map((part) => Number.parseInt(part, 10));
74
- };
75
- const isVersionAtLeast = (version, min) => {
76
- const parts = parseVersionParts(version);
77
- if (!parts) {
78
- return false;
79
- }
80
- for (let i = 0; i < min.length; i++) {
81
- const part = parts[i] ?? 0;
82
- if (part > min[i]) {
83
- return true;
84
- }
85
- if (part < min[i]) {
86
- return false;
87
- }
88
- }
89
- return true;
90
- };
91
- /**
92
- * Whether the connected MCP client is known to enforce `_meta["anthropic/requiresUserInteraction"]`
93
- * (ARCHITECTURE.md §12 / issue #14). `clientInfo` is self-reported at `initialize` — a hostile
94
- * client could claim to be Claude Code, but that is outside this feature's threat model
95
- * (unattended automation, not a malicious client); it is documented as a known limitation.
96
- */
97
- const clientHonorsRequiresUserInteraction = (server) => {
98
- const clientInfo = server.getClientVersion();
99
- return clientInfo?.name === "claude-code" && isVersionAtLeast(clientInfo.version, REQUIRES_USER_INTERACTION_MIN_VERSION);
100
- };
101
- /**
102
- * Whether the connected MCP client declared the `elicitation` capability at `initialize`
103
- * (ARCHITECTURE.md §12 / issue #10) — the preferred `"prompt"`-policy consent channel, checked
104
- * fresh on every call rather than cached (same principle as `clientHonorsRequiresUserInteraction`
105
- * above: nothing about consent is trusted from a stale snapshot). A bare `elicitation: {}` from the
106
- * client normalizes to `{ form: {} }` in the SDK's parsed capabilities (backwards-compat default),
107
- * which is what `server.elicitInput`'s own form-mode request actually requires — this check only
108
- * needs to know the key is present at all, and lets `elicitInput` itself fail (caught below, and
109
- * treated as "no channel", never as approval) if the specific mode turns out unsupported.
110
- */
111
- const clientSupportsElicitation = (server) => {
112
- return server.getClientCapabilities()?.elicitation !== undefined;
113
- };
114
- /** Bounds an elicitation's wait server-side (ARCHITECTURE.md §9/§12 / issue #10) — comfortably
115
- * under the 30-minute idle window a stdio MCP tool call gets before Claude Code aborts it, so an
116
- * unanswered prompt resolves as a clean decline-shaped result naming the timeout rather than an
117
- * opaque abort at the transport's own limit. */
118
- const ELICITATION_TIMEOUT_MS = 10 * 60 * 1000;
119
- /** Caps how much of a call's JSON-stringified `args` is rendered into an elicitation message body —
120
- * plenty to show a human what's about to run without flooding a prompt UI with a large payload. */
121
- const ELICITATION_ARGS_PREVIEW_MAX_CHARS = 2048;
122
- const formatElicitationArgsPreview = (args) => {
123
- const json = JSON.stringify(args);
124
- if (json.length <= ELICITATION_ARGS_PREVIEW_MAX_CHARS) {
125
- return json;
126
- }
127
- return `${json.slice(0, ELICITATION_ARGS_PREVIEW_MAX_CHARS)}… [truncated at ${ELICITATION_ARGS_PREVIEW_MAX_CHARS} of ${json.length} characters]`;
128
- };
129
- /** Names the session alias, the tool, and the actual arguments — the human answering this prompt
130
- * has nothing else to go on (ARCHITECTURE.md §12 / issue #10). */
131
- const buildElicitationMessage = (tool, args) => {
132
- return (`Appduct: allow the MCP tool "${tool.descriptor.name}" to run on session "${tool.selector}"? ` +
133
- `Arguments: ${formatElicitationArgsPreview(args)}`);
134
- };
135
- /** Thrown from `callProxiedTool` when the human declined or cancelled an elicitation prompt (or it
136
- * timed out), so the outer `tools/call` handler can turn it into an ordinary MCP tool **result**
137
- * with `isError: true` — never a thrown protocol-level error — matching every other error path in
138
- * this file (`toolErrorContentFromError` below): the agent reading the result must be able to
139
- * branch on "the user said no" the same way it branches on any other tool error. */
140
- class ElicitationDeclinedError extends Error {
141
- constructor(message) {
142
- super(message);
143
- this.name = "ElicitationDeclinedError";
144
- }
145
- }
146
- /**
147
- * Sends one `elicitation/create` request for a `"prompt"`-policy call and interprets the reply
148
- * (ARCHITECTURE.md §12 / issue #10). Three outcomes:
149
- * - `"accepted"`: the daemon call proceeds with `consent: "elicitation"`.
150
- * - `"declined"`: an explicit decline, a cancel, or a server-side timeout — all render as the same
151
- * decline-shaped result to the agent; the daemon is never called.
152
- * - `"no-channel"`: the request itself failed even though the client declared the capability (it
153
- * rejected `elicitation/create` as unsupported despite advertising it, a transport error, ...).
154
- * This is deliberately **not** treated as approval — the caller falls through with no consent
155
- * marker, which yields the daemon's existing `policy_denied`/`no_consent_channel` fail-closed
156
- * path, exactly as if no channel had ever been offered.
157
- */
158
- const requestElicitationConsent = async (server, tool, args,
159
- // Injectable so tests can exercise the timeout branch without an actual 10-minute wait; every
160
- // production caller passes `ELICITATION_TIMEOUT_MS` (via `CreateMcpServerOptions.elicitationTimeoutMs`).
161
- timeoutMs) => {
162
- try {
163
- const result = await server.elicitInput({
164
- message: buildElicitationMessage(tool, args),
165
- // The accept/decline/cancel action itself is the consent signal — nothing is actually being
166
- // collected, so the schema asks for no fields. An empty `properties` record is a valid
167
- // restricted-JSON-Schema object per the MCP spec (the SDK's request schema requires the key
168
- // but not that it be non-empty), and renders as a plain confirm/decline prompt.
169
- requestedSchema: { type: "object", properties: {} },
170
- }, { timeout: timeoutMs });
171
- if (result.action === "accept") {
172
- return { type: "accepted" };
173
- }
174
- return {
175
- type: "declined",
176
- message: `The user ${result.action === "cancel" ? "cancelled" : "declined"} the request to call "${tool.descriptor.name}" on session "${tool.selector}".`,
177
- };
178
- }
179
- catch (error) {
180
- if (error instanceof McpError && error.code === ErrorCode.RequestTimeout) {
181
- return {
182
- type: "declined",
183
- message: `Timed out after ${timeoutMs / 1000}s waiting for a human to respond to the consent prompt for "${tool.descriptor.name}" on session "${tool.selector}".`,
184
- };
185
- }
186
- // The client declared the `elicitation` capability at `initialize` but the request itself
187
- // failed anyway (rejected as unsupported, transport error, ...). Never treat a failure as
188
- // approval.
189
- return { type: "no-channel" };
190
- }
191
- };
192
- const toolSuccessContent = (result) => {
193
- const content = [{ type: "text", text: JSON.stringify(result) }];
194
- if (isPlainObject(result)) {
195
- return { content, structuredContent: result };
196
- }
197
- return { content };
198
- };
199
- /** Errors from a tool call — proxied device tool or built-in management tool alike — become MCP
200
- * tool-error *content*, never a thrown protocol-level error: the preserved
201
- * `type` and `message` are put in the text so an agent reading the result can branch on them. */
202
- const toolErrorContent = (type, message, details) => {
203
- const suffix = details !== undefined ? ` ${JSON.stringify(details)}` : "";
204
- return {
205
- isError: true,
206
- content: [{ type: "text", text: `${type}: ${message}${suffix}` }],
207
- };
208
- };
209
- const toolErrorContentFromError = (error) => {
210
- if (error instanceof DaemonRpcError && error.data?.type) {
211
- return toolErrorContent(error.data.type, error.message, error.data.details);
212
- }
213
- if (error instanceof McpBuiltinToolError) {
214
- return toolErrorContent(error.type, error.message);
215
- }
216
- // A human declined/cancelled an elicitation prompt, or it timed out (ARCHITECTURE.md §12 /
217
- // issue #10) — the daemon was never called for this attempt.
218
- if (error instanceof ElicitationDeclinedError) {
219
- return toolErrorContent("consent_declined", error.message);
220
- }
221
- if (error instanceof Error) {
222
- return toolErrorContent("tool_execution_error", error.message);
223
- }
224
- return toolErrorContent("tool_execution_error", "An unexpected error occurred.");
225
- };
226
- /**
227
- * The success path for a *proxied* device tool (issue #26). An MCP client requires
228
- * `structuredContent` on every successful call to a tool whose `tools/list` entry carried an
229
- * `outputSchema`, so this shares `emitsMcpOutputSchema` with the mapping layer: the two decisions
230
- * are the same predicate and cannot drift. When a schema *was* advertised and the result is not an
231
- * object anyway (reachable only when the schema's validator is looser than its declared shape,
232
- * `tool-invocation.ts`), the call fails as `tool_output_validation_error` rather than as an opaque
233
- * client-side protocol error. A tool whose schema was dropped, or that has none, keeps the
234
- * opportunistic behaviour: text content always, plus `structuredContent` when the result happens
235
- * to be an object — which is allowed, since the client has no schema to validate it against.
236
- */
237
- const proxiedToolResultContent = (tool, result) => {
238
- if (!emitsMcpOutputSchema(tool.descriptor.output_schema)) {
239
- return toolSuccessContent(result);
240
- }
241
- if (!isPlainObject(result)) {
242
- return toolErrorContent("tool_output_validation_error", `Tool "${tool.mcpName}" declares an object output schema but returned ${describeJsonValue(result)}.`);
243
- }
244
- return toolSuccessContent(result);
245
- };
246
- export const createMcpServer = async (options) => {
247
- let stream;
248
- try {
249
- stream = await openDaemonStream({
250
- stateDir: options.stateDir,
251
- spawn: options.spawn,
252
- checkVersion: options.checkVersion,
253
- });
254
- }
255
- catch (error) {
256
- if (error instanceof DaemonVersionMismatchError) {
257
- // An MCP client renders a startup failure as a bare "server failed to start" with no cause,
258
- // and the operator never sees the thrown message. This stderr line — which the client's own
259
- // logs keep — is the only place the actual diagnosis reaches a human (ARCHITECTURE.md §4).
260
- console.error(`appduct mcp: refusing to start. ${error.message}`);
261
- }
262
- throw error;
263
- }
264
- const server = new Server({ name: "appduct", version: packageVersion }, { capabilities: { tools: { listChanged: true }, resources: {} } });
265
- // The `mcpName`s this connection's *most recent* `tools/list` response actually emitted
266
- // `_meta["anthropic/requiresUserInteraction"]` for — repopulated on every `tools/list` request,
267
- // never on the internal `list_changed` refresh below (which doesn't answer a client request, so
268
- // nothing was shown to a human). `resolveToolCallConsent` requires membership here in addition to
269
- // recomputing the tool's live policy and the client check, so `consent: "client"` reflects an
270
- // MCP `Tool` the client actually listed with the flag set on *this* connection, not merely a
271
- // client that happens to qualify version-wise (ARCHITECTURE.md §12 / issue #14). Stays empty for
272
- // any connection where elicitation is preferred (issue #10) — see the `tools/list` handler below.
273
- const emittedRequiresUserInteraction = new Set();
274
- /**
275
- * Resolves the `consent` param for one `"prompt"`-policy `tools.call` (ARCHITECTURE.md §12),
276
- * shared by both call paths below so the two channels' logic exists exactly once. Recomputes the
277
- * tool's live policy's implications at call time — never trusts anything cached from a prior
278
- * `tools/list` snapshot except membership in `emittedRequiresUserInteraction` above, which by
279
- * construction can only be true for *this* connection's most recent listing.
280
- *
281
- * Channel preference (never both armed for one call): elicitation (issue #10) whenever the
282
- * client declared the capability, else the flag-based gate (issue #14) as a fallback. An
283
- * elicitation decline/cancel/timeout throws `ElicitationDeclinedError` — the caller must not
284
- * catch it, since it belongs in the outer `tools/call` handler's result path, not lumped in with
285
- * "no consent obtained".
286
- */
287
- const resolveToolCallConsent = async (tool, args) => {
288
- if (tool.policy !== "prompt") {
289
- return undefined;
290
- }
291
- if (clientSupportsElicitation(server)) {
292
- const outcome = await requestElicitationConsent(server, tool, args, options.elicitationTimeoutMs ?? ELICITATION_TIMEOUT_MS);
293
- if (outcome.type === "accepted") {
294
- return "elicitation";
295
- }
296
- if (outcome.type === "declined") {
297
- throw new ElicitationDeclinedError(outcome.message);
298
- }
299
- // "no-channel": never fabricate approval from a failed request — fall through with no
300
- // consent so the daemon's own "prompt" gate denies the call exactly as it would for any
301
- // other ungated caller (ARCHITECTURE.md §12).
302
- return undefined;
303
- }
304
- return clientHonorsRequiresUserInteraction(server) && emittedRequiresUserInteraction.has(tool.mcpName)
305
- ? "client"
306
- : undefined;
307
- };
308
- // One mapper per server: it owns the dedup for the "this schema had to be degraded" stderr
309
- // notices, which would otherwise repeat on every `tools/list` and every list-changed refresh.
310
- const mapToMcpTool = createMcpToolMapper();
311
- const callProxiedTool = async (tool, args, progressToken, sendNotification,
312
- // The MCP SDK aborts this automatically on an inbound `notifications/cancelled` for this
313
- // request (ARCHITECTURE.md §9). Only honored on the progress-tracked path below, which is the
314
- // only one that learns `callId` while the call is still in flight — a non-progress call has no
315
- // `callId` to cancel by until it has already resolved.
316
- signal) => {
317
- const consent = await resolveToolCallConsent(tool, args);
318
- // The deadline this call runs under, resolved once from the `tools.list` snapshot it was
319
- // matched against, and sent explicitly rather than left to the daemon's `?? tool.timeout_ms`
320
- // fallback. `clampTimeout` folds in the 10 s default for a tool that declares nothing, so
321
- // there is exactly one number here and the watchdog below can never be sized off a different
322
- // read: the daemon consults its live registry, which a re-registration between that snapshot
323
- // and this call could have moved, and a watchdog built for the older value would fire first
324
- // and mask the daemon's real `tool_timeout` (issue #25).
325
- const timeoutMs = clampTimeout(tool.descriptor.timeout_ms);
326
- const transportTimeoutMs = deriveCallTransportTimeoutMs(timeoutMs);
327
- if (progressToken === undefined) {
328
- const result = await stream.call(RPC_METHODS.toolsCall, {
329
- selector: tool.selector,
330
- name: tool.descriptor.name,
331
- args,
332
- caller: "mcp",
333
- timeoutMs,
334
- ...(consent ? { consent } : {}),
335
- }, transportTimeoutMs);
336
- return result.result;
337
- }
338
- // A dedicated connection carries only this one in-flight `tools.call`, so the first
339
- // `tool_call_started` it sees for this tool name is unambiguously this call — the daemon only
340
- // reveals `callId` once the call is already in flight (ARCHITECTURE.md §5's `ToolsCallResult`
341
- // doc comment), so this is the only way to correlate it before the call finishes.
342
- const progressStream = await openDaemonStream({ stateDir: options.stateDir, spawn: options.spawn });
343
- try {
344
- await progressStream.call(RPC_METHODS.eventsSubscribe, {
345
- sessionSelector: tool.selector,
346
- kinds: ["tool_call_started", "tool_call_progress"],
347
- });
348
- let callId;
349
- let cancelRequested = signal.aborted;
350
- const maybeCancel = () => {
351
- if (callId === undefined || !cancelRequested) {
352
- return;
353
- }
354
- progressStream
355
- .call(RPC_METHODS.toolsCancel, { selector: tool.selector, callId, reason: "mcp_client_cancelled" })
356
- .catch((error) => {
357
- console.error("appduct mcp: failed to cancel a proxied tool call:", error);
358
- });
359
- };
360
- const onAbort = () => {
361
- cancelRequested = true;
362
- maybeCancel();
363
- };
364
- signal.addEventListener("abort", onAbort);
365
- const unsubscribe = progressStream.onNotification((payload) => {
366
- const event = payload;
367
- if (event.kind === "tool_call_started" && callId === undefined) {
368
- const data = event.data;
369
- if (data.name === tool.descriptor.name) {
370
- callId = data.callId;
371
- maybeCancel();
372
- }
373
- return;
374
- }
375
- if (event.kind === "tool_call_progress" && callId !== undefined) {
376
- const data = event.data;
377
- if (data.callId === callId) {
378
- sendNotification({
379
- method: "notifications/progress",
380
- params: { progressToken, progress: data.progress ?? 0, message: data.message },
381
- }).catch((error) => {
382
- console.error("appduct mcp: failed to send a progress notification:", error);
383
- });
384
- }
385
- }
386
- });
387
- try {
388
- const result = await progressStream.call(RPC_METHODS.toolsCall, {
389
- selector: tool.selector,
390
- name: tool.descriptor.name,
391
- args,
392
- caller: "mcp",
393
- timeoutMs,
394
- ...(consent ? { consent } : {}),
395
- }, transportTimeoutMs);
396
- return result.result;
397
- }
398
- finally {
399
- unsubscribe();
400
- signal.removeEventListener("abort", onAbort);
401
- }
402
- }
403
- finally {
404
- progressStream.close();
405
- }
406
- };
407
- server.setRequestHandler(ListToolsRequestSchema, async () => {
408
- const tools = await fetchEffectiveTools(stream.call);
409
- // Channel preference (ARCHITECTURE.md §12 / issues #10 & #14): elicitation always wins over the
410
- // flag-based gate when the client declared it, so a "prompt" tool never arms two consent UIs
411
- // for one call. When elicitation is preferred, the flag is suppressed entirely — this listing
412
- // never sets `_meta["anthropic/requiresUserInteraction"]`, `emittedRequiresUserInteraction`
413
- // stays empty, and `resolveToolCallConsent` above takes the elicitation branch at call time
414
- // instead.
415
- const emitRequiresUserInteractionFlag = !clientSupportsElicitation(server) && clientHonorsRequiresUserInteraction(server);
416
- emittedRequiresUserInteraction.clear();
417
- for (const tool of tools) {
418
- if (tool.policy === "prompt" && emitRequiresUserInteractionFlag) {
419
- emittedRequiresUserInteraction.add(tool.mcpName);
420
- }
421
- }
422
- return {
423
- tools: [
424
- CONNECT_TOOL_DESCRIPTOR,
425
- WAIT_FOR_SESSION_TOOL_DESCRIPTOR,
426
- EVENTS_TOOL_DESCRIPTOR,
427
- WAIT_FOR_EVENT_TOOL_DESCRIPTOR,
428
- ...tools.map((tool) => mapToMcpTool(tool, emitRequiresUserInteractionFlag)),
429
- ],
430
- };
431
- });
432
- server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
433
- const name = request.params.name;
434
- const args = (request.params.arguments ?? {});
435
- const progressToken = extra._meta?.progressToken;
436
- try {
437
- if (name === CONNECT_TOOL_NAME) {
438
- return toolSuccessContent(await handleConnectTool(args, {
439
- call: stream.call,
440
- scheme: options.scheme,
441
- schemeTried: options.schemeTried,
442
- iosBundleId: options.iosBundleId,
443
- exec: options.exec,
444
- env: options.env,
445
- }));
446
- }
447
- if (name === WAIT_FOR_SESSION_TOOL_NAME) {
448
- return toolSuccessContent(await handleWaitForSessionTool(args, { stateDir: options.stateDir, spawn: options.spawn }));
449
- }
450
- if (name === EVENTS_TOOL_NAME) {
451
- return toolSuccessContent(await handleEventsTool(args, { call: stream.call }));
452
- }
453
- if (name === WAIT_FOR_EVENT_TOOL_NAME) {
454
- return toolSuccessContent(await handleWaitForEventTool(args, {
455
- stateDir: options.stateDir,
456
- spawn: options.spawn,
457
- progress: progressToken !== undefined
458
- ? { token: progressToken, sendNotification: extra.sendNotification }
459
- : undefined,
460
- }));
461
- }
462
- const tools = await fetchEffectiveTools(stream.call);
463
- const tool = findNamespacedTool(tools, name);
464
- if (!tool) {
465
- return toolErrorContent("tool_not_found", `Tool "${name}" is not registered.`);
466
- }
467
- return proxiedToolResultContent(tool, await callProxiedTool(tool, args, progressToken, extra.sendNotification, extra.signal));
468
- }
469
- catch (error) {
470
- return toolErrorContentFromError(error);
471
- }
472
- });
473
- server.setRequestHandler(ListResourcesRequestSchema, async () => {
474
- return {
475
- resources: [
476
- {
477
- uri: SESSIONS_RESOURCE_URI,
478
- name: "sessions",
479
- description: "Live Appduct device sessions (sessions.list passthrough), as JSON.",
480
- mimeType: "application/json",
481
- },
482
- ],
483
- };
484
- });
485
- server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
486
- if (request.params.uri !== SESSIONS_RESOURCE_URI) {
487
- throw new Error(`Unknown resource "${request.params.uri}".`);
488
- }
489
- const sessions = await stream.call(RPC_METHODS.sessionsList);
490
- return {
491
- contents: [{ uri: SESSIONS_RESOURCE_URI, mimeType: "application/json", text: JSON.stringify(sessions) }],
492
- };
493
- });
494
- // --- notifications/tools/list_changed ---
495
- let lastSnapshotKey;
496
- let closed = false;
497
- const refreshAndMaybeNotifyListChanged = async () => {
498
- const tools = await fetchEffectiveTools(stream.call);
499
- if (closed) {
500
- return;
501
- }
502
- const key = namespacedToolsSnapshotKey(tools);
503
- const changed = lastSnapshotKey !== undefined && key !== lastSnapshotKey;
504
- lastSnapshotKey = key;
505
- if (changed) {
506
- await server.sendToolListChanged();
507
- }
508
- };
509
- const unsubscribeFromDaemonEvents = stream.onNotification((payload) => {
510
- const event = payload;
511
- if (LIST_CHANGE_EVENT_KINDS.includes(event.kind)) {
512
- refreshAndMaybeNotifyListChanged().catch((error) => {
513
- if (closed) {
514
- // Expected: the shared connection closed (server shutting down) while a refresh
515
- // triggered by the last few daemon events was still in flight.
516
- return;
517
- }
518
- // A failed refresh/notify must never crash the process — the next qualifying event (or the
519
- // client's own next `tools/list`) will simply see the current state instead.
520
- console.error("appduct mcp: failed to refresh the tool list after a daemon event:", error);
521
- });
522
- }
523
- });
524
- await stream.call(RPC_METHODS.eventsSubscribe, { kinds: LIST_CHANGE_EVENT_KINDS });
525
- // Establish the baseline before any daemon event can race a real change in — the guard above
526
- // (`lastSnapshotKey !== undefined`) means this first call only seeds state, never notifies.
527
- await refreshAndMaybeNotifyListChanged();
528
- const close = async () => {
529
- closed = true;
530
- unsubscribeFromDaemonEvents();
531
- stream.close();
532
- };
533
- return {
534
- server,
535
- connect: (transport) => server.connect(transport),
536
- close,
537
- };
538
- };
@@ -1,58 +0,0 @@
1
- /**
2
- * Maps a `NamespacedTool` (daemon `ToolDescriptor` + resolved external name) onto the MCP SDK's
3
- * `Tool` wire shape (ARCHITECTURE.md §9/§7): `description` maps directly and `annotations` map
4
- * verbatim, while `input_schema`/`output_schema` are emitted only if the SDK's own `ToolSchema`
5
- * accepts them (issue #26) — a rejected `input_schema` is replaced with an empty, permissive
6
- * object schema and a rejected `output_schema` is dropped entirely. Either way the tool stays
7
- * listed and callable: one app-side schema MCP cannot represent must never take down the whole
8
- * `tools/list`.
9
- *
10
- * The gate is the SDK schema itself rather than a hand-rolled `type === "object"` check, because
11
- * MCP constrains more than the root type — `properties` must be a record of *object* subschemas
12
- * (the JSON Schema shorthand `properties: { a: true }` is rejected) and `required` must be an
13
- * array. Any of those makes a client reject the entire `tools/list` result, so the only safe
14
- * predicate is the one the client will actually apply.
15
- *
16
- * A tool whose effective policy is `"prompt"` (ARCHITECTURE.md §12) gets
17
- * `_meta["anthropic/requiresUserInteraction"] = true` — but only when the caller says to
18
- * (`emitRequiresUserInteractionFlag`), which `mcp/server.ts` decides is true only when the
19
- * connected client is known to enforce the flag (issue #14) *and* it hasn't already preferred the
20
- * elicitation channel instead (issue #10). Emitting the flag for a client that ignores it would
21
- * create a false sense of security; emitting it *and* using elicitation for the same tool would
22
- * arm two consent prompts for one call — so this function never decides that itself, only renders
23
- * the decision it's handed.
24
- */
25
- import type { ToolSchemaDescriptor } from "@appduct/shared";
26
- import type { NamespacedTool } from "./tool-namespace.js";
27
- export type McpToolSchema = {
28
- name: string;
29
- description: string;
30
- inputSchema: Record<string, unknown>;
31
- outputSchema?: Record<string, unknown>;
32
- annotations?: Record<string, unknown>;
33
- _meta?: Record<string, unknown>;
34
- };
35
- /**
36
- * Whether `tools/list` will carry an `outputSchema` for this descriptor. `server.ts`'s success
37
- * path shares this predicate so the "did we advertise a schema?" and "must this result carry
38
- * `structuredContent`?" decisions can never disagree — a client demands the latter exactly when
39
- * it saw the former.
40
- */
41
- export declare const emitsMcpOutputSchema: (schema: ToolSchemaDescriptor | undefined) => boolean;
42
- export type McpToolMapper = (tool: NamespacedTool, emitRequiresUserInteractionFlag: boolean) => McpToolSchema;
43
- /**
44
- * Builds a mapper with its own degradation-notice dedup, so nothing here is module state and no
45
- * reset hook has to be exported for tests: a server owns one mapper for its lifetime, and a test
46
- * owns one per case. `warn` defaults to stderr and exists so a test can capture notices without
47
- * spying on the console.
48
- *
49
- * Notices dedupe on session + tool name + the offending schema, because `tools/list` is
50
- * re-answered on every client request and on every `notifications/tools/list_changed` refresh. Two
51
- * devices exposing the same broken tool each get their own notice, and re-registering a tool with
52
- * a *differently* broken schema warns again; only the same problem on the same tool of the same
53
- * session stays quiet. The session's `selector` is the key rather than the namespaced `mcpName` so
54
- * the single↔multi session flip, which rewrites every `mcpName`, does not re-warn about tools
55
- * nothing changed about.
56
- */
57
- export declare const createMcpToolMapper: (warn?: (message: string) => void) => McpToolMapper;
58
- //# sourceMappingURL=tool-mapping.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"tool-mapping.d.ts","sourceRoot":"","sources":["../../src/mcp/tool-mapping.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAIH,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,iBAAiB,CAAC;AAE5D,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AAM1D,MAAM,MAAM,aAAa,GAAG;IAC1B,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACrC,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACvC,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACtC,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACjC,CAAC;AAiCF;;;;;GAKG;AACH,eAAO,MAAM,oBAAoB,GAAI,QAAQ,oBAAoB,GAAG,SAAS,KAAG,OAE/E,CAAC;AAeF,MAAM,MAAM,aAAa,GAAG,CAAC,IAAI,EAAE,cAAc,EAAE,+BAA+B,EAAE,OAAO,KAAK,aAAa,CAAC;AAE9G;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,mBAAmB,GAAI,OAAM,CAAC,OAAO,EAAE,MAAM,KAAK,IAAkB,KAAG,aAwFnF,CAAC"}