@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/README.md +20 -0
- package/dist/defaultReplyRoute.d.ts +4 -2
- package/dist/defaultReplyRoute.d.ts.map +1 -1
- package/dist/defaultReplyRoute.js +3 -0
- package/dist/installDefaults.d.ts +2 -1
- package/dist/installDefaults.d.ts.map +1 -1
- package/dist/installDefaults.js +14 -3
- package/dist/oauthRequestAuth.d.ts +67 -0
- package/dist/oauthRequestAuth.d.ts.map +1 -0
- package/dist/oauthRequestAuth.js +117 -0
- package/dist/receiver-command.d.ts +2 -1
- package/dist/receiver-command.d.ts.map +1 -1
- package/dist/server.d.ts +2 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +977 -65
- package/dist/state.d.ts +25 -0
- package/dist/state.d.ts.map +1 -1
- package/dist/state.js +91 -0
- package/node_modules/@hirey/hi-agent-contracts/dist/index.d.ts +51 -1
- package/node_modules/@hirey/hi-agent-contracts/dist/index.d.ts.map +1 -1
- package/node_modules/@hirey/hi-agent-contracts/dist/index.js +93 -0
- package/node_modules/@hirey/hi-agent-contracts/package.json +1 -1
- package/node_modules/@hirey/hi-agent-sdk/dist/client.d.ts +2 -1
- package/node_modules/@hirey/hi-agent-sdk/dist/client.d.ts.map +1 -1
- package/node_modules/@hirey/hi-agent-sdk/dist/client.js +94 -7
- package/node_modules/@hirey/hi-agent-sdk/package.json +2 -2
- package/package.json +3 -3
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: "
|
|
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
|
-
|
|
555
|
+
const profile = effectiveStateProfile();
|
|
556
|
+
const raw = await readState({
|
|
393
557
|
stateDir: config.stateDir,
|
|
394
|
-
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:
|
|
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:
|
|
634
|
+
profile: effectiveStateProfile(),
|
|
437
635
|
});
|
|
438
636
|
}
|
|
439
637
|
function normalizeHostKind(raw) {
|
|
440
|
-
|
|
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
|
|
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
|
-
|
|
828
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
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
|
-
|
|
1296
|
-
|
|
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
|
-
}
|
|
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
|
-
...
|
|
1714
|
-
|
|
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:
|
|
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
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
sessionIdGenerator: undefined,
|
|
2685
|
+
function runMcpRequest(req, res, toolSurface) {
|
|
2686
|
+
requestToolSurfaceStorage.run(toolSurface, () => {
|
|
2687
|
+
void handleMcpRequest(req, res);
|
|
1786
2688
|
});
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
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.
|
|
1809
|
-
res
|
|
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
|
-
|
|
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());
|