Skip to main content
Version: v5

MCP Tools and Resources

Added in: v5.1.0

This page documents what the MCP server actually exposes — which tools land on tools/list for which user, how their input schemas are built, and what shows up in resources/list for each profile. Configuration knobs that gate this surface are documented in MCP Configuration.

Operations profile — tool generation​

Tools are generated by walking Harper's OPERATION_FUNCTION_MAP and filtering through the configured allow/deny lists. Each tool is named for its operation (describe_all, search_by_value, system_information, …) and dispatches through the same chooseOperation + processLocalTransaction path the REST /operation endpoint uses, so existing verifyPerms enforcement runs unchanged.

Default-allow list​

The default mcp.operations.allow list is intentionally narrow and read-only:

  • describe_* — schema / database / table descriptions.
  • list_* — enumerations (users, roles, databases).
  • search_* — search operations.
  • get_job, get_status, get_analytics, get_metrics — explicit safe getters.
  • system_information — server-level information.
  • read_log, read_audit_log — log readers.

get_* is deliberately not a wildcard. That glob would otherwise pull in:

  • get_configuration — returns TLS, S3, and authentication secrets.
  • get_components, get_component_file, get_custom_function, get_custom_functions — return component source code, which can embed secrets.
  • get_backup — backup metadata / payload.
  • get_deployment, get_deployment_payload — deployment artifacts.

These are all gated by verifyPerms, but defaulting to "expose them to the LLM if a super_user invokes them" is the wrong posture for MCP — the LLM provider sees and may log every input/output. Operators who want any of them on the surface opt them in via mcp.operations.allow.

Tool annotations​

Each generated tool carries MCP annotations the client can use to decide how to surface it:

  • readOnlyHint: true — operations matching the read-only set (describe_*, list_*, search_*, get_*, read_*, system_information, status). MCP hosts can render these as "safe to call without confirmation".
  • destructiveHint: true — operations Harper knows are destructive (drop_*, delete*, restart*, set_configuration, remove_node). MCP hosts SHOULD prompt before invoking.

Neither hint is an authorization check — verifyPerms runs at dispatch.

Per-user filtering​

tools/list is filtered through canRoleInvokeOperation so each session sees only the operations its user can actually call:

  • super_user sees everything in the allow list.
  • A user with structure_user: true sees schema-structure operations (create_schema, drop_table, create_attribute, etc.) in addition to anything in permission.operations.
  • Other users see only operations listed in permission.operations.

The list is cached per session and recomputed when a notifications/tools/list_changed event would fire.

Application profile — tool generation​

The application profile walks Harper's Resources registry. For each exported Resource whose registration does not set exportTypes.mcp = false, Harper emits one MCP tool per implemented REST verb:

Verb on Resource prototypeTool nameSchema source
get(target, request)get_<name>Primary key + optional get_attributes
search(target, request)search_<name>conditions, operator, get_attributes, limit, cursor
post(target, data)create_<name>All writable attributes; non-nullable non-PK fields required
put(target, data)update_<name> (put)PK + writable attributes
patch(target, data)patch_<name> (patch)PK + writable attributes
delete(target, request)delete_<name>Primary key

A Resource that implements both put and patch emits update_<name> (favoring put).

Tool-name sanitization​

The Resource's path is sanitized into a valid tool name: / and . become _. If two Resources sanitize to the same name, Harper disambiguates by prefixing the database name; if a collision still occurs, a 6-character hash suffix is appended.

Input schema derivation​

Input schemas come from Table.attributes:

  • Harper types map to JSON Schema primitive types (Int/Long/BigInt → integer, Float → number, String/ID → string, Boolean → boolean, Date → [string, number], Bytes/Blob → string with contentEncoding: base64).
  • Nested Object and Array attributes recurse into their properties / elements.
  • nullable: true adds "null" to the type union.
  • Auto-managed columns (assignCreatedTime, assignUpdatedTime, expiresAt) and computed columns are stripped from write schemas (create_*, update_*) — the server fills them in.
  • Per-attribute attribute_permissions narrow the schema per requesting user: attributes the user cannot read are stripped from get_* / search_* schemas; attributes the user cannot insert/update are stripped from create_* / update_* schemas.

The schema narrowing is a UX optimization, not a security boundary — runtime Table.allowUpdate / Table.allowCreate still enforces. The narrowing just avoids burning LLM tokens on fields the user couldn't write anyway.

Custom mcpTools opt-in​

A component author can expose non-verb instance methods as MCP tools by declaring a static mcpTools array on the Resource class:

class Orders extends Tables.orders {
static mcpTools = [
{
name: 'reconcile_unsettled',
method: 'reconcileUnsettled',
description: 'Reconcile all orders flagged as unsettled and emit a summary',
inputSchema: {
type: 'object',
properties: { since: { type: 'string', description: 'ISO 8601 timestamp' } },
},
},
];

async reconcileUnsettled({ since }) {
/* ... */
}
}

The corresponding instance method runs through Harper's normal transactional() envelope, so per-record allow* predicates and audit logging behave the same way as regular verb dispatch. Authentication is "is the user logged in" only — finer-grained gating is the method's responsibility.

exportTypes gating​

The MCP surface mirrors the public REST surface. A Resource is filtered out of MCP enumeration entirely when its registration sets exportTypes.mcp = false:

server.http(Resource, { name: 'internal-thing', exportTypes: { mcp: false } });

This is independent of the http exportType — the only switch that operators set to scope MCP visibility is mcp.

Resources surface​

Both profiles serve resources/list, resources/read, and resources/templates/list. Row-backed application resources can additionally be watched with resources/subscribe — see MCP Resource Subscriptions.

harper:// URIs​

URIProfileContent
harper://aboutbothServer version, profile name, protocol versions, capabilities.
harper://operationsoperationsUser-filtered list of allowed operation names.
harper://openapiapplicationThe OpenAPI 3.0.3 document for the application's REST surface.
harper://schema/{database}/{table}applicationPer-table attribute definitions, RBAC-filtered at read time.

The schema URIs honor each user's permission[db].tables[table] walk — a user with no read or describe perm on a table gets a "permission denied" response from resources/read.

https:// URIs​

The application profile additionally exposes every exported Resource (that passes the exportTypes.mcp gate and the hasRestVerbs check) as an https://<host>:<port>/<path> URI. These resolve in-process via Resources.getMatch(path, 'mcp') — there is no outbound HTTP request. The body returned by resources/read is a small descriptor:

{
"uri": "https://node.example.com:9926/Product",
"path": "Product",
"database": "data",
"table": "product",
"hint": "Use the corresponding `get_*` or `search_*` tool from `tools/list` to fetch records."
}

Per-record reads go through the tools surface, where each Resource's allow{Read,…} predicates run. The resources/read descriptor itself is a fast, side-effect-free hint — not a capability.

notifications/*/list_changed​

After the initialize handshake, an MCP client opens GET /mcp to keep an SSE channel open for server-push frames. Harper subscribes to its existing role-cache and schema-reload event channels and, whenever one fires:

  1. Walks the per-worker session registry.
  2. For each session on that profile, re-resolves the bound user (so any role/permission mutations occurring between the handshake and the event are evaluated against current permissions, rather than a frozen snapshot).
  3. Recomputes the session's tools/list and resources/list against the fresh user.
  4. Compares to the snapshot taken at session start (or after the last fire).
  5. Emits notifications/tools/list_changed and / or notifications/resources/list_changed if and only if the visible set actually changed.

Sessions whose visible surface is unchanged see nothing — there is no broadcast. The notification carries no diff payload; clients call tools/list and resources/list again to fetch the new state.

The GET-SSE channel itself closes on:

  • Explicit DELETE /mcp (when mcp.session.allowClientDelete is true).
  • The session being TTL-evicted from system.mcp_session after mcp.session.idleTimeoutSeconds.
  • The client dropping the underlying TCP connection (Harper's HTTP server propagates that to the iterator's return(), which the registry's on-close listener catches).
  • An idle-prune sweep (belt-and-braces against the cases above missing — see Configuration / mcp.session.idleTimeoutSeconds).