How to Build an MCP Server for Your API with Ollama and Kimi
Listen to this article
Preparing audio…
AI Engineering
An API becomes useful to an AI system when the model can discover a small capability, supply valid arguments, and receive a result it can reason about. That does not mean handing an OpenAPI document and an administrator token to the model. I prefer a narrow MCP adapter that preserves the API's existing authorization and domain rules.
This guide builds that adapter with the official TypeScript MCP SDK, connects it to Codex or another MCP host, and shows the tool loop needed when Ollama or an open Kimi model is the model layer. The example API is synthetic, but the boundary is the one I use for real systems: the model proposes; the application validates and decides.

Start with the boundary, not the SDK
MCP defines three server capabilities: resources, tools, and prompts. A resource is readable context, a tool performs an action or query, and a prompt is a reusable interaction template. For an existing API, I normally begin with tools because they give the model a named operation with an explicit input schema.
The first design exercise is not “How do I convert every endpoint?” It is “Which business questions should the model be allowed to ask?” An internal API with 180 operations may need only three initial tools:
orders.get_statusreads one authoritative order;orders.searchsearches within the caller's tenant and a fixed time window;orders.prepare_cancellationvalidates a cancellation proposal without executing it.
I do not expose raw URLs, arbitrary HTTP methods, SQL, access tokens, or a generic call_api tool. A narrow tool gives me a stable contract, a smaller authorization surface, better descriptions for the model, and meaningful audit events.
Scaffold the MCP server
The current official TypeScript SDK uses separate server and client packages. A local stdio server needs Node.js 20 or later:
mkdir api-mcp && cd api-mcp
npm init -y
npm pkg set type=module
npm install @modelcontextprotocol/server zod tsx
mkdir -p src/plugins
Create src/index.ts. The API URL and token come from the process environment; they are never arguments the model can choose.
import { McpServer } from '@modelcontextprotocol/server';
import { serveStdio } from '@modelcontextprotocol/server/stdio';
import * as z from 'zod/v4';
const API_BASE = new URL(process.env.API_BASE ?? 'https://api.example.com');
const API_TOKEN = process.env.API_TOKEN;
if (!API_TOKEN) throw new Error('API_TOKEN is required');
const Order = z.object({
id: z.string(),
status: z.enum(['pending', 'confirmed', 'cancelled']),
updatedAt: z.string()
});
function createServer(): McpServer {
const server = new McpServer({ name: 'orders-api', version: '1.0.0' });
server.registerTool(
'orders.get_status',
{
title: 'Get order status',
description: 'Read the authoritative status of one order visible to the caller.',
inputSchema: z.object({
orderId: z.string().regex(/^ord_[a-zA-Z0-9]+$/)
}),
outputSchema: Order,
annotations: { readOnlyHint: true, idempotentHint: true }
},
async ({ orderId }) => {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
try {
const url = new URL(`/v1/orders/${encodeURIComponent(orderId)}`, API_BASE);
const response = await fetch(url, {
headers: { Authorization: `Bearer ${API_TOKEN}` },
signal: controller.signal
});
if (response.status === 404) {
return { content: [{ type: 'text', text: 'Order not found.' }], isError: true };
}
if (!response.ok) {
return {
content: [{ type: 'text', text: `Upstream API failed with HTTP ${response.status}.` }],
isError: true
};
}
const order = Order.parse(await response.json());
return {
content: [{ type: 'text', text: JSON.stringify(order) }],
structuredContent: order
};
} finally {
clearTimeout(timeout);
}
}
);
return server;
}
void serveStdio(createServer);
console.error('orders-api MCP server running on stdio');
One Zod schema produces the JSON Schema advertised to clients and validates arguments before the handler executes. The output schema gives clients a machine-readable contract as well. I still parse the upstream response because an API returning malformed data should fail at the adapter boundary, not become confident model context.
The readOnlyHint and idempotentHint annotations improve client presentation; they are not security controls. The server and the upstream API must enforce identity, tenant scope, role, current state, rate limits, and domain invariants.
Add tools as explicit plugins
As the server grows, I keep each business capability in a small module. “Plugin” here means application code registered deliberately at startup—not arbitrary packages downloaded and executed because a model requested them.
// src/plugins/types.ts
import type { McpServer } from '@modelcontextprotocol/server';
export type ToolPlugin = {
name: string;
register(server: McpServer): void;
};
// src/plugins/index.ts
import { orderStatusPlugin } from './order-status.js';
import { customerSummaryPlugin } from './customer-summary.js';
export const plugins = [orderStatusPlugin, customerSummaryPlugin];
const server = new McpServer({ name: 'operations-api', version: '1.0.0' });
for (const plugin of plugins) plugin.register(server);
This layout gives every plugin an owner, tests, schemas, endpoint allowlist, authorization policy, and version. A plugin registry can load configuration, but I avoid scanning an untrusted directory and importing whatever file appears there.
Test before connecting a model
The official MCP Inspector runs through npx and lets you list tools, inspect schemas, and call handlers directly:
API_BASE=https://sandbox.example.com \
API_TOKEN=replace-locally \
npx @modelcontextprotocol/inspector npx tsx src/index.ts
Test valid calls, malformed identifiers, unauthorized records, timeouts, unexpected upstream JSON, and concurrent requests. Verify that logs contain trace identifiers and decisions but never raw bearer tokens or excessive personal data.
For stdio, stdout belongs to JSON-RPC. A single console.log can corrupt the protocol stream, so operational logging goes to stderr or a separate sink.
Register it in Codex or another host
Codex can register the same local command and pass environment variables to the child process:
codex mcp add orders-api \
--env API_BASE=https://sandbox.example.com \
--env API_TOKEN=replace-locally \
-- npx tsx /absolute/path/api-mcp/src/index.ts
Do not commit the real token or place it in an article, shell history, or model prompt. In production, prefer a short-lived workload identity or secret manager and bind authorization to the authenticated user rather than one shared super-token.
VS Code uses the same command in .vscode/mcp.json:
{
"servers": {
"orders-api": {
"type": "stdio",
"command": "npx",
"args": ["tsx", "src/index.ts"]
}
}
}
The host performs tools/list, gives the names, descriptions, and schemas to the model, then sends the selected tools/call to the MCP server. The model does not call the REST API directly.
Where Ollama fits
Ollama exposes a local chat API at http://localhost:11434/api/chat and supports tool definitions and tool-call responses. Ollama is the model runtime, not the MCP server. A host or a small bridge must translate MCP tools into the function schema sent to Ollama, execute selected calls through the MCP client, append tool results to the conversation, and ask the model to continue.
The core loop looks like this:
import { Client } from '@modelcontextprotocol/client';
import { StdioClientTransport } from '@modelcontextprotocol/client/stdio';
const mcp = new Client({ name: 'ollama-host', version: '1.0.0' });
await mcp.connect(new StdioClientTransport({
command: 'npx', args: ['tsx', 'src/index.ts']
}));
const { tools } = await mcp.listTools();
const ollamaTools = tools.map(tool => ({
type: 'function',
function: {
name: tool.name,
description: tool.description,
parameters: tool.inputSchema
}
}));
const response = await fetch('http://localhost:11434/api/chat', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
model: process.env.OLLAMA_MODEL ?? 'qwen3:8b',
messages: [{ role: 'user', content: 'What is the status of ord_1042?' }],
tools: ollamaTools,
stream: false
})
});
const turn = await response.json();
for (const call of turn.message.tool_calls ?? []) {
const result = await mcp.callTool({
name: call.function.name,
arguments: call.function.arguments
});
// Append the assistant turn and this tool result, then call Ollama again.
}
A production host adds the complete message loop, cancellation, maximum tool-call depth, per-tool approval, result-size limits, telemetry, and a hard allowlist. Never dispatch call.function.name into eval, a shell, or a dynamic import.
Where open Kimi fits
Moonshot AI publishes Kimi model code and weights under a Modified MIT licence. Kimi K2 is designed for tool use, but its published architecture has one trillion total parameters with 32 billion activated parameters. Open weights do not mean laptop-scale. Running the full model locally requires serious inference infrastructure; Moonshot documents engines such as vLLM and SGLang.
If an Ollama installation exposes a Kimi model with tool support, the bridge above changes only OLLAMA_MODEL. If Kimi is served through an OpenAI-compatible endpoint, the same MCP client can sit behind an OpenAI-compatible host loop. The MCP server itself does not change because model selection belongs above the tool boundary.
For a genuinely local developer workstation, start with a smaller Ollama model marked as tool-capable. Use Kimi when its serving footprint and operating model fit the environment. Do not market a cloud-routed model as locally hosted simply because the command starts with ollama.
What this architecture actually costs
MCP itself does not add a licence fee. The official SDK is open source, and the adapter in this guide is a small process. The material costs sit around it: remote compute, model inference, logs, secrets, identity, testing, and the engineering time needed to keep tool contracts aligned with the API.
The following prices were checked on 21 July 2026 and are reference points, not quotations. They exclude tax, storage, observability, managed identity, network charges, support, and staff time.
| Deployment choice | Published entry price | What the number includes | What it does not include |
|---|---|---|---|
| Local stdio MCP + local Ollama | $0 incremental software cost | MCP process and Ollama Free on hardware you already own | Hardware purchase, electricity, backups, developer time, or adequate RAM/GPU capacity |
| Ollama cloud-assisted use | $0 Free; $20/month Pro; $100/month Max | Usage allowance and concurrency vary by plan | Your MCP hosting, API platform, logs, and production support |
| Small remote MCP VM | DigitalOcean Droplets start at $4/month | A basic general-purpose VM; suitable as a cost floor | Production redundancy, database, load balancer, backups, monitoring, or model inference |
| Serverless remote adapter | Cloudflare Workers Free, or $5/month minimum for Paid | Worker execution and the plan's included usage | Model tokens, external API charges, persistent services, and engineering controls |
| Self-hosted open Kimi | Model licence may be $0; infrastructure is not | You control model serving and data path | GPU capacity, idle headroom, replicas, model loading, power, operations, and incident response |
The choice is therefore not “free versus paid.” It is where the cost and operational responsibility land. A local Ollama prototype can have no new invoice and still consume a workstation. A $4 VM can run a lightweight adapter, but not a large open-weight model. A remote MCP endpoint also needs TLS, workload identity, secret rotation, rate limiting, telemetry, patching, and an availability design.
A cost model I can defend
For a production estimate, I separate fixed platform cost from usage-driven model cost:
monthly cost = adapter compute
+ identity, secrets, logs, and storage
+ (input tokens / 1,000,000 × input rate)
+ (output tokens / 1,000,000 × output rate)
+ operational ownership
Consider an illustrative workload of 50,000 tool-assisted turns per month, averaging 2,000 input tokens and 500 output tokens per turn. That is 100 million input tokens and 25 million output tokens. At hypothetical rates of $0.60 per million input tokens and $2.50 per million output tokens, inference would be:
| Cost component | Calculation | Illustrative monthly cost |
|---|---|---|
| Input tokens | 100 × $0.60 | $60.00 |
| Output tokens | 25 × $2.50 | $62.50 |
| Model subtotal | $60.00 + $62.50 | $122.50 |
| Adapter and controls | Add the chosen hosting, telemetry, secrets, and support | Variable |
Those token prices are deliberately illustrative so the method remains valid when providers change their rates. Replace them with the current rate for the exact model and region, then measure real prompts rather than trusting a demo average. Tool descriptions, schemas, conversation history, retries, and repeated agent loops all consume tokens. Cache discounts and batch rates should be modelled separately, not assumed.
For self-hosted inference I use a different denominator: cost per successful, policy-compliant task, not cost per token. I include amortized hardware or GPU rental, electricity, average and p95 latency, concurrency, utilization, failed tool loops, on-call work, and the spare capacity required during maintenance. Open weights remove a usage invoice; they do not remove capacity planning.
Measure value and failure together
A research-grade pilot needs a baseline and a holdout. I compare the MCP-assisted workflow with the current API or manual workflow on the same task set and record:
| Measure | Why it matters |
|---|---|
| Successful tasks / attempted tasks | Prevents a cheap but unreliable model from looking efficient |
| Cost / successful task | Joins model, adapter, and retry cost to an outcome |
| p50 and p95 completion time | Shows whether the tail makes the workflow unusable |
| Tool calls and retries / task | Reveals loops, weak descriptions, and schema mismatch |
| Unauthorized or over-broad attempts | Tests the capability boundary, not only answer quality |
| Human corrections / task | Measures the operational burden hidden by a polished demo |
| Trace completeness | Confirms that a result can be reconstructed and challenged |
I would not approve a production rollout from answer-quality scores alone. The evidence package should include the task set, model and prompt versions, tool-schema versions, raw outcome categories, cost assumptions, excluded costs, failure examples, and the date on which provider prices were retrieved.
Production controls I would not skip
Before exposing a real API, I require:
- A capability allowlist. No generic URL, method, SQL, shell, or unrestricted search tool.
- Two validation layers. MCP schema validation at the adapter and domain validation in the API.
- Caller-bound authorization. Tenant, role, legal entity, record scope, and purpose remain explicit.
- Separate read and write tools. A read tool cannot become a write through a hidden query parameter.
- Preparation before material effects. High-impact changes produce a reviewable proposal before execution.
- Idempotency and state checks. Retries cannot create duplicate effects, and stale proposals fail closed.
- Output minimization. Return only fields needed for the model's task; redact secrets and unnecessary personal data.
- Bounded execution. Deadlines, concurrency limits, response-size limits, and maximum tool-call depth.
- Evidence. Record authenticated subject, tool, schema version, decision, trace ID, result class, and timing.
- Versioned contracts. A breaking domain change creates a new tool or contract version.
The patterns in AI Agents in Regulated FinTech, Responsible AI Governance as an Engineering System, and Resilient Payment APIs apply even when the example is not financial. MCP gives the model a clean interface; it does not absolve the system from engineering discipline.
The design test
I consider the adapter ready when I can replace Ollama with Kimi—or replace both with another host—without changing authorization, endpoint mapping, domain rules, or audit evidence. That is the practical value of MCP: the model layer can evolve while the capability boundary stays deliberate.
References
- Model Context Protocol — Introduction
- Model Context Protocol — Build an MCP Server
- Official MCP TypeScript SDK
- MCP TypeScript SDK — Tool Contracts
- Model Context Protocol — Inspector
- Ollama API — Tool Calling
- Ollama — Pricing
- Moonshot AI — Kimi K2 Repository
- Moonshot AI — Kimi K2 Tool-Calling Guidance
- DigitalOcean — Droplet Pricing
- Cloudflare Workers — Pricing
What decision would you make?
Add a question, a field note, or a respectful counterpoint. Comments are for signed-in members so the discussion stays useful and professional.