@rikaidev/yomi 0.3.1 → 0.4.1

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.
@@ -1,103 +1,24 @@
1
- /**
2
- * Yomi MCP `login`/`login_complete` handlers.
3
- *
4
- * Split out of handlers.ts purely to keep that file (the general tool
5
- * handler surface) under the project's 500-scc-line module cap; behavior
6
- * owned here is otherwise unchanged in spirit from what previously lived
7
- * inline there.
8
- *
9
- * Two login paths exist, dispatched by client capability:
10
- *
11
- * - Elicitation clients: the original single-call flow — elicit phone/
12
- * region, elicit PIN acknowledgement, block to completion. Unchanged.
13
- *
14
- * - Non-elicitation clients (e.g. Claude Desktop, confirmed empirically —
15
- * it does not support MCP elicitation): the PIN has nowhere to go if we
16
- * block, since it is produced mid-flow and stderr is invisible to the
17
- * human, but a tool RESULT is visible. So the flow is split across two
18
- * tool calls: `login` starts the passwordless flow and returns the PIN
19
- * as soon as LINE issues it (bounded wait), then `login_complete`
20
- * awaits the same in-flight login to finish once the human has acted
21
- * on their phone.
22
- *
23
- * Both paths call the ONE shared login sequence in ../cli/login.ts
24
- * (`runPwlessLogin`) — neither duplicates it.
25
- */
26
1
  import { runPwlessLogin } from '../../cli/login.js';
27
2
  import { LINE_PIN_CODE_LIFETIME_MS, PAAK_AUTH_CLIENT_CEILING_MS, PIN_VERIFY_CLIENT_CEILING_MS, } from '../../line/auth/pwless/index.js';
28
3
  import { createCliLogger } from '../../util/log.js';
4
+ import { supportsFormElicitation } from '../client-capabilities.js';
29
5
  import { supportsMcpApps } from '../ui/capability.js';
6
+ import { buildAcknowledgementForm, buildPreflightForm, certSteps, LOGIN_REGIONS, PRIMARY_DEVICE_SETTING_PATH, pinSteps, prerequisiteNotMetResult, readPreflightForm, } from './login-copy.js';
7
+ import { handleLoginMrtr } from './login-mrtr.js';
8
+ import { finishPendingLogin, getLivePendingLogin, getPendingLogin, PIN_WAIT_TIMEOUT_MS, startPendingLogin, waitForPin, } from './login-session.js';
30
9
  import { jsonText, toolError } from './shared.js';
31
10
  const log = createCliLogger('Yomi');
32
- /** LINE region codes offered on the login elicitation form. */
33
- const LOGIN_REGIONS = ['TW', 'JP', 'TH', 'ID', 'US'];
34
- /**
35
- * How long a live `pendingLogin` may be reused by a later `login` call for
36
- * the same phone/region, before treating it as stale and starting fresh.
37
- *
38
- * This is bound to LINE's real PIN code lifetime (~line/auth/pwless/index.ts,
39
- * an external fact from LINE Help Center, ~3 minutes) — NOT to how long
40
- * yomi's client is willing to keep polling (~16 minutes). A login started
41
- * 90 seconds ago is still very much alive and its PIN is still valid; the
42
- * only thing that actually kills a pending login early is LINE's own code
43
- * expiring, so that is the correct bound for "is this still worth reusing".
44
- */
45
- const PIN_WINDOW_MS = LINE_PIN_CODE_LIFETIME_MS;
46
11
  /**
47
12
  * How long yomi's client keeps polling for the device-approval step once a
48
13
  * PIN has been verified (or skipped via a stored certificate) — the real
49
- * ceiling `login_complete` blocks for, used only to describe that wait to
14
+ * ceiling the completing call blocks for, used only to describe that wait to
50
15
  * the human, not to bound anything server-side.
51
16
  */
52
17
  const APPROVAL_WINDOW_MS = PAAK_AUTH_CLIENT_CEILING_MS;
53
- /** How long `login` waits for LINE to issue a PIN before reporting a failure. */
54
- const PIN_WAIT_TIMEOUT_MS = 20000;
55
- /**
56
- * Elicit `phone`/`region` from the human in one form, pre-filling whatever
57
- * the caller already supplied.
58
- *
59
- * @param server - MCP Server instance (elicitation already capability-gated
60
- * by the caller).
61
- * @param phone - Phone already supplied by the tool call, if any.
62
- * @param region - Region already supplied by the tool call, if any.
63
- * @returns The collected phone/region, or `null` if the human declined/cancelled.
64
- */
65
- async function elicitPhoneRegion(server, phone, region) {
66
- const collected = await server.elicitInput({
67
- message: 'Yomi needs your LINE phone number and region to start passwordless login.',
68
- requestedSchema: {
69
- type: 'object',
70
- properties: {
71
- phone: {
72
- type: 'string',
73
- title: 'Phone number',
74
- description: 'E.164 form, e.g. +8869XXXXXXXX.',
75
- ...(phone ? { default: phone } : {}),
76
- },
77
- region: {
78
- type: 'string',
79
- title: 'Region',
80
- enum: LOGIN_REGIONS,
81
- ...(region ? { default: region } : {}),
82
- },
83
- },
84
- required: ['phone', 'region'],
85
- },
86
- });
87
- if (collected.action !== 'accept') {
88
- return null;
89
- }
90
- const collectedPhone = String(collected.content?.phone ?? '');
91
- const collectedRegion = String(collected.content?.region ?? '');
92
- if (!collectedPhone || !collectedRegion) {
93
- return null;
94
- }
95
- return { phone: collectedPhone, region: collectedRegion };
96
- }
97
18
  /**
98
19
  * Elicit acknowledgement of the login PIN — fire-and-forget from the
99
- * caller's perspective (must not block `startPwlessLogin`, which is
100
- * already running concurrently and waiting on phone biometric approval).
20
+ * caller's perspective (must not block `runPwlessLogin`, which is already
21
+ * running concurrently and waiting on phone biometric approval).
101
22
  *
102
23
  * @param server - MCP Server instance.
103
24
  * @param pin - The PIN LINE just issued.
@@ -107,19 +28,7 @@ async function elicitPhoneRegion(server, phone, region) {
107
28
  */
108
29
  function elicitPinAcknowledgement(server, pin, onDeclined) {
109
30
  server
110
- .elicitInput({
111
- message: `LINE PIN: ${pin}\n\nEnter this PIN in the LINE app on your primary phone, then approve ` +
112
- 'the new device from there. Acknowledge below once you have done this.',
113
- requestedSchema: {
114
- type: 'object',
115
- properties: {
116
- entered: {
117
- type: 'boolean',
118
- title: 'I entered the PIN and approved the device on my phone',
119
- },
120
- },
121
- },
122
- })
31
+ .elicitInput(buildAcknowledgementForm(pin))
123
32
  .then((result) => {
124
33
  if (result.action !== 'accept') {
125
34
  onDeclined();
@@ -132,12 +41,13 @@ function elicitPinAcknowledgement(server, pin, onDeclined) {
132
41
  });
133
42
  }
134
43
  /**
135
- * Handle `login` for a client that supports MCP elicitation — drive the
136
- * passwordless flow entirely through elicitation, with no out-of-band
137
- * channel required. Phone/region are elicited when missing, and the PIN is
44
+ * Handle `login` on a LEGACY-era connection whose client supports form
45
+ * elicitation — drive the passwordless flow entirely through pushed
46
+ * elicitation requests, with no out-of-band channel required. A pre-flight
47
+ * form confirms the 允許自其他裝置登入 prerequisite (and collects
48
+ * phone/region when missing) BEFORE any PIN is issued; the PIN is then
138
49
  * surfaced through a second elicitation fired without blocking
139
- * `startPwlessLogin`, which keeps running while the human acts on their
140
- * phone. See ../cli/login.ts for the shared login sequence.
50
+ * `runPwlessLogin`, which keeps running while the human acts on their phone.
141
51
  *
142
52
  * @param server - MCP Server instance (for elicitInput).
143
53
  * @param service - LineProtocolService (not yet authenticated).
@@ -145,15 +55,19 @@ function elicitPinAcknowledgement(server, pin, onDeclined) {
145
55
  * @returns MCP tool result describing the logged-in profile.
146
56
  */
147
57
  async function handleLoginElicitation(server, service, args) {
148
- let { phone, region } = args;
149
- if (!phone || !region) {
150
- const collected = await elicitPhoneRegion(server, phone, region);
151
- if (!collected) {
152
- return toolError('Login was cancelled by the user.');
58
+ const collected = await server.elicitInput(buildPreflightForm(args.phone, args.region));
59
+ if (collected.action !== 'accept') {
60
+ return toolError('Login was cancelled by the user.');
61
+ }
62
+ const preflight = readPreflightForm(collected.content, args.phone, args.region);
63
+ if (!preflight.ok) {
64
+ if (preflight.reason === 'prerequisite') {
65
+ log.warn('login.prerequisite', { answer: preflight.answer });
66
+ return prerequisiteNotMetResult(preflight.answer);
153
67
  }
154
- ;
155
- ({ phone, region } = collected);
68
+ return toolError('Login was cancelled by the user.');
156
69
  }
70
+ const { phone, region } = preflight;
157
71
  let pinDeclined = false;
158
72
  const onPin = (pin) => {
159
73
  log.warn('login.pin', { action: 'elicit_acknowledgement' });
@@ -188,90 +102,12 @@ async function handleLoginElicitation(server, service, args) {
188
102
  return toolError(error?.message ?? String(error));
189
103
  }
190
104
  }
191
- let pendingLogin = null;
192
- /**
193
- * Start a new passwordless login in the background and register it as the
194
- * one in-flight pending login.
195
- *
196
- * The stored promise is the actual `runPwlessLogin` promise, so
197
- * `login_complete` can await it and see the real success/failure. A
198
- * `.catch()` is attached immediately (its result discarded, not stored) so
199
- * an abandoned login — one nobody ever calls `login_complete` for — never
200
- * surfaces as an unhandled rejection; this does not affect what
201
- * `pendingLogin.promise` itself resolves/rejects with.
202
- *
203
- * @param service - LineProtocolService (not yet authenticated).
204
- * @param phone - Phone number in E.164 form.
205
- * @param region - Region code.
206
- * @returns The newly registered pending-login record.
207
- */
208
- function startPendingLogin(service, phone, region) {
209
- let resolvePinReady = () => { };
210
- const pinReady = new Promise((resolve) => {
211
- resolvePinReady = resolve;
212
- });
213
- const pending = {
214
- phone,
215
- region,
216
- pin: null,
217
- certSkippedPin: false,
218
- startedAt: Date.now(),
219
- pinReady,
220
- };
221
- const onPin = (pin) => {
222
- pending.pin = pin;
223
- resolvePinReady();
224
- };
225
- const onBiometric = () => {
226
- log.warn('login.waiting_biometric', {
227
- action: 'approve the new device on your phone',
228
- });
229
- // Reaching biometric wait with no PIN ever set means a valid stored
230
- // certificate skipped the PIN step entirely (see LinePwlessLogin.login,
231
- // step 2-3) — resolve `pinReady` now so `waitForPin` doesn't block out
232
- // its full timeout waiting for a PIN that will never arrive.
233
- if (!pending.pin) {
234
- pending.certSkippedPin = true;
235
- resolvePinReady();
236
- }
237
- };
238
- const promise = runPwlessLogin(service, phone, region, {
239
- onPin,
240
- onWaitingBiometric: onBiometric,
241
- });
242
- promise.catch(() => {
243
- // Swallow here only to prevent an unhandled-rejection warning for a
244
- // login nobody completes; the real error still surfaces to whoever
245
- // awaits `pending.promise` (login_complete) below.
246
- });
247
- pending.promise = promise;
248
- return pending;
249
- }
250
- /**
251
- * Wait for a pending login's PIN to arrive, bounded by a timeout.
252
- *
253
- * @param pending - The pending login to wait on.
254
- * @param timeoutMs - Maximum time to wait.
255
- * @returns The PIN, or `null` if it did not arrive within `timeoutMs`.
256
- */
257
- async function waitForPin(pending, timeoutMs) {
258
- if (pending.pin) {
259
- return pending.pin;
260
- }
261
- const outcome = await Promise.race([
262
- pending.pinReady.then(() => pending.pin ?? null),
263
- new Promise((resolve) => {
264
- setTimeout(() => resolve(null), timeoutMs);
265
- }),
266
- ]);
267
- return outcome;
268
- }
269
105
  /**
270
- * Handle `login` for a client that does NOT support MCP elicitation
271
- * (confirmed empirically for Claude Desktop). Splits the flow into two
272
- * tool calls since the PIN cannot be prompted for in-band: this call
273
- * starts (or reuses) the passwordless flow and returns the PIN as soon as
274
- * LINE issues it; `login_complete` finishes it once the human has acted.
106
+ * Handle `login` for a client that supports no form of elicitation at all
107
+ * (confirmed empirically for Claude Desktop). Splits the flow into two tool
108
+ * calls since the PIN cannot be prompted for in-band: this call starts (or
109
+ * reuses) the passwordless flow and returns the PIN as soon as LINE issues
110
+ * it; `login_complete` finishes it once the human has acted.
275
111
  *
276
112
  * The MCP Apps view (./ui/login-app.ts) has never been observed to render on
277
113
  * any deployed client (see that file's header) — resources/read succeeds
@@ -322,6 +158,9 @@ async function handleLoginNoElicitation(service, args, supportsUi) {
322
158
  type: 'text',
323
159
  text: 'Ask the human for their LINE phone number (E.164 form, e.g. +8869XXXXXXXX) and region ' +
324
160
  `(one of ${LOGIN_REGIONS.join(', ')}), then call \`login\` again with those as arguments. ` +
161
+ 'In the same message, tell them yomi signs in as a SECOND device, which requires ' +
162
+ `${PRIMARY_DEVICE_SETTING_PATH} to be enabled on the phone already logged in — if it is ` +
163
+ 'off, LINE never prompts that phone and the login cannot finish. ' +
325
164
  'A login form may also appear for the human to fill in directly — if they use it, the ' +
326
165
  'login proceeds on its own and there is nothing further for you to do — but do not wait ' +
327
166
  'for that; if no form appears, asking the human directly is how this login makes progress.',
@@ -335,14 +174,13 @@ async function handleLoginNoElicitation(service, args, supportsUi) {
335
174
  missing.push('phone');
336
175
  if (!region)
337
176
  missing.push('region');
338
- return toolError(`Missing ${missing.join(' and ')} for login. This MCP client does not support elicitation, so ` +
177
+ return toolError(`Missing ${missing.join(' and ')} for login. This MCP client does not support form elicitation, so ` +
339
178
  `${missing.join('/')} must be supplied as a tool argument, or persisted by running ` +
340
- '`npx @rikaidev/yomi login` once in a terminal.');
179
+ '`npx @rikaidev/yomi login` once in a terminal. Also tell the human that yomi signs in as a ' +
180
+ `SECOND device, which requires ${PRIMARY_DEVICE_SETTING_PATH} enabled on the phone already ` +
181
+ 'logged in — with it off, LINE never prompts that phone and no login can succeed.');
341
182
  }
342
- const now = Date.now();
343
- const active = pendingLogin && now - pendingLogin.startedAt < PIN_WINDOW_MS
344
- ? pendingLogin
345
- : null;
183
+ const active = getLivePendingLogin(Date.now());
346
184
  if (active && (active.phone !== phone || active.region !== region)) {
347
185
  // A live login for a DIFFERENT number is still in flight. Never hand
348
186
  // back its PIN under this call's identity, and never silently start a
@@ -352,10 +190,8 @@ async function handleLoginNoElicitation(service, args, supportsUi) {
352
190
  'before starting a login for another number.');
353
191
  }
354
192
  // Reuse the in-flight login only when it matches this number; otherwise
355
- // start a fresh one. `pending` is non-null either way, so no narrowing of
356
- // the module-level `pendingLogin` is needed downstream.
193
+ // start a fresh one.
357
194
  const pending = active ?? startPendingLogin(service, phone, region);
358
- pendingLogin = pending;
359
195
  const pin = await waitForPin(pending, PIN_WAIT_TIMEOUT_MS);
360
196
  if (!pin) {
361
197
  if (pending.certSkippedPin) {
@@ -368,14 +204,7 @@ async function handleLoginNoElicitation(service, args, supportsUi) {
368
204
  pin: null,
369
205
  clientApprovalPollCeilingSeconds: Math.round(APPROVAL_WINDOW_MS / 1000),
370
206
  };
371
- const certSteps = 'No PIN needed — LINE recognized a stored login certificate from a previous login, ' +
372
- 'so the PIN step was skipped.\n\n' +
373
- '0. This only works if the primary phone has 設定 > 我的帳號 > 允許自其他裝置登入 ' +
374
- 'enabled — without it LINE will not offer this device a sign-in prompt at all.\n' +
375
- '1. Bring the LINE app to the foreground on your primary phone — it should prompt you ' +
376
- 'to approve this new device signing in.\n' +
377
- '2. Approve the device from there.\n\n';
378
- const certText = certSteps +
207
+ const certText = certSteps() +
379
208
  'Call the `login_complete` tool (no arguments) IMMEDIATELY now — do not wait for ' +
380
209
  'confirmation that the approval happened first. `login_complete` blocks by itself while ' +
381
210
  `you approve (up to about ${Math.round(APPROVAL_WINDOW_MS / 60000)} minutes) and returns ` +
@@ -399,16 +228,7 @@ async function handleLoginNoElicitation(service, args, supportsUi) {
399
228
  clientPinPollCeilingSeconds: Math.round(PIN_VERIFY_CLIENT_CEILING_MS / 1000),
400
229
  clientApprovalPollCeilingSeconds: Math.round(APPROVAL_WINDOW_MS / 1000),
401
230
  };
402
- const pinSteps = 'LINE PIN:\n\n' +
403
- ` ${pin}\n\n` +
404
- '0. This only works if the primary phone has 設定 > 我的帳號 > 允許自其他裝置登入 enabled — ' +
405
- 'without it LINE will not offer this device a sign-in prompt at all.\n' +
406
- '1. Open LINE on the primary phone. If a verification prompt does not appear immediately, ' +
407
- 'bringing the app to the foreground surfaces it.\n' +
408
- `2. Enter this PIN and tick the device that is signing in, then tap 「用戶確認」 — LINE gives ` +
409
- `you ${pinCodeLifetimeSeconds} seconds (about 3 minutes) from when the code was shown to do this.\n` +
410
- '3. LINE will then ask that same phone to approve this new device signing in — approve it there.\n\n';
411
- const pinText = pinSteps +
231
+ const pinText = pinSteps(pin) +
412
232
  `Call the \`login_complete\` tool (no arguments) IMMEDIATELY now — do not wait until you have ` +
413
233
  'entered the PIN or approved the device. `login_complete` blocks by itself through both of ' +
414
234
  `those steps: it keeps waiting up to about ${clientPinPollCeilingMinutes} minutes for step 2 and ` +
@@ -422,41 +242,50 @@ async function handleLoginNoElicitation(service, args, supportsUi) {
422
242
  };
423
243
  }
424
244
  /**
425
- * Handle `login` — dispatches to the elicitation flow when the connected
426
- * client supports it, otherwise the two-phase flow that finishes via
427
- * `login_complete`.
245
+ * Handle `login` — dispatches to the path the connected client can actually
246
+ * complete. See this module's header for the three paths.
247
+ *
248
+ * Elicitation is gated on the FORM sub-capability specifically, not on the
249
+ * presence of an `elicitation` object: since MCP 2026-07-28 a client may
250
+ * declare `elicitation: { url: {} }` alone, and both elicitation paths send
251
+ * a `requestedSchema` that such a client would reject. See
252
+ * ../client-capabilities.ts, which also owns WHERE the capabilities are read
253
+ * from — the accessor is empty on a modern-era stdio connection.
428
254
  *
429
- * @param server - MCP Server instance (for elicitInput/getClientCapabilities).
255
+ * @param server - MCP Server instance (for elicitInput on the legacy path).
430
256
  * @param service - LineProtocolService (not yet authenticated).
431
257
  * @param args - Tool arguments.
432
- * @returns MCP tool result.
258
+ * @param context - Era, client capabilities, and the multi-round-trip input
259
+ * responses a retried `tools/call` carried (`ctx.mcpReq.inputResponses`).
260
+ * @returns MCP tool result, or an `input_required` result on the MRTR path.
433
261
  */
434
- export async function handleLogin(server, service, args) {
435
- if (server.getClientCapabilities()?.elicitation) {
436
- return handleLoginElicitation(server, service, args);
262
+ export async function handleLogin(server, service, args, context = {}) {
263
+ const { era = 'legacy', capabilities = server.getClientCapabilities(), inputResponses, } = context;
264
+ if (supportsFormElicitation(capabilities)) {
265
+ return era === 'legacy'
266
+ ? handleLoginElicitation(server, service, args)
267
+ : handleLoginMrtr(service, args, inputResponses);
437
268
  }
438
- return handleLoginNoElicitation(service, args, supportsMcpApps(server.getClientCapabilities()));
269
+ return handleLoginNoElicitation(service, args, supportsMcpApps(capabilities));
439
270
  }
440
271
  /**
441
272
  * Handle `login_complete` — finish a login started by a prior `login` call
442
- * on a non-elicitation client. Awaits the same in-flight `runPwlessLogin`
443
- * promise `login` started, so the real success/failure surfaces here.
444
- * Never touches credentials on the failure path.
273
+ * on a client with no elicitation. Awaits the same in-flight
274
+ * `runPwlessLogin` promise `login` started, so the real success/failure
275
+ * surfaces here.
445
276
  *
446
277
  * @returns MCP tool result describing the logged-in profile, or an honest error.
447
278
  */
448
279
  export async function handleLoginComplete() {
449
- if (!pendingLogin) {
280
+ const pending = getPendingLogin();
281
+ if (!pending) {
450
282
  return toolError('No login is pending. Call `login` first to start a passwordless login. ' +
451
283
  '(If you already called `login`, a previous attempt may have finished or its PIN code may have ' +
452
284
  `expired — LINE only gives the human about ${Math.round(LINE_PIN_CODE_LIFETIME_MS / 1000)} seconds ` +
453
285
  '(about 3 minutes) to act on a shown code. Calling `login` again is safe.)');
454
286
  }
455
- const pending = pendingLogin;
456
287
  try {
457
- const { mid, displayName } = await pending.promise;
458
- pendingLogin = null;
459
- log.info('login.complete', { mid });
288
+ const { mid, displayName } = await finishPendingLogin(pending);
460
289
  return {
461
290
  content: [
462
291
  {
@@ -467,7 +296,6 @@ export async function handleLoginComplete() {
467
296
  };
468
297
  }
469
298
  catch (error) {
470
- pendingLogin = null;
471
299
  return toolError(error?.message ?? String(error));
472
300
  }
473
301
  }