| title | MCP Tools and Resources |
|---|
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.
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.
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.
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.
tools/list is filtered through canRoleInvokeOperation so each session sees only the operations its user can actually call:
super_usersees everything in the allow list.- A user with
structure_user: truesees schema-structure operations (create_schema,drop_table,create_attribute, etc.) in addition to anything inpermission.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.
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 prototype | Tool name | Schema 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).
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 schemas come from Table.attributes — or, for a programmatic Resource that declares no attributes, from its static properties , whose JSON Schema types pass through unchanged:
- Harper types map to JSON Schema primitive types (
Int/Long/BigInt→integer,Float→number,String/ID→string,Boolean→boolean,Date→[string, number],Bytes/Blob→stringwithcontentEncoding: base64). - Nested
ObjectandArrayattributes recurse into theirproperties/elements. nullable: trueadds"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_permissionsnarrow the schema per requesting user: attributes the user cannot read are stripped fromget_*/search_*schemas; attributes the user cannot insert/update are stripped fromcreate_*/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.
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.
Custom tools are exposed to any MCP session — including anonymous, unauthenticated ones. Unlike the auto-generated verb tools (which are RBAC-filtered per user at tools/list time and enforce table permissions on call), the MCP layer performs no authentication or ACL check for a custom tool: it is listed to every session and its method executes even when no user is logged in (context.user may be empty). Access control is entirely the method's responsibility — to restrict a tool to authenticated users or specific roles, check context.user (or rely on the per-record allow* predicates its data access triggers) inside the method and throw when the caller doesn't qualify.
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.
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.
| URI | Profile | Content |
|---|---|---|
harper://about |
both | Server version, profile name, protocol versions, capabilities. |
harper://operations |
operations | User-filtered list of allowed operation names. |
harper://openapi |
application | The OpenAPI 3.0.3 document for the application's REST surface. |
harper://schema/{database}/{table} |
application | Per-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.
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.
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:
- Walks the per-worker session registry.
- 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).
- Recomputes the session's
tools/listandresources/listagainst the fresh user. - Compares to the snapshot taken at session start (or after the last fire).
- Emits
notifications/tools/list_changedand / ornotifications/resources/list_changedif 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(whenmcp.session.allowClientDeleteistrue). - The session being TTL-evicted from
system.mcp_sessionaftermcp.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).