Skip to content

Commit cc4e057

Browse files
jonathanMLDevzho
andauthored
Load per-source schema manifests from Pinecone _mcp_config at startup (cppalliance#210)
* Support JSON-shaped PINECONE_SOURCES with descriptions and namespaces * remove a staff example line * updated CHANGELOG.md * Load per-source schema manifests from Pinecone _mcp_config at startup * addressed ai reviews * fixed format error * One-line JSDoc on loadRemoteSchemaForSource and loadRemoteSchemaForSources * fixed CONFIGURATION.md --------- Co-authored-by: zho <jornathanm910923@gmail.com>
1 parent e2a0e91 commit cc4e057

18 files changed

Lines changed: 901 additions & 149 deletions

CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ Tagged releases are published to npm from GitHub Actions when a **GitHub Release
1010

1111
### Added
1212

13-
- **Per-source and per-namespace private config:** optional `description` (source-level) and `namespaces` map (`description` + declared `metadata_schema`) in JSON config files (`PINECONE_CONFIG_FILE` only — not inline `PINECONE_SOURCES`). Declared schemas skip live sampling; stale declared namespaces surface as `config_warnings` in `list_namespaces`. See [CONFIGURATION.md](docs/CONFIGURATION.md#json-config-file).
13+
- **Per-source and per-namespace private config:** optional `description` (source-level) and `namespaces` map (`description` + declared `metadata_schema`) in JSON config files (`PINECONE_CONFIG_FILE`), or loaded automatically from the `_mcp_config` Pinecone namespace when not declared locally. Declared schemas skip live sampling; stale declared namespaces surface as `config_warnings` in `list_namespaces`. See [CONFIGURATION.md § Multi-source mode](docs/CONFIGURATION.md#multi-source-mode).
14+
- **Remote `_mcp_config` schema manifest:** automatic per-source loading of description and namespace declarations from Pinecone at startup (opt out via `PINECONE_DISABLE_REMOTE_SCHEMA` / `--disable-remote-schema`); non-fatal on failure.
1415
- **`list_namespaces`:** optional per-namespace `schema_source` (`declared` | `sampled`), optional per-namespace `description` (from private config), and top-level `config_warnings` when private config declarations do not match live Pinecone data.
1516
- **Tests:** MCP verification harness ([src/__tests__/mcp-rc-readiness.test.ts](src/__tests__/mcp-rc-readiness.test.ts)) that drives the real server over an in-memory transport with the SDK client (initialize and protocol negotiation, the full registered tool surface, and a round-trip tool call), so a future SDK or MCP protocol bump is verified in one place. Protocol assertions key off the SDK's `LATEST_PROTOCOL_VERSION`, so pinning the RC SDK re-runs the check with no test edits. (#202)
1617

docs/CONFIGURATION.md

Lines changed: 151 additions & 103 deletions
Large diffs are not rendered by default.

docs/SECURITY.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ This covers tool error payloads, hybrid degradation reasons, and SDK error text
2525

2626
## Private config content (descriptions and schemas)
2727

28-
Per-source `description` and per-namespace `namespaces` (including namespace names and `metadata_schema` field names) loaded from `PINECONE_CONFIG_FILE` are **High**-risk if committed to the open-source repo or shared through public distribution channels — they can disclose what private corpora contain and which internal metadata fields exist. Keep real values on staff machines only; use generic placeholders in examples and tests. Same deployment-profile separation as API keys — see [CONFIGURATION.md § Deployment profiles](./CONFIGURATION.md#deployment-profiles).
28+
Per-source `description` and per-namespace `namespaces` (including namespace names and `metadata_schema` field names) loaded from `PINECONE_CONFIG_FILE` or the `_mcp_config` schema manifest are **High**-risk if committed to the open-source repo or shared through public distribution channels — they can disclose what private corpora contain and which internal metadata fields exist. Manifests in `_mcp_config` inherit the same access boundary as the rest of that Pinecone index; do not publish sensitive descriptions on indexes shared more broadly than the private data warrants. Keep real values on staff machines only; use generic placeholders in examples and tests. Same deployment-profile separation as API keys — see [CONFIGURATION.md § Deployment profiles](./CONFIGURATION.md#deployment-profiles).
2929

3030
## Docker image
3131

docs/TOOLS.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,19 @@ Or single-shot: `guided_query` (routes across sources when `namespace` is omitte
6464

6565
**Suggest-flow in multi-source mode:** gate state uses compound keys `source:namespace`. Pass the same `source` + `namespace` pair for `suggest_query_params` and gated tools when the namespace exists on multiple sources.
6666

67+
### Design notes
68+
69+
**Chosen:** multi-source server with optional `source` parameter on tools (`SourceRegistry` + `PINECONE_SOURCES` / JSON config), over:
70+
71+
- **Cursor routing rules only:** doesn't fix the UX problem when users forget which MCP entry to use; no code changes.
72+
- **Thin proxy MCP:** extra package and latency; duplicates routing logic already in `ServerContext`.
73+
74+
**Rationale:** Pinecone SDK v8 supports multiple `Pinecone({ apiKey })` instances per process; MCP has no barrier to aggregating backends. Security is enforced by **deployment profiles** (public-only vs merged config), not per-query MCP authorization. All multi-source results include `source` for LLM provenance.
75+
76+
**Execution semantics:** discovery tools aggregate all sources when `source` is omitted. Execution tools (`query`, `count`, etc.) call `resolveSource`: infer source when the namespace exists on exactly one project; return `VALIDATION` when ambiguous. They do **not** fan out one query to all sources. `guided_query` without `namespace` routes via the aggregated namespace list and sets `selected_source` in `decision_trace`.
77+
78+
**Audit logging:** when a tool resolves a specific source, stderr logs `toolname [source=name]` at INFO (execution tools and discovery tools that pass an explicit `source` filter). Aggregated discovery without `source` does not log per-source lines.
79+
6780
---
6881

6982
## `list_sources` (multi-source only)

src/cli.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,4 +36,12 @@ describe('parseCli', () => {
3636
expect(r.overrides.defaultTopK).toBe(25);
3737
}
3838
});
39+
40+
it('parses --disable-remote-schema', () => {
41+
const r = parseCli(['--disable-remote-schema']);
42+
expect(r.kind).toBe('config');
43+
if (r.kind === 'config') {
44+
expect(r.overrides.disableRemoteSchema).toBe(true);
45+
}
46+
});
3947
});

src/cli.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,9 @@ export function parseCli(argv: string[] = process.argv.slice(2)): ParseCliResult
117117
case '--check-indexes':
118118
overrides.checkIndexes = true;
119119
break;
120+
case '--disable-remote-schema':
121+
overrides.disableRemoteSchema = true;
122+
break;
120123
case '--sources': {
121124
const v = readOptionValue(argv, i);
122125
if (v !== undefined) {
@@ -160,7 +163,8 @@ Options:
160163
--request-timeout-ms N Per Pinecone call timeout [env: PINECONE_REQUEST_TIMEOUT_MS]
161164
--disable-suggest-flow Bypass suggest_query_params gate (PINECONE_DISABLE_SUGGEST_FLOW)
162165
--check-indexes Verify dense + sparse indexes then exit 0/1 (PINECONE_CHECK_INDEXES)
163-
--sources TEXT Multi-source inline config (PINECONE_SOURCES)
166+
--disable-remote-schema Skip loading schema manifest from _mcp_config (PINECONE_DISABLE_REMOTE_SCHEMA)
167+
--sources TEXT Multi-source inline config: name:apiKey:indexName;... (PINECONE_SOURCES)
164168
--config-file PATH Multi-source JSON config file (PINECONE_CONFIG_FILE)
165169
--help, -h Show this message
166170
--version, -v Print package version

src/core/config.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@ export interface ServerConfigBase {
5757
disableSuggestFlow: boolean;
5858
/** When true, on-startup probe verifies dense + sparse indexes exist. */
5959
checkIndexes: boolean;
60+
/** When true, skip loading schema manifest from `_mcp_config` at startup. */
61+
disableRemoteSchema: boolean;
6062
/** Named Pinecone sources when multi-project mode is enabled. */
6163
sources?: SourceDefinition[];
6264
/** Default source name when multi-source mode is enabled. */
@@ -150,6 +152,7 @@ export interface ConfigOverrides {
150152
requestTimeoutMs?: number;
151153
disableSuggestFlow?: boolean;
152154
checkIndexes?: boolean;
155+
disableRemoteSchema?: boolean;
153156
sources?: string;
154157
configFile?: string;
155158
}
@@ -196,6 +199,8 @@ export function resolveConfig(
196199
const disableSuggestFlow =
197200
overrides.disableSuggestFlow ?? asBool(env['PINECONE_DISABLE_SUGGEST_FLOW'], true);
198201
const checkIndexes = overrides.checkIndexes ?? asBool(env['PINECONE_CHECK_INDEXES'], false);
202+
const disableRemoteSchema =
203+
overrides.disableRemoteSchema ?? asBool(env['PINECONE_DISABLE_REMOTE_SCHEMA'], false);
199204

200205
const multi = resolveSourceDefinitions(overrides, env, parseSourcesOptions);
201206
if (multi) {
@@ -217,6 +222,7 @@ export function resolveConfig(
217222
requestTimeoutMs,
218223
disableSuggestFlow,
219224
checkIndexes,
225+
disableRemoteSchema,
220226
sources: multi.sources,
221227
defaultSource: multi.defaultSource,
222228
});
@@ -254,6 +260,7 @@ export function resolveConfig(
254260
requestTimeoutMs,
255261
disableSuggestFlow,
256262
checkIndexes,
263+
disableRemoteSchema,
257264
});
258265
}
259266

src/core/pinecone-client.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,21 @@ describe('PineconeClient', () => {
6969
});
7070
});
7171

72+
describe('fetchRecordFields', () => {
73+
it('delegates to indexSession.fetchRecordFields', async () => {
74+
const fields = { chunk_text: '{"ok":true}' };
75+
const fetchMock = vi.fn().mockResolvedValue(fields);
76+
Object.assign(client, {
77+
indexSession: { fetchRecordFields: fetchMock },
78+
});
79+
80+
const result = await client.fetchRecordFields('_mcp_config', 'schema_manifest');
81+
82+
expect(fetchMock).toHaveBeenCalledWith('_mcp_config', 'schema_manifest');
83+
expect(result).toEqual(fields);
84+
});
85+
});
86+
7287
describe('query', () => {
7388
it('should throw error for empty query', async () => {
7489
await expect(

src/core/pinecone-client.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import type {
1515
HybridLegFailed,
1616
} from '../types.js';
1717
import { DEFAULT_TOP_K, MAX_TOP_K, COUNT_TOP_K, COUNT_FIELDS } from '../constants.js';
18+
import { DEFAULT_REQUEST_TIMEOUT_MS } from './config.js';
1819
import { PineconeIndexSession, type NamespacesWithMetadataResult } from './pinecone/indexes.js';
1920
import {
2021
countUniqueDocumentsFromHits,
@@ -39,7 +40,8 @@ export class PineconeClient {
3940
this.indexSession = new PineconeIndexSession(
4041
config.apiKey,
4142
config.indexName,
42-
config.sparseIndexName
43+
config.sparseIndexName,
44+
config.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS
4345
);
4446
const normalizedRerankModel = config.rerankModel?.trim();
4547
this.rerankModel = normalizedRerankModel ? normalizedRerankModel : undefined;
@@ -90,6 +92,11 @@ export class PineconeClient {
9092
return this.indexSession.checkIndexes();
9193
}
9294

95+
/** Fetch record fields from the dense index (metadata + top-level scalars). */
96+
async fetchRecordFields(namespace: string, id: string): Promise<Record<string, unknown> | null> {
97+
return this.indexSession.fetchRecordFields(namespace, id);
98+
}
99+
93100
private async searchIndex(
94101
index: SearchableIndex,
95102
query: string,

src/core/pinecone/indexes.test.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,4 +270,83 @@ describe('PineconeIndexSession', () => {
270270
expect(result.errors.some((e) => e.includes('no client'))).toBe(true);
271271
});
272272
});
273+
274+
describe('fetchRecordFields', () => {
275+
class FetchFieldsSession extends PineconeIndexSession {
276+
constructor(
277+
private readonly fetchResponse: {
278+
records?: Record<string, { metadata?: Record<string, unknown>; [key: string]: unknown }>;
279+
}
280+
) {
281+
super('test-api-key', 'test-index');
282+
}
283+
284+
override ensureClient() {
285+
return {
286+
index: () => ({
287+
fetch: vi.fn().mockResolvedValue(this.fetchResponse),
288+
}),
289+
} as never;
290+
}
291+
}
292+
293+
it('merges metadata and top-level scalar fields', async () => {
294+
const session = new FetchFieldsSession({
295+
records: {
296+
schema_manifest: {
297+
id: 'schema_manifest',
298+
chunk_text: '{"ok":true}',
299+
metadata: { extra: 'meta' },
300+
},
301+
},
302+
});
303+
304+
const fields = await session.fetchRecordFields('_mcp_config', 'schema_manifest');
305+
expect(fields).toMatchObject({
306+
extra: 'meta',
307+
chunk_text: '{"ok":true}',
308+
id: 'schema_manifest',
309+
});
310+
});
311+
312+
it('returns null when record is missing', async () => {
313+
const session = new FetchFieldsSession({ records: {} });
314+
const fields = await session.fetchRecordFields('_mcp_config', 'schema_manifest');
315+
expect(fields).toBeNull();
316+
});
317+
318+
it('returns metadata-only fields when chunk_text is only in metadata', async () => {
319+
const session = new FetchFieldsSession({
320+
records: {
321+
schema_manifest: {
322+
metadata: { chunk_text: '{"from":"metadata"}' },
323+
},
324+
},
325+
});
326+
327+
const fields = await session.fetchRecordFields('_mcp_config', 'schema_manifest');
328+
expect(fields?.chunk_text).toBe('{"from":"metadata"}');
329+
});
330+
331+
it('rejects when fetch exceeds requestTimeoutMs', async () => {
332+
class HangingFetchSession extends PineconeIndexSession {
333+
constructor() {
334+
super('test-api-key', 'test-index', undefined, 50);
335+
}
336+
337+
override ensureClient() {
338+
return {
339+
index: () => ({
340+
fetch: () => new Promise(() => {}),
341+
}),
342+
} as never;
343+
}
344+
}
345+
346+
const session = new HangingFetchSession();
347+
await expect(session.fetchRecordFields('_mcp_config', 'schema_manifest')).rejects.toThrow(
348+
/Timeout after 50ms while waiting for fetchRecordFields/
349+
);
350+
});
351+
});
273352
});

0 commit comments

Comments
 (0)