MCP: From Hardcoded to Live Data
Hardcoded data in MCP servers is a trap. It works until it doesn't—until someone adds a new action, renames a widget, or deprecates an integration, and suddenly your MCP server is lying to the AI. This post covers how to make MCP data dynamic, when it's worth the complexity, and how to do it efficiently.
Thesis: MCP resources and tools should pull from live APIs whenever the underlying data can change. But dynamic data introduces latency, failure modes, and caching complexity. The art is knowing which data to make dynamic, how to cache it, and when to fall back gracefully.
The Problem with Hardcoded Data
We started with agent catalogs embedded directly in the MCP server:
// The trap: hardcoded catalog
const AGENT_CATALOG = [
{ name: "chat_categorizer", category: "routing", ... },
{ name: "search", category: "retrieval", ... },
{ name: "respond", category: "output", ... },
// ... 40 more entries that will become stale
];
This worked great during initial development. The AI assistant could query available agents, understand their inputs and outputs, and suggest appropriate workflows.
Then reality happened:
- Platform team added 5 new agents
- An agent was renamed for clarity
- Input schemas changed in a minor release
- A deprecated agent was removed
Our MCP server kept serving the old data. The AI confidently suggested agents that no longer existed and missed new capabilities entirely.
Hardcoded data is a snapshot. Platforms are movies.
When to Make Data Dynamic
Not everything needs to be dynamic. Here's the decision framework:
| Data Type | Make Dynamic If... | Keep Static If... |
|---|---|---|
| Agent/Action Catalogs | Platform adds agents regularly | Catalog is frozen, you control it |
| Templates | Templates evolve with platform features | Templates are internal tools only |
| Configuration Schemas | Schema validation happens on the platform | Schema is local contract only |
| Terminology/Glossary | New concepts are added frequently | Terms are stable, canonical |
| Validation Rules | Rules come from platform policy | Rules are hardcoded best practices |
Rule of thumb: If someone else can change it, make it dynamic. If you control it completely, static is fine.
Signals You Need Dynamic Data
- Frequent staleness: You're manually updating embedded data more than monthly
- Multi-consumer truth: Multiple clients (MCP, CLI, web) need the same data
- Platform dependency: The data is authoritative in another system
- Release mismatch: Your MCP server release cycle differs from the platform's
The Dynamic Data Architecture
Here's the pattern we settled on:
flowchart TD
subgraph server["MCP Server"]
CL["Cache Layer<br/>(in-memory, TTL-based)"]
FL["Fetch Layer<br/>1. Try API (with timeout)<br/>2. Fall back to embedded data<br/>3. Return cached on API failure"]
CL --> FL
end
FL --> API["Platform API<br/>(source of truth)"]
The Three-Tier Fallback
async function getAgentCatalog(
env: string = "prod",
): Promise<AgentDefinition[]> {
const cacheKey = `agents:${env}`;
// Tier 1: Check cache
const cached = cache.get(cacheKey);
if (cached && !cached.isExpired()) {
return cached.data;
}
// Tier 2: Try API
try {
const agents = await fetchAgentsFromAPI(env, { timeout: 5000 });
cache.set(cacheKey, agents, { ttl: 300 }); // 5 min TTL
return agents;
} catch (error) {
// Tier 3: Fall back gracefully
if (cached) {
// Return stale cache rather than nothing
console.warn(`API failed, serving stale cache for ${cacheKey}`);
return cached.data;
}
// Tier 4: Embedded fallback (last resort)
console.warn(`API failed, no cache, using embedded fallback`);
return EMBEDDED_AGENT_CATALOG;
}
}
Why this matters: The AI assistant always gets something. Degraded data is better than no data. Stale cache is better than embedded fallback. Fresh API data is best.
Caching Strategies
TTL-Based Caching
The simplest approach: cache responses for a fixed duration.
const CACHE_CONFIG = {
agents: { ttl: 300 }, // 5 minutes - changes infrequently
templates: { ttl: 600 }, // 10 minutes - even more stable
actions: { ttl: 60 }, // 1 minute - may change during active dev
schemas: { ttl: 3600 }, // 1 hour - rarely changes
};
Trade-offs:
- ✅ Simple to implement and reason about
- ✅ Predictable behavior
- ❌ May serve stale data for up to TTL duration
- ❌ May hit API unnecessarily if data hasn't changed
Stale-While-Revalidate
Serve cached data immediately, refresh in background:
async function getWithSWR<T>(
key: string,
fetcher: () => Promise<T>,
options: { ttl: number; staleWhileRevalidate: number },
): Promise<T> {
const cached = cache.get(key);
if (cached) {
const age = Date.now() - cached.timestamp;
if (age < options.ttl) {
// Fresh: return immediately
return cached.data;
}
if (age < options.ttl + options.staleWhileRevalidate) {
// Stale but acceptable: return immediately, refresh in background
fetcher()
.then((data) => cache.set(key, data))
.catch(() => {});
return cached.data;
}
}
// Expired or missing: fetch synchronously
const data = await fetcher();
cache.set(key, data);
return data;
}
Trade-offs:
- ✅ Fast responses (almost always from cache)
- ✅ Eventually consistent with source
- ❌ More complex implementation
- ❌ Background fetches use resources
Request Coalescing
Prevent thundering herd when cache expires:
const inFlightRequests = new Map<string, Promise<any>>();
async function getCoalesced<T>(
key: string,
fetcher: () => Promise<T>,
): Promise<T> {
// If request is already in flight, wait for it
if (inFlightRequests.has(key)) {
return inFlightRequests.get(key);
}
// Start new request
const promise = fetcher().finally(() => {
inFlightRequests.delete(key);
});
inFlightRequests.set(key, promise);
return promise;
}
Trade-offs:
- ✅ Prevents duplicate API calls
- ✅ Reduces load on platform API
- ❌ All waiters fail if the request fails
- ❌ Adds complexity
Environment-Aware Dynamic Data
Many platforms have multiple environments (dev, staging, prod). Your MCP server should support this:
// Resource URI with environment parameter
// ema://catalog/agents?env=dev
server.resource("catalog/agents", async (uri) => {
const env = uri.searchParams.get("env") || "prod";
const agents = await getAgentCatalog(env);
return {
contents: [
{
uri: uri.toString(),
mimeType: "application/json",
text: JSON.stringify(agents, null, 2),
},
],
};
});
This lets the AI assistant work with different environments without code changes:
User: "What agents are available in the dev environment?"
AI: [Fetches ema://catalog/agents?env=dev]
"In the dev environment, there are 45 agents including 3 that are still experimental..."
Efficient API Design
Batch Endpoints
Instead of N requests for N items:
// Bad: N+1 queries
for (const agentName of agentNames) {
const details = await fetchAgentDetails(agentName);
}
// Good: Single batch request
const allDetails = await fetchAgentDetailsBatch(agentNames);
If the platform API doesn't support batching, implement client-side batching:
class BatchedFetcher {
private pending = new Map<string, { resolve: Function; reject: Function }>();
private batchTimeout: NodeJS.Timeout | null = null;
async get(id: string): Promise<AgentDetails> {
return new Promise((resolve, reject) => {
this.pending.set(id, { resolve, reject });
if (!this.batchTimeout) {
this.batchTimeout = setTimeout(() => this.flush(), 10); // 10ms batch window
}
});
}
private async flush() {
const batch = new Map(this.pending);
this.pending.clear();
this.batchTimeout = null;
try {
const results = await fetchAgentDetailsBatch([...batch.keys()]);
for (const [id, result] of Object.entries(results)) {
batch.get(id)?.resolve(result);
}
} catch (error) {
for (const { reject } of batch.values()) {
reject(error);
}
}
}
}
Summary vs. Detail Endpoints
Provide both summary (for discovery) and detail (for deep dives):
// Summary: fast, returns all items with minimal fields
server.resource("catalog/agents-summary", async () => {
const summary = await getAgentSummary(); // name, category, one-liner
return { contents: [{ text: JSON.stringify(summary) }] };
});
// Detail: slower, returns full schema for specific agent
server.tool("get_agent_details", async ({ action_name }) => {
const details = await getAgentDetails(action_name); // full inputs, outputs, rules
return { content: [{ type: "text", text: JSON.stringify(details) }] };
});
The AI can quickly browse the summary, then drill into specific agents as needed.
Failure Modes and Graceful Degradation
Timeout Handling
APIs will be slow sometimes. Don't let one slow request block everything:
async function fetchWithTimeout<T>(
fetcher: () => Promise<T>,
timeoutMs: number,
fallback: T,
): Promise<T> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
const result = await fetcher();
clearTimeout(timeoutId);
return result;
} catch (error) {
clearTimeout(timeoutId);
if (error.name === "AbortError") {
console.warn(`Request timed out after ${timeoutMs}ms, using fallback`);
}
return fallback;
}
}
Partial Failure
When fetching multiple resources, don't let one failure poison the batch:
async function fetchAllCatalogs(): Promise<CatalogResult> {
const [agents, templates, schemas] = await Promise.allSettled([
getAgentCatalog(),
getTemplateCatalog(),
getSchemaCatalog(),
]);
return {
agents: agents.status === "fulfilled" ? agents.value : FALLBACK_AGENTS,
templates:
templates.status === "fulfilled" ? templates.value : FALLBACK_TEMPLATES,
schemas: schemas.status === "fulfilled" ? schemas.value : FALLBACK_SCHEMAS,
warnings: [
agents.status === "rejected" &&
"Agent catalog unavailable, using fallback",
templates.status === "rejected" &&
"Template catalog unavailable, using fallback",
schemas.status === "rejected" &&
"Schema catalog unavailable, using fallback",
].filter(Boolean),
};
}
Communicating Degradation
Let the AI know when it's working with degraded data:
server.tool("list_agents", async () => {
const result = await getAgentCatalog();
return {
content: [
{
type: "text",
text: JSON.stringify({
agents: result.agents,
metadata: {
source: result.source, // 'api' | 'cache' | 'fallback'
freshness: result.fetchedAt,
warning:
result.source === "fallback"
? "Using embedded fallback data—may be outdated"
: undefined,
},
}),
},
],
};
});
When NOT to Use Dynamic Data
Dynamic isn't always better. Keep data static when:
1. Latency Is Critical
If the tool is in a tight loop or latency-sensitive path, dynamic fetching adds overhead:
// Bad: Dynamic fetch in validation loop
for (const node of workflow.nodes) {
const schema = await fetchSchema(node.type); // N API calls
validate(node, schema);
}
// Good: Pre-fetch once, validate many
const schemas = await fetchAllSchemas(); // 1 API call, cached
for (const node of workflow.nodes) {
validate(node, schemas[node.type]);
}
2. Offline/Air-Gapped Environments
Some deployments can't reach external APIs:
const config = {
dataSource: process.env.MCP_DATA_SOURCE || "api", // 'api' | 'embedded'
};
async function getCatalog() {
if (config.dataSource === "embedded") {
return EMBEDDED_CATALOG; // No network dependency
}
return fetchFromAPI();
}
3. The Data Is Truly Static
If data genuinely doesn't change, dynamic fetching is waste:
// This doesn't need to be dynamic
const WELL_KNOWN_TYPES = {
WELL_KNOWN_TYPE_STRING: { base: "string" },
WELL_KNOWN_TYPE_JSON: { base: "object" },
// ... stable, canonical type definitions
};
4. You Need Deterministic Behavior
Testing and debugging are easier with predictable data:
// Test mode: use static fixtures
if (process.env.NODE_ENV === "test") {
return TEST_FIXTURES.agentCatalog;
}
Implementation Checklist
When converting from hardcoded to dynamic:
- [ ] Identify the source of truth — which API endpoint owns this data?
- [ ] Define cache TTL — how stale is acceptable?
- [ ] Implement fallback chain — API → cache → embedded
- [ ] Add timeout handling — don't block on slow APIs
- [ ] Support environment selection — dev/staging/prod
- [ ] Include metadata — let consumers know data freshness
- [ ] Test failure modes — what happens when the API is down?
- [ ] Monitor cache hit rates — are you hitting the API too much?
The Result
After moving to dynamic data:
Before:
- MCP server releases needed to update agent catalog
- AI suggested deprecated agents
- New features invisible until manual update
- Different clients had different "truths"
After:
- Platform API is single source of truth
- AI sees new agents within cache TTL
- Deprecated agents disappear automatically
- All clients (MCP, CLI, web) see same data
- Graceful degradation when API is unavailable
The AI assistant now works with current platform capabilities, not a snapshot from whenever you last deployed.
This post is part of a series on MCP patterns. See Cursor Rules vs MCP for when to use rules vs. MCP, and The MCP Mental Model for the conceptual foundation.