MCP Server Discovery: How Clients Find and Trust Servers
You've built an MCP server. It runs perfectly on your machine, exposes a clean set of tools, and answers requests exactly as designed. Then you try to hand it to a teammate, or worse, ship it to a user who has never heard of the Model Context Protocol — and suddenly you're stuck answering a much harder question than "does my server work?" The real question is: how does a client even find this thing, decide it's the right one, and trust it enough to let an LLM call its tools? Discovery sounds like a solved problem until you actually try to do it across a registry, a config file, and a network boundary, at which point you realize most of the hard engineering in MCP isn't the protocol handshake — it's everything that happens before the handshake even starts.
Why discovery is a different problem than connection
Connection is mechanical. Once a client knows a server's address — a local command to spawn, or a URL to hit — the MCP handshake takes over: initialize, exchange capabilities, list tools, done. Discovery is the step before that, and it's messier because it involves a decision under uncertainty. The client (or the human configuring the client) has to answer three questions before a single JSON-RPC message is sent:
- Where does this server live, and how do I start or reach it
- Is this actually the server I think it is
- What is it allowed to do once connected
Skip any of these and you get real failure modes: a config file that silently points at the wrong binary, a registry entry that's been squatted by an unrelated package, or a remote server that gets full tool access before anyone checks what "full tool access" actually means for that server. MCP as a protocol doesn't mandate a single discovery mechanism — it's deliberately agnostic — which means the ecosystem has converged on a handful of patterns, each with different trust properties. Understanding those patterns is what separates a server that just runs from a server that people can safely install and forget about.
The three discovery paths: local, registry, and remote
Local discovery is the simplest and, right now, the most common. A client like Claude Code or an IDE integration reads a configuration file — typically JSON — that maps a server name to a launch command:
{
"mcpServers": {
"filesystem": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/Users/pramod/projects"]
},
"internal-crm": {
"command": "node",
"args": ["/opt/tools/crm-mcp/dist/index.js"],
"env": {
"CRM_API_KEY": "${CRM_API_KEY}"
}
}
}
}There is no discovery algorithm here. Trust is delegated entirely to whoever wrote that config — usually a human who typed a path or pasted a snippet from documentation. This is fine for personal setups and internal tooling because the trust decision already happened when the person chose to write that line. It breaks down the moment you want to distribute a server to people who don't read JSON before running it.
Registry-based discovery is the next rung up. Instead of a human hand-typing a command, a client (or a package manager acting on the client's behalf) queries a registry — think of it as analogous to npm or PyPI, but for MCP servers — by name, and the registry resolves that name to an installable package plus metadata: what tools it exposes, what permissions it wants, who published it, and a version history. The registry becomes the trust anchor. It doesn't guarantee the server is safe, but it centralizes the point where safety checks *could* happen: signature verification, namespace ownership, automated scanning for suspicious tool descriptions.
Remote discovery is the hardest case, and the one most teams get wrong first. A remote MCP server is reached over HTTP, often with OAuth in front of it, and might be one of many servers behind a single well-known endpoint. Here the client has to resolve a URL to a live, authenticated session, which means discovery and authentication are no longer separable steps — you can't know what a remote server offers until you've already established enough trust to talk to it.
Manifest files: the server's self-description
Every discovery path eventually needs the server to describe itself, and this is where the manifest comes in. Whether it's embedded in a registry entry or shipped alongside the server package, the manifest is the machine-readable contract: name, version, the tools it exposes, and critically, the permissions or capabilities it's asking for.
A minimal manifest looks something like this:
{
"name": "teachyou-course-search",
"version": "1.2.0",
"description": "Search and retrieve course content from the TeachYou catalog",
"capabilities": {
"tools": true,
"resources": true,
"prompts": false
},
"author": "teachyou.ai",
"license": "MIT",
"permissions": {
"network": ["api.teachyou.ai"],
"filesystem": "none"
}
}Note what's doing the real work here: the permissions block. This is the part most early MCP server authors skip because the protocol doesn't require it, but it's exactly what a client-side trust layer needs to make an informed decision. A server that declares "filesystem": "none" and then tries to read arbitrary paths at runtime is a server that should be flagged, not silently allowed. The manifest is only useful if something actually checks it — and increasingly, that something is the client itself, either through static analysis before install or runtime sandboxing after.
The manifest also solves a naming problem. "filesystem" as a server name is meaningless without a namespace — there could be a dozen filesystem servers with wildly different implementations. Registries handle this the way package managers always have: scoped names (@teachyou/course-search), reverse-DNS style identifiers, or publisher-verified namespaces. Whichever convention wins, the point is the same — a bare tool name is not an identity, and pretending otherwise is how you end up connecting an LLM to a tool you never intended to trust.
How a client actually resolves a server at runtime
Walk through what happens inside a client like Claude Desktop or Claude Code when it starts up and needs to bring MCP servers online. It's a small state machine, and it's worth internalizing because most discovery bugs live in the transitions, not the states.
- Load configuration. The client reads its config source — a local file, a workspace-level override, or a fetched policy from an admin-managed registry — and produces a list of server entries.
- Resolve each entry to a launch spec. For local servers this means resolving the command and args, possibly expanding environment variables. For registry-referenced servers this means an additional round trip to fetch the actual package or manifest.
- Spawn or connect. Local servers get spawned as a subprocess communicating over stdio. Remote servers get an HTTP connection, and if OAuth is configured, this is where the authorization code flow kicks in before anything else proceeds.
- Handshake. The client sends
initialize, the server responds with its declared capabilities, and the client compares that against what the manifest promised. A mismatch here — a server claiming five tools in its manifest but exposing eight at runtime — is a legitimate reason to refuse the connection or at minimum surface a warning. - Enumerate tools, resources, prompts. Only after the handshake does the client call
tools/list,resources/list, and so on, populating what the LLM will actually see as available capabilities. - Apply policy. Before any tool becomes callable, the client applies whatever permission model it enforces — user confirmation prompts, allowlists, sandboxing rules — layered on top of whatever the manifest declared.
The reason to lay this out step by step is that "discovery" isn't one moment, it's steps 1 through 4, and "trust" isn't a checkbox, it's steps 4 through 6 continuously re-checked. A server that behaves one way during handshake and differently once tools start getting called is a real threat model, not a hypothetical one, and it's why capability comparison at handshake time matters more than it looks like it should.
Trust signals that actually hold up
Given that the protocol itself doesn't enforce trust, the ecosystem has to lean on signals outside the protocol. Some of these are strong, some are theater. Worth being explicit about which is which.
- Publisher verification. A registry that ties a server to a verified organization or domain (similar to how npm scopes work) gives you *some* signal — you know who to blame if something goes wrong, which changes incentives even if it doesn't guarantee code quality.
- Reproducible builds and source linkage. A server whose published package can be traced back to a public source repo, ideally with a matching commit hash, lets you actually audit what you're running instead of trusting a black box.
- Declared vs. observed capability parity. As mentioned above, checking that a server's runtime tool list matches its manifest is cheap to implement and catches an entire class of bait-and-switch behavior.
- Least-privilege permission requests. A server asking for exactly the network hosts and filesystem paths it needs is more trustworthy by construction than one requesting broad, unscoped access "just in case."
- Version pinning and changelogs. Discovery isn't a one-time event — a client that re-resolves a server on every launch without pinning a version is trusting every future release equally, which is rarely the intent.
What doesn't hold up as a trust signal, despite looking like one: popularity or download counts alone, a polished README, or the mere presence of a manifest file. A manifest is a claim, not a proof. Anyone building or evaluating MCP servers should treat these signals as inputs to a decision, not the decision itself.
It also helps to think about these signals the way you'd think about vetting a new dependency in any other language ecosystem, because the failure modes are the same ones that have shown up in npm and PyPI for years — typosquatting a popular server name, publishing a helpful-looking utility that later ships a malicious update, or wrapping a legitimate API behind a tool description that quietly asks the LLM to exfiltrate more than the user expects. MCP doesn't introduce new categories of risk so much as it adds a new place for old risks to hide: inside a tool's natural-language description, which is parsed by a model rather than a compiler, and is therefore much easier to make deceptively reasonable-sounding. A permission block that looks fine to a human skimming it can still authorize far more than the tool actually needs, and the only real defense is treating every new server the way you'd treat a new production dependency — pin it, review it, and re-review it on upgrade.
A worked example: securing discovery for an internal server
Say you're building an MCP server at a company that wraps an internal ticketing system, and you want engineers across the org to discover and connect to it without each of them hand-writing a config entry. Here's a reasonable shape for that, combining a lightweight internal registry with explicit capability declaration:
# server_manifest.py
MANIFEST = {
"name": "acme/ticketing-mcp",
"version": "0.4.1",
"transport": "http",
"endpoint": "https://mcp.acme.internal/ticketing",
"auth": {
"type": "oauth2",
"authorization_endpoint": "https://auth.acme.internal/oauth/authorize",
"token_endpoint": "https://auth.acme.internal/oauth/token",
"scopes": ["tickets:read", "tickets:comment"]
},
"tools": [
{"name": "search_tickets", "readonly": True},
{"name": "add_comment", "readonly": False},
],
}The important design decision here is that readonly is declared per tool, not just at the server level. A client that respects this can prompt users differently for search_tickets (silent, auto-approved) versus add_comment (explicit confirmation), without the server author having to build that UX themselves. This is the pattern worth copying: push as much trust-relevant metadata as possible into structured, machine-checkable fields, and resist the temptation to bury permission semantics in prose documentation that a client can't parse and an LLM might misread.
On the client side, the corresponding check is straightforward to implement even as a thin wrapper:
def evaluate_tool_call(tool_name: str, manifest: dict) -> str:
tool = next(t for t in manifest["tools"] if t["name"] == tool_name)
if tool["readonly"]:
return "auto_approve"
return "require_confirmation"Trivial code, but it's the difference between a server that quietly writes to production ticket data and one where a human explicitly signed off first. None of this requires waiting on some future version of the MCP spec — it's achievable today with conventions layered on top of the existing protocol, which is exactly how most real security postures in this ecosystem get built.
Where remote and OAuth-protected servers add complexity
Local, stdio-based servers keep discovery and trust relatively contained — the worst case is a malicious npm package, which is a known and at least partially tooled problem. Remote servers change the calculus because now you're also dealing with network trust, token lifecycle, and the possibility of a server that's legitimate at discovery time but compromised later.
A few things worth doing deliberately if you're standing up or connecting to a remote MCP server:
- Always resolve the server's identity through TLS-verified DNS and certificates — don't let a discovery flow fall back to plaintext HTTP even for "internal" servers, because internal today is exposed tomorrow.
- Scope OAuth tokens as tightly as the auth server allows, and prefer short-lived tokens with refresh over long-lived static API keys baked into config.
- Treat token storage on the client side as sensitive — the same rules that apply to any OAuth-consuming application apply here, don't log tokens, don't put them in shell history, don't check them into the config file you're about to commit.
- Re-validate server capabilities periodically, not just at first connection, since a remote server can change its tool list on its own schedule, entirely outside the client's control.
This is also where the gap between "the protocol supports it" and "your client actually enforces it" tends to show up. MCP's authorization spec gives you the vocabulary for OAuth flows, but whether a given client rejects a token with excessive scope, or warns on a capability change, is an implementation choice each client team makes independently. If you're evaluating clients for a team rollout, this is one of the first things worth testing directly rather than assuming from the spec.
There's also a discovery-specific wrinkle unique to remote servers: a single HTTP endpoint can, in principle, front multiple logical MCP servers behind path-based routing, or route a client to different tool sets depending on the authenticated identity. That's a legitimate multi-tenant pattern, but it means "I connected to mcp.acme.internal/ticketing" is not by itself a complete description of what you're trusting — the actual tool list can vary by user, by team, or by feature flag on the server side. If you're building a client-side allowlist or audit trail, it needs to record the resolved capability set per session, not just the endpoint URL, or you'll end up with logs that say a connection happened without saying what it was actually able to do.
Practical checklist for server authors
If you're the one publishing an MCP server rather than consuming one, discovery and trust are your responsibility to make easy for the client side, not something you can leave entirely to the registry or the client vendor. A short list that tends to catch the most common gaps:
- Ship a manifest with accurate, minimal permission declarations — don't request broader scopes than the current tool set needs
- Pin and publish versions with real changelogs, so clients pinning to a version aren't surprised by silent behavior changes
- Keep tool names and descriptions unambiguous — an LLM reading a vague tool description is a bigger risk surface than most people initially assume
- Match your runtime capability list to your manifest exactly, and treat any drift as a bug, not a detail
- If your server touches anything sensitive — filesystem, internal APIs, credentials — document exactly what and let that inform your permission declarations rather than writing them last
None of this is exotic engineering. It's the same discipline that mature API design already requires — clear contracts, least privilege, versioning discipline — applied to a newer transport. The teams that get bitten by MCP security incidents are, almost without exception, the ones that skipped these basics because the protocol didn't force them to.
It's also worth planning for deprecation up front, which is the item most server authors forget until they need it. When you rename a tool, tighten a permission, or retire an endpoint, clients that cached your old manifest or pinned an old version need a signal that something changed — a version bump alone, silently pushed, is easy for a busy engineering team to miss. A short-lived deprecation window, a clear changelog entry, and where possible a runtime warning surfaced through the tool description itself will save the people downstream of you a debugging session that looks like a mysterious permission error but is actually just a manifest nobody re-read.
What this means for anyone building on MCP right now
Discovery and trust in MCP are still being figured out in public, and that's not a criticism — it's an accurate description of where a young protocol always sits. What's already clear is that the interesting engineering isn't in the JSON-RPC handshake, which is genuinely simple. It's in the layer around it: how a name resolves to a running process or a live endpoint, how a client decides that endpoint deserves the permissions it's asking for, and how that decision gets re-checked as both the server and the surrounding threat landscape change. Anyone building serious tooling on top of MCP — internal platform teams, SaaS products embedding LLM tool use, or individual engineers shipping a server for the first time — will spend more time here than on the protocol mechanics themselves, and that time is well spent, because this is exactly where the failures that actually matter tend to originate.
If you want to go deeper into the practical side of this — writing manifests correctly, wiring up OAuth for a remote server, building the client-side checks that turn declared permissions into enforced ones — that's the core of what we cover in Building & Integrating MCP Servers, with worked examples that go from a local stdio server all the way to a production, registry-discoverable deployment.
BootcampA 30-day guided bootcamp: build, harden and ship a production autonomous agent from scratch.
AI AgentsUnderstand how AI agents really work: the loop, the tools, the memory, and why most agent projects fail.