Skip to content

Expose Operations (API) through an MCP Wrapper #465

@kriszyp

Description

@kriszyp

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.

Stacking 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 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):

Topic Source Used in
Overview & architecture /architecture Two-Profile Architecture
Lifecycle & version negotiation /basic/lifecycle initialize sample, session bookkeeping
Transports (Streamable HTTP, stdio) /basic/transports Transports section, stdio CLI, Mcp-Session-Id, GET/POST + SSE framing
Authorization (OAuth 2.1 PRM) /basic/authorization Auth (v1.1 follow-on)
Tools (definition, inputSchema, ToolAnnotations, tools/list, tools/call, isError, structuredContent) /server/tools Tool generation, safety annotations, error mapping
Resources (resources/list, resources/read, templates, subscribe) /server/resources MCP resources/ capability section
Pagination (cursor / nextCursor) /server/utilities/pagination Search tool cursor design
List-changed notifications /server/tools#list-changed-notification tools/list_changed bookkeeping
Logging utility /server/utilities/logging Audit logging
Security & trust principles /basic/security_best_practices Default-allow tightening, prompt-injection mitigations
JSON-RPC 2.0 jsonrpc.org/specification Message envelopes, error codes

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:

  1. 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.
  2. 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.

Two-Profile Architecture

flowchart LR
    subgraph Clients
      CD[Claude Desktop / Cursor / IDE]
      Agent[Custom Agent / SDK]
    end

    CLI[harper mcp CLI<br/>stdio &lt;-&gt; 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 --> Audit
Loading

MCP resources/ capability

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)

MCP requires every resource to have a URI (MCP §server/resources →
"Common URI
schemes"
).
This design uses two URI schemes intentionally:

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

  2. 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) Resources.getMatch(url) / Resources.call(), RBAC-gated varies (per resource content-type negotiation)
Application https://<host>:<httpPort>/<resourcePath>?openapi or harper://openapi generateJsonApi() (resources/openApi.ts), RBAC-filtered application/json (OpenAPI 3.0.3)
Application harper://schema/{database}/{table} 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).
  • Resources:
    • 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

  • 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://schema/<db>/<table> — attribute list, types, primary key,
      indexes, relationships (RBAC-filtered). Synthetic — no REST URL.
    • 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:

    const hasGet    = prototype.get    !== Resource.prototype.get;
    const hasPost   = prototype.post   !== Resource.prototype.post || prototype.update;
    const hasPut    = typeof prototype.put    === 'function' && prototype.put    !== Resource.prototype.put;
    const hasPatch  = typeof prototype.patch  === 'function' && prototype.patch  !== Resource.prototype.patch;
    const hasDelete = typeof prototype.delete === 'function' && prototype.delete !== Resource.prototype.delete;

    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:

    const tablePerm = user?.role?.permission?.[db]?.tables?.[table];
    if (!tablePerm?.read)   omit `get_*`, `search_*`;
    if (!tablePerm?.insert) omit `create_*`;
    if (!tablePerm?.update) omit `update_*`;
    if (!tablePerm?.delete) omit `delete_*`;

    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:

  1. 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.
  2. 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".

Security headers (MCP §transports → Security Warning)

  • 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:

  1. 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.
  2. 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.

Configuration:

  • --profile operations|application (default: application)
  • --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.

Connection matrix

Client class Connects via Endpoint
Desktop / IDE MCP clients (Claude Desktop, Cursor, Zed, Continue, Cline) stdio, by launching harper mcp as a subprocess n/a (subprocess)
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.

The CLI has two modes (see stdio above):

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

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 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
--urlHARPER_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):

import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";

const transport = new StreamableHTTPClientTransport(
  new URL("https://harper.example.com:9926/mcp"),
  {
    requestInit: {
      headers: {
        Authorization: `Bearer ${process.env.HARPER_TOKEN}`,
        Origin: "https://my-agent.example.com",
      },
    },
  },
);

const client = new Client({ name: "my-agent", version: "1.0.0" });
await client.connect(transport);

const tools = await client.listTools();
const result = await client.callTool({
  name: "search_product",
  arguments: { conditions: [{ attribute: "category", comparator: "eq", value: "books" }] },
});

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:

  1. 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.
  2. 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."
  • v1.1 (MCP-conformant follow-on):
    • Implement OAuth 2.0 Protected Resource Metadata (RFC9728), expose
      /.well-known/oauth-protected-resource.
    • Return WWW-Authenticate with the PRM URL on 401.
    • Validate token audience per RFC8707 (resource parameter) — MCP §auth
      "Token Audience Binding and Validation" is a MUST.
    • Support OAuth 2.0 Dynamic Client Registration (RFC7591).
    • The MCP handler still reads request.user regardless of how it got
      populated, so v1.1 is additive on top of v1.

Safety & Observability

  • Tool annotations. Every generated tool sets the optional
    ToolAnnotations defined in MCP §server/tools →
    "Tool Annotations"
    :
    • 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:
    • limit (integer, optional, default = config cap, capped at config cap)
    • 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.
  • Error mapping. Distinguish protocol vs. tool-execution failures per
    MCP §server/tools →
    "Error Handling"
    :
    • 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: 25
      perToolBurst: 50
      sessionConcurrency: 50
      sessionPerSecond: 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 no mcp: 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:
    • protocol/ — JSON-RPC, Streamable HTTP framing, initialize/list_tools/
      call_tool/list_resources/read_resource/notifications.
    • operations-profile/ — tool generation over OPERATION_FUNCTION_MAP,
      RBAC filter, schemas, dispatch.
    • application-profile/ — tool generation over Resources registry,
      Resource.attributes → JSON Schema, RBAC filter, dispatch.
    • audit/ — audit log adapter.
  • 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)
  • 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 own
    request/response abstraction — used in lieu of any Fastify types)
  • server/serverHelpers/contentTypes.ts (existing text/event-stream
    • 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)
  • dataLayer/schemaDescribe.js (RBAC-aware schema describe)
  • cli/ entry point (location TBD by reading the CLI source) for harper mcp

Verification

  1. Unit tests (per component submodule):
    • Tool-list filtering produces correct sets for super_user, structure_user,
      read-only role, role with attribute permissions, anonymous.
    • Input schemas reflect Table.attributes correctly for varied types.
    • update_* honors attribute_permissions; restricted fields rejected.
  2. Integration tests (spin up a Harper test server):
    • Operations MCP: initializetools/listtools/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.
  3. 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.
  4. Audit log spot-check: confirm every tool call appears with the expected
    fields.
  5. 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).

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 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
Loading

Sample 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 /mcp and GET /mcp):

Header Required Purpose Spec
Authorization: Basic … or Bearer … Yes (v1) Harper auth; same scheme accepted today on the corresponding port. mTLS replaces this when configured. Harper auth; MCP §authorization (v1.1 swap)
Content-Type: application/json Yes on POST The body is a single JSON-RPC message. §transports
Accept: application/json, text/event-stream MUST on POST Client signals it can handle either single JSON or an SSE stream response. §transports → Sending Messages #2
Accept: text/event-stream MUST on GET Client opens the server-push SSE channel. §transports → Listening for Messages #2
Mcp-Session-Id: <uuid> MUST after initialize Routes the request to its session; missing = HTTP 400, terminated = HTTP 404. Omitted on the very first initialize POST. §transports → Session Management
MCP-Protocol-Version: 2025-06-18 MUST on every request after initialize Pins the negotiated version; mismatched/unsupported = HTTP 400. §transports → Protocol Version Header
Origin: https://… Validated server-side Server MUST verify against the existing per-port CORS access list (http_corsAccessList / operationsApi_network_corsAccessList); mismatch = HTTP 403. §transports → Security Warning
Last-Event-ID: <id> Optional, deferred to v2 Resume an SSE stream after disconnect. v1 ignores it. §transports → Resumability

Server → Client:

Header When Purpose
Mcp-Session-Id: <uuid> On initialize response Assigns the session id the client must echo afterward.
Content-Type: application/json POST single-response path Body is one JSON-RPC response.
Content-Type: text/event-stream; charset=utf-8 POST streamed path, and every GET that opens a stream Body is an SSE stream of JSON-RPC messages.
Cache-Control: no-store SSE responses Prevents intermediary caching.
WWW-Authenticate: Basic realm="harper" (or Bearer …) On 401 Standard auth challenge. v1.1 replaces this with a PRM-pointing form per MCP §authorization.

HTTP status codes:

Status When
200 OK Normal POST returning a single JSON-RPC response.
202 Accepted (empty body) Client POST whose body is a JSON-RPC notification or response (no id to answer). Example: notifications/initialized. (§transports MUST.)
400 Bad Request Missing Mcp-Session-Id when required; invalid/unsupported MCP-Protocol-Version; malformed body.
401 Unauthorized Missing or invalid credentials.
403 Forbidden Origin validation failure.
404 Not Found Request bearing a terminated Mcp-Session-Id; client must re-initialize.
405 Method Not Allowed Returned on GET /mcp if the server is not offering an SSE channel; returned on DELETE /mcp if session.allowClientDelete: false.

initialize

Defined in MCP §basic/lifecycle →
"Initialization"
.

Request:

POST /mcp HTTP/1.1
Host: harper.example.com:9925
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
Origin: https://app.example.com
Content-Type: application/json
Accept: application/json, text/event-stream
Content-Length: 213

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "initialize",
  "params": {
    "protocolVersion": "2025-06-18",
    "capabilities": { "roots": { "listChanged": true } },
    "clientInfo": { "name": "claude-desktop", "version": "1.4.2" }
  }
}

Response:

HTTP/1.1 200 OK
Content-Type: application/json
Mcp-Session-Id: 7f3a4e90-2c31-4b5e-9d24-a1b3c4d5e6f7
{
  "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 Accepted with no body:

POST /mcp HTTP/1.1
Host: harper.example.com:9925
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
Origin: https://app.example.com
Mcp-Session-Id: 7f3a4e90-2c31-4b5e-9d24-a1b3c4d5e6f7
MCP-Protocol-Version: 2025-06-18
Content-Type: application/json
Accept: application/json, text/event-stream

{ "jsonrpc": "2.0", "method": "notifications/initialized" }
HTTP/1.1 202 Accepted
Content-Length: 0

All subsequent requests carry Mcp-Session-Id and MCP-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=="
  }
}

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

tools/callsearch_product

Shape per MCP §server/tools →
"Calling Tools"
;
structuredContent per 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-RPC
error object.

{
  "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 MCP describe_all

Request:

{
  "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 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.

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/readharper://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:

GET /mcp HTTP/1.1
Host: harper.example.com:9925
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
Origin: https://app.example.com
Mcp-Session-Id: 7f3a4e90-2c31-4b5e-9d24-a1b3c4d5e6f7
MCP-Protocol-Version: 2025-06-18
Accept: text/event-stream

Response stream:

HTTP/1.1 200 OK
Content-Type: text/event-stream; charset=utf-8
Cache-Control: no-store

Each event on the channel:

event: message
data: {"jsonrpc":"2.0","method":"notifications/tools/list_changed"}

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)

  1. "readOnlyHint / destructiveHint etc. are not in the MCP spec."
    Pushed back. They live on ToolAnnotations (MCP spec rev 2025-03-26+).
    Kept.
  2. "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.
  • Per-session concurrency ceiling — default 25 concurrent tool calls.
  • Per-session global ceiling — default 100 calls/sec.
  • All thresholds configurable per profile:
mcp:
  application:
    rateLimit:
      perToolPerSecond: 10
      perToolBurst: 20
      sessionConcurrency: 25
      sessionPerSecond: 100

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 §transports Session Management Conforms
Client DELETE /mcp to terminate; server MAY 405 §transports Session Management 5 Conforms
Resumability via Last-Event-ID §transports Resumability Deferred to v2 (documented; SSE id reserved)
stdio: newline-delimited UTF-8, stderr for logs §transports stdio Conforms
initialize → response → notifications/initialized handshake §lifecycle Conforms
Version negotiation (server returns same or alternate supported version) §lifecycle Conforms
tools capability with listChanged: true §server/tools Conforms
tools/list supports pagination via cursor/nextCursor §server/tools Conforms (server caps page size at maxTools, emits nextCursor)
Tool fields: name, title, description, inputSchema, outputSchema?, annotations? §server/tools Conforms
Tool name format constraint §server/tools schema.ts Conforms via sanitization
outputSchema-validated results MUST conform §server/tools Conforms for tools that declare one
isError for tool-execution failures; JSON-RPC errors for protocol failures §server/tools Conforms
Server MUST validate tool inputs §server/tools Security Conforms (Harper RBAC + JSON Schema validation)
Server MUST implement access controls §server/tools Security Conforms (Harper RBAC)
Server MUST rate-limit tool invocations §server/tools Security Conforms (added in v1)
Server MUST sanitize tool outputs §server/tools Security Conforms (no stack traces leaked)
resources capability + resources/list, read, templates/list §server/resources Conforms
resources.subscribe §server/resources Not declared (v2)
Pagination uses opaque cursor (cursor request / nextCursor result) §pagination Conforms
HTTP transport auth: OAuth 2.1 + PRM + WWW-Authenticate pointing at PRM §authorization DEVIATES in v1 (uses Harper Basic/JWT/mTLS); v1.1 conforms
stdio auth from environment §authorization Conforms
Token audience validation (RFC8707) §authorization N/A in v1 (no MCP-issued tokens); v1.1 conforms
TLS on HTTPS for all auth endpoints §authorization Conforms (inherits Harper TLS)

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)

  • OAuth 2.1 protected-resource metadata + dynamic client registration (v1.1).
  • Resource subscriptions via resources/subscribe driven by
    Resource.subscribe() (v2).
  • Per-tool rate limiting and concurrency caps (v1.1).
  • Scoped MCP-only token issuance via a new admin operation (v1.1).

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:mcpModel Context Protocol (MCP) server: protocol, profiles, stdio CLIenhancementNew feature or requestfeature:mcp-v1Rollout of native MCP server v1 (HarperFast/harper#465). Removed when v1 closes.

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions