Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions packages/aitm/docs/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,25 @@ Per-role allowlists scope which servers reach which subagent. Useful for sandbox

Unlisted servers default to all roles. Optional — most users skip this.

## Deferred tool loading

Mounting a large MCP registry (a GitHub, browser, or database server can expose dozens of tools) would serialize every tool's full JSON schema into every request, on every step — burning context window and per-step cost on schemas the subagent never calls. `mcpDeferToolsOver` bounds that: once a role's MCP tools (beyond the fixed local slots like `readFile`/`bash`) exceed the threshold, the surplus is **deferred** — presented name-only in the system prompt, with schemas absent from requests until fetched.

```jsonc
{
"mcpServers": { /* ... */ },
"mcpDeferToolsOver": 20 // default 20; 0 = always defer surplus tools
}
```

At or below the threshold, surplus tools mount directly (full schema) — identical to plain mounting. Above it:

- The system prompt carries a name-only index (`<name>: <first sentence>` per tool) plus a fetch-before-call contract.
- A `tool_search` tool is mounted. `select:mcp__server__tool,…` fetches those exact tools by name; any other query is keyword-ranked over names + descriptions (capped by `max_results`, default 5). Each match's full schema is returned and the tool becomes callable on subsequent steps.
- Calling a deferred tool before fetching its schema returns a typed "fetch it via `tool_search` first" result — never a provider-level error.

Activation is scoped to one subagent invocation; a fresh invocation starts fully deferred again. Applies to the **Worker** and **Reviewer** surfaces (the Planner keeps its read-only trio; the CI-fix session keeps its fixed record). Config-only, resolved project > global. See `src/mcp/tool-search.ts` and `src/loop/run-loop-adapter.ts`.

## Lifecycle

`McpClientManager` (`src/mcp/mcp-client.ts`) owns the lifecycle:
Expand Down
18 changes: 18 additions & 0 deletions packages/aitm/src/config/config-loader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,13 +58,31 @@ test('resolve: uses built-in defaults when only env key is set', async () => {
assert.equal(resolved.logLevel, 'info');
assert.equal(resolved.concurrency, 1);
assert.equal(resolved.allowForcePush, true);
assert.equal(resolved.mcpDeferToolsOver, 20);
assert.deepEqual(resolved.models, DEFAULT_MODELS);
} finally {
await home.cleanup();
await cwd.cleanup();
}
});

test('resolve: mcpDeferToolsOver is project over global, and a configured 0 is honored (issue #119)', async () => {
const home = await tempDir('aitm-home-');
const cwd = await tempDir('aitm-cwd-');
try {
await writeGlobalConfig(home.path, { mcpDeferToolsOver: 50 });
const loader = new ConfigLoader(cwd.path, home.path, { OPENROUTER_API_KEY: 'sk-env' });
assert.equal((await loader.resolve({})).mcpDeferToolsOver, 50, 'global applies');

// 0 means "always defer" — pick() must treat it as set, not fall through to the default.
await writeProjectConfig(cwd.path, { mcpDeferToolsOver: 0 });
assert.equal((await loader.resolve({})).mcpDeferToolsOver, 0, 'project 0 wins over global');
} finally {
await home.cleanup();
await cwd.cleanup();
}
});

test('resolve: formatCommand is read from project, then global (issue #48)', async () => {
const home = await tempDir('aitm-home-');
const cwd = await tempDir('aitm-cwd-');
Expand Down
11 changes: 11 additions & 0 deletions packages/aitm/src/config/config-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { ZodError, z } from 'zod';
import { DEFAULT_MODELS } from '../credentials/defaults.ts';
import { atomicWrite } from '../fs/atomic-write.ts';
import { DEFAULT_MAX_CI_FIX_ATTEMPTS } from '../loop/constants.ts';
import { DEFAULT_MCP_DEFER_TOOLS_OVER } from '../mcp/mcp-client.ts';
import { type McpServers, McpServersSchema } from '../mcp/schema.ts';
import { DEFAULT_LLM_STEP_TIMEOUT_MS } from '../subagents/factory.ts';
import {
Expand Down Expand Up @@ -55,6 +56,7 @@ const KNOWN_KEYS = new Set<string>([
'reasoningEffort',
'mcpServers',
'mcpRoleAllowlist',
'mcpDeferToolsOver',
'hooks',
]);

Expand Down Expand Up @@ -85,6 +87,7 @@ const DEFAULTS = {
logLevel: 'info' as const,
concurrency: 1,
allowForcePush: true,
mcpDeferToolsOver: DEFAULT_MCP_DEFER_TOOLS_OVER,
};

type WarnFn = (msg: string) => void;
Expand Down Expand Up @@ -249,6 +252,14 @@ export class ConfigLoader {
...((project?.mcpRoleAllowlist ?? global?.mcpRoleAllowlist)
? { mcpRoleAllowlist: project?.mcpRoleAllowlist ?? global?.mcpRoleAllowlist }
: {}),
// Defer-tools threshold — aitm config only, project over global (issue #119). pick() treats a
// configured 0 as set (!== undefined), so "always defer" survives the default.
mcpDeferToolsOver: pick(
undefined,
project?.mcpDeferToolsOver,
global?.mcpDeferToolsOver,
DEFAULTS.mcpDeferToolsOver,
),
// Tool-registry hooks (issue #121). Hooks run shell commands with the operator's privileges, so
// they are honored ONLY from the user-owned global config (~/.aitm.json) — NEVER from the
// per-repo project config, which an untrusted repo could ship (CR: arbitrary code execution). A
Expand Down
7 changes: 7 additions & 0 deletions packages/aitm/src/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,10 @@ export const ConfigFileSchema = z
// patterns. aitm-config-only (project > global); the Claude Code interop sources contribute
// mcpServers alone. See src/mcp/schema.ts and src/mcp/mcp-client.ts.
mcpRoleAllowlist: McpRoleAllowlistSchema.optional(),
// Defer a role's MCP tools to name-only stubs + `tool_search` once their count exceeds this
// (issue #119), keeping their JSON schemas out of every request. Default 20; 0 = always defer.
// aitm-config-only (project > global). See src/mcp/mcp-client.ts and src/mcp/tool-search.ts.
mcpDeferToolsOver: z.number().int().min(0).optional(),
// PreToolUse/PostToolUse shell hooks on the tool registry (issue #121). Hooks run shell commands
// with operator privileges, so they are honored ONLY from the user-owned global config
// (~/.aitm.json); the same key in a repo-shippable project config is parsed but ignored + warned.
Expand Down Expand Up @@ -256,6 +260,9 @@ export type ResolvedConfig = {
// Per-role MCP allowlist (issue #115), aitm-config-only (project > global). Undefined → every role
// gets every connected server. Passed into McpClientManager.
mcpRoleAllowlist?: import('../mcp/schema.ts').McpRoleAllowlist | undefined;
// Threshold above which a role's MCP tools are deferred (issue #119). Default
// DEFAULT_MCP_DEFER_TOOLS_OVER; 0 = always defer. Passed into McpClientManager.
mcpDeferToolsOver: number;
// Tool-registry hooks (issue #121). Global config only (~/.aitm.json) — project hooks are ignored
// as a code-execution trust boundary. Undefined → no hooks; behavior unchanged. Applied over the
// resolved tool records in run-loop-adapter via withHooks.
Expand Down
115 changes: 114 additions & 1 deletion packages/aitm/src/loop/run-loop-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,22 +12,27 @@ import { test } from 'node:test';
import {
AUTONOMY_CONTRACT_TEXT,
COMMUNICATION_CONTRACT_TEXT,
SUBMIT_TOOL_NAME,
SYSTEM_REMINDER_CONTRACT,
} from '@developerz.ai/ai-claude-compat';
import type { ToolSet } from 'ai';
import { tool } from 'ai';
import { MockLanguageModelV3 } from 'ai/test';
import { z } from 'zod';
import type { RunLoopInput } from '../cli/commands.ts';
import { Credentials } from '../credentials/credentials.ts';
import { GitHubClient } from '../github/github-client.ts';
import type { PullRequest, ReviewThread } from '../github/schema.ts';
import { McpClientManager } from '../mcp/mcp-client.ts';
import { TOOL_SEARCH_TOOL_NAME } from '../mcp/tool-search.ts';
import type { Plan } from '../plan/schema.ts';
import type { PrGroup, RunState } from '../state/schema.ts';
import { StateStore } from '../state/state-store.ts';
import type { ReviewerResult } from '../subagents/reviewer.ts';
import type { WorkerDelivery, WorkerResult } from '../subagents/worker.ts';
import {
type AdapterStatePort,
activeToolNames,
applyHooks,
branchFor,
createRollingContextAccumulator,
Expand All @@ -38,6 +43,7 @@ import {
localEditTools,
localReadTools,
mcpTool,
mountDeferredTools,
type PlanGroupsOutcome,
persistRollingContext,
planToPrGroups,
Expand Down Expand Up @@ -713,7 +719,10 @@ test('defaultMakeOrchestrator constructs the Compactor and wires it into the sta
},
modelIdForCapability: () => 'openai/gpt-5',
};
const mcp = { toolsForRole: () => ({}) };
const mcp = {
toolsForRole: () => ({}),
toolSurfaceForRole: () => ({ direct: {}, deferred: {} }),
};
const input = {
cwd: '/tmp/adapter-compaction',
resolved: { openrouterApiKey: 'sk-or-test', maxSessions: null },
Expand Down Expand Up @@ -971,3 +980,107 @@ test('resolvePlannerTools mounts explore only when the caller wires it', () => {
// The Planner never gets a memory tool — it reads memory files directly (issue #118).
assert.equal('memory' in withExplore, false, 'planner has no memory tool');
});

// ---- deferred MCP tool loading (issue #119) ----

function mcpFake(desc: string): ToolSet[string] {
return { description: desc, inputSchema: { type: 'object' } } as ToolSet[string];
}

test('mountDeferredTools: below threshold (nothing deferred) mounts surplus direct, no tool_search (issue #119)', () => {
const mount = mountDeferredTools({
direct: { mcp__gh__create_issue: mcpFake('Create an issue.') },
deferred: {},
});
assert.deepEqual(Object.keys(mount.extraTools), ['mcp__gh__create_issue']);
assert.equal(mount.indexBlock, '');
assert.equal(mount.activated, null);
assert.equal(mount.deferredNames.size, 0);
assert.equal(
TOOL_SEARCH_TOOL_NAME in mount.extraTools,
false,
'no tool_search when nothing deferred',
);
});

test('mountDeferredTools: above threshold defers surplus behind tool_search + a name-only index (issue #119)', () => {
const mount = mountDeferredTools({
direct: {},
deferred: {
mcp__gh__create_issue: mcpFake('Create an issue.'),
mcp__db__query: mcpFake('Query the DB.'),
},
});
assert.ok(TOOL_SEARCH_TOOL_NAME in mount.extraTools, 'tool_search mounted');
assert.ok(
'mcp__gh__create_issue' in mount.extraTools,
'deferred tool guard-wrapped into the record',
);
assert.ok('mcp__db__query' in mount.extraTools);
assert.match(mount.indexBlock, /mcp__gh__create_issue: Create an issue\./);
assert.notEqual(mount.activated, null);
assert.deepEqual([...mount.deferredNames].sort(), ['mcp__db__query', 'mcp__gh__create_issue']);
});

test('mountDeferredTools: fixed-slot-named MCP tools are not surplus — excluded from the mount (issue #119)', () => {
const mount = mountDeferredTools({
direct: {},
deferred: { mcp__fs__readFile: mcpFake('read'), mcp__gh__x: mcpFake('x') },
});
// readFile is a fixed slot (partial-filled elsewhere) → not deferred here; only true surplus is.
assert.deepEqual([...mount.deferredNames], ['mcp__gh__x']);
assert.equal('mcp__fs__readFile' in mount.extraTools, false);
});

test('activeToolNames: hides un-activated deferred tools, always keeps submit + non-deferred (issue #119)', () => {
const tools: ToolSet = {
readFile: mcpFake('r'),
mcp__gh__x: mcpFake('x'),
[TOOL_SEARCH_TOOL_NAME]: mcpFake('search'),
};
const deferredNames = new Set(['mcp__gh__x']);
const before = activeToolNames(tools, deferredNames, new Set());
assert.equal(before.includes('mcp__gh__x'), false, 'deferred tool inactive until fetched');
assert.ok(before.includes('readFile') && before.includes(TOOL_SEARCH_TOOL_NAME));
assert.ok(before.includes(SUBMIT_TOOL_NAME), 'submit always active');
const after = activeToolNames(tools, deferredNames, new Set(['mcp__gh__x']));
assert.ok(after.includes('mcp__gh__x'), 'an activated deferred tool becomes active');
});

test('deferred loading end-to-end: an over-threshold MCP server surfaces name-only + tool_search on the Worker (issue #119)', async () => {
const surplus: ToolSet = {
create_issue: mcpFake('Create a GitHub issue.'),
list_prs: mcpFake('List PRs.'),
};
const mcp = new McpClientManager({
servers: { gh: { command: 'gh-mcp' } },
deferToolsOver: 1, // 2 surplus tools > 1 → deferred
createClient: (async () =>
({ tools: async () => surplus, close: async () => {} }) as never) as never,
});
await mcp.connectAll();
const mount = mountDeferredTools(mcp.toolSurfaceForRole('worker'));
// resolveWorkerTools fills the fixed slots (local, since the server supplies none); the surplus is
// added by the mount — proving tools beyond the fixed slots now reach the Worker (dropped pre-#119).
const workerTools: ToolSet = {
...resolveWorkerTools(mcp.toolsForRole('worker'), '/tmp/wt'),
...mount.extraTools,
};
assert.ok(TOOL_SEARCH_TOOL_NAME in workerTools, 'tool_search reaches the Worker');
assert.ok(
'mcp__gh__create_issue' in workerTools,
'surplus tools reach the Worker (were dropped before #119)',
);
assert.ok('readFile' in workerTools, 'fixed slots still present');
const active = activeToolNames(workerTools, mount.deferredNames, mount.activated ?? new Set());
assert.equal(
active.includes('mcp__gh__create_issue'),
false,
'deferred schema absent from active tools until fetched',
);
assert.ok(
active.includes('readFile') && active.includes(SUBMIT_TOOL_NAME),
'fixed slots + submit stay active',
);
await mcp.close();
});
Loading
Loading