@seasonkoh/webaz 0.1.27 → 0.1.28

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 CHANGED
@@ -79,7 +79,7 @@ WebAZ deliberately separates **principle** (permanent) from **mechanism** (tunab
79
79
  - Money flows are fully parameterized and on-chain-auditable. The operator earns only an explicit platform fee + risk-event slashing — most of it rerouted to a public-good fund. Full walk-through: [`docs/ECONOMIC-MODEL.md`](docs/ECONOMIC-MODEL.md).
80
80
  - The constitution (meta-rules) and amendment thresholds live in [`docs/CHARTER.md`](docs/CHARTER.md) / [`docs/META-RULES-FULL.md`](docs/META-RULES-FULL.md).
81
81
 
82
- > **No MLM / matching-reward engine.** The binary/PV matching-reward engine has been **excised** from the public code (`src/pwa/internal/pv-settlement.ts` is a permanent no-op stub). PV is a **participation record only — not income, not redeemable, no entitlement.** See [`docs/REWARD-ENGINES-DECOUPLING.md`](docs/REWARD-ENGINES-DECOUPLING.md).
82
+ > Matching rewards are disabled by default; PV / position is a participation record only — not income, not redeemable, no entitlement. See [`docs/REWARD-ENGINES-DECOUPLING.md`](docs/REWARD-ENGINES-DECOUPLING.md).
83
83
 
84
84
  **Fund split** (current `protocol_params` defaults, DAO-adjustable; 100-unit shop order):
85
85
 
@@ -0,0 +1,69 @@
1
+ /**
2
+ * RFC-003 — MCP NETWORK-mode tool gating (pure, testable; no I/O).
3
+ *
4
+ * In `network` / `network_readonly` mode the dispatcher hard-fails any tool that is
5
+ * NOT allowed through, so an un-migrated tool can't silently fall back to the local
6
+ * sandbox (a write would become a phantom op). This module holds the allow-sets +
7
+ * the predicate so the gate decision is unit-testable without booting the stdio
8
+ * server. (Extracted from server.ts; behavior unchanged except adding webaz_pair.)
9
+ */
10
+ // Tools that talk to the live webaz.xyz network (Bearer api_key where needed).
11
+ // Un-listed tools run sandbox (local). `_mode` annotation = network for these.
12
+ export const NETWORK_TOOLS = new Set([
13
+ // RFC-020 onboarding/auth: pairing is the no-key credential bootstrap — it MUST be
14
+ // reachable in the default network_readonly install (you pair to GET a credential).
15
+ 'webaz_pair',
16
+ 'webaz_price_history',
17
+ 'webaz_leaderboard',
18
+ 'webaz_verify_price',
19
+ 'webaz_place_order',
20
+ 'webaz_list_product',
21
+ 'webaz_update_order',
22
+ 'webaz_search',
23
+ 'webaz_get_status',
24
+ 'webaz_feedback',
25
+ 'webaz_contribute',
26
+ // Batch 1(只读 + 低危自身写):走 webaz.xyz Bearer api_key。
27
+ 'webaz_notifications',
28
+ 'webaz_nearby',
29
+ 'webaz_profile',
30
+ 'webaz_shareables',
31
+ 'webaz_mykey',
32
+ // Batch 2(低危写,无钱无 escrow):走 webaz.xyz Bearer api_key。
33
+ 'webaz_follows',
34
+ 'webaz_like',
35
+ 'webaz_blocklist',
36
+ 'webaz_default_address',
37
+ 'webaz_chat',
38
+ 'webaz_rfq',
39
+ 'webaz_referral',
40
+ // Batch 3(商务):
41
+ 'webaz_secondhand',
42
+ 'webaz_skill',
43
+ 'webaz_skill_market',
44
+ 'webaz_auction',
45
+ // Batch 4(资金/质押,守恒由服务端 RFC-014 保证;wallet 只读,写=Passkey 仅 PWA):
46
+ 'webaz_wallet',
47
+ 'webaz_trial',
48
+ 'webaz_charity',
49
+ 'webaz_bid',
50
+ 'webaz_auto_bid',
51
+ // Batch 5(铁律/敏感):
52
+ 'webaz_dispute',
53
+ 'webaz_claim_verify',
54
+ 'webaz_rotate_key',
55
+ 'webaz_revoke_key',
56
+ // #1122:share_link 现有服务端端点 /api/share-link,可走网络。
57
+ 'webaz_share_link',
58
+ ]);
59
+ // Not in NETWORK_TOOLS but still allowed to run in NETWORK mode as self-aware /
60
+ // onboarding tools (non-data ops): info = local introspection; register = redirect
61
+ // the human to webaz.xyz. Everything else un-migrated hard-fails.
62
+ export const NETWORK_SELF_AWARE = new Set(['webaz_info', 'webaz_register']);
63
+ /**
64
+ * In NETWORK / network_readonly mode, may this tool proceed (vs migration-pending
65
+ * hard-fail)? True for migrated network tools and the self-aware onboarding tools.
66
+ */
67
+ export function toolAllowedInNetworkMode(tool) {
68
+ return NETWORK_TOOLS.has(tool) || NETWORK_SELF_AWARE.has(tool);
69
+ }
@@ -23,6 +23,13 @@ import { CallToolRequestSchema, ListToolsRequestSchema, ListResourcesRequestSche
23
23
  GetPromptRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
24
24
  import { initDatabase, generateId } from '../../layer0-foundation/L0-1-database/schema.js';
25
25
  import { setSeamDb } from '../../layer0-foundation/L0-1-database/db.js'; // RFC-016 异步 DB seam(本进程注入)
26
+ import { applyWebazRuntimeSchema } from '../../runtime/apply-webaz-runtime-schema.js'; // 与 PWA 同源的纯 schema 桥(防 MCP fresh DB 漂移)
27
+ import { generateCodeVerifier, pkceChallengeS256 } from '../../runtime/agent-pairing.js'; // RFC-020 PR-C1 — PKCE 配对
28
+ import { NETWORK_TOOLS, NETWORK_SELF_AWARE, toolAllowedInNetworkMode } from './network-mode.js'; // RFC-003 网络门(可单测)
29
+ import { homedir } from 'node:os';
30
+ import { join as pathJoin } from 'node:path';
31
+ import { existsSync as fsExists, mkdirSync, writeFileSync, readFileSync, unlinkSync, chmodSync } from 'node:fs';
32
+ import { execFileSync } from 'node:child_process';
26
33
  import { transition, getOrderStatus, initSystemUser, } from '../../layer0-foundation/L0-2-state-machine/engine.js';
27
34
  import { initDisputeSchema, createDispute, respondToDispute, getDisputeDetails, getOrderDispute, getOpenDisputes, } from '../../layer3-trust/L3-1-dispute-engine/dispute-engine.js';
28
35
  import { initNotificationSchema, notifyTransition, getNotifications, getUnreadCount, markRead, } from '../../layer2-business/L2-6-notifications/notification-engine.js';
@@ -63,56 +70,6 @@ const MODE = WEBAZ_MODE_ENV === 'network' ? 'network'
63
70
  : (WEBAZ_API_KEY ? 'network' : 'network_readonly');
64
71
  // network 或 network_readonly 都"走真网络"(后者无 Bearer)。sandbox 才是本地。
65
72
  const isNetworkMode = () => MODE === 'network' || MODE === 'network_readonly';
66
- // 已迁移到 NETWORK 的工具名。P1/P2 逐个加入;未在集合里的工具仍走 sandbox(本地)。
67
- // P1(读工具): 纯公开读,无写无 Passkey,作"MCP 连得上生产网络"的首验证。
68
- const NETWORK_TOOLS = new Set([
69
- 'webaz_price_history',
70
- 'webaz_leaderboard',
71
- 'webaz_verify_price',
72
- 'webaz_place_order',
73
- 'webaz_list_product',
74
- 'webaz_update_order',
75
- 'webaz_search',
76
- 'webaz_get_status',
77
- 'webaz_feedback',
78
- 'webaz_contribute',
79
- // Batch 1(只读 + 低危自身写):走 webaz.xyz Bearer api_key。
80
- 'webaz_notifications',
81
- 'webaz_nearby',
82
- 'webaz_profile',
83
- 'webaz_shareables',
84
- 'webaz_mykey',
85
- // Batch 2(低危写,无钱无 escrow):走 webaz.xyz Bearer api_key。
86
- // 注:share_link 暂不迁(无对应服务端端点,需新建,留待后续)。
87
- 'webaz_follows',
88
- 'webaz_like',
89
- 'webaz_blocklist',
90
- 'webaz_default_address',
91
- 'webaz_chat',
92
- 'webaz_rfq',
93
- 'webaz_referral',
94
- // Batch 3(商务):secondhand/skill_market/auction 纯 pwaApi(mode-aware 自动走网络);
95
- // skill 直连本地引擎,加了显式 apiCall network 分支。
96
- 'webaz_secondhand',
97
- 'webaz_skill',
98
- 'webaz_skill_market',
99
- 'webaz_auction',
100
- // Batch 4(资金/质押,守恒由服务端 RFC-014 保证;wallet 只读,写=Passkey 仅 PWA):
101
- 'webaz_wallet',
102
- 'webaz_trial',
103
- 'webaz_charity',
104
- 'webaz_bid',
105
- 'webaz_auto_bid',
106
- // Batch 5(铁律/敏感):claim_verify 纯 pwaApi(真人门由服务端 require_human_presence 强制);
107
- // dispute view/list_open/respond/add_evidence 走网络,arbitrate 仅返回 Passkey 指引;
108
- // rotate_key/revoke_key 仅返回 Passkey 指引(不本地校验)。
109
- 'webaz_dispute',
110
- 'webaz_claim_verify',
111
- 'webaz_rotate_key',
112
- 'webaz_revoke_key',
113
- // #1122:share_link 现有服务端端点 /api/share-link,可走网络。
114
- 'webaz_share_link',
115
- ]);
116
73
  const recentCalls = [];
117
74
  function pushRecentCall(c) {
118
75
  recentCalls.push(c);
@@ -124,9 +81,8 @@ function pushRecentCall(c) {
124
81
  function toolBackend(tool) {
125
82
  return (isNetworkMode() && NETWORK_TOOLS.has(tool)) ? 'network' : 'sandbox';
126
83
  }
127
- // 未在 NETWORK_TOOLS 名单、但 NETWORK 模式下仍可本地运行的"自省/引导"工具(非数据操作)。
128
- // info = 本地自省(并拉 live 网络状态);register = 引导真人去 webaz.xyz。其余未迁工具一律硬失败。
129
- const NETWORK_SELF_AWARE = new Set(['webaz_info', 'webaz_register']);
84
+ // NETWORK_SELF_AWARE imported from ./network-mode.ts (info = local introspection;
85
+ // register = redirect human to webaz.xyz). Everything else un-migrated hard-fails.
130
86
  // RFC-003 Batch 0 安全网:NETWORK 模式下调用【未迁移】工具时的诚实拒绝(而非静默落本地沙盒)。
131
87
  // 否则带 key 的用户调未迁工具会被悄悄喂本地结果——写操作=幻影操作(根本没到 webaz.xyz)。
132
88
  function networkMigrationPending(tool) {
@@ -180,6 +136,200 @@ async function apiCall(path, opts = {}) {
180
136
  return { error: `网络错误:${msg}`, network_error: true };
181
137
  }
182
138
  }
139
+ // ─────────────────────────── RFC-020 PR-C1: webaz_pair (pairing + local credential storage) ───────────────────────────
140
+ // C1 scope: pairing / consent / retrieval / LOCAL credential storage ONLY. The grant is
141
+ // NOT wired into any business tool (that is PR-C2 — per-request scope enforcement + audit).
142
+ // The raw bearer appears exactly once, in the server→MCP retrieval response; it is written
143
+ // straight to the OS secret store (Keychain → 0600 file fallback) and NEVER returned to the
144
+ // user / chat / tool-response / log. webaz_pair returns only a credential handle + status.
145
+ const WEBAZ_DIR = pathJoin(homedir(), '.webaz');
146
+ const PENDING_PATH = pathJoin(WEBAZ_DIR, 'pairing-pending.json'); // holds the PKCE verifier between start→complete
147
+ const CRED_FALLBACK_PATH = pathJoin(WEBAZ_DIR, 'credentials'); // 0600 fallback when Keychain unavailable
148
+ const KEYCHAIN_SERVICE = 'webaz-grant';
149
+ function ensureWebazDir() { if (!fsExists(WEBAZ_DIR))
150
+ mkdirSync(WEBAZ_DIR, { recursive: true, mode: 0o700 }); }
151
+ function write0600(path, data) { ensureWebazDir(); writeFileSync(path, data, { mode: 0o600 }); try {
152
+ chmodSync(path, 0o600);
153
+ }
154
+ catch { /* best effort */ } }
155
+ /** Store the raw bearer in the OS secret store; never log it. Returns an opaque handle. */
156
+ function storeGrantCredential(grantId, token) {
157
+ // macOS Keychain first (non-interactive upsert). Any failure → strict-perms file fallback.
158
+ if (process.platform === 'darwin') {
159
+ try {
160
+ execFileSync('security', ['add-generic-password', '-U', '-s', KEYCHAIN_SERVICE, '-a', grantId, '-w', token], { stdio: ['ignore', 'ignore', 'ignore'] });
161
+ return `keychain:${KEYCHAIN_SERVICE}/${grantId}`;
162
+ }
163
+ catch { /* fall through to file */ }
164
+ }
165
+ let store = {};
166
+ try {
167
+ if (fsExists(CRED_FALLBACK_PATH))
168
+ store = JSON.parse(readFileSync(CRED_FALLBACK_PATH, 'utf8'));
169
+ }
170
+ catch {
171
+ store = {};
172
+ }
173
+ store[grantId] = { token, stored_at: new Date().toISOString() };
174
+ write0600(CRED_FALLBACK_PATH, JSON.stringify(store, null, 2));
175
+ return `file:~/.webaz/credentials#${grantId}`;
176
+ }
177
+ // Non-secret index of the current grant (grant_id + handle + metadata) so the stored
178
+ // credential can be resolved back without re-pairing. The SECRET (token) lives only in
179
+ // the OS secret store / 0600 file — never in this pointer.
180
+ const CRED_POINTER_PATH = pathJoin(WEBAZ_DIR, 'grant-current.json');
181
+ function saveGrantPointer(grantId, handle, capabilities, expiresAt) {
182
+ write0600(CRED_POINTER_PATH, JSON.stringify({ grant_id: grantId, handle, capabilities: capabilities ?? null, expires_at: expiresAt ?? null, stored_at: new Date().toISOString() }));
183
+ }
184
+ function readGrantPointer() {
185
+ try {
186
+ return fsExists(CRED_POINTER_PATH) ? JSON.parse(readFileSync(CRED_POINTER_PATH, 'utf8')) : null;
187
+ }
188
+ catch {
189
+ return null;
190
+ }
191
+ }
192
+ /**
193
+ * Resolve the stored grant bearer (reverse of storeGrantCredential). Returns null if not paired.
194
+ * ⚠️ The returned `token` is the RAW bearer — pass it only as an Authorization header; NEVER log it,
195
+ * print it, or return it in a tool response.
196
+ */
197
+ export function resolveGrantCredential() {
198
+ const ptr = readGrantPointer();
199
+ if (!ptr?.grant_id)
200
+ return null;
201
+ let token = '';
202
+ if (typeof ptr.handle === 'string' && ptr.handle.startsWith('keychain:') && process.platform === 'darwin') {
203
+ try {
204
+ token = execFileSync('security', ['find-generic-password', '-s', KEYCHAIN_SERVICE, '-a', ptr.grant_id, '-w'], { encoding: 'utf8' }).trim();
205
+ }
206
+ catch {
207
+ token = '';
208
+ }
209
+ }
210
+ if (!token) { // file fallback (also covers keychain-read failure)
211
+ try {
212
+ const store = JSON.parse(readFileSync(CRED_FALLBACK_PATH, 'utf8'));
213
+ token = store[ptr.grant_id]?.token || '';
214
+ }
215
+ catch {
216
+ token = '';
217
+ }
218
+ }
219
+ if (!token)
220
+ return null;
221
+ return { grant_id: ptr.grant_id, token, handle: ptr.handle, capabilities: ptr.capabilities, expires_at: ptr.expires_at };
222
+ }
223
+ function savePending(pairingId, codeVerifier) {
224
+ write0600(PENDING_PATH, JSON.stringify({ pairing_id: pairingId, code_verifier: codeVerifier, started_at: new Date().toISOString() }));
225
+ }
226
+ function readPending() {
227
+ try {
228
+ return fsExists(PENDING_PATH) ? JSON.parse(readFileSync(PENDING_PATH, 'utf8')) : null;
229
+ }
230
+ catch {
231
+ return null;
232
+ }
233
+ }
234
+ function clearPending() { try {
235
+ if (fsExists(PENDING_PATH))
236
+ unlinkSync(PENDING_PATH);
237
+ }
238
+ catch { /* best effort */ } }
239
+ export async function handlePair(args) {
240
+ // Mode isolation (fail-closed): every webaz_pair action (start / complete / verify) talks to the
241
+ // LIVE webaz.xyz API. In explicit sandbox mode (local-only) we must NOT touch the live network.
242
+ if (!isNetworkMode()) {
243
+ return {
244
+ _mode: MODE,
245
+ error: 'webaz_pair requires NETWORK mode — you are in sandbox (local-only, isolated from webaz.xyz). Unset WEBAZ_MODE (or set WEBAZ_MODE=network / network_readonly) to pair with / verify against a real human account.',
246
+ error_code: 'PAIRING_REQUIRES_NETWORK',
247
+ };
248
+ }
249
+ const action = args.action || 'start';
250
+ if (action === 'start') {
251
+ const caps = Array.isArray(args.capabilities) && args.capabilities.length
252
+ ? args.capabilities.map(c => ({ capability: String(c) }))
253
+ : [{ capability: 'read_public' }, { capability: 'search' }]; // default: minimal safe scopes
254
+ const verifier = generateCodeVerifier();
255
+ const challenge = pkceChallengeS256(verifier);
256
+ const resp = await apiCall('/api/agent-grants/pair/start', {
257
+ method: 'POST',
258
+ body: {
259
+ code_challenge: challenge,
260
+ capabilities: caps,
261
+ agent_label: typeof args.agent_label === 'string' ? args.agent_label : undefined,
262
+ reason: typeof args.reason === 'string' ? args.reason : undefined, // free-text reason only
263
+ },
264
+ });
265
+ if (resp.error)
266
+ return resp;
267
+ savePending(String(resp.pairing_id), verifier); // verifier stays LOCAL; never sent until retrieve
268
+ return {
269
+ status: 'awaiting_human_approval',
270
+ pairing_id: resp.pairing_id,
271
+ user_code: resp.user_code,
272
+ approve_url: `${WEBAZ_API_URL}${String(resp.approve_url || '')}`,
273
+ expires_at: resp.expires_at,
274
+ next: `Ask the human to open approve_url (logged in at webaz.xyz) and approve. Then call webaz_pair again with action="complete".`,
275
+ capabilities_requested: caps.map(c => c.capability),
276
+ note: 'Safe scopes only. The credential is delivered once on completion and stored in your OS secret store — it is never shown here.',
277
+ };
278
+ }
279
+ if (action === 'complete') {
280
+ const pending = readPending();
281
+ if (!pending)
282
+ return { error: 'no pending pairing — run webaz_pair (action="start") first', error_code: 'NO_PENDING_PAIRING' };
283
+ const resp = await apiCall(`/api/agent-grants/pair/${pending.pairing_id}/retrieve`, {
284
+ method: 'POST',
285
+ body: { code_verifier: pending.code_verifier },
286
+ });
287
+ if (resp.error) {
288
+ // not approved yet → keep pending so the human can still approve + a later retry works
289
+ if (resp.http_status === 409)
290
+ return { status: 'not_ready', detail: resp.error, hint: 'Human has not approved yet (or it expired). Approve, then call action="complete" again.' };
291
+ clearPending();
292
+ return resp;
293
+ }
294
+ const token = String(resp.token || '');
295
+ const grantId = String(resp.grant_id || '');
296
+ if (!token || !grantId) {
297
+ clearPending();
298
+ return { error: 'retrieval returned no credential', error_code: 'RETRIEVE_EMPTY' };
299
+ }
300
+ const handle = storeGrantCredential(grantId, token); // raw bearer goes straight to secret store
301
+ saveGrantPointer(grantId, handle, resp.capabilities, resp.expires_at); // non-secret index for later resolve
302
+ clearPending();
303
+ return {
304
+ status: 'stored',
305
+ credential_handle: handle, // opaque handle — NOT the token
306
+ grant_id: grantId,
307
+ capabilities: resp.capabilities,
308
+ expires_at: resp.expires_at,
309
+ note: 'Credential stored in your OS secret store (raw token NOT shown; server keeps only a hash). Use webaz_pair action="verify" to confirm it; the server enforces active/expiry/revoked/scope on every call. Safe scopes only.',
310
+ };
311
+ }
312
+ if (action === 'verify') {
313
+ // Consume the stored grant against a SAFE grant-gated route (read_public). The SERVER is authoritative:
314
+ // it re-checks active/expiry/revoked/subject-suspension/scope + audits on EVERY call. We resolve the
315
+ // bearer from the secret store and attach it; the raw token is never printed. Safe scopes only — no
316
+ // business tool and no risk scope is wired to grants here.
317
+ const cred = resolveGrantCredential();
318
+ if (!cred)
319
+ return { status: 'not_paired', error_code: 'NO_GRANT_CREDENTIAL', hint: 'No stored grant. Run webaz_pair action="start", have the human approve, then action="complete".' };
320
+ const resp = await apiCall('/api/agent-grants/whoami', { method: 'GET', apiKey: cred.token });
321
+ if (resp.error) {
322
+ return { status: 'grant_invalid', grant_id: cred.grant_id, error: resp.error, error_code: resp.error_code, hint: 'Grant is no longer valid (revoked / expired / suspended). Re-pair with webaz_pair action="start".' };
323
+ }
324
+ return {
325
+ status: 'active',
326
+ grant: resp.grant, // SERVER-AUTHORITATIVE principal (grant_id/human_id/agent_label/capability)
327
+ local_cache: { capabilities: cred.capabilities, expires_at: cred.expires_at }, // advisory only, may be stale — NOT authoritative
328
+ note: 'Grant verified LIVE by the server (per-call: active/expiry/revoked/subject-suspension/scope + audited). `grant` is authoritative; `local_cache` is advisory. Safe scopes only; no business tool or risk scope consumes this grant.',
329
+ };
330
+ }
331
+ return { error: `unknown action "${action}" — use "start" | "complete" | "verify"`, error_code: 'BAD_ACTION' };
332
+ }
183
333
  // 启动 banner(stderr)+ status 声明用 —— 让用户/agent 一眼知道现在是真网络还是沙盒
184
334
  function modeBanner() {
185
335
  if (MODE === 'network') {
@@ -195,6 +345,13 @@ function modeBanner() {
195
345
  // ─── 初始化 ──────────────────────────────────────────────────
196
346
  const db = initDatabase();
197
347
  setSeamDb(db); // RFC-016 Phase 1:注入异步 DB seam(本进程)—— 共享引擎迁 seam 后 MCP 进程也能用,否则 dbOne/dbAll 抛"未初始化"
348
+ // MCP fresh-DB schema bridge: apply the SAME pure schema helpers the PWA boot
349
+ // runs (incl. product_aliases + users.permanent_code/handle/region), so an
350
+ // MCP-initialized DB is schema-complete for the sandbox tool path WITHOUT first
351
+ // booting the PWA server. Pure idempotent DDL only — no business writes, no
352
+ // money/order/status path. (MCP_PRODUCT_COLS below now overlaps the products
353
+ // columns and is redundant-but-harmless; left untouched to avoid scope creep.)
354
+ applyWebazRuntimeSchema(db);
198
355
  initSystemUser(db);
199
356
  initDisputeSchema(db);
200
357
  initNotificationSchema(db);
@@ -325,6 +482,26 @@ No auth required, no parameters needed.
325
482
  properties: {},
326
483
  },
327
484
  },
485
+ {
486
+ name: 'webaz_pair',
487
+ description: `Pair this agent with a human's WebAZ account via a Passkey-approved, scoped, short-lived **delegation grant** (RFC-020) — NOT by pasting a permanent api_key. Two steps:
488
+ 1. action="start" → returns an approve_url + short user_code. The human opens it at webaz.xyz (logged in) and approves the server-shown consent (safe scopes only).
489
+ 2. action="complete" → retrieves the credential ONCE (PKCE) and stores it in your OS secret store (macOS Keychain → ~/.webaz/credentials 0600 fallback). Returns only a credential_handle + status — the raw token is never shown.
490
+ 3. action="verify" → uses the stored grant against a safe read endpoint to confirm it is still valid. The server re-checks active/expiry/revoked/suspension/scope and audits the call every time. Returns the grant principal/status; never the raw token.
491
+
492
+ Scopes: SAFE only (read_public, profile_read, search, list_product_draft, product_publish_request, draft_order). Risk scopes (place_order/wallet/refund/...) and never-delegable actions (withdraw/key-change/vote/...) are hard-rejected.
493
+
494
+ NOTE: this consumes the grant only on safe read paths; no business tool and no risk scope is wired to grants. No account is created (registration stays human-only at webaz.xyz).`,
495
+ inputSchema: {
496
+ type: 'object',
497
+ properties: {
498
+ action: { type: 'string', enum: ['start', 'complete', 'verify'], description: 'start a pairing, complete it after the human approves, or verify the stored grant (default: start)' },
499
+ capabilities: { type: 'array', items: { type: 'string' }, description: 'Requested SAFE scopes (default: read_public, search). Non-safe scopes are rejected.' },
500
+ agent_label: { type: 'string', description: 'Human-friendly name for this agent (shown in the consent screen)' },
501
+ reason: { type: 'string', description: 'Free-text reason shown to the human (you cannot relabel the scopes)' },
502
+ },
503
+ },
504
+ },
328
505
  {
329
506
  name: 'webaz_register',
330
507
  // was ~1732 chars, now ~780 chars
@@ -4948,7 +5125,7 @@ export async function startMCPServer() {
4948
5125
  // ─── RFC-003 Batch 0 安全网:NETWORK 模式下未迁移的工具【硬失败】,不静默落本地沙盒 ───
4949
5126
  // 例外:info / register(NETWORK_SELF_AWARE)有专门 network-aware 处理,照常放行。
4950
5127
  let handled = false;
4951
- if (isNetworkMode() && !NETWORK_TOOLS.has(name) && !NETWORK_SELF_AWARE.has(name)) {
5128
+ if (isNetworkMode() && !toolAllowedInNetworkMode(name)) {
4952
5129
  result = networkMigrationPending(name);
4953
5130
  handled = true;
4954
5131
  }
@@ -4957,6 +5134,9 @@ export async function startMCPServer() {
4957
5134
  case 'webaz_info':
4958
5135
  result = await handleInfo();
4959
5136
  break;
5137
+ case 'webaz_pair':
5138
+ result = await handlePair(args);
5139
+ break;
4960
5140
  case 'webaz_register':
4961
5141
  result = handleRegister(args);
4962
5142
  break;