Introduction
Reading individual resources is the foundation, but real applications need patterns that tie resources into the broader workflow. How do you inject resource data into LLM conversations? How do you build a UI that updates when resources change? How do you combine resources from multiple servers into a unified context? This lesson covers the practical patterns that make resources useful in production applications.
Key Concepts
- Context Injection: Reading resources and embedding their content directly into LLM conversation messages to provide relevant context.
- Resource-Driven UI: Using resource data to populate user interfaces, with subscriptions or polling to keep the UI in sync with the server.
- Multi-Server Resources: Aggregating resources from multiple MCP servers into a single catalog, with server prefixes to avoid URI collisions.
- Smart Resource Selection: Choosing which resources to inject based on relevance to the user's query, rather than loading everything.
Real World Context
You are building an AI-powered code review tool that connects to three MCP servers: a file system server for source code, a GitHub server for PR metadata, and a linting server for code quality reports. When a user asks for a review of a pull request, your application reads the PR description from GitHub, the changed files from the file system, and the lint results from the linting server. All three are injected into the LLM's context as resources. The LLM produces a comprehensive review that considers the code changes, the PR intent, and any quality issues.
Deep Dive
Context Injection for LLMs
The most powerful resource pattern is injecting resource content directly into the LLM conversation. This gives the LLM relevant context without using tool calls:
typescriptasync function buildContext( client: Client, resourceUris: string[] ): Promise<string> { const resources = await Promise.all( resourceUris.map(uri => client.readResource({ uri })) ); return resources .map((resource, i) => { const content = resource.contents[0]; const text = content.text || "[Binary content]"; return `## ${resourceUris[i]}\n\n${text}`; }) .join("\n\n---\n\n"); } // Usage in LLM conversation const context = await buildContext(client, [ "file:///project/src/index.ts", "file:///project/package.json", "file:///project/README.md" ]); const messages = [ { role: "user" as const, content: `Here is the project context:\n\n${context}\n\nQuestion: ${userQuestion}` } ];
This pattern reads multiple resources in parallel, formats them into a context string, and includes them in the user message. The LLM sees the full context and can reference any of the included resources in its response.
Smart Resource Selection
Not every resource is relevant to every query. A smarter approach selects resources based on the user's question:
typescriptasync function selectRelevantResources( client: Client, query: string ): Promise<string[]> { const allResources = await client.listResources(); const relevantUris: string[] = []; // Simple keyword matching (use embeddings for production) const queryLower = query.toLowerCase(); for (const resource of allResources.resources) { const name = (resource.name || "").toLowerCase(); const desc = (resource.description || "").toLowerCase(); if ( queryLower.includes(name) || name.includes(queryLower) || desc.includes(queryLower) ) { relevantUris.push(resource.uri); } } return relevantUris; }
For production applications, you would use vector embeddings or an LLM to determine relevance rather than simple keyword matching. The key idea is to be selective — injecting irrelevant resources wastes context window tokens.
Combining Resources from Multiple Servers
When your application connects to multiple MCP servers, you need to aggregate resources without URI collisions:
typescriptinterface AggregatedResource { serverName: string; uri: string; name: string; mimeType?: string; description?: string; } async function aggregateResources( clients: Map<string, Client> ): Promise<AggregatedResource[]> { const allResources: AggregatedResource[] = []; for (const [serverName, client] of clients.entries()) { try { const result = await client.listResources(); for (const resource of result.resources) { allResources.push({ serverName, uri: resource.uri, name: resource.name || resource.uri, mimeType: resource.mimeType, description: resource.description }); } } catch (error) { console.error( `Failed to list resources from ${serverName}:`, error ); } } return allResources; } // When reading, route to the correct client async function readAggregatedResource( clients: Map<string, Client>, serverName: string, uri: string ) { const client = clients.get(serverName); if (!client) throw new Error(`Unknown server: ${serverName}`); return client.readResource({ uri }); }
Tagging each resource with its server name prevents confusion when two servers use the same URI scheme. When reading a resource, route the request to the correct client.
Resource-Driven UI Updates
In a web application, resources can drive UI updates through subscriptions:
typescript// React-style pseudo-code for resource-driven UI async function setupResourceUI( client: Client, uri: string, onUpdate: (content: string) => void ) { // Initial read const resource = await client.readResource({ uri }); onUpdate(resource.contents[0].text || ""); // Subscribe for live updates const capabilities = client.getServerCapabilities(); if (capabilities?.resources?.subscribe) { client.setNotificationHandler( "notifications/resources/updated", async (notification) => { if (notification.params.uri === uri) { const updated = await client.readResource({ uri }); onUpdate(updated.contents[0].text || ""); } } ); await client.subscribeResource({ uri }); } }
This pattern reads the resource once for the initial display, then subscribes for live updates. The onUpdate callback triggers a UI re-render whenever the resource changes. This is ideal for dashboards, monitoring tools, or collaborative editors.
Resource + Tool Combination Pattern
A powerful pattern combines resources for context and tools for actions:
typescriptasync function assistWithContext( client: Client, anthropic: Anthropic, userMessage: string ) { // Read resources for context (application-controlled) const contextResources = await Promise.all([ client.readResource({ uri: "project://structure" }), client.readResource({ uri: "project://conventions" }) ]); const context = contextResources .map(r => r.contents[0].text) .join("\n\n"); // Get tools for actions (model-controlled) const toolsResult = await client.listTools(); const tools = toolsResult.tools.map(t => ({ name: t.name, description: t.description || "", input_schema: t.inputSchema })); // LLM gets both context (from resources) and tools (for actions) const response = await anthropic.messages.create({ model: "claude-sonnet-4-20250514", max_tokens: 4096, system: `Project context:\n${context}`, tools, messages: [{ role: "user", content: userMessage }] }); return response; }
Resources provide the background context (project structure, coding conventions), while tools enable the LLM to take actions (create files, run commands). This separation keeps the LLM well-informed and action-capable.
Common Pitfalls
- Injecting too many resources — Every resource consumes tokens from the LLM's context window. Be selective about which resources to include. A 100-file project does not need all files in every prompt.
- Not tagging resources with server names — When aggregating from multiple servers, two servers might use the same URI for different resources. Always track which server owns each resource.
- Ignoring binary resources in context injection — Binary resources (images, PDFs) cannot be embedded as text. When building context strings, handle binary content differently — either skip it, include a description, or pass it as an image to a multimodal LLM.
Best Practices
- Be selective with context injection — Choose resources that are relevant to the current query. Use resource names, descriptions, or embeddings to determine relevance.
- Use the system message for stable context — Resources that do not change (coding conventions, project structure) work well as system message content. Dynamic resources go in user messages.
- Separate concerns: resources for context, tools for actions — Let resources inform the LLM and tools empower it. This creates a clean architecture where data flows in through resources and actions flow out through tools.
Summary
- Context injection reads resources and embeds their content in LLM conversation messages for richer, more informed responses.
- Smart resource selection filters resources by relevance to the user's query, conserving context window tokens.
- Multi-server resource aggregation tags resources with server names and routes read requests to the correct client.
- Resource-driven UI updates use subscriptions (or polling) to keep the interface in sync with server data.
- The resource + tool combination pattern uses resources for context and tools for actions, creating a clean separation of concerns.
Code Examples
// Build context from multiple resources for LLM
const resources = await Promise.all([
client.readResource({ uri: "file:///project/src/index.ts" }),
client.readResource({ uri: "file:///project/package.json" })
]);
const context = resources
.map(r => r.contents[0].text)
.join("\n---\n");
// Use in system message for stable context
const response = await anthropic.messages.create({
model: "claude-sonnet-4-20250514",
max_tokens: 4096,
system: `Project context:\n${context}`,
messages: [{ role: "user", content: userQuestion }]
});