Skip to content

Commit cb2a8c1

Browse files
author
oleina
committed
merge w master
2 parents 9d86bf9 + b265398 commit cb2a8c1

File tree

12 files changed

+323
-5
lines changed

12 files changed

+323
-5
lines changed

src/mcp/tool.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,19 @@ export interface ServerTool<InputSchema extends ZodTypeAny = ZodTypeAny> {
2121
inputSchema: any;
2222
annotations?: {
2323
title?: string;
24+
25+
// If this tool modifies data or not.
2426
readOnlyHint?: boolean;
27+
28+
// this tool can destroy data.
2529
destructiveHint?: boolean;
30+
31+
// this tool is safe to run multiple times.
2632
idempotentHint?: boolean;
33+
34+
// If this is true, it connects to the internet or other open world
35+
// systems. If false, the tool only performs actions in an enclosed
36+
// system, such as your project.
2737
openWorldHint?: boolean;
2838
};
2939
_meta?: {

src/mcp/tools/database/get_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 { Client } from "../../../apiv2";
6+
import { text } from "node:stream/consumers";
7+
import path from "node:path";
8+
9+
export const get_data = tool(
10+
{
11+
name: "get_data",
12+
description: "Returns RTDB data from 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.firebasedatabase.app. 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+
}),
22+
annotations: {
23+
title: "Get Realtime Database data",
24+
readOnlyHint: true,
25+
},
26+
27+
_meta: {
28+
// it's possible that a user attempts to query a database that they aren't
29+
// authed into: we should let the rules evaluate as the author intended.
30+
// If they have written rules to leave paths public, then having mcp
31+
// grab their data is perfectly valid.
32+
requiresAuth: false,
33+
requiresProject: false,
34+
},
35+
},
36+
async ({ path: getPath, databaseUrl }, { projectId, host }) => {
37+
if (!getPath.startsWith("/")) {
38+
return mcpError(`paths must start with '/' (you passed ''${getPath}')`);
39+
}
40+
41+
const dbUrl = new url.URL(
42+
databaseUrl
43+
? `${databaseUrl}/${getPath}.json`
44+
: path.join(
45+
`https://${projectId}-default-rtdb.us-central1.firebasedatabase.app`,
46+
`${getPath}.json`,
47+
),
48+
);
49+
50+
const client = new Client({
51+
urlPrefix: dbUrl.origin,
52+
auth: true,
53+
});
54+
55+
host.logger.debug(`sending read request to path '${getPath}' for url '${dbUrl.toString()}'`);
56+
57+
const res = await client.request<unknown, NodeJS.ReadableStream>({
58+
method: "GET",
59+
path: dbUrl.pathname,
60+
responseType: "stream",
61+
resolveOnHTTPError: true,
62+
});
63+
64+
const content = await text(res.body);
65+
return toContent(content);
66+
},
67+
);

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

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";
99
import { remoteConfigTools } from "./remoteconfig/index";
1010
import { crashlyticsTools } from "./crashlytics/index";
1111
import { appHostingTools } from "./apphosting/index";
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

0 commit comments

Comments
 (0)