Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions mcpjam-inspector/HOSTED_MCPJAM.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Goal

We want to create a hosted version of MCPJam. It will be hosted via Docker on Railway. It must have everything the Desktop version has.

# Current limitations

Currently MCPJam was designed to be a Desktop app ran locally either through `npx`, Electron, Docker. The Hono backend has a MCPClientManager singleton. This means if we host it, everyone will be sharing the same MCPClientManager object, and we will have collisions. People will have access to other people's MCP servers and see everyone else's logs.

We cannot have this singleton behavior in a hosted version. Everyone should have their own MCPClientManager in isolation.

# Requirements

- We want to create a mono-repo that supports both the current local desktop, but also a hosted version.
- In hosted environment, everyone must have their own client manager in isolation.
- Try to scale, handle what happens when there are LOTS of people connected to the server.
- Must be deployable via Docker.
- Changes must be as minimalistic as possible. Least amount of impact and code required as possible.
95 changes: 55 additions & 40 deletions mcpjam-inspector/server/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,12 @@ import { MCPClientManager } from "@mcpjam/sdk";
import { initElicitationCallback } from "./routes/mcp/elicitation.js";
import { rpcLogBus } from "./services/rpc-log-bus.js";
import { progressStore } from "./services/progress-store.js";
import {
createClientManagerStore,
readSessionStoreOptionsFromEnv,
} from "./services/client-manager-store.js";
import { CORS_ORIGINS, HOSTED_MODE, ALLOWED_HOSTS } from "./config.js";
import { resolveRequestSessionId } from "./middleware/client-manager-session.js";
import path from "path";

// Security imports
Expand Down Expand Up @@ -82,52 +87,54 @@ export function createHonoApp() {
generateSessionToken();
const app = new Hono();

// Create the MCPJam client manager instance and wire RPC logging to SSE bus
const mcpClientManager = new MCPClientManager(
{},
{
rpcLogger: ({ direction, message, serverId }) => {
rpcLogBus.publish({
serverId,
direction,
timestamp: new Date().toISOString(),
message,
});
},
progressHandler: ({
serverId,
progressToken,
progress,
total,
message,
}) => {
// Store progress for UI access using the real progressToken from the notification
progressStore.publish({
serverId,
progressToken,
progress,
total,
message,
timestamp: new Date().toISOString(),
});
},
},
);
const mcpClientManagerStore = createClientManagerStore({
hostedMode: HOSTED_MODE,
managerFactory: (sessionId) => {
// Create a manager and wire RPC/progress logging for each isolated session.
const manager = new MCPClientManager(
{},
{
rpcLogger: ({ direction, message, serverId }) => {
rpcLogBus.publish({
sessionId,
serverId,
direction,
timestamp: new Date().toISOString(),
message,
});
},
progressHandler: ({
serverId,
progressToken,
progress,
total,
message,
}) => {
// Store progress for UI access using the real progressToken from the notification
progressStore.publish({
sessionId,
serverId,
progressToken,
progress,
total,
message,
timestamp: new Date().toISOString(),
});
},
},
);

// Initialize elicitation callback immediately so tasks/result calls work
// without needing to hit the elicitation endpoints first
initElicitationCallback(mcpClientManager);
// Register callback per manager instance so task-related elicitation works.
initElicitationCallback(manager);
return manager;
},
sessionStoreOptions: readSessionStoreOptionsFromEnv(),
});
Comment on lines +90 to +132
Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n mcpjam-inspector/server/app.ts | sed -n '85,135p'

Repository: MCPJam/inspector

Length of output: 1936


🏁 Script executed:

cat -n mcpjam-inspector/server/index.ts | sed -n '225,270p'

Repository: MCPJam/inspector

Length of output: 1453


🏁 Script executed:

# Also check for the actual createClientManagerStore and managerFactory pattern
rg -n "createClientManagerStore|managerFactory" --type ts mcpjam-inspector/server/ -A 5 -B 1

Repository: MCPJam/inspector

Length of output: 12905


Extract the createClientManagerStore configuration into a shared factory function.

Lines 90–132 in app.ts and lines 228–267 in index.ts contain identical store initialization logic. This duplicated block—including the managerFactory with RPC logger, progress handler, and elicitation callback setup—should be extracted to a reusable function (e.g., createDefaultClientManagerStore()) in a shared utilities module. Both entry points can then invoke this function, eliminating the maintenance burden of keeping two identical copies in sync.

🤖 Prompt for AI Agents
In `@mcpjam-inspector/server/app.ts` around lines 90 - 132, Extract the duplicated
createClientManagerStore configuration into a shared factory function (e.g.,
createDefaultClientManagerStore) that returns the store configured with
HOSTED_MODE, managerFactory, and sessionStoreOptions from
readSessionStoreOptionsFromEnv(); inside managerFactory re-use the
MCPClientManager construction including the rpcLogger that calls
rpcLogBus.publish and the progressHandler that calls progressStore.publish, and
ensure initElicitationCallback(manager) is invoked before returning the manager;
replace the two inline initializations in app.ts and index.ts with calls to this
new shared factory function.


if (process.env.DEBUG_MCP_SELECTION === "1") {
appLogger.debug("[mcpjam][boot] DEBUG_MCP_SELECTION enabled");
}

// Middleware to inject the client manager into context
app.use("*", async (c, next) => {
c.mcpClientManager = mcpClientManager;
await next();
});

// ===== SECURITY MIDDLEWARE STACK =====
// Order matters: headers -> origin validation -> session auth

Expand All @@ -142,6 +149,14 @@ export function createHonoApp() {

// ===== END SECURITY MIDDLEWARE =====

// Resolve the manager only after security middleware has run.
app.use("*", async (c, next) => {
const sessionId = resolveRequestSessionId(c, HOSTED_MODE);
c.mcpSessionId = sessionId;
c.mcpClientManager = mcpClientManagerStore.getManager(sessionId);
await next();
});

// Middleware - only enable HTTP request logging in dev mode or when --verbose is passed
const enableHttpLogs =
process.env.NODE_ENV !== "production" ||
Expand Down
76 changes: 57 additions & 19 deletions mcpjam-inspector/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
sessionAuthMiddleware,
scrubTokenFromUrl,
} from "./middleware/session-auth";
import { resolveRequestSessionId } from "./middleware/client-manager-session";
import { originValidationMiddleware } from "./middleware/origin-validation";
import { securityHeadersMiddleware } from "./middleware/security-headers";

Expand Down Expand Up @@ -82,8 +83,14 @@ function logBox(content: string, title?: string) {
// Import routes and services
import mcpRoutes from "./routes/mcp/index";
import appsRoutes from "./routes/apps/index";
import { initElicitationCallback } from "./routes/mcp/elicitation";
import { rpcLogBus } from "./services/rpc-log-bus";
import { progressStore } from "./services/progress-store";
import { tunnelManager } from "./services/tunnel-manager";
import {
createClientManagerStore,
readSessionStoreOptionsFromEnv,
} from "./services/client-manager-store";
import {
SERVER_PORT,
SERVER_HOSTNAME,
Expand Down Expand Up @@ -218,24 +225,45 @@ if (!process.env.CONVEX_HTTP_URL) {
);
}

// Initialize centralized MCPJam Client Manager and wire RPC logging to SSE bus
const mcpClientManager = new MCPClientManager(
{},
{
rpcLogger: ({ direction, message, serverId }) => {
rpcLogBus.publish({
serverId,
direction,
timestamp: new Date().toISOString(),
message,
});
},
const mcpClientManagerStore = createClientManagerStore({
hostedMode: HOSTED_MODE,
managerFactory: (sessionId) => {
const manager = new MCPClientManager(
{},
{
rpcLogger: ({ direction, message, serverId }) => {
rpcLogBus.publish({
sessionId,
serverId,
direction,
timestamp: new Date().toISOString(),
message,
});
},
progressHandler: ({
serverId,
progressToken,
progress,
total,
message,
}) => {
progressStore.publish({
sessionId,
serverId,
progressToken,
progress,
total,
message,
timestamp: new Date().toISOString(),
});
},
},
);

initElicitationCallback(manager);
return manager;
Comment on lines +263 to +264
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# Check how broadcastElicitation works and whether it's session-scoped
rg -n 'broadcastElicitation' --type=ts -C5
echo "---"
# Check the elicitation SSE endpoint for session filtering
rg -n 'elicitation.*stream\|elicitation.*subscribe\|elicitation.*sse' --type=ts -C5

Repository: MCPJam/inspector

Length of output: 2746


🏁 Script executed:

# Find where elicitationSubscribers are registered
rg -n 'elicitationSubscribers' --type=ts -B3 -A3

echo "---"

# Look for the SSE endpoint registration
rg -n 'subscribe\|/elicitation' --type=ts -B2 -A10

Repository: MCPJam/inspector

Length of output: 2466


🏁 Script executed:

# Get the full context of the SSE endpoint where subscribers are added
sed -n '80,120p' mcpjam-inspector/server/routes/mcp/elicitation.ts

Repository: MCPJam/inspector

Length of output: 1202


🏁 Script executed:

# Find the complete route definition with endpoint path
rg -n 'elicitation\.get\|\.get.*subscribe\|\.get.*stream' mcpjam-inspector/server/routes/mcp/elicitation.ts -B5 -A20

Repository: MCPJam/inspector

Length of output: 42


🏁 Script executed:

# Get file size and view the complete elicitation.ts
wc -l mcpjam-inspector/server/routes/mcp/elicitation.ts
echo "---"
cat -n mcpjam-inspector/server/routes/mcp/elicitation.ts

Repository: MCPJam/inspector

Length of output: 6013


Elicitation broadcasts leak across sessions in hosted mode.

The broadcastElicitation function (line 13) broadcasts to all subscribers in a global elicitationSubscribers Set without session awareness. When clients connect to the /stream endpoint, they're added to this unscoped global Set with no session context. Broadcast events (lines 53–61, 147) contain no sessionId, so in hosted mode, elicitation requests from one user's MCP server reach all connected SSE clients, breaking tenant isolation.

Scoped subscribers by session (e.g., per-session Sets) or add sessionId to broadcast events and filter on the client side.

🤖 Prompt for AI Agents
In `@mcpjam-inspector/server/index.ts` around lines 263 - 264, The global
elicitationSubscribers Set and broadcastElicitation must be scoped by session to
avoid cross-tenant leaks: replace elicitationSubscribers with a Map<sessionId,
Set<Subscriber>> (or similar), update the SSE /stream subscription logic to
register subscribers into the Map under their sessionId, change
broadcastElicitation to accept a sessionId (or attach sessionId to the event
payload) and only iterate/send to the Set for that session, and update
initElicitationCallback and any code that calls broadcastElicitation to pass the
correct sessionId; alternatively, if you prefer client-side filtering, ensure
broadcast events include sessionId in their payload and only add global
subscribers that filter incoming events by sessionId.

},
);
// Middleware to inject client manager into context
app.use("*", async (c, next) => {
c.mcpClientManager = mcpClientManager;
await next();
sessionStoreOptions: readSessionStoreOptionsFromEnv(),
});

// ===== SECURITY MIDDLEWARE STACK =====
Expand All @@ -252,6 +280,14 @@ app.use("*", sessionAuthMiddleware);

// ===== END SECURITY MIDDLEWARE =====

// Resolve a manager only after the security stack has run.
app.use("*", async (c, next) => {
const sessionId = resolveRequestSessionId(c, HOSTED_MODE);
c.mcpSessionId = sessionId;
c.mcpClientManager = mcpClientManagerStore.getManager(sessionId);
await next();
});

// Middleware - only enable HTTP request logging in dev mode or when --verbose is passed
const enableHttpLogs =
process.env.NODE_ENV !== "production" || process.env.VERBOSE_LOGS === "true";
Expand Down Expand Up @@ -410,14 +446,16 @@ const server = serve({

// Handle graceful shutdown
process.on("SIGINT", async () => {
console.log("\n🛑 Shutting down gracefully...");
appLogger.info("\n🛑 Shutting down gracefully...");
await mcpClientManagerStore.dispose();
await tunnelManager.closeAll();
server.close();
process.exit(0);
});

process.on("SIGTERM", async () => {
console.log("\n🛑 Shutting down gracefully...");
appLogger.info("\n🛑 Shutting down gracefully...");
await mcpClientManagerStore.dispose();
await tunnelManager.closeAll();
server.close();
process.exit(0);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { describe, expect, it } from "vitest";
import { Hono } from "hono";
import { resolveRequestSessionId } from "../client-manager-session.js";

function createTestApp(hostedMode: boolean): Hono {
const app = new Hono();

app.use("*", async (c, next) => {
(c as any).resolvedSessionId = resolveRequestSessionId(c, hostedMode);
await next();
});

app.get("/api/test", (c) => {
return c.json({ sessionId: (c as any).resolvedSessionId ?? null });
});

return app;
}

describe("resolveRequestSessionId", () => {
it("does not create a session when hosted mode is disabled", async () => {
const app = createTestApp(false);

const res = await app.request("/api/test");
const data = await res.json();

expect(data.sessionId).toBeNull();
expect(res.headers.get("set-cookie")).toBeNull();
});

it("uses explicit session header when present", async () => {
const app = createTestApp(true);

const res = await app.request("/api/test", {
headers: { "x-mcpjam-session-id": "header-session-1" },
});
const data = await res.json();

expect(data.sessionId).toBe("header-session-1");
expect(res.headers.get("set-cookie")).toBeNull();
});

it("uses existing cookie session when present", async () => {
const app = createTestApp(true);

const res = await app.request("/api/test", {
headers: { Cookie: "mcpjam_session_id=cookie-session-1" },
});
const data = await res.json();

expect(data.sessionId).toBe("cookie-session-1");
expect(res.headers.get("set-cookie")).toBeNull();
});

it("creates a new session cookie when no session identifier exists", async () => {
const app = createTestApp(true);

const res = await app.request("/api/test");
const data = await res.json();
const setCookie = res.headers.get("set-cookie");

expect(data.sessionId).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
);
expect(setCookie).toContain(`mcpjam_session_id=${data.sessionId}`);
expect(setCookie).toContain("HttpOnly");
});
});
Loading