From 4f2f0824034f2016abc4269dd3a1667578fbfc87 Mon Sep 17 00:00:00 2001 From: Tanish Manku Date: Tue, 25 Aug 2026 00:38:15 -0700 Subject: [PATCH] =?UTF-8?q?mcp:=20/connections=20manages=20personal=20toke?= =?UTF-8?q?ns=20=E2=80=94=20create=20(re-confirmed),=20show=20once,=20list?= =?UTF-8?q?,=20revoke?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A signed-in user names a token, picks 30 or 90 days, and confirms with their password or Google; that sign-in becomes the token's own upstream session and must be the account on the page. The secret is rendered once in the POST response (never a redirect URL); the list shows label, hint, and dates only. Revoke is scoped to the user. Claude-Session: https://claude.ai/code/session_01H3gfN2NCx3hBXSrZXYHUQ3 --- mcp/src/pages/connections-page.tsx | 188 ++++++++++++++++++++---- mcp/src/pages/connections.ts | 145 +++++++++++++++++- mcp/test/pages/connections-page.test.ts | 60 +++++++- mcp/test/pages/connections.test.ts | 100 +++++++++++++ 4 files changed, 457 insertions(+), 36 deletions(-) diff --git a/mcp/src/pages/connections-page.tsx b/mcp/src/pages/connections-page.tsx index 64ee55269c..626702cb25 100644 --- a/mcp/src/pages/connections-page.tsx +++ b/mcp/src/pages/connections-page.tsx @@ -2,8 +2,9 @@ import { isValidElement } from "hono/jsx"; /** * `/connections` — where a user sees which AI apps hold a grant to their Artcraft account and - * disconnects them. The app itself has no sessions screen, so this page is the only place that - * can show "Claude is connected". A pure function of its view model; everything is escaped. + * disconnects them, and where they mint personal tokens for integrations that cannot run the + * OAuth flow themselves. The app itself has no sessions screen, so this page is the only place + * that can show "Claude is connected". A pure function of its view model; everything is escaped. */ export interface ConnectionView { @@ -13,6 +14,21 @@ export interface ConnectionView { readonly createdAt: string; } +export interface PersonalTokenView { + readonly id: string; + readonly label: string; + readonly hint: string; + readonly createdAt: string; + readonly expiresAt: string; +} + +/** Shown exactly once, in the response to the request that created it. */ +export interface NewTokenView { + readonly label: string; + readonly secret: string; + readonly expiresAt: string; +} + export interface ConnectionsView { readonly csrfToken: string; readonly scriptNonce: string; @@ -24,6 +40,8 @@ export interface ConnectionsView { readonly signedIn?: { readonly username: string; readonly connections: readonly ConnectionView[]; + readonly personalTokens: readonly PersonalTokenView[]; + readonly newToken?: NewTokenView; }; } @@ -34,6 +52,9 @@ export const CONNECTIONS_FORM_FIELDS = { password: "password", googleCredential: "google_credential", grantId: "grant_id", + tokenLabel: "token_label", + tokenLifetime: "token_lifetime", + tokenId: "token_id", } as const; export const CONNECTIONS_PATHS = { @@ -41,8 +62,14 @@ export const CONNECTIONS_PATHS = { signIn: "/connections/sign-in", signOut: "/connections/sign-out", revoke: "/connections/revoke", + createToken: "/connections/tokens/create", + revokeToken: "/connections/tokens/revoke", } as const; +/** The lifetimes a user can pick, in days; the form value is the key. */ +export const TOKEN_LIFETIME_DAYS = ["30", "90"] as const; +export type TokenLifetimeDays = (typeof TOKEN_LIFETIME_DAYS)[number]; + export function renderConnectionsPage(view: ConnectionsView): string { const element: unknown = ; if (!isValidElement(element)) throw new Error("connections page did not render to a JSX node"); @@ -77,7 +104,7 @@ function ConnectionsPage({ view }: { view: ConnectionsView }) { )} - {view.googleClientId && !view.signedIn ? ( + {view.googleClientId ? ( <> @@ -98,25 +125,9 @@ function SignIn({ view }: { view: ConnectionsView }) {

- - - {view.googleClientId ? ( -
-
- -

or sign in with your password

-
- ) : null} + + + {view.googleClientId ? : null}