Σε δύο σειρές

Έχω παραδώσει δύο production MCP servers σε TypeScript με το @modelcontextprotocol/sdk:

  • Heartbeat PIMembedded MCP server που τρέχει μέσα στο ίδιο Express backend με το REST API του καταλόγου. Ο Claude agent συνδέεται μέσω stdio και κάνει direct tool calls στη βάση προϊόντων. Καμία HTTP overhead, καμία public επιφάνεια.
  • Rorsa Toolsstandalone MCP server, που διανέμεται ως Claude Code plugin. Ο ίδιος TypeScript κώδικας λειτουργεί και ως CLI binary για developers που θέλουν να χρησιμοποιήσουν το toolkit από το terminal.

Η απόφαση «embedded vs standalone» καθορίζει σχεδόν τα πάντα στο υπόλοιπο του build. Αυτό το άρθρο αναλύει και τα δύο σχήματα, την πραγματική αρχιτεκτονική, τα trade-offs, και πότε διαλέγεις το καθένα.

Τι είναι πραγματικά ένας MCP server

Το Model Context Protocol είναι ένα JSON-RPC-over-stdio (ή HTTP / SSE) protocol που εκδίδει η Anthropic, και επιτρέπει σε έναν AI agent (Claude, Claude Code, Continue, κ.λπ.) να καλεί tools που εσύ ορίζεις και να διαβάζει resources που εκθέτεις. Από την οπτική του agent, τα tools σου μοιάζουν απαράλλακτα από τα built-in.

Ένας μίνιμαλ TypeScript MCP server μοιάζει έτσι:

import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';

const server = new McpServer({ name: 'pim', version: '1.0.0' });

server.tool(
  'get_product',
  { sku: { type: 'string' } },
  async ({ sku }) => {
    const product = await db.products.findOne({ where: { sku } });
    return { content: [{ type: 'text', text: JSON.stringify(product) }] };
  }
);

await server.connect(new StdioServerTransport());

Αυτό είναι όλο το «API». Ο Claude agent στην άλλη άκρη παίρνει ένα tool με όνομα get_product που μπορεί να καλέσει με ένα SKU.

Το standalone σχήμα: Rorsa Tools

Το Rorsa Tools είναι ένα published MCP toolkit (και Claude Code plugin) που εκθέτει 14 image-generation, editing, και processing tools σε AI agents. Είναι το toolkit που δημιούργησε κάθε pixel-art εικόνα σε αυτό το portfolio.

Η αρχιτεκτονική:

  • CLI binary (vertex-img) — Commander-driven CLI για developers που θέλουν να τρέξουν τα ίδια tools από το terminal
  • MCP server (vertex-image-mcp) — εκθέτει τα ίδια 14 tools σε Claude agents
  • Ένα codebase, δύο interfaces — η κεντρική αρχιτεκτονική απόφαση

Παραδείγματα tool surface:

  • generate_image — Vertex AI Nano Banana ή Gemini Flash, ανάλογα με ένα text-density heuristic
  • remove_background — τρέχει σε Python sidecar process (RMBG-2.0 model), επειδή υπάρχει μόνο σε PyTorch
  • generate_responsive_sets — Sharp pipeline, παράγει 640w/960w/1280w/1920w από ένα source
  • compress_images, convert_image_formats, resize_images, get_image_metadata — Sharp πάλι
  • generate_favicon — png-to-ico για το πλήρες favicon bundle

Ένας Claude Code agent μπορεί να αλυσιδώσει generate_image → remove_background → generate_responsive_sets → compress_images σε μία συνομιλία, χωρίς script.

Το standalone είναι σωστό σχήμα όταν:

  • Τα tools είναι χρήσιμα σε πολλά projects
  • Θες versioned distribution (Claude Code plugin manifest, npm package)
  • Το tool surface είναι portable και δεν χρειάζεται τα ιδιωτικά σου δεδομένα

Το embedded σχήμα: Heartbeat PIM

Το δεύτερο MCP shipment ζει μέσα στο product information management backend του Heartbeat. Το PIM είναι ένα Express 5 + TypeORM service που:

  • Φιλοξενεί το REST API που χρησιμοποιεί το React admin για catalog browsing, editing, και order ops
  • Επιπλέον τρέχει έναν @modelcontextprotocol/sdk server στο ίδιο Node process, μιλώντας στην ίδια Postgres βάση

Ο Claude agent συνδέεται μέσω stdio (local IPC, χωρίς HTTP) και μπορεί:

  • get_product(sku) — διαβάζει product details
  • update_description(sku, description) — ξαναγράφει μια περιγραφή μετά από AI enrichment
  • enrich_batch(skus, strategy) — περνά μια λίστα SKUs μέσα από το enrichment pipeline

Το point είναι ότι ο agent έχει το ίδιο data view με το REST API. Δεν υπάρχει ξεχωριστό microservice, καμία δημόσια network egress, κανένα API key προς rotation.

Shared state, shared transactions

Το δύσκολο κομμάτι ενός embedded MCP server είναι το transaction scoping. Και οι REST handlers και τα MCP tools χρησιμοποιούν την ίδια TypeORM data source. Αν ένα tool ξεκινήσει transaction για να ενημερώσει ένα product, και μια REST request έρθει μέσα στη μέση, και τα δύο πρέπει να είναι ασφαλή.

Δύο κανόνες το έκαναν tractable:

  1. Τα read-only tools παίρνουν read-only data source. Τα get_product, list_products, search_catalog χρησιμοποιούν Postgres role που δεν έχει write privileges. Ο agent κυριολεκτικά δεν μπορεί να μεταλλάξει state μέσω αυτών.
  2. Τα write tools παίρνουν explicit transactions. Το update_description τυλίγει τη δουλειά του σε ένα dataSource.transaction(async (tx) => {...}) block. Αν το REST layer κάνει concurrent update, το Postgres serialize-άρει τα writes· αν ένα write αποτύχει, το transaction κάνει rollback· η MCP response επιστρέφει το error στον agent.

Γιατί να μην χτυπήσει το REST API απευθείας;

Αυτή είναι η ερώτηση που ακούω συχνότερα. Γιατί ο Claude agent δεν καλεί απλά τα υπάρχοντα REST endpoints; Τρεις λόγοι:

  1. Καμία HTTP overhead. Το stdio είναι local IPC. Κάθε 1ms κλήση αντικαθιστά ένα 30ms HTTP round-trip.
  2. Καμία public επιφάνεια. Ο MCP server δεν είναι network-exposed. Δεν υπάρχει port να μπλοκάρεις, ούτε API key να διαχειριστείς, ούτε rate-limit να ρυθμίσεις.
  3. Tool semantics > endpoint semantics. Ένα REST endpoint σχηματίζεται γύρω από HTTP verbs και resource paths. Ένα MCP tool σχηματίζεται γύρω από αυτό που ο AI agent χρειάζεται να κάνει. Το enrich_batch(skus, strategy: 'descriptions' | 'keywords' | 'all') είναι ρήμα. Το POST /api/products/bulk-enrich είναι route. Δεν είναι στο ίδιο επίπεδο abstraction.

Το embedded είναι σωστό σχήμα όταν:

  • Τα δεδομένα είναι ιδιωτικά και δεν πρέπει να βγαίνουν εκτός process boundary
  • Ο agent χρειάζεται high-frequency tool surface (σκέψου enrichment πάνω σε χιλιάδες προϊόντα)
  • Τα tools είναι coupled με το business data model σου, με τρόπο που δεν θα είχε νόημα ως generic toolkit

Δύο production decisions που αξίζει να αντιγράψεις

1. Μη περνάς JSON blobs ως tool args

Ο πειρασμός είναι να φτιάξεις generic query_product(filter: object) tool που δέχεται αυθαίρετο filter. Μην το κάνεις. Όρισε narrow, named tools — find_by_sku, find_by_vendor, list_low_stock — καθένα με explicit args. Ο Claude είναι δραματικά καλύτερος στο να διαλέγει το σωστό tool παρά στο να κατασκευάζει το σωστό filter blob.

2. Επιστρέφε text content, όχι raw JSON

Τα tools επιστρέφουν content μέσω { content: [{ type: 'text', text: '...' }] }. Ο agent διαβάζει αυτό το text. Αν επιστρέφεις raw JSON, ο agent πρέπει να το κάνει parse. Αν επιστρέφεις μια παράγραφο summary συν το JSON, μπορεί και να διαβάσει το summary γρήγορα και να κάνει parse το detail αν χρειαστεί:

return {
  content: [
    { type: 'text', text: `Product ${product.sku}: ${product.name} — ${product.stock} in stock, last updated ${product.updatedAt}` },
    { type: 'text', text: JSON.stringify(product, null, 2) },
  ],
};

Αυτή είναι η μεγαλύτερη μεμονωμένη tool-quality νίκη που έχω μετρήσει. Το πρώτο text block είναι token-cheap summary· το δεύτερο είναι τα raw data που ο agent μπορεί να χρησιμοποιήσει ως fallback.

Πότε standalone, πότε embedded

Standalone (Rorsa Tools σχήμα)Embedded (Heartbeat PIM σχήμα)
Distributionnpm / Claude Code pluginΚανένα — εσωτερικό στο host backend
DataPublic / χωρίς secretsPrivate — business data
Tool designGeneric, reusableTied στο domain model σου
Transportstdio + προαιρετικό HTTPμόνο stdio
AuthΜερικές φορές tokenΤο process boundary ΕΙΝΑΙ το auth
Κόστος shippingVersioned package + docsΆλλο ένα module στο backend σου

Αν χτίζεις agentic features για το δικό σου προϊόν, embed. Αν παραδίδεις tools που άλλες ομάδες πρέπει να χρησιμοποιήσουν, standalone.

Παρόμοια έργα