Skip to content
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
2 changes: 1 addition & 1 deletion src/commands/database-instances-create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,12 @@
.before(warnEmulatorNotSupported, Emulators.DATABASE)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.action(async (instanceName: string, options: any) => {
const projectId = needProjectId(options);

Check warning on line 27 in src/commands/database-instances-create.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe argument of type `any` assigned to a parameter of type `{ projectId?: string | undefined; project?: string | undefined; rc?: RC | undefined; }`
const defaultDatabaseInstance = await getDefaultDatabaseInstance({ project: projectId });
const defaultDatabaseInstance = await getDefaultDatabaseInstance(projectId);
if (defaultDatabaseInstance === "") {
throw new FirebaseError(MISSING_DEFAULT_INSTANCE_ERROR_MESSAGE);
}
const location = parseDatabaseLocation(options.location, DatabaseLocation.US_CENTRAL1);

Check warning on line 32 in src/commands/database-instances-create.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe member access .location on an `any` value

Check warning on line 32 in src/commands/database-instances-create.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe argument of type `any` assigned to a parameter of type `string`
const instance = await createInstance(
projectId,
instanceName,
Expand Down
2 changes: 1 addition & 1 deletion src/emulator/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@

/**
* Exports emulator data on clean exit (SIGINT or process end)
* @param options

Check warning on line 76 in src/emulator/controller.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Missing JSDoc @param "options" description
*/
export async function exportOnExit(options: Options): Promise<void> {
// Note: options.exportOnExit is coerced to a string before this point in commandUtils.ts#setExportOnExitOptions
Expand All @@ -86,7 +86,7 @@
);
await exportEmulatorData(exportOnExitDir, options, /* initiatedBy= */ "exit");
} catch (e: unknown) {
utils.logWarning(`${e}`);

Check warning on line 89 in src/emulator/controller.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Invalid type "unknown" of template literal expression
utils.logWarning(`Automatic export to "${exportOnExitDir}" failed, going to exit now...`);
}
}
Expand All @@ -94,10 +94,10 @@

/**
* Hook to do things when we're exiting cleanly (this does not include errors). Will be skipped on a second SIGINT
* @param options

Check warning on line 97 in src/emulator/controller.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Missing JSDoc @param "options" description
*/
export async function onExit(options: any) {

Check warning on line 99 in src/emulator/controller.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unexpected any. Specify a different type

Check warning on line 99 in src/emulator/controller.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Missing return type on function
await exportOnExit(options);

Check warning on line 100 in src/emulator/controller.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe argument of type `any` assigned to a parameter of type `Options`
}

/**
Expand All @@ -116,7 +116,7 @@

/**
* Filters a list of emulators to only those specified in the config
* @param options

Check warning on line 119 in src/emulator/controller.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Missing @param "options.only"
*/
export function filterEmulatorTargets(options: { only: string; config: any }): Emulators[] {
let targets = [...ALL_SERVICE_EMULATORS];
Expand Down Expand Up @@ -754,7 +754,7 @@
// can't because the user may be using a fake project.
try {
if (!options.instance) {
options.instance = await getDefaultDatabaseInstance(options);
options.instance = await getDefaultDatabaseInstance(projectId);
}
} catch (e: any) {
databaseLogger.log(
Expand Down
4 changes: 2 additions & 2 deletions src/getDefaultDatabaseInstance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { getFirebaseProject } from "./management/projects";
* @param options The command-line options object
* @return The instance ID, empty if it doesn't exist.
*/
export async function getDefaultDatabaseInstance(options: any): Promise<string> {
const projectDetails = await getFirebaseProject(options.project);
export async function getDefaultDatabaseInstance(project: string): Promise<string> {
const projectDetails = await getFirebaseProject(project);
return projectDetails.resources?.realtimeDatabaseInstance || "";
}
2 changes: 1 addition & 1 deletion src/init/features/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ async function initializeDatabaseInstance(projectId: string): Promise<DatabaseIn
await ensure(projectId, rtdbManagementOrigin(), "database", false);
logger.info();

const instance = await getDefaultDatabaseInstance({ project: projectId });
const instance = await getDefaultDatabaseInstance(projectId);
if (instance !== "") {
return await getDatabaseInstanceDetails(projectId, instance);
}
Expand Down
58 changes: 58 additions & 0 deletions src/mcp/tools/core/get_rules.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { z } from "zod";
import { Client } from "../../../apiv2";
import { tool } from "../../tool";
import { mcpError, toContent } from "../../util";
import { getLatestRulesetName, getRulesetContent } from "../../../gcp/rules";
import { getDefaultDatabaseInstance } from "../../../getDefaultDatabaseInstance";

export const get_rules = tool(
{
name: "get_rules",
description: "Retrieves the security rules for a specified Firebase service.",
inputSchema: z.object({
type: z.enum(["firestore", "rtdb", "storage"]).describe("The service to get rules for."),
// TODO: Add a resourceID argument that lets you choose non default buckets/dbs.
}),
annotations: {
title: "Get Firebase Rules",
readOnlyHint: true,
},
_meta: {
requiresProject: true,
requiresAuth: true,
},
},
async ({ type }, { projectId }) => {
if (type === "rtdb") {
const dbUrl = await getDefaultDatabaseInstance(projectId);
if (dbUrl === "") {
return mcpError(`No default RTDB instance found for project ${projectId}`);
}
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);
}

const serviceInfo = {
firestore: { productName: "Firestore", releaseName: "cloud.firestore" },
storage: { productName: "Storage", releaseName: "firebase.storage" },
};
const { productName, releaseName } = serviceInfo[type];

const rulesetName = await getLatestRulesetName(projectId, releaseName);
if (!rulesetName)
return mcpError(`No active ${productName} rules were found in project '${projectId}'`);
const rules = await getRulesetContent(rulesetName);
return toContent(rules?.[0].content ?? "Ruleset contains no rules files.");
},
);
4 changes: 4 additions & 0 deletions src/mcp/tools/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,14 @@ import { update_environment } from "./update_environment";
import { list_projects } from "./list_projects";
import { login } from "./login";
import { logout } from "./logout";
import { get_rules } from "./get_rules";
import { validate_rules } from "./validate_rules";
import { read_resources } from "./read_resources";

export const coreTools: ServerTool[] = [
login,
logout,
validate_rules, // TODO (joehan): Only enable this tool when at least once of rtdb/storage/firestore is active.
get_project,
list_apps,
get_admin_sdk_config,
Expand All @@ -29,5 +32,6 @@ export const coreTools: ServerTool[] = [
get_environment,
update_environment,
init,
get_rules,
read_resources,
];
141 changes: 141 additions & 0 deletions src/mcp/tools/core/validate_rules.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import { z } from "zod";
import { tool } from "../../tool";
import { mcpError, toContent } from "../../util";
import { testRuleset } from "../../../gcp/rules";
import { resolve } from "path";
import { Client } from "../../../apiv2";
import { updateRulesWithClient } from "../../../rtdb";
import { getErrMsg } from "../../../error";
import { getDefaultDatabaseInstance } from "../../../getDefaultDatabaseInstance";

interface SourcePosition {
fileName?: string;
line?: number;
column?: number;
currentOffset?: number;
endOffset?: number;
}

interface Issue {
sourcePosition: SourcePosition;
description: string;
severity: string;
}

function formatRulesetIssues(issues: Issue[], rulesSource: string): string {
const sourceLines = rulesSource.split("\n");
const formattedOutput: string[] = [];

for (const issue of issues) {
const { sourcePosition, description, severity } = issue;

let issueString = `${severity}: ${description} [Ln ${sourcePosition.line}, Col ${sourcePosition.column}]`;

if (sourcePosition.line) {
const lineIndex = sourcePosition.line - 1;
if (lineIndex >= 0 && lineIndex < sourceLines.length) {
const errorLine = sourceLines[lineIndex];
issueString += `\n\`\`\`\n${errorLine}`;

if (
sourcePosition.column &&
sourcePosition.currentOffset &&
sourcePosition.endOffset &&
sourcePosition.column > 0 &&
sourcePosition.endOffset > sourcePosition.currentOffset
) {
const startColumnOnLine = sourcePosition.column - 1;
const errorTokenLength = sourcePosition.endOffset - sourcePosition.currentOffset;

if (
startColumnOnLine >= 0 &&
errorTokenLength > 0 &&
startColumnOnLine <= errorLine.length
) {
const padding = " ".repeat(startColumnOnLine);
const carets = "^".repeat(errorTokenLength);
issueString += `\n${padding}${carets}\n\`\`\``;
}
}
}
}
formattedOutput.push(issueString);
}
return formattedOutput.join("\n\n");
}

export const validate_rules = tool(
{
name: "validate_rules",
description:
"Use this to check Firebase Security Rules for Firestore, Storage, or Realtime Database for syntax and validation errors.",
inputSchema: z.object({
type: z.enum(["firestore", "storage", "rtdb"]),
source: z
.string()
.optional()
.describe("The rules source code to check. Provide either this or a path."),
source_file: z
.string()
.optional()
.describe(
"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.",
),
}),
annotations: {
title: "Validate Firebase Security Rules",
readOnlyHint: true,
},
_meta: {
requiresProject: true,
requiresAuth: true,
},
},
async ({ type, source, source_file }, { projectId, config, host }) => {
let rulesSourceContent: string;
if (source && source_file) {
return mcpError("Must supply `source` or `source_file`, not both.");
} else if (source_file) {
try {
const filePath = resolve(source_file, host.cachedProjectDir!);
if (filePath.includes("../"))
return mcpError("Cannot read files outside of the project directory.");
rulesSourceContent = config.readProjectFile(source_file);
} catch (e: any) {
return mcpError(`Failed to read source_file '${source_file}': ${e.message}`);
}
} else if (source) {
rulesSourceContent = source;
} else {
return mcpError("Must supply at least one of `source` or `source_file`.");
}

if (type === "rtdb") {
const dbUrl = await getDefaultDatabaseInstance(projectId);
const client = new Client({ urlPrefix: dbUrl });
try {
await updateRulesWithClient(client, source, { dryRun: true });
} catch (e: unknown) {
host.logger.debug(`failed to validate rules at url ${dbUrl}`);
// TODO: This really should only return an MCP error if we couldn't validate
// If the rules are invalid, we should return that as content
return mcpError(getErrMsg(e));
}
return toContent("The inputted rules are valid!");
}

// Firestore and Storage
const result = await testRuleset(projectId, [
{ name: "test.rules", content: rulesSourceContent },
]);

if (result.body?.issues?.length) {
const issues = result.body.issues as unknown as Issue[];
let out = `Found ${issues.length} issues in rules source:\n\n`;
out += formatRulesetIssues(issues, rulesSourceContent);
return toContent(out);
}

return toContent("OK: No errors detected.");
},
);
46 changes: 0 additions & 46 deletions src/mcp/tools/database/get_rules.ts

This file was deleted.

4 changes: 1 addition & 3 deletions src/mcp/tools/database/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
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];
export const realtimeDatabaseTools: ServerTool[] = [get_data, set_data];
50 changes: 0 additions & 50 deletions src/mcp/tools/database/validate_rules.ts

This file was deleted.

11 changes: 1 addition & 10 deletions src/mcp/tools/firestore/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,5 @@ import { delete_document } from "./delete_document";
import { get_documents } from "./get_documents";
import { list_collections } from "./list_collections";
import { query_collection } from "./query_collection";
import { validateRulesTool } from "../rules/validate_rules";
import { getRulesTool } from "../rules/get_rules";

export const firestoreTools = [
delete_document,
get_documents,
list_collections,
query_collection,
getRulesTool("Firestore", "cloud.firestore"),
validateRulesTool("Firestore"),
];
export const firestoreTools = [delete_document, get_documents, list_collections, query_collection];
Loading
Loading