mcptest docs GitHub

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:

ClientConfig path
Claude Desktopclaude_desktop_config.json (platform app-support dir)
Claude Code~/.claude/config.json
Cursor~/.cursor/mcp.json
VS Codeuser-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:

LayerWhen you see it
dnsDNS lookup failed (NXDOMAIN, resolver timeout)
tcpTCP connect failed (refused, unreachable, timed out)
tlsTLS handshake failed (cert, protocol, SNI)
http-transportHTTP failed (read timeout, malformed response, 5xx)
authentication401 or 403, or OAuth refresh denied
mcp-initializeMCP initialize exchange failed after HTTP succeeded
readinessServer 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:

  1. If the error carries an HTTP status, 401 and 403 map to authentication; every other status maps to http-transport.
  2. If the message mentions certificate, tls, or ssl, the layer is tls. This wins even when is_connect() is also true, because a failed TLS handshake often looks like a connect failure under the hood.
  3. If is_connect() is true, a DNS-flavored message (dns, name resolution, getaddrinfo) maps to dns; otherwise tcp.
  4. A response-phase timeout maps to http-transport.
  5. 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.