Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion src/lib/oauth/mcp-oauth.functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,5 +60,12 @@ export const issueOauthCode = createServerFn({ method: "POST" })

const sep = data.redirect_uri.includes("?") ? "&" : "?";
const stateQ = data.state ? `&state=${encodeURIComponent(data.state)}` : "";
return { redirect_to: `${data.redirect_uri}${sep}code=${encodeURIComponent(code)}${stateQ}` };
// We also return the raw `code` so the success page can show it as a
// manual paste fallback when the client's loopback listener is no
// longer up (or for clients that use private-use URI schemes that
// never actually surface back to the browser tab).
return {
redirect_to: `${data.redirect_uri}${sep}code=${encodeURIComponent(code)}${stateQ}`,
code,
};
});
31 changes: 31 additions & 0 deletions src/routeTree.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import { Route as UHandleRouteImport } from './routes/u.$handle'
import { Route as SoulsSlugRouteImport } from './routes/souls.$slug'
import { Route as RunSlugRouteImport } from './routes/run.$slug'
import { Route as PacksSlugRouteImport } from './routes/packs.$slug'
import { Route as OauthSuccessRouteImport } from './routes/oauth.success'
import { Route as OauthAuthorizeRouteImport } from './routes/oauth.authorize'
import { Route as MarketplaceRankingsRouteImport } from './routes/marketplace.rankings'
import { Route as MarketplaceLeaderboardRouteImport } from './routes/marketplace.leaderboard'
Expand Down Expand Up @@ -285,6 +286,11 @@ const PacksSlugRoute = PacksSlugRouteImport.update({
path: '/packs/$slug',
getParentRoute: () => rootRouteImport,
} as any)
const OauthSuccessRoute = OauthSuccessRouteImport.update({
id: '/oauth/success',
path: '/oauth/success',
getParentRoute: () => rootRouteImport,
} as any)
const OauthAuthorizeRoute = OauthAuthorizeRouteImport.update({
id: '/oauth/authorize',
path: '/oauth/authorize',
Expand Down Expand Up @@ -654,6 +660,7 @@ export interface FileRoutesByFullPath {
'/marketplace/leaderboard': typeof MarketplaceLeaderboardRoute
'/marketplace/rankings': typeof MarketplaceRankingsRoute
'/oauth/authorize': typeof OauthAuthorizeRoute
'/oauth/success': typeof OauthSuccessRoute
'/packs/$slug': typeof PacksSlugRouteWithChildren
'/run/$slug': typeof RunSlugRoute
'/souls/$slug': typeof SoulsSlugRoute
Expand Down Expand Up @@ -750,6 +757,7 @@ export interface FileRoutesByTo {
'/marketplace/leaderboard': typeof MarketplaceLeaderboardRoute
'/marketplace/rankings': typeof MarketplaceRankingsRoute
'/oauth/authorize': typeof OauthAuthorizeRoute
'/oauth/success': typeof OauthSuccessRoute
'/packs/$slug': typeof PacksSlugRouteWithChildren
'/run/$slug': typeof RunSlugRoute
'/souls/$slug': typeof SoulsSlugRoute
Expand Down Expand Up @@ -848,6 +856,7 @@ export interface FileRoutesById {
'/marketplace/leaderboard': typeof MarketplaceLeaderboardRoute
'/marketplace/rankings': typeof MarketplaceRankingsRoute
'/oauth/authorize': typeof OauthAuthorizeRoute
'/oauth/success': typeof OauthSuccessRoute
'/packs/$slug': typeof PacksSlugRouteWithChildren
'/run/$slug': typeof RunSlugRoute
'/souls/$slug': typeof SoulsSlugRoute
Expand Down Expand Up @@ -947,6 +956,7 @@ export interface FileRouteTypes {
| '/marketplace/leaderboard'
| '/marketplace/rankings'
| '/oauth/authorize'
| '/oauth/success'
| '/packs/$slug'
| '/run/$slug'
| '/souls/$slug'
Expand Down Expand Up @@ -1043,6 +1053,7 @@ export interface FileRouteTypes {
| '/marketplace/leaderboard'
| '/marketplace/rankings'
| '/oauth/authorize'
| '/oauth/success'
| '/packs/$slug'
| '/run/$slug'
| '/souls/$slug'
Expand Down Expand Up @@ -1140,6 +1151,7 @@ export interface FileRouteTypes {
| '/marketplace/leaderboard'
| '/marketplace/rankings'
| '/oauth/authorize'
| '/oauth/success'
| '/packs/$slug'
| '/run/$slug'
| '/souls/$slug'
Expand Down Expand Up @@ -1226,6 +1238,7 @@ export interface RootRouteChildren {
MarketplaceLeaderboardRoute: typeof MarketplaceLeaderboardRoute
MarketplaceRankingsRoute: typeof MarketplaceRankingsRoute
OauthAuthorizeRoute: typeof OauthAuthorizeRoute
OauthSuccessRoute: typeof OauthSuccessRoute
PacksSlugRoute: typeof PacksSlugRouteWithChildren
RunSlugRoute: typeof RunSlugRoute
SoulsSlugRoute: typeof SoulsSlugRoute
Expand Down Expand Up @@ -1507,6 +1520,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof PacksSlugRouteImport
parentRoute: typeof rootRouteImport
}
'/oauth/success': {
id: '/oauth/success'
path: '/oauth/success'
fullPath: '/oauth/success'
preLoaderRoute: typeof OauthSuccessRouteImport
parentRoute: typeof rootRouteImport
}
'/oauth/authorize': {
id: '/oauth/authorize'
path: '/oauth/authorize'
Expand Down Expand Up @@ -2106,6 +2126,7 @@ const rootRouteChildren: RootRouteChildren = {
MarketplaceLeaderboardRoute: MarketplaceLeaderboardRoute,
MarketplaceRankingsRoute: MarketplaceRankingsRoute,
OauthAuthorizeRoute: OauthAuthorizeRoute,
OauthSuccessRoute: OauthSuccessRoute,
PacksSlugRoute: PacksSlugRouteWithChildren,
RunSlugRoute: RunSlugRoute,
SoulsSlugRoute: SoulsSlugRoute,
Expand Down Expand Up @@ -2138,3 +2159,13 @@ const rootRouteChildren: RootRouteChildren = {
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()

import type { getRouter } from './router.tsx'
import type { startInstance } from './start.ts'
declare module '@tanstack/react-start' {
interface Register {
ssr: true
router: Awaited<ReturnType<typeof getRouter>>
config: Awaited<ReturnType<typeof startInstance.getOptions>>
}
}
28 changes: 22 additions & 6 deletions src/routes/api/public/oauth/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,34 @@ import { supabaseAdmin as _supabaseAdmin } from "@/integrations/supabase/client.
const supabaseAdmin = _supabaseAdmin as any;
import { corsPreflight, jsonResponse, oauthError } from "@/lib/oauth/mcp-oauth.server";

// RFC 8252 (OAuth for Native Apps) permits three redirect_uri shapes:
// 1. https:// (web / claimed-https)
// 2. loopback http (http://127.0.0.1, http://[::1], http://localhost)
// 3. private-use URI schemes registered by the native app (e.g. cursor://,
// vscode://, claude://, codex://, lovable://, hermes://, openclaw://)
// Many MCP clients (Cursor, VS Code, Claude Desktop dev builds) advertise
// scheme-style redirects, so rejecting them forced users into the broken
// "loopback page that doesn't exist" failure mode.
const SCHEME = /^[a-z][a-z0-9+.-]*:\/\//i;
const RedirectUri = z
.string()
.min(3)
.max(2000)
.refine(
(u) =>
u.startsWith("https://") ||
.refine((u) => {
if (u.startsWith("https://")) return true;
if (
u.startsWith("http://localhost") ||
u.startsWith("http://127.0.0.1") ||
u.startsWith("http://[::1]"),
"redirect_uri must use https or loopback http",
);
u.startsWith("http://[::1]")
) {
return true;
}
// Private-use URI scheme: must contain a dot in the scheme per RFC 8252
// §7.1 (e.g. com.example.app:/oauth) OR be a known MCP client scheme.
if (!SCHEME.test(u)) return false;
if (u.startsWith("http://")) return false; // non-loopback plain http forbidden
return true;
}, "redirect_uri must use https, loopback http, or a private-use URI scheme (RFC 8252)");

const RegisterSchema = z.object({
client_name: z.string().min(1).max(120).optional(),
Expand Down
13 changes: 11 additions & 2 deletions src/routes/docs.mcp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -177,8 +177,17 @@ POST /api/public/oauth/revoke → RFC 7009
WWW-Authenticate: Bearer resource_metadata="https://superagentskill.com/.well-known/oauth-protected-resource/api/mcp"`}
/>
<p className="mt-3 text-muted-foreground">
Compliant clients run the browser consent flow automatically. The fastest path on
any client is the CLI below — it does the whole dance for you.
Compliant clients run the browser consent flow automatically. After you click
<strong> Authorize</strong>, we route you to <code>/oauth/success</code> — a
friendly confirmation page that delivers the auth code to your client (loopback
listener or private-use URI scheme like <code>cursor://</code>, <code>vscode://</code>,
<code> claude://</code>, <code>codex://</code>, <code>lovable://</code>,
<code> hermes://</code>, <code>openclaw://</code>) and falls back to a
copy-pasteable code if your local listener already closed. No more browser
"site can't be reached" dead-ends.
</p>
<p className="mt-3 text-muted-foreground">
The fastest path on any client is the CLI below — it does the whole dance for you.
</p>

{/* CLI */}
Expand Down
25 changes: 23 additions & 2 deletions src/routes/oauth.authorize.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ function AuthorizePage() {
async function approve() {
setWorking(true);
try {
const { redirect_to } = await consent({
const { redirect_to, code } = await consent({
data: {
client_id: params.client_id,
redirect_uri: params.redirect_uri,
Expand All @@ -75,7 +75,28 @@ function AuthorizePage() {
code_challenge_method: "S256",
},
});
window.location.href = redirect_to;
// Hand off to /oauth/success via sessionStorage so the auth code
// never lands in the URL bar / history of the consent tab. The
// success page handles delivery to loopback listeners, private-use
// URI schemes, and https callbacks — and shows a friendly screen
// (with a manual paste fallback) instead of the browser's
// "site can't be reached" page when a loopback listener closed.
try {
window.sessionStorage.setItem(
"sas_oauth_handoff_v1",
JSON.stringify({
redirect_to,
code,
client_name: client?.client_name ?? params.client_id,
redirect_uri: params.redirect_uri,
}),
);
navigate({ to: "/oauth/success" });
} catch {
// sessionStorage blocked (rare): fall back to the original
// direct redirect so the flow still completes.
window.location.href = redirect_to;
}
} catch (e) {
setError(e instanceof Error ? e.message : "Authorization failed");
setWorking(false);
Expand Down
Loading
Loading