# Mcp Server Builder

> Build production MCP servers: tool/resource/prompt schemas (Zod/Pydantic), Streamable HTTP + stdio (spec 2025-11-25, SSE legacy), OAuth 2.1 bearer auth, FastMCP (Python) and @modelcontextprotocol/sdk (TS), Stripe + x402 v2 monetization, deploy. Use when shipping, monetizing, or deploying an MCP server or wrapping a REST API as tools.

- **Type:** Skill
- **Install:** `agentstack add skill-san-npm-skills-ws-mcp-server-builder`
- **Verified:** Pending review
- **Seller:** [san-npm](https://agentstack.voostack.com/s/san-npm)
- **Installs:** 0
- **Category:** [Finance & Payments](https://agentstack.voostack.com/c/finance-and-payments)
- **Latest version:** 0.1.0
- **License:** MIT
- **Upstream author:** [san-npm](https://github.com/san-npm)
- **Source:** https://github.com/san-npm/skills-ws/tree/main/skills/mcp-server-builder
- **Website:** https://skills-ws.vercel.app

## Install

```sh
agentstack add skill-san-npm-skills-ws-mcp-server-builder
```

Requires the [AgentStack CLI](https://agentstack.voostack.com/docs/cli). Works with Claude Code, Cursor, and any MCP-compatible agent.

## About

# MCP Server Builder — Production Skill

> **Pick the transport first.** **stdio** for local-process servers (Claude Desktop, CLI). **Streamable HTTP** for everything remote/shared/monetized — `StreamableHTTPServerTransport` in TS (`@modelcontextprotocol/sdk` v1.x), `FastMCP` in Python. The two standard transports are stdio and Streamable HTTP. The old **HTTP+SSE** transport (`/sse` + `/messages`) is **legacy/deprecated** (replaced in spec 2025-03-26, current spec **2025-11-25**); ship it only as a backward-compat appendix for old clients (see §2c).

> Build production-grade Model Context Protocol servers that wrap any REST API into AI-callable tools, with three-tier auth, monetization, and battle-tested deployment.

> **Related skills:** for the consuming side (connecting to / calling MCP servers) see `mcp-client`; for general agent architecture see `ai-agent-building`; for REST contract/versioning design see `api-design`; for the Stripe billing details behind §7 see `stripe-billing`; for the SSRF/secret-handling depth in §9 see `security-hardening`.

## When to Use

- User wants to build an MCP server (stdio or Streamable HTTP)
- User wants to wrap a REST API as MCP tools
- User asks about MCP architecture, tool/resource/prompt schemas, or transports
- User wants to monetize an MCP server (free tier, API keys, x402 micropayments, Stripe subscriptions)
- User wants OAuth/bearer auth on a remote MCP server, or to deploy/list one
- User mentions `@modelcontextprotocol/sdk`, `mcp` Python package, FastMCP, or MCP in general

---

## 1. MCP Architecture Overview

MCP (Model Context Protocol) defines three primitives that a server exposes to AI clients:

| Primitive    | Purpose                              | Example                          |
|-------------|---------------------------------------|----------------------------------|
| **Tools**    | Actions the model can invoke          | `screenshot`, `dns_lookup`       |
| **Resources**| Read-only data the model can access   | `config://settings`, `db://users`|
| **Prompts**  | Reusable prompt templates             | `summarize`, `code_review`       |

### Transports

**stdio** — Server runs as a child process. Client spawns it, communicates over stdin/stdout. One client per process; no auth layer (trust is the local OS).
Best for: local tools, Claude Desktop, Claude Code, CLI integrations.

**Streamable HTTP** *(recommended for all remote servers; MCP spec 2025-03-26, refined 2025-11-25)* — A **single endpoint** (conventionally `/mcp`) that serves **POST** (client→server JSON-RPC), **GET** (open a server→client SSE stream for notifications/resumability), and **DELETE** (terminate a session). It is *not* "just request/response": per request the server replies either `application/json` (one-shot) **or** `text/event-stream` (streamed result + server notifications); responses/notifications that aren't requests get `202 Accepted` with no body. Supports optional **sessions** (`Mcp-Session-Id` header), **resumability** (`Last-Event-ID` + an event store), and a **JSON-only mode** (`enableJsonResponse` / `json_response=True`) for stateless API-style scaling.
Best for: remote servers, shared services, monetized APIs, multi-node deployments.

**HTTP+SSE** *(legacy — backward compat only)* — Two endpoints: `GET /sse` to open the stream, `POST /messages?sessionId=…` to send. Deprecated in spec 2025-03-26 and superseded by Streamable HTTP; the SDK still ships `SSEServerTransport` so you can host `/sse` alongside `/mcp` for clients that predate Streamable HTTP. Do not build new servers SSE-first — see the dual-transport appendix in §2c.

### Message Flow (Streamable HTTP — current)

```
Client                          Server   (single endpoint, e.g. POST/GET/DELETE /mcp)
  |--- POST /mcp (initialize) ---->|  server may return Mcp-Session-Id response header
  ||  with Mcp-Session-Id header
  ||  optional: server→client notifications, resumable
  |--- DELETE /mcp --------------->|  end the session
```

### JSON-RPC Protocol

Every MCP message is JSON-RPC 2.0:

```json
// Request
{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"screenshot","arguments":{"url":"https://example.com"}}}

// Response
{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"Screenshot captured successfully"}]}}
```

---

## 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 as a compat alias,
//  but registerTool is the documented best practice — 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.resource(
  "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.prompt(
  "analyze-domain",
  "Analyze a domain's DNS, SSL, and WHOIS info",
  { 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 = {};

// 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(J

…

## Source & license

This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.

- **Author:** [san-npm](https://github.com/san-npm)
- **Source:** [san-npm/skills-ws](https://github.com/san-npm/skills-ws)
- **License:** MIT
- **Homepage:** https://skills-ws.vercel.app

Install and usage instructions live in the source repository linked above.

## Pricing

- **Free** — Free


## Versions

- **0.1.0** — security scan: flagged — Imported from the upstream source.

## Links

- Listing page: https://agentstack.voostack.com/l/skill-san-npm-skills-ws-mcp-server-builder
- Seller: https://agentstack.voostack.com/s/san-npm
- Browse the marketplace: https://agentstack.voostack.com/browse

---
Listed on AgentStack — the marketplace for AI agent skills and MCP servers. Every listing is security-reviewed. Creators keep 70%.
