Skip to content
Draft
Show file tree
Hide file tree
Changes from 9 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
7 changes: 6 additions & 1 deletion mcpjam-inspector/client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import {
import { useHostedApiContext } from "./hooks/hosted/use-hosted-api-context";
import { HOSTED_MODE } from "./lib/config";
import { resolveHostedNavigation } from "./lib/hosted-navigation";
import { resolveHostedWorkspaceId } from "./lib/hosted-workspace";
import { buildOAuthTokensByServerId } from "./lib/oauth/oauth-tokens";
import {
clearSharedSignInReturnPath,
Expand Down Expand Up @@ -252,7 +253,10 @@ export default function App() {

// Get the Convex workspace ID from the active workspace
const activeWorkspace = workspaces[activeWorkspaceId];
const convexWorkspaceId = activeWorkspace?.sharedWorkspaceId ?? null;
const convexWorkspaceId = resolveHostedWorkspaceId(
isAuthenticated,
activeWorkspace?.sharedWorkspaceId,
);

// Fetch views for the workspace to determine which servers have saved views
const { viewsByServer } = useViewQueries({
Expand Down Expand Up @@ -305,6 +309,7 @@ export default function App() {
oauthTokensByServerId,
guestOauthTokensByServerName,
isAuthenticated,
hasSession: !!workOsUser,
serverConfigs: guestServerConfigs,
enabled: !isSharedChatRoute,
});
Expand Down
6 changes: 5 additions & 1 deletion mcpjam-inspector/client/src/components/ChatTabV2.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import { XRaySnapshotView } from "@/components/xray/xray-snapshot-view";
import { useSharedAppState } from "@/state/app-state-context";
import { useWorkspaceServers } from "@/hooks/useViews";
import { HOSTED_MODE } from "@/lib/config";
import { resolveHostedWorkspaceId } from "@/lib/hosted-workspace";
import { buildOAuthTokensByServerId } from "@/lib/oauth/oauth-tokens";

interface ChatTabProps {
Expand Down Expand Up @@ -131,7 +132,10 @@ export function ChatTabV2({
);

const activeWorkspace = appState.workspaces[appState.activeWorkspaceId];
const convexWorkspaceId = activeWorkspace?.sharedWorkspaceId ?? null;
const convexWorkspaceId = resolveHostedWorkspaceId(
isConvexAuthenticated,
activeWorkspace?.sharedWorkspaceId,
);
const { serversByName } = useWorkspaceServers({
isAuthenticated: isConvexAuthenticated,
workspaceId: convexWorkspaceId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ import { Settings2 } from "lucide-react";
import { ToolRenderOverride } from "@/components/chat-v2/thread/tool-render-overrides";
import { useConvexAuth } from "convex/react";
import { useWorkspaceServers } from "@/hooks/useViews";
import { resolveHostedWorkspaceId } from "@/lib/hosted-workspace";
import { buildOAuthTokensByServerId } from "@/lib/oauth/oauth-tokens";

/** Custom device config - dimensions come from store */
Expand Down Expand Up @@ -228,7 +229,10 @@ export function PlaygroundMain({

// Hosted mode context (workspaceId, serverIds, OAuth tokens)
const activeWorkspace = appState.workspaces[appState.activeWorkspaceId];
const convexWorkspaceId = activeWorkspace?.sharedWorkspaceId ?? null;
const convexWorkspaceId = resolveHostedWorkspaceId(
isConvexAuthenticated,
activeWorkspace?.sharedWorkspaceId,
);
const { serversByName } = useWorkspaceServers({
isAuthenticated: isConvexAuthenticated,
workspaceId: convexWorkspaceId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ vi.mock("@/lib/apis/mcp-tokenizer-api", () => ({
}));

vi.mock("@/lib/session-token", () => ({
authFetch: vi.fn(),
getAuthHeaders: vi.fn(() => ({})),
}));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ interface UseHostedApiContextOptions {
guestOauthTokensByServerName?: Record<string, string>;
shareToken?: string;
isAuthenticated?: boolean;
/** True when a WorkOS session exists, even if the token hasn't resolved yet. */
hasSession?: boolean;
/** Maps server name → MCPServerConfig for guest mode (no Convex). */
serverConfigs?: Record<string, unknown>;
enabled?: boolean;
Expand All @@ -23,6 +25,7 @@ export function useHostedApiContext({
guestOauthTokensByServerName,
shareToken,
isAuthenticated,
hasSession,
serverConfigs,
enabled = true,
}: UseHostedApiContextOptions): void {
Expand All @@ -49,6 +52,7 @@ export function useHostedApiContext({
guestOauthTokensByServerName,
shareToken,
isAuthenticated,
hasSession,
serverConfigs,
});

Expand All @@ -64,6 +68,7 @@ export function useHostedApiContext({
guestOauthTokensByServerName,
shareToken,
isAuthenticated,
hasSession,
serverConfigs,
]);
}
121 changes: 97 additions & 24 deletions mcpjam-inspector/client/src/hooks/use-chat-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,13 @@
import { DEFAULT_SYSTEM_PROMPT } from "@/components/chat-v2/shared/chat-helpers";
import { getToolsMetadata, ToolServerMap } from "@/lib/apis/mcp-tools-api";
import { countTextTokens } from "@/lib/apis/mcp-tokenizer-api";
import { getAuthHeaders as getSessionAuthHeaders } from "@/lib/session-token";
import {
authFetch,
getAuthHeaders as getSessionAuthHeaders,
} from "@/lib/session-token";
import { getGuestBearerToken } from "@/lib/guest-session";
import { HOSTED_MODE } from "@/lib/config";
import { GUEST_ALLOWED_MODEL_IDS, isGuestAllowedModel } from "@/shared/types";
import { useSharedChatWidgetCapture } from "@/hooks/useSharedChatWidgetCapture";

export interface UseChatSessionOptions {
Expand Down Expand Up @@ -238,6 +243,8 @@
const [requireToolApproval, setRequireToolApproval] = useState(false);
const requireToolApprovalRef = useRef(requireToolApproval);
requireToolApprovalRef.current = requireToolApproval;
const guestMode =
HOSTED_MODE && !isAuthenticated && !isAuthLoading && !hostedWorkspaceId;
const skipNextForkDetectionRef = useRef(false);
const pendingForkSessionIdRef = useRef<string | null>(null);
const pendingForkMessagesRef = useRef<UIMessage[] | null>(null);
Expand All @@ -253,7 +260,25 @@
customProviders,
});
if (HOSTED_MODE) {
return models.filter((model) => isMCPJamProvidedModel(String(model.id)));
const mcpjamModels = models.filter((model) =>
isMCPJamProvidedModel(String(model.id)),
);
// Guest users only see the free guest models
if (guestMode) {
return mcpjamModels.filter((m) =>
GUEST_ALLOWED_MODEL_IDS.includes(String(m.id)),
);
}
return mcpjamModels;
}
// Non-hosted: filter out non-guest MCPJam models when unauthenticated
// (keep user-provided models + 3 free MCPJam models)
if (!isAuthenticated) {
return models.filter(
(m) =>
!isMCPJamProvidedModel(String(m.id)) ||
isGuestAllowedModel(String(m.id)),
);
}
return models;
}, [
Expand All @@ -262,6 +287,8 @@
isOllamaRunning,
ollamaModels,
getAzureBaseUrl,
guestMode,
isAuthenticated,
customProviders,
]);

Expand Down Expand Up @@ -308,35 +335,48 @@
string,
string
>;
const transportHeaders = HOSTED_MODE
? undefined
: Object.keys(mergedHeaders).length > 0
? mergedHeaders
: undefined;

const chatApi = HOSTED_MODE ? "/api/web/chat-v2" : "/api/mcp/chat-v2";

// Build hosted body based on whether we have a workspace.
// Signed-in users are blocked from submitting until hostedWorkspaceId loads
// (via hostedContextNotReady), so this branch only runs for guests.
const buildHostedBody = () => {
if (!hostedWorkspaceId) {
return {};
}
return {
workspaceId: hostedWorkspaceId,
chatSessionId,
selectedServerIds: hostedSelectedServerIds,
accessScope: "chat_v2" as const,
...(hostedShareToken ? { shareToken: hostedShareToken } : {}),
...(hostedOAuthTokens && Object.keys(hostedOAuthTokens).length > 0
? { oauthTokens: hostedOAuthTokens }
: {}),
};
};

return new DefaultChatTransport({
api: chatApi,
fetch: HOSTED_MODE ? authFetch : undefined,

Check failure on line 367 in mcpjam-inspector/client/src/hooks/use-chat-session.ts

View workflow job for this annotation

GitHub Actions / Run Tests

src/hooks/__tests__/use-chat-session.fork.test.tsx > useChatSession fork preservation > keeps resetChat as an intentional clear after changing session IDs

Error: [vitest] No "authFetch" export is defined on the "@/lib/session-token" mock. Did you forget to return it from "vi.mock"? If you need to partially mock a module, you can use "importOriginal" helper inside: vi.mock(import("@/lib/session-token"), async (importOriginal) => { const actual = await importOriginal() return { ...actual, // your mocked methods } }) ❯ src/hooks/use-chat-session.ts:367:28 ❯ mountMemo ../node_modules/react-dom/cjs/react-dom-client.development.js:6603:23 ❯ Object.useMemo ../node_modules/react-dom/cjs/react-dom-client.development.js:22924:18 ❯ process.env.NODE_ENV.exports.useMemo ../node_modules/react/cjs/react.development.js:1209:34 ❯ Module.useChatSession src/hooks/use-chat-session.ts:318:21 ❯ src/hooks/__tests__/use-chat-session.fork.test.tsx:284:7 ❯ TestComponent ../node_modules/@testing-library/react/dist/pure.js:330:27 ❯ Object.react-stack-bottom-frame ../node_modules/react-dom/cjs/react-dom-client.development.js:23863:20

Check failure on line 367 in mcpjam-inspector/client/src/hooks/use-chat-session.ts

View workflow job for this annotation

GitHub Actions / Run Tests

src/hooks/__tests__/use-chat-session.fork.test.tsx > useChatSession fork preservation > does not fork when only transient messages are removed

Error: [vitest] No "authFetch" export is defined on the "@/lib/session-token" mock. Did you forget to return it from "vi.mock"? If you need to partially mock a module, you can use "importOriginal" helper inside: vi.mock(import("@/lib/session-token"), async (importOriginal) => { const actual = await importOriginal() return { ...actual, // your mocked methods } }) ❯ src/hooks/use-chat-session.ts:367:28 ❯ mountMemo ../node_modules/react-dom/cjs/react-dom-client.development.js:6603:23 ❯ Object.useMemo ../node_modules/react-dom/cjs/react-dom-client.development.js:22924:18 ❯ process.env.NODE_ENV.exports.useMemo ../node_modules/react/cjs/react.development.js:1209:34 ❯ Module.useChatSession src/hooks/use-chat-session.ts:318:21 ❯ src/hooks/__tests__/use-chat-session.fork.test.tsx:249:7 ❯ TestComponent ../node_modules/@testing-library/react/dist/pure.js:330:27 ❯ Object.react-stack-bottom-frame ../node_modules/react-dom/cjs/react-dom-client.development.js:23863:20

Check failure on line 367 in mcpjam-inspector/client/src/hooks/use-chat-session.ts

View workflow job for this annotation

GitHub Actions / Run Tests

src/hooks/__tests__/use-chat-session.fork.test.tsx > useChatSession fork preservation > preserves trimmed messages across a fork and updates the hosted transport body

Error: [vitest] No "authFetch" export is defined on the "@/lib/session-token" mock. Did you forget to return it from "vi.mock"? If you need to partially mock a module, you can use "importOriginal" helper inside: vi.mock(import("@/lib/session-token"), async (importOriginal) => { const actual = await importOriginal() return { ...actual, // your mocked methods } }) ❯ src/hooks/use-chat-session.ts:367:28 ❯ mountMemo ../node_modules/react-dom/cjs/react-dom-client.development.js:6603:23 ❯ Object.useMemo ../node_modules/react-dom/cjs/react-dom-client.development.js:22924:18 ❯ process.env.NODE_ENV.exports.useMemo ../node_modules/react/cjs/react.development.js:1209:34 ❯ Module.useChatSession src/hooks/use-chat-session.ts:318:21 ❯ src/hooks/__tests__/use-chat-session.fork.test.tsx:202:7 ❯ TestComponent ../node_modules/@testing-library/react/dist/pure.js:330:27 ❯ Object.react-stack-bottom-frame ../node_modules/react-dom/cjs/react-dom-client.development.js:23863:20
body: () => ({
model: selectedModel,
...(HOSTED_MODE ? {} : { apiKey }),
...(isGpt5 ? {} : { temperature }),
systemPrompt,
...(HOSTED_MODE
? {
workspaceId: hostedWorkspaceId,
chatSessionId,
selectedServerIds: hostedSelectedServerIds,
accessScope: "chat_v2" as const,
...(hostedShareToken ? { shareToken: hostedShareToken } : {}),
...(hostedOAuthTokens && Object.keys(hostedOAuthTokens).length > 0
? { oauthTokens: hostedOAuthTokens }
: {}),
}
: { selectedServers }),
...(HOSTED_MODE ? buildHostedBody() : { selectedServers }),
requireToolApproval: requireToolApprovalRef.current,
...(!HOSTED_MODE && customProviders.length > 0
? { customProviders }
: {}),
}),
headers:
Object.keys(mergedHeaders).length > 0 ? mergedHeaders : undefined,
headers: transportHeaders,
});
}, [
selectedModel,
Expand Down Expand Up @@ -459,21 +499,41 @@
useEffect(() => {
let active = true;
(async () => {
let resolved = false;

try {
const token = await getAccessToken?.();
if (!active) return;
if (token) {
setAuthHeaders({ Authorization: `Bearer ${token}` });
resolved = true;
}
} catch {
// getAccessToken threw (e.g. LoginRequiredError) — not authenticated
}

// Only fall back to guest token if user is NOT authenticated.
// If authenticated but getAccessToken failed/returned falsy, don't
// silently downgrade to a guest token — leave authHeaders undefined
// so the UI shows the auth-not-ready state instead of sending
// requests with a token that can't authorize workspace operations.
if (!resolved && active && !isAuthenticated) {
if (HOSTED_MODE) {
const guestToken = await getGuestBearerToken();
if (!active) return;
if (guestToken) {
setAuthHeaders({ Authorization: `Bearer ${guestToken}` });
} else {
setAuthHeaders(undefined);
}
} else {
setAuthHeaders(undefined);
}
} catch (err) {
console.error("[useChatSession] Failed to get access token:", err);
if (!active) return;
} else if (!resolved && active) {
setAuthHeaders(undefined);
}

// Reset chat to force new session with updated auth headers
// This ensures the transport is recreated with the correct headers
if (active) {
skipNextForkDetectionRef.current = true;
pendingForkSessionIdRef.current = null;
Expand All @@ -486,7 +546,7 @@
return () => {
active = false;
};
}, [getAccessToken, setMessages]);
}, [getAccessToken, isAuthenticated, setMessages]);

// Ollama model detection
useEffect(() => {
Expand Down Expand Up @@ -650,14 +710,27 @@
}, [messages]);

// Computed state for UI
const requiresAuthForChat = HOSTED_MODE || isMcpJamModel;
// Compute guest mode from React state instead of the global isGuestMode().
// The global hostedApiContext is updated via useLayoutEffect in the parent,
// which can be stale during child renders — causing signed-in users to be
// misclassified as guests while auth is still loading.
// In hosted mode: always require auth (guest JWT or WorkOS — handled by authFetch).
// In non-hosted mode: only require auth for non-guest MCPJam models
// (server injects production guest token for guest-allowed models).
const requiresAuthForChat = HOSTED_MODE
? true
: isMcpJamModel && !isGuestAllowedModel(String(selectedModel?.id ?? ""));
const isAuthReady =
!requiresAuthForChat || (isAuthenticated && !!authHeaders);
const disableForAuthentication = !isAuthenticated && requiresAuthForChat;
!requiresAuthForChat || guestMode || (isAuthenticated && !!authHeaders);
// Guest users don't need WorkOS auth — authFetch handles guest bearer tokens
const disableForAuthentication =
!isAuthenticated && requiresAuthForChat && !guestMode;
const authHeadersNotReady =
requiresAuthForChat && isAuthenticated && !authHeaders;
// Guests don't need a workspace — skip hostedContextNotReady for them
const hostedContextNotReady =
HOSTED_MODE &&
!guestMode &&
(!hostedWorkspaceId ||
(selectedServers.length > 0 &&
hostedSelectedServerIds.length !== selectedServers.length));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
describe("hosted web context", () => {
afterEach(() => {
setHostedApiContext(null);
localStorage.removeItem("mcp-tokens-myServer");
});

it("includes share token and chat_v2 scope for shared-chat requests", () => {
Expand Down Expand Up @@ -101,6 +102,42 @@ describe("hosted web context", () => {
});
});

it("prefers persisted guest OAuth token from localStorage when available", () => {
localStorage.setItem(
"mcp-tokens-myServer",
JSON.stringify({
access_token: "storage-access-token",
}),
);

setHostedApiContext({
workspaceId: null,
isAuthenticated: false,
serverIdsByName: {},
guestOauthTokensByServerName: {
myServer: "context-access-token",
},
serverConfigs: {
myServer: {
url: "https://example.com/mcp",
requestInit: {
headers: {
"X-Api-Key": "key123",
},
},
},
},
});

expect(buildHostedServerRequest("myServer")).toEqual({
serverUrl: "https://example.com/mcp",
serverHeaders: {
"X-Api-Key": "key123",
},
oauthAccessToken: "storage-access-token",
});
});

it("handles URL objects in guest server configs", () => {
setHostedApiContext({
workspaceId: null,
Expand Down
17 changes: 17 additions & 0 deletions mcpjam-inspector/client/src/lib/__tests__/hosted-workspace.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest";

import { resolveHostedWorkspaceId } from "../hosted-workspace";

describe("resolveHostedWorkspaceId", () => {
it("keeps the workspace id for authenticated users", () => {
expect(resolveHostedWorkspaceId(true, "ws_123")).toBe("ws_123");
});

it("drops stale workspace ids for signed-out users", () => {
expect(resolveHostedWorkspaceId(false, "ws_stale")).toBeNull();
});

it("returns null when no workspace id exists", () => {
expect(resolveHostedWorkspaceId(true, null)).toBeNull();
});
});
Loading
Loading