@jini-ai/mcp 0.3.4 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +156 -0
- package/README.md +97 -22
- package/dist/agent-install/install.d.ts +20 -4
- package/dist/agent-install/install.js +11 -11
- package/dist/bin/serve.d.ts +25 -13
- package/dist/bin/serve.js +42 -21
- package/dist/client/client.d.ts +28 -8
- package/dist/client/client.js +25 -21
- package/dist/core/config.d.ts +57 -18
- package/dist/core/config.js +76 -48
- package/dist/core/index.d.ts +1 -2
- package/dist/core/index.js +1 -2
- package/dist/core/install-info.d.ts +4 -1
- package/dist/core/install-info.js +5 -5
- package/dist/core/tokens.d.ts +39 -13
- package/dist/core/tokens.js +54 -24
- package/dist/federation/adapter.http.d.ts +67 -0
- package/dist/federation/adapter.http.js +414 -0
- package/dist/federation/approvals/call-confirmation.d.ts +23 -0
- package/dist/federation/approvals/call-confirmation.js +124 -0
- package/dist/federation/approvals/index.d.ts +4 -0
- package/dist/federation/approvals/index.js +4 -0
- package/dist/federation/approvals/ports.d.ts +107 -0
- package/dist/federation/approvals/ports.js +2 -0
- package/dist/federation/approvals/revocation.d.ts +61 -0
- package/dist/federation/approvals/revocation.js +52 -0
- package/dist/federation/bootstrap.d.ts +100 -0
- package/dist/federation/bootstrap.js +182 -0
- package/dist/federation/config.d.ts +68 -0
- package/dist/federation/config.js +40 -0
- package/dist/federation/index.d.ts +14 -0
- package/dist/federation/index.js +14 -0
- package/dist/federation/mcp-protocol.d.ts +115 -0
- package/dist/federation/mcp-protocol.js +151 -0
- package/dist/federation/messages.d.ts +54 -0
- package/dist/federation/messages.js +35 -0
- package/dist/federation/ports.d.ts +287 -0
- package/dist/federation/ports.js +5 -0
- package/dist/federation/presets.d.ts +66 -0
- package/dist/federation/presets.js +30 -0
- package/dist/federation/refusal-notice.d.ts +198 -0
- package/dist/federation/refusal-notice.js +193 -0
- package/dist/federation/registrations.d.ts +171 -0
- package/dist/federation/registrations.js +181 -0
- package/dist/federation/reload.d.ts +112 -0
- package/dist/federation/reload.js +110 -0
- package/dist/federation/stdio/adapter.stdio.d.ts +157 -0
- package/dist/federation/stdio/adapter.stdio.js +526 -0
- package/dist/federation/stdio/default-connect.d.ts +29 -0
- package/dist/federation/stdio/default-connect.js +92 -0
- package/dist/federation/stdio/index.d.ts +4 -0
- package/dist/federation/stdio/index.js +4 -0
- package/dist/federation/stdio/stdio-launch-resolver.d.ts +139 -0
- package/dist/federation/stdio/stdio-launch-resolver.js +227 -0
- package/dist/federation/testing/adapter.memory.d.ts +177 -0
- package/dist/federation/testing/adapter.memory.js +189 -0
- package/dist/federation/testing/index.d.ts +3 -0
- package/dist/federation/testing/index.js +3 -0
- package/dist/federation/testing/tool-approvals.memory.d.ts +27 -0
- package/dist/federation/testing/tool-approvals.memory.js +38 -0
- package/dist/federation/tool-approvals.d.ts +97 -0
- package/dist/federation/tool-approvals.js +42 -0
- package/dist/federation/trust.d.ts +471 -0
- package/dist/federation/trust.js +707 -0
- package/dist/index.d.ts +175 -2
- package/dist/index.js +172 -2
- package/dist/server/daemon-client.d.ts +16 -4
- package/dist/server/daemon-client.js +41 -19
- package/dist/server/ports.d.ts +9 -0
- package/dist/server/ports.js +2 -0
- package/dist/server/resource-protocol.d.ts +14 -4
- package/dist/server/resource-protocol.js +6 -6
- package/dist/server/resources/active-resource.js +3 -3
- package/dist/server/sdk-transport.d.ts +11 -0
- package/dist/server/sdk-transport.js +31 -0
- package/dist/server/tool-protocol.d.ts +31 -9
- package/dist/server/tool-protocol.js +20 -18
- package/dist/server/tool-server.d.ts +60 -43
- package/dist/server/tool-server.js +51 -27
- package/dist/server/tools/component-catalog-tools.js +7 -7
- package/dist/server/tools/delegated-tool.d.ts +7 -4
- package/dist/server/tools/delegated-tool.js +18 -16
- package/dist/server/tools/run-tools.js +15 -15
- package/dist/server/tools/tool-catalog-tools.js +7 -7
- package/dist/tools/ask-choice/index.d.ts +123 -0
- package/dist/tools/ask-choice/index.js +98 -0
- package/dist/tools/ask-choice/pending.d.ts +11 -0
- package/dist/tools/ask-choice/pending.js +34 -0
- package/dist/tools/ask-choice/question.d.ts +16 -0
- package/dist/tools/ask-choice/question.js +60 -0
- package/dist/tools/ask-choice/schema.d.ts +91 -0
- package/dist/tools/ask-choice/schema.js +27 -0
- package/dist/tools/ask-choice/types.d.ts +106 -0
- package/dist/tools/ask-choice/types.js +2 -0
- package/package.json +43 -5
- package/dist/core/oauth.d.ts +0 -274
- package/dist/core/oauth.js +0 -667
- package/dist/core/secure-write.d.ts +0 -17
- package/dist/core/secure-write.js +0 -72
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { FederationMessages } from "./messages.js";
|
|
2
|
+
import type { McpHttpExchange, McpHttpLaunchSpec, McpSessionPort, McpClientInfo } from "./ports.js";
|
|
3
|
+
/**
|
|
4
|
+
* A connected MCP client session against one hosted server.
|
|
5
|
+
*
|
|
6
|
+
* Constructed by {@link connectMcpHttpSession} rather than directly, so a session that exists is
|
|
7
|
+
* always one whose handshake completed — mirroring `adapter.stdio.ts`, and for the same reason:
|
|
8
|
+
* there is no half-initialized state a caller could accidentally use.
|
|
9
|
+
*/
|
|
10
|
+
/** Supplies a current token per exchange. Undefined preserves configured headers. */
|
|
11
|
+
export type McpBearerTokenSupplier = (request: {
|
|
12
|
+
readonly url: string;
|
|
13
|
+
}, options?: {
|
|
14
|
+
readonly signal?: AbortSignal | undefined;
|
|
15
|
+
}) => string | undefined | Promise<string | undefined>;
|
|
16
|
+
/** Notifies the host of a 401, including its authentication challenge. No OAuth flow or retry here. */
|
|
17
|
+
export type McpAuthenticationChallengeHook = (request: {
|
|
18
|
+
readonly url: string;
|
|
19
|
+
readonly status: 401;
|
|
20
|
+
readonly wwwAuthenticate: string | undefined;
|
|
21
|
+
}, options?: {
|
|
22
|
+
readonly signal?: AbortSignal | undefined;
|
|
23
|
+
}) => void | Promise<void>;
|
|
24
|
+
export interface McpHttpSessionDependencies {
|
|
25
|
+
readonly messages: FederationMessages;
|
|
26
|
+
readonly exchange: McpHttpExchange;
|
|
27
|
+
readonly spec: McpHttpLaunchSpec;
|
|
28
|
+
readonly requestTimeoutMs: number;
|
|
29
|
+
readonly clientInfo: McpClientInfo;
|
|
30
|
+
readonly bearerToken: McpBearerTokenSupplier;
|
|
31
|
+
readonly onAuthenticationChallenge?: McpAuthenticationChallengeHook | undefined;
|
|
32
|
+
/** Observer failures use the host's logger; diagnostics never replace an authentication error. */
|
|
33
|
+
readonly logger?: {
|
|
34
|
+
warn(required: {
|
|
35
|
+
message: string;
|
|
36
|
+
}): void;
|
|
37
|
+
} | undefined;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Performs the MCP handshake against a hosted server and returns the connected session.
|
|
41
|
+
*
|
|
42
|
+
* @param deps.exchange - The transport seam. {@link createFetchMcpHttpExchange} in production, a
|
|
43
|
+
* scripted double in tests.
|
|
44
|
+
* @param deps.spec - Endpoint and authenticating headers.
|
|
45
|
+
* @param deps.requestTimeoutMs - Per-request ceiling, applied to the handshake too.
|
|
46
|
+
* @throws {McpProtocolError} If `initialize` fails, times out, or the server refuses it.
|
|
47
|
+
* @complexity O(1) beyond the remote's round-trip.
|
|
48
|
+
*/
|
|
49
|
+
export declare function connectMcpHttpSession(deps: Omit<McpHttpSessionDependencies, "onAuthenticationChallenge" | "logger">, options?: Pick<McpHttpSessionDependencies, "onAuthenticationChallenge" | "logger">): Promise<McpSessionPort>;
|
|
50
|
+
/**
|
|
51
|
+
* The production {@link McpHttpExchange}: one `fetch` per exchange.
|
|
52
|
+
*
|
|
53
|
+
* Deliberately logic-free — the same discipline as `spawnMcpStdioChannel`. Everything above it is
|
|
54
|
+
* already under test through the exchange seam, and code that only runs when a real network exists
|
|
55
|
+
* is code that only fails in production.
|
|
56
|
+
*
|
|
57
|
+
* `redirect: "error"` is the security-relevant choice, and matches `src/platform/oauth/token-endpoint.ts`:
|
|
58
|
+
* these requests carry a bearer token in a header, and a followed cross-origin redirect would
|
|
59
|
+
* re-send that header to whatever host the server nominated. Refusing to follow means a redirect is
|
|
60
|
+
* a visible failure rather than a silent credential disclosure.
|
|
61
|
+
*
|
|
62
|
+
* @complexity O(n) in the response body length.
|
|
63
|
+
*/
|
|
64
|
+
export declare function createFetchMcpHttpExchange({ fetch: fetchImpl }: {
|
|
65
|
+
fetch: typeof fetch;
|
|
66
|
+
}): McpHttpExchange;
|
|
67
|
+
//# sourceMappingURL=adapter.http.d.ts.map
|
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
import { buildInitializeParams, CLIENT_PROTOCOL_VERSION, drainToolsList, McpAuthFailedError, McpProtocolError, parseCallToolResult, parseInitializeResult, } from "./mcp-protocol.js";
|
|
2
|
+
/**
|
|
3
|
+
* @file The second real `McpSessionPort` adapter: an MCP client speaking JSON-RPC 2.0 over MCP's
|
|
4
|
+
* Streamable HTTP transport, which is how every HOSTED MCP server is reached.
|
|
5
|
+
*
|
|
6
|
+
* Why this exists, given `adapter.stdio.ts` already federates:
|
|
7
|
+
* stdio requires a program on the operator's own disk. A hosted server — Higgsfield, Supabase's
|
|
8
|
+
* `https://mcp.supabase.com/mcp`, anything SaaS — has no such program, and the only way to reach it
|
|
9
|
+
* is an authenticated HTTPS endpoint. `external-mcp-server-federation.md` deferred this transport
|
|
10
|
+
* with a specific, now-expired reason: "OAuth needs an interactive browser consent flow and a token
|
|
11
|
+
* store, neither of which host app has". `src/platform/oauth/` and `assistant/external-mcp-oauth.ts` are both of
|
|
12
|
+
* those, so the blocker is gone and, exactly as that doc predicted, this lands as an added adapter
|
|
13
|
+
* rather than a redesign.
|
|
14
|
+
*
|
|
15
|
+
* How much of Streamable HTTP is implemented, and what is deliberately left out:
|
|
16
|
+
* the client-initiated half — POST a request, read the response, carry `Mcp-Session-Id` forward. A
|
|
17
|
+
* server may answer either with `application/json` (one response) or `text/event-stream` (the same
|
|
18
|
+
* response, SSE-framed); both are handled, because which one a server picks is its choice and not
|
|
19
|
+
* something an operator configures.
|
|
20
|
+
*
|
|
21
|
+
* NOT implemented, on purpose: the standalone `GET` stream that lets a server push requests to the
|
|
22
|
+
* client unprompted. That channel exists to carry server-to-client calls — `sampling/createMessage`
|
|
23
|
+
* above all, a remote asking host app to run model inference on its behalf. `adapter.stdio.ts` answers
|
|
24
|
+
* those with "method not found"; here the stronger move is available, so this client simply never
|
|
25
|
+
* opens the channel they would arrive on. host app advertises no capabilities in `initialize`, so there
|
|
26
|
+
* is nothing a server may legitimately push. Resumability (`Last-Event-ID`) goes with it: this
|
|
27
|
+
* client makes bounded request/response calls, and `trust.ts` R5 freezes the admitted tool set at
|
|
28
|
+
* connect, so there is no long-lived stream whose position would need restoring.
|
|
29
|
+
*
|
|
30
|
+
* Hostile-server posture (this file's share; `trust.ts` owns the rest):
|
|
31
|
+
* every request is timeout-bounded by an `AbortSignal` the caller cannot lose, a response body is
|
|
32
|
+
* length-bounded before it is parsed, a non-2xx is a typed error rather than a parse attempt, and
|
|
33
|
+
* the session id a server hands back is validated before it is echoed into any later request
|
|
34
|
+
* header — an unvalidated one is a header-injection primitive handed to the remote.
|
|
35
|
+
*
|
|
36
|
+
* Architectural role:
|
|
37
|
+
* Infrastructure adapter, co-located with its port. Protocol logic it shares with the stdio adapter
|
|
38
|
+
* lives in `mcp-protocol.ts`; what is here is HTTP, and only HTTP.
|
|
39
|
+
*/
|
|
40
|
+
/** Bound on one response body, matching `adapter.stdio.ts`'s per-message cap. A server that streams
|
|
41
|
+
* an unbounded body would otherwise grow this process's heap without limit. `trust.ts` caps what
|
|
42
|
+
* reaches a MODEL, but that runs after parsing, so parsing needs its own bound. */
|
|
43
|
+
const MAX_RESPONSE_BYTES = 4 * 1024 * 1024;
|
|
44
|
+
/**
|
|
45
|
+
* What a server's `Mcp-Session-Id` may contain before this client will echo it back.
|
|
46
|
+
*
|
|
47
|
+
* The spec says visible ASCII (0x21..0x7E). Enforced rather than trusted because this value goes
|
|
48
|
+
* straight into an outbound request header: a server answering with a session id containing CR or
|
|
49
|
+
* LF would, against a naive HTTP client, be able to inject additional headers into every subsequent
|
|
50
|
+
* request host app makes to it. Bounded in length for the same reason a body is.
|
|
51
|
+
*/
|
|
52
|
+
const SESSION_ID_PATTERN = /^[\x21-\x7e]{1,512}$/;
|
|
53
|
+
/** MCP requires a client to accept both response modes on every request, and the server picks. */
|
|
54
|
+
const ACCEPT_BOTH = "application/json, text/event-stream";
|
|
55
|
+
/** Wires `controller` to also abort when `callerSignal` does — including firing immediately if
|
|
56
|
+
* `callerSignal` is already aborted by the time this runs. Returns the listener so the caller can
|
|
57
|
+
* remove it again once the request settles. Split out of
|
|
58
|
+
* {@link McpHttpSession.postWithTimeout} purely to keep that method under the shop complexity
|
|
59
|
+
* ceiling; behavior is unchanged. */
|
|
60
|
+
function forwardAbort(controller, callerSignal) {
|
|
61
|
+
const onCallerAbort = () => controller.abort();
|
|
62
|
+
callerSignal?.addEventListener("abort", onCallerAbort, { once: true });
|
|
63
|
+
if (callerSignal?.aborted)
|
|
64
|
+
controller.abort();
|
|
65
|
+
return onCallerAbort;
|
|
66
|
+
}
|
|
67
|
+
/** Builds the right {@link McpProtocolError} for a failed POST — distinguishing "the caller
|
|
68
|
+
* cancelled" from "the request timed out" from "the network/transport itself failed". Split out
|
|
69
|
+
* of {@link McpHttpSession.postWithTimeout} purely to keep that method under the shop complexity
|
|
70
|
+
* ceiling; behavior (including the exact message text) is unchanged. */
|
|
71
|
+
function buildPostFailureError(params) {
|
|
72
|
+
if (params.timedOut) {
|
|
73
|
+
return new McpProtocolError({ message: params.callerAborted ? "mcp-federation: the request was aborted" : `mcp-federation: the request timed out after ${params.requestTimeoutMs}ms` });
|
|
74
|
+
}
|
|
75
|
+
return new McpProtocolError({ message: `mcp-federation: the request failed — ${params.error instanceof Error ? params.error.message : String(params.error)}` });
|
|
76
|
+
}
|
|
77
|
+
class McpHttpSession {
|
|
78
|
+
messages;
|
|
79
|
+
exchange;
|
|
80
|
+
spec;
|
|
81
|
+
requestTimeoutMs;
|
|
82
|
+
clientInfo;
|
|
83
|
+
bearerToken;
|
|
84
|
+
onAuthenticationChallenge;
|
|
85
|
+
logger;
|
|
86
|
+
nextId = 1;
|
|
87
|
+
sessionId = null;
|
|
88
|
+
closed = false;
|
|
89
|
+
/** The protocol version the SERVER said it would speak, for diagnostics. */
|
|
90
|
+
serverProtocolVersion = "";
|
|
91
|
+
/** The server's self-reported identity, for diagnostics and audit. Untrusted, like everything
|
|
92
|
+
* else it sends — recorded, never acted on. */
|
|
93
|
+
serverInfo = {};
|
|
94
|
+
constructor(deps) {
|
|
95
|
+
this.messages = deps.messages;
|
|
96
|
+
this.exchange = deps.exchange;
|
|
97
|
+
this.clientInfo = deps.clientInfo;
|
|
98
|
+
this.bearerToken = deps.bearerToken;
|
|
99
|
+
this.onAuthenticationChallenge = deps.onAuthenticationChallenge;
|
|
100
|
+
this.logger = deps.logger;
|
|
101
|
+
this.spec = deps.spec;
|
|
102
|
+
this.requestTimeoutMs = deps.requestTimeoutMs;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* @complexity O(p) in `tools/list` pages, bounded by `mcp-protocol.ts`'s page cap.
|
|
106
|
+
*/
|
|
107
|
+
async listTools() {
|
|
108
|
+
return drainToolsList({ requestPage: ({ params }) => this.request("tools/list", params) });
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* @complexity O(1) beyond the remote's own round-trip.
|
|
112
|
+
*/
|
|
113
|
+
async callTool(request, options = {}) {
|
|
114
|
+
return parseCallToolResult({ result: await this.request("tools/call", { name: request.name, arguments: request.arguments }, options.signal) });
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Marks the session closed and tells the server to drop it, best-effort.
|
|
118
|
+
*
|
|
119
|
+
* The DELETE is best-effort by design: a server is entitled to refuse it (the spec allows 405),
|
|
120
|
+
* and a hosted server being unreachable at shutdown must not throw out of a caller that is only
|
|
121
|
+
* tidying up. Local state is cleared either way, so a closed session refuses further requests
|
|
122
|
+
* whatever the server did.
|
|
123
|
+
*/
|
|
124
|
+
async close(_required) {
|
|
125
|
+
if (this.closed)
|
|
126
|
+
return;
|
|
127
|
+
this.closed = true;
|
|
128
|
+
if (this.sessionId === null)
|
|
129
|
+
return;
|
|
130
|
+
const sessionId = this.sessionId;
|
|
131
|
+
const controller = new AbortController();
|
|
132
|
+
let timer;
|
|
133
|
+
const expired = new Promise(resolve => {
|
|
134
|
+
timer = setTimeout(() => { controller.abort(); resolve(); }, this.requestTimeoutMs);
|
|
135
|
+
});
|
|
136
|
+
try {
|
|
137
|
+
// Bound credential lookup as well as DELETE. A non-cooperative port must not hold shutdown
|
|
138
|
+
// open, and a late credential result must never dispatch a request after the deadline.
|
|
139
|
+
const deleting = Promise.resolve().then(async () => {
|
|
140
|
+
const headers = await this.authenticatedHeaders(this.baseHeaders(), controller.signal);
|
|
141
|
+
if (controller.signal.aborted)
|
|
142
|
+
return;
|
|
143
|
+
await this.exchange.send({ url: this.spec.url, method: "DELETE", headers: { ...headers, "mcp-session-id": sessionId } }, { signal: controller.signal });
|
|
144
|
+
}).catch(() => undefined);
|
|
145
|
+
await Promise.race([deleting, expired]);
|
|
146
|
+
}
|
|
147
|
+
finally {
|
|
148
|
+
clearTimeout(timer);
|
|
149
|
+
this.sessionId = null;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
/** Sends the handshake. Separate from the constructor because it is the one part that can fail,
|
|
153
|
+
* and a session object that exists must be one that completed it. */
|
|
154
|
+
async initialize() {
|
|
155
|
+
const identity = parseInitializeResult({ result: await this.request("initialize", buildInitializeParams({ clientInfo: this.clientInfo })) });
|
|
156
|
+
this.serverProtocolVersion = identity.protocolVersion;
|
|
157
|
+
this.serverInfo = identity.info;
|
|
158
|
+
// MCP requires this notification after a successful initialize, and requires that a
|
|
159
|
+
// notification carry no `id`. Its response carries no body to correlate — a 202 is the
|
|
160
|
+
// expected outcome — so unlike every other call it is sent without awaiting a JSON-RPC result.
|
|
161
|
+
await this.send(JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }));
|
|
162
|
+
}
|
|
163
|
+
/** Headers common to every request. Built fresh per call so a session id acquired mid-flight is
|
|
164
|
+
* picked up by the next request without mutating the operator's configured header bag. */
|
|
165
|
+
baseHeaders() {
|
|
166
|
+
return {
|
|
167
|
+
...this.spec.headers,
|
|
168
|
+
"content-type": "application/json",
|
|
169
|
+
accept: ACCEPT_BOTH,
|
|
170
|
+
// Required from the negotiated version onward, and harmless before it. Sent on every request
|
|
171
|
+
// so a stateless server load-balanced across instances sees it on the one that handles a
|
|
172
|
+
// given call, not only on the one that handled `initialize`.
|
|
173
|
+
"mcp-protocol-version": this.serverProtocolVersion || CLIENT_PROTOCOL_VERSION,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
/** POSTs one JSON-RPC frame and returns the raw response, applying the timeout and recording any
|
|
177
|
+
* session id the server issues. */
|
|
178
|
+
async send(body, signal) {
|
|
179
|
+
if (this.closed)
|
|
180
|
+
throw new McpProtocolError({ message: "mcp-federation: session is closed" });
|
|
181
|
+
const headers = this.baseHeaders();
|
|
182
|
+
if (this.sessionId !== null)
|
|
183
|
+
headers["mcp-session-id"] = this.sessionId;
|
|
184
|
+
const response = await this.postWithTimeout(body, headers, signal);
|
|
185
|
+
// A known auth failure must reach assertOkStatus even if the remote supplied a bad session id.
|
|
186
|
+
if (response.status === 401)
|
|
187
|
+
return response;
|
|
188
|
+
this.adoptSessionId(response);
|
|
189
|
+
return response;
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Applies the per-request ceiling.
|
|
193
|
+
*
|
|
194
|
+
* The timeout is enforced with an `AbortSignal` this method owns rather than by racing a promise:
|
|
195
|
+
* a race leaves the underlying request running after the loser settles, and an abandoned request
|
|
196
|
+
* against a hosted server still holds a socket and still delivers its body. Aborting cancels the
|
|
197
|
+
* work as well as the wait.
|
|
198
|
+
*/
|
|
199
|
+
async postWithTimeout(body, headers, callerSignal) {
|
|
200
|
+
const controller = new AbortController();
|
|
201
|
+
const onCallerAbort = forwardAbort(controller, callerSignal);
|
|
202
|
+
const timer = setTimeout(() => controller.abort(), this.requestTimeoutMs);
|
|
203
|
+
try {
|
|
204
|
+
const response = await this.exchange.send({ url: this.spec.url, method: "POST", headers: await this.authenticatedHeaders(headers, controller.signal) }, { body, signal: controller.signal });
|
|
205
|
+
if (response.status === 401)
|
|
206
|
+
await this.notifyAuthenticationChallenge({ response, signal: controller.signal });
|
|
207
|
+
return response;
|
|
208
|
+
}
|
|
209
|
+
catch (error) {
|
|
210
|
+
throw buildPostFailureError({
|
|
211
|
+
error,
|
|
212
|
+
timedOut: controller.signal.aborted,
|
|
213
|
+
callerAborted: callerSignal?.aborted === true,
|
|
214
|
+
requestTimeoutMs: this.requestTimeoutMs,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
finally {
|
|
218
|
+
clearTimeout(timer);
|
|
219
|
+
callerSignal?.removeEventListener("abort", onCallerAbort);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
/** Observes a 401 within the POST's remaining budget, without superseding its typed auth error.
|
|
223
|
+
* @complexity O(1) beyond the injected hook's work, bounded by the request signal.
|
|
224
|
+
*/
|
|
225
|
+
async notifyAuthenticationChallenge({ response, signal }) {
|
|
226
|
+
const hook = this.onAuthenticationChallenge;
|
|
227
|
+
if (!hook || signal.aborted)
|
|
228
|
+
return;
|
|
229
|
+
let onAbort;
|
|
230
|
+
const aborted = new Promise((resolve) => {
|
|
231
|
+
onAbort = resolve;
|
|
232
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
233
|
+
});
|
|
234
|
+
try {
|
|
235
|
+
// Keep the POST's timer and caller listener alive. Forward cancellation to cooperative hooks,
|
|
236
|
+
// but also bound the wait for a hook that ignores it. Consume even a late rejection so the
|
|
237
|
+
// observer can never hide McpAuthFailedError from the registration's auth-revocation handler.
|
|
238
|
+
const notified = Promise.resolve().then(() => hook({
|
|
239
|
+
url: this.spec.url, status: 401, wwwAuthenticate: response.wwwAuthenticate,
|
|
240
|
+
}, { signal })).catch(() => {
|
|
241
|
+
try {
|
|
242
|
+
this.logger?.warn({ message: "mcp-federation: authentication challenge hook failed" });
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
// Logging is another observer; a broken logger cannot replace the authentication error.
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
await Promise.race([notified, aborted]);
|
|
249
|
+
}
|
|
250
|
+
finally {
|
|
251
|
+
signal.removeEventListener("abort", onAbort);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
/** Resolves fresh credentials without mutating the configured header bag.
|
|
255
|
+
* @complexity O(h) in configured headers, plus the injected supplier's work.
|
|
256
|
+
*/
|
|
257
|
+
async authenticatedHeaders(headers, signal) {
|
|
258
|
+
const token = await this.bearerToken({ url: this.spec.url }, { signal });
|
|
259
|
+
if (token === undefined)
|
|
260
|
+
return headers;
|
|
261
|
+
const authenticated = Object.fromEntries(Object.entries(headers).filter(([name]) => name.toLowerCase() !== "authorization"));
|
|
262
|
+
return { ...authenticated, authorization: `Bearer ${token}` };
|
|
263
|
+
}
|
|
264
|
+
/** Records a server-issued session id, refusing one that could not safely be echoed back. */
|
|
265
|
+
adoptSessionId(response) {
|
|
266
|
+
const issued = response.sessionId;
|
|
267
|
+
if (issued === undefined || issued === "" || this.sessionId !== null)
|
|
268
|
+
return;
|
|
269
|
+
if (!SESSION_ID_PATTERN.test(issued)) {
|
|
270
|
+
throw new McpProtocolError({ message: "mcp-federation: the server issued an Mcp-Session-Id containing characters that are not safe to send back" });
|
|
271
|
+
}
|
|
272
|
+
this.sessionId = issued;
|
|
273
|
+
}
|
|
274
|
+
/** Issues one JSON-RPC request and returns its `result`.
|
|
275
|
+
* @throws {McpProtocolError} On a transport failure, a non-2xx, an unparseable body, or a
|
|
276
|
+
* JSON-RPC error frame. */
|
|
277
|
+
async request(method, params, signal) {
|
|
278
|
+
const id = this.nextId++;
|
|
279
|
+
const response = await this.send(JSON.stringify({ jsonrpc: "2.0", id, method, params }), signal);
|
|
280
|
+
assertOkStatus(method, response, this.messages);
|
|
281
|
+
const frame = extractJsonRpcFrame(method, response);
|
|
282
|
+
if (frame.error) {
|
|
283
|
+
throw new McpProtocolError({ message: `mcp-federation: remote returned JSON-RPC error ${frame.error.code ?? "?"}: ${frame.error.message ?? "(no message)"}` });
|
|
284
|
+
}
|
|
285
|
+
return frame.result;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* Turns a non-2xx into a message an operator can act on.
|
|
290
|
+
*
|
|
291
|
+
* Only 401 is called out by name, and by TYPE (`McpAuthFailedError`): it is the one status with a
|
|
292
|
+
* specific cause and a specific fix — the connection's OAuth token has expired or been revoked, and
|
|
293
|
+
* the row needs reconnecting. `external-mcp-store.ts` already reports that state at boot; this is
|
|
294
|
+
* the same finding arrived at from the other direction, when a token that was valid at boot stops
|
|
295
|
+
* being valid mid-session — this module has no OAuth knowledge of its own, so it only distinguishes
|
|
296
|
+
* the failure; `mcp-federation/registrations.ts`'s `onAuthFailed` hook is what a caller that DOES
|
|
297
|
+
* know what "reconnect" means reacts to it with.
|
|
298
|
+
*
|
|
299
|
+
* 403 is deliberately NOT treated the same: it means the token was accepted but the server refused
|
|
300
|
+
* this particular request anyway (an out-of-scope tool, an unauthorized action) — a fact about this
|
|
301
|
+
* call, not about the token's validity. Reporting it as `McpAuthFailedError` would make
|
|
302
|
+
* `onAuthFailed` clear an otherwise-good token and force a needless reauth. It falls through to the
|
|
303
|
+
* generic branch below, same as any other non-2xx whose cause is not "the token expired".
|
|
304
|
+
*/
|
|
305
|
+
function assertOkStatus(method, response, messages) {
|
|
306
|
+
if (response.status >= 200 && response.status < 300)
|
|
307
|
+
return;
|
|
308
|
+
if (response.status === 401) {
|
|
309
|
+
throw new McpAuthFailedError({ message: messages.authenticationRefused({ method, status: response.status }) });
|
|
310
|
+
}
|
|
311
|
+
throw new McpProtocolError({ message: `mcp-federation: the server answered '${method}' with HTTP ${response.status}` });
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Pulls the JSON-RPC frame out of whichever response mode the server chose.
|
|
315
|
+
*
|
|
316
|
+
* @throws {McpProtocolError} On an oversized body, a body carrying no frame, or one that does not
|
|
317
|
+
* parse.
|
|
318
|
+
* @complexity O(n) in the body length.
|
|
319
|
+
*/
|
|
320
|
+
function extractJsonRpcFrame(method, response) {
|
|
321
|
+
if (Buffer.byteLength(response.text, "utf8") > MAX_RESPONSE_BYTES) {
|
|
322
|
+
throw new McpProtocolError({ message: `mcp-federation: the server's response to '${method}' exceeded ${MAX_RESPONSE_BYTES} bytes` });
|
|
323
|
+
}
|
|
324
|
+
const payload = response.contentType.startsWith("text/event-stream") ? firstSseData(response.text) : response.text.trim();
|
|
325
|
+
if (payload === null || payload === "") {
|
|
326
|
+
throw new McpProtocolError({ message: `mcp-federation: the server's response to '${method}' carried no JSON-RPC message` });
|
|
327
|
+
}
|
|
328
|
+
try {
|
|
329
|
+
return JSON.parse(payload);
|
|
330
|
+
}
|
|
331
|
+
catch {
|
|
332
|
+
throw new McpProtocolError({ message: `mcp-federation: the server's response to '${method}' was not valid JSON` });
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* The first SSE `data:` payload in a stream, or `null`.
|
|
337
|
+
*
|
|
338
|
+
* Only the first is taken, and deliberately: a server may interleave progress notifications before
|
|
339
|
+
* the response, but this client issues one request at a time and wants the frame answering it.
|
|
340
|
+
* Multi-line `data:` fields are joined with newlines per the SSE spec, since a server is entitled
|
|
341
|
+
* to split a long JSON body across them.
|
|
342
|
+
*
|
|
343
|
+
* @complexity O(n) in the body length.
|
|
344
|
+
*/
|
|
345
|
+
function firstSseData(body) {
|
|
346
|
+
const collected = [];
|
|
347
|
+
for (const rawLine of body.split(/\r\n|\r|\n/)) {
|
|
348
|
+
if (rawLine.startsWith("data:")) {
|
|
349
|
+
collected.push(rawLine.slice("data:".length).replace(/^ /, ""));
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
// A blank line ends an event. Anything collected so far is that event's complete payload.
|
|
353
|
+
if (rawLine.trim() === "" && collected.length > 0)
|
|
354
|
+
return collected.join("\n");
|
|
355
|
+
}
|
|
356
|
+
return collected.length > 0 ? collected.join("\n") : null;
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* Performs the MCP handshake against a hosted server and returns the connected session.
|
|
360
|
+
*
|
|
361
|
+
* @param deps.exchange - The transport seam. {@link createFetchMcpHttpExchange} in production, a
|
|
362
|
+
* scripted double in tests.
|
|
363
|
+
* @param deps.spec - Endpoint and authenticating headers.
|
|
364
|
+
* @param deps.requestTimeoutMs - Per-request ceiling, applied to the handshake too.
|
|
365
|
+
* @throws {McpProtocolError} If `initialize` fails, times out, or the server refuses it.
|
|
366
|
+
* @complexity O(1) beyond the remote's round-trip.
|
|
367
|
+
*/
|
|
368
|
+
export async function connectMcpHttpSession(deps, options = {}) {
|
|
369
|
+
const session = new McpHttpSession({ ...deps, ...options });
|
|
370
|
+
try {
|
|
371
|
+
await session.initialize();
|
|
372
|
+
}
|
|
373
|
+
catch (error) {
|
|
374
|
+
// A failed handshake may still have been issued a session id the server is now holding open.
|
|
375
|
+
await session.close({}).catch(() => undefined);
|
|
376
|
+
throw error;
|
|
377
|
+
}
|
|
378
|
+
return session;
|
|
379
|
+
}
|
|
380
|
+
/**
|
|
381
|
+
* The production {@link McpHttpExchange}: one `fetch` per exchange.
|
|
382
|
+
*
|
|
383
|
+
* Deliberately logic-free — the same discipline as `spawnMcpStdioChannel`. Everything above it is
|
|
384
|
+
* already under test through the exchange seam, and code that only runs when a real network exists
|
|
385
|
+
* is code that only fails in production.
|
|
386
|
+
*
|
|
387
|
+
* `redirect: "error"` is the security-relevant choice, and matches `src/platform/oauth/token-endpoint.ts`:
|
|
388
|
+
* these requests carry a bearer token in a header, and a followed cross-origin redirect would
|
|
389
|
+
* re-send that header to whatever host the server nominated. Refusing to follow means a redirect is
|
|
390
|
+
* a visible failure rather than a silent credential disclosure.
|
|
391
|
+
*
|
|
392
|
+
* @complexity O(n) in the response body length.
|
|
393
|
+
*/
|
|
394
|
+
export function createFetchMcpHttpExchange({ fetch: fetchImpl }) {
|
|
395
|
+
return {
|
|
396
|
+
async send(request, options = {}) {
|
|
397
|
+
const response = await fetchImpl(request.url, {
|
|
398
|
+
method: request.method,
|
|
399
|
+
headers: { ...request.headers },
|
|
400
|
+
...(options.body === undefined ? {} : { body: options.body }),
|
|
401
|
+
redirect: "error",
|
|
402
|
+
...(options.signal === undefined ? {} : { signal: options.signal }),
|
|
403
|
+
});
|
|
404
|
+
return {
|
|
405
|
+
status: response.status,
|
|
406
|
+
contentType: (response.headers.get("content-type") ?? "").toLowerCase(),
|
|
407
|
+
sessionId: response.headers.get("mcp-session-id") ?? undefined,
|
|
408
|
+
wwwAuthenticate: response.headers.get("www-authenticate") ?? undefined,
|
|
409
|
+
text: await response.text(),
|
|
410
|
+
};
|
|
411
|
+
},
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
//# sourceMappingURL=adapter.http.js.map
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { FederationMessages } from "../messages.js";
|
|
2
|
+
import type { FederatedCallConfirmationOutcome, FederatedCallConfirmationRequest } from "../ports.js";
|
|
3
|
+
import type { FederatedCallConfirmerOptions, FederatedCallConfirmerRequired, FederatedCardOffers, FederatedConfirmationContext, FederatedConfirmationSpec, FederatedConfirmCallInput } from "./ports.js";
|
|
4
|
+
/** Pure card data. The host supplies branding; the caller supplies already frozen call arguments. */
|
|
5
|
+
export declare function buildFederatedCallConfirmSpec({ request, messages, errorCode }: {
|
|
6
|
+
request: FederatedCallConfirmationRequest;
|
|
7
|
+
messages: FederationMessages;
|
|
8
|
+
errorCode: string;
|
|
9
|
+
}, { offers }?: {
|
|
10
|
+
offers?: FederatedCardOffers;
|
|
11
|
+
}): FederatedConfirmationSpec;
|
|
12
|
+
/** Remembered-approval orchestration over host-owned storage, presentation and permission ports.
|
|
13
|
+
* The host adapts the returned object-argument function to its federation runtime callback.
|
|
14
|
+
* Lookup failures fail closed. Save failures allow the approved call and are reported via a port.
|
|
15
|
+
* A destructive or write-shaped call always asks again: neither remembered scope can skip its
|
|
16
|
+
* card and no remember button is offered. The card and lookups are one mechanism for every
|
|
17
|
+
* external server, agent plugin and integration; no per-plugin consent setting is read.
|
|
18
|
+
* A stale always grant is removed so the operator's list stops claiming it is still valid.
|
|
19
|
+
* Frozen arguments shown on the card are the arguments that run; a remembered grant changes
|
|
20
|
+
* only whether a card is required, never what is sent. One explicit answer authorizes one call.
|
|
21
|
+
*/
|
|
22
|
+
export declare function createFederatedCallConfirmer<Context extends FederatedConfirmationContext, Exchanges>(required: FederatedCallConfirmerRequired<Context, Exchanges>, options?: FederatedCallConfirmerOptions): (required: FederatedConfirmCallInput<Context>) => Promise<FederatedCallConfirmationOutcome>;
|
|
23
|
+
//# sourceMappingURL=call-confirmation.d.ts.map
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { nowIso } from "@jini-ai/core/primitives";
|
|
2
|
+
import { federatedToolApprovalFingerprint } from "../tool-approvals.js";
|
|
3
|
+
const INLINE_ARGUMENT_MAX_LENGTH = 60;
|
|
4
|
+
function argumentDetail(name, value) {
|
|
5
|
+
if (typeof value === "string") {
|
|
6
|
+
return value.length > INLINE_ARGUMENT_MAX_LENGTH || value.includes("\n") ? { label: name, value, format: "code" } : { label: name, value };
|
|
7
|
+
}
|
|
8
|
+
if (value === null || typeof value === "number" || typeof value === "boolean")
|
|
9
|
+
return { label: name, value: String(value) };
|
|
10
|
+
return { label: name, value: JSON.stringify(value, null, 2) ?? String(value), format: "code" };
|
|
11
|
+
}
|
|
12
|
+
/** Pure card data. The host supplies branding; the caller supplies already frozen call arguments. */
|
|
13
|
+
export function buildFederatedCallConfirmSpec({ request, messages, errorCode }, { offers = { offerChat: false, offerAlways: false } } = {}) {
|
|
14
|
+
const argumentRows = Object.entries(request.arguments).map(([name, value]) => argumentDetail(name, value));
|
|
15
|
+
const alternatives = [];
|
|
16
|
+
// Permanent deletion always needs this call's explicit answer, even if a host asks for scopes.
|
|
17
|
+
if (!request.destructive && request.writeShapedInputs.length === 0) {
|
|
18
|
+
if (offers.offerChat)
|
|
19
|
+
alternatives.push({ id: "allow-chat", label: messages.allowChatLabel, choice: "chat" });
|
|
20
|
+
if (offers.offerAlways && !request.destructive)
|
|
21
|
+
alternatives.push({ id: "allow-always", label: messages.allowAlwaysLabel, choice: "always" });
|
|
22
|
+
}
|
|
23
|
+
return {
|
|
24
|
+
toolId: request.toolId, errorCode, title: messages.confirmationTitle({ request }),
|
|
25
|
+
description: messages.confirmationDescription,
|
|
26
|
+
details: [{ label: messages.serviceLabel, value: request.connectionLabel }, { label: messages.toolLabel, value: request.remoteName },
|
|
27
|
+
...(argumentRows.length ? argumentRows : [{ label: messages.argumentsLabel, value: messages.noArguments }])],
|
|
28
|
+
warning: messages.confirmationWarning({ request }),
|
|
29
|
+
danger: request.destructive, confirmLabel: messages.confirmLabel, ...(alternatives.length ? { alternatives } : {}),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
function approvalContext(context, request, approvals, fingerprintDomain) {
|
|
33
|
+
const fingerprint = federatedToolApprovalFingerprint({ identity: request, fingerprintDomain });
|
|
34
|
+
const conversationId = approvals?.conversationIdForRun?.({ runId: context.run.id });
|
|
35
|
+
return {
|
|
36
|
+
fingerprint,
|
|
37
|
+
chatKey: approvals?.chat && conversationId ? { conversationId, principalId: context.principal.id, connectionId: request.connectionId, toolName: request.remoteName, fingerprint } : undefined,
|
|
38
|
+
alwaysKey: approvals?.always && request.origin?.kind === "roster" ? { serverId: request.connectionId, toolName: request.remoteName } : undefined,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
async function isRemembered(approvals, request, keys, options) {
|
|
42
|
+
if (request.writeShapedInputs.length > 0)
|
|
43
|
+
return false;
|
|
44
|
+
if (keys.alwaysKey && approvals.always) {
|
|
45
|
+
const saved = await approvals.always.find(keys.alwaysKey, options.scope === undefined ? {} : { scope: options.scope });
|
|
46
|
+
if (saved && saved.fingerprint === keys.fingerprint && !request.destructive)
|
|
47
|
+
return true;
|
|
48
|
+
if (saved)
|
|
49
|
+
await approvals.always.delete(keys.alwaysKey, options.scope === undefined ? {} : { scope: options.scope });
|
|
50
|
+
}
|
|
51
|
+
// Neither a matching chat grant nor an always grant can authorize permanent deletion.
|
|
52
|
+
if (request.destructive)
|
|
53
|
+
return false;
|
|
54
|
+
return keys.chatKey !== undefined && approvals.chat !== undefined && await approvals.chat.has(keys.chatKey);
|
|
55
|
+
}
|
|
56
|
+
function report(options, diagnostic) {
|
|
57
|
+
// Observability must never turn an explicit approval into a failed mutation.
|
|
58
|
+
try {
|
|
59
|
+
options.onPersistenceError?.(diagnostic);
|
|
60
|
+
}
|
|
61
|
+
catch { /* Host diagnostic failures are isolated. */ }
|
|
62
|
+
}
|
|
63
|
+
async function remember(input) {
|
|
64
|
+
const { context, request, keys, approvals, choice, options } = input;
|
|
65
|
+
// Defense in depth: a forged remember choice must not persist a destructive call's approval.
|
|
66
|
+
if (request.destructive || request.writeShapedInputs.length > 0)
|
|
67
|
+
return;
|
|
68
|
+
let grantedAt;
|
|
69
|
+
try {
|
|
70
|
+
grantedAt = nowIso({ clock: approvals.clock });
|
|
71
|
+
if (choice === "chat" && keys.chatKey && approvals.chat)
|
|
72
|
+
await approvals.chat.grant({ key: keys.chatKey, grantedAt });
|
|
73
|
+
else if (choice === "always" && keys.alwaysKey && approvals.always)
|
|
74
|
+
await approvals.always.upsert({
|
|
75
|
+
...keys.alwaysKey, fingerprint: keys.fingerprint, grantedByPrincipalId: context.principal.id, grantedAt,
|
|
76
|
+
}, options.scope === undefined ? {} : { scope: options.scope });
|
|
77
|
+
else
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
report(options, { toolId: request.toolId, choice, stage: "store", error });
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
try {
|
|
85
|
+
await options.webhooks?.approvalRemembered({ principalId: context.principal.id,
|
|
86
|
+
connectionId: request.connectionId, toolName: request.remoteName, fingerprint: keys.fingerprint, scope: choice, grantedAt }, options.scope === undefined ? {} : { scope: options.scope });
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
report(options, { toolId: request.toolId, choice, stage: "webhook", error });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/** Remembered-approval orchestration over host-owned storage, presentation and permission ports.
|
|
93
|
+
* The host adapts the returned object-argument function to its federation runtime callback.
|
|
94
|
+
* Lookup failures fail closed. Save failures allow the approved call and are reported via a port.
|
|
95
|
+
* A destructive or write-shaped call always asks again: neither remembered scope can skip its
|
|
96
|
+
* card and no remember button is offered. The card and lookups are one mechanism for every
|
|
97
|
+
* external server, agent plugin and integration; no per-plugin consent setting is read.
|
|
98
|
+
* A stale always grant is removed so the operator's list stops claiming it is still valid.
|
|
99
|
+
* Frozen arguments shown on the card are the arguments that run; a remembered grant changes
|
|
100
|
+
* only whether a card is required, never what is sent. One explicit answer authorizes one call.
|
|
101
|
+
*/
|
|
102
|
+
export function createFederatedCallConfirmer(required, options = {}) {
|
|
103
|
+
return async ({ context, request }) => {
|
|
104
|
+
const approvals = options.approvals;
|
|
105
|
+
const keys = approvalContext(context, request, approvals, required.fingerprintDomain);
|
|
106
|
+
if (approvals && await isRemembered(approvals, request, keys, options))
|
|
107
|
+
return { confirmed: true };
|
|
108
|
+
const rememberable = !request.destructive && request.writeShapedInputs.length === 0;
|
|
109
|
+
const offerAlways = rememberable && !request.destructive && keys.alwaysKey !== undefined && approvals !== undefined
|
|
110
|
+
&& await approvals.mayAlwaysAllow({ principalId: context.principal.id }, options.scope === undefined ? {} : { scope: options.scope });
|
|
111
|
+
const spec = buildFederatedCallConfirmSpec({ request, messages: required.messages, errorCode: required.errorCode }, {
|
|
112
|
+
offers: { offerChat: rememberable && keys.chatKey !== undefined, offerAlways },
|
|
113
|
+
});
|
|
114
|
+
const outcome = await required.humanConfirm.ask({ context, surfaceExchanges: required.surfaceExchanges, spec });
|
|
115
|
+
if (!outcome.confirmed)
|
|
116
|
+
return outcome;
|
|
117
|
+
// Never trust a remember scope the host did not offer, even with a valid one-call answer.
|
|
118
|
+
const choice = spec.alternatives?.find(alternative => alternative.choice === outcome.choice)?.choice;
|
|
119
|
+
if (approvals && choice)
|
|
120
|
+
await remember({ context, request, keys, approvals, choice, options });
|
|
121
|
+
return { confirmed: true };
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
//# sourceMappingURL=call-confirmation.js.map
|