diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f4955d..f86e417 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,22 +8,25 @@ Tagged releases are published to npm from GitHub Actions when a **GitHub Release ## [Unreleased] +### Added + +- `ServerContextComposition` interface plus `NamespaceCacheSeed` and `SuggestionFlowSeedEntry` types for dependency injection into `ServerContext`. +- `createIsolatedContext(config, composition?)` factory for multi-tenant embedders (no process-global side effects). +- Zod schemas for all nine MCP tool success responses (`queryResponseSchema`, `guidedQueryResponseSchema`, etc.) exported from the package root for client-side validation. Success payloads are runtime-validated before return. +- Stable vs experimental response field taxonomy documented in [docs/TOOLS.md](docs/TOOLS.md) and [docs/deprecation-policy.md](docs/deprecation-policy.md#stable-vs-experimental-mcp-response-fields). +- Formal deprecation policy ([docs/deprecation-policy.md](docs/deprecation-policy.md)) and breaking-change release notes template ([docs/templates/breaking-change-release-notes.md](docs/templates/breaking-change-release-notes.md)). + ### Changed +- **Breaking (pre-1.0, core):** `ServerContext` constructor second positional argument is now `composition?: ServerContextComposition` (was `client?: PineconeClient`). Migration: use `ServerContext.fromClient(config, client)` or `new ServerContext(config, { client })`. +- `createServer(config, composition?)` now accepts an optional composition object. - **Breaking (MCP):** Experimental tool response fields are nested under `experimental` on success payloads. Affected tools: `query`, `query_documents`, `guided_query`. Fields moved: `degraded`, `degradation_reason`, `hybrid_leg_failed`, `rerank_skipped_reason` (query-shaped tools); `decision_trace` (`guided_query`). Stable fields (`status`, `results`, `namespace`, etc.) are unchanged. See [MIGRATION.md](docs/MIGRATION.md#unreleased-stable-vs-experimental-response-fields). - **Breaking (core):** `resolveConfig` requires a Pinecone index name and no longer applies Alliance index/rerank defaults. Removed exported `DEFAULT_INDEX_NAME` and `DEFAULT_RERANK_MODEL` from the package root. Rerank is opt-in when `PINECONE_RERANK_MODEL` / `rerankModel` is unset. - **Breaking (core):** `setupCoreServer` MCP `instructions` use `CORE_SERVER_INSTRUCTIONS` (no `guided_query` / `suggest_query_params`). `resolveConfig` defaults `disableSuggestFlow` to `true` so `query` / `count` / `query_documents` work without Alliance tools. Alliance CLI / `resolveAllianceConfig` unchanged: gate on by default, `ALLIANCE_SERVER_INSTRUCTIONS`. - **Alliance CLI / `resolveAllianceConfig`:** When index or rerank env/CLI values are omitted, defaults remain `rag-hybrid` and `bge-reranker-v2-m3` (API-key-only MCP configs unchanged). See [examples/alliance/.env.example](examples/alliance/.env.example). -### Added - -- Zod schemas for all nine MCP tool success responses (`queryResponseSchema`, `guidedQueryResponseSchema`, etc.) exported from the package root for client-side validation. Success payloads are runtime-validated before return. -- Stable vs experimental response field taxonomy documented in [docs/TOOLS.md](docs/TOOLS.md) and [docs/deprecation-policy.md](docs/deprecation-policy.md#stable-vs-experimental-mcp-response-fields). -- Formal deprecation policy ([docs/deprecation-policy.md](docs/deprecation-policy.md)) and breaking-change release notes template ([docs/templates/breaking-change-release-notes.md](docs/templates/breaking-change-release-notes.md)). ## [0.2.0] - 2026-05-29 -### Changed - - Package root export is the generic **core** layer (`setupCoreServer`); full CLI parity uses `@will-cppa/pinecone-read-only-mcp/alliance` (`setupAllianceServer`, built-in URL generators). `resolveConfig` uses env when set, else defaults: index **`rag-hybrid`**, rerank **`bge-reranker-v2-m3`** (constants `DEFAULT_INDEX_NAME` / `DEFAULT_RERANK_MODEL` in `src/core/config.ts`). - When reranking was requested but `PineconeClient` has no rerank model (manual library use): `query` / `query_documents` include `rerank_skipped_reason: no_model`; `guided_query` sets `decision_trace.rerank_status: skipped_no_model`. diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md index 0b6c61d..9881a35 100644 --- a/docs/MIGRATION.md +++ b/docs/MIGRATION.md @@ -93,7 +93,7 @@ const server = await setupAllianceServer(config); Module-level helpers (`getPineconeClient`, `registerUrlGenerator`, `requireSuggested`, etc.) continue to work; they delegate to a process-default context. -**New (recommended — phase 4 explicit context at setup):** +**New (recommended — phase 4 explicit context at setup):** For one-shot client injection at construction, see [ServerContext composition API](#unreleased-servercontext-composition-api) (`createServer(config, { client })` or `createIsolatedContext`). ```ts import { createServer, PineconeClient } from '@will-cppa/pinecone-read-only-mcp'; @@ -103,6 +103,21 @@ import { } from '@will-cppa/pinecone-read-only-mcp/alliance'; const config = resolveAllianceConfig({ apiKey: process.env.PINECONE_API_KEY! }); +const client = new PineconeClient({ + apiKey: config.apiKey, + indexName: config.indexName, + sparseIndexName: config.sparseIndexName, + rerankModel: config.rerankModel, + defaultTopK: config.defaultTopK, + requestTimeoutMs: config.requestTimeoutMs, +}); +const ctx = createServer(config, { client }); // equivalent to createServer + setClient +const server = await setupAllianceServer({ context: ctx }); +``` + +Alternatively, inject the client after `createServer`: + +```ts const ctx = createServer(config); ctx.setClient( new PineconeClient({ @@ -117,16 +132,23 @@ ctx.setClient( const server = await setupAllianceServer({ context: ctx }); ``` -Pass `config` at setup only when the context is not yet configured; after `createServer` + `setClient`, pass `{ context: ctx }` only. +Pass `config` at setup only when the context is not yet configured; after `createServer` + client injection, pass `{ context: ctx }` only. **Core-only setup** (seven tools, no Alliance builtins): ```ts -import { createServer, PineconeClient, resolveConfig, setupCoreServer } from '@will-cppa/pinecone-read-only-mcp'; +import { + createServer, + PineconeClient, + resolveConfig, + setupCoreServer, +} from '@will-cppa/pinecone-read-only-mcp'; const config = resolveConfig({ apiKey: '...', indexName: 'my-index' }); -const ctx = createServer(config); -ctx.setClient(new PineconeClient({ /* ... */ })); +const client = new PineconeClient({ + /* ... */ +}); +const ctx = createServer(config, { client }); const server = await setupCoreServer({ context: ctx }); ``` @@ -145,6 +167,48 @@ See also [deprecation-policy.md § Future instance APIs](./deprecation-policy.md --- +## Unreleased: ServerContext composition API + +**Rationale:** Embedders can inject client, URL generators, namespace cache seed, and suggest-flow seed at construction without process-global facades. + +**Who is affected:** Library embedders calling `new ServerContext(config, client)` directly or building multi-tenant servers. + +**Before:** + +```ts +new ServerContext(config, pineconeClient); +``` + +**After:** + +```ts +import { + createIsolatedContext, + createServer, + resolveConfig, + setupCoreServer, + ServerContext, +} from '@will-cppa/pinecone-read-only-mcp'; + +ServerContext.fromClient(config, pineconeClient); +// or +new ServerContext(config, { client: pineconeClient }); + +// Multi-tenant (no process default): +const config = resolveConfig({ apiKey: '...', indexName: 'my-index' }); +const ctx = createIsolatedContext(config, { + client: myClient, + urlGenerators: [['my-ns', myGenerator]], +}); +await setupCoreServer({ context: ctx }); +``` + +Suggest-flow gate settings (`disableSuggestFlow`, `cacheTtlMs`) remain on `ServerConfig`, not on composition. + +See also [ServerContext instance APIs (phase 1)](#unreleased-servercontext-instance-apis-phase-1) for legacy vs explicit-context setup. + +--- + ## 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. diff --git a/src/alliance/config.ts b/src/alliance/config.ts index 1085183..294965f 100644 --- a/src/alliance/config.ts +++ b/src/alliance/config.ts @@ -22,6 +22,10 @@ export const DEFAULT_ALLIANCE_RERANK_MODEL = ALLIANCE_DEFAULT_RERANK_MODEL; /** * Build {@link ServerConfig} for Alliance CLI and `setupAllianceServer`. * Fills index and rerank from Alliance defaults when unset, then calls core `resolveConfig`. + * + * Output is the `config` half of the embedder pattern `{ config, composition }`. + * Pair with {@link createIsolatedContext} or {@link createServer} and an optional + * {@link ServerContextComposition} for per-instance injectables. */ export function resolveAllianceConfig( overrides: ConfigOverrides = {}, diff --git a/src/core/config.ts b/src/core/config.ts index ee8d009..e983474 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -101,6 +101,13 @@ export interface ConfigOverrides { * Build a `ServerConfig` from CLI overrides, environment variables, and defaults. * CLI > env > default precedence is preserved. * + * Output is the `config` half of the embedder pattern `{ config, composition }`. + * Suggest-flow gate settings (`disableSuggestFlow`, `cacheTtlMs`) belong on the + * returned config. Per-instance injectables (Pinecone client, URL generators, + * namespace cache seed, suggest-flow seed) belong in {@link ServerContextComposition} + * passed to {@link createIsolatedContext} (multi-tenant) or {@link createServer} + * (singleton CLI path). + * * @throws Error when no API key or index name is provided. */ export function resolveConfig( diff --git a/src/core/index.ts b/src/core/index.ts index d2845cb..402766b 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -7,7 +7,17 @@ */ export { setPineconeClient } from './server/client-context.js'; -export { ServerContext, createServer, getDefaultServerContext } from './server/server-context.js'; +export { + ServerContext, + createServer, + createIsolatedContext, + getDefaultServerContext, +} from './server/server-context.js'; +export type { + ServerContextComposition, + NamespaceCacheSeed, + SuggestionFlowSeedEntry, +} from './server/server-context.js'; export { validateMetadataFilter, validateMetadataFilterDetailed, diff --git a/src/core/server/server-context.composition.test.ts b/src/core/server/server-context.composition.test.ts new file mode 100644 index 0000000..7dd424d --- /dev/null +++ b/src/core/server/server-context.composition.test.ts @@ -0,0 +1,297 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { PineconeClient } from '../pinecone-client.js'; +import { resolveTestConfig } from './tools/test-helpers.js'; +import { + ServerContext, + createIsolatedContext, + createServer, + getDefaultServerContext, + teardownDefaultServerContext, +} from './server-context.js'; + +describe('ServerContext composition API', () => { + afterEach(() => { + teardownDefaultServerContext(); + }); + + const testConfig = () => resolveTestConfig(); + + it('applies injected client, URL generators, namespace cache seed, and suggestion flow seed at construction', async () => { + const listNamespaces = vi.fn(); + const generator = vi.fn(() => ({ + url: 'https://example.com/doc', + method: 'generated.custom' as const, + })); + const cacheData = [{ namespace: 'wg21', recordCount: 1, metadata: { title: 'string' } }]; + const expiresAt = Date.now() + 60_000; + + const ctx = new ServerContext(testConfig(), { + client: { listNamespacesWithMetadata: listNamespaces } as never, + urlGenerators: [['wg21', generator]], + namespaceCacheSeed: { data: cacheData, expiresAt }, + suggestionFlowSeed: [ + { + namespace: 'wg21', + recommended_tool: 'fast', + suggested_fields: ['title'], + user_query: 'contracts', + }, + ], + }); + + expect(ctx.hasInjectedClient()).toBe(true); + expect(ctx.hasUrlGenerator('wg21')).toBe(true); + + const cached = await ctx.getNamespacesWithCache(); + expect(cached.cache_hit).toBe(true); + expect(cached.data).toEqual(cacheData); + expect(listNamespaces).not.toHaveBeenCalled(); + + const flow = ctx.requireSuggested('wg21'); + expect(flow.ok).toBe(true); + }); + + it('constructs with suggestionFlowSeed and no config without throwing', () => { + expect( + () => + new ServerContext(undefined, { + suggestionFlowSeed: [ + { + namespace: 'wg21', + recommended_tool: 'fast', + suggested_fields: ['title'], + user_query: 'contracts', + }, + ], + }) + ).not.toThrow(); + + expect(() => new ServerContext(undefined, {}).getConfig()).toThrow(/Missing Pinecone API key/); + }); + + it('createIsolatedContext does not install process default; createServer does', () => { + const config = testConfig(); + const isolated = createIsolatedContext(config, { + client: { query: vi.fn() } as never, + }); + expect(getDefaultServerContext()).not.toBe(isolated); + + teardownDefaultServerContext(); + + const singleton = createServer(config, { + client: { query: vi.fn() } as never, + }); + expect(getDefaultServerContext()).toBe(singleton); + }); + + it('matches post-hoc setClient for getClient and getNamespacesWithCache', async () => { + const config = testConfig(); + const listNamespaces = vi + .fn() + .mockResolvedValue([{ namespace: 'wg21', recordCount: 1, metadata: { title: 'string' } }]); + const injected = { listNamespacesWithMetadata: listNamespaces } as never; + + const viaComposition = new ServerContext(config, { client: injected }); + const viaSetter = new ServerContext(config); + viaSetter.setClient(injected); + + expect(viaComposition.getClient()).toBe(injected); + expect(viaSetter.getClient()).toBe(injected); + + await viaComposition.getNamespacesWithCache(); + await viaSetter.getNamespacesWithCache(); + expect(listNamespaces).toHaveBeenCalledTimes(2); + }); + + it('refetches when namespace cache seed is expired', async () => { + const listNamespaces = vi + .fn() + .mockResolvedValue([{ namespace: 'wg21', recordCount: 2, metadata: { title: 'string' } }]); + const ctx = new ServerContext(testConfig(), { + client: { listNamespacesWithMetadata: listNamespaces } as never, + namespaceCacheSeed: { + data: [{ namespace: 'stale', recordCount: 1, metadata: { title: 'string' } }], + expiresAt: Date.now() - 1, + }, + }); + + const result = await ctx.getNamespacesWithCache(); + expect(result.cache_hit).toBe(false); + expect(listNamespaces).toHaveBeenCalledOnce(); + expect(result.data[0]?.namespace).toBe('wg21'); + }); + + it('setConfig preserves URL generators but clears namespace cache and suggest-flow', async () => { + const listNamespaces = vi + .fn() + .mockResolvedValue([{ namespace: 'wg21', recordCount: 1, metadata: { title: 'string' } }]); + const ctx = new ServerContext(testConfig(), { + client: { listNamespacesWithMetadata: listNamespaces } as never, + urlGenerators: [['wg21', () => ({ url: 'https://example.com', method: 'generated.custom' })]], + namespaceCacheSeed: { + data: [{ namespace: 'wg21', recordCount: 1, metadata: { title: 'string' } }], + expiresAt: Date.now() + 60_000, + }, + suggestionFlowSeed: [ + { + namespace: 'wg21', + recommended_tool: 'fast', + suggested_fields: ['title'], + user_query: 'contracts', + }, + ], + }); + + expect((await ctx.getNamespacesWithCache()).cache_hit).toBe(true); + expect(ctx.requireSuggested('wg21').ok).toBe(true); + + ctx.setConfig(resolveTestConfig({ indexName: 'other-index' })); + ctx.setClient({ listNamespacesWithMetadata: listNamespaces } as never); + + expect(ctx.hasUrlGenerator('wg21')).toBe(true); + expect(ctx.requireSuggested('wg21').ok).toBe(false); + + const afterConfigChange = await ctx.getNamespacesWithCache(); + expect(afterConfigChange.cache_hit).toBe(false); + expect(listNamespaces).toHaveBeenCalledTimes(1); + }); + + it('throws synchronously for invalid composition seeds', () => { + expect( + () => + new ServerContext(testConfig(), { + suggestionFlowSeed: [ + { + namespace: ' ', + recommended_tool: 'fast', + suggested_fields: [], + user_query: '', + }, + ], + }) + ).toThrow(/suggestionFlowSeed: namespace must not be empty/); + + expect( + () => + new ServerContext(testConfig(), { + urlGenerators: [['', () => ({ url: null, method: 'unavailable' })]], + }) + ).toThrow(/namespace must be a non-empty string/); + }); + + it('teardown and AsyncDisposable clear injected client and URL generators', async () => { + const ctx = new ServerContext(testConfig(), { + client: { query: vi.fn() } as never, + urlGenerators: [['wg21', () => ({ url: 'https://example.com', method: 'generated.custom' })]], + }); + + expect(ctx.hasUrlGenerator('wg21')).toBe(true); + + await (async () => { + await using scoped = ctx; + expect(scoped.disposed).toBe(false); + })(); + + expect(ctx.disposed).toBe(true); + expect(() => ctx.getClientIfSet()).toThrow(/not initialized/); + expect(ctx.hasUrlGenerator('wg21')).toBe(false); + }); + + it('fromClient wraps client in composition object', () => { + const injected = { query: vi.fn() } as unknown as PineconeClient; + const ctx = ServerContext.fromClient(testConfig(), injected); + expect(ctx.getClient()).toBe(injected); + expect(ctx.hasInjectedClient()).toBe(true); + }); + + it('isolates namespace cache between two createIsolatedContext instances', async () => { + const listA = vi + .fn() + .mockResolvedValue([{ namespace: 'a', recordCount: 1, metadata: { source: 'a' } }]); + const listB = vi + .fn() + .mockResolvedValue([{ namespace: 'b', recordCount: 2, metadata: { source: 'b' } }]); + const cfgA = resolveTestConfig({ apiKey: 'iso-a' }); + const cfgB = resolveTestConfig({ apiKey: 'iso-b' }); + const seed = { + data: [{ namespace: 'seeded', recordCount: 10, metadata: { source: 'seed' } }], + expiresAt: Date.now() + 60_000, + }; + + const ctxA = createIsolatedContext(cfgA, { + client: { listNamespacesWithMetadata: listA } as never, + namespaceCacheSeed: seed, + }); + const ctxB = createIsolatedContext(cfgB, { + client: { listNamespacesWithMetadata: listB } as never, + }); + + const resultA = await ctxA.getNamespacesWithCache(); + expect(resultA.cache_hit).toBe(true); + expect(listA).not.toHaveBeenCalled(); + + const resultB = await ctxB.getNamespacesWithCache(); + expect(resultB.cache_hit).toBe(false); + expect(listB).toHaveBeenCalledOnce(); + expect(listA).not.toHaveBeenCalled(); + }); + + it('does not alias namespaceCacheSeed.data after construction', async () => { + const cacheData = [{ namespace: 'wg21', recordCount: 1, metadata: { title: 'string' } }]; + const ctx = new ServerContext(testConfig(), { + client: { listNamespacesWithMetadata: vi.fn() } as never, + namespaceCacheSeed: { data: cacheData, expiresAt: Date.now() + 60_000 }, + }); + + cacheData[0]!.namespace = 'mutated'; + cacheData[0]!.metadata.title = 'changed'; + + const cached = await ctx.getNamespacesWithCache(); + expect(cached.data[0]?.namespace).toBe('wg21'); + expect(cached.data[0]?.metadata.title).toBe('string'); + }); + + it('does not alias suggestionFlowSeed suggested_fields after construction', () => { + const flowSeed = [ + { + namespace: 'wg21', + recommended_tool: 'fast' as const, + suggested_fields: ['title'], + user_query: 'contracts', + }, + ]; + const ctx = new ServerContext(resolveTestConfig({ disableSuggestFlow: false }), { + suggestionFlowSeed: flowSeed, + }); + + flowSeed[0]!.suggested_fields.push('mutated'); + + const result = ctx.requireSuggested('wg21'); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.flow.suggested_fields).toEqual(['title']); + } + }); + + it('does not share mutable seed state between two contexts built from the same seed object', async () => { + const sharedSeed = { + data: [{ namespace: 'wg21', recordCount: 1, metadata: { title: 'string' } }], + expiresAt: Date.now() + 60_000, + }; + const ctxA = createIsolatedContext(testConfig({ apiKey: 'shared-a' }), { + namespaceCacheSeed: sharedSeed, + client: { listNamespacesWithMetadata: vi.fn() } as never, + }); + const ctxB = createIsolatedContext(testConfig({ apiKey: 'shared-b' }), { + namespaceCacheSeed: sharedSeed, + client: { listNamespacesWithMetadata: vi.fn() } as never, + }); + + sharedSeed.data[0]!.namespace = 'mutated'; + + const fromA = await ctxA.getNamespacesWithCache(); + const fromB = await ctxB.getNamespacesWithCache(); + expect(fromA.data[0]?.namespace).toBe('wg21'); + expect(fromB.data[0]?.namespace).toBe('wg21'); + }); +}); diff --git a/src/core/server/server-context.ts b/src/core/server/server-context.ts index 4b13072..3345131 100644 --- a/src/core/server/server-context.ts +++ b/src/core/server/server-context.ts @@ -11,6 +11,28 @@ export type NamespaceInfo = { metadata: Record; }; +/** Public seed shape for namespace cache injection (not the internal {@link NamespaceInfo} type). {@link ServerContext} copies `data` at construction so callers may reuse or mutate seed buffers afterward. */ +export type NamespaceCacheSeed = { + data: Array<{ namespace: string; recordCount: number; metadata: Record }>; + expiresAt: number; +}; + +/** Pre-warmed suggest-flow entry for a namespace. */ +export type SuggestionFlowSeedEntry = { + namespace: string; + recommended_tool: RecommendedTool; + suggested_fields: string[]; + user_query: string; +}; + +/** Pre-built dependencies accepted by {@link ServerContext} and factory helpers. */ +export interface ServerContextComposition { + client?: PineconeClient; + urlGenerators?: Iterable; + namespaceCacheSeed?: NamespaceCacheSeed; + suggestionFlowSeed?: SuggestionFlowSeedEntry[]; +} + type FlowState = { updatedAt: number; recommended_tool: RecommendedTool; @@ -53,19 +75,50 @@ export class ServerContext implements AsyncDisposable { private readonly suggestionFlow = new Map(); private namespacesCache: CacheEntry | null = null; - constructor(config?: ServerConfig, client?: PineconeClient) { + constructor(config?: ServerConfig, composition?: ServerContextComposition) { if (config) { this.configValue = config; } - if (client) { - this.client = client; + if (composition?.client) { + this.client = composition.client; this.clientExplicitlySet = true; } + if (composition?.urlGenerators) { + for (const [ns, gen] of composition.urlGenerators) { + this.registerUrlGenerator(ns, gen); + } + } + if (composition?.namespaceCacheSeed) { + const { data, expiresAt } = composition.namespaceCacheSeed; + this.namespacesCache = { + data: data.map((entry) => ({ + namespace: entry.namespace, + recordCount: entry.recordCount, + metadata: { ...entry.metadata }, + })), + expiresAt, + }; + } + if (composition?.suggestionFlowSeed) { + const now = Date.now(); + for (const entry of composition.suggestionFlowSeed) { + const key = normalizeNamespace(entry.namespace); + if (!key) { + throw new Error('suggestionFlowSeed: namespace must not be empty after trim'); + } + this.suggestionFlow.set(key, { + recommended_tool: entry.recommended_tool, + suggested_fields: [...entry.suggested_fields], + user_query: entry.user_query, + updatedAt: now, + }); + } + } } /** Build a context with an externally-constructed Pinecone client. */ static fromClient(config: ServerConfig, client: PineconeClient): ServerContext { - return new ServerContext(config, client); + return new ServerContext(config, { client }); } getConfig(): ServerConfig { @@ -306,12 +359,14 @@ export class ServerContext implements AsyncDisposable { if (defaultContext === this) { defaultContext = null; pendingConfig = null; + pendingComposition = null; } } } let defaultContext: ServerContext | null = null; let pendingConfig: ServerConfig | null = null; +let pendingComposition: ServerContextComposition | null = null; /** Peek at the process-default context without materializing a new one. */ export function peekDefaultServerContext(): ServerContext | null { @@ -321,8 +376,11 @@ export function peekDefaultServerContext(): ServerContext | null { /** Process-default context used by legacy module facades. */ export function getDefaultServerContext(): ServerContext { if (!defaultContext) { - defaultContext = pendingConfig ? new ServerContext(pendingConfig) : new ServerContext(); + const cfg = pendingConfig ?? undefined; + const comp = pendingComposition ?? undefined; + defaultContext = new ServerContext(cfg, comp); pendingConfig = null; + pendingComposition = null; } return defaultContext; } @@ -332,6 +390,7 @@ export function setDefaultServerContext(ctx: ServerContext | null): void { defaultContext = ctx; if (ctx === null) { pendingConfig = null; + pendingComposition = null; } } @@ -350,12 +409,25 @@ export function teardownDefaultServerContext(): void { defaultContext = null; } pendingConfig = null; + pendingComposition = null; +} + +/** Multi-tenant: no process-global side effects. */ +export function createIsolatedContext( + config: ServerConfig, + composition?: ServerContextComposition +): ServerContext { + return new ServerContext(config, composition); } /** Create a configured context and install it as the process default. */ -export function createServer(config: ServerConfig): ServerContext { - const ctx = new ServerContext(config); +export function createServer( + config: ServerConfig, + composition?: ServerContextComposition +): ServerContext { + const ctx = new ServerContext(config, composition); defaultContext = ctx; pendingConfig = null; + pendingComposition = null; return ctx; } diff --git a/src/core/server/tools/test-helpers.ts b/src/core/server/tools/test-helpers.ts index 037218d..ddb6982 100644 --- a/src/core/server/tools/test-helpers.ts +++ b/src/core/server/tools/test-helpers.ts @@ -3,7 +3,11 @@ 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, teardownDefaultServerContext } from '../server-context.js'; +import { + ServerContext, + teardownDefaultServerContext, + type ServerContextComposition, +} from '../server-context.js'; import type { ToolError, ToolErrorCode } from '../tool-error.js'; import { toolErrorSchema } from '../tool-error.js'; @@ -137,10 +141,20 @@ export function isolateFromDefaultContext(): void { export function createTestServerContext(options?: { config?: ConfigOverrides; client?: PineconeClient; + composition?: ServerContextComposition; }): ServerContext { const config = resolveTestConfig(options?.config); - if (options?.client) { - return ServerContext.fromClient(config, options.client); + const composition: ServerContextComposition = { + ...options?.composition, + ...(options?.client ? { client: options.client } : {}), + }; + if ( + composition.client || + composition.urlGenerators || + composition.namespaceCacheSeed || + composition.suggestionFlowSeed + ) { + return new ServerContext(config, composition); } return new ServerContext(config); } diff --git a/src/index.ts b/src/index.ts index cb37871..558da23 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,8 +3,8 @@ /** * Pinecone Read-Only MCP CLI entry point. * - * Thin composition root: parseCli() -> resolveAllianceConfig() -> createServer(config) - * -> ctx.setClient(...) -> setupAllianceServer({ context: ctx }) -> connect to stdio transport. + * Thin composition root: parseCli() -> resolveAllianceConfig() -> createServer(config, { client }) + * -> setupAllianceServer({ context: ctx }) -> connect to stdio transport. */ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; @@ -64,8 +64,7 @@ async function main(): Promise { defaultTopK: config.defaultTopK, requestTimeoutMs: config.requestTimeoutMs, }); - const ctx = createServer(config); - ctx.setClient(client); + const ctx = createServer(config, { client }); if (config.checkIndexes) { const result = await client.checkIndexes();