Skip to main content

Identity

The built-in authentication described in Security covers the common cases: a shared MCP_TOKEN for the standalone server, and the Jupyter token when running as an extension. This page is about the next step β€” authenticating MCP clients the way your own platform does, with your accounts, your OAuth, and your notion of what a client is allowed to do.

Why a configurable identity​

The server runs in two modes, and each authenticates through a mechanism owned by a different project:

ModeTransportAuthenticated byResult read from
MCP_SERVERFastMCP over streamable HTTPA token verifier of the MCP SDKThe bearer-auth middleware
JUPYTER_SERVERTornado handlers in a Jupyter ServerA Jupyter identity providerself.current_user

Both are pluggable, but they have different shapes, and a tool that wants to know who is calling should not have to care which mode it is running in. The jupyter_mcp_server.identity module gives both one shape.

The Identity​

Whatever authenticated the request, a tool sees the same object:

from jupyter_mcp_server.identity import current_identity

identity = current_identity()
identity.username # who the user is
identity.client_id # which client is acting for them, when one is named
identity.scopes # what that client was allowed to do
identity.token # the credential to act with, when it travels per request
identity.has_scope("code:execute")
An unscoped credential is not a powerless one

has_scope() returns True for any scope when scopes is empty. That is deliberate: a Jupyter token or a personal access token is the user acting as themselves, delegating nothing, so it carries their whole authority. Only a delegated credential β€” an OAuth token issued to an agent β€” narrows it.

The identity lives in a ContextVar set at the start of each request and reset when it finishes, so concurrent requests never see one another's caller.

MCP_SERVER mode: a token verifier​

Name your class in the JUPYTER_MCP_TOKEN_VERIFIER_CLASS environment variable, as package.module:ClassName (a dotted path works too):

export JUPYTER_MCP_TOKEN_VERIFIER_CLASS="my_platform.mcp:MyTokenVerifier"

jupyter mcp start --transport streamable-http --jupyter-token JUPYTER_SECRET

A verifier is any object with one asynchronous method. Return None to refuse the request, or an AccessToken to accept it:

from mcp.server.auth.provider import AccessToken


class MyTokenVerifier:
"""Verify a bearer token against my platform."""

async def verify_token(self, token: str) -> AccessToken | None:
claims = my_platform.decode(token) # signature, issuer, audience…
if claims is None:
return None
return AccessToken(
token=token,
client_id=claims["client_id"],
scopes=claims.get("scope", "").split(),
subject=claims["sub"],
)

The class is instantiated with no arguments, so it reads whatever it needs from the environment. When one is set, the MCP SDK installs its bearer-auth middleware and refuses every unverified request; subject, client_id and scopes become the Identity.

Resolution order​

  1. JUPYTER_MCP_TOKEN_VERIFIER_CLASS β€” your verifier, if named;
  2. --mcp-token / MCP_TOKEN β€” the built-in shared secret;
  3. nothing β€” the server refuses to start on streamable-http unless you pass --insecure-mcp-noauth.

A class that does not implement verify_token raises TypeError at startup rather than on the first request, so a typo in the path is found immediately.

JUPYTER_SERVER mode: an identity provider​

As an extension, the MCP handlers require self.current_user, which Jupyter resolves through its own IdentityProvider. Plug yours in the usual Jupyter way β€” nothing in this project needs changing:

# jupyter_server_config.py
c.ServerApp.identity_provider_class = "my_platform.jupyter:MyIdentityProvider"
from jupyter_server.auth.identity import IdentityProvider, User


class MyIdentityProvider(IdentityProvider):
"""Resolve a Jupyter request against my platform."""

def get_user(self, handler):
token = handler.request.headers.get("Authorization", "").removeprefix("Bearer ")
claims = my_platform.decode(token)
if claims is None:
return None
user = User(username=claims["sub"])
# Optional: narrow what this session may do. Without it the session is
# unscoped, which means the user's full authority.
user.mcp_scopes = claims.get("scope", "").split()
return user

The mcp_scopes attribute is the one addition this project looks for: it is how a Jupyter session carries the same scope information an OAuth token would, so the two modes stay at parity.

Parity between the modes​

MCP_SERVERJUPYTER_SERVER
Plug-in pointJUPYTER_MCP_TOKEN_VERIFIER_CLASSc.ServerApp.identity_provider_class
Implementasync verify_token(token)get_user(handler)
UserAccessToken.subjectUser.username
ClientAccessToken.client_idUser.client_id, when set
ScopesAccessToken.scopesUser.mcp_scopes, when set
Read from a toolcurrent_identity()current_identity()

Serving more than one user​

A server that serves one person needs one credential. It is configured once β€” --document-token, --code-sandbox-token β€” and every request uses it. That is the ordinary case, and nothing below changes it.

A server that accepts many users cannot work that way. The configured token belongs to somebody, so using it for everyone means one user's requests run with another user's authority. The credential has to travel with the request.

That is what identity.token is for. When a verifier puts a credential on the Identity, the server presents it to the document and code sandbox servers instead of the configured one:

from mcp.server.auth.provider import AccessToken


class MyVerifier:
async def verify_token(self, token: str) -> AccessToken | None:
claims = my_platform.verify(token)
if claims is None:
return None
return AccessToken(
token=token, # carried through as identity.token
client_id=claims["client_id"],
scopes=claims["scopes"],
subject=claims["user_id"],
)

Nothing else is needed. identity_from_access_token() copies the bearer token onto the Identity, and the configuration resolves it:

config.resolved_document_token() # the caller's, else the configured one
config.resolved_code_sandbox_token() # the same rule, for the execution server

Both are read wherever a token is needed, so a tool cannot forget to ask. A verifier that would rather not pass the credential through returns an AccessToken with an empty token, and the configured one is used as before.

This depends on the transport being stateless

The identity is a ContextVar, and whether a tool can see it depends on which task the tool runs in.

With stateless_http=True β€” what this project uses β€” the server task is started per request, in that request's context, so the tool sees that request's caller.

With a stateful transport the server task is started once, when the session is created, and inherits that first request's context. Every later call in the session then runs as whoever opened it, whatever credential the later request carried. Measured, not assumed:

TransportSession opened by alice, call sent by bobTool sees
statelessper requestbob
statefulpinned at initializealice

The danger is not that it fails β€” it is that it succeeds with the wrong identity. An authorization layer checking each request's token would then disagree with the identity the tool acts under, silently. If you make the transport stateful, do not rely on identity.token.

Serving several users means one process each

Passing the credential per request covers the document and sandbox tokens. It does not make a single process multi-tenant, and it was never going to: the configuration is a singleton, ServerContext caches HTTP clients with a token baked into their sessions, and NotebookManager holds the notebooks that have been used. Each is correct for one person and none is safe to share, and every future piece of cached state would be another one to find.

So the supported way to serve several users is one server process per user, with authentication and authorization in front of them. A process is a boundary the operating system already enforces, and it covers the state you have not thought of as well as the state you have.

The hosted Datalayer gateway is built this way: it verifies the token, checks the scope and the notebook permission, and then hands the request to that user's own process, which holds no credential of its own and authenticates the forwarded header exactly as a directly connected client would.

Per-request credentials still matter inside that design β€” a worker serves one user but sees many of their tokens over time, and identity.token is what keeps each request acting with the one it arrived with.

Using the identity in a tool​

from jupyter_mcp_server.identity import current_identity


async def my_tool(...):
identity = current_identity()
if identity and not identity.has_scope("code:execute"):
raise PermissionError(
"This client was not granted permission to execute code."
)
...
Scopes say what kind, permissions say which object

A scope allows a kind of operation. It does not say which notebook or which dataset β€” that decision belongs to your platform, and should be made per request against the user in identity.username. A token carrying notebooks:write must still not let one user edit another's notebook.

An example: hosted Datalayer​

The Datalayer hosted MCP endpoint is built on exactly this hook. It supplies a verifier that accepts both OAuth 2.1 access tokens and personal access tokens, checks the audience so a token minted for another API cannot be replayed, and maps the platform's scopes β€” notebooks:read, notebooks:write, code:execute, data:read β€” onto the Identity the tools read. Nothing in this project is patched to make that work.