Model Context Protocol
MCP Apps
An MCP App is a web interface attached to a tool. An Apps-capable host reads the tool's associated HTML resource, renders it in a sandbox, and delivers tool results to it. Soklet supplies the tool/resource registration and protocol metadata; the host owns rendering and the browser bridge.
An App supplements the tool's ordinary response. Keep useful text and structured content for clients that do not render Apps. The Toy Store example adds a read-only catalog view without changing its existing authentication or enabling purchases.
An App does not receive the MCP access token. Its host makes authorized MCP requests on its behalf. To protect those requests with OAuth, follow MCP OAuth Integration; continue to authorize each tool and resource read for the admitted caller.
Associate a tool with an HTML resource
Add @McpAppTool to an existing @McpTool method. The associated resource must be an exact, normalized ASCII ui:// URI registered in the same endpoint, with MIME type text/html;profile=mcp-app; a resource template is not a substitute.
Inside your annotated endpoint:
public record CatalogResult(String summary, List<String> items) {}
@McpTool(
name = "show_catalog",
description = "Shows matching catalog items.",
structuredContentMirroredAsText = true,
protocolVersions = {McpProtocolVersion.V2026_07_28}
)
@McpAppTool(resourceUri = "ui://catalog/view-v1",
protocolVersions = {McpProtocolVersion.V2026_07_28})
public CatalogResult showCatalog(
@McpToolArgument(name = "query") Optional<String> query) {
return lookupCatalog(query);
}
public record CatalogResult(String summary, List<String> items) {}
@McpTool(
name = "show_catalog",
description = "Shows matching catalog items.",
structuredContentMirroredAsText = true,
protocolVersions = {McpProtocolVersion.V2026_07_28}
)
@McpAppTool(resourceUri = "ui://catalog/view-v1",
protocolVersions = {McpProtocolVersion.V2026_07_28})
public CatalogResult showCatalog(
@McpToolArgument(name = "query") Optional<String> query) {
return lookupCatalog(query);
}
lookupCatalog is your application's authorized, read-only catalog query. This example retains Soklet's structured-content text mirroring, so the response remains useful without a renderer. If your application formats its own text output, preserve the actual catalog information, not merely “open the App.”
Serve a reviewed, static classpath document through the matching resource:
@McpResource(
uri = "ui://catalog/view-v1",
name = "catalog-view",
mimeType = "text/html;profile=mcp-app",
protocolVersions = {McpProtocolVersion.V2026_07_28}
)
public McpResourceOutput catalogView() throws IOException {
String html;
try (InputStream input = getClass().getResourceAsStream("/mcp/apps/catalog.html")) {
if (input == null)
throw new IOException("Catalog UI resource is missing");
html = new String(input.readAllBytes(), StandardCharsets.UTF_8);
}
McpAppResourceMetadata policy = McpAppResourceMetadata.builder()
.contentSecurityPolicy(
McpAppResourceMetadata.ContentSecurityPolicy.defaultInstance())
.prefersBorder(true)
.build();
return McpResourceOutput.fromContent(
McpTextResourceContents.withUriAndText(
URI.create("ui://catalog/view-v1"), html)
.mimeType("text/html;profile=mcp-app")
.appResourceMetadata(policy)
.build());
}
@McpResource(
uri = "ui://catalog/view-v1",
name = "catalog-view",
mimeType = "text/html;profile=mcp-app",
protocolVersions = {McpProtocolVersion.V2026_07_28}
)
public McpResourceOutput catalogView() throws IOException {
String html;
try (InputStream input = getClass().getResourceAsStream("/mcp/apps/catalog.html")) {
if (input == null)
throw new IOException("Catalog UI resource is missing");
html = new String(input.readAllBytes(), StandardCharsets.UTF_8);
}
McpAppResourceMetadata policy = McpAppResourceMetadata.builder()
.contentSecurityPolicy(
McpAppResourceMetadata.ContentSecurityPolicy.defaultInstance())
.prefersBorder(true)
.build();
return McpResourceOutput.fromContent(
McpTextResourceContents.withUriAndText(
URI.create("ui://catalog/view-v1"), html)
.mimeType("text/html;profile=mcp-app")
.appResourceMetadata(policy)
.build());
}
Use com.soklet.*, com.soklet.annotation.*, java.io.IOException, java.io.InputStream, java.net.URI, java.nio.charset.StandardCharsets, and java.util.List and Optional. Configure the annotated endpoint and admission controller as described in Endpoint Authoring and Security and Policy.
The classpath path above is fixed application code, not a path supplied by the caller. Package the HTML and its bundled browser code with the application. Do not interpolate tokens, account data, or private tool results into that document: hosts may prefetch it before invoking the tool. Send caller-specific data through the authorized tool result instead.
For programmatic tools, attach McpAppToolMetadata using the tool registration builder's appToolMetadata(...) method. Resource policy belongs on the returned resource contents, as above. Do not also write the same typed-owned fields into raw metadata; Soklet rejects the conflict.
Connect browser code through the host
Use the official @modelcontextprotocol/ext-apps browser SDK. The Toy Store example bundles SDK 2.0.0 into its checked-in HTML; it does not load scripts from a CDN or send an MCP bearer token into the iframe.
The essential interaction is:
import { App } from '@modelcontextprotocol/ext-apps'
const app = new App({ name: 'Catalog', version: '1.0.0' }, {})
app.ontoolresult = (result) => renderCatalog(result.structuredContent)
await app.connect()
async function refreshCatalog(query) {
clearCatalog()
const result = await app.callServerTool({
name: 'list_toys',
arguments: query ? { query } : {},
})
if (result.isError) throw new Error('Catalog refresh failed')
renderCatalog(result.structuredContent)
}
import { App } from '@modelcontextprotocol/ext-apps'
const app = new App({ name: 'Catalog', version: '1.0.0' }, {})
app.ontoolresult = (result) => renderCatalog(result.structuredContent)
await app.connect()
async function refreshCatalog(query) {
clearCatalog()
const result = await app.callServerTool({
name: 'list_toys',
arguments: query ? { query } : {},
})
if (result.isError) throw new Error('Catalog refresh failed')
renderCatalog(result.structuredContent)
}
Here renderCatalog and clearCatalog are application UI functions. Register result handlers before connecting, validate the result shape before rendering, and insert untrusted strings with textContent, not innerHTML. A complete UI also handles tool errors, connection failure, loading state, and repeated requests; keep stale personalized data cleared when a refresh fails. The Toy Store source contains the complete catalog implementation and its bundle inputs.
app.connect() establishes the iframe-to-host Apps bridge; it does not add an initialize handshake or session to Soklet's MCP HTTP transport. callServerTool asks the host to perform a new tool call. The host supplies its MCP authentication and current protocol metadata; Soklet must independently authorize that operation. The iframe should not make a direct authenticated fetch to the MCP listener.
Revocation and already displayed content
Changes to account permissions or credential validity take effect on the next authorized MCP request. They do not erase results already delivered to the host, model, or iframe. An idle App needs application-owned expiry, refresh, or a supported notification path if it must stop displaying data immediately. An already admitted request also needs application-owned checks if permission must be re-evaluated before it finishes.
Keep protected content cleared on refresh failure, cancellation, timeout, and teardown. A host locale or time-zone notification can invalidate pending work; guard against a late result restoring the old view. Reopening should create a new authorized instance. A host's notification behavior must be checked in the host used by the application.
Localize application data separately from catalog metadata
Toy Store's successful catalog results include a locale selected by the server from the authenticated account's preferences and supported translations (en-US, de-DE, or pt-BR). Its App uses that value to translate its own interface, while preserving server-formatted price and date strings. The initial waiting view is English; missing locale fields in older results and valid but unsupported locale tags also fall back to English. Malformed locale fields fail result validation and clear the catalog instead of selecting a language.
A fresh authorized result can change the displayed language. Browser or host preferences and tool arguments do not override the account's locale. The request's HTTP Accept-Language still controls localizable MCP catalog metadata, which is a separate concern. When the host delivers a changed locale or time-zone notification, this example invalidates the existing view and requires reopening it. All accounts receive the same credential-free static HTML; language selection arrives with the authorized result, not through personalized resource contents.
Capabilities and visibility
The host advertises the Apps extension on each request in clientCapabilities.extensions:
{
"io.modelcontextprotocol/ui": {
"mimeTypes": ["text/html;profile=mcp-app"]
}
}
{
"io.modelcontextprotocol/ui": {
"mimeTypes": ["text/html;profile=mcp-app"]
}
}
An empty extension object or support for plain text/html does not declare support for the Apps MIME type. Soklet derives its server advertisement from the endpoint's Apps registrations; there is no separate server-wide enable flag.
The annotation defaults to both MODEL and APP visibility. Non-Apps clients keep model-visible tools, without the recognized Apps presentation metadata. An app-only tool is excluded from their catalog, and its direct invocation requires the Apps capability. An empty visibility set exposes neither audience. These are audience rules, not caller identity: any client can claim Apps capability, and an APP call is not proof of an authorized UI interaction.
Soklet's static resource catalog lists eligible UI resources for both Apps-capable and non-Apps clients; resource listing is not capability-filtered like the tool catalog. A custom resource-list handler owns its returned page. If you want to omit UI descriptors for non-Apps clients, check the request's getClientCapabilities().supportsAppMimeType("text/html;profile=mcp-app") before including them. Soklet does not automatically filter that custom page. The tool association also gives the host the exact URI. Listing is not authorization. An exact resource read still traverses admission and the resource handler even if the caller did not discover the resource. Authenticate and authorize every read, including host prefetches.
Browser policy is not application authorization
ContentSecurityPolicy.defaultInstance() explicitly supplies empty CSP allowlists; builder().build() is equivalent. Use the builder when adding permitted origins. Empty allowlists permit no additional external connection, asset, or frame origins and keep document base URIs same-origin. The browser host enforces these constraints and may impose stricter rules. An origin in an allowlist does not guarantee that a host will accept it; declared nested frames or cross-origin base URLs may still be blocked. Omitting .contentSecurityPolicy(...) instead leaves its getter as Optional.empty() and omits CSP metadata; the host still applies the Apps specification's restrictive defaults. Omission does not disable sandbox protection.
McpAppResourceMetadata.Permission can request camera, microphone, geolocation, or clipboard-write access; permissions default to an empty set, requesting none. Permission requests do not grant access, and a domain hint does not provision a sandbox origin. prefersBorder(true) asks the host for a framed presentation; it is a presentation hint, not App CSS or a security setting. Omitting the hint leaves the choice to the host.
Neither CSP nor browser permissions grant tool access, establish a user or tenant, or replace business authorization. Treat every bridge call like an ordinary authenticated tool call. UI metadata is not a secret channel: hosts may retain or forward it, and text/structured content can be model-visible. Use the tool-result sanitizer when application policy requires result redaction.
Host compatibility
Use a host that supports both Apps and Soklet's stateless MCP 2026-07-28 transport. Metadata or resource-read success alone does not prove rendering, bridge calls, or sandbox enforcement.
Authenticated Apps support remains unverified in unmodified Inspector 2.7.0. Known behavior in the examined Claude web custom connector configuration includes rendering, App-only refresh, caller changes, denial/revocation, Arabic rendering, and locale changes during a pending refresh. That host blocks declared nested frames and cross-origin base URLs. Its backend build version is unavailable, so compatibility across versions is unverified. Production OAuth recovery and host time-zone changes also remain unverified.
The Toy Store example demonstrates application integration. Host support varies. Test the exact host/version and authentication route you intend to deploy, retaining ordinary text output as the fallback. See Compatibility and Scope.

