MCP Resources vs Tools: Which One Should Search Be?
The protocol gives you two ways to expose data. Picking the wrong one produces a server that technically works and nobody uses well.


- The actual difference
- Search as a tool
- Where resources earn their place
- Both, for page reading
- Saved searches as resources
- The decision rule
- One caveat on host support
MCP has two primitives that both look like “give the model some data,” and the docs describe the distinction in one line that’s easy to skim past: tools are model-controlled, resources are application-controlled.
That distinction is the whole design decision. Get it wrong and you ship a server where the model can’t invoke the thing it needs, or where the host application has no way to pin the context a user explicitly chose.
The actual difference
A tool is something the model decides to call, when it decides to call it, with arguments it chooses. The host surfaces it to the model as a callable.
A resource is something the host application — or the user through the host’s UI — attaches to the conversation. The model doesn’t invoke it; it arrives in context because someone put it there.
Search is a tool. Unambiguously. The model has to decide what to search for based on the conversation, and no host UI can pre-select a query it doesn’t know yet.
But there’s a real case for resources alongside it, and the interesting servers use both.
Search as a tool
from mcp.server.fastmcp import FastMCP
import os
import httpx
from urllib.parse import quote_plus
API_KEY = os.environ["SERPLY_API_KEY"]
BASE = "https://api.serply.io/v1"
mcp = FastMCP("serply")
@mcp.tool()
async def web_search(query: str, num: int = 10, country: str = "US") -> str:
"""Search the live web via Google.
Use for current events, specific facts, or anything you should not answer
from memory. Returns titles, URLs, and short snippets.
Args:
query: Keyword-style search query, not a full sentence.
num: Number of results, 1-100.
country: Two-letter country code to search from.
"""
async with httpx.AsyncClient() as client:
resp = await client.get(
f"{BASE}/search/q={quote_plus(query)}&num={num}",
headers={"X-Api-Key": API_KEY, "X-Proxy-Location": country.upper()},
timeout=30,
)
if resp.status_code == 429:
return "Rate limited. Wait a few seconds before searching again."
if not resp.is_success:
return f"Search failed: HTTP {resp.status_code}"
results = resp.json().get("results", [])
if not results:
return f'No results for "{query}". Try different keywords.'
return "\n\n".join(
f"{i}. {r.get('title')}\n {r.get('link')}\n {r.get('description', '')}"
for i, r in enumerate(results, 1)
)
Standard, and correct as a tool. Nothing about this could work as a resource — the query is a runtime decision.
Where resources earn their place
The case for a resource is a specific, addressable piece of content the user wants pinned. A page they’re working from. A saved search. A URI they can reference by name.
from urllib.parse import unquote
@mcp.resource("page://{url}")
async def page_resource(url: str) -> str:
"""The full text of a specific web page, as markdown."""
target = unquote(url)
async with httpx.AsyncClient() as client:
resp = await client.post(
f"{BASE}/request",
headers={"X-Api-Key": API_KEY, "Content-Type": "application/json"},
json={"url": target, "response_type": "markdown"},
timeout=90,
)
resp.raise_for_status()
return resp.text
Markdown mode returns the content as the response body, so resp.text rather than resp.json().
The difference this makes in practice: a user working through a long specification can attach page://https%3A%2F%2Fexample.com%2Fspec once, and it stays in context across every turn. As a tool call, the model re-fetches it whenever it happens to remember to, and forgets it whenever the conversation gets long.
Both, for page reading
Here’s the nuance that catches people out: page fetching is legitimately both.
The model needs to read pages it discovers mid-conversation — that’s a tool. The user needs to pin pages they already know about — that’s a resource. Exposing only one leaves a real gap.
@mcp.tool()
async def read_page(url: str, max_chars: int = 15000) -> str:
"""Fetch the full text of a web page as markdown.
Use after web_search when a snippet is not enough to answer. More expensive
than a search — read the 2-3 most promising results, not everything.
Args:
url: Absolute URL, normally from a previous search result.
max_chars: Truncation limit.
"""
async with httpx.AsyncClient() as client:
resp = await client.post(
f"{BASE}/request",
headers={"X-Api-Key": API_KEY, "Content-Type": "application/json"},
json={"url": url, "response_type": "markdown"},
timeout=90,
)
if not resp.is_success:
return f"Could not fetch {url} (HTTP {resp.status_code}). Try another source."
text = resp.text
if len(text) > max_chars:
return text[:max_chars] + f"\n\n[Truncated at {max_chars} of {len(text)}.]"
return text
Same underlying call, two entry points, different control. That’s not duplication — it’s the protocol distinction doing its job.
Saved searches as resources
The pattern that makes resources genuinely useful for a search server: named, recurring queries.
SAVED = {
"competitors": "competitor product launch announcements",
"industry-news": "enterprise AI infrastructure funding",
"our-brand": "\"YourCompany\" reviews OR complaints",
}
@mcp.resource("search://saved/{name}")
async def saved_search(name: str) -> str:
"""Results for a saved, named search query."""
query = SAVED.get(name)
if not query:
return f"No saved search named '{name}'. Available: {', '.join(SAVED)}"
return await web_search(query, num=15)
@mcp.resource("news://{topic}")
async def news_resource(topic: str) -> str:
"""Recent news coverage of a topic."""
async with httpx.AsyncClient() as client:
resp = await client.get(
f"{BASE}/news/q={quote_plus(topic)}",
headers={"X-Api-Key": API_KEY},
timeout=30,
)
resp.raise_for_status()
entries = resp.json().get("feed", {}).get("entries", [])
return "\n\n".join(
f"{e.get('title')}\n {e.get('source')} — {e.get('published')}\n"
f" {e.get('link')}\n {e.get('summary', '')}"
for e in entries
) or f"No recent coverage of {topic}."
News articles come back under feed.entries, not results — the one shape difference across these endpoints.
A user who checks the same three queries every morning attaches search://saved/competitors and gets fresh results in context with no prompting. As a tool, they’d have to ask for it every time, and the model would have to guess the right query.
The decision rule
Ask who knows the parameter.
If the model has to figure it out from the conversation — a search query, a URL it just discovered — it’s a tool.
If the user or the host application knows it in advance and wants it persistent — a specific document, a named saved query, a monitored topic — it’s a resource.
If both, expose both. The implementation is shared and the control semantics are what differ.
One caveat on host support
Tool support in MCP clients is near-universal. Resource support is less consistent, and how resources are surfaced in the UI varies — some hosts let users browse and attach them, some don’t expose them at all.
Practical consequence: never make a resource the only way to reach something important. Ship tools as the baseline that works everywhere, and add resources as the better experience where the host supports them.