From 63ca7b3b0144882d4976e081205b1d3e3119ecb2 Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Wed, 1 Jul 2026 22:24:25 -0600 Subject: [PATCH 1/2] feat(agent): expose the built-in agent over MCP (curated tools) (#626) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lets any MCP client drive the built-in agent (agent_prompt, get_agent_session, list_agent_sessions, approve_agent_action, cancel_agent_run). Why not just `mcp.operations.allow`: the generic MCP operations profile walks OPERATION_FUNCTION_MAP once, BEFORE this component registers its operations, so the agent ops are added to the map too late for the walk — allow-listing them has no effect (confirmed live: profile builds at 14 tools, then the 6 agent ops register; tools/list never shows them). So we register a curated agent tool set directly into the registry AFTER the ops exist. - agent/mcpTools.ts (new): registerAgentMcpTools(operations) adds curated tools (proper schemas/descriptions, destructive/read-only annotations) on the operations profile. visibleTo is super_user-only for listing; each handler dispatches to the operation's execute with the MCP caller as hdb_user, so the op's own super_user check + downstream RBAC still apply. set_agent_config is intentionally NOT exposed (operator/config action). - agent/agent.ts: call it after registering the ops. Tools sit inertly in the registry unless the MCP HTTP surface is enabled. Verified live over the MCP Streamable HTTP transport: an MCP client completed initialize → tools/list (agent tools present) → tools/call agent_prompt → get_agent_session, and the agent ran a tool and answered. Stacked on the best-practices PR (kris/agent-bestpractices). Co-Authored-By: Claude Opus 4.8 --- agent/agent.ts | 6 ++ agent/mcpTools.ts | 138 +++++++++++++++++++++++++++++++ unitTests/agent/mcpTools.test.js | 76 +++++++++++++++++ 3 files changed, 220 insertions(+) create mode 100644 agent/mcpTools.ts create mode 100644 unitTests/agent/mcpTools.test.js diff --git a/agent/agent.ts b/agent/agent.ts index a95c111796..5725481fca 100644 --- a/agent/agent.ts +++ b/agent/agent.ts @@ -26,6 +26,7 @@ import { buildInspectorTools } from './tools/inspectorTool.ts'; import { buildBestPracticeTool, loadBestPracticesOverview } from './bestPractices.ts'; import { composeRegistryTools, ensureOperationsToolsRegistered } from './registryTools.ts'; import { buildOperations } from './operations.ts'; +import { registerAgentMcpTools } from './mcpTools.ts'; import { runAgent, _resetInFlightForTests } from './loop.ts'; import { appendMessage, getSession } from './session.ts'; import type { AgentConfig, AgentScopes, AgentTool } from './types.ts'; @@ -213,6 +214,11 @@ export async function startOnMainThread(opts: StartOpts): Promise { }); for (const op of operations) opts.server.registerOperation(op); + // Expose the agent over MCP: register curated agent tools into the registry AFTER the ops exist. + // (The generic operations-profile walk ran before this, so allow-listing the agent ops wouldn't + // surface them — see mcpTools.ts.) They only reach clients when the MCP surface is enabled. + registerAgentMcpTools(operations); + log.info?.(`Agent component initialized with ${composed.tools.length} tools`); } diff --git a/agent/mcpTools.ts b/agent/mcpTools.ts new file mode 100644 index 0000000000..da9e5b61f6 --- /dev/null +++ b/agent/mcpTools.ts @@ -0,0 +1,138 @@ +/** + * Expose the built-in agent over MCP (#626). + * + * The generic MCP operations profile (#617) walks `OPERATION_FUNCTION_MAP` once, + * before this component registers its operations — so the agent ops never land + * on the MCP surface via `mcp.operations.allow` alone (they're added to the map + * too late for the walk). We therefore register a *curated* agent tool set + * directly into the registry after the ops exist: guaranteed exposure, proper + * schemas/descriptions, and independent of walk timing or the allow-list. + * + * Each tool dispatches straight to the agent operation's `execute`, with the MCP + * caller's identity as `hdb_user` — so the operation's own super_user check and + * any downstream RBAC still apply. Listing is restricted to super_users. + */ + +import { addTool, isSuperUser, type AuthedUser, type ToolResult } from '../components/mcp/toolRegistry.ts'; +import harperLogger from '../utility/logging/harper_logger.ts'; +import type { OperationDefinition } from '../server/serverHelpers/serverUtilities.ts'; + +const log = harperLogger.loggerWithTag('agent'); + +interface AgentMcpToolMeta { + description: string; + inputSchema: object; + destructive?: boolean; + readOnly?: boolean; +} + +// Curated client-facing surface for driving the agent. `set_agent_config` is intentionally excluded +// — it's an operator/config action, not something an MCP client should reach. +const AGENT_MCP_TOOLS: Record = { + agent_prompt: { + description: + 'Send a prompt to the built-in Harper agent. Starts a new session, or continues one when session_id is given. Returns { session_id, status }; poll get_agent_session for the transcript and result.', + inputSchema: { + type: 'object', + properties: { + message: { type: 'string', description: 'The instruction/prompt for the agent.' }, + session_id: { type: 'string', description: 'Optional existing session id to continue the conversation.' }, + }, + required: ['message'], + }, + destructive: true, // the agent may take actions in response + }, + get_agent_session: { + description: + 'Read a built-in-agent session: its status, full transcript (messages, tool calls and results), and any pending approvals.', + inputSchema: { + type: 'object', + properties: { session_id: { type: 'string' } }, + required: ['session_id'], + }, + readOnly: true, + }, + list_agent_sessions: { + description: 'List built-in-agent sessions, most recent first.', + inputSchema: { + type: 'object', + properties: { limit: { type: 'integer', minimum: 1, description: 'Max sessions to return (default 100).' } }, + }, + readOnly: true, + }, + approve_agent_action: { + description: + 'Approve or deny a pending agent tool call (when autoApprove is off), then resume the run. Get the approval_id from get_agent_session.pendingApprovals.', + inputSchema: { + type: 'object', + properties: { + session_id: { type: 'string' }, + approval_id: { type: 'string' }, + approved: { type: 'boolean', description: 'true to approve (default), false to deny.' }, + }, + required: ['session_id', 'approval_id'], + }, + destructive: true, + }, + cancel_agent_run: { + description: 'Cancel an in-progress agent run for a session.', + inputSchema: { + type: 'object', + properties: { session_id: { type: 'string' } }, + required: ['session_id'], + }, + destructive: true, + }, +}; + +/** + * Register the curated agent tools into the MCP registry (operations profile). Call after the agent + * operations are registered. Safe to call whether or not the MCP HTTP surface is enabled — the tools + * simply sit in the registry until an MCP client lists them. + */ +export function registerAgentMcpTools(operations: OperationDefinition[]): void { + const byName = new Map(operations.map((op) => [op.name, op])); + let count = 0; + for (const [name, meta] of Object.entries(AGENT_MCP_TOOLS)) { + const op = byName.get(name); + if (!op) continue; // op not registered (shouldn't happen) — skip rather than expose a dead tool + const annotations = meta.destructive ? { destructiveHint: true } : meta.readOnly ? { readOnlyHint: true } : {}; + addTool({ + name, + description: meta.description, + inputSchema: meta.inputSchema, + profile: 'operations', + ...(Object.keys(annotations).length > 0 ? { annotations } : {}), + // Listing is restricted to super_users; the operation's own super_user check enforces at call time. + visibleTo: (user: AuthedUser) => isSuperUser(user), + handler: async (args: unknown, context: { user: AuthedUser }): Promise => { + try { + const body = { + ...(args && typeof args === 'object' ? (args as Record) : {}), + operation: name, + hdb_user: context.user, + }; + const data = await op.execute(body); + const result: ToolResult = { + content: [{ type: 'text', text: typeof data === 'string' ? data : JSON.stringify(data ?? null) }], + }; + if (data !== null && typeof data === 'object') result.structuredContent = data as object; + return result; + } catch (err) { + const e = err as { message?: string; http_resp_msg?: string }; + return { + isError: true, + content: [ + { + type: 'text', + text: JSON.stringify({ error: e?.http_resp_msg ?? e?.message ?? `agent op '${name}' failed` }), + }, + ], + }; + } + }, + }); + count++; + } + log.info?.(`Agent MCP tools registered: ${count}`); +} diff --git a/unitTests/agent/mcpTools.test.js b/unitTests/agent/mcpTools.test.js new file mode 100644 index 0000000000..5ec37a297b --- /dev/null +++ b/unitTests/agent/mcpTools.test.js @@ -0,0 +1,76 @@ +'use strict'; + +/** + * Unit tests for exposing the built-in agent over MCP (#626). + * Verifies the curated agent tools register into the MCP registry, are super_user-gated for listing, + * dispatch to the underlying operation's execute with the caller as hdb_user, and surface errors. + */ + +const assert = require('node:assert/strict'); +const { getTool, snapshotProfileTools, _resetRegistryForTest } = require('#src/components/mcp/toolRegistry'); +const { registerAgentMcpTools } = require('#src/agent/mcpTools'); + +const SUPER = { username: 'admin', role: { permission: { super_user: true } } }; +const RESTRICTED = { username: 'ro', role: { permission: { super_user: false } } }; + +function fakeOps(calls) { + const make = (name) => ({ name, execute: async (body) => (calls.push({ name, body }), { ok: true, op: name }) }); + return ['agent_prompt', 'get_agent_session', 'list_agent_sessions', 'approve_agent_action', 'cancel_agent_run'].map( + make + ); +} + +describe('agent/mcpTools registerAgentMcpTools', () => { + beforeEach(() => _resetRegistryForTest()); + afterEach(() => _resetRegistryForTest()); + + it('registers the curated agent tools on the operations profile', () => { + registerAgentMcpTools(fakeOps([])); + const names = snapshotProfileTools('operations').map((t) => t.name); + assert.ok(names.includes('agent_prompt')); + assert.ok(names.includes('get_agent_session')); + assert.ok(names.includes('list_agent_sessions')); + // set_agent_config is intentionally NOT exposed over MCP. + assert.ok(!names.includes('set_agent_config')); + }); + + it('lists only for super_users', () => { + registerAgentMcpTools(fakeOps([])); + const prompt = getTool('agent_prompt'); + assert.equal(prompt.visibleTo(SUPER), true); + assert.equal(prompt.visibleTo(RESTRICTED), false); + }); + + it('annotates prompt/approve/cancel destructive and reads read-only', () => { + registerAgentMcpTools(fakeOps([])); + assert.equal(getTool('agent_prompt').annotations?.destructiveHint, true); + assert.equal(getTool('cancel_agent_run').annotations?.destructiveHint, true); + assert.equal(getTool('get_agent_session').annotations?.readOnlyHint, true); + }); + + it('dispatches to the op execute with operation + caller hdb_user, returning structuredContent', async () => { + const calls = []; + registerAgentMcpTools(fakeOps(calls)); + const res = await getTool('agent_prompt').handler({ message: 'hi' }, { user: SUPER }); + assert.deepEqual(res.structuredContent, { ok: true, op: 'agent_prompt' }); + assert.equal(calls.length, 1); + assert.equal(calls[0].body.operation, 'agent_prompt'); + assert.equal(calls[0].body.message, 'hi'); + assert.equal(calls[0].body.hdb_user, SUPER); // enforcement identity passed through + }); + + it('surfaces an operation failure as an isError result (not a throw)', async () => { + const ops = [ + { + name: 'agent_prompt', + execute: async () => { + throw Object.assign(new Error('denied'), { http_resp_msg: 'nope' }); + }, + }, + ]; + registerAgentMcpTools(ops); + const res = await getTool('agent_prompt').handler({ message: 'x' }, { user: SUPER }); + assert.equal(res.isError, true); + assert.match(res.content[0].text, /nope/); + }); +}); From a1c56f16d4a9effe7964b34c90b2eb0b4d8fac03 Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Thu, 9 Jul 2026 17:48:25 -0600 Subject: [PATCH 2/2] fix(agent): preserve non-standard error details in MCP tool error responses Fall back to String(err) before the generic message so string/plain-object thrown values aren't reduced to a useless generic message (#1561). --- agent/mcpTools.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/agent/mcpTools.ts b/agent/mcpTools.ts index da9e5b61f6..b2a859ae2c 100644 --- a/agent/mcpTools.ts +++ b/agent/mcpTools.ts @@ -125,7 +125,9 @@ export function registerAgentMcpTools(operations: OperationDefinition[]): void { content: [ { type: 'text', - text: JSON.stringify({ error: e?.http_resp_msg ?? e?.message ?? `agent op '${name}' failed` }), + text: JSON.stringify({ + error: e?.http_resp_msg ?? e?.message ?? (err ? String(err) : `agent op '${name}' failed`), + }), }, ], };