Skip to content

Firestore mcp emulator #8664

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

Open
wants to merge 4 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 53 additions & 11 deletions src/gcp/firestore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,16 @@ const prodOnlyClient = new Client({
urlPrefix: firestoreOrigin(),
});

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

export interface Database {
name: string;
Expand Down Expand Up @@ -149,7 +154,7 @@ export async function getDatabase(
database: string,
allowEmulator: boolean = false,
): Promise<Database> {
const apiClient = allowEmulator ? emuOrProdClient : prodOnlyClient;
const apiClient = getClient(allowEmulator);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(offline discussion)

Not a big fan of the allowEmulator boolean + the env override. Can do a simple refactor to change them to take a apiClient override option.
If the client wants to use emulator, it can override.

const url = `projects/${project}/databases/${database}`;
try {
const resp = await apiClient.get<Database>(url);
Expand All @@ -171,7 +176,7 @@ export function listCollectionIds(
project: string,
allowEmulator: boolean = false,
): Promise<string[]> {
const apiClient = allowEmulator ? emuOrProdClient : prodOnlyClient;
const apiClient = getClient(allowEmulator);
const url = "projects/" + project + "/databases/(default)/documents:listCollectionIds";
const data = {
// Maximum 32-bit integer
Expand All @@ -194,7 +199,7 @@ export async function getDocuments(
paths: string[],
allowEmulator?: boolean,
): Promise<{ documents: FirestoreDocument[]; missing: string[] }> {
const apiClient = allowEmulator ? emuOrProdClient : prodOnlyClient;
const apiClient = getClient(allowEmulator);
const basePath = `projects/${project}/databases/(default)/documents`;
const url = `${basePath}:batchGet`;
const fullPaths = paths.map((p) => `${basePath}/${p}`);
Expand All @@ -218,7 +223,7 @@ export async function queryCollection(
structuredQuery: StructuredQuery,
allowEmulator?: boolean,
): Promise<{ documents: FirestoreDocument[] }> {
const apiClient = allowEmulator ? emuOrProdClient : prodOnlyClient;
const apiClient = getClient(allowEmulator);
const basePath = `projects/${project}/databases/(default)/documents`;
const url = `${basePath}:runQuery`;
try {
Expand Down Expand Up @@ -259,7 +264,7 @@ export async function queryCollection(
* @return {Promise} a promise for the delete operation.
*/
export async function deleteDocument(doc: any, allowEmulator: boolean = false): Promise<any> {
const apiClient = allowEmulator ? emuOrProdClient : prodOnlyClient;
const apiClient = getClient(allowEmulator);
return apiClient.delete(doc.name);
}

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

const writes = docs.map((doc) => {
Expand All @@ -293,6 +298,43 @@ export async function deleteDocuments(
return res.body.writeResults.length;
}

/**
* Create or update a single Firestore document.
*
* For document format see:
* https://firebase.google.com/docs/firestore/reference/rest/v1/projects.databases.documents#Document
* @param {string} project the Google Cloud project ID.
* @param {string} path The document path.
* @param {FirestoreDocument} document The document object to put.
* @return {Promise<FirestoreDocument>} a promise for the put operation.
*/
export async function commitDocument(
project: string,
path: string,
document: FirestoreDocument,
allowEmulator: boolean = false,
): Promise<any[]> {
const apiClient = getClient(allowEmulator);
const url = `projects/${project}/databases/(default)/documents:commit`;

const writes = [
{
update: {
name: `projects/${project}/databases/(default)/documents/${path}`,
fields: document.fields,
},
},
];
const data = { writes };

const res = await apiClient.post<any, { writeResults: any[] }>(url, data, {
retries: 10,
retryCodes: [429, 409, 503],
retryMaxTimeout: 20 * 1000,
});
return res.body.writeResults[0];
}

/**
* Create a backup schedule for the given Firestore database.
* @param {string} project the Google Cloud project ID.
Expand Down
71 changes: 71 additions & 0 deletions src/mcp/tools/firestore/commit_document.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { z } from "zod";
import { tool } from "../../tool.js";
import { mcpError, toContent } from "../../util.js";
import { commitDocument, FirestoreDocument, FirestoreValue } from "../../../gcp/firestore.js";
import { convertInputToValue, firestoreDocumentToJson } from "./converter.js";

Check failure on line 5 in src/mcp/tools/firestore/commit_document.ts

View workflow job for this annotation

GitHub Actions / unit (20)

'firestoreDocumentToJson' is defined but never used

Check failure on line 5 in src/mcp/tools/firestore/commit_document.ts

View workflow job for this annotation

GitHub Actions / unit (22)

'firestoreDocumentToJson' is defined but never used
import { getFirestoreEmulatorHost } from "./emulator.js";
import { NO_PROJECT_ERROR } from "../../errors.js";

export const commit_document = tool(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's separate it out to a separate PR

{
name: "commit_document",
description:
"Creates or updates a Firestore document in a database in the current project. If the document does not exist, it will be created. If the document does exist, its contents will be overwritten with the new data.",
inputSchema: z.object({
// TODO: Support configurable database
// database: z
// .string()
// .nullish()
// .describe("Database id to use. Defaults to `(default)` if unspecified."),
path: z
.string()
.describe(
"A document path (e.g. `collectionName/documentId` or `parentCollection/parentDocument/collectionName/documentId`)",
),
document_data: z
.record(z.any())
.describe(
"A JSON object representing the fields of the document. For special data types like GeoPoint or Timestamp, refer to Firestore documentation for the correct JSON representation or use simpler types like strings/numbers/booleans/arrays/maps.",
),
use_emulator: z.boolean().default(false).describe("Target the Firestore emulator if true."),
}),
annotations: {
title: "Put Firestore document",
},
_meta: {
requiresAuth: true,
requiresProject: true,
},
},
async ({ path, document_data, use_emulator }, { projectId, host }) => {
if (!projectId) return NO_PROJECT_ERROR;
if (use_emulator) {
const emulatorHost = await getFirestoreEmulatorHost(await host.getEmulatorHubClient());
if (emulatorHost) {
process.env.FIRESTORE_EMULATOR_HOST = emulatorHost;
}
}

if (!path || Object.keys(document_data).length === 0) {
return mcpError("Document path and document_data cannot be empty.");
}

const fields: { [key: string]: FirestoreValue } = {};
for (const key in document_data) {
if (Object.prototype.hasOwnProperty.call(document_data, key)) {
fields[key] = convertInputToValue(document_data[key]);
}
}

const firestoreDocToPut: Partial<FirestoreDocument> & { fields: { [key: string]: FirestoreValue } } = {

Check failure on line 60 in src/mcp/tools/firestore/commit_document.ts

View workflow job for this annotation

GitHub Actions / unit (20)

Replace `·fields:·{·[key:·string]:·FirestoreValue·}` with `⏎······fields:·{·[key:·string]:·FirestoreValue·};⏎···`

Check failure on line 60 in src/mcp/tools/firestore/commit_document.ts

View workflow job for this annotation

GitHub Actions / unit (22)

Replace `·fields:·{·[key:·string]:·FirestoreValue·}` with `⏎······fields:·{·[key:·string]:·FirestoreValue·};⏎···`
fields,
};

try {
const result = await commitDocument(projectId, path, firestoreDocToPut as any, use_emulator);
return toContent(result);
} catch (err: any) {
return mcpError(`Failed to put document at path '${path}': ${err.message || err}`);
}
},
);

Check failure on line 71 in src/mcp/tools/firestore/commit_document.ts

View workflow job for this annotation

GitHub Actions / unit (20)

Insert `⏎`

Check failure on line 71 in src/mcp/tools/firestore/commit_document.ts

View workflow job for this annotation

GitHub Actions / unit (22)

Insert `⏎`
14 changes: 11 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 { mcpError, toContent } from "../../util.js";
import { getDocuments } from "../../../gcp/firestore.js";
import { FirestoreDelete } from "../../../firestore/delete.js";
import { getFirestoreEmulatorHost } from "./emulator.js";

export const delete_document = tool(
{
Expand All @@ -20,19 +21,26 @@
.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",
destructiveHint: true,
},
_meta: {
_meta: {

Check failure on line 30 in src/mcp/tools/firestore/delete_document.ts

View workflow job for this annotation

GitHub Actions / unit (20)

Insert `··`

Check failure on line 30 in src/mcp/tools/firestore/delete_document.ts

View workflow job for this annotation

GitHub Actions / unit (22)

Insert `··`
requiresAuth: true,
requiresProject: true,
},
},
async ({ path }, { projectId }) => {
async ({ path, use_emulator }, { projectId, host }) => {
// database ??= "(default)";
const { documents, missing } = await getDocuments(projectId!, [path]);

if (use_emulator) {
const emulatorHost = await getFirestoreEmulatorHost(await host.getEmulatorHubClient());
process.env.FIRESTORE_EMULATOR_HOST = emulatorHost;
}

const { documents, missing } = await getDocuments(projectId!, [path], use_emulator);
if (missing.length > 0 && documents && documents.length === 0) {
return mcpError(`None of the specified documents were found in project '${projectId}'`);
}
Expand Down
27 changes: 27 additions & 0 deletions src/mcp/tools/firestore/emulator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
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 getFirestoreEmulatorHost(hubClient?: EmulatorHubClient): Promise<string> {
if (!hubClient) {
throw Error(

Check failure on line 12 in src/mcp/tools/firestore/emulator.ts

View workflow job for this annotation

GitHub Actions / unit (20)

Replace `⏎······"Emulator·Hub·not·found·or·is·not·running.·Cannot·target·Firestore·emulator.",⏎····` with `"Emulator·Hub·not·found·or·is·not·running.·Cannot·target·Firestore·emulator."`

Check failure on line 12 in src/mcp/tools/firestore/emulator.ts

View workflow job for this annotation

GitHub Actions / unit (22)

Replace `⏎······"Emulator·Hub·not·found·or·is·not·running.·Cannot·target·Firestore·emulator.",⏎····` with `"Emulator·Hub·not·found·or·is·not·running.·Cannot·target·Firestore·emulator."`
"Emulator Hub not found or is not running. Cannot target Firestore emulator.",
);
}

const emulators = await hubClient.getEmulators();
const firestoreEmulatorInfo = emulators[Emulators.FIRESTORE];

if (!firestoreEmulatorInfo) {
throw Error(

Check failure on line 21 in src/mcp/tools/firestore/emulator.ts

View workflow job for this annotation

GitHub Actions / unit (20)

Replace `⏎······"No·Firestore·Emulator·found·running.",⏎····` with `"No·Firestore·Emulator·found·running."`

Check failure on line 21 in src/mcp/tools/firestore/emulator.ts

View workflow job for this annotation

GitHub Actions / unit (22)

Replace `⏎······"No·Firestore·Emulator·found·running.",⏎····` with `"No·Firestore·Emulator·found·running."`
"No Firestore Emulator found running.",
);
}

return `${firestoreEmulatorInfo.host}:${firestoreEmulatorInfo.port}`;
}

Check failure on line 27 in src/mcp/tools/firestore/emulator.ts

View workflow job for this annotation

GitHub Actions / unit (20)

Insert `⏎`

Check failure on line 27 in src/mcp/tools/firestore/emulator.ts

View workflow job for this annotation

GitHub Actions / unit (22)

Insert `⏎`
9 changes: 8 additions & 1 deletion 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 { getFirestoreEmulatorHost } 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,10 +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.");

if (use_emulator) {
const emulatorHost = await getFirestoreEmulatorHost(await host.getEmulatorHubClient());
process.env.FIRESTORE_EMULATOR_HOST = emulatorHost;
}

const { documents, missing } = await getDocuments(projectId!, paths);
if (missing.length > 0 && documents && documents.length === 0) {
return mcpError(`None of the specified documents were found in project '${projectId}'`);
Expand Down
2 changes: 2 additions & 0 deletions src/mcp/tools/firestore/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { delete_document } from "./delete_document";
import { get_documents } from "./get_documents";
import { list_collections } from "./list_collections";
import { query_collection } from "./query_collection";
import { commit_document } from "./commit_document";
import { validateRulesTool } from "../rules/validate_rules";
import { getRulesTool } from "../rules/get_rules";

Expand All @@ -10,6 +11,7 @@ export const firestoreTools = [
get_documents,
list_collections,
query_collection,
commit_document,
getRulesTool("Firestore", "cloud.firestore"),
validateRulesTool("Firestore"),
];
12 changes: 10 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 { getFirestoreEmulatorHost } from "./emulator.js";

export const list_collections = tool(
{
Expand All @@ -15,6 +16,7 @@ export const list_collections = tool(
// .string()
// .nullish()
// .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,15 @@ export const list_collections = tool(
requiresProject: true,
},
},
async (_, { projectId }) => {
async ({ use_emulator }, { projectId, host }) => {
// database ??= "(default)";
if (!projectId) return NO_PROJECT_ERROR;
return toContent(await listCollectionIds(projectId));

if (use_emulator) {
const emulatorHost = await getFirestoreEmulatorHost(await host.getEmulatorHubClient());
process.env.FIRESTORE_EMULATOR_HOST = emulatorHost;
}

return toContent(await listCollectionIds(projectId, use_emulator));
},
);
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 { mcpError, toContent } from "../../util.js";
import { queryCollection, StructuredQuery } from "../../../gcp/firestore.js";
import { convertInputToValue, firestoreDocumentToJson } from "./converter.js";
import { getFirestoreEmulatorHost } from "./emulator.js";

export const query_collection = tool(
{
Expand Down Expand Up @@ -68,6 +69,7 @@
.number()
.describe("The maximum amount of records to return. Default is 10.")
.nullish(),
use_emulator: z.boolean().default(false).describe("Target the Firestore emulator if true."),
}),
annotations: {
title: "Query Firestore collection",
Expand All @@ -78,12 +80,17 @@
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)
return mcpError("Must supply at least one collection path.");

if (use_emulator) {
const emulatorHost = await getFirestoreEmulatorHost(await host.getEmulatorHubClient());
process.env.FIRESTORE_EMULATOR_HOST = emulatorHost;
}

const structuredQuery: StructuredQuery = {
from: [{ collectionId: collection_path, allDescendants: false }],
};
Expand Down Expand Up @@ -125,7 +132,7 @@
}
structuredQuery.limit = limit ? limit : 10;

const { documents } = await queryCollection(projectId!, structuredQuery);
const { documents } = await queryCollection(projectId!, structuredQuery, /** allowEmulator= */ use_emulator);

Check failure on line 135 in src/mcp/tools/firestore/query_collection.ts

View workflow job for this annotation

GitHub Actions / unit (20)

Replace `projectId!,·structuredQuery,·/**·allowEmulator=·*/·use_emulator` with `⏎······projectId!,⏎······structuredQuery,⏎······/**·allowEmulator=·*/·use_emulator,⏎····`

Check failure on line 135 in src/mcp/tools/firestore/query_collection.ts

View workflow job for this annotation

GitHub Actions / unit (22)

Replace `projectId!,·structuredQuery,·/**·allowEmulator=·*/·use_emulator` with `⏎······projectId!,⏎······structuredQuery,⏎······/**·allowEmulator=·*/·use_emulator,⏎····`

const docs = documents.map(firestoreDocumentToJson);

Expand Down
Loading