Skip to content

Commit 0b9f783

Browse files
authored
feat(mcp): adds firebase://docs/{path} resource template to MCP (#9198)
1 parent 4aca0cb commit 0b9f783

File tree

5 files changed

+145
-12
lines changed

5 files changed

+145
-12
lines changed

src/mcp/index.ts

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,11 @@ import {
1717
ListResourcesResult,
1818
ReadResourceRequest,
1919
ReadResourceResult,
20+
ReadResourceRequestSchema,
21+
ListResourceTemplatesRequestSchema,
22+
ListResourceTemplatesResult,
2023
McpError,
2124
ErrorCode,
22-
ReadResourceRequestSchema,
2325
} from "@modelcontextprotocol/sdk/types.js";
2426
import { checkFeatureActive, mcpError } from "./util";
2527
import { ClientConfig, McpContext, SERVER_FEATURES, ServerFeature } from "./types";
@@ -42,7 +44,7 @@ import { existsSync } from "node:fs";
4244
import { LoggingStdioServerTransport } from "./logging-transport";
4345
import { isFirebaseStudio } from "../env";
4446
import { timeoutFallback } from "../timeout";
45-
import { resources } from "./resources";
47+
import { resolveResource, resources, resourceTemplates } from "./resources";
4648

4749
const SERVER_VERSION = "0.3.0";
4850

@@ -115,9 +117,12 @@ export class FirebaseMcpServer {
115117
this.server.setRequestHandler(CallToolRequestSchema, this.mcpCallTool.bind(this));
116118
this.server.setRequestHandler(ListPromptsRequestSchema, this.mcpListPrompts.bind(this));
117119
this.server.setRequestHandler(GetPromptRequestSchema, this.mcpGetPrompt.bind(this));
120+
this.server.setRequestHandler(
121+
ListResourceTemplatesRequestSchema,
122+
this.mcpListResourceTemplates.bind(this),
123+
);
118124
this.server.setRequestHandler(ListResourcesRequestSchema, this.mcpListResources.bind(this));
119125
this.server.setRequestHandler(ReadResourceRequestSchema, this.mcpReadResource.bind(this));
120-
121126
const onInitialized = (): void => {
122127
const clientInfo = this.server.getClientVersion();
123128
this.clientInfo = clientInfo;
@@ -424,9 +429,13 @@ export class FirebaseMcpServer {
424429
};
425430
}
426431

427-
async mcpReadResource(req: ReadResourceRequest): Promise<ReadResourceResult> {
428-
const resource = resources.find((r) => r.mcp.uri === req.params.uri);
432+
async mcpListResourceTemplates(): Promise<ListResourceTemplatesResult> {
433+
return {
434+
resourceTemplates: resourceTemplates.map((rt) => rt.mcp),
435+
};
436+
}
429437

438+
async mcpReadResource(req: ReadResourceRequest): Promise<ReadResourceResult> {
430439
let projectId = await this.getProjectId();
431440
projectId = projectId || "";
432441

@@ -435,13 +444,14 @@ export class FirebaseMcpServer {
435444

436445
const resourceCtx = this._createMcpContext(projectId, accountEmail);
437446

438-
if (!resource) {
447+
const resolved = await resolveResource(req.params.uri, resourceCtx);
448+
if (!resolved) {
439449
throw new McpError(
440450
ErrorCode.InvalidParams,
441451
`Resource '${req.params.uri}' could not be found.`,
442452
);
443453
}
444-
return resource.fn(req.params.uri, resourceCtx);
454+
return resolved.result;
445455
}
446456

447457
async start(): Promise<void> {

src/mcp/resource.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,45 @@ export function resource(
2727
: fnOrText;
2828
return { mcp: options, fn };
2929
}
30+
31+
export interface ServerResourceTemplate {
32+
mcp: {
33+
uriTemplate: string;
34+
/** How to know if a URI matches this template, can be a string (prefix), regex, or function. */
35+
name: string;
36+
description?: string;
37+
title?: string;
38+
_meta?: {
39+
/** Set this on a resource if it *always* requires a signed-in user to work. */
40+
requiresAuth?: boolean;
41+
/** Set this on a resource if it uses Gemini in Firebase API in any way. */
42+
requiresGemini?: boolean;
43+
};
44+
};
45+
match: (uri: string) => boolean;
46+
fn: (uri: string, ctx: McpContext) => Promise<ReadResourceResult>;
47+
}
48+
49+
export function resourceTemplate(
50+
options: ServerResourceTemplate["mcp"] & {
51+
match: string | RegExp | ServerResourceTemplate["match"];
52+
},
53+
fnOrText: ServerResourceTemplate["fn"] | string,
54+
): ServerResourceTemplate {
55+
let matchFn: ServerResourceTemplate["match"];
56+
const { match, ...mcp } = options;
57+
58+
if (match instanceof RegExp) {
59+
matchFn = (uri) => match.test(uri);
60+
} else if (typeof match === "string") {
61+
matchFn = (uri) => uri.startsWith(match);
62+
} else {
63+
matchFn = match;
64+
}
65+
66+
const fn: ServerResourceTemplate["fn"] =
67+
typeof fnOrText === "string"
68+
? async (uri) => ({ contents: [{ uri, text: fnOrText }] })
69+
: fnOrText;
70+
return { mcp, match: matchFn, fn };
71+
}

src/mcp/resources/docs.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { resourceTemplate } from "../resource";
2+
3+
export const docs = resourceTemplate(
4+
{
5+
name: "docs",
6+
title: "Firebase Docs",
7+
description:
8+
"loads plain text content from Firebase documentation, e.g. `https://firebase.google.com/docs/functions` becomes `firebase://docs/functions`",
9+
uriTemplate: `firebase://docs/{path}`,
10+
match: `firebase://docs/`,
11+
},
12+
async (uri) => {
13+
const path = uri.replace("firebase://docs/", "");
14+
try {
15+
const response = await fetch(`https://firebase.google.com/docs/${path}.md.txt`);
16+
17+
if (response.status >= 400) {
18+
return {
19+
contents: [
20+
{
21+
uri,
22+
text: `Received a ${response.status} error while fetching '${uri}':\n\n${await response.text()}`,
23+
},
24+
],
25+
};
26+
}
27+
28+
return {
29+
contents: [
30+
{
31+
uri,
32+
text: await response.text(),
33+
},
34+
],
35+
};
36+
} catch (e) {
37+
return {
38+
contents: [
39+
{
40+
uri,
41+
text: `ERROR: There was an error fetching content for ${uri}`,
42+
},
43+
],
44+
};
45+
}
46+
},
47+
);

src/mcp/resources/index.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
1+
import { ReadResourceResult } from "@modelcontextprotocol/sdk/types";
2+
import { McpContext } from "../types";
3+
import { docs } from "./docs";
14
import { init_ai } from "./guides/init_ai";
25
import { init_auth } from "./guides/init_auth";
36
import { init_backend } from "./guides/init_backend";
47
import { init_data_connect } from "./guides/init_data_connect";
58
import { init_firestore } from "./guides/init_firestore";
69
import { init_hosting } from "./guides/init_hosting";
710
import { init_rtdb } from "./guides/init_rtdb";
11+
import { ServerResource, ServerResourceTemplate } from "../resource";
812

913
export const resources = [
1014
init_backend,
@@ -15,3 +19,34 @@ export const resources = [
1519
init_auth,
1620
init_hosting,
1721
];
22+
23+
export const resourceTemplates = [docs];
24+
25+
export async function resolveResource(
26+
uri: string,
27+
ctx: McpContext,
28+
): Promise<
29+
| ({
30+
result: ReadResourceResult;
31+
} & (
32+
| { type: "template"; mcp: ServerResourceTemplate["mcp"] }
33+
| { type: "resource"; mcp: ServerResource["mcp"] }
34+
))
35+
| null
36+
> {
37+
// check if an exact resource name matches first
38+
const resource = resources.find((r) => r.mcp.uri === uri);
39+
if (resource) {
40+
const result = await resource.fn(uri, ctx);
41+
return { type: "resource", mcp: resource.mcp, result };
42+
}
43+
44+
// then check if any templates match
45+
const template = resourceTemplates.find((rt) => rt.match(uri));
46+
if (template) {
47+
const result = await template.fn(uri, ctx);
48+
return { type: "template", mcp: template.mcp, result };
49+
}
50+
51+
return null;
52+
}

src/mcp/tools/core/read_resources.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { z } from "zod";
22
import { tool } from "../../tool";
3-
import { resources } from "../../resources";
3+
import { resolveResource, resources } from "../../resources";
44
import { toContent } from "../../util";
55

66
export const read_resources = tool(
@@ -36,14 +36,13 @@ export const read_resources = tool(
3636

3737
const out: string[] = [];
3838
for (const uri of uris) {
39-
const resource = resources.find((r) => r.mcp.uri === uri);
40-
if (!resource) {
39+
const resolved = await resolveResource(uri, ctx);
40+
if (!resolved) {
4141
out.push(`<resource uri="${uri}" error>\nRESOURCE NOT FOUND\n</resource>`);
4242
continue;
4343
}
44-
const result = await resource.fn(uri, ctx);
4544
out.push(
46-
`<resource uri="${uri}" title="${resource?.mcp.title}">\n${result.contents.map((c) => c.text).join("")}\n</resource>`,
45+
`<resource uri="${uri}" title="${resolved.mcp.title || resolved.mcp.name}">\n${resolved.result.contents.map((c) => c.text).join("")}\n</resource>`,
4746
);
4847
}
4948

0 commit comments

Comments
 (0)