From 8803f0e16e1880b9d4105ea55e1cfeddb9e6d150 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alek=20A=CC=8Astro=CC=88m?= Date: Sat, 9 May 2026 10:19:49 +0200 Subject: [PATCH 1/3] feat(mcp): support reference_value filters in firestore_query_collection Adds a `reference_value` field to the `compare_value` schema so MCP clients can filter Firestore collections by document reference. Relative document paths are expanded to a full resource name using the active project and database; full resource names are passed through unchanged. --- src/mcp/tools/firestore/converter.ts | 30 ++++- .../tools/firestore/query_collection.spec.ts | 112 ++++++++++++++++++ src/mcp/tools/firestore/query_collection.ts | 12 +- 3 files changed, 146 insertions(+), 8 deletions(-) create mode 100644 src/mcp/tools/firestore/query_collection.spec.ts diff --git a/src/mcp/tools/firestore/converter.ts b/src/mcp/tools/firestore/converter.ts index 7f20e45f77d..7c7fdbda9c7 100644 --- a/src/mcp/tools/firestore/converter.ts +++ b/src/mcp/tools/firestore/converter.ts @@ -3,10 +3,21 @@ import { logger } from "../../../logger"; /** * Takes an arbitrary value from a user and returns a FirestoreValue equivalent. - * @param {any} inputValue the JSON object input value. - * return FirestoreValue a firestorevalue object used in the Firestore API. + * @param inputValue the JSON object input value. + * @param key the schema field name driving the conversion (e.g. `reference_value`). When + * set to `reference_value`, the string is treated as a document path/resource name and + * converted to a `referenceValue` rather than a `stringValue`. + * @param projectId the active project id, used to build a full resource name from a + * relative document path when `key === "reference_value"`. + * @param databaseId the active database id (defaults to `(default)`). + * @return a FirestoreValue object used in the Firestore API. */ -export function convertInputToValue(inputValue: any): FirestoreValue { +export function convertInputToValue( + inputValue: any, + key?: string, + projectId?: string, + databaseId: string = "(default)", +): FirestoreValue { if (inputValue === null) { return { nullValue: null }; } else if (typeof inputValue === "boolean") { @@ -19,9 +30,16 @@ export function convertInputToValue(inputValue: any): FirestoreValue { return { doubleValue: inputValue }; } } else if (typeof inputValue === "string") { - // This is a simplification. In a real-world scenario, you might want to - // check for specific string formats like timestamp, bytes, or referenceValue. - // For now, it defaults to stringValue. + if (key === "reference_value") { + if (inputValue.startsWith("projects/")) { + return { referenceValue: inputValue }; + } + if (!projectId) { + throw new Error("projectId is required to convert a relative reference_value path."); + } + const root = `projects/${projectId}/databases/${databaseId}/documents`; + return { referenceValue: `${root}/${inputValue.replace(/^\/+/, "")}` }; + } return { stringValue: inputValue }; } else if (Array.isArray(inputValue)) { const arrayValue: { values?: FirestoreValue[] } = { diff --git a/src/mcp/tools/firestore/query_collection.spec.ts b/src/mcp/tools/firestore/query_collection.spec.ts new file mode 100644 index 00000000000..33dfc747d42 --- /dev/null +++ b/src/mcp/tools/firestore/query_collection.spec.ts @@ -0,0 +1,112 @@ +import { expect } from "chai"; +import * as sinon from "sinon"; +import { query_collection } from "./query_collection"; +import * as firestore from "../../../gcp/firestore"; +import { McpContext } from "../../types"; + +describe("query_collection tool", () => { + const projectId = "test-project"; + const ctx = { projectId } as McpContext; + + let queryCollectionStub: sinon.SinonStub; + + beforeEach(() => { + queryCollectionStub = sinon.stub(firestore, "queryCollection").resolves({ documents: [] }); + }); + + afterEach(() => { + sinon.restore(); + }); + + describe("reference_value filter", () => { + it("expands a relative document path to a full resource name", async () => { + await query_collection.fn( + { + collection_path: "posts", + filters: [ + { + field: "author", + op: "EQUAL", + compare_value: { reference_value: "users/abc123" }, + }, + ], + use_emulator: false, + }, + ctx, + ); + + const [, structuredQuery] = queryCollectionStub.firstCall.args; + expect(structuredQuery.where.compositeFilter.filters[0].fieldFilter.value).to.deep.equal({ + referenceValue: `projects/${projectId}/databases/(default)/documents/users/abc123`, + }); + }); + + it("respects a non-default database id when expanding the path", async () => { + await query_collection.fn( + { + database: "my-db", + collection_path: "posts", + filters: [ + { + field: "author", + op: "EQUAL", + compare_value: { reference_value: "users/abc123" }, + }, + ], + use_emulator: false, + }, + ctx, + ); + + const [, structuredQuery] = queryCollectionStub.firstCall.args; + expect(structuredQuery.where.compositeFilter.filters[0].fieldFilter.value).to.deep.equal({ + referenceValue: `projects/${projectId}/databases/my-db/documents/users/abc123`, + }); + }); + + it("strips a leading slash from a relative document path", async () => { + await query_collection.fn( + { + collection_path: "posts", + filters: [ + { + field: "author", + op: "EQUAL", + compare_value: { reference_value: "/users/abc123" }, + }, + ], + use_emulator: false, + }, + ctx, + ); + + const [, structuredQuery] = queryCollectionStub.firstCall.args; + expect(structuredQuery.where.compositeFilter.filters[0].fieldFilter.value).to.deep.equal({ + referenceValue: `projects/${projectId}/databases/(default)/documents/users/abc123`, + }); + }); + + it("passes through a full resource name unchanged", async () => { + const fullName = "projects/other-project/databases/(default)/documents/users/abc123"; + await query_collection.fn( + { + collection_path: "posts", + filters: [ + { + field: "author", + op: "EQUAL", + compare_value: { reference_value: fullName }, + }, + ], + use_emulator: false, + }, + ctx, + ); + + const [, structuredQuery] = queryCollectionStub.firstCall.args; + expect(structuredQuery.where.compositeFilter.filters[0].fieldFilter.value).to.deep.equal({ + referenceValue: fullName, + }); + }); + }); +}); diff --git a/src/mcp/tools/firestore/query_collection.ts b/src/mcp/tools/firestore/query_collection.ts index 29402553acb..e73e9837f0a 100644 --- a/src/mcp/tools/firestore/query_collection.ts +++ b/src/mcp/tools/firestore/query_collection.ts @@ -39,6 +39,12 @@ export const query_collection = tool( .optional() .describe("The integer value to compare against."), double_value: z.number().optional().describe("The double value to compare against."), + reference_value: z + .string() + .optional() + .describe( + "A document reference value to compare against. Accepts either a document path (e.g. `users/abc123`) or a full resource name (e.g. `projects/{projectId}/databases/{databaseId}/documents/users/abc123`).", + ), }) .describe("One and only one value may be specified per filters object."), field: z.string().describe("the field searching against"), @@ -108,18 +114,20 @@ export const query_collection = tool( f.compare_value.double_value && f.compare_value.integer_value && f.compare_value.string_array_value && - f.compare_value.string_value + f.compare_value.string_value && + f.compare_value.reference_value ) { throw mcpError("One and only one value may be specified per filters object."); } const out = Object.entries(f.compare_value).filter(([, value]) => { return value !== null && value !== undefined; }); + const [key, value] = out[0]; return { fieldFilter: { field: { fieldPath: f.field }, op: f.op, - value: convertInputToValue(out[0][1]), + value: convertInputToValue(value, key, projectId, database), }, }; }), From 2b06486e8f79095a0115633fdae1af344e3fac3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alek=20A=CC=8Astro=CC=88m?= Date: Sat, 9 May 2026 10:29:42 +0200 Subject: [PATCH 2/3] feat(mcp): support timestamp_value filters in firestore_query_collection Adds a `timestamp_value` field to the `compare_value` schema, encoded as a Firestore `timestampValue` (RFC 3339/ISO 8601) so MCP clients can filter collections by date/time fields. --- src/mcp/tools/firestore/converter.ts | 3 +++ .../tools/firestore/query_collection.spec.ts | 25 +++++++++++++++++++ src/mcp/tools/firestore/query_collection.ts | 9 ++++++- 3 files changed, 36 insertions(+), 1 deletion(-) diff --git a/src/mcp/tools/firestore/converter.ts b/src/mcp/tools/firestore/converter.ts index 7c7fdbda9c7..76374ec5e5a 100644 --- a/src/mcp/tools/firestore/converter.ts +++ b/src/mcp/tools/firestore/converter.ts @@ -40,6 +40,9 @@ export function convertInputToValue( const root = `projects/${projectId}/databases/${databaseId}/documents`; return { referenceValue: `${root}/${inputValue.replace(/^\/+/, "")}` }; } + if (key === "timestamp_value") { + return { timestampValue: inputValue }; + } return { stringValue: inputValue }; } else if (Array.isArray(inputValue)) { const arrayValue: { values?: FirestoreValue[] } = { diff --git a/src/mcp/tools/firestore/query_collection.spec.ts b/src/mcp/tools/firestore/query_collection.spec.ts index 33dfc747d42..d755c20a5fb 100644 --- a/src/mcp/tools/firestore/query_collection.spec.ts +++ b/src/mcp/tools/firestore/query_collection.spec.ts @@ -109,4 +109,29 @@ describe("query_collection tool", () => { }); }); }); + + describe("timestamp_value filter", () => { + it("encodes the value as a Firestore timestampValue", async () => { + const iso = "2026-05-09T12:34:56Z"; + await query_collection.fn( + { + collection_path: "posts", + filters: [ + { + field: "publishedAt", + op: "GREATER_THAN", + compare_value: { timestamp_value: iso }, + }, + ], + use_emulator: false, + }, + ctx, + ); + + const [, structuredQuery] = queryCollectionStub.firstCall.args; + expect(structuredQuery.where.compositeFilter.filters[0].fieldFilter.value).to.deep.equal({ + timestampValue: iso, + }); + }); + }); }); diff --git a/src/mcp/tools/firestore/query_collection.ts b/src/mcp/tools/firestore/query_collection.ts index e73e9837f0a..6300acf7972 100644 --- a/src/mcp/tools/firestore/query_collection.ts +++ b/src/mcp/tools/firestore/query_collection.ts @@ -45,6 +45,12 @@ export const query_collection = tool( .describe( "A document reference value to compare against. Accepts either a document path (e.g. `users/abc123`) or a full resource name (e.g. `projects/{projectId}/databases/{databaseId}/documents/users/abc123`).", ), + timestamp_value: z + .string() + .optional() + .describe( + "A timestamp value to compare against, in RFC 3339/ISO 8601 format (e.g. `2026-05-09T00:00:00Z`).", + ), }) .describe("One and only one value may be specified per filters object."), field: z.string().describe("the field searching against"), @@ -115,7 +121,8 @@ export const query_collection = tool( f.compare_value.integer_value && f.compare_value.string_array_value && f.compare_value.string_value && - f.compare_value.reference_value + f.compare_value.reference_value && + f.compare_value.timestamp_value ) { throw mcpError("One and only one value may be specified per filters object."); } From 0ba8f4fc030290ed1bb0d6973b6a1cfef540adde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alek=20A=CC=8Astro=CC=88m?= Date: Sun, 10 May 2026 14:35:34 +0200 Subject: [PATCH 3/3] fix(mcp): correctly validate single compare_value in firestore_query_collection The previous `&&`-chained guard only triggered when *all* compare_value fields were set simultaneously, so it never caught a request with two values, and an empty `compare_value` crashed on `out[0]` destructuring. Switch to a length check so any count other than 1 produces a clean error, and align with the rest of the file by returning the error instead of throwing. Also use `mcpError` for the missing-projectId guard in the firestore converter for consistency. --- src/mcp/tools/firestore/converter.ts | 3 +- .../tools/firestore/query_collection.spec.ts | 34 ++++++++++++++ src/mcp/tools/firestore/query_collection.ts | 45 ++++++++----------- 3 files changed, 54 insertions(+), 28 deletions(-) diff --git a/src/mcp/tools/firestore/converter.ts b/src/mcp/tools/firestore/converter.ts index 76374ec5e5a..f087a74deb2 100644 --- a/src/mcp/tools/firestore/converter.ts +++ b/src/mcp/tools/firestore/converter.ts @@ -1,5 +1,6 @@ import { FirestoreDocument, FirestoreValue } from "../../../gcp/firestore"; import { logger } from "../../../logger"; +import { mcpError } from "../../util"; /** * Takes an arbitrary value from a user and returns a FirestoreValue equivalent. @@ -35,7 +36,7 @@ export function convertInputToValue( return { referenceValue: inputValue }; } if (!projectId) { - throw new Error("projectId is required to convert a relative reference_value path."); + throw mcpError("projectId is required to convert a relative reference_value path."); } const root = `projects/${projectId}/databases/${databaseId}/documents`; return { referenceValue: `${root}/${inputValue.replace(/^\/+/, "")}` }; diff --git a/src/mcp/tools/firestore/query_collection.spec.ts b/src/mcp/tools/firestore/query_collection.spec.ts index d755c20a5fb..cd2733d508f 100644 --- a/src/mcp/tools/firestore/query_collection.spec.ts +++ b/src/mcp/tools/firestore/query_collection.spec.ts @@ -110,6 +110,40 @@ describe("query_collection tool", () => { }); }); + describe("compare_value validation", () => { + it("returns an error when no value is provided", async () => { + const result = await query_collection.fn( + { + collection_path: "posts", + filters: [{ field: "author", op: "EQUAL", compare_value: {} }], + use_emulator: false, + }, + ctx, + ); + expect(result.isError).to.equal(true); + expect(queryCollectionStub).to.not.have.been.called; + }); + + it("returns an error when more than one value is provided", async () => { + const result = await query_collection.fn( + { + collection_path: "posts", + filters: [ + { + field: "author", + op: "EQUAL", + compare_value: { string_value: "a", integer_value: 1 }, + }, + ], + use_emulator: false, + }, + ctx, + ); + expect(result.isError).to.equal(true); + expect(queryCollectionStub).to.not.have.been.called; + }); + }); + describe("timestamp_value filter", () => { it("encodes the value as a Firestore timestampValue", async () => { const iso = "2026-05-09T12:34:56Z"; diff --git a/src/mcp/tools/firestore/query_collection.ts b/src/mcp/tools/firestore/query_collection.ts index 6300acf7972..2f72a54d32d 100644 --- a/src/mcp/tools/firestore/query_collection.ts +++ b/src/mcp/tools/firestore/query_collection.ts @@ -111,34 +111,25 @@ export const query_collection = tool( from: [{ collectionId: collection_path, allDescendants: false }], }; if (filters) { + const fieldFilters = []; + for (const f of filters) { + const provided = Object.entries(f.compare_value).filter(([, value]) => { + return value !== null && value !== undefined; + }); + if (provided.length !== 1) { + return mcpError("One and only one value must be specified per filters object."); + } + const [key, value] = provided[0]; + fieldFilters.push({ + fieldFilter: { + field: { fieldPath: f.field }, + op: f.op, + value: convertInputToValue(value, key, projectId, database), + }, + }); + } structuredQuery.where = { - compositeFilter: { - op: "AND", - filters: filters.map((f) => { - if ( - f.compare_value.boolean_value && - f.compare_value.double_value && - f.compare_value.integer_value && - f.compare_value.string_array_value && - f.compare_value.string_value && - f.compare_value.reference_value && - f.compare_value.timestamp_value - ) { - throw mcpError("One and only one value may be specified per filters object."); - } - const out = Object.entries(f.compare_value).filter(([, value]) => { - return value !== null && value !== undefined; - }); - const [key, value] = out[0]; - return { - fieldFilter: { - field: { fieldPath: f.field }, - op: f.op, - value: convertInputToValue(value, key, projectId, database), - }, - }; - }), - }, + compositeFilter: { op: "AND", filters: fieldFilters }, }; } if (order) {