Skip to content

Commit 9fecd2b

Browse files
committed
format
1 parent 429113e commit 9fecd2b

File tree

13 files changed

+557
-542
lines changed

13 files changed

+557
-542
lines changed

src/lib/components/mcp/ServerCard.svelte

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
import IconRefresh from "~icons/carbon/renew";
1414
import IconTrash from "~icons/carbon/trash-can";
1515
import IconTools from "~icons/carbon/tools";
16-
import { authServerIds } from "$lib/stores/mcpServers";
16+
import { authServerIds } from "$lib/stores/mcpServers";
1717
1818
interface Props {
1919
server: MCPServer;
@@ -22,7 +22,7 @@
2222
2323
let { server, isSelected }: Props = $props();
2424
25-
let hasAuth = $derived($authServerIds.has(server.id));
25+
let hasAuth = $derived($authServerIds.has(server.id));
2626
2727
let isLoadingHealth = $state(false);
2828

src/lib/mcp/auth/browserProvider.ts

Lines changed: 186 additions & 183 deletions
Original file line numberDiff line numberDiff line change
@@ -2,192 +2,195 @@
22
// Inspired by ../use-mcp BrowserOAuthClientProvider but without extra deps
33

44
import type {
5-
OAuthClientInformation,
6-
OAuthTokens,
7-
OAuthClientMetadata,
8-
} from '@modelcontextprotocol/sdk/shared/auth.js'
9-
import type { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js'
10-
import { browser } from '$app/environment'
11-
import type { StoredState } from './types'
5+
OAuthClientInformation,
6+
OAuthTokens,
7+
OAuthClientMetadata,
8+
} from "@modelcontextprotocol/sdk/shared/auth.js";
9+
import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";
10+
import { browser } from "$app/environment";
11+
import type { StoredState } from "./types";
1212

1313
function sanitizeUrl(input: string): string {
14-
try {
15-
const url = new URL(input)
16-
return url.toString()
17-
} catch {
18-
return input
19-
}
14+
try {
15+
const url = new URL(input);
16+
return url.toString();
17+
} catch {
18+
return input;
19+
}
2020
}
2121

2222
export class BrowserOAuthClientProvider implements OAuthClientProvider {
23-
readonly serverUrl: string
24-
readonly storageKeyPrefix: string
25-
readonly serverUrlHash: string
26-
readonly clientName: string
27-
readonly clientUri: string
28-
readonly callbackUrl: string
29-
private preventAutoAuth?: boolean
30-
readonly onPopupWindow: ((url: string, features: string, win: Window | null) => void) | undefined
31-
32-
constructor(
33-
serverUrl: string,
34-
options: {
35-
storageKeyPrefix?: string
36-
clientName?: string
37-
clientUri?: string
38-
callbackUrl?: string
39-
preventAutoAuth?: boolean
40-
onPopupWindow?: (url: string, features: string, win: Window | null) => void
41-
} = {}
42-
) {
43-
this.serverUrl = serverUrl
44-
this.storageKeyPrefix = options.storageKeyPrefix || 'mcp:auth'
45-
this.serverUrlHash = this.hashString(serverUrl)
46-
this.clientName = options.clientName || 'chat-ui'
47-
this.clientUri = options.clientUri || (browser ? window.location.origin : '')
48-
this.callbackUrl = sanitizeUrl(
49-
options.callbackUrl || (browser ? new URL('/oauth/callback', window.location.origin).toString() : '/oauth/callback')
50-
)
51-
this.preventAutoAuth = options.preventAutoAuth
52-
this.onPopupWindow = options.onPopupWindow
53-
}
54-
55-
get redirectUrl(): string {
56-
return sanitizeUrl(this.callbackUrl)
57-
}
58-
59-
get clientMetadata(): OAuthClientMetadata {
60-
return {
61-
redirect_uris: [this.redirectUrl],
62-
token_endpoint_auth_method: 'none',
63-
grant_types: ['authorization_code', 'refresh_token'],
64-
response_types: ['code'],
65-
client_name: this.clientName,
66-
client_uri: this.clientUri,
67-
}
68-
}
69-
70-
async clientInformation(): Promise<OAuthClientInformation | undefined> {
71-
const data = browser ? localStorage.getItem(this.getKey('client_info')) : null
72-
if (!data) return undefined
73-
try {
74-
return JSON.parse(data) as OAuthClientInformation
75-
} catch {
76-
localStorage.removeItem(this.getKey('client_info'))
77-
return undefined
78-
}
79-
}
80-
81-
async saveClientInformation(info: OAuthClientInformation): Promise<void> {
82-
if (!browser) return
83-
localStorage.setItem(this.getKey('client_info'), JSON.stringify(info))
84-
}
85-
86-
async tokens(): Promise<OAuthTokens | undefined> {
87-
const data = browser ? localStorage.getItem(this.getKey('tokens')) : null
88-
if (!data) return undefined
89-
try {
90-
return JSON.parse(data) as OAuthTokens
91-
} catch {
92-
localStorage.removeItem(this.getKey('tokens'))
93-
return undefined
94-
}
95-
}
96-
97-
async saveTokens(tokens: OAuthTokens): Promise<void> {
98-
if (!browser) return
99-
localStorage.setItem(this.getKey('tokens'), JSON.stringify(tokens))
100-
localStorage.removeItem(this.getKey('code_verifier'))
101-
localStorage.removeItem(this.getKey('last_auth_url'))
102-
}
103-
104-
async saveCodeVerifier(verifier: string): Promise<void> {
105-
if (!browser) return
106-
localStorage.setItem(this.getKey('code_verifier'), verifier)
107-
}
108-
109-
async codeVerifier(): Promise<string> {
110-
const v = browser ? localStorage.getItem(this.getKey('code_verifier')) : null
111-
if (!v) throw new Error('Code verifier not found')
112-
return v
113-
}
114-
115-
async prepareAuthorizationUrl(authorizationUrl: URL): Promise<string> {
116-
if (!browser) return authorizationUrl.toString()
117-
const state = Math.random().toString(36).slice(2)
118-
const stateKey = `${this.storageKeyPrefix}:state_${state}`
119-
const expiry = Date.now() + 5 * 60 * 1000
120-
const stored: StoredState = {
121-
expiry,
122-
serverUrlHash: this.serverUrlHash,
123-
providerOptions: {
124-
serverUrl: this.serverUrl,
125-
storageKeyPrefix: this.storageKeyPrefix,
126-
clientName: this.clientName,
127-
clientUri: this.clientUri,
128-
callbackUrl: this.callbackUrl,
129-
},
130-
}
131-
localStorage.setItem(stateKey, JSON.stringify(stored))
132-
authorizationUrl.searchParams.set('state', state)
133-
const full = authorizationUrl.toString()
134-
const sanitized = sanitizeUrl(full)
135-
localStorage.setItem(this.getKey('last_auth_url'), sanitized)
136-
return sanitized
137-
}
138-
139-
async redirectToAuthorization(authorizationUrl: URL): Promise<void> {
140-
if (this.preventAutoAuth || !browser) return
141-
const url = await this.prepareAuthorizationUrl(authorizationUrl)
142-
const features = 'width=600,height=700,resizable=yes,scrollbars=yes,status=yes'
143-
try {
144-
const win = window.open(url, `mcp_auth_${this.serverUrlHash}`, features)
145-
this.onPopupWindow?.(url, features, win)
146-
if (!win || win.closed) console.warn('[mcp] popup may be blocked')
147-
} catch (e) {
148-
console.error('[mcp] failed to open popup', e)
149-
}
150-
}
151-
152-
getLastAttemptedAuthUrl(): string | null {
153-
if (!browser) return null
154-
const v = localStorage.getItem(this.getKey('last_auth_url'))
155-
return v ? sanitizeUrl(v) : null
156-
}
157-
158-
clearStorage(): number {
159-
if (!browser) return 0
160-
const prefix = `${this.storageKeyPrefix}_${this.serverUrlHash}_`
161-
const statePrefix = `${this.storageKeyPrefix}:state_`
162-
const toRemove: string[] = []
163-
for (let i = 0; i < localStorage.length; i++) {
164-
const k = localStorage.key(i)
165-
if (!k) continue
166-
if (k.startsWith(prefix)) toRemove.push(k)
167-
if (k.startsWith(statePrefix)) {
168-
try {
169-
const raw = localStorage.getItem(k)
170-
if (!raw) continue
171-
const parsed = JSON.parse(raw) as Partial<StoredState>
172-
if (parsed.serverUrlHash === this.serverUrlHash) toRemove.push(k)
173-
} catch {}
174-
}
175-
}
176-
const unique = [...new Set(toRemove)]
177-
unique.forEach((k) => localStorage.removeItem(k))
178-
return unique.length
179-
}
180-
181-
getKey(suffix: string): string {
182-
return `${this.storageKeyPrefix}_${this.serverUrlHash}_${suffix}`
183-
}
184-
185-
private hashString(str: string): string {
186-
let hash = 0
187-
for (let i = 0; i < str.length; i++) {
188-
hash = (hash << 5) - hash + str.charCodeAt(i)
189-
hash |= 0
190-
}
191-
return Math.abs(hash).toString(16)
192-
}
23+
readonly serverUrl: string;
24+
readonly storageKeyPrefix: string;
25+
readonly serverUrlHash: string;
26+
readonly clientName: string;
27+
readonly clientUri: string;
28+
readonly callbackUrl: string;
29+
private preventAutoAuth?: boolean;
30+
readonly onPopupWindow: ((url: string, features: string, win: Window | null) => void) | undefined;
31+
32+
constructor(
33+
serverUrl: string,
34+
options: {
35+
storageKeyPrefix?: string;
36+
clientName?: string;
37+
clientUri?: string;
38+
callbackUrl?: string;
39+
preventAutoAuth?: boolean;
40+
onPopupWindow?: (url: string, features: string, win: Window | null) => void;
41+
} = {}
42+
) {
43+
this.serverUrl = serverUrl;
44+
this.storageKeyPrefix = options.storageKeyPrefix || "mcp:auth";
45+
this.serverUrlHash = this.hashString(serverUrl);
46+
this.clientName = options.clientName || "chat-ui";
47+
this.clientUri = options.clientUri || (browser ? window.location.origin : "");
48+
this.callbackUrl = sanitizeUrl(
49+
options.callbackUrl ||
50+
(browser
51+
? new URL("/oauth/callback", window.location.origin).toString()
52+
: "/oauth/callback")
53+
);
54+
this.preventAutoAuth = options.preventAutoAuth;
55+
this.onPopupWindow = options.onPopupWindow;
56+
}
57+
58+
get redirectUrl(): string {
59+
return sanitizeUrl(this.callbackUrl);
60+
}
61+
62+
get clientMetadata(): OAuthClientMetadata {
63+
return {
64+
redirect_uris: [this.redirectUrl],
65+
token_endpoint_auth_method: "none",
66+
grant_types: ["authorization_code", "refresh_token"],
67+
response_types: ["code"],
68+
client_name: this.clientName,
69+
client_uri: this.clientUri,
70+
};
71+
}
72+
73+
async clientInformation(): Promise<OAuthClientInformation | undefined> {
74+
const data = browser ? localStorage.getItem(this.getKey("client_info")) : null;
75+
if (!data) return undefined;
76+
try {
77+
return JSON.parse(data) as OAuthClientInformation;
78+
} catch {
79+
localStorage.removeItem(this.getKey("client_info"));
80+
return undefined;
81+
}
82+
}
83+
84+
async saveClientInformation(info: OAuthClientInformation): Promise<void> {
85+
if (!browser) return;
86+
localStorage.setItem(this.getKey("client_info"), JSON.stringify(info));
87+
}
88+
89+
async tokens(): Promise<OAuthTokens | undefined> {
90+
const data = browser ? localStorage.getItem(this.getKey("tokens")) : null;
91+
if (!data) return undefined;
92+
try {
93+
return JSON.parse(data) as OAuthTokens;
94+
} catch {
95+
localStorage.removeItem(this.getKey("tokens"));
96+
return undefined;
97+
}
98+
}
99+
100+
async saveTokens(tokens: OAuthTokens): Promise<void> {
101+
if (!browser) return;
102+
localStorage.setItem(this.getKey("tokens"), JSON.stringify(tokens));
103+
localStorage.removeItem(this.getKey("code_verifier"));
104+
localStorage.removeItem(this.getKey("last_auth_url"));
105+
}
106+
107+
async saveCodeVerifier(verifier: string): Promise<void> {
108+
if (!browser) return;
109+
localStorage.setItem(this.getKey("code_verifier"), verifier);
110+
}
111+
112+
async codeVerifier(): Promise<string> {
113+
const v = browser ? localStorage.getItem(this.getKey("code_verifier")) : null;
114+
if (!v) throw new Error("Code verifier not found");
115+
return v;
116+
}
117+
118+
async prepareAuthorizationUrl(authorizationUrl: URL): Promise<string> {
119+
if (!browser) return authorizationUrl.toString();
120+
const state = Math.random().toString(36).slice(2);
121+
const stateKey = `${this.storageKeyPrefix}:state_${state}`;
122+
const expiry = Date.now() + 5 * 60 * 1000;
123+
const stored: StoredState = {
124+
expiry,
125+
serverUrlHash: this.serverUrlHash,
126+
providerOptions: {
127+
serverUrl: this.serverUrl,
128+
storageKeyPrefix: this.storageKeyPrefix,
129+
clientName: this.clientName,
130+
clientUri: this.clientUri,
131+
callbackUrl: this.callbackUrl,
132+
},
133+
};
134+
localStorage.setItem(stateKey, JSON.stringify(stored));
135+
authorizationUrl.searchParams.set("state", state);
136+
const full = authorizationUrl.toString();
137+
const sanitized = sanitizeUrl(full);
138+
localStorage.setItem(this.getKey("last_auth_url"), sanitized);
139+
return sanitized;
140+
}
141+
142+
async redirectToAuthorization(authorizationUrl: URL): Promise<void> {
143+
if (this.preventAutoAuth || !browser) return;
144+
const url = await this.prepareAuthorizationUrl(authorizationUrl);
145+
const features = "width=600,height=700,resizable=yes,scrollbars=yes,status=yes";
146+
try {
147+
const win = window.open(url, `mcp_auth_${this.serverUrlHash}`, features);
148+
this.onPopupWindow?.(url, features, win);
149+
if (!win || win.closed) console.warn("[mcp] popup may be blocked");
150+
} catch (e) {
151+
console.error("[mcp] failed to open popup", e);
152+
}
153+
}
154+
155+
getLastAttemptedAuthUrl(): string | null {
156+
if (!browser) return null;
157+
const v = localStorage.getItem(this.getKey("last_auth_url"));
158+
return v ? sanitizeUrl(v) : null;
159+
}
160+
161+
clearStorage(): number {
162+
if (!browser) return 0;
163+
const prefix = `${this.storageKeyPrefix}_${this.serverUrlHash}_`;
164+
const statePrefix = `${this.storageKeyPrefix}:state_`;
165+
const toRemove: string[] = [];
166+
for (let i = 0; i < localStorage.length; i++) {
167+
const k = localStorage.key(i);
168+
if (!k) continue;
169+
if (k.startsWith(prefix)) toRemove.push(k);
170+
if (k.startsWith(statePrefix)) {
171+
try {
172+
const raw = localStorage.getItem(k);
173+
if (!raw) continue;
174+
const parsed = JSON.parse(raw) as Partial<StoredState>;
175+
if (parsed.serverUrlHash === this.serverUrlHash) toRemove.push(k);
176+
} catch {}
177+
}
178+
}
179+
const unique = [...new Set(toRemove)];
180+
unique.forEach((k) => localStorage.removeItem(k));
181+
return unique.length;
182+
}
183+
184+
getKey(suffix: string): string {
185+
return `${this.storageKeyPrefix}_${this.serverUrlHash}_${suffix}`;
186+
}
187+
188+
private hashString(str: string): string {
189+
let hash = 0;
190+
for (let i = 0; i < str.length; i++) {
191+
hash = (hash << 5) - hash + str.charCodeAt(i);
192+
hash |= 0;
193+
}
194+
return Math.abs(hash).toString(16);
195+
}
193196
}

0 commit comments

Comments
 (0)