@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,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file The in-memory doubles for both federation seams — rule-of-two's partner to
|
|
3
|
+
* `adapter.stdio.ts`, and this codebase's usual `repo.memory.ts`/`repo.sqlite.ts` split applied to
|
|
4
|
+
* an outbound protocol boundary instead of a database.
|
|
5
|
+
*
|
|
6
|
+
* One double per seam (see `ports.ts`), and they test different things:
|
|
7
|
+
*
|
|
8
|
+
* - {@link InMemoryMcpSession} fakes `McpSessionPort`, i.e. "an MCP server exists and behaves". It
|
|
9
|
+
* is what `registrations.ts`'s tests use, so the trust tier and the registration wiring are
|
|
10
|
+
* exercised with no protocol in the picture at all.
|
|
11
|
+
*
|
|
12
|
+
* - {@link ScriptedMcpStdioChannel} fakes `McpStdioChannel`, i.e. raw newline-delimited JSON-RPC.
|
|
13
|
+
* It is what `adapter.stdio.ts`'s OWN tests use, so the real client's handshake ordering,
|
|
14
|
+
* pagination, id correlation, timeout and close behaviour are exercised against something that
|
|
15
|
+
* can also misbehave on purpose — reply out of order, reply late, reply twice, never reply,
|
|
16
|
+
* send an unsolicited request, or die mid-flight.
|
|
17
|
+
*
|
|
18
|
+
* - {@link ScriptedMcpHttpExchange} does the same for `McpHttpExchange`, the hosted transport's
|
|
19
|
+
* seam, so `adapter.http.ts` is under test on the same terms.
|
|
20
|
+
*
|
|
21
|
+
* Those inner-seam doubles are the ones that make the "no live third-party server in this sandbox"
|
|
22
|
+
* constraint survivable: the protocol code under test is the real production code, and only the
|
|
23
|
+
* pipe beneath it is fake. A double at the outer seam alone would have proved only that the double
|
|
24
|
+
* works.
|
|
25
|
+
*
|
|
26
|
+
* Architectural role:
|
|
27
|
+
* Test doubles. Ships in `src/` beside the port they implement, exactly as every feature's
|
|
28
|
+
* `repo.memory.ts` does, so they are importable from `__tests__/` without a second tsconfig.
|
|
29
|
+
* See docs/decisions/DR-003-trusted-federation-composition.md.
|
|
30
|
+
*/
|
|
31
|
+
/**
|
|
32
|
+
* A scripted `McpSessionPort`: a fixed tool list plus a per-tool call handler.
|
|
33
|
+
*
|
|
34
|
+
* `callTool` records every call, so a test can assert not just the returned value but that the
|
|
35
|
+
* REMOTE NAME was sent — the property that proves namespacing is applied on the way in and stripped
|
|
36
|
+
* on the way out, which is the single most load-bearing behaviour in the registration layer.
|
|
37
|
+
*/
|
|
38
|
+
export class InMemoryMcpSession {
|
|
39
|
+
calls = [];
|
|
40
|
+
closed = false;
|
|
41
|
+
tools;
|
|
42
|
+
handler;
|
|
43
|
+
listToolsBehaviour;
|
|
44
|
+
constructor({ tools }, options = {}) {
|
|
45
|
+
this.tools = [...tools];
|
|
46
|
+
this.handler = options.onCall ?? (({ name, args }) => ({ content: [{ type: "text", text: `called ${name} with ${JSON.stringify(args)}` }] }));
|
|
47
|
+
this.listToolsBehaviour = options.onListTools;
|
|
48
|
+
}
|
|
49
|
+
async listTools() {
|
|
50
|
+
if (this.listToolsBehaviour)
|
|
51
|
+
return this.listToolsBehaviour();
|
|
52
|
+
return [...this.tools];
|
|
53
|
+
}
|
|
54
|
+
async callTool(request, _options = {}) {
|
|
55
|
+
this.calls.push({ name: request.name, arguments: request.arguments });
|
|
56
|
+
return this.handler({ name: request.name, args: request.arguments });
|
|
57
|
+
}
|
|
58
|
+
async close(_required) {
|
|
59
|
+
this.closed = true;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* A fake {@link McpStdioChannel} that plays the SERVER side of MCP, under the test's control.
|
|
64
|
+
*
|
|
65
|
+
* `respond` is a plain function of the received message, so a test scripts a server by writing one:
|
|
66
|
+
* return a response object to reply, or `undefined` to stay silent (the case that exercises the
|
|
67
|
+
* client's timeout path). Everything the client sends is captured in {@link sent}, which is how
|
|
68
|
+
* handshake ordering is asserted — that `initialize` comes first and that
|
|
69
|
+
* `notifications/initialized` follows it with no `id`.
|
|
70
|
+
*/
|
|
71
|
+
export class ScriptedMcpStdioChannel {
|
|
72
|
+
/** Every message the client wrote, parsed, in order. */
|
|
73
|
+
sent = [];
|
|
74
|
+
closedReason = null;
|
|
75
|
+
messageListeners = [];
|
|
76
|
+
closeListeners = [];
|
|
77
|
+
respond;
|
|
78
|
+
messages;
|
|
79
|
+
constructor(options) {
|
|
80
|
+
this.respond = options.respond;
|
|
81
|
+
this.messages = options.messages;
|
|
82
|
+
}
|
|
83
|
+
send({ message }) {
|
|
84
|
+
if (this.closedReason !== null)
|
|
85
|
+
throw new Error("scripted channel is closed");
|
|
86
|
+
const parsed = JSON.parse(message);
|
|
87
|
+
this.sent.push(parsed);
|
|
88
|
+
const reply = this.respond({ message: parsed });
|
|
89
|
+
// Delivered on a later microtask, as a real pipe would: a client that only works when its reply
|
|
90
|
+
// arrives synchronously inside its own `send` is a client that does not work.
|
|
91
|
+
if (reply !== undefined)
|
|
92
|
+
queueMicrotask(() => this.deliver({ message: reply }));
|
|
93
|
+
}
|
|
94
|
+
onMessage({ listener }) {
|
|
95
|
+
this.messageListeners.push(listener);
|
|
96
|
+
}
|
|
97
|
+
onClose({ listener }) {
|
|
98
|
+
this.closeListeners.push(listener);
|
|
99
|
+
}
|
|
100
|
+
close(_required) {
|
|
101
|
+
this.fail({ reason: this.messages.closedByHost });
|
|
102
|
+
}
|
|
103
|
+
/** Pushes a message to the client out of band — for late replies, unsolicited server requests,
|
|
104
|
+
* and notifications. */
|
|
105
|
+
deliver({ message }) {
|
|
106
|
+
const line = typeof message === "string" ? message : JSON.stringify(message);
|
|
107
|
+
for (const listener of this.messageListeners)
|
|
108
|
+
listener({ message: line });
|
|
109
|
+
}
|
|
110
|
+
/** Simulates the far end dying. */
|
|
111
|
+
fail({ reason }) {
|
|
112
|
+
if (this.closedReason !== null)
|
|
113
|
+
return;
|
|
114
|
+
this.closedReason = reason;
|
|
115
|
+
for (const listener of this.closeListeners)
|
|
116
|
+
listener({ reason: reason });
|
|
117
|
+
}
|
|
118
|
+
/** The last id the client used for `method`, for building a matching reply. */
|
|
119
|
+
idFor({ method }) {
|
|
120
|
+
for (let i = this.sent.length - 1; i >= 0; i -= 1) {
|
|
121
|
+
if (this.sent[i]?.method === method)
|
|
122
|
+
return this.sent[i]?.id;
|
|
123
|
+
}
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* A fake {@link McpHttpExchange} that plays the SERVER side of MCP's Streamable HTTP transport.
|
|
129
|
+
*
|
|
130
|
+
* The hosted counterpart to {@link ScriptedMcpStdioChannel}, and it exists for the same reason: so
|
|
131
|
+
* `adapter.http.ts`'s REAL handshake ordering, session-id propagation, SSE framing, pagination,
|
|
132
|
+
* status mapping and timeout behaviour are exercised against something that can misbehave on
|
|
133
|
+
* purpose — answer 401, answer with an unparseable body, issue a header-injecting session id,
|
|
134
|
+
* paginate forever, or never answer at all — rather than against a stub of the client itself.
|
|
135
|
+
*
|
|
136
|
+
* `respond` is a plain function of the received request, so a test scripts a server by writing one.
|
|
137
|
+
* Returning `undefined` means "never answer", which is how the timeout path is reached: the
|
|
138
|
+
* returned promise stays pending until the client's own `AbortSignal` fires.
|
|
139
|
+
*/
|
|
140
|
+
export class ScriptedMcpHttpExchange {
|
|
141
|
+
/** Every request the client made, in order. */
|
|
142
|
+
sent = [];
|
|
143
|
+
respond;
|
|
144
|
+
constructor(options) {
|
|
145
|
+
this.respond = options.respond;
|
|
146
|
+
}
|
|
147
|
+
async send(request, options = {}) {
|
|
148
|
+
const captured = {
|
|
149
|
+
url: request.url,
|
|
150
|
+
method: request.method,
|
|
151
|
+
headers: { ...request.headers },
|
|
152
|
+
message: options.body === undefined ? undefined : JSON.parse(options.body),
|
|
153
|
+
};
|
|
154
|
+
this.sent.push(captured);
|
|
155
|
+
const reply = this.respond({ request: captured });
|
|
156
|
+
if (reply === undefined)
|
|
157
|
+
return this.neverAnswer(options.signal);
|
|
158
|
+
return {
|
|
159
|
+
status: reply.status ?? 200,
|
|
160
|
+
contentType: reply.contentType ?? "application/json",
|
|
161
|
+
...(reply.sessionId === undefined ? {} : { sessionId: reply.sessionId }),
|
|
162
|
+
...(reply.wwwAuthenticate === undefined ? {} : { wwwAuthenticate: reply.wwwAuthenticate }),
|
|
163
|
+
text: typeof reply.body === "string" ? reply.body : JSON.stringify(reply.body ?? {}),
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
/** A server that accepts the request and then goes quiet. Settles only when the client aborts,
|
|
167
|
+
* which is exactly what a real stalled connection does. */
|
|
168
|
+
neverAnswer(signal) {
|
|
169
|
+
return new Promise((_resolve, reject) => {
|
|
170
|
+
if (!signal)
|
|
171
|
+
return;
|
|
172
|
+
if (signal.aborted) {
|
|
173
|
+
reject(new Error("aborted"));
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
signal.addEventListener("abort", () => reject(new Error("aborted")), { once: true });
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
/** The last request whose JSON-RPC `method` matches, for asserting on what was sent. */
|
|
180
|
+
lastRequestFor({ method }) {
|
|
181
|
+
for (let i = this.sent.length - 1; i >= 0; i -= 1) {
|
|
182
|
+
const candidate = this.sent[i];
|
|
183
|
+
if (candidate?.message?.method === method)
|
|
184
|
+
return candidate;
|
|
185
|
+
}
|
|
186
|
+
return undefined;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
//# sourceMappingURL=adapter.memory.js.map
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { ExternalMcpToolApprovalRepoPort, ExternalMcpToolApprovalRecord, ConversationToolApprovalStore } from "../tool-approvals.js";
|
|
2
|
+
/** In-memory {@link ExternalMcpToolApprovalRepoPort}, for DB-less compositions and tests. */
|
|
3
|
+
export declare class InMemoryExternalMcpToolApprovalRepo implements ExternalMcpToolApprovalRepoPort {
|
|
4
|
+
private readonly rows;
|
|
5
|
+
constructor(_required: Record<string, never>);
|
|
6
|
+
find(input: {
|
|
7
|
+
serverId: string;
|
|
8
|
+
toolName: string;
|
|
9
|
+
}, optional?: {
|
|
10
|
+
scope?: string;
|
|
11
|
+
}): Promise<ExternalMcpToolApprovalRecord | null>;
|
|
12
|
+
upsert(record: Omit<ExternalMcpToolApprovalRecord, "scope">, optional?: {
|
|
13
|
+
scope?: string;
|
|
14
|
+
}): Promise<void>;
|
|
15
|
+
listByScope(_required: Record<string, never>, { scope }?: {
|
|
16
|
+
scope?: string;
|
|
17
|
+
}): Promise<ExternalMcpToolApprovalRecord[]>;
|
|
18
|
+
delete(input: {
|
|
19
|
+
serverId: string;
|
|
20
|
+
toolName: string;
|
|
21
|
+
}, optional?: {
|
|
22
|
+
scope?: string;
|
|
23
|
+
}): Promise<boolean>;
|
|
24
|
+
}
|
|
25
|
+
/** In-memory {@link ConversationToolApprovalStore}, for DB-less compositions and tests. */
|
|
26
|
+
export declare function createInMemoryConversationToolApprovalStore(_required: Record<string, never>): ConversationToolApprovalStore;
|
|
27
|
+
//# sourceMappingURL=tool-approvals.memory.d.ts.map
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
function approvalKey(input, optional) {
|
|
2
|
+
return JSON.stringify([optional.scope, input.serverId, input.toolName]);
|
|
3
|
+
}
|
|
4
|
+
/** In-memory {@link ExternalMcpToolApprovalRepoPort}, for DB-less compositions and tests. */
|
|
5
|
+
export class InMemoryExternalMcpToolApprovalRepo {
|
|
6
|
+
rows = new Map();
|
|
7
|
+
constructor(_required) { }
|
|
8
|
+
async find(input, optional = {}) {
|
|
9
|
+
return this.rows.get(approvalKey(input, optional)) ?? null;
|
|
10
|
+
}
|
|
11
|
+
async upsert(record, optional = {}) {
|
|
12
|
+
const scopedRecord = { ...record, ...optional };
|
|
13
|
+
// Scope belongs to the options bag, even if a caller passes a record with extra fields.
|
|
14
|
+
if (optional.scope === undefined)
|
|
15
|
+
delete scopedRecord.scope;
|
|
16
|
+
this.rows.set(approvalKey(record, optional), scopedRecord);
|
|
17
|
+
}
|
|
18
|
+
async listByScope(_required, { scope } = {}) {
|
|
19
|
+
return [...this.rows.values()].filter((row) => row.scope === scope);
|
|
20
|
+
}
|
|
21
|
+
async delete(input, optional = {}) {
|
|
22
|
+
return this.rows.delete(approvalKey(input, optional));
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/** In-memory {@link ConversationToolApprovalStore}, for DB-less compositions and tests. */
|
|
26
|
+
export function createInMemoryConversationToolApprovalStore(_required) {
|
|
27
|
+
const rows = new Map();
|
|
28
|
+
const keyOf = (key) => JSON.stringify([key.conversationId, key.principalId, key.connectionId, key.toolName]);
|
|
29
|
+
return {
|
|
30
|
+
async has(key) {
|
|
31
|
+
return rows.get(keyOf(key)) === key.fingerprint;
|
|
32
|
+
},
|
|
33
|
+
async grant({ key }) {
|
|
34
|
+
rows.set(keyOf(key), key.fingerprint);
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
//# sourceMappingURL=tool-approvals.memory.js.map
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { FederatedConnectionOrigin, RemoteToolDescriptor } from "./ports.js";
|
|
2
|
+
/**
|
|
3
|
+
* Shared remembered approvals for external servers, agent plugins and integrations; no plugin
|
|
4
|
+
* configuration is read here. Chat grants belong to a conversation AND a person, so another
|
|
5
|
+
* person's conversation id cannot reuse them. Always grants belong beside a connection in the
|
|
6
|
+
* host store, for its operator to list and revoke. A fingerprint pins both scopes to exactly the
|
|
7
|
+
* admitted tool; changes to server identity, hints, description or schema ask again.
|
|
8
|
+
* Destructive calls always require a fresh one-call answer and never use either remembered scope.
|
|
9
|
+
*
|
|
10
|
+
* How long a person's answer on the card should count. `once` is the plain "Allow".
|
|
11
|
+
*/
|
|
12
|
+
export type FederatedApprovalScope = "once" | "chat" | "always";
|
|
13
|
+
/** Everything that identifies a tool for a remembered approval — change any of it and it asks again. */
|
|
14
|
+
export interface FederatedToolIdentity {
|
|
15
|
+
readonly connectionId: string;
|
|
16
|
+
readonly remoteName: string;
|
|
17
|
+
/** The hints recorded when the tool was admitted (`AdmittedFederatedTool.declaredAnnotations`). */
|
|
18
|
+
readonly declaredAnnotations: RemoteToolDescriptor["annotations"] | undefined;
|
|
19
|
+
/**
|
|
20
|
+
* Where the connection came from. A roster connection's `admissionRevision` already changes when
|
|
21
|
+
* its url/command/args/transport/auth mode/credential or the row itself changes
|
|
22
|
+
* (`externalMcpAdmissionRevision`), which is what "the server changed" means here.
|
|
23
|
+
*/
|
|
24
|
+
readonly origin: FederatedConnectionOrigin | undefined;
|
|
25
|
+
/** The description the model reads, as admitted (`AdmittedFederatedTool.description`). */
|
|
26
|
+
readonly description: string;
|
|
27
|
+
/** The input schema, as admitted. */
|
|
28
|
+
readonly inputSchema: Readonly<Record<string, unknown>>;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* The fingerprint a remembered approval is saved under and later matched against: the connection,
|
|
32
|
+
* the server it reaches (its origin and admission revision), the remote tool name, its declared
|
|
33
|
+
* hints, the description the model reads and its input schema — so any drift in what the tool says
|
|
34
|
+
* about itself voids the approval. Object keys are sorted first, so key order alone never counts as
|
|
35
|
+
* drift. Hashed, because an admission revision is itself derived from values that can carry
|
|
36
|
+
* credentials and this string is stored and compared, never shown.
|
|
37
|
+
*
|
|
38
|
+
* The host supplies the persisted domain. Changing it invalidates remembered grants.
|
|
39
|
+
* The v2 revision (2026-09-27, "hint drift") added description and input schema: v1 grants
|
|
40
|
+
* no longer matched, asked once and were saved again. A host must keep its chosen bytes stable.
|
|
41
|
+
*
|
|
42
|
+
* @complexity O(h + s) in the size of the hints and the schema.
|
|
43
|
+
*/
|
|
44
|
+
export declare function federatedToolApprovalFingerprint({ identity, fingerprintDomain }: {
|
|
45
|
+
identity: FederatedToolIdentity;
|
|
46
|
+
fingerprintDomain: string;
|
|
47
|
+
}): string;
|
|
48
|
+
/** One saved "Always allow". */
|
|
49
|
+
export interface ExternalMcpToolApprovalRecord {
|
|
50
|
+
readonly scope?: string;
|
|
51
|
+
readonly serverId: string;
|
|
52
|
+
readonly toolName: string;
|
|
53
|
+
readonly fingerprint: string;
|
|
54
|
+
readonly grantedByPrincipalId: string;
|
|
55
|
+
readonly grantedAt: string;
|
|
56
|
+
}
|
|
57
|
+
/** The "Always allow" store (`external_mcp_tool_approvals`). */
|
|
58
|
+
export interface ExternalMcpToolApprovalRepoPort {
|
|
59
|
+
find(input: {
|
|
60
|
+
serverId: string;
|
|
61
|
+
toolName: string;
|
|
62
|
+
}, optional?: {
|
|
63
|
+
scope?: string;
|
|
64
|
+
}): Promise<ExternalMcpToolApprovalRecord | null>;
|
|
65
|
+
/** Inserts, or replaces the fingerprint/grant of, the one row for scope + connection + tool. */
|
|
66
|
+
upsert(record: Omit<ExternalMcpToolApprovalRecord, "scope">, optional?: {
|
|
67
|
+
scope?: string;
|
|
68
|
+
}): Promise<void>;
|
|
69
|
+
listByScope(input: Record<string, never>, optional?: {
|
|
70
|
+
scope?: string;
|
|
71
|
+
}): Promise<ExternalMcpToolApprovalRecord[]>;
|
|
72
|
+
/** @returns Whether a row was removed. */
|
|
73
|
+
delete(input: {
|
|
74
|
+
serverId: string;
|
|
75
|
+
toolName: string;
|
|
76
|
+
}, optional?: {
|
|
77
|
+
scope?: string;
|
|
78
|
+
}): Promise<boolean>;
|
|
79
|
+
}
|
|
80
|
+
/** One saved "Allow for this chat". */
|
|
81
|
+
export interface ConversationToolApprovalKey {
|
|
82
|
+
readonly conversationId: string;
|
|
83
|
+
readonly principalId: string;
|
|
84
|
+
readonly connectionId: string;
|
|
85
|
+
readonly toolName: string;
|
|
86
|
+
readonly fingerprint: string;
|
|
87
|
+
}
|
|
88
|
+
/** The "Allow for this chat" store, kept with the conversation (`chat.db`). */
|
|
89
|
+
export interface ConversationToolApprovalStore {
|
|
90
|
+
/** True only for this conversation AND this person AND this exact fingerprint. */
|
|
91
|
+
has(key: ConversationToolApprovalKey): Promise<boolean>;
|
|
92
|
+
grant(input: {
|
|
93
|
+
key: ConversationToolApprovalKey;
|
|
94
|
+
grantedAt: string;
|
|
95
|
+
}): Promise<void>;
|
|
96
|
+
}
|
|
97
|
+
//# sourceMappingURL=tool-approvals.d.ts.map
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
/** Stable JSON: object keys sorted at every depth, so the same hints always hash the same. */
|
|
3
|
+
function canonicalJson(value) {
|
|
4
|
+
if (Array.isArray(value))
|
|
5
|
+
return `[${value.map(canonicalJson).join(",")}]`;
|
|
6
|
+
if (value !== null && typeof value === "object") {
|
|
7
|
+
const entries = Object.entries(value)
|
|
8
|
+
.filter(([, child]) => child !== undefined)
|
|
9
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
10
|
+
return `{${entries.map(([key, child]) => `${JSON.stringify(key)}:${canonicalJson(child)}`).join(",")}}`;
|
|
11
|
+
}
|
|
12
|
+
return JSON.stringify(value) ?? "null";
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* The fingerprint a remembered approval is saved under and later matched against: the connection,
|
|
16
|
+
* the server it reaches (its origin and admission revision), the remote tool name, its declared
|
|
17
|
+
* hints, the description the model reads and its input schema — so any drift in what the tool says
|
|
18
|
+
* about itself voids the approval. Object keys are sorted first, so key order alone never counts as
|
|
19
|
+
* drift. Hashed, because an admission revision is itself derived from values that can carry
|
|
20
|
+
* credentials and this string is stored and compared, never shown.
|
|
21
|
+
*
|
|
22
|
+
* The host supplies the persisted domain. Changing it invalidates remembered grants.
|
|
23
|
+
* The v2 revision (2026-09-27, "hint drift") added description and input schema: v1 grants
|
|
24
|
+
* no longer matched, asked once and were saved again. A host must keep its chosen bytes stable.
|
|
25
|
+
*
|
|
26
|
+
* @complexity O(h + s) in the size of the hints and the schema.
|
|
27
|
+
*/
|
|
28
|
+
export function federatedToolApprovalFingerprint({ identity, fingerprintDomain }) {
|
|
29
|
+
const origin = identity.origin === undefined ? null : identity.origin.kind === "roster" ? ["roster", identity.origin.admissionRevision] : ["preset"];
|
|
30
|
+
return createHash("sha256")
|
|
31
|
+
.update(canonicalJson([
|
|
32
|
+
fingerprintDomain,
|
|
33
|
+
identity.connectionId,
|
|
34
|
+
origin,
|
|
35
|
+
identity.remoteName,
|
|
36
|
+
identity.declaredAnnotations ?? null,
|
|
37
|
+
identity.description,
|
|
38
|
+
identity.inputSchema,
|
|
39
|
+
]))
|
|
40
|
+
.digest("hex");
|
|
41
|
+
}
|
|
42
|
+
//# sourceMappingURL=tool-approvals.js.map
|