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>
This commit is contained in:
co-authored by
Claude Fable 5
Jakob Wennberg
parent
a717f03898
commit
f929b4b1d2
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Truncate a client IP to a privacy-preserving prefix for rate limiting and
|
||||
* forensic logging: IPv4 to its /24, IPv6 to its /48. Enough for abuse
|
||||
* correlation, not enough to identify a point of presence.
|
||||
*
|
||||
* Honors `x-forwarded-for` when set (Vercel / proxies); behind Vercel the
|
||||
* leftmost value is rewritten by the edge so we accept it as authoritative.
|
||||
*
|
||||
* Lives in its own module (no route or init imports) so both the v1 REST
|
||||
* wrapper and the MCP server can use it without an import cycle.
|
||||
*/
|
||||
export function truncateIp(ip: string | undefined): string | undefined {
|
||||
if (!ip) return undefined
|
||||
// IPv4: validate octets are 0-255, then drop last octet → "203.0.113.0/24".
|
||||
// Out-of-range octets indicate a spoofed or malformed header; refuse to
|
||||
// log a pseudo-IP that would pollute abuse-pattern analysis.
|
||||
const v4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(ip)
|
||||
if (v4) {
|
||||
const octets = [v4[1], v4[2], v4[3], v4[4]].map((s) => Number.parseInt(s, 10))
|
||||
if (octets.every((o) => o >= 0 && o <= 255)) {
|
||||
return `${octets[0]}.${octets[1]}.${octets[2]}.0/24`
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
// IPv6: keep first 3 hextets → "2001:db8:abc::/48"
|
||||
const v6 = /^([0-9a-f]{1,4}:[0-9a-f]{1,4}:[0-9a-f]{1,4}):/i.exec(ip)
|
||||
if (v6) return `${v6[1]}::/48`
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* The client IP as the request presents it: leftmost `x-forwarded-for`
|
||||
* entry, else `x-real-ip`, else undefined.
|
||||
*/
|
||||
export function requestClientIp(request: Request): string | undefined {
|
||||
const forwarded = request.headers.get('x-forwarded-for')
|
||||
if (forwarded) return forwarded.split(',')[0]?.trim() || undefined
|
||||
return request.headers.get('x-real-ip') ?? undefined
|
||||
}
|
||||
@@ -38,6 +38,7 @@ import { type SupabaseClient } from '@supabase/supabase-js'
|
||||
import { createServiceRoleClient } from '@/lib/supabase/service-client'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { truncateIp } from '@/lib/api/ip'
|
||||
import {
|
||||
type ApiKeyMode,
|
||||
type ApiKeyScope,
|
||||
@@ -167,24 +168,7 @@ function createAnonClient(): SupabaseClient {
|
||||
* Honors `x-forwarded-for` when set (Vercel / proxies); behind Vercel the
|
||||
* leftmost value is rewritten by the edge so we accept it as authoritative.
|
||||
*/
|
||||
export function truncateIp(ip: string | undefined): string | undefined {
|
||||
if (!ip) return undefined
|
||||
// IPv4: validate octets are 0-255, then drop last octet → "203.0.113.0/24".
|
||||
// Out-of-range octets indicate a spoofed or malformed header; refuse to
|
||||
// log a pseudo-IP that would pollute abuse-pattern analysis.
|
||||
const v4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(ip)
|
||||
if (v4) {
|
||||
const octets = [v4[1], v4[2], v4[3], v4[4]].map((s) => Number.parseInt(s, 10))
|
||||
if (octets.every((o) => o >= 0 && o <= 255)) {
|
||||
return `${octets[0]}.${octets[1]}.${octets[2]}.0/24`
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
// IPv6: keep first 3 hextets → "2001:db8:abc::/48"
|
||||
const v6 = /^([0-9a-f]{1,4}:[0-9a-f]{1,4}:[0-9a-f]{1,4}):/i.exec(ip)
|
||||
if (v6) return `${v6[1]}::/48`
|
||||
return undefined
|
||||
}
|
||||
export { truncateIp }
|
||||
|
||||
function extractForensicContext(request: Request, log: Logger): { ip: string | undefined; userAgent: string | undefined } {
|
||||
const fwd = request.headers.get('x-forwarded-for')
|
||||
|
||||
Reference in New Issue
Block a user