* 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>
57 lines
1.9 KiB
TypeScript
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',
|
|
])
|