From 84ab9ee159e96911a3c51e040133033349b393f6 Mon Sep 17 00:00:00 2001 From: Fred Zhang Date: Wed, 17 Sep 2025 09:34:56 -0700 Subject: [PATCH 1/7] [FDC init] Handle error of template create (#9119) * handle error * m --- src/init/features/dataconnect/sdk.ts | 29 ++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/src/init/features/dataconnect/sdk.ts b/src/init/features/dataconnect/sdk.ts index 45223ca9bf6..74b6ee40377 100644 --- a/src/init/features/dataconnect/sdk.ts +++ b/src/init/features/dataconnect/sdk.ts @@ -75,18 +75,23 @@ export async function askQuestions(setup: Setup): Promise { { name: "no", value: "no" }, ], }); - switch (choice) { - case "react": - await createReactApp(newUniqueId("web-app", listFiles(cwd))); - break; - case "next": - await createNextApp(newUniqueId("web-app", listFiles(cwd))); - break; - case "flutter": - await createFlutterApp(newUniqueId("flutter_app", listFiles(cwd))); - break; - case "no": - break; + try { + switch (choice) { + case "react": + await createReactApp(newUniqueId("web-app", listFiles(cwd))); + break; + case "next": + await createNextApp(newUniqueId("web-app", listFiles(cwd))); + break; + case "flutter": + await createFlutterApp(newUniqueId("flutter_app", listFiles(cwd))); + break; + case "no": + break; + } + } catch (err: unknown) { + // The detailed error message are already piped into stderr. No need to repeat here. + logLabeledError("dataconnect", `Failed to create a ${choice} app template`); } } From 2a443b79872ec9e7a91829390ad6246902723cad Mon Sep 17 00:00:00 2001 From: Bryan Kendall Date: Wed, 17 Sep 2025 14:11:36 -0700 Subject: [PATCH 2/7] 14.17.0 --- npm-shrinkwrap.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index fe4455c77aa..da3dbd58b10 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -1,12 +1,12 @@ { "name": "firebase-tools", - "version": "14.16.0", + "version": "14.17.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "firebase-tools", - "version": "14.16.0", + "version": "14.17.0", "license": "MIT", "dependencies": { "@electric-sql/pglite": "^0.3.3", diff --git a/package.json b/package.json index 8e5a3dc47db..c9e387c3d4a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "firebase-tools", - "version": "14.16.0", + "version": "14.17.0", "description": "Command-Line Interface for Firebase", "main": "./lib/index.js", "bin": { From 05c53e87f46d51c622b880c7357acf3d24693984 Mon Sep 17 00:00:00 2001 From: Bryan Kendall Date: Wed, 17 Sep 2025 14:12:24 -0700 Subject: [PATCH 3/7] clear changelog for v14.17.0 release --- CHANGELOG.md | 1 - 1 file changed, 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ceccf65fcd5..e69de29bb2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1 +0,0 @@ -- Change `dataconnect`'s default region to `us-east4` (#9126) From ece5bcd48e32e22bc3680c172b9f81f11c3238bd Mon Sep 17 00:00:00 2001 From: Max Lord Date: Wed, 17 Sep 2025 16:42:29 -0400 Subject: [PATCH 4/7] (feat) Crashlytics tools improvements (#9138) - Improving instructions and return values in crashlytics tools - Making interval handling more robust - Adding return messages to avoid undefined tool responses --- src/crashlytics/events.spec.ts | 52 +++++++++++++++++++++++++- src/crashlytics/events.ts | 43 ++++++++++++++++++++- src/crashlytics/filters.ts | 10 ++++- src/crashlytics/issues.spec.ts | 2 +- src/crashlytics/issues.ts | 2 +- src/crashlytics/notes.ts | 3 +- src/crashlytics/types.ts | 28 +++++++++----- src/mcp/prompts/crashlytics/connect.ts | 12 ++++-- src/mcp/tools/crashlytics/notes.ts | 3 +- src/mcp/tools/crashlytics/reports.ts | 4 ++ 10 files changed, 137 insertions(+), 22 deletions(-) diff --git a/src/crashlytics/events.spec.ts b/src/crashlytics/events.spec.ts index a6b74e11854..0c7c45afb0a 100644 --- a/src/crashlytics/events.spec.ts +++ b/src/crashlytics/events.spec.ts @@ -2,7 +2,7 @@ import * as chai from "chai"; import * as nock from "nock"; import * as chaiAsPromised from "chai-as-promised"; -import { listEvents } from "./events"; +import { listEvents, batchGetEvents } from "./events"; import { FirebaseError } from "../error"; import { crashlyticsApiOrigin } from "../api"; @@ -82,4 +82,54 @@ describe("events", () => { ).to.be.rejectedWith(FirebaseError, "Unable to get the projectId from the AppId."); }); }); + + describe("batchGetEvents", () => { + const eventNames = [ + "projects/1234567890/apps/1:1234567890:android:abcdef1234567890/events/test_event_id_1", + "projects/1234567890/apps/1:1234567890:android:abcdef1234567890/events/test_event_id_2", + ]; + + it("should resolve with the response body on success", async () => { + const mockResponse = { + events: [{ id: "test_event_id_1" }, { id: "test_event_id_2" }], + }; + + nock(crashlyticsApiOrigin()) + .get(`/v1alpha/projects/${requestProjectNumber}/apps/${appId}/events:batchGet`) + .query({ + "event.names": eventNames, + }) + .reply(200, mockResponse); + + const result = await batchGetEvents(appId, eventNames); + + expect(result).to.deep.equal(mockResponse); + expect(nock.isDone()).to.be.true; + }); + + it("should throw a FirebaseError if the API call fails", async () => { + nock(crashlyticsApiOrigin()) + .get(`/v1alpha/projects/${requestProjectNumber}/apps/${appId}/events:batchGet`) + .query({ + "event.names": eventNames, + }) + .reply(500, { error: "Internal Server Error" }); + + await expect(batchGetEvents(appId, eventNames)).to.be.rejectedWith( + FirebaseError, + `Failed to batch get events for app_id ${appId}.`, + ); + }); + + it("should throw a FirebaseError if there are too many events", async () => { + const tooManyEventNames = Array.from(Array(101).keys()).map( + (i) => + `projects/1234567890/apps/1:1234567890:android:abcdef1234567890/events/test_event_id_${i}`, + ); + await expect(batchGetEvents(appId, tooManyEventNames)).to.be.rejectedWith( + FirebaseError, + "Too many events in batchGet request", + ); + }); + }); }); diff --git a/src/crashlytics/events.ts b/src/crashlytics/events.ts index d1352e3d0fa..0727ef26d73 100644 --- a/src/crashlytics/events.ts +++ b/src/crashlytics/events.ts @@ -1,7 +1,7 @@ import { logger } from "../logger"; import { FirebaseError, getError } from "../error"; import { CRASHLYTICS_API_CLIENT, parseProjectNumber, TIMEOUT } from "./utils"; -import { ListEventsResponse } from "./types"; +import { BatchGetEventsResponse, ListEventsResponse } from "./types"; import { EventFilter, filterToUrlSearchParams } from "./filters"; /** @@ -43,3 +43,44 @@ export async function listEvents( }); } } + +/** + * Get multiple events by resource name. + * Can be used with the `sampleEvent` resource included in topIssues reports. + * @param appId Firebase app_id + * @param eventNames the resource names for the desired events. + * Format: "projects/{project}/apps/{app_id}/events/{event_id}" + * @return A BatchGetEventsResponse including an array of Event. + */ +export async function batchGetEvents( + appId: string, + eventNames: string[], +): Promise { + const requestProjectNumber = parseProjectNumber(appId); + if (eventNames.length > 100) throw new FirebaseError("Too many events in batchGet request"); + logger.debug( + `[crashlytics] batchGetEvents called with appId: ${appId}, eventNames: ${eventNames.join(", ")}`, + ); + const queryParams = new URLSearchParams(); + eventNames.forEach((en) => { + queryParams.append("event.names", en); + }); + + try { + const response = await CRASHLYTICS_API_CLIENT.request({ + method: "GET", + headers: { + "Content-Type": "application/json", + }, + path: `/projects/${requestProjectNumber}/apps/${appId}/events:batchGet`, + queryParams: queryParams, + timeout: TIMEOUT, + }); + + return response.body; + } catch (err: unknown) { + throw new FirebaseError(`Failed to batch get events for app_id ${appId}.`, { + original: getError(err), + }); + } +} diff --git a/src/crashlytics/filters.ts b/src/crashlytics/filters.ts index 2d64ea08cc1..f3ceb26120c 100644 --- a/src/crashlytics/filters.ts +++ b/src/crashlytics/filters.ts @@ -14,8 +14,14 @@ export const IssueIdSchema = z.string().describe("Crashlytics issue id, as hexid export const EventFilterSchema = z .object({ - intervalStartTime: z.string().optional().describe(`A timestamp in ISO 8601 string format`), - intervalEndTime: z.string().optional().describe(`A timestamp in ISO 8601 string format.`), + intervalStartTime: z + .string() + .optional() + .describe(`A timestamp in ISO 8601 string format. Defaults to 7 days ago.`), + intervalEndTime: z + .string() + .optional() + .describe(`A timestamp in ISO 8601 string format. Defaults to now.`), versionDisplayNames: z .array(z.string()) .optional() diff --git a/src/crashlytics/issues.spec.ts b/src/crashlytics/issues.spec.ts index 277c25fc9ba..aca3f8a831e 100644 --- a/src/crashlytics/issues.spec.ts +++ b/src/crashlytics/issues.spec.ts @@ -68,7 +68,7 @@ describe("issues", () => { nock(crashlyticsApiOrigin()) .patch(`/v1alpha/projects/${requestProjectNumber}/apps/${appId}/issues/${issueId}`, { - issue: { state: state }, + state, }) .query({ updateMask: "state" }) .reply(200, mockResponse); diff --git a/src/crashlytics/issues.ts b/src/crashlytics/issues.ts index e6f500a43d8..b3881b030ad 100644 --- a/src/crashlytics/issues.ts +++ b/src/crashlytics/issues.ts @@ -51,7 +51,7 @@ export async function updateIssue(appId: string, issueId: string, state: State): }, path: `/projects/${requestProjectNumber}/apps/${appId}/issues/${issueId}`, queryParams: { updateMask: "state" }, - body: { issue: { state } }, + body: { state }, timeout: TIMEOUT, }); diff --git a/src/crashlytics/notes.ts b/src/crashlytics/notes.ts index 66684173a73..79c178507c8 100644 --- a/src/crashlytics/notes.ts +++ b/src/crashlytics/notes.ts @@ -44,7 +44,7 @@ export async function createNote(appId: string, issueId: string, note: string): * @param issueId Crashlytics issue id * @param noteId Crashlytics note id */ -export async function deleteNote(appId: string, issueId: string, noteId: string): Promise { +export async function deleteNote(appId: string, issueId: string, noteId: string): Promise { const requestProjectNumber = parseProjectNumber(appId); logger.debug( @@ -56,6 +56,7 @@ export async function deleteNote(appId: string, issueId: string, noteId: string) path: `/projects/${requestProjectNumber}/apps/${appId}/issues/${issueId}/notes/${noteId}`, timeout: TIMEOUT, }); + return `Deleted note ${noteId}`; } catch (err: unknown) { throw new FirebaseError( `Failed to delete note ${noteId} from issue ${issueId} for app ${appId}`, diff --git a/src/crashlytics/types.ts b/src/crashlytics/types.ts index 97f266aa809..20d7993c5ed 100644 --- a/src/crashlytics/types.ts +++ b/src/crashlytics/types.ts @@ -458,16 +458,12 @@ export enum ThreadState { /** Request message for the ListEvents method. */ export interface ListEventsRequest { - /** The Firebase application. Formatted like "projects/{project}/apps/{app_id}" */ - parent: string; /** The maximum number of events per page. If omitted, defaults to 10. */ pageSize?: number; /** A page token, received from a previous calls. */ pageToken?: string; /** Filter only the desired events. */ filter?: EventFilters; - /** The list of Event fields to include in the response. If omitted, the full event is returned. */ - readMask?: string; } /** Response message for the ListEvents method. */ @@ -478,6 +474,22 @@ export interface ListEventsResponse { nextPageToken?: string; } +/** Request message for the BatchGetEvents method. */ +export interface BatchGetEventsRequest { + /** + * The resource names of the desired events. + * A maximum of 100 events can be retrieved in a batch. + * Format: "projects/{project}/apps/{app_id}/events/{event_id}" + */ + names: string[]; +} + +/** Response message for the BatchGetEvents method. */ +export interface BatchGetEventsResponse { + /** Returns one or more events. */ + events: Event[]; +} + /** * Filters for ListEvents method. * Multiple conditions for the same field are combined in an ‘OR’ expr @@ -590,8 +602,6 @@ export interface DeviceFilter { /** The request method for the GetReport method. */ export interface GetReportRequest { - /** The report name. Formatted like "projects/{project}/apps/{app_id}/reports/{report}". */ - name: string; /** Filters to customize the report. */ filter?: ReportFilters; /** The maximum number of result groups to return. If omitted, defaults to 25. */ @@ -637,8 +647,6 @@ export interface ReportFilters { /** Request message for the UpdateIssue method. */ export interface UpdateIssueRequest { - /** The issue to update. */ - issue: Issue; - /** The list of Issue fields to update. Currently only "state" is mutable. */ - updateMask?: string; + /** Only the "state" field is mutable. */ + state: State; } diff --git a/src/mcp/prompts/crashlytics/connect.ts b/src/mcp/prompts/crashlytics/connect.ts index 24549469833..43b3ed59d3b 100644 --- a/src/mcp/prompts/crashlytics/connect.ts +++ b/src/mcp/prompts/crashlytics/connect.ts @@ -53,8 +53,11 @@ they would like to perform. Here are some possibilities and instructions follow Follow these steps to fetch issues and prioritize them. - 1. Use the 'crashlytics_list_top_issues' tool to fetch up to 20 issues. - 2. Use the 'crashlytics_list_top_versions' tool to fetch the top versions for this app. + 1. Use the 'crashlytics_get_top_issues' tool to fetch up to 20 issues. + 1a. Analyze the user's query and apply the appropriate filters. + 1b. If the user asks for crashes, then set the issueErrorType filter to *FATAL*. + 1c. If the user asks about a particular time range, then set both the intervalStartTime and intervalEndTime. + 2. Use the 'crashlytics_get_top_versions' tool to fetch the top versions for this app. 3. If the user instructions include statements about prioritization, use those instructions. 4. If the user instructions do not include statements about prioritization, then prioritize the returned issues using the following criteria: @@ -73,8 +76,9 @@ Follow these steps to fetch issues and prioritize them. Follow these steps to diagnose and fix issues. 1. Make sure you have a good understanding of the code structure and where different functionality exists - 2. Use the 'crashlytics_get_issue_details' tool to get more context on the issue. - 3. Use the 'crashlytics_get_sample_crash_for_issue' tool to get 3 example crashes for this issue. + 2. Use the 'crashlytics_get_issue' tool to get more context on the issue. + 3. Use the 'crashlytics_list_events' tool to get an example crash for this issue. + 3a. Apply the same filtering criteria that you used to find the issue, so that you find an appropriate event. 4. Read the files that exist in the stack trace of the issue to understand the crash deeply. 5. Determine the root cause of the crash. 6. Write out a plan using the following criteria: diff --git a/src/mcp/tools/crashlytics/notes.ts b/src/mcp/tools/crashlytics/notes.ts index f28c18b4db6..f312d415b63 100644 --- a/src/mcp/tools/crashlytics/notes.ts +++ b/src/mcp/tools/crashlytics/notes.ts @@ -66,7 +66,8 @@ export const delete_note = tool( }), annotations: { title: "Delete Crashlytics Issue Note", - readOnlyHint: true, + readOnlyHint: false, + destructiveHint: true, }, _meta: { requiresAuth: true, diff --git a/src/mcp/tools/crashlytics/reports.ts b/src/mcp/tools/crashlytics/reports.ts index 87d52930187..b241b926051 100644 --- a/src/mcp/tools/crashlytics/reports.ts +++ b/src/mcp/tools/crashlytics/reports.ts @@ -16,6 +16,10 @@ function getReportContent( return async ({ appId, filter, pageSize }) => { if (!appId) return mcpError(`Must specify 'appId' parameter.`); filter ??= {}; + if (!!filter.intervalStartTime && !filter.intervalEndTime) { + // interval.end_time is required if interval.start_time is set but the agent likes to forget it + filter.intervalEndTime = new Date().toISOString(); + } return toContent(await getReport(report, appId, filter, pageSize)); }; } From 3cc12e7a4be6e0d3c4668eac69704b513a002a62 Mon Sep 17 00:00:00 2001 From: Cleo Schneider Date: Fri, 19 Sep 2025 12:48:30 -0400 Subject: [PATCH 5/7] Tweak the connect prompt to look at gitignored files and to be less eager to do more than check your login status (#9133) * Tweak the connect prompt to look at gitignored files and to be less eager to do more than check your login status * Loosen prescriptiveness, ease testing proactivity, come up with multiple root causes * Respond to prompt review comments. --- src/mcp/prompts/crashlytics/connect.ts | 71 +++++++++++++++++--------- 1 file changed, 47 insertions(+), 24 deletions(-) diff --git a/src/mcp/prompts/crashlytics/connect.ts b/src/mcp/prompts/crashlytics/connect.ts index 43b3ed59d3b..53b0f95dd5a 100644 --- a/src/mcp/prompts/crashlytics/connect.ts +++ b/src/mcp/prompts/crashlytics/connect.ts @@ -24,19 +24,36 @@ Active user: ${accountEmail || ""} ## Required first steps! Absolutely required! Incredibly important! 1. **Make sure the user is logged in. No Crashlytics tools will work if the user is not logged in.** - a. Use the \`firebase_get_environment\` tool to verify that the user is logged in, - and find the active Firebase project. + a. Use the \`firebase_get_environment\` tool to verify that the user is logged in. b. If the Firebase 'Active user' is set to , instruct the user to run \`firebase login\` - before continuing. - - 2. **Get the app_id for the Firebase application.** - a. If this is an Android app, read the mobilesdk_app_id value specified in the - google-services.json file. If there are multiple files or multiple app ids in a - single file, ask the user to choose one by providing a numbered list of all the package names. - b. If this is an iOS app, read the GOOGLE_APP_ID from GoogleService-Info.plist file. - If there are multiple files or multiple app ids in single file, ask the user to - choose one by providing a numbered list of all the bundle names. - c. If you can't find either of the above, just ask the user for the app id. + before continuing. Ignore other fields that are set to . We are just making sure the + user is logged in. + + 2. **Get the app ID for the Firebase application.** + + Use the information below to help you find the developer's app ID. If you cannot find it after 2-3 + attempts, just ask the user for the value they want to use, providing the description of what the + value looks like. + + * **Description:** The app ID we are looking for contains four colon (":") delimited parts: a version + number (typically "1"), a project number, a platform type ("android", "ios", or "web"), + and a sequence of hexadecimal characters. This can be found in the project settings in the Firebase Console + or in the appropriate google services file for the application type. + * For Android apps, you will typically find the app ID in a file called google-services.json under the + mobilesdk_app_id key. The file is most often located in the app directory that contains the src directory. + * For iOS apps, you will typically find the app ID in a property list file called GoogleService-Info.plist under the + GOOGLE_APP_ID key. The plist file is most often located in the main project directory. + * Sometimes developers will not check in the google services file because it is a shared or public + repository. If you can't find the file, the files may be included in the .gitignore. Check again for the file + removing restrictions around looking for tracked files. + * Developers may have multiple google services files that map to different releases. In cases like this, + developers may create different directories to hold each like alpha/google-services.json or alpha/GoogleService-Info.plist. + In other cases, developers may change the suffix of the file to something like google-services-alpha.json or + GoogleService-Alpha.plist. Look for as many google services files as you can find. + * Sometimes developers may include the codebase for both the Android app and the iOS app in the same repository. + * If there are multiple files or multiple app IDs in a single file, ask the user to choose one by providing + a numbered list of all the package names. + * Again, if you have trouble finding the app ID, just ask the user for it. ## Next steps @@ -70,6 +87,7 @@ Follow these steps to fetch issues and prioritize them. * * **Description:** * **Rationale:** + 6. Ask the user if they would like to diagnose and fix any of the issues presented ### How to diagnose and fix issues @@ -80,16 +98,18 @@ Follow these steps to diagnose and fix issues. 3. Use the 'crashlytics_list_events' tool to get an example crash for this issue. 3a. Apply the same filtering criteria that you used to find the issue, so that you find an appropriate event. 4. Read the files that exist in the stack trace of the issue to understand the crash deeply. - 5. Determine the root cause of the crash. - 6. Write out a plan using the following criteria: - 6a. Write out a description of the issue and including + 5. Determine possible root causes for the crash - no more than 5 potential root causes. + 6. Critique your own determination, analyzing how plausible each scenario is given the crash details. + 7. Choose the most likely root cause given your analysis. + 8. Write out a plan for the most likely root cause using the following criteria: + 8a. Write out a description of the issue and including * A brief description of the cause of the issue - * A determination of your level of confidence in the cause of the issue + * A determination of your level of confidence in the cause of the issue using your analysis. * A determination of which library is at fault, this codebase or a dependent library * A determination for how complex the fix will be - 6b. The plan should include relevant files to change - 6c. The plan should include a test plan to verify the fix - 6d. Use the following format for the plan: + 8b. The plan should include relevant files to change + 8c. The plan should include a test plan for how the user might verify the fix + 8d. Use the following format for the plan: ## Cause @@ -105,12 +125,15 @@ Follow these steps to diagnose and fix issues. 1. 2. + + ## Other potential causes + 1. + 2. - 7. Present the plan to the user and get approval before making the change. - 8. Fix the issue. - 8a. Be mindful of API contracts and do not add fields to resources without a clear way to populate those fields - 8b. If there is not enough information in the crash report to find a root cause, describe why you cannot fix the issue instead of making a guess. - 9. Ask the developer if they would like you to test the fix for them. + 9. Present the plan to the user and get approval before making the change. + 10. Only if they approve the plan, create a fix for the issue. + 10a. Be mindful of API contracts and do not add fields to resources without a clear way to populate those fields + 10b. If there is not enough information in the crash report to find a root cause, describe why you cannot fix the issue instead of making a guess. `.trim(), }, }, From b1bdeca2bed12587c93378b2a3051e9b8e12f0d2 Mon Sep 17 00:00:00 2001 From: Fred Zhang Date: Fri, 19 Sep 2025 11:58:24 -0700 Subject: [PATCH 6/7] [VS Code] Remove unused codes associated with gca cmds (#9117) --- .../data-connect/ai-tools/gca-tool-types.ts | 509 ------------------ .../src/data-connect/ai-tools/gca-tool.ts | 238 -------- .../data-connect/ai-tools/tool-controller.ts | 289 ---------- .../src/data-connect/ai-tools/types.ts | 25 - firebase-vscode/src/data-connect/index.ts | 13 +- firebase-vscode/src/data-connect/service.ts | 59 +- .../cloudAICompanionClient.spec.ts | 94 ---- src/dataconnect/cloudAICompanionClient.ts | 106 ---- 8 files changed, 4 insertions(+), 1329 deletions(-) delete mode 100644 firebase-vscode/src/data-connect/ai-tools/gca-tool-types.ts delete mode 100644 firebase-vscode/src/data-connect/ai-tools/gca-tool.ts delete mode 100644 firebase-vscode/src/data-connect/ai-tools/tool-controller.ts delete mode 100644 firebase-vscode/src/data-connect/ai-tools/types.ts delete mode 100644 src/dataconnect/cloudAICompanionClient.spec.ts delete mode 100644 src/dataconnect/cloudAICompanionClient.ts diff --git a/firebase-vscode/src/data-connect/ai-tools/gca-tool-types.ts b/firebase-vscode/src/data-connect/ai-tools/gca-tool-types.ts deleted file mode 100644 index 183b81354f4..00000000000 --- a/firebase-vscode/src/data-connect/ai-tools/gca-tool-types.ts +++ /dev/null @@ -1,509 +0,0 @@ -import { - CancellationToken, - Disposable, - MarkdownString, - ThemeIcon, - Uri, -} from "vscode"; - -/** - * The public API for Gemini Code Assist to be utilized by external providers to - * extend Gemini Code Assist functionality. - */ -export interface GeminiCodeAssist extends Disposable { - /** - * Registers the caller as a tool for Gemini Code Assist. The tool will be - * identified to the end user through the id parameter. The tool will further - * identify itself through the extension id. An extension may choose to - * register any number of tools. - * @param id The id to use when referring to the tool. For example this may - * be `gemini` so that the tool will be addressed as `@gemini` by the user. - * Note that this id cannot be reused by another tool or other entity like - * a variable provider. - * @param displayName The name of the tool, to be used when referring to the - * tool in chat. - * @param extensionId The extension that implements the tool. The tool's - * icon will be loaded from this extension by default and used when - * displaying the tool's participation in chat. - * @param iconPath The path to the tool's icon, can be an icon for any theme, - * contain a dark and light icon or be a ThemeIcon type. The iconPath should be a join - * of the extension path and the relative path to the icon. - * @param command A command for Gemini Code Assist to execute on activation. - * If this is specified by the tool registration Gemini Code Assist will wait - * for the tool's extension to activate and then execute the command - * specified. This can be used to allow the tool to guarantee registration - * whenever Gemini Code Assist is loaded. - * @return The tool's registration to be modified by the tool provider with - * the capabilities of the tool. - */ - registerTool( - id: string, - displayName: string, - extensionId: string, - iconPath?: Uri | { dark: Uri; light: Uri } | ThemeIcon, - command?: string, - ): GeminiTool; -} - -/** - * Represents a tool to Gemini Code Assist. This allows the external provider - * to provide specific services to Gemini Code Assist. Upon dispose this tool - * registration will be removed from Gemini Code Assist for this instance only. - * Upon subsequent activations Gemini Code Assist will attempt to execute the - * command that was specified in the tool's registration if any was specified. - */ -export interface GeminiTool extends Disposable { - /** - * Registers a handler for chat. This allows the tool to handle incoming - * chat requests. - * @param handler The chat handler method that will be called with the - * registered tool is called. - * @return Disposable for subscription purposes, calling dispose will remove - * the registration. - */ - registerChatHandler(handler: ChatHandler): Disposable; - - /** - * Registers a variable provider for the tool. Variable provider ids should - * be unique for the tool but other tools may choose to implement the same - * provider id. For example `@bug` could be registered to `@jira` and - * `@github`. - * @param id The variable provider id, used to isolate typeahead to a specific - * variable type. For example using `@bug` will allow users to limit - * typeahead to bugs only instead of anything that can be completed. - * @param provider The provider to register, this will provide both static - * resolution as well as dynamic resolution. - * @return Disposable for removing the variable provider from Gemini Code - * Assist. - */ - registerVariableProvider(id: string, provider: VariableProvider): Disposable; - - /** - * Registers a slash command provider for the tool. This allows the tool - * to provide slash commands for the user. For example `/list` can be - * registered to the `@jira` tool to list bugs assigned to the user. - * @param provider The slash command provider to be registered. - * @return Disposable for removing the command provider from Gemini Code - * Assist. - */ - registerCommandProvider(provider: CommandProvider): Disposable; - - /** - * Registers a suggested prompt provider for the tool. This allows the tool - * to provide suggestions of prompts that the user can either tab complete or - * click to use. - * @param provider The child provider to be registered. - * @return Disposable for removing the suggested prompt provider from Gemini - * Code Assist. - */ - registerSuggestedPromptProvider( - provider: SuggestedPromptProvider, - ): Disposable; -} - -/** - * Provides suggested prompts which serve as example queries for the tool. - * These suggested prompts allow the user to see specific examples when using - * the tool and give some guidance as to helpful prompts as a starting point for - * using the tool with Gemini Code Assist. - */ -export interface SuggestedPromptProvider { - /** - * Provides a list of suggested prompts for the tool that will be displayed to - * the user as examples or templates for using the tool. In this text the - * user can specify placeholder text as text surrounded by square brackets. - * For example a suggested prompt value of `/generate an api specification for - * [function]` provided by `apigee` would provide a suggested prompt of - * `@apigee /generate an api specification for [function]` and the user would - * be prompted to supply a value for the [function] placeholder. - */ - provideSuggestedPrompts(): string[]; -} - -/** - * Provides the chat handler functionality to Gemini Code Assist, allowing a - * tool to extend chat. Through this handler the tool can service chat - * requests, add context for Gemini chat requests, and/or rewrite the prompt - * before it is sent to the LLM service. - */ -export interface ChatHandler { - /** - * @param request The chat request, can be used to manipulate the prompt and - * reference parts. - */ - ( - request: ChatRequest, - responseStream: ChatResponseStream, - token: CancellationToken, - ): Promise; -} - -/** - * Provides support for variables through the `@` designator. For example - * `@repo` could represent the current repository for a SCM tool. This - * interface allows the tool to provide a static list of variables as well as - * a dynamic list. - */ -export interface VariableProvider { - /** - * Allows the tool to return a static list of variables that it supports. - * This list is not expected to change as the user is typing. - * @return Returns a list of variables instances. - */ - listVariables(): Promise; - - /** - * Allows for dynamic variable support. This function will allow the tool - * to resolve variables as the user types. - * @param part Current text part that the user has typed, this is what - * currently follows the `@` symbol in the user's prompt. - * @param limit The number of typeahead suggestions that the UI will show to - * the user at once. - * @param token Supports cancellation (user types an additional character). - * @return Returns a list of variable instances that match the type ahead. - */ - typeahead( - part: string, - limit: number, - token: CancellationToken, - ): Promise; -} - -/** - * Represents a variable instance, the name and description are used to display - * the variable to the user. The variable instance will be passed as is to the - * tool, so it can carry any additional context necessary. - */ -export interface Variable { - /** - * The name of the variable, this would be what the variable looks like to the - * user. - */ - name: string; - - /** - * The optional description of the variable to show the user in the UX. - */ - description?: string | MarkdownString; -} - -/** - * Provides support for commands through the `/` designator. This takes the - * form of `@tool /command`. - */ -export interface CommandProvider { - /** - * Lists the slash commands provided by the tool. - * @return Command gives a list of the commands provided by the tool. - */ - listCommands(): Promise; -} - -/** - * CommandDetail exports a command along with any other context the tool may - * want to have in coordination with the command. - */ -export interface CommandDetail { - /** - * The string that identifies the command in question. - */ - command: string; - - /** - * The optional description of the slash command to display to the user. - */ - description?: string | MarkdownString; - - /** - * The optional codicon of the slash command. - */ - icon?: string; -} - -/** - * CommandPromptPart is the part of the prompt that is associated with a slash - * command. - */ -export interface CommandPromptPart extends PromptPart { - /** - * The CommandDetail provided by the CommandProvider's listCommands() - * function. - */ - command: CommandDetail; -} - -/** - * Provides the context for the chat request. The context can be used to - * provide additional information to the LLM service. - */ -export interface ChatRequestContext { - /** - * Pushes a new context onto the context stack. - * @param context The context to push. - */ - push(context: ChatContext | VariableChatContext): void; -} - -/** - * Represents a context that can be used to provide additional information to the - * LLM service. - */ -export interface ChatContext { - /** - * The id of the reference that this context is associated with. - */ - id: string | Uri; - - /** - * Gets the text of the context. - */ - getText(): string; -} - -/** - * Represents a context for a variable in the prompt. - */ -export interface VariableChatContext extends ChatContext { - /** - * The variable that this context represents. - */ - variable: Variable; -} - -/** - * Represents a chat request which is comprised of a prompt and context. - */ -export interface ChatRequest { - /** - * The prompt of the chat request. This can be manipulated by the tool. - */ - prompt: ChatPrompt; - - /** - * The context for the request. This can be used by the tool to add context - * to the request. - */ - context: ChatRequestContext; -} - -/** - * Represents the current chat prompt. - */ -export interface ChatPrompt { - /** - * Used to retrieve all parts of the prompt, including the tool prompts. - * @return An array of all parts of the prompt in order that they appear. - */ - getPromptParts(): PromptPart[]; - - /** - * Removes the specified prompt part. - * @param part The prompt part to remove. - */ - deletePromptPart(part: PromptPart): void; - - /** - * Splices in prompt part(s) similarly to Array.splice(). This can be used to - * insert a number of prompt part(s) (including none) and can remove existing - * elements. - * @param index The starting index for the splice operation. - * @param remove The number of elements to remove. - * @param parts The prompt part(s) to insert. - */ - splice(index: number, remove: number, ...parts: PromptPart[]): void; - - /** - * Pushes the prompt part(s) into the chat prompt. These part(s) are appended - * similarly to array.push(). - * @param parts The prompt part(s) to push. - */ - push(...parts: PromptPart[]): void; - - /** - * Returns the string representation of the prompt. - */ - fullPrompt(): string; - - /** - * The length of the prompt in parts. - */ - length: number; -} - -/** - * Represents a prompt part that is provided by a tool. - */ -export interface PromptPart { - /** - * Gets the prompt of the prompt part. - */ - getPrompt(): string; -} - -/** - * Represents a prompt part that is provided by a tool. - */ -export interface ToolPromptPart extends PromptPart { - /** - * The id of the tool that provided the prompt part. - */ - toolId: string; - - /** - * The command of the prompt part. - */ - command: string; -} - -/** - * Represents a prompt part that refers to a variable. - */ -export interface VariablePromptPart extends PromptPart { - variable: Variable; -} - -/** - * Represents a stream of chat responses. Used by the tool to provide chat - * based responses to the user. This stream can be used to push both partial - * responses as well as to close the stream. - */ -export interface ChatResponseStream { - /** - * Pushes a new content onto the response stream. - * @param content The content to push. - */ - push(content: MarkdownString | Citation): void; - - /** - * Closes the steam and prevents the request from going to the LLM after tool - * processing. This can be utilized by the client for commands that are - * client only. Returning without calling close will result in the processed - * prompt and context being sent to the LLM for a result. - */ - close(): void; - - /** - * Adds a button handler to code responses that come back from the LLM. This - * allows the tool to present the user with a button attached to this response - * and on click process the code response. - * @param title The title of the button. - * @param handler The handler to execute when the user clicks on the button. - * The code block will be sent as an argument to the handler on execution as - * CodeHandlerCommandArgs. - * @param languageFilter Optional parameter, if this is specified the - * language specified on the block will be checked for a match against this - * and the button will be displayed on match. If this is not specified the - * buttone will be displayed on any language result. - */ - addCodeHandlerButton( - title: string, - handler: CodeHandler, - options: HandlerButtonOptions, - ): void; -} - -/** - * Method for handling code responses from the LLM attached to the response via - * ChatResponseStream.addCodeHandlerButton. - */ -export interface CodeHandler { - /** - * @param args The code block and language specifiers from the LLM response to - * handle. Called when the user clicks on a CodeHandlerButton. - */ - (args: CodeHandlerCommandArgs): void; -} - -/** - * The arguments that are sent when calling the command associated with the - * addCodeHandlerButton method. - */ -export interface CodeHandlerCommandArgs { - /** - * The code block that was attached to the code handler button. - */ - codeBlock: string; - /** - * The language specifier on the code block associated with the code handler - * button. - */ - language: string; -} - -/** - * Provides options for a code handler button. This allows the tool to - * specialize the way GCA handles specific code blocks when the agent is - * involved. - */ -export interface HandlerButtonOptions { - /** - * Optional parameter, if this is specified the language specified on the - * block will be checked for a match against this and the button will be - * displayed on match. If this is not specified the button will be displayed - * on any language result. - */ - languages?: RegExp; - - /** - * Optional, if this is specified the block will be either expanded or - * collapsed as specified. If this is not specified the built in default - * handler will be used. - */ - displayType?: BlockDisplayType; -} - -/** - * Specifies how code blocks should be handled in chat. - */ -export enum BlockDisplayType { - /** - * The code block will be expanded by default. - */ - Expanded, - - /** - * The code block will be collapsed by default. - */ - Collapsed, -} - -/** - * Represents the type of citation. - */ -export enum CitationType { - /** - * General citation where the link is from a unspecific source. - */ - Unknown, - - /** - * The citation originates from the user's machine, for example a file on the - * user's disk. - */ - Local, - - /** - * The citation comes from Github. - */ - Github, -} - -/** - * Represents a citation. - */ -export interface Citation { - /** - * The URI of the citation. - */ - uri: Uri; - - /** - * The license of the citation. - */ - license: string | undefined; - - /** - * The type of the citation. - */ - type: CitationType; -} diff --git a/firebase-vscode/src/data-connect/ai-tools/gca-tool.ts b/firebase-vscode/src/data-connect/ai-tools/gca-tool.ts deleted file mode 100644 index e93554a5802..00000000000 --- a/firebase-vscode/src/data-connect/ai-tools/gca-tool.ts +++ /dev/null @@ -1,238 +0,0 @@ -import { AnalyticsLogger } from "../../analytics"; -import { ExtensionBrokerImpl } from "../../extension-broker"; -import * as vscode from "vscode"; -import { DataConnectService } from "../service"; -import { - ChatPrompt, - ChatRequest, - ChatResponseStream, - CommandDetail, - CommandProvider, - GeminiCodeAssist, -} from "./gca-tool-types"; -import { insertToBottomOfActiveFile } from "../file-utils"; -import { ExtensionContext } from "vscode"; -import { Chat, Command } from "./types"; -import { GeminiToolController } from "./tool-controller"; -import { ChatMessage } from "../../dataconnect/cloudAICompanionTypes"; -export const DATACONNECT_TOOL_ID = "FirebaseDataConnect"; -const AT_DATACONNECT_TOOL_ID = `@${DATACONNECT_TOOL_ID}`; -export const DATACONNECT_DISPLAY_NAME = "Firebase Data Connect"; -export const SUGGESTED_PROMPTS = [ - "/generate_schema Create a schema for a pizza store", - "/generate_operation Create a mutations for all my types", -]; -const HELP_MESSAGE = ` -Welcome to the Data Connect Tool. -Usage: - ${AT_DATACONNECT_TOOL_ID} /generate_schema \n - ${AT_DATACONNECT_TOOL_ID} /generate_operation -`; - -export class GCAToolClient { - private history: Chat[] = []; - private icon = vscode.Uri.joinPath( - this.context.extensionUri, - "resources", - "firebase_dataconnect_logo.png", - ); - constructor( - private context: ExtensionContext, - private toolController: GeminiToolController, - ) {} - - async activate() { - const gemini = vscode.extensions.getExtension( - "google.geminicodeassist", - ); - if (!gemini || !gemini.isActive) { - throw new Error("Gemini extension not found"); // should never happen, gemini is an extension depedency - } - - gemini?.activate().then(async (gca) => { - const tool = gca.registerTool( - DATACONNECT_TOOL_ID, - DATACONNECT_DISPLAY_NAME, - "GoogleCloudTools.firebase-dataconnect-vscode", - this.icon, - "help", - ); - tool.registerChatHandler(this.handleChat.bind(this)); - tool.registerSuggestedPromptProvider(this); - tool.registerCommandProvider( - new DataConnectCommandProvider(this.icon.toString()), - ); - }); - } - - /** implementation of handleChat interface; - * We redirect the request to our controller - */ - async handleChat( - request: ChatRequest, - responseStream: ChatResponseStream, - token: vscode.CancellationToken, - ): Promise { - // Helper just to convert to markdown first - function pushToResponseStream(text: string) { - const markdown = new vscode.MarkdownString(text); - responseStream.push(markdown); - } - - // Adds the Graphql code block button "Insert to bottom of file" - addCodeHandlers(responseStream); - - let response: ChatMessage[]; - - // parse the prompt - if (!isPromptValid(request.prompt)) { - pushToResponseStream(HELP_MESSAGE); - responseStream.close(); - return; - } - const content = getPrompt(request.prompt); - const command = getCommand(request.prompt); - - // Forward to tool controller - try { - this.history.push({ author: "USER", content, commandContext: command }); - response = await this.toolController.handleChat( - content, - this.history, - command, - ); - } catch (error) { - let errorMessage = ""; - if (error instanceof Error) { - errorMessage = error.message; - } else if (typeof error === "string") { - errorMessage = error; - } - - pushToResponseStream(errorMessage); - - // reset history on error - this.history = []; - responseStream.close(); - return; - } - const agentMessage = response.pop()?.content; - - if (agentMessage) { - this.history.push({ author: "AGENT", content: agentMessage }); - } - - pushToResponseStream( - agentMessage || "Gemini encountered an error. Please try again.}", - ); - responseStream.close(); - } - - provideSuggestedPrompts(): string[] { - return SUGGESTED_PROMPTS; - } -} - -class DataConnectCommandProvider implements CommandProvider { - schemaCommand: CommandDetail = { - command: Command.GENERATE_SCHEMA, - description: "Generates a GraphQL schema based on a prompt", - icon: this.icon, - }; - - operationCommand: CommandDetail = { - command: Command.GENERATE_OPERATION, - description: "Generates a GraphQL query or mutation based on a prompt", - icon: this.icon, - }; - - helpCommand: CommandDetail = { - command: "help", - description: "Shows this help message", - icon: this.icon, - }; - constructor(readonly icon: string) {} - listCommands(): Promise { - const commands: CommandDetail[] = [ - this.schemaCommand, - this.operationCommand, - // this.helpCommand, - ]; - return Promise.resolve(commands); - } -} - -/** Exploring a variable provider for dataconnect introspected types */ -// class DataConnectTypeVariableProvider implements VariableProvider { -// constructor(private fdcService: DataConnectService) {} -// async listVariables(): Promise { -// const introspection = await this.fdcService.introspect(); -// console.log(introspection); -// return introspection.data!.__schema.types.map((type) => { -// return { -// name: type.name, -// description: type.description as string, -// }; -// }); -// } - -// typeahead( -// part: string, -// limit: number, -// token: vscode.CancellationToken, -// ): Promise { -// throw new Error("Method not implemented."); -// } -// } - -// currently only supports a single button -function addCodeHandlers(responseStream: ChatResponseStream) { - responseStream.addCodeHandlerButton( - "Insert to bottom of file", - ({ codeBlock }) => { - insertToBottomOfActiveFile(codeBlock); - }, - { languages: /graphql|graphqllanguage/ }, - ); -} - -// Basic validation function to ensure deterministic command -function isPromptValid(prompt: ChatPrompt): boolean { - if (prompt.length < 2) { - return false; - } - if (prompt.getPromptParts()[0].getPrompt() !== AT_DATACONNECT_TOOL_ID) { - return false; - } - - return isCommandValid( - prompt.getPromptParts()[1].getPrompt().replace("/", ""), - ); -} - -function isCommandValid(command: string): boolean { - return (Object.values(Command) as string[]).includes(command); -} - -// get the /command without the / -function getCommand(prompt: ChatPrompt): Command { - if (prompt.length > 2) { - return prompt.getPromptParts()[1].getPrompt().replace("/", "") as Command; - } - - // fallback if prompt parts doesn't work - return prompt.fullPrompt().replace(AT_DATACONNECT_TOOL_ID, "").trimStart().split(" ")[0] as Command; -} - -// get the entire prompt without the @tool & /command -function getPrompt(prompt: ChatPrompt): string { - if ( - prompt.length > 2 && - prompt.getPromptParts()[0].getPrompt() === AT_DATACONNECT_TOOL_ID - ) { - return prompt.getPromptParts()[2].getPrompt(); - } - - // fallback if prompt parts doesn't work - return prompt.fullPrompt().replace(AT_DATACONNECT_TOOL_ID, "").replace(/\/\w+/, "").trimStart(); -} diff --git a/firebase-vscode/src/data-connect/ai-tools/tool-controller.ts b/firebase-vscode/src/data-connect/ai-tools/tool-controller.ts deleted file mode 100644 index 500334099b6..00000000000 --- a/firebase-vscode/src/data-connect/ai-tools/tool-controller.ts +++ /dev/null @@ -1,289 +0,0 @@ -import * as fs from "fs"; -import * as path from "path"; -import * as vscode from "vscode"; -import { Signal } from "@preact/signals-core"; - -import { Result } from "../../result"; -import { AnalyticsLogger } from "../../analytics"; -import { ResolvedDataConnectConfigs } from "../config"; -import { DataConnectService } from "../service"; -import { CloudAICompanionResponse, ChatMessage } from "../../dataconnect/cloudAICompanionTypes"; -import { ObjectTypeDefinitionNode, OperationDefinitionNode } from "graphql"; -import { getHighlightedText, findGqlFiles } from "../file-utils"; -import { CommandContext, Chat, Context, Command, BackendAuthor } from "./types"; -import { DATA_CONNECT_EVENT_NAME } from "../../analytics"; - -const USER_PREAMBLE = "This is the user's prompt: \n"; - -const SCHEMA_PROMPT_PREAMBLE = - "This is the user's current schema in their code base.: \n"; - -const NEW_LINE = "\n"; -const HIGHLIGHTED_TEXT_PREAMBLE = - "This is the highlighted code in the users active editor: \n"; - -/** - * Logic for talking to CloudCompanion API - * Handles Context collection and management - * - */ -export class GeminiToolController { - constructor( - private readonly analyticsLogger: AnalyticsLogger, - private readonly fdcService: DataConnectService, - private configs: Signal< - Result | undefined - >, - ) { - this.registerCommands(); - } - - // entry points from vscode to respsective tools - private registerCommands(): void { - /** Demo only */ - // vscode.commands.registerCommand( - // "firebase.dataConnect.refineOperation", - // async (ast: ObjectTypeDefinitionNode) => { - // this.highlightActiveType(ast); - // if (env.value.isMonospace) { - // vscode.commands.executeCommand("aichat.prompt", { - // prefillPrompt: "@data-connect /generate_operation ", - // }); - // } else { - // // change to prefill when GCA releases feature - // vscode.commands.executeCommand("cloudcode.gemini.chatView.focus"); - // } - // }, - // ); - /** End Demo only */ - } - private highlightActiveType(ast: ObjectTypeDefinitionNode) { - const editor = vscode.window.activeTextEditor; - if (!editor || !ast.loc) { - // TODO: add a warning, and skip this process - } else { - // highlight the schema in question - const startPostion = new vscode.Position( - ast.loc?.startToken.line - 1, - ast.loc?.startToken.column - 1, - ); - const endPosition = new vscode.Position( - ast.loc?.endToken.line, - ast.loc?.endToken.column - 1, - ); - editor.selection = new vscode.Selection(startPostion, endPosition); - } - } - - /** - * Entry point to chat interface; - * Builds prompt given chatHistory and generation type - * We use some basic heuristics such as - * - presence of previously generated code - * - activeEditor + any highlighted code - */ - public async handleChat( - userPrompt: string, // prompt without toolname and command - chatHistory: Chat[], - command: Command, - ): Promise { - let prompt = ""; - let currentChat: Chat = { - author: "USER", - content: "to_be_set", - commandContext: CommandContext.NO_OP /* to be set */, - }; - let type: "schema" | "operation"; - - // set type - if (command === Command.GENERATE_OPERATION) { - type = "operation"; - this.analyticsLogger.logger.logUsage( - DATA_CONNECT_EVENT_NAME.GEMINI_OPERATION_CALL, - ); - } else if (command === Command.GENERATE_SCHEMA) { - type = "schema"; - this.analyticsLogger.logger.logUsage( - DATA_CONNECT_EVENT_NAME.GEMINI_SCHEMA_CALL, - ); - } else { - // undetermined process - chatHistory.push({ - author: "MODEL", - content: - "Gemini is unable to complete that request. Try '/generate_schema' or '/generate_operation' to get started.", - }); - return chatHistory; - } - - //TODO: deal with non-open editor situation - const currentDocumentPath = - vscode.window.activeTextEditor?.document.uri.path; - - // get additional context - const schema = await this.collectSchemaText(); - const highlighted = getHighlightedText(); - - // check if highlighted is a single operation - if (highlighted) { - prompt = prompt.concat(HIGHLIGHTED_TEXT_PREAMBLE, highlighted); - } - - // only add schema for operation generation - if (schema && command === Command.GENERATE_OPERATION) { - prompt = prompt.concat(SCHEMA_PROMPT_PREAMBLE, schema); - } - - // finalize prompt w/ user prompt - prompt = prompt.concat(USER_PREAMBLE, userPrompt); - - const resp = await this.callGenerateApi( - currentDocumentPath || "", - prompt, - type, - this.cleanHistory(chatHistory, type), - ); - - if (resp.error) { - this.analyticsLogger.logger.logUsage( - DATA_CONNECT_EVENT_NAME.GEMINI_ERROR, - ); - return [{ author: "MODEL", content: resp.error.message }]; - } - - return resp.output.messages; - } - - // clean history for API consumption - public cleanHistory(history: ChatMessage[], type: string): Chat[] { - if (type === "operation") { - // operation api uses "SYSTEM" to represent API responses - return history.map((item) => { - if ( - item.author.toUpperCase() === "MODEL" || - item.author.toUpperCase() === "AGENT" - ) { - item.author = "SYSTEM"; - } - - if (item.author.toUpperCase() === "USER") { - item.author = "USER"; // set upper case - } - // remove command context - return { author: item.author, content: item.content }; - }); - } else { - return history.map((item) => { - if ( - item.author.toUpperCase() === "AGENT" || - item.author.toUpperCase() === "SYSTEM" - ) { - item.author = "MODEL"; - } - item.author = item.author.toUpperCase(); - - return { - author: item.author, - content: item.content, - }; - }); - } - } - - async callGenerateApi( - documentPath: string, - prompt: string, - type: "schema" | "operation", - chatHistory: Chat[], - ): Promise { - // TODO: Call Gemini API with the document content and context - try { - const response = await this.fdcService.generateOperation( - documentPath, - prompt, - type, - chatHistory, - ); - if (!response) { - throw new Error("No response from Cloud AI API"); - } - return response; - } catch (error) { - throw new Error(`Failed to call Gemini API: ${error}`); - } - } - - async collectSchemaText(): Promise { - try { - const service = this.configs?.value?.tryReadValue?.values[0]; - - if (!service) { - // The entrypoint is not a codelens file, so we can't determine the service. - return ""; - } - - let schema: string = ""; - const schemaPath = path.join(service.path, service.schemaDir); - const schemaFiles = await findGqlFiles(schemaPath); - for (const file of schemaFiles) { - schema = schema.concat(fs.readFileSync(file, "utf-8")); - } - return schema; - } catch (error) { - throw new Error(`Failed to collect GQL files: ${error}`); - } - } - - /** Demo usage only */ - private async setupRefineOperation(prompt: string, chatHistory: Chat[]) { - const preamble = - "This is the GraphQL Operation that was generated previously: "; - - // TODO: more verification - const lastChat = chatHistory.pop(); - let operation = ""; - if (!lastChat) { - // could not find an operation, TODO: response appropriately - } else { - operation = lastChat.content; - } - - return preamble.concat(NEW_LINE, operation); - } - - private async setupRefineSchema(prompt: string, chatHistory: Chat[]) { - const SCHEMA_PREAMBLE = - "This is the GraphQL Schema that was generated previously: \n"; - - // TODO: more verification - const lastChat = chatHistory.pop(); - let schema = ""; - if (!lastChat) { - // could not find a schema, use the schema in editor - schema = await this.collectSchemaText(); - } else { - schema = lastChat.content; - } - - return prompt.concat(SCHEMA_PREAMBLE, schema); - } - - private isAuthorBackend(author: string) { - return Object.values(BackendAuthor).includes(author); - } - - // checks if last chat in the history is a generated code response from a model - private isLastChatGenerated(chatHistory: Chat[]): boolean { - const lastChat = chatHistory.pop(); - return ( - lastChat !== undefined && - this.isAuthorBackend(lastChat.author) && - lastChat.commandContext !== undefined && - lastChat.commandContext !== CommandContext.NO_OP - ); - } - - /** End demo code */ - - dispose() {} -} diff --git a/firebase-vscode/src/data-connect/ai-tools/types.ts b/firebase-vscode/src/data-connect/ai-tools/types.ts deleted file mode 100644 index 8d3dbc7df29..00000000000 --- a/firebase-vscode/src/data-connect/ai-tools/types.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { ChatMessage } from "../../dataconnect/cloudAICompanionTypes"; - -export enum Command { - GENERATE_SCHEMA = "generate_schema", - GENERATE_OPERATION = "generate_operation", -} -export enum Context { - REFINE_SCHEMA = "refine_schema", - REFINE_OPERATION = "refine_op", - NO_OP = "no_op", // not no_operation, it's just a no-op -} -// export type CommandContext = Command | Context; -export const CommandContext = { ...Command, ...Context }; -export type CommandContextType = Command | Context; - -// adds context to the ChatMessage type for reasoning -export interface Chat extends ChatMessage { - commandContext?: CommandContextType; -} - -// represents a backend chat response -export const BackendAuthor = { - MODEL: "MODEL", // schema api - SYSTEM: "SYSTEM", // operation api -}; diff --git a/firebase-vscode/src/data-connect/index.ts b/firebase-vscode/src/data-connect/index.ts index 8fc1bf1fcd1..98d2b7279b3 100644 --- a/firebase-vscode/src/data-connect/index.ts +++ b/firebase-vscode/src/data-connect/index.ts @@ -1,4 +1,4 @@ -import vscode, { Disposable, ExtensionContext, TelemetryLogger } from "vscode"; +import vscode, { Disposable, ExtensionContext } from "vscode"; import { Signal, effect } from "@preact/signals-core"; import { ExtensionBrokerImpl } from "../extension-broker"; import { registerExecution } from "./execution/execution"; @@ -31,14 +31,8 @@ import { registerWebview } from "../webview"; import { DataConnectToolkit } from "./toolkit"; import { registerFdcSdkGeneration } from "./sdk-generation"; import { registerDiagnostics } from "./diagnostics"; -import { AnalyticsLogger, DATA_CONNECT_EVENT_NAME } from "../analytics"; -import { emulators } from "../init/features"; -import { GCAToolClient } from "./ai-tools/gca-tool"; -import { GeminiToolController } from "./ai-tools/tool-controller"; -import { - registerFirebaseMCP, - writeToGeminiConfig, -} from "./ai-tools/firebase-mcp"; +import { AnalyticsLogger } from "../analytics"; +import { registerFirebaseMCP } from "./ai-tools/firebase-mcp"; class CodeActionsProvider implements vscode.CodeActionProvider { constructor( @@ -238,7 +232,6 @@ export function registerFdc( registerTerminalTasks(broker, analyticsLogger), registerFirebaseMCP(broker, analyticsLogger), operationCodeLensProvider, - vscode.languages.registerCodeLensProvider( // **Hack**: For testing purposes, enable code lenses on all graphql files // inside the test_projects folder. diff --git a/firebase-vscode/src/data-connect/service.ts b/firebase-vscode/src/data-connect/service.ts index 99eafe1d768..dd07679195f 100644 --- a/firebase-vscode/src/data-connect/service.ts +++ b/firebase-vscode/src/data-connect/service.ts @@ -10,7 +10,7 @@ import { AuthService } from "../auth/service"; import { UserMockKind } from "../../common/messaging/protocol"; import { firstWhereDefined } from "../utils/signal"; import { EmulatorsController } from "../core/emulators"; -import { dataConnectConfigs, VSCODE_ENV_VARS } from "../data-connect/config"; +import { dataConnectConfigs } from "../data-connect/config"; import { firebaseRC } from "../core/config"; import { @@ -20,22 +20,12 @@ import { DATACONNECT_API_VERSION, } from "../../../src/dataconnect/dataplaneClient"; -import { - cloudAICompationClient, - callCloudAICompanion, -} from "../../../src/dataconnect/cloudAiCompanionClient"; - import { ExecuteGraphqlRequest, GraphqlResponse, GraphqlResponseError, Impersonation, } from "../dataconnect/types"; -import { - CloudAICompanionResponse, - CallCloudAiCompanionRequest, - ChatMessage, -} from "../dataconnect/cloudAICompanionTypes"; import { Client, ClientResponse } from "../../../src/apiv2"; import { InstanceType } from "./code-lens-provider"; import { pluginLogger } from "../logger-wrapper"; @@ -63,27 +53,6 @@ export class DataConnectService { return dcs?.getApiServicePathByPath(projectId, path); } - private async decodeResponse( - response: Response, - format?: "application/json", - ): Promise { - const contentType = response.headers.get("Content-Type"); - if (!contentType) { - throw new Error("Invalid content type"); - } - - if (format && !contentType.includes(format)) { - throw new Error( - `Invalid content type. Expected ${format} but got ${contentType}`, - ); - } - - if (contentType.includes("application/json")) { - return response.json(); - } - - return response.text(); - } private async handleProdResponse( response: ClientResponse, ): Promise { @@ -257,32 +226,6 @@ export class DataConnectService { docsLink() { return this.dataConnectToolkit.getGeneratedDocsURL(); } - - // Start cloud section - - async generateOperation( - path: string /** currently unused; instead reading the first service config */, - naturalLanguageQuery: string, - type: "schema" | "operation", - chatHistory: ChatMessage[], - ): Promise { - const client = cloudAICompationClient(); - const servicePath = await this.servicePath( - dataConnectConfigs.value?.tryReadValue?.values[0].path as string, - ); - - if (!servicePath) { - return undefined; - } - - const request: CallCloudAiCompanionRequest = { - servicePath, - naturalLanguageQuery, - chatHistory, - }; - const resp = await callCloudAICompanion(client, request, type); - return resp; - } } function parseVariableString(variables: string): Record { diff --git a/src/dataconnect/cloudAICompanionClient.spec.ts b/src/dataconnect/cloudAICompanionClient.spec.ts deleted file mode 100644 index f7af7125e12..00000000000 --- a/src/dataconnect/cloudAICompanionClient.spec.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { expect } from "chai"; -import * as sinon from "sinon"; -import * as nock from "nock"; -import * as chai from "chai"; -import { callCloudAICompanion, cloudAICompationClient } from "./cloudAICompanionClient"; -import { Client } from "../apiv2"; -import { CallCloudAiCompanionRequest, CloudAICompanionResponse } from "./cloudAICompanionTypes"; - -chai.use(require("chai-as-promised")); - -describe("cloudAICompanionClient", () => { - let sandbox: sinon.SinonSandbox; - - beforeEach(() => { - sandbox = sinon.createSandbox(); - }); - - afterEach(() => { - sandbox.restore(); - nock.cleanAll(); - }); - - describe("callCloudAICompanion", () => { - const fakeRequest: CallCloudAiCompanionRequest = { - servicePath: "projects/my-project/locations/us-central1/services/my-service", - naturalLanguageQuery: "Get all users", - chatHistory: [], - }; - - it("should call the Cloud AI Companion API for schema generation", async () => { - const expectedResponse: CloudAICompanionResponse = { - output: { - messages: [{ author: "MODEL", content: "Generated schema" }], - }, - }; - nock("https://cloudaicompanion.googleapis.com") - .post("/v1/projects/my-project/locations/global/instances/default:completeTask", (body) => { - expect(body.experienceContext.experience).to.equal( - "/appeco/firebase/fdc-schema-generator", - ); - return true; - }) - .reply(200, expectedResponse); - - const client = cloudAICompationClient(); - const response = await callCloudAICompanion(client, fakeRequest, "schema"); - expect(response).to.deep.equal(expectedResponse); - }); - - it("should call the Cloud AI Companion API for operation generation", async () => { - const expectedResponse: CloudAICompanionResponse = { - output: { - messages: [{ author: "MODEL", content: "Generated operation" }], - }, - }; - nock("https://cloudaicompanion.googleapis.com") - .post("/v1/projects/my-project/locations/global/instances/default:completeTask", (body) => { - expect(body.experienceContext.experience).to.equal( - "/appeco/firebase/fdc-query-generator", - ); - return true; - }) - .reply(200, expectedResponse); - - const client = cloudAICompationClient(); - const response = await callCloudAICompanion(client, fakeRequest, "operation"); - expect(response).to.deep.equal(expectedResponse); - }); - - it("should handle errors from the Cloud AI Companion API", async () => { - nock("https://cloudaicompanion.googleapis.com") - .post("/v1/projects/my-project/locations/global/instances/default:completeTask") - .reply(500, { error: { message: "Internal Server Error" } }); - - const client = cloudAICompationClient(); - const response = await callCloudAICompanion(client, fakeRequest, "schema"); - - expect(response.error).to.exist; - expect(response.output.messages).to.deep.equal([]); - }); - - it("should throw an error for an invalid service name", async () => { - const invalidRequest: CallCloudAiCompanionRequest = { - servicePath: "invalid-service-name", - naturalLanguageQuery: "Get all users", - chatHistory: [], - }; - const client = new Client({ urlPrefix: "", apiVersion: "" }); - await expect(callCloudAICompanion(client, invalidRequest, "schema")).to.be.rejectedWith( - "Invalid service name: invalid-service-name", - ); - }); - }); -}); diff --git a/src/dataconnect/cloudAICompanionClient.ts b/src/dataconnect/cloudAICompanionClient.ts deleted file mode 100644 index 1e6544b2b60..00000000000 --- a/src/dataconnect/cloudAICompanionClient.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { Client } from "../apiv2"; -import { cloudAiCompanionOrigin } from "../api"; -import { - CloudAICompanionResponse, - CloudAICompanionRequest, - CloudAICompanionInput, - ClientContext, - CallCloudAiCompanionRequest, -} from "./cloudAICompanionTypes"; -import { FirebaseError } from "../error"; - -const CLOUD_AI_COMPANION_VERSION = "v1"; -const CLIENT_CONTEXT_NAME_IDENTIFIER = "firebase_vscode"; -const FIREBASE_CHAT_REQUEST_CONTEXT_TYPE_NAME = - "type.googleapis.com/google.cloud.cloudaicompanion.v1main.FirebaseChatRequestContext"; -const FDC_SCHEMA_EXPERIENCE_CONTEXT = "/appeco/firebase/fdc-schema-generator"; -const FDC_OPERATION_EXPERIENCE_CONTEXT = "/appeco/firebase/fdc-query-generator"; -const USER_AUTHOR = "USER"; -type GENERATION_TYPE = "schema" | "operation"; - -export function cloudAICompationClient(): Client { - return new Client({ - urlPrefix: cloudAiCompanionOrigin(), - apiVersion: CLOUD_AI_COMPANION_VERSION, - auth: true, - }); -} - -export async function callCloudAICompanion( - client: Client, - vscodeRequest: CallCloudAiCompanionRequest, - type: GENERATION_TYPE, -): Promise { - const request = buildRequest(vscodeRequest, type); - const { projectId } = getServiceParts(vscodeRequest.servicePath); - - const instance = toChatResourceName(projectId); - - try { - const res = await client.post( - `${instance}:completeTask`, - request, - ); - return res.body; - } catch (error: unknown) { - return { output: { messages: [] }, error: error as FirebaseError }; - } -} - -function buildRequest( - { servicePath, naturalLanguageQuery, chatHistory }: CallCloudAiCompanionRequest, - type: GENERATION_TYPE, -): CloudAICompanionRequest { - const { serviceId } = getServiceParts(servicePath); - const input: CloudAICompanionInput = { - messages: [ - ...chatHistory, - { - author: USER_AUTHOR, - content: naturalLanguageQuery, - }, - ], - }; - - const clientContext: ClientContext = { - name: CLIENT_CONTEXT_NAME_IDENTIFIER, - // TODO: determine if we should pass vscode version; // version: ideContext.ver, - additionalContext: { - "@type": FIREBASE_CHAT_REQUEST_CONTEXT_TYPE_NAME, - fdcInfo: { - serviceId, - fdcServiceName: servicePath, - requiresQuery: true, - }, - }, - }; - - return { - input, - clientContext, - experienceContext: { - experience: - type === "schema" ? FDC_SCHEMA_EXPERIENCE_CONTEXT : FDC_OPERATION_EXPERIENCE_CONTEXT, - }, - }; -} - -function toChatResourceName(projectId: string): string { - return `projects/${projectId}/locations/global/instances/default`; -} - -/** Gets service name parts */ -interface ServiceParts { - projectId: string; - locationId: string; - serviceId: string; -} -function getServiceParts(name: string): ServiceParts { - const match = name.match(/projects\/([^/]*)\/locations\/([^/]*)\/services\/([^/]*)/); - - if (!match) { - throw new Error(`Invalid service name: ${name}`); - } - - return { projectId: match[1], locationId: match[2], serviceId: match[3] }; -} From 3b874f7626f6533ba56c54ac36abad2b7dc93972 Mon Sep 17 00:00:00 2001 From: Fred Zhang Date: Fri, 19 Sep 2025 12:11:10 -0700 Subject: [PATCH 7/7] [MCP] `firebase_update_environment` tool can be used to accept Gemini in Firebase ToS (#9143) * update_environment can be used to accept Gemini ToS * changelog * Update src/mcp/tools/core/update_environment.ts Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * m * m * m * m * m --------- Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- CHANGELOG.md | 1 + src/mcp/errors.ts | 42 ++++++++++++++++----- src/mcp/index.ts | 48 +++++++++--------------- src/mcp/tool.ts | 2 + src/mcp/tools/core/get_environment.ts | 5 ++- src/mcp/tools/core/init.ts | 6 +++ src/mcp/tools/core/update_environment.ts | 21 ++++++++--- src/mcp/tools/rules/validate_rules.ts | 2 +- 8 files changed, 81 insertions(+), 46 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e69de29bb2d..cf5ff939942 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -0,0 +1 @@ +- `firebase_update_environment` MCP tool supports accepting Gemini in Firebase Terms of Service. diff --git a/src/mcp/errors.ts b/src/mcp/errors.ts index 832ff7a1183..c6329b07fb9 100644 --- a/src/mcp/errors.ts +++ b/src/mcp/errors.ts @@ -1,11 +1,43 @@ import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import { mcpError } from "./util"; +import { configstore } from "../configstore"; +import { check, ensure } from "../ensureApiEnabled"; +import { cloudAiCompanionOrigin } from "../api"; export const NO_PROJECT_ERROR = mcpError( - 'No active project was found. Use the `firebase_update_environment` tool to set the project directory to an absolute folder location containing a firebase.json config file. Alternatively, change the MCP server config to add [...,"--dir","/absolute/path/to/project/directory"] in its command-line arguments.', + "This tool requires an active project. Use the `firebase_update_environment` tool to set a project ID", "PRECONDITION_FAILED", ); +const GEMINI_TOS_ERROR = mcpError( + "This tool requires the Gemini in Firebase API, please review the terms of service and accept it using `firebase_update_environment`.\n" + + "Learn more about Gemini in Firebase and how it uses your data: https://firebase.google.com/docs/gemini-in-firebase#how-gemini-in-firebase-uses-your-data", + "PRECONDITION_FAILED", +); + +/** Enable the Gemini in Firebase API or return an error to accept it */ +export async function requireGeminiToS(projectId: string): Promise { + if (!projectId) { + return NO_PROJECT_ERROR; + } + if (configstore.get("gemini")) { + await ensure(projectId, cloudAiCompanionOrigin(), ""); + } else { + if (!(await check(projectId, cloudAiCompanionOrigin(), ""))) { + return GEMINI_TOS_ERROR; + } + } + return undefined; +} + +export function noProjectDirectory(projectRoot: string | undefined): CallToolResult { + return mcpError( + `The current project directory '${ + projectRoot || "" + }' does not exist. Please use the 'update_firebase_environment' tool to target a different project directory.`, + ); +} + export function mcpAuthError(skipADC: boolean): CallToolResult { if (skipADC) { return mcpError( @@ -15,11 +47,3 @@ export function mcpAuthError(skipADC: boolean): CallToolResult { return mcpError(`The user is not currently logged into the Firebase CLI, which is required to use this tool. Please run the 'firebase_login' tool to log in, or instruct the user to configure [Application Default Credentials][ADC] on their machine. [ADC]: https://cloud.google.com/docs/authentication/application-default-credentials`); } - -export function mcpGeminiError(projectId: string) { - const consoleUrl = `https://firebase.corp.google.com/project/${projectId}/overview`; - return mcpError( - `This tool uses the Gemini in Firebase API. Visit Firebase Console to enable the Gemini in Firebase API ${consoleUrl} and try again.`, - "PRECONDITION_FAILED", - ); -} diff --git a/src/mcp/index.ts b/src/mcp/index.ts index 2bbab682567..a50d730fbbc 100644 --- a/src/mcp/index.ts +++ b/src/mcp/index.ts @@ -25,15 +25,13 @@ import { Command } from "../command"; import { requireAuth } from "../requireAuth"; import { Options } from "../options"; import { getProjectId } from "../projectUtils"; -import { mcpAuthError, NO_PROJECT_ERROR, mcpGeminiError } from "./errors"; +import { mcpAuthError, noProjectDirectory, NO_PROJECT_ERROR, requireGeminiToS } from "./errors"; import { trackGA4 } from "../track"; import { Config } from "../config"; import { loadRC } from "../rc"; import { EmulatorHubClient } from "../emulator/hubClient"; import { Emulators } from "../emulator/types"; import { existsSync } from "node:fs"; -import { ensure, check } from "../ensureApiEnabled"; -import * as api from "../api"; import { LoggingStdioServerTransport } from "./logging-transport"; import { isFirebaseStudio } from "../env"; import { timeoutFallback } from "../timeout"; @@ -57,7 +55,7 @@ export class FirebaseMcpServer { private _ready: boolean = false; private _readyPromises: { resolve: () => void; reject: (err: unknown) => void }[] = []; startupRoot?: string; - cachedProjectRoot?: string; + cachedProjectDir?: string; server: Server; activeFeatures?: ServerFeature[]; detectedFeatures?: ServerFeature[]; @@ -156,11 +154,11 @@ export class FirebaseMcpServer { async detectProjectRoot(): Promise { await timeoutFallback(this.ready(), null, 2000); - if (this.cachedProjectRoot) return this.cachedProjectRoot; + if (this.cachedProjectDir) return this.cachedProjectDir; const storedRoot = this.getStoredClientConfig().projectRoot; - this.cachedProjectRoot = storedRoot || this.startupRoot || process.cwd(); - this.log("debug", "detected and cached project root: " + this.cachedProjectRoot); - return this.cachedProjectRoot; + this.cachedProjectDir = storedRoot || this.startupRoot || process.cwd(); + this.log("debug", "detected and cached project root: " + this.cachedProjectDir); + return this.cachedProjectDir; } async detectActiveFeatures(): Promise { @@ -235,14 +233,14 @@ export class FirebaseMcpServer { setProjectRoot(newRoot: string | null): void { this.updateStoredClientConfig({ projectRoot: newRoot }); - this.cachedProjectRoot = newRoot || undefined; + this.cachedProjectDir = newRoot || undefined; this.detectedFeatures = undefined; // reset detected features void this.server.sendToolListChanged(); void this.server.sendPromptListChanged(); } async resolveOptions(): Promise> { - const options: Partial = { cwd: this.cachedProjectRoot, isMCP: true }; + const options: Partial = { cwd: this.cachedProjectDir, isMCP: true }; await cmd.prepare(options); return options; } @@ -272,7 +270,7 @@ export class FirebaseMcpServer { return { tools: this.availableTools.map((t) => t.mcp), _meta: { - projectRoot: this.cachedProjectRoot, + projectRoot: this.cachedProjectDir, projectDetected: hasActiveProject, authenticatedUser: await this.getAuthenticatedUser(skipAutoAuthForStudio), activeFeatures: this.activeFeatures, @@ -289,15 +287,10 @@ export class FirebaseMcpServer { if (!tool) throw new Error(`Tool '${toolName}' could not be found.`); // Check if the current project directory exists. - if ( - tool.mcp.name !== "firebase_update_environment" && // allow this tool only, to fix the issue - (!this.cachedProjectRoot || !existsSync(this.cachedProjectRoot)) - ) { - return mcpError( - `The current project directory '${ - this.cachedProjectRoot || "" - }' does not exist. Please use the 'update_firebase_environment' tool to target a different project directory.`, - ); + if (!tool.mcp._meta?.optionalProjectDir) { + if (!this.cachedProjectDir || !existsSync(this.cachedProjectDir)) { + return noProjectDirectory(this.cachedProjectDir); + } } // Check if the project ID is set. @@ -316,16 +309,11 @@ export class FirebaseMcpServer { // Check if the tool requires Gemini in Firebase API. if (tool.mcp._meta?.requiresGemini) { - if (configstore.get("gemini")) { - await ensure(projectId, api.cloudAiCompanionOrigin(), ""); - } else { - if (!(await check(projectId, api.cloudAiCompanionOrigin(), ""))) { - return mcpGeminiError(projectId); - } - } + const err = await requireGeminiToS(projectId); + if (err) return err; } - const options = { projectDir: this.cachedProjectRoot, cwd: this.cachedProjectRoot }; + const options = { projectDir: this.cachedProjectDir, cwd: this.cachedProjectDir }; const toolsCtx: ServerToolContext = { projectId: projectId, host: this, @@ -362,7 +350,7 @@ export class FirebaseMcpServer { arguments: p.mcp.arguments, })), _meta: { - projectRoot: this.cachedProjectRoot, + projectRoot: this.cachedProjectDir, projectDetected: hasActiveProject, authenticatedUser: await this.getAuthenticatedUser(skipAutoAuthForStudio), activeFeatures: this.activeFeatures, @@ -386,7 +374,7 @@ export class FirebaseMcpServer { const skipAutoAuthForStudio = isFirebaseStudio(); const accountEmail = await this.getAuthenticatedUser(skipAutoAuthForStudio); - const options = { projectDir: this.cachedProjectRoot, cwd: this.cachedProjectRoot }; + const options = { projectDir: this.cachedProjectDir, cwd: this.cachedProjectDir }; const promptsCtx: ServerPromptContext = { projectId: projectId, host: this, diff --git a/src/mcp/tool.ts b/src/mcp/tool.ts index 990bb7adef9..b09329565a6 100644 --- a/src/mcp/tool.ts +++ b/src/mcp/tool.ts @@ -37,6 +37,8 @@ export interface ServerTool { openWorldHint?: boolean; }; _meta?: { + /** Set this on a tool if it cannot work without a Firebase project directory. */ + optionalProjectDir?: boolean; /** Set this on a tool if it *always* requires a project to work. */ requiresProject?: boolean; /** Set this on a tool if it *always* requires a signed-in user to work. */ diff --git a/src/mcp/tools/core/get_environment.ts b/src/mcp/tools/core/get_environment.ts index e46e928971e..dff9063085c 100644 --- a/src/mcp/tools/core/get_environment.ts +++ b/src/mcp/tools/core/get_environment.ts @@ -4,6 +4,7 @@ import { toContent } from "../../util"; import { getAliases } from "../../../projectUtils"; import { dump } from "js-yaml"; import { getAllAccounts } from "../../../auth"; +import { configstore } from "../../../configstore"; export const get_environment = tool( { @@ -22,14 +23,16 @@ export const get_environment = tool( }, async (_, { projectId, host, accountEmail, rc, config }) => { const aliases = projectId ? getAliases({ rc }, projectId) : []; + const geminiTosAccepted = !!configstore.get("gemini"); return toContent(`# Environment Information -Project Directory: ${host.cachedProjectRoot} +Project Directory: ${host.cachedProjectDir} Project Config Path: ${config.projectFileExists("firebase.json") ? config.path("firebase.json") : ""} Active Project ID: ${ projectId ? `${projectId}${aliases.length ? ` (alias: ${aliases.join(",")})` : ""}` : "" } Authenticated User: ${accountEmail || ""} +Gemini in Firebase Terms of Service: ${geminiTosAccepted ? "Accepted" : "Not Accepted"} # Available Project Aliases (format: '[alias]: [projectId]') diff --git a/src/mcp/tools/core/init.ts b/src/mcp/tools/core/init.ts index a3e07590b3f..7ce6ab23d21 100644 --- a/src/mcp/tools/core/init.ts +++ b/src/mcp/tools/core/init.ts @@ -4,6 +4,7 @@ import { toContent } from "../../util"; import { DEFAULT_RULES } from "../../../init/features/database"; import { actuate, Setup, SetupInfo } from "../../../init/index"; import { freeTrialTermsLink } from "../../../dataconnect/freeTrial"; +import { requireGeminiToS } from "../../errors"; export const init = tool( { @@ -157,6 +158,11 @@ export const init = tool( }; } if (features.dataconnect) { + if (features.dataconnect.app_description) { + // If app description is provided, ensure the Gemini in Firebase API is enabled. + const err = await requireGeminiToS(projectId); + if (err) return err; + } featuresList.push("dataconnect"); featureInfo.dataconnect = { analyticsFlow: "mcp", diff --git a/src/mcp/tools/core/update_environment.ts b/src/mcp/tools/core/update_environment.ts index 07577811748..ce3c4d25098 100644 --- a/src/mcp/tools/core/update_environment.ts +++ b/src/mcp/tools/core/update_environment.ts @@ -4,12 +4,13 @@ import { mcpError, toContent } from "../../util"; import { setNewActive } from "../../../commands/use"; import { assertAccount, setProjectAccount } from "../../../auth"; import { existsSync } from "node:fs"; +import { configstore } from "../../../configstore"; export const update_environment = tool( { name: "update_environment", description: - "Updates Firebase environment config such as project directory, active project, active user account, and more. Use `firebase_get_environment` to see the currently configured environment.", + "Updates Firebase environment config such as project directory, active project, active user account, accept terms of service, and more. Use `firebase_get_environment` to see the currently configured environment.", inputSchema: z.object({ project_dir: z .string() @@ -29,17 +30,25 @@ export const update_environment = tool( .describe( "The email address of the signed-in user to authenticate as when interacting with the current project directory.", ), + accept_gemini_tos: z + .boolean() + .optional() + .describe("Accept the Gemini in Firebase terms of service."), }), annotations: { title: "Update Firebase Environment", readOnlyHint: false, }, _meta: { + optionalProjectDir: true, requiresAuth: false, requiresProject: false, }, }, - async ({ project_dir, active_project, active_user_account }, { config, rc, host }) => { + async ( + { project_dir, active_project, active_user_account, accept_gemini_tos }, + { config, rc, host }, + ) => { let output = ""; if (project_dir) { if (!existsSync(project_dir)) @@ -55,12 +64,14 @@ export const update_environment = tool( } if (active_user_account) { assertAccount(active_user_account, { mcp: true }); - setProjectAccount(host.cachedProjectRoot!, active_user_account); + setProjectAccount(host.cachedProjectDir!, active_user_account); output += `- Updated active account to '${active_user_account}'\n`; } - + if (accept_gemini_tos) { + configstore.set("gemini", true); + output += `- Accepted the Gemini in Firebase terms of service\n`; + } if (output === "") output = "No changes were made."; - return toContent(output); }, ); diff --git a/src/mcp/tools/rules/validate_rules.ts b/src/mcp/tools/rules/validate_rules.ts index cfeeeeba89a..798ba8655bb 100644 --- a/src/mcp/tools/rules/validate_rules.ts +++ b/src/mcp/tools/rules/validate_rules.ts @@ -105,7 +105,7 @@ export function validateRulesTool(productName: string) { let rulesSourceContent: string; if (source_file) { try { - const filePath = resolve(source_file, host.cachedProjectRoot!); + const filePath = resolve(source_file, host.cachedProjectDir!); if (filePath.includes("../")) return mcpError("Cannot read files outside of the project directory."); rulesSourceContent = config.readProjectFile(source_file);