Server discovery and layered network errors
This document covers two related pieces of mcptest-core that ship together: the .well-known/mcp.json discovery fetcher and the Layer-typed network error model the fetcher reports through. The two are bundled because the discovery code is the first consumer of the layered error surface; the HTTP runner is next.
Run this example. examples/discovery/ serves a .well-known/mcp.json. Point mcptest discover at the host and it renders a ready-to-paste server: block.
mcptest discover --url https://your-host
.well-known/mcp.json discovery
Some MCP servers publish a discovery document at the well-known URL so a client can learn the endpoint, advertised transports, auth flow, and an optional tool catalog without out-of-band configuration. The fetcher in mcptest_core::discovery reads that document, parses it strictly with serde(deny_unknown_fields), and exposes it as an McpDiscovery struct.
The shape we expect
{
"name": "Example MCP",
"description": "A sample server",
"endpoint": "https://mcp.example.com/v1",
"transports": ["streamable-http", "sse"],
"auth": {
"type": "oauth2",
"authorization_url": "https://auth.example.com/authorize",
"token_url": "https://auth.example.com/token",
"scopes": ["mcp.read", "mcp.write"],
"pkce": "S256"
},
"tools": [
{ "name": "echo", "description": "echoes input" }
]
}
auth is one of {"type":"none"}, {"type":"bearer"}, or {"type":"oauth2", ...}. Unknown fields are rejected so a server typo (endpiont instead of endpoint) fails loudly during discovery rather than silently dropping the field.
Rendering YAML
discovery::to_server_yaml(&doc) renders the parsed document as the server: block of an mcptest YAML config. The runner can merge it into mcptest.local.yml directly. The first transport wins; later transports become runtime fallbacks.
Future CLI shape
The actual mcptest discover subcommand lands with the runner. The intended flow is documented here so the library shape stays stable.
$ mcptest discover --url https://mcp.example.com
server:
url: https://mcp.example.com/v1
transport: streamable-http
name: Example MCP
auth:
type: oauth2
authorization_url: https://auth.example.com/authorize
token_url: https://auth.example.com/token
scopes:
- mcp.read
- mcp.write
pkce: S256
Shipping when the runner lands. The library is ready; the subcommand wiring is tracked in the runner ticket.
Local client-config inventory
Most developers already have MCP servers configured in the editors and agents they use. mcptest reads those configs so you do not have to retype a server's launch command into mcptest.yml. The discovery walk covers six clients:
| Client | Config path |
|---|---|
| Claude Desktop | claude_desktop_config.json (platform app-support dir) |
| Claude Code | ~/.claude/config.json |
| Cursor | ~/.cursor/mcp.json |
| VS Code | user-profile mcp.json (servers map) |
| Windsurf | ~/.codeium/windsurf/mcp_config.json |
| Codex | ~/.codex/mcp.json |
The walk is read-only: mcptest never writes to a client config. A missing file is skipped silently; a malformed one is reported but does not abort the walk for the other clients.
mcptest doctor lists what it found
doctor prints the discovered servers as a test-readiness inventory:
discovered MCP servers (2, test readiness):
github source=claude-desktop transport=stdio auth=present
filesystem source=cursor transport=stdio auth=none
The inventory is privacy-preserving by construction: it emits server identity, transport, and presence-of-auth only. It never prints the env values inlined in a client's env block, an absolute filesystem path, or a raw command line, and the server name (the one free-text field) is run through the secret redactor. auth=present means the server declares environment variables (likely credentials); it does not reveal what they are.
mcptest init --from-discovered <name>
Scaffold a starter suite straight from one discovered server:
mcptest init --from-discovered github
This writes a tests/example.yaml with the discovered launch command and a tools/list probe test. The env-var names the server declares are listed in a comment so you know what to export; their values are never copied into the scaffold. An unknown name fails with exit 2 and points you at mcptest doctor to see the available names.
Layered network error messages
A "connection failed" message is useless on its own. A developer who sees [DNS] mcp.example.com: name resolution failed knows to check their resolver or /etc/hosts. A developer who sees [TLS] ... certificate verify failed knows to inspect the cert chain. The Layer enum captures the seven layers a URL-targeted MCP request can fail at:
| Layer | When you see it |
|---|---|
dns | DNS lookup failed (NXDOMAIN, resolver timeout) |
tcp | TCP connect failed (refused, unreachable, timed out) |
tls | TLS handshake failed (cert, protocol, SNI) |
http-transport | HTTP failed (read timeout, malformed response, 5xx) |
authentication | 401 or 403, or OAuth refresh denied |
mcp-initialize | MCP initialize exchange failed after HTTP succeeded |
readiness | Server accepted init but is not yet serving tools |
How the classifier picks a layer
classify_reqwest_error uses reqwest's public predicates plus a substring sniff on the lowercased error chain:
- If the error carries an HTTP status, 401 and 403 map to
authentication; every other status maps tohttp-transport. - If the message mentions
certificate,tls, orssl, the layer istls. This wins even whenis_connect()is also true, because a failed TLS handshake often looks like a connect failure under the hood. - If
is_connect()is true, a DNS-flavored message (dns,name resolution,getaddrinfo) maps todns; otherwisetcp. - A response-phase timeout maps to
http-transport. - Anything left falls through to
http-transport.
The decision tree is exposed as pub(crate) for unit tests so the classifier can be exercised without fabricating a reqwest::Error.
Rendering for humans
NetworkDiagnostic::render_pretty produces the canonical one-line shape every reporter uses:
[TCP] https://mcp.example.com: connection refused
hint: verify the server is running and the port is reachable
The suggestion is optional. The discovery fetcher attaches a sensible hint per layer; the runner supplies its own hints for the mcp-initialize and readiness layers.