From 7ae8db1bc84fd1ef49a0dfc43e33ef60cd8a51be Mon Sep 17 00:00:00 2001 From: Benny Burkert Date: Thu, 3 Sep 2026 12:31:05 +0200 Subject: [PATCH] Fix multi-base tool calls failing in post-handler logging handleCallTool() read getBaseInfo() after runWithBase() had returned, so the AsyncLocalStorage store was already unset. With two or more bases ClientRegistry.resolve(undefined) has no default and throws "Multiple bases available", turning every successful tool call into an error. Capture the base info inside the handler scope instead, and use it in both the success and the error log line. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TXnsxX58RpvMWysyjqjrKJ --- src/mcp/server.ts | 21 ++++++++++++--- tests/server.spec.ts | 62 +++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 79 insertions(+), 4 deletions(-) diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 85bb447..c2efc82 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -262,9 +262,26 @@ export class SeaTableMCPServer { const start = Date.now() toolCallsByToolTotal.inc({ tool: toolName }) + // Read inside runWithBase(): once the scope has returned, the AsyncLocalStorage + // store is gone and ContextualClient cannot resolve a base in multi-base mode. + let baseInfo: { dtableUuid?: string; appName?: string } = {} + const captureBaseInfo = () => { + try { + baseInfo = this.client.getBaseInfo?.() ?? {} + } catch { + // Base could not be resolved (e.g. missing or unknown "base" argument); + // the log line then simply omits the base fields. + } + } try { // Wrap handler to support thread-safe multi-base routing via AsyncLocalStorage - const runHandler = () => tool.handler(request.params.arguments) + const runHandler = async () => { + try { + return await tool.handler(request.params.arguments) + } finally { + captureBaseInfo() + } + } const result = this.contextualClient ? await this.contextualClient.runWithBase( (request.params.arguments as Record | undefined)?.base as string | undefined, @@ -273,7 +290,6 @@ export class SeaTableMCPServer { : await runHandler() const durationMs = Date.now() - start const durationSec = durationMs / 1000 - const baseInfo = this.client.getBaseInfo?.() ?? {} logger.info({ ...logCtx, ...(baseInfo.dtableUuid && { dtable_uuid: baseInfo.dtableUuid }), @@ -286,7 +302,6 @@ export class SeaTableMCPServer { } catch (error) { const durationMs = Date.now() - start const durationSec = durationMs / 1000 - const baseInfo = this.client.getBaseInfo?.() ?? {} const errorCode = (error as CodedError)?.code const isClientError = typeof errorCode === 'string' && CLIENT_ERROR_CODES.has(errorCode) diff --git a/tests/server.spec.ts b/tests/server.spec.ts index 09504c6..3f63a9e 100644 --- a/tests/server.spec.ts +++ b/tests/server.spec.ts @@ -1,10 +1,12 @@ -import { beforeAll, describe, expect, it } from 'vitest' +import { beforeAll, describe, expect, it, vi } from 'vitest' import { buildServer, getStaticToolDefinitions, SeaTableMCPServer } from '../src/mcp/server' import { registerEchoArgs } from '../src/mcp/tools/echoArgs' import { registerPingSeatable } from '../src/mcp/tools/pingSeatable' import type { ClientLike } from '../src/mcp/tools/types' +import { ContextualClient } from '../src/seatable/contextualClient' import { MockSeaTableClient } from '../src/seatable/mockClient' +import { logger } from '../src/logger' beforeAll(() => { process.env.SEATABLE_SERVER_URL = 'http://localhost' @@ -37,6 +39,29 @@ function getToolNames(debug: boolean): string[] { return names } +/** Registry with one client per base, mimicking ClientRegistry for multi-base tests */ +function createMultiBaseRegistry(baseNames: string[]) { + const clients = new Map() + for (const name of baseNames) { + clients.set(name, { + getBaseInfo: () => ({ dtableUuid: `uuid_${name}`, appName: `app_${name}` }), + getMetadata: async () => ({ tables: [{ _id: `tbl_${name}`, name: `Table_${name}`, columns: [] }] }), + } as unknown as ClientLike) + } + return { + baseNames, + isMultiBase: baseNames.length > 1, + resolve(baseName?: string): ClientLike { + if (!baseName) { + throw new Error(`Multiple bases available (${baseNames.join(', ')}). Specify "base" parameter.`) + } + const client = clients.get(baseName) + if (!client) throw new Error(`Unknown base "${baseName}". Available: ${baseNames.join(', ')}`) + return client + }, + } +} + describe('SeaTableMCPServer', () => { let server: SeaTableMCPServer @@ -113,6 +138,41 @@ describe('SeaTableMCPServer', () => { expect(names).toContain('echo_args') }) + it('handleCallTool succeeds in multi-base mode with two bases', async () => { + const registry = createMultiBaseRegistry(['CRM', 'Projects']) + const contextualClient = new ContextualClient(registry as any) + const multiBaseServer = new SeaTableMCPServer(contextualClient as unknown as ClientLike, { + contextualClient, + baseNames: registry.baseNames, + }) + + const result = await callTool(multiBaseServer, 'list_tables', { base: 'CRM' }) + expect(result.isError).toBeUndefined() + expect(result.content[0].text).toContain('Table_CRM') + }) + + it('logs base info of the resolved base in multi-base mode', async () => { + const registry = createMultiBaseRegistry(['CRM', 'Projects']) + const contextualClient = new ContextualClient(registry as any) + const multiBaseServer = new SeaTableMCPServer(contextualClient as unknown as ClientLike, { + contextualClient, + baseNames: registry.baseNames, + }) + + const infoSpy = vi.spyOn(logger, 'info') + let calls: unknown[][] + try { + await callTool(multiBaseServer, 'list_tables', { base: 'Projects' }) + calls = infoSpy.mock.calls.map((call) => [...call]) + } finally { + infoSpy.mockRestore() + } + + const completed = calls.find((call) => call[1] === 'Tool call completed') as [Record, string] | undefined + expect(completed).toBeDefined() + expect(completed![0]).toMatchObject({ dtable_uuid: 'uuid_Projects', app_name: 'app_Projects' }) + }) + it('handleListTools in multi-base mode does NOT inject base into list_bases', async () => { const mockClient = new MockSeaTableClient() as unknown as ClientLike const multiBaseServer = new SeaTableMCPServer(mockClient, {