Vid mitten av 2026 är MCP (Model Context Protocol) den faktiska standarden för att ansluta LLM:er till verktyg. Anthropic introducerade det; OpenAI, Google och det bredare ekosystemet har anammat det. Cursor, Claude Desktop, ChatGPT och anpassade agenter – alla talar MCP.
Om du vill att LLM-agenter ska interagera med din tjänst är en MCP-server lösningen. När du har byggt en eller två ser du att själva protokollet är litet. Det intressanta ingenjörsarbetet finns i allt runt omkring: schemadesign, felhantering, autentisering, strömning, prestanda och observerbarhet.
Den här artikeln är en djupdykning i att bygga MCP-servrar av produktionsklass i TypeScript. Vi behandlar de mönster som håller för verklig agentanvändning, inte bara protokollmekaniken.
Kort om vad MCP är
MCP är ett klient–server-protokoll där:
- Servrar exponerar verktyg, resurser och prompter.
- Klienter vanligtvis är LLM-agenter som använder dem.
Protokollet använder JSON-RPC 2.0 (den officiella specifikationen är kort och värd att läsa en gång). Transporterna är stdio för lokala processer och Streamable HTTP för fjärrservrar – den äldre transporten HTTP+SSE utfasades i specifikationsrevisionen 2025-03-26, så betrakta alla handledningar som bygger på den som historiska. Autentisering och säkerhet är protokollfrågor; större implementationer stöder OAuth, API-nycklar och liknande.
Serverns uppgift är att exponera användbara funktioner för LLM:er på ett sätt som gör att de kan upptäcka och använda dem.
Grundstrukturen
Med det officiella paketet @modelcontextprotocol/sdk ser en minimal server med högnivå-API:t McpServer ut så här:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
const server = new McpServer({
name: "my-server",
version: "1.0.0",
});
server.registerTool(
"echo",
{
description: "Returnera den angivna texten.",
inputSchema: { text: z.string() },
},
async ({ text }) => ({
content: [{ type: "text", text }],
})
);
const transport = new StdioServerTransport();
await server.connect(transport);
(Samma SDK exponerar också lågnivåklassen Server tillsammans med setRequestHandler för ListToolsRequestSchema/CallToolRequestSchema om du vill ha full kontroll över hanterarna för begäranden – men för de flesta servrar är McpServer.registerTool kortare och svårare att göra fel med.)
Det är stommen. Det är innehållet i verktygshanterarna – och hur du bygger dem – som är själva arbetet.
Mönster 1: Filosofi för verktygsdesign
Det första beslutet är vilka verktyg du exponerar och på vilken detaljnivå.
Ett vanligt fel är att exponera det underliggande API:t som verktyg. Om du har 200 REST-slutpunkter är det katastrofalt att exponera 200 verktyg. Modeller presterar sämre med för många verktyg, verktygsbeskrivningarna blir oöverskådliga och protokollet blir en labyrint.
Bättre: utforma verktygen efter hur agenter vill använda dem. Varje verktyg gör en enda väldefinierad sak, tar väldefinierade indata och returnerar väldefinierade utdata.
Några principer:
Ett koncept per verktyg. Ha inte ett manage_customer-verktyg som gör 12 olika saker. Ha search_customers, get_customer, update_customer_email och archive_customer – vart och ett med tydligt fokus.
Rätt detaljnivå. Är verktygen för detaljerade behöver agenten göra många anrop; är de för grova kan den inte utföra precis det som behövs. Sikta på ”operationer som en människa skulle namnge”.
Handlingsverb. search_documents, inte documents. Verktyg ska namnges efter vad de gör.
Skillnad mellan läsning och skrivning. Läsverktyg är säkrare; skrivverktyg har sidoeffekter. Skilj dem åt i namngivningen (list_x respektive create_x) och behandla dem olika (kräv uttrycklig bekräftelse, idempotensnycklar med mera).
Aggregera när det är användbart. Ett get_customer_profile som returnerar kund + senaste beställningar + supportärenden i ett anrop är ofta bättre än tre separata anrop. Agenten får sammanhanget direkt.
För en server som exempelvis exponerar ett kundtjänstsystem är 8–15 verktyg en rimlig uppsättning. Fler är vanligtvis för många.
Mönster 2: Schemadesign
Varje verktyg har ett indataschema (parametrarna som LLM:en måste ange) och utdata (det verktyget returnerar). Scheman är inte bara till för validering; de är promptutformning.
Indatascheman med Zod:
const searchCustomersSchema = z.object({
query: z.string().describe(
"Sökterm: namn, e-postadress eller företag. Var specifik för att undvika för många träffar."
),
limit: z.number().int().min(1).max(50).default(10).describe(
"Högsta antal resultat som ska returneras. Standardvärde 10, högst 50."
),
filters: z.object({
tier: z.enum(["free", "pro", "enterprise"]).optional().describe(
"Filtrera på en viss kundnivå"
),
status: z.enum(["active", "trial", "churned"]).optional().describe(
"Filtrera på kundstatus"
),
}).optional(),
});
Lägg märke till:
- Varje fält har en
.describe(). Beskrivningen är det som LLM:en läser. - Enum-värden är explicita. Fritext begränsas när det är möjligt.
- Standardvärdena är rimliga.
- Begränsningar (min/max, längd) är uttryckliga.
- Det framgår tydligt vad som är valfritt respektive obligatoriskt.
Beskrivningarna är oerhört viktiga. ”sökterm” hjälper inte; ”Sökterm: namn, e-postadress eller företag. Var specifik för att undvika för många träffar.” ger LLM:en användbar vägledning.en användbar vägledning.
Mönster 3: Utdataform
Utdata är det som LLM:en ser och agerar på. Bra utformade utdata förbättrar LLM:ens beteende dramatiskt.
Strukturerade utdata.
type SearchResult = {
customers: Customer[];
total_matches: number;
truncated: boolean;
next_page_cursor?: string;
};
Med sammanhang.
{
customers: [...],
total_matches: 47,
truncated: true,
next_page_cursor: "abc",
message: "Hittade 47 träffar; visar de första 10. Använd next_page_cursor för att hämta fler."
}
Fältet message ger läsbar vägledning. LLM:er använder den.
Med väl hanterade fel.
{
error: "ambiguous_query",
message: "Söktermen 'john' gav 247 kundträffar. Var mer specifik.",
suggestion: "Försök inkludera ett företagsnamn eller en e-postdomän.",
partial_results: [...] // de tre mest relevanta, valfritt
}
Felet är strukturerat (maskinläsbart), men innehåller också ett meddelande och ett förslag (läsbart för LLM:en). LLM:en kan anpassa sig – antingen be användaren om ett förtydligande eller förfina sökningen.
Lämplig storlek.
Ett verktyg som returnerar 10 000 poster är oanvändbart. De ryms inte i LLM:ens kontext, och även om de gjorde det skulle modellen inte använda dem väl. Paginera, korta av eller sammanfatta alltid. Returnera tillräckligt för att LLM:en ska kunna fatta ett beslut, inte allt som finns.
Mönster 4: Felsemantik
Verktyg fallerar. Hur de kommunicerar fel till LLM:en avgör om den återhämtar sig smidigt eller förvärrar felet.
Felkategorier.
type ToolError =
| { type: "validation"; message: string; field?: string }
| { type: "auth"; message: string }
| { type: "not_found"; message: string; suggestion?: string }
| { type: "conflict"; message: string; resolution?: string }
| { type: "rate_limit"; message: string; retry_after_seconds: number }
| { type: "service_unavailable"; message: string; retryable: boolean }
| { type: "internal"; message: string; trace_id: string };
Varje kategori har olika semantik. LLM:en bör reagera olika:
validation: korrigera indata och försök igen.not_found: informera användaren eller prova en annan sökning.conflict: be om ett avgörande.rate_limit: vänta och försök igen.service_unavailable: prova en reservlösning eller meddela användaren.internal: avbryt och visa felet för användaren.
När detta dokumenteras på servern blir LLM:en mer kompetent.
Felformatering.
Returnera fel som strukturerade data med tydliga, åtgärdbara meddelanden:
{
error: {
type: "validation",
message: "E-postadressen har inte ett giltigt format.",
field: "email",
suggestion: "Ange en giltig e-postadress, till exempel 'namn@example.com'."
}
}
Undvik:
{
error: "Ogiltiga indata"
}
Det första gör det möjligt för LLM:en att återhämta sig. Det andra tvingar den att gissa.
Mönster 5: Autentisering och behörighetskontroll
MCP-servrar i produktion behöver autentisering. Den som kan nå servern kan använda verktygen. Det är nästan alltid ett problem.
Autentisering: vem anropar?
Vanliga mönster:
- API-nyckel. Enkelt och vanligt, fungerar mellan tjänster. Utfärda en per konsument och rotera regelbundet.
- OAuth. För system med flera användare där slutanvändare ger agenter behörighet. Mer komplext, men rätt lösning för många användningsfall.
- mTLS. För miljöer med höga säkerhetskrav. Ömsesidiga TLS-certifikat för båda sidor.
Implementeringen beror på transporten. Över HTTP autentiserar du begäran innan den når MCP-hanteraren (i mellanprogramvara för Express/Hono/Fastify) och sparar anroparen i begäran:
// Mellanprogramvara i Express-stil framför MCP:s HTTP-slutpunkt.
app.use("/mcp", async (req, res, next) => {
const apiKey = req.header("x-api-key");
const caller = await authenticate(apiKey);
if (!caller) return res.status(401).send("Obehörig");
(req as any).caller = caller;
next();
});
I varje verktygshanterare hämtar du sedan anroparen från kontexten per anrop (extra), inte från råa headers. Över stdio finns inga HTTP-headers; autentiseringen kommer vanligtvis från processmiljön eller konfigurationsfiler.
Behörighetskontroll: vad får anroparen göra?
När anroparen har autentiserats: vilka verktyg får personen använda och för vilka data?
function authorize(caller: Caller, tool: string, params: any): boolean {
// Anroparnivå: får den här anroparen över huvud taget använda verktyget?
if (!caller.tools.includes(tool)) return false;
// Datanivå: har den här anroparen behörighet till just dessa data?
if (params.tenant_id && params.tenant_id !== caller.tenant_id) return false;
return true;
}
Låt inte LLM:en fatta behörighetsbeslut. Den kan luras. Behörighetskontroll är serverns uppgift; LLM:en ser endast data som den har behörighet att se.
I system med flera kundorganisationer avgränsas varje verktygsanrop till en kundorganisation. Kundorganisationen fastställs av autentiseringen, inte av parametrar som LLM:en anger.
Mönster 6: Idempotens
Idempotens är viktigt för skrivoperationer. LLM:en kan försöka igen eller anropa samma verktyg två gånger i olika sammanhang. Utan idempotens får du dubbletter.
Idempotensnycklar.
Verktyget tar parametern idempotency_key. Servern kontrollerar: har vi sett nyckeln förut? Om ja returneras det cachelagrade resultatet. Om nej utförs operationen och resultatet cachelagras.
async function createInvoice(params: {
amount: number;
customer_id: string;
idempotency_key: string;
}) {
const cached = await idempotencyStore.get(params.idempotency_key);
if (cached) return cached;
const invoice = await actuallyCreateInvoice(params);
await idempotencyStore.set(params.idempotency_key, invoice, { ttl: 86400 });
return invoice;
}
Ge LLM:en en ledtråd om detta i verktygsbeskrivningen:
"Generera ett UUID för varje unik faktura du skapar och skicka det som idempotency_key. Om du behöver försöka utföra operationen igen använder du samma UUID för att undvika dubbletter."
Mönster 7: Strömning
För verktyg som producerar stora utdata eller tar lång tid ger strömning av utdata en bättre användarupplevelse. MCP stöder förloppsaviseringar inifrån en verktygshanterare via argumentet extra per anrop:
server.registerTool(
"long_running_task",
{ description: "...", inputSchema: { ... } },
async (input, extra) => {
await extra.sendNotification({
method: "notifications/progress",
params: { progressToken: extra._meta?.progressToken, progress: 0, message: "Startar..." },
});
for (const step of steps) {
await doStep(step);
await extra.sendNotification({
method: "notifications/progress",
params: {
progressToken: extra._meta?.progressToken,
progress: step.index / steps.length,
message: step.name,
},
});
}
return { content: [{ type: "text", text: JSON.stringify({ result: finalResult }) }] };
}
);
Använd strömning för:
- Långvariga operationer (>5 sekunder).
- Stora utdata (så att LLM:en kan börja bearbeta medan utdata fortfarande kommer).
- Operationer med mellanresultat som är värda att visa.
Strömma inte snabba, enkla operationer – det ökar komplexiteten utan att tillföra värde.
Mönster 8: Cachelagring
Många verktygsanrop hämtar samma data upprepade gånger. Cachelagring kan förbättra prestandan dramatiskt och minska belastningen på bakomliggande system.
Lokal cache. Cache i processen (exempelvis LRU) för ofta använd data.
Distribuerad cache. Redis eller motsvarande för en gemensam cache mellan serverinstanser.
Cacheinvalidering. När data ändras avlägsnas relevanta poster. (Det här är den svåra delen.)
TTL-värden. Cachelagrade poster upphör att gälla efter en definierad tid. Anpassa per datatyp – kundprofiler kan cachelagras i timmar, priser i minuter.
För att cachelagring ska hjälpa måste samma anrop återkomma. Det gör de på många MCP-servrar – agenter hänvisar ofta upprepade gånger till samma entiteter inom en session.
Ett mönster:
async function getCustomerCached(id: string) {
const cached = await cache.get(`customer:${id}`);
if (cached) {
metrics.increment("cache.hit");
return cached;
}
metrics.increment("cache.miss");
const customer = await db.getCustomer(id);
await cache.set(`customer:${id}`, customer, { ttl: 300 });
return customer;
}
Mönster 9: Hastighetsbegränsning
LLM-agenter kan vara förvånansvärt aggressiva – de loopar, försöker igen och sprider anrop. En felande agent kan överbelasta bakomliggande system.
Hastighetsbegränsning per anropare är nödvändig:
const limiter = new RateLimiter({
windowMs: 60_000,
max: 100 // 100 anrop/minut per anropare
});
server.setRequestHandler(CallToolRequestSchema, async (request, context) => {
if (await limiter.exceeded(context.caller.id)) {
return errorResponse("rate_limit", "För många förfrågningar");
}
// ...
});
Utöver globala hastighetsgränser behövs gränser per verktyg – vissa verktyg är dyra och bör begränsas hårt.
Använd strängare gränser eller kräv uttryckliga bekräftelseflöden för operationer med konsekvenser (skapa poster, skicka meddelanden).
Mönster 10: Resurser
MCP har ”resurser” – skrivskyddade datakällor som LLM:en kan bläddra i och hänvisa till. De skiljer sig från verktyg (som anropas aktivt).
server.setRequestHandler(ListResourcesRequestSchema, async () => ({
resources: [
{
uri: "doc://my-server/handbook",
name: "Personalhandbok",
mimeType: "text/markdown",
description: "Företagets personalhandbok"
},
// ...
]
}));
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
const content = await loadResource(request.params.uri);
return { contents: [{ uri: request.params.uri, mimeType: "text/markdown", text: content }] };
});
Resurser är användbara för:
- Referensdokument som LLM:en kan vilja bläddra i.
- Konfigurations- eller kontextdata.
- Uppslagstabeller eller scheman som LLM:en kan behöva.
Resurser läses; verktyg utför åtgärder. Använd rätt koncept för respektive behov.
Mönster 11: Observerbarhet
Samma mönster som på andra håll i produktions-AI. Instrumentera följande för MCP-servern:
- Varje verktygsanrop: tidsstämpel, anropare, verktyg, parametrar, resultat, latens och status.
- Mätetal per verktyg: anropsvolym, p50-/p95-latens och felfrekvens.
- Mätetal per anropare: vem anropar och hur ofta.
- Spårningskontext: skicka spår-ID:n från anroparen vidare till bakomliggande anrop.
Strukturerade loggar:
logger.info("tool_call", {
tool: request.params.name,
caller_id: context.caller.id,
trace_id: context.trace_id,
params: redactPII(request.params.arguments),
duration_ms: duration,
status: "success"
});
Skicka dem till observerbarhetsplattformen.
Mönster 12: Versionshantering
Din MCP-server kommer att utvecklas. Verktyg ändras. Nya verktyg läggs till. Gamla fasas ut.
Serverversionshantering. Konstruktorn Server tar ett versionsnummer. Höj det vid ändringar. Klienterna kan upptäcka det.
Verktygsversionering. När ett verktygs signatur ändras på ett inkompatibelt sätt versionerar du det: search_customers_v2. Behåll den gamla versionen under en utfasningsperiod.
Schemautveckling. Valfria fält kan läggas till på ett säkert sätt. Att ta bort fält eller ändra typer är brytande ändringar.
Utfasning. När ett verktyg fasas ut markerar du det i beskrivningen: ”DEPRECATED: use search_customers_v2 instead.”
Versionshantering är nödvändig för MCP-servrar i produktion som används av flera klienter. Servrar som endast används internt kan vara mer flexibla.
Mönster 13: Testning
Hur testar du en MCP-server?
Enhetstester. Varje verktygs logik, med mockade beroenden. Vanlig TypeScript-testning.
Schematester. Scheman validerar som förväntat. Extremfall (saknade fält, fel typer) hanteras korrekt.
Integrationstester. Starta servern, skicka riktiga MCP-begäranden och verifiera svaren. @modelcontextprotocol/sdk innehåller testverktyg.
Heltäckande test med en verklig LLM. Svårast men mest värdefullt. Låt en LLM använda MCP-servern för att utföra realistiska uppgifter. Verifiera att den använder verktygen korrekt. Hitta problem i verktygsbeskrivningarna.
En heltäckande testuppsättning (pseudokod; den exakta klientkopplingen beror på vilken LLM-klient du använder – Anthropics TypeScript-SDK, OpenAI:s eller ett ramverk med MCP-stöd):
// Starta MCP-servern som en underordnad process eller med transport i minnet.
const server = await startTestServer();
// Kör en LLM med MCP-verktygen anslutna. Exakt API beror på klienten.
const result = await runAgent({
mcpServer: server,
systemPrompt: "Du är en kundtjänstagent...",
userMessage: "Hitta kunden Alice och kontrollera hennes öppna ärenden",
});
// Granska de verktygsanrop som servern registrerade under körningen.
expect(server.callLog.map((c) => c.name)).toEqual([
"search_customers",
"list_tickets",
]);
Heltäckande tester upptäcker problem i verktygsbeskrivningar som enhetstester inte kan hitta.
Mönster 14: Driftsättning
Var körs din MCP-server?
Stdio (lokalt). Servern körs som en process och klienten startar den. Passar bäst för skrivbordsappar (Claude Desktop, Cursor) och lokala verktyg.
HTTP/SSE (fjärranslutet). Servern är en nätverkstjänst. Passar bäst för driftade tjänster, delad infrastruktur och åtkomst från flera klienter.
För produktionsservrar:
- Streamable HTTP är vanligtvis rätt val.
- Driftsätt som vilken webbtjänst som helst: containrar, lastbalansering och automatisk skalning.
- TLS krävs.
- Hälsokontroller för driftsättningsplattformen.
- Kontrollerad avstängning för pågående begäranden.
Mönster 15: Säkerhetsöverväganden
MCP-servrar exponerar funktioner för LLM:er. LLM:er kan manipuleras. Säkerhetskonsekvenser:
Promptinjektion via verktygsindata. En användarbegäran kan innehålla text som försöker lura LLM:en att anropa verktyg på ett skadligt sätt. Försvar:
- Verktygsbeskrivningar som tydligt anger avsedd användning.
- Behörighetskontroll på serversidan (oberoende av parametrar som LLM:en väljer).
- Bekräftelser för åtgärder med konsekvenser.
Dataexfiltrering. Verktyg som returnerar data kan missbrukas – LLM:en kan luras att lämna ut känsliga data på ett olämpligt sätt. Försvar:
- Behörighetskontroller.
- Loggning av vilka data som nås och av vem.
- Mönsterdetektering för ovanliga åtkomstmönster.
Resursuttömning. Verktyg som förbrukar resurser i bakomliggande system kan missbrukas. Försvar:
- Hastighetsbegränsning.
- Resursgränser per verktygsanrop.
- Kretsbrytare när bakomliggande system har försämrats.
Injektion i verktygsutdata. Ett verktygs utdata kan innehålla text som manipulerar LLM:en när den läser den. Försvar:
- Sanera utdata där det är möjligt.
- Var försiktig med verktyg som returnerar användargenererat innehåll.
Det här är verkliga angreppsytor. Behandla MCP-servrar som alla andra produktions-API:er: använd försvar på djupet.
Ett komplett exempel: en liten men verklig MCP-server
Här är en server som exponerar ett litet CRM-system och sammanför mönstren:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { z } from "zod";
import { db, cache, logger, authenticate } from "./infra.js";
const server = new McpServer({
name: "crm-server",
version: "1.0.0",
});
// === Verktyg: search_customers ===
server.registerTool(
"search_customers",
{
description: "Sök efter kunder utifrån namn, e-postadress eller företag.",
inputSchema: {
query: z.string().describe("Namn, e-postadress eller företag"),
limit: z.number().int().min(1).max(50).default(10),
},
},
async ({ query, limit }, extra) => {
const auth = await authenticate(extra);
const cacheKey = `search:${auth.tenant_id}:${query}:${limit}`;
const cached = await cache.get(cacheKey);
if (cached) return cached;
const customers = await db.searchCustomers({
tenant_id: auth.tenant_id,
query,
limit,
});
const result = {
content: [{
type: "text" as const,
text: JSON.stringify({
customers,
total_matches: customers.length,
truncated: customers.length === limit,
message:
customers.length === limit
? `Visar de första ${limit}; det kan finnas fler träffar.`
: `Hittade ${customers.length} kund(er).`,
}),
}],
};
await cache.set(cacheKey, result, { ttl: 60 });
logger.info("search_customers", { tenant: auth.tenant_id, query, results: customers.length });
return result;
}
);
// === Verktyg: get_customer ===
server.registerTool(
"get_customer",
{
description: "Hämta en enskild kund utifrån id.",
inputSchema: { customer_id: z.string() },
},
async ({ customer_id }, extra) => {
const auth = await authenticate(extra);
const customer = await db.getCustomer(auth.tenant_id, customer_id);
if (!customer) {
return {
isError: true,
content: [{
type: "text" as const,
text: `Kunden ${customer_id} hittades inte. Använd search_customers för att söka efter namn eller e-postadress.`,
}],
};
}
return { content: [{ type: "text" as const, text: JSON.stringify({ customer }) }] };
}
);
// === Verktyg: update_customer_email (med idempotens) ===
server.registerTool(
"update_customer_email",
{
description: "Uppdatera en kunds e-postadress; skicka samma idempotency_key vid ett nytt försök.",
inputSchema: {
customer_id: z.string(),
new_email: z.string().email(),
idempotency_key: z
.string()
.describe("UUID för denna uppdatering; skicka samma värde vid ett nytt försök för att undvika dubbletter"),
},
},
async (params, extra) => {
const auth = await authenticate(extra);
// ... idempotenskontroll, validering, uppdatering
return { content: [{ type: "text" as const, text: "ok" }] };
}
);
// ... fler verktyg ...
// Anslut fjärrtransport (strömmande HTTP) till en vald port via önskad HTTP-server.
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => crypto.randomUUID() });
await server.connect(transport);
Det här är en startstruktur. Lägg till observerbarhet, hastighetsbegränsning, fler verktyg och mer genomtänkta scheman – men stommen finns här.
Det som skiljer produktionsservrar från demonstrationer
MCP är ett litet protokoll; att bygga en server av produktionsklass är riktigt ingenjörsarbete. Vinsten är att din tjänst blir användbar för alla LLM-agenter genom en standardiserad integrering som inte kräver koppling till enskilda modeller.
Mönstren som är viktiga är fokuserad verktygsdesign, promptmedvetna scheman, strukturerad felsemantik, robust autentisering, idempotens, observerbarhet och säkerhet. Om du hoppar över någon av dem får du en MCP-server som fallerar i produktion.
Bygg in dem. Testa mot verkliga LLM:er. Iterera på verktygsbeskrivningarna. Resultatet är en tjänst som en LLM kan använda lika obehindrat som en människa – och som skalar med det snabbt växande universumet av AI-agenter.



