Skip to content

Commit 8a87fcb

Browse files
committed
feat(inspect): inspect the browser side: client RPC functions and WebMCP tools
1 parent c9c686e commit 8a87fcb

12 files changed

Lines changed: 778 additions & 86 deletions

File tree

docs/content/5.add-ons/1.devframes/2.inspect.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ _History panels_
2424
## What it does
2525

2626
- **Functions**: type, flags, JSON Schema, agent exposure; read-only `query` / `static` invokable inline.
27+
- **Client**: the browser side of the connection: client RPC functions (invoked locally in the page) and the page's WebMCP tools, live from the model context's `getTools()` when the browser supports discovery, otherwise projected from `agent`-flagged client functions.
2728
- **State**: shared-state keys in a live JSON tree that flashes changes.
2829
- **Agent**: tools and resources for agents.
2930
- **History**: a timeline of RPC calls and shared-state updates.

packages/devframe/src/client/webmcp.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,18 +29,42 @@ export interface WebMcpToolDescriptor {
2929
execute: (args: Record<string, unknown>) => Promise<WebMcpToolResult>
3030
}
3131

32+
/**
33+
* A tool as reported by {@link WebMcpModelContext.getTools}: the
34+
* serializable descriptor fields plus the registering `origin`. The
35+
* browser may attach more members (e.g. the owner `window`); pass the
36+
* object through unchanged to {@link WebMcpModelContext.executeTool}.
37+
*/
38+
export interface WebMcpRegisteredTool {
39+
name: string
40+
description?: string
41+
inputSchema?: unknown
42+
origin?: string
43+
}
44+
3245
/**
3346
* Structural subset of the experimental WebMCP model context
3447
* (`document.modelContext` / `navigator.modelContext`). The current draft
3548
* unregisters a tool by aborting the passed `AbortSignal` and returns a
3649
* promise; earlier drafts returned a handle with `unregister()`. Typed to
37-
* accept both generations.
50+
* accept both generations. `getTools` / `executeTool` are the draft's
51+
* discovery/execution surface for in-page agents; absent on older drafts.
3852
*/
3953
export interface WebMcpModelContext {
4054
registerTool: (
4155
tool: WebMcpToolDescriptor,
4256
options?: { signal?: AbortSignal },
4357
) => void | { unregister?: () => void } | Promise<unknown>
58+
getTools?: (options?: { fromOrigins?: string[] }) => Promise<WebMcpRegisteredTool[]>
59+
/**
60+
* The spec draft takes the args as a dictionary; Chromium's current
61+
* build takes (and returns) JSON strings instead, hence the union.
62+
*/
63+
executeTool?: (
64+
tool: WebMcpRegisteredTool,
65+
args: Record<string, unknown> | string,
66+
options?: { signal?: AbortSignal },
67+
) => Promise<unknown>
4468
}
4569

4670
interface WebMcpModelContextCarrier {

plugins/inspect/app/App.vue

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
connectionTitle,
1515
} from '../../../design/design'
1616
import AgentSmart from './components/AgentSmart.vue'
17+
import ClientSmart from './components/ClientSmart.vue'
1718
import CommandsSmart from './components/CommandsSmart.vue'
1819
import FunctionsSmart from './components/FunctionsSmart.vue'
1920
import HistorySmart from './components/HistorySmart.vue'
@@ -22,7 +23,7 @@ import StateSmart from './components/StateSmart.vue'
2223
import { useRefresh } from './composables/refresh'
2324
import { connect, connection, isStatic } from './composables/rpc'
2425
25-
type Tab = 'functions' | 'state' | 'agent' | 'commands' | 'history' | 'instances'
26+
type Tab = 'functions' | 'client' | 'state' | 'agent' | 'commands' | 'history' | 'instances'
2627
2728
const tab = ref<Tab>('functions')
2829
const { refresh, loading } = useRefresh()
@@ -37,6 +38,7 @@ const connState = computed(() => connectionState(connection.status))
3738
3839
const allTabs: { value: Tab, label: string, icon: string }[] = [
3940
{ value: 'functions', label: 'Functions', icon: 'i-ph-function-duotone' },
41+
{ value: 'client', label: 'Client', icon: 'i-ph-browser-duotone' },
4042
{ value: 'state', label: 'State', icon: 'i-ph-database-duotone' },
4143
{ value: 'agent', label: 'Agent', icon: 'i-ph-robot-duotone' },
4244
{ value: 'commands', label: 'Commands', icon: 'i-ph-terminal-window-duotone' },
@@ -109,6 +111,7 @@ function reload(): void {
109111
</div>
110112
<template v-else>
111113
<FunctionsSmart v-if="tab === 'functions'" />
114+
<ClientSmart v-else-if="tab === 'client'" />
112115
<StateSmart v-else-if="tab === 'state'" />
113116
<AgentSmart v-else-if="tab === 'agent'" />
114117
<CommandsSmart v-else-if="tab === 'commands'" />
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
<script setup lang="ts">
2+
import type { ClientWebMcpState } from '../composables/client'
3+
import type { InvokeResult, RpcFunctionInfo } from '../connect'
4+
import { resolveWebMcpModelContext } from 'devframe/client'
5+
import { onMounted, onUnmounted, reactive, shallowRef } from 'vue'
6+
import { executeWebMcpTool, invokeClientFunction, listClientFunctions, loadWebMcpState } from '../composables/client'
7+
import { useRefreshProvider } from '../composables/refresh'
8+
import { useRpc } from '../composables/rpc'
9+
import ClientView from './ClientView.vue'
10+
11+
const rpc = useRpc()
12+
const functions = shallowRef<RpcFunctionInfo[] | null>(null)
13+
const webmcp = shallowRef<ClientWebMcpState | null>(null)
14+
const results = reactive<Record<string, InvokeResult | { ok: false, error: { name: string, message: string } }>>({})
15+
const pending = reactive<Record<string, boolean>>({})
16+
17+
async function fetchData(): Promise<void> {
18+
if (!rpc.value)
19+
return
20+
functions.value = listClientFunctions(rpc.value.client)
21+
webmcp.value = await loadWebMcpState(rpc.value.client)
22+
}
23+
24+
useRefreshProvider(fetchData)
25+
let unsubscribe: (() => void) | undefined
26+
onMounted(() => {
27+
void fetchData()
28+
// Client functions register locally at any time; follow the collector.
29+
unsubscribe = rpc.value?.client.onChanged(() => void fetchData())
30+
})
31+
onUnmounted(() => unsubscribe?.())
32+
33+
async function onInvoke(fn: RpcFunctionInfo, parsedArgs: unknown[]): Promise<void> {
34+
if (!rpc.value)
35+
return
36+
pending[fn.name] = true
37+
try {
38+
results[fn.name] = await invokeClientFunction(rpc.value.client, fn.name, parsedArgs)
39+
}
40+
finally {
41+
pending[fn.name] = false
42+
}
43+
}
44+
45+
async function onInvokeTool(name: string, parsedArgs: Record<string, unknown>): Promise<void> {
46+
const modelContext = resolveWebMcpModelContext()
47+
const tool = webmcp.value?.tools.find(t => t.name === name)
48+
if (!modelContext || !tool)
49+
return
50+
const key = `webmcp:${name}`
51+
pending[key] = true
52+
try {
53+
results[key] = await executeWebMcpTool(modelContext, tool, parsedArgs)
54+
}
55+
finally {
56+
pending[key] = false
57+
}
58+
}
59+
</script>
60+
61+
<template>
62+
<ClientView
63+
:functions="functions"
64+
:webmcp="webmcp"
65+
:results="results"
66+
:pending="pending"
67+
@invoke="onInvoke"
68+
@invoke-tool="onInvokeTool"
69+
/>
70+
</template>
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import type { Meta, StoryObj } from '@storybook/vue3-vite'
2+
import ClientView from './ClientView.vue'
3+
4+
const meta = {
5+
title: 'Inspector/ClientView',
6+
component: ClientView,
7+
tags: ['autodocs'],
8+
argTypes: {
9+
onInvoke: { action: 'invoked' },
10+
onInvokeTool: { action: 'invoked-tool' },
11+
},
12+
} satisfies Meta<typeof ClientView>
13+
14+
export default meta
15+
type Story = StoryObj<typeof meta>
16+
17+
const functions = [
18+
{
19+
name: 'devframe:rpc:client-state:updated',
20+
type: 'event' as const,
21+
jsonSerializable: false,
22+
snapshot: false,
23+
cacheable: false,
24+
hasArgs: false,
25+
hasReturns: false,
26+
hasDump: false,
27+
hasSetup: false,
28+
hasHandler: true,
29+
invokable: false,
30+
},
31+
{
32+
name: 'my-plugin:get-selection',
33+
type: 'query' as const,
34+
jsonSerializable: true,
35+
snapshot: false,
36+
cacheable: false,
37+
hasArgs: false,
38+
hasReturns: true,
39+
hasDump: false,
40+
hasSetup: false,
41+
hasHandler: true,
42+
invokable: true,
43+
agent: {
44+
description: 'Return the node currently selected in the page.',
45+
title: 'Get selection',
46+
},
47+
},
48+
]
49+
50+
export const LiveModelContext: Story = {
51+
args: {
52+
functions,
53+
webmcp: {
54+
available: true,
55+
live: true,
56+
executable: true,
57+
tools: [
58+
{
59+
name: 'my-plugin_get-selection',
60+
description: 'Return the node currently selected in the page.',
61+
inputSchema: { type: 'object', properties: {} },
62+
origin: 'http://localhost:5173',
63+
},
64+
{
65+
name: 'add-todo',
66+
description: 'Add a new item to the todo list.',
67+
inputSchema: { type: 'object', properties: { text: { type: 'string' } }, required: ['text'] },
68+
origin: 'http://localhost:5173',
69+
},
70+
],
71+
},
72+
results: {},
73+
pending: {},
74+
},
75+
}
76+
77+
export const ProjectedWithoutModelContext: Story = {
78+
args: {
79+
functions,
80+
webmcp: {
81+
available: false,
82+
live: false,
83+
executable: false,
84+
tools: [
85+
{
86+
name: 'my-plugin_get-selection',
87+
description: 'Return the node currently selected in the page.',
88+
source: 'my-plugin:get-selection',
89+
},
90+
],
91+
},
92+
results: {},
93+
pending: {},
94+
},
95+
}
96+
97+
export const Empty: Story = {
98+
args: {
99+
functions: [],
100+
webmcp: { available: false, live: false, executable: false, tools: [] },
101+
results: {},
102+
pending: {},
103+
},
104+
}

0 commit comments

Comments
 (0)