@casys/mcp-server 0.11.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +37 -23
- package/mod.ts +47 -16
- 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} +381 -144
- 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/security/csp.ts +3 -1
- package/src/types.ts +100 -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,12 +69,12 @@ export { SamplingBridge } from "./src/sampling/sampling-bridge.js";
|
|
|
60
69
|
|
|
61
70
|
// Type exports
|
|
62
71
|
export type {
|
|
63
|
-
ConcurrentServerOptions,
|
|
64
72
|
HttpRateLimitContext,
|
|
65
73
|
HttpRateLimitOptions,
|
|
66
74
|
HttpServerInstance,
|
|
67
75
|
// HTTP Server types
|
|
68
76
|
HttpServerOptions,
|
|
77
|
+
McpAppOptions,
|
|
69
78
|
// MCP Apps types (SEP-1865)
|
|
70
79
|
MCPResource,
|
|
71
80
|
MCPTool,
|
|
@@ -79,22 +88,36 @@ export type {
|
|
|
79
88
|
SamplingClient,
|
|
80
89
|
SamplingParams,
|
|
81
90
|
SamplingResult,
|
|
91
|
+
StructuredToolResult,
|
|
92
|
+
ToolAnnotations,
|
|
93
|
+
ToolErrorMapper,
|
|
82
94
|
ToolHandler,
|
|
83
95
|
} from "./src/types.js";
|
|
84
96
|
|
|
97
|
+
/**
|
|
98
|
+
* @deprecated Use {@link McpAppOptions} instead. `ConcurrentServerOptions`
|
|
99
|
+
* is kept as a re-export for backwards compatibility and will be removed
|
|
100
|
+
* in v1.0.
|
|
101
|
+
*/
|
|
102
|
+
export type { McpAppOptions as ConcurrentServerOptions } from "./src/types.js";
|
|
103
|
+
|
|
85
104
|
// MCP Apps constants & viewer utilities
|
|
86
105
|
export { MCP_APP_MIME_TYPE } from "./src/types.js";
|
|
87
106
|
export type {
|
|
88
107
|
RegisterViewersConfig,
|
|
89
108
|
RegisterViewersSummary,
|
|
90
|
-
} from "./src/
|
|
109
|
+
} from "./src/mcp-app.js";
|
|
91
110
|
export {
|
|
92
|
-
resolveViewerDistPath,
|
|
93
111
|
discoverViewers,
|
|
112
|
+
resolveViewerDistPath,
|
|
94
113
|
} from "./src/ui/viewer-utils.js";
|
|
95
114
|
|
|
96
115
|
// MCP Compose — UI composition helpers (re-exported from @casys/mcp-compose)
|
|
97
|
-
export {
|
|
116
|
+
export {
|
|
117
|
+
COMPOSE_EVENT_METHOD,
|
|
118
|
+
composeEvents,
|
|
119
|
+
uiMeta,
|
|
120
|
+
} from "@casys/mcp-compose/sdk";
|
|
98
121
|
export type {
|
|
99
122
|
ComposeEventHandler,
|
|
100
123
|
ComposeEventPayload,
|
|
@@ -131,6 +154,14 @@ export type {
|
|
|
131
154
|
ProtectedResourceMetadata,
|
|
132
155
|
} from "./src/auth/mod.js";
|
|
133
156
|
|
|
157
|
+
// Auth - Multi-tenant resolution
|
|
158
|
+
export { createMultiTenantMiddleware } from "./src/auth/mod.js";
|
|
159
|
+
export type {
|
|
160
|
+
MultiTenantMiddlewareOptions,
|
|
161
|
+
TenantResolution,
|
|
162
|
+
TenantResolver,
|
|
163
|
+
} from "./src/auth/mod.js";
|
|
164
|
+
|
|
134
165
|
// Auth - JWT Provider + Presets
|
|
135
166
|
export { JwtAuthProvider } from "./src/auth/mod.js";
|
|
136
167
|
export type { JwtAuthProviderOptions } from "./src/auth/mod.js";
|
package/package.json
CHANGED
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", {
|