Skip to content

Commit dc896cc

Browse files
feat(ai): tool-level MCP capability-gating mechanism (#13745) (#13747)
Agent profiles select MCP servers all-or-nothing (Agent.servers); the Loop exposes every tool of every connected client to the model. Add an optional per-server `allowedTools` allowlist so a profile can expose only a subset of a server's tools — e.g. limiting a local lower-parameter worker to a single trap endpoint — enforced at tool-assembly via the pure resolveAllowedTools filter. Default null = no filtering (backward-compatible). Slice of #9980 (the tier-to-tool policy matrix stays open for design-convergence); this is the enabling mechanism only.
1 parent f79c86d commit dc896cc

4 files changed

Lines changed: 138 additions & 18 deletions

File tree

ai/Agent.mjs

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,16 @@ class Agent extends Base {
4646
* @member {String[]} servers=[]
4747
*/
4848
servers: [],
49+
/**
50+
* Optional per-server tool allowlist for least-privilege capability gating. Shape:
51+
* `{[serverName]: String[]}` — restricts a connected server to the named tools (e.g. limiting a
52+
* local lower-parameter worker to `{'github-workflow': ['signal_state_transition']}` so it cannot
53+
* initiate destructive operations it lacks the cognitive architecture to navigate). A server absent
54+
* from the map keeps its full surface; `null` disables filtering entirely (the backward-compatible
55+
* default). Enforced at tool-assembly via {@link Neo.ai.agent.resolveAllowedTools}.
56+
* @member {Object|null} allowedTools=null
57+
*/
58+
allowedTools: null,
4959
/**
5060
* Registered sub-agent profiles available for delegation.
5161
* @member {Object} subAgents
@@ -132,9 +142,10 @@ ALWAYS use your file system or knowledge base tools to read the relevant source
132142

133143
// Create the Loop
134144
this.loop = Neo.create(Loop, {
135-
agent: this,
145+
agent : this,
146+
allowedTools: this.allowedTools,
136147
assembler,
137-
clients: this.clients,
148+
clients : this.clients,
138149
provider,
139150
scheduler
140151
});
@@ -217,7 +228,7 @@ ALWAYS use your file system or knowledge base tools to read the relevant source
217228

218229
subAgent = Neo.create(ProfileClass);
219230
await subAgent.ready();
220-
231+
221232
this.activeSubAgents[profileName] = subAgent;
222233
this.subAgentTurns[profileName] = 0;
223234
} else {
@@ -226,7 +237,7 @@ ALWAYS use your file system or knowledge base tools to read the relevant source
226237

227238
try {
228239
this.subAgentTurns[profileName]++;
229-
240+
230241
const result = await subAgent.loop.processEvent({
231242
type: 'delegate',
232243
data: request,

ai/agent/Loop.mjs

Lines changed: 29 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
import Assembler from '../context/Assembler.mjs';
2-
import Base from '../../src/core/Base.mjs';
3-
import ClassSystemUtil from '../../src/util/ClassSystem.mjs';
4-
import Provider from '../provider/Base.mjs';
5-
import * as SDK from '../services.mjs';
1+
import Assembler from '../context/Assembler.mjs';
2+
import Base from '../../src/core/Base.mjs';
3+
import ClassSystemUtil from '../../src/util/ClassSystem.mjs';
4+
import Provider from '../provider/Base.mjs';
5+
import * as SDK from '../services.mjs';
6+
import {resolveAllowedTools} from './resolveAllowedTools.mjs';
67

78
/**
89
* The cognitive event loop for the Agent.
@@ -20,6 +21,15 @@ class Loop extends Base {
2021
* @protected
2122
*/
2223
className: 'Neo.ai.agent.Loop',
24+
/**
25+
* Optional per-server tool allowlist injected by the Agent for capability gating. Shape
26+
* `{[serverName]: String[]}`, or null for no filtering. Applied at tool-assembly so only the
27+
* permitted subset of each connected server's tools is exposed to the model — and only those are
28+
* registered in `toolRegistry`, so a gated-out tool is not even routable.
29+
* See {@link Neo.ai.agent.resolveAllowedTools}.
30+
* @member {Object|null} allowedTools=null
31+
*/
32+
allowedTools: null,
2333
/**
2434
* The Context Assembler instance.
2535
* @member {Neo.ai.context.Assembler|Object} assembler_=null
@@ -193,7 +203,12 @@ class Loop extends Base {
193203

194204
for (const [clientName, client] of Object.entries(this.clients)) {
195205
try {
196-
const clientTools = await client.listTools();
206+
// Least-privilege: expose only the profile-permitted subset of this server's tools.
207+
const clientTools = resolveAllowedTools({
208+
tools : await client.listTools(),
209+
allowedTools: this.allowedTools,
210+
serverName : client.serverName
211+
});
197212
for (const tool of clientTools) {
198213
this.tools.push(tool);
199214
this.toolRegistry[tool.name] = { client, clientName, method: Neo.camel(tool.name) };
@@ -356,7 +371,7 @@ class Loop extends Base {
356371
// Allow up to 10 iterations of tool chaining
357372
for (let i = 0; i < 10; i++) {
358373
const result = await this.provider.generate(messages, { tools: this.tools });
359-
374+
360375
if (result.content) {
361376
console.log(`[Loop] Model Response:\n${result.content}`);
362377
}
@@ -365,21 +380,21 @@ class Loop extends Base {
365380
if (result.toolCalls && result.toolCalls.length > 0) {
366381
this.state = 'acting';
367382
actionResult = await this.executeTools(result.toolCalls);
368-
383+
369384
// Push the model's textual response (if any)
370385
messages.push({
371386
role : 'model',
372387
content: result.content || `(Delegated to tools: ${result.toolCalls.map(t => t.function.name).join(', ')})`
373388
});
374-
389+
375390
// Format tool results as a user observation for the next LLM turn
376391
const toolResponsesStr = actionResult.map(r => `[Tool Result: ${r.name}]\n${r.error || JSON.stringify(r.result)}`).join('\n\n');
377-
392+
378393
messages.push({
379394
role : 'user',
380395
content: `Observation from tools:\n${toolResponsesStr}\n\nPlease proceed based on the above results.`
381396
});
382-
397+
383398
this.state = 'thinking'; // Resume reasoning
384399
} else {
385400
// No further tool calls -> Final answer achieved
@@ -445,12 +460,12 @@ class Loop extends Base {
445460

446461
if (success) {
447462
console.log(`[Loop] ✓ Cycle succeeded for ${event.type}`);
448-
463+
449464
if (event.type === 'user:input' || event.type === 'delegate') {
450465
const agentName = this.agent?.constructor?.config?.className?.split('.').pop() || 'unknown';
451466
const modelName = this.provider?.model || this.provider?.modelName || 'unknown';
452467
const sessionId = this.agent?.sessionId || 'default-session';
453-
468+
454469
await SDK.Memory_Service.addMemory({
455470
prompt: event.data,
456471
thought: decision.thought || 'Internal reflection',
@@ -486,7 +501,7 @@ class Loop extends Base {
486501
// Route to injected MCP client
487502
const entry = this.toolRegistry[name];
488503
console.log(`[Loop] Routing tool '${name}' to MCP Server '${entry.clientName}'`);
489-
504+
490505
const res = await entry.client.callTool(name, args);
491506
results.push({ name, result: res });
492507
} else if (name === 'delegate_task') {

ai/agent/resolveAllowedTools.mjs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/**
2+
* @summary Pure least-privilege filter for an agent profile's MCP tool surface — the capability-gating mechanism.
3+
*
4+
* `Agent.servers` selects MCP servers all-or-nothing: `Loop.initAsync` exposes every tool of every connected
5+
* client to the model. That is fine for a frontier model, but over-exposes a local lower-parameter worker (e.g.
6+
* a Gemma Librarian) to an API surface it cannot navigate safely — the risk of destructive CLI/GraphQL
7+
* loops. This function lets a profile additionally restrict a connected server to an explicit subset of its
8+
* tools (e.g. a worker limited to the `signal_state_transition` trap endpoint) without forking the server.
9+
*
10+
* Pure and side-effect-free so it is testable without standing up a live MCP client; the caller (the Loop's
11+
* tool-assembly) applies it per connected server. It is policy-agnostic: the tier→tool *matrix* (which tools a
12+
* model tier gets) is the caller's to define — this primitive only enforces whatever allowlist it is handed.
13+
*
14+
* Default is fail-open per server, so the mechanism is backward-compatible and opt-in:
15+
* - `allowedTools` null/undefined → no filtering anywhere (existing profiles keep their full surface).
16+
* - a server **absent** from a non-null `allowedTools` map → that server's full surface (only the servers you
17+
* name are constrained, so forgetting one is not a silent capability loss).
18+
* - a server **present** in the map → only its listed tools (an explicit empty list `[]` denies all of them).
19+
*
20+
* @param {Object} options
21+
* @param {Object[]} options.tools The full tool list from one server (each a `{name, ...}` schema).
22+
* @param {Object|null} [options.allowedTools=null] Per-server allowlist `{[serverName]: String[]}`, or null for none.
23+
* @param {String} options.serverName The raw server name (matches the `Agent.servers` keys, e.g. 'github-workflow').
24+
* @returns {Object[]} The permitted subset of `tools` — a new array; the input is never mutated.
25+
*/
26+
export function resolveAllowedTools({tools, allowedTools = null, serverName}) {
27+
if (!allowedTools || !Object.hasOwn(allowedTools, serverName)) {
28+
return tools;
29+
}
30+
31+
const allow = new Set(allowedTools[serverName]);
32+
33+
return tools.filter(tool => allow.has(tool.name));
34+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import {test, expect} from '@playwright/test';
2+
import {resolveAllowedTools} from '../../../../ai/agent/resolveAllowedTools.mjs';
3+
4+
// Pure function — imported directly (no live MCP client), so each case is fully isolated. Tool fixtures
5+
// mirror a real github-workflow surface where most tools are dangerous and only one is a safe trap endpoint.
6+
const tools = [
7+
{name: 'signal_state_transition', inputSchema: {}},
8+
{name: 'create_issue', inputSchema: {}},
9+
{name: 'manage_issue_labels', inputSchema: {}},
10+
{name: 'sync_all', inputSchema: {}}
11+
];
12+
13+
test.describe('resolveAllowedTools (#9980 MCP tool-level capability gating)', () => {
14+
test('returns the full surface when allowedTools is absent (backward-compatible default)', () => {
15+
expect(resolveAllowedTools({tools, serverName: 'github-workflow'})).toEqual(tools);
16+
expect(resolveAllowedTools({tools, allowedTools: null, serverName: 'github-workflow'})).toEqual(tools);
17+
expect(resolveAllowedTools({tools, allowedTools: undefined, serverName: 'github-workflow'})).toEqual(tools);
18+
});
19+
20+
test('returns the full surface for a server absent from a non-null map (per-server opt-in)', () => {
21+
// only knowledge-base is constrained; github-workflow is unnamed, so it keeps its full surface
22+
const allowedTools = {'knowledge-base': ['ask_knowledge_base']};
23+
expect(resolveAllowedTools({tools, allowedTools, serverName: 'github-workflow'})).toEqual(tools);
24+
});
25+
26+
test('restricts a named server to its allowlisted subset (the trap-endpoint case)', () => {
27+
const allowedTools = {'github-workflow': ['signal_state_transition']};
28+
const result = resolveAllowedTools({tools, allowedTools, serverName: 'github-workflow'});
29+
expect(result.map(t => t.name)).toEqual(['signal_state_transition']);
30+
});
31+
32+
test('preserves allowlist multiplicity and original order, not the allowlist order', () => {
33+
const allowedTools = {'github-workflow': ['sync_all', 'signal_state_transition']};
34+
const result = resolveAllowedTools({tools, allowedTools, serverName: 'github-workflow'});
35+
// filtered from the source list, so source order wins
36+
expect(result.map(t => t.name)).toEqual(['signal_state_transition', 'sync_all']);
37+
});
38+
39+
test('an explicit empty allowlist denies every tool from that server', () => {
40+
const allowedTools = {'github-workflow': []};
41+
expect(resolveAllowedTools({tools, allowedTools, serverName: 'github-workflow'})).toEqual([]);
42+
});
43+
44+
test('an allowlisted name that the server does not expose is simply omitted (no throw)', () => {
45+
const allowedTools = {'github-workflow': ['signal_state_transition', 'nonexistent_tool']};
46+
const result = resolveAllowedTools({tools, allowedTools, serverName: 'github-workflow'});
47+
expect(result.map(t => t.name)).toEqual(['signal_state_transition']);
48+
});
49+
50+
test('never mutates the input tool list (returns a new array)', () => {
51+
const allowedTools = {'github-workflow': ['signal_state_transition']};
52+
const result = resolveAllowedTools({tools, allowedTools, serverName: 'github-workflow'});
53+
expect(result).not.toBe(tools);
54+
expect(tools).toHaveLength(4); // source untouched
55+
});
56+
57+
test('an empty source surface resolves to empty regardless of allowlist', () => {
58+
expect(resolveAllowedTools({tools: [], allowedTools: {'github-workflow': ['x']}, serverName: 'github-workflow'})).toEqual([]);
59+
});
60+
});

0 commit comments

Comments
 (0)