Sharing system prompts across 3 AI clients in 64 lines of MCP

Put each system prompt in its own markdown file and expose the folder through MCP's prompts primitive. Claude Code turns each prompt into a slash command, Claude Desktop lists them in its attach menu, and VS Code Copilot Chat offers them as /mcp commands. The server is 64 lines of TypeScript. Two traps to know first: MCP prompt messages have no system role, and anything printed to stdout breaks the connection.

The System Prompt Library I link to below is one I built. I'd gone through four other prompt collections, and every one either wanted an account before showing the actual text or buried it under upvote widgets and "pro" badges. Mine is free and runs client-side. No signup, no upload. You read the prompt and copy it. If you have a better one, tell me and I'll use it.

The goal: one folder of prompts, three clients

Last Tuesday I counted, and I had 23 system prompts spread around my laptop. Some lived in a Notes file. Some were pasted into Claude Desktop projects. A few sat in a CLAUDE.md that had grown so large the agent was reading my opinions on SQL formatting while I asked it to rename a CSS class. The code review prompt existed in three slightly different copies, so I never knew which one had my fix for the "stop telling me to add comments" problem.

What I wanted was boring. One folder of markdown files in a git repo, and every AI client I use able to pull a prompt out of it on demand. Edit the file once, restart, done.

Most people know MCP for tools. Fewer people touch the prompts primitive, which is built for exactly this job. A prompt is a user-triggered template: the client asks the server for a list, you pick one, and the server returns messages that get dropped into the conversation. The model never decides to fetch it on its own.

Here's the end state. In Claude Code I type /mcp__prompt-shelf__code-review and a 600-word review persona lands in the chat. In Claude Desktop the same prompt shows up under the attach menu for the server. In VS Code's Copilot Chat it's /mcp.prompt-shelf.code-review. The same file on disk sits behind all of them, and when I tighten a sentence in it, every client gets the change the next time it starts the server.

Setup and the auth you don't need

The project is small. Run npm init -y, then npm i @modelcontextprotocol/sdk zod and npm i -D typescript @types/node. Set "type": "module" in package.json, because the server uses top-level await and import.meta.url. In tsconfig, "module": "NodeNext" with "target": "ES2022" is what worked for me. Source goes in src/, compiled output in dist/, and the prompts live in a prompts/ folder at the repo root.

Auth is the short part. There isn't any. This is a stdio server: the client spawns it as a child process and talks JSON-RPC over stdin and stdout, so there's no port, no token, and no secret to stash in an env var. That changes the moment you move to HTTP so a teammate can hit it (the spec expects OAuth for remote servers), but I'm not doing that here. The only env var I read is PROMPT_DIR, so my work machine can point at a different checkout.

Registering it is where the gotchas live:

  • Claude Code: claude mcp add --scope user prompt-shelf -- node /Users/me/code/prompt-shelf/dist/server.js. Without --scope user it only registers for the current project, which confused me for a minute when it vanished in another repo.
  • Claude Desktop: add an entry under mcpServers in claude_desktop_config.json with command and args. Desktop doesn't load your shell profile, so if you installed Node with nvm, plain node won't be on its PATH. Use the absolute path that which node prints. Then quit Desktop fully with Cmd+Q, because closing the window doesn't restart servers.
  • VS Code: put the config in .vscode/mcp.json, and note that the top-level key there is servers. I pasted my Desktop block across verbatim, mcpServers and all, and VS Code just didn't pick it up.

If your prompts folder is empty, start with someone else's work and edit hard. I seeded about half of mine from the System Prompt Library, then cut each one roughly in half, because most published prompts are twice as long as they need to be (mine included, which is a little embarrassing to admit).

The server, with the trouble spots marked

Each prompt is a markdown file. The filename becomes the prompt name, so keep it kebab-case. An optional first line holds a one-line description in an HTML comment, which the client shows in its picker. So prompts/code-review.md starts with <!-- Strict reviewer. Flags bugs, ignores style. --> and the rest is the prompt itself.

Here's the whole server:

// server.ts: expose a folder of markdown system prompts as MCP prompts (stdio) import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { readdirSync, readFileSync } from "node:fs"; import { basename, dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { z } from "zod"; // Trouble spot 1: the client starts us with ITS working directory, not ours. // Resolve the folder relative to this file, never process.cwd(). const here = dirname(fileURLToPath(import.meta.url)); const PROMPT_DIR = process.env.PROMPT_DIR ?? join(here, "..", "prompts"); type PromptFile = { name: string; description: string; body: string }; function loadPrompts(): PromptFile[] { return readdirSync(PROMPT_DIR) .filter((f) => f.endsWith(".md")) .map((f) => { const raw = readFileSync(join(PROMPT_DIR, f), "utf8"); // Convention: optional first line "<!-- one-line description -->" const head = raw.match(/^<!--\s*(.+?)\s*-->\r?\n/); return { name: basename(f, ".md"), description: head?.[1] ?? "No description", body: (head ? raw.slice(head[0].length) : raw).trim(), }; }); } const server = new McpServer({ name: "prompt-shelf", version: "0.3.1" }); const prompts = loadPrompts(); for (const p of prompts) { server.registerPrompt( p.name, { title: p.name.replace(/-/g, " "), description: p.description, // Trouble spot 2: prompt arguments are always strings. Keep them optional. argsSchema: { task: z.string().optional() }, }, ({ task }) => ({ // Trouble spot 3: prompt messages only allow "user" or "assistant". // There is no "system" role, so the wrapper tags do that job instead. messages: [ { role: "user", content: { type: "text", text: `<system_instructions>\n${p.body}\n</system_instructions>` + (task ? `\n\nTask: ${task}` : ""), }, }, ], }) ); } // Trouble spot 4: stdout IS the protocol on a stdio server. Log to stderr only. console.error(`[prompt-shelf] ${prompts.length} prompts from ${PROMPT_DIR}`); await server.connect(new StdioServerTransport()); 

Build with npx tsc. Before touching any real client, run it under the MCP Inspector: npx @modelcontextprotocol/inspector node dist/server.js. It gives you a UI to list prompts and fetch one with arguments, and it shows you the raw JSON coming back. That alone would have saved me most of the debugging further down.

The single task argument is deliberate. Clients collect prompt arguments differently. Claude Code takes them after the slash command, and I haven't checked how every version splits multi-word input, so I keep exactly one optional argument and the prompt works fine when it's empty.

One limitation I haven't fixed: prompts are read once at startup. Edit a file's body and you need to restart the server. The SDK can send a list-changed notification, but I haven't wired up a file watcher, and I honestly don't know whether all three clients re-fetch the list when they get one.

Prompts, tools, or a rules file

There are three ways to get shared instructions into a model, and I've now used all of them for this. They aren't interchangeable.

Approach Who triggers it How it shows up Context cost Cross-client story
MCP prompt (this post) You, explicitly Slash command or attach menu Zero until you pick one Any client with prompts support
MCP tool that returns prompt text The model decides A tool call in the transcript Every tool definition, every request Nearly every MCP client
Rules file (CLAUDE.md, AGENTS.md, .cursorrules) Nobody, it's always on Nothing visible The whole file, every turn Each client reads its own filename

I was wrong about tools. My first version exposed one tool per prompt, things like get_code_review_prompt. Two problems showed up within a day. The model called them whenever it felt like it (it once fetched my SQL style guide halfway through a React refactor). And 23 tool definitions cost 1,912 tokens on every request before I'd typed a word. Prompts cost nothing until I pick one.

Rules files still win for things that should always be true: "use pnpm", "tests live next to the source file". I keep those in AGENTS.md. Personas, review checklists and writing styles go in the folder, because I want them on purpose rather than ambiently.

What went wrong the first time

The stdout thing cost me 47 minutes. I had a console.log that printed "loaded 23 prompts" at startup, which felt harmless. Claude Desktop showed the server as failed with no useful message, and Claude Code reported it couldn't connect. On a stdio server, stdout is the JSON-RPC channel, so any stray line is a malformed message and the client gives up. console.error writes to stderr, which the clients capture as logs instead. On my Mac, Desktop puts them under ~/Library/Logs/Claude/, with a separate log file per server. That's where I finally saw the error.

The missing system role annoyed me for a day. My first version returned role: "system", because the whole folder is system prompts and that seemed obvious. The SDK rejected it at validation: the spec only defines user and assistant for prompt messages. So the server wraps the text in <system_instructions> tags inside a user message. In practice the model follows a tagged block at the top of a conversation well. It's still not identical to a real system prompt, since the client's own system prompt sits above it and wins any conflict. I don't have a cleaner fix and I'm not sure one exists inside the current spec.

The working directory bug was sneakier. My original code did readdirSync("./prompts"). It worked perfectly in the Inspector, because I launched that from the project folder. Under Desktop it threw ENOENT, since Desktop started the process from /. Lines 11 and 12 of the server exist entirely because of that afternoon.

Last one, and I'm less certain about the details. When I set this up three weeks ago, I'd installed zod 4 out of habit and got a wall of type errors around argsSchema with the SDK version I had pinned. Dropping to zod 3 made them go away. Newer SDK releases say they handle zod 4, so check the changelog before copying my workaround.

FAQ

Q: Does this work in Cursor?

A: I haven't tested it recently. Cursor's MCP support has mostly been about tools, and prompt support has shifted between releases. Check their current docs. If prompts aren't listed, the tool approach from the table is the fallback, with the token cost that comes with it.

Q: Why the XML-style tags instead of plain text?

A: They give the model a clear boundary between the instructions and the task that follows. Without them, a long prompt plus a short task sometimes got read as one blob, and the task got treated as part of the persona.

Q: Can my team share one server over the network?

A: Yes, swap StdioServerTransport for the SDK's Streamable HTTP transport. Then auth stops being optional, because anyone who can reach the port can read your prompts. For a small team, a shared git repo of prompt files with everyone running the stdio server locally is simpler.

Q: Why not expose the files as MCP resources?

A: Resources are meant to be data the app or user attaches as context, and how clients let you browse them varies a lot. Prompts give you the slash-command flow, which is the part I actually wanted.

Written with AI assistance and human review. Try the tool at aidevhub.io/prompt-library.