@casys/mcp-server 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,9 +4,12 @@
4
4
  [![JSR](https://jsr.io/badges/@casys/mcp-server)](https://jsr.io/@casys/mcp-server)
5
5
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
6
6
 
7
- **The "Hono for MCP"** — a production-grade framework for building Model Context Protocol servers in TypeScript.
7
+ **The "Hono for MCP"** — a production-grade framework for building Model Context
8
+ Protocol servers in TypeScript.
8
9
 
9
- Composable middleware, OAuth2 auth, dual transport, observability, and everything you need to ship reliable MCP servers. Built on the official [@modelcontextprotocol/sdk](https://github.com/modelcontextprotocol/sdk).
10
+ Composable middleware, OAuth2 auth, dual transport, observability, and
11
+ everything you need to ship reliable MCP servers. Built on the official
12
+ [@modelcontextprotocol/sdk](https://github.com/modelcontextprotocol/sdk).
10
13
 
11
14
  ```
12
15
  rate-limit → auth → custom middleware → scope-check → validation → backpressure → handler
@@ -16,7 +19,8 @@ rate-limit → auth → custom middleware → scope-check → validation → bac
16
19
 
17
20
  ## Why @casys/mcp-server?
18
21
 
19
- The official SDK gives you the protocol. This framework gives you the production stack.
22
+ The official SDK gives you the protocol. This framework gives you the production
23
+ stack.
20
24
 
21
25
  | | Official SDK | @casys/mcp-server |
22
26
  | ----------------------- | :----------: | :----------------------------: |
@@ -51,9 +55,9 @@ deno add jsr:@casys/mcp-server
51
55
  ### STDIO Server (5 lines)
52
56
 
53
57
  ```typescript
54
- import { ConcurrentMCPServer } from "@casys/mcp-server";
58
+ import { McpApp } from "@casys/mcp-server";
55
59
 
56
- const server = new ConcurrentMCPServer({ name: "my-server", version: "1.0.0" });
60
+ const server = new McpApp({ name: "my-server", version: "1.0.0" });
57
61
 
58
62
  server.registerTool(
59
63
  {
@@ -74,12 +78,9 @@ await server.start();
74
78
  ### HTTP Server with Auth
75
79
 
76
80
  ```typescript
77
- import {
78
- ConcurrentMCPServer,
79
- createGoogleAuthProvider,
80
- } from "@casys/mcp-server";
81
+ import { createGoogleAuthProvider, McpApp } from "@casys/mcp-server";
81
82
 
82
- const server = new ConcurrentMCPServer({
83
+ const server = new McpApp({
83
84
  name: "my-api",
84
85
  version: "1.0.0",
85
86
  maxConcurrent: 10,
@@ -156,7 +157,8 @@ const timing: Middleware = async (ctx, next) => {
156
157
  server.use(timing);
157
158
  ```
158
159
 
159
- Built-in pipeline: `rate-limit → auth → custom → scope-check → validation → backpressure → handler`
160
+ Built-in pipeline:
161
+ `rate-limit → auth → custom → scope-check → validation → backpressure → handler`
160
162
 
161
163
  ### OAuth2 / JWT Auth
162
164
 
@@ -164,9 +166,9 @@ Four OIDC presets out of the box:
164
166
 
165
167
  ```typescript
166
168
  import {
167
- createGoogleAuthProvider, // Google OIDC
168
169
  createAuth0AuthProvider, // Auth0
169
170
  createGitHubAuthProvider, // GitHub Actions OIDC
171
+ createGoogleAuthProvider, // Google OIDC
170
172
  createOIDCAuthProvider, // Generic OIDC (Keycloak, Okta, etc.)
171
173
  } from "@casys/mcp-server";
172
174
 
@@ -191,7 +193,8 @@ const provider = new JwtAuthProvider({
191
193
  });
192
194
  ```
193
195
 
194
- Token verification is cached (SHA-256 hash → AuthInfo, TTL = min(token expiry, 5min)) to avoid redundant JWKS round-trips.
196
+ Token verification is cached (SHA-256 hash → AuthInfo, TTL = min(token expiry,
197
+ 5min)) to avoid redundant JWKS round-trips.
195
198
 
196
199
  ### YAML + Env Config
197
200
 
@@ -217,7 +220,9 @@ Priority: `programmatic > env vars > YAML > no auth`
217
220
 
218
221
  ### RFC 9728
219
222
 
220
- When auth is configured, the framework automatically exposes `GET /.well-known/oauth-protected-resource` per [RFC 9728](https://www.rfc-editor.org/rfc/rfc9728).
223
+ When auth is configured, the framework automatically exposes
224
+ `GET /.well-known/oauth-protected-resource` per
225
+ [RFC 9728](https://www.rfc-editor.org/rfc/rfc9728).
221
226
 
222
227
  ### Observability
223
228
 
@@ -272,7 +277,7 @@ Three backpressure strategies when the server is at capacity:
272
277
  | `reject` | Fail fast with immediate error |
273
278
 
274
279
  ```typescript
275
- new ConcurrentMCPServer({
280
+ new McpApp({
276
281
  maxConcurrent: 10,
277
282
  backpressureStrategy: "queue",
278
283
  });
@@ -283,7 +288,7 @@ new ConcurrentMCPServer({
283
288
  Sliding window rate limiter with per-client tracking:
284
289
 
285
290
  ```typescript
286
- new ConcurrentMCPServer({
291
+ new McpApp({
287
292
  rateLimit: {
288
293
  maxRequests: 100,
289
294
  windowMs: 60_000,
@@ -293,15 +298,19 @@ new ConcurrentMCPServer({
293
298
  });
294
299
  ```
295
300
 
296
- For HTTP endpoints, use `startHttp({ ipRateLimit: ... })` to rate limit by client IP (or custom key).
301
+ For HTTP endpoints, use `startHttp({ ipRateLimit: ... })` to rate limit by
302
+ client IP (or custom key).
297
303
 
298
304
  ### Security Best Practices (Tool Handlers)
299
305
 
300
306
  Tool handlers receive **untrusted JSON input**. Treat args as hostile:
301
307
 
302
- - **Define strict schemas**: `additionalProperties: false`, `minLength`, `pattern`, `enum`.
303
- - **Never pass raw args to a shell** (`Deno.Command`, `child_process.exec`). If you must, use an allowlist + argv array (no shell).
304
- - **Validate paths & resources**: allowlisted roots, deny `..`, restrict env access.
308
+ - **Define strict schemas**: `additionalProperties: false`, `minLength`,
309
+ `pattern`, `enum`.
310
+ - **Never pass raw args to a shell** (`Deno.Command`, `child_process.exec`). If
311
+ you must, use an allowlist + argv array (no shell).
312
+ - **Validate paths & resources**: allowlisted roots, deny `..`, restrict env
313
+ access.
305
314
  - **Prefer safe APIs**: parameterized DB queries, SDK methods, typed clients.
306
315
  - **Log sensitive actions**: file writes, network calls, admin ops.
307
316
 
@@ -310,7 +319,7 @@ Tool handlers receive **untrusted JSON input**. Treat args as hostile:
310
319
  Register interactive UIs as MCP resources:
311
320
 
312
321
  ```typescript
313
- import { ConcurrentMCPServer, MCP_APP_MIME_TYPE } from "@casys/mcp-server";
322
+ import { MCP_APP_MIME_TYPE, McpApp } from "@casys/mcp-server";
314
323
 
315
324
  server.registerResource(
316
325
  { uri: "ui://my-server/viewer", name: "Data Viewer" },
@@ -326,10 +335,15 @@ server.registerResource(
326
335
 
327
336
  ## API Reference
328
337
 
329
- ### ConcurrentMCPServer
338
+ ### McpApp
339
+
340
+ > **Note:** `ConcurrentMCPServer` and `ConcurrentServerOptions` remain exported
341
+ > as `@deprecated` aliases for backwards compatibility and will be removed in
342
+ > v1.0. New code should use `McpApp` / `McpAppOptions`. The aliases point to the
343
+ > exact same class — `instanceof` checks pass on both.
330
344
 
331
345
  ```typescript
332
- const server = new ConcurrentMCPServer(options: ConcurrentServerOptions);
346
+ const server = new McpApp(options: McpAppOptions);
333
347
 
334
348
  // Registration (before start)
335
349
  server.registerTool(tool, handler);
package/mod.ts CHANGED
@@ -1,33 +1,32 @@
1
1
  /**
2
- * MCP Concurrent Server Framework
2
+ * @casys/mcp-server Hono-style framework for MCP servers
3
3
  *
4
- * Production-ready MCP server framework with built-in concurrency control,
5
- * backpressure strategies, and optional sampling support.
4
+ * Production-ready MCP server framework with built-in middleware pipeline,
5
+ * auth, concurrency control, backpressure, and observability.
6
6
  *
7
- * Built on top of the official @modelcontextprotocol/sdk with added
8
- * production features for reliability and performance.
7
+ * Built on top of the official @modelcontextprotocol/sdk.
9
8
  *
10
9
  * @example
11
10
  * ```typescript
12
- * import { ConcurrentMCPServer } from "@casys/mcp-server";
11
+ * import { McpApp } from "@casys/mcp-server";
13
12
  *
14
- * const server = new ConcurrentMCPServer({
13
+ * const app = new McpApp({
15
14
  * name: "my-server",
16
15
  * version: "1.0.0",
17
16
  * maxConcurrent: 10,
18
17
  * backpressureStrategy: 'queue'
19
18
  * });
20
19
  *
21
- * server.registerTool(
20
+ * app.registerTool(
22
21
  * { name: "greet", description: "Greet someone", inputSchema: { type: "object" } },
23
22
  * (args) => `Hello, ${args.name}!`,
24
23
  * );
25
24
  *
26
25
  * // STDIO transport
27
- * await server.start();
26
+ * await app.start();
28
27
  *
29
28
  * // — or — HTTP transport with security-first defaults
30
- * const http = await server.startHttp({
29
+ * const http = await app.startHttp({
31
30
  * port: 3000,
32
31
  * maxBodyBytes: 1_048_576, // 1 MB (default)
33
32
  * corsOrigins: ["https://app.example.com"], // allowlist
@@ -39,8 +38,18 @@
39
38
  * @module @casys/mcp-server
40
39
  */
41
40
 
42
- // Main server class
43
- export { ConcurrentMCPServer } from "./src/concurrent-server.js";
41
+ // Main server class — canonical name
42
+ export { McpApp } from "./src/mcp-app.js";
43
+
44
+ /**
45
+ * @deprecated Use {@link McpApp} instead. `ConcurrentMCPServer` is kept as
46
+ * a re-export for backwards compatibility and will be removed in v1.0.
47
+ *
48
+ * Both names point to the exact same class — `McpApp === ConcurrentMCPServer`
49
+ * is true at runtime, and `instanceof` checks pass in both directions.
50
+ * Migration is a one-line import swap.
51
+ */
52
+ export { McpApp as ConcurrentMCPServer } from "./src/mcp-app.js";
44
53
 
45
54
  // Concurrency primitives
46
55
  export { RequestQueue } from "./src/concurrency/request-queue.js";
@@ -60,12 +69,12 @@ export { SamplingBridge } from "./src/sampling/sampling-bridge.js";
60
69
 
61
70
  // Type exports
62
71
  export type {
63
- ConcurrentServerOptions,
64
72
  HttpRateLimitContext,
65
73
  HttpRateLimitOptions,
66
74
  HttpServerInstance,
67
75
  // HTTP Server types
68
76
  HttpServerOptions,
77
+ McpAppOptions,
69
78
  // MCP Apps types (SEP-1865)
70
79
  MCPResource,
71
80
  MCPTool,
@@ -79,22 +88,36 @@ export type {
79
88
  SamplingClient,
80
89
  SamplingParams,
81
90
  SamplingResult,
91
+ StructuredToolResult,
92
+ ToolAnnotations,
93
+ ToolErrorMapper,
82
94
  ToolHandler,
83
95
  } from "./src/types.js";
84
96
 
97
+ /**
98
+ * @deprecated Use {@link McpAppOptions} instead. `ConcurrentServerOptions`
99
+ * is kept as a re-export for backwards compatibility and will be removed
100
+ * in v1.0.
101
+ */
102
+ export type { McpAppOptions as ConcurrentServerOptions } from "./src/types.js";
103
+
85
104
  // MCP Apps constants & viewer utilities
86
105
  export { MCP_APP_MIME_TYPE } from "./src/types.js";
87
106
  export type {
88
107
  RegisterViewersConfig,
89
108
  RegisterViewersSummary,
90
- } from "./src/concurrent-server.js";
109
+ } from "./src/mcp-app.js";
91
110
  export {
92
- resolveViewerDistPath,
93
111
  discoverViewers,
112
+ resolveViewerDistPath,
94
113
  } from "./src/ui/viewer-utils.js";
95
114
 
96
115
  // MCP Compose — UI composition helpers (re-exported from @casys/mcp-compose)
97
- export { composeEvents, COMPOSE_EVENT_METHOD, uiMeta } from "@casys/mcp-compose/sdk";
116
+ export {
117
+ COMPOSE_EVENT_METHOD,
118
+ composeEvents,
119
+ uiMeta,
120
+ } from "@casys/mcp-compose/sdk";
98
121
  export type {
99
122
  ComposeEventHandler,
100
123
  ComposeEventPayload,
@@ -131,6 +154,14 @@ export type {
131
154
  ProtectedResourceMetadata,
132
155
  } from "./src/auth/mod.js";
133
156
 
157
+ // Auth - Multi-tenant resolution
158
+ export { createMultiTenantMiddleware } from "./src/auth/mod.js";
159
+ export type {
160
+ MultiTenantMiddlewareOptions,
161
+ TenantResolution,
162
+ TenantResolver,
163
+ } from "./src/auth/mod.js";
164
+
134
165
  // Auth - JWT Provider + Presets
135
166
  export { JwtAuthProvider } from "./src/auth/mod.js";
136
167
  export type { JwtAuthProviderOptions } from "./src/auth/mod.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@casys/mcp-server",
3
- "version": "0.11.0",
3
+ "version": "0.13.0",
4
4
  "description": "Production-ready MCP server framework with concurrency control, auth, and observability",
5
5
  "type": "module",
6
6
  "main": "mod.ts",
package/src/auth/mod.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Auth module for ConcurrentMCPServer.
2
+ * Auth module for McpApp.
3
3
  *
4
4
  * @module lib/server/auth
5
5
  */
@@ -26,6 +26,14 @@ export {
26
26
  // Scope enforcement
27
27
  export { createScopeMiddleware } from "./scope-middleware.js";
28
28
 
29
+ // Multi-tenant resolution (tenant enforcement on top of auth)
30
+ export { createMultiTenantMiddleware } from "./multi-tenant-middleware.js";
31
+ export type {
32
+ MultiTenantMiddlewareOptions,
33
+ TenantResolution,
34
+ TenantResolver,
35
+ } from "./multi-tenant-middleware.js";
36
+
29
37
  // JWT Provider
30
38
  export { JwtAuthProvider } from "./jwt-provider.js";
31
39
  export type { JwtAuthProviderOptions } from "./jwt-provider.js";
@@ -0,0 +1,236 @@
1
+ /**
2
+ * Multi-tenant resolution middleware for MCP servers.
3
+ *
4
+ * Extends the auth pipeline with tenant identification and validation.
5
+ * Consumers provide a {@link TenantResolver} that knows how to map an
6
+ * authenticated request to a tenant identifier — typically by combining
7
+ * the HTTP Host header (subdomain-based tenancy) with a custom JWT claim.
8
+ *
9
+ * The resolved `tenantId` is injected into `ctx.authInfo.tenantId` so
10
+ * downstream middlewares and tool handlers can scope data access.
11
+ *
12
+ * This middleware is **optional**. Single-tenant servers do not need it.
13
+ * When used, it MUST be placed AFTER the auth middleware (which populates
14
+ * `ctx.authInfo`) and BEFORE the scope middleware and tool handlers.
15
+ *
16
+ * @example Pipeline wiring
17
+ * ```typescript
18
+ * server.use(createAuthMiddleware(authProvider));
19
+ * server.use(createMultiTenantMiddleware(new MyTenantResolver(), {
20
+ * onRejection: (ctx, reason) => auditLog.warn("tenant_reject", { reason }),
21
+ * }));
22
+ * server.use(createScopeMiddleware(toolScopes));
23
+ * ```
24
+ *
25
+ * @module lib/server/auth/multi-tenant-middleware
26
+ */
27
+
28
+ import type { Middleware, MiddlewareContext } from "../middleware/types.js";
29
+ import type { AuthInfo } from "./types.js";
30
+ import { AuthError } from "./middleware.js";
31
+
32
+ /**
33
+ * Result of a tenant resolution attempt.
34
+ *
35
+ * Use `{ ok: true, tenantId }` when the request is valid and the tenant
36
+ * has been identified. Use `{ ok: false, reason }` to reject the request
37
+ * (the reason is passed to `onRejection` for logging but NEVER returned
38
+ * to the client, to avoid leaking tenant topology).
39
+ */
40
+ export type TenantResolution =
41
+ | { ok: true; tenantId: string }
42
+ | { ok: false; reason: string };
43
+
44
+ /**
45
+ * Resolves and validates the tenant for an incoming MCP request.
46
+ *
47
+ * Implementations typically:
48
+ * 1. Read a tenant hint from the request (subdomain, path, header, …)
49
+ * 2. Read the authoritative tenant from `ctx.authInfo.claims`
50
+ * 3. Validate they match
51
+ * 4. Return the tenant identifier, or a rejection reason
52
+ *
53
+ * @example Subdomain + JWT claim matching
54
+ * ```typescript
55
+ * class SubdomainTenantResolver implements TenantResolver {
56
+ * async resolve(ctx: MiddlewareContext): Promise<TenantResolution> {
57
+ * const host = ctx.request!.headers.get("host") ?? "";
58
+ * const subdomain = host.split(".")[0];
59
+ *
60
+ * const authInfo = ctx.authInfo as AuthInfo;
61
+ * const claim = authInfo.claims?.["urn:my-app:tenant_id"];
62
+ *
63
+ * if (typeof claim !== "string") {
64
+ * return { ok: false, reason: "tenant_id claim missing or not a string" };
65
+ * }
66
+ * if (claim !== subdomain) {
67
+ * return { ok: false, reason: `subdomain=${subdomain} claim=${claim}` };
68
+ * }
69
+ * return { ok: true, tenantId: subdomain };
70
+ * }
71
+ * }
72
+ * ```
73
+ */
74
+ export interface TenantResolver {
75
+ /**
76
+ * Resolve the tenant for this request.
77
+ *
78
+ * @param ctx - Middleware context with `ctx.request` and `ctx.authInfo` populated
79
+ * @returns A successful resolution with the tenant id, or a rejection with a reason
80
+ * @throws May throw — throwing is treated identically to returning `{ ok: false }`
81
+ */
82
+ resolve(ctx: MiddlewareContext): Promise<TenantResolution>;
83
+ }
84
+
85
+ /**
86
+ * Options for {@link createMultiTenantMiddleware}.
87
+ */
88
+ export interface MultiTenantMiddlewareOptions {
89
+ /**
90
+ * Called whenever a request is rejected — either because the resolver
91
+ * returned `{ ok: false }`, threw an exception, or returned an empty
92
+ * `tenantId`.
93
+ *
94
+ * Typical use: write an audit log entry for compliance / forensics.
95
+ * The `reason` string may contain sensitive details (tenant ids, claim
96
+ * values) — log it server-side but never expose it to clients.
97
+ *
98
+ * Awaited before the client receives the 401 response, so audit writes
99
+ * are guaranteed to land before the error is observable. Keep it fast —
100
+ * slow callbacks delay the rejection.
101
+ *
102
+ * If this hook itself throws, the exception is **caught and logged to
103
+ * stderr** but NOT rethrown — the client still receives the standard
104
+ * generic `invalid_token` AuthError. A crashing audit hook must never
105
+ * change client-visible behaviour, otherwise a buggy audit path becomes
106
+ * an observable oracle for attackers probing tenant topology.
107
+ */
108
+ onRejection?: (
109
+ ctx: MiddlewareContext,
110
+ reason: string,
111
+ ) => void | Promise<void>;
112
+ }
113
+
114
+ /**
115
+ * Create a tenant resolution middleware.
116
+ *
117
+ * Pipeline behaviour:
118
+ *
119
+ * - **STDIO transport** (no `ctx.request`) → pass through unchanged. STDIO
120
+ * is a local trusted transport with no meaningful notion of tenant.
121
+ *
122
+ * - **HTTP without `ctx.authInfo`** → throws a configuration error. This
123
+ * indicates the auth middleware is missing from the pipeline. Fail fast
124
+ * rather than silently skipping tenant enforcement.
125
+ *
126
+ * - **HTTP with `ctx.authInfo`** → calls `resolver.resolve(ctx)`:
127
+ * - On success with a non-empty `tenantId`: copies `authInfo`, injects
128
+ * `tenantId`, re-freezes, continues.
129
+ * - On rejection (`ok: false`, thrown, or empty `tenantId`): calls
130
+ * `onRejection`, then throws {@link AuthError}`("invalid_token")` —
131
+ * the client sees a generic 401 with no tenant details leaked.
132
+ *
133
+ * @param resolver - Implementation that maps requests to tenant ids
134
+ * @param options - Optional hooks (notably `onRejection` for audit logging)
135
+ */
136
+ export function createMultiTenantMiddleware(
137
+ resolver: TenantResolver,
138
+ options: MultiTenantMiddlewareOptions = {},
139
+ ): Middleware {
140
+ return async (ctx, next) => {
141
+ // STDIO transport: no request, tenant routing does not apply
142
+ if (!ctx.request) {
143
+ return next();
144
+ }
145
+
146
+ // Auth middleware MUST have populated authInfo before we run.
147
+ // MiddlewareContext is indexed as `[key: string]: unknown` for extensibility,
148
+ // so every auth-aware middleware in this lib casts to the concrete type —
149
+ // matches the convention in middleware.ts and scope-middleware.ts.
150
+ const authInfo = ctx.authInfo as AuthInfo | undefined;
151
+ if (!authInfo) {
152
+ throw new Error(
153
+ "[MultiTenantMiddleware] ctx.authInfo is not set. Ensure auth " +
154
+ "middleware is placed BEFORE multi-tenant middleware in the pipeline.",
155
+ );
156
+ }
157
+
158
+ // Resolve the tenant — any failure path collapses to a generic 401
159
+ let resolution: TenantResolution;
160
+ try {
161
+ resolution = await resolver.resolve(ctx);
162
+ } catch (err) {
163
+ const reason = err instanceof Error ? err.message : String(err);
164
+ throw await rejectWithAudit(ctx, reason, options.onRejection);
165
+ }
166
+
167
+ if (!resolution.ok) {
168
+ throw await rejectWithAudit(ctx, resolution.reason, options.onRejection);
169
+ }
170
+
171
+ // Defense-in-depth: reject empty string tenantId even when the resolver
172
+ // reports success. Some consumers use truthy guards (`if (tenantId)`),
173
+ // and an empty string would slip past them without being noticed.
174
+ if (!resolution.tenantId) {
175
+ throw await rejectWithAudit(
176
+ ctx,
177
+ "resolver returned empty tenantId",
178
+ options.onRejection,
179
+ );
180
+ }
181
+
182
+ // Re-freeze authInfo with tenantId injected. Two reasons for the re-freeze:
183
+ // 1. Preserves the immutability guarantee downstream middleware and
184
+ // tool handlers rely on — they must never observe a mutable tenantId.
185
+ // 2. The original authInfo is already frozen by the auth middleware,
186
+ // so in-place mutation is impossible anyway. We must spread-copy.
187
+ ctx.authInfo = Object.freeze({
188
+ ...authInfo,
189
+ tenantId: resolution.tenantId,
190
+ });
191
+
192
+ return next();
193
+ };
194
+ }
195
+
196
+ /**
197
+ * Run the audit hook (if any) and return a generic `invalid_token` AuthError.
198
+ *
199
+ * The hook is intentionally shielded with try/catch: a crashing audit hook
200
+ * must NEVER change the client-visible response. If the hook throws, the
201
+ * audit is lost (logged to stderr as a last resort) but the client still
202
+ * receives the standard 401 AuthError — preserving the non-leak guarantee.
203
+ *
204
+ * @internal
205
+ */
206
+ async function rejectWithAudit(
207
+ ctx: MiddlewareContext,
208
+ reason: string,
209
+ onRejection: MultiTenantMiddlewareOptions["onRejection"],
210
+ ): Promise<AuthError> {
211
+ if (onRejection) {
212
+ try {
213
+ await onRejection(ctx, reason);
214
+ } catch (hookErr) {
215
+ // Last-resort logging: stderr is the only safe channel here — we must
216
+ // not rethrow (would defeat the non-leak guarantee) and we must not
217
+ // silently drop (would leave no trace of an audit failure).
218
+ console.error(
219
+ "[MultiTenantMiddleware] onRejection hook threw; audit entry lost:",
220
+ hookErr,
221
+ );
222
+ }
223
+ }
224
+ return buildAuthError(ctx);
225
+ }
226
+
227
+ /**
228
+ * Build a generic `invalid_token` AuthError using the resource metadata URL
229
+ * already set by the upstream auth middleware. If the URL is missing (should
230
+ * not happen in a well-formed pipeline), fall back to an empty string — the
231
+ * AuthError still produces a valid 401 response.
232
+ */
233
+ function buildAuthError(ctx: MiddlewareContext): AuthError {
234
+ const metadataUrl = (ctx.resourceMetadataUrl as string | undefined) ?? "";
235
+ return new AuthError("invalid_token", metadataUrl);
236
+ }
package/src/auth/types.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Authentication types for ConcurrentMCPServer.
2
+ * Authentication types for McpApp.
3
3
  *
4
4
  * Types follow RFC 9728 (OAuth Protected Resource Metadata)
5
5
  * and MCP Auth spec (draft 2025-11-25).
@@ -26,6 +26,17 @@ export interface AuthInfo {
26
26
 
27
27
  /** Token expiration timestamp (Unix epoch seconds) */
28
28
  expiresAt?: number;
29
+
30
+ /**
31
+ * Tenant identifier for multi-tenant servers.
32
+ *
33
+ * Populated by `createMultiTenantMiddleware` when a `TenantResolver`
34
+ * is configured. Undefined on single-tenant servers.
35
+ *
36
+ * Tool handlers in multi-tenant servers should read this (never trust
37
+ * raw JWT claims directly) to scope data access to the current tenant.
38
+ */
39
+ tenantId?: string;
29
40
  }
30
41
 
31
42
  /**
@@ -54,8 +54,12 @@ export async function launchInspector(
54
54
  CLIENT_PORT: String(port),
55
55
  };
56
56
 
57
- console.error(`[mcp-inspector] Starting inspector on http://localhost:${port}`);
58
- console.error(`[mcp-inspector] Server: ${serverCommand} ${filteredArgs.join(" ")}`);
57
+ console.error(
58
+ `[mcp-inspector] Starting inspector on http://localhost:${port}`,
59
+ );
60
+ console.error(
61
+ `[mcp-inspector] Server: ${serverCommand} ${filteredArgs.join(" ")}`,
62
+ );
59
63
 
60
64
  // Use npx to run the inspector
61
65
  const command = new Deno.Command("npx", {