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:
Jakob Wennberg
2026-08-25 11:38:29 +02:00
committed by GitHub
co-authored by Claude Fable 5 Jakob Wennberg
parent a717f03898
commit f929b4b1d2
9 changed files with 460 additions and 96 deletions
+2
View File
@@ -1206,3 +1206,5 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-08-24] Keys minted from the OAuth popup before the first company exists get company_id NULL and are bound lazily in validateApiKey (first validation after a company exists) instead of at company creation: creation happens in a Server Action that knows nothing about keys, and one chokepoint covers every creation path.
[2026-08-24] /api/mcp-oauth/authorize now forces TOTP enrollment (not just verification) for password accounts with no factor: the middleware skips enrollment for zero-company users, so a popup signup would otherwise mint an MFA-exempt key for an account with no second factor. BankID-linked accounts stay exempt.
[2026-08-24] /auth/callback honours next only when it targets /api/mcp-oauth/authorize (via safeReturnTo): consent handles the zero-company state, an arbitrary deep link would not.
[2026-08-24] MCP lazy authentication (#1814 PR 2) lists the FULL default tool catalog to anonymous clients and only gates tools/call: the agent has to be able to name a protected tool to trigger the 401 challenge that opens the Connect (and signup) prompt; listing only public tools would hide the trigger. Descriptions are public documentation anyway.
[2026-08-24] Public (pre-auth) MCP tools are the three documentation tools only (search_tools, list_skills, load_skill); org-number lookup stays behind the challenge for now because the TIC lookup lives in another extension and cross-extension imports are forbidden.
@@ -197,3 +197,22 @@ describe('MCP company routing', () => {
})
})
})
describe('optional-company tools (issue #1814)', () => {
it('gnubok_list_skills is company-independent but still advertises company_id', () => {
expect(isCompanyDependentTool('gnubok_list_skills')).toBe(false)
const projected = projectToolInputSchema({
name: 'gnubok_list_skills',
inputSchema: { type: 'object', properties: { tag: { type: 'string' } } },
})
expect((projected.properties as Record<string, unknown>).company_id).toBeDefined()
})
it('purely context-free tools do not advertise company_id', () => {
const projected = projectToolInputSchema({
name: 'gnubok_search_tools',
inputSchema: { type: 'object', properties: { query: { type: 'string' } } },
})
expect((projected.properties as Record<string, unknown>).company_id).toBeUndefined()
})
})
@@ -0,0 +1,186 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { eventBus } from '@/lib/events/bus'
// Lazy authentication (issue #1814 PR 2): a client with no token may connect,
// list the catalog and call the public documentation tools; anything that
// touches a company answers 401 + WWW-Authenticate at the transport level,
// which is what Claude / Claude Code / Codex turn into their Connect prompt.
const mocks = vi.hoisted(() => ({
validateApiKey: vi.fn(),
checkRateLimit: vi.fn(),
}))
vi.mock('@/lib/auth/api-keys', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/auth/api-keys')>()
return {
...actual,
validateApiKey: (...args: unknown[]) => mocks.validateApiKey(...args),
createServiceClientNoCookies: vi.fn(() => ({
from: vi.fn(() => {
throw new Error('anonymous requests must not touch tenant tables')
}),
})),
}
})
vi.mock('@/lib/auth/rate-limit-http', () => ({
checkRateLimit: (...args: unknown[]) => mocks.checkRateLimit(...args),
}))
// Skills come from the filesystem/registry; keep the list deterministic and
// free of Supabase so the public tools can run under the throwing client.
vi.mock('../skills', async (importOriginal) => {
const actual = await importOriginal<typeof import('../skills')>()
return {
...actual,
loadAllSkills: vi.fn().mockResolvedValue([
{
slug: 'month-end-close',
name: 'Month-end close',
summary: 'Close a month.',
tags: ['close'],
tier: 'workflow',
body: 'Steps…',
applicability: null,
},
]),
}
})
import { handleMcpRequest } from '../server'
const ENDPOINT = 'http://localhost:3000/api/extensions/ext/mcp-server/mcp'
function rpc(
method: string,
params?: Record<string, unknown>,
opts: { token?: string; ip?: string } = {}
): Request {
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
if (opts.token) headers.Authorization = `Bearer ${opts.token}`
if (opts.ip) headers['x-forwarded-for'] = opts.ip
return new Request(ENDPOINT, {
method: 'POST',
headers,
body: JSON.stringify({ jsonrpc: '2.0', id: 7, method, ...(params ? { params } : {}) }),
})
}
describe('MCP lazy authentication', () => {
beforeEach(() => {
vi.clearAllMocks()
eventBus.clear()
mocks.checkRateLimit.mockResolvedValue({ ok: true })
mocks.validateApiKey.mockResolvedValue({
userId: 'user-1',
companyId: '11111111-1111-4111-8111-111111111111',
scopes: ['companies:read'],
apiKeyId: 'key-1',
apiKeyName: 'Test key',
mode: 'live',
})
})
it('answers initialize without a token and says the client is not connected', async () => {
const response = await handleMcpRequest(rpc('initialize', { protocolVersion: '2025-06-18' }))
expect(response.status).toBe(200)
const body = await response.json()
expect(body.result.instructions).toContain('NOT CONNECTED YET')
expect(body.result.instructions).toContain('gnubok_search_tools')
expect(mocks.validateApiKey).not.toHaveBeenCalled()
})
it('lists the full default catalog without a token so protected tools can be called', async () => {
const response = await handleMcpRequest(rpc('tools/list'))
expect(response.status).toBe(200)
const body = await response.json()
const names = (body.result.tools as Array<{ name: string }>).map((t) => t.name)
expect(names).toContain('gnubok_search_tools')
// A company-scoped tool is listed too: calling it is the connect trigger.
expect(names).toContain('gnubok_list_companies')
expect(names.length).toBeGreaterThan(20)
})
it('runs a public documentation tool without a token', async () => {
const response = await handleMcpRequest(
rpc('tools/call', { name: 'gnubok_list_skills', arguments: {} })
)
expect(response.status).toBe(200)
const body = await response.json()
expect(body.result.isError).not.toBe(true)
const payload = JSON.parse(body.result.content[0].text)
expect(payload.count).toBe(1)
expect(payload.skills[0].slug).toBe('month-end-close')
expect(mocks.validateApiKey).not.toHaveBeenCalled()
})
it('ignores a company_id on a public tool when anonymous instead of touching tenant tables', async () => {
const response = await handleMcpRequest(
rpc('tools/call', {
name: 'gnubok_list_skills',
arguments: { company_id: '11111111-1111-4111-8111-111111111111' },
})
)
expect(response.status).toBe(200)
const body = await response.json()
expect(body.result.isError).not.toBe(true)
})
it('challenges a protected tool call with a transport-level 401 + WWW-Authenticate', async () => {
const response = await handleMcpRequest(
rpc('tools/call', { name: 'gnubok_list_companies', arguments: {} })
)
expect(response.status).toBe(401)
expect(response.headers.get('WWW-Authenticate')).toMatch(
/^Bearer resource_metadata="http:\/\/localhost:3000\/\.well-known\/oauth-protected-resource"$/
)
})
it('challenges tenant-scoped methods (resources/read, tasks/get) without a token', async () => {
for (const [method, params] of [
['resources/read', { uri: 'Accounted://company/current' }],
['tasks/get', { taskId: 'x' }],
] as const) {
const response = await handleMcpRequest(rpc(method, params as Record<string, unknown>))
expect(response.status, method).toBe(401)
expect(response.headers.get('WWW-Authenticate'), method).toContain('resource_metadata')
}
})
it('keeps a tokenless unparseable body on the pre-lazy-auth answer (401, no detail)', async () => {
const response = await handleMcpRequest(
new Request(ENDPOINT, { method: 'POST', body: 'not json' })
)
expect(response.status).toBe(401)
})
it('rate-limits anonymous calls per truncated IP', async () => {
mocks.checkRateLimit.mockResolvedValueOnce({
ok: false,
response: new Response('slow down', { status: 429 }),
})
const response = await handleMcpRequest(
rpc('tools/list', undefined, { ip: '203.0.113.42, 10.0.0.1' })
)
expect(response.status).toBe(429)
expect(mocks.checkRateLimit).toHaveBeenCalledWith(
expect.objectContaining({ prefix: 'mcp:anonymous', identifier: '203.0.113.0/24' })
)
})
it('never rate-limits or bypasses validation for a token bearer', async () => {
const response = await handleMcpRequest(
rpc('tools/call', { name: 'gnubok_list_skills', arguments: {} }, { token: 'gnubok_sk_x' })
)
expect(response.status).toBe(200)
expect(mocks.validateApiKey).toHaveBeenCalledWith('gnubok_sk_x')
expect(mocks.checkRateLimit).not.toHaveBeenCalled()
})
it('still rejects an invalid token with 401 even on an anonymous-capable method', async () => {
mocks.validateApiKey.mockResolvedValueOnce({ error: 'Invalid API key', status: 401 })
const response = await handleMcpRequest(rpc('tools/list', undefined, { token: 'gnubok_sk_bad' }))
expect(response.status).toBe(401)
})
})
@@ -7,10 +7,24 @@ const UUID_PATTERN =
const COMPANY_INDEPENDENT_TOOLS = new Set([
'gnubok_search_tools',
'gnubok_list_skills',
'gnubok_load_skill',
'gnubok_list_companies',
])
/**
* Company-independent tools that still USE a company when one is available:
* they run without one (anonymous or not-yet-onboarded callers, issue #1814)
* but accept an explicit company_id, which is then membership-checked like
* on any company-dependent tool. gnubok_list_skills filters skills by the
* company's entity type, employees and VAT registration.
*/
const OPTIONAL_COMPANY_TOOLS = new Set(['gnubok_list_skills'])
export function isOptionalCompanyTool(toolName: string): boolean {
return OPTIONAL_COMPANY_TOOLS.has(toolName)
}
export const COMPANY_ID_INPUT_PROPERTY = {
type: 'string',
format: 'uuid',
@@ -68,7 +82,7 @@ export function isTenantWriteScope(scope: ApiKeyScope | undefined): boolean {
}
export function projectToolInputSchema(tool: ToolSchemaSource): Record<string, unknown> {
if (!isCompanyDependentTool(tool.name)) return tool.inputSchema
if (!isCompanyDependentTool(tool.name) && !isOptionalCompanyTool(tool.name)) return tool.inputSchema
const properties =
tool.inputSchema.properties && typeof tool.inputSchema.properties === 'object'
@@ -0,0 +1,56 @@
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',
])
+133 -69
View File
@@ -13,7 +13,16 @@ import {
createServiceClientNoCookies,
hasScope,
TOOL_SCOPE_MAP,
type ApiKeyMode,
type ApiKeyScope,
} from '@/lib/auth/api-keys'
import { checkRateLimit } from '@/lib/auth/rate-limit-http'
import {
ANONYMOUS_METHODS,
ANONYMOUS_RATE_LIMIT,
anonymousRateLimitIdentifier,
isPublicTool,
} from './public-tools'
import { createLogger } from '@/lib/logger'
import { roundOre, sumOre } from '@/lib/money'
import {
@@ -156,6 +165,7 @@ import {
codedError,
extractRequestedCompany,
isCompanyDependentTool,
isOptionalCompanyTool,
noCompanyYetError,
projectToolInputSchema,
resolveMcpCompanyContext,
@@ -344,7 +354,9 @@ function resolveInvoiceLineFromArticle(
}
interface ActorContext {
type: 'user' | 'api_key' | 'mcp_oauth' | 'cron'
// 'anonymous': a client that has not connected an account yet (lazy
// authentication, issue #1814). Only PUBLIC_TOOLS ever run under it.
type: 'user' | 'api_key' | 'mcp_oauth' | 'cron' | 'anonymous'
id?: string
label?: string
/**
@@ -3205,19 +3217,23 @@ export const tools: McpTool[] = [
// Resolve company context: read once per call. Failures degrade
// gracefully: an unresolved field means "don't filter on it" so a
// misconfigured company still gets the full skill list.
const [settings, employeeCount] = await Promise.all([
supabase
.from('company_settings')
.select('entity_type, vat_registered')
.eq('company_id', companyId)
.maybeSingle(),
supabase
.from('employees')
.select('id', { count: 'exact', head: true })
.eq('company_id', companyId)
.eq('is_active', true),
])
// misconfigured company still gets the full skill list. No company at
// all (anonymous or not-yet-onboarded caller, issue #1814) means no
// context to filter on: the full list, without the two lookups.
const [settings, employeeCount] = companyId
? await Promise.all([
supabase
.from('company_settings')
.select('entity_type, vat_registered')
.eq('company_id', companyId)
.maybeSingle(),
supabase
.from('employees')
.select('id', { count: 'exact', head: true })
.eq('company_id', companyId)
.eq('is_active', true),
])
: [{ data: null }, { count: 0 }]
const entityType = (settings.data?.entity_type as string | undefined) ?? null
const vatRegistered = Boolean(settings.data?.vat_registered)
const hasEmployees = (employeeCount.count ?? 0) > 0
@@ -18142,44 +18158,88 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
}
const wwwAuth = `Bearer resource_metadata="${resourceMetadataUrl.toString()}"`
// ── Pre-auth: handle fire-and-forget notifications before auth check ──
// MCP notifications have no id and don't expect error responses.
// Checking auth on them would return 401 which confuses clients.
const clonedRequest = request.clone()
const unauthorized = () =>
new Response('Unauthorized', {
status: 401,
headers: { 'WWW-Authenticate': wwwAuth },
})
// ── Parse JSON-RPC ──
// Parsed before auth: with lazy authentication (issue #1814) the method and
// tool name decide whether a token is required at all. A body that cannot
// be parsed keeps the pre-lazy-auth answer for a tokenless caller (401), so
// probing the endpoint without credentials learns nothing new.
const token = extractBearerToken(request)
let body: JsonRpcRequest
try {
const peek = await clonedRequest.json()
if (peek.method === 'notifications/initialized') {
return new Response(null, { status: 202 })
}
body = await request.json()
} catch {
// Not valid JSON: fall through to auth + parse below
if (!token) return unauthorized()
return NextResponse.json(
jsonRpcError(null, -32700, 'Parse error: expected JSON-RPC 2.0 request body'),
{ status: 400 }
)
}
// Fire-and-forget notification: no id, no response expected. Answering
// 401 here confuses clients, so it is accepted before any auth check.
if (body.method === 'notifications/initialized') {
return new Response(null, { status: 202 })
}
if (body.jsonrpc !== '2.0' || !body.method) {
if (!token) return unauthorized()
return NextResponse.json(
jsonRpcError(body.id ?? null, -32600, 'Invalid Request: must include jsonrpc="2.0" and method'),
{ status: 400 }
)
}
// ── Auth ──
const token = extractBearerToken(request)
if (!token) {
return new Response('Unauthorized', {
status: 401,
headers: { 'WWW-Authenticate': wwwAuth },
})
}
// Lazy authentication: a client with no token may connect, list the
// catalog and call the PUBLIC_TOOLS. Every other request answers 401 +
// WWW-Authenticate at the transport level: that challenge is what the
// client turns into its Connect prompt (a 200 with isError never would).
// The account can be created inside that prompt (app/api/mcp-oauth), so
// the first protected tool call is the whole signup trigger.
const requestedTool =
body.method === 'tools/call'
? toCanonicalToolName(String((body.params as Record<string, unknown> | undefined)?.name ?? ''))
: null
const anonymousAllowed =
ANONYMOUS_METHODS.has(body.method) || (requestedTool !== null && isPublicTool(requestedTool))
if (!token && !anonymousAllowed) return unauthorized()
const authResult = await validateApiKey(token)
if ('error' in authResult) {
const status = authResult.status
if (status === 429) {
return new Response(authResult.error, {
status: 429,
headers: { 'Content-Type': 'text/plain', 'Retry-After': '60' },
})
const isAnonymous = !token
let userId = ''
let companyId: string | null = null
let keyScopes: ApiKeyScope[] = []
let apiKeyId: string | undefined
let apiKeyName: string | undefined
let keyMode: ApiKeyMode = 'live'
if (token) {
const authResult = await validateApiKey(token)
if ('error' in authResult) {
const status = authResult.status
if (status === 429) {
return new Response(authResult.error, {
status: 429,
headers: { 'Content-Type': 'text/plain', 'Retry-After': '60' },
})
}
return unauthorized()
}
return new Response('Unauthorized', {
status: 401,
headers: { 'WWW-Authenticate': wwwAuth },
;({ userId, companyId, scopes: keyScopes, apiKeyId, apiKeyName, mode: keyMode } = authResult)
} else {
// Anonymous traffic has no key to rate-limit on: per truncated IP instead.
// No-op without Upstash (self-hosted), like the OAuth register endpoint.
const rl = await checkRateLimit({
prefix: 'mcp:anonymous',
identifier: anonymousRateLimitIdentifier(request),
...ANONYMOUS_RATE_LIMIT,
})
if (!rl.ok) return rl.response!
}
const { userId, companyId, scopes: keyScopes, apiKeyId, apiKeyName, mode: keyMode } = authResult
const supabase = createServiceClientNoCookies()
// The Mcp-Session-Id header (introduced in spec 2025-06-18) is the canonical
// way for an agent to keep a stable identifier across tools/call invocations
@@ -18195,31 +18255,15 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
request.headers.get('x-gnubok-client') ??
new URL(request.url).searchParams.get('client')
const client = rawClient && /^[A-Za-z0-9._-]{1,64}$/.test(rawClient) ? rawClient.toLowerCase() : null
const actor: ActorContext = {
type: 'api_key',
id: apiKeyId,
label: apiKeyName ?? 'Unnamed API key',
sessionId,
client,
}
// ── Parse JSON-RPC ──
let body: JsonRpcRequest
try {
body = await request.json()
} catch {
return NextResponse.json(
jsonRpcError(null, -32700, 'Parse error: expected JSON-RPC 2.0 request body'),
{ status: 400 }
)
}
if (body.jsonrpc !== '2.0' || !body.method) {
return NextResponse.json(
jsonRpcError(body.id ?? null, -32600, 'Invalid Request: must include jsonrpc="2.0" and method'),
{ status: 400 }
)
}
const actor: ActorContext = isAnonymous
? { type: 'anonymous', label: 'Not connected', sessionId, client }
: {
type: 'api_key',
id: apiKeyId,
label: apiKeyName ?? 'Unnamed API key',
sessionId,
client,
}
// ── Stateless core (spec 2026-07-28) ──
// New-style clients carry their protocol version in _meta on every request
@@ -18328,6 +18372,12 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
const instructions = projectToolReferencesInText([
'Accounted: Swedish double-entry bookkeeping via conversation.',
'',
...(isAnonymous
? [
'NOT CONNECTED YET. Without an account you can call gnubok_search_tools, gnubok_list_skills and gnubok_load_skill. Every other tool needs the user to connect their Accounted account: calling one returns an authentication challenge that your client shows as a Connect prompt. A user who has no account creates one right there (BankID or e-mail, about a minute), and the call is then retried automatically. To start bookkeeping for a company that is not in Accounted yet, call gnubok_list_companies to trigger the connect step, then continue with the setup.',
'',
]
: []),
'Discovery:',
'• tools/list returns common tool schemas. Call gnubok_search_tools(query="…") for specialized tools: it ranks all capabilities; pass detail="name"|"summary"|"full" to control payload size.',
'• gnubok_get_agent_briefing returns recommended_tools: ordered per-workflow tool loadouts (categorize_month, close_period, invoice_run, vat_declaration, payroll_month). If your harness defers tool loading, batch-load a whole workflow in one call (e.g. Claude Code ToolSearch select:a,b,c) instead of searching cluster by cluster.',
@@ -18395,6 +18445,10 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
const listStartedAt = Date.now()
const allowedTools = tools.filter((t) => {
if (!isDefaultCatalogTool(t)) return false
// Not connected yet: the whole default catalog is listed so the agent
// can pick the right tool; calling a protected one is what produces
// the 401 challenge that starts the connect (and signup) flow.
if (isAnonymous) return true
const required = TOOL_SCOPE_MAP[t.name]
return !required || hasScope(keyScopes, required)
})
@@ -18444,6 +18498,9 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
>
const tool = tools.find((t) => t.name === toolName)
// The pre-auth gate already refused anonymous calls to anything outside
// PUBLIC_TOOLS; re-checked here so the dispatcher never depends on it.
if (isAnonymous && !isPublicTool(toolName)) return unauthorized()
if (!tool) {
emitToolCallTelemetry({
tool: toolName ?? '<unknown>',
@@ -18527,7 +18584,14 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
)
}
if (isCompanyDependentTool(toolName)) {
// Optional-company tools resolve (and membership-check) a company only
// when the caller names one; anonymous callers have no memberships to
// check, so a company_id from them is dropped rather than resolved.
const wantsCompanyContext =
isCompanyDependentTool(toolName) ||
(isOptionalCompanyTool(toolName) && !isAnonymous && extracted.requestedCompanyId !== undefined)
if (wantsCompanyContext) {
const companyContext = await resolveMcpCompanyContext({
supabase,
userId,
+39
View File
@@ -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
}
+2 -18
View File
@@ -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')
+8 -8
View File
@@ -206,7 +206,7 @@ export type CoreEvent =
| { type: 'mcp.tool_called'; payload: {
tool: string // e.g. 'gnubok_create_invoice'
requiredScope: string | null // from TOOL_SCOPE_MAP, null if unscoped
actorType: 'user' | 'api_key' | 'mcp_oauth' | 'cron'
actorType: 'user' | 'api_key' | 'mcp_oauth' | 'cron' | 'anonymous'
actorId: string | null // api_key id, oauth client, etc.
actorLabel: string | null // human-readable actor label
latencyMs: number // wall-clock time inside execute()
@@ -229,7 +229,7 @@ export type CoreEvent =
// the caller's scope set.
| { type: 'mcp.tools_list_called'; payload: {
toolCount: number // tools actually returned (post scope filter)
actorType: 'user' | 'api_key' | 'mcp_oauth' | 'cron'
actorType: 'user' | 'api_key' | 'mcp_oauth' | 'cron' | 'anonymous'
actorId: string | null
actorLabel: string | null
latencyMs: number
@@ -248,7 +248,7 @@ export type CoreEvent =
success: boolean
errorCode: string | null
latencyMs: number
actorType: 'user' | 'api_key' | 'mcp_oauth' | 'cron'
actorType: 'user' | 'api_key' | 'mcp_oauth' | 'cron' | 'anonymous'
actorId: string | null
actorLabel: string | null
requestId: string | number | null
@@ -264,7 +264,7 @@ export type CoreEvent =
| { type: 'mcp.workflow_started'; payload: {
slug: string // e.g. 'month-end-close'
sessionId: string | null
actorType: 'user' | 'api_key' | 'mcp_oauth' | 'cron'
actorType: 'user' | 'api_key' | 'mcp_oauth' | 'cron' | 'anonymous'
actorId: string | null
actorLabel: string | null
userId: string
@@ -276,7 +276,7 @@ export type CoreEvent =
outcome: 'success' | 'abandoned' | 'failed'
stepsCompleted: number | null // null when not tracked granularly
durationMs: number | null
actorType: 'user' | 'api_key' | 'mcp_oauth' | 'cron'
actorType: 'user' | 'api_key' | 'mcp_oauth' | 'cron' | 'anonymous'
actorId: string | null
actorLabel: string | null
userId: string
@@ -291,7 +291,7 @@ export type CoreEvent =
slug: string // e.g. 'modifier/holding-ab', 'month-end-close'
tier: 'workflow' | 'horizontal' | 'vertical' | 'modifier'
sessionId: string | null
actorType: 'user' | 'api_key' | 'mcp_oauth' | 'cron'
actorType: 'user' | 'api_key' | 'mcp_oauth' | 'cron' | 'anonymous'
actorId: string | null
actorLabel: string | null
userId: string
@@ -305,7 +305,7 @@ export type CoreEvent =
fromTool: string
toTool: string
sessionId: string | null
actorType: 'user' | 'api_key' | 'mcp_oauth' | 'cron'
actorType: 'user' | 'api_key' | 'mcp_oauth' | 'cron' | 'anonymous'
actorId: string | null
actorLabel: string | null
userId: string
@@ -320,7 +320,7 @@ export type CoreEvent =
toolName: string | null
skillSlug: string | null
sessionId: string | null
actorType: 'user' | 'api_key' | 'mcp_oauth' | 'cron'
actorType: 'user' | 'api_key' | 'mcp_oauth' | 'cron' | 'anonymous'
actorId: string | null
actorLabel: string | null
userId: string