diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md index 68acfa6..2fac8ef 100644 --- a/docs/MIGRATION.md +++ b/docs/MIGRATION.md @@ -6,6 +6,67 @@ This guide is for **library and MCP client authors** upgrading from earlier **0. Under [semver 0.y.z](https://semver.org/spec/v2.0.0.html#spec-item-4), **0.1.x → 0.2.0 is a breaking minor** — pin `@0.2.0` only after reading this guide. +## Unreleased: `ServerContext` instance APIs (phase 1) + +**Rationale:** Process-global singletons (Pinecone client slot, config, URL registry, suggest-flow gate, namespaces cache) complicate testing and multi-tenant embedding. Phase 1 introduces an opt-in **`ServerContext`** without removing legacy getters. + +**Now (0.2.x — unchanged for existing embedders):** + +```ts +import { PineconeClient, setPineconeClient } from '@will-cppa/pinecone-read-only-mcp'; +import { + resolveAllianceConfig, + setupAllianceServer, +} from '@will-cppa/pinecone-read-only-mcp/alliance'; + +const config = resolveAllianceConfig({ apiKey: process.env.PINECONE_API_KEY! }); +setPineconeClient( + new PineconeClient({ + /* ... */ + }) +); +const server = await setupAllianceServer(config); +``` + +Module-level helpers (`getPineconeClient`, `registerUrlGenerator`, `requireSuggested`, etc.) continue to work; they delegate to a process-default context. + +**New (opt-in instance path):** + +```ts +import { createServer, PineconeClient } from '@will-cppa/pinecone-read-only-mcp'; +import { + resolveAllianceConfig, + setupAllianceServer, +} from '@will-cppa/pinecone-read-only-mcp/alliance'; + +const config = resolveAllianceConfig({ apiKey: process.env.PINECONE_API_KEY! }); +const ctx = createServer(config); // installs process-default + returns instance +ctx.setClient( + new PineconeClient({ + apiKey: config.apiKey, + indexName: config.indexName, + sparseIndexName: config.sparseIndexName, + rerankModel: config.rerankModel, + defaultTopK: config.defaultTopK, + requestTimeoutMs: config.requestTimeoutMs, + }) +); +const server = await setupAllianceServer(config); // uses process-default ctx for migrated tools +``` + +For custom tool wiring, pass `ctx` to migrated registrars: + +```ts +import { registerQueryTool, registerCountTool, registerListNamespacesTool } from '…'; // internal today +registerQueryTool(server, ctx); +``` + +**Later (future minors/major):** Legacy module getters will be marked `### Deprecated` per [deprecation-policy.md](./deprecation-policy.md). Multi-tenant HTTP embedders should use one `ServerContext` per session rather than sharing process-global state. + +See also [deprecation-policy.md § Future instance APIs](./deprecation-policy.md#future-instance-apis-servercontext). + +--- + ## Unreleased: core vs Alliance config defaults **Rationale:** Generic npm consumers must not silently connect to Alliance infrastructure or inherit Alliance rerank settings when using `resolveConfig` from the package root. @@ -70,12 +131,12 @@ const ns = userInput.trim(); **`code` values (discriminated union):** -| `code` | `recoverable` | Notes | -| ------ | --------------- | ----- | -| `FLOW_GATE` | `true` | Suggestion: call `suggest_query_params` for the namespace first | -| `VALIDATION` | `true` | **`field` required** — input or `metadata_filter` dot-path | -| `PINECONE_ERROR` | `true` or `false` | Upstream / network / Pinecone failures | -| `TIMEOUT` | `true` | Outbound deadline exceeded | +| `code` | `recoverable` | Notes | +| ---------------- | ----------------- | --------------------------------------------------------------- | +| `FLOW_GATE` | `true` | Suggestion: call `suggest_query_params` for the namespace first | +| `VALIDATION` | `true` | **`field` required** — input or `metadata_filter` dot-path | +| `PINECONE_ERROR` | `true` or `false` | Upstream / network / Pinecone failures | +| `TIMEOUT` | `true` | Outbound deadline exceeded | **Migration steps:** @@ -104,12 +165,12 @@ const ns = userInput.trim(); **Rationale:** Align routing hints with the unified `query` tool vocabulary. -| Old (legacy) | New | -| ------------ | --- | -| `query_fast` | `fast` | -| `query_detailed` | `detailed` | -| `count` | `count` (unchanged) | -| _(n/a)_ | `full` (explicit preset) | +| Old (legacy) | New | +| ---------------- | ------------------------ | +| `query_fast` | `fast` | +| `query_detailed` | `detailed` | +| `count` | `count` (unchanged) | +| _(n/a)_ | `full` (explicit preset) | **Migration steps:** @@ -122,10 +183,10 @@ const ns = userInput.trim(); **Rationale:** One hybrid tool with a `preset` knob instead of duplicate registrations. -| Legacy tool call | New `query` call | -| ---------------- | ---------------- | -| `query_fast({ ...params })` | `query({ ...params, preset: 'fast' })` | -| `query_detailed({ ...params })` | `query({ ...params, preset: 'detailed' })` | +| Legacy tool call | New `query` call | +| ------------------------------- | --------------------------------------------------------------- | +| `query_fast({ ...params })` | `query({ ...params, preset: 'fast' })` | +| `query_detailed({ ...params })` | `query({ ...params, preset: 'detailed' })` | | Custom / explicit rerank+fields | `query({ ...params, preset: 'full', use_reranking?, fields? })` | **Example:** diff --git a/docs/deprecation-policy.md b/docs/deprecation-policy.md index 4d2fd22..260e3a0 100644 --- a/docs/deprecation-policy.md +++ b/docs/deprecation-policy.md @@ -66,14 +66,14 @@ Security fixes may break behavior when required; document impact in CHANGELOG an ## Future instance APIs (`ServerContext`) -A planned refactor introduces **`ServerContext`** and **`createServer(config)`** while keeping legacy module-level getters during a transition. That work will: +Phase 1 of the **`ServerContext`** / **`createServer(config)`** refactor is available while legacy module-level getters remain supported. See [MIGRATION.md § ServerContext instance APIs](./MIGRATION.md#unreleased-servercontext-instance-apis-phase-1) for upgrade steps. + +That work will: - Add new instance APIs without removing legacy getters in the same release. - Document legacy getters under `### Deprecated` with a named removal target per this policy. - Link migration steps from [MIGRATION.md](./MIGRATION.md) to this document. -Until that migration guide is published, treat this section as the policy constraint for that refactor. - ## CHANGELOG format for breaking changes This project follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). Each version block should use the sections that apply: `### Added`, `### Changed`, `### Deprecated`, `### Removed`, `### Fixed`. diff --git a/examples/alliance/library-embedding-demo.ts b/examples/alliance/library-embedding-demo.ts index 6b70309..1b47ee0 100644 --- a/examples/alliance/library-embedding-demo.ts +++ b/examples/alliance/library-embedding-demo.ts @@ -3,7 +3,7 @@ * * Pattern (mirrors `src/index.ts`): * 1. `resolveAllianceConfig({ apiKey, indexName, ... })` — Alliance index/rerank defaults when unset. - * 2. `new PineconeClient({ ... })` + `setPineconeClient(client)`. + * 2. `new PineconeClient({ ... })` + `setPineconeClient(client)` (legacy), or `createServer(config)` + `ctx.setClient(...)`. * 3. `await setupAllianceServer(config)` then `server.connect(transport)`. * * **Single process:** `setupAllianceServer` registers tools against process-global diff --git a/src/core/index.ts b/src/core/index.ts index 99f9964..7d96c75 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -7,6 +7,7 @@ */ export { setPineconeClient } from './server/client-context.js'; +export { ServerContext, createServer, getDefaultServerContext } from './server/server-context.js'; export { validateMetadataFilter, validateMetadataFilterDetailed, diff --git a/src/core/server/client-context.ts b/src/core/server/client-context.ts index e894fc0..aac7fd1 100644 --- a/src/core/server/client-context.ts +++ b/src/core/server/client-context.ts @@ -1,22 +1,17 @@ import { PineconeClient } from '../pinecone-client.js'; - -// Global Pinecone client (initialized lazily) -let pineconeClient: PineconeClient | null = null; +import { getDefaultServerContext } from './server-context.js'; /** Return the shared Pinecone client; throws if setPineconeClient has not been called. */ export function getPineconeClient(): PineconeClient { - if (!pineconeClient) { - throw new Error('Pinecone client not initialized. Call setPineconeClient first.'); - } - return pineconeClient; + return getDefaultServerContext().getClientIfSet(); } /** Set the shared Pinecone client used by all MCP tools. */ export function setPineconeClient(client: PineconeClient): void { - pineconeClient = client; + getDefaultServerContext().setClient(client); } /** Clear the shared client (used by {@link teardownServer} and tests). */ export function clearPineconeClient(): void { - pineconeClient = null; + getDefaultServerContext().clearClient(); } diff --git a/src/core/server/config-context.ts b/src/core/server/config-context.ts index 4ce47d5..e89ced1 100644 --- a/src/core/server/config-context.ts +++ b/src/core/server/config-context.ts @@ -1,16 +1,18 @@ import type { ServerConfig } from '../config.js'; -import { resolveConfig } from '../config.js'; - -let activeConfig: ServerConfig | null = null; +import { + getDefaultServerContext, + setDefaultServerContext, + setPendingServerConfig, +} from './server-context.js'; /** Replace the process-global server config (called from setup with CLI/env-derived config). */ export function setServerConfig(config: ServerConfig): void { - activeConfig = config; + setPendingServerConfig(config); } /** Clear active config so the next `getServerConfig()` resolves again (used by {@link teardownServer}). */ export function resetServerConfig(): void { - activeConfig = null; + setDefaultServerContext(null); } /** @@ -22,8 +24,5 @@ export function resetServerConfig(): void { * pass config from `resolveAllianceConfig()` into `setupAllianceServer(config)`. */ export function getServerConfig(): ServerConfig { - if (!activeConfig) { - activeConfig = resolveConfig({}); - } - return activeConfig; + return getDefaultServerContext().getConfig(); } diff --git a/src/core/server/namespaces-cache.ts b/src/core/server/namespaces-cache.ts index a6437b3..8814cbc 100644 --- a/src/core/server/namespaces-cache.ts +++ b/src/core/server/namespaces-cache.ts @@ -1,18 +1,6 @@ -import { getServerConfig } from './config-context.js'; -import { getPineconeClient } from './client-context.js'; +import { getDefaultServerContext, type NamespaceInfo } from './server-context.js'; -export type NamespaceInfo = { - namespace: string; - recordCount: number; - metadata: Record; -}; - -type CacheEntry = { - data: NamespaceInfo[]; - expiresAt: number; -}; - -let namespacesCache: CacheEntry | null = null; +export type { NamespaceInfo }; /** * Return namespace list with metadata; uses an in-memory cache whose TTL is @@ -23,24 +11,10 @@ export async function getNamespacesWithCache(): Promise<{ cache_hit: boolean; expires_at: number; }> { - const now = Date.now(); - if (namespacesCache && now < namespacesCache.expiresAt) { - return { - data: namespacesCache.data, - cache_hit: true, - expires_at: namespacesCache.expiresAt, - }; - } - - const client = getPineconeClient(); - const data = await client.listNamespacesWithMetadata(); - const ttlMs = getServerConfig().cacheTtlMs; - const expiresAt = now + ttlMs; - namespacesCache = { data, expiresAt }; - return { data, cache_hit: false, expires_at: expiresAt }; + return getDefaultServerContext().getNamespacesWithCache(); } /** Clear the namespaces cache so the next call to getNamespacesWithCache refetches. */ export function invalidateNamespacesCache(): void { - namespacesCache = null; + getDefaultServerContext().invalidateNamespacesCache(); } diff --git a/src/core/server/server-context.test.ts b/src/core/server/server-context.test.ts new file mode 100644 index 0000000..66d5314 --- /dev/null +++ b/src/core/server/server-context.test.ts @@ -0,0 +1,149 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { PineconeClient } from '../pinecone-client.js'; +import { resolveTestConfig } from './tools/test-helpers.js'; +import { + ServerContext, + createServer, + getDefaultServerContext, + setDefaultServerContext, + teardownDefaultServerContext, +} from './server-context.js'; + +describe('ServerContext', () => { + afterEach(() => { + teardownDefaultServerContext(); + }); + + const testConfig = () => resolveTestConfig(); + + it('lazy-builds Pinecone client on first getClient()', () => { + const ctx = new ServerContext(testConfig()); + const client = ctx.getClient(); + expect(client).toBeInstanceOf(PineconeClient); + expect(ctx.getClient()).toBe(client); + }); + + it('honors externally injected client via setClient and fromClient', () => { + const config = testConfig(); + const injected = { query: vi.fn() } as unknown as PineconeClient; + + const viaSetter = new ServerContext(config); + viaSetter.setClient(injected); + expect(viaSetter.getClient()).toBe(injected); + expect(viaSetter.getClientIfSet()).toBe(injected); + + const viaFactory = ServerContext.fromClient(config, injected); + expect(viaFactory.getClient()).toBe(injected); + }); + + it('createServer installs default context', () => { + const config = testConfig(); + const ctx = createServer(config); + expect(getDefaultServerContext()).toBe(ctx); + expect(ctx.getConfig()).toEqual(config); + }); + + it('setConfig clears client, namespaces cache, and suggest-flow', async () => { + const listA = vi + .fn() + .mockResolvedValue([{ namespace: 'a', recordCount: 1, metadata: { title: 'string' } }]); + const listB = vi + .fn() + .mockResolvedValue([{ namespace: 'b', recordCount: 2, metadata: { title: 'string' } }]); + const ctx = ServerContext.fromClient(testConfig(), { + listNamespacesWithMetadata: listA, + } as never); + + ctx.markSuggested('wg21', { + recommended_tool: 'fast', + suggested_fields: ['title'], + user_query: 'contracts', + }); + await ctx.getNamespacesWithCache(); + expect((await ctx.getNamespacesWithCache()).cache_hit).toBe(true); + expect(ctx.requireSuggested('wg21').ok).toBe(true); + + ctx.setConfig(resolveTestConfig({ indexName: 'other-index' })); + expect(() => ctx.getClientIfSet()).toThrow(/not initialized/); + expect(ctx.requireSuggested('wg21').ok).toBe(false); + + ctx.setClient({ listNamespacesWithMetadata: listB } as never); + const afterConfigChange = await ctx.getNamespacesWithCache(); + expect(afterConfigChange.cache_hit).toBe(false); + expect(listA).toHaveBeenCalledOnce(); + expect(listB).toHaveBeenCalledOnce(); + }); + + it('requireSuggested bypasses gate when disableSuggestFlow comes from resolved config', () => { + const prevDisable = process.env['PINECONE_DISABLE_SUGGEST_FLOW']; + const prevKey = process.env['PINECONE_API_KEY']; + const prevIndex = process.env['PINECONE_INDEX_NAME']; + try { + process.env['PINECONE_DISABLE_SUGGEST_FLOW'] = 'true'; + process.env['PINECONE_API_KEY'] = 'sk-test'; + process.env['PINECONE_INDEX_NAME'] = 'test-index'; + const ctx = new ServerContext(); + expect(ctx.requireSuggested('wg21')).toMatchObject({ ok: true }); + } finally { + if (prevDisable === undefined) { + delete process.env['PINECONE_DISABLE_SUGGEST_FLOW']; + } else { + process.env['PINECONE_DISABLE_SUGGEST_FLOW'] = prevDisable; + } + if (prevKey === undefined) { + delete process.env['PINECONE_API_KEY']; + } else { + process.env['PINECONE_API_KEY'] = prevKey; + } + if (prevIndex === undefined) { + delete process.env['PINECONE_INDEX_NAME']; + } else { + process.env['PINECONE_INDEX_NAME'] = prevIndex; + } + } + }); + + it('teardown clears client, URL registry, suggest-flow, and namespaces cache', async () => { + const config = testConfig(); + const listNamespaces = vi + .fn() + .mockResolvedValue([{ namespace: 'wg21', recordCount: 1, metadata: { title: 'string' } }]); + const ctx = ServerContext.fromClient(config, { + listNamespacesWithMetadata: listNamespaces, + } as never); + + ctx.registerUrlGenerator('wg21', () => ({ + url: 'https://example.com/doc', + method: 'generated.custom', + })); + ctx.markSuggested('wg21', { + recommended_tool: 'count', + suggested_fields: ['title'], + user_query: 'how many', + }); + + await ctx.getNamespacesWithCache(); + expect(ctx.hasUrlGenerator('wg21')).toBe(true); + expect(ctx.requireSuggested('wg21').ok).toBe(true); + expect((await ctx.getNamespacesWithCache()).cache_hit).toBe(true); + + ctx.teardown(); + expect(() => ctx.getClientIfSet()).toThrow(/not initialized/); + expect(ctx.hasUrlGenerator('wg21')).toBe(false); + ctx.setConfig(testConfig()); + expect(ctx.requireSuggested('wg21').ok).toBe(false); + + ctx.setClient({ listNamespacesWithMetadata: listNamespaces } as never); + const afterTeardown = await ctx.getNamespacesWithCache(); + expect(afterTeardown.cache_hit).toBe(false); + expect(listNamespaces).toHaveBeenCalledTimes(2); + }); + + it('teardownDefaultServerContext clears process default', () => { + createServer(testConfig()); + teardownDefaultServerContext(); + setDefaultServerContext(null); + const fresh = getDefaultServerContext(); + expect(fresh).not.toBeNull(); + }); +}); diff --git a/src/core/server/server-context.ts b/src/core/server/server-context.ts new file mode 100644 index 0000000..91d1c63 --- /dev/null +++ b/src/core/server/server-context.ts @@ -0,0 +1,311 @@ +import type { ServerConfig } from '../config.js'; +import { resolveConfig } from '../config.js'; +import { PineconeClient } from '../pinecone-client.js'; +import { normalizeNamespace } from './namespace-utils.js'; +import type { RecommendedTool } from './query-suggestion.js'; +import type { UrlGenerationResult, UrlGeneratorFn } from './url-registry.js'; + +export type NamespaceInfo = { + namespace: string; + recordCount: number; + metadata: Record; +}; + +type FlowState = { + updatedAt: number; + recommended_tool: RecommendedTool; + suggested_fields: string[]; + user_query: string; +}; + +type CacheEntry = { + data: NamespaceInfo[]; + expiresAt: number; +}; + +/** Return a trimmed non-empty string or null for empty/missing values. */ +function asString(value: unknown): string | null { + return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null; +} + +function buildPineconeClient(config: ServerConfig): PineconeClient { + return new PineconeClient({ + apiKey: config.apiKey, + indexName: config.indexName, + sparseIndexName: config.sparseIndexName, + rerankModel: config.rerankModel, + defaultTopK: config.defaultTopK, + requestTimeoutMs: config.requestTimeoutMs, + }); +} + +/** + * Encapsulates per-server state: Pinecone client, config, URL registry, + * suggest-flow gate, and namespaces cache. + */ +export class ServerContext { + private client: PineconeClient | null = null; + private configValue: ServerConfig | null = null; + private readonly urlGenerators = new Map(); + private readonly suggestionFlow = new Map(); + private namespacesCache: CacheEntry | null = null; + + constructor(config?: ServerConfig, client?: PineconeClient) { + if (config) { + this.configValue = config; + } + if (client) { + this.client = client; + } + } + + /** Build a context with an externally-constructed Pinecone client. */ + static fromClient(config: ServerConfig, client: PineconeClient): ServerContext { + return new ServerContext(config, client); + } + + getConfig(): ServerConfig { + if (!this.configValue) { + this.configValue = resolveConfig({}); + } + return this.configValue; + } + + setConfig(config: ServerConfig): void { + this.configValue = config; + this.invalidateConfigDerivedState(); + } + + /** Drop client, namespace cache, and suggest-flow tied to a previous config. */ + private invalidateConfigDerivedState(): void { + this.client = null; + this.namespacesCache = null; + this.suggestionFlow.clear(); + } + + setClient(client: PineconeClient): void { + this.client = client; + } + + clearClient(): void { + this.client = null; + } + + /** Return the client only when explicitly set (legacy {@link getPineconeClient} path). */ + getClientIfSet(): PineconeClient { + if (!this.client) { + throw new Error('Pinecone client not initialized. Call setPineconeClient first.'); + } + return this.client; + } + + /** Return the Pinecone client, lazily constructing from config when unset. */ + getClient(): PineconeClient { + if (!this.client) { + this.client = buildPineconeClient(this.getConfig()); + } + return this.client; + } + + resetUrlGenerators(): void { + this.urlGenerators.clear(); + } + + registerUrlGenerator(namespace: string, generator: UrlGeneratorFn): void { + const normalizedNamespace = namespace.trim(); + if (normalizedNamespace.length === 0) { + throw new TypeError('namespace must be a non-empty string'); + } + if (typeof generator !== 'function') { + throw new TypeError('generator must be a function'); + } + this.urlGenerators.set(normalizedNamespace, generator); + } + + unregisterUrlGenerator(namespace: string): boolean { + return this.urlGenerators.delete(namespace.trim()); + } + + hasUrlGenerator(namespace: string): boolean { + return this.urlGenerators.has(namespace.trim()); + } + + generateUrlForNamespace( + namespace: string, + metadata: Record + ): UrlGenerationResult { + const existingUrl = asString(metadata['url']); + if (existingUrl) { + return { url: existingUrl, method: 'metadata.url' }; + } + + const generator = this.urlGenerators.get(namespace.trim()); + if (generator) { + return generator(metadata); + } + + return { + url: null, + method: 'unavailable', + reason: `URL generation is not supported for namespace "${namespace}"`, + }; + } + + private sweepExpiredSuggestionFlow(): void { + const ttlMs = this.getConfig().cacheTtlMs; + const now = Date.now(); + for (const [ns, state] of this.suggestionFlow) { + if (now - state.updatedAt > ttlMs) { + this.suggestionFlow.delete(ns); + } + } + } + + markSuggested(namespace: string, state: Omit): void { + const key = normalizeNamespace(namespace); + if (!key) { + throw new Error('markSuggested: namespace must not be empty after trim'); + } + this.sweepExpiredSuggestionFlow(); + this.suggestionFlow.set(key, { + ...state, + updatedAt: Date.now(), + }); + } + + requireSuggested(namespace: string): + | { + ok: true; + flow: FlowState; + } + | { + ok: false; + message: string; + } { + const key = normalizeNamespace(namespace); + if (!key) { + return { + ok: false, + message: 'namespace cannot be empty after trimming whitespace.', + }; + } + + if (this.getConfig().disableSuggestFlow) { + return { + ok: true, + flow: { + updatedAt: Date.now(), + recommended_tool: 'fast', + suggested_fields: [], + user_query: '', + }, + }; + } + + const state = this.suggestionFlow.get(key); + if (!state) { + return { + ok: false, + message: + 'Flow requires suggest_query_params first. Call suggest_query_params with namespace and user_query before query/count tools.', + }; + } + + const cfg = this.getConfig(); + const now = Date.now(); + if (now - state.updatedAt > cfg.cacheTtlMs) { + this.suggestionFlow.delete(key); + return { + ok: false, + message: + 'Previous suggest_query_params context expired. Call suggest_query_params again before query/count tools.', + }; + } + + return { ok: true, flow: state }; + } + + resetSuggestionFlow(): void { + this.suggestionFlow.clear(); + } + + async getNamespacesWithCache(): Promise<{ + data: NamespaceInfo[]; + cache_hit: boolean; + expires_at: number; + }> { + const now = Date.now(); + if (this.namespacesCache && now < this.namespacesCache.expiresAt) { + return { + data: this.namespacesCache.data, + cache_hit: true, + expires_at: this.namespacesCache.expiresAt, + }; + } + + const client = this.getClient(); + const data = await client.listNamespacesWithMetadata(); + const ttlMs = this.getConfig().cacheTtlMs; + const expiresAt = now + ttlMs; + this.namespacesCache = { data, expiresAt }; + return { data, cache_hit: false, expires_at: expiresAt }; + } + + invalidateNamespacesCache(): void { + this.namespacesCache = null; + } + + /** Clear all encapsulated state (client handle, caches, registries). */ + teardown(): void { + this.client = null; + this.configValue = null; + this.urlGenerators.clear(); + this.suggestionFlow.clear(); + this.namespacesCache = null; + } +} + +let defaultContext: ServerContext | null = null; +let pendingConfig: ServerConfig | null = null; + +/** Process-default context used by legacy module facades. */ +export function getDefaultServerContext(): ServerContext { + if (!defaultContext) { + defaultContext = pendingConfig ? new ServerContext(pendingConfig) : new ServerContext(); + pendingConfig = null; + } + return defaultContext; +} + +/** Replace or clear the process-default context (tests and teardown). */ +export function setDefaultServerContext(ctx: ServerContext | null): void { + defaultContext = ctx; + if (ctx === null) { + pendingConfig = null; + } +} + +/** Stash config until the default context is first materialized. */ +export function setPendingServerConfig(config: ServerConfig): void { + pendingConfig = config; + if (defaultContext) { + defaultContext.setConfig(config); + } +} + +/** Tear down and clear the process-default context. */ +export function teardownDefaultServerContext(): void { + if (defaultContext) { + defaultContext.teardown(); + defaultContext = null; + } + pendingConfig = null; +} + +/** Create a configured context and install it as the process default. */ +export function createServer(config: ServerConfig): ServerContext { + const ctx = new ServerContext(config); + defaultContext = ctx; + pendingConfig = null; + return ctx; +} diff --git a/src/core/server/suggestion-flow.ts b/src/core/server/suggestion-flow.ts index 2e34cde..403d668 100644 --- a/src/core/server/suggestion-flow.ts +++ b/src/core/server/suggestion-flow.ts @@ -1,6 +1,5 @@ -import { getServerConfig } from './config-context.js'; +import { getDefaultServerContext } from './server-context.js'; import type { RecommendedTool } from './query-suggestion.js'; -import { normalizeNamespace } from './namespace-utils.js'; type FlowState = { updatedAt: number; @@ -9,33 +8,9 @@ type FlowState = { user_query: string; }; -const stateByNamespace = new Map(); - -/** - * Evict all entries older than the configured cache TTL. - * Called on every write so the map stays bounded without a background timer. - */ -function sweepExpired(): void { - const ttlMs = getServerConfig().cacheTtlMs; - const now = Date.now(); - for (const [ns, state] of stateByNamespace) { - if (now - state.updatedAt > ttlMs) { - stateByNamespace.delete(ns); - } - } -} - /** Record that suggest_query_params was called for this namespace (enables query/count for the flow). */ export function markSuggested(namespace: string, state: Omit): void { - const key = normalizeNamespace(namespace); - if (!key) { - throw new Error('markSuggested: namespace must not be empty after trim'); - } - sweepExpired(); - stateByNamespace.set(key, { - ...state, - updatedAt: Date.now(), - }); + getDefaultServerContext().markSuggested(namespace, state); } /** @@ -55,50 +30,10 @@ export function requireSuggested(namespace: string): ok: false; message: string; } { - const cfg = getServerConfig(); - if (cfg.disableSuggestFlow) { - return { - ok: true, - flow: { - updatedAt: Date.now(), - recommended_tool: 'fast', - suggested_fields: [], - user_query: '', - }, - }; - } - - const key = normalizeNamespace(namespace); - if (!key) { - return { - ok: false, - message: 'namespace cannot be empty after trimming whitespace.', - }; - } - - const state = stateByNamespace.get(key); - if (!state) { - return { - ok: false, - message: - 'Flow requires suggest_query_params first. Call suggest_query_params with namespace and user_query before query/count tools.', - }; - } - - const now = Date.now(); - if (now - state.updatedAt > cfg.cacheTtlMs) { - stateByNamespace.delete(key); - return { - ok: false, - message: - 'Previous suggest_query_params context expired. Call suggest_query_params again before query/count tools.', - }; - } - - return { ok: true, flow: state }; + return getDefaultServerContext().requireSuggested(namespace); } /** Clear suggest-flow gate state (used by {@link teardownServer} and tests). */ export function resetSuggestionFlow(): void { - stateByNamespace.clear(); + getDefaultServerContext().resetSuggestionFlow(); } diff --git a/src/core/server/tools/count-tool.context.test.ts b/src/core/server/tools/count-tool.context.test.ts new file mode 100644 index 0000000..c5c108f --- /dev/null +++ b/src/core/server/tools/count-tool.context.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it, vi } from 'vitest'; +import { registerCountTool } from './count-tool.js'; +import { + assertToolErrorCode, + createMockServer, + createTestServerContext, + parseToolJson, +} from './test-helpers.js'; + +describe('count tool handler (ServerContext instance path)', () => { + it('returns success when flow is satisfied on injected context', async () => { + const count = vi.fn().mockResolvedValue({ count: 7, truncated: true }); + const ctx = createTestServerContext({ + client: { count } as never, + }); + ctx.markSuggested('wg21', { + recommended_tool: 'count', + suggested_fields: [], + user_query: 'how many', + }); + + const server = createMockServer(); + registerCountTool(server as never, ctx); + const raw = await server.getHandler('count')!({ + namespace: 'wg21', + query_text: 'papers', + }); + const body = parseToolJson(raw); + expect(body).toMatchObject({ + status: 'success', + count: 7, + truncated: true, + namespace: 'wg21', + }); + expect(count).toHaveBeenCalledOnce(); + }); + + it('returns FLOW_GATE when injected context has no suggest-flow state', async () => { + const ctx = createTestServerContext({ + client: { count: vi.fn() } as never, + }); + const server = createMockServer(); + registerCountTool(server as never, ctx); + const raw = await server.getHandler('count')!({ + namespace: 'wg21', + query_text: 'papers', + }); + const err = assertToolErrorCode(raw, 'FLOW_GATE'); + expect(err.suggestion).toBe("Call suggest_query_params for namespace 'wg21' first"); + }); +}); diff --git a/src/core/server/tools/count-tool.ts b/src/core/server/tools/count-tool.ts index b582e8a..a31809f 100644 --- a/src/core/server/tools/count-tool.ts +++ b/src/core/server/tools/count-tool.ts @@ -3,6 +3,7 @@ import { z } from 'zod'; import { getPineconeClient } from '../client-context.js'; import { metadataFilterSchema, validateMetadataFilterDetailed } from '../metadata-filter.js'; import { normalizeNamespace } from '../namespace-utils.js'; +import type { ServerContext } from '../server-context.js'; import { requireSuggested } from '../suggestion-flow.js'; import { classifyToolCatchError, @@ -21,8 +22,58 @@ type CountResponse = { metadata_filter?: Record; }; +type CountExecParams = { + namespace: string; + query_text: string; + metadata_filter?: Record; +}; + +async function executeCount(params: CountExecParams, ctx?: ServerContext) { + try { + const { namespace, query_text, metadata_filter } = params; + const nsNorm = normalizeNamespace(namespace); + if (!nsNorm) { + return jsonErrorResponse( + validationToolError('namespace cannot be empty', 'namespace', { + suggestion: 'Use a namespace name from list_namespaces (trimmed).', + }) + ); + } + if (!query_text.trim()) { + return jsonErrorResponse(validationToolError('query_text cannot be empty', 'query_text')); + } + if (metadata_filter) { + const err = validateMetadataFilterDetailed(metadata_filter); + if (err) { + return jsonErrorResponse(validationToolError(err.message, err.field)); + } + } + const flowCheck = ctx ? ctx.requireSuggested(nsNorm) : requireSuggested(nsNorm); + if (!flowCheck.ok) { + return jsonErrorResponse(flowGateToolError(nsNorm, flowCheck.message)); + } + const client = ctx ? ctx.getClient() : getPineconeClient(); + const { count, truncated } = await client.count({ + query: query_text.trim(), + namespace: nsNorm, + metadataFilter: metadata_filter, + }); + const response: CountResponse = { + status: COUNT_RESPONSE_STATUS, + count, + truncated, + namespace: nsNorm, + metadata_filter, + }; + return jsonResponse(response); + } catch (error) { + logToolError('count', error); + return jsonErrorResponse(classifyToolCatchError(error, 'Failed to get count')); + } +} + /** Register the count tool on the MCP server. */ -export function registerCountTool(server: McpServer): void { +export function registerCountTool(server: McpServer, ctx?: ServerContext): void { server.registerTool( 'count', { @@ -51,48 +102,6 @@ export function registerCountTool(server: McpServer): void { ), }, }, - async (params) => { - try { - const { namespace, query_text, metadata_filter } = params; - const nsNorm = normalizeNamespace(namespace); - if (!nsNorm) { - return jsonErrorResponse( - validationToolError('namespace cannot be empty', 'namespace', { - suggestion: 'Use a namespace name from list_namespaces (trimmed).', - }) - ); - } - if (!query_text.trim()) { - return jsonErrorResponse(validationToolError('query_text cannot be empty', 'query_text')); - } - if (metadata_filter) { - const err = validateMetadataFilterDetailed(metadata_filter); - if (err) { - return jsonErrorResponse(validationToolError(err.message, err.field)); - } - } - const flowCheck = requireSuggested(nsNorm); - if (!flowCheck.ok) { - return jsonErrorResponse(flowGateToolError(nsNorm, flowCheck.message)); - } - const client = getPineconeClient(); - const { count, truncated } = await client.count({ - query: query_text.trim(), - namespace: nsNorm, - metadataFilter: metadata_filter, - }); - const response: CountResponse = { - status: COUNT_RESPONSE_STATUS, - count, - truncated, - namespace: nsNorm, - metadata_filter, - }; - return jsonResponse(response); - } catch (error) { - logToolError('count', error); - return jsonErrorResponse(classifyToolCatchError(error, 'Failed to get count')); - } - } + async (params) => executeCount(params, ctx) ); } diff --git a/src/core/server/tools/list-namespaces-tool.context.test.ts b/src/core/server/tools/list-namespaces-tool.context.test.ts new file mode 100644 index 0000000..9304b6e --- /dev/null +++ b/src/core/server/tools/list-namespaces-tool.context.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it, vi } from 'vitest'; +import { registerListNamespacesTool } from './list-namespaces-tool.js'; +import { createMockServer, createTestServerContext, parseToolJson } from './test-helpers.js'; + +describe('list_namespaces tool handler (ServerContext instance path)', () => { + it('returns namespaces from injected context cache miss', async () => { + const listNamespacesWithMetadata = vi.fn().mockResolvedValue([ + { + namespace: 'wg21', + recordCount: 10, + metadata: { title: 'string', url: 'string' }, + }, + ]); + const ctx = createTestServerContext({ + client: { listNamespacesWithMetadata } as never, + }); + + const server = createMockServer(); + registerListNamespacesTool(server as never, ctx); + const raw = await server.getHandler('list_namespaces')!({}); + const body = parseToolJson(raw); + expect(body).toMatchObject({ + status: 'success', + cache_hit: false, + count: 1, + }); + expect(body['namespaces']).toEqual([ + { + name: 'wg21', + record_count: 10, + metadata_fields: { title: 'string', url: 'string' }, + }, + ]); + expect(listNamespacesWithMetadata).toHaveBeenCalledOnce(); + }); + + it('serves cached namespaces on second call via injected context', async () => { + const listNamespacesWithMetadata = vi.fn().mockResolvedValue([ + { + namespace: 'wg21', + recordCount: 10, + metadata: { title: 'string' }, + }, + ]); + const ctx = createTestServerContext({ + client: { listNamespacesWithMetadata } as never, + }); + const server = createMockServer(); + registerListNamespacesTool(server as never, ctx); + + await server.getHandler('list_namespaces')!({}); + const raw = await server.getHandler('list_namespaces')!({}); + const body = parseToolJson(raw); + expect(body['cache_hit']).toBe(true); + expect(listNamespacesWithMetadata).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/core/server/tools/list-namespaces-tool.ts b/src/core/server/tools/list-namespaces-tool.ts index f7ae9fa..5bc094a 100644 --- a/src/core/server/tools/list-namespaces-tool.ts +++ b/src/core/server/tools/list-namespaces-tool.ts @@ -1,10 +1,41 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { getNamespacesWithCache } from '../namespaces-cache.js'; +import type { ServerContext } from '../server-context.js'; import { classifyToolCatchError, logToolError } from '../tool-error.js'; import { jsonErrorResponse, jsonResponse } from '../tool-response.js'; +async function executeListNamespaces(ctx?: ServerContext) { + try { + const { + data: namespacesInfo, + cache_hit, + expires_at, + } = ctx ? await ctx.getNamespacesWithCache() : await getNamespacesWithCache(); + const now = Date.now(); + const ttlSeconds = Math.max(0, Math.floor((expires_at - now) / 1000)); + + const response = { + status: 'success', + cache_hit, + cache_ttl_seconds: ttlSeconds, + expires_at_iso: new Date(expires_at).toISOString(), + count: namespacesInfo.length, + namespaces: namespacesInfo.map((ns) => ({ + name: ns.namespace, + record_count: ns.recordCount, + metadata_fields: ns.metadata, + })), + }; + + return jsonResponse(response); + } catch (error) { + logToolError('list_namespaces', error); + return jsonErrorResponse(classifyToolCatchError(error, 'Failed to list namespaces')); + } +} + /** Register the list_namespaces tool on the MCP server. */ -export function registerListNamespacesTool(server: McpServer): void { +export function registerListNamespacesTool(server: McpServer, ctx?: ServerContext): void { server.registerTool( 'list_namespaces', { @@ -15,30 +46,6 @@ export function registerListNamespacesTool(server: McpServer): void { 'Results are cached in-memory for 30 minutes for better performance.', inputSchema: {}, }, - async () => { - try { - const { data: namespacesInfo, cache_hit, expires_at } = await getNamespacesWithCache(); - const now = Date.now(); - const ttlSeconds = Math.max(0, Math.floor((expires_at - now) / 1000)); - - const response = { - status: 'success', - cache_hit, - cache_ttl_seconds: ttlSeconds, - expires_at_iso: new Date(expires_at).toISOString(), - count: namespacesInfo.length, - namespaces: namespacesInfo.map((ns) => ({ - name: ns.namespace, - record_count: ns.recordCount, - metadata_fields: ns.metadata, - })), - }; - - return jsonResponse(response); - } catch (error) { - logToolError('list_namespaces', error); - return jsonErrorResponse(classifyToolCatchError(error, 'Failed to list namespaces')); - } - } + async () => executeListNamespaces(ctx) ); } diff --git a/src/core/server/tools/query-tool.context.test.ts b/src/core/server/tools/query-tool.context.test.ts new file mode 100644 index 0000000..e8dd6a8 --- /dev/null +++ b/src/core/server/tools/query-tool.context.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it, vi } from 'vitest'; +import { registerQueryTool } from './query-tool.js'; +import { + assertToolErrorCode, + createMockServer, + createTestServerContext, + makeHybridQueryResult, + parseToolJson, +} from './test-helpers.js'; + +describe('query tool handler (ServerContext instance path)', () => { + it('returns success when flow is satisfied on injected context', async () => { + const query = vi.fn().mockResolvedValue(makeHybridQueryResult()); + const ctx = createTestServerContext({ + client: { query } as never, + }); + ctx.markSuggested('wg21', { + recommended_tool: 'fast', + suggested_fields: ['title'], + user_query: 'contracts', + }); + + const server = createMockServer(); + registerQueryTool(server as never, ctx); + const raw = await server.getHandler('query')!({ + query_text: 'contracts', + namespace: 'wg21', + preset: 'fast', + }); + const body = parseToolJson(raw); + expect(body).toMatchObject({ + status: 'success', + mode: 'query_fast', + namespace: 'wg21', + result_count: 1, + }); + expect(query).toHaveBeenCalledOnce(); + }); + + it('returns FLOW_GATE when injected context has no suggest-flow state', async () => { + const ctx = createTestServerContext({ + client: { query: vi.fn() } as never, + }); + const server = createMockServer(); + registerQueryTool(server as never, ctx); + const raw = await server.getHandler('query')!({ + query_text: 'contracts', + namespace: 'wg21', + preset: 'fast', + }); + const err = assertToolErrorCode(raw, 'FLOW_GATE'); + expect(err.suggestion).toBe("Call suggest_query_params for namespace 'wg21' first"); + }); +}); diff --git a/src/core/server/tools/query-tool.ts b/src/core/server/tools/query-tool.ts index 7412532..7f449eb 100644 --- a/src/core/server/tools/query-tool.ts +++ b/src/core/server/tools/query-tool.ts @@ -6,6 +6,7 @@ import { getPineconeClient } from '../client-context.js'; import { formatQueryResultRows } from '../format-query-result.js'; import { metadataFilterSchema, validateMetadataFilterDetailed } from '../metadata-filter.js'; import { normalizeNamespace } from '../namespace-utils.js'; +import type { ServerContext } from '../server-context.js'; import { requireSuggested } from '../suggestion-flow.js'; import { classifyToolCatchError, @@ -28,7 +29,7 @@ type QueryExecParams = { }; /** Run the query tool: validate flow, call Pinecone, format and return results. */ -async function executeQuery(params: QueryExecParams) { +async function executeQuery(params: QueryExecParams, ctx?: ServerContext) { const { query_text, namespace, top_k, use_reranking, metadata_filter, fields, mode } = params; try { if (!query_text.trim()) { @@ -53,12 +54,12 @@ async function executeQuery(params: QueryExecParams) { ); } - const flowCheck = requireSuggested(nsNorm); + const flowCheck = ctx ? ctx.requireSuggested(nsNorm) : requireSuggested(nsNorm); if (!flowCheck.ok) { return jsonErrorResponse(flowGateToolError(nsNorm, flowCheck.message)); } - const client = getPineconeClient(); + const client = ctx ? ctx.getClient() : getPineconeClient(); const queryOutcome = await client.query({ query: query_text.trim(), topK: top_k, @@ -126,7 +127,7 @@ const baseSchema = { * Registers semantic chunk query via one preset-driven `query` tool. * See "Retrieval tool decision matrix" in README.md for tool-selection guidance. */ -export function registerQueryTool(server: McpServer): void { +export function registerQueryTool(server: McpServer, ctx?: ServerContext): void { server.registerTool( 'query', { @@ -169,15 +170,18 @@ export function registerQueryTool(server: McpServer): void { mode = 'query'; } - return executeQuery({ - query_text: params.query_text, - namespace: params.namespace, - top_k: params.top_k, - use_reranking, - metadata_filter: params.metadata_filter, - fields, - mode, - }); + return executeQuery( + { + query_text: params.query_text, + namespace: params.namespace, + top_k: params.top_k, + use_reranking, + metadata_filter: params.metadata_filter, + fields, + mode, + }, + ctx + ); } ); } diff --git a/src/core/server/tools/test-helpers.ts b/src/core/server/tools/test-helpers.ts index fa5a843..17412a0 100644 --- a/src/core/server/tools/test-helpers.ts +++ b/src/core/server/tools/test-helpers.ts @@ -1,4 +1,8 @@ import type { HybridQueryResult, SearchResult } from '../../../types.js'; +import { resolveConfig } from '../../config.js'; +import type { PineconeClient } from '../../pinecone-client.js'; +import type { ConfigOverrides, ServerConfig } from '../../config.js'; +import { ServerContext } from '../server-context.js'; import type { ToolError, ToolErrorCode } from '../tool-error.js'; import { toolErrorSchema } from '../tool-error.js'; @@ -105,3 +109,32 @@ export function makeNamespaceCacheEntry( ): { namespace: string; recordCount: number; metadata: Record } { return { namespace, recordCount, metadata }; } + +/** + * Resolved config for tests: explicit credentials and suggest-flow **enabled** + * so `PINECONE_DISABLE_SUGGEST_FLOW` in CI/env cannot bypass the gate. + */ +/** Stable TTL (seconds) for tests — overrides env `PINECONE_CACHE_TTL_SECONDS`. */ +const TEST_CACHE_TTL_SECONDS = 3600; + +export function resolveTestConfig(overrides: ConfigOverrides = {}): ServerConfig { + return resolveConfig({ + apiKey: 'sk-test', + indexName: 'test-index', + disableSuggestFlow: false, + cacheTtlSeconds: TEST_CACHE_TTL_SECONDS, + ...overrides, + }); +} + +/** Build an isolated {@link ServerContext} for instance-path tool tests. */ +export function createTestServerContext(options?: { + config?: ConfigOverrides; + client?: PineconeClient; +}): ServerContext { + const config = resolveTestConfig(options?.config); + if (options?.client) { + return ServerContext.fromClient(config, options.client); + } + return new ServerContext(config); +} diff --git a/src/core/server/url-registry.ts b/src/core/server/url-registry.ts index 017e8bb..ff7c619 100644 --- a/src/core/server/url-registry.ts +++ b/src/core/server/url-registry.ts @@ -5,6 +5,8 @@ * Library consumers can plug in their own with `registerUrlGenerator(namespace, generator)`. */ +import { getDefaultServerContext } from './server-context.js'; + /** Outcome of a URL-generation attempt. */ export type UrlGenerationResult = { url: string | null; @@ -33,20 +35,12 @@ export type UrlGenerator = (metadata: Record) => UrlGenerationR */ export type UrlGeneratorFn = UrlGenerator; -/** Registry of namespace -> URL generator. */ -const urlGenerators = new Map(); - -/** Return a trimmed non-empty string or null for empty/missing values. */ -function asString(value: unknown): string | null { - return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null; -} - /** * Clear all URL generators. * Used by {@link teardownServer} so a subsequent setup can reinstall generators. */ export function resetUrlGenerationRegistry(): void { - urlGenerators.clear(); + getDefaultServerContext().resetUrlGenerators(); } /** @@ -56,24 +50,17 @@ export function resetUrlGenerationRegistry(): void { * @param generator function that turns a record's metadata into a URL ({@link UrlGeneratorFn}). */ export function registerUrlGenerator(namespace: string, generator: UrlGeneratorFn): void { - const normalizedNamespace = namespace.trim(); - if (normalizedNamespace.length === 0) { - throw new TypeError('namespace must be a non-empty string'); - } - if (typeof generator !== 'function') { - throw new TypeError('generator must be a function'); - } - urlGenerators.set(normalizedNamespace, generator); + getDefaultServerContext().registerUrlGenerator(namespace, generator); } /** Remove a namespace's URL generator. Returns true if a generator was removed. */ export function unregisterUrlGenerator(namespace: string): boolean { - return urlGenerators.delete(namespace.trim()); + return getDefaultServerContext().unregisterUrlGenerator(namespace); } /** True when the namespace has a registered URL generator (does not consider `metadata.url`). */ export function hasUrlGenerator(namespace: string): boolean { - return urlGenerators.has(namespace.trim()); + return getDefaultServerContext().hasUrlGenerator(namespace); } /** @@ -84,19 +71,5 @@ export function generateUrlForNamespace( namespace: string, metadata: Record ): UrlGenerationResult { - const existingUrl = asString(metadata['url']); - if (existingUrl) { - return { url: existingUrl, method: 'metadata.url' }; - } - - const generator = urlGenerators.get(namespace.trim()); - if (generator) { - return generator(metadata); - } - - return { - url: null, - method: 'unavailable', - reason: `URL generation is not supported for namespace "${namespace}"`, - }; + return getDefaultServerContext().generateUrlForNamespace(namespace, metadata); } diff --git a/src/core/setup.ts b/src/core/setup.ts index b1776ae..56b9c79 100644 --- a/src/core/setup.ts +++ b/src/core/setup.ts @@ -1,11 +1,13 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { SERVER_INSTRUCTIONS, SERVER_NAME, SERVER_VERSION } from '../constants.js'; import type { ServerConfig } from './config.js'; -import { clearPineconeClient } from './server/client-context.js'; -import { setServerConfig, resetServerConfig } from './server/config-context.js'; -import { invalidateNamespacesCache } from './server/namespaces-cache.js'; -import { resetSuggestionFlow } from './server/suggestion-flow.js'; -import { resetUrlGenerationRegistry } from './server/url-registry.js'; +import { PineconeClient } from './pinecone-client.js'; +import { + createServer, + getDefaultServerContext, + teardownDefaultServerContext, + type ServerContext, +} from './server/server-context.js'; import { registerCountTool } from './server/tools/count-tool.js'; import { registerGenerateUrlsTool } from './server/tools/generate-urls-tool.js'; import { registerKeywordSearchTool } from './server/tools/keyword-search-tool.js'; @@ -21,11 +23,7 @@ let mcpServerInitialized = false; * Pinecone client handle, URL generator registry). Call before a second {@link setupCoreServer}. */ export function teardownServer(): void { - resetSuggestionFlow(); - invalidateNamespacesCache(); - resetServerConfig(); - clearPineconeClient(); - resetUrlGenerationRegistry(); + teardownDefaultServerContext(); mcpServerInitialized = false; } @@ -43,8 +41,20 @@ export async function setupCoreServer(config?: ServerConfig): Promise ); } + let ctx: ServerContext; if (config) { - setServerConfig(config); + let existingClient: PineconeClient | undefined; + try { + existingClient = getDefaultServerContext().getClientIfSet(); + } catch { + existingClient = undefined; + } + ctx = createServer(config); + if (existingClient) { + ctx.setClient(existingClient); + } + } else { + ctx = getDefaultServerContext(); } const server = new McpServer( @@ -57,10 +67,10 @@ export async function setupCoreServer(config?: ServerConfig): Promise } ); - registerListNamespacesTool(server); + registerListNamespacesTool(server, ctx); registerNamespaceRouterTool(server); - registerCountTool(server); - registerQueryTool(server); + registerCountTool(server, ctx); + registerQueryTool(server, ctx); registerKeywordSearchTool(server); registerQueryDocumentsTool(server); registerGenerateUrlsTool(server);