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 on Harper's curated destructive list, which is enumerated in core rather than matched by prefix. MCP hosts SHOULD prompt before invoking. See Tool Metadata for the exact membership and for the damaging operations that are absent from it.

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/BigIntinteger, Floatnumber, String/IDstring, Booleanboolean, Date[string, number], Bytes/Blobstring 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.
  • Every remaining attribute is included. Schemas are derived once at registration time, with no caller permissions in scope, so attribute_permissions does not narrow them — the descriptor a restricted user receives is identical to a super-user's.

attribute_permissions is enforced when the tool runs, not when it is described: runtime Table.allowUpdate / Table.allowCreate and the per-attribute checks reject a restricted read or write regardless of what the advertised schema listed. What RBAC does filter is the tool listtools/list omits a Resource's verb tools unless the caller holds the matching table-level permission (read or describe for get_* / search_*, and insert / update / delete for the write verbs). Because the schema is caller-agnostic, treat every attribute name and description in it as visible to any authenticated caller who can see the tool, and use @hidden for anything that shouldn't be.

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 MCP transport audits the tools/call, but invokes the custom instance method directly. It does not open a Resource transaction or run an allow* gate automatically.

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 inside the method and throw when the caller doesn't qualify.

A custom method must pass an armed context or target when delegating to a static Resource operation. The MCP-created instance context carries the authenticated user and a one-shot authorize flag, so the first call can use Orders.get(target, this.getContext()); that flag is consumed by the first static Resource operation. For every later delegated operation, use a fresh RequestTarget, set target.checkPermission = true so authorization derives from context.user, and pass this.getContext(). Never accept checkPermission from tool arguments or other client input.

Custom mcpResources opt-in

Added in: v5.1.18

A component author can expose arbitrary content — documentation pages, rendered reports, any text or blob payload — as MCP resources under author-chosen URIs by declaring a static mcpResources array:

// Template parameters are client-controlled, so an allowlist decides what
// content is reachable — see Access control below.
const PAGES = {
'guides/install.md': '# Install\n\nnpm install -g harper',
'guides/deploy.md': '# Deploy\n\nharper deploy',
};

class DocsPages extends Resource {
static mcpResources = [
{
uri: 'docs:///index',
name: 'docs index',
description: 'List of all documentation pages',
mimeType: 'text/markdown',
method: 'readIndex',
},
{
uriTemplate: 'docs:///{+path}',
name: 'docs page',
description: 'A documentation page by path',
mimeType: 'text/markdown',
method: 'readPage',
completions: { path: ['guides/install.md', 'guides/deploy.md'] },
},
];

async readIndex() {
return {
text: Object.keys(PAGES)
.map((page) => `- docs:///${page}`)
.join('\n'),
mimeType: 'text/markdown',
};
}

async readPage(params) {
const body = PAGES[params.path];
if (!body) throw new Error(`no such page: ${params.path}`);
return { text: body, mimeType: 'text/markdown' };
}
}

Each entry declares exactly one of uri (fixed — listed by resources/list) or uriTemplate (listed by resources/templates/list). Templates use {name} to match a single path segment and {+name} to match across segments; resources/read extracts the parameters and invokes the named instance method on the live class as (params, context):

  • params — the extracted template parameters as a { [name]: string } map, percent-decoded. A fixed-uri entry gets {}.
  • context{ user, profile }: the MCP session's user ({ username, role }, where role.permission is the RBAC block) and the profile name, always 'application' for custom resources.

The method returns a string (text content), { text, mimeType? }, { blob, mimeType? } (base64 binary), or any other object (serialized as JSON).

Notes:

  • Reads dispatch on the live registry class, so an exported resources.js subclass's method (and its access control) always wins — the same rule as custom mcpTools.
  • completions optionally declares candidate values per template parameter, served by completion/complete.
  • Custom URIs must use an author-chosen scheme (docs:///... above), and that scheme must be a literal — a template whose scheme position holds a parameter ({scheme}://...) is rejected. The reserved schemes — harper:, harper+rest:, http:, https: — are rejected at registration so custom entries cannot shadow the built-in surfaces.
  • Invalid entries are skipped with a warning in the server log rather than failing the profile rebuild: a missing name or method, both or neither of uri/uriTemplate, a malformed or parameter-less template, a reserved scheme, or a method that is not a function on the prototype.
  • A read error from the method surfaces to the client as a sanitized JSON-RPC error; the raw error is written to the server log.

Template parameters are client-controlled

A template parameter is whatever the client put in the URI it asked to read, so treat it as untrusted input. {+name} matches across /, and the captured value is percent-decoded before your method sees it — a read of docs:///../../secret calls readPage({ path: '../../secret' }). Resolve the parameter through an allowlist (the PAGES map above) or validate containment before any filesystem- or URL-backed load; never interpolate it straight into a path.

Access control for custom resources

The MCP layer runs no authorization check on a custom resource. Entries are listed to every session on the profile — including anonymous, unauthenticated ones where the deployment allows them (the public-docs case this feature targets) — and RBAC is delegated to the Resource, the same as custom mcpTools. Enforcement is the read method's responsibility.

For content the method renders itself, gate on context.user and throw:

class InternalReports extends Resource {
static mcpResources = [
{
uriTemplate: 'reports:///{id}',
name: 'internal report',
description: 'Rendered internal report',
method: 'readReport',
},
];

async readReport(params, context) {
if (!context.user?.role?.permission?.super_user) throw new Error('not authorized');
return { text: renderReport(params.id), mimeType: 'text/markdown' };
}
}

An anonymous session arrives as { username: '' }, not undefined, so if (!context.user) does not reject it. Test the field you actually require — a non-empty context.user.username, or the specific context.user.role.permission entry.

When the content wraps guarded table data, fetch it through the exported (routing) Resource — the subclass whose operation override is the gate REST enforces Added in: v5.2.0:

import { getUser } from 'harper';

// The exported subclass is the routing Resource. Its `get` override is the
// authorization gate, and routing through this class is the only thing that
// runs it.
export class Order extends tables.Order {
static async get(target, context) {
const order = await super.get(target, context);
const user = getUser();
if (order && order.customerId !== user?.username) throw new Error('not authorized');
return order;
}
}

export class OrderDocs extends Resource {
static mcpResources = [
{
uriTemplate: 'orders:///{+orderId}',
name: 'order by id',
description: 'Fetch an order by id',
mimeType: 'application/json',
method: 'readOrder',
},
];

async readOrder(params) {
const order = await Order.get(params.orderId); // runs Order's get override
if (!order) throw new Error(`no such order: ${params.orderId}`);
return { text: JSON.stringify(order), mimeType: 'application/json' };
}
}

Put the check in an operation override (get, put, delete) rather than in an allow* hook: the allow* hooks are deprecated one-time operation gates, and an override receives the complete target and context. See Authorization.

getUser() reads the authenticated user from the current async request context. From 5.2.0 the MCP layer runs a custom resource's read inside a transaction that carries the calling MCP session user, which is what makes getUser() return that user inside the override; an AccessViolation raised on the way surfaces to the client as permission denied rather than a generic read failure. That ambient transaction is also why the fetch above passes no context of its own — forwarding the resource's own context (Order.get(target, this.getContext())) would start an independent transaction and drop the shared snapshot.

warning

Do not fetch guarded rows through the base table class (tables.Order.get(id)). The base class does not carry the exported subclass's override, so the gate never runs and a user holding table-level read receives rows the same request is denied over REST (harper#1735).

On 5.1.x the read does not run inside a user-carrying transaction at all: the context.user gate above works, but a delegated guarded fetch has no user to authorize against. Serve guarded content from 5.2.0 or later.

If a Resource still relies on the deprecated allow* hooks, a delegated call arms them with target.checkPermission = true on a fresh RequestTarget. Set it to true only — never copy a permission object into it, and never take it from client input such as a template parameter.

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. The exportTypes map is supplied to the registration call — server.resources.set(path, Resource, exportTypes) — not to server.http (which registers HTTP handlers and does not read exportTypes), and a static exportTypes field on the class is not read either:

server.resources.set('internal-thing', Resource, { 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.

harper+rest:// URIs

Changed in: v5.1.18

The application profile additionally exposes every exported Resource (that passes the exportTypes.mcp gate and the hasRestVerbs check) as a harper+rest://<host>:<port>/<path> URI. Earlier releases listed these under http(s)://, which the MCP spec reserves for resources a client can fetch directly from the web; legacy http(s):// URIs continue to work for resources/read and resources/subscribe. 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": "harper+rest://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.

Custom content URIs

Author-declared mcpResources entries (see Custom mcpResources opt-in) appear alongside the built-in surfaces: fixed URIs in resources/list, templates in resources/templates/list. A registered custom URI always wins over the discovered surfaces on resources/read.

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).