MCPTypeScriptGitHub CopilotVS CodeAI

Build a Custom MCP Server with TypeScript to Extend GitHub Copilot

Sloth255
Sloth255
·8 min read·1,795 words

Introduction

MCP (Model Context Protocol) is an Anthropic protocol for connecting AI models to external tools and data sources. It is supported by GitHub Copilot, Claude, Cursor, and other AI clients.

Build an MCP server when you need Copilot to call an internal API or run project-specific operations.

This article explains how to implement an MCP server with TypeScript and connect it to GitHub Copilot in VS Code, covering setup, tool design, and operations.


MCP Fundamentals

flowchart TD
  A[VS Code Copilot Agent] -->|MCP| B[MCP Server]
  B --> C[External Services]
  B --> D[API]
  B --> E[Data Store]

An MCP server exposes three primary capabilities.

Type Description Examples
Tools Operations an LLM can invoke API calls, file operations
Resources Readable data and context Documents, configuration files
Prompts Reusable prompt templates Code review, incident investigation

MCP uses JSON-RPC over stdio or Streamable HTTP.


MCP Design Philosophy and Architecture

Why MCP Was Created

Before MCP, each AI client needed its own integration with external systems. Supporting the same internal API in multiple AI tools meant building a separate integration for every client.

[Before MCP]
GitHub Copilot -> Custom integration A -> Internal API
Claude         -> Custom integration B -> Internal API (duplicate)
Cursor         -> Custom integration C -> Internal API (duplicate)

[With MCP]
GitHub Copilot -+
Claude         +-> MCP Server -> Internal API (one implementation)
Cursor         -+

MCP standardizes these integrations. One server can expose the same capability to multiple clients, although the available functionality still depends on each client’s supported MCP version, transport, and authentication methods.

The Core Principle: LLMs Reason, Servers Execute

MCP separates decision-making from execution.

  • LLM (such as Copilot): Interprets context and chooses a tool and its arguments
  • MCP server: Executes the tool and returns its result

An LLM does not call external systems directly. Each request goes through an explicit tool interface, allowing the server to centralize authentication, validation, and logging.

sequenceDiagram
  participant U as User
  participant L as LLM (Copilot)
  participant S as MCP Server
  participant A as External API

  U->>L: "List open Issues"
  L->>S: tools/call search_github_issues
  S->>A: GET /search/issues
  A-->>S: JSON result
  S-->>L: Tool result
  L-->>U: Answer

Communication with JSON-RPC 2.0

MCP uses JSON-RPC 2.0. After connecting, the client and server complete these three exchanges.

Step Method Description
1 initialize Client and server exchange protocol versions and capabilities
2 tools/list Client retrieves tool names and schemas
3 tools/call Client requests a tool invocation

Here is a request and response for search_github_issues:

// Request
{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/call",
  "params": {
    "name": "search_github_issues",
    "arguments": {
      "query": "authentication",
      "repo": "owner/my-repo",
      "state": "open"
    }
  }
}
// Response
{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "content": [
      {
        "type": "text",
        "text": "[{\"number\": 42, \"title\": \"Fix OAuth token refresh\", ...}]"
      }
    ]
  }
}

The LLM receives content, turns it into a natural-language response, and returns it to the user.

The Role of Transports

MCP defines two standard transports: stdio and Streamable HTTP. HTTP+SSE remains for backward compatibility, but new HTTP-based implementations should use Streamable HTTP.

stdio Streamable HTTP
Mechanism Runs the server as a subprocess and communicates through standard input/output Sends and receives JSON-RPC through HTTP POST / GET, with SSE streaming when needed
Best for Local development and individual use Team sharing, centralized management, and multiple clients
Startup cost Low; the server can start per invocation Assumes a persistent service

For local use in VS Code, stdio is usually the right choice. “Choosing stdio or Streamable HTTP” returns to this decision later.


When to Build MCP Instead of Using Skills

GitHub Copilot in VS Code supports two Markdown-based customization mechanisms. Custom instruction files (.github/copilot-instructions.md, .instructions.md, and others) provide guidance to the AI, while agent skills (SKILL.md) package reusable workflows, procedures, scripts, and supporting resources. This article refers to both as “instruction-based customization.” Assess whether they are sufficient before building an MCP server.

Cases Solved by Instruction-Based Customization

  • You need to give the AI static information, such as coding conventions or project structure
  • You want to reuse a known procedure as a prompt template
  • You need a workflow with no external-system access

Instruction-based customization needs no dedicated server. An Agent can run Skill scripts through existing tools such as the terminal, but execution availability, approvals, and output format depend on the host environment. Start by considering this option.

Skills Can Also Teach How to Call an External API

For an “external API” requirement, a Skill can document the request and ask Copilot to generate code or a curl command.

<!-- Example: SKILL.md -->
The internal status API endpoint is https://api.internal/status.
For authentication, specify a Bearer token in the Authorization header.
curl invocation example: curl -H "Authorization: Bearer $TOKEN" https://api.internal/status

This works when the goal is code generation. Where an Agent can call an API through an existing terminal or extension, the execution procedure, approval, and result handling still depend on the host.

The Boundary Where MCP Is Needed

Use MCP when Skills no longer provide enough control.

Situation Instruction-based customization MCP
Teach Copilot how to call an API Yes
Define an API execution procedure using host-provided tools Yes
Expose an API as a typed, dedicated tool No Yes
Standardize authentication, input validation, and result format on the server No Yes
Expose the same capability to multiple MCP clients No Yes

Skills tell an Agent what to do and in which order. MCP exposes a typed, dedicated tool contract that clients can invoke. The server executes the call and returns a defined result to Copilot’s context. That control and portability are the key distinction.

Cases That Need an MCP Server

Build an MCP server when instruction-based customization alone cannot adequately control requirements such as these.

Requirement Reason
Use API results in Copilot reasoning Results enter the context directly for follow-up questions and transformations
Access authenticated resources Tokens and credentials stay in environment variables rather than being exposed to users
Perform write or update operations Creates Issues, updates labels, or changes records with side effects
Require real-time data Fetches current CI status or the latest DB values that cannot live in static files
Handle large or dynamic data Handles documents too large for prompts or data that changes frequently
Perform complex processing or conversions Runs file parsing, aggregation, and format conversion in code
Share across projects or teams Centrally manages tools used by multiple developers and repositories

Decision Flow

flowchart TD
  A[Task to automate] --> B{Need an<br/>external system?}
  B -->|No| C[Use instruction-based customization]
  B -->|Yes| D{Must Copilot receive the result<br/>and reason over it?}
  D -->|No - code generation is enough| C
  D -->|Yes| E{Authentication, side effects,<br/>or dynamic data involved?}
  E -->|No| F[Check for an existing<br/>MCP server]
  E -->|Yes| G[Build an MCP server]
  F -->|None available| G

The decisive question is whether the server must enforce a dedicated tool contract, authentication, and input validation.


Setup

mkdir my-mcp-server
cd my-mcp-server
npm init -y
npm install @modelcontextprotocol/server zod
npm install -D typescript @types/node tsx
// tsconfig.json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "Node16",
    "moduleResolution": "Node16",
    "outDir": "./dist",
    "strict": true
  },
  "include": ["src/**/*"]
}
// package.json (excerpt)
{
  "type": "module",
  "scripts": {
    "dev": "tsx src/index.ts",
    "build": "tsc",
    "start": "node dist/index.js"
  }
}

Implementing a Basic MCP Server

// src/index.ts
import { McpServer } from '@modelcontextprotocol/server'
import { serveStdio } from '@modelcontextprotocol/server/stdio'
import * as z from 'zod/v4'
import { fetchWeather } from './tools/weather.js'

interface GithubIssueSearchResponse {
  items: Array<{
    number: number
    title: string
    state: string
    html_url: string
  }>
}

function createServer(): McpServer {
  const server = new McpServer({
    name: 'my-custom-tools',
    version: '1.0.0',
  })

  server.registerTool(
    'get_weather',
    {
      description: 'Gets the current weather for the specified city',
      inputSchema: z.object({
        city: z.string().describe('City to look up the weather for (e.g., Tokyo, Osaka)'),
      }),
    },
    async ({ city }) => {
      const weather = await fetchWeather(city)
      return {
        content: [{
          type: 'text',
          text: `Current weather in ${city}: ${weather.description}, temperature: ${weather.temp}°C`,
        }],
      }
    }
  )

  server.registerTool(
    'search_github_issues',
    {
      description: 'Searches issues in a GitHub repository',
      inputSchema: z.object({
        query: z.string().describe('Search query'),
        repo: z.string().regex(/^[^/]+\/[^/]+$/).describe('Repository name (owner/repo format)'),
        state: z.enum(['open', 'closed', 'all']).default('open').describe('Issue state'),
      }),
    },
    async ({ query, repo, state }) => {
      const token = process.env.GITHUB_TOKEN
      if (!token) {
        return {
          content: [{ type: 'text', text: 'GITHUB_TOKEN is not configured' }],
          isError: true,
        }
      }

      const stateQualifier = state === 'all' ? '' : ` state:${state}`
      const url = new URL('https://api.github.com/search/issues')
      url.searchParams.set(
        'q',
        `${query} is:issue repo:${repo}${stateQualifier}`
      )

      const response = await fetch(url.toString(), {
        headers: {
          Authorization: `Bearer ${token}`,
          Accept: 'application/vnd.github.v3+json',
        },
      })

      if (!response.ok) {
        return {
          content: [{ type: 'text', text: `GitHub API error: ${response.status}` }],
          isError: true,
        }
      }

      const data = await response.json() as GithubIssueSearchResponse
      const issues = data.items.slice(0, 5).map((issue) => ({
        number: issue.number,
        title: issue.title,
        state: issue.state,
        url: issue.html_url,
      }))

      return {
        content: [{ type: 'text', text: JSON.stringify(issues, null, 2) }],
      }
    }
  )

  return server
}

void serveStdio(createServer)
console.error('MCP server started')

Practical Tool Implementation Examples

Weather API (External API Call)

// src/tools/weather.ts
interface WeatherData {
  description: string
  temp: number
  humidity: number
}

export async function fetchWeather(city: string): Promise<WeatherData> {
  const apiKey = process.env.OPENWEATHER_API_KEY
  if (!apiKey) throw new Error('OPENWEATHER_API_KEY is not configured')

  const url = `https://api.openweathermap.org/data/2.5/weather?q=${encodeURIComponent(city)}&appid=${apiKey}&units=metric&lang=ja`

  const response = await fetch(url)
  if (!response.ok) {
    throw new Error(`Failed to fetch weather data: ${response.statusText}`)
  }

  const data = await response.json()
  return {
    description: data.weather[0].description,
    temp: Math.round(data.main.temp),
    humidity: data.main.humidity,
  }
}

Internal Document Search (Local Files)

// src/tools/docs-search.ts
import * as fs from 'fs/promises'
import * as path from 'path'
import { pathToFileURL } from 'url'

interface DocResult {
  title: string
  excerpt: string
  url: string
}

export async function searchDocs(
  query: string,
  limit: number
): Promise<DocResult[]> {
  const docsDir = process.env.DOCS_DIR ?? './docs'
  const files = await fs.readdir(docsDir)
  const results: DocResult[] = []

  for (const file of files.filter((f) => f.endsWith('.md'))) {
    const content = await fs.readFile(path.join(docsDir, file), 'utf-8')

    if (content.toLowerCase().includes(query.toLowerCase())) {
      const lines = content.split('\n')
      // Skip the frontmatter (the opening --- block) and use the first H1 as the title
      let bodyStart = 0
      if (lines[0]?.trim() === '---') {
        const end = lines.findIndex((l, i) => i > 0 && l.trim() === '---')
        bodyStart = end >= 0 ? end + 1 : 0
      }
      const h1 = lines.slice(bodyStart).find((l) => /^#\s/.test(l))
      const title = h1 ? h1.replace(/^#\s*/, '') : path.basename(file, '.md')
      const matchIndex = content.toLowerCase().indexOf(query.toLowerCase())
      const excerpt = content.slice(
        Math.max(0, matchIndex - 50),
        matchIndex + 150
      )

      results.push({
        title,
        excerpt,
        url: pathToFileURL(path.resolve(docsDir, file)).toString(),
      })

      if (results.length >= limit) break
    }
  }

  return results
}

Implementing Resources

Resources expose read-only data for an AI to reference. They suit material the model should consult consistently, such as internal policies, operating procedures, and API documentation.

// Add these imports at the top of src/index.ts
import { readFile } from 'node:fs/promises'
import path from 'node:path'

// Register before return server in createServer
server.registerResource(
  'project-guide',
  'docs://project-guide',
  {
    title: 'Project Guide',
    description: 'A project guide to reference during development',
    mimeType: 'text/markdown',
  },
  async (uri) => {
  const docsDir = process.env.DOCS_DIR ?? path.join(process.cwd(), 'docs')
  const content = await readFile(
    path.join(docsDir, 'project-guide.md'),
    'utf-8'
  )

  return {
    contents: [
      {
        uri: uri.href,
        mimeType: 'text/markdown',
        text: content,
      },
    ],
  }
  }
)

Useful Resources include:

  • Design guides and coding guidelines
  • Operations runbooks
  • API-specification summaries
  • Internal FAQs

Resources make this material available without pasting it into every chat.


Using Prompts

MCP can expose Prompts as well as Tools. Use them to standardize recurring procedures or review perspectives.

  • Security-review prompts
  • PR-description prompts
  • Initial incident-investigation prompts

Tools perform actions; Prompts capture reusable reasoning patterns.


Connecting to VS Code

// .vscode/mcp.json
{
  "servers": {
    "my-custom-tools": {
      "type": "stdio",
      "command": "node",
      "args": ["${workspaceFolder}/dist/index.js"],
      "env": {
        "GITHUB_TOKEN": "${env:GITHUB_TOKEN}",
        "OPENWEATHER_API_KEY": "${env:OPENWEATHER_API_KEY}"
      }
    }
  }
}

During development, run the entry point directly with tsx to skip the build step.

{
  "servers": {
    "my-custom-tools-dev": {
      "type": "stdio",
      "command": "npx",
      "args": ["tsx", "${workspaceFolder}/src/index.ts"]
    }
  }
}

Using It in Copilot Chat

After restarting VS Code, the MCP tools appear in Copilot Chat.

"Show the weather in Tokyo"
  -> Copilot automatically calls the get_weather tool

"Find open Issues about \"memory leak\""
  -> Copilot automatically calls the search_github_issues tool

Prefix a request with a server name to limit the available tools.

#my-custom-tools List open Issues about "authentication" in owner/my-repo
  -> Calls search_github_issues through this server

Debugging Tips

# Test tools interactively with MCP Inspector
npx @modelcontextprotocol/inspector npx tsx src/index.ts

Open the URL printed by Inspector in a browser to inspect tool calls and return values.


Tool Design Best Practices

Begin With What a Tool Must Not Do

MCP Tools are useful, but overly broad tools create unnecessary risk.

Overbroad tool Narrow tool
manage_project list_open_issues, update_issue_labels
run_anything get_pipeline_status
execute_sql search_customers

Tools are easier to operate when their names make side effects clear, their inputs stay small, and their failure behavior is easy to explain.

Design inputSchema Carefully

A tool input schema is both validation and an LLM-facing interface. Vague descriptions make arguments harder to infer reliably.

const updateIssueInput = {
  type: 'object',
  properties: {
    repo: { type: 'string', pattern: '^[^/]+/[^/]+$' },
    issueNumber: { type: 'integer', minimum: 1 },
    labels: {
      type: 'array',
      items: { type: 'string' },
      maxItems: 10,
    },
  },
  required: ['repo', 'issueNumber', 'labels'],
  additionalProperties: false,
}

Guidelines:

  • Write precise description values
  • Use enum where possible
  • Declare required explicitly
  • Use additionalProperties: false to reject unintended parameters

Separate Zod and JSON Schema Responsibilities

inputSchema defines the external contract; Zod revalidates values at runtime. Do not omit server-side revalidation for tools that call an external API or database.

case 'update_issue': {
  // Describe the model-facing shape with JSON Schema,
  // then validate the accepted values with Zod
  const input = z.object({
    repo: z.string().regex(/^[^/]+\/[^/]+$/),
    issueNumber: z.number().int().positive(),
    labels: z.array(z.string()).max(10),
  }).parse(args)
  // ...
}

Error-Handling Principles

Classify errors into at least these three categories.

  • Input errors (validation failures)
  • Authentication / authorization errors
  • External-service failures
function toToolErrorMessage(error: unknown): string {
  if (error instanceof z.ZodError) {
    return 'Invalid input. Check the required fields and value formats.'
  }

  if (error instanceof Error) {
    return `Tool execution failed: ${error.message}`
  }

  return 'Tool execution failed.'
}

Error messages should not expose unnecessary internal detail and should make the next action clear.


Handling Credentials

Never pass secrets through prompts or arguments. Read them from environment variables or a secure execution environment.

Do Do not
Read API tokens from env Include secrets in tool arguments
Use least-privilege tokens Write tokens to logs
Separate production and development environments Include secrets in error messages

Keep Permissions Minimal

  • Use a read-only token for GitHub Issue searches
  • Use a SELECT-only user for a database-reading tool
  • Use workflow-read permission only for a deployment-check tool

For team use, separate read-only tools, update tools, and production-oriented tools.


Operational Design

Timeouts, Retries, and Partial Failures

Code that works in a local experiment can fail in production. Do not assume every request succeeds.

async function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
  return await Promise.race([
    promise,
    new Promise<T>((_, reject) => {
      setTimeout(() => reject(new Error(`Timed out after ${ms}ms`)), ms)
    }),
  ])
}

Baseline safeguards:

  • Timeouts
  • Retry limits
  • Rate limiting
  • Error-message normalization

Observability (Logging Design)

A log that only records what was called is not enough for debugging. Record:

  • Tool name
  • Invocation time and duration
  • Success or failure and the failure category

Limit what you log: exclude personal data, access tokens, complete SQL statements, and entire external API responses.

Testing Strategy

Keep tool implementations in functions so their logic can be tested outside the MCP protocol.

// Keep business logic separate for testing
export async function searchGithubIssues(input: SearchIssuesInput) {
  // Implementation
}

server.registerTool(
  'search_github_issues',
  { inputSchema: SearchIssuesInputSchema },
  async (input) => formatResult(await searchGithubIssues(input))
)

Suggested test layers:

  1. Unit tests for input validation
  2. Unit tests for tool handlers
  3. Integration tests including the transport
  4. Manual verification with MCP Inspector

Choosing stdio or Streamable HTTP

Perspective stdio Streamable HTTP
Local setup Very easy Somewhat heavier
Central auditing Weak Strong
Authentication governance Depends on individual users Easier
Debugging Easy More network factors
Distribution npm / binary Service URL

Evaluate usability with stdio first. Consider Streamable HTTP when you need team sharing or centralized management. For HTTP exposure, implement Origin validation, localhost binding, and authentication.


Splitting MCP Servers

A single server tends to accumulate responsibilities. Grouping tools by units such as these makes operations easier.

  • GitHub-related tools
  • Internal API-related tools
  • Database-read tools
  • Resource-only servers for documentation

It also simplifies permissions and environment-variable management.


Common Failure Patterns

1. Building an Unbounded General-Purpose Tool

Designs such as run_anything or execute_sql (arbitrary SQL) reduce both safety and reproducibility.

2. Making inputSchema Too Permissive

The model cannot construct reliable arguments, leading to more failed calls.

3. Accepting Secrets as Arguments

They can leak through logs or conversation paths.

4. Combining Read and Update Operations Under One Permission

This makes operational incidents more likely.

5. Embedding Tool Implementations Directly in Handlers

This makes them hard to test and maintain.


A Practical Adoption Sequence

For a first MCP server, use this sequence.

  1. Build a local-only server with stdio
  2. Create only one or two read-only tools
  3. Add Resources / Prompts
  4. Improve logging and error handling
  5. Carefully add update tools only when needed

This progression balances safety and utility.

Good initial candidates include:

  • Project configuration lookup
  • Internal-document search
  • CI status checks
  • PR / Issue search

Add update tools only after user trust and audit design are in place.


Summary

  • MCP servers use JSON-RPC over stdio or Streamable HTTP, and @modelcontextprotocol/server supports straightforward TypeScript implementations
  • Tools perform actions, Resources provide reference data, and Prompts capture reusable reasoning patterns for Copilot
  • Registering a server in .vscode/mcp.json makes it available to GitHub Copilot in VS Code
  • Start tool design by deciding what not to allow; a precise inputSchema improves argument inference
  • Store secrets in environment variables and keep permissions minimal
  • Begin with stdio and one or two read-only tools, then expand after establishing a track record

References