From b889fae906a79ce819a71a421ca8531f6cf21cfb Mon Sep 17 00:00:00 2001 From: Shawn Kuang Date: Wed, 11 Feb 2026 18:30:09 -0800 Subject: [PATCH 01/10] Developer Knowledge MCP proxy --- src/api.ts | 3 ++ src/apiv2.ts | 6 +-- src/bin/mcp.ts | 2 +- src/mcp/onemcp/index.ts | 7 +++ src/mcp/onemcp/onemcp_server.ts | 81 +++++++++++++++++++++++++++++++++ src/mcp/prompts/index.ts | 1 + src/mcp/tools/index.ts | 43 +++++++++++++---- src/mcp/types.ts | 1 + src/mcp/util.ts | 5 +- src/mcp/util/availability.ts | 1 + 10 files changed, 137 insertions(+), 13 deletions(-) create mode 100644 src/mcp/onemcp/index.ts create mode 100644 src/mcp/onemcp/onemcp_server.ts diff --git a/src/api.ts b/src/api.ts index 85e594cbdd6..614f50c75d9 100755 --- a/src/api.ts +++ b/src/api.ts @@ -177,6 +177,9 @@ export const appTestingOrigin = () => export const cloudTestingOrigin = () => utils.envOverride("CLOUD_TESTING_URL", "https://testing.googleapis.com"); +export const developerKnowledgeOrigin = () => + utils.envOverride("DEVELOPER_KNOWLEDGE_URL", "https://developerknowledge.googleapis.com"); + /** Gets scopes that have been set. */ export function getScopes(): string[] { return Array.from(commandScopes); diff --git a/src/apiv2.ts b/src/apiv2.ts index c4fb07d9c39..2de3a00e07d 100644 --- a/src/apiv2.ts +++ b/src/apiv2.ts @@ -237,9 +237,8 @@ export class Client { /** * Makes a request as specified by the options. * By default, this will: - * - use content-type: application/json - * - assume the HTTP GET method - * + * - use content-type: application/json + * - assume the HTTP GET method * @example * const res = apiv2.request({ * method: "POST", @@ -341,6 +340,7 @@ export class Client { } else { token = await getAccessToken(); } + reqOptions.headers.set("Authorization", `Bearer ${token}`); return reqOptions; } diff --git a/src/bin/mcp.ts b/src/bin/mcp.ts index ad99766b632..6ad168b1a7f 100644 --- a/src/bin/mcp.ts +++ b/src/bin/mcp.ts @@ -69,7 +69,7 @@ export async function mcp(): Promise { earlyExit = true; } if (values["generate-tool-list"]) { - console.log(markdownDocsOfTools()); + console.log(await markdownDocsOfTools()); earlyExit = true; } if (values["generate-prompt-list"]) { diff --git a/src/mcp/onemcp/index.ts b/src/mcp/onemcp/index.ts new file mode 100644 index 00000000000..b8d295128b3 --- /dev/null +++ b/src/mcp/onemcp/index.ts @@ -0,0 +1,7 @@ +import { developerKnowledgeOrigin } from "../../api"; +import { ServerFeature } from "../types"; +import { OneMcpServer } from "./onemcp_server"; + +export const ONEMCP_SERVERS: Partial> = { + developerknowledge: new OneMcpServer("developerknowledge", developerKnowledgeOrigin()), +}; diff --git a/src/mcp/onemcp/onemcp_server.ts b/src/mcp/onemcp/onemcp_server.ts new file mode 100644 index 00000000000..f27f706a13d --- /dev/null +++ b/src/mcp/onemcp/onemcp_server.ts @@ -0,0 +1,81 @@ +import { CallToolResultSchema, ListToolsResultSchema } from "@modelcontextprotocol/sdk/types.js"; +import { Client } from "../../apiv2"; +import { ServerTool } from "../tool"; +import { McpContext, ServerFeature } from "../types"; + +/** + * OneMcpServer encapsulates the logic for interacting with a remote MCP server. + */ +export class OneMcpServer { + private listClient: Client; + private callClient: Client; + constructor( + private readonly feature: ServerFeature, + private readonly serverUrl: string, + ) { + this.listClient = new Client({ + urlPrefix: this.serverUrl, + auth: false, + }); + this.callClient = new Client({ + urlPrefix: this.serverUrl, + auth: true, + }); + } + + /** + * Fetches tools from the remote MCP server. + */ + async fetchRemoteTools(): Promise { + const res = await this.listClient.post("/mcp", { + method: "tools/list", + jsonrpc: "2.0", + id: 1, + }); + + const parsed = ListToolsResultSchema.parse(res.body.result); + return (parsed.tools as any[]).map((mcpTool) => ({ + mcp: { + ...mcpTool, + name: `${this.feature}_${mcpTool.name}`, + _meta: { + ...(mcpTool._meta || {}), + feature: this.feature, + }, + }, + fn: (args: any, ctx: McpContext) => this.proxyRemoteToolCall(mcpTool.name, args, ctx), + isAvailable: () => Promise.resolve(true), + })); + } + + /** + * Proxies a tool call to the remote MCP server. + */ + private async proxyRemoteToolCall(toolName: string, args: any, ctx: McpContext): Promise { + const res = await this.callClient.post( + "/mcp", + { + method: "tools/call", + params: { + name: toolName, + arguments: args, + }, + jsonrpc: "2.0", + id: 1, + }, + ctx.projectId + ? { + headers: { + "x-goog-user-project": ctx.projectId, + }, + } + : {}, + ); + if (res.body.error) { + throw new Error( + `Remote MCP error: ${res.body.error.message || JSON.stringify(res.body.error)}`, + ); + } + return CallToolResultSchema.parse(res.body.result); + } +} diff --git a/src/mcp/prompts/index.ts b/src/mcp/prompts/index.ts index 44b9508378f..22491efc1a6 100644 --- a/src/mcp/prompts/index.ts +++ b/src/mcp/prompts/index.ts @@ -20,6 +20,7 @@ const prompts: Record = { apptesting: namespacePrompts(apptestingPrompts, "apptesting"), apphosting: [], database: [], + developerknowledge: [], }; function namespacePrompts( diff --git a/src/mcp/tools/index.ts b/src/mcp/tools/index.ts index 2f82fccffee..6c919a58496 100644 --- a/src/mcp/tools/index.ts +++ b/src/mcp/tools/index.ts @@ -1,3 +1,4 @@ +import { ONEMCP_SERVERS } from "../onemcp/index"; import { ServerTool } from "../tool"; import { McpContext, ServerFeature } from "../types"; import { appHostingTools } from "./apphosting/index"; @@ -40,6 +41,8 @@ const tools: Record = { messaging: addFeaturePrefix("messaging", messagingTools), remoteconfig: addFeaturePrefix("remoteconfig", remoteConfigTools), storage: addFeaturePrefix("storage", storageTools), + // No local tools for developer knowledge + developerknowledge: [], }; const allToolsMap = new Map( @@ -49,11 +52,13 @@ const allToolsMap = new Map( .map((t) => [t.mcp.name, t]), ); -function getToolsByName(names: string[]): ServerTool[] { +async function getToolsByName(names: string[]): Promise { const selectedTools = new Set(); + const remoteTools = new Map((await getRemoteToolsByFeature()).map((t) => [t.mcp.name, t])); + for (const toolName of names) { - const tool = allToolsMap.get(toolName); + const tool = allToolsMap.get(toolName) || remoteTools.get(toolName); if (tool) { selectedTools.add(tool); } @@ -62,13 +67,35 @@ function getToolsByName(names: string[]): ServerTool[] { return Array.from(selectedTools); } -export function getToolsByFeature(serverFeatures?: ServerFeature[]): ServerTool[] { +/** + * + */ +export async function getToolsByFeature(serverFeatures?: ServerFeature[]): Promise { const features = new Set( - serverFeatures?.length ? serverFeatures : (Object.keys(tools) as ServerFeature[]), + serverFeatures?.length + ? serverFeatures + : (Object.keys({ ...tools, ...ONEMCP_SERVERS }) as ServerFeature[]), ); features.add("core"); - return Array.from(features).flatMap((feature) => tools[feature] || []); + const featureList = Array.from(features); + const localTools = featureList.flatMap((feature) => tools[feature] || []); + const remoteTools = await getRemoteToolsByFeature(featureList); + return [...localTools, ...remoteTools]; +} + +/** + * Fetches tools from remote MCP servers. + */ +export async function getRemoteToolsByFeature(features?: ServerFeature[]): Promise { + const remoteToolsPromises: Promise[] = []; + for (const feature of features || (Object.keys(ONEMCP_SERVERS) as ServerFeature[])) { + const server = ONEMCP_SERVERS[feature]; + if (server) { + remoteToolsPromises.push(server.fetchRemoteTools()); + } + } + return Promise.all(remoteToolsPromises).then((tools) => tools.flat()); } /** @@ -90,7 +117,7 @@ export async function availableTools( return getToolsByFeature(activeFeatures); } - const allTools = getToolsByFeature(detectedFeatures); + const allTools = await getToolsByFeature(detectedFeatures); const availabilities = await Promise.all( allTools.map((t) => { if (t.isAvailable) { @@ -106,8 +133,8 @@ export async function availableTools( * Generates a markdown table of all available tools and their descriptions. * This is used for generating documentation. */ -export function markdownDocsOfTools(): string { - const allTools = getToolsByFeature([]); +export async function markdownDocsOfTools(): Promise { + const allTools = await getToolsByFeature([]); let doc = ` | Tool Name | Feature Group | Description | | --------- | ------------- | ----------- |`; diff --git a/src/mcp/types.ts b/src/mcp/types.ts index 0b328bb1005..ad5e4f86f69 100644 --- a/src/mcp/types.ts +++ b/src/mcp/types.ts @@ -15,6 +15,7 @@ export const SERVER_FEATURES = [ "apptesting", "apphosting", "database", + "developerknowledge", ] as const; export type ServerFeature = (typeof SERVER_FEATURES)[number]; diff --git a/src/mcp/util.ts b/src/mcp/util.ts index f7a11b88d2a..b40a12e9f11 100644 --- a/src/mcp/util.ts +++ b/src/mcp/util.ts @@ -13,6 +13,7 @@ import { crashlyticsApiOrigin, appDistributionOrigin, realtimeOrigin, + developerKnowledgeOrigin, } from "../api"; import { check } from "../ensureApiEnabled"; import { timeoutFallback } from "../timeout"; @@ -77,6 +78,7 @@ const SERVER_FEATURE_APIS: Record = { apptesting: appDistributionOrigin(), apphosting: apphostingOrigin(), database: realtimeOrigin(), + developerknowledge: developerKnowledgeOrigin(), }; const DETECTED_API_FEATURES: Record = { @@ -92,6 +94,7 @@ const DETECTED_API_FEATURES: Record = { apptesting: undefined, apphosting: undefined, database: undefined, + developerknowledge: undefined, }; /** @@ -131,7 +134,7 @@ export async function checkFeatureActive( // Helper function to process a single schema node (could be a property schema, items schema, etc.) // Returns the cleaned schema, or null if the schema becomes invalid and should be removed according to the rules. // The isRoot parameter is true only for the top-level schema object. -function deepClean(obj: any, isRootLevel: boolean = false): any { +function deepClean(obj: any, isRootLevel = false): any { if (typeof obj !== "object" || obj === null) { return obj; // Not a schema object or null, return as is } diff --git a/src/mcp/util/availability.ts b/src/mcp/util/availability.ts index f5f487fb812..0e4b0b6f9ac 100644 --- a/src/mcp/util/availability.ts +++ b/src/mcp/util/availability.ts @@ -6,6 +6,7 @@ import { isAppTestingAvailable } from "./apptesting/availability"; const DEFAULT_AVAILABILITY_CHECKS: Record Promise> = { // eslint-disable-next-line @typescript-eslint/no-unused-vars core: async (ctx: McpContext): Promise => true, + developerknowledge: async (ctx: McpContext): Promise => true, firestore: (ctx: McpContext): Promise => checkFeatureActive("firestore", ctx.projectId, { config: ctx.config }), storage: (ctx: McpContext): Promise => From 3b7973bba53acbd0c2a4e3b115dc12a0c4f73390 Mon Sep 17 00:00:00 2001 From: Shawn Kuang Date: Fri, 13 Feb 2026 10:36:54 -0800 Subject: [PATCH 02/10] minor fix --- src/mcp/onemcp/onemcp_server.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/mcp/onemcp/onemcp_server.ts b/src/mcp/onemcp/onemcp_server.ts index f27f706a13d..848b0f81982 100644 --- a/src/mcp/onemcp/onemcp_server.ts +++ b/src/mcp/onemcp/onemcp_server.ts @@ -1,4 +1,4 @@ -import { CallToolResultSchema, ListToolsResultSchema } from "@modelcontextprotocol/sdk/types.js"; +import { CallToolResult, CallToolResultSchema, ListToolsResultSchema } from "@modelcontextprotocol/sdk/types.js"; import { Client } from "../../apiv2"; import { ServerTool } from "../tool"; import { McpContext, ServerFeature } from "../types"; @@ -51,7 +51,7 @@ export class OneMcpServer { /** * Proxies a tool call to the remote MCP server. */ - private async proxyRemoteToolCall(toolName: string, args: any, ctx: McpContext): Promise { + private async proxyRemoteToolCall(toolName: string, args: any, ctx: McpContext): Promise { const res = await this.callClient.post( "/mcp", { From 50fcb244520c131a3d4c9384d9f74b6c7f1aac35 Mon Sep 17 00:00:00 2001 From: Shawn Kuang Date: Fri, 13 Feb 2026 12:24:16 -0800 Subject: [PATCH 03/10] Improve error handling --- src/mcp/onemcp/onemcp_server.ts | 89 ++++++++++++++++++--------------- 1 file changed, 50 insertions(+), 39 deletions(-) diff --git a/src/mcp/onemcp/onemcp_server.ts b/src/mcp/onemcp/onemcp_server.ts index 848b0f81982..2b165de7b00 100644 --- a/src/mcp/onemcp/onemcp_server.ts +++ b/src/mcp/onemcp/onemcp_server.ts @@ -2,6 +2,7 @@ import { CallToolResult, CallToolResultSchema, ListToolsResultSchema } from "@mo import { Client } from "../../apiv2"; import { ServerTool } from "../tool"; import { McpContext, ServerFeature } from "../types"; +import { FirebaseError } from "../../error"; /** * OneMcpServer encapsulates the logic for interacting with a remote MCP server. @@ -27,55 +28,65 @@ export class OneMcpServer { * Fetches tools from the remote MCP server. */ async fetchRemoteTools(): Promise { - const res = await this.listClient.post("/mcp", { - method: "tools/list", - jsonrpc: "2.0", - id: 1, - }); + try { + const res = await this.listClient.post("/mcp", { + method: "tools/list", + jsonrpc: "2.0", + id: 1, + }); - const parsed = ListToolsResultSchema.parse(res.body.result); - return (parsed.tools as any[]).map((mcpTool) => ({ - mcp: { - ...mcpTool, - name: `${this.feature}_${mcpTool.name}`, - _meta: { - ...(mcpTool._meta || {}), - feature: this.feature, + const parsed = ListToolsResultSchema.parse(res.body.result); + return (parsed.tools as any[]).map((mcpTool) => ({ + mcp: { + ...mcpTool, + name: `${this.feature}_${mcpTool.name}`, + _meta: { + requiresAuth: true, + requiresProject: true, + }, }, - }, - fn: (args: any, ctx: McpContext) => this.proxyRemoteToolCall(mcpTool.name, args, ctx), - isAvailable: () => Promise.resolve(true), - })); + fn: (args: any, ctx: McpContext) => this.proxyRemoteToolCall(mcpTool.name, args, ctx), + isAvailable: () => Promise.resolve(true), + })); + } catch (error) { + throw new FirebaseError("Failed to fetch remote tools for " + this.serverUrl + ": " + JSON.stringify(error)); + } } /** * Proxies a tool call to the remote MCP server. */ private async proxyRemoteToolCall(toolName: string, args: any, ctx: McpContext): Promise { - const res = await this.callClient.post( - "/mcp", - { - method: "tools/call", - params: { - name: toolName, - arguments: args, - }, - jsonrpc: "2.0", - id: 1, - }, - ctx.projectId - ? { - headers: { - "x-goog-user-project": ctx.projectId, + try { + const res = await this.callClient.post( + "/mcp", + { + method: "tools/call", + params: { + name: toolName, + arguments: args, }, - } - : {}, - ); - if (res.body.error) { - throw new Error( - `Remote MCP error: ${res.body.error.message || JSON.stringify(res.body.error)}`, + jsonrpc: "2.0", + id: 1, + }, + ctx.projectId + ? { + headers: { + "x-goog-user-project": ctx.projectId, + }, + } + : {}, ); + return CallToolResultSchema.parse(res.body.result); + } catch (error) { + if (error instanceof FirebaseError) { + const firebaseError = error as FirebaseError; + const body = (firebaseError.context as any)?.body; + if (body?.result?.isError) { + return CallToolResultSchema.parse(body.result); + } + } + throw error; } - return CallToolResultSchema.parse(res.body.result); } } From 04ddac51fc8ea82e473f0e150e0b09fd6e742ec1 Mon Sep 17 00:00:00 2001 From: Shawn Kuang Date: Fri, 13 Feb 2026 16:59:15 -0800 Subject: [PATCH 04/10] Address some feedback and lint --- src/mcp/onemcp/onemcp_server.ts | 28 +++++++++++++++++++--------- src/mcp/util/availability.ts | 4 ++-- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/src/mcp/onemcp/onemcp_server.ts b/src/mcp/onemcp/onemcp_server.ts index 2b165de7b00..aa0d2f6c3fb 100644 --- a/src/mcp/onemcp/onemcp_server.ts +++ b/src/mcp/onemcp/onemcp_server.ts @@ -1,4 +1,8 @@ -import { CallToolResult, CallToolResultSchema, ListToolsResultSchema } from "@modelcontextprotocol/sdk/types.js"; +import { + CallToolResult, + CallToolResultSchema, + ListToolsResultSchema, +} from "@modelcontextprotocol/sdk/types.js"; import { Client } from "../../apiv2"; import { ServerTool } from "../tool"; import { McpContext, ServerFeature } from "../types"; @@ -36,7 +40,7 @@ export class OneMcpServer { }); const parsed = ListToolsResultSchema.parse(res.body.result); - return (parsed.tools as any[]).map((mcpTool) => ({ + return parsed.tools.map((mcpTool) => ({ mcp: { ...mcpTool, name: `${this.feature}_${mcpTool.name}`, @@ -49,14 +53,20 @@ export class OneMcpServer { isAvailable: () => Promise.resolve(true), })); } catch (error) { - throw new FirebaseError("Failed to fetch remote tools for " + this.serverUrl + ": " + JSON.stringify(error)); + throw new FirebaseError( + "Failed to fetch remote tools for " + this.serverUrl + ": " + JSON.stringify(error), + ); } } /** * Proxies a tool call to the remote MCP server. */ - private async proxyRemoteToolCall(toolName: string, args: any, ctx: McpContext): Promise { + private async proxyRemoteToolCall( + toolName: string, + args: any, + ctx: McpContext, + ): Promise { try { const res = await this.callClient.post( "/mcp", @@ -71,16 +81,16 @@ export class OneMcpServer { }, ctx.projectId ? { - headers: { - "x-goog-user-project": ctx.projectId, - }, - } + headers: { + "x-goog-user-project": ctx.projectId, + }, + } : {}, ); return CallToolResultSchema.parse(res.body.result); } catch (error) { if (error instanceof FirebaseError) { - const firebaseError = error as FirebaseError; + const firebaseError = error; const body = (firebaseError.context as any)?.body; if (body?.result?.isError) { return CallToolResultSchema.parse(body.result); diff --git a/src/mcp/util/availability.ts b/src/mcp/util/availability.ts index 0e4b0b6f9ac..31262e0e349 100644 --- a/src/mcp/util/availability.ts +++ b/src/mcp/util/availability.ts @@ -5,8 +5,8 @@ import { isAppTestingAvailable } from "./apptesting/availability"; const DEFAULT_AVAILABILITY_CHECKS: Record Promise> = { // eslint-disable-next-line @typescript-eslint/no-unused-vars - core: async (ctx: McpContext): Promise => true, - developerknowledge: async (ctx: McpContext): Promise => true, + core: async (): Promise => true, + developerknowledge: async (): Promise => true, firestore: (ctx: McpContext): Promise => checkFeatureActive("firestore", ctx.projectId, { config: ctx.config }), storage: (ctx: McpContext): Promise => From 3f51b94b8798111f67b4cc71dfe1cbb0204c7482 Mon Sep 17 00:00:00 2001 From: Shawn Kuang Date: Fri, 13 Feb 2026 17:04:52 -0800 Subject: [PATCH 05/10] Revert meaningless changes --- src/apiv2.ts | 7 ++++--- src/mcp/tools/index.ts | 3 --- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/apiv2.ts b/src/apiv2.ts index 2de3a00e07d..bc4cc98ea78 100644 --- a/src/apiv2.ts +++ b/src/apiv2.ts @@ -237,9 +237,10 @@ export class Client { /** * Makes a request as specified by the options. * By default, this will: - * - use content-type: application/json - * - assume the HTTP GET method - * @example + * - use content-type: application/json + * - assume the HTTP GET method + * + * @example * const res = apiv2.request({ * method: "POST", * path: "/some/resource", diff --git a/src/mcp/tools/index.ts b/src/mcp/tools/index.ts index 6c919a58496..06e29342ddb 100644 --- a/src/mcp/tools/index.ts +++ b/src/mcp/tools/index.ts @@ -67,9 +67,6 @@ async function getToolsByName(names: string[]): Promise { return Array.from(selectedTools); } -/** - * - */ export async function getToolsByFeature(serverFeatures?: ServerFeature[]): Promise { const features = new Set( serverFeatures?.length From b1b2a6b8b2c32a7b22685f5ae3c09213d9df2e64 Mon Sep 17 00:00:00 2001 From: Shawn Kuang Date: Fri, 13 Feb 2026 17:11:36 -0800 Subject: [PATCH 06/10] spaces and Changelog --- CHANGELOG.md | 1 + src/apiv2.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e69de29bb2d..2cb4524b25f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -0,0 +1 @@ +- Added new DevKnowledge MCP tools \ No newline at end of file diff --git a/src/apiv2.ts b/src/apiv2.ts index bc4cc98ea78..e0d16ac7105 100644 --- a/src/apiv2.ts +++ b/src/apiv2.ts @@ -240,7 +240,7 @@ export class Client { * - use content-type: application/json * - assume the HTTP GET method * - * @example + * @example * const res = apiv2.request({ * method: "POST", * path: "/some/resource", From 7e67f8561e65d4085776f5240207e567b0f10631 Mon Sep 17 00:00:00 2001 From: Shawn Kuang Date: Fri, 13 Feb 2026 17:18:15 -0800 Subject: [PATCH 07/10] cleanup --- src/apiv2.ts | 1 - src/mcp/util.ts | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/apiv2.ts b/src/apiv2.ts index e0d16ac7105..c4fb07d9c39 100644 --- a/src/apiv2.ts +++ b/src/apiv2.ts @@ -341,7 +341,6 @@ export class Client { } else { token = await getAccessToken(); } - reqOptions.headers.set("Authorization", `Bearer ${token}`); return reqOptions; } diff --git a/src/mcp/util.ts b/src/mcp/util.ts index b40a12e9f11..af63b1d337c 100644 --- a/src/mcp/util.ts +++ b/src/mcp/util.ts @@ -134,7 +134,7 @@ export async function checkFeatureActive( // Helper function to process a single schema node (could be a property schema, items schema, etc.) // Returns the cleaned schema, or null if the schema becomes invalid and should be removed according to the rules. // The isRoot parameter is true only for the top-level schema object. -function deepClean(obj: any, isRootLevel = false): any { +function deepClean(obj: any, isRootLevel: boolean = false): any { if (typeof obj !== "object" || obj === null) { return obj; // Not a schema object or null, return as is } From 705329c15f9dd22adc82f479f791357041a9af2a Mon Sep 17 00:00:00 2001 From: Shawn Kuang Date: Fri, 13 Feb 2026 17:23:46 -0800 Subject: [PATCH 08/10] Prettier --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2cb4524b25f..fa933608934 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1 +1 @@ -- Added new DevKnowledge MCP tools \ No newline at end of file +- Added new DevKnowledge MCP tools From 25757e58cb2cdf09ed234ed7dd6b3e0d719f8023 Mon Sep 17 00:00:00 2001 From: Shawn Kuang Date: Fri, 13 Feb 2026 17:33:03 -0800 Subject: [PATCH 09/10] Ensure API is enabled for MCP tools/call --- src/mcp/onemcp/onemcp_server.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/mcp/onemcp/onemcp_server.ts b/src/mcp/onemcp/onemcp_server.ts index aa0d2f6c3fb..f5b5b4aeeb9 100644 --- a/src/mcp/onemcp/onemcp_server.ts +++ b/src/mcp/onemcp/onemcp_server.ts @@ -7,6 +7,7 @@ import { Client } from "../../apiv2"; import { ServerTool } from "../tool"; import { McpContext, ServerFeature } from "../types"; import { FirebaseError } from "../../error"; +import { ensure } from "../../ensureApiEnabled"; /** * OneMcpServer encapsulates the logic for interacting with a remote MCP server. @@ -67,6 +68,7 @@ export class OneMcpServer { args: any, ctx: McpContext, ): Promise { + await ensure(ctx.projectId, this.serverUrl, this.feature, /* silent=*/ true); try { const res = await this.callClient.post( "/mcp", From 390aa5525c303c2ade96546db52c38ccf5a9dc8c Mon Sep 17 00:00:00 2001 From: Shawn Kuang Date: Fri, 13 Feb 2026 18:11:16 -0800 Subject: [PATCH 10/10] Add tests and fix existing tests --- src/mcp/onemcp/onemcp_server.spec.ts | 150 +++++++++++++++++++++++++++ src/mcp/tools/index.spec.ts | 74 ++++++++++++- src/mcp/tools/index.ts | 5 +- src/mcp/util/availability.spec.ts | 9 +- 4 files changed, 235 insertions(+), 3 deletions(-) create mode 100644 src/mcp/onemcp/onemcp_server.spec.ts diff --git a/src/mcp/onemcp/onemcp_server.spec.ts b/src/mcp/onemcp/onemcp_server.spec.ts new file mode 100644 index 00000000000..0cc73afbabf --- /dev/null +++ b/src/mcp/onemcp/onemcp_server.spec.ts @@ -0,0 +1,150 @@ +import { expect } from "chai"; +import * as sinon from "sinon"; +import { OneMcpServer } from "./onemcp_server"; +import { Client } from "../../apiv2"; +import * as ensureModule from "../../ensureApiEnabled"; +import { FirebaseError } from "../../error"; + +describe("OneMcpServer", () => { + let sandbox: sinon.SinonSandbox; + let clientRequestStub: sinon.SinonStub; + let ensureStub: sinon.SinonStub; + + const feature = "test_feature" as any; + const serverUrl = "https://example.com"; + let server: OneMcpServer; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + clientRequestStub = sandbox.stub(Client.prototype, "request"); + ensureStub = sandbox.stub(ensureModule, "ensure").resolves(); + server = new OneMcpServer(feature, serverUrl); + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe("fetchRemoteTools", () => { + it("should fetch and parse remote tools successfully", async () => { + const mockMcpTool = { + name: "test_tool", + description: "A test tool", + inputSchema: { type: "object", properties: {} }, + }; + clientRequestStub.resolves({ + body: { + result: { + tools: [mockMcpTool], + }, + }, + }); + + const tools = await server.fetchRemoteTools(); + + expect(tools).to.have.length(1); + expect(tools[0].mcp.name).to.equal("test_feature_test_tool"); + expect(tools[0].mcp.description).to.equal(mockMcpTool.description); + expect(tools[0].mcp._meta).to.deep.equal({ + requiresAuth: true, + requiresProject: true, + }); + expect(clientRequestStub).to.have.been.calledOnce; + }); + + it("should throw FirebaseError if fetch fails", async () => { + clientRequestStub.rejects(new Error("Network Error")); + + await expect(server.fetchRemoteTools()).to.be.rejectedWith( + FirebaseError, + /Failed to fetch remote tools/, + ); + }); + }); + + describe("proxyRemoteToolCall", () => { + const mockContext: any = { + projectId: "test-project", + }; + + it("should call ensure and proxy tool call successfully", async () => { + const mockMcpTool = { name: "test_tool", inputSchema: { type: "object", properties: {} } }; + clientRequestStub.onFirstCall().resolves({ + body: { result: { tools: [mockMcpTool] } }, + }); + + const tools = await server.fetchRemoteTools(); + const tool = tools[0]; + + const mockCallResult = { content: [{ type: "text", text: "success" }] }; + clientRequestStub.onSecondCall().resolves({ + body: { result: mockCallResult }, + }); + + const result = await tool.fn({ arg: "val" }, mockContext); + + expect(result).to.deep.equal(mockCallResult); + expect(ensureStub).to.have.been.calledOnceWith( + mockContext.projectId, + serverUrl, + feature, + true, + ); + expect(clientRequestStub.secondCall.args[0]).to.deep.include({ + method: "POST", + body: { + method: "tools/call", + params: { + name: "test_tool", + arguments: { arg: "val" }, + }, + jsonrpc: "2.0", + id: 1, + }, + }); + expect(clientRequestStub.secondCall.args[0].headers).to.deep.include({ + "x-goog-user-project": "test-project", + }); + }); + + it("should handle remote tool error results", async () => { + const mockMcpTool = { name: "test_tool", inputSchema: { type: "object", properties: {} } }; + clientRequestStub.onFirstCall().resolves({ + body: { result: { tools: [mockMcpTool] } }, + }); + + const tools = await server.fetchRemoteTools(); + const tool = tools[0]; + + const mockErrorResult = { isError: true, content: [{ type: "text", text: "remote error" }] }; + const firebaseError = new FirebaseError("Remote tool error", { + status: 400, + context: { + body: { + result: mockErrorResult, + }, + }, + }); + clientRequestStub.onSecondCall().rejects(firebaseError); + + const result = await tool.fn({ arg: "val" }, mockContext); + + expect(result).to.deep.equal(mockErrorResult); + }); + + it("should throw original error if not a handled FirebaseError", async () => { + const mockMcpTool = { name: "test_tool", inputSchema: { type: "object", properties: {} } }; + clientRequestStub.onFirstCall().resolves({ + body: { result: { tools: [mockMcpTool] } }, + }); + + const tools = await server.fetchRemoteTools(); + const tool = tools[0]; + + const genericError = new Error("Generic Error"); + clientRequestStub.onSecondCall().rejects(genericError); + + await expect(tool.fn({}, mockContext)).to.be.rejectedWith("Generic Error"); + }); + }); +}); diff --git a/src/mcp/tools/index.spec.ts b/src/mcp/tools/index.spec.ts index 53a349a2222..f41d258fa7d 100644 --- a/src/mcp/tools/index.spec.ts +++ b/src/mcp/tools/index.spec.ts @@ -1,6 +1,9 @@ import { expect } from "chai"; +import * as sinon from "sinon"; import { McpContext } from "../types"; -import { availableTools } from "./index"; +import { availableTools, getRemoteToolsByFeature } from "./index"; +import { ONEMCP_SERVERS } from "../onemcp/index"; +import { OneMcpServer } from "../onemcp/onemcp_server"; describe("availableTools", () => { const mockContext: McpContext = { @@ -56,3 +59,72 @@ describe("availableTools", () => { expect(firestoreTool).to.exist; }); }); + +describe("getRemoteToolsByFeature", () => { + let sandbox: sinon.SinonSandbox; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it("should call fetchRemoteTools on servers in ONEMCP_SERVERS", async () => { + const mockTool = { mcp: { name: "remote_tool" } }; + const fetchStub = sandbox + .stub(OneMcpServer.prototype, "fetchRemoteTools") + .resolves([mockTool as any]); + + const tools = await getRemoteToolsByFeature(["developerknowledge"]); + + expect(fetchStub).to.have.been.calledOnce; + expect(tools).to.have.length(1); + expect(tools[0].mcp.name).to.equal("remote_tool"); + }); + + it("should filter by provided features", async () => { + const fetchStub = sandbox.stub(OneMcpServer.prototype, "fetchRemoteTools").resolves([]); + + await getRemoteToolsByFeature(["developerknowledge"]); + // Since only 'developerknowledge' is in ONEMCP_SERVERS currently, + // we check that it was called once for that feature. + expect(fetchStub).to.have.been.calledOnce; + + fetchStub.resetHistory(); + await getRemoteToolsByFeature([]); + // If features is empty, it should use all keys in ONEMCP_SERVERS. + expect(fetchStub).to.have.been.calledOnce; + }); + + it("should return flattened results from all remote servers", async () => { + const mockTool1 = { mcp: { name: "developerknowledge_tool1" } }; + const mockTool2 = { mcp: { name: "firestore_tool1" } }; + const mockTool3 = { mcp: { name: "firestore_tool2" } }; + + // Fake ONEMCP_SERVERS with multiple entries to test flattening + const originalServers = { ...ONEMCP_SERVERS }; + (ONEMCP_SERVERS as any).developerknowledge = new OneMcpServer("developerknowledge", "url1"); + (ONEMCP_SERVERS as any).firestore = new OneMcpServer("firestore", "url2"); + + const fetchStub = sandbox.stub(OneMcpServer.prototype, "fetchRemoteTools"); + fetchStub.onFirstCall().resolves([mockTool1 as any]); + fetchStub.onSecondCall().resolves([mockTool2 as any, mockTool3 as any]); + + try { + const tools = await getRemoteToolsByFeature(["developerknowledge", "firestore"]); + expect(tools).to.have.length(3); + expect(tools.map((t) => t.mcp.name)).to.include("developerknowledge_tool1"); + expect(tools.map((t) => t.mcp.name)).to.include("firestore_tool1"); + expect(tools.map((t) => t.mcp.name)).to.include("firestore_tool2"); + } finally { + // Restore original ONEMCP_SERVERS + for (const key of Object.keys(ONEMCP_SERVERS)) { + if (!(key in originalServers)) { + delete (ONEMCP_SERVERS as any)[key]; + } + } + } + }); +}); diff --git a/src/mcp/tools/index.ts b/src/mcp/tools/index.ts index 06e29342ddb..fb13c8da3ce 100644 --- a/src/mcp/tools/index.ts +++ b/src/mcp/tools/index.ts @@ -86,7 +86,10 @@ export async function getToolsByFeature(serverFeatures?: ServerFeature[]): Promi */ export async function getRemoteToolsByFeature(features?: ServerFeature[]): Promise { const remoteToolsPromises: Promise[] = []; - for (const feature of features || (Object.keys(ONEMCP_SERVERS) as ServerFeature[])) { + const featureSet = new Set( + features?.length ? features : (Object.keys(ONEMCP_SERVERS) as ServerFeature[]), + ); + for (const feature of featureSet) { const server = ONEMCP_SERVERS[feature]; if (server) { remoteToolsPromises.push(server.fetchRemoteTools()); diff --git a/src/mcp/util/availability.spec.ts b/src/mcp/util/availability.spec.ts index 3041f8c59e8..0eafbba9cb0 100644 --- a/src/mcp/util/availability.spec.ts +++ b/src/mcp/util/availability.spec.ts @@ -41,9 +41,16 @@ describe("getDefaultFeatureAvailabilityCheck", () => { expect(crashlyticsCheck).to.equal(crashlytics.isCrashlyticsAvailable); }); + it("should return a function that always returns true for 'developerknowledge'", async () => { + const developerknowledgeCheck = getDefaultFeatureAvailabilityCheck("developerknowledge"); + const result = await developerknowledgeCheck(mockContext()); + expect(result).to.be.true; + expect(checkFeatureActiveStub.notCalled).to.be.true; + }); + // Test all other features that rely on checkFeatureActive const featuresThatUseCheckActive = SERVER_FEATURES.filter( - (f) => f !== "core" && f !== "crashlytics" && f !== "apptesting", + (f) => f !== "core" && f !== "crashlytics" && f !== "apptesting" && f !== "developerknowledge", ); for (const feature of featuresThatUseCheckActive) {