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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions frontend/src/mcp/tools/context.ts
Original file line number Diff line number Diff line change
@@ -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
13 changes: 13 additions & 0 deletions frontend/src/mcp/tools/index.ts
Original file line number Diff line number Diff line change
@@ -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
37 changes: 37 additions & 0 deletions frontend/src/mcp/tools/navigation.ts
Original file line number Diff line number Diff line change
@@ -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 },

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be readOnlyHint: false since it can navigate for the user??

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<string, string> }

@n-lark n-lark Jun 30, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This as is sus - the type is unknown then we force it here. Should we be validating this shape?


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
35 changes: 35 additions & 0 deletions frontend/src/mcp/tools/routes.ts
Original file line number Diff line number Diff line change
@@ -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
67 changes: 67 additions & 0 deletions frontend/src/services/automations.service.ts
Original file line number Diff line number Diff line change
@@ -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<string, McpToolDefinition>

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<unknown> {
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
4 changes: 3 additions & 1 deletion frontend/src/services/service.registry.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { createAutomationsService } from './automations.service'
import { createBootstrapService } from './bootstrap.service'
import { createMqttService } from './mqtt.service'
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 }
]
1 change: 1 addition & 0 deletions frontend/src/types/common/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './types.js'
12 changes: 6 additions & 6 deletions frontend/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions frontend/src/types/mcp/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './mcp.types.js'
33 changes: 33 additions & 0 deletions frontend/src/types/mcp/mcp.types.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>
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<unknown>
}

export interface McpToolWireDefinition {
name: string
description: string
annotations: McpToolAnnotations
inputSchema: McpToolInputSchema
}
6 changes: 6 additions & 0 deletions frontend/src/types/services/automations.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { McpToolWireDefinition } from '@/types'

export interface AutomationsServiceI {
getToolDefinitions(): McpToolWireDefinition[]
dispatch(toolName: string, args?: unknown): Promise<unknown>
}
1 change: 1 addition & 0 deletions frontend/src/types/services/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
3 changes: 2 additions & 1 deletion frontend/src/types/services/service.types.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -15,6 +15,7 @@ export type ServiceInstances = {
bootstrap: BootstrapServiceI | null
postMessage: PostMessageServiceI | null
mqtt: MqttServiceI | null
automations: AutomationsServiceI | null
}

export interface CreateServiceOptions {
Expand Down
1 change: 1 addition & 0 deletions frontend/src/types/subscribers/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './subscriber.types'
1 change: 1 addition & 0 deletions frontend/src/types/transport/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './transport.types.js'
15 changes: 13 additions & 2 deletions test/unit/frontend/services/app.orchestrator.spec.js
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down
Loading