@casys/mcp-server 0.12.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,18 +69,17 @@ 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,
72
81
  MCPToolMeta,
73
82
  McpUiToolMeta,
74
- ToolAnnotations,
75
83
  QueueMetrics,
76
84
  RateLimitContext,
77
85
  RateLimitOptions,
@@ -81,23 +89,35 @@ export type {
81
89
  SamplingParams,
82
90
  SamplingResult,
83
91
  StructuredToolResult,
92
+ ToolAnnotations,
84
93
  ToolErrorMapper,
85
94
  ToolHandler,
86
95
  } from "./src/types.js";
87
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
+
88
104
  // MCP Apps constants & viewer utilities
89
105
  export { MCP_APP_MIME_TYPE } from "./src/types.js";
90
106
  export type {
91
107
  RegisterViewersConfig,
92
108
  RegisterViewersSummary,
93
- } from "./src/concurrent-server.js";
109
+ } from "./src/mcp-app.js";
94
110
  export {
95
- resolveViewerDistPath,
96
111
  discoverViewers,
112
+ resolveViewerDistPath,
97
113
  } from "./src/ui/viewer-utils.js";
98
114
 
99
115
  // MCP Compose — UI composition helpers (re-exported from @casys/mcp-compose)
100
- 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";
101
121
  export type {
102
122
  ComposeEventHandler,
103
123
  ComposeEventPayload,
@@ -134,6 +154,14 @@ export type {
134
154
  ProtectedResourceMetadata,
135
155
  } from "./src/auth/mod.js";
136
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
+
137
165
  // Auth - JWT Provider + Presets
138
166
  export { JwtAuthProvider } from "./src/auth/mod.js";
139
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.12.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", {
@@ -1,21 +1,23 @@
1
1
  /**
2
- * Concurrent MCP Server Framework
2
+ * McpApp — Hono-style framework for MCP servers
3
3
  *
4
4
  * High-performance MCP server with built-in concurrency control,
5
5
  * backpressure, and optional sampling support.
6
6
  *
7
7
  * Wraps the official @modelcontextprotocol/sdk with production-ready
8
- * concurrency features.
8
+ * middleware, auth, and observability features.
9
9
  *
10
- * @module lib/server/concurrent-server
10
+ * @module lib/server/mcp-app
11
11
  */
12
12
 
13
13
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
14
14
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
15
15
  import {
16
+ type CallToolRequest,
16
17
  CallToolRequestSchema,
17
- ListToolsRequestSchema,
18
18
  ListResourcesRequestSchema,
19
+ ListToolsRequestSchema,
20
+ type ReadResourceRequest,
19
21
  ReadResourceRequestSchema,
20
22
  } from "@modelcontextprotocol/sdk/types.js";
21
23
  import { Hono } from "hono";
@@ -45,9 +47,10 @@ import { createScopeMiddleware } from "./auth/scope-middleware.js";
45
47
  import { createAuthProviderFromConfig, loadAuthConfig } from "./auth/config.js";
46
48
  import type { AuthProvider } from "./auth/provider.js";
47
49
  import type {
48
- ConcurrentServerOptions,
50
+ FetchHandler,
49
51
  HttpRateLimitContext,
50
52
  HttpServerOptions,
53
+ McpAppOptions,
51
54
  MCPResource,
52
55
  MCPTool,
53
56
  QueueMetrics,
@@ -57,7 +60,7 @@ import type {
57
60
  ToolHandler,
58
61
  } from "./types.js";
59
62
  import { MCP_APP_MIME_TYPE, MCP_APP_URI_SCHEME } from "./types.js";
60
- import { resolveViewerDistPath, discoverViewers } from "./ui/viewer-utils.js";
63
+ import { discoverViewers, resolveViewerDistPath } from "./ui/viewer-utils.js";
61
64
  import type { DirEntry, DiscoverViewersFS } from "./ui/viewer-utils.js";
62
65
  import { buildCspHeader, injectCspMetaTag } from "./security/csp.js";
63
66
  import { ServerMetrics } from "./observability/metrics.js";
@@ -157,7 +160,7 @@ async function readBodyWithLimit(
157
160
  }
158
161
 
159
162
  /**
160
- * ConcurrentMCPServer provides a high-performance MCP server
163
+ * McpApp provides a high-performance MCP server
161
164
  *
162
165
  * Features:
163
166
  * - Wraps official @modelcontextprotocol/sdk
@@ -169,7 +172,7 @@ async function readBodyWithLimit(
169
172
  *
170
173
  * @example
171
174
  * ```typescript
172
- * const server = new ConcurrentMCPServer({
175
+ * const server = new McpApp({
173
176
  * name: "my-server",
174
177
  * version: "1.0.0",
175
178
  * maxConcurrent: 5,
@@ -180,7 +183,7 @@ async function readBodyWithLimit(
180
183
  * await server.start();
181
184
  * ```
182
185
  */
183
- export class ConcurrentMCPServer {
186
+ export class McpApp {
184
187
  private mcpServer: McpServer;
185
188
  private requestQueue: RequestQueue;
186
189
  private rateLimiter: RateLimiter | null = null;
@@ -188,7 +191,7 @@ export class ConcurrentMCPServer {
188
191
  private samplingBridge: SamplingBridge | null = null;
189
192
  private tools = new Map<string, ToolWithHandler>();
190
193
  private resources = new Map<string, RegisteredResourceInfo>();
191
- private options: ConcurrentServerOptions;
194
+ private options: McpAppOptions;
192
195
  private started = false;
193
196
  private resourceHandlersInstalled = false;
194
197
 
@@ -222,7 +225,7 @@ export class ConcurrentMCPServer {
222
225
  windowMs: 60_000,
223
226
  });
224
227
 
225
- constructor(options: ConcurrentServerOptions) {
228
+ constructor(options: McpAppOptions) {
226
229
  this.options = options;
227
230
 
228
231
  // Create SDK MCP server
@@ -300,26 +303,29 @@ export class ConcurrentMCPServer {
300
303
  });
301
304
 
302
305
  // resources/read — serve resource content by URI
303
- server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
304
- const uri = request.params.uri;
305
- const info = this.resources.get(uri);
306
- if (!info) {
307
- throw new Error(`Resource not found: ${uri}`);
308
- }
306
+ server.setRequestHandler(
307
+ ReadResourceRequestSchema,
308
+ async (request: ReadResourceRequest) => {
309
+ const uri = request.params.uri;
310
+ const info = this.resources.get(uri);
311
+ if (!info) {
312
+ throw new Error(`Resource not found: ${uri}`);
313
+ }
309
314
 
310
- try {
311
- const content = await info.handler(new URL(uri));
312
- const finalContent = this.applyResourceCsp(content);
313
- return { contents: [finalContent] };
314
- } catch (error) {
315
- this.log(
316
- `[ERROR] Resource handler failed for ${uri}: ${
317
- error instanceof Error ? error.message : String(error)
318
- }`,
319
- );
320
- throw error;
321
- }
322
- });
315
+ try {
316
+ const content = await info.handler(new URL(uri));
317
+ const finalContent = this.applyResourceCsp(content);
318
+ return { contents: [finalContent] };
319
+ } catch (error) {
320
+ this.log(
321
+ `[ERROR] Resource handler failed for ${uri}: ${
322
+ error instanceof Error ? error.message : String(error)
323
+ }`,
324
+ );
325
+ throw error;
326
+ }
327
+ },
328
+ );
323
329
 
324
330
  this.resourceHandlersInstalled = true;
325
331
  this.log("Resources capability pre-declared (expectResources: true)");
@@ -342,20 +348,24 @@ export class ConcurrentMCPServer {
342
348
  });
343
349
 
344
350
  // tools/call handler (delegates to middleware pipeline)
345
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
346
- const toolName = request.params.name;
347
- const args = request.params.arguments || {};
351
+ server.setRequestHandler(
352
+ CallToolRequestSchema,
353
+ async (request: CallToolRequest) => {
354
+ const toolName = request.params.name;
355
+ const args = request.params.arguments || {};
348
356
 
349
- let result: unknown;
350
- try {
351
- result = await this.executeToolCall(toolName, args);
352
- } catch (error) {
353
- return this.handleToolError(error, toolName);
354
- }
357
+ let result: unknown;
358
+ try {
359
+ result = await this.executeToolCall(toolName, args);
360
+ } catch (error) {
361
+ return this.handleToolError(error, toolName);
362
+ }
355
363
 
356
- // Serialization errors are framework bugs, not tool errors — let them propagate
357
- return this.buildToolCallResult(toolName, result);
358
- });
364
+ // Serialization errors are framework bugs, not tool errors —
365
+ // let them propagate
366
+ return this.buildToolCallResult(toolName, result);
367
+ },
368
+ );
359
369
  }
360
370
 
361
371
  /**
@@ -370,7 +380,7 @@ export class ConcurrentMCPServer {
370
380
  ): void {
371
381
  if (this.started) {
372
382
  throw new Error(
373
- "[ConcurrentMCPServer] Cannot register tools after server started. " +
383
+ "[McpApp] Cannot register tools after server started. " +
374
384
  "Call registerTools() before start() or startHttp().",
375
385
  );
376
386
  }
@@ -403,7 +413,7 @@ export class ConcurrentMCPServer {
403
413
  registerTool(tool: MCPTool, handler: ToolHandler): void {
404
414
  if (this.started) {
405
415
  throw new Error(
406
- "[ConcurrentMCPServer] Cannot register tools after server started. " +
416
+ "[McpApp] Cannot register tools after server started. " +
407
417
  "Call registerTool() before start() or startHttp().",
408
418
  );
409
419
  }
@@ -481,7 +491,9 @@ export class ConcurrentMCPServer {
481
491
  unregisterTool(toolName: string): boolean {
482
492
  const deleted = this.tools.delete(toolName);
483
493
  if (deleted) {
484
- this.log(`Unregistered tool: ${toolName} (remaining: ${this.tools.size})`);
494
+ this.log(
495
+ `Unregistered tool: ${toolName} (remaining: ${this.tools.size})`,
496
+ );
485
497
  }
486
498
  return deleted;
487
499
  }
@@ -513,7 +525,7 @@ export class ConcurrentMCPServer {
513
525
  use(middleware: Middleware): this {
514
526
  if (this.started) {
515
527
  throw new Error(
516
- "[ConcurrentMCPServer] Cannot add middleware after server started. " +
528
+ "[McpApp] Cannot add middleware after server started. " +
517
529
  "Call use() before start() or startHttp().",
518
530
  );
519
531
  }
@@ -593,7 +605,7 @@ export class ConcurrentMCPServer {
593
605
  ): Promise<MiddlewareResult> {
594
606
  if (!this.middlewareRunner) {
595
607
  throw new Error(
596
- "[ConcurrentMCPServer] Pipeline not built. Call start() or startHttp() first.",
608
+ "[McpApp] Pipeline not built. Call start() or startHttp() first.",
597
609
  );
598
610
  }
599
611
 
@@ -698,7 +710,7 @@ export class ConcurrentMCPServer {
698
710
  // Check for duplicate
699
711
  if (this.resources.has(resource.uri)) {
700
712
  throw new Error(
701
- `[ConcurrentMCPServer] Resource already registered: ${resource.uri}`,
713
+ `[McpApp] Resource already registered: ${resource.uri}`,
702
714
  );
703
715
  }
704
716
 
@@ -760,7 +772,7 @@ export class ConcurrentMCPServer {
760
772
 
761
773
  if (missingHandlers.length > 0) {
762
774
  throw new Error(
763
- `[ConcurrentMCPServer] Missing handlers for resources:\n` +
775
+ `[McpApp] Missing handlers for resources:\n` +
764
776
  missingHandlers.map((uri) => ` - ${uri}`).join("\n"),
765
777
  );
766
778
  }
@@ -775,7 +787,7 @@ export class ConcurrentMCPServer {
775
787
 
776
788
  if (duplicateUris.length > 0) {
777
789
  throw new Error(
778
- `[ConcurrentMCPServer] Resources already registered:\n` +
790
+ `[McpApp] Resources already registered:\n` +
779
791
  duplicateUris.map((uri) => ` - ${uri}`).join("\n"),
780
792
  );
781
793
  }
@@ -786,7 +798,7 @@ export class ConcurrentMCPServer {
786
798
  if (!handler) {
787
799
  // Should never happen after validation, but defensive check
788
800
  throw new Error(
789
- `[ConcurrentMCPServer] Handler disappeared for ${resource.uri}`,
801
+ `[McpApp] Handler disappeared for ${resource.uri}`,
790
802
  );
791
803
  }
792
804
  this.registerResource(resource, handler);
@@ -808,7 +820,9 @@ export class ConcurrentMCPServer {
808
820
  */
809
821
  registerViewers(config: RegisterViewersConfig): RegisterViewersSummary {
810
822
  if (!config.prefix) {
811
- throw new Error("[ConcurrentMCPServer] registerViewers: prefix is required");
823
+ throw new Error(
824
+ "[McpApp] registerViewers: prefix is required",
825
+ );
812
826
  }
813
827
 
814
828
  // Resolve viewer list: explicit or auto-discovered
@@ -826,7 +840,11 @@ export class ConcurrentMCPServer {
826
840
  const skipped: string[] = [];
827
841
 
828
842
  for (const viewerName of viewerNames) {
829
- const distPath = resolveViewerDistPath(config.moduleUrl, viewerName, config.exists);
843
+ const distPath = resolveViewerDistPath(
844
+ config.moduleUrl,
845
+ viewerName,
846
+ config.exists,
847
+ );
830
848
 
831
849
  if (!distPath) {
832
850
  this.log(
@@ -856,7 +874,9 @@ export class ConcurrentMCPServer {
856
874
  text: html,
857
875
  };
858
876
  if (config.csp) {
859
- (content as unknown as Record<string, unknown>)._meta = { ui: { csp: config.csp } };
877
+ (content as unknown as Record<string, unknown>)._meta = {
878
+ ui: { csp: config.csp },
879
+ };
860
880
  }
861
881
  return content;
862
882
  },
@@ -866,7 +886,9 @@ export class ConcurrentMCPServer {
866
886
  }
867
887
 
868
888
  if (registered.length > 0) {
869
- this.log(`Registered ${registered.length} viewer(s): ${registered.join(", ")}`);
889
+ this.log(
890
+ `Registered ${registered.length} viewer(s): ${registered.join(", ")}`,
891
+ );
870
892
  }
871
893
 
872
894
  return { registered, skipped };
@@ -911,8 +933,8 @@ export class ConcurrentMCPServer {
911
933
  */
912
934
  private cleanupSessions(): void {
913
935
  const now = Date.now();
914
- const ttlWithGrace = ConcurrentMCPServer.SESSION_TTL_MS +
915
- ConcurrentMCPServer.SESSION_GRACE_PERIOD_MS;
936
+ const ttlWithGrace = McpApp.SESSION_TTL_MS +
937
+ McpApp.SESSION_GRACE_PERIOD_MS;
916
938
  let cleaned = 0;
917
939
  for (const [sessionId, session] of this.sessions) {
918
940
  if (now - session.lastActivity > ttlWithGrace) {
@@ -998,7 +1020,7 @@ export class ConcurrentMCPServer {
998
1020
  *
999
1021
  * @example
1000
1022
  * ```typescript
1001
- * const server = new ConcurrentMCPServer({ name: "my-server", version: "1.0.0" });
1023
+ * const server = new McpApp({ name: "my-server", version: "1.0.0" });
1002
1024
  * server.registerTools(tools, handlers);
1003
1025
  * server.registerResource(resource, handler);
1004
1026
  *
@@ -1038,7 +1060,7 @@ export class ConcurrentMCPServer {
1038
1060
  const requireAuth = options.requireAuth ?? false;
1039
1061
  if (requireAuth && !this.authProvider) {
1040
1062
  throw new Error(
1041
- "[ConcurrentMCPServer] HTTP auth is required (requireAuth=true) but no auth provider is configured.",
1063
+ "[McpApp] HTTP auth is required (requireAuth=true) but no auth provider is configured.",
1042
1064
  );
1043
1065
  }
1044
1066
  if (!this.authProvider && !requireAuth) {
@@ -1411,9 +1433,9 @@ export class ConcurrentMCPServer {
1411
1433
  }
1412
1434
 
1413
1435
  // Guard against session exhaustion
1414
- if (this.sessions.size >= ConcurrentMCPServer.MAX_SESSIONS) {
1436
+ if (this.sessions.size >= McpApp.MAX_SESSIONS) {
1415
1437
  this.cleanupSessions();
1416
- if (this.sessions.size >= ConcurrentMCPServer.MAX_SESSIONS) {
1438
+ if (this.sessions.size >= McpApp.MAX_SESSIONS) {
1417
1439
  return c.json({
1418
1440
  jsonrpc: "2.0",
1419
1441
  id,
@@ -1442,7 +1464,9 @@ export class ConcurrentMCPServer {
1442
1464
  name: this.options.name,
1443
1465
  version: this.options.version,
1444
1466
  },
1445
- ...(this.options.instructions ? { instructions: this.options.instructions } : {}),
1467
+ ...(this.options.instructions
1468
+ ? { instructions: this.options.instructions }
1469
+ : {}),
1446
1470
  },
1447
1471
  }),
1448
1472
  {
@@ -1513,7 +1537,9 @@ export class ConcurrentMCPServer {
1513
1537
  } catch (rethrown) {
1514
1538
  this.log(
1515
1539
  `Error executing tool ${toolName}: ${
1516
- rethrown instanceof Error ? rethrown.message : String(rethrown)
1540
+ rethrown instanceof Error
1541
+ ? rethrown.message
1542
+ : String(rethrown)
1517
1543
  }`,
1518
1544
  );
1519
1545
  const errorMessage = rethrown instanceof Error
@@ -1663,6 +1689,37 @@ export class ConcurrentMCPServer {
1663
1689
  // deno-lint-ignore no-explicit-any
1664
1690
  app.post("/", handleMcpPost as any);
1665
1691
 
1692
+ // Embedded mode: skip serve(), surface the Hono fetch handler to the
1693
+ // caller and let them mount it inside their own framework (Fresh, Hono,
1694
+ // Express, etc.). The session cleanup timer + post-init still runs so
1695
+ // SSE clients and sessions are managed identically to the serve() path.
1696
+ if (options.embedded) {
1697
+ if (!options.embeddedHandlerCallback) {
1698
+ throw new Error(
1699
+ "[McpApp] embedded=true requires embeddedHandlerCallback",
1700
+ );
1701
+ }
1702
+ // deno-lint-ignore no-explicit-any
1703
+ options.embeddedHandlerCallback(app.fetch as any);
1704
+ this.started = true;
1705
+ this.sessionCleanupTimer = setInterval(
1706
+ () => this.cleanupSessions(),
1707
+ McpApp.SESSION_CLEANUP_INTERVAL_MS,
1708
+ );
1709
+ unrefTimer(this.sessionCleanupTimer as unknown as number);
1710
+ this.log(
1711
+ `HTTP handler ready (embedded mode — no port bound, max concurrent: ${
1712
+ this.options.maxConcurrent ?? 10
1713
+ })`,
1714
+ );
1715
+ return {
1716
+ shutdown: async () => {
1717
+ await this.stop();
1718
+ },
1719
+ addr: { hostname: "embedded", port: 0 },
1720
+ };
1721
+ }
1722
+
1666
1723
  // Start server
1667
1724
  this.httpServer = serve(
1668
1725
  {
@@ -1683,7 +1740,7 @@ export class ConcurrentMCPServer {
1683
1740
  // Start session cleanup timer (prevents unbounded memory growth)
1684
1741
  this.sessionCleanupTimer = setInterval(
1685
1742
  () => this.cleanupSessions(),
1686
- ConcurrentMCPServer.SESSION_CLEANUP_INTERVAL_MS,
1743
+ McpApp.SESSION_CLEANUP_INTERVAL_MS,
1687
1744
  );
1688
1745
  // Don't block Deno from exiting because of cleanup timer
1689
1746
  unrefTimer(this.sessionCleanupTimer as unknown as number);
@@ -1714,6 +1771,67 @@ export class ConcurrentMCPServer {
1714
1771
  };
1715
1772
  }
1716
1773
 
1774
+ /**
1775
+ * Build the HTTP middleware stack and return its fetch handler without
1776
+ * binding a port. Use this when you want to mount the MCP HTTP layer
1777
+ * inside another HTTP framework (Fresh, Hono, Express, Cloudflare Workers,
1778
+ * etc.) instead of giving up port ownership to {@link startHttp}.
1779
+ *
1780
+ * The returned handler accepts a Web Standard {@link Request} and returns
1781
+ * a Web Standard {@link Response}. It exposes the same routes as
1782
+ * {@link startHttp}: `POST /mcp`, `GET /mcp` (SSE), `GET /health`,
1783
+ * `GET /metrics`, and `GET /.well-known/oauth-protected-resource`.
1784
+ *
1785
+ * Auth, multi-tenant middleware, scope checks, and rate limiting are all
1786
+ * applied identically. The session cleanup timer and OTel hooks are
1787
+ * started, so the server is fully live after this returns — just without
1788
+ * its own listening socket.
1789
+ *
1790
+ * Multi-tenant SaaS pattern: cache one `McpApp` per tenant
1791
+ * and call `getFetchHandler()` once per server, then dispatch each
1792
+ * inbound request to the right cached handler from your framework's
1793
+ * routing layer.
1794
+ *
1795
+ * @example
1796
+ * ```typescript
1797
+ * // In a Fresh route at routes/mcp/[...path].tsx
1798
+ * const server = new McpApp({ name: "my-mcp", version: "1.0.0" });
1799
+ * server.registerTools(tools, handlers);
1800
+ * const handler = await server.getFetchHandler({
1801
+ * requireAuth: true,
1802
+ * auth: { provider: myAuthProvider },
1803
+ * });
1804
+ * // Later, in your route handler:
1805
+ * return await handler(ctx.req);
1806
+ * ```
1807
+ *
1808
+ * @param options - Same as {@link startHttp}, minus `port`/`hostname`/`onListen`.
1809
+ * @returns A Web Standard fetch handler.
1810
+ */
1811
+ async getFetchHandler(
1812
+ options: Omit<HttpServerOptions, "port" | "hostname" | "onListen"> = {},
1813
+ ): Promise<FetchHandler> {
1814
+ let captured: FetchHandler | null = null;
1815
+ await this.startHttp({
1816
+ // port/hostname are unused in embedded mode but the type requires them.
1817
+ // Pass sentinel values that would never bind even if used.
1818
+ port: 0,
1819
+ ...options,
1820
+ embedded: true,
1821
+ embeddedHandlerCallback: (handler) => {
1822
+ captured = handler;
1823
+ },
1824
+ });
1825
+ if (!captured) {
1826
+ // Defensive: startHttp should always invoke the callback synchronously
1827
+ // before returning. If it didn't, something is structurally wrong.
1828
+ throw new Error(
1829
+ "[McpApp] getFetchHandler: embedded callback was not invoked",
1830
+ );
1831
+ }
1832
+ return captured;
1833
+ }
1834
+
1717
1835
  /**
1718
1836
  * Send a JSON-RPC message to all SSE clients in a session
1719
1837
  * Used for server-initiated notifications and requests
@@ -1948,7 +2066,12 @@ export class ConcurrentMCPServer {
1948
2066
  * without re-wrapping. This supports proxy/gateway patterns.
1949
2067
  */
1950
2068
  // deno-lint-ignore no-explicit-any
1951
- private isPreformattedResult(result: unknown): result is { content: Array<{ type: string; text: string }>; _meta?: Record<string, unknown> } {
2069
+ private isPreformattedResult(
2070
+ result: unknown,
2071
+ ): result is {
2072
+ content: Array<{ type: string; text: string }>;
2073
+ _meta?: Record<string, unknown>;
2074
+ } {
1952
2075
  if (!result || typeof result !== "object") return false;
1953
2076
  const obj = result as Record<string, unknown>;
1954
2077
  return Array.isArray(obj.content) &&
@@ -2055,7 +2178,9 @@ export class ConcurrentMCPServer {
2055
2178
  } catch (mapperError) {
2056
2179
  this.log(
2057
2180
  `toolErrorMapper threw for tool ${toolName}: ${
2058
- mapperError instanceof Error ? mapperError.message : String(mapperError)
2181
+ mapperError instanceof Error
2182
+ ? mapperError.message
2183
+ : String(mapperError)
2059
2184
  } (original error: ${
2060
2185
  error instanceof Error ? error.message : String(error)
2061
2186
  })`,
@@ -2114,7 +2239,11 @@ export interface RegisterViewersConfig {
2114
2239
  humanName?: (viewerName: string) => string;
2115
2240
  /** MCP Apps CSP — declares external domains the viewer needs (tiles, APIs, CDNs).
2116
2241
  * Uses McpUiCsp from @casys/mcp-compose (resourceDomains, connectDomains). */
2117
- csp?: { resourceDomains?: string[]; connectDomains?: string[]; frameDomains?: string[] };
2242
+ csp?: {
2243
+ resourceDomains?: string[];
2244
+ connectDomains?: string[];
2245
+ frameDomains?: string[];
2246
+ };
2118
2247
  }
2119
2248
 
2120
2249
  /** Summary returned by registerViewers() */
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Middleware module for ConcurrentMCPServer.
2
+ * Middleware module for McpApp.
3
3
  *
4
4
  * @module lib/server/middleware
5
5
  */
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Rate limiting middleware.
3
3
  *
4
- * Extracted from ConcurrentMCPServer's inline rate limit logic.
4
+ * Extracted from McpApp's inline rate limit logic.
5
5
  *
6
6
  * @module lib/server/middleware/rate-limit
7
7
  */
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Middleware pipeline types for ConcurrentMCPServer.
2
+ * Middleware pipeline types for McpApp.
3
3
  *
4
4
  * Provides an onion-model middleware system (similar to Koa/Hono)
5
5
  * where each middleware wraps the next, enabling before/after logic.
@@ -2,7 +2,7 @@
2
2
  * Server Metrics Collector for @casys/mcp-server
3
3
  *
4
4
  * In-memory counters, histograms, and gauges with Prometheus text format export.
5
- * Designed to be embedded in ConcurrentMCPServer — no external dependencies.
5
+ * Designed to be embedded in McpApp — no external dependencies.
6
6
  *
7
7
  * @module lib/server/observability/metrics
8
8
  */
package/src/types.ts CHANGED
@@ -45,9 +45,9 @@ export interface RateLimitContext {
45
45
  }
46
46
 
47
47
  /**
48
- * Configuration options for ConcurrentMCPServer
48
+ * Configuration options for McpApp
49
49
  */
50
- export interface ConcurrentServerOptions {
50
+ export interface McpAppOptions {
51
51
  /** Server name (shown in MCP protocol) */
52
52
  name: string;
53
53
 
@@ -485,8 +485,15 @@ export interface HttpRateLimitOptions {
485
485
  /**
486
486
  * Options for starting an HTTP server
487
487
  */
488
+ // Re-exported from the runtime port so consumers can import the canonical
489
+ // fetch handler type from the same module as HttpServerOptions.
490
+ export type { FetchHandler } from "./runtime/types.js";
491
+ import type { FetchHandler } from "./runtime/types.js";
492
+
488
493
  export interface HttpServerOptions {
489
- /** Port to listen on */
494
+ /**
495
+ * Port to listen on. Ignored when {@link embedded} is `true`.
496
+ */
490
497
  port: number;
491
498
 
492
499
  /** Hostname to bind to (default: "0.0.0.0") */
@@ -532,6 +539,27 @@ export interface HttpServerOptions {
532
539
  * @param info - Server address info
533
540
  */
534
541
  onListen?: (info: { hostname: string; port: number }) => void;
542
+
543
+ /**
544
+ * Embedded mode: skip binding a port and instead surface the Hono fetch
545
+ * handler via the {@link embeddedHandlerCallback} option. Used by
546
+ * {@link McpApp.getFetchHandler} so consumers (Fresh, Hono,
547
+ * Express, etc.) can mount the MCP HTTP stack inside their own server
548
+ * without giving up port ownership.
549
+ *
550
+ * When `true`, `port`, `hostname`, and `onListen` are ignored.
551
+ */
552
+ embedded?: boolean;
553
+
554
+ /**
555
+ * Receives the Hono fetch handler when running in embedded mode.
556
+ * Required when {@link embedded} is `true`. Called exactly once,
557
+ * synchronously, before `startHttp` returns.
558
+ *
559
+ * Most consumers should use {@link McpApp.getFetchHandler}
560
+ * instead of setting this directly.
561
+ */
562
+ embeddedHandlerCallback?: (handler: FetchHandler) => void;
535
563
  }
536
564
 
537
565
  /**
@@ -8,7 +8,13 @@
8
8
  */
9
9
 
10
10
  /** Directories to skip during auto-discovery */
11
- const SKIP_DIRS = new Set(["shared", "dist", "node_modules", ".cache", ".vite"]);
11
+ const SKIP_DIRS = new Set([
12
+ "shared",
13
+ "dist",
14
+ "node_modules",
15
+ ".cache",
16
+ ".vite",
17
+ ]);
12
18
 
13
19
  /**
14
20
  * Convert a file:// URL to a filesystem path.