diff --git a/mcpjam-inspector/client/src/App.tsx b/mcpjam-inspector/client/src/App.tsx index e1df675bd..5c939427c 100644 --- a/mcpjam-inspector/client/src/App.tsx +++ b/mcpjam-inspector/client/src/App.tsx @@ -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, @@ -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({ @@ -305,6 +309,7 @@ export default function App() { oauthTokensByServerId, guestOauthTokensByServerName, isAuthenticated, + hasSession: !!workOsUser, serverConfigs: guestServerConfigs, enabled: !isSharedChatRoute, }); diff --git a/mcpjam-inspector/client/src/components/ChatTabV2.tsx b/mcpjam-inspector/client/src/components/ChatTabV2.tsx index 3188c2c03..df1193e2e 100644 --- a/mcpjam-inspector/client/src/components/ChatTabV2.tsx +++ b/mcpjam-inspector/client/src/components/ChatTabV2.tsx @@ -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 { @@ -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, diff --git a/mcpjam-inspector/client/src/components/ui-playground/PlaygroundMain.tsx b/mcpjam-inspector/client/src/components/ui-playground/PlaygroundMain.tsx index 2fabc01f7..331150c58 100644 --- a/mcpjam-inspector/client/src/components/ui-playground/PlaygroundMain.tsx +++ b/mcpjam-inspector/client/src/components/ui-playground/PlaygroundMain.tsx @@ -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 */ @@ -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, diff --git a/mcpjam-inspector/client/src/hooks/__tests__/use-chat-session.hosted.test.tsx b/mcpjam-inspector/client/src/hooks/__tests__/use-chat-session.hosted.test.tsx index 17413c80d..784e5f4d5 100644 --- a/mcpjam-inspector/client/src/hooks/__tests__/use-chat-session.hosted.test.tsx +++ b/mcpjam-inspector/client/src/hooks/__tests__/use-chat-session.hosted.test.tsx @@ -1,5 +1,5 @@ -import { describe, expect, it, vi } from "vitest"; -import { renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { renderHook, waitFor } from "@testing-library/react"; import { useChatSession } from "../use-chat-session"; const mockState = vi.hoisted(() => ({ @@ -8,6 +8,7 @@ const mockState = vi.hoisted(() => ({ setMessages: vi.fn(), addToolApprovalResponse: vi.fn(), getAccessToken: vi.fn(async () => "access-token"), + getGuestBearerToken: vi.fn(async () => "guest-token"), hasToken: vi.fn(() => false), getToken: vi.fn(() => ""), getOpenRouterSelectedModels: vi.fn(() => []), @@ -16,6 +17,10 @@ const mockState = vi.hoisted(() => ({ getCustomProviderByName: vi.fn(), setSelectedModelId: vi.fn(), useSharedChatWidgetCapture: vi.fn(), + convexAuth: { + isAuthenticated: true, + isLoading: false, + }, detectOllamaModels: vi.fn(async () => ({ isRunning: false, availableModels: [], @@ -30,19 +35,24 @@ const mockState = vi.hoisted(() => ({ })); let lastTransportOptions: any; -const baseModel = { - id: "gpt-4.1-mini", - name: "GPT-4.1 Mini", +const guestModel = { + id: "openai/gpt-5-mini", + name: "GPT-5 Mini", provider: "openai" as const, }; +const premiumModel = { + id: "anthropic/claude-sonnet-4.5", + name: "Claude Sonnet 4.5", + provider: "anthropic" as const, +}; vi.mock("@/lib/config", () => ({ HOSTED_MODE: true, })); vi.mock("@/components/chat-v2/shared/model-helpers", () => ({ - buildAvailableModels: vi.fn(() => [baseModel]), - getDefaultModel: vi.fn(() => baseModel), + buildAvailableModels: vi.fn(() => [premiumModel, guestModel]), + getDefaultModel: vi.fn((models: Array) => models[0]), })); vi.mock("@/hooks/use-ai-provider-keys", () => ({ @@ -64,7 +74,7 @@ vi.mock("@/hooks/use-custom-providers", () => ({ vi.mock("@/hooks/use-persisted-model", () => ({ usePersistedModel: () => ({ - selectedModelId: "gpt-4.1-mini", + selectedModelId: "openai/gpt-5-mini", setSelectedModelId: mockState.setSelectedModelId, }), })); @@ -87,9 +97,14 @@ vi.mock("@/lib/apis/mcp-tokenizer-api", () => ({ })); vi.mock("@/lib/session-token", () => ({ + authFetch: vi.fn(), getAuthHeaders: vi.fn(() => ({})), })); +vi.mock("@/lib/guest-session", () => ({ + getGuestBearerToken: mockState.getGuestBearerToken, +})); + vi.mock("@workos-inc/authkit-react", () => ({ useAuth: () => ({ getAccessToken: mockState.getAccessToken, @@ -97,10 +112,7 @@ vi.mock("@workos-inc/authkit-react", () => ({ })); vi.mock("convex/react", () => ({ - useConvexAuth: () => ({ - isAuthenticated: true, - isLoading: false, - }), + useConvexAuth: () => mockState.convexAuth, })); vi.mock("@ai-sdk/react", () => ({ @@ -126,6 +138,15 @@ vi.mock("ai", () => ({ })); describe("useChatSession hosted mode", () => { + beforeEach(() => { + mockState.convexAuth.isAuthenticated = true; + mockState.convexAuth.isLoading = false; + mockState.getAccessToken.mockReset(); + mockState.getAccessToken.mockResolvedValue("access-token"); + mockState.getGuestBearerToken.mockReset(); + mockState.getGuestBearerToken.mockResolvedValue("guest-token"); + }); + it("includes chatSessionId in the hosted transport body", async () => { const { result, unmount } = renderHook(() => useChatSession({ @@ -147,4 +168,28 @@ describe("useChatSession hosted mode", () => { }); unmount(); }); + + it("treats anonymous shared-chat viewers as guest users", async () => { + mockState.convexAuth.isAuthenticated = false; + mockState.getAccessToken.mockRejectedValue(new Error("LoginRequiredError")); + + const { result, unmount } = renderHook(() => + useChatSession({ + selectedServers: ["server-1"], + hostedWorkspaceId: "workspace-1", + hostedSelectedServerIds: ["server-id-1"], + hostedShareToken: "share-token", + }), + ); + + await waitFor(() => { + expect(result.current.disableForAuthentication).toBe(false); + }); + + expect(result.current.availableModels.map((model) => model.id)).toEqual([ + "openai/gpt-5-mini", + ]); + expect(result.current.isAuthReady).toBe(true); + unmount(); + }); }); diff --git a/mcpjam-inspector/client/src/hooks/hosted/use-hosted-api-context.ts b/mcpjam-inspector/client/src/hooks/hosted/use-hosted-api-context.ts index 7c7cea2c8..c3f1d1676 100644 --- a/mcpjam-inspector/client/src/hooks/hosted/use-hosted-api-context.ts +++ b/mcpjam-inspector/client/src/hooks/hosted/use-hosted-api-context.ts @@ -10,6 +10,8 @@ interface UseHostedApiContextOptions { guestOauthTokensByServerName?: Record; 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; enabled?: boolean; @@ -23,6 +25,7 @@ export function useHostedApiContext({ guestOauthTokensByServerName, shareToken, isAuthenticated, + hasSession, serverConfigs, enabled = true, }: UseHostedApiContextOptions): void { @@ -49,6 +52,7 @@ export function useHostedApiContext({ guestOauthTokensByServerName, shareToken, isAuthenticated, + hasSession, serverConfigs, }); @@ -64,6 +68,7 @@ export function useHostedApiContext({ guestOauthTokensByServerName, shareToken, isAuthenticated, + hasSession, serverConfigs, ]); } diff --git a/mcpjam-inspector/client/src/hooks/use-chat-session.ts b/mcpjam-inspector/client/src/hooks/use-chat-session.ts index 5273f643b..79d914153 100644 --- a/mcpjam-inspector/client/src/hooks/use-chat-session.ts +++ b/mcpjam-inspector/client/src/hooks/use-chat-session.ts @@ -47,8 +47,13 @@ import { 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 { @@ -238,6 +243,19 @@ export function useChatSession({ const [requireToolApproval, setRequireToolApproval] = useState(false); const requireToolApprovalRef = useRef(requireToolApproval); requireToolApprovalRef.current = requireToolApproval; + const directGuestMode = + HOSTED_MODE && + !isAuthenticated && + !isAuthLoading && + !hostedWorkspaceId && + !hostedShareToken; + const sharedGuestMode = + HOSTED_MODE && + !isAuthenticated && + !isAuthLoading && + !!hostedWorkspaceId && + !!hostedShareToken; + const guestMode = directGuestMode || sharedGuestMode; const skipNextForkDetectionRef = useRef(false); const pendingForkSessionIdRef = useRef(null); const pendingForkMessagesRef = useRef(null); @@ -253,7 +271,25 @@ export function useChatSession({ 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; }, [ @@ -262,6 +298,8 @@ export function useChatSession({ isOllamaRunning, ollamaModels, getAzureBaseUrl, + guestMode, + isAuthenticated, customProviders, ]); @@ -308,35 +346,48 @@ export function useChatSession({ 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, 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, @@ -459,21 +510,41 @@ export function useChatSession({ 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 a guest token for explicit guest surfaces: + // direct guest chat and shared-chat guests. A regular hosted workspace + // should never silently downgrade to guest auth. + if ( + !resolved && + active && + !isAuthenticated && + HOSTED_MODE && + (!hostedWorkspaceId || !!hostedShareToken) + ) { + const guestToken = await getGuestBearerToken(); + if (!active) return; + if (guestToken) { + setAuthHeaders({ Authorization: `Bearer ${guestToken}` }); } 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; @@ -486,7 +557,13 @@ export function useChatSession({ return () => { active = false; }; - }, [getAccessToken, setMessages]); + }, [ + getAccessToken, + hostedShareToken, + hostedWorkspaceId, + isAuthenticated, + setMessages, + ]); // Ollama model detection useEffect(() => { @@ -650,14 +727,26 @@ export function useChatSession({ }, [messages]); // Computed state for UI - const requiresAuthForChat = HOSTED_MODE || isMcpJamModel; + // Compute guest access from React state instead of the global hostedApiContext. + // Shared chats are guest-capable even though they are scoped to a workspace, + // while direct guests have no workspace at all. + // 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; + // Direct guests don't need a workspace; shared guests still do. const hostedContextNotReady = HOSTED_MODE && + !directGuestMode && (!hostedWorkspaceId || (selectedServers.length > 0 && hostedSelectedServerIds.length !== selectedServers.length)); diff --git a/mcpjam-inspector/client/src/lib/__tests__/hosted-web-context.test.ts b/mcpjam-inspector/client/src/lib/__tests__/hosted-web-context.test.ts index 597e1b91c..fbe76cafd 100644 --- a/mcpjam-inspector/client/src/lib/__tests__/hosted-web-context.test.ts +++ b/mcpjam-inspector/client/src/lib/__tests__/hosted-web-context.test.ts @@ -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", () => { @@ -70,6 +71,26 @@ describe("hosted web context", () => { }); }); + it("keeps using direct guest requests when AuthKit still reports a session", () => { + setHostedApiContext({ + workspaceId: null, + hasSession: true, + isAuthenticated: false, + serverIdsByName: {}, + 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" }, + }); + }); + it("includes the latest guest OAuth token separately from server headers", () => { setHostedApiContext({ workspaceId: null, @@ -101,6 +122,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, diff --git a/mcpjam-inspector/client/src/lib/__tests__/hosted-workspace.test.ts b/mcpjam-inspector/client/src/lib/__tests__/hosted-workspace.test.ts new file mode 100644 index 000000000..9fac3dbb1 --- /dev/null +++ b/mcpjam-inspector/client/src/lib/__tests__/hosted-workspace.test.ts @@ -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(); + }); +}); diff --git a/mcpjam-inspector/client/src/lib/__tests__/session-token.hosted-retry.test.ts b/mcpjam-inspector/client/src/lib/__tests__/session-token.hosted-retry.test.ts index d127abad4..7a3acaacb 100644 --- a/mcpjam-inspector/client/src/lib/__tests__/session-token.hosted-retry.test.ts +++ b/mcpjam-inspector/client/src/lib/__tests__/session-token.hosted-retry.test.ts @@ -14,6 +14,7 @@ vi.mock("@/lib/config", () => ({ vi.mock("@/lib/guest-session", () => ({ getGuestBearerToken: vi.fn(), forceRefreshGuestSession: vi.fn(), + peekStoredGuestToken: vi.fn(), })); vi.mock("@/lib/apis/web/context", async () => { @@ -29,7 +30,10 @@ vi.mock("@/lib/apis/web/context", async () => { }); import { authFetch } from "../session-token"; -import { forceRefreshGuestSession } from "@/lib/guest-session"; +import { + forceRefreshGuestSession, + peekStoredGuestToken, +} from "@/lib/guest-session"; import { getHostedAuthorizationHeader, resetTokenCache, @@ -41,7 +45,9 @@ describe("authFetch hosted 401 retry", () => { vi.mocked(getHostedAuthorizationHeader).mockReset(); vi.mocked(resetTokenCache).mockReset(); vi.mocked(forceRefreshGuestSession).mockReset(); + vi.mocked(peekStoredGuestToken).mockReset(); vi.mocked(isGuestMode).mockReturnValue(true); + vi.mocked(peekStoredGuestToken).mockReturnValue("stale-token"); vi.mocked(global.fetch).mockReset(); }); @@ -140,6 +146,7 @@ describe("authFetch hosted 401 retry", () => { it("does not retry when not in guest mode", async () => { vi.mocked(isGuestMode).mockReturnValue(false); + vi.mocked(peekStoredGuestToken).mockReturnValue(null); vi.mocked(getHostedAuthorizationHeader).mockResolvedValueOnce( "Bearer workos-token", ); @@ -157,6 +164,34 @@ describe("authFetch hosted 401 retry", () => { expect(global.fetch).toHaveBeenCalledTimes(1); }); + it("retries when the failing request used the persisted guest token", async () => { + vi.mocked(isGuestMode).mockReturnValue(false); + vi.mocked(peekStoredGuestToken).mockReturnValue("stale-token"); + vi.mocked(getHostedAuthorizationHeader).mockResolvedValueOnce( + "Bearer stale-token", + ); + vi.mocked(forceRefreshGuestSession).mockResolvedValue("fresh-token"); + + vi.mocked(global.fetch) + .mockResolvedValueOnce({ status: 401, ok: false } as Response) + .mockResolvedValueOnce({ + status: 200, + ok: true, + json: () => Promise.resolve({ success: true }), + } as Response); + + const response = await authFetch("/api/web/chat-v2", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + + expect(response.status).toBe(200); + expect(resetTokenCache).toHaveBeenCalledTimes(1); + expect(forceRefreshGuestSession).toHaveBeenCalledTimes(1); + expect(global.fetch).toHaveBeenCalledTimes(2); + }); + it("returns original 401 when forceRefresh returns null", async () => { vi.mocked(getHostedAuthorizationHeader).mockResolvedValueOnce( "Bearer stale-token", diff --git a/mcpjam-inspector/client/src/lib/apis/mcp-skills-api.ts b/mcpjam-inspector/client/src/lib/apis/mcp-skills-api.ts index b93a8aa5c..c913b17b2 100644 --- a/mcpjam-inspector/client/src/lib/apis/mcp-skills-api.ts +++ b/mcpjam-inspector/client/src/lib/apis/mcp-skills-api.ts @@ -1,4 +1,5 @@ import { authFetch } from "@/lib/session-token"; +import { HOSTED_MODE } from "@/lib/config"; import type { Skill, SkillListItem, @@ -23,6 +24,10 @@ export interface UploadSkillResponse { * List all available skills from .mcpjam/skills/ */ export async function listSkills(): Promise { + if (HOSTED_MODE) { + return []; + } + const res = await authFetch("/api/mcp/skills/list", { method: "POST", headers: { "Content-Type": "application/json" }, diff --git a/mcpjam-inspector/client/src/lib/apis/web/__tests__/context.guest-fallback.test.ts b/mcpjam-inspector/client/src/lib/apis/web/__tests__/context.guest-fallback.test.ts index e72df328e..8f815835d 100644 --- a/mcpjam-inspector/client/src/lib/apis/web/__tests__/context.guest-fallback.test.ts +++ b/mcpjam-inspector/client/src/lib/apis/web/__tests__/context.guest-fallback.test.ts @@ -1,12 +1,4 @@ -/** - * getHostedAuthorizationHeader Guest Fallback Tests - * - * Tests for the core behavior change: when WorkOS getAccessToken() is - * unavailable or throws LoginRequiredError, the function falls back - * to a guest bearer token instead of returning null. - */ - -import { beforeEach, describe, expect, it, vi, afterEach } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("@/lib/config", () => ({ HOSTED_MODE: true, @@ -16,18 +8,17 @@ vi.mock("@/lib/guest-session", () => ({ getGuestBearerToken: vi.fn(), })); -import { setHostedApiContext, getHostedAuthorizationHeader } from "../context"; - import { getGuestBearerToken } from "@/lib/guest-session"; +import { getHostedAuthorizationHeader, setHostedApiContext } from "../context"; describe("getHostedAuthorizationHeader guest fallback", () => { beforeEach(() => { - // Reset context between tests to clear cachedBearerToken setHostedApiContext(null); vi.mocked(getGuestBearerToken).mockReset(); }); afterEach(() => { + setHostedApiContext(null); vi.restoreAllMocks(); }); @@ -36,6 +27,7 @@ describe("getHostedAuthorizationHeader guest fallback", () => { workspaceId: "ws-1", serverIdsByName: {}, getAccessToken: () => Promise.resolve("workos-token-abc"), + isAuthenticated: true, }); const result = await getHostedAuthorizationHeader(); @@ -44,91 +36,81 @@ describe("getHostedAuthorizationHeader guest fallback", () => { expect(getGuestBearerToken).not.toHaveBeenCalled(); }); - it("falls back to guest token when getAccessToken throws", async () => { + it("prefers guest token for direct guest mode without calling WorkOS", async () => { + const getAccessToken = vi + .fn() + .mockResolvedValue("workos-token-should-skip"); setHostedApiContext({ - workspaceId: "ws-1", + workspaceId: null, + isAuthenticated: false, serverIdsByName: {}, - getAccessToken: () => { - throw new Error("LoginRequiredError"); - }, - }); - - vi.mocked(getGuestBearerToken).mockResolvedValue("guest-token-xyz"); - - const result = await getHostedAuthorizationHeader(); - - expect(result).toBe("Bearer guest-token-xyz"); - expect(getGuestBearerToken).toHaveBeenCalled(); - }); - - it("falls back to guest token when getAccessToken rejects", async () => { - setHostedApiContext({ - workspaceId: "ws-1", - serverIdsByName: {}, - getAccessToken: () => Promise.reject(new Error("LoginRequiredError")), - }); - - vi.mocked(getGuestBearerToken).mockResolvedValue("guest-token-abc"); - - const result = await getHostedAuthorizationHeader(); - - expect(result).toBe("Bearer guest-token-abc"); - }); - - it("falls back to guest token when getAccessToken returns null", async () => { - setHostedApiContext({ - workspaceId: "ws-1", - serverIdsByName: {}, - getAccessToken: () => Promise.resolve(null), + getAccessToken, }); - vi.mocked(getGuestBearerToken).mockResolvedValue("guest-fallback"); + vi.mocked(getGuestBearerToken).mockResolvedValue("guest-direct"); const result = await getHostedAuthorizationHeader(); - expect(result).toBe("Bearer guest-fallback"); + expect(result).toBe("Bearer guest-direct"); + expect(getAccessToken).not.toHaveBeenCalled(); }); - it("falls back to guest token when getAccessToken returns undefined", async () => { + it("prefers guest token for shared guests without calling WorkOS", async () => { + const getAccessToken = vi + .fn() + .mockResolvedValue("workos-token-should-skip"); setHostedApiContext({ - workspaceId: "ws-1", - serverIdsByName: {}, - getAccessToken: () => Promise.resolve(undefined), + workspaceId: "ws-shared", + isAuthenticated: false, + serverIdsByName: { bench: "srv-1" }, + getAccessToken, + shareToken: "share_tok_123", }); - vi.mocked(getGuestBearerToken).mockResolvedValue("guest-undef"); + vi.mocked(getGuestBearerToken).mockResolvedValue("guest-shared"); const result = await getHostedAuthorizationHeader(); - expect(result).toBe("Bearer guest-undef"); + expect(result).toBe("Bearer guest-shared"); + expect(getAccessToken).not.toHaveBeenCalled(); }); - it("falls back to guest token when getAccessToken is not set", async () => { + it("still prefers guest token when no workspace is loaded but AuthKit session exists", async () => { + const getAccessToken = vi + .fn() + .mockResolvedValue("workos-token-should-skip"); setHostedApiContext({ - workspaceId: "ws-1", + workspaceId: null, + isAuthenticated: false, + hasSession: true, serverIdsByName: {}, - // No getAccessToken — simulates no WorkOS provider + getAccessToken, }); - vi.mocked(getGuestBearerToken).mockResolvedValue("guest-no-workos"); + vi.mocked(getGuestBearerToken).mockResolvedValue("guest-despite-session"); const result = await getHostedAuthorizationHeader(); - expect(result).toBe("Bearer guest-no-workos"); + expect(result).toBe("Bearer guest-despite-session"); + expect(getAccessToken).not.toHaveBeenCalled(); }); - it("returns null when both WorkOS and guest token fail", async () => { + it("returns null for hosted workspace requests that do not allow guest access", async () => { + const getAccessToken = vi + .fn() + .mockRejectedValue(new Error("LoginRequired")); setHostedApiContext({ - workspaceId: "ws-1", - serverIdsByName: {}, - getAccessToken: () => Promise.reject(new Error("LoginRequiredError")), + workspaceId: "ws-member", + isAuthenticated: false, + serverIdsByName: { bench: "srv-1" }, + getAccessToken, }); - vi.mocked(getGuestBearerToken).mockResolvedValue(null); - const result = await getHostedAuthorizationHeader(); expect(result).toBeNull(); + expect(getGuestBearerToken).not.toHaveBeenCalled(); + expect(getAccessToken).toHaveBeenCalledTimes(1); }); it("caches WorkOS token and does not call guest on subsequent calls", async () => { @@ -137,6 +119,7 @@ describe("getHostedAuthorizationHeader guest fallback", () => { workspaceId: "ws-1", serverIdsByName: {}, getAccessToken, + isAuthenticated: true, }); const result1 = await getHostedAuthorizationHeader(); @@ -144,27 +127,26 @@ describe("getHostedAuthorizationHeader guest fallback", () => { expect(result1).toBe("Bearer cached-workos"); expect(result2).toBe("Bearer cached-workos"); - // getAccessToken called once, then cached for 30s expect(getAccessToken).toHaveBeenCalledTimes(1); expect(getGuestBearerToken).not.toHaveBeenCalled(); }); - it("re-evaluates after cache expires", async () => { + it("re-evaluates guest token after cache expiry", async () => { vi.useFakeTimers(); setHostedApiContext({ - workspaceId: "ws-1", + workspaceId: null, + isAuthenticated: false, serverIdsByName: {}, - getAccessToken: () => Promise.reject(new Error("LoginRequiredError")), }); - vi.mocked(getGuestBearerToken).mockResolvedValue("guest-1"); + vi.mocked(getGuestBearerToken).mockResolvedValueOnce("guest-1"); + vi.mocked(getGuestBearerToken).mockResolvedValueOnce("guest-2"); const result1 = await getHostedAuthorizationHeader(); expect(result1).toBe("Bearer guest-1"); - // Guest tokens aren't cached in cachedBearerToken, so each call re-evaluates - vi.mocked(getGuestBearerToken).mockResolvedValue("guest-2"); + vi.advanceTimersByTime(30_001); const result2 = await getHostedAuthorizationHeader(); expect(result2).toBe("Bearer guest-2"); diff --git a/mcpjam-inspector/client/src/lib/apis/web/context.ts b/mcpjam-inspector/client/src/lib/apis/web/context.ts index ebae07c08..e1543aa60 100644 --- a/mcpjam-inspector/client/src/lib/apis/web/context.ts +++ b/mcpjam-inspector/client/src/lib/apis/web/context.ts @@ -11,6 +11,8 @@ export interface HostedApiContext { guestOauthTokensByServerName?: Record; shareToken?: string; isAuthenticated?: boolean; + /** True when a WorkOS session exists (user signed in), even if token hasn't resolved yet. */ + hasSession?: boolean; /** Maps server name → MCPServerConfig for guest mode (no Convex). */ serverConfigs?: Record; } @@ -31,6 +33,29 @@ export function resetTokenCache() { cachedBearerToken = null; } +function readStoredGuestOAuthAccessToken( + serverName: string, +): string | undefined { + if (typeof window === "undefined") return undefined; + + try { + const raw = localStorage.getItem(`mcp-tokens-${serverName}`); + if (!raw) return undefined; + + const parsed = JSON.parse(raw) as { access_token?: unknown }; + if ( + typeof parsed.access_token === "string" && + parsed.access_token.trim().length > 0 + ) { + return parsed.access_token; + } + } catch { + // Ignore malformed localStorage data and fall back to in-memory context. + } + + return undefined; +} + function assertHostedMode() { if (!HOSTED_MODE) { throw new Error("Hosted API context is only available in hosted mode"); @@ -38,16 +63,36 @@ function assertHostedMode() { } /** - * True when running in hosted mode without an authenticated workspace. - * Guest users store server configs in localStorage and connect directly - * (no Convex involvement). + * True when running in hosted mode as a direct guest connection. + * Direct guests store server configs in localStorage and connect directly + * without Convex authorization. */ export function isGuestMode(): boolean { if (!HOSTED_MODE) return false; return !hostedApiContext.workspaceId && !hostedApiContext.isAuthenticated; } -function buildGuestServerRequest( +/** + * Hosted guest access comes in 2 shapes: + * - direct guest: no workspace, direct serverUrl requests + * - shared guest: workspace-scoped share token, Convex-backed requests + */ +function hasHostedGuestAccess(): boolean { + if (!HOSTED_MODE) return false; + if (hostedApiContext.isAuthenticated) return false; + return !hostedApiContext.workspaceId || !!hostedApiContext.shareToken; +} + +/** + * Prefer the guest bearer for both direct guests and shared guests. + * Shared guests still use Convex-backed requests; they only differ in how the + * bearer is obtained. + */ +function shouldPreferGuestBearer(): boolean { + return hasHostedGuestAccess(); +} + +export function buildGuestServerRequest( config: unknown, oauthAccessToken?: string, ): Record { @@ -164,10 +209,13 @@ export function buildHostedServerRequest( "The server may not be loaded yet.", ); } - return buildGuestServerRequest( - config, - hostedApiContext.guestOauthTokensByServerName?.[serverNameOrId], - ); + // Prefer persisted OAuth tokens so guest requests can keep working even if + // React state has not yet synchronized token updates. + const oauthToken = + readStoredGuestOAuthAccessToken(serverNameOrId) ?? + hostedApiContext.guestOauthTokensByServerName?.[serverNameOrId]; + + return buildGuestServerRequest(config, oauthToken); } // Authenticated path: resolve via Convex server mappings @@ -223,6 +271,20 @@ export async function getHostedAuthorizationHeader(): Promise { return `Bearer ${cachedBearerToken.token}`; } + // In guest mode, bypass WorkOS token bootstrap entirely and use a guest + // bearer token directly. This avoids stale/invalid WorkOS tokens from + // masking valid guest sessions. + if (shouldPreferGuestBearer()) { + const guestToken = await getGuestBearerToken(); + if (guestToken) { + cachedBearerToken = { + token: guestToken, + expiresAt: now + TOKEN_CACHE_TTL_MS, + }; + return `Bearer ${guestToken}`; + } + } + // Try WorkOS (logged-in user) const getAccessToken = hostedApiContext.getAccessToken; if (getAccessToken) { @@ -237,9 +299,19 @@ export async function getHostedAuthorizationHeader(): Promise { } } - // Fall back to guest token + if (!hasHostedGuestAccess()) { + return null; + } + + // Fall back to guest token for explicit guest-capable surfaces only. const guestToken = await getGuestBearerToken(); - if (guestToken) return `Bearer ${guestToken}`; + if (guestToken) { + cachedBearerToken = { + token: guestToken, + expiresAt: now + TOKEN_CACHE_TTL_MS, + }; + return `Bearer ${guestToken}`; + } return null; } diff --git a/mcpjam-inspector/client/src/lib/guest-session.ts b/mcpjam-inspector/client/src/lib/guest-session.ts index e7fa5868f..fce0dd9f7 100644 --- a/mcpjam-inspector/client/src/lib/guest-session.ts +++ b/mcpjam-inspector/client/src/lib/guest-session.ts @@ -15,6 +15,8 @@ interface GuestSession { } let inFlightRequest: Promise | null = null; +let forceRefreshInFlight: Promise | null = null; +let sessionGeneration = 0; function readFromStorage(): GuestSession | null { try { @@ -48,6 +50,7 @@ export async function getOrCreateGuestSession(): Promise { return inFlightRequest; } + const generation = sessionGeneration; inFlightRequest = (async () => { try { const response = await fetch("/api/web/guest-session", { @@ -65,7 +68,10 @@ export async function getOrCreateGuestSession(): Promise { } const session: GuestSession = await response.json(); - writeToStorage(session); + // Only write if no force-refresh has invalidated this generation + if (sessionGeneration === generation) { + writeToStorage(session); + } return session; } catch (error) { console.error("Failed to create guest session:", error); @@ -86,6 +92,15 @@ export async function getGuestBearerToken(): Promise { return session?.token ?? null; } +/** + * Returns the currently persisted guest token without triggering a network + * request. Used by retry logic to detect whether a failing hosted request + * was sent with the guest bearer even if hosted context classification is stale. + */ +export function peekStoredGuestToken(): string | null { + return readFromStorage()?.token ?? null; +} + /** * Clear the guest session from localStorage. * Call this when the user logs in with WorkOS. @@ -99,10 +114,27 @@ export function clearGuestSession(): void { * and fetching a new one from the server. Used when the server * rejects a token that hasn't expired client-side (e.g., after * a server restart with new signing keys). + * + * Deduplicates concurrent force-refresh calls (e.g., when multiple + * parallel requests all get 401 and each triggers a retry). */ export async function forceRefreshGuestSession(): Promise { + // If a force-refresh is already in flight, piggyback on it + // instead of clearing its state and starting yet another request. + if (forceRefreshInFlight) { + const session = await forceRefreshInFlight; + return session?.token ?? null; + } + clearGuestSession(); + sessionGeneration++; inFlightRequest = null; - const session = await getOrCreateGuestSession(); - return session?.token ?? null; + + forceRefreshInFlight = getOrCreateGuestSession(); + try { + const session = await forceRefreshInFlight; + return session?.token ?? null; + } finally { + forceRefreshInFlight = null; + } } diff --git a/mcpjam-inspector/client/src/lib/hosted-workspace.ts b/mcpjam-inspector/client/src/lib/hosted-workspace.ts new file mode 100644 index 000000000..737ba72b1 --- /dev/null +++ b/mcpjam-inspector/client/src/lib/hosted-workspace.ts @@ -0,0 +1,6 @@ +export function resolveHostedWorkspaceId( + isAuthenticated: boolean, + sharedWorkspaceId: string | null | undefined, +): string | null { + return isAuthenticated ? (sharedWorkspaceId ?? null) : null; +} diff --git a/mcpjam-inspector/client/src/lib/session-token.ts b/mcpjam-inspector/client/src/lib/session-token.ts index ca9d952b9..b9094f759 100644 --- a/mcpjam-inspector/client/src/lib/session-token.ts +++ b/mcpjam-inspector/client/src/lib/session-token.ts @@ -18,7 +18,10 @@ import { isGuestMode, resetTokenCache, } from "@/lib/apis/web/context"; -import { forceRefreshGuestSession } from "@/lib/guest-session"; +import { + forceRefreshGuestSession, + peekStoredGuestToken, +} from "@/lib/guest-session"; // Extend window type for the injected token declare global { @@ -230,11 +233,14 @@ export async function authFetch( const callerProvidedAuthorization = hasAuthorizationHeader(init?.headers); const mergedInit = buildAuthFetchInit(init, hostedAuthHeader); const response = await fetch(input, mergedInit); + const requestUsedStoredGuestToken = + !!hostedAuthHeader && + hostedAuthHeader === `Bearer ${peekStoredGuestToken() ?? ""}`; if ( response.status !== 401 || !HOSTED_MODE || - !isGuestMode() || + (!isGuestMode() && !requestUsedStoredGuestToken) || callerProvidedAuthorization ) { return response; diff --git a/mcpjam-inspector/client/src/stores/traffic-log-store.ts b/mcpjam-inspector/client/src/stores/traffic-log-store.ts index a85177cd8..e50d6f13c 100644 --- a/mcpjam-inspector/client/src/stores/traffic-log-store.ts +++ b/mcpjam-inspector/client/src/stores/traffic-log-store.ts @@ -11,6 +11,7 @@ import { create } from "zustand"; import { addTokenToUrl } from "@/lib/session-token"; +import { HOSTED_MODE } from "@/lib/config"; export type UiProtocol = "mcp-apps" | "openai-apps"; @@ -78,6 +79,10 @@ let sseConnection: EventSource | null = null; let sseSubscriberCount = 0; export function subscribeToRpcStream(): () => void { + if (HOSTED_MODE) { + return () => {}; + } + sseSubscriberCount++; if (!sseConnection) { diff --git a/mcpjam-inspector/server/app.ts b/mcpjam-inspector/server/app.ts index d2465770d..4d91077b1 100644 --- a/mcpjam-inspector/server/app.ts +++ b/mcpjam-inspector/server/app.ts @@ -27,7 +27,8 @@ import { generateSessionToken, getSessionToken, } from "./services/session-token.js"; -import { initGuestTokenSecret, getGuestJwks } from "./services/guest-token.js"; +import { initGuestTokenSecret } from "./services/guest-token.js"; +import { syncGuestAuthConfigToConvex } from "./utils/convex-guest-auth-sync.js"; import { isAllowedHost } from "./utils/localhost-check.js"; import { sessionAuthMiddleware, @@ -42,7 +43,9 @@ const __dirname = dirname(__filename); export function createHonoApp() { // Load environment variables early so route handlers can read CONVEX_HTTP_URL const envFile = - process.env.NODE_ENV === "production" ? ".env.production" : ".env.local"; + process.env.NODE_ENV === "production" + ? ".env.production" + : ".env.development"; // Determine where to look for .env file: // 1. Electron packaged: use process.resourcesPath directly @@ -87,6 +90,7 @@ export function createHonoApp() { // Initialize RS256 key pair for guest JWTs initGuestTokenSecret(); + syncGuestAuthConfigToConvex(); const app = new Hono(); const strictModeResponse = (c: any, path: string) => @@ -218,12 +222,8 @@ export function createHonoApp() { return c.json({ status: "ok", timestamp: new Date().toISOString() }); }); - // Guest JWT JWKS endpoint — public, cacheable, no auth required. - // Convex uses this to verify guest JWTs natively. - app.get("/guest/jwks", (c) => { - c.header("Cache-Control", "public, max-age=3600"); - return c.json(getGuestJwks()); - }); + // Guest JWKS is now served via /api/web/guest-jwks (see routes/web/index.ts) + // so it isn't intercepted by the SPA's serveStatic catch-all. // Session token endpoint (for dev mode where HTML isn't served by this server) // Token is only served to localhost or allowed hosts (in hosted mode) diff --git a/mcpjam-inspector/server/index.ts b/mcpjam-inspector/server/index.ts index 1cf88a9f5..7cc401c05 100644 --- a/mcpjam-inspector/server/index.ts +++ b/mcpjam-inspector/server/index.ts @@ -27,6 +27,7 @@ import { import { originValidationMiddleware } from "./middleware/origin-validation"; import { securityHeadersMiddleware } from "./middleware/security-headers"; import { inAppBrowserMiddleware } from "./middleware/in-app-browser"; +import { syncGuestAuthConfigToConvex } from "./utils/convex-guest-auth-sync"; // Handle unhandled promise rejections gracefully (Node.js v24+ throws by default) // This prevents the server from crashing when MCP connections are closed while @@ -183,8 +184,8 @@ try { // Generate session token for API authentication generateSessionToken(); -// Initialize guest token secret for hosted mode -initGuestTokenSecret(); +// Guest token secret is initialized after dotenv.config() below (line ~231) +// so that GUEST_JWT_PRIVATE_KEY / GUEST_JWT_PUBLIC_KEY are available. const app = new Hono().onError((err, c) => { appLogger.error("Unhandled error:", err); @@ -230,6 +231,10 @@ if ( dotenv.config({ path: envPath }); +// Initialize guest token secret (must be after dotenv.config so env vars are available) +initGuestTokenSecret(); +syncGuestAuthConfigToConvex(); + // Validate required env vars if (!process.env.CONVEX_HTTP_URL) { throw new Error( diff --git a/mcpjam-inspector/server/middleware/bearer-auth.ts b/mcpjam-inspector/server/middleware/bearer-auth.ts index 344bdac65..9690c9096 100644 --- a/mcpjam-inspector/server/middleware/bearer-auth.ts +++ b/mcpjam-inspector/server/middleware/bearer-auth.ts @@ -1,6 +1,6 @@ import type { Context, Next } from "hono"; import { ErrorCode } from "../routes/web/errors.js"; -import { validateGuestToken } from "../services/guest-token.js"; +import { validateGuestTokenDetailedAsync } from "../services/guest-token.js"; /** * Reusable Hono middleware that: @@ -25,7 +25,7 @@ export async function bearerAuthMiddleware( // Try validating as a guest token try { - const result = validateGuestToken(token); + const result = await validateGuestTokenDetailedAsync(token); if (result.valid && result.guestId) { c.set("guestId", result.guestId); return next(); diff --git a/mcpjam-inspector/server/routes/mcp/__tests__/chat-v2.test.ts b/mcpjam-inspector/server/routes/mcp/__tests__/chat-v2.test.ts index 16c750b55..6b219523a 100644 --- a/mcpjam-inspector/server/routes/mcp/__tests__/chat-v2.test.ts +++ b/mcpjam-inspector/server/routes/mcp/__tests__/chat-v2.test.ts @@ -87,6 +87,14 @@ vi.mock("../../../utils/chat-helpers", async () => { vi.mock("@/shared/types", () => ({ isGPT5Model: vi.fn().mockReturnValue(false), isMCPJamProvidedModel: vi.fn().mockReturnValue(false), + isGuestAllowedModel: vi.fn().mockReturnValue(true), +})); + +// Mock guest-auth to avoid needing real JWT keys in tests +vi.mock("../../../utils/guest-auth.js", () => ({ + getProductionGuestAuthHeader: vi + .fn() + .mockResolvedValue("Bearer mock-guest-token"), })); // Mock http-tool-calls for testing unresolved tool calls scenario diff --git a/mcpjam-inspector/server/routes/mcp/chat-v2.ts b/mcpjam-inspector/server/routes/mcp/chat-v2.ts index 87b08f3c6..5271698d3 100644 --- a/mcpjam-inspector/server/routes/mcp/chat-v2.ts +++ b/mcpjam-inspector/server/routes/mcp/chat-v2.ts @@ -7,8 +7,9 @@ import { } from "ai"; import type { ChatV2Request } from "@/shared/chat-v2"; import { createLlmModel } from "../../utils/chat-helpers"; -import { isMCPJamProvidedModel } from "@/shared/types"; +import { isMCPJamProvidedModel, isGuestAllowedModel } from "@/shared/types"; import type { ModelProvider } from "@/shared/types"; +import { getProductionGuestAuthHeader } from "../../utils/guest-auth.js"; import { logger } from "../../utils/logger"; import { handleMCPJamFreeChatModel } from "../../utils/mcpjam-stream-handler"; import type { ModelMessage } from "@ai-sdk/provider-utils"; @@ -124,6 +125,37 @@ chatV2.post("/", async (c) => { ); } + // Resolve auth header: use client-provided token (WorkOS) if present, + // otherwise fetch a production guest token for guest-allowed models. + let authHeader = c.req.header("authorization"); + + if (!authHeader) { + if (!isGuestAllowedModel(String(modelDefinition.id))) { + return c.json( + { + error: + "Sign in to use this model. Guest users can use: claude-haiku-4.5, gpt-5-mini, gemini-2.5-flash.", + }, + 403, + ); + } + + try { + authHeader = (await getProductionGuestAuthHeader()) ?? undefined; + } catch { + authHeader = undefined; + } + if (!authHeader) { + return c.json( + { + error: + "Unable to authenticate with MCPJam servers. Please try again or sign in.", + }, + 503, + ); + } + } + const modelMessages = await convertToModelMessages(messages); return handleMCPJamFreeChatModel({ @@ -132,7 +164,7 @@ chatV2.post("/", async (c) => { systemPrompt: enhancedSystemPrompt, temperature: resolvedTemperature, tools: allTools as ToolSet, - authHeader: c.req.header("authorization"), + authHeader, mcpClientManager, selectedServers, requireToolApproval, diff --git a/mcpjam-inspector/server/routes/web/__tests__/chat-v2.guest.test.ts b/mcpjam-inspector/server/routes/web/__tests__/chat-v2.guest.test.ts new file mode 100644 index 000000000..70178c51e --- /dev/null +++ b/mcpjam-inspector/server/routes/web/__tests__/chat-v2.guest.test.ts @@ -0,0 +1,253 @@ +import { createSign, generateKeyPairSync } from "crypto"; +import { mkdtempSync, rmSync } from "fs"; +import os from "os"; +import path from "path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { Hono } from "hono"; + +const { + prepareChatV2Mock, + handleMCPJamFreeChatModelMock, + disconnectAllServersMock, +} = vi.hoisted(() => ({ + prepareChatV2Mock: vi.fn(), + handleMCPJamFreeChatModelMock: vi.fn(), + disconnectAllServersMock: vi.fn(), +})); + +vi.mock("ai", async () => { + const actual = await vi.importActual("ai"); + return { + ...actual, + convertToModelMessages: vi.fn((messages) => messages), + }; +}); + +vi.mock("@mcpjam/sdk", () => ({ + isMCPAuthError: vi.fn().mockReturnValue(false), + MCPClientManager: vi.fn().mockImplementation(() => ({ + disconnectAllServers: disconnectAllServersMock, + })), +})); + +vi.mock("../../../utils/chat-v2-orchestration.js", () => ({ + prepareChatV2: prepareChatV2Mock, +})); + +vi.mock("../../../utils/mcpjam-stream-handler.js", () => ({ + handleMCPJamFreeChatModel: handleMCPJamFreeChatModelMock, +})); + +vi.mock("../apps.js", () => ({ + default: new Hono(), +})); + +vi.mock("@/shared/types", async () => { + const actual = + await vi.importActual("@/shared/types"); + return { + ...actual, + isMCPJamProvidedModel: vi.fn().mockReturnValue(true), + }; +}); + +import { createWebTestApp, expectJson, postJson } from "./helpers/test-app.js"; +import { + initGuestTokenSecret, + issueGuestToken, +} from "../../../services/guest-token.js"; + +describe("web routes — chat-v2 guest mode", () => { + const originalConvexHttpUrl = process.env.CONVEX_HTTP_URL; + const originalNodeEnv = process.env.NODE_ENV; + const originalHostedGuestJwksUrl = process.env.MCPJAM_GUEST_JWKS_URL; + const originalLocalSigning = process.env.MCPJAM_USE_LOCAL_GUEST_SIGNING; + const originalGuestJwtKeyDir = process.env.GUEST_JWT_KEY_DIR; + const originalFetch = global.fetch; + let testGuestKeyDir: string; + + const signHostedGuestToken = () => { + const pair = generateKeyPairSync("rsa", { modulusLength: 2048 }); + const now = Math.floor(Date.now() / 1000); + const header = { alg: "RS256", typ: "JWT", kid: "guest-1" }; + const payload = { + iss: "https://api.mcpjam.com/guest", + sub: "hosted-guest-id", + iat: now, + exp: now + 3600, + }; + + const encodedHeader = Buffer.from(JSON.stringify(header)).toString( + "base64url", + ); + const encodedPayload = Buffer.from(JSON.stringify(payload)).toString( + "base64url", + ); + const signingInput = `${encodedHeader}.${encodedPayload}`; + const signer = createSign("RSA-SHA256"); + signer.update(signingInput); + + return { + token: `${signingInput}.${signer.sign(pair.privateKey, "base64url")}`, + jwks: { + keys: [ + { + ...(pair.publicKey.export({ format: "jwk" }) as JsonWebKey), + kid: "guest-1", + alg: "RS256", + use: "sig", + }, + ], + }, + }; + }; + + beforeEach(() => { + vi.clearAllMocks(); + testGuestKeyDir = mkdtempSync(path.join(os.tmpdir(), "chat-v2-guest-test-")); + process.env.GUEST_JWT_KEY_DIR = testGuestKeyDir; + initGuestTokenSecret(); + process.env.CONVEX_HTTP_URL = "https://example.convex.site"; + prepareChatV2Mock.mockResolvedValue({ + allTools: {}, + enhancedSystemPrompt: "system", + resolvedTemperature: 0.7, + scrubMessages: (messages: unknown) => messages, + }); + handleMCPJamFreeChatModelMock.mockResolvedValue( + new Response("ok", { status: 200 }), + ); + disconnectAllServersMock.mockResolvedValue(undefined); + }); + + afterEach(() => { + if (originalConvexHttpUrl === undefined) { + delete process.env.CONVEX_HTTP_URL; + } else { + process.env.CONVEX_HTTP_URL = originalConvexHttpUrl; + } + if (originalNodeEnv === undefined) { + delete process.env.NODE_ENV; + } else { + process.env.NODE_ENV = originalNodeEnv; + } + if (originalHostedGuestJwksUrl === undefined) { + delete process.env.MCPJAM_GUEST_JWKS_URL; + } else { + process.env.MCPJAM_GUEST_JWKS_URL = originalHostedGuestJwksUrl; + } + if (originalLocalSigning === undefined) { + delete process.env.MCPJAM_USE_LOCAL_GUEST_SIGNING; + } else { + process.env.MCPJAM_USE_LOCAL_GUEST_SIGNING = originalLocalSigning; + } + if (originalGuestJwtKeyDir === undefined) { + delete process.env.GUEST_JWT_KEY_DIR; + } else { + process.env.GUEST_JWT_KEY_DIR = originalGuestJwtKeyDir; + } + rmSync(testGuestKeyDir, { recursive: true, force: true }); + global.fetch = originalFetch; + }); + + it("returns 401 when a non-guest bearer token reaches the guest branch", async () => { + const { app } = createWebTestApp(); + + const response = await postJson( + app, + "/api/web/chat-v2", + { + messages: [{ role: "user", parts: [{ type: "text", text: "hi" }] }], + model: { + id: "anthropic/claude-haiku-4.5", + provider: "anthropic", + name: "Claude Haiku 4.5", + }, + }, + "non-guest-token", + ); + + const { status, data } = await expectJson<{ + code: string; + message: string; + }>(response); + expect(status).toBe(401); + expect(data.code).toBe("UNAUTHORIZED"); + expect(data.message).toContain("Valid guest token required"); + }); + + it("streams hosted guest chat when a valid guest token is present", async () => { + const { app } = createWebTestApp(); + const { token } = issueGuestToken(); + + const response = await postJson( + app, + "/api/web/chat-v2", + { + messages: [{ role: "user", parts: [{ type: "text", text: "hey" }] }], + model: { + id: "anthropic/claude-haiku-4.5", + provider: "anthropic", + name: "Claude Haiku 4.5", + }, + systemPrompt: "You are helpful", + temperature: 0.7, + }, + token, + ); + + expect(response.status).toBe(200); + expect(await response.text()).toBe("ok"); + expect(prepareChatV2Mock).toHaveBeenCalledWith( + expect.objectContaining({ + selectedServers: [], + requireToolApproval: undefined, + }), + ); + expect(handleMCPJamFreeChatModelMock).toHaveBeenCalledWith( + expect.objectContaining({ + modelId: "anthropic/claude-haiku-4.5", + authHeader: `Bearer ${token}`, + selectedServers: [], + }), + ); + }); + + it("accepts a hosted guest token in development when local signing is disabled", async () => { + process.env.NODE_ENV = "development"; + process.env.MCPJAM_USE_LOCAL_GUEST_SIGNING = "false"; + process.env.MCPJAM_GUEST_JWKS_URL = + "https://app.mcpjam.com/api/web/guest-jwks"; + const { token, jwks } = signHostedGuestToken(); + global.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify(jwks), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ) as typeof fetch; + + const { app } = createWebTestApp(); + + const response = await postJson( + app, + "/api/web/chat-v2", + { + messages: [{ role: "user", parts: [{ type: "text", text: "hey" }] }], + model: { + id: "anthropic/claude-haiku-4.5", + provider: "anthropic", + name: "Claude Haiku 4.5", + }, + }, + token, + ); + + expect(response.status).toBe(200); + expect(await response.text()).toBe("ok"); + expect(handleMCPJamFreeChatModelMock).toHaveBeenCalledWith( + expect.objectContaining({ + authHeader: `Bearer ${token}`, + }), + ); + }); +}); diff --git a/mcpjam-inspector/server/routes/web/__tests__/guest-jwks.test.ts b/mcpjam-inspector/server/routes/web/__tests__/guest-jwks.test.ts new file mode 100644 index 000000000..7388e2f57 --- /dev/null +++ b/mcpjam-inspector/server/routes/web/__tests__/guest-jwks.test.ts @@ -0,0 +1,116 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { mkdtempSync, rmSync } from "fs"; +import os from "os"; +import path from "path"; +import { Hono } from "hono"; +import webRoutes from "../index.js"; +import { initGuestTokenSecret } from "../../../services/guest-token.js"; + +vi.mock("@mcpjam/sdk", () => ({ + MCPClientManager: vi.fn(), + isMCPAuthError: vi.fn().mockReturnValue(false), +})); + +vi.mock("../apps.js", () => ({ + default: new Hono(), +})); + +const ORIGINAL_NODE_ENV = process.env.NODE_ENV; +const ORIGINAL_GUEST_JWT_KEY_DIR = process.env.GUEST_JWT_KEY_DIR; + +describe("GET /api/web/guest-jwks", () => { + let app: Hono; + let testGuestKeyDir: string; + + beforeEach(() => { + testGuestKeyDir = mkdtempSync(path.join(os.tmpdir(), "guest-jwks-test-")); + process.env.NODE_ENV = "test"; + process.env.GUEST_JWT_KEY_DIR = testGuestKeyDir; + initGuestTokenSecret(); + + app = new Hono(); + app.route("/api/web", webRoutes); + }); + + afterEach(() => { + process.env.NODE_ENV = ORIGINAL_NODE_ENV; + if (ORIGINAL_GUEST_JWT_KEY_DIR === undefined) { + delete process.env.GUEST_JWT_KEY_DIR; + } else { + process.env.GUEST_JWT_KEY_DIR = ORIGINAL_GUEST_JWT_KEY_DIR; + } + rmSync(testGuestKeyDir, { recursive: true, force: true }); + }); + + it("returns a public, cacheable JWKS document", async () => { + const response = await app.request("/api/web/guest-jwks"); + + expect(response.status).toBe(200); + expect(response.headers.get("cache-control")).toBe("public, max-age=3600"); + expect(response.headers.get("content-type")).toContain("application/json"); + + const body = await response.json(); + expect(body).toMatchObject({ + keys: [ + expect.objectContaining({ + kid: "guest-1", + alg: "RS256", + use: "sig", + }), + ], + }); + }); + + it("returns a valid RSA public key with required JWK fields", async () => { + const response = await app.request("/api/web/guest-jwks"); + const body = await response.json(); + const key = body.keys[0]; + + // RSA public keys must have kty, n (modulus), and e (exponent) + expect(key.kty).toBe("RSA"); + expect(key.n).toEqual(expect.any(String)); + expect(key.e).toEqual(expect.any(String)); + // n should be a base64url-encoded RSA modulus (at least 100 chars for 2048-bit) + expect(key.n.length).toBeGreaterThan(100); + }); + + it("returns exactly one key", async () => { + const response = await app.request("/api/web/guest-jwks"); + const body = await response.json(); + + expect(body.keys).toHaveLength(1); + }); +}); + +describe("GET /api/web/guest-jwks (uninitialized)", () => { + it("returns 500 when initGuestTokenSecret() was not called", async () => { + // Simulate the crash that happens when getGuestJwks() is called before + // initGuestTokenSecret(). We can't un-initialize the module-level keys, + // so we build a minimal Hono app that throws the same error and uses + // the same onError handler as the real web routes. + const { mapRuntimeError, webError } = await import("../errors.js"); + + const errorApp = new Hono(); + errorApp.get("/api/web/guest-jwks", () => { + throw new Error( + "Guest JWT keys not initialized. Call initGuestTokenSecret() first.", + ); + }); + errorApp.onError((error, c) => { + const routeError = mapRuntimeError(error); + return webError( + c, + routeError.status, + routeError.code, + routeError.message, + ); + }); + + const response = await errorApp.request("/api/web/guest-jwks"); + + expect(response.status).toBe(500); + const body = await response.json(); + expect(body.code).toBe("INTERNAL_ERROR"); + expect(body.message).toContain("not initialized"); + }); +}); diff --git a/mcpjam-inspector/server/routes/web/__tests__/guest-session.test.ts b/mcpjam-inspector/server/routes/web/__tests__/guest-session.test.ts index 5c62cf709..bfed10523 100644 --- a/mcpjam-inspector/server/routes/web/__tests__/guest-session.test.ts +++ b/mcpjam-inspector/server/routes/web/__tests__/guest-session.test.ts @@ -5,11 +5,20 @@ * Covers token issuance, response format, and IP-based rate limiting. */ -import { describe, it, expect, beforeEach } from "vitest"; +import { mkdtempSync, rmSync } from "fs"; +import os from "os"; +import path from "path"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { Hono } from "hono"; import guestSession from "../guest-session.js"; import { initGuestTokenSecret } from "../../../services/guest-token.js"; +const ORIGINAL_NODE_ENV = process.env.NODE_ENV; +const ORIGINAL_GUEST_JWT_KEY_DIR = process.env.GUEST_JWT_KEY_DIR; +const ORIGINAL_REMOTE_URL = process.env.MCPJAM_GUEST_SESSION_URL; +const ORIGINAL_LOCAL_SIGNING = process.env.MCPJAM_USE_LOCAL_GUEST_SIGNING; +const ORIGINAL_FETCH = global.fetch; + function createTestApp(): Hono { const app = new Hono(); app.route("/guest-session", guestSession); @@ -18,12 +27,42 @@ function createTestApp(): Hono { describe("POST /guest-session", () => { let app: Hono; + let testGuestKeyDir: string; beforeEach(() => { + vi.restoreAllMocks(); + testGuestKeyDir = mkdtempSync( + path.join(os.tmpdir(), "guest-session-test-"), + ); + process.env.NODE_ENV = "test"; + process.env.GUEST_JWT_KEY_DIR = testGuestKeyDir; + delete process.env.MCPJAM_GUEST_SESSION_URL; + delete process.env.MCPJAM_USE_LOCAL_GUEST_SIGNING; initGuestTokenSecret(); app = createTestApp(); }); + afterEach(() => { + process.env.NODE_ENV = ORIGINAL_NODE_ENV; + if (ORIGINAL_GUEST_JWT_KEY_DIR === undefined) { + delete process.env.GUEST_JWT_KEY_DIR; + } else { + process.env.GUEST_JWT_KEY_DIR = ORIGINAL_GUEST_JWT_KEY_DIR; + } + if (ORIGINAL_REMOTE_URL === undefined) { + delete process.env.MCPJAM_GUEST_SESSION_URL; + } else { + process.env.MCPJAM_GUEST_SESSION_URL = ORIGINAL_REMOTE_URL; + } + if (ORIGINAL_LOCAL_SIGNING === undefined) { + delete process.env.MCPJAM_USE_LOCAL_GUEST_SIGNING; + } else { + process.env.MCPJAM_USE_LOCAL_GUEST_SIGNING = ORIGINAL_LOCAL_SIGNING; + } + global.fetch = ORIGINAL_FETCH; + rmSync(testGuestKeyDir, { recursive: true, force: true }); + }); + describe("token issuance", () => { it("returns 200 with guestId, token, and expiresAt", async () => { const res = await app.request("/guest-session", { method: "POST" }); @@ -93,6 +132,44 @@ describe("POST /guest-session", () => { }); }); + describe("remote guest session mode", () => { + it("proxies the hosted guest session when local signing is explicitly disabled", async () => { + process.env.MCPJAM_USE_LOCAL_GUEST_SIGNING = "false"; + process.env.MCPJAM_GUEST_SESSION_URL = + "https://app.mcpjam.com/api/web/guest-session"; + global.fetch = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + guestId: "guest-remote", + token: "remote-token", + expiresAt: 123456789, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + }, + ), + ) as typeof fetch; + + const res = await app.request("/guest-session", { method: "POST" }); + const data = await res.json(); + + expect(res.status).toBe(200); + expect(data).toEqual({ + guestId: "guest-remote", + token: "remote-token", + expiresAt: 123456789, + }); + expect(global.fetch).toHaveBeenCalledWith( + "https://app.mcpjam.com/api/web/guest-session", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + }, + ); + }); + }); + describe("IP-based rate limiting", () => { it("allows up to 10 requests per IP", async () => { for (let i = 0; i < 10; i++) { diff --git a/mcpjam-inspector/server/routes/web/auth.ts b/mcpjam-inspector/server/routes/web/auth.ts index 8251f8407..418d9e995 100644 --- a/mcpjam-inspector/server/routes/web/auth.ts +++ b/mcpjam-inspector/server/routes/web/auth.ts @@ -75,7 +75,7 @@ export const hostedChatSchema = z // ── Guest Schema ───────────────────────────────────────────────────── -const guestServerInputSchema = z.object({ +export const guestServerInputSchema = z.object({ serverUrl: z.string().min(1), serverHeaders: z.record(z.string(), z.string()).optional(), }); diff --git a/mcpjam-inspector/server/routes/web/chat-v2.ts b/mcpjam-inspector/server/routes/web/chat-v2.ts index 9361caa97..7c4c0e147 100644 --- a/mcpjam-inspector/server/routes/web/chat-v2.ts +++ b/mcpjam-inspector/server/routes/web/chat-v2.ts @@ -2,14 +2,17 @@ import { Hono } from "hono"; import { convertToModelMessages, type ToolSet } from "ai"; import type { ModelMessage } from "@ai-sdk/provider-utils"; import type { ChatV2Request } from "@/shared/chat-v2"; -import { isMCPAuthError } from "@mcpjam/sdk"; +import { isMCPAuthError, MCPClientManager } from "@mcpjam/sdk"; +import type { HttpServerConfig } from "@mcpjam/sdk"; import { handleMCPJamFreeChatModel } from "../../utils/mcpjam-stream-handler.js"; -import { isMCPJamProvidedModel } from "@/shared/types"; +import { isMCPJamProvidedModel, isGuestAllowedModel } from "@/shared/types"; import { WEB_STREAM_TIMEOUT_MS } from "../../config.js"; import { prepareChatV2 } from "../../utils/chat-v2-orchestration.js"; +import { validateUrl, OAuthProxyError } from "../../utils/oauth-proxy.js"; import { saveThreadToConvex } from "../../utils/shared-chat-persistence.js"; import { hostedChatSchema, + guestServerInputSchema, createAuthorizedManager, assertBearerToken, readJsonBody, @@ -31,6 +34,171 @@ chatV2.post("/", async (c) => { try { const bearerToken = assertBearerToken(c); const rawBody = await readJsonBody>(c); + + // Detect guest request by body shape: no workspaceId means guest-direct + // (matching the pattern from withEphemeralConnection in auth.ts) + const isGuestRequest = !rawBody.workspaceId; + + if (isGuestRequest) { + // ── Guest path: direct connection, no Convex authorization ── + const guestId = c.get("guestId") as string | undefined; + if (!guestId) { + throw new WebRouteError( + 401, + ErrorCode.UNAUTHORIZED, + "Valid guest token required. Please refresh the page to obtain a new session.", + ); + } + + const body = rawBody as unknown as ChatV2Request & { + serverUrl?: string; + serverHeaders?: Record; + oauthAccessToken?: string; + }; + + const { + messages, + model, + systemPrompt, + temperature, + requireToolApproval, + } = body; + + if (!Array.isArray(messages) || messages.length === 0) { + throw new WebRouteError( + 400, + ErrorCode.VALIDATION_ERROR, + "messages are required", + ); + } + + const modelDefinition = model; + if (!modelDefinition) { + throw new WebRouteError( + 400, + ErrorCode.VALIDATION_ERROR, + "model is not supported", + ); + } + + if (modelDefinition.id && isMCPJamProvidedModel(modelDefinition.id)) { + if (!isGuestAllowedModel(String(modelDefinition.id))) { + throw new WebRouteError( + 403, + ErrorCode.UNAUTHORIZED, + "Sign in to use this model. Guest users can use: claude-haiku-4.5, gpt-5-mini, gemini-2.5-flash.", + ); + } + if (!process.env.CONVEX_HTTP_URL) { + throw new WebRouteError( + 500, + ErrorCode.INTERNAL_ERROR, + "Server missing CONVEX_HTTP_URL configuration", + ); + } + } else { + throw new WebRouteError( + 400, + ErrorCode.FEATURE_NOT_SUPPORTED, + "Only MCPJam hosted models are supported in hosted mode", + ); + } + + // Build the MCPClientManager: either with a guest server or empty + let manager: InstanceType; + const hasServer = typeof body.serverUrl === "string" && body.serverUrl; + + if (hasServer) { + // Guest with MCP server — validate and connect + const guestInput = parseWithSchema(guestServerInputSchema, rawBody); + + try { + await validateUrl(guestInput.serverUrl, true); + } catch (err) { + if (err instanceof OAuthProxyError) { + throw new WebRouteError( + err.status, + ErrorCode.VALIDATION_ERROR, + err.message, + ); + } + throw err; + } + + const headers: Record = { + ...(guestInput.serverHeaders ?? {}), + }; + + if (typeof body.oauthAccessToken === "string") { + headers["Authorization"] = `Bearer ${body.oauthAccessToken}`; + } + + const httpConfig: HttpServerConfig = { + url: guestInput.serverUrl, + requestInit: { headers }, + timeout: WEB_STREAM_TIMEOUT_MS, + }; + + manager = new MCPClientManager( + { __guest__: httpConfig }, + { defaultTimeout: WEB_STREAM_TIMEOUT_MS }, + ); + } else { + // Guest without servers — empty manager for plain LLM chat + manager = new MCPClientManager( + {}, + { defaultTimeout: WEB_STREAM_TIMEOUT_MS }, + ); + } + + try { + const selectedServers = hasServer ? ["__guest__"] : []; + + let prepared; + try { + prepared = await prepareChatV2({ + mcpClientManager: manager, + selectedServers, + modelDefinition, + systemPrompt, + temperature, + requireToolApproval, + }); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + if (msg.includes("Invalid tool name(s) for Anthropic")) { + throw new WebRouteError(400, ErrorCode.VALIDATION_ERROR, msg); + } + throw error; + } + + const { + allTools, + enhancedSystemPrompt, + resolvedTemperature, + scrubMessages, + } = prepared; + + const modelMessages = await convertToModelMessages(messages); + return handleMCPJamFreeChatModel({ + messages: scrubMessages(modelMessages as ModelMessage[]), + modelId: String(modelDefinition.id), + systemPrompt: enhancedSystemPrompt, + temperature: resolvedTemperature, + tools: allTools as ToolSet, + authHeader: c.req.header("authorization"), + mcpClientManager: manager, + selectedServers, + requireToolApproval, + onStreamComplete: () => manager.disconnectAllServers(), + }); + } catch (error) { + await manager.disconnectAllServers(); + throw error; + } + } + + // ── Authenticated path: Convex authorization ────────────────── const hostedBody = parseWithSchema(hostedChatSchema, rawBody); const body = rawBody as unknown as ChatV2Request & { workspaceId: string; diff --git a/mcpjam-inspector/server/routes/web/guest-session.ts b/mcpjam-inspector/server/routes/web/guest-session.ts index c70a08b29..4a340303e 100644 --- a/mcpjam-inspector/server/routes/web/guest-session.ts +++ b/mcpjam-inspector/server/routes/web/guest-session.ts @@ -1,5 +1,9 @@ import { Hono } from "hono"; import { issueGuestToken } from "../../services/guest-token.js"; +import { + fetchRemoteGuestSession, + shouldUseLocalGuestSigning, +} from "../../utils/guest-session-source.js"; import { ErrorCode } from "./errors.js"; const guestSession = new Hono(); @@ -30,7 +34,9 @@ function getClientIp(c: any): string { /** * POST /api/web/guest-session * - * Issues a new guest bearer token for unauthenticated visitors. + * Returns a guest bearer token for unauthenticated visitors. + * By default the token is issued here using the local signer; the hosted + * guest-session endpoint remains available as an explicit opt-in. * Rate limited to 10 requests per minute per IP. */ guestSession.post("/", async (c) => { @@ -60,6 +66,21 @@ guestSession.post("/", async (c) => { ipWindows.set(ip, { count: 1, windowStart: now }); } + if (!shouldUseLocalGuestSigning()) { + const session = await fetchRemoteGuestSession(); + if (!session) { + return c.json( + { + code: ErrorCode.INTERNAL_ERROR, + message: + "Unable to obtain a guest session right now. Please try again.", + }, + 503, + ); + } + return c.json(session); + } + const { guestId, token, expiresAt } = issueGuestToken(); return c.json({ guestId, token, expiresAt }); }); diff --git a/mcpjam-inspector/server/routes/web/index.ts b/mcpjam-inspector/server/routes/web/index.ts index 08ab89d2b..42c8be391 100644 --- a/mcpjam-inspector/server/routes/web/index.ts +++ b/mcpjam-inspector/server/routes/web/index.ts @@ -12,6 +12,7 @@ import oauthWeb from "./oauth.js"; import xrayPayload from "./xray-payload.js"; import exporter from "./export.js"; import guestSession from "./guest-session.js"; +import { getGuestJwks } from "../../services/guest-token.js"; const web = new Hono(); @@ -20,6 +21,7 @@ web.use("/servers/*", bearerAuthMiddleware, guestRateLimitMiddleware); web.use("/tools/*", bearerAuthMiddleware, guestRateLimitMiddleware); web.use("/resources/*", bearerAuthMiddleware, guestRateLimitMiddleware); web.use("/prompts/*", bearerAuthMiddleware, guestRateLimitMiddleware); +web.use("/chat-v2/*", bearerAuthMiddleware, guestRateLimitMiddleware); web.route("/servers", servers); web.route("/tools", tools); @@ -32,6 +34,14 @@ web.route("/oauth", oauthWeb); web.route("/xray-payload", xrayPayload); web.route("/guest-session", guestSession); +// Guest JWT JWKS endpoint — public, cacheable, no auth required. +// Convex fetches this to verify guest JWTs. +// Placed under /api/web/ so the SPA static file serving doesn't intercept it. +web.get("/guest-jwks", (c) => { + c.header("Cache-Control", "public, max-age=3600"); + return c.json(getGuestJwks()); +}); + web.onError((error, c) => { const routeError = mapRuntimeError(error); return webError(c, routeError.status, routeError.code, routeError.message); diff --git a/mcpjam-inspector/server/services/__tests__/guest-token.test.ts b/mcpjam-inspector/server/services/__tests__/guest-token.test.ts index 7e59a13d7..16ac6f289 100644 --- a/mcpjam-inspector/server/services/__tests__/guest-token.test.ts +++ b/mcpjam-inspector/server/services/__tests__/guest-token.test.ts @@ -7,6 +7,9 @@ */ import { generateKeyPairSync } from "crypto"; +import { existsSync, mkdtempSync, rmSync } from "fs"; +import path from "path"; +import os from "os"; import { describe, it, expect, beforeEach, vi, afterEach } from "vitest"; import { initGuestTokenSecret, @@ -19,7 +22,9 @@ import { logger } from "../../utils/logger.js"; const ORIGINAL_GUEST_JWT_PRIVATE_KEY = process.env.GUEST_JWT_PRIVATE_KEY; const ORIGINAL_GUEST_JWT_PUBLIC_KEY = process.env.GUEST_JWT_PUBLIC_KEY; +const ORIGINAL_GUEST_JWT_KEY_DIR = process.env.GUEST_JWT_KEY_DIR; const ORIGINAL_NODE_ENV = process.env.NODE_ENV; +let testGuestKeyDir: string; function restoreEnv() { if (ORIGINAL_GUEST_JWT_PRIVATE_KEY === undefined) { @@ -34,6 +39,12 @@ function restoreEnv() { process.env.GUEST_JWT_PUBLIC_KEY = ORIGINAL_GUEST_JWT_PUBLIC_KEY; } + if (ORIGINAL_GUEST_JWT_KEY_DIR === undefined) { + delete process.env.GUEST_JWT_KEY_DIR; + } else { + process.env.GUEST_JWT_KEY_DIR = ORIGINAL_GUEST_JWT_KEY_DIR; + } + if (ORIGINAL_NODE_ENV === undefined) { delete process.env.NODE_ENV; } else { @@ -44,6 +55,8 @@ function restoreEnv() { describe("guest-token service", () => { beforeEach(() => { restoreEnv(); + testGuestKeyDir = mkdtempSync(path.join(os.tmpdir(), "guest-token-test-")); + process.env.GUEST_JWT_KEY_DIR = testGuestKeyDir; delete process.env.GUEST_JWT_PRIVATE_KEY; delete process.env.GUEST_JWT_PUBLIC_KEY; initGuestTokenSecret(); @@ -51,11 +64,12 @@ describe("guest-token service", () => { afterEach(() => { restoreEnv(); + rmSync(testGuestKeyDir, { recursive: true, force: true }); vi.restoreAllMocks(); }); describe("initGuestTokenSecret", () => { - it("generates an ephemeral key pair when env vars are not set", () => { + it("creates a stable local dev key pair when env vars are not set", () => { delete process.env.GUEST_JWT_PRIVATE_KEY; delete process.env.GUEST_JWT_PUBLIC_KEY; initGuestTokenSecret(); @@ -63,18 +77,38 @@ describe("guest-token service", () => { const { token } = issueGuestToken(); const result = validateGuestToken(token); expect(result.valid).toBe(true); + expect( + existsSync(path.join(testGuestKeyDir, "guest-jwt-private.pem")), + ).toBe(true); + expect( + existsSync(path.join(testGuestKeyDir, "guest-jwt-public.pem")), + ).toBe(true); }); - it("tokens from different key pairs are incompatible", () => { + it("tokens from different local key dirs are incompatible", () => { initGuestTokenSecret(); const { token: token1 } = issueGuestToken(); - // Re-initialize with a new ephemeral key pair + // Re-initialize with a different persisted key pair + rmSync(testGuestKeyDir, { recursive: true, force: true }); + testGuestKeyDir = mkdtempSync( + path.join(os.tmpdir(), "guest-token-test-"), + ); + process.env.GUEST_JWT_KEY_DIR = testGuestKeyDir; initGuestTokenSecret(); const result = validateGuestToken(token1); expect(result.valid).toBe(false); }); + it("keeps tokens valid across reinitialization when dev keys are persisted", () => { + initGuestTokenSecret(); + const { token } = issueGuestToken(); + + initGuestTokenSecret(); + const result = validateGuestToken(token); + expect(result.valid).toBe(true); + }); + it("keeps tokens valid across reinitialization when env keys are stable", () => { const pair = generateKeyPairSync("rsa", { modulusLength: 2048 }); process.env.GUEST_JWT_PRIVATE_KEY = pair.privateKey.export({ diff --git a/mcpjam-inspector/server/services/guest-token.ts b/mcpjam-inspector/server/services/guest-token.ts index 4b67f6dc5..21d112368 100644 --- a/mcpjam-inspector/server/services/guest-token.ts +++ b/mcpjam-inspector/server/services/guest-token.ts @@ -10,6 +10,7 @@ import { generateKeyPairSync, + createHash, createSign, createVerify, createPrivateKey, @@ -17,15 +18,97 @@ import { randomUUID, type KeyObject, } from "crypto"; +import { + chmodSync, + existsSync, + mkdirSync, + readFileSync, + writeFileSync, +} from "fs"; +import os from "os"; +import path from "path"; +import { shouldUseLocalGuestSigning } from "../utils/guest-session-source.js"; import { logger } from "../utils/logger.js"; const GUEST_TOKEN_TTL_S = 24 * 60 * 60; // 24 hours in seconds const GUEST_ISSUER = "https://api.mcpjam.com/guest"; const KID = "guest-1"; +const DEFAULT_HOSTED_GUEST_JWKS_URL = + "https://app.mcpjam.com/api/web/guest-jwks"; +const HOSTED_GUEST_JWKS_CACHE_MS = 5 * 60 * 1000; let privateKey: KeyObject; let publicKey: KeyObject; let jwks: { keys: JsonWebKey[] }; +let hostedGuestPublicKeysCache: + | { + fetchedAt: number; + keysByKid: Map; + fallbackKey: KeyObject | null; + } + | undefined; + +function getLocalGuestKeyDir(): string { + return process.env.GUEST_JWT_KEY_DIR || path.join(os.homedir(), ".mcpjam"); +} + +function getLocalGuestKeyPaths(): { privatePath: string; publicPath: string } { + const dir = getLocalGuestKeyDir(); + return { + privatePath: path.join(dir, "guest-jwt-private.pem"), + publicPath: path.join(dir, "guest-jwt-public.pem"), + }; +} + +function setKeyPair(nextPrivateKey: KeyObject, nextPublicKey: KeyObject): void { + privateKey = nextPrivateKey; + publicKey = nextPublicKey; +} + +function createAndPersistLocalDevKeyPair(): void { + const { privatePath, publicPath } = getLocalGuestKeyPaths(); + const dir = path.dirname(privatePath); + mkdirSync(dir, { recursive: true }); + + const pair = generateKeyPairSync("rsa", { modulusLength: 2048 }); + const privatePem = pair.privateKey.export({ type: "pkcs8", format: "pem" }); + const publicPem = pair.publicKey.export({ type: "spki", format: "pem" }); + + writeFileSync(privatePath, privatePem); + writeFileSync(publicPath, publicPem); + + try { + chmodSync(privatePath, 0o600); + chmodSync(publicPath, 0o644); + } catch { + // Best effort. Some platforms/filesystems do not support chmod semantics. + } + + setKeyPair(createPrivateKey(privatePem), createPublicKey(publicPem)); + logger.info(`Guest JWT: created local dev signing key pair at ${dir}`); +} + +function loadPersistedLocalDevKeyPair(): boolean { + const { privatePath, publicPath } = getLocalGuestKeyPaths(); + if (!existsSync(privatePath) || !existsSync(publicPath)) { + return false; + } + + try { + const privatePem = readFileSync(privatePath, "utf-8"); + const publicPem = readFileSync(publicPath, "utf-8"); + setKeyPair(createPrivateKey(privatePem), createPublicKey(publicPem)); + logger.info( + `Guest JWT: using local dev signing key pair from ${path.dirname(privatePath)}`, + ); + return true; + } catch (error) { + logger.warn( + `Guest JWT: failed to load local dev key pair, regenerating (${error instanceof Error ? error.message : String(error)})`, + ); + return false; + } +} function warnAboutEphemeralKeys(reason: "missing" | "invalid"): void { if (process.env.NODE_ENV !== "production") { @@ -56,9 +139,191 @@ function base64urlDecode(str: string): Buffer { return Buffer.from(str, "base64url"); } +function getHostedGuestJwksUrl(): string { + return process.env.MCPJAM_GUEST_JWKS_URL || DEFAULT_HOSTED_GUEST_JWKS_URL; +} + +function verifyGuestTokenSignature( + signingInput: string, + signature: string, + verificationKey: KeyObject, +): { valid: boolean; reason?: string } { + try { + const verifier = createVerify("RSA-SHA256"); + verifier.update(signingInput); + if (!verifier.verify(verificationKey, signature, "base64url")) { + return { valid: false, reason: "signature_invalid" }; + } + return { valid: true }; + } catch { + return { valid: false, reason: "signature_error" }; + } +} + +function parseGuestToken(token: string): + | { + parsed: { + header: Record; + payload: { iss: string; sub: string; exp: number }; + signingInput: string; + signature: string; + }; + } + | { + reason: string; + } { + if (!token || typeof token !== "string") { + return { reason: "missing_token" }; + } + + const parts = token.split("."); + if (parts.length !== 3) { + return { reason: "malformed_token" }; + } + + const [encodedHeader, encodedPayload, signature] = parts; + + try { + const header = JSON.parse( + base64urlDecode(encodedHeader).toString("utf-8"), + ) as Record | undefined; + if (!header || header.alg !== "RS256") { + return { reason: "invalid_alg" }; + } + + const payload = JSON.parse( + base64urlDecode(encodedPayload).toString("utf-8"), + ) as Partial<{ iss: string; sub: string; exp: number }> | undefined; + + if (!payload || payload.iss !== GUEST_ISSUER) { + return { reason: "issuer_mismatch" }; + } + + if (typeof payload.sub !== "string" || typeof payload.exp !== "number") { + return { reason: "missing_claims" }; + } + + const nowSeconds = Math.floor(Date.now() / 1000); + if (nowSeconds >= payload.exp) { + return { reason: "expired" }; + } + + return { + parsed: { + header, + payload: { + iss: payload.iss, + sub: payload.sub, + exp: payload.exp, + }, + signingInput: `${encodedHeader}.${encodedPayload}`, + signature, + }, + }; + } catch { + return { reason: "invalid_payload" }; + } +} + +async function fetchAndCacheHostedGuestKeys( + kid: string | undefined, +): Promise { + const now = Date.now(); + try { + const response = await fetch(getHostedGuestJwksUrl(), { + method: "GET", + headers: { Accept: "application/json" }, + }); + + if (!response.ok) { + logger.warn( + `[guest-auth] Failed to fetch hosted guest JWKS: ${response.status} ${response.statusText}`, + ); + // Keep serving stale cache on fetch failure + return resolveKeyFromCache(kid); + } + + const body = (await response.json()) as { + keys?: Array; + }; + const keys = Array.isArray(body.keys) ? body.keys : []; + const keysByKid = new Map(); + let fallbackKey: KeyObject | null = null; + + for (const jwk of keys) { + try { + const nextKey = createPublicKey({ + key: jwk, + format: "jwk", + }); + if (!fallbackKey) { + fallbackKey = nextKey; + } + if (typeof jwk.kid === "string") { + keysByKid.set(jwk.kid, nextKey); + } + } catch { + // Skip malformed keys. + } + } + + hostedGuestPublicKeysCache = { + fetchedAt: now, + keysByKid, + fallbackKey, + }; + + if (kid && keysByKid.has(kid)) { + return keysByKid.get(kid) ?? null; + } + return fallbackKey; + } catch (error) { + logger.warn( + `[guest-auth] Failed to fetch hosted guest JWKS: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + // Keep serving stale cache on network failure + return resolveKeyFromCache(kid); + } +} + +function resolveKeyFromCache(kid: string | undefined): KeyObject | null { + if (!hostedGuestPublicKeysCache) return null; + if (kid && hostedGuestPublicKeysCache.keysByKid.has(kid)) { + return hostedGuestPublicKeysCache.keysByKid.get(kid) ?? null; + } + return hostedGuestPublicKeysCache.fallbackKey; +} + +async function getHostedGuestVerificationKey( + kid: string | undefined, +): Promise { + const now = Date.now(); + const cacheIsValid = + hostedGuestPublicKeysCache && + now - hostedGuestPublicKeysCache.fetchedAt < HOSTED_GUEST_JWKS_CACHE_MS; + + if (cacheIsValid) { + // Cache hit with matching kid — use it + if (kid && hostedGuestPublicKeysCache!.keysByKid.has(kid)) { + return hostedGuestPublicKeysCache!.keysByKid.get(kid) ?? null; + } + // Cache hit but kid not found — try a refresh (key rotation) + if (kid) { + return fetchAndCacheHostedGuestKeys(kid); + } + return hostedGuestPublicKeysCache!.fallbackKey; + } + + // Cache expired or empty — fetch fresh keys + return fetchAndCacheHostedGuestKeys(kid); +} + /** * Initialize the RS256 key pair for guest JWTs. - * Reads PEM from GUEST_JWT_PRIVATE_KEY env var or generates an ephemeral pair. + * Reads PEM from GUEST_JWT_PRIVATE_KEY env var, or in local dev loads/generates + * a stable key pair under ~/.mcpjam, or finally falls back to ephemeral keys. * Must be called once at server startup. */ export function initGuestTokenSecret(): void { @@ -67,15 +332,22 @@ export function initGuestTokenSecret(): void { if (envPrivate && envPublic) { try { - privateKey = createPrivateKey(envPrivate); - publicKey = createPublicKey(envPublic); + setKeyPair(createPrivateKey(envPrivate), createPublicKey(envPublic)); logger.info("Guest JWT: using keys from environment"); } catch (e) { - logger.warn( - "Guest JWT: failed to parse env key pair, generating ephemeral keys", - ); - warnAboutEphemeralKeys("invalid"); - generateEphemeralKeyPair(); + logger.warn("Guest JWT: failed to parse env key pair"); + if (process.env.NODE_ENV !== "production") { + if (!loadPersistedLocalDevKeyPair()) { + createAndPersistLocalDevKeyPair(); + } + } else { + warnAboutEphemeralKeys("invalid"); + generateEphemeralKeyPair(); + } + } + } else if (process.env.NODE_ENV !== "production") { + if (!loadPersistedLocalDevKeyPair()) { + createAndPersistLocalDevKeyPair(); } } else { warnAboutEphemeralKeys("missing"); @@ -98,13 +370,12 @@ export function initGuestTokenSecret(): void { function generateEphemeralKeyPair(): void { const pair = generateKeyPairSync("rsa", { modulusLength: 2048 }); - privateKey = pair.privateKey; - publicKey = pair.publicKey; + setKeyPair(pair.privateKey, pair.publicKey); } /** * Returns the JWKS document for the guest issuer. - * Serve this at /guest/jwks (or /.well-known/jwks.json). + * Serve this at /api/web/guest-jwks. */ export function getGuestJwks(): { keys: JsonWebKey[] } { if (!jwks) { @@ -120,6 +391,32 @@ export function getGuestIssuer(): string { return GUEST_ISSUER; } +/** + * Returns the guest public key as a PEM string. + * Used by the dev startup script to push the current key to Convex. + */ +export function getGuestPublicKeyPem(): string { + if (!publicKey) { + throw new Error( + "Guest JWT keys not initialized. Call initGuestTokenSecret() first.", + ); + } + return publicKey.export({ type: "spki", format: "pem" }) as string; +} + +/** + * Returns a short, non-reversible fingerprint for log correlation. + * Never log the raw guest token itself. + */ +export function getGuestTokenFingerprint( + token: string | null | undefined, +): string { + if (!token || typeof token !== "string") { + return "none"; + } + return createHash("sha256").update(token).digest("hex").slice(0, 12); +} + /** * Issue a new guest JWT with a unique guestId as `sub`. * Returns the signed JWT string and metadata. @@ -160,6 +457,17 @@ export function issueGuestToken(): { export function validateGuestToken(token: string): { valid: boolean; guestId?: string; +} { + const result = validateGuestTokenDetailed(token); + return result.valid + ? { valid: true, guestId: result.guestId } + : { valid: false }; +} + +export function validateGuestTokenDetailed(token: string): { + valid: boolean; + guestId?: string; + reason?: string; } { if (!publicKey) { throw new Error( @@ -167,54 +475,69 @@ export function validateGuestToken(token: string): { ); } - if (!token || typeof token !== "string") { - return { valid: false }; + const parsed = parseGuestToken(token); + if (!("parsed" in parsed)) { + return { valid: false, reason: parsed.reason }; } - const parts = token.split("."); - if (parts.length !== 3) { - return { valid: false }; + const signatureResult = verifyGuestTokenSignature( + parsed.parsed.signingInput, + parsed.parsed.signature, + publicKey, + ); + if (!signatureResult.valid) { + return { valid: false, reason: signatureResult.reason }; } - const [encodedHeader, encodedPayload, signature] = parts; + return { valid: true, guestId: parsed.parsed.payload.sub }; +} - // Verify RS256 signature - try { - const verifier = createVerify("RSA-SHA256"); - verifier.update(`${encodedHeader}.${encodedPayload}`); - if (!verifier.verify(publicKey, signature, "base64url")) { - return { valid: false }; - } - } catch { - return { valid: false }; +export async function validateGuestTokenDetailedAsync(token: string): Promise<{ + valid: boolean; + guestId?: string; + reason?: string; +}> { + if (!publicKey) { + throw new Error( + "Guest JWT keys not initialized. Call initGuestTokenSecret() first.", + ); } - // Decode and validate claims - try { - const header = JSON.parse(base64urlDecode(encodedHeader).toString("utf-8")); - if (header.alg !== "RS256") { - return { valid: false }; - } - - const payload = JSON.parse( - base64urlDecode(encodedPayload).toString("utf-8"), - ); + const parsed = parseGuestToken(token); + if (!("parsed" in parsed)) { + return { valid: false, reason: parsed.reason }; + } - if (payload.iss !== GUEST_ISSUER) { - return { valid: false }; - } + const localSignatureResult = verifyGuestTokenSignature( + parsed.parsed.signingInput, + parsed.parsed.signature, + publicKey, + ); + if (localSignatureResult.valid) { + return { valid: true, guestId: parsed.parsed.payload.sub }; + } - if (!payload.sub || !payload.exp) { - return { valid: false }; - } + if (shouldUseLocalGuestSigning()) { + return { valid: false, reason: localSignatureResult.reason }; + } - const nowSeconds = Math.floor(Date.now() / 1000); - if (nowSeconds > payload.exp) { - return { valid: false }; - } + const hostedKey = await getHostedGuestVerificationKey( + typeof parsed.parsed.header.kid === "string" + ? parsed.parsed.header.kid + : undefined, + ); + if (!hostedKey) { + return { valid: false, reason: "hosted_key_unavailable" }; + } - return { valid: true, guestId: payload.sub }; - } catch { - return { valid: false }; + const hostedSignatureResult = verifyGuestTokenSignature( + parsed.parsed.signingInput, + parsed.parsed.signature, + hostedKey, + ); + if (!hostedSignatureResult.valid) { + return { valid: false, reason: hostedSignatureResult.reason }; } + + return { valid: true, guestId: parsed.parsed.payload.sub }; } diff --git a/mcpjam-inspector/server/utils/__tests__/guest-auth.test.ts b/mcpjam-inspector/server/utils/__tests__/guest-auth.test.ts new file mode 100644 index 000000000..54b09eb3f --- /dev/null +++ b/mcpjam-inspector/server/utils/__tests__/guest-auth.test.ts @@ -0,0 +1,155 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mockIssueGuestToken = vi.fn(); +const mockLogger = { + info: vi.fn(), + warn: vi.fn(), +}; + +vi.mock("../logger", () => ({ + logger: mockLogger, +})); + +vi.mock("../../services/guest-token.js", () => ({ + issueGuestToken: mockIssueGuestToken, +})); + +describe("guest-auth", () => { + const originalFetch = global.fetch; + const originalNodeEnv = process.env.NODE_ENV; + const originalLocalSigning = process.env.MCPJAM_USE_LOCAL_GUEST_SIGNING; + const originalPrivateKey = process.env.GUEST_JWT_PRIVATE_KEY; + const originalPublicKey = process.env.GUEST_JWT_PUBLIC_KEY; + const originalRemoteUrl = process.env.MCPJAM_GUEST_SESSION_URL; + + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + delete process.env.MCPJAM_USE_LOCAL_GUEST_SIGNING; + delete process.env.GUEST_JWT_PRIVATE_KEY; + delete process.env.GUEST_JWT_PUBLIC_KEY; + delete process.env.MCPJAM_GUEST_SESSION_URL; + global.fetch = vi.fn(); + }); + + afterEach(() => { + process.env.NODE_ENV = originalNodeEnv; + if (originalLocalSigning === undefined) { + delete process.env.MCPJAM_USE_LOCAL_GUEST_SIGNING; + } else { + process.env.MCPJAM_USE_LOCAL_GUEST_SIGNING = originalLocalSigning; + } + if (originalPrivateKey === undefined) { + delete process.env.GUEST_JWT_PRIVATE_KEY; + } else { + process.env.GUEST_JWT_PRIVATE_KEY = originalPrivateKey; + } + if (originalPublicKey === undefined) { + delete process.env.GUEST_JWT_PUBLIC_KEY; + } else { + process.env.GUEST_JWT_PUBLIC_KEY = originalPublicKey; + } + if (originalRemoteUrl === undefined) { + delete process.env.MCPJAM_GUEST_SESSION_URL; + } else { + process.env.MCPJAM_GUEST_SESSION_URL = originalRemoteUrl; + } + global.fetch = originalFetch; + }); + + it("uses local guest signing in development by default", async () => { + process.env.NODE_ENV = "development"; + mockIssueGuestToken.mockReturnValue({ + token: "local-guest-token", + expiresAt: Date.now() + 60_000, + }); + + const { getProductionGuestAuthHeader } = await import("../guest-auth.js"); + const header = await getProductionGuestAuthHeader(); + + expect(header).toBe("Bearer local-guest-token"); + expect(mockIssueGuestToken).toHaveBeenCalledTimes(1); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it("fetches a hosted guest session in development when local signing is explicitly disabled", async () => { + process.env.NODE_ENV = "development"; + process.env.MCPJAM_USE_LOCAL_GUEST_SIGNING = "false"; + process.env.MCPJAM_GUEST_SESSION_URL = + "https://app.mcpjam.com/api/web/guest-session"; + vi.mocked(global.fetch).mockResolvedValue( + new Response( + JSON.stringify({ + guestId: "guest-dev", + token: "remote-dev-token", + expiresAt: Date.now() + 60_000, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + }, + ), + ); + + const { getProductionGuestAuthHeader } = await import("../guest-auth.js"); + const header = await getProductionGuestAuthHeader(); + + expect(header).toBe("Bearer remote-dev-token"); + expect(global.fetch).toHaveBeenCalledWith( + "https://app.mcpjam.com/api/web/guest-session", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + }, + ); + expect(mockIssueGuestToken).not.toHaveBeenCalled(); + }); + + it("uses local guest signing in production by default", async () => { + process.env.NODE_ENV = "production"; + mockIssueGuestToken.mockReturnValue({ + token: "prod-local-guest-token", + expiresAt: Date.now() + 60_000, + }); + + const { getProductionGuestAuthHeader } = await import("../guest-auth.js"); + const header = await getProductionGuestAuthHeader(); + + expect(header).toBe("Bearer prod-local-guest-token"); + expect(mockIssueGuestToken).toHaveBeenCalledTimes(1); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it("fetches a hosted guest session in production when local signing is explicitly disabled", async () => { + process.env.NODE_ENV = "production"; + process.env.MCPJAM_USE_LOCAL_GUEST_SIGNING = "false"; + process.env.MCPJAM_GUEST_SESSION_URL = + "https://app.mcpjam.com/api/web/guest-session"; + vi.mocked(global.fetch).mockResolvedValue( + new Response( + JSON.stringify({ + guestId: "guest-1", + token: "remote-guest-token", + expiresAt: Date.now() + 60_000, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + }, + ), + ); + + const { getProductionGuestAuthHeader } = await import("../guest-auth.js"); + const header = await getProductionGuestAuthHeader(); + + expect(header).toBe("Bearer remote-guest-token"); + expect(global.fetch).toHaveBeenCalledWith( + "https://app.mcpjam.com/api/web/guest-session", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + }, + ); + expect(mockIssueGuestToken).not.toHaveBeenCalled(); + }); +}); diff --git a/mcpjam-inspector/server/utils/__tests__/mcpjam-stream-handler.test.ts b/mcpjam-inspector/server/utils/__tests__/mcpjam-stream-handler.test.ts index fa6a02577..e988dc917 100644 --- a/mcpjam-inspector/server/utils/__tests__/mcpjam-stream-handler.test.ts +++ b/mcpjam-inspector/server/utils/__tests__/mcpjam-stream-handler.test.ts @@ -58,6 +58,7 @@ vi.mock("../mcpjam-tool-helpers", () => ({ vi.mock("../logger", () => ({ logger: { + info: vi.fn(), error: vi.fn(), }, })); diff --git a/mcpjam-inspector/server/utils/convex-guest-auth-sync.ts b/mcpjam-inspector/server/utils/convex-guest-auth-sync.ts new file mode 100644 index 000000000..7821bc428 --- /dev/null +++ b/mcpjam-inspector/server/utils/convex-guest-auth-sync.ts @@ -0,0 +1,89 @@ +import { getGuestPublicKeyPem } from "../services/guest-token.js"; +import { shouldUseLocalGuestSigning } from "./guest-session-source.js"; +import { logger } from "./logger.js"; + +let syncStarted = false; + +/** + * When local guest signing is enabled in dev, push the guest public key and + * Convex guest JWKS override so Convex can verify JWTs signed by this local + * inspector process. + */ +export function syncGuestAuthConfigToConvex(): void { + if ( + process.env.NODE_ENV === "production" || + !shouldUseLocalGuestSigning() || + syncStarted + ) { + return; + } + + syncStarted = true; + + void (async () => { + try { + const pem = getGuestPublicKeyPem(); + const convexUrl = process.env.CONVEX_URL; + if (!convexUrl) return; + + const match = convexUrl.match(/https:\/\/([^.]+)\.convex\.cloud/); + if (!match) return; + + const deploymentName = match[1]; + const guestJwksUrl = new URL( + "/guest/jwks", + process.env.CONVEX_HTTP_URL ?? `https://${deploymentName}.convex.site`, + ).toString(); + + const npxCommand = process.platform === "win32" ? "npx.cmd" : "npx"; + const convexEnv = { + ...process.env, + CONVEX_DEPLOYMENT: `dev:${deploymentName}`, + }; + + const { execFile } = await import("child_process"); + + const setConvexEnv = async (name: string, value: string) => { + await new Promise((resolve, reject) => { + execFile( + npxCommand, + ["convex", "env", "set", name, "--", value], + { + env: convexEnv, + timeout: 15_000, + maxBuffer: 1024 * 1024, + windowsHide: true, + }, + (error, stdout, stderr) => { + if (!error) { + resolve(); + return; + } + + reject( + new Error( + stderr?.trim() || + stdout?.trim() || + error.message || + `convex env set ${name} failed`, + ), + ); + }, + ); + }); + }; + + await setConvexEnv("GUEST_JWT_PUBLIC_KEY", pem); + await setConvexEnv("GUEST_JWKS_URL", guestJwksUrl); + logger.info( + `[guest-auth] Pushed guest key + JWKS URL to Convex (${deploymentName})`, + ); + } catch (err) { + logger.warn( + `[guest-auth] Failed to push guest auth config to Convex: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } + })(); +} diff --git a/mcpjam-inspector/server/utils/guest-auth.ts b/mcpjam-inspector/server/utils/guest-auth.ts new file mode 100644 index 000000000..56ad37f4d --- /dev/null +++ b/mcpjam-inspector/server/utils/guest-auth.ts @@ -0,0 +1,55 @@ +/** + * Guest Auth Header Provider + * + * Provides a valid guest JWT for MCPJam model requests from unauthenticated + * users in non-hosted mode (npx/electron/docker). + * + * By default, local runtimes sign guest tokens locally so they keep working + * against their own Convex dev/sandbox setup. Hosted guest-session fetching + * remains available as an explicit opt-in. + */ + +import { issueGuestToken } from "../services/guest-token.js"; +import { logger } from "./logger.js"; +import { + fetchRemoteGuestSession, + shouldUseLocalGuestSigning, +} from "./guest-session-source.js"; + +/** Buffer before expiry to trigger a refresh (5 minutes in ms). */ +const REFRESH_BUFFER_MS = 5 * 60 * 1000; + +let cachedToken: { token: string; expiresAt: number } | null = null; + +/** + * Returns a Bearer authorization header for unauthenticated MCPJam model calls. + * + * By default, local runtimes sign guest tokens locally so they keep working + * against their own Convex dev/sandbox setup. Hosted guest-session fetching + * remains available as an explicit opt-in. + */ +export async function getProductionGuestAuthHeader(): Promise { + if (cachedToken && cachedToken.expiresAt > Date.now() + REFRESH_BUFFER_MS) { + return `Bearer ${cachedToken.token}`; + } + + if (!shouldUseLocalGuestSigning()) { + const session = await fetchRemoteGuestSession(); + if (!session) { + return null; + } + cachedToken = { token: session.token, expiresAt: session.expiresAt }; + return `Bearer ${session.token}`; + } + + try { + const { token, expiresAt } = issueGuestToken(); + cachedToken = { token, expiresAt }; + logger.info("[guest-auth] Issued guest token for MCPJam model request"); + return `Bearer ${token}`; + } catch (error) { + const errMsg = error instanceof Error ? error.message : String(error); + logger.warn(`[guest-auth] Failed to issue guest token: ${errMsg}`); + return null; + } +} diff --git a/mcpjam-inspector/server/utils/guest-session-source.ts b/mcpjam-inspector/server/utils/guest-session-source.ts new file mode 100644 index 000000000..ba51c2287 --- /dev/null +++ b/mcpjam-inspector/server/utils/guest-session-source.ts @@ -0,0 +1,68 @@ +import { logger } from "./logger.js"; + +const DEFAULT_REMOTE_GUEST_SESSION_URL = + "https://app.mcpjam.com/api/web/guest-session"; + +export type RemoteGuestSession = { + guestId?: string; + token: string; + expiresAt: number; +}; + +export function shouldUseLocalGuestSigning(): boolean { + if (process.env.MCPJAM_USE_LOCAL_GUEST_SIGNING === "false") { + return false; + } + + return true; +} + +export function getRemoteGuestSessionUrl(): string { + return ( + process.env.MCPJAM_GUEST_SESSION_URL || DEFAULT_REMOTE_GUEST_SESSION_URL + ); +} + +export async function fetchRemoteGuestSession(): Promise { + try { + const response = await fetch(getRemoteGuestSessionUrl(), { + method: "POST", + headers: { "Content-Type": "application/json" }, + }); + + if (!response.ok) { + logger.warn( + `[guest-auth] Failed to fetch MCPJam guest session: ${response.status} ${response.statusText}`, + ); + return null; + } + + const session = (await response.json()) as { + guestId?: unknown; + token?: unknown; + expiresAt?: unknown; + }; + + if ( + typeof session.token !== "string" || + typeof session.expiresAt !== "number" + ) { + logger.warn( + "[guest-auth] MCPJam guest session response was missing token or expiresAt", + ); + return null; + } + + logger.info("[guest-auth] Fetched guest token from MCPJam guest session"); + return { + guestId: + typeof session.guestId === "string" ? session.guestId : undefined, + token: session.token, + expiresAt: session.expiresAt, + }; + } catch (error) { + const errMsg = error instanceof Error ? error.message : String(error); + logger.warn(`[guest-auth] Failed to fetch MCPJam guest session: ${errMsg}`); + return null; + } +} diff --git a/mcpjam-inspector/shared/types.ts b/mcpjam-inspector/shared/types.ts index 6a73025c3..d8efed169 100644 --- a/mcpjam-inspector/shared/types.ts +++ b/mcpjam-inspector/shared/types.ts @@ -135,6 +135,19 @@ export const isMCPJamProvidedModel = (modelId: string): boolean => { return MCPJAM_PROVIDED_MODEL_IDS.includes(modelId); }; +// Models available to guest (unauthenticated) users. +// Duplicated from backend GUEST_ALLOWED_MODELS — kept in sync manually +// since the repos are separate. Backend is the authoritative enforcement. +export const GUEST_ALLOWED_MODEL_IDS: string[] = [ + "anthropic/claude-haiku-4.5", + "openai/gpt-5-mini", + "google/gemini-2.5-flash", +]; + +export const isGuestAllowedModel = (modelId: string): boolean => { + return GUEST_ALLOWED_MODEL_IDS.includes(modelId); +}; + export const isGPT5Model = (modelId: string | Model): boolean => { const id = String(modelId); // Only disable temperature for OpenAI GPT-5 models (not MCPJam provided ones)