From f7d63c715989d40de7cf006e679b5a7a94ebbd1f Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Wed, 12 Aug 2026 11:18:32 -0700 Subject: [PATCH] fix: MCP file ops use the connected OperationContext engine, not the global DB singleton (#3869) Wave-assembled from PR #3869 by @dpaluy. Co-Authored-By: David Paluy --- src/core/operations.ts | 13 +++-- test/file-upload-engine-context.test.ts | 69 +++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 5 deletions(-) create mode 100644 test/file-upload-engine-context.test.ts diff --git a/src/core/operations.ts b/src/core/operations.ts index d7e3f7cae..2e62f8b2a 100644 --- a/src/core/operations.ts +++ b/src/core/operations.ts @@ -3246,8 +3246,9 @@ const file_list: Operation = { }, scope: 'admin', localOnly: true, - handler: async (_ctx, p) => { - const sql = db.getConnection(); + handler: async (ctx, p) => { + const { sqlQueryForEngine } = await import('./sql-query.ts'); + const sql = sqlQueryForEngine(ctx.engine); const slug = p.slug as string | undefined; const rows = slug ? await sql`SELECT id, page_slug, filename, storage_path, mime_type, size_bytes, content_hash, created_at FROM files WHERE page_slug = ${slug} ORDER BY filename LIMIT ${FILE_LIST_LIMIT}` @@ -3304,7 +3305,8 @@ const file_upload: Operation = { }; const mimeType = MIME_TYPES[extname(filePath).toLowerCase()] || null; - const sql = db.getConnection(); + const { sqlQueryForEngine } = await import('./sql-query.ts'); + const sql = sqlQueryForEngine(ctx.engine); const existing = await sql`SELECT id FROM files WHERE content_hash = ${hash} AND storage_path = ${storagePath}`; if (existing.length > 0) { return { status: 'already_exists', storage_path: storagePath }; @@ -3354,8 +3356,9 @@ const file_url: Operation = { }, scope: 'admin', localOnly: true, - handler: async (_ctx, p) => { - const sql = db.getConnection(); + handler: async (ctx, p) => { + const { sqlQueryForEngine } = await import('./sql-query.ts'); + const sql = sqlQueryForEngine(ctx.engine); const rows = await sql`SELECT storage_path, mime_type, size_bytes FROM files WHERE storage_path = ${p.storage_path as string}`; if (rows.length === 0) { throw new OperationError('storage_error', `File not found: ${p.storage_path}`); diff --git a/test/file-upload-engine-context.test.ts b/test/file-upload-engine-context.test.ts new file mode 100644 index 000000000..94f95baf7 --- /dev/null +++ b/test/file-upload-engine-context.test.ts @@ -0,0 +1,69 @@ +/** + * Regression: MCP file_upload must use the connected OperationContext engine. + * + * A long-running `gbrain serve` owns the PGLite connection. The handler must not + * reach for the module-global db singleton, which is intentionally uninitialized + * in the MCP dispatch path and throws "connect() has not been called". + */ + +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { PGLiteEngine } from '../src/core/pglite-engine.ts'; +import { dispatchToolCall } from '../src/mcp/dispatch.ts'; + +let engine: PGLiteEngine; +let fixtureDir: string; + +beforeAll(async () => { + engine = new PGLiteEngine(); + await engine.connect({ engine: 'pglite' } as never); + await engine.initSchema(); + + // Remote MCP uploads are intentionally confined to the server working tree. + fixtureDir = mkdtempSync(join(process.cwd(), '.file-upload-engine-context-')); +}); + +afterAll(async () => { + if (engine) await engine.disconnect(); + if (fixtureDir) rmSync(fixtureDir, { recursive: true, force: true }); +}); + +describe('file_upload engine ownership', () => { + test('uses the MCP context engine instead of the module-global DB singleton', async () => { + const fixture = join(fixtureDir, 'capture.json'); + writeFileSync(fixture, '{"source":"camofox"}\n'); + + const result = await dispatchToolCall(engine, 'file_upload', { + path: fixture, + page_slug: 'concepts/hermes-kanban', + }, { remote: true, sourceId: 'default' }); + + expect(result.isError).toBeFalsy(); + expect(JSON.parse(result.content[0].text)).toEqual({ + status: 'uploaded', + storage_path: 'concepts/hermes-kanban/capture.json', + size_bytes: 21, + }); + + const listed = await dispatchToolCall(engine, 'file_list', { + slug: 'concepts/hermes-kanban', + }, { remote: true, sourceId: 'default' }); + expect(listed.isError).toBeFalsy(); + expect(JSON.parse(listed.content[0].text)).toEqual([ + expect.objectContaining({ + page_slug: 'concepts/hermes-kanban', + storage_path: 'concepts/hermes-kanban/capture.json', + }), + ]); + + const url = await dispatchToolCall(engine, 'file_url', { + storage_path: 'concepts/hermes-kanban/capture.json', + }, { remote: true, sourceId: 'default' }); + expect(url.isError).toBeFalsy(); + expect(JSON.parse(url.content[0].text)).toEqual({ + storage_path: 'concepts/hermes-kanban/capture.json', + url: 'gbrain:files/concepts/hermes-kanban/capture.json', + }); + }); +});