MCP Servers as Personalization Runtime: Wiring Your User Graph to Every LLM Agent
Most teams personalize by stuffing user profiles into system prompts. That breaks at scale. MCP servers are the protocol layer that lets any LLM agent query your knowledge graph on-demand — here's the architecture, the tool schemas, and the latency budget.
MCP Servers as Personalization Runtime: Wiring Your User Graph to Every LLM Agent
TL;DR.
- MCP (Model Context Protocol) is the standard interface for LLM agents to call external tools and fetch structured data at inference time — by 2026, every major LLM runtime ships an MCP client.
- A personalization MCP server exposes your user
knowledge graphas typed tool calls — preference lookup, context retrieval, collaborative neighbor lookup — that any compliant agent can invoke without custom integration.- System-prompt injection, the dominant approach before MCP, fails at scale: context windows bloat, static snapshots miss query intent, and cold-start users get the same empty preamble as power users.
- MCP-native personalization decouples the user model from the LLM call — the agent queries what it needs, when it needs it, conditioned on what the user actually asked.
- If you only remember one thing: MCP servers turn your personalization graph from a backend service into a first-class reasoning primitive that any LLM can interrogate in real time.
The Model Context Protocol landed quietly in late 2024. By mid-2026 it is infrastructure: Claude, GPT-4o, Gemini, Mistral, and local Ollama deployments all ship MCP clients. The protocol defines a standard JSON-RPC 2.0 interface through which agents call external tools, read structured resources, and invoke templated prompts. What it does not define is what those tools should contain. For anyone building recommendation, discovery, or content surfaces, the answer is clear: the user graph. This post is about what it takes to build the server that wires those two things together — the tool schemas, the resource layout, the latency budget, and how to handle the case where you know almost nothing about the user.
What Is MCP and Why Should Personalization Engineers Care?
MCP gives an LLM agent the ability to reach outside its context window and fetch structured information at inference time via tools it calls with typed arguments. The agent decides when to call, what to ask, and how to incorporate the result. The server owns the data and the query logic.
For personalization, this changes the fundamental unit of work. The question shifts from "what profile data should I pre-compute and inject before the LLM ever sees the query?" to "what does the LLM need to answer this query for this user, and can I return it under 30ms?" That is a pull model, not a push model. The difference is architectural.
The MCP spec distinguishes three server primitives:
- Tools — callable functions with a JSON Schema for inputs. The LLM invokes these to fetch targeted data or perform actions. Personalization tools:
get_user_preferences,retrieve_user_context,get_collaborative_neighbors,record_signal. - Resources — addressable content at a URI that the client can read. Personalization resources:
user://{id}/profile,user://{id}/recent_history. Useful for audit, debug, and explanation flows where the caller wants the full object. - Prompts — templated message sequences the server can inject. Less interesting for real-time use cases than tools, but useful for reusable onboarding preambles.
Most personalization work lives in tools. The pull-on-demand pattern tools enable is what makes MCP worth adopting over the inject-everything approach.
Why System Prompt Injection Breaks
The standard approach from 2024–2025: prepend a user profile summary to every LLM system prompt.
System: You are a helpful assistant. The user is Jamie, a software engineer
who prefers technical depth. Recent interests: distributed systems, Rust,
Kubernetes. Preferred content length: long-form. Avoid beginner tutorials.
This works in demos. It fails in production for three compounding reasons.
Context windows are not free. Even at 200K tokens, filling context with profile data costs on every call. A user with 18 months of interaction history — genre affinities, duration preferences, explicit ratings, inferred mood signals, topic clusters, device context — does not compress cleanly into 500 tokens without lossy truncation. You are constantly trading completeness for cost, and "completeness" still means a static snapshot that is already stale by the time the model reads it.
Static injection cannot anticipate query intent. "Something like what I watched last Tuesday but shorter." At injection time you had no way to know that recency and duration would be the operative dimensions for this query. You injected genre affinities. The model tries to answer with the wrong features. The correct behavior is: understand the query intent, then reach into the graph for the relevant slice. Static injection forces you to predict intent before the query exists. You will be wrong more often than the user tolerates.
Cold-start users get useless context. A user with three signals and a user with three thousand receive the same injection template. There is no mechanism for the model to say "I have high uncertainty about this user — I need to ask or use population priors." With MCP, get_user_preferences returns a confidence score alongside preferences. The agent can branch on that: ask a clarifying question, surface a diversity-boosted result set, or fall back to synthetic_prior behavior. Without confidence, every user looks equally known.
System-prompt injection is not wrong for all cases. A simple chatbot with a known persona and stable preferences is adequately served by it. The problem is that production-grade personalization involves millions of heterogeneous users, multi-turn context, real-time signals, and day-zero cold-start — and static injection is a patch, not an architecture, for that scope.
MCP Server Architecture for a Personalization Graph
A personalization MCP server sits between your knowledge graph and your LLM runtime. It exposes tools the agent calls. It owns query routing, caching, and serialization. The agent never touches the graph directly — it sees only typed tool responses.
LLM Agent (Claude / GPT-4o / local model)
│
│ JSON-RPC 2.0 over HTTP Streamable
▼
MCP Personalization Server
│
├── get_user_preferences → Serving store (Redis / materialized view)
├── retrieve_user_context → ANN index (HNSW) over user history embeddings
├── get_collaborative_neighbors → Pre-materialized neighbor table
├── record_signal → Async write buffer → Kafka → graph ingest
└── explain_recommendation → Path-tracing query on graph + template render
The server is stateless between calls. It holds no conversation memory. User state lives in the graph; short-term conversational context lives in the LLM's own context window. Stateless servers scale horizontally and degrade gracefully under partial failure.
Transport
Three transports exist in the spec:
- stdio — process-local, sub-millisecond overhead, unsuitable for distributed deployments.
- SSE — streaming over HTTP, the 2024 spec version. Works but has connection management overhead.
- HTTP Streamable — the 2025 revision, replaces SSE, designed for stateless scale-out. Use this in production.
Co-locate the MCP server with your LLM inference host — same Kubernetes pod or the same availability zone at minimum. A cross-region hop for every tool call will blow your latency budget. Keep the MCP server as a sidecar; it is cheap to run.
The Four Tools Every Personalization MCP Server Should Expose
1. get_user_preferences
The most frequently called tool. Returns a scored preference vector for a user, optionally filtered by domain or recency window. This is the read path on your knowledge graph's user node and its outgoing preference edges.
{
"name": "get_user_preferences",
"description": "Returns scored preference signals for the user across specified dimensions. Call this before generating recommendations to understand what the user likes and how confident the system is in that understanding.",
"inputSchema": {
"type": "object",
"properties": {
"user_id": { "type": "string" },
"dimensions": {
"type": "array",
"items": { "type": "string" },
"description": "e.g. ['genre', 'topic', 'format', 'duration_bucket', 'creator']"
},
"recency_window_days": { "type": "integer", "default": 30 },
"include_confidence": { "type": "boolean", "default": true }
},
"required": ["user_id"]
}
}
The include_confidence flag is the most important field in this schema. A low-confidence response ("confidence": 0.09) tells the agent this user is effectively cold-start — it should behave differently: surface broader results, fall back to synthetic_prior, or ask an onboarding question. Without confidence, the agent treats every user as equally known. You end up with the same failure mode as static injection, just deferred by one call.
2. retrieve_user_context
A retrieval-augmented tool that returns the most relevant slice of a user's history given a natural-language query. This replaces "dump the last N items the user interacted with" with "fetch the items most semantically relevant to what the user just asked."
{
"name": "retrieve_user_context",
"description": "Retrieves items from the user's interaction history that are most relevant to the given query. Use when the user's request implies a specific context, mood, or preference dimension not captured in the general preference profile.",
"inputSchema": {
"type": "object",
"properties": {
"user_id": { "type": "string" },
"query": { "type": "string" },
"top_k": { "type": "integer", "default": 5 },
"include_signals": {
"type": "boolean",
"default": false,
"description": "If true, include the interaction signals (rating, completion rate) alongside each item."
}
},
"required": ["user_id", "query"]
}
}
The implementation is hybrid retrieval: embed the query, ANN-search over the user's history embeddings, rerank by a recency × relevance score. This is the same retrieval pattern as RAG applied to user graphs rather than document corpora (Lewis et al. 2020). The difference: you are retrieving from a per-user corpus, not a shared one. This is what handles "something like what I watched last Tuesday" — the agent calls retrieve_user_context with "query": "like last Tuesday, shorter", gets back the three most relevant items from that session window, and reasons about the genre, format, and duration from those results.
3. get_collaborative_neighbors
Returns aggregated preference signals from users with similar interaction patterns. The agent calls this when the current user's signal is sparse — it can reason "users with similar early-session behavior also engaged deeply with X" rather than fabricating a recommendation from three data points.
{
"name": "get_collaborative_neighbors",
"description": "Returns aggregated preference signals from users with similar behavioral profiles. Use for cold-start users or to diversify recommendations beyond the user's own explicit history.",
"inputSchema": {
"type": "object",
"properties": {
"user_id": { "type": "string" },
"top_k_neighbors": { "type": "integer", "default": 20 },
"min_similarity": { "type": "number", "default": 0.7 },
"aggregate_by": {
"type": "string",
"enum": ["preference_vector", "item_affinity", "both"],
"default": "preference_vector"
}
},
"required": ["user_id"]
}
}
This tool should never return raw user IDs or raw interaction logs — only aggregated preference vectors or anonymized item affinity scores. The server enforces this; the agent has no business needing individual neighbor identities. Knowledge graph-based collaborative filtering shows significant gains from explicit graph structure in exactly these sparse-signal cases (Wang et al. 2019).
4. record_signal
The write path. When the LLM observes something meaningful — the user selected item X, skipped Y, stated a preference explicitly — it writes that signal back to the graph in real time. This closes the feedback loop without requiring a separate client-side instrumentation layer.
{
"name": "record_signal",
"description": "Records a user interaction signal to the personalization graph. Call when you observe the user take an action or state a preference that reveals taste.",
"inputSchema": {
"type": "object",
"properties": {
"user_id": { "type": "string" },
"item_id": { "type": "string" },
"signal_type": {
"type": "string",
"enum": ["click", "skip", "complete", "rate", "explicit_preference", "explicit_rejection"]
},
"signal_value": { "type": "number" },
"context": {
"type": "object",
"description": "Session context: device, surface, query that led to the interaction."
}
},
"required": ["user_id", "item_id", "signal_type"]
}
}
explicit_preference and explicit_rejection are new signal types relative to classic instrumentation schemas. They handle the case where the LLM's conversation directly elicits stated preferences — "I hate action movies", "only show me albums longer than 40 minutes". Stated preferences are higher-quality signal than inferred behavior. They should be stored with a separate confidence weight and used to override inferred signals rather than blend with them.
Always implement record_signal as async fire-and-forget. Enqueue to Kafka or a write buffer; acknowledge immediately. The agent does not wait for graph persistence. A slow write path must not block inference.
Latency: MCP Overhead and How to Stay Under 30ms
Adding an MCP layer introduces round-trips. Every tool call is a latency budget decision. The goal is p50 < 30ms per tool call, measured at the server boundary.
The three dominant contributors:
Transport. stdio adds ~0.5ms. HTTP Streamable over localhost adds 1–2ms. HTTP over a network boundary adds 5–20ms depending on geography. Co-location eliminates most of this. Do not route MCP traffic across regions.
Graph query. A single-hop read from a warm serving store — reading a user's preference vector from Redis or a materialized Postgres view — is 1–5ms. Multi-hop graph traversals in the live graph (find items liked by users similar to this user, filtered by freshness, ranked by signal weight) can run 20–100ms. The MCP tool layer must read from pre-computed serving stores, not issue live traversal queries. Materialize the hot paths. The graph runs asynchronous background jobs that update the serving store; the MCP server reads from the store, not the graph itself.
Embedding lookup. retrieve_user_context requires embedding the incoming query and running ANN search over the user's history embeddings. HNSW indexes return in 2–10ms at 10M vectors. The bottleneck is the embedding model: use a cached sentence transformer, not a generalist LLM, for user history embeddings. Embed item content offline at index time; embed the live query at call time.
Target latencies for a well-architected server:
| Tool | Target p50 |
|---|---|
| get_user_preferences (warm cache) | < 5ms |
| retrieve_user_context (HNSW + rerank) | < 20ms |
| get_collaborative_neighbors (pre-materialized) | < 10ms |
| record_signal (async acknowledge) | < 3ms |
If you miss these, the problem is almost always an unmatured hot path — a graph traversal that should have been materialized as a serving table or a cold Redis key that evicted between calls. Instrument every tool call with p50/p99 distributions from day one.
Day-Zero Personalization via MCP
Cold-start is where an MCP personalization server earns its keep. A brand-new user has no history for get_user_preferences to return and no context for retrieve_user_context to retrieve. The naive response is an empty array. The correct response is a structured representation of uncertainty paired with a synthetic_prior that represents population-level defaults for users from this acquisition cohort.
A synthetic clone is a pre-built preference profile derived from the behavioral archetype most predictive of users who onboarded via a specific channel. When a user signs up through a "jazz essentials" recommendation ad, their synthetic clone starts with preference scores that reflect what new users who clicked that specific ad went on to engage with in months one and two.
{
"user_id": "new_user_xyz",
"confidence": 0.09,
"preferences": {},
"synthetic_prior": {
"source": "cohort:social_jazz_2026q2",
"preferences": {
"genre": [
{ "label": "jazz", "score": 0.81 },
{ "label": "blues", "score": 0.62 },
{ "label": "soul", "score": 0.54 }
],
"format": [{ "label": "album", "score": 0.74 }],
"duration_bucket": [{ "label": "30-60min", "score": 0.68 }]
},
"confidence": 0.61,
"is_synthetic": true
}
}
confidence: 0.09 at the root level tells the agent: you are working almost entirely from a synthetic prior, not observed behavior. The agent can use this to decide whether to present an onboarding question, show a discovery-broadened result set with explicit labeling ("popular with listeners like you"), or deprioritize explicit taste assertions the user has not yet validated. As real signals accumulate, confidence rises, is_synthetic edges fall off the prior, and the tool response shifts toward observed behavior.
This pattern — propagating population-level signals through graph structure to new users before direct behavioral evidence exists — is the practical form of graph-based collaborative filtering for cold-start (Zhang et al. 2020).
Security and Privacy at the MCP Layer
The MCP server is a data access surface. Every tool call is a potential vector for user data leakage. Three non-negotiable controls.
Scope user_id to the authenticated session. The LLM must not be able to call get_user_preferences with an arbitrary user_id. The MCP server validates that the user_id in the tool call matches the session principal bound at connection time. Implement this as server middleware, not inside individual tool handlers — it is too easy to forget one.
Return signals, not PII. get_collaborative_neighbors returns aggregated preference vectors, not raw user IDs. retrieve_user_context returns scored item summaries, not full interaction logs with timestamps and device metadata. The LLM does not need raw PII to personalize — it needs signals. Enforce this at the serialization layer.
Rate-limit record_signal. A rogue or hallucinating agent that spams signal writes will corrupt your graph within hours. Apply per-user, per-session write rate limits. Validate item_id against your catalog before writing — reject writes for unknown items. Reject signal_value outside the valid range. Log every write with the calling session ID for audit.
These are table stakes for any system that processes user data. MCP does not change the threat model; it creates a new access surface that needs the same controls you would apply to any internal API.
Where to Start
Week one: build get_user_preferences as a single MCP tool. Expose your most-used profile dimensions — top genres, recency-weighted affinities, explicit preferences. Include confidence. Point it at your serving store, not your training store. Measure p50 latency before moving on. This one tool will already outperform system-prompt injection for any user with a real interaction history.
Week two: add retrieve_user_context. Embed your item content at index time using a sentence transformer. Build a per-user HNSW index or a shared index with user-partitioned namespaces. Connect it to the tool. Test with multi-turn queries that reference past behavior: "something like last week but faster-paced." This is the tool that makes conversational personalization functional.
Week three: synthetic_prior for cold-start. Define your top three to five acquisition cohorts. Build synthetic preference profiles for each based on the first-30-day behavioral distribution of users from that channel. Return them from get_user_preferences when confidence < 0.25. Measure new-user engagement metrics before and after. The lift on day-zero users is typically the most legible signal — your injected profile for a new user was empty, while even a modest synthetic prior beats the population default.
Week four: record_signal and the feedback loop. Add the write path. Start with explicit signals — the LLM observes the user say "I liked that" or "skip this." Add implicit signals (completion rate, dwell, return sessions) only after the explicit signal pipeline is validated and your graph ingest is handling the write load without degradation.
If you are migrating from system-prompt injection rather than building from scratch, do not do a big-bang rewrite. Implement get_user_preferences alongside your existing injection and A/B test on a 10% traffic slice. The control group will show the most visible weakness — cold-start users — and the improvement in that cohort is usually enough to validate the migration.
The MCP personalization server is not a complex build. The core is a few hundred lines of Python or TypeScript wrapping your existing serving infrastructure. The protocol is the tractable part. The hard work — graph quality, serving store freshness, synthetic prior accuracy, signal taxonomy — was always the hard work. MCP gives every LLM agent in your stack a standard way to ask for the results.
×marble is the personalization graph.
One API. A living knowledge graph per user. Day-zero ready, explainable by construction. We built it so you don't have to.