@opentiny/next-sdk 0.2.9 → 0.3.0-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +69 -53
- package/agent/AgentModelProvider.ts +77 -25
- package/agent/type.ts +3 -3
- package/core.ts +28 -0
- package/dist/AgentModelProvider-BIOOEdcN.js +4068 -0
- package/dist/agent/AgentModelProvider.d.ts +2 -0
- package/dist/agent/type.d.ts +3 -0
- package/dist/core.d.ts +24 -0
- package/dist/core.js +35 -0
- package/dist/index.es.dev.js +1475 -240
- package/dist/index.es.js +17007 -15999
- package/dist/index.js +894 -4475
- package/dist/index.umd.dev.js +1475 -240
- package/dist/index.umd.js +57 -58
- package/dist/page-tools/bridge.d.ts +94 -7
- package/dist/remoter/createRemoter.d.ts +3 -2
- package/dist/transport/ExtensionPageServerTransport.d.ts +0 -1
- package/dist/webagent.dev.js +1467 -789
- package/dist/webagent.es.dev.js +1467 -789
- package/dist/webagent.es.js +13644 -13046
- package/dist/webagent.js +58 -59
- package/dist/webmcp-full.dev.js +0 -12
- package/dist/webmcp-full.es.dev.js +0 -12
- package/dist/webmcp-full.es.js +1050 -1058
- package/dist/webmcp-full.js +9 -9
- package/dist/webmcp.dev.js +0 -12
- package/dist/webmcp.es.dev.js +0 -12
- package/dist/webmcp.es.js +78 -86
- package/dist/webmcp.js +1 -1
- package/package.json +11 -1
- package/page-tools/bridge.ts +907 -102
- package/remoter/createRemoter.ts +44 -23
- package/transport/ExtensionClientTransport.ts +15 -5
- package/transport/ExtensionContentServerTransport.ts +4 -15
- package/transport/ExtensionPageServerTransport.ts +3 -13
- package/vite-build-tsc.ts +5 -2
package/page-tools/bridge.ts
CHANGED
|
@@ -8,11 +8,12 @@
|
|
|
8
8
|
* - setNavigator(fn) 在应用入口注册导航函数
|
|
9
9
|
* - withPageTools(server)
|
|
10
10
|
* 包装 WebMcpServer,让 registerTool 第三个参数
|
|
11
|
-
* 同时支持原始回调函数和路由配置对象(RouteConfig
|
|
11
|
+
* 同时支持原始回调函数和路由配置对象(RouteConfig),
|
|
12
|
+
* 并提供 server.unregisterTool / 工具状态查询能力
|
|
12
13
|
* - registerPageTool() 在目标页面激活工具处理器,返回 cleanup 函数
|
|
13
14
|
*/
|
|
14
15
|
|
|
15
|
-
import type { ZodRawShape } from 'zod'
|
|
16
|
+
import type { ZodRawShape, ZodTypeAny } from 'zod'
|
|
16
17
|
import { z } from 'zod'
|
|
17
18
|
import type { RegisteredTool } from '@modelcontextprotocol/sdk/server/mcp.js'
|
|
18
19
|
import type { ToolAnnotations } from '@modelcontextprotocol/sdk/types.js'
|
|
@@ -25,15 +26,17 @@ import { hideToolInvokeEffect, resolveRuntimeEffectConfig, showToolInvokeEffect
|
|
|
25
26
|
const MSG_TOOL_CALL = 'next-sdk:tool-call'
|
|
26
27
|
const MSG_TOOL_RESPONSE = 'next-sdk:tool-response'
|
|
27
28
|
const MSG_PAGE_READY = 'next-sdk:page-ready'
|
|
28
|
-
/**
|
|
29
|
+
/** 页面卸载广播消息 */
|
|
29
30
|
export const MSG_PAGE_LEAVE = 'next-sdk:page-leave'
|
|
30
31
|
/** iframe 内 Remoter 就绪后向父窗口发送,父窗口回传 route-state-initial */
|
|
31
32
|
export const MSG_REMOTER_READY = 'next-sdk:remoter-ready'
|
|
32
|
-
/**
|
|
33
|
+
/** 历史兼容消息类型(当前简化方案不再使用) */
|
|
33
34
|
export const MSG_ROUTE_STATE_INITIAL = 'next-sdk:route-state-initial'
|
|
35
|
+
/** 工具目录发生变更(新增/删除/路由重绑定) */
|
|
36
|
+
export const MSG_TOOL_CATALOG_CHANGED = 'next-sdk:tool-catalog-changed'
|
|
34
37
|
|
|
35
|
-
// 已激活页面注册表:路由路径 →
|
|
36
|
-
const activePages = new Map<string,
|
|
38
|
+
// 已激活页面注册表:路由路径 → 当前页面已挂载的工具名集合
|
|
39
|
+
const activePages = new Map<string, Set<string>>()
|
|
37
40
|
|
|
38
41
|
// 路由路径规范化:去除尾部斜杠,空路径兜底为 '/'
|
|
39
42
|
const normalizeRoute = (value: string) => value.replace(/\/+$/, '') || '/'
|
|
@@ -51,8 +54,8 @@ function initBroadcastTargets() {
|
|
|
51
54
|
initBroadcastTargets()
|
|
52
55
|
|
|
53
56
|
/** 向所有广播目标发送路由变更消息(同窗口 + iframe 均能收到) */
|
|
54
|
-
function broadcastRouteChange(type: string, route: string) {
|
|
55
|
-
const msg = { type, route }
|
|
57
|
+
function broadcastRouteChange(type: string, route: string, extra: Record<string, unknown> = {}) {
|
|
58
|
+
const msg = { type, route, ...extra }
|
|
56
59
|
broadcastTargets.forEach(({ win, origin }) => {
|
|
57
60
|
try {
|
|
58
61
|
win.postMessage(msg, origin)
|
|
@@ -62,7 +65,7 @@ function broadcastRouteChange(type: string, route: string) {
|
|
|
62
65
|
})
|
|
63
66
|
}
|
|
64
67
|
|
|
65
|
-
/** 监听 iframe 内 Remoter 的 remoter-ready
|
|
68
|
+
/** 监听 iframe 内 Remoter 的 remoter-ready,并加入广播目标 */
|
|
66
69
|
function setupIframeRemoterBridge() {
|
|
67
70
|
if (typeof window === 'undefined') return
|
|
68
71
|
window.addEventListener('message', (event: MessageEvent) => {
|
|
@@ -71,30 +74,42 @@ function setupIframeRemoterBridge() {
|
|
|
71
74
|
if (event.origin !== window.location.origin) return
|
|
72
75
|
const target = event.source as Window
|
|
73
76
|
broadcastTargets.add({ win: target, origin: event.origin || '*' })
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
setupIframeRemoterBridge()
|
|
80
|
+
|
|
81
|
+
// runtime 一体化注册的工具(用于引用计数管理)
|
|
82
|
+
const runtimeRegisteredTools = new Map<string, { tool: RegisteredTool; route: string; refCount: number }>()
|
|
83
|
+
|
|
84
|
+
function broadcastToolCatalogChanged() {
|
|
85
|
+
if (typeof window === 'undefined') return
|
|
86
|
+
const payload = {
|
|
87
|
+
type: MSG_TOOL_CATALOG_CHANGED
|
|
88
|
+
}
|
|
89
|
+
broadcastTargets.forEach(({ win, origin }) => {
|
|
79
90
|
try {
|
|
80
|
-
|
|
91
|
+
win.postMessage(payload, origin)
|
|
81
92
|
} catch {
|
|
82
|
-
//
|
|
93
|
+
// ignore
|
|
83
94
|
}
|
|
84
95
|
})
|
|
85
96
|
}
|
|
86
|
-
setupIframeRemoterBridge()
|
|
87
97
|
|
|
88
|
-
|
|
89
|
-
const
|
|
98
|
+
function notifyServerToolListChanged(server: unknown) {
|
|
99
|
+
const maybeServer = server as { sendToolListChanged?: () => void }
|
|
100
|
+
try {
|
|
101
|
+
maybeServer.sendToolListChanged?.()
|
|
102
|
+
} catch {
|
|
103
|
+
// ignore
|
|
104
|
+
}
|
|
105
|
+
}
|
|
90
106
|
|
|
91
107
|
/**
|
|
92
108
|
* 获取通过 withPageTools + RouteConfig 注册的全部工具路由映射。
|
|
93
|
-
*
|
|
94
|
-
* @returns toolName → route 的只读 Map
|
|
109
|
+
* 为保持向后兼容,仍保留该 API;简化模式下不再维护此映射,始终返回空 Map。
|
|
95
110
|
*/
|
|
96
111
|
export function getToolRouteMap(): ReadonlyMap<string, string> {
|
|
97
|
-
return new Map(
|
|
112
|
+
return new Map()
|
|
98
113
|
}
|
|
99
114
|
|
|
100
115
|
/**
|
|
@@ -106,6 +121,476 @@ export function getActiveRoutes(): Set<string> {
|
|
|
106
121
|
return new Set(activePages.keys())
|
|
107
122
|
}
|
|
108
123
|
|
|
124
|
+
/**
|
|
125
|
+
* 获取当前已激活页面上的工具清单快照。
|
|
126
|
+
* key 为 route,value 为该页面当前可执行的工具名数组。
|
|
127
|
+
*/
|
|
128
|
+
export function getActivePageTools(): ReadonlyMap<string, string[]> {
|
|
129
|
+
const snapshot = new Map<string, string[]>()
|
|
130
|
+
activePages.forEach((toolNames, route) => {
|
|
131
|
+
snapshot.set(route, Array.from(toolNames))
|
|
132
|
+
})
|
|
133
|
+
return snapshot
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function isToolReadyOnRoute(route: string, toolName: string): boolean {
|
|
137
|
+
const toolNames = activePages.get(route)
|
|
138
|
+
return !!toolNames && toolNames.has(toolName)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
type JsonSchema = {
|
|
142
|
+
type?: 'string' | 'number' | 'integer' | 'boolean' | 'object' | 'array' | 'null'
|
|
143
|
+
description?: string
|
|
144
|
+
properties?: Record<string, JsonSchema>
|
|
145
|
+
required?: string[]
|
|
146
|
+
items?: JsonSchema
|
|
147
|
+
enum?: Array<string | number | boolean | null>
|
|
148
|
+
const?: string | number | boolean | null
|
|
149
|
+
anyOf?: JsonSchema[]
|
|
150
|
+
additionalProperties?: boolean
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
type BrowserBuiltinModelContextTool = {
|
|
154
|
+
name: string
|
|
155
|
+
description?: string
|
|
156
|
+
inputSchema?: JsonSchema
|
|
157
|
+
execute: (params: Record<string, unknown>) => unknown | Promise<unknown>
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
type BrowserBuiltinModelContext = {
|
|
161
|
+
registerTool: (tool: BrowserBuiltinModelContextTool) => unknown | Promise<unknown>
|
|
162
|
+
unregisterTool?: (arg: unknown) => unknown | Promise<unknown>
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
type BrowserBuiltinModelContextTesting = {
|
|
166
|
+
listTools?: () => unknown[] | Promise<unknown[]>
|
|
167
|
+
getTools?: () => unknown[] | Promise<unknown[]>
|
|
168
|
+
provideContext?: (init: unknown) => unknown | Promise<unknown>
|
|
169
|
+
clearContext?: () => unknown | Promise<unknown>
|
|
170
|
+
executeTool?: (name: string, input: string) => unknown | Promise<unknown>
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
type NavigatorWithBuiltinMcp = Navigator & {
|
|
174
|
+
modelContext?: BrowserBuiltinModelContext
|
|
175
|
+
modelContextTesting?: BrowserBuiltinModelContextTesting
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const nativeRegisteredTools = new Set<string>()
|
|
179
|
+
const nativeToolDisposers = new Map<string, () => unknown | Promise<unknown>>()
|
|
180
|
+
const nativeRegisteredToolDefs = new Map<string, BrowserBuiltinModelContextTool>()
|
|
181
|
+
const nativeRegisterTasks = new Map<string, Promise<void>>()
|
|
182
|
+
const BUILTIN_REMOVE_PATCH_SYMBOL = Symbol('builtin-remove-patched')
|
|
183
|
+
|
|
184
|
+
function attachBuiltinUnregisterOnRemove(name: string, tool: RegisteredTool): RegisteredTool {
|
|
185
|
+
const mutableTool = tool as RegisteredTool & {
|
|
186
|
+
remove?: () => void
|
|
187
|
+
[BUILTIN_REMOVE_PATCH_SYMBOL]?: boolean
|
|
188
|
+
}
|
|
189
|
+
if (mutableTool[BUILTIN_REMOVE_PATCH_SYMBOL]) return tool
|
|
190
|
+
if (typeof mutableTool.remove !== 'function') return tool
|
|
191
|
+
|
|
192
|
+
const originalRemove = mutableTool.remove.bind(mutableTool)
|
|
193
|
+
mutableTool.remove = () => {
|
|
194
|
+
try {
|
|
195
|
+
originalRemove()
|
|
196
|
+
} finally {
|
|
197
|
+
void unregisterBuiltinWebMcpTool(name)
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
mutableTool[BUILTIN_REMOVE_PATCH_SYMBOL] = true
|
|
201
|
+
return mutableTool
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function getBuiltinModelContext(): BrowserBuiltinModelContext | null {
|
|
205
|
+
if (typeof navigator === 'undefined') return null
|
|
206
|
+
const nav = navigator as NavigatorWithBuiltinMcp
|
|
207
|
+
if (!nav.modelContext?.registerTool) return null
|
|
208
|
+
return nav.modelContext
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function getBuiltinModelContextTesting(): BrowserBuiltinModelContextTesting | null {
|
|
212
|
+
if (typeof navigator === 'undefined') return null
|
|
213
|
+
const nav = navigator as NavigatorWithBuiltinMcp
|
|
214
|
+
return nav.modelContextTesting ?? null
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function isWebMcpDebugEnabled(): boolean {
|
|
218
|
+
if (typeof window === 'undefined') return false
|
|
219
|
+
const w = window as Window & { __NEXT_SDK_WEBMCP_DEBUG__?: boolean }
|
|
220
|
+
if (w.__NEXT_SDK_WEBMCP_DEBUG__ === true) return true
|
|
221
|
+
try {
|
|
222
|
+
return window.localStorage?.getItem('next-sdk:webmcp-debug') === '1'
|
|
223
|
+
} catch {
|
|
224
|
+
return false
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function debugWebMcpLog(event: string, payload: Record<string, unknown> = {}) {
|
|
229
|
+
if (!isWebMcpDebugEnabled()) return
|
|
230
|
+
try {
|
|
231
|
+
console.info('[next-sdk/webmcp]', event, payload)
|
|
232
|
+
} catch {
|
|
233
|
+
// ignore
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async function debugBuiltinToolSnapshot(event: string) {
|
|
238
|
+
if (!isWebMcpDebugEnabled()) return
|
|
239
|
+
const testingApi = getBuiltinModelContextTesting()
|
|
240
|
+
if (!testingApi) {
|
|
241
|
+
debugWebMcpLog(`${event}:snapshot`, { available: false })
|
|
242
|
+
return
|
|
243
|
+
}
|
|
244
|
+
try {
|
|
245
|
+
const list = testingApi.listTools ?? testingApi.getTools
|
|
246
|
+
if (!list) {
|
|
247
|
+
debugWebMcpLog(`${event}:snapshot`, { available: false, reason: 'no-list-method' })
|
|
248
|
+
return
|
|
249
|
+
}
|
|
250
|
+
const result = await list()
|
|
251
|
+
const tools = Array.isArray(result) ? result : []
|
|
252
|
+
const names = tools
|
|
253
|
+
.map((item) => {
|
|
254
|
+
if (!item || typeof item !== 'object') return ''
|
|
255
|
+
return String((item as { name?: unknown }).name ?? '')
|
|
256
|
+
})
|
|
257
|
+
.filter(Boolean)
|
|
258
|
+
debugWebMcpLog(`${event}:snapshot`, { count: names.length, names })
|
|
259
|
+
} catch (error) {
|
|
260
|
+
debugWebMcpLog(`${event}:snapshot-error`, { error: error instanceof Error ? error.message : String(error) })
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function tryDirectBuiltinUnregisterByName(name: string) {
|
|
265
|
+
const modelContext = getBuiltinModelContext()
|
|
266
|
+
if (!modelContext?.unregisterTool) {
|
|
267
|
+
debugWebMcpLog('direct-unregister-skip', { name, reason: 'missing-unregister' })
|
|
268
|
+
return
|
|
269
|
+
}
|
|
270
|
+
debugWebMcpLog('direct-unregister-start', { name })
|
|
271
|
+
try {
|
|
272
|
+
const result = modelContext.unregisterTool.call(modelContext, name)
|
|
273
|
+
if (result && typeof result === 'object' && 'then' in result) {
|
|
274
|
+
void (result as Promise<unknown>)
|
|
275
|
+
.then(() => {
|
|
276
|
+
debugWebMcpLog('direct-unregister-done', { name, async: true })
|
|
277
|
+
void debugBuiltinToolSnapshot(`direct-unregister-done:${name}`)
|
|
278
|
+
})
|
|
279
|
+
.catch((error) => {
|
|
280
|
+
debugWebMcpLog('direct-unregister-error', { name, async: true, error: error instanceof Error ? error.message : String(error) })
|
|
281
|
+
})
|
|
282
|
+
return
|
|
283
|
+
}
|
|
284
|
+
debugWebMcpLog('direct-unregister-done', { name, async: false })
|
|
285
|
+
void debugBuiltinToolSnapshot(`direct-unregister-done:${name}`)
|
|
286
|
+
} catch {
|
|
287
|
+
debugWebMcpLog('direct-unregister-error', { name, async: false })
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function resolveBuiltinToolDisposer(result: unknown): (() => unknown | Promise<unknown>) | null {
|
|
292
|
+
if (typeof result === 'function') {
|
|
293
|
+
return result as () => unknown | Promise<unknown>
|
|
294
|
+
}
|
|
295
|
+
if (!result || typeof result !== 'object') {
|
|
296
|
+
return null
|
|
297
|
+
}
|
|
298
|
+
const value = result as {
|
|
299
|
+
unregister?: () => unknown | Promise<unknown>
|
|
300
|
+
remove?: () => unknown | Promise<unknown>
|
|
301
|
+
dispose?: () => unknown | Promise<unknown>
|
|
302
|
+
close?: () => unknown | Promise<unknown>
|
|
303
|
+
}
|
|
304
|
+
if (typeof value.unregister === 'function') return value.unregister.bind(value)
|
|
305
|
+
if (typeof value.remove === 'function') return value.remove.bind(value)
|
|
306
|
+
if (typeof value.dispose === 'function') return value.dispose.bind(value)
|
|
307
|
+
if (typeof value.close === 'function') return value.close.bind(value)
|
|
308
|
+
return null
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export function isBuiltinWebMcpSupported(): boolean {
|
|
312
|
+
return !!getBuiltinModelContext()
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function getSchemaTypeName(schema: ZodTypeAny): string | undefined {
|
|
316
|
+
return (schema as { _def?: { typeName?: string } })._def?.typeName
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function getSchemaDescription(schema: ZodTypeAny): string | undefined {
|
|
320
|
+
return (schema as { description?: string }).description
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function withSchemaDescription(schema: ZodTypeAny, base: JsonSchema): JsonSchema {
|
|
324
|
+
const description = getSchemaDescription(schema)
|
|
325
|
+
return description ? { ...base, description } : base
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function isOptionalSchema(schema: ZodTypeAny): boolean {
|
|
329
|
+
const typeName = getSchemaTypeName(schema)
|
|
330
|
+
if (typeName === z.ZodFirstPartyTypeKind.ZodOptional || typeName === z.ZodFirstPartyTypeKind.ZodDefault) {
|
|
331
|
+
return true
|
|
332
|
+
}
|
|
333
|
+
if (typeName === z.ZodFirstPartyTypeKind.ZodEffects) {
|
|
334
|
+
const inner = (schema as { _def: { schema: ZodTypeAny } })._def.schema
|
|
335
|
+
return isOptionalSchema(inner)
|
|
336
|
+
}
|
|
337
|
+
return false
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function toPrimitiveJsonType(value: unknown): JsonSchema['type'] {
|
|
341
|
+
if (typeof value === 'string') return 'string'
|
|
342
|
+
if (typeof value === 'number') return 'number'
|
|
343
|
+
if (typeof value === 'boolean') return 'boolean'
|
|
344
|
+
if (value === null) return 'null'
|
|
345
|
+
return undefined
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function zodTypeToJsonSchema(schema: ZodTypeAny): JsonSchema {
|
|
349
|
+
const typeName = getSchemaTypeName(schema)
|
|
350
|
+
|
|
351
|
+
switch (typeName) {
|
|
352
|
+
case z.ZodFirstPartyTypeKind.ZodString:
|
|
353
|
+
return withSchemaDescription(schema, { type: 'string' })
|
|
354
|
+
case z.ZodFirstPartyTypeKind.ZodNumber:
|
|
355
|
+
return withSchemaDescription(schema, { type: 'number' })
|
|
356
|
+
case z.ZodFirstPartyTypeKind.ZodBoolean:
|
|
357
|
+
return withSchemaDescription(schema, { type: 'boolean' })
|
|
358
|
+
case z.ZodFirstPartyTypeKind.ZodArray: {
|
|
359
|
+
const itemSchema = (schema as { _def: { type: ZodTypeAny } })._def.type
|
|
360
|
+
return withSchemaDescription(schema, { type: 'array', items: zodTypeToJsonSchema(itemSchema) })
|
|
361
|
+
}
|
|
362
|
+
case z.ZodFirstPartyTypeKind.ZodEnum: {
|
|
363
|
+
const values = (schema as unknown as { options: string[] }).options ?? []
|
|
364
|
+
return withSchemaDescription(schema, { type: 'string', enum: values })
|
|
365
|
+
}
|
|
366
|
+
case z.ZodFirstPartyTypeKind.ZodNativeEnum: {
|
|
367
|
+
const rawValues = Object.values((schema as { _def: { values: Record<string, unknown> } })._def.values)
|
|
368
|
+
const enumValues = rawValues.filter(
|
|
369
|
+
(value): value is string | number => typeof value === 'string' || typeof value === 'number'
|
|
370
|
+
)
|
|
371
|
+
return withSchemaDescription(schema, { enum: enumValues })
|
|
372
|
+
}
|
|
373
|
+
case z.ZodFirstPartyTypeKind.ZodLiteral: {
|
|
374
|
+
const literalValue = (schema as { _def: { value: unknown } })._def.value
|
|
375
|
+
const primitiveType = toPrimitiveJsonType(literalValue)
|
|
376
|
+
return withSchemaDescription(schema, {
|
|
377
|
+
...(primitiveType ? { type: primitiveType } : {}),
|
|
378
|
+
const: (literalValue as string | number | boolean | null) ?? null
|
|
379
|
+
})
|
|
380
|
+
}
|
|
381
|
+
case z.ZodFirstPartyTypeKind.ZodUnion: {
|
|
382
|
+
const options = (schema as { _def: { options: ZodTypeAny[] } })._def.options ?? []
|
|
383
|
+
return withSchemaDescription(schema, { anyOf: options.map((item) => zodTypeToJsonSchema(item)) })
|
|
384
|
+
}
|
|
385
|
+
case z.ZodFirstPartyTypeKind.ZodNullable: {
|
|
386
|
+
const inner = (schema as { _def: { innerType: ZodTypeAny } })._def.innerType
|
|
387
|
+
return withSchemaDescription(schema, { anyOf: [zodTypeToJsonSchema(inner), { type: 'null' }] })
|
|
388
|
+
}
|
|
389
|
+
case z.ZodFirstPartyTypeKind.ZodObject: {
|
|
390
|
+
const schemaDef = schema as { shape?: ZodRawShape; _def?: { shape?: ZodRawShape | (() => ZodRawShape) } }
|
|
391
|
+
const shape =
|
|
392
|
+
schemaDef.shape ??
|
|
393
|
+
(typeof schemaDef._def?.shape === 'function' ? schemaDef._def.shape() : schemaDef._def?.shape) ??
|
|
394
|
+
{}
|
|
395
|
+
return withSchemaDescription(schema, zodShapeToJsonSchema(shape))
|
|
396
|
+
}
|
|
397
|
+
case z.ZodFirstPartyTypeKind.ZodEffects: {
|
|
398
|
+
const inner = (schema as { _def: { schema: ZodTypeAny } })._def.schema
|
|
399
|
+
return withSchemaDescription(schema, zodTypeToJsonSchema(inner))
|
|
400
|
+
}
|
|
401
|
+
case z.ZodFirstPartyTypeKind.ZodOptional:
|
|
402
|
+
case z.ZodFirstPartyTypeKind.ZodDefault: {
|
|
403
|
+
const inner = (schema as { _def: { innerType: ZodTypeAny } })._def.innerType
|
|
404
|
+
return withSchemaDescription(schema, zodTypeToJsonSchema(inner))
|
|
405
|
+
}
|
|
406
|
+
default:
|
|
407
|
+
return withSchemaDescription(schema, {})
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function zodShapeToJsonSchema(shape: ZodRawShape = {}): JsonSchema {
|
|
412
|
+
const properties: Record<string, JsonSchema> = {}
|
|
413
|
+
const required: string[] = []
|
|
414
|
+
|
|
415
|
+
Object.entries(shape).forEach(([key, schema]) => {
|
|
416
|
+
properties[key] = zodTypeToJsonSchema(schema as ZodTypeAny)
|
|
417
|
+
if (!isOptionalSchema(schema as ZodTypeAny)) {
|
|
418
|
+
required.push(key)
|
|
419
|
+
}
|
|
420
|
+
})
|
|
421
|
+
|
|
422
|
+
return {
|
|
423
|
+
type: 'object',
|
|
424
|
+
properties,
|
|
425
|
+
...(required.length ? { required } : {}),
|
|
426
|
+
additionalProperties: false
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
export async function registerBuiltinWebMcpTool(options: {
|
|
431
|
+
name: string
|
|
432
|
+
description?: string
|
|
433
|
+
inputSchema?: ZodRawShape
|
|
434
|
+
execute: (params: Record<string, unknown>) => unknown | Promise<unknown>
|
|
435
|
+
}): Promise<boolean> {
|
|
436
|
+
const modelContext = getBuiltinModelContext()
|
|
437
|
+
if (!modelContext) {
|
|
438
|
+
debugWebMcpLog('register-builtin-skip', { name: options.name, reason: 'unsupported' })
|
|
439
|
+
return false
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
if (nativeRegisteredTools.has(options.name)) {
|
|
443
|
+
debugWebMcpLog('register-builtin-skip', { name: options.name, reason: 'already-registered' })
|
|
444
|
+
return true
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
const cleanupLocalRegisterState = () => {
|
|
448
|
+
nativeToolDisposers.delete(options.name)
|
|
449
|
+
nativeRegisteredToolDefs.delete(options.name)
|
|
450
|
+
nativeRegisteredTools.delete(options.name)
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
debugWebMcpLog('register-builtin-start', { name: options.name })
|
|
454
|
+
const task = (async () => {
|
|
455
|
+
const toolDefinition: BrowserBuiltinModelContextTool = {
|
|
456
|
+
name: options.name,
|
|
457
|
+
description: options.description,
|
|
458
|
+
inputSchema: zodShapeToJsonSchema(options.inputSchema ?? {}),
|
|
459
|
+
execute: options.execute
|
|
460
|
+
}
|
|
461
|
+
const result = await modelContext.registerTool(toolDefinition)
|
|
462
|
+
const disposer = resolveBuiltinToolDisposer(result)
|
|
463
|
+
if (disposer) {
|
|
464
|
+
nativeToolDisposers.set(options.name, disposer)
|
|
465
|
+
}
|
|
466
|
+
nativeRegisteredToolDefs.set(options.name, toolDefinition)
|
|
467
|
+
nativeRegisteredTools.add(options.name)
|
|
468
|
+
debugWebMcpLog('register-builtin-success', { name: options.name, hasDisposer: !!disposer })
|
|
469
|
+
void debugBuiltinToolSnapshot(`register-success:${options.name}`)
|
|
470
|
+
})()
|
|
471
|
+
nativeRegisterTasks.set(options.name, task)
|
|
472
|
+
|
|
473
|
+
try {
|
|
474
|
+
await task
|
|
475
|
+
return true
|
|
476
|
+
} catch (error) {
|
|
477
|
+
cleanupLocalRegisterState()
|
|
478
|
+
debugWebMcpLog('register-builtin-error', {
|
|
479
|
+
name: options.name,
|
|
480
|
+
error: error instanceof Error ? error.message : String(error)
|
|
481
|
+
})
|
|
482
|
+
return false
|
|
483
|
+
} finally {
|
|
484
|
+
nativeRegisterTasks.delete(options.name)
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
export async function unregisterBuiltinWebMcpTool(name: string): Promise<boolean> {
|
|
489
|
+
debugWebMcpLog('unregister-builtin-start', { name })
|
|
490
|
+
const cleanup = () => {
|
|
491
|
+
nativeToolDisposers.delete(name)
|
|
492
|
+
nativeRegisteredToolDefs.delete(name)
|
|
493
|
+
nativeRegisteredTools.delete(name)
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const pendingRegister = nativeRegisterTasks.get(name)
|
|
497
|
+
if (pendingRegister) {
|
|
498
|
+
try {
|
|
499
|
+
await pendingRegister
|
|
500
|
+
} catch {
|
|
501
|
+
// ignore
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const disposer = nativeToolDisposers.get(name)
|
|
506
|
+
if (disposer) {
|
|
507
|
+
try {
|
|
508
|
+
await disposer()
|
|
509
|
+
cleanup()
|
|
510
|
+
debugWebMcpLog('unregister-builtin-success', { name, method: 'disposer' })
|
|
511
|
+
void debugBuiltinToolSnapshot(`unregister-success:${name}`)
|
|
512
|
+
return true
|
|
513
|
+
} catch (error) {
|
|
514
|
+
debugWebMcpLog('unregister-builtin-disposer-error', {
|
|
515
|
+
name,
|
|
516
|
+
error: error instanceof Error ? error.message : String(error)
|
|
517
|
+
})
|
|
518
|
+
// 继续尝试 modelContext.unregisterTool 的多签名兜底
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const modelContext = getBuiltinModelContext()
|
|
523
|
+
if (!modelContext) {
|
|
524
|
+
cleanup()
|
|
525
|
+
debugWebMcpLog('unregister-builtin-skip', { name, reason: 'unsupported' })
|
|
526
|
+
return false
|
|
527
|
+
}
|
|
528
|
+
if (!modelContext.unregisterTool) {
|
|
529
|
+
cleanup()
|
|
530
|
+
debugWebMcpLog('unregister-builtin-skip', { name, reason: 'missing-unregister' })
|
|
531
|
+
return false
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
const definition = nativeRegisteredToolDefs.get(name)
|
|
535
|
+
const candidates: unknown[] = [name, { name }, { toolName: name }, { tool: { name } }, definition].filter(Boolean)
|
|
536
|
+
for (const candidate of candidates) {
|
|
537
|
+
try {
|
|
538
|
+
debugWebMcpLog('unregister-builtin-try', { name, candidate })
|
|
539
|
+
const result = await modelContext.unregisterTool.call(modelContext, candidate)
|
|
540
|
+
if (result === false) continue
|
|
541
|
+
cleanup()
|
|
542
|
+
debugWebMcpLog('unregister-builtin-success', { name, method: 'unregisterTool', candidate })
|
|
543
|
+
void debugBuiltinToolSnapshot(`unregister-success:${name}`)
|
|
544
|
+
return true
|
|
545
|
+
} catch (error) {
|
|
546
|
+
debugWebMcpLog('unregister-builtin-try-error', {
|
|
547
|
+
name,
|
|
548
|
+
candidate,
|
|
549
|
+
error: error instanceof Error ? error.message : String(error)
|
|
550
|
+
})
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
cleanup()
|
|
555
|
+
debugWebMcpLog('unregister-builtin-failed', { name })
|
|
556
|
+
void debugBuiltinToolSnapshot(`unregister-failed:${name}`)
|
|
557
|
+
return false
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
export function hasBuiltinWebMcpTool(name: string): boolean {
|
|
561
|
+
return nativeRegisteredTools.has(name)
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
export async function forceResetBuiltinWebMcpTools(): Promise<void> {
|
|
565
|
+
const names = Array.from(nativeRegisteredTools)
|
|
566
|
+
for (const name of names) {
|
|
567
|
+
try {
|
|
568
|
+
await unregisterBuiltinWebMcpTool(name)
|
|
569
|
+
} catch {
|
|
570
|
+
// ignore
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
export async function listBuiltinWebMcpTools(): Promise<unknown[]> {
|
|
576
|
+
const testingApi = getBuiltinModelContextTesting()
|
|
577
|
+
if (!testingApi?.listTools) return []
|
|
578
|
+
try {
|
|
579
|
+
const result = await testingApi.listTools()
|
|
580
|
+
return Array.isArray(result) ? result : []
|
|
581
|
+
} catch {
|
|
582
|
+
return []
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
export async function executeBuiltinWebMcpTool(name: string, input: Record<string, unknown>): Promise<unknown> {
|
|
587
|
+
const testingApi = getBuiltinModelContextTesting()
|
|
588
|
+
if (!testingApi?.executeTool) {
|
|
589
|
+
throw new Error('当前浏览器不支持 navigator.modelContextTesting.executeTool。')
|
|
590
|
+
}
|
|
591
|
+
return await testingApi.executeTool(name, JSON.stringify(input ?? {}))
|
|
592
|
+
}
|
|
593
|
+
|
|
109
594
|
// 应用注册的导航函数,由 setNavigator 设置
|
|
110
595
|
let _navigator: ((route: string) => void | Promise<void>) | null = null
|
|
111
596
|
|
|
@@ -118,12 +603,26 @@ export function setNavigator(fn: (route: string) => void | Promise<void>) {
|
|
|
118
603
|
}
|
|
119
604
|
|
|
120
605
|
/**
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
* - 使用与 registerPageTool 相同的路由规范化规则
|
|
124
|
-
* - 内置兜底超时,防止 Promise 永远不 resolve
|
|
606
|
+
* 当前 pathname 是否已匹配目标路由。
|
|
607
|
+
* 兼容子路径部署(例如 current=/ai/orders, target=/orders)。
|
|
125
608
|
*/
|
|
126
|
-
function
|
|
609
|
+
function isCurrentPathMatched(path: string): boolean {
|
|
610
|
+
if (typeof window === 'undefined') return false
|
|
611
|
+
const target = normalizeRoute(path)
|
|
612
|
+
const current = normalizeRoute(window.location.pathname)
|
|
613
|
+
return (
|
|
614
|
+
current === target ||
|
|
615
|
+
(current.endsWith(target) && (current.length === target.length || current[current.lastIndexOf(target) - 1] === '/'))
|
|
616
|
+
)
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* 跳转握手等待:
|
|
621
|
+
* - 分离式路由工具:等待目标路由 page-ready
|
|
622
|
+
* - 一体化动态注册:等待 tool-catalog-changed(且当前已在目标路由)
|
|
623
|
+
* - 兜底超时,防止 Promise 永远不 resolve
|
|
624
|
+
*/
|
|
625
|
+
function waitForNavigationReady(path: string, timeoutMs = 1500): Promise<void> {
|
|
127
626
|
if (typeof window === 'undefined') {
|
|
128
627
|
return Promise.resolve()
|
|
129
628
|
}
|
|
@@ -141,9 +640,17 @@ function waitForPageReady(path: string, timeoutMs = 1500): Promise<void> {
|
|
|
141
640
|
}
|
|
142
641
|
|
|
143
642
|
const handleMessage = (event: MessageEvent) => {
|
|
144
|
-
if (event.source !== window
|
|
145
|
-
|
|
146
|
-
if (
|
|
643
|
+
if (event.source !== window) return
|
|
644
|
+
|
|
645
|
+
if (event.data?.type === MSG_PAGE_READY) {
|
|
646
|
+
const route = normalizeRoute(String(event.data.route ?? ''))
|
|
647
|
+
if (route === target) {
|
|
648
|
+
cleanup()
|
|
649
|
+
}
|
|
650
|
+
return
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
if (event.data?.type === MSG_TOOL_CATALOG_CHANGED && isCurrentPathMatched(target)) {
|
|
147
654
|
cleanup()
|
|
148
655
|
}
|
|
149
656
|
}
|
|
@@ -172,6 +679,17 @@ export type RouteConfig = {
|
|
|
172
679
|
invokeEffect?: boolean | ToolInvokeEffectConfig
|
|
173
680
|
}
|
|
174
681
|
|
|
682
|
+
export type WithPageToolsOptions = {
|
|
683
|
+
/**
|
|
684
|
+
* Chrome 内置 WebMCP 兼容模式。
|
|
685
|
+
* - auto(默认):检测到 navigator.modelContext 时,同步注册内置工具(同时保留 next-sdk 现有链路)
|
|
686
|
+
* - disabled:关闭内置兼容,仅使用 next-sdk 现有链路
|
|
687
|
+
*/
|
|
688
|
+
nativeWebMcp?: {
|
|
689
|
+
mode?: 'auto' | 'disabled'
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
|
|
175
693
|
// 对外暴露调用提示配置类型,便于业务方在 RouteConfig 外单独复用
|
|
176
694
|
export type { ToolInvokeEffectConfig }
|
|
177
695
|
|
|
@@ -201,6 +719,132 @@ export type PageAwareServer = Omit<WebMcpServer, 'registerTool'> & {
|
|
|
201
719
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
202
720
|
handlerOrRoute: ((...args: any[]) => any) | RouteConfig
|
|
203
721
|
): RegisteredTool
|
|
722
|
+
unregisterTool(name: string): boolean
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
export type PageToolDefinition<
|
|
726
|
+
InputArgs extends ZodRawShape = ZodRawShape,
|
|
727
|
+
OutputArgs extends ZodRawShape = ZodRawShape
|
|
728
|
+
> = {
|
|
729
|
+
/** 工具名称 */
|
|
730
|
+
name: string
|
|
731
|
+
/** 工具声明配置(title/description/schema/annotations) */
|
|
732
|
+
config: RegisterToolConfig<InputArgs, OutputArgs>
|
|
733
|
+
/** 工具绑定路由 */
|
|
734
|
+
route: string
|
|
735
|
+
/** 页面响应超时(ms) */
|
|
736
|
+
timeout?: number
|
|
737
|
+
/** 页面调用特效 */
|
|
738
|
+
invokeEffect?: boolean | ToolInvokeEffectConfig
|
|
739
|
+
/** 工具执行回调(可选 context,便于页面注入运行时依赖) */
|
|
740
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
741
|
+
handler: (input: any, context?: unknown) => any | Promise<any>
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
export function definePageTool<InputArgs extends ZodRawShape, OutputArgs extends ZodRawShape>(
|
|
745
|
+
definition: PageToolDefinition<InputArgs, OutputArgs>
|
|
746
|
+
): PageToolDefinition<InputArgs, OutputArgs> {
|
|
747
|
+
return definition
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
/**
|
|
751
|
+
* 批量注册页面工具声明(schema/route)到 MCP Server。
|
|
752
|
+
* 可与 mountPageTools 配套使用,实现“声明与执行回调在同一工具定义对象内”。
|
|
753
|
+
*/
|
|
754
|
+
export function registerPageTools(server: PageAwareServer, definitions: PageToolDefinition[]): RegisteredTool[] {
|
|
755
|
+
return definitions.map((definition) =>
|
|
756
|
+
server.registerTool(definition.name, definition.config, {
|
|
757
|
+
route: definition.route,
|
|
758
|
+
timeout: definition.timeout,
|
|
759
|
+
invokeEffect: definition.invokeEffect
|
|
760
|
+
})
|
|
761
|
+
)
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
/**
|
|
765
|
+
* 使用 PageToolDefinition 快速在页面侧挂载 handlers(声明与执行同源)。
|
|
766
|
+
* 等价于 registerPageTool({ tools, route?, context? })
|
|
767
|
+
*/
|
|
768
|
+
export function mountPageTools(options: MountPageToolsOptions): () => void {
|
|
769
|
+
return registerPageTool(options)
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
export type RegisterRuntimePageToolsOptions = MountPageToolsOptions & {
|
|
773
|
+
/**
|
|
774
|
+
* 是否在页面卸载时自动移除工具声明。
|
|
775
|
+
* - true(默认):页面即工具,离开页面后从 MCP 工具目录移除
|
|
776
|
+
* - false:只卸载 handler,工具声明保留(适合希望工具常驻目录的场景)
|
|
777
|
+
*/
|
|
778
|
+
removeOnUnmount?: boolean
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
/**
|
|
782
|
+
* 在业务页面内“一处定义 + 一处生效”:
|
|
783
|
+
* 1) 注册工具声明(name/description/schema/route)
|
|
784
|
+
* 2) 同时挂载工具 handler(页面生命周期内生效)
|
|
785
|
+
*
|
|
786
|
+
* 该能力与“分离式 mcp-servers + registerPageTool”并存,不会破坏原有写法。
|
|
787
|
+
*/
|
|
788
|
+
export function registerRuntimePageTools(server: PageAwareServer, options: RegisterRuntimePageToolsOptions): () => void {
|
|
789
|
+
const allTools = options.tools ?? []
|
|
790
|
+
if (!allTools.length) {
|
|
791
|
+
throw new Error('registerRuntimePageTools: tools 不能为空。')
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
const explicitRoute = options.route ? normalizeRoute(options.route) : null
|
|
795
|
+
const routes = new Set(allTools.map((tool) => normalizeRoute(tool.route)))
|
|
796
|
+
if (!explicitRoute && routes.size > 1) {
|
|
797
|
+
throw new Error('registerRuntimePageTools: tools 包含多个 route,请显式传入 route。')
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
const mountRoute = explicitRoute ?? Array.from(routes)[0]
|
|
801
|
+
const routeTools = allTools.filter((tool) => normalizeRoute(tool.route) === mountRoute)
|
|
802
|
+
if (!routeTools.length) {
|
|
803
|
+
throw new Error(`registerRuntimePageTools: route "${mountRoute}" 下未找到工具定义。`)
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
routeTools.forEach((definition) => {
|
|
807
|
+
const route = normalizeRoute(definition.route)
|
|
808
|
+
const existing = runtimeRegisteredTools.get(definition.name)
|
|
809
|
+
if (existing) {
|
|
810
|
+
if (existing.route !== route) {
|
|
811
|
+
throw new Error(
|
|
812
|
+
`registerRuntimePageTools: 工具 "${definition.name}" 已绑定路由 "${existing.route}",不能重复绑定到 "${route}"。`
|
|
813
|
+
)
|
|
814
|
+
}
|
|
815
|
+
existing.refCount += 1
|
|
816
|
+
return
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
const tool = server.registerTool(definition.name, definition.config, {
|
|
820
|
+
route,
|
|
821
|
+
timeout: definition.timeout,
|
|
822
|
+
invokeEffect: definition.invokeEffect
|
|
823
|
+
})
|
|
824
|
+
runtimeRegisteredTools.set(definition.name, { tool, route, refCount: 1 })
|
|
825
|
+
})
|
|
826
|
+
|
|
827
|
+
const cleanupHandlers = registerPageTool({
|
|
828
|
+
route: mountRoute,
|
|
829
|
+
tools: routeTools,
|
|
830
|
+
context: options.context
|
|
831
|
+
})
|
|
832
|
+
|
|
833
|
+
return () => {
|
|
834
|
+
cleanupHandlers()
|
|
835
|
+
|
|
836
|
+
if (options.removeOnUnmount === false) return
|
|
837
|
+
|
|
838
|
+
routeTools.forEach((definition) => {
|
|
839
|
+
const existing = runtimeRegisteredTools.get(definition.name)
|
|
840
|
+
if (!existing) return
|
|
841
|
+
existing.refCount -= 1
|
|
842
|
+
if (existing.refCount > 0) return
|
|
843
|
+
|
|
844
|
+
runtimeRegisteredTools.delete(definition.name)
|
|
845
|
+
server.unregisterTool(definition.name)
|
|
846
|
+
})
|
|
847
|
+
}
|
|
204
848
|
}
|
|
205
849
|
|
|
206
850
|
/**
|
|
@@ -234,67 +878,84 @@ export function registerNavigateTool(server: WebMcpServer, options?: NavigateToo
|
|
|
234
878
|
'当需要的工具在当前页面不可用时,使用此工具跳转到特定页面。例如:要查询订单时跳转到 "/orders",要创建价保时跳转到 "/price-protection"。'
|
|
235
879
|
const timeoutMs = options?.timeoutMs ?? 1500
|
|
236
880
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
881
|
+
const inputSchema = {
|
|
882
|
+
path: z.string().describe('目标页面的路由地址,例如 "/orders"、"/inventory"、"/price-protection" 等。')
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
const handler: ({ path }: { path: string }) => Promise<{ content: Array<{ type: 'text'; text: string }> }> = async ({
|
|
886
|
+
path
|
|
887
|
+
}: {
|
|
888
|
+
path: string
|
|
889
|
+
}) => {
|
|
890
|
+
if (typeof window === 'undefined') {
|
|
891
|
+
return {
|
|
892
|
+
content: [{ type: 'text', text: '当前环境不支持页面跳转(window 不存在)。' }]
|
|
244
893
|
}
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
if (!_navigator) {
|
|
897
|
+
return {
|
|
898
|
+
content: [
|
|
899
|
+
{
|
|
900
|
+
type: 'text',
|
|
901
|
+
text: '页面跳转失败:尚未在应用入口调用 setNavigator 注册导航函数,无法执行路由跳转。'
|
|
902
|
+
}
|
|
903
|
+
]
|
|
251
904
|
}
|
|
905
|
+
}
|
|
252
906
|
|
|
253
|
-
|
|
907
|
+
try {
|
|
908
|
+
// 若当前已在目标路由上,直接返回成功,避免不必要的跳转。
|
|
909
|
+
if (isCurrentPathMatched(path)) {
|
|
254
910
|
return {
|
|
255
|
-
content: [
|
|
256
|
-
{
|
|
257
|
-
type: 'text',
|
|
258
|
-
text: '页面跳转失败:尚未在应用入口调用 setNavigator 注册导航函数,无法执行路由跳转。'
|
|
259
|
-
}
|
|
260
|
-
]
|
|
911
|
+
content: [{ type: 'text', text: `当前已在页面:${path}。请继续你的下一步操作。` }]
|
|
261
912
|
}
|
|
262
913
|
}
|
|
263
914
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
const current = normalizeRoute(window.location.pathname)
|
|
269
|
-
const isAlreadyOnTarget =
|
|
270
|
-
current === target ||
|
|
271
|
-
(current.endsWith(target) && (current.length === target.length || current[current.lastIndexOf(target) - 1] === '/'))
|
|
272
|
-
if (isAlreadyOnTarget) {
|
|
273
|
-
return {
|
|
274
|
-
content: [{ type: 'text', text: `当前已在页面:${path}。请继续你的下一步操作。` }]
|
|
275
|
-
}
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
// 先注册 page-ready 监听再触发导航,避免极快导航下事件先于监听器触发而漏收(与 buildPageHandler 中的顺序一致)
|
|
279
|
-
const readyPromise = waitForPageReady(path, timeoutMs)
|
|
280
|
-
await _navigator(path)
|
|
281
|
-
await readyPromise
|
|
915
|
+
// 先注册握手监听再触发导航,避免极快导航下事件先于监听器触发而漏收。
|
|
916
|
+
const readyPromise = waitForNavigationReady(path, timeoutMs)
|
|
917
|
+
await _navigator(path)
|
|
918
|
+
await readyPromise
|
|
282
919
|
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
}
|
|
920
|
+
return {
|
|
921
|
+
content: [{ type: 'text', text: `已成功跳转至页面:${path}。请继续你的下一步操作。` }]
|
|
922
|
+
}
|
|
923
|
+
} catch (err) {
|
|
924
|
+
return {
|
|
925
|
+
content: [
|
|
926
|
+
{
|
|
927
|
+
type: 'text',
|
|
928
|
+
text: `页面跳转失败:${err instanceof Error ? err.message : String(err)}。`
|
|
929
|
+
}
|
|
930
|
+
]
|
|
295
931
|
}
|
|
296
932
|
}
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
const registeredTool = server.registerTool(
|
|
936
|
+
name,
|
|
937
|
+
{
|
|
938
|
+
title,
|
|
939
|
+
description,
|
|
940
|
+
inputSchema
|
|
941
|
+
},
|
|
942
|
+
handler
|
|
297
943
|
)
|
|
944
|
+
|
|
945
|
+
const managedByPageTools = typeof (server as Partial<PageAwareServer>).unregisterTool === 'function'
|
|
946
|
+
if (!managedByPageTools) {
|
|
947
|
+
void registerBuiltinWebMcpTool({
|
|
948
|
+
name,
|
|
949
|
+
description,
|
|
950
|
+
inputSchema,
|
|
951
|
+
execute: async (input) => {
|
|
952
|
+
return await handler(input as { path: string })
|
|
953
|
+
}
|
|
954
|
+
})
|
|
955
|
+
return attachBuiltinUnregisterOnRemove(name, registeredTool)
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
return registeredTool
|
|
298
959
|
}
|
|
299
960
|
|
|
300
961
|
/**
|
|
@@ -368,7 +1029,7 @@ function buildPageHandler(
|
|
|
368
1029
|
showToolInvokeEffect(effectConfig)
|
|
369
1030
|
}
|
|
370
1031
|
|
|
371
|
-
if (
|
|
1032
|
+
if (isToolReadyOnRoute(route, name)) {
|
|
372
1033
|
// 页面已激活,直接发送
|
|
373
1034
|
sendCallOnce()
|
|
374
1035
|
return
|
|
@@ -378,10 +1039,18 @@ function buildPageHandler(
|
|
|
378
1039
|
// 若先导航再注册,极快的导航(同步或微任务)可能导致
|
|
379
1040
|
// 目标页面已广播 page-ready 而监听器尚未挂载,从而错过信号。
|
|
380
1041
|
readyHandler = (event: MessageEvent) => {
|
|
381
|
-
if (event.source
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
1042
|
+
if (event.source !== window || event.data?.type !== MSG_PAGE_READY) return
|
|
1043
|
+
const readyRoute = normalizeRoute(String(event.data.route ?? ''))
|
|
1044
|
+
if (readyRoute !== route) return
|
|
1045
|
+
const readyTools = Array.isArray(event.data.toolNames)
|
|
1046
|
+
? new Set((event.data.toolNames as unknown[]).map((item) => String(item)))
|
|
1047
|
+
: null
|
|
1048
|
+
// 兼容旧版 page-ready(未携带 toolNames):
|
|
1049
|
+
// 若 toolNames 缺失,则按“路由已就绪”处理;若存在则必须包含目标工具名。
|
|
1050
|
+
if (readyTools && !readyTools.has(name)) return
|
|
1051
|
+
|
|
1052
|
+
window.removeEventListener('message', readyHandler!)
|
|
1053
|
+
sendCallOnce()
|
|
385
1054
|
}
|
|
386
1055
|
window.addEventListener('message', readyHandler)
|
|
387
1056
|
|
|
@@ -394,7 +1063,7 @@ function buildPageHandler(
|
|
|
394
1063
|
// message 事件已被 handleMessage 消费但 readyHandler 未执行,
|
|
395
1064
|
// 此处补充检查确保不会永久等待。
|
|
396
1065
|
// sendCallOnce 保证即使两条路径都触发,消息也只发送一次。
|
|
397
|
-
if (
|
|
1066
|
+
if (isToolReadyOnRoute(route, name)) {
|
|
398
1067
|
window.removeEventListener('message', readyHandler)
|
|
399
1068
|
sendCallOnce()
|
|
400
1069
|
}
|
|
@@ -416,23 +1085,103 @@ function buildPageHandler(
|
|
|
416
1085
|
* - 第三个参数为 **RouteConfig 对象**:自动生成转发 handler,工具调用时
|
|
417
1086
|
* 先导航到目标路由,再通过 postMessage 与页面通信
|
|
418
1087
|
*/
|
|
419
|
-
export function withPageTools(server: WebMcpServer): PageAwareServer
|
|
1088
|
+
export function withPageTools(server: WebMcpServer): PageAwareServer
|
|
1089
|
+
export function withPageTools(server: WebMcpServer, options: WithPageToolsOptions): PageAwareServer
|
|
1090
|
+
export function withPageTools(server: WebMcpServer, options?: WithPageToolsOptions): PageAwareServer {
|
|
1091
|
+
const nativeMode = options?.nativeWebMcp?.mode ?? 'auto'
|
|
1092
|
+
const shouldRegisterBuiltin = nativeMode !== 'disabled'
|
|
1093
|
+
const proxyRegisteredTools = new Map<string, RegisteredTool>()
|
|
1094
|
+
|
|
1095
|
+
const unregisterByName = (target: WebMcpServer, name: string, silent = false): boolean => {
|
|
1096
|
+
const existing = proxyRegisteredTools.get(name)
|
|
1097
|
+
const hadTrackedState = !!existing
|
|
1098
|
+
|
|
1099
|
+
debugWebMcpLog('proxy-unregister-start', { name, hasExisting: !!existing, silent })
|
|
1100
|
+
|
|
1101
|
+
// 优先命中浏览器内置 WebMCP 的最常见签名:unregisterTool('tool_name')
|
|
1102
|
+
// 可覆盖部分实现差异场景下的反注册遗漏。
|
|
1103
|
+
tryDirectBuiltinUnregisterByName(name)
|
|
1104
|
+
|
|
1105
|
+
proxyRegisteredTools.delete(name)
|
|
1106
|
+
runtimeRegisteredTools.delete(name)
|
|
1107
|
+
|
|
1108
|
+
if (existing) {
|
|
1109
|
+
try {
|
|
1110
|
+
existing.remove()
|
|
1111
|
+
} catch {
|
|
1112
|
+
// ignore
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
void unregisterBuiltinWebMcpTool(name)
|
|
1116
|
+
|
|
1117
|
+
if (!silent && hadTrackedState) {
|
|
1118
|
+
notifyServerToolListChanged(target)
|
|
1119
|
+
broadcastToolCatalogChanged()
|
|
1120
|
+
}
|
|
1121
|
+
debugWebMcpLog('proxy-unregister-done', { name, removed: !!existing, silent })
|
|
1122
|
+
return !!existing
|
|
1123
|
+
}
|
|
1124
|
+
|
|
420
1125
|
return new Proxy(server, {
|
|
421
1126
|
get(target, prop, receiver) {
|
|
1127
|
+
if (prop === 'unregisterTool') {
|
|
1128
|
+
return (name: string) => unregisterByName(target, name, false)
|
|
1129
|
+
}
|
|
422
1130
|
if (prop === 'registerTool') {
|
|
423
1131
|
return (name: string, config: any, handlerOrRoute: ((...args: any[]) => any) | RouteConfig) => {
|
|
1132
|
+
debugWebMcpLog('proxy-register-start', {
|
|
1133
|
+
name,
|
|
1134
|
+
mode: typeof handlerOrRoute === 'function' ? 'callback' : 'route',
|
|
1135
|
+
shouldRegisterBuiltin
|
|
1136
|
+
})
|
|
1137
|
+
// 同名工具热更新:先移除旧工具,再注册新工具,保证 remoter 始终拿到最新定义
|
|
1138
|
+
unregisterByName(target, name, true)
|
|
1139
|
+
|
|
424
1140
|
// 第三个参数是函数 → 直接透传,行为与原始 registerTool 完全相同
|
|
425
1141
|
// 通过 (target as any) 避免 WebMcpServer.registerTool 深层泛型触发"类型实例化过深"
|
|
426
1142
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
427
1143
|
const rawRegister = (target as any).registerTool.bind(target)
|
|
428
1144
|
if (typeof handlerOrRoute === 'function') {
|
|
429
|
-
|
|
1145
|
+
const registeredTool = rawRegister(name, config, handlerOrRoute)
|
|
1146
|
+
const wrapped = shouldRegisterBuiltin ? attachBuiltinUnregisterOnRemove(name, registeredTool) : registeredTool
|
|
1147
|
+
proxyRegisteredTools.set(name, wrapped)
|
|
1148
|
+
notifyServerToolListChanged(target)
|
|
1149
|
+
broadcastToolCatalogChanged()
|
|
1150
|
+
if (shouldRegisterBuiltin) {
|
|
1151
|
+
void registerBuiltinWebMcpTool({
|
|
1152
|
+
name,
|
|
1153
|
+
description: config?.description,
|
|
1154
|
+
inputSchema: config?.inputSchema,
|
|
1155
|
+
execute: async (input) => {
|
|
1156
|
+
return await handlerOrRoute(input)
|
|
1157
|
+
}
|
|
1158
|
+
})
|
|
1159
|
+
}
|
|
1160
|
+
debugWebMcpLog('proxy-register-done', { name, mode: 'callback' })
|
|
1161
|
+
return wrapped
|
|
430
1162
|
}
|
|
431
1163
|
// 第三个参数是路由配置对象 → 自动生成转发 handler,并记录 tool → route 映射
|
|
432
1164
|
const { route, timeout, invokeEffect } = handlerOrRoute
|
|
433
|
-
|
|
1165
|
+
const normalizedRoute = normalizeRoute(route)
|
|
434
1166
|
const effectConfig = resolveRuntimeEffectConfig(name, config?.title, invokeEffect)
|
|
435
|
-
|
|
1167
|
+
const pageHandler = buildPageHandler(name, normalizedRoute, timeout, effectConfig)
|
|
1168
|
+
const registeredTool = rawRegister(name, config, pageHandler)
|
|
1169
|
+
const wrapped = shouldRegisterBuiltin ? attachBuiltinUnregisterOnRemove(name, registeredTool) : registeredTool
|
|
1170
|
+
proxyRegisteredTools.set(name, wrapped)
|
|
1171
|
+
notifyServerToolListChanged(target)
|
|
1172
|
+
broadcastToolCatalogChanged()
|
|
1173
|
+
if (shouldRegisterBuiltin) {
|
|
1174
|
+
void registerBuiltinWebMcpTool({
|
|
1175
|
+
name,
|
|
1176
|
+
description: config?.description,
|
|
1177
|
+
inputSchema: config?.inputSchema,
|
|
1178
|
+
execute: async (input) => {
|
|
1179
|
+
return await pageHandler(input)
|
|
1180
|
+
}
|
|
1181
|
+
})
|
|
1182
|
+
}
|
|
1183
|
+
debugWebMcpLog('proxy-register-done', { name, mode: 'route' })
|
|
1184
|
+
return wrapped
|
|
436
1185
|
}
|
|
437
1186
|
}
|
|
438
1187
|
return Reflect.get(target, prop, receiver)
|
|
@@ -450,7 +1199,12 @@ export function withPageTools(server: WebMcpServer): PageAwareServer {
|
|
|
450
1199
|
*
|
|
451
1200
|
* 返回 cleanup 函数,页面销毁时调用。
|
|
452
1201
|
*/
|
|
453
|
-
|
|
1202
|
+
type PageToolHandlers = {
|
|
1203
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1204
|
+
[toolName: string]: (input: any) => Promise<any> | any
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
export type RegisterPageToolByHandlersOptions = {
|
|
454
1208
|
/**
|
|
455
1209
|
* 目标路由路径,与 RouteConfig.route 保持一致。
|
|
456
1210
|
* 省略时自动读取 window.location.pathname。
|
|
@@ -465,14 +1219,66 @@ export function registerPageTool(options: {
|
|
|
465
1219
|
*(如 `async ({ productId }: { productId: string }) => ...`)无法通过类型检查,
|
|
466
1220
|
* 破坏现有调用方代码的开发体验。运行时输入由 MCP inputSchema 保证类型安全。
|
|
467
1221
|
*/
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
1222
|
+
handlers: PageToolHandlers
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
export type MountPageToolsOptions = {
|
|
1226
|
+
/**
|
|
1227
|
+
* 待激活的工具定义(定义中同时包含 schema + handler)。
|
|
1228
|
+
* 若 route 省略且 tools 包含多个路由,将抛出错误提示显式指定 route。
|
|
1229
|
+
*/
|
|
1230
|
+
tools: PageToolDefinition[]
|
|
1231
|
+
/** 可选:覆盖 route(只激活该路由下的工具定义) */
|
|
1232
|
+
route?: string
|
|
1233
|
+
/** 运行时上下文,会作为第二参数透传给 definition.handler */
|
|
1234
|
+
context?: unknown
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
function resolveRouteAndHandlers(options: RegisterPageToolByHandlersOptions | MountPageToolsOptions): {
|
|
1238
|
+
route: string
|
|
1239
|
+
handlers: PageToolHandlers
|
|
1240
|
+
} {
|
|
1241
|
+
if ('handlers' in options) {
|
|
1242
|
+
return {
|
|
1243
|
+
route: normalizeRoute(options.route ?? window.location.pathname),
|
|
1244
|
+
handlers: options.handlers
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
const tools = options.tools ?? []
|
|
1249
|
+
if (!tools.length) {
|
|
1250
|
+
throw new Error('registerPageTool: tools 不能为空。')
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
const targetRoute = options.route ? normalizeRoute(options.route) : null
|
|
1254
|
+
const uniqueRoutes = new Set(tools.map((item) => normalizeRoute(item.route)))
|
|
1255
|
+
if (!targetRoute && uniqueRoutes.size > 1) {
|
|
1256
|
+
throw new Error('registerPageTool: tools 包含多个 route,请显式传入 route 参数。')
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
const route = targetRoute ?? Array.from(uniqueRoutes)[0]
|
|
1260
|
+
const handlers: PageToolHandlers = {}
|
|
1261
|
+
tools
|
|
1262
|
+
.filter((item) => normalizeRoute(item.route) === route)
|
|
1263
|
+
.forEach((item) => {
|
|
1264
|
+
if (handlers[item.name]) {
|
|
1265
|
+
throw new Error(`registerPageTool: 工具 "${item.name}" 在 route "${route}" 上重复定义。`)
|
|
1266
|
+
}
|
|
1267
|
+
handlers[item.name] = (input) => item.handler(input, options.context)
|
|
1268
|
+
})
|
|
1269
|
+
|
|
1270
|
+
if (!Object.keys(handlers).length) {
|
|
1271
|
+
throw new Error(`registerPageTool: route "${route}" 下未找到可激活的工具定义。`)
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
return { route, handlers }
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
export function registerPageTool(options: RegisterPageToolByHandlersOptions): () => void
|
|
1278
|
+
export function registerPageTool(options: MountPageToolsOptions): () => void
|
|
1279
|
+
export function registerPageTool(options: RegisterPageToolByHandlersOptions | MountPageToolsOptions): () => void {
|
|
1280
|
+
const { route, handlers } = resolveRouteAndHandlers(options)
|
|
1281
|
+
const toolNames = Object.keys(handlers)
|
|
476
1282
|
|
|
477
1283
|
const handleMessage = async (event: MessageEvent) => {
|
|
478
1284
|
// 同时校验 route 字段,防止多页面注册同名工具时发生跨路由串扰
|
|
@@ -502,9 +1308,9 @@ export function registerPageTool(options: {
|
|
|
502
1308
|
}
|
|
503
1309
|
|
|
504
1310
|
// 注册页面为已激活状态并广播就绪信号(同窗口 + iframe Remoter 均能收到)
|
|
505
|
-
activePages.set(route,
|
|
1311
|
+
activePages.set(route, new Set(toolNames))
|
|
506
1312
|
window.addEventListener('message', handleMessage)
|
|
507
|
-
broadcastRouteChange(MSG_PAGE_READY, route)
|
|
1313
|
+
broadcastRouteChange(MSG_PAGE_READY, route, { toolNames })
|
|
508
1314
|
|
|
509
1315
|
// 返回 cleanup,由各框架在页面销毁时调用
|
|
510
1316
|
return () => {
|
|
@@ -513,4 +1319,3 @@ export function registerPageTool(options: {
|
|
|
513
1319
|
broadcastRouteChange(MSG_PAGE_LEAVE, route)
|
|
514
1320
|
}
|
|
515
1321
|
}
|
|
516
|
-
|