mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-31 23:47:51 +00:00
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:
1 parent
728398603a
commit
bf12fcbf66
7 files changed
+224
-60
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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/);
|
||||
|
||||
Reference in new issue
Block a user