@hirey/hi-mcp-server 0.1.19 → 0.1.25

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/dist/server.js CHANGED
@@ -1,26 +1,96 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawn } from 'node:child_process';
3
+ import { AsyncLocalStorage } from 'node:async_hooks';
3
4
  import fs from 'node:fs/promises';
5
+ import fsSync from 'node:fs';
4
6
  import { createServer } from 'node:http';
5
7
  import path from 'node:path';
6
8
  import process from 'node:process';
9
+ import { fileURLToPath } from 'node:url';
10
+ // 从 package.json 实时读 version,避免源里 hardcoded 版本号跟实际 publish 版本号长期 drift
11
+ // (历史上一直挂着 0.1.7 但实际 npm 上是 0.1.19,导致 mcp-server 自报 version 跟 npm 不一致)。
12
+ const __pkgJsonPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'package.json');
13
+ const pkgJson = JSON.parse(fsSync.readFileSync(__pkgJsonPath, 'utf8'));
7
14
  import { createMcpExpressApp } from '@modelcontextprotocol/sdk/server/express.js';
8
15
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
9
16
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
10
17
  import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
11
18
  import { CallToolRequestSchema, ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
12
- import { AGENT_GATEWAY_EVENT_TOPICS, normalizeAgentEndpointList, normalizeAgentInstallationDeliveryDeclaration, normalizeAgentSubscriptionList, normalizeText, } from '@hirey/hi-agent-contracts';
19
+ import { AGENT_GATEWAY_EVENT_TOPICS, normalizeAgentEndpointList, normalizeAgentInstallationDeliveryDeclaration, normalizeAgentSubscriptionList, normalizeText, INSTALL_WELCOME_ONBOARDING_KIND, INSTALL_WELCOME_ONBOARDING_INSTRUCTION, DEFAULT_INTENT_OPTIONS, } from '@hirey/hi-agent-contracts';
13
20
  import { createHiAgentClients, exchangeHiAgentClientCredentialsToken, HiAgentGatewayClient, HiAgentPlatformClient, } from '@hirey/hi-agent-sdk';
14
- import { readState, resolveCanonicalOpenClawStateDir, resolveDefaultStateDir, resolveLegacyStateFiles, resolveStateFile, validateOpenClawStateDir, updateState, normalizeStateProfile, } from './state.js';
21
+ import { composeSubjectScopedProfile, quarantineStaleIdentityIfNeeded, readState, resolveCanonicalOpenClawStateDir, resolveDefaultStateDir, resolveLegacyStateFiles, resolveStateFile, validateOpenClawStateDir, updateState, normalizeStateProfile, } from './state.js';
15
22
  import { looksLikeOpenClawSessionKey, validateOpenClawSessionKey, } from './openclaw-session-key.js';
16
23
  import { buildInstallReceiverCommandArgv, } from './receiver-command.js';
17
24
  import { applyReceiverRuntimeSnapshot, receiverConfigMaterialEquals, } from './receiver-config-material.js';
18
25
  import { resolveInstallDefaultReplyDeliveryContext, resolveInstallRouteMissingPolicy, } from './defaultReplyRoute.js';
19
26
  import { resolveInstallDisplayName } from './installDefaults.js';
27
+ import { buildAuthorizationServerMetadataAlias, buildOAuthVerifier, buildProtectedResourceMetadata, buildWwwAuthenticateChallenge, getCurrentRequestAuth, requestAuthStorage, verifyRequestBearer, } from './oauthRequestAuth.js';
28
+ // Hosts that hi-mcp-server can claim a first-class identity for. Everything
29
+ // outside this set normalizes down to `generic`. We carry the wider list here
30
+ // (not the narrow openclaw/generic split) so install state + telemetry can
31
+ // distinguish a Codex install from a Claude Code install from a bare generic
32
+ // MCP host, even though the install flow itself only branches on openclaw.
33
+ const KNOWN_HOST_KINDS = new Set([
34
+ 'openclaw',
35
+ 'generic',
36
+ 'codex',
37
+ 'claude_code',
38
+ 'claude_chat',
39
+ 'claude_cowork',
40
+ 'claude_desktop',
41
+ 'chatgpt_app',
42
+ ]);
20
43
  const CAPABILITY_CACHE_TTL_MS = 30_000;
21
44
  const RECEIVER_STOP_TIMEOUT_MS = 3_000;
22
45
  const RECEIVER_STOP_POLL_MS = 100;
23
46
  const resolvedProfile = normalizeStateProfile(process.env.HI_MCP_PROFILE);
47
+ const requestToolSurfaceStorage = new AsyncLocalStorage();
48
+ const CHATGPT_APP_TOOL_NAMES = new Set([
49
+ 'listing_taxonomy',
50
+ 'agent_listings',
51
+ 'matching_sessions',
52
+ 'pairings',
53
+ 'thread_meetings',
54
+ 'faq_search',
55
+ 'faq_get',
56
+ 'content_render',
57
+ 'content_get',
58
+ ]);
59
+ const CAPABILITY_TOOL_OUTPUT_SCHEMA = {
60
+ type: 'object',
61
+ properties: {
62
+ ok: { type: 'boolean' },
63
+ capability_id: { type: 'string' },
64
+ tool_name: { type: 'string' },
65
+ result: {
66
+ type: ['object', 'array', 'string', 'number', 'boolean', 'null'],
67
+ description: 'Tool-specific result payload returned by Hi.',
68
+ },
69
+ },
70
+ required: ['ok', 'capability_id', 'tool_name', 'result'],
71
+ additionalProperties: false,
72
+ };
73
+ function normalizeToolSurface(input) {
74
+ const value = normalizeText(input).toLowerCase().replace(/[\s-]+/g, '_');
75
+ if (['chatgpt', 'chatgpt_app', 'chatgpt_apps', 'openai_app', 'apps'].includes(value)) {
76
+ return 'chatgpt_app';
77
+ }
78
+ return 'full';
79
+ }
80
+ function effectiveToolSurface() {
81
+ return requestToolSurfaceStorage.getStore() || config.toolSurface;
82
+ }
83
+ function isToolVisibleOnSurface(toolName) {
84
+ if (effectiveToolSurface() === 'full')
85
+ return true;
86
+ return CHATGPT_APP_TOOL_NAMES.has(toolName);
87
+ }
88
+ function readListEnv(name, fallback) {
89
+ const raw = normalizeText(process.env[name]);
90
+ if (!raw)
91
+ return fallback;
92
+ return raw.split(/[,\s]+/).map((x) => x.trim()).filter(Boolean);
93
+ }
24
94
  const config = {
25
95
  host: normalizeText(process.env.HI_MCP_HOST) || '127.0.0.1',
26
96
  port: Number(process.env.HI_MCP_PORT || 8788),
@@ -28,6 +98,19 @@ const config = {
28
98
  stateDir: normalizeText(process.env.HI_MCP_STATE_DIR) || resolveDefaultStateDir(resolvedProfile),
29
99
  platformBaseUrl: normalizeText(process.env.HI_PLATFORM_BASE_URL),
30
100
  transport: normalizeText(process.env.HI_MCP_TRANSPORT).toLowerCase() === 'stdio' ? 'stdio' : 'http',
101
+ toolSurface: normalizeToolSurface(process.env.HI_MCP_TOOL_SURFACE),
102
+ authMode: normalizeText(process.env.HI_MCP_AUTH_MODE).toLowerCase() === 'oauth' ? 'oauth' : 'local',
103
+ // HI_MCP_OAUTH_RESOURCE accepts a single URL or a comma/whitespace-separated
104
+ // list. First entry is the primary. Old single-value deployments continue
105
+ // to work unchanged (a 1-element array).
106
+ oauthMcpResources: readListEnv('HI_MCP_OAUTH_RESOURCE', []),
107
+ oauthAuthorizationServer: normalizeText(process.env.HI_MCP_OAUTH_AUTHORIZATION_SERVER),
108
+ // Backwards-compat: when HI_MCP_OAUTH_ISSUER is not set, fall back to
109
+ // HI_MCP_OAUTH_AUTHORIZATION_SERVER so older single-tenant smoke setups
110
+ // (where AS URL == JWT issuer == same host) keep working.
111
+ oauthIssuer: normalizeText(process.env.HI_MCP_OAUTH_ISSUER)
112
+ || normalizeText(process.env.HI_MCP_OAUTH_AUTHORIZATION_SERVER),
113
+ oauthScopesSupported: readListEnv('HI_MCP_OAUTH_SCOPES', ['hi.read', 'hi.write', 'hi.events']),
31
114
  };
32
115
  let capabilityCache = null;
33
116
  function assertConfig() {
@@ -37,6 +120,20 @@ function assertConfig() {
37
120
  if (!Number.isFinite(config.port) || config.port <= 0) {
38
121
  throw new Error('invalid_hi_mcp_port');
39
122
  }
123
+ if (config.authMode === 'oauth') {
124
+ if (config.transport !== 'http') {
125
+ throw new Error('oauth_auth_mode_requires_http_transport');
126
+ }
127
+ if (config.oauthMcpResources.length === 0) {
128
+ throw new Error('missing_hi_mcp_oauth_resource');
129
+ }
130
+ if (!config.oauthIssuer) {
131
+ throw new Error('missing_hi_mcp_oauth_issuer');
132
+ }
133
+ if (!config.oauthAuthorizationServer) {
134
+ throw new Error('missing_hi_mcp_oauth_authorization_server');
135
+ }
136
+ }
40
137
  }
41
138
  function jsonText(value) {
42
139
  return JSON.stringify(value, null, 2);
@@ -97,7 +194,7 @@ function controlTools() {
97
194
  type: 'object',
98
195
  properties: {
99
196
  display_name: { type: 'string', description: '首次安装且当前 profile 还没有 identity 时使用的人类可读名称。省略时会按 host_kind 使用稳定默认值:OpenClaw 为 `OpenClaw Hi Agent`,generic 为 `Hi Agent`。' },
100
- host_kind: { type: 'string', description: "可选:'openclaw'|'generic'。默认 generic。" },
197
+ host_kind: { type: 'string', description: "可选。已知:'openclaw' | 'generic' | 'codex' | 'claude_code' | 'claude_chat' | 'claude_cowork' | 'claude_desktop' | 'chatgpt_app'。默认 generic。仅 'openclaw' 触发本地 receiver / hooks 适配;其他值按远程 / pull 形态走,但仍会保留在 install state 里用于 doctor + telemetry。" },
101
198
  agent_kind: { type: 'string', description: '首次 register 时可选的 agent_kind。默认 external。' },
102
199
  replace_existing_state: { type: 'boolean', description: '首次 register 且本地已有 state 时,是否允许覆盖本地持久化身份。' },
103
200
  migrate_legacy_state: { type: 'boolean', description: '默认 true。若 canonical state 为空,则尝试一次已知 legacy state 迁移。' },
@@ -122,6 +219,10 @@ function controlTools() {
122
219
  default_reply_thread_id: { type: 'string', description: '可选:default continuation route 的 thread_id。' },
123
220
  subscribe_default_topics: { type: 'boolean', description: '是否自动补齐 Hi 官方默认事件订阅。默认 true。' },
124
221
  run_doctor: { type: 'boolean', description: '默认 true。安装完成后自动跑一次 hi_agent_doctor。' },
222
+ metadata: {
223
+ type: 'object',
224
+ description: '首次 register 时透传给 hi-platform 写入 agents.metadata_json 的自定义字段。常见用法:渠道归因 `{"channel_code":"..."}`——用户被某邀请落地页提供的 prompt 里带了 channel_code,必须**原样保留**透传给本工具,不要丢弃也不要修改其值。',
225
+ },
125
226
  },
126
227
  },
127
228
  },
@@ -148,6 +249,14 @@ function controlTools() {
148
249
  },
149
250
  },
150
251
  },
252
+ {
253
+ name: 'hi_agent_state_resync',
254
+ description: '把本地持久化 identity 拉回 remote canonical(/me + /installation 当前真值)。专为 server-side admin consolidate / mergeAgents 之后 client local state 还停在 merge 前快照的场景兜底;只刷 agent_id / display_name / delivery_capabilities,不动 installation_id 和长期凭证(client_id / client_secret 等)。看到 hi_agent_doctor 报 `agent_identity_split` blocker 时调这个工具,不要再调 hi_agent_register(会再造孤儿)。',
255
+ inputSchema: {
256
+ type: 'object',
257
+ properties: {},
258
+ },
259
+ },
151
260
  {
152
261
  name: 'hi_agent_register',
153
262
  description: '按 Hi 官方 register -> token -> activate 主线创建一个新的 external agent installation,并把长期凭证持久化到本地 state。',
@@ -189,6 +298,25 @@ function controlTools() {
189
298
  },
190
299
  },
191
300
  },
301
+ {
302
+ name: 'hi_agent_claim_export',
303
+ description: '导出一个一次性、短过期的"重挂凭单"(claim token),用于把另一台新设备挂回**当前这个 agent**(同一身份)。要求当前 agent 已绑定手机号(=工作区所有权证明;没绑会 403,请先用 phone_binding 绑定)。返回的 claim_token 像密码一样——谁 redeem 谁就接入这个 agent,只发给你自己的其它设备。典型场景:换电脑 / 重装 / 凭证丢了,不想变成一个新的空 agent、也不想重新绑手机时——在老设备上 export,到新设备上调 hi_agent_claim_redeem,就能继续用同一个 agent(listings / 会话 / 对端回复都在)。',
304
+ inputSchema: {
305
+ type: 'object',
306
+ properties: {},
307
+ },
308
+ },
309
+ {
310
+ name: 'hi_agent_claim_redeem',
311
+ description: '在一台**新设备 / 新安装**上消费另一台设备用 hi_agent_claim_export 导出的 claim_token,把当前这台安装重挂到那个 agent——之后你就是同一个 agent(之前的 listings、会话、对端回复都在,可直接接着回复)。一次性使用,用过即作废;过期 / 被吊销 / 已用过都会被拒。',
312
+ inputSchema: {
313
+ type: 'object',
314
+ properties: {
315
+ claim_token: { type: 'string', description: '老设备 hi_agent_claim_export 返回的一次性凭单 secret。' },
316
+ },
317
+ required: ['claim_token'],
318
+ },
319
+ },
192
320
  {
193
321
  name: 'hi_agent_installation_get',
194
322
  description: '读取当前 installation 的正式 persisted state,包括 delivery_capabilities。',
@@ -320,7 +448,7 @@ function controlTools() {
320
448
  },
321
449
  {
322
450
  name: 'hi_agent_events_wait',
323
- description: '用 claim/poll 等待下一批 durable events,适合没有 live push 的 MCP-first 宿主。',
451
+ description: '用 claim/poll 等待下一批 durable events,适合没有 live push 的 MCP-first 宿主。\n\n**宿主无 push channel 时(典型如 Codex CLI)**:用户结束当前 turn 后没有任何 background process 会把 Hi 事件投递给 LLM —— 对方 agent 的 reply、meeting 确认、listing reaction 都只会静静躺在 Hi outbox 里等下次 claim/wait。这种宿主上发起任何 outreach 后(pairings.contact_target / matching_sessions.contact_match / thread_meetings.start / agent_listings.upsert),都应当主动建议用户开一条 scheduled drain:Codex 用户可以用 thread automation(每 N 分钟唤回当前 thread)或外置 cron + `codex exec "drain my Hi inbox via hi_agent_events_wait then ack"`;其它 MCP-first 宿主同理。如果用户没设这种轮询,对方的回复就会一直被 silent-suppress 在 outbox 里直到用户主动问起,体验上等同于"消息丢了"。',
324
452
  inputSchema: {
325
453
  type: 'object',
326
454
  properties: {
@@ -388,21 +516,91 @@ async function createBootstrapClients() {
388
516
  gateway,
389
517
  };
390
518
  }
519
+ // Per-subject record of "this process has just quarantined a stale identity
520
+ // belonging to <subject_id>". In OAuth multi-tenant HTTP mode we MUST NOT
521
+ // share a single notice across subjects — A's quarantine notice would
522
+ // otherwise leak A's issuer / previous_agent_id / previous_installation_id
523
+ // out through B's hi_agent_status response. In stdio / single-tenant mode
524
+ // there's exactly one operator and we keep the historic single-notice
525
+ // behaviour (keyed under the bare `config.profile`).
526
+ const quarantineNoticeBySubject = new Map();
527
+ function noticeKeyForCurrentSubject() {
528
+ if (config.authMode !== 'oauth')
529
+ return config.profile;
530
+ const auth = getCurrentRequestAuth();
531
+ return auth?.subjectId ? `subject:${auth.subjectId}` : config.profile;
532
+ }
533
+ // Effective profile for state I/O. In OAuth multi-tenant HTTP mode, each
534
+ // /mcp request carries a verified bearer with a `sub` claim — we derive a
535
+ // per-subject suffix so two OAuth callers never share a state file.
536
+ //
537
+ // Fail-closed: if OAuth mode is active but the request auth is somehow not
538
+ // in scope (e.g. a tool handler escapes the AsyncLocalStorage frame via a
539
+ // stray setTimeout / unawaited promise), we throw rather than silently
540
+ // falling back to `config.profile` (which would write into the shared
541
+ // single-tenant file — exactly the cross-tenant leak this isolation aims
542
+ // to prevent). stdio / local single-tenant mode never has request auth, so
543
+ // it cleanly falls back to `config.profile` and the historic single-file
544
+ // layout.
545
+ function effectiveStateProfile() {
546
+ if (config.authMode !== 'oauth')
547
+ return config.profile;
548
+ const auth = getCurrentRequestAuth();
549
+ if (!auth?.subjectId) {
550
+ throw new Error('oauth_state_io_outside_request_auth_scope');
551
+ }
552
+ return composeSubjectScopedProfile(config.profile, auth.subjectId);
553
+ }
391
554
  async function loadPersistedState() {
392
- return await readState({
555
+ const profile = effectiveStateProfile();
556
+ const raw = await readState({
393
557
  stateDir: config.stateDir,
394
- profile: config.profile,
558
+ profile,
395
559
  });
560
+ const { state, quarantined } = await quarantineStaleIdentityIfNeeded({
561
+ stateDir: config.stateDir,
562
+ profile,
563
+ currentPlatformBaseUrl: config.platformBaseUrl,
564
+ state: raw,
565
+ logStderr: (line) => process.stderr.write(`${line}\n`),
566
+ });
567
+ if (quarantined) {
568
+ quarantineNoticeBySubject.set(noticeKeyForCurrentSubject(), quarantined);
569
+ }
570
+ return state;
571
+ }
572
+ // peek 不消费,让 install / status / doctor 调用期间的所有响应都能 surface 一次同样的
573
+ // quarantine event;mcp 进程退出后 in-memory 通知自然丢掉。In OAuth multi-tenant
574
+ // HTTP mode the notice is scoped to the current request's subject so other
575
+ // subjects never see this subject's quarantine details.
576
+ export function peekQuarantineNotice() {
577
+ return quarantineNoticeBySubject.get(noticeKeyForCurrentSubject()) || null;
396
578
  }
397
579
  async function persistState(updater) {
398
580
  return await updateState({
399
581
  stateDir: config.stateDir,
400
- profile: config.profile,
582
+ profile: effectiveStateProfile(),
401
583
  updater,
402
584
  });
403
585
  }
404
586
  async function createAuthorizedClients() {
405
587
  let state = await loadPersistedState();
588
+ // OAuth mode: the inbound /mcp request already carries a verified bearer
589
+ // bound to a Codex / Claude Code installation. Replay it verbatim to
590
+ // downstream Hi services and skip the disk identity entirely — there is
591
+ // no "current installation on this process" when running multi-tenant.
592
+ const requestAuth = config.authMode === 'oauth' ? getCurrentRequestAuth() : null;
593
+ if (requestAuth) {
594
+ const clients = await createHiAgentClients({
595
+ platformBaseUrl: config.platformBaseUrl,
596
+ token: requestAuth.bearer,
597
+ });
598
+ return {
599
+ state,
600
+ accessToken: requestAuth.bearer,
601
+ ...clients,
602
+ };
603
+ }
406
604
  if (!state.identity)
407
605
  throw new Error('missing_agent_identity');
408
606
  const token = await exchangeHiAgentClientCredentialsToken({
@@ -433,11 +631,25 @@ async function createAuthorizedClients() {
433
631
  function resolveCurrentStateFile() {
434
632
  return resolveStateFile({
435
633
  stateDir: config.stateDir,
436
- profile: config.profile,
634
+ profile: effectiveStateProfile(),
437
635
  });
438
636
  }
439
637
  function normalizeHostKind(raw) {
440
- return normalizeText(raw).toLowerCase() === 'openclaw' ? 'openclaw' : 'generic';
638
+ const value = normalizeText(raw).toLowerCase().replace(/-/g, '_');
639
+ if (!value)
640
+ return 'generic';
641
+ // Aliases for host strings users may type without underscores.
642
+ if (value === 'claudecode')
643
+ return 'claude_code';
644
+ if (value === 'claudechat' || value === 'claude')
645
+ return 'claude_chat';
646
+ if (value === 'claudedesktop')
647
+ return 'claude_desktop';
648
+ if (value === 'claudecowork' || value === 'cowork')
649
+ return 'claude_cowork';
650
+ if (value === 'chatgpt' || value === 'chatgptapp')
651
+ return 'chatgpt_app';
652
+ return KNOWN_HOST_KINDS.has(value) ? value : 'generic';
441
653
  }
442
654
  function normalizeReceiverTransport(raw) {
443
655
  return normalizeText(raw).toLowerCase() === 'stream' ? 'stream' : 'claim';
@@ -463,6 +675,17 @@ function buildInstallRuntimeState(current, patch) {
463
675
  }
464
676
  async function maybeMigrateLegacyState() {
465
677
  const targetStateFile = resolveCurrentStateFile();
678
+ // Legacy single-tenant state migration is unsafe in OAuth multi-tenant mode:
679
+ // the legacy candidate files (e.g. `~/.openclaw/hi/default.json`) are
680
+ // un-scoped per subject, so copying one into a new OAuth subject's
681
+ // subject-scoped state file would re-introduce the cross-tenant identity
682
+ // leak we just closed in commit ab8c41d (every fresh OAuth subject on first
683
+ // install would inherit whatever identity the legacy single-tenant install
684
+ // had left on disk). Stdio / local single-tenant mode keeps the historic
685
+ // migration behaviour because there it's exactly the operator's own state.
686
+ if (config.authMode === 'oauth') {
687
+ return { migrated: false, from: null, to: targetStateFile };
688
+ }
466
689
  try {
467
690
  await fs.access(targetStateFile);
468
691
  return { migrated: false, from: null, to: targetStateFile };
@@ -628,7 +851,7 @@ function buildDefaultReplyRoute(args, options = {}) {
628
851
  requireOpenClawSessionKey: options.requireOpenClawSessionKey,
629
852
  });
630
853
  const deliveryContext = resolveInstallDefaultReplyDeliveryContext({
631
- hostKind: options.hostKind === 'openclaw' ? 'openclaw' : 'generic',
854
+ hostKind: options.hostKind || 'generic',
632
855
  hasSessionKey: !!sessionKey,
633
856
  defaultReplyChannel: args.default_reply_channel,
634
857
  defaultReplyTo: args.default_reply_to,
@@ -803,6 +1026,14 @@ async function startDetachedReceiver(args) {
803
1026
  function buildDoctorSummary(args) {
804
1027
  const installState = stateInstallSnapshot(args.state.runtime);
805
1028
  const remoteInstallation = args.remote?.installation;
1029
+ const remoteMe = args.remote?.me;
1030
+ // OAuth 多租户模式下没有本地 state.identity;connected/activated 完全靠 remote probe
1031
+ // 推。OpenClaw 路径反过来——本地 identity 才是 truth,没远端响应也能算 connected。
1032
+ const remoteConnected = !!(normalizeText(remoteMe?.agent?.agent_id)
1033
+ && normalizeText(remoteMe?.agent?.status || 'active').toLowerCase() !== 'disabled'
1034
+ && normalizeText(remoteInstallation?.installation?.status || 'active').toLowerCase() !== 'disabled');
1035
+ const connected = !!args.state.identity || remoteConnected;
1036
+ const activated = !!(remoteInstallation?.installation?.activated_at || args.state.identity?.activated_at);
806
1037
  const deliveryDeclaration = remoteInstallation?.installation?.delivery_capabilities
807
1038
  || args.state.identity?.delivery_capabilities
808
1039
  || null;
@@ -824,8 +1055,9 @@ function buildDoctorSummary(args) {
824
1055
  state_dir: config.stateDir,
825
1056
  state_file: resolveCurrentStateFile(),
826
1057
  canonical_openclaw_state_dir: resolveCanonicalOpenClawStateDir(config.profile),
827
- connected: !!args.state.identity,
828
- activated: !!(remoteInstallation?.installation?.activated_at || args.state.identity?.activated_at),
1058
+ quarantined_stale_identity: peekQuarantineNotice(),
1059
+ connected,
1060
+ activated,
829
1061
  event_path: deliveryDeclaration?.preferred || (localReceiverEnabled ? 'local_receiver' : 'claim_ack'),
830
1062
  receiver_config_path: installState.receiver_config_path,
831
1063
  receiver_running: isProcessAlive(installState.receiver_pid),
@@ -913,6 +1145,22 @@ async function diagnoseOpenClawHookBasePathMismatch(args) {
913
1145
  };
914
1146
  }
915
1147
  async function handleRegister(args) {
1148
+ // 2026-05:OAuth 多租户模式(Codex / Claude Code 走 /mcp)下,bearer 已经在 hi-auth
1149
+ // 那一层通过 ensureRemoteOAuthAgentConnection 自动 provision 了 agent + installation
1150
+ // (见 hi-agent-gateway agentPlatform.ts:ensureRemoteOAuthAgentConnection)。再走
1151
+ // gateway.register 会 mint 一个新 installation_id 当 auth_principal,造出一条
1152
+ // 跟当前 bearer 不挂钩的孤儿 agent ——本地 state 写了它但 bearer 永远 resolve 回旧 agent。
1153
+ // 用户感知到的就是"local identity 跟 /me 不一致 → 我的数据没了"。
1154
+ //
1155
+ // OAuth 模式正确做身份切换的路径:admin 调 /internal/agents/:source/consolidate-into
1156
+ // 让 installation 原子改绑到 target;客户端跑 hi_agent_state_resync 让 local state
1157
+ // 跟上 remote。绝对不要在 OAuth 路径下再走 register。
1158
+ if (config.authMode === 'oauth' && getCurrentRequestAuth()) {
1159
+ return fail('hi_agent_register_not_allowed_in_oauth_mode', {
1160
+ hint: 'OAuth bearer 已经通过 ensureRemoteOAuthAgentConnection 自动 provision 了 agent;再 register 会造孤儿(local agent_id 跟 bearer 解析出的 agent_id 永远不一致)。需要切换身份请 admin 调 /internal/agents/:source/consolidate-into;客户端本地 state 出 drift 请用 hi_agent_state_resync。',
1161
+ profile: config.profile,
1162
+ });
1163
+ }
916
1164
  const current = await loadPersistedState();
917
1165
  if (current.identity && args.replace_existing_state !== true) {
918
1166
  return fail('agent_state_already_exists', {
@@ -968,6 +1216,108 @@ async function handleRegister(args) {
968
1216
  persisted_profile: config.profile,
969
1217
  });
970
1218
  }
1219
+ // 2026-05:在 server-side agent_installations.agent_id 被 admin consolidate / mergeAgents
1220
+ // 改绑之后,把本地 state.identity 拉回 remote canonical。bearer 不动、installation_id 不动、
1221
+ // 长期凭证(client_id / client_secret 等)不动——只刷三个被 merge 改过的字段:agent_id、
1222
+ // display_name、delivery_capabilities。专门给 hi_agent_doctor 报 agent_identity_split blocker
1223
+ // 之后兜底用,不需要服务端配合的轻量 client-side fix。
1224
+ //
1225
+ // 跟 hi_agent_connect 的区别:connect 是 "把当前 installation 绑到另一个 agent",会调
1226
+ // gateway.connect 走 mutation 路径——不该在已经 merge 完的 installation 上重复发起。
1227
+ // 这里只读 /me + /installation,纯 client-side 文件 patch,不进 hi-platform 写路径,
1228
+ // 也不会跟正在跑的 admin merge 冲突。
1229
+ //
1230
+ // OAuth 模式下 state.identity 永远是 null(bearer 决定身份),调用时直接 no-op 报回当前
1231
+ // remote view——不应该阻断,让 LLM 在 OAuth 路径下也能用它做"确认我现在到底是谁"探查。
1232
+ async function handleStateResync(_args) {
1233
+ const { gateway } = await createAuthorizedClients();
1234
+ const [me, installation] = await Promise.all([
1235
+ gateway.me(),
1236
+ gateway.getInstallation(),
1237
+ ]);
1238
+ const remoteAgentId = normalizeText(me?.agent?.agent_id) || null;
1239
+ const remoteInstallationId = normalizeText(installation.installation?.installation_id) || null;
1240
+ const remoteAgentDisplayName = normalizeText(me?.agent?.display_name) || null;
1241
+ const remoteAgentKind = normalizeText(me?.agent?.agent_kind) || null;
1242
+ const remoteDeliveryCapabilities = installation.installation?.delivery_capabilities || null;
1243
+ const remoteActivatedAt = normalizeText(installation.installation?.activated_at) || null;
1244
+ if (!remoteAgentId || !remoteInstallationId) {
1245
+ return fail('state_resync_remote_unreadable', {
1246
+ hint: 'gateway.me / getInstallation 没回出 canonical IDs;可能是 bearer 还没 provision agent,跑 hi_agent_install 先。',
1247
+ });
1248
+ }
1249
+ // OAuth 多租户模式(Codex / Claude Code 走 /mcp):state.identity 故意是 null,
1250
+ // 每条请求的真身份由 bearer 决定。即使 disk 上有残留 identity(不该有但理论可能),
1251
+ // 也不要 patch——把 disk 改成"当前 bearer 解析出的 agent"在多租户下是错的(下一条
1252
+ // 请求可能是另一个 subject)。直接报当前 remote view 让 caller 知道现在 bearer
1253
+ // 落在哪个 agent。
1254
+ if (config.authMode === 'oauth') {
1255
+ return ok({
1256
+ ok: true,
1257
+ patched: false,
1258
+ reason: 'oauth_multitenant_no_disk_identity',
1259
+ remote: {
1260
+ agent_id: remoteAgentId,
1261
+ installation_id: remoteInstallationId,
1262
+ display_name: remoteAgentDisplayName,
1263
+ agent_kind: remoteAgentKind,
1264
+ },
1265
+ });
1266
+ }
1267
+ const before = await loadPersistedState();
1268
+ const beforeIdentity = before.identity;
1269
+ const beforeAgentId = beforeIdentity?.agent_id || null;
1270
+ const beforeInstallationId = beforeIdentity?.installation_id || null;
1271
+ // 非 OAuth 模式下到这里 state.identity 不应该是 null——createAuthorizedClients 在
1272
+ // 没有 disk identity 时已经 throw missing_agent_identity 提前结束。保险起见还是检查一遍。
1273
+ if (!beforeIdentity) {
1274
+ return fail('state_resync_no_local_identity', {
1275
+ hint: '非 OAuth 模式下 local state 还没 identity,先跑 hi_agent_install。',
1276
+ });
1277
+ }
1278
+ const agentIdDrift = beforeAgentId !== remoteAgentId;
1279
+ const installationIdDrift = beforeInstallationId !== remoteInstallationId;
1280
+ if (!agentIdDrift && !installationIdDrift) {
1281
+ return ok({
1282
+ ok: true,
1283
+ patched: false,
1284
+ reason: 'no_drift',
1285
+ identity: { agent_id: beforeAgentId, installation_id: beforeInstallationId },
1286
+ });
1287
+ }
1288
+ // installation_id drift 在 client_credentials 模式下意味着客户端凭证完全错位——
1289
+ // client_id / client_secret 还认旧 installation 的 token,但 server 把 installation
1290
+ // 重新绑给了另一个 (auth_principal, auth_issuer) tuple,那 bearer 自己就拉不到 /me。
1291
+ // 这种情况 resync 救不了,得走 hi_agent_install 重建凭证。
1292
+ if (installationIdDrift) {
1293
+ return fail('state_resync_installation_id_drift_unrecoverable', {
1294
+ local_installation_id: beforeInstallationId,
1295
+ remote_installation_id: remoteInstallationId,
1296
+ hint: '本地 installation_id 跟 /me 拿到的 installation 不一致——这说明 client_credentials 凭证已经不再认得当前 installation。resync 只能修 agent_id drift(merge 场景)。需要重新走 hi_agent_install。',
1297
+ });
1298
+ }
1299
+ await persistState((current) => ({
1300
+ ...current,
1301
+ identity: current.identity
1302
+ ? {
1303
+ ...current.identity,
1304
+ agent_id: remoteAgentId,
1305
+ display_name: remoteAgentDisplayName || current.identity.display_name,
1306
+ agent_kind: remoteAgentKind || current.identity.agent_kind,
1307
+ activated_at: remoteActivatedAt || current.identity.activated_at,
1308
+ delivery_capabilities: remoteDeliveryCapabilities || current.identity.delivery_capabilities,
1309
+ }
1310
+ : current.identity,
1311
+ runtime: { ...current.runtime, updated_at: new Date().toISOString() },
1312
+ }));
1313
+ return ok({
1314
+ ok: true,
1315
+ patched: true,
1316
+ reason: 'agent_id_resynced_from_remote',
1317
+ before: { agent_id: beforeAgentId, installation_id: beforeInstallationId },
1318
+ after: { agent_id: remoteAgentId, installation_id: remoteInstallationId },
1319
+ });
1320
+ }
971
1321
  async function handleConnect(args) {
972
1322
  const { gateway, state } = await createAuthorizedClients();
973
1323
  const connected = await gateway.connect({
@@ -1029,14 +1379,124 @@ async function handleActivate(args) {
1029
1379
  contract: activated.contract,
1030
1380
  });
1031
1381
  }
1382
+ // claim-token re-attach (2026-06):登录后"重挂凭单"。export 在已绑手机的 agent 上签发一个一次性
1383
+ // 短过期凭单;redeem 在另一台新设备上消费它,把这台设备挂回同一个 agent。直接打 gateway 的
1384
+ // /v1/agents/claim/* (与 connect/activate 同一 registry base),复用当前请求/凭证换来的 bearer。
1385
+ function claimGatewayBaseUrl(clients) {
1386
+ const wk = clients.wellKnown;
1387
+ return (wk?.platform?.registry_base_url || wk?.registry_base_url || config.platformBaseUrl);
1388
+ }
1389
+ async function handleClaimExport(_args) {
1390
+ const clients = await createAuthorizedClients();
1391
+ const res = await fetch(`${claimGatewayBaseUrl(clients)}/v1/agents/claim/export`, {
1392
+ method: 'POST',
1393
+ headers: { authorization: `Bearer ${clients.accessToken}`, 'content-type': 'application/json' },
1394
+ body: '{}',
1395
+ });
1396
+ const data = (await res.json().catch(() => ({})));
1397
+ if (!res.ok)
1398
+ return fail(String(data?.error || 'claim_export_failed'), data);
1399
+ return ok(data);
1400
+ }
1401
+ async function handleClaimRedeem(args) {
1402
+ const claimToken = normalizeText(args.claim_token);
1403
+ if (!claimToken)
1404
+ return fail('missing_claim_token');
1405
+ const clients = await createAuthorizedClients();
1406
+ const res = await fetch(`${claimGatewayBaseUrl(clients)}/v1/agents/claim/redeem`, {
1407
+ method: 'POST',
1408
+ headers: { authorization: `Bearer ${clients.accessToken}`, 'content-type': 'application/json' },
1409
+ body: JSON.stringify({ claim_token: claimToken }),
1410
+ });
1411
+ const data = (await res.json().catch(() => ({})));
1412
+ if (!res.ok)
1413
+ return fail(String(data?.error || 'claim_redeem_failed'), data);
1414
+ // identity 已在服务端重挂到 data.agent_id;本地 client_credentials 不变,下次调用按 principal
1415
+ // 自然解析到新 agent,无需改本地 state。
1416
+ return ok(data);
1417
+ }
1032
1418
  async function handleStatus(args) {
1033
1419
  const state = await loadPersistedState();
1420
+ const requestAuth = config.authMode === 'oauth' ? getCurrentRequestAuth() : null;
1421
+ if (requestAuth) {
1422
+ const auth = {
1423
+ subject: requestAuth.subjectId,
1424
+ issuer: normalizeText(requestAuth.claims?.iss),
1425
+ audience: requestAuth.claims?.aud ?? null,
1426
+ client_id: normalizeText(requestAuth.claims?.client_id) || null,
1427
+ scope: normalizeText(requestAuth.claims?.scope) || null,
1428
+ };
1429
+ try {
1430
+ const { gateway } = await createAuthorizedClients();
1431
+ const [me, installation, endpoints, subscriptions] = await Promise.all([
1432
+ gateway.me(),
1433
+ gateway.getInstallation(),
1434
+ gateway.listEndpoints(),
1435
+ gateway.listSubscriptions(),
1436
+ ]);
1437
+ const remoteAgent = isPlainObject(me?.agent) ? me.agent : null;
1438
+ const remoteInstallation = isPlainObject(installation?.installation)
1439
+ ? installation.installation
1440
+ : null;
1441
+ const connected = normalizeText(remoteAgent?.agent_id) !== ''
1442
+ && normalizeText(remoteAgent?.status || 'active') !== 'disabled'
1443
+ && normalizeText(remoteInstallation?.installation_id) !== ''
1444
+ && normalizeText(remoteInstallation?.status || 'active') !== 'disabled';
1445
+ const activated = connected && (normalizeText(remoteInstallation?.activated_at) !== ''
1446
+ || normalizeText(remoteInstallation?.status) === 'active');
1447
+ return ok({
1448
+ ok: true,
1449
+ mode: 'remote_oauth',
1450
+ profile: config.profile,
1451
+ state_dir: config.stateDir,
1452
+ state_file: resolveCurrentStateFile(),
1453
+ auth,
1454
+ quarantined_stale_identity: peekQuarantineNotice(),
1455
+ summary: {
1456
+ connected,
1457
+ activated,
1458
+ receiver_config_path: null,
1459
+ receiver_running: false,
1460
+ event_path: remoteInstallation?.delivery_capabilities?.preferred || null,
1461
+ },
1462
+ state,
1463
+ remote: {
1464
+ me,
1465
+ installation,
1466
+ endpoints,
1467
+ subscriptions,
1468
+ },
1469
+ });
1470
+ }
1471
+ catch (error) {
1472
+ const message = error instanceof Error ? error.message : String(error);
1473
+ return ok({
1474
+ ok: true,
1475
+ mode: 'remote_oauth',
1476
+ profile: config.profile,
1477
+ state_dir: config.stateDir,
1478
+ state_file: resolveCurrentStateFile(),
1479
+ auth,
1480
+ quarantined_stale_identity: peekQuarantineNotice(),
1481
+ summary: {
1482
+ connected: false,
1483
+ activated: false,
1484
+ receiver_config_path: null,
1485
+ receiver_running: false,
1486
+ },
1487
+ reason: 'remote_oauth_installation_unavailable',
1488
+ detail: message || 'unknown_error',
1489
+ state,
1490
+ });
1491
+ }
1492
+ }
1034
1493
  if (args.include_remote !== true || !state.identity) {
1035
1494
  return ok({
1036
1495
  ok: true,
1037
1496
  profile: config.profile,
1038
1497
  state_dir: config.stateDir,
1039
1498
  state_file: resolveCurrentStateFile(),
1499
+ quarantined_stale_identity: peekQuarantineNotice(),
1040
1500
  summary: {
1041
1501
  connected: !!state.identity,
1042
1502
  activated: !!state.identity?.activated_at,
@@ -1058,6 +1518,7 @@ async function handleStatus(args) {
1058
1518
  profile: config.profile,
1059
1519
  state_dir: config.stateDir,
1060
1520
  state_file: resolveCurrentStateFile(),
1521
+ quarantined_stale_identity: peekQuarantineNotice(),
1061
1522
  summary: {
1062
1523
  connected: !!state.identity,
1063
1524
  activated: !!installation.installation?.activated_at,
@@ -1090,7 +1551,13 @@ async function handleDoctor(args) {
1090
1551
  session_key: null,
1091
1552
  };
1092
1553
  let openClawStateDirValidation = null;
1093
- if (!state.identity) {
1554
+ // OAuth 多租户模式(Codex / Claude Code 走 /mcp)下,state.identity 故意是 null —— 真
1555
+ // 身份由请求 bearer 决定,没有"per-process 单一 identity"概念(见 createAuthorizedClients
1556
+ // 注释)。doctor 在这种模式下 fallback 走 remote probe 判断 connected/activated,不再把
1557
+ // state.identity 缺失算 blocker,否则每次 hi_agent_install 末尾跑 doctor 都会拿到
1558
+ // `missing_agent_identity` 的假阳性,把刚成功的 OAuth install 标成 fail。
1559
+ const oauthDoctorAuth = config.authMode === 'oauth' ? getCurrentRequestAuth() : null;
1560
+ if (!state.identity && !oauthDoctorAuth) {
1094
1561
  blockers.push('missing_agent_identity');
1095
1562
  }
1096
1563
  if (installState.host_kind === 'openclaw') {
@@ -1107,7 +1574,9 @@ async function handleDoctor(args) {
1107
1574
  }
1108
1575
  }
1109
1576
  }
1110
- if (includeRemote && state.identity) {
1577
+ // OAuth 模式下 state.identity 是 null 也照样有真身份(bearer 在 request 上),所以
1578
+ // 这里的 gate 用 "有 oauth bearer 或者有本地 identity" 表示"能拿到远端".
1579
+ if (includeRemote && (state.identity || oauthDoctorAuth)) {
1111
1580
  try {
1112
1581
  const { gateway } = await createAuthorizedClients();
1113
1582
  const [me, installation, endpoints, subscriptions] = await Promise.all([
@@ -1122,6 +1591,23 @@ async function handleDoctor(args) {
1122
1591
  endpoints: endpoints.endpoints,
1123
1592
  subscriptions: subscriptions.subscriptions,
1124
1593
  };
1594
+ // 2026-05:身份分叉检测。server-side admin consolidate / agentMerge 之后,
1595
+ // agent_installations.agent_id 会被改成 target;但本地 state.identity.agent_id
1596
+ // 还是 merge 前快照——客户端没机制知道自己被合并掉了。表现是 LLM 看到 "我以为
1597
+ // 是 ag_X 但 /me 说我是 ag_Y" 然后开始反复 reset / re-login / 再造孤儿。
1598
+ //
1599
+ // 这里把三个 agent_id 都拉出来对比,任何一对不一致就直接 blocker。提示客户端
1600
+ // 调 hi_agent_state_resync 把本地刷成 remote canonical(不是新 register,那会
1601
+ // 再造一个孤儿)。OAuth 模式下 state.identity 永远是 null,这条 blocker 自动跳过。
1602
+ const localAgentId = normalizeText(state.identity?.agent_id) || null;
1603
+ const remoteMeAgentId = normalizeText(me?.agent?.agent_id) || null;
1604
+ const remoteInstallationAgentId = normalizeText(installation.installation?.agent_id) || null;
1605
+ if (localAgentId && remoteMeAgentId && localAgentId !== remoteMeAgentId) {
1606
+ blockers.push(`agent_identity_split:local=${localAgentId},remote_me=${remoteMeAgentId},remote_installation=${remoteInstallationAgentId || 'unknown'}`);
1607
+ }
1608
+ else if (remoteMeAgentId && remoteInstallationAgentId && remoteMeAgentId !== remoteInstallationAgentId) {
1609
+ warnings.push(`remote_agent_id_mismatch:me=${remoteMeAgentId},installation=${remoteInstallationAgentId}`);
1610
+ }
1125
1611
  const declaration = installation.installation.delivery_capabilities || null;
1126
1612
  const defaultReplyRoute = isPlainObject(declaration?.default_reply_route)
1127
1613
  ? declaration.default_reply_route
@@ -1239,8 +1725,27 @@ async function handleInstall(args) {
1239
1725
  ? await maybeMigrateLegacyState()
1240
1726
  : { migrated: false, from: null, to: resolveCurrentStateFile() };
1241
1727
  let state = await loadPersistedState();
1728
+ // OAuth 多租户模式(Codex / Claude Code 远程 MCP)下,agent 在 `codex mcp login hi`
1729
+ // 这一步就已经被 ensureRemoteOAuthAgentConnection 建好 + 激活了——/mcp 请求带过来的
1730
+ // bearer 已经绑到一条真实存在的 active agent。再走 handleRegister 会在 hi-platform
1731
+ // 多开一条 agent 记录(partial install / activated_at=null),admin panel 看到的就是
1732
+ // "Hi OAuth agent xxx + Codex Hi Agent xxx" 两条;同时邀请归因(channel_code)会被
1733
+ // 写到那条 ghost 上,真正在用的 OAuth agent 完全没有 inviter 归属。
1734
+ //
1735
+ // 修复:OAuth 模式下跳过 handleRegister / handleActivate,把 metadata(含
1736
+ // channel_code)顺到下面那次 gateway.updateInstallation 调用里。gateway
1737
+ // 的 updateAgentInstallation 会把 installation metadata 里的 channel_code
1738
+ // 提升到 agents.metadata_json,admin 现有归因 query 不动也能拿到。
1739
+ const oauthInstallAuth = config.authMode === 'oauth' ? getCurrentRequestAuth() : null;
1740
+ // Snapshot the identity BEFORE handleRegister/handleActivate run so that if
1741
+ // the trailing gateway.updateInstallation call fails, we can roll back to
1742
+ // the actual pre-install baseline. Capturing it any later (e.g. just before
1743
+ // updateInstallation) is a no-op rollback on fresh installs — the snapshot
1744
+ // would be the already-persisted just-registered identity, and the retry
1745
+ // would short-circuit register and loop on the same updateInstallation 404.
1746
+ const preInstallIdentitySnapshot = state.identity;
1242
1747
  let registerPayload = null;
1243
- if (!state.identity) {
1748
+ if (!oauthInstallAuth && !state.identity) {
1244
1749
  const displayName = resolveInstallDisplayName({
1245
1750
  explicitDisplayName: args.display_name,
1246
1751
  hostKind,
@@ -1249,6 +1754,10 @@ async function handleInstall(args) {
1249
1754
  display_name: displayName,
1250
1755
  agent_kind: normalizeText(args.agent_kind) || undefined,
1251
1756
  replace_existing_state: args.replace_existing_state === true,
1757
+ // metadata 必须透传到 register:渠道归因(如 channel_code)走这条路径
1758
+ // 落进 hi-platform 的 agents.metadata_json,被 admin panel 反查邀请人。
1759
+ // 历史 bug:之前这里漏了 metadata,导致 hi_agent_install 的入参直接被吞。
1760
+ metadata: isPlainObject(args.metadata) ? args.metadata : undefined,
1252
1761
  });
1253
1762
  if (registerResult.isError)
1254
1763
  return registerResult;
@@ -1257,11 +1766,57 @@ async function handleInstall(args) {
1257
1766
  : null;
1258
1767
  state = await loadPersistedState();
1259
1768
  }
1769
+ // Helper: roll the on-disk identity back to whatever we held BEFORE this
1770
+ // hi_agent_install turn touched anything. Used by any failure between
1771
+ // handleRegister/handleActivate/updateInstallation and a successful end —
1772
+ // each of those steps can throw if the gateway 404s (the
1773
+ // sub-vs-installation_subject contract mismatch in OAuth multi-tenant mode
1774
+ // means the just-registered installation isn't visible to the bearer that
1775
+ // tries to mutate it). On a fresh install preInstallIdentitySnapshot is
1776
+ // null, so rollback clears identity and the next install retry starts
1777
+ // cleanly from register. On a re-install we restore the prior identity.
1778
+ async function rollbackInstallIdentity() {
1779
+ try {
1780
+ await persistState((current) => ({
1781
+ ...current,
1782
+ identity: preInstallIdentitySnapshot,
1783
+ runtime: { ...current.runtime, updated_at: new Date().toISOString() },
1784
+ }));
1785
+ return true;
1786
+ }
1787
+ catch (rollbackError) {
1788
+ process.stderr.write(`[hi-mcp] install rollback failed: ${String(rollbackError)}\n`);
1789
+ return false;
1790
+ }
1791
+ }
1792
+ function buildInstallStepFailure(stepName, errorMessage, rolledBack) {
1793
+ return fail('install_step_failed', {
1794
+ step: stepName,
1795
+ cause: errorMessage,
1796
+ rolled_back_local_identity: rolledBack,
1797
+ hint: `${stepName} failed against the gateway; local identity has been ${rolledBack ? 'restored to its pre-install baseline' : 'left in an inconsistent state — clear with hi_agent_reset before retrying'}. Any gateway-side agent / installation rows created earlier this turn become orphans (tracked as a TODO in the multi-tenant install path).`,
1798
+ });
1799
+ }
1260
1800
  let activatePayload = null;
1261
- if (state.identity && !state.identity.activated_at) {
1262
- const activateResult = await handleActivate({});
1263
- if (activateResult.isError)
1264
- return activateResult;
1801
+ // OAuth 模式同样跳过 activate —— OAuth agent 在 token 兑换那一刻就是 active 的,
1802
+ // 再走一遍 /v1/agents/activate 既无意义也会拿不到 state.identity 一路 throw。
1803
+ if (!oauthInstallAuth && state.identity && !state.identity.activated_at) {
1804
+ let activateResult;
1805
+ try {
1806
+ activateResult = await handleActivate({});
1807
+ }
1808
+ catch (error) {
1809
+ const message = error instanceof Error ? error.message : String(error);
1810
+ const rolledBack = await rollbackInstallIdentity();
1811
+ return buildInstallStepFailure('activate', message, rolledBack);
1812
+ }
1813
+ if (activateResult.isError) {
1814
+ const rolledBack = await rollbackInstallIdentity();
1815
+ const innerErr = isPlainObject(activateResult.structuredContent)
1816
+ ? activateResult.structuredContent.error
1817
+ : undefined;
1818
+ return buildInstallStepFailure('activate', String(innerErr || 'activate_failed'), rolledBack);
1819
+ }
1265
1820
  activatePayload = isPlainObject(activateResult.structuredContent)
1266
1821
  ? activateResult.structuredContent
1267
1822
  : null;
@@ -1292,9 +1847,46 @@ async function handleInstall(args) {
1292
1847
  routeMissingPolicy,
1293
1848
  defaultReplyRoute,
1294
1849
  });
1295
- const updatedInstallation = await gateway.updateInstallation({
1296
- delivery_capabilities: desiredDeliveryCapabilities,
1297
- });
1850
+ let mergedInstallMetadata = null;
1851
+ if (oauthInstallAuth && isPlainObject(args.metadata) && Object.keys(args.metadata).length > 0) {
1852
+ // gateway 的 updateAgentInstallation 把 metadata 当作"整字段 patch"——给了就替换、
1853
+ // 不给就保留——见 mergeInstallationMetadata。OAuth installation 上本来就有
1854
+ // {provisioned_by, oauth_client_id, oauth_scope, oauth_audience} 这些 bookkeeping
1855
+ // 字段(ensureRemoteOAuthAgentConnection 写的),直接 PUT {channel_code: X} 会把它们
1856
+ // 全冲掉。所以这里先拉一次 installation 把现有 metadata 取出来,再浅合并写回。
1857
+ try {
1858
+ const existingInstallation = await gateway.getInstallation();
1859
+ const existing = isPlainObject(existingInstallation?.installation?.metadata)
1860
+ ? existingInstallation.installation.metadata
1861
+ : {};
1862
+ mergedInstallMetadata = { ...existing, ...args.metadata };
1863
+ }
1864
+ catch (error) {
1865
+ // 拉不到现有 installation 不应该阻断 install——退化成只写 caller 传的 metadata,
1866
+ // 最坏情况只是 oauth bookkeeping 字段被清空,gateway 仍然能从 agent_installations 行
1867
+ // 重新推导身份。channel_code 仍然落到 installation 上,归因 query 不受影响。
1868
+ process.stderr.write(`[hi-mcp] failed to fetch existing installation metadata before update: ${String(error)}\n`);
1869
+ mergedInstallMetadata = { ...args.metadata };
1870
+ }
1871
+ }
1872
+ let updatedInstallation;
1873
+ try {
1874
+ // OAuth 路径下把 install metadata(含 channel_code 渠道归因)顺过来 ——
1875
+ // 不再走 register,更新存量 OAuth installation 的 metadata 是唯一落点。
1876
+ // gateway 端 updateAgentInstallation 会把白名单键(channel_code)再 promote 到
1877
+ // agents.metadata_json,admin panel 的归因 query 沿用原路径。
1878
+ // OpenClaw 路径下,metadata 已经在 handleRegister 里写过 agent + installation 了,
1879
+ // 这里就不再重复透传,避免把 install metadata patch 成空 / 覆盖既有值。
1880
+ updatedInstallation = await gateway.updateInstallation({
1881
+ delivery_capabilities: desiredDeliveryCapabilities,
1882
+ ...(mergedInstallMetadata ? { metadata: mergedInstallMetadata } : {}),
1883
+ });
1884
+ }
1885
+ catch (error) {
1886
+ const message = error instanceof Error ? error.message : String(error);
1887
+ const rolledBack = await rollbackInstallIdentity();
1888
+ return buildInstallStepFailure('update_installation', message, rolledBack);
1889
+ }
1298
1890
  await persistState((current) => ({
1299
1891
  ...current,
1300
1892
  identity: current.identity
@@ -1416,18 +2008,84 @@ async function handleInstall(args) {
1416
2008
  include_remote: true,
1417
2009
  probe_delivery: enableLocalReceiver,
1418
2010
  });
2011
+ // 2026-05:post-install welcome onboarding。
2012
+ //
2013
+ // 业界 SaaS / 对话式 AI onboarding 共识(Build context, Ask intent EARLY, Show populated
2014
+ // state preview, Single clear next action)+ 我们 prod 数据观察(10 个新装 owner 里只有
2015
+ // 一半发了 friendship listing,剩下一半实际意图是招聘 / 找房 / 合伙人)共同推出的同步
2016
+ // welcome 路径——install 末尾立即把"问 owner 想找什么样的人"的 instruction + 跨频道
2017
+ // recent_activity preview + intent_options 选项嵌入工具 result。
2018
+ //
2019
+ // 同步路径的目的是让**新装**用户一气呵成不依赖 push 链路(push worker 5-15 秒才扫到)。
2020
+ // **存量**用户由 platform 端 bootstrapOnboardingFanOutWorker 异步覆盖;两路 dedup 信号
2021
+ // 是 owner listing 状态(push instruction 里明确要求收到时先调 agent_listings.list)。
2022
+ //
2023
+ // 失败 fail-soft:拉 recent_activity 的 capability 调用任何环节失败都不影响 install
2024
+ // 主流程返回 ok(welcome 是 instruction + intent_options 为核心,recent_activity 只是
2025
+ // populated state 增强)。welcome.recent_activity_error 字段把失败原因留给 LLM 知道。
2026
+ let welcome = null;
2027
+ try {
2028
+ // 仅在 doctor pass 之后才做 welcome:identity 没建好 / installation 没激活时
2029
+ // 调 capability 必然 401/403,没意义且会污染 install result。
2030
+ const doctorBody = doctor?.structuredContent;
2031
+ const doctorOk = !!(doctorBody && doctorBody.ok && doctorBody.activated);
2032
+ if (doctorOk) {
2033
+ let recentActivity = [];
2034
+ let recentActivityError = null;
2035
+ try {
2036
+ const { platform } = await createAuthorizedClients();
2037
+ const callResult = await platform.callCapability('hi.agent-listings', {
2038
+ action: 'browse_recent',
2039
+ limit: 8,
2040
+ });
2041
+ const items = callResult?.result?.items;
2042
+ if (Array.isArray(items)) {
2043
+ recentActivity = items
2044
+ .filter((it) => it && typeof it === 'object')
2045
+ .map((it) => ({
2046
+ listing_id: String(it.listing_id || ''),
2047
+ listing_type_id: String(it.listing_type_id || ''),
2048
+ published_by_agent_id: String(it.published_by_agent_id || ''),
2049
+ target_preview_text: String(it.target_preview_text || ''),
2050
+ listing_created_at: String(it.listing_created_at || ''),
2051
+ }))
2052
+ .filter((it) => it.listing_id && it.target_preview_text);
2053
+ }
2054
+ else {
2055
+ recentActivityError = 'browse_recent_returned_no_items_array';
2056
+ }
2057
+ }
2058
+ catch (err) {
2059
+ recentActivityError = String(err?.message || err || 'browse_recent_failed').slice(0, 240);
2060
+ }
2061
+ welcome = {
2062
+ kind: INSTALL_WELCOME_ONBOARDING_KIND,
2063
+ instruction_to_llm: INSTALL_WELCOME_ONBOARDING_INSTRUCTION,
2064
+ recent_activity: recentActivity,
2065
+ intent_options: [...DEFAULT_INTENT_OPTIONS],
2066
+ ...(recentActivityError ? { recent_activity_error: recentActivityError } : {}),
2067
+ };
2068
+ }
2069
+ }
2070
+ catch {
2071
+ // welcome 整体失败也 fail-soft:install 主流程返回 ok=true,welcome=null 让 LLM
2072
+ // fallback 到自然行为(拿到 install ok 后给 owner 一句"装好了"),不阻断 install。
2073
+ welcome = null;
2074
+ }
1419
2075
  return ok({
1420
2076
  ok: true,
1421
2077
  profile: config.profile,
1422
2078
  state_dir: config.stateDir,
1423
2079
  state_file: resolveCurrentStateFile(),
1424
2080
  migrated_legacy_state: migration,
2081
+ quarantined_stale_identity: peekQuarantineNotice(),
1425
2082
  register: registerPayload,
1426
2083
  activate: activatePayload,
1427
2084
  installation: updatedInstallation,
1428
2085
  subscriptions: subscriptionsPayload,
1429
2086
  receiver: receiverPayload,
1430
2087
  doctor: doctor?.structuredContent || null,
2088
+ welcome,
1431
2089
  });
1432
2090
  }
1433
2091
  async function handleReset(args) {
@@ -1654,7 +2312,7 @@ async function handleEventsWait(args) {
1654
2312
  async function handleCapabilityTool(name, args) {
1655
2313
  const { platform } = await createAuthorizedClients();
1656
2314
  const capabilities = await loadCapabilities();
1657
- const capability = capabilities.find((item) => item.tool_name === name);
2315
+ const capability = capabilities.find((item) => item.tool_name === name && isToolVisibleOnSurface(item.tool_name));
1658
2316
  if (!capability)
1659
2317
  return fail('unknown_hi_capability_tool', { name });
1660
2318
  const out = await platform.callCapability(capability.capability_id, args);
@@ -1662,8 +2320,8 @@ async function handleCapabilityTool(name, args) {
1662
2320
  ok: true,
1663
2321
  capability_id: capability.capability_id,
1664
2322
  tool_name: capability.tool_name,
1665
- result: out.result,
1666
- }, jsonText(out));
2323
+ result: out.result ?? null,
2324
+ });
1667
2325
  }
1668
2326
  async function handleControlTool(name, args) {
1669
2327
  switch (name) {
@@ -1677,10 +2335,16 @@ async function handleControlTool(name, args) {
1677
2335
  return await handleReset(args);
1678
2336
  case 'hi_agent_register':
1679
2337
  return await handleRegister(args);
2338
+ case 'hi_agent_state_resync':
2339
+ return await handleStateResync(args);
1680
2340
  case 'hi_agent_connect':
1681
2341
  return await handleConnect(args);
1682
2342
  case 'hi_agent_activate':
1683
2343
  return await handleActivate(args);
2344
+ case 'hi_agent_claim_export':
2345
+ return await handleClaimExport(args);
2346
+ case 'hi_agent_claim_redeem':
2347
+ return await handleClaimRedeem(args);
1684
2348
  case 'hi_agent_installation_get':
1685
2349
  return await handleInstallationGet();
1686
2350
  case 'hi_agent_installation_update':
@@ -1707,11 +2371,78 @@ async function handleControlTool(name, args) {
1707
2371
  return fail('unknown_control_tool', { name });
1708
2372
  }
1709
2373
  }
2374
+ // MCP tool annotations for hi-mcp-server's hi_agent_* control tools.
2375
+ // OpenAI Apps SDK review hard-requires readOnlyHint/openWorldHint/
2376
+ // destructiveHint on every tool surfaced by an MCP server; Missing on any
2377
+ // of these blocks Submit. Business tools get their annotations from the
2378
+ // hi-platform public capability catalog (forwarded below); the control
2379
+ // tools are defined locally here so the annotations live here too.
2380
+ //
2381
+ // Semantics:
2382
+ // readOnlyHint: only reads server state, no mutation
2383
+ // openWorldHint: reaches outside Hi's own systems (real people via SMS/
2384
+ // webhook, real phone calls, third-party APIs, etc.)
2385
+ // destructiveHint: effect is non-reversible (data wipe, message that can't
2386
+ // be unsent). Only meaningful when readOnlyHint=false.
2387
+ const CONTROL_TOOL_ANNOTATIONS = {
2388
+ // Pure local-state reads. Even include_remote:true is a read-only GET
2389
+ // against the gateway.
2390
+ hi_agent_status: { readOnlyHint: true, openWorldHint: false, destructiveHint: false, title: 'Hi agent status' },
2391
+ // Creates a new agent identity, writes local state, optionally starts a
2392
+ // local receiver. All effects stay inside Hi infrastructure.
2393
+ hi_agent_install: { readOnlyHint: false, openWorldHint: false, destructiveHint: false, title: 'Hi agent install' },
2394
+ // Diagnostic. probe_delivery sends a synthetic test event targeting the
2395
+ // agent's own configured route; not third-party-visible.
2396
+ hi_agent_doctor: { readOnlyHint: true, openWorldHint: false, destructiveHint: false, title: 'Hi agent doctor' },
2397
+ // Wipes local persisted identity + state. Non-recoverable for this profile.
2398
+ hi_agent_reset: { readOnlyHint: false, openWorldHint: false, destructiveHint: true, title: 'Hi agent reset' },
2399
+ // Creates a brand new agent + installation at the gateway. Additive.
2400
+ hi_agent_register: { readOnlyHint: false, openWorldHint: false, destructiveHint: false, title: 'Hi agent register' },
2401
+ // Reads gateway /me + /installation and patches local state.identity to match.
2402
+ // Pure local-state write; no gateway mutation. Idempotent.
2403
+ hi_agent_state_resync: { readOnlyHint: false, openWorldHint: false, destructiveHint: false, title: 'Hi agent state resync' },
2404
+ // Binds local profile to an existing remote agent_id. Additive.
2405
+ hi_agent_connect: { readOnlyHint: false, openWorldHint: false, destructiveHint: false, title: 'Hi agent connect' },
2406
+ // Activates the installation. Additive.
2407
+ hi_agent_activate: { readOnlyHint: false, openWorldHint: false, destructiveHint: false, title: 'Hi agent activate' },
2408
+ hi_agent_claim_export: { readOnlyHint: false, openWorldHint: false, destructiveHint: false, title: 'Claim export' },
2409
+ hi_agent_claim_redeem: { readOnlyHint: false, openWorldHint: false, destructiveHint: false, title: 'Claim redeem' },
2410
+ hi_agent_installation_get: { readOnlyHint: true, openWorldHint: false, destructiveHint: false, title: 'Installation get' },
2411
+ hi_agent_installation_update: { readOnlyHint: false, openWorldHint: false, destructiveHint: false, title: 'Installation update' },
2412
+ hi_agent_endpoints_list: { readOnlyHint: true, openWorldHint: false, destructiveHint: false, title: 'Endpoints list' },
2413
+ hi_agent_endpoints_upsert: { readOnlyHint: false, openWorldHint: false, destructiveHint: false, title: 'Endpoints upsert' },
2414
+ hi_agent_subscriptions_list: { readOnlyHint: true, openWorldHint: false, destructiveHint: false, title: 'Subscriptions list' },
2415
+ hi_agent_subscriptions_upsert: { readOnlyHint: false, openWorldHint: false, destructiveHint: false, title: 'Subscriptions upsert' },
2416
+ // Sends a test event through the configured delivery profile, which may
2417
+ // hit a public webhook URL the operator registered. Treat as open-world.
2418
+ hi_agent_test_delivery: { readOnlyHint: false, openWorldHint: true, destructiveHint: false, title: 'Test delivery' },
2419
+ // Acquires a queue lease on the durable event queue. Changes server-side
2420
+ // lease state but not externally observable / not destructive.
2421
+ hi_agent_events_claim: { readOnlyHint: false, openWorldHint: false, destructiveHint: false, title: 'Events claim' },
2422
+ // Read a claimed event's payload. Pure read.
2423
+ hi_agent_event_fetch: { readOnlyHint: true, openWorldHint: false, destructiveHint: false, title: 'Event fetch' },
2424
+ // Advances persisted cursor + marks events consumed. Not destructive
2425
+ // (consumer-side commit is expected normal operation).
2426
+ hi_agent_events_ack: { readOnlyHint: false, openWorldHint: false, destructiveHint: false, title: 'Events ack' },
2427
+ // Long-poll claim + optional auto-ack. Changes queue state via ack.
2428
+ hi_agent_events_wait: { readOnlyHint: false, openWorldHint: false, destructiveHint: false, title: 'Events wait' },
2429
+ };
1710
2430
  async function listTools() {
1711
2431
  const capabilities = await loadCapabilities();
2432
+ const toolSurface = effectiveToolSurface();
2433
+ const controlSurfaceTools = toolSurface === 'chatgpt_app' ? [] : controlTools();
2434
+ const capabilitySurfaceTools = capabilities.filter((capability) => isToolVisibleOnSurface(capability.tool_name));
1712
2435
  return [
1713
- ...controlTools(),
1714
- ...capabilities.map((capability) => ({
2436
+ ...controlSurfaceTools.map((tool) => ({
2437
+ ...tool,
2438
+ // Annotations are required by OpenAI Apps SDK review. Default to a
2439
+ // conservative "false / false / false" if a control tool ever gets
2440
+ // added here without an entry in CONTROL_TOOL_ANNOTATIONS, so review
2441
+ // still passes (the human reviewer can audit the conservative fallback).
2442
+ annotations: CONTROL_TOOL_ANNOTATIONS[tool.name]
2443
+ || { readOnlyHint: false, openWorldHint: false, destructiveHint: false },
2444
+ })),
2445
+ ...capabilitySurfaceTools.map((capability) => ({
1715
2446
  name: capability.tool_name,
1716
2447
  title: capability.title || capability.tool_name,
1717
2448
  description: capability.description || capability.tool_name,
@@ -1719,13 +2450,19 @@ async function listTools() {
1719
2450
  inputSchema: isPlainObject(capability.parameters)
1720
2451
  ? capability.parameters
1721
2452
  : { type: 'object', properties: {} },
2453
+ outputSchema: CAPABILITY_TOOL_OUTPUT_SCHEMA,
2454
+ // Business tools get their annotations from hi-platform's catalog
2455
+ // (set in hi-platform/src/mcp/schema/tools.ts). Fall back to a
2456
+ // conservative default for any capability missing them.
2457
+ annotations: capability.annotations
2458
+ || { readOnlyHint: false, openWorldHint: false, destructiveHint: false },
1722
2459
  })),
1723
2460
  ];
1724
2461
  }
1725
2462
  function createMcpServer() {
1726
2463
  const server = new Server({
1727
2464
  name: 'hi-mcp-server',
1728
- version: '0.1.7',
2465
+ version: pkgJson.version,
1729
2466
  }, {
1730
2467
  capabilities: {
1731
2468
  tools: {},
@@ -1741,6 +2478,12 @@ function createMcpServer() {
1741
2478
  const name = normalizeText(request.params.name);
1742
2479
  const args = normalizeRecord(request.params.arguments);
1743
2480
  try {
2481
+ if (!isToolVisibleOnSurface(name)) {
2482
+ return fail('tool_not_available_on_surface', {
2483
+ name,
2484
+ tool_surface: effectiveToolSurface(),
2485
+ });
2486
+ }
1744
2487
  const controlToolNames = new Set(controlTools().map((tool) => tool.name));
1745
2488
  return controlToolNames.has(name)
1746
2489
  ? await handleControlTool(name, args)
@@ -1752,13 +2495,171 @@ function createMcpServer() {
1752
2495
  });
1753
2496
  return server;
1754
2497
  }
2498
+ async function handleMcpRequest(req, res) {
2499
+ const server = createMcpServer();
2500
+ const transport = new StreamableHTTPServerTransport({
2501
+ sessionIdGenerator: undefined,
2502
+ });
2503
+ try {
2504
+ await server.connect(transport);
2505
+ await transport.handleRequest(req, res, req.body);
2506
+ res.on('close', () => {
2507
+ void transport.close().catch(() => undefined);
2508
+ void server.close().catch(() => undefined);
2509
+ });
2510
+ }
2511
+ catch (error) {
2512
+ if (!res.headersSent) {
2513
+ res.status(500).json({
2514
+ jsonrpc: '2.0',
2515
+ error: {
2516
+ code: -32603,
2517
+ message: normalizeText(error?.message) || 'internal_server_error',
2518
+ },
2519
+ id: null,
2520
+ });
2521
+ }
2522
+ }
2523
+ }
1755
2524
  async function runHttpServer() {
1756
2525
  const app = createMcpExpressApp({ host: config.host });
2526
+ // CORS — every request gets permissive headers + preflight short-circuit.
2527
+ // Why: claude.ai's web connector (and other browser-based MCP UIs) talks
2528
+ // to /mcp from a https://claude.ai origin. Without these the browser
2529
+ // silently drops responses; the connector reports a generic "Couldn't
2530
+ // reach the MCP server" error even though the server is reachable.
2531
+ // Bearer-auth-only, no cookies, so wildcard origin is safe (we cannot
2532
+ // combine `*` with credentials, which we never set).
2533
+ // WWW-Authenticate MUST be in Expose-Headers so browser JS can read the
2534
+ // RFC 9728 resource_metadata hint on a 401 and start the OAuth flow.
2535
+ app.use((req, res, next) => {
2536
+ res.setHeader('Access-Control-Allow-Origin', '*');
2537
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
2538
+ res.setHeader('Access-Control-Allow-Headers', 'Authorization, Content-Type, Accept, Mcp-Session-Id, Mcp-Protocol-Version, Last-Event-ID');
2539
+ res.setHeader('Access-Control-Expose-Headers', 'WWW-Authenticate, Mcp-Session-Id, Mcp-Protocol-Version');
2540
+ res.setHeader('Access-Control-Max-Age', '86400');
2541
+ res.setHeader('Vary', 'Origin');
2542
+ if (req.method === 'OPTIONS') {
2543
+ res.status(204).end();
2544
+ return;
2545
+ }
2546
+ next();
2547
+ });
2548
+ // RFC 9728 — Protected Resource Metadata. Codex / Claude Code discover
2549
+ // the Authorization Server from here after the first 401 challenge.
2550
+ // Public surface, no auth, cached briefly.
2551
+ //
2552
+ // Multi-host: each configured resource URL gets its own PRM payload so a
2553
+ // request that arrives via mcp.hirey.ai sees `resource:
2554
+ // "https://mcp.hirey.ai/mcp"` while a request via the legacy
2555
+ // hi.hirey.ai alias sees `resource: "https://hi.hirey.ai/mcp"`. The
2556
+ // alternative — a single PRM advertising the canonical URL on the alias
2557
+ // host — fails RFC 9728 §3.3 strict clients (Codex included) because the
2558
+ // resource URL no longer matches the host that served the metadata.
2559
+ const protectedResourceMetadataPath = '/.well-known/oauth-protected-resource';
2560
+ const oauthResourceTable = config.authMode === 'oauth'
2561
+ ? config.oauthMcpResources.map((resourceUrl) => {
2562
+ const trimmed = resourceUrl.replace(/\/+$/, '');
2563
+ const baseNoMcp = trimmed.replace(/\/mcp$/, '');
2564
+ const host = new URL(resourceUrl).host.toLowerCase();
2565
+ return {
2566
+ resource: resourceUrl,
2567
+ host,
2568
+ protectedResourceMetadataUrl: `${baseNoMcp}${protectedResourceMetadataPath}`,
2569
+ metadata: buildProtectedResourceMetadata({
2570
+ resource: resourceUrl,
2571
+ authorizationServer: config.oauthAuthorizationServer,
2572
+ scopes: config.oauthScopesSupported,
2573
+ resourceDocumentationUrl: `${config.platformBaseUrl.replace(/\/+$/, '')}/docs/mcp`,
2574
+ }),
2575
+ };
2576
+ })
2577
+ : [];
2578
+ // Pick the resource entry whose hostname matches the inbound request, so
2579
+ // a 401 challenge served on mcp.hirey.ai points back at
2580
+ // mcp.hirey.ai/.well-known/oauth-protected-resource. Falls back to the
2581
+ // primary (index 0) when the request host isn't in the table — that path
2582
+ // matters for direct cluster-internal probes and dev curl calls without
2583
+ // a Host header.
2584
+ function resolveOAuthResource(req) {
2585
+ if (oauthResourceTable.length === 0)
2586
+ return null;
2587
+ const hostHeader = String(req.headers.host || '').toLowerCase().split(':')[0];
2588
+ return oauthResourceTable.find((e) => e.host === hostHeader) || oauthResourceTable[0];
2589
+ }
2590
+ if (config.authMode === 'oauth') {
2591
+ app.get(protectedResourceMetadataPath, (req, res) => {
2592
+ const entry = resolveOAuthResource(req);
2593
+ if (!entry) {
2594
+ res.status(503).json({ error: 'oauth_resource_unconfigured' });
2595
+ return;
2596
+ }
2597
+ res.setHeader('Cache-Control', 'public, max-age=300');
2598
+ res.json(entry.metadata);
2599
+ });
2600
+ const authorizationServerMetadataAlias = buildAuthorizationServerMetadataAlias({
2601
+ issuer: config.oauthIssuer,
2602
+ authorizationServer: config.oauthAuthorizationServer,
2603
+ scopes: config.oauthScopesSupported,
2604
+ serviceDocumentationUrl: `${config.oauthAuthorizationServer.replace(/\/+$/, '')}/docs/oauth`,
2605
+ });
2606
+ for (const metadataPath of [
2607
+ '/.well-known/oauth-authorization-server',
2608
+ '/.well-known/oauth-authorization-server/mcp',
2609
+ '/.well-known/oauth-authorization-server/mcp/chatgpt',
2610
+ '/mcp/.well-known/oauth-authorization-server',
2611
+ '/mcp/chatgpt/.well-known/oauth-authorization-server',
2612
+ ]) {
2613
+ app.get(metadataPath, (_req, res) => {
2614
+ res.setHeader('Cache-Control', 'public, max-age=300');
2615
+ res.json(authorizationServerMetadataAlias);
2616
+ });
2617
+ }
2618
+ }
2619
+ // Per-request OAuth verifier — JWKS is cached inside the closure so we
2620
+ // pay the network round trip once, not per request. Skipped entirely in
2621
+ // 'local' mode so stdio + legacy installs incur zero overhead.
2622
+ const oauthVerifier = config.authMode === 'oauth'
2623
+ ? buildOAuthVerifier({
2624
+ // JWT `iss` check — must match what hi-auth signs into tokens. In
2625
+ // prod this is the legacy issuer (hi.hirey.ai) so OpenClaw and
2626
+ // every other client_credentials caller keep working. Discovery
2627
+ // metadata can live on a different host (oauthAuthorizationServer).
2628
+ issuer: config.oauthIssuer,
2629
+ // Audience check accepts ANY of the configured resource URLs.
2630
+ // jose.jwtVerify with array audience matches if at least one is
2631
+ // present in the token's `aud` claim (RFC 7519 §4.1.3).
2632
+ audience: config.oauthMcpResources,
2633
+ })
2634
+ : null;
2635
+ function send401(req, res, description, error = 'invalid_token') {
2636
+ if (config.authMode === 'oauth') {
2637
+ const entry = resolveOAuthResource(req);
2638
+ const challenge = buildWwwAuthenticateChallenge({
2639
+ protectedResourceMetadataUrl: entry?.protectedResourceMetadataUrl || '',
2640
+ scope: config.oauthScopesSupported,
2641
+ error,
2642
+ errorDescription: description,
2643
+ });
2644
+ res.setHeader('WWW-Authenticate', challenge);
2645
+ }
2646
+ res.status(401).json({
2647
+ jsonrpc: '2.0',
2648
+ error: {
2649
+ code: -32001,
2650
+ message: error,
2651
+ ...(description ? { data: { description } } : {}),
2652
+ },
2653
+ id: null,
2654
+ });
2655
+ }
1757
2656
  app.get('/healthz', (_req, res) => {
1758
2657
  res.json({
1759
2658
  ok: true,
1760
2659
  profile: config.profile,
1761
2660
  transport: config.transport,
2661
+ auth_mode: config.authMode,
2662
+ tool_surface: config.toolSurface,
1762
2663
  });
1763
2664
  });
1764
2665
  // readiness 必须确认上游 discovery 可达;否则 pod 虽然活着,但实际上没法服务真实 MCP 请求。
@@ -1769,6 +2670,8 @@ async function runHttpServer() {
1769
2670
  ok: true,
1770
2671
  profile: config.profile,
1771
2672
  transport: config.transport,
2673
+ auth_mode: config.authMode,
2674
+ tool_surface: config.toolSurface,
1772
2675
  registry_base_url: wellKnown.platform.registry_base_url,
1773
2676
  });
1774
2677
  }
@@ -1779,52 +2682,61 @@ async function runHttpServer() {
1779
2682
  });
1780
2683
  }
1781
2684
  });
1782
- app.post('/mcp', async (req, res) => {
1783
- const server = createMcpServer();
1784
- const transport = new StreamableHTTPServerTransport({
1785
- sessionIdGenerator: undefined,
2685
+ function runMcpRequest(req, res, toolSurface) {
2686
+ requestToolSurfaceStorage.run(toolSurface, () => {
2687
+ void handleMcpRequest(req, res);
1786
2688
  });
1787
- try {
1788
- await server.connect(transport);
1789
- await transport.handleRequest(req, res, req.body);
1790
- res.on('close', () => {
1791
- void transport.close().catch(() => undefined);
1792
- void server.close().catch(() => undefined);
1793
- });
1794
- }
1795
- catch (error) {
1796
- if (!res.headersSent) {
1797
- res.status(500).json({
1798
- jsonrpc: '2.0',
1799
- error: {
1800
- code: -32603,
1801
- message: normalizeText(error?.message) || 'internal_server_error',
1802
- },
1803
- id: null,
1804
- });
2689
+ }
2690
+ async function handleMcpPost(req, res, toolSurface = null) {
2691
+ // OAuth-mode bearer enforcement. Stdio path and 'local' HTTP path keep
2692
+ // the existing single-tenant disk identity model untouched.
2693
+ if (config.authMode === 'oauth' && oauthVerifier) {
2694
+ const outcome = await verifyRequestBearer(req, oauthVerifier);
2695
+ if (!outcome.ok) {
2696
+ return send401(req, res, outcome.description);
1805
2697
  }
2698
+ requestAuthStorage.run(outcome.auth, () => {
2699
+ runMcpRequest(req, res, toolSurface);
2700
+ });
2701
+ return;
1806
2702
  }
2703
+ runMcpRequest(req, res, toolSurface);
2704
+ }
2705
+ // Dedicated ChatGPT App / Apps SDK review surface. Keep `/mcp` as the
2706
+ // full Codex/OpenClaw/Claude-compatible endpoint so existing remote MCP
2707
+ // users do not lose onboarding and event tools.
2708
+ app.post('/mcp/chatgpt', async (req, res) => {
2709
+ await handleMcpPost(req, res, 'chatgpt_app');
1807
2710
  });
1808
- app.get('/mcp', (_req, res) => {
1809
- res.status(405).json({
1810
- jsonrpc: '2.0',
1811
- error: {
1812
- code: -32000,
1813
- message: 'Method not allowed.',
1814
- },
1815
- id: null,
1816
- });
2711
+ app.post('/mcp', async (req, res) => {
2712
+ await handleMcpPost(req, res);
1817
2713
  });
1818
- app.delete('/mcp', (_req, res) => {
2714
+ // A GET/DELETE to the MCP endpoint targets a protected resource. When the
2715
+ // request is unauthenticated in oauth mode, answer with the 401 +
2716
+ // WWW-Authenticate challenge — same as the POST path — so a client that
2717
+ // probes the endpoint via GET (rather than an initialize POST) can still
2718
+ // discover the OAuth protected-resource metadata, instead of bouncing off a
2719
+ // bare 405 that hides the auth requirement. The streamable-HTTP transport is
2720
+ // stateless and only serves POST, so an *authenticated* GET/DELETE still
2721
+ // legitimately gets 405 (method not allowed for this transport).
2722
+ const methodNotAllowed = (res) => {
1819
2723
  res.status(405).json({
1820
2724
  jsonrpc: '2.0',
1821
- error: {
1822
- code: -32000,
1823
- message: 'Method not allowed.',
1824
- },
2725
+ error: { code: -32000, message: 'Method not allowed.' },
1825
2726
  id: null,
1826
2727
  });
1827
- });
2728
+ };
2729
+ const challengeOrMethodNotAllowed = async (req, res) => {
2730
+ if (config.authMode === 'oauth' && oauthVerifier) {
2731
+ const outcome = await verifyRequestBearer(req, oauthVerifier);
2732
+ if (!outcome.ok) {
2733
+ return send401(req, res, outcome.description);
2734
+ }
2735
+ }
2736
+ methodNotAllowed(res);
2737
+ };
2738
+ app.get(['/mcp', '/mcp/chatgpt'], challengeOrMethodNotAllowed);
2739
+ app.delete(['/mcp', '/mcp/chatgpt'], challengeOrMethodNotAllowed);
1828
2740
  const httpServer = createServer(app);
1829
2741
  await new Promise((resolve) => {
1830
2742
  httpServer.listen(config.port, config.host, () => resolve());