Skip to content
Open
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
81 changes: 81 additions & 0 deletions mcpjam-inspector/client/src/components/LearningTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,12 @@ import { McpToolsArticle } from "@/components/mcp-tools/McpToolsArticle";
import { McpResourcesArticle } from "@/components/mcp-resources/McpResourcesArticle";
import { McpPromptsArticle } from "@/components/mcp-prompts/McpPromptsArticle";
import { useLearningProgress } from "@/hooks/use-learning-progress";
import { LearningStateProvider } from "@/state/LearningStateProvider";
import { LearningToolsExplorer } from "@/components/learning-sandbox/LearningToolsExplorer";
import { LearningResourcesExplorer } from "@/components/learning-sandbox/LearningResourcesExplorer";
import { LearningPromptsExplorer } from "@/components/learning-sandbox/LearningPromptsExplorer";
import { ArrowLeft } from "lucide-react";
import { Button } from "@/components/ui/button";

/**
* Sentinel value used as `currentStep` when the lifecycle walkthrough is at step 0.
Expand Down Expand Up @@ -331,6 +337,67 @@ function AppsSdkWalkthrough({
);
}

// ---------------------------------------------------------------------------
// Interactive sandbox shell — wraps explorer in LearningStateProvider
// ---------------------------------------------------------------------------

const INTERACTIVE_MODULE_IDS = new Set([
"learning-tools",
"learning-resources",
"learning-prompts",
]);

function InteractiveSandboxShell({
moduleId,
onBack,
onComplete,
}: {
moduleId: string;
onBack: () => void;
onComplete: () => void;
}) {
const labels: Record<string, string> = {
"learning-tools": "Explore Tools",
"learning-resources": "Explore Resources",
"learning-prompts": "Explore Prompts",
};

return (
<LearningStateProvider>
<div className="flex h-full flex-col">
<div className="flex shrink-0 items-center gap-2 border-b px-4 py-2">
<Button
variant="ghost"
size="sm"
className="h-7 gap-1 px-2"
onClick={onBack}
>
<ArrowLeft className="h-3.5 w-3.5" />
Back
</Button>
<span className="text-sm font-medium">
{labels[moduleId] ?? "Try MCP"}
</span>
<div className="flex-1" />
<Button
variant="secondary"
size="sm"
className="h-7"
onClick={onComplete}
>
Mark complete
</Button>
</div>
<div className="min-h-0 flex-1 overflow-hidden">
{moduleId === "learning-tools" && <LearningToolsExplorer />}
{moduleId === "learning-resources" && <LearningResourcesExplorer />}
{moduleId === "learning-prompts" && <LearningPromptsExplorer />}
</div>
</div>
</LearningStateProvider>
);
}

// ---------------------------------------------------------------------------
// LearningTab — routes to the selected concept
// ---------------------------------------------------------------------------
Expand All @@ -342,6 +409,20 @@ export function LearningTab() {

const goBack = useCallback(() => setSelectedConcept(null), []);

// Interactive sandbox modules — routed through LearningStateProvider
if (selectedConcept && INTERACTIVE_MODULE_IDS.has(selectedConcept)) {
return (
<InteractiveSandboxShell
moduleId={selectedConcept}
onBack={goBack}
onComplete={() => {
markComplete(selectedConcept);
goBack();
}}
/>
);
}

if (selectedConcept === "why-mcp") {
return (
<ArticleShell
Expand Down
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
Expand Up @@ -3,6 +3,7 @@ import {
Blocks,
BookOpen,
Database,
FlaskConical,
GitBranch,
Globe,
Lightbulb,
Expand Down Expand Up @@ -128,6 +129,42 @@ export const LEARNING_GROUPS: LearningGroup[] = [
},
],
},
{
title: "Try MCP",
subtitle: "Connect to a live server and explore interactively",
modules: [
{
id: "learning-tools",
title: "Explore Tools",
description:
"Connect to a learning server and browse, invoke, and inspect MCP tools in real time.",
icon: Wrench,
totalSteps: 1,
category: "Interactive",
estimatedMinutes: 5,
},
{
id: "learning-resources",
title: "Explore Resources",
description:
"Browse and read MCP resources from a live learning server — URIs, templates, and content types.",
icon: Database,
totalSteps: 1,
category: "Interactive",
estimatedMinutes: 5,
},
{
id: "learning-prompts",
title: "Explore Prompts",
description:
"List and execute MCP prompts on a live learning server — arguments, messages, and slash commands.",
icon: FlaskConical,
totalSteps: 1,
category: "Interactive",
estimatedMinutes: 5,
},
],
},
{
title: "MCP in Context",
subtitle: "Compare MCP to tools you already know",
Expand Down
81 changes: 81 additions & 0 deletions mcpjam-inspector/client/src/hooks/use-learning-server.ts
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;
Comment on lines +21 to +25
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
node <<'NODE'
for (const value of [
  "https://learning-server.mcpjam.com/mcp",
  "/mcp",
  "not a url",
]) {
  try {
    console.log(value, "=>", new URL(value).toString());
  } catch (error) {
    console.log(value, "=> throws", error.name);
  }
}
NODE

Repository: MCPJam/inspector

Length of output: 195


🏁 Script executed:

cat -n mcpjam-inspector/client/src/hooks/use-learning-server.ts

Repository: MCPJam/inspector

Length of output: 2898


Guard buildLearningServerConfig() against malformed URLs.

The new URL() constructor throws synchronously for invalid URLs. If VITE_LEARNING_SERVER_URL contains a malformed value, the effect crashes before LearningConnectionGate can render.

Wrap URL construction in a try-catch and return null on 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
Verify each finding against the current code and only fix it if needed.

In `@mcpjam-inspector/client/src/hooks/use-learning-server.ts` around lines 21 -
25, Wrap URL parsing in buildLearningServerConfig(url: string) with a try-catch
so it returns null on invalid/malformed URLs instead of throwing; change its
signature/return to MCPServerConfig | null. Update both call sites that pass
VITE_LEARNING_SERVER_URL into buildLearningServerConfig (the two effects that
currently call it) to check for a null return and skip attempting to create a
LearningConnection/connection when config is null (i.e., do not call connect or
instantiate LearningConnectionGate logic if buildLearningServerConfig returns
null). Ensure any downstream variables/types accept a nullable config and
short-circuit the effect when config === null.

}

/**
* 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],
);
}
41 changes: 41 additions & 0 deletions mcpjam-inspector/client/src/lib/apis/web/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 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);
NODE

Repository: MCPJam/inspector

Length of output: 116


🏁 Script executed:

cat -n mcpjam-inspector/client/src/lib/apis/web/context.ts | head -80

Repository: MCPJam/inspector

Length of output: 3263


🏁 Script executed:

rg -n "buildHostedServerRequest|runtimeServerConfigs\[" mcpjam-inspector/client/src/lib/apis/web/context.ts

Repository: MCPJam/inspector

Length of output: 343


🏁 Script executed:

sed -n '268,290p' mcpjam-inspector/client/src/lib/apis/web/context.ts

Repository: MCPJam/inspector

Length of output: 833


Use a prototype-free registry for runtime configs.

runtimeServerConfigs is a plain object, so names like "toString" or "constructor" read as truthy even when never registered, and "__proto__" mutates the registry's prototype. Since buildHostedServerRequest() checks this registry first (line 273), a legitimate hosted server with one of those names gets misrouted into the runtime path.

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
Verify each finding against the current code and only fix it if needed.

In `@mcpjam-inspector/client/src/lib/apis/web/context.ts` around lines 45 - 63,
runtimeServerConfigs should be a prototype-free object to avoid collisions with
keys like "toString" or "__proto__"; change its initialization to a
prototype-less map (e.g., Object.create(null)) and keep
registerRuntimeServerConfig and unregisterRuntimeServerConfig using the same
keys so buildHostedServerRequest will correctly detect only explicitly
registered servers; ensure any key-existence checks against runtimeServerConfigs
rely on direct property access (not inherited) so registry behavior is safe for
reserved names.


const TOKEN_CACHE_TTL_MS = 30_000;

export function resetTokenCache() {
Expand Down Expand Up @@ -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];
Expand Down
Loading