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
6 changes: 6 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,12 @@ The server adapter in `server.ts` collects these registrations into an internal

`ClientRegistry` (`src/seatable/clientRegistry.ts`) manages multiple `SeaTableClient` instances keyed by base name. `ContextualClient` (`src/seatable/contextualClient.ts`) implements `ClientLike` and proxies calls to the right client based on a `base` parameter. In multi-base mode, `handleCallTool()` extracts the `base` arg and `handleListTools()` injects it into every tool schema dynamically — no changes needed in individual tool files.

### Multi-base and the base context

`ContextualClient` binds the target base to an `AsyncLocalStorage` scope opened by `runWithBase()`, which `handleCallTool()` wraps around the tool handler. **Everything that needs the client must read it inside that scope.** Reading after it returns throws `ContextualClient was used outside runWithBase()` — deliberately, and identically whether one base or ten are configured.

That guard exists because the opposite behaviour hid a bug for five months (fixed in #5): the post-handler log line called `getBaseInfo()` after the scope had closed. With a single base it silently succeeded via the registry default; with two or more `resolve(undefined)` threw `Multiple bases available … Specify "base" parameter`, which reads like a caller mistake and discarded a result the handler had already produced correctly. Coverage is in `tests/contextualClient.spec.ts` and `tests/server.spec.ts`.

### Schema Utilities

- `src/schema/map.ts` — converts SeaTable metadata to `GenericSchema` format
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@seatable/mcp-seatable",
"version": "1.6.3",
"version": "1.6.4",
"type": "module",
"license": "MIT",
"mcpName": "io.github.seatable/seatable",
Expand Down
26 changes: 23 additions & 3 deletions src/seatable/contextualClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@ import { AsyncLocalStorage } from 'node:async_hooks'
import type { ClientLike } from '../mcp/tools/types.js'
import type { ClientRegistry } from './clientRegistry.js'

const baseContext = new AsyncLocalStorage<string | undefined>()
/**
* The store is an object rather than the bare name so that "no scope at all"
* (getStore() === undefined) stays distinguishable from "in a scope, no base
* named" ({ base: undefined }). Those two need very different errors.
*/
const baseContext = new AsyncLocalStorage<{ base?: string }>()

/**
* A ClientLike proxy that delegates to a specific base client
Expand All @@ -24,11 +29,26 @@ export class ContextualClient implements ClientLike {
* will route to the specified base.
*/
runWithBase<T>(name: string | undefined, fn: () => T): T {
return baseContext.run(name, fn)
return baseContext.run({ base: name }, fn)
}

/**
* Resolving outside a runWithBase() scope is always a programming error, and
* it used to hide well: with a single base it quietly succeeded via the
* registry default, and only with two or more did it surface — as "Specify
* base parameter", which blames the caller for omitting an argument they
* did supply. It cost five months to find that way once, so it is named
* here and fails the same way whatever the base count.
*/
private get client(): ClientLike {
return this.registry.resolve(baseContext.getStore())
const store = baseContext.getStore()
if (!store) {
throw new Error(
'ContextualClient was used outside runWithBase(). The base context only exists for the '
+ 'duration of that scope — read what you need inside it rather than after it returns.'
)
}
return this.registry.resolve(store.base)
}

// Base info
Expand Down
31 changes: 31 additions & 0 deletions tests/contextualClient.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,37 @@ describe('ContextualClient', () => {
expect(() => ctx.runWithBase(undefined, () => ctx.listTables())).toThrow('Specify "base" parameter')
})

/*
* The failure mode this guards against cost five months to find: reading the
* client after the runWithBase() scope returned. With one base it silently
* worked (resolve(undefined) fell back to the default), with two it failed as
* "Specify base parameter" — which reads like the caller forgot an argument
* they had in fact supplied. Both halves are wrong, so the escape is named.
*/
it('throws a diagnostic error when used outside runWithBase()', () => {
const registry = createMockRegistry(['A', 'B'])
const ctx = new ContextualClient(registry as any)

expect(() => ctx.getBaseInfo()).toThrow(/outside runWithBase/)
})

it('throws the same diagnostic outside the scope even with a single base', () => {
// The single-base case used to succeed here, which is exactly what kept
// the defect invisible until someone configured a second base.
const registry = createMockRegistry(['OnlyBase'])
const ctx = new ContextualClient(registry as any)

expect(() => ctx.listTables()).toThrow(/outside runWithBase/)
})

it('still reports a genuinely missing base argument inside the scope', () => {
const registry = createMockRegistry(['A', 'B'])
const ctx = new ContextualClient(registry as any)

// Inside the scope with no name, the caller really did omit "base".
expect(() => ctx.runWithBase(undefined, () => ctx.listTables())).toThrow('Specify "base" parameter')
})

it('throws for unknown base name', () => {
const registry = createMockRegistry(['CRM'])
const ctx = new ContextualClient(registry as any)
Expand Down
Loading