MCP fra bunden: byg en produktionsklar server i TypeScript
Avanceret14 min læsningAI til virksomheder

MCP fra bunden: byg en produktionsklar server i TypeScript

At bygge en produktionsklar Model Context Protocol-server kræver mere end at forbinde nogle få værktøjer. Her er mønstrene for skemadesign, godkendelse, fejlhåndtering, streaming og observabilitet samt de praktiske produktionsforhold, der gør MCP-servere nyttige i stor skala.

Hvad du bør kunne

En produktionsklar MCP-server er en lille, fokuseret tjeneste med veldesignede værktøjer, omhyggelige skemaer, robust fejlhåndtering, korrekt godkendelse og indbygget observabilitet. Protokollen er enkel; ingeniørarbejdet ligger i at gøre serveren reelt nyttig i virkelige AI-arbejdsgange.

AI Expert TeamUdgivet: 15. maj 2026
Gemt kun i denne browser.
I denne artikel

I midten af 2026, hvor denne artikel skrives, er MCP (Model Context Protocol) de facto-standarden for at forbinde LLM’er med værktøjer. Anthropic introducerede protokollen; OpenAI, Google og det bredere økosystem har taget den til sig. Cursor, Claude Desktop, ChatGPT og specialbyggede agenter – alle understøtter MCP.

Hvis LLM-agenter skal interagere med din tjeneste, er en MCP-server løsningen. Når du har bygget én eller to, opdager du, at selve protokollen er lille. Det interessante ingeniørarbejde ligger i alt omkring den: skemadesign, fejlhåndtering, godkendelse, streaming, ydeevne og observabilitet.

Denne artikel er en dybdegående gennemgang af, hvordan man bygger MCP-servere i produktionskvalitet med TypeScript. Vi behandler de mønstre, der holder til reel agentbrug, ikke kun protokollens mekanik.

Hvad MCP er, kort

MCP er en klient-serverprotokol, hvor:

  • Servere gør værktøjer, ressourcer og prompts tilgængelige.
  • Klienter er typisk LLM-agenter, der bruger dem.

Protokollen bruger JSON-RPC 2.0 (den officielle specifikation er kort og værd at læse én gang). Transportformerne er stdio til lokale processer og Streamable HTTP til eksterne servere – den ældre HTTP+SSE-transport blev udfaset i specifikationsrevisionen 2025-03-26 og bør derfor betragtes som historisk i vejledninger, der bygger på den. Godkendelse og sikkerhed er en del af protokollen; de vigtigste implementeringer understøtter OAuth, API-nøgler og lignende.

Serverens opgave er at gøre nyttige funktioner tilgængelige for LLM’er på en måde, de kan opdage og bruge.

Den grundlæggende struktur

Med den officielle pakke @modelcontextprotocol/sdk ser en minimal server, der bruger højniveau-API’en McpServer, sådan ud:

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: "Echo back the provided text.",
    inputSchema: { text: z.string() },
  },
  async ({ text }) => ({
    content: [{ type: "text", text }],
  })
);

const transport = new StdioServerTransport();
await server.connect(transport);

(Det samme SDK stiller også lavniveauklassen Server og setRequestHandler til rådighed for ListToolsRequestSchema / CallToolRequestSchema, hvis du vil have fuld kontrol over request-handlers – men for de fleste servere er McpServer.registerTool kortere og sværere at bruge forkert.)

Det er skelettet. Arbejdet ligger i, hvad du lægger i værktøjshandlerne – og hvordan.

Mønster 1: Værktøjsdesignfilosofi

Den første beslutning er, hvilke værktøjer du udstiller, og med hvilken detaljeringsgrad.

En almindelig fejl er at udstille den underliggende API direkte som værktøjer. Har du 200 REST-endpoints, er det en katastrofe at udstille 200 værktøjer. Modeller med for mange værktøjer præsterer dårligere; værktøjsbeskrivelserne bliver uoverskuelige, og protokollen bliver en labyrint.

Bedre: design værktøjer efter, hvordan agenter vil bruge dem. Hvert værktøj gør én veldefineret ting, tager veldefinerede input og returnerer veldefinerede output.

Nogle principper:

Ét koncept pr. værktøj. Undgå et manage_customer-værktøj, der gør 12 forskellige ting. Brug search_customers, get_customer, update_customer_email og archive_customer – hvert med ét klart ansvar.

Den rette detaljeringsgrad. Er den for fin, skal agenten foretage mange kald; er den for grov, kan agenten ikke udføre opgaven præcist. Sigt efter “operationer, et menneske ville navngive.”

Handlingsverber. search_documents, ikke documents. Værktøjer bør navngives efter det, de gør.

Skeln mellem læsning og skrivning. Læseværktøjer er sikrere; skriveværktøjer har sideeffekter. Gør forskellen tydelig i navnene (list_x vs create_x), og behandl dem forskelligt (kræv eksplicit bekræftelse, idempotensnøgler osv.).

Aggregér, når det er nyttigt. Et get_customer_profile-værktøj, der returnerer kunde + seneste ordrer + supportsager i ét kald, er ofte bedre end tre separate kald. Agenten får konteksten på én gang.

For en server, der eksempelvis udstiller et kundesupportsystem, vil et rimeligt værktøjssæt være 8-15 værktøjer. Flere er som regel for mange.

Mønster 2: Skemadesign

Hvert værktøj har et inputskema (parametre, som LLM’en skal angive) og et output (det, værktøjet returnerer). Skemaer er ikke kun til validering; de er prompt engineering.

Brug Zod til inputskemaer:

const searchCustomersSchema = z.object({
  query: z.string().describe(
    "Search term: name, email, or company. Be specific to avoid too many matches."
  ),
  limit: z.number().int().min(1).max(50).default(10).describe(
    "Maximum results to return. Default 10, max 50."
  ),
  filters: z.object({
    tier: z.enum(["free", "pro", "enterprise"]).optional().describe(
      "Filter to specific customer tier"
    ),
    status: z.enum(["active", "trial", "churned"]).optional().describe(
      "Filter by customer status"
    ),
  }).optional(),
});

Bemærk:

  • Hvert felt har en .describe(). Beskrivelsen er det, LLM’en læser.
  • Enumværdier er eksplicitte. Frie tekststrenge begrænses, hvor det er muligt.
  • Standardværdier er fornuftige.
  • Begrænsninger (min/max, længde) er eksplicitte.
  • Obligatoriske og valgfrie felter er tydeligt angivet.

Beskrivelserne er afgørende. “Søgeterm” er intetsigende; “Søgeterm: navn, e-mail eller virksomhed. Vær specifik for at undgå for mange resultater” er nyttig vejledning til LLM’en.

Mønster 3: Outputform

Outputtet er det, LLM’en ser og handler på. Godt outputdesign forbedrer LLM’ens adfærd markant.

Struktureret output.

type SearchResult = {
  customers: Customer[];
  total_matches: number;
  truncated: boolean;
  next_page_cursor?: string;
};

Med kontekst.

{
  customers: [...],
  total_matches: 47,
  truncated: true,
  next_page_cursor: "abc",
  message: "Found 47 matches; showing first 10. Use next_page_cursor to get more."
}

Feltet message er menneskelæselig vejledning. LLM’er bruger det.

Med fejl, der håndteres pænt.

{
  error: "ambiguous_query",
  message: "Search term 'john' matched 247 customers. Please be more specific.",
  suggestion: "Try including a company name or email domain.",
  partial_results: [...]  // top 3 by relevance, optional
}

Fejlen er struktureret (maskinlæsbar), men indeholder også en besked og et forslag (læsbart for LLM’en). LLM’en kan tilpasse sig ved enten at bede brugeren om en præcisering eller forbedre forespørgslen.

En passende størrelse.

Et værktøj, der returnerer 10,000 poster, er ubrugeligt. LLM’ens kontekst kan ikke rumme dem; selv hvis den kunne, ville LLM’en ikke bruge dem effektivt. Brug altid sideinddeling, afkortning eller opsummering. Returnér nok til, at LLM’en kan træffe en beslutning, ikke alt, der findes.

Mønster 4: Fejlsemantik

Værktøjer fejler. Den måde, de kommunikerer fejl til LLM’en på, afgør, om den håndterer fejlen korrekt eller gør problemet værre.

Fejlkategorier.

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 };

Hver kategori har sin egen semantik. LLM’en bør reagere forskelligt:

  • validation: ret input og prøv igen.
  • not_found: fortæl brugeren, eller prøv en anden søgning.
  • conflict: bed om en afklaring.
  • rate_limit: vent og prøv igen.
  • service_unavailable: prøv en reserveløsning, eller informér brugeren.
  • internal: stop, og gør fejlen synlig for brugeren.

Når disse kategorier dokumenteres på serveren, bliver LLM’en bedre i stand til at reagere korrekt.

Fejlformatering.

Returnér fejl som struktureret data med klare, handlingsspecifikke beskeder:

{
  error: {
    type: "validation",
    message: "The email address is not in a valid format.",
    field: "email",
    suggestion: "Provide a valid email address like 'name@example.com'."
  }
}

Undgå:

{
  error: "Invalid input"
}

Den første gør det muligt for LLM’en at rette fejlen. Den anden efterlader den med et gæt.

Mønster 5: Autentificering og autorisering

MCP-servere i produktion kræver godkendelse. Enhver, der kan nå serveren, kan ellers bruge værktøjerne. Det er næsten altid et problem.

Godkendelse: Hvem kalder?

Almindelige mønstre:

  • API-nøgle. Enkel, udbredt og velegnet fra tjeneste til tjeneste. Udsted én pr. forbruger, og rotér dem regelmæssigt.
  • OAuth. Til flerbrugersystemer, hvor slutbrugere giver agenter adgang. Mere komplekst, men den rette løsning i mange brugsscenarier.
  • mTLS. Til miljøer med høje sikkerhedskrav. Gensidige TLS-certifikater på begge sider.

Implementeringen afhænger af transportformen. Over HTTP godkender du forespørgslen, før den når MCP-handleren (i din Express/Hono/Fastify-middleware), og knytter kalderens identitet til forespørgslen:

// Express-style middleware in front of the MCP HTTP endpoint.
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("Unauthorized");
  (req as any).caller = caller;
  next();
});

I hver værktøjshandler henter du derefter kalderen fra konteksten for det enkelte kald (extra), ikke fra rå headers. Over stdio findes ingen HTTP-headers; godkendelsen kommer typisk fra procesmiljøet eller konfigurationsfiler.

Autorisation: Hvad må de gøre?

Når kalderen er godkendt, hvilke værktøjer må vedkommende så bruge, og på hvilke data?

function authorize(caller: Caller, tool: string, params: any): boolean {
  // Caller-level: can this caller use this tool at all?
  if (!caller.tools.includes(tool)) return false;
  
  // Data-level: is this caller authorized for this specific data?
  if (params.tenant_id && params.tenant_id !== caller.tenant_id) return false;
  
  return true;
}

Lad ikke LLM’en træffe autorisationsbeslutninger. Den kan manipuleres. Autorisation er serverens ansvar; LLM’en må kun se data, som kalderen er autoriseret til.

I systemer med flere tenants afgrænses hvert værktøjskald til én tenant. Denne tenant bestemmes af godkendelsen, ikke af parametre, som LLM’en angiver.

Mønster 6: Idempotens

Idempotens er vigtig ved skriveoperationer. LLM’en kan prøve igen eller kalde det samme værktøj to gange i forskellige sammenhænge. Uden idempotens opstår der dubletter.

Idempotensnøgler.

Værktøjet accepterer parameteren idempotency_key. Serveren kontrollerer, om nøglen er set før. Hvis ja, returneres det cachede resultat. Hvis nej, udføres handlingen, og resultatet caches.

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;
}

Gør LLM’en opmærksom på dette i værktøjsbeskrivelsen:

"For each unique invoice you create, generate a UUID and pass it as idempotency_key. If you need to retry the operation, use the same UUID to avoid duplicate creation."

Mønster 7: Streaming

For værktøjer, der producerer store output eller tager lang tid, giver streaming en bedre brugeroplevelse. MCP understøtter statusmeddelelser inde fra en værktøjshandler via argumentet extra for det enkelte kald:

server.registerTool(
  "long_running_task",
  { description: "...", inputSchema: { ... } },
  async (input, extra) => {
    await extra.sendNotification({
      method: "notifications/progress",
      params: { progressToken: extra._meta?.progressToken, progress: 0, message: "Starting..." },
    });

    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 }) }] };
  }
);

Brug streaming til:

  • Længerevarige operationer (>5 sekunder).
  • Store output (så LLM’en kan begynde behandlingen, mens outputtet stadig modtages).
  • Operationer med mellemresultater, der er værd at vise.

Brug ikke streaming til hurtige, enkle operationer – det tilføjer kompleksitet uden værdi.

Mønster 8: Caching

Mange værktøjskald rammer samme data gentagne gange. Caching kan drastisk forbedre ydeevne og reducere backend-belastning.

Lokal cache. Cache i processen (f.eks. LRU) til hyppigt anvendte data.

Distribueret cache. Redis eller lignende til en delt cache på tværs af serverinstanser.

Cacheinvalidering. Når data ændres, fjernes de relevante poster. (Det er den svære del.)

TTL’er. Cachede poster udløber efter et fast tidsrum. Tilpas det pr. datatype – kundeprofiler kan caches i timer, mens priser måske kun caches i minutter.

Caching hjælper kun, når de samme kald gentages. Det sker på mange MCP-servere – agenter refererer ofte flere gange til de samme entiteter i en session.

Et 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: Hastighedsbegrænsning

LLM-agenter kan være overraskende aggressive – de kan køre i løkker, gentage kald og sprede dem parallelt. En agent med fejlbehæftet adfærd kan overbelaste din backend.

Hastighedsbegrænsning pr. kalder er afgørende:

const limiter = new RateLimiter({ 
  windowMs: 60_000, 
  max: 100  // 100 calls/minute per caller
});

server.setRequestHandler(CallToolRequestSchema, async (request, context) => {
  if (await limiter.exceeded(context.caller.id)) {
    return errorResponse("rate_limit", "Too many requests");
  }
  // ...
});

Ud over globale grænser er grænser pr. værktøj vigtige – nogle værktøjer er dyre og bør begrænses stramt.

Brug strengere grænser eller eksplicitte bekræftelsesforløb til handlinger med væsentlige konsekvenser (oprettelse af poster, afsendelse af beskeder).

Mønster 10: Ressourcer

MCP har “ressourcer” – skrivebeskyttede datakilder, som LLM’en kan gennemse og henvise til. De adskiller sig fra værktøjer, som kaldes aktivt.

server.setRequestHandler(ListResourcesRequestSchema, async () => ({
  resources: [
    {
      uri: "doc://my-server/handbook",
      name: "Employee Handbook",
      mimeType: "text/markdown",
      description: "Company employee handbook"
    },
    // ...
  ]
}));

server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
  const content = await loadResource(request.params.uri);
  return { contents: [{ uri: request.params.uri, mimeType: "text/markdown", text: content }] };
});

Ressourcer er nyttige til:

  • Referencedokumenter, som LLM’en kan få brug for at gennemse.
  • Konfigurations- eller kontekstdata.
  • Opslagstabeller eller skemaer, som LLM’en kan få brug for.

Ressourcer læses; værktøjer udfører handlinger. Brug det rette koncept til hvert formål.

Mønster 11: Observabilitet

Her gælder de samme mønstre som andre steder i produktions-AI. Instrumentér følgende på din MCP-server:

  • Hvert værktøjskald: tidsstempel, kalder, værktøj, parametre, resultat, latenstid og status.
  • Målinger pr. værktøj: antal kald, p50/p95-latenstid og fejlrate.
  • Målinger pr. kalder: hvem der kalder og hvor ofte.
  • Sporkontekst: viderefør spor-id’er fra kalderen til backendkaldene.

Strukturerede logfiler:

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"
});

Send loggene til din observabilitetsplatform.

Mønster 12: Versionering

Din MCP-server vil udvikle sig. Værktøjer ændres, nye kommer til, og gamle udfases.

Serverversionering. Konstruktøren Server modtager en version. Hæv den ved ændringer, så klienterne kan registrere dem.

Værktøjsversionering. Når et værktøjs signatur ændres inkompatibelt, skal det versioneres: search_customers_v2. Behold den gamle version i en udfasningsperiode.

Skemaudvikling. Valgfrie felter kan tilføjes sikkert. Det er kontraktbrud at fjerne felter eller ændre deres typer.

Udfasning. Når et værktøj udfases, skal det markeres i beskrivelsen: “UDFASSET: Brug search_customers_v2 i stedet.”

Versionering er afgørende for MCP-servere i produktion, som bruges af flere klienter. Servere udelukkende til intern brug kan være mere fleksible.

Mønster 13: Testning

Hvordan tester du en MCP-server?

Enhedstest. Test logikken i hvert værktøj med simulerede afhængigheder. Det er almindelig TypeScript-test.

Skematest. Kontrollér, at skemaerne validerer som forventet, og at kanttilfælde (manglende felter, forkerte typer) håndteres korrekt.

Integrationstest. Start serveren, send faktiske MCP-forespørgsler, og verificér svarene. @modelcontextprotocol/sdk indeholder testværktøjer.

End-to-end med en rigtig LLM. Den sværeste, men mest værdifulde test. Lad en LLM bruge MCP-serveren til realistiske opgaver. Kontrollér, at LLM’en bruger værktøjerne korrekt, og find problemer i værktøjsbeskrivelserne.

En end-to-end testopsætning (pseudokode; den præcise klientforbindelse afhænger af, hvilken LLM-klient du bruger – Anthropics TypeScript SDK, OpenAIs eller et rammeværk, der understøtter MCP):

// Start your MCP server as a child process or in-memory transport.
const server = await startTestServer();

// Drive an LLM with the MCP tools attached. The exact API depends on the client.
const result = await runAgent({
  mcpServer: server,
  systemPrompt: "You are a customer service agent...",
  userMessage: "Find the customer Alice and check her open tickets",
});

// Inspect the tool calls captured by the server during the run.
expect(server.callLog.map((c) => c.name)).toEqual([
  "search_customers",
  "list_tickets",
]);

End-to-end-test opdager problemer i værktøjsbeskrivelser, som enhedstest ikke kan finde.

Mønster 14: Udrulning

Hvor bor din MCP-server?

Stdio (lokalt). Serveren kører som en proces; klienten kalder den. Bedst for desktop-applikationer (Claude Desktop, Cursor) og lokale værktøjer.

Streamable HTTP (eksternt). Serveren er en netværkstjeneste. Bedst til hostede tjenester, delt infrastruktur og adgang fra flere klienter.

For servere i produktion:

  • Streamable HTTP er typisk valget.
  • Udrul som en webtjeneste: containere, belastningsfordeling og automatisk skalering.
  • TLS kræves.
  • Sundhedstjek til udrulningsplatformen.
  • Kontrolleret nedlukning af igangværende forespørgsler.

Mønster 15: Sikkerhedshensyn

MCP-servere udstiller funktioner til LLM’er. LLM’er kan manipuleres. Det har sikkerhedsmæssige konsekvenser:

Promptinjektion via værktøjsinput. En brugerforespørgsel kan indeholde tekst, der forsøger at manipulere LLM’en til skadelige værktøjskald. Modforanstaltninger:

  • Tydelige værktøjsbeskrivelser om forventet brug.
  • Autorisation på serversiden (uafhængigt af de parametre, LLM’en vælger).
  • Bekræftelse ved handlinger med væsentlige konsekvenser.

Dataeksfiltration. Værktøjer, der returnerer data, kan misbruges – LLM’en kan manipuleres til at videregive følsomme data på upassende måder. Modforanstaltninger:

  • Autoriseringstjek.
  • Logning af, hvilke data der tilgås af hvem.
  • Registrering af usædvanlige adgangsmønstre.

Ressourceudtømning. Værktøjer, der bruger backendressourcer, kan misbruges. Modforanstaltninger:

  • Hastighedsbegrænsning.
  • Ressourcegrænser pr. værktøjskald.
  • Circuit breakers, når backenden er forringet.

Injektion via værktøjsoutput. Et værktøjs output kan indeholde tekst, som manipulerer LLM’en, når den læser det. Modforanstaltninger:

  • Rens output, hvor det er muligt.
  • Vær opmærksom på værktøjer, der returnerer brugergenereret indhold.

Det er reelle angrebsflader. Behandl MCP-servere som enhver anden API i produktion: Brug forsvar i dybden.

Et komplet eksempel: en lille, men reel MCP-server

Her samles delene i en server, der udstiller et mindre CRM-system:

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",
});

// === Tool: search_customers ===

server.registerTool(
  "search_customers",
  {
    description: "Search customers by name, email, or company.",
    inputSchema: {
      query: z.string().describe("Name, email, or company"),
      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
              ? `Showing first ${limit}; there may be more matches.`
              : `Found ${customers.length} customer(s).`,
        }),
      }],
    };

    await cache.set(cacheKey, result, { ttl: 60 });
    logger.info("search_customers", { tenant: auth.tenant_id, query, results: customers.length });
    return result;
  }
);

// === Tool: get_customer ===

server.registerTool(
  "get_customer",
  {
    description: "Fetch a single customer by 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: `Customer ${customer_id} not found. Use search_customers to find by name or email.`,
        }],
      };
    }
    return { content: [{ type: "text" as const, text: JSON.stringify({ customer }) }] };
  }
);

// === Tool: update_customer_email (with idempotency) ===

server.registerTool(
  "update_customer_email",
  {
    description: "Update a customer's email; pass the same idempotency_key on retry.",
    inputSchema: {
      customer_id: z.string(),
      new_email: z.string().email(),
      idempotency_key: z
        .string()
        .describe("UUID for this update; pass the same value on retry to prevent duplicates"),
    },
  },
  async (params, extra) => {
    const auth = await authenticate(extra);
    // ... idempotency check, validation, update
    return { content: [{ type: "text" as const, text: "ok" }] };
  }
);

// ... more tools ...

// Wire up a remote transport (Streamable HTTP) on a chosen port via your HTTP server of choice.
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => crypto.randomUUID() });
await server.connect(transport);

Det er en grundstruktur. Tilføj observabilitet, hastighedsbegrænsning, flere værktøjer og mere omhyggelige skemaer – men fundamentet er på plads.

Hvad adskiller produktionsservere fra demoer?

MCP er en lille protokol; det kræver reelt ingeniørarbejde at bygge en server i produktionskvalitet. Gevinsten er, at enhver LLM-agent kan bruge din tjeneste gennem en standardiseret integration uden modelspecifik kobling.

De afgørende mønstre er fokuseret værktøjsdesign, promptbevidste skemaer, struktureret fejlsemantik, robust godkendelse, idempotens, observabilitet og sikkerhed. Springer du et af dem over, får du en MCP-server, der fejler i produktion.

Byg dem ind fra starten. Test med rigtige LLM’er. Forbedr værktøjsbeskrivelserne løbende. Resultatet er en tjeneste, som en LLM kan bruge lige så ubesværet som et menneske – og som kan skaleres med det hastigt voksende økosystem af AI-agenter.

Læs næste

Fortsæt ad den samme læsevej med de næste praktiske artikler.