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
69 changes: 69 additions & 0 deletions src/__tests__/unit/core/mcp-host-extension.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { ArtifactService } from '@/core/artifacts/index.js';
import {
FileMcpCatalogRepository,
FileMcpConfigRepository,
McpClientService,
McpService,
} from '@/core/mcp/index.js';
import {
Expand Down Expand Up @@ -159,6 +160,74 @@ describe('defineMcpHostExtension', () => {
expect(tools.map((tool) => tool.name)).toEqual(['create_deck', 'validate_deck']);
});

it('resolves and executes tools from embedded MCP data without reading stateRoot', async () => {
// An empty stateRoot: no mcp.json, no catalog. The stateRoot-reading path
// would find nothing here — so any resolved tool proves the embedded path.
const workspaceRoot = mkdtempSync(join(tmpdir(), 'heddle-mcp-embedded-'));
const stateRoot = join(workspaceRoot, '.heddle');
mkdirSync(stateRoot, { recursive: true });
const context: ToolToolkitContext = {
workspaceRoot,
stateRoot,
artifactRoot: join(stateRoot, 'artifacts'),
sessionId: 'session-embedded',
model: 'gpt-5.5',
memoryDir: join(stateRoot, 'memory'),
memoryMode: 'none',
};

const resolved = {
server: {
id: 'deck_service',
transport: 'stdio' as const,
source: 'heddle' as const,
command: 'npm',
args: ['run', 'mcp'],
env: {},
tools: { deny: ['validate-deck'], approval: 'always' as const },
},
catalog: {
serverId: 'deck_service',
tools: [
{
name: 'create-deck',
description: 'Create a presentation deck.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: { title: { type: 'string' } },
required: ['title'],
},
},
{
name: 'validate-deck',
description: 'Validate a presentation deck.',
inputSchema: { type: 'object', properties: {} },
},
],
refreshedAt: '2026-07-09T00:00:00.000Z',
},
};

const extension = defineMcpHostExtension({ id: 'slides', serverId: 'deck_service' }, resolved);
const tools = extension.toolkits?.flatMap((toolkit) => toolkit.createTools(context)) ?? [];

// Resolved from embedded catalog (empty stateRoot), and deny policy honored.
expect(tools.map((tool) => tool.name)).toEqual(['create_deck']);

const clientCall = vi.spyOn(McpClientService.prototype, 'callTool')
.mockResolvedValue({ ok: true, output: { done: true } });
const stateCall = vi.spyOn(McpService.prototype, 'callTool');

const result = await tools[0]?.execute({ title: 'Quarterly plan' });

expect(result?.ok).toBe(true);
// Execution went straight to the embedded server config via the client,
// never through the stateRoot-backed McpService.
expect(clientCall).toHaveBeenCalledWith(resolved.server, 'create-deck', { title: 'Quarterly plan' });
expect(stateCall).not.toHaveBeenCalled();
});

it('supports multiple MCP host extensions without tool name conflicts', () => {
const context = contextFixture();
const extensions = [
Expand Down
17 changes: 17 additions & 0 deletions src/core/chat/engine/mcp-host-extension/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,23 @@ defineMcpHostExtension(...)
Those helpers are stable entrypoints. The implementation behind them is split
into services so future changes have one clear owner.

## Self-contained (stateless) extensions

`prepareMcpHostExtension(...)` returns a **self-contained** extension: it embeds
the resolved server config and cached catalog, so at runtime the toolkit
resolves and executes tools from that embedded data instead of re-reading the
MCP config/catalog from `context.stateRoot`. This lets one prepared extension be
reused across many cheap, per-request engines — e.g. a multi-tenant server that
builds a fresh engine per request with a per-user API key and per-user storage,
without any per-engine MCP prep or a per-user `.heddle/` directory. Tool
execution stays stateless: each call spawns a fresh MCP subprocess via
`McpClientService` from the embedded config and closes it.

A plain `defineMcpHostExtension(options)` with no embedded data keeps the
original behavior: the toolkit reads the server + catalog from `stateRoot` at
runtime (the local CLI-host path). Preparation passes the embedded data as the
optional second argument to `defineMcpHostExtension(options, resolved)`.

## Service Boundaries

- `McpHostExtensionService` composes the Heddle host extension shell. Keep this
Expand Down
18 changes: 17 additions & 1 deletion src/core/chat/engine/mcp-host-extension/preparation-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,10 +69,23 @@ export class McpHostExtensionPreparationService {
};
}

const resolvedServer = mcp
.listOverview()
.servers.find((server) => server.id === options.serverId)?.config;
if (!resolvedServer) {
return {
ok: false,
serverId: options.serverId,
step: 'refresh_catalog',
error: `Resolved server config missing after refresh: ${options.serverId}`,
};
}

return {
ok: true,
serverId: options.serverId,
refresh,
resolvedServer,
toolNames: refresh.record.tools.map((tool) => tool.name),
};
}
Expand All @@ -90,7 +103,10 @@ export class McpHostExtensionPreparationService {
return prepared.ok
? {
...prepared,
extension: McpHostExtensionService.define(options),
extension: McpHostExtensionService.define(options, {
server: prepared.resolvedServer,
catalog: prepared.refresh.record,
}),
}
: prepared;
}
Expand Down
9 changes: 6 additions & 3 deletions src/core/chat/engine/mcp-host-extension/service.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { defineHostExtension } from '../host-extension.js';
import { McpHostToolDefinitionService } from './tool-definition-service.js';
import type { ConversationEngineHostExtension } from '../host-extension.js';
import type { DefineMcpHostExtensionOptions } from './types.js';
import type { DefineMcpHostExtensionOptions, ResolvedMcpHostExtensionData } from './types.js';

/**
* Public facade for building MCP-backed host extensions.
Expand All @@ -11,8 +11,11 @@ import type { DefineMcpHostExtensionOptions } from './types.js';
* narrower services in this folder.
*/
export class McpHostExtensionService {
static define(options: DefineMcpHostExtensionOptions): ConversationEngineHostExtension {
const toolkit = McpHostToolDefinitionService.createToolkit(options);
static define(
options: DefineMcpHostExtensionOptions,
resolved?: ResolvedMcpHostExtensionData,
): ConversationEngineHostExtension {
const toolkit = McpHostToolDefinitionService.createToolkit(options, resolved);

return defineHostExtension({
id: options.id,
Expand Down
56 changes: 48 additions & 8 deletions src/core/chat/engine/mcp-host-extension/tool-definition-service.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
import {
McpClientService,
McpService,
isToolAllowed,
shouldMcpToolRequireApproval,
} from '@/core/mcp/index.js';
import { McpResultArtifactService } from './result-artifact-service.js';
import { McpHostValueService } from './value-service.js';
import type { McpCallToolResult, McpServerCatalogRecord, McpServerConfig } from '@/core/mcp/index.js';
import type { ToolDefinition } from '@/core/types.js';
import type { ToolToolkit, ToolToolkitContext } from '@/core/tools/index.js';
import type {
DefineMcpHostExtensionOptions,
ResolvedMcpHostExtensionData,
ResolvedMcpTool,
} from './types.js';

Expand All @@ -20,14 +23,18 @@ import type {
* succeeds so this service remains focused on tool-definition semantics.
*/
export class McpHostToolDefinitionService {
static createToolkit(options: DefineMcpHostExtensionOptions): ToolToolkit {
static createToolkit(
options: DefineMcpHostExtensionOptions,
resolved?: ResolvedMcpHostExtensionData,
): ToolToolkit {
return {
id: `mcp.${options.id}`,
createTools(context) {
return McpHostToolDefinitionService.resolveTools({ context, options })
return McpHostToolDefinitionService.resolveTools({ context, options, resolved })
.map(({ server, tool }) => McpHostToolDefinitionService.createTool({
context,
options,
resolved,
server,
tool,
}));
Expand All @@ -38,14 +45,16 @@ export class McpHostToolDefinitionService {
private static resolveTools(args: {
context: ToolToolkitContext;
options: DefineMcpHostExtensionOptions;
resolved?: ResolvedMcpHostExtensionData;
}): ResolvedMcpTool[] {
const mcp = McpHostToolDefinitionService.createMcpService(args.context);
const server = mcp.listOverview().servers.find((candidate) => candidate.id === args.options.serverId);
if (server?.status !== 'enabled' || !server.config || !server.catalog) {
const source = args.resolved
? { config: args.resolved.server, catalog: args.resolved.catalog }
: McpHostToolDefinitionService.resolveFromState(args.context, args.options.serverId);
if (!source) {
return [];
}

const { config, catalog } = server;
const { config, catalog } = source;
const include = args.options.includeTools ? new Set(args.options.includeTools) : undefined;
const exclude = new Set(args.options.excludeTools ?? []);

Expand All @@ -59,9 +68,24 @@ export class McpHostToolDefinitionService {
}));
}

/** Read the server config + cached catalog from `stateRoot` (the CLI-host path,
* used when the extension was not prepared with embedded MCP data). */
private static resolveFromState(
context: ToolToolkitContext,
serverId: string,
): { config: McpServerConfig; catalog: McpServerCatalogRecord } | undefined {
const mcp = McpHostToolDefinitionService.createMcpService(context);
const server = mcp.listOverview().servers.find((candidate) => candidate.id === serverId);
if (server?.status !== 'enabled' || !server.config || !server.catalog) {
return undefined;
}
return { config: server.config, catalog: server.catalog };
}

private static createTool(args: ResolvedMcpTool & {
context: ToolToolkitContext;
options: DefineMcpHostExtensionOptions;
resolved?: ResolvedMcpHostExtensionData;
}): ToolDefinition {
const override = args.options.toolOverrides?.[args.tool.name];
const name = override?.name ?? McpHostToolDefinitionService.toHostToolName(args.options, args.tool.name);
Expand All @@ -73,8 +97,11 @@ export class McpHostToolDefinitionService {
capabilities: override?.capabilities ?? args.options.defaultCapabilities ?? ['mcp.unknown'],
parameters: args.tool.inputSchema,
async execute(raw: unknown) {
const result = await McpHostToolDefinitionService.createMcpService(args.context)
.callTool(args.options.serverId, args.tool.name, McpHostValueService.isRecord(raw) ? raw : {});
const callArgs = McpHostValueService.isRecord(raw) ? raw : {};
const result = args.resolved
? await McpHostToolDefinitionService.callEmbedded(args.resolved.server, args.tool.name, callArgs)
: await McpHostToolDefinitionService.createMcpService(args.context)
.callTool(args.options.serverId, args.tool.name, callArgs);

return result.ok
? {
Expand Down Expand Up @@ -113,4 +140,17 @@ export class McpHostToolDefinitionService {
stateRoot: context.stateRoot,
});
}

/** Execute a tool directly from an embedded server config, without touching
* `stateRoot`. Mirrors the allow/deny guard `McpService.callTool` applies. */
private static async callEmbedded(
server: McpServerConfig,
toolName: string,
args: Record<string, unknown>,
): Promise<McpCallToolResult> {
if (!isToolAllowed(server, toolName)) {
return { ok: false, error: `MCP tool is denied by Heddle config: ${server.id}/${toolName}` };
}
return await new McpClientService().callTool(server, toolName, args);
}
}
17 changes: 16 additions & 1 deletion src/core/chat/engine/mcp-host-extension/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { ArtifactKind, RuntimeArtifact } from '@/core/artifacts/index.js';
import type { McpRefreshResult, McpServerConfig, McpToolDescriptor } from '@/core/mcp/index.js';
import type { McpRefreshResult, McpServerCatalogRecord, McpServerConfig, McpToolDescriptor } from '@/core/mcp/index.js';
import type { ToolToolkitContext } from '@/core/tools/index.js';
import type {
ConversationEngineHostArtifactOptions,
Expand Down Expand Up @@ -108,6 +108,9 @@ export type PrepareMcpHostExtensionCatalogResult =
ok: true;
serverId: string;
refresh: Extract<McpRefreshResult, { ok: true }>;
/** Normalized server config from the just-saved MCP config, so the prepared
* extension can execute tools without re-reading `stateRoot` at runtime. */
resolvedServer: McpServerConfig;
toolNames: string[];
}
| {
Expand All @@ -128,6 +131,18 @@ export type ResolvedMcpTool = {
tool: McpToolDescriptor;
};

/**
* Self-contained MCP state embedded into a prepared host extension. When
* present, the toolkit resolves and executes tools from this data instead of
* re-reading the MCP config/catalog from `context.stateRoot` at runtime. This is
* what lets one prepared extension be reused across cheap, per-request engines
* (e.g. a multi-tenant server) without per-engine MCP prep.
*/
export type ResolvedMcpHostExtensionData = {
server: McpServerConfig;
catalog: McpServerCatalogRecord;
};

export type ResolvedResultArtifactsOptions = {
auto?: McpHostAutoResultArtifactsOptions;
rules: readonly McpHostResultArtifactRule[];
Expand Down
Loading