@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.
@@ -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, pause_until, cursor, cursor_updated_at,
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', pause_until = 0,
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 source_message_key, source_open_kfid,
422
- source_external_userid, created_at
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
- const account = existing
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(input);
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, source_message_key, source_open_kfid,
456
- source_external_userid, account_key, result, offered_at, completed_at
457
- ) VALUES (?, ?, ?, ?, ?, 'confirmed', ?, ?)
458
- `).run(offerId, offer.source_message_key, offer.source_open_kfid, offer.source_external_userid, account.accountKey, offer.created_at, this.#now(input.now));
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
+ }
@@ -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,