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:
| Mode | Transport | Authenticated by | Result read from |
|---|---|---|---|
MCP_SERVER | FastMCP over streamable HTTP | A token verifier of the MCP SDK | The bearer-auth middleware |
JUPYTER_SERVER | Tornado handlers in a Jupyter Server | A Jupyter identity provider | self.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")
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β
JUPYTER_MCP_TOKEN_VERIFIER_CLASSβ your verifier, if named;--mcp-token/MCP_TOKENβ the built-in shared secret;- nothing β the server refuses to start on
streamable-httpunless 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_SERVER | JUPYTER_SERVER | |
|---|---|---|
| Plug-in point | JUPYTER_MCP_TOKEN_VERIFIER_CLASS | c.ServerApp.identity_provider_class |
| Implement | async verify_token(token) | get_user(handler) |
| User | AccessToken.subject | User.username |
| Client | AccessToken.client_id | User.client_id, when set |
| Scopes | AccessToken.scopes | User.mcp_scopes, when set |
| Read from a tool | current_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.
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:
| Transport | Session opened by alice, call sent by bob | Tool sees |
|---|---|---|
| stateless | per request | bob |
| stateful | pinned at initialize | alice |
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.
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."
)
...
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.