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
@@ -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',
|
||||
])
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
|
||||
+8
-8
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user