diff --git a/src/mcp/tool.ts b/src/mcp/tool.ts index faffd577f0b..990bb7adef9 100644 --- a/src/mcp/tool.ts +++ b/src/mcp/tool.ts @@ -21,9 +21,19 @@ export interface ServerTool { inputSchema: any; 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?: { diff --git a/src/mcp/tools/database/get_data.ts b/src/mcp/tools/database/get_data.ts new file mode 100644 index 00000000000..ea88e39ecb3 --- /dev/null +++ b/src/mcp/tools/database/get_data.ts @@ -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 -default-rtdb.firebasedatabase.app. Can point to emulator URL (e.g. localhost:6000/)", + ), + 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()}'`); + + const res = await client.request({ + method: "GET", + path: dbUrl.pathname, + responseType: "stream", + resolveOnHTTPError: true, + }); + + const content = await text(res.body); + return toContent(content); + }, +); diff --git a/src/mcp/tools/database/get_rules.ts b/src/mcp/tools/database/get_rules.ts new file mode 100644 index 00000000000..e9eb05d6cef --- /dev/null +++ b/src/mcp/tools/database/get_rules.ts @@ -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 -default-rtdb.firebaseio.com. Can point to emulator URL (e.g. localhost:6000/)", + ), + }), + 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({ + 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); + }, +); diff --git a/src/mcp/tools/database/index.ts b/src/mcp/tools/database/index.ts new file mode 100644 index 00000000000..f532461fa74 --- /dev/null +++ b/src/mcp/tools/database/index.ts @@ -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]; diff --git a/src/mcp/tools/database/set_data.ts b/src/mcp/tools/database/set_data.ts new file mode 100644 index 00000000000..998e76a6703 --- /dev/null +++ b/src/mcp/tools/database/set_data.ts @@ -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 -default-rtdb.us-central1.firebasedatabase.app. Can point to emulator URL (e.g. localhost:6000/)", + ), + 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()}'`); + + try { + await client.request({ + method: "PUT", + path: dbUrl.pathname, + body: inStream, + }); + } catch (err: unknown) { + host.logger.debug(getErrMsg(err)); + return mcpError(`Unexpected error while setting data: ${getErrMsg(err)}`); + } + + return toContent("write successful!"); + }, +); diff --git a/src/mcp/tools/database/set_rules.ts b/src/mcp/tools/database/set_rules.ts new file mode 100644 index 00000000000..817284606a9 --- /dev/null +++ b/src/mcp/tools/database/set_rules.ts @@ -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 -default-rtdb.firebaseio.com. Can point to emulator URL (e.g. localhost:6000/)", + ), + 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}`); + return mcpError(getErrMsg(e)); + } + + return toContent("the inputted rules are valid!"); + }, +); diff --git a/src/mcp/tools/database/validate_rules.ts b/src/mcp/tools/database/validate_rules.ts new file mode 100644 index 00000000000..53603472ed8 --- /dev/null +++ b/src/mcp/tools/database/validate_rules.ts @@ -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 -default-rtdb.firebaseio.com. Can point to emulator URL (e.g. localhost:6000/)", + ), + 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}`); + return mcpError(getErrMsg(e)); + } + + return toContent("the inputted rules are valid!"); + }, +); diff --git a/src/mcp/tools/index.ts b/src/mcp/tools/index.ts index b51a8dc6a4f..83bc2647073 100644 --- a/src/mcp/tools/index.ts +++ b/src/mcp/tools/index.ts @@ -9,6 +9,7 @@ import { messagingTools } from "./messaging/index"; 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[] { @@ -32,6 +33,7 @@ const tools: Record = { remoteconfig: addFeaturePrefix("remoteconfig", remoteConfigTools), crashlytics: addFeaturePrefix("crashlytics", crashlyticsTools), apphosting: addFeaturePrefix("apphosting", appHostingTools), + database: addFeaturePrefix("database", realtimeDatabaseTools), }; function addFeaturePrefix(feature: string, tools: ServerTool[]): ServerTool[] { diff --git a/src/mcp/tools/rules/get_rules.ts b/src/mcp/tools/rules/get_rules.ts index 9a7ff35bbcb..e9407bc4a32 100644 --- a/src/mcp/tools/rules/get_rules.ts +++ b/src/mcp/tools/rules/get_rules.ts @@ -21,7 +21,7 @@ export function getRulesTool(productName: string, releaseName: string) { 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); }, diff --git a/src/mcp/types.ts b/src/mcp/types.ts index 784137b4ab4..aff2f7f421f 100644 --- a/src/mcp/types.ts +++ b/src/mcp/types.ts @@ -7,6 +7,7 @@ export const SERVER_FEATURES = [ "remoteconfig", "crashlytics", "apphosting", + "database", ] as const; export type ServerFeature = (typeof SERVER_FEATURES)[number]; diff --git a/src/mcp/util.ts b/src/mcp/util.ts index 1f4d9a5be2b..4dd1c477036 100644 --- a/src/mcp/util.ts +++ b/src/mcp/util.ts @@ -12,6 +12,7 @@ import { remoteConfigApiOrigin, storageOrigin, crashlyticsApiOrigin, + realtimeOrigin, } from "../api"; import { check } from "../ensureApiEnabled"; import { timeoutFallback } from "../timeout"; @@ -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. */ @@ -90,6 +95,7 @@ const SERVER_FEATURE_APIS: Record = { remoteconfig: remoteConfigApiOrigin(), crashlytics: crashlyticsApiOrigin(), apphosting: apphostingOrigin(), + database: realtimeOrigin(), }; /** diff --git a/src/rtdb.ts b/src/rtdb.ts index f3407f3beea..1a9d9aa4d4e 100644 --- a/src/rtdb.ts +++ b/src/rtdb.ts @@ -13,10 +13,6 @@ export async function updateRules( src: any, options: { dryRun?: boolean } = {}, ): Promise { - const queryParams: { dryRun?: string } = {}; - if (options.dryRun) { - queryParams.dryRun = "true"; - } const downstreamOptions: { instance: string; project: string; @@ -32,6 +28,20 @@ export async function updateRules( "", ); const client = new Client({ urlPrefix: origin }); + + return updateRulesWithClient(client, options); +} + +export async function updateRulesWithClient( + client: Client, + src: unknown, + options: { dryRun?: boolean } = {}, +) { + const queryParams: { dryRun?: string } = {}; + if (options.dryRun) { + queryParams.dryRun = "true"; + } + const response = await client.request({ method: "PUT", path: ".settings/rules.json",