DEV Community

Leo Kadieff
Leo Kadieff

Posted on

If your MCP server uses OAuth, every directory thinks it has zero tools

We shipped a remote MCP server, registered it everywhere, and then noticed
something odd: every directory listed it as having no tools at all.

Not the wrong tools. Not a stale count. Zero.

glama's API returned this:

{
  "name": "FrameThrower MCP Server",
  "attributes": ["author:official", "hosting:remote-capable"],
  "tools": []
}
Enter fullscreen mode Exit fullscreen mode

Smithery's page rendered the same nothing. So did mcp.directory. Four working
tools, and every discovery surface in the ecosystem said the server did nothing.

If you run a remote MCP server behind OAuth 2.1, this is almost certainly
happening to you too, and nothing in your logs will tell you.

Why it happens

An MCP client discovers what a server can do by calling tools/list. That's a
normal JSON-RPC method, and if you wrapped your handler in auth — which the docs
and every example encourage — then tools/list is behind auth along with
everything else.

Our handler looked like this:

const authed = withMcpAuth(auth, (req, session) => {
  const userId = session?.userId ?? session?.user?.id
  if (!userId) return new Response('Unauthorized', { status: 401 })
  return callerStore.run({ userId }, () => handler(req))
})

export { authed as GET, authed as POST, authed as DELETE }
Enter fullscreen mode Exit fullscreen mode

Correct, and completely sensible. Every tool call spends credits, so every tool
call needs a user.

But a directory crawler has no account. It performs the handshake, gets a 401,
and records what it can see — which is a name, a URL, and an empty capability
list. It cannot tell the difference between "this server requires auth" and
"this server does nothing".

The most telling part was Smithery's scanner log:

[scan] Discovering server metadata...
[scan] Server metadata discovered (OAuth required).
[scan] Connecting to MCP server...
[scan] Authentication required. Please authorize at: https://connect.smithery.ai/...
Enter fullscreen mode Exit fullscreen mode

It stopped dead. Only after a human clicked through an interactive OAuth
authorization did it get:

[scan] Capabilities found: 4 tools.
Enter fullscreen mode Exit fullscreen mode

Its scanner found all four — but that result came from a one-off human
authorization, and it isn't what the public page renders. So the listing still
told visitors the server had no capabilities.

Why it matters more than it looks

MCP directories are the discovery layer. Someone browsing for a server reads the
tool list to decide whether to install it. A listing with no tools isn't a weak
listing, it's a dead one — and every directory that mirrors another directory
copies the emptiness forward.

You can register on every registry that exists and still be invisible.

The fix

Describing what a server offers is not a privileged operation. Calling those
tools is. So split them:

/**
 * The handshake methods a directory crawler needs to read our tool list.
 * Describing what a server offers is not privileged; everything that spends
 * credits or touches user data stays behind the token.
 */
const PUBLIC_METHODS = new Set([
  'initialize', 'notifications/initialized', 'ping', 'tools/list',
])

async function isPublicHandshake(req: Request): Promise<boolean> {
  if (req.method !== 'POST') return false
  if (req.headers.get('authorization')) return false
  try {
    const body = await req.clone().json()
    const msgs = Array.isArray(body) ? body : [body]
    return msgs.length > 0 && msgs.every((m) => PUBLIC_METHODS.has(m?.method))
  } catch {
    return false
  }
}

const gated = async (req: Request) =>
  (await isPublicHandshake(req)) ? handler(req) : authed(req)
Enter fullscreen mode Exit fullscreen mode

The three narrowings that keep this from being an auth bypass

This is the part worth copying carefully. Each of these exists for a specific
reason.

1. An Authorization header means validate it. If a request carries a token,
it goes down the authenticated path even when the method is public. Without this,
a client holding an expired token would silently fall back to anonymous access
instead of getting the 401 that triggers a refresh. Failing quietly is worse than
failing.

2. POST only. In Streamable HTTP, GET opens the SSE stream and DELETE
terminates the session. Neither carries a JSON-RPC method you can inspect, so
neither can be classified as public. They stay authenticated.

3. Every message in a batch, not just one. JSON-RPC allows batching. A batch
mixing tools/list with tools/call is not a public request. .every(), never
.some().

There's also a second line of defence: the public path runs with no caller in
context, so if a tools/call ever reached it, the charging function finds no
user and refuses. The gate fails closed from both directions.

Verify it properly

The failure mode that would actually hurt is losing OAuth discovery. If your 401
stops advertising WWW-Authenticate, compliant clients no longer know where to
authenticate, and they fail silently instead of prompting. Check that explicitly:

Check Expected
initialize, no auth 200
tools/list, no auth full tool list
tools/call, no auth 401
tools/list with an invalid token 401
Batch mixing tools/list + tools/call 401
GET (SSE stream), no auth 401
WWW-Authenticate on any 401 present, with resource_metadata

That last row is the one to actually run:

curl -s -D - -o /dev/null -X POST https://your-server/api/mcp \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"x","arguments":{}}}' \
  | grep -i 'www-authenticate'
Enter fullscreen mode Exit fullscreen mode

You want to see:

www-authenticate: Bearer resource_metadata="https://your-server/api/auth/.well-known/oauth-protected-resource"
Enter fullscreen mode Exit fullscreen mode

After the change, Smithery's scanner ran clean with no browser step at all:

[scan] Server info retrieved. name: FrameThrower, version: 1.0.0
[scan] Capabilities found: 4 tools.
Enter fullscreen mode Exit fullscreen mode

The bug this uncovered on the way

Look again at that log line: name: FrameThrower. Before the fix, it said this:

[scan] Server info retrieved. name: mcp-typescript server on vercel, version: 0.1.0
Enter fullscreen mode Exit fullscreen mode

createMcpHandler from mcp-handler defaults serverInfo to
"mcp-typescript server on vercel" v0.1.0 if you don't set it. We hadn't. So our
server had been introducing itself to every connected client — Claude Desktop,
Cursor, all of them — under the library's placeholder name.

One line:

serverInfo: { name: 'FrameThrower', version: '1.0.0' },
Enter fullscreen mode Exit fullscreen mode

It goes in the same options object as instructions. Worth checking yours right
now; it costs nothing and it's the string every client displays.

The takeaway

If you run a remote MCP server with auth, go and look at how the directories
render it. Not your logs — their pages. tools: [] is a silent failure that
looks exactly like a healthy listing until you read it.

The split is the same one HTTP has always had: describing a resource is public,
using it is not.


This came out of building FrameThrower, a
cinematography reference library with a REST API and an MCP server. The server
is at github.com/framethrower-ai/framethrower-mcp
if you want to see the whole handler.

Top comments (0)