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
6 changes: 6 additions & 0 deletions agent/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -213,6 +214,11 @@ export async function startOnMainThread(opts: StartOpts): Promise<void> {
});
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`);
}

Expand Down
140 changes: 140 additions & 0 deletions agent/mcpTools.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
/**
* 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<string, AgentMcpToolMeta> = {
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<ToolResult> => {
try {
const body = {
...(args && typeof args === 'object' ? (args as Record<string, unknown>) : {}),
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 ?? (err ? String(err) : `agent op '${name}' failed`),
}),
},
Comment thread
kriszyp marked this conversation as resolved.
],
};
}
},
});
count++;
}
log.info?.(`Agent MCP tools registered: ${count}`);
}
76 changes: 76 additions & 0 deletions unitTests/agent/mcpTools.test.js
Original file line number Diff line number Diff line change
@@ -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/);
});
});
Loading