Add OpenMCP: an open catalog of MCP relays (#153)

A relay serves /.well-known/openmcp.json; a catalog probes it (the
descriptor from the relay's own origin, then initialize and tools/list)
and lists only what it found; a client reaches every relay through the
catalog's REST, its own MCP endpoint, or signed webhooks. Landing page at
/openmcp, the document at /docs/openmcp, registered in the same four
places as the other specs. Reference implementation at
github.com/logicsrc/openmcp.


Claude-Session: https://claude.ai/code/session_01FMT2v1YxmgcDuionrfT719

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Anthony Ettinger 2026-09-12 09:55:14 -07:00 • committed by GitHub
parent 1a2d143a23
commit 30facd71ff
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 393 additions and 0 deletions

View file

@ -19,6 +19,7 @@ export function GET(): Response {
- [ASDLC](${SITE_URL}/asdlc): The Agentic Software Development Lifecycle: nine phases for building software when agents work in parallel and CI/CD is the only gate, with conformance levels and the ratchet rule.
- [OpenProfile.md](${SITE_URL}/openprofile): One Markdown file that says who you are and where you are, for people and agents alike: identity block, accounts, topics, reshare terms and operator, discovered at /.well-known/openprofile.md or through rel="openprofile".
- [OpenMCP](${SITE_URL}/openmcp): An open catalog of MCP relays: a relay serves /.well-known/openmcp.json, a catalog probes it and lists only what it found, and clients reach every relay through the catalog's REST, its own MCP endpoint, or signed webhooks.
- [AgentSwarm](${SITE_URL}/agent-swarm): Provider-neutral agent orchestration, model routing, and cost controls.
- [AgentByte](${SITE_URL}/agentbyte): Agent screening sessions, policy events, and APIs.
- [Credential Sharing](${SITE_URL}/credential-sharing): End-to-end-encrypted team vaults, plus source/target credential diffs, approval, sync, rollback, and audit.

View file

@ -0,0 +1,219 @@
import Link from "next/link";
import type { ReactNode } from "react";
import type { Metadata } from "next";
import { SiteShell } from "@/components/site-shell";
import { mono, pre, table, td, th } from "../openontology/ui";
export const metadata: Metadata = {
title: "OpenMCP · LogicSRC",
description:
"OpenMCP is an open catalog protocol for MCP relays: a relay serves /.well-known/openmcp.json, a catalog probes it and lists only what it found, and a client reaches every relay through the catalog's REST, its own MCP endpoint, or signed webhooks. Reference implementation at github.com/logicsrc/openmcp.",
alternates: { canonical: "/openmcp" }
};
const DESCRIPTOR = `{
"openmcp": "0.1",
"mcp": "https://agenticjobs.work/api/mcp",
"name": "Agentic Jobs",
"description": "A job board where agents apply to agents.",
"auth": { "kind": "bearer", "open": ["search_jobs", "get_job"] },
"tags": ["jobs", "hiring", "agents"],
"operator": "https://profullstack.com/.well-known/openprofile.md",
"tools": ["search_jobs", "get_job", "apply_to_job", "post_update"]
}`;
const CLI = `npx @logicsrc/openmcp serve --url https://catalog.example # run a catalog
openmcp add https://agenticjobs.work # register by any URL on the relay
openmcp find "post an update" # search every relay's tools
openmcp call agenticjobs.work post_update '{"body":"Shipped."}' --relay-token <t>
openmcp webhook add https://me.example/hooks --events relay.offline,relay.online
openmcp --transport mcp relays # the same, over MCP`;
const PROBE: Array<[string, string, string]> = [
["1. The descriptor", "GET /.well-known/openmcp.json on the relay's origin", "Parses: the record is verified. The descriptor is the relay's own word, not the registrant's."],
["2. The handshake", "initialize, then tools/list at the MCP endpoint", "Answers: the record is online and the tools are what the relay reported, schemas included."],
["Neither", "", "Not listed. A catalog never lists a relay it could neither verify nor reach."]
];
const DOORS: Array<[string, string, string, string]> = [
["List relays", "GET /v1/relays?q=&tag=&online=1", "list_relays", ""],
["One relay, its tools", "GET /v1/relays/:id", "get_relay", ""],
["Register, refresh", "POST /v1/relays {url}", "register_relay", "relay.registered, relay.updated"],
["Find a tool anywhere", "GET /v1/tools?q=", "find_tool", ""],
["Call a relay's tool", "POST /v1/relays/:id/call", "call_tool", ""],
["Up, down", "POST /v1/relays/:id/refresh", "refresh_relay", "relay.online, relay.offline"],
["Subscribe", "POST /v1/webhooks", "subscribe", "a signed POST per event"],
["Peers", "GET/POST /v1/peers, /sync", "list_peers", ""]
];
const ABSENT: Array<[string, string]> = [
["No registry of names", "A relay's id is derived from its endpoint. Nobody owns a name; two catalogs derive the same id."],
["No trust score", "verified and online are facts a catalog checked. Whether a relay is good is the reader's judgement, with the operator's profile as the place to start."],
["No credentials in the catalog", "Not for probing, not for forwarding. A caller's credential for a relay travels with the call and is never kept."],
["No new transport", "Relays are Streamable HTTP MCP, as MCP defines it. The catalog adds discovery, not protocol."]
];
export default function OpenMcpPage(): ReactNode {
return (
<SiteShell active="OpenMCP">
<div className="band">
<div className="section-head">
<p className="eyebrow">LogicSRC standards surface</p>
<h2>OpenMCP</h2>
<p>
An open catalog of MCP relays. A relay says what it is in one file, a catalog lists
only what it has reached, and a client gets one door to all of them.
</p>
</div>
<p style={{ color: "#41505d" }}>
Every product that speaks MCP is a relay: an endpoint an agent can call. There are
thousands, each found by hand from a README and wired into a client&apos;s configuration
by a person. Nothing says what a relay is, nothing says whether it answered yesterday, and
nothing lets an agent reach a relay it was not told about. OpenMCP puts the pieces that
already exist, the MCP handshake, <code style={mono}>/.well-known/</code> and webhooks,
together so a relay can be discovered instead of configured.
</p>
<p style={{ color: "#5b6b7a" }}>
Status: 0.1. Reference implementation at{" "}
<a href="https://github.com/logicsrc/openmcp">github.com/logicsrc/openmcp</a>: a catalog
server on Node 24 with one SQLite file, and a client and CLI that speak REST, MCP and
webhooks.
</p>
</div>
<div className="band">
<div className="section-head">
<h2>The descriptor</h2>
<p>
Served at <code style={mono}>/.well-known/openmcp.json</code>. Only{" "}
<code style={mono}>mcp</code> is required.
</p>
</div>
<pre style={pre}>{DESCRIPTOR}</pre>
<p style={{ color: "#41505d" }}>
<code style={mono}>auth</code> says how a caller authenticates and which tools are open
without a credential. <code style={mono}>operator</code> is the person answerable, as an{" "}
<Link href="/openprofile">OpenProfile.md</Link>. <code style={mono}>tools</code> names them
so a catalog can index a relay it cannot reach; what <code style={mono}>tools/list</code>{" "}
says wins whenever both exist. The descriptor is a claim; the probe is the verification.
</p>
</div>
<div className="band">
<div className="section-head">
<h2>The probe</h2>
<p>Two questions, in order, of any URL a catalog is handed.</p>
</div>
<table style={table}>
<thead>
<tr>
<th style={th}>Question</th>
<th style={th}>How</th>
<th style={th}>Then</th>
</tr>
</thead>
<tbody>
{PROBE.map(([question, how, then]) => (
<tr key={question}>
<td style={td}>
<strong>{question}</strong>
</td>
<td style={td}>{how ? <code style={mono}>{how}</code> : ""}</td>
<td style={td}>{then}</td>
</tr>
))}
</tbody>
</table>
<p style={{ color: "#41505d", marginTop: "1rem" }}>
The probe is unauthenticated and repeats on a schedule. A change in online, descriptor or
tools is an event. Registration is open, because nothing a registrant types is listed:
the probe is.
</p>
</div>
<div className="band">
<div className="section-head">
<h2>Three doors to the same records</h2>
<p>A catalog is itself a relay, and an agent that reaches one reaches everything in it.</p>
</div>
<table style={table}>
<thead>
<tr>
<th style={th}></th>
<th style={th}>REST</th>
<th style={th}>MCP tool</th>
<th style={th}>Webhook</th>
</tr>
</thead>
<tbody>
{DOORS.map(([what, rest, tool, hook]) => (
<tr key={what}>
<td style={td}>
<strong>{what}</strong>
</td>
<td style={td}>
<code style={mono}>{rest}</code>
</td>
<td style={td}>
<code style={mono}>{tool}</code>
</td>
<td style={td}>{hook}</td>
</tr>
))}
</tbody>
</table>
<p style={{ color: "#41505d", marginTop: "1rem" }}>
<code style={mono}>call_tool</code> forwards a call to a relay with the caller&apos;s own
credential, never kept. Every webhook delivery carries{" "}
<code style={mono}>X-OpenMCP-Signature: sha256=&lt;HMAC of the raw body&gt;</code>. Catalogs
peer, and a relay learned from a peer is still probed here before it is listed.
</p>
</div>
<div className="band">
<div className="section-head">
<h2>From a terminal</h2>
</div>
<pre style={pre}>{CLI}</pre>
</div>
<div className="band">
<div className="section-head">
<h2>What is deliberately absent</h2>
</div>
<table style={table}>
<tbody>
{ABSENT.map(([what, why]) => (
<tr key={what}>
<td style={td}>
<strong>{what}</strong>
</td>
<td style={td}>{why}</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="band">
<div className="section-head">
<h2>Where everything lives</h2>
</div>
<ul style={{ color: "#41505d", lineHeight: 1.9, paddingLeft: "1.1rem" }}>
<li>
<Link href="/docs/openmcp">Specification</Link>: the descriptor, the probe, the record,
the three doors, webhooks, peering
</li>
<li>
<a href="https://github.com/logicsrc/openmcp">github.com/logicsrc/openmcp</a>: the
reference catalog and client, <code style={mono}>npx @logicsrc/openmcp</code>
</li>
<li>
<Link href="/openprofile">OpenProfile.md</Link>, the operator behind a relay;{" "}
<Link href="/opencreds">OpenCreds</Link>, where a caller&apos;s credential is kept
</li>
</ul>
</div>
</SiteShell>
);
}

View file

@ -23,6 +23,7 @@ const STATIC_ROUTES: Array<{
{ path: "/openprd", changeFrequency: "weekly", priority: 0.9 },
{ path: "/asdlc", changeFrequency: "weekly", priority: 0.9 },
{ path: "/openprofile", changeFrequency: "weekly", priority: 0.9 },
{ path: "/openmcp", changeFrequency: "weekly", priority: 0.9 },
{ path: "/openontology/explore", changeFrequency: "daily", priority: 0.7 },
{ path: "/openspec", changeFrequency: "weekly", priority: 0.8 },
{ path: "/agent-swarm", changeFrequency: "weekly", priority: 0.8 },

View file

@ -15,6 +15,7 @@ const NAV: Array<{ href: string; label: string; external?: boolean }> = [
{ href: "/openprd", label: "OpenPRD" },
{ href: "/asdlc", label: "ASDLC" },
{ href: "/openprofile", label: "OpenProfile" },
{ href: "/openmcp", label: "OpenMCP" },
{ href: "/#cli", label: "CLI" },
{ href: "/docs", label: "Docs" },
{ href: "/blog", label: "Blog" },

View file

@ -18,6 +18,7 @@ export const DOC_SLUGS = [
"openjob",
"openresume",
"openprofile",
"openmcp",
"openstream",
"openspec-comparison",
"data-model",

170
docs/openmcp.md Normal file
View file

@ -0,0 +1,170 @@
# OpenMCP
OpenMCP is an open catalog protocol for MCP relays: MCP servers you can reach over HTTP. It says how a relay describes itself in one file, how a catalog lists relays it has actually reached, how a client finds a tool and calls it through a catalog or direct, and how a catalog tells subscribers what changed. It is maintained by Profullstack, Inc. as part of the LogicSRC open-standards surface, with a reference implementation at [github.com/logicsrc/openmcp](https://github.com/logicsrc/openmcp).
Status: **0.1**. A description of a catalog already running, published so others can run one, list in one, or be listed.
Slug: `openmcp`
## The problem
Every product that speaks MCP is a relay: an endpoint an agent can call. There are thousands, and each one is found by hand, from a README, and wired into a client's configuration by a person. There is no file a relay serves to say what it is, no way for a directory to say "this one answered yesterday", and no single door an agent can open to reach relays it has not been told about.
The pieces exist. MCP defines the handshake and `tools/list`. `/.well-known/` is where a host says things about itself. Webhooks are how a service tells another service something happened. What is missing is the one convention that puts them together for MCP, so a relay can be discovered instead of configured.
## Terms
- A **relay** is an MCP server reachable over HTTP (Streamable HTTP transport). Its **descriptor** is the file it serves about itself.
- A **catalog** is a server that keeps relay records, probes each relay, and serves the records for discovery. A catalog is also a relay.
- A **record** is a relay as a catalog holds it: the descriptor, the tools the relay reported, whether it is online, whether it is verified, and when it was last reached.
- A **client** is anything that reads a catalog: a person's terminal, a program, an agent.
## The descriptor
A relay serves a JSON document at `/.well-known/openmcp.json` on its own origin.
```json
{
"openmcp": "0.1",
"mcp": "https://agenticjobs.work/api/mcp",
"name": "Agentic Jobs",
"description": "A job board where agents apply to agents, with a person at both ends.",
"url": "https://agenticjobs.work",
"auth": { "kind": "bearer", "url": "https://agenticjobs.work/me/tokens", "open": ["search_jobs", "get_job"] },
"tags": ["jobs", "hiring", "agents"],
"operator": "https://profullstack.com/.well-known/openprofile.md",
"webhooks": "https://agenticjobs.work/api/v1/webhooks",
"tools": ["search_jobs", "get_job", "apply_to_job", "post_update"],
"catalogs": ["https://openmcp.logicsrc.com"]
}
```
The rules, and every one degrades:
1. **`mcp` is required and is the only required key.** Absolute, or relative to the descriptor's own URL. A descriptor with `mcp` alone is valid.
2. **`openmcp`** is the version of this document the descriptor follows. Absent means the current one.
3. **`name`, `description`, `url`** are for a listing: one line, a paragraph, the site behind the relay.
4. **`auth.kind`** is `none`, `bearer`, `oauth` or `api-key`. `auth.url` is where a person gets a credential. `auth.open` names the tools that work with no credential. Absent `auth` means unstated, which a reader reports rather than assumes.
5. **`tags`** are free-form and lowercase. A catalog groups by them.
6. **`operator`** is the person or organisation answerable for the relay, as an [OpenProfile.md](/openprofile) URL. An agent that meets a relay with no operator should say so.
7. **`webhooks`** is where a caller subscribes to the relay's own events, if it has any. Its shape is the relay's business; this document only defines the catalog's webhooks.
8. **`tools`** names the tools, so a catalog can index a relay it cannot reach right now. What `tools/list` says wins over this list whenever both exist.
9. **`catalogs`** names catalogs the relay is listed in, so a reader that found the relay can find more.
Unknown keys are kept. Serve it as `application/json`. The descriptor is a claim; the probe is the verification.
## The probe
A catalog builds a record by asking two questions, in order, of any URL it is handed (the descriptor, the MCP endpoint, or just the site):
1. **Does the origin serve a descriptor?** Fetch `/.well-known/openmcp.json` on the origin of the given URL. If it parses, the record is **verified**: the descriptor came from the relay itself, not from whoever registered it. If not, the given URL is taken as the MCP endpoint and the record is unverified.
2. **Does the MCP endpoint answer?** `initialize`, then `tools/list`. If both succeed, the record is **online** and its tools are what the relay reported, with their schemas. If not, the record is **offline**, its last known tools are kept and marked so, and its failure count goes up.
A catalog lists a relay only if at least one question was answered yes. It never lists a relay it could neither verify nor reach. It probes again on a schedule, and a change in online, descriptor or tools is an event.
A catalog holds no credentials for any relay. The probe is unauthenticated, so a relay whose handshake needs a credential will be listed as offline with the tools its descriptor names; the descriptor is the way such a relay is still findable.
## The record
```json
{
"id": "agenticjobs.work",
"source": "https://agenticjobs.work/.well-known/openmcp.json",
"descriptor": { "...": "as served" },
"tools": [{ "name": "search_jobs", "description": "...", "inputSchema": { "...": "..." } }],
"server": { "name": "agenticjobs", "version": "0.15.0", "protocolVersion": "2025-06-18" },
"verified": true,
"online": true,
"seenAt": "2026-09-12T18:04:11.000Z",
"firstSeenAt": "2026-09-12T18:04:11.000Z",
"failures": 0,
"lastError": null,
"via": null
}
```
`id` is a slug of the MCP endpoint's host and path with the endpoint's own name (`/mcp`, `/api/mcp`, `/v1/mcp`) dropped, stable across probes. `via` names another catalog the record was learned from, or is null when it was registered here.
## The catalog
A catalog serves the same descriptor as any relay, at its own `/.well-known/openmcp.json`, with one more key:
```json
{ "catalog": { "relays": 128, "online": 117, "api": "https://openmcp.logicsrc.com/v1", "peers": ["https://other.catalog"] } }
```
It exposes three doors to the same records.
**REST**, under `api`:
| | |
|---|---|
| `GET /v1/relays?q=&tag=&online=1&limit=` | the records, filtered |
| `POST /v1/relays {url}` | register by any URL on the relay's origin; the probe decides what is listed |
| `GET /v1/relays/:id`, `GET /v1/relays/:id/tools` | one record, its tools |
| `POST /v1/relays/:id/refresh` | probe now |
| `DELETE /v1/relays/:id` | admin |
| `GET /v1/tools?q=` | every online relay's tools that match, with where each lives |
| `POST /v1/relays/:id/call {tool, arguments, token?}` | forward one call; the relay's result comes back whole |
| `POST /v1/webhooks`, `GET /v1/webhooks/:id`, `DELETE /v1/webhooks/:id` | subscriptions |
| `GET /v1/peers`, `POST /v1/peers`, `DELETE /v1/peers`, `POST /v1/peers/sync` | peering (admin) |
**MCP**, at the catalog's own `mcp` endpoint, with these tools: `list_relays`, `get_relay`, `find_tool`, `call_tool`, `register_relay`, `refresh_relay`, `subscribe`, `unsubscribe`, `list_peers`. `call_tool` forwards a call to a relay; the caller's credential for that relay travels in the arguments as `token` and is never kept. An agent that can reach one catalog can reach every relay in it.
**Webhooks**, out. A subscription is a URL, a secret, a list of events and optionally a list of relay ids. The events:
| event | when |
|---|---|
| `relay.registered` | a relay was listed for the first time |
| `relay.updated` | its descriptor or tools changed |
| `relay.online` | it answered after not answering |
| `relay.offline` | it stopped answering |
| `relay.removed` | it was removed |
A delivery is one `POST` of `{id, event, at, catalog, relay}` with headers `X-OpenMCP-Event`, `X-OpenMCP-Delivery` and `X-OpenMCP-Signature: sha256=<hex HMAC-SHA256 of the raw body under the secret>`. A receiver verifies the signature over the raw body before reading it. Three attempts, then the failure is recorded; fifty consecutive failures and the subscription is inactive but still listed, so its owner can see why. The subscription id is unguessable and is the only handle on it; the secret is shown once.
Registration is open. Anyone may register any relay, because nothing a registrant types is listed: the probe is. Removing a relay and changing peers need the catalog's admin credential.
## Peering
A catalog may name other catalogs as peers and learn their relays. A relay learned from a peer is still probed here before it is listed, is marked `via` the peer, and is never allowed to overwrite a record that was registered here directly. Catalogs of catalogs, with no catalog required to trust another.
## The client
A conforming client:
1. Reads a catalog over REST or over its MCP endpoint; the two answer the same questions.
2. Treats `online` and `verified` as two facts and shows both. A verified offline relay is real and down; an online unverified relay answers but has not said who it is.
3. Calls a relay's tool through the catalog with `call`, or direct with a plain MCP client once it has the record, and passes its own credential for the relay either way. The catalog never has it.
4. Verifies every webhook delivery's signature over the raw body before acting on it.
5. Reports absence as absence: an unstated `auth`, an unstated `operator`.
## What is deliberately absent
**No registry of names.** A relay's id is derived from its endpoint; nobody owns a name, and two catalogs derive the same id for the same relay.
**No trust score.** `verified` and `online` are facts a catalog checked. Whether a relay is good is the reader's judgement, with the operator's profile as the place to start.
**No credentials in the catalog.** Not for probing, not for forwarding. A relay that needs a credential to be listed serves a descriptor.
**No new transport.** Relays are Streamable HTTP MCP, as MCP defines it. The catalog adds discovery, not protocol.
## Serving one
By hand, or `openmcp descriptor <mcp url>` prints a template. [agenticjobs](https://agenticjobs.work), [tsbb](https://tsbb.dev) and [myna](https://mynaposter.com) serve one. The reference catalog runs on Node 24 with one SQLite file: `npx @logicsrc/openmcp serve`.
## Related standards
- [OpenProfile.md](/openprofile): the `operator` behind a relay.
- [OpenCreds](/opencreds): where the credential a client passes to a relay is kept.
- [Model Context Protocol](https://modelcontextprotocol.io): what a relay speaks.
## Version history
| Version | Date | Change |
|---|---|---|
| 0.1 | 2026-09-12 | First publication: the descriptor, the probe, the record, the three doors, webhooks, peering. |
## License
The specification text is CC BY 4.0. The reference implementation is MIT.