diff --git a/.env.example b/.env.example index 4cb373c..6a70808 100644 --- a/.env.example +++ b/.env.example @@ -3,10 +3,10 @@ # Required: Your Pinecone API key PINECONE_API_KEY=your-pinecone-api-key-here -# Optional: Pinecone index name (default: rag-hybrid) -PINECONE_INDEX_NAME=rag-hybrid +# Optional: Dense (hybrid) Pinecone index name (default: rag-hybrid when unset) +# PINECONE_INDEX_NAME=rag-hybrid -# Optional: Reranking model (default: bge-reranker-v2-m3) +# Optional: Reranking model (default: bge-reranker-v2-m3 when unset) PINECONE_RERANK_MODEL=bge-reranker-v2-m3 # Optional: Default number of results to return (default: 10) @@ -21,7 +21,7 @@ PINECONE_READ_ONLY_MCP_LOG_LEVEL=INFO PINECONE_READ_ONLY_MCP_LOG_FORMAT=text # Optional: Sparse index name (default: ${PINECONE_INDEX_NAME}-sparse) -# PINECONE_SPARSE_INDEX_NAME=rag-hybrid-sparse +# PINECONE_SPARSE_INDEX_NAME=my-index-sparse # Optional: Namespace + suggestion-flow cache TTL in seconds (default: 1800 = 30min) # PINECONE_CACHE_TTL_SECONDS=1800 diff --git a/CHANGELOG.md b/CHANGELOG.md index 4439f0e..23d64db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,11 @@ Tagged releases are published to npm from GitHub Actions when a **GitHub Release ## [Unreleased] +### 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`. + ### Added - `UrlGeneratorFn` type alias (same as `UrlGenerator`) and `RegisterBuiltinUrlGeneratorsOptions` with `reinstallBuiltins` on `registerBuiltinUrlGenerators()` to restore default `mailing` / `slack-Cpplang` generators after overrides; README “Custom URL generators” section and tests for custom registration and built-in override. diff --git a/README.md b/README.md index 5b79fc2..ff770da 100644 --- a/README.md +++ b/README.md @@ -84,16 +84,24 @@ npm install npm run build ``` +## Architecture + +The codebase is split into two layers: + +- **`src/core/`** — generic MCP–Pinecone bridge (`PineconeClient`, `resolveConfig`, core MCP tools). Import from `@will-cppa/pinecone-read-only-mcp` (package root). +- **`src/alliance/`** — C++ Alliance app tools (`suggest_query_params`, `guided_query`, Boost/Slack URL builtins). Import from `@will-cppa/pinecone-read-only-mcp/alliance` and call `setupAllianceServer(config)` for the full tool surface (what the CLI uses). + ## Configuration -You need a **Pinecone API key** and (by default) a **dense** index plus matching **sparse** index; see [docs/CONFIGURATION.md](docs/CONFIGURATION.md) for every environment variable and CLI flag. +You need a **Pinecone API key**. **Index** uses `PINECONE_INDEX_NAME` when set, else defaults to `rag-hybrid`; sparse index defaults to `{index}-sparse`. **Reranking** uses `PINECONE_RERANK_MODEL` when set, else `bge-reranker-v2-m3`. MCP configs with only `PINECONE_API_KEY` keep prior Alliance defaults. See [docs/CONFIGURATION.md](docs/CONFIGURATION.md) for every variable and CLI flag. Quick reference: | Variable | Required | Default | | ----------------------------------- | ----------------------- | --------------------------------- | | `PINECONE_API_KEY` | Yes (for live Pinecone) | — | -| `PINECONE_INDEX_NAME` | No | `rag-hybrid` | +| `PINECONE_INDEX_NAME` | No | `rag-hybrid` when unset | +| `PINECONE_RERANK_MODEL` | No | `bge-reranker-v2-m3` when unset | | `PINECONE_SPARSE_INDEX_NAME` | No | `{index}-sparse` | | `PINECONE_READ_ONLY_MCP_LOG_LEVEL` | No | `INFO` (`DEBUG`–`ERROR`) | | `PINECONE_READ_ONLY_MCP_LOG_FORMAT` | No | `text` (`json` for log pipelines) | @@ -102,31 +110,37 @@ Run `pinecone-read-only-mcp --help` for CLI equivalents (`--cache-ttl-seconds`, ### Deployment model -The server uses **process-global** memory for the suggest-flow gate (`suggest_query_params` context), namespaces cache, URL generator registry, and active configuration. **Stdio MCP (one client per Node process)** matches this model. If you embed `setupServer` behind a multi-tenant HTTP transport, isolate those structures per session yourself or treat the suggest-flow guard as best-effort only. +The server uses **process-global** memory for the suggest-flow gate (`suggest_query_params` context), namespaces cache, URL generator registry, and active configuration. **Stdio MCP (one client per Node process)** matches this model. If you embed `setupAllianceServer` behind a multi-tenant HTTP transport, isolate those structures per session yourself or treat the suggest-flow guard as best-effort only. -### Library embedding (`setupServer`) +### Library embedding -Treat **`setupServer()` as one logical server per Node process**: it mutates shared module singletons (suggest-flow map, namespaces cache, URL registry, config context, shared `PineconeClient` slot). A **second** `setupServer()` in the same process **throws** unless you call **`teardownServer()`** first. +Treat **`setupCoreServer()` / `setupAllianceServer()` as one logical server per Node process**: they mutate shared module singletons (suggest-flow map, namespaces cache, URL registry, config context, shared `PineconeClient` slot). A **second** setup call in the same process **throws** unless you call **`teardownServer()`** first. -Recommended pattern: `resolveConfig` → `setPineconeClient(new PineconeClient(...))` → `await setupServer(config)` → connect one MCP transport. For tests or re-initialization in the same process, call `teardownServer()` then `setupServer(config)` again. For isolated production tenants, prefer **one server per Node process** (or separate OS processes) rather than sharing one embedder across tenants. +- **Generic bridge only:** `import { setupCoreServer, teardownServer, ... } from '@will-cppa/pinecone-read-only-mcp'` +- **Full Alliance surface (CLI parity):** `import { setupAllianceServer } from '@will-cppa/pinecone-read-only-mcp/alliance'` -Import `setupServer` and `teardownServer` from `@will-cppa/pinecone-read-only-mcp`. See [examples/library-embedding-demo.ts](examples/library-embedding-demo.ts) and [docs/TOOLS.md](docs/TOOLS.md#suggest-flow-gate). +Recommended pattern: `resolveConfig({ apiKey, indexName, ... })` → `setPineconeClient(new PineconeClient(...))` → `await setupAllianceServer(config)` → connect one MCP transport. See [examples/library-embedding-demo.ts](examples/library-embedding-demo.ts) and [docs/TOOLS.md](docs/TOOLS.md#suggest-flow-gate). ### Custom URL generators Namespaces other than `mailing` and `slack-Cpplang` (or different URL rules for any namespace) can use programmatic registration — no fork required. -Import `registerUrlGenerator` and types `UrlGeneratorFn` / `UrlGenerationResult` from `@will-cppa/pinecone-read-only-mcp`. Register **additional** namespaces before tools that emit URLs run (typically right after `setupServer` resolves config). To **replace** the built-in `mailing` or `slack-Cpplang` generators, call `registerUrlGenerator` **after** `setupServer`, because `setupServer` installs the defaults first. +Import `registerUrlGenerator` and types `UrlGeneratorFn` / `UrlGenerationResult` from `@will-cppa/pinecone-read-only-mcp`. Register **additional** namespaces before tools that emit URLs run. Built-in `mailing` / `slack-Cpplang` generators are installed by `setupAllianceServer` (not by `setupCoreServer`). ```ts import { + PineconeClient, registerUrlGenerator, - setupServer, + resolveConfig, + setPineconeClient, type UrlGenerationResult, type UrlGeneratorFn, } from '@will-cppa/pinecone-read-only-mcp'; +import { setupAllianceServer } from '@will-cppa/pinecone-read-only-mcp/alliance'; -const server = await setupServer(config); +const config = resolveConfig({ apiKey: '...', indexName: 'your-index' }); +setPineconeClient(new PineconeClient({ apiKey: config.apiKey, indexName: config.indexName })); +const server = await setupAllianceServer(config); const myDocs: UrlGeneratorFn = (metadata): UrlGenerationResult => { const id = typeof metadata.doc_id === 'string' ? metadata.doc_id : null; @@ -146,7 +160,7 @@ A fuller embedding sample lives in [examples/custom-url-generator.ts](examples/c | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------ | | [examples/suggest-flow-demo.ts](examples/suggest-flow-demo.ts) | Manual **suggest_query_params → query** flow with namespace consistency notes. | | [examples/guided-query-demo.ts](examples/guided-query-demo.ts) | **guided_query** orchestration and how to read `decision_trace`. | -| [examples/library-embedding-demo.ts](examples/library-embedding-demo.ts) | Programmatic **setupServer** wiring without the CLI binary. | +| [examples/library-embedding-demo.ts](examples/library-embedding-demo.ts) | Programmatic **setupAllianceServer** wiring without the CLI binary. | | [examples/custom-url-generator.ts](examples/custom-url-generator.ts) | Custom **URL generator** registration for `generate_urls` / row enrichment. | Run with `npx tsx examples/.ts` from a checkout (requires valid Pinecone env for live paths). @@ -162,7 +176,9 @@ Add to your `claude_desktop_config.json`: "command": "npx", "args": ["-y", "@will-cppa/pinecone-read-only-mcp"], "env": { - "PINECONE_API_KEY": "your-api-key-here" + "PINECONE_API_KEY": "your-api-key-here", + "PINECONE_INDEX_NAME": "your-index-name" + "PINECONE_RERANK_MODEL": "your-rerank-model" } } } @@ -230,12 +246,14 @@ node node_modules/@will-cppa/pinecone-read-only-mcp/dist/index.js --api-key YOUR ``` --api-key TEXT Pinecone API key (or set PINECONE_API_KEY env var) ---index-name TEXT Pinecone index name [default: rag-hybrid] ---rerank-model TEXT Reranking model [default: bge-reranker-v2-m3] +--index-name TEXT Dense index (required, or PINECONE_INDEX_NAME) +--rerank-model TEXT Reranker model (defalut: bge-reranker-v2-m3) --log-level TEXT Logging level [default: INFO] --help, -h Show help message ``` +Run `pinecone-read-only-mcp --help` for the full option list. + ## Deployment ### Production Readiness Defaults @@ -264,7 +282,7 @@ docker build -t pinecone-read-only-mcp:latest . # run (stdio MCP server) docker run --rm -i \ -e PINECONE_API_KEY=YOUR_API_KEY \ - -e PINECONE_INDEX_NAME=rag-hybrid \ + -e PINECONE_INDEX_NAME=your-index-name \ pinecone-read-only-mcp:latest ``` @@ -436,7 +454,7 @@ Returns the **unique document count** matching a metadata filter and semantic qu ### `keyword_search` -Performs **keyword (lexical/sparse-only)** search over the dedicated sparse index (default: `rag-hybrid-sparse`, i.e. `{PINECONE_INDEX_NAME}-sparse`). Use for exact or keyword-style queries. Does not use the dense index or semantic reranking. Call `list_namespaces` first to discover namespaces; `suggest_query_params` is optional. +Performs **keyword (lexical/sparse-only)** search over the sparse index (`{PINECONE_INDEX_NAME}-sparse` by default). Use for exact or keyword-style queries. Does not use the dense index or semantic reranking. Call `list_namespaces` first to discover namespaces; `suggest_query_params` is optional. **Parameters:** @@ -457,7 +475,7 @@ Performs **keyword (lexical/sparse-only)** search over the dedicated sparse inde "status": "success", "query": "contracts C++", "namespace": "wg21-papers", - "index": "rag-hybrid-sparse", + "index": "your-index-sparse", "result_count": 5, "results": [ { @@ -624,7 +642,7 @@ The script prints a table of p50, p95, and p99 latencies in milliseconds and wri PINECONE_API_KEY=your-key npm run test:search ``` - If the sparse index (`rag-hybrid-sparse` by default) does not exist or has no data, the keyword search step is skipped with a warning. + If the sparse index (`{PINECONE_INDEX_NAME}-sparse`) does not exist or has no data, the keyword search step is skipped with a warning. 2. **Via MCP client:** Start the server and call the `keyword_search` tool with `query_text`, `namespace` (from `list_namespaces`), and optional `top_k` or `metadata_filter`. Response shape is the same as the `query` tool (e.g. `results` with ids, metadata, scores; `reranked` is always `false`). diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index dc15d2d..ae4ff80 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -6,7 +6,7 @@ Configuration is built from **CLI flags** (when using the binary), **environment **CLI / `ConfigOverrides` > environment variables > built-in defaults.** -`resolveConfig` in `src/config.ts` applies this order for each field. +`resolveConfig` in `src/core/config.ts` applies this order for each field. --- @@ -15,9 +15,9 @@ Configuration is built from **CLI flags** (when using the binary), **environment | Field | Source | Default / notes | | ----- | ------ | --------------- | | `apiKey` | `apiKey` / `PINECONE_API_KEY` | **Required** (non-empty after trim) | -| `indexName` | `indexName` / `PINECONE_INDEX_NAME` | `rag-hybrid` | +| `indexName` | `indexName` / `PINECONE_INDEX_NAME` | `rag-hybrid` when env and overrides omit it | | `sparseIndexName` | `sparseIndexName` / `PINECONE_SPARSE_INDEX_NAME` | `{indexName}-sparse` | -| `rerankModel` | `rerankModel` / `PINECONE_RERANK_MODEL` | `bge-reranker-v2-m3` | +| `rerankModel` | `rerankModel` / `PINECONE_RERANK_MODEL` | `bge-reranker-v2-m3` when env and overrides omit it | | `defaultTopK` | `defaultTopK` / `PINECONE_TOP_K` | `10` (positive int) | | `logLevel` | `logLevel` / `PINECONE_READ_ONLY_MCP_LOG_LEVEL` | `INFO` (`DEBUG`–`ERROR`) | | `logFormat` | `logFormat` / `PINECONE_READ_ONLY_MCP_LOG_FORMAT` | `text` or `json` | @@ -28,6 +28,12 @@ Configuration is built from **CLI flags** (when using the binary), **environment **Throws** if `apiKey` is missing after trim. +For the full Alliance tool surface (including `suggest_query_params`, `guided_query`, and built-in URL generators), import from `@will-cppa/pinecone-read-only-mcp/alliance` and call `setupAllianceServer(config)`. + +### Rerank model + +`resolveConfig` uses `PINECONE_INDEX_NAME` / `PINECONE_RERANK_MODEL` when set; otherwise **`rag-hybrid`** and **`bge-reranker-v2-m3`**. MCP configs that only set `PINECONE_API_KEY` keep the same defaults as before. + --- ## CLI flags (`parseCli` / `src/cli.ts`) @@ -52,9 +58,9 @@ Configuration is built from **CLI flags** (when using the binary), **environment ## Library embedding -1. Build `ServerConfig` with `resolveConfig({ apiKey: '...', ... })` or pass explicit overrides. -2. Construct `PineconeClient` and `setPineconeClient(client)` before `setupServer(config)` (mirrors `src/index.ts`). -3. `await setupServer(config)` then connect an MCP transport. +1. Build `ServerConfig` with `resolveConfig({ apiKey: '...', indexName: '...', ... })` or pass explicit overrides. +2. Construct `PineconeClient` and `setPineconeClient(client)` before `setupAllianceServer(config)` (mirrors `src/index.ts`). +3. `await setupAllianceServer(config)` (or `setupCoreServer` for generic tools only) then connect an MCP transport. See [README deployment model](../README.md#deployment-model) and [examples/library-embedding-demo.ts](../examples/library-embedding-demo.ts). diff --git a/examples/README.md b/examples/README.md index c595b3f..8d81459 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,10 +1,12 @@ # Examples -| File | Description | -|------|-------------| -| [custom-url-generator.ts](./custom-url-generator.ts) | Embed the MCP server as a library, register a **custom URL generator** for a namespace, and wire `PineconeClient` + `setupServer()`. | -| [suggest-flow-demo.ts](./suggest-flow-demo.ts) | Document the **suggest_query_params → query** gate sequence and trimmed namespace usage. | -| [guided-query-demo.ts](./guided-query-demo.ts) | Document **guided_query** and the **`decision_trace`** payload. | -| [library-embedding-demo.ts](./library-embedding-demo.ts) | Minimal **library embedding** (`resolveConfig`, `setPineconeClient`, `setupServer`). | +| File | Description | +| -------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | +| [custom-url-generator.ts](./custom-url-generator.ts) | Embed the MCP server, register a **custom URL generator**, and call `setupAllianceServer()` (full tool surface). | +| [suggest-flow-demo.ts](./suggest-flow-demo.ts) | Document the **suggest_query_params → query** gate sequence and trimmed namespace usage. | +| [guided-query-demo.ts](./guided-query-demo.ts) | Document **guided_query** and the **`decision_trace`** payload. | +| [library-embedding-demo.ts](./library-embedding-demo.ts) | Minimal **library embedding** (`resolveConfig`, `setPineconeClient`, `setupAllianceServer`). | -Run with `npx tsx examples/.ts` after `npm install` (live Pinecone calls need `PINECONE_API_KEY` and related env). +**Required env for live runs:** `PINECONE_API_KEY`. Optional: `PINECONE_INDEX_NAME` (default `rag-hybrid`), `PINECONE_RERANK_MODEL` (default `bge-reranker-v2-m3`). + +Run with `npm run build` then `npx tsx examples/.ts` from the repo root (examples resolve the package via `dist/core` and `dist/alliance`). diff --git a/examples/custom-url-generator.ts b/examples/custom-url-generator.ts index f461d4b..859f450 100644 --- a/examples/custom-url-generator.ts +++ b/examples/custom-url-generator.ts @@ -3,18 +3,16 @@ * * The Pinecone Read-Only MCP exposes a per-namespace URL registry so library * consumers can synthesize URLs from metadata when records do not already - * carry a `url` field. Built-ins cover `mailing` (Boost archives) and - * `slack-Cpplang`; everything else is up to you. + * carry a `url` field. Built-in `mailing` / `slack-Cpplang` generators are + * registered by `setupAllianceServer` (Alliance layer); everything else is up to you. * * Usage: - * 1. Import { registerUrlGenerator, setupServer, ... } from the package. - * 2. Call registerUrlGenerator(namespace, fn) BEFORE setupServer(config) so - * the registry is populated when the server registers tools. - * 3. The generate_urls tool, plus query/keyword_search/guided_query rows, - * will pick up the generator automatically. + * 1. Import `registerUrlGenerator` from `@will-cppa/pinecone-read-only-mcp`. + * 2. Import `setupAllianceServer` from `@will-cppa/pinecone-read-only-mcp/alliance`. + * 3. Call `registerUrlGenerator(namespace, fn)` before `setupAllianceServer(config)`. + * 4. The generate_urls tool and query row enrichment use the registry automatically. * - * Run from a project that depends on the package, or replace the import - * with a relative path when running inside this repo. + * Run from a project that depends on the package, or use this repo after `npm run build`. */ import { @@ -22,12 +20,10 @@ import { registerUrlGenerator, resolveConfig, setPineconeClient, - setupServer, type UrlGenerationResult, } from '@will-cppa/pinecone-read-only-mcp'; +import { setupAllianceServer } from '@will-cppa/pinecone-read-only-mcp/alliance'; -// Example domain: a documentation index whose chunks carry { product, slug } -// metadata. We turn that into https://docs.example.com/{product}/{slug}. registerUrlGenerator('product-docs', (metadata): UrlGenerationResult => { const product = typeof metadata['product'] === 'string' ? metadata['product'] : null; const slug = typeof metadata['slug'] === 'string' ? metadata['slug'] : null; @@ -45,7 +41,16 @@ registerUrlGenerator('product-docs', (metadata): UrlGenerationResult => { }); async function main(): Promise { - const config = resolveConfig({ apiKey: process.env['PINECONE_API_KEY'] ?? 'demo-key' }); + const apiKey = process.env['PINECONE_API_KEY']?.trim(); + const indexName = process.env['PINECONE_INDEX_NAME']?.trim(); + if (!apiKey || !indexName) { + console.log( + '[custom-url-generator] Set PINECONE_API_KEY and PINECONE_INDEX_NAME to run live.' + ); + return; + } + const config = resolveConfig({ apiKey, indexName }); + setPineconeClient( new PineconeClient({ apiKey: config.apiKey, @@ -56,10 +61,7 @@ async function main(): Promise { }) ); - const server = await setupServer(config); - // `server` is ready to connect to a transport. The generate_urls tool - // (and any query result enrichment) will route `product-docs` records - // through the generator registered above. + const server = await setupAllianceServer(config); void server; console.log('Custom URL generator registered for namespace "product-docs".'); } diff --git a/examples/demo-mock-pinecone-client.ts b/examples/demo-mock-pinecone-client.ts index 9592062..b2b0939 100644 --- a/examples/demo-mock-pinecone-client.ts +++ b/examples/demo-mock-pinecone-client.ts @@ -33,7 +33,7 @@ const demoHit: SearchResult = { export class DemoMockPineconeClient extends PineconeClient { constructor() { - super({ apiKey: '00000000-0000-0000-0000-000000000000' }); + super({ apiKey: '00000000-0000-0000-0000-000000000000', indexName: 'demo-index' }); } override async listNamespacesWithMetadata(): Promise< diff --git a/examples/guided-query-demo.ts b/examples/guided-query-demo.ts index f95e3f2..d0e96dd 100644 --- a/examples/guided-query-demo.ts +++ b/examples/guided-query-demo.ts @@ -10,7 +10,7 @@ * selected namespace, suggested fields/tools, and the final `selected_tool`. * Use the trace in UIs or logs to explain why a path was chosen. * - * **Reranking fidelity:** when reranking was expected, inspect each row's + * **Reranking fidelity:** when a rerank model is configured, inspect each row's * `reranked` boolean; `false` can indicate rerank was skipped or failed while * still returning HTTP/MCP success (see docs/TOOLS.md). */ @@ -19,20 +19,21 @@ import { PineconeClient, resolveConfig, setPineconeClient, - setupServer, } from '@will-cppa/pinecone-read-only-mcp'; +import { setupAllianceServer } from '@will-cppa/pinecone-read-only-mcp/alliance'; async function main(): Promise { const apiKey = process.env['PINECONE_API_KEY']?.trim(); - if (!apiKey) { + const indexName = process.env['PINECONE_INDEX_NAME']?.trim(); + if (!apiKey || !indexName) { console.log( - '[guided-query-demo] Set PINECONE_API_KEY to run live. ' + + '[guided-query-demo] Set PINECONE_API_KEY and PINECONE_INDEX_NAME to run live. ' + 'Call guided_query with user_query; read decision_trace + result in the JSON response.' ); return; } - const config = resolveConfig({ apiKey }); + const config = resolveConfig({ apiKey, indexName }); setPineconeClient( new PineconeClient({ apiKey: config.apiKey, @@ -44,7 +45,7 @@ async function main(): Promise { }) ); - const server = await setupServer(config); + const server = await setupAllianceServer(config); void server; console.log('Server ready — call guided_query({ user_query, preferred_tool?: "auto" }).'); } diff --git a/examples/library-embedding-demo.ts b/examples/library-embedding-demo.ts index 1c60edc..e0c2146 100644 --- a/examples/library-embedding-demo.ts +++ b/examples/library-embedding-demo.ts @@ -2,13 +2,13 @@ * Library embedding: build the MCP server from a Node script (not the CLI). * * Pattern (mirrors `src/index.ts`): - * 1. `resolveConfig({ apiKey, ... })` — never rely on ambient env alone in libraries. + * 1. `resolveConfig({ apiKey, indexName, ... })` — env rerank model or default bge-reranker-v2-m3. * 2. `new PineconeClient({ ... })` + `setPineconeClient(client)`. - * 3. `await setupServer(config)` then `server.connect(transport)`. + * 3. `await setupAllianceServer(config)` then `server.connect(transport)`. * - * **Single process:** `setupServer` registers tools against process-global + * **Single process:** `setupAllianceServer` registers tools against process-global * singletons (suggest-flow state, namespaces cache, URL registry, active config). - * A second `setupServer` throws — call `teardownServer()` first to re-initialize + * A second setup call throws — call `teardownServer()` first to re-initialize * (tests). For isolated tenants in production, prefer one server per Node process. */ @@ -16,8 +16,8 @@ import { PineconeClient, resolveConfig, setPineconeClient, - setupServer, } from '@will-cppa/pinecone-read-only-mcp'; +import { setupAllianceServer } from '@will-cppa/pinecone-read-only-mcp/alliance'; async function main(): Promise { const apiKey = process.env['PINECONE_API_KEY']?.trim(); @@ -27,7 +27,12 @@ async function main(): Promise { ); return; } - const config = resolveConfig({ apiKey }); + const indexName = process.env['PINECONE_INDEX_NAME']?.trim(); + if (!indexName) { + console.log('Set PINECONE_INDEX_NAME to run this example. Skipping live setup in doc-only mode.'); + return; + } + const config = resolveConfig({ apiKey, indexName }); setPineconeClient( new PineconeClient({ @@ -40,7 +45,7 @@ async function main(): Promise { }) ); - const server = await setupServer(config); + const server = await setupAllianceServer(config); // const transport = new StdioServerTransport(); // await server.connect(transport); void server; diff --git a/examples/suggest-flow-demo.ts b/examples/suggest-flow-demo.ts index 24ce76d..e7f76e4 100644 --- a/examples/suggest-flow-demo.ts +++ b/examples/suggest-flow-demo.ts @@ -13,27 +13,28 @@ * optional `fields` from `suggested_fields`. * * This file is runnable without Pinecone only in **documentation mode**; set - * `PINECONE_API_KEY` and wire an MCP transport to execute real tool calls. + * `PINECONE_API_KEY` and `PINECONE_INDEX_NAME`, then wire an MCP transport. */ import { PineconeClient, resolveConfig, setPineconeClient, - setupServer, } from '@will-cppa/pinecone-read-only-mcp'; +import { setupAllianceServer } from '@will-cppa/pinecone-read-only-mcp/alliance'; async function main(): Promise { const apiKey = process.env['PINECONE_API_KEY']?.trim(); - if (!apiKey) { + const indexName = process.env['PINECONE_INDEX_NAME']?.trim(); + if (!apiKey || !indexName) { console.log( - '[suggest-flow-demo] Set PINECONE_API_KEY to run against Pinecone. ' + + '[suggest-flow-demo] Set PINECONE_API_KEY and PINECONE_INDEX_NAME to run against Pinecone. ' + 'Flow: list_namespaces → suggest_query_params → query (same trimmed namespace).' ); return; } - const config = resolveConfig({ apiKey }); + const config = resolveConfig({ apiKey, indexName }); setPineconeClient( new PineconeClient({ apiKey: config.apiKey, @@ -45,7 +46,7 @@ async function main(): Promise { }) ); - const server = await setupServer(config); + const server = await setupAllianceServer(config); // With an MCP client connected to `server`, invoke tools in order: // 1) suggest_query_params({ namespace: "mailing".trim(), user_query: "..." }) // 2) query({ query_text, namespace: "mailing", preset: "detailed", ... }) diff --git a/examples/tsconfig.json b/examples/tsconfig.json index 0c20ccd..f20f454 100644 --- a/examples/tsconfig.json +++ b/examples/tsconfig.json @@ -4,11 +4,13 @@ "module": "Node16", "moduleResolution": "Node16", "strict": true, + "ignoreDeprecations": "6.0", "noEmit": true, "skipLibCheck": true, - "baseUrl": ".", + "types": ["node"], "paths": { - "@will-cppa/pinecone-read-only-mcp": ["../dist/server.js"] + "@will-cppa/pinecone-read-only-mcp": ["../src/core/index.ts"], + "@will-cppa/pinecone-read-only-mcp/alliance": ["../src/alliance/index.ts"] } }, "include": ["./**/*.ts"] diff --git a/package.json b/package.json index adb61f0..576e933 100644 --- a/package.json +++ b/package.json @@ -3,13 +3,17 @@ "version": "0.1.6", "description": "A Model Context Protocol (MCP) server that provides semantic search over Pinecone vector databases using hybrid search (dense + sparse) with reranking.", "type": "module", - "main": "dist/server.js", - "module": "dist/server.js", - "types": "dist/server.d.ts", + "main": "dist/core/index.js", + "module": "dist/core/index.js", + "types": "dist/core/index.d.ts", "exports": { ".": { - "types": "./dist/server.d.ts", - "import": "./dist/server.js" + "types": "./dist/core/index.d.ts", + "import": "./dist/core/index.js" + }, + "./alliance": { + "types": "./dist/alliance/index.d.ts", + "import": "./dist/alliance/index.js" }, "./package.json": "./package.json" }, diff --git a/scripts/test-search.ts b/scripts/test-search.ts index 449f78c..0294b2b 100644 --- a/scripts/test-search.ts +++ b/scripts/test-search.ts @@ -167,7 +167,7 @@ async function test() { `⚠️ Keyword search skipped: sparse index has no namespaces (or index unavailable).` ); console.log( - ` Ensure the sparse index (e.g. rag-hybrid-sparse) exists and has data.` + ` Ensure the sparse index (PINECONE_INDEX_NAME-sparse) exists and has data.` ); } else { const sparseTestNamespace = sparseNamespaces[0].namespace; diff --git a/src/alliance/config.test.ts b/src/alliance/config.test.ts new file mode 100644 index 0000000..098184f --- /dev/null +++ b/src/alliance/config.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; +import { DEFAULT_RERANK_MODEL } from '../core/config.js'; +import { resolveAllianceConfig } from './config.js'; + +describe('resolveAllianceConfig', () => { + it('applies Alliance rerank default when env and overrides omit rerankModel', () => { + const cfg = resolveAllianceConfig( + { apiKey: 'sk-test', indexName: 'my-index' }, + { PINECONE_API_KEY: 'sk-test', PINECONE_INDEX_NAME: 'my-index' } + ); + expect(cfg.rerankModel).toBe(DEFAULT_RERANK_MODEL); + }); + + it('preserves explicit rerankModel from overrides', () => { + const cfg = resolveAllianceConfig({ + apiKey: 'sk-test', + indexName: 'my-index', + rerankModel: 'custom-reranker', + }); + expect(cfg.rerankModel).toBe('custom-reranker'); + }); + + it('preserves rerankModel from env over Alliance default', () => { + const cfg = resolveAllianceConfig( + { apiKey: 'sk-test', indexName: 'my-index' }, + { + PINECONE_API_KEY: 'sk-test', + PINECONE_INDEX_NAME: 'my-index', + PINECONE_RERANK_MODEL: 'env-reranker', + } + ); + expect(cfg.rerankModel).toBe('env-reranker'); + }); +}); diff --git a/src/alliance/config.ts b/src/alliance/config.ts new file mode 100644 index 0000000..d659bda --- /dev/null +++ b/src/alliance/config.ts @@ -0,0 +1,27 @@ +/** + * Alliance entry re-exports core config. Rerank default lives in {@link resolveConfig} + * (`PINECONE_RERANK_MODEL` when set, else `bge-reranker-v2-m3`). + */ + +import { + DEFAULT_RERANK_MODEL, + resolveConfig, + type ConfigOverrides, + type ServerConfig, +} from '../core/config.js'; + +/** @deprecated Use {@link DEFAULT_RERANK_MODEL} from core config. */ +export const DEFAULT_ALLIANCE_RERANK_MODEL = DEFAULT_RERANK_MODEL; + +/** @deprecated No-op; {@link resolveConfig} already applies the rerank default. */ +export function applyAllianceRerankDefault(config: ServerConfig): ServerConfig { + return config; +} + +/** Alias for {@link resolveConfig} (Alliance CLI and `setupAllianceServer`). */ +export function resolveAllianceConfig( + overrides: ConfigOverrides = {}, + env: NodeJS.ProcessEnv = process.env +): ServerConfig { + return resolveConfig(overrides, env); +} diff --git a/src/alliance/index.ts b/src/alliance/index.ts new file mode 100644 index 0000000..659b47c --- /dev/null +++ b/src/alliance/index.ts @@ -0,0 +1,18 @@ +/** + * @packageDocumentation + * **@will-cppa/pinecone-read-only-mcp/alliance** — full server including Alliance app tools. + */ + +export * from '../core/index.js'; +export { + applyAllianceRerankDefault, + DEFAULT_ALLIANCE_RERANK_MODEL, + resolveAllianceConfig, +} from './config.js'; +export { setupAllianceServer } from './setup.js'; +export { + registerBuiltinUrlGenerators, + generatorMailing, + generatorSlackCpplang, +} from './url-builtins.js'; +export type { RegisterBuiltinUrlGeneratorsOptions } from './url-builtins.js'; diff --git a/src/alliance/setup.ts b/src/alliance/setup.ts new file mode 100644 index 0000000..e470ba1 --- /dev/null +++ b/src/alliance/setup.ts @@ -0,0 +1,21 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { ServerConfig } from '../core/config.js'; +import { resolveConfig } from '../core/config.js'; +import { setupCoreServer } from '../core/setup.js'; +import { registerBuiltinUrlGenerators } from './url-builtins.js'; +import { registerGuidedQueryTool } from './tools/guided-query-tool.js'; +import { registerSuggestQueryParamsTool } from './tools/suggest-query-params-tool.js'; + +/** + * Create and configure the MCP server with the full Alliance tool surface: + * all core tools plus `suggest_query_params`, `guided_query`, and built-in URL generators. + * + * When `config` is omitted, resolves env via {@link resolveConfig} (rerank: env or default). + */ +export async function setupAllianceServer(config?: ServerConfig): Promise { + const server = await setupCoreServer(config ?? resolveConfig({})); + registerBuiltinUrlGenerators(); + registerSuggestQueryParamsTool(server); + registerGuidedQueryTool(server); + return server; +} diff --git a/src/server/tools/guided-query-tool.test.ts b/src/alliance/tools/guided-query-tool.test.ts similarity index 81% rename from src/server/tools/guided-query-tool.test.ts rename to src/alliance/tools/guided-query-tool.test.ts index b1e8073..bcc5b68 100644 --- a/src/server/tools/guided-query-tool.test.ts +++ b/src/alliance/tools/guided-query-tool.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { getPineconeClient } from '../client-context.js'; -import { getNamespacesWithCache } from '../namespaces-cache.js'; +import { getPineconeClient } from '../../core/server/client-context.js'; +import { getNamespacesWithCache } from '../../core/server/namespaces-cache.js'; import { registerGuidedQueryTool } from './guided-query-tool.js'; import { assertToolErrorCode, @@ -9,18 +9,18 @@ import { makeNamespaceCacheEntry, makeSearchResult, parseToolJson, -} from './test-helpers.js'; +} from '../../core/server/tools/test-helpers.js'; -vi.mock('../client-context.js', () => ({ +vi.mock('../../core/server/client-context.js', () => ({ getPineconeClient: vi.fn(), })); -vi.mock('../namespaces-cache.js', () => ({ +vi.mock('../../core/server/namespaces-cache.js', () => ({ getNamespacesWithCache: vi.fn(), })); /** Real `markSuggested` may call `getServerConfig()` during sweep (CI has no API key); isolate the handler. */ -vi.mock('../suggestion-flow.js', () => ({ +vi.mock('../../core/server/suggestion-flow.js', () => ({ markSuggested: vi.fn(), })); @@ -81,6 +81,32 @@ describe('guided_query tool handler', () => { expect(result.degradation_reason).toBe('rerank_failed: timeout'); }); + it('guided_query: reports skipped_no_model when rerank was requested but no model configured', async () => { + mockedGetClient.mockReturnValue({ + query: vi.fn().mockResolvedValue( + makeHybridQueryResult({ + rerank_skipped_reason: 'no_model', + degradation_reason: 'rerank_skipped_no_model: set PINECONE_RERANK_MODEL', + }) + ), + count: vi.fn().mockResolvedValue({ count: 7, truncated: false }), + } as never); + + const server = createMockServer(); + registerGuidedQueryTool(server as never); + + const body = parseToolJson( + await server.getHandler('guided_query')!({ + user_query: 'What does the paper say about contracts?', + namespace: 'papers', + preferred_tool: 'detailed', + }) + ); + + const trace = body.decision_trace as Record; + expect(trace.rerank_status).toBe('skipped_no_model'); + }); + it('runs query_detailed path on auto when user asks for content', async () => { const server = createMockServer(); registerGuidedQueryTool(server as never); diff --git a/src/server/tools/guided-query-tool.ts b/src/alliance/tools/guided-query-tool.ts similarity index 90% rename from src/server/tools/guided-query-tool.ts rename to src/alliance/tools/guided-query-tool.ts index a127a04..ab3ff9b 100644 --- a/src/server/tools/guided-query-tool.ts +++ b/src/alliance/tools/guided-query-tool.ts @@ -1,22 +1,26 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; +import { guidedRerankStatus } from '../../core/rerank-trace.js'; import { FAST_QUERY_FIELDS, MAX_TOP_K, MIN_TOP_K } from '../../constants.js'; import type { QueryResponse } from '../../types.js'; -import { getPineconeClient } from '../client-context.js'; -import { formatQueryResultRows } from '../format-query-result.js'; -import { metadataFilterSchema, validateMetadataFilterDetailed } from '../metadata-filter.js'; -import { rankNamespacesByQuery } from '../namespace-router.js'; -import { getNamespacesWithCache } from '../namespaces-cache.js'; -import { normalizeNamespace } from '../namespace-utils.js'; -import { suggestQueryParams } from '../query-suggestion.js'; -import { markSuggested } from '../suggestion-flow.js'; +import { getPineconeClient } from '../../core/server/client-context.js'; +import { formatQueryResultRows } from '../../core/server/format-query-result.js'; +import { + metadataFilterSchema, + validateMetadataFilterDetailed, +} from '../../core/server/metadata-filter.js'; +import { rankNamespacesByQuery } from '../../core/server/namespace-router.js'; +import { getNamespacesWithCache } from '../../core/server/namespaces-cache.js'; +import { normalizeNamespace } from '../../core/server/namespace-utils.js'; +import { suggestQueryParams } from '../../core/server/query-suggestion.js'; +import { markSuggested } from '../../core/server/suggestion-flow.js'; import { classifyToolCatchError, logToolError, pineconeToolError, validationToolError, -} from '../tool-error.js'; -import { jsonErrorResponse, jsonResponse } from '../tool-response.js'; +} from '../../core/server/tool-error.js'; +import { jsonErrorResponse, jsonResponse } from '../../core/server/tool-response.js'; type GuidedToolName = 'count' | 'fast' | 'detailed' | 'full'; @@ -210,11 +214,7 @@ export function registerGuidedQueryTool(server: McpServer): void { useReranking: !isFast, fields: fields?.length ? fields : undefined, }); - const rerank_status: 'success' | 'skipped' | 'failed' = isFast - ? 'skipped' - : queryOutcome.degraded && queryOutcome.hybrid_leg_failed === null - ? 'failed' - : 'success'; + const rerank_status = guidedRerankStatus(!isFast, queryOutcome); const formattedResults = formatQueryResultRows(queryOutcome.results, { namespace, enrichUrls: enrich_urls, diff --git a/src/server/tools/suggest-query-params-tool.test.ts b/src/alliance/tools/suggest-query-params-tool.test.ts similarity index 93% rename from src/server/tools/suggest-query-params-tool.test.ts rename to src/alliance/tools/suggest-query-params-tool.test.ts index 5882a55..9f7035c 100644 --- a/src/server/tools/suggest-query-params-tool.test.ts +++ b/src/alliance/tools/suggest-query-params-tool.test.ts @@ -1,19 +1,19 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { getNamespacesWithCache } from '../namespaces-cache.js'; -import { markSuggested } from '../suggestion-flow.js'; +import { getNamespacesWithCache } from '../../core/server/namespaces-cache.js'; +import { markSuggested } from '../../core/server/suggestion-flow.js'; import { registerSuggestQueryParamsTool } from './suggest-query-params-tool.js'; import { createMockServer, makeNamespaceCacheEntry, parseToolJson, assertToolErrorCode, -} from './test-helpers.js'; +} from '../../core/server/tools/test-helpers.js'; -vi.mock('../namespaces-cache.js', () => ({ +vi.mock('../../core/server/namespaces-cache.js', () => ({ getNamespacesWithCache: vi.fn(), })); -vi.mock('../suggestion-flow.js', () => ({ +vi.mock('../../core/server/suggestion-flow.js', () => ({ markSuggested: vi.fn(), })); diff --git a/src/server/tools/suggest-query-params-tool.ts b/src/alliance/tools/suggest-query-params-tool.ts similarity index 85% rename from src/server/tools/suggest-query-params-tool.ts rename to src/alliance/tools/suggest-query-params-tool.ts index 461a40e..1c21232 100644 --- a/src/server/tools/suggest-query-params-tool.ts +++ b/src/alliance/tools/suggest-query-params-tool.ts @@ -1,11 +1,15 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; -import { normalizeNamespace } from '../namespace-utils.js'; -import { getNamespacesWithCache } from '../namespaces-cache.js'; -import { suggestQueryParams } from '../query-suggestion.js'; -import { markSuggested } from '../suggestion-flow.js'; -import { classifyToolCatchError, logToolError, validationToolError } from '../tool-error.js'; -import { jsonErrorResponse, jsonResponse } from '../tool-response.js'; +import { normalizeNamespace } from '../../core/server/namespace-utils.js'; +import { getNamespacesWithCache } from '../../core/server/namespaces-cache.js'; +import { suggestQueryParams } from '../../core/server/query-suggestion.js'; +import { markSuggested } from '../../core/server/suggestion-flow.js'; +import { + classifyToolCatchError, + logToolError, + validationToolError, +} from '../../core/server/tool-error.js'; +import { jsonErrorResponse, jsonResponse } from '../../core/server/tool-response.js'; /** Register the suggest_query_params tool on the MCP server. */ export function registerSuggestQueryParamsTool(server: McpServer): void { diff --git a/src/alliance/url-builtins.test.ts b/src/alliance/url-builtins.test.ts new file mode 100644 index 0000000..c4eea94 --- /dev/null +++ b/src/alliance/url-builtins.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it, beforeAll, afterEach } from 'vitest'; +import { generateUrlForNamespace } from '../core/server/url-registry.js'; +import { registerBuiltinUrlGenerators } from './url-builtins.js'; +import { registerUrlGenerator, unregisterUrlGenerator } from '../core/server/url-registry.js'; + +beforeAll(() => { + registerBuiltinUrlGenerators(); +}); + +describe('generateUrlForNamespace (alliance builtins)', () => { + it('generates mailing URL from doc_id', () => { + const r = generateUrlForNamespace('mailing', { + doc_id: 'boost-announce@lists.boost.org/message/O5VYCDZADVDHK5Z5LAYJBHMDOAFQL7P6', + }); + expect(r.url).toBe( + 'https://lists.boost.org/archives/list/boost-announce@lists.boost.org/message/O5VYCDZADVDHK5Z5LAYJBHMDOAFQL7P6/' + ); + expect(r.method).toBe('generated.mailing'); + }); + + it('generates slack URL from team/channel/doc_id', () => { + const r = generateUrlForNamespace('slack-Cpplang', { + team_id: 'T123456789', + channel_id: 'C123456', + doc_id: '1234567.890', + }); + expect(r.url).toBe('https://app.slack.com/client/T123456789/C123456/p1234567890'); + expect(r.method).toBe('generated.slack'); + }); +}); + +describe('registerBuiltinUrlGenerators', () => { + const customNs = 'acme-docs'; + + afterEach(() => { + unregisterUrlGenerator(customNs); + registerBuiltinUrlGenerators({ reinstallBuiltins: true }); + }); + + it('allows a custom generator to override the mailing built-in', () => { + registerUrlGenerator('mailing', () => ({ + url: 'https://override.example/mailing', + method: 'generated.custom', + })); + const r = generateUrlForNamespace('mailing', { + doc_id: 'boost-announce@lists.boost.org/message/O5VYCDZADVDHK5Z5LAYJBHMDOAFQL7P6', + }); + expect(r.url).toBe('https://override.example/mailing'); + expect(r.method).toBe('generated.custom'); + }); +}); diff --git a/src/alliance/url-builtins.ts b/src/alliance/url-builtins.ts new file mode 100644 index 0000000..ae2ea13 --- /dev/null +++ b/src/alliance/url-builtins.ts @@ -0,0 +1,90 @@ +/** + * C++ Alliance domain-specific URL generators (Boost mailing list, Slack). + */ + +import { registerUrlGenerator } from '../core/server/url-registry.js'; +import type { UrlGenerationResult } from '../core/server/url-registry.js'; + +/** 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; +} + +/** + * Build a mailing-list URL (e.g. Boost archives). + */ +export function generatorMailing(metadata: Record): UrlGenerationResult { + const listName = asString(metadata['list_name']); + const docId = asString(metadata['doc_id']) ?? asString(metadata['msg_id']); + const threadId = asString(metadata['thread_id']); + + if (listName && docId && !docId.includes(listName)) { + return { + url: `https://lists.boost.org/archives/list/${listName}/message/${docId}/`, + method: 'generated.mailing', + }; + } + + const docIdOrThread = docId ?? threadId; + if (!docIdOrThread) { + return { + url: null, + method: 'unavailable', + reason: 'mailing requires doc_id, msg_id, or thread_id to generate URL', + }; + } + return { + url: `https://lists.boost.org/archives/list/${docIdOrThread}/`, + method: 'generated.mailing', + }; +} + +/** Build a Slack message URL from source or team_id/channel_id/doc_id. */ +export function generatorSlackCpplang(metadata: Record): UrlGenerationResult { + const source = asString(metadata['source']); + if (source) { + return { url: source, method: 'metadata.source' }; + } + const teamId = asString(metadata['team_id']); + const channelId = asString(metadata['channel_id']); + const docId = asString(metadata['doc_id']); + if (!teamId || !channelId || !docId) { + return { + url: null, + method: 'unavailable', + reason: 'slack-Cpplang requires team_id, channel_id, and doc_id (or source)', + }; + } + const messageId = docId.replace(/\./g, ''); + return { + url: `https://app.slack.com/client/${teamId}/${channelId}/p${messageId}`, + method: 'generated.slack', + }; +} + +let builtinGeneratorsRegistered = false; + +/** Options for {@link registerBuiltinUrlGenerators}. */ +export type RegisterBuiltinUrlGeneratorsOptions = { + /** + * When `true`, re-applies the built-in `mailing` and `slack-Cpplang` generators + * even if they were already registered or were replaced by {@link registerUrlGenerator}. + */ + reinstallBuiltins?: boolean; +}; + +/** + * Register built-in Alliance generators (`mailing`, `slack-Cpplang`). + */ +export function registerBuiltinUrlGenerators(options?: RegisterBuiltinUrlGeneratorsOptions): void { + if (options?.reinstallBuiltins) { + registerUrlGenerator('mailing', generatorMailing); + registerUrlGenerator('slack-Cpplang', generatorSlackCpplang); + builtinGeneratorsRegistered = true; + return; + } + if (builtinGeneratorsRegistered) return; + registerUrlGenerator('mailing', generatorMailing); + registerUrlGenerator('slack-Cpplang', generatorSlackCpplang); + builtinGeneratorsRegistered = true; +} diff --git a/src/cli.ts b/src/cli.ts index 6123a23..e5eedb6 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -3,8 +3,8 @@ * for {@link resolveConfig}; environment variables remain the fallback there. */ -import { DEFAULT_INDEX_NAME, DEFAULT_RERANK_MODEL, SERVER_VERSION } from './constants.js'; -import type { ConfigOverrides } from './config.js'; +import { SERVER_VERSION } from './constants.js'; +import type { ConfigOverrides } from './core/config.js'; export type ParseCliResult = | { kind: 'help' } @@ -136,9 +136,9 @@ Usage: pinecone-read-only-mcp [options] Options: --api-key TEXT Pinecone API key (or PINECONE_API_KEY) - --index-name TEXT Dense index [default: ${DEFAULT_INDEX_NAME}] + --index-name TEXT Dense index (default: rag-hybrid, or PINECONE_INDEX_NAME) --sparse-index-name TEXT Sparse index [default: {index-name}-sparse] - --rerank-model TEXT Reranker model [default: ${DEFAULT_RERANK_MODEL}] + --rerank-model TEXT Reranker model (default: bge-reranker-v2-m3, or PINECONE_RERANK_MODEL) --top-k N Default top-k for queries [env: PINECONE_TOP_K] --log-level LEVEL DEBUG | INFO | WARN | ERROR [default: INFO] --log-format FORMAT text | json [default: text] diff --git a/src/constants.ts b/src/constants.ts index 449e536..fad6ffd 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -2,8 +2,6 @@ * Constants for Pinecone Read-Only MCP */ -export const DEFAULT_INDEX_NAME = 'rag-hybrid'; -export const DEFAULT_RERANK_MODEL = 'bge-reranker-v2-m3'; export const DEFAULT_TOP_K = 10; export const MAX_TOP_K = 100; export const MIN_TOP_K = 1; @@ -40,13 +38,13 @@ A semantic search server that provides hybrid search capabilities over Pinecone Features: - Hybrid Search: Combines dense and sparse embeddings for superior recall -- Semantic Reranking: Uses a configurable reranker model (default bge-reranker-v2-m3) for improved precision +- Semantic Reranking: Uses a configurable reranker model when PINECONE_RERANK_MODEL is set; disabled when unset - Dynamic Namespace Discovery: Automatically discovers available namespaces - Metadata Filtering: Supports optional metadata filters for refined searches - Namespace Router: Suggests likely namespace(s) from natural-language intent - Count: Use the count tool for "how many X?" questions; it uses semantic search only and minimal fields (no content) for performance, returning unique document count. - URL Generation: Use generate_urls to synthesize URLs for namespaces that have a registered generator when metadata lacks url. -- Document reassembly: Use query_documents to get whole documents (chunks grouped and merged by document_number/doc_id/url) for content analysis or summarization. query_documents always reranks for best document-level relevance. +- Document reassembly: Use query_documents to get whole documents (chunks grouped and merged by document_number/doc_id/url) for content analysis or summarization. query_documents reranks when a rerank model is configured. - Keyword search: Use keyword_search to query the sparse index for lexical/keyword-only retrieval without reranking. Usage: diff --git a/src/core/config.test.ts b/src/core/config.test.ts new file mode 100644 index 0000000..d6da729 --- /dev/null +++ b/src/core/config.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest'; +import { DEFAULT_INDEX_NAME, DEFAULT_RERANK_MODEL, resolveConfig } from './config.js'; + +describe('resolveConfig', () => { + it('throws when API key is missing', () => { + expect(() => resolveConfig({}, { PINECONE_API_KEY: '' })).toThrow(/Missing Pinecone API key/); + }); + + it('uses DEFAULT_INDEX_NAME when env and overrides omit indexName', () => { + const cfg = resolveConfig({ apiKey: 'sk-test' }, { PINECONE_API_KEY: 'sk-test' }); + expect(cfg.indexName).toBe(DEFAULT_INDEX_NAME); + expect(cfg.sparseIndexName).toBe(`${DEFAULT_INDEX_NAME}-sparse`); + }); + + it('uses PINECONE_INDEX_NAME from env when set', () => { + const cfg = resolveConfig( + { apiKey: 'sk-test' }, + { PINECONE_API_KEY: 'sk-test', PINECONE_INDEX_NAME: 'my-index' } + ); + expect(cfg.indexName).toBe('my-index'); + expect(cfg.sparseIndexName).toBe('my-index-sparse'); + }); + + it('uses DEFAULT_INDEX_NAME when env index is empty after trim', () => { + const cfg = resolveConfig( + { apiKey: 'sk-test' }, + { PINECONE_API_KEY: 'sk-test', PINECONE_INDEX_NAME: ' ' } + ); + expect(cfg.indexName).toBe(DEFAULT_INDEX_NAME); + }); + + it('uses DEFAULT_RERANK_MODEL when env and overrides omit rerankModel', () => { + const cfg = resolveConfig( + { apiKey: 'sk-test', indexName: 'my-index' }, + { PINECONE_API_KEY: 'sk-test', PINECONE_INDEX_NAME: 'my-index' } + ); + expect(cfg.rerankModel).toBe(DEFAULT_RERANK_MODEL); + }); + + it('uses PINECONE_RERANK_MODEL from env when set', () => { + const cfg = resolveConfig( + { apiKey: 'sk-test', indexName: 'my-index' }, + { + PINECONE_API_KEY: 'sk-test', + PINECONE_INDEX_NAME: 'my-index', + PINECONE_RERANK_MODEL: 'env-reranker', + } + ); + expect(cfg.rerankModel).toBe('env-reranker'); + }); + + it('sets rerankModel when provided via overrides', () => { + const cfg = resolveConfig({ + apiKey: 'sk-test', + indexName: 'my-index', + rerankModel: 'my-reranker', + }); + expect(cfg.rerankModel).toBe('my-reranker'); + }); +}); diff --git a/src/config.ts b/src/core/config.ts similarity index 75% rename from src/config.ts rename to src/core/config.ts index 9872b2c..0e2e08a 100644 --- a/src/config.ts +++ b/src/core/config.ts @@ -2,16 +2,11 @@ * Shared runtime config types and resolver. * * `ServerConfig` is the single source of truth flowed from `parseCli()` → - * `setupServer(config)` → every collaborator. Modules MUST NOT read - * `process.env` directly anymore — they receive their slice of the config. + * `setupCoreServer(config)` / `setupAllianceServer(config)` → every collaborator. + * Modules MUST NOT read `process.env` directly anymore — they receive their slice of the config. */ -import { - DEFAULT_INDEX_NAME, - DEFAULT_RERANK_MODEL, - DEFAULT_TOP_K, - FLOW_CACHE_TTL_MS, -} from './constants.js'; +import { DEFAULT_TOP_K, FLOW_CACHE_TTL_MS } from '../constants.js'; /** Allowed log levels, in ascending severity. */ export type LogLevel = 'DEBUG' | 'INFO' | 'WARN' | 'ERROR'; @@ -23,17 +18,16 @@ export type LogFormat = 'text' | 'json'; * Unified runtime configuration for the MCP server. * * Built once by `parseCli()` (or constructed directly by library consumers) - * and threaded through `setupServer(config)`. All optional knobs have safe - * defaults; only `apiKey` is required. + * and threaded through setup. `apiKey` is required; `indexName` defaults via {@link DEFAULT_INDEX_NAME}. */ export interface ServerConfig { /** Pinecone API key. Required. */ apiKey: string; - /** Dense (hybrid) index name. Defaults to `DEFAULT_INDEX_NAME`. */ + /** Dense (hybrid) index name (`PINECONE_INDEX_NAME` or {@link DEFAULT_INDEX_NAME}). */ indexName: string; /** Sparse index name. Defaults to `${indexName}-sparse`. */ sparseIndexName: string; - /** Reranker model identifier. Defaults to `DEFAULT_RERANK_MODEL`. */ + /** Reranker model identifier (`PINECONE_RERANK_MODEL` or {@link DEFAULT_RERANK_MODEL}). */ rerankModel: string; /** Default top-k when callers omit it on `query`. */ defaultTopK: number; @@ -54,6 +48,12 @@ export interface ServerConfig { /** Default per-call timeout for Pinecone requests, in milliseconds. */ export const DEFAULT_REQUEST_TIMEOUT_MS = 15_000; +/** Default Pinecone inference rerank model when `PINECONE_RERANK_MODEL` is unset. */ +export const DEFAULT_RERANK_MODEL = 'bge-reranker-v2-m3'; + +/** Default dense index name when `PINECONE_INDEX_NAME` is unset. */ +export const DEFAULT_INDEX_NAME = 'rag-hybrid'; + function asLogLevel(value: string | undefined, fallback: LogLevel): LogLevel { const allowed: LogLevel[] = ['DEBUG', 'INFO', 'WARN', 'ERROR']; return allowed.includes(value as LogLevel) ? (value as LogLevel) : fallback; @@ -77,6 +77,12 @@ function asBool(value: string | undefined, fallback: boolean): boolean { return fallback; } +function trimOptional(value: string | undefined): string | undefined { + if (value === undefined) return undefined; + const t = value.trim(); + return t.length > 0 ? t : undefined; +} + /** Partial config used by `resolveConfig` (CLI overrides for env). */ export interface ConfigOverrides { apiKey?: string; @@ -96,7 +102,7 @@ export interface ConfigOverrides { * Build a `ServerConfig` from CLI overrides, environment variables, and defaults. * CLI > env > default precedence is preserved. * - * @throws Error when no API key is provided (empty after trim from overrides or `PINECONE_API_KEY`). + * @throws Error when no API key is provided. */ export function resolveConfig( overrides: ConfigOverrides, @@ -108,10 +114,17 @@ export function resolveConfig( 'Missing Pinecone API key: set PINECONE_API_KEY or pass --api-key (or apiKey in ConfigOverrides for library use).' ); } - const indexName = overrides.indexName ?? env['PINECONE_INDEX_NAME'] ?? DEFAULT_INDEX_NAME; + + const indexName = + trimOptional(overrides.indexName ?? env['PINECONE_INDEX_NAME']) ?? DEFAULT_INDEX_NAME; + const sparseIndexName = - overrides.sparseIndexName ?? env['PINECONE_SPARSE_INDEX_NAME'] ?? `${indexName}-sparse`; - const rerankModel = overrides.rerankModel ?? env['PINECONE_RERANK_MODEL'] ?? DEFAULT_RERANK_MODEL; + trimOptional(overrides.sparseIndexName ?? env['PINECONE_SPARSE_INDEX_NAME']) ?? + `${indexName}-sparse`; + + const rerankModel = + trimOptional(overrides.rerankModel ?? env['PINECONE_RERANK_MODEL']) ?? DEFAULT_RERANK_MODEL; + const defaultTopK = overrides.defaultTopK ?? asPositiveInt(env['PINECONE_TOP_K'], DEFAULT_TOP_K); const logLevel = asLogLevel( overrides.logLevel ?? env['PINECONE_READ_ONLY_MCP_LOG_LEVEL'], diff --git a/src/core/index.ts b/src/core/index.ts new file mode 100644 index 0000000..5b4e6af --- /dev/null +++ b/src/core/index.ts @@ -0,0 +1,43 @@ +/** + * @packageDocumentation + * **@will-cppa/pinecone-read-only-mcp** — generic (core) programmatic entrypoint. + * + * Import from the package root for the generic MCP-Pinecone bridge only. + * For the full Alliance tool surface, use `@will-cppa/pinecone-read-only-mcp/alliance`. + */ + +export { setPineconeClient } from './server/client-context.js'; +export { + validateMetadataFilter, + validateMetadataFilterDetailed, +} from './server/metadata-filter.js'; +export type { MetadataFilterValidationError } from './server/metadata-filter.js'; +export { toolErrorSchema } from './server/tool-error.js'; +export type { ToolError, ToolErrorCode } from './server/tool-error.js'; +export { suggestQueryParams } from './server/query-suggestion.js'; +export type { RecommendedTool, SuggestQueryParamsResult } from './server/query-suggestion.js'; +export { + registerUrlGenerator, + unregisterUrlGenerator, + generateUrlForNamespace, + hasUrlGenerator, +} from './server/url-registry.js'; +export type { UrlGenerationResult, UrlGenerator, UrlGeneratorFn } from './server/url-registry.js'; +export { resolveConfig, DEFAULT_INDEX_NAME, DEFAULT_RERANK_MODEL } from './config.js'; +export type { ServerConfig, LogLevel, LogFormat, ConfigOverrides } from './config.js'; +export { PineconeClient } from './pinecone-client.js'; +export type { + PineconeClientConfig, + QueryParams, + CountParams, + CountResult, + KeywordSearchParams, + SearchResult, + PineconeMetadataValue, + QueryResponse, + QueryResultRowShape, + KeywordIndexNamespacesResult, + HybridQueryResult, + HybridLegFailed, +} from '../types.js'; +export { setupCoreServer, teardownServer } from './setup.js'; diff --git a/src/pinecone-client.test.ts b/src/core/pinecone-client.test.ts similarity index 90% rename from src/pinecone-client.test.ts rename to src/core/pinecone-client.test.ts index a101cb0..aec4edd 100644 --- a/src/pinecone-client.test.ts +++ b/src/core/pinecone-client.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { PineconeClient } from './pinecone-client.js'; import { resolveConfig } from './config.js'; -import type { SearchableIndex, PineconeHit } from './types.js'; +import type { SearchableIndex, PineconeHit } from '../types.js'; import * as rerankModule from './pinecone/rerank.js'; /** Stubs for private methods (assigned at runtime; avoid intersecting private `PineconeClient` members). */ @@ -265,6 +265,44 @@ describe('PineconeClient', () => { expect(fieldsPassed).toContain('title'); }); + it('skips rerank API when rerankModel is not configured even if useReranking is true', async () => { + const noRerankClient = new PineconeClient({ + apiKey: 'test-api-key', + indexName: 'test-index', + }); + const spy = vi.spyOn(rerankModule, 'rerankResults'); + try { + const testClient = stubPineconeClient(noRerankClient); + const denseRef = {} as SearchableIndex; + const sparseRef = {} as SearchableIndex; + testClient.ensureIndexes = async () => ({ + denseIndex: denseRef, + sparseIndex: sparseRef, + }); + testClient.searchIndex = async (index) => { + if (index === denseRef) { + return [{ _id: 'd1', _score: 0.9, fields: { chunk_text: 'plain' } }]; + } + return []; + }; + + const results = await noRerankClient.query({ + query: 'q', + namespace: 'n', + topK: 5, + useReranking: true, + }); + + expect(results.results).toHaveLength(1); + expect(results.results[0]?.reranked).toBe(false); + expect(results.rerank_skipped_reason).toBe('no_model'); + expect(results.degradation_reason).toMatch(/rerank_skipped_no_model/); + expect(spy).not.toHaveBeenCalled(); + } finally { + spy.mockRestore(); + } + }); + it('uses rerankResults from pinecone/rerank when useReranking is true', async () => { const spy = vi.spyOn(rerankModule, 'rerankResults').mockResolvedValue({ results: [ diff --git a/src/pinecone-client.ts b/src/core/pinecone-client.ts similarity index 87% rename from src/pinecone-client.ts rename to src/core/pinecone-client.ts index 3efde17..85ce4b7 100644 --- a/src/pinecone-client.ts +++ b/src/core/pinecone-client.ts @@ -1,6 +1,6 @@ /** Hybrid dense+sparse query client with optional reranking (facade over `src/pinecone/*`). */ -import { error as logError, info as logInfo } from './logger.js'; +import { error as logError, info as logInfo } from '../logger.js'; import type { PineconeClientConfig, SearchResult, @@ -13,15 +13,8 @@ import type { SearchableIndex, HybridQueryResult, HybridLegFailed, -} from './types.js'; -import { - DEFAULT_INDEX_NAME, - DEFAULT_RERANK_MODEL, - DEFAULT_TOP_K, - MAX_TOP_K, - COUNT_TOP_K, - COUNT_FIELDS, -} from './constants.js'; +} from '../types.js'; +import { DEFAULT_TOP_K, MAX_TOP_K, COUNT_TOP_K, COUNT_FIELDS } from '../constants.js'; import { PineconeIndexSession } from './pinecone/indexes.js'; import { countUniqueDocumentsFromHits, @@ -33,7 +26,7 @@ import { import { rerankResults as rerankResultsImpl } from './pinecone/rerank.js'; export class PineconeClient { - private rerankModel: string; + private readonly rerankModel: string | undefined; private defaultTopK: number; private readonly indexSession: PineconeIndexSession; @@ -43,9 +36,9 @@ export class PineconeClient { * built via {@link resolveConfig} / CLI); this class does not read `process.env`. */ constructor(config: PineconeClientConfig) { - const indexName = config.indexName ?? DEFAULT_INDEX_NAME; - this.indexSession = new PineconeIndexSession(config.apiKey, indexName); - this.rerankModel = config.rerankModel ?? DEFAULT_RERANK_MODEL; + this.indexSession = new PineconeIndexSession(config.apiKey, config.indexName); + const normalizedRerankModel = config.rerankModel?.trim(); + this.rerankModel = normalizedRerankModel ? normalizedRerankModel : undefined; this.defaultTopK = config.defaultTopK ?? DEFAULT_TOP_K; } @@ -123,8 +116,9 @@ export class PineconeClient { const topK = this.clampTopK(requestedTopK); + const effectiveReranking = useReranking && this.rerankModel !== undefined; const searchFields = - requestedFields?.length && useReranking && !requestedFields.includes('chunk_text') + requestedFields?.length && effectiveReranking && !requestedFields.includes('chunk_text') ? [...requestedFields, 'chunk_text'] : requestedFields; @@ -169,8 +163,9 @@ export class PineconeClient { let degraded = false; let degradation_reason: string | undefined; + let rerank_skipped_reason: 'no_model' | undefined; let documents: SearchResult[]; - if (useReranking) { + if (effectiveReranking && this.rerankModel) { const rerankOut = await rerankResultsImpl( this.indexSession.ensureClient(), this.rerankModel, @@ -183,6 +178,11 @@ export class PineconeClient { degradation_reason = rerankOut.degradation_reason; } else { documents = sliceMergedHitsToSearchResults(mergedResults, topK); + if (useReranking && this.rerankModel === undefined) { + rerank_skipped_reason = 'no_model'; + degradation_reason = + 'rerank_skipped_no_model: set PINECONE_RERANK_MODEL, pass rerankModel in config, or construct PineconeClient from resolveConfig().'; + } } logInfo( @@ -194,6 +194,7 @@ export class PineconeClient { degraded, ...(degradation_reason !== undefined ? { degradation_reason } : {}), hybrid_leg_failed: hybridLegFailed, + ...(rerank_skipped_reason !== undefined ? { rerank_skipped_reason } : {}), }; } diff --git a/src/pinecone/indexes.test.ts b/src/core/pinecone/indexes.test.ts similarity index 99% rename from src/pinecone/indexes.test.ts rename to src/core/pinecone/indexes.test.ts index a70d93b..47520fa 100644 --- a/src/pinecone/indexes.test.ts +++ b/src/core/pinecone/indexes.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi } from 'vitest'; import { PineconeIndexSession } from './indexes.js'; -import type { SearchableIndex } from '../types.js'; +import type { SearchableIndex } from '../../types.js'; /** Subclass so tests inject index handles without calling the real Pinecone SDK. */ class PineconeIndexSessionTestDouble extends PineconeIndexSession { diff --git a/src/pinecone/indexes.ts b/src/core/pinecone/indexes.ts similarity index 97% rename from src/pinecone/indexes.ts rename to src/core/pinecone/indexes.ts index e1af568..60dbcc0 100644 --- a/src/pinecone/indexes.ts +++ b/src/core/pinecone/indexes.ts @@ -3,8 +3,12 @@ */ import { Pinecone } from '@pinecone-database/pinecone'; -import { error as logError, info as logInfo } from '../logger.js'; -import type { KeywordIndexNamespacesResult, NamespaceHandle, SearchableIndex } from '../types.js'; +import { error as logError, info as logInfo } from '../../logger.js'; +import type { + KeywordIndexNamespacesResult, + NamespaceHandle, + SearchableIndex, +} from '../../types.js'; /** * Infers a human-readable metadata field type for namespace discovery. diff --git a/src/pinecone/rerank.test.ts b/src/core/pinecone/rerank.test.ts similarity index 97% rename from src/pinecone/rerank.test.ts rename to src/core/pinecone/rerank.test.ts index 518f5ad..c26c63e 100644 --- a/src/pinecone/rerank.test.ts +++ b/src/core/pinecone/rerank.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi } from 'vitest'; import { rerankResults } from './rerank.js'; -import type { MergedHit } from '../types.js'; +import type { MergedHit } from '../../types.js'; const sampleMerged: MergedHit[] = [ { _id: '1', _score: 0.5, chunk_text: 'hello', metadata: { k: 'v' } }, diff --git a/src/pinecone/rerank.ts b/src/core/pinecone/rerank.ts similarity index 95% rename from src/pinecone/rerank.ts rename to src/core/pinecone/rerank.ts index a712691..e39194d 100644 --- a/src/pinecone/rerank.ts +++ b/src/core/pinecone/rerank.ts @@ -3,8 +3,8 @@ */ import type { Pinecone } from '@pinecone-database/pinecone'; -import { error as logError } from '../logger.js'; -import type { MergedHit, SearchResult } from '../types.js'; +import { error as logError } from '../../logger.js'; +import type { MergedHit, SearchResult } from '../../types.js'; export type RerankOutcome = { results: SearchResult[]; diff --git a/src/pinecone/search.test.ts b/src/core/pinecone/search.test.ts similarity index 98% rename from src/pinecone/search.test.ts rename to src/core/pinecone/search.test.ts index 98bdbe9..31f322a 100644 --- a/src/pinecone/search.test.ts +++ b/src/core/pinecone/search.test.ts @@ -6,7 +6,7 @@ import { mapSparseHitsToSearchResults, countUniqueDocumentsFromHits, } from './search.js'; -import type { PineconeHit, SearchableIndex } from '../types.js'; +import type { PineconeHit, SearchableIndex } from '../../types.js'; describe('searchIndex', () => { it('uses index.search when available and passes fields', async () => { diff --git a/src/pinecone/search.ts b/src/core/pinecone/search.ts similarity index 97% rename from src/pinecone/search.ts rename to src/core/pinecone/search.ts index 030631e..44ebe0e 100644 --- a/src/pinecone/search.ts +++ b/src/core/pinecone/search.ts @@ -2,8 +2,8 @@ * Pinecone search pipeline: single-index text query and dense/sparse merge. */ -import { COUNT_FIELDS, COUNT_TOP_K } from '../constants.js'; -import { debug as logDebug, warn as logWarn } from '../logger.js'; +import { COUNT_FIELDS, COUNT_TOP_K } from '../../constants.js'; +import { debug as logDebug, warn as logWarn } from '../../logger.js'; import type { CountResult, MergedHit, @@ -11,7 +11,7 @@ import type { PineconeMetadataValue, SearchResult, SearchableIndex, -} from '../types.js'; +} from '../../types.js'; /** * Search a Pinecone index using text query with optional metadata filtering. diff --git a/src/core/rerank-trace.test.ts b/src/core/rerank-trace.test.ts new file mode 100644 index 0000000..1eb785a --- /dev/null +++ b/src/core/rerank-trace.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; +import { guidedRerankStatus } from './rerank-trace.js'; +import { makeHybridQueryResult } from './server/tools/test-helpers.js'; + +describe('guidedRerankStatus', () => { + it('returns skipped when rerank was not requested', () => { + expect(guidedRerankStatus(false, makeHybridQueryResult())).toBe('skipped'); + }); + + it('returns skipped_no_model when outcome reports no rerank model', () => { + expect( + guidedRerankStatus(true, makeHybridQueryResult({ rerank_skipped_reason: 'no_model' })) + ).toBe('skipped_no_model'); + }); + + it('returns failed when rerank degraded without hybrid leg failure', () => { + expect( + guidedRerankStatus( + true, + makeHybridQueryResult({ + degraded: true, + degradation_reason: 'rerank_failed: timeout', + }) + ) + ).toBe('failed'); + }); + + it('returns success when rerank was requested and not skipped or failed', () => { + expect(guidedRerankStatus(true, makeHybridQueryResult())).toBe('success'); + }); +}); diff --git a/src/core/rerank-trace.ts b/src/core/rerank-trace.ts new file mode 100644 index 0000000..71b2cf9 --- /dev/null +++ b/src/core/rerank-trace.ts @@ -0,0 +1,20 @@ +import type { HybridQueryResult } from '../types.js'; + +export type GuidedRerankStatus = 'success' | 'skipped' | 'skipped_no_model' | 'failed'; + +/** Map hybrid query outcome to guided_query `decision_trace.rerank_status`. */ +export function guidedRerankStatus( + requestedRerank: boolean, + outcome: HybridQueryResult +): GuidedRerankStatus { + if (!requestedRerank) { + return 'skipped'; + } + if (outcome.rerank_skipped_reason === 'no_model') { + return 'skipped_no_model'; + } + if (outcome.degraded && outcome.hybrid_leg_failed === null) { + return 'failed'; + } + return 'success'; +} diff --git a/src/server.test.ts b/src/core/server.test.ts similarity index 81% rename from src/server.test.ts rename to src/core/server.test.ts index 3da3982..7f1d406 100644 --- a/src/server.test.ts +++ b/src/core/server.test.ts @@ -2,13 +2,14 @@ import { describe, expect, it, afterEach } from 'vitest'; import { validateMetadataFilter, suggestQueryParams, - setupServer, + setupCoreServer, teardownServer, setPineconeClient, resolveConfig, PineconeClient, hasUrlGenerator, -} from './server.js'; +} from './index.js'; +import { setupAllianceServer } from '../alliance/setup.js'; describe('suggestQueryParams', () => { const wg21Fields = { @@ -106,13 +107,13 @@ describe('validateMetadataFilter', () => { }); }); -describe('setupServer lifecycle', () => { +describe('setupCoreServer lifecycle', () => { afterEach(() => { teardownServer(); }); - it('throws on second setupServer without teardown', async () => { - const cfg = resolveConfig({ apiKey: 'lifecycle-test-key' }); + it('throws on second setupCoreServer without teardown', async () => { + const cfg = resolveConfig({ apiKey: 'lifecycle-test-key', indexName: 'test-index' }); setPineconeClient( new PineconeClient({ apiKey: cfg.apiKey, @@ -121,12 +122,12 @@ describe('setupServer lifecycle', () => { defaultTopK: cfg.defaultTopK, }) ); - await setupServer(cfg); - await expect(setupServer(cfg)).rejects.toThrow(/teardownServer/); + await setupCoreServer(cfg); + await expect(setupCoreServer(cfg)).rejects.toThrow(/teardownServer/); }); - it('allows setup after teardown and reinstalls built-in URL generators', async () => { - const cfg = resolveConfig({ apiKey: 'lifecycle-test-key-2' }); + it('core setup does not register Alliance built-in URL generators', async () => { + const cfg = resolveConfig({ apiKey: 'lifecycle-test-key-2', indexName: 'test-index' }); setPineconeClient( new PineconeClient({ apiKey: cfg.apiKey, @@ -135,10 +136,13 @@ describe('setupServer lifecycle', () => { defaultTopK: cfg.defaultTopK, }) ); - await setupServer(cfg); - expect(hasUrlGenerator('mailing')).toBe(true); - teardownServer(); + await setupCoreServer(cfg); expect(hasUrlGenerator('mailing')).toBe(false); + teardownServer(); + }); + + it('alliance setup registers built-in URL generators after teardown', async () => { + const cfg = resolveConfig({ apiKey: 'lifecycle-test-key-3', indexName: 'test-index' }); setPineconeClient( new PineconeClient({ apiKey: cfg.apiKey, @@ -147,7 +151,9 @@ describe('setupServer lifecycle', () => { defaultTopK: cfg.defaultTopK, }) ); - await setupServer(cfg); + await setupAllianceServer(cfg); expect(hasUrlGenerator('mailing')).toBe(true); + teardownServer(); + expect(hasUrlGenerator('mailing')).toBe(false); }); }); diff --git a/src/server/client-context.ts b/src/core/server/client-context.ts similarity index 100% rename from src/server/client-context.ts rename to src/core/server/client-context.ts diff --git a/src/server/config-context.ts b/src/core/server/config-context.ts similarity index 65% rename from src/server/config-context.ts rename to src/core/server/config-context.ts index 42ed419..00e1cd8 100644 --- a/src/server/config-context.ts +++ b/src/core/server/config-context.ts @@ -3,7 +3,7 @@ import { resolveConfig } from '../config.js'; let activeConfig: ServerConfig | null = null; -/** Replace the process-global server config (called from `setupServer` with CLI/env-derived config). */ +/** Replace the process-global server config (called from setup with CLI/env-derived config). */ export function setServerConfig(config: ServerConfig): void { activeConfig = config; } @@ -17,9 +17,9 @@ export function resetServerConfig(): void { * Active server config for modules that cannot receive `ServerConfig` through parameters * (namespace cache TTL, suggest-flow gate, etc.). * - * When `setupServer()` runs without an explicit config, falls back to `resolveConfig({})` - * so env defaults still apply. That requires `PINECONE_API_KEY` (or the call throws); embedders - * should pass `config` into `setupServer(config)` when env is not set. + * When setup runs without an explicit config, falls back to `resolveConfig({})` + * (requires `PINECONE_API_KEY` and `PINECONE_INDEX_NAME` or the call throws). Embedders + * should pass `config` into `setupCoreServer(config)` / `setupAllianceServer(config)`. */ export function getServerConfig(): ServerConfig { if (!activeConfig) { diff --git a/src/server/format-query-result.test.ts b/src/core/server/format-query-result.test.ts similarity index 95% rename from src/server/format-query-result.test.ts rename to src/core/server/format-query-result.test.ts index d5da2f9..9c440a1 100644 --- a/src/server/format-query-result.test.ts +++ b/src/core/server/format-query-result.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import * as loggerModule from '../logger.js'; -import type { SearchResult } from '../types.js'; +import * as loggerModule from '../../logger.js'; +import type { SearchResult } from '../../types.js'; import { formatQueryResultRows, formatSearchResultAsRow, diff --git a/src/server/format-query-result.ts b/src/core/server/format-query-result.ts similarity index 94% rename from src/server/format-query-result.ts rename to src/core/server/format-query-result.ts index 5c01aa4..700f5a5 100644 --- a/src/server/format-query-result.ts +++ b/src/core/server/format-query-result.ts @@ -3,9 +3,9 @@ * Used by query tool and guided_query to avoid duplicated identifier/title/author/url logic. */ -import type { PineconeMetadataValue, SearchResult } from '../types.js'; -import { warn as logWarn } from '../logger.js'; -import { generateUrlForNamespace } from './url-generation.js'; +import type { PineconeMetadataValue, SearchResult } from '../../types.js'; +import { warn as logWarn } from '../../logger.js'; +import { generateUrlForNamespace } from './url-registry.js'; const DEFAULT_CONTENT_MAX_LENGTH = 2000; diff --git a/src/server/metadata-filter.test.ts b/src/core/server/metadata-filter.test.ts similarity index 100% rename from src/server/metadata-filter.test.ts rename to src/core/server/metadata-filter.test.ts diff --git a/src/server/metadata-filter.ts b/src/core/server/metadata-filter.ts similarity index 100% rename from src/server/metadata-filter.ts rename to src/core/server/metadata-filter.ts diff --git a/src/server/namespace-router.ts b/src/core/server/namespace-router.ts similarity index 100% rename from src/server/namespace-router.ts rename to src/core/server/namespace-router.ts diff --git a/src/server/namespace-utils.ts b/src/core/server/namespace-utils.ts similarity index 100% rename from src/server/namespace-utils.ts rename to src/core/server/namespace-utils.ts diff --git a/src/server/namespaces-cache.ts b/src/core/server/namespaces-cache.ts similarity index 100% rename from src/server/namespaces-cache.ts rename to src/core/server/namespaces-cache.ts diff --git a/src/server/query-suggestion.ts b/src/core/server/query-suggestion.ts similarity index 98% rename from src/server/query-suggestion.ts rename to src/core/server/query-suggestion.ts index f02dca8..0146969 100644 --- a/src/server/query-suggestion.ts +++ b/src/core/server/query-suggestion.ts @@ -1,4 +1,4 @@ -import { COUNT_FIELDS } from '../constants.js'; +import { COUNT_FIELDS } from '../../constants.js'; /** Values returned by `suggest_query_params` / suggestion flow; align with `query` tool `preset`. */ export type RecommendedTool = 'count' | 'fast' | 'detailed' | 'full'; diff --git a/src/server/reassemble-documents.test.ts b/src/core/server/reassemble-documents.test.ts similarity index 98% rename from src/server/reassemble-documents.test.ts rename to src/core/server/reassemble-documents.test.ts index b14d086..c96af8e 100644 --- a/src/server/reassemble-documents.test.ts +++ b/src/core/server/reassemble-documents.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import * as loggerModule from '../logger.js'; +import * as loggerModule from '../../logger.js'; import { reassembleByDocument } from './reassemble-documents.js'; -import type { SearchResult } from '../types.js'; +import type { SearchResult } from '../../types.js'; describe('reassembleByDocument', () => { let warnSpy: ReturnType; diff --git a/src/server/reassemble-documents.ts b/src/core/server/reassemble-documents.ts similarity index 97% rename from src/server/reassemble-documents.ts rename to src/core/server/reassemble-documents.ts index 54aafec..6897475 100644 --- a/src/server/reassemble-documents.ts +++ b/src/core/server/reassemble-documents.ts @@ -4,8 +4,8 @@ * for content analysis (summarization, full-document Q&A, etc.). */ -import type { PineconeMetadataValue, SearchResult } from '../types.js'; -import { warn as logWarn } from '../logger.js'; +import type { PineconeMetadataValue, SearchResult } from '../../types.js'; +import { warn as logWarn } from '../../logger.js'; /** Default metadata keys tried for chunk ordering (RecursiveCharacterTextSplitter often adds these). */ const CHUNK_ORDER_KEYS = ['chunk_index', 'start_index', 'loc'] as const; diff --git a/src/server/retry.test.ts b/src/core/server/retry.test.ts similarity index 100% rename from src/server/retry.test.ts rename to src/core/server/retry.test.ts diff --git a/src/server/retry.ts b/src/core/server/retry.ts similarity index 100% rename from src/server/retry.ts rename to src/core/server/retry.ts diff --git a/src/server/suggestion-flow.ts b/src/core/server/suggestion-flow.ts similarity index 100% rename from src/server/suggestion-flow.ts rename to src/core/server/suggestion-flow.ts diff --git a/src/server/tool-error.test.ts b/src/core/server/tool-error.test.ts similarity index 100% rename from src/server/tool-error.test.ts rename to src/core/server/tool-error.test.ts diff --git a/src/server/tool-error.ts b/src/core/server/tool-error.ts similarity index 98% rename from src/server/tool-error.ts rename to src/core/server/tool-error.ts index d506f96..dffe42d 100644 --- a/src/server/tool-error.ts +++ b/src/core/server/tool-error.ts @@ -4,7 +4,7 @@ */ import { z } from 'zod'; -import { getLogLevel, error as logError } from '../logger.js'; +import { getLogLevel, error as logError } from '../../logger.js'; /** User-facing error message: detailed in DEBUG, generic otherwise. */ export function getToolErrorMessage(error: unknown, fallbackMessage: string): string { diff --git a/src/server/tool-response.ts b/src/core/server/tool-response.ts similarity index 100% rename from src/server/tool-response.ts rename to src/core/server/tool-response.ts diff --git a/src/server/tools/count-tool.test.ts b/src/core/server/tools/count-tool.test.ts similarity index 100% rename from src/server/tools/count-tool.test.ts rename to src/core/server/tools/count-tool.test.ts diff --git a/src/server/tools/count-tool.ts b/src/core/server/tools/count-tool.ts similarity index 100% rename from src/server/tools/count-tool.ts rename to src/core/server/tools/count-tool.ts diff --git a/src/server/tools/generate-urls-tool.test.ts b/src/core/server/tools/generate-urls-tool.test.ts similarity index 89% rename from src/server/tools/generate-urls-tool.test.ts rename to src/core/server/tools/generate-urls-tool.test.ts index d08e640..74e1b75 100644 --- a/src/server/tools/generate-urls-tool.test.ts +++ b/src/core/server/tools/generate-urls-tool.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import * as urlGeneration from '../url-generation.js'; +import * as urlRegistry from '../url-registry.js'; import { registerGenerateUrlsTool } from './generate-urls-tool.js'; import { assertToolErrorCode, createMockServer } from './test-helpers.js'; @@ -20,7 +20,7 @@ describe('generate_urls tool handler', () => { }); it('returns PINECONE_ERROR when generateUrlForNamespace throws', async () => { - vi.spyOn(urlGeneration, 'generateUrlForNamespace').mockImplementation(() => { + vi.spyOn(urlRegistry, 'generateUrlForNamespace').mockImplementation(() => { throw new Error('generator boom'); }); const server = createMockServer(); diff --git a/src/server/tools/generate-urls-tool.ts b/src/core/server/tools/generate-urls-tool.ts similarity index 97% rename from src/server/tools/generate-urls-tool.ts rename to src/core/server/tools/generate-urls-tool.ts index 34006da..8ee5e2d 100644 --- a/src/server/tools/generate-urls-tool.ts +++ b/src/core/server/tools/generate-urls-tool.ts @@ -1,6 +1,6 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; -import { generateUrlForNamespace } from '../url-generation.js'; +import { generateUrlForNamespace } from '../url-registry.js'; import { normalizeNamespace } from '../namespace-utils.js'; import { classifyToolCatchError, logToolError, validationToolError } from '../tool-error.js'; import { jsonErrorResponse, jsonResponse } from '../tool-response.js'; diff --git a/src/server/tools/keyword-search-tool.test.ts b/src/core/server/tools/keyword-search-tool.test.ts similarity index 96% rename from src/server/tools/keyword-search-tool.test.ts rename to src/core/server/tools/keyword-search-tool.test.ts index 96e6f21..576d4cb 100644 --- a/src/server/tools/keyword-search-tool.test.ts +++ b/src/core/server/tools/keyword-search-tool.test.ts @@ -14,7 +14,7 @@ describe('keyword_search tool handler', () => { vi.clearAllMocks(); mockedGetClient.mockReturnValue({ keywordSearch: vi.fn().mockResolvedValue([makeSearchResult()]), - getSparseIndexName: () => 'rag-hybrid-sparse', + getSparseIndexName: () => 'test-index-sparse', } as never); }); @@ -77,7 +77,7 @@ describe('keyword_search tool handler', () => { it('returns PINECONE_ERROR when keywordSearch throws', async () => { mockedGetClient.mockReturnValue({ keywordSearch: vi.fn().mockRejectedValue(new Error('sparse error')), - getSparseIndexName: () => 'rag-hybrid-sparse', + getSparseIndexName: () => 'test-index-sparse', } as never); const server = createMockServer(); registerKeywordSearchTool(server as never); diff --git a/src/server/tools/keyword-search-tool.ts b/src/core/server/tools/keyword-search-tool.ts similarity index 97% rename from src/server/tools/keyword-search-tool.ts rename to src/core/server/tools/keyword-search-tool.ts index 36e2fa5..d7e2b89 100644 --- a/src/server/tools/keyword-search-tool.ts +++ b/src/core/server/tools/keyword-search-tool.ts @@ -1,6 +1,6 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; -import { MAX_TOP_K, MIN_TOP_K } from '../../constants.js'; +import { MAX_TOP_K, MIN_TOP_K } from '../../../constants.js'; import { getPineconeClient } from '../client-context.js'; import { formatQueryResultRows } from '../format-query-result.js'; import { metadataFilterSchema, validateMetadataFilterDetailed } from '../metadata-filter.js'; @@ -109,7 +109,7 @@ export function registerKeywordSearchTool(server: McpServer): void { 'keyword_search', { description: - 'Keyword (lexical/sparse-only) search over the Pinecone sparse index (default: rag-hybrid-sparse). ' + + 'Keyword (lexical/sparse-only) search over the Pinecone sparse index ({indexName}-sparse). ' + 'Use for exact or keyword-style queries. Does not use semantic reranking. ' + 'Call list_namespaces first to discover namespaces. Does not require suggest_query_params.', inputSchema: { diff --git a/src/server/tools/list-namespaces-tool.test.ts b/src/core/server/tools/list-namespaces-tool.test.ts similarity index 100% rename from src/server/tools/list-namespaces-tool.test.ts rename to src/core/server/tools/list-namespaces-tool.test.ts diff --git a/src/server/tools/list-namespaces-tool.ts b/src/core/server/tools/list-namespaces-tool.ts similarity index 100% rename from src/server/tools/list-namespaces-tool.ts rename to src/core/server/tools/list-namespaces-tool.ts diff --git a/src/server/tools/namespace-router-tool.test.ts b/src/core/server/tools/namespace-router-tool.test.ts similarity index 100% rename from src/server/tools/namespace-router-tool.test.ts rename to src/core/server/tools/namespace-router-tool.test.ts diff --git a/src/server/tools/namespace-router-tool.ts b/src/core/server/tools/namespace-router-tool.ts similarity index 100% rename from src/server/tools/namespace-router-tool.ts rename to src/core/server/tools/namespace-router-tool.ts diff --git a/src/server/tools/query-documents-tool.test.ts b/src/core/server/tools/query-documents-tool.test.ts similarity index 99% rename from src/server/tools/query-documents-tool.test.ts rename to src/core/server/tools/query-documents-tool.test.ts index 1d0a713..2affd46 100644 --- a/src/server/tools/query-documents-tool.test.ts +++ b/src/core/server/tools/query-documents-tool.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { DEFAULT_QUERY_DOCUMENTS_TOP_K, QUERY_DOCUMENTS_MAX_CHUNKS } from '../../constants.js'; +import { DEFAULT_QUERY_DOCUMENTS_TOP_K, QUERY_DOCUMENTS_MAX_CHUNKS } from '../../../constants.js'; import { getPineconeClient } from '../client-context.js'; import { reassembleByDocument } from '../reassemble-documents.js'; import * as suggestionFlow from '../suggestion-flow.js'; diff --git a/src/server/tools/query-documents-tool.ts b/src/core/server/tools/query-documents-tool.ts similarity index 94% rename from src/server/tools/query-documents-tool.ts rename to src/core/server/tools/query-documents-tool.ts index adc1c69..9e1c041 100644 --- a/src/server/tools/query-documents-tool.ts +++ b/src/core/server/tools/query-documents-tool.ts @@ -4,7 +4,7 @@ import { DEFAULT_QUERY_DOCUMENTS_TOP_K, MAX_QUERY_DOCUMENTS_TOP_K, QUERY_DOCUMENTS_MAX_CHUNKS, -} from '../../constants.js'; +} from '../../../constants.js'; import { getPineconeClient } from '../client-context.js'; import { metadataFilterSchema, validateMetadataFilterDetailed } from '../metadata-filter.js'; import { normalizeNamespace } from '../namespace-utils.js'; @@ -37,7 +37,7 @@ export function registerQueryDocumentsTool(server: McpServer): void { { description: 'Run a semantic query and return whole documents (reassembled from chunks). ' + - 'Always uses semantic reranking for document-level relevance (higher latency/cost than chunk-only query). ' + + 'Reranks for document-level relevance when a rerank model is configured (higher latency/cost than chunk-only query). ' + 'Use for content analysis, summarization, or when you need full-document context. ' + 'Chunks are grouped by document_number/doc_id/url, ordered by chunk_index when present (e.g. from RecursiveCharacterTextSplitter), and merged into one content per document. ' + 'Requires suggest_query_params to be called first for the target namespace. Use list_namespaces to discover namespaces.', @@ -136,6 +136,9 @@ export function registerQueryDocumentsTool(server: McpServer): void { ? { degradation_reason: queryOutcome.degradation_reason } : {}), hybrid_leg_failed: queryOutcome.hybrid_leg_failed, + ...(queryOutcome.rerank_skipped_reason !== undefined + ? { rerank_skipped_reason: queryOutcome.rerank_skipped_reason } + : {}), documents: topDocuments.map((doc) => ({ document_id: doc.document_id, merged_content: doc.merged_content, diff --git a/src/server/tools/query-tool.test.ts b/src/core/server/tools/query-tool.test.ts similarity index 89% rename from src/server/tools/query-tool.test.ts rename to src/core/server/tools/query-tool.test.ts index 167199d..ec18544 100644 --- a/src/server/tools/query-tool.test.ts +++ b/src/core/server/tools/query-tool.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { FAST_QUERY_FIELDS } from '../../constants.js'; +import { FAST_QUERY_FIELDS } from '../../../constants.js'; import { getPineconeClient } from '../client-context.js'; import * as suggestionFlow from '../suggestion-flow.js'; import { registerQueryTool } from './query-tool.js'; @@ -217,6 +217,34 @@ describe('query tool handler (preset-driven)', () => { expect(query).not.toHaveBeenCalled(); }); + it('query: forwards rerank_skipped_reason when rerank was skipped (no model)', async () => { + mockedGetClient.mockReturnValue({ + query: vi.fn().mockResolvedValue( + makeHybridQueryResult({ + rerank_skipped_reason: 'no_model', + degradation_reason: 'rerank_skipped_no_model: set PINECONE_RERANK_MODEL', + }) + ), + count: vi.fn(), + } as never); + + const server = createMockServer(); + registerQueryTool(server as never); + + const body = parseToolJson( + await server.getHandler('query')!({ + query_text: 'hello', + namespace: 'wg21', + top_k: 3, + preset: 'detailed', + }) + ); + + expect(body.status).toBe('success'); + expect(body.rerank_skipped_reason).toBe('no_model'); + expect(body.degradation_reason).toMatch(/rerank_skipped_no_model/); + }); + it('query: surfaces unreranked hits when client returns reranked:false (rerank fallback shape)', async () => { mockedGetClient.mockReturnValue({ query: vi.fn().mockResolvedValue( diff --git a/src/server/tools/query-tool.ts b/src/core/server/tools/query-tool.ts similarity index 95% rename from src/server/tools/query-tool.ts rename to src/core/server/tools/query-tool.ts index ddbbc77..7412532 100644 --- a/src/server/tools/query-tool.ts +++ b/src/core/server/tools/query-tool.ts @@ -1,7 +1,7 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; -import { FAST_QUERY_FIELDS, MAX_TOP_K, MIN_TOP_K } from '../../constants.js'; -import type { QueryResponse } from '../../types.js'; +import { FAST_QUERY_FIELDS, MAX_TOP_K, MIN_TOP_K } from '../../../constants.js'; +import type { QueryResponse } from '../../../types.js'; import { getPineconeClient } from '../client-context.js'; import { formatQueryResultRows } from '../format-query-result.js'; import { metadataFilterSchema, validateMetadataFilterDetailed } from '../metadata-filter.js'; @@ -84,6 +84,9 @@ async function executeQuery(params: QueryExecParams) { ? { degradation_reason: queryOutcome.degradation_reason } : {}), hybrid_leg_failed: queryOutcome.hybrid_leg_failed, + ...(queryOutcome.rerank_skipped_reason !== undefined + ? { rerank_skipped_reason: queryOutcome.rerank_skipped_reason } + : {}), }; return jsonResponse(response); } catch (error) { diff --git a/src/server/tools/test-helpers.ts b/src/core/server/tools/test-helpers.ts similarity index 94% rename from src/server/tools/test-helpers.ts rename to src/core/server/tools/test-helpers.ts index b0e4541..fa5a843 100644 --- a/src/server/tools/test-helpers.ts +++ b/src/core/server/tools/test-helpers.ts @@ -1,4 +1,4 @@ -import type { HybridQueryResult, SearchResult } from '../../types.js'; +import type { HybridQueryResult, SearchResult } from '../../../types.js'; import type { ToolError, ToolErrorCode } from '../tool-error.js'; import { toolErrorSchema } from '../tool-error.js'; @@ -85,6 +85,9 @@ export function makeHybridQueryResult(overrides?: Partial): H ? { degradation_reason: overrides.degradation_reason } : {}), hybrid_leg_failed: overrides?.hybrid_leg_failed ?? null, + ...(overrides?.rerank_skipped_reason !== undefined + ? { rerank_skipped_reason: overrides.rerank_skipped_reason } + : {}), }; } diff --git a/src/core/server/url-registry.test.ts b/src/core/server/url-registry.test.ts new file mode 100644 index 0000000..acf1713 --- /dev/null +++ b/src/core/server/url-registry.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it, afterEach } from 'vitest'; +import type { UrlGeneratorFn } from './url-registry.js'; +import { + generateUrlForNamespace, + registerUrlGenerator, + unregisterUrlGenerator, + resetUrlGenerationRegistry, +} from './url-registry.js'; + +describe('url-registry', () => { + afterEach(() => { + resetUrlGenerationRegistry(); + }); + + it('uses existing metadata.url when present', () => { + registerUrlGenerator('custom', () => ({ + url: 'https://override.example', + method: 'generated.custom', + })); + const r = generateUrlForNamespace('custom', { + url: 'https://example.com/custom', + doc_id: 'ignored', + }); + expect(r.url).toBe('https://example.com/custom'); + expect(r.method).toBe('metadata.url'); + }); + + it('returns unavailable for unsupported namespace', () => { + const r = generateUrlForNamespace('unknown-ns', { doc_id: 'x' }); + expect(r.url).toBeNull(); + expect(r.method).toBe('unavailable'); + }); +}); + +describe('registerUrlGenerator', () => { + const customNs = 'acme-docs'; + + afterEach(() => { + unregisterUrlGenerator(customNs); + resetUrlGenerationRegistry(); + }); + + it('registers a custom generator for a new namespace', () => { + const fn: UrlGeneratorFn = () => ({ + url: 'https://example.com/doc/1', + method: 'generated.custom', + }); + registerUrlGenerator(customNs, fn); + const r = generateUrlForNamespace(customNs, {}); + expect(r.url).toBe('https://example.com/doc/1'); + expect(r.method).toBe('generated.custom'); + }); +}); diff --git a/src/core/server/url-registry.ts b/src/core/server/url-registry.ts new file mode 100644 index 0000000..017e8bb --- /dev/null +++ b/src/core/server/url-registry.ts @@ -0,0 +1,102 @@ +/** + * Per-namespace URL generation registry (generic API). + * + * Domain-specific built-in generators live in `src/alliance/url-builtins.ts`. + * Library consumers can plug in their own with `registerUrlGenerator(namespace, generator)`. + */ + +/** Outcome of a URL-generation attempt. */ +export type UrlGenerationResult = { + url: string | null; + method: + | 'metadata.url' + | 'metadata.source' + | 'generated.mailing' + | 'generated.slack' + | 'generated.custom' + | 'unavailable'; + reason?: string; +}; + +/** + * Function that builds a URL for a record's metadata. + * + * Custom generators may return any of the standard `method` values, plus + * `'generated.custom'` for namespace-specific generators registered by + * library consumers. + */ +export type UrlGenerator = (metadata: Record) => UrlGenerationResult; + +/** + * Alias for {@link UrlGenerator} (issue / API naming: `UrlGeneratorFn`). + * Use either type when implementing custom URL synthesis. + */ +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(); +} + +/** + * Register a URL generator for a namespace, replacing any existing entry. + * + * @param namespace exact namespace name (matches the value returned by `list_namespaces`). + * @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); +} + +/** Remove a namespace's URL generator. Returns true if a generator was removed. */ +export function unregisterUrlGenerator(namespace: string): boolean { + return urlGenerators.delete(namespace.trim()); +} + +/** 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()); +} + +/** + * Generate a URL for a record in the given namespace when metadata.url is missing. + * Uses the registry of URL generators; returns unavailable for namespaces without a generator. + */ +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}"`, + }; +} diff --git a/src/core/setup.ts b/src/core/setup.ts new file mode 100644 index 0000000..b1776ae --- /dev/null +++ b/src/core/setup.ts @@ -0,0 +1,70 @@ +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 { 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'; +import { registerListNamespacesTool } from './server/tools/list-namespaces-tool.js'; +import { registerNamespaceRouterTool } from './server/tools/namespace-router-tool.js'; +import { registerQueryDocumentsTool } from './server/tools/query-documents-tool.js'; +import { registerQueryTool } from './server/tools/query-tool.js'; + +let mcpServerInitialized = false; + +/** + * Reset process-global MCP server state (suggest-flow, namespace cache, active config, + * Pinecone client handle, URL generator registry). Call before a second {@link setupCoreServer}. + */ +export function teardownServer(): void { + resetSuggestionFlow(); + invalidateNamespacesCache(); + resetServerConfig(); + clearPineconeClient(); + resetUrlGenerationRegistry(); + mcpServerInitialized = false; +} + +/** + * Create and configure the MCP server with generic (core) tools only. + * + * Does not register Alliance-specific tools (`suggest_query_params`, `guided_query`) + * or built-in Boost/Slack URL generators. Use {@link setupAllianceServer} from + * `@will-cppa/pinecone-read-only-mcp/alliance` for the full tool surface. + */ +export async function setupCoreServer(config?: ServerConfig): Promise { + if (mcpServerInitialized) { + throw new Error( + 'setupCoreServer() already called in this process. The MCP server uses process-global state (suggest-flow, namespace cache, URL generators, config). Call teardownServer() first if you need to re-initialize.' + ); + } + + if (config) { + setServerConfig(config); + } + + const server = new McpServer( + { + name: SERVER_NAME, + version: SERVER_VERSION, + }, + { + instructions: SERVER_INSTRUCTIONS, + } + ); + + registerListNamespacesTool(server); + registerNamespaceRouterTool(server); + registerCountTool(server); + registerQueryTool(server); + registerKeywordSearchTool(server); + registerQueryDocumentsTool(server); + registerGenerateUrlsTool(server); + + mcpServerInitialized = true; + return server; +} diff --git a/src/index.ts b/src/index.ts index 1050743..faa05b5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,24 +3,24 @@ /** * Pinecone Read-Only MCP CLI entry point. * - * Thin composition root: parseCli() -> resolveConfig() -> setupServer(config) - * -> connect to stdio transport. All argv parsing lives in `src/cli.ts`; - * all configuration/defaults live in `src/config.ts`. + * Thin composition root: parseCli() -> resolveConfig() -> setupAllianceServer(config) + * -> connect to stdio transport. */ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import * as dotenv from 'dotenv'; import { parseCli, printHelp, printVersion } from './cli.js'; -import { resolveConfig, type ServerConfig } from './config.js'; -import { PineconeClient } from './pinecone-client.js'; -import { setupServer, setPineconeClient } from './server.js'; +import { resolveConfig, type ServerConfig } from './core/config.js'; +import { PineconeClient } from './core/pinecone-client.js'; +import { setPineconeClient } from './core/server/client-context.js'; +import { setupAllianceServer } from './alliance/setup.js'; import { setLogFormat, setLogLevel, warn as logWarn } from './logger.js'; dotenv.config(); /** * Build a config from CLI argv + environment, exiting fast on - * --help, --version, or missing API key. + * --help, --version, or missing API key / index name. */ function buildConfigOrExit(): ServerConfig { const parsed = parseCli(); @@ -82,9 +82,12 @@ async function main(): Promise { process.stderr.write( `Using Pinecone index: ${config.indexName} (sparse: ${config.sparseIndexName})\n` ); + if (config.rerankModel) { + process.stderr.write(`Rerank model: ${config.rerankModel}\n`); + } process.stderr.write(`Log level: ${config.logLevel} (format: ${config.logFormat})\n`); - const server = await setupServer(config); + const server = await setupAllianceServer(config); const transport = new StdioServerTransport(); await server.connect(transport); diff --git a/src/logger.ts b/src/logger.ts index 5c829ac..1baf5d0 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -11,7 +11,7 @@ * cannot leak through `error()`. */ -import type { LogFormat, LogLevel } from './config.js'; +import type { LogFormat, LogLevel } from './core/config.js'; const LEVEL_ORDER: Record = { DEBUG: 0, diff --git a/src/server.ts b/src/server.ts deleted file mode 100644 index 05fcad2..0000000 --- a/src/server.ts +++ /dev/null @@ -1,151 +0,0 @@ -/** - * @packageDocumentation - * **@will-cppa/pinecone-read-only-mcp** — programmatic entrypoint for the - * Pinecone read-only MCP server. - * - * Import from the package root: - * - * - {@link setupServer} — build an `McpServer` with all tools registered (at most once per process unless {@link teardownServer} runs). - * - {@link teardownServer} — clear process-global server state so {@link setupServer} can run again (tests, re-embedding). - * - {@link PineconeClient} — hybrid search, count, namespace listing, etc. - * - {@link resolveConfig} — merge CLI-style overrides with `process.env`. - * - {@link setPineconeClient} — inject a client instance before `setupServer()`. - * - {@link registerUrlGenerator} / {@link unregisterUrlGenerator} — extend URL synthesis (`UrlGeneratorFn`). - * - {@link toolErrorSchema} / {@link ToolError} — parse MCP tool failures (`isError: true` JSON bodies). - * - Built-in `mailing` / `slack-Cpplang` URL generators are registered from {@link setupServer} - * via {@link registerBuiltinUrlGenerators}; call it yourself if you use the library without `setupServer`. - * - * The CLI binary (`pinecone-read-only-mcp`) lives in `dist/index.js` and is not - * exported from this module. - */ - -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 { setServerConfig, resetServerConfig } from './server/config-context.js'; -import { clearPineconeClient } from './server/client-context.js'; -import { - registerBuiltinUrlGenerators, - resetUrlGenerationRegistry, -} from './server/url-generation.js'; -import { invalidateNamespacesCache } from './server/namespaces-cache.js'; -import { resetSuggestionFlow } from './server/suggestion-flow.js'; -import { registerCountTool } from './server/tools/count-tool.js'; -import { registerGuidedQueryTool } from './server/tools/guided-query-tool.js'; -import { registerGenerateUrlsTool } from './server/tools/generate-urls-tool.js'; -import { registerKeywordSearchTool } from './server/tools/keyword-search-tool.js'; -import { registerListNamespacesTool } from './server/tools/list-namespaces-tool.js'; -import { registerNamespaceRouterTool } from './server/tools/namespace-router-tool.js'; -import { registerQueryDocumentsTool } from './server/tools/query-documents-tool.js'; -import { registerQueryTool } from './server/tools/query-tool.js'; -import { registerSuggestQueryParamsTool } from './server/tools/suggest-query-params-tool.js'; - -export { setPineconeClient } from './server/client-context.js'; -/** Validate user-supplied Pinecone metadata filter objects before querying. */ -export { validateMetadataFilter } from './server/metadata-filter.js'; -/** Structured metadata filter validation (`field` dot-path); {@link validateMetadataFilter} remains a string-only wrapper. */ -export { validateMetadataFilterDetailed } from './server/metadata-filter.js'; -export type { MetadataFilterValidationError } from './server/metadata-filter.js'; -/** Zod schema and types for MCP tool error JSON bodies (`isError: true`). */ -export { toolErrorSchema } from './server/tool-error.js'; -export type { ToolError, ToolErrorCode } from './server/tool-error.js'; -/** Heuristic field + tool suggestions from a namespace schema + user query. */ -export { suggestQueryParams } from './server/query-suggestion.js'; -export type { RecommendedTool, SuggestQueryParamsResult } from './server/query-suggestion.js'; -/** Register custom per-namespace URL synthesis used by `generate_urls` / row enrichment. */ -export { - registerUrlGenerator, - unregisterUrlGenerator, - generateUrlForNamespace, - hasUrlGenerator, - registerBuiltinUrlGenerators, -} from './server/url-generation.js'; -export type { - UrlGenerationResult, - UrlGenerator, - UrlGeneratorFn, - RegisterBuiltinUrlGeneratorsOptions, -} from './server/url-generation.js'; -/** Build {@link ServerConfig} from CLI overrides + environment variables. */ -export { resolveConfig } from './config.js'; -export type { ServerConfig, LogLevel, LogFormat, ConfigOverrides } from './config.js'; -/** Pinecone SDK wrapper: hybrid query, keyword search, count, namespace metadata. */ -export { PineconeClient } from './pinecone-client.js'; -export type { - PineconeClientConfig, - QueryParams, - CountParams, - CountResult, - KeywordSearchParams, - SearchResult, - PineconeMetadataValue, - QueryResponse, - QueryResultRowShape, - KeywordIndexNamespacesResult, - HybridQueryResult, - HybridLegFailed, -} from './types.js'; - -let mcpServerInitialized = false; - -/** - * Reset process-global MCP server state (suggest-flow, namespace cache, active config, - * Pinecone client handle, URL generator registry). Call before a second {@link setupServer}. - */ -export function teardownServer(): void { - resetSuggestionFlow(); - invalidateNamespacesCache(); - resetServerConfig(); - clearPineconeClient(); - resetUrlGenerationRegistry(); - mcpServerInitialized = false; -} - -/** - * Create and configure the MCP server with all tools. - * - * Process-global state (one MCP client per Node process is assumed): - * suggest-flow gate (`stateByNamespace`), namespaces cache, URL generator registry, - * and {@link setServerConfig} — see README “Deployment model”. Multi-tenant HTTP - * multiplexing can violate the suggest-flow guarantee unless you isolate by session. - * - * A second call in the same process throws unless {@link teardownServer} runs first. - * - * @returns the configured `McpServer` instance, ready to connect to a transport. - */ -export async function setupServer(config?: ServerConfig): Promise { - if (mcpServerInitialized) { - throw new Error( - 'setupServer() already called in this process. The MCP server uses process-global state (suggest-flow, namespace cache, URL generators, config). Call teardownServer() first if you need to re-initialize.' - ); - } - - if (config) { - setServerConfig(config); - } - - registerBuiltinUrlGenerators(); - - const server = new McpServer( - { - name: SERVER_NAME, - version: SERVER_VERSION, - }, - { - instructions: SERVER_INSTRUCTIONS, - } - ); - - registerListNamespacesTool(server); - registerNamespaceRouterTool(server); - registerSuggestQueryParamsTool(server); - registerCountTool(server); - registerQueryTool(server); - registerKeywordSearchTool(server); - registerQueryDocumentsTool(server); - registerGuidedQueryTool(server); - registerGenerateUrlsTool(server); - - mcpServerInitialized = true; - return server; -} diff --git a/src/server/url-generation.test.ts b/src/server/url-generation.test.ts deleted file mode 100644 index c6269f9..0000000 --- a/src/server/url-generation.test.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { describe, expect, it, beforeAll, afterEach } from 'vitest'; -import type { UrlGeneratorFn } from './url-generation.js'; -import { - generateUrlForNamespace, - registerBuiltinUrlGenerators, - registerUrlGenerator, - unregisterUrlGenerator, -} from './url-generation.js'; - -beforeAll(() => { - registerBuiltinUrlGenerators(); -}); - -describe('generateUrlForNamespace', () => { - it('uses existing metadata.url when present', () => { - const r = generateUrlForNamespace('mailing', { - url: 'https://example.com/custom', - doc_id: 'ignored', - }); - expect(r.url).toBe('https://example.com/custom'); - expect(r.method).toBe('metadata.url'); - }); - - it('generates mailing URL from doc_id', () => { - const r = generateUrlForNamespace('mailing', { - doc_id: 'boost-announce@lists.boost.org/message/O5VYCDZADVDHK5Z5LAYJBHMDOAFQL7P6', - }); - expect(r.url).toBe( - 'https://lists.boost.org/archives/list/boost-announce@lists.boost.org/message/O5VYCDZADVDHK5Z5LAYJBHMDOAFQL7P6/' - ); - expect(r.method).toBe('generated.mailing'); - }); - - it('generates mailing URL from thread_id when doc_id missing', () => { - const r = generateUrlForNamespace('mailing', { - thread_id: 'boost@lists.boost.org/thread/ABC123', - }); - expect(r.url).toBe( - 'https://lists.boost.org/archives/list/boost@lists.boost.org/thread/ABC123/' - ); - expect(r.method).toBe('generated.mailing'); - }); - - it('generates mailing URL as list_name/message/doc_id when list_name present and doc_id does not contain it', () => { - const r = generateUrlForNamespace('mailing', { - list_name: 'boost-users', - doc_id: '12345', - }); - expect(r.url).toBe('https://lists.boost.org/archives/list/boost-users/message/12345/'); - expect(r.method).toBe('generated.mailing'); - }); - - it('uses msg_id when list_name present and doc_id missing', () => { - const r = generateUrlForNamespace('mailing', { - list_name: 'boost-announce', - msg_id: '67890', - }); - expect(r.url).toBe('https://lists.boost.org/archives/list/boost-announce/message/67890/'); - expect(r.method).toBe('generated.mailing'); - }); - - it('uses single-path form when doc_id contains list_name (no list_name/message split)', () => { - const r = generateUrlForNamespace('mailing', { - list_name: 'boost-users', - doc_id: 'boost-users@lists.boost.org/message/12345', - }); - expect(r.url).toBe( - 'https://lists.boost.org/archives/list/boost-users@lists.boost.org/message/12345/' - ); - expect(r.method).toBe('generated.mailing'); - }); - - it('uses slack source when available', () => { - const r = generateUrlForNamespace('slack-Cpplang', { - source: 'https://app.slack.com/client/T123/C123/p123', - team_id: 'T999', - channel_id: 'C999', - doc_id: '1.2', - }); - expect(r.url).toBe('https://app.slack.com/client/T123/C123/p123'); - expect(r.method).toBe('metadata.source'); - }); - - it('generates slack URL from team/channel/doc_id', () => { - const r = generateUrlForNamespace('slack-Cpplang', { - team_id: 'T123456789', - channel_id: 'C123456', - doc_id: '1234567.890', - }); - expect(r.url).toBe('https://app.slack.com/client/T123456789/C123456/p1234567890'); - expect(r.method).toBe('generated.slack'); - }); - - it('returns unavailable for unsupported namespace', () => { - const r = generateUrlForNamespace('wg21-papers', { doc_id: 'x' }); - expect(r.url).toBeNull(); - expect(r.method).toBe('unavailable'); - }); - it('returns unavailable for mailing when no doc_id or thread_id', () => { - const r = generateUrlForNamespace('mailing', { author: 'someone' }); - expect(r.url).toBeNull(); - expect(r.method).toBe('unavailable'); - }); - - it('returns unavailable for slack-Cpplang when required fields are missing', () => { - const r = generateUrlForNamespace('slack-Cpplang', { - team_id: 'T123', - // channel_id missing, doc_id missing, no source - }); - expect(r.url).toBeNull(); - expect(r.method).toBe('unavailable'); - }); -}); - -describe('registerUrlGenerator', () => { - const customNs = 'acme-docs'; - - afterEach(() => { - unregisterUrlGenerator(customNs); - registerBuiltinUrlGenerators({ reinstallBuiltins: true }); - }); - - it('registers a custom generator for a new namespace', () => { - const fn: UrlGeneratorFn = () => ({ - url: 'https://example.com/doc/1', - method: 'generated.custom', - }); - registerUrlGenerator(customNs, fn); - const r = generateUrlForNamespace(customNs, {}); - expect(r.url).toBe('https://example.com/doc/1'); - expect(r.method).toBe('generated.custom'); - }); - - it('allows a custom generator to override the mailing built-in', () => { - registerUrlGenerator('mailing', () => ({ - url: 'https://override.example/mailing', - method: 'generated.custom', - })); - const r = generateUrlForNamespace('mailing', { - doc_id: 'boost-announce@lists.boost.org/message/O5VYCDZADVDHK5Z5LAYJBHMDOAFQL7P6', - }); - expect(r.url).toBe('https://override.example/mailing'); - expect(r.method).toBe('generated.custom'); - }); -}); diff --git a/src/server/url-generation.ts b/src/server/url-generation.ts deleted file mode 100644 index e64c4c9..0000000 --- a/src/server/url-generation.ts +++ /dev/null @@ -1,209 +0,0 @@ -/** - * Per-namespace URL generation registry. - * - * Built-in generators cover `mailing` (Boost archive style) and - * `slack-Cpplang`. Library consumers can plug in their own with - * `registerUrlGenerator(namespace, generator)`. - */ - -/** Outcome of a URL-generation attempt. */ -export type UrlGenerationResult = { - url: string | null; - method: - | 'metadata.url' - | 'metadata.source' - | 'generated.mailing' - | 'generated.slack' - | 'generated.custom' - | 'unavailable'; - reason?: string; -}; - -/** - * Function that builds a URL for a record's metadata. - * - * Custom generators may return any of the standard `method` values, plus - * `'generated.custom'` for namespace-specific generators registered by - * library consumers. - */ -export type UrlGenerator = (metadata: Record) => UrlGenerationResult; - -/** - * Alias for {@link UrlGenerator} (issue / API naming: `UrlGeneratorFn`). - * Use either type when implementing custom URL synthesis. - */ -export type UrlGeneratorFn = UrlGenerator; - -/** Registry of namespace -> URL generator. Built-ins register via {@link registerBuiltinUrlGenerators}. */ -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; -} - -/** - * Build a mailing-list URL (e.g. Boost archives). - * Two cases: - * 1. If metadata has list_name and doc_id (or msg_id) and the message id does not contain list_name, - * URL is https://lists.boost.org/archives/list/{list_name}/message/{doc_id}/ - * 2. Otherwise use doc_id or thread_id as the list path: .../list/{doc_id_or_thread_id}/ - */ -function generatorMailing(metadata: Record): UrlGenerationResult { - const listName = asString(metadata['list_name']); - const docId = asString(metadata['doc_id']) ?? asString(metadata['msg_id']); - const threadId = asString(metadata['thread_id']); - - if (listName && docId && !docId.includes(listName)) { - return { - url: `https://lists.boost.org/archives/list/${listName}/message/${docId}/`, - method: 'generated.mailing', - }; - } - - const docIdOrThread = docId ?? threadId; - if (!docIdOrThread) { - return { - url: null, - method: 'unavailable', - reason: 'mailing requires doc_id, msg_id, or thread_id to generate URL', - }; - } - return { - url: `https://lists.boost.org/archives/list/${docIdOrThread}/`, - method: 'generated.mailing', - }; -} - -/** Build a Slack message URL from source or team_id/channel_id/doc_id. */ -function generatorSlackCpplang(metadata: Record): UrlGenerationResult { - const source = asString(metadata['source']); - if (source) { - return { url: source, method: 'metadata.source' }; - } - const teamId = asString(metadata['team_id']); - const channelId = asString(metadata['channel_id']); - const docId = asString(metadata['doc_id']); - if (!teamId || !channelId || !docId) { - return { - url: null, - method: 'unavailable', - reason: 'slack-Cpplang requires team_id, channel_id, and doc_id (or source)', - }; - } - const messageId = docId.replace(/\./g, ''); - return { - url: `https://app.slack.com/client/${teamId}/${channelId}/p${messageId}`, - method: 'generated.slack', - }; -} - -let builtinGeneratorsRegistered = false; - -/** Options for {@link registerBuiltinUrlGenerators}. */ -export type RegisterBuiltinUrlGeneratorsOptions = { - /** - * When `true`, re-applies the built-in `mailing` and `slack-Cpplang` generators - * even if they were already registered or were replaced by {@link registerUrlGenerator}. - * Use to restore defaults after an override (e.g. in tests). - */ - reinstallBuiltins?: boolean; -}; - -/** - * Register built-in generators (`mailing`, `slack-Cpplang`). - * - * With no options (or `reinstallBuiltins` omitted / `false`), the call is idempotent: - * only the first invocation in the process installs the two built-ins. - * - * With `{ reinstallBuiltins: true }`, always resets `mailing` and `slack-Cpplang` to - * the library implementations (does not remove other custom namespaces). - * - * Invoked from {@link setupServer} so embedders get the same defaults as the CLI; - * pure library use without calling `setupServer` should register explicitly if needed. - */ -export function registerBuiltinUrlGenerators(options?: RegisterBuiltinUrlGeneratorsOptions): void { - if (options?.reinstallBuiltins) { - urlGenerators.set('mailing', generatorMailing); - urlGenerators.set('slack-Cpplang', generatorSlackCpplang); - builtinGeneratorsRegistered = true; - return; - } - if (builtinGeneratorsRegistered) return; - urlGenerators.set('mailing', generatorMailing); - urlGenerators.set('slack-Cpplang', generatorSlackCpplang); - builtinGeneratorsRegistered = true; -} - -/** - * Clear all URL generators and the built-in registration latch. - * Used by {@link teardownServer} so a subsequent `setupServer()` can reinstall built-ins. - */ -export function resetUrlGenerationRegistry(): void { - urlGenerators.clear(); - builtinGeneratorsRegistered = false; -} - -/** - * Register a URL generator for a namespace, replacing any existing entry. - * - * @param namespace exact namespace name (matches the value returned by `list_namespaces`). - * @param generator function that turns a record's metadata into a URL ({@link UrlGeneratorFn}). - * - * @example - * ```ts - * import { registerUrlGenerator } from '@will-cppa/pinecone-read-only-mcp'; - * - * registerUrlGenerator('my-docs', (metadata) => { - * const id = typeof metadata.doc_id === 'string' ? metadata.doc_id : null; - * return id - * ? { url: `https://docs.example.com/${id}`, method: 'generated.custom' } - * : { url: null, method: 'unavailable', reason: 'doc_id missing' }; - * }); - * ``` - */ -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); -} - -/** Remove a namespace's URL generator. Returns true if a generator was removed. */ -export function unregisterUrlGenerator(namespace: string): boolean { - return urlGenerators.delete(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); -} - -/** - * Generate a URL for a record in the given namespace when metadata.url is missing. - * Uses the registry of URL generators; returns unavailable for namespaces without a generator. - */ -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); - if (generator) { - return generator(metadata); - } - - return { - url: null, - method: 'unavailable', - reason: `URL generation is not supported for namespace "${namespace}"`, - }; -} diff --git a/src/types.ts b/src/types.ts index b99e7c2..8cfe2a5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -8,18 +8,18 @@ export type PineconeMetadataValue = string | number | boolean | string[]; /** * Configuration for `new PineconeClient(config)`. * - * `apiKey` is required. All other fields are optional and fall back to - * sensible defaults from `src/constants.ts`. Values are expected to come from + * `apiKey` is required; `indexName` comes from {@link resolveConfig} (env or default). + * Values are expected to come from * {@link resolveConfig} / CLI or an equivalent resolved object — `PineconeClient` * does not read `process.env` directly. */ export interface PineconeClientConfig { apiKey: string; - /** Dense (hybrid) index name. */ - indexName?: string; + /** Dense (hybrid) index name. Required. */ + indexName: string; /** Sparse index name. Defaults to `${indexName}-sparse`. */ sparseIndexName?: string; - /** Reranker model identifier. */ + /** Reranker model identifier. When unset, reranking is disabled. */ rerankModel?: string; /** Default top-k for `query()`. */ defaultTopK?: number; @@ -41,6 +41,9 @@ export type HybridLegFailed = 'dense' | 'sparse' | null; /** * Outcome of {@link PineconeClient.query}: result rows plus degradation signals for MCP clients. */ +/** Why semantic rerank was not applied despite `useReranking: true`. */ +export type RerankSkippedReason = 'no_model'; + export interface HybridQueryResult { results: SearchResult[]; /** True when reranking was attempted and failed (rows may have `reranked: false`). */ @@ -49,6 +52,11 @@ export interface HybridQueryResult { degradation_reason?: string; /** Set when exactly one of dense/sparse search failed but the other succeeded. */ hybrid_leg_failed: HybridLegFailed; + /** + * Set when `useReranking` was true but no rerank model is configured on the client + * (manual `PineconeClient` without `rerankModel`). Normal MCP/CLI use sets a model via {@link resolveConfig}. + */ + rerank_skipped_reason?: RerankSkippedReason; } export interface PineconeHit { @@ -155,6 +163,8 @@ export interface QueryResponse { degradation_reason?: string; /** Partial hybrid failure: one leg failed while the other returned hits. */ hybrid_leg_failed?: HybridLegFailed; + /** Present when reranking was requested but no rerank model is on the client. */ + rerank_skipped_reason?: RerankSkippedReason; } /** Internal merged hit shape before rerank (dense + sparse deduped). */