/** * The MCP half of document sync, built with `new` and plain mocks: no registry, * no MCP server, no container. * * The tool bodies are three lines each, and what is worth pinning about them is * not the plumbing but the decisions around it. The access check is the whole * security story: there is no guard in front of an MCP tool, so a dropped * `verifyTripAccess` is a stranger reading where somebody's documents live. The * absence of a connect tool is a decision too: setting a connection up means * handing over a credential that usually reaches a person's entire document * archive, and a decision nobody asserts is one somebody re-adds by accident. * And the refusal for an unconnected trip is the only thing standing between an * assistant and answering "nothing to sync" to a question about someone's * boarding pass. */ import { ADDON_IDS } from '../../../../src/addons'; import type { McpContext, McpTextResult, ToolOptions } from '../../../../src/nest-mcp'; import { getEntry, type ClassRef } from '../../../../src/nest-mcp/metadata'; import type { AddonsService } from '../../../../src/nest/addons/addons.service'; import type { DocSyncConfigService, LinkRow } from '../../../../src/nest/doc-sync/doc-sync-config.service'; import { DocSyncMcp } from '../../../../src/nest/doc-sync/doc-sync.mcp'; import type { DocSyncService } from '../../../../src/nest/doc-sync/doc-sync.service'; import type { FilesService } from '../../../../src/nest/files/files.service'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { z, type ZodRawShape } from 'zod'; const ctx = { userId: 7, scopes: null, isStaticToken: false } as McpContext; const link = (id: number, providerId = 'paperless', lastSyncState: string | null = null): LinkRow => ({ id, provider_id: providerId, trip_id: 3, last_sync_state: lastSyncState }) as LinkRow; const RUN = { state: 'ok', pulled: 2, pushed: 1, conflicts: 0, missing: 0 }; interface Setup { /** `undefined` is what canAccessTrip answers for a trip the user cannot see. */ access: boolean; links: LinkRow[]; status: Record; issues: Array>; addonOn: boolean; /** Bindings whose provider an admin has switched off. */ off: number[]; } function makeMcp(over: Partial = {}) { const setup: Setup = { access: true, links: [], status: {}, issues: [], addonOn: true, off: [], ...over }; const files = { verifyTripAccess: vi.fn(() => (setup.access ? { id: 3, user_id: 7 } : undefined)) }; const config = { listLinks: vi.fn(() => setup.links), // The real helper also asks whether the owner is still on the trip; here // the mark stands in for both. isOrphaned: vi.fn((l: LinkRow) => l.last_sync_state === 'orphaned'), }; const sync = { status: vi.fn(() => setup.status), issues: vi.fn(() => setup.issues), syncLink: vi.fn(async () => RUN), retryShelvedItems: vi.fn(), isSwitchedOff: vi.fn((l: LinkRow) => setup.off.includes(l.id)), }; const addons = { isAddonEnabled: vi.fn(() => Promise.resolve(setup.addonOn)) }; const mcp = new DocSyncMcp( config as unknown as DocSyncConfigService, sync as unknown as DocSyncService, files as unknown as FilesService, addons as unknown as AddonsService, ); return { mcp, files, config, sync, addons }; } /** A success is one JSON text block and carries no error flag. */ function payload(result: McpTextResult): Record { expect(result.isError).toBeUndefined(); return JSON.parse(result.content[0].text) as Record; } function refusal(result: McpTextResult): string { expect(result.isError).toBe(true); return result.content[0].text; } function toolOptions(method: string): ToolOptions { const entry = getEntry(DocSyncMcp as unknown as ClassRef, method); if (!entry || entry.kind !== 'tool') throw new Error(`${method} is not a registered MCP tool`); return entry.options; } /** * Read off the prototype rather than listed here, so a fourth tool fails the * set assertion below instead of quietly slipping past a table this file keeps. */ function registeredMethods(): string[] { return Object.getOwnPropertyNames(DocSyncMcp.prototype) .filter((name) => getEntry(DocSyncMcp as unknown as ClassRef, name) !== undefined) .sort(); } beforeEach(() => vi.clearAllMocks()); describe('DocSyncMcp surface', () => { it('offers three tools, none of which can create or change a connection', () => { expect(registeredMethods()).toEqual(['getTripDocumentSync', 'listIssues', 'syncNow']); expect( registeredMethods() .map((m) => toolOptions(m).name) .sort(), ).toEqual(['get_trip_document_sync', 'list_trip_document_sync_issues', 'sync_trip_documents']); }); it('asks for nothing but a trip id and a full-run flag', () => { // The other half of the same decision: a credential surface could arrive // bolted onto an existing schema rather than as a tool of its own. const fields = registeredMethods().flatMap((m) => Object.keys(toolOptions(m).inputSchema as ZodRawShape)); expect([...new Set(fields)].sort()).toEqual(['full', 'tripId']); }); it.each(registeredMethods())('%s disappears while the documents addon is off', async (method) => { const { mcp, addons } = makeMcp({ addonOn: false }); const { when } = toolOptions(method); expect(typeof when).toBe('function'); await expect(when?.(ctx, mcp)).resolves.toBe(false); expect(addons.isAddonEnabled).toHaveBeenCalledWith(ADDON_IDS.DOCUMENTS); addons.isAddonEnabled.mockResolvedValue(true); await expect(when?.(ctx, mcp)).resolves.toBe(true); }); it.each([ ['getTripDocumentSync', 'read'], ['listIssues', 'read'], ['syncNow', 'write'], ])('%s claims files:%s, so a read-scoped token cannot move files', (method, mode) => { expect(toolOptions(method).access).toEqual({ group: 'files', mode }); }); it('advertises the run as a write even though it creates nothing itself', () => { // It moves bytes in both directions, and a host that trusts readOnlyHint // would otherwise call it unattended. expect(toolOptions('syncNow').annotations).toMatchObject({ readOnlyHint: false, destructiveHint: false }); expect(toolOptions('getTripDocumentSync').annotations).toMatchObject({ readOnlyHint: true }); expect(toolOptions('listIssues').annotations).toMatchObject({ readOnlyHint: true }); }); it('refuses a trip id that is not a positive integer before a handler sees it', () => { const schema = z.object(toolOptions('getTripDocumentSync').inputSchema as ZodRawShape); expect(schema.safeParse({ tripId: 3 }).success).toBe(true); expect(schema.safeParse({ tripId: 0 }).success).toBe(false); expect(schema.safeParse({ tripId: -1 }).success).toBe(false); expect(schema.safeParse({ tripId: 1.5 }).success).toBe(false); }); }); describe('DocSyncMcp access', () => { it.each([ ['get_trip_document_sync', (m: DocSyncMcp) => m.getTripDocumentSync({ tripId: 3 }, ctx)], ['list_trip_document_sync_issues', (m: DocSyncMcp) => m.listIssues({ tripId: 3 }, ctx)], ['sync_trip_documents', (m: DocSyncMcp) => m.syncNow({ tripId: 3 }, ctx)], ])('%s tells a user who cannot see the trip nothing beyond that', async (_name, call) => { const { mcp, files } = makeMcp({ access: false, links: [link(1)] }); const result = await call(mcp); expect(refusal(result as McpTextResult)).toBe('Trip not found or access denied.'); expect(files.verifyTripAccess).toHaveBeenCalledWith(3, ctx.userId); }); it('does not reach the sync service at all for a trip the user cannot see', async () => { const { mcp, sync, config } = makeMcp({ access: false, links: [link(1)] }); await mcp.syncNow({ tripId: 3 }, ctx); await mcp.listIssues({ tripId: 3 }, ctx); await mcp.getTripDocumentSync({ tripId: 3 }, ctx); expect(sync.syncLink).not.toHaveBeenCalled(); expect(sync.status).not.toHaveBeenCalled(); expect(sync.issues).not.toHaveBeenCalled(); expect(config.listLinks).not.toHaveBeenCalled(); }); }); describe('get_trip_document_sync', () => { it('hands back the trip status as the service reports it', async () => { const status = { links: [{ id: 1, remoteLabel: 'Japan 2026' }], items: { synced: 4, conflict: 1 } }; const { mcp, sync } = makeMcp({ status }); expect(payload(await mcp.getTripDocumentSync({ tripId: 3 }, ctx))).toEqual(status); expect(sync.status).toHaveBeenCalledWith(3); }); }); describe('list_trip_document_sync_issues', () => { it('says the trip is not configured rather than answering with an empty list', async () => { // An empty list and no connection at all read identically to an assistant, // and "everything is in step" is the wrong answer to give about documents // that were never being synced. const { mcp, sync } = makeMcp({ links: [] }); expect(payload(await mcp.listIssues({ tripId: 3 }, ctx))).toEqual({ configured: false, issues: [] }); expect(sync.issues).not.toHaveBeenCalled(); }); it('reports an empty list for a configured trip with nothing to decide', async () => { const { mcp } = makeMcp({ links: [link(1)], issues: [] }); expect(payload(await mcp.listIssues({ tripId: 3 }, ctx))).toEqual({ configured: true, issues: [] }); }); it('passes the open issues through for a configured trip', async () => { const issues = [{ id: 9, state: 'conflict', remote_name: 'boarding.pdf' }]; const { mcp, sync } = makeMcp({ links: [link(1)], issues }); expect(payload(await mcp.listIssues({ tripId: 3 }, ctx))).toEqual({ configured: true, issues }); expect(sync.issues).toHaveBeenCalledWith(3); }); }); describe('sync_trip_documents', () => { it('refuses an unconnected trip with the step that would fix it', async () => { const { mcp, sync } = makeMcp({ links: [] }); const text = refusal(await mcp.syncNow({ tripId: 3 }, ctx)); expect(text).toContain('not connected to a document store'); expect(text).toContain('file manager'); expect(sync.syncLink).not.toHaveBeenCalled(); }); it('runs every binding of the trip and reports each one separately', async () => { // A trip can be bound to more than one store, and collapsing the runs into // one total would hide which of them failed. const links = [link(1, 'paperless'), link(2, 'nextcloud')]; const { mcp, sync } = makeMcp({ links }); const body = payload(await mcp.syncNow({ tripId: 3 }, ctx)); expect(sync.syncLink).toHaveBeenCalledTimes(2); expect(body.runs).toEqual([ { linkId: 1, provider: 'paperless', ...RUN }, { linkId: 2, provider: 'nextcloud', ...RUN }, ]); }); it('un-shelves the documents that gave up before running, as the REST route does', async () => { // "Run it now" means "try again", including the items parked after too many // failures. A tool that skipped this reported "in sync" while leaving them // parked, and the only way out was the button in the web UI. const links = [link(1, 'paperless'), link(2, 'nextcloud')]; const { mcp, sync } = makeMcp({ links }); await mcp.syncNow({ tripId: 3 }, ctx); expect(sync.retryShelvedItems.mock.calls).toEqual([[1], [2]]); }); it('refuses with the same code as the REST route when every binding is switched off', async () => { const { mcp, sync } = makeMcp({ links: [link(1)], off: [1] }); const text = refusal(await mcp.syncNow({ tripId: 3 }, ctx)); expect(text.startsWith('provider_disabled:')).toBe(true); expect(text).toContain('switched back on'); // Nothing un-shelved, nothing run: the binding resumes as it was left. expect(sync.retryShelvedItems).not.toHaveBeenCalled(); expect(sync.syncLink).not.toHaveBeenCalled(); }); it('runs the bindings that are on and reports the switched-off one by its code', async () => { const links = [link(1, 'paperless'), link(2, 'nextcloud')]; const { mcp, sync } = makeMcp({ links, off: [1] }); const body = payload(await mcp.syncNow({ tripId: 3 }, ctx)); expect(body.runs).toEqual([ { linkId: 1, provider: 'paperless', state: 'disabled', errorCode: 'provider_disabled' }, { linkId: 2, provider: 'nextcloud', ...RUN }, ]); expect(sync.retryShelvedItems.mock.calls).toEqual([[2]]); expect(sync.syncLink).toHaveBeenCalledTimes(1); }); it('refuses an orphaned binding as the REST route does, without touching it', async () => { const { mcp, sync } = makeMcp({ links: [link(1, 'paperless', 'orphaned')] }); const text = refusal(await mcp.syncNow({ tripId: 3 }, ctx)); expect(text.startsWith('orphaned:')).toBe(true); expect(text).toContain('connect the store again'); // The credential belongs to somebody who left the trip: no run, and the // shelved rows stay shelved. expect(sync.retryShelvedItems).not.toHaveBeenCalled(); expect(sync.syncLink).not.toHaveBeenCalled(); }); it('runs the bindings that still have an owner and reports the orphaned one by its code', async () => { const links = [link(1, 'paperless', 'orphaned'), link(2, 'nextcloud')]; const { mcp, sync } = makeMcp({ links }); const body = payload(await mcp.syncNow({ tripId: 3 }, ctx)); expect(body.runs).toEqual([ { linkId: 1, provider: 'paperless', state: 'orphaned', errorCode: 'orphaned' }, { linkId: 2, provider: 'nextcloud', ...RUN }, ]); expect(sync.retryShelvedItems.mock.calls).toEqual([[2]]); expect(sync.syncLink).toHaveBeenCalledTimes(1); }); it('asks for the cheap incremental run unless the caller says otherwise', async () => { const { mcp, sync } = makeMcp({ links: [link(1)] }); await mcp.syncNow({ tripId: 3 }, ctx); expect(sync.syncLink).toHaveBeenCalledWith(link(1), { full: false }); await mcp.syncNow({ tripId: 3, full: true }, ctx); expect(sync.syncLink).toHaveBeenLastCalledWith(link(1), { full: true }); }); });