From 946c62f9759f0cd2f6ba1221d8fc6c41dccfbfb8 Mon Sep 17 00:00:00 2001 From: Anirudh Kamath Date: Tue, 28 Jul 2026 11:03:30 -0700 Subject: [PATCH 1/2] Run scripts as the signed-in email and return an auth URL on 401. Lock the run-as field to session email, require a session once OIDC is configured, and surface authorizationUrl (plus MCP WWW-Authenticate) so clients can open sign-in and retry. Co-authored-by: Cursor --- apps/frontend/src/atoms.ts | 15 +++--- apps/frontend/src/components/auth-status.tsx | 9 ++-- .../layouts/scripts-workbench.stories.tsx | 3 +- .../src/components/scripts/panes.test.tsx | 2 - .../components/scripts/run-panel.stories.tsx | 4 -- .../src/components/scripts/run-panel.tsx | 48 ++++++++++++++----- .../src/components/scripts/script-panes.tsx | 6 +-- .../scripts/script-run-pane.stories.tsx | 4 -- .../src/components/scripts/script-screen.tsx | 28 +++++++---- apps/frontend/src/hooks/api.ts | 22 ++++++++- apps/frontend/src/lib/auth-client.ts | 4 +- packages/api/src/app.ts | 20 ++++++-- packages/api/src/auth-schema.ts | 6 +-- packages/api/src/auth.ts | 25 ++++++---- packages/api/src/mcp.ts | 36 +++++++++++++- packages/api/src/schemas.ts | 17 +++++++ 16 files changed, 177 insertions(+), 72 deletions(-) diff --git a/apps/frontend/src/atoms.ts b/apps/frontend/src/atoms.ts index aa3b124..08b2598 100644 --- a/apps/frontend/src/atoms.ts +++ b/apps/frontend/src/atoms.ts @@ -5,19 +5,18 @@ import { atom } from "jotai" * * Almost nothing does. What the user is looking at is the URL's job, and a run * belongs to the script whose page it happens on — so that lives in the pane and - * resets when the pane changes script. What is left is the user we run as, which is - * worth typing once, and the delete dialog's target, because the dialog is mounted - * above the routes. + * resets when the pane changes script. What is left is the user we run as, which + * the auth chip fills from the signed-in email, and the delete dialog's target, + * because the dialog is mounted above the routes. */ /** - * The Arcade end user tools execute as. Kept across scripts; it rarely changes. + * The Arcade end user tools execute as. Kept across scripts. * - * When OIDC login is configured, the rail auth chip overwrites this with the - * signed-in account id (`sub`). The default remains a real account so local - * runs without auth still reach authorized tools instead of looking broken. + * The rail auth chip sets this from the signed-in email and clears it on sign + * out. Empty means not signed in; a run then gets a 401 with an authorization URL. */ -export const userIdAtom = atom("anirudh@arcade.dev") +export const userIdAtom = atom("") /** Which script the delete dialog is about to remove. */ export const deleteTargetAtom = atom(null) diff --git a/apps/frontend/src/components/auth-status.tsx b/apps/frontend/src/components/auth-status.tsx index 7081731..52ce2ab 100644 --- a/apps/frontend/src/components/auth-status.tsx +++ b/apps/frontend/src/components/auth-status.tsx @@ -1,6 +1,6 @@ /** - * Session chip in the rail: sign in / out, and mirror the Arcade account id - * into the run-as atom so tool calls use the signed-in identity. + * Session chip in the rail: sign in / out, and mirror the signed-in email into + * the run-as atom so tool calls use that identity. */ import { Link } from "@tanstack/react-router" import { useSetAtom } from "jotai" @@ -14,9 +14,10 @@ export function AuthStatus() { const setUserId = useSetAtom(userIdAtom) useEffect(() => { + if (isPending) return const id = session?.user ? arcadeUserIdFromSession(session.user) : null - if (id) setUserId(id) - }, [session, setUserId]) + setUserId(id ?? "") + }, [session, isPending, setUserId]) if (isPending) { return ( diff --git a/apps/frontend/src/components/layouts/scripts-workbench.stories.tsx b/apps/frontend/src/components/layouts/scripts-workbench.stories.tsx index 3a2dde9..c78a366 100644 --- a/apps/frontend/src/components/layouts/scripts-workbench.stories.tsx +++ b/apps/frontend/src/components/layouts/scripts-workbench.stories.tsx @@ -66,7 +66,7 @@ function Workbench({ const [sheetOpen, setSheetOpen] = useState(detailsOpen) const [chatVisible, setChatVisible] = useState(chatOpen) const [prompt, setPrompt] = useState("") - const [userId, setUserId] = useState("anirudh@arcade.dev") + const userId = "anirudh@arcade.dev" const [inputJson, setInputJson] = useState( '{\n "owner": "arcadeai",\n "repo": "arcade-ai",\n "number": 481\n}\n' ) @@ -130,7 +130,6 @@ function Workbench({ onInputJsonChange={setInputJson} onRun={noop} onShowDetails={() => setSheetOpen(true)} - onUserIdChange={setUserId} report={report} running={running} script={script} diff --git a/apps/frontend/src/components/scripts/panes.test.tsx b/apps/frontend/src/components/scripts/panes.test.tsx index 21f7401..415dbfc 100644 --- a/apps/frontend/src/components/scripts/panes.test.tsx +++ b/apps/frontend/src/components/scripts/panes.test.tsx @@ -45,7 +45,6 @@ const runPane = (name: string) => onInputJsonChange={noop} onRun={noop} onShowDetails={noop} - onUserIdChange={noop} script={scriptNamed(name)} userId="user" /> @@ -193,7 +192,6 @@ test("run panel reports the outcome, the calls and the drift", () => { diff --git a/apps/frontend/src/components/scripts/run-panel.stories.tsx b/apps/frontend/src/components/scripts/run-panel.stories.tsx index e42d488..3930bdb 100644 --- a/apps/frontend/src/components/scripts/run-panel.stories.tsx +++ b/apps/frontend/src/components/scripts/run-panel.stories.tsx @@ -14,7 +14,6 @@ const meta = { inputJson: '{\n "owner": "arcadeai",\n "repo": "arcade-ai",\n "number": 481\n}\n', onInputJsonChange: noop, - onUserIdChange: noop, userId: "anirudh@arcade.dev", }, decorators: [ @@ -31,7 +30,6 @@ type Story = StoryObj export const Empty: Story = { render: function EmptyStory(args) { - const [userId, setUserId] = useState(args.userId) const [inputJson, setInputJson] = useState(args.inputJson) return ( @@ -39,8 +37,6 @@ export const Empty: Story = { {...args} inputJson={inputJson} onInputJsonChange={setInputJson} - onUserIdChange={setUserId} - userId={userId} /> ) }, diff --git a/apps/frontend/src/components/scripts/run-panel.tsx b/apps/frontend/src/components/scripts/run-panel.tsx index 7bfa54c..5c264b1 100644 --- a/apps/frontend/src/components/scripts/run-panel.tsx +++ b/apps/frontend/src/components/scripts/run-panel.tsx @@ -1,13 +1,16 @@ /** * What you fill in to run a script, and what comes back. * - * The user id is a field rather than a setting because tools execute as a named - * end user with that user's authorizations — never as the deployment — so it is - * part of the run, not of the app. There is no dry-run: a plausible value - * generated from a declared shape only proves the shape was declared. + * The end user is shown rather than edited: tools execute as that named + * account with their authorizations — never as the deployment — and a signed-in + * session locks the field to the user's email. A 401 with an authorization URL + * is shown here the same way tool grants surface an Authorize link. There is no + * dry-run: a plausible value generated from a declared shape only proves the + * shape was declared. */ import { UserIcon } from "lucide-react" import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" +import { Button } from "@/components/ui/button" import { Field, FieldDescription, FieldLabel } from "@/components/ui/field" import { InputGroup, @@ -20,19 +23,19 @@ import type { RunReportView } from "./types" function RunPanel({ userId, - onUserIdChange, inputJson, onInputJsonChange, disabled = false, error = null, + authorizationUrl = null, report = null, }: { userId: string - onUserIdChange: (userId: string) => void inputJson: string onInputJsonChange: (inputJson: string) => void disabled?: boolean error?: string | null + authorizationUrl?: string | null report?: RunReportView | null }) { return ( @@ -44,17 +47,15 @@ function RunPanel({ onUserIdChange(event.target.value)} placeholder="user@example.com" value={userId} /> - The Arcade end user. Signed-in sessions fill this from your OIDC - account id; otherwise type an email or UUID. Every tool call is - bounded by what they could already do themselves. + The Arcade end user. Filled from your signed-in email. Every tool call + is bounded by what they could already do themselves. @@ -70,8 +71,29 @@ function RunPanel({ {error ? ( - Could not run - {error} + + {authorizationUrl ? "Sign in required" : "Could not run"} + + + {error} + {authorizationUrl ? ( + + ) : null} + ) : null} diff --git a/apps/frontend/src/components/scripts/script-panes.tsx b/apps/frontend/src/components/scripts/script-panes.tsx index c5a6fb5..7552d86 100644 --- a/apps/frontend/src/components/scripts/script-panes.tsx +++ b/apps/frontend/src/components/scripts/script-panes.tsx @@ -124,11 +124,11 @@ function BrowsePane({ function ScriptRunPane({ script, userId, - onUserIdChange, inputJson, onInputJsonChange, running = false, error = null, + authorizationUrl = null, report = null, onRun, onShowDetails, @@ -136,11 +136,11 @@ function ScriptRunPane({ }: { script: ScriptView userId: string - onUserIdChange: (userId: string) => void inputJson: string onInputJsonChange: (inputJson: string) => void running?: boolean error?: string | null + authorizationUrl?: string | null report?: RunReportView | null onRun: () => void onShowDetails: () => void @@ -183,11 +183,11 @@ function ScriptRunPane({ diff --git a/apps/frontend/src/components/scripts/script-run-pane.stories.tsx b/apps/frontend/src/components/scripts/script-run-pane.stories.tsx index f9e4845..69da62b 100644 --- a/apps/frontend/src/components/scripts/script-run-pane.stories.tsx +++ b/apps/frontend/src/components/scripts/script-run-pane.stories.tsx @@ -29,7 +29,6 @@ const meta = { onInputJsonChange: noop, onRun: noop, onShowDetails: noop, - onUserIdChange: noop, script: summarizeIssue, userId: "anirudh@arcade.dev", }, @@ -50,7 +49,6 @@ type Story = StoryObj /** Editable, so the input's JSON badge reacts to typing. */ export const Ready: Story = { render: function ReadyStory(args) { - const [userId, setUserId] = useState(args.userId) const [inputJson, setInputJson] = useState(args.inputJson) return ( @@ -58,8 +56,6 @@ export const Ready: Story = { {...args} inputJson={inputJson} onInputJsonChange={setInputJson} - onUserIdChange={setUserId} - userId={userId} /> ) }, diff --git a/apps/frontend/src/components/scripts/script-screen.tsx b/apps/frontend/src/components/scripts/script-screen.tsx index 4d11e5f..d5889d5 100644 --- a/apps/frontend/src/components/scripts/script-screen.tsx +++ b/apps/frontend/src/components/scripts/script-screen.tsx @@ -6,13 +6,15 @@ * report next to this one's input. The input starts from the schema's declared * `default`s, with type-shaped placeholders for any required field that lacks * one — so Run always has a complete payload to edit. The user id is the - * exception and lives in an atom: it is the same person whichever script they run. + * exception and lives in an atom: filled from the signed-in email when present. + * An unauthenticated run is allowed to hit the API; a 401 returns an + * authorization URL to open. */ -import { useAtom } from "jotai" +import { useAtomValue } from "jotai" import { useState } from "react" import { userIdAtom } from "@/atoms" -import { useRunScript } from "@/hooks/api" +import { AuthRecoveryError, useRunScript } from "@/hooks/api" import { useScriptActions } from "@/hooks/script-actions" import { defaultInputJson } from "./default-input" import { ScriptDetailsSheet } from "./script-detail" @@ -20,11 +22,12 @@ import { ScriptRunPane } from "./script-panes" import type { RunReportView, ScriptView } from "./types" export function ScriptScreen({ script }: { script: ScriptView }) { - const [userId, setUserId] = useAtom(userIdAtom) + const userId = useAtomValue(userIdAtom) const [inputJson, setInputJson] = useState(() => defaultInputJson(script.input) ) const [error, setError] = useState(null) + const [authorizationUrl, setAuthorizationUrl] = useState(null) const [report, setReport] = useState(null) const [detailsOpen, setDetailsOpen] = useState(false) const { openDelete } = useScriptActions() @@ -32,6 +35,7 @@ export function ScriptScreen({ script }: { script: ScriptView }) { const onRun = async () => { setError(null) + setAuthorizationUrl(null) setReport(null) let input: unknown try { @@ -40,18 +44,22 @@ export function ScriptScreen({ script }: { script: ScriptView }) { setError("Input must be valid JSON") return } - if (!userId.trim()) { - setError("A user id is required — tools run as a named end user") - return - } try { setReport( await run.mutateAsync({ name: script.name, - body: { input, userId: userId.trim() }, + // Body `userId` is ignored once a session exists; a placeholder keeps + // the request valid so an unauthenticated run can still receive a 401 + // with an authorization URL. + body: { input, userId: userId.trim() || "unauthenticated" }, }) ) } catch (err) { + if (err instanceof AuthRecoveryError) { + setError(err.message) + setAuthorizationUrl(err.authorizationUrl) + return + } setError(err instanceof Error ? err.message : String(err)) } } @@ -59,13 +67,13 @@ export function ScriptScreen({ script }: { script: ScriptView }) { return ( <> void onRun()} onShowDetails={() => setDetailsOpen(true)} - onUserIdChange={setUserId} report={report} running={run.isPending} script={script} diff --git a/apps/frontend/src/hooks/api.ts b/apps/frontend/src/hooks/api.ts index 239b436..a6f0d1f 100644 --- a/apps/frontend/src/hooks/api.ts +++ b/apps/frontend/src/hooks/api.ts @@ -109,12 +109,32 @@ export type RunScriptBody = { export type RunReport = Awaited> +/** 401 from run — open `authorizationUrl`, then retry. */ +export class AuthRecoveryError extends Error { + readonly authorizationUrl: string + + constructor(message: string, authorizationUrl: string) { + super(message) + this.name = "AuthRecoveryError" + this.authorizationUrl = authorizationUrl + } +} + async function fetchRun(name: string, body: RunScriptBody) { const res = await api.scripts[":name"].run.$post({ param: { name }, json: body, }) - if (res.status === 404 || res.status === 401) { + if (res.status === 401) { + const err = await res.json() + const url = + "authorizationUrl" in err && typeof err.authorizationUrl === "string" + ? err.authorizationUrl + : null + if (url) throw new AuthRecoveryError(err.message, url) + throw new Error(err.message) + } + if (res.status === 404) { const err = await res.json() throw new Error(err.message) } diff --git a/apps/frontend/src/lib/auth-client.ts b/apps/frontend/src/lib/auth-client.ts index f39dece..2ee4bc0 100644 --- a/apps/frontend/src/lib/auth-client.ts +++ b/apps/frontend/src/lib/auth-client.ts @@ -30,12 +30,12 @@ export function signInWithOidc(callbackURL = "/") { }) } -/** Arcade `user_id` from a Better Auth session user. */ +/** Arcade `user_id` from a Better Auth session user: email, else OIDC `sub`. */ export function arcadeUserIdFromSession(user: { accountId?: string | null email?: string | null }): string | null { - if (user.accountId) return user.accountId if (user.email) return user.email + if (user.accountId) return user.accountId return null } diff --git a/packages/api/src/app.ts b/packages/api/src/app.ts index 6f778a6..a3daee0 100644 --- a/packages/api/src/app.ts +++ b/packages/api/src/app.ts @@ -10,6 +10,7 @@ import { getSessionUser, isAuthConfigured, resolveRunUserId, + signInAuthorizationUrl, } from "./auth" import { authorizationFor, @@ -24,6 +25,7 @@ import { mcpHandler } from "./mcp" import { openApiDocument } from "./openapi" import { type ScriptRow, scripts, tools } from "./schema" import { + AuthRecoveryResponseSchema, CoverageResponseSchema, ErrorResponseSchema, MeResponseSchema, @@ -477,8 +479,8 @@ export const routes = new Hono() "The input did not match the script's declared `input` schema." ), 401: json( - ErrorResponseSchema, - "AUTH_REQUIRED is set and there is no session." + AuthRecoveryResponseSchema, + "Sign in required — body includes `authorizationUrl` for the browser OIDC flow." ), 404: json(ErrorResponseSchema, "No such script."), 409: json( @@ -505,11 +507,21 @@ export const routes = new Hono() const { input, userId: bodyUserId } = c.req.valid("json") const userId = await resolveRunUserId(c.req.raw.headers, bodyUserId) if (userId === null) { + const authorizationUrl = signInAuthorizationUrl() c.header("x-bff-auth-recovery", "session_missing") + // MCP clients look for WWW-Authenticate on 401 (SEP-1489 / RFC 9728). + // `authorization_uri` carries the browser sign-in URL until this app + // publishes protected-resource metadata. + c.header( + "WWW-Authenticate", + `Bearer error="invalid_token", error_description="Sign in required to run scripts", authorization_uri="${authorizationUrl}"` + ) return c.json( { - error: "auth_recovery_required", - message: "Sign in required to run scripts.", + error: "auth_recovery_required" as const, + message: + "Sign in required to run scripts. Open authorizationUrl in a browser, complete sign-in, then retry.", + authorizationUrl, }, 401 ) diff --git a/packages/api/src/auth-schema.ts b/packages/api/src/auth-schema.ts index db2b314..bf85066 100644 --- a/packages/api/src/auth-schema.ts +++ b/packages/api/src/auth-schema.ts @@ -2,9 +2,9 @@ * Better Auth tables. Kept separate from the app schema so the CLI / docs can * regenerate this file without fighting scripts/tools/runs. * - * `user.accountId` is Arcade's identity key: the OIDC `sub` (Ory identity id). - * Tool runs should use that value as Arcade `user_id`, not Better Auth's internal - * `user.id`. + * `user.accountId` stores the OIDC `sub` (Ory identity id). Tool runs prefer the + * user's email as Arcade `user_id`, falling back to `accountId` — never Better + * Auth's internal `user.id`. */ import { boolean, pgTable, text, timestamp } from "drizzle-orm/pg-core" diff --git a/packages/api/src/auth.ts b/packages/api/src/auth.ts index 73b8deb..50a8fc4 100644 --- a/packages/api/src/auth.ts +++ b/packages/api/src/auth.ts @@ -3,11 +3,11 @@ * * Same model as Arcade's experience-api: the browser only holds httpOnly session * cookies; OIDC access tokens live in the DB and are attached as Bearer when we - * call Arcade upstream. Identity for tool runs is `user.accountId` (= OIDC `sub`). + * call Arcade upstream. Identity for tool runs is the signed-in user's email + * (falling back to `user.accountId` / OIDC `sub` when email is missing). * - * Auth is optional until OIDC env is set — the rest of the API keeps working with - * a typed-in Arcade user id. Set `AUTH_REQUIRED=true` to refuse unauthenticated - * runs once login works. + * Auth is optional until OIDC env is set — without it, the API accepts a body + * `userId` for local runs. Once OIDC is configured, runs require a session. */ import { betterAuth } from "better-auth" import { drizzleAdapter } from "better-auth/adapters/drizzle" @@ -69,6 +69,11 @@ export function authBaseURL(): string { ) } +/** Browser sign-in page for session recovery (401 → open this, then retry). */ +export function signInAuthorizationUrl(): string { + return new URL("/login", authBaseURL()).href +} + function trustedOrigins(): string[] { const fromEnv = (process.env.CORS_ORIGIN ?? "") .split(",") @@ -160,13 +165,13 @@ export function getAuth(): Auth | null { export type SessionUser = z.infer -/** Arcade `user_id` for a signed-in user: OIDC `sub`, else email. */ +/** Arcade `user_id` for a signed-in user: email, else OIDC `sub`. */ export function arcadeUserId(user: { accountId?: string | null email?: string | null }): string | null { - if (user.accountId) return user.accountId if (user.email) return user.email + if (user.accountId) return user.accountId return null } @@ -182,9 +187,9 @@ export async function getSessionUser( } /** - * Prefer the signed-in Arcade identity; fall back to the request body for local - * runs without OIDC. When `AUTH_REQUIRED=true`, missing session yields null so - * the route can 401 with a typed error body. + * Prefer the signed-in Arcade identity. When OIDC is configured (or + * `AUTH_REQUIRED=true`), a missing session yields null so the route can 401. + * Without auth configured, fall back to the request body for local/API tests. */ export async function resolveRunUserId( headers: Headers, @@ -195,7 +200,7 @@ export async function resolveRunUserId( const id = arcadeUserId(user) if (id) return id } - if (process.env.AUTH_REQUIRED === "true") return null + if (isAuthConfigured() || process.env.AUTH_REQUIRED === "true") return null return bodyUserId } diff --git a/packages/api/src/mcp.ts b/packages/api/src/mcp.ts index cdd3f41..94b5f93 100644 --- a/packages/api/src/mcp.ts +++ b/packages/api/src/mcp.ts @@ -155,11 +155,43 @@ function successSchema(responses: Json, components: Json): Json | undefined { * JSON 2xx responses become `structuredContent` so clients get typed results; * anything 4xx/5xx becomes an `isError` result carrying the response body, which * the SDK exempts from output-schema validation. + * + * A 401 with `WWW-Authenticate` (or an `authorizationUrl` in the JSON body) is + * shaped for MCP auth recovery: agents see the URL in `content`, and clients that + * understand SEP-1489 get `mcp/www_authenticate` in `_meta`. */ async function toResult(response: Response): Promise { const text = await response.text() - const content = [{ type: "text" as const, text }] - if (!response.ok) return { content, isError: true } + const wwwAuthenticate = response.headers.get("www-authenticate") + let bodyText = text + if (!response.ok && response.headers.get("content-type")?.includes("json")) { + try { + const body = JSON.parse(text) as { + message?: string + authorizationUrl?: string + } + if (typeof body.authorizationUrl === "string") { + bodyText = [ + body.message ?? "Authorization required.", + "", + "Open this URL to authorize, then retry this tool call:", + body.authorizationUrl, + ].join("\n") + } + } catch { + // Keep the raw body when it is not JSON we recognize. + } + } + const content = [{ type: "text" as const, text: bodyText }] + if (!response.ok) { + return { + content, + isError: true, + ...(wwwAuthenticate + ? { _meta: { "mcp/www_authenticate": [wwwAuthenticate] } } + : {}), + } + } const parsed = response.headers .get("content-type") ?.includes("application/json") diff --git a/packages/api/src/schemas.ts b/packages/api/src/schemas.ts index d36decd..7808c9a 100644 --- a/packages/api/src/schemas.ts +++ b/packages/api/src/schemas.ts @@ -103,6 +103,23 @@ export const ErrorResponseSchema = z .object({ error: z.string(), message: z.string() }) .meta({ id: "ErrorResponse" }) +/** + * 401 when a run needs a browser sign-in. `authorizationUrl` is what MCP clients + * and agents should hand to the user — open it, complete OIDC, then retry. + */ +export const AuthRecoveryResponseSchema = z + .object({ + error: z.literal("auth_recovery_required"), + message: z.string(), + authorizationUrl: z + .string() + .url() + .describe( + "Open this URL in a browser to sign in, then retry the tool call." + ), + }) + .meta({ id: "AuthRecoveryResponse" }) + /** `?toolkit=Github,Slack` — same parsing as `ToolsQuerySchema`. */ const toolkitFilter = z .union([z.string(), z.array(z.string())]) From 433c60809581893401d96ec4613bd96357c31e20 Mon Sep 17 00:00:00 2001 From: Anirudh Kamath Date: Tue, 28 Jul 2026 11:03:55 -0700 Subject: [PATCH 2/2] Fix biome format and drop type assertion in MCP 401 handling. Co-authored-by: Cursor --- .../src/components/scripts/run-panel.tsx | 6 +----- packages/api/src/mcp.ts | 19 ++++++++++++------- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/apps/frontend/src/components/scripts/run-panel.tsx b/apps/frontend/src/components/scripts/run-panel.tsx index 5c264b1..c1109a7 100644 --- a/apps/frontend/src/components/scripts/run-panel.tsx +++ b/apps/frontend/src/components/scripts/run-panel.tsx @@ -81,11 +81,7 @@ function RunPanel({ className="w-fit" nativeButton={false} render={ - + } size="sm" variant="outline" diff --git a/packages/api/src/mcp.ts b/packages/api/src/mcp.ts index 94b5f93..ba8dd09 100644 --- a/packages/api/src/mcp.ts +++ b/packages/api/src/mcp.ts @@ -166,16 +166,21 @@ async function toResult(response: Response): Promise { let bodyText = text if (!response.ok && response.headers.get("content-type")?.includes("json")) { try { - const body = JSON.parse(text) as { - message?: string - authorizationUrl?: string - } - if (typeof body.authorizationUrl === "string") { + const body: unknown = JSON.parse(text) + const authorizationUrl = + isJson(body) && typeof body.authorizationUrl === "string" + ? body.authorizationUrl + : undefined + const message = + isJson(body) && typeof body.message === "string" + ? body.message + : undefined + if (authorizationUrl !== undefined) { bodyText = [ - body.message ?? "Authorization required.", + message ?? "Authorization required.", "", "Open this URL to authorize, then retry this tool call:", - body.authorizationUrl, + authorizationUrl, ].join("\n") } } catch {