Skip to content
Open
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
37 changes: 6 additions & 31 deletions agent/mcpTools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { addTool, canRoleInvokeOperation, type AuthedUser } from '../components/
import { makeOperationToolHandler } from '../components/mcp/tools/operations.ts';
import harperLogger from '../utility/logging/harper_logger.ts';
import type { OperationDefinition } from '../server/serverHelpers/serverUtilities.ts';
import { AGENT_OPERATION_INPUT_SCHEMAS } from './operationInputSchemas.ts';

const log = harperLogger.loggerWithTag('agent');

Expand All @@ -36,55 +37,29 @@ 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'],
},
inputSchema: AGENT_OPERATION_INPUT_SCHEMAS.agent_prompt,
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'],
},
inputSchema: AGENT_OPERATION_INPUT_SCHEMAS.get_agent_session,
readOnly: true,
},
list_agent_sessions: {
description: 'List built-in-agent sessions, most recently updated first.',
inputSchema: {
type: 'object',
properties: { limit: { type: 'integer', minimum: 1, description: 'Max sessions to return (default 100).' } },
},
inputSchema: AGENT_OPERATION_INPUT_SCHEMAS.list_agent_sessions,
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'],
},
inputSchema: AGENT_OPERATION_INPUT_SCHEMAS.approve_agent_action,
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'],
},
inputSchema: AGENT_OPERATION_INPUT_SCHEMAS.cancel_agent_run,
destructive: true,
},
};
Expand Down
46 changes: 46 additions & 0 deletions agent/operationInputSchemas.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
export const AGENT_OPERATION_INPUT_SCHEMAS = {
agent_prompt: {
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'],
},
get_agent_session: {
type: 'object',
properties: { session_id: { type: 'string' } },
required: ['session_id'],
},
list_agent_sessions: {
type: 'object',
properties: { limit: { type: 'integer', minimum: 1, description: 'Max sessions to return (default 100).' } },
},
approve_agent_action: {
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'],
},
cancel_agent_run: {
type: 'object',
properties: { session_id: { type: 'string' } },
required: ['session_id'],
},
set_agent_config: {
type: 'object',
properties: {
enabled: { type: 'boolean' },
provider: { type: 'string' },
model: { type: 'string' },
maxTurns: { type: 'integer', minimum: 1 },
maxCostUsd: { type: 'number', minimum: 0 },
autoApprove: { type: 'boolean' },
allowDestructive: { type: 'boolean' },
systemPromptAppend: { type: 'string' },
},
},
} as const satisfies Record<string, object>;
7 changes: 7 additions & 0 deletions agent/operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { OPERATIONS_ENUM } from '../utility/hdbTerms.ts';
import { ClientError } from '../utility/errors/hdbError.ts';
import { createSession, getSession, listSessions, appendMessage, resolveApproval, setStatus } from './session.ts';
import type { AgentConfig, AgentMessage, AgentRunStatus } from './types.ts';
import { AGENT_OPERATION_INPUT_SCHEMAS } from './operationInputSchemas.ts';

export interface OperationDeps {
getConfig: () => AgentConfig;
Expand All @@ -28,31 +29,37 @@ export function buildOperations(deps: OperationDeps): OperationDefinition[] {
{
name: OPERATIONS_ENUM.AGENT_PROMPT,
execute: async (op) => agentPrompt(op, deps),
inputSchema: AGENT_OPERATION_INPUT_SCHEMAS.agent_prompt,
requiresSuperUser: true,
},
{
name: OPERATIONS_ENUM.GET_AGENT_SESSION,
execute: async (op) => getAgentSession(op),
inputSchema: AGENT_OPERATION_INPUT_SCHEMAS.get_agent_session,
requiresSuperUser: true,
},
{
name: OPERATIONS_ENUM.LIST_AGENT_SESSIONS,
execute: async (op) => listAgentSessions(op),
inputSchema: AGENT_OPERATION_INPUT_SCHEMAS.list_agent_sessions,
requiresSuperUser: true,
},
{
name: OPERATIONS_ENUM.CANCEL_AGENT_RUN,
execute: async (op) => cancelAgentRun(op, deps),
inputSchema: AGENT_OPERATION_INPUT_SCHEMAS.cancel_agent_run,
requiresSuperUser: true,
},
{
name: OPERATIONS_ENUM.APPROVE_AGENT_ACTION,
execute: async (op) => approveAgentAction(op, deps),
inputSchema: AGENT_OPERATION_INPUT_SCHEMAS.approve_agent_action,
requiresSuperUser: true,
},
{
name: OPERATIONS_ENUM.SET_AGENT_CONFIG,
execute: async (op) => setAgentConfig(op, deps),
inputSchema: AGENT_OPERATION_INPUT_SCHEMAS.set_agent_config,
requiresSuperUser: true,
},
];
Expand Down
111 changes: 91 additions & 20 deletions components/mcp/tools/operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,32 +45,42 @@ import {
type ToolDef,
type ToolResult,
} from '../toolRegistry.ts';
import { OPERATION_INPUT_SCHEMAS, PERMISSIVE_SCHEMA } from './schemas/operations.ts';
import { OPERATION_DESCRIPTIONS } from './schemas/operationDescriptions.ts';

// Resolved from Harper's server-helpers graph on demand. The map is built at
// Harper boot but keeps mutating as components register operations during
// `startOnMainThread` — the provider below re-reads it per request rather than
// snapshotting (#1562).
type OperationFunction = (json: object) => unknown | Promise<unknown>;
type OperationFunctionMap = Map<string, { operation_function: OperationFunction }>;
type OperationFunctionEntry = { inputSchema?: object };
type OperationFunctionMap = Map<string, OperationFunctionEntry>;

type ChooseOperation = (body: object) => OperationFunction;
type ProcessLocalTransaction = (req: { body: object }, fn: OperationFunction) => Promise<unknown>;

interface OperationsConfig {
allow?: readonly string[];
allowSchemaless?: readonly string[];
deny?: readonly string[];
}
type RemoteOperationSchema = { inputSchema?: object; issue?: 'missing' | 'inconsistent' };

// Test seams. Avoids importing Harper's heavy server-helpers graph from unit
// tests that only want to exercise the registration logic.
let _opMapOverride: OperationFunctionMap | undefined;
let _remoteSchemasOverride: Array<[string, RemoteOperationSchema]> | undefined;
let _chooseOperationOverride: ChooseOperation | undefined;
let _processLocalTransactionOverride: ProcessLocalTransaction | undefined;
const warnedMissingSchemas = new Set<string>();

export function _setOperationFunctionMapForTest(m: OperationFunctionMap | undefined): void {
_opMapOverride = m;
warnedMissingSchemas.clear();
}
export function _setRemoteOperationInputSchemasForTest(
schemas: Array<[string, RemoteOperationSchema]> | undefined
): void {
_remoteSchemasOverride = schemas;
}
export function _setChooseOperationForTest(fn: ChooseOperation | undefined): void {
_chooseOperationOverride = fn;
Expand All @@ -83,6 +93,8 @@ function loadServerUtilities():
| {
OPERATION_FUNCTION_MAP?: OperationFunctionMap;
chooseOperation?: ChooseOperation;
getRemoteOperationInputSchema?: (name: string) => RemoteOperationSchema | undefined;
getRemoteOperationInputSchemas?: () => Array<[string, RemoteOperationSchema]>;
processLocalTransaction?: ProcessLocalTransaction;
}
| undefined {
Expand All @@ -104,6 +116,18 @@ function getOperationFunctionMap(): OperationFunctionMap | undefined {
return utils?.OPERATION_FUNCTION_MAP;
}

function getRemoteOperationInputSchemas(): Array<[string, RemoteOperationSchema]> {
if (_remoteSchemasOverride) return _remoteSchemasOverride;
if (_opMapOverride) return [];
return loadServerUtilities()?.getRemoteOperationInputSchemas?.() ?? [];
}

function getRemoteOperationInputSchema(name: string): RemoteOperationSchema | undefined {
if (_remoteSchemasOverride) return _remoteSchemasOverride.find(([operationName]) => operationName === name)?.[1];
if (_opMapOverride) return undefined;
return loadServerUtilities()?.getRemoteOperationInputSchema?.(name);
}

function getChooseOperation(): ChooseOperation | undefined {
if (_chooseOperationOverride) return _chooseOperationOverride;
return loadServerUtilities()?.chooseOperation;
Expand Down Expand Up @@ -268,7 +292,7 @@ function matchesAny(operation: string, patterns: readonly string[] | undefined):
return false;
}

function isOperationAllowed(operation: string, config: OperationsConfig): boolean {
export function isOperationAllowed(operation: string, config: OperationsConfig): boolean {
const usingDefaultAllow = !(config.allow && config.allow.length > 0);
if (usingDefaultAllow && DEFAULT_EXCLUDED.has(operation)) return false;
const allowList = usingDefaultAllow ? DEFAULT_ALLOW : config.allow;
Expand All @@ -279,21 +303,21 @@ function isOperationAllowed(operation: string, config: OperationsConfig): boolea

function getOperationsConfig(): OperationsConfig {
const allow = env.get(CONFIG_PARAMS.MCP_OPERATIONS_ALLOW);
const allowSchemaless = env.get(CONFIG_PARAMS.MCP_OPERATIONS_ALLOWSCHEMALESS);
const deny = env.get(CONFIG_PARAMS.MCP_OPERATIONS_DENY);
return {
allow: Array.isArray(allow) ? (allow as readonly string[]) : undefined,
allowSchemaless: Array.isArray(allowSchemaless) ? (allowSchemaless as readonly string[]) : undefined,
deny: Array.isArray(deny) ? (deny as readonly string[]) : undefined,
};
}

function buildDescription(operationName: string, hasCuratedSchema: boolean): string {
const curated = OPERATION_DESCRIPTIONS[operationName];
function buildDescription(operationName: string): string {
const curated = Object.hasOwn(OPERATION_DESCRIPTIONS, operationName)
? OPERATION_DESCRIPTIONS[operationName]
: undefined;
if (curated) return curated;
const base = `Harper operation '${operationName}'.`;
const schemaNote = hasCuratedSchema
? ' Arguments validated against the curated schema below.'
: ' Arguments forwarded as-is; the server validates and returns a structured error on rejection.';
return base + schemaNote;
return `Harper operation '${operationName}'. Arguments are described by its registered schema and validated by the operation handler.`;
}

/**
Expand Down Expand Up @@ -385,18 +409,16 @@ export function makeOperationToolHandler(operationName: string) {
* function of the operation name (its schema, description, annotations, RBAC
* predicate, and handler don't depend on the allow/deny config, which only
* decides *whether* the op is exposed, checked per request in the provider).
* `tools/list` isn't a hot path, so the provider rebuilds defs per call rather
* than caching (no module-level state to leak or stale-cache across tests).
* `tools/list` isn't a hot path, so the provider rebuilds defs per call.
*/
function buildOperationToolDef(operationName: string): ToolDef {
const inputSchema = OPERATION_INPUT_SCHEMAS[operationName] ?? PERMISSIVE_SCHEMA;
function buildOperationToolDef(operationName: string, inputSchema: object): ToolDef {
const annotations: { readOnlyHint?: boolean; destructiveHint?: boolean; idempotentHint?: boolean } = {};
if (isReadOnly(operationName)) annotations.readOnlyHint = true;
if (isDestructive(operationName)) annotations.destructiveHint = true;
if (isIdempotent(operationName)) annotations.idempotentHint = true;
return {
name: operationName,
description: buildDescription(operationName, operationName in OPERATION_INPUT_SCHEMAS),
description: buildDescription(operationName),
inputSchema,
profile: 'operations',
...(Object.keys(annotations).length > 0 ? { annotations } : {}),
Expand All @@ -405,6 +427,33 @@ function buildOperationToolDef(operationName: string): ToolDef {
};
}

function buildRegisteredOperationToolDef(
operationName: string,
operation: OperationFunctionEntry,
config: OperationsConfig,
issue: RemoteOperationSchema['issue'] = 'missing'
): ToolDef | undefined {
if (!operation.inputSchema) {
if (issue !== 'inconsistent' && matchesAny(operationName, config.allowSchemaless)) {
return buildOperationToolDef(operationName, { type: 'object' });
}
const warningKey = `${operationName}:${issue}`;
if (!warnedMissingSchemas.has(warningKey)) {
warnedMissingSchemas.add(warningKey);
const source = config.allow?.length ? ' is named by mcp.operations.allow but' : '';
const message =
issue === 'inconsistent'
? `MCP operations profile: '${operationName}' is registered by workers with different inputSchema values; withholding it until they agree`
: `MCP operations profile: '${operationName}'${source} was registered without inputSchema; pass inputSchema to server.registerOperation() or add it to mcp.operations.allowSchemaless`;
harperLogger.warn(message);
}
return undefined;
}
warnedMissingSchemas.delete(`${operationName}:missing`);
warnedMissingSchemas.delete(`${operationName}:inconsistent`);
return buildOperationToolDef(operationName, operation.inputSchema);
}

/**
* Lazy operations-profile tool provider. Consulted by the registry on every
* `tools/list` / `tools/call`, so it reflects the live `OPERATION_FUNCTION_MAP`
Expand All @@ -419,17 +468,39 @@ const operationsToolProvider: ProfileToolProvider = {
}
const config = getOperationsConfig();
const defs: ToolDef[] = [];
for (const operationName of opMap.keys()) {
for (const [operationName, operation] of opMap) {
if (!isOperationAllowed(operationName, config)) continue;
defs.push(buildOperationToolDef(operationName));
const def = buildRegisteredOperationToolDef(operationName, operation, config);
if (def) defs.push(def);
}
for (const [operationName, remoteSchema] of getRemoteOperationInputSchemas()) {
if (opMap.has(operationName) || !isOperationAllowed(operationName, config)) continue;
const def = buildRegisteredOperationToolDef(
operationName,
{ inputSchema: remoteSchema.inputSchema },
config,
remoteSchema.issue
);
if (def) defs.push(def);
}
return defs;
},
get(operationName: string): ToolDef | undefined {
const opMap = getOperationFunctionMap();
if (!opMap || !opMap.has(operationName)) return undefined;
if (!isOperationAllowed(operationName, getOperationsConfig())) return undefined;
return buildOperationToolDef(operationName);
if (!opMap) return undefined;
let operation = opMap.get(operationName);
let issue: RemoteOperationSchema['issue'];
if (!operation) {
const remote = getRemoteOperationInputSchema(operationName);
if (remote) {
operation = { inputSchema: remote.inputSchema };
issue = remote.issue;
}
}
if (!operation) return undefined;
const config = getOperationsConfig();
if (!isOperationAllowed(operationName, config)) return undefined;
return buildRegisteredOperationToolDef(operationName, operation, config, issue);
},
};

Expand Down
8 changes: 8 additions & 0 deletions dependencies.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,14 @@ In reviewing the third party package or dependency, the following questions shou

Generally, dependencies are added by simply adding them to the dependencies list in package.json. If the dependency is not necessary for the actual execution of the application (testing or building), it can be placed in devDependencies, or in optionalDependencies (we have done that with packages with binary compilations).

## ajv

- Need for usage: Validates JSON Schema metadata supplied when operations are registered, before that metadata can be exposed to MCP clients. This keeps a malformed component schema from breaking `tools/list` while preserving the operation handler.
- Size and overlap: Ajv is already present transitively through Fastify; declaring Fastify's existing 8.18.0 resolution directly makes Harper's runtime use explicit without changing its schema compiler version or adding a package family. Fastify's internal compiler is not a public standalone schema-validation API. Future upgrades must check both consumers.
- Performance and memory: Loaded lazily when `server.registerOperation()` supplies or inherits schema metadata and never on the operation dispatch hot path. Built-in schemas are checked in CI; their boot-time attachment skips runtime validation.
- Security and environment: Pure JavaScript, no native build or global mutation. Component schemas larger than 64 KiB are rejected before parsing and meta-validation, and are not passed through Ajv's code generator.
- Eventual removal: Remove the direct dependency if operation contracts move to a schema facility already owned by Harper or a stable public validator supplied by the runtime.

## react-native-fs (removed from the published tree, not a dependency)

This is the inverse of the entries below — a dependency we take deliberate steps to _not_ ship.
Expand Down
Loading
Loading