Files
accounted/extensions/general/mcp-server/__tests__/company-routing.test.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

219 lines
7.3 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest'
import {
addCompanyToNextHint,
addCompanyToTopLevelNext,
assertMcpCompanyWriteAccess,
extractRequestedCompany,
isCompanyDependentTool,
projectToolInputSchema,
resolveMcpCompanyContext,
} from '../company-routing'
const DEFAULT_COMPANY_ID = '11111111-1111-4111-8111-111111111111'
const OTHER_COMPANY_ID = '22222222-2222-4222-8222-222222222222'
function membershipClient(result: { data: unknown; error: unknown }) {
const chain: Record<string, ReturnType<typeof vi.fn>> = {
select: vi.fn(() => chain),
eq: vi.fn(() => chain),
is: vi.fn(() => chain),
maybeSingle: vi.fn().mockResolvedValue(result),
}
return {
client: { from: vi.fn(() => chain) },
chain,
}
}
describe('MCP company routing', () => {
it('projects company_id onto company-dependent tool schemas without mutating the source', () => {
const inputSchema = {
type: 'object',
additionalProperties: false,
properties: { invoice_id: { type: 'string' } },
required: ['invoice_id'],
}
const projected = projectToolInputSchema({ name: 'gnubok_send_invoice', inputSchema })
expect(projected).not.toBe(inputSchema)
expect(projected.properties).toEqual({
invoice_id: { type: 'string' },
company_id: expect.objectContaining({ type: 'string', format: 'uuid' }),
})
expect(inputSchema.properties).not.toHaveProperty('company_id')
expect(projected.additionalProperties).toBe(false)
})
it.each(['gnubok_search_tools', 'gnubok_load_skill', 'gnubok_list_companies'])(
'keeps the company-independent schema unchanged for %s',
(name) => {
const inputSchema = {
type: 'object',
additionalProperties: false,
properties: {},
}
expect(isCompanyDependentTool(name)).toBe(false)
expect(projectToolInputSchema({ name, inputSchema })).toBe(inputSchema)
}
)
it('extracts and strips a valid company_id before tool execution', () => {
expect(
extractRequestedCompany({ company_id: OTHER_COMPANY_ID, invoice_id: 'invoice-1' })
).toEqual({
requestedCompanyId: OTHER_COMPANY_ID,
toolArgs: { invoice_id: 'invoice-1' },
})
})
it('rejects a malformed company_id', () => {
expect(() => extractRequestedCompany({ company_id: 'not-a-uuid' })).toThrow(
expect.objectContaining({ code: 'VALIDATION_ERROR' })
)
})
it('checks membership and resolves the requested company role', async () => {
const { client, chain } = membershipClient({
data: { company_id: OTHER_COMPANY_ID, role: 'admin' },
error: null,
})
await expect(
resolveMcpCompanyContext({
supabase: client as never,
userId: 'user-1',
defaultCompanyId: DEFAULT_COMPANY_ID,
requestedCompanyId: OTHER_COMPANY_ID,
})
).resolves.toEqual({
companyId: OTHER_COMPANY_ID,
role: 'admin',
isDefault: false,
})
expect(chain.eq).toHaveBeenCalledWith('user_id', 'user-1')
expect(chain.eq).toHaveBeenCalledWith('company_id', OTHER_COMPANY_ID)
expect(chain.is).toHaveBeenCalledWith('companies.archived_at', null)
})
it('checks the API key default company when company_id is omitted', async () => {
const { client, chain } = membershipClient({
data: { company_id: DEFAULT_COMPANY_ID, role: 'owner' },
error: null,
})
await expect(
resolveMcpCompanyContext({
supabase: client as never,
userId: 'user-1',
defaultCompanyId: DEFAULT_COMPANY_ID,
})
).resolves.toEqual({
companyId: DEFAULT_COMPANY_ID,
role: 'owner',
isDefault: true,
})
expect(chain.eq).toHaveBeenCalledWith('company_id', DEFAULT_COMPANY_ID)
})
it('refuses company-dependent calls on a key whose user has no company yet', async () => {
// A key minted from the OAuth popup before onboarding (issue #1814) has
// no default company. The refusal is a distinct, actionable code and
// never reaches the membership lookup.
const { client, chain } = membershipClient({ data: null, error: null })
await expect(
resolveMcpCompanyContext({
supabase: client as never,
userId: 'user-1',
defaultCompanyId: null,
})
).rejects.toMatchObject({ code: 'NO_COMPANY_YET' })
expect(chain.maybeSingle).not.toHaveBeenCalled()
})
it('rejects companies without a current non-archived membership', async () => {
const { client } = membershipClient({ data: null, error: null })
await expect(
resolveMcpCompanyContext({
supabase: client as never,
userId: 'user-1',
defaultCompanyId: DEFAULT_COMPANY_ID,
requestedCompanyId: OTHER_COMPANY_ID,
})
).rejects.toMatchObject({ code: 'NOT_FOUND' })
})
it('fails closed when the membership lookup fails', async () => {
const { client } = membershipClient({
data: null,
error: { message: 'database unavailable' },
})
await expect(
resolveMcpCompanyContext({
supabase: client as never,
userId: 'user-1',
defaultCompanyId: DEFAULT_COMPANY_ID,
})
).rejects.toMatchObject({ code: 'INTERNAL_ERROR' })
})
it('allows viewer reads but rejects viewer writes, approvals, and management', () => {
const context = { companyId: OTHER_COMPANY_ID, role: 'viewer' as const, isDefault: false }
expect(() => assertMcpCompanyWriteAccess(context, 'reports:read')).not.toThrow()
expect(() => assertMcpCompanyWriteAccess(context, undefined)).not.toThrow()
expect(() => assertMcpCompanyWriteAccess(context, 'invoices:write')).toThrow(
expect.objectContaining({ code: 'FORBIDDEN' })
)
expect(() => assertMcpCompanyWriteAccess(context, 'pending_operations:approve')).toThrow(
expect.objectContaining({ code: 'FORBIDDEN' })
)
expect(() => assertMcpCompanyWriteAccess(context, 'webhooks:manage')).toThrow(
expect.objectContaining({ code: 'FORBIDDEN' })
)
})
it('keeps company context in follow-up tool hints', () => {
const next = {
tool: 'gnubok_approve_pending_operation',
description: 'Approve the operation',
args: { operation_id: 'operation-1' },
}
expect(addCompanyToNextHint(next, OTHER_COMPANY_ID)).toEqual({
...next,
args: { operation_id: 'operation-1', company_id: OTHER_COMPANY_ID },
})
expect(addCompanyToTopLevelNext({ data: {}, next }, OTHER_COMPANY_ID)).toEqual({
data: {},
next: {
...next,
args: { operation_id: 'operation-1', company_id: OTHER_COMPANY_ID },
},
})
})
})
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()
})
})