@anchrd/intel-api 0.7.0 → 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/README.md +30 -11
- package/dist/adapters/cloudflare/cloudflare.js +71 -40
- package/dist/adapters/cloudflare/cloudflare.types.d.ts +9 -0
- package/dist/adapters/db/db.js +11 -0
- package/dist/adapters/gate-applications/gate-applications.js +22 -0
- package/dist/adapters/tool-delegation/tool-delegation.d.ts +22 -0
- package/dist/adapters/tool-delegation/tool-delegation.js +90 -0
- package/dist/agent-runtime/agent-runtime.js +74 -0
- package/dist/agent-runtime/agent-runtime.types.d.ts +65 -0
- package/dist/bundle/bundle.js +13 -0
- package/dist/http/http.js +32 -8
- package/dist/mcp/mcp.js +48 -12
- package/dist/nodes/nodes.js +152 -23
- package/dist/nodes/nodes.types.d.ts +94 -6
- package/dist/tools/tool-servers/tool-servers.d.ts +46 -0
- package/dist/tools/tool-servers/tool-servers.js +114 -0
- package/dist/tools/tools.js +190 -31
- package/dist/tools/tools.types.d.ts +23 -1
- package/migrations/0015_tools_delegated_from_a_connection.sql +15 -0
- package/package.json +2 -2
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { serverOf } from "@anchrd/intel-contract";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
/**
|
|
4
|
+
* The portal's own directory tool. It is an ordinary entry in `tools/list`, so it is reached the
|
|
5
|
+
* same way every other tool is: `tools/call` with the asking user's portal token.
|
|
6
|
+
*
|
|
7
|
+
* ⚠️ Its presence in the live list is also the permission check. A deployment whose portal does not
|
|
8
|
+
* offer it, or a user whose Access policies hide it, gets no server list at all — and therefore
|
|
9
|
+
* cannot delegate anything. That is deliberate: the alternative would be inventing the server list
|
|
10
|
+
* out of tool names, which is exactly the guess this module exists to avoid.
|
|
11
|
+
*/
|
|
12
|
+
export const ServerDirectoryTool = "portal_list_servers";
|
|
13
|
+
/**
|
|
14
|
+
* What the portal answers about one server, read as tolerantly as possible.
|
|
15
|
+
*
|
|
16
|
+
* ⚠️ Only the shape is assumed, never the vocabulary. The portal is somebody else's product and its
|
|
17
|
+
* payload is not part of any contract Intel owns, so every field is optional and the answer is
|
|
18
|
+
* accepted from `structuredContent`, from `{ servers: [...] }` or from a JSON text block. What
|
|
19
|
+
* makes a row usable is not that it parsed but that its identifier is confirmed against the live
|
|
20
|
+
* tool list below.
|
|
21
|
+
*/
|
|
22
|
+
const DirectoryRow = z.looseObject({
|
|
23
|
+
id: z.string().optional(),
|
|
24
|
+
name: z.string().optional(),
|
|
25
|
+
enabled: z.boolean().optional(),
|
|
26
|
+
});
|
|
27
|
+
const DirectoryRows = z.array(DirectoryRow);
|
|
28
|
+
const DirectoryEnvelope = z.looseObject({ servers: DirectoryRows });
|
|
29
|
+
/**
|
|
30
|
+
* Every place the portal could reasonably have put its list, tried in order. An MCP tool result is
|
|
31
|
+
* either structured or text, and a server that answers with `{ servers: [...] }` is as likely as
|
|
32
|
+
* one that answers with a bare array.
|
|
33
|
+
*/
|
|
34
|
+
function rows(answer) {
|
|
35
|
+
const candidates = [answer.structuredContent];
|
|
36
|
+
for (const block of answer.content) {
|
|
37
|
+
if (typeof block === "object" && block !== null && "text" in block) {
|
|
38
|
+
const text = block.text;
|
|
39
|
+
if (typeof text !== "string")
|
|
40
|
+
continue;
|
|
41
|
+
try {
|
|
42
|
+
candidates.push(JSON.parse(text));
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
// A text block that is not JSON is prose, not a directory. Skip it rather than fail: the
|
|
46
|
+
// portal may well add a human sentence beside the payload.
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
for (const candidate of candidates) {
|
|
51
|
+
const bare = DirectoryRows.safeParse(candidate);
|
|
52
|
+
if (bare.success)
|
|
53
|
+
return bare.data;
|
|
54
|
+
const wrapped = DirectoryEnvelope.safeParse(candidate);
|
|
55
|
+
if (wrapped.success)
|
|
56
|
+
return wrapped.data.servers;
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* ⚠️ The rule itself — longest match against declared handles, never a cut at an underscore — is
|
|
62
|
+
* `serverOf` in `@anchrd/intel-contract`, because the tools screen has to read a name exactly the
|
|
63
|
+
* way the delegation does (#212). What stays here is what the rule is USED for: a tool whose prefix
|
|
64
|
+
* matches no declared handle belongs to no server and can never be delegated, which is what keeps
|
|
65
|
+
* the portal's own management tools (`portal_list_servers`, `portal_toggle_servers`) out of an
|
|
66
|
+
* agent's reach — the portal does not list itself as an upstream server.
|
|
67
|
+
*/
|
|
68
|
+
/**
|
|
69
|
+
* ⚠️ A handle that would carry the portal's OWN tools is never a server anybody can be given.
|
|
70
|
+
*
|
|
71
|
+
* The portal does not list itself as an upstream, so in practice `portal` never appears in the
|
|
72
|
+
* directory — but "in practice" is not a guard. One row called `portal` (an upstream a customer
|
|
73
|
+
* happens to name that way, a future portal that describes itself) would make `portal_toggle_servers`
|
|
74
|
+
* a delegable tool, and an agent could then switch its delegator's MCP servers on and off. The rule
|
|
75
|
+
* is written as a question about reach rather than as a blocklist of one string: if delegating this
|
|
76
|
+
* handle would hand over `portal_list_servers`, the handle is refused.
|
|
77
|
+
*/
|
|
78
|
+
function ownsTheDirectory(handle) {
|
|
79
|
+
return ServerDirectoryTool.startsWith(`${handle}_`);
|
|
80
|
+
}
|
|
81
|
+
export function toolServersFrom(input) {
|
|
82
|
+
const parsed = rows(input.directory);
|
|
83
|
+
if (parsed === null)
|
|
84
|
+
return null;
|
|
85
|
+
// `id` and `name` are both offered as candidates because the portal's directory names them
|
|
86
|
+
// separately and only one of them is the namespace. The live list decides which.
|
|
87
|
+
const display = new Map();
|
|
88
|
+
const offerable = new Set();
|
|
89
|
+
for (const row of parsed) {
|
|
90
|
+
for (const candidate of [row.name, row.id]) {
|
|
91
|
+
if (candidate === undefined || candidate.length === 0)
|
|
92
|
+
continue;
|
|
93
|
+
if (ownsTheDirectory(candidate))
|
|
94
|
+
continue;
|
|
95
|
+
if (!display.has(candidate))
|
|
96
|
+
display.set(candidate, row.name ?? row.id ?? candidate);
|
|
97
|
+
if (row.enabled !== false)
|
|
98
|
+
offerable.add(candidate);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const counts = new Map();
|
|
102
|
+
for (const toolName of input.toolNames) {
|
|
103
|
+
const handle = serverOf(toolName, display.keys());
|
|
104
|
+
if (handle === null || !offerable.has(handle))
|
|
105
|
+
continue;
|
|
106
|
+
counts.set(handle, (counts.get(handle) ?? 0) + 1);
|
|
107
|
+
}
|
|
108
|
+
return {
|
|
109
|
+
declared: [...display.keys()],
|
|
110
|
+
servers: [...counts.entries()]
|
|
111
|
+
.map(([handle, toolCount]) => ({ handle, name: display.get(handle) ?? handle, toolCount }))
|
|
112
|
+
.sort((a, b) => a.handle.localeCompare(b.handle)),
|
|
113
|
+
};
|
|
114
|
+
}
|
package/dist/tools/tools.js
CHANGED
|
@@ -1,10 +1,24 @@
|
|
|
1
|
+
import { serverOf } from "@anchrd/intel-contract";
|
|
1
2
|
import { IntelError } from "../shared/intel-error/intel-error.js";
|
|
2
3
|
import { reportUnexpectedError } from "../shared/report-unexpected-error/report-unexpected-error.js";
|
|
4
|
+
import { ServerDirectoryTool, toolServersFrom, } from "./tool-servers/tool-servers.js";
|
|
3
5
|
const CallTimeoutMs = 15_000;
|
|
4
6
|
const MaxTools = 1_000;
|
|
5
7
|
const MaxResultBytes = 1_000_000;
|
|
6
8
|
// Refresh slightly early so a call cannot start with a token that expires mid-flight.
|
|
7
9
|
const RefreshWindowMs = 30_000;
|
|
10
|
+
/**
|
|
11
|
+
* An agent that delegates nothing — no servers, or no delegator to act for.
|
|
12
|
+
*
|
|
13
|
+
* ⚠️ It is answered without touching the token store at all. `delegatedBy` is empty for an archived
|
|
14
|
+
* agent, one whose definition Intel could not read, and one that was never given tools; asking the
|
|
15
|
+
* store for the connection of user "" would be a lookup that can only ever fail, on a path that has
|
|
16
|
+
* already decided the answer.
|
|
17
|
+
*/
|
|
18
|
+
function delegatesNothing(who) {
|
|
19
|
+
return (who.delegation !== null &&
|
|
20
|
+
(who.delegation.servers.length === 0 || who.delegation.delegatedBy.length === 0));
|
|
21
|
+
}
|
|
8
22
|
export function createTools(deps) {
|
|
9
23
|
function portal() {
|
|
10
24
|
if (!deps.portalUrl || !deps.sourceAllowed(deps.portalUrl)) {
|
|
@@ -12,12 +26,23 @@ export function createTools(deps) {
|
|
|
12
26
|
}
|
|
13
27
|
return deps.portalUrl;
|
|
14
28
|
}
|
|
29
|
+
// One lookup per request, before anything else happens: everything below has to know whether it
|
|
30
|
+
// is answering a person or an agent, and a second lookup could answer differently mid-call.
|
|
31
|
+
async function acting(actor) {
|
|
32
|
+
const delegation = await deps.delegation(actor.id);
|
|
33
|
+
return {
|
|
34
|
+
actor,
|
|
35
|
+
connectionOf: delegation?.delegatedBy ?? actor.id,
|
|
36
|
+
delegation,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
15
39
|
// Authorization for tools lives entirely in the portal, so "may this user act" reduces to "does
|
|
16
40
|
// this user have a usable portal token". ⚠️ The token is read per actor and never shared: one
|
|
17
41
|
// operator token for everybody would make every catalog the same one and the portal's Access
|
|
18
|
-
// policies decorative (ADR-0003).
|
|
19
|
-
|
|
20
|
-
|
|
42
|
+
// policies decorative (ADR-0003). For an agent the actor IS somebody else — the delegator — which
|
|
43
|
+
// is the whole of D30 and the reason this takes an `Acting` rather than a `ToolActor`.
|
|
44
|
+
async function accessToken(who) {
|
|
45
|
+
const stored = await deps.tokens.read(who.connectionOf);
|
|
21
46
|
if (!stored) {
|
|
22
47
|
throw new IntelError(401, "portal_not_connected", "The portal has not signed this user in yet");
|
|
23
48
|
}
|
|
@@ -33,20 +58,81 @@ export function createTools(deps) {
|
|
|
33
58
|
// A token that cannot be renewed is dropped: leaving it would keep failing every call with a
|
|
34
59
|
// stale credential. The browser answers this by signing in silently again (#60); an MCP
|
|
35
60
|
// client sees the code and repeats its own authorization.
|
|
36
|
-
await deps.tokens.clear(
|
|
61
|
+
await deps.tokens.clear(who.connectionOf);
|
|
37
62
|
throw new IntelError(401, "portal_reconnect_required", "The portal sign-in for this user has expired");
|
|
38
63
|
}
|
|
39
|
-
await deps.tokens.write(
|
|
64
|
+
await deps.tokens.write(who.connectionOf, refreshed);
|
|
40
65
|
return refreshed.accessToken;
|
|
41
66
|
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
async function capabilities(actor) {
|
|
45
|
-
const token = await accessToken(actor);
|
|
67
|
+
async function remoteTools(who) {
|
|
68
|
+
const token = await accessToken(who);
|
|
46
69
|
const remote = await deps.remote.list(portal(), token, AbortSignal.timeout(CallTimeoutMs));
|
|
47
70
|
if (remote.length > MaxTools) {
|
|
48
71
|
throw new IntelError(502, "tool_catalog_too_large", "The portal returned too many tools");
|
|
49
72
|
}
|
|
73
|
+
return remote;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* The servers the portal says this connection reaches, confirmed against its live tool list.
|
|
77
|
+
*
|
|
78
|
+
* ⚠️ Fails rather than answering "none" when the portal keeps no directory. An empty list would
|
|
79
|
+
* be indistinguishable from "you have no servers", and the difference decides whether a screen
|
|
80
|
+
* says "connect something" or "this portal cannot be delegated from".
|
|
81
|
+
*/
|
|
82
|
+
async function serverList(who, toolNames) {
|
|
83
|
+
if (!toolNames.includes(ServerDirectoryTool)) {
|
|
84
|
+
throw new IntelError(502, "tool_servers_unavailable", `The portal does not offer ${ServerDirectoryTool}, so its servers cannot be listed`);
|
|
85
|
+
}
|
|
86
|
+
const answer = await deps.remote.call({
|
|
87
|
+
url: portal(),
|
|
88
|
+
accessToken: await accessToken(who),
|
|
89
|
+
name: ServerDirectoryTool,
|
|
90
|
+
arguments: {},
|
|
91
|
+
signal: AbortSignal.timeout(CallTimeoutMs),
|
|
92
|
+
});
|
|
93
|
+
const directory = answer.isError ? null : toolServersFrom({ directory: answer, toolNames });
|
|
94
|
+
if (directory === null) {
|
|
95
|
+
throw new IntelError(502, "tool_servers_unavailable", "The portal did not answer with a server list this Intel can read");
|
|
96
|
+
}
|
|
97
|
+
return directory;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* One live tools/list is both the catalog and the authorization answer: the portal only returns
|
|
101
|
+
* what this user may reach. Nothing here is cached as a permission.
|
|
102
|
+
*
|
|
103
|
+
* ⚠️ For an agent the list is cut to the delegated servers, and the cut is made against the
|
|
104
|
+
* portal's own directory rather than against the tool names — `tool-servers.ts` says why the
|
|
105
|
+
* namespace alone is not enough to attribute a tool. The second return value is the attribution
|
|
106
|
+
* itself, so the audit below names the server the cut actually used rather than guessing again.
|
|
107
|
+
*/
|
|
108
|
+
async function capabilities(who) {
|
|
109
|
+
const delegated = who.delegation;
|
|
110
|
+
const serverOfTool = new Map();
|
|
111
|
+
if (delegatesNothing(who))
|
|
112
|
+
return { items: [], serverOfTool };
|
|
113
|
+
let remote = await remoteTools(who);
|
|
114
|
+
if (delegated) {
|
|
115
|
+
const directory = await serverList(who, remote.map((tool) => tool.name));
|
|
116
|
+
// ⚠️ Attributed against every identifier the portal DECLARED — including rows it marked
|
|
117
|
+
// disabled — and only then checked against the delegation. Attributing against the offerable
|
|
118
|
+
// subset (or against the delegation itself) would widen it: with `wiki` delegated and
|
|
119
|
+
// `wiki_extra` merely declared, `wiki_extra__read` starts with `wiki_` and would be handed
|
|
120
|
+
// over as a `wiki` tool. The longest match over the widest declared set is the only reading
|
|
121
|
+
// that no missing row can loosen.
|
|
122
|
+
const delegatedSet = new Set(delegated.servers);
|
|
123
|
+
// ⚠️ And the attribution is then required to land on a server that is still OFFERABLE — the
|
|
124
|
+
// narrow set, enabled and confirmed. Wide for attribution, narrow for permission: a portal
|
|
125
|
+
// that switches a server off takes it away from the agent here, which is revocation reaching
|
|
126
|
+
// an agent without anybody touching its definition (D30).
|
|
127
|
+
const offerable = new Set(directory.servers.map((server) => server.handle));
|
|
128
|
+
remote = remote.filter((tool) => {
|
|
129
|
+
const handle = serverOf(tool.name, directory.declared);
|
|
130
|
+
if (handle === null || !delegatedSet.has(handle) || !offerable.has(handle))
|
|
131
|
+
return false;
|
|
132
|
+
serverOfTool.set(tool.name, handle);
|
|
133
|
+
return true;
|
|
134
|
+
});
|
|
135
|
+
}
|
|
50
136
|
const items = [];
|
|
51
137
|
for (const tool of remote) {
|
|
52
138
|
items.push({
|
|
@@ -59,27 +145,63 @@ export function createTools(deps) {
|
|
|
59
145
|
}),
|
|
60
146
|
});
|
|
61
147
|
}
|
|
62
|
-
return items;
|
|
148
|
+
return { items, serverOfTool };
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* ⚠️ The refusal that costs nothing. A delegated caller naming a tool outside every delegated
|
|
152
|
+
* namespace is turned away here — before the token store, before `tools/list`, before the portal
|
|
153
|
+
* hears anything at all. The authoritative cut still happens in `capabilities`, against the
|
|
154
|
+
* portal's directory; this one exists so the portal is never touched on behalf of a call that was
|
|
155
|
+
* always going to be refused, and so the refusal can name the tool and the servers (D30).
|
|
156
|
+
*/
|
|
157
|
+
function requireDelegated(who, name) {
|
|
158
|
+
const delegated = who.delegation;
|
|
159
|
+
if (!delegated)
|
|
160
|
+
return;
|
|
161
|
+
if (delegatesNothing(who)) {
|
|
162
|
+
throw new IntelError(403, "tool_not_delegated", `${name} is not available: this agent has no delegated MCP servers`);
|
|
163
|
+
}
|
|
164
|
+
if (serverOf(name, delegated.servers) === null) {
|
|
165
|
+
throw new IntelError(403, "tool_not_delegated", `${name} is not part of this agent's delegated servers (${delegated.servers.join(", ")})`);
|
|
166
|
+
}
|
|
63
167
|
}
|
|
64
|
-
async function requireCapability(
|
|
65
|
-
const
|
|
168
|
+
async function requireCapability(who, name) {
|
|
169
|
+
const reachable = await capabilities(who);
|
|
170
|
+
const found = reachable.items.find((capability) => capability.name === name);
|
|
66
171
|
if (!found) {
|
|
67
172
|
throw new IntelError(404, "tool_not_available", `Tool ${name} is not available to you`);
|
|
68
173
|
}
|
|
69
|
-
return found;
|
|
174
|
+
return { capability: found, server: reachable.serverOfTool.get(name) };
|
|
70
175
|
}
|
|
71
|
-
async function call(
|
|
72
|
-
if (!actor.canExecute) {
|
|
176
|
+
async function call(who, input) {
|
|
177
|
+
if (!who.actor.canExecute) {
|
|
73
178
|
throw new IntelError(403, "tool_execute_forbidden", "Tool execution permission is required");
|
|
74
179
|
}
|
|
75
|
-
|
|
180
|
+
requireDelegated(who, input.name);
|
|
181
|
+
const { capability, server } = await requireCapability(who, input.name);
|
|
76
182
|
const validation = deps.validate(capability.inputSchema, input.arguments);
|
|
77
183
|
if (!validation.valid) {
|
|
78
184
|
throw new IntelError(400, "tool_arguments_invalid", validation.detail ?? "Invalid arguments");
|
|
79
185
|
}
|
|
186
|
+
// ⚠️ Written before the call, not after it: an audit that only records what succeeded is a
|
|
187
|
+
// record of the harmless half. Both principals are named — the agent that acted and the person
|
|
188
|
+
// whose connection carried it (D30) — and no argument is.
|
|
189
|
+
//
|
|
190
|
+
// ⚠️ The server is the one the CUT used, carried out of `capabilities`, not a second guess made
|
|
191
|
+
// against the delegated handles. Re-deriving it here would name `wiki` for a tool the cut
|
|
192
|
+
// attributed to `wiki_extra`, and an audit row that names the wrong system is worse than none.
|
|
193
|
+
if (who.delegation) {
|
|
194
|
+
await deps.audit({
|
|
195
|
+
agentId: who.delegation.agentId,
|
|
196
|
+
applicationId: who.actor.id,
|
|
197
|
+
delegatedBy: who.delegation.delegatedBy,
|
|
198
|
+
server: server ?? "",
|
|
199
|
+
tool: capability.name,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
80
202
|
const result = await deps.remote.call({
|
|
81
203
|
url: portal(),
|
|
82
|
-
accessToken: await accessToken(
|
|
204
|
+
accessToken: await accessToken(who),
|
|
83
205
|
name: capability.name,
|
|
84
206
|
arguments: input.arguments,
|
|
85
207
|
signal: AbortSignal.timeout(CallTimeoutMs),
|
|
@@ -89,41 +211,78 @@ export function createTools(deps) {
|
|
|
89
211
|
}
|
|
90
212
|
return result;
|
|
91
213
|
}
|
|
214
|
+
// A token that is gone or beyond renewal is the same answer as never having signed in, so reading
|
|
215
|
+
// a catalog reports it as a state. Only a portal that does not answer stays an error — the view
|
|
216
|
+
// has to tell "sign in again" apart from "the portal failed", and a 401 here would otherwise look
|
|
217
|
+
// like an expired Intel session to the browser.
|
|
218
|
+
function disconnected(error) {
|
|
219
|
+
return (error instanceof IntelError &&
|
|
220
|
+
(error.code === "portal_not_connected" || error.code === "portal_reconnect_required"));
|
|
221
|
+
}
|
|
92
222
|
return {
|
|
93
223
|
async catalog(actor) {
|
|
94
|
-
const
|
|
224
|
+
const who = await acting(actor);
|
|
225
|
+
if (delegatesNothing(who))
|
|
226
|
+
return { portalConnected: true, items: [] };
|
|
227
|
+
const stored = await deps.tokens.read(who.connectionOf);
|
|
95
228
|
// No portal sign-in yet is a normal state, not an error: the browser answers it by running
|
|
96
|
-
// the silent sign-in and asking again (#60).
|
|
229
|
+
// the silent sign-in and asking again (#60). For an agent it is the ordinary shape of
|
|
230
|
+
// revocation — the delegator disconnected, so the agent reaches nothing (D30).
|
|
231
|
+
if (!stored)
|
|
232
|
+
return { portalConnected: false, items: [] };
|
|
233
|
+
try {
|
|
234
|
+
return { portalConnected: true, items: (await capabilities(who)).items };
|
|
235
|
+
}
|
|
236
|
+
catch (error) {
|
|
237
|
+
if (disconnected(error))
|
|
238
|
+
return { portalConnected: false, items: [] };
|
|
239
|
+
throw error;
|
|
240
|
+
}
|
|
241
|
+
},
|
|
242
|
+
async servers(actor) {
|
|
243
|
+
const who = await acting(actor);
|
|
244
|
+
if (delegatesNothing(who))
|
|
245
|
+
return { portalConnected: true, items: [] };
|
|
246
|
+
const stored = await deps.tokens.read(who.connectionOf);
|
|
97
247
|
if (!stored)
|
|
98
248
|
return { portalConnected: false, items: [] };
|
|
99
249
|
try {
|
|
100
|
-
|
|
250
|
+
const remote = await remoteTools(who);
|
|
251
|
+
const { servers } = await serverList(who, remote.map((tool) => tool.name));
|
|
252
|
+
// ⚠️ An agent sees its own delegation, never the delegator's whole shelf. Its catalog is cut
|
|
253
|
+
// anyway, so the uncut list would grant nothing — it would only tell an agent, and through
|
|
254
|
+
// it a model, which other systems the person it acts for is connected to.
|
|
255
|
+
const delegated = who.delegation;
|
|
256
|
+
return {
|
|
257
|
+
portalConnected: true,
|
|
258
|
+
items: delegated
|
|
259
|
+
? servers.filter((server) => delegated.servers.includes(server.handle))
|
|
260
|
+
: servers,
|
|
261
|
+
};
|
|
101
262
|
}
|
|
102
263
|
catch (error) {
|
|
103
|
-
|
|
104
|
-
// reading the catalog reports it as a state. Only a portal that does not answer stays an
|
|
105
|
-
// error — the view has to tell "sign in again" apart from "the portal failed", and a 401
|
|
106
|
-
// here would otherwise look like an expired Intel session to the browser.
|
|
107
|
-
if (error instanceof IntelError &&
|
|
108
|
-
(error.code === "portal_not_connected" || error.code === "portal_reconnect_required")) {
|
|
264
|
+
if (disconnected(error))
|
|
109
265
|
return { portalConnected: false, items: [] };
|
|
110
|
-
}
|
|
111
266
|
throw error;
|
|
112
267
|
}
|
|
113
268
|
},
|
|
114
|
-
execute: async (actor, input) => await call(actor, input),
|
|
269
|
+
execute: async (actor, input) => await call(await acting(actor), input),
|
|
115
270
|
async test(actor, input) {
|
|
116
|
-
const
|
|
271
|
+
const who = await acting(actor);
|
|
272
|
+
requireDelegated(who, input.name);
|
|
273
|
+
const { capability } = await requireCapability(who, input.name);
|
|
117
274
|
if (capability.annotations.readOnlyHint !== true ||
|
|
118
275
|
capability.annotations.destructiveHint === true) {
|
|
119
276
|
throw new IntelError(409, "tool_test_unsafe", "Only explicitly read-only, non-destructive tools can run in the test surface");
|
|
120
277
|
}
|
|
121
|
-
return await call(
|
|
278
|
+
return await call(who, input);
|
|
122
279
|
},
|
|
123
280
|
async unavailable(actor, names) {
|
|
124
281
|
if (names.length === 0)
|
|
125
282
|
return [];
|
|
126
|
-
const
|
|
283
|
+
const who = await acting(actor);
|
|
284
|
+
const reachable = await capabilities(who);
|
|
285
|
+
const available = new Set(reachable.items.map((capability) => capability.name));
|
|
127
286
|
return names.filter((name) => !available.has(name));
|
|
128
287
|
},
|
|
129
288
|
};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ExecuteToolInput, TestToolInput, ToolCapability, ToolCatalog, ToolTestResult } from "@anchrd/intel-contract";
|
|
1
|
+
import type { ExecuteToolInput, TestToolInput, ToolCapability, ToolCatalog, ToolServerCatalog, ToolTestResult } from "@anchrd/intel-contract";
|
|
2
2
|
export interface ToolActor {
|
|
3
3
|
id: string;
|
|
4
4
|
email: string;
|
|
@@ -35,6 +35,25 @@ export interface RemoteTools {
|
|
|
35
35
|
signal: AbortSignal;
|
|
36
36
|
}): Promise<ToolTestResult>;
|
|
37
37
|
}
|
|
38
|
+
/**
|
|
39
|
+
* What an agent's definition delegates, resolved from the Gate Application the caller authenticated
|
|
40
|
+
* as (D30). `null` from the port means "this caller is not an agent" — the ordinary user path.
|
|
41
|
+
*
|
|
42
|
+
* ⚠️ Read fresh on every call and never cached as a permission. It is the same rule the catalog
|
|
43
|
+
* follows: the answer has to be able to change between two runs without anybody editing anything.
|
|
44
|
+
*/
|
|
45
|
+
export interface ToolDelegation {
|
|
46
|
+
agentId: string;
|
|
47
|
+
delegatedBy: string;
|
|
48
|
+
servers: string[];
|
|
49
|
+
}
|
|
50
|
+
export interface ToolAuditEvent {
|
|
51
|
+
agentId: string;
|
|
52
|
+
applicationId: string;
|
|
53
|
+
delegatedBy: string;
|
|
54
|
+
server: string;
|
|
55
|
+
tool: string;
|
|
56
|
+
}
|
|
38
57
|
export interface ToolDeps {
|
|
39
58
|
portalUrl: string | null;
|
|
40
59
|
remote: RemoteTools;
|
|
@@ -47,9 +66,12 @@ export interface ToolDeps {
|
|
|
47
66
|
valid: boolean;
|
|
48
67
|
detail?: string;
|
|
49
68
|
};
|
|
69
|
+
delegation(applicationId: string): Promise<ToolDelegation | null>;
|
|
70
|
+
audit(event: ToolAuditEvent): Promise<void>;
|
|
50
71
|
}
|
|
51
72
|
export interface ToolService {
|
|
52
73
|
catalog(actor: ToolActor): Promise<ToolCatalog>;
|
|
74
|
+
servers(actor: ToolActor): Promise<ToolServerCatalog>;
|
|
53
75
|
execute(actor: ToolActor, input: ExecuteToolInput): Promise<ToolTestResult>;
|
|
54
76
|
test(actor: ToolActor, input: TestToolInput): Promise<ToolTestResult>;
|
|
55
77
|
unavailable(actor: ToolActor, names: string[]): Promise<string[]>;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
-- #208 (D30): an agent's tools are whole MCP servers its owner delegated from the owner's own
|
|
2
|
+
-- portal connection. Nothing about that selection is stored here — it lives in the agent's
|
|
3
|
+
-- definition, versioned in R2 like the rest of the document.
|
|
4
|
+
--
|
|
5
|
+
-- What this file adds is the one lookup the run path needs and `0014` did not provide: a call
|
|
6
|
+
-- arrives authenticated as a Gate Application, and Intel has to answer "which agent is that"
|
|
7
|
+
-- before it may read anybody's portal token. `0014` indexed `node_id` only (it is the primary
|
|
8
|
+
-- key), so the reverse question was a table scan on every delegated tool call.
|
|
9
|
+
--
|
|
10
|
+
-- ⚠️ UNIQUE, not just an index. Two agents sharing one Application would make the lookup ambiguous
|
|
11
|
+
-- and would hand one agent the other's delegation — the same class of hole `AGENT_APPLICATION_KEYS`
|
|
12
|
+
-- refuses a doubly listed id for (D27). The constraint is what makes "one Application per agent"
|
|
13
|
+
-- a fact of the database rather than a promise of the code that writes it.
|
|
14
|
+
CREATE UNIQUE INDEX agent_applications_application_idx
|
|
15
|
+
ON agent_applications(application_id);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@anchrd/intel-api",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"repository": {
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
45
|
"@anchrd/gate-sdk": "^0.7.0",
|
|
46
|
-
"@anchrd/intel-contract": "^0.
|
|
46
|
+
"@anchrd/intel-contract": "^0.7.0",
|
|
47
47
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
48
48
|
"ajv": "^8.20.0",
|
|
49
49
|
"fflate": "^0.8.3",
|