@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 CHANGED
@@ -41,13 +41,35 @@ stack.
41
41
  ## Install
42
42
 
43
43
  ```bash
44
- # npm
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 { MCP_APP_MIME_TYPE } from "./src/types.js";
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.13.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.15.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",
@@ -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
  }
@@ -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"],
@@ -123,10 +123,10 @@ export function createAuthMiddleware(provider: AuthProvider): Middleware {
123
123
  return next();
124
124
  }
125
125
 
126
- const metadata = provider.getResourceMetadata();
127
- const resource = metadata.resource;
128
- const base = resource.endsWith("/") ? resource.slice(0, -1) : resource;
129
- const metadataUrl = `${base}/.well-known/oauth-protected-resource`;
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) {
@@ -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
 
@@ -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
- /** Resource identifier (URL of this MCP server) */
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 { MCP_APP_MIME_TYPE, MCP_APP_URI_SCHEME } from "./types.js";
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
- buildMetadataUrl(metadata.resource),
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
- buildMetadataUrl(metadata.resource),
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, Express, Cloudflare Workers,
1778
- * etc.) instead of giving up port ownership to {@link startHttp}.
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
  // ============================================