{"slug": "teaching-an-llm-to-pull-mcp-resources-and-prompts-on-demand-instead-of-drowning", "title": "Teaching an LLM to pull MCP Resources and Prompts on demand (instead of drowning it in context)", "summary": "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.", "body_md": "*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.*\n\nThe 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.\n\nThat approach bloats context, truncates large documents, breaks on binary files, and gives the model zero say in what it actually needs.\n\nOur fix: **promote resources and prompts into synthetic, auto-approved LLM tools** — `read_resource(uri)`\n\nand `invoke_prompt(name)`\n\n. 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.\n\nMCP defines three server capabilities, but the interesting part is *who's in control* of each:\n\n| Primitive | Who decides when it's used | Natural fit for tool-calling? |\n|---|---|---|\nTools |\nThe model (it calls them) |\n✅ Yes — this is what function calling is\n|\nResources |\nThe application / user\n|\n❌ No native hook in the loop |\nPrompts |\nThe user (usually a slash-command) |\n❌ No native hook in the loop |\n\nTool calling is *model-controlled* by design: the LLM emits a `tool_use`\n\nblock, you execute it, you feed the result back. Beautiful.\n\nResources 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?\n\nThe path of least resistance is to fetch **every** resource at startup and paste it into the system prompt:\n\n```\n## Available Resources\n### Resource: SUM ABAP Test Matrix\nURI: sap-btp://sum-abap-v1\nContent:\n<... 11,000 characters of markdown ...>\n### Resource: API Docs\nURI: sap-btp://api-docs\nContent:\n<... more ...>\n```\n\nFour problems show up fast:\n\n`content[:2000]`\n\n) — 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()`\n\n, hit a `UnicodeDecodeError`\n\n, and quietly emit `\"[No content available]\"`\n\n.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.\n\nWe register two **DARA-internal** tools that don't exist on any MCP server — they're synthesized client-side:\n\n`read_resource(uri)`\n\n`invoke_prompt(name)`\n\nThe system prompt now advertises only a **catalog** — names, URIs, and descriptions, *no content*:\n\n```\n## Available Resources\nThe following resources can be read on demand. To read one, call the\n`read_resource` tool with its exact URI. Do not assume a resource's\ncontents until you have read it.\n\n### Resource: SUM ABAP Test Matrix\nURI: sap-btp://sum-abap-v1\nDescription: SUM (Software Update Manager) test matrix specification for ABAP products\n```\n\nThe model sees what exists, then reaches for exactly what it needs — through the tool loop it already speaks fluently.\n\nWe built this on top of LangGraph + `langchain-mcp-adapters`\n\n, but the pattern is framework-agnostic.\n\n`read_resource`\n\nis a `StructuredTool`\n\nwith a one-field schema. Its `func`\n\nis a no-op lambda — we never actually *run* it as a function; we intercept it in the graph (see step 3).\n\n``` python\nfrom pydantic import BaseModel, Field\nfrom langchain_core.tools import StructuredTool\n\nclass _ReadResourceArgs(BaseModel):\n    uri: str = Field(description=\"The exact URI of the MCP resource to read, \"\n                                 \"e.g. 'sap-btp://sum-abap-v1'.\")\n\nread_resource_tool = StructuredTool.from_function(\n    func=lambda uri: \"\",                      # placeholder — handled in the graph\n    name=\"read_resource\",\n    description=(\n        \"Read the full contents of an MCP resource by its URI. \"\n        \"Call this when the user asks to read, open, or summarize a resource, \"\n        \"or when you need a resource's contents to answer. \"\n        \"Only resources listed under 'Available Resources' can be read.\"\n    ),\n    args_schema=_ReadResourceArgs,\n)\ntools = list(tools) + [read_resource_tool]\nallowed_tools_without_review.append(\"read_resource\")   # auto-approve, no human gate\n```\n\nTwo things matter here:\n\nWe fetch resources once (the adapter already returns their content as `Blob`\n\nobjects) and index them by URI so the on-demand read is an O(1) lookup — no second network round-trip:\n\n```\nresources_content = {}\nfor res in (resources or []):\n    uri, name, description, content = _extract_resource_fields(res)\n    # URIs can arrive as pydantic AnyUrl objects — normalize to str so the\n    # plain-string URI the LLM passes actually matches the dict key. (Gotcha!)\n    uri = str(uri) if uri is not None else \"\"\n    if uri and uri != \"unknown\":\n        resources_content[uri] = {\"name\": name, \"content\": content}\n```\n\nWhen the model emits a `read_resource`\n\ncall, 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*:\n\n```\nif tool_call[\"name\"] == \"read_resource\":\n    uri = str(tool_call[\"args\"].get(\"uri\", \"\"))\n    res = resources_content.get(uri)\n    if res:\n        new_messages.append({\n            \"role\": \"tool\",\n            \"name\": \"read_resource\",\n            \"content\": res[\"content\"],          # full content, no truncation\n            \"tool_call_id\": tool_call[\"id\"],\n        })\n    else:\n        available = list(resources_content.keys())\n        new_messages.append({\n            \"role\": \"tool\",\n            \"name\": \"read_resource\",\n            \"content\": f\"Resource '{uri}' not found. Available URIs: {available}\",\n            \"tool_call_id\": tool_call[\"id\"],\n        })\n    continue\n```\n\nThe mirror image for prompts: `invoke_prompt`\n\ninjects the template's messages as real `Human`\n\n/`AI`\n\nturns, which is exactly how the MCP spec intends prompts to be surfaced.\n\n`langchain-mcp-adapters`\n\ncollapses every resource into a `Blob`\n\n: text lands in `.data`\n\nas a `str`\n\n, binary as raw `bytes`\n\n, 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\n\n`.as_string()`\n\n:\n\n``` php\ndef _is_text_mime(mime: str) -> bool:\n    mime = (mime or \"\").lower().split(\";\")[0].strip()\n    return (mime.startswith(\"text/\")\n            or mime.endswith((\"+json\", \"+xml\", \"+yaml\"))\n            or mime in {\"application/json\", \"application/xml\", \"application/yaml\"})\n\n# inside the extractor, for a Blob:\ndata = getattr(resource, \"data\", None)\nif isinstance(data, bytes):\n    if _is_text_mime(mime):\n        return uri, name, description, data.decode(\"utf-8\")\n    # Binary (PDF, PNG, ...) → an honest descriptor, NOT raw bytes/base64\n    return uri, name, description, (\n        f\"[Binary resource: {mime or 'application/octet-stream'}, \"\n        f\"{_human_size(len(data))}. This is not text and cannot be inlined; \"\n        f\"open it with a client that handles its media type.]\"\n    )\n```\n\nThis 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]`\n\nknows exactly what it's looking at and can decide what to do, instead of choking on garbage or getting a misleading \"no content.\"\n\n```\n   ┌─────────────────┐\n   │  User message   │\n   └────────┬────────┘\n            │\n            ▼\n   ┌──────────────────────────────────────────┐\n   │ System prompt = resource CATALOG only     │\n   │ (URIs + descriptions, NO content)         │\n   └────────┬─────────────────────────────────┘\n            │\n            ▼\n      ┌───────────────┐\n      │  LLM decides  │\n      └──┬─────────┬──┘\n         │         │\n needs a │         │ doesn't need one\n resource│         └──────────────► Answer directly\n         ▼\n ┌──────────────────────────┐\n │ tool_use: read_resource  │\n │        (uri)             │\n └────────────┬─────────────┘\n              ▼\n ┌──────────────────────────────┐\n │ Graph intercepts the call     │\n │ (auto-approved, no HITL gate) │\n └────────────┬─────────────────┘\n              ▼\n ┌──────────────────────────────┐\n │ Look up URI in content map    │\n └───────┬───────────────┬──────┘\n         │ text          │ binary\n         ▼               ▼\n ┌────────────────┐  ┌──────────────────────┐\n │ Full content   │  │ Descriptor:          │\n │ as tool message│  │ mime type + size     │\n └───────┬────────┘  └───────────┬──────────┘\n         │                       │\n         └───────────┬───────────┘\n                     ▼\n              (back to LLM ──► answer)\n```\n\nOnly the *\"needs a resource\"* branch ever pays the content cost — and it pays the **full** cost, untruncated, for **just** that resource.\n\n`AnyUrl`\n\nvs `str`\n\n.`AnyUrl`\n\nobjects. If your content map is keyed by `AnyUrl`\n\nand the LLM passes a plain string, `.get()`\n\nsilently misses. Normalize to `str`\n\non `.mimetype`\n\n, not metadata.`langchain-mcp-adapters`\n\n`Blob`\n\ns, the media type is an attribute; metadata only carries the `uri`\n\n. Read the right field or every binary looks like `application/octet-stream`\n\n.`description`\n\n. Invest in it.`func`\n\nis fine.The binary branch is the single hook for richer handling: extract PDF text server-side, or emit an `ImageContent`\n\nblock to a vision-capable model for images. Because everything already funnels through one `read_resource`\n\npath, adding a modality is a localized change — not a re-architecture.\n\nThe 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.\n\n*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.*", "url": "https://wpnews.pro/news/teaching-an-llm-to-pull-mcp-resources-and-prompts-on-demand-instead-of-drowning", "canonical_source": "https://dev.to/anirbaan_chowdhury_58a600/teaching-an-llm-to-pull-mcp-resources-and-prompts-on-demand-instead-of-drowning-it-in-context-591l", "published_at": "2026-08-30 05:19:03+00:00", "updated_at": "2026-08-30 05:52:12.675055+00:00", "lang": "en", "topics": ["large-language-models", "ai-agents", "developer-tools", "ai-infrastructure"], "entities": ["Model Context Protocol", "LangGraph", "langchain-mcp-adapters", "DARA"], "alternates": {"html": "https://wpnews.pro/news/teaching-an-llm-to-pull-mcp-resources-and-prompts-on-demand-instead-of-drowning", "markdown": "https://wpnews.pro/news/teaching-an-llm-to-pull-mcp-resources-and-prompts-on-demand-instead-of-drowning.md", "text": "https://wpnews.pro/news/teaching-an-llm-to-pull-mcp-resources-and-prompts-on-demand-instead-of-drowning.txt", "jsonld": "https://wpnews.pro/news/teaching-an-llm-to-pull-mcp-resources-and-prompts-on-demand-instead-of-drowning.jsonld"}}