From c1321a3b897c5110e79029e865a13e10a0f2213d Mon Sep 17 00:00:00 2001 From: Moltbot Date: Thu, 29 Jan 2026 19:34:01 +0000 Subject: [PATCH] feat(app): add runtime capability checks for host requests --- src/app-capabilities.test.ts | 120 +++++++++++++++++++++++++++++++++++ src/app.ts | 78 ++++++++++++++++++++++- 2 files changed, 195 insertions(+), 3 deletions(-) create mode 100644 src/app-capabilities.test.ts diff --git a/src/app-capabilities.test.ts b/src/app-capabilities.test.ts new file mode 100644 index 00000000..ecb78c4c --- /dev/null +++ b/src/app-capabilities.test.ts @@ -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" }); + }); +}); diff --git a/src/app.ts b/src/app.ts index f813f44c..8bcf453b 100644 --- a/src/app.ts +++ b/src/app.ts @@ -212,7 +212,7 @@ export class App extends Protocol { private _capabilities: McpUiAppCapabilities = {}, private options: AppOptions = { autoResize: true }, ) { - super(options); + super({ enforceStrictCapabilities: true, ...options }); this.setRequestHandler(PingRequestSchema, (request) => { console.log("Received ping:", request.params); @@ -636,7 +636,59 @@ export class App extends Protocol { * @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; + } } /** @@ -666,7 +718,27 @@ export class App extends Protocol { * @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; + } } /**