{"slug": "if-your-mcp-server-uses-oauth-every-directory-thinks-it-has-zero-tools", "title": "If your MCP server uses OAuth, every directory thinks it has zero tools", "summary": "A developer discovered that remote MCP servers protected by OAuth appear to have zero tools in directories like Glama, Smithery, and mcp.directory, because directory crawlers cannot authenticate and receive an empty tool list. The developer's fix is to make handshake methods such as tools/list public while keeping privileged operations behind auth, and they urge the MCP ecosystem to adopt this pattern to prevent servers from being invisible.", "body_md": "We shipped a remote MCP server, registered it everywhere, and then noticed\n\nsomething odd: every directory listed it as having **no tools at all**.\n\nNot the wrong tools. Not a stale count. Zero.\n\nglama's API returned this:\n\n```\n{\n  \"name\": \"FrameThrower MCP Server\",\n  \"attributes\": [\"author:official\", \"hosting:remote-capable\"],\n  \"tools\": []\n}\n```\n\nSmithery's page rendered the same nothing. So did mcp.directory. Four working\n\ntools, and every discovery surface in the ecosystem said the server did nothing.\n\nIf you run a remote MCP server behind OAuth 2.1, this is almost certainly\n\nhappening to you too, and nothing in your logs will tell you.\n\nAn MCP client discovers what a server can do by calling `tools/list`\n\n. That's a\n\nnormal JSON-RPC method, and if you wrapped your handler in auth — which the docs\n\nand every example encourage — then `tools/list`\n\nis behind auth along with\n\neverything else.\n\nOur handler looked like this:\n\n``` js\nconst authed = withMcpAuth(auth, (req, session) => {\n  const userId = session?.userId ?? session?.user?.id\n  if (!userId) return new Response('Unauthorized', { status: 401 })\n  return callerStore.run({ userId }, () => handler(req))\n})\n\nexport { authed as GET, authed as POST, authed as DELETE }\n```\n\nCorrect, and completely sensible. Every tool call spends credits, so every tool\n\ncall needs a user.\n\nBut a directory crawler has no account. It performs the handshake, gets a 401,\n\nand records what it can see — which is a name, a URL, and an empty capability\n\nlist. It cannot tell the difference between \"this server requires auth\" and\n\n\"this server does nothing\".\n\nThe most telling part was Smithery's scanner log:\n\n```\n[scan] Discovering server metadata...\n[scan] Server metadata discovered (OAuth required).\n[scan] Connecting to MCP server...\n[scan] Authentication required. Please authorize at: https://connect.smithery.ai/...\n```\n\nIt stopped dead. Only after a human clicked through an interactive OAuth\n\nauthorization did it get:\n\n```\n[scan] Capabilities found: 4 tools.\n```\n\nIts scanner found all four — but that result came from a one-off human\n\nauthorization, and it isn't what the public page renders. So the listing still\n\ntold visitors the server had no capabilities.\n\nMCP directories are the discovery layer. Someone browsing for a server reads the\n\ntool list to decide whether to install it. A listing with no tools isn't a weak\n\nlisting, it's a dead one — and every directory that mirrors another directory\n\ncopies the emptiness forward.\n\nYou can register on every registry that exists and still be invisible.\n\nDescribing what a server offers is not a privileged operation. Calling those\n\ntools is. So split them:\n\n```\n/**\n * The handshake methods a directory crawler needs to read our tool list.\n * Describing what a server offers is not privileged; everything that spends\n * credits or touches user data stays behind the token.\n */\nconst PUBLIC_METHODS = new Set([\n  'initialize', 'notifications/initialized', 'ping', 'tools/list',\n])\n\nasync function isPublicHandshake(req: Request): Promise<boolean> {\n  if (req.method !== 'POST') return false\n  if (req.headers.get('authorization')) return false\n  try {\n    const body = await req.clone().json()\n    const msgs = Array.isArray(body) ? body : [body]\n    return msgs.length > 0 && msgs.every((m) => PUBLIC_METHODS.has(m?.method))\n  } catch {\n    return false\n  }\n}\n\nconst gated = async (req: Request) =>\n  (await isPublicHandshake(req)) ? handler(req) : authed(req)\n```\n\nThis is the part worth copying carefully. Each of these exists for a specific\n\nreason.\n\n**1. An Authorization header means validate it.** If a request carries a token,\n\n**2. POST only.** In Streamable HTTP, `GET`\n\nopens the SSE stream and `DELETE`\n\nterminates the session. Neither carries a JSON-RPC method you can inspect, so\n\nneither can be classified as public. They stay authenticated.\n\n**3. Every message in a batch, not just one.** JSON-RPC allows batching. A batch\n\nmixing `tools/list`\n\nwith `tools/call`\n\nis not a public request. `.every()`\n\n, never\n\n`.some()`\n\n.\n\nThere's also a second line of defence: the public path runs with no caller in\n\ncontext, so if a `tools/call`\n\never reached it, the charging function finds no\n\nuser and refuses. The gate fails closed from both directions.\n\nThe failure mode that would actually hurt is losing OAuth discovery. If your 401\n\nstops advertising `WWW-Authenticate`\n\n, compliant clients no longer know where to\n\nauthenticate, and they fail silently instead of prompting. Check that explicitly:\n\n| Check | Expected |\n|---|---|\n`initialize` , no auth |\n200 |\n`tools/list` , no auth |\nfull tool list |\n`tools/call` , no auth |\n401 |\n`tools/list` with an invalid token |\n401 |\nBatch mixing `tools/list` + `tools/call`\n|\n401 |\n`GET` (SSE stream), no auth |\n401 |\n`WWW-Authenticate` on any 401 |\npresent, with `resource_metadata`\n|\n\nThat last row is the one to actually run:\n\n```\ncurl -s -D - -o /dev/null -X POST https://your-server/api/mcp \\\n  -H 'Content-Type: application/json' \\\n  -H 'Accept: application/json, text/event-stream' \\\n  -d '{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"x\",\"arguments\":{}}}' \\\n  | grep -i 'www-authenticate'\n```\n\nYou want to see:\n\n```\nwww-authenticate: Bearer resource_metadata=\"https://your-server/api/auth/.well-known/oauth-protected-resource\"\n```\n\nAfter the change, Smithery's scanner ran clean with no browser step at all:\n\n```\n[scan] Server info retrieved. name: FrameThrower, version: 1.0.0\n[scan] Capabilities found: 4 tools.\n```\n\nLook again at that log line: `name: FrameThrower`\n\n. Before the fix, it said this:\n\n```\n[scan] Server info retrieved. name: mcp-typescript server on vercel, version: 0.1.0\n```\n\n`createMcpHandler`\n\nfrom `mcp-handler`\n\ndefaults `serverInfo`\n\nto\n\n`\"mcp-typescript server on vercel\"`\n\nv0.1.0 if you don't set it. We hadn't. So our\n\nserver had been introducing itself to every connected client — Claude Desktop,\n\nCursor, all of them — under the library's placeholder name.\n\nOne line:\n\n```\nserverInfo: { name: 'FrameThrower', version: '1.0.0' },\n```\n\nIt goes in the same options object as `instructions`\n\n. Worth checking yours right\n\nnow; it costs nothing and it's the string every client displays.\n\nIf you run a remote MCP server with auth, go and look at how the directories\n\nrender it. Not your logs — their pages. `tools: []`\n\nis a silent failure that\n\nlooks exactly like a healthy listing until you read it.\n\nThe split is the same one HTTP has always had: describing a resource is public,\n\nusing it is not.\n\n*This came out of building FrameThrower, a\ncinematography reference library with a REST API and an MCP server. The server\nis at github.com/framethrower-ai/framethrower-mcp\nif you want to see the whole handler.*", "url": "https://wpnews.pro/news/if-your-mcp-server-uses-oauth-every-directory-thinks-it-has-zero-tools", "canonical_source": "https://dev.to/leo_framethrower/if-your-mcp-server-uses-oauth-every-directory-thinks-it-has-zero-tools-3cco", "published_at": "2026-08-22 18:07:33+00:00", "updated_at": "2026-08-22 18:13:24.797342+00:00", "lang": "en", "topics": ["developer-tools", "ai-tools", "ai-infrastructure"], "entities": ["Glama", "Smithery", "mcp.directory", "FrameThrower MCP Server", "MCP"], "alternates": {"html": "https://wpnews.pro/news/if-your-mcp-server-uses-oauth-every-directory-thinks-it-has-zero-tools", "markdown": "https://wpnews.pro/news/if-your-mcp-server-uses-oauth-every-directory-thinks-it-has-zero-tools.md", "text": "https://wpnews.pro/news/if-your-mcp-server-uses-oauth-every-directory-thinks-it-has-zero-tools.txt", "jsonld": "https://wpnews.pro/news/if-your-mcp-server-uses-oauth-every-directory-thinks-it-has-zero-tools.jsonld"}}