cd /news/developer-tools/if-your-mcp-server-uses-oauth-every-… · home topics developer-tools article
[ARTICLE · art-107255] src=dev.to ↗ pub= topic=developer-tools verified=true sentiment=· neutral

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

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.

read5 min views1 publishedAug 22, 2026

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": []
}

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.

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 }

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/...

It stopped dead. Only after a human clicked through an interactive OAuth

authorization did it get:

[scan] Capabilities found: 4 tools.

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.

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.

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)

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,

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.

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'

You want to see:

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

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.

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

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' },

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.

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.

── more in #developer-tools 4 stories · sorted by recency
── more on @glama 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/if-your-mcp-server-u…] indexed:0 read:5min 2026-08-22 ·