Skip to content

Commit 4b78587

Browse files
jonathanMLDevzho
andauthored
Add typed error response (#88)
* added typed error response * add a test * fixed type check errors * added some tests * addressed ai reviews * fixed tool error --------- Co-authored-by: zho <jornathanm910923@gmail.com>
1 parent b961f3d commit 4b78587

28 files changed

Lines changed: 813 additions & 188 deletions

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ Tagged releases are published to npm from GitHub Actions when a **GitHub Release
1010

1111
### Added
1212

13+
- Zod `toolErrorSchema` and exported types `ToolError` / `ToolErrorCode` for parsing MCP tool failures; all tools now return this JSON shape in the text content when `isError` is true.
14+
- `validateMetadataFilterDetailed()` returns `{ message, field }` for invalid filters; `validateMetadataFilter()` remains a string-only wrapper for backward compatibility.
1315
- `.coderabbit.yaml` sets the pre-merge **docstring coverage** threshold to **79%** (default **80%**) so marginal documentation-only gaps do not block merges; adjust upward as coverage improves.
1416
- `registerBuiltinUrlGenerators()` for built-in URL generators; `setupServer()` invokes it so CLI/library parity stays default.
1517
- Discriminated result type for `listNamespacesFromKeywordIndex()` (`KeywordIndexNamespacesResult`).
@@ -24,6 +26,8 @@ Tagged releases are published to npm from GitHub Actions when a **GitHub Release
2426

2527
### Changed
2628

29+
- **Breaking (MCP):** Tool error bodies no longer use `{ status: 'error', message }`. Failures are typed `ToolError` objects: `code` (`FLOW_GATE` | `VALIDATION` | `PINECONE_ERROR` | `TIMEOUT`), `message`, `recoverable`, optional `suggestion`, and optional `field` (required for `VALIDATION`). The outer MCP result still sets `isError: true`.
30+
- **Breaking (types):** `QueryResponse` and exported `KeywordSearchResponse` no longer include `status: 'error'` / error-only fields; errors use `ToolError` only.
2731
- **Breaking (MCP):** `suggest_query_params` and in-process suggestion flow now emit `recommended_tool` as `count` | `fast` | `detailed` | `full` (aligned with the unified `query` tool `preset`), not legacy `query_fast` / `query_detailed` strings.
2832
- **Breaking (MCP):** Single hybrid `query` tool with `preset` (`fast` | `detailed` | `full`); removed separate `query_fast` / `query_detailed` tool registrations.
2933
- `resolveConfig()` throws if the Pinecone API key is missing (after trim); library callers must supply `apiKey` via overrides or set `PINECONE_API_KEY`.

README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,20 @@ A Model Context Protocol (MCP) server that provides semantic search over Pinecon
2121
| [CONTRIBUTING.md](CONTRIBUTING.md) | How to contribute |
2222
| [SECURITY.md](SECURITY.md) | Vulnerability reporting |
2323

24+
## Error responses
25+
26+
When a tool fails, the MCP tool result sets **`isError: true`**. The `text` content is JSON matching **`ToolError`** (parse with `toolErrorSchema` from `@will-cppa/pinecone-read-only-mcp`).
27+
28+
| Field | Description |
29+
| ----- | ----------- |
30+
| `code` | `FLOW_GATE``suggest_query_params` was not run for this namespace (or context expired). `VALIDATION` — bad input or metadata filter. `PINECONE_ERROR` — SDK / network / server failure. `TIMEOUT` — outbound Pinecone call exceeded `--request-timeout-ms`. |
31+
| `message` | Human-readable detail (`DEBUG` log level may surface raw SDK messages in the message for `PINECONE_ERROR` / `TIMEOUT`). |
32+
| `recoverable` | Whether the client can plausibly fix the issue and retry (`true` for flow gate, validation, timeouts; typically `false` for generic Pinecone errors). |
33+
| `suggestion` | Optional hint. **`FLOW_GATE`** always includes: `Call suggest_query_params for namespace '<ns>' first`. **`TIMEOUT`** suggests retrying or increasing the request timeout. |
34+
| `field` | **Required when `code` is `VALIDATION`:** the input parameter name (e.g. `query_text`, `namespace`) or a dot-path into `metadata_filter` (e.g. `author.$in`). |
35+
36+
Success payloads are unchanged and do **not** wrap `ToolError`. Clients that still expect `{ "status": "error", "message": "..." }` must migrate to the shape above.
37+
2438
## Features
2539

2640
- **Hybrid Search**: Combines dense and sparse embeddings for superior recall

src/server.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
* - {@link resolveConfig} — merge CLI-style overrides with `process.env`.
1111
* - {@link setPineconeClient} — inject a client instance before `setupServer()`.
1212
* - {@link registerUrlGenerator} / {@link unregisterUrlGenerator} — extend URL synthesis.
13+
* - {@link toolErrorSchema} / {@link ToolError} — parse MCP tool failures (`isError: true` JSON bodies).
1314
* - Built-in `mailing` / `slack-Cpplang` URL generators are registered from {@link setupServer}
1415
* via {@link registerBuiltinUrlGenerators}; call it yourself if you use the library without `setupServer`.
1516
*
@@ -35,6 +36,12 @@ import { registerSuggestQueryParamsTool } from './server/tools/suggest-query-par
3536
export { setPineconeClient } from './server/client-context.js';
3637
/** Validate user-supplied Pinecone metadata filter objects before querying. */
3738
export { validateMetadataFilter } from './server/metadata-filter.js';
39+
/** Structured metadata filter validation (`field` dot-path); {@link validateMetadataFilter} remains a string-only wrapper. */
40+
export { validateMetadataFilterDetailed } from './server/metadata-filter.js';
41+
export type { MetadataFilterValidationError } from './server/metadata-filter.js';
42+
/** Zod schema and types for MCP tool error JSON bodies (`isError: true`). */
43+
export { toolErrorSchema } from './server/tool-error.js';
44+
export type { ToolError, ToolErrorCode } from './server/tool-error.js';
3845
/** Heuristic field + tool suggestions from a namespace schema + user query. */
3946
export { suggestQueryParams } from './server/query-suggestion.js';
4047
export type { RecommendedTool, SuggestQueryParamsResult } from './server/query-suggestion.js';

src/server/metadata-filter.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { validateMetadataFilter, validateMetadataFilterDetailed } from './metadata-filter.js';
3+
4+
describe('validateMetadataFilterDetailed', () => {
5+
it('returns null for a valid filter', () => {
6+
expect(
7+
validateMetadataFilterDetailed({
8+
year: { $gte: 2020, $lte: 2026 },
9+
tags: { $in: ['a', 'b'] },
10+
})
11+
).toBeNull();
12+
});
13+
14+
it('returns message and dot-path field for unknown nested operator', () => {
15+
const d = validateMetadataFilterDetailed({
16+
year: { $regex: '^202' },
17+
});
18+
expect(d).not.toBeNull();
19+
expect(d!.message).toContain('Unknown filter operator');
20+
expect(d!.field).toBe('year.$regex');
21+
expect(validateMetadataFilter({ year: { $regex: '^202' } })).toBe(d!.message);
22+
});
23+
24+
it('returns field for invalid $in value', () => {
25+
const d = validateMetadataFilterDetailed({
26+
tags: { $in: 'not-an-array' },
27+
});
28+
expect(d!.field).toBe('tags.$in');
29+
expect(d!.message).toContain('primitive values');
30+
});
31+
32+
it('returns field for null metadata value', () => {
33+
const d = validateMetadataFilterDetailed({
34+
author: null as unknown as Record<string, unknown>,
35+
});
36+
expect(d!.field).toBe('author');
37+
expect(d!.message).toContain('null');
38+
});
39+
40+
it('returns field when nested $and value is not an array', () => {
41+
const d = validateMetadataFilterDetailed({
42+
tags: { $and: { $eq: 'x' } },
43+
});
44+
expect(d!.field).toBe('tags.$and');
45+
});
46+
47+
it('returns field when nested $or array element is an array, not a filter object', () => {
48+
const d = validateMetadataFilterDetailed({
49+
tags: { $or: [[1]] },
50+
});
51+
expect(d!.field).toBe('tags.$or.0');
52+
});
53+
});

src/server/metadata-filter.ts

Lines changed: 60 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,12 @@ const ALLOWED_FILTER_OPERATORS = new Set([
2727
'$or',
2828
]);
2929

30+
export type MetadataFilterValidationError = {
31+
message: string;
32+
/** Dot-path to the failing segment (metadata key and/or operator chain). */
33+
field: string;
34+
};
35+
3036
/** True if value is a string, number, or boolean (allowed for $eq, $gt, etc.). */
3137
function isPrimitiveFilterValue(value: unknown): boolean {
3238
return typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean';
@@ -40,10 +46,17 @@ function isPrimitiveArray(value: unknown): boolean {
4046
);
4147
}
4248

43-
/** Recursively validate a filter value; returns an error string or null if valid. */
44-
function validateMetadataFilterValue(value: unknown, path: string[]): string | null {
49+
function err(message: string, path: string[]): MetadataFilterValidationError {
50+
return { message, field: path.join('.') };
51+
}
52+
53+
/** Recursively validate a filter value; returns an error or null if valid. */
54+
function validateMetadataFilterValue(
55+
value: unknown,
56+
path: string[]
57+
): MetadataFilterValidationError | null {
4558
if (value === null || value === undefined) {
46-
return `Invalid null/undefined at "${path.join('.')}".`;
59+
return err(`Invalid null/undefined at "${path.join('.')}".`, path);
4760
}
4861

4962
if (isPrimitiveFilterValue(value) || isPrimitiveArray(value)) {
@@ -54,27 +67,39 @@ function validateMetadataFilterValue(value: unknown, path: string[]): string | n
5467
for (let i = 0; i < value.length; i++) {
5568
const item = value[i];
5669
if (typeof item !== 'object' || item === null || Array.isArray(item)) {
57-
return `Operator "${path[path.length - 1]}" at "${[...path, String(i)].join('.')}" must use an array of filter objects.`;
70+
return err(
71+
`Operator "${path[path.length - 1]}" at "${[...path, String(i)].join('.')}" must use an array of filter objects.`,
72+
[...path, String(i)]
73+
);
5874
}
59-
const nestedError = validateMetadataFilter(item as Record<string, unknown>);
75+
const nestedError = validateMetadataFilterRecord(item as Record<string, unknown>);
6076
if (nestedError) return nestedError;
6177
}
6278
return null;
6379
}
6480

6581
if (typeof value !== 'object') {
66-
return `Unsupported filter value at "${path.join('.')}".`;
82+
return err(`Unsupported filter value at "${path.join('.')}".`, path);
6783
}
6884

6985
for (const [key, nestedValue] of Object.entries(value)) {
7086
if (!key.startsWith('$')) {
71-
return `Nested metadata filters must use operator keys starting with "$" at "${path.join('.')}"; got "${key}".`;
87+
return err(
88+
`Nested metadata filters must use operator keys starting with "$" at "${path.join('.')}"; got "${key}".`,
89+
[...path, key]
90+
);
7291
}
7392
if (!ALLOWED_FILTER_OPERATORS.has(key)) {
74-
return `Unknown filter operator "${key}" at "${path.join('.')}". Allowed operators: ${[...ALLOWED_FILTER_OPERATORS].join(', ')}.`;
93+
return err(
94+
`Unknown filter operator "${key}" at "${path.join('.')}". Allowed operators: ${[...ALLOWED_FILTER_OPERATORS].join(', ')}.`,
95+
[...path, key]
96+
);
7597
}
7698
if ((key === '$in' || key === '$nin') && !isPrimitiveArray(nestedValue)) {
77-
return `Operator "${key}" at "${path.join('.')}" must use an array of primitive values.`;
99+
return err(
100+
`Operator "${key}" at "${path.join('.')}" must use an array of primitive values.`,
101+
[...path, key]
102+
);
78103
}
79104
if (
80105
(key === '$eq' ||
@@ -85,10 +110,16 @@ function validateMetadataFilterValue(value: unknown, path: string[]): string | n
85110
key === '$lte') &&
86111
!isPrimitiveFilterValue(nestedValue)
87112
) {
88-
return `Operator "${key}" at "${path.join('.')}" must use a primitive value.`;
113+
return err(`Operator "${key}" at "${path.join('.')}" must use a primitive value.`, [
114+
...path,
115+
key,
116+
]);
89117
}
90118
if ((key === '$and' || key === '$or') && !Array.isArray(nestedValue)) {
91-
return `Operator "${key}" at "${path.join('.')}" must use an array of filter objects.`;
119+
return err(`Operator "${key}" at "${path.join('.')}" must use an array of filter objects.`, [
120+
...path,
121+
key,
122+
]);
92123
}
93124

94125
const nestedError = validateMetadataFilterValue(nestedValue, [...path, key]);
@@ -100,11 +131,27 @@ function validateMetadataFilterValue(value: unknown, path: string[]): string | n
100131
return null;
101132
}
102133

103-
/** Validate a Pinecone metadata filter object; returns an error message or null if valid. */
104-
export function validateMetadataFilter(filter: Record<string, unknown>): string | null {
134+
function validateMetadataFilterRecord(
135+
filter: Record<string, unknown>
136+
): MetadataFilterValidationError | null {
105137
for (const [field, value] of Object.entries(filter)) {
106138
const error = validateMetadataFilterValue(value, [field]);
107139
if (error) return error;
108140
}
109141
return null;
110142
}
143+
144+
/**
145+
* Validate a Pinecone metadata filter object; returns structured error or null if valid.
146+
*/
147+
export function validateMetadataFilterDetailed(
148+
filter: Record<string, unknown>
149+
): MetadataFilterValidationError | null {
150+
return validateMetadataFilterRecord(filter);
151+
}
152+
153+
/** Validate a Pinecone metadata filter object; returns an error message or null if valid. */
154+
export function validateMetadataFilter(filter: Record<string, unknown>): string | null {
155+
const detailed = validateMetadataFilterDetailed(filter);
156+
return detailed?.message ?? null;
157+
}

src/server/tool-error.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { describe, expect, it } from 'vitest';
2+
import {
3+
classifyToolCatchError,
4+
flowGateToolError,
5+
toolErrorSchema,
6+
validationToolError,
7+
} from './tool-error.js';
8+
9+
describe('ToolError schema and builders', () => {
10+
it('FLOW_GATE: includes required suggestion template', () => {
11+
const err = flowGateToolError('wg21', 'Flow requires suggest_query_params first.');
12+
const parsed = toolErrorSchema.parse(err);
13+
expect(parsed.code).toBe('FLOW_GATE');
14+
expect(parsed.recoverable).toBe(true);
15+
expect(parsed.suggestion).toBe("Call suggest_query_params for namespace 'wg21' first");
16+
expect(parsed.message).toContain('Flow requires');
17+
});
18+
19+
it('VALIDATION: requires field and parses', () => {
20+
const err = validationToolError('Unknown filter operator', 'author.$badop');
21+
const parsed = toolErrorSchema.parse(err);
22+
expect(parsed.code).toBe('VALIDATION');
23+
expect(parsed.field).toBe('author.$badop');
24+
expect(parsed.recoverable).toBe(true);
25+
});
26+
27+
it('VALIDATION: schema rejects payload missing field', () => {
28+
const bad = { code: 'VALIDATION' as const, message: 'x', recoverable: true as const };
29+
const result = toolErrorSchema.safeParse(bad);
30+
expect(result.success).toBe(false);
31+
});
32+
33+
it('PINECONE_ERROR: classifyToolCatchError maps generic Error', () => {
34+
const err = classifyToolCatchError(new Error('SDK boom'), 'fallback');
35+
expect(err.code).toBe('PINECONE_ERROR');
36+
expect(err.recoverable).toBe(false);
37+
expect(toolErrorSchema.parse(err).code).toBe('PINECONE_ERROR');
38+
});
39+
40+
it('TIMEOUT: classifyToolCatchError matches withTimeout message prefix', () => {
41+
const err = classifyToolCatchError(
42+
new Error('Timeout after 100ms while waiting for query'),
43+
'fallback'
44+
);
45+
expect(err.code).toBe('TIMEOUT');
46+
expect(err.recoverable).toBe(true);
47+
expect(err.suggestion).toMatch(/retry|timeout/i);
48+
toolErrorSchema.parse(err);
49+
});
50+
});

0 commit comments

Comments
 (0)