Fix/supp ag fb (#1023)
* fix: prevent credit notes from entering payment flow * fix: persist and display customer personal numbers * feat: configure automatic invoice reminder days * fix: issue credit notes through send flow * chore: add repository agent guidance * feat(mcp): route tools across user companies * fix(articles): delete unused register entries * feat(invoices): improve issued invoice actions * feat(supplier-invoices): retain uploaded source documents * docs: record implementation decisions * feat: enhance customer personal number handling and validation - Updated CustomerForm to allow personal numbers in the format of "********-1234" for individual customers. - Added validation to ensure personal numbers are only accepted for individual customers in CreateCustomerSchema. - Implemented masking and encryption for personal numbers to enhance data protection. - Introduced new utility functions for masking and encrypting personal numbers. - Added database migration to enforce unique constraints on credit note relationships and prevent duplicate entries. - Enhanced error handling and logging for credit note issuance and invoice processing. - Updated tests to cover new credit note creation guards and personal number handling. * test: enhance list companies test with supabase query mocks
This commit is contained in:
@@ -14,6 +14,6 @@
|
||||
"dataPattern": "core",
|
||||
"readsCoreTables": ["invoices", "customers", "company_settings"],
|
||||
"description": "Skicka fakturor och påminnelser via e-post",
|
||||
"longDescription": "Aktiverar e-postfunktioner: skicka fakturor till kunder, automatiska betalningspåminnelser (15/30/45 dagar), och e-postmeddelanden. Kräver ett Resend-konto med verifierad domän."
|
||||
"longDescription": "Aktiverar e-postfunktioner: skicka fakturor till kunder, automatiska betalningspåminnelser enligt valt schema, och e-postmeddelanden. Kräver ett Resend-konto med verifierad domän."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,24 @@ vi.mock('@/lib/auth/api-keys', async (importOriginal) => {
|
||||
},
|
||||
},
|
||||
)
|
||||
const membershipChain: unknown = new Proxy(
|
||||
{},
|
||||
{
|
||||
get(_t, prop) {
|
||||
if (prop === 'then') {
|
||||
return (resolve: (v: unknown) => void) =>
|
||||
resolve({
|
||||
data: {
|
||||
company_id: '11111111-1111-4111-8111-111111111111',
|
||||
role: 'owner',
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
}
|
||||
return () => membershipChain
|
||||
},
|
||||
}
|
||||
)
|
||||
return {
|
||||
...actual,
|
||||
extractBearerToken: vi.fn().mockReturnValue('test-token'),
|
||||
@@ -48,7 +66,10 @@ vi.mock('@/lib/auth/api-keys', async (importOriginal) => {
|
||||
apiKeyId: 'key-1',
|
||||
apiKeyName: 'Test Key',
|
||||
}),
|
||||
createServiceClientNoCookies: vi.fn(() => ({ from: () => chain, rpc: () => chain })),
|
||||
createServiceClientNoCookies: vi.fn(() => ({
|
||||
from: (table: string) => (table === 'company_members' ? membershipChain : chain),
|
||||
rpc: () => chain,
|
||||
})),
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
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('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 },
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,121 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TOOL_SCOPE_MAP } from '@/lib/auth/api-keys'
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
getUserCompanies: vi.fn(),
|
||||
}))
|
||||
|
||||
import { getUserCompanies } from '@/lib/company/context'
|
||||
import { tools } from '../server'
|
||||
|
||||
const DEFAULT_COMPANY_ID = '11111111-1111-4111-8111-111111111111'
|
||||
const OTHER_COMPANY_ID = '22222222-2222-4222-8222-222222222222'
|
||||
const ARCHIVED_COMPANY_ID = '33333333-3333-4333-8333-333333333333'
|
||||
const listCompaniesTool = tools.find((tool) => tool.name === 'gnubok_list_companies')!
|
||||
|
||||
describe('gnubok_list_companies', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('is a read-only companies:read discovery tool', () => {
|
||||
expect(listCompaniesTool).toBeDefined()
|
||||
expect(TOOL_SCOPE_MAP.gnubok_list_companies).toBe('companies:read')
|
||||
expect(listCompaniesTool.annotations).toMatchObject({
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('lists every non-archived membership with role and marks the default', async () => {
|
||||
vi.mocked(getUserCompanies).mockResolvedValue([
|
||||
{
|
||||
company_id: DEFAULT_COMPANY_ID,
|
||||
role: 'owner',
|
||||
joined_at: '2026-01-01',
|
||||
companies: {
|
||||
id: DEFAULT_COMPANY_ID,
|
||||
name: 'Legal Default AB',
|
||||
org_number: '559000-0001',
|
||||
entity_type: 'AB',
|
||||
archived_at: null,
|
||||
created_at: '2026-01-01',
|
||||
},
|
||||
},
|
||||
{
|
||||
company_id: OTHER_COMPANY_ID,
|
||||
role: 'viewer',
|
||||
joined_at: '2026-02-01',
|
||||
companies: {
|
||||
id: OTHER_COMPANY_ID,
|
||||
name: 'Other Legal Name',
|
||||
org_number: null,
|
||||
entity_type: 'EF',
|
||||
archived_at: null,
|
||||
created_at: '2026-02-01',
|
||||
},
|
||||
},
|
||||
{
|
||||
company_id: ARCHIVED_COMPANY_ID,
|
||||
role: 'admin',
|
||||
joined_at: '2026-03-01',
|
||||
companies: {
|
||||
id: ARCHIVED_COMPANY_ID,
|
||||
name: 'Archived AB',
|
||||
org_number: '559000-0003',
|
||||
entity_type: 'AB',
|
||||
archived_at: '2026-06-01',
|
||||
created_at: '2026-03-01',
|
||||
},
|
||||
},
|
||||
] as never)
|
||||
|
||||
const rangeMock = vi.fn().mockResolvedValue({
|
||||
data: [{ company_id: DEFAULT_COMPANY_ID, company_name: 'Configured Default AB' }],
|
||||
error: null,
|
||||
})
|
||||
const orderMock = vi.fn(() => ({ range: rangeMock }))
|
||||
const inMock = vi.fn(() => ({ order: orderMock }))
|
||||
const supabase = {
|
||||
from: vi.fn(() => ({
|
||||
select: vi.fn(() => ({ in: inMock })),
|
||||
})),
|
||||
}
|
||||
|
||||
const result = (await listCompaniesTool.execute(
|
||||
{},
|
||||
DEFAULT_COMPANY_ID,
|
||||
'user-1',
|
||||
supabase as never,
|
||||
{ type: 'api_key' }
|
||||
)) as Record<string, unknown>
|
||||
|
||||
expect(getUserCompanies).toHaveBeenCalledWith(supabase, 'user-1')
|
||||
expect(inMock).toHaveBeenCalledWith('company_id', [DEFAULT_COMPANY_ID, OTHER_COMPANY_ID])
|
||||
expect(orderMock).toHaveBeenCalledWith('company_id', { ascending: true })
|
||||
expect(rangeMock).toHaveBeenCalledWith(0, 999)
|
||||
expect(result).toEqual({
|
||||
companies: [
|
||||
{
|
||||
company_id: DEFAULT_COMPANY_ID,
|
||||
name: 'Configured Default AB',
|
||||
org_number: '559000-0001',
|
||||
entity_type: 'AB',
|
||||
role: 'owner',
|
||||
is_default: true,
|
||||
},
|
||||
{
|
||||
company_id: OTHER_COMPANY_ID,
|
||||
name: 'Other Legal Name',
|
||||
org_number: null,
|
||||
entity_type: 'EF',
|
||||
role: 'viewer',
|
||||
is_default: false,
|
||||
},
|
||||
],
|
||||
count: 2,
|
||||
default_company_id: DEFAULT_COMPANY_ID,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,165 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
|
||||
const DEFAULT_COMPANY_ID = '11111111-1111-4111-8111-111111111111'
|
||||
const OTHER_COMPANY_ID = '22222222-2222-4222-8222-222222222222'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
membership: {
|
||||
data: {
|
||||
company_id: '22222222-2222-4222-8222-222222222222',
|
||||
role: 'owner',
|
||||
} as Record<string, unknown> | null,
|
||||
error: null as { message: string } | null,
|
||||
},
|
||||
companyIds: [] as string[],
|
||||
hasCapability: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: vi.fn(),
|
||||
createServiceClient: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/api-keys', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/auth/api-keys')>()
|
||||
return {
|
||||
...actual,
|
||||
extractBearerToken: vi.fn().mockReturnValue('test-token'),
|
||||
validateApiKey: vi.fn().mockResolvedValue({
|
||||
userId: 'user-1',
|
||||
companyId: '11111111-1111-4111-8111-111111111111',
|
||||
scopes: ['companies:read', 'invoices:write', 'reports:read'],
|
||||
apiKeyId: 'key-1',
|
||||
apiKeyName: 'Test Key',
|
||||
}),
|
||||
createServiceClientNoCookies: vi.fn(() => ({
|
||||
from: vi.fn((table: string) => {
|
||||
if (table !== 'company_members') throw new Error(`Unexpected table: ${table}`)
|
||||
const chain: Record<string, ReturnType<typeof vi.fn>> = {
|
||||
select: vi.fn(() => chain),
|
||||
eq: vi.fn((column: string, value: string) => {
|
||||
if (column === 'company_id') mocks.companyIds.push(value)
|
||||
return chain
|
||||
}),
|
||||
is: vi.fn(() => chain),
|
||||
maybeSingle: vi.fn(async () => mocks.membership),
|
||||
}
|
||||
return chain
|
||||
}),
|
||||
})),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/lib/entitlements/has-capability', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/entitlements/has-capability')>()
|
||||
return { ...actual, hasCapability: mocks.hasCapability }
|
||||
})
|
||||
|
||||
import { handleMcpRequest } from '../server'
|
||||
|
||||
function toolCall(args: Record<string, unknown>): Request {
|
||||
return new Request('http://localhost:3000/api/extensions/ext/mcp-server/mcp', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer test-token' },
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'tools/call',
|
||||
params: { name: 'gnubok_send_invoice', arguments: args },
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
async function parseToolResult(response: Response) {
|
||||
const json = await response.json()
|
||||
const result = json.result as { isError?: boolean; content: Array<{ text: string }> }
|
||||
return {
|
||||
isError: result.isError === true,
|
||||
payload: JSON.parse(result.content[0].text) as Record<string, unknown>,
|
||||
}
|
||||
}
|
||||
|
||||
describe('MCP multi-company dispatch', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
eventBus.clear()
|
||||
mocks.companyIds.length = 0
|
||||
mocks.membership = {
|
||||
data: { company_id: OTHER_COMPANY_ID, role: 'owner' },
|
||||
error: null,
|
||||
}
|
||||
mocks.hasCapability.mockResolvedValue(false)
|
||||
})
|
||||
|
||||
it('routes a tool call to an accessible requested company', async () => {
|
||||
const result = await parseToolResult(
|
||||
await handleMcpRequest(
|
||||
toolCall({ invoice_id: 'invoice-1', company_id: OTHER_COMPANY_ID })
|
||||
)
|
||||
)
|
||||
|
||||
expect(result.isError).toBe(true)
|
||||
expect((result.payload.error as Record<string, unknown>).capability_blocked).toBe(true)
|
||||
expect(mocks.companyIds).toEqual([OTHER_COMPANY_ID])
|
||||
expect(mocks.hasCapability).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
OTHER_COMPANY_ID,
|
||||
'email_send'
|
||||
)
|
||||
})
|
||||
|
||||
it('revalidates and uses the API key default company when company_id is omitted', async () => {
|
||||
mocks.membership.data = { company_id: DEFAULT_COMPANY_ID, role: 'admin' }
|
||||
|
||||
await handleMcpRequest(toolCall({ invoice_id: 'invoice-1' }))
|
||||
|
||||
expect(mocks.companyIds).toEqual([DEFAULT_COMPANY_ID])
|
||||
expect(mocks.hasCapability).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
DEFAULT_COMPANY_ID,
|
||||
'email_send'
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects a company the user does not belong to before capability or execution', async () => {
|
||||
mocks.membership.data = null
|
||||
|
||||
const result = await parseToolResult(
|
||||
await handleMcpRequest(
|
||||
toolCall({ invoice_id: 'invoice-1', company_id: OTHER_COMPANY_ID })
|
||||
)
|
||||
)
|
||||
|
||||
expect(result.isError).toBe(true)
|
||||
expect((result.payload.error as Record<string, unknown>).code).toBe('NOT_FOUND')
|
||||
expect(mocks.hasCapability).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects writes for a viewer in the selected company', async () => {
|
||||
mocks.membership.data = { company_id: OTHER_COMPANY_ID, role: 'viewer' }
|
||||
|
||||
const result = await parseToolResult(
|
||||
await handleMcpRequest(
|
||||
toolCall({ invoice_id: 'invoice-1', company_id: OTHER_COMPANY_ID })
|
||||
)
|
||||
)
|
||||
|
||||
expect(result.isError).toBe(true)
|
||||
expect((result.payload.error as Record<string, unknown>).code).toBe('FORBIDDEN')
|
||||
expect(mocks.hasCapability).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects malformed company_id before querying membership', async () => {
|
||||
const result = await parseToolResult(
|
||||
await handleMcpRequest(
|
||||
toolCall({ invoice_id: 'invoice-1', company_id: 'not-a-uuid' })
|
||||
)
|
||||
)
|
||||
|
||||
expect(result.isError).toBe(true)
|
||||
expect((result.payload.error as Record<string, unknown>).code).toBe('VALIDATION_ERROR')
|
||||
expect(mocks.companyIds).toEqual([])
|
||||
expect(mocks.hasCapability).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { tools, deriveToolMeta } from '../server'
|
||||
import { projectToolInputSchema } from '../company-routing'
|
||||
|
||||
describe('tools/list payload size guard', () => {
|
||||
it('keeps the projected tools/list payload under the context-budget ceiling', () => {
|
||||
@@ -12,7 +13,7 @@ describe('tools/list payload size guard', () => {
|
||||
name: t.name,
|
||||
...(t.title ? { title: t.title } : {}),
|
||||
description: t.description,
|
||||
inputSchema: t.inputSchema,
|
||||
inputSchema: projectToolInputSchema(t),
|
||||
...(t.outputSchema ? { outputSchema: t.outputSchema } : {}),
|
||||
annotations: t.annotations,
|
||||
...(Object.keys(meta).length > 0 ? { _meta: meta } : {}),
|
||||
@@ -20,7 +21,7 @@ describe('tools/list payload size guard', () => {
|
||||
})
|
||||
const payload = JSON.stringify({ tools: projection })
|
||||
const approxTokens = Math.round(payload.length / 4)
|
||||
// Ceiling progression: 20K → 25K → 30K → 31K → 31.5K → 32K → 36K.
|
||||
// Ceiling progression: 20K to 25K to 30K to 31K to 31.5K to 32K to 36K.
|
||||
// * 20K → 25K when item 8 of the agent-native API plan landed
|
||||
// (additionalProperties: false on all inputSchemas + period_status in the
|
||||
// staged operation envelope).
|
||||
@@ -110,9 +111,14 @@ describe('tools/list payload size guard', () => {
|
||||
// gnubok_get_vacation_balance (ledger read) + gnubok_close_vacation_year
|
||||
// (staged HIGH semesterårsavslut with STAGED_OPERATION_SCHEMA + _meta).
|
||||
// Fortnox gap category E closed; both schemas already minimal.
|
||||
// * 51K to 54K for stateless multi-company MCP routing. Every
|
||||
// company-dependent tool must expose the optional company_id input so
|
||||
// the client can target another authorized company without shared
|
||||
// mutable connection state. The repeated property is intentionally
|
||||
// minimal; gnubok_list_companies and initialize instructions explain it.
|
||||
// Long-term answer to growth is leaning harder on gnubok_search_tools: if this
|
||||
// fires again, prefer trimming descriptions or making a tool opt-in via search
|
||||
// before bumping further.
|
||||
expect(approxTokens).toBeLessThan(51_000)
|
||||
expect(approxTokens).toBeLessThan(54_000)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -24,6 +24,7 @@ vi.mock('@/lib/auth/api-keys', async (importOriginal) => {
|
||||
extractBearerToken: vi.fn().mockReturnValue('test-token'),
|
||||
validateApiKey: vi.fn().mockResolvedValue({
|
||||
userId: 'user-1',
|
||||
companyId: '11111111-1111-4111-8111-111111111111',
|
||||
scopes: ['transactions:read', 'transactions:write', 'customers:read', 'customers:write', 'invoices:read', 'invoices:write', 'suppliers:read', 'reports:read'],
|
||||
}),
|
||||
createServiceClientNoCookies: vi.fn(),
|
||||
@@ -179,7 +180,30 @@ describe('MCP Receipt Matcher', () => {
|
||||
const mock = createQueuedMockSupabase()
|
||||
supabase = mock.supabase
|
||||
enqueueMany = mock.enqueueMany
|
||||
vi.mocked(createServiceClientNoCookies).mockReturnValue(supabase as never)
|
||||
const membershipChain: unknown = new Proxy(
|
||||
{},
|
||||
{
|
||||
get(_t, prop) {
|
||||
if (prop === 'then') {
|
||||
return (resolve: (v: unknown) => void) =>
|
||||
resolve({
|
||||
data: {
|
||||
company_id: '11111111-1111-4111-8111-111111111111',
|
||||
role: 'owner',
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
}
|
||||
return () => membershipChain
|
||||
},
|
||||
}
|
||||
)
|
||||
vi.mocked(createServiceClientNoCookies).mockReturnValue({
|
||||
...supabase,
|
||||
from: vi.fn((table: string) =>
|
||||
table === 'company_members' ? membershipChain : supabase.from(table)
|
||||
),
|
||||
} as never)
|
||||
})
|
||||
|
||||
// ── Protocol: initialize includes resources capability ──
|
||||
|
||||
@@ -55,6 +55,19 @@ describe('gnubok_search_tools', () => {
|
||||
const tool = result.tools[0]
|
||||
expect(tool).toHaveProperty('inputSchema')
|
||||
expect(tool).toHaveProperty('outputSchema')
|
||||
expect(
|
||||
(tool.inputSchema as { properties: Record<string, unknown> }).properties
|
||||
).toHaveProperty('company_id')
|
||||
})
|
||||
|
||||
it('does not add company_id to company-independent discovery tools', async () => {
|
||||
const result = await call({ detail: 'full', query: 'search tools', limit: 5 })
|
||||
const tool = result.tools.find((candidate) => candidate.name === 'gnubok_search_tools')
|
||||
|
||||
expect(tool).toBeDefined()
|
||||
expect(
|
||||
(tool!.inputSchema as { properties: Record<string, unknown> }).properties
|
||||
).not.toHaveProperty('company_id')
|
||||
})
|
||||
|
||||
it('filters by query keyword', async () => {
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { tools } from '../server'
|
||||
import { TOOL_SCOPE_MAP } from '@/lib/auth/api-keys'
|
||||
import { isTenantWriteScope } from '../company-routing'
|
||||
|
||||
describe('MCP tool inputSchema strictness', () => {
|
||||
it('every tool inputSchema has additionalProperties: false at the top level', () => {
|
||||
@@ -22,4 +24,21 @@ describe('MCP tool inputSchema strictness', () => {
|
||||
.map((t) => t.name)
|
||||
expect(missing).toEqual([])
|
||||
})
|
||||
|
||||
it('every tenant write tool has a scope that the central role guard can classify', () => {
|
||||
const allowedNonTenantWrites = new Set([
|
||||
'gnubok_audit_package',
|
||||
'gnubok_feedback',
|
||||
])
|
||||
const missing = tools
|
||||
.filter(
|
||||
(tool) =>
|
||||
tool.annotations.readOnlyHint !== true &&
|
||||
!isTenantWriteScope(TOOL_SCOPE_MAP[tool.name]) &&
|
||||
!allowedNonTenantWrites.has(tool.name)
|
||||
)
|
||||
.map((tool) => tool.name)
|
||||
|
||||
expect(missing).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -22,7 +22,7 @@ vi.mock('@/lib/auth/api-keys', async (importOriginal) => {
|
||||
extractBearerToken: vi.fn().mockReturnValue('test-token'),
|
||||
validateApiKey: vi.fn().mockResolvedValue({
|
||||
userId: 'user-1',
|
||||
companyId: 'company-1',
|
||||
companyId: '11111111-1111-4111-8111-111111111111',
|
||||
// Only reports:read: enough to call gnubok_get_trial_balance, NOT enough
|
||||
// to call gnubok_create_invoice (invoices:write). Drives the scope-denied test.
|
||||
scopes: ['reports:read'],
|
||||
@@ -35,6 +35,24 @@ vi.mock('@/lib/auth/api-keys', async (importOriginal) => {
|
||||
// filter has data to work against.
|
||||
createServiceClientNoCookies: vi.fn(() => ({
|
||||
from: vi.fn((table: string) => {
|
||||
if (table === 'company_members') {
|
||||
return {
|
||||
select: vi.fn(() => {
|
||||
const chain: Record<string, ReturnType<typeof vi.fn>> = {
|
||||
eq: vi.fn(() => chain),
|
||||
is: vi.fn(() => chain),
|
||||
maybeSingle: vi.fn().mockResolvedValue({
|
||||
data: {
|
||||
company_id: '11111111-1111-4111-8111-111111111111',
|
||||
role: 'owner',
|
||||
},
|
||||
error: null,
|
||||
}),
|
||||
}
|
||||
return chain
|
||||
}),
|
||||
}
|
||||
}
|
||||
if (table === 'company_settings') {
|
||||
return {
|
||||
select: vi.fn(() => ({
|
||||
@@ -191,7 +209,7 @@ describe('mcp.tool_called telemetry', () => {
|
||||
expect(event.actorId).toBe('key-1')
|
||||
expect(event.actorLabel).toBe('Test Key')
|
||||
expect(event.userId).toBe('user-1')
|
||||
expect(event.companyId).toBe('company-1')
|
||||
expect(event.companyId).toBe('11111111-1111-4111-8111-111111111111')
|
||||
expect(event.requestId).toBe(1)
|
||||
// Real wall-clock: non-negative number
|
||||
expect(typeof event.latencyMs).toBe('number')
|
||||
@@ -393,7 +411,7 @@ describe('mcp.tools_list_called telemetry', () => {
|
||||
expect(event.toolCount).toBeLessThan(100)
|
||||
expect(event.actorType).toBe('api_key')
|
||||
expect(event.userId).toBe('user-1')
|
||||
expect(event.companyId).toBe('company-1')
|
||||
expect(event.companyId).toBe('11111111-1111-4111-8111-111111111111')
|
||||
expect(typeof event.latencyMs).toBe('number')
|
||||
expect(event.latencyMs).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
@@ -496,7 +514,7 @@ describe('mcp.skill_loaded telemetry', () => {
|
||||
expect(event.actorType).toBe('api_key')
|
||||
expect(event.actorId).toBe('key-1')
|
||||
expect(event.userId).toBe('user-1')
|
||||
expect(event.companyId).toBe('company-1')
|
||||
expect(event.companyId).toBe('11111111-1111-4111-8111-111111111111')
|
||||
|
||||
// The pre-existing workflow-funnel event still fires for workflow tier.
|
||||
const wf = await workflowStartedPromise
|
||||
|
||||
@@ -29,6 +29,24 @@ vi.mock('@/lib/auth/api-keys', async (importOriginal) => {
|
||||
},
|
||||
},
|
||||
)
|
||||
const membershipChain: unknown = new Proxy(
|
||||
{},
|
||||
{
|
||||
get(_t, prop) {
|
||||
if (prop === 'then') {
|
||||
return (resolve: (v: unknown) => void) =>
|
||||
resolve({
|
||||
data: {
|
||||
company_id: '11111111-1111-4111-8111-111111111111',
|
||||
role: 'owner',
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
}
|
||||
return () => membershipChain
|
||||
},
|
||||
}
|
||||
)
|
||||
return {
|
||||
...actual,
|
||||
extractBearerToken: vi.fn().mockReturnValue('test-token'),
|
||||
@@ -42,7 +60,10 @@ vi.mock('@/lib/auth/api-keys', async (importOriginal) => {
|
||||
apiKeyName: 'Test Key',
|
||||
mode: 'test',
|
||||
}),
|
||||
createServiceClientNoCookies: vi.fn(() => ({ from: () => chain, rpc: () => chain })),
|
||||
createServiceClientNoCookies: vi.fn(() => ({
|
||||
from: (table: string) => (table === 'company_members' ? membershipChain : chain),
|
||||
rpc: () => chain,
|
||||
})),
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ vi.mock('@/lib/auth/api-keys', async (importOriginal) => {
|
||||
extractBearerToken: vi.fn().mockReturnValue('test-token'),
|
||||
validateApiKey: vi.fn().mockResolvedValue({
|
||||
userId: 'user-1',
|
||||
companyId: 'company-1',
|
||||
companyId: '11111111-1111-4111-8111-111111111111',
|
||||
scopes: ['reports:read'],
|
||||
}),
|
||||
// Fully-chainable, awaitable proxy resolving to empty data: satisfies both
|
||||
@@ -38,7 +38,27 @@ vi.mock('@/lib/auth/api-keys', async (importOriginal) => {
|
||||
},
|
||||
},
|
||||
)
|
||||
return { from: () => makeChain() }
|
||||
const membershipChain: unknown = new Proxy(
|
||||
{},
|
||||
{
|
||||
get(_t, prop) {
|
||||
if (prop === 'then') {
|
||||
return (resolve: (v: unknown) => void) =>
|
||||
resolve({
|
||||
data: {
|
||||
company_id: '11111111-1111-4111-8111-111111111111',
|
||||
role: 'owner',
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
}
|
||||
return () => membershipChain
|
||||
},
|
||||
}
|
||||
)
|
||||
return {
|
||||
from: (table: string) => (table === 'company_members' ? membershipChain : makeChain()),
|
||||
}
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import type { ApiKeyScope } from '@/lib/auth/api-keys'
|
||||
import type { CompanyRole } from '@/types'
|
||||
|
||||
const UUID_PATTERN =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
||||
|
||||
const COMPANY_INDEPENDENT_TOOLS = new Set([
|
||||
'gnubok_search_tools',
|
||||
'gnubok_load_skill',
|
||||
'gnubok_list_companies',
|
||||
])
|
||||
|
||||
export const COMPANY_ID_INPUT_PROPERTY = {
|
||||
type: 'string',
|
||||
format: 'uuid',
|
||||
description: 'Target company ID. Omit for default.',
|
||||
} as const
|
||||
|
||||
export interface McpCompanyContext {
|
||||
companyId: string
|
||||
role: CompanyRole
|
||||
isDefault: boolean
|
||||
}
|
||||
|
||||
interface ToolSchemaSource {
|
||||
name: string
|
||||
inputSchema: Record<string, unknown>
|
||||
}
|
||||
|
||||
function codedError(
|
||||
code: 'VALIDATION_ERROR' | 'NOT_FOUND' | 'FORBIDDEN' | 'INTERNAL_ERROR',
|
||||
message: string
|
||||
) {
|
||||
return Object.assign(new Error(message), { code })
|
||||
}
|
||||
|
||||
function isCompanyRole(value: unknown): value is CompanyRole {
|
||||
return value === 'owner' || value === 'admin' || value === 'member' || value === 'viewer'
|
||||
}
|
||||
|
||||
export function isCompanyDependentTool(toolName: string): boolean {
|
||||
return !COMPANY_INDEPENDENT_TOOLS.has(toolName)
|
||||
}
|
||||
|
||||
export function isTenantWriteScope(scope: ApiKeyScope | undefined): boolean {
|
||||
return (
|
||||
scope?.endsWith(':write') === true ||
|
||||
scope?.endsWith(':approve') === true ||
|
||||
scope?.endsWith(':manage') === true
|
||||
)
|
||||
}
|
||||
|
||||
export function projectToolInputSchema(tool: ToolSchemaSource): Record<string, unknown> {
|
||||
if (!isCompanyDependentTool(tool.name)) return tool.inputSchema
|
||||
|
||||
const properties =
|
||||
tool.inputSchema.properties && typeof tool.inputSchema.properties === 'object'
|
||||
? (tool.inputSchema.properties as Record<string, unknown>)
|
||||
: {}
|
||||
|
||||
return {
|
||||
...tool.inputSchema,
|
||||
properties: {
|
||||
...properties,
|
||||
company_id: COMPANY_ID_INPUT_PROPERTY,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function extractRequestedCompany(
|
||||
rawArgs: Record<string, unknown>
|
||||
): { requestedCompanyId: string | undefined; toolArgs: Record<string, unknown> } {
|
||||
const { company_id: rawCompanyId, ...toolArgs } = rawArgs
|
||||
if (rawCompanyId === undefined) return { requestedCompanyId: undefined, toolArgs }
|
||||
if (typeof rawCompanyId !== 'string' || !UUID_PATTERN.test(rawCompanyId)) {
|
||||
throw codedError('VALIDATION_ERROR', 'company_id must be a valid UUID')
|
||||
}
|
||||
return { requestedCompanyId: rawCompanyId, toolArgs }
|
||||
}
|
||||
|
||||
export async function resolveMcpCompanyContext(args: {
|
||||
supabase: SupabaseClient
|
||||
userId: string
|
||||
defaultCompanyId: string
|
||||
requestedCompanyId?: string
|
||||
}): Promise<McpCompanyContext> {
|
||||
const companyId = args.requestedCompanyId ?? args.defaultCompanyId
|
||||
|
||||
const { data: membership, error } = await args.supabase
|
||||
.from('company_members')
|
||||
.select('company_id, role, companies!inner(archived_at)')
|
||||
.eq('user_id', args.userId)
|
||||
.eq('company_id', companyId)
|
||||
.is('companies.archived_at', null)
|
||||
.maybeSingle()
|
||||
|
||||
if (error) {
|
||||
throw codedError('INTERNAL_ERROR', `Failed to resolve company membership: ${error.message}`)
|
||||
}
|
||||
if (!membership) {
|
||||
throw codedError('NOT_FOUND', 'Company not found')
|
||||
}
|
||||
if (!isCompanyRole(membership.role)) {
|
||||
throw codedError('FORBIDDEN', 'Company membership has an unsupported role')
|
||||
}
|
||||
|
||||
return {
|
||||
companyId,
|
||||
role: membership.role,
|
||||
isDefault: companyId === args.defaultCompanyId,
|
||||
}
|
||||
}
|
||||
|
||||
export function assertMcpCompanyWriteAccess(
|
||||
context: McpCompanyContext,
|
||||
scope: ApiKeyScope | undefined
|
||||
): void {
|
||||
if (context.role === 'viewer' && isTenantWriteScope(scope)) {
|
||||
throw codedError('FORBIDDEN', 'Write permission required for this company')
|
||||
}
|
||||
}
|
||||
|
||||
export function addCompanyToNextHint(next: unknown, companyId: string): unknown {
|
||||
if (!next || typeof next !== 'object' || Array.isArray(next)) return next
|
||||
const hint = next as Record<string, unknown>
|
||||
if (typeof hint.tool !== 'string' || !isCompanyDependentTool(hint.tool)) return next
|
||||
const args =
|
||||
hint.args && typeof hint.args === 'object' && !Array.isArray(hint.args)
|
||||
? (hint.args as Record<string, unknown>)
|
||||
: {}
|
||||
return {
|
||||
...hint,
|
||||
args: { ...args, company_id: companyId },
|
||||
}
|
||||
}
|
||||
|
||||
export function addCompanyToTopLevelNext(result: unknown, companyId: string): unknown {
|
||||
if (!result || typeof result !== 'object' || Array.isArray(result)) return result
|
||||
const record = result as Record<string, unknown>
|
||||
if (!record.next) return result
|
||||
return {
|
||||
...record,
|
||||
next: addCompanyToNextHint(record.next, companyId),
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import type { McpResource } from './types'
|
||||
export const companyCurrentResource: McpResource = {
|
||||
uri: 'Accounted://company/current',
|
||||
name: 'Active Company',
|
||||
description: 'Per-company working memory: identity, active fiscal period, lock dates, entity counts, voucher series state, recent activity, approaching Swedish filing deadlines. Read this first when starting work on a company.',
|
||||
description: 'Working memory for the API key default company: identity, active fiscal period, lock dates, entity counts, voucher series state, recent activity, and filing deadlines. For another company, call gnubok_get_agent_briefing with company_id.',
|
||||
mimeType: 'application/json',
|
||||
read: async ({ supabase, companyId }) => {
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
|
||||
@@ -58,6 +58,15 @@ import {
|
||||
IdempotencyKeyReuseError,
|
||||
} from '@/lib/api/idempotency'
|
||||
import { toToolError, type NextActionHint } from './tool-result'
|
||||
import {
|
||||
addCompanyToNextHint,
|
||||
addCompanyToTopLevelNext,
|
||||
assertMcpCompanyWriteAccess,
|
||||
extractRequestedCompany,
|
||||
isCompanyDependentTool,
|
||||
projectToolInputSchema,
|
||||
resolveMcpCompanyContext,
|
||||
} from './company-routing'
|
||||
import { findSupplierCandidates } from './supplier-candidates'
|
||||
import { assertNoPlaintextPersonnummer } from './staging-pii-guard'
|
||||
import { generateBalanceSheet } from '@/lib/reports/balance-sheet'
|
||||
@@ -112,6 +121,7 @@ import { formatRedovisningsperiod } from '@/lib/skatteverket/format'
|
||||
import { createExtensionContext } from '@/lib/extensions/context-factory'
|
||||
import { commitPendingOperation } from '@/lib/pending-operations/commit'
|
||||
import { appendProcessingHistory } from '@/lib/processing-history/append'
|
||||
import { getUserCompanies } from '@/lib/company/context'
|
||||
// ensureInitialized() is called by the extension router (ext/[...path]/route.ts)
|
||||
// which dispatches to this handler: no duplicate call needed here.
|
||||
import type { Transaction, TransactionCategory, EntityType, VatTreatment, Invoice, Currency, CompanySettings, Customer, InvoiceItem, PendingOperation, VatPeriodType } from '@/types'
|
||||
@@ -333,7 +343,7 @@ async function stagePendingOperation(
|
||||
message: `Dry run: would stage "${operationType}" (risk: ${riskLevel}). No changes made.`,
|
||||
preview: previewData,
|
||||
...(periodStatus ? { period_status: periodStatus } : {}),
|
||||
...(next ? { next } : {}),
|
||||
...(next ? { next: addCompanyToNextHint(next, companyId) as NextActionHint } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -358,7 +368,12 @@ async function stagePendingOperation(
|
||||
? `Replayed cached response for idempotency_key "${options.idempotencyKey}": already staged as pending_operation ${cachedOpId}. No new side-effects. ${buildApprovalGuidance(cachedOpId, riskLevel)}`
|
||||
: `Replayed cached response for idempotency_key "${options.idempotencyKey}". No new side-effects.`,
|
||||
...(cachedOpId
|
||||
? { approve: { tool: 'gnubok_approve_pending_operation', args: { operation_id: cachedOpId } } }
|
||||
? {
|
||||
approve: {
|
||||
tool: 'gnubok_approve_pending_operation',
|
||||
args: { operation_id: cachedOpId, company_id: companyId },
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
preview: periodStatus ? { ...previewData, period_status: periodStatus } : previewData,
|
||||
...(periodStatus ? { period_status: periodStatus } : {}),
|
||||
@@ -399,11 +414,11 @@ async function stagePendingOperation(
|
||||
message: `Staged as pending_operation ${data.id} (risk: ${riskLevel}). ${buildApprovalGuidance(data.id, riskLevel)} The user can also approve at /pending in the ${branding} web app.`,
|
||||
approve: {
|
||||
tool: 'gnubok_approve_pending_operation',
|
||||
args: { operation_id: data.id } as Record<string, unknown>,
|
||||
args: { operation_id: data.id, company_id: companyId } as Record<string, unknown>,
|
||||
},
|
||||
preview: periodStatus ? { ...previewData, period_status: periodStatus } : previewData,
|
||||
...(periodStatus ? { period_status: periodStatus } : {}),
|
||||
...(next ? { next } : {}),
|
||||
...(next ? { next: addCompanyToNextHint(next, companyId) as NextActionHint } : {}),
|
||||
} as const
|
||||
|
||||
if (options.idempotencyKey && requestHash) {
|
||||
@@ -1760,7 +1775,7 @@ export const tools: McpTool[] = [
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
scope: requiredScope,
|
||||
inputSchema: t.inputSchema,
|
||||
inputSchema: projectToolInputSchema(t),
|
||||
...(t.outputSchema ? { outputSchema: t.outputSchema } : {}),
|
||||
annotations: t.annotations,
|
||||
...(Object.keys(meta).length > 0 ? { _meta: meta } : {}),
|
||||
@@ -1779,6 +1794,109 @@ export const tools: McpTool[] = [
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'gnubok_list_companies',
|
||||
title: 'List Companies',
|
||||
description: 'List every non-archived company this API-key user can access. Use company_id from this result on other tools; omit it there to use the API key default.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {},
|
||||
},
|
||||
outputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
companies: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
company_id: { type: 'string' },
|
||||
name: { type: 'string' },
|
||||
org_number: { type: ['string', 'null'] },
|
||||
entity_type: { type: ['string', 'null'] },
|
||||
role: { type: 'string', enum: ['owner', 'admin', 'member', 'viewer'] },
|
||||
is_default: { type: 'boolean' },
|
||||
},
|
||||
required: ['company_id', 'name', 'org_number', 'entity_type', 'role', 'is_default'],
|
||||
},
|
||||
},
|
||||
count: { type: 'number' },
|
||||
default_company_id: { type: ['string', 'null'] },
|
||||
},
|
||||
required: ['companies', 'count', 'default_company_id'],
|
||||
},
|
||||
annotations: {
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
},
|
||||
async execute(_args, defaultCompanyId, userId, supabase) {
|
||||
type CompanyRow = {
|
||||
id: string
|
||||
name: string
|
||||
org_number: string | null
|
||||
entity_type: string | null
|
||||
archived_at: string | null
|
||||
}
|
||||
type MembershipRow = {
|
||||
company_id: string
|
||||
role: 'owner' | 'admin' | 'member' | 'viewer'
|
||||
companies: CompanyRow | CompanyRow[] | null
|
||||
}
|
||||
|
||||
const memberships = (await getUserCompanies(supabase, userId)) as unknown as MembershipRow[]
|
||||
const accessible = memberships.flatMap((membership) => {
|
||||
const company = Array.isArray(membership.companies)
|
||||
? membership.companies[0]
|
||||
: membership.companies
|
||||
return company && company.archived_at === null ? [{ membership, company }] : []
|
||||
})
|
||||
const companyIds = accessible.map(({ company }) => company.id)
|
||||
const displayNames = new Map<string, string>()
|
||||
|
||||
if (companyIds.length > 0) {
|
||||
try {
|
||||
const settings = await fetchAllRows<{ company_id: string; company_name: string | null }>(
|
||||
({ from, to }) =>
|
||||
supabase
|
||||
.from('company_settings')
|
||||
.select('company_id, company_name')
|
||||
.in('company_id', companyIds)
|
||||
.order('company_id', { ascending: true })
|
||||
.range(from, to),
|
||||
)
|
||||
for (const row of settings) {
|
||||
if (row.company_name) displayNames.set(row.company_id, row.company_name)
|
||||
}
|
||||
} catch (error) {
|
||||
log.warn('gnubok_list_companies display-name lookup failed', {
|
||||
error: error instanceof Error ? error.message : 'unknown',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const companies = accessible.map(({ membership, company }) => ({
|
||||
company_id: company.id,
|
||||
name: displayNames.get(company.id) ?? company.name,
|
||||
org_number: company.org_number,
|
||||
entity_type: company.entity_type,
|
||||
role: membership.role,
|
||||
is_default: company.id === defaultCompanyId,
|
||||
}))
|
||||
const hasAccessibleDefault = companies.some((company) => company.is_default)
|
||||
|
||||
return {
|
||||
companies,
|
||||
count: companies.length,
|
||||
default_company_id: hasAccessibleDefault ? defaultCompanyId : null,
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'gnubok_list_skills',
|
||||
title: 'List Domain Skills',
|
||||
@@ -2245,10 +2363,10 @@ export const tools: McpTool[] = [
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
description:
|
||||
'The single company every tool call in this session reads and writes. Confirm this is the entity the user means BEFORE any staged write: there is no per-call company switch; scope is fixed by the API key.',
|
||||
'The company selected for this call. Confirm it is the entity the user means before staging a write. Pass company_id on later calls to keep working in a non-default company.',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Deprecated: read company_id instead.' },
|
||||
company_id: { type: 'string', description: 'company_id this session is scoped to.' },
|
||||
company_id: { type: 'string', description: 'company_id selected for this call.' },
|
||||
name: { type: ['string', 'null'] },
|
||||
org_number: { type: ['string', 'null'] },
|
||||
entity_type: { type: ['string', 'null'], description: 'e.g. "aktiebolag", "enskild_firma". Null if unset.' },
|
||||
@@ -2493,10 +2611,10 @@ export const tools: McpTool[] = [
|
||||
.select('full_name')
|
||||
.eq('id', userId)
|
||||
.maybeSingle(),
|
||||
// Company identity so the agent can confirm WHICH entity it operates on
|
||||
// before any write. Scope is fixed by the API key: there is no per-call
|
||||
// switch: so this is the session's "whoami for the company". Best-effort:
|
||||
// a failed read still yields a company block with at least the id.
|
||||
// Company identity so the agent can confirm which entity it operates on
|
||||
// before any write. The dispatcher has already resolved and authorized
|
||||
// the optional per-call company_id. A failed read still yields a company
|
||||
// block with at least the id.
|
||||
supabase
|
||||
.from('companies')
|
||||
.select('name, org_number, entity_type')
|
||||
@@ -12006,20 +12124,6 @@ export const tools: McpTool[] = [
|
||||
if (!fiscalPeriodId) throw new Error('fiscal_period_id is required')
|
||||
const assetIds = Array.isArray(args.asset_ids) ? (args.asset_ids as string[]) : undefined
|
||||
|
||||
// Mirror the HTTP route's `requireWrite: true` guard so a viewer-role
|
||||
// member can't post depreciation through the MCP surface. RLS would
|
||||
// reject the underlying INSERTs anyway, but failing fast here
|
||||
// produces a much cleaner error than the cascaded RLS rejection.
|
||||
const { data: membership } = await supabase
|
||||
.from('company_members')
|
||||
.select('role')
|
||||
.eq('company_id', companyId)
|
||||
.eq('user_id', userId)
|
||||
.maybeSingle()
|
||||
if (!membership || membership.role === 'viewer') {
|
||||
throw new Error('Write permission required')
|
||||
}
|
||||
|
||||
const { data: period } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('id, name, period_end, is_closed, locked_at, closing_entry_id')
|
||||
@@ -12629,7 +12733,7 @@ function emitToolCallTelemetry(payload: {
|
||||
success: boolean
|
||||
isError: boolean
|
||||
errorCode: string | null
|
||||
errorKind: 'execution' | 'scope_denied' | 'capability_denied' | 'unknown_tool' | 'test_key_write_blocked' | null
|
||||
errorKind: 'execution' | 'scope_denied' | 'capability_denied' | 'company_access_denied' | 'unknown_tool' | 'test_key_write_blocked' | null
|
||||
errorMessage: string | null
|
||||
requestId: string | number | null
|
||||
userId: string
|
||||
@@ -12957,6 +13061,8 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
|
||||
'',
|
||||
'Discovery:',
|
||||
'• tools/list returns the full schema for every tool. To narrow a large catalog, call gnubok_search_tools(query="…"): it ranks tools by relevance; pass detail="name"|"summary"|"full" to control payload size.',
|
||||
`• This connection can work with every non-archived company the API-key user belongs to. Call gnubok_list_companies to discover company_id values. Omit company_id to use the API key default (${companyId}); when selecting another company, repeat company_id on every company-data call, including approval.`,
|
||||
'• MCP resources use the API key default company. For a selected non-default company, call gnubok_get_agent_briefing with company_id instead of relying on Accounted://company/current or other company-data resources.',
|
||||
'• When the user asks "how do I do X" or you\'re unsure of the correct sequence (month-end close, VAT review, year-end, invoicing, payroll), call gnubok_list_skills first: domain workflows are documented as loadable skills with tool references.',
|
||||
'',
|
||||
'Common workflows:',
|
||||
@@ -13014,7 +13120,7 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
|
||||
name: t.name,
|
||||
...(t.title ? { title: t.title } : {}),
|
||||
description: t.description,
|
||||
inputSchema: t.inputSchema,
|
||||
inputSchema: projectToolInputSchema(t),
|
||||
...(t.outputSchema ? { outputSchema: t.outputSchema } : {}),
|
||||
annotations: t.annotations,
|
||||
...(Object.keys(meta).length > 0 ? { _meta: meta } : {}),
|
||||
@@ -13026,7 +13132,7 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
|
||||
|
||||
case 'tools/call': {
|
||||
const toolName = (params as Record<string, unknown>)?.name as string
|
||||
const toolArgs = ((params as Record<string, unknown>)?.arguments ?? {}) as Record<
|
||||
const rawToolArgs = ((params as Record<string, unknown>)?.arguments ?? {}) as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
@@ -13085,12 +13191,55 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
|
||||
)
|
||||
}
|
||||
|
||||
let toolArgs: Record<string, unknown>
|
||||
let effectiveCompanyId = companyId
|
||||
const companyRoutingStartedAt = Date.now()
|
||||
try {
|
||||
const extracted = extractRequestedCompany(rawToolArgs)
|
||||
toolArgs = extracted.toolArgs
|
||||
|
||||
if (isCompanyDependentTool(toolName)) {
|
||||
const companyContext = await resolveMcpCompanyContext({
|
||||
supabase,
|
||||
userId,
|
||||
defaultCompanyId: companyId,
|
||||
requestedCompanyId: extracted.requestedCompanyId,
|
||||
})
|
||||
assertMcpCompanyWriteAccess(companyContext, requiredScope)
|
||||
effectiveCompanyId = companyContext.companyId
|
||||
}
|
||||
} catch (err) {
|
||||
const structured = toToolError(err, { toolName })
|
||||
emitToolCallTelemetry({
|
||||
tool: toolName,
|
||||
requiredScope: requiredScope ?? null,
|
||||
actor,
|
||||
latencyMs: Date.now() - companyRoutingStartedAt,
|
||||
success: false,
|
||||
isError: true,
|
||||
errorCode: structured.error.code,
|
||||
errorKind: 'company_access_denied',
|
||||
errorMessage: structured.error.message_sv,
|
||||
requestId: id ?? null,
|
||||
userId,
|
||||
// Keep denied attempts attributed to the key default. An arbitrary,
|
||||
// unauthorized target must never create tenant telemetry there.
|
||||
companyId,
|
||||
})
|
||||
return NextResponse.json(
|
||||
jsonRpc(id ?? null, {
|
||||
content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
|
||||
isError: true,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// Enforce the capability paywall: the MCP/agent path is a paid chokepoint
|
||||
// just like the HTTP routes (send_invoice → email_send, the two SKV
|
||||
// submissions → skatteverket). Fail-closed; self-hosted short-circuits to
|
||||
// all-on inside hasCapability. Blocks before any pending op is staged.
|
||||
const requiredCapability = MCP_TOOL_CAPABILITY_MAP[toolName]
|
||||
if (requiredCapability && !(await hasCapability(supabase, companyId, requiredCapability))) {
|
||||
if (requiredCapability && !(await hasCapability(supabase, effectiveCompanyId, requiredCapability))) {
|
||||
const capError = { error: capabilityBlockedError(requiredCapability) }
|
||||
emitToolCallTelemetry({
|
||||
tool: toolName,
|
||||
@@ -13104,7 +13253,7 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
|
||||
errorMessage: capError.error.message_sv,
|
||||
requestId: id ?? null,
|
||||
userId,
|
||||
companyId,
|
||||
companyId: effectiveCompanyId,
|
||||
})
|
||||
return NextResponse.json(
|
||||
jsonRpc(id ?? null, {
|
||||
@@ -13144,7 +13293,7 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
|
||||
errorMessage: blocked.error.message_sv,
|
||||
requestId: id ?? null,
|
||||
userId,
|
||||
companyId,
|
||||
companyId: effectiveCompanyId,
|
||||
})
|
||||
return NextResponse.json(
|
||||
jsonRpc(id ?? null, {
|
||||
@@ -13158,7 +13307,7 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
|
||||
// Detect if THIS call follows the previous call's `next` hint: must
|
||||
// run before execute() so we don't double-store on this call. Emits
|
||||
// mcp.next_hint_followed when the agent's behaviour matches the hint.
|
||||
checkAndEmitNextHintFollowed(sessionId, toolName, actor, userId, companyId)
|
||||
checkAndEmitNextHintFollowed(sessionId, toolName, actor, userId, effectiveCompanyId)
|
||||
|
||||
const callStartedAt = Date.now()
|
||||
try {
|
||||
@@ -13167,7 +13316,8 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
|
||||
if (toolName === 'gnubok_search_tools') {
|
||||
(toolArgs as Record<string, unknown>).__keyScopes = keyScopes
|
||||
}
|
||||
const result = await tool.execute(toolArgs, companyId, userId, supabase, actor)
|
||||
const rawResult = await tool.execute(toolArgs, effectiveCompanyId, userId, supabase, actor)
|
||||
const result = addCompanyToTopLevelNext(rawResult, effectiveCompanyId)
|
||||
const latencyMs = Date.now() - callStartedAt
|
||||
const response: Record<string, unknown> = {
|
||||
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
||||
@@ -13208,7 +13358,7 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
|
||||
errorMessage: null,
|
||||
requestId: id ?? null,
|
||||
userId,
|
||||
companyId,
|
||||
companyId: effectiveCompanyId,
|
||||
})
|
||||
return NextResponse.json(jsonRpc(id ?? null, response))
|
||||
} catch (err) {
|
||||
@@ -13229,7 +13379,7 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
|
||||
errorMessage: structured.error.message_sv,
|
||||
requestId: id ?? null,
|
||||
userId,
|
||||
companyId,
|
||||
companyId: effectiveCompanyId,
|
||||
})
|
||||
return NextResponse.json(
|
||||
jsonRpc(id ?? null, {
|
||||
|
||||
Reference in New Issue
Block a user