From 15e283a52b12f7897cfbe8f002ac08161b5badf1 Mon Sep 17 00:00:00 2001 From: Kyle Bernhardy Date: Wed, 8 Jul 2026 16:43:20 -0600 Subject: [PATCH 1/7] feat: add harper-mcp skill covering the full MCP interface Ten synthesized rules across four categories: setup & connection (profiles/config, wire handshake incl. session and protocol-version header semantics), tools & prompts (automatic RBAC-filtered verb tools, custom mcpTools with the anonymous-exposure model, mcpPrompts), resources (harper:// metadata, harper+rest:// descriptors, subscriptions, custom mcpResources with template semantics and the encoded-separator guard), and operations & security (session/per-client rate limiting incl. identityHeader trust model, durable quota hook with race-safe counter guidance, hardening checklist). Content authored from the Harper 5.1/5.2 implementation and runtime-verified behavior. Co-Authored-By: Claude Fable 5 --- AGENTS.md | 1 + README.md | 9 + harper-mcp/AGENTS.md | 488 +++++++++++++++++++++++ harper-mcp/SKILL.md | 91 +++++ harper-mcp/rules.manifest.yaml | 83 ++++ harper-mcp/rules/automatic-verb-tools.md | 52 +++ harper-mcp/rules/connecting-clients.md | 59 +++ harper-mcp/rules/custom-mcp-prompts.md | 51 +++ harper-mcp/rules/custom-mcp-resources.md | 58 +++ harper-mcp/rules/custom-mcp-tools.md | 66 +++ harper-mcp/rules/durable-quotas.md | 65 +++ harper-mcp/rules/enabling-mcp.md | 54 +++ harper-mcp/rules/rate-limiting.md | 54 +++ harper-mcp/rules/resources-surface.md | 44 ++ harper-mcp/rules/security-posture.md | 34 ++ 15 files changed, 1209 insertions(+) create mode 100644 harper-mcp/AGENTS.md create mode 100644 harper-mcp/SKILL.md create mode 100644 harper-mcp/rules.manifest.yaml create mode 100644 harper-mcp/rules/automatic-verb-tools.md create mode 100644 harper-mcp/rules/connecting-clients.md create mode 100644 harper-mcp/rules/custom-mcp-prompts.md create mode 100644 harper-mcp/rules/custom-mcp-resources.md create mode 100644 harper-mcp/rules/custom-mcp-tools.md create mode 100644 harper-mcp/rules/durable-quotas.md create mode 100644 harper-mcp/rules/enabling-mcp.md create mode 100644 harper-mcp/rules/rate-limiting.md create mode 100644 harper-mcp/rules/resources-surface.md create mode 100644 harper-mcp/rules/security-posture.md diff --git a/AGENTS.md b/AGENTS.md index ae9e868..57a61be 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,3 +5,4 @@ This repository contains "skills" that guide AI agents in developing Harper appl ## Available Skills - [Harper Best Practices](harper-best-practices/SKILL.md): Comprehensive guidelines for building, extending, and deploying Harper applications. +- [Harper MCP](harper-mcp/SKILL.md): Comprehensive guide to Harper's Model Context Protocol (MCP) interface — server setup, tools, prompts, resources, rate limiting, quotas, and security. diff --git a/README.md b/README.md index c694146..4128604 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,15 @@ Comprehensive guidelines for building, extending, and deploying Harper applicati - Custom resources and table extensions. - Advanced features like Vector Indexing and Caching. +### [Harper MCP](harper-mcp/SKILL.md) + +Comprehensive guide to Harper's Model Context Protocol (MCP) interface. Covers: + +- Enabling the MCP server profiles and connecting AI clients. +- Automatic CRUD verb tools and custom `mcpTools` / `mcpPrompts` / `mcpResources`. +- The resources surface (`harper://`, `harper+rest://`, custom URIs and templates). +- Rate limiting, durable quotas, and the security model for public endpoints. + ## How it Works These skills are structured to be easily consumed by Large Language Models (LLMs) and AI agents. For a technical overview of how agents use these files, see [AGENTS.md](AGENTS.md). diff --git a/harper-mcp/AGENTS.md b/harper-mcp/AGENTS.md new file mode 100644 index 0000000..f52990e --- /dev/null +++ b/harper-mcp/AGENTS.md @@ -0,0 +1,488 @@ +# Harper MCP + +Guidelines for exposing a Harper instance as a Model Context Protocol (MCP) server and for building the tools, prompts, and resources AI clients consume. Harper implements MCP Streamable HTTP (spec rev 2025-06-18) with two independent profiles: `application` (your app's surface) and `operations` (Harper administration). + +## 1. Setup & Connection + +### 1.1 Enabling MCP + +Instructions for exposing a Harper instance as a Model Context Protocol (MCP) server. + +#### When to Use + +Use this skill when an AI client (Claude, an agent framework, or any MCP-capable tool) should talk to Harper directly — calling tools backed by your tables and Resources, reading data as MCP resources, or driving Harper operations. + +#### How It Works + +Harper ships two independent MCP **profiles**, each a Streamable HTTP endpoint (MCP spec rev 2025-06-18): + +1. **`application` profile** — the profile most apps want. Exposes your application's surface: auto-generated CRUD verb tools for `@export`ed tables, plus anything components opt in via `static mcpTools` / `static mcpPrompts` / `static mcpResources`. Mounts on the application HTTP port (default `9926`). +2. **`operations` profile** — exposes Harper's operations API as tools (user-filtered by operation permissions). Mounts on the operations port (default `9925`). Intended for administrative agents, not application clients. + +Enable them in `harper-config.yaml` (or the equivalent `HARPER_SET_CONFIG` process env var): + +```yaml +mcp: + application: + mountPath: /mcp # path on the application HTTP port + operations: + mountPath: /mcp # path on the operations port +``` + +Each profile is independent — enable only what you need. Key per-profile options: + +- `mountPath` — where the endpoint mounts. Required to enable the profile. +- `allow` / `deny` — name filters for the generated tool surface. +- `maxTools` — cap on generated tools (large schemas can otherwise flood a client's tool list). +- `rateLimit.*` — see the [Rate Limiting](rate-limiting.md) skill. +- `quota.*` — durable, operator-defined quotas; see the [Durable Quotas](durable-quotas.md) skill. + +Version notes: the transport and tool surface shipped across 5.1.x (complete protocol surface — prompts, resources, subscriptions, completions, cancellation, progress — in 5.1.10+). Custom content resources (`mcpResources`) are 5.1.18+. Per-client rate limiting and durable quotas are 5.2.0+. + +#### Examples + +Minimal application-profile setup for a project with `@export`ed tables: + +```yaml +# harper-config.yaml +mcp: + application: + mountPath: /mcp +``` + +An MCP client pointed at `http://:9926/mcp` then sees `get_*` / `search_*` / `create_*` / `update_*` / `delete_*` tools for every exported table, RBAC-filtered per authenticated user. See the [Connecting Clients](connecting-clients.md) skill for the handshake. + +### 1.2 Connecting Clients + +The wire-level contract an MCP client (or your own HTTP code) must follow against a Harper MCP endpoint. + +#### When to Use + +Use this skill when configuring an MCP client against Harper, writing integration tests that drive `/mcp` directly, or debugging 400/404 responses from the endpoint. + +#### How It Works + +Harper implements MCP **Streamable HTTP** (spec rev 2025-06-18; rev 2025-03-26 also accepted): + +1. **Initialize.** POST a JSON-RPC `initialize` to the mount path with `Accept: application/json, text/event-stream`. The response carries the negotiated `protocolVersion`, the server's capabilities, and — critically — an `Mcp-Session-Id` response header. +2. **Session header.** Every subsequent request MUST send that `Mcp-Session-Id` back. Missing/unknown ids get 400/404 (a 404 means re-initialize — sessions idle out after `mcp.session.idleTimeoutSeconds`, default 30 minutes). +3. **Protocol-version header.** Requests after initialize should send `MCP-Protocol-Version: `. A header naming a _different_ supported version than the session negotiated is rejected (400). A **missing** header is accepted as the session's own negotiated version (5.2.0+, patched into 5.1.x; older 5.1 releases treated a missing header as `2025-03-26` and rejected it as a mismatch on `2025-06-18` sessions). +4. **Server-push SSE.** A GET on the mount path (with `Accept: text/event-stream`) opens the server→client stream used for `notifications/*/list_changed`, resource-update notifications, progress, and server-initiated requests. Some flows require it: `resources/subscribe` is rejected until the session has an open SSE stream. +5. **Authentication.** Standard Harper authentication applies (Basic auth, tokens, mTLS — whatever the instance is configured with). Anonymous sessions are accepted when the deployment allows them; see the [Security Posture](security-posture.md) skill for what anonymous callers can reach. + +#### Examples + +Full curl handshake: + +```bash +# 1. initialize — capture the session id from the response headers +curl -si http://localhost:9926/mcp \ + -H 'content-type: application/json' \ + -H 'accept: application/json, text/event-stream' \ + -u admin:password \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{ + "protocolVersion":"2025-06-18","capabilities":{}, + "clientInfo":{"name":"my-client","version":"1.0"}}}' +# → 200, header: Mcp-Session-Id: + +# 2. subsequent calls carry the session + protocol headers +curl -s http://localhost:9926/mcp \ + -H 'content-type: application/json' \ + -H 'accept: application/json, text/event-stream' \ + -H 'mcp-session-id: ' \ + -H 'mcp-protocol-version: 2025-06-18' \ + -u admin:password \ + -d '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' +``` + +Claude Desktop / MCP-client configuration is just the URL (plus auth): point a Streamable HTTP transport at `http://:9926/mcp`. + +Debugging cheat sheet: + +- `400 missing Mcp-Session-Id header` — you skipped step 2. +- `404` on a previously working session — idle timeout; re-initialize. +- `400 MCP-Protocol-Version mismatch` — you sent a header naming a different version than the session negotiated. +- `406` — missing the required `Accept` media type (JSON for POST, `text/event-stream` for GET). +- `403 origin_not_allowed` — browser-origin request rejected by CORS-tied origin validation; see [Security Posture](security-posture.md). + +## 2. Tools & Prompts + +### 2.1 Automatic Verb Tools + +The zero-code tool surface: every `@export`ed table becomes a family of CRUD tools on the application profile. + +#### When to Use + +Use this skill when deciding what an MCP client will see for a given schema, when tools are unexpectedly missing from `tools/list`, or when trimming a large generated surface. + +#### How It Works + +1. **Generation.** For each exported table `Widget`, the application profile registers `get_widget`, `search_widget`, `create_widget`, `update_widget`, and `delete_widget` tools. Input/output schemas are derived from the table's typed attributes, so clients get real parameter validation and result shapes. +2. **RBAC is enforced, twice.** `tools/list` is filtered per authenticated user (a user with no read permission on a table does not see its `get_`/`search_` tools), and calls run through the same permission enforcement as REST — including per-record `allow*` predicates on Resource subclasses. This is the key contrast with [custom tools](custom-mcp-tools.md), which are visible to everyone. +3. **`exportTypes` gating.** A Resource registered with `exportTypes: { mcp: false }` is excluded from MCP enumeration entirely, independent of its REST exposure. +4. **Surface controls.** Per profile: `allow` / `deny` name filters and `maxTools` cap the generated set. Prefer trimming to what the AI actually needs — every tool costs client context. +5. **Live registration.** The tool registry rebuilds lazily when the underlying Resource registry changes (schema changes, deploys, components that finish loading after boot), so tools stay in sync without restarts; connected sessions receive `notifications/tools/list_changed` when their visible set actually changes. + +#### Examples + +```graphql +# schema.graphql +type Widget @table @export { + id: ID @primaryKey + name: String @indexed + price: Float +} +``` + +With `mcp.application.mountPath` set, `tools/list` (as a user with read/write on Widget) includes: + +```json +{ "name": "search_widget", "inputSchema": { "properties": { "conditions": { "...": "..." } } } } +{ "name": "get_widget", "inputSchema": { "properties": { "id": { "type": "string" } } } } +{ "name": "create_widget", "...": "..." } +``` + +Trimming the surface to read-only: + +```yaml +mcp: + application: + mountPath: /mcp + allow: + - 'get_*' + - 'search_*' +``` + +### 2.2 Custom MCP Tools + +Expose non-CRUD operations — an LLM-backed `answer`, a domain action, a report generator — as first-class MCP tools. + +#### When to Use + +Use this skill when the auto-generated verb tools aren't enough: the AI should invoke _behavior_, not just CRUD. Also read it before shipping any custom tool on a publicly reachable instance — the security model differs from verb tools. + +#### How It Works + +1. **Declare `static mcpTools`** on a Resource class (typically in `resources.js`/`resources.ts`): + +```javascript +export class Orders extends tables.Orders { + static mcpTools = [ + { + name: 'reconcile_unsettled', + description: 'Reconcile all unsettled orders and return a summary', + method: 'reconcileUnsettled', + inputSchema: { + type: 'object', + properties: { since: { type: 'string', description: 'ISO 8601 timestamp' } }, + }, + }, + ]; + + async reconcileUnsettled(args, context) { + // context: { user, profile, sessionId, signal, progress?, serverRequest? } + return { reconciled: 12 }; + } +} +``` + +2. **Dispatch is live-class.** Calls construct an instance of the class currently in the Resource registry, so an exported subclass (and its access-control overrides) always wins after a reload/deploy. +3. **Per-call context.** The second argument carries `user`, `profile`, `sessionId`, an `AbortSignal` (`signal`) wired to MCP cancellation, and — on streaming calls — `progress()` and `serverRequest()`. Guard optional members (`context.progress?.(…)`). +4. **Results and errors.** Return values are wrapped into MCP tool results (objects become structured content). Thrown errors surface as `isError: true` tool results with the message only — stack traces stay in the server log. +5. **Security: custom tools are exposed to ANY session, including anonymous ones.** Unlike verb tools (RBAC-filtered per user), a custom tool is listed to every session and its method executes even with no logged-in user (`context.user` may be empty). The method runs inside the normal `transactional()` envelope, so data access it performs still hits per-record `allow*` predicates — but the _tool itself_ has no gate. To restrict one: + +```javascript +async reconcileUnsettled(args, context) { + if (!context.user?.username) { + throw new Error('authentication required'); + } + // ... +} +``` + +6. **Cost control for public tools.** A cost-bearing anonymous tool needs more than auth checks — see [Rate Limiting](rate-limiting.md) (per-client buckets survive session cycling) and [Durable Quotas](durable-quotas.md) (persisted per-identity limits). + +#### Examples + +Warn-worthy anti-pattern — a public instance with an expensive tool and no gating: + +```javascript +static mcpTools = [{ name: 'answer', method: 'llmAnswer', ... }]; +async llmAnswer(args) { return await callExpensiveModel(args.q); } // anonymous callers burn your budget +``` + +Fixed: check `context.user` (or accept anonymity deliberately) _and_ configure `rateLimit.perClientPerSecond` + a `quota` hook. + +### 2.3 Custom MCP Prompts + +Publish parameterized prompt templates that MCP clients surface to their users (slash-command style). + +#### When to Use + +Use this skill when your application wants to hand well-crafted, data-aware prompts to any connected AI client — "summarize this account", "draft a reply about order X" — instead of hoping each client writes a good one. + +#### How It Works + +1. **Declare `static mcpPrompts`** on a Resource class: + +```javascript +export class Support extends tables.Ticket { + static mcpPrompts = [ + { + name: 'draft_reply', + description: 'Draft a support reply for a ticket', + arguments: [{ name: 'ticketId', description: 'Ticket to reply to', required: true }], + method: 'draftReplyPrompt', + }, + ]; + + async draftReplyPrompt(args) { + const ticket = await Support.get(args.ticketId); + return { + messages: [ + { + role: 'user', + content: { type: 'text', text: `Draft a courteous reply to: ${ticket.body}` }, + }, + ], + }; + } +} +``` + +2. **Surface.** Prompts appear in `prompts/list` and render via `prompts/get`; argument completion is served through `completion/complete` when declared. Connected sessions get `notifications/prompts/list_changed` when the set changes (reload/deploy). +3. **Render method contract.** The method receives the client-supplied arguments and returns the MCP prompt shape (`messages` array; a bare string is wrapped as a single user text message). It can read tables — it runs server-side with the same live-class dispatch as custom tools. +4. **Exposure.** Like custom tools, prompts are listed to every session on the profile, including anonymous ones — keep secrets out of prompt text and gate inside the method if needed. + +#### Examples + +A data-aware prompt beats a static template: fetch current state (ticket, order, account) inside the render method so the client's LLM starts from live facts rather than asking for them turn by turn. + +## 3. Resources + +### 3.1 Resources Surface + +What `resources/list`, `resources/read`, `resources/templates/list`, and `resources/subscribe` expose from a Harper instance. + +#### When to Use + +Use this skill when an MCP client should _read_ from Harper (schemas, API descriptions, data descriptors) rather than call tools, or when wiring change notifications. + +#### How It Works + +Three URI families appear on the application profile: + +1. **`harper://` metadata resources** (no HTTP equivalent): + - `harper://about` — server version, profile, protocol versions, capabilities (both profiles). + - `harper://schema/{database}/{table}` — per-table attribute definitions, RBAC-filtered at read time (also offered as a URI template). + - `harper://openapi` — the OpenAPI 3.0.3 document for the REST surface. + - `harper://operations` — operations profile only; the caller's allowed operation names. +2. **`harper+rest://:/` descriptors** — one per exported Resource passing the `exportTypes.mcp` gate. Reading one returns a small JSON descriptor (path, database, table, and a hint to use the corresponding verb tools for records) — it is a pointer, not a data dump. The scheme is `harper+rest` (5.1.18+) because the MCP spec reserves `https://` for resources a client can fetch from the web; legacy `http(s)://` URIs from older listings still read and subscribe. +3. **Author-defined custom resources** — arbitrary URIs/templates served by component code; see the [Custom MCP Resources](custom-mcp-resources.md) skill. Custom URIs take precedence over the discovered surfaces on `resources/read`. + +Other behaviors: + +- **Subscriptions.** Row-backed application resources support `resources/subscribe` (change notifications delivered on the SSE stream). The session must have its GET SSE stream open first — subscribe calls before that are rejected with an instructive error. Subscriptions are restored on session resume. +- **`notifications/resources/list_changed`.** Sessions are notified when their _visible_ resource set (including templates) actually changes — per-session diffing keeps no-op rebuilds silent. +- **Pagination.** All list endpoints use opaque cursors per the MCP spec; treat `nextCursor` as a black box. + +#### Examples + +Reading a table's schema as an AI-consumable resource: + +```json +→ {"method":"resources/read","params":{"uri":"harper://schema/data/widget"}} +← {"result":{"contents":[{"uri":"harper://schema/data/widget","mimeType":"application/json", + "text":"{\"attributes\":[{\"name\":\"id\",\"type\":\"ID\"},...]}"}]}} +``` + +Point a client at `harper://openapi` when it needs the whole REST contract in one read. + +### 3.2 Custom MCP Resources + +Serve author-defined content — documentation pages, rendered reports, binary assets — under your own URIs (5.1.18+). + +#### When to Use + +Use this skill to build a content surface for AI clients: a public docs server over MCP, per-entity report resources, or any read-oriented content the auto-generated descriptors can't express. + +#### How It Works + +1. **Declare `static mcpResources`** on a Resource class. Each entry has exactly one of `uri` (fixed) or `uriTemplate`, plus a `method` naming the instance method that serves reads: + +```javascript +export class DocsPages extends Resource { + static mcpResources = [ + { + uri: 'docs:///index', + name: 'docs index', + description: 'List of all documentation pages', + mimeType: 'text/markdown', + method: 'readIndex', + }, + { + uriTemplate: 'docs:///{+path}', + name: 'docs page', + mimeType: 'text/markdown', + method: 'readPage', + completions: { path: ['guides/install.md', 'guides/deploy.md'] }, + }, + ]; + + async readIndex() { + return { text: '- docs:///guides/install.md\n…', mimeType: 'text/markdown' }; + } + + async readPage(params /* { path } */, context /* { user, profile, sessionId } */) { + return { text: loadPage(params.path), mimeType: 'text/markdown' }; + } +} +``` + +2. **Templates.** `{name}` matches exactly one path segment; `{+name}` matches across segments (RFC-6570-style reserved expansion — how MCP clients expand templates). The single-segment contract is enforced through percent-decoding: a URI smuggling an encoded separator (`%2F`/`%5C`) into a `{name}` slot simply fails to match, so `{name}` is safe to use in path construction. Duplicate parameter names are rejected at registration. +3. **Schemes.** Pick a custom scheme (`docs:///…` above). The reserved schemes — `harper:`, `harper+rest:`, `http:`, `https:` — are rejected at registration (and a template cannot parameterize the scheme position), so custom entries can never shadow the built-in surfaces. +4. **Content shapes.** The read method returns a string (text), `{ text, mimeType? }`, `{ blob, mimeType? }` (base64 binary), or any other object (serialized as JSON). Read errors surface to the client as a sanitized JSON-RPC error; the raw error goes to the server log only. +5. **Listing and completion.** Fixed URIs appear in `resources/list`, templates in `resources/templates/list`; declared `completions` values serve `completion/complete` per template parameter. Custom URIs win over the discovered surfaces on `resources/read`. +6. **Anonymous by design.** Custom resources are served to any session — including unauthenticated ones (the public-docs case this feature targets). The MCP layer performs no auth check for them; gate inside the read method (`context.user`) if content is restricted. +7. **No tables required.** A tableless component's resources register reliably — the registry rebuilds lazily per request when component loading completes after boot. + +#### Examples + +Public docs server: the two-entry declaration above plus `mcp.application.mountPath` is the whole server — clients browse `resources/list`, complete paths, and read pages, all anonymous. Pair with [Rate Limiting](rate-limiting.md) and [Durable Quotas](durable-quotas.md) if any companion tools bear cost. + +## 4. Operations & Security + +### 4.1 Rate Limiting + +In-memory token buckets that bound `tools/call` throughput per profile. + +#### When to Use + +Use this skill when tuning MCP throughput, and **always** when a tool is exposed on a public or anonymous-accessible instance — the default limits alone do not stop session-cycling abuse. + +#### How It Works + +Four session-scoped limits ship with sensible defaults (per profile, configurable under `mcp..rateLimit.*`): + +- `perToolPerSecond` / `perToolBurst` — sustained rate and burst per (session, tool). +- `sessionConcurrency` — max in-flight calls per session. +- `sessionPerSecond` — sustained rate per session across all tools. + +Limit hits return an `isError` tool result with `kind: 'rate_limited'` and a `scope` field (not a JSON-RPC error), so the calling LLM can see and back off. + +**Session buckets are evadable by anonymous clients**: `initialize → call → drop session → repeat` gets fresh buckets every loop. Two additions close that (5.2.0+): + +- `perClientPerSecond` / `perClientBurst` (default off) — a bucket keyed on **client identity** rather than session, surviving the cycling loop. Burst defaults to the sustained rate, floored at one whole token (a fractional rate like `0.1` = "6/minute" still admits its first call). Denials report `scope: 'per_client'`. +- `identityHeader` — identity defaults to the client socket IP; deployments behind a reverse proxy can name a trusted header (typically `x-forwarded-for`, first value wins). **Only set this when the proxy strips or replaces the header on untrusted traffic** — a client-controlled identity header lets callers mint fresh identities per request and bypass the limit; Harper logs a startup warning when it is configured. + +All bucket state is in-memory per worker: it resets on restart and is not shared across workers. For durable, restart-surviving limits, see the [Durable Quotas](durable-quotas.md) skill. + +#### Examples + +Public docs server with a cost-bearing `answer` tool — bound instantaneous abuse: + +```yaml +mcp: + application: + mountPath: /mcp + rateLimit: + perClientPerSecond: 0.5 # 30/minute sustained per client + perClientBurst: 5 +``` + +Behind a proxy that manages `X-Forwarded-For` correctly: + +```yaml +mcp: + application: + rateLimit: + identityHeader: x-forwarded-for + perClientPerSecond: 0.5 +``` + +### 4.2 Durable Quotas + +Persisted, restart-surviving cost controls for `tools/call`, implemented by your code behind a config hook (5.2.0+). + +#### When to Use + +Use this skill when in-memory [rate limiting](rate-limiting.md) is not enough as a cost control — typically a public, unauthenticated, cost-bearing tool (an LLM-backed `answer`) where you need "N calls per identity per day" semantics that survive restarts and span workers. + +#### How It Works + +1. **Name a quota Resource in config:** + +```yaml +mcp: + application: + quota: + resource: McpQuota # exported Resource path + # method: allowMcpCall # optional; this is the default +``` + +2. **Implement the static method.** Before each admitted `tools/call`, Harper calls it with `{ identity, tool, user, profile, sessionId }` (`identity` is the client identity from the rate-limit layer — socket IP or trusted-header value — and may be `undefined`). Return `true` to allow, or `{ allowed: false, message?, retryAfterSeconds? }` to deny; denials surface as `isError` tool results with `kind: 'quota_exceeded'` plus your message. +3. **Ordering.** The hook runs _after_ the in-memory buckets admit the call, so rate-limited clients cannot spam a table-backed hook. +4. **Fail-closed.** A hook that throws — or a configured `resource`/`method` that doesn't resolve — **denies** the call (sanitized `quota policy unavailable` / `quota check failed`; raw error in the server log). Cost protection that silently disables itself on a bug is worse than a hard failure. The blast radius is `tools/call` only; list/read surfaces stay up. +5. **Race-safety is your hook's business.** It can run concurrently for the same identity — within a worker (interleavings across your own `await`s) and across workers. A naive `get` → `put used+1` counter undercounts under concurrency; make the read-modify-write atomic (a transaction that serializes conflicting writers, a compare-and-set retry loop, or a store with native atomic increments). + +#### Examples + +Persisted per-identity daily counter (schema + hook): + +```graphql +type QuotaCounter @table { + id: ID @primaryKey # identity + used: Int + day: String +} +``` + +```javascript +const DAILY_LIMIT = 100; + +export class McpQuota extends tables.QuotaCounter { + static async allowMcpCall({ identity, tool }) { + const id = identity ?? 'unknown'; + const today = new Date().toISOString().slice(0, 10); + // NOTE: naive get-then-put shown for shape; production code must make + // this read-modify-write atomic (see Race-safety above). + const existing = await McpQuota.get(id); + const used = existing?.day === today ? existing.used + 1 : 1; + await McpQuota.put({ id, used, day: today }); + if (used > DAILY_LIMIT) { + return { allowed: false, message: 'daily quota reached', retryAfterSeconds: 3600 }; + } + return true; + } +} +``` + +The counter is a real table: inspect or reset it over REST (`GET /QuotaCounter/`), and it survives restarts — an attacker who exhausted their quota stays exhausted after the process bounces. + +### 4.3 Security Posture + +What is and is not protected on a Harper MCP endpoint, and the checklist for exposing one publicly. + +#### When to Use + +Read this skill before exposing any MCP profile beyond localhost, and when reasoning about what an anonymous or low-privilege caller can reach. + +#### How It Works + +1. **Authentication is Harper's, not MCP's.** The MCP layer adds no login gate. Sessions bind to whatever user Harper's auth pipeline resolves — which can be nobody: anonymous sessions initialize, list, and call successfully where the deployment allows unauthenticated requests. +2. **Two different tool trust models.** Auto-generated verb tools are RBAC-filtered per user and enforce table permissions (including per-record `allow*` predicates) on every call. Custom `mcpTools` / `mcpPrompts` / `mcpResources` are exposed to **every** session — access control is entirely the author's method's responsibility. Never assume a custom tool is login-gated. +3. **Origin validation (DNS-rebinding defense).** Browser-origin requests are validated against Harper's CORS configuration; a disallowed `Origin` gets 403. The secure default for anything browser-reachable beyond loopback: enable CORS with an explicit allow-list (`http.cors` + `http.corsAccessList` for the application profile; `operationsApi.network.*` for operations). CORS off means origin checks are off — appropriate only for localhost/non-browser clients. +4. **Error hygiene.** Tool and resource errors cross the wire as sanitized messages; stack traces and raw author errors stay in the server log. +5. **Audit.** Every `tools/call` (including rate-limited, quota-denied, and protocol-error outcomes) emits an audit entry — profile, session, tool, user, duration, status, with sensitive-looking argument keys redacted. +6. **Template safety.** Custom resource URI templates enforce their single-segment contract against encoded-separator smuggling, and custom URIs cannot claim reserved schemes — see [Custom MCP Resources](custom-mcp-resources.md). + +#### Examples + +Hardening checklist for a public application-profile endpoint: + +- [ ] Every custom tool/prompt/resource either tolerates anonymous callers or checks `context.user` and throws. +- [ ] `rateLimit.perClientPerSecond` set (session limits alone are cycle-evadable); `identityHeader` only if the proxy strips it. +- [ ] A durable `quota` hook backs any cost-bearing tool ([Durable Quotas](durable-quotas.md)), with an atomic counter. +- [ ] CORS allow-list configured if browsers will reach the endpoint. +- [ ] `allow`/`deny`/`maxTools` trim the verb-tool surface to what the AI needs. +- [ ] Audit log shipping somewhere you actually read. diff --git a/harper-mcp/SKILL.md b/harper-mcp/SKILL.md new file mode 100644 index 0000000..0928be0 --- /dev/null +++ b/harper-mcp/SKILL.md @@ -0,0 +1,91 @@ +--- +name: harper-mcp +description: Comprehensive guide to Harper's Model Context Protocol (MCP) interface, + covering server setup, client connection, automatic and custom tools, prompts, + resources, rate limiting, durable quotas, and the security model. + Triggers on tasks involving MCP servers on Harper, AI-client integration, + and exposing Harper data or behavior to LLM agents. +license: Apache-2.0 +metadata: + author: harper + version: '1.0.0' +--- + +# Harper MCP + +Guidelines for exposing a Harper instance as a Model Context Protocol (MCP) server and for building the tools, prompts, and resources AI clients consume. Harper implements MCP Streamable HTTP (spec rev 2025-06-18) with two independent profiles: `application` (your app's surface) and `operations` (Harper administration). + +## When to Use + +Reference these guidelines when: + +- Enabling or configuring the MCP endpoint on a Harper instance +- Connecting an MCP client (Claude, agent frameworks, custom HTTP code) to Harper +- Deciding what tools an AI should see for a schema, or trimming that surface +- Exposing custom behavior (`mcpTools`), prompt templates (`mcpPrompts`), or content (`mcpResources`) to AI clients +- Protecting a public or anonymous-accessible MCP endpoint (rate limits, durable quotas, hardening) +- Debugging MCP wire errors (session/protocol headers, 400s, SSE) + +## How It Works + +1. Start with `enabling-mcp` to mount a profile, then `connecting-clients` for the handshake contract. +2. For the tool surface, consult `automatic-verb-tools` first — most CRUD needs are covered with zero code — and reach for `custom-mcp-tools` only for real behavior. +3. For content and templates, use `custom-mcp-resources` and `custom-mcp-prompts`; `resources-surface` explains what exists without any code. +4. Before any public exposure, work through `security-posture`'s checklist and configure `rate-limiting` (+ `durable-quotas` for cost-bearing tools). + +## Examples + +See the concrete examples embedded in each rule (curl handshakes, `static mcpTools`/`mcpResources` declarations, quota-hook implementations, and hardening configs). + + + +## Rule Categories by Priority + +| Priority | Category | Impact | Prefix | +| -------- | --------------------- | ------ | ------------ | +| 1 | Setup & Connection | HIGH | `setup-` | +| 2 | Tools & Prompts | HIGH | `tools-` | +| 3 | Resources | MEDIUM | `resources-` | +| 4 | Operations & Security | HIGH | `ops-` | + +## Quick Reference + +### 1. Setup & Connection (HIGH) + +- `enabling-mcp` — How to enable and configure Harper's MCP server profiles (application and operations). +- `connecting-clients` — How MCP clients connect to Harper - the initialize handshake, session and protocol-version headers, and authentication. + +### 2. Tools & Prompts (HIGH) + +- `automatic-verb-tools` — How Harper auto-generates CRUD MCP tools from exported tables, with RBAC filtering and allow/deny/maxTools controls. +- `custom-mcp-tools` — How to expose custom instance methods as MCP tools via static mcpTools, including the anonymous-exposure security model. +- `custom-mcp-prompts` — How to publish reusable prompt templates to MCP clients via static mcpPrompts. + +### 3. Resources (MEDIUM) + +- `resources-surface` — The MCP resources surface - harper:// metadata URIs, harper+rest:// table descriptors, templates, subscriptions, and list_changed notifications. +- `custom-mcp-resources` — How to serve custom content (docs pages, reports, binaries) as MCP resources via static mcpResources with URI templates and completions. + +### 4. Operations & Security (HIGH) + +- `rate-limiting` — MCP tools/call rate limiting - per-tool, per-session, and per-client-identity token buckets, and the identityHeader trust model. +- `durable-quotas` — Operator-pluggable durable quotas for MCP tools/call via the config-named quota Resource hook, with a race-safe counter pattern. +- `security-posture` — The MCP security model - anonymous access, RBAC boundaries, origin validation, audit logging, and the hardening checklist for public instances. + + + +## How to Use + +Read individual rule files for detailed explanations and code examples: + +``` +rules/enabling-mcp.md +rules/connecting-clients.md +rules/custom-mcp-tools.md +rules/custom-mcp-resources.md +rules/security-posture.md +``` + +## Full Compiled Document + +For the complete guide with all rules expanded: `AGENTS.md` diff --git a/harper-mcp/rules.manifest.yaml b/harper-mcp/rules.manifest.yaml new file mode 100644 index 0000000..17cbe8c --- /dev/null +++ b/harper-mcp/rules.manifest.yaml @@ -0,0 +1,83 @@ +# Rules manifest for the harper-mcp skill. +# +# Declarative source of truth for rule taxonomy, sources, and generation mode. +# All rules are currently synthesized (hand-authored from the Harper 5.1/5.2 +# MCP implementation and its documentation); flip individual rules to +# mode: generate once dedicated documentation sources exist for them. + +rules: + # =========================================================================== + # Setup & Connection (priority 1 — HIGH) + # =========================================================================== + + - rule: enabling-mcp + description: How to enable and configure Harper's MCP server profiles (application and operations). + category: setup + priority: 1 + order: 1 + + - rule: connecting-clients + description: How MCP clients connect to Harper - the initialize handshake, session and protocol-version headers, and authentication. + category: setup + priority: 1 + order: 2 + + # =========================================================================== + # Tools & Prompts (priority 2 — HIGH) + # =========================================================================== + + - rule: automatic-verb-tools + description: How Harper auto-generates CRUD MCP tools from exported tables, with RBAC filtering and allow/deny/maxTools controls. + category: tools + priority: 2 + order: 1 + + - rule: custom-mcp-tools + description: How to expose custom instance methods as MCP tools via static mcpTools, including the anonymous-exposure security model. + category: tools + priority: 2 + order: 2 + + - rule: custom-mcp-prompts + description: How to publish reusable prompt templates to MCP clients via static mcpPrompts. + category: tools + priority: 2 + order: 3 + + # =========================================================================== + # Resources (priority 3 — MEDIUM) + # =========================================================================== + + - rule: resources-surface + description: The MCP resources surface - harper:// metadata URIs, harper+rest:// table descriptors, templates, subscriptions, and list_changed notifications. + category: resources + priority: 3 + order: 1 + + - rule: custom-mcp-resources + description: How to serve custom content (docs pages, reports, binaries) as MCP resources via static mcpResources with URI templates and completions. + category: resources + priority: 3 + order: 2 + + # =========================================================================== + # Operations & Security (priority 4 — HIGH for public deployments) + # =========================================================================== + + - rule: rate-limiting + description: MCP tools/call rate limiting - per-tool, per-session, and per-client-identity token buckets, and the identityHeader trust model. + category: ops + priority: 4 + order: 1 + + - rule: durable-quotas + description: Operator-pluggable durable quotas for MCP tools/call via the config-named quota Resource hook, with a race-safe counter pattern. + category: ops + priority: 4 + order: 2 + + - rule: security-posture + description: The MCP security model - anonymous access, RBAC boundaries, origin validation, audit logging, and the hardening checklist for public instances. + category: ops + priority: 4 + order: 3 diff --git a/harper-mcp/rules/automatic-verb-tools.md b/harper-mcp/rules/automatic-verb-tools.md new file mode 100644 index 0000000..7d7a433 --- /dev/null +++ b/harper-mcp/rules/automatic-verb-tools.md @@ -0,0 +1,52 @@ +--- +name: automatic-verb-tools +description: How Harper auto-generates CRUD MCP tools from exported tables, with RBAC filtering and allow/deny/maxTools controls. +metadata: + mode: synthesized +--- + +# Automatic Verb Tools + +The zero-code tool surface: every `@export`ed table becomes a family of CRUD tools on the application profile. + +## When to Use + +Use this skill when deciding what an MCP client will see for a given schema, when tools are unexpectedly missing from `tools/list`, or when trimming a large generated surface. + +## How It Works + +1. **Generation.** For each exported table `Widget`, the application profile registers `get_widget`, `search_widget`, `create_widget`, `update_widget`, and `delete_widget` tools. Input/output schemas are derived from the table's typed attributes, so clients get real parameter validation and result shapes. +2. **RBAC is enforced, twice.** `tools/list` is filtered per authenticated user (a user with no read permission on a table does not see its `get_`/`search_` tools), and calls run through the same permission enforcement as REST — including per-record `allow*` predicates on Resource subclasses. This is the key contrast with [custom tools](custom-mcp-tools.md), which are visible to everyone. +3. **`exportTypes` gating.** A Resource registered with `exportTypes: { mcp: false }` is excluded from MCP enumeration entirely, independent of its REST exposure. +4. **Surface controls.** Per profile: `allow` / `deny` name filters and `maxTools` cap the generated set. Prefer trimming to what the AI actually needs — every tool costs client context. +5. **Live registration.** The tool registry rebuilds lazily when the underlying Resource registry changes (schema changes, deploys, components that finish loading after boot), so tools stay in sync without restarts; connected sessions receive `notifications/tools/list_changed` when their visible set actually changes. + +## Examples + +```graphql +# schema.graphql +type Widget @table @export { + id: ID @primaryKey + name: String @indexed + price: Float +} +``` + +With `mcp.application.mountPath` set, `tools/list` (as a user with read/write on Widget) includes: + +```json +{ "name": "search_widget", "inputSchema": { "properties": { "conditions": { "...": "..." } } } } +{ "name": "get_widget", "inputSchema": { "properties": { "id": { "type": "string" } } } } +{ "name": "create_widget", "...": "..." } +``` + +Trimming the surface to read-only: + +```yaml +mcp: + application: + mountPath: /mcp + allow: + - 'get_*' + - 'search_*' +``` diff --git a/harper-mcp/rules/connecting-clients.md b/harper-mcp/rules/connecting-clients.md new file mode 100644 index 0000000..ed379f9 --- /dev/null +++ b/harper-mcp/rules/connecting-clients.md @@ -0,0 +1,59 @@ +--- +name: connecting-clients +description: How MCP clients connect to Harper - the initialize handshake, session and protocol-version headers, and authentication. +metadata: + mode: synthesized +--- + +# Connecting Clients + +The wire-level contract an MCP client (or your own HTTP code) must follow against a Harper MCP endpoint. + +## When to Use + +Use this skill when configuring an MCP client against Harper, writing integration tests that drive `/mcp` directly, or debugging 400/404 responses from the endpoint. + +## How It Works + +Harper implements MCP **Streamable HTTP** (spec rev 2025-06-18; rev 2025-03-26 also accepted): + +1. **Initialize.** POST a JSON-RPC `initialize` to the mount path with `Accept: application/json, text/event-stream`. The response carries the negotiated `protocolVersion`, the server's capabilities, and — critically — an `Mcp-Session-Id` response header. +2. **Session header.** Every subsequent request MUST send that `Mcp-Session-Id` back. Missing/unknown ids get 400/404 (a 404 means re-initialize — sessions idle out after `mcp.session.idleTimeoutSeconds`, default 30 minutes). +3. **Protocol-version header.** Requests after initialize should send `MCP-Protocol-Version: `. A header naming a _different_ supported version than the session negotiated is rejected (400). A **missing** header is accepted as the session's own negotiated version (5.2.0+, patched into 5.1.x; older 5.1 releases treated a missing header as `2025-03-26` and rejected it as a mismatch on `2025-06-18` sessions). +4. **Server-push SSE.** A GET on the mount path (with `Accept: text/event-stream`) opens the server→client stream used for `notifications/*/list_changed`, resource-update notifications, progress, and server-initiated requests. Some flows require it: `resources/subscribe` is rejected until the session has an open SSE stream. +5. **Authentication.** Standard Harper authentication applies (Basic auth, tokens, mTLS — whatever the instance is configured with). Anonymous sessions are accepted when the deployment allows them; see the [Security Posture](security-posture.md) skill for what anonymous callers can reach. + +## Examples + +Full curl handshake: + +```bash +# 1. initialize — capture the session id from the response headers +curl -si http://localhost:9926/mcp \ + -H 'content-type: application/json' \ + -H 'accept: application/json, text/event-stream' \ + -u admin:password \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{ + "protocolVersion":"2025-06-18","capabilities":{}, + "clientInfo":{"name":"my-client","version":"1.0"}}}' +# → 200, header: Mcp-Session-Id: + +# 2. subsequent calls carry the session + protocol headers +curl -s http://localhost:9926/mcp \ + -H 'content-type: application/json' \ + -H 'accept: application/json, text/event-stream' \ + -H 'mcp-session-id: ' \ + -H 'mcp-protocol-version: 2025-06-18' \ + -u admin:password \ + -d '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' +``` + +Claude Desktop / MCP-client configuration is just the URL (plus auth): point a Streamable HTTP transport at `http://:9926/mcp`. + +Debugging cheat sheet: + +- `400 missing Mcp-Session-Id header` — you skipped step 2. +- `404` on a previously working session — idle timeout; re-initialize. +- `400 MCP-Protocol-Version mismatch` — you sent a header naming a different version than the session negotiated. +- `406` — missing the required `Accept` media type (JSON for POST, `text/event-stream` for GET). +- `403 origin_not_allowed` — browser-origin request rejected by CORS-tied origin validation; see [Security Posture](security-posture.md). diff --git a/harper-mcp/rules/custom-mcp-prompts.md b/harper-mcp/rules/custom-mcp-prompts.md new file mode 100644 index 0000000..791edc8 --- /dev/null +++ b/harper-mcp/rules/custom-mcp-prompts.md @@ -0,0 +1,51 @@ +--- +name: custom-mcp-prompts +description: How to publish reusable prompt templates to MCP clients via static mcpPrompts. +metadata: + mode: synthesized +--- + +# Custom MCP Prompts + +Publish parameterized prompt templates that MCP clients surface to their users (slash-command style). + +## When to Use + +Use this skill when your application wants to hand well-crafted, data-aware prompts to any connected AI client — "summarize this account", "draft a reply about order X" — instead of hoping each client writes a good one. + +## How It Works + +1. **Declare `static mcpPrompts`** on a Resource class: + +```javascript +export class Support extends tables.Ticket { + static mcpPrompts = [ + { + name: 'draft_reply', + description: 'Draft a support reply for a ticket', + arguments: [{ name: 'ticketId', description: 'Ticket to reply to', required: true }], + method: 'draftReplyPrompt', + }, + ]; + + async draftReplyPrompt(args) { + const ticket = await Support.get(args.ticketId); + return { + messages: [ + { + role: 'user', + content: { type: 'text', text: `Draft a courteous reply to: ${ticket.body}` }, + }, + ], + }; + } +} +``` + +2. **Surface.** Prompts appear in `prompts/list` and render via `prompts/get`; argument completion is served through `completion/complete` when declared. Connected sessions get `notifications/prompts/list_changed` when the set changes (reload/deploy). +3. **Render method contract.** The method receives the client-supplied arguments and returns the MCP prompt shape (`messages` array; a bare string is wrapped as a single user text message). It can read tables — it runs server-side with the same live-class dispatch as custom tools. +4. **Exposure.** Like custom tools, prompts are listed to every session on the profile, including anonymous ones — keep secrets out of prompt text and gate inside the method if needed. + +## Examples + +A data-aware prompt beats a static template: fetch current state (ticket, order, account) inside the render method so the client's LLM starts from live facts rather than asking for them turn by turn. diff --git a/harper-mcp/rules/custom-mcp-resources.md b/harper-mcp/rules/custom-mcp-resources.md new file mode 100644 index 0000000..9c65223 --- /dev/null +++ b/harper-mcp/rules/custom-mcp-resources.md @@ -0,0 +1,58 @@ +--- +name: custom-mcp-resources +description: How to serve custom content (docs pages, reports, binaries) as MCP resources via static mcpResources with URI templates and completions. +metadata: + mode: synthesized +--- + +# Custom MCP Resources + +Serve author-defined content — documentation pages, rendered reports, binary assets — under your own URIs (5.1.18+). + +## When to Use + +Use this skill to build a content surface for AI clients: a public docs server over MCP, per-entity report resources, or any read-oriented content the auto-generated descriptors can't express. + +## How It Works + +1. **Declare `static mcpResources`** on a Resource class. Each entry has exactly one of `uri` (fixed) or `uriTemplate`, plus a `method` naming the instance method that serves reads: + +```javascript +export class DocsPages extends Resource { + static mcpResources = [ + { + uri: 'docs:///index', + name: 'docs index', + description: 'List of all documentation pages', + mimeType: 'text/markdown', + method: 'readIndex', + }, + { + uriTemplate: 'docs:///{+path}', + name: 'docs page', + mimeType: 'text/markdown', + method: 'readPage', + completions: { path: ['guides/install.md', 'guides/deploy.md'] }, + }, + ]; + + async readIndex() { + return { text: '- docs:///guides/install.md\n…', mimeType: 'text/markdown' }; + } + + async readPage(params /* { path } */, context /* { user, profile, sessionId } */) { + return { text: loadPage(params.path), mimeType: 'text/markdown' }; + } +} +``` + +2. **Templates.** `{name}` matches exactly one path segment; `{+name}` matches across segments (RFC-6570-style reserved expansion — how MCP clients expand templates). The single-segment contract is enforced through percent-decoding: a URI smuggling an encoded separator (`%2F`/`%5C`) into a `{name}` slot simply fails to match, so `{name}` is safe to use in path construction. Duplicate parameter names are rejected at registration. +3. **Schemes.** Pick a custom scheme (`docs:///…` above). The reserved schemes — `harper:`, `harper+rest:`, `http:`, `https:` — are rejected at registration (and a template cannot parameterize the scheme position), so custom entries can never shadow the built-in surfaces. +4. **Content shapes.** The read method returns a string (text), `{ text, mimeType? }`, `{ blob, mimeType? }` (base64 binary), or any other object (serialized as JSON). Read errors surface to the client as a sanitized JSON-RPC error; the raw error goes to the server log only. +5. **Listing and completion.** Fixed URIs appear in `resources/list`, templates in `resources/templates/list`; declared `completions` values serve `completion/complete` per template parameter. Custom URIs win over the discovered surfaces on `resources/read`. +6. **Anonymous by design.** Custom resources are served to any session — including unauthenticated ones (the public-docs case this feature targets). The MCP layer performs no auth check for them; gate inside the read method (`context.user`) if content is restricted. +7. **No tables required.** A tableless component's resources register reliably — the registry rebuilds lazily per request when component loading completes after boot. + +## Examples + +Public docs server: the two-entry declaration above plus `mcp.application.mountPath` is the whole server — clients browse `resources/list`, complete paths, and read pages, all anonymous. Pair with [Rate Limiting](rate-limiting.md) and [Durable Quotas](durable-quotas.md) if any companion tools bear cost. diff --git a/harper-mcp/rules/custom-mcp-tools.md b/harper-mcp/rules/custom-mcp-tools.md new file mode 100644 index 0000000..30a7bee --- /dev/null +++ b/harper-mcp/rules/custom-mcp-tools.md @@ -0,0 +1,66 @@ +--- +name: custom-mcp-tools +description: How to expose custom instance methods as MCP tools via static mcpTools, including the anonymous-exposure security model. +metadata: + mode: synthesized +--- + +# Custom MCP Tools + +Expose non-CRUD operations — an LLM-backed `answer`, a domain action, a report generator — as first-class MCP tools. + +## When to Use + +Use this skill when the auto-generated verb tools aren't enough: the AI should invoke _behavior_, not just CRUD. Also read it before shipping any custom tool on a publicly reachable instance — the security model differs from verb tools. + +## How It Works + +1. **Declare `static mcpTools`** on a Resource class (typically in `resources.js`/`resources.ts`): + +```javascript +export class Orders extends tables.Orders { + static mcpTools = [ + { + name: 'reconcile_unsettled', + description: 'Reconcile all unsettled orders and return a summary', + method: 'reconcileUnsettled', + inputSchema: { + type: 'object', + properties: { since: { type: 'string', description: 'ISO 8601 timestamp' } }, + }, + }, + ]; + + async reconcileUnsettled(args, context) { + // context: { user, profile, sessionId, signal, progress?, serverRequest? } + return { reconciled: 12 }; + } +} +``` + +2. **Dispatch is live-class.** Calls construct an instance of the class currently in the Resource registry, so an exported subclass (and its access-control overrides) always wins after a reload/deploy. +3. **Per-call context.** The second argument carries `user`, `profile`, `sessionId`, an `AbortSignal` (`signal`) wired to MCP cancellation, and — on streaming calls — `progress()` and `serverRequest()`. Guard optional members (`context.progress?.(…)`). +4. **Results and errors.** Return values are wrapped into MCP tool results (objects become structured content). Thrown errors surface as `isError: true` tool results with the message only — stack traces stay in the server log. +5. **Security: custom tools are exposed to ANY session, including anonymous ones.** Unlike verb tools (RBAC-filtered per user), a custom tool is listed to every session and its method executes even with no logged-in user (`context.user` may be empty). The method runs inside the normal `transactional()` envelope, so data access it performs still hits per-record `allow*` predicates — but the _tool itself_ has no gate. To restrict one: + +```javascript +async reconcileUnsettled(args, context) { + if (!context.user?.username) { + throw new Error('authentication required'); + } + // ... +} +``` + +6. **Cost control for public tools.** A cost-bearing anonymous tool needs more than auth checks — see [Rate Limiting](rate-limiting.md) (per-client buckets survive session cycling) and [Durable Quotas](durable-quotas.md) (persisted per-identity limits). + +## Examples + +Warn-worthy anti-pattern — a public instance with an expensive tool and no gating: + +```javascript +static mcpTools = [{ name: 'answer', method: 'llmAnswer', ... }]; +async llmAnswer(args) { return await callExpensiveModel(args.q); } // anonymous callers burn your budget +``` + +Fixed: check `context.user` (or accept anonymity deliberately) _and_ configure `rateLimit.perClientPerSecond` + a `quota` hook. diff --git a/harper-mcp/rules/durable-quotas.md b/harper-mcp/rules/durable-quotas.md new file mode 100644 index 0000000..76e4f51 --- /dev/null +++ b/harper-mcp/rules/durable-quotas.md @@ -0,0 +1,65 @@ +--- +name: durable-quotas +description: Operator-pluggable durable quotas for MCP tools/call via the config-named quota Resource hook, with a race-safe counter pattern. +metadata: + mode: synthesized +--- + +# Durable Quotas + +Persisted, restart-surviving cost controls for `tools/call`, implemented by your code behind a config hook (5.2.0+). + +## When to Use + +Use this skill when in-memory [rate limiting](rate-limiting.md) is not enough as a cost control — typically a public, unauthenticated, cost-bearing tool (an LLM-backed `answer`) where you need "N calls per identity per day" semantics that survive restarts and span workers. + +## How It Works + +1. **Name a quota Resource in config:** + +```yaml +mcp: + application: + quota: + resource: McpQuota # exported Resource path + # method: allowMcpCall # optional; this is the default +``` + +2. **Implement the static method.** Before each admitted `tools/call`, Harper calls it with `{ identity, tool, user, profile, sessionId }` (`identity` is the client identity from the rate-limit layer — socket IP or trusted-header value — and may be `undefined`). Return `true` to allow, or `{ allowed: false, message?, retryAfterSeconds? }` to deny; denials surface as `isError` tool results with `kind: 'quota_exceeded'` plus your message. +3. **Ordering.** The hook runs _after_ the in-memory buckets admit the call, so rate-limited clients cannot spam a table-backed hook. +4. **Fail-closed.** A hook that throws — or a configured `resource`/`method` that doesn't resolve — **denies** the call (sanitized `quota policy unavailable` / `quota check failed`; raw error in the server log). Cost protection that silently disables itself on a bug is worse than a hard failure. The blast radius is `tools/call` only; list/read surfaces stay up. +5. **Race-safety is your hook's business.** It can run concurrently for the same identity — within a worker (interleavings across your own `await`s) and across workers. A naive `get` → `put used+1` counter undercounts under concurrency; make the read-modify-write atomic (a transaction that serializes conflicting writers, a compare-and-set retry loop, or a store with native atomic increments). + +## Examples + +Persisted per-identity daily counter (schema + hook): + +```graphql +type QuotaCounter @table { + id: ID @primaryKey # identity + used: Int + day: String +} +``` + +```javascript +const DAILY_LIMIT = 100; + +export class McpQuota extends tables.QuotaCounter { + static async allowMcpCall({ identity, tool }) { + const id = identity ?? 'unknown'; + const today = new Date().toISOString().slice(0, 10); + // NOTE: naive get-then-put shown for shape; production code must make + // this read-modify-write atomic (see Race-safety above). + const existing = await McpQuota.get(id); + const used = existing?.day === today ? existing.used + 1 : 1; + await McpQuota.put({ id, used, day: today }); + if (used > DAILY_LIMIT) { + return { allowed: false, message: 'daily quota reached', retryAfterSeconds: 3600 }; + } + return true; + } +} +``` + +The counter is a real table: inspect or reset it over REST (`GET /QuotaCounter/`), and it survives restarts — an attacker who exhausted their quota stays exhausted after the process bounces. diff --git a/harper-mcp/rules/enabling-mcp.md b/harper-mcp/rules/enabling-mcp.md new file mode 100644 index 0000000..9723c63 --- /dev/null +++ b/harper-mcp/rules/enabling-mcp.md @@ -0,0 +1,54 @@ +--- +name: enabling-mcp +description: How to enable and configure Harper's MCP server profiles (application and operations). +metadata: + mode: synthesized +--- + +# Enabling MCP + +Instructions for exposing a Harper instance as a Model Context Protocol (MCP) server. + +## When to Use + +Use this skill when an AI client (Claude, an agent framework, or any MCP-capable tool) should talk to Harper directly — calling tools backed by your tables and Resources, reading data as MCP resources, or driving Harper operations. + +## How It Works + +Harper ships two independent MCP **profiles**, each a Streamable HTTP endpoint (MCP spec rev 2025-06-18): + +1. **`application` profile** — the profile most apps want. Exposes your application's surface: auto-generated CRUD verb tools for `@export`ed tables, plus anything components opt in via `static mcpTools` / `static mcpPrompts` / `static mcpResources`. Mounts on the application HTTP port (default `9926`). +2. **`operations` profile** — exposes Harper's operations API as tools (user-filtered by operation permissions). Mounts on the operations port (default `9925`). Intended for administrative agents, not application clients. + +Enable them in `harper-config.yaml` (or the equivalent `HARPER_SET_CONFIG` process env var): + +```yaml +mcp: + application: + mountPath: /mcp # path on the application HTTP port + operations: + mountPath: /mcp # path on the operations port +``` + +Each profile is independent — enable only what you need. Key per-profile options: + +- `mountPath` — where the endpoint mounts. Required to enable the profile. +- `allow` / `deny` — name filters for the generated tool surface. +- `maxTools` — cap on generated tools (large schemas can otherwise flood a client's tool list). +- `rateLimit.*` — see the [Rate Limiting](rate-limiting.md) skill. +- `quota.*` — durable, operator-defined quotas; see the [Durable Quotas](durable-quotas.md) skill. + +Version notes: the transport and tool surface shipped across 5.1.x (complete protocol surface — prompts, resources, subscriptions, completions, cancellation, progress — in 5.1.10+). Custom content resources (`mcpResources`) are 5.1.18+. Per-client rate limiting and durable quotas are 5.2.0+. + +## Examples + +Minimal application-profile setup for a project with `@export`ed tables: + +```yaml +# harper-config.yaml +mcp: + application: + mountPath: /mcp +``` + +An MCP client pointed at `http://:9926/mcp` then sees `get_*` / `search_*` / `create_*` / `update_*` / `delete_*` tools for every exported table, RBAC-filtered per authenticated user. See the [Connecting Clients](connecting-clients.md) skill for the handshake. diff --git a/harper-mcp/rules/rate-limiting.md b/harper-mcp/rules/rate-limiting.md new file mode 100644 index 0000000..705e694 --- /dev/null +++ b/harper-mcp/rules/rate-limiting.md @@ -0,0 +1,54 @@ +--- +name: rate-limiting +description: MCP tools/call rate limiting - per-tool, per-session, and per-client-identity token buckets, and the identityHeader trust model. +metadata: + mode: synthesized +--- + +# Rate Limiting + +In-memory token buckets that bound `tools/call` throughput per profile. + +## When to Use + +Use this skill when tuning MCP throughput, and **always** when a tool is exposed on a public or anonymous-accessible instance — the default limits alone do not stop session-cycling abuse. + +## How It Works + +Four session-scoped limits ship with sensible defaults (per profile, configurable under `mcp..rateLimit.*`): + +- `perToolPerSecond` / `perToolBurst` — sustained rate and burst per (session, tool). +- `sessionConcurrency` — max in-flight calls per session. +- `sessionPerSecond` — sustained rate per session across all tools. + +Limit hits return an `isError` tool result with `kind: 'rate_limited'` and a `scope` field (not a JSON-RPC error), so the calling LLM can see and back off. + +**Session buckets are evadable by anonymous clients**: `initialize → call → drop session → repeat` gets fresh buckets every loop. Two additions close that (5.2.0+): + +- `perClientPerSecond` / `perClientBurst` (default off) — a bucket keyed on **client identity** rather than session, surviving the cycling loop. Burst defaults to the sustained rate, floored at one whole token (a fractional rate like `0.1` = "6/minute" still admits its first call). Denials report `scope: 'per_client'`. +- `identityHeader` — identity defaults to the client socket IP; deployments behind a reverse proxy can name a trusted header (typically `x-forwarded-for`, first value wins). **Only set this when the proxy strips or replaces the header on untrusted traffic** — a client-controlled identity header lets callers mint fresh identities per request and bypass the limit; Harper logs a startup warning when it is configured. + +All bucket state is in-memory per worker: it resets on restart and is not shared across workers. For durable, restart-surviving limits, see the [Durable Quotas](durable-quotas.md) skill. + +## Examples + +Public docs server with a cost-bearing `answer` tool — bound instantaneous abuse: + +```yaml +mcp: + application: + mountPath: /mcp + rateLimit: + perClientPerSecond: 0.5 # 30/minute sustained per client + perClientBurst: 5 +``` + +Behind a proxy that manages `X-Forwarded-For` correctly: + +```yaml +mcp: + application: + rateLimit: + identityHeader: x-forwarded-for + perClientPerSecond: 0.5 +``` diff --git a/harper-mcp/rules/resources-surface.md b/harper-mcp/rules/resources-surface.md new file mode 100644 index 0000000..7853776 --- /dev/null +++ b/harper-mcp/rules/resources-surface.md @@ -0,0 +1,44 @@ +--- +name: resources-surface +description: The MCP resources surface - harper:// metadata URIs, harper+rest:// table descriptors, templates, subscriptions, and list_changed notifications. +metadata: + mode: synthesized +--- + +# Resources Surface + +What `resources/list`, `resources/read`, `resources/templates/list`, and `resources/subscribe` expose from a Harper instance. + +## When to Use + +Use this skill when an MCP client should _read_ from Harper (schemas, API descriptions, data descriptors) rather than call tools, or when wiring change notifications. + +## How It Works + +Three URI families appear on the application profile: + +1. **`harper://` metadata resources** (no HTTP equivalent): + - `harper://about` — server version, profile, protocol versions, capabilities (both profiles). + - `harper://schema/{database}/{table}` — per-table attribute definitions, RBAC-filtered at read time (also offered as a URI template). + - `harper://openapi` — the OpenAPI 3.0.3 document for the REST surface. + - `harper://operations` — operations profile only; the caller's allowed operation names. +2. **`harper+rest://:/` descriptors** — one per exported Resource passing the `exportTypes.mcp` gate. Reading one returns a small JSON descriptor (path, database, table, and a hint to use the corresponding verb tools for records) — it is a pointer, not a data dump. The scheme is `harper+rest` (5.1.18+) because the MCP spec reserves `https://` for resources a client can fetch from the web; legacy `http(s)://` URIs from older listings still read and subscribe. +3. **Author-defined custom resources** — arbitrary URIs/templates served by component code; see the [Custom MCP Resources](custom-mcp-resources.md) skill. Custom URIs take precedence over the discovered surfaces on `resources/read`. + +Other behaviors: + +- **Subscriptions.** Row-backed application resources support `resources/subscribe` (change notifications delivered on the SSE stream). The session must have its GET SSE stream open first — subscribe calls before that are rejected with an instructive error. Subscriptions are restored on session resume. +- **`notifications/resources/list_changed`.** Sessions are notified when their _visible_ resource set (including templates) actually changes — per-session diffing keeps no-op rebuilds silent. +- **Pagination.** All list endpoints use opaque cursors per the MCP spec; treat `nextCursor` as a black box. + +## Examples + +Reading a table's schema as an AI-consumable resource: + +```json +→ {"method":"resources/read","params":{"uri":"harper://schema/data/widget"}} +← {"result":{"contents":[{"uri":"harper://schema/data/widget","mimeType":"application/json", + "text":"{\"attributes\":[{\"name\":\"id\",\"type\":\"ID\"},...]}"}]}} +``` + +Point a client at `harper://openapi` when it needs the whole REST contract in one read. diff --git a/harper-mcp/rules/security-posture.md b/harper-mcp/rules/security-posture.md new file mode 100644 index 0000000..3423a0f --- /dev/null +++ b/harper-mcp/rules/security-posture.md @@ -0,0 +1,34 @@ +--- +name: security-posture +description: The MCP security model - anonymous access, RBAC boundaries, origin validation, audit logging, and the hardening checklist for public instances. +metadata: + mode: synthesized +--- + +# Security Posture + +What is and is not protected on a Harper MCP endpoint, and the checklist for exposing one publicly. + +## When to Use + +Read this skill before exposing any MCP profile beyond localhost, and when reasoning about what an anonymous or low-privilege caller can reach. + +## How It Works + +1. **Authentication is Harper's, not MCP's.** The MCP layer adds no login gate. Sessions bind to whatever user Harper's auth pipeline resolves — which can be nobody: anonymous sessions initialize, list, and call successfully where the deployment allows unauthenticated requests. +2. **Two different tool trust models.** Auto-generated verb tools are RBAC-filtered per user and enforce table permissions (including per-record `allow*` predicates) on every call. Custom `mcpTools` / `mcpPrompts` / `mcpResources` are exposed to **every** session — access control is entirely the author's method's responsibility. Never assume a custom tool is login-gated. +3. **Origin validation (DNS-rebinding defense).** Browser-origin requests are validated against Harper's CORS configuration; a disallowed `Origin` gets 403. The secure default for anything browser-reachable beyond loopback: enable CORS with an explicit allow-list (`http.cors` + `http.corsAccessList` for the application profile; `operationsApi.network.*` for operations). CORS off means origin checks are off — appropriate only for localhost/non-browser clients. +4. **Error hygiene.** Tool and resource errors cross the wire as sanitized messages; stack traces and raw author errors stay in the server log. +5. **Audit.** Every `tools/call` (including rate-limited, quota-denied, and protocol-error outcomes) emits an audit entry — profile, session, tool, user, duration, status, with sensitive-looking argument keys redacted. +6. **Template safety.** Custom resource URI templates enforce their single-segment contract against encoded-separator smuggling, and custom URIs cannot claim reserved schemes — see [Custom MCP Resources](custom-mcp-resources.md). + +## Examples + +Hardening checklist for a public application-profile endpoint: + +- [ ] Every custom tool/prompt/resource either tolerates anonymous callers or checks `context.user` and throws. +- [ ] `rateLimit.perClientPerSecond` set (session limits alone are cycle-evadable); `identityHeader` only if the proxy strips it. +- [ ] A durable `quota` hook backs any cost-bearing tool ([Durable Quotas](durable-quotas.md)), with an atomic counter. +- [ ] CORS allow-list configured if browsers will reach the endpoint. +- [ ] `allow`/`deny`/`maxTools` trim the verb-tool surface to what the AI needs. +- [ ] Audit log shipping somewhere you actually read. From 2127a33800c2fd5e833c5dbb138fd52597ba5cbe Mon Sep 17 00:00:00 2001 From: Kyle Bernhardy Date: Wed, 8 Jul 2026 17:02:39 -0600 Subject: [PATCH 2/7] fix: ground rules against the MCP implementation; declare docs sources for flippable rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Grounding pass against harper main + merged docs found four fidelity gaps, all corrected: verb-tool names preserve Resource-path case (get_Widget, with deterministic collision prefixing); allow/deny globs are an operations-profile knob (replacing a read-only default), not an application one — application trimming is exportTypes.mcp + RBAC; maxTools is the tools/list page size (default 200), not a generation cap; mcpPrompts entries carry a render(args) function returning the messages shape, not a method name. Manifest now declares sources + must_cover for the five rules that map 1:1 to canonical documentation pages (enabling-mcp, resources-surface, custom-mcp-resources, rate-limiting, durable-quotas), readying the flip to mode: generate; the remaining five are agent-oriented synthesis with no single docs source. Co-Authored-By: Claude Fable 5 --- harper-mcp/AGENTS.md | 63 +++++++++++------------- harper-mcp/rules.manifest.yaml | 53 +++++++++++++++++++- harper-mcp/rules/automatic-verb-tools.md | 23 ++++----- harper-mcp/rules/custom-mcp-prompts.md | 34 ++++++------- harper-mcp/rules/enabling-mcp.md | 4 +- harper-mcp/rules/security-posture.md | 2 +- 6 files changed, 111 insertions(+), 68 deletions(-) diff --git a/harper-mcp/AGENTS.md b/harper-mcp/AGENTS.md index f52990e..247acb5 100644 --- a/harper-mcp/AGENTS.md +++ b/harper-mcp/AGENTS.md @@ -32,8 +32,8 @@ mcp: Each profile is independent — enable only what you need. Key per-profile options: - `mountPath` — where the endpoint mounts. Required to enable the profile. -- `allow` / `deny` — name filters for the generated tool surface. -- `maxTools` — cap on generated tools (large schemas can otherwise flood a client's tool list). +- `allow` / `deny` (operations profile only) — glob patterns or literal operation names selecting which operations become tools. The default is a deliberately read-only list; setting `allow` **replaces** it (no merge), so destructive operations like `set_configuration` must be opted in explicitly. +- `maxTools` — page size for `tools/list` responses (default 200); overflow pages via the MCP cursor. - `rateLimit.*` — see the [Rate Limiting](rate-limiting.md) skill. - `quota.*` — durable, operator-defined quotas; see the [Durable Quotas](durable-quotas.md) skill. @@ -117,10 +117,10 @@ Use this skill when deciding what an MCP client will see for a given schema, whe #### How It Works -1. **Generation.** For each exported table `Widget`, the application profile registers `get_widget`, `search_widget`, `create_widget`, `update_widget`, and `delete_widget` tools. Input/output schemas are derived from the table's typed attributes, so clients get real parameter validation and result shapes. +1. **Generation.** For each exported table `Widget`, the application profile registers `get_Widget`, `search_Widget`, `create_Widget`, `update_Widget`, and `delete_Widget` tools. Names preserve the Resource path's case (`/` and `.` become `_`, other non-identifier characters are dropped); path collisions get a deterministic database-name prefix. Input/output schemas are derived from the table's typed attributes, so clients get real parameter validation and result shapes. 2. **RBAC is enforced, twice.** `tools/list` is filtered per authenticated user (a user with no read permission on a table does not see its `get_`/`search_` tools), and calls run through the same permission enforcement as REST — including per-record `allow*` predicates on Resource subclasses. This is the key contrast with [custom tools](custom-mcp-tools.md), which are visible to everyone. 3. **`exportTypes` gating.** A Resource registered with `exportTypes: { mcp: false }` is excluded from MCP enumeration entirely, independent of its REST exposure. -4. **Surface controls.** Per profile: `allow` / `deny` name filters and `maxTools` cap the generated set. Prefer trimming to what the AI actually needs — every tool costs client context. +4. **Surface controls.** On the application profile, trim per Resource with `exportTypes: { mcp: false }`; `maxTools` sets the `tools/list` page size (default 200, cursor pages overflow). The `allow`/`deny` glob filters belong to the **operations** profile's tool generation, not this one. Prefer trimming to what the AI actually needs — every tool costs client context. 5. **Live registration.** The tool registry rebuilds lazily when the underlying Resource registry changes (schema changes, deploys, components that finish loading after boot), so tools stay in sync without restarts; connected sessions receive `notifications/tools/list_changed` when their visible set actually changes. #### Examples @@ -137,22 +137,19 @@ type Widget @table @export { With `mcp.application.mountPath` set, `tools/list` (as a user with read/write on Widget) includes: ```json -{ "name": "search_widget", "inputSchema": { "properties": { "conditions": { "...": "..." } } } } -{ "name": "get_widget", "inputSchema": { "properties": { "id": { "type": "string" } } } } -{ "name": "create_widget", "...": "..." } +{ "name": "search_Widget", "inputSchema": { "properties": { "conditions": { "...": "..." } } } } +{ "name": "get_Widget", "inputSchema": { "properties": { "id": { "type": "string" } } } } +{ "name": "create_Widget", "...": "..." } ``` -Trimming the surface to read-only: +Excluding an exported Resource from MCP while keeping its REST surface: -```yaml -mcp: - application: - mountPath: /mcp - allow: - - 'get_*' - - 'search_*' +```javascript +server.http(InternalThing, { name: 'internal-thing', exportTypes: { mcp: false } }); ``` +To expose read-only _data_, rely on RBAC: a role without write permissions never sees `create_`/`update_`/`delete_` tools for the table. + ### 2.2 Custom MCP Tools Expose non-CRUD operations — an LLM-backed `answer`, a domain action, a report generator — as first-class MCP tools. @@ -223,36 +220,36 @@ Use this skill when your application wants to hand well-crafted, data-aware prom #### How It Works -1. **Declare `static mcpPrompts`** on a Resource class: +1. **Declare `static mcpPrompts`** on a Resource class. Each entry carries a `render` **function** (not a method name): ```javascript export class Support extends tables.Ticket { static mcpPrompts = [ { name: 'draft_reply', + title: 'Draft support reply', description: 'Draft a support reply for a ticket', arguments: [{ name: 'ticketId', description: 'Ticket to reply to', required: true }], - method: 'draftReplyPrompt', + async render(args) { + const ticket = await Support.get(args.ticketId); + return { + messages: [ + { + role: 'user', + content: { type: 'text', text: `Draft a courteous reply to: ${ticket.body}` }, + }, + ], + }; + }, }, ]; - - async draftReplyPrompt(args) { - const ticket = await Support.get(args.ticketId); - return { - messages: [ - { - role: 'user', - content: { type: 'text', text: `Draft a courteous reply to: ${ticket.body}` }, - }, - ], - }; - } } ``` -2. **Surface.** Prompts appear in `prompts/list` and render via `prompts/get`; argument completion is served through `completion/complete` when declared. Connected sessions get `notifications/prompts/list_changed` when the set changes (reload/deploy). -3. **Render method contract.** The method receives the client-supplied arguments and returns the MCP prompt shape (`messages` array; a bare string is wrapped as a single user text message). It can read tables — it runs server-side with the same live-class dispatch as custom tools. -4. **Exposure.** Like custom tools, prompts are listed to every session on the profile, including anonymous ones — keep secrets out of prompt text and gate inside the method if needed. +2. **Entry shape.** `name` and `render` are required (invalid entries are skipped with a warning); `title`, `description`, and `arguments` (`{ name, description?, required? }`) are optional metadata surfaced to clients. +3. **Render contract.** `render(args)` receives the client-supplied argument values (strings) and returns the MCP prompt result shape — a `messages` array of `{ role, content }` entries (`content.type`: `text`, `image`, `audio`, or `resource`). It runs server-side and can read tables, so prompts can embed live data. +4. **Surface.** Prompts appear in `prompts/list` and render via `prompts/get`; declared arguments are served through `completion/complete`. Connected sessions get `notifications/prompts/list_changed` when the set changes (reload/deploy). +5. **Exposure.** Like custom tools, prompts are listed to every session on the profile, including anonymous ones — keep secrets out of prompt text and gate inside `render` if needed. #### Examples @@ -484,5 +481,5 @@ Hardening checklist for a public application-profile endpoint: - [ ] `rateLimit.perClientPerSecond` set (session limits alone are cycle-evadable); `identityHeader` only if the proxy strips it. - [ ] A durable `quota` hook backs any cost-bearing tool ([Durable Quotas](durable-quotas.md)), with an atomic counter. - [ ] CORS allow-list configured if browsers will reach the endpoint. -- [ ] `allow`/`deny`/`maxTools` trim the verb-tool surface to what the AI needs. +- [ ] The tool surface is trimmed to what the AI needs: `exportTypes: { mcp: false }` on internal Resources (application), a deliberate `allow` list (operations — remember it replaces the read-only default). - [ ] Audit log shipping somewhere you actually read. diff --git a/harper-mcp/rules.manifest.yaml b/harper-mcp/rules.manifest.yaml index 17cbe8c..cfe8f89 100644 --- a/harper-mcp/rules.manifest.yaml +++ b/harper-mcp/rules.manifest.yaml @@ -2,8 +2,12 @@ # # Declarative source of truth for rule taxonomy, sources, and generation mode. # All rules are currently synthesized (hand-authored from the Harper 5.1/5.2 -# MCP implementation and its documentation); flip individual rules to -# mode: generate once dedicated documentation sources exist for them. +# MCP implementation and its documentation). Rules with declared `sources` +# below map 1:1 to canonical HarperFast/documentation pages and are candidates +# to flip to mode: generate once a generation run is reviewed; rules without +# sources (connecting-clients' debugging guidance, custom tool/prompt security +# patterns, the security-posture checklist) are agent-oriented synthesis with +# no single docs source and should stay synthesized. rules: # =========================================================================== @@ -15,6 +19,16 @@ rules: category: setup priority: 1 order: 1 + # synthesized today; flip to generate once a generation run is reviewed + sources: + - path: reference/mcp/overview.md + role: primary + - path: reference/mcp/configuration.md + role: supporting + must_cover: + - both profiles (application/operations) and their default ports + - mountPath is what enables a profile + - version availability of major MCP features - rule: connecting-clients description: How MCP clients connect to Harper - the initialize handshake, session and protocol-version headers, and authentication. @@ -53,12 +67,31 @@ rules: category: resources priority: 3 order: 1 + sources: + - path: reference/mcp/tools-and-resources.md + section: 'Resources surface' + role: primary + - path: reference/mcp/subscriptions.md + role: supporting + must_cover: + - the harper:// metadata URIs and which profile serves each + - harper+rest:// descriptor scheme with http(s) back-compat + - subscribe requires an open GET SSE stream - rule: custom-mcp-resources description: How to serve custom content (docs pages, reports, binaries) as MCP resources via static mcpResources with URI templates and completions. category: resources priority: 3 order: 2 + sources: + - path: reference/mcp/tools-and-resources.md + section: 'Custom mcpResources opt-in' + role: primary + must_cover: + - exactly one of uri/uriTemplate per entry; {name} vs {+name} semantics + - encoded-separator rejection preserves the single-segment contract + - reserved schemes are rejected at registration + - anonymous sessions are served; gating is the read method's job # =========================================================================== # Operations & Security (priority 4 — HIGH for public deployments) @@ -69,12 +102,28 @@ rules: category: ops priority: 4 order: 1 + sources: + - path: reference/mcp/configuration.md + section: 'mcp..rateLimit.*' + role: primary + must_cover: + - session-scoped buckets are evadable by session cycling; per-client closes it + - perClientBurst defaults to the rate, floored at one whole token + - identityHeader is a spoofing trap unless the proxy strips it - rule: durable-quotas description: Operator-pluggable durable quotas for MCP tools/call via the config-named quota Resource hook, with a race-safe counter pattern. category: ops priority: 4 order: 2 + sources: + - path: reference/mcp/configuration.md + section: 'mcp..quota.*' + role: primary + must_cover: + - fail-closed on hook errors or misconfiguration + - hook runs after the in-memory buckets admit + - read-modify-write counters must be atomic - rule: security-posture description: The MCP security model - anonymous access, RBAC boundaries, origin validation, audit logging, and the hardening checklist for public instances. diff --git a/harper-mcp/rules/automatic-verb-tools.md b/harper-mcp/rules/automatic-verb-tools.md index 7d7a433..5959463 100644 --- a/harper-mcp/rules/automatic-verb-tools.md +++ b/harper-mcp/rules/automatic-verb-tools.md @@ -15,10 +15,10 @@ Use this skill when deciding what an MCP client will see for a given schema, whe ## How It Works -1. **Generation.** For each exported table `Widget`, the application profile registers `get_widget`, `search_widget`, `create_widget`, `update_widget`, and `delete_widget` tools. Input/output schemas are derived from the table's typed attributes, so clients get real parameter validation and result shapes. +1. **Generation.** For each exported table `Widget`, the application profile registers `get_Widget`, `search_Widget`, `create_Widget`, `update_Widget`, and `delete_Widget` tools. Names preserve the Resource path's case (`/` and `.` become `_`, other non-identifier characters are dropped); path collisions get a deterministic database-name prefix. Input/output schemas are derived from the table's typed attributes, so clients get real parameter validation and result shapes. 2. **RBAC is enforced, twice.** `tools/list` is filtered per authenticated user (a user with no read permission on a table does not see its `get_`/`search_` tools), and calls run through the same permission enforcement as REST — including per-record `allow*` predicates on Resource subclasses. This is the key contrast with [custom tools](custom-mcp-tools.md), which are visible to everyone. 3. **`exportTypes` gating.** A Resource registered with `exportTypes: { mcp: false }` is excluded from MCP enumeration entirely, independent of its REST exposure. -4. **Surface controls.** Per profile: `allow` / `deny` name filters and `maxTools` cap the generated set. Prefer trimming to what the AI actually needs — every tool costs client context. +4. **Surface controls.** On the application profile, trim per Resource with `exportTypes: { mcp: false }`; `maxTools` sets the `tools/list` page size (default 200, cursor pages overflow). The `allow`/`deny` glob filters belong to the **operations** profile's tool generation, not this one. Prefer trimming to what the AI actually needs — every tool costs client context. 5. **Live registration.** The tool registry rebuilds lazily when the underlying Resource registry changes (schema changes, deploys, components that finish loading after boot), so tools stay in sync without restarts; connected sessions receive `notifications/tools/list_changed` when their visible set actually changes. ## Examples @@ -35,18 +35,15 @@ type Widget @table @export { With `mcp.application.mountPath` set, `tools/list` (as a user with read/write on Widget) includes: ```json -{ "name": "search_widget", "inputSchema": { "properties": { "conditions": { "...": "..." } } } } -{ "name": "get_widget", "inputSchema": { "properties": { "id": { "type": "string" } } } } -{ "name": "create_widget", "...": "..." } +{ "name": "search_Widget", "inputSchema": { "properties": { "conditions": { "...": "..." } } } } +{ "name": "get_Widget", "inputSchema": { "properties": { "id": { "type": "string" } } } } +{ "name": "create_Widget", "...": "..." } ``` -Trimming the surface to read-only: +Excluding an exported Resource from MCP while keeping its REST surface: -```yaml -mcp: - application: - mountPath: /mcp - allow: - - 'get_*' - - 'search_*' +```javascript +server.http(InternalThing, { name: 'internal-thing', exportTypes: { mcp: false } }); ``` + +To expose read-only _data_, rely on RBAC: a role without write permissions never sees `create_`/`update_`/`delete_` tools for the table. diff --git a/harper-mcp/rules/custom-mcp-prompts.md b/harper-mcp/rules/custom-mcp-prompts.md index 791edc8..78d75b8 100644 --- a/harper-mcp/rules/custom-mcp-prompts.md +++ b/harper-mcp/rules/custom-mcp-prompts.md @@ -15,36 +15,36 @@ Use this skill when your application wants to hand well-crafted, data-aware prom ## How It Works -1. **Declare `static mcpPrompts`** on a Resource class: +1. **Declare `static mcpPrompts`** on a Resource class. Each entry carries a `render` **function** (not a method name): ```javascript export class Support extends tables.Ticket { static mcpPrompts = [ { name: 'draft_reply', + title: 'Draft support reply', description: 'Draft a support reply for a ticket', arguments: [{ name: 'ticketId', description: 'Ticket to reply to', required: true }], - method: 'draftReplyPrompt', + async render(args) { + const ticket = await Support.get(args.ticketId); + return { + messages: [ + { + role: 'user', + content: { type: 'text', text: `Draft a courteous reply to: ${ticket.body}` }, + }, + ], + }; + }, }, ]; - - async draftReplyPrompt(args) { - const ticket = await Support.get(args.ticketId); - return { - messages: [ - { - role: 'user', - content: { type: 'text', text: `Draft a courteous reply to: ${ticket.body}` }, - }, - ], - }; - } } ``` -2. **Surface.** Prompts appear in `prompts/list` and render via `prompts/get`; argument completion is served through `completion/complete` when declared. Connected sessions get `notifications/prompts/list_changed` when the set changes (reload/deploy). -3. **Render method contract.** The method receives the client-supplied arguments and returns the MCP prompt shape (`messages` array; a bare string is wrapped as a single user text message). It can read tables — it runs server-side with the same live-class dispatch as custom tools. -4. **Exposure.** Like custom tools, prompts are listed to every session on the profile, including anonymous ones — keep secrets out of prompt text and gate inside the method if needed. +2. **Entry shape.** `name` and `render` are required (invalid entries are skipped with a warning); `title`, `description`, and `arguments` (`{ name, description?, required? }`) are optional metadata surfaced to clients. +3. **Render contract.** `render(args)` receives the client-supplied argument values (strings) and returns the MCP prompt result shape — a `messages` array of `{ role, content }` entries (`content.type`: `text`, `image`, `audio`, or `resource`). It runs server-side and can read tables, so prompts can embed live data. +4. **Surface.** Prompts appear in `prompts/list` and render via `prompts/get`; declared arguments are served through `completion/complete`. Connected sessions get `notifications/prompts/list_changed` when the set changes (reload/deploy). +5. **Exposure.** Like custom tools, prompts are listed to every session on the profile, including anonymous ones — keep secrets out of prompt text and gate inside `render` if needed. ## Examples diff --git a/harper-mcp/rules/enabling-mcp.md b/harper-mcp/rules/enabling-mcp.md index 9723c63..5a62a40 100644 --- a/harper-mcp/rules/enabling-mcp.md +++ b/harper-mcp/rules/enabling-mcp.md @@ -33,8 +33,8 @@ mcp: Each profile is independent — enable only what you need. Key per-profile options: - `mountPath` — where the endpoint mounts. Required to enable the profile. -- `allow` / `deny` — name filters for the generated tool surface. -- `maxTools` — cap on generated tools (large schemas can otherwise flood a client's tool list). +- `allow` / `deny` (operations profile only) — glob patterns or literal operation names selecting which operations become tools. The default is a deliberately read-only list; setting `allow` **replaces** it (no merge), so destructive operations like `set_configuration` must be opted in explicitly. +- `maxTools` — page size for `tools/list` responses (default 200); overflow pages via the MCP cursor. - `rateLimit.*` — see the [Rate Limiting](rate-limiting.md) skill. - `quota.*` — durable, operator-defined quotas; see the [Durable Quotas](durable-quotas.md) skill. diff --git a/harper-mcp/rules/security-posture.md b/harper-mcp/rules/security-posture.md index 3423a0f..f74a2f6 100644 --- a/harper-mcp/rules/security-posture.md +++ b/harper-mcp/rules/security-posture.md @@ -30,5 +30,5 @@ Hardening checklist for a public application-profile endpoint: - [ ] `rateLimit.perClientPerSecond` set (session limits alone are cycle-evadable); `identityHeader` only if the proxy strips it. - [ ] A durable `quota` hook backs any cost-bearing tool ([Durable Quotas](durable-quotas.md)), with an atomic counter. - [ ] CORS allow-list configured if browsers will reach the endpoint. -- [ ] `allow`/`deny`/`maxTools` trim the verb-tool surface to what the AI needs. +- [ ] The tool surface is trimmed to what the AI needs: `exportTypes: { mcp: false }` on internal Resources (application), a deliberate `allow` list (operations — remember it replaces the read-only default). - [ ] Audit log shipping somewhere you actually read. From 0d8a451493b211015496b08cc4b3a3c3cf37baad Mon Sep 17 00:00:00 2001 From: Kyle Bernhardy Date: Wed, 8 Jul 2026 17:15:12 -0600 Subject: [PATCH 3/7] fix: wire harper-mcp into the generation pipeline and publish allowlists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cross-model review follow-ups (Codex + Gemini): - Register harper-mcp in the SKILLS registry so validate-generated's manifest lint and AGENTS.md / SKILL.md round-trip checks actively cover it; regenerate both artifacts through the repo renderer so the round-trips pass by construction. - The AGENTS.md lead paragraph moves from a hardcoded constant into the per-skill registry (agentsLead) — the single-skill assumption broke with a second skill. - Category maps gain the harper-mcp categories; the ops key collision is avoided by naming the category `security`. - Manifest: explicit mode: synthesized on every entry (house style); planned sources/must_cover mappings preserved as comments (the schema only permits them on generate rules) with a note that mcpPrompts is currently undocumented in reference/mcp/; cross_links added. - package.json files + .npmignore include harper-mcp so the markdown ships to package consumers (dist/index.js export shape deliberately unchanged — flagged in the PR as a separate packaging decision). Co-Authored-By: Claude Fable 5 --- .npmignore | 1 + harper-mcp/AGENTS.md | 2 +- harper-mcp/rules.manifest.yaml | 154 ++++++++++++++-------- package.json | 3 +- scripts/generation/generate-rules.mjs | 5 +- scripts/generation/lib/manifest.mjs | 17 +++ scripts/generation/lib/render.mjs | 10 ++ scripts/generation/validate-generated.mjs | 7 +- 8 files changed, 136 insertions(+), 63 deletions(-) diff --git a/.npmignore b/.npmignore index 2f52e0a..085303d 100644 --- a/.npmignore +++ b/.npmignore @@ -1,5 +1,6 @@ * !harper-best-practices/**/* +!harper-mcp/**/* !dist/**/* !AGENTS.md !LICENSE diff --git a/harper-mcp/AGENTS.md b/harper-mcp/AGENTS.md index 247acb5..4f44061 100644 --- a/harper-mcp/AGENTS.md +++ b/harper-mcp/AGENTS.md @@ -1,4 +1,4 @@ -# Harper MCP +# Harper Best Practices Guidelines for exposing a Harper instance as a Model Context Protocol (MCP) server and for building the tools, prompts, and resources AI clients consume. Harper implements MCP Streamable HTTP (spec rev 2025-06-18) with two independent profiles: `application` (your app's surface) and `operations` (Harper administration). diff --git a/harper-mcp/rules.manifest.yaml b/harper-mcp/rules.manifest.yaml index cfe8f89..b54e328 100644 --- a/harper-mcp/rules.manifest.yaml +++ b/harper-mcp/rules.manifest.yaml @@ -2,12 +2,15 @@ # # Declarative source of truth for rule taxonomy, sources, and generation mode. # All rules are currently synthesized (hand-authored from the Harper 5.1/5.2 -# MCP implementation and its documentation). Rules with declared `sources` -# below map 1:1 to canonical HarperFast/documentation pages and are candidates -# to flip to mode: generate once a generation run is reviewed; rules without -# sources (connecting-clients' debugging guidance, custom tool/prompt security -# patterns, the security-posture checklist) are agent-oriented synthesis with -# no single docs source and should stay synthesized. +# MCP implementation and its documentation). The commented `sources` / +# `must_cover` blocks below record the 1:1 mapping to canonical +# HarperFast/documentation pages for the rules that are candidates to flip to +# mode: generate (the schema only permits live sources/must_cover on generate +# rules; uncomment together with the mode flip once a generation run is +# reviewed). Rules without a mapping (connecting-clients' debugging guidance, +# custom-mcp-prompts — whose mcpPrompts feature is currently UNDOCUMENTED in +# reference/mcp/ — and the security-posture checklist) are agent-oriented +# synthesis with no single docs source and should stay synthesized. rules: # =========================================================================== @@ -19,22 +22,27 @@ rules: category: setup priority: 1 order: 1 + mode: synthesized # synthesized today; flip to generate once a generation run is reviewed - sources: - - path: reference/mcp/overview.md - role: primary - - path: reference/mcp/configuration.md - role: supporting - must_cover: - - both profiles (application/operations) and their default ports - - mountPath is what enables a profile - - version availability of major MCP features + # sources: + # - path: reference/mcp/overview.md + # role: primary + # - path: reference/mcp/configuration.md + # role: supporting + # must_cover: + # - both profiles (application/operations) and their default ports + # - mountPath is what enables a profile + # - version availability of major MCP features + cross_links: + - connecting-clients + - rate-limiting - rule: connecting-clients description: How MCP clients connect to Harper - the initialize handshake, session and protocol-version headers, and authentication. category: setup priority: 1 order: 2 + mode: synthesized # =========================================================================== # Tools & Prompts (priority 2 — HIGH) @@ -45,18 +53,44 @@ rules: category: tools priority: 2 order: 1 + mode: synthesized + # sources: + # - path: reference/mcp/tools-and-resources.md + # section: 'Application profile — tool generation' + # role: primary + # must_cover: + # - tool names preserve Resource-path case (get_Widget) + # - tools/list is RBAC-filtered per user; calls enforce permissions + # - exportTypes.mcp gating + cross_links: + - custom-mcp-tools - rule: custom-mcp-tools description: How to expose custom instance methods as MCP tools via static mcpTools, including the anonymous-exposure security model. category: tools priority: 2 order: 2 + mode: synthesized + # sources: + # - path: reference/mcp/tools-and-resources.md + # section: 'Custom mcpTools opt-in' + # role: primary + # must_cover: + # - custom tools are exposed to every session including anonymous ones + # - live-class dispatch; gating is the method's responsibility + cross_links: + - rate-limiting + - durable-quotas - rule: custom-mcp-prompts description: How to publish reusable prompt templates to MCP clients via static mcpPrompts. category: tools priority: 2 order: 3 + mode: synthesized + # No canonical docs source exists for mcpPrompts yet (shipped 5.1.10, + # undocumented in reference/mcp/) — stays synthesized until that docs + # gap is filled; content grounded in the implementation. # =========================================================================== # Resources (priority 3 — MEDIUM) @@ -67,31 +101,38 @@ rules: category: resources priority: 3 order: 1 - sources: - - path: reference/mcp/tools-and-resources.md - section: 'Resources surface' - role: primary - - path: reference/mcp/subscriptions.md - role: supporting - must_cover: - - the harper:// metadata URIs and which profile serves each - - harper+rest:// descriptor scheme with http(s) back-compat - - subscribe requires an open GET SSE stream + mode: synthesized + # sources: + # - path: reference/mcp/tools-and-resources.md + # section: 'Resources surface' + # role: primary + # - path: reference/mcp/subscriptions.md + # role: supporting + # must_cover: + # - the harper:// metadata URIs and which profile serves each + # - harper+rest:// descriptor scheme with http(s) back-compat + # - subscribe requires an open GET SSE stream + cross_links: + - custom-mcp-resources - rule: custom-mcp-resources description: How to serve custom content (docs pages, reports, binaries) as MCP resources via static mcpResources with URI templates and completions. category: resources priority: 3 order: 2 - sources: - - path: reference/mcp/tools-and-resources.md - section: 'Custom mcpResources opt-in' - role: primary - must_cover: - - exactly one of uri/uriTemplate per entry; {name} vs {+name} semantics - - encoded-separator rejection preserves the single-segment contract - - reserved schemes are rejected at registration - - anonymous sessions are served; gating is the read method's job + mode: synthesized + # sources: + # - path: reference/mcp/tools-and-resources.md + # section: 'Custom mcpResources opt-in' + # role: primary + # must_cover: + # - exactly one of uri/uriTemplate per entry; {name} vs {+name} semantics + # - encoded-separator rejection preserves the single-segment contract + # - reserved schemes are rejected at registration + # - anonymous sessions are served; gating is the read method's job + cross_links: + - resources-surface + - rate-limiting # =========================================================================== # Operations & Security (priority 4 — HIGH for public deployments) @@ -99,34 +140,41 @@ rules: - rule: rate-limiting description: MCP tools/call rate limiting - per-tool, per-session, and per-client-identity token buckets, and the identityHeader trust model. - category: ops + category: security priority: 4 order: 1 - sources: - - path: reference/mcp/configuration.md - section: 'mcp..rateLimit.*' - role: primary - must_cover: - - session-scoped buckets are evadable by session cycling; per-client closes it - - perClientBurst defaults to the rate, floored at one whole token - - identityHeader is a spoofing trap unless the proxy strips it + mode: synthesized + # sources: + # - path: reference/mcp/configuration.md + # section: 'mcp..rateLimit.*' + # role: primary + # must_cover: + # - session-scoped buckets are evadable by session cycling; per-client closes it + # - perClientBurst defaults to the rate, floored at one whole token + # - identityHeader is a spoofing trap unless the proxy strips it + cross_links: + - durable-quotas - rule: durable-quotas description: Operator-pluggable durable quotas for MCP tools/call via the config-named quota Resource hook, with a race-safe counter pattern. - category: ops + category: security priority: 4 order: 2 - sources: - - path: reference/mcp/configuration.md - section: 'mcp..quota.*' - role: primary - must_cover: - - fail-closed on hook errors or misconfiguration - - hook runs after the in-memory buckets admit - - read-modify-write counters must be atomic + mode: synthesized + # sources: + # - path: reference/mcp/configuration.md + # section: 'mcp..quota.*' + # role: primary + # must_cover: + # - fail-closed on hook errors or misconfiguration + # - hook runs after the in-memory buckets admit + # - read-modify-write counters must be atomic + cross_links: + - rate-limiting - rule: security-posture description: The MCP security model - anonymous access, RBAC boundaries, origin validation, audit logging, and the hardening checklist for public instances. - category: ops + category: security priority: 4 order: 3 + mode: synthesized diff --git a/package.json b/package.json index 086ee92..1ed84a2 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,8 @@ "repository": "github:HarperFast/skills", "files": [ "dist", - "harper-best-practices" + "harper-best-practices", + "harper-mcp" ], "type": "module", "exports": { diff --git a/scripts/generation/generate-rules.mjs b/scripts/generation/generate-rules.mjs index b5e92f2..aedcb1c 100644 --- a/scripts/generation/generate-rules.mjs +++ b/scripts/generation/generate-rules.mjs @@ -45,9 +45,6 @@ import { SKILL_INDEX_END, } from './lib/render.mjs'; -const AGENTS_LEAD = - 'Guidelines for building scalable, secure, and performant applications on Harper. These practices cover everything from initial schema design to advanced deployment strategies.'; - function parseArgs(argv) { const args = { docsPath: process.env.DOCS_PATH || '../documentation', rule: null, force: false }; for (let i = 0; i < argv.length; i++) { @@ -197,7 +194,7 @@ async function main() { bodies.set(entry.rule, bodyOf(raw)); } const agentsMd = assembleAgentsMd(manifest, (slug) => bodies.get(slug), { - lead: AGENTS_LEAD, + lead: skill.agentsLead, }); const agentsPath = path.join(process.cwd(), skill.dir, skill.agentsFile); await fs.writeFile(agentsPath, agentsMd, 'utf-8'); diff --git a/scripts/generation/lib/manifest.mjs b/scripts/generation/lib/manifest.mjs index 0f36531..5228590 100644 --- a/scripts/generation/lib/manifest.mjs +++ b/scripts/generation/lib/manifest.mjs @@ -16,6 +16,18 @@ export const SKILLS = [ rulesDir: 'rules', skillFile: 'SKILL.md', agentsFile: 'AGENTS.md', + // Lead paragraph of the assembled AGENTS.md, per skill. + agentsLead: + 'Guidelines for building scalable, secure, and performant applications on Harper. These practices cover everything from initial schema design to advanced deployment strategies.', + }, + { + dir: 'harper-mcp', + manifestFile: 'rules.manifest.yaml', + rulesDir: 'rules', + skillFile: 'SKILL.md', + agentsFile: 'AGENTS.md', + agentsLead: + "Guidelines for exposing a Harper instance as a Model Context Protocol (MCP) server and for building the tools, prompts, and resources AI clients consume. Harper implements MCP Streamable HTTP (spec rev 2025-06-18) with two independent profiles: `application` (your app's surface) and `operations` (Harper administration).", }, ]; @@ -26,6 +38,11 @@ export const CATEGORY_LABELS = { api: 'API & Communication', logic: 'Logic & Extension', ops: 'Infrastructure & Ops', + // harper-mcp categories + setup: 'Setup & Connection', + tools: 'Tools & Prompts', + resources: 'Resources', + security: 'Operations & Security', }; export const VALID_MODES = new Set(['generate', 'direct', 'synthesized']); diff --git a/scripts/generation/lib/render.mjs b/scripts/generation/lib/render.mjs index 5561447..9d8e581 100644 --- a/scripts/generation/lib/render.mjs +++ b/scripts/generation/lib/render.mjs @@ -24,6 +24,11 @@ const CATEGORY_IMPACT = { api: 'HIGH', logic: 'MEDIUM', ops: 'MEDIUM', + // harper-mcp categories + setup: 'HIGH', + tools: 'HIGH', + resources: 'MEDIUM', + security: 'HIGH', }; // URL-path prefix convention for each category. @@ -32,6 +37,11 @@ const CATEGORY_PREFIX = { api: 'api-', logic: 'logic-', ops: 'ops-', + // harper-mcp categories + setup: 'setup-', + tools: 'tools-', + resources: 'resources-', + security: 'ops-', }; // Produce the "Rule Categories by Priority" table + "Quick Reference" grouped diff --git a/scripts/generation/validate-generated.mjs b/scripts/generation/validate-generated.mjs index b250640..54df1ac 100644 --- a/scripts/generation/validate-generated.mjs +++ b/scripts/generation/validate-generated.mjs @@ -43,9 +43,6 @@ import { SKILL_INDEX_END, } from './lib/render.mjs'; -const AGENTS_LEAD = - 'Guidelines for building scalable, secure, and performant applications on Harper. These practices cover everything from initial schema design to advanced deployment strategies.'; - const MIN_GENERATED_BODY_CHARS = 200; function parseArgs(argv) { @@ -324,7 +321,9 @@ async function checkAgentsRoundTrip(manifest, skill, scope, errors) { } } - const assembled = assembleAgentsMd(manifest, (slug) => bodies.get(slug), { lead: AGENTS_LEAD }); + const assembled = assembleAgentsMd(manifest, (slug) => bodies.get(slug), { + lead: skill.agentsLead, + }); let expected; try { expected = oxfmtString(assembled); From 0920e4dfc83db9e6fb70b093c1c65c08207a94b3 Mon Sep 17 00:00:00 2001 From: Kyle Bernhardy Date: Wed, 8 Jul 2026 17:41:14 -0600 Subject: [PATCH 4/7] fix: harden the skill per blind agent tire-kick findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A clean-room agent evaluation (agent restricted to the skill as its only Harper-MCP knowledge, building the protected docs-server scenario against a live instance) surfaced four gaps, all fixed: - Version-verification procedure: unsupported config keys — including the rateLimit.perClient*/quota.* security controls — are accepted and SILENTLY IGNORED by older Harper versions; the skill now says to check serverInfo.version and prove each protection denies once. - The durable-quota example exposed its own counter table (exporting the hook class surfaces update_/delete_ verb tools and REST, letting clients reset their own counters); the example now carries exportTypes: { mcp: false } and a permissions note, and the security-posture checklist gained both items. - Exported plain-Resource classes surface partial verb-tool families (a lone create_*) — now documented. - "Tools stay in sync without restarts" gained its 5.1.18+ gate: the agent independently reproduced the pre-5.1.18 empty-registry-after- restart bug that harper#1613's lazy rebuild fixed. Co-Authored-By: Claude Fable 5 --- harper-mcp/AGENTS.md | 18 ++++++++++++++++-- harper-mcp/rules/automatic-verb-tools.md | 3 ++- harper-mcp/rules/durable-quotas.md | 9 ++++++++- harper-mcp/rules/enabling-mcp.md | 2 ++ harper-mcp/rules/rate-limiting.md | 2 ++ harper-mcp/rules/security-posture.md | 2 ++ 6 files changed, 32 insertions(+), 4 deletions(-) diff --git a/harper-mcp/AGENTS.md b/harper-mcp/AGENTS.md index 4f44061..9de9c90 100644 --- a/harper-mcp/AGENTS.md +++ b/harper-mcp/AGENTS.md @@ -39,6 +39,8 @@ Each profile is independent — enable only what you need. Key per-profile optio Version notes: the transport and tool surface shipped across 5.1.x (complete protocol surface — prompts, resources, subscriptions, completions, cancellation, progress — in 5.1.10+). Custom content resources (`mcpResources`) are 5.1.18+. Per-client rate limiting and durable quotas are 5.2.0+. +**Verify the version before relying on gated features.** Unsupported config keys (including the `rateLimit.perClient*` and `quota.*` security controls) are **accepted and silently ignored** by older versions — nothing errors, the feature just doesn't run. Check `serverInfo.version` in the `initialize` response (or read `harper://about`) first, and after configuring a limit, prove it denies at least once before trusting it. + #### Examples Minimal application-profile setup for a project with `@export`ed tables: @@ -121,7 +123,8 @@ Use this skill when deciding what an MCP client will see for a given schema, whe 2. **RBAC is enforced, twice.** `tools/list` is filtered per authenticated user (a user with no read permission on a table does not see its `get_`/`search_` tools), and calls run through the same permission enforcement as REST — including per-record `allow*` predicates on Resource subclasses. This is the key contrast with [custom tools](custom-mcp-tools.md), which are visible to everyone. 3. **`exportTypes` gating.** A Resource registered with `exportTypes: { mcp: false }` is excluded from MCP enumeration entirely, independent of its REST exposure. 4. **Surface controls.** On the application profile, trim per Resource with `exportTypes: { mcp: false }`; `maxTools` sets the `tools/list` page size (default 200, cursor pages overflow). The `allow`/`deny` glob filters belong to the **operations** profile's tool generation, not this one. Prefer trimming to what the AI actually needs — every tool costs client context. -5. **Live registration.** The tool registry rebuilds lazily when the underlying Resource registry changes (schema changes, deploys, components that finish loading after boot), so tools stay in sync without restarts; connected sessions receive `notifications/tools/list_changed` when their visible set actually changes. +5. **Live registration (5.1.18+).** The tool registry rebuilds lazily when the underlying Resource registry changes (schema changes, deploys, components that finish loading after boot), so tools stay in sync without restarts; connected sessions receive `notifications/tools/list_changed` when their visible set actually changes. On earlier 5.1.x, registration depends on schema-creation events — a restart on an existing data root can come up with an **empty custom-tool registry** (the tables already exist, so no event fires); upgrading is the fix. +6. **Plain `Resource` classes get partial tool families.** An exported non-table `Resource` subclass surfaces verb tools only for the REST verbs it actually has (typically a lone `create_*` from the base `post`) — if you export a class purely to host `mcpTools`/`mcpResources`, consider `exportTypes: { mcp: false }`-gating its verb surface or not exporting REST verbs at all. #### Examples @@ -374,6 +377,8 @@ Limit hits return an `isError` tool result with `kind: 'rate_limited'` and a `sc All bucket state is in-memory per worker: it resets on restart and is not shared across workers. For durable, restart-surviving limits, see the [Durable Quotas](durable-quotas.md) skill. +On Harper versions before 5.2.0 the `perClient*`/`identityHeader` keys are **accepted and silently ignored** — verify `serverInfo.version` and prove a denial once before trusting the limit (see [Enabling MCP](enabling-mcp.md)). + #### Examples Public docs server with a cost-bearing `answer` tool — bound instantaneous abuse: @@ -438,6 +443,11 @@ type QuotaCounter @table { const DAILY_LIMIT = 100; export class McpQuota extends tables.QuotaCounter { + // The hook class must be exported to be config-addressable — which would + // also surface update_/delete_McpQuota verb tools and a REST endpoint, + // letting a permitted client RESET ITS OWN COUNTER. Keep the quota table + // off the MCP surface and lock down its REST permissions. + static exportTypes = { mcp: false }; static async allowMcpCall({ identity, tool }) { const id = identity ?? 'unknown'; const today = new Date().toISOString().slice(0, 10); @@ -454,7 +464,9 @@ export class McpQuota extends tables.QuotaCounter { } ``` -The counter is a real table: inspect or reset it over REST (`GET /QuotaCounter/`), and it survives restarts — an attacker who exhausted their quota stays exhausted after the process bounces. +The counter is a real table: operators can inspect or reset it over REST (subject to the permissions you set), and it survives restarts — an attacker who exhausted their quota stays exhausted after the process bounces. + +Also verify the hook actually runs (call the tool past the limit once): on Harper versions before 5.2.0 the `quota.*` config keys are accepted and silently ignored — see [Enabling MCP](enabling-mcp.md). ### 4.3 Security Posture @@ -483,3 +495,5 @@ Hardening checklist for a public application-profile endpoint: - [ ] CORS allow-list configured if browsers will reach the endpoint. - [ ] The tool surface is trimmed to what the AI needs: `exportTypes: { mcp: false }` on internal Resources (application), a deliberate `allow` list (operations — remember it replaces the read-only default). - [ ] Audit log shipping somewhere you actually read. +- [ ] Version verified (`serverInfo.version` ≥ the feature gates you rely on) and each protection **proven to deny once** — older versions accept and silently ignore `rateLimit.perClient*` / `quota.*` keys. +- [ ] The quota hook's table is not itself exposed (`exportTypes: { mcp: false }` + restrictive REST permissions) — otherwise clients can reset their own counters. diff --git a/harper-mcp/rules/automatic-verb-tools.md b/harper-mcp/rules/automatic-verb-tools.md index 5959463..4111338 100644 --- a/harper-mcp/rules/automatic-verb-tools.md +++ b/harper-mcp/rules/automatic-verb-tools.md @@ -19,7 +19,8 @@ Use this skill when deciding what an MCP client will see for a given schema, whe 2. **RBAC is enforced, twice.** `tools/list` is filtered per authenticated user (a user with no read permission on a table does not see its `get_`/`search_` tools), and calls run through the same permission enforcement as REST — including per-record `allow*` predicates on Resource subclasses. This is the key contrast with [custom tools](custom-mcp-tools.md), which are visible to everyone. 3. **`exportTypes` gating.** A Resource registered with `exportTypes: { mcp: false }` is excluded from MCP enumeration entirely, independent of its REST exposure. 4. **Surface controls.** On the application profile, trim per Resource with `exportTypes: { mcp: false }`; `maxTools` sets the `tools/list` page size (default 200, cursor pages overflow). The `allow`/`deny` glob filters belong to the **operations** profile's tool generation, not this one. Prefer trimming to what the AI actually needs — every tool costs client context. -5. **Live registration.** The tool registry rebuilds lazily when the underlying Resource registry changes (schema changes, deploys, components that finish loading after boot), so tools stay in sync without restarts; connected sessions receive `notifications/tools/list_changed` when their visible set actually changes. +5. **Live registration (5.1.18+).** The tool registry rebuilds lazily when the underlying Resource registry changes (schema changes, deploys, components that finish loading after boot), so tools stay in sync without restarts; connected sessions receive `notifications/tools/list_changed` when their visible set actually changes. On earlier 5.1.x, registration depends on schema-creation events — a restart on an existing data root can come up with an **empty custom-tool registry** (the tables already exist, so no event fires); upgrading is the fix. +6. **Plain `Resource` classes get partial tool families.** An exported non-table `Resource` subclass surfaces verb tools only for the REST verbs it actually has (typically a lone `create_*` from the base `post`) — if you export a class purely to host `mcpTools`/`mcpResources`, consider `exportTypes: { mcp: false }`-gating its verb surface or not exporting REST verbs at all. ## Examples diff --git a/harper-mcp/rules/durable-quotas.md b/harper-mcp/rules/durable-quotas.md index 76e4f51..953a903 100644 --- a/harper-mcp/rules/durable-quotas.md +++ b/harper-mcp/rules/durable-quotas.md @@ -46,6 +46,11 @@ type QuotaCounter @table { const DAILY_LIMIT = 100; export class McpQuota extends tables.QuotaCounter { + // The hook class must be exported to be config-addressable — which would + // also surface update_/delete_McpQuota verb tools and a REST endpoint, + // letting a permitted client RESET ITS OWN COUNTER. Keep the quota table + // off the MCP surface and lock down its REST permissions. + static exportTypes = { mcp: false }; static async allowMcpCall({ identity, tool }) { const id = identity ?? 'unknown'; const today = new Date().toISOString().slice(0, 10); @@ -62,4 +67,6 @@ export class McpQuota extends tables.QuotaCounter { } ``` -The counter is a real table: inspect or reset it over REST (`GET /QuotaCounter/`), and it survives restarts — an attacker who exhausted their quota stays exhausted after the process bounces. +The counter is a real table: operators can inspect or reset it over REST (subject to the permissions you set), and it survives restarts — an attacker who exhausted their quota stays exhausted after the process bounces. + +Also verify the hook actually runs (call the tool past the limit once): on Harper versions before 5.2.0 the `quota.*` config keys are accepted and silently ignored — see [Enabling MCP](enabling-mcp.md). diff --git a/harper-mcp/rules/enabling-mcp.md b/harper-mcp/rules/enabling-mcp.md index 5a62a40..1e1a3f9 100644 --- a/harper-mcp/rules/enabling-mcp.md +++ b/harper-mcp/rules/enabling-mcp.md @@ -40,6 +40,8 @@ Each profile is independent — enable only what you need. Key per-profile optio Version notes: the transport and tool surface shipped across 5.1.x (complete protocol surface — prompts, resources, subscriptions, completions, cancellation, progress — in 5.1.10+). Custom content resources (`mcpResources`) are 5.1.18+. Per-client rate limiting and durable quotas are 5.2.0+. +**Verify the version before relying on gated features.** Unsupported config keys (including the `rateLimit.perClient*` and `quota.*` security controls) are **accepted and silently ignored** by older versions — nothing errors, the feature just doesn't run. Check `serverInfo.version` in the `initialize` response (or read `harper://about`) first, and after configuring a limit, prove it denies at least once before trusting it. + ## Examples Minimal application-profile setup for a project with `@export`ed tables: diff --git a/harper-mcp/rules/rate-limiting.md b/harper-mcp/rules/rate-limiting.md index 705e694..c836d24 100644 --- a/harper-mcp/rules/rate-limiting.md +++ b/harper-mcp/rules/rate-limiting.md @@ -30,6 +30,8 @@ Limit hits return an `isError` tool result with `kind: 'rate_limited'` and a `sc All bucket state is in-memory per worker: it resets on restart and is not shared across workers. For durable, restart-surviving limits, see the [Durable Quotas](durable-quotas.md) skill. +On Harper versions before 5.2.0 the `perClient*`/`identityHeader` keys are **accepted and silently ignored** — verify `serverInfo.version` and prove a denial once before trusting the limit (see [Enabling MCP](enabling-mcp.md)). + ## Examples Public docs server with a cost-bearing `answer` tool — bound instantaneous abuse: diff --git a/harper-mcp/rules/security-posture.md b/harper-mcp/rules/security-posture.md index f74a2f6..86d1043 100644 --- a/harper-mcp/rules/security-posture.md +++ b/harper-mcp/rules/security-posture.md @@ -32,3 +32,5 @@ Hardening checklist for a public application-profile endpoint: - [ ] CORS allow-list configured if browsers will reach the endpoint. - [ ] The tool surface is trimmed to what the AI needs: `exportTypes: { mcp: false }` on internal Resources (application), a deliberate `allow` list (operations — remember it replaces the read-only default). - [ ] Audit log shipping somewhere you actually read. +- [ ] Version verified (`serverInfo.version` ≥ the feature gates you rely on) and each protection **proven to deny once** — older versions accept and silently ignore `rateLimit.perClient*` / `quota.*` keys. +- [ ] The quota hook's table is not itself exposed (`exportTypes: { mcp: false }` + restrictive REST permissions) — otherwise clients can reset their own counters. From dec41cfdbba8a1f9b0921fa255e8dcde3b153a9e Mon Sep 17 00:00:00 2001 From: Kyle Bernhardy Date: Thu, 9 Jul 2026 18:44:08 -0600 Subject: [PATCH 5/7] fix: per-skill AGENTS.md title; harden rule example code (review round 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - assembleAgentsMd hardcoded '# Harper Best Practices' as the H1 — parameterized as agentsTitle in the SKILLS registry alongside agentsLead (kriszyp), threaded through generate/validate call sites; harper-mcp/AGENTS.md now carries its own title. - custom-mcp-resources example: the {+path} read now models the safe keyed-lookup pattern with an explicit warning against unvalidated filesystem path construction (gemini bot, seconded by Ethan — the rule's own prose says {+name} spans segments, so the example must not model the unguarded pattern). - custom-mcp-prompts example: null-ticket guard (gemini bot). Co-Authored-By: Claude Fable 5 --- harper-mcp/AGENTS.md | 11 +++++++++-- harper-mcp/rules/custom-mcp-prompts.md | 1 + harper-mcp/rules/custom-mcp-resources.md | 8 +++++++- scripts/generation/generate-rules.mjs | 1 + scripts/generation/lib/manifest.mjs | 4 +++- scripts/generation/lib/render.mjs | 4 ++-- 6 files changed, 23 insertions(+), 6 deletions(-) diff --git a/harper-mcp/AGENTS.md b/harper-mcp/AGENTS.md index 9de9c90..3a88795 100644 --- a/harper-mcp/AGENTS.md +++ b/harper-mcp/AGENTS.md @@ -1,4 +1,4 @@ -# Harper Best Practices +# Harper MCP Guidelines for exposing a Harper instance as a Model Context Protocol (MCP) server and for building the tools, prompts, and resources AI clients consume. Harper implements MCP Streamable HTTP (spec rev 2025-06-18) with two independent profiles: `application` (your app's surface) and `operations` (Harper administration). @@ -235,6 +235,7 @@ export class Support extends tables.Ticket { arguments: [{ name: 'ticketId', description: 'Ticket to reply to', required: true }], async render(args) { const ticket = await Support.get(args.ticketId); + if (!ticket) throw new Error(`ticket not found: ${args.ticketId}`); return { messages: [ { @@ -334,7 +335,13 @@ export class DocsPages extends Resource { } async readPage(params /* { path } */, context /* { user, profile, sessionId } */) { - return { text: loadPage(params.path), mimeType: 'text/markdown' }; + // {+path} spans segments BY DESIGN (it can contain `/` and `..`), so never + // hand it to filesystem path construction unvalidated. A keyed lookup like + // this is inherently safe; if you must touch the filesystem, resolve and + // verify containment first. + const page = PAGES.get(params.path); + if (!page) throw new Error(`no such page: ${params.path}`); + return { text: page, mimeType: 'text/markdown' }; } } ``` diff --git a/harper-mcp/rules/custom-mcp-prompts.md b/harper-mcp/rules/custom-mcp-prompts.md index 78d75b8..07b8975 100644 --- a/harper-mcp/rules/custom-mcp-prompts.md +++ b/harper-mcp/rules/custom-mcp-prompts.md @@ -27,6 +27,7 @@ export class Support extends tables.Ticket { arguments: [{ name: 'ticketId', description: 'Ticket to reply to', required: true }], async render(args) { const ticket = await Support.get(args.ticketId); + if (!ticket) throw new Error(`ticket not found: ${args.ticketId}`); return { messages: [ { diff --git a/harper-mcp/rules/custom-mcp-resources.md b/harper-mcp/rules/custom-mcp-resources.md index 9c65223..2d3f00f 100644 --- a/harper-mcp/rules/custom-mcp-resources.md +++ b/harper-mcp/rules/custom-mcp-resources.md @@ -41,7 +41,13 @@ export class DocsPages extends Resource { } async readPage(params /* { path } */, context /* { user, profile, sessionId } */) { - return { text: loadPage(params.path), mimeType: 'text/markdown' }; + // {+path} spans segments BY DESIGN (it can contain `/` and `..`), so never + // hand it to filesystem path construction unvalidated. A keyed lookup like + // this is inherently safe; if you must touch the filesystem, resolve and + // verify containment first. + const page = PAGES.get(params.path); + if (!page) throw new Error(`no such page: ${params.path}`); + return { text: page, mimeType: 'text/markdown' }; } } ``` diff --git a/scripts/generation/generate-rules.mjs b/scripts/generation/generate-rules.mjs index aedcb1c..caf6463 100644 --- a/scripts/generation/generate-rules.mjs +++ b/scripts/generation/generate-rules.mjs @@ -194,6 +194,7 @@ async function main() { bodies.set(entry.rule, bodyOf(raw)); } const agentsMd = assembleAgentsMd(manifest, (slug) => bodies.get(slug), { + title: skill.agentsTitle, lead: skill.agentsLead, }); const agentsPath = path.join(process.cwd(), skill.dir, skill.agentsFile); diff --git a/scripts/generation/lib/manifest.mjs b/scripts/generation/lib/manifest.mjs index 5228590..c6fc871 100644 --- a/scripts/generation/lib/manifest.mjs +++ b/scripts/generation/lib/manifest.mjs @@ -16,7 +16,8 @@ export const SKILLS = [ rulesDir: 'rules', skillFile: 'SKILL.md', agentsFile: 'AGENTS.md', - // Lead paragraph of the assembled AGENTS.md, per skill. + // H1 title and lead paragraph of the assembled AGENTS.md, per skill. + agentsTitle: 'Harper Best Practices', agentsLead: 'Guidelines for building scalable, secure, and performant applications on Harper. These practices cover everything from initial schema design to advanced deployment strategies.', }, @@ -26,6 +27,7 @@ export const SKILLS = [ rulesDir: 'rules', skillFile: 'SKILL.md', agentsFile: 'AGENTS.md', + agentsTitle: 'Harper MCP', agentsLead: "Guidelines for exposing a Harper instance as a Model Context Protocol (MCP) server and for building the tools, prompts, and resources AI clients consume. Harper implements MCP Streamable HTTP (spec rev 2025-06-18) with two independent profiles: `application` (your app's surface) and `operations` (Harper administration).", }, diff --git a/scripts/generation/lib/render.mjs b/scripts/generation/lib/render.mjs index 9d8e581..998e27a 100644 --- a/scripts/generation/lib/render.mjs +++ b/scripts/generation/lib/render.mjs @@ -146,10 +146,10 @@ function splitTitle(body) { // category in manifest order. Deterministic: same manifest + same rule bodies // always produce byte-identical output. `readBody(slug)` returns the rule's // body (frontmatter already stripped). -export function assembleAgentsMd(manifest, readBody, { lead } = {}) { +export function assembleAgentsMd(manifest, readBody, { title, lead } = {}) { const rules = sortedRules(manifest); - const out = ['# Harper Best Practices', '']; + const out = [`# ${title ?? 'Harper Best Practices'}`, '']; if (lead) out.push(lead.trim(), ''); // Group rules by category, preserving first-seen category order. From 64d27661d197452c876c4c0c0e7e64683e51e5f4 Mon Sep 17 00:00:00 2001 From: Kyle Bernhardy Date: Thu, 9 Jul 2026 18:46:56 -0600 Subject: [PATCH 6/7] fix: thread agentsTitle through the validator's round-trip assembly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prior commit threaded it through generate-rules but the validator call site edit silently no-op'd (unasserted string replace against an already-reformatted line) — the round-trip was comparing against the default title. Co-Authored-By: Claude Fable 5 --- scripts/generation/validate-generated.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/generation/validate-generated.mjs b/scripts/generation/validate-generated.mjs index 54df1ac..5d59a35 100644 --- a/scripts/generation/validate-generated.mjs +++ b/scripts/generation/validate-generated.mjs @@ -322,6 +322,7 @@ async function checkAgentsRoundTrip(manifest, skill, scope, errors) { } const assembled = assembleAgentsMd(manifest, (slug) => bodies.get(slug), { + title: skill.agentsTitle, lead: skill.agentsLead, }); let expected; From 9ad155d1db30980ed884436607aa650dfe87a1a0 Mon Sep 17 00:00:00 2001 From: Kyle Bernhardy Date: Thu, 9 Jul 2026 18:54:25 -0600 Subject: [PATCH 7/7] fix: use the real exportTypes registration mechanism in quota/tool guidance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A static exportTypes field on an exported class is never read (kriszyp on documentation#576) — the wired mechanism is server.resources.set(name, Class, { mcp: false, rest: false }). Runtime-verified on main: hook class absent from tools/list, REST 404, quota hook still resolves and denies. Also documents that mcp: false excludes the class's own custom mcpTools, so cost-bearing tools belong on a separate class. Co-Authored-By: Claude Fable 5 --- harper-mcp/AGENTS.md | 20 ++++++++++++-------- harper-mcp/rules/automatic-verb-tools.md | 2 +- harper-mcp/rules/durable-quotas.md | 16 ++++++++++------ harper-mcp/rules/security-posture.md | 2 +- 4 files changed, 24 insertions(+), 16 deletions(-) diff --git a/harper-mcp/AGENTS.md b/harper-mcp/AGENTS.md index 3a88795..cfb9e3a 100644 --- a/harper-mcp/AGENTS.md +++ b/harper-mcp/AGENTS.md @@ -124,7 +124,7 @@ Use this skill when deciding what an MCP client will see for a given schema, whe 3. **`exportTypes` gating.** A Resource registered with `exportTypes: { mcp: false }` is excluded from MCP enumeration entirely, independent of its REST exposure. 4. **Surface controls.** On the application profile, trim per Resource with `exportTypes: { mcp: false }`; `maxTools` sets the `tools/list` page size (default 200, cursor pages overflow). The `allow`/`deny` glob filters belong to the **operations** profile's tool generation, not this one. Prefer trimming to what the AI actually needs — every tool costs client context. 5. **Live registration (5.1.18+).** The tool registry rebuilds lazily when the underlying Resource registry changes (schema changes, deploys, components that finish loading after boot), so tools stay in sync without restarts; connected sessions receive `notifications/tools/list_changed` when their visible set actually changes. On earlier 5.1.x, registration depends on schema-creation events — a restart on an existing data root can come up with an **empty custom-tool registry** (the tables already exist, so no event fires); upgrading is the fix. -6. **Plain `Resource` classes get partial tool families.** An exported non-table `Resource` subclass surfaces verb tools only for the REST verbs it actually has (typically a lone `create_*` from the base `post`) — if you export a class purely to host `mcpTools`/`mcpResources`, consider `exportTypes: { mcp: false }`-gating its verb surface or not exporting REST verbs at all. +6. **Plain `Resource` classes get partial tool families.** An exported non-table `Resource` subclass surfaces verb tools only for the REST verbs it actually has (typically a lone `create_*` from the base `post`). To host `mcpTools`/`mcpResources` without any verb surface, register the class via `server.resources.set(name, Class, { mcp: false })`-style exportTypes at registration — note a `static exportTypes` field on the class is NOT read. #### Examples @@ -449,12 +449,7 @@ type QuotaCounter @table { ```javascript const DAILY_LIMIT = 100; -export class McpQuota extends tables.QuotaCounter { - // The hook class must be exported to be config-addressable — which would - // also surface update_/delete_McpQuota verb tools and a REST endpoint, - // letting a permitted client RESET ITS OWN COUNTER. Keep the quota table - // off the MCP surface and lock down its REST permissions. - static exportTypes = { mcp: false }; +class McpQuota extends tables.QuotaCounter { static async allowMcpCall({ identity, tool }) { const id = identity ?? 'unknown'; const today = new Date().toISOString().slice(0, 10); @@ -469,8 +464,17 @@ export class McpQuota extends tables.QuotaCounter { return true; } } + +// Register the class so the hook can resolve it by name — do NOT module-export +// it (that would surface update_/delete_McpQuota verb tools and a REST endpoint +// letting a permitted client reset its own counter). `exportTypes` gates each +// transport independently; a `static exportTypes` field on the class is NOT +// read — only this registration call (or @export directives) sets it. +server.resources.set('McpQuota', McpQuota, { mcp: false, rest: false }); ``` +Keep any cost-bearing `mcpTools` on a **separate** class: `mcp: false` excludes the whole class from the MCP walk, custom tools included. + The counter is a real table: operators can inspect or reset it over REST (subject to the permissions you set), and it survives restarts — an attacker who exhausted their quota stays exhausted after the process bounces. Also verify the hook actually runs (call the tool past the limit once): on Harper versions before 5.2.0 the `quota.*` config keys are accepted and silently ignored — see [Enabling MCP](enabling-mcp.md). @@ -503,4 +507,4 @@ Hardening checklist for a public application-profile endpoint: - [ ] The tool surface is trimmed to what the AI needs: `exportTypes: { mcp: false }` on internal Resources (application), a deliberate `allow` list (operations — remember it replaces the read-only default). - [ ] Audit log shipping somewhere you actually read. - [ ] Version verified (`serverInfo.version` ≥ the feature gates you rely on) and each protection **proven to deny once** — older versions accept and silently ignore `rateLimit.perClient*` / `quota.*` keys. -- [ ] The quota hook's table is not itself exposed (`exportTypes: { mcp: false }` + restrictive REST permissions) — otherwise clients can reset their own counters. +- [ ] The quota hook's table is not itself exposed — register it via `server.resources.set('McpQuota', McpQuota, { mcp: false, rest: false })` instead of module-exporting it (a `static exportTypes` field is not read); otherwise clients can reset their own counters. diff --git a/harper-mcp/rules/automatic-verb-tools.md b/harper-mcp/rules/automatic-verb-tools.md index 4111338..a44da35 100644 --- a/harper-mcp/rules/automatic-verb-tools.md +++ b/harper-mcp/rules/automatic-verb-tools.md @@ -20,7 +20,7 @@ Use this skill when deciding what an MCP client will see for a given schema, whe 3. **`exportTypes` gating.** A Resource registered with `exportTypes: { mcp: false }` is excluded from MCP enumeration entirely, independent of its REST exposure. 4. **Surface controls.** On the application profile, trim per Resource with `exportTypes: { mcp: false }`; `maxTools` sets the `tools/list` page size (default 200, cursor pages overflow). The `allow`/`deny` glob filters belong to the **operations** profile's tool generation, not this one. Prefer trimming to what the AI actually needs — every tool costs client context. 5. **Live registration (5.1.18+).** The tool registry rebuilds lazily when the underlying Resource registry changes (schema changes, deploys, components that finish loading after boot), so tools stay in sync without restarts; connected sessions receive `notifications/tools/list_changed` when their visible set actually changes. On earlier 5.1.x, registration depends on schema-creation events — a restart on an existing data root can come up with an **empty custom-tool registry** (the tables already exist, so no event fires); upgrading is the fix. -6. **Plain `Resource` classes get partial tool families.** An exported non-table `Resource` subclass surfaces verb tools only for the REST verbs it actually has (typically a lone `create_*` from the base `post`) — if you export a class purely to host `mcpTools`/`mcpResources`, consider `exportTypes: { mcp: false }`-gating its verb surface or not exporting REST verbs at all. +6. **Plain `Resource` classes get partial tool families.** An exported non-table `Resource` subclass surfaces verb tools only for the REST verbs it actually has (typically a lone `create_*` from the base `post`). To host `mcpTools`/`mcpResources` without any verb surface, register the class via `server.resources.set(name, Class, { mcp: false })`-style exportTypes at registration — note a `static exportTypes` field on the class is NOT read. ## Examples diff --git a/harper-mcp/rules/durable-quotas.md b/harper-mcp/rules/durable-quotas.md index 953a903..a5c597c 100644 --- a/harper-mcp/rules/durable-quotas.md +++ b/harper-mcp/rules/durable-quotas.md @@ -45,12 +45,7 @@ type QuotaCounter @table { ```javascript const DAILY_LIMIT = 100; -export class McpQuota extends tables.QuotaCounter { - // The hook class must be exported to be config-addressable — which would - // also surface update_/delete_McpQuota verb tools and a REST endpoint, - // letting a permitted client RESET ITS OWN COUNTER. Keep the quota table - // off the MCP surface and lock down its REST permissions. - static exportTypes = { mcp: false }; +class McpQuota extends tables.QuotaCounter { static async allowMcpCall({ identity, tool }) { const id = identity ?? 'unknown'; const today = new Date().toISOString().slice(0, 10); @@ -65,8 +60,17 @@ export class McpQuota extends tables.QuotaCounter { return true; } } + +// Register the class so the hook can resolve it by name — do NOT module-export +// it (that would surface update_/delete_McpQuota verb tools and a REST endpoint +// letting a permitted client reset its own counter). `exportTypes` gates each +// transport independently; a `static exportTypes` field on the class is NOT +// read — only this registration call (or @export directives) sets it. +server.resources.set('McpQuota', McpQuota, { mcp: false, rest: false }); ``` +Keep any cost-bearing `mcpTools` on a **separate** class: `mcp: false` excludes the whole class from the MCP walk, custom tools included. + The counter is a real table: operators can inspect or reset it over REST (subject to the permissions you set), and it survives restarts — an attacker who exhausted their quota stays exhausted after the process bounces. Also verify the hook actually runs (call the tool past the limit once): on Harper versions before 5.2.0 the `quota.*` config keys are accepted and silently ignored — see [Enabling MCP](enabling-mcp.md). diff --git a/harper-mcp/rules/security-posture.md b/harper-mcp/rules/security-posture.md index 86d1043..7439ebf 100644 --- a/harper-mcp/rules/security-posture.md +++ b/harper-mcp/rules/security-posture.md @@ -33,4 +33,4 @@ Hardening checklist for a public application-profile endpoint: - [ ] The tool surface is trimmed to what the AI needs: `exportTypes: { mcp: false }` on internal Resources (application), a deliberate `allow` list (operations — remember it replaces the read-only default). - [ ] Audit log shipping somewhere you actually read. - [ ] Version verified (`serverInfo.version` ≥ the feature gates you rely on) and each protection **proven to deny once** — older versions accept and silently ignore `rateLimit.perClient*` / `quota.*` keys. -- [ ] The quota hook's table is not itself exposed (`exportTypes: { mcp: false }` + restrictive REST permissions) — otherwise clients can reset their own counters. +- [ ] The quota hook's table is not itself exposed — register it via `server.resources.set('McpQuota', McpQuota, { mcp: false, rest: false })` instead of module-exporting it (a `static exportTypes` field is not read); otherwise clients can reset their own counters.