* 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>
219 lines
7.3 KiB
TypeScript
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()
|
|
})
|
|
})
|