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, {