Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions mcpjam-inspector/client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ import {
getChatboxPathTokenFromLocation,
} from "./components/hosted/ChatboxChatPage";
import { useHostedApiContext } from "./hooks/hosted/use-hosted-api-context";
import { useLocalStateMigration } from "./hooks/use-local-state-migration";
import { AppReadyProvider } from "./hooks/use-app-ready";
import { useInspectorCommandBus } from "./hooks/use-inspector-command-bus";
import { HOSTED_MODE, NON_PROD_LOCKDOWN } from "./lib/config";
Expand Down Expand Up @@ -802,6 +803,12 @@ export default function App() {
: undefined,
});
useInspectorCommandBus();
// One-time migration from legacy localStorage state to Convex. No-op in
// hosted mode and after the first successful run; safe to keep in the tree.
useLocalStateMigration({
isAuthenticated,
organizationId: activeOrganizationId,
});
Comment on lines +808 to +812
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Disable the old project migration path

When a local signed-in or guest session has legacy local projects and Convex initially returns no remote projects, this new migration hook runs in the same App mount as the existing use-project-state migration effect (client/src/hooks/use-project-state.ts:760-823). That older effect still calls projects:createProject for every unshared local project and has no awareness of MIGRATION_FLAG_KEY, while this hook also calls runLocalStateMigration from localStorage, so the first boot after this change can create each local project twice before either subscription observes the new remote rows. Gate or remove the old effect before enabling this one to avoid duplicate Convex projects/servers.

Useful? React with 👍 / 👎.

const oauthDebuggerServersRef = useRef(appState.servers);
oauthDebuggerServersRef.current = appState.servers;
const projectServersRef = useRef(projectServers);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ vi.mock("@/lib/oauth/mcp-oauth", () => ({

vi.mock("@/lib/apis/web/context", () => ({
injectHostedServerMapping: vi.fn(),
tryGetHostedServerDisplayName: vi.fn(),
tryResolveProjectServer: vi.fn(() => null),
}));

vi.mock("@/lib/session-token", () => ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ vi.mock("@/lib/oauth/mcp-oauth", () => ({

vi.mock("@/lib/apis/web/context", () => ({
injectHostedServerMapping: vi.fn(),
tryGetHostedServerDisplayName: vi.fn(),
tryResolveProjectServer: vi.fn(() => null),
}));

vi.mock("@/lib/session-token", () => ({
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { useLayoutEffect } from "react";
import { HOSTED_MODE } from "@/lib/config";
import { setHostedApiContext } from "@/lib/apis/web/context";

interface UseHostedApiContextOptions {
Expand Down Expand Up @@ -35,11 +34,6 @@ export function useHostedApiContext({
// between this effect's cleanup (which nulls the context) and its setup,
// causing "Hosted server not found" errors for shared-chat OAuth servers.
useLayoutEffect(() => {
if (!HOSTED_MODE) {
setHostedApiContext(null);
return;
}

if (!enabled) {
return;
}
Expand Down
78 changes: 78 additions & 0 deletions mcpjam-inspector/client/src/hooks/use-local-state-migration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { useEffect, useRef } from "react";
import { useMutation } from "convex/react";
import {
hasMigrationCompleted,
runLocalStateMigration,
} from "@/lib/local-state-migration";
import { HOSTED_MODE } from "@/lib/config";
import { useLogger } from "./use-logger";

interface UseLocalStateMigrationOptions {
/** True when Convex auth has resolved (signed-in user OR guest). */
isAuthenticated: boolean;
/**
* Optional org id to migrate into. Undefined defers to Convex's
* resolveProjectOrganizationId which falls back to the actor's default
* organization (provisioned by `users:ensureUser`).
*/
organizationId?: string;
}

/**
* Runs the legacy-localStorage → Convex migration exactly once per install.
*
* Skips when:
* - HOSTED_MODE (hosted users never had localStorage state)
* - The migration flag is already set
* - Convex auth hasn't resolved
* - Migration is already in flight (prevents duplicate fires across renders)
*/
export function useLocalStateMigration({
isAuthenticated,
organizationId,
}: UseLocalStateMigrationOptions): void {
const logger = useLogger("LocalStateMigration");
const inFlightRef = useRef(false);
const doneRef = useRef(false);
const createProject = useMutation("projects:createProject" as any);

useEffect(() => {
if (HOSTED_MODE) return;
if (!isAuthenticated) return;
if (doneRef.current) return;
if (inFlightRef.current) return;
if (hasMigrationCompleted()) {
doneRef.current = true;
Comment on lines +165 to +171
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Wait for organization selection before migrating

In signed-in local sessions, this effect can run as soon as Convex auth and ensureUser finish, while useOrganizationQueries is still loading and activeOrganizationId is still undefined. In that window runLocalStateMigration calls createProject with organizationId: undefined, so Convex falls back to the actor's default org, then clears the legacy keys and sets the migration flag; users with a stored/route-selected different org have their local projects migrated to the wrong organization permanently. Gate this on organization loading/selection resolution for signed-in users before acquiring the migration lease.

Useful? React with 👍 / 👎.

return;
}

inFlightRef.current = true;
runLocalStateMigration({
createProject: createProject as any,
organizationId,
logger,
})
.then((result) => {
if (result.ok) {
doneRef.current = true;
if (result.projectsMigrated > 0) {
logger.info("Local state migration completed", {
projectsMigrated: result.projectsMigrated,
});
}
} else {
logger.warn("Local state migration partially failed; will retry", {
errors: result.errors,
});
}
Comment thread
cursor[bot] marked this conversation as resolved.
})
.catch((error) => {
logger.error("Local state migration threw", {
error: error instanceof Error ? error.message : String(error),
});
})
.finally(() => {
inFlightRef.current = false;
});
}, [isAuthenticated, organizationId, createProject, logger]);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}
99 changes: 55 additions & 44 deletions mcpjam-inspector/client/src/hooks/use-server-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import { HOSTED_MODE } from "@/lib/config";
import {
injectHostedServerMapping,
tryGetHostedServerDisplayName,
tryResolveProjectServer,
} from "@/lib/apis/web/context";
import type { OAuthTestProfile } from "@/lib/oauth/profile";
import { authFetch } from "@/lib/session-token";
Expand Down Expand Up @@ -733,6 +734,18 @@ export function useServerState({
const guardedTestConnection = useCallback(
async (serverConfig: MCPServerConfig, serverName: string) => {
assertClientConfigSynced();
// Opt into the resolver path when both projectId and a Convex serverId
// are populated in the API context; otherwise fall back to legacy
// {serverConfig, serverId} so brand-new servers (not yet synced to
// Convex) keep working. The 2-arg call signature is preserved when no
// resolver context is available so existing test mocks keep matching.
const resolved = tryResolveProjectServer(serverName);
if (resolved) {
return testConnection(serverConfig, resolved.serverId, {
Comment on lines +775 to +777
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Preserve local OAuth tokens on resolver connects

When local mode has a Convex mapping, this resolver branch is also taken for OAuth servers. The callers pass a serverConfig that contains the local Authorization header from stored tokens (including the existing-token and OAuth-callback paths), but testConnection switches to a {projectId, serverId} body and drops that config; the local resolver then rejects useOAuth servers unless Convex returns oauthAccessToken. Since the local OAuth provider still saves tokens only in localStorage for !HOSTED_MODE, synced local OAuth servers will fail to connect with oauthRequired despite having valid local tokens. Keep the legacy body for local OAuth or forward/persist the token into the resolver path.

Useful? React with 👍 / 👎.

projectId: resolved.projectId,
serverName,
Comment on lines +776 to +779
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Keep local connections keyed by display name

When local mode has a populated Convex mapping (for migrated/existing synced servers), this connects the MCP client manager under the Convex serverId, while the local UI still calls /api/mcp/tools/* with the display name via listTools({ serverId: serverName }) in components like ToolsTab. Since the local tools routes only look up connected clients by the provided id/name, the connect succeeds but listing/executing tools for that server reports it is not connected. The resolver request can still carry the Convex id, but the local manager key needs to remain the UI server name or all subsequent local API calls must be translated too.

Useful? React with 👍 / 👎.

});
}
return testConnection(serverConfig, serverName);
},
[assertClientConfigSynced]
Expand All @@ -741,6 +754,13 @@ export function useServerState({
const guardedReconnectServer = useCallback(
async (serverName: string, serverConfig: MCPServerConfig) => {
assertClientConfigSynced();
const resolved = tryResolveProjectServer(serverName);
if (resolved) {
return reconnectServer(resolved.serverId, serverConfig, {
projectId: resolved.projectId,
serverName,
});
}
return reconnectServer(serverName, serverConfig);
},
[assertClientConfigSynced]
Expand Down Expand Up @@ -1683,54 +1703,45 @@ export function useServerState({
oauthFlowProfile: formOAuthProfile,
hasClientSecret: nextHasClientSecret,
};
if (HOSTED_MODE) {
let syncErr: unknown;
try {
const serverId = await syncServerToConvex(
formData.name,
serverEntryForSave,
clientSecretSyncOptions
);
if (serverId) {
hostedServerId = serverId;
injectHostedServerMapping(formData.name, serverId);
}
} catch (err) {
syncErr = err;
logger.warn("Sync to Convex failed (pre-connection)", {
serverName: formData.name,
err,
});
}
// OAuth in hosted mode requires a Convex serverId to bind credentials
// to. Without it, prepareHostedProjectOAuthRedirect is a no-op and the
// local fallback can't persist tokens either (mcp-oauth.saveTokens is
// gated on !HOSTED_MODE), so the OAuth dance would complete without a
// durable credential. Fail loudly instead.
if (formData.useOAuth && !hostedServerId) {
const errorMessage =
syncErr instanceof Error
? `Could not save the hosted server before starting OAuth: ${syncErr.message}`
: "Could not save the hosted server before starting OAuth. Please try again.";
dispatch({
type: "CONNECT_FAILURE",
name: formData.name,
error: errorMessage,
});
toast.error(errorMessage);
return;
}
} else {
syncServerToConvex(
// Both modes: await Convex sync so the returned serverId is available
// for OAuth binding (hosted) and for the new {projectId, serverId}
// request shape (local mode resolver path). Failure is non-fatal in
// local mode — the legacy {serverConfig, serverId: name} body still
// works as a fallback.
let syncErr: unknown;
try {
const serverId = await syncServerToConvex(
formData.name,
serverEntryForSave,
clientSecretSyncOptions
).catch((err) =>
logger.warn("Background sync to Convex failed (pre-connection)", {
serverName: formData.name,
err,
})
);
if (serverId) {
hostedServerId = serverId;
injectHostedServerMapping(formData.name, serverId);
}
} catch (err) {
syncErr = err;
logger.warn("Sync to Convex failed (pre-connection)", {
serverName: formData.name,
err,
});
}
if (HOSTED_MODE && formData.useOAuth && !hostedServerId) {
// OAuth in hosted mode requires a Convex serverId to bind credentials
// to; without it the OAuth dance would complete without a durable
// credential. Local-mode OAuth follows the same constraint post-
// unification but the legacy localStorage fallback still catches it.
const errorMessage =
syncErr instanceof Error
? `Could not save the hosted server before starting OAuth: ${syncErr.message}`
: "Could not save the hosted server before starting OAuth. Please try again.";
dispatch({
type: "CONNECT_FAILURE",
name: formData.name,
error: errorMessage,
});
toast.error(errorMessage);
return;
}
if (!isAuthenticated) {
const project = appState.projects[appState.activeProjectId];
Expand Down
Loading
Loading