Skip to content

Commit 4f29269

Browse files
Vu-Johnclaude
andauthored
PR-I4: XAA resource-app registration wizard, steps 1-2 (#2568)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 55de588 commit 4f29269

6 files changed

Lines changed: 926 additions & 0 deletions

File tree

Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
import { useState } from "react";
2+
import {
3+
AlertTriangle,
4+
CheckCircle2,
5+
Loader2,
6+
ShieldAlert,
7+
} from "lucide-react";
8+
import { Button } from "@mcpjam/design-system/button";
9+
import { Input } from "@mcpjam/design-system/input";
10+
import { Label } from "@mcpjam/design-system/label";
11+
import { RadioGroup, RadioGroupItem } from "@mcpjam/design-system/radio-group";
12+
import {
13+
discoverAuthorizationServer,
14+
type AsDiscoveryResult,
15+
} from "@/lib/xaa/discovery-client";
16+
import type { XaaAuthServerMode } from "@/lib/xaa/types";
17+
import type { RegistrationDraft } from "./wizard-draft";
18+
19+
interface AuthServerStepProps {
20+
draft: RegistrationDraft;
21+
onChange: (updates: Partial<RegistrationDraft>) => void;
22+
/** Editing a registration that already has a stored secret. */
23+
hasStoredSecret: boolean;
24+
}
25+
26+
type DiscoveryState =
27+
| { status: "idle" }
28+
| { status: "loading" }
29+
| { status: "success"; result: AsDiscoveryResult }
30+
| { status: "error"; message: string };
31+
32+
function DiscoveryVerdict({ result }: { result: AsDiscoveryResult }) {
33+
const supportTone =
34+
result.jwtBearerSupport === "pass"
35+
? {
36+
Icon: CheckCircle2,
37+
className: "text-green-600 dark:text-green-400",
38+
}
39+
: result.jwtBearerSupport === "warn"
40+
? { Icon: AlertTriangle, className: "text-amber-500" }
41+
: { Icon: ShieldAlert, className: "text-red-500" };
42+
43+
return (
44+
<div
45+
data-testid="xaa-reg-discovery-verdict"
46+
className="space-y-1.5 rounded-md border border-border bg-muted/30 px-3 py-2 text-xs"
47+
>
48+
{result.issuer && (
49+
<div>
50+
<span className="text-muted-foreground">Issuer: </span>
51+
<code className="font-mono break-all">{result.issuer}</code>
52+
</div>
53+
)}
54+
<div className="flex items-start gap-1.5">
55+
<supportTone.Icon
56+
className={`mt-0.5 h-3.5 w-3.5 shrink-0 ${supportTone.className}`}
57+
/>
58+
<span className="text-muted-foreground">{result.jwtBearerDetail}</span>
59+
</div>
60+
{result.issuerMismatch && (
61+
<div className="flex items-start gap-1.5">
62+
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-amber-500" />
63+
<span className="text-muted-foreground">
64+
Advertised issuer{" "}
65+
<code className="font-mono">
66+
{result.issuerMismatch.advertised}
67+
</code>{" "}
68+
doesn&apos;t match the URL you entered.
69+
{result.issuerMismatch.schemeOnly &&
70+
" Only the scheme differs — if your server sits behind a TLS-terminating proxy, check its X-Forwarded-Proto handling."}
71+
</span>
72+
</div>
73+
)}
74+
</div>
75+
);
76+
}
77+
78+
export function AuthServerStep({
79+
draft,
80+
onChange,
81+
hasStoredSecret,
82+
}: AuthServerStepProps) {
83+
const [discovery, setDiscovery] = useState<DiscoveryState>({
84+
status: "idle",
85+
});
86+
87+
const discoveryInput = (draft.issuer || draft.tokenEndpoint).trim();
88+
89+
const handleDiscover = async () => {
90+
if (!discoveryInput) return;
91+
setDiscovery({ status: "loading" });
92+
try {
93+
const result = await discoverAuthorizationServer(
94+
draft.issuer.trim()
95+
? { issuer: draft.issuer.trim() }
96+
: { tokenEndpoint: draft.tokenEndpoint.trim() },
97+
);
98+
setDiscovery({ status: "success", result });
99+
onChange({
100+
...(result.issuer ? { issuer: result.issuer } : {}),
101+
// Autofill the token endpoint from metadata when the user hasn't
102+
// typed one themselves.
103+
...(result.tokenEndpoint && !draft.tokenEndpoint.trim()
104+
? { tokenEndpoint: result.tokenEndpoint }
105+
: {}),
106+
});
107+
} catch (error) {
108+
setDiscovery({
109+
status: "error",
110+
message: error instanceof Error ? error.message : "Discovery failed",
111+
});
112+
}
113+
};
114+
115+
const own = draft.authServerMode === "own";
116+
117+
return (
118+
<div className="space-y-4">
119+
<div className="space-y-1.5">
120+
<Label>Auth server</Label>
121+
<RadioGroup
122+
value={draft.authServerMode}
123+
onValueChange={(value) =>
124+
onChange({ authServerMode: value as XaaAuthServerMode })
125+
}
126+
className="flex flex-col gap-2"
127+
>
128+
<div className="flex items-center gap-2">
129+
<RadioGroupItem value="own" id="xaa-reg-as-own" />
130+
<Label htmlFor="xaa-reg-as-own" className="font-normal">
131+
My own auth server (issues access tokens for this resource)
132+
</Label>
133+
</div>
134+
<div className="flex items-center gap-2">
135+
<RadioGroupItem value="mcpjam" id="xaa-reg-as-mcpjam" />
136+
<Label htmlFor="xaa-reg-as-mcpjam" className="font-normal">
137+
MCPJam test auth server
138+
</Label>
139+
</div>
140+
</RadioGroup>
141+
</div>
142+
143+
{own && (
144+
<>
145+
<div className="space-y-1.5">
146+
<Label htmlFor="xaa-reg-issuer">Issuer</Label>
147+
<div className="flex items-stretch gap-2">
148+
<Input
149+
id="xaa-reg-issuer"
150+
value={draft.issuer}
151+
onChange={(event) => onChange({ issuer: event.target.value })}
152+
placeholder="https://auth.example.com"
153+
autoComplete="off"
154+
/>
155+
<Button
156+
type="button"
157+
variant="outline"
158+
size="sm"
159+
className="shrink-0 self-stretch"
160+
onClick={handleDiscover}
161+
disabled={!discoveryInput || discovery.status === "loading"}
162+
>
163+
{discovery.status === "loading" ? (
164+
<>
165+
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
166+
Discovering
167+
</>
168+
) : (
169+
"Discover"
170+
)}
171+
</Button>
172+
</div>
173+
<p className="text-xs text-muted-foreground">
174+
Discover fetches the server&apos;s metadata and fills in the token
175+
endpoint.
176+
</p>
177+
</div>
178+
179+
{discovery.status === "error" && (
180+
<div
181+
data-testid="xaa-reg-discovery-error"
182+
className="rounded-md border border-red-300 bg-red-50 px-3 py-2 text-xs text-red-700 dark:border-red-900 dark:bg-red-950/20 dark:text-red-300"
183+
>
184+
{discovery.message}
185+
</div>
186+
)}
187+
{discovery.status === "success" && (
188+
<DiscoveryVerdict result={discovery.result} />
189+
)}
190+
191+
<div className="space-y-1.5">
192+
<Label htmlFor="xaa-reg-token-endpoint">Token endpoint</Label>
193+
<Input
194+
id="xaa-reg-token-endpoint"
195+
value={draft.tokenEndpoint}
196+
onChange={(event) =>
197+
onChange({ tokenEndpoint: event.target.value })
198+
}
199+
placeholder="https://auth.example.com/oauth/token"
200+
autoComplete="off"
201+
/>
202+
</div>
203+
204+
<div className="space-y-1.5">
205+
<Label htmlFor="xaa-reg-client-id">Client ID</Label>
206+
<Input
207+
id="xaa-reg-client-id"
208+
value={draft.targetClientId}
209+
onChange={(event) =>
210+
onChange({ targetClientId: event.target.value })
211+
}
212+
placeholder="Client registered at your auth server"
213+
autoComplete="off"
214+
/>
215+
</div>
216+
217+
<div className="space-y-1.5">
218+
<Label htmlFor="xaa-reg-client-secret">Client secret</Label>
219+
<Input
220+
id="xaa-reg-client-secret"
221+
type="password"
222+
value={draft.secret}
223+
onChange={(event) => onChange({ secret: event.target.value })}
224+
placeholder={hasStoredSecret ? "••••••••" : "Optional"}
225+
autoComplete="new-password"
226+
/>
227+
<p className="text-xs text-muted-foreground">
228+
{hasStoredSecret
229+
? "A secret is stored for this registration. Leave blank to keep it; type a new value to replace it."
230+
: "Stored securely and never shown again."}
231+
</p>
232+
</div>
233+
</>
234+
)}
235+
236+
{!own && (
237+
<p className="text-xs text-muted-foreground">
238+
MCPJam plays the auth server: it validates the ID-JAG it minted and
239+
issues the access token itself. No endpoint or credentials needed.
240+
</p>
241+
)}
242+
</div>
243+
);
244+
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { Input } from "@mcpjam/design-system/input";
2+
import { Label } from "@mcpjam/design-system/label";
3+
import { RadioGroup, RadioGroupItem } from "@mcpjam/design-system/radio-group";
4+
import type { XaaResourceType } from "@/lib/xaa/types";
5+
import type { RegistrationDraft } from "./wizard-draft";
6+
7+
interface BasicInfoStepProps {
8+
draft: RegistrationDraft;
9+
onChange: (updates: Partial<RegistrationDraft>) => void;
10+
}
11+
12+
export function BasicInfoStep({ draft, onChange }: BasicInfoStepProps) {
13+
return (
14+
<div className="space-y-4">
15+
<div className="space-y-1.5">
16+
<Label htmlFor="xaa-reg-name">Name</Label>
17+
<Input
18+
id="xaa-reg-name"
19+
value={draft.name}
20+
onChange={(event) => onChange({ name: event.target.value })}
21+
placeholder="My resource server"
22+
autoComplete="off"
23+
/>
24+
</div>
25+
26+
<div className="space-y-1.5">
27+
<Label>Resource type</Label>
28+
<RadioGroup
29+
value={draft.resourceType}
30+
onValueChange={(value) =>
31+
onChange({ resourceType: value as XaaResourceType })
32+
}
33+
className="flex gap-4"
34+
>
35+
<div className="flex items-center gap-2">
36+
<RadioGroupItem value="mcp" id="xaa-reg-type-mcp" />
37+
<Label htmlFor="xaa-reg-type-mcp" className="font-normal">
38+
MCP server
39+
</Label>
40+
</div>
41+
<div className="flex items-center gap-2">
42+
<RadioGroupItem value="rest" id="xaa-reg-type-rest" />
43+
<Label htmlFor="xaa-reg-type-rest" className="font-normal">
44+
REST API
45+
</Label>
46+
</div>
47+
</RadioGroup>
48+
</div>
49+
50+
<div className="space-y-1.5">
51+
<Label htmlFor="xaa-reg-resource-url">Resource URL</Label>
52+
<Input
53+
id="xaa-reg-resource-url"
54+
value={draft.resourceUrl}
55+
onChange={(event) => onChange({ resourceUrl: event.target.value })}
56+
placeholder="https://your-server.example.com/mcp"
57+
autoComplete="off"
58+
/>
59+
<p className="text-xs text-muted-foreground">
60+
The resource identifier the ID-JAG&apos;s{" "}
61+
<code className="font-mono">resource</code> claim points at.
62+
</p>
63+
</div>
64+
</div>
65+
);
66+
}

0 commit comments

Comments
 (0)