diff --git a/src/gcp/auth.spec.ts b/src/gcp/auth.spec.ts index 64027efcb6a..7998ea18680 100644 --- a/src/gcp/auth.spec.ts +++ b/src/gcp/auth.spec.ts @@ -189,7 +189,7 @@ describe("auth", () => { }) .reply(200, {}); - const result = await auth.disableUser(PROJECT_ID, "test-uid", true); + const result = await auth.toggleUserEnablement(PROJECT_ID, "test-uid", true); expect(result).to.be.true; expect(nock.isDone()).to.be.true; @@ -204,7 +204,9 @@ describe("auth", () => { }) .reply(404, { error: { message: "Not Found" } }); - await expect(auth.disableUser(PROJECT_ID, "test-uid", true)).to.be.rejectedWith("Not Found"); + await expect(auth.toggleUserEnablement(PROJECT_ID, "test-uid", true)).to.be.rejectedWith( + "Not Found", + ); expect(nock.isDone()).to.be.true; }); }); diff --git a/src/gcp/auth.ts b/src/gcp/auth.ts index 4273d5a57fa..6a75de057a1 100644 --- a/src/gcp/auth.ts +++ b/src/gcp/auth.ts @@ -200,7 +200,7 @@ export async function listUsers(project: string, limit: number): Promise { - const projectId = "test-project"; - const uid = "test-uid"; - - let disableUserStub: sinon.SinonStub; - - beforeEach(() => { - disableUserStub = sinon.stub(auth, "disableUser"); - }); - - afterEach(() => { - sinon.restore(); - }); - - it("should disable a user successfully", async () => { - disableUserStub.resolves(true); - - const result = await disable_user.fn({ uid, disabled: true }, { - projectId, - } as McpContext); - - expect(disableUserStub).to.be.calledWith(projectId, uid, true); - expect(result).to.deep.equal(toContent(`User ${uid} has been disabled`)); - }); - - it("should enable a user successfully", async () => { - disableUserStub.resolves(true); - - const result = await disable_user.fn({ uid, disabled: false }, { - projectId, - } as McpContext); - - expect(disableUserStub).to.be.calledWith(projectId, uid, false); - expect(result).to.deep.equal(toContent(`User ${uid} has been enabled`)); - }); - - it("should handle failure to disable a user", async () => { - disableUserStub.resolves(false); - - const result = await disable_user.fn({ uid, disabled: true }, { - projectId, - } as McpContext); - - expect(result).to.deep.equal(toContent(`Failed to disable user ${uid}`)); - }); - - it("should handle failure to enable a user", async () => { - disableUserStub.resolves(false); - - const result = await disable_user.fn({ uid, disabled: false }, { - projectId, - } as McpContext); - - expect(result).to.deep.equal(toContent(`Failed to enable user ${uid}`)); - }); -}); diff --git a/src/mcp/tools/auth/disable_user.ts b/src/mcp/tools/auth/disable_user.ts deleted file mode 100644 index 7726bb06ddd..00000000000 --- a/src/mcp/tools/auth/disable_user.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { z } from "zod"; -import { tool } from "../../tool"; -import { toContent } from "../../util"; -import { disableUser } from "../../../gcp/auth"; - -export const disable_user = tool( - { - name: "disable_user", - description: "Disables or enables a user based on a UID.", - inputSchema: z.object({ - uid: z.string().describe("The localId or UID of the user to disable or enable"), - disabled: z.boolean().describe("true disables the user, false enables the user"), - }), - annotations: { - title: "Disable or enable a particular user", - destructiveHint: true, - idempotentHint: true, - }, - _meta: { - requiresAuth: true, - requiresProject: true, - }, - }, - async ({ uid, disabled }, { projectId }) => { - const res = await disableUser(projectId, uid, disabled); - if (res) { - return toContent(`User ${uid} has been ${disabled ? "disabled" : "enabled"}`); - } - return toContent(`Failed to ${disabled ? "disable" : "enable"} user ${uid}`); - }, -); diff --git a/src/mcp/tools/auth/index.ts b/src/mcp/tools/auth/index.ts index 415dbe46c1a..431c611037b 100644 --- a/src/mcp/tools/auth/index.ts +++ b/src/mcp/tools/auth/index.ts @@ -1,7 +1,6 @@ import { ServerTool } from "../../tool"; +import { update_user } from "./update_user"; import { get_users } from "./get_users"; -import { disable_user } from "./disable_user"; -import { set_claim } from "./set_claims"; import { set_sms_region_policy } from "./set_sms_region_policy"; -export const authTools: ServerTool[] = [get_users, disable_user, set_claim, set_sms_region_policy]; +export const authTools: ServerTool[] = [get_users, update_user, set_sms_region_policy]; diff --git a/src/mcp/tools/auth/set_claims.spec.ts b/src/mcp/tools/auth/set_claims.spec.ts deleted file mode 100644 index 5a8146e09e2..00000000000 --- a/src/mcp/tools/auth/set_claims.spec.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { expect } from "chai"; -import * as sinon from "sinon"; -import { set_claim } from "./set_claims"; -import * as auth from "../../../gcp/auth"; -import * as util from "../../util"; -import { McpContext } from "../../types"; - -describe("set_claim tool", () => { - const projectId = "test-project"; - const uid = "test-uid"; - const claim = "admin"; - - let setCustomClaimStub: sinon.SinonStub; - let mcpErrorStub: sinon.SinonStub; - - beforeEach(() => { - setCustomClaimStub = sinon.stub(auth, "setCustomClaim"); - mcpErrorStub = sinon.stub(util, "mcpError"); - }); - - afterEach(() => { - sinon.restore(); - }); - - it("should set a simple claim", async () => { - const value = true; - setCustomClaimStub.resolves({ success: true }); - - const result = await set_claim.fn({ uid, claim, value }, { projectId } as McpContext); - - expect(setCustomClaimStub).to.be.calledWith( - projectId, - uid, - { [claim]: value }, - { merge: true }, - ); - expect(result).to.deep.equal(util.toContent({ success: true })); - }); - - it("should set a JSON claim", async () => { - const json_value = '{"role": "editor"}'; - const parsedValue = { role: "editor" }; - setCustomClaimStub.resolves({ success: true }); - - const result = await set_claim.fn({ uid, claim, json_value }, { - projectId, - } as McpContext); - - expect(setCustomClaimStub).to.be.calledWith( - projectId, - uid, - { [claim]: parsedValue }, - { merge: true }, - ); - expect(result).to.deep.equal(util.toContent({ success: true })); - }); - - it("should return an error for invalid JSON", async () => { - const json_value = "invalid-json"; - await set_claim.fn({ uid, claim, json_value }, { projectId } as McpContext); - expect(mcpErrorStub).to.be.calledWith( - `Provided \`json_value\` was not valid JSON: ${json_value}`, - ); - }); - - it("should return an error if both value and json_value are provided", async () => { - const value = "simple"; - const json_value = '{"complex": true}'; - await set_claim.fn({ uid, claim, value, json_value }, { projectId } as McpContext); - expect(mcpErrorStub).to.be.calledWith("Must supply only `value` or `json_value`, not both."); - }); -}); diff --git a/src/mcp/tools/auth/set_claims.ts b/src/mcp/tools/auth/set_claims.ts deleted file mode 100644 index 2197d67481a..00000000000 --- a/src/mcp/tools/auth/set_claims.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { z } from "zod"; -import { tool } from "../../tool"; -import { mcpError, toContent } from "../../util"; -import { setCustomClaim } from "../../../gcp/auth"; - -export const set_claim = tool( - { - name: "set_claim", - description: - "Sets a custom claim on a specific user's account. Use to create trusted values associated with a user e.g. marking them as an admin. Claims are limited in size and should be succinct in name and value. Specify ONLY ONE OF `value` or `json_value` parameters.", - inputSchema: z.object({ - uid: z.string().describe("the UID of the user to update"), - claim: z.string().describe("the name (key) of the claim to update, e.g. 'admin'"), - value: z - .union([z.string(), z.number(), z.boolean()]) - .optional() - .describe( - "Set the value of the custom claim to the specified simple scalar value. One of `value` or `json_value` must be provided.", - ), - json_value: z - .string() - .optional() - .describe( - "Set the claim to a complex JSON value like an object or an array by providing stringified JSON. String must be parseable as valid JSON. One of `value` or `json_value` must be provided.", - ), - }), - annotations: { - title: "Set custom Firebase Auth claim", - idempotentHint: true, - }, - _meta: { - requiresAuth: true, - requiresProject: true, - }, - }, - async ({ uid, claim, value, json_value }, { projectId }) => { - if (value && json_value) return mcpError("Must supply only `value` or `json_value`, not both."); - if (json_value) { - try { - value = JSON.parse(json_value); - } catch (e) { - return mcpError(`Provided \`json_value\` was not valid JSON: ${json_value}`); - } - } - return toContent(await setCustomClaim(projectId, uid, { [claim]: value }, { merge: true })); - }, -); diff --git a/src/mcp/tools/auth/update_user.spec.ts b/src/mcp/tools/auth/update_user.spec.ts new file mode 100644 index 00000000000..1429e8323db --- /dev/null +++ b/src/mcp/tools/auth/update_user.spec.ts @@ -0,0 +1,106 @@ +import { expect } from "chai"; +import * as sinon from "sinon"; +import { update_user } from "./update_user"; +import * as auth from "../../../gcp/auth"; +import { McpContext } from "../../types"; +import * as util from "../../util"; + +describe("update_user tool", () => { + const projectId = "test-project"; + let setCustomClaimsStub: sinon.SinonStub; + let toggleuserEnablementStub: sinon.SinonStub; + let mcpErrorStub: sinon.SinonStub; + + beforeEach(() => { + setCustomClaimsStub = sinon.stub(auth, "setCustomClaim"); + toggleuserEnablementStub = sinon.stub(auth, "toggleUserEnablement"); + mcpErrorStub = sinon.stub(util, "mcpError"); + }); + + afterEach(() => { + sinon.restore(); + }); + + it("should disable a user", async () => { + toggleuserEnablementStub.resolves(true); + + const result = await update_user.fn({ uid: "123", disabled: true }, { + projectId, + } as McpContext); + + expect(result).to.deep.equal({ + content: [ + { + text: "Successfully updated user 123. User disabled.", + type: "text", + }, + ], + }); + expect(toggleuserEnablementStub).to.have.been.calledWith(projectId, "123", true); + expect(setCustomClaimsStub).to.not.have.been.called; + }); + + it("should enable a user", async () => { + toggleuserEnablementStub.resolves(true); + + const result = await update_user.fn({ uid: "123", disabled: false }, { + projectId, + } as McpContext); + + expect(result).to.deep.equal({ + content: [ + { + text: "Successfully updated user 123. User enabled.", + type: "text", + }, + ], + }); + expect(toggleuserEnablementStub).to.have.been.calledWith(projectId, "123", false); + expect(setCustomClaimsStub).to.not.have.been.called; + }); + + it("should set a custom claim", async () => { + setCustomClaimsStub.resolves({ uid: "123", customClaims: { admin: true } }); + + const result = await update_user.fn( + { + uid: "123", + claim: { key: "admin", value: true }, + }, + { + projectId, + } as McpContext, + ); + + expect(result).to.deep.equal({ + content: [ + { + text: "Successfully updated user 123. Claim 'admin' set.", + type: "text", + }, + ], + }); + expect(setCustomClaimsStub).to.have.been.calledWith(projectId, "123", { admin: true }); + expect(toggleuserEnablementStub).to.not.have.been.called; + }); + + it("should fail to set a custom claim and disable a user", async () => { + setCustomClaimsStub.resolves({ uid: "123", customClaims: { admin: true } }); + toggleuserEnablementStub.resolves(true); + + await update_user.fn( + { + uid: "123", + claim: { key: "admin", value: true }, + disabled: true, + }, + { + projectId, + } as McpContext, + ); + + expect(mcpErrorStub).to.be.calledWith( + "Can only enable/disable a user or set a claim, not both.", + ); + }); +}); diff --git a/src/mcp/tools/auth/update_user.ts b/src/mcp/tools/auth/update_user.ts new file mode 100644 index 00000000000..025aa260a01 --- /dev/null +++ b/src/mcp/tools/auth/update_user.ts @@ -0,0 +1,93 @@ +import { z } from "zod"; +import { tool } from "../../tool"; +import { mcpError, toContent } from "../../util"; +import { toggleUserEnablement, setCustomClaim } from "../../../gcp/auth"; + +export const update_user = tool( + { + name: "update_user", + description: + "Disables, enables a user account or sets a custom claim on a specific user's account. The tool cannot do both at once.", + inputSchema: z.object({ + uid: z.string().describe("the UID of the user to update"), + disabled: z.boolean().optional().describe("true disables the user, false enables the user"), + claim: z + .object({ + key: z.string().describe("the name (key) of the claim to update, e.g. 'admin'"), + value: z + .union([z.string(), z.number(), z.boolean()]) + .optional() + .describe( + "Set the value of the custom claim to the specified simple scalar value. One of `value` or `json_value` must be provided if setting a claim.", + ), + json_value: z + .string() + .optional() + .describe( + "Set the claim to a complex JSON value like an object or an array by providing stringified JSON. String must be parseable as valid JSON. One of `value` or `json_value` must be provided if setting a claim.", + ), + }) + .optional(), + }), + annotations: { + title: "Update a user", + idempotentHint: true, + }, + _meta: { + requiresAuth: true, + requiresProject: true, + }, + }, + async ({ uid, disabled, claim }, { projectId }) => { + if (disabled && claim) { + return mcpError("Can only enable/disable a user or set a claim, not both."); + } + if (disabled === undefined && !claim) { + return mcpError("At least one of 'disabled' or 'claim' must be provided to update the user."); + } + if (claim && claim.value === undefined && claim.json_value === undefined) { + return mcpError( + "When providing 'key' for the claim, you must also provide either 'value' or 'json_value' for the claim.", + ); + } + if (disabled !== undefined) { + try { + await toggleUserEnablement(projectId, uid, disabled); + } catch (err: any) { + return mcpError(`Failed to ${disabled ? "disable" : "enable"} user ${uid}`); + } + } + + if (claim) { + if (claim.value && claim.json_value) { + return mcpError("Must supply only `value` or `json_value`, not both."); + } + let claimValue = claim.value; + if (claim.json_value) { + try { + claimValue = JSON.parse(claim.json_value); + } catch (e) { + return mcpError(`Provided \`json_value\` was not valid JSON: ${claim.json_value}`); + } + } + try { + await setCustomClaim(projectId, uid, { [claim.key]: claimValue }, { merge: true }); + } catch (e: any) { + let errorMsg = `Failed to set claim: ${e.message}`; + if (disabled !== undefined) { + errorMsg = `User was successfully ${disabled ? "disabled" : "enabled"}, but setting the claim failed: ${e.message}`; + } + return mcpError(errorMsg); + } + } + const messageParts = []; + if (disabled !== undefined) { + messageParts.push(`User ${disabled ? "disabled" : "enabled"}`); + } + if (claim) { + messageParts.push(`Claim '${claim.key}' set`); + } + + return toContent(`Successfully updated user ${uid}. ${messageParts.join(". ")}.`); + }, +);