Hosted Model Context Protocol server that exposes JobRunr documentation to AI coding assistants (Claude Code, Cursor, VS Code, Windsurf, ChatGPT desktop, ...).
Add one URL to your IDE config and JobRunr docs are live in every conversation — your agent stops hallucinating APIs and starts citing real jobrunr.io pages.
Live endpoint: https://mcp.jobrunr.io/mcp
Built on Spring Boot 4.1 + Spring AI 2.0's MCP server starter + Apache Lucene (BM25) + a local ONNX MiniLM embedding model. Retrieval is hybrid: BM25 and semantic search combined via Reciprocal Rank Fusion.
The server speaks Streamable HTTP at /mcp — the transport every current MCP client uses, where each
JSON-RPC message is its own HTTP POST. It negotiates protocol versions 2024-11-05 through 2025-11-25;
newer clients negotiate down to 2025-11-25.
The deprecated HTTP+SSE transport (GET /sse + POST /mcp/message, protocol 2024-11-05) still runs
alongside it so clients installed before Streamable HTTP landed keep working. It is deprecated upstream and
will be removed once that traffic stops — new installs should use /mcp. Set MCP_LEGACY_SSE_ENABLED=false
to switch it off.
| Tool | Purpose |
|---|---|
search_jobrunr_docs(query, limit?) |
Hybrid search. Returns ranked pages with title, URL, section, tier (oss or pro), and a highlighted snippet. When any result is tier: "pro", the response also carries a proTrialHint pointing the agent at the trial tool. |
fetch_jobrunr_doc(path) |
Full markdown for a single page. Use the path field from search_jobrunr_docs results. |
list_jobrunr_doc_sections() |
Grouped TOC. Useful for browsing before searching. |
request_jobrunr_pro_trial(email, company, interested_in?, use_case?) |
Submit a free JobRunr Pro trial request on the user's behalf. The agent is instructed to offer this when surfacing Pro features. Always collects email and company from the user — never invented. |
- User asks about a JobRunr Pro feature (priority queues, real-time scheduling, multi-cluster dashboard, ...).
search_jobrunr_docsreturns the Pro page plus aproTrialHinttelling the agent a trial is available.- The agent offers: "This is a JobRunr Pro feature — want me to request a free trial for you?"
- If the user agrees, the agent asks for their email and company, then calls
request_jobrunr_pro_trial. - The tool POSTs to a configured n8n webhook with
{email, company, form: "mcp-trial", interested_in?, use_case?, submitted_at}and returns a success or failure message that the agent relays.
The form: "mcp-trial" field is hardcoded server-side so n8n can route MCP-sourced trials separately from website form trials.
claude mcp add --transport http jobrunr-docs https://mcp.jobrunr.io/mcp
Restart Claude Code, then ask about anything JobRunr-related. Run /mcp inside Claude Code to confirm the connection.
If you added this server before August 2026 you'll have it on the old SSE URL. That still works, but re-add it
to move to the current transport: claude mcp remove jobrunr-docs then the command above.
Add to ~/.cursor/mcp.json:
{
"mcpServers": {
"jobrunr-docs": {
"url": "https://mcp.jobrunr.io/mcp"
}
}
}Restart Cursor. Tools appear under Settings → MCP.
Use the same URL — https://mcp.jobrunr.io/mcp — with whatever MCP config the client expects. Most accept a one-line url entry.
npx @modelcontextprotocol/inspector --cli https://mcp.jobrunr.io/mcp --transport http --method tools/list
Or drop the --cli … flags for the browser UI: transport = Streamable HTTP, URL = https://mcp.jobrunr.io/mcp, click Connect.
curl https://mcp.jobrunr.io/actuator/health
# {"status":"UP",...}
# Full JSON-RPC handshake — this is exactly what a client's first request looks like:
curl -X POST https://mcp.jobrunr.io/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H 'MCP-Protocol-Version: 2025-11-25' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{
"protocolVersion":"2025-11-25","capabilities":{},
"clientInfo":{"name":"curl","version":"1.0"}}}'The response carries an Mcp-Session-Id header; send it back on every subsequent POST (tools/list,
tools/call, …). Note the Accept header must list both types — the spec requires it and the server
returns 400 without it.
mvn spring-boot:run
Defaults to polling https://www.jobrunr.io/mcp/docs.json (once the Hugo workflow lands), falling back to the bootstrap URL configured in application.yml. Override either via env:
DOCS_URL=file:///absolute/path/to/docs.json \
DOCS_MANIFEST_URL=file:///absolute/path/to/manifest.json \
mvn spring-boot:run
file:// URLs are supported for quick local iteration without an HTTP server.
Run tests:
mvn test
Tests use the bundled src/test/resources/sample-docs.json so they don't depend on the network. A deterministic stub embedding model is used to keep the suite fast and offline.
Hugo build (JobRunr website repo)
──► public/mcp/docs.json + manifest.json
│
▼
GitHub Pages: https://www.jobrunr.io/mcp/...
│
▼ (15-min poll + HMAC-signed /admin/reindex webhook)
Spring Boot app (this repo)
│
├── LuceneIndex BM25, in-memory RAMDirectory
├── VectorIndex Spring AI TransformersEmbeddingModel (ONNX MiniLM)
├── HybridSearch Reciprocal Rank Fusion (k=60)
└── DocsTools @Tool methods → MCP tools/call
│
▼
Streamable HTTP /mcp ──► Claude Code / Cursor / ...
(+ deprecated /sse)
The server never generates answers — it surfaces relevant pages and lets the client LLM synthesize. Stateless apart from the in-memory indexes.
| Endpoint | Purpose |
|---|---|
POST /mcp |
MCP Streamable HTTP endpoint — every JSON-RPC message. What clients should use. |
GET /mcp |
Standalone SSE stream for server-initiated messages (same session) |
GET /sse |
Deprecated HTTP+SSE entry, kept for clients installed before Streamable HTTP |
POST /mcp/message?sessionId=… |
Deprecated JSON-RPC messages for the above (advertised over SSE) |
GET /actuator/health |
Liveness + readiness |
POST /admin/reindex |
Force reload of docs.json (HMAC headers required) |
Both transports are served by the same application on the same port and expose the same tools — the legacy
one runs as a second in-process MCP server (LegacySseTransportConfig) sharing the primary server's tool
specifications and capabilities. Idle streams are kept alive by protocol-level ping messages every 25 s.
/admin/reindex requires two headers:
X-Reindex-Timestamp: <unix seconds>
X-Reindex-Signature: <hex hmac-sha256 of the timestamp, keyed with MCP_REINDEX_SECRET>
The JobRunr website's GitHub Actions deploy step builds these automatically (see .github/workflows/hugo.yml in that repo).
| Env var | Default | Purpose |
|---|---|---|
PORT |
8080 |
HTTP port |
DOCS_URL |
website docs.json |
Where to fetch the catalog (HTTP or file://) |
DOCS_MANIFEST_URL |
website manifest.json |
Where to fetch the manifest (used to detect changes) |
DOCS_POLL_INTERVAL |
PT15M |
ISO-8601 duration for the scheduled poll |
MCP_REINDEX_SECRET |
(empty) | HMAC secret for /admin/reindex. If empty, the endpoint returns 503. |
TRIAL_WEBHOOK_URL |
n8n webhook | Where request_jobrunr_pro_trial POSTs trial submissions. |
TRIAL_TIMEOUT |
PT10S |
Webhook call timeout. |
MCP_LEGACY_SSE_ENABLED |
true |
Serve the deprecated /sse transport alongside /mcp. Set false to retire it. |
RATELIMIT_ENABLED |
false |
Per-IP rate limiting. See the caveat below before enabling. |
Rate limiting caveat. Under Streamable HTTP every tool call is its own POST, whereas the old SSE transport counted one long-lived GET per session. The existing 10 requests/minute default is therefore far more aggressive than it used to be — retune
RATELIMIT_RPMbefore turningRATELIMIT_ENABLEDon.
These are deliberate shortcuts taken to get the server live for testing. Track these before opening it up.
FlipDone — the server loads fromDOCS_URLback to the canonical website path.https://www.jobrunr.io/mcp/docs.json. The staledata/docs.jsonanddata/manifest.jsoncopies are still in this repo though; they're only used as local dev fixtures now and can be deleted.- Wire up
MCP_REINDEX_SECRET. Generate a long random value and set it on both sides:flyctl secrets set MCP_REINDEX_SECRET=<value> -a jobrunr-docs-mcp- GitHub repo
jobrunr/jobrunr.io(or wherever the website lives) → Settings → Secrets →MCP_REINDEX_SECRET = <same value> - The Hugo deploy workflow will then push fresh docs to the server immediately on each website deploy, instead of waiting up to 15 minutes for the scheduled poll.
DNS forDone — that hostname serves the app and is the canonical URL everywhere in this README. Notemcp.jobrunr.io.jobrunr-docs-mcp.fly.devno longer resolves at all, so any install instructions still pointing there (jobrunr.io website, older blog posts) are broken and need updating.- GitHub Actions auto-deploy. Still outstanding — the
deployjob has failed on every push since May withno access token available, so deploys are manual. Add aFLY_API_TOKENrepo secret (generate at fly.io/user/personal_access_tokens) and.github/workflows/deploy.ymlwill test, deploy and smoke-test each push tomain. - Shrink the Docker image and drop VM size. Current image is ~240 MB because Spring AI's
TransformersEmbeddingModelpulls in DJL + a 150 MB libtorch native lib at first run, which is why the VM is 2 GB instead of the planned 512 MB. Options:- Pre-download the ONNX model + tokenizer into the image at build time (in the
Dockerfile) so cold starts don't fetch them. Saves 30-60 s of startup. Still uses libtorch. - Switch to a direct ONNX-Runtime-only embedding pipeline (skip DJL/PyTorch). Drops the image by ~150 MB and the VM
could likely go back to 512 MB. Means writing a small
EmbeddingModelimplementation backed bycom.microsoft.onnxruntime.
- Pre-download the ONNX model + tokenizer into the image at build time (in the
- Move the Fly app from
personalorg to ajobrunrorg. Currently sits in Nicholas's personal Fly org. Migrate when JobRunr's Fly billing is set up.flyctl apps move jobrunr-docs-mcp --new-org jobrunr. - Lower the WebClient body buffer. Bumped to 16 MB in
DocsLoaderbecause 256 KB default choked on the 330 KBdocs.json. 1-2 MB would be plenty and bounds memory better. Cosmetic. - Pre-build a
VectorIndexsnapshot. Embedding 301 chunks at startup takes ~30 s. Persisting the vectors to disk (volume or baked into the image alongside the model) would make warm restarts near-instant. Only worth doing if cold starts become a UX problem. - Telemetry. No query logging today. Once usage is non-trivial, add a simple
OncePerRequestFilterthat emitstool_name, hashed IP, query string, top-result path → wherever metrics live (Cloudflare Analytics Engine, Loki, ClickHouse, ...). Useful for understanding what devs actually ask before deciding what to improve. - Marketing surface (separate sprint). Per the original plan:
/en/mcp/install page on jobrunr.io with copy-paste configs for every IDE, sidebar CTAs on every documentation page, launch blog post. Deferred until the server has been validated in production for a couple of weeks. - Rate-limit trial submissions if abuse appears. Currently every
request_jobrunr_pro_trialcall hits n8n; dedupe lives on the n8n side. If a misbehaving agent loop spams submissions, add a small in-memoryemail → lastSubmittedAtmap with a 1-hour cooldown inTrialTools, or move rate limiting to Cloudflare oncemcp.jobrunr.ioDNS is wired.
If you want to run this somewhere other than the current Fly app:
- Provision a host that can run a JVM container (Fly.io / Hetzner / DigitalOcean / Cloud Run / anywhere with Docker).
- Build and push the image:
flyctl deployfor Fly, ordocker build -t … . && docker push …for anything else. - Set the env vars from the table above. At minimum:
DOCS_URL,DOCS_MANIFEST_URL. Generate and setMCP_REINDEX_SECRET. - Make sure the host has enough RAM. Currently 2 GB is the safe minimum because of the libtorch native lib (see TODO #5). 512 MB will OOM during startup; 1 GB is borderline.
- Open ports 80/443 (or whatever your TLS proxy expects) and route to container port 8080.
- Point a DNS name at it and add TLS.
- Smoke test:
curl https://<host>/actuator/healththen a full MCP roundtrip viamcp-inspector.
The app is fully stateless and idempotent, so blue/green or rolling deploys work fine. Indexes rebuild from the polled
docs.json within a minute of startup.