-
Notifications
You must be signed in to change notification settings - Fork 1.2k
feat(mcp): Add support for filtering on reference_value and timestamp_value in firestore_query_collection #10481
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,137 @@ | ||
| 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, | ||
| }); | ||
| }); | ||
| }); | ||
|
|
||
| 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, | ||
| }); | ||
| }); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -39,6 +39,18 @@ 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`).", | ||
| ), | ||
| 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"), | ||
|
|
@@ -108,18 +120,21 @@ 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 && | ||
| 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]; | ||
|
Comment on lines
120
to
+132
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The validation logic for ensuring exactly one comparison value is provided is incorrect. The current implementation uses the const out = Object.entries(f.compare_value).filter(([, value]) => {
return value !== null && value !== undefined;
});
if (out.length !== 1) {
throw mcpError("One and only one value may be specified per filters object.");
}
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), | ||
| }, | ||
| }; | ||
| }), | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
According to the repository style guide (line 30), expected user-facing errors should throw a
FirebaseErrorinstead of a genericError. Note that you will need to importFirebaseErrorfrom../../../error.References