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/2] 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/2] 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."); }