Install
$ agentstack add skill-san-npm-skills-ws-mcp-server-builder Open-source listing, not yet scanned by AgentStack. Follow the source repository for install instructions.
Security review
⚠ Flagged1 finding(s); flagged for manual review. · v0.1.0 How review works →
- • Prompt-injection patterns
- • Secret / credential exfiltration
- • Dangerous shell & filesystem operations
- • Untrusted network calls
- • Known-malicious package signatures
- high Reads credentials/environment and may exfiltrate them.
What it can access
- ● Network access Used
- ✓ Filesystem access No
- ✓ Shell / process execution No
- ● Environment & secrets Used
- ✓ Dynamic code execution No
From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.
Reliability & compatibility
Declared compatibility
Compatibility is declared by the source manifest. End-to-end runtime verification is coming, see below.
We're building live execution health for every listing: tool-call success rate, median latency, uptime, and last-checked timestamps, measured, not self-reported. It isn't live yet, so we don't show numbers we can't stand behind.
How agent discovery & health will work →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,mcpPython 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:
// 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
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
// tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": true
},
"include": ["src"]
}
// 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
#!/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.
// 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.
// 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.
Reviews
No reviews yet, be the first.
Write a review
Versions
- v0.1.0 Imported from the upstream source.