Large Language ModelsGenerate imagesGenerate videos
MCP Server TypeScript Tutorial: SDK Example and Template You Can Copy
A working MCP server in TypeScript, from npm install to Claude Code. Copy the SDK template, register a tool, a resource and a prompt, choose stdio or Streamable HTTP, test in the Inspector, then add image and video tools that call a real API without blocking the client.
You can connect a language model to your own code in roughly forty lines. This MCP server TypeScript tutorial builds exactly that: a working server on the official SDK, a project template you can copy, and the two transports that matter, stdio for local clients and Streamable HTTP for remote ones. You will end up with a tool, a resource and a prompt, tested in the Inspector and registered in Claude Code. Then we add image and video tools on top, because that is where a Model Context Protocol server stops being a demo and starts doing real work. Every snippet runs on Node.js 20 or newer with the @modelcontextprotocol/sdk package.
What an MCP Server Does
The Model Context Protocol (MCP) is an open standard that lets an AI client, such as Claude Code, Claude Desktop or an IDE agent, call functions and read data that live inside your process. Messages travel as JSON-RPC 2.0. Your server announces what it offers, the client lists those capabilities, and the model decides when to use them. Your code never talks to the model directly. It answers requests, and that is why the server stays small.
Three Building Blocks
Every MCP server is made from some mix of three primitives:
Block
Who decides to use it
Typical use
Tool
The model
Query a database, call an API, generate an image
Resource
The application or the user
Expose a document, a file or a config as readable context
Prompt
The user
A reusable template such as "review this pull request"
Tools do most of the work in practice. A tool is a named function with a typed input schema and a text or image result. Resources and prompts are optional, but they cost almost nothing to add once the server exists.
Client, Server and Transport
The transport is only the pipe the JSON-RPC messages move through. The same McpServer object works over stdio or HTTP, so the right structure is to build the server in one function and attach a transport in a separate entry file. The template below follows that rule, which keeps tests simple and lets you ship both transports from one codebase.
Set Up the TypeScript Project
Install the SDK and zod
Create a folder and install the dependencies. The SDK uses zod for input schemas: it converts them to JSON Schema for the client and validates every incoming argument before your handler runs.
mcp-notes-server/
src/
index.ts stdio transport and startup
http.ts Streamable HTTP transport
server.ts buildServer() factory
package.json
tsconfig.json
💡 SDK imports end with .js, even in TypeScript files. With Node16 resolution the compiler requires explicit extensions, and a missing one shows up at runtime as ERR_MODULE_NOT_FOUND.
Build the Server Template
The Server Factory
Put everything the server offers in src/server.ts. This version registers one tool that saves a note and one that looks a note up:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
const notes = new Map<string, string>();
export function buildServer(): McpServer {
const server = new McpServer({ name: "notes-server", version: "1.0.0" });
server.registerTool(
"add_note",
{
title: "Add note",
description: "Save a short note under a unique id. Overwrites an existing id.",
inputSchema: {
id: z.string().min(1).describe("Unique id, for example 'standup-0612'"),
text: z.string().min(1).max(2000).describe("The note body"),
},
},
async ({ id, text }) => {
notes.set(id, text);
return { content: [{ type: "text", text: `Saved note ${id}` }] };
}
);
server.registerTool(
"get_note",
{
title: "Get note",
description: "Return the text of a saved note by id.",
inputSchema: { id: z.string().min(1).describe("The note id") },
},
async ({ id }) => {
const text = notes.get(id);
if (text === undefined) {
return { isError: true, content: [{ type: "text", text: `No note with id ${id}` }] };
}
return { content: [{ type: "text", text }] };
}
);
// resources and prompts go here (next section)
return server;
}
Two details matter more than they look. First, the description is what the model reads when it decides whether to call the tool, so write it like documentation for a colleague. Second, when something fails, return isError: true with a readable message instead of throwing. The model can then retry with a corrected argument or explain the problem to the user.
Add a Resource and a Prompt
Replace the placeholder comment with this:
server.registerResource(
"all-notes",
"notes://all",
{
title: "All notes",
description: "Every saved note as JSON",
mimeType: "application/json",
},
async (uri) => ({
contents: [{ uri: uri.href, text: JSON.stringify([...notes.entries()]) }],
})
);
server.registerPrompt(
"summarize-notes",
{
title: "Summarize notes",
description: "Ask for a short summary of the saved notes",
argsSchema: { tone: z.string().optional() },
},
({ tone }) => ({
messages: [
{
role: "user",
content: {
type: "text",
text: `Summarize my saved notes in a ${tone ?? "neutral"} tone.`,
},
},
],
})
);
Resources are addressed by URI (notes://all), and clients typically show them in a picker so the user can attach them as context. Prompts appear as slash commands or menu entries, depending on the client.
Let a Coding Model Help
Once this template runs, a coding model can add tools in minutes. Paste src/server.ts into a chat and ask for a new tool that follows the same pattern: schema first, isError on failure. Claude Sonnet 5, Kimi K2.6 and GPT 5.6 Sol are all built for code work and available on PicassoIA. Read the generated schema before you accept it: a model will happily make a field optional that should be required.
Choose a Transport
stdio for Local Clients
stdio is the simplest transport. The client launches your server as a child process and talks to it through stdin and stdout. Create src/index.ts:
#!/usr/bin/env node
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { buildServer } from "./server.js";
const server = buildServer();
await server.connect(new StdioServerTransport());
console.error("notes-server ready on stdio");
💡 Never use console.log in a stdio server. Stdout carries the protocol, so one stray log line corrupts the stream and the client disconnects with a parse error. Send logs to stderr with console.error.
Streamable HTTP for Remote Clients
For a server that lives on a host instead of a laptop, use Streamable HTTP. It replaced the older HTTP plus SSE transport and needs a single endpoint. Install Express with npm install express and npm install -D @types/express, then save this as src/http.ts:
import express from "express";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { buildServer } from "./server.js";
const app = express();
app.use(express.json());
app.post("/mcp", async (req, res) => {
const server = buildServer();
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
res.on("close", () => {
transport.close();
server.close();
});
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
});
app.listen(3000, () => console.error("MCP endpoint on http://localhost:3000/mcp"));
Setting sessionIdGenerator: undefined runs the transport in stateless mode: a fresh server per request, no session to track, and horizontal scaling like any other API. If you need server-initiated notifications or resumable streams, switch to stateful mode with session ids.
stdio
Streamable HTTP
Runs where
Child process of the client
Any host reachable over HTTP
Authentication
Inherits the user's environment
You add it (OAuth or bearer tokens)
Best for
Personal and local developer tools
Shared and hosted servers
Scaling
One process per client
Stateless, scale like an API
Test Before You Connect
Run the MCP Inspector
The Inspector is the official debugging UI. Build the project and launch your server through it:
npm run build
npx @modelcontextprotocol/inspector node dist/index.js
Open the local URL it prints, press Connect, then use the Tools tab to list tools and run add_note with a JSON argument. The history pane shows the raw JSON-RPC traffic, which is the fastest way to spot a schema that does not match what you meant.
Register in Claude Code and Claude Desktop
Claude Code registers a local server with one command, and an HTTP server with a transport flag:
claude mcp add notes -- node /absolute/path/mcp-notes-server/dist/index.js
claude mcp add --transport http notes-remote http://localhost:3000/mcp
Claude Desktop reads a JSON file instead (claude_desktop_config.json):
💡 Use absolute paths in both. A relative path resolves against the client's working directory, not yours, and the failure message rarely says so.
Add Image and Video Tools
A notes server proves the pattern. Media tools show why it pays off: slow jobs, large outputs and an external API with its own limits. PicassoIA exposes a Replicate-style developer API at https://api.picassoia.com/v1, authenticated with a Bearer token that starts with pia_sk_. Four models are reachable through the API and the MCP connector: PicassoIA Image for text to image, PicassoIA Image Editor Pro for edits, PicassoIA Video for video, and Seedance 2.5 Lite for video with audio. Jobs are asynchronous: you create a prediction, poll it, then read the result.
Wrap an Image Endpoint
Two small helpers serve every model, because the API shape is the same:
const API = "https://api.picassoia.com/v1";
const headers = {
Authorization: `Bearer ${process.env.PICASSOIA_API_TOKEN}`,
"Content-Type": "application/json",
};
export async function createPrediction(model: string, input: Record<string, unknown>) {
const res = await fetch(`${API}/models/${model}/predictions`, {
method: "POST",
headers,
body: JSON.stringify({ input }),
signal: AbortSignal.timeout(15_000),
});
if (!res.ok) throw new Error(`Create failed: HTTP ${res.status}`);
return (await res.json()) as { id: string };
}
export async function getPrediction(id: string) {
const res = await fetch(`${API}/predictions/${id}`, { headers });
if (!res.ok) throw new Error(`Status failed: HTTP ${res.status}`);
return (await res.json()) as { status: string; output?: unknown; error?: string };
}
The image tool uses them and waits up to two minutes for a result:
server.registerTool(
"generate_image",
{
title: "Generate image",
description: "Create an image from a text prompt and return its URL.",
inputSchema: { prompt: z.string().min(10).max(4000) },
},
async ({ prompt }) => {
try {
const { id } = await createPrediction("picassoia/picassoia-image", { prompt });
for (let i = 0; i < 60; i++) {
const job = await getPrediction(id);
if (job.status === "succeeded") {
return { content: [{ type: "text", text: JSON.stringify(job.output) }] };
}
if (job.status === "failed") throw new Error(job.error ?? "Generation failed");
await new Promise((r) => setTimeout(r, 2000));
}
throw new Error("Timed out waiting for the image");
} catch (err) {
return { isError: true, content: [{ type: "text", text: String(err) }] };
}
}
);
The 4,000 character cap in the schema matches the API's prompt limit, and each model page lists its exact input fields and output format. The API also allows 5 concurrent predictions per account, shared across tokens and MCP connections, so queue parallel tool calls instead of firing them all at once.
Handle Slow Video Jobs
Video takes far longer than an image, and a tool call that blocks for minutes can hit the client's own timeout. Split the work into two tools: one starts the job and returns its id at once, the other checks it.
server.registerTool(
"start_video",
{
title: "Start video",
description: "Start a video job from a prompt. Returns a prediction id to check later.",
inputSchema: { prompt: z.string().min(10).max(4000) },
},
async ({ prompt }) => {
const { id } = await createPrediction("picassoia/picassoia-video", { prompt });
return { content: [{ type: "text", text: JSON.stringify({ predictionId: id }) }] };
}
);
server.registerTool(
"check_video",
{
title: "Check video",
description: "Return the status and output of a video job by prediction id.",
inputSchema: { predictionId: z.string().min(1) },
},
async ({ predictionId }) => {
const job = await getPrediction(predictionId);
return { content: [{ type: "text", text: JSON.stringify(job) }] };
}
);
The model calls start_video, does other work, and polls check_video until the status reads succeeded. Nothing blocks, and a failed job is just another status to report. For video with audio, switch the model to picassoia/seedance-2.5-lite (Seedance 2.5 Lite); the helpers do not change.
Ship It Safely
Validate Inputs and Guard Secrets
Treat every tool argument as untrusted. A model can be steered by text it reads on the web or in a file, so an injected page can ask your tool to do something you never intended. Three habits cut most of the risk:
Bound every field in zod: min, max, enum, and regex for ids.
Never pass arguments into a shell command or a SQL string. Use parameterized queries and confine file paths to one base directory.
Read tokens from environment variables, never hard-code them, and never echo them back in a tool result.
For stdio clients, set secrets in the env block of the client config. For HTTP servers, require an Authorization header and verify it before handleRequest runs.
Fix the Three Usual Mistakes
console.log on stdio. Switch it to console.error.
Missing .js extensions. An import like ./server fails at runtime under Node16.
Vague descriptions. A tool named run with the description "does stuff" never gets chosen, or gets chosen with the wrong arguments. Name it for the action and say when to use it.
To publish, keep the shebang line at the top of src/index.ts, run npm run build, then npm publish. Anyone can register it with claude mcp add notes -- npx -y mcp-notes-server. HTTP servers ship as a container or run on any Node host.
Try It on Picasso IA
You now have a template that runs locally, runs remotely, and can call image and video models. The fastest way to see what those tools return is to try the models by hand first. Open PicassoIA Image and write a prompt as specific as the ones you would send from generate_image: subject, lens, light, setting. Then try PicassoIA Video or Seedance 2.5 Lite to animate the idea, and note which phrasing gives the result you want before you hard-code defaults into your server. Pick any model from the full catalog at picassoia.com/en/all-models and wire it in with the same two helpers. Create your own images on Picasso IA today, and let your first MCP tool call hand you the result.