Skip to content

Commit c54675e

Browse files
committed
feat(core): apply session tool and MCP selections to the running agent
`agent_config.tools` and `agent_config.mcp_servers` were accepted by the profile route and then dropped. They now persist to session metadata and reach `ToolManager`. The two fields merge independently, so a patch that supplies only MCP servers keeps the current builtin tool selection instead of stripping it. An empty array still clears its own half. `SessionService.update` resumes an inactive session first, and the selection is applied in one `setActiveTools` call so the replay record stays complete. MCP server names go through `mcpServerToolPattern`, which sanitizes the name the same way qualified tool names are built, so a server called `My Search` matches its own tools.
1 parent f97b801 commit c54675e

11 files changed

Lines changed: 419 additions & 2 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@pymodel/agent-core": minor
3+
"@pymodel/server": minor
4+
---
5+
6+
Make `agent_config.tools` and `agent_config.mcp_servers` reach the running agent. A session profile update now persists the selection, merges each field independently so supplying one half does not clear the other, resumes an inactive session before the mutation, and applies the result through a single `setActiveTools` call. MCP server names are turned into tool patterns with the shared naming helper, so a server whose name needs sanitizing still matches its tools.

packages/agent-core/src/agent/tool/index.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,16 @@ export class ToolManager {
349349
this.mcpAccessPatterns = names.filter((name) => isMcpToolName(name));
350350
}
351351

352+
patchActiveTools(input: {
353+
readonly tools?: readonly string[];
354+
readonly mcpPatterns?: readonly string[];
355+
}): void {
356+
this.setActiveTools([
357+
...(input.tools ?? this.enabledTools),
358+
...(input.mcpPatterns ?? this.mcpAccessPatterns),
359+
]);
360+
}
361+
352362
copyLoopToolsFrom(source: ToolManager): void {
353363
this.loopToolsOverride = source.loopTools;
354364
}

packages/agent-core/src/mcp/tool-naming.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ const MCP_NAME_SEPARATOR = '__';
66
* hash suffix so collisions remain extremely unlikely.
77
*/
88
const MAX_QUALIFIED_LENGTH = 64;
9+
const MAX_HASH_SUFFIX_LENGTH = 10;
910

1011
/**
1112
* Replace any character outside the safe ASCII set with `_`, then collapse
@@ -22,6 +23,11 @@ export function isMcpToolName(name: string): boolean {
2223
return name.startsWith(MCP_NAME_PREFIX);
2324
}
2425

26+
export function mcpServerToolPattern(serverName: string): string {
27+
const prefix = `${MCP_NAME_PREFIX}${sanitizeMcpNamePart(serverName)}${MCP_NAME_SEPARATOR}`;
28+
return `${prefix.slice(0, MAX_QUALIFIED_LENGTH - MAX_HASH_SUFFIX_LENGTH)}*`;
29+
}
30+
2531
/**
2632
* Produce the qualified MCP tool name used inside the agent and on the wire.
2733
* If the result would exceed {@link MAX_QUALIFIED_LENGTH}, a deterministic

packages/agent-core/src/services/session/session.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,8 @@ export function toProtocolSession(
122122
metadata: mergedMetadata,
123123
agent_config: {
124124
model: '',
125+
tools: meta?.agentConfig?.tools?.slice(),
126+
mcp_servers: meta?.agentConfig?.mcpServers?.slice(),
125127
},
126128
usage: emptySessionUsage(),
127129
permission_rules: [],

packages/agent-core/src/services/session/sessionService.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ const DEFAULT_UNDO_MESSAGE_PAGE_SIZE = 50;
4444
const MAX_UNDO_MESSAGE_PAGE_SIZE = 100;
4545
const CHILD_SESSION_KIND = 'child';
4646

47+
type ToolSelectionPatch = NonNullable<SessionMeta['agentConfig']>;
48+
4749
function asJsonObject(value: Record<string, unknown>): JsonObject {
4850
return value as unknown as JsonObject;
4951
}
@@ -250,6 +252,10 @@ export class SessionService extends Disposable implements ISessionService {
250252
} catch {
251253
}
252254
}
255+
const toolPatch = this.toToolPatch(input.agent_config);
256+
if (toolPatch !== undefined) {
257+
await this.persistToolSelection(summary.id, toolPatch);
258+
}
253259
const meta = await this.tryGetMeta(summary.id);
254260
const session = this._patchSessionStatus(
255261
toProtocolSession(summary, meta, await this.tryResolveWorkspaceId(summary.workDir)),
@@ -315,6 +321,7 @@ export class SessionService extends Disposable implements ISessionService {
315321
if (summary === undefined) {
316322
throw new SessionNotFoundError(id);
317323
}
324+
await this.core.rpc.resumeSession({ sessionId: id });
318325

319326
if (input.title !== undefined) {
320327
await this.core.rpc.renameSession({ sessionId: id, title: input.title });
@@ -330,6 +337,10 @@ export class SessionService extends Disposable implements ISessionService {
330337

331338
const ac = input.agent_config;
332339
if (ac !== undefined) {
340+
const toolPatch = this.toToolPatch(ac);
341+
if (toolPatch !== undefined) {
342+
await this.persistToolSelection(id, toolPatch);
343+
}
333344
const patch: AgentStatePatch = {};
334345
if (ac.model !== undefined && ac.model !== '') patch.model = ac.model;
335346
if (ac.thinking !== undefined) patch.thinking = ac.thinking;
@@ -359,6 +370,30 @@ export class SessionService extends Disposable implements ISessionService {
359370
);
360371
}
361372

373+
private toToolPatch(
374+
agentConfig: SessionCreate['agent_config'],
375+
): ToolSelectionPatch | undefined {
376+
if (agentConfig?.tools === undefined && agentConfig?.mcp_servers === undefined) {
377+
return undefined;
378+
}
379+
return {
380+
tools: agentConfig.tools,
381+
mcpServers: agentConfig.mcp_servers,
382+
};
383+
}
384+
385+
private async persistToolSelection(id: string, patch: ToolSelectionPatch): Promise<void> {
386+
await this.core.rpc.updateSessionMetadata({
387+
sessionId: id,
388+
metadata: {
389+
agentConfig: {
390+
tools: patch.tools,
391+
mcpServers: patch.mcpServers,
392+
},
393+
},
394+
});
395+
}
396+
362397
async fork(id: string, input: SessionFork): Promise<Session> {
363398
const source = await this.get(id);
364399
const title = input.title ?? `Fork: ${source.title || source.id}`;

packages/agent-core/src/session/index.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,10 @@ export interface SessionMeta {
213213
lastPrompt?: string;
214214
forkedFrom?: string;
215215
agents: Record<string, AgentMeta>;
216+
agentConfig?: {
217+
readonly tools?: readonly string[];
218+
readonly mcpServers?: readonly string[];
219+
};
216220
custom: Record<string, any>;
217221
}
218222

@@ -238,6 +242,13 @@ const SessionMetaSchema = z
238242
lastPrompt: z.string().optional(),
239243
forkedFrom: z.string().optional(),
240244
agents: z.record(z.string(), AgentMetaSchema),
245+
agentConfig: z
246+
.object({
247+
tools: z.array(z.string()).optional(),
248+
mcpServers: z.array(z.string()).optional(),
249+
})
250+
.strict()
251+
.optional(),
241252
custom: z.record(z.string(), z.unknown()),
242253
})
243254
.strict();

packages/agent-core/src/session/rpc.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { ErrorCodes, PythinkerError } from '#/errors';
22
import { convertMCPContentBlock } from '#/mcp/output';
3+
import { mcpServerToolPattern } from '#/mcp/tool-naming';
34
import type {
45
ActivateSkillPayload,
56
AdvisorStatus,
@@ -71,13 +72,33 @@ export class SessionAPIImpl implements PromisableMethods<SessionAPI> {
7172
'sessionFormatVersion cannot be updated',
7273
);
7374
}
75+
const incoming = payload.metadata.agentConfig;
76+
const previous = this.session.metadata.agentConfig;
77+
const agentConfig =
78+
incoming === undefined
79+
? previous
80+
: {
81+
tools: incoming.tools ?? previous?.tools,
82+
mcpServers: incoming.mcpServers ?? previous?.mcpServers,
83+
};
7484
this.session.metadata = {
7585
...this.session.metadata,
7686
...payload.metadata,
87+
agentConfig,
7788
agents: this.session.metadata.agents,
7889
sessionFormatVersion: this.session.metadata.sessionFormatVersion,
7990
};
8091
await this.session.writeMetadata();
92+
if (
93+
incoming !== undefined &&
94+
(incoming.tools !== undefined || incoming.mcpServers !== undefined)
95+
) {
96+
const agent = await this.session.ensureAgentResumed('main');
97+
agent.tools.patchActiveTools({
98+
tools: incoming.tools,
99+
mcpPatterns: incoming.mcpServers?.map(mcpServerToolPattern),
100+
});
101+
}
81102
}
82103

83104
getSessionMetadata(_payload: EmptyPayload): SessionMeta {

packages/agent-core/test/mcp/tool-naming.test.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
1+
import picomatch from 'picomatch';
12
import { describe, expect, it } from 'vitest';
23

3-
import { isMcpToolName, qualifyMcpToolName, sanitizeMcpNamePart } from '../../src/mcp/tool-naming';
4+
import {
5+
isMcpToolName,
6+
mcpServerToolPattern,
7+
qualifyMcpToolName,
8+
sanitizeMcpNamePart,
9+
} from '../../src/mcp/tool-naming';
410

511
describe('sanitizeMcpNamePart', () => {
612
it('passes alphanumeric, underscore, and dash through unchanged', () => {
@@ -62,3 +68,22 @@ describe('isMcpToolName', () => {
6268
expect(isMcpToolName('mcp_one_underscore__no')).toBe(false);
6369
});
6470
});
71+
72+
describe('mcpServerToolPattern', () => {
73+
it.each(['My Search', 'files[*]'])('matches qualified tools for server %s', (serverName) => {
74+
const pattern = mcpServerToolPattern(serverName);
75+
expect(picomatch.isMatch(qualifyMcpToolName(serverName, 'lookup'), pattern)).toBe(true);
76+
expect(pattern).not.toContain('[');
77+
expect(pattern).not.toContain(']');
78+
});
79+
80+
it('matches qualified tools when the server prefix is truncated', () => {
81+
const serverName = 'long server '.repeat(8);
82+
expect(
83+
picomatch.isMatch(
84+
qualifyMcpToolName(serverName, 'lookup'),
85+
mcpServerToolPattern(serverName),
86+
),
87+
).toBe(true);
88+
});
89+
});

0 commit comments

Comments
 (0)