diff --git a/mcpjam-inspector/.env.local b/mcpjam-inspector/.env.local
index 018343b99..007a87403 100644
--- a/mcpjam-inspector/.env.local
+++ b/mcpjam-inspector/.env.local
@@ -1,7 +1,7 @@
-VITE_CONVEX_URL=https://proper-clownfish-150.convex.cloud
-CONVEX_URL=https://proper-clownfish-150.convex.cloud
+VITE_CONVEX_URL=https://tough-cassowary-291.convex.cloud
+CONVEX_URL=https://tough-cassowary-291.convex.cloud
VITE_WORKOS_CLIENT_ID=client_01K4C1TVA6CMQ3G32F1P301A9G
VITE_WORKOS_REDIRECT_URI=mcpjam://oauth/callback
-CONVEX_HTTP_URL=https://proper-clownfish-150.convex.site
+CONVEX_HTTP_URL=https://tough-cassowary-291.convex.site
ENVIRONMENT=local
-VITE_DISABLE_POSTHOG_LOCAL=true
\ No newline at end of file
+VITE_DISABLE_POSTHOG_LOCAL=true
diff --git a/mcpjam-inspector/client/src/App.tsx b/mcpjam-inspector/client/src/App.tsx
index e1df675bd..fa6cf01ef 100644
--- a/mcpjam-inspector/client/src/App.tsx
+++ b/mcpjam-inspector/client/src/App.tsx
@@ -13,6 +13,7 @@ import { ChatTabV2 } from "./components/ChatTabV2";
import { EvalsTab } from "./components/EvalsTab";
import { CiEvalsTab } from "./components/CiEvalsTab";
import { ViewsTab } from "./components/ViewsTab";
+import { SandboxesTab } from "./components/SandboxesTab";
import { SettingsTab } from "./components/SettingsTab";
import { TracingTab } from "./components/TracingTab";
import { AuthTab } from "./components/AuthTab";
@@ -54,23 +55,55 @@ import {
SharedServerChatPage,
getSharedPathTokenFromLocation,
} from "./components/hosted/SharedServerChatPage";
+import {
+ SandboxChatPage,
+ getSandboxPathTokenFromLocation,
+} from "./components/hosted/SandboxChatPage";
import { useHostedApiContext } from "./hooks/hosted/use-hosted-api-context";
import { HOSTED_MODE } from "./lib/config";
import { resolveHostedNavigation } from "./lib/hosted-navigation";
import { buildOAuthTokensByServerId } from "./lib/oauth/oauth-tokens";
+import {
+ clearHostedOAuthPendingState,
+ getHostedOAuthCallbackContext,
+ resolveHostedOAuthReturnHash,
+} from "./lib/hosted-oauth-callback";
+import {
+ clearSandboxSignInReturnPath,
+ readSandboxSession,
+ readSandboxSignInReturnPath,
+ writeSandboxSignInReturnPath,
+} from "./lib/sandbox-session";
import {
clearSharedSignInReturnPath,
- hasActiveSharedSession,
readSharedServerSession,
readSharedSignInReturnPath,
slugify,
- SHARED_OAUTH_PENDING_KEY,
writeSharedSignInReturnPath,
readPendingServerAdd,
clearPendingServerAdd,
} from "./lib/shared-server-session";
+import {
+ sanitizeHostedOAuthErrorMessage,
+ writeHostedOAuthResumeMarker,
+} from "./lib/hosted-oauth-resume";
import { handleOAuthCallback } from "./lib/oauth/mcp-oauth";
+function getHostedOAuthCallbackErrorMessage(): string {
+ const params = new URLSearchParams(window.location.search);
+ const error = params.get("error");
+ const description = params.get("error_description");
+
+ if (error === "access_denied" && !description) {
+ return "Authorization was cancelled. Try again.";
+ }
+
+ return sanitizeHostedOAuthErrorMessage(
+ description || error,
+ "Authorization could not be completed. Try again.",
+ );
+}
+
export default function App() {
const [activeTab, setActiveTab] = useState("servers");
const [activeOrganizationId, setActiveOrganizationId] = useState<
@@ -88,38 +121,128 @@ export default function App() {
isLoading: isWorkOsLoading,
} = useAuth();
const { isAuthenticated, isLoading: isAuthLoading } = useConvexAuth();
- const [sharedOAuthHandling, setSharedOAuthHandling] = useState(false);
+ const [hostedOAuthHandling, setHostedOAuthHandling] = useState(() =>
+ HOSTED_MODE ? getHostedOAuthCallbackContext() !== null : false,
+ );
const [exitedSharedChat, setExitedSharedChat] = useState(false);
+ const [exitedSandboxChat, setExitedSandboxChat] = useState(false);
const sharedPathToken = HOSTED_MODE ? getSharedPathTokenFromLocation() : null;
+ const sandboxPathToken = HOSTED_MODE
+ ? getSandboxPathTokenFromLocation()
+ : null;
+ const sharedSession = HOSTED_MODE ? readSharedServerSession() : null;
+ const sandboxSession = HOSTED_MODE ? readSandboxSession() : null;
+ const currentHashSlug = window.location.hash
+ .replace(/^#/, "")
+ .replace(/^\/+/, "")
+ .split("/")[0];
+ const hostedRouteKind = useMemo(() => {
+ if (!HOSTED_MODE) {
+ return null;
+ }
+
+ if (sharedPathToken) {
+ return "shared" as const;
+ }
+ if (sandboxPathToken) {
+ return "sandbox" as const;
+ }
+
+ if (sharedSession && sandboxSession) {
+ if (currentHashSlug === slugify(sharedSession.payload.serverName)) {
+ return "shared" as const;
+ }
+ if (currentHashSlug === slugify(sandboxSession.payload.name)) {
+ return "sandbox" as const;
+ }
+ return null;
+ }
+
+ if (sharedSession) {
+ return "shared" as const;
+ }
+ if (sandboxSession) {
+ return "sandbox" as const;
+ }
+
+ return null;
+ }, [
+ currentHashSlug,
+ sandboxPathToken,
+ sandboxSession,
+ sharedPathToken,
+ sharedSession,
+ ]);
const isSharedChatRoute =
- HOSTED_MODE &&
- !exitedSharedChat &&
- (!!sharedPathToken || hasActiveSharedSession());
+ HOSTED_MODE && !exitedSharedChat && hostedRouteKind === "shared";
+ const isSandboxChatRoute =
+ HOSTED_MODE && !exitedSandboxChat && hostedRouteKind === "sandbox";
+ const isHostedChatRoute = isSharedChatRoute || isSandboxChatRoute;
- // Handle shared OAuth callback: detect code + pending flag before normal rendering
+ // Handle hosted OAuth callback: claim the callback before any hosted page renders.
useEffect(() => {
+ const callbackContext = getHostedOAuthCallbackContext();
+ if (!callbackContext) return;
+
const urlParams = new URLSearchParams(window.location.search);
const code = urlParams.get("code");
- if (!code || !localStorage.getItem(SHARED_OAUTH_PENDING_KEY)) return;
+ const error = urlParams.get("error");
let cancelled = false;
- setSharedOAuthHandling(true);
+ setHostedOAuthHandling(true);
- const cleanupOAuth = () => {
+ const finalizeHostedOAuth = (errorMessage?: string | null) => {
if (cancelled) return;
- localStorage.removeItem(SHARED_OAUTH_PENDING_KEY);
- const storedSession = readSharedServerSession();
- const sharedHash = storedSession
- ? slugify(storedSession.payload.serverName)
- : "shared";
- window.history.replaceState({}, "", `/#${sharedHash}`);
+ if (callbackContext.serverName) {
+ writeHostedOAuthResumeMarker({
+ surface: callbackContext.surface,
+ serverName: callbackContext.serverName,
+ serverUrl: callbackContext.serverUrl,
+ errorMessage:
+ errorMessage && errorMessage.trim() ? errorMessage : null,
+ });
+ }
+
+ clearHostedOAuthPendingState();
+ localStorage.removeItem("mcp-oauth-pending");
+ localStorage.removeItem("mcp-oauth-return-hash");
+ window.history.replaceState(
+ {},
+ "",
+ `/${resolveHostedOAuthReturnHash(callbackContext)}`,
+ );
};
+ if (error || !code) {
+ finalizeHostedOAuth(getHostedOAuthCallbackErrorMessage());
+ setHostedOAuthHandling(false);
+ return;
+ }
+
handleOAuthCallback(code)
- .then(cleanupOAuth)
- .catch(cleanupOAuth)
+ .then((result) => {
+ if (result.success) {
+ finalizeHostedOAuth(null);
+ return;
+ }
+
+ finalizeHostedOAuth(
+ sanitizeHostedOAuthErrorMessage(
+ result.error,
+ "Authorization could not be completed. Try again.",
+ ),
+ );
+ })
+ .catch((callbackError) => {
+ finalizeHostedOAuth(
+ sanitizeHostedOAuthErrorMessage(
+ callbackError,
+ "Authorization could not be completed. Try again.",
+ ),
+ );
+ })
.finally(() => {
- if (!cancelled) setSharedOAuthHandling(false);
+ if (!cancelled) setHostedOAuthHandling(false);
});
return () => {
@@ -167,9 +290,15 @@ export default function App() {
// Let AuthKit + Convex auth settle before leaving /callback.
if (!isAuthLoading && isAuthenticated) {
+ const sandboxReturnPath = readSandboxSignInReturnPath();
const sharedReturnPath = readSharedSignInReturnPath();
+ clearSandboxSignInReturnPath();
clearSharedSignInReturnPath();
- window.history.replaceState({}, "", sharedReturnPath ?? "/");
+ window.history.replaceState(
+ {},
+ "",
+ sandboxReturnPath ?? sharedReturnPath ?? "/",
+ );
setCallbackCompleted(true);
setCallbackRecoveryExpired(false);
return;
@@ -212,7 +341,7 @@ export default function App() {
// Auto-add a shared server when returning from SharedServerChatPage via "Open MCPJam"
useEffect(() => {
- if (isSharedChatRoute) return;
+ if (isHostedChatRoute) return;
if (isLoadingRemoteWorkspaces) return;
if (isAuthLoading) return;
@@ -233,7 +362,7 @@ export default function App() {
oauthScopes: pending.oauthScopes ?? undefined,
});
}, [
- isSharedChatRoute,
+ isHostedChatRoute,
isLoadingRemoteWorkspaces,
isAuthLoading,
workspaceServers,
@@ -306,7 +435,7 @@ export default function App() {
guestOauthTokensByServerName,
isAuthenticated,
serverConfigs: guestServerConfigs,
- enabled: !isSharedChatRoute,
+ enabled: !isHostedChatRoute,
});
// Compute the set of server names that have saved views
@@ -337,6 +466,17 @@ export default function App() {
return;
}
+ if (isSandboxChatRoute) {
+ const storedSession = readSandboxSession();
+ if (storedSession) {
+ const expectedHash = slugify(storedSession.payload.name);
+ if (window.location.hash !== `#${expectedHash}`) {
+ window.location.hash = expectedHash;
+ }
+ }
+ return;
+ }
+
const resolved = resolveHostedNavigation(target, HOSTED_MODE);
if (
@@ -374,12 +514,16 @@ export default function App() {
}
setActiveTab(resolved.normalizedTab);
},
- [isSharedChatRoute, setSelectedMultipleServersToAllServers],
+ [
+ isSandboxChatRoute,
+ isSharedChatRoute,
+ setSelectedMultipleServersToAllServers,
+ ],
);
// Sync tab with hash on mount and when hash changes
useEffect(() => {
- if (isSharedChatRoute) {
+ if (isHostedChatRoute) {
return;
}
@@ -390,7 +534,7 @@ export default function App() {
applyHash();
window.addEventListener("hashchange", applyHash);
return () => window.removeEventListener("hashchange", applyHash);
- }, [applyNavigation, isSharedChatRoute]);
+ }, [applyNavigation, isHostedChatRoute]);
// Redirect away from tabs hidden by the ci-evals feature flag.
// Use strict equality to avoid redirecting while the flag is still loading (undefined).
@@ -410,7 +554,7 @@ export default function App() {
return
+ Select a workspace to manage sandboxes. +
++ Hosted chat environments +
+No sandboxes yet
++ Create one to package a prompt, model, and server set into a + hosted environment. +
++ {sandbox.name} +
++ {sandbox.description} +
+ ) : null} + {sandbox.serverNames.length > 0 && ( ++ Select a sandbox to view details. +
++ Sandbox not found. +
+