@kin-tio/cli 0.6.2 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +4 -2
- package/CHANGELOG.md +26 -0
- package/README.md +52 -15
- package/README.zh-CN.md +33 -9
- package/dist/src/cli.js +245 -4
- package/dist/src/config.js +72 -17
- package/dist/src/ilink/cli-accounts.js +66 -0
- package/dist/src/ilink/cli-login.js +563 -0
- package/dist/src/ilink/cli-start.js +57 -0
- package/dist/src/ilink/enrollment.js +24 -0
- package/dist/src/ilink/login-manager.js +102 -30
- package/dist/src/ilink/login-store.js +78 -29
- package/dist/src/ilink/qr.js +67 -3
- package/dist/src/ilink/secret-box.js +73 -0
- package/dist/src/ilink/sqlite-store.js +211 -13
- package/dist/src/mcp/ilink-login-server.js +160 -0
- package/dist/src/mcp/ipc-host.js +4 -1
- package/dist/src/mcp/ipc-protocol.js +22 -0
- package/dist/src/runtime.js +226 -92
- package/dist/src/services/codex-agent.js +57 -27
- package/dist/src/services/codex-app-server.js +4 -2
- package/dist/src/services/conversation-processor.js +8 -2
- package/dist/src/state/sqlite-store.js +151 -10
- package/dist/src/version.js +1 -1
- package/package.json +1 -1
|
@@ -50,6 +50,14 @@ function providerIdentity(value, label) {
|
|
|
50
50
|
}
|
|
51
51
|
return value;
|
|
52
52
|
}
|
|
53
|
+
function agentAccess(value) {
|
|
54
|
+
if (value === undefined)
|
|
55
|
+
return 'restricted';
|
|
56
|
+
if (value !== 'restricted' && value !== 'host') {
|
|
57
|
+
fail('invalid_input', 'agentAccess is invalid');
|
|
58
|
+
}
|
|
59
|
+
return value;
|
|
60
|
+
}
|
|
53
61
|
function sealedSecret(row) {
|
|
54
62
|
const result = {
|
|
55
63
|
nonce: row.nonce,
|
|
@@ -114,6 +122,8 @@ function mapAccount(row) {
|
|
|
114
122
|
baseUrl: row.base_url,
|
|
115
123
|
generation: Number(row.generation),
|
|
116
124
|
status: row.status,
|
|
125
|
+
agentAccess: row.agent_access,
|
|
126
|
+
runtimeEnabled: row.runtime_enabled === 1,
|
|
117
127
|
pauseUntil: Number(row.pause_until),
|
|
118
128
|
createdAt: Number(row.created_at),
|
|
119
129
|
updatedAt: Number(row.updated_at),
|
|
@@ -334,6 +344,7 @@ export class IlinkSqliteStore {
|
|
|
334
344
|
const accountKey = createIlinkAccountKey(input.providerAccountId);
|
|
335
345
|
const ownerPeerId = providerIdentity(input.ownerPeerId, 'ownerPeerId');
|
|
336
346
|
const baseUrl = normalizeIlinkBaseUrl(input.baseUrl);
|
|
347
|
+
const requestedAccess = agentAccess(input.agentAccess);
|
|
337
348
|
const now = this.#now(input.now);
|
|
338
349
|
return this.#transaction(() => {
|
|
339
350
|
if (this.#accountRow(accountKey)) {
|
|
@@ -349,10 +360,11 @@ export class IlinkSqliteStore {
|
|
|
349
360
|
this.#database.prepare(`
|
|
350
361
|
INSERT INTO ilink_accounts (
|
|
351
362
|
account_key, provider_account_id, owner_peer_id, base_url,
|
|
352
|
-
generation, status,
|
|
363
|
+
generation, status, agent_access, runtime_enabled,
|
|
364
|
+
pause_until, cursor, cursor_updated_at,
|
|
353
365
|
created_at, updated_at
|
|
354
|
-
) VALUES (?, ?, ?, ?, 1, 'active', 0, '', 0, ?, ?)
|
|
355
|
-
`).run(accountKey, input.providerAccountId, ownerPeerId, baseUrl, now, now);
|
|
366
|
+
) VALUES (?, ?, ?, ?, 1, 'active', ?, ?, 0, '', 0, ?, ?)
|
|
367
|
+
`).run(accountKey, input.providerAccountId, ownerPeerId, baseUrl, requestedAccess, 0, now, now);
|
|
356
368
|
this.#database.prepare(`
|
|
357
369
|
INSERT INTO ilink_account_secrets (
|
|
358
370
|
account_key, account_generation, nonce, ciphertext, auth_tag,
|
|
@@ -371,6 +383,7 @@ export class IlinkSqliteStore {
|
|
|
371
383
|
fail('pair_mismatch', 'iLink provider account does not match account key');
|
|
372
384
|
}
|
|
373
385
|
const baseUrl = normalizeIlinkBaseUrl(input.baseUrl);
|
|
386
|
+
const requestedAccess = agentAccess(input.agentAccess);
|
|
374
387
|
const now = this.#now(input.now);
|
|
375
388
|
return this.#transaction(() => {
|
|
376
389
|
const current = this.#accountRow(input.accountKey);
|
|
@@ -384,14 +397,21 @@ export class IlinkSqliteStore {
|
|
|
384
397
|
fail('pair_mismatch', 'iLink account binding cannot change during rotation');
|
|
385
398
|
}
|
|
386
399
|
const nextGeneration = current.generation + 1;
|
|
400
|
+
const runtimeEnabled = current.status === 'active'
|
|
401
|
+
? current.runtime_enabled
|
|
402
|
+
: 0;
|
|
403
|
+
const agentAccess = current.agent_access === 'host' || requestedAccess === 'host'
|
|
404
|
+
? 'host'
|
|
405
|
+
: 'restricted';
|
|
387
406
|
positiveInteger(nextGeneration, 'nextGeneration');
|
|
388
407
|
this.#cancelOpenWindows(input.accountKey, now, 'account_generation_changed');
|
|
389
408
|
const updated = this.#database.prepare(`
|
|
390
409
|
UPDATE ilink_accounts
|
|
391
|
-
SET base_url = ?, generation = ?, status = 'active',
|
|
410
|
+
SET base_url = ?, generation = ?, status = 'active', agent_access = ?,
|
|
411
|
+
runtime_enabled = ?, pause_until = 0,
|
|
392
412
|
updated_at = ?
|
|
393
413
|
WHERE account_key = ? AND generation = ?
|
|
394
|
-
`).run(baseUrl, nextGeneration, now, input.accountKey, input.expectedGeneration);
|
|
414
|
+
`).run(baseUrl, nextGeneration, agentAccess, runtimeEnabled, now, input.accountKey, input.expectedGeneration);
|
|
395
415
|
if (updated.changes !== 1) {
|
|
396
416
|
fail('generation_conflict', 'iLink account generation changed');
|
|
397
417
|
}
|
|
@@ -418,8 +438,8 @@ export class IlinkSqliteStore {
|
|
|
418
438
|
positiveInteger(input.maxAccounts, 'maxAccounts');
|
|
419
439
|
return this.#transaction(() => {
|
|
420
440
|
const offer = rowAs(this.#database.prepare(`
|
|
421
|
-
SELECT
|
|
422
|
-
|
|
441
|
+
SELECT initiator_kind, source_channel, source_message_key,
|
|
442
|
+
source_account_id, source_peer_id, created_at
|
|
423
443
|
FROM ilink_login_offers
|
|
424
444
|
WHERE offer_id = ? AND status IN ('waiting', 'scanned')
|
|
425
445
|
AND expires_at > ?
|
|
@@ -439,7 +459,7 @@ export class IlinkSqliteStore {
|
|
|
439
459
|
if (input.accountGeneration !== (existing?.generation || 0) + 1) {
|
|
440
460
|
fail('generation_conflict', 'iLink enrollment generation changed');
|
|
441
461
|
}
|
|
442
|
-
|
|
462
|
+
let account = existing
|
|
443
463
|
? this.rotateAccount({
|
|
444
464
|
accountKey,
|
|
445
465
|
expectedGeneration: existing.generation,
|
|
@@ -447,15 +467,23 @@ export class IlinkSqliteStore {
|
|
|
447
467
|
ownerPeerId: input.ownerPeerId,
|
|
448
468
|
baseUrl: input.baseUrl,
|
|
449
469
|
encryptedBotToken: input.encryptedBotToken,
|
|
470
|
+
agentAccess: offer.initiator_kind === 'local_operator' ? 'host' : 'restricted',
|
|
450
471
|
now: input.now,
|
|
451
472
|
})
|
|
452
|
-
: this.registerAccount(
|
|
473
|
+
: this.registerAccount({
|
|
474
|
+
...input,
|
|
475
|
+
agentAccess: offer.initiator_kind === 'local_operator' ? 'host' : 'restricted',
|
|
476
|
+
});
|
|
477
|
+
if (offer.initiator_kind === 'remote_adapter' && !account.runtimeEnabled) {
|
|
478
|
+
account = this.setRuntimeEnabled(account.accountKey, true, input.now);
|
|
479
|
+
}
|
|
453
480
|
this.#database.prepare(`
|
|
454
481
|
INSERT INTO ilink_enrollment_audit (
|
|
455
|
-
offer_id,
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
482
|
+
offer_id, initiator_kind, source_channel, source_message_key,
|
|
483
|
+
source_account_id, source_peer_id,
|
|
484
|
+
account_key, result, offered_at, completed_at
|
|
485
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, 'confirmed', ?, ?)
|
|
486
|
+
`).run(offerId, offer.initiator_kind, offer.source_channel, offer.source_message_key, offer.source_account_id, offer.source_peer_id, account.accountKey, offer.created_at, this.#now(input.now));
|
|
459
487
|
const removed = this.#database.prepare(`
|
|
460
488
|
DELETE FROM ilink_login_offers
|
|
461
489
|
WHERE offer_id = ? AND status IN ('waiting', 'scanned')
|
|
@@ -478,6 +506,50 @@ export class IlinkSqliteStore {
|
|
|
478
506
|
const row = this.#accountRow(accountKey);
|
|
479
507
|
return row ? mapAccount(row) : undefined;
|
|
480
508
|
}
|
|
509
|
+
confirmExistingEnrollment(input) {
|
|
510
|
+
if (!/^qo_[A-Za-z0-9_-]{1,128}$/u.test(input.offerId)) {
|
|
511
|
+
fail('invalid_input', 'iLink login offer ID is invalid');
|
|
512
|
+
}
|
|
513
|
+
assertIlinkAccountKey(input.accountKey);
|
|
514
|
+
const now = this.#now(input.now);
|
|
515
|
+
return this.#transaction(() => {
|
|
516
|
+
const offer = rowAs(this.#database.prepare(`
|
|
517
|
+
SELECT initiator_kind, source_channel, source_message_key,
|
|
518
|
+
source_account_id, source_peer_id, created_at
|
|
519
|
+
FROM ilink_login_offers
|
|
520
|
+
WHERE offer_id = ? AND initiator_kind = 'local_operator'
|
|
521
|
+
AND status IN ('waiting', 'scanned') AND expires_at > ?
|
|
522
|
+
AND EXISTS (
|
|
523
|
+
SELECT 1 FROM json_each(candidate_account_keys_json)
|
|
524
|
+
WHERE value = ?
|
|
525
|
+
)
|
|
526
|
+
`).get(input.offerId, now, input.accountKey));
|
|
527
|
+
if (!offer)
|
|
528
|
+
fail('invalid_input', 'Unknown or invalid local iLink login offer');
|
|
529
|
+
const account = this.#accountRow(input.accountKey);
|
|
530
|
+
if (!account || account.status !== 'active') {
|
|
531
|
+
fail('account_not_active', 'iLink account is not active');
|
|
532
|
+
}
|
|
533
|
+
this.#database.prepare(`
|
|
534
|
+
UPDATE ilink_accounts SET agent_access = 'host', updated_at = ?
|
|
535
|
+
WHERE account_key = ?
|
|
536
|
+
`).run(now, input.accountKey);
|
|
537
|
+
this.#database.prepare(`
|
|
538
|
+
INSERT INTO ilink_enrollment_audit (
|
|
539
|
+
offer_id, initiator_kind, source_channel, source_message_key,
|
|
540
|
+
source_account_id, source_peer_id, account_key,
|
|
541
|
+
result, offered_at, completed_at
|
|
542
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, 'already_connected', ?, ?)
|
|
543
|
+
`).run(input.offerId, offer.initiator_kind, offer.source_channel, offer.source_message_key, offer.source_account_id, offer.source_peer_id, input.accountKey, offer.created_at, now);
|
|
544
|
+
const removed = this.#database.prepare(`
|
|
545
|
+
DELETE FROM ilink_login_offers
|
|
546
|
+
WHERE offer_id = ? AND status IN ('waiting', 'scanned')
|
|
547
|
+
`).run(input.offerId);
|
|
548
|
+
if (removed.changes !== 1)
|
|
549
|
+
fail('attempt_conflict', 'iLink login offer changed');
|
|
550
|
+
return mapAccount(this.#accountRow(input.accountKey));
|
|
551
|
+
});
|
|
552
|
+
}
|
|
481
553
|
getAccountSecret(accountKey) {
|
|
482
554
|
const row = this.#accountSecretRow(accountKey);
|
|
483
555
|
return row ? mapAccountSecret(row) : undefined;
|
|
@@ -495,6 +567,16 @@ export class IlinkSqliteStore {
|
|
|
495
567
|
ORDER BY created_at, account_key
|
|
496
568
|
`).all()).map(mapAccount);
|
|
497
569
|
}
|
|
570
|
+
hasEncryptedState() {
|
|
571
|
+
return Boolean(this.#database.prepare(`
|
|
572
|
+
SELECT 1
|
|
573
|
+
FROM ilink_account_secrets
|
|
574
|
+
UNION ALL
|
|
575
|
+
SELECT 1
|
|
576
|
+
FROM ilink_login_offers
|
|
577
|
+
LIMIT 1
|
|
578
|
+
`).get());
|
|
579
|
+
}
|
|
498
580
|
listActiveAccountsWithSecrets() {
|
|
499
581
|
return rowsAs(this.#database.prepare(`
|
|
500
582
|
SELECT account.*, secret.account_generation,
|
|
@@ -509,6 +591,122 @@ export class IlinkSqliteStore {
|
|
|
509
591
|
secret: mapAccountSecret(row),
|
|
510
592
|
}));
|
|
511
593
|
}
|
|
594
|
+
listRuntimeAccountsWithSecrets() {
|
|
595
|
+
return rowsAs(this.#database.prepare(`
|
|
596
|
+
SELECT account.*, secret.account_generation,
|
|
597
|
+
secret.nonce, secret.ciphertext, secret.auth_tag,
|
|
598
|
+
secret.updated_at AS secret_updated_at
|
|
599
|
+
FROM ilink_accounts AS account
|
|
600
|
+
JOIN ilink_account_secrets AS secret USING (account_key)
|
|
601
|
+
WHERE account.status = 'active' AND account.runtime_enabled = 1
|
|
602
|
+
ORDER BY account.created_at, account.account_key
|
|
603
|
+
`).all()).map((row) => ({
|
|
604
|
+
account: mapAccount(row),
|
|
605
|
+
secret: mapAccountSecret(row),
|
|
606
|
+
}));
|
|
607
|
+
}
|
|
608
|
+
setRuntimeEnabled(accountKey, enabled, now = this.#now()) {
|
|
609
|
+
assertIlinkAccountKey(accountKey);
|
|
610
|
+
return this.#transaction(() => {
|
|
611
|
+
const updated = this.#database.prepare(`
|
|
612
|
+
UPDATE ilink_accounts
|
|
613
|
+
SET runtime_enabled = ?, updated_at = ?
|
|
614
|
+
WHERE account_key = ? AND status = 'active'
|
|
615
|
+
`).run(enabled ? 1 : 0, this.#now(now), accountKey);
|
|
616
|
+
if (updated.changes !== 1) {
|
|
617
|
+
const account = this.#accountRow(accountKey);
|
|
618
|
+
if (!account)
|
|
619
|
+
fail('account_not_found', 'Unknown iLink account');
|
|
620
|
+
fail('account_not_active', 'iLink account is not active');
|
|
621
|
+
}
|
|
622
|
+
return mapAccount(this.#accountRow(accountKey));
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
selectRuntimeAccount(accountKey, now = this.#now()) {
|
|
626
|
+
assertIlinkAccountKey(accountKey);
|
|
627
|
+
return this.#transaction(() => {
|
|
628
|
+
const account = this.#accountRow(accountKey);
|
|
629
|
+
if (!account)
|
|
630
|
+
fail('account_not_found', 'Unknown iLink account');
|
|
631
|
+
if (account.status !== 'active') {
|
|
632
|
+
fail('account_not_active', 'iLink account is not active');
|
|
633
|
+
}
|
|
634
|
+
const timestamp = this.#now(now);
|
|
635
|
+
this.#database.prepare(`
|
|
636
|
+
UPDATE ilink_accounts
|
|
637
|
+
SET runtime_enabled = CASE WHEN account_key = ? THEN 1 ELSE 0 END,
|
|
638
|
+
updated_at = CASE
|
|
639
|
+
WHEN runtime_enabled <> CASE WHEN account_key = ? THEN 1 ELSE 0 END
|
|
640
|
+
THEN ? ELSE updated_at END
|
|
641
|
+
WHERE status = 'active'
|
|
642
|
+
`).run(accountKey, accountKey, timestamp);
|
|
643
|
+
return mapAccount(this.#accountRow(accountKey));
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
deleteAccountCompletely(accountKey, now = this.#now()) {
|
|
647
|
+
assertIlinkAccountKey(accountKey);
|
|
648
|
+
return this.#transaction(() => {
|
|
649
|
+
const account = this.#accountRow(accountKey);
|
|
650
|
+
if (!account)
|
|
651
|
+
fail('account_not_found', 'Unknown iLink account');
|
|
652
|
+
const deletedAccount = mapAccount(account);
|
|
653
|
+
const timestamp = this.#now(now);
|
|
654
|
+
this.#cancelOpenWindows(accountKey, timestamp, 'account_deleted');
|
|
655
|
+
this.#database.prepare(`
|
|
656
|
+
UPDATE ilink_login_offers
|
|
657
|
+
SET candidate_account_keys_json = (
|
|
658
|
+
SELECT COALESCE(json_group_array(value), '[]')
|
|
659
|
+
FROM json_each(candidate_account_keys_json)
|
|
660
|
+
WHERE value <> ?
|
|
661
|
+
), updated_at = ?
|
|
662
|
+
WHERE EXISTS (
|
|
663
|
+
SELECT 1 FROM json_each(candidate_account_keys_json) WHERE value = ?
|
|
664
|
+
)
|
|
665
|
+
`).run(accountKey, timestamp, accountKey);
|
|
666
|
+
this.#database.prepare(`
|
|
667
|
+
DELETE FROM ilink_enrollment_audit WHERE account_key = ?
|
|
668
|
+
`).run(accountKey);
|
|
669
|
+
this.#database.prepare(`
|
|
670
|
+
DELETE FROM delivery_failures
|
|
671
|
+
WHERE matched_attempt_key IN (
|
|
672
|
+
SELECT attempt_key FROM send_attempts
|
|
673
|
+
WHERE channel = 'weixin_ilink' AND open_kfid = ?
|
|
674
|
+
)
|
|
675
|
+
`).run(accountKey);
|
|
676
|
+
this.#database.prepare(`
|
|
677
|
+
DELETE FROM agent_sessions
|
|
678
|
+
WHERE channel = 'weixin_ilink' AND open_kfid = ?
|
|
679
|
+
`).run(accountKey);
|
|
680
|
+
this.#database.prepare(`
|
|
681
|
+
DELETE FROM send_attempts
|
|
682
|
+
WHERE channel = 'weixin_ilink' AND open_kfid = ?
|
|
683
|
+
`).run(accountKey);
|
|
684
|
+
this.#database.prepare(`
|
|
685
|
+
DELETE FROM ilink_reply_windows WHERE account_key = ?
|
|
686
|
+
`).run(accountKey);
|
|
687
|
+
this.#database.prepare(`
|
|
688
|
+
DELETE FROM inbound_messages
|
|
689
|
+
WHERE channel = 'weixin_ilink' AND open_kfid = ?
|
|
690
|
+
`).run(accountKey);
|
|
691
|
+
this.#database.prepare(`
|
|
692
|
+
DELETE FROM conversations
|
|
693
|
+
WHERE channel = 'weixin_ilink' AND open_kfid = ?
|
|
694
|
+
`).run(accountKey);
|
|
695
|
+
const deleted = this.#database.prepare(`
|
|
696
|
+
DELETE FROM ilink_accounts WHERE account_key = ?
|
|
697
|
+
`).run(accountKey);
|
|
698
|
+
if (deleted.changes !== 1) {
|
|
699
|
+
fail('attempt_conflict', 'iLink account changed during deletion');
|
|
700
|
+
}
|
|
701
|
+
return {
|
|
702
|
+
...deletedAccount,
|
|
703
|
+
status: 'revoked',
|
|
704
|
+
runtimeEnabled: false,
|
|
705
|
+
pauseUntil: 0,
|
|
706
|
+
updatedAt: timestamp,
|
|
707
|
+
};
|
|
708
|
+
});
|
|
709
|
+
}
|
|
512
710
|
getCursor(accountKey) {
|
|
513
711
|
const row = this.#accountRow(accountKey);
|
|
514
712
|
if (!row)
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { KINTIO_VERSION } from '../version.js';
|
|
4
|
+
const OFFER_ID = z.string().regex(/^qo_[A-Za-z0-9_-]{1,128}$/u);
|
|
5
|
+
const ACCOUNT_KEY = z.string().regex(/^ia_[0-9a-f]{40}$/u);
|
|
6
|
+
const OPERATOR_ACCOUNT = z.object({
|
|
7
|
+
accountKey: ACCOUNT_KEY,
|
|
8
|
+
providerAccountId: z.string().min(1).max(512),
|
|
9
|
+
runtimeEnabled: z.boolean(),
|
|
10
|
+
});
|
|
11
|
+
const LOGIN_STATUS = z.enum([
|
|
12
|
+
'waiting',
|
|
13
|
+
'scanned',
|
|
14
|
+
'confirmed',
|
|
15
|
+
'expired',
|
|
16
|
+
'failed',
|
|
17
|
+
'cancelled',
|
|
18
|
+
'already_connected',
|
|
19
|
+
'verification_required',
|
|
20
|
+
'unknown',
|
|
21
|
+
]);
|
|
22
|
+
function textResult(text, structuredContent) {
|
|
23
|
+
return {
|
|
24
|
+
content: [{ type: 'text', text }],
|
|
25
|
+
structuredContent,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
function failure(error, operation = 'login') {
|
|
29
|
+
const message = error instanceof Error ? error.message : '';
|
|
30
|
+
const code = /account limit reached/iu.test(message)
|
|
31
|
+
? 'account_limit_reached'
|
|
32
|
+
: /already pending/iu.test(message)
|
|
33
|
+
? 'login_pending'
|
|
34
|
+
: operation === 'account'
|
|
35
|
+
? 'account_operation_unavailable'
|
|
36
|
+
: 'login_unavailable';
|
|
37
|
+
const publicMessage = code === 'account_limit_reached'
|
|
38
|
+
? 'The iLink account limit has been reached.'
|
|
39
|
+
: code === 'login_pending'
|
|
40
|
+
? 'An iLink terminal login is already pending.'
|
|
41
|
+
: code === 'account_operation_unavailable'
|
|
42
|
+
? 'The iLink account operation is unavailable.'
|
|
43
|
+
: 'The iLink login operation is unavailable.';
|
|
44
|
+
return {
|
|
45
|
+
content: [{ type: 'text', text: publicMessage }],
|
|
46
|
+
isError: true,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
export function createIlinkLoginMcpServer(operator) {
|
|
50
|
+
const server = new McpServer({ name: 'kintio-ilink-login', version: KINTIO_VERSION }, {
|
|
51
|
+
instructions: 'Private local operator tools for iLink enrollment and account lifecycle. Never expose this server to an Agent.',
|
|
52
|
+
});
|
|
53
|
+
server.registerTool('begin_login', {
|
|
54
|
+
description: 'Create one terminal iLink login offer.',
|
|
55
|
+
inputSchema: {},
|
|
56
|
+
outputSchema: {
|
|
57
|
+
offerId: OFFER_ID,
|
|
58
|
+
qrContent: z.string().min(1).max(2_048),
|
|
59
|
+
expiresAt: z.number().int().positive(),
|
|
60
|
+
},
|
|
61
|
+
annotations: { readOnlyHint: false, idempotentHint: false },
|
|
62
|
+
}, async (_input, { signal }) => {
|
|
63
|
+
let offered;
|
|
64
|
+
try {
|
|
65
|
+
offered = await operator.begin(signal);
|
|
66
|
+
if (signal.aborted) {
|
|
67
|
+
throw signal.reason;
|
|
68
|
+
}
|
|
69
|
+
return textResult('iLink login started.', offered);
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
if (offered && signal.aborted)
|
|
73
|
+
operator.cancel(offered.offerId);
|
|
74
|
+
return failure(error);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
server.registerTool('login_status', {
|
|
78
|
+
description: 'Read one terminal iLink login status.',
|
|
79
|
+
inputSchema: { offerId: OFFER_ID },
|
|
80
|
+
outputSchema: {
|
|
81
|
+
status: LOGIN_STATUS,
|
|
82
|
+
},
|
|
83
|
+
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
84
|
+
}, ({ offerId }) => {
|
|
85
|
+
try {
|
|
86
|
+
return textResult('iLink login status.', operator.status(offerId));
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
return failure(error);
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
server.registerTool('cancel_login', {
|
|
93
|
+
description: 'Cancel one terminal iLink login offer.',
|
|
94
|
+
inputSchema: { offerId: OFFER_ID },
|
|
95
|
+
outputSchema: { cancelled: z.boolean() },
|
|
96
|
+
annotations: { readOnlyHint: false, idempotentHint: true },
|
|
97
|
+
}, ({ offerId }) => {
|
|
98
|
+
try {
|
|
99
|
+
return textResult('iLink login cancelled.', {
|
|
100
|
+
cancelled: operator.cancel(offerId),
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
return failure(error);
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
server.registerTool('list_accounts', {
|
|
108
|
+
description: 'List enrolled iLink accounts without credentials.',
|
|
109
|
+
inputSchema: {},
|
|
110
|
+
outputSchema: { accounts: z.array(OPERATOR_ACCOUNT).max(1_000) },
|
|
111
|
+
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
112
|
+
}, () => {
|
|
113
|
+
try {
|
|
114
|
+
return textResult('iLink accounts.', { accounts: operator.listAccounts() });
|
|
115
|
+
}
|
|
116
|
+
catch (error) {
|
|
117
|
+
return failure(error, 'account');
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
for (const [name, enabled] of [
|
|
121
|
+
['start_account', true],
|
|
122
|
+
['stop_account', false],
|
|
123
|
+
]) {
|
|
124
|
+
server.registerTool(name, {
|
|
125
|
+
description: `${enabled ? 'Start' : 'Stop'} one enrolled iLink account.`,
|
|
126
|
+
inputSchema: { accountKey: ACCOUNT_KEY },
|
|
127
|
+
outputSchema: {
|
|
128
|
+
account: OPERATOR_ACCOUNT,
|
|
129
|
+
runningCount: z.number().int().nonnegative(),
|
|
130
|
+
},
|
|
131
|
+
annotations: { readOnlyHint: false, idempotentHint: true },
|
|
132
|
+
}, async ({ accountKey }) => {
|
|
133
|
+
try {
|
|
134
|
+
const result = await operator.setAccountRuntime(accountKey, enabled);
|
|
135
|
+
return textResult(`iLink account ${enabled ? 'started' : 'stopped'}.`, result);
|
|
136
|
+
}
|
|
137
|
+
catch (error) {
|
|
138
|
+
return failure(error, 'account');
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
server.registerTool('delete_account', {
|
|
143
|
+
description: 'Permanently delete one iLink account and all Kintio data scoped to it.',
|
|
144
|
+
inputSchema: { accountKey: ACCOUNT_KEY },
|
|
145
|
+
outputSchema: {
|
|
146
|
+
account: OPERATOR_ACCOUNT,
|
|
147
|
+
runningCount: z.number().int().nonnegative(),
|
|
148
|
+
},
|
|
149
|
+
annotations: { readOnlyHint: false, idempotentHint: false, destructiveHint: true },
|
|
150
|
+
}, async ({ accountKey }) => {
|
|
151
|
+
try {
|
|
152
|
+
const result = await operator.deleteAccount(accountKey);
|
|
153
|
+
return textResult('iLink account and its Kintio data were deleted.', result);
|
|
154
|
+
}
|
|
155
|
+
catch (error) {
|
|
156
|
+
return failure(error, 'account');
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
return server;
|
|
160
|
+
}
|
package/dist/src/mcp/ipc-host.js
CHANGED
|
@@ -16,6 +16,7 @@ export class McpIpcHost {
|
|
|
16
16
|
#wechatKf;
|
|
17
17
|
#memory;
|
|
18
18
|
#ilink;
|
|
19
|
+
#operator;
|
|
19
20
|
#logger;
|
|
20
21
|
#connections = new Set();
|
|
21
22
|
#server;
|
|
@@ -25,13 +26,14 @@ export class McpIpcHost {
|
|
|
25
26
|
#closing;
|
|
26
27
|
#force = false;
|
|
27
28
|
#closed = false;
|
|
28
|
-
constructor({ instanceKey, stateDirectory, relayFile, wechatKf, memory, ilink, logger = console, }) {
|
|
29
|
+
constructor({ instanceKey, stateDirectory, relayFile, wechatKf, memory, ilink, operator, logger = console, }) {
|
|
29
30
|
this.#instanceKey = path.resolve(instanceKey);
|
|
30
31
|
this.#stateDirectory = path.join(path.resolve(stateDirectory), '.kintio-mcp', mcpInstanceId(this.#instanceKey));
|
|
31
32
|
this.#relayFile = path.resolve(relayFile);
|
|
32
33
|
this.#wechatKf = wechatKf;
|
|
33
34
|
this.#memory = memory;
|
|
34
35
|
this.#ilink = ilink;
|
|
36
|
+
this.#operator = operator;
|
|
35
37
|
this.#logger = logger;
|
|
36
38
|
}
|
|
37
39
|
start() {
|
|
@@ -114,6 +116,7 @@ export class McpIpcHost {
|
|
|
114
116
|
case 'wechat_kf': return this.#wechatKf;
|
|
115
117
|
case 'weixin_ilink': return this.#ilink;
|
|
116
118
|
case 'conversation_memory': return this.#memory;
|
|
119
|
+
case 'operator': return this.#operator;
|
|
117
120
|
}
|
|
118
121
|
}
|
|
119
122
|
#accept(socket) {
|
|
@@ -7,6 +7,7 @@ const MCP_ROUTES = [
|
|
|
7
7
|
'wechat_kf',
|
|
8
8
|
'weixin_ilink',
|
|
9
9
|
'conversation_memory',
|
|
10
|
+
'operator',
|
|
10
11
|
];
|
|
11
12
|
export const MCP_FRAME_MAX_BYTES = 256 * 1024;
|
|
12
13
|
export const MCP_HANDSHAKE_OK = 'KINTIO-MCP/1 OK';
|
|
@@ -37,6 +38,9 @@ export function mcpIpcAddress(instanceKey, generation, platform = process.platfo
|
|
|
37
38
|
export function mcpInstanceId(instanceKey) {
|
|
38
39
|
return createHash('sha256').update(canonicalPath(instanceKey)).digest('hex').slice(0, 16);
|
|
39
40
|
}
|
|
41
|
+
export function operatorMcpInstanceKey(instanceKey) {
|
|
42
|
+
return `${path.resolve(instanceKey)}.operator`;
|
|
43
|
+
}
|
|
40
44
|
export function ensureMcpStateDirectory(directory) {
|
|
41
45
|
const target = ensurePrivateDirectory(directory);
|
|
42
46
|
assertPrivateDirectory(target);
|
|
@@ -105,6 +109,24 @@ export function readMcpDescriptor(filePath) {
|
|
|
105
109
|
fs.closeSync(descriptor);
|
|
106
110
|
}
|
|
107
111
|
}
|
|
112
|
+
export function findMcpDescriptorFile(stateDirectory, instanceKey) {
|
|
113
|
+
const directory = path.join(path.resolve(stateDirectory), '.kintio-mcp', mcpInstanceId(instanceKey));
|
|
114
|
+
let names;
|
|
115
|
+
try {
|
|
116
|
+
names = fs.readdirSync(directory).filter((name) => /^mcp-runtime-[A-Za-z0-9_-]{24}\.json$/u.test(name));
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
if (error.code === 'ENOENT') {
|
|
120
|
+
throw new Error('Kintio runtime is not running');
|
|
121
|
+
}
|
|
122
|
+
throw error;
|
|
123
|
+
}
|
|
124
|
+
if (names.length !== 1)
|
|
125
|
+
throw new Error('Kintio runtime is not running');
|
|
126
|
+
const descriptorFile = path.join(directory, names[0]);
|
|
127
|
+
readMcpDescriptor(descriptorFile);
|
|
128
|
+
return descriptorFile;
|
|
129
|
+
}
|
|
108
130
|
export function mcpHandshake(descriptor, route) {
|
|
109
131
|
return `${JSON.stringify({
|
|
110
132
|
version: descriptor.version,
|