mcp: get_account tool

Who is signed in: username, display name, enabled feature flags, and
account-setup state (email set/confirmed, password set, username
customized) with a one-line text rendering that names what is still to
do. Credits and plan stay in get_credit_balance. Registered for
read:account alongside it.

Tests (4): structured output and text, the "setup complete" wording, an
anonymous session as a tool failure, 401 as an invalid session. The
SessionInfoSuccessResponse fixture moves to test/helpers/fixtures.ts
and is shared with the authenticator suite.

Claude-Session: https://claude.ai/code/session_01H3gfN2NCx3hBXSrZXYHUQ3
This commit is contained in:
Tanish Manku committed 2026-08-24 21:36:34 -07:00
1 parent 728398603a
commit bf12fcbf66
7 files changed
+224 -60

No files matched your search

+5 -1
View File
@@ -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<z.ZodRawShape, z.ZodRawShape>[] = [getCreditBalance];
export const TOOLS: readonly ToolDefinition<z.ZodRawShape, z.ZodRawShape>[] = [
getAccount,
getCreditBalance,
];
export interface McpServerDeps {
readonly principal: Principal;
+69
View File
@@ -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<Record<string, never>, 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<z.ZodObject<typeof outputSchema>>): 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}`;
}
+1 -55
View File
@@ -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;
+61
View File
@@ -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,
});
+84
View File
@@ -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);
});
});
+1 -1
View File
@@ -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();
+3 -3
View File
@@ -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/);