diff --git a/CHANGELOG.md b/CHANGELOG.md index 98912f2..a56f1de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Tagged releases are published to npm from GitHub Actions when a **GitHub Release ### 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. - Zod `toolErrorSchema` and exported types `ToolError` / `ToolErrorCode` for parsing MCP tool failures; all tools now return this JSON shape in the text content when `isError` is true. - `validateMetadataFilterDetailed()` returns `{ message, field }` for invalid filters; `validateMetadataFilter()` remains a string-only wrapper for backward compatibility. - `.coderabbit.yaml` sets the pre-merge **docstring coverage** threshold to **79%** (default **80%**) so marginal documentation-only gaps do not block merges; adjust upward as coverage improves. diff --git a/README.md b/README.md index 228eb3e..f4ca377 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,34 @@ Run `pinecone-read-only-mcp --help` for CLI equivalents (`--cache-ttl-seconds`, 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. +### 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. + +```ts +import { + registerUrlGenerator, + setupServer, + type UrlGenerationResult, + type UrlGeneratorFn, +} from '@will-cppa/pinecone-read-only-mcp'; + +const server = await setupServer(config); + +const myDocs: UrlGeneratorFn = (metadata): UrlGenerationResult => { + 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' }; +}; + +registerUrlGenerator('product-docs', myDocs); +``` + +A fuller embedding sample lives in [examples/custom-url-generator.ts](examples/custom-url-generator.ts). + ### Claude Desktop Configuration Add to your `claude_desktop_config.json`: diff --git a/src/server.ts b/src/server.ts index 7badaac..6ed356c 100644 --- a/src/server.ts +++ b/src/server.ts @@ -9,7 +9,7 @@ * - {@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. + * - {@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`. @@ -53,7 +53,12 @@ export { hasUrlGenerator, registerBuiltinUrlGenerators, } from './server/url-generation.js'; -export type { UrlGenerationResult, UrlGenerator } 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'; diff --git a/src/server/url-generation.test.ts b/src/server/url-generation.test.ts index 716df20..c6269f9 100644 --- a/src/server/url-generation.test.ts +++ b/src/server/url-generation.test.ts @@ -1,5 +1,11 @@ -import { describe, expect, it, beforeAll } from 'vitest'; -import { generateUrlForNamespace, registerBuiltinUrlGenerators } from './url-generation.js'; +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(); @@ -105,3 +111,35 @@ describe('generateUrlForNamespace', () => { 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 index b4e20a1..8acbd11 100644 --- a/src/server/url-generation.ts +++ b/src/server/url-generation.ts @@ -28,8 +28,14 @@ export type UrlGenerationResult = { */ 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(); +const urlGenerators = new Map(); /** Return a trimmed non-empty string or null for empty/missing values. */ function asString(value: unknown): string | null { @@ -94,12 +100,35 @@ function generatorSlackCpplang(metadata: Record): UrlGeneration 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`). Idempotent. + * 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(): void { +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); @@ -110,7 +139,7 @@ export function registerBuiltinUrlGenerators(): void { * 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. + * @param generator function that turns a record's metadata into a URL ({@link UrlGeneratorFn}). * * @example * ```ts @@ -124,8 +153,15 @@ export function registerBuiltinUrlGenerators(): void { * }); * ``` */ -export function registerUrlGenerator(namespace: string, generator: UrlGenerator): void { - urlGenerators.set(namespace, generator); +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. */