Skip to content
88 changes: 87 additions & 1 deletion src/gcp/firestore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,49 @@
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",
Expand Down Expand Up @@ -104,7 +147,7 @@
export async function getDatabase(
project: string,
database: string,
allowEmulator: boolean = false,

Check warning on line 150 in src/gcp/firestore.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Type boolean trivially inferred from a boolean literal, remove type annotation
): Promise<Database> {
const apiClient = allowEmulator ? emuOrProdClient : prodOnlyClient;
const url = `projects/${project}/databases/${database}`;
Expand All @@ -126,7 +169,7 @@
*/
export function listCollectionIds(
project: string,
allowEmulator: boolean = false,

Check warning on line 172 in src/gcp/firestore.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Type boolean trivially inferred from a boolean literal, remove type annotation
): Promise<string[]> {
const apiClient = allowEmulator ? emuOrProdClient : prodOnlyClient;
const url = "projects/" + project + "/databases/(default)/documents:listCollectionIds";
Expand All @@ -135,7 +178,7 @@
pageSize: 2147483647,
};

return apiClient.post<any, { collectionIds?: string[] }>(url, data).then((res) => {

Check warning on line 181 in src/gcp/firestore.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unexpected any. Specify a different type
return res.body.collectionIds || [];
});
}
Expand All @@ -144,7 +187,7 @@
* Get multiple documents by path.
* @param {string} project the Google Cloud project ID.
* @param {string[]} paths The document paths to fetch.
* @return {Promise<string[]>} 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,
Expand All @@ -160,10 +203,53 @@
{ found?: FirestoreDocument; missing?: string }[]
>(url, { documents: fullPaths });
const out: { documents: FirestoreDocument[]; missing: string[] } = { documents: [], missing: [] };
res.body.map((r) => (r.missing ? out.missing.push(r.missing) : out.documents.push(r.found!)));

Check warning on line 206 in src/gcp/firestore.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Forbidden non-null assertion
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.
*
Expand All @@ -172,9 +258,9 @@
* @param {object} doc a Document object to delete.
* @return {Promise} a promise for the delete operation.
*/
export async function deleteDocument(doc: any, allowEmulator: boolean = false): Promise<any> {

Check warning on line 261 in src/gcp/firestore.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unexpected any. Specify a different type

Check warning on line 261 in src/gcp/firestore.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Type boolean trivially inferred from a boolean literal, remove type annotation

Check warning on line 261 in src/gcp/firestore.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unexpected any. Specify a different type
const apiClient = allowEmulator ? emuOrProdClient : prodOnlyClient;
return apiClient.delete(doc.name);

Check warning on line 263 in src/gcp/firestore.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe member access .name on an `any` value

Check warning on line 263 in src/gcp/firestore.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe argument of type `any` assigned to a parameter of type `string`
}

/**
Expand All @@ -188,7 +274,7 @@
*/
export async function deleteDocuments(
project: string,
docs: any[],

Check warning on line 277 in src/gcp/firestore.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unexpected any. Specify a different type
allowEmulator: boolean = false,
): Promise<number> {
const apiClient = allowEmulator ? emuOrProdClient : prodOnlyClient;
Expand Down
55 changes: 55 additions & 0 deletions src/mcp/tools/firestore/converter.ts
Original file line number Diff line number Diff line change
@@ -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<string, FirestoreValue> } = {
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
Expand Down
9 changes: 8 additions & 1 deletion src/mcp/tools/firestore/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
];
133 changes: 133 additions & 0 deletions src/mcp/tools/firestore/query_collection.ts
Original file line number Diff line number Diff line change
@@ -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;
},
);
Loading