Skip to content

[MCP] implement {get,set}_data and {validate,get}_rules for RTDB MCP #8854

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

Merged
merged 9 commits into from
Aug 13, 2025
Merged
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
10 changes: 10 additions & 0 deletions src/mcp/tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,22 @@
mcp: {
name: string;
description?: string;
inputSchema: any;

Check warning on line 21 in src/mcp/tool.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unexpected any. Specify a different type
annotations?: {
title?: string;

// If this tool modifies data or not.
readOnlyHint?: boolean;

// this tool can destroy data.
destructiveHint?: boolean;

// this tool is safe to run multiple times.
idempotentHint?: boolean;

// If this is true, it connects to the internet or other open world
// systems. If false, the tool only performs actions in an enclosed
// system, such as your project.
openWorldHint?: boolean;
};
_meta?: {
Expand All @@ -40,7 +50,7 @@
fn: (input: z.infer<InputSchema>, ctx: ServerToolContext) => Promise<CallToolResult>;
}

export function tool<InputSchema extends ZodTypeAny>(

Check warning on line 53 in src/mcp/tool.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Missing JSDoc comment
options: Omit<ServerTool<InputSchema>["mcp"], "inputSchema"> & {
inputSchema: InputSchema;
},
Expand Down
67 changes: 67 additions & 0 deletions src/mcp/tools/database/get_data.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { z } from "zod";
import { tool } from "../../tool";
import { mcpError, toContent } from "../../util";
import * as url from "node:url";
import { Client } from "../../../apiv2";
import { text } from "node:stream/consumers";
import path from "node:path";

export const get_data = tool(
{
name: "get_data",
description: "Returns RTDB data from the specified location",
inputSchema: z.object({
databaseUrl: z
.string()
.optional()
.describe(
"connect to the database at url. If omitted, use default database instance <project>-default-rtdb.firebasedatabase.app. Can point to emulator URL (e.g. localhost:6000/<instance>)",
),
path: z.string().describe("The path to the data to read. (ex: /my/cool/path)"),
}),
annotations: {
title: "Get Realtime Database data",
readOnlyHint: true,
},

_meta: {
// it's possible that a user attempts to query a database that they aren't
// authed into: we should let the rules evaluate as the author intended.
// If they have written rules to leave paths public, then having mcp
// grab their data is perfectly valid.
requiresAuth: false,
requiresProject: false,
},
},
async ({ path: getPath, databaseUrl }, { projectId, host }) => {
if (!getPath.startsWith("/")) {
return mcpError(`paths must start with '/' (you passed ''${getPath}')`);
}

const dbUrl = new url.URL(
databaseUrl
? `${databaseUrl}/${getPath}.json`
: path.join(
`https://${projectId}-default-rtdb.us-central1.firebasedatabase.app`,
`${getPath}.json`,
),
);

const client = new Client({
urlPrefix: dbUrl.origin,
auth: true,
});

host.logger.debug(`sending read request to path '${getPath}' for url '${dbUrl.toString()}'`);

Check warning on line 55 in src/mcp/tools/database/get_data.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator

const res = await client.request<unknown, NodeJS.ReadableStream>({
method: "GET",
path: dbUrl.pathname,
responseType: "stream",
resolveOnHTTPError: true,
});

const content = await text(res.body);
return toContent(content);
},
);
46 changes: 46 additions & 0 deletions src/mcp/tools/database/get_rules.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { z } from "zod";
import { Client } from "../../../apiv2";
import { tool } from "../../tool";
import { mcpError, toContent } from "../../util";

export const get_rules = tool(
{
name: "get_rules",
description: "Get an RTDB database's rules",
inputSchema: z.object({
databaseUrl: z
.string()
.optional()
.describe(
"connect to the database at url. If omitted, use default database instance <project>-default-rtdb.firebaseio.com. Can point to emulator URL (e.g. localhost:6000/<instance>)",
),
}),
annotations: {
title: "Get Realtime Database rules",
readOnlyHint: true,
},

_meta: {
requiresAuth: false,
requiresProject: false,
},
},
async ({ databaseUrl }, { projectId }) => {
const dbUrl =
databaseUrl ?? `https://${projectId}-default-rtdb.us-central1.firebasedatabase.app`;

const client = new Client({ urlPrefix: dbUrl });
const response = await client.request<void, NodeJS.ReadableStream>({
method: "GET",
path: "/.settings/rules.json",
responseType: "stream",
resolveOnHTTPError: true,
});
if (response.status !== 200) {
return mcpError(`Failed to fetch current rules. Code: ${response.status}`);
}

const rules = await response.response.text();
return toContent(rules);
},
);
7 changes: 7 additions & 0 deletions src/mcp/tools/database/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import type { ServerTool } from "../../tool";
import { get_rules } from "./get_rules";
import { get_data } from "./get_data";
import { set_data } from "./set_data";
import { validate_rules } from "./validate_rules";

export const realtimeDatabaseTools: ServerTool[] = [get_data, set_data, get_rules, validate_rules];
71 changes: 71 additions & 0 deletions src/mcp/tools/database/set_data.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { z } from "zod";
import { tool } from "../../tool";
import { mcpError, toContent } from "../../util";
import * as url from "node:url";
import { stringToStream } from "../../../utils";
import { Client } from "../../../apiv2";
import { getErrMsg } from "../../../error";
import path from "node:path";

export const set_data = tool(
{
name: "set_data",
description: "Writes RTDB data to the specified location",
inputSchema: z.object({
databaseUrl: z
.string()
.optional()
.describe(
"connect to the database at url. If omitted, use default database instance <project>-default-rtdb.us-central1.firebasedatabase.app. Can point to emulator URL (e.g. localhost:6000/<instance>)",
),
path: z.string().describe("The path to the data to read. (ex: /my/cool/path)"),
data: z.string().describe('The JSON to write. (ex: {"alphabet": ["a", "b", "c"]})'),
}),
annotations: {
title: "Set Realtime Database data",
readOnlyHint: false,
idempotentHint: true,
},

_meta: {
requiresAuth: false,
requiresProject: false,
},
},
async ({ path: setPath, databaseUrl, data }, { projectId, host }) => {
if (!setPath.startsWith("/")) {
return mcpError(`paths must start with '/' (you passed ''${setPath}')`);
}

const dbUrl = new url.URL(
databaseUrl
? `${databaseUrl}/${setPath}.json`
: path.join(
`https://${projectId}-default-rtdb.us-central1.firebasedatabase.app`,
`${setPath}.json`,
),
);

const client = new Client({
urlPrefix: dbUrl.origin,
auth: true,
});

const inStream = stringToStream(data);

host.logger.debug(`sending write request to path '${setPath}' for url '${dbUrl.toString()}'`);

Check warning on line 56 in src/mcp/tools/database/set_data.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator

try {
await client.request({
method: "PUT",
path: dbUrl.pathname,
body: inStream,
});
} catch (err: unknown) {
host.logger.debug(getErrMsg(err));

Check warning on line 65 in src/mcp/tools/database/set_data.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator
return mcpError(`Unexpected error while setting data: ${getErrMsg(err)}`);
}

return toContent("write successful!");
},
);
48 changes: 48 additions & 0 deletions src/mcp/tools/database/set_rules.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { z } from "zod";
import { Client } from "../../../apiv2";
import { tool } from "../../tool";
import { mcpError, toContent } from "../../util";
import { updateRulesWithClient } from "../../../rtdb";
import { getErrMsg } from "../../../error";

export const validate_rules = tool(
{
name: "validate_rules",
description: "Validates an RTDB database's rules",
inputSchema: z.object({
databaseUrl: z
.string()
.optional()
.describe(
"connect to the database at url. If omitted, use default database instance <project>-default-rtdb.firebaseio.com. Can point to emulator URL (e.g. localhost:6000/<instance>)",
),
rules: z
.string()
.describe('The rules object, as a string (ex: {".read": false, ".write": false})'),
}),
annotations: {
title: "Validate Realtime Database rules",
idempotentHint: true,
},

_meta: {
requiresAuth: true,
requiresProject: false,
},
},
async ({ databaseUrl, rules }, { projectId, host }) => {
const dbUrl =
databaseUrl ?? `https://${projectId}-default-rtdb.us-central1.firebasedatabase.app`;

const client = new Client({ urlPrefix: dbUrl });

try {
await updateRulesWithClient(client, rules, { dryRun: true });
} catch (e: unknown) {
host.logger.debug(`failed to update rules at url ${dbUrl}`);

Check warning on line 42 in src/mcp/tools/database/set_rules.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator
return mcpError(getErrMsg(e));
}

return toContent("the inputted rules are valid!");
},
);
50 changes: 50 additions & 0 deletions src/mcp/tools/database/validate_rules.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { z } from "zod";
import { Client } from "../../../apiv2";
import { tool } from "../../tool";
import { mcpError, toContent } from "../../util";
import { updateRulesWithClient } from "../../../rtdb";
import { getErrMsg } from "../../../error";

export const validate_rules = tool(
{
name: "validate_rules",
description: "Validates an RTDB database's rules",
inputSchema: z.object({
databaseUrl: z
.string()
.optional()
.describe(
"connect to the database at url. If omitted, use default database instance <project>-default-rtdb.firebaseio.com. Can point to emulator URL (e.g. localhost:6000/<instance>)",
),
rules: z
.string()
.describe(
'The rules object, as a string (ex: {"rules": {".read": false, ".write": false}})',
),
}),
annotations: {
title: "Validate Realtime Database rules",
idempotentHint: true,
},

_meta: {
requiresAuth: true,
requiresProject: false,
},
},
async ({ databaseUrl, rules }, { projectId, host }) => {
const dbUrl =
databaseUrl ?? `https://${projectId}-default-rtdb.us-central1.firebasedatabase.app`;

const client = new Client({ urlPrefix: dbUrl });

try {
await updateRulesWithClient(client, rules, { dryRun: true });
} catch (e: unknown) {
host.logger.debug(`failed to validate rules at url ${dbUrl}`);

Check warning on line 44 in src/mcp/tools/database/validate_rules.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator
return mcpError(getErrMsg(e));
}

return toContent("the inputted rules are valid!");
},
);
2 changes: 2 additions & 0 deletions src/mcp/tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,17 @@
import { remoteConfigTools } from "./remoteconfig/index";
import { crashlyticsTools } from "./crashlytics/index";
import { appHostingTools } from "./apphosting/index";
import { realtimeDatabaseTools } from "./database/index";

/** availableTools returns the list of MCP tools available given the server flags */
export function availableTools(activeFeatures?: ServerFeature[]): ServerTool[] {
// Core tools are always present.
const toolDefs: ServerTool[] = addFeaturePrefix("firebase", coreTools);
if (!activeFeatures?.length) {
activeFeatures = Object.keys(tools) as ServerFeature[];

Check warning on line 19 in src/mcp/tools/index.ts

View workflow job for this annotation

GitHub Actions / lint (20)

'tools' was used before it was defined
}
for (const key of activeFeatures) {
toolDefs.push(...tools[key]);

Check warning on line 22 in src/mcp/tools/index.ts

View workflow job for this annotation

GitHub Actions / lint (20)

'tools' was used before it was defined
}
return toolDefs;
}
Expand All @@ -32,6 +33,7 @@
remoteconfig: addFeaturePrefix("remoteconfig", remoteConfigTools),
crashlytics: addFeaturePrefix("crashlytics", crashlyticsTools),
apphosting: addFeaturePrefix("apphosting", appHostingTools),
database: addFeaturePrefix("database", realtimeDatabaseTools),
};

function addFeaturePrefix(feature: string, tools: ServerTool[]): ServerTool[] {
Expand Down
2 changes: 1 addition & 1 deletion src/mcp/tools/rules/get_rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import { mcpError, toContent } from "../../util";
import { getLatestRulesetName, getRulesetContent } from "../../../gcp/rules";

export function getRulesTool(productName: string, releaseName: string) {

Check warning on line 6 in src/mcp/tools/rules/get_rules.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Missing return type on function
return tool(
{
name: "get_rules",
Expand All @@ -21,7 +21,7 @@
async (_, { projectId }) => {
const rulesetName = await getLatestRulesetName(projectId, releaseName);
if (!rulesetName)
return mcpError(`No active Firestore rules were found in project '${projectId}'`);
return mcpError(`No active ${productName} rules were found in project '${projectId}'`);
const rules = await getRulesetContent(rulesetName);
return toContent(rules[0].content);
},
Expand Down
1 change: 1 addition & 0 deletions src/mcp/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export const SERVER_FEATURES = [
"remoteconfig",
"crashlytics",
"apphosting",
"database",
] as const;
export type ServerFeature = (typeof SERVER_FEATURES)[number];

Expand Down
6 changes: 6 additions & 0 deletions src/mcp/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
remoteConfigApiOrigin,
storageOrigin,
crashlyticsApiOrigin,
realtimeOrigin,
} from "../api";
import { check } from "../ensureApiEnabled";
import { timeoutFallback } from "../timeout";
Expand Down Expand Up @@ -59,6 +60,10 @@ export function mcpError(message: Error | string | unknown, code?: string): Call
};
}

/*
* Wraps a throwing function with a safe conversion to mcpError.
*/

/**
* Checks if a command exists in the system.
*/
Expand Down Expand Up @@ -90,6 +95,7 @@ const SERVER_FEATURE_APIS: Record<ServerFeature, string> = {
remoteconfig: remoteConfigApiOrigin(),
crashlytics: crashlyticsApiOrigin(),
apphosting: apphostingOrigin(),
database: realtimeOrigin(),
};

/**
Expand Down
Loading
Loading