diff --git a/extension/channel/ax-channel.ts b/extension/channel/ax-channel.ts index da4f171..58ec6b4 100644 --- a/extension/channel/ax-channel.ts +++ b/extension/channel/ax-channel.ts @@ -22,9 +22,9 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import type { PluginRuntime } from "clawdbot/plugin-sdk"; -import type { AxDispatchPayload, AxDispatchResponse, DispatchSession } from "../lib/types.js"; +import type { AxDispatchPayload, AxDispatchResponse, DispatchSession, OutboundConfig } from "../lib/types.js"; import { loadAgentRegistry, getAgent, verifySignature, logRegisteredAgents } from "../lib/auth.js"; -import { sendProgressUpdate } from "../lib/api.js"; +import { sendProgressUpdate, callAxTool } from "../lib/api.js"; import { buildMissionBriefing } from "../lib/context.js"; // ─── Agent Event Tracking ─────────────────────────────────────────────────── @@ -571,11 +571,46 @@ export function getDispatchSession(sessionKey: string): DispatchSession | undefi /** * Create the aX Platform channel plugin */ + +/** + * Resolve auth token for outbound MCP calls. + * Checks token file first, then env var. + */ +async function resolveOutboundAuthToken( + outboundCfg: OutboundConfig, + logger: { error: (msg: string) => void } +): Promise { + let authToken: string | undefined; + + if (outboundCfg.tokenFile) { + try { + const fs = await import("node:fs"); + const tokenPath = outboundCfg.tokenFile.replace(/^~/, process.env.HOME || ""); + const tokenData = JSON.parse(fs.readFileSync(tokenPath, "utf-8")); + authToken = tokenData.access_token; + } catch (err) { + logger.error(`[ax-platform] Failed to read token file: ${err}`); + } + } + + if (!authToken) { + authToken = process.env.AX_ACCESS_TOKEN; + } + + if (!authToken) { + throw new Error("[ax-platform] Outbound failed: No access token configured. Set outbound.tokenFile or AX_ACCESS_TOKEN."); + } + + return authToken; +} + export function createAxChannel(config: { + outbound?: OutboundConfig; agents?: Array<{ id: string; secret: string; handle?: string; env?: string }>; backendUrl?: string; }) { const backendUrl = config.backendUrl || process.env.AX_BACKEND_URL || "http://localhost:8001"; + const outboundConfig = config.outbound || {}; // Load agent registry from config loadAgentRegistry(config.agents); @@ -601,13 +636,108 @@ export function createAxChannel(config: { }, outbound: { - deliveryMode: "direct", + deliveryMode: "direct" as const, + + /** + * Resolve outbound target for aX Platform + * Accepts "space:" or raw space ID + */ + resolveTarget({ to }: { to?: string; accountId?: string | null }) { + const spaceId = to?.replace(/^space:/, "") || outboundConfig?.defaultSpaceId; + if (!spaceId) { + return { ok: false as const, error: new Error("No target space. Use 'space:' or configure defaultSpaceId.") }; + } + return { ok: true as const, to: spaceId }; + }, - // Handle agent responses - this is called by the dispatcher - async sendText({ text, sessionKey }: { text: string; sessionKey?: string }) { - // For aX, responses are returned via HTTP response in the dispatch handler - // This is a no-op since we use sync-over-async pattern - return { ok: true }; + /** + * Send text to aX Platform via MCP JSON-RPC protocol. + * + * Matches Clawdbot ChannelOutboundContext signature: + * (ctx: { cfg, to, text, accountId?, ... }) => Promise + * + * Uses callAxTool which sends proper MCP tools/call JSON-RPC to the server. + */ + async sendText({ text, to }: { + cfg: unknown; + to: string; + text: string; + mediaUrl?: string; + accountId?: string | null; + replyToId?: string | null; + threadId?: string | number | null; + deps?: unknown; + gifPlayback?: boolean; + }) { + const logger = runtime?.logger || { info: console.log, error: console.error }; + const outboundCfg = outboundConfig || {}; + const mcpEndpoint = outboundCfg.mcpEndpoint || process.env.AX_MCP_ENDPOINT || "https://mcp.paxai.app"; + const authToken = await resolveOutboundAuthToken(outboundCfg, logger); + + // Send via MCP JSON-RPC (callAxTool now uses proper protocol) + try { + const result = await callAxTool(mcpEndpoint, authToken, "messages", { + action: "send", + content: text, + space_id: to, + }); + + const messageId = (result && typeof result === "object" && "message_id" in result) + ? String((result as Record).message_id) + : `ax-${Date.now()}`; + + const preview = text.length > 50 ? text.substring(0, 50) + "..." : text; + logger.info(`[ax-platform] Outbound sent to ${to}: ${preview}`); + + return { + channel: "ax-platform" as const, + messageId, + chatId: to, + }; + } catch (err) { + logger.error(`[ax-platform] Outbound error: ${err}`); + throw err; + } + }, + + /** + * Send media to aX Platform (sends caption + media link) + */ + async sendMedia({ text, to, mediaUrl }: { + cfg: unknown; + to: string; + text: string; + mediaUrl?: string; + accountId?: string | null; + replyToId?: string | null; + threadId?: string | number | null; + deps?: unknown; + gifPlayback?: boolean; + }) { + const logger = runtime?.logger || { info: console.log, error: console.error }; + const outboundCfg = outboundConfig || {}; + const mcpEndpoint = outboundCfg.mcpEndpoint || process.env.AX_MCP_ENDPOINT || "https://mcp.paxai.app"; + const authToken = await resolveOutboundAuthToken(outboundCfg, logger); + + const content = mediaUrl ? `${text}\n${mediaUrl}` : text; + try { + const result = await callAxTool(mcpEndpoint, authToken, "messages", { + action: "send", + content, + space_id: to, + }); + const messageId = (result && typeof result === "object" && "message_id" in result) + ? String((result as Record).message_id) + : `ax-${Date.now()}`; + return { + channel: "ax-platform" as const, + messageId, + chatId: to, + }; + } catch (err) { + logger.error(`[ax-platform] Outbound media error: ${err}`); + throw err; + } }, }, diff --git a/extension/index.ts b/extension/index.ts index 5729c52..f076b81 100644 --- a/extension/index.ts +++ b/extension/index.ts @@ -11,6 +11,7 @@ import type { ClawdbotPluginApi, PluginRuntime } from "clawdbot/plugin-sdk"; import { createAxChannel, createDispatchHandler, setAxPlatformRuntime, getDispatchSession } from "./channel/ax-channel.js"; +import type { OutboundConfig } from "./lib/types.js"; import { logRegisteredAgents } from "./lib/auth.js"; import { axMessagesTool } from "./tools/ax-messages.js"; import { axTasksTool } from "./tools/ax-tasks.js"; @@ -18,6 +19,7 @@ import { axContextTool } from "./tools/ax-context.js"; import { axAgentsTool } from "./tools/ax-agents.js"; import { axThreadTool } from "./tools/ax-thread.js"; import { createAxProgressTool } from "./tools/ax-progress.js"; +import { axDashboardTool } from "./tools/ax-dashboard.js"; import { buildMissionBriefing } from "./lib/context.js"; interface AxPlatformConfig { @@ -28,6 +30,7 @@ interface AxPlatformConfig { env?: string; }>; backendUrl?: string; + outbound?: OutboundConfig; } const plugin = { @@ -65,7 +68,7 @@ const plugin = { api.logger.info(`[ax-platform] Config received: agents=${config.agents?.length || 0}, backendUrl=${config.backendUrl || 'default'}`); // Register the aX Platform channel - const channel = createAxChannel(config); + const channel = createAxChannel({ ...config, outbound: config.outbound }); api.registerChannel({ plugin: channel }); api.logger.info("[ax-platform] Channel registered: ax-platform"); @@ -79,7 +82,8 @@ const plugin = { api.registerTool(axAgentsTool, { optional: true }); api.registerTool(axThreadTool, { optional: true }); api.registerTool(createAxProgressTool(api.runtime), { optional: true }); - api.logger.info("[ax-platform] Tools registered: ax_messages, ax_tasks, ax_context, ax_agents, ax_thread, ax_progress"); + api.registerTool(axDashboardTool, { optional: true }); + api.logger.info("[ax-platform] Tools registered: ax_messages, ax_tasks, ax_context, ax_agents, ax_thread, ax_progress, ax_dashboard"); // Register before_agent_start hook for context injection // Uses api.on() event pattern (like memory-lancedb plugin) diff --git a/extension/lib/api.ts b/extension/lib/api.ts index 5dfd72e..31c1594 100644 --- a/extension/lib/api.ts +++ b/extension/lib/api.ts @@ -35,7 +35,13 @@ export async function sendProgressUpdate( } /** - * Call aX MCP tool via backend API + * Call aX MCP tool via JSON-RPC (MCP protocol) + * + * The MCP server speaks JSON-RPC at {mcpEndpoint}/mcp. + * We send a `tools/call` request with the tool name and arguments. + * + * Previous implementation used REST: `${mcpEndpoint}/tools/${toolName}` + * which doesn't exist on the MCP server. Fixed to use proper MCP JSON-RPC. */ export async function callAxTool( mcpEndpoint: string, @@ -43,18 +49,104 @@ export async function callAxTool( toolName: string, params: Record ): Promise { - const response = await fetch(`${mcpEndpoint}/tools/${toolName}`, { + if (!mcpEndpoint) throw new Error("callAxTool: mcpEndpoint is required"); + if (!authToken) throw new Error("callAxTool: authToken is required"); + if (!toolName) throw new Error("callAxTool: toolName is required"); + + // Ensure endpoint ends with /mcp (the JSON-RPC endpoint) + const rpcUrl = mcpEndpoint.endsWith("/mcp") + ? mcpEndpoint + : `${mcpEndpoint.replace(/\/+$/, "")}/mcp`; + + const body = { + jsonrpc: "2.0", + id: `ax-plugin-${Date.now()}`, + method: "tools/call", + params: { + name: toolName, + arguments: params, + }, + }; + + const response = await fetch(rpcUrl, { method: "POST", headers: { "Content-Type": "application/json", - "Authorization": `Bearer ${authToken}`, + Accept: "application/json, text/event-stream", + Authorization: `Bearer ${authToken}`, }, - body: JSON.stringify(params), + body: JSON.stringify(body), }); if (!response.ok) { - throw new Error(`aX tool ${toolName} failed: ${response.status}`); + const errText = await response.text().catch(() => ""); + throw new Error(`aX tool ${toolName} failed: ${response.status} ${errText}`); + } + + const contentType = response.headers.get("content-type") || ""; + + // Handle SSE (streaming) responses — collect until we get a JSON-RPC result + if (contentType.includes("text/event-stream")) { + const text = await response.text(); + // Parse SSE events — find the last `data:` line with a JSON-RPC result + const lines = text.split("\n"); + for (let i = lines.length - 1; i >= 0; i--) { + const line = lines[i].trim(); + if (line.startsWith("data:")) { + try { + const eventData = JSON.parse(line.slice(5).trim()); + if (eventData.result) { + return extractToolResult(eventData.result); + } + if (eventData.error) { + throw new Error(`MCP error: ${eventData.error.message || JSON.stringify(eventData.error)}`); + } + } catch (parseErr) { + if (parseErr instanceof Error && parseErr.message.startsWith("MCP error:")) throw parseErr; + // Not valid JSON — log for debugging and skip + if (process.env.DEBUG || process.env.LOG_LEVEL === "debug") { + console.debug(`[ax-platform] SSE parse skip: ${line.substring(0, 100)}`); + } + } + } + } + throw new Error(`aX tool ${toolName}: no result in SSE stream`); } - return response.json(); + // Handle plain JSON-RPC response + const rpcResult = await response.json(); + if (rpcResult.error) { + throw new Error(`MCP error: ${rpcResult.error.message || JSON.stringify(rpcResult.error)}`); + } + return extractToolResult(rpcResult.result); +} + +/** + * Extract the text content from an MCP tools/call result. + * + * MCP returns results in the format: + * { content: [{ type: "text", text: "..." }, ...] } + * + * This function: + * 1. Extracts all text content parts from the content array + * 2. Joins them with newlines + * 3. Attempts to parse as JSON (for structured results like message lists) + * 4. Falls back to raw text string if not valid JSON + * 5. Returns the original result if no content array is present + */ +function extractToolResult(result: unknown): unknown { + if (!result || typeof result !== "object") return result; + const r = result as Record; + if (Array.isArray(r.content)) { + const textParts = (r.content as Array<{ type?: string; text?: string }>) + .filter(c => c.type === "text" && c.text) + .map(c => c.text!); + const joined = textParts.join("\n"); + try { + return JSON.parse(joined); + } catch { + return joined || result; + } + } + return result; } diff --git a/extension/lib/types.ts b/extension/lib/types.ts index 505c090..3485cde 100644 --- a/extension/lib/types.ts +++ b/extension/lib/types.ts @@ -99,3 +99,10 @@ export interface DispatchSession { contextData?: ContextData; startTime: number; } + +// Outbound delivery configuration +export interface OutboundConfig { + mcpEndpoint?: string; // Default: https://mcp.paxai.app + tokenFile?: string; // Path to token JSON file + defaultSpaceId?: string; // Default space for delivery +} diff --git a/extension/tools/ax-agents.ts b/extension/tools/ax-agents.ts index 1e3d61f..bb8dc2c 100644 --- a/extension/tools/ax-agents.ts +++ b/extension/tools/ax-agents.ts @@ -18,8 +18,8 @@ export const axAgentsTool = { limit: Type.Optional(Type.Number({ description: "Max results", default: 25 })), }), - async execute(_toolCallId: string, params: Record, context: { sessionKey?: string }) { - const sessionKey = context.sessionKey; + async execute(_toolCallId: string, params: Record, context: Record) { + const sessionKey = (context.sessionKey || context.SessionKey) as string | undefined; const session = sessionKey ? getDispatchSession(sessionKey) : undefined; if (!session?.authToken || !session?.mcpEndpoint) { diff --git a/extension/tools/ax-context.ts b/extension/tools/ax-context.ts index c29edd8..02baebd 100644 --- a/extension/tools/ax-context.ts +++ b/extension/tools/ax-context.ts @@ -21,8 +21,8 @@ export const axContextTool = { prefix: Type.Optional(Type.String({ description: "Prefix filter (for list)" })), }), - async execute(_toolCallId: string, params: Record, context: { sessionKey?: string }) { - const sessionKey = context.sessionKey; + async execute(_toolCallId: string, params: Record, context: Record) { + const sessionKey = (context.sessionKey || context.SessionKey) as string | undefined; const session = sessionKey ? getDispatchSession(sessionKey) : undefined; if (!session?.authToken || !session?.mcpEndpoint) { diff --git a/extension/tools/ax-dashboard.ts b/extension/tools/ax-dashboard.ts new file mode 100644 index 0000000..96baa5c --- /dev/null +++ b/extension/tools/ax-dashboard.ts @@ -0,0 +1,56 @@ +/** + * ax_dashboard Tool + * + * Returns the aX Platform visibility dashboard as an MCP UI resource. + * Implements SEP-1865 (MCP Apps Extension) for interactive UI rendering. + */ + +import type { ClawdbotTool } from "clawdbot/plugin-sdk"; +import { buildDashboardHtml, createMockContext } from '../ui/index.js'; +import type { DashboardContext } from '../ui/index.js'; + +export const axDashboardTool: ClawdbotTool = { + name: "ax_dashboard", + description: "[Preview] Display the aX Platform visibility dashboard. Currently shows demo data — live data integration pending.", + inputSchema: { + type: "object" as const, + properties: { + view: { + type: "string", + enum: ["full", "tasks", "activity"], + description: "Which view to display (default: full)", + }, + }, + }, + + async execute(params: { view?: string }, context: any) { + // Preview mode: uses mock context until live MCP data integration is implemented + const dashboardContext = createMockContext(); + + // Build the HTML dashboard + const html = buildDashboardHtml(dashboardContext); + + // Return as MCP UI resource reference + return { + content: [ + { + type: "resource", + resource: { + uri: "ui://ax-platform/dashboard", + mimeType: "text/html;profile=mcp-app", + text: html, + }, + }, + { + type: "text", + text: `📊 **aX Platform Dashboard**\n\nShowing: ${dashboardContext.space.name}\nAgent: @${dashboardContext.identity.handle}\nTasks: ${dashboardContext.tasks.length} total\n\n_If your client supports MCP Apps, you should see an interactive dashboard above._`, + }, + ], + _meta: { + ui: { + resourceUri: "ui://ax-platform/dashboard", + }, + }, + }; + }, +}; diff --git a/extension/tools/ax-messages.ts b/extension/tools/ax-messages.ts index f4d17fe..5dd4f10 100644 --- a/extension/tools/ax-messages.ts +++ b/extension/tools/ax-messages.ts @@ -18,8 +18,8 @@ export const axMessagesTool = { wait: Type.Optional(Type.Boolean({ description: "Wait for new messages", default: false })), }), - async execute(_toolCallId: string, params: Record, context: { sessionKey?: string }) { - const sessionKey = context.sessionKey; + async execute(_toolCallId: string, params: Record, context: Record) { + const sessionKey = (context.sessionKey || context.SessionKey) as string | undefined; const session = sessionKey ? getDispatchSession(sessionKey) : undefined; if (!session?.authToken || !session?.mcpEndpoint) { diff --git a/extension/tools/ax-tasks.ts b/extension/tools/ax-tasks.ts index 42028bd..a81807f 100644 --- a/extension/tools/ax-tasks.ts +++ b/extension/tools/ax-tasks.ts @@ -23,8 +23,8 @@ export const axTasksTool = { filter: Type.Optional(Type.String({ description: "Filter for list", default: "my_tasks" })), }), - async execute(_toolCallId: string, params: Record, context: { sessionKey?: string }) { - const sessionKey = context.sessionKey; + async execute(_toolCallId: string, params: Record, context: Record) { + const sessionKey = (context.sessionKey || context.SessionKey) as string | undefined; const session = sessionKey ? getDispatchSession(sessionKey) : undefined; if (!session?.authToken || !session?.mcpEndpoint) { diff --git a/extension/tools/ax-thread.ts b/extension/tools/ax-thread.ts index 703c08d..7cca0c4 100644 --- a/extension/tools/ax-thread.ts +++ b/extension/tools/ax-thread.ts @@ -59,8 +59,8 @@ export const axThreadTool = { })), }), - async execute(_toolCallId: string, params: Record, context: { sessionKey?: string }) { - const sessionKey = context.sessionKey; + async execute(_toolCallId: string, params: Record, context: Record) { + const sessionKey = (context.sessionKey || context.SessionKey) as string | undefined; const session = sessionKey ? getDispatchSession(sessionKey) : undefined; if (!session?.authToken || !session?.mcpEndpoint) { diff --git a/extension/ui/dashboard.ts b/extension/ui/dashboard.ts new file mode 100644 index 0000000..4cb1603 --- /dev/null +++ b/extension/ui/dashboard.ts @@ -0,0 +1,265 @@ +/** + * MCP UI Dashboard Builder + * + * Generates the HTML for the aX Platform visibility dashboard. + * Built on SEP-1865 (MCP Apps Extension) using Flowbite/Tailwind components. + */ + +import type { DashboardContext, Task, ActivityBreadcrumb } from './types.js'; + +/** + * Build the complete dashboard HTML + */ +export function buildDashboardHtml(context: DashboardContext): string { + return ` + + + + + aX Platform Dashboard + + + + + + + + ${buildHeader(context)} + + +
+ ${buildKanban(context.tasks)} +
+ + + ${buildActivityPulse(context.recentActivity)} + + + + +`; +} + +function buildHeader(context: DashboardContext): string { + const { identity, space, tools } = context; + + return ` +
+
+ +
+
+
+ ${identity.handle?.charAt(1)?.toUpperCase() || 'A'} +
+
+

${escapeHtml(identity.name || identity.handle)}

+

@${escapeHtml(identity.handle)} • ${escapeHtml(identity.specialization || 'Agent')}

+
+
+
+ + + Online + +
+
+ + +
+ + + + ${escapeHtml(space.name)} + ${space.description ? `•${escapeHtml(space.description)}` : ''} +
+ + +
+ ${tools.available.map(tool => ` + + ✓ ${escapeHtml(tool)} + + `).join('')} + ${tools.restricted.map(tool => ` + + ✗ ${escapeHtml(tool)} + + `).join('')} +
+
+
`; +} + +function buildKanban(tasks: Task[]): string { + const columns = { + not_started: tasks.filter(t => t.status === 'not_started'), + in_progress: tasks.filter(t => t.status === 'in_progress'), + completed: tasks.filter(t => t.status === 'completed'), + }; + + return ` +
+ +
+

+ + Backlog + ${columns.not_started.length} +

+
+ ${columns.not_started.map(task => buildTaskCard(task)).join('')} +
+
+ + +
+

+ + In Progress + ${columns.in_progress.length} +

+
+ ${columns.in_progress.map(task => buildTaskCard(task)).join('')} +
+
+ + +
+

+ + Done + ${columns.completed.length} +

+
+ ${columns.completed.slice(0, 5).map(task => buildTaskCard(task)).join('')} + ${columns.completed.length > 5 ? `

+${columns.completed.length - 5} more

` : ''} +
+
+
`; +} + +function buildTaskCard(task: Task): string { + const priorityColors: Record = { + urgent: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300', + high: 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-300', + medium: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300', + low: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300', + }; + + return ` +
+
+ ${escapeHtml(task.id.slice(0, 8))} + + ${escapeHtml(task.priority)} + +
+

${escapeHtml(task.title)}

+ ${task.description ? `

${escapeHtml(task.description)}

` : ''} + ${task.status !== 'completed' ? ` +
+ + ${!task.assignedTo ? ` + + ` : ''} +
+ ` : ''} +
`; +} + +function buildActivityPulse(activities: ActivityBreadcrumb[]): string { + if (activities.length === 0) { + return ` +
+
+ No recent activity +
+
`; + } + + return ` +
+
+

Recent Activity

+
+ ${activities.slice(0, 5).map(activity => buildActivityItem(activity)).join('')} +
+
+
`; +} + +function buildActivityItem(activity: ActivityBreadcrumb): string { + const statusIcons: Record = { + success: '✓', + info: 'ℹ', + warning: '⚠', + }; + + const isHeartbeat = activity.source === 'heartbeat'; + + return ` +
+ ${statusIcons[activity.status] || '•'} + ${escapeHtml(activity.action)} + ${formatTimestamp(activity.timestamp)} +
`; +} + +function formatTimestamp(iso: string): string { + try { + const date = new Date(iso); + return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + } catch { + return ''; + } +} + +function escapeHtml(text: string): string { + const map: Record = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' + }; + return text?.replace(/[&<>"']/g, m => map[m]) || ''; +} diff --git a/extension/ui/index.ts b/extension/ui/index.ts new file mode 100644 index 0000000..681d7d9 --- /dev/null +++ b/extension/ui/index.ts @@ -0,0 +1,9 @@ +/** + * MCP UI Module + * + * Exports all UI-related functionality for the aX Platform dashboard. + */ + +export * from './types.js'; +export * from './dashboard.js'; +export * from './resource-handler.js'; diff --git a/extension/ui/resource-handler.ts b/extension/ui/resource-handler.ts new file mode 100644 index 0000000..8a10bde --- /dev/null +++ b/extension/ui/resource-handler.ts @@ -0,0 +1,109 @@ +/** + * MCP UI Resource Handler + * + * Implements SEP-1865 (MCP Apps Extension) resource handling for the aX Platform dashboard. + * Registers ui:// resources and handles resources/read requests. + */ + +import { buildDashboardHtml } from './dashboard.js'; +import type { DashboardContext, Task, ActivityBreadcrumb, WhoamiResponse, SpaceInfo, ToolPermissions } from './types.js'; + +/** + * UI Resource definition for MCP + */ +export const UI_RESOURCES = [ + { + uri: 'ui://ax-platform/dashboard', + name: 'aX Platform Dashboard', + description: 'Interactive visibility dashboard showing agent identity, space, tasks, and activity', + mimeType: 'text/html;profile=mcp-app', + }, +]; + +/** + * Get the MCP capabilities for UI extension + */ +export function getUICapabilities() { + return { + resources: { + subscribe: false, + listChanged: true, + }, + // Declare support for MCP Apps extension + experimental: { + 'io.modelcontextprotocol/ui': true, + }, + }; +} + +/** + * Handle resources/read for UI resources + */ +export async function handleResourceRead( + uri: string, + fetchContext: () => Promise +): Promise<{ contents: Array<{ uri: string; mimeType: string; text: string; _meta?: any }> } | null> { + + if (uri !== 'ui://ax-platform/dashboard') { + return null; + } + + // Fetch live context data + const context = await fetchContext(); + + // Build the HTML + const html = buildDashboardHtml(context); + + return { + contents: [{ + uri, + mimeType: 'text/html;profile=mcp-app', + text: html, + _meta: { + ui: { + csp: { + connectDomains: ['https://mcp.paxai.app', 'https://api.paxai.app'], + resourceDomains: ['https://cdn.tailwindcss.com', 'https://cdnjs.cloudflare.com'], + }, + prefersBorder: true, + }, + }, + }], + }; +} + +/** + * Create a mock context for testing/demo + */ +export function createMockContext(): DashboardContext { + return { + identity: { + name: 'Clawdbot Cipher', + handle: 'clawdbot_cipher', + bio: 'Resident Mind for Jacob\'s Workspace', + capabilities: ['messaging', 'tasks', 'search'], + specialization: 'Workspace Intelligence', + }, + space: { + id: 'space_123', + name: 'Jacob\'s Workspace', + description: 'Main development workspace', + }, + tools: { + available: ['messages', 'tasks', 'search', 'agents', 'spaces'], + restricted: ['admin_tools'], + }, + tasks: [ + { id: 'b8b04e', title: 'MCP App Spec Implementation', status: 'in_progress', priority: 'high', assignedTo: 'clawdbot_cipher' }, + { id: '8a214c', title: 'Clawdbot Plugin Enhancement', status: 'in_progress', priority: 'high', assignedTo: 'clawdbot_cipher' }, + { id: 'd8ec3b', title: 'Outbound Delivery Fix', status: 'not_started', priority: 'medium' }, + { id: 'bdb1bf', title: 'One-Click Onboarding', status: 'not_started', priority: 'medium' }, + { id: 'abc123', title: 'Documentation Update', status: 'completed', priority: 'low' }, + ], + recentActivity: [ + { timestamp: new Date().toISOString(), source: 'user', action: 'Opened dashboard', status: 'success' }, + { timestamp: new Date(Date.now() - 300000).toISOString(), source: 'heartbeat', action: 'Scanned PR #25', status: 'info' }, + { timestamp: new Date(Date.now() - 600000).toISOString(), source: 'system', action: 'Refreshed tokens', status: 'success' }, + ], + }; +} diff --git a/extension/ui/types.ts b/extension/ui/types.ts new file mode 100644 index 0000000..4e831af --- /dev/null +++ b/extension/ui/types.ts @@ -0,0 +1,61 @@ +/** + * MCP UI Dashboard Types + * + * Types for the aX Platform visibility dashboard following SEP-1865 (MCP Apps Extension) + */ + +export interface WhoamiResponse { + name: string; + handle: string; + bio?: string; + capabilities?: string[]; + specialization?: string; +} + +export interface SpaceInfo { + id: string; + name: string; + description?: string; +} + +export interface Task { + id: string; + title: string; + description?: string; + status: 'not_started' | 'in_progress' | 'blocked' | 'completed' | 'cancelled'; + priority: 'low' | 'medium' | 'high' | 'urgent'; + assignedTo?: string; + createdBy?: string; + createdAt?: string; +} + +export interface ActivityBreadcrumb { + timestamp: string; + source: 'user' | 'heartbeat' | 'system'; + action: string; + target?: string; + status: 'success' | 'info' | 'warning'; +} + +export interface ToolPermissions { + available: string[]; + restricted: string[]; +} + +/** + * Complete dashboard context for rendering the MCP UI + */ +export interface DashboardContext { + // Header: Identity + Space + identity: WhoamiResponse; + space: SpaceInfo; + + // Permission Strip + tools: ToolPermissions; + + // Content: Task Kanban + tasks: Task[]; + + // Footer: Activity Pulse + recentActivity: ActivityBreadcrumb[]; +}