diff --git a/mcp/src/mcp/server.ts b/mcp/src/mcp/server.ts index 97b13ec471..1a57d04e2a 100644 --- a/mcp/src/mcp/server.ts +++ b/mcp/src/mcp/server.ts @@ -10,6 +10,7 @@ import type { z } from "zod"; import { hasScope, type Principal } from "../tokens/principal"; import type { UpstreamClient } from "../upstream/client"; +import { getAccount } from "./tools/get-account"; import { getCreditBalance } from "./tools/get-credit-balance"; import { type ToolDefinition, ToolFailure, UpstreamSessionInvalid } from "./tools/types"; @@ -21,7 +22,10 @@ import { type ToolDefinition, ToolFailure, UpstreamSessionInvalid } from "./tool export const SERVER_INFO = { name: "artcraft", version: "0.0.0" } as const; -export const TOOLS: readonly ToolDefinition[] = [getCreditBalance]; +export const TOOLS: readonly ToolDefinition[] = [ + getAccount, + getCreditBalance, +]; export interface McpServerDeps { readonly principal: Principal; diff --git a/mcp/src/mcp/tools/get-account.ts b/mcp/src/mcp/tools/get-account.ts new file mode 100644 index 0000000000..64d76cb269 --- /dev/null +++ b/mcp/src/mcp/tools/get-account.ts @@ -0,0 +1,69 @@ +import { z } from "zod"; + +import { READ_ONLY_ANNOTATIONS, type ToolDefinition, ToolFailure, unwrapUpstream } from "./types"; + +/** + * Who the signed-in user is. Deliberately small: name, the feature flags that change what + * they can do, and the account-setup facts that explain "why can't I…" questions. Credits and + * plan live in get_credit_balance. + */ + +const outputSchema = { + username: z.string().describe("The user's Artcraft username."), + display_name: z.string().describe("The name shown in the app."), + feature_flags: z + .array(z.string()) + .describe("Feature flags enabled on the account (e.g. api_key, upload_3d). Empty when none."), + account_setup: z + .object({ + email_set: z.boolean(), + email_confirmed: z.boolean(), + password_set: z.boolean(), + username_customized: z.boolean(), + }) + .describe("Onboarding state; a false value is something the user still has to do in the app."), +}; + +export const getAccount: ToolDefinition, typeof outputSchema> = { + name: "get_account", + title: "Get account", + description: + "Returns who is signed in to Artcraft: username, display name, enabled feature flags, and " + + "account-setup state (email set/confirmed, password set, username customized). Use it to " + + "address the user or to explain what their account can do. Takes no arguments. For credits " + + "and plan, call get_credit_balance.", + requiredScope: "read:account", + inputSchema: {}, + outputSchema, + annotations: { ...READ_ONLY_ANNOTATIONS, title: "Get account" }, + + async handler({ upstream }) { + const session = unwrapUpstream(await upstream.GET("/v1/session")); + if (!session.logged_in || !session.user) { + // The credential was accepted but identifies nobody: treat as an invalid session. + throw new ToolFailure("Artcraft did not recognise this sign-in. Disconnect and reconnect."); + } + const { user } = session; + const structured = { + username: user.username, + display_name: user.display_name, + feature_flags: [...user.maybe_feature_flags], + account_setup: { + email_set: !user.onboarding.email_not_set, + email_confirmed: !user.onboarding.email_not_confirmed, + password_set: !user.onboarding.password_not_set, + username_customized: !user.onboarding.username_not_customized, + }, + }; + return { structured, text: describe(structured) }; + }, +}; + +function describe(account: z.infer>): string { + const flags = account.feature_flags.length > 0 ? account.feature_flags.join(", ") : "none"; + const todo = Object.entries(account.account_setup) + .filter(([, done]) => !done) + .map(([key]) => key.replace(/_/g, " ")); + const setup = todo.length > 0 ? `Still to do: ${todo.join(", ")}.` : "Account setup complete."; + return `Signed in as ${account.username} (${account.display_name}). Feature flags: ${flags}. ${setup}`; +} diff --git a/mcp/test/auth/authenticator.test.ts b/mcp/test/auth/authenticator.test.ts index dfe05f86a3..34169ad5dd 100644 --- a/mcp/test/auth/authenticator.test.ts +++ b/mcp/test/auth/authenticator.test.ts @@ -3,10 +3,9 @@ import { describe, expect, it } from "vitest"; import { createArtcraftAuthenticator, type SignInOutcome } from "../../src/auth/authenticator"; import { SESSION_HEADER_NAME } from "../../src/upstream/credential"; import { fixture } from "../helpers/contract"; +import { SESSION_ANONYMOUS, SESSION_OK, SIGNED_SESSION } from "../helpers/fixtures"; const UPSTREAM = "https://api.example.test"; -const SIGNED_SESSION = - "eyJhbGciOiJIUzI1NiJ9.eyJzZXNzaW9uX3Rva2VuIjoic2Vzc2lvbl90ZXN0In0.c2lnbmF0dXJl"; const PASSWORD = "localdev1pass"; // Fixtures are validated against the spec snapshot when declared. @@ -33,59 +32,6 @@ const GOOGLE_REJECTED = fixture("CommonWebError", { error_code_str: "NotAuthorized", message: "google credential was not accepted", }); -const SESSION_USER = { - core_info: { - user_token: "user_localdev1", - username: "localdev1", - display_name: "Local Dev", - gravatar_hash: "0".repeat(32), - default_avatar: { color_index: 0, image_index: 0 }, - }, - user_token: "user_localdev1", - username: "localdev1", - display_name: "Local Dev", - email_gravatar_hash: "0".repeat(32), - onboarding: { - email_not_set: false, - email_not_confirmed: false, - password_not_set: false, - username_not_customized: false, - }, - can_access_studio: false, - maybe_feature_flags: [], - fakeyou_plan: "free", - storyteller_stream_plan: "free", - can_use_tts: true, - can_use_w2l: true, - can_delete_own_tts_results: true, - can_delete_own_w2l_results: true, - can_delete_own_account: true, - can_upload_tts_models: true, - can_upload_w2l_templates: true, - can_delete_own_tts_models: true, - can_delete_own_w2l_templates: true, - can_approve_w2l_templates: false, - can_edit_other_users_profiles: false, - can_edit_other_users_tts_models: false, - can_edit_other_users_w2l_templates: false, - can_delete_other_users_tts_models: false, - can_delete_other_users_tts_results: false, - can_delete_other_users_w2l_templates: false, - can_delete_other_users_w2l_results: false, - can_ban_users: false, - can_delete_users: false, -}; -const SESSION_OK = fixture("SessionInfoSuccessResponse", { - success: true, - logged_in: true, - user: SESSION_USER, -}); -const SESSION_ANONYMOUS = fixture("SessionInfoSuccessResponse", { - success: true, - logged_in: false, - user: null, -}); - interface Route { status: number; body: unknown; diff --git a/mcp/test/helpers/fixtures.ts b/mcp/test/helpers/fixtures.ts new file mode 100644 index 0000000000..f638a3546a --- /dev/null +++ b/mcp/test/helpers/fixtures.ts @@ -0,0 +1,61 @@ +import { fixture } from "./contract"; + +/** Spec-validated upstream fixtures shared across suites. */ + +export const SIGNED_SESSION = + "eyJhbGciOiJIUzI1NiJ9.eyJzZXNzaW9uX3Rva2VuIjoic2Vzc2lvbl90ZXN0In0.c2lnbmF0dXJl"; + +export const SESSION_USER = { + core_info: { + user_token: "user_localdev1", + username: "localdev1", + display_name: "Local Dev", + gravatar_hash: "0".repeat(32), + default_avatar: { color_index: 0, image_index: 0 }, + }, + user_token: "user_localdev1", + username: "localdev1", + display_name: "Local Dev", + email_gravatar_hash: "0".repeat(32), + onboarding: { + email_not_set: false, + email_not_confirmed: true, + password_not_set: false, + username_not_customized: false, + }, + can_access_studio: false, + maybe_feature_flags: ["api_key", "upload_3d"], + fakeyou_plan: "free", + storyteller_stream_plan: "free", + can_use_tts: true, + can_use_w2l: true, + can_delete_own_tts_results: true, + can_delete_own_w2l_results: true, + can_delete_own_account: true, + can_upload_tts_models: true, + can_upload_w2l_templates: true, + can_delete_own_tts_models: true, + can_delete_own_w2l_templates: true, + can_approve_w2l_templates: false, + can_edit_other_users_profiles: false, + can_edit_other_users_tts_models: false, + can_edit_other_users_w2l_templates: false, + can_delete_other_users_tts_models: false, + can_delete_other_users_tts_results: false, + can_delete_other_users_w2l_templates: false, + can_delete_other_users_w2l_results: false, + can_ban_users: false, + can_delete_users: false, +}; + +export const SESSION_OK = fixture("SessionInfoSuccessResponse", { + success: true, + logged_in: true, + user: SESSION_USER, +}); + +export const SESSION_ANONYMOUS = fixture("SessionInfoSuccessResponse", { + success: true, + logged_in: false, + user: null, +}); diff --git a/mcp/test/mcp/get-account.test.ts b/mcp/test/mcp/get-account.test.ts new file mode 100644 index 0000000000..13ad6d8613 --- /dev/null +++ b/mcp/test/mcp/get-account.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "vitest"; + +import { getAccount } from "../../src/mcp/tools/get-account"; +import { ToolFailure, UpstreamSessionInvalid } from "../../src/mcp/tools/types"; +import { principalFromProps } from "../../src/tokens/principal"; +import { createUpstreamClient } from "../../src/upstream/client"; +import { SESSION_ANONYMOUS, SESSION_OK, SIGNED_SESSION } from "../helpers/fixtures"; + +const principal = principalFromProps({ + credential: { kind: "session", signedSession: SIGNED_SESSION }, + grantIssuedAt: 1_800_000_000_000, + userToken: "user_localdev1", + username: "localdev1", + displayName: "Local Dev", + scopes: ["read:account"], +}); + +function contextWith(status: number, body: unknown) { + const upstream = createUpstreamClient({ + baseUrl: "https://api.example.test", + use: "read", + credential: principal.credential, + fetch: () => + Promise.resolve( + new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }), + ), + }); + return { principal, upstream }; +} + +describe("get_account", () => { + it("describes the signed-in user, flags, and setup state", async () => { + const result = await getAccount.handler(contextWith(200, SESSION_OK), {}); + expect(result.structured).toEqual({ + username: "localdev1", + display_name: "Local Dev", + feature_flags: ["api_key", "upload_3d"], + account_setup: { + email_set: true, + email_confirmed: false, + password_set: true, + username_customized: true, + }, + }); + expect(result.text).toBe( + "Signed in as localdev1 (Local Dev). Feature flags: api_key, upload_3d. Still to do: email confirmed.", + ); + }); + + it("says setup is complete when nothing is outstanding", async () => { + const complete = { + ...SESSION_OK, + user: { + ...SESSION_OK.user, + onboarding: { + email_not_set: false, + email_not_confirmed: false, + password_not_set: false, + username_not_customized: false, + }, + maybe_feature_flags: [], + }, + }; + const result = await getAccount.handler(contextWith(200, complete), {}); + expect(result.text).toBe( + "Signed in as localdev1 (Local Dev). Feature flags: none. Account setup complete.", + ); + }); + + it("fails when the session identifies nobody", async () => { + await expect(getAccount.handler(contextWith(200, SESSION_ANONYMOUS), {})).rejects.toThrow( + ToolFailure, + ); + }); + + it("signals an invalid upstream session on 401", async () => { + await expect( + getAccount.handler(contextWith(401, { success: false, error_code: 401 }), {}), + ).rejects.toThrow(UpstreamSessionInvalid); + }); +}); diff --git a/mcp/test/mcp/handler.test.ts b/mcp/test/mcp/handler.test.ts index 92a5d90da9..626901b54c 100644 --- a/mcp/test/mcp/handler.test.ts +++ b/mcp/test/mcp/handler.test.ts @@ -135,7 +135,7 @@ describe("the protected MCP route", () => { expect(client.getServerVersion()?.name).toBe("artcraft"); const { tools } = await client.listTools(); - expect(tools.map((t) => t.name)).toEqual(["get_credit_balance"]); + expect(tools.map((t) => t.name).sort()).toEqual(["get_account", "get_credit_balance"]); const result = await client.callTool({ name: "get_credit_balance", arguments: {} }); expect(result.isError).toBeFalsy(); diff --git a/mcp/test/mcp/server.test.ts b/mcp/test/mcp/server.test.ts index 4ef5282b5f..0a2858edb4 100644 --- a/mcp/test/mcp/server.test.ts +++ b/mcp/test/mcp/server.test.ts @@ -71,12 +71,12 @@ async function connect(scopes: string[], status = 200) { } describe("MCP server", () => { - it("identifies itself and lists get_credit_balance with its schema and annotations", async () => { + it("identifies itself and lists the read:account tools with schemas and annotations", async () => { const { client } = await connect(["read:account"]); expect(client.getServerVersion()).toMatchObject(SERVER_INFO); const { tools } = await client.listTools(); - expect(tools.map((t) => t.name)).toEqual(["get_credit_balance"]); - const tool = tools[0]; + expect(tools.map((t) => t.name).sort()).toEqual(["get_account", "get_credit_balance"]); + const tool = tools.find((t) => t.name === "get_credit_balance"); expect(tool?.annotations).toMatchObject({ readOnlyHint: true, destructiveHint: false }); expect(tool?.outputSchema).toBeDefined(); expect(tool?.description).toMatch(/credit balance/);