@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 +37 -23
- package/mod.ts +45 -17
- package/package.json +1 -1
- package/src/auth/mod.ts +9 -1
- package/src/auth/multi-tenant-middleware.ts +236 -0
- package/src/auth/types.ts +12 -1
- package/src/inspector/launcher.ts +6 -2
- package/src/{concurrent-server.ts → mcp-app.ts} +196 -67
- package/src/middleware/mod.ts +1 -1
- package/src/middleware/rate-limit.ts +1 -1
- package/src/middleware/types.ts +1 -1
- package/src/observability/metrics.ts +1 -1
- package/src/types.ts +31 -3
- package/src/ui/viewer-utils.ts +7 -1
package/README.md
CHANGED
|
@@ -4,9 +4,12 @@
|
|
|
4
4
|
[](https://jsr.io/@casys/mcp-server)
|
|
5
5
|
[](LICENSE)
|
|
6
6
|
|
|
7
|
-
**The "Hono for MCP"** — a production-grade framework for building Model Context
|
|
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
|
|
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
|
|
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 {
|
|
58
|
+
import { McpApp } from "@casys/mcp-server";
|
|
55
59
|
|
|
56
|
-
const server = new
|
|
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
|
|
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:
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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`,
|
|
303
|
-
|
|
304
|
-
- **
|
|
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 {
|
|
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
|
-
###
|
|
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
|
|
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
|
-
*
|
|
2
|
+
* @casys/mcp-server — Hono-style framework for MCP servers
|
|
3
3
|
*
|
|
4
|
-
* Production-ready MCP server framework with built-in
|
|
5
|
-
*
|
|
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
|
|
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 {
|
|
11
|
+
* import { McpApp } from "@casys/mcp-server";
|
|
13
12
|
*
|
|
14
|
-
* const
|
|
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
|
-
*
|
|
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
|
|
26
|
+
* await app.start();
|
|
28
27
|
*
|
|
29
28
|
* // — or — HTTP transport with security-first defaults
|
|
30
|
-
* const http = await
|
|
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 {
|
|
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/
|
|
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 {
|
|
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
package/src/auth/mod.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Auth module for
|
|
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
|
|
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(
|
|
58
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
8
|
+
* middleware, auth, and observability features.
|
|
9
9
|
*
|
|
10
|
-
* @module lib/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
|
-
|
|
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 {
|
|
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
|
-
*
|
|
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
|
|
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
|
|
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:
|
|
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:
|
|
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(
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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(
|
|
346
|
-
|
|
347
|
-
|
|
351
|
+
server.setRequestHandler(
|
|
352
|
+
CallToolRequestSchema,
|
|
353
|
+
async (request: CallToolRequest) => {
|
|
354
|
+
const toolName = request.params.name;
|
|
355
|
+
const args = request.params.arguments || {};
|
|
348
356
|
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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
|
-
|
|
357
|
-
|
|
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
|
-
"[
|
|
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
|
-
"[
|
|
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(
|
|
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
|
-
"[
|
|
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
|
-
"[
|
|
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
|
-
`[
|
|
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
|
-
`[
|
|
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
|
-
`[
|
|
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
|
-
`[
|
|
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(
|
|
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(
|
|
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 = {
|
|
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(
|
|
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 =
|
|
915
|
-
|
|
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
|
|
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
|
-
"[
|
|
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 >=
|
|
1436
|
+
if (this.sessions.size >= McpApp.MAX_SESSIONS) {
|
|
1415
1437
|
this.cleanupSessions();
|
|
1416
|
-
if (this.sessions.size >=
|
|
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
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
|
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?: {
|
|
2242
|
+
csp?: {
|
|
2243
|
+
resourceDomains?: string[];
|
|
2244
|
+
connectDomains?: string[];
|
|
2245
|
+
frameDomains?: string[];
|
|
2246
|
+
};
|
|
2118
2247
|
}
|
|
2119
2248
|
|
|
2120
2249
|
/** Summary returned by registerViewers() */
|
package/src/middleware/mod.ts
CHANGED
package/src/middleware/types.ts
CHANGED
|
@@ -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
|
|
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
|
|
48
|
+
* Configuration options for McpApp
|
|
49
49
|
*/
|
|
50
|
-
export interface
|
|
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
|
-
/**
|
|
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
|
/**
|
package/src/ui/viewer-utils.ts
CHANGED
|
@@ -8,7 +8,13 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
/** Directories to skip during auto-discovery */
|
|
11
|
-
const SKIP_DIRS = new Set([
|
|
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.
|