From 549af22d568cd8889d15cfbf7f097d211d594817 Mon Sep 17 00:00:00 2001 From: cstns Date: Wed, 24 Jun 2026 13:04:25 +0300 Subject: [PATCH 1/2] Add automations service with MCP tools for UI context, navigation, and route listing --- frontend/src/mcp/tools/context.ts | 20 ++++++ frontend/src/mcp/tools/index.ts | 13 ++++ frontend/src/mcp/tools/navigation.ts | 37 ++++++++++ frontend/src/mcp/tools/routes.ts | 35 ++++++++++ frontend/src/services/automations.service.ts | 67 +++++++++++++++++++ frontend/src/services/service.registry.ts | 4 +- frontend/src/types/common/index.ts | 1 + frontend/src/types/index.ts | 12 ++-- frontend/src/types/mcp/index.ts | 1 + frontend/src/types/mcp/mcp.types.ts | 33 +++++++++ .../src/types/services/automations.types.ts | 6 ++ frontend/src/types/services/index.ts | 1 + frontend/src/types/services/service.types.ts | 3 +- frontend/src/types/subscribers/index.ts | 1 + frontend/src/types/transport/index.ts | 1 + 15 files changed, 227 insertions(+), 8 deletions(-) create mode 100644 frontend/src/mcp/tools/context.ts create mode 100644 frontend/src/mcp/tools/index.ts create mode 100644 frontend/src/mcp/tools/navigation.ts create mode 100644 frontend/src/mcp/tools/routes.ts create mode 100644 frontend/src/services/automations.service.ts create mode 100644 frontend/src/types/common/index.ts create mode 100644 frontend/src/types/mcp/index.ts create mode 100644 frontend/src/types/mcp/mcp.types.ts create mode 100644 frontend/src/types/services/automations.types.ts create mode 100644 frontend/src/types/subscribers/index.ts create mode 100644 frontend/src/types/transport/index.ts diff --git a/frontend/src/mcp/tools/context.ts b/frontend/src/mcp/tools/context.ts new file mode 100644 index 0000000000..18391898ef --- /dev/null +++ b/frontend/src/mcp/tools/context.ts @@ -0,0 +1,20 @@ +import { useContextStore } from '@/stores/context.js' +import type { McpToolDefinition } from '@/types' + +const tools: McpToolDefinition[] = [ + { + name: 'ui.get-context', + description: 'Get the current UI context: what team, application, instance, or device the user is viewing, the current page, scope (immersive editor vs main app), and editor state when applicable.', + annotations: { readOnlyHint: true, destructiveHint: false }, + inputSchema: { + type: 'object', + properties: {} + }, + handler () { + const contextStore = useContextStore() + return contextStore.expert + } + } +] + +export default tools diff --git a/frontend/src/mcp/tools/index.ts b/frontend/src/mcp/tools/index.ts new file mode 100644 index 0000000000..cb0174161c --- /dev/null +++ b/frontend/src/mcp/tools/index.ts @@ -0,0 +1,13 @@ +import contextTools from './context.js' +import navigationTools from './navigation.js' +import routesTools from './routes.js' + +import type { McpToolDefinition } from '@/types' + +const allTools: McpToolDefinition[] = [ + ...contextTools, + ...routesTools, + ...navigationTools +] + +export default allTools diff --git a/frontend/src/mcp/tools/navigation.ts b/frontend/src/mcp/tools/navigation.ts new file mode 100644 index 0000000000..8f32ef4759 --- /dev/null +++ b/frontend/src/mcp/tools/navigation.ts @@ -0,0 +1,37 @@ +import type { McpToolDefinition } from '@/types' + +const tools: McpToolDefinition[] = [ + { + name: 'ui.navigate', + description: 'Navigate the user\'s browser to a specific page. Takes a route name and optional params. Use ui.list-routes to discover valid route names and their required parameters.', + annotations: { readOnlyHint: true, destructiveHint: false }, + inputSchema: { + type: 'object', + properties: { + route: { + type: 'string', + description: 'The route name to navigate to (e.g. "instance-overview", "team-home")' + }, + params: { + type: 'object', + description: 'Route parameters (e.g. { id: "abc123" } or { team_slug: "my-team" })', + additionalProperties: { type: 'string' } + } + }, + required: ['route'] + }, + async handler (args, { router }) { + const { route: routeName, params } = args as { route: string, params?: Record } + + const resolved = router.resolve({ name: routeName, params }) + if (!resolved || !resolved.matched.length) { + return { success: false, error: `Route "${routeName}" not found` } + } + + await router.push({ name: routeName, params }) + return { success: true, route: routeName, path: resolved.fullPath } + } + } +] + +export default tools diff --git a/frontend/src/mcp/tools/routes.ts b/frontend/src/mcp/tools/routes.ts new file mode 100644 index 0000000000..1eff03da73 --- /dev/null +++ b/frontend/src/mcp/tools/routes.ts @@ -0,0 +1,35 @@ +import type { Router } from 'vue-router' + +import type { McpToolDefinition } from '@/types' + +function getRouteList (router: Router) { + return router.getRoutes() + .filter(route => route.name && !route.redirect) + .map(route => ({ + name: route.name as string, + path: route.path, + meta: { + title: route.meta?.title || null, + adminOnly: route.meta?.adminOnly || false, + requiresLogin: route.meta?.requiresLogin ?? true + } + })) + .sort((a, b) => a.name.localeCompare(b.name)) +} + +const tools: McpToolDefinition[] = [ + { + name: 'ui.list-routes', + description: 'List all available UI routes with their names, path patterns, and metadata. Use this to discover valid route names for the ui.navigate tool.', + annotations: { readOnlyHint: true, destructiveHint: false }, + inputSchema: { + type: 'object', + properties: {} + }, + handler (_args, { router }) { + return { routes: getRouteList(router) } + } + } +] + +export default tools diff --git a/frontend/src/services/automations.service.ts b/frontend/src/services/automations.service.ts new file mode 100644 index 0000000000..fe67f5404b --- /dev/null +++ b/frontend/src/services/automations.service.ts @@ -0,0 +1,67 @@ +import { BaseService } from './service.contract' + +import allTools from '@/mcp/tools' +import type { AutomationsServiceI, CreateServiceOptions, McpToolDefinition, McpToolWireDefinition } from '@/types' + +class AutomationsService extends BaseService implements AutomationsServiceI { + private $tools: Map + + constructor ({ app, router, services }: CreateServiceOptions) { + super({ + name: 'automations', + app, + router, + services + }) + + this.$tools = new Map() + for (const tool of allTools) { + this.$tools.set(tool.name, tool) + } + } + + /** + * Returns tool definitions without handlers, suitable for + * sending over MQTT in response to a tool list discovery request. + */ + getToolDefinitions (): McpToolWireDefinition[] { + return Array.from(this.$tools.values()).map((tool) => ({ + name: tool.name, + description: tool.description, + annotations: tool.annotations, + inputSchema: tool.inputSchema + })) + } + + /** + * Dispatches a tool call by name. Returns the tool result or an error object. + */ + async dispatch (toolName: string, args: unknown = {}): Promise { + const tool = this.$tools.get(toolName) + if (!tool) { + return { error: `Unknown UI tool: ${toolName}` } + } + + try { + return await tool.handler(args, { router: this.$router! }) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + return { error: `Tool "${toolName}" failed: ${message}` } + } + } +} + +let AutomationsServiceInstance: AutomationsService | null = null + +export function createAutomationsService ({ app, router, services }: CreateServiceOptions): AutomationsService { + if (!AutomationsServiceInstance) { + AutomationsServiceInstance = new AutomationsService({ + app, + router, + services + }) + } + return AutomationsServiceInstance +} + +export default createAutomationsService diff --git a/frontend/src/services/service.registry.ts b/frontend/src/services/service.registry.ts index b49a5bb8b5..c71e161c2a 100644 --- a/frontend/src/services/service.registry.ts +++ b/frontend/src/services/service.registry.ts @@ -1,3 +1,4 @@ +import { createAutomationsService } from './automations.service' import { createBootstrapService } from './bootstrap.service' import { createMqttService } from './mqtt.service' import { createMessagingService } from './post-message.service' @@ -5,5 +6,6 @@ import { createMessagingService } from './post-message.service' export default [ { key: 'bootstrap' as const, create: createBootstrapService, requiredLifecycle: ['init', 'destroy'] as const }, { key: 'postMessage' as const, create: createMessagingService, requiredLifecycle: ['destroy'] as const }, - { key: 'mqtt' as const, create: createMqttService, requiredLifecycle: ['destroy'] as const } + { key: 'mqtt' as const, create: createMqttService, requiredLifecycle: ['destroy'] as const }, + { key: 'automations' as const, create: createAutomationsService, requiredLifecycle: [] as const } ] diff --git a/frontend/src/types/common/index.ts b/frontend/src/types/common/index.ts new file mode 100644 index 0000000000..fdc633235f --- /dev/null +++ b/frontend/src/types/common/index.ts @@ -0,0 +1 @@ +export * from './types.js' diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 5f0f5b8ec2..a866fdae00 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -11,13 +11,13 @@ // Re-export all auto-generated OpenAPI types. // Run `npm run generate:types` to produce this file. -export * from './generated.js' - -export * from './services/index.js' +export * from './common' +export * from './mcp' +export * from './services' +export * from './subscribers' +export * from './transport' -export * from './transport/transport.types.js' - -export * from './subscribers/subscriber.types.js' +export * from './generated.js' // --------------------------------------------------------------------------- // Roles diff --git a/frontend/src/types/mcp/index.ts b/frontend/src/types/mcp/index.ts new file mode 100644 index 0000000000..7c42d3b96a --- /dev/null +++ b/frontend/src/types/mcp/index.ts @@ -0,0 +1 @@ +export * from './mcp.types.js' diff --git a/frontend/src/types/mcp/mcp.types.ts b/frontend/src/types/mcp/mcp.types.ts new file mode 100644 index 0000000000..1a059f45d1 --- /dev/null +++ b/frontend/src/types/mcp/mcp.types.ts @@ -0,0 +1,33 @@ +import type { Router } from 'vue-router' + +export interface McpToolAnnotations { + readOnlyHint?: boolean + destructiveHint?: boolean + idempotentHint?: boolean + openWorldHint?: boolean +} + +export interface McpToolInputSchema { + type: 'object' + properties: Record + required?: string[] +} + +export interface McpToolHandlerContext { + router: Router +} + +export interface McpToolDefinition { + name: string + description: string + annotations: McpToolAnnotations + inputSchema: McpToolInputSchema + handler: (args: unknown, context: McpToolHandlerContext) => unknown | Promise +} + +export interface McpToolWireDefinition { + name: string + description: string + annotations: McpToolAnnotations + inputSchema: McpToolInputSchema +} diff --git a/frontend/src/types/services/automations.types.ts b/frontend/src/types/services/automations.types.ts new file mode 100644 index 0000000000..453c58c9e1 --- /dev/null +++ b/frontend/src/types/services/automations.types.ts @@ -0,0 +1,6 @@ +import { McpToolWireDefinition } from '@/types' + +export interface AutomationsServiceI { + getToolDefinitions(): McpToolWireDefinition[] + dispatch(toolName: string, args?: unknown): Promise +} diff --git a/frontend/src/types/services/index.ts b/frontend/src/types/services/index.ts index 130d91b51e..33f4940600 100644 --- a/frontend/src/types/services/index.ts +++ b/frontend/src/types/services/index.ts @@ -2,3 +2,4 @@ export * from './service.types.js' export * from './mqtt.types.js' export * from './post-message.types.js' export * from './bootstrap.types.js' +export * from './automations.types.js' diff --git a/frontend/src/types/services/service.types.ts b/frontend/src/types/services/service.types.ts index 5972a71b50..a2cc99069b 100644 --- a/frontend/src/types/services/service.types.ts +++ b/frontend/src/types/services/service.types.ts @@ -1,7 +1,7 @@ import type { App } from 'vue' import type { Router } from 'vue-router' -import type { BootstrapServiceI, MqttServiceI, PostMessageServiceI } from '@/types' +import type { AutomationsServiceI, BootstrapServiceI, MqttServiceI, PostMessageServiceI } from '@/types' /** * Minimal lifecycle contract for app services. @@ -15,6 +15,7 @@ export type ServiceInstances = { bootstrap: BootstrapServiceI | null postMessage: PostMessageServiceI | null mqtt: MqttServiceI | null + automations: AutomationsServiceI | null } export interface CreateServiceOptions { diff --git a/frontend/src/types/subscribers/index.ts b/frontend/src/types/subscribers/index.ts new file mode 100644 index 0000000000..27c1517115 --- /dev/null +++ b/frontend/src/types/subscribers/index.ts @@ -0,0 +1 @@ +export * from './subscriber.types' diff --git a/frontend/src/types/transport/index.ts b/frontend/src/types/transport/index.ts new file mode 100644 index 0000000000..e412ad0051 --- /dev/null +++ b/frontend/src/types/transport/index.ts @@ -0,0 +1 @@ +export * from './transport.types.js' From 7efc9679c2b2e8ce27a51409bd68d3daeb3ae4e2 Mon Sep 17 00:00:00 2001 From: cstns Date: Wed, 24 Jun 2026 13:13:37 +0300 Subject: [PATCH 2/2] fix fe unit tests --- .../frontend/services/app.orchestrator.spec.js | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/test/unit/frontend/services/app.orchestrator.spec.js b/test/unit/frontend/services/app.orchestrator.spec.js index 256f1c9684..26ea53e8f8 100644 --- a/test/unit/frontend/services/app.orchestrator.spec.js +++ b/test/unit/frontend/services/app.orchestrator.spec.js @@ -1,11 +1,18 @@ import { beforeEach, describe, expect, test, vi } from 'vitest' +const mockCreateAutomationsService = vi.fn() const mockCreateBootstrapService = vi.fn() const mockCreateMessagingService = vi.fn() const mockCreateMqttService = vi.fn() const mockCreateTeamChannelSubscriber = vi.fn() const mockCreateMqttTransport = vi.fn() +vi.mock('../../../../frontend/src/services/automations.service.js', () => { + return { + createAutomationsService: mockCreateAutomationsService + } +}) + vi.mock('../../../../frontend/src/services/bootstrap.service.js', () => { return { createBootstrapService: mockCreateBootstrapService @@ -42,23 +49,26 @@ async function loadOrchestratorModule () { } function seedServices () { + const automationsService = { name: 'automations' } const bootstrapService = { name: 'bootstrap', init: vi.fn(), destroy: vi.fn().mockResolvedValue() } const postMessageService = { name: 'postMessage', destroy: vi.fn().mockResolvedValue() } const mqttService = { name: 'mqtt', destroy: vi.fn().mockResolvedValue() } const teamChannelSubscriber = { name: 'teamChannel', destroy: vi.fn().mockResolvedValue() } const transport = { name: 'mqtt-transport' } + mockCreateAutomationsService.mockReturnValue(automationsService) mockCreateBootstrapService.mockReturnValue(bootstrapService) mockCreateMessagingService.mockReturnValue(postMessageService) mockCreateMqttService.mockReturnValue(mqttService) mockCreateMqttTransport.mockReturnValue(transport) mockCreateTeamChannelSubscriber.mockReturnValue(teamChannelSubscriber) - return { bootstrapService, postMessageService, mqttService, teamChannelSubscriber, transport } + return { automationsService, bootstrapService, postMessageService, mqttService, teamChannelSubscriber, transport } } describe('AppOrchestrator', () => { beforeEach(() => { + mockCreateAutomationsService.mockReset() mockCreateBootstrapService.mockReset() mockCreateMessagingService.mockReset() mockCreateMqttService.mockReset() @@ -129,7 +139,8 @@ describe('AppOrchestrator', () => { expect(orchestrator.$serviceInstances).toEqual({ bootstrap: null, postMessage: null, - mqtt: null + mqtt: null, + automations: null }) expect(orchestrator.$subscriberInstances).toEqual({ teamChannel: null }) expect(orchestrator.$app).toBeNull()