Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@
- Fixed issue where `firebase use --add` didn't correctly set the active project. (#8694)
- Always setup Data Connect SDK when FDC_CONNECTOR env var is set.
- `firebase init` now uses FIREBASE_PROJECT env var as the default project name.
- Add emulator support to firestore MCP tools. (#8700)
2 changes: 1 addition & 1 deletion src/deploy/functions/services/firestore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

/**
* A memoized version of firestore.getDatabase that avoids repeated calls to the API.
* This implementation prevents concurrent calls for the same database.

Check warning on line 19 in src/deploy/functions/services/firestore.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Expected only 0 line after block description
*
* @param project the project ID
* @param databaseId the database ID or "(default)"
Expand All @@ -25,15 +25,15 @@
const key = `${project}/${databaseId}`;

if (dbCache.has(key)) {
return dbCache.get(key)!;

Check warning on line 28 in src/deploy/functions/services/firestore.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Forbidden non-null assertion
}

if (dbPromiseCache.has(key)) {
return dbPromiseCache.get(key)!;

Check warning on line 32 in src/deploy/functions/services/firestore.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Forbidden non-null assertion
}

const dbPromise = firestore
.getDatabase(project, databaseId, false)
.getDatabase(project, databaseId)
.then((db) => {
dbCache.set(key, db);
dbPromiseCache.delete(key);
Expand Down
12 changes: 8 additions & 4 deletions src/firestore/delete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
total: Number.MAX_SAFE_INTEGER,
});

private urlPrefix: string;
private apiClient: apiv2.Client;

public isDocumentPath: boolean;
Expand All @@ -50,14 +51,15 @@
private parent: string;

/**
* Construct a new Firestore delete operation.

Check warning on line 54 in src/firestore/delete.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Expected only 0 line after block description
*
* @param project the Firestore project ID.
* @param path path to a document or collection.

Check warning on line 57 in src/firestore/delete.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Missing @param "options.urlPrefix"

Check warning on line 57 in src/firestore/delete.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Missing @param "options.databaseId"

Check warning on line 57 in src/firestore/delete.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Missing @param "options.allCollections"

Check warning on line 57 in src/firestore/delete.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Missing @param "options.shallow"

Check warning on line 57 in src/firestore/delete.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Missing @param "options.recursive"
* @param options options object with three optional parameters:
* - options.recursive true if the delete should be recursive.
* - options.shallow true if the delete should be shallow (non-recursive).
* - options.allCollections true if the delete should universally remove all collections and docs.
* - options.urlPrefix if specified initializes the client to use the given url, otherwise determine from environment
*/
constructor(
project: string,
Expand All @@ -67,6 +69,7 @@
shallow?: boolean;
allCollections?: boolean;
databaseId: string;
urlPrefix?: string;
},
) {
this.project = project;
Expand All @@ -75,6 +78,7 @@
this.shallow = Boolean(options.shallow);
this.allCollections = Boolean(options.allCollections);
this.databaseId = options.databaseId;
this.urlPrefix = options.urlPrefix ?? firestoreOriginOrEmulator();

// Tunable deletion parameters
this.readBatchSize = 7500;
Expand Down Expand Up @@ -113,7 +117,7 @@
this.apiClient = new apiv2.Client({
auth: true,
apiVersion: "v1",
urlPrefix: firestoreOriginOrEmulator(),
urlPrefix: this.urlPrefix,
});
}

Expand Down Expand Up @@ -156,7 +160,7 @@
* Construct a StructuredQuery to find descendant documents of a collection.
*
* See:
* https://firebase.google.com/docs/firestore/reference/rest/v1/StructuredQuery

Check warning on line 163 in src/firestore/delete.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Expected only 0 line after block description
*
* @param allDescendants true if subcollections should be included.
* @param batchSize maximum number of documents to target (limit).
Expand Down Expand Up @@ -394,7 +398,7 @@

numPendingDeletes++;
firestore
.deleteDocuments(this.project, toDelete, true)
.deleteDocuments(this.project, toDelete, this.urlPrefix)
.then((numDeleted) => {
FirestoreDelete.progressBar.tick(numDeleted);
numDocsDeleted += numDeleted;
Expand Down Expand Up @@ -499,7 +503,7 @@
let initialDelete;
if (this.isDocumentPath) {
const doc = { name: this.root + "/" + this.path };
initialDelete = firestore.deleteDocument(doc, true).catch((err) => {
initialDelete = firestore.deleteDocument(doc, this.urlPrefix).catch((err) => {
logger.debug("deletePath:initialDelete:error", err);
if (this.allDescendants) {
// On a recursive delete, we are insensitive to
Expand All @@ -526,7 +530,7 @@
*/
public deleteDatabase(): Promise<any[]> {
return firestore
.listCollectionIds(this.project, true)
.listCollectionIds(this.project, this.urlPrefix)
.catch((err) => {
logger.debug("deleteDatabase:listCollectionIds:error", err);
return utils.reject("Unable to list collection IDs");
Expand Down
44 changes: 23 additions & 21 deletions src/gcp/firestore.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { firestoreOrigin, firestoreOriginOrEmulator } from "../api";
import { firestoreOrigin } from "../api";
import { Client } from "../apiv2";
import { logger } from "../logger";
import { Duration, assertOneOf, durationFromSeconds } from "./proto";
Expand All @@ -10,11 +10,16 @@ const prodOnlyClient = new Client({
urlPrefix: firestoreOrigin(),
});

const emuOrProdClient = new Client({
auth: true,
apiVersion: "v1",
urlPrefix: firestoreOriginOrEmulator(),
});
function getClient(emulatorUrl?: string) {
if (emulatorUrl) {
return new Client({
auth: true,
apiVersion: "v1",
urlPrefix: emulatorUrl,
});
}
return prodOnlyClient;
}

export interface Database {
name: string;
Expand Down Expand Up @@ -147,9 +152,9 @@ export interface FirestoreDocument {
export async function getDatabase(
project: string,
database: string,
allowEmulator: boolean = false,
emulatorUrl?: string,
): Promise<Database> {
const apiClient = allowEmulator ? emuOrProdClient : prodOnlyClient;
const apiClient = getClient(emulatorUrl);
const url = `projects/${project}/databases/${database}`;
try {
const resp = await apiClient.get<Database>(url);
Expand All @@ -167,11 +172,8 @@ export async function getDatabase(
* @param {string} project the Google Cloud project ID.
* @return {Promise<string[]>} a promise for an array of collection IDs.
*/
export function listCollectionIds(
project: string,
allowEmulator: boolean = false,
): Promise<string[]> {
const apiClient = allowEmulator ? emuOrProdClient : prodOnlyClient;
export function listCollectionIds(project: string, emulatorUrl?: string): Promise<string[]> {
const apiClient = getClient(emulatorUrl);
const url = "projects/" + project + "/databases/(default)/documents:listCollectionIds";
const data = {
// Maximum 32-bit integer
Expand All @@ -192,9 +194,9 @@ export function listCollectionIds(
export async function getDocuments(
project: string,
paths: string[],
allowEmulator?: boolean,
emulatorUrl?: string,
): Promise<{ documents: FirestoreDocument[]; missing: string[] }> {
const apiClient = allowEmulator ? emuOrProdClient : prodOnlyClient;
const apiClient = getClient(emulatorUrl);
const basePath = `projects/${project}/databases/(default)/documents`;
const url = `${basePath}:batchGet`;
const fullPaths = paths.map((p) => `${basePath}/${p}`);
Expand All @@ -216,9 +218,9 @@ export async function getDocuments(
export async function queryCollection(
project: string,
structuredQuery: StructuredQuery,
allowEmulator?: boolean,
emulatorUrl?: string,
): Promise<{ documents: FirestoreDocument[] }> {
const apiClient = allowEmulator ? emuOrProdClient : prodOnlyClient;
const apiClient = getClient(emulatorUrl);
const basePath = `projects/${project}/databases/(default)/documents`;
const url = `${basePath}:runQuery`;
try {
Expand Down Expand Up @@ -258,8 +260,8 @@ export async function queryCollection(
* @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> {
const apiClient = allowEmulator ? emuOrProdClient : prodOnlyClient;
export async function deleteDocument(doc: any, emulatorUrl?: string): Promise<any> {
const apiClient = getClient(emulatorUrl);
return apiClient.delete(doc.name);
}

Expand All @@ -275,9 +277,9 @@ export async function deleteDocument(doc: any, allowEmulator: boolean = false):
export async function deleteDocuments(
project: string,
docs: any[],
allowEmulator: boolean = false,
emulatorUrl?: string,
): Promise<number> {
const apiClient = allowEmulator ? emuOrProdClient : prodOnlyClient;
const apiClient = getClient(emulatorUrl);
const url = "projects/" + project + "/databases/(default)/documents:commit";

const writes = docs.map((doc) => {
Expand Down
18 changes: 15 additions & 3 deletions src/mcp/tools/firestore/delete_document.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { tool } from "../../tool.js";
import { mcpError, toContent } from "../../util.js";
import { getDocuments } from "../../../gcp/firestore.js";
import { FirestoreDelete } from "../../../firestore/delete.js";
import { getFirestoreEmulatorUrl } from "./emulator.js";

export const delete_document = tool(
{
Expand All @@ -20,6 +21,7 @@ export const delete_document = tool(
.describe(
"A document path (e.g. `collectionName/documentId` or `parentCollection/parentDocument/collectionName/documentId`)",
),
use_emulator: z.boolean().default(false).describe("Target the Firestore emulator if true."),
}),
annotations: {
title: "Delete Firestore document",
Expand All @@ -30,20 +32,30 @@ export const delete_document = tool(
requiresProject: true,
},
},
async ({ path }, { projectId }) => {
async ({ path, use_emulator }, { projectId, host }) => {
// database ??= "(default)";
const { documents, missing } = await getDocuments(projectId, [path]);

let emulatorUrl: string | undefined;
if (use_emulator) {
emulatorUrl = await getFirestoreEmulatorUrl(await host.getEmulatorHubClient());
}

const { documents, missing } = await getDocuments(projectId!, [path], emulatorUrl);
if (missing.length > 0 && documents && documents.length === 0) {
return mcpError(`None of the specified documents were found in project '${projectId}'`);
}

const firestoreDelete = new FirestoreDelete(projectId, path, { databaseId: "(default)" });
const firestoreDelete = new FirestoreDelete(projectId, path, {
databaseId: "(default)",
urlPrefix: emulatorUrl,
});

await firestoreDelete.execute();

const { documents: postDeleteDocuments, missing: postDeleteMissing } = await getDocuments(
projectId,
[path],
emulatorUrl,
);
if (postDeleteMissing.length > 0 && postDeleteDocuments.length === 0) {
return toContent(`Successfully removed document located at : ${path}`);
Expand Down
26 changes: 26 additions & 0 deletions src/mcp/tools/firestore/emulator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { EmulatorHubClient } from "../../../emulator/hubClient.js";
import { Emulators } from "../../../emulator/types.js";

/**
* Gets the Firestore emulator host and port from the Emulator Hub.
* Throws an error if the Emulator Hub or Firestore emulator is not running.
* @param hubClient The EmulatorHubClient instance.
* @returns A string in the format "host:port".
*/
export async function getFirestoreEmulatorUrl(hubClient?: EmulatorHubClient): Promise<string> {
if (!hubClient) {
throw Error(
"Emulator Hub not found or is not running. You can start the emulator by running `firebase emulators:start` in your firebase project directory.",
);
}

const emulators = await hubClient.getEmulators();
const firestoreEmulatorInfo = emulators[Emulators.FIRESTORE];
if (!firestoreEmulatorInfo) {
throw Error(
"No Firestore Emulator found running. Make sure your project firebase.json file includes firestore and then rerun emulator using `firebase emulators:start` from your project directory.",
);
}

return `http://${firestoreEmulatorInfo.host}:${firestoreEmulatorInfo.port}`;
}
10 changes: 8 additions & 2 deletions src/mcp/tools/firestore/get_documents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { tool } from "../../tool.js";
import { mcpError, toContent } from "../../util.js";
import { getDocuments } from "../../../gcp/firestore.js";
import { firestoreDocumentToJson } from "./converter.js";
import { getFirestoreEmulatorUrl } from "./emulator.js";

export const get_documents = tool(
{
Expand All @@ -20,6 +21,7 @@ export const get_documents = tool(
.describe(
"One or more document paths (e.g. `collectionName/documentId` or `parentCollection/parentDocument/collectionName/documentId`)",
),
use_emulator: z.boolean().default(false).describe("Target the Firestore emulator if true."),
}),
annotations: {
title: "Get Firestore documents",
Expand All @@ -30,11 +32,15 @@ export const get_documents = tool(
requiresProject: true,
},
},
async ({ paths }, { projectId }) => {
async ({ paths, use_emulator }, { projectId, host }) => {
// database ??= "(default)";
if (!paths || !paths.length) return mcpError("Must supply at least one document path.");

const { documents, missing } = await getDocuments(projectId, paths);
let emulatorUrl: string | undefined;
if (use_emulator) {
emulatorUrl = await getFirestoreEmulatorUrl(await host.getEmulatorHubClient());
}
const { documents, missing } = await getDocuments(projectId, paths, emulatorUrl);
if (missing.length > 0 && documents && documents.length === 0) {
return mcpError(`None of the specified documents were found in project '${projectId}'`);
}
Expand Down
11 changes: 9 additions & 2 deletions src/mcp/tools/firestore/list_collections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { tool } from "../../tool.js";
import { toContent } from "../../util.js";
import { listCollectionIds } from "../../../gcp/firestore.js";
import { NO_PROJECT_ERROR } from "../../errors.js";
import { getFirestoreEmulatorUrl } from "./emulator.js";

export const list_collections = tool(
{
Expand All @@ -15,6 +16,7 @@ export const list_collections = tool(
// .string()
// .optional()
// .describe("Database id to use. Defaults to `(default)` if unspecified."),
use_emulator: z.boolean().default(false).describe("Target the Firestore emulator if true."),
}),
annotations: {
title: "List Firestore collections",
Expand All @@ -25,9 +27,14 @@ export const list_collections = tool(
requiresProject: true,
},
},
async (_, { projectId }) => {
async ({ use_emulator }, { projectId, host }) => {
// database ??= "(default)";
let emulatorUrl: string | undefined;
if (use_emulator) {
emulatorUrl = await getFirestoreEmulatorUrl(await host.getEmulatorHubClient());
}

if (!projectId) return NO_PROJECT_ERROR;
return toContent(await listCollectionIds(projectId));
return toContent(await listCollectionIds(projectId, emulatorUrl));
},
);
11 changes: 9 additions & 2 deletions src/mcp/tools/firestore/query_collection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { tool } from "../../tool.js";
import { mcpError, toContent } from "../../util.js";
import { queryCollection, StructuredQuery } from "../../../gcp/firestore.js";
import { convertInputToValue, firestoreDocumentToJson } from "./converter.js";
import { getFirestoreEmulatorUrl } from "./emulator.js";

export const query_collection = tool(
{
Expand Down Expand Up @@ -74,6 +75,7 @@ export const query_collection = tool(
.number()
.describe("The maximum amount of records to return. Default is 10.")
.optional(),
use_emulator: z.boolean().default(false).describe("Target the Firestore emulator if true."),
}),
annotations: {
title: "Query Firestore collection",
Expand All @@ -84,7 +86,7 @@ export const query_collection = tool(
requiresProject: true,
},
},
async ({ collection_path, filters, order, limit }, { projectId }) => {
async ({ collection_path, filters, order, limit, use_emulator }, { projectId, host }) => {
// database ??= "(default)";

if (!collection_path || !collection_path.length)
Expand Down Expand Up @@ -131,7 +133,12 @@ export const query_collection = tool(
}
structuredQuery.limit = limit ? limit : 10;

const { documents } = await queryCollection(projectId, structuredQuery);
let emulatorUrl: string | undefined;
if (use_emulator) {
emulatorUrl = await getFirestoreEmulatorUrl(await host.getEmulatorHubClient());
}

const { documents } = await queryCollection(projectId, structuredQuery, emulatorUrl);

const docs = documents.map(firestoreDocumentToJson);

Expand Down
Loading