# MCP Registry

> Serve a domain proof, server card and AI Catalog, then publish and manage your server's MCP Registry listing with the CLI.

Source: https://frontmcp.dev/reference/deployment/mcp-registry

Clients need to find your server before they can connect. `@FrontMcp({ registry })` serves a domain proof for the MCP Registry, a server card describing your endpoint, and an AI Catalog linking to the card. `frontmcp registry publish` builds and validates the listing from your project, proves its namespace, and publishes it.

```ts
@FrontMcp({ info, apps, registry: { name: "com.example/help-desk", description: "Search support tickets" } })
```

```bash
frontmcp registry keygen
frontmcp registry publish --dry-run
frontmcp registry publish
frontmcp registry status deprecated --server-version 1.0.0 --message "Use 2.x"
```

---

## Reference

### `@FrontMcp({ registry })`

New in 1.10.0. Leave `registry` out to serve none of these documents. It describes the server; the CLI's separate [`frontmcp.config.registry`](https://frontmcp.dev/reference/server/config-files#registry) says where to publish it.

| Option | Type | Default | Description |
| --- | --- | --- | --- |
| `name` | `string` | none | Registry name, like `"com.example/help-desk"`: a reverse-DNS namespace and a server name, separated by `/`. Each part has at least two characters; the whole name has at most 200. Enables the card and catalog. |
| `description` | `string` | none | 1–100 characters. Required when `name` is set. |
| `repository` | `{ url: string, source: "github" \| "gitlab", subfolder?: string }` | none | The source repository, with a path inside a monorepo if needed. |
| `publicKey` | `string` | from the environment | Base64 Ed25519 public key (32 bytes), or a complete `v=MCPv1; k=...; p=...` record. Complete records accept `ed25519` or `ecdsap384` (49 bytes) and are served as given, with surrounding whitespace removed. |
| `publicKeyEnv` | `string` | `"MCP_REGISTRY_PUBLIC_KEY"` | Environment variable read on each proof request when `publicKey` isn't set. |
| `serverCard` | `boolean` | `true` when `name` is set | Serve the server card. `false` also disables the catalog. |
| `aiCatalog` | `boolean` | `true` when the card is served | Serve the catalog linking to the card. |

An invalid option fails validation at startup. An invalid environment key logs a warning once for that variable and serves no proof. Put only the **public** key on the server: the CLI keeps the private key for publishing.

### Documents served

These routes are public, with the server's IP filter and global rate limit still applied. They work on the Node server, serverless HTTP handlers, [`createFetchHandler()`](https://frontmcp.dev/reference/sdk/create-fetch-handler) and [`@frontmcp/edge`](https://frontmcp.dev/reference/deployment/edge).

| Request | Response |
| --- | --- |
| `GET /.well-known/mcp-registry-auth` | `text/plain; charset=utf-8`, `v=MCPv1; k=ed25519; p=<key>` for a bare key. Always at the host's root, regardless of `http.entryPath`. Without a valid key, `404`. |
| `GET <entryPath>/server-card` | `application/mcp-server-card+json`: the SEP-2127 card. `name`, `description` and `repository` come from `registry`; `version`, `title`, `websiteUrl` and `icons` come from `info`. `remotes[0]` names the server's Streamable HTTP URL and supported protocol versions. |
| `OPTIONS <entryPath>/server-card` | `204`. The card's CORS allows any origin (`*`), even without `http.cors`. |
| `GET /.well-known/ai-catalog.json` | `application/ai-catalog+json`, with `specVersion: "1.0"` and an entry linking to the card. `com.example/help-desk` becomes `urn:air:example.com:mcp:help-desk`. |

The card includes only HTTPS `websiteUrl` and icons; icon URLs longer than 255 characters and titles longer than 100 characters are omitted. It has an `ETag`; `If-None-Match` returns `304` when it matches. Set `FRONTMCP_PUBLIC_URL` to the public base URL behind a proxy. With that URL pinned the card sends `Cache-Control: public, max-age=3600`; otherwise it uses the request's origin and sends `no-store`.

#### Caveats

- With `splitByApp`, or when every app is `standalone`, there is no server-level endpoint to describe: only the domain proof is served.
- [`create()`, `createDirect()` and `connect()` in its default mode](https://frontmcp.dev/reference/sdk/create#what-the-server-initializes) serve in process and have no HTTP endpoint for these documents. Configured Registry flows are still registered in their scopes.
- Changed in 1.10.0: before, a custom route could use `<entryPath>/server-card`. It is now reserved for `http.routes`, even with `registry` unset. `/.well-known/*` was already reserved. Move a custom route that uses the card's path.
- The browser build doesn't read the public-key environment variable. Pass `registry.publicKey` when running a fetch handler in a page.

## Usage

### Serving the proof and discovery documents

The tests mount the help desk at `/mcp` for clients and `/mcp/server-card` for its card; the Playground's client uses the default root endpoint. The proof and catalog stay at the host's root in both cases. The public key here is a test fixture; deploy with the public key printed by `frontmcp registry keygen`.

```ts main.ts active
import { App, FrontMcp, Tool, ToolContext } from "@frontmcp/sdk";

@Tool({ name: "count_open_tickets", description: "Count open support tickets", inputSchema: {} })
class CountOpenTickets extends ToolContext {
  async execute() { return { open: 3 }; }
}

@App({ id: "help-desk", name: "Help Desk", tools: [CountOpenTickets] })
export class HelpDeskApp {}

export const publicKey = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
export const config = {
  info: { name: "help-desk", title: "Help Desk", version: "1.0.0", websiteUrl: "https://desk.example.com" },
  apps: [HelpDeskApp],
  http: { cors: { origin: ["https://desk.example.com"] } },
  registry: { name: "com.example/help-desk", description: "Search support tickets", publicKey },
};

@FrontMcp(config)
export default class Server {}
```

```ts registry.test.ts
import { test, expect } from "@frontmcp/testing";
import { FrontMcpInstance } from "@frontmcp/sdk";
import { config as serverConfig, publicKey } from "./main";

const config = { ...serverConfig, http: { ...serverConfig.http, entryPath: "/mcp" } };

test("the proof stays at the host root", async () => {
  const handler = await FrontMcpInstance.createFetchHandler(config);
  const proof = await handler(new Request("https://example.com/.well-known/mcp-registry-auth"));
  expect(proof.status).toBe(200);
  expect(proof.headers.get("content-type")).toContain("text/plain");
  expect(await proof.text()).toBe(`v=MCPv1; k=ed25519; p=${publicKey}`);
  expect((await handler(new Request("https://example.com/mcp/.well-known/mcp-registry-auth"))).status).toBe(404);
});

test("the card describes the endpoint and keeps its own CORS", async () => {
  const handler = await FrontMcpInstance.createFetchHandler(config);
  const card = await handler(new Request("https://example.com/mcp/server-card", { headers: { origin: "https://other.example.com" } }));
  expect(card.headers.get("content-type")).toContain("application/mcp-server-card+json");
  expect(card.headers.get("access-control-allow-origin")).toBe("*");
  expect(card.headers.get("cache-control")).toBe("no-store");
  expect(await card.json()).toMatchObject({
    name: "com.example/help-desk", title: "Help Desk", version: "1.0.0", description: "Search support tickets",
    remotes: [{ type: "streamable-http", url: "https://example.com/mcp", supportedProtocolVersions: expect.arrayContaining(["2026-07-28"]) }],
  });
  const cached = await handler(new Request("https://example.com/mcp/server-card", { headers: { "if-none-match": card.headers.get("etag")! } }));
  expect(cached.status).toBe(304);
  expect(await cached.text()).toBe("");
  const preflight = await handler(new Request("https://example.com/mcp/server-card", { method: "OPTIONS" }));
  expect(preflight.status).toBe(204);
  expect(preflight.headers.get("access-control-allow-origin")).toBe("*");
});

test("the catalog links to the card", async () => {
  const handler = await FrontMcpInstance.createFetchHandler(config);
  const catalog = await handler(new Request("https://example.com/.well-known/ai-catalog.json"));
  expect(catalog.headers.get("content-type")).toContain("application/ai-catalog+json");
  expect(await catalog.json()).toEqual({ specVersion: "1.0", entries: [{
    identifier: "urn:air:example.com:mcp:help-desk", type: "application/mcp-server-card+json", url: "https://example.com/mcp/server-card",
  }] });
});

test("a card needs no key, and disabling it also disables the catalog", async () => {
  const withoutKey = await FrontMcpInstance.createFetchHandler({ ...config, registry: { name: config.registry.name, description: config.registry.description, publicKeyEnv: "DESK_UNUSED_REGISTRY_KEY" } });
  expect((await withoutKey(new Request("https://example.com/.well-known/mcp-registry-auth"))).status).toBe(404);
  expect((await withoutKey(new Request("https://example.com/mcp/server-card"))).status).toBe(200);
  const withoutCard = await FrontMcpInstance.createFetchHandler({ ...config, registry: { ...config.registry, serverCard: false } });
  expect((await withoutCard(new Request("https://example.com/mcp/server-card"))).status).toBe(404);
  expect((await withoutCard(new Request("https://example.com/.well-known/ai-catalog.json"))).status).toBe(404);
  expect((await withoutCard(new Request("https://example.com/.well-known/mcp-registry-auth"))).status).toBe(200);
});

test("split apps keep the proof but have no server card", async () => {
  const handler = await FrontMcpInstance.createFetchHandler({ ...config, splitByApp: true });
  expect((await handler(new Request("https://example.com/.well-known/mcp-registry-auth"))).status).toBe(200);
  expect((await handler(new Request("https://example.com/mcp/help-desk/server-card"))).status).toBe(404);
  expect((await handler(new Request("https://example.com/.well-known/ai-catalog.json"))).status).toBe(404);
});

test("server-card is reserved even without registry configured", async () => {
  await expect(FrontMcpInstance.createFetchHandler({ ...config, registry: undefined, http: { entryPath: "/mcp", routes: [{ method: "GET", path: "/mcp/server-card", handler: (_req, res) => res.json({}) }] } })).rejects.toThrow("collides with the reserved FrontMCP path");
});

test("the catalog can be disabled without disabling the card", async () => {
  const handler = await FrontMcpInstance.createFetchHandler({ ...config, registry: { ...config.registry, aiCatalog: false } });
  expect((await handler(new Request("https://example.com/mcp/server-card"))).status).toBe(200);
  expect((await handler(new Request("https://example.com/.well-known/ai-catalog.json"))).status).toBe(404);
});

test("Node reads the public key per request and caches a pinned card", async () => {
  // Real browser builds don't read these server environment variables.
  if (typeof WorkerGlobalScope !== "undefined") return;
  const previousKey = process.env.DESK_REGISTRY_KEY;
  const previousUrl = process.env.FRONTMCP_PUBLIC_URL;
  delete process.env.DESK_REGISTRY_KEY;
  delete process.env.FRONTMCP_PUBLIC_URL;
  try {
    const handler = await FrontMcpInstance.createFetchHandler({ ...config, registry: { ...config.registry, publicKey: undefined, publicKeyEnv: "DESK_REGISTRY_KEY" } });
    const proofRequest = () => new Request("https://example.com/.well-known/mcp-registry-auth");
    expect((await handler(proofRequest())).status).toBe(404);
    process.env.DESK_REGISTRY_KEY = publicKey;
    expect(await (await handler(proofRequest())).text()).toBe(`v=MCPv1; k=ed25519; p=${publicKey}`);
    process.env.DESK_REGISTRY_KEY = "invalid";
    expect((await handler(proofRequest())).status).toBe(404);
    process.env.FRONTMCP_PUBLIC_URL = "https://public.example.com";
    const card = await handler(new Request("https://internal.example.com/mcp/server-card"));
    expect(card.headers.get("cache-control")).toBe("public, max-age=3600");
    expect((await card.json()).remotes[0].url).toBe("https://public.example.com/mcp");
  } finally {
    if (previousKey === undefined) delete process.env.DESK_REGISTRY_KEY;
    else process.env.DESK_REGISTRY_KEY = previousKey;
    if (previousUrl === undefined) delete process.env.FRONTMCP_PUBLIC_URL;
    else process.env.FRONTMCP_PUBLIC_URL = previousUrl;
  }
});
```

### Publishing a listing

> **Note**
The CLI writes a private-key file and calls the remote Registry, so publishing can't run in a Playground. The example above checks the documents it publishes from without opening a port.

1. Run `frontmcp registry keygen`. It writes a 64-character Ed25519 hex seed to `~/.frontmcp/keys/mcp-registry.key`, with file mode `0600`, and prints the public key and DNS TXT record. `--out <file>` changes the path; `--force` replaces an existing key.
2. Set `MCP_REGISTRY_PUBLIC_KEY` on your deployed server to the printed public key, and set `FRONTMCP_PUBLIC_URL=https://example.com`. Configure `registry.name` and `description` as above, and redeploy.
3. Check `https://example.com/.well-known/mcp-registry-auth`. It must return `200` with the record, without a redirect, on port 443; the Registry reads at most 4096 bytes.
4. Configure the deployed endpoint in `frontmcp.config.ts`, then preview and publish:

```ts frontmcp.config.ts
import { defineConfig } from "frontmcp";

export default defineConfig({
  name: "help-desk",
  entry: "./src/main.ts",
  registry: { url: "https://example.com/mcp" },
});
```

```bash
frontmcp registry publish --dry-run > server.json
frontmcp registry publish --file server.json
```

`publish` builds `server.json` from `@FrontMcp({ info, registry })` and `package.json`. Description, version and repository fall back to `package.json`; the decorator requires a description when `registry.name` is set. `--url` overrides `registry.url`. Set `registry.packages: ["npm"]` to also list the package's name and version with a stdio transport. An npm listing needs `"mcpName": "com.example/help-desk"` in the published package's `package.json`; publish checks the local one first.

Both local checks and `POST /v0.1/validate` run, including on a dry run. `--dry-run` writes only the listing to stdout and diagnostics to stderr; it skips login and publishing. `--file` publishes that JSON as supplied, with validation. Each `name@version` can be published once, even after deletion: use a new version for the next release.

### Choosing how to prove the namespace

`publish` and `status` share these authentication methods:

| `--auth` | Proof | Namespace granted |
| --- | --- | --- |
| `http` (default) | The record at `https://<domain>/.well-known/mcp-registry-auth` | The exact host: `example.com` grants `com.example/*`. |
| `dns` | The same record in a TXT record at the domain apex | That domain and its subdomains: `com.example/*` and `com.example.*/*`. |
| `github-oidc` | A GitHub Actions ID token; no key or domain proof | `io.github.<owner>/*`. Give the job `permissions: { id-token: write, contents: read }`. |

`--domain` defaults to the namespace reversed: `com.example.mcp/help-desk` uses `mcp.example.com`. A `github.io` domain needs GitHub OIDC. HTTP and DNS login read the private key from `--key-file`, then `MCP_REGISTRY_PRIVATE_KEY`, then keygen's default file; both a hex seed and an Ed25519 PEM work. In CI, put the file's content in a secret and expose it as `MCP_REGISTRY_PRIVATE_KEY`. Keys and login tokens aren't printed.

`--registry` selects a different Registry, overriding `frontmcp.config.registry.registryUrl`. It defaults to `https://registry.modelcontextprotocol.io`; use `https://staging.registry.modelcontextprotocol.io` to try publishing. `--entry` selects the entry file instead of the config's entry or auto-detection. [The CLI reference](https://frontmcp.dev/reference/cli#frontmcp-registry) lists every flag.

### Changing a listing's status

```bash
frontmcp registry status deprecated --server-version 1.0.0 --message "Use 2.x"
frontmcp registry status deleted --server-version 1.0.0
frontmcp registry status active --server-version 2.0.0
frontmcp registry status deprecated --all-versions --message "Moved to com.example/support"
```

Pass exactly one of `--server-version` and `--all-versions`; `--version` prints the CLI's version. `--message` is at most 500 characters and goes only with `deprecated` or `deleted`. `--name` supplies the registry name when you aren't reading it from a project.

## Troubleshooting

### The proof answers `404`

Set `registry` and a valid public key, in `publicKey` or its environment variable. The proof needs no `registry.name`. An invalid environment value logs `MCP Registry domain proof not served: …`; an invalid decorator key fails validation. Check the host's root path, even when MCP uses `/mcp`.

### The card answers `404`

Set `registry.name` and `description`, and leave `serverCard` enabled. The card describes only the server-level endpoint: `splitByApp` and servers with only standalone apps have none. Disabling the card disables the catalog too.

### The card names an internal host or `http://`

Set `FRONTMCP_PUBLIC_URL` to the public base URL behind the proxy. Without it the card uses the request's origin and stays uncached. The setting doesn't apply in a browser page.

### `registry.description is required when registry.name is set`

Add a 1–100 character description to the `registry` option, alongside `name`.

### `registry.publicKey must be a base64 Ed25519 public key or a "v=MCPv1; k=...; p=..." record`

Use the public key keygen prints, not the private hex seed. A bare Ed25519 key is padded base64 encoding 32 bytes. Complete records must use a supported algorithm and the matching key length.

### `registry.name must be "<namespace>/<name>" in reverse-DNS form, e.g. "com.acme/weather"`

Use a namespace and server name separated by `/`, with at least two characters in each. The namespace accepts letters, digits, dots and hyphens; the server name also accepts underscores. Each starts and ends with a letter or digit.

### `registry.publicKeyEnv must be an environment variable name`

Use letters, digits and underscores, starting with a letter or underscore. The default is `MCP_REGISTRY_PUBLIC_KEY`.

### `The HTTP proof is not ready: …`

The CLI fetches the proof before login. Serve it on the exact HTTPS host the namespace names, without redirects, and with the public key matching the private key used to log in. See [publishing errors](https://frontmcp.dev/reference/cli#the-http-proof-is-not-ready-).
