Building MCP Servers Skill
Authors a Model Context Protocol server in TypeScript or Python using the official SDKs (@modelcontextprotocol/sdk, mcp), with deliberate tool/resource/prompt design, both stdio and Streamable HTTP transports, OAuth 2.1 auth, and a test fixture. Does NOT roll its own JSON-RPC framing and does NOT ship without registry + config snippet.
When to Use
- A required system is not in
mcp/registry.yaml.
- An existing MCP server is too broad; you need a domain-specific facade.
- User says "build", "write", "author", "create" + "MCP server".
- Wrapping internal APIs so an agent can call them without bespoke skills.
Prerequisites
- Node 20+ (TypeScript) or Python 3.11+.
- The official SDK installed (
@modelcontextprotocol/sdk or mcp).
- An auth strategy decided upfront (CLI inheritance for local, OAuth 2.1 + PKCE for hosted).
mcp/registry.yaml and mcp/configs/ writable.
@modelcontextprotocol/inspector for handshake debugging.
How to Run
1. Pick primitives: tools / resources / prompts. Default to tools.
2. Design tool names (verb_object) and JSON Schema inputs.
3. Scaffold the server with the official SDK + stdio transport.
4. Add Streamable HTTP transport if the server is hosted.
5. Implement auth — CLI inheritance (local) or OAuth 2.1 + PKCE (hosted).
6. Add tests: in-memory transport unit tests + a real stdio smoke test.
7. Register in `mcp/registry.yaml` and add a `mcp/configs/` snippet.
Quick Reference
| Primitive |
Use for |
Mental model |
| tools |
Side-effectful or arg'd queries |
RPC the agent decides to invoke |
| resources |
Read-only documents addressable by URI |
Things the agent or user attaches |
| prompts |
Pre-baked agent instructions |
Slash commands the user invokes |
| Concern |
Rule |
| Tool naming |
verb_object snake_case (get_my_deals) |
| Input schema |
JSON Schema, required fields marked, description on every property |
| Output |
Text content first; structured JSON in a second content block |
| Logging |
stderr only — stdout is reserved for JSON-RPC framing |
| Auth |
CLI inheritance (local) or OAuth 2.1 + PKCE (hosted), never tokens in tool args |
| Pagination |
Every list tool has limit (default ≤50) and cursor |
| Idempotency |
Mutations accept a client-provided idempotency key |
| Versioning |
Bump serverInfo.version on schema changes |
Procedure
Step 1: Choose Primitives
Default to tools. Use resources only when the data is naturally addressable by URI. Use prompts only when the user invokes them explicitly (slash commands).
Step 2: Design Tool Surface
For each tool:
- Name:
verb_object snake_case.
- Description: answer "when do I call this?", not "what does it do internally". Lead with the trigger context.
- Input: JSON Schema with
required, every property has a human-readable description.
- Output: text summary first; machine-readable JSON in a second content block if the agent will chain calls.
Step 3: Stdio Scaffold (TypeScript)
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
const server = new Server(
{ name: "my-server", version: "1.0.0" },
{ capabilities: { tools: {} } }
);
server.setRequestHandler(/* tools/list */, async () => ({ tools: [...] }));
server.setRequestHandler(/* tools/call */, async (req) => { /* ... */ });
await server.connect(new StdioServerTransport());
Step 4: Streamable HTTP (Hosted)
- Use
StreamableHTTPServerTransport.
- Mount behind a real auth provider (Entra ID, Auth0, custom OAuth 2.1).
- Implement
/mcp POST + GET (SSE streaming). Do not invent endpoints.
- Add protected resource metadata so clients can discover the auth server.
Step 5: Stdout Discipline
The single most common failure: a console.log or stray print before the JSON-RPC handshake corrupts the stream. The client silently dies.
- TypeScript: route logs to
console.error.
- Python: configure
logging to sys.stderr, never print.
Step 6: Auth Without Inline Secrets
- Local dev: inherit from
az, gh, kubectl, gcloud.
- Hosted: OAuth 2.1 + PKCE. Map
Authorization: Bearer … to your resource server.
- Never accept tokens via tool arguments — that places them in the agent's context window and chat history.
Step 7: Tests
- Unit-test handlers with the SDK's in-memory transport.
- Smoke test: spawn the server, call
tools/list then one representative tools/call.
- Ship an
auth_status tool for diagnostics.
- Provide a
--inspect mode wired to @modelcontextprotocol/inspector.
Step 8: Register and Document
After the server works:
- Add an entry to
mcp/registry.yaml (transport, scopes, auth model).
- Add a config snippet under
mcp/configs/.
- Cross-link from the relevant consumer skill.
Pitfalls
- DO NOT log to stdout in a stdio server.
- DO NOT build a mega-tool with a
mode enum that switches behavior. One job per tool.
- DO NOT write tool descriptions that explain implementation. Lead with the trigger context.
- DO NOT return unbounded result sets. Every list tool has
limit and cursor.
- DO NOT accept credentials as tool arguments. They will end up in chat history.
- DO NOT re-implement JSON-RPC framing. Use the SDK.
- DO NOT ship a server without an
auth_status / health tool.
Verification
1---2name: building-mcp-servers3description: Authors an MCP server with the official SDK and gates.4---5
6# Building MCP Servers Skill
7
8Authors a Model Context Protocol server in TypeScript or Python using the official SDKs (`@modelcontextprotocol/sdk`, `mcp`), with deliberate tool/resource/prompt design, both stdio and Streamable HTTP transports, OAuth 2.1 auth, and a test fixture. Does NOT roll its own JSON-RPC framing and does NOT ship without registry + config snippet.
9
10## When to Use
11
12- A required system is not in `mcp/registry.yaml`.
13- An existing MCP server is too broad; you need a domain-specific facade.
14- User says "build", "write", "author", "create" + "MCP server".
15- Wrapping internal APIs so an agent can call them without bespoke skills.
16
17## Prerequisites
18
19- Node 20+ (TypeScript) or Python 3.11+.
20- The official SDK installed (`@modelcontextprotocol/sdk` or `mcp`).
21- An auth strategy decided upfront (CLI inheritance for local, OAuth 2.1 + PKCE for hosted).
22- `mcp/registry.yaml` and `mcp/configs/` writable.
23- `@modelcontextprotocol/inspector` for handshake debugging.
24
25## How to Run
26
27```text
281. Pick primitives: tools / resources / prompts. Default to tools.
292. Design tool names (verb_object) and JSON Schema inputs.
303. Scaffold the server with the official SDK + stdio transport.
314. Add Streamable HTTP transport if the server is hosted.
325. Implement auth — CLI inheritance (local) or OAuth 2.1 + PKCE (hosted).
336. Add tests: in-memory transport unit tests + a real stdio smoke test.
347. Register in `mcp/registry.yaml` and add a `mcp/configs/` snippet.
35```
36
37## Quick Reference
38
39| Primitive | Use for | Mental model |
40|---|---|---|
41| tools | Side-effectful or arg'd queries | RPC the agent decides to invoke |
42| resources | Read-only documents addressable by URI | Things the agent or user attaches |
43| prompts | Pre-baked agent instructions | Slash commands the user invokes |
44
45| Concern | Rule |
46|---|---|
47| Tool naming | `verb_object` snake_case (`get_my_deals`) |
48| Input schema | JSON Schema, required fields marked, `description` on every property |
49| Output | Text content first; structured JSON in a second content block |
50| Logging | stderr only — stdout is reserved for JSON-RPC framing |
51| Auth | CLI inheritance (local) or OAuth 2.1 + PKCE (hosted), never tokens in tool args |
52| Pagination | Every list tool has `limit` (default ≤50) and `cursor` |
53| Idempotency | Mutations accept a client-provided idempotency key |
54| Versioning | Bump `serverInfo.version` on schema changes |
55
56## Procedure
57
58### Step 1: Choose Primitives
59
60Default to tools. Use resources only when the data is naturally addressable by URI. Use prompts only when the user invokes them explicitly (slash commands).
61
62### Step 2: Design Tool Surface
63
64For each tool:
65
66- Name: `verb_object` snake_case.
67- Description: answer "when do I call this?", not "what does it do internally". Lead with the trigger context.
68- Input: JSON Schema with `required`, every property has a human-readable `description`.
69- Output: text summary first; machine-readable JSON in a second content block if the agent will chain calls.
70
71### Step 3: Stdio Scaffold (TypeScript)
72
73```ts
74import { Server } from "@modelcontextprotocol/sdk/server/index.js";
75import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
76
77const server = new Server(
78 { name: "my-server", version: "1.0.0" },
79 { capabilities: { tools: {} } }
80);
81
82server.setRequestHandler(/* tools/list */, async () => ({ tools: [...] }));
83server.setRequestHandler(/* tools/call */, async (req) => { /* ... */ });
84
85await server.connect(new StdioServerTransport());
86```
87
88### Step 4: Streamable HTTP (Hosted)
89
90- Use `StreamableHTTPServerTransport`.
91- Mount behind a real auth provider (Entra ID, Auth0, custom OAuth 2.1).
92- Implement `/mcp` POST + GET (SSE streaming). Do not invent endpoints.
93- Add protected resource metadata so clients can discover the auth server.
94
95### Step 5: Stdout Discipline
96
97The single most common failure: a `console.log` or stray print before the JSON-RPC handshake corrupts the stream. The client silently dies.
98
99- TypeScript: route logs to `console.error`.
100- Python: configure `logging` to `sys.stderr`, never `print`.
101
102### Step 6: Auth Without Inline Secrets
103
104- Local dev: inherit from `az`, `gh`, `kubectl`, `gcloud`.
105- Hosted: OAuth 2.1 + PKCE. Map `Authorization: Bearer …` to your resource server.
106- Never accept tokens via tool arguments — that places them in the agent's context window and chat history.
107
108### Step 7: Tests
109
110- Unit-test handlers with the SDK's in-memory transport.
111- Smoke test: spawn the server, call `tools/list` then one representative `tools/call`.
112- Ship an `auth_status` tool for diagnostics.
113- Provide a `--inspect` mode wired to `@modelcontextprotocol/inspector`.
114
115### Step 8: Register and Document
116
117After the server works:
118
1191. Add an entry to `mcp/registry.yaml` (transport, scopes, auth model).
1202. Add a config snippet under `mcp/configs/`.
1213. Cross-link from the relevant consumer skill.
122
123## Pitfalls
124
125- **DO NOT** log to stdout in a stdio server.
126- **DO NOT** build a mega-tool with a `mode` enum that switches behavior. One job per tool.
127- **DO NOT** write tool descriptions that explain implementation. Lead with the trigger context.
128- **DO NOT** return unbounded result sets. Every list tool has `limit` and `cursor`.
129- **DO NOT** accept credentials as tool arguments. They will end up in chat history.
130- **DO NOT** re-implement JSON-RPC framing. Use the SDK.
131- **DO NOT** ship a server without an `auth_status` / health tool.
132
133## Verification
134
135- [ ] Built with `@modelcontextprotocol/sdk` (TS) or `mcp` (Python) — no custom framing.
136- [ ] All tools have JSON Schema input with `required` set and per-property `description`.
137- [ ] Stdout is reserved for JSON-RPC; all logs go to stderr.
138- [ ] Auth uses CLI inheritance (local) or OAuth 2.1 + PKCE (hosted).
139- [ ] `auth_status` tool exists.
140- [ ] Smoke test covers `tools/list` and one `tools/call` over real stdio.
141- [ ] Registry entry and `mcp/configs/` snippet exist.
142- [ ] `serverInfo.version` set and bumped on every schema change.