diff --git a/docs/reference/frontend-mcp.md b/docs/reference/frontend-mcp.md index 3247116a..9683d7c9 100644 --- a/docs/reference/frontend-mcp.md +++ b/docs/reference/frontend-mcp.md @@ -59,6 +59,7 @@ Each exposed tool receives a stable model-visible name: - Streamable HTTP is the initial transport. - Discovery and connection have a bounded timeout (8 seconds by default). - Remote servers require HTTPS. Loopback HTTP is allowed only without headers. +- A server URL may be one exact environment reference such as `${MCP_URL}`. - Header values may reference one exact environment variable with `${VARIABLE}`. A missing variable is a configuration error. - Every enabled tool must explicitly declare `readOnly`. A writable tool must diff --git a/docs/reference/frontend-mcp.zh.md b/docs/reference/frontend-mcp.zh.md index d1ffce05..0c8dcfd8 100644 --- a/docs/reference/frontend-mcp.zh.md +++ b/docs/reference/frontend-mcp.zh.md @@ -57,6 +57,7 @@ DOCUMENT_MCP_AUTHORIZATION=Bearer replace-me - 首个版本使用 Streamable HTTP Transport。 - 工具发现和连接有超时边界,默认 8 秒。 - 远端服务必须使用 HTTPS;回环地址可以使用 HTTP,但不能携带 Header。 +- Server URL 可以用 `${MCP_URL}` 精确引用一个环境变量。 - Header 值可以用 `${VARIABLE}` 精确引用一个环境变量;变量缺失即配置错误。 - 启用的工具必须明确声明 `readOnly`。可写工具还必须设置 `approval: "required"`,否则 Gateway 会在启动时拒绝该配置。 diff --git a/examples/car/.env.example b/examples/car/.env.example index 577ddd31..c955b9e8 100644 --- a/examples/car/.env.example +++ b/examples/car/.env.example @@ -12,6 +12,8 @@ QWEN_AUDIO_REALTIME_VOICE=longanqian COCKPIT_DOMAIN_HOST=127.0.0.1 COCKPIT_DOMAIN_PORT=3010 COCKPIT_DOMAIN_ORIGIN=http://127.0.0.1:3010 +# Optional explicit foreground MCP endpoint; derived from COCKPIT_DOMAIN_ORIGIN by default. +# COCKPIT_FRONTEND_MCP_URL=http://127.0.0.1:3010/mcp/frontend COCKPIT_AGENT_HOST=127.0.0.1 COCKPIT_AGENT_PORT=3020 COCKPIT_AGENT_CARD_URL=http://127.0.0.1:3020/.well-known/agent-card.json diff --git a/examples/car/AGENTS.md b/examples/car/AGENTS.md index cedd8086..1c3fb057 100644 --- a/examples/car/AGENTS.md +++ b/examples/car/AGENTS.md @@ -5,7 +5,7 @@ - `react-app/` 是可替换的客户场景客户端,只依赖 GCP Client SDK;浏览器音频和 UI 保持场景本地。 - `gateway.mjs` 是场景装配点,不复制 Gateway、Realtime、Task、播报或历史实现。 - `agent/` 是轻量、可替换的 A2A 后台示例,不扩展成通用 Agent 框架。 -- `domain/` 是单一业务状态源,通过 MCP 服务 Agent、通过 HTTP/SSE 服务 UI。 +- `domain/` 是单一业务状态源,通过 HTTP/SSE 服务 UI;`tools/` 按领域组织场景工具并通过前台/后台 MCP 工具面暴露。 - 不增加 `actions[]`、第二套 Realtime Server、第二套会话历史或 Gateway 可解析的座舱对象。 前端使用 React 19、Vite 8 和 JavaScript;组件用函数与 Hooks,样式保留在 `App.css`。提交前运行: diff --git a/examples/car/README.md b/examples/car/README.md index 4d785acc..106963b6 100644 --- a/examples/car/README.md +++ b/examples/car/README.md @@ -45,8 +45,9 @@ A preflight validates the Realtime configuration and all four ports before any c - The UI talks to the Gateway through GCP and knows nothing about the Realtime provider or backend Agent. - The primary cockpit stays voice-only. Transcripts appear only in the debug panel, and ASR displays final results only. - Scenario-specific HTTP/SSE projects vehicle, route, media, weather, and order state, plus fine-grained scenario progress. The Gateway does not parse those objects. -- The foreground Agent owns realtime conversation and submits cockpit work through the fixed `spawn_thinking` bridge. The scenario customizes only the backend capability description in [`spawn-thinking-tool.mjs`](spawn-thinking-tool.mjs), while the tool name and argument contract stay fixed. -- The example backend attaches over A2A and invokes domain capabilities through MCP. It intentionally implements only a small intent router. +- The foreground Agent owns realtime conversation and can call the read-only weather tool directly through standard MCP. Other cockpit work goes through the fixed `spawn_thinking` bridge. +- The example backend attaches over A2A and uses a separate backend MCP surface for vehicle, navigation, music, and flash-buy operations. It intentionally implements only a small intent router. +- Scenario tools live in domain-oriented packages under [`tools/`](tools/README.md). One explicit registry adds groups or assigns them to the foreground or backend without changing Gateway protocols. - Customers can replace the UI, backend Agent, or domain service without changing the framework core. ## Development and tests diff --git a/examples/car/README_ZH.md b/examples/car/README_ZH.md index b6d381ac..2a4cc1eb 100644 --- a/examples/car/README_ZH.md +++ b/examples/car/README_ZH.md @@ -45,8 +45,9 @@ npm run example:car - UI 仅通过 GCP 与 Gateway 对话,不感知 Realtime Provider 或后台 Agent。 - 主座舱区域保持纯语音交互;文字转写只进入调试面板,并且 ASR 仅展示最终结果。 - UI 通过场景自己的 HTTP/SSE 通道展示车辆、路线、音乐、天气和订单状态,以及细粒度场景进度;Gateway 不解析这些对象。 -- 前台 Agent 负责实时聊天,座舱任务通过固定的 `spawn_thinking` 桥梁提交给后台;场景只在 [`spawn-thinking-tool.mjs`](spawn-thinking-tool.mjs) 定义后台能力描述,不改工具名称和参数协议。 -- 示例后台通过 A2A 接入 Gateway,并通过 MCP 调用领域能力;它只实现少量意图路由,不模拟完整行业 Agent。 +- 前台 Agent 负责实时聊天,可通过标准 MCP 直接调用只读天气工具;其他座舱任务通过固定的 `spawn_thinking` 桥梁提交给后台。 +- 示例后台通过 A2A 接入 Gateway,并通过独立的后台 MCP 工具面调用车控、导航、音乐和闪购;它只实现少量意图路由。 +- 场景工具按领域收敛在 [`tools/`](tools/README.md),开发者通过显式注册表增加工具包或调整前后台归属,不需要修改 Gateway 协议。 - 客户可以替换整个 UI、后台 Agent 或领域服务,而不修改框架核心。 ## 开发与测试 diff --git a/examples/car/agent/README.md b/examples/car/agent/README.md index 37b2864e..ea2341db 100644 --- a/examples/car/agent/README.md +++ b/examples/car/agent/README.md @@ -17,7 +17,7 @@ Defaults: - A2A Agent: `http://127.0.0.1:3020` - Agent Card: `http://127.0.0.1:3020/.well-known/agent-card.json` -- Cockpit MCP: `http://127.0.0.1:3010/mcp?cockpitId=default` +- Cockpit MCP: `http://127.0.0.1:3010/mcp/backend?cockpitId=default` Environment variables: diff --git a/examples/car/agent/executor.mjs b/examples/car/agent/executor.mjs index 4ad6f509..516f4e89 100644 --- a/examples/car/agent/executor.mjs +++ b/examples/car/agent/executor.mjs @@ -88,7 +88,7 @@ export class CockpitAgentExecutor { const result = plan ? await this.tools.call(plan.name, plan.arguments, { signal: controller.signal }) : { - content: '这个轻量示例只演示车控、导航、音乐、天气和闪购能力。', + content: '这个轻量示例的后台 Agent 只演示车控、导航、音乐和闪购能力。', data: {}, } eventBus.publish(AgentEvent.artifactUpdate({ diff --git a/examples/car/agent/intent-router.mjs b/examples/car/agent/intent-router.mjs index 5ac42cd8..704c56f1 100644 --- a/examples/car/agent/intent-router.mjs +++ b/examples/car/agent/intent-router.mjs @@ -23,11 +23,6 @@ function destination(text) { return clean(match?.[1]).replace(/(?:怎么走|的路线|路线)$/u, '') } -function city(text) { - const match = text.match(/([\p{Script=Han}]{2,12})(?:市|的)?(?:天气|气温)/u) - return clean(match?.[1]).replace(/市$/u, '') || '杭州' -} - function songQuery(text) { const match = text.match(/(?:播放|放|听)(?:一首|一下)?([^,。,.!?!?\n]{1,30})/u) return clean(match?.[1]).replace(/(?:这首歌|音乐|歌曲)$/u, '') @@ -56,10 +51,6 @@ export function planCockpitTool(input) { } } - if (/(天气|气温)/u.test(text)) { - return { name: 'weather', arguments: { city: city(text) } } - } - if (/(停止导航|结束导航|取消导航)/u.test(text)) { return { name: 'navigation_stop', arguments: {} } } @@ -129,6 +120,5 @@ export function describePlan(plan) { if (plan.name.startsWith('vehicle_')) return '正在执行车辆操作' if (plan.name.startsWith('navigation_')) return '正在处理导航请求' if (plan.name.startsWith('music_')) return '正在处理音乐请求' - if (plan.name === 'weather') return '正在查询天气' return '正在处理闪购请求' } diff --git a/examples/car/agent/mcp-client.mjs b/examples/car/agent/mcp-client.mjs index ee452339..69e6dcd6 100644 --- a/examples/car/agent/mcp-client.mjs +++ b/examples/car/agent/mcp-client.mjs @@ -8,7 +8,7 @@ export class CockpitMcpTools { origin = 'http://127.0.0.1:3010', cockpitId = 'default', } = {}) { - this.url = new URL('/mcp', origin) + this.url = new URL('/mcp/backend', origin) this.url.searchParams.set('cockpitId', cockpitId) this.client = null this.connecting = null diff --git a/examples/car/agent/server.mjs b/examples/car/agent/server.mjs index f3da52c5..6526340d 100644 --- a/examples/car/agent/server.mjs +++ b/examples/car/agent/server.mjs @@ -45,9 +45,9 @@ function agentCard(origin) { skills: [{ id: 'cockpit_operations', name: 'Cockpit operations', - description: 'Vehicle, navigation, music, weather and flash-buy example operations.', + description: 'Vehicle, navigation, music and flash-buy example operations.', tags: ['cockpit', 'vehicle', 'navigation', 'music'], - examples: ['打开主驾车窗', '导航到西湖', '播放晴天', '杭州天气怎么样'], + examples: ['打开主驾车窗', '导航到西湖', '播放晴天'], inputModes: ['text/plain'], outputModes: ['text/plain', 'application/json'], securityRequirements: [], diff --git a/examples/car/agent/test/integration.test.mjs b/examples/car/agent/test/integration.test.mjs index 9bec1488..cd44503b 100644 --- a/examples/car/agent/test/integration.test.mjs +++ b/examples/car/agent/test/integration.test.mjs @@ -26,9 +26,6 @@ test('runs core cockpit capabilities through A2A and MCP without UI actions', as trafficSegments: [], } }, - async weather(city) { - return { city, dayweather: '晴', daytemp: '26', nighttemp: '18' } - }, }, }) const domain = new CockpitDomainServer({ domain: cockpit, port: 0 }) @@ -65,9 +62,6 @@ test('runs core cockpit capabilities through A2A and MCP without UI actions', as const music = await submit('播放晴天') assert.match(music.content, /正在播放:晴天/u) - const weather = await submit('杭州天气怎么样') - assert.match(weather.content, /杭州,晴,26°/u) - const cart = await submit('帮我买杯奶茶') assert.match(cart.content, /订单预览/u) assert.match(cart.content, /确认是否下单/u) @@ -82,6 +76,5 @@ test('runs core cockpit capabilities through A2A and MCP without UI actions', as assert.equal(state.navigation.destination, '杭州西湖') assert.equal(state.music.playing, true) assert.equal(state.music.playlist[state.music.currentIndex].title, '晴天') - assert.equal(state.weather.dayweather, '晴') assert.match(state.flashbuy.order.id, /^SG/u) }) diff --git a/examples/car/agent/test/intent-router.test.mjs b/examples/car/agent/test/intent-router.test.mjs index 9508baed..997dba91 100644 --- a/examples/car/agent/test/intent-router.test.mjs +++ b/examples/car/agent/test/intent-router.test.mjs @@ -23,10 +23,7 @@ test('routes representative cockpit requests to existing MCP tools', () => { name: 'music_play', arguments: { query: '晴天' }, }) - assert.deepEqual(planCockpitTool('杭州天气怎么样'), { - name: 'weather', - arguments: { city: '杭州' }, - }) + assert.equal(planCockpitTool('杭州天气怎么样'), null) }) test('keeps explicit order confirmation separate from product search', () => { diff --git a/examples/car/docs/architecture.md b/examples/car/docs/architecture.md index 06ec9389..ab0ec357 100644 --- a/examples/car/docs/architecture.md +++ b/examples/car/docs/architecture.md @@ -3,18 +3,13 @@ ## 四个独立进程 ```text -┌──────────────────────────┐ GCP 6.0 ┌──────────────────────────┐ -│ cockpit-client │◄────────────────────►│ cockpit-gateway │ -│ React UI + Browser Audio │ transcript/audio/ │ qwen-audio-agent Gateway │ -│ │ Tasks/receipts │ + 前台 Realtime Agent │ -└────────────┬─────────────┘ └────────────┬─────────────┘ - │ HTTP/SSE │ BackendPort - │ 业务状态与面板操作 │ A2A - ▼ ▼ -┌──────────────────────────┐ MCP ┌──────────────────────────┐ -│ cockpit-domain │◄─────────────────────│ cockpit-agent │ -│ 单一领域状态与能力 │ │ 轻量示例后台 Agent │ -└──────────────────────────┘ └──────────────────────────┘ +cockpit-client ── GCP 6.0 ──► cockpit-gateway ── A2A ──► cockpit-agent + │ │ │ + │ HTTP/SSE │ frontend MCP │ backend MCP + │ 业务状态 │ weather │ 车控/导航/音乐/闪购 + ▼ ▼ ▼ + cockpit-domain + 单一领域状态与工具执行 ``` 这里不存在“框架 WebUI”。`react-app` 是客户场景客户端的参考实现,它直接使用公开的 `qwen-audio-agent/gateway-client-sdk`,并自行负责浏览器麦克风、音频播放、页面布局和业务面板。 @@ -27,13 +22,15 @@ - `cockpit-domain` 是车辆、导航、音乐、天气和闪购状态的唯一来源。 - UI 通过 HTTP 获取快照、执行面板操作,通过 SSE 接收状态变化。 -- 后台 Agent 通过 MCP 使用同一组领域能力。 +- Gateway 的前台 Agent 通过 `/mcp/frontend` 直接使用只读天气工具。 +- 后台 Agent 通过 `/mcp/backend` 使用车控、导航、音乐和闪购工具。 +- 两个工具面由 `tools/registry.mjs` 显式组合,但共用同一份座舱状态。 - Gateway 不接收 `actions[]`,也不理解车辆、路线、媒体或订单结构。 因此后台任务还可以把详细状态发送给客户自己的座舱系统;Gateway 只接收适合继续对话和播报的 Task 进展与结果。 ## 场景装配 -`gateway.mjs` 是唯一的场景装配点:它通过公开入口创建 A2A Backend Adapter、Backend Agent Host 和 Gateway Application。场景人设集中在 `ASSISTANT.md`,没有引入策略注册表或座舱专用框架分支。 +`gateway.mjs` 是唯一的场景装配点:它通过公开入口创建 A2A Backend Adapter、Backend Agent Host 和 Gateway Application。场景人设集中在 `ASSISTANT.md`,前台 MCP 工具源由 `frontend-profile.json` 引用,没有引入座舱专用框架分支。 四个进程的默认端口只用于本地示例,可通过 `.env.local` 覆盖。`COCKPIT_ID` 用于隔离不同座舱实例,UI 与 Agent 必须使用同一个值。 diff --git a/examples/car/docs/replacing-components.md b/examples/car/docs/replacing-components.md index a18bc453..2c13a906 100644 --- a/examples/car/docs/replacing-components.md +++ b/examples/car/docs/replacing-components.md @@ -29,3 +29,7 @@ - 给 UI 的业务状态投影面(HTTP/SSE、消息总线或客户协议)。 不要把场景对象塞入 Gateway,也不要恢复 `actions[]` 作为隐式 UI 控制协议。 + +## 增加或调整工具 + +`tools/` 中每个目录是一个领域工具包:`manifest.json` 定义 MCP 工具,`execute.mjs` 实现场景逻辑。将工具包加入 `registry.mjs` 的 `FRONTEND_TOOL_GROUPS` 或 `BACKEND_TOOL_GROUPS` 即可决定调用面。这是代码层的明确修改点,不是新的动态插件框架。 diff --git a/examples/car/docs/test-matrix.md b/examples/car/docs/test-matrix.md index 6f871c87..060ce0bb 100644 --- a/examples/car/docs/test-matrix.md +++ b/examples/car/docs/test-matrix.md @@ -3,8 +3,8 @@ | 边界 | 覆盖内容 | 自动化入口 | |---|---|---| | cockpit-domain | 多座舱隔离、车控校验、音乐、导航阶段、闪购确认、状态事件 | `examples/car/domain/test` | -| MCP | 工具发现、参数传递、与 HTTP 共用单一状态 | `examples/car/domain/test/server.test.mjs` | -| cockpit-agent | 车控、导航、音乐、天气、闪购意图;未知请求不臆造能力 | `examples/car/agent/test` | +| MCP | 前/后台工具面隔离、工具发现、参数传递、与 HTTP 共用单一状态 | `examples/car/domain/test/server.test.mjs` | +| cockpit-agent | 车控、导航、音乐、闪购意图;天气和未知请求不进入后台 | `examples/car/agent/test` | | A2A → MCP | 标准 Task 生命周期和真实领域状态变更 | `examples/car/agent/test/integration.test.mjs` | | Gateway 装配 | 仅使用公开 API 注入 A2A Agent、健康和关闭 | `examples/car/test/gateway-composition.test.mjs` | | 启动预检 | Realtime 配置、四进程端口、无效端口 | `examples/car/test/preflight.test.mjs` | diff --git a/examples/car/domain/README.md b/examples/car/domain/README.md index dfa1fe91..91ff373e 100644 --- a/examples/car/domain/README.md +++ b/examples/car/domain/README.md @@ -1,8 +1,8 @@ # Cockpit domain service This scenario-owned service is the single source of truth for the cockpit demo. -It exposes the same vehicle, navigation, music, weather, and flash-buy operations -through MCP and a small HTTP command endpoint. Cockpit panels consume snapshots +It exposes scenario operations through two scoped MCP surfaces and a small HTTP +command endpoint. Cockpit panels consume snapshots and the SSE state stream directly; business state does not pass through the qwen-audio-agent Gateway. @@ -13,11 +13,11 @@ npm run example:car:domain Endpoints: -- `POST /mcp` — stateless Streamable HTTP MCP. +- `POST /mcp/frontend` — foreground MCP surface; weather only. +- `POST /mcp/backend` — backend Agent MCP surface; vehicle, navigation, music, and flash-buy. - `GET /api/cockpit/state?cockpitId=default` — current snapshot. - `GET /api/cockpit/events?cockpitId=default` — snapshot plus state updates via SSE. - `POST /api/cockpit/commands` — direct scenario UI operations using the same tool names. -The existing self-contained car server remains available during migration. The -GCP client and A2A agent issues will switch to this service before removing the -legacy action protocol. +Tool manifests and executors live under `../tools`; this service owns only their +shared state, external service dependencies, and protocol transport. diff --git a/examples/car/domain/cockpit-domain.mjs b/examples/car/domain/cockpit-domain.mjs index bea39a40..df2473e3 100644 --- a/examples/car/domain/cockpit-domain.mjs +++ b/examples/car/domain/cockpit-domain.mjs @@ -1,81 +1,13 @@ -import { - DEFAULT_ORIGIN, - FLASHBUY_CATALOG, -} from './catalog.mjs' import { CockpitStateStore } from './state-store.mjs' -import { COCKPIT_TOOL_NAMES } from './tool-catalog.mjs' - -const WINDOW_PARTS = ['windowFL', 'windowFR', 'windowRL', 'windowRR'] -const VEHICLE_PARTS = [...WINDOW_PARTS, 'sunroof', 'headlights'] -const PART_LABELS = Object.freeze({ - windowFL: '主驾车窗', - windowFR: '副驾车窗', - windowRL: '左后车窗', - windowRR: '右后车窗', - windows: '全部车窗', - sunroof: '天窗', - headlights: '大灯', - ac: '空调', - all: '全部可控部件', -}) +import { + COCKPIT_TOOL_NAMES, + executeCockpitTool, +} from '../tools/registry.mjs' function clean(value) { return String(value || '').trim() } -function activity(callback, category, status, message) { - callback?.({ - kind: 'status', - category, - status, - message, - }) -} - -function result(content, state, changed, data = {}) { - return { - content, - stateVersion: state.version, - changed, - data, - } -} - -function inferCategory(query = '', category) { - if (['food', 'tea'].includes(category)) return category - if (/(外卖|吃|饭|面|沙拉|餐|牛肉|肥牛)/u.test(query)) return 'food' - return 'tea' -} - -function itemMatches(item, query = '') { - if (!query) return true - return [item.name, item.shopName, item.category, item.tag] - .some(value => String(value || '').includes(query)) -} - -function normalizeItem(item) { - return structuredClone(item) -} - -function weatherText(data) { - if (!data) return '天气查询失败' - if (data.raw) return data.raw - const summary = [ - data.city || '当前城市', - data.dayweather || data.nightweather || '未知', - data.daytemp ? `${data.daytemp}°` : '', - data.nighttemp ? `夜间${data.nighttemp}°` : '', - data.daywind && data.daypower ? `${data.daywind}风${data.daypower}级` : '', - ].filter(Boolean).join(',') - const tips = [] - const weather = data.dayweather || data.nightweather || '' - const temperature = Number(data.daytemp || data.nighttemp) - if (/雨/u.test(weather)) tips.push('记得带伞') - if (Number.isFinite(temperature) && temperature <= 10) tips.push('注意保暖') - if (Number.isFinite(temperature) && temperature >= 30) tips.push('注意防晒补水') - return tips.length ? `${summary}。${tips.join(',')}` : summary -} - function emptyServices() { return { async resolvePlace() { return null }, @@ -149,428 +81,14 @@ export class CockpitDomain { } this.#publishActivity(cockpitId, event) } - if (name.startsWith('vehicle_')) { - return this.#vehicle(name, args, cockpitId) - } - if (name.startsWith('music_')) { - return this.#music(name, args, cockpitId) - } - if (name.startsWith('navigation_')) { - return this.#navigation(name, args, cockpitId, reportActivity) - } - if (name === 'weather') { - return this.#weather(args, cockpitId, reportActivity) - } - return this.#flashbuy(args, cockpitId, reportActivity) - } - - #vehicle(name, args, cockpitId) { - if (name === 'vehicle_state_query') { - const state = this.snapshot(cockpitId) - return result(this.#vehicleStateText(args.part, state.vehicle), state, [], { - vehicle: state.vehicle, - }) - } - - if (name === 'vehicle_climate_control') { - const action = clean(args.action) - const current = this.snapshot(cockpitId) - if (action === 'set_temp') { - const temperature = Number(args.temperature) - if (!Number.isFinite(temperature) || temperature < 16 || temperature > 32) { - return result('温度超出范围,空调温度需在 16~32°C 之间', current, []) - } - } - if (action === 'set_fan') { - const fan = Number(args.fan) - if (!Number.isInteger(fan) || fan < 1 || fan > 5) { - return result('风量超出范围,需在 1~5 档之间', current, []) - } - } - if (action === 'set_mode' && !['cool', 'heat'].includes(args.mode)) { - return result('请指定空调模式(cool 或 heat)', current, []) - } - const state = this.store.update(cockpitId, ['vehicle'], next => { - if (action === 'open') next.vehicle.ac = 1 - else if (action === 'close') next.vehicle.ac = 0 - else if (action === 'set_temp') { - next.vehicle.ac = 1 - next.vehicle.acTemp = Number(args.temperature) - } else if (action === 'set_mode') { - next.vehicle.ac = 1 - next.vehicle.acMode = args.mode - } else if (action === 'set_fan') { - next.vehicle.ac = 1 - next.vehicle.acFan = Number(args.fan) - } else { - throw new Error(`Unknown climate action: ${action}`) - } - }) - return result(this.#vehicleStateText('ac', state.vehicle), state, ['vehicle'], { - vehicle: state.vehicle, - }) - } - - const action = clean(args.action) - if (!['open', 'close'].includes(action)) throw new Error(`Unknown vehicle action: ${action}`) - const stateValue = action === 'open' ? 1 : 0 - let parts - if (name === 'vehicle_window_control') { - const window = clean(args.window) || 'windows' - parts = window === 'windows' ? WINDOW_PARTS : [window] - } else if (name === 'vehicle_sunroof_control') { - parts = ['sunroof'] - } else { - parts = ['headlights'] - } - if (parts.some(part => !VEHICLE_PARTS.includes(part))) throw new Error('Unknown vehicle part') - const state = this.store.update(cockpitId, ['vehicle'], next => { - for (const part of parts) next.vehicle[part] = stateValue - }) - const target = name === 'vehicle_window_control' - ? PART_LABELS[args.window || 'windows'] - : PART_LABELS[parts[0]] - return result(`已${action === 'open' ? '打开' : '关闭'}${target}`, state, ['vehicle'], { - vehicle: state.vehicle, - }) - } - - #vehicleStateText(part = 'all', vehicle) { - if (!part || part === 'all' || part === 'windows') { - return [ - ...VEHICLE_PARTS.map(key => `${PART_LABELS[key]}: ${vehicle[key] ? '开启' : '关闭'}`), - `空调: ${vehicle.ac ? '开启' : '关闭'},${vehicle.acMode === 'heat' ? '制热' : '制冷'},${vehicle.acTemp}°C,${vehicle.acFan}档`, - ].join(',') - } - if (part === 'ac') { - return `空调当前${vehicle.ac ? '开启' : '关闭'},${vehicle.acMode === 'heat' ? '制热' : '制冷'},${vehicle.acTemp}°C,${vehicle.acFan}档` - } - return `${PART_LABELS[part] || part}当前${vehicle[part] ? '开启' : '关闭'}` - } - - #music(name, args, cockpitId) { - const snapshot = this.snapshot(cockpitId) - const query = clean(args.query).toLowerCase() - const matches = snapshot.music.playlist.filter(song => ( - !query - || song.title.toLowerCase().includes(query) - || song.artist.toLowerCase().includes(query) - || song.album.toLowerCase().includes(query) - )) - if (name === 'music_search') { - const state = this.store.update(cockpitId, ['music'], next => { - next.music.results = matches - }) - const content = matches.length - ? `找到 ${matches.length} 首相关歌曲:${matches.map(song => `${song.title} - ${song.artist}`).join(';')}` - : `未找到与“${args.query}”相关的歌曲` - return result(content, state, ['music'], { matches }) - } - const state = this.store.update(cockpitId, ['music'], next => { - if (name === 'music_pause') next.music.playing = false - if (name === 'music_next') { - next.music.currentIndex = (next.music.currentIndex + 1) % next.music.playlist.length - next.music.playing = true - } - if (name === 'music_previous') { - next.music.currentIndex = (next.music.currentIndex - 1 + next.music.playlist.length) % next.music.playlist.length - next.music.playing = true - } - if (name === 'music_play') { - if (matches.length && query) { - next.music.currentIndex = next.music.playlist.findIndex(song => song.id === matches[0].id) - } - next.music.playing = true - } - }) - const current = state.music.playlist[state.music.currentIndex] - const content = name === 'music_pause' - ? '已暂停播放' - : `正在播放:${current.title} - ${current.artist}` - return result(content, state, ['music'], { music: state.music }) - } - - async #navigation(name, args, cockpitId, onActivity) { - if (name === 'navigation_stop') { - const state = this.store.update(cockpitId, ['navigation'], next => { - next.navigation = { - status: 'idle', - destination: null, - via: null, - strategy: next.navigation.strategy, - route: null, - map: { markers: [], polylines: [] }, - } - }) - activity(onActivity, 'navigation', 'navigation_stopped', '已停止导航') - return result('已停止导航', state, ['navigation'], { navigation: state.navigation }) - } - - const destination = clean(args.destination) - if (!destination && name === 'navigation_route_query') { - const state = this.snapshot(cockpitId) - const navigation = state.navigation - if (!navigation.route || navigation.status === 'idle') { - return result('当前没有进行中的导航,请先告诉我要去哪里', state, [], { - navigation, - }) - } - const viaText = navigation.via ? `,途经${navigation.via}` : '' - return result( - `当前正${navigation.status === 'navigating' ? '导航' : '规划'}到${navigation.destination}${viaText},` - + `全程${navigation.route.distKm}公里,约${navigation.route.durationMin}分钟`, - state, - [], - { navigation }, - ) - } - if (!destination) throw new Error('Destination is required') - const via = clean(args.via) - const strategy = Number(args.strategy) || 0 - activity(onActivity, 'navigation', 'searching_destination', '正在查找目的地') - const destinationLocation = await this.services.resolvePlace(destination, DEFAULT_ORIGIN.city) - if (!destinationLocation) { - activity(onActivity, 'navigation', 'destination_not_found', `没有找到${destination}`) - const state = this.snapshot(cockpitId) - return result(`无法找到“${destination}”的位置信息`, state, []) - } - activity(onActivity, 'navigation', 'destination_locked', `已找到${destination}`) - let viaLocation = null - if (via) { - activity(onActivity, 'navigation', 'searching_via', '正在查找途经点') - viaLocation = await this.services.resolvePlace(via, DEFAULT_ORIGIN.city) - if (!viaLocation) { - activity(onActivity, 'navigation', 'via_not_found', `没有找到${via}`) - return result(`无法找到途经点“${via}”的位置信息`, this.snapshot(cockpitId), []) - } - activity(onActivity, 'navigation', 'via_locked', `已找到途经点${via}`) - } - activity(onActivity, 'navigation', 'planning_route', '正在规划路线') - const route = await this.#planRoute(destinationLocation, viaLocation, strategy) - if (!route) { - activity(onActivity, 'navigation', 'route_failed', '路线规划失败') - return result('路线规划失败,请稍后重试', this.snapshot(cockpitId), []) - } - const state = this.store.update(cockpitId, ['navigation'], next => { - next.navigation = { - status: name === 'navigation_start' ? 'navigating' : 'preview', - destination, - via: via || null, - strategy, - route, - map: { - markers: [ - { role: 'destination', name: destination, location: destinationLocation }, - ...(viaLocation ? [{ role: 'via', name: via, location: viaLocation }] : []), - ], - polylines: route.legs.map((leg, index) => ({ - segment: index, - polyline: leg.polyline, - trafficSegments: leg.trafficSegments || [], - })), - }, - } - }) - const status = name === 'navigation_start' ? 'navigation_started' : 'route_ready' - activity(onActivity, 'navigation', status, name === 'navigation_start' ? '开始导航' : '路线规划好了') - const viaText = via ? `,途经${via}` : '' - const content = `已规划到${destination}${viaText}的路线,全程${route.distKm}公里,约${route.durationMin}分钟` - return result(content, state, ['navigation'], { navigation: state.navigation }) - } - - async #planRoute(destination, via, strategy) { - const points = via - ? [[DEFAULT_ORIGIN.location, via], [via, destination]] - : [[DEFAULT_ORIGIN.location, destination]] - const legs = [] - for (const [origin, target] of points) { - const leg = await this.services.drivingRoute(origin, target, strategy) - if (!leg) return null - legs.push(leg) - } - const distance = legs.reduce((total, leg) => total + Number(leg.distance || 0), 0) - const duration = legs.reduce((total, leg) => total + Number(leg.duration || 0), 0) - const arrival = new Date(this.now() + duration * 1_000) - return { - distance, - duration, - distKm: (distance / 1_000).toFixed(1), - durationMin: Math.ceil(duration / 60), - arrival: `${String(arrival.getHours()).padStart(2, '0')}:${String(arrival.getMinutes()).padStart(2, '0')}`, - legs: structuredClone(legs), - } - } - - async #weather(args, cockpitId, onActivity) { - const city = clean(args.city) || '杭州' - activity(onActivity, 'weather', 'weather_querying', '正在查询天气') - const weather = await this.services.weather(city) - if (!weather) return result('天气查询失败', this.snapshot(cockpitId), []) - const state = this.store.update(cockpitId, ['weather'], next => { - next.weather = structuredClone(weather) - }) - activity(onActivity, 'weather', 'weather_ready', '天气已更新') - return result(weatherText(weather), state, ['weather'], { weather: state.weather }) - } - - #flashbuy(args, cockpitId, onActivity) { - const action = clean(args.action) - const before = this.snapshot(cockpitId) - if (action === 'search') { - const query = clean(args.query) - const category = inferCategory(query, args.category) - let items = FLASHBUY_CATALOG.filter(item => item.category === category && itemMatches(item, query)) - if (!items.length) items = FLASHBUY_CATALOG.filter(item => item.category === category) - activity(onActivity, 'flashbuy', 'flashbuy_searching', '正在查找附近可送商品') - const state = this.store.update(cockpitId, ['flashbuy'], next => { - Object.assign(next.flashbuy, { - status: 'selecting', - message: items.length ? '已找到附近可送商品' : '没有找到可送商品', - query, - category, - items: items.map(normalizeItem), - order: null, - }) - }) - return result(`找到${items.length}个可送商品`, state, ['flashbuy'], { flashbuy: state.flashbuy }) - } - if (action === 'cancel_order') { - const state = this.store.update(cockpitId, ['flashbuy'], next => { - Object.assign(next.flashbuy, { - status: 'cancelled', - message: '已取消当前闪购流程', - cartItems: [], - total: 0, - preview: null, - order: null, - }) - }) - return result('已取消当前闪购流程', state, ['flashbuy'], { flashbuy: state.flashbuy }) - } - if (action === 'add_to_cart') return this.#addToCart(args, cockpitId, onActivity) - if (action === 'update_cart') return this.#updateCart(args, cockpitId) - if (action === 'preview_order') return this.#previewOrder(args, cockpitId, onActivity) - if (action === 'confirm_order') { - if (before.flashbuy.order) { - return result(`订单${before.flashbuy.order.id}已经提交,请勿重复下单`, before, [], { - order: before.flashbuy.order, - duplicate: true, - }) - } - if (!before.flashbuy.preview) { - return result('还没有可确认的订单,请先选择商品并预览订单', before, []) - } - if (args.confirmed !== true) { - return result('下单前需要用户明确确认', before, [], { - requireConfirm: true, - preview: before.flashbuy.preview, - }) - } - activity(onActivity, 'flashbuy', 'flashbuy_ordering', '正在提交订单') - const state = this.store.update(cockpitId, ['flashbuy'], next => { - const preview = next.flashbuy.preview - next.flashbuy.status = 'completed' - next.flashbuy.message = '已完成下单' - next.flashbuy.order = { - id: `SG${Math.floor(1_000 + this.random() * 9_000)}`, - status: '骑手取货中', - eta: preview.eta, - total: preview.total, - address: preview.address, - items: preview.items, - } - next.flashbuy.cartItems = [] - next.flashbuy.total = 0 - next.flashbuy.preview = null - }) - activity(onActivity, 'flashbuy', 'flashbuy_order_completed', '已完成下单') - return result(`已下单,订单${state.flashbuy.order.id},预计${state.flashbuy.order.eta}送达`, state, ['flashbuy'], { - order: state.flashbuy.order, - }) - } - throw new Error(`Unknown flashbuy action: ${action}`) - } - - #addToCart(args, cockpitId, onActivity) { - let state = this.snapshot(cockpitId) - if (!state.flashbuy.items.length) { - this.#flashbuy({ action: 'search', query: args.query, category: args.category }, cockpitId, onActivity) - state = this.snapshot(cockpitId) - } - const item = args.itemId - ? state.flashbuy.items.find(row => row.id === args.itemId) - : state.flashbuy.items.find(row => itemMatches(row, clean(args.query))) || state.flashbuy.items[0] - if (!item) return result('没有可加入购物车的商品', state, []) - const quantity = Math.max(1, Number(args.quantity) || 1) - activity(onActivity, 'flashbuy', 'flashbuy_adding', '正在加入购物车') - this.store.update(cockpitId, ['flashbuy'], next => { - next.flashbuy.cartItems.push({ - ...normalizeItem(item), - lineId: `${item.id}-${this.now()}`, - quantity, - selectedOptions: structuredClone(args.options || {}), - }) - next.flashbuy.total = next.flashbuy.cartItems.reduce((sum, row) => sum + row.price * row.quantity, 0) - next.flashbuy.status = 'cart_updated' - next.flashbuy.message = '已更新购物车' - next.flashbuy.order = null - next.flashbuy.preview = null - }) - const preview = this.#previewOrder(args, cockpitId, onActivity) - return { - ...preview, - content: `已加入${item.name}。${preview.content}。请向用户确认是否下单。`, - } - } - - #updateCart(args, cockpitId) { - const quantity = Math.max(0, Number(args.quantity) || 0) - const state = this.store.update(cockpitId, ['flashbuy'], next => { - next.flashbuy.cartItems = next.flashbuy.cartItems - .map(row => ( - row.lineId === args.lineId || row.id === args.itemId - ? { ...row, quantity } - : row - )) - .filter(row => row.quantity > 0) - next.flashbuy.total = next.flashbuy.cartItems.reduce((sum, row) => sum + row.price * row.quantity, 0) - next.flashbuy.status = next.flashbuy.cartItems.length ? 'cart_updated' : 'selecting' - next.flashbuy.message = next.flashbuy.cartItems.length ? '已更新购物车' : '购物车已清空' - next.flashbuy.preview = null - next.flashbuy.order = null - }) - return result(state.flashbuy.message, state, ['flashbuy'], { flashbuy: state.flashbuy }) - } - - #previewOrder(args, cockpitId, onActivity) { - const before = this.snapshot(cockpitId) - if (!before.flashbuy.cartItems.length) { - return result('购物车为空,请先选择商品', before, []) - } - activity(onActivity, 'flashbuy', 'flashbuy_previewing', '正在试算订单') - const state = this.store.update(cockpitId, ['flashbuy'], next => { - const subtotal = next.flashbuy.cartItems.reduce((sum, row) => sum + row.price * row.quantity, 0) - const deliveryFee = subtotal >= 35 ? 0 : 5 - const eta = next.flashbuy.cartItems - .map(row => Number.parseInt(row.eta, 10)) - .filter(Number.isFinite) - .sort((a, b) => a - b)[0] || 25 - next.flashbuy.address = clean(args.address) || next.flashbuy.address - next.flashbuy.status = 'awaiting_confirm' - next.flashbuy.message = '请确认订单后下单' - next.flashbuy.total = subtotal - next.flashbuy.preview = { - shopName: next.flashbuy.cartItems[0].shopName, - items: structuredClone(next.flashbuy.cartItems), - subtotal, - deliveryFee, - total: subtotal + deliveryFee, - address: next.flashbuy.address, - eta: `${eta}分钟`, - } + return executeCockpitTool(name, args, { + cockpitId, + now: this.now, + onActivity: reportActivity, + random: this.random, + services: this.services, + snapshot: () => this.snapshot(cockpitId), + store: this.store, }) - const preview = state.flashbuy.preview - const content = `订单预览:${preview.items.map(row => `${row.name}x${row.quantity}`).join('、')},总价${preview.total}元,预计${preview.eta}送达` - return result(content, state, ['flashbuy'], { preview, requireConfirm: true }) } } diff --git a/examples/car/domain/mcp-server.mjs b/examples/car/domain/mcp-server.mjs index 3ba2ce81..aba396b4 100644 --- a/examples/car/domain/mcp-server.mjs +++ b/examples/car/domain/mcp-server.mjs @@ -3,8 +3,6 @@ import { CallToolRequestSchema, ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js' -import { COCKPIT_TOOL_DEFINITIONS } from './tool-catalog.mjs' - function errorResult(error) { return { content: [{ @@ -15,8 +13,13 @@ function errorResult(error) { } } -export function createCockpitMcpServer({ domain, cockpitId = 'default' } = {}) { +export function createCockpitMcpServer({ + domain, + cockpitId = 'default', + tools, +} = {}) { if (!domain?.execute) throw new TypeError('Cockpit MCP server requires a domain runtime') + if (!Array.isArray(tools)) throw new TypeError('Cockpit MCP server requires a scoped tool list') const server = new Server({ name: 'qwen-audio-agent-cockpit', version: '1.0.0', @@ -24,10 +27,13 @@ export function createCockpitMcpServer({ domain, cockpitId = 'default' } = {}) { capabilities: { tools: {} }, }) server.setRequestHandler(ListToolsRequestSchema, async () => ({ - tools: COCKPIT_TOOL_DEFINITIONS, + tools, })) server.setRequestHandler(CallToolRequestSchema, async request => { try { + if (!tools.some(tool => tool.name === request.params.name)) { + throw new Error(`Tool is not available on this MCP surface: ${request.params.name}`) + } const output = await domain.execute( request.params.name, request.params.arguments || {}, diff --git a/examples/car/domain/server.mjs b/examples/car/domain/server.mjs index e44b9f7c..13083e10 100644 --- a/examples/car/domain/server.mjs +++ b/examples/car/domain/server.mjs @@ -7,6 +7,10 @@ import { createAmapCockpitServices } from './amap-services.mjs' import { CockpitDomain } from './cockpit-domain.mjs' import { createCockpitMcpServer } from './mcp-server.mjs' import { loadCockpitEnvironment } from '../environment.mjs' +import { + BACKEND_TOOL_DEFINITIONS, + FRONTEND_TOOL_DEFINITIONS, +} from '../tools/registry.mjs' const MAX_JSON_BYTES = 64 * 1024 @@ -120,11 +124,16 @@ export class CockpitDomainServer { json(response, 200, output) return } - if (url.pathname === '/mcp' && request.method === 'POST') { - await this.#mcp(request, response, cockpitId(request, url)) + const mcpTools = url.pathname === '/mcp/frontend' + ? FRONTEND_TOOL_DEFINITIONS + : url.pathname === '/mcp/backend' + ? BACKEND_TOOL_DEFINITIONS + : null + if (mcpTools && request.method === 'POST') { + await this.#mcp(request, response, cockpitId(request, url), mcpTools) return } - if (url.pathname === '/mcp') { + if (mcpTools) { json(response, 405, { jsonrpc: '2.0', id: null, @@ -160,8 +169,12 @@ export class CockpitDomainServer { }) } - async #mcp(request, response, id) { - const server = createCockpitMcpServer({ domain: this.domain, cockpitId: id }) + async #mcp(request, response, id, tools) { + const server = createCockpitMcpServer({ + domain: this.domain, + cockpitId: id, + tools, + }) const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, }) diff --git a/examples/car/domain/state-store.mjs b/examples/car/domain/state-store.mjs index b84d967d..f3d8b154 100644 --- a/examples/car/domain/state-store.mjs +++ b/examples/car/domain/state-store.mjs @@ -1,7 +1,7 @@ import { DEFAULT_DELIVERY_ADDRESS, - SONGS, -} from './catalog.mjs' +} from '../tools/flashbuy/catalog.mjs' +import { SONGS } from '../tools/music/catalog.mjs' const DEFAULT_COCKPIT_ID = 'default' diff --git a/examples/car/domain/test/server.test.mjs b/examples/car/domain/test/server.test.mjs index f4115ebd..b97d7caa 100644 --- a/examples/car/domain/test/server.test.mjs +++ b/examples/car/domain/test/server.test.mjs @@ -121,30 +121,65 @@ test('streams navigation activity to the scenario UI', async t => { await reader.cancel() }) -test('exposes the same domain operations through MCP', async t => { +test('separates frontend and backend tools across standard MCP surfaces', async t => { const server = new CockpitDomainServer({ domain: domainFixture(), port: 0 }) await server.start() t.after(() => server.close()) - const client = new Client({ name: 'cockpit-domain-test', version: '1.0.0' }) - const transport = new StreamableHTTPClientTransport( - new URL(`${server.origin}/mcp?cockpitId=mcp-car`), - ) - await client.connect(transport) - t.after(() => client.close()) + const backend = new Client({ name: 'cockpit-backend-test', version: '1.0.0' }) + await backend.connect(new StreamableHTTPClientTransport( + new URL(`${server.origin}/mcp/backend?cockpitId=mcp-car`), + )) + t.after(() => backend.close()) + const frontend = new Client({ name: 'cockpit-frontend-test', version: '1.0.0' }) + await frontend.connect(new StreamableHTTPClientTransport( + new URL(`${server.origin}/mcp/frontend?cockpitId=mcp-car`), + )) + t.after(() => frontend.close()) + + const backendTools = await backend.listTools() + assert.equal(backendTools.tools.length, 14) + assert.ok(backendTools.tools.some(tool => tool.name === 'vehicle_climate_control')) + assert.ok(backendTools.tools.some(tool => tool.name === 'flashbuy')) + assert.ok(!backendTools.tools.some(tool => tool.name === 'weather')) - const tools = await client.listTools() - assert.equal(tools.tools.length, 15) - assert.ok(tools.tools.some(tool => tool.name === 'vehicle_climate_control')) - assert.ok(tools.tools.some(tool => tool.name === 'flashbuy')) + const frontendTools = await frontend.listTools() + assert.deepEqual(frontendTools.tools.map(tool => tool.name), ['weather']) - const output = await client.callTool({ + const output = await backend.callTool({ name: 'vehicle_climate_control', arguments: { action: 'set_temp', temperature: 22 }, }) assert.equal(output.isError, undefined) assert.equal(output.structuredContent.vehicle.acTemp, 22) + const weather = await frontend.callTool({ + name: 'weather', + arguments: { city: '杭州' }, + }) + assert.equal(weather.isError, undefined) + assert.match(weather.content[0].text, /杭州,晴,25°/u) + + const unavailable = await frontend.callTool({ + name: 'vehicle_state_query', + arguments: {}, + }) + assert.equal(unavailable.isError, true) + assert.match(unavailable.content[0].text, /not available on this MCP surface/u) + const state = await fetch(`${server.origin}/api/cockpit/state?cockpitId=mcp-car`) .then(response => response.json()) assert.equal(state.vehicle.acTemp, 22) + assert.equal(state.weather.dayweather, '晴') +}) + +test('does not expose an ambiguous combined MCP endpoint', async t => { + const server = new CockpitDomainServer({ domain: domainFixture(), port: 0 }) + await server.start() + t.after(() => server.close()) + const response = await fetch(`${server.origin}/mcp`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' }), + }) + assert.equal(response.status, 404) }) diff --git a/examples/car/domain/tool-catalog.mjs b/examples/car/domain/tool-catalog.mjs deleted file mode 100644 index 0562348a..00000000 --- a/examples/car/domain/tool-catalog.mjs +++ /dev/null @@ -1,39 +0,0 @@ -import { readFileSync } from 'node:fs' - -const DOMAIN_FILES = [ - 'vehicle', - 'navigation', - 'music', - 'weather', - 'flashbuy', -] - -function loadDomain(name) { - const url = new URL(`./domains/${name}.json`, import.meta.url) - return JSON.parse(readFileSync(url, 'utf8')) -} - -export const COCKPIT_TOOL_DEFINITIONS = Object.freeze( - DOMAIN_FILES.flatMap(name => loadDomain(name).functions) - .filter(tool => tool.enabled !== false) - .map(tool => Object.freeze({ - name: tool.name, - title: tool.label, - description: tool.description, - inputSchema: tool.parameters, - annotations: { - readOnlyHint: [ - 'vehicle_state_query', - 'navigation_route_query', - 'music_search', - 'weather', - ].includes(tool.name), - openWorldHint: ['navigation_start', 'navigation_route_query', 'weather'].includes(tool.name), - destructiveHint: tool.name === 'flashbuy', - }, - })), -) - -export const COCKPIT_TOOL_NAMES = Object.freeze( - COCKPIT_TOOL_DEFINITIONS.map(tool => tool.name), -) diff --git a/examples/car/frontend-profile.json b/examples/car/frontend-profile.json index 3825f842..ca570220 100644 --- a/examples/car/frontend-profile.json +++ b/examples/car/frontend-profile.json @@ -2,5 +2,8 @@ "version": 1, "name": "cockpit-example", "description": "A concise, action-oriented foreground voice assistant for the cockpit example.", - "assistant": "ASSISTANT.md" + "assistant": "ASSISTANT.md", + "toolSources": { + "mcp": "tools/frontend-mcp.json" + } } diff --git a/examples/car/gateway.mjs b/examples/car/gateway.mjs index 795a7269..a888e408 100644 --- a/examples/car/gateway.mjs +++ b/examples/car/gateway.mjs @@ -5,6 +5,9 @@ import { COCKPIT_SPAWN_THINKING_DESCRIPTION } from './spawn-thinking-tool.mjs' loadCockpitEnvironment() process.env.QWAUDIO_CONFIG_DIR ||= fileURLToPath(new URL('./.runtime', import.meta.url)) process.env.QWAUDIO_DATA_DIR ||= process.env.QWAUDIO_CONFIG_DIR +process.env.COCKPIT_FRONTEND_MCP_URL ||= `${ + process.env.COCKPIT_DOMAIN_ORIGIN || 'http://127.0.0.1:3010' +}/mcp/frontend` process.env.QWEN_AUDIO_FRONTEND_PROFILE ||= fileURLToPath( new URL('./frontend-profile.json', import.meta.url), ) @@ -26,6 +29,27 @@ function port(value, fallback) { : fallback } +export async function waitForCockpitDomain({ + origin = process.env.COCKPIT_DOMAIN_ORIGIN || 'http://127.0.0.1:3010', + timeoutMs = 8_000, + intervalMs = 100, + fetchImpl = fetch, +} = {}) { + const deadline = Date.now() + timeoutMs + let lastError = null + while (Date.now() < deadline) { + try { + const response = await fetchImpl(new URL('/health', origin)) + if (response.ok) return + lastError = new Error(`Cockpit Domain health returned ${response.status}`) + } catch (error) { + lastError = error + } + await new Promise(resolve => setTimeout(resolve, intervalMs)) + } + throw new Error(`Cockpit Domain is not ready: ${lastError?.message || origin}`) +} + export function startCockpitGateway({ host = process.env.COCKPIT_GATEWAY_HOST || '127.0.0.1', port: listenPort = port(process.env.COCKPIT_GATEWAY_PORT, 18_888), @@ -66,6 +90,7 @@ export function startCockpitGateway({ } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + await waitForCockpitDomain() const runtime = startCockpitGateway() runtime.server.once('listening', () => { const address = runtime.server.address() diff --git a/examples/car/spawn-thinking-tool.mjs b/examples/car/spawn-thinking-tool.mjs index 986edea7..a6cbf383 100644 --- a/examples/car/spawn-thinking-tool.mjs +++ b/examples/car/spawn-thinking-tool.mjs @@ -1,5 +1,5 @@ export const COCKPIT_SPAWN_THINKING_DESCRIPTION = [ '将需要座舱 Agent 执行的任务异步提交到后台。', - '用于车辆车窗、天窗、灯光和空调控制,导航与当前路线查询,音乐播放,天气查询,以及示例闪购流程。', + '用于车辆车窗、天窗、灯光和空调控制,导航与当前路线查询,音乐播放,以及示例闪购流程。', '不要用它处理普通闲聊,也不要声称它具备桌面文件、屏幕、代码或其他未列出的能力。', ].join('') diff --git a/examples/car/test/frontend-weather.test.mjs b/examples/car/test/frontend-weather.test.mjs new file mode 100644 index 00000000..a4e8fab4 --- /dev/null +++ b/examples/car/test/frontend-weather.test.mjs @@ -0,0 +1,42 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { CockpitDomain } from '../domain/cockpit-domain.mjs' +import { CockpitDomainServer } from '../domain/server.mjs' +import { FrontendMcpClient } from '../../../server/src/providers/mcp/frontend-mcp-client.mjs' +import { + normalizeFrontendMcpConfiguration, +} from '../../../server/src/providers/mcp/frontend-mcp-config.mjs' + +test('calls cockpit weather inline through the framework frontend MCP client', async t => { + const domain = new CockpitDomain({ + services: { + async weather(city) { + return { city, dayweather: '晴', daytemp: '27' } + }, + }, + }) + const server = new CockpitDomainServer({ domain, port: 0 }) + await server.start() + t.after(() => server.close()) + const client = new FrontendMcpClient({ + configuration: normalizeFrontendMcpConfiguration({ + version: 1, + servers: { + cockpit: { + enabled: true, + url: `${server.origin}/mcp/frontend`, + tools: { + weather: { enabled: true, readOnly: true }, + }, + }, + }, + }), + }) + t.after(() => client.close()) + + const tools = await client.initialize() + assert.deepEqual(tools.map(tool => tool.name), ['mcp__cockpit__weather']) + const output = await client.execute('mcp__cockpit__weather', { city: '杭州' }) + assert.match(output.text, /杭州,晴,27°/u) + assert.equal(domain.snapshot().weather.daytemp, '27') +}) diff --git a/examples/car/test/tool-surfaces.test.mjs b/examples/car/test/tool-surfaces.test.mjs new file mode 100644 index 00000000..ae0e708a --- /dev/null +++ b/examples/car/test/tool-surfaces.test.mjs @@ -0,0 +1,27 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' +import { + BACKEND_TOOL_DEFINITIONS, + FRONTEND_TOOL_DEFINITIONS, +} from '../tools/registry.mjs' + +test('assigns weather to the foreground and all other cockpit tools to the backend', () => { + assert.deepEqual(FRONTEND_TOOL_DEFINITIONS.map(tool => tool.name), ['weather']) + assert.equal(BACKEND_TOOL_DEFINITIONS.length, 14) + assert.ok(!BACKEND_TOOL_DEFINITIONS.some(tool => tool.name === 'weather')) +}) + +test('binds the cockpit frontend profile to the scoped MCP configuration', () => { + const profile = JSON.parse(readFileSync( + new URL('../frontend-profile.json', import.meta.url), + 'utf8', + )) + const config = JSON.parse(readFileSync( + new URL('../tools/frontend-mcp.json', import.meta.url), + 'utf8', + )) + assert.equal(profile.toolSources.mcp, 'tools/frontend-mcp.json') + assert.equal(config.servers.cockpit.url, '${COCKPIT_FRONTEND_MCP_URL}') + assert.deepEqual(Object.keys(config.servers.cockpit.tools), ['weather']) +}) diff --git a/examples/car/tools/README.md b/examples/car/tools/README.md new file mode 100644 index 00000000..3d613b45 --- /dev/null +++ b/examples/car/tools/README.md @@ -0,0 +1,13 @@ +# Cockpit tool groups + +Each directory is one scenario capability group rather than one file per MCP +function. A group keeps its MCP manifest next to its executor and receives the +shared cockpit state and external services through the execution context. + +`registry.mjs` is the only composition point: + +- `FRONTEND_TOOL_GROUPS` are called inline by the foreground Realtime Agent. +- `BACKEND_TOOL_GROUPS` are called by the replaceable cockpit Agent over MCP. + +Both surfaces use the standard MCP contract. Adding a group requires no change +to the Gateway protocol or the cockpit UI protocol. diff --git a/examples/car/domain/catalog.mjs b/examples/car/tools/flashbuy/catalog.mjs similarity index 68% rename from examples/car/domain/catalog.mjs rename to examples/car/tools/flashbuy/catalog.mjs index 01ba7b63..d9713a46 100644 --- a/examples/car/domain/catalog.mjs +++ b/examples/car/tools/flashbuy/catalog.mjs @@ -1,12 +1,3 @@ -export const SONGS = Object.freeze([ - Object.freeze({ id: 'sunny-day', title: '晴天', artist: '周杰伦', album: '叶惠美' }), - Object.freeze({ id: 'common-jasmine-orange', title: '七里香', artist: '周杰伦', album: '七里香' }), - Object.freeze({ id: 'rice-field', title: '稻香', artist: '周杰伦', album: '魔杰座' }), - Object.freeze({ id: 'nocturne', title: '夜曲', artist: '周杰伦', album: '十一月的萧邦' }), - Object.freeze({ id: 'simple-love', title: '简单爱', artist: '周杰伦', album: '范特西' }), - Object.freeze({ id: 'blue-and-white-porcelain', title: '青花瓷', artist: '周杰伦', album: '我很忙' }), -]) - export const FLASHBUY_CATALOG = Object.freeze([ Object.freeze({ id: 'latte', shopId: 'tea-island', shopName: '茶屿', category: 'tea', name: '茉莉轻乳茶', eta: '18分钟', price: 18, tag: '少糖推荐', options: { sugar: ['无糖', '少糖', '正常糖'], temperature: ['冰', '常温', '热'], size: ['中杯', '大杯'] } }), Object.freeze({ id: 'milk', shopId: 'daily-cup', shopName: '满杯日常', category: 'tea', name: '厚芋泥鲜奶', eta: '20分钟', price: 24, tag: '热饮', options: { sugar: ['少糖', '正常糖'], temperature: ['常温', '热'], size: ['中杯', '大杯'] } }), @@ -17,8 +8,3 @@ export const FLASHBUY_CATALOG = Object.freeze([ ]) export const DEFAULT_DELIVERY_ADDRESS = '阿里巴巴云谷园区 · P2 车位' -export const DEFAULT_ORIGIN = Object.freeze({ - name: '阿里巴巴云谷园区', - location: '120.037239,30.318522', - city: '杭州', -}) diff --git a/examples/car/tools/flashbuy/execute.mjs b/examples/car/tools/flashbuy/execute.mjs new file mode 100644 index 00000000..f9cc0ccb --- /dev/null +++ b/examples/car/tools/flashbuy/execute.mjs @@ -0,0 +1,207 @@ +import { FLASHBUY_CATALOG } from './catalog.mjs' +import { clean, reportActivity, toolResult } from '../shared.mjs' + +function inferCategory(query = '', category) { + if (['food', 'tea'].includes(category)) return category + if (/(外卖|吃|饭|面|沙拉|餐|牛肉|肥牛)/u.test(query)) return 'food' + return 'tea' +} + +function itemMatches(item, query = '') { + if (!query) return true + return [item.name, item.shopName, item.category, item.tag] + .some(value => String(value || '').includes(query)) +} + +function normalizeItem(item) { + return structuredClone(item) +} + +function previewOrder(args, context) { + const { + cockpitId, + onActivity, + snapshot, + store, + } = context + const before = snapshot() + if (!before.flashbuy.cartItems.length) { + return toolResult('购物车为空,请先选择商品', before, []) + } + reportActivity(onActivity, 'flashbuy', 'flashbuy_previewing', '正在试算订单') + const state = store.update(cockpitId, ['flashbuy'], next => { + const subtotal = next.flashbuy.cartItems.reduce((sum, row) => sum + row.price * row.quantity, 0) + const deliveryFee = subtotal >= 35 ? 0 : 5 + const eta = next.flashbuy.cartItems + .map(row => Number.parseInt(row.eta, 10)) + .filter(Number.isFinite) + .sort((a, b) => a - b)[0] || 25 + next.flashbuy.address = clean(args.address) || next.flashbuy.address + next.flashbuy.status = 'awaiting_confirm' + next.flashbuy.message = '请确认订单后下单' + next.flashbuy.total = subtotal + next.flashbuy.preview = { + shopName: next.flashbuy.cartItems[0].shopName, + items: structuredClone(next.flashbuy.cartItems), + subtotal, + deliveryFee, + total: subtotal + deliveryFee, + address: next.flashbuy.address, + eta: `${eta}分钟`, + } + }) + const preview = state.flashbuy.preview + const content = `订单预览:${preview.items.map(row => `${row.name}x${row.quantity}`).join('、')},总价${preview.total}元,预计${preview.eta}送达` + return toolResult(content, state, ['flashbuy'], { preview, requireConfirm: true }) +} + +function search(args, context) { + const { + cockpitId, + onActivity, + store, + } = context + const query = clean(args.query) + const category = inferCategory(query, args.category) + let items = FLASHBUY_CATALOG.filter(item => item.category === category && itemMatches(item, query)) + if (!items.length) items = FLASHBUY_CATALOG.filter(item => item.category === category) + reportActivity(onActivity, 'flashbuy', 'flashbuy_searching', '正在查找附近可送商品') + const state = store.update(cockpitId, ['flashbuy'], next => { + Object.assign(next.flashbuy, { + status: 'selecting', + message: items.length ? '已找到附近可送商品' : '没有找到可送商品', + query, + category, + items: items.map(normalizeItem), + order: null, + }) + }) + return toolResult(`找到${items.length}个可送商品`, state, ['flashbuy'], { flashbuy: state.flashbuy }) +} + +function addToCart(args, context) { + const { + cockpitId, + now, + onActivity, + snapshot, + store, + } = context + let state = snapshot() + if (!state.flashbuy.items.length) { + search(args, context) + state = snapshot() + } + const item = args.itemId + ? state.flashbuy.items.find(row => row.id === args.itemId) + : state.flashbuy.items.find(row => itemMatches(row, clean(args.query))) || state.flashbuy.items[0] + if (!item) return toolResult('没有可加入购物车的商品', state, []) + const quantity = Math.max(1, Number(args.quantity) || 1) + reportActivity(onActivity, 'flashbuy', 'flashbuy_adding', '正在加入购物车') + store.update(cockpitId, ['flashbuy'], next => { + next.flashbuy.cartItems.push({ + ...normalizeItem(item), + lineId: `${item.id}-${now()}`, + quantity, + selectedOptions: structuredClone(args.options || {}), + }) + next.flashbuy.total = next.flashbuy.cartItems.reduce((sum, row) => sum + row.price * row.quantity, 0) + next.flashbuy.status = 'cart_updated' + next.flashbuy.message = '已更新购物车' + next.flashbuy.order = null + next.flashbuy.preview = null + }) + const preview = previewOrder(args, context) + return { + ...preview, + content: `已加入${item.name}。${preview.content}。请向用户确认是否下单。`, + } +} + +function updateCart(args, context) { + const { cockpitId, store } = context + const quantity = Math.max(0, Number(args.quantity) || 0) + const state = store.update(cockpitId, ['flashbuy'], next => { + next.flashbuy.cartItems = next.flashbuy.cartItems + .map(row => ( + row.lineId === args.lineId || row.id === args.itemId + ? { ...row, quantity } + : row + )) + .filter(row => row.quantity > 0) + next.flashbuy.total = next.flashbuy.cartItems.reduce((sum, row) => sum + row.price * row.quantity, 0) + next.flashbuy.status = next.flashbuy.cartItems.length ? 'cart_updated' : 'selecting' + next.flashbuy.message = next.flashbuy.cartItems.length ? '已更新购物车' : '购物车已清空' + next.flashbuy.preview = null + next.flashbuy.order = null + }) + return toolResult(state.flashbuy.message, state, ['flashbuy'], { flashbuy: state.flashbuy }) +} + +export function executeFlashbuyTool(_name, args, context) { + const { + cockpitId, + onActivity, + random, + snapshot, + store, + } = context + const action = clean(args.action) + const before = snapshot() + if (action === 'search') return search(args, context) + if (action === 'cancel_order') { + const state = store.update(cockpitId, ['flashbuy'], next => { + Object.assign(next.flashbuy, { + status: 'cancelled', + message: '已取消当前闪购流程', + cartItems: [], + total: 0, + preview: null, + order: null, + }) + }) + return toolResult('已取消当前闪购流程', state, ['flashbuy'], { flashbuy: state.flashbuy }) + } + if (action === 'add_to_cart') return addToCart(args, context) + if (action === 'update_cart') return updateCart(args, context) + if (action === 'preview_order') return previewOrder(args, context) + if (action === 'confirm_order') { + if (before.flashbuy.order) { + return toolResult(`订单${before.flashbuy.order.id}已经提交,请勿重复下单`, before, [], { + order: before.flashbuy.order, + duplicate: true, + }) + } + if (!before.flashbuy.preview) { + return toolResult('还没有可确认的订单,请先选择商品并预览订单', before, []) + } + if (args.confirmed !== true) { + return toolResult('下单前需要用户明确确认', before, [], { + requireConfirm: true, + preview: before.flashbuy.preview, + }) + } + reportActivity(onActivity, 'flashbuy', 'flashbuy_ordering', '正在提交订单') + const state = store.update(cockpitId, ['flashbuy'], next => { + const preview = next.flashbuy.preview + next.flashbuy.status = 'completed' + next.flashbuy.message = '已完成下单' + next.flashbuy.order = { + id: `SG${Math.floor(1_000 + random() * 9_000)}`, + status: '骑手取货中', + eta: preview.eta, + total: preview.total, + address: preview.address, + items: preview.items, + } + next.flashbuy.cartItems = [] + next.flashbuy.total = 0 + next.flashbuy.preview = null + }) + reportActivity(onActivity, 'flashbuy', 'flashbuy_order_completed', '已完成下单') + return toolResult(`已下单,订单${state.flashbuy.order.id},预计${state.flashbuy.order.eta}送达`, state, ['flashbuy'], { + order: state.flashbuy.order, + }) + } + throw new Error(`Unknown flashbuy action: ${action}`) +} diff --git a/examples/car/domain/domains/flashbuy.json b/examples/car/tools/flashbuy/manifest.json similarity index 56% rename from examples/car/domain/domains/flashbuy.json rename to examples/car/tools/flashbuy/manifest.json index c9c4a629..4da25aef 100644 --- a/examples/car/domain/domains/flashbuy.json +++ b/examples/car/tools/flashbuy/manifest.json @@ -3,37 +3,25 @@ "label": "淘宝闪购", "description": "座舱示例的附近商品搜索、加购、订单预览、确认下单和取消能力。", "enabled": true, - "routeRules": [ - "用户表达任何外卖、奶茶、咖啡、点餐、淘宝闪购、下单相关意图 → 必须调用 flashbuy", - "用户只说“看看/搜一下/有哪些”时 → 调用 flashbuy(action=search)", - "用户说“帮我点/来一杯/点杯/想喝/想吃”时 → 优先调用 flashbuy(action=add_to_cart),由 executor 搜索、选择候选、加入购物车并给出订单预览,然后询问是否确认下单", - "只有已经给用户播报或展示过订单预览后,用户再次明确说“确认”“下单”“买”“就这个”“可以”等确认意图时,才调用 flashbuy(action=confirm_order, confirmed=true)", - "如果当前还没有订单预览,用户说“确认”“下单吧”时,禁止直接 confirm_order;应先调用 flashbuy(action=add_to_cart 或 preview_order) 生成预览,再询问确认", - "下单前必须让用户听到或看到商品、价格、送达位置和预计送达时间" - ], - "examples": [ - "用户说“帮我点杯奶茶” → 调用 flashbuy(action=add_to_cart, query=奶茶)", - "用户说“看看附近有什么外卖” → 调用 flashbuy(action=search, category=food)", - "用户说“确认下单”且已有订单预览 → 调用 flashbuy(action=confirm_order, confirmed=true)" - ], "functions": [ { "name": "flashbuy", "label": "淘宝闪购", "description": "搜索外卖/奶茶/咖啡等附近可配送商品、加入购物车、修改购物车、预览订单、确认下单或取消。用户说帮我点/来一杯/想喝/想吃时优先 add_to_cart 生成订单预览;用户只是看看/搜一下/有哪些时用 search。下单前必须先预览订单并获得用户明确确认;没有订单预览时禁止直接 confirm_order。", - "executor": "flashbuy.run", "enabled": true, - "exposure": { - "text": true, - "voice": true, - "debug": true - }, "parameters": { "type": "object", "properties": { "action": { "type": "string", - "enum": ["search", "add_to_cart", "update_cart", "preview_order", "confirm_order", "cancel_order"], + "enum": [ + "search", + "add_to_cart", + "update_cart", + "preview_order", + "confirm_order", + "cancel_order" + ], "description": "search=搜索商品, add_to_cart=加入购物车, update_cart=修改购物车, preview_order=预览订单, confirm_order=确认下单, cancel_order=取消当前闪购流程" }, "query": { @@ -42,7 +30,10 @@ }, "category": { "type": "string", - "enum": ["food", "tea"], + "enum": [ + "food", + "tea" + ], "description": "food=外卖热餐, tea=奶茶饮品" }, "itemId": { @@ -66,7 +57,9 @@ "description": "只有用户明确说确认、下单、买、支付等确认意图时才为 true" } }, - "required": ["action"] + "required": [ + "action" + ] } } ] diff --git a/examples/car/tools/frontend-mcp.json b/examples/car/tools/frontend-mcp.json new file mode 100644 index 00000000..845a52cc --- /dev/null +++ b/examples/car/tools/frontend-mcp.json @@ -0,0 +1,16 @@ +{ + "version": 1, + "servers": { + "cockpit": { + "enabled": true, + "url": "${COCKPIT_FRONTEND_MCP_URL}", + "tools": { + "weather": { + "enabled": true, + "readOnly": true, + "description": "查询指定城市的当日天气、温度和出行提示。这是可由前台语音助手直接调用的座舱示例工具。" + } + } + } + } +} diff --git a/examples/car/tools/music/catalog.mjs b/examples/car/tools/music/catalog.mjs new file mode 100644 index 00000000..9502589b --- /dev/null +++ b/examples/car/tools/music/catalog.mjs @@ -0,0 +1,8 @@ +export const SONGS = Object.freeze([ + Object.freeze({ id: 'sunny-day', title: '晴天', artist: '周杰伦', album: '叶惠美' }), + Object.freeze({ id: 'common-jasmine-orange', title: '七里香', artist: '周杰伦', album: '七里香' }), + Object.freeze({ id: 'rice-field', title: '稻香', artist: '周杰伦', album: '魔杰座' }), + Object.freeze({ id: 'nocturne', title: '夜曲', artist: '周杰伦', album: '十一月的萧邦' }), + Object.freeze({ id: 'simple-love', title: '简单爱', artist: '周杰伦', album: '范特西' }), + Object.freeze({ id: 'blue-and-white-porcelain', title: '青花瓷', artist: '周杰伦', album: '我很忙' }), +]) diff --git a/examples/car/tools/music/execute.mjs b/examples/car/tools/music/execute.mjs new file mode 100644 index 00000000..3eda713f --- /dev/null +++ b/examples/car/tools/music/execute.mjs @@ -0,0 +1,44 @@ +import { clean, toolResult } from '../shared.mjs' + +export function executeMusicTool(name, args, context) { + const { cockpitId, snapshot, store } = context + const currentState = snapshot() + const query = clean(args.query).toLowerCase() + const matches = currentState.music.playlist.filter(song => ( + !query + || song.title.toLowerCase().includes(query) + || song.artist.toLowerCase().includes(query) + || song.album.toLowerCase().includes(query) + )) + if (name === 'music_search') { + const state = store.update(cockpitId, ['music'], next => { + next.music.results = matches + }) + const content = matches.length + ? `找到 ${matches.length} 首相关歌曲:${matches.map(song => `${song.title} - ${song.artist}`).join(';')}` + : `未找到与“${args.query}”相关的歌曲` + return toolResult(content, state, ['music'], { matches }) + } + const state = store.update(cockpitId, ['music'], next => { + if (name === 'music_pause') next.music.playing = false + if (name === 'music_next') { + next.music.currentIndex = (next.music.currentIndex + 1) % next.music.playlist.length + next.music.playing = true + } + if (name === 'music_previous') { + next.music.currentIndex = (next.music.currentIndex - 1 + next.music.playlist.length) % next.music.playlist.length + next.music.playing = true + } + if (name === 'music_play') { + if (matches.length && query) { + next.music.currentIndex = next.music.playlist.findIndex(song => song.id === matches[0].id) + } + next.music.playing = true + } + }) + const current = state.music.playlist[state.music.currentIndex] + const content = name === 'music_pause' + ? '已暂停播放' + : `正在播放:${current.title} - ${current.artist}` + return toolResult(content, state, ['music'], { music: state.music }) +} diff --git a/examples/car/domain/domains/music.json b/examples/car/tools/music/manifest.json similarity index 54% rename from examples/car/domain/domains/music.json rename to examples/car/tools/music/manifest.json index 592edeb6..e285fa82 100644 --- a/examples/car/domain/domains/music.json +++ b/examples/car/tools/music/manifest.json @@ -3,31 +3,12 @@ "label": "音乐", "description": "座舱示例的音乐播放、暂停、切歌和搜索能力。", "enabled": true, - "routeRules": [ - "用户说“我要听歌”、“放首歌”、“播放音乐”、“继续播放”等任何播放意图 → 必须调用 music_play", - "用户说“搜一下/找一下某首歌、某个歌手或专辑”且没有要求播放 → 必须调用 music_search", - "用户说“暂停”、“停止播放” → 必须调用 music_pause", - "用户说“下一首”、“切歌” → 必须调用 music_next", - "用户说“上一首” → 必须调用 music_previous", - "禁止用文字描述播放行为而不调用技能;“正在为您播放...”这种回复如果没有先调用 music_play 就是严重错误" - ], - "examples": [ - "用户说“播放晴天” → 调用 music_play(query=晴天)", - "用户说“搜一下周杰伦” → 调用 music_search(query=周杰伦)", - "用户说“下一首” → 调用 music_next()" - ], "functions": [ { "name": "music_play", "label": "音乐播放", "description": "播放或继续播放音乐,可按歌曲名、歌手或专辑点歌。当前歌单:晴天、七里香、稻香、夜曲、简单爱、青花瓷", - "executor": "music.play", "enabled": true, - "exposure": { - "text": true, - "voice": true, - "debug": true - }, "parameters": { "type": "object", "properties": { @@ -42,13 +23,7 @@ "name": "music_pause", "label": "音乐暂停", "description": "暂停当前音乐播放。", - "executor": "music.pause", "enabled": true, - "exposure": { - "text": true, - "voice": true, - "debug": true - }, "parameters": { "type": "object", "properties": {} @@ -58,13 +33,7 @@ "name": "music_next", "label": "下一首", "description": "切换到下一首音乐。", - "executor": "music.next", "enabled": true, - "exposure": { - "text": true, - "voice": true, - "debug": true - }, "parameters": { "type": "object", "properties": {} @@ -74,13 +43,7 @@ "name": "music_previous", "label": "上一首", "description": "切换到上一首音乐。", - "executor": "music.previous", "enabled": true, - "exposure": { - "text": true, - "voice": true, - "debug": true - }, "parameters": { "type": "object", "properties": {} @@ -90,13 +53,7 @@ "name": "music_search", "label": "音乐搜索", "description": "搜索歌曲、歌手或专辑,不直接开始播放。当前歌单:晴天、七里香、稻香、夜曲、简单爱、青花瓷", - "executor": "music.search", "enabled": true, - "exposure": { - "text": true, - "voice": true, - "debug": true - }, "parameters": { "type": "object", "properties": { @@ -105,7 +62,9 @@ "description": "要搜索的歌曲名、歌手、专辑或关键词" } }, - "required": ["query"] + "required": [ + "query" + ] } } ] diff --git a/examples/car/tools/navigation/catalog.mjs b/examples/car/tools/navigation/catalog.mjs new file mode 100644 index 00000000..50251758 --- /dev/null +++ b/examples/car/tools/navigation/catalog.mjs @@ -0,0 +1,5 @@ +export const DEFAULT_ORIGIN = Object.freeze({ + name: '阿里巴巴云谷园区', + location: '120.037239,30.318522', + city: '杭州', +}) diff --git a/examples/car/tools/navigation/execute.mjs b/examples/car/tools/navigation/execute.mjs new file mode 100644 index 00000000..09a46ccd --- /dev/null +++ b/examples/car/tools/navigation/execute.mjs @@ -0,0 +1,120 @@ +import { DEFAULT_ORIGIN } from './catalog.mjs' +import { clean, reportActivity, toolResult } from '../shared.mjs' + +async function planRoute(destination, via, strategy, { now, services }) { + const points = via + ? [[DEFAULT_ORIGIN.location, via], [via, destination]] + : [[DEFAULT_ORIGIN.location, destination]] + const legs = [] + for (const [origin, target] of points) { + const leg = await services.drivingRoute(origin, target, strategy) + if (!leg) return null + legs.push(leg) + } + const distance = legs.reduce((total, leg) => total + Number(leg.distance || 0), 0) + const duration = legs.reduce((total, leg) => total + Number(leg.duration || 0), 0) + const arrival = new Date(now() + duration * 1_000) + return { + distance, + duration, + distKm: (distance / 1_000).toFixed(1), + durationMin: Math.ceil(duration / 60), + arrival: `${String(arrival.getHours()).padStart(2, '0')}:${String(arrival.getMinutes()).padStart(2, '0')}`, + legs: structuredClone(legs), + } +} + +export async function executeNavigationTool(name, args, context) { + const { + cockpitId, + onActivity, + services, + snapshot, + store, + } = context + if (name === 'navigation_stop') { + const state = store.update(cockpitId, ['navigation'], next => { + next.navigation = { + status: 'idle', + destination: null, + via: null, + strategy: next.navigation.strategy, + route: null, + map: { markers: [], polylines: [] }, + } + }) + reportActivity(onActivity, 'navigation', 'navigation_stopped', '已停止导航') + return toolResult('已停止导航', state, ['navigation'], { navigation: state.navigation }) + } + + const destination = clean(args.destination) + if (!destination && name === 'navigation_route_query') { + const state = snapshot() + const navigation = state.navigation + if (!navigation.route || navigation.status === 'idle') { + return toolResult('当前没有进行中的导航,请先告诉我要去哪里', state, [], { + navigation, + }) + } + const viaText = navigation.via ? `,途经${navigation.via}` : '' + return toolResult( + `当前正${navigation.status === 'navigating' ? '导航' : '规划'}到${navigation.destination}${viaText},` + + `全程${navigation.route.distKm}公里,约${navigation.route.durationMin}分钟`, + state, + [], + { navigation }, + ) + } + if (!destination) throw new Error('Destination is required') + const via = clean(args.via) + const strategy = Number(args.strategy) || 0 + reportActivity(onActivity, 'navigation', 'searching_destination', '正在查找目的地') + const destinationLocation = await services.resolvePlace(destination, DEFAULT_ORIGIN.city) + if (!destinationLocation) { + reportActivity(onActivity, 'navigation', 'destination_not_found', `没有找到${destination}`) + const state = snapshot() + return toolResult(`无法找到“${destination}”的位置信息`, state, []) + } + reportActivity(onActivity, 'navigation', 'destination_locked', `已找到${destination}`) + let viaLocation = null + if (via) { + reportActivity(onActivity, 'navigation', 'searching_via', '正在查找途经点') + viaLocation = await services.resolvePlace(via, DEFAULT_ORIGIN.city) + if (!viaLocation) { + reportActivity(onActivity, 'navigation', 'via_not_found', `没有找到${via}`) + return toolResult(`无法找到途经点“${via}”的位置信息`, snapshot(), []) + } + reportActivity(onActivity, 'navigation', 'via_locked', `已找到途经点${via}`) + } + reportActivity(onActivity, 'navigation', 'planning_route', '正在规划路线') + const route = await planRoute(destinationLocation, viaLocation, strategy, context) + if (!route) { + reportActivity(onActivity, 'navigation', 'route_failed', '路线规划失败') + return toolResult('路线规划失败,请稍后重试', snapshot(), []) + } + const state = store.update(cockpitId, ['navigation'], next => { + next.navigation = { + status: name === 'navigation_start' ? 'navigating' : 'preview', + destination, + via: via || null, + strategy, + route, + map: { + markers: [ + { role: 'destination', name: destination, location: destinationLocation }, + ...(viaLocation ? [{ role: 'via', name: via, location: viaLocation }] : []), + ], + polylines: route.legs.map((leg, index) => ({ + segment: index, + polyline: leg.polyline, + trafficSegments: leg.trafficSegments || [], + })), + }, + } + }) + const status = name === 'navigation_start' ? 'navigation_started' : 'route_ready' + reportActivity(onActivity, 'navigation', status, name === 'navigation_start' ? '开始导航' : '路线规划好了') + const viaText = via ? `,途经${via}` : '' + const content = `已规划到${destination}${viaText}的路线,全程${route.distKm}公里,约${route.durationMin}分钟` + return toolResult(content, state, ['navigation'], { navigation: state.navigation }) +} diff --git a/examples/car/domain/domains/navigation.json b/examples/car/tools/navigation/manifest.json similarity index 62% rename from examples/car/domain/domains/navigation.json rename to examples/car/tools/navigation/manifest.json index 311a1fdf..768d1ca5 100644 --- a/examples/car/domain/domains/navigation.json +++ b/examples/car/tools/navigation/manifest.json @@ -3,29 +3,12 @@ "label": "导航", "description": "座舱示例的地点搜索、路线规划、开始导航与停止导航能力。", "enabled": true, - "routeRules": [ - "用户表达开始导航、带我去、导航到某地 → 必须调用 navigation_start", - "用户表达查路线、看看怎么走、规划路线但未要求开始导航 → 必须调用 navigation_route_query", - "用户表达停止导航、结束导航、取消路线 → 必须调用 navigation_stop", - "导航是最高优先级任务,应立即响应,不要反复确认" - ], - "examples": [ - "用户说“导航到西湖” → 调用 navigation_start(destination=西湖)", - "用户说“看看去西湖怎么走” → 调用 navigation_route_query(destination=西湖)", - "用户说“停止导航” → 调用 navigation_stop()" - ], "functions": [ { "name": "navigation_start", "label": "开始导航", "description": "搜索目的地、规划驾车路线并开始导航。", - "executor": "navigation.start", "enabled": true, - "exposure": { - "text": true, - "voice": true, - "debug": true - }, "parameters": { "type": "object", "properties": { @@ -39,24 +22,28 @@ }, "strategy": { "type": "number", - "enum": [0, 13, 5, 4, 11, 14, 2], + "enum": [ + 0, + 13, + 5, + 4, + 11, + 14, + 2 + ], "description": "路线偏好: 0=智能推荐, 13=高速优先, 5=不走高速, 4=躲避拥堵, 11=少收费, 14=大路优先, 2=时间优先。当用户提及路线偏好时设置此参数" } }, - "required": ["destination"] + "required": [ + "destination" + ] } }, { "name": "navigation_route_query", "label": "路线查询", "description": "搜索目的地并规划驾车路线,但不开始导航。", - "executor": "navigation.route_query", "enabled": true, - "exposure": { - "text": true, - "voice": true, - "debug": true - }, "parameters": { "type": "object", "properties": { @@ -70,7 +57,15 @@ }, "strategy": { "type": "number", - "enum": [0, 13, 5, 4, 11, 14, 2], + "enum": [ + 0, + 13, + 5, + 4, + 11, + 14, + 2 + ], "description": "路线偏好: 0=智能推荐, 13=高速优先, 5=不走高速, 4=躲避拥堵, 11=少收费, 14=大路优先, 2=时间优先。当用户提及路线偏好时设置此参数" } } @@ -80,13 +75,7 @@ "name": "navigation_stop", "label": "停止导航", "description": "停止当前导航并清空地图路线。", - "executor": "navigation.stop", "enabled": true, - "exposure": { - "text": true, - "voice": true, - "debug": true - }, "parameters": { "type": "object", "properties": {} diff --git a/examples/car/tools/registry.mjs b/examples/car/tools/registry.mjs new file mode 100644 index 00000000..dbac2a16 --- /dev/null +++ b/examples/car/tools/registry.mjs @@ -0,0 +1,94 @@ +import { readFileSync } from 'node:fs' +import { executeFlashbuyTool } from './flashbuy/execute.mjs' +import { executeMusicTool } from './music/execute.mjs' +import { executeNavigationTool } from './navigation/execute.mjs' +import { executeVehicleTool } from './vehicle/execute.mjs' +import { executeWeatherTool } from './weather/execute.mjs' + +function loadManifest(name) { + return JSON.parse(readFileSync(new URL(`./${name}/manifest.json`, import.meta.url), 'utf8')) +} + +function definition(tool) { + return Object.freeze({ + name: tool.name, + title: tool.label, + description: tool.description, + inputSchema: tool.parameters, + annotations: { + readOnlyHint: [ + 'vehicle_state_query', + 'navigation_route_query', + 'music_search', + 'weather', + ].includes(tool.name), + openWorldHint: ['navigation_start', 'navigation_route_query', 'weather'].includes(tool.name), + destructiveHint: tool.name === 'flashbuy', + }, + }) +} + +function toolGroup(name, execute) { + const manifest = Object.freeze(loadManifest(name)) + if (manifest.domain !== name || !Array.isArray(manifest.functions)) { + throw new Error(`Invalid cockpit tool group manifest: ${name}`) + } + if (typeof execute !== 'function') { + throw new TypeError(`Cockpit tool group ${name} requires an executor`) + } + return Object.freeze({ + name, + manifest, + definitions: Object.freeze( + manifest.functions + .filter(tool => tool.enabled !== false) + .map(definition), + ), + execute, + }) +} + +const vehicle = toolGroup('vehicle', executeVehicleTool) +const navigation = toolGroup('navigation', executeNavigationTool) +const music = toolGroup('music', executeMusicTool) +const weather = toolGroup('weather', executeWeatherTool) +const flashbuy = toolGroup('flashbuy', executeFlashbuyTool) + +// This explicit composition is the scenario customization point. It keeps the +// MCP contract standard without introducing a dynamic plugin framework. +export const FRONTEND_TOOL_GROUPS = Object.freeze([weather]) +export const BACKEND_TOOL_GROUPS = Object.freeze([ + vehicle, + navigation, + music, + flashbuy, +]) +export const COCKPIT_TOOL_GROUPS = Object.freeze([ + ...FRONTEND_TOOL_GROUPS, + ...BACKEND_TOOL_GROUPS, +]) + +function definitions(groups) { + return Object.freeze(groups.flatMap(group => group.definitions)) +} + +export const FRONTEND_TOOL_DEFINITIONS = definitions(FRONTEND_TOOL_GROUPS) +export const BACKEND_TOOL_DEFINITIONS = definitions(BACKEND_TOOL_GROUPS) +export const COCKPIT_TOOL_DEFINITIONS = definitions(COCKPIT_TOOL_GROUPS) +export const COCKPIT_TOOL_NAMES = Object.freeze( + COCKPIT_TOOL_DEFINITIONS.map(tool => tool.name), +) + +if (new Set(COCKPIT_TOOL_NAMES).size !== COCKPIT_TOOL_NAMES.length) { + throw new Error('Cockpit tool names must be unique across groups') +} + +const EXECUTORS = new Map(COCKPIT_TOOL_GROUPS.flatMap(group => ( + group.definitions.map(tool => [tool.name, group.execute]) +))) + +export function executeCockpitTool(name, args, context) { + const execute = EXECUTORS.get(name) + if (!execute) throw new Error(`Unknown cockpit tool: ${name}`) + return execute(name, args, context) +} diff --git a/examples/car/tools/shared.mjs b/examples/car/tools/shared.mjs new file mode 100644 index 00000000..04679247 --- /dev/null +++ b/examples/car/tools/shared.mjs @@ -0,0 +1,21 @@ +export function clean(value) { + return String(value || '').trim() +} + +export function reportActivity(callback, category, status, message) { + callback?.({ + kind: 'status', + category, + status, + message, + }) +} + +export function toolResult(content, state, changed, data = {}) { + return { + content, + stateVersion: state.version, + changed, + data, + } +} diff --git a/examples/car/tools/vehicle/execute.mjs b/examples/car/tools/vehicle/execute.mjs new file mode 100644 index 00000000..38531e66 --- /dev/null +++ b/examples/car/tools/vehicle/execute.mjs @@ -0,0 +1,100 @@ +import { clean, toolResult } from '../shared.mjs' + +const WINDOW_PARTS = ['windowFL', 'windowFR', 'windowRL', 'windowRR'] +const VEHICLE_PARTS = [...WINDOW_PARTS, 'sunroof', 'headlights'] +const PART_LABELS = Object.freeze({ + windowFL: '主驾车窗', + windowFR: '副驾车窗', + windowRL: '左后车窗', + windowRR: '右后车窗', + windows: '全部车窗', + sunroof: '天窗', + headlights: '大灯', + ac: '空调', + all: '全部可控部件', +}) + +function stateText(part = 'all', vehicle) { + if (!part || part === 'all' || part === 'windows') { + return [ + ...VEHICLE_PARTS.map(key => `${PART_LABELS[key]}: ${vehicle[key] ? '开启' : '关闭'}`), + `空调: ${vehicle.ac ? '开启' : '关闭'},${vehicle.acMode === 'heat' ? '制热' : '制冷'},${vehicle.acTemp}°C,${vehicle.acFan}档`, + ].join(',') + } + if (part === 'ac') { + return `空调当前${vehicle.ac ? '开启' : '关闭'},${vehicle.acMode === 'heat' ? '制热' : '制冷'},${vehicle.acTemp}°C,${vehicle.acFan}档` + } + return `${PART_LABELS[part] || part}当前${vehicle[part] ? '开启' : '关闭'}` +} + +export function executeVehicleTool(name, args, context) { + const { cockpitId, snapshot, store } = context + if (name === 'vehicle_state_query') { + const state = snapshot() + return toolResult(stateText(args.part, state.vehicle), state, [], { + vehicle: state.vehicle, + }) + } + + if (name === 'vehicle_climate_control') { + const action = clean(args.action) + const current = snapshot() + if (action === 'set_temp') { + const temperature = Number(args.temperature) + if (!Number.isFinite(temperature) || temperature < 16 || temperature > 32) { + return toolResult('温度超出范围,空调温度需在 16~32°C 之间', current, []) + } + } + if (action === 'set_fan') { + const fan = Number(args.fan) + if (!Number.isInteger(fan) || fan < 1 || fan > 5) { + return toolResult('风量超出范围,需在 1~5 档之间', current, []) + } + } + if (action === 'set_mode' && !['cool', 'heat'].includes(args.mode)) { + return toolResult('请指定空调模式(cool 或 heat)', current, []) + } + const state = store.update(cockpitId, ['vehicle'], next => { + if (action === 'open') next.vehicle.ac = 1 + else if (action === 'close') next.vehicle.ac = 0 + else if (action === 'set_temp') { + next.vehicle.ac = 1 + next.vehicle.acTemp = Number(args.temperature) + } else if (action === 'set_mode') { + next.vehicle.ac = 1 + next.vehicle.acMode = args.mode + } else if (action === 'set_fan') { + next.vehicle.ac = 1 + next.vehicle.acFan = Number(args.fan) + } else { + throw new Error(`Unknown climate action: ${action}`) + } + }) + return toolResult(stateText('ac', state.vehicle), state, ['vehicle'], { + vehicle: state.vehicle, + }) + } + + const action = clean(args.action) + if (!['open', 'close'].includes(action)) throw new Error(`Unknown vehicle action: ${action}`) + const stateValue = action === 'open' ? 1 : 0 + let parts + if (name === 'vehicle_window_control') { + const window = clean(args.window) || 'windows' + parts = window === 'windows' ? WINDOW_PARTS : [window] + } else if (name === 'vehicle_sunroof_control') { + parts = ['sunroof'] + } else { + parts = ['headlights'] + } + if (parts.some(part => !VEHICLE_PARTS.includes(part))) throw new Error('Unknown vehicle part') + const state = store.update(cockpitId, ['vehicle'], next => { + for (const part of parts) next.vehicle[part] = stateValue + }) + const target = name === 'vehicle_window_control' + ? PART_LABELS[args.window || 'windows'] + : PART_LABELS[parts[0]] + return toolResult(`已${action === 'open' ? '打开' : '关闭'}${target}`, state, ['vehicle'], { + vehicle: state.vehicle, + }) +} diff --git a/examples/car/domain/domains/vehicle.json b/examples/car/tools/vehicle/manifest.json similarity index 57% rename from examples/car/domain/domains/vehicle.json rename to examples/car/tools/vehicle/manifest.json index f9e2377d..ec4bb119 100644 --- a/examples/car/domain/domains/vehicle.json +++ b/examples/car/tools/vehicle/manifest.json @@ -3,36 +3,28 @@ "label": "车控", "description": "座舱示例的车辆状态查询、车窗、天窗、大灯与空调控制能力。", "enabled": true, - "routeRules": [ - "用户查询车辆状态、车窗状态、天窗状态、大灯状态、空调状态 → 必须调用 vehicle_state_query", - "用户表达任何车窗开关相关意图 → 必须调用 vehicle_window_control", - "用户表达任何天窗开关相关意图 → 必须调用 vehicle_sunroof_control", - "用户表达任何大灯开关相关意图 → 必须调用 vehicle_headlights_control", - "用户表达任何空调、温度、风量、制冷/制热相关意图 → 必须调用 vehicle_climate_control", - "车控类 function 会在 executor 中完成状态读取、业务校验和车辆动作生成;禁止直接用文字描述车控行为而不调用对应 function" - ], - "examples": [ - "用户说“打开车窗” → 调用 vehicle_window_control(action=open, window=windows)", - "用户说“空调调到 23 度” → 调用 vehicle_climate_control(action=set_temp, temperature=23)" - ], "functions": [ { "name": "vehicle_state_query", "label": "车况查询", "description": "查询车窗、天窗、大灯、空调等车辆当前状态。", - "executor": "vehicle.state_query", "enabled": true, - "exposure": { - "text": true, - "voice": true, - "debug": true - }, "parameters": { "type": "object", "properties": { "part": { "type": "string", - "enum": ["windowFL", "windowFR", "windowRL", "windowRR", "windows", "sunroof", "headlights", "ac", "all"], + "enum": [ + "windowFL", + "windowFR", + "windowRL", + "windowRR", + "windows", + "sunroof", + "headlights", + "ac", + "all" + ], "description": "要查询的车辆部件。windows=全部车窗,all=全部车辆状态" } } @@ -42,93 +34,96 @@ "name": "vehicle_window_control", "label": "车窗控制", "description": "打开或关闭一个车窗,或一次打开/关闭全部车窗。", - "executor": "vehicle.window_control", "enabled": true, - "exposure": { - "text": true, - "voice": true, - "debug": true - }, "parameters": { "type": "object", "properties": { "action": { "type": "string", - "enum": ["open", "close"], + "enum": [ + "open", + "close" + ], "description": "open=打开车窗,close=关闭车窗" }, "window": { "type": "string", - "enum": ["windowFL", "windowFR", "windowRL", "windowRR", "windows"], + "enum": [ + "windowFL", + "windowFR", + "windowRL", + "windowRR", + "windows" + ], "description": "要控制的车窗。windows=全部车窗;不指定时默认全部车窗" } }, - "required": ["action"] + "required": [ + "action" + ] } }, { "name": "vehicle_sunroof_control", "label": "天窗控制", "description": "打开或关闭车辆天窗。", - "executor": "vehicle.sunroof_control", "enabled": true, - "exposure": { - "text": true, - "voice": true, - "debug": true - }, "parameters": { "type": "object", "properties": { "action": { "type": "string", - "enum": ["open", "close"], + "enum": [ + "open", + "close" + ], "description": "open=打开天窗,close=关闭天窗" } }, - "required": ["action"] + "required": [ + "action" + ] } }, { "name": "vehicle_headlights_control", "label": "大灯控制", "description": "打开或关闭车辆大灯。", - "executor": "vehicle.headlights_control", "enabled": true, - "exposure": { - "text": true, - "voice": true, - "debug": true - }, "parameters": { "type": "object", "properties": { "action": { "type": "string", - "enum": ["open", "close"], + "enum": [ + "open", + "close" + ], "description": "open=打开大灯,close=关闭大灯" } }, - "required": ["action"] + "required": [ + "action" + ] } }, { "name": "vehicle_climate_control", "label": "空调控制", "description": "打开或关闭空调,并设置温度、制冷/制热模式或风量。", - "executor": "vehicle.climate_control", "enabled": true, - "exposure": { - "text": true, - "voice": true, - "debug": true - }, "parameters": { "type": "object", "properties": { "action": { "type": "string", - "enum": ["open", "close", "set_temp", "set_mode", "set_fan"], + "enum": [ + "open", + "close", + "set_temp", + "set_mode", + "set_fan" + ], "description": "open=打开空调, close=关闭空调, set_temp=设置温度, set_mode=设置模式, set_fan=设置风量" }, "temperature": { @@ -137,7 +132,10 @@ }, "mode": { "type": "string", - "enum": ["cool", "heat"], + "enum": [ + "cool", + "heat" + ], "description": "空调模式,仅 set_mode 使用" }, "fan": { @@ -145,7 +143,9 @@ "description": "空调风量档位,范围 1~5,仅 set_fan 使用" } }, - "required": ["action"] + "required": [ + "action" + ] } } ] diff --git a/examples/car/tools/weather/execute.mjs b/examples/car/tools/weather/execute.mjs new file mode 100644 index 00000000..f9ae13ec --- /dev/null +++ b/examples/car/tools/weather/execute.mjs @@ -0,0 +1,39 @@ +import { clean, reportActivity, toolResult } from '../shared.mjs' + +function weatherText(data) { + if (!data) return '天气查询失败' + if (data.raw) return data.raw + const summary = [ + data.city || '当前城市', + data.dayweather || data.nightweather || '未知', + data.daytemp ? `${data.daytemp}°` : '', + data.nighttemp ? `夜间${data.nighttemp}°` : '', + data.daywind && data.daypower ? `${data.daywind}风${data.daypower}级` : '', + ].filter(Boolean).join(',') + const tips = [] + const weather = data.dayweather || data.nightweather || '' + const temperature = Number(data.daytemp || data.nighttemp) + if (/雨/u.test(weather)) tips.push('记得带伞') + if (Number.isFinite(temperature) && temperature <= 10) tips.push('注意保暖') + if (Number.isFinite(temperature) && temperature >= 30) tips.push('注意防晒补水') + return tips.length ? `${summary}。${tips.join(',')}` : summary +} + +export async function executeWeatherTool(_name, args, context) { + const { + cockpitId, + onActivity, + services, + snapshot, + store, + } = context + const city = clean(args.city) || '杭州' + reportActivity(onActivity, 'weather', 'weather_querying', '正在查询天气') + const weather = await services.weather(city) + if (!weather) return toolResult('天气查询失败', snapshot(), []) + const state = store.update(cockpitId, ['weather'], next => { + next.weather = structuredClone(weather) + }) + reportActivity(onActivity, 'weather', 'weather_ready', '天气已更新') + return toolResult(weatherText(weather), state, ['weather'], { weather: state.weather }) +} diff --git a/examples/car/domain/domains/weather.json b/examples/car/tools/weather/manifest.json similarity index 50% rename from examples/car/domain/domains/weather.json rename to examples/car/tools/weather/manifest.json index 16c09af1..dd2b818a 100644 --- a/examples/car/domain/domains/weather.json +++ b/examples/car/tools/weather/manifest.json @@ -3,28 +3,12 @@ "label": "天气", "description": "座舱示例的当前位置或指定城市天气、气温、风力和出行建议能力。", "enabled": true, - "routeRules": [ - "用户表达任何天气、气温、下雨、带伞、穿衣、冷不冷、热不热、风力相关意图 → 必须调用 weather", - "用户未指定城市时,默认查询杭州/当前车辆所在城市天气", - "禁止直接凭常识或静态状态栏内容回答天气" - ], - "examples": [ - "用户说“今天杭州天气怎么样” → 调用 weather(city=杭州)", - "用户说“外面冷不冷” → 调用 weather(city=杭州)", - "用户说“今天要带伞吗” → 调用 weather(city=杭州)" - ], "functions": [ { "name": "weather", "label": "天气", "description": "查询当前城市或指定城市天气、气温、下雨、风力和出行建议。用户未指定城市时默认杭州/当前车辆所在城市。", - "executor": "weather.query", "enabled": true, - "exposure": { - "text": true, - "voice": true, - "debug": true - }, "parameters": { "type": "object", "properties": { diff --git a/server/src/providers/mcp/frontend-mcp-config.mjs b/server/src/providers/mcp/frontend-mcp-config.mjs index d36e9051..dc9198ea 100644 --- a/server/src/providers/mcp/frontend-mcp-config.mjs +++ b/server/src/providers/mcp/frontend-mcp-config.mjs @@ -20,7 +20,7 @@ function clean(value, maxChars = 1_000) { return [...String(value || '').trim()].slice(0, maxChars).join('') } -function resolveSecret(value, env) { +function resolveEnvironmentReference(value, env) { const source = clean(value, 8_192) const match = ENV_REFERENCE.exec(source) if (!match) return source @@ -40,7 +40,7 @@ function normalizedHeaders(value, env) { if (entries.length > 16) throw new Error('Frontend MCP has too many headers.') return Object.fromEntries(entries.map(([name, content]) => { const header = clean(name, 80).toLowerCase() - const resolved = resolveSecret(content, env) + const resolved = resolveEnvironmentReference(content, env) if (!/^[a-z0-9-]+$/u.test(header) || /[\r\n]/u.test(resolved)) { throw new Error('Frontend MCP contains an invalid header.') } @@ -48,10 +48,10 @@ function normalizedHeaders(value, env) { }).filter(([, content]) => Boolean(content))) } -function normalizedUrl(value, { hasHeaders }) { +function normalizedUrl(value, { env, hasHeaders }) { let url try { - url = new URL(clean(value, 2_048)) + url = new URL(resolveEnvironmentReference(value, env)) } catch { throw new Error('Frontend MCP URL is invalid.') } @@ -132,6 +132,7 @@ function normalizedServer(key, value, env) { transport: { type: 'streamable-http', url: normalizedUrl(value.url, { + env, hasHeaders: Object.keys(headers).length > 0, }), headers, diff --git a/server/test/frontend-mcp-config.test.mjs b/server/test/frontend-mcp-config.test.mjs index ecd6dd6f..e0bb4147 100644 --- a/server/test/frontend-mcp-config.test.mjs +++ b/server/test/frontend-mcp-config.test.mjs @@ -64,6 +64,26 @@ test('normalizes explicit server and tool policies', () => { }) }) +test('resolves a complete MCP endpoint from the launching environment', () => { + const normalized = normalizeFrontendMcpConfiguration(configuration({ + servers: { + cockpit: { + enabled: true, + url: '${COCKPIT_MCP_URL}', + tools: { + weather: { enabled: true, readOnly: true }, + }, + }, + }, + }), { + env: { COCKPIT_MCP_URL: 'http://127.0.0.1:3010/mcp/frontend' }, + }) + assert.equal( + normalized.servers[0].transport.url, + 'http://127.0.0.1:3010/mcp/frontend', + ) +}) + test('loads and validates a versioned frontend MCP JSON file', () => { const directory = mkdtempSync(join(tmpdir(), 'qwen-audio-mcp-')) const filePath = join(directory, 'mcp.json')