Skip to main content
Glama
README.md
# Personal MCP Hub

A Node.js personal MCP service hosting multiple MCP modules under one domain. It provides authenticated HTML artifacts, questionnaire management with signed public answer links, and authenticated Jev structured decisions through OpenRouter.

## Route layout

| Route | Access | Purpose |
|---|---|---|
| `POST /artifact/mcp` | Shared bearer secret | Stateless Streamable HTTP MCP endpoint |
| `POST /artifact/<id>/attachments?filename=<encoded-basename>` | Shared bearer secret | Stream one raw binary attachment |
| `GET/HEAD /artifact/<id>/attachments/<attachment-id>?expires=...&signature=...` | Attachment-scoped signed URL | Serve attachment bytes, with video range support |
| `GET /artifact/<24-char-id>` | Signed URL or Basic Auth | Render the latest artifact version |
| `GET /artifact/<id>/versions/<n>` | Signed URL or Basic Auth | Render one immutable version snapshot |
| `GET /artifact` | Public | Non-indexed module landing page |
| `GET /artifacts` | Caddy Basic Auth | Private gallery with confirmed deletion controls |
| `POST /artifacts/<id>/delete` | Caddy Basic Auth + CSRF token | Permanently delete from the gallery |
| `GET /artifact/README.md` | Public | Secret-free cross-harness installation guide |
| `GET /artifact/SKILL.md` | Public | Companion agent skill with collision routing |
| `POST /questionnaire/mcp` | Public (no auth) | Stateless Streamable HTTP questionnaire management endpoint |
| `GET /questionnaire/<id>/r/<revision>?expires=...&signature=...` | Signed URL | Render one exact questionnaire revision |
| `POST /questionnaire/<id>/r/<revision>/responses` | Signed URL | Create one anonymous draft |
| `GET/PATCH /questionnaire/<id>/r/<revision>/responses/<response-id>` | Signed URL + edit token | Resume or autosave a draft |
| `POST /questionnaire/<id>/r/<revision>/responses/<response-id>/submit` | Signed URL + edit token | Validate, identify, and finalize one response |
| `GET /questionnaire` | Public | Non-indexed module landing page |
| `GET /questionnaire/README.md` | Public | Secret-free installation and schema guide |
| `GET /questionnaire/SKILL.md` | Public | Companion agent skill |
| `POST /jev/mcp` | Shared bearer secret | Jev typed decisions through OpenRouter |
| `GET /jev` | Public | Non-indexed module landing page |
| `GET /jev/README.md` | Public | Secret-free installation and schema guide |
| `GET /jev/SKILL.md` | Public | Companion agent skill |
| `GET /jev/logs` | Caddy Basic Auth | Private Jev call log with arguments, results, failures, and costs |
| `GET /questionnaires` | Caddy Basic Auth | Private questionnaire index with links, status, and response counts |
| `GET /questionnaires/<id>/responses[/<response-id>]` | Caddy Basic Auth | Inspect collected response metadata and answers |
| `POST /questionnaires/<id>/{sign,status,delete}` | Caddy Basic Auth + action-scoped CSRF token | Mint links, open/close, or permanently delete from the index |
| `GET /logo.svg` | Public | Full MCP brand mark |
| `GET /favicon.svg` | Public | Browser/favicon brand mark |
| `GET /healthz` | Public | Health probe |

Future MCPs should use sibling namespaces such as `/<module>/mcp`; see `docs/ADDING_MODULES.md`.

Public installation URLs:

- `https://mcp.aravindmj.in/artifact/README.md`
- `https://mcp.aravindmj.in/artifact/SKILL.md`
- `https://mcp.aravindmj.in/questionnaire/README.md`
- `https://mcp.aravindmj.in/questionnaire/SKILL.md`
- `https://mcp.aravindmj.in/jev/README.md`
- `https://mcp.aravindmj.in/jev/SKILL.md`

Use the local MCP name `aravind_html_publisher` in other harnesses. The companion skill distinguishes this externally hosted publisher from Claude's built-in Artifacts feature: public/shareable-link requests use this MCP; Claude-native in-chat canvas requests use the built-in feature.

Gallery deletion is intentionally two-step: hover a card and press the top-right trash button, then confirm in the modal. Touch devices show the button persistently. The form carries an HMAC confirmation token and the entire `/artifacts/*` namespace remains behind Caddy Basic Auth.

The private questionnaire index is available at `https://mcp.aravindmj.in/questionnaires` behind the same Caddy Basic Auth. It supports copying fresh one-week links, opening or closing collection, browsing human-readable responses, deleting individual responses, and confirmed questionnaire deletion. Node accepts these routes only when Caddy injects its narrow trusted-header marker; each state-changing form also requires a domain-separated HMAC CSRF token.

Successful deletion uses a signed, short-lived, HTTP-only flash cookie. The gallery consumes and clears it on the first render, so the success message is absent on reload and never appears in the URL.

## Artifact tools

- `publish_html(html, title?, tags?)` — publishes HTML and returns one-week signed latest/version URLs plus SHA-256 metadata.
- `update_artifact(artifact_id, html, title?, tags?)` — creates a new version while preserving artifact identity and returns fresh signed URLs.
- `get_signed_url(artifact_id, expires_in_seconds?, version?)` — creates a fresh expiring share URL; defaults to one week.
- `get_attachment_upload_url(artifact_id)` returns the authenticated streaming upload endpoint, byte limit, and existing attachment references.
- `list_artifacts(limit?, tag?)` returns recent private metadata filtered before limiting, plus all unique tags across the entire collection.
- `delete_artifact(artifact_id)` permanently removes one artifact, every version, and its attachments.

## Artifact attachments and tags

See [the public upload and tagging guide](public/README.md#attachment-uploads) for the client workflow and exact semantics. Publish an initial page, get its upload endpoint with `get_attachment_upload_url`, stream raw binary using its 600-second artifact-scoped capability URL without exposing the shared Bearer credential, and place the returned `artifact-attachment:ID` reference in a follow-up HTML version. The response lists existing uploads so clients can recover references without re-uploading.

Attachments are immutable, artifact-owned files under `data/artifact/attachments/<artifact-id>/`. Every read checks an attachment-specific HMAC and expiry, then checks that its parent artifact still exists. Signed-page assets inherit that page's expiry; owner Basic Auth views receive one-hour assets. Asset signatures cannot authorize HTML pages or other files. No directory is exposed by a static file server. Non-allowlisted MIME types are forced downloads; all asset responses include `nosniff` and sandbox headers. HEAD and ranges support videos without reading entire files into memory. Pages with substituted references and binary responses use `private, no-store` instead of immutable caching. Metadata hashes continue to identify the stored source.

Defaults are 256 MiB per file and 100 files per artifact, controlled by `ARTIFACT_MAX_ATTACHMENT_BYTES` and `ARTIFACT_MAX_ATTACHMENTS`. HTML has its own independent limit. Streaming writes count actual bytes, use private temporary files, and remove partial files on handled failure. Upload, update, and deletion share a per-artifact lock. Run one Node writer for a data directory. Deletion removes attachment bytes and all HTML versions. Uploaded but unreferenced files remain until artifact deletion.

Tags are normalized private metadata. Publish defaults to `[]`; updates preserve omitted tags and clear on `[]`. Listing filters before limiting and returns the sorted unique tags from the entire current collection. The private gallery displays accessible filter chips, clear, and no-results states. Historical untagged metadata requires no migration.

### Reverse-proxy requirements for attachments

Before enabling this release publicly, route `POST /artifact/<id>/attachments` and `GET/HEAD /artifact/<id>/attachments/<attachment-id>` to Node without a separate Basic Auth challenge. Node enforces expiring artifact-scoped capabilities or optional Bearer authentication on uploads and file-scoped signatures on reads. Match only the two attachment route shapes; do not broaden a public matcher to the entire artifact namespace. Keep `/artifacts` and owner page fallbacks behind existing Basic Auth. Strip client-provided `X-Artifact-Basic-Auth` and `X-Questionnaire-Basic-Auth` in the public fallback proxy; inject trusted markers only after Basic Auth in private branches, as for existing artifact routes. Never serve the attachment storage directory directly or cache signed asset responses.

The proxy must preserve the query string, Authorization, Content-Type, Range, and If-Range headers, and permit a streaming body up to the configured attachment limit. Align proxy body-size limits and request timeouts with the expected video size. Review these live proxy settings during deployment; no proxy configuration is stored or changed by this repository patch.

## Questionnaire tools

- `create_questionnaire` — creates revision 1 and returns a one-week signed answer URL.
- `update_questionnaire` — creates a new immutable revision while preserving prior links and responses.
- `get_questionnaire` / `list_questionnaires` — retrieves definitions, status, and response counts.
- `get_questionnaire_signed_url` — mints an exact-revision link for 60 seconds through one year.
- `submit_questionnaire_response` — atomically submits complete answers with respondent name and email through MCP.
- `set_questionnaire_status` — opens or closes response collection.
- `delete_questionnaire` — removes every revision and response.
- `list_questionnaire_responses` — lists bounded response metadata; `get_questionnaire_response` retrieves one answer body by ID.
- `delete_questionnaire_response` — permanently removes one response.

Supported types: short/long text, email, URL, phone, number, date, time, date-time, single/multiple choice, dropdown, yes/no, consent, rating, scale, ranking, and matrix. The answering UI is responsive, keyboard accessible, progress-aware, dark-mode aware, and autosaves incomplete anonymous drafts before strict final validation and respondent attribution.

## Jev tool

- `make_decisions` — evaluates one text or structured state against independent `noul`, `choice`, and `score` questions in parallel through OpenRouter's Decisions API. It defaults to pinned `typesafe/jev-1.13`; `~typesafe/jev-latest` is available only for deliberate model drift.

The upstream OpenRouter key is read from `secrets/openrouter-api-key` inside the runtime directory on every call, so rotation takes effect without embedding it in source or client configuration. Use `scripts/setup-jev-openrouter.sh` to verify, install, or rotate it with hidden input, mode `0600`, and health verification; the running service picks it up without a restart.

### Jev call log

Node writes an audit record for every authenticated `tools/call` that reaches `POST /jev/mcp` before it makes any OpenRouter request. That includes unknown tool names, calls rejected by input validation, calls the MCP transport rejects, and each `tools/call` item in a JSON-RPC batch. It does not record `initialize`, `tools/list`, other protocol messages, or requests that fail bearer authentication.

Each record holds:

- an opaque call ID, and the JSON-RPC ID when it is a safe integer or a string of at most 256 characters;
- the tool name and the MCP arguments as JSON, exactly as the server parsed them;
- UTC start and completion times and the duration;
- `success` or `failure`, the full structured result of a successful call, and a failure message capped at 1,000 characters;
- the requested model, served model, provider, and OpenRouter generation ID;
- input, output, and total token counts, and OpenRouter `usage.cost` as `cost_usd`, when OpenRouter reports them.

Records never contain request headers, bearer tokens, API keys, secret files, error causes, or upstream error bodies.

A call counts as successful only after OpenRouter returns a response that passes the client checks and the tool's output schema. HTTP errors, network errors, timeouts, invalid responses, unknown tools, and schema rejections are failures. The cost is OpenRouter's `usage.cost` in USD, and the page shows it digit for digit, so `1.2e-8` appears as `$0.000000012`. A missing, negative, or non-numeric cost shows as "Cost unavailable", never as `$0`. A failed call has a cost only when OpenRouter returned a parseable response with valid usage data. Totals add costs as decimals, so they carry no floating-point residue.

If the record cannot be written, the call returns a tool error and OpenRouter is not called. If the final write fails after OpenRouter answers, the caller still gets the result and the record stays pending. On startup, Node marks every pending record as a failure. The page flags a pending record older than the OpenRouter timeout plus 60 seconds as needing review. A JSON-RPC ID reused within one HTTP request makes responses unattributable, so Node records those calls as failures and does not run them.

The private page is `https://mcp.aravindmj.in/jev/logs`. It shows total, successful, failed, and unfinished counts, the total cost, and pages of 25 calls, newest first. Each call has expandable request and result details. Failed calls carry a "Failed" badge with an icon, a highlighted card, and the failure reason. Node serves the page only when Caddy sets `X-Jev-Basic-Auth: 1` and returns 404 otherwise. The page uses a nonce-based CSP with no scripts, `no-store`, `noindex`, and frame denial, and it escapes all logged content. The public `/jev` landing page links to it, protected by the same HTTP Basic Auth as the page itself.

### Reverse-proxy requirements for the Jev call log

Update Caddy before deploying the Jev call log. Put `/jev/logs` and `/jev/logs/*` behind the existing Basic Auth, and inject `X-Jev-Basic-Auth: 1` only in that authenticated branch. Strip any client-provided `X-Jev-Basic-Auth` header in the public fallback proxy, as for the artifact and questionnaire markers. Until the public branch strips that header, anyone who can reach the host can send it and read every logged argument and result. No proxy configuration is stored or changed by this repository.

## Security model

- Node binds only to `127.0.0.1:4330`; Caddy is the only public ingress.
- Artifact, Questionnaire, and Jev MCP calls require the common bearer secret. The separate OpenRouter key used by Jev never leaves the server.
- Jev call records are stored in `/data/mcp-hub/data/jev/audit.sqlite3` using WAL mode, full synchronization, and private filesystem permissions. They keep full arguments and results with no automatic retention limit.
- The shared secret is stored outside the repository at `/data/mcp-hub/secrets/shared-secret`, mode `0600`; its directory is `0700`.
- The secret is read on every MCP request, allowing atomic rotation without restarting Node.
- Secret comparison uses Node's constant-time `crypto.timingSafeEqual`.
- Artifact IDs use 24 uniformly random base62 characters (about 143 bits) with no punctuation.
- Artifact and version pages require either a valid HMAC-signed URL or the gallery's HTTP Basic Auth. Signed URLs default to seven days and may be requested for 60 seconds through one year.
- Artifact responses include `X-Robots-Tag: noindex, nofollow, noarchive, nosnippet, noimageindex`.
- HTML and metadata use atomic writes and mode `0600`.
- Latest and version-specific HTML are served with CSP sandboxing, `nosniff`, no-referrer, and a restrictive Permissions Policy. Version URLs are immutable; stable artifact URLs revalidate to the latest version.
- Default HTML limit: 2 MiB.
- Questionnaire IDs and response IDs are 24 uniformly random base62 characters. Signed links bind the questionnaire ID, exact revision, and expiry.
- Questionnaire definitions, revisions, answers, and submitted respondent names/emails are stored in `/data/mcp-hub/data/questionnaire/questionnaires.sqlite3` using foreign keys, WAL mode, full synchronization, and private filesystem permissions.
- Storage is bounded by default to 100 immutable revisions and 10,000 response rows per questionnaire, plus 256 KiB of serialized answers per response. Operators may adjust these with `QUESTIONNAIRE_MAX_REVISIONS`, `QUESTIONNAIRE_MAX_RESPONSES`, and `QUESTIONNAIRE_MAX_ANSWER_BYTES`.
- Draft edit tokens are returned only to the answering browser, isolated to the current tab through session storage, and persisted server-side only as SHA-256 hashes. Management tools never return them. Response versions prevent a stale duplicated tab from silently overwriting newer answers.
- All questionnaire pages and response APIs require an unexpired signed URL. Response resume/autosave/submit also requires the draft edit token. Cross-site unsafe requests are rejected.

Anyone who has an artifact URL can view it. Never publish secrets or private data in an artifact.

## Local development

Requires Node.js 22+.

```bash
npm install
npm test
MCP_HUB_RUNTIME_DIR="$PWD/.runtime" npm run rotate-secret
MCP_HUB_RUNTIME_DIR="$PWD/.runtime" MCP_HUB_PUBLIC_BASE_URL=http://127.0.0.1:4330 npm start
```

Do not put secrets in `.env` or shell startup files. For platform-managed environment overrides, use **hPanel → Hermes Agent → Dashboard → Environment**. The deployed service uses the protected secret file by default.

## Operations

```bash
npm test
MCP_HUB_RUNTIME_DIR=/data/mcp-hub node /data/mcp-hub/app/scripts/rotate-secret.js
curl -fsS http://127.0.0.1:4330/healthz
hermes mcp test aravind_html_publisher
hermes mcp test aravind_questionnaires
hermes mcp test aravind_jev_decisions
```

The questionnaire SQLite database is persistent runtime state, not release content. Back up `/data/mcp-hub/data/questionnaire/questionnaires.sqlite3` with SQLite's online backup mechanism while the service is running, or stop the service and copy the database together with any `-wal` and `-shm` sidecars. Back up the Jev call log at `/data/mcp-hub/data/jev/audit.sqlite3` the same way. Never replace `/data/mcp-hub/data` during an application deployment.

### Ask an agent to update installed user-scope skills

The Node updater discovers existing user-scope copies across Hermes, Cursor, OpenCode, Claude Code, and Codex. It never creates a missing harness installation. Its generated prompt pins the downloaded skill by SHA-256 and forbids credential/config changes.

```bash
# Inspect detected installations and the exact agent prompt; no mutation
npm run update-harness-skills -- --dry-run

# Ask Cursor Agent to update every detected user-scope copy
npm run update-harness-skills -- --run --agent cursor

# Alternative agent runners
npm run update-harness-skills -- --run --agent opencode
npm run update-harness-skills -- --run --agent hermes
```

Cursor is the default runner. The script exits without invoking an agent when no existing user-scope copies are found.

The production rotation command atomically updates both the service secret and Hermes' protected `MCP_ARAVIND_HTML_PUBLISHER_API_KEY` credential without printing either value. It also removes the retired `MCP_ARTIFACT_API_KEY` entry. Start a new Hermes session after rotation so long-lived clients cannot retain the prior header.

A no-agent Hermes cron job runs `scripts/watchdog.sh` once per minute. The wrapper executes the Node watchdog; no Python runtime is used.