Build an MCP server
An MCP server lets your assistant use several of your system's actions at once, such as searching products, listing a customer's orders and starting a return. This guide builds one from a working sample.
Updated
On this page
REST tool or MCP server?
#| REST tool | MCP server | |
|---|---|---|
| Best for | One or two existing API endpoints | A set of actions you want to manage in one place |
| You write | Nothing: describe the endpoint in the dashboard | A small server that lists and runs tools |
| Tool descriptions | Written in the dashboard | Written in your code, next to the logic |
| Changes | Edit each tool in the dashboard | Deploy your server, then press Refresh tools |
Kothadesk supports remote MCP servers over Streamable HTTP. Servers that only speak stdio or the older SSE transport cannot be connected.
The sample server
#A small online shop with three tools, written with the official MCP Python SDK (a Node.js version follows). Replace the in-memory data with calls to your own database or API.
| Tool | Type | What it shows |
|---|---|---|
| search_products | Read | A public lookup anyone can use |
| get_my_orders | Read, signed-in only | Using the signed-in customer's id from the X-Kothadesk-End-User header instead of asking for it |
| start_return | Write | An action the visitor confirms before it runs, with your own business rules |
"""Sample MCP server for Kothadesk: a small online shop with three tools.
Run it, expose it over https, and connect it on a bot's Tools page (Add MCP server):
* ``search_products`` read-only: anyone can ask what is in stock.
* ``get_my_orders`` read-only: only for signed-in customers. Kothadesk sends the signed-in
user's id in the ``X-Kothadesk-End-User`` header; the model never
chooses whose orders it sees.
* ``start_return`` changes something: Kothadesk asks the visitor to confirm first.
Every request must carry ``Authorization: Bearer <MCP_TOKEN>`` (set in Kothadesk as the
server's Bearer token). The data is in memory; replace the three functions with calls to
your own database or API.
"""
import os
import secrets
from datetime import date, timedelta
from typing import Any
import uvicorn
from mcp.server.mcpserver import Context, MCPServer
from mcp.server.transport_security import TransportSecuritySettings
from mcp_types import ToolAnnotations
from starlette.types import Receive, Scope, Send
MCP_TOKEN = os.environ.get("MCP_TOKEN", "")
END_USER_HEADER = "x-kothadesk-end-user"
PRODUCTS = [
{"sku": "TEA-ASSAM-250", "name": "Assam CTC tea, 250 g", "price_inr": 180, "in_stock": True},
{
"sku": "TEA-GREEN-100",
"name": "Darjeeling green tea, 100 g",
"price_inr": 320,
"in_stock": True,
},
{"sku": "MUG-CERAMIC", "name": "Ceramic mug, 350 ml", "price_inr": 250, "in_stock": False},
]
ORDERS: dict[str, list[dict[str, Any]]] = {
# Keyed by your own user id: the "sub" claim of the identity token your site signs.
"user_1001": [
{
"order_id": "ORD-48213",
"status": "shipped",
"items": ["TEA-ASSAM-250"],
"delivered_on": None,
"eta": str(date.today() + timedelta(days=2)),
},
{
"order_id": "ORD-47790",
"status": "delivered",
"items": ["MUG-CERAMIC"],
"delivered_on": str(date.today() - timedelta(days=3)),
"eta": None,
},
],
}
RETURN_WINDOW_DAYS = 7
mcp = MCPServer("sample-shop")
@mcp.tool(
name="search_products",
title="Search products",
annotations=ToolAnnotations(read_only_hint=True),
)
def search_products(query: str) -> dict[str, Any]:
"""Find products by name and say whether they are in stock and what they cost."""
words = query.lower().split()
found = [p for p in PRODUCTS if all(w in p["name"].lower() for w in words)]
return {"results": found or [], "count": len(found)}
@mcp.tool(
name="get_my_orders",
title="My orders",
annotations=ToolAnnotations(read_only_hint=True),
)
def get_my_orders(ctx: Context) -> dict[str, Any]:
"""List the signed-in customer's recent orders with status and expected delivery date."""
user = _end_user(ctx)
if user is None:
return {"error": "Please sign in to see your orders."}
return {"orders": ORDERS.get(user, [])}
@mcp.tool(
name="start_return",
title="Start a return",
annotations=ToolAnnotations(read_only_hint=False, destructive_hint=False),
)
def start_return(order_id: str, reason: str, ctx: Context) -> dict[str, Any]:
"""Start a return for one of the signed-in customer's delivered orders.
Only orders delivered in the last 7 days can be returned."""
user = _end_user(ctx)
if user is None:
return {"error": "Please sign in to start a return."}
order = next((o for o in ORDERS.get(user, []) if o["order_id"] == order_id), None)
if order is None:
return {"error": f"No order {order_id} on this account."}
if order["status"] != "delivered" or order["delivered_on"] is None:
return {"error": "Only delivered orders can be returned."}
if date.fromisoformat(order["delivered_on"]) < date.today() - timedelta(
days=RETURN_WINDOW_DAYS
):
return {"error": f"The {RETURN_WINDOW_DAYS}-day return window has passed."}
return {
"return_id": f"RET-{secrets.randbelow(90000) + 10000}",
"order_id": order_id,
"reason": reason,
"next_step": "We will email a pickup slot within 24 hours.",
}
def _end_user(ctx: Context) -> str | None:
"""The signed-in customer's id, set by Kothadesk from your verified identity token."""
headers = {k.lower(): v for k, v in (ctx.headers or {}).items()}
return headers.get(END_USER_HEADER) or None
def build_app() -> Any:
inner = mcp.streamable_http_app(
stateless_http=True,
json_response=True,
# Kothadesk calls from its servers, so the Host header is your public host name.
transport_security=TransportSecuritySettings(enable_dns_rebinding_protection=False),
)
async def app(scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] == "http":
headers = {k.decode().lower(): v.decode() for k, v in scope.get("headers", [])}
if not MCP_TOKEN or not secrets.compare_digest(
headers.get("authorization", ""), f"Bearer {MCP_TOKEN}"
):
await send(
{
"type": "http.response.start",
"status": 401,
"headers": [(b"content-type", b"application/json")],
}
)
await send({"type": "http.response.body", "body": b'{"error":"unauthorized"}'})
return
await inner(scope, receive, send)
return app
if __name__ == "__main__":
if not MCP_TOKEN:
raise SystemExit("Set MCP_TOKEN to a long random value first.")
# All interfaces: it runs in a container or behind your reverse proxy.
uvicorn.run(build_app(), host="0.0.0.0", port=int(os.environ.get("PORT", "8000"))) # noqa: S104
mcp>=2.2,<3
uvicorn>=0.30
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY server.py .
ENV PORT=8000
EXPOSE 8000
USER nobody
CMD ["python", "server.py"]
The same server in Node.js
#Prefer JavaScript? This version behaves exactly like the Python one, with the official MCP TypeScript SDK and no build step (Node 20 or later).
/**
* Sample MCP server for Kothadesk (Node.js): a small online shop with three tools.
*
* Run it, expose it over https, and connect it on a bot's Tools page (Add MCP server):
*
* - search_products read-only: anyone can ask what is in stock.
* - get_my_orders read-only: only for signed-in customers. Kothadesk sends the signed-in
* user's id in the X-Kothadesk-End-User header (turn on "Signed-in users only"
* for this tool); the model never chooses whose orders it sees.
* - start_return changes something: Kothadesk asks the visitor to confirm first.
*
* Every request must carry "Authorization: Bearer <MCP_TOKEN>" (set in Kothadesk as the
* server's Bearer token). The data is in memory; replace the three handlers with calls to
* your own database or API.
*/
import { randomInt, timingSafeEqual } from "node:crypto";
import { createServer } from "node:http";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { z } from "zod";
const MCP_TOKEN = process.env.MCP_TOKEN ?? "";
const PORT = Number(process.env.PORT ?? 8000);
const END_USER_HEADER = "x-kothadesk-end-user";
const RETURN_WINDOW_DAYS = 7;
const DAY = 24 * 60 * 60 * 1000;
const isoDate = (offsetDays) => new Date(Date.now() + offsetDays * DAY).toISOString().slice(0, 10);
const PRODUCTS = [
{ sku: "TEA-ASSAM-250", name: "Assam CTC tea, 250 g", price_inr: 180, in_stock: true },
{ sku: "TEA-GREEN-100", name: "Darjeeling green tea, 100 g", price_inr: 320, in_stock: true },
{ sku: "MUG-CERAMIC", name: "Ceramic mug, 350 ml", price_inr: 250, in_stock: false },
];
/** Keyed by your own user id: the "sub" claim of the identity token your site signs. */
const ORDERS = {
user_1001: [
{ order_id: "ORD-48213", status: "shipped", items: ["TEA-ASSAM-250"], delivered_on: null, eta: isoDate(2) },
{ order_id: "ORD-47790", status: "delivered", items: ["MUG-CERAMIC"], delivered_on: isoDate(-3), eta: null },
],
};
/** A tool result: JSON text the assistant reads. */
const result = (data) => ({ content: [{ type: "text", text: JSON.stringify(data) }] });
/** The signed-in customer's id, set by Kothadesk from your verified identity token. */
function endUser(extra) {
const value = extra.requestInfo?.headers?.[END_USER_HEADER];
return (Array.isArray(value) ? value[0] : value) || null;
}
function buildServer() {
const mcp = new McpServer({ name: "sample-shop", version: "1.0.0" });
mcp.registerTool(
"search_products",
{
title: "Search products",
description: "Find products by name and say whether they are in stock and what they cost.",
inputSchema: { query: z.string().describe("Words from the product name, for example assam tea") },
annotations: { readOnlyHint: true },
},
async ({ query }) => {
const words = query.toLowerCase().split(/\s+/).filter(Boolean);
const found = PRODUCTS.filter((p) => words.every((w) => p.name.toLowerCase().includes(w)));
return result({ results: found, count: found.length });
},
);
mcp.registerTool(
"get_my_orders",
{
title: "My orders",
description: "List the signed-in customer's recent orders with status and expected delivery date.",
annotations: { readOnlyHint: true },
},
async (extra) => {
const user = endUser(extra);
if (!user) return result({ error: "Please sign in to see your orders." });
return result({ orders: ORDERS[user] ?? [] });
},
);
mcp.registerTool(
"start_return",
{
title: "Start a return",
description:
"Start a return for one of the signed-in customer's delivered orders. Only orders delivered in the last 7 days can be returned.",
inputSchema: {
order_id: z.string().describe("The order number, for example ORD-47790"),
reason: z.string().describe("Why the customer is returning it"),
},
annotations: { readOnlyHint: false, destructiveHint: false },
},
async ({ order_id, reason }, extra) => {
const user = endUser(extra);
if (!user) return result({ error: "Please sign in to start a return." });
const order = (ORDERS[user] ?? []).find((o) => o.order_id === order_id);
if (!order) return result({ error: `No order ${order_id} on this account.` });
if (order.status !== "delivered" || !order.delivered_on) {
return result({ error: "Only delivered orders can be returned." });
}
if (Date.parse(order.delivered_on) < Date.now() - RETURN_WINDOW_DAYS * DAY) {
return result({ error: `The ${RETURN_WINDOW_DAYS}-day return window has passed.` });
}
return result({
return_id: `RET-${randomInt(10000, 100000)}`,
order_id,
reason,
next_step: "We will email a pickup slot within 24 hours.",
});
},
);
return mcp;
}
function authorized(req) {
const given = Buffer.from(req.headers.authorization ?? "");
const expected = Buffer.from(`Bearer ${MCP_TOKEN}`);
return given.length === expected.length && timingSafeEqual(given, expected);
}
async function readJson(req) {
const chunks = [];
for await (const chunk of req) chunks.push(chunk);
const text = Buffer.concat(chunks).toString("utf8");
return text ? JSON.parse(text) : undefined;
}
if (!MCP_TOKEN) {
console.error("Set MCP_TOKEN to a long random value first.");
process.exit(1);
}
createServer(async (req, res) => {
if (!authorized(req)) {
res.writeHead(401, { "content-type": "application/json" }).end('{"error":"unauthorized"}');
return;
}
if (!req.url?.startsWith("/mcp")) {
res.writeHead(404).end();
return;
}
try {
// Stateless: a fresh server and transport per request, answered as plain JSON.
const server = buildServer();
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
enableJsonResponse: true,
});
res.on("close", () => {
void transport.close();
void server.close();
});
await server.connect(transport);
await transport.handleRequest(req, res, req.method === "POST" ? await readJson(req) : undefined);
} catch (error) {
console.error(error);
if (!res.headersSent) res.writeHead(500, { "content-type": "application/json" }).end('{"error":"internal"}');
}
}).listen(PORT, () => console.log(`Sample MCP server on http://0.0.0.0:${PORT}/mcp`));
{
"name": "kothadesk-sample-mcp-server",
"version": "1.0.0",
"private": true,
"description": "Sample MCP server for Kothadesk: a small online shop with three tools",
"type": "module",
"main": "server.mjs",
"scripts": {
"start": "node server.mjs"
},
"engines": {
"node": ">=20"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.30.1",
"zod": "^4.1.0"
}
}
FROM node:22-slim
WORKDIR /app
COPY package.json .
RUN npm install --omit=dev --no-audit --no-fund
COPY server.mjs .
ENV PORT=8000
EXPOSE 8000
USER node
CMD ["node", "server.mjs"]
Run it
#- 01
Create a token
Generate a long random value, for example with: openssl rand -base64 32. Kothadesk sends it on every request; the server refuses requests without it.
- 02
Start the server
Python: pip install -r requirements.txt, then MCP_TOKEN=your-token python server.py. Node.js: npm install, then MCP_TOKEN=your-token npm start. With Docker (either version): docker build -t sample-mcp . and docker run -e MCP_TOKEN=your-token -p 8000:8000 sample-mcp.
- 03
Put it behind https
Kothadesk only calls public https addresses on port 443 or 8443. Deploy it like any small web service (behind your reverse proxy, a PaaS or a container platform) so it is reachable at, for example, https://mcp.yourshop.com/mcp.
Watch out: Keep the token secret and rotate it if it leaks. Anyone with the address and the token can call your tools.
Connect it to your bot
#- 01
Add the server
On the bot's Tools tab, choose Add MCP server. Name: Sample shop. Server URL: https://mcp.yourshop.com/mcp. Authentication: Bearer token, with your token.
- 02
Review the tools
Kothadesk lists the server's tools. search_products and get_my_orders declare themselves read-only, so they start as Read; start_return starts as Write and will ask the visitor to confirm.
- 03
Enable them
Switch on each tool the bot should use. For get_my_orders and start_return, also turn on Signed-in users only: only then does Kothadesk send the customer's id, and the tools stay hidden from anonymous visitors.
- 04
Set up signed identity
Your site must start the chat with a signed identity token for the customer (see the Install guide), with sub set to the same user id your server uses.
Test it
#Use the Playground with Test as a signed-in user switched on and the user id set to user_1001 (the sample's test customer). Then ask:
Visitor: Do you have Assam CTC tea in stock, and how much is it?
Bot: Yes, Assam CTC tea (250 g) is in stock and costs ₹180.
Visitor: Where are my orders?
Bot: ORD-48213 (Assam CTC tea) has shipped and should arrive in two days. ORD-47790 (ceramic mug) was delivered three days ago.
Visitor: Please start a return for ORD-47790, the mug arrived chipped.
Bot: Confirm: Start a return. Order ORD-47790, reason: arrived chipped. [Confirm] [Cancel]
Bot: Your return has been started (RET-72958). We will email you a pickup slot within 24 hours.
These answers come from real test runs of both sample servers with a Gemini model. Every call appears under the reply in the conversation view, with its arguments, result and timing.
Writing your own tools
#- Write the docstring for the model: when to use the tool and what it returns. It becomes the tool's description.
- Mark lookups with read_only_hint=True. Anything that changes data should be a write tool, so the visitor confirms it.
- Never take a user id, email or customer number as a parameter for account actions. Read the X-Kothadesk-End-User header, which Kothadesk sets from your verified identity token.
- Check your business rules in the server (return windows, stock, permissions) and return a clear error message the assistant can pass on.
- Return small, focused results. The assistant sees at most about 6000 characters per result.
- Answer within the tool timeout (10 seconds by default).
- After you change a tool's name, parameters or description, press Refresh tools in Kothadesk and approve the changed tool again.
Tool ideas by business
#Online shop
Online shop
Answer stock and order questions and handle returns.
- search_products and check_stock (read)
- get_my_orders (read, signed-in only)
- start_return and change_delivery_address (write, signed-in only)
Clinic or bookings
Clinic
Show free slots and manage appointments.
- list_free_slots(date, doctor) (read)
- my_appointments (read, signed-in only)
- book_appointment and cancel_appointment (write, signed-in only)
Software company
Software company
Answer account questions and open tickets.
- get_my_plan and get_usage (read, signed-in only)
- service_status (read)
- create_ticket (write, auto-run can be on because it is low risk)
Coaching institute
Coaching institute
Answer batch, fee and test questions.
- batch_timings(course, centre) (read)
- my_fee_status and my_test_scores (read, signed-in only)
- request_callback (write)
Troubleshooting
#The server saved, but Kothadesk says it did not answer. What should I check?
Check that the URL ends in /mcp, is a public https address, and that the Bearer token matches the MCP_TOKEN your server expects.
What happens when the server's status is error?
Its tools are not offered to the assistant until it answers again. Fix the server, then press Refresh tools.
Why does an account tool reply “Please sign in”?
Turn on Signed-in users only for that tool and make sure the chat starts with an identity token, so Kothadesk can send the signed-in user's id.
Why does a tool show “changed on the server”?
You changed its definition on your server. Review the new definition and switch the tool on again.