cd /news/large-language-models/teaching-an-llm-to-pull-mcp-resource… Β· home β€Ί topics β€Ί large-language-models β€Ί article
[ARTICLE Β· art-115561] src=dev.to β†— pub= topic=large-language-models verified=true sentiment=Β· neutral

Teaching an LLM to pull MCP Resources and Prompts on demand (instead of drowning it in context)

A developer detailed a technique for integrating Model Context Protocol (MCP) resources and prompts into an LLM's tool-calling loop, converting them into synthetic tools to avoid context bloat. The approach, built on LangGraph and langchain-mcp-adapters, allows the model to fetch resources on demand, preserving full fidelity and reducing token usage.

read7 min views2 publishedAug 30, 2026

How we wired the Model Context Protocol's "application-controlled" primitives into a model-controlled tool-calling loop β€” and why that small shift changes everything about context hygiene.

The Model Context Protocol (MCP) gives a server three ways to expose capability: tools, resources, and prompts. Tools drop straight into an LLM's function-calling loop. Resources and prompts don't β€” they're application-controlled, so most integrations just dump every resource's content into the system prompt and hope for the best.

That approach bloats context, truncates large documents, breaks on binary files, and gives the model zero say in what it actually needs.

Our fix: promote resources and prompts into synthetic, auto-approved LLM tools β€” read_resource(uri)

and invoke_prompt(name)

. The system prompt now carries only a lightweight catalog (URIs + descriptions). The model reads a resource only when it decides it needs one, through the exact same tool-calling machinery it already uses. On-demand, selective, full-fidelity.

MCP defines three server capabilities, but the interesting part is who's in control of each:

Primitive Who decides when it's used Natural fit for tool-calling?
Tools
The model (it calls them)
βœ… Yes β€” this is what function calling is
Resources
The application / user
❌ No native hook in the loop
Prompts
The user (usually a slash-command)
❌ No native hook in the loop

Tool calling is model-controlled by design: the LLM emits a tool_use

block, you execute it, you feed the result back. Beautiful.

Resources and prompts are application-controlled. The spec's mental model is a human clicking "attach this file" or "/use this prompt template." There is no obvious place for them inside an autonomous agent's reasoning loop. So what do most integrations do?

The path of least resistance is to fetch every resource at startup and paste it into the system prompt:

## Available Resources
### Resource: SUM ABAP Test Matrix
URI: sap-btp://sum-abap-v1
Content:
<... 11,000 characters of markdown ...>
### Resource: API Docs
URI: sap-btp://api-docs
Content:
<... more ...>

Four problems show up fast:

content[:2000]

) β€” and now large documents are silently chopped. In our case the SUM ABAP matrix lost its entire product list and output-format section below the 2,000-char line.blob.as_string()

, hit a UnicodeDecodeError

, and quietly emit "[No content available]"

.Here's the shift. The LLM already has a clean, well-understood way to ask for something on demand: it calls a tool. So instead of fighting the control model, we translate it.

We register two DARA-internal tools that don't exist on any MCP server β€” they're synthesized client-side:

read_resource(uri)

invoke_prompt(name)

The system prompt now advertises only a catalog β€” names, URIs, and descriptions, no content:

## Available Resources
The following resources can be read on demand. To read one, call the
`read_resource` tool with its exact URI. Do not assume a resource's
contents until you have read it.

### Resource: SUM ABAP Test Matrix
URI: sap-btp://sum-abap-v1
Description: SUM (Software Update Manager) test matrix specification for ABAP products

The model sees what exists, then reaches for exactly what it needs β€” through the tool loop it already speaks fluently.

We built this on top of LangGraph + langchain-mcp-adapters

, but the pattern is framework-agnostic.

read_resource

is a StructuredTool

with a one-field schema. Its func

is a no-op lambda β€” we never actually run it as a function; we intercept it in the graph (see step 3).

from pydantic import BaseModel, Field
from langchain_core.tools import StructuredTool

class _ReadResourceArgs(BaseModel):
    uri: str = Field(description="The exact URI of the MCP resource to read, "
                                 "e.g. 'sap-btp://sum-abap-v1'.")

read_resource_tool = StructuredTool.from_function(
    func=lambda uri: "",                      # placeholder β€” handled in the graph
    name="read_resource",
    description=(
        "Read the full contents of an MCP resource by its URI. "
        "Call this when the user asks to read, open, or summarize a resource, "
        "or when you need a resource's contents to answer. "
        "Only resources listed under 'Available Resources' can be read."
    ),
    args_schema=_ReadResourceArgs,
)
tools = list(tools) + [read_resource_tool]
allowed_tools_without_review.append("read_resource")   # auto-approve, no human gate

Two things matter here:

We fetch resources once (the adapter already returns their content as Blob

objects) and index them by URI so the on-demand read is an O(1) lookup β€” no second network round-trip:

resources_content = {}
for res in (resources or []):
    uri, name, description, content = _extract_resource_fields(res)
    uri = str(uri) if uri is not None else ""
    if uri and uri != "unknown":
        resources_content[uri] = {"name": name, "content": content}

When the model emits a read_resource

call, we don't invoke a function β€” we look up the content and hand it back as a tool message. Because a tool result flows naturally back into the model's context, the content lands only when requested:

if tool_call["name"] == "read_resource":
    uri = str(tool_call["args"].get("uri", ""))
    res = resources_content.get(uri)
    if res:
        new_messages.append({
            "role": "tool",
            "name": "read_resource",
            "content": res["content"],          # full content, no truncation
            "tool_call_id": tool_call["id"],
        })
    else:
        available = list(resources_content.keys())
        new_messages.append({
            "role": "tool",
            "name": "read_resource",
            "content": f"Resource '{uri}' not found. Available URIs: {available}",
            "tool_call_id": tool_call["id"],
        })
    continue

The mirror image for prompts: invoke_prompt

injects the template's messages as real Human

/AI

turns, which is exactly how the MCP spec intends prompts to be surfaced.

langchain-mcp-adapters

collapses every resource into a Blob

: text lands in .data

as a str

, binary as raw bytes

, with the media type on the ** .mimetype attribute** (not in metadata β€” a common trip-up). So we branch on the mime type instead of blindly calling

.as_string()

:

def _is_text_mime(mime: str) -> bool:
    mime = (mime or "").lower().split(";")[0].strip()
    return (mime.startswith("text/")
            or mime.endswith(("+json", "+xml", "+yaml"))
            or mime in {"application/json", "application/xml", "application/yaml"})

data = getattr(resource, "data", None)
if isinstance(data, bytes):
    if _is_text_mime(mime):
        return uri, name, description, data.decode("utf-8")
    return uri, name, description, (
        f"[Binary resource: {mime or 'application/octet-stream'}, "
        f"{_human_size(len(data))}. This is not text and cannot be inlined; "
        f"open it with a client that handles its media type.]"
    )

This is aligned with the MCP spec itself: binary payloads belong in typed media content blocks or are referenced by URI β€” never stuffed into a text field. A model reading [Binary resource: application/pdf, 240.0 KB]

knows exactly what it's looking at and can decide what to do, instead of choking on garbage or getting a misleading "no content."

   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
   β”‚  User message   β”‚
   β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜
            β”‚
            β–Ό
   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
   β”‚ System prompt = resource CATALOG only     β”‚
   β”‚ (URIs + descriptions, NO content)         β”‚
   β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
            β”‚
            β–Ό
      β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
      β”‚  LLM decides  β”‚
      β””β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”˜
         β”‚         β”‚
 needs a β”‚         β”‚ doesn't need one
 resourceβ”‚         └──────────────► Answer directly
         β–Ό
 β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
 β”‚ tool_use: read_resource  β”‚
 β”‚        (uri)             β”‚
 β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
              β–Ό
 β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
 β”‚ Graph intercepts the call     β”‚
 β”‚ (auto-approved, no HITL gate) β”‚
 β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
              β–Ό
 β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
 β”‚ Look up URI in content map    β”‚
 β””β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜
         β”‚ text          β”‚ binary
         β–Ό               β–Ό
 β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
 β”‚ Full content   β”‚  β”‚ Descriptor:          β”‚
 β”‚ as tool messageβ”‚  β”‚ mime type + size     β”‚
 β””β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
         β”‚                       β”‚
         β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                     β–Ό
              (back to LLM ──► answer)

Only the "needs a resource" branch ever pays the content cost β€” and it pays the full cost, untruncated, for just that resource.

AnyUrl

vs str

.AnyUrl

objects. If your content map is keyed by AnyUrl

and the LLM passes a plain string, .get()

silently misses. Normalize to str

on .mimetype

, not metadata.langchain-mcp-adapters

Blob

s, the media type is an attribute; metadata only carries the uri

. Read the right field or every binary looks like application/octet-stream

.description

. Invest in it.func

is fine.The binary branch is the single hook for richer handling: extract PDF text server-side, or emit an ImageContent

block to a vision-capable model for images. Because everything already funnels through one read_resource

path, adding a modality is a localized change β€” not a re-architecture.

The bigger takeaway: when a protocol primitive doesn't fit your execution model, don't force the model to swallow it up front. Give the model an affordance to ask, and let the loop it already understands do the rest.

Built on the Model Context Protocol (2025-03-26), LangGraph, and langchain-mcp-adapters. The pattern is framework-agnostic β€” anywhere you have tool-calling and MCP, you can do this.

── more in #large-language-models 4 stories Β· sorted by recency
── more on @model context protocol 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain β€” perfect for shipping the agent you just read about.

$git push zahid main
β†’ Live at https://your-agent.zahid.host βœ“
Get free account β†’ Pricing
from €0/mo Β· no card required
LIVE [news/teaching-an-llm-to-p…] indexed:0 read:7min 2026-08-30 Β· β€”