The TypeScript framework
Everything in the preceding pages — the contract, forwarding the user’s auth, the bind endpoint — is plain Model Context Protocol plus a small set of HTTP conventions. You can implement it in any language with no dependency on DioscHub, and nothing here changes that.
If you build in TypeScript, there is an optional package that implements that contract for you:
@dioschub/mcp-server. You write
tools; it owns the auth plumbing — the BYOA token broker, the opaque
handle, the store, the tool-call auth gate, and the result wrapping. A server becomes a list of tools
and one createMcpServer call.
What it does for you
Section titled “What it does for you”The framework is the reference implementation of the auth-forwarding flow. It holds two properties by construction:
- Credential-blind, enforced. Your app binds its native artifacts to the framework once,
server-to-server. The framework mints a handle-JWT — a random
jti, an audience, and an expiry, and nothing else — and hands only that to the Hub. The Hub replays the handle on every tool call; your real credentials never leave the MCP server. The model and the Hub see the handle, never the secret. AdevGuardeven scans tool results for leaked artifact values and screams if one appears. - Store miss →
401, never500. A missing, expired, or evicted session returns a clean401withWWW-Authenticate: Bearer. That is exactly the signal that trips the Hub’s mid-turn re-auth, so the widget re-binds and the turn continues. You get automatic recovery instead of a dropped call. - The admin-key trap, caught loudly. The bind route rejects a non-admin key with
403up front, not with a confusing downstream failure — so wiring an embed key where an admin key belongs fails at the door with a clear message.
Install
Section titled “Install”npm install @dioschub/mcp-serverQuickstart
Section titled “Quickstart”A whole server is a createMcpServer call, one or more tool registrations, and listen:
import { createMcpServer } from '@dioschub/mcp-server';import { z } from 'zod';
// Your app's artifact shape. The framework is blind to it; you own what's inside.interface OrdersAuth { sessionCookie: string;}
const server = createMcpServer<OrdersAuth>({ name: 'orders-mcp', // also the JWT audience adminKey: process.env.ADMIN_BIND_KEY!, // authenticates your app's /bind call jwtSecrets: process.env.MCP_JWT_SECRET!, // signs the opaque handle hub: { url: process.env.HUB_URL!, apiKey: process.env.HUB_API_KEY!, // a diosc_ak_… admin key, scope auth:bind },});
server.tool({ name: 'list_orders', description: 'List the signed-in customer’s orders', input: z.object({ status: z.enum(['open', 'shipped', 'all']).default('all') }), handler: async ({ status }, ctx) => { // ctx.auth is exactly what your app sent at /bind — you know its shape. const res = await fetch(`${process.env.API}/orders?status=${status}`, { headers: { cookie: ctx.auth.sessionCookie }, }); return res.json(); // plain data → wrapped into an MCP result for you },});
server.listen(8080);That is the entire server. The framework owns POST /bind and POST /mcp; you never touch the
transport, the JWT, or the auth header.
What a tool handler receives
Section titled “What a tool handler receives”Business arguments come first, validated against the tool’s z.object({...}). Everything else arrives
as the second argument, ctx:
| Field | What it is |
|---|---|
ctx.auth | Your native artifacts, replayed verbatim — the framework never parses them. |
ctx.connectionId | The Hub connection this call is bound to, for correlation. |
ctx.logger | A redaction-aware logger scoped to this tool and connection. It never logs ctx.auth. |
ctx.signal | Aborts when the caller disconnects — honor it at the next boundary (drain, don’t abort). |
Return plain data and the framework wraps it into an MCP result. Return an object with a content
array to take full control of the response.
Configuration
Section titled “Configuration”| Field | Required | Notes |
|---|---|---|
name | yes | Server name; also the JWT audience (aud). |
adminKey | yes | Authenticates your app’s /bind call. Must be an admin key — an embed key here is a 403. |
jwtSecrets | yes | HS256 secret, or an array. The first signs; all verify — add a new one at the front to rotate with zero downtime. |
hub | yes* | { url, apiKey, bindPath?, apiKeyHeader? }. apiKey is a diosc_ak_… admin key scoped auth:bind. Defaults: path /api/auth/bind, header x-api-key. |
hubClient | yes* | *Provide hub or a custom hubClient (your own HubBinder — custom retry, transport, or a test fake). |
store | no | MemoryArtifactStore (default) or RedisArtifactStore. |
ttlSeconds | no | Session and JWT lifetime. Default 28800 (8 hours). |
basePath | no | Mount prefix for /bind and /mcp. |
devGuard | no | Scan tool results for leaked artifacts. Default: on unless NODE_ENV=production. |
Mount your own routes — a health check, readiness — on server.app, the underlying Express app.
Attaching to a Hub
Section titled “Attaching to a Hub”Registering the server as a tool source is the same for any MCP server, framework or not: register it
as a conduit instance (authConfig: {}, so the Hub forwards per-user auth), attach it to an
Assistant, and restart the Hub so it scans the new tools into its graph. That flow — and the boot-scan
restart it depends on — is covered once in
MCP servers & Toolsets.
Running more than one replica
Section titled “Running more than one replica”MemoryArtifactStore (the default) keeps bound sessions in process, so it is single-instance only —
after a restart every user re-binds, correctly but visibly, through the 401 path. For more than one
replica, a handle minted on replica A has to resolve on replica B, so back the store with Redis:
import { createMcpServer, RedisArtifactStore } from '@dioschub/mcp-server';
const server = createMcpServer<OrdersAuth>({ // …name, adminKey, jwtSecrets, hub… store: new RedisArtifactStore({ url: process.env.REDIS_URL }),});The store keys on the JWT jti, never the raw credential.