Skip to content

Commit bff5222

Browse files
author
oleina
committed
[MCP] implement {get,set}_data and {validate,get}_rules for RTDB MCP
1 parent a3e87e5 commit bff5222

File tree

11 files changed

+305
-5
lines changed

11 files changed

+305
-5
lines changed

src/mcp/tools/database/get_data.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { z } from "zod";
2+
import { tool } from "../../tool";
3+
import { mcpError, toContent } from "../../util";
4+
import * as url from "node:url";
5+
import { Client } from "../../../apiv2";
6+
import { text } from "node:stream/consumers";
7+
8+
export const get_data = tool(
9+
{
10+
name: "get_data",
11+
description: "Returns RTDB data from the specified location",
12+
inputSchema: z.object({
13+
databaseUrl: z
14+
.string()
15+
.optional()
16+
.describe(
17+
"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>)",
18+
),
19+
path: z.string().describe("The path to the data to read. (ex: /my/cool/path)"),
20+
}),
21+
annotations: {
22+
title: "Get Realtime Database data",
23+
readOnlyHint: true,
24+
},
25+
26+
_meta: {
27+
// it's possible that a user attempts to query a database that they aren't
28+
// authed into: we should let the rules evaluate as the author intended.
29+
// If they have written rules to leave paths public, then having mcp
30+
// grab their data is perfectly valid.
31+
requiresAuth: false,
32+
requiresProject: false,
33+
},
34+
},
35+
async ({ path, databaseUrl }, { projectId, host }) => {
36+
if (!path.startsWith("/")) {
37+
return mcpError(`paths must start with '/' (you passed ''${path}')`);
38+
}
39+
40+
const dbUrl = new url.URL(
41+
databaseUrl
42+
? `${databaseUrl}/${path}.json`
43+
: `https://${projectId}-default-rtdb.us-central1.firebasedatabase.app/${path}.json`,
44+
);
45+
46+
const client = new Client({
47+
urlPrefix: dbUrl.origin,
48+
auth: true,
49+
});
50+
51+
host.logger.debug(`sending read request to path '${path}' for url '${dbUrl.toString()}'`);
52+
53+
const res = await client.request<unknown, NodeJS.ReadableStream>({
54+
method: "GET",
55+
path: dbUrl.pathname,
56+
responseType: "stream",
57+
resolveOnHTTPError: true,
58+
});
59+
60+
const content = await text(res.body);
61+
return toContent(content);
62+
},
63+
);

src/mcp/tools/database/get_rules.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { z } from "zod";
2+
import { Client } from "../../../apiv2";
3+
import { tool } from "../../tool";
4+
import { mcpError, toContent } from "../../util";
5+
6+
export const get_rules = tool(
7+
{
8+
name: "get_rules",
9+
description: "Get an RTDB database's rules",
10+
inputSchema: z.object({
11+
databaseUrl: z
12+
.string()
13+
.optional()
14+
.describe(
15+
"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>)",
16+
),
17+
}),
18+
annotations: {
19+
title: "Get Realtime Database rules",
20+
readOnlyHint: true,
21+
},
22+
23+
_meta: {
24+
requiresAuth: false,
25+
requiresProject: false,
26+
},
27+
},
28+
async ({ databaseUrl }, { projectId }) => {
29+
const dbUrl =
30+
databaseUrl ?? `https://${projectId}-default-rtdb.us-central1.firebasedatabase.app`;
31+
32+
const client = new Client({ urlPrefix: dbUrl });
33+
const response = await client.request<void, NodeJS.ReadableStream>({
34+
method: "GET",
35+
path: "/.settings/rules.json",
36+
responseType: "stream",
37+
resolveOnHTTPError: true,
38+
});
39+
if (response.status !== 200) {
40+
return mcpError(`Failed to fetch current rules. Code: ${response.status}`);
41+
}
42+
43+
const rules = await response.response.text();
44+
return toContent(rules);
45+
},
46+
);

src/mcp/tools/database/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import type { ServerTool } from "../../tool";
2+
import { get_rules } from "./get_rules";
3+
import { get_data } from "./get_data";
4+
import { set_data } from "./set_data";
5+
import { validate_rules } from "./validate_rules";
6+
7+
export const realtimeDatabaseTools: ServerTool[] = [get_data, set_data, get_rules, validate_rules];

src/mcp/tools/database/set_data.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { z } from "zod";
2+
import { tool } from "../../tool";
3+
import { mcpError, toContent } from "../../util";
4+
import * as url from "node:url";
5+
import { stringToStream } from "../../../utils";
6+
import { Client } from "../../../apiv2";
7+
import { getErrMsg } from "../../../error";
8+
9+
export const set_data = tool(
10+
{
11+
name: "set_data",
12+
description: "Writes RTDB data to the specified location",
13+
inputSchema: z.object({
14+
databaseUrl: z
15+
.string()
16+
.optional()
17+
.describe(
18+
"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>)",
19+
),
20+
path: z.string().describe("The path to the data to read. (ex: /my/cool/path)"),
21+
data: z.string().describe('The JSON to write. (ex: {"alphabet": ["a", "b", "c"]})'),
22+
}),
23+
annotations: {
24+
title: "Set Realtime Database data",
25+
readOnlyHint: false,
26+
idempotentHint: true,
27+
},
28+
29+
_meta: {
30+
requiresAuth: false,
31+
requiresProject: false,
32+
},
33+
},
34+
async ({ path, databaseUrl, data }, { projectId, host }) => {
35+
if (!path.startsWith("/")) {
36+
return mcpError(`paths must start with '/' (you passed ''${path}')`);
37+
}
38+
39+
const dbUrl = new url.URL(
40+
databaseUrl
41+
? `${databaseUrl}/${path}.json`
42+
: `https://${projectId}-default-rtdb.us-central1.firebasedatabase.app/${path}.json`,
43+
);
44+
45+
const client = new Client({
46+
urlPrefix: dbUrl.origin,
47+
auth: true,
48+
});
49+
50+
const inStream = stringToStream(data);
51+
52+
host.logger.debug(`sending write request to path '${path}' for url '${dbUrl.toString()}'`);
53+
54+
try {
55+
await client.request({
56+
method: "PUT",
57+
path: dbUrl.pathname,
58+
body: inStream,
59+
});
60+
} catch (err: unknown) {
61+
host.logger.debug(getErrMsg(err));
62+
return mcpError(`Unexpected error while setting data: ${getErrMsg(err)}`);
63+
}
64+
65+
return toContent("write successful!");
66+
},
67+
);

src/mcp/tools/database/set_rules.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { z } from "zod";
2+
import { Client } from "../../../apiv2";
3+
import { tool } from "../../tool";
4+
import { mcpError, toContent } from "../../util";
5+
import { updateRulesWithClient } from "../../../rtdb";
6+
import { getErrMsg } from "../../../error";
7+
8+
export const validate_rules = tool(
9+
{
10+
name: "validate_rules",
11+
description: "Validates an RTDB database's rules",
12+
inputSchema: z.object({
13+
databaseUrl: z
14+
.string()
15+
.optional()
16+
.describe(
17+
"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>)",
18+
),
19+
rules: z
20+
.string()
21+
.describe('The rules object, as a string (ex: {".read": false, ".write": false})'),
22+
}),
23+
annotations: {
24+
title: "Validate Realtime Database rules",
25+
idempotentHint: true,
26+
},
27+
28+
_meta: {
29+
requiresAuth: true,
30+
requiresProject: false,
31+
},
32+
},
33+
async ({ databaseUrl, rules }, { projectId, host }) => {
34+
const dbUrl =
35+
databaseUrl ?? `https://${projectId}-default-rtdb.us-central1.firebasedatabase.app`;
36+
37+
const client = new Client({ urlPrefix: dbUrl });
38+
39+
try {
40+
await updateRulesWithClient(client, rules, { dryRun: true });
41+
} catch (e: unknown) {
42+
host.logger.debug(`failed to update rules at url ${dbUrl}`);
43+
return mcpError(getErrMsg(e));
44+
}
45+
46+
return toContent("the inputted rules are valid!");
47+
},
48+
);
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { z } from "zod";
2+
import { Client } from "../../../apiv2";
3+
import { tool } from "../../tool";
4+
import { mcpError, toContent } from "../../util";
5+
import { updateRulesWithClient } from "../../../rtdb";
6+
import { getErrMsg } from "../../../error";
7+
8+
export const validate_rules = tool(
9+
{
10+
name: "validate_rules",
11+
description: "Validates an RTDB database's rules",
12+
inputSchema: z.object({
13+
databaseUrl: z
14+
.string()
15+
.optional()
16+
.describe(
17+
"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>)",
18+
),
19+
rules: z
20+
.string()
21+
.describe(
22+
'The rules object, as a string (ex: {"rules": {".read": false, ".write": false}})',
23+
),
24+
}),
25+
annotations: {
26+
title: "Validate Realtime Database rules",
27+
idempotentHint: true,
28+
},
29+
30+
_meta: {
31+
requiresAuth: true,
32+
requiresProject: false,
33+
},
34+
},
35+
async ({ databaseUrl, rules }, { projectId, host }) => {
36+
const dbUrl =
37+
databaseUrl ?? `https://${projectId}-default-rtdb.us-central1.firebasedatabase.app`;
38+
39+
const client = new Client({ urlPrefix: dbUrl });
40+
41+
try {
42+
await updateRulesWithClient(client, rules, { dryRun: true });
43+
} catch (e: unknown) {
44+
host.logger.debug(`failed to update rules at url ${dbUrl}`);
45+
return mcpError(getErrMsg(e));
46+
}
47+
48+
return toContent("the inputted rules are valid!");
49+
},
50+
);

src/mcp/tools/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { messagingTools } from "./messaging/index.js";
99
import { remoteConfigTools } from "./remoteconfig/index.js";
1010
import { crashlyticsTools } from "./crashlytics/index.js";
1111
import { appHostingTools } from "./apphosting/index.js";
12+
import { realtimeDatabaseTools } from "./database/index.js";
1213

1314
/** availableTools returns the list of MCP tools available given the server flags */
1415
export function availableTools(activeFeatures?: ServerFeature[]): ServerTool[] {
@@ -32,6 +33,7 @@ const tools: Record<ServerFeature, ServerTool[]> = {
3233
remoteconfig: addFeaturePrefix("remoteconfig", remoteConfigTools),
3334
crashlytics: addFeaturePrefix("crashlytics", crashlyticsTools),
3435
apphosting: addFeaturePrefix("apphosting", appHostingTools),
36+
database: addFeaturePrefix("database", realtimeDatabaseTools),
3537
};
3638

3739
function addFeaturePrefix(feature: string, tools: ServerTool[]): ServerTool[] {

src/mcp/tools/rules/get_rules.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ export function getRulesTool(productName: string, releaseName: string) {
2121
async (_, { projectId }) => {
2222
const rulesetName = await getLatestRulesetName(projectId, releaseName);
2323
if (!rulesetName)
24-
return mcpError(`No active Firestore rules were found in project '${projectId}'`);
24+
return mcpError(`No active ${productName} rules were found in project '${projectId}'`);
2525
const rules = await getRulesetContent(rulesetName);
2626
return toContent(rules[0].content);
2727
},

src/mcp/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export const SERVER_FEATURES = [
77
"remoteconfig",
88
"crashlytics",
99
"apphosting",
10+
"database",
1011
] as const;
1112
export type ServerFeature = (typeof SERVER_FEATURES)[number];
1213

src/mcp/util.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
remoteConfigApiOrigin,
1313
storageOrigin,
1414
crashlyticsApiOrigin,
15+
realtimeOrigin,
1516
} from "../api";
1617
import { check } from "../ensureApiEnabled";
1718

@@ -58,6 +59,10 @@ export function mcpError(message: Error | string | unknown, code?: string): Call
5859
};
5960
}
6061

62+
/*
63+
* Wraps a throwing function with a safe conversion to mcpError.
64+
*/
65+
6166
/**
6267
* Checks if a command exists in the system.
6368
*/
@@ -89,6 +94,7 @@ const SERVER_FEATURE_APIS: Record<ServerFeature, string> = {
8994
remoteconfig: remoteConfigApiOrigin(),
9095
crashlytics: crashlyticsApiOrigin(),
9196
apphosting: apphostingOrigin(),
97+
database: realtimeOrigin(),
9298
};
9399

94100
/**

0 commit comments

Comments
 (0)