A model can only pick from the tools you load. Load none, and it picks none.
TL;DR
- MCP’s
.well-knowndiscovery (RFC 8414/9728) tells an agent how to authenticate. It says nothing about what a tool does. The tool list comes from an authenticatedtools/listcall.- Enterprise servers gate
tools/listbehind OAuth. Atlassian’s remote MCP server is one. The model can’t see a connector the user hasn’t connected, so the user’s question has nothing to match against.- A smarter model won’t fix this. Static, human-written metadata that you show before login will.
- You can surface it two ways. A placeholder tool per connector is contextual and works headless, but costs tokens. Connect buttons in your UI are honest and free, but need a UI you own. Strong products ship both.
- Have admins pick those descriptions from a fixed list. A freehand field gets whatever someone types at 5pm.
Here’s a design that looks clean. You register a tool server once for the whole org. Each user logs into it with their own account, so every call the agent makes runs with that person’s permissions. When a user asks about something the server handles, the agent offers to connect them.
The last step fails. The agent offers to connect the user when their question matches a tool. Matches against what? A server nobody has logged into advertises no tools.
No single component is broken. The gap comes from how the pieces fit together.
The agent never sees an unconnected connector
Look at what the agent knows when a user types a question.
The agent builds its tool registry per user, per request. For each connector the admin registered, the code checks whether this user has a valid credential. If yes, it opens a session, lists the tools, and adds them to the registry. If no, it skips the connector.
In the Vercel AI SDK the load looks like this:
import { createMCPClient } from '@ai-sdk/mcp';
// Runs per user, per request.
const tools = {};
for (const c of connectors) {
const auth = user.authFor(c.id);
if (!auth) continue; // unconnected, so skipped; the model never sees it
const client = await createMCPClient({
transport: { type: 'http', url: c.url, authProvider: auth },
});
Object.assign(tools, await client.tools()); // MCP tools/list
}
The connector doesn’t show up in a reduced form the model can reason about. It doesn’t show up at all. That continue is where it disappears.
So when the user asks a question that connector would answer, the model picks from a tool list that never mentioned it. The model didn’t miss a match. It had nothing to match.
MCP discovery covers auth, not capabilities
The obvious fix is to look the tools up. MCP discovery uses .well-known endpoints: RFC 8414 for authorization-server metadata, RFC 9728 for protected-resource metadata. You might assume they carry the tool list.
They don’t. They tell you how to authenticate: which authorization server to use and which endpoints to call. They say nothing about the tools.
The tool list comes from a different call. tools/list runs over an initialized MCP session. It’s an RPC that runs after the handshake, so you can’t fetch it cold like a file. Each server decides whether the session needs a token before it lists anything, and many enterprise servers require one.
Atlassian’s remote MCP server works this way. One server fronts Jira, Confluence, and Compass, and tools/list returns nothing until the OAuth flow completes. When one server fronts several products, the tool list depends on who you are. What you can touch depends on your Atlassian permissions, your sites, and your products. A list returned before login would advertise tools you can’t call, or show the shape of an instance to someone with no credential. Gating the list keeps it per-user. The auth check is access control doing its job.
The server is built this way on purpose, and your code isn’t wrong either.
After connect, a server can send a notifications/tools/list_changed message, and the client re-runs tools/list to load the tools it can now see. That’s the standard way the real tools show up on the next load. The spec also has elicitation, where a server asks the client for input during a session. Neither helps before first login. Both need an open session, and you don’t have one yet. The gap sits before anything the protocol offers.
The prompt that looked like it already handled this
You probably already have an inline “connect your account” prompt. That prompt hides the gap.
It fires when a tool throws an auth error mid-call. The agent has the tool, tries to use it, finds the token expired, and asks the user to log in again.
That’s reconnect. It works because the agent already knew the tool existed, and it only knew because the user had connected before. Every path through it starts with a user who logged in once.
First-time connect has no path. In a demo the two look the same. They share no code.
How other products handle it
Before you build something new, check what already exists. No product infers what’s behind a login.
The main agent clients, Claude Desktop and Cursor, make connecting an explicit setup step. You add a server, log in, then chat.
Products with a connector catalog, like the Slack App Directory and ChatGPT connectors, show a short blurb a human wrote ahead of time. That blurb is the only signal before login, and the match runs against it.
So the current standard is static, human-written metadata in place of live discovery. Nobody treats it as a hack.
Two ways to fix it
Both fixes put a static, human-written description where the real tool list can’t reach. They differ in where that description lives: in the model’s tool list, or in your UI. You can run both, and strong products do.
Option 1: a placeholder tool per connector
For every connector you registered but the user hasn’t connected, add one placeholder tool to the registry. Its description is the admin’s blurb. Its execute does no real work. It throws the same auth error your reconnect flow already handles.
Reconnect only fired for a tool the user had connected before. The placeholder gives first-time connect a path: the model can pick it before anyone logs in, and picking it triggers the first auth prompt.
import { tool } from 'ai';
import { z } from 'zod';
// A tool the model can see before login. It does no real work.
// Selecting it triggers first-time auth.
function placeholder(connector) {
return tool({
description: connector.blurb, // "Search and update Jira issues"
inputSchema: z.object({}),
execute: async () => {
throw new AuthRequiredError(connector.id); // same error reconnect catches
},
});
}
Now replace the earlier continue with the placeholder, so the model always sees something:
for (const c of connectors) {
const auth = user.authFor(c.id);
if (!auth) {
tools[`${c.id}_connect`] = placeholder(c); // visible, not runnable
continue;
}
const client = await createMCPClient({
transport: { type: 'http', url: c.url, authProvider: auth },
});
Object.assign(tools, await client.tools());
}
The throw lands in the handler your reconnect flow already has, the one place that turns an auth error into a connect prompt:
class AuthRequiredError extends Error {
constructor(public connectorId: string) { super('auth_required'); }
}
// One handler, shared by reconnect and first-time connect:
try {
await runToolCall(call);
} catch (err) {
if (err instanceof AuthRequiredError) {
return promptConnect(err.connectorId); // hand the OAuth URL to the UI
}
throw err;
}
When a question matches the description, normal tool selection picks the placeholder, it throws, and the user gets the connect prompt. The intent matching is the model’s existing behavior. You didn’t build a matcher, a classifier, or a routing layer. You made the connector visible and let tool selection do the rest. It’s the same approach as putting the guardrail in the code instead of the prompt: use a mechanism you already trust instead of hoping for a smarter model. Once the user connects, real tools replace the placeholder on the next load.
Pros. It’s contextual: the connect prompt appears when the user asks something the connector would answer. It needs no UI work, because it uses the model’s own selection. It works in any surface, including a headless or API-only agent.
Cons. Every unconnected connector adds a tool to the list. That costs tokens, and past a few dozen connectors the placeholders compete with the real tools during selection. Match quality depends entirely on the blurb. And a tool that only throws is a dishonest abstraction: the model calls something that was never callable.
Option 2: connect buttons in the chat UI
This option skips the model. Render the unconnected connectors as a row of Connect buttons in the chat UI. Each button carries the same human-written blurb and runs the same OAuth flow.
Pros. It’s honest. The user sees what they can connect and clicks it. The model doesn’t guess, and no tool claims to be callable when it isn’t. Nothing enters the tool list, so you pay no tokens and the model can’t pick a placeholder by mistake. The user sees the options before asking.
Cons. Buttons alone aren’t contextual. A row of buttons doesn’t say “you asked about Jira, connect Jira.” For that you need intent matching, which Option 1 gives you. And buttons only work where you own the UI. A headless agent has nowhere to render them.
Which one, when
| Placeholder tool | Connect buttons in UI | |
|---|---|---|
| Where the blurb lives | Model’s tool list | Your chat UI |
| Fires on user intent | Yes, uses model selection | No, needs a separate intent match |
| Token cost | One tool per unconnected connector | None |
| Works headless / API-only | Yes | No, needs a UI you own |
| Honesty | A tool that only throws | Fully honest |
| Proactive (see before asking) | No | Yes |
| Build cost | Low, one placeholder function | Medium, UI row plus OAuth wiring |
| Reach for it when | connect must appear mid-conversation, or there’s no UI | you own the chat UI and want browsing |
If you own a chat UI, start with the buttons. They’re the more honest surface. If you need the prompt mid-conversation, or you run headless, add the placeholder. The two cover different moments, so strong products ship both: buttons for browsing, a placeholder for the moment someone asks.
One detail applies to both. Don’t let admins write the blurb freehand. If they do, every match and every button label depends on a sentence someone typed at 5pm while setting up a connector, and you’ll get a product name or an empty string. Give them a fixed list to choose from: Ticketing, Docs, CRM, HR. It costs them little, and the descriptions stay good enough to match against.
Before you ship it
- The pre-login description is static and human-written, not discovered at runtime.
- Descriptions come from a fixed list, not a freehand text field.
- First-time connect has its own path, not just reconnect on 401.
- The placeholder’s
executethrows the same auth error your reconnect flow catches. - After connect, real tools replace the placeholder on the next load. Honor
tools/list_changedif the server sends it. - Watch token and selection cost past a few dozen connectors. Cap or group the placeholders.
The general rule
When real discovery needs auth, use static metadata, and plan for it early.
The instinct to resist says the model will figure it out. That instinct is right most of the time. It fails at a permission boundary, because a model can’t reason about a tool you never gave it.
When a capability sits behind per-user credentials, someone has to describe it before anyone logs in. Runtime discovery can’t produce that description. Decide who writes it and how much work it costs them. That decision determines whether the feature works at all.