Skip to content

Commit 820cb41

Browse files
Refactor: Combine Rules Validation and Get Rules MCP Tools (#9157)
* refactor: combine firestore, storage, and database validate rules tools Combines the firestore_validate_rules, storage_validate_rules, and database_validate_rules MCP tools into a single validate_rules MCP tool in the core feature group. This new tool takes an additional required argument 'type', which can be either 'firestore', 'rtdb', or 'storage', and then triggers the appropriate behavior based on that. * Format * Remove unneeded comment * PR fixes and RTDB cleanup * feat(mcp): combine get_rules tools (#9158) * feat(mcp): combine get_rules tools into a single core_get_rules tool Combines the firestore_get_rules, storage_get_rules, and database_get_rules tools into a single get_rules tool in the core tool group. This new tool takes an additional required argument 'type', which can be either 'firestore', 'rtdb', or 'storage', and then triggers the appropriate behavior based on that. It also adds a validation check to ensure that the `databaseUrl` argument is only provided when the type is 'rtdb'. * one last file * PR fixes * GCA suggestions * Formats --------- Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com> Co-authored-by: Joe Hanley <[email protected]> --------- Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com> Co-authored-by: Joe Hanley <[email protected]>
1 parent 36b1a2c commit 820cb41

File tree

16 files changed

+212
-318
lines changed

16 files changed

+212
-318
lines changed

src/commands/database-instances-create.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ export const command = new Command("database:instances:create <instanceName>")
2525
// eslint-disable-next-line @typescript-eslint/no-explicit-any
2626
.action(async (instanceName: string, options: any) => {
2727
const projectId = needProjectId(options);
28-
const defaultDatabaseInstance = await getDefaultDatabaseInstance({ project: projectId });
28+
const defaultDatabaseInstance = await getDefaultDatabaseInstance(projectId);
2929
if (defaultDatabaseInstance === "") {
3030
throw new FirebaseError(MISSING_DEFAULT_INSTANCE_ERROR_MESSAGE);
3131
}

src/emulator/controller.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -754,7 +754,7 @@ export async function startAll(
754754
// can't because the user may be using a fake project.
755755
try {
756756
if (!options.instance) {
757-
options.instance = await getDefaultDatabaseInstance(options);
757+
options.instance = await getDefaultDatabaseInstance(projectId);
758758
}
759759
} catch (e: any) {
760760
databaseLogger.log(

src/getDefaultDatabaseInstance.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { getFirebaseProject } from "./management/projects";
55
* @param options The command-line options object
66
* @return The instance ID, empty if it doesn't exist.
77
*/
8-
export async function getDefaultDatabaseInstance(options: any): Promise<string> {
9-
const projectDetails = await getFirebaseProject(options.project);
8+
export async function getDefaultDatabaseInstance(project: string): Promise<string> {
9+
const projectDetails = await getFirebaseProject(project);
1010
return projectDetails.resources?.realtimeDatabaseInstance || "";
1111
}

src/init/features/database.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ async function initializeDatabaseInstance(projectId: string): Promise<DatabaseIn
110110
await ensure(projectId, rtdbManagementOrigin(), "database", false);
111111
logger.info();
112112

113-
const instance = await getDefaultDatabaseInstance({ project: projectId });
113+
const instance = await getDefaultDatabaseInstance(projectId);
114114
if (instance !== "") {
115115
return await getDatabaseInstanceDetails(projectId, instance);
116116
}

src/mcp/tools/core/get_rules.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { z } from "zod";
2+
import { Client } from "../../../apiv2";
3+
import { tool } from "../../tool";
4+
import { mcpError, toContent } from "../../util";
5+
import { getLatestRulesetName, getRulesetContent } from "../../../gcp/rules";
6+
import { getDefaultDatabaseInstance } from "../../../getDefaultDatabaseInstance";
7+
8+
export const get_rules = tool(
9+
{
10+
name: "get_rules",
11+
description: "Retrieves the security rules for a specified Firebase service.",
12+
inputSchema: z.object({
13+
type: z.enum(["firestore", "rtdb", "storage"]).describe("The service to get rules for."),
14+
// TODO: Add a resourceID argument that lets you choose non default buckets/dbs.
15+
}),
16+
annotations: {
17+
title: "Get Firebase Rules",
18+
readOnlyHint: true,
19+
},
20+
_meta: {
21+
requiresProject: true,
22+
requiresAuth: true,
23+
},
24+
},
25+
async ({ type }, { projectId }) => {
26+
if (type === "rtdb") {
27+
const dbUrl = await getDefaultDatabaseInstance(projectId);
28+
if (dbUrl === "") {
29+
return mcpError(`No default RTDB instance found for project ${projectId}`);
30+
}
31+
const client = new Client({ urlPrefix: dbUrl });
32+
const response = await client.request<void, NodeJS.ReadableStream>({
33+
method: "GET",
34+
path: "/.settings/rules.json",
35+
responseType: "stream",
36+
resolveOnHTTPError: true,
37+
});
38+
if (response.status !== 200) {
39+
return mcpError(`Failed to fetch current rules. Code: ${response.status}`);
40+
}
41+
42+
const rules = await response.response.text();
43+
return toContent(rules);
44+
}
45+
46+
const serviceInfo = {
47+
firestore: { productName: "Firestore", releaseName: "cloud.firestore" },
48+
storage: { productName: "Storage", releaseName: "firebase.storage" },
49+
};
50+
const { productName, releaseName } = serviceInfo[type];
51+
52+
const rulesetName = await getLatestRulesetName(projectId, releaseName);
53+
if (!rulesetName)
54+
return mcpError(`No active ${productName} rules were found in project '${projectId}'`);
55+
const rules = await getRulesetContent(rulesetName);
56+
return toContent(rules?.[0].content ?? "Ruleset contains no rules files.");
57+
},
58+
);

src/mcp/tools/core/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,14 @@ import { update_environment } from "./update_environment";
1313
import { list_projects } from "./list_projects";
1414
import { login } from "./login";
1515
import { logout } from "./logout";
16+
import { get_rules } from "./get_rules";
17+
import { validate_rules } from "./validate_rules";
1618
import { read_resources } from "./read_resources";
1719

1820
export const coreTools: ServerTool[] = [
1921
login,
2022
logout,
23+
validate_rules, // TODO (joehan): Only enable this tool when at least once of rtdb/storage/firestore is active.
2124
get_project,
2225
list_apps,
2326
get_admin_sdk_config,
@@ -29,5 +32,6 @@ export const coreTools: ServerTool[] = [
2932
get_environment,
3033
update_environment,
3134
init,
35+
get_rules,
3236
read_resources,
3337
];
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import { z } from "zod";
2+
import { tool } from "../../tool";
3+
import { mcpError, toContent } from "../../util";
4+
import { testRuleset } from "../../../gcp/rules";
5+
import { resolve } from "path";
6+
import { Client } from "../../../apiv2";
7+
import { updateRulesWithClient } from "../../../rtdb";
8+
import { getErrMsg } from "../../../error";
9+
import { getDefaultDatabaseInstance } from "../../../getDefaultDatabaseInstance";
10+
11+
interface SourcePosition {
12+
fileName?: string;
13+
line?: number;
14+
column?: number;
15+
currentOffset?: number;
16+
endOffset?: number;
17+
}
18+
19+
interface Issue {
20+
sourcePosition: SourcePosition;
21+
description: string;
22+
severity: string;
23+
}
24+
25+
function formatRulesetIssues(issues: Issue[], rulesSource: string): string {
26+
const sourceLines = rulesSource.split("\n");
27+
const formattedOutput: string[] = [];
28+
29+
for (const issue of issues) {
30+
const { sourcePosition, description, severity } = issue;
31+
32+
let issueString = `${severity}: ${description} [Ln ${sourcePosition.line}, Col ${sourcePosition.column}]`;
33+
34+
if (sourcePosition.line) {
35+
const lineIndex = sourcePosition.line - 1;
36+
if (lineIndex >= 0 && lineIndex < sourceLines.length) {
37+
const errorLine = sourceLines[lineIndex];
38+
issueString += `\n\`\`\`\n${errorLine}`;
39+
40+
if (
41+
sourcePosition.column &&
42+
sourcePosition.currentOffset &&
43+
sourcePosition.endOffset &&
44+
sourcePosition.column > 0 &&
45+
sourcePosition.endOffset > sourcePosition.currentOffset
46+
) {
47+
const startColumnOnLine = sourcePosition.column - 1;
48+
const errorTokenLength = sourcePosition.endOffset - sourcePosition.currentOffset;
49+
50+
if (
51+
startColumnOnLine >= 0 &&
52+
errorTokenLength > 0 &&
53+
startColumnOnLine <= errorLine.length
54+
) {
55+
const padding = " ".repeat(startColumnOnLine);
56+
const carets = "^".repeat(errorTokenLength);
57+
issueString += `\n${padding}${carets}\n\`\`\``;
58+
}
59+
}
60+
}
61+
}
62+
formattedOutput.push(issueString);
63+
}
64+
return formattedOutput.join("\n\n");
65+
}
66+
67+
export const validate_rules = tool(
68+
{
69+
name: "validate_rules",
70+
description:
71+
"Use this to check Firebase Security Rules for Firestore, Storage, or Realtime Database for syntax and validation errors.",
72+
inputSchema: z.object({
73+
type: z.enum(["firestore", "storage", "rtdb"]),
74+
source: z
75+
.string()
76+
.optional()
77+
.describe("The rules source code to check. Provide either this or a path."),
78+
source_file: z
79+
.string()
80+
.optional()
81+
.describe(
82+
"A file path, relative to the project root, to a file containing the rules source you want to validate. Provide this or source, not both.",
83+
),
84+
}),
85+
annotations: {
86+
title: "Validate Firebase Security Rules",
87+
readOnlyHint: true,
88+
},
89+
_meta: {
90+
requiresProject: true,
91+
requiresAuth: true,
92+
},
93+
},
94+
async ({ type, source, source_file }, { projectId, config, host }) => {
95+
let rulesSourceContent: string;
96+
if (source && source_file) {
97+
return mcpError("Must supply `source` or `source_file`, not both.");
98+
} else if (source_file) {
99+
try {
100+
const filePath = resolve(source_file, host.cachedProjectDir!);
101+
if (filePath.includes("../"))
102+
return mcpError("Cannot read files outside of the project directory.");
103+
rulesSourceContent = config.readProjectFile(source_file);
104+
} catch (e: any) {
105+
return mcpError(`Failed to read source_file '${source_file}': ${e.message}`);
106+
}
107+
} else if (source) {
108+
rulesSourceContent = source;
109+
} else {
110+
return mcpError("Must supply at least one of `source` or `source_file`.");
111+
}
112+
113+
if (type === "rtdb") {
114+
const dbUrl = await getDefaultDatabaseInstance(projectId);
115+
const client = new Client({ urlPrefix: dbUrl });
116+
try {
117+
await updateRulesWithClient(client, source, { dryRun: true });
118+
} catch (e: unknown) {
119+
host.logger.debug(`failed to validate rules at url ${dbUrl}`);
120+
// TODO: This really should only return an MCP error if we couldn't validate
121+
// If the rules are invalid, we should return that as content
122+
return mcpError(getErrMsg(e));
123+
}
124+
return toContent("The inputted rules are valid!");
125+
}
126+
127+
// Firestore and Storage
128+
const result = await testRuleset(projectId, [
129+
{ name: "test.rules", content: rulesSourceContent },
130+
]);
131+
132+
if (result.body?.issues?.length) {
133+
const issues = result.body.issues as unknown as Issue[];
134+
let out = `Found ${issues.length} issues in rules source:\n\n`;
135+
out += formatRulesetIssues(issues, rulesSourceContent);
136+
return toContent(out);
137+
}
138+
139+
return toContent("OK: No errors detected.");
140+
},
141+
);

src/mcp/tools/database/get_rules.ts

Lines changed: 0 additions & 46 deletions
This file was deleted.

src/mcp/tools/database/index.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
import type { ServerTool } from "../../tool";
2-
import { get_rules } from "./get_rules";
32
import { get_data } from "./get_data";
43
import { set_data } from "./set_data";
5-
import { validate_rules } from "./validate_rules";
64

7-
export const realtimeDatabaseTools: ServerTool[] = [get_data, set_data, get_rules, validate_rules];
5+
export const realtimeDatabaseTools: ServerTool[] = [get_data, set_data];

src/mcp/tools/database/validate_rules.ts

Lines changed: 0 additions & 50 deletions
This file was deleted.

0 commit comments

Comments
 (0)