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.

MCP Server TypeScript Tutorial: SDK Example and Template You Can Copy
Cristian Da Conceicao
Founder of Picasso IA

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.

A developer typing TypeScript on a laptop at a wooden desk in soft morning light

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:

BlockWho decides to use itTypical use
ToolThe modelQuery a database, call an API, generate an image
ResourceThe application or the userExpose a document, a file or a config as readable context
PromptThe userA 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.

mkdir mcp-notes-server && cd mcp-notes-server
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node

Configure package.json and tsconfig

Switch the project to ES modules and add a build script:

{
  "type": "module",
  "bin": { "mcp-notes-server": "dist/index.js" },
  "files": ["dist"],
  "scripts": {
    "build": "tsc",
    "start": "node dist/index.js"
  }
}

Then add a tsconfig.json that matches how Node resolves modules:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "Node16",
    "moduleResolution": "Node16",
    "outDir": "dist",
    "rootDir": "src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true
  },
  "include": ["src/**/*"]
}

The folder layout is deliberately boring:

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.

Top-down view of a tidy desk with a notebook sketching a project folder tree

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.

A hand drawing connected boxes and arrows on a whiteboard in a bright meeting room

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.

stdioStreamable HTTP
Runs whereChild process of the clientAny host reachable over HTTP
AuthenticationInherits the user's environmentYou add it (OAuth or bearer tokens)
Best forPersonal and local developer toolsShared and hosted servers
ScalingOne process per clientStateless, scale like an API

Low-angle view down a narrow aisle of server racks with neatly routed network cables

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):

{
  "mcpServers": {
    "notes": {
      "command": "node",
      "args": ["/absolute/path/mcp-notes-server/dist/index.js"]
    }
  }
}

💡 Use absolute paths in both. A relative path resolves against the client's working directory, not yours, and the failure message rarely says so.

A developer at a standing desk with two monitors, focused on debugging a terminal

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.

A designer's desk with printed mountain landscape photos, a tablet and color swatches

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.

A video editor's workstation with a blurred timeline of sunset footage and a plain clapperboard

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.

A hand inserting a brushed steel hardware security token into a laptop

Fix the Three Usual Mistakes

  1. console.log on stdio. Switch it to console.error.
  2. Missing .js extensions. An import like ./server fails at runtime under Node16.
  3. 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.

Four colleagues reviewing a laptop together around a sunlit table in a co-working space

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.

Share this article