UNPKG

firebase-tools

Version:
417 lines (416 loc) 18.7 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.FirebaseMcpServer = void 0; const index_js_1 = require("@modelcontextprotocol/sdk/server/index.js"); const stdio_js_1 = require("@modelcontextprotocol/sdk/server/stdio.js"); const types_js_1 = require("@modelcontextprotocol/sdk/types.js"); const util_1 = require("./util"); const types_1 = require("./types"); const index_1 = require("./tools/index"); const index_2 = require("./prompts/index"); const configstore_1 = require("../configstore"); const command_1 = require("../command"); const requireAuth_1 = require("../requireAuth"); const projectUtils_1 = require("../projectUtils"); const errors_1 = require("./errors"); const track_1 = require("../track"); const config_1 = require("../config"); const rc_1 = require("../rc"); const hubClient_1 = require("../emulator/hubClient"); const node_fs_1 = require("node:fs"); const logging_transport_1 = require("./logging-transport"); const env_1 = require("../env"); const timeout_1 = require("../timeout"); const resources_1 = require("./resources"); const crossSpawn = require("cross-spawn"); const availability_1 = require("./util/availability"); const SERVER_VERSION = "0.3.0"; const cmd = new command_1.Command("mcp"); const orderedLogLevels = [ "debug", "info", "notice", "warning", "error", "critical", "alert", "emergency", ]; class FirebaseMcpServer { async trackGA4(event, params = {}) { var _a, _b; if (!this.clientInfo) await (0, timeout_1.timeoutFallback)(this.ready(), null, 2000); const clientInfoParams = { mcp_client_name: ((_a = this.clientInfo) === null || _a === void 0 ? void 0 : _a.name) || "<unknown-client>", mcp_client_version: ((_b = this.clientInfo) === null || _b === void 0 ? void 0 : _b.version) || "<unknown-version>", gemini_cli_extension: process.env.IS_GEMINI_CLI_EXTENSION ? "true" : "false", }; return (0, track_1.trackGA4)(event, Object.assign(Object.assign({}, params), clientInfoParams)); } constructor(options) { this._ready = false; this._readyPromises = []; this._pendingMessages = []; this.currentLogLevel = process.env.FIREBASE_MCP_DEBUG_LOG ? "debug" : undefined; this.activeFeatures = options.activeFeatures; this.startupRoot = options.projectRoot || process.env.PROJECT_ROOT; this.server = new index_js_1.Server({ name: "firebase", version: SERVER_VERSION }); this.server.registerCapabilities({ tools: { listChanged: true }, logging: {}, prompts: { listChanged: true }, resources: {}, }); this.server.setRequestHandler(types_js_1.ListToolsRequestSchema, this.mcpListTools.bind(this)); this.server.setRequestHandler(types_js_1.CallToolRequestSchema, this.mcpCallTool.bind(this)); this.server.setRequestHandler(types_js_1.ListPromptsRequestSchema, this.mcpListPrompts.bind(this)); this.server.setRequestHandler(types_js_1.GetPromptRequestSchema, this.mcpGetPrompt.bind(this)); this.server.setRequestHandler(types_js_1.ListResourceTemplatesRequestSchema, this.mcpListResourceTemplates.bind(this)); this.server.setRequestHandler(types_js_1.ListResourcesRequestSchema, this.mcpListResources.bind(this)); this.server.setRequestHandler(types_js_1.ReadResourceRequestSchema, this.mcpReadResource.bind(this)); const onInitialized = () => { var _a, _b; const clientInfo = this.server.getClientVersion(); this.clientInfo = clientInfo; if (clientInfo === null || clientInfo === void 0 ? void 0 : clientInfo.name) { void this.trackGA4("mcp_client_connected"); } if (!((_a = this.clientInfo) === null || _a === void 0 ? void 0 : _a.name)) this.clientInfo = { name: "<unknown-client>" }; this._ready = true; while (this._readyPromises.length) { (_b = this._readyPromises.pop()) === null || _b === void 0 ? void 0 : _b.resolve(); } }; this.server.oninitialized = () => { void onInitialized(); }; this.server.setRequestHandler(types_js_1.SetLevelRequestSchema, async ({ params }) => { this.currentLogLevel = params.level; return {}; }); void this.detectProjectSetup(); } ready() { if (this._ready) return Promise.resolve(); return new Promise((resolve, reject) => { this._readyPromises.push({ resolve: resolve, reject }); }); } get clientName() { var _a, _b; return (_b = (_a = this.clientInfo) === null || _a === void 0 ? void 0 : _a.name) !== null && _b !== void 0 ? _b : ((0, env_1.isFirebaseStudio)() ? "Firebase Studio" : "<unknown-client>"); } get clientConfigKey() { return `mcp.clientConfigs.${this.clientName}:${this.startupRoot || process.cwd()}`; } getStoredClientConfig() { return configstore_1.configstore.get(this.clientConfigKey) || {}; } updateStoredClientConfig(update) { const config = configstore_1.configstore.get(this.clientConfigKey) || {}; const newConfig = Object.assign(Object.assign({}, config), update); configstore_1.configstore.set(this.clientConfigKey, newConfig); return newConfig; } async detectProjectSetup() { await this.detectProjectRoot(); await this.detectActiveFeatures(); } async detectProjectRoot() { await (0, timeout_1.timeoutFallback)(this.ready(), null, 2000); if (this.cachedProjectDir) return this.cachedProjectDir; const storedRoot = this.getStoredClientConfig().projectRoot; this.cachedProjectDir = storedRoot || this.startupRoot || process.cwd(); this.logger.debug(`detected and cached project root: ${this.cachedProjectDir}`); return this.cachedProjectDir; } async detectActiveFeatures() { var _a; if ((_a = this.detectedFeatures) === null || _a === void 0 ? void 0 : _a.length) return this.detectedFeatures; this.logger.debug("detecting active features of Firebase MCP server..."); const projectId = (await this.getProjectId()) || ""; const accountEmail = await this.getAuthenticatedUser(); const ctx = this._createMcpContext(projectId, accountEmail); const detected = await Promise.all(types_1.SERVER_FEATURES.map(async (f) => { const availabilityCheck = (0, availability_1.getDefaultFeatureAvailabilityCheck)(f); if (await availabilityCheck(ctx)) return f; return null; })); this.detectedFeatures = detected.filter((f) => !!f); this.logger.debug(`detected features of Firebase MCP server: ${this.detectedFeatures.join(", ") || "<none>"}`); return this.detectedFeatures; } async getEmulatorHubClient() { if (this.emulatorHubClient) { return this.emulatorHubClient; } const projectId = await this.getProjectId(); this.emulatorHubClient = new hubClient_1.EmulatorHubClient(projectId); return this.emulatorHubClient; } async getEmulatorUrl(emulatorType) { const hubClient = await this.getEmulatorHubClient(); if (!hubClient) { throw Error("Emulator Hub not found or is not running. You can start the emulator by running `firebase emulators:start` in your firebase project directory."); } const emulators = await hubClient.getEmulators(); const emulatorInfo = emulators[emulatorType]; if (!emulatorInfo) { throw Error(`No ${emulatorType} Emulator found running. Make sure your project firebase.json file includes ${emulatorType} and then rerun emulator using \`firebase emulators:start\` from your project directory.`); } const host = emulatorInfo.host.includes(":") ? `[${emulatorInfo.host}]` : emulatorInfo.host; return `http://${host}:${emulatorInfo.port}`; } async getAvailableTools() { var _a; const features = ((_a = this.activeFeatures) === null || _a === void 0 ? void 0 : _a.length) ? this.activeFeatures : this.detectedFeatures; const projectId = (await this.getProjectId()) || ""; const accountEmail = await this.getAuthenticatedUser(); const ctx = this._createMcpContext(projectId, accountEmail); return (0, index_1.availableTools)(ctx, features); } async getTool(name) { const tools = await this.getAvailableTools(); return tools.find((t) => t.mcp.name === name) || null; } async getAvailablePrompts() { var _a; const features = ((_a = this.activeFeatures) === null || _a === void 0 ? void 0 : _a.length) ? this.activeFeatures : this.detectedFeatures; const projectId = (await this.getProjectId()) || ""; const accountEmail = await this.getAuthenticatedUser(); const ctx = this._createMcpContext(projectId, accountEmail); return (0, index_2.availablePrompts)(ctx, features); } async getPrompt(name) { const prompts = await this.getAvailablePrompts(); return prompts.find((p) => p.mcp.name === name) || null; } setProjectRoot(newRoot) { this.updateStoredClientConfig({ projectRoot: newRoot }); this.cachedProjectDir = newRoot || undefined; this.detectedFeatures = undefined; void this.server.sendToolListChanged(); void this.server.sendPromptListChanged(); } async resolveOptions() { const options = { cwd: this.cachedProjectDir, isMCP: true }; await cmd.prepare(options); return options; } async getProjectId() { return (0, projectUtils_1.getProjectId)(await this.resolveOptions()); } async getAuthenticatedUser(skipAutoAuth = false) { try { this.logger.debug("calling requireAuth"); const email = await (0, requireAuth_1.requireAuth)(await this.resolveOptions(), skipAutoAuth); this.logger.debug(`detected authenticated account: ${email || "<none>"}`); return email !== null && email !== void 0 ? email : (skipAutoAuth ? null : "Application Default Credentials"); } catch (e) { this.logger.debug(`error in requireAuth: ${e}`); return null; } } _createMcpContext(projectId, accountEmail) { const options = { projectDir: this.cachedProjectDir, cwd: this.cachedProjectDir }; return { projectId: projectId, host: this, config: config_1.Config.load(options, true) || new config_1.Config({}, options), rc: (0, rc_1.loadRC)(options), accountEmail, firebaseCliCommand: this._getFirebaseCliCommand(), }; } _getFirebaseCliCommand() { if (!this.cliCommand) { const testCommand = crossSpawn.sync("firebase --version"); this.cliCommand = testCommand.error ? "npx firebase-tools@latest" : "firebase"; } return this.cliCommand; } async mcpListTools() { await Promise.all([this.detectActiveFeatures(), this.detectProjectRoot()]); const hasActiveProject = !!(await this.getProjectId()); await this.trackGA4("mcp_list_tools"); const skipAutoAuthForStudio = (0, env_1.isFirebaseStudio)(); this.logger.debug(`skip auto-auth in studio environment: ${skipAutoAuthForStudio}`); const availableTools = await this.getAvailableTools(); return { tools: availableTools.map((t) => t.mcp), _meta: { projectRoot: this.cachedProjectDir, projectDetected: hasActiveProject, authenticatedUser: await this.getAuthenticatedUser(skipAutoAuthForStudio), activeFeatures: this.activeFeatures, detectedFeatures: this.detectedFeatures, }, }; } async mcpCallTool(request) { var _a, _b, _c, _d; await this.detectProjectRoot(); const toolName = request.params.name; const toolArgs = request.params.arguments; const tool = await this.getTool(toolName); if (!tool) throw new Error(`Tool '${toolName}' could not be found.`); if (!((_a = tool.mcp._meta) === null || _a === void 0 ? void 0 : _a.optionalProjectDir)) { if (!this.cachedProjectDir || !(0, node_fs_1.existsSync)(this.cachedProjectDir)) { return (0, errors_1.noProjectDirectory)(this.cachedProjectDir); } } let projectId = await this.getProjectId(); if (((_b = tool.mcp._meta) === null || _b === void 0 ? void 0 : _b.requiresProject) && !projectId) { return errors_1.NO_PROJECT_ERROR; } projectId = projectId || ""; const skipAutoAuthForStudio = (0, env_1.isFirebaseStudio)(); const accountEmail = await this.getAuthenticatedUser(skipAutoAuthForStudio); if (((_c = tool.mcp._meta) === null || _c === void 0 ? void 0 : _c.requiresAuth) && !accountEmail) { return (0, errors_1.mcpAuthError)(skipAutoAuthForStudio); } if ((_d = tool.mcp._meta) === null || _d === void 0 ? void 0 : _d.requiresGemini) { const err = await (0, errors_1.requireGeminiToS)(projectId); if (err) return err; } const toolsCtx = this._createMcpContext(projectId, accountEmail); try { const res = await tool.fn(toolArgs, toolsCtx); await this.trackGA4("mcp_tool_call", { tool_name: toolName, error: res.isError ? 1 : 0, }); return res; } catch (err) { await this.trackGA4("mcp_tool_call", { tool_name: toolName, error: 1, }); return (0, util_1.mcpError)(err); } } async mcpListPrompts() { await Promise.all([this.detectActiveFeatures(), this.detectProjectRoot()]); const hasActiveProject = !!(await this.getProjectId()); await this.trackGA4("mcp_list_prompts"); const skipAutoAuthForStudio = (0, env_1.isFirebaseStudio)(); return { prompts: (await this.getAvailablePrompts()).map((p) => ({ name: p.mcp.name, description: p.mcp.description, annotations: p.mcp.annotations, arguments: p.mcp.arguments, })), _meta: { projectRoot: this.cachedProjectDir, projectDetected: hasActiveProject, authenticatedUser: await this.getAuthenticatedUser(skipAutoAuthForStudio), activeFeatures: this.activeFeatures, detectedFeatures: this.detectedFeatures, }, }; } async mcpGetPrompt(req) { await this.detectProjectRoot(); const promptName = req.params.name; const promptArgs = req.params.arguments || {}; const prompt = await this.getPrompt(promptName); if (!prompt) { throw new Error(`Prompt '${promptName}' could not be found.`); } let projectId = await this.getProjectId(); projectId = projectId || ""; const skipAutoAuthForStudio = (0, env_1.isFirebaseStudio)(); const accountEmail = await this.getAuthenticatedUser(skipAutoAuthForStudio); const promptsCtx = this._createMcpContext(projectId, accountEmail); try { const messages = await prompt.fn(promptArgs, promptsCtx); await this.trackGA4("mcp_get_prompt", { tool_name: promptName, }); return { messages, }; } catch (err) { await this.trackGA4("mcp_get_prompt", { tool_name: promptName, error: 1, }); throw err; } } async mcpListResources() { await (0, track_1.trackGA4)("mcp_read_resource", { resource_name: "__list__" }); return { resources: resources_1.resources.map((r) => r.mcp), }; } async mcpListResourceTemplates() { return { resourceTemplates: resources_1.resourceTemplates.map((rt) => rt.mcp), }; } async mcpReadResource(req) { let projectId = await this.getProjectId(); projectId = projectId || ""; const skipAutoAuthForStudio = (0, env_1.isFirebaseStudio)(); const accountEmail = await this.getAuthenticatedUser(skipAutoAuthForStudio); const resourceCtx = this._createMcpContext(projectId, accountEmail); const resolved = await (0, resources_1.resolveResource)(req.params.uri, resourceCtx); if (!resolved) { throw new types_js_1.McpError(types_js_1.ErrorCode.InvalidParams, `Resource '${req.params.uri}' could not be found.`); } return resolved.result; } async start() { const transport = process.env.FIREBASE_MCP_DEBUG_LOG ? new logging_transport_1.LoggingStdioServerTransport(process.env.FIREBASE_MCP_DEBUG_LOG) : new stdio_js_1.StdioServerTransport(); await this.server.connect(transport); } get logger() { const logAtLevel = (level, message) => { let data = message; if (typeof message === "string") { data = { message }; } if (!this.currentLogLevel) { return; } if (orderedLogLevels.indexOf(this.currentLogLevel) > orderedLogLevels.indexOf(level)) { return; } if (this._ready) { while (this._pendingMessages.length) { const message = this._pendingMessages.shift(); if (!message) continue; this.server.sendLoggingMessage({ level: message.level, data: message.data, }); } void this.server.sendLoggingMessage({ level, data }); } else { this._pendingMessages.push({ level, data }); } }; return Object.fromEntries(orderedLogLevels.map((logLevel) => [ logLevel, (message) => logAtLevel(logLevel, message), ])); } } exports.FirebaseMcpServer = FirebaseMcpServer;