Skip to main content

How to List an MCP Server in Directories

Short answer

Directories that read the official MCP Registry build their listings from the metadata you publish, so make that metadata accurate first. Give the npm package an mcpName that matches your server.json name, write a description under 100 characters, mark each tool's behavior with annotations, and publish a README that explains what the server touches. Then publish the package to npm and the metadata to the registry. Directories pick up new data on their own schedule, and a published version can't be edited, so check everything before you publish.

Tested withMCP spec 2026-07-28MCP Registry server.json schema 2025-12-11TypeScript SDK (@modelcontextprotocol/server) 2.3.1MCP Inspector 2.11.0Node.js 24.19Checked 20 Oct 2026

What you'll have at the end​

A notes server with three tools, a server.json and a listing check that fails if the metadata is wrong. This is the output of the check and of the tool metadata test, trimmed:

listing checks passed (3 tools documented)
== tools/list
ok: tools/list with annotations (modern)
ok: tools/list with annotations (legacy)
== tool calls
...
ok: add, list and delete
All checks passed

The tools/list response for each tool now carries its annotations. This is delete_note from the modern run:

{
"name": "delete_note",
"title": "Delete note",
"description": "Permanently delete a note by its id. Call list_notes first if you don't know the id.",
"inputSchema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"id": {
"type": "integer",
"exclusiveMinimum": 0,
"maximum": 9007199254740991,
"description": "Id of the note, as shown by list_notes"
}
},
"required": [
"id"
]
},
"annotations": {
"readOnlyHint": false,
"destructiveHint": true,
"idempotentHint": true,
"openWorldHint": false
}
}

The metadata flows from your package to the registry and then to the directories that read it:

The registry is a metadata store, not a directory. The registry's own docs say host apps should read downstream marketplaces rather than the registry directly.

Before you start

  • Node.js 22.19 or later, which MCP Inspector needs. The example was tested on Node.js 24.19.
  • npm account to publish the package, and GitHub account to sign in to the MCP Registry.
  • An HTTPS URL for your icon, and a repository on GitHub.
  • A working server. This guide uses the notes server in docs/guides-examples/list-in-mcp-directories/. If you're starting from nothing, build an MCP server first.

1. Make the package name and version match​

The registry checks the npm package against your server name, so the package has to point back at it. The mcpName field is that link. The registry checks it when you publish, and it must equal the name in server.json.

package.json
{
"name": "notes-mcp",
"version": "1.0.0",
"description": "Save, list and delete short text notes over MCP.",
"mcpName": "io.github.example/notes",
"license": "MIT",
"type": "module",
"repository": {
"type": "git",
"url": "git+https://github.com/example/notes-mcp.git"
},
"bin": {
"notes-mcp": "./build/index.js"
},
"scripts": {
"build": "tsc && chmod 755 build/index.js",
"check-listing": "node check-listing.mjs"
},
"files": ["build"],
"dependencies": {
"@modelcontextprotocol/server": "^2.3.1",
"zod": "^4.6.7"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^7.0.2"
}
}

Three details matter here:

  • mcpName is io.github.<your-github-username>/<server-name>. The example in the file is a placeholder for your own GitHub username.
  • version must match the base version in server.json, the part before any prerelease suffix. Bump both together for every release.
  • npm always includes your README in the package, along with package.json and the license file. The files field doesn't change that. The repository field points npm at your source. The registry reads its own repository field from server.json.

2. Describe the server and its tools​

The server's own metadata and each tool's annotations are what an AI app sees when it connects. Put real effort into both.

src/index.ts
#!/usr/bin/env node
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { McpServer } from "@modelcontextprotocol/server";
import { serveStdio } from "@modelcontextprotocol/server/stdio";
import { z } from "zod";

const NOTES_FILE = process.env.NOTES_FILE ?? join(homedir(), ".notes-mcp.json");

const ICON_SVG =
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><rect width="48" height="48" rx="8" fill="#2563eb"/><path d="M14 12h20v26l-10-6-10 6z" fill="#ffffff"/></svg>';
const ICON_DATA_URI = `data:image/svg+xml;base64,${Buffer.from(ICON_SVG).toString("base64")}`;

interface Note {
id: number;
text: string;
}

function loadNotes(): Note[] {
if (!existsSync(NOTES_FILE)) return [];
return JSON.parse(readFileSync(NOTES_FILE, "utf8")) as Note[];
}

function saveNotes(notes: Note[]): void {
writeFileSync(NOTES_FILE, JSON.stringify(notes, null, 2));
}

function text(value: string) {
return { content: [{ type: "text" as const, text: value }] };
}

// serveStdio calls this whenever it needs a server instance,
// so it must return a new McpServer every time.
function createServer() {
const server = new McpServer({
name: "notes",
version: "1.0.0",
title: "Notes",
description: "Save, list and delete short text notes in a local file.",
websiteUrl: "https://github.com/example/notes-mcp",
icons: [{ src: ICON_DATA_URI, mimeType: "image/svg+xml", sizes: ["any"] }],
});

server.registerTool(
"list_notes",
{
title: "List notes",
description:
"List all saved notes with their ids, oldest first. Use this when the user asks what they have saved.",
annotations: {
readOnlyHint: true,
idempotentHint: true,
openWorldHint: false,
},
},
async () => {
const notes = loadNotes();
if (notes.length === 0) return text("No notes saved yet.");
return text(notes.map((n) => `${n.id}: ${n.text}`).join("\n"));
},
);

server.registerTool(
"add_note",
{
title: "Add note",
description:
"Save a new note. Use this when the user asks you to remember or write something down.",
inputSchema: z.object({
note: z.string().min(1).max(2000).describe("The note to save, as plain text"),
}),
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
openWorldHint: false,
},
},
async ({ note }) => {
const notes = loadNotes();
const id = notes.reduce((max, n) => Math.max(max, n.id), 0) + 1;
notes.push({ id, text: note });
saveNotes(notes);
console.error(`Saved note ${id}`);
return text(`Saved note ${id}.`);
},
);

server.registerTool(
"delete_note",
{
title: "Delete note",
description:
"Permanently delete a note by its id. Call list_notes first if you don't know the id.",
inputSchema: z.object({
id: z.coerce.number().int().positive().describe("Id of the note, as shown by list_notes"),
}),
annotations: {
readOnlyHint: false,
destructiveHint: true,
idempotentHint: true,
openWorldHint: false,
},
},
async ({ id }) => {
const notes = loadNotes();
const remaining = notes.filter((n) => n.id !== id);
if (remaining.length === notes.length) return text(`No note with id ${id}.`);
saveNotes(remaining);
console.error(`Deleted note ${id}`);
return text(`Deleted note ${id}.`);
},
);

return server;
}

// Logs go to stderr. Anything written to stdout corrupts the stdio transport.
serveStdio(createServer, {
onerror: (error) => console.error("Notes server error:", error),
});
console.error("Notes MCP server running on stdio");

The server-level fields are title, description, websiteUrl and icons, passed to new McpServer(...). The icons entry uses a data URI, which the 2026-07-28 spec allows for icons. The spec says icon sources can be HTTPS URLs or data: URIs, and clients that render icons must support image/png and image/jpeg.

Each tool gets a title, a description the model reads to decide when to call it, and annotations. Annotations are hints about what a tool does to the outside world:

HintDefault in the specSet it when
readOnlyHintfalseThe tool only reads. list_notes sets it to true.
destructiveHinttrueThe tool can destroy data. Only meaningful when readOnlyHint is false. delete_note sets it to true.
idempotentHintfalseRepeating the same call changes nothing more. delete_note sets it to true, because deleting a missing note does nothing.
openWorldHinttrueThe tool reaches an open set of outside entities, such as the web. The notes tools are closed, so they set it to false.

The default for openWorldHint is true, so set it explicitly whenever your tool works only with local data.

Annotations are hints, not a security boundary. The spec says clients must treat them as untrusted unless they come from a trusted server. Keep the descriptions honest, because that's the text people and models read.

The spec recommends tool names of 1 to 128 characters that use only letters, digits, _, - and .. The names here follow that.

3. Write a README that says what the server touches​

The registry has no README field. The README you ship is the long-form description, and it's where a reader checks what the server does with their machine. The example README lists every tool, says where data is stored and says what isn't done.

README.md
# notes-mcp

An MCP server that saves, lists and deletes short text notes. It stores them in one JSON file on your computer.

## Tools

| Tool | What it does | Changes data |
|---|---|---|
| `list_notes` | Lists every saved note with its id | No |
| `add_note` | Saves a new note | Yes, adds a note |
| `delete_note` | Deletes a note by id | Yes, removes a note permanently |

## Install

```bash
npx -y notes-mcp
```

The server runs over stdio. An MCP client starts it as a child process; you don't run it by hand.

## Configure

| Variable | Default | Purpose |
|---|---|---|
| `NOTES_FILE` | `~/.notes-mcp.json` | Where the notes are stored |

Example client configuration:

```json
{
"mcpServers": {
"notes": {
"command": "npx",
"args": ["-y", "notes-mcp"],
"env": {
"NOTES_FILE": "/Users/you/notes.json"
}
}
}
}
```

## Security

- The server makes no network calls and asks for no sign-in.
- It reads and writes only the file at `NOTES_FILE`. Notes are stored as plain text, unencrypted.
- `delete_note` removes notes permanently. There is no undo.
- Each tool declares whether it changes data (`readOnlyHint`, `destructiveHint`). Clients should treat those hints as advice from this server, not as a guarantee.

## Versions

Versions follow semantic versioning. The version in `package.json` matches the version in `server.json`.

## License

MIT

Four things make a README useful for a listing:

  • Every tool, by name. The check script in the next step fails if a tool in the code isn't in the README.
  • What it reads, writes and sends. Say where data lives, whether there are network calls, and whether anything is permanent. The Security section above answers those questions in four lines.
  • Install and configure in one place. Show the exact command and environment variables.
  • The version and license. People deciding whether to install look for both.

Don't overstate safety. A README that says the server "is secure" gives readers nothing they can check. A README that says "deletes are permanent" gives them the fact they need.

4. Write server.json for the registry​

server.json is the metadata the official registry stores. It's separate from package.json, and the two must agree on name, version and package identifier.

server.json
{
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
"name": "io.github.example/notes",
"title": "Notes",
"description": "Save, list and delete short text notes in a local file.",
"version": "1.0.0",
"websiteUrl": "https://github.com/example/notes-mcp",
"repository": {
"url": "https://github.com/example/notes-mcp",
"source": "github"
},
"icons": [
{
"src": "https://example.com/notes-mcp/icon-48.png",
"mimeType": "image/png",
"sizes": ["48x48"]
}
],
"packages": [
{
"registryType": "npm",
"identifier": "notes-mcp",
"version": "1.0.0",
"transport": {
"type": "stdio"
}
}
]
}

The fields that matter most:

  • name must be io.github.<your-username>/<name> if you sign in with GitHub. Domain sign-in uses a reverse-DNS name for a domain you control.
  • description must be at most 100 characters. The registry rejects longer ones.
  • version must be one plain semantic version such as 1.0.0. Ranges like ^1.0.0 or 1.x are prohibited.
  • icons must use HTTPS src URLs in server.json. Host a PNG at a URL you control, and replace the example.com address in the example. The registry schema requires HTTPS here, even though the runtime icon above can be a data URI.
  • packages lists each package. For npm, identifier must equal the package name and version must equal the package's own version.

Replace example, example.com and the other placeholders before you publish. Don't leave a placeholder in a public listing.

Other package types use different ownership checks. The registry's package type page lists them:

Package typeWhere the ownership check looks
npmmcpName in package.json
PyPI and NuGetA line mcp-name: <server name> in the package README, which can sit inside an HTML comment
Cargo (crates.io)A visible mcp-name: <server name> line in the README. HTML comments are stripped.
Docker or OCI imageAn io.modelcontextprotocol.server.name annotation on the image, which a Dockerfile LABEL sets
MCPB fileAn artifact URL containing mcp, plus a fileSha256 value

5. Check the listing before you publish​

Publishing is the point of no return. Once a version is on the registry it can't be changed, and the FAQ says servers currently can't be deleted. Run a check first.

check-listing.mjs
// Checks the metadata a registry or directory reads before listing this server.
// Run with: npm run check-listing
import { readFileSync } from "node:fs";

const read = (file) => readFileSync(new URL(file, import.meta.url), "utf8");

const pkg = JSON.parse(read("./package.json"));
const server = JSON.parse(read("./server.json"));
const readme = read("./README.md");
const source = read("./src/index.ts");

const problems = [];
const check = (ok, message) => {
if (!ok) problems.push(message);
};

check(
/^[a-zA-Z0-9.-]+\/[a-zA-Z0-9._-]+$/.test(server.name),
`server.json name "${server.name}" must look like namespace/name`,
);
check(
/^io\.github\.[^/]+\//.test(server.name),
'server.json name must start with "io.github.<your-github-username>/" to use GitHub sign-in',
);
check(
server.description.length <= 100,
`description is ${server.description.length} characters; the registry limit is 100`,
);
check(
/^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/.test(server.version),
`version "${server.version}" must be one plain semantic version, not a range`,
);
// A metadata-only release can add a prerelease suffix (1.0.0-1) while the
// package stays at 1.0.0, so compare the part before the suffix.
const baseVersion = server.version.split("-")[0];
check(
pkg.mcpName === server.name,
"package.json mcpName must equal the server.json name, or the registry rejects the publish",
);
check(
pkg.version === baseVersion,
`package.json version must equal the server.json version without any prerelease suffix (${baseVersion})`,
);

const npmPackage = server.packages?.find((p) => p.registryType === "npm");
check(npmPackage !== undefined, "server.json needs an npm entry in packages");
check(
npmPackage?.identifier === pkg.name,
"packages[].identifier must equal the package.json name",
);
check(
npmPackage?.version === pkg.version,
"packages[].version must equal the package.json version",
);
check(
typeof server.repository?.url === "string" &&
server.repository.url.startsWith("https://"),
"repository.url must be an https URL",
);
check(
(server.icons ?? []).every((icon) => icon.src.startsWith("https://")),
"every server.json icon src must be an https URL",
);

const toolNames = [...source.matchAll(/registerTool\(\s*"([a-z_]+)"/g)].map(
(match) => match[1],
);
check(toolNames.length > 0, "found no registerTool calls in src/index.ts");
for (const name of toolNames) {
check(readme.includes(`\`${name}\``), `README.md does not document the ${name} tool`);
}

if (problems.length > 0) {
for (const problem of problems) console.error(`FAIL: ${problem}`);
process.exit(1);
}
console.log(`listing checks passed (${toolNames.length} tools documented)`);

The script reads package.json, server.json, the README and the source, and it checks the rules above. Run it from the project folder:

npm run check-listing

It doesn't replace the registry's own validation. It catches the mistakes you can fix locally: a missing mcpName, a version mismatch, a long description, an undocumented tool and a non-HTTPS icon. The registry checks more, including namespace ownership.

6. Test the server and its annotations​

The metadata is only useful if the server actually sends it. The test script builds the server, runs the listing check, then uses MCP Inspector to list tools in both protocol eras and make a save, list and delete round trip.

bash docs/guides-examples/list-in-mcp-directories/test.sh

The Inspector's --protocol-era modern run covers the 2026-07-28 revision, and --protocol-era legacy covers older clients. The script checks both, so the annotations can't disappear from one era without the test failing.

7. Publish to npm and the MCP Registry​

First, check what npm will ship. This dry run lists the files without publishing anything:

cd docs/guides-examples/list-in-mcp-directories/typescript
npm pack --dry-run

The output lists README.md, build/index.js and package.json. If a file you expected is missing, check the files field.

Then publish the package to npm. The registry only stores metadata, so the npm package has to exist first:

npm publish --access public

Next, publish server.json from the same folder. The sign-in, install and troubleshooting steps are in Publish an MCP server to the MCP Registry. In short:

mcp-publisher login github
mcp-publisher publish

Once a version is published, its metadata is fixed. To change anything, including the README, icons or annotations, publish a new version string. The registry's versioning guide suggests a prerelease suffix for metadata-only changes. Set server.json to 1.0.0-1 and keep the npm package at 1.0.0. The check script accepts that pattern. When the package code changes, bump the package and the server version to the same new number.

8. Connect it to an AI app​

A listed server still needs to work in a client. For a local stdio server, point the client at the built file. The Claude Desktop guide and the Cursor guide show the config for each app. Once the package is on npm, npx -y notes-mcp runs it.

How directories pick up the registry​

The registry doesn't push your listing anywhere. It's built for downstream aggregators to read, and its docs describe them pulling data on a regular but infrequent basis, for example once an hour. They read GET /v0.1/servers, follow nextCursor to page through, and can use updated_since to fetch only changes.

A few consequences follow from that:

  • Your listing appears after the directory's next pull. The registry gives no uptime or durability guarantee to the aggregators that read it.
  • Status can change without you editing anything. Servers can be marked deprecated or deleted. The registry's moderation policy sets deleted for illegal content, malware, spam and servers that don't work. It doesn't remove servers for being low quality or insecure. So a registry listing isn't a safety review.
  • Directories apply their own rules. The registry says curation, ratings and security scans are the job of the downstream marketplace. Check each directory's own listing rules.

The registry is a source of metadata, and the next pull copies whatever you published. The checks in this guide exist so the copy is correct the first time.

Common problems​

Publish fails with "Registry validation failed for package". The mcpName in package.json is missing or doesn't equal the name in server.json. Set both to io.github.<your-username>/<name> and publish the npm package again with a new version.

Publish fails with "You do not have permission to publish this server". The server name doesn't match the account you signed in with. A GitHub sign-in needs a name that starts with io.github.<your-username>/. Sign in as that user, or change the name.

Publish fails with "Invalid or expired Registry JWT token". Your registry session has expired. Run mcp-publisher login github again, then publish.

The description is rejected. The description is longer than 100 characters. Shorten it to what the server does. npm run check-listing catches this before you publish.

Publish rejects the version. Either you reused a version that's already on the registry, or you used a range such as ^1.0.0. Each published version must be unique and must be a single version. Bump the version in both package.json and server.json.

FAQ​

Do I need to publish to the MCP Registry to be listed in a directory?

Not necessarily. Each directory decides what it lists and how. The official registry is a metadata source that downstream directories and marketplaces are built to read, so publishing there makes your metadata available to any of them that read it.

Can I edit my listing after I publish it?

Not on the official registry. Metadata for a published version can't be changed, so you publish a new version string with the changes. Directories may offer their own editing for their listings.

Can I remove my server from the registry?

Not at the moment. The registry's FAQ says servers can't currently be deleted, so check the listing carefully before you publish.

Does the registry read my README?

Not as metadata. The registry's server.json has no README field, so the description and the other fields carry the listing. npm always includes the README in the package, and people read it to see what the server does before they install it.

Are tool annotations enough to show a tool is safe?

No. Annotations are hints from the server, and the spec says clients must treat them as untrusted unless they come from a trusted server. They describe what the tool is meant to do, and the spec calls every annotation a hint.