appduct 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +274 -0
- package/bin.js +2 -0
- package/dist/artifact-inspect.d.ts +110 -0
- package/dist/artifact-inspect.d.ts.map +1 -0
- package/dist/artifact-inspect.js +315 -0
- package/dist/bin.d.ts +3 -0
- package/dist/bin.d.ts.map +1 -0
- package/dist/bin.js +4 -0
- package/dist/cli/command-options.d.ts +44 -0
- package/dist/cli/command-options.d.ts.map +1 -0
- package/dist/cli/command-options.js +95 -0
- package/dist/cli/create-cli.d.ts +2 -0
- package/dist/cli/create-cli.d.ts.map +1 -0
- package/dist/cli/create-cli.js +56 -0
- package/dist/cli/dispatch.d.ts +3 -0
- package/dist/cli/dispatch.d.ts.map +1 -0
- package/dist/cli/dispatch.js +275 -0
- package/dist/cli/open-target.d.ts +112 -0
- package/dist/cli/open-target.d.ts.map +1 -0
- package/dist/cli/open-target.js +438 -0
- package/dist/cli/result-types.d.ts +194 -0
- package/dist/cli/result-types.d.ts.map +1 -0
- package/dist/cli/result-types.js +6 -0
- package/dist/cli/runner.d.ts +35 -0
- package/dist/cli/runner.d.ts.map +1 -0
- package/dist/cli/runner.js +111 -0
- package/dist/cli/types.d.ts +20 -0
- package/dist/cli/types.d.ts.map +1 -0
- package/dist/cli/types.js +10 -0
- package/dist/cli.d.ts +4 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +2 -0
- package/dist/client/app-client.d.ts +126 -0
- package/dist/client/app-client.d.ts.map +1 -0
- package/dist/client/app-client.js +223 -0
- package/dist/client/bootstrap.d.ts +48 -0
- package/dist/client/bootstrap.d.ts.map +1 -0
- package/dist/client/bootstrap.js +108 -0
- package/dist/client/connect.d.ts +17 -0
- package/dist/client/connect.d.ts.map +1 -0
- package/dist/client/connect.js +38 -0
- package/dist/client/errors.d.ts +26 -0
- package/dist/client/errors.d.ts.map +1 -0
- package/dist/client/errors.js +40 -0
- package/dist/client/index.d.ts +19 -0
- package/dist/client/index.d.ts.map +1 -0
- package/dist/client/index.js +16 -0
- package/dist/commands/daemon.d.ts +22 -0
- package/dist/commands/daemon.d.ts.map +1 -0
- package/dist/commands/daemon.js +128 -0
- package/dist/commands/doctor.d.ts +23 -0
- package/dist/commands/doctor.d.ts.map +1 -0
- package/dist/commands/doctor.js +42 -0
- package/dist/commands/events.d.ts +37 -0
- package/dist/commands/events.d.ts.map +1 -0
- package/dist/commands/events.js +76 -0
- package/dist/commands/init.d.ts +51 -0
- package/dist/commands/init.d.ts.map +1 -0
- package/dist/commands/init.js +231 -0
- package/dist/commands/invoke.d.ts +22 -0
- package/dist/commands/invoke.d.ts.map +1 -0
- package/dist/commands/invoke.js +54 -0
- package/dist/commands/keygen.d.ts +23 -0
- package/dist/commands/keygen.d.ts.map +1 -0
- package/dist/commands/keygen.js +46 -0
- package/dist/commands/link.d.ts +30 -0
- package/dist/commands/link.d.ts.map +1 -0
- package/dist/commands/link.js +43 -0
- package/dist/commands/ls.d.ts +9 -0
- package/dist/commands/ls.d.ts.map +1 -0
- package/dist/commands/ls.js +7 -0
- package/dist/commands/mcp.d.ts +49 -0
- package/dist/commands/mcp.d.ts.map +1 -0
- package/dist/commands/mcp.js +98 -0
- package/dist/commands/revoke.d.ts +12 -0
- package/dist/commands/revoke.d.ts.map +1 -0
- package/dist/commands/revoke.js +7 -0
- package/dist/commands/tools.d.ts +23 -0
- package/dist/commands/tools.d.ts.map +1 -0
- package/dist/commands/tools.js +52 -0
- package/dist/daemon/address.d.ts +30 -0
- package/dist/daemon/address.d.ts.map +1 -0
- package/dist/daemon/address.js +71 -0
- package/dist/daemon/audit.d.ts +107 -0
- package/dist/daemon/audit.d.ts.map +1 -0
- package/dist/daemon/audit.js +237 -0
- package/dist/daemon/calls.d.ts +84 -0
- package/dist/daemon/calls.d.ts.map +1 -0
- package/dist/daemon/calls.js +158 -0
- package/dist/daemon/config.d.ts +79 -0
- package/dist/daemon/config.d.ts.map +1 -0
- package/dist/daemon/config.js +170 -0
- package/dist/daemon/daemon.d.ts +43 -0
- package/dist/daemon/daemon.d.ts.map +1 -0
- package/dist/daemon/daemon.js +560 -0
- package/dist/daemon/event-bus.d.ts +49 -0
- package/dist/daemon/event-bus.d.ts.map +1 -0
- package/dist/daemon/event-bus.js +111 -0
- package/dist/daemon/links.d.ts +54 -0
- package/dist/daemon/links.d.ts.map +1 -0
- package/dist/daemon/links.js +118 -0
- package/dist/daemon/listener.d.ts +34 -0
- package/dist/daemon/listener.d.ts.map +1 -0
- package/dist/daemon/listener.js +132 -0
- package/dist/daemon/log-rotation.d.ts +82 -0
- package/dist/daemon/log-rotation.d.ts.map +1 -0
- package/dist/daemon/log-rotation.js +136 -0
- package/dist/daemon/pidfile.d.ts +38 -0
- package/dist/daemon/pidfile.d.ts.map +1 -0
- package/dist/daemon/pidfile.js +90 -0
- package/dist/daemon/policy.d.ts +22 -0
- package/dist/daemon/policy.d.ts.map +1 -0
- package/dist/daemon/policy.js +24 -0
- package/dist/daemon/registry.d.ts +19 -0
- package/dist/daemon/registry.d.ts.map +1 -0
- package/dist/daemon/registry.js +31 -0
- package/dist/daemon/rpc-server.d.ts +54 -0
- package/dist/daemon/rpc-server.d.ts.map +1 -0
- package/dist/daemon/rpc-server.js +203 -0
- package/dist/daemon/sessions.d.ts +86 -0
- package/dist/daemon/sessions.d.ts.map +1 -0
- package/dist/daemon/sessions.js +429 -0
- package/dist/daemon/socket-probe.d.ts +16 -0
- package/dist/daemon/socket-probe.d.ts.map +1 -0
- package/dist/daemon/socket-probe.js +28 -0
- package/dist/daemon/state-dir.d.ts +31 -0
- package/dist/daemon/state-dir.d.ts.map +1 -0
- package/dist/daemon/state-dir.js +43 -0
- package/dist/daemon/timers.d.ts +16 -0
- package/dist/daemon/timers.d.ts.map +1 -0
- package/dist/daemon/timers.js +12 -0
- package/dist/daemon/tls.d.ts +58 -0
- package/dist/daemon/tls.d.ts.map +1 -0
- package/dist/daemon/tls.js +79 -0
- package/dist/errors.d.ts +28 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +162 -0
- package/dist/host-certificate.d.ts +8 -0
- package/dist/host-certificate.d.ts.map +1 -0
- package/dist/host-certificate.js +111 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/key-material.d.ts +14 -0
- package/dist/key-material.d.ts.map +1 -0
- package/dist/key-material.js +42 -0
- package/dist/link.d.ts +76 -0
- package/dist/link.d.ts.map +1 -0
- package/dist/link.js +117 -0
- package/dist/mcp/connect-tool.d.ts +115 -0
- package/dist/mcp/connect-tool.d.ts.map +1 -0
- package/dist/mcp/connect-tool.js +396 -0
- package/dist/mcp/daemon-tools.d.ts +13 -0
- package/dist/mcp/daemon-tools.d.ts.map +1 -0
- package/dist/mcp/daemon-tools.js +29 -0
- package/dist/mcp/events-tool.d.ts +111 -0
- package/dist/mcp/events-tool.d.ts.map +1 -0
- package/dist/mcp/events-tool.js +322 -0
- package/dist/mcp/server.d.ts +58 -0
- package/dist/mcp/server.d.ts.map +1 -0
- package/dist/mcp/server.js +538 -0
- package/dist/mcp/tool-mapping.d.ts +58 -0
- package/dist/mcp/tool-mapping.d.ts.map +1 -0
- package/dist/mcp/tool-mapping.js +143 -0
- package/dist/mcp/tool-namespace.d.ts +40 -0
- package/dist/mcp/tool-namespace.d.ts.map +1 -0
- package/dist/mcp/tool-namespace.js +65 -0
- package/dist/native-scheme.d.ts +59 -0
- package/dist/native-scheme.d.ts.map +1 -0
- package/dist/native-scheme.js +465 -0
- package/dist/output.d.ts +28 -0
- package/dist/output.d.ts.map +1 -0
- package/dist/output.js +342 -0
- package/dist/package-version.d.ts +30 -0
- package/dist/package-version.d.ts.map +1 -0
- package/dist/package-version.js +47 -0
- package/dist/qr-terminal.d.ts +13 -0
- package/dist/qr-terminal.d.ts.map +1 -0
- package/dist/qr-terminal.js +45 -0
- package/dist/rpc/client.d.ts +137 -0
- package/dist/rpc/client.d.ts.map +1 -0
- package/dist/rpc/client.js +819 -0
- package/dist/scheme.d.ts +181 -0
- package/dist/scheme.d.ts.map +1 -0
- package/dist/scheme.js +321 -0
- package/dist/spki-pin.d.ts +3 -0
- package/dist/spki-pin.d.ts.map +1 -0
- package/dist/spki-pin.js +14 -0
- package/package.json +65 -0
|
@@ -0,0 +1,538 @@
|
|
|
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
|
+
};
|
|
@@ -0,0 +1,58 @@
|
|
|
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
|
|
@@ -0,0 +1 @@
|
|
|
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"}
|