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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 42 additions & 5 deletions docs/reference/frontend-mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,14 @@ DOCUMENT_MCP_AUTHORIZATION=Bearer replace-me
"servers": {
"documents": {
"enabled": true,
"url": "https://mcp.example.com/mcp",
"connectTimeoutMs": 8000,
"headers": {
"authorization": "${DOCUMENT_MCP_AUTHORIZATION}"
"transport": {
"type": "streamable-http",
"url": "https://mcp.example.com/mcp",
"headers": {
"authorization": "${DOCUMENT_MCP_AUTHORIZATION}"
}
},
"connectTimeoutMs": 8000,
"tools": {
"search": {
"enabled": true,
Expand All @@ -47,18 +50,52 @@ DOCUMENT_MCP_AUTHORIZATION=Bearer replace-me
}
```

Local MCP servers can use standard input and output:

```json
{
"version": 1,
"servers": {
"filesystem": {
"enabled": true,
"transport": {
"type": "stdio",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "${FILES_ROOT}"],
"env": {
"SERVICE_TOKEN": "${SERVICE_TOKEN}"
},
"cwd": "${MCP_WORKING_DIRECTORY}"
},
"tools": {
"list_directory": { "enabled": true }
}
}
}
}
```

For compatibility, top-level `url` and `headers` fields still select Streamable
HTTP. Top-level `command`, `args`, `env`, and `cwd` fields are also accepted as
stdio shorthand. New configurations should use the explicit `transport` object.

Each exposed tool receives a stable model-visible name:
`mcp__<server>__<tool>`. Tools omitted from `tools`, or without
`enabled: true`, are never exposed.

## Current policy

- Streamable HTTP is the initial transport.
- Streamable HTTP and stdio transports are supported. The legacy standalone SSE
transport is not supported.
- 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.
- The Gateway starts stdio servers directly without a shell and closes their child
processes when it shuts down. `command`, arguments, environment values, and
`cwd` may use exact environment references; a configured `cwd` must be absolute.
The child receives only the SDK's safe base environment plus explicit `env` values.
- `tools` is an explicit allowlist. Enabled tools execute inline in the current
conversation turn; the Gateway does not insert a generic confirmation turn
based on whether a tool reads or writes.
Expand Down
45 changes: 40 additions & 5 deletions docs/reference/frontend-mcp.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,14 @@ DOCUMENT_MCP_AUTHORIZATION=Bearer replace-me
"servers": {
"documents": {
"enabled": true,
"url": "https://mcp.example.com/mcp",
"connectTimeoutMs": 8000,
"headers": {
"authorization": "${DOCUMENT_MCP_AUTHORIZATION}"
"transport": {
"type": "streamable-http",
"url": "https://mcp.example.com/mcp",
"headers": {
"authorization": "${DOCUMENT_MCP_AUTHORIZATION}"
}
},
"connectTimeoutMs": 8000,
"tools": {
"search": {
"enabled": true,
Expand All @@ -45,17 +48,49 @@ DOCUMENT_MCP_AUTHORIZATION=Bearer replace-me
}
```

本地 MCP Server 可以使用标准输入输出:

```json
{
"version": 1,
"servers": {
"filesystem": {
"enabled": true,
"transport": {
"type": "stdio",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "${FILES_ROOT}"],
"env": {
"SERVICE_TOKEN": "${SERVICE_TOKEN}"
},
"cwd": "${MCP_WORKING_DIRECTORY}"
},
"tools": {
"list_directory": { "enabled": true }
}
}
}
}
```

为兼容已有配置,Server 顶层的 `url`、`headers` 仍表示 Streamable HTTP;
顶层的 `command`、`args`、`env`、`cwd` 也可作为 stdio 的简写。新配置推荐使用
显式 `transport` 对象。

每个公开工具会获得稳定的模型可见名称:
`mcp__<server>__<tool>`。未写入 `tools` 或未设置 `enabled: true`
的工具不会暴露。

## 当前策略

- 首个版本使用 Streamable HTTP Transport。
- 支持 Streamable HTTP 和 stdio Transport;不支持旧版独立 SSE Transport。
- 工具发现和连接有超时边界,默认 8 秒。
- 远端服务必须使用 HTTPS;回环地址可以使用 HTTP,但不能携带 Header。
- Server URL 可以用 `${MCP_URL}` 精确引用一个环境变量。
- Header 值可以用 `${VARIABLE}` 精确引用一个环境变量;变量缺失即配置错误。
- stdio Server 由 Gateway 直接启动,不经过 Shell;Gateway 关闭时会一并关闭子进程。
- stdio 的 `command`、参数、环境变量值和 `cwd` 可以精确引用环境变量;`cwd`
如果填写,必须是绝对路径。子进程只继承 SDK 的安全基础环境和显式配置的 `env`。
- `tools` 是显式白名单;启用的工具由 Gateway 在当前对话轮次内直接调用,不再根据
读写类型插入一轮通用确认。
- `readOnlyHint`、`destructiveHint` 等行为信息由 MCP Server 按标准 Tool Annotations
Expand Down
65 changes: 48 additions & 17 deletions examples/smart-cockpit/client/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,11 @@ import FlashBuyPanel from './components/FlashBuyPanel'
import useCockpitState from './hooks/useCockpitState'
import useCockpitSkills from './hooks/useCockpitSkills'
import useVoiceSession from './hooks/useVoiceSession'
import { finalUserTranscript } from './projections/voice-transcript'
import {
finalUserTranscript,
voiceConversationMessageId,
voiceEventBelongsToTurn,
} from './projections/voice-transcript'
import { cockpitScreenForProgress } from './projections/cockpit-activity'
import {
COCKPIT_VOICE_IDS,
Expand Down Expand Up @@ -120,6 +124,7 @@ export default function App() {
const weatherState = cockpitState?.weather || INITIAL_WEATHER_STATE
const [voiceMuted, setVoiceMuted] = useState(true)
const voiceAssistantMessageIdRef = useRef(null)
const voiceTurnIdRef = useRef('')

const runCockpitCommand = useCallback((name, args = {}) => {
executeCockpitCommand(name, args).catch(error => {
Expand Down Expand Up @@ -197,16 +202,20 @@ export default function App() {
setShowChat(prev => !prev)
}, [])

const toggleVoiceMute = useCallback(() => {
setVoiceMuted(prev => !prev)
}, [])

const handleVoiceMessage = useCallback((event) => {
if (!event) return

const updateAssistantMessage = (updater) => {
const id = voiceAssistantMessageIdRef.current || crypto.randomUUID()
voiceAssistantMessageIdRef.current = id
const id = voiceConversationMessageId(
event,
voiceAssistantMessageIdRef.current || crypto.randomUUID(),
)
if (
event.final !== true
&& voiceEventBelongsToTurn(event, voiceTurnIdRef.current)
) {
voiceAssistantMessageIdRef.current = id
}
setChatMessages(prev => {
const next = [...prev]
let index = next.findIndex(msg => msg.id === id)
Expand All @@ -217,6 +226,7 @@ export default function App() {
next[index] = updater(next[index])
return next.slice(-80)
})
return id
}

if (event.thinkingDelta) {
Expand Down Expand Up @@ -272,13 +282,17 @@ export default function App() {
voiceAssistantMessageIdRef.current = null
const content = finalUserTranscript(event)
if (!content) return
voiceTurnIdRef.current = String(event.turnId || '').trim()
const id = voiceConversationMessageId(event, crypto.randomUUID())
setChatMessages(prev => {
const last = prev.at(-1)
if (last?.role === 'user' && last.content === content) return prev
return [
...prev,
{ id: crypto.randomUUID(), role: 'user', content },
].slice(-80)
const next = [...prev]
const index = next.findIndex(message => message.id === id)
if (index >= 0) {
next[index] = { ...next[index], role: 'user', content }
} else {
next.push({ id, role: 'user', content })
}
return next.slice(-80)
})
return
}
Expand All @@ -292,17 +306,26 @@ export default function App() {
}

if (event.final) {
updateAssistantMessage(msg => ({ ...msg, content: event.content || msg.content }))
voiceAssistantMessageIdRef.current = null
const id = updateAssistantMessage(msg => ({
...msg,
content: event.content || msg.content,
}))
if (voiceAssistantMessageIdRef.current === id) {
voiceAssistantMessageIdRef.current = null
}
}
}, [])

const handleConversationRecovery = useCallback((messages) => {
voiceAssistantMessageIdRef.current = null
setChatMessages((Array.isArray(messages) ? messages : []).map(message => ({
const recovered = (Array.isArray(messages) ? messages : []).map(message => ({
...message,
id: message.id || crypto.randomUUID(),
})).slice(-10))
})).slice(-10)
voiceTurnIdRef.current = [...recovered]
.reverse()
.find(message => message.role === 'user')?.turnId || ''
setChatMessages(recovered)
}, [])

const {
Expand All @@ -311,6 +334,7 @@ export default function App() {
outputLevel,
progress: voiceProgress,
error: voiceError,
activateVoice,
sendInput,
} = useVoiceSession({
muted: voiceMuted,
Expand All @@ -320,6 +344,13 @@ export default function App() {
onVoiceMessage: handleVoiceMessage,
onConversationRecovery: handleConversationRecovery,
})
const toggleVoiceMute = useCallback(() => {
if (!voiceMuted) {
setVoiceMuted(true)
return
}
if (activateVoice()) setVoiceMuted(false)
}, [activateVoice, voiceMuted])
const visualProgress = cockpitProgress || voiceProgress

const handleTextMessage = useCallback((text) => (
Expand Down
25 changes: 25 additions & 0 deletions examples/smart-cockpit/client/src/audio/activation.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
export function activateAudioContext({ current, AudioContextClass } = {}) {
if (!AudioContextClass) {
throw new Error('当前浏览器不支持实时语音播放')
}

const context = current?.state === 'closed'
? new AudioContextClass()
: current || new AudioContextClass()
// `resume()` must be called synchronously from the user's click stack.
// Deferring it to a React effect loses browser user activation and can leave
// the promise pending forever, while the UI misleadingly appears unmuted.
const resumed = context.state === 'running'
? Promise.resolve()
: context.resume()

return {
context,
ready: Promise.resolve(resumed).then(() => {
if (context.state !== 'running') {
throw new Error('浏览器未允许启用语音,请再次点击麦克风')
}
return context
}),
}
}
Loading