-
Notifications
You must be signed in to change notification settings - Fork 8
feat(agent): expose the built-in agent over MCP (curated tools) (#626) #1561
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
+222
−0
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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`), | ||
| }), | ||
| }, | ||
| ], | ||
| }; | ||
| } | ||
| }, | ||
| }); | ||
| count++; | ||
| } | ||
| log.info?.(`Agent MCP tools registered: ${count}`); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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/); | ||
| }); | ||
| }); |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.