Codex tries resources/read on a tool-only MCP server instead of invoking the available fetch tool
Summary
Codex appears to over-route URL fetching through MCP resources when using a tool-only MCP server.
My MCP server exposes a fetch tool, but does not expose arbitrary HTTP/HTTPS URLs as MCP resources. When I asked Codex to read a web page, it first listed MCP resources, got an empty list, and then attempted to call resources/read using the raw HTTPS URL.
This failed with Unknown resource, even though the correct tool was available.
Example failure
Error: resources/read failed: resources/read failed for llmmcp-fetch
(https://example.com/article):
Mcp error: 0: Unknown resource:
https://example.com/article
Codex then called:
Called llmmcp-fetch.list_mcp_resources({"server":"llmmcp-fetch"})
└ {"server": "llmmcp-fetch", "resources": []}
And then:
Called llmmcp-fetch.read_mcp_resource({
"server": "llmmcp-fetch",
"uri": "https://example.com/article"
})
Which failed:
Error: resources/read failed: resources/read failed for llmmcp-fetch
(https://example.com/article):
Mcp error: 0: Unknown resource:
https://example.com/article
Expected behavior
For a tool-only MCP server, an empty resource list should not cause Codex to attempt resources/read with an arbitrary HTTPS URL.
If the server exposes a tool such as:
fetch_url(url: string, timeout?: number)
Codex should prefer invoking that tool for arbitrary URL fetching.
Actual behavior
Codex probes MCP resources:
list_mcp_resources -> []
Then attempts:
read_mcp_resource(uri="https://example.com/article")
This fails because arbitrary HTTPS URLs are not MCP resources for this server.
Why this matters
According to the MCP model:
- tools are model-callable actions
- resources are application-provided context objects with known URIs/templates
- arbitrary web URL fetching is usually better represented as a tool, not as a resource
A tool-only server returning resources=[] is valid and should not imply that arbitrary URLs can be passed to resources/read.
Workaround that helped
I made two changes in my MCP server.
1. Add explicit tool descriptions
For the fetch tool, I added a strong description:
@mcp.tool()
async def fetch_url(url: str, timeout: int = 20) -> FetchResponse:
"""Fetch arbitrary HTTP/HTTPS URLs and extract readable content.
Use this tool to read web pages returned by search tools. Do not use
resources/read for ordinary URLs. This server does not expose arbitrary
HTTPS URLs as MCP resources; resources are only a compatibility fallback
for fetch://{encoded_url}.
"""
I also added similar descriptions to other tools to clarify that they are tool-based actions, not resources.
2. Add a narrow resource-template fallback
I kept fetch_url(url, timeout) as the primary interface, but added a compatibility resource template:
fetch://{encoded_url}
Example:
fetch://https%3A%2F%2Fexample.com%2Farticle
Implementation:
from urllib.parse import unquote
@mcp.resource("fetch://{encoded_url}")
async def fetch_url_resource(encoded_url: str) -> str:
"""Compatibility resource for clients that try resources/read before tools."""
url = unquote(encoded_url)
response = await _fetch_url(url=url)
return response.model_dump_json()
This gives clients a valid resource template if they insist on trying resources, but keeps arbitrary HTTPS URLs as tool inputs rather than pretending they are native MCP resources.
Suggested Codex behavior
When an MCP server has tools but returns no resources:
- Do not call
resources/readwith arbitrary URLs unless a matching resource template exists. - Prefer tool invocation when a relevant tool is available, such as
fetch_url. - Treat
resources=[]as normal for tool-only servers. - Optionally surface a hint that the server is tool-only.
Environment
- Codex CLI
- Local model provider through LM Studio
- MCP server using stdio transport
- MCP server exposes tools, no arbitrary HTTPS resources