diff --git a/CHANGELOG.md b/CHANGELOG.md index e69de29bb2d..fa933608934 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -0,0 +1 @@ +- Added new DevKnowledge MCP tools 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/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.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/onemcp/onemcp_server.ts b/src/mcp/onemcp/onemcp_server.ts new file mode 100644 index 00000000000..f5b5b4aeeb9 --- /dev/null +++ b/src/mcp/onemcp/onemcp_server.ts @@ -0,0 +1,104 @@ +import { + CallToolResult, + CallToolResultSchema, + ListToolsResultSchema, +} from "@modelcontextprotocol/sdk/types.js"; +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. + */ +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 { + 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.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), + })); + } 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 { + await ensure(ctx.projectId, this.serverUrl, this.feature, /* silent=*/ true); + try { + 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, + }, + } + : {}, + ); + return CallToolResultSchema.parse(res.body.result); + } catch (error) { + if (error instanceof FirebaseError) { + const firebaseError = error; + const body = (firebaseError.context as any)?.body; + if (body?.result?.isError) { + return CallToolResultSchema.parse(body.result); + } + } + throw error; + } + } +} 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.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 2f82fccffee..fb13c8da3ce 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[] = []; + 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()); + } + } + 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..af63b1d337c 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, }; /** 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) { diff --git a/src/mcp/util/availability.ts b/src/mcp/util/availability.ts index f5f487fb812..31262e0e349 100644 --- a/src/mcp/util/availability.ts +++ b/src/mcp/util/availability.ts @@ -5,7 +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, + core: async (): Promise => true, + developerknowledge: async (): Promise => true, firestore: (ctx: McpContext): Promise => checkFeatureActive("firestore", ctx.projectId, { config: ctx.config }), storage: (ctx: McpContext): Promise =>