@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.
@@ -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
- /** 页面卸载广播,供 pageToolsOnDemand 模式监听 */
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
- /** 父窗口向 iframe Remoter 回传的初始路由状态(toolRouteMap + activeRoutes) */
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, boolean>()
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
- const payload = {
75
- type: MSG_ROUTE_STATE_INITIAL,
76
- toolRouteMap: Array.from(toolRouteMap.entries()),
77
- activeRoutes: Array.from(activePages.keys())
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
- target.postMessage(payload, event.origin || '*')
91
+ win.postMessage(payload, origin)
81
92
  } catch {
82
- // 忽略跨域错误
93
+ // ignore
83
94
  }
84
95
  })
85
96
  }
86
- setupIframeRemoterBridge()
87
97
 
88
- // withPageTools 注册的工具路由映射表:工具名 → 目标路由
89
- const toolRouteMap = new Map<string, string>()
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
- * 返回的是内部 Map 的只读快照,可安全遍历。
94
- * @returns toolName → route 的只读 Map
109
+ * 为保持向后兼容,仍保留该 API;简化模式下不再维护此映射,始终返回空 Map
95
110
  */
96
111
  export function getToolRouteMap(): ReadonlyMap<string, string> {
97
- return new Map(toolRouteMap)
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
- * 等待指定路由页面完成挂载并广播 page-ready。
122
- * - 仅在浏览器环境下生效(window 存在时)
123
- * - 使用与 registerPageTool 相同的路由规范化规则
124
- * - 内置兜底超时,防止 Promise 永远不 resolve
606
+ * 当前 pathname 是否已匹配目标路由。
607
+ * 兼容子路径部署(例如 current=/ai/orders, target=/orders)。
125
608
  */
126
- function waitForPageReady(path: string, timeoutMs = 1500): Promise<void> {
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 || event.data?.type !== MSG_PAGE_READY) return
145
- const route = normalizeRoute(String(event.data.route ?? ''))
146
- if (route === target) {
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
- return server.registerTool(
238
- name,
239
- {
240
- title,
241
- description,
242
- inputSchema: {
243
- path: z.string().describe('目标页面的路由地址,例如 "/orders"、"/inventory"、"/price-protection" 等。')
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
- async ({ path }: { path: string }) => {
247
- if (typeof window === 'undefined') {
248
- return {
249
- content: [{ type: 'text', text: '当前环境不支持页面跳转(window 不存在)。' }]
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
- if (!_navigator) {
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
- try {
265
- const target = normalizeRoute(path)
266
- // 若当前已在目标路由上,直接返回成功,避免不必要的跳转。
267
- // 兼容子路径部署(如 base: '/ai-vue/'):pathname 可能为 /ai-vue/orders,而 path 为 /orders,需判断 pathname 是否“以目标路由结尾”
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
- return {
284
- content: [{ type: 'text', text: `已成功跳转至页面:${path}。请继续你的下一步操作。` }]
285
- }
286
- } catch (err) {
287
- return {
288
- content: [
289
- {
290
- type: 'text',
291
- text: `页面跳转失败:${err instanceof Error ? err.message : String(err)}。`
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 (activePages.get(route)) {
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 === window && event.data?.type === MSG_PAGE_READY && event.data.route === route) {
382
- window.removeEventListener('message', readyHandler!)
383
- sendCallOnce()
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 (activePages.get(route)) {
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
- return rawRegister(name, config, handlerOrRoute)
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
- toolRouteMap.set(name, route)
1165
+ const normalizedRoute = normalizeRoute(route)
434
1166
  const effectConfig = resolveRuntimeEffectConfig(name, config?.title, invokeEffect)
435
- return rawRegister(name, config, buildPageHandler(name, route, timeout, effectConfig))
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
- export function registerPageTool(options: {
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
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
469
- handlers: Record<string, (input: any) => Promise<any>>
470
- }): () => void {
471
- const { route: routeOption, handlers } = options
472
- // 规范化路由:去除尾部斜杠,空路径兜底为 '/',确保与 buildPageHandler 侧一致
473
- // 优先使用用户传入的 route,否则回退到 window.location.pathname
474
- const normalizeRoute = (value: string) => value.replace(/\/+$/, '') || '/'
475
- const route = normalizeRoute(routeOption ?? window.location.pathname)
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, true)
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
-