createServer({ auth }). Tools remain standalone and
read the verified identity using their integration’s synchronous useAuth(ctx).
The API is the same for MCP 2026-07-28 and 2025-06-18.
JWT
JWT_ISSUER is an application-defined environment variable containing your
authorization provider’s exact issuer identifier. Toolgrain discovers jwks_uri
through OAuth authorization server metadata or OpenID Connect discovery, checking
that the document’s issuer matches the configured issuer. Path issuers use the
standard discovery URL variants. jwksUrl is an optional explicit override for
issuers without discovery. Metadata retrieval is bounded by size and time and
does not follow redirects.
Verification uses jose, with cached discovery and remote signing keys, including
key rotation. The defaults allow RS256 and ES256. Override algorithms with
supported asymmetric algorithms; symmetric algorithms and none are rejected.
Tokens require a valid signature, the configured issuer, a matching resource
audience, and exp. Expiration and any nbf are checked. Standard claim types
are checked before exposure. Audience arrays contain only strings; exp, iat,
and nbf must be finite numbers when present. Unsupported JWT critical headers
are invalid tokens (401); signing-key retrieval/import failures remain provider
errors (503). iss, aud, and exp are required; sub, iat,
nbf, and jti are optional. Other claims have type unknown.
JWT verification does not issue tokens or implement login, consent, OAuth client
registration, or an authorization server. Use an OAuth provider supporting your
MCP clients’ authorization and registration flows.
Clerk
CLERK_ISSUER variable is needed: the publishable key identifies the Frontend API
and its issuer. Configure OAuth applications and client onboarding in Clerk for
the MCP clients you intend to support. See Clerk’s OAuth guide.
For a runnable framework application and Inspector OAuth setup, see the
Elysia Clerk example.
Enable aud_claim_enabled in Clerk’s instance OAuth application settings. Clerk
then includes the requested OAuth resource in access tokens’ audience. Both JWT
and opaque tokens must include the resolved MCP resource as their audience.
Otherwise token exchange can succeed while MCP requests return 401 invalid_token.
Authorize again after enabling it to obtain a new token; existing tokens do not
acquire the missing audience.
Only OAuth access tokens for your resource are accepted. JWT OAuth tokens are
verified against the Clerk issuer’s signing keys; opaque oat_ tokens are
verified with Clerk’s backend SDK and checked for audience, revocation, and
expiration. Session tokens, API keys, and M2M tokens are rejected. The context
contains plain identity data; credentials and SDK token-getter methods are not
exposed. Scopes are available to application code but Toolgrain v0 does not
implement per-tool scope policies.
Standalone Node HTTP
http() from @toolgrain/node owns the HTTP listener, routing, host validation,
and shutdown. It works with both public and auth-configured servers:
127.0.0.1, port is 3000, and endpoint path is
/mcp. Port 0 requests an available port; transport.address() returns a copied
Node address after startup and undefined before startup or after shutdown.
Default accepted hosts are localhost, 127.0.0.1, and [::1] on the actual
listening port. hostname controls the bind address; allowedHosts separately
controls accepted request authorities. Closing the server aborts active requests
and verification, stops the listener, and closes its connections. Startup failures
release the listener too. server.listen() claims each transport instance for
its first server; that instance can be attached only once. Rejected reuse leaves the owning server running and
the other server free to attach a fresh transport. Ownership is shared across
compatible package copies and supports frozen custom transports. The transport
does not install global process handlers.
When auth is configured, Toolgrain automatically serves public GET/HEAD metadata
at /.well-known/oauth-protected-resource/mcp. Custom paths, root paths, and
significant trailing slashes produce their corresponding metadata paths. An
explicit provider resource determines the advertised public metadata path.
Unmatched routes return 404; a server without auth exposes no resource metadata.
Combined handlers for existing applications
createHttpHandler(server, options?) returns
(request: Request) => Promise<Response> and includes the same MCP routing,
public metadata, host validation, and authentication. Mount it at the application
root so that both the endpoint and well-known metadata path reach it. A framework
mount that strips /mcp from the Request URL is unsuitable for this combined
handler. The returned HttpHandler also provides matches(pathname) for
framework middleware to identify the MCP and configured metadata paths before
consuming the request body. A Hono middleware can call
handler.matches(c.req.path) ? handler(c.req.raw) : next().
For example, Elysia on Bun can mount the handler without consuming the body:
createNodeHttpHandler(server, options?) from @toolgrain/node provides the
same combined handler for Node IncomingMessage/ServerResponse. Mount it at the
root before body parsing middleware. When Express supplies next, unmatched
paths are delegated immediately, with their request bodies untouched. Matched MCP
requests keep their authentication, protocol, and error responses, including 404
protocol errors. Without a middleware continuation, unmatched paths return 404.
These handler factories leave listener ownership with the application. Separate
createProtectedResourceMetadataHandler(server, { allowedHosts? }) and
createNodeProtectedResourceMetadataHandler(server, { allowedHosts? }) remain
available for frameworks that require explicit filesystem or route registration.
They apply host validation too; the hosting platform must route the public
well-known path to them.
Host validation and resource inference
Handlers acceptpath, allowedHosts, allowedOrigins, and maxMessageBytes.
Paths default to /mcp and must be absolute URL paths without query strings,
fragments, whitespace, or dot-segment normalization. allowedHosts contains exact
hostnames or IP addresses, optionally with ports, for example
["api.example.com", "[::1]:3003"]. Matching normalizes case and standard URL
ports; wildcards, schemes, and paths are rejected. An empty list rejects every
host. Configured arrays are copied. Both the request URL authority and any
supplied Host header must be allowed. When a framework normalizes the URL host
(for example Next.js using localhost for loopback), the validated Host header
preserves the actual resource authority. Disallowed hosts receive 403 before
routing, authentication, or body parsing, including on metadata requests.
Standalone http() derives exact local authorities from the listening port.
Existing-application handlers default to the three loopback hostnames on any
port because they do not own the listener. Set allowedHosts for production or
to restrict development to a particular port. allowedOrigins separately checks
the browser Origin header on MCP requests; it is not a host allowlist or a CORS
implementation.
For https://api.example.com/mcp, the inferred resource is
https://api.example.com/mcp. Query parameters are excluded. Paths and significant
trailing slashes are preserved; a root resource uses the origin without /.
Metadata works before any authenticated request; no first-request origin is
cached. Metadata, challenges, and token audience verification use the same
resource.
Applications still supply deployment configuration and trusted proxy
normalization. Node uses the actual Host, target, port, and direct TLS scheme and
ignores Forwarded and X-Forwarded-* headers. If a trusted proxy rewrites the
external scheme, host, or path, supply a normalized public Web Request (including
its Host header), or configure a canonical provider resource:
resource override. Configure
allowedHosts for the authorities that reach the handler; a resource override
does not disable host validation. Auth URLs require HTTPS, with HTTP loopback
exceptions for development. Configured auth URLs reject credentials, queries,
fragments, and whitespace. Applications continue to own TLS, CORS, and any
framework listener. The JWT example demonstrates
managed Node HTTP and both protocol revisions.
Request behavior
After endpoint checks, authentication runs before body parsing or tool work. Credentials must be a bearer token in theAuthorization header. Query tokens
are rejected; cookies, request _meta, and tool arguments do not establish an
identity. Each authenticated request gets its own copied, deeply frozen identity.
Calling another integration’s useAuth or using a context without auth throws.
These HTTP statuses are the same in both protocol eras. JWT and Clerk audience
failures include an actionable
error_description in the JSON body and the
WWW-Authenticate challenge. Missing aud and an audience that does not match
the resource have distinct messages. For example:
AuthError("temporarily_unavailable") outages, reach the server’s onError(error)
callback once with the original thrown value and its private cause, if present.
No tool/request details or credentials are added. Expected auth rejections
(invalid_request, invalid_token, and access_denied), closed-server rejections,
and failures after cancellation do not reach the callback. Callback throws and
asynchronous rejections cannot disrupt the response or cleanup.
Public metadata uses Cache-Control: no-store and is available without a bearer
token.
Verification, body reading, and execution share request cancellation and server
shutdown. The server stops waiting if verification cannot be cancelled by the
integration. Shared JWT discovery/key fetches may finish for other requests;
Clerk’s backend SDK may finish its in-flight request. Late identities and handler
results are discarded. Tool execution remains cooperatively cancellable.
An auth-configured server accepts request transports such as http() and rejects
message transports such as stdio() before startup. This authorization API is
HTTP-only; stdio servers use their process/environment trust model.
Custom providers and direct invocation
Integrations implementAuthProvider<T> from @toolgrain/mcp, with a registered
provider type symbol, optional canonical resource, nonempty
authorizationServers, and verify(token, { resource, signal }). Return plain
identity data and throw AuthError with invalid_token, access_denied, or
temporarily_unavailable as appropriate. createAuthAccessor<T>(type) creates a
typed accessor returning recursively readonly identity data. Matching compatible
package copies share provider/context symbols.
Custom providers can use validateTokenAudience(verifiedAudience, resource) from
@toolgrain/mcp after verifying a token’s authenticity and validity. It accepts a
string or array of strings, requires a match for the expected resource, and throws
AuthError with public guidance for missing or mismatched audiences. It does not
verify signatures, issuers, expiration, or revocation itself.
new AuthError(code, { description, cause }) allows an explicitly public
description separate from a private cause. description also becomes the error’s
message and is included as error_description by HTTP adapters. Include only
information intended for clients; never copy raw provider errors or credentials
into it. Omitting description preserves the existing generic response.
The HTTP adapters call server.authorize(token, { resource, signal? }), then pass
the opaque scope to server.handle(message, { auth: scope, protocolVersion? }).
Custom transport integrations must dispose the scope in finally. Scopes are
single-use, server-bound, and cannot be fabricated or reused after disposal.
Calling handle without a valid scope on a protected server is rejected.
getAuthMetadata() returns copied configuration for HTTP adapters.
toolContext() creates an unauthenticated context. Directly calling a tool’s raw
execute bypasses server authorization and validation; test protected execution
through authorize and handle instead.
Lazy context providers can read useAuth(ctx) and depend on other
providers through inferred async accessors. Authorization is still eagerly
verified before context resolution; synchronous useAuth(ctx) does no work.