@casys/mcp-server 0.13.0 → 0.15.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 +78 -4
- package/mod.ts +10 -1
- package/package.json +2 -2
- package/src/auth/config.ts +20 -0
- package/src/auth/jwt-provider.ts +45 -0
- package/src/auth/middleware.ts +4 -4
- package/src/auth/presets.ts +9 -0
- package/src/auth/provider.ts +2 -0
- package/src/auth/types.ts +20 -1
- package/src/mcp-app.ts +63 -11
- package/src/types.ts +111 -0
package/README.md
CHANGED
|
@@ -41,13 +41,35 @@ stack.
|
|
|
41
41
|
## Install
|
|
42
42
|
|
|
43
43
|
```bash
|
|
44
|
-
#
|
|
45
|
-
npm install @casys/mcp-server
|
|
46
|
-
|
|
47
|
-
# Deno
|
|
44
|
+
# Deno (primary target — JSR)
|
|
48
45
|
deno add jsr:@casys/mcp-server
|
|
46
|
+
|
|
47
|
+
# Node (secondary — npm, via build-node compilation)
|
|
48
|
+
npm install @casys/mcp-server
|
|
49
49
|
```
|
|
50
50
|
|
|
51
|
+
## Runtime targets
|
|
52
|
+
|
|
53
|
+
`@casys/mcp-server` is **Deno-first**. The canonical deployment path is Deno 2.x
|
|
54
|
+
running on [Deno Deploy](https://deno.com/deploy) or self-hosted Deno, with a
|
|
55
|
+
Node 20+ distribution as a secondary target via `scripts/build-node.sh` (which
|
|
56
|
+
swaps the HTTP runtime adapter and remaps `@std/*` imports to their npm
|
|
57
|
+
equivalents).
|
|
58
|
+
|
|
59
|
+
| Runtime | Status |
|
|
60
|
+
| --------------------------------------------- | :--------------: |
|
|
61
|
+
| **Deno 2.x** (Deno Deploy, self-hosted) | ✅ Primary |
|
|
62
|
+
| **Node.js 20+** (Express, Hono-on-Node, bare) | ✅ Secondary |
|
|
63
|
+
| **Cloudflare Workers / workerd** | ❌ Not supported |
|
|
64
|
+
| **Browser / WebContainer** | ❌ Not supported |
|
|
65
|
+
|
|
66
|
+
If you need to target Cloudflare Workers or the browser, use
|
|
67
|
+
[`@modelcontextprotocol/server`](https://www.npmjs.com/package/@modelcontextprotocol/server)
|
|
68
|
+
directly with its workerd / browser shims — that package focuses on the protocol
|
|
69
|
+
and runtime portability, while `@casys/mcp-server` focuses on the production
|
|
70
|
+
stack (auth, middleware, observability, multi-tenant, MCP Apps helpers) for Deno
|
|
71
|
+
deployments.
|
|
72
|
+
|
|
51
73
|
---
|
|
52
74
|
|
|
53
75
|
## Quick Start
|
|
@@ -331,6 +353,58 @@ server.registerResource(
|
|
|
331
353
|
);
|
|
332
354
|
```
|
|
333
355
|
|
|
356
|
+
#### Capability negotiation (clients that don't support MCP Apps)
|
|
357
|
+
|
|
358
|
+
Not every MCP client renders UI resources. Clients that do advertise the
|
|
359
|
+
[MCP Apps extension](https://github.com/modelcontextprotocol/ext-apps) in their
|
|
360
|
+
capabilities (per the SDK 1.29 `extensions` field). Read it from a tool handler
|
|
361
|
+
to decide between rich UI and a text-only fallback:
|
|
362
|
+
|
|
363
|
+
```typescript
|
|
364
|
+
import { MCP_APP_MIME_TYPE, McpApp } from "@casys/mcp-server";
|
|
365
|
+
|
|
366
|
+
const app = new McpApp({ name: "weather-server", version: "1.0.0" });
|
|
367
|
+
|
|
368
|
+
app.registerTool(
|
|
369
|
+
{
|
|
370
|
+
name: "get-weather",
|
|
371
|
+
description: "Get the weather forecast for a city",
|
|
372
|
+
inputSchema: {
|
|
373
|
+
type: "object",
|
|
374
|
+
properties: { city: { type: "string" } },
|
|
375
|
+
required: ["city"],
|
|
376
|
+
},
|
|
377
|
+
},
|
|
378
|
+
async ({ city }) => {
|
|
379
|
+
const forecast = await fetchForecast(city);
|
|
380
|
+
const cap = app.getClientMcpAppsCapability();
|
|
381
|
+
|
|
382
|
+
if (cap?.mimeTypes?.includes(MCP_APP_MIME_TYPE)) {
|
|
383
|
+
// Rich UI: small text summary + interactive resource
|
|
384
|
+
return {
|
|
385
|
+
content: [{ type: "text", text: `Forecast for ${city} loaded` }],
|
|
386
|
+
_meta: { ui: { resourceUri: `ui://weather/${city}` } },
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Text-only fallback for clients that can't render the UI
|
|
391
|
+
return {
|
|
392
|
+
content: [{ type: "text", text: formatForecastAsText(forecast) }],
|
|
393
|
+
};
|
|
394
|
+
},
|
|
395
|
+
);
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
`getClientMcpAppsCapability()` returns `undefined` before the client has
|
|
399
|
+
completed its initialize handshake, when the client doesn't advertise MCP Apps
|
|
400
|
+
support, or when the advertised capability is malformed. The standalone
|
|
401
|
+
`getMcpAppsCapability(clientCapabilities)` function is also exported for use
|
|
402
|
+
against arbitrary capability objects.
|
|
403
|
+
|
|
404
|
+
The constants `MCP_APPS_EXTENSION_ID` (`"io.modelcontextprotocol/ui"`) and
|
|
405
|
+
`MCP_APPS_PROTOCOL_VERSION` (`"2026-01-26"`) are exported for agents that need
|
|
406
|
+
to introspect the protocol target directly.
|
|
407
|
+
|
|
334
408
|
---
|
|
335
409
|
|
|
336
410
|
## API Reference
|
package/mod.ts
CHANGED
|
@@ -102,7 +102,16 @@ export type {
|
|
|
102
102
|
export type { McpAppOptions as ConcurrentServerOptions } from "./src/types.js";
|
|
103
103
|
|
|
104
104
|
// MCP Apps constants & viewer utilities
|
|
105
|
-
export {
|
|
105
|
+
export {
|
|
106
|
+
MCP_APP_MIME_TYPE,
|
|
107
|
+
MCP_APPS_EXTENSION_ID,
|
|
108
|
+
MCP_APPS_PROTOCOL_VERSION,
|
|
109
|
+
} from "./src/types.js";
|
|
110
|
+
|
|
111
|
+
// MCP Apps capability negotiation (per ext-apps spec 2026-01-26 + SDK 1.29 extensions)
|
|
112
|
+
export { getMcpAppsCapability } from "./src/types.js";
|
|
113
|
+
export type { McpAppsClientCapability } from "./src/types.js";
|
|
114
|
+
|
|
106
115
|
export type {
|
|
107
116
|
RegisterViewersConfig,
|
|
108
117
|
RegisterViewersSummary,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@casys/mcp-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"description": "Production-ready MCP server framework with concurrency control, auth, and observability",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "mod.ts",
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"test": "tsx --test src/**/*_test.ts"
|
|
11
11
|
},
|
|
12
12
|
"dependencies": {
|
|
13
|
-
"@modelcontextprotocol/sdk": "^1.
|
|
13
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
14
14
|
"hono": "^4.0.0",
|
|
15
15
|
"ajv": "^8.17.1",
|
|
16
16
|
"jose": "^6.0.0",
|
package/src/auth/config.ts
CHANGED
|
@@ -42,6 +42,17 @@ export interface AuthConfig {
|
|
|
42
42
|
jwksUri?: string;
|
|
43
43
|
/** Supported scopes */
|
|
44
44
|
scopesSupported?: string[];
|
|
45
|
+
/**
|
|
46
|
+
* Absolute HTTP(S) URL where the `/.well-known/oauth-protected-resource`
|
|
47
|
+
* metadata document is served publicly (RFC 9728 § 3).
|
|
48
|
+
*
|
|
49
|
+
* Required when `resource` is an opaque URI (e.g., an OIDC project ID used
|
|
50
|
+
* as JWT audience per RFC 9728 § 2) — otherwise `JwtAuthProvider` will
|
|
51
|
+
* throw at construction. When `resource` is itself an HTTP(S) URL, this
|
|
52
|
+
* field is optional and auto-derived by the factory. Mirrors
|
|
53
|
+
* `JwtAuthProviderOptions.resourceMetadataUrl`.
|
|
54
|
+
*/
|
|
55
|
+
resourceMetadataUrl?: string;
|
|
45
56
|
}
|
|
46
57
|
|
|
47
58
|
/**
|
|
@@ -56,6 +67,7 @@ interface ConfigFile {
|
|
|
56
67
|
issuer?: string;
|
|
57
68
|
jwksUri?: string;
|
|
58
69
|
scopesSupported?: string[];
|
|
70
|
+
resourceMetadataUrl?: string;
|
|
59
71
|
};
|
|
60
72
|
}
|
|
61
73
|
|
|
@@ -75,6 +87,7 @@ interface ConfigFile {
|
|
|
75
87
|
* - MCP_AUTH_ISSUER → auth.issuer
|
|
76
88
|
* - MCP_AUTH_JWKS_URI → auth.jwksUri
|
|
77
89
|
* - MCP_AUTH_SCOPES → auth.scopesSupported (space-separated)
|
|
90
|
+
* - MCP_AUTH_RESOURCE_METADATA_URL → auth.resourceMetadataUrl
|
|
78
91
|
*
|
|
79
92
|
* @param configPath - Path to YAML config file. Defaults to "mcp-server.yaml" in cwd.
|
|
80
93
|
* @returns AuthConfig or null if no auth configured
|
|
@@ -94,6 +107,7 @@ export async function loadAuthConfig(
|
|
|
94
107
|
const envIssuer = env("MCP_AUTH_ISSUER");
|
|
95
108
|
const envJwksUri = env("MCP_AUTH_JWKS_URI");
|
|
96
109
|
const envScopes = env("MCP_AUTH_SCOPES");
|
|
110
|
+
const envResourceMetadataUrl = env("MCP_AUTH_RESOURCE_METADATA_URL");
|
|
97
111
|
|
|
98
112
|
// 3. Merge: env overrides YAML
|
|
99
113
|
const provider = envProvider ?? yamlAuth?.provider;
|
|
@@ -135,6 +149,8 @@ export async function loadAuthConfig(
|
|
|
135
149
|
scopesSupported: envScopes
|
|
136
150
|
? envScopes.split(" ").filter(Boolean)
|
|
137
151
|
: yamlAuth?.scopesSupported,
|
|
152
|
+
resourceMetadataUrl: envResourceMetadataUrl ??
|
|
153
|
+
yamlAuth?.resourceMetadataUrl,
|
|
138
154
|
};
|
|
139
155
|
|
|
140
156
|
// Provider-specific validation (fail-fast)
|
|
@@ -165,6 +181,7 @@ export function createAuthProviderFromConfig(config: AuthConfig): AuthProvider {
|
|
|
165
181
|
audience: config.audience,
|
|
166
182
|
resource: config.resource,
|
|
167
183
|
scopesSupported: config.scopesSupported,
|
|
184
|
+
resourceMetadataUrl: config.resourceMetadataUrl,
|
|
168
185
|
};
|
|
169
186
|
|
|
170
187
|
switch (config.provider) {
|
|
@@ -225,5 +242,8 @@ async function loadYamlAuth(
|
|
|
225
242
|
typeof s === "string"
|
|
226
243
|
)
|
|
227
244
|
: undefined,
|
|
245
|
+
resourceMetadataUrl: typeof auth.resourceMetadataUrl === "string"
|
|
246
|
+
? auth.resourceMetadataUrl
|
|
247
|
+
: undefined,
|
|
228
248
|
};
|
|
229
249
|
}
|
package/src/auth/jwt-provider.ts
CHANGED
|
@@ -28,6 +28,23 @@ export interface JwtAuthProviderOptions {
|
|
|
28
28
|
authorizationServers: string[];
|
|
29
29
|
/** Scopes supported by this server */
|
|
30
30
|
scopesSupported?: string[];
|
|
31
|
+
/**
|
|
32
|
+
* Absolute HTTP(S) URL where the `/.well-known/oauth-protected-resource`
|
|
33
|
+
* metadata document is served publicly. Used to populate the
|
|
34
|
+
* `resource_metadata` parameter of the WWW-Authenticate challenge
|
|
35
|
+
* (RFC 9728 § 5) and the `resource_metadata_url` field of
|
|
36
|
+
* `ProtectedResourceMetadata`.
|
|
37
|
+
*
|
|
38
|
+
* - When omitted AND `resource` is an HTTP(S) URL, the factory
|
|
39
|
+
* auto-derives `${resource}/.well-known/oauth-protected-resource`.
|
|
40
|
+
* - When omitted AND `resource` is an opaque URI (e.g., an OIDC project
|
|
41
|
+
* ID used as JWT audience — valid per RFC 9728 § 2), the factory
|
|
42
|
+
* throws at construction. Set this option explicitly in that case
|
|
43
|
+
* (fail-fast, no silent broken header).
|
|
44
|
+
*
|
|
45
|
+
* @example "https://my-mcp.example.com/.well-known/oauth-protected-resource"
|
|
46
|
+
*/
|
|
47
|
+
resourceMetadataUrl?: string;
|
|
31
48
|
}
|
|
32
49
|
|
|
33
50
|
/**
|
|
@@ -54,6 +71,7 @@ interface CachedAuth {
|
|
|
54
71
|
export class JwtAuthProvider extends AuthProvider {
|
|
55
72
|
private jwks: ReturnType<typeof createRemoteJWKSet>;
|
|
56
73
|
private options: JwtAuthProviderOptions;
|
|
74
|
+
private readonly resourceMetadataUrl: string;
|
|
57
75
|
|
|
58
76
|
// Token verification cache: hash(token) → AuthInfo with TTL
|
|
59
77
|
// Prevents redundant JWKS fetches (network round-trip per tool call)
|
|
@@ -79,6 +97,32 @@ export class JwtAuthProvider extends AuthProvider {
|
|
|
79
97
|
);
|
|
80
98
|
}
|
|
81
99
|
|
|
100
|
+
// Resolve resource_metadata_url: explicit > auto-derive (if URL) > throw.
|
|
101
|
+
// Per RFC 9728 § 2, `resource` is an URI identifier and MAY be opaque
|
|
102
|
+
// (e.g., an OIDC project ID used as JWT audience). The metadata document
|
|
103
|
+
// URL is a separate concept — always an HTTP(S) URL. We used to derive
|
|
104
|
+
// it from `resource` by string concatenation, which produced a broken
|
|
105
|
+
// URL when `resource` was not itself an HTTP(S) URL. 0.15.0+ requires
|
|
106
|
+
// the caller to provide the URL explicitly whenever `resource` is opaque.
|
|
107
|
+
if (options.resourceMetadataUrl) {
|
|
108
|
+
this.resourceMetadataUrl = options.resourceMetadataUrl;
|
|
109
|
+
} else {
|
|
110
|
+
const isUrl = /^https?:\/\//.test(options.resource);
|
|
111
|
+
if (!isUrl) {
|
|
112
|
+
throw new Error(
|
|
113
|
+
`[JwtAuthProvider] resourceMetadataUrl is required when 'resource' ` +
|
|
114
|
+
`is not an HTTP(S) URL (got resource="${options.resource}"). ` +
|
|
115
|
+
`Per RFC 9728 § 2, 'resource' is an URI identifier that can be ` +
|
|
116
|
+
`opaque (e.g., an OIDC project ID used as JWT audience). The ` +
|
|
117
|
+
`metadata document URL is a separate concept. Set 'resourceMetadataUrl' ` +
|
|
118
|
+
`to the HTTPS URL where your /.well-known/oauth-protected-resource ` +
|
|
119
|
+
`endpoint is served publicly (e.g., "https://my-mcp.example.com/.well-known/oauth-protected-resource").`,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
const base = options.resource.replace(/\/$/, "");
|
|
123
|
+
this.resourceMetadataUrl = `${base}/.well-known/oauth-protected-resource`;
|
|
124
|
+
}
|
|
125
|
+
|
|
82
126
|
this.options = options;
|
|
83
127
|
const jwksUri = options.jwksUri ??
|
|
84
128
|
`${options.issuer}/.well-known/jwks.json`;
|
|
@@ -153,6 +197,7 @@ export class JwtAuthProvider extends AuthProvider {
|
|
|
153
197
|
getResourceMetadata(): ProtectedResourceMetadata {
|
|
154
198
|
return {
|
|
155
199
|
resource: this.options.resource,
|
|
200
|
+
resource_metadata_url: this.resourceMetadataUrl,
|
|
156
201
|
authorization_servers: this.options.authorizationServers,
|
|
157
202
|
scopes_supported: this.options.scopesSupported,
|
|
158
203
|
bearer_methods_supported: ["header"],
|
package/src/auth/middleware.ts
CHANGED
|
@@ -123,10 +123,10 @@ export function createAuthMiddleware(provider: AuthProvider): Middleware {
|
|
|
123
123
|
return next();
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
const metadataUrl =
|
|
126
|
+
// 0.15.0+: provider's getResourceMetadata() always returns a valid
|
|
127
|
+
// absolute URL in resource_metadata_url (type system guarantees it).
|
|
128
|
+
// No derivation needed at the middleware level anymore.
|
|
129
|
+
const metadataUrl = provider.getResourceMetadata().resource_metadata_url;
|
|
130
130
|
|
|
131
131
|
const token = extractBearerToken(ctx.request);
|
|
132
132
|
if (!token) {
|
package/src/auth/presets.ts
CHANGED
|
@@ -23,6 +23,12 @@ export interface PresetOptions {
|
|
|
23
23
|
resource: string;
|
|
24
24
|
/** Scopes supported by this server */
|
|
25
25
|
scopesSupported?: string[];
|
|
26
|
+
/**
|
|
27
|
+
* Absolute HTTP(S) URL where the `/.well-known/oauth-protected-resource`
|
|
28
|
+
* metadata document is served publicly (RFC 9728 § 3). Required when
|
|
29
|
+
* `resource` is an opaque URI. See `JwtAuthProviderOptions.resourceMetadataUrl`.
|
|
30
|
+
*/
|
|
31
|
+
resourceMetadataUrl?: string;
|
|
26
32
|
}
|
|
27
33
|
|
|
28
34
|
/**
|
|
@@ -48,6 +54,7 @@ export function createGitHubAuthProvider(
|
|
|
48
54
|
resource: options.resource,
|
|
49
55
|
authorizationServers: ["https://token.actions.githubusercontent.com"],
|
|
50
56
|
scopesSupported: options.scopesSupported,
|
|
57
|
+
resourceMetadataUrl: options.resourceMetadataUrl,
|
|
51
58
|
});
|
|
52
59
|
}
|
|
53
60
|
|
|
@@ -75,6 +82,7 @@ export function createGoogleAuthProvider(
|
|
|
75
82
|
authorizationServers: ["https://accounts.google.com"],
|
|
76
83
|
jwksUri: "https://www.googleapis.com/oauth2/v3/certs",
|
|
77
84
|
scopesSupported: options.scopesSupported,
|
|
85
|
+
resourceMetadataUrl: options.resourceMetadataUrl,
|
|
78
86
|
});
|
|
79
87
|
}
|
|
80
88
|
|
|
@@ -104,6 +112,7 @@ export function createAuth0AuthProvider(
|
|
|
104
112
|
authorizationServers: [issuer],
|
|
105
113
|
jwksUri: `${issuer}.well-known/jwks.json`,
|
|
106
114
|
scopesSupported: options.scopesSupported,
|
|
115
|
+
resourceMetadataUrl: options.resourceMetadataUrl,
|
|
107
116
|
});
|
|
108
117
|
}
|
|
109
118
|
|
package/src/auth/provider.ts
CHANGED
|
@@ -24,6 +24,8 @@ import type { AuthInfo, ProtectedResourceMetadata } from "./types.js";
|
|
|
24
24
|
* getResourceMetadata(): ProtectedResourceMetadata {
|
|
25
25
|
* return {
|
|
26
26
|
* resource: "https://my-mcp.example.com",
|
|
27
|
+
* resource_metadata_url:
|
|
28
|
+
* "https://my-mcp.example.com/.well-known/oauth-protected-resource",
|
|
27
29
|
* authorization_servers: ["https://auth.example.com"],
|
|
28
30
|
* bearer_methods_supported: ["header"],
|
|
29
31
|
* };
|
package/src/auth/types.ts
CHANGED
|
@@ -66,9 +66,28 @@ import type { AuthProvider } from "./provider.js";
|
|
|
66
66
|
* @see https://datatracker.ietf.org/doc/html/rfc9728
|
|
67
67
|
*/
|
|
68
68
|
export interface ProtectedResourceMetadata {
|
|
69
|
-
/**
|
|
69
|
+
/**
|
|
70
|
+
* RFC 9728 § 2 resource identifier. URI that identifies the protected
|
|
71
|
+
* resource — used as the JWT `aud` claim. Can be an HTTP(S) URL OR an
|
|
72
|
+
* opaque URI (e.g., an OIDC project ID). Do NOT assume it's an URL.
|
|
73
|
+
*/
|
|
70
74
|
resource: string;
|
|
71
75
|
|
|
76
|
+
/**
|
|
77
|
+
* Absolute HTTP(S) URL where this metadata document is served publicly.
|
|
78
|
+
* Per RFC 9728 § 3, this is the URL a client discovers via the
|
|
79
|
+
* WWW-Authenticate challenge. Always an HTTP(S) URL, regardless of
|
|
80
|
+
* whether `resource` itself is an URL or an opaque URI.
|
|
81
|
+
*
|
|
82
|
+
* REQUIRED as of 0.15.0 — previously derived at the middleware level
|
|
83
|
+
* from `resource`, which produced a broken URL when `resource` was not
|
|
84
|
+
* itself an HTTP(S) URL. Callers using the `createOIDCAuthProvider`
|
|
85
|
+
* factory or `JwtAuthProvider` can omit the explicit value when their
|
|
86
|
+
* `resource` IS an HTTP(S) URL (the factory auto-derives). Custom
|
|
87
|
+
* `AuthProvider` subclasses must always set it explicitly.
|
|
88
|
+
*/
|
|
89
|
+
resource_metadata_url: string;
|
|
90
|
+
|
|
72
91
|
/** Authorization servers that can issue valid tokens */
|
|
73
92
|
authorization_servers: string[];
|
|
74
93
|
|
package/src/mcp-app.ts
CHANGED
|
@@ -51,6 +51,7 @@ import type {
|
|
|
51
51
|
HttpRateLimitContext,
|
|
52
52
|
HttpServerOptions,
|
|
53
53
|
McpAppOptions,
|
|
54
|
+
McpAppsClientCapability,
|
|
54
55
|
MCPResource,
|
|
55
56
|
MCPTool,
|
|
56
57
|
QueueMetrics,
|
|
@@ -59,7 +60,11 @@ import type {
|
|
|
59
60
|
StructuredToolResult,
|
|
60
61
|
ToolHandler,
|
|
61
62
|
} from "./types.js";
|
|
62
|
-
import {
|
|
63
|
+
import {
|
|
64
|
+
getMcpAppsCapability,
|
|
65
|
+
MCP_APP_MIME_TYPE,
|
|
66
|
+
MCP_APP_URI_SCHEME,
|
|
67
|
+
} from "./types.js";
|
|
63
68
|
import { discoverViewers, resolveViewerDistPath } from "./ui/viewer-utils.js";
|
|
64
69
|
import type { DirEntry, DiscoverViewersFS } from "./ui/viewer-utils.js";
|
|
65
70
|
import { buildCspHeader, injectCspMetaTag } from "./security/csp.js";
|
|
@@ -1154,15 +1159,16 @@ export class McpApp {
|
|
|
1154
1159
|
return c.json(this.authProvider.getResourceMetadata());
|
|
1155
1160
|
});
|
|
1156
1161
|
|
|
1157
|
-
// Helper: build resource metadata URL safely (avoid double slash)
|
|
1158
|
-
const buildMetadataUrl = (resource: string): string => {
|
|
1159
|
-
const base = resource.endsWith("/") ? resource.slice(0, -1) : resource;
|
|
1160
|
-
return `${base}/.well-known/oauth-protected-resource`;
|
|
1161
|
-
};
|
|
1162
|
-
|
|
1163
1162
|
// Auth verification helper for HTTP endpoints.
|
|
1164
1163
|
// Returns an error Response if auth is required but token is missing/invalid.
|
|
1165
1164
|
// Returns null if auth passes or is not configured.
|
|
1165
|
+
//
|
|
1166
|
+
// 0.15.0+: provider's getResourceMetadata() always returns a valid
|
|
1167
|
+
// absolute URL in resource_metadata_url (type-enforced). We used to
|
|
1168
|
+
// derive this URL from `metadata.resource` by string concatenation
|
|
1169
|
+
// (see `buildMetadataUrl` helper, removed 0.15.0), which produced a
|
|
1170
|
+
// broken URL when `resource` was an opaque URI per RFC 9728 § 2
|
|
1171
|
+
// (e.g., an OIDC project ID used as JWT audience).
|
|
1166
1172
|
const verifyHttpAuth = async (
|
|
1167
1173
|
request: Request,
|
|
1168
1174
|
): Promise<Response | null> => {
|
|
@@ -1172,7 +1178,7 @@ export class McpApp {
|
|
|
1172
1178
|
if (!token) {
|
|
1173
1179
|
const metadata = this.authProvider.getResourceMetadata();
|
|
1174
1180
|
return createUnauthorizedResponse(
|
|
1175
|
-
|
|
1181
|
+
metadata.resource_metadata_url,
|
|
1176
1182
|
"missing_token",
|
|
1177
1183
|
"Authorization header with Bearer token required",
|
|
1178
1184
|
);
|
|
@@ -1182,7 +1188,7 @@ export class McpApp {
|
|
|
1182
1188
|
if (!authInfo) {
|
|
1183
1189
|
const metadata = this.authProvider.getResourceMetadata();
|
|
1184
1190
|
return createUnauthorizedResponse(
|
|
1185
|
-
|
|
1191
|
+
metadata.resource_metadata_url,
|
|
1186
1192
|
"invalid_token",
|
|
1187
1193
|
"Invalid or expired token",
|
|
1188
1194
|
);
|
|
@@ -1774,8 +1780,17 @@ export class McpApp {
|
|
|
1774
1780
|
/**
|
|
1775
1781
|
* Build the HTTP middleware stack and return its fetch handler without
|
|
1776
1782
|
* binding a port. Use this when you want to mount the MCP HTTP layer
|
|
1777
|
-
* inside another HTTP framework (Fresh, Hono,
|
|
1778
|
-
*
|
|
1783
|
+
* inside another HTTP framework on Deno (Fresh, Hono, `Deno.serve`) or
|
|
1784
|
+
* Node (Express, Hono-on-Node) instead of giving up port ownership to
|
|
1785
|
+
* {@link startHttp}.
|
|
1786
|
+
*
|
|
1787
|
+
* **Runtime targets:** `@casys/mcp-server` is Deno-first — the canonical
|
|
1788
|
+
* deployment path is Deno 2.x on Deno Deploy or self-hosted Deno, with
|
|
1789
|
+
* a Node 20+ distribution via `scripts/build-node.sh` as a secondary
|
|
1790
|
+
* target. Cloudflare Workers, workerd, and browser runtimes are not
|
|
1791
|
+
* supported — if you target those, use
|
|
1792
|
+
* [`@modelcontextprotocol/server`](https://www.npmjs.com/package/@modelcontextprotocol/server)
|
|
1793
|
+
* directly instead.
|
|
1779
1794
|
*
|
|
1780
1795
|
* The returned handler accepts a Web Standard {@link Request} and returns
|
|
1781
1796
|
* a Web Standard {@link Response}. It exposes the same routes as
|
|
@@ -1908,6 +1923,43 @@ export class McpApp {
|
|
|
1908
1923
|
return this.samplingBridge;
|
|
1909
1924
|
}
|
|
1910
1925
|
|
|
1926
|
+
/**
|
|
1927
|
+
* Read the MCP Apps capability advertised by the connected client.
|
|
1928
|
+
*
|
|
1929
|
+
* Returns the capability object (possibly empty `{}`) when the client
|
|
1930
|
+
* advertised support for MCP Apps via its `extensions` capability,
|
|
1931
|
+
* or `undefined` when:
|
|
1932
|
+
* - the client did not send capabilities yet (called before initialize)
|
|
1933
|
+
* - the client did not advertise the MCP Apps extension at all
|
|
1934
|
+
* - the client sent a malformed extension value
|
|
1935
|
+
*
|
|
1936
|
+
* Use this from a tool handler to decide whether to return a UI
|
|
1937
|
+
* resource (`_meta.ui`) or a text-only fallback. Hosts that don't
|
|
1938
|
+
* support MCP Apps will silently drop the `_meta.ui` field, but
|
|
1939
|
+
* checking explicitly lets you serve a richer text response when
|
|
1940
|
+
* the UI path isn't available.
|
|
1941
|
+
*
|
|
1942
|
+
* @returns MCP Apps capability or `undefined` if not supported.
|
|
1943
|
+
*
|
|
1944
|
+
* @example
|
|
1945
|
+
* ```typescript
|
|
1946
|
+
* const cap = app.getClientMcpAppsCapability();
|
|
1947
|
+
* if (cap?.mimeTypes?.includes(MCP_APP_MIME_TYPE)) {
|
|
1948
|
+
* return {
|
|
1949
|
+
* content: [{ type: "text", text: summary }],
|
|
1950
|
+
* _meta: { ui: { resourceUri: "ui://my-app/dashboard" } },
|
|
1951
|
+
* };
|
|
1952
|
+
* }
|
|
1953
|
+
* return { content: [{ type: "text", text: detailedTextFallback }] };
|
|
1954
|
+
* ```
|
|
1955
|
+
*
|
|
1956
|
+
* @see {@link getMcpAppsCapability} for the standalone reader
|
|
1957
|
+
* @see {@link MCP_APPS_EXTENSION_ID} for the extension key
|
|
1958
|
+
*/
|
|
1959
|
+
getClientMcpAppsCapability(): McpAppsClientCapability | undefined {
|
|
1960
|
+
return getMcpAppsCapability(this.mcpServer.server.getClientCapabilities());
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1911
1963
|
/**
|
|
1912
1964
|
* Get queue metrics for monitoring
|
|
1913
1965
|
*/
|
package/src/types.ts
CHANGED
|
@@ -251,6 +251,117 @@ export const MCP_APP_MIME_TYPE = "text/html;profile=mcp-app" as const;
|
|
|
251
251
|
/** URI scheme for MCP Apps resources */
|
|
252
252
|
export const MCP_APP_URI_SCHEME = "ui:" as const;
|
|
253
253
|
|
|
254
|
+
/**
|
|
255
|
+
* Well-known extension identifier for the MCP Apps protocol.
|
|
256
|
+
*
|
|
257
|
+
* Clients advertise MCP Apps support by including this key in
|
|
258
|
+
* `clientCapabilities.extensions` (per the MCP SDK 1.29 extensions
|
|
259
|
+
* feature). Servers read it via {@link getMcpAppsCapability} to decide
|
|
260
|
+
* whether to register UI-rendering tools or fall back to text-only.
|
|
261
|
+
*
|
|
262
|
+
* @see {@link https://github.com/modelcontextprotocol/ext-apps | MCP Apps spec}
|
|
263
|
+
*/
|
|
264
|
+
export const MCP_APPS_EXTENSION_ID = "io.modelcontextprotocol/ui" as const;
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* MCP Apps protocol spec version this package targets.
|
|
268
|
+
*
|
|
269
|
+
* Matches the dated spec at
|
|
270
|
+
* https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/2026-01-26/apps.mdx
|
|
271
|
+
*
|
|
272
|
+
* Bump this constant in the same commit that adopts a newer dated spec.
|
|
273
|
+
*/
|
|
274
|
+
export const MCP_APPS_PROTOCOL_VERSION = "2026-01-26" as const;
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* MCP Apps capability advertised by a client.
|
|
278
|
+
*
|
|
279
|
+
* Returned by {@link getMcpAppsCapability} after reading
|
|
280
|
+
* `clientCapabilities.extensions[MCP_APPS_EXTENSION_ID]`.
|
|
281
|
+
*
|
|
282
|
+
* The capability object is intentionally minimal — the spec keeps it
|
|
283
|
+
* extensible by adding fields rather than removing them, so consumers
|
|
284
|
+
* MUST tolerate unknown fields.
|
|
285
|
+
*/
|
|
286
|
+
export interface McpAppsClientCapability {
|
|
287
|
+
/**
|
|
288
|
+
* MIME types the client can render as MCP Apps.
|
|
289
|
+
*
|
|
290
|
+
* Typically includes `"text/html;profile=mcp-app"`. An empty or
|
|
291
|
+
* absent array means the client advertised support but listed no
|
|
292
|
+
* concrete mime types — defensively assume nothing.
|
|
293
|
+
*/
|
|
294
|
+
mimeTypes?: string[];
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Read the MCP Apps capability from a client's advertised capabilities.
|
|
299
|
+
*
|
|
300
|
+
* Best-effort, defensive reader. Returns `undefined` for any of:
|
|
301
|
+
* - `null` / `undefined` input
|
|
302
|
+
* - `clientCapabilities` without an `extensions` field
|
|
303
|
+
* - `extensions` without the {@link MCP_APPS_EXTENSION_ID} key
|
|
304
|
+
* - extension value that is not a plain object (string, number, null, ...)
|
|
305
|
+
*
|
|
306
|
+
* Malformed `mimeTypes` (wrong type, non-string entries) are silently
|
|
307
|
+
* filtered rather than thrown — agents reading this function need a
|
|
308
|
+
* predictable contract that never crashes downstream consumers on
|
|
309
|
+
* untrusted client data.
|
|
310
|
+
*
|
|
311
|
+
* **Validation scope:** this function only validates the *type* of
|
|
312
|
+
* `mimeTypes` entries (must be string). It does NOT validate that the
|
|
313
|
+
* strings look like valid mime types (e.g. empty strings or garbage
|
|
314
|
+
* content pass through). Consumers should compare against known
|
|
315
|
+
* constants like {@link MCP_APP_MIME_TYPE} via `.includes()` rather
|
|
316
|
+
* than treating the array as a generic allowlist.
|
|
317
|
+
*
|
|
318
|
+
* @param clientCapabilities - The `ClientCapabilities` object from the
|
|
319
|
+
* MCP SDK initialize handshake. May be `null` or `undefined` if the
|
|
320
|
+
* client never sent capabilities.
|
|
321
|
+
* @returns The MCP Apps capability if the client advertised support,
|
|
322
|
+
* otherwise `undefined`.
|
|
323
|
+
*
|
|
324
|
+
* @example
|
|
325
|
+
* ```typescript
|
|
326
|
+
* const cap = getMcpAppsCapability(client.getClientCapabilities());
|
|
327
|
+
* if (cap?.mimeTypes?.includes(MCP_APP_MIME_TYPE)) {
|
|
328
|
+
* // register UI-rendering tools
|
|
329
|
+
* } else {
|
|
330
|
+
* // register text-only fallback tools
|
|
331
|
+
* }
|
|
332
|
+
* ```
|
|
333
|
+
*/
|
|
334
|
+
export function getMcpAppsCapability(
|
|
335
|
+
clientCapabilities:
|
|
336
|
+
| (Record<string, unknown> & { extensions?: Record<string, unknown> })
|
|
337
|
+
| null
|
|
338
|
+
| undefined,
|
|
339
|
+
): McpAppsClientCapability | undefined {
|
|
340
|
+
if (clientCapabilities === null || clientCapabilities === undefined) {
|
|
341
|
+
return undefined;
|
|
342
|
+
}
|
|
343
|
+
const extensions = clientCapabilities.extensions;
|
|
344
|
+
if (extensions === null || typeof extensions !== "object") {
|
|
345
|
+
return undefined;
|
|
346
|
+
}
|
|
347
|
+
const raw = extensions[MCP_APPS_EXTENSION_ID];
|
|
348
|
+
if (raw === null || typeof raw !== "object") {
|
|
349
|
+
return undefined;
|
|
350
|
+
}
|
|
351
|
+
// We have a capability object — extract known fields defensively.
|
|
352
|
+
const result: McpAppsClientCapability = {};
|
|
353
|
+
const rawMimeTypes = (raw as Record<string, unknown>).mimeTypes;
|
|
354
|
+
if (Array.isArray(rawMimeTypes)) {
|
|
355
|
+
const validMimeTypes = rawMimeTypes.filter(
|
|
356
|
+
(m): m is string => typeof m === "string",
|
|
357
|
+
);
|
|
358
|
+
if (validMimeTypes.length > 0) {
|
|
359
|
+
result.mimeTypes = validMimeTypes;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
return result;
|
|
363
|
+
}
|
|
364
|
+
|
|
254
365
|
// ============================================
|
|
255
366
|
// MCP Tool Types
|
|
256
367
|
// ============================================
|