Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 18 additions & 3 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> | undefined)?.base as string | undefined,
Expand All @@ -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 }),
Expand All @@ -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)

Expand Down
62 changes: 61 additions & 1 deletion tests/server.spec.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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<string, ClientLike>()
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

Expand Down Expand Up @@ -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, unknown>, 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, {
Expand Down
Loading