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, not what a tool does. The real tool list comes from an authenticatedtools/listcall.- Enterprise servers gate
tools/listbehind OAuth. Atlassian’s remote MCP is one. An unconnected connector is invisible to the model, so the user’s intent has nothing to match against.- The fix isn’t a smarter model. It’s static, human-written metadata you show before login.
- Two ways to surface it: a placeholder tool per connector (contextual, works headless, costs tokens), or Connect buttons in your UI (honest, free, needs a UI you own). Strong products ship both.
- Write those descriptions from a fixed list, not a freehand field someone fills 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 problem is that last step. Your agent offers to connect the user when its intent matches. Matched against what? A server nobody has logged into advertises nothing.
This isn’t a bug. It’s how the system works.
The agent never sees an unconnected connector
Look at what the agent knows when a user types a question.
The agent loads tools into its registry per user, per request. For each connector the admin registered, the code checks whether this user has a valid credential. If they do, it opens a session, lists the tools, and adds them to the registry. If they don’t, 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
}
It skips. It doesn’t degrade. The connector doesn’t appear in a reduced form the model can reason about. It isn’t there at all. That continue is the bug.
So when the user asks a question that connector would answer, the model picks from a tool list that never mentioned it. There’s no missed match. There was nothing to match.
MCP discovery covers auth, not capabilities
Your obvious fix is to look it up. MCP discovery uses .well-known endpoints: RFC 8414 for authorization-server metadata, RFC 9728 for protected-resource metadata. It’s easy to assume they carry the tool list.
They don’t. They tell you how to authenticate: which authorization server to use, which endpoints to call. They say nothing about what the tools do.
The tool list comes from a different call. tools/list runs over an initialized MCP session. It’s a post-handshake RPC, not a file you can fetch cold. Whether that session needs a token before it lists anything is up to the server, 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. The reason: 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, your products. Returning a list before login would advertise tools you can’t call, or expose the shape of an instance to someone with no credential. Gating the list keeps it per-user. The auth check isn’t laziness. It’s access control working as intended.
This is how these servers are built. Your code isn’t the problem.
One more thing to know. After connect, a server can send a notifications/tools/list_changed message, and the client re-runs tools/list to load the now-visible tools. 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, because both need an open session, and you don’t have one yet. The gap sits upstream of anything the protocol offers.
The prompt that looked like it already handled this
You probably already have an inline “connect your account” prompt. That’s what hides the gap.
It fires when a tool throws an auth error mid-call. The agent has the tool, tries to use it, the token has expired, the user logs in again.
That’s reconnect. It works because the agent already knew the tool existed, which only happened because the user had connected before. Every path through it starts with a user who logged in once.
First-time connect has no path. The two look the same in a demo. 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, you log in, then you chat. No inference.
Directory products with a catalog of connectors, like the Slack App Directory and ChatGPT connectors, show a short blurb written by a human ahead of time. That blurb is the only signal before login, and the match runs against it.
Static, human-written metadata in place of live discovery. That’s the current standard, and no one treats it as a hack.
Two ways to fix it
Both fixes do the same thing. They 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 short blurb. Its execute does no real work. It throws the same auth error your reconnect flow already handles.
This matters. 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 swap the earlier continue for 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 hits the same 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 that description, normal tool selection picks it, the tool 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 your existing selection logic do the work. It’s the same approach as putting the guardrail in the code instead of the prompt: use the mechanism you already trust instead of a smarter model. Once the user connects, real tools replace the placeholder on the next load.
Pros. Contextual. The connect prompt appears when the user asks something the connector would answer. No UI work; it uses the model’s own selection. 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 it competes 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
The second option skips the model. Render the unconnected connectors as a row of Connect buttons in the chat UI. Each carries the same human-written blurb. Each runs the same OAuth flow.
Pros. Honest and clear. The user sees what’s connectable and clicks it. No model guessing, no tool that claims to be callable. Nothing enters the tool list, so no token cost and no risk of the model picking a placeholder by mistake. The user sees the options before asking.
Cons. It isn’t contextual on its own. A row of buttons doesn’t say “you asked about Jira, connect Jira.” You’d need intent matching for that, which is what Option 1 gives you. And it works only where you own the UI. A headless agent has no button to render.
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 |
Own a chat UI? Start with the buttons. It’s the more honest surface. Need the prompt mid-conversation, or running headless? Add the placeholder. They solve different parts of the same problem, so strong products ship both: buttons for browsing, a placeholder for the moment someone asks.
One detail matters, and it applies to both. Don’t let admins write the blurb freehand. Then 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. Low effort, 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 rule underneath
When real discovery needs auth, static metadata is the answer. Reach for it early.
The instinct to resist is the one that says the model will figure it out. That instinct is right most of the time. It fails at a permission boundary. A model can’t reason about a tool you never handed it.
When a capability sits behind per-user credentials, someone has to describe it before anyone logs in. You can’t discover that description at runtime. Decide who writes it and how much work it costs them. That decides whether the feature works at all.