Skip to content

Commit 6489830

Browse files
authored
Merge branch 'main' into sushichan044-202510302252-node-version-syntax
2 parents 862dc7d + c91960c commit 6489830

File tree

10 files changed

+159
-44
lines changed

10 files changed

+159
-44
lines changed

.github/workflows/e2e_tests.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ on:
88

99
jobs:
1010
test:
11-
# Installing Playright dependencies can take quite awhile, and also depends on GitHub CI load.
11+
# Installing Playwright dependencies can take quite awhile, and also depends on GitHub CI load.
1212
timeout-minutes: 15
1313
runs-on: ubuntu-latest
1414

client/src/components/DynamicJsonForm.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -113,8 +113,8 @@ const DynamicJsonForm = forwardRef<DynamicJsonFormRef, DynamicJsonFormProps>(
113113
setJsonError(errorMessage);
114114

115115
// Reset to default for clearly invalid JSON (not just incomplete typing)
116-
const trimmed = jsonString.trim();
117-
if (trimmed.length > 5 && !trimmed.match(/^[\s[{]/)) {
116+
const trimmed = jsonString?.trim();
117+
if (trimmed && trimmed.length > 5 && !trimmed.match(/^[\s[{]/)) {
118118
onChange(generateDefaultValue(schema));
119119
}
120120
}
@@ -155,7 +155,7 @@ const DynamicJsonForm = forwardRef<DynamicJsonFormRef, DynamicJsonFormProps>(
155155

156156
const formatJson = () => {
157157
try {
158-
const jsonStr = rawJsonValue.trim();
158+
const jsonStr = rawJsonValue?.trim();
159159
if (!jsonStr) {
160160
return;
161161
}
@@ -171,7 +171,7 @@ const DynamicJsonForm = forwardRef<DynamicJsonFormRef, DynamicJsonFormProps>(
171171
const validateJson = () => {
172172
if (!isJsonMode) return { isValid: true, error: null };
173173
try {
174-
const jsonStr = rawJsonValue.trim();
174+
const jsonStr = rawJsonValue?.trim();
175175
if (!jsonStr) return { isValid: true, error: null };
176176
const parsed = JSON.parse(jsonStr);
177177
// Clear any pending debounced update and immediately update parent

client/src/components/ToolsTab.tsx

Lines changed: 32 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
generateDefaultValue,
1919
isPropertyRequired,
2020
normalizeUnionType,
21+
resolveRef,
2122
} from "@/utils/schemaUtils";
2223
import {
2324
CompatibilityCallToolResult,
@@ -90,14 +91,21 @@ const ToolsTab = ({
9091
useEffect(() => {
9192
const params = Object.entries(
9293
selectedTool?.inputSchema.properties ?? [],
93-
).map(([key, value]) => [
94-
key,
95-
generateDefaultValue(
94+
).map(([key, value]) => {
95+
// First resolve any $ref references
96+
const resolvedValue = resolveRef(
9697
value as JsonSchemaType,
97-
key,
9898
selectedTool?.inputSchema as JsonSchemaType,
99-
),
100-
]);
99+
);
100+
return [
101+
key,
102+
generateDefaultValue(
103+
resolvedValue,
104+
key,
105+
selectedTool?.inputSchema as JsonSchemaType,
106+
),
107+
];
108+
});
101109
setParams(Object.fromEntries(params));
102110

103111
// Reset validation errors when switching tools
@@ -154,7 +162,12 @@ const ToolsTab = ({
154162
</p>
155163
{Object.entries(selectedTool.inputSchema.properties ?? []).map(
156164
([key, value]) => {
157-
const prop = normalizeUnionType(value as JsonSchemaType);
165+
// First resolve any $ref references
166+
const resolvedValue = resolveRef(
167+
value as JsonSchemaType,
168+
selectedTool.inputSchema as JsonSchemaType,
169+
);
170+
const prop = normalizeUnionType(resolvedValue);
158171
const inputSchema =
159172
selectedTool.inputSchema as JsonSchemaType;
160173
const required = isPropertyRequired(key, inputSchema);
@@ -181,16 +194,18 @@ const ToolsTab = ({
181194
...params,
182195
[key]: checked
183196
? null
184-
: prop.default !== null
185-
? prop.default
186-
: prop.type === "boolean"
187-
? false
188-
: prop.type === "string"
189-
? ""
190-
: prop.type === "number" ||
191-
prop.type === "integer"
192-
? undefined
193-
: undefined,
197+
: prop.type === "array"
198+
? undefined
199+
: prop.default !== null
200+
? prop.default
201+
: prop.type === "boolean"
202+
? false
203+
: prop.type === "string"
204+
? ""
205+
: prop.type === "number" ||
206+
prop.type === "integer"
207+
? undefined
208+
: undefined,
194209
})
195210
}
196211
/>

client/src/lib/auth.ts

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -102,16 +102,40 @@ export const clearClientInformationFromSessionStorage = ({
102102
sessionStorage.removeItem(key);
103103
};
104104

105+
export const getScopeFromSessionStorage = (
106+
serverUrl: string,
107+
): string | undefined => {
108+
const key = getServerSpecificKey(SESSION_KEYS.SCOPE, serverUrl);
109+
const value = sessionStorage.getItem(key);
110+
return value || undefined;
111+
};
112+
113+
export const saveScopeToSessionStorage = (
114+
serverUrl: string,
115+
scope: string | undefined,
116+
) => {
117+
const key = getServerSpecificKey(SESSION_KEYS.SCOPE, serverUrl);
118+
if (scope) {
119+
sessionStorage.setItem(key, scope);
120+
} else {
121+
sessionStorage.removeItem(key);
122+
}
123+
};
124+
125+
export const clearScopeFromSessionStorage = (serverUrl: string) => {
126+
const key = getServerSpecificKey(SESSION_KEYS.SCOPE, serverUrl);
127+
sessionStorage.removeItem(key);
128+
};
129+
105130
export class InspectorOAuthClientProvider implements OAuthClientProvider {
106-
constructor(
107-
protected serverUrl: string,
108-
scope?: string,
109-
) {
110-
this.scope = scope;
131+
constructor(protected serverUrl: string) {
111132
// Save the server URL to session storage
112133
sessionStorage.setItem(SESSION_KEYS.SERVER_URL, serverUrl);
113134
}
114-
scope: string | undefined;
135+
136+
get scope(): string | undefined {
137+
return getScopeFromSessionStorage(this.serverUrl);
138+
}
115139

116140
get redirectUrl() {
117141
return window.location.origin + "/oauth/callback";

client/src/lib/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export const SESSION_KEYS = {
1717
PREREGISTERED_CLIENT_INFORMATION: "mcp_preregistered_client_information",
1818
SERVER_METADATA: "mcp_server_metadata",
1919
AUTH_DEBUGGER_STATE: "mcp_auth_debugger_state",
20+
SCOPE: "mcp_scope",
2021
} as const;
2122

2223
// Generate server-specific session storage keys

client/src/lib/hooks/__tests__/useConnection.test.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,8 @@ jest.mock("../../auth", () => ({
112112
})),
113113
clearClientInformationFromSessionStorage: jest.fn(),
114114
saveClientInformationToSessionStorage: jest.fn(),
115+
saveScopeToSessionStorage: jest.fn(),
116+
clearScopeFromSessionStorage: jest.fn(),
115117
discoverScopes: jest.fn(),
116118
}));
117119

client/src/lib/hooks/useConnection.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ import {
4545
clearClientInformationFromSessionStorage,
4646
InspectorOAuthClientProvider,
4747
saveClientInformationToSessionStorage,
48+
saveScopeToSessionStorage,
49+
clearScopeFromSessionStorage,
4850
discoverScopes,
4951
} from "../auth";
5052
import {
@@ -142,6 +144,15 @@ export function useConnection({
142144
});
143145
}, [oauthClientId, oauthClientSecret, sseUrl]);
144146

147+
useEffect(() => {
148+
if (!oauthScope) {
149+
clearScopeFromSessionStorage(sseUrl);
150+
return;
151+
}
152+
153+
saveScopeToSessionStorage(sseUrl, oauthScope);
154+
}, [oauthScope, sseUrl]);
155+
145156
const pushHistory = (request: object, response?: object) => {
146157
setRequestHistory((prev) => [
147158
...prev,
@@ -346,10 +357,9 @@ export function useConnection({
346357
}
347358
scope = await discoverScopes(sseUrl, resourceMetadata);
348359
}
349-
const serverAuthProvider = new InspectorOAuthClientProvider(
350-
sseUrl,
351-
scope,
352-
);
360+
361+
saveScopeToSessionStorage(sseUrl, scope);
362+
const serverAuthProvider = new InspectorOAuthClientProvider(sseUrl);
353363

354364
const result = await auth(serverAuthProvider, {
355365
serverUrl: sseUrl,

client/src/lib/oauth-state-machine.ts

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -80,13 +80,16 @@ export const oauthTransitions: Record<OAuthStep, StateTransition> = {
8080
const metadata = context.state.oauthMetadata!;
8181
const clientMetadata = context.provider.clientMetadata;
8282

83-
// Prefer scopes from resource metadata if available
84-
const scopesSupported =
85-
context.state.resourceMetadata?.scopes_supported ||
86-
metadata.scopes_supported;
87-
// Add all supported scopes to client registration
88-
if (scopesSupported) {
89-
clientMetadata.scope = scopesSupported.join(" ");
83+
// Priority: user-provided scope > discovered scopes
84+
if (!context.provider.scope || context.provider.scope.trim() === "") {
85+
// Prefer scopes from resource metadata if available
86+
const scopesSupported =
87+
context.state.resourceMetadata?.scopes_supported ||
88+
metadata.scopes_supported;
89+
// Add all supported scopes to client registration
90+
if (scopesSupported) {
91+
clientMetadata.scope = scopesSupported.join(" ");
92+
}
9093
}
9194

9295
// Try Static client first, with DCR as fallback
@@ -113,10 +116,14 @@ export const oauthTransitions: Record<OAuthStep, StateTransition> = {
113116
const metadata = context.state.oauthMetadata!;
114117
const clientInformation = context.state.oauthClientInfo!;
115118

116-
const scope = await discoverScopes(
117-
context.serverUrl,
118-
context.state.resourceMetadata ?? undefined,
119-
);
119+
// Priority: user-provided scope > discovered scopes
120+
let scope = context.provider.scope;
121+
if (!scope || scope.trim() === "") {
122+
scope = await discoverScopes(
123+
context.serverUrl,
124+
context.state.resourceMetadata ?? undefined,
125+
);
126+
}
120127

121128
const { authorizationUrl, codeVerifier } = await startAuthorization(
122129
context.serverUrl,

client/src/utils/jsonUtils.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ export type JsonSchemaType = {
4848
const?: JsonValue;
4949
oneOf?: (JsonSchemaType | JsonSchemaConst)[];
5050
anyOf?: (JsonSchemaType | JsonSchemaConst)[];
51+
$ref?: string;
5152
};
5253

5354
export type JsonObject = { [key: string]: JsonValue };
@@ -84,8 +85,9 @@ export function tryParseJson(str: string): {
8485
success: boolean;
8586
data: JsonValue;
8687
} {
87-
const trimmed = str.trim();
88+
const trimmed = str?.trim();
8889
if (
90+
trimmed &&
8991
!(trimmed.startsWith("{") && trimmed.endsWith("}")) &&
9092
!(trimmed.startsWith("[") && trimmed.endsWith("]"))
9193
) {

client/src/utils/schemaUtils.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,50 @@ export function isPropertyRequired(
145145
return schema.required?.includes(propertyName) ?? false;
146146
}
147147

148+
/**
149+
* Resolves $ref references in JSON schema
150+
* @param schema The schema that may contain $ref
151+
* @param rootSchema The root schema to resolve references against
152+
* @returns The resolved schema without $ref
153+
*/
154+
export function resolveRef(
155+
schema: JsonSchemaType,
156+
rootSchema: JsonSchemaType,
157+
): JsonSchemaType {
158+
if (!("$ref" in schema) || !schema.$ref) {
159+
return schema;
160+
}
161+
162+
const ref = schema.$ref;
163+
164+
// Handle simple #/properties/name references
165+
if (ref.startsWith("#/")) {
166+
const path = ref.substring(2).split("/");
167+
let current: unknown = rootSchema;
168+
169+
for (const segment of path) {
170+
if (
171+
current &&
172+
typeof current === "object" &&
173+
current !== null &&
174+
segment in current
175+
) {
176+
current = (current as Record<string, unknown>)[segment];
177+
} else {
178+
// If reference cannot be resolved, return the original schema
179+
console.warn(`Could not resolve $ref: ${ref}`);
180+
return schema;
181+
}
182+
}
183+
184+
return current as JsonSchemaType;
185+
}
186+
187+
// For other types of references, return the original schema
188+
console.warn(`Unsupported $ref format: ${ref}`);
189+
return schema;
190+
}
191+
148192
/**
149193
* Normalizes union types (like string|null from FastMCP) to simple types for form rendering
150194
* @param schema The JSON schema to normalize
@@ -191,6 +235,16 @@ export function normalizeUnionType(schema: JsonSchemaType): JsonSchemaType {
191235
return { ...schema, type: "integer", anyOf: undefined, nullable: true };
192236
}
193237

238+
// Handle anyOf with exactly array and null (FastMCP pattern)
239+
if (
240+
schema.anyOf &&
241+
schema.anyOf.length === 2 &&
242+
schema.anyOf.some((t) => (t as JsonSchemaType).type === "array") &&
243+
schema.anyOf.some((t) => (t as JsonSchemaType).type === "null")
244+
) {
245+
return { ...schema, type: "array", anyOf: undefined, nullable: true };
246+
}
247+
194248
// Handle array type with exactly string and null
195249
if (
196250
Array.isArray(schema.type) &&

0 commit comments

Comments
 (0)