Files
accounted/extensions/general/mcp-server/public-tools.ts
T
f929b4b1d2 feat(mcp): lazy authentication so a client can connect before an account exists (#1814 PR 2) (#1892)
* feat(mcp): lazy authentication so a client can connect before an account exists

Second PR of agent-first onboarding (#1814). A client with no token may
now initialize, list the default catalog and call the three documentation
tools (search_tools, list_skills, load_skill). Every other request keeps
the transport-level 401 + WWW-Authenticate, which is what Claude, Claude
Code and Codex turn into their Connect prompt; with #1855 the account is
created inside that prompt, so the first protected tool call is the whole
signup trigger.

- The JSON-RPC body is parsed before auth so the method and tool name can
  decide whether a token is required. A tokenless unparseable body keeps
  the old 401 answer.
- Anonymous callers get an 'anonymous' actor, an empty scope set, a
  not-connected variant of the initialize instructions, and the full
  default catalog from tools/list (the agent has to be able to name a
  protected tool to trigger the challenge).
- Anonymous traffic is rate-limited per truncated IP via checkRateLimit;
  truncateIp moves to lib/api/ip.ts so the MCP server can use it without
  importing the v1 wrapper (which pulls lib/init and would cycle).
- gnubok_list_skills is now company-independent and skips its two context
  lookups when there is no company (anonymous or not yet onboarded).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018wCdzRTatKiDByKB8hCNT6

* fix(mcp): gnubok_list_skills keeps its company_id argument as an optional-company tool

Making list_skills company-independent (so anonymous callers can run it)
silently dropped its company_id argument: a multi-company user asking
for another company's skill list got the key default instead. Optional-
company tools now advertise company_id and resolve (membership-checked)
it when an authenticated caller names one; anonymous callers cannot.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018wCdzRTatKiDByKB8hCNT6

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 11:38:29 +02:00

57 lines
1.9 KiB
TypeScript

import { requestClientIp, truncateIp } from '@/lib/api/ip'
/**
* Tools an MCP client may call BEFORE the user has connected an account
* (lazy authentication, issue #1814 PR 2).
*
* The server accepts anonymous initialize / tools/list / prompts+resources
* listing and calls to these tools. Every other tools/call answers with a
* transport-level 401 + WWW-Authenticate, which the client turns into its
* Connect prompt (Claude's inline Connect card, Claude Code's /mcp login,
* Codex's `codex mcp login`) and then retries with a token.
*
* Membership rules: a public tool must (1) read no tenant data, (2) need no
* scope (it is absent from TOOL_SCOPE_MAP), and (3) be company-independent.
* Documentation and discovery only; anything that touches a company stays
* behind the challenge so the account is created first.
*/
export const PUBLIC_TOOLS: ReadonlySet<string> = new Set([
'gnubok_search_tools',
'gnubok_list_skills',
'gnubok_load_skill',
])
export function isPublicTool(canonicalToolName: string): boolean {
return PUBLIC_TOOLS.has(canonicalToolName)
}
/**
* Anonymous calls have no API key to meter on, so they are limited per
* truncated client IP. Generous for a human exploring the catalog, tight
* enough that the documentation tools cannot be farmed. Enforced by
* checkRateLimit (Upstash), which no-ops on deployments without Redis.
*/
export const ANONYMOUS_RATE_LIMIT = {
maxRequests: 60,
windowMs: 60 * 1000,
} as const
export function anonymousRateLimitIdentifier(request: Request): string {
return truncateIp(requestClientIp(request)) ?? 'unknown'
}
/**
* JSON-RPC methods that carry no tenant data and are needed for a client to
* connect and orient itself before authentication.
*/
export const ANONYMOUS_METHODS: ReadonlySet<string> = new Set([
'initialize',
'server/discover',
'ping',
'notifications/initialized',
'tools/list',
'prompts/list',
'resources/list',
'resources/templates/list',
])