Skip to content

fix(native-server): replace getMcpServer() singleton with factory to allow simultaneous Chrome extension + external MCP client connections#301

Open
PyEL666 wants to merge 1 commit intohangwin:masterfrom
PyEL666:fix/mcp-server-singleton-multi-transport
Open

fix(native-server): replace getMcpServer() singleton with factory to allow simultaneous Chrome extension + external MCP client connections#301
PyEL666 wants to merge 1 commit intohangwin:masterfrom
PyEL666:fix/mcp-server-singleton-multi-transport

Conversation

@PyEL666
Copy link

@PyEL666 PyEL666 commented Feb 22, 2026

Problem

It is currently impossible to use the Chrome extension UI and an external MCP client (e.g. mcporter, Claude Desktop) at the same time.

When the Chrome extension connects to /sse, the bridge calls:

const server = getMcpServer();
await server.connect(sseTransport);   // sets server._transport

When an external MCP client subsequently connects to /mcp, the bridge calls:

await getMcpServer().connect(httpTransport);  // throws!

The @modelcontextprotocol/sdk Protocol.connect() has a hard guard:

if (this._transport !== undefined) {
  throw new Error('Already connected to a transport');
}

Because getMcpServer() returns the same cached instance every time, whichever transport connected first permanently locks out all subsequent connections. The external client receives Failed to connect to MCP server.

This produces an intermittent first-success / always-fail-after pattern:

  • First test: extension had not yet connected → external client wins the race → succeeds
  • Every test after: extension reconnects and holds the transport → external client is rejected

Root Cause

File: app/native-server/src/mcp/mcp-server.ts

// Before – module-level singleton, only one transport allowed at a time
export let mcpServer: Server | null = null;

export const getMcpServer = () => {
  if (mcpServer) {
    return mcpServer;   // ← cached instance, already has _transport set
  }
  mcpServer = new Server(...);
  setupTools(mcpServer);
  return mcpServer;
};

server/index.ts calls getMcpServer().connect(transport) in both the /sse route and the /mcp route. Since they receive the same Server object, the second connect() always throws.

Fix

Convert getMcpServer() from a singleton accessor to a factory function that creates a fresh Server instance on every call.

// After – factory, each transport gets its own Server instance
export const getMcpServer = () => {
  const server = new Server(
    { name: 'ChromeMcpServer', version: '1.0.0' },
    { capabilities: { tools: {} } },
  );
  setupTools(server);
  return server;
};

Why this is safe

setupTools() registers handlers that forward every Chrome API call through nativeMessagingHostInstance — a separate module-level singleton in native-messaging-host.ts. Each independent Server object shares the same underlying Native Messaging channel to the Chrome extension, so all tool calls still reach Chrome correctly.

Changed Files

File Change
app/native-server/src/mcp/mcp-server.ts Remove cached mcpServer variable; make getMcpServer() a pure factory

Testing

  1. Open the Chrome extension (establishes SSE connection to /sse)
  2. Connect an external MCP client (e.g. mcporter) via stdio → StreamableHTTP to /mcp
  3. Both connections should be active simultaneously
  4. Calling tools from the external client should succeed while the extension UI remains connected

Related

A secondary issue (bridge process exits when Chrome extension disconnects via Native Messaging stdin EOF) is not addressed in this PR. Fixing that requires careful design to avoid port-conflict problems when Chrome respawns the bridge. It can be tracked separately.


Discovered while configuring mcporter on Windows with the Chrome MCP extension.

…ction

Previously getMcpServer() cached and reused a single Server instance.
The @modelcontextprotocol/sdk Protocol.connect() method throws
"Already connected to a transport" when called on an already-connected
Server, so only one transport could be active at a time.

This caused a race condition: whichever caller (Chrome extension via /sse
or an external MCP client via /mcp) connected first would lock out the
other, making it impossible to use both the Chrome extension UI and
external tools like mcporter or Claude Desktop simultaneously.

Fix: convert getMcpServer() from a cached singleton to a pure factory
function that creates a fresh Server instance per call. Tool handlers
registered by setupTools() delegate all Chrome API calls through the
shared nativeMessagingHostInstance module singleton, so each independent
Server instance still reaches the Chrome extension correctly.

Also removes the now-unused `export let mcpServer` variable.
@oshliaer
Copy link

It works

┌───────────────┬─────────────────────────────────────────┐
│ Component     │ Value                                   │
├───────────────┼─────────────────────────────────────────┤
│ OS            │ Linux 6.8.0-100-generic (Ubuntu) x86_64 │
│ Node.js       │ v22.14.0                                │
│ npm           │ 11.8.0                                  │
│ pnpm          │ 10.29.2                                 │
│ Google Chrome │ 145.0.7632.109                          │
└───────────────┴─────────────────────────────────────────┘

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants