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
8 changes: 7 additions & 1 deletion src/appUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,8 +257,8 @@
export function extractAppIdentifierIos(fileContent: string): AppIdentifier[] {
const appIdRegex = /<key>GOOGLE_APP_ID<\/key>\s*<string>([^<]*)<\/string>/;
const bundleIdRegex = /<key>BUNDLE_ID<\/key>\s*<string>([^<]*)<\/string>/;
const appIdMatch = fileContent.match(appIdRegex);

Check warning on line 260 in src/appUtils.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Use the `RegExp#exec()` method instead
const bundleIdMatch = fileContent.match(bundleIdRegex);

Check warning on line 261 in src/appUtils.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Use the `RegExp#exec()` method instead
if (appIdMatch?.[1]) {
return [
{
Expand All @@ -278,13 +278,13 @@
export function extractAppIdentifiersAndroid(fileContent: string): AppIdentifier[] {
const identifiers: AppIdentifier[] = [];
try {
const config = JSON.parse(fileContent);

Check warning on line 281 in src/appUtils.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe assignment of an `any` value
if (config.client && Array.isArray(config.client)) {

Check warning on line 282 in src/appUtils.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe member access .client on an `any` value

Check warning on line 282 in src/appUtils.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe member access .client on an `any` value
for (const client of config.client) {

Check warning on line 283 in src/appUtils.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe member access .client on an `any` value
if (client.client_info?.mobilesdk_app_id) {

Check warning on line 284 in src/appUtils.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe member access .client_info on an `any` value
identifiers.push({
appId: client.client_info.mobilesdk_app_id,

Check warning on line 286 in src/appUtils.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe member access .client_info on an `any` value

Check warning on line 286 in src/appUtils.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe assignment of an `any` value
bundleId: client.client_info.android_client_info?.package_name,

Check warning on line 287 in src/appUtils.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe assignment of an `any` value
});
}
}
Expand All @@ -296,7 +296,13 @@
return identifiers;
}

async function detectFiles(dirPath: string, filePattern: string): Promise<string[]> {
/**
* Detects files matching a pattern within a directory, ignoring common dependency and build folders.
* @param dirPath The directory to search in.
* @param filePattern The glob pattern for the files to detect (e.g., "*.json").
* @return A promise that resolves to an array of file paths relative to `dirPath`.
*/
export async function detectFiles(dirPath: string, filePattern: string): Promise<string[]> {
const options = {
cwd: dirPath,
ignore: [
Expand Down
41 changes: 25 additions & 16 deletions src/mcp/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,24 +238,32 @@ export class FirebaseMcpServer {
return `http://${host}:${emulatorInfo.port}`;
}

get availableTools(): ServerTool[] {
return availableTools(
this.activeFeatures?.length ? this.activeFeatures : this.detectedFeatures,
);
async getAvailableTools(): Promise<ServerTool[]> {
const features = this.activeFeatures?.length ? this.activeFeatures : this.detectedFeatures;
// We need a project ID and user for the context, but it's ok if they're empty.
const projectId = (await this.getProjectId()) || "";
const accountEmail = await this.getAuthenticatedUser();
const ctx = this._createMcpContext(projectId, accountEmail);
return availableTools(ctx, features);
}

getTool(name: string): ServerTool | null {
return this.availableTools.find((t) => t.mcp.name === name) || null;
async getTool(name: string): Promise<ServerTool | null> {
const tools = await this.getAvailableTools();
return tools.find((t) => t.mcp.name === name) || null;
}

get availablePrompts(): ServerPrompt[] {
return availablePrompts(
this.activeFeatures?.length ? this.activeFeatures : this.detectedFeatures,
);
async getAvailablePrompts(): Promise<ServerPrompt[]> {
const features = this.activeFeatures?.length ? this.activeFeatures : this.detectedFeatures;
// We need a project ID and user for the context, but it's ok if they're empty.
const projectId = (await this.getProjectId()) || "";
const accountEmail = await this.getAuthenticatedUser();
const ctx = this._createMcpContext(projectId, accountEmail);
return availablePrompts(ctx, features);
}

getPrompt(name: string): ServerPrompt | null {
return this.availablePrompts.find((p) => p.mcp.name === name) || null;
async getPrompt(name: string): Promise<ServerPrompt | null> {
const prompts = await this.getAvailablePrompts();
return prompts.find((p) => p.mcp.name === name) || null;
}

setProjectRoot(newRoot: string | null): void {
Expand Down Expand Up @@ -314,8 +322,9 @@ export class FirebaseMcpServer {
await this.trackGA4("mcp_list_tools");
const skipAutoAuthForStudio = isFirebaseStudio();
this.log("debug", `skip auto-auth in studio environment: ${skipAutoAuthForStudio}`);
const availableTools = await this.getAvailableTools();
return {
tools: this.availableTools.map((t) => t.mcp),
tools: availableTools.map((t) => t.mcp),
_meta: {
projectRoot: this.cachedProjectDir,
projectDetected: hasActiveProject,
Expand All @@ -330,7 +339,7 @@ export class FirebaseMcpServer {
await this.detectProjectRoot();
const toolName = request.params.name;
const toolArgs = request.params.arguments;
const tool = this.getTool(toolName);
const tool = await this.getTool(toolName);
if (!tool) throw new Error(`Tool '${toolName}' could not be found.`);

// Check if the current project directory exists.
Expand Down Expand Up @@ -383,7 +392,7 @@ export class FirebaseMcpServer {
await this.trackGA4("mcp_list_prompts");
const skipAutoAuthForStudio = isFirebaseStudio();
return {
prompts: this.availablePrompts.map((p) => ({
prompts: (await this.getAvailablePrompts()).map((p) => ({
name: p.mcp.name,
description: p.mcp.description,
annotations: p.mcp.annotations,
Expand All @@ -403,7 +412,7 @@ export class FirebaseMcpServer {
await this.detectProjectRoot();
const promptName = req.params.name;
const promptArgs = req.params.arguments || {};
const prompt = this.getPrompt(promptName);
const prompt = await this.getPrompt(promptName);
if (!prompt) {
throw new Error(`Prompt '${promptName}' could not be found.`);
}
Expand Down
79 changes: 79 additions & 0 deletions src/mcp/prompt.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { expect } from "chai";
import * as sinon from "sinon";
import { prompt } from "./prompt";
import * as availability from "./util/availability";
import { McpContext } from "./types";

describe("prompt", () => {
let sandbox: sinon.SinonSandbox;
let getDefaultFeatureAvailabilityCheckStub: sinon.SinonStub;

// A mock context object for calling isAvailable functions.
const mockContext = {} as McpContext;

beforeEach(() => {
sandbox = sinon.createSandbox();
// Stub the function that provides the default availability checks.
getDefaultFeatureAvailabilityCheckStub = sandbox.stub(
availability,
"getDefaultFeatureAvailabilityCheck",
);
});

afterEach(() => {
sandbox.restore();
});

it("should create a prompt with the correct shape and properties", () => {
const testFn = async () => [];
const testPrompt = prompt(
"core",
{
name: "test_prompt",
description: "A test prompt",
},
testFn,
);

expect(testPrompt.mcp.name).to.equal("test_prompt");
expect(testPrompt.mcp.description).to.equal("A test prompt");
expect(testPrompt.fn).to.equal(testFn);
});

it("should use the default availability check for the feature if none is provided", () => {
// Arrange: Prepare a fake default check function to be returned by our stub.
const fakeDefaultCheck = async () => true;
getDefaultFeatureAvailabilityCheckStub.withArgs("core").returns(fakeDefaultCheck);

// Act: Create a prompt WITHOUT providing an isAvailable function.
const testPrompt = prompt("core", { name: "test_prompt" }, async () => []);

// Assert: The prompt's isAvailable function should be the one our stub provided.
expect(testPrompt.isAvailable).to.equal(fakeDefaultCheck);

// Assert: The factory function should have called the stub to get the default.
expect(getDefaultFeatureAvailabilityCheckStub.calledOnceWith("core")).to.be.true;
});

it("should override the default and use the provided availability check", async () => {
const fakeDefaultCheck = async () => true;
const overrideCheck = async () => false;
getDefaultFeatureAvailabilityCheckStub.withArgs("core").returns(fakeDefaultCheck);

const testPrompt = prompt(
"core",
{
name: "test_prompt",
},
async () => [],
overrideCheck,
);

expect(testPrompt.isAvailable).to.equal(overrideCheck);

const isAvailable = await testPrompt.isAvailable(mockContext);
expect(isAvailable).to.be.false;

expect(getDefaultFeatureAvailabilityCheckStub.notCalled).to.be.true;
});
});
15 changes: 13 additions & 2 deletions src/mcp/prompt.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { PromptMessage } from "@modelcontextprotocol/sdk/types.js";
import { McpContext } from "./types";
import { McpContext, ServerFeature } from "./types";
import { getDefaultFeatureAvailabilityCheck } from "./util/availability";

export interface ServerPrompt {
mcp: {
Expand All @@ -16,11 +17,21 @@ export interface ServerPrompt {
};
};
fn: (args: Record<string, string>, ctx: McpContext) => Promise<PromptMessage[]>;
isAvailable: (ctx: McpContext) => Promise<boolean>;
}

export function prompt(options: ServerPrompt["mcp"], fn: ServerPrompt["fn"]): ServerPrompt {
export function prompt(
feature: ServerFeature,
options: ServerPrompt["mcp"],
fn: ServerPrompt["fn"],
isAvailable?: (ctx: McpContext) => Promise<boolean>,
): ServerPrompt {
// default to the feature level availability check, but allow override
const isAvailableFunc = isAvailable || getDefaultFeatureAvailabilityCheck(feature);

return {
mcp: options,
fn,
isAvailable: isAvailableFunc,
};
}
1 change: 1 addition & 0 deletions src/mcp/prompts/core/consult.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { requireGeminiToS } from "../../errors";
import { prompt } from "../../prompt";

export const consult = prompt(
"core",
{
name: "consult",
description:
Expand Down
1 change: 1 addition & 0 deletions src/mcp/prompts/core/deploy.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { prompt } from "../../prompt";

export const deploy = prompt(
"core",
{
name: "deploy",
description: "Use this command to deploy resources to Firebase.",
Expand Down
1 change: 1 addition & 0 deletions src/mcp/prompts/core/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { getPlatformsFromFolder } from "../../../appUtils";
import { prompt } from "../../prompt";

export const init = prompt(
"core",
{
name: "init",
description: "Use this command to set up Firebase services, like backend and AI features.",
Expand Down
1 change: 1 addition & 0 deletions src/mcp/prompts/crashlytics/connect.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { prompt } from "../../prompt";

export const connect = prompt(
"crashlytics",
{
name: "connect",
omitPrefix: false,
Expand Down
1 change: 1 addition & 0 deletions src/mcp/prompts/dataconnect/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ function renderErrors(errors?: string) {
}

export const schema = prompt(
"core",
{
name: "schema",
description: "Generate or update your Firebase Data Connect schema.",
Expand Down
36 changes: 26 additions & 10 deletions src/mcp/prompts/index.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
import { ServerFeature } from "../types";
import { McpContext, ServerFeature } from "../types";
import { ServerPrompt } from "../prompt";
import { corePrompts } from "./core";
import { dataconnectPrompts } from "./dataconnect";
import { crashlyticsPrompts } from "./crashlytics";

const prompts: Record<ServerFeature, ServerPrompt[]> = {
core: corePrompts,
core: namespacePrompts(corePrompts, "core"),
firestore: [],
storage: [],
dataconnect: dataconnectPrompts,
dataconnect: namespacePrompts(dataconnectPrompts, "dataconnect"),
auth: [],
messaging: [],
functions: [],
remoteconfig: [],
crashlytics: crashlyticsPrompts,
crashlytics: namespacePrompts(crashlyticsPrompts, "crashlytics"),
apphosting: [],
database: [],
};
Expand All @@ -40,27 +40,43 @@ function namespacePrompts(
/**
* Return available prompts based on the list of registered features.
*/
export function availablePrompts(activeFeatures?: ServerFeature[]): ServerPrompt[] {
const allPrompts: ServerPrompt[] = [];
export async function availablePrompts(
ctx: McpContext,
activeFeatures?: ServerFeature[],
): Promise<ServerPrompt[]> {
const allPrompts = getAllPrompts(activeFeatures);

const availabilities = await Promise.all(
allPrompts.map((p) => {
if (p.isAvailable) {
return p.isAvailable(ctx);
}
return true;
}),
);
return allPrompts.filter((_, i) => availabilities[i]);
}

function getAllPrompts(activeFeatures?: ServerFeature[]): ServerPrompt[] {
const promptDefs: ServerPrompt[] = [];
if (!activeFeatures?.length) {
activeFeatures = Object.keys(prompts) as ServerFeature[];
}
if (!activeFeatures.includes("core")) {
activeFeatures = ["core", ...activeFeatures];
activeFeatures.unshift("core");
}
for (const feature of activeFeatures) {
allPrompts.push(...namespacePrompts(prompts[feature], feature));
promptDefs.push(...prompts[feature]);
}
return allPrompts;
return promptDefs;
}

/**
* Generates a markdown table of all available prompts and their descriptions.
* This is used for generating documentation.
*/
export function markdownDocsOfPrompts(): string {
const allPrompts = availablePrompts();
const allPrompts = getAllPrompts();
let doc = `
| Prompt Name | Feature Group | Description |
| ----------- | ------------- | ----------- |`;
Expand Down
Loading
Loading