Skip to content

Commit 9a5bc4f

Browse files
authored
feat(mcp): Adds MCP resources and a read_resources tool. (#9149)
- Adds `/firebase:init` prompt with placeholder guidance. - Resources are defined in `src/mcp/resources`. - Prompts and other output can "link" to resources by saying to use the `read_resources` tool with a particular URI. - Consolidates context into a single McpContext type. - Some additional refactoring and cleanup.
1 parent 94f0a78 commit 9a5bc4f

22 files changed

+424
-75
lines changed

.eslintrc.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ module.exports = {
110110
rules: {},
111111
},
112112
{
113-
files: ["src/mcp/tools/**/*.ts", "src/mcp/prompts/**/*.ts"],
113+
files: ["src/mcp/tools/**/*.ts", "src/mcp/prompts/**/*.ts", "src/mcp/resources/**/*.ts"],
114114
rules: { camelcase: "off" },
115115
},
116116
],

src/config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,7 @@ export class Config {
212212
return outPath;
213213
}
214214

215-
readProjectFile(p: string, options: any = {}) {
215+
readProjectFile(p: string, options: { json?: boolean; fallback?: any } = {}) {
216216
options = options || {};
217217
try {
218218
const content = fs.readFileSync(this.path(p), "utf8");

src/mcp/index.ts

Lines changed: 63 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,20 @@ import {
1313
ListPromptsResult,
1414
GetPromptResult,
1515
GetPromptRequest,
16+
ListResourcesRequestSchema,
17+
ListResourcesResult,
18+
ReadResourceRequest,
19+
ReadResourceResult,
20+
McpError,
21+
ErrorCode,
22+
ReadResourceRequestSchema,
1623
} from "@modelcontextprotocol/sdk/types.js";
1724
import { checkFeatureActive, mcpError } from "./util";
18-
import { ClientConfig, SERVER_FEATURES, ServerFeature } from "./types";
25+
import { ClientConfig, McpContext, SERVER_FEATURES, ServerFeature } from "./types";
1926
import { availableTools } from "./tools/index";
20-
import { ServerTool, ServerToolContext } from "./tool";
27+
import { ServerTool } from "./tool";
2128
import { availablePrompts } from "./prompts/index";
22-
import { ServerPrompt, ServerPromptContext } from "./prompt";
29+
import { ServerPrompt } from "./prompt";
2330
import { configstore } from "../configstore";
2431
import { Command } from "../command";
2532
import { requireAuth } from "../requireAuth";
@@ -35,6 +42,7 @@ import { existsSync } from "node:fs";
3542
import { LoggingStdioServerTransport } from "./logging-transport";
3643
import { isFirebaseStudio } from "../env";
3744
import { timeoutFallback } from "../timeout";
45+
import { resources } from "./resources";
3846

3947
const SERVER_VERSION = "0.3.0";
4048

@@ -52,7 +60,7 @@ const orderedLogLevels = [
5260
] as const;
5361

5462
export class FirebaseMcpServer {
55-
private _ready: boolean = false;
63+
private _ready = false;
5664
private _readyPromises: { resolve: () => void; reject: (err: unknown) => void }[] = [];
5765
startupRoot?: string;
5866
cachedProjectDir?: string;
@@ -84,7 +92,7 @@ export class FirebaseMcpServer {
8492
mcp_client_name: this.clientInfo?.name || "<unknown-client>",
8593
mcp_client_version: this.clientInfo?.version || "<unknown-version>",
8694
};
87-
trackGA4(event, { ...params, ...clientInfoParams });
95+
return trackGA4(event, { ...params, ...clientInfoParams });
8896
}
8997

9098
constructor(options: { activeFeatures?: ServerFeature[]; projectRoot?: string }) {
@@ -95,18 +103,21 @@ export class FirebaseMcpServer {
95103
tools: { listChanged: true },
96104
logging: {},
97105
prompts: { listChanged: true },
106+
resources: {},
98107
});
99108

100109
this.server.setRequestHandler(ListToolsRequestSchema, this.mcpListTools.bind(this));
101110
this.server.setRequestHandler(CallToolRequestSchema, this.mcpCallTool.bind(this));
102111
this.server.setRequestHandler(ListPromptsRequestSchema, this.mcpListPrompts.bind(this));
103112
this.server.setRequestHandler(GetPromptRequestSchema, this.mcpGetPrompt.bind(this));
113+
this.server.setRequestHandler(ListResourcesRequestSchema, this.mcpListResources.bind(this));
114+
this.server.setRequestHandler(ReadResourceRequestSchema, this.mcpReadResource.bind(this));
104115

105-
this.server.oninitialized = async () => {
116+
const onInitialized = (): void => {
106117
const clientInfo = this.server.getClientVersion();
107118
this.clientInfo = clientInfo;
108119
if (clientInfo?.name) {
109-
this.trackGA4("mcp_client_connected");
120+
void this.trackGA4("mcp_client_connected");
110121
}
111122
if (!this.clientInfo?.name) this.clientInfo = { name: "<unknown-client>" };
112123

@@ -116,6 +127,10 @@ export class FirebaseMcpServer {
116127
}
117128
};
118129

130+
this.server.oninitialized = () => {
131+
void onInitialized();
132+
};
133+
119134
this.server.setRequestHandler(SetLevelRequestSchema, async ({ params }) => {
120135
this.currentLogLevel = params.level;
121136
return {};
@@ -261,6 +276,17 @@ export class FirebaseMcpServer {
261276
}
262277
}
263278

279+
private _createMcpContext(projectId: string, accountEmail: string | null): McpContext {
280+
const options = { projectDir: this.cachedProjectDir, cwd: this.cachedProjectDir };
281+
return {
282+
projectId: projectId,
283+
host: this,
284+
config: Config.load(options, true) || new Config({}, options),
285+
rc: loadRC(options),
286+
accountEmail,
287+
};
288+
}
289+
264290
async mcpListTools(): Promise<ListToolsResult> {
265291
await Promise.all([this.detectActiveFeatures(), this.detectProjectRoot()]);
266292
const hasActiveProject = !!(await this.getProjectId());
@@ -313,14 +339,7 @@ export class FirebaseMcpServer {
313339
if (err) return err;
314340
}
315341

316-
const options = { projectDir: this.cachedProjectDir, cwd: this.cachedProjectDir };
317-
const toolsCtx: ServerToolContext = {
318-
projectId: projectId,
319-
host: this,
320-
config: Config.load(options, true) || new Config({}, options),
321-
rc: loadRC(options),
322-
accountEmail,
323-
};
342+
const toolsCtx = this._createMcpContext(projectId, accountEmail);
324343
try {
325344
const res = await tool.fn(toolArgs, toolsCtx);
326345
await this.trackGA4("mcp_tool_call", {
@@ -374,14 +393,7 @@ export class FirebaseMcpServer {
374393
const skipAutoAuthForStudio = isFirebaseStudio();
375394
const accountEmail = await this.getAuthenticatedUser(skipAutoAuthForStudio);
376395

377-
const options = { projectDir: this.cachedProjectDir, cwd: this.cachedProjectDir };
378-
const promptsCtx: ServerPromptContext = {
379-
projectId: projectId,
380-
host: this,
381-
config: Config.load(options, true) || new Config({}, options),
382-
rc: loadRC(options),
383-
accountEmail,
384-
};
396+
const promptsCtx = this._createMcpContext(projectId, accountEmail);
385397

386398
try {
387399
const messages = await prompt.fn(promptArgs, promptsCtx);
@@ -401,14 +413,40 @@ export class FirebaseMcpServer {
401413
}
402414
}
403415

416+
async mcpListResources(): Promise<ListResourcesResult> {
417+
return {
418+
resources: resources.map((r) => r.mcp),
419+
};
420+
}
421+
422+
async mcpReadResource(req: ReadResourceRequest): Promise<ReadResourceResult> {
423+
const resource = resources.find((r) => r.mcp.uri === req.params.uri);
424+
425+
let projectId = await this.getProjectId();
426+
projectId = projectId || "";
427+
428+
const skipAutoAuthForStudio = isFirebaseStudio();
429+
const accountEmail = await this.getAuthenticatedUser(skipAutoAuthForStudio);
430+
431+
const resourceCtx = this._createMcpContext(projectId, accountEmail);
432+
433+
if (!resource) {
434+
throw new McpError(
435+
ErrorCode.InvalidParams,
436+
`Resource '${req.params.uri}' could not be found.`,
437+
);
438+
}
439+
return resource.fn(req.params.uri, resourceCtx);
440+
}
441+
404442
async start(): Promise<void> {
405443
const transport = process.env.FIREBASE_MCP_DEBUG_LOG
406444
? new LoggingStdioServerTransport(process.env.FIREBASE_MCP_DEBUG_LOG)
407445
: new StdioServerTransport();
408446
await this.server.connect(transport);
409447
}
410448

411-
private async log(level: LoggingLevel, message: unknown) {
449+
private log(level: LoggingLevel, message: unknown): void {
412450
let data = message;
413451

414452
// mcp protocol only takes jsons or it errors; for convienence, format
@@ -425,6 +463,6 @@ export class FirebaseMcpServer {
425463
return;
426464
}
427465

428-
if (this._ready) await this.server.sendLoggingMessage({ level, data });
466+
if (this._ready) void this.server.sendLoggingMessage({ level, data });
429467
}
430468
}

src/mcp/prompt.ts

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,5 @@
11
import { PromptMessage } from "@modelcontextprotocol/sdk/types.js";
2-
import type { FirebaseMcpServer } from "./index";
3-
import type { Config } from "../config";
4-
import { RC } from "../rc";
5-
6-
export interface ServerPromptContext {
7-
projectId: string;
8-
accountEmail: string | null;
9-
config: Config;
10-
host: FirebaseMcpServer;
11-
rc: RC;
12-
}
2+
import { McpContext } from "./types";
133

144
export interface ServerPrompt {
155
mcp: {
@@ -25,7 +15,7 @@ export interface ServerPrompt {
2515
feature?: string;
2616
};
2717
};
28-
fn: (args: Record<string, string>, ctx: ServerPromptContext) => Promise<PromptMessage[]>;
18+
fn: (args: Record<string, string>, ctx: McpContext) => Promise<PromptMessage[]>;
2919
}
3020

3121
export function prompt(options: ServerPrompt["mcp"], fn: ServerPrompt["fn"]): ServerPrompt {

src/mcp/prompts/core/index.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,10 @@
1+
import { init } from "./init";
12
import { deploy } from "./deploy";
3+
import { isEnabled } from "../../../experiments";
24

3-
export const corePrompts = [deploy];
5+
const corePrompts = [deploy];
6+
if (isEnabled("mcpalpha")) {
7+
corePrompts.push(init);
8+
}
9+
10+
export { corePrompts };

src/mcp/prompts/core/init.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { getPlatformFromFolder } from "../../../dataconnect/appFinder";
2+
import { Platform } from "../../../dataconnect/types";
3+
import { prompt } from "../../prompt";
4+
5+
export const init = prompt(
6+
{
7+
name: "init",
8+
description: "Use this command to setup Firebase for the current workspace.",
9+
arguments: [
10+
{
11+
name: "prompt",
12+
description: "any Firebase products you want to use or the problems you're trying to solve",
13+
required: false,
14+
},
15+
],
16+
annotations: {
17+
title: "Initialize Firebase",
18+
},
19+
},
20+
async ({ prompt }, { config, projectId, accountEmail }) => {
21+
const platform = await getPlatformFromFolder(config.projectDir);
22+
23+
return [
24+
{
25+
role: "user" as const,
26+
content: {
27+
type: "text",
28+
text: `
29+
Your goal is to help the user setup Firebase services in this workspace. Firebase is a large platform with many potential uses, so you will:
30+
31+
1. Detect which Firebase services are already in use in the workspace, if any
32+
2. Determine which new Firebase services will help the user build their app
33+
3. Provision and configure the services requested by the user
34+
35+
## Workspace Info
36+
37+
Use this information to determine which Firebase services the user is already using (if any).
38+
39+
Workspace platform: ${[Platform.NONE, Platform.MULTIPLE].includes(platform) ? "<UNABLE TO DETECT>" : platform}
40+
Active user: ${accountEmail || "<NONE>"}
41+
Active project: ${projectId || "<NONE>"}
42+
43+
Contents of \`firebase.json\` config file:
44+
45+
\`\`\`json
46+
${config.readProjectFile("firebase.json", { fallback: "<FILE DOES NOT EXIST>" })}
47+
\`\`\`
48+
49+
## User Instructions
50+
51+
${prompt || "<the user didn't supply specific instructions>"}
52+
53+
## Steps
54+
55+
Follow the steps below taking note of any user instructions provided above.
56+
57+
1. If there is no active user, use the \`firebase_login\` tool to help them sign in.
58+
2. Determine which of the services listed below are the best match for the user's needs based on their instructions or by asking them.
59+
3. Read the guide for the appropriate services and follow the instructions. If no guides match the user's need, inform the user.
60+
61+
## Available Services
62+
63+
The following Firebase services are available to be configured. Use the Firebase \`read_resources\` tool to load their instructions for further guidance.
64+
65+
- [Backend Services](firebase://guides/init/backend): Read this resource to setup backend services for the user such as user authentication, database, or cloud file storage.
66+
- [GenAI Services](firebase://guides/init/ai): Read this resource to setup GenAI services for the user such as building agents, LLM usage, unstructured data analysis, image editing, video generation, etc.
67+
68+
UNAVAILABLE SERVICES: Analytics, Remote Config (feature flagging), A/B testing, Crashlytics (crash reporting), and Cloud Messaging (push notifications) are not yet available for setup via this command.
69+
`.trim(),
70+
},
71+
},
72+
];
73+
},
74+
);

src/mcp/resource.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import type { ReadResourceResult } from "@modelcontextprotocol/sdk/types.js";
2+
import { McpContext } from "./types";
3+
4+
export interface ServerResource {
5+
mcp: {
6+
uri: string;
7+
name: string;
8+
description?: string;
9+
title?: string;
10+
_meta?: {
11+
/** Set this on a resource if it *always* requires a signed-in user to work. */
12+
requiresAuth?: boolean;
13+
/** Set this on a resource if it uses Gemini in Firebase API in any way. */
14+
requiresGemini?: boolean;
15+
};
16+
};
17+
fn: (uri: string, ctx: McpContext) => Promise<ReadResourceResult>;
18+
}
19+
20+
export function resource(
21+
options: ServerResource["mcp"],
22+
fnOrText: ServerResource["fn"] | string,
23+
): ServerResource {
24+
const fn: ServerResource["fn"] =
25+
typeof fnOrText === "string"
26+
? async (uri) => ({ contents: [{ uri, text: fnOrText }] })
27+
: fnOrText;
28+
return { mcp: options, fn };
29+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { resource } from "../../resource";
2+
3+
export const init_ai = resource(
4+
{
5+
uri: "firebase://guides/init/ai",
6+
name: "ai_init_guide",
7+
title: "Firebase GenAI Init Guide",
8+
description:
9+
"guides the coding agent through configuring GenAI capabilities in the current project utilizing Firebase",
10+
},
11+
async (uri) => {
12+
return {
13+
contents: [
14+
{
15+
uri,
16+
type: "text",
17+
text: `Create ai.ts with import { AI } from "firebase";`,
18+
},
19+
],
20+
};
21+
},
22+
);

0 commit comments

Comments
 (0)