You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
This issue is the design + umbrella tracker. The work ships as 11 stacked PRs (see Delivery Plan in the design below). The list below mirrors the GitHub Sub-issues panel above; check each item as the corresponding PR merges.
Source of truth: this issue is canonical for the design. If the design needs to change mid-rollout, edit this issue body; do not let the design drift across multiple places.
Currently agents that want to administer or otherwise interact with Harper must do so through the operations API. The LLM might be able to figure how to interact faster by speaking in MCP.
Proposed design:
Native MCP Server for Harper — Design
Source Specifications
This design targets MCP protocol revision 2025-06-18 (the current stable
revision at the time of writing). All section anchors below resolve under https://modelcontextprotocol.io/specification/2025-06-18/. The wire schema
is the canonical schema.ts.
Key spec sections this design is built on (each is cited inline where it
informs a decision):
Throughout the document, citations of the form (MCP §transports) point
back to the corresponding row above.
Context
Harper today ships no in-tree Model Context Protocol (MCP) integration. An
external addon (HarperFast/mcp-server) exposes a small read-only HTTP /mcp
endpoint that lists tables and fetches rows with equality filters only. As
agentic clients (Claude Desktop, Cursor, the Claude API, custom agents) become
a first-class way that operators and developers use databases, the addon is
too narrow:
It is read-only and equality-only, so agents cannot do real work.
It does not distinguish operator/admin workflows from application workflows.
It is bolted on, not driven by Harper's own RBAC, schema, and resource
registry.
This design replaces the addon with a native MCP server built into Harper that
exposes two independent MCP profiles:
Operations MCP ("admin") — mounted on the Harper operations port,
wrapping the operations API (the 91 operations in OPERATION_FUNCTION_MAP),
honoring the existing verifyPerms / role / super_user / operation
allowlist model.
Application MCP — mounted on the Harper HTTP port, wrapping every
exported Resource (auto-exported tables + user-defined Resource classes),
honoring allowRead/Create/Update/Delete and attribute-level permissions.
Both profiles speak MCP Streamable HTTP transport. A bundled harper mcp
stdio subcommand proxies stdio MCP clients to either profile.
Goals
Native, supported MCP surface that exposes the full read-and-write power
of Harper, gated entirely by the existing RBAC.
Two well-bounded profiles so operators and application users see only the
surface relevant to them.
LLM-friendly tool shapes (per-resource and per-operation tools with rich
JSON Schema) with per-user filtering of tools/list.
Drop-in for desktop MCP clients via stdio.
Auditable, paginated, safety-annotated.
Non-goals (v1)
SQL exposure on either profile.
Resource subscriptions / live notifications (designed-for, deferred to v2).
OAuth 2.1 protected-resource flow (deferred to v1.1).
Sampling / elicitation MCP features.
Per-tool rate limiting — promoted into v1 after spec audit: MCP
§server/tools → Security Considerations states the server MUST
rate-limit tool invocations. See "Safety & Observability" below.
Both profiles implement the standard MCP resource methods defined in MCP
§server/resources: resources/list (enumerates the URIs below), resources/read (returns
content for a URI), and resources/templates/list (declares URI templates
such as harper://schema/{db}/{table}). The URIs below are the v1 surface.
We do not declare the subscribe capability in v1 (see Open Items).
URI schemes: https:// (default for app) and harper:// (synthetic only)
https:// (canonical for app-profile resources that exist in REST).
Any resource exported on the Harper HTTP port is already addressable
over REST at a real, resolvable URL (e.g., https://harper.example.com/Product/abc-123). For those, the MCP
resource URI is the real REST URL. Two reasons:
The URI is dereferenceable outside the MCP session — an operator,
a tool, or even a different MCP server can fetch the same resource
by URL with the same auth.
The LLM gets one consistent identifier across REST docs, OpenAPI
output (generateJsonApi()), MCP tool results, and resources/read
responses — no harper:// ↔ https:// translation step.
Concretely, resources/list on the Application profile returns the
absolute HTTP URL the resource lives at; resources/read fetches
the in-process resource (it does not make a real outbound HTTP
call — it short-circuits through Resources.getMatch(url) / Resources.call()).
harper:// (synthetic / metadata only). For things that don't
have a real REST endpoint — server metadata, JSON-Schema dumps of
tables, the OpenAPI doc itself, the operations catalog — we mint URIs
in a custom harper:// scheme. These are virtual: the only way to
dereference them is via resources/read on this MCP server. Authority
(the // part) is intentionally empty — the connected MCP server is the authority.
Concrete URI surface in v1:
Profile
URI
Resolver reads from
mimeType
Application
https://<host>:<httpPort>/<resourcePath>(one per exported Resource)
Table.attributes on the resolved Resource class, filtered by the user's attribute_permissions
application/json
Application + Operations
harper://about
static server metadata (version, profile, enabled features)
application/json
Operations
harper://operations
the user-filtered subset of OPERATION_FUNCTION_MAP plus the curated JSON Schemas for each operation
application/json
resources/templates/list advertises the parameterized forms
(harper://schema/{database}/{table} and the https://... template for
app resources) so an LLM can construct URIs for tables it discovers via
tools. Every read is re-checked against current RBAC — a URI returned in resources/list is not a capability token, so revoking a role mid-session
causes subsequent reads to fail with an isError result rather than
serving stale content.
Schema/role changes emit notifications/resources/list_changed on the
session's SSE channel (same hook as tools/list_changed — see "Session
bookkeeping" below).
Operations MCP (admin)
Transport: Streamable HTTP at POST <operationsPort>/mcp (path
configurable).
Auth: Reuse authHandler (server/serverHelpers/serverHandlers.ts); same
Basic / JWT / mTLS that the operations API already accepts. Same user/role
resolution.
Tools: One MCP tool per Harper operation (~91). Tool shape follows MCP §server/tools →
"Listing Tools"
(name, description, inputSchema, optional outputSchema, annotations). Each generated tool delegates to the existing OPERATION_FUNCTION_MAP handler — there is no re-implementation of
operation logic. Tools are filtered per user via the
existing requiredPermissions map (utility/operation_authorization.js)
and the user's role (super_user / structure_user / cluster_user / operation
allowlist / DB+table+attribute grants).
harper://about — server version, enabled profile, available roles,
pointers to key tools.
Application MCP
Transport: Streamable HTTP at POST <httpPort>/mcp (path configurable).
Auth: Reuse the application HTTP auth chain — same Basic / JWT / mTLS /
custom authenticators that protect REST today. Anonymous role honored when
Harper is configured to allow it.
Tools: Per-resource generated tools driven by the Resources registry
(resources/Resources.ts):
For each exported Resource (tables and user-defined Resources), publish
only the verbs the class actually implements (overrides static get/post/put/patch/delete/search).
Generated tool set per resource: get_<r>, search_<r>, create_<r>, update_<r>, delete_<r>.
Tool name sanitization. Resource paths can contain characters
invalid for MCP tool names (which must match [A-Za-z0-9_-]{1,64}).
Sanitization rule: replace / and . with _, drop other invalid
characters. On collision (e.g., dev.users and prod.users both
sanitize to users), disambiguate by prepending the database segment: dev_users, prod_users. If still colliding, append a short hash of
the original path. The sanitized name plus the canonical path are both
surfaced in the tool description so the LLM can disambiguate visually.
For custom Resources, additional methods are opt-in via a static mcpTools declaration (or @mcpTool decorator) on the class. Each entry
names the method, an MCP tool name, an input JSON Schema, and a
description. Reflection alone never publishes arbitrary methods.
Input schemas for table-backed tools are derived from Table.attributes
(types, nullable, primary key) at registration time. For custom
Resources, the input schema is whatever the developer declares in
the static mcpTools entry. Reflecting TypeScript types from
custom Resource classes into JSON Schema is out of scope for v1 —
developers write the JSON Schema by hand alongside the method they
are exposing.
All tools delegate to the existing Resource.get/post/... invocation
path; the per-record allowRead/Create/Update/Delete predicates run
inside transactional() exactly as they do for REST today. The MCP
layer does not re-implement RBAC enforcement — it only shapes which
tools are advertised in tools/list (see "Tool-list filtering"
below for the two-step class-level + role-walk mechanism).
Resources:
https://<host>:<httpPort>/<resourcePath> — one URI per exported
Resource (the actual REST URL). These are the resources that exist
in REST; the MCP URI is the same identifier so it stays consistent
across REST docs, OpenAPI, and MCP responses. resources/read
short-circuits through Resources.getMatch(url) rather than making
an outbound HTTP call.
harper://openapi — output of existing generateJsonApi()
(resources/openApi.ts), RBAC-filtered. Synthetic.
Tool-list filtering
tools/list is computed per authenticated session against the resolved
user. The mechanism differs from earlier drafts: allowRead/Create/Update/ Delete on Resource are instance methods bound to a specific record
id (verified at resources/Resource.ts:413-427) — they cannot answer the
class-level question "does this user have any read access to this
resource." So they are not used for tools/list filtering. Instead:
Step 1 — class-level verb introspection (which tools are even publishable for a Resource). Reuse the same prototype-comparison
pattern that resources/openApi.ts:149-153 already employs:
A verb tool (get_*, create_*, update_*, …) is registered only if
the corresponding prototype slot is overridden.
Step 2 — user-level tool filtering (which publishable tools are visible to this user). Walk user.role.permission[database].tables[ table] directly — the same pattern used by dataLayer/schemaDescribe.ts:29-49:
attribute_permissions further narrow input schemas (restricted fields
are dropped from the JSON Schema). For super_user the whole filter is
short-circuited.
Step 3 — operations profile. Walk OPERATION_FUNCTION_MAP keys; for
each operation, run a class-level permission predicate against the
user (the static prefix of what verifyPerms does today — super_user / structure_user / cluster_user flags and the operations allowlist on the role). Operations whose check is per-call
(e.g., depends on a specific schema/table that's not in the request
yet) are included whenever the user has any role-level permission to
invoke them — the final per-call check still runs at tool-call time.
Runtime enforcement is unchanged. Tool calls still flow through transactional() which invokes the instance-bound allowRead/Create/Update/Delete for the specific record(s) being
operated on. The schema-level filtering in Steps 1–3 is a UX optimization
(so the LLM only sees what's relevant) — it is not a security
boundary. v1 includes a verification test where the role's tool list
omits update_product, the LLM nonetheless sends an update_product
call, and the runtime predicate rejects it with an isError tool result.
tools/list_changed session bookkeeping
Driven by the notifications/tools/list_changed mechanism in MCP
§server/tools → "List Changed
Notification";
the server advertises tools.listChanged: true in its initialize
response capabilities and emits the notification when the list changes.
The component maintains a Session table in-memory: session id → { user, role, profile, sseStream? }. Two event hooks feed it:
Role/permission changes. Harper already emits role-cache invalidation
when alter_role / alter_user runs (security/user.ts). The MCP
component subscribes; on event, for each session whose user/role is in
the affected set, recompute tools/list, diff against the cached list,
and if changed send notifications/tools/list_changed over that
session's SSE stream.
Schema changes. Harper emits schema-reload events on create_schema/create_table/drop_table/GraphQL reload. On event,
recompute affected sessions' tool lists similarly.
The component never broadcasts to all sessions; bookkeeping is per-session,
so users never learn about schema or role changes for objects they cannot
see.
Transports
Streamable HTTP
Implements MCP Streamable HTTP as specified in MCP §basic/transports
→ "Streamable HTTP".
This is the only HTTP transport defined in the 2025-06-18 revision; the
older HTTP+SSE transport from rev 2024-11-05 is not what we implement.
Single endpoint per profile (default /mcp), per the spec:
POST /mcp — client sends JSON-RPC. The server response is either application/json (single response) or text/event-stream (one or more
messages) at the server's discretion. See MCP §transports →
"Sending Messages to the Server" and "Listening for Messages from the
Server".
For client-sent JSON-RPC notifications or responses on POST /mcp,
the server MUST return HTTP 202 Accepted with no body (MCP §transports
→ "Sending Messages to the Server", item 4). This applies to notifications/initialized and to client responses to server-initiated
requests.
GET /mcp — optional, opens a long-lived text/event-stream for
server-initiated messages (notifications, tools/list_changed, future
resource update pushes). If the server does not offer a GET stream at a
given moment it MUST return HTTP 405. MCP §transports → "Listening for
Messages from the Server".
DELETE /mcp — clients SHOULD use this to explicitly terminate a
session; the server MAY return 405 if it does not allow client-initiated
termination. MCP §transports → "Session Management" (5).
Sessions are tracked via the Mcp-Session-Id response header on initialize and required on subsequent requests; the ID is a UUIDv4
(visible-ASCII, satisfies the 0x21–0x7E constraint). Requests carrying a
terminated session id MUST receive HTTP 404 so the client knows to
re-initialize. MCP §transports → "Session Management".
MCP-Protocol-Version header is REQUIRED on every HTTP request after initialize and MUST match the version negotiated during
initialization. The server MUST respond 400 Bad Request to an invalid
or unsupported version. v1 supports 2025-06-18 (preferred) and 2025-03-26 (for backward compatibility); requests with no header are
treated as 2025-03-26 per the spec's compatibility rule. MCP
§transports → "Protocol Version Header".
The server MUST validate the Origin header on every request.
The MCP endpoint reuses the existing per-port CORS access list — http_corsAccessList on the HTTP port and operationsApi_network_corsAccessList on the operations port — so
there is no separate MCP-only origin list. Mismatches return HTTP
403. This blocks DNS-rebinding attacks.
Bind-address / interface decisions (e.g., 127.0.0.1 only for local
dev) are governed by the existing Harper network config (the host
port's bind address). The MCP component inherits whatever address the
host port is bound to; it does not introduce its own binding knob.
TLS termination, body-size limits, and compression inherit from the
host port.
Server-side integration with existing Harper machinery
The MCP component does not introduce new transport or content-type
handling. It plugs into the primitives that already exist:
Request/response abstraction. The component is registered as a
Harper HTTP handler with the same async function http(request, next) signature used by the rest of server/REST.ts:22. It consumes
Harper's own Request / Headers types (server/serverHelpers/ Request.ts, Headers.ts) — no Fastify dependency is added by
this component. Fastify remains optional in Harper and the MCP code
has no direct API surface against it.
SSE serialization. Streamable HTTP responses that need to push
multiple JSON-RPC messages reuse the existing text/event-stream
serializer registered in server/serverHelpers/contentTypes.ts:127-161,
feeding it an AsyncIterable<{ event?, data, id?, retry? }>. The GET /mcp channel is the same SSE plumbing.
JSON serialization. The default response body uses the existing application/json entry in the same contentTypes registry — no
bespoke JSON serializer.
Improvements made to that shared SSE / contentTypes infrastructure
(e.g., a new id-aware writer to enable MCP resumability later)
benefit every Harper component, not just MCP.
Resumability (designed-for, v2 work)
The spec's resumability mechanism (Last-Event-ID on GET, server-assigned id on SSE events, per-stream replay — MCP §transports → "Resumability and
Redelivery") is not implemented in v1. Sessions survive transient
disconnects only insofar as the client can re-initialize. v1 reserves the
SSE id field so future enablement is non-breaking.
stdio (harper mcp)
Follows the stdio transport defined in MCP §basic/transports →
"stdio":
newline-delimited JSON-RPC messages on stdin/stdout, stderr reserved for
logging.
The CLI is a new subcommand of the existing Harper CLI. It speaks MCP
over stdin/stdout to the parent client (Claude Desktop, Cursor, etc.) and
runs as an MCP Streamable HTTP client against the Harper instance.
Two connection modes — UDS first, network as fallback:
Local UDS (default when invoked on the same host as the running
Harper instance). The CLI connects to the operations API's existing
Unix domain socket at the path set by operationsApi_network_domainSocket (or the application equivalent
when --profile application is used and an app-port UDS is
configured). This is the same channel cliOperations.ts:119-131 uses
today for the rest of the Harper CLI. No credentials are
required in this mode — access is gated by filesystem permissions
on the socket, exactly as the rest of Harper's local CLI works.
Network (HTTPS). When --url is given (or HARPER_URL is set to
a value the CLI can't resolve to a local socket), the CLI connects
over Streamable HTTP using the headers and handshake documented in
the HTTP transport section. Auth is required in
this mode (token, basic, or mTLS).
Whichever mode is selected, the CLI implements a full MCP Streamable
HTTP client above the transport. Concretely:
Reads JSON-RPC messages from stdin and writes them to the upstream
transport as POST /mcp (or UDS equivalent), parsing the response as
either application/json or text/event-stream and emitting each
contained message on stdout.
Persists Mcp-Session-Id from the initialize response and sends it
on all subsequent requests.
Opens a GET /mcp SSE channel once initialize succeeds and forwards
server-initiated notifications (tools/list_changed, future updates)
to stdout.
Strips Authorization and credential env vars from any error output
it writes; never echoes them to the parent client.
--socket <path> to pin a specific UDS (else auto-detected from Harper
config)
--url <https://host:port> to force network mode
--mount-path /mcp
No local state, no secret storage. In local UDS mode no env vars need
to be set; in network mode credentials come from env vars (see Client Configuration & Invocation).
Client Configuration & Invocation
This section covers how three classes of MCP clients connect to Harper's
two profiles in v1.
Custom agents using an MCP SDK (@modelcontextprotocol/sdk, the Python SDK, the Anthropic Agent SDK, etc.)
Streamable HTTP directly
https://<host>:<opsPort>/mcp (operations) or https://<host>:<httpPort>/mcp (application)
Bare curl / scripts / CI
Streamable HTTP directly
Same as above
The stdio CLI is provided for desktop clients that do not yet speak
Streamable HTTP natively. Direct HTTP is preferred when the client
supports it.
1. Desktop / IDE clients (stdio via harper mcp)
Desktop MCP clients launch a configured subprocess and speak MCP over its
stdin/stdout. The Harper config slots into the same JSON config files
those clients already use.
Local UDS (default). When the CLI is running on the same host as
the Harper instance and the operations API has a UDS configured, the
CLI connects to that socket directly. No credentials, no HARPER_URL needed — filesystem permissions on the socket gate
access, exactly as for the rest of the Harper local CLI.
Network HTTPS. Only when --url / HARPER_URL points at a
remote Harper, credentials are required.
Cursor / Continue / Cline — the same mcpServers shape applies (they
adopted Claude Desktop's schema). For Cursor, this lives in ~/.cursor/mcp.json.
Zed — uses the equivalent context_servers block in settings.json
with the same command / args / env keys.
harper mcp CLI surface:
harper mcp [--profile application|operations] # default: application
[--socket <path>] # pin a specific UDS
[--url <https://host:port>] # force network mode
[--mount-path /mcp] # endpoint path on the server
[--protocol-version 2025-06-18] # else negotiated
[--insecure] # skip TLS verify (dev only)
Resolution order: --socket ▸ auto-detect a local UDS from Harper config
▸ --url ▸ HARPER_URL. If no source resolves, the CLI exits with a
clear error.
Credentials (network mode only — not used in UDS mode, where access
is gated by filesystem permissions):
Env var
Maps to
Notes
HARPER_URL
Connection URL
Required for network mode unless --url given.
HARPER_TOKEN
Authorization: Bearer <token>
Preferred for network mode. Pair with a scoped JWT issued by Harper.
HARPER_AUTH
Full Authorization header verbatim
Escape hatch for unusual auth.
HARPER_USER / HARPER_PASS
Authorization: Basic <…>
Convenience for dev.
HARPER_CA_CERT
Custom CA bundle path
For private TLS.
HARPER_MCP_LOG_LEVEL
CLI log level on stderr
error (default), info, debug.
Under the hood the CLI is a full MCP Streamable HTTP client (see stdio (harper mcp)): it forwards stdin JSON-RPC to POST /mcp (over UDS or HTTPS as configured), parses application/json
or text/event-stream responses back to stdout, persists the Mcp-Session-Id, and opens a GET /mcp SSE channel for server-initiated
notifications.
2. Custom agents using an MCP SDK (direct Streamable HTTP)
Agents that already speak Streamable HTTP skip the CLI and connect
directly. Example with the TypeScript SDK
(@modelcontextprotocol/sdk@>=1.10):
The SDK manages the Mcp-Session-Id header, the initialize / notifications/initialized handshake, the GET SSE channel, and the MCP-Protocol-Version header automatically.
The Python SDK and Anthropic Agent SDK use the same model — pass the
endpoint URL and an auth header in the transport constructor; the SDK
does the rest.
3. Bare HTTP (curl / scripts / debugging)
Useful for smoke tests and CI. Full request/response framing — including
all required headers — is documented in the Sample Payloads section below.
Discovery aids for users
harper mcp ships two convenience subcommands aimed at humans setting up
clients, not at MCP traffic:
harper mcp print-config --client claude-desktop --profile application
emits a ready-to-paste JSON block for the named client.
harper mcp doctor runs initialize against the configured URL with
the configured credentials and prints whether each step (TLS, auth,
Origin, protocol version, tool count) succeeded.
Auth
MCP §basic/authorization
states authorization is OPTIONAL, but when supported on HTTP transports
implementations SHOULD conform to its OAuth 2.1 + Protected Resource
Metadata model. v1 of this design does not conform to that flow; it
reuses Harper's existing Basic / JWT / mTLS authentication. This is a deliberate, documented deviation for two reasons:
OAuth is an external component in Harper today. Wiring MCP-spec
OAuth 2.1 into core would either pull that external component into
core or introduce a parallel OAuth implementation in the MCP
component. Both add scope this design does not want to take on for
v1.
Harper customers already provision and manage Harper credentials, so
"drop a Harper credential into your MCP client config and it works"
is the lowest-friction v1.
v1.1 adds spec-conformant OAuth 2.1 once OAuth lands (or is brought
in-core) as a Harper-wide capability — the MCP component does not own
that work.
v1 (non-MCP-auth-conformant):
The MCP handler runs after the existing Harper auth middleware so request.user is populated identically to other endpoints.
On unauthenticated requests, the server returns 401 Unauthorized with
a standard WWW-Authenticate: Basic realm="harper" header (or Bearer
when JWT/mTLS is the configured method). This is HTTP-correct but is not the MCP-spec response form (which would point at PRM).
stdio CLI auth matches the MCP spec directly: "Implementations using
an STDIO transport SHOULD NOT follow this specification, and instead
retrieve credentials from the environment."
readOnlyHint: true for get_*, search_*, read operations, describe_*.
destructiveHint: true for delete_*, drop_*, clear_*.
idempotentHint: true for update_*, upsert, set_*.
openWorldHint: false (operations all hit Harper itself).
Per the spec, annotations are hints and not security boundaries —
Harper's RBAC remains the enforcement layer.
Audit logging. Every MCP tool invocation is logged through Harper's
existing audit-log path with: timestamp, profile, tool name, arg summary
(with hooks for PII redaction), user id/role, result status, duration.
Response size + pagination. Follows the cursor convention from MCP
§server/utilities/pagination:
opaque cursor request param, opaque nextCursor in result, server-defined
page size. Search tools cap default results
(mcp.application.searchMaxResults, default 100). The generated input
JSON Schema for every search_* tool explicitly includes:
cursor (string, optional, opaque) — round-trips through Harper's
existing conditions+offset machinery (resources/RequestTarget.ts).
Responses include a nextCursor field when more rows exist. Tool
descriptions explicitly tell the LLM that results may be truncated and
that it must pass cursor from the prior result to read further pages.
Tool-execution errors (permission denied, validation failure, record
not found, Harper operation rejection) return a successful JSON-RPC
response containing a tool result with isError: true and a structured
text content payload ({ kind: "permission_denied" | "validation" | "not_found" | "harper_error", message, details }). The spec is explicit:
"Errors that occur during tool execution should be returned in the
result object, not as MCP protocol-level errors. This allows the LLM to
see and potentially handle the error."
Protocol-level errors (malformed JSON-RPC, unknown method, invalid
params at the JSON-RPC layer, unknown tool name) return JSON-RPC error
responses (-32700 parse error, -32601 method not found, -32602
invalid params) per JSON-RPC 2.0
§5.1.
The MCP server never reveals Harper stack traces; full errors go to the
audit/server log.
Config (harperdb-config.yaml)
The native MCP server is configured under a new top-level mcp: key in harperdb-config.yaml. The two profiles are configured independently —
disabling either skips registration on the corresponding port. Full
schema, with every key annotated:
mcp:
# ─── Operations MCP (admin) ─────────────────────────────────────────# Mounted on the Harper operations port (operationsApi_network_port).# Wraps Harper's operations API. Inherits the same auth chain that# protects the operations API today.operations:
# Profile is *enabled by presence* of this sub-block (Harper# convention — same as `replication`). To disable, omit or comment# out the `operations:` block entirely. There is no `enabled` flag.mountPath: /mcp # HTTP path the MCP endpoint is bound to# on the operations port. Must not# collide with an existing operations# route. POST/GET/DELETE all live here.# Tool publishing controls.# `allow` is an array of glob patterns matched against Harper operation# names (the keys of OPERATION_FUNCTION_MAP, e.g. "describe_all",# "add_user", "csv_data_load"). The published tool set is computed as:# (operations matching ANY allow pattern)# − (operations matching ANY deny pattern)# ∩ (operations the authenticated user is permitted to run by RBAC)# Empty `allow` means "no operations" (fail-closed). Empty `deny` means# no deny filter. RBAC is always the final gate; the allow/deny knobs# narrow what MCP exposes regardless of what the user could technically# invoke via the raw operations API.allow: # Conservative default: read/describe# only. Destructive ops (drop_*, delete_*,# restart, set_configuration,# set_node_replication, deploy_component)# must be opted in explicitly even for# super_user, to blunt prompt-injection# blast radius.
- "describe_*"
- "list_*"
- "search_*"
- "get_*"
- "system_information"
- "read_log"
- "read_audit_log"deny: [] # Operation-name globs explicitly excluded.maxTools: 200# Hard cap on the size of `tools/list`# returned to any one client. If the# filtered+allowed set exceeds this, the# server paginates via `nextCursor` per# MCP §server/tools. Protects against# context blowups from very large RBAC# surfaces.rateLimit: # Server-enforced rate limiting (MCP# §server/tools Security Considerations# requires this). All values are per# session.perToolPerSecond: 10# Sustained per-tool calls/sec.perToolBurst: 20# Token-bucket burst capacity per tool.sessionConcurrency: 25# Max in-flight tool calls in one session.sessionPerSecond: 100# Sustained total calls/sec per session.# ─── Application MCP ───────────────────────────────────────────────# Mounted on the Harper HTTP port (http_network_port). Wraps every# exported Resource — tables auto-exported via GraphQL plus user-# defined Resource classes. Inherits the same auth chain that protects# the REST API today.application:
# Profile is *enabled by presence* of this sub-block (Harper# convention — same as `replication`). To disable, omit or comment# out the `application:` block entirely. There is no `enabled` flag.mountPath: /mcp # HTTP path. Must not collide with an# existing application route or with a# user-defined Resource at the same# path. Validation runs at startup.# `allow` / `deny` are glob patterns matched against the *generated*# tool names (e.g. "get_product", "search_order", "delete_*"), NOT# against resource paths. Empty `allow` means "publish all eligible# tools" for the application profile (contrast with operations,# where empty allow = no tools — the asymmetry is intentional: app-# side tools are RBAC-gated per resource and not as blast-radius-# sensitive as admin operations).allow: []deny: []maxTools: 500# Cap on `tools/list` page size; server# paginates via `nextCursor` beyond this.searchMaxResults: 100# Default cap on rows returned by any# search_<resource> tool call. Clients# may request fewer via the `limit`# argument; values above this are# clamped silently. Pagination via# `cursor` lets the LLM walk past it.rateLimit: # Same shape as operations.rateLimit.perToolPerSecond: 25perToolBurst: 50sessionConcurrency: 50sessionPerSecond: 200# ─── Cross-profile session settings ────────────────────────────────## `Origin` validation (required by MCP §transports Security Warning)# is performed against the existing per-port CORS access list —# `http_corsAccessList` on the HTTP port and# `operationsApi_network_corsAccessList` on the operations port — so# there is intentionally no separate MCP origin list. Configure CORS# the way you already do for REST/operations.session:
idleTimeoutSeconds: 1800# Idle timeout per Mcp-Session-Id. The# server terminates idle sessions and# returns HTTP 404 on subsequent# requests so clients re-initialize# (MCP §transports Session Management).allowClientDelete: true # When true, accept DELETE /mcp from# clients to terminate a session. When# false, the server returns 405 per# spec, and sessions are only ended by# idle timeout or server shutdown.
Defaults summary
Enablement is presence-based. A profile is on iff its sub-block is
present under mcp: (matches replication's convention — there is no enabled flag). When the sub-block is present, any omitted key falls
back to the Joi defaults shown above (e.g., mountPath defaults to /mcp).
mcp: absent → both profiles off; no MCP routes registered. This is
the default state and what existing deployments see on upgrade.
mcp.operations: {} → operations profile on, with every key at its
Joi default.
mcp.application: { mountPath: /foo } → application profile on at /foo, every other key at its Joi default.
mcp: {} (block present but no sub-keys) → neither profile on; same
as omitting mcp: entirely.
static/defaultConfig.yaml intentionally has nomcp: block — MCP is
opt-in, configured by adding the block.
Migration from the external addon
The native MCP server takes /mcp on the HTTP port by default.
The external HarperFast/mcp-server addon is archived at native launch.
Release notes link to a migration page mapping addon tools to native tools.
A short pre-archive release publishes a deprecation warning on load.
Implementation Location
Component:components/mcp/ (new) — registers itself like other
built-in components. Submodules:
CLI:harper mcp subcommand added to the existing CLI entry point.
Implementation under cli/mcp/.
Wiring points (read-only edits to existing files):
server/operationsServer.ts — register MCP handler when the mcp.operations sub-block is present in config (presence-based
enablement; no enabled flag).
server/http.ts (and/or server/REST.ts) — register MCP handler when
the mcp.application sub-block is present in config. Handler uses
the existing async function http(request, next) shape and Harper's Request / Headers types — no Fastify API surface.
server/serverHelpers/contentTypes.ts — reuse the existing text/event-stream and application/json entries; do not register
new content-type handlers from inside the MCP component.
UDS socket handling for harper mcp follows the pattern in bin/cliOperations.ts:119-131.
Subscribe to schema/role-change events for tools/list_changed.
Critical Files (read or modify during implementation)
server/operationsServer.ts (operations port wiring)
server/serverHelpers/serverUtilities.ts (initializeOperationFunctionMap, chooseOperation — source of truth for ops registry)
application/json serializers — reused, not replaced)
resources/Resource.ts (allowRead/Create/Update/Delete are instance methods; class-level introspection follows the prototype.method !== Resource.prototype.method pattern from resources/openApi.ts:149-153)
resources/Resources.ts (registry of exported resources)
bin/cliOperations.ts:119-131 (UDS connection pattern for the harper mcp stdio CLI)
resources/Table.ts (Table.attributes for schema derivation)
resources/graphql.ts (where tables become exports)
resources/openApi.ts (generateJsonApi — reuse for harper://openapi)
Operations MCP: initialize → tools/list → tools/call for describe_all, add_user, create_schema, csv_data_load, with
different roles.
Application MCP: same flow for get_*, search_*, create_*, update_*, delete_* on a seeded table; verify RBAC denials surface as
MCP errors with expected codes.
Pagination: search_* returns nextCursor; resuming returns the next
page; final page omits cursor.
tools/list_changed fires when alter_role runs in another session.
End-to-end with a real client:
harper mcp --profile application connected to Claude Desktop; verify
tool discovery, schema reads (harper://schema/..., harper://openapi), and CRUD against a sample app.
Streamable HTTP with curl-driven JSON-RPC against both ports.
Audit log spot-check: confirm every tool call appears with the expected
fields.
Regression: existing operations API and REST endpoints behave
identically when the mcp: block is absent from config (the default
state — MCP is opt-in via block presence).
sequenceDiagram
autonumber
participant C as MCP client
participant H as Harper HTTP port
participant A as Auth middleware
participant M as MCP component
participant R as Resource (e.g. Product)
C->>H: POST /mcp { "method": "initialize", ... }<br/>Authorization: Bearer ...
H->>A: verify creds
A-->>H: user, role resolved
H->>M: dispatch initialize
M->>M: create session (Mcp-Session-Id)
M-->>H: 200 application/json<br/>Mcp-Session-Id: 7f3a...
H-->>C: response
C->>H: POST /mcp { "method": "tools/list" }<br/>Mcp-Session-Id: 7f3a...
H->>A: verify creds
H->>M: dispatch tools/list
M->>R: enumerate exported resources<br/>filter by allowRead/Create/...
R-->>M: permitted resources + attributes
M-->>C: { tools: [get_product, search_product, ...] }
C->>H: POST /mcp { "method": "tools/call",<br/>"params": { "name": "search_product", "arguments": {...} } }
H->>M: dispatch tools/call
M->>R: Resource.search(target, ctx)
R->>R: RBAC + attribute filtering
R-->>M: rows (capped) + cursor
M-->>C: tool result (JSON) with nextCursor
Loading
Permission-denied tool call (returns isError, not JSON-RPC error)
sequenceDiagram
autonumber
participant C as MCP client
participant M as MCP component
participant R as Resource
C->>M: tools/call create_user
M->>R: Resource.post(record, ctx)
R-->>M: HdbError permission_denied
M->>M: map to tool result (isError=true)
M-->>C: { content: [...], isError: true }
Note over C,M: JSON-RPC response is success;<br/>LLM sees the error and can correct.
Loading
tools/list_changed on role mutation
sequenceDiagram
autonumber
participant Admin as Admin (alter_role)
participant Ops as Operations profile
participant Cache as Role cache
participant Sess as Session table
participant App as Application profile
participant SSE as GET /mcp SSE stream
participant C as Active MCP client
Admin->>Ops: tools/call alter_role
Ops->>Cache: update role
Cache-->>Sess: emit role-changed event
Sess->>App: for each session whose user is affected:<br/>recompute tools/list, diff
App-->>SSE: notifications/tools/list_changed
SSE-->>C: SSE event
C->>App: POST tools/list (fresh)
App-->>C: new filtered tool list
Loading
stdio CLI bridge
sequenceDiagram
autonumber
participant P as Parent client (stdio)
participant CLI as harper mcp
participant H as Harper HTTP MCP
P->>CLI: JSON-RPC line on stdin (initialize)
CLI->>H: POST /mcp + Authorization (from env)
H-->>CLI: 200, Mcp-Session-Id: 7f3a...
CLI->>CLI: persist session id
CLI-->>P: JSON-RPC line on stdout
CLI->>H: GET /mcp (open SSE channel)
H-->>CLI: SSE stream (notifications)
loop steady state
P->>CLI: stdin JSON-RPC
CLI->>H: POST /mcp + Mcp-Session-Id
alt single response
H-->>CLI: application/json
else streamed
H-->>CLI: text/event-stream (1..n messages)
end
CLI-->>P: stdout JSON-RPC line(s)
end
H-->>CLI: SSE event (e.g. tools/list_changed)
CLI-->>P: stdout notification
nextCursor is omitted on the final page. The client passes it as params.cursor on the next tools/list call. This is required by MCP
§server/tools → "Listing Tools" (pagination).
resources/read — app resource at its canonical HTTPS URL
App-exported resources are addressed by their real REST URL. The MCP
server resolves them in-process via Resources.getMatch(url) without an
outbound HTTP request.
"readOnlyHint / destructiveHint etc. are not in the MCP spec." —
Pushed back. They live on ToolAnnotations (MCP spec rev 2025-03-26+).
Kept.
"MCP HTTP transport requires opening an SSE channel first and then
POSTing to a separate URL." — Pushed back. That describes the older
HTTP+SSE transport. Streamable HTTP (2025-03-26+) uses a single /mcp
endpoint with POST and optional GET, where the POST response itself may
be SSE. We use Streamable HTTP exclusively.
EVP-Engineering review
#
Point
Status
1
"Use existing auth; OAuth deferred because external"
Accepted. Auth section now states OAuth is deferred because it's an external component today, not just timing.
2
"Rich JSON Schema via TS types is out of scope"
Accepted. Application MCP section now states reflecting TS types from custom Resources is out of v1 scope; developers hand-write the JSON Schema in the static mcpTools declaration.
3
"Rate limiting at MCP level before REST/ops feels inverted"
Acknowledged. Rate Limiting section now notes the session-scoped MCP limiter is not a substitute for global REST/operations rate limiting.
4
"App resources should use resolvable http: URIs by default"
Accepted. URI scheme section reworked: app-exported resources use their real https://host:port/<path> URLs. harper:// is now reserved for synthetic resources (schemas, OpenAPI, operations catalog, server metadata). Samples updated.
5
"allowRead/allowCreate are instance methods bound to a record id"
Accepted (real correction). Verified at resources/Resource.ts:413-427. Tool-list filtering section rewritten: class-level verb publication uses the prototype.method !== Resource.prototype.method pattern from resources/openApi.ts:149-153; user-level filtering walks user.role.permission[db].tables[table] directly (pattern from dataLayer/schemaDescribe.ts:29-49). Runtime enforcement via instance predicates is unchanged.
6
"127.0.0.1 binding is a network-layer concern, not MCP's"
Accepted. Removed; the MCP component inherits the host port's bind address.
7
"Why proxy stdio over the network instead of calling resource methods directly?"
Accepted. stdio CLI now defaults to local UDS (the same operationsApi_network_domainSocket channel bin/cliOperations.ts:119-131 uses), with network HTTPS only when --url points off-box. There is no in-process resource API to call directly today (verified — Resources.call() still requires a Request), so UDS is the closest available primitive.
8
"HARPER_USER for stdio is strange when UDS doesn't need it"
Accepted. Credentials are required only in network mode; local UDS mode is credential-free. Config examples updated.
9
"Don't increase Fastify dependence"
Accepted. Verified Harper has its own Request / Headers abstraction (server/serverHelpers/Request.ts, Headers.ts) used by server/REST.ts:22. MCP component uses the async function http(request, next) signature with Harper's types. "No Fastify API surface" called out explicitly.
10
"allowedOrigins should match existing CORS naming"
Accepted. Removed mcp.allowedOrigins. Origin validation now uses the existing per-port CORS access lists: http_corsAccessList and operationsApi_network_corsAccessList.
11
"Streamable HTTP should leverage existing SSE and contentTypes"
Accepted. The MCP component reuses the existing text/event-stream and application/json entries in server/serverHelpers/contentTypes.ts:127-161. Improvements made there (e.g., SSE id support for resumability) benefit every Harper component.
Rate Limiting (v1, required by spec)
MCP §server/tools → Security
Considerations
states the server MUST rate-limit tool invocations. v1 implements
this at MCP-session scope only — it is a per-session ceiling, not a
substitute for global REST/operations API rate limiting (which Harper
does not currently have and which is out of scope for this design). It
is intentional that we are landing session-scoped rate limiting on a
new component without yet having a global rate limiter for the
underlying APIs.
v1 implements:
Token-bucket per (session, tool) — default 10 calls/sec, burst 20.
When a limit is hit, the offending tools/call returns an MCP tool result
with isError: true and kind: "rate_limited" (per the error-mapping
rules above; it is not a JSON-RPC error).
Lifecycle Notes
Beyond the initialize request/response shown in the Sample Payloads
section, the v1 server implements the rest of MCP
§basic/lifecycle:
After the client receives the initialize response it MUST send notifications/initialized; on this POST the server returns HTTP 202
Accepted with no body.
The server SHOULD NOT send anything but ping / logging to a session
before that notification arrives.
Version negotiation: v1 advertises and accepts 2025-06-18
(preferred). If a client sends an older version we support
(2025-03-26), the server returns the client's version. If we do not
support the requested version, the server returns its latest supported
version and the client SHOULD disconnect (MCP §lifecycle → Version
Negotiation).
Compliance Audit Summary
The design has been audited against the 2025-06-18 spec. The table below
summarizes each spec MUST/SHOULD against this design.
Spec requirement
Source
Status
Streamable HTTP single endpoint with POST + GET
§transports
Conforms
Origin validation on every request
§transports Security Warning
Conforms (added)
Accept: application/json, text/event-stream on POST
§transports
Conforms (client requirement; server tolerates both)
202 Accepted on client notifications/responses
§transports 4
Conforms
MCP-Protocol-Version header on every request after init; 400 on bad version
§transports
Conforms
Mcp-Session-Id header; ASCII 0x21–0x7E; 404 on terminated
Implementation progress
This issue is the design + umbrella tracker. The work ships as 11 stacked PRs (see Delivery Plan in the design below). The list below mirrors the GitHub Sub-issues panel above; check each item as the corresponding PR merges.
resources/list,read,templates/list(https://+harper://)OPERATION_FUNCTION_MAPResourcesregistrylistChangednotifications + per-session bookkeepingharper mcp) with UDS-first, network fallbackmcpToolsdeclarationStacking order: #613 → #614 → ( #615, #616 in parallel ) → ( #617, #618, #620 in parallel ) → #619 → #622 → #623. #621 can run in parallel anywhere after #614.
Source of truth: this issue is canonical for the design. If the design needs to change mid-rollout, edit this issue body; do not let the design drift across multiple places.
Labels:
enhancement,area:mcp,feature:mcp-v1. Milestone:MCP v1.Currently agents that want to administer or otherwise interact with Harper must do so through the operations API. The LLM might be able to figure how to interact faster by speaking in MCP.
Proposed design:
Native MCP Server for Harper — Design
Source Specifications
This design targets MCP protocol revision
2025-06-18(the current stablerevision at the time of writing). All section anchors below resolve under
https://modelcontextprotocol.io/specification/2025-06-18/. The wire schemais the canonical
schema.ts.Key spec sections this design is built on (each is cited inline where it
informs a decision):
initializesample, session bookkeepingMcp-Session-Id, GET/POST + SSE framinginputSchema,ToolAnnotations,tools/list,tools/call,isError,structuredContent)resources/list,resources/read, templates,subscribe)resources/capability sectioncursor/nextCursor)tools/list_changedbookkeepingThroughout the document, citations of the form (MCP §transports) point
back to the corresponding row above.
Context
Harper today ships no in-tree Model Context Protocol (MCP) integration. An
external addon (
HarperFast/mcp-server) exposes a small read-only HTTP/mcpendpoint that lists tables and fetches rows with equality filters only. As
agentic clients (Claude Desktop, Cursor, the Claude API, custom agents) become
a first-class way that operators and developers use databases, the addon is
too narrow:
registry.
This design replaces the addon with a native MCP server built into Harper that
exposes two independent MCP profiles:
wrapping the operations API (the 91 operations in
OPERATION_FUNCTION_MAP),honoring the existing
verifyPerms/ role / super_user / operationallowlist model.
exported
Resource(auto-exported tables + user-defined Resource classes),honoring
allowRead/Create/Update/Deleteand attribute-level permissions.Both profiles speak MCP Streamable HTTP transport. A bundled
harper mcpstdio subcommand proxies stdio MCP clients to either profile.
Goals
of Harper, gated entirely by the existing RBAC.
surface relevant to them.
JSON Schema) with per-user filtering of
tools/list.Non-goals (v1)
Per-tool rate limiting— promoted into v1 after spec audit: MCP§server/tools → Security Considerations states the server MUST
rate-limit tool invocations. See "Safety & Observability" below.
Two-Profile Architecture
flowchart LR subgraph Clients CD[Claude Desktop / Cursor / IDE] Agent[Custom Agent / SDK] end CLI[harper mcp CLI<br/>stdio <-> Streamable HTTP] subgraph Harper["Harper server process"] direction TB OpsPort["Operations port<br/>(operationsServer.ts)"] HttpPort["HTTP port<br/>(server/http.ts + REST.ts)"] subgraph MCP["components/mcp/ (new)"] Proto[protocol/<br/>JSON-RPC + Streamable HTTP] OpsProf[operations-profile/<br/>tool registry + dispatch] AppProf[application-profile/<br/>tool registry + dispatch] Audit[audit/] Sessions[(session table<br/>per Mcp-Session-Id)] end Auth[Existing auth middleware<br/>Basic / JWT / mTLS] OpsMap[(OPERATION_FUNCTION_MAP<br/>91 operations)] ResReg[(Resources registry<br/>tables + custom)] RBAC{{verifyPerms / allowRead etc.}} end CD -- stdio --> CLI Agent -- "Streamable HTTP" --> OpsPort Agent -- "Streamable HTTP" --> HttpPort CLI -- "Streamable HTTP" --> OpsPort CLI -- "Streamable HTTP" --> HttpPort OpsPort --> Auth HttpPort --> Auth Auth --> Proto Proto --> OpsProf Proto --> AppProf Proto <--> Sessions OpsProf -->|delegates to| OpsMap AppProf -->|delegates to| ResReg OpsMap --> RBAC ResReg --> RBAC OpsProf --> Audit AppProf --> AuditMCP
resources/capabilityBoth profiles implement the standard MCP resource methods defined in MCP
§server/resources:
resources/list(enumerates the URIs below),resources/read(returnscontent for a URI), and
resources/templates/list(declares URI templatessuch as
harper://schema/{db}/{table}). The URIs below are the v1 surface.We do not declare the
subscribecapability in v1 (see Open Items).URI schemes:
https://(default for app) andharper://(synthetic only)MCP requires every resource to have a URI (MCP §server/resources →
"Common URI
schemes").
This design uses two URI schemes intentionally:
https://(canonical for app-profile resources that exist in REST).Any resource exported on the Harper HTTP port is already addressable
over REST at a real, resolvable URL (e.g.,
https://harper.example.com/Product/abc-123). For those, the MCPresource URI is the real REST URL. Two reasons:
a tool, or even a different MCP server can fetch the same resource
by URL with the same auth.
output (
generateJsonApi()), MCP tool results, andresources/readresponses — no
harper://↔https://translation step.Concretely,
resources/liston the Application profile returns theabsolute HTTP URL the resource lives at;
resources/readfetchesthe in-process resource (it does not make a real outbound HTTP
call — it short-circuits through
Resources.getMatch(url)/Resources.call()).harper://(synthetic / metadata only). For things that don'thave a real REST endpoint — server metadata, JSON-Schema dumps of
tables, the OpenAPI doc itself, the operations catalog — we mint URIs
in a custom
harper://scheme. These are virtual: the only way todereference them is via
resources/readon this MCP server. Authority(the
//part) is intentionally empty — the connected MCP serveris the authority.
Concrete URI surface in v1:
mimeTypehttps://<host>:<httpPort>/<resourcePath>(one per exported Resource)Resources.getMatch(url)/Resources.call(), RBAC-gatedhttps://<host>:<httpPort>/<resourcePath>?openapiorharper://openapigenerateJsonApi()(resources/openApi.ts), RBAC-filteredapplication/json(OpenAPI 3.0.3)harper://schema/{database}/{table}Table.attributeson the resolvedResourceclass, filtered by the user'sattribute_permissionsapplication/jsonharper://aboutapplication/jsonharper://operationsOPERATION_FUNCTION_MAPplus the curated JSON Schemas for each operationapplication/jsonresources/templates/listadvertises the parameterized forms(
harper://schema/{database}/{table}and thehttps://...template forapp resources) so an LLM can construct URIs for tables it discovers via
tools. Every read is re-checked against current RBAC — a URI returned in
resources/listis not a capability token, so revoking a role mid-sessioncauses subsequent reads to fail with an
isErrorresult rather thanserving stale content.
Schema/role changes emit
notifications/resources/list_changedon thesession's SSE channel (same hook as
tools/list_changed— see "Sessionbookkeeping" below).
Operations MCP (admin)
POST <operationsPort>/mcp(pathconfigurable).
authHandler(server/serverHelpers/serverHandlers.ts); sameBasic / JWT / mTLS that the operations API already accepts. Same user/role
resolution.
MCP §server/tools →
"Listing Tools"
(
name,description,inputSchema, optionaloutputSchema,annotations). Each generated tool delegates to the existingOPERATION_FUNCTION_MAPhandler — there is no re-implementation ofoperation logic. Tools are filtered per user via the
existing
requiredPermissionsmap (utility/operation_authorization.js)and the user's role (super_user / structure_user / cluster_user / operation
allowlist / DB+table+attribute grants).
harper://operations— JSON catalog of permitted operations (names,descriptions, param JSON Schemas).
harper://about— server version, enabled profile, available roles,pointers to key tools.
Application MCP
POST <httpPort>/mcp(path configurable).custom authenticators that protect REST today. Anonymous role honored when
Harper is configured to allow it.
Resourcesregistry(
resources/Resources.ts):Resource(tables and user-defined Resources), publishonly the verbs the class actually implements (overrides static
get/post/put/patch/delete/search).get_<r>,search_<r>,create_<r>,update_<r>,delete_<r>.invalid for MCP tool names (which must match
[A-Za-z0-9_-]{1,64}).Sanitization rule: replace
/and.with_, drop other invalidcharacters. On collision (e.g.,
dev.usersandprod.usersbothsanitize to
users), disambiguate by prepending the database segment:dev_users,prod_users. If still colliding, append a short hash ofthe original path. The sanitized name plus the canonical path are both
surfaced in the tool description so the LLM can disambiguate visually.
mcpToolsdeclaration (or@mcpTooldecorator) on the class. Each entrynames the method, an MCP tool name, an input JSON Schema, and a
description. Reflection alone never publishes arbitrary methods.
Table.attributes(types, nullable, primary key) at registration time. For custom
Resources, the input schema is whatever the developer declares in
the static
mcpToolsentry. Reflecting TypeScript types fromcustom Resource classes into JSON Schema is out of scope for v1 —
developers write the JSON Schema by hand alongside the method they
are exposing.
Resource.get/post/...invocationpath; the per-record
allowRead/Create/Update/Deletepredicates runinside
transactional()exactly as they do for REST today. The MCPlayer does not re-implement RBAC enforcement — it only shapes which
tools are advertised in
tools/list(see "Tool-list filtering"below for the two-step class-level + role-walk mechanism).
https://<host>:<httpPort>/<resourcePath>— one URI per exportedResource (the actual REST URL). These are the resources that exist
in REST; the MCP URI is the same identifier so it stays consistent
across REST docs, OpenAPI, and MCP responses.
resources/readshort-circuits through
Resources.getMatch(url)rather than makingan outbound HTTP call.
harper://schema/<db>/<table>— attribute list, types, primary key,indexes, relationships (RBAC-filtered). Synthetic — no REST URL.
harper://openapi— output of existinggenerateJsonApi()(
resources/openApi.ts), RBAC-filtered. Synthetic.Tool-list filtering
tools/listis computed per authenticated session against the resolveduser. The mechanism differs from earlier drafts:
allowRead/Create/Update/ DeleteonResourceare instance methods bound to a specific recordid (verified at
resources/Resource.ts:413-427) — they cannot answer theclass-level question "does this user have any read access to this
resource." So they are not used for
tools/listfiltering. Instead:Step 1 — class-level verb introspection (which tools are even
publishable for a Resource). Reuse the same prototype-comparison
pattern that
resources/openApi.ts:149-153already employs:A verb tool (
get_*,create_*,update_*, …) is registered only ifthe corresponding prototype slot is overridden.
Step 2 — user-level tool filtering (which publishable tools are
visible to this user). Walk
user.role.permission[database].tables[ table]directly — the same pattern used bydataLayer/schemaDescribe.ts:29-49:attribute_permissionsfurther narrow input schemas (restricted fieldsare dropped from the JSON Schema). For super_user the whole filter is
short-circuited.
Step 3 — operations profile. Walk
OPERATION_FUNCTION_MAPkeys; foreach operation, run a class-level permission predicate against the
user (the static prefix of what
verifyPermsdoes today —super_user/structure_user/cluster_userflags and theoperationsallowlist on the role). Operations whose check is per-call(e.g., depends on a specific schema/table that's not in the request
yet) are included whenever the user has any role-level permission to
invoke them — the final per-call check still runs at tool-call time.
Runtime enforcement is unchanged. Tool calls still flow through
transactional()which invokes the instance-boundallowRead/Create/Update/Deletefor the specific record(s) beingoperated on. The schema-level filtering in Steps 1–3 is a UX optimization
(so the LLM only sees what's relevant) — it is not a security
boundary. v1 includes a verification test where the role's tool list
omits
update_product, the LLM nonetheless sends anupdate_productcall, and the runtime predicate rejects it with an
isErrortool result.tools/list_changedsession bookkeepingDriven by the
notifications/tools/list_changedmechanism in MCP§server/tools → "List Changed
Notification";
the server advertises
tools.listChanged: truein itsinitializeresponse capabilities and emits the notification when the list changes.
The component maintains a
Sessiontable in-memory: session id →{ user, role, profile, sseStream? }. Two event hooks feed it:when
alter_role/alter_userruns (security/user.ts). The MCPcomponent subscribes; on event, for each session whose user/role is in
the affected set, recompute
tools/list, diff against the cached list,and if changed send
notifications/tools/list_changedover thatsession's SSE stream.
create_schema/create_table/drop_table/GraphQL reload. On event,recompute affected sessions' tool lists similarly.
The component never broadcasts to all sessions; bookkeeping is per-session,
so users never learn about schema or role changes for objects they cannot
see.
Transports
Streamable HTTP
→ "Streamable HTTP".
This is the only HTTP transport defined in the 2025-06-18 revision; the
older HTTP+SSE transport from rev 2024-11-05 is not what we implement.
/mcp), per the spec:POST /mcp— client sends JSON-RPC. The server response is eitherapplication/json(single response) ortext/event-stream(one or moremessages) at the server's discretion. See MCP §transports →
"Sending Messages to the Server" and "Listening for Messages from the
Server".
POST /mcp,the server MUST return HTTP 202 Accepted with no body (MCP §transports
→ "Sending Messages to the Server", item 4). This applies to
notifications/initializedand to client responses to server-initiatedrequests.
GET /mcp— optional, opens a long-livedtext/event-streamforserver-initiated messages (notifications,
tools/list_changed, futureresource update pushes). If the server does not offer a GET stream at a
given moment it MUST return HTTP 405. MCP §transports → "Listening for
Messages from the Server".
DELETE /mcp— clients SHOULD use this to explicitly terminate asession; the server MAY return 405 if it does not allow client-initiated
termination. MCP §transports → "Session Management" (5).
Mcp-Session-Idresponse header oninitializeand required on subsequent requests; the ID is a UUIDv4(visible-ASCII, satisfies the 0x21–0x7E constraint). Requests carrying a
terminated session id MUST receive HTTP 404 so the client knows to
re-initialize. MCP §transports → "Session Management".
MCP-Protocol-Versionheader is REQUIRED on every HTTP request afterinitializeand MUST match the version negotiated duringinitialization. The server MUST respond
400 Bad Requestto an invalidor unsupported version. v1 supports
2025-06-18(preferred) and2025-03-26(for backward compatibility); requests with no header aretreated as
2025-03-26per the spec's compatibility rule. MCP§transports → "Protocol Version Header".
Security headers (MCP §transports → Security Warning)
Originheader on every request.The MCP endpoint reuses the existing per-port CORS access list —
http_corsAccessListon the HTTP port andoperationsApi_network_corsAccessListon the operations port — sothere is no separate MCP-only origin list. Mismatches return HTTP
403. This blocks DNS-rebinding attacks.
127.0.0.1only for localdev) are governed by the existing Harper network config (the host
port's bind address). The MCP component inherits whatever address the
host port is bound to; it does not introduce its own binding knob.
host port.
Server-side integration with existing Harper machinery
The MCP component does not introduce new transport or content-type
handling. It plugs into the primitives that already exist:
Harper HTTP handler with the same
async function http(request, next)signature used by the rest ofserver/REST.ts:22. It consumesHarper's own
Request/Headerstypes (server/serverHelpers/ Request.ts,Headers.ts) — no Fastify dependency is added bythis component. Fastify remains optional in Harper and the MCP code
has no direct API surface against it.
multiple JSON-RPC messages reuse the existing
text/event-streamserializer registered in
server/serverHelpers/contentTypes.ts:127-161,feeding it an
AsyncIterable<{ event?, data, id?, retry? }>. TheGET /mcpchannel is the same SSE plumbing.application/jsonentry in the samecontentTypesregistry — nobespoke JSON serializer.
contentTypesinfrastructure(e.g., a new
id-aware writer to enable MCP resumability later)benefit every Harper component, not just MCP.
Resumability (designed-for, v2 work)
The spec's resumability mechanism (
Last-Event-IDon GET, server-assignedidon SSE events, per-stream replay — MCP §transports → "Resumability andRedelivery") is not implemented in v1. Sessions survive transient
disconnects only insofar as the client can re-
initialize. v1 reserves theSSE
idfield so future enablement is non-breaking.stdio (
harper mcp)Follows the stdio transport defined in MCP §basic/transports →
"stdio":
newline-delimited JSON-RPC messages on stdin/stdout, stderr reserved for
logging.
The CLI is a new subcommand of the existing Harper CLI. It speaks MCP
over stdin/stdout to the parent client (Claude Desktop, Cursor, etc.) and
runs as an MCP Streamable HTTP client against the Harper instance.
Two connection modes — UDS first, network as fallback:
Harper instance). The CLI connects to the operations API's existing
Unix domain socket at the path set by
operationsApi_network_domainSocket(or the application equivalentwhen
--profile applicationis used and an app-port UDS isconfigured). This is the same channel
cliOperations.ts:119-131usestoday for the rest of the Harper CLI. No credentials are
required in this mode — access is gated by filesystem permissions
on the socket, exactly as the rest of Harper's local CLI works.
--urlis given (orHARPER_URLis set toa value the CLI can't resolve to a local socket), the CLI connects
over Streamable HTTP using the headers and handshake documented in
the HTTP transport section. Auth is required in
this mode (token, basic, or mTLS).
Whichever mode is selected, the CLI implements a full MCP Streamable
HTTP client above the transport. Concretely:
transport as
POST /mcp(or UDS equivalent), parsing the response aseither
application/jsonortext/event-streamand emitting eachcontained message on stdout.
Mcp-Session-Idfrom theinitializeresponse and sends iton all subsequent requests.
GET /mcpSSE channel onceinitializesucceeds and forwardsserver-initiated notifications (
tools/list_changed, future updates)to stdout.
Authorizationand credential env vars from any error outputit writes; never echoes them to the parent client.
Configuration:
--profile operations|application(default:application)--socket <path>to pin a specific UDS (else auto-detected from Harperconfig)
--url <https://host:port>to force network mode--mount-path /mcpto be set; in network mode credentials come from env vars (see
Client Configuration & Invocation).
Client Configuration & Invocation
This section covers how three classes of MCP clients connect to Harper's
two profiles in v1.
Connection matrix
harper mcpas a subprocess@modelcontextprotocol/sdk, the Python SDK, the Anthropic Agent SDK, etc.)https://<host>:<opsPort>/mcp(operations) orhttps://<host>:<httpPort>/mcp(application)curl/ scripts / CIThe stdio CLI is provided for desktop clients that do not yet speak
Streamable HTTP natively. Direct HTTP is preferred when the client
supports it.
1. Desktop / IDE clients (stdio via
harper mcp)Desktop MCP clients launch a configured subprocess and speak MCP over its
stdin/stdout. The Harper config slots into the same JSON config files
those clients already use.
The CLI has two modes (see stdio above):
the Harper instance and the operations API has a UDS configured, the
CLI connects to that socket directly. No credentials, no
HARPER_URLneeded — filesystem permissions on the socket gateaccess, exactly as for the rest of the Harper local CLI.
--url/HARPER_URLpoints at aremote Harper, credentials are required.
Claude Desktop — local Harper (UDS, zero-config):
{ "mcpServers": { "harper-app": { "command": "harper", "args": ["mcp", "--profile", "application"] }, "harper-ops": { "command": "harper", "args": ["mcp", "--profile", "operations"] } } }Claude Desktop — remote Harper (HTTPS, credentials required):
{ "mcpServers": { "harper-app-prod": { "command": "harper", "args": ["mcp", "--profile", "application", "--url", "https://harper.example.com:9926"], "env": { "HARPER_TOKEN": "eyJhbGciOi..." } }, "harper-ops-prod": { "command": "harper", "args": ["mcp", "--profile", "operations", "--url", "https://harper.example.com:9925"], "env": { "HARPER_USER": "admin", "HARPER_PASS": "..." } } } }Cursor / Continue / Cline — the same
mcpServersshape applies (theyadopted Claude Desktop's schema). For Cursor, this lives in
~/.cursor/mcp.json.Zed — uses the equivalent
context_serversblock insettings.jsonwith the same
command/args/envkeys.harper mcpCLI surface:Resolution order:
--socket▸ auto-detect a local UDS from Harper config▸
--url▸HARPER_URL. If no source resolves, the CLI exits with aclear error.
Credentials (network mode only — not used in UDS mode, where access
is gated by filesystem permissions):
HARPER_URL--urlgiven.HARPER_TOKENAuthorization: Bearer <token>HARPER_AUTHAuthorizationheader verbatimHARPER_USER/HARPER_PASSAuthorization: Basic <…>HARPER_CA_CERTHARPER_MCP_LOG_LEVELerror(default),info,debug.Under the hood the CLI is a full MCP Streamable HTTP client (see
stdio (
harper mcp)): it forwards stdin JSON-RPC toPOST /mcp(over UDS or HTTPS as configured), parsesapplication/jsonor
text/event-streamresponses back to stdout, persists theMcp-Session-Id, and opens aGET /mcpSSE channel for server-initiatednotifications.
2. Custom agents using an MCP SDK (direct Streamable HTTP)
Agents that already speak Streamable HTTP skip the CLI and connect
directly. Example with the TypeScript SDK
(
@modelcontextprotocol/sdk@>=1.10):The SDK manages the
Mcp-Session-Idheader, theinitialize/notifications/initializedhandshake, the GET SSE channel, and theMCP-Protocol-Versionheader automatically.The Python SDK and Anthropic Agent SDK use the same model — pass the
endpoint URL and an auth header in the transport constructor; the SDK
does the rest.
3. Bare HTTP (curl / scripts / debugging)
Useful for smoke tests and CI. Full request/response framing — including
all required headers — is documented in the
Sample Payloads section below.
Discovery aids for users
harper mcpships two convenience subcommands aimed at humans setting upclients, not at MCP traffic:
harper mcp print-config --client claude-desktop --profile applicationemits a ready-to-paste JSON block for the named client.
harper mcp doctorrunsinitializeagainst the configured URL withthe configured credentials and prints whether each step (TLS, auth,
Origin, protocol version, tool count) succeeded.
Auth
MCP §basic/authorization
states authorization is OPTIONAL, but when supported on HTTP transports
implementations SHOULD conform to its OAuth 2.1 + Protected Resource
Metadata model. v1 of this design does not conform to that flow; it
reuses Harper's existing Basic / JWT / mTLS authentication. This is a
deliberate, documented deviation for two reasons:
OAuth 2.1 into core would either pull that external component into
core or introduce a parallel OAuth implementation in the MCP
component. Both add scope this design does not want to take on for
v1.
"drop a Harper credential into your MCP client config and it works"
is the lowest-friction v1.
v1.1 adds spec-conformant OAuth 2.1 once OAuth lands (or is brought
in-core) as a Harper-wide capability — the MCP component does not own
that work.
request.useris populated identically to other endpoints.401 Unauthorizedwitha standard
WWW-Authenticate: Basic realm="harper"header (orBearerwhen JWT/mTLS is the configured method). This is HTTP-correct but is
not the MCP-spec response form (which would point at PRM).
an STDIO transport SHOULD NOT follow this specification, and instead
retrieve credentials from the environment."
/.well-known/oauth-protected-resource.WWW-Authenticatewith the PRM URL on 401.resourceparameter) — MCP §auth"Token Audience Binding and Validation" is a MUST.
request.userregardless of how it gotpopulated, so v1.1 is additive on top of v1.
Safety & Observability
ToolAnnotationsdefined in MCP §server/tools →"Tool Annotations":
readOnlyHint: trueforget_*,search_*, read operations,describe_*.destructiveHint: truefordelete_*,drop_*,clear_*.idempotentHint: trueforupdate_*,upsert,set_*.openWorldHint: false(operations all hit Harper itself).Per the spec, annotations are hints and not security boundaries —
Harper's RBAC remains the enforcement layer.
existing audit-log path with: timestamp, profile, tool name, arg summary
(with hooks for PII redaction), user id/role, result status, duration.
§server/utilities/pagination:
opaque
cursorrequest param, opaquenextCursorin result, server-definedpage size. Search tools cap default results
(
mcp.application.searchMaxResults, default 100). The generated inputJSON Schema for every
search_*tool explicitly includes:limit(integer, optional, default = config cap, capped at config cap)cursor(string, optional, opaque) — round-trips through Harper'sexisting conditions+offset machinery (
resources/RequestTarget.ts).Responses include a
nextCursorfield when more rows exist. Tooldescriptions explicitly tell the LLM that results may be truncated and
that it must pass
cursorfrom the prior result to read further pages.MCP §server/tools →
"Error Handling":
not found, Harper operation rejection) return a successful JSON-RPC
response containing a tool result with
isError: trueand a structuredtext content payload (
{ kind: "permission_denied" | "validation" | "not_found" | "harper_error", message, details }). The spec is explicit:"Errors that occur during tool execution should be returned in the
result object, not as MCP protocol-level errors. This allows the LLM to
see and potentially handle the error."
params at the JSON-RPC layer, unknown tool name) return JSON-RPC error
responses (
-32700parse error,-32601method not found,-32602invalid params) per JSON-RPC 2.0
§5.1.
audit/server log.
Config (
harperdb-config.yaml)The native MCP server is configured under a new top-level
mcp:key inharperdb-config.yaml. The two profiles are configured independently —disabling either skips registration on the corresponding port. Full
schema, with every key annotated:
Defaults summary
Enablement is presence-based. A profile is on iff its sub-block is
present under
mcp:(matchesreplication's convention — there is noenabledflag). When the sub-block is present, any omitted key fallsback to the Joi defaults shown above (e.g.,
mountPathdefaults to/mcp).mcp:absent → both profiles off; no MCP routes registered. This isthe default state and what existing deployments see on upgrade.
mcp.operations: {}→ operations profile on, with every key at itsJoi default.
mcp.application: { mountPath: /foo }→ application profile on at/foo, every other key at its Joi default.mcp: {}(block present but no sub-keys) → neither profile on; sameas omitting
mcp:entirely.static/defaultConfig.yamlintentionally has nomcp:block — MCP isopt-in, configured by adding the block.
Migration from the external addon
/mcpon the HTTP port by default.HarperFast/mcp-serveraddon is archived at native launch.Release notes link to a migration page mapping addon tools to native tools.
Implementation Location
components/mcp/(new) — registers itself like otherbuilt-in components. Submodules:
protocol/— JSON-RPC, Streamable HTTP framing, initialize/list_tools/call_tool/list_resources/read_resource/notifications.
operations-profile/— tool generation overOPERATION_FUNCTION_MAP,RBAC filter, schemas, dispatch.
application-profile/— tool generation overResourcesregistry,Resource.attributes→ JSON Schema, RBAC filter, dispatch.audit/— audit log adapter.harper mcpsubcommand added to the existing CLI entry point.Implementation under
cli/mcp/.server/operationsServer.ts— register MCP handler when themcp.operationssub-block is present in config (presence-basedenablement; no
enabledflag).server/http.ts(and/orserver/REST.ts) — register MCP handler whenthe
mcp.applicationsub-block is present in config. Handler usesthe existing
async function http(request, next)shape and Harper'sRequest/Headerstypes — no Fastify API surface.server/serverHelpers/contentTypes.ts— reuse the existingtext/event-streamandapplication/jsonentries; do not registernew content-type handlers from inside the MCP component.
harper mcpfollows the pattern inbin/cliOperations.ts:119-131.tools/list_changed.Critical Files (read or modify during implementation)
server/operationsServer.ts(operations port wiring)server/serverHelpers/serverUtilities.ts(initializeOperationFunctionMap,chooseOperation— source of truth for ops registry)utility/hdbTerms.ts(OPERATIONS_ENUM, config param names)utility/operation_authorization.js(requiredPermissions,verifyPerms)security/user.ts(user/role/permission resolution)server/http.ts,server/REST.ts,server/middlewareChain.ts(HTTP wiring)server/serverHelpers/Request.ts,Headers.ts(Harper's ownrequest/response abstraction — used in lieu of any Fastify types)
server/serverHelpers/contentTypes.ts(existingtext/event-streamapplication/jsonserializers — reused, not replaced)resources/Resource.ts(allowRead/Create/Update/Deleteareinstance methods; class-level introspection follows the
prototype.method !== Resource.prototype.methodpattern fromresources/openApi.ts:149-153)resources/Resources.ts(registry of exported resources)bin/cliOperations.ts:119-131(UDS connection pattern for theharper mcpstdio CLI)resources/Table.ts(Table.attributesfor schema derivation)resources/graphql.ts(where tables become exports)resources/openApi.ts(generateJsonApi— reuse forharper://openapi)dataLayer/schemaDescribe.js(RBAC-aware schema describe)cli/entry point (location TBD by reading the CLI source) forharper mcpVerification
read-only role, role with attribute permissions, anonymous.
Table.attributescorrectly for varied types.update_*honors attribute_permissions; restricted fields rejected.initialize→tools/list→tools/callfordescribe_all,add_user,create_schema,csv_data_load, withdifferent roles.
get_*,search_*,create_*,update_*,delete_*on a seeded table; verify RBAC denials surface asMCP errors with expected codes.
search_*returnsnextCursor; resuming returns the nextpage; final page omits cursor.
tools/list_changedfires whenalter_roleruns in another session.harper mcp --profile applicationconnected to Claude Desktop; verifytool discovery, schema reads (
harper://schema/...,harper://openapi), and CRUD against a sample app.curl-driven JSON-RPC against both ports.fields.
identically when the
mcp:block is absent from config (the defaultstate — MCP is opt-in via block presence).
Key Flows
Initialize → tools/list → tools/call (Application MCP)
sequenceDiagram autonumber participant C as MCP client participant H as Harper HTTP port participant A as Auth middleware participant M as MCP component participant R as Resource (e.g. Product) C->>H: POST /mcp { "method": "initialize", ... }<br/>Authorization: Bearer ... H->>A: verify creds A-->>H: user, role resolved H->>M: dispatch initialize M->>M: create session (Mcp-Session-Id) M-->>H: 200 application/json<br/>Mcp-Session-Id: 7f3a... H-->>C: response C->>H: POST /mcp { "method": "tools/list" }<br/>Mcp-Session-Id: 7f3a... H->>A: verify creds H->>M: dispatch tools/list M->>R: enumerate exported resources<br/>filter by allowRead/Create/... R-->>M: permitted resources + attributes M-->>C: { tools: [get_product, search_product, ...] } C->>H: POST /mcp { "method": "tools/call",<br/>"params": { "name": "search_product", "arguments": {...} } } H->>M: dispatch tools/call M->>R: Resource.search(target, ctx) R->>R: RBAC + attribute filtering R-->>M: rows (capped) + cursor M-->>C: tool result (JSON) with nextCursorPermission-denied tool call (returns isError, not JSON-RPC error)
sequenceDiagram autonumber participant C as MCP client participant M as MCP component participant R as Resource C->>M: tools/call create_user M->>R: Resource.post(record, ctx) R-->>M: HdbError permission_denied M->>M: map to tool result (isError=true) M-->>C: { content: [...], isError: true } Note over C,M: JSON-RPC response is success;<br/>LLM sees the error and can correct.tools/list_changed on role mutation
sequenceDiagram autonumber participant Admin as Admin (alter_role) participant Ops as Operations profile participant Cache as Role cache participant Sess as Session table participant App as Application profile participant SSE as GET /mcp SSE stream participant C as Active MCP client Admin->>Ops: tools/call alter_role Ops->>Cache: update role Cache-->>Sess: emit role-changed event Sess->>App: for each session whose user is affected:<br/>recompute tools/list, diff App-->>SSE: notifications/tools/list_changed SSE-->>C: SSE event C->>App: POST tools/list (fresh) App-->>C: new filtered tool liststdio CLI bridge
sequenceDiagram autonumber participant P as Parent client (stdio) participant CLI as harper mcp participant H as Harper HTTP MCP P->>CLI: JSON-RPC line on stdin (initialize) CLI->>H: POST /mcp + Authorization (from env) H-->>CLI: 200, Mcp-Session-Id: 7f3a... CLI->>CLI: persist session id CLI-->>P: JSON-RPC line on stdout CLI->>H: GET /mcp (open SSE channel) H-->>CLI: SSE stream (notifications) loop steady state P->>CLI: stdin JSON-RPC CLI->>H: POST /mcp + Mcp-Session-Id alt single response H-->>CLI: application/json else streamed H-->>CLI: text/event-stream (1..n messages) end CLI-->>P: stdout JSON-RPC line(s) end H-->>CLI: SSE event (e.g. tools/list_changed) CLI-->>P: stdout notificationSample Payloads
All payloads are JSON-RPC 2.0 (per JSON-RPC
2.0) over Streamable HTTP (MCP
§transports →
"Streamable HTTP").
Field names and shapes mirror
schema.ts(2025-06-18).
HTTP Headers
The Streamable HTTP transport pins specific headers on both directions.
Every sample below shows the full headers it requires.
Client → Server (every
POST /mcpandGET /mcp):Authorization: Basic …orBearer …Content-Type: application/jsonAccept: application/json, text/event-streamAccept: text/event-streamMcp-Session-Id: <uuid>initializeinitializePOST.MCP-Protocol-Version: 2025-06-18initializeOrigin: https://…http_corsAccessList/operationsApi_network_corsAccessList); mismatch = HTTP 403.Last-Event-ID: <id>Server → Client:
Mcp-Session-Id: <uuid>initializeresponseContent-Type: application/jsonContent-Type: text/event-stream; charset=utf-8Cache-Control: no-storeWWW-Authenticate: Basic realm="harper"(orBearer …)HTTP status codes:
200 OK202 Accepted(empty body)idto answer). Example:notifications/initialized. (§transports MUST.)400 Bad RequestMcp-Session-Idwhen required; invalid/unsupportedMCP-Protocol-Version; malformed body.401 Unauthorized403 ForbiddenOriginvalidation failure.404 Not FoundMcp-Session-Id; client must re-initialize.405 Method Not AllowedGET /mcpif the server is not offering an SSE channel; returned onDELETE /mcpifsession.allowClientDelete: false.initializeDefined in MCP §basic/lifecycle →
"Initialization".
Request:
Response:
{ "jsonrpc": "2.0", "id": 1, "result": { "protocolVersion": "2025-06-18", "serverInfo": { "name": "harper-mcp", "version": "1.0.0" }, "capabilities": { "tools": { "listChanged": true }, "resources": { "listChanged": true }, "logging": {} }, "instructions": "Application MCP for Harper. Use search_<table> with cursor for paging. Writes are RBAC-gated." } }After receiving this response the client MUST send
notifications/initialized(MCP §lifecycle). That POST returns
202 Acceptedwith no body:All subsequent requests carry
Mcp-Session-IdandMCP-Protocol-Version;those headers are omitted from later samples for brevity.
tools/list(Application MCP, filtered to user)Shape per MCP §server/tools →
"Listing Tools";
annotation fields per MCP §server/tools →
"Tool Annotations".
{ "jsonrpc": "2.0", "id": 2, "result": { "tools": [ { "name": "search_product", "description": "Search the `product` table in database `shop`. Results may be truncated; pass `cursor` from prior result to fetch more.", "annotations": { "title": "Search products", "readOnlyHint": true, "destructiveHint": false, "idempotentHint": true, "openWorldHint": false }, "inputSchema": { "type": "object", "properties": { "conditions": { "type": "array", "items": { "type": "object", "properties": { "attribute": { "type": "string", "enum": ["id", "name", "price", "category", "in_stock"] }, "comparator": { "type": "string", "enum": ["eq","ne","gt","lt","ge","le","contains","starts_with","between"] }, "value": {} }, "required": ["attribute", "comparator", "value"] } }, "operator": { "type": "string", "enum": ["AND", "OR"], "default": "AND" }, "select": { "type": "array", "items": { "type": "string" } }, "sort": { "type": "array", "items": { "type": "object" } }, "limit": { "type": "integer", "minimum": 1, "maximum": 100, "default": 100 }, "cursor": { "type": "string", "description": "Opaque cursor from prior result." } }, "additionalProperties": false } }, { "name": "create_product", "annotations": { "readOnlyHint": false, "destructiveHint": false, "idempotentHint": false }, "inputSchema": { "type": "object", "required": ["name", "price"], "properties": { "id": { "type": "string" }, "name": { "type": "string" }, "price": { "type": "number" }, "category": { "type": "string" }, "in_stock": { "type": "boolean" } }, "additionalProperties": false } } ], "nextCursor": "eyJwYWdlIjoyfQ==" } }nextCursoris omitted on the final page. The client passes it asparams.cursoron the nexttools/listcall. This is required by MCP§server/tools → "Listing Tools" (pagination).
tools/call—search_productShape per MCP §server/tools →
"Calling Tools";
structuredContentper the same section's "Structured Content" subsection.Request:
{ "jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": { "name": "search_product", "arguments": { "conditions": [{ "attribute": "category", "comparator": "eq", "value": "books" }], "select": ["id", "name", "price"], "limit": 50 } } }Response:
{ "jsonrpc": "2.0", "id": 3, "result": { "content": [ { "type": "text", "text": "{\"rows\":[{\"id\":\"p_001\",\"name\":\"Dune\",\"price\":12.99},{\"id\":\"p_002\",\"name\":\"Foundation\",\"price\":9.99}],\"nextCursor\":\"eyJvZmZzZXQiOjUwfQ==\"}" } ], "structuredContent": { "rows": [ { "id": "p_001", "name": "Dune", "price": 12.99 }, { "id": "p_002", "name": "Foundation", "price": 9.99 } ], "nextCursor": "eyJvZmZzZXQiOjUwfQ==" }, "isError": false } }tools/call— permission denied (isError result)Per MCP §server/tools →
"Error Handling":
tool-execution failures return
result.isError = true, not a JSON-RPCerrorobject.{ "jsonrpc": "2.0", "id": 4, "result": { "content": [ { "type": "text", "text": "{\"kind\":\"permission_denied\",\"message\":\"Role 'reader' does not permit update on shop.product\",\"details\":{\"resource\":\"shop.product\",\"verb\":\"update\"}}" } ], "isError": true } }tools/call— Operations MCPdescribe_allRequest:
{ "jsonrpc": "2.0", "id": 5, "method": "tools/call", "params": { "name": "describe_all", "arguments": {} } }Response (truncated):
{ "jsonrpc": "2.0", "id": 5, "result": { "structuredContent": { "shop": { "product": { "schema": "shop", "name": "product", "hash_attribute": "id", "attributes": [ { "attribute": "id", "type": "String" }, { "attribute": "name", "type": "String" }, { "attribute": "price", "type": "Float" } ], "record_count": 4823 } } }, "isError": false } }resources/read— app resource at its canonical HTTPS URLApp-exported resources are addressed by their real REST URL. The MCP
server resolves them in-process via
Resources.getMatch(url)without anoutbound HTTP request.
Request:
{ "jsonrpc": "2.0", "id": 6, "method": "resources/read", "params": { "uri": "https://harper.example.com/Product/p_001" } }Response:
{ "jsonrpc": "2.0", "id": 6, "result": { "contents": [ { "uri": "https://harper.example.com/Product/p_001", "mimeType": "application/json", "text": "{\"id\":\"p_001\",\"name\":\"Dune\",\"price\":12.99,\"category\":\"books\",\"in_stock\":true}" } ] } }resources/read—harper://schema/...(synthetic resource)For things that do not have a REST endpoint (schemas, OpenAPI, server
metadata, operations catalog), use the
harper://scheme.Shape per MCP §server/resources →
"Reading Resources".
Request:
{ "jsonrpc": "2.0", "id": 6, "method": "resources/read", "params": { "uri": "harper://schema/shop/product" } }Response:
{ "jsonrpc": "2.0", "id": 6, "result": { "contents": [ { "uri": "harper://schema/shop/product", "mimeType": "application/json", "text": "{\"database\":\"shop\",\"table\":\"product\",\"primaryKey\":\"id\",\"attributes\":[{\"name\":\"id\",\"type\":\"String\",\"nullable\":false,\"isPrimaryKey\":true},{\"name\":\"name\",\"type\":\"String\",\"nullable\":false},{\"name\":\"price\",\"type\":\"Float\",\"nullable\":true},{\"name\":\"category\",\"type\":\"String\",\"nullable\":true,\"indexed\":true}],\"relationships\":[]}" } ] } }Server-initiated notification (SSE)
Opening the channel:
Response stream:
Each event on the channel:
Protocol-level error (unknown tool)
JSON-RPC error envelope per JSON-RPC 2.0
§5.1.
{ "jsonrpc": "2.0", "id": 7, "error": { "code": -32601, "message": "Method not found", "data": { "kind": "unknown_tool", "tool": "search_widget" } } }Review Disposition
Earlier external review (LLM-driven spec audit)
readOnlyHint/destructiveHintetc. are not in the MCP spec." —Pushed back. They live on
ToolAnnotations(MCP spec rev 2025-03-26+).Kept.
POSTing to a separate URL." — Pushed back. That describes the older
HTTP+SSE transport. Streamable HTTP (2025-03-26+) uses a single
/mcpendpoint with POST and optional GET, where the POST response itself may
be SSE. We use Streamable HTTP exclusively.
EVP-Engineering review
mcpToolsdeclaration.http:URIs by default"https://host:port/<path>URLs.harper://is now reserved for synthetic resources (schemas, OpenAPI, operations catalog, server metadata). Samples updated.allowRead/allowCreateare instance methods bound to a record id"resources/Resource.ts:413-427. Tool-list filtering section rewritten: class-level verb publication uses theprototype.method !== Resource.prototype.methodpattern fromresources/openApi.ts:149-153; user-level filtering walksuser.role.permission[db].tables[table]directly (pattern fromdataLayer/schemaDescribe.ts:29-49). Runtime enforcement via instance predicates is unchanged.127.0.0.1binding is a network-layer concern, not MCP's"operationsApi_network_domainSocketchannelbin/cliOperations.ts:119-131uses), with network HTTPS only when--urlpoints off-box. There is no in-process resource API to call directly today (verified —Resources.call()still requires a Request), so UDS is the closest available primitive.HARPER_USERfor stdio is strange when UDS doesn't need it"Request/Headersabstraction (server/serverHelpers/Request.ts,Headers.ts) used byserver/REST.ts:22. MCP component uses theasync function http(request, next)signature with Harper's types. "No Fastify API surface" called out explicitly.allowedOriginsshould match existing CORS naming"mcp.allowedOrigins. Origin validation now uses the existing per-port CORS access lists:http_corsAccessListandoperationsApi_network_corsAccessList.contentTypes"text/event-streamandapplication/jsonentries inserver/serverHelpers/contentTypes.ts:127-161. Improvements made there (e.g., SSEidsupport for resumability) benefit every Harper component.Rate Limiting (v1, required by spec)
MCP §server/tools → Security
Considerations
states the server MUST rate-limit tool invocations. v1 implements
this at MCP-session scope only — it is a per-session ceiling, not a
substitute for global REST/operations API rate limiting (which Harper
does not currently have and which is out of scope for this design). It
is intentional that we are landing session-scoped rate limiting on a
new component without yet having a global rate limiter for the
underlying APIs.
v1 implements:
(session, tool)— default 10 calls/sec, burst 20.When a limit is hit, the offending
tools/callreturns an MCP tool resultwith
isError: trueandkind: "rate_limited"(per the error-mappingrules above; it is not a JSON-RPC error).
Lifecycle Notes
Beyond the
initializerequest/response shown in the Sample Payloadssection, the v1 server implements the rest of MCP
§basic/lifecycle:
initializeresponse it MUST sendnotifications/initialized; on this POST the server returns HTTP 202Accepted with no body.
ping/loggingto a sessionbefore that notification arrives.
2025-06-18(preferred). If a client sends an older version we support
(
2025-03-26), the server returns the client's version. If we do notsupport the requested version, the server returns its latest supported
version and the client SHOULD disconnect (MCP §lifecycle → Version
Negotiation).
Compliance Audit Summary
The design has been audited against the 2025-06-18 spec. The table below
summarizes each spec MUST/SHOULD against this design.
Originvalidation on every requestAccept: application/json, text/event-streamon POSTMCP-Protocol-Versionheader on every request after init; 400 on bad versionMcp-Session-Idheader; ASCII 0x21–0x7E; 404 on terminatedDELETE /mcpto terminate; server MAY 405Last-Event-IDidreserved)initialize→ response →notifications/initializedhandshaketoolscapability withlistChanged: truetools/listsupports pagination viacursor/nextCursormaxTools, emitsnextCursor)name,title,description,inputSchema,outputSchema?,annotations?outputSchema-validated results MUST conformisErrorfor tool-execution failures; JSON-RPC errors for protocol failuresresourcescapability +resources/list,read,templates/listresources.subscribecursorrequest /nextCursorresult)The single deliberate non-conformance is v1 HTTP-transport authorization;
this is called out in the Auth section and resolved in v1.1.
Open Items / Designed-For (not v1 scope)
resources/subscribedriven byResource.subscribe()(v2).