diff --git a/src/gcp/firestore.ts b/src/gcp/firestore.ts index 3dba011278b..853a41f349f 100644 --- a/src/gcp/firestore.ts +++ b/src/gcp/firestore.ts @@ -33,6 +33,49 @@ export interface Database { etag: string; } +interface FieldFilter { + field: { fieldPath: string }; + op: + | "OPERATOR_UNSPECIFIED" + | "LESS_THAN" + | "LESS_THAN_OR_EQUAL" + | "GREATER_THAN" + | "GREATER_THAN_OR_EQUAL" + | "EQUAL" + | "NOT_EQUAL" + | "ARRAY_CONTAINS" + | "ARRAY_CONTAINS_ANY" + | "IN" + | "NOT_IN"; + value: FirestoreValue; +} + +interface CompositeFilter { + op: "OR" | "AND"; + filters: { + fieldFilter?: FieldFilter; + compositeFilter?: CompositeFilter; + }[]; +} + +export interface StructuredQuery { + from: { collectionId: string; allDescendants: boolean }[]; + where?: { + compositeFilter?: CompositeFilter; + fieldFilter?: FieldFilter; + }; + orderBy?: { + field: { fieldPath: string }; + direction: "ASCENDING" | "DESCENDING" | "DIRECTION_UNSPECIFIED"; + }[]; + limit?: number; +} + +interface RunQueryResponse { + document?: FirestoreDocument; + readTime?: string; +} + export enum DayOfWeek { MONDAY = "MONDAY", TUEDAY = "TUESDAY", @@ -144,7 +187,7 @@ export function listCollectionIds( * Get multiple documents by path. * @param {string} project the Google Cloud project ID. * @param {string[]} paths The document paths to fetch. - * @return {Promise} a promise for an array of collection IDs. + * @return {Promise<{ documents: FirestoreDocument[]; missing: string[] }>} a promise for an array of firestore documents and missing documents in the request. */ export async function getDocuments( project: string, @@ -164,6 +207,49 @@ export async function getDocuments( return out; } +/** + * Get documents based on a simple query to a collection. + * @param {string} project the Google Cloud project ID. + * @param {StructuredQuery} structuredQuery The structured query of the request including filters and ordering. + * @return {Promise<{ documents: FirestoreDocument[] }>} a promise for an array of retrieved firestore documents. + */ +export async function queryCollection( + project: string, + structuredQuery: StructuredQuery, + allowEmulator?: boolean, +): Promise<{ documents: FirestoreDocument[] }> { + const apiClient = allowEmulator ? emuOrProdClient : prodOnlyClient; + const basePath = `projects/${project}/databases/(default)/documents`; + const url = `${basePath}:runQuery`; + try { + const res = await apiClient.post< + { + structuredQuery: StructuredQuery; + explainOptions: { analyze: boolean }; + newTransaction: { readOnly: { readTime: string } }; + // readTime: string; + }, + RunQueryResponse[] + >(url, { + structuredQuery: structuredQuery, + explainOptions: { analyze: true }, + newTransaction: { readOnly: { readTime: new Date().toISOString() } }, + // readTime: new Date().toISOString(), + }); + const out: { documents: FirestoreDocument[] } = { documents: [] }; + res.body.map((r) => { + if (r.document) { + out.documents.push(r.document); + } + }); + return out; + } catch (err: FirebaseError | unknown) { + // Used to get the URL to automatically build the composite index. + // Otherwise a generic 400 error is returned to the user without info. + throw JSON.stringify(err); + } +} + /** * Delete a single Firestore document. * diff --git a/src/mcp/tools/firestore/converter.ts b/src/mcp/tools/firestore/converter.ts index 9d7990b2c94..7f20e45f77d 100644 --- a/src/mcp/tools/firestore/converter.ts +++ b/src/mcp/tools/firestore/converter.ts @@ -1,6 +1,61 @@ import { FirestoreDocument, FirestoreValue } from "../../../gcp/firestore"; 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. + */ +export function convertInputToValue(inputValue: any): FirestoreValue { + if (inputValue === null) { + return { nullValue: null }; + } else if (typeof inputValue === "boolean") { + return { booleanValue: inputValue }; + } else if (typeof inputValue === "number") { + // Distinguish between integers and doubles + if (Number.isInteger(inputValue)) { + return { integerValue: inputValue.toString() }; // Represent integers as string for consistency with Firestore + } else { + 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. + return { stringValue: inputValue }; + } else if (Array.isArray(inputValue)) { + const arrayValue: { values?: FirestoreValue[] } = { + values: inputValue.map((item) => convertInputToValue(item)), + }; + return { arrayValue: arrayValue }; + } else if (typeof inputValue === "object") { + // Check for LatLng structure + if ( + inputValue.hasOwnProperty("latitude") && + typeof inputValue.latitude === "number" && + inputValue.hasOwnProperty("longitude") && + typeof inputValue.longitude === "number" + ) { + return { geoPointValue: inputValue as { latitude: number; longitude: number } }; + } + + // Otherwise, treat as a MapValue + const mapValue: { fields?: Record } = { + fields: {}, + }; + for (const key in inputValue) { + if (Object.prototype.hasOwnProperty.call(inputValue, key)) { + if (mapValue.fields) { + mapValue.fields[key] = convertInputToValue(inputValue[key]); + } + } + } + return { mapValue: mapValue }; + } + // Fallback for unsupported types (e.g., undefined, functions, symbols) + return { nullValue: null }; +} + /** * Converts a Firestore REST API Value object to a plain Javascript object, * applying special transformations for Reference and GeoPoint types, and diff --git a/src/mcp/tools/firestore/index.ts b/src/mcp/tools/firestore/index.ts index 342e9bd0507..bb3e62ee27b 100644 --- a/src/mcp/tools/firestore/index.ts +++ b/src/mcp/tools/firestore/index.ts @@ -2,5 +2,12 @@ import { delete_document } from "./delete_document"; import { get_documents } from "./get_documents"; import { get_rules } from "./get_rules"; import { list_collections } from "./list_collections"; +import { query_collection } from "./query_collection"; -export const firestoreTools = [delete_document, get_documents, get_rules, list_collections]; +export const firestoreTools = [ + delete_document, + get_documents, + get_rules, + list_collections, + query_collection, +]; diff --git a/src/mcp/tools/firestore/query_collection.ts b/src/mcp/tools/firestore/query_collection.ts new file mode 100644 index 00000000000..bb9c33263ec --- /dev/null +++ b/src/mcp/tools/firestore/query_collection.ts @@ -0,0 +1,133 @@ +import { z } from "zod"; +import { tool } from "../../tool.js"; +import { mcpError, toContent } from "../../util.js"; +import { queryCollection, StructuredQuery } from "../../../gcp/firestore.js"; +import { convertInputToValue, firestoreDocumentToJson } from "./converter.js"; + +export const query_collection = tool( + { + name: "query_collection", + description: + "Retrieves one or more Firestore documents from a collection is a database in the current project by a collection with a full document path. Use this if you know the exact path of a collection and the filtering clause you would like for the document.", + inputSchema: z.object({ + // TODO: Support configurable database + // database: z + // .string() + // .nullish() + // .describe("Database id to use. Defaults to `(default)` if unspecified."), + collectionPath: z + .string() + .describe( + "A collection path (e.g. `collectionName/` or `parentCollection/parentDocument/collectionName`)", + ), + filters: z + .object({ + compareValue: z + .object({ + stringValue: z.string().nullish().describe("The string value to compare against."), + booleanValue: z.string().nullish().describe("The boolean value to compare against."), + stringArrayValue: z + .array(z.string()) + .nullish() + .describe("The string value to compare against."), + integerValue: z.number().nullish().describe("The integer value to compare against."), + doubleValue: z.number().nullish().describe("The double value to compare against."), + }) + .describe("One and only one value may be specified per filters object."), + field: z.string().describe("the field searching against"), + op: z + .enum([ + "OPERATOR_UNSPECIFIED", + "LESS_THAN", + "LESS_THAN_OR_EQUAL", + "GREATER_THAN", + "GREATER_THAN_OR_EQUAL", + "EQUAL", + "NOT_EQUAL", + "ARRAY_CONTAINS", + "ARRAY_CONTAINS_ANY", + "IN", + "NOT_IN", + ]) + .describe("the equality evaluator to use"), + }) + .array() + .describe("the multiple filters to use in querying against the existing collection."), + order: z + .object({ + orderBy: z.string().describe("the field to order by"), + orderByDirection: z + .enum(["ASCENDING", "DESCENDING", "DIRECTION_UNSPECIFIED"]) + .describe("the direction to order values"), + }) + .nullish(), + limit: z + .number() + .describe("The maximum amount of records to return. Default is 10.") + .nullish(), + }), + annotations: { + title: "Query Firestore collection", + readOnlyHint: true, + }, + _meta: { + requiresAuth: true, + requiresProject: true, + }, + }, + async ({ collectionPath, filters, order, limit }, { projectId }) => { + // database ??= "(default)"; + + if (!collectionPath || !collectionPath.length) + return mcpError("Must supply at least one collection path."); + + const structuredQuery: StructuredQuery = { + from: [{ collectionId: collectionPath, allDescendants: false }], + }; + if (filters) { + structuredQuery.where = { + compositeFilter: { + op: "AND", + filters: filters.map((f) => { + if ( + f.compareValue.booleanValue && + f.compareValue.doubleValue && + f.compareValue.integerValue && + f.compareValue.stringArrayValue && + f.compareValue.stringValue + ) { + throw mcpError("One and only one value may be specified per filters object."); + } + const out = Object.entries(f.compareValue).filter(([, value]) => { + return value !== null && value !== undefined; + }); + return { + fieldFilter: { + field: { fieldPath: f.field }, + op: f.op, + value: convertInputToValue(out[0][1]), + }, + }; + }), + }, + }; + } + if (order) { + structuredQuery.orderBy = [ + { + field: { fieldPath: order.orderBy }, + direction: order.orderByDirection, + }, + ]; + } + structuredQuery.limit = limit ? limit : 10; + + const { documents } = await queryCollection(projectId!, structuredQuery); + + const docs = documents.map(firestoreDocumentToJson); + + const docsContent = toContent(docs); + + return docsContent; + }, +);