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:
- A server that queries your team's internal documentation wiki
- A server that interacts with your deployment pipeline
- A server that reads/writes from a shared team config service
- A server that wraps your Angular component library's API docs
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:
- Starts up and listens over a transport (stdio or HTTP)
- Responds to a discovery request - telling the client what tools/resources/prompts it offers
- 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:
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
- Capability negotiation - client and server agree on what's supported before anything else happens. This is why your server can expose only tools, only resources, or any combination.
- Stateless tool calls - each
tools/callis independent. The server doesn't need to remember previous calls (though it can if you want). - The context cost is in the discovery - the
tools/listresponse (with all schemas) is what Kiro injects into context. The actual tool calls happen at runtime and only their results use tokens. - JSON-RPC means request/response - every request has an ID, and the response matches that ID. This is how stdio (which is a raw stream) maintains order.
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:
- name - identifier (what hooks match against with
@mcp) - description - helps the model decide when to call it
- input schema - Zod schema defining the parameters
- handler - async function that does the work and returns results
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.
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
- Scaffold -
npm init, install SDK + zod - Define tools -
server.tool()with Zod schemas and handlers - Build -
tsc - Test - MCP Inspector (
npx @modelcontextprotocol/inspector) - Connect - add to
mcp.json - Use - Kiro sees the tools and calls them when relevant
- Iterate - add tools, rebuild, Kiro reconnects automatically
When to Build vs. Use an Existing Server
| Build your own when... | Use an existing server when... |
|---|---|
| The API/service is internal to your org | It's a public service (GitHub, Jira, Slack) |
| You need custom logic or formatting | The standard tools cover your needs |
| You want to wrap multiple APIs into one cohesive interface | A single existing server covers it |
| You need team-specific tooling | The 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.