-
-
Notifications
You must be signed in to change notification settings - Fork 204
Add interactive learning sandbox with live MCP server exploration #1672
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,56 @@ | ||
| import type { ConnectionStatus } from "@/state/app-types"; | ||
| import type { ReactNode } from "react"; | ||
| import { Loader2, AlertTriangle } from "lucide-react"; | ||
|
|
||
| interface LearningConnectionGateProps { | ||
| connectionStatus: ConnectionStatus; | ||
| label: string; | ||
| children: ReactNode; | ||
| } | ||
|
|
||
| export function LearningConnectionGate({ | ||
| connectionStatus, | ||
| label, | ||
| children, | ||
| }: LearningConnectionGateProps) { | ||
| if (connectionStatus === "connected") { | ||
| return <>{children}</>; | ||
| } | ||
|
|
||
| if (connectionStatus === "connecting") { | ||
| return ( | ||
| <div className="flex h-full items-center justify-center"> | ||
| <div className="flex flex-col items-center gap-3 text-muted-foreground"> | ||
| <Loader2 className="h-6 w-6 animate-spin" /> | ||
| <p className="text-sm">Connecting to learning server…</p> | ||
| </div> | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| if (connectionStatus === "failed") { | ||
| return ( | ||
| <div className="flex h-full items-center justify-center"> | ||
| <div className="flex flex-col items-center gap-3 text-muted-foreground"> | ||
| <AlertTriangle className="h-6 w-6 text-destructive" /> | ||
| <p className="text-sm"> | ||
| Could not connect to the learning server for {label}. | ||
| </p> | ||
| <p className="text-xs"> | ||
| Check your network connection and try again. | ||
| </p> | ||
| </div> | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| // disconnected / oauth-flow — waiting for mount effect | ||
| return ( | ||
| <div className="flex h-full items-center justify-center"> | ||
| <div className="flex flex-col items-center gap-3 text-muted-foreground"> | ||
| <Loader2 className="h-6 w-6 animate-spin" /> | ||
| <p className="text-sm">Initializing {label} explorer…</p> | ||
| </div> | ||
| </div> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| import { useLearningServer } from "@/hooks/use-learning-server"; | ||
| import { PromptsTab } from "@/components/PromptsTab"; | ||
| import { LearningConnectionGate } from "./LearningConnectionGate"; | ||
|
|
||
| export function LearningPromptsExplorer() { | ||
| const { serverName, config, connectionStatus } = useLearningServer(); | ||
|
|
||
| return ( | ||
| <LearningConnectionGate connectionStatus={connectionStatus} label="Prompts"> | ||
| <PromptsTab serverConfig={config} serverName={serverName} /> | ||
| </LearningConnectionGate> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| import { useLearningServer } from "@/hooks/use-learning-server"; | ||
| import { ResourcesTab } from "@/components/ResourcesTab"; | ||
| import { LearningConnectionGate } from "./LearningConnectionGate"; | ||
|
|
||
| export function LearningResourcesExplorer() { | ||
| const { serverName, config, connectionStatus } = useLearningServer(); | ||
|
|
||
| return ( | ||
| <LearningConnectionGate | ||
| connectionStatus={connectionStatus} | ||
| label="Resources" | ||
| > | ||
| <ResourcesTab serverConfig={config} serverName={serverName} /> | ||
| </LearningConnectionGate> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| import { useLearningServer } from "@/hooks/use-learning-server"; | ||
| import { ToolsTab } from "@/components/ToolsTab"; | ||
| import { LearningConnectionGate } from "./LearningConnectionGate"; | ||
|
|
||
| export function LearningToolsExplorer() { | ||
| const { serverName, config, connectionStatus } = useLearningServer(); | ||
|
|
||
| return ( | ||
| <LearningConnectionGate connectionStatus={connectionStatus} label="Tools"> | ||
| <ToolsTab serverConfig={config} serverName={serverName} /> | ||
| </LearningConnectionGate> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,81 @@ | ||
| import { useEffect, useRef, useMemo } from "react"; | ||
| import { useSharedAppRuntime } from "@/state/app-state-context"; | ||
| import type { MCPServerConfig } from "@mcpjam/sdk/browser"; | ||
|
|
||
| const LEARNING_SERVER_ID = "__learning__"; | ||
|
|
||
| const DEFAULT_LEARNING_SERVER_URL = "https://learning-server.mcpjam.com/mcp"; | ||
|
|
||
| function getLearningServerUrl(): string { | ||
| try { | ||
| const envUrl = import.meta.env.VITE_LEARNING_SERVER_URL; | ||
| if (typeof envUrl === "string" && envUrl.trim().length > 0) { | ||
| return envUrl.trim(); | ||
| } | ||
| } catch { | ||
| // import.meta.env may not be available in test environments. | ||
| } | ||
| return DEFAULT_LEARNING_SERVER_URL; | ||
| } | ||
|
|
||
| function buildLearningServerConfig(url: string): MCPServerConfig { | ||
| return { | ||
| url: new URL(url), | ||
| transportType: "streamable-http", | ||
| } as MCPServerConfig; | ||
| } | ||
|
|
||
| /** | ||
| * Auto-connects the learning server on mount, reconnects when the URL changes, | ||
| * and disconnects on unmount. | ||
| * | ||
| * Must be used inside a `<LearningStateProvider>`. | ||
| */ | ||
| export function useLearningServer() { | ||
| const runtime = useSharedAppRuntime(); | ||
| const urlRef = useRef(getLearningServerUrl()); | ||
| const mountedRef = useRef(true); | ||
|
|
||
| const serverName = LEARNING_SERVER_ID; | ||
|
|
||
| useEffect(() => { | ||
| mountedRef.current = true; | ||
|
|
||
| if (!runtime) return; | ||
|
|
||
| const url = getLearningServerUrl(); | ||
| urlRef.current = url; | ||
|
|
||
| const config = buildLearningServerConfig(url); | ||
| runtime.connectRuntimeServer({ name: serverName, config, silent: true }); | ||
|
|
||
| return () => { | ||
| mountedRef.current = false; | ||
| runtime.disconnectRuntimeServer(serverName); | ||
| }; | ||
| }, [runtime, serverName]); | ||
|
|
||
| // Reconnect when the env URL changes (hot-reload / dev scenario). | ||
| useEffect(() => { | ||
| if (!runtime) return; | ||
|
|
||
| const currentUrl = getLearningServerUrl(); | ||
| if (currentUrl !== urlRef.current) { | ||
| urlRef.current = currentUrl; | ||
| const config = buildLearningServerConfig(currentUrl); | ||
| runtime.connectRuntimeServer({ name: serverName, config, silent: true }); | ||
| } | ||
| }); | ||
|
|
||
| const serverEntry = runtime?.getServerEntry(serverName); | ||
|
|
||
| return useMemo( | ||
| () => ({ | ||
| serverName, | ||
| serverEntry, | ||
| connectionStatus: serverEntry?.connectionStatus ?? "disconnected", | ||
| config: serverEntry?.config, | ||
| }), | ||
| [serverName, serverEntry], | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -32,6 +32,36 @@ const EMPTY_CONTEXT: HostedApiContext = { | |
| let hostedApiContext: HostedApiContext = EMPTY_CONTEXT; | ||
| let cachedBearerToken: { token: string; expiresAt: number } | null = null; | ||
|
|
||
| /** | ||
| * Module-scoped registry for runtime server configs (e.g. the learning sandbox). | ||
| * | ||
| * This is intentionally module-scoped mutable state for the single-provider / | ||
| * fixed-server-id v1 design. If multiple learning providers are ever needed, | ||
| * the registry must be namespaced or ref-counted. | ||
| * | ||
| * Registered configs are checked by `buildHostedServerRequest` before the | ||
| * Convex server-ID lookup so runtime servers work without a persisted workspace entry. | ||
| */ | ||
| const runtimeServerConfigs: Record<string, unknown> = {}; | ||
|
|
||
| /** | ||
| * Register a runtime server config so hosted request builders can resolve it | ||
| * without a Convex server ID. | ||
| */ | ||
| export function registerRuntimeServerConfig( | ||
| serverName: string, | ||
| config: unknown, | ||
| ): void { | ||
| runtimeServerConfigs[serverName] = config; | ||
| } | ||
|
|
||
| /** | ||
| * Remove a previously registered runtime server config. | ||
| */ | ||
| export function unregisterRuntimeServerConfig(serverName: string): void { | ||
| delete runtimeServerConfigs[serverName]; | ||
| } | ||
|
Comment on lines
+45
to
+63
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
node <<'NODE'
const registry = {};
console.log("truthy before registration:", Boolean(registry["toString"]));
registry["__proto__"] = { injected: true };
console.log("prototype changed:", Object.getPrototypeOf(registry).injected === true);
NODERepository: MCPJam/inspector Length of output: 116 🏁 Script executed: cat -n mcpjam-inspector/client/src/lib/apis/web/context.ts | head -80Repository: MCPJam/inspector Length of output: 3263 🏁 Script executed: rg -n "buildHostedServerRequest|runtimeServerConfigs\[" mcpjam-inspector/client/src/lib/apis/web/context.tsRepository: MCPJam/inspector Length of output: 343 🏁 Script executed: sed -n '268,290p' mcpjam-inspector/client/src/lib/apis/web/context.tsRepository: MCPJam/inspector Length of output: 833 Use a prototype-free registry for runtime configs.
Suggested fix-const runtimeServerConfigs: Record<string, unknown> = {};
+const runtimeServerConfigs = new Map<string, unknown>();
@@
export function registerRuntimeServerConfig(
serverName: string,
config: unknown,
): void {
- runtimeServerConfigs[serverName] = config;
+ runtimeServerConfigs.set(serverName, config);
}
@@
export function unregisterRuntimeServerConfig(serverName: string): void {
- delete runtimeServerConfigs[serverName];
+ runtimeServerConfigs.delete(serverName);
}
@@
- const runtimeConfig = runtimeServerConfigs[serverNameOrId];
- if (runtimeConfig) {
+ const runtimeConfig = runtimeServerConfigs.get(serverNameOrId);
+ if (runtimeServerConfigs.has(serverNameOrId)) {
return buildGuestServerRequest(
runtimeConfig,
undefined,
hostedApiContext.clientCapabilities,
);
}🤖 Prompt for AI Agents |
||
|
|
||
| const TOKEN_CACHE_TTL_MS = 30_000; | ||
|
|
||
| export function resetTokenCache() { | ||
|
|
@@ -238,6 +268,17 @@ function getHostedAccessScope(): HostedAccessScope | undefined { | |
| export function buildHostedServerRequest( | ||
| serverNameOrId: string, | ||
| ): Record<string, unknown> { | ||
| // Runtime-config path: check module-scoped registry for runtime servers | ||
| // (e.g. the learning sandbox) before guest/Convex paths. | ||
| const runtimeConfig = runtimeServerConfigs[serverNameOrId]; | ||
| if (runtimeConfig) { | ||
| return buildGuestServerRequest( | ||
| runtimeConfig, | ||
| undefined, | ||
| hostedApiContext.clientCapabilities, | ||
| ); | ||
| } | ||
|
|
||
| // Guest path: use directly-provided server config (no Convex) | ||
| if (isGuestMode()) { | ||
| const config = hostedApiContext.serverConfigs?.[serverNameOrId]; | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
Repository: MCPJam/inspector
Length of output: 195
🏁 Script executed:
Repository: MCPJam/inspector
Length of output: 2898
Guard
buildLearningServerConfig()against malformed URLs.The
new URL()constructor throws synchronously for invalid URLs. IfVITE_LEARNING_SERVER_URLcontains a malformed value, the effect crashes beforeLearningConnectionGatecan render.Wrap URL construction in a try-catch and return
nullon failure, then skip the server connection when config is null. This applies at three locations: the function itself (lines 21–25) and both call sites within the effects (lines 49–50 and 65–66).The initial repro confirms valid absolute URLs succeed while relative paths and invalid strings throw
TypeError.🤖 Prompt for AI Agents