Skip to content

Commit d9a14ed

Browse files
committed
feat(server): add hasUiSupport/getUiCapability for experimental+extensions
Support double-tagging for MCP Apps capability negotiation: - Check both experimental and extensions fields in client capabilities - Add hasUiSupport() to easily check if client supports MCP Apps - Add getUiCapability() to retrieve the capability settings - Update spec to document both capability locations This enables forward compatibility as MCP transitions from experimental to the extensions field (SEP-1724). Claude-Generated-By: Claude Code (cli/claude-opus-4-5=100%) Claude-Steers: 0 Claude-Permission-Prompts: 0 Claude-Escapes: 0
1 parent f6ee5d5 commit d9a14ed

3 files changed

Lines changed: 338 additions & 6 deletions

File tree

specification/draft/apps.mdx

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1412,11 +1412,43 @@ Note: Tools with `visibility: ["app"]` are hidden from the agent but remain call
14121412

14131413
### Client\<\>Server Capability Negotiation
14141414

1415-
Clients and servers negotiate MCP Apps support through the standard MCP extensions capability mechanism (defined in SEP-1724).
1415+
Clients and servers negotiate MCP Apps support using the extension identifier `io.modelcontextprotocol/ui`.
1416+
1417+
#### Capability Location
1418+
1419+
The MCP Apps capability can be advertised in either of two locations within `ClientCapabilities`:
1420+
1421+
1. **`experimental`** (currently preferred): The `experimental` field is part of the current MCP schema and allows arbitrary extension data. Use this for maximum compatibility.
1422+
1423+
2. **`extensions`** (future): Once SEP-1724 is accepted and deployed, `extensions` will be the canonical location. Servers SHOULD check both locations for forward compatibility.
14161424

14171425
#### Client (Host) Capabilities
14181426

1419-
Clients advertise MCP Apps support in the initialize request using the extension identifier `io.modelcontextprotocol/ui`:
1427+
Clients advertise MCP Apps support in the initialize request:
1428+
1429+
**Using `experimental` (recommended for current deployments):**
1430+
1431+
```json
1432+
{
1433+
"method": "initialize",
1434+
"params": {
1435+
"protocolVersion": "2024-11-05",
1436+
"capabilities": {
1437+
"experimental": {
1438+
"io.modelcontextprotocol/ui": {
1439+
"mimeTypes": ["text/html;profile=mcp-app"]
1440+
}
1441+
}
1442+
},
1443+
"clientInfo": {
1444+
"name": "claude-desktop",
1445+
"version": "1.0.0"
1446+
}
1447+
}
1448+
}
1449+
```
1450+
1451+
**Using `extensions` (once SEP-1724 is accepted):**
14201452

14211453
```json
14221454
{
@@ -1449,13 +1481,12 @@ Future versions may add additional settings:
14491481

14501482
#### Server Behavior
14511483

1452-
Servers SHOULD check client (host would-be) capabilities before registering UI-enabled tools:
1484+
Servers SHOULD check both `experimental` and `extensions` before registering UI-enabled tools. The SDK provides the `hasUiSupport` helper for this:
14531485

14541486
```typescript
1455-
const hasUISupport =
1456-
clientCapabilities?.extensions?.["io.modelcontextprotocol/ui"]?.mimeTypes?.includes("text/html;profile=mcp-app");
1487+
import { hasUiSupport } from "@modelcontextprotocol/ext-apps/server";
14571488

1458-
if (hasUISupport) {
1489+
if (hasUiSupport(clientCapabilities)) {
14591490
// Register tools with UI templates
14601491
server.registerTool("get_weather", {
14611492
description: "Get weather with interactive dashboard",

src/server/index.test.ts

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ import {
44
registerAppResource,
55
RESOURCE_URI_META_KEY,
66
RESOURCE_MIME_TYPE,
7+
hasUiSupport,
8+
getUiCapability,
9+
EXTENSION_ID,
710
} from "./index";
811
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
912

@@ -318,3 +321,162 @@ describe("registerAppResource", () => {
318321
expect(result).toEqual(expectedResult);
319322
});
320323
});
324+
325+
describe("hasUiSupport", () => {
326+
const MIME_TYPE = "text/html;profile=mcp-app";
327+
328+
it("should return false for null/undefined capabilities", () => {
329+
expect(hasUiSupport(null)).toBe(false);
330+
expect(hasUiSupport(undefined)).toBe(false);
331+
});
332+
333+
it("should return false for empty capabilities", () => {
334+
expect(hasUiSupport({})).toBe(false);
335+
});
336+
337+
it("should detect support in experimental field", () => {
338+
const caps = {
339+
experimental: {
340+
[EXTENSION_ID]: {
341+
mimeTypes: [MIME_TYPE],
342+
},
343+
},
344+
};
345+
expect(hasUiSupport(caps)).toBe(true);
346+
});
347+
348+
it("should detect support in extensions field", () => {
349+
const caps = {
350+
extensions: {
351+
[EXTENSION_ID]: {
352+
mimeTypes: [MIME_TYPE],
353+
},
354+
},
355+
};
356+
expect(hasUiSupport(caps)).toBe(true);
357+
});
358+
359+
it("should detect support when both fields are present", () => {
360+
const caps = {
361+
experimental: {
362+
[EXTENSION_ID]: {
363+
mimeTypes: [MIME_TYPE],
364+
},
365+
},
366+
extensions: {
367+
[EXTENSION_ID]: {
368+
mimeTypes: [MIME_TYPE],
369+
},
370+
},
371+
};
372+
expect(hasUiSupport(caps)).toBe(true);
373+
});
374+
375+
it("should return false if MIME type is not in the list", () => {
376+
const caps = {
377+
experimental: {
378+
[EXTENSION_ID]: {
379+
mimeTypes: ["text/plain"],
380+
},
381+
},
382+
};
383+
expect(hasUiSupport(caps)).toBe(false);
384+
});
385+
386+
it("should check for custom MIME type when specified", () => {
387+
const caps = {
388+
experimental: {
389+
[EXTENSION_ID]: {
390+
mimeTypes: ["application/x-custom"],
391+
},
392+
},
393+
};
394+
expect(hasUiSupport(caps, "application/x-custom")).toBe(true);
395+
expect(hasUiSupport(caps, MIME_TYPE)).toBe(false);
396+
});
397+
398+
it("should return false when extension ID is missing", () => {
399+
const caps = {
400+
experimental: {
401+
"some-other-extension": {
402+
mimeTypes: [MIME_TYPE],
403+
},
404+
},
405+
};
406+
expect(hasUiSupport(caps)).toBe(false);
407+
});
408+
409+
it("should return false when mimeTypes is missing", () => {
410+
const caps = {
411+
experimental: {
412+
[EXTENSION_ID]: {},
413+
},
414+
};
415+
expect(hasUiSupport(caps)).toBe(false);
416+
});
417+
});
418+
419+
describe("getUiCapability", () => {
420+
const MIME_TYPE = "text/html;profile=mcp-app";
421+
422+
it("should return undefined for null/undefined capabilities", () => {
423+
expect(getUiCapability(null)).toBeUndefined();
424+
expect(getUiCapability(undefined)).toBeUndefined();
425+
});
426+
427+
it("should return undefined for empty capabilities", () => {
428+
expect(getUiCapability({})).toBeUndefined();
429+
});
430+
431+
it("should return capability from experimental field", () => {
432+
const caps = {
433+
experimental: {
434+
[EXTENSION_ID]: {
435+
mimeTypes: [MIME_TYPE],
436+
},
437+
},
438+
};
439+
const result = getUiCapability(caps);
440+
expect(result).toEqual({ mimeTypes: [MIME_TYPE] });
441+
});
442+
443+
it("should return capability from extensions field", () => {
444+
const caps = {
445+
extensions: {
446+
[EXTENSION_ID]: {
447+
mimeTypes: [MIME_TYPE],
448+
},
449+
},
450+
};
451+
const result = getUiCapability(caps);
452+
expect(result).toEqual({ mimeTypes: [MIME_TYPE] });
453+
});
454+
455+
it("should prefer extensions over experimental when both are present", () => {
456+
const caps = {
457+
experimental: {
458+
[EXTENSION_ID]: {
459+
mimeTypes: ["text/plain"],
460+
},
461+
},
462+
extensions: {
463+
[EXTENSION_ID]: {
464+
mimeTypes: [MIME_TYPE],
465+
},
466+
},
467+
};
468+
const result = getUiCapability(caps);
469+
expect(result).toEqual({ mimeTypes: [MIME_TYPE] });
470+
});
471+
472+
it("should return undefined when extension ID is missing", () => {
473+
const caps = {
474+
experimental: {
475+
"some-other-extension": {
476+
mimeTypes: [MIME_TYPE],
477+
},
478+
},
479+
};
480+
expect(getUiCapability(caps)).toBeUndefined();
481+
});
482+
});

src/server/index.ts

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,3 +281,142 @@ export function registerAppResource(
281281
readCallback,
282282
);
283283
}
284+
285+
/**
286+
* Extension identifier for MCP Apps capability negotiation.
287+
*
288+
* Used as the key in `experimental` or `extensions` to advertise MCP Apps support.
289+
*/
290+
export const EXTENSION_ID = "io.modelcontextprotocol/ui";
291+
292+
/**
293+
* MCP Apps capability settings advertised by clients.
294+
*
295+
* @see {@link hasUiSupport} for checking client support
296+
*/
297+
export interface McpUiClientCapability {
298+
/**
299+
* Array of supported MIME types for UI resources.
300+
* Must include `"text/html;profile=mcp-app"` for MCP Apps support.
301+
*/
302+
mimeTypes?: string[];
303+
}
304+
305+
/**
306+
* Check if client capabilities indicate MCP Apps support.
307+
*
308+
* This helper checks both `experimental` and `extensions` fields for the
309+
* MCP Apps capability, providing forward compatibility as the MCP specification
310+
* evolves. Currently, `experimental` is preferred (it's part of the existing
311+
* MCP schema); once SEP-1724 is accepted, `extensions` will be the canonical
312+
* location.
313+
*
314+
* @param clientCapabilities - The client capabilities from the initialize response
315+
* @param mimeType - MIME type to check for (defaults to `"text/html;profile=mcp-app"`)
316+
* @returns `true` if the client supports MCP Apps with the specified MIME type
317+
*
318+
* @example Basic usage in server initialization
319+
* ```typescript
320+
* import { hasUiSupport, registerAppTool } from "@modelcontextprotocol/ext-apps/server";
321+
*
322+
* server.oninitialized = ({ clientCapabilities }) => {
323+
* if (hasUiSupport(clientCapabilities)) {
324+
* registerAppTool(server, "weather", {
325+
* description: "Get weather with interactive dashboard",
326+
* _meta: { ui: { resourceUri: "ui://weather/dashboard" } },
327+
* }, weatherHandler);
328+
* } else {
329+
* // Register text-only fallback
330+
* server.registerTool("weather", {
331+
* description: "Get weather as text",
332+
* }, textWeatherHandler);
333+
* }
334+
* };
335+
* ```
336+
*
337+
* @example Checking for specific MIME type
338+
* ```typescript
339+
* if (hasUiSupport(clientCapabilities, "application/x-custom-widget")) {
340+
* // Client supports custom widget MIME type
341+
* }
342+
* ```
343+
*/
344+
export function hasUiSupport(
345+
clientCapabilities:
346+
| {
347+
experimental?: Record<string, unknown>;
348+
extensions?: Record<string, unknown>;
349+
}
350+
| null
351+
| undefined,
352+
mimeType: string = RESOURCE_MIME_TYPE,
353+
): boolean {
354+
if (!clientCapabilities) {
355+
return false;
356+
}
357+
358+
// Check experimental field (current MCP schema)
359+
const experimentalCap = clientCapabilities.experimental?.[
360+
EXTENSION_ID
361+
] as McpUiClientCapability | undefined;
362+
if (experimentalCap?.mimeTypes?.includes(mimeType)) {
363+
return true;
364+
}
365+
366+
// Check extensions field (future SEP-1724)
367+
const extensionsCap = clientCapabilities.extensions?.[
368+
EXTENSION_ID
369+
] as McpUiClientCapability | undefined;
370+
if (extensionsCap?.mimeTypes?.includes(mimeType)) {
371+
return true;
372+
}
373+
374+
return false;
375+
}
376+
377+
/**
378+
* Get MCP Apps capability settings from client capabilities.
379+
*
380+
* This helper retrieves the capability object from either `experimental` or
381+
* `extensions`, preferring `extensions` when both are present (for forward
382+
* compatibility with SEP-1724).
383+
*
384+
* @param clientCapabilities - The client capabilities from the initialize response
385+
* @returns The MCP Apps capability settings, or `undefined` if not supported
386+
*
387+
* @example
388+
* ```typescript
389+
* import { getUiCapability } from "@modelcontextprotocol/ext-apps/server";
390+
*
391+
* const uiCap = getUiCapability(clientCapabilities);
392+
* if (uiCap?.mimeTypes?.includes("text/html;profile=mcp-app")) {
393+
* // Client supports MCP Apps
394+
* }
395+
* ```
396+
*/
397+
export function getUiCapability(
398+
clientCapabilities:
399+
| {
400+
experimental?: Record<string, unknown>;
401+
extensions?: Record<string, unknown>;
402+
}
403+
| null
404+
| undefined,
405+
): McpUiClientCapability | undefined {
406+
if (!clientCapabilities) {
407+
return undefined;
408+
}
409+
410+
// Prefer extensions when available (forward compatibility with SEP-1724)
411+
const extensionsCap = clientCapabilities.extensions?.[
412+
EXTENSION_ID
413+
] as McpUiClientCapability | undefined;
414+
if (extensionsCap) {
415+
return extensionsCap;
416+
}
417+
418+
// Fall back to experimental (current MCP schema)
419+
return clientCapabilities.experimental?.[
420+
EXTENSION_ID
421+
] as McpUiClientCapability | undefined;
422+
}

0 commit comments

Comments
 (0)