OAuth authentication in an MCP server: the real Sherpa Platform case
The inputSchema validates that an identifier has the shape of a UUID. It does not validate that the UUID belongs to the user asking. That difference is what separates an MCP server that works from one that is also secure.
LUCAS JIN
Software Engineer
An MCP server with all its tools registered and working can still be insecure: nothing in its design stops anyone with the URL from calling any of them. OAuth authentication in an MCP server is the layer that decides who gets to make that call, built separately from the code that registers each tool, not scattered inside every one of them. That is how we did it in the real Sherpa Platform server, applying the Model Context Protocol (MCP) fundamentals that Arturo Gómez, Software Engineer at Orbitant, laid out about the difference between MCP tools, resources and prompts.
Sherpa Platform is the outsourced financial management service we built at Orbitant for a client, and its MCP server exposes data such as sales or bank liquidity for the company querying it. Everything the model sees of a tool (its name, its description, its argument schema) is a declaration, not a check. The question that really matters is a different one: who decides what each user can ask for, and where that decision lives.
What the model sees of a tool: description, not security
Everything the model knows about a Sherpa Platform tool comes down to what we declare when we register it: it does not read the handler, it does not see the database, and it has no idea whether there is real validation behind it. It picks and calls based on three pieces (description, inputSchema, and annotations): text and schema, nothing more. Mistaking that declaration for a security barrier was the hardest habit to unlearn while designing the catalog. Before getting to authentication, it is worth understanding what each of those three pieces does on its own.
The description is the piece with the most real impact: the model picks a tool from this string of text, and without it, it only has the name to guess which one is closest to what it needs. The inputSchema is a Zod schema, the validation library we use to type every argument: every .describe() we write there reaches the model too, as in this real Sherpa Platform tool:
const inputSchema = {
companyGroupId: z
.uuid()
.describe('Company group ID (from sherpa_list_company_groups)'),
year: z.number().int().describe('Fiscal year'),
}
export const getProfitAndLossReportTool = {
name: 'sherpa_get_profit_and_loss_report',
config: {
description:
'Returns the main P&L report for a company group and fiscal year. Contains revenue, expenses, and profit breakdown with monthly values and YTD totals.',
inputSchema,
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
},
}
The (from sherpa_list_company_groups) comment inside the .describe() is the entire reason the model calls that tool first: the call chaining is written right there, without anyone programming it anywhere else.
Annotations
The annotations were the ones we understood worst at first, because their defaults are pessimistic: staying silent presents a read-only query as a destructive, non-idempotent, open-world write. That is why we declare all four on every tool, as you can see in the example above:
readOnlyHint: true: the tool only reads, it never modifies state. Defaults tofalse.destructiveHint: false: if it did write, the change would not be destructive or irreversible. Defaults totrue.idempotentHint: true: calling it again with the same arguments has no additional effect. Defaults tofalse.openWorldHint: false: closed world, the tool only touches Sherpa Platform’s own data. Defaults totrue.
Describing does not protect
The inputSchema validates shape, type and constraints (never business permissions), and readOnlyHint: true does not make the handler read-only, because nothing inspects the code to check. Everything inside config describes: the name, the text, the schema, the four annotations. The one that actually decides what happens on each call is the handler, and that is where the question that really mattered was still unanswered: who was calling the tool.
Follow our journey building AI agents from the inside
The server we built before thinking about who calls it
We designed the Sherpa Platform server in two deliberately separate blocks: first the tools, with none of them knowing anything about who was calling them; then authentication, as a layer of its own. That separation was not accidental: it let us build and test the full catalog (six tools, arguing over names and descriptions) without mixing business logic with access logic.
The endpoint and the tool catalog
The server lives at a single route, /api/mcp, served by the createMcpHandler function that the mcp-handler library provides:
createMcpHandler(
registerMcpTools,
{ serverInfo: { name: 'sherpa', version: MCP_SERVER_VERSION } },
{ basePath: '/api' }
)
It takes three parameters: initializeServer (the function where we register each tool), serverOptions (the identity the client sees when it connects, with a version we bump every time the catalog changes) and config (the transport configuration, with the basePath). Inside initializeServer we register each tool with registerTool:
export const registerMcpTools = (server) => {
server.registerTool(listCompanyGroupsTool.name, listCompanyGroupsTool.config, listCompanyGroupsTool.handler)
server.registerTool(listBankProductsTool.name, listBankProductsTool.config, listBankProductsTool.handler)
// … four more
}
The name of each tool was the hardest thing to internalize: get_bank_liquidity means nothing outside our server, because the MCP client merges the tools from every connected server into a single flat list before handing it to the model. With Sherpa Platform, an ERP and GitHub connected at the same time, get_transactions collides with any other tool called the same thing. Prefixing with sherpa_ (so it becomes sherpa_get_bank_liquidity) is the only version that always works, whether or not the client namespaces by server.
The callback and the errors the model can fix
The callback is the only piece in all of this that actually runs code. Here is a complete tool, start to finish:
export const getBankLiquidityTool = {
name: 'sherpa_get_bank_liquidity',
config: {
description: TOOL_DESCRIPTION,
inputSchema,
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
},
handler: async ({ companyGroupId }, extra) => {
const authExtras = extra.authInfo?.extra
// … look up the company group, fetch the products, transform and return
return { content: [{ type: 'text', text: JSON.stringify(result) }] }
},
}
args arrives already validated against the inputSchema; extra is context the SDK builds on every call (signal, requestId, and authInfo, still undefined in this block because the auth layer did not exist yet). What the handler returns has four possible fields:
content: what the model and the user readstructuredContent: an object that only makes sense if you declared anoutputSchema_meta: metadata for that executionisError
Setting isError to true is not a crash or a 500 (those do not even reach this far): it is the tool saying it ran but could not satisfy the request. Leaving it out turns “Company group not found” into a response the model reads as successful; declaring it, with text pointing to the tool that fixes the problem, lets the model retry within the same turn without asking again.
OAuth authentication in an MCP server: why it is a layer, not a repeated check
With the six tools registered and discoverable, extra.authInfo was still undefined: anyone with the URL could read any company’s liquidity. The answer was not to drop a token check into every handler, but to wrap the whole server and add three discovery routes, without touching a single line of the tools.
The wrapper and the discovery documents
The route file no longer exports the MCP handler directly, but a wrapped one:
const handler = withMcpAuth(
createMcpHandler(registerMcpTools, { serverInfo: { /* … */ } }, { basePath: '/api' }),
verifyToken,
{ required: true, resourceMetadataPath: '/.well-known/oauth-protected-resource' }
)
export { handler as GET, handler as POST, handler as DELETE }
Its three arguments answer three questions:
- What to protect: everything from the previous block, untouched.
- How to verify: the library hands you the
Authorization: Bearer <token>and you return anAuthInfo, orundefinedif there is nothing valid; how the token gets validated is our call, and in Sherpa Platform, it is our own OAuth service. - What rules to apply:
required: trueblocks every request without valid auth, andresourceMetadataPathis the address the 401 returns so the client knows where to authenticate.
Two documents complete the layer: /.well-known/oauth-protected-resource declares which authorization server issues tokens for this resource, and /.well-known/oauth-authorization-server exposes the registration, authorization, consent and token endpoints the client uses to work out how to authenticate.
The full flow
The flow, exactly as the MCP authorization specification describes it, has three phases and a single human step:
- It starts with a 401 acting as the front door
- It continues with registration and authorization, with the verified code exchange that PKCE requires
- It ends up operating with
Bearer access_tokenon every call.
The access token lasts an hour; the refresh token lasts 30 days, renewing on each use, so the user consents once and rarely sees that screen again.
A valid UUID is not an authorized UUID
All the scaffolding above exists so that three lines fit inside the handler:
const authExtras = extra.authInfo?.extra
const companyGroup = authExtras.companyGroups.find((group) => group.uuid === companyGroupId)
if (!companyGroup) return accessDeniedError
Read the company groups the user is allowed to see, look for the companyGroupId the model is asking for in that list, and return an access-denied error if it is not there. No database call, no trust involved: it is an array coming from a verified token and a .find().
The model can propose any companyGroupId with a valid UUID shape (the inputSchema is not going to stop it), but only the handler decides whether that UUID ever belonged to the user asking. That is the rule we apply to every tool we have added since: describe well for the model, annotate with hints that tell the truth, return actionable errors with isError so it can retry in the same turn, and always validate against the token, never against the schema. A valid token and a direct tools/call get just as far as the most carefully crafted chat, so the model is never the control point: the handler is.
Everything that makes it possible for the model to call sherpa_list_company_groups first, without anyone telling it to, comes out of this combination: tools that tell the truth about what they do, and an authentication layer that decides, separately, who can ask for what.