Large Language ModelsGenerate imagesGenerate videos

Create an MCP Server for Claude Code and GitHub Copilot

Build a single TypeScript MCP server and register it in both Claude Code and GitHub Copilot. You get working tool code, the exact config for each client, a debugging routine with the MCP Inspector, and an image tool that calls the PicassoIA API.

Create an MCP Server for Claude Code and GitHub Copilot
Cristian Da Conceicao
Founder of Picasso IA

You wrote a script that saves you ten minutes a day, and now you want your AI assistant to run it without a copy and paste detour. Build it once as a Model Context Protocol server and both Claude Code and GitHub Copilot can call the same tools, because MCP is the shared language they use to talk to anything outside the editor. This walkthrough builds a small TypeScript server, registers it in Claude Code, registers it in Copilot inside VS Code, and finishes with a real image tool that calls the PicassoIA API. Plan on about 40 minutes and roughly 100 lines of code.

Why One Server Beats Two

Before MCP, every assistant demanded its own plugin format, its own manifest and its own packaging rules. An MCP server replaces that pile with a single process that advertises what it can do. The client starts the process, asks for its list of capabilities, and hands that list to the model. The model then decides, mid-conversation, when a call is worth making.

Overhead view of a desk with a hand-drawn diagram of boxes and arrows beside a laptop and coffee

Same Protocol, Two Clients

A server can expose three kinds of capability:

  • Tools: functions the model can call, such as add_note or generate_image.
  • Resources: read-only data the client can attach to a conversation, such as a log file or a schema.
  • Prompts: reusable templates the user triggers on purpose.

Tools are where almost all the value sits today, so this article stays focused on them. Both Claude Code and Copilot speak the same JSON-RPC messages over the same transports, which means a server that passes one client will pass the other with almost no changes.

Developer standing beside a whiteboard filled with sticky notes arranged in three columns

Where the Setups Differ

The server is identical. The registration is not. Here is the whole difference in one table:

SettingClaude CodeGitHub Copilot in VS Code
Config file.mcp.json in the project, or ~/.claude.json.vscode/mcp.json, or your user profile
Root propertymcpServersservers
Add from the terminalclaude mcp addCommand Palette: MCP: Add Server
Transport fieldtype (stdio, http, sse)type is required (stdio or http)
Secrets--env flag or ${VAR} expansioninputs block with ${input:id}
Where tools runAny sessionAgent mode only

💡 Tip: The root property is the classic trap. Paste a Claude Code config into VS Code unchanged and nothing loads, because Copilot looks for servers, not mcpServers.

Set Up the Project

Pick a folder outside your main repository so the server can serve several projects later. You need Node.js 20 or newer and a terminal.

Close-up of a developer's hands typing, with a blurred code editor behind

Install the SDK

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

Open package.json and add "type": "module" plus two scripts, "build": "tsc" and "dev": "tsx src/index.ts". Then create a tsconfig.json:

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

The examples use the @modelcontextprotocol/sdk 1.x API with McpServer and registerTool. If a newer major version changes an import path, the concepts below stay the same.

Start With stdio

MCP defines two main transports. stdio means the client launches your server as a child process and exchanges messages through standard input and output. Streamable HTTP means the server runs on its own and clients connect by URL. Start with stdio. It needs no port, no authentication layer and no hosting, and both clients support it out of the box. Move to HTTP only when several people must share one running instance.

Write the Server

Our example is a tiny team notes server with two tools: one saves a note, one searches them. It is small enough to read in a minute and real enough to be useful.

Two developers sitting side by side while one points at a laptop screen

Register a Tool

Create src/index.ts:

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { promises as fs } from "node:fs";
import path from "node:path";

const NOTES_FILE = path.join(process.env.NOTES_DIR ?? process.cwd(), "notes.json");

type Note = { id: number; text: string; tags: string[]; createdAt: string };

async function readNotes(): Promise<Note[]> {
  try {
    return JSON.parse(await fs.readFile(NOTES_FILE, "utf8"));
  } catch {
    return [];
  }
}

const server = new McpServer({ name: "team-notes", version: "1.0.0" });

server.registerTool(
  "add_note",
  {
    title: "Add note",
    description:
      "Save a short engineering note with optional tags. Use it when the user asks to remember a decision, a command or a bug.",
    inputSchema: {
      text: z.string().min(3).max(2000),
      tags: z.array(z.string()).default([]),
    },
  },
  async ({ text, tags }) => {
    const notes = await readNotes();
    const note: Note = {
      id: notes.length + 1,
      text,
      tags,
      createdAt: new Date().toISOString(),
    };
    await fs.writeFile(NOTES_FILE, JSON.stringify([...notes, note], null, 2));
    return { content: [{ type: "text", text: `Saved note #${note.id}` }] };
  }
);

server.registerTool(
  "search_notes",
  {
    title: "Search notes",
    description: "Find saved notes whose text or tags contain the query.",
    inputSchema: { query: z.string().min(1) },
  },
  async ({ query }) => {
    const q = query.toLowerCase();
    const hits = (await readNotes()).filter(
      (n) =>
        n.text.toLowerCase().includes(q) ||
        n.tags.some((t) => t.toLowerCase().includes(q))
    );
    const text = hits.length
      ? hits.map((n) => `#${n.id} [${n.tags.join(", ")}] ${n.text}`).join("\n")
      : "No notes matched.";
    return { content: [{ type: "text", text }] };
  }
);

await server.connect(new StdioServerTransport());
console.error("team-notes MCP server running on stdio");

Run npm run build. You now have dist/index.js, and that file is the only thing the two clients need to know about.

Return Clean Results

The model reads whatever you return, so treat the return value as an interface. Keep results short, structured and honest. When something fails, do not throw an exception that dies in the transport layer. Return an error the model can read and react to:

return {
  isError: true,
  content: [{ type: "text", text: "notes.json is not valid JSON. Fix or delete it." }],
};

An isError result lets the assistant explain the problem to you or retry with different input. A crash just shows a vague "server disconnected" banner.

Keep stdout Silent

This is the single most common reason a first server fails. With stdio, standard output belongs to the protocol. One stray console.log injects plain text into the JSON-RPC stream and the client drops the connection. Log to console.error, which writes to stderr, and both clients will capture it as diagnostic output instead.

💡 Tip: Write tool descriptions as instructions to the model, not as documentation for humans. "Use it when the user asks to remember a decision" gets the tool picked at the right moment. "Notes utility" does not.

Connect Claude Code

Macro shot of a terminal window on a laptop screen reflected in a pair of glasses

Add It With the CLI

One command registers the server. Options go before the name, and a double dash separates the name from the command Claude Code will launch:

claude mcp add --transport stdio --scope user \
  --env NOTES_DIR=/home/dev/notes \
  team-notes -- node /absolute/path/to/team-notes-mcp/dist/index.js

Use an absolute path. Claude Code starts the process from whatever directory your session uses, so relative paths break the moment you open a different project. Then confirm it:

claude mcp list
claude mcp get team-notes

Inside a session, type /mcp to see connection status and the list of tools. Ask something natural, like "Remember that we deploy on Thursdays, tag it release", and watch Claude Code request permission to call add_note.

Share It Through .mcp.json

The scope flag decides who gets the server. local keeps it private to you in one project, user makes it available in every project, and project writes a .mcp.json file you can commit so the whole team gets it. Here is a shared config that avoids hardcoded paths:

{
  "mcpServers": {
    "team-notes": {
      "type": "stdio",
      "command": "node",
      "args": ["${TEAM_NOTES_PATH}/dist/index.js"],
      "env": { "NOTES_DIR": "${NOTES_DIR:-.notes}" }
    }
  }
}

Each teammate sets TEAM_NOTES_PATH once in their shell. The ${NOTES_DIR:-.notes} form supplies a default when the variable is missing. Claude Code asks for approval the first time it sees a project-scoped server, which is a sensible safeguard for anything pulled from a repository.

Connect GitHub Copilot

Developer leaning back in an office chair smiling at two monitors in late afternoon light

Write .vscode/mcp.json

Create .vscode/mcp.json in your workspace. Remember the different root property and the required type:

{
  "servers": {
    "team-notes": {
      "type": "stdio",
      "command": "node",
      "args": ["${workspaceFolder}/tools/team-notes-mcp/dist/index.js"],
      "env": { "NOTES_DIR": "${workspaceFolder}/.notes" }
    }
  }
}

${workspaceFolder} makes the file portable, so you can commit it. For secrets, add an inputs array. VS Code prompts once, stores the value securely and injects it:

{
  "inputs": [
    { "type": "promptString", "id": "picassoia-token", "description": "PicassoIA API token", "password": true }
  ],
  "servers": {
    "team-notes": {
      "type": "stdio",
      "command": "node",
      "args": ["${workspaceFolder}/tools/team-notes-mcp/dist/index.js"],
      "env": { "PICASSOIA_API_TOKEN": "${input:picassoia-token}" }
    }
  }
}

Switch to Agent Mode

Copilot Chat opens in Ask mode by default, and MCP tools only fire in Agent mode. Switch the mode in the chat panel, open the tools picker, and confirm that team-notes shows up with both tools ticked. If it does not appear, run MCP: List Servers from the Command Palette, pick the server, and read its output. Restart it from the same menu after every rebuild.

Copilot also lets you choose among the models your plan offers, so the same server gets exercised by different models. That is a cheap way to check whether your tool descriptions are clear enough for every one of them.

Test and Debug Before Shipping

Side profile of a bearded developer in a beanie at a standing desk beside a rainy window

Run the MCP Inspector

Before blaming either client, test the server alone. The official Inspector opens a local web page where you can list tools, fill in arguments and see raw responses:

npx @modelcontextprotocol/inspector node dist/index.js

Call add_note with an empty text. Your Zod schema should reject it with a readable validation message. Then call search_notes with a tag you just saved. If both behave here, any remaining problem lives in client configuration, not in your code.

Fix the Usual Failures

SymptomLikely causeFix
Server never connectsA console.log wrote to stdoutSwitch to console.error
"Command not found"Relative path or missing buildUse an absolute path and run npm run build
Tools missing in CopilotChat is in Ask modeSwitch to Agent mode
Tool exists but is never chosenVague descriptionRewrite it with trigger phrases
Empty environment variableNot declared in the configAdd it to the env block
Image calls fail under loadMore than 5 jobs at onceQueue calls inside the tool

Give Your Server an Image Tool

Notes are fine, but the best demonstration of MCP is a tool that does something the assistant cannot do alone. Image generation is a good fit: the model writes a precise prompt, your server turns it into a file, and the URL lands straight back in the conversation.

Overhead view of four people pointing at printed diagrams around a long oak table

Call the PicassoIA API

The PicassoIA developer API lives at https://api.picassoia.com/v1 and uses a Bearer token that starts with pia_sk_. Predictions are asynchronous and Replicate-style: you create one, then poll it until the status reads succeeded. The PicassoIA Image model accepts a prompt of up to 4,000 characters, an aspect_ratio, and returns a list of image URLs. Add this tool before the server.connect line:

const API = "https://api.picassoia.com/v1";
const headers = {
  Authorization: `Bearer ${process.env.PICASSOIA_API_TOKEN}`,
  "Content-Type": "application/json",
};

server.registerTool(
  "generate_image",
  {
    title: "Generate image",
    description: "Create an image from a text prompt with PicassoIA and return its URL.",
    inputSchema: {
      prompt: z.string().min(1).max(4000),
      aspect_ratio: z.enum(["1:1", "16:9", "9:16", "4:3", "3:4", "3:2", "2:3"]).default("16:9"),
    },
  },
  async ({ prompt, aspect_ratio }) => {
    const created = await fetch(`${API}/models/picassoia/picassoia-image/predictions`, {
      method: "POST",
      headers,
      body: JSON.stringify({ input: { prompt, aspect_ratio } }),
    }).then((r) => r.json());

    let prediction = created;
    while (["starting", "processing"].includes(prediction.status)) {
      const wait = prediction.eta?.next_poll_in_seconds ?? 2;
      await new Promise((resolve) => setTimeout(resolve, wait * 1000));
      prediction = await fetch(`${API}/predictions/${created.id}`, { headers }).then((r) => r.json());
    }

    if (prediction.status !== "succeeded") {
      return {
        isError: true,
        content: [{ type: "text", text: `Generation ${prediction.status ?? "request failed"}` }],
      };
    }
    return { content: [{ type: "text", text: prediction.output[0] }] };
  }
);

An account allows 5 concurrent predictions, shared across every token and connection, so a loop that fires ten images at once will hit that ceiling. Generate them one after another inside the tool, or keep a small queue.

💡 Tip: Check the current pricing and plan requirements on the PicassoIA API page before you publish a server for others. The docs and the pricing page describe access differently, so confirm what your own plan includes.

How to Use Sonnet 5 on PicassoIA

Your tool descriptions are prompts, and a language model is the best editor for them. Claude Sonnet 5 is a strong pick for this job, and you can run it on PicassoIA without leaving the browser.

  1. Open the Claude Sonnet 5 page in the large language models collection.
  2. Paste your tool definitions, including names, descriptions and schemas, into the prompt.
  3. Ask it to rewrite each description as a short instruction that states when to call the tool and what it returns.
  4. Ask for ten edge case inputs per tool, such as empty strings, very long text and unusual tags.
  5. Run those inputs through the MCP Inspector, fix every failure, and paste the improved descriptions back into your code.

For a second opinion on tricky logic, Claude Fable 5 and GPT 5.6 Sol are both listed for coding tasks. Comparing their rewrites of one description often reveals which phrasing is ambiguous.

Try It Yourself on PicassoIA

You now have one server running in two assistants: notes for memory, an image tool for output, and a testing routine that keeps both honest. The same pattern scales to anything you can wrap in a function, from deploy scripts to database lookups.

Laptop and flat white coffee on a café table with a smartphone beside them

Start with the image tool, because it gives instant feedback. Write a prompt, call generate_image from Claude Code or Copilot, and see what comes back in seconds. Then open the PicassoIA Image page and experiment with aspect ratios and prompt styles directly, or browse every available model at picassoia.com/en/all-models. Your first image is a single prompt away.

Share this article