skills-ws
Version:
87 agent skills for AI coding assistants — marketing, growth, web3, dev, design & operations. Built for OpenClaw, Claude Code, Cursor, and Codex.
354 lines (308 loc) • 15.2 kB
Markdown
## Contents
- 2. Server Setup — TypeScript (@modelcontextprotocol/sdk)
- Project Init
- Minimal stdio Server
- 2a. Streamable HTTP — Stateful (sessions + resumability) — RECOMMENDED
- 2b. Streamable HTTP — Stateless (horizontal scale, JSON-only)
- 2c. Backward-compat appendix — host legacy HTTP+SSE alongside /mcp
## 2. Server Setup — TypeScript (@modelcontextprotocol/sdk)
### Project Init
```bash
mkdir my-mcp-server && cd my-mcp-server
npm init -y
npm install @modelcontextprotocol/sdk zod express cors
npm install -D typescript @types/node @types/express tsx
```
```json
// tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": true
},
"include": ["src"]
}
```
```json
// package.json (relevant fields)
{
"type": "module",
"bin": { "my-mcp-server": "dist/index.js" },
"scripts": {
"build": "tsc",
"dev": "tsx src/index.ts",
"start": "node dist/index.js"
}
}
```
### Minimal stdio Server
```typescript
#!/usr/bin/env node
// src/index.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
const server = new McpServer(
{ name: "my-mcp-server", version: "1.0.0" },
);
// --- TOOLS ---
// Current SDK (v1.x) API: server.registerTool(name, config, handler).
// config carries { title, description, inputSchema, outputSchema?, annotations? }.
// (The older server.tool(name, desc, shape, handler) still works but is marked
// @deprecated in the SDK; registerTool is the documented API: it adds a UI `title`,
// optional `outputSchema`, and lets handlers return `structuredContent`.)
server.registerTool(
"screenshot",
{
title: "Webpage Screenshot",
description: "Capture a screenshot of a webpage",
inputSchema: {
url: z.string().url().describe("URL to capture"),
width: z.number().int().min(320).max(3840).default(1280).describe("Viewport width"),
height: z.number().int().min(240).max(2160).default(720).describe("Viewport height"),
fullPage: z.boolean().default(false).describe("Capture full page scroll"),
},
},
async ({ url, width, height, fullPage }) => {
const apiUrl = `https://api.screenshotone.com/take?url=${encodeURIComponent(url)}&viewport_width=${width}&viewport_height=${height}&full_page=${fullPage}&format=png&access_key=${process.env.SCREENSHOT_API_KEY}`;
const res = await fetch(apiUrl);
if (!res.ok) {
return { content: [{ type: "text", text: `Screenshot failed: ${res.status} ${res.statusText}` }], isError: true };
}
const buffer = Buffer.from(await res.arrayBuffer());
return {
content: [
{ type: "image", data: buffer.toString("base64"), mimeType: "image/png" },
{ type: "text", text: `Screenshot of ${url} (${width}x${height}, fullPage=${fullPage})` },
],
};
}
);
server.registerTool(
"dns_lookup",
{
title: "DNS Lookup",
description: "Resolve DNS records for a domain",
inputSchema: {
domain: z.string().min(1).describe("Domain to look up"),
type: z.enum(["A", "AAAA", "CNAME", "MX", "NS", "TXT", "SOA"]).default("A").describe("Record type"),
},
// outputSchema makes the result machine-readable; pair it with `structuredContent` below.
outputSchema: {
records: z.array(z.object({ name: z.string(), type: z.number(), TTL: z.number(), data: z.string() })).default([]),
status: z.number(),
},
},
async ({ domain, type }) => {
const res = await fetch(`https://dns.google/resolve?name=${encodeURIComponent(domain)}&type=${type}`);
const data = await res.json();
const structuredContent = { records: data.Answer ?? [], status: data.Status ?? 0 };
// When you declare outputSchema, ALSO return a text block (for clients that ignore
// structuredContent) plus the structuredContent itself (for clients that parse it).
return {
content: [{ type: "text", text: JSON.stringify(structuredContent, null, 2) }],
structuredContent,
};
}
);
// --- RESOURCES ---
server.registerResource(
"server-info",
"info://server",
{ description: "Server metadata and capabilities" },
async () => ({
contents: [{
uri: "info://server",
mimeType: "application/json",
text: JSON.stringify({ name: "my-mcp-server", version: "1.0.0", tools: 2 }),
}],
})
);
// --- PROMPTS ---
server.registerPrompt(
"analyze-domain",
{
description: "Analyze a domain's DNS, SSL, and WHOIS info",
argsSchema: { domain: z.string().describe("Domain to analyze") },
},
({ domain }) => ({
messages: [{
role: "user",
content: {
type: "text",
text: `Analyze the domain "${domain}": 1) Look up DNS records (A, MX, NS, TXT). 2) Check SSL certificate. 3) Get WHOIS info. Summarize findings with any security concerns.`,
},
}],
})
);
// --- START ---
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("MCP server running on stdio");
}
main().catch((err) => {
console.error("Fatal:", err);
process.exit(1);
});
```
### 2a. Streamable HTTP — Stateful (sessions + resumability) — RECOMMENDED
This is the default remote transport. One endpoint `/mcp` handles POST (requests), GET (server→client SSE stream), and DELETE (session teardown). Sessions are keyed by the `Mcp-Session-Id` response header the server returns on `initialize`.
```typescript
// src/http-server.ts
import express from "express";
import cors from "cors";
import { randomUUID } from "node:crypto";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
import { z } from "zod";
const app = express();
// CRITICAL: raw body for webhook signature verification BEFORE the JSON parser (see §8).
app.use("/webhooks", express.raw({ type: "application/json" }));
app.use(express.json());
// CORS: fail closed. NEVER "*" on an MCP/auth endpoint. Browsers also must be allowed
// to READ the session header, so expose it. (Non-browser MCP clients ignore CORS.)
const allowedOrigins = (process.env.ALLOWED_ORIGINS || "").split(",").map(s => s.trim()).filter(Boolean);
app.use(cors({
origin: allowedOrigins.length ? allowedOrigins : false, // false = deny cross-origin in browsers
methods: ["GET", "POST", "DELETE"],
allowedHeaders: ["Content-Type", "Mcp-Session-Id", "Last-Event-ID", "Authorization"],
exposedHeaders: ["Mcp-Session-Id"],
}));
app.get("/health", (_req, res) =>
res.json({ status: "ok", uptime: process.uptime(), timestamp: new Date().toISOString() }));
// One McpServer per session. Register tools/resources/prompts here.
function createMcpServer(): McpServer {
const server = new McpServer({ name: "my-mcp-server", version: "1.0.0" });
server.registerTool(
"screenshot",
{ title: "Webpage Screenshot", description: "Capture a screenshot of a webpage",
inputSchema: { url: z.string().url(), width: z.number().int().default(1280), height: z.number().int().default(720) } },
async ({ url, width, height }) => {
const apiRes = await fetch(
`https://api.screenshotone.com/take?url=${encodeURIComponent(url)}&viewport_width=${width}&viewport_height=${height}&format=png&access_key=${process.env.SCREENSHOT_API_KEY}`
);
if (!apiRes.ok) return { content: [{ type: "text" as const, text: `Error: ${apiRes.status}` }], isError: true };
const buf = Buffer.from(await apiRes.arrayBuffer());
return { content: [{ type: "image" as const, data: buf.toString("base64"), mimeType: "image/png" }] };
}
);
return server;
}
// Transports keyed by session id. In multi-node deploys, either pin sessions with a
// sticky load balancer or run stateless (§2b) — this in-memory map is per-process.
const transports: Record<string, StreamableHTTPServerTransport> = {};
// POST /mcp — every JSON-RPC request. Creates a session on `initialize`, reuses it after.
app.post("/mcp", async (req, res) => {
const sessionId = req.headers["mcp-session-id"] as string | undefined;
try {
let transport: StreamableHTTPServerTransport;
if (sessionId && transports[sessionId]) {
transport = transports[sessionId]; // reuse existing session
} else if (!sessionId && isInitializeRequest(req.body)) {
transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(), // stateful: hand out a session id
// enableJsonResponse: true, // uncomment for JSON-only (no SSE) replies
// eventStore: new InMemoryEventStore(), // enable Last-Event-ID resumability
onsessioninitialized: (sid) => { transports[sid] = transport; }, // store AFTER init (no races)
});
transport.onclose = () => {
const sid = transport.sessionId;
if (sid) delete transports[sid];
};
// Connect BEFORE handling so responses flow back over the same transport.
await createMcpServer().connect(transport);
await transport.handleRequest(req, res, req.body);
return;
} else {
res.status(400).json({ jsonrpc: "2.0", error: { code: -32000, message: "Bad Request: no valid session id" }, id: null });
return;
}
await transport.handleRequest(req, res, req.body);
} catch (err) {
console.error("MCP request error:", err);
if (!res.headersSent) res.status(500).json({ jsonrpc: "2.0", error: { code: -32603, message: "Internal server error" }, id: null });
}
});
// GET /mcp — open the server→client SSE notification stream (supports Last-Event-ID resume).
// DELETE /mcp — terminate the session. Both just hand off to the existing transport.
const sessionRequest = async (req: express.Request, res: express.Response) => {
const sessionId = req.headers["mcp-session-id"] as string | undefined;
if (!sessionId || !transports[sessionId]) return res.status(400).send("Invalid or missing session id");
await transports[sessionId].handleRequest(req, res);
};
app.get("/mcp", sessionRequest);
app.delete("/mcp", sessionRequest);
const PORT = parseInt(process.env.PORT || "3100");
app.listen(PORT, () => console.log(`MCP Streamable HTTP server on http://localhost:${PORT}/mcp`));
// Graceful shutdown: close every live session (see Appendix A for the full handler).
process.on("SIGTERM", async () => {
for (const sid of Object.keys(transports)) { try { await transports[sid].close(); } catch {} }
process.exit(0);
});
```
### 2b. Streamable HTTP — Stateless (horizontal scale, JSON-only)
For pure API proxies / serverless / multi-node behind a round-robin LB, run stateless: a fresh transport + server **per request**, no session header, GET/DELETE return `405`. Set `sessionIdGenerator: undefined` and (typically) `enableJsonResponse: true`.
```typescript
// src/http-server-stateless.ts
app.post("/mcp", async (req, res) => {
try {
const server = createMcpServer();
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined, // stateless: no sessions, any node can serve any request
enableJsonResponse: true, // reply application/json instead of SSE
});
res.on("close", () => { transport.close(); server.close(); });
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
} catch (err) {
console.error("MCP request error:", err);
if (!res.headersSent) res.status(500).json({ jsonrpc: "2.0", error: { code: -32603, message: "Internal server error" }, id: null });
}
});
// No sessions ⇒ no SSE stream / no teardown to honor.
const methodNotAllowed = (_req: express.Request, res: express.Response) =>
res.writeHead(405).end(JSON.stringify({ jsonrpc: "2.0", error: { code: -32000, message: "Method not allowed." }, id: null }));
app.get("/mcp", methodNotAllowed);
app.delete("/mcp", methodNotAllowed);
```
> **Pick one:** *stateful* keeps per-connection context, supports streaming notifications + resumability, needs sticky routing across nodes. *stateless* scales trivially and is the better default for tool-only API wrappers. Don't mix them on one endpoint.
### 2c. Backward-compat appendix — host legacy HTTP+SSE alongside `/mcp`
**Only if you must support clients that predate Streamable HTTP** (the old two-endpoint transport: `GET /sse` opens the stream, `POST /messages?sessionId=…` sends). New servers should be `/mcp`-only. To serve both from one process, run Streamable HTTP on `/mcp` (per §2a) **and** add the deprecated `SSEServerTransport` pair below. Keep the SSE transports in their own session map — the two transports are not interchangeable.
```typescript
// src/legacy-sse.ts — mount onto the SAME Express app that already serves /mcp (§2a).
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
import type express from "express";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
// Separate map: SSE sessions are keyed by the id SSEServerTransport generates.
const sseTransports: Record<string, SSEServerTransport> = {};
export function mountLegacySSE(app: express.Express, createMcpServer: () => McpServer) {
// GET /sse — open the stream. The transport writes an `endpoint` event telling the
// client where to POST (/messages?sessionId=…). One McpServer per SSE connection.
app.get("/sse", async (_req, res) => {
const transport = new SSEServerTransport("/messages", res); // path the client POSTs back to
sseTransports[transport.sessionId] = transport;
res.on("close", () => { delete sseTransports[transport.sessionId]; });
await createMcpServer().connect(transport); // connect AFTER registering in the map
});
// POST /messages?sessionId=… — deliver a client message into its SSE session.
// NOTE: do NOT put express.json() in front of this route — handlePostMessage reads the
// raw stream itself. Mount it on a sub-router without the JSON body parser.
app.post("/messages", async (req, res) => {
const sessionId = req.query.sessionId as string | undefined;
const transport = sessionId ? sseTransports[sessionId] : undefined;
if (!transport) return res.status(400).send("No transport for that sessionId");
await transport.handlePostMessage(req, res); // parses the body internally
});
}
```
> **Migration note:** the SDK still ships `SSEServerTransport`, but the SSE transport is deprecated (spec 2025-03-26) and will be dropped from clients over time. Treat `/sse` as a sunset path: log its usage, and once your clients negotiate `protocolVersion >= 2025-03-26` over `/mcp`, remove it. The official `mcp-remote` shim and current Claude clients already speak Streamable HTTP — point new integrations at `/mcp` (see §12).
---