Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:
Expand Down
9 changes: 7 additions & 2 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -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';
Expand Down
42 changes: 40 additions & 2 deletions src/server/url-generation.test.ts
Original file line number Diff line number Diff line change
@@ -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();
Expand Down Expand Up @@ -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');
});
});
48 changes: 42 additions & 6 deletions src/server/url-generation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,14 @@ export type UrlGenerationResult = {
*/
export type UrlGenerator = (metadata: Record<string, unknown>) => 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<string, UrlGenerator>();
const urlGenerators = new Map<string, UrlGeneratorFn>();

/** Return a trimmed non-empty string or null for empty/missing values. */
function asString(value: unknown): string | null {
Expand Down Expand Up @@ -94,12 +100,35 @@ function generatorSlackCpplang(metadata: Record<string, unknown>): 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);
Expand All @@ -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
Expand All @@ -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);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/** Remove a namespace's URL generator. Returns true if a generator was removed. */
Expand Down
Loading