Skip to content
Open
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Added new DevKnowledge MCP tools
3 changes: 3 additions & 0 deletions src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,32 +5,32 @@

let commandScopes = new Set<string>();

export const authProxyOrigin = () =>

Check warning on line 8 in src/api.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Missing return type on function
utils.envOverride("FIREBASE_AUTHPROXY_URL", "https://auth.firebase.tools");
// "In this context, the client secret is obviously not treated as a secret"
// https://developers.google.com/identity/protocols/OAuth2InstalledApp
export const clientId = () =>

Check warning on line 12 in src/api.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Missing return type on function
utils.envOverride(
"FIREBASE_CLIENT_ID",
"563584335869-fgrhgmd47bqnekij5i8b5pr03ho849e6.apps.googleusercontent.com",
);
export const clientSecret = () =>

Check warning on line 17 in src/api.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Missing return type on function
utils.envOverride("FIREBASE_CLIENT_SECRET", "j9iVZfS8kkCEFUPaAeJV0sAi");
export const cloudbillingOrigin = () =>

Check warning on line 19 in src/api.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Missing return type on function
utils.envOverride("FIREBASE_CLOUDBILLING_URL", "https://cloudbilling.googleapis.com");
export const cloudloggingOrigin = () =>

Check warning on line 21 in src/api.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Missing return type on function
utils.envOverride("FIREBASE_CLOUDLOGGING_URL", "https://logging.googleapis.com");
export const cloudMonitoringOrigin = () =>

Check warning on line 23 in src/api.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Missing return type on function
utils.envOverride("CLOUD_MONITORING_URL", "https://monitoring.googleapis.com");
export const containerRegistryDomain = () =>

Check warning on line 25 in src/api.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Missing return type on function
utils.envOverride("CONTAINER_REGISTRY_DOMAIN", "gcr.io");

export const developerConnectOrigin = () =>

Check warning on line 28 in src/api.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Missing return type on function
utils.envOverride("DEVELOPERCONNECT_URL", "https://developerconnect.googleapis.com");
export const developerConnectP4SADomain = () =>

Check warning on line 30 in src/api.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Missing return type on function
utils.envOverride("DEVELOPERCONNECT_P4SA_DOMAIN", "gcp-sa-devconnect.iam.gserviceaccount.com");

export const artifactRegistryDomain = () =>

Check warning on line 33 in src/api.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Missing return type on function
utils.envOverride("ARTIFACT_REGISTRY_DOMAIN", "https://artifactregistry.googleapis.com");
export const appDistributionOrigin = () =>
utils.envOverride(
Expand Down Expand Up @@ -177,6 +177,9 @@
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);
Expand Down
2 changes: 1 addition & 1 deletion src/bin/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ export async function mcp(): Promise<void> {
earlyExit = true;
}
if (values["generate-tool-list"]) {
console.log(markdownDocsOfTools());
console.log(await markdownDocsOfTools());
earlyExit = true;
}
if (values["generate-prompt-list"]) {
Expand Down
7 changes: 7 additions & 0 deletions src/mcp/onemcp/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { developerKnowledgeOrigin } from "../../api";
import { ServerFeature } from "../types";
import { OneMcpServer } from "./onemcp_server";

export const ONEMCP_SERVERS: Partial<Record<ServerFeature, OneMcpServer>> = {
developerknowledge: new OneMcpServer("developerknowledge", developerKnowledgeOrigin()),
};
150 changes: 150 additions & 0 deletions src/mcp/onemcp/onemcp_server.spec.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
});
104 changes: 104 additions & 0 deletions src/mcp/onemcp/onemcp_server.ts
Original file line number Diff line number Diff line change
@@ -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<ServerTool[]> {
try {
const res = await this.listClient.post<any, any>("/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<CallToolResult> {
await ensure(ctx.projectId, this.serverUrl, this.feature, /* silent=*/ true);
try {
const res = await this.callClient.post<any, any>(
"/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;
}
}
}
1 change: 1 addition & 0 deletions src/mcp/prompts/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const prompts: Record<ServerFeature, ServerPrompt[]> = {
apptesting: namespacePrompts(apptestingPrompts, "apptesting"),
apphosting: [],
database: [],
developerknowledge: [],
};

function namespacePrompts(
Expand Down
74 changes: 73 additions & 1 deletion src/mcp/tools/index.spec.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down Expand Up @@ -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];
}
}
}
});
});
Loading
Loading