Skip to content

Commit 38c2e26

Browse files
committed
feat: add metadata filtering and typed upsert guardrails
1 parent 29a057f commit 38c2e26

File tree

5 files changed

+193
-7
lines changed

5 files changed

+193
-7
lines changed

src/test-utils/mock-pinecone.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
import {vi} from 'vitest';
22

3-
export function createMockNamespace() {
3+
export function createMockNamespace(): any {
44
return {
55
searchRecords: vi.fn(),
66
upsertRecords: vi.fn(),
77
};
88
}
99

10-
export function createMockIndex() {
10+
export function createMockIndex(): any {
1111
const mockNamespace = createMockNamespace();
1212
return {
1313
describeIndexStats: vi.fn(),
@@ -16,7 +16,7 @@ export function createMockIndex() {
1616
};
1717
}
1818

19-
export function createMockPinecone() {
19+
export function createMockPinecone(): any {
2020
const mockIndex = createMockIndex();
2121
return {
2222
listIndexes: vi.fn(),

src/tools/database/search-records.ts

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,25 +57,60 @@ const SCHEMA = {
5757
namespace: z.string().describe('The namespace to search.'),
5858
query: SEARCH_QUERY_SCHEMA,
5959
rerank: RERANK_SCHEMA,
60+
selectedMetadataKeys: z
61+
.array(z.string())
62+
.optional()
63+
.describe(
64+
'Optional: List of metadata keys to return. If omitted, all metadata is returned (Potential PII risk).',
65+
),
6066
};
6167

6268
type SearchArgs = {
6369
name: string;
6470
namespace: string;
6571
query: SearchQuery;
6672
rerank?: SearchRerank;
73+
selectedMetadataKeys?: string[];
6774
};
75+
type SearchRecord = {
76+
metadata?: Record<string, unknown>;
77+
} & Record<string, unknown>;
78+
type SearchResults = {
79+
records?: SearchRecord[];
80+
matches?: SearchRecord[];
81+
} & Record<string, unknown>;
6882

6983
export function addSearchRecordsTool(server: McpServer) {
7084
registerDatabaseTool(
7185
server,
7286
'search-records',
7387
{description: INSTRUCTIONS, inputSchema: SCHEMA},
7488
async (args, pc) => {
75-
const {name, namespace, query, rerank} = args as SearchArgs;
89+
const {name, namespace, query, rerank, selectedMetadataKeys} = args as SearchArgs;
7690
try {
7791
const ns = pc.index(name).namespace(namespace);
78-
const results = await ns.searchRecords({query, rerank});
92+
const results = (await ns.searchRecords({query, rerank})) as SearchResults;
93+
94+
// --- SECURITY PATCH: Metadata Filtering
95+
if (selectedMetadataKeys) {
96+
const filterRecordMetadata = (recordArray: SearchRecord[]) =>
97+
recordArray.map((record) => {
98+
if (!record.metadata) return record;
99+
const filteredMetadata: Record<string, unknown> = {};
100+
selectedMetadataKeys.forEach((key) => {
101+
if (record.metadata[key] !== undefined)
102+
filteredMetadata[key] = record.metadata[key];
103+
});
104+
return {...record, metadata: filteredMetadata};
105+
});
106+
107+
if (Array.isArray(results.records)) {
108+
results.records = filterRecordMetadata(results.records);
109+
}
110+
if (Array.isArray(results.matches)) {
111+
results.matches = filterRecordMetadata(results.matches);
112+
}
113+
}
79114
return {
80115
content: [
81116
{
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest';
2+
3+
// 1. HOISTED MOCK: This must be at the very top to intercept the tools' imports
4+
vi.mock('./common/pinecone-client.js', () => ({
5+
getPineconeClient: vi.fn(),
6+
}));
7+
8+
// Now import the tools and utilities
9+
import { createMockServer } from '../../test-utils/mock-server.js';
10+
import { createMockPinecone } from '../../test-utils/mock-pinecone.js';
11+
import { getPineconeClient } from './common/pinecone-client.js';
12+
import { addSearchRecordsTool } from './search-records.js';
13+
import { addUpsertRecordsTool } from './upsert-records.js';
14+
15+
describe('Pinecone MCP Security Hardening', () => {
16+
let mockServer: any;
17+
let mockPc: any;
18+
19+
beforeEach(() => {
20+
vi.clearAllMocks();
21+
mockServer = createMockServer();
22+
mockPc = createMockPinecone();
23+
24+
// 2. Force the client factory to return our mock for every call
25+
vi.mocked(getPineconeClient).mockReturnValue(mockPc);
26+
27+
addSearchRecordsTool(mockServer);
28+
addUpsertRecordsTool(mockServer);
29+
30+
// Satisfy the initial constant check
31+
vi.stubEnv('PINECONE_API_KEY', 'test-key');
32+
});
33+
34+
describe('Metadata Firewall (search-records)', () => {
35+
it('should strip unauthorized metadata keys from search results', async () => {
36+
const mockRecords = {
37+
records: [{
38+
id: 'vec-1',
39+
metadata: { text: 'public', ssn: 'secret-123' }
40+
}]
41+
};
42+
43+
mockPc._mockIndex._mockNamespace.searchRecords.mockResolvedValue(mockRecords);
44+
45+
const tool = mockServer.getRegisteredTool('search-records');
46+
const result = await tool.handler({
47+
name: 'idx', namespace: 'ns',
48+
query: { inputs: { text: 'test' }, topK: 1 },
49+
selectedMetadataKeys: ['text']
50+
});
51+
52+
// Helpful debugging: if it fails, show why
53+
if (result.isError) {
54+
throw new Error(`Tool returned error: ${result.content[0].text}`);
55+
}
56+
57+
const output = JSON.parse(result.content[0].text);
58+
expect(output.records[0].metadata.ssn).toBeUndefined();
59+
expect(output.records[0].metadata.text).toBe('public');
60+
});
61+
62+
it('should return all metadata if selectedMetadataKeys is omitted', async () => {
63+
mockPc._mockIndex._mockNamespace.searchRecords.mockResolvedValue({
64+
records: [{ id: '1', metadata: { secret: 'data' } }]
65+
});
66+
67+
const tool = mockServer.getRegisteredTool('search-records');
68+
const result = await tool.handler({
69+
name: 'idx', namespace: 'ns',
70+
query: { inputs: { text: 'test' }, topK: 1 }
71+
});
72+
73+
if (result.isError) {
74+
throw new Error(`Tool returned error: ${result.content[0].text}`);
75+
}
76+
77+
const output = JSON.parse(result.content[0].text);
78+
expect(output.records[0].metadata.secret).toBe('data');
79+
});
80+
});
81+
82+
describe('Upsert Guardrail (upsert-records)', () => {
83+
it('should block upsert if confirmOverwrite flag is missing', async () => {
84+
const tool = mockServer.getRegisteredTool('upsert-records');
85+
const result = await tool.handler({
86+
name: 'idx', namespace: 'ns',
87+
records: [{ id: '1', text: 'val' }]
88+
});
89+
90+
expect(result.isError).toBe(true);
91+
expect(result.content[0].text).toContain('confirmOverwrite must be set to true');
92+
});
93+
94+
it('should execute upsert when confirmOverwrite is true', async () => {
95+
mockPc._mockIndex._mockNamespace.upsertRecords.mockResolvedValue(undefined);
96+
const tool = mockServer.getRegisteredTool('upsert-records');
97+
98+
const result = await tool.handler({
99+
name: 'idx', namespace: 'ns',
100+
records: [{ id: '1', text: 'val' }],
101+
confirmOverwrite: true
102+
});
103+
104+
// Final check: ensure no error was returned
105+
expect(result.isError).toBeFalsy();
106+
expect(result.content[0].text).toBe('Data upserted successfully');
107+
});
108+
});
109+
});

src/tools/database/upsert-records.handler.test.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ describe('upsert-records tool handler', () => {
4242
name: 'test-index',
4343
namespace: 'test-ns',
4444
records: [{id: '1', content: 'test content'}],
45+
confirmOverwrite: true,
4546
});
4647

4748
expect(mockPc.index).toHaveBeenCalledWith('test-index');
@@ -63,6 +64,7 @@ describe('upsert-records tool handler', () => {
6364
name: 'test-index',
6465
namespace: 'test-ns',
6566
records: [{id: '1', content: 'test content'}],
67+
confirmOverwrite: true,
6668
});
6769

6870
expect(result).toEqual({
@@ -80,11 +82,32 @@ describe('upsert-records tool handler', () => {
8082
name: 'test-index',
8183
namespace: 'test-ns',
8284
records: [{_id: 'alt-1', content: 'test content'}],
85+
confirmOverwrite: true,
8386
})) as {content: Array<{text: string}>};
8487

8588
expect(mockPc._mockIndex._mockNamespace.upsertRecords).toHaveBeenCalledWith([
8689
{_id: 'alt-1', content: 'test content'},
8790
]);
8891
expect(result.content[0].text).toContain('successfully');
8992
});
90-
});
93+
94+
it('fails if confirmOverwrite is missing or false', async () => {
95+
// 🛡️ BULLETPROOF SCOPING: Register the tool explicitly for this test
96+
// This bypasses any nested 'beforeEach' boundary issues
97+
addUpsertRecordsTool(mockServer);
98+
99+
// Now we are 100% guaranteed the tool exists
100+
const tool = mockServer.getRegisteredTool('upsert-records')!;
101+
102+
const result = (await tool.handler({
103+
name: 'test-index',
104+
namespace: 'test-ns',
105+
records: [{id: '1', content: 'test content'}]
106+
})) as any;
107+
108+
expect(result.isError).toBe(true);
109+
expect(result.content[0].text).toContain('confirmOverwrite must be set to true');
110+
expect(mockPc._mockIndex._mockNamespace.upsertRecords).not.toHaveBeenCalled();
111+
});
112+
113+
});

src/tools/database/upsert-records.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,12 +35,18 @@ const SCHEMA = {
3535
name: z.string().describe('The index to upsert into.'),
3636
namespace: z.string().describe('The namespace to upsert into.'),
3737
records: RECORD_SET_SCHEMA,
38+
confirmOverwrite: z
39+
.boolean()
40+
.describe(
41+
'CRITICAL: Set to true to acknowledge that this will overwrite any existing records with the same IDs.',
42+
),
3843
};
3944

4045
type UpsertArgs = {
4146
name: string;
4247
namespace: string;
4348
records: IntegratedRecord[];
49+
confirmOverwrite: boolean;
4450
};
4551

4652
export function addUpsertRecordsTool(server: McpServer) {
@@ -49,7 +55,20 @@ export function addUpsertRecordsTool(server: McpServer) {
4955
'upsert-records',
5056
{description: INSTRUCTIONS, inputSchema: SCHEMA},
5157
async (args, pc) => {
52-
const {name, namespace, records} = args as UpsertArgs;
58+
const {name, namespace, records, confirmOverwrite} = args as UpsertArgs;
59+
60+
// --- SECURITY PATCH: Destructive Action Guardrail ---
61+
if (confirmOverwrite !== true) {
62+
return {
63+
isError: true,
64+
content: [
65+
{
66+
type: 'text' as const,
67+
text: 'Error: confirmOverwrite must be set to true. Upserting records can overwrite existing data. Please ask the user for confirmation.',
68+
},
69+
],
70+
};
71+
}
5372
try {
5473
const ns = pc.index(name).namespace(namespace);
5574
await ns.upsertRecords(records);

0 commit comments

Comments
 (0)