Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 120 additions & 0 deletions src/app-capabilities.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import type { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { App } from "./app";
import { AppBridge, type McpUiHostCapabilities } from "./app-bridge";

const testHostInfo = { name: "TestHost", version: "1.0.0" };
const testAppInfo = { name: "TestApp", version: "1.0.0" };

function createMockClient() {
return {
getServerCapabilities: () => ({}),
request: async () => ({}) as never,
notification: async () => {},
};
}

describe("App capabilities", () => {
let app: App;
let bridge: AppBridge;
let appTransport: InMemoryTransport;
let bridgeTransport: InMemoryTransport;

beforeEach(() => {
[appTransport, bridgeTransport] = InMemoryTransport.createLinkedPair();
});

afterEach(async () => {
await appTransport.close();
await bridgeTransport.close();
});

it("throws when calling openLink without host capability", async () => {
const limitedCaps: McpUiHostCapabilities = {}; // No openLinks
app = new App(testAppInfo, {}, { autoResize: false });
bridge = new AppBridge(createMockClient() as Client, testHostInfo, limitedCaps);

await bridge.connect(bridgeTransport);
await app.connect(appTransport);

const promise = app.openLink({ url: "https://example.com" });
await expect(promise).rejects.toThrow("Host does not support opening links");
});

it("throws when calling sendMessage without host capability", async () => {
const limitedCaps: McpUiHostCapabilities = {}; // No message
app = new App(testAppInfo, {}, { autoResize: false });
bridge = new AppBridge(createMockClient() as Client, testHostInfo, limitedCaps);

await bridge.connect(bridgeTransport);
await app.connect(appTransport);

const promise = app.sendMessage({ role: "user", content: [] });
await expect(promise).rejects.toThrow("Host does not support messages");
});

it("throws when calling callServerTool without host capability", async () => {
const limitedCaps: McpUiHostCapabilities = {}; // No serverTools
app = new App(testAppInfo, {}, { autoResize: false });
bridge = new AppBridge(createMockClient() as Client, testHostInfo, limitedCaps);

await bridge.connect(bridgeTransport);
await app.connect(appTransport);

const promise = app.callServerTool({ name: "test", arguments: {} });
await expect(promise).rejects.toThrow("Host does not support server tools");
});

it("throws when calling updateModelContext without host capability", async () => {
const limitedCaps: McpUiHostCapabilities = {}; // No updateModelContext
app = new App(testAppInfo, {}, { autoResize: false });
bridge = new AppBridge(createMockClient() as Client, testHostInfo, limitedCaps);

await bridge.connect(bridgeTransport);
await app.connect(appTransport);

const promise = app.updateModelContext({ content: [] });
await expect(promise).rejects.toThrow("Host does not support model context updates");
});

it("throws when sending log message without host capability", async () => {
const limitedCaps: McpUiHostCapabilities = {}; // No logging
app = new App(testAppInfo, {}, { autoResize: false });
bridge = new AppBridge(createMockClient() as Client, testHostInfo, limitedCaps);

await bridge.connect(bridgeTransport);
await app.connect(appTransport);

const promise = app.sendLog({ level: "info", data: "test" });
await expect(promise).rejects.toThrow("Host does not support logging");
});

it("succeeds when capabilities are present", async () => {
const fullCaps: McpUiHostCapabilities = {
openLinks: {},
message: {},
serverTools: {},
updateModelContext: {},
logging: {},
};
app = new App(testAppInfo, {}, { autoResize: false });
bridge = new AppBridge(createMockClient() as Client, testHostInfo, fullCaps);

// Mock bridge handlers to prevent "Method not found" errors
bridge.onopenlink = async () => ({});
bridge.onmessage = async () => ({});
bridge.oncalltool = async () => ({ content: [] });
bridge.onupdatemodelcontext = async () => ({});
bridge.onloggingmessage = () => {};

await bridge.connect(bridgeTransport);
await app.connect(appTransport);

await app.openLink({ url: "https://example.com" });
await app.sendMessage({ role: "user", content: [] });
await app.callServerTool({ name: "test", arguments: {} });
await app.updateModelContext({ content: [] });
await app.sendLog({ level: "info", data: "test" });
});
});
78 changes: 75 additions & 3 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@ export class App extends Protocol<AppRequest, AppNotification, AppResult> {
private _capabilities: McpUiAppCapabilities = {},
private options: AppOptions = { autoResize: true },
) {
super(options);
super({ enforceStrictCapabilities: true, ...options });

this.setRequestHandler(PingRequestSchema, (request) => {
console.log("Received ping:", request.params);
Expand Down Expand Up @@ -636,7 +636,59 @@ export class App extends Protocol<AppRequest, AppNotification, AppResult> {
* @internal
*/
assertCapabilityForMethod(method: AppRequest["method"]): void {
// TODO
const caps = this.getHostCapabilities();

// If we're not connected yet (caps is undefined), we skip validation.
// The Protocol class handles connection state checks separately if needed.
if (!caps) {
return;
}

switch (method) {
case "ui/open-link":
if (!caps.openLinks) {
throw new Error(
`Host does not support opening links (required for ${method})`,
);
}
break;
case "ui/message":
if (!caps.message) {
throw new Error(
`Host does not support messages (required for ${method})`,
);
}
break;
case "ui/update-model-context":
if (!caps.updateModelContext) {
throw new Error(
`Host does not support model context updates (required for ${method})`,
);
}
break;
case "tools/call":
case "tools/list":
if (!caps.serverTools) {
throw new Error(
`Host does not support server tools (required for ${method})`,
);
}
break;
case "resources/list":
case "resources/read":
case "resources/templates/list":
if (!caps.serverResources) {
throw new Error(
`Host does not support server resources (required for ${method})`,
);
}
break;
case "ui/request-display-mode":
// Display mode requests don't require a specific capability object,
// but checking availableDisplayModes in context would be the runtime check.
// Capabilities doesn't have a flag for this, it's core UI.
break;
}
}

/**
Expand Down Expand Up @@ -666,7 +718,27 @@ export class App extends Protocol<AppRequest, AppNotification, AppResult> {
* @internal
*/
assertNotificationCapability(method: AppNotification["method"]): void {
// TODO
const caps = this.getHostCapabilities();

if (!caps) {
return;
}

switch (method) {
case "notifications/message":
if (!caps.logging) {
throw new Error(
`Host does not support logging (required for ${method})`,
);
}
break;
case "ui/notifications/size-changed":
// Core capability, always allowed
break;
case "ui/notifications/initialized":
// Core capability, always allowed
break;
}
}

/**
Expand Down