diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e2ec39..1f4955d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,11 +10,14 @@ Tagged releases are published to npm from GitHub Actions when a **GitHub Release ### Changed +- **Breaking (MCP):** Experimental tool response fields are nested under `experimental` on success payloads. Affected tools: `query`, `query_documents`, `guided_query`. Fields moved: `degraded`, `degradation_reason`, `hybrid_leg_failed`, `rerank_skipped_reason` (query-shaped tools); `decision_trace` (`guided_query`). Stable fields (`status`, `results`, `namespace`, etc.) are unchanged. See [MIGRATION.md](docs/MIGRATION.md#unreleased-stable-vs-experimental-response-fields). - **Breaking (core):** `resolveConfig` requires a Pinecone index name and no longer applies Alliance index/rerank defaults. Removed exported `DEFAULT_INDEX_NAME` and `DEFAULT_RERANK_MODEL` from the package root. Rerank is opt-in when `PINECONE_RERANK_MODEL` / `rerankModel` is unset. - **Breaking (core):** `setupCoreServer` MCP `instructions` use `CORE_SERVER_INSTRUCTIONS` (no `guided_query` / `suggest_query_params`). `resolveConfig` defaults `disableSuggestFlow` to `true` so `query` / `count` / `query_documents` work without Alliance tools. Alliance CLI / `resolveAllianceConfig` unchanged: gate on by default, `ALLIANCE_SERVER_INSTRUCTIONS`. - **Alliance CLI / `resolveAllianceConfig`:** When index or rerank env/CLI values are omitted, defaults remain `rag-hybrid` and `bge-reranker-v2-m3` (API-key-only MCP configs unchanged). See [examples/alliance/.env.example](examples/alliance/.env.example). ### Added +- Zod schemas for all nine MCP tool success responses (`queryResponseSchema`, `guidedQueryResponseSchema`, etc.) exported from the package root for client-side validation. Success payloads are runtime-validated before return. +- Stable vs experimental response field taxonomy documented in [docs/TOOLS.md](docs/TOOLS.md) and [docs/deprecation-policy.md](docs/deprecation-policy.md#stable-vs-experimental-mcp-response-fields). - Formal deprecation policy ([docs/deprecation-policy.md](docs/deprecation-policy.md)) and breaking-change release notes template ([docs/templates/breaking-change-release-notes.md](docs/templates/breaking-change-release-notes.md)). ## [0.2.0] - 2026-05-29 diff --git a/README.md b/README.md index 9253e5b..079ff92 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,7 @@ To try the server on **your own** Pinecone project (free tier, no Alliance index 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`; embed via `resolveAllianceConfig` → `createServer` / `setClient` → `setupAllianceServer({ context: ctx })` (see [Library embedding](#library-embedding)). +- **`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 use `createServer(config)` → `ctx.setClient(...)` → `setupAllianceServer({ context: ctx })` for the full tool surface (what the CLI uses); see [Library embedding](#library-embedding) below. ## Configuration @@ -198,7 +198,7 @@ A fuller embedding sample lives in [examples/alliance/custom-url-generator.ts](e | File | Description | | ---- | ----------- | | [examples/alliance/suggest-flow-demo.ts](examples/alliance/suggest-flow-demo.ts) | Manual **suggest_query_params → query** flow | -| [examples/alliance/guided-query-demo.ts](examples/alliance/guided-query-demo.ts) | **guided_query** and `decision_trace` | +| [examples/alliance/guided-query-demo.ts](examples/alliance/guided-query-demo.ts) | **guided_query** and `experimental.decision_trace` | | [examples/alliance/library-embedding-demo.ts](examples/alliance/library-embedding-demo.ts) | **setupAllianceServer** without the CLI | | [examples/alliance/custom-url-generator.ts](examples/alliance/custom-url-generator.ts) | Custom **URL generator** registration | @@ -424,7 +424,7 @@ Single orchestrator tool that runs the full flow in one call: 2. query param suggestion, 3. execution via `count` or hybrid `query` (`fast` / `detailed` / `full` presets). -It returns both the final result and a `decision_trace` for transparency. +It returns both the final result and `experimental.decision_trace` for transparency. **Parameters:** @@ -437,7 +437,7 @@ It returns both the final result and a `decision_trace` for transparency. | `preferred_tool` | enum | No | `auto` | One of `auto`, `count`, `fast`, `detailed`, `full` | | `enrich_urls` | boolean | No | `true` | Auto-generate URLs for `mailing` and `slack-Cpplang` when `metadata.url` is missing | -**Returns:** JSON containing `decision_trace` and `result`. +**Returns:** JSON containing `experimental.decision_trace` and `result`. ### `generate_urls` diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index f84d19e..d113b08 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -28,7 +28,7 @@ Configuration is built from **CLI flags** (when using the binary), **environment **Throws** if `apiKey` or `indexName` 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 follow the [Library embedding](#library-embedding) flow: `resolveAllianceConfig` → `createServer` / `setClient` → `setupAllianceServer({ context: ctx })`. +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 use the three-step instance-first recipe at [Library embedding](#library-embedding) below. ### Core vs Alliance resolvers diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 2ccbc8f..7325f88 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -49,6 +49,14 @@ Follow [deprecation-policy.md](./deprecation-policy.md). In summary: - **Breaking** a release (especially while `0.y.z`): use labeled bullets — `**Breaking (MCP):**`, `**Breaking (types):**`, etc. — each with what changed, who is affected, and a link to a MIGRATION anchor. - **Removing** deprecated APIs: add `### Removed` only after the deprecation window; keep migration text in MIGRATION.md. +## Response field stability + +When changing MCP tool **success** response shapes: + +- New fields default to the **`experimental`** nested object unless explicitly promoted (see [deprecation-policy.md § Stable vs experimental](./deprecation-policy.md#stable-vs-experimental-mcp-response-fields)). +- Update Zod schemas in `src/core/server/response-schemas.ts` and export from `src/core/index.ts`. +- Update [TOOLS.md](./TOOLS.md) stable/experimental tables for the affected tool(s). + ## Documentation Authoritative reference lives under [`docs/`](./README.md). When adding tools or config knobs, update `docs/TOOLS.md` and `docs/CONFIGURATION.md` in the same PR when possible. diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md index b079a19..0b6c61d 100644 --- a/docs/MIGRATION.md +++ b/docs/MIGRATION.md @@ -6,6 +6,69 @@ This guide is for **library and MCP client authors** upgrading from earlier **0. Under [semver 0.y.z](https://semver.org/spec/v2.0.0.html#spec-item-4), **0.1.x → 0.2.0 is a breaking minor** — pin `@0.2.0` only after reading this guide. +## Unreleased: Stable vs experimental response fields + +**Rationale:** Tool success payloads mixed stable contract fields with experimental diagnostics (`degraded`, `decision_trace`, etc.) at the top level. Experimental fields are now nested under `experimental` so consumers know which fields are safe across minor version bumps. + +**Who is affected:** MCP clients and integrators parsing `query`, `query_documents`, or `guided_query` success JSON. + +**Before (`query`):** + +```json +{ + "status": "success", + "results": [], + "degraded": true, + "degradation_reason": "rerank_failed: timeout", + "hybrid_leg_failed": "dense" +} +``` + +**After (`query`):** + +```json +{ + "status": "success", + "results": [], + "experimental": { + "degraded": true, + "degradation_reason": "rerank_failed: timeout", + "hybrid_leg_failed": "dense" + } +} +``` + +**Before (`guided_query`):** + +```json +{ + "status": "success", + "decision_trace": { "selected_namespace": "mailing" }, + "result": { "status": "success", "results": [], "degraded": false } +} +``` + +**After (`guided_query`):** + +```json +{ + "status": "success", + "experimental": { + "decision_trace": { "selected_namespace": "mailing", "rerank_status": "success" } + }, + "result": { + "status": "success", + "results": [] + } +} +``` + +When no experimental fields apply, the `experimental` key is **omitted** (not an empty object). + +**Client-side validation:** Import Zod schemas from the package root, e.g. `import { queryResponseSchema } from '@will-cppa/pinecone-read-only-mcp'`. + +**Promotion:** Moving a field from `experimental` to stable requires CHANGELOG, TOOLS.md, and schema updates per [deprecation-policy.md § Stable vs experimental](./deprecation-policy.md#stable-vs-experimental-mcp-response-fields). + ## Unreleased: `ServerContext` instance APIs (phase 1) **Rationale:** Process-global singletons (Pinecone client slot, config, URL registry, suggest-flow gate, namespaces cache) complicate testing and multi-tenant embedding. Phase 1 introduces an opt-in **`ServerContext`** without removing legacy getters. diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 0a28765..0ca6790 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -2,6 +2,24 @@ Unless noted, failures return MCP `isError: true` with JSON matching `ToolError` (see [MIGRATION.md](./MIGRATION.md) and [README error table](../README.md#error-responses)). +## Response field stability + +Success payloads separate **stable** fields (safe across minor bumps after `1.0.0`) from **experimental** fields (may change before `1.0.0`). Experimental fields are nested under `experimental` when present; the key is omitted when empty. + +| Tool | Stable | Experimental | +| ---- | ------ | ------------ | +| `list_namespaces` | `status`, `cache_hit`, `cache_ttl_seconds`, `expires_at_iso`, `count`, `namespaces` | _(none)_ | +| `namespace_router` | `status`, `cache_hit`, `user_query`, `suggestions`, `recommended_namespace` | _(none)_ | +| `suggest_query_params` | `status`, `cache_hit`, `suggested_fields`, `recommended_tool`, `use_count_tool`, `explanation`, `namespace_found` | _(none)_ | +| `count` | `status`, `count`, `truncated`, `namespace`, `metadata_filter` | _(none)_ | +| `query` | `status`, `mode`, `query`, `namespace`, `metadata_filter`, `result_count`, `results`, `fields` | `experimental.degraded`, `experimental.degradation_reason`, `experimental.hybrid_leg_failed`, `experimental.rerank_skipped_reason` | +| `keyword_search` | `status`, `query`, `namespace`, `index`, `metadata_filter`, `result_count`, `results`, `fields` | _(none)_ | +| `query_documents` | `status`, `query`, `namespace`, `metadata_filter`, `result_count`, `documents` | Same experimental degradation fields as `query` | +| `guided_query` | `status`, `result` (count or query-shaped stable fields) | `experimental.decision_trace`; query-path `result.experimental` degradation fields | +| `generate_urls` | `status`, `namespace`, `count`, `results` | _(none)_ | + +Promotion process: [deprecation-policy.md § Stable vs experimental](./deprecation-policy.md#stable-vs-experimental-mcp-response-fields). + ## Core vs Alliance tool surface | Setup | Tools | MCP instructions | @@ -108,7 +126,7 @@ When **`disableSuggestFlow`** is **`true`** (core default via `resolveConfig`), | `metadata_filter` | object | no | Metadata filter | | `fields` | string[] | no | Pinecone fields to return | -**Success (`QueryResponse`):** `{ status: 'success', mode?: 'query' \| 'query_fast' \| 'query_detailed', query, namespace, metadata_filter?, result_count, results[], fields?, degraded?, degradation_reason?, hybrid_leg_failed? }`. +**Success (`QueryResponse`):** `{ status: 'success', mode?, query, namespace, metadata_filter?, result_count, results[], fields?, experimental?: { degraded?, degradation_reason?, hybrid_leg_failed?, rerank_skipped_reason? } }`. Each row: `document_id`, `paper_number` (deprecated alias), `title`, `author`, `url`, `content`, `score`, `reranked`, optional `metadata`. @@ -125,17 +143,17 @@ Each row: `document_id`, `paper_number` (deprecated alias), `title`, `author`, ` ### Rerank and hybrid degradation -When reranking is requested but the rerank API fails, the server still returns **`status: 'success'`** with rows where `reranked: false`, plus envelope fields: +When reranking is requested but the rerank API fails, the server still returns **`status: 'success'`** with rows where `reranked: false`, plus **experimental** envelope fields: | Field | When set | Meaning | | ----- | -------- | ------- | -| `degraded` | `true` | Rerank was attempted and failed (or another degradation path fired) | -| `degradation_reason` | string | Human-readable detail for MCP/LLM clients (e.g. `rerank_failed: timeout after 5000ms`) | -| `hybrid_leg_failed` | `'dense'` \| `'sparse'` \| omitted / `null` | Exactly one hybrid search leg failed while the other returned hits | +| `experimental.degraded` | `true` | Rerank was attempted and failed (or another degradation path fired) | +| `experimental.degradation_reason` | string | Human-readable detail for MCP/LLM clients (e.g. `rerank_failed: timeout after 5000ms`) | +| `experimental.hybrid_leg_failed` | `'dense'` \| `'sparse'` | Exactly one hybrid search leg failed while the other returned hits | -Treat **`degraded: true`** as lower confidence even when `status` is `success`. Combine with per-row `reranked`, `preset`, and `use_reranking`. Structured stderr logs may include additional detail. +Treat **`experimental.degraded: true`** as lower confidence even when `status` is `success`. Combine with per-row `reranked`, `preset`, and `use_reranking`. Structured stderr logs may include additional detail. -`query_documents` propagates the same flags on its nested query payload when applicable. +`query_documents` propagates the same experimental flags when applicable. --- @@ -167,7 +185,7 @@ Treat **`degraded: true`** as lower confidence even when `status` is `success`. | `metadata_filter` | object | no | Filter | | `max_chunks_per_document` | int | no | Cap merged chunks per doc (default 200, max 500) | -**Success:** `{ status: 'success', query, namespace, metadata_filter?, result_count, documents: [{ document_id, merged_content, metadata, chunk_count, best_score }] }`. +**Success:** `{ status: 'success', query, namespace, metadata_filter?, result_count, documents[], experimental?: { degraded?, degradation_reason?, hybrid_leg_failed?, rerank_skipped_reason? } }`. --- @@ -184,11 +202,11 @@ Treat **`degraded: true`** as lower confidence even when `status` is `success`. | `preferred_tool` | `auto` \| `count` \| `fast` \| `detailed` \| `full` | no | Override automated tool choice | | `enrich_urls` | boolean | no (default true) | Run URL generator when metadata lacks `url` | -**Success:** `{ status: 'success', decision_trace, result }` where `result` is either a count payload or a `QueryResponse`-shaped query payload. +**Success:** `{ status: 'success', experimental: { decision_trace }, result }` where `result` is either a count payload or a `QueryResponse`-shaped query payload. -**`decision_trace` fields (non-exhaustive):** `cache_hit`, `input_namespace`, `routed_namespace`, `selected_namespace`, `ranked_namespaces`, `suggested_fields`, `suggested_tool`, `selected_tool`, `explanation`, `enrich_urls`, `rerank_status` (`success` \| `skipped` \| `failed`). +**`experimental.decision_trace` fields (non-exhaustive):** `cache_hit`, `input_namespace`, `routed_namespace`, `selected_namespace`, `ranked_namespaces`, `suggested_fields`, `suggested_tool`, `selected_tool`, `explanation`, `enrich_urls`, `rerank_status` (`success` \| `skipped` \| `skipped_no_model` \| `failed`). -When the inner query path runs, `result` includes the same `degraded`, `degradation_reason`, and `hybrid_leg_failed` fields as `query` (see [Rerank and hybrid degradation](#rerank-and-hybrid-degradation)). +When the inner query path runs, `result.experimental` includes the same degradation fields as `query` (see [Rerank and hybrid degradation](#rerank-and-hybrid-degradation)). **Example:** @@ -227,4 +245,4 @@ Single-shot: guided_query ``` -Canonical Zod schemas live beside each handler under `src/server/tools/*.ts`. +Canonical Zod input schemas live beside each handler under `src/core/server/tools/*.ts` and `src/alliance/tools/*.ts`. Success response schemas are in `src/core/server/response-schemas.ts` and exported from the package root (e.g. `queryResponseSchema`, `guidedQueryResponseSchema`). diff --git a/docs/deprecation-policy.md b/docs/deprecation-policy.md index 260e3a0..67963f3 100644 --- a/docs/deprecation-policy.md +++ b/docs/deprecation-policy.md @@ -64,6 +64,33 @@ While **`0.y.z`**, a minor release **may** ship breaking changes without a prior Security fixes may break behavior when required; document impact in CHANGELOG and MIGRATION. +## Stable vs experimental MCP response fields + +MCP tool **success** responses separate fields into two tiers: + +| Tier | Meaning | Location in JSON | +| ---- | ------- | ---------------- | +| **Stable** | Safe to depend on across minor version bumps after `1.0.0` | Top-level keys (`status`, `results`, `namespace`, `count`, etc.) | +| **Experimental** | May change or be removed before `1.0.0` | Nested under `experimental` when present | + +Current experimental fields (see [TOOLS.md](./TOOLS.md) per tool): + +- `degraded`, `degradation_reason`, `hybrid_leg_failed`, `rerank_skipped_reason` — query-shaped tools (`query`, `query_documents`, and `guided_query.result` when applicable) +- `decision_trace` — `guided_query` only + +The `experimental` key is **omitted** when no experimental fields are set. + +### Promoting experimental → stable + +To promote a field from experimental to stable: + +1. **CHANGELOG** — `### Changed` entry describing the promotion and target release. +2. **TOOLS.md** — Move the field from the Experimental to Stable table for the affected tool(s). +3. **Schema** — Move the field out of `experimental` in `response-schemas.ts` and update handler builders. +4. **MIGRATION.md** — Document before/after for integrators. + +New response fields added before `1.0.0` default to `experimental` unless explicitly promoted through this process. + ## Future instance APIs (`ServerContext`) Phase 1 of the **`ServerContext`** / **`createServer(config)`** refactor is available while legacy module-level getters remain supported. See [MIGRATION.md § ServerContext instance APIs](./MIGRATION.md#unreleased-servercontext-instance-apis-phase-1) for upgrade steps. diff --git a/examples/alliance/README.md b/examples/alliance/README.md index 8e7f89a..3b98224 100644 --- a/examples/alliance/README.md +++ b/examples/alliance/README.md @@ -3,7 +3,7 @@ These examples target the **full** MCP surface via `setupAllianceServer` from `@will-cppa/pinecone-read-only-mcp/alliance`: - `suggest_query_params` and the suggest-flow gate -- `guided_query` with `decision_trace` +- `guided_query` with `experimental.decision_trace` - Built-in URL generators for `mailing` and `slack-Cpplang` They assume a Pinecone index you control with compatible data (not necessarily the C++ Alliance production index). To bootstrap a **neutral** index from scratch, start with [examples/quickstart/README.md](../quickstart/README.md). @@ -24,7 +24,7 @@ Optional: `PINECONE_RERANK_MODEL`, `PINECONE_SPARSE_INDEX_NAME`, etc. See [docs/ | File | Description | | ---- | ----------- | | [suggest-flow-demo.ts](./suggest-flow-demo.ts) | Manual **suggest_query_params → query** flow | -| [guided-query-demo.ts](./guided-query-demo.ts) | **guided_query** and `decision_trace` | +| [guided-query-demo.ts](./guided-query-demo.ts) | **guided_query** and `experimental.decision_trace` | | [library-embedding-demo.ts](./library-embedding-demo.ts) | Programmatic **setupAllianceServer** wiring | | [custom-url-generator.ts](./custom-url-generator.ts) | Custom **URL generator** registration | | [demo-mock-pinecone-client.ts](./demo-mock-pinecone-client.ts) | Mock client with `mailing` namespace (no network) | diff --git a/examples/alliance/guided-query-demo.ts b/examples/alliance/guided-query-demo.ts index d0e96dd..6c10a77 100644 --- a/examples/alliance/guided-query-demo.ts +++ b/examples/alliance/guided-query-demo.ts @@ -6,7 +6,7 @@ * - internal `suggest_query_params` + `markSuggested` * - execution of `count` or hybrid `query` with `fast` / `detailed` / `full` * - * The success payload includes **`decision_trace`**: cache hit, routed vs + * The success payload includes **`experimental.decision_trace`**: cache hit, routed vs * selected namespace, suggested fields/tools, and the final `selected_tool`. * Use the trace in UIs or logs to explain why a path was chosen. * @@ -28,7 +28,7 @@ async function main(): Promise { if (!apiKey || !indexName) { console.log( '[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.' + 'Call guided_query with user_query; read experimental.decision_trace + result in the JSON response.' ); return; } diff --git a/src/alliance/tools/guided-query-tool.context.test.ts b/src/alliance/tools/guided-query-tool.context.test.ts index 7e8a2a0..5948dfe 100644 --- a/src/alliance/tools/guided-query-tool.context.test.ts +++ b/src/alliance/tools/guided-query-tool.context.test.ts @@ -3,10 +3,12 @@ import { registerBuiltinUrlGenerators } from '../url-builtins.js'; import { registerQueryTool } from '../../core/server/tools/query-tool.js'; import { registerSuggestQueryParamsTool } from './suggest-query-params-tool.js'; import { registerGuidedQueryTool } from './guided-query-tool.js'; +import { guidedQueryResponseSchema } from '../../core/server/response-schemas.js'; import { assertToolErrorCode, createMockServer, createTestServerContext, + expectMatchesResponseSchema, makeHybridQueryResult, makeSearchResult, parseToolJson, @@ -64,10 +66,14 @@ describe('guided_query tool handler (ServerContext instance path)', () => { enrich_urls: false, }); const body = parseToolJson(raw); + expectMatchesResponseSchema(guidedQueryResponseSchema, body); expect(body).toMatchObject({ status: 'success', }); - const trace = body['decision_trace'] as Record; + const trace = (body['experimental'] as Record)['decision_trace'] as Record< + string, + unknown + >; expect(trace).toMatchObject({ cache_hit: false, selected_namespace: 'papers', @@ -99,9 +105,10 @@ describe('guided_query tool handler (ServerContext instance path)', () => { }) ); const result = body['result'] as Record; - expect(result['degraded']).toBe(true); - expect(result['hybrid_leg_failed']).toBe('sparse'); - expect(result['degradation_reason']).toBe('sparse_leg_empty'); + const resultExperimental = result['experimental'] as Record; + expect(resultExperimental['degraded']).toBe(true); + expect(resultExperimental['hybrid_leg_failed']).toBe('sparse'); + expect(resultExperimental['degradation_reason']).toBe('sparse_leg_empty'); }); it('enriches urls via ctx builtins when enrich_urls is true', async () => { diff --git a/src/alliance/tools/guided-query-tool.test.ts b/src/alliance/tools/guided-query-tool.test.ts index bcc5b68..d39f614 100644 --- a/src/alliance/tools/guided-query-tool.test.ts +++ b/src/alliance/tools/guided-query-tool.test.ts @@ -74,11 +74,15 @@ describe('guided_query tool handler', () => { }) ); - const trace = body.decision_trace as Record; + const trace = (body.experimental as Record).decision_trace as Record< + string, + unknown + >; expect(trace.rerank_status).toBe('failed'); const result = body.result as Record; - expect(result.degraded).toBe(true); - expect(result.degradation_reason).toBe('rerank_failed: timeout'); + const resultExperimental = result.experimental as Record; + expect(resultExperimental.degraded).toBe(true); + expect(resultExperimental.degradation_reason).toBe('rerank_failed: timeout'); }); it('guided_query: reports skipped_no_model when rerank was requested but no model configured', async () => { @@ -103,7 +107,10 @@ describe('guided_query tool handler', () => { }) ); - const trace = body.decision_trace as Record; + const trace = (body.experimental as Record).decision_trace as Record< + string, + unknown + >; expect(trace.rerank_status).toBe('skipped_no_model'); }); @@ -123,7 +130,10 @@ describe('guided_query tool handler', () => { ); expect(body.status).toBe('success'); - const trace = body.decision_trace as Record; + const trace = (body.experimental as Record).decision_trace as Record< + string, + unknown + >; expect(trace.selected_namespace).toBe('papers'); expect(trace.selected_tool).toBe('detailed'); expect(trace.rerank_status).toBe('success'); @@ -160,7 +170,10 @@ describe('guided_query tool handler', () => { const result = body.result as Record; expect(result.tool).toBe('count'); expect(result.count).toBe(7); - const trace = body.decision_trace as Record; + const trace = (body.experimental as Record).decision_trace as Record< + string, + unknown + >; expect(trace.rerank_status).toBe('skipped'); }); diff --git a/src/alliance/tools/guided-query-tool.ts b/src/alliance/tools/guided-query-tool.ts index 0471db1..a6c2347 100644 --- a/src/alliance/tools/guided-query-tool.ts +++ b/src/alliance/tools/guided-query-tool.ts @@ -2,7 +2,6 @@ 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 '../../core/server/client-context.js'; import { formatQueryResultRows } from '../../core/server/format-query-result.js'; import { @@ -22,7 +21,14 @@ import { pineconeToolError, validationToolError, } from '../../core/server/tool-error.js'; -import { jsonErrorResponse, jsonResponse } from '../../core/server/tool-response.js'; +import { + buildGuidedQueryExperimental, + buildQueryExperimental, + guidedQueryResponseSchema, + type GuidedQueryResponse, + type QueryResponse, +} from '../../core/server/response-schemas.js'; +import { jsonErrorResponse, validatedJsonResponse } from '../../core/server/tool-response.js'; type GuidedToolName = 'count' | 'fast' | 'detailed' | 'full'; @@ -48,7 +54,7 @@ export function registerGuidedQueryTool(server: McpServer, ctx?: ServerContext): description: 'Combines namespace routing, suggestion, and query into a single call — no prerequisite tools needed. ' + 'Single orchestrator: optional namespace_router logic -> executes count or hybrid query (fast / detailed / full presets). ' + - 'Returns decision_trace so behavior stays transparent and debuggable.', + 'Returns experimental.decision_trace so behavior stays transparent and debuggable.', inputSchema: { user_query: z.string().describe('User question or intent.'), namespace: z @@ -172,6 +178,7 @@ export function registerGuidedQueryTool(server: McpServer, ctx?: ServerContext): } const client = ctx ? ctx.getClient() : getPineconeClient(); + const enrichUrls = enrich_urls ?? true; const baseTrace = { cache_hit, input_namespace: inputNamespace ?? null, @@ -182,7 +189,7 @@ export function registerGuidedQueryTool(server: McpServer, ctx?: ServerContext): suggested_tool: suggestion.recommended_tool, selected_tool: selectedTool, explanation: suggestion.explanation, - enrich_urls, + enrich_urls: enrichUrls, }; if (selectedTool === 'count') { @@ -191,21 +198,22 @@ export function registerGuidedQueryTool(server: McpServer, ctx?: ServerContext): namespace, metadataFilter: metadata_filter, }); - return jsonResponse({ + const countResponse: GuidedQueryResponse = { status: 'success', - decision_trace: { + ...buildGuidedQueryExperimental({ ...baseTrace, rerank_status: 'skipped' as const, - }, + }), result: { tool: 'count', namespace, query: queryText, - metadata_filter, + ...(metadata_filter !== undefined ? { metadata_filter } : {}), count, truncated, }, - }); + }; + return validatedJsonResponse(guidedQueryResponseSchema, countResponse); } const isFast = selectedTool === 'fast'; @@ -232,7 +240,7 @@ export function registerGuidedQueryTool(server: McpServer, ctx?: ServerContext): const rerank_status = guidedRerankStatus(!isFast, queryOutcome); const formattedResults = formatQueryResultRows(queryOutcome.results, { namespace, - enrichUrls: enrich_urls, + enrichUrls: enrichUrls, ctx, }); const result: QueryResponse = { @@ -244,20 +252,17 @@ export function registerGuidedQueryTool(server: McpServer, ctx?: ServerContext): result_count: formattedResults.length, ...(fields?.length ? { fields } : {}), results: formattedResults, - degraded: queryOutcome.degraded, - ...(queryOutcome.degradation_reason !== undefined - ? { degradation_reason: queryOutcome.degradation_reason } - : {}), - hybrid_leg_failed: queryOutcome.hybrid_leg_failed, + ...buildQueryExperimental(queryOutcome), }; - return jsonResponse({ + const queryResponse: GuidedQueryResponse = { status: 'success', - decision_trace: { + ...buildGuidedQueryExperimental({ ...baseTrace, rerank_status, - }, + }), result, - }); + }; + return validatedJsonResponse(guidedQueryResponseSchema, queryResponse); } catch (error) { logToolError('guided_query', error); return jsonErrorResponse(classifyToolCatchError(error, 'Failed to execute guided query')); diff --git a/src/alliance/tools/suggest-query-params-tool.context.test.ts b/src/alliance/tools/suggest-query-params-tool.context.test.ts index 7455d98..ccb81c0 100644 --- a/src/alliance/tools/suggest-query-params-tool.context.test.ts +++ b/src/alliance/tools/suggest-query-params-tool.context.test.ts @@ -1,9 +1,11 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { registerQueryTool } from '../../core/server/tools/query-tool.js'; import { registerSuggestQueryParamsTool } from './suggest-query-params-tool.js'; +import { suggestQueryParamsResponseSchema } from '../../core/server/response-schemas.js'; import { createMockServer, createTestServerContext, + expectMatchesResponseSchema, makeHybridQueryResult, parseToolJson, } from '../../core/server/tools/test-helpers.js'; @@ -54,6 +56,7 @@ describe('suggest_query_params tool handler (ServerContext instance path)', () = user_query: 'List papers with titles', }); const body = parseToolJson(raw); + expectMatchesResponseSchema(suggestQueryParamsResponseSchema, body); expect(body).toMatchObject({ status: 'success', namespace_found: true, diff --git a/src/alliance/tools/suggest-query-params-tool.ts b/src/alliance/tools/suggest-query-params-tool.ts index 83c36c4..09f846f 100644 --- a/src/alliance/tools/suggest-query-params-tool.ts +++ b/src/alliance/tools/suggest-query-params-tool.ts @@ -11,7 +11,11 @@ import { logToolError, validationToolError, } from '../../core/server/tool-error.js'; -import { jsonErrorResponse, jsonResponse } from '../../core/server/tool-response.js'; +import { + suggestQueryParamsResponseSchema, + type SuggestQueryParamsResponse, +} from '../../core/server/response-schemas.js'; +import { jsonErrorResponse, validatedJsonResponse } from '../../core/server/tool-response.js'; /** Register the suggest_query_params tool on the MCP server. */ export function registerSuggestQueryParamsTool(server: McpServer, ctx?: ServerContext): void { @@ -76,12 +80,12 @@ export function registerSuggestQueryParamsTool(server: McpServer, ctx?: ServerCo }); } } - const response = { + const response: SuggestQueryParamsResponse = { ...result, - status: 'success' as const, + status: 'success', cache_hit, }; - return jsonResponse(response); + return validatedJsonResponse(suggestQueryParamsResponseSchema, response); } catch (error) { logToolError('suggest_query_params', error); return jsonErrorResponse(classifyToolCatchError(error, 'Failed to suggest query params')); diff --git a/src/constants.ts b/src/constants.ts index 0dc6994..1d5476f 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -62,7 +62,7 @@ Usage: /** Alliance-only supplement appended to core instructions for {@link setupAllianceServer}. */ export const ALLIANCE_INSTRUCTIONS_APPENDIX = ` -Alliance quickstart: for most user questions, call \`guided_query\` with the user's question — it does namespace routing, suggestion, and execution in one shot and returns a decision_trace you can show the user. The Alliance CLI and \`resolveAllianceConfig\` default the index to \`rag-hybrid\` (and rerank to \`bge-reranker-v2-m3\`) when those env vars are omitted. +Alliance quickstart: for most user questions, call \`guided_query\` with the user's question — it does namespace routing, suggestion, and execution in one shot and returns \`experimental.decision_trace\` you can show the user. The Alliance CLI and \`resolveAllianceConfig\` default the index to \`rag-hybrid\` (and rerank to \`bge-reranker-v2-m3\`) when those env vars are omitted. For manual flows with the full tool surface, call \`list_namespaces\` -> \`suggest_query_params\` -> \`query\` (use preset fast/detailed/full per suggestion) or \`count\` (the suggest step is a mandatory gate unless \`PINECONE_DISABLE_SUGGEST_FLOW=true\`). diff --git a/src/core/index.ts b/src/core/index.ts index 986111b..d2845cb 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -15,6 +15,36 @@ export { 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 { + buildGuidedQueryExperimental, + buildQueryExperimental, + countResponseSchema, + generateUrlsResponseSchema, + guidedQueryResponseSchema, + keywordSearchResponseSchema, + keywordSearchSuccessResponseSchema, + listNamespacesResponseSchema, + namespaceRouterResponseSchema, + queryDocumentsResponseSchema, + queryResponseSchema, + querySuccessResponseSchema, + queryResultRowSchema, + suggestQueryParamsResponseSchema, +} from './server/response-schemas.js'; +export type { + CountResponse, + GenerateUrlsResponse, + GuidedQueryDecisionTrace, + GuidedQueryResponse, + KeywordSearchResponse, + KeywordSearchSuccessResponse, + ListNamespacesSuccessResponse, + NamespaceRouterResponse, + QueryDocumentsResponse, + QueryExperimental, + QuerySuccessResponse, + SuggestQueryParamsResponse, +} from './server/response-schemas.js'; export { suggestQueryParams } from './server/query-suggestion.js'; export type { RecommendedTool, SuggestQueryParamsResult } from './server/query-suggestion.js'; export { diff --git a/src/core/server/format-query-result.ts b/src/core/server/format-query-result.ts index 9c6914a..98f105e 100644 --- a/src/core/server/format-query-result.ts +++ b/src/core/server/format-query-result.ts @@ -3,8 +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 type { SearchResult } from '../../types.js'; import { warn as logWarn } from '../../logger.js'; +import type { QueryResultRowShape } from './response-schemas.js'; import type { ServerContext } from './server-context.js'; import { generateUrlForNamespace } from './url-registry.js'; @@ -17,31 +18,8 @@ export type FormatQueryResultOptions = { const DEFAULT_CONTENT_MAX_LENGTH = 2000; -/** - * One formatted query-result row. - * - * `document_id` is the canonical identifier. `paper_number` is a deprecated - * alias kept for one minor cycle for backwards compatibility — it will be - * removed in the next major release. - */ -export interface QueryResultRow { - /** Canonical document identifier. Prefer this in new code. */ - document_id: string | null; - /** - * @deprecated Use `document_id`. Kept for one minor cycle and removed in - * the next major release. The first formatted query-result row in the - * process triggers a single `WARN` log per session so consumers see the - * deprecation deadline. - */ - paper_number: string | null; - title: string; - author: string; - url: string; - content: string; - score: number; - reranked: boolean; - metadata?: Record; -} +/** Alias for {@link QueryResultRowShape}; single source of truth in `response-schemas.ts`. */ +export type QueryResultRow = QueryResultRowShape; let deprecationWarnedThisSession = false; @@ -64,7 +42,7 @@ export function formatSearchResultAsRow( options?: FormatQueryResultOptions ): QueryResultRow { const contentMaxLength = options?.contentMaxLength ?? DEFAULT_CONTENT_MAX_LENGTH; - const metadata = { ...doc.metadata } as Record; + const metadata = { ...doc.metadata }; if (options?.enrichUrls && options?.namespace) { const generated = options.ctx diff --git a/src/core/server/redaction.test.ts b/src/core/server/redaction.test.ts index 40e7417..332c875 100644 --- a/src/core/server/redaction.test.ts +++ b/src/core/server/redaction.test.ts @@ -134,8 +134,9 @@ describe('MCP response redaction', () => { }) ); - expect(body.degradation_reason).not.toContain(PCSK_KEY); - expect(String(body.degradation_reason)).toContain('***'); + const experimental = body.experimental as Record; + expect(experimental.degradation_reason).not.toContain(PCSK_KEY); + expect(String(experimental.degradation_reason)).toContain('***'); }); it('preserves non-sensitive UUIDs in success payload metadata', async () => { @@ -197,14 +198,15 @@ describe('MCP response redaction', () => { ); const result = body.result as Record; - expect(result.degradation_reason).not.toContain(PCSK_KEY); - expect(String(result.degradation_reason)).toContain('***'); + const resultExperimental = result.experimental as Record; + expect(resultExperimental.degradation_reason).not.toContain(PCSK_KEY); + expect(String(resultExperimental.degradation_reason)).toContain('***'); }); it('jsonResponse redacts nested degradation_reason without masking metadata UUIDs', () => { const payload = jsonResponse({ status: 'success', - degradation_reason: `rerank_failed: ${PCSK_KEY}`, + experimental: { degradation_reason: `rerank_failed: ${PCSK_KEY}` }, results: [{ metadata: { document_id: UUID_KEY } }], }); const text = payload.content[0]!.text; diff --git a/src/core/server/response-schemas.test.ts b/src/core/server/response-schemas.test.ts new file mode 100644 index 0000000..2a7d6fa --- /dev/null +++ b/src/core/server/response-schemas.test.ts @@ -0,0 +1,256 @@ +import { describe, expect, it } from 'vitest'; +import { + countResponseSchema, + generateUrlsResponseSchema, + guidedQueryResponseSchema, + keywordSearchResponseSchema, + keywordSearchSuccessResponseSchema, + listNamespacesResponseSchema, + namespaceRouterResponseSchema, + queryDocumentsResponseSchema, + queryResponseSchema, + queryResultRowSchema, + querySuccessResponseSchema, + suggestQueryParamsResponseSchema, +} from './response-schemas.js'; + +describe('response-schemas', () => { + it('accepts minimal valid query response', () => { + expect( + queryResponseSchema.parse({ + status: 'success', + mode: 'query_fast', + query: 'q', + namespace: 'wg21', + result_count: 1, + results: [ + { + document_id: 'D1', + paper_number: 'D1', + title: 'T', + author: 'A', + url: '', + content: 'c', + score: 0.9, + reranked: false, + }, + ], + }) + ).toBeDefined(); + }); + + it('accepts permissive query response with experimental degradation fields only', () => { + expect( + queryResponseSchema.parse({ + status: 'success', + experimental: { + degraded: true, + degradation_reason: 'rerank_failed: timeout', + hybrid_leg_failed: 'dense', + rerank_skipped_reason: 'no_model', + }, + }) + ).toBeDefined(); + }); + + it('accepts strict query success response with experimental degradation fields', () => { + expect( + querySuccessResponseSchema.parse({ + status: 'success', + mode: 'query_fast', + query: 'q', + namespace: 'wg21', + result_count: 0, + results: [], + experimental: { + degraded: true, + degradation_reason: 'rerank_failed: timeout', + hybrid_leg_failed: 'dense', + rerank_skipped_reason: 'no_model', + }, + }) + ).toBeDefined(); + }); + + it('rejects strict query success response missing stable fields', () => { + expect(() => + querySuccessResponseSchema.parse({ + status: 'success', + experimental: { degraded: true }, + }) + ).toThrow(); + }); + + it('rejects strict keyword search success response missing stable fields', () => { + expect(() => keywordSearchSuccessResponseSchema.parse({ status: 'success' })).toThrow(); + }); + + it('rejects query response missing status', () => { + expect(() => queryResponseSchema.parse({ results: [] })).toThrow(); + }); + + it('accepts guided_query count branch', () => { + expect( + guidedQueryResponseSchema.parse({ + status: 'success', + experimental: { + decision_trace: { + cache_hit: false, + input_namespace: null, + routed_namespace: 'papers', + selected_namespace: 'papers', + ranked_namespaces: [], + suggested_fields: [], + suggested_tool: 'count', + selected_tool: 'count', + explanation: 'count', + enrich_urls: true, + rerank_status: 'skipped', + }, + }, + result: { + tool: 'count', + namespace: 'papers', + query: 'how many', + count: 3, + truncated: false, + }, + }) + ).toBeDefined(); + }); + + it('accepts guided_query query branch with nested experimental on result', () => { + expect( + guidedQueryResponseSchema.parse({ + status: 'success', + experimental: { + decision_trace: { + cache_hit: true, + input_namespace: 'papers', + routed_namespace: 'papers', + selected_namespace: 'papers', + ranked_namespaces: [], + suggested_fields: ['title'], + suggested_tool: 'fast', + selected_tool: 'fast', + explanation: 'fast', + enrich_urls: false, + rerank_status: 'success', + }, + }, + result: { + status: 'success', + mode: 'query_fast', + query: 'q', + namespace: 'papers', + result_count: 0, + results: [], + experimental: { hybrid_leg_failed: 'sparse' }, + }, + }) + ).toBeDefined(); + }); + + it('validates all nine tool schemas with minimal fixtures', () => { + expect( + listNamespacesResponseSchema.parse({ + status: 'success', + cache_hit: false, + cache_ttl_seconds: 1800, + expires_at_iso: '2026-01-01T00:00:00.000Z', + count: 1, + namespaces: [{ name: 'wg21', record_count: 1, metadata_fields: { title: 'string' } }], + }) + ).toBeDefined(); + + expect( + namespaceRouterResponseSchema.parse({ + status: 'success', + cache_hit: true, + user_query: 'allocator', + suggestions: [{ namespace: 'wg21', score: 3, record_count: 1, reasons: ['token'] }], + recommended_namespace: 'wg21', + }) + ).toBeDefined(); + + expect( + suggestQueryParamsResponseSchema.parse({ + status: 'success', + cache_hit: false, + suggested_fields: ['title'], + recommended_tool: 'fast', + use_count_tool: false, + explanation: 'fast', + namespace_found: true, + }) + ).toBeDefined(); + + expect( + countResponseSchema.parse({ + status: 'success', + count: 5, + truncated: false, + namespace: 'wg21', + }) + ).toBeDefined(); + + expect( + keywordSearchResponseSchema.parse({ + status: 'success', + query: 'kw', + namespace: 'wg21', + result_count: 0, + results: [], + }) + ).toBeDefined(); + + expect( + queryDocumentsResponseSchema.parse({ + status: 'success', + query: 'q', + namespace: 'wg21', + result_count: 1, + documents: [ + { + document_id: 'D1', + merged_content: 'text', + metadata: { title: 'T' }, + chunk_count: 2, + best_score: 0.8, + }, + ], + }) + ).toBeDefined(); + + expect( + generateUrlsResponseSchema.parse({ + status: 'success', + namespace: 'mailing', + count: 1, + results: [ + { + index: 0, + url: 'https://example.com', + method: 'generated.custom', + reason: null, + metadata: {}, + }, + ], + }) + ).toBeDefined(); + }); + + it('queryResultRowSchema requires paper_number alias', () => { + expect(() => + queryResultRowSchema.parse({ + document_id: 'D1', + title: 'T', + author: 'A', + url: '', + content: '', + score: 1, + reranked: false, + }) + ).toThrow(); + }); +}); diff --git a/src/core/server/response-schemas.ts b/src/core/server/response-schemas.ts new file mode 100644 index 0000000..80965e4 --- /dev/null +++ b/src/core/server/response-schemas.ts @@ -0,0 +1,281 @@ +/** + * Zod schemas for MCP tool success responses. + * Types are derived via `z.infer` — single source of truth for response contracts. + */ + +import { z } from 'zod'; + +/** Pinecone metadata value types: string, number, boolean, or list of strings. */ +export const pineconeMetadataValueSchema = z.union([ + z.string(), + z.number(), + z.boolean(), + z.array(z.string()), +]); + +export const hybridLegFailedSchema = z.enum(['dense', 'sparse']).nullable(); + +export const rerankSkippedReasonSchema = z.literal('no_model'); + +/** One row in query / keyword_search / guided_query query results. */ +export const queryResultRowSchema = z.object({ + document_id: z.string().nullable(), + paper_number: z.string().nullable(), + title: z.string(), + author: z.string(), + url: z.string(), + content: z.string(), + score: z.number(), + reranked: z.boolean(), + metadata: z.record(z.string(), pineconeMetadataValueSchema).optional(), +}); + +export type QueryResultRowShape = z.infer; + +/** Experimental degradation / diagnostic fields for query-shaped responses. */ +export const queryExperimentalSchema = z.object({ + degraded: z.boolean().optional(), + degradation_reason: z.string().optional(), + hybrid_leg_failed: hybridLegFailedSchema.optional(), + rerank_skipped_reason: rerankSkippedReasonSchema.optional(), +}); + +export type QueryExperimental = z.infer; + +const rankedNamespaceSchema = z.object({ + namespace: z.string(), + score: z.number(), + record_count: z.number(), + reasons: z.array(z.string()), +}); + +export const guidedQueryDecisionTraceSchema = z.object({ + cache_hit: z.boolean(), + input_namespace: z.string().nullable(), + routed_namespace: z.string().nullable(), + selected_namespace: z.string(), + ranked_namespaces: z.array(rankedNamespaceSchema), + suggested_fields: z.array(z.string()), + suggested_tool: z.enum(['count', 'fast', 'detailed', 'full']), + selected_tool: z.enum(['count', 'fast', 'detailed', 'full']), + explanation: z.string(), + enrich_urls: z.boolean(), + rerank_status: z.enum(['success', 'skipped', 'skipped_no_model', 'failed']), +}); + +export type GuidedQueryDecisionTrace = z.infer; + +/** Top-level experimental block for guided_query (decision trace only). */ +export const guidedQueryExperimentalSchema = z.object({ + decision_trace: guidedQueryDecisionTraceSchema, +}); + +export const queryResponseSchema = z.object({ + status: z.literal('success'), + mode: z.enum(['query', 'query_fast', 'query_detailed']).optional(), + query: z.string().optional(), + namespace: z.string().optional(), + metadata_filter: z.record(z.string(), z.unknown()).optional(), + result_count: z.number().optional(), + fields: z.array(z.string()).optional(), + results: z.array(queryResultRowSchema).optional(), + experimental: queryExperimentalSchema.optional(), +}); + +export type QueryResponse = z.infer; + +/** Strict handler-boundary schema for `query` / `query_fast` / `query_detailed` success payloads. */ +export const querySuccessResponseSchema = z.object({ + status: z.literal('success'), + mode: z.enum(['query', 'query_fast', 'query_detailed']), + query: z.string(), + namespace: z.string(), + metadata_filter: z.record(z.string(), z.unknown()).optional(), + result_count: z.number(), + fields: z.array(z.string()).optional(), + results: z.array(queryResultRowSchema), + experimental: queryExperimentalSchema.optional(), +}); + +export type QuerySuccessResponse = z.infer; + +export const listNamespacesResponseSchema = z.object({ + status: z.literal('success'), + cache_hit: z.boolean(), + cache_ttl_seconds: z.number(), + expires_at_iso: z.string(), + count: z.number(), + namespaces: z.array( + z.object({ + name: z.string(), + record_count: z.number(), + metadata_fields: z.record(z.string(), z.string()), + }) + ), +}); + +export type ListNamespacesSuccessResponse = z.infer; + +export const namespaceRouterResponseSchema = z.object({ + status: z.literal('success'), + cache_hit: z.boolean(), + user_query: z.string(), + suggestions: z.array(rankedNamespaceSchema), + recommended_namespace: z.string().nullable(), +}); + +export type NamespaceRouterResponse = z.infer; + +export const suggestQueryParamsResponseSchema = z.object({ + status: z.literal('success'), + cache_hit: z.boolean(), + suggested_fields: z.array(z.string()), + use_count_tool: z.boolean(), + recommended_tool: z.enum(['count', 'fast', 'detailed', 'full']), + explanation: z.string(), + namespace_found: z.boolean(), +}); + +export type SuggestQueryParamsResponse = z.infer; + +export const countResponseSchema = z.object({ + status: z.literal('success'), + count: z.number(), + truncated: z.boolean(), + namespace: z.string(), + metadata_filter: z.record(z.string(), z.unknown()).optional(), +}); + +export type CountResponse = z.infer; + +export const keywordSearchResponseSchema = z.object({ + status: z.literal('success'), + query: z.string().optional(), + namespace: z.string().optional(), + index: z.string().optional(), + metadata_filter: z.record(z.string(), z.unknown()).optional(), + result_count: z.number().optional(), + results: z.array(queryResultRowSchema).optional(), + fields: z.array(z.string()).optional(), +}); + +/** @deprecated Import from `response-schemas` / package root; alias kept for one minor cycle. */ +export type KeywordSearchResponse = z.infer; + +/** Strict handler-boundary schema for `keyword_search` success payloads. */ +export const keywordSearchSuccessResponseSchema = z.object({ + status: z.literal('success'), + query: z.string(), + namespace: z.string(), + index: z.string(), + metadata_filter: z.record(z.string(), z.unknown()).optional(), + result_count: z.number(), + results: z.array(queryResultRowSchema), + fields: z.array(z.string()).optional(), +}); + +export type KeywordSearchSuccessResponse = z.infer; + +const queryDocumentRowSchema = z.object({ + document_id: z.string(), + merged_content: z.string(), + metadata: z.record(z.string(), pineconeMetadataValueSchema), + chunk_count: z.number(), + best_score: z.number(), +}); + +export const queryDocumentsResponseSchema = z.object({ + status: z.literal('success'), + query: z.string(), + namespace: z.string(), + metadata_filter: z.record(z.string(), z.unknown()).optional(), + result_count: z.number(), + documents: z.array(queryDocumentRowSchema), + experimental: queryExperimentalSchema.optional(), +}); + +export type QueryDocumentsResponse = z.infer; + +export const guidedCountResultSchema = z.object({ + tool: z.literal('count'), + namespace: z.string(), + query: z.string(), + metadata_filter: z.record(z.string(), z.unknown()).optional(), + count: z.number(), + truncated: z.boolean(), +}); + +export const guidedQueryResultSchema = z.union([guidedCountResultSchema, queryResponseSchema]); + +export const guidedQueryResponseSchema = z.object({ + status: z.literal('success'), + result: guidedQueryResultSchema, + experimental: guidedQueryExperimentalSchema.optional(), +}); + +export type GuidedQueryResponse = z.infer; + +const urlMethodSchema = z.enum([ + 'metadata.url', + 'metadata.source', + 'generated.mailing', + 'generated.slack', + 'generated.custom', + 'unavailable', +]); + +export const generateUrlsResponseSchema = z.object({ + status: z.literal('success'), + namespace: z.string(), + count: z.number(), + results: z.array( + z.object({ + index: z.number(), + url: z.string().nullable(), + method: urlMethodSchema, + reason: z.string().nullable(), + metadata: z.record(z.string(), z.unknown()), + }) + ), +}); + +export type GenerateUrlsResponse = z.infer; + +/** + * Assemble optional `experimental` block for query-shaped tool responses. + * Omits the key entirely when no experimental fields are present. + */ +export function buildQueryExperimental(outcome: { + degraded: boolean; + degradation_reason?: string; + hybrid_leg_failed: 'dense' | 'sparse' | null; + rerank_skipped_reason?: 'no_model'; +}): { experimental?: QueryExperimental } { + const experimental: QueryExperimental = {}; + + if (outcome.degraded === true) { + experimental.degraded = true; + } + if (outcome.degradation_reason !== undefined) { + experimental.degradation_reason = outcome.degradation_reason; + } + if (outcome.hybrid_leg_failed === 'dense' || outcome.hybrid_leg_failed === 'sparse') { + experimental.hybrid_leg_failed = outcome.hybrid_leg_failed; + } + if (outcome.rerank_skipped_reason !== undefined) { + experimental.rerank_skipped_reason = outcome.rerank_skipped_reason; + } + + if (Object.keys(experimental).length === 0) { + return {}; + } + + return { experimental }; +} + +/** Assemble `experimental.decision_trace` for guided_query responses. */ +export function buildGuidedQueryExperimental(decision_trace: GuidedQueryDecisionTrace): { + experimental: z.infer; +} { + return { experimental: { decision_trace } }; +} diff --git a/src/core/server/tool-response.test.ts b/src/core/server/tool-response.test.ts new file mode 100644 index 0000000..983223d --- /dev/null +++ b/src/core/server/tool-response.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest'; +import { z } from 'zod'; +import { validatedJsonResponse } from './tool-response.js'; + +describe('tool-response', () => { + it('returns VALIDATION error when schema rejects payload', () => { + const schema = z.object({ status: z.literal('success'), count: z.number() }); + const result = validatedJsonResponse(schema, { status: 'success' } as never); + expect(result.isError).toBe(true); + const body = JSON.parse(result.content[0]!.text); + expect(body.code).toBe('VALIDATION'); + expect(body.field).toBe('response'); + }); +}); diff --git a/src/core/server/tool-response.ts b/src/core/server/tool-response.ts index 86483c6..3134a92 100644 --- a/src/core/server/tool-response.ts +++ b/src/core/server/tool-response.ts @@ -1,6 +1,7 @@ -import { redactSensitiveFields } from '../../logger.js'; +import type { z } from 'zod'; +import { error as logError, redactSensitiveFields } from '../../logger.js'; import type { ToolError } from './tool-error.js'; -import { toolErrorSchema } from './tool-error.js'; +import { toolErrorSchema, validationToolError } from './tool-error.js'; export type TextPayload = { content: Array<{ type: 'text'; text: string }>; @@ -20,6 +21,19 @@ export function jsonResponse(payload: unknown): TextPayload { }; } +/** Build an MCP tool success payload validated against a Zod schema before returning. */ +export function validatedJsonResponse(schema: z.ZodType, payload: T): TextPayload { + try { + const validated = schema.parse(payload); + return jsonResponse(validated); + } catch (err) { + logError('Response schema validation failed', err); + return jsonErrorResponse( + validationToolError('Internal response shape validation failed', 'response') + ); + } +} + /** Build an MCP tool error payload with JSON-stringified {@link ToolError} and isError: true. */ export function jsonErrorResponse(err: ToolError): TextPayload { const validated = toolErrorSchema.parse(err); diff --git a/src/core/server/tools/count-tool.context.test.ts b/src/core/server/tools/count-tool.context.test.ts index 13c17f8..32116d3 100644 --- a/src/core/server/tools/count-tool.context.test.ts +++ b/src/core/server/tools/count-tool.context.test.ts @@ -1,9 +1,11 @@ import { describe, expect, it, vi } from 'vitest'; import { registerCountTool } from './count-tool.js'; +import { countResponseSchema } from '../response-schemas.js'; import { assertToolErrorCode, createMockServer, createTestServerContext, + expectMatchesResponseSchema, parseToolJson, } from './test-helpers.js'; @@ -26,6 +28,7 @@ describe('count tool handler (ServerContext instance path)', () => { query_text: 'papers', }); const body = parseToolJson(raw); + expectMatchesResponseSchema(countResponseSchema, body); expect(body).toMatchObject({ status: 'success', count: 7, diff --git a/src/core/server/tools/count-tool.ts b/src/core/server/tools/count-tool.ts index 50e6fc6..97dac0a 100644 --- a/src/core/server/tools/count-tool.ts +++ b/src/core/server/tools/count-tool.ts @@ -12,16 +12,8 @@ import { logToolError, validationToolError, } from '../tool-error.js'; -import { jsonErrorResponse, jsonResponse } from '../tool-response.js'; - -const COUNT_RESPONSE_STATUS = 'success' as const; -type CountResponse = { - status: 'success'; - count: number; - truncated: boolean; - namespace: string; - metadata_filter?: Record; -}; +import { countResponseSchema, type CountResponse } from '../response-schemas.js'; +import { jsonErrorResponse, validatedJsonResponse } from '../tool-response.js'; type CountExecParams = { namespace: string; @@ -63,13 +55,13 @@ async function executeCount(params: CountExecParams, ctx?: ServerContext) { metadataFilter: metadata_filter, }); const response: CountResponse = { - status: COUNT_RESPONSE_STATUS, + status: 'success', count, truncated, namespace: nsNorm, metadata_filter, }; - return jsonResponse(response); + return validatedJsonResponse(countResponseSchema, response); } catch (error) { logToolError('count', error); return jsonErrorResponse(classifyToolCatchError(error, 'Failed to get count')); diff --git a/src/core/server/tools/generate-urls-tool.context.test.ts b/src/core/server/tools/generate-urls-tool.context.test.ts index 28b18ce..3a41ecf 100644 --- a/src/core/server/tools/generate-urls-tool.context.test.ts +++ b/src/core/server/tools/generate-urls-tool.context.test.ts @@ -1,13 +1,19 @@ import { describe, expect, it } from 'vitest'; import { registerGenerateUrlsTool } from './generate-urls-tool.js'; -import { createMockServer, createTestServerContext, parseToolJson } from './test-helpers.js'; +import { generateUrlsResponseSchema } from '../response-schemas.js'; +import { + createMockServer, + createTestServerContext, + expectMatchesResponseSchema, + parseToolJson, +} from './test-helpers.js'; describe('generate_urls tool handler (ServerContext instance path)', () => { it('uses URL generator registered on injected context', async () => { const ctx = createTestServerContext(); ctx.registerUrlGenerator('mailing', () => ({ url: 'https://example.com/doc/P1234', - method: 'generator', + method: 'generated.custom', })); const server = createMockServer(); @@ -17,6 +23,7 @@ describe('generate_urls tool handler (ServerContext instance path)', () => { records: [{ document_number: 'P1234' }], }); const body = parseToolJson(raw); + expectMatchesResponseSchema(generateUrlsResponseSchema, body); expect(body).toMatchObject({ status: 'success', namespace: 'mailing', @@ -25,7 +32,7 @@ describe('generate_urls tool handler (ServerContext instance path)', () => { const results = body['results'] as Array<{ url: string; method: string }>; expect(results[0]).toMatchObject({ url: 'https://example.com/doc/P1234', - method: 'generator', + method: 'generated.custom', }); }); }); diff --git a/src/core/server/tools/generate-urls-tool.ts b/src/core/server/tools/generate-urls-tool.ts index 8ad19ae..25cd928 100644 --- a/src/core/server/tools/generate-urls-tool.ts +++ b/src/core/server/tools/generate-urls-tool.ts @@ -9,7 +9,8 @@ import { logToolError, validationToolError, } from '../tool-error.js'; -import { jsonErrorResponse, jsonResponse } from '../tool-response.js'; +import { generateUrlsResponseSchema, type GenerateUrlsResponse } from '../response-schemas.js'; +import { jsonErrorResponse, validatedJsonResponse } from '../tool-response.js'; /** Get metadata from a record (either record.metadata or the record itself). */ function extractMetadata(record: Record): Record { @@ -70,12 +71,13 @@ export function registerGenerateUrlsTool(server: McpServer, ctx?: ServerContext) }; }); - return jsonResponse({ + const response: GenerateUrlsResponse = { status: 'success', namespace: nsNorm, count: results.length, results, - }); + }; + return validatedJsonResponse(generateUrlsResponseSchema, response); } catch (error) { logToolError('generate_urls', error); return jsonErrorResponse(classifyToolCatchError(error, 'Failed to generate URLs')); diff --git a/src/core/server/tools/keyword-search-tool.context.test.ts b/src/core/server/tools/keyword-search-tool.context.test.ts index 491e31f..77347dc 100644 --- a/src/core/server/tools/keyword-search-tool.context.test.ts +++ b/src/core/server/tools/keyword-search-tool.context.test.ts @@ -1,8 +1,10 @@ import { describe, expect, it, vi } from 'vitest'; import { registerKeywordSearchTool } from './keyword-search-tool.js'; +import { keywordSearchResponseSchema } from '../response-schemas.js'; import { createMockServer, createTestServerContext, + expectMatchesResponseSchema, makeSearchResult, parseToolJson, } from './test-helpers.js'; @@ -25,6 +27,7 @@ describe('keyword_search tool handler (ServerContext instance path)', () => { top_k: 5, }); const body = parseToolJson(raw); + expectMatchesResponseSchema(keywordSearchResponseSchema, body); expect(body).toMatchObject({ status: 'success', query: 'contracts', diff --git a/src/core/server/tools/keyword-search-tool.ts b/src/core/server/tools/keyword-search-tool.ts index e594dc6..5be7fc1 100644 --- a/src/core/server/tools/keyword-search-tool.ts +++ b/src/core/server/tools/keyword-search-tool.ts @@ -12,31 +12,14 @@ import { logToolError, validationToolError, } from '../tool-error.js'; -import { jsonErrorResponse, jsonResponse } from '../tool-response.js'; +import { + keywordSearchSuccessResponseSchema, + type KeywordSearchResponse, +} from '../response-schemas.js'; +import { jsonErrorResponse, validatedJsonResponse } from '../tool-response.js'; -/** Success response shape for keyword_search (aligned with query tool fields). */ -export interface KeywordSearchResponse { - status: 'success'; - query?: string; - namespace?: string; - index?: string; - metadata_filter?: Record; - result_count?: number; - results?: Array<{ - /** Canonical document identifier. */ - document_id: string | null; - /** @deprecated Use `document_id`; removed in the next major release. */ - paper_number: string | null; - title: string; - author: string; - url: string; - content: string; - score: number; - reranked: boolean; - metadata?: Record; - }>; - fields?: string[]; -} +/** @deprecated Import {@link KeywordSearchResponse} from `response-schemas` or package root. */ +export type { KeywordSearchResponse }; type KeywordSearchExecResult = | { ok: true; body: KeywordSearchResponse } @@ -163,7 +146,7 @@ export function registerKeywordSearchTool(server: McpServer, ctx?: ServerContext if (!result.ok) { return jsonErrorResponse(result.error); } - return jsonResponse(result.body); + return validatedJsonResponse(keywordSearchSuccessResponseSchema, result.body); } catch (error) { logToolError('keyword_search', error); return jsonErrorResponse(classifyToolCatchError(error, 'Keyword search failed')); diff --git a/src/core/server/tools/list-namespaces-tool.context.test.ts b/src/core/server/tools/list-namespaces-tool.context.test.ts index 9304b6e..3d196c2 100644 --- a/src/core/server/tools/list-namespaces-tool.context.test.ts +++ b/src/core/server/tools/list-namespaces-tool.context.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it, vi } from 'vitest'; import { registerListNamespacesTool } from './list-namespaces-tool.js'; -import { createMockServer, createTestServerContext, parseToolJson } from './test-helpers.js'; +import { listNamespacesResponseSchema } from '../response-schemas.js'; +import { + createMockServer, + createTestServerContext, + expectMatchesResponseSchema, + parseToolJson, +} from './test-helpers.js'; describe('list_namespaces tool handler (ServerContext instance path)', () => { it('returns namespaces from injected context cache miss', async () => { @@ -19,6 +25,7 @@ describe('list_namespaces tool handler (ServerContext instance path)', () => { registerListNamespacesTool(server as never, ctx); const raw = await server.getHandler('list_namespaces')!({}); const body = parseToolJson(raw); + expectMatchesResponseSchema(listNamespacesResponseSchema, body); expect(body).toMatchObject({ status: 'success', cache_hit: false, diff --git a/src/core/server/tools/list-namespaces-tool.ts b/src/core/server/tools/list-namespaces-tool.ts index 0d841d8..c93ea7d 100644 --- a/src/core/server/tools/list-namespaces-tool.ts +++ b/src/core/server/tools/list-namespaces-tool.ts @@ -2,7 +2,11 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { getNamespacesWithCache } from '../namespaces-cache.js'; import type { ServerContext } from '../server-context.js'; import { classifyToolCatchError, lifecycleToolError, logToolError } from '../tool-error.js'; -import { jsonErrorResponse, jsonResponse } from '../tool-response.js'; +import { + listNamespacesResponseSchema, + type ListNamespacesSuccessResponse, +} from '../response-schemas.js'; +import { jsonErrorResponse, validatedJsonResponse } from '../tool-response.js'; async function executeListNamespaces(ctx?: ServerContext) { try { @@ -17,7 +21,7 @@ async function executeListNamespaces(ctx?: ServerContext) { const now = Date.now(); const ttlSeconds = Math.max(0, Math.floor((expires_at - now) / 1000)); - const response = { + const response: ListNamespacesSuccessResponse = { status: 'success', cache_hit, cache_ttl_seconds: ttlSeconds, @@ -30,7 +34,7 @@ async function executeListNamespaces(ctx?: ServerContext) { })), }; - return jsonResponse(response); + return validatedJsonResponse(listNamespacesResponseSchema, response); } catch (error) { logToolError('list_namespaces', error); return jsonErrorResponse(classifyToolCatchError(error, 'Failed to list namespaces')); diff --git a/src/core/server/tools/namespace-router-tool.context.test.ts b/src/core/server/tools/namespace-router-tool.context.test.ts index c7f8004..c60e5d5 100644 --- a/src/core/server/tools/namespace-router-tool.context.test.ts +++ b/src/core/server/tools/namespace-router-tool.context.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it, vi } from 'vitest'; import { registerNamespaceRouterTool } from './namespace-router-tool.js'; -import { createMockServer, createTestServerContext, parseToolJson } from './test-helpers.js'; +import { namespaceRouterResponseSchema } from '../response-schemas.js'; +import { + createMockServer, + createTestServerContext, + expectMatchesResponseSchema, + parseToolJson, +} from './test-helpers.js'; describe('namespace_router tool handler (ServerContext instance path)', () => { it('returns ranked suggestions from injected context cache miss', async () => { @@ -22,6 +28,7 @@ describe('namespace_router tool handler (ServerContext instance path)', () => { top_n: 3, }); const body = parseToolJson(raw); + expectMatchesResponseSchema(namespaceRouterResponseSchema, body); expect(body).toMatchObject({ status: 'success', cache_hit: false, diff --git a/src/core/server/tools/namespace-router-tool.ts b/src/core/server/tools/namespace-router-tool.ts index 092ea52..2ffb271 100644 --- a/src/core/server/tools/namespace-router-tool.ts +++ b/src/core/server/tools/namespace-router-tool.ts @@ -9,7 +9,11 @@ import { logToolError, validationToolError, } from '../tool-error.js'; -import { jsonErrorResponse, jsonResponse } from '../tool-response.js'; +import { + namespaceRouterResponseSchema, + type NamespaceRouterResponse, +} from '../response-schemas.js'; +import { jsonErrorResponse, validatedJsonResponse } from '../tool-response.js'; /** Register the namespace_router tool on the MCP server. */ export function registerNamespaceRouterTool(server: McpServer, ctx?: ServerContext): void { @@ -46,14 +50,14 @@ export function registerNamespaceRouterTool(server: McpServer, ctx?: ServerConte : await getNamespacesWithCache(); const ranked = rankNamespacesByQuery(user_query.trim(), data, top_n); - const response = { - status: 'success' as const, + const response: NamespaceRouterResponse = { + status: 'success', cache_hit, user_query: user_query.trim(), suggestions: ranked, recommended_namespace: ranked[0]?.namespace ?? null, }; - return jsonResponse(response); + return validatedJsonResponse(namespaceRouterResponseSchema, response); } catch (error) { logToolError('namespace_router', error); return jsonErrorResponse(classifyToolCatchError(error, 'Failed to route namespace')); diff --git a/src/core/server/tools/query-documents-tool.context.test.ts b/src/core/server/tools/query-documents-tool.context.test.ts index 5d23032..080d238 100644 --- a/src/core/server/tools/query-documents-tool.context.test.ts +++ b/src/core/server/tools/query-documents-tool.context.test.ts @@ -1,9 +1,11 @@ import { describe, expect, it, vi } from 'vitest'; import { registerQueryDocumentsTool } from './query-documents-tool.js'; +import { queryDocumentsResponseSchema } from '../response-schemas.js'; import { assertToolErrorCode, createMockServer, createTestServerContext, + expectMatchesResponseSchema, makeHybridQueryResult, parseToolJson, } from './test-helpers.js'; @@ -27,6 +29,7 @@ describe('query_documents tool handler (ServerContext instance path)', () => { namespace: 'wg21', }); const body = parseToolJson(raw); + expectMatchesResponseSchema(queryDocumentsResponseSchema, body); expect(body).toMatchObject({ status: 'success', namespace: 'wg21', diff --git a/src/core/server/tools/query-documents-tool.ts b/src/core/server/tools/query-documents-tool.ts index c0e9c16..8d201cc 100644 --- a/src/core/server/tools/query-documents-tool.ts +++ b/src/core/server/tools/query-documents-tool.ts @@ -18,7 +18,12 @@ import { logToolError, validationToolError, } from '../tool-error.js'; -import { jsonErrorResponse, jsonResponse } from '../tool-response.js'; +import { + buildQueryExperimental, + queryDocumentsResponseSchema, + type QueryDocumentsResponse, +} from '../response-schemas.js'; +import { jsonErrorResponse, validatedJsonResponse } from '../tool-response.js'; /** * Heuristic multiplier: chunks fetched = top_k × CHUNKS_PER_DOCUMENT, capped by @@ -130,20 +135,13 @@ export function registerQueryDocumentsTool(server: McpServer, ctx?: ServerContex .sort((a, b) => b.best_score - a.best_score) .slice(0, top_k); - return jsonResponse({ + const response: QueryDocumentsResponse = { status: 'success', query: query_text.trim(), namespace: nsNorm, metadata_filter, result_count: topDocuments.length, - degraded: queryOutcome.degraded, - ...(queryOutcome.degradation_reason !== undefined - ? { degradation_reason: queryOutcome.degradation_reason } - : {}), - hybrid_leg_failed: queryOutcome.hybrid_leg_failed, - ...(queryOutcome.rerank_skipped_reason !== undefined - ? { rerank_skipped_reason: queryOutcome.rerank_skipped_reason } - : {}), + ...buildQueryExperimental(queryOutcome), documents: topDocuments.map((doc) => ({ document_id: doc.document_id, merged_content: doc.merged_content, @@ -151,7 +149,8 @@ export function registerQueryDocumentsTool(server: McpServer, ctx?: ServerContex chunk_count: doc.chunk_count, best_score: doc.best_score, })), - }); + }; + return validatedJsonResponse(queryDocumentsResponseSchema, response); } catch (error) { logToolError('query_documents', error); return jsonErrorResponse( diff --git a/src/core/server/tools/query-tool.context.test.ts b/src/core/server/tools/query-tool.context.test.ts index 96221ab..c621fed 100644 --- a/src/core/server/tools/query-tool.context.test.ts +++ b/src/core/server/tools/query-tool.context.test.ts @@ -1,9 +1,11 @@ import { describe, expect, it, vi } from 'vitest'; import { registerQueryTool } from './query-tool.js'; +import { queryResponseSchema } from '../response-schemas.js'; import { assertToolErrorCode, createMockServer, createTestServerContext, + expectMatchesResponseSchema, makeHybridQueryResult, parseToolJson, } from './test-helpers.js'; @@ -28,6 +30,7 @@ describe('query tool handler (ServerContext instance path)', () => { preset: 'fast', }); const body = parseToolJson(raw); + expectMatchesResponseSchema(queryResponseSchema, body); expect(body).toMatchObject({ status: 'success', mode: 'query_fast', @@ -95,8 +98,9 @@ describe('query tool handler (ServerContext instance path)', () => { preset: 'detailed', }) ); - expect(body['rerank_skipped_reason']).toBe('no_model'); - expect(body['degradation_reason']).toMatch(/rerank_skipped_no_model/); + const experimental = body['experimental'] as Record; + expect(experimental['rerank_skipped_reason']).toBe('no_model'); + expect(experimental['degradation_reason']).toMatch(/rerank_skipped_no_model/); }); it('forwards hybrid_leg_failed for dense and sparse partial hybrid', async () => { @@ -117,7 +121,9 @@ describe('query tool handler (ServerContext instance path)', () => { const denseBody = parseToolJson( await handler({ query_text: 'a', namespace: 'wg21', preset: 'fast' }) ); - expect(denseBody['hybrid_leg_failed']).toBe('dense'); + expect((denseBody['experimental'] as Record)['hybrid_leg_failed']).toBe( + 'dense' + ); query.mockResolvedValue( makeHybridQueryResult({ hybrid_leg_failed: 'sparse', degraded: false }) @@ -125,7 +131,9 @@ describe('query tool handler (ServerContext instance path)', () => { const sparseBody = parseToolJson( await handler({ query_text: 'b', namespace: 'wg21', preset: 'fast' }) ); - expect(sparseBody['hybrid_leg_failed']).toBe('sparse'); + expect((sparseBody['experimental'] as Record)['hybrid_leg_failed']).toBe( + 'sparse' + ); }); it('returns TIMEOUT when client throws timeout error', async () => { diff --git a/src/core/server/tools/query-tool.test.ts b/src/core/server/tools/query-tool.test.ts index ec18544..6bcbcb4 100644 --- a/src/core/server/tools/query-tool.test.ts +++ b/src/core/server/tools/query-tool.test.ts @@ -241,8 +241,9 @@ describe('query tool handler (preset-driven)', () => { ); expect(body.status).toBe('success'); - expect(body.rerank_skipped_reason).toBe('no_model'); - expect(body.degradation_reason).toMatch(/rerank_skipped_no_model/); + const experimental = body.experimental as Record; + expect(experimental.rerank_skipped_reason).toBe('no_model'); + expect(experimental.degradation_reason).toMatch(/rerank_skipped_no_model/); }); it('query: surfaces unreranked hits when client returns reranked:false (rerank fallback shape)', async () => { diff --git a/src/core/server/tools/query-tool.ts b/src/core/server/tools/query-tool.ts index d3b2695..f0a20d1 100644 --- a/src/core/server/tools/query-tool.ts +++ b/src/core/server/tools/query-tool.ts @@ -15,7 +15,8 @@ import { logToolError, validationToolError, } from '../tool-error.js'; -import { jsonErrorResponse, jsonResponse } from '../tool-response.js'; +import { buildQueryExperimental, querySuccessResponseSchema } from '../response-schemas.js'; +import { jsonErrorResponse, validatedJsonResponse } from '../tool-response.js'; type QueryMode = 'query' | 'query_fast' | 'query_detailed'; @@ -84,16 +85,9 @@ async function executeQuery(params: QueryExecParams, ctx?: ServerContext) { result_count: formattedResults.length, results: formattedResults, ...(fields?.length ? { fields } : {}), - degraded: queryOutcome.degraded, - ...(queryOutcome.degradation_reason !== undefined - ? { degradation_reason: queryOutcome.degradation_reason } - : {}), - hybrid_leg_failed: queryOutcome.hybrid_leg_failed, - ...(queryOutcome.rerank_skipped_reason !== undefined - ? { rerank_skipped_reason: queryOutcome.rerank_skipped_reason } - : {}), + ...buildQueryExperimental(queryOutcome), }; - return jsonResponse(response); + return validatedJsonResponse(querySuccessResponseSchema, response); } catch (error) { logToolError(mode, error); return jsonErrorResponse( diff --git a/src/core/server/tools/test-helpers.ts b/src/core/server/tools/test-helpers.ts index 770dc0b..037218d 100644 --- a/src/core/server/tools/test-helpers.ts +++ b/src/core/server/tools/test-helpers.ts @@ -1,3 +1,4 @@ +import type { z } from 'zod'; import type { HybridQueryResult, SearchResult } from '../../../types.js'; import { resolveConfig } from '../../config.js'; import type { PineconeClient } from '../../pinecone-client.js'; @@ -143,3 +144,8 @@ export function createTestServerContext(options?: { } return new ServerContext(config); } + +/** Assert tool success JSON matches the exported Zod response schema (schema-response alignment). */ +export function expectMatchesResponseSchema(schema: z.ZodType, body: unknown): T { + return schema.parse(body); +} diff --git a/src/types.ts b/src/types.ts index 9a1e726..83d32a0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -115,57 +115,16 @@ export interface ListNamespacesResponse { message?: string; } -/** - * One row in a query response. - * - * `document_id` is the canonical identifier going forward. `paper_number` - * is kept as a deprecated alias for one minor cycle so existing clients - * keep working — it is removed in the next major release. - */ -export interface QueryResultRowShape { - /** - * Canonical document identifier (derived from `document_number` or - * `filename` metadata). Use this for new code. - */ - document_id: string | null; - /** - * @deprecated Use `document_id`. Kept for one minor cycle and will be - * removed in the next major release. - */ - paper_number: string | null; - title: string; - author: string; - url: string; - content: string; - score: number; - reranked: boolean; - metadata?: Record; -} - /** Outcome of listing namespaces on the sparse (keyword) index. */ export type KeywordIndexNamespacesResult = | { ok: true; namespaces: Array<{ namespace: string; recordCount: number }> } | { ok: false; error: string }; -export interface QueryResponse { - status: 'success'; - mode?: 'query' | 'query_fast' | 'query_detailed'; - query?: string; - namespace?: string; - metadata_filter?: Record; - result_count?: number; - /** Present when the query requested specific fields. */ - fields?: string[]; - results?: QueryResultRowShape[]; - /** True when reranking was attempted but failed; see `degradation_reason`. */ - degraded?: boolean; - /** Human-readable explanation when {@link degraded} is true. */ - 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; -} +export type { + QueryResultRowShape, + QueryResponse, + KeywordSearchResponse, +} from './core/server/response-schemas.js'; /** Internal merged hit shape before rerank (dense + sparse deduped). */ export interface MergedHit {