← Back to Index

Lesson 6

MCP Servers: Building

Why Build Your Own

You'd build an MCP server when you want to give Kiro access to something it can't reach with built-in tools - an internal API, a bespoke service, a database with custom query patterns, or any workflow that's specific to your team.

Real examples:

The Architecture

An MCP server is any process that speaks the MCP protocol (JSON-RPC over stdio or HTTP). It can be written in any language - TypeScript/Node and Python are most common because they have official SDKs, but community SDKs exist for Go, Rust, Java, C#, and others. We'll use the TypeScript SDK here since it's the most common for web development teams.

Regardless of language, every MCP server does three things:

  1. Starts up and listens over a transport (stdio or HTTP)
  2. Responds to a discovery request - telling the client what tools/resources/prompts it offers
  3. Handles tool calls - receives parameters, does work, returns results

Communication uses JSON-RPC. But you don't need to implement that yourself - the SDK handles it.

The Protocol Under the Hood

The SDK abstracts this away, but understanding what's on the wire helps you debug issues and reason about performance. MCP is built on JSON-RPC 2.0 - every message is a JSON object with a method name, parameters, and an ID for request/response matching.

The Connection Lifecycle

When Kiro connects to your MCP server, this sequence happens:

Client (Kiro) Server (your code) INITIALIZATION initialize "What version? What can you do?" initialize response "I support tools + resources" notifications/initialized "OK, we're live" DISCOVERY tools/list "What tools do you have?" tools/list response "Here are my tools + schemas" SESSION ACTIVE tools/call "Run 'get-sprint-summary'" tools/call response "Here's the result" SHUTDOWN shutdown "We're done"

The MCP connection lifecycle: initialize, discover tools, call tools during the session, then shut down. All messages are JSON-RPC over stdio.

What the messages look like

1. Initialize request (Kiro → Server):

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "initialize",
  "params": {
    "protocolVersion": "2025-06-18",
    "capabilities": { "roots": { "listChanged": true } },
    "clientInfo": { "name": "Kiro", "version": "0.11.0" }
  }
}

2. Initialize response (Server → Kiro):

{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "protocolVersion": "2025-06-18",
    "capabilities": { "tools": {}, "resources": {} },
    "serverInfo": { "name": "my-team-tools", "version": "1.0.0" }
  }
}

The capabilities field is the negotiation - the server declares what it supports (tools, resources, prompts). Kiro only asks for things the server said it can do.

3. Tool discovery (Kiro → Server):

{
  "jsonrpc": "2.0",
  "id": 2,
  "method": "tools/list"
}

4. Tool list response (Server → Kiro):

{
  "jsonrpc": "2.0",
  "id": 2,
  "result": {
    "tools": [{
      "name": "get-sprint-summary",
      "description": "Get a summary of the current sprint for a Jira project",
      "inputSchema": {
        "type": "object",
        "properties": {
          "projectKey": { "type": "string", "description": "The Jira project key" }
        },
        "required": ["projectKey"]
      }
    }]
  }
}

This is what gets loaded into Kiro's context window - every tool's name, description, and full schema. This is why more tools = more context cost.

5. Tool call (Kiro → Server):

{
  "jsonrpc": "2.0",
  "id": 3,
  "method": "tools/call",
  "params": {
    "name": "get-sprint-summary",
    "arguments": { "projectKey": "LMT" }
  }
}

6. Tool result (Server → Kiro):

{
  "jsonrpc": "2.0",
  "id": 3,
  "result": {
    "content": [{ "type": "text", "text": "Sprint summary for LMT:\n- 5 stories in progress..." }]
  }
}

Key takeaways from the protocol

When this matters: If your MCP server is slow to respond or crashes, Kiro will see a timeout or error on the JSON-RPC response. If your server returns malformed JSON, the connection breaks. The SDK prevents most of these issues, but if you're debugging connectivity problems, knowing the protocol helps you inspect the raw messages.

Setup: Project Scaffolding

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

Create a tsconfig.json:

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

Add to package.json:

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

A Complete Working Server

Here's a minimal server that exposes one tool - a Jira issue summariser that takes a project key and returns a formatted summary. This shows all the moving parts:

Create src/index.ts:

#!/usr/bin/env node
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";

// 1. Create the server
const server = new McpServer({
  name: "my-team-tools",
  version: "1.0.0",
});

// 2. Register a tool
server.tool(
  // Tool name (what appears in Kiro's tool list)
  "get-sprint-summary",
  // Description (helps the model decide when to use it)
  "Get a summary of the current sprint for a Jira project",
  // Input schema using Zod
  {
    projectKey: z.string().describe("The Jira project key, e.g. 'LMT'"),
  },
  // Handler function
  async ({ projectKey }) => {
    // Your logic here - call an API, query a DB, whatever
    // This is a placeholder; replace with real Jira API call
    const summary = `Sprint summary for ${projectKey}:\n` +
      `- 5 stories in progress\n` +
      `- 3 stories completed\n` +
      `- 2 blocked items`;

    return {
      content: [{ type: "text", text: summary }],
    };
  }
);

// 3. Connect transport and start
const transport = new StdioServerTransport();
await server.connect(transport);

The Three Key Concepts

1. McpServer - the container

Creates a server instance with a name and version. This is what clients see when they connect.

2. server.tool() - registering capabilities

Each call to server.tool() registers one tool. It takes:

3. StdioServerTransport - how it communicates

For local servers, stdio is the standard. The server reads JSON-RPC from stdin and writes responses to stdout. Kiro launches the process and handles the plumbing.

Ad

Adding More Tools

Just call server.tool() multiple times:

server.tool(
  "search-components",
  "Search the Angular component library documentation",
  {
    query: z.string().describe("Search term, e.g. 'datepicker'"),
    category: z.enum(["form", "layout", "navigation", "data"]).optional()
      .describe("Optional category filter"),
  },
  async ({ query, category }) => {
    // Search your component docs API
    const results = await searchComponentDocs(query, category);
    return {
      content: [{ type: "text", text: JSON.stringify(results, null, 2) }],
    };
  }
);

server.tool(
  "create-component-scaffold",
  "Generate boilerplate for a new Angular component following team patterns",
  {
    name: z.string().describe("Component name in kebab-case, e.g. 'user-profile'"),
    standalone: z.boolean().default(true).describe("Whether to create a standalone component"),
  },
  async ({ name, standalone }) => {
    const scaffold = generateComponentBoilerplate(name, standalone);
    return {
      content: [{ type: "text", text: scaffold }],
    };
  }
);

Building and Testing

Build

npm run build

Test locally

You can test with the MCP Inspector (a debugging tool):

npx @modelcontextprotocol/inspector node dist/index.js

This opens a web UI where you can invoke tools and see responses.

Connect to Kiro

Add to your .kiro/settings/mcp.json:

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

Or if you've published it to npm:

{
  "mcpServers": {
    "my-team-tools": {
      "command": "npx",
      "args": ["my-team-tools"]
    }
  }
}

Adding Resources

Resources make static data available without the model needing to "decide" to fetch it:

server.resource(
  "component-guidelines",
  "file:///docs/component-guidelines",
  async (uri) => ({
    contents: [{
      uri: uri.href,
      mimeType: "text/markdown",
      text: "# Component Guidelines\n\nAll components must be standalone..."
    }]
  })
);

Adding Prompts

Prompts are reusable templates users can invoke explicitly:

server.prompt(
  "code-review",
  { file: z.string().describe("Path to the file to review") },
  ({ file }) => ({
    messages: [{
      role: "user",
      content: {
        type: "text",
        text: `Review ${file} for: security issues, performance problems, and Angular best practices. Be specific about line numbers.`
      }
    }]
  })
);

Environment Variables

Pass secrets via the env field in mcp.json, then read them in your server:

// In your server code
const apiKey = process.env.MY_API_KEY;
if (!apiKey) throw new Error("MY_API_KEY not set");
// In mcp.json
{
  "mcpServers": {
    "my-team-tools": {
      "command": "node",
      "args": ["/path/to/dist/index.js"],
      "env": {
        "MY_API_KEY": "secret-value"
      }
    }
  }
}

The Full Development Loop

  1. Scaffold - npm init, install SDK + zod
  2. Define tools - server.tool() with Zod schemas and handlers
  3. Build - tsc
  4. Test - MCP Inspector (npx @modelcontextprotocol/inspector)
  5. Connect - add to mcp.json
  6. Use - Kiro sees the tools and calls them when relevant
  7. Iterate - add tools, rebuild, Kiro reconnects automatically
Hot reload: After changing and rebuilding your server, Kiro reconnects to MCP servers automatically when the config changes. Or you can manually reconnect from the MCP Servers panel in the Kiro sidebar.

When to Build vs. Use an Existing Server

Build your own when...Use an existing server when...
The API/service is internal to your orgIt's a public service (GitHub, Jira, Slack)
You need custom logic or formattingThe standard tools cover your needs
You want to wrap multiple APIs into one cohesive interfaceA single existing server covers it
You need team-specific toolingThe community has already built it

Your Tangible Win

You can now build a custom MCP server from scratch in TypeScript: scaffold the project, register tools with Zod schemas, test with MCP Inspector, and connect to Kiro. You know the full development loop and when building your own makes sense vs. using an existing server.

Quiz

What does Zod do in an MCP server?

Handles the JSON-RPC transport layer
Defines and validates the input schema for each tool's parameters
Manages authentication with the MCP client

What transport should you use for a local MCP server that Kiro launches?

StdioServerTransport - communicates over stdin/stdout
HttpServerTransport - listens on a port
WebSocketTransport - persistent bidirectional connection

How do you test an MCP server before connecting it to Kiro?

Run it and check the console output
Use the MCP Inspector (npx @modelcontextprotocol/inspector)
Write unit tests for the JSON-RPC handlers