From 9bb51d49873e478e3a6de0af958cd756aaeaa0f7 Mon Sep 17 00:00:00 2001 From: clawdbot_cipher Date: Fri, 6 Feb 2026 17:29:46 +0000 Subject: [PATCH 1/5] feat: implement outbound delivery for heartbeat/cron Adds proper outbound.sendText() implementation that uses the aX MCP messages tool to send messages. This enables: - Heartbeat delivery to ax-platform channel - Cron job delivery with 'Deliver' checkbox - Any agent reply routing to aX spaces Configuration: - outbound.tokenFile: Path to OAuth token JSON file - outbound.mcpEndpoint: MCP server URL (default: https://mcp.paxai.app) - outbound.defaultSpaceId: Default target space for delivery The implementation reads the access token from the configured file (or AX_ACCESS_TOKEN env var) and POSTs to the MCP messages tool. Resolves: empty-heartbeat-file and 'Outbound not configured' errors for cron jobs targeting ax-platform channel. --- extension/channel/ax-channel.ts | 95 ++++++++++++++++++++++++++++++--- extension/index.ts | 4 +- extension/lib/types.ts | 7 +++ 3 files changed, 97 insertions(+), 9 deletions(-) diff --git a/extension/channel/ax-channel.ts b/extension/channel/ax-channel.ts index da4f171..03869c8 100644 --- a/extension/channel/ax-channel.ts +++ b/extension/channel/ax-channel.ts @@ -22,7 +22,7 @@ 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 { buildMissionBriefing } from "../lib/context.js"; @@ -572,10 +572,12 @@ export function getDispatchSession(sessionKey: string): DispatchSession | undefi * Create the aX Platform channel plugin */ 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 +603,89 @@ export function createAxChannel(config: { }, outbound: { - deliveryMode: "direct", - - // 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 }; + deliveryMode: "direct" as const, + + /** + * Send text to aX Platform (for heartbeat/cron delivery) + * Uses MCP messages tool with configured token + */ + async sendText({ + text, + to, + accountId, + sessionKey + }: { + text: string; + to?: string; + accountId?: string; + sessionKey?: string; + }) { + const logger = runtime?.logger || { info: console.log, error: console.error }; + + // Get outbound config + const outboundCfg = outboundConfig || {}; + const mcpEndpoint = outboundCfg.mcpEndpoint || process.env.AX_MCP_ENDPOINT || "https://mcp.paxai.app"; + + let authToken: string | undefined; + + // Try token file first + 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}`); + } + } + + // Fallback to env var + if (!authToken) { + authToken = process.env.AX_ACCESS_TOKEN; + } + + if (!authToken) { + logger.error("[ax-platform] Outbound failed: No access token configured"); + return { ok: false, error: "No access token" }; + } + + // Determine target space (parse "space:UUID" or use directly) + const spaceId = to?.replace(/^space:/, "") || outboundCfg.defaultSpaceId; + if (!spaceId) { + logger.error("[ax-platform] Outbound failed: No target space"); + return { ok: false, error: "No target space" }; + } + + // Send via MCP messages tool + try { + const response = await fetch(`${mcpEndpoint}/tools/messages`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${authToken}`, + }, + body: JSON.stringify({ + action: "send", + content: text, + space_id: spaceId, + }), + }); + + if (!response.ok) { + const errText = await response.text(); + logger.error(`[ax-platform] Outbound failed: ${response.status} ${errText}`); + return { ok: false, error: `HTTP ${response.status}` }; + } + + const result = await response.json(); + const preview = text.length > 50 ? text.substring(0, 50) + "..." : text; + logger.info(`[ax-platform] Outbound sent to ${spaceId}: ${preview}`); + return { ok: true, messageId: result.message_id }; + } catch (err) { + logger.error(`[ax-platform] Outbound error: ${err}`); + return { ok: false, error: String(err) }; + } }, }, @@ -641,6 +719,7 @@ export function createDispatchHandler( config: { backendUrl?: string } ) { const backendUrl = config.backendUrl || "http://localhost:8001"; + const outboundConfig = config.outbound || {}; return async (req: IncomingMessage, res: ServerResponse): Promise => { // Only handle /ax/dispatch diff --git a/extension/index.ts b/extension/index.ts index 5729c52..c12e5c7 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"; @@ -28,6 +29,7 @@ interface AxPlatformConfig { env?: string; }>; backendUrl?: string; + outbound?: OutboundConfig; } const plugin = { @@ -65,7 +67,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"); 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 +} From 72e120b03b1e479f99c7aebc6c259629589642ca Mon Sep 17 00:00:00 2001 From: clawdbot_cipher Date: Fri, 6 Feb 2026 21:18:00 +0000 Subject: [PATCH 2/5] feat: add MCP UI dashboard (SEP-1865) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the aX Platform visibility dashboard following the SEP-1865 MCP Apps Extension specification. Components: - ui/types.ts: DashboardContext schema with identity, space, tools, tasks, activity - ui/dashboard.ts: Flowbite/Tailwind HTML builder for the three-layer dashboard - ui/resource-handler.ts: MCP resources/read handler for ui://ax-platform/dashboard - tools/ax-dashboard.ts: Tool that returns the interactive dashboard Dashboard layers: 1. Header: Agent identity + space anchoring + permission badges 2. Content: Task Kanban board (Backlog → In Progress → Done) 3. Footer: Activity Pulse timeline (user actions + heartbeat work) The dashboard provides full visibility into: - Who: Agent identity via whoami - Where: Current space context - What: Available/restricted tools - History: Recent activity breadcrumbs Co-authored-by: @quantum_phoenix_307 --- extension/index.ts | 4 +- extension/tools/ax-dashboard.ts | 56 +++++++ extension/ui/dashboard.ts | 255 +++++++++++++++++++++++++++++++ extension/ui/index.ts | 9 ++ extension/ui/resource-handler.ts | 109 +++++++++++++ extension/ui/types.ts | 61 ++++++++ 6 files changed, 493 insertions(+), 1 deletion(-) create mode 100644 extension/tools/ax-dashboard.ts create mode 100644 extension/ui/dashboard.ts create mode 100644 extension/ui/index.ts create mode 100644 extension/ui/resource-handler.ts create mode 100644 extension/ui/types.ts diff --git a/extension/index.ts b/extension/index.ts index c12e5c7..f076b81 100644 --- a/extension/index.ts +++ b/extension/index.ts @@ -19,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 { @@ -81,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/tools/ax-dashboard.ts b/extension/tools/ax-dashboard.ts new file mode 100644 index 0000000..d53df25 --- /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: "Display the aX Platform visibility dashboard showing agent identity, space, tasks, and activity timeline", + 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) { + // For now, use mock context - will be replaced with live data + 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/ui/dashboard.ts b/extension/ui/dashboard.ts new file mode 100644 index 0000000..908644d --- /dev/null +++ b/extension/ui/dashboard.ts @@ -0,0 +1,255 @@ +/** + * 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 div = { innerHTML: '' }; + 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[]; +} From c197d31d9f69600ef71fdcc9e84d98cbaca4ef20 Mon Sep 17 00:00:00 2001 From: clawdbot_cipher Date: Sun, 8 Feb 2026 16:26:08 +0000 Subject: [PATCH 3/5] fix: use MCP JSON-RPC protocol for tool calls and outbound delivery The plugin was calling ${mcpEndpoint}/tools/${toolName} (REST) which doesn't exist on the MCP server. The server only speaks JSON-RPC at ${mcpEndpoint}/mcp. Changes: - api.ts: callAxTool now sends proper JSON-RPC tools/call requests - Handles both JSON and SSE response formats - Extracts text content from MCP result envelopes - Better error messages with response body context - ax-channel.ts: outbound sendText/sendMedia match ChannelOutboundContext signature - sendText(ctx: { cfg, to, text, ... }) -> OutboundDeliveryResult - Added sendMedia for media delivery (text + link fallback) - Added resolveTarget for proper target resolution - Uses callAxTool instead of raw fetch to wrong endpoint This fixes: tools registered by the plugin (ax_messages, ax_tasks, etc.) returning errors, and outbound delivery (heartbeat/cron) failing silently. --- extension/channel/ax-channel.ts | 164 +++++++++++++++++++++----------- extension/lib/api.ts | 89 +++++++++++++++-- 2 files changed, 194 insertions(+), 59 deletions(-) diff --git a/extension/channel/ax-channel.ts b/extension/channel/ax-channel.ts index 03869c8..b0001b8 100644 --- a/extension/channel/ax-channel.ts +++ b/extension/channel/ax-channel.ts @@ -24,7 +24,7 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import type { PluginRuntime } from "clawdbot/plugin-sdk"; 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 ─────────────────────────────────────────────────── @@ -606,29 +606,45 @@ export function createAxChannel(config: { deliveryMode: "direct" as const, /** - * Send text to aX Platform (for heartbeat/cron delivery) - * Uses MCP messages tool with configured token + * Resolve outbound target for aX Platform + * Accepts "space:" or raw space ID */ - async sendText({ - text, - to, - accountId, - sessionKey - }: { - text: string; - to?: string; - accountId?: string; - sessionKey?: string; + 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 }; + }, + + /** + * 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 }; - - // Get outbound config + + // Resolve auth token const outboundCfg = outboundConfig || {}; const mcpEndpoint = outboundCfg.mcpEndpoint || process.env.AX_MCP_ENDPOINT || "https://mcp.paxai.app"; - + let authToken: string | undefined; - - // Try token file first + + // Try token file first (e.g., ~/.clawdbot/workspaces//.ax-token.json) if (outboundCfg.tokenFile) { try { const fs = await import("node:fs"); @@ -639,52 +655,94 @@ export function createAxChannel(config: { logger.error(`[ax-platform] Failed to read token file: ${err}`); } } - + // Fallback to env var if (!authToken) { authToken = process.env.AX_ACCESS_TOKEN; } - + if (!authToken) { - logger.error("[ax-platform] Outbound failed: No access token configured"); - return { ok: false, error: "No access token" }; - } - - // Determine target space (parse "space:UUID" or use directly) - const spaceId = to?.replace(/^space:/, "") || outboundCfg.defaultSpaceId; - if (!spaceId) { - logger.error("[ax-platform] Outbound failed: No target space"); - return { ok: false, error: "No target space" }; + throw new Error("[ax-platform] Outbound failed: No access token configured. Set outbound.tokenFile or AX_ACCESS_TOKEN."); } - - // Send via MCP messages tool + + // Send via MCP JSON-RPC (callAxTool now uses proper protocol) try { - const response = await fetch(`${mcpEndpoint}/tools/messages`, { - method: "POST", - headers: { - "Content-Type": "application/json", - "Authorization": `Bearer ${authToken}`, - }, - body: JSON.stringify({ - action: "send", - content: text, - space_id: spaceId, - }), + const result = await callAxTool(mcpEndpoint, authToken, "messages", { + action: "send", + content: text, }); - - if (!response.ok) { - const errText = await response.text(); - logger.error(`[ax-platform] Outbound failed: ${response.status} ${errText}`); - return { ok: false, error: `HTTP ${response.status}` }; - } - - const result = await response.json(); + + 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 ${spaceId}: ${preview}`); - return { ok: true, messageId: result.message_id }; + 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}`); - return { ok: false, error: String(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"; + + 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."); + } + + const content = mediaUrl ? `${text}\n${mediaUrl}` : text; + try { + const result = await callAxTool(mcpEndpoint, authToken, "messages", { + action: "send", + content, + }); + 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/lib/api.ts b/extension/lib/api.ts index 5dfd72e..125ddb7 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,89 @@ export async function callAxTool( toolName: string, params: Record ): Promise { - const response = await fetch(`${mcpEndpoint}/tools/${toolName}`, { + // 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 — skip this line + } + } + } + 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 { content: [{ type: "text", text: "..." }] } + * We parse the text as JSON if possible, otherwise return it as-is. + */ +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; } From 43cd83dd93ecf4e966be7c8793737372b3bb5363 Mon Sep 17 00:00:00 2001 From: clawdbot_cipher Date: Sun, 8 Feb 2026 16:36:39 +0000 Subject: [PATCH 4/5] fix: address all 4 review findings from Claude bot 1. XSS: Replace inline onclick handlers with data-* attributes + event delegation. Task IDs now escaped via escapeHtml(). 2. Missing space_id: Both sendText and sendMedia now pass space_id to callAxTool so outbound messages route to the correct space. 3. Dead code: Remove unused outboundConfig in createDispatchHandler (only needed in createAxChannel where it's actually used). 4. postMessage origin: Use window.location.origin instead of '*' to prevent data leakage to untrusted parent frames. --- extension/channel/ax-channel.ts | 3 ++- extension/ui/dashboard.ts | 17 ++++++++++++++--- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/extension/channel/ax-channel.ts b/extension/channel/ax-channel.ts index b0001b8..6cbe289 100644 --- a/extension/channel/ax-channel.ts +++ b/extension/channel/ax-channel.ts @@ -670,6 +670,7 @@ export function createAxChannel(config: { const result = await callAxTool(mcpEndpoint, authToken, "messages", { action: "send", content: text, + space_id: to, }); const messageId = (result && typeof result === "object" && "message_id" in result) @@ -731,6 +732,7 @@ export function createAxChannel(config: { 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) @@ -777,7 +779,6 @@ export function createDispatchHandler( config: { backendUrl?: string } ) { const backendUrl = config.backendUrl || "http://localhost:8001"; - const outboundConfig = config.outbound || {}; return async (req: IncomingMessage, res: ServerResponse): Promise => { // Only handle /ax/dispatch diff --git a/extension/ui/dashboard.ts b/extension/ui/dashboard.ts index 908644d..3a86c4a 100644 --- a/extension/ui/dashboard.ts +++ b/extension/ui/dashboard.ts @@ -48,7 +48,7 @@ export function buildDashboardHtml(context: DashboardContext): string { jsonrpc: '2.0', method: 'tools/call', params: { name: toolName, arguments: params } - }, '*'); + }, window.location.origin); } function completeTask(taskId) { @@ -58,6 +58,17 @@ export function buildDashboardHtml(context: DashboardContext): string { function assignTask(taskId) { callTool('ax_tasks', { action: 'assign', task_id: taskId }); } + + // Event delegation for task action buttons (avoids inline onclick XSS risk) + document.addEventListener('click', function(e) { + const btn = e.target.closest('[data-action]'); + if (!btn) return; + const action = btn.getAttribute('data-action'); + const taskId = btn.getAttribute('data-task-id'); + if (!taskId) return; + if (action === 'complete') completeTask(taskId); + else if (action === 'assign') assignTask(taskId); + }); `; @@ -182,11 +193,11 @@ function buildTaskCard(task: Task): string { ${task.description ? `

${escapeHtml(task.description)}

` : ''} ${task.status !== 'completed' ? `
- ${!task.assignedTo ? ` - ` : ''} From f31c87ce986e62c751322e2d95418dfa9efa50e8 Mon Sep 17 00:00:00 2001 From: clawdbot_cipher Date: Sun, 8 Feb 2026 17:09:49 +0000 Subject: [PATCH 5/5] fix: address all review findings (round 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Must-fix: 1. Tool context access — use Record and check both sessionKey and SessionKey for cross-compatibility 2. Dashboard marked as [Preview] with demo data disclaimer 5. Input validation in callAxTool (mcpEndpoint, authToken, toolName) Should-fix: 3. Removed dead div from escapeHtml 4. Extracted resolveOutboundAuthToken() — eliminates 25 lines of duplication between sendText and sendMedia 2b. Added debug logging for SSE parse errors Nice-to-have: 9. Enhanced JSDoc for extractToolResult with full format docs 7. Consistent context access across all 5 tool files --- extension/channel/ax-channel.ts | 78 +++++++++++++++------------------ extension/lib/api.ts | 21 +++++++-- extension/tools/ax-agents.ts | 4 +- extension/tools/ax-context.ts | 4 +- extension/tools/ax-dashboard.ts | 4 +- extension/tools/ax-messages.ts | 4 +- extension/tools/ax-tasks.ts | 4 +- extension/tools/ax-thread.ts | 4 +- extension/ui/dashboard.ts | 1 - 9 files changed, 65 insertions(+), 59 deletions(-) diff --git a/extension/channel/ax-channel.ts b/extension/channel/ax-channel.ts index 6cbe289..58ec6b4 100644 --- a/extension/channel/ax-channel.ts +++ b/extension/channel/ax-channel.ts @@ -571,6 +571,39 @@ 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 }>; @@ -637,33 +670,9 @@ export function createAxChannel(config: { gifPlayback?: boolean; }) { const logger = runtime?.logger || { info: console.log, error: console.error }; - - // Resolve auth token const outboundCfg = outboundConfig || {}; const mcpEndpoint = outboundCfg.mcpEndpoint || process.env.AX_MCP_ENDPOINT || "https://mcp.paxai.app"; - - let authToken: string | undefined; - - // Try token file first (e.g., ~/.clawdbot/workspaces//.ax-token.json) - 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}`); - } - } - - // Fallback to env var - 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."); - } + const authToken = await resolveOutboundAuthToken(outboundCfg, logger); // Send via MCP JSON-RPC (callAxTool now uses proper protocol) try { @@ -708,24 +717,7 @@ export function createAxChannel(config: { 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"; - - 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."); - } + const authToken = await resolveOutboundAuthToken(outboundCfg, logger); const content = mediaUrl ? `${text}\n${mediaUrl}` : text; try { diff --git a/extension/lib/api.ts b/extension/lib/api.ts index 125ddb7..31c1594 100644 --- a/extension/lib/api.ts +++ b/extension/lib/api.ts @@ -49,6 +49,10 @@ export async function callAxTool( toolName: string, params: Record ): Promise { + 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 @@ -99,7 +103,10 @@ export async function callAxTool( } } catch (parseErr) { if (parseErr instanceof Error && parseErr.message.startsWith("MCP error:")) throw parseErr; - // Not valid JSON — skip this line + // 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)}`); + } } } } @@ -116,8 +123,16 @@ export async function callAxTool( /** * Extract the text content from an MCP tools/call result. - * MCP returns { content: [{ type: "text", text: "..." }] } - * We parse the text as JSON if possible, otherwise return it as-is. + * + * 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; 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 index d53df25..96baa5c 100644 --- a/extension/tools/ax-dashboard.ts +++ b/extension/tools/ax-dashboard.ts @@ -11,7 +11,7 @@ import type { DashboardContext } from '../ui/index.js'; export const axDashboardTool: ClawdbotTool = { name: "ax_dashboard", - description: "Display the aX Platform visibility dashboard showing agent identity, space, tasks, and activity timeline", + description: "[Preview] Display the aX Platform visibility dashboard. Currently shows demo data — live data integration pending.", inputSchema: { type: "object" as const, properties: { @@ -24,7 +24,7 @@ export const axDashboardTool: ClawdbotTool = { }, async execute(params: { view?: string }, context: any) { - // For now, use mock context - will be replaced with live data + // Preview mode: uses mock context until live MCP data integration is implemented const dashboardContext = createMockContext(); // Build the HTML 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 index 3a86c4a..4cb1603 100644 --- a/extension/ui/dashboard.ts +++ b/extension/ui/dashboard.ts @@ -254,7 +254,6 @@ function formatTimestamp(iso: string): string { } function escapeHtml(text: string): string { - const div = { innerHTML: '' }; const map: Record = { '&': '&', '<': '<',