Skip to content

feat: Add Proxied Jupyter UI Tools MCP Server - #398

Open
acook4148 wants to merge 1 commit into
plmbr:mainfrom
acook4148:jupyterUIProxiedMCP
Open

feat: Add Proxied Jupyter UI Tools MCP Server#398
acook4148 wants to merge 1 commit into
plmbr:mainfrom
acook4148:jupyterUIProxiedMCP

Conversation

@acook4148

@acook4148 acook4148 commented Aug 17, 2026

Copy link
Copy Markdown

Sets up a proxied Jupyter UI Tools MCP server that allows conflict resolution with an enterprise MCP config for Claude Code.

The current MCP setup implements the jupyter UI tools MCP server as an in-process MCP server which gets passed to the claude-agent-sdk and then passed to the Claude Code CLI as it's own MCP config with --mcp-config. This will conflict with an enterprise MCP config (managed-mcp.json) file, causing an error to be thrown by the Claude Code CLI and then the Claude Mode sidebar to fail.

This proxied jupyter UI tools MCP server is a mimic of the current in process server, except it runs in the same jupyterlab server through a proxy URL. This allows developers to modify their enterprise MCP config to add a section for this proxied MCP server so that it will work in the same way as the in-process MCP server

A toggle is provided which allows developers to choose whether they want the proxied MCP jupyter UI tools server, or the original in-process one. The default is the original in-process jupyter UI tools server. Using the proxied MCP server requires the enterprise MCP config to also register this server.

@pjdoland pjdoland added the enhancement New feature or request label Aug 19, 2026

@pjdoland pjdoland left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks for this. The external-transport approach is a clean way to work around managed MCP configs that reject dynamically-configured servers, and keeping the tool names and system prompt identical across both transports is the right call. Two things I want to flag before this lands, one blocking.

Blocking: the proxy's credential can't authenticate against the relay

The proxy authenticates with the NBI shared secret, but UIToolsHandler requires a Jupyter identity. Those are different credentials, so every bridged call gets a 403.

The path:

  • claude.py:2221 sets NBI_UI_TOOLS_TOKEN to get_ui_tools_secret()
  • mcp_ui_proxy.py:66 resolves NBI_UI_TOOLS_TOKEN ahead of JUPYTER_TOKEN, so that secret is what goes out in the Authorization header
  • both UIToolsHandler.get and .post carry @tornado.web.authenticated, and jupyter_server's identity provider only accepts Authorization: token <token> when the value matches the server token

I mounted the real UIToolsHandler in a real ServerApp to confirm rather than reason from the code alone:

credential sent POST result
NBI secret 403
Jupyter server token 200
none 403

So external mode fails closed on both the manifest fetch and every tool call. Worth saying explicitly: fail-closed means this is a functionality bug, not a security hole.

The check_xsrf_cookie override itself is written well: constant-time compare, falls through to super() on any mismatch, and it never weakens the @authenticated gate. It just solves XSRF, which was not the barrier here. Authentication is.

Two ways out, depending on what you intend the secret to mean:

  1. Send the Jupyter token as Authorization (so @authenticated passes) and carry the NBI secret in a separate header that the XSRF override reads. Keeps the existing auth surface exactly as it is.
  2. Teach the handler to accept the secret as an identity of its own.

I'd suggest the first. The second widens what counts as an authenticated caller on a route that can drive the notebook UI, and the secret's value is to prove "this is the proxy NBI spawned", which pairs naturally with a dedicated header rather than replacing the server's own auth.

Non-blocking: the route registers even when the tools are disabled

extension.py:3483 registers route_pattern_ui_tools unconditionally, so the endpoint exists whether or not JupyterUITools is in the configured tools. Real exposure is limited: execution is gated on an active chat turn, and GET only returns the 15 tool names and schemas. Still, gating registration on the same flag that gates the transport would keep the surface aligned with the feature.

There are also no tests covering the new handler or the proxy. Given the auth path above is exactly the kind of thing a test would have caught at the boundary, a case asserting that a proxy-shaped request reaches invoke_ui_tool would be worth having.

Things that read well

  • The api.py change is a real fix independent of this feature: moving the disconnect into a finally stops the signal handler leaking when the wait is cancelled or raises, where before it only disconnected on the success path.
  • asyncio.to_thread around the blocking urllib calls keeps the stdio server's event loop free.
  • Building the opener with an empty ProxyHandler so the loopback call ignores ambient HTTP(S)_PROXY is a good detail, and the comment explains why.
  • invoke_ui_tool validates the name against JUPYTER_UI_TOOLS before dispatch, bounds the call with wait_for, and converts failures into MCP error results rather than letting them escape.
  • Returning types.CallToolResult is valid; the lowlevel server handles it as a first-class return type, which I confirmed at runtime.

Test suite passes at 1377 on the branch.

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

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants