@rikaidev/yomi 0.4.0 → 0.4.2

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/PRIVACY.md CHANGED
@@ -19,6 +19,20 @@ The only data that leaves your device is whatever the agent surfaces in its
19
19
  replies to you — and in a cloud assistant, that surfaced text is processed by
20
20
  that assistant's model. Yomi itself makes no outbound copy of your messages.
21
21
 
22
+ ## The one non-LINE request Yomi makes
23
+
24
+ At startup Yomi asks the public npm registry whether a newer version has been
25
+ published, and tells the agent if so. This exists because a Desktop Extension
26
+ (`.mcpb`) installed by hand never auto-updates — without the check you could
27
+ sit on an old build, security fixes included, and never know.
28
+
29
+ It sends **no message content and no account identifier**: it is a plain GET of
30
+ a public package document. What it does reveal to the registry is that your IP
31
+ address is running Yomi, and roughly which version. Nothing is uploaded.
32
+
33
+ Set **`YOMI_NO_UPDATE_CHECK=1`** to turn it off; Yomi then makes no outbound
34
+ request except to LINE itself.
35
+
22
36
  ## Excluding conversations
23
37
 
24
38
  You can exclude any conversation at any time, in plain language to the agent
@@ -16,6 +16,15 @@
16
16
  * KeychainService service name: see keychain.ts — writes go to Yomi's own
17
17
  * canonical namespace; a legacy namespace from before this split is read
18
18
  * as a fallback and migrated forward, never written to or deleted.
19
+ *
20
+ * The Keychain is machine-global: it is NOT scoped by YOMI_DATA_DIR, so on
21
+ * macOS an automated run that only redirected the data dir would still find —
22
+ * and act on — the developer's real LINE session (a stored login certificate
23
+ * is enough for `login` to start a device sign-in against the live account).
24
+ * `YOMI_NO_KEYCHAIN=1` forces the file backend so tests and smoke runs are
25
+ * genuinely isolated by pointing YOMI_DATA_DIR somewhere disposable. It is a
26
+ * testing escape hatch: setting it in normal use downgrades credential
27
+ * storage from the Keychain to a plain JSON file.
19
28
  */
20
29
  import { dirname, join } from 'node:path';
21
30
  import { getKeychainService } from './keychain.js';
@@ -140,10 +149,10 @@ class VolatileCacheStore {
140
149
  while (this.dirty) {
141
150
  this.dirty = false;
142
151
  try {
143
- const dir = this.filePath.substring(0, this.filePath.lastIndexOf('/'));
144
- if (dir) {
145
- await fs.mkdir(dir, { recursive: true, mode: 0o700 });
146
- }
152
+ await fs.mkdir(dirname(this.filePath), {
153
+ recursive: true,
154
+ mode: 0o700,
155
+ });
147
156
  const tmp = `${this.filePath}.tmp`;
148
157
  // Group shared keys live here — they decrypt group messages. The
149
158
  // default 0644 made them readable by every other account on the
@@ -188,7 +197,8 @@ export class CredentialStore {
188
197
  this.filePath = fallbackFilePath;
189
198
  this.cache = new Map();
190
199
  this.loaded = false;
191
- this.secureStorageEnabled = process.platform === 'darwin';
200
+ this.secureStorageEnabled =
201
+ process.platform === 'darwin' && process.env.YOMI_NO_KEYCHAIN !== '1';
192
202
  this.keychainService = this.secureStorageEnabled
193
203
  ? getKeychainService()
194
204
  : null;
@@ -258,8 +268,11 @@ export class CredentialStore {
258
268
  }
259
269
  try {
260
270
  const fs = await import('node:fs/promises');
261
- const dir = this.filePath.substring(0, this.filePath.lastIndexOf('/'));
262
- await fs.mkdir(dir, { recursive: true, mode: 0o700 });
271
+ // `dirname`, not a slice on '/': a Windows path has no forward slash, so
272
+ // the slice yielded '' and `mkdir('')` threw ENOENT — swallowed by the
273
+ // catch below, so every credential write on Windows silently failed and
274
+ // no session (auth token, mid, E2EE keypair) was ever persisted.
275
+ await fs.mkdir(dirname(this.filePath), { recursive: true, mode: 0o700 });
263
276
  await fs.writeFile(this.filePath, blob, { mode: 0o600 });
264
277
  // `mode` is ignored when the file already exists, so repair older installs.
265
278
  await fs.chmod(this.filePath, 0o600).catch(() => { });
@@ -0,0 +1,55 @@
1
+ import { CLIENT_CAPABILITIES_META_KEY } from '@modelcontextprotocol/server';
2
+ /**
3
+ * The capabilities the CURRENT request's client declared.
4
+ *
5
+ * @param server - MCP Server instance (the legacy, handshake-scoped source).
6
+ * @param ctx - Request handler context (the modern, per-request source).
7
+ * @returns The declared capabilities, or undefined when the client declared
8
+ * none and no envelope carried any.
9
+ */
10
+ export function clientCapabilitiesOf(server, ctx) {
11
+ // The envelope type is opaque (`RequestMetaEnvelope = {}`) — the SDK models
12
+ // it as a bag of reserved keys, so reaching the declared capabilities off it
13
+ // needs an index-signature view rather than a property access.
14
+ const envelope = ctx?.mcpReq.envelope;
15
+ return (envelope?.[CLIENT_CAPABILITIES_META_KEY] ?? server.getClientCapabilities());
16
+ }
17
+ /**
18
+ * Does the connected client accept form-mode elicitation (`elicitation/create`
19
+ * carrying a `requestedSchema`)?
20
+ *
21
+ * @param capabilities - Result of `server.getClientCapabilities()`.
22
+ * @returns True when the client declared `elicitation.form`, or declared a
23
+ * bare `elicitation` with no mode sub-keys at all (pre-2026-07-28 shape).
24
+ */
25
+ export function supportsFormElicitation(capabilities) {
26
+ const elicitation = capabilities?.elicitation;
27
+ if (!elicitation || typeof elicitation !== 'object') {
28
+ return false;
29
+ }
30
+ if ('form' in elicitation && elicitation.form) {
31
+ return true;
32
+ }
33
+ // A client that named `url` and not `form` is url-only; one that named
34
+ // neither predates the split and can only have meant form.
35
+ return !('url' in elicitation && elicitation.url);
36
+ }
37
+ /**
38
+ * Does the connected client accept url-mode elicitation (`elicitation/create`
39
+ * with `mode: "url"`, answered out-of-band and closed by a
40
+ * `notifications/elicitation/complete`)?
41
+ *
42
+ * Not used by any flow yet — url mode has no observed client support, the
43
+ * same trap ./ui/login-app.ts documents for MCP Apps. Exported so a future
44
+ * url-mode path can gate on the real declaration rather than assume it.
45
+ *
46
+ * @param capabilities - Result of `server.getClientCapabilities()`.
47
+ * @returns True only when the client explicitly declared `elicitation.url`.
48
+ */
49
+ export function supportsUrlElicitation(capabilities) {
50
+ const elicitation = capabilities?.elicitation;
51
+ if (!elicitation || typeof elicitation !== 'object') {
52
+ return false;
53
+ }
54
+ return 'url' in elicitation && Boolean(elicitation.url);
55
+ }
@@ -0,0 +1,200 @@
1
+ import { LINE_PIN_CODE_LIFETIME_MS } from '../../line/auth/pwless/index.js';
2
+ /** LINE region codes offered on the login form. */
3
+ export const LOGIN_REGIONS = ['TW', 'JP', 'TH', 'ID', 'US'];
4
+ /** Where the toggle lives on the primary phone, in LINE's own words. */
5
+ export const PRIMARY_DEVICE_SETTING_PATH = '設定 > 我的帳號 > 允許自其他裝置登入';
6
+ /**
7
+ * The prerequisite as a single line, for texts that report on a login
8
+ * already in flight (the PIN and cert steps) rather than gating one.
9
+ */
10
+ const PRIMARY_DEVICE_SETTING_NOTE = `0. This only works if the primary phone has ${PRIMARY_DEVICE_SETTING_PATH} ` +
11
+ '(Settings > Account > allow logging in from other devices; the label is ' +
12
+ 'localized) enabled — without it LINE will not offer this device a sign-in ' +
13
+ 'prompt at all.\n';
14
+ /**
15
+ * Step-by-step instructions for turning the setting on, returned when the
16
+ * human says it is off or that they do not know where it is.
17
+ */
18
+ const PRIMARY_DEVICE_SETTING_STEPS = 'On the PRIMARY phone (the one already logged in to this LINE account):\n\n' +
19
+ '1. Open LINE and go to the 主頁 / Home tab.\n' +
20
+ '2. Tap the gear icon in the top corner to open 設定 (Settings).\n' +
21
+ '3. Open 我的帳號 (Account).\n' +
22
+ '4. Turn ON 允許自其他裝置登入 (the toggle allowing login from other ' +
23
+ 'devices — the exact wording is localized).\n\n' +
24
+ 'This toggle is what lets LINE prompt a second device to sign in. With it ' +
25
+ 'off, nothing at all appears on the phone and the login simply never ' +
26
+ 'completes.';
27
+ /** Answers offered by the pre-flight prerequisite check. */
28
+ const PREREQ_ANSWERS = {
29
+ enabled: 'enabled',
30
+ notEnabled: 'not_enabled',
31
+ unsure: 'unsure',
32
+ };
33
+ /**
34
+ * The prerequisite question, in the 2026-07-28 `oneOf`-with-`title`
35
+ * single-select shape (`TitledSingleSelectEnumSchema`) so each choice carries
36
+ * human-facing text of its own, rather than an enum of bare machine values
37
+ * the client has to render raw.
38
+ */
39
+ const PREREQ_SCHEMA_PROPERTY = {
40
+ type: 'string',
41
+ title: 'Is 允許自其他裝置登入 enabled on your primary phone?',
42
+ description: `On the phone already logged in to LINE: ${PRIMARY_DEVICE_SETTING_PATH}. ` +
43
+ 'Without it, LINE never prompts this device and the login cannot finish.',
44
+ oneOf: [
45
+ { const: PREREQ_ANSWERS.enabled, title: 'Yes — it is already on' },
46
+ { const: PREREQ_ANSWERS.notEnabled, title: 'No / not yet — show me how' },
47
+ {
48
+ const: PREREQ_ANSWERS.unsure,
49
+ title: "I don't know where that setting is",
50
+ },
51
+ ],
52
+ };
53
+ /**
54
+ * Build the one form shown before a login starts: the prerequisite, plus
55
+ * `phone`/`region` when the caller did not supply them.
56
+ *
57
+ * Both live in ONE form on purpose — the human answers a single prompt, and
58
+ * the prerequisite is raised while it can still change the outcome.
59
+ *
60
+ * @param phone - Phone already supplied by the tool call, if any.
61
+ * @param region - Region already supplied by the tool call, if any.
62
+ * @returns The elicitation body, for `elicitInput` or `inputRequired.elicit`.
63
+ */
64
+ export function buildPreflightForm(phone, region) {
65
+ const needsCredentials = !phone || !region;
66
+ const properties = {
67
+ primaryDeviceLogin: PREREQ_SCHEMA_PROPERTY,
68
+ };
69
+ const required = ['primaryDeviceLogin'];
70
+ if (needsCredentials) {
71
+ properties.phone = {
72
+ type: 'string',
73
+ title: 'Phone number',
74
+ description: 'E.164 form, e.g. +8869XXXXXXXX.',
75
+ ...(phone ? { default: phone } : {}),
76
+ };
77
+ properties.region = {
78
+ type: 'string',
79
+ title: 'Region',
80
+ enum: LOGIN_REGIONS,
81
+ ...(region ? { default: region } : {}),
82
+ };
83
+ required.push('phone', 'region');
84
+ }
85
+ return {
86
+ message: 'Yomi signs in as a SECOND LINE device. That requires ' +
87
+ `${PRIMARY_DEVICE_SETTING_PATH} to be enabled on the phone already ` +
88
+ 'logged in — confirm that below' +
89
+ (needsCredentials
90
+ ? ', along with your number and region.'
91
+ : ' before the login starts.'),
92
+ requestedSchema: { type: 'object', properties, required },
93
+ };
94
+ }
95
+ /**
96
+ * Read an accepted pre-flight form back. Shared by every path so the answer
97
+ * is interpreted identically no matter how it was collected.
98
+ *
99
+ * @param content - The accepted form content (untrusted client input).
100
+ * @param phone - Phone supplied by the tool call, used when the form did not
101
+ * carry the field because it was already known.
102
+ * @param region - Region, same rule.
103
+ * @returns What the login should do next.
104
+ */
105
+ export function readPreflightForm(content, phone, region) {
106
+ const answer = String(content?.primaryDeviceLogin ?? '');
107
+ if (answer !== PREREQ_ANSWERS.enabled) {
108
+ return { ok: false, reason: 'prerequisite', answer };
109
+ }
110
+ const collectedPhone = String(content?.phone ?? phone ?? '');
111
+ const collectedRegion = String(content?.region ?? region ?? '');
112
+ if (!collectedPhone || !collectedRegion) {
113
+ return { ok: false, reason: 'cancelled' };
114
+ }
115
+ return { ok: true, phone: collectedPhone, region: collectedRegion };
116
+ }
117
+ /**
118
+ * The numbered steps for acting on an issued PIN. Step 0 repeats the
119
+ * prerequisite even though the pre-flight form already asked about it: a
120
+ * human who answered "yes" without checking finds out here, while the code
121
+ * is still valid.
122
+ *
123
+ * @param pin - The PIN LINE just issued.
124
+ * @returns The steps, ending in a blank line.
125
+ */
126
+ export function pinSteps(pin) {
127
+ const lifetimeSeconds = Math.round(LINE_PIN_CODE_LIFETIME_MS / 1000);
128
+ return (`LINE PIN:\n\n ${pin}\n\n` +
129
+ PRIMARY_DEVICE_SETTING_NOTE +
130
+ '1. Open LINE on the primary phone. If a verification prompt does not ' +
131
+ 'appear immediately, bringing the app to the foreground surfaces it.\n' +
132
+ `2. Enter this PIN and tick the device that is signing in, then tap ` +
133
+ `「用戶確認」 — LINE gives you ${lifetimeSeconds} seconds (about 3 ` +
134
+ 'minutes) from when the code was shown to do this.\n' +
135
+ '3. LINE will then ask that same phone to approve this new device ' +
136
+ 'signing in — approve it there.\n\n');
137
+ }
138
+ /**
139
+ * The steps when a stored login certificate skipped the PIN entirely — the
140
+ * flow is already waiting on phone approval of this device.
141
+ *
142
+ * @returns The steps, ending in a blank line.
143
+ */
144
+ export function certSteps() {
145
+ return ('No PIN needed — LINE recognized a stored login certificate from a ' +
146
+ 'previous login, so the PIN step was skipped.\n\n' +
147
+ PRIMARY_DEVICE_SETTING_NOTE +
148
+ '1. Bring the LINE app to the foreground on your primary phone — it ' +
149
+ 'should prompt you to approve this new device signing in.\n' +
150
+ '2. Approve the device from there.\n\n');
151
+ }
152
+ /**
153
+ * The acknowledgement form shown alongside an issued PIN (or alongside the
154
+ * cert-skipped approval prompt).
155
+ *
156
+ * @param pin - The PIN, or `null` when a stored certificate skipped it.
157
+ * @returns The elicitation body.
158
+ */
159
+ export function buildAcknowledgementForm(pin) {
160
+ return {
161
+ message: (pin ? pinSteps(pin) : certSteps()) +
162
+ 'Acknowledge below once you have done this.',
163
+ requestedSchema: {
164
+ type: 'object',
165
+ properties: {
166
+ entered: {
167
+ type: 'boolean',
168
+ title: pin
169
+ ? 'I entered the PIN and approved the device on my phone'
170
+ : 'I approved the device on my phone',
171
+ },
172
+ },
173
+ },
174
+ };
175
+ }
176
+ /**
177
+ * Build the tool result returned when the human has not confirmed the
178
+ * prerequisite. Deliberately NOT a tool error: nothing failed, the login was
179
+ * simply never started, and the human has one concrete thing to do.
180
+ *
181
+ * @param answer - Which non-confirming answer they gave, for wording.
182
+ * @returns MCP tool result carrying the enabling steps and the retry.
183
+ */
184
+ export function prerequisiteNotMetResult(answer) {
185
+ const lead = answer === PREREQ_ANSWERS.unsure
186
+ ? 'Login was not started — here is where that setting lives.\n\n'
187
+ : 'Login was not started, because LINE would never prompt the phone.\n\n';
188
+ return {
189
+ content: [
190
+ {
191
+ type: 'text',
192
+ text: lead +
193
+ PRIMARY_DEVICE_SETTING_STEPS +
194
+ '\n\nOnce the toggle is on, call `login` again. Relay these steps ' +
195
+ 'to the human as they are — do not summarize the settings path ' +
196
+ 'away, it is the whole point.',
197
+ },
198
+ ],
199
+ };
200
+ }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * `login` on a MCP 2026-07-28 connection: the multi-round-trip (MRTR) path.
3
+ *
4
+ * That revision removed the server→client request channel — `elicitInput`
5
+ * throws on a modern-era instance before any wire traffic (SDK:
6
+ * `_assertPushApiInServedEra`, "the 2026-07-28 revision has no server→client
7
+ * request channel"). The same conversation is instead carried by
8
+ * `input_required` RESULTS: the handler returns the elicitation it needs,
9
+ * the client fulfils it through its own `elicitation/create` handler, then
10
+ * RE-CALLS `login` with the answers in `inputResponses`.
11
+ *
12
+ * So this file is ./login.ts's pushed elicitation flow turned inside out —
13
+ * same three beats, one tool call each:
14
+ *
15
+ * round 1 no responses → ask the pre-flight form
16
+ * round 2 `preflight` answered → start the login, ask for PIN ack
17
+ * round 3 `ack` answered → await completion, return the profile
18
+ *
19
+ * The in-flight login survives between rounds in ./login-session.ts, exactly
20
+ * as it survives between `login` and `login_complete` on the fallback path.
21
+ *
22
+ * `requestState` (the other half of MRTR) is deliberately NOT used: it
23
+ * round-trips through the client and comes back attacker-controlled, so the
24
+ * spec requires integrity-protecting anything that influences behavior. The
25
+ * only state this flow needs is the pending login, which never leaves the
26
+ * server.
27
+ */
28
+ import { acceptedContent, inputRequired, inputResponse, } from '@modelcontextprotocol/server';
29
+ import { createCliLogger } from '../../util/log.js';
30
+ import { buildAcknowledgementForm, buildPreflightForm, prerequisiteNotMetResult, readPreflightForm, } from './login-copy.js';
31
+ import { finishPendingLogin, getLivePendingLogin, getPendingLogin, PIN_WAIT_TIMEOUT_MS, startPendingLogin, waitForPin, } from './login-session.js';
32
+ import { jsonText, toolError } from './shared.js';
33
+ const log = createCliLogger('Yomi');
34
+ /** Keys this flow assigns to its embedded input requests. */
35
+ const PREFLIGHT_KEY = 'preflight';
36
+ const ACK_KEY = 'ack';
37
+ /**
38
+ * Round 3 — the human says they have acted on their phone. Await the login
39
+ * `startPendingLogin` began in round 2.
40
+ *
41
+ * @returns The logged-in profile, or an honest error.
42
+ */
43
+ async function completeRound() {
44
+ const pending = getPendingLogin();
45
+ if (!pending) {
46
+ return toolError('No login is pending — the attempt this acknowledgement belongs to has already finished or ' +
47
+ 'expired (LINE gives the human about 3 minutes to act on a shown code). Calling `login` ' +
48
+ 'again is safe and starts a fresh attempt.');
49
+ }
50
+ try {
51
+ const { mid, displayName } = await finishPendingLogin(pending);
52
+ return {
53
+ content: [
54
+ {
55
+ type: 'text',
56
+ text: jsonText({ loggedIn: true, mid, displayName }),
57
+ },
58
+ ],
59
+ };
60
+ }
61
+ catch (error) {
62
+ return toolError(error?.message ?? String(error));
63
+ }
64
+ }
65
+ /**
66
+ * Round 2 — the pre-flight form came back accepted and confirming. Start
67
+ * (or reuse) the passwordless login and ask the human to act on the PIN.
68
+ *
69
+ * @param service - LineProtocolService (not yet authenticated).
70
+ * @param phone - Confirmed phone number.
71
+ * @param region - Confirmed region code.
72
+ * @returns An `input_required` result carrying the PIN acknowledgement form,
73
+ * or an honest error.
74
+ */
75
+ async function startRound(service, phone, region) {
76
+ const active = getLivePendingLogin(Date.now());
77
+ if (active && (active.phone !== phone || active.region !== region)) {
78
+ // A live login for a DIFFERENT number is still in flight. Never hand
79
+ // back its PIN under this call's identity. Name nothing sensitive.
80
+ return toolError('A login for a different phone number is still in progress. Wait for its login code to expire ' +
81
+ '(LINE gives the human about 3 minutes to act on it) before starting a login for another number.');
82
+ }
83
+ const pending = active ?? startPendingLogin(service, phone, region);
84
+ const pin = await waitForPin(pending, PIN_WAIT_TIMEOUT_MS);
85
+ if (!pin && !pending.certSkippedPin) {
86
+ return toolError('LINE has not issued a login PIN within 20s. The attempt is still running in the background ' +
87
+ '(do not worry, nothing was lost) — call `login` again shortly to check for the PIN.');
88
+ }
89
+ log.warn('login.pin', {
90
+ action: pin ? 'returned_in_input_required' : 'skipped_cert_valid',
91
+ });
92
+ // The PIN travels inside the elicitation the client is about to render,
93
+ // and the login keeps running while the human acts on it — this handler
94
+ // returns immediately rather than blocking through LINE's ~3-minute
95
+ // window, so the human's clock starts now and not after a timeout.
96
+ return inputRequired({
97
+ inputRequests: {
98
+ [ACK_KEY]: inputRequired.elicit(buildAcknowledgementForm(pin)),
99
+ },
100
+ });
101
+ }
102
+ /**
103
+ * Handle `login` on a modern-era connection. Which round this call is comes
104
+ * from the responses it carries: none means round 1.
105
+ *
106
+ * @param service - LineProtocolService (not yet authenticated).
107
+ * @param args - Tool arguments.
108
+ * @param inputResponses - `ctx.mcpReq.inputResponses` from a retried call.
109
+ * Untrusted client input — read only through the SDK's response readers.
110
+ * @returns An `input_required` result, or a terminal tool result.
111
+ */
112
+ export async function handleLoginMrtr(service, args, inputResponses) {
113
+ // Round 3 first: a client that accumulates responses across rounds would
114
+ // send both keys, and the later beat is the one to act on.
115
+ const ack = inputResponse(inputResponses, ACK_KEY);
116
+ if (ack.kind === 'elicit') {
117
+ if (ack.action !== 'accept') {
118
+ return toolError('Login was cancelled by the user.');
119
+ }
120
+ return await completeRound();
121
+ }
122
+ const preflight = inputResponse(inputResponses, PREFLIGHT_KEY);
123
+ if (preflight.kind === 'elicit') {
124
+ if (preflight.action !== 'accept') {
125
+ return toolError('Login was cancelled by the user.');
126
+ }
127
+ const outcome = readPreflightForm(acceptedContent(inputResponses, PREFLIGHT_KEY), args.phone, args.region);
128
+ if (!outcome.ok) {
129
+ if (outcome.reason === 'prerequisite') {
130
+ log.warn('login.prerequisite', { answer: outcome.answer });
131
+ return prerequisiteNotMetResult(outcome.answer);
132
+ }
133
+ return toolError('Login was cancelled by the user.');
134
+ }
135
+ return await startRound(service, outcome.phone, outcome.region);
136
+ }
137
+ // Round 1. Persisted credentials still pre-fill the form, so a returning
138
+ // user only confirms rather than retypes.
139
+ const phone = args.phone ??
140
+ (await service.credentialStore?.get?.('line_phone')) ??
141
+ undefined;
142
+ const region = args.region ??
143
+ (await service.credentialStore?.get?.('line_region')) ??
144
+ undefined;
145
+ return inputRequired({
146
+ inputRequests: {
147
+ [PREFLIGHT_KEY]: inputRequired.elicit(buildPreflightForm(phone, region)),
148
+ },
149
+ });
150
+ }
@@ -0,0 +1,132 @@
1
+ import { runPwlessLogin } from '../../cli/login.js';
2
+ import { LINE_PIN_CODE_LIFETIME_MS } from '../../line/auth/pwless/index.js';
3
+ import { createCliLogger } from '../../util/log.js';
4
+ const log = createCliLogger('Yomi');
5
+ /**
6
+ * How long a live pending login may be reused by a later `login` call for
7
+ * the same phone/region, before treating it as stale and starting fresh.
8
+ *
9
+ * This is bound to LINE's real PIN code lifetime (~line/auth/pwless/index.ts,
10
+ * an external fact from LINE Help Center, ~3 minutes) — NOT to how long
11
+ * yomi's client is willing to keep polling (~16 minutes). A login started
12
+ * 90 seconds ago is still very much alive and its PIN is still valid; the
13
+ * only thing that actually kills a pending login early is LINE's own code
14
+ * expiring, so that is the correct bound for "is this still worth reusing".
15
+ */
16
+ const PIN_WINDOW_MS = LINE_PIN_CODE_LIFETIME_MS;
17
+ /** How long a `login` call waits for LINE to issue a PIN before giving up. */
18
+ export const PIN_WAIT_TIMEOUT_MS = 20000;
19
+ let pendingLogin = null;
20
+ /** @returns The in-flight login, or null when none is pending. */
21
+ export function getPendingLogin() {
22
+ return pendingLogin;
23
+ }
24
+ /**
25
+ * The in-flight login only if it is still worth reusing: started within
26
+ * LINE's PIN lifetime.
27
+ *
28
+ * @param now - Current epoch millis.
29
+ * @returns The live pending login, or null.
30
+ */
31
+ export function getLivePendingLogin(now) {
32
+ if (!pendingLogin) {
33
+ return null;
34
+ }
35
+ return now - pendingLogin.startedAt < PIN_WINDOW_MS ? pendingLogin : null;
36
+ }
37
+ /**
38
+ * Start a new passwordless login in the background and register it as the
39
+ * one in-flight pending login.
40
+ *
41
+ * The stored promise is the actual `runPwlessLogin` promise, so a later call
42
+ * can await it and see the real success/failure. A `.catch()` is attached
43
+ * immediately (its result discarded, not stored) so an abandoned login — one
44
+ * nobody ever finishes — never surfaces as an unhandled rejection; this does
45
+ * not affect what `promise` itself resolves/rejects with.
46
+ *
47
+ * @param service - LineProtocolService (not yet authenticated).
48
+ * @param phone - Phone number in E.164 form.
49
+ * @param region - Region code.
50
+ * @returns The newly registered pending-login record.
51
+ */
52
+ export function startPendingLogin(service, phone, region) {
53
+ let resolvePinReady = () => { };
54
+ const pinReady = new Promise((resolve) => {
55
+ resolvePinReady = resolve;
56
+ });
57
+ const pending = {
58
+ phone,
59
+ region,
60
+ pin: null,
61
+ certSkippedPin: false,
62
+ startedAt: Date.now(),
63
+ pinReady,
64
+ };
65
+ const onPin = (pin) => {
66
+ pending.pin = pin;
67
+ resolvePinReady();
68
+ };
69
+ const onBiometric = () => {
70
+ log.warn('login.waiting_biometric', {
71
+ action: 'approve the new device on your phone',
72
+ });
73
+ // Reaching biometric wait with no PIN ever set means a valid stored
74
+ // certificate skipped the PIN step entirely (see LinePwlessLogin.login,
75
+ // step 2-3) — resolve `pinReady` now so `waitForPin` doesn't block out
76
+ // its full timeout waiting for a PIN that will never arrive.
77
+ if (!pending.pin) {
78
+ pending.certSkippedPin = true;
79
+ resolvePinReady();
80
+ }
81
+ };
82
+ const promise = runPwlessLogin(service, phone, region, {
83
+ onPin,
84
+ onWaitingBiometric: onBiometric,
85
+ });
86
+ promise.catch(() => {
87
+ // Swallow here only to prevent an unhandled-rejection warning for a
88
+ // login nobody completes; the real error still surfaces to whoever
89
+ // awaits `promise` below.
90
+ });
91
+ pending.promise = promise;
92
+ pendingLogin = pending;
93
+ return pendingLogin;
94
+ }
95
+ /**
96
+ * Wait for a pending login's PIN to arrive, bounded by a timeout.
97
+ *
98
+ * @param pending - The pending login to wait on.
99
+ * @param timeoutMs - Maximum time to wait.
100
+ * @returns The PIN, or `null` if it did not arrive within `timeoutMs` (also
101
+ * the answer when a stored certificate skipped the PIN — check
102
+ * `certSkippedPin` to tell the two apart).
103
+ */
104
+ export async function waitForPin(pending, timeoutMs) {
105
+ if (pending.pin) {
106
+ return pending.pin;
107
+ }
108
+ return await Promise.race([
109
+ pending.pinReady.then(() => pending.pin ?? null),
110
+ new Promise((resolve) => {
111
+ setTimeout(() => resolve(null), timeoutMs);
112
+ }),
113
+ ]);
114
+ }
115
+ /**
116
+ * Await the in-flight login to completion and clear it either way. Never
117
+ * touches credentials on the failure path — `runPwlessLogin` owns that.
118
+ *
119
+ * @param pending - The pending login to finish.
120
+ * @returns The logged-in profile.
121
+ * @throws Whatever `runPwlessLogin` failed with.
122
+ */
123
+ export async function finishPendingLogin(pending) {
124
+ try {
125
+ const result = await pending.promise;
126
+ log.info('login.complete', { mid: result.mid });
127
+ return result;
128
+ }
129
+ finally {
130
+ pendingLogin = null;
131
+ }
132
+ }
@@ -1,78 +1,24 @@
1
1
  import { runPwlessLogin } from '../../cli/login.js';
2
2
  import { LINE_PIN_CODE_LIFETIME_MS, PAAK_AUTH_CLIENT_CEILING_MS, PIN_VERIFY_CLIENT_CEILING_MS, } from '../../line/auth/pwless/index.js';
3
3
  import { createCliLogger } from '../../util/log.js';
4
+ import { supportsFormElicitation } from '../client-capabilities.js';
4
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';
5
9
  import { jsonText, toolError } from './shared.js';
6
10
  const log = createCliLogger('Yomi');
7
- /** LINE region codes offered on the login elicitation form. */
8
- const LOGIN_REGIONS = ['TW', 'JP', 'TH', 'ID', 'US'];
9
- /**
10
- * How long a live `pendingLogin` may be reused by a later `login` call for
11
- * the same phone/region, before treating it as stale and starting fresh.
12
- *
13
- * This is bound to LINE's real PIN code lifetime (~line/auth/pwless/index.ts,
14
- * an external fact from LINE Help Center, ~3 minutes) — NOT to how long
15
- * yomi's client is willing to keep polling (~16 minutes). A login started
16
- * 90 seconds ago is still very much alive and its PIN is still valid; the
17
- * only thing that actually kills a pending login early is LINE's own code
18
- * expiring, so that is the correct bound for "is this still worth reusing".
19
- */
20
- const PIN_WINDOW_MS = LINE_PIN_CODE_LIFETIME_MS;
21
11
  /**
22
12
  * How long yomi's client keeps polling for the device-approval step once a
23
13
  * PIN has been verified (or skipped via a stored certificate) — the real
24
- * 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
25
15
  * the human, not to bound anything server-side.
26
16
  */
27
17
  const APPROVAL_WINDOW_MS = PAAK_AUTH_CLIENT_CEILING_MS;
28
- /** How long `login` waits for LINE to issue a PIN before reporting a failure. */
29
- const PIN_WAIT_TIMEOUT_MS = 20000;
30
- /**
31
- * Elicit `phone`/`region` from the human in one form, pre-filling whatever
32
- * the caller already supplied.
33
- *
34
- * @param server - MCP Server instance (elicitation already capability-gated
35
- * by the caller).
36
- * @param phone - Phone already supplied by the tool call, if any.
37
- * @param region - Region already supplied by the tool call, if any.
38
- * @returns The collected phone/region, or `null` if the human declined/cancelled.
39
- */
40
- async function elicitPhoneRegion(server, phone, region) {
41
- const collected = await server.elicitInput({
42
- message: 'Yomi needs your LINE phone number and region to start passwordless login.',
43
- requestedSchema: {
44
- type: 'object',
45
- properties: {
46
- phone: {
47
- type: 'string',
48
- title: 'Phone number',
49
- description: 'E.164 form, e.g. +8869XXXXXXXX.',
50
- ...(phone ? { default: phone } : {}),
51
- },
52
- region: {
53
- type: 'string',
54
- title: 'Region',
55
- enum: LOGIN_REGIONS,
56
- ...(region ? { default: region } : {}),
57
- },
58
- },
59
- required: ['phone', 'region'],
60
- },
61
- });
62
- if (collected.action !== 'accept') {
63
- return null;
64
- }
65
- const collectedPhone = String(collected.content?.phone ?? '');
66
- const collectedRegion = String(collected.content?.region ?? '');
67
- if (!collectedPhone || !collectedRegion) {
68
- return null;
69
- }
70
- return { phone: collectedPhone, region: collectedRegion };
71
- }
72
18
  /**
73
19
  * Elicit acknowledgement of the login PIN — fire-and-forget from the
74
- * caller's perspective (must not block `startPwlessLogin`, which is
75
- * 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).
76
22
  *
77
23
  * @param server - MCP Server instance.
78
24
  * @param pin - The PIN LINE just issued.
@@ -82,19 +28,7 @@ async function elicitPhoneRegion(server, phone, region) {
82
28
  */
83
29
  function elicitPinAcknowledgement(server, pin, onDeclined) {
84
30
  server
85
- .elicitInput({
86
- message: `LINE PIN: ${pin}\n\nEnter this PIN in the LINE app on your primary phone, then approve ` +
87
- 'the new device from there. Acknowledge below once you have done this.',
88
- requestedSchema: {
89
- type: 'object',
90
- properties: {
91
- entered: {
92
- type: 'boolean',
93
- title: 'I entered the PIN and approved the device on my phone',
94
- },
95
- },
96
- },
97
- })
31
+ .elicitInput(buildAcknowledgementForm(pin))
98
32
  .then((result) => {
99
33
  if (result.action !== 'accept') {
100
34
  onDeclined();
@@ -107,12 +41,13 @@ function elicitPinAcknowledgement(server, pin, onDeclined) {
107
41
  });
108
42
  }
109
43
  /**
110
- * Handle `login` for a client that supports MCP elicitation — drive the
111
- * passwordless flow entirely through elicitation, with no out-of-band
112
- * 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
113
49
  * surfaced through a second elicitation fired without blocking
114
- * `startPwlessLogin`, which keeps running while the human acts on their
115
- * phone. See ../cli/login.ts for the shared login sequence.
50
+ * `runPwlessLogin`, which keeps running while the human acts on their phone.
116
51
  *
117
52
  * @param server - MCP Server instance (for elicitInput).
118
53
  * @param service - LineProtocolService (not yet authenticated).
@@ -120,15 +55,19 @@ function elicitPinAcknowledgement(server, pin, onDeclined) {
120
55
  * @returns MCP tool result describing the logged-in profile.
121
56
  */
122
57
  async function handleLoginElicitation(server, service, args) {
123
- let { phone, region } = args;
124
- if (!phone || !region) {
125
- const collected = await elicitPhoneRegion(server, phone, region);
126
- if (!collected) {
127
- 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);
128
67
  }
129
- ;
130
- ({ phone, region } = collected);
68
+ return toolError('Login was cancelled by the user.');
131
69
  }
70
+ const { phone, region } = preflight;
132
71
  let pinDeclined = false;
133
72
  const onPin = (pin) => {
134
73
  log.warn('login.pin', { action: 'elicit_acknowledgement' });
@@ -163,90 +102,12 @@ async function handleLoginElicitation(server, service, args) {
163
102
  return toolError(error?.message ?? String(error));
164
103
  }
165
104
  }
166
- let pendingLogin = null;
167
105
  /**
168
- * Start a new passwordless login in the background and register it as the
169
- * one in-flight pending login.
170
- *
171
- * The stored promise is the actual `runPwlessLogin` promise, so
172
- * `login_complete` can await it and see the real success/failure. A
173
- * `.catch()` is attached immediately (its result discarded, not stored) so
174
- * an abandoned login — one nobody ever calls `login_complete` for — never
175
- * surfaces as an unhandled rejection; this does not affect what
176
- * `pendingLogin.promise` itself resolves/rejects with.
177
- *
178
- * @param service - LineProtocolService (not yet authenticated).
179
- * @param phone - Phone number in E.164 form.
180
- * @param region - Region code.
181
- * @returns The newly registered pending-login record.
182
- */
183
- function startPendingLogin(service, phone, region) {
184
- let resolvePinReady = () => { };
185
- const pinReady = new Promise((resolve) => {
186
- resolvePinReady = resolve;
187
- });
188
- const pending = {
189
- phone,
190
- region,
191
- pin: null,
192
- certSkippedPin: false,
193
- startedAt: Date.now(),
194
- pinReady,
195
- };
196
- const onPin = (pin) => {
197
- pending.pin = pin;
198
- resolvePinReady();
199
- };
200
- const onBiometric = () => {
201
- log.warn('login.waiting_biometric', {
202
- action: 'approve the new device on your phone',
203
- });
204
- // Reaching biometric wait with no PIN ever set means a valid stored
205
- // certificate skipped the PIN step entirely (see LinePwlessLogin.login,
206
- // step 2-3) — resolve `pinReady` now so `waitForPin` doesn't block out
207
- // its full timeout waiting for a PIN that will never arrive.
208
- if (!pending.pin) {
209
- pending.certSkippedPin = true;
210
- resolvePinReady();
211
- }
212
- };
213
- const promise = runPwlessLogin(service, phone, region, {
214
- onPin,
215
- onWaitingBiometric: onBiometric,
216
- });
217
- promise.catch(() => {
218
- // Swallow here only to prevent an unhandled-rejection warning for a
219
- // login nobody completes; the real error still surfaces to whoever
220
- // awaits `pending.promise` (login_complete) below.
221
- });
222
- pending.promise = promise;
223
- return pending;
224
- }
225
- /**
226
- * Wait for a pending login's PIN to arrive, bounded by a timeout.
227
- *
228
- * @param pending - The pending login to wait on.
229
- * @param timeoutMs - Maximum time to wait.
230
- * @returns The PIN, or `null` if it did not arrive within `timeoutMs`.
231
- */
232
- async function waitForPin(pending, timeoutMs) {
233
- if (pending.pin) {
234
- return pending.pin;
235
- }
236
- const outcome = await Promise.race([
237
- pending.pinReady.then(() => pending.pin ?? null),
238
- new Promise((resolve) => {
239
- setTimeout(() => resolve(null), timeoutMs);
240
- }),
241
- ]);
242
- return outcome;
243
- }
244
- /**
245
- * Handle `login` for a client that does NOT support MCP elicitation
246
- * (confirmed empirically for Claude Desktop). Splits the flow into two
247
- * tool calls since the PIN cannot be prompted for in-band: this call
248
- * starts (or reuses) the passwordless flow and returns the PIN as soon as
249
- * 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.
250
111
  *
251
112
  * The MCP Apps view (./ui/login-app.ts) has never been observed to render on
252
113
  * any deployed client (see that file's header) — resources/read succeeds
@@ -297,6 +158,9 @@ async function handleLoginNoElicitation(service, args, supportsUi) {
297
158
  type: 'text',
298
159
  text: 'Ask the human for their LINE phone number (E.164 form, e.g. +8869XXXXXXXX) and region ' +
299
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. ' +
300
164
  'A login form may also appear for the human to fill in directly — if they use it, the ' +
301
165
  'login proceeds on its own and there is nothing further for you to do — but do not wait ' +
302
166
  'for that; if no form appears, asking the human directly is how this login makes progress.',
@@ -310,14 +174,13 @@ async function handleLoginNoElicitation(service, args, supportsUi) {
310
174
  missing.push('phone');
311
175
  if (!region)
312
176
  missing.push('region');
313
- 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 ` +
314
178
  `${missing.join('/')} must be supplied as a tool argument, or persisted by running ` +
315
- '`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.');
316
182
  }
317
- const now = Date.now();
318
- const active = pendingLogin && now - pendingLogin.startedAt < PIN_WINDOW_MS
319
- ? pendingLogin
320
- : null;
183
+ const active = getLivePendingLogin(Date.now());
321
184
  if (active && (active.phone !== phone || active.region !== region)) {
322
185
  // A live login for a DIFFERENT number is still in flight. Never hand
323
186
  // back its PIN under this call's identity, and never silently start a
@@ -327,10 +190,8 @@ async function handleLoginNoElicitation(service, args, supportsUi) {
327
190
  'before starting a login for another number.');
328
191
  }
329
192
  // Reuse the in-flight login only when it matches this number; otherwise
330
- // start a fresh one. `pending` is non-null either way, so no narrowing of
331
- // the module-level `pendingLogin` is needed downstream.
193
+ // start a fresh one.
332
194
  const pending = active ?? startPendingLogin(service, phone, region);
333
- pendingLogin = pending;
334
195
  const pin = await waitForPin(pending, PIN_WAIT_TIMEOUT_MS);
335
196
  if (!pin) {
336
197
  if (pending.certSkippedPin) {
@@ -343,14 +204,7 @@ async function handleLoginNoElicitation(service, args, supportsUi) {
343
204
  pin: null,
344
205
  clientApprovalPollCeilingSeconds: Math.round(APPROVAL_WINDOW_MS / 1000),
345
206
  };
346
- const certSteps = 'No PIN needed — LINE recognized a stored login certificate from a previous login, ' +
347
- 'so the PIN step was skipped.\n\n' +
348
- '0. This only works if the primary phone has 設定 > 我的帳號 > 允許自其他裝置登入 ' +
349
- 'enabled — without it LINE will not offer this device a sign-in prompt at all.\n' +
350
- '1. Bring the LINE app to the foreground on your primary phone — it should prompt you ' +
351
- 'to approve this new device signing in.\n' +
352
- '2. Approve the device from there.\n\n';
353
- const certText = certSteps +
207
+ const certText = certSteps() +
354
208
  'Call the `login_complete` tool (no arguments) IMMEDIATELY now — do not wait for ' +
355
209
  'confirmation that the approval happened first. `login_complete` blocks by itself while ' +
356
210
  `you approve (up to about ${Math.round(APPROVAL_WINDOW_MS / 60000)} minutes) and returns ` +
@@ -374,16 +228,7 @@ async function handleLoginNoElicitation(service, args, supportsUi) {
374
228
  clientPinPollCeilingSeconds: Math.round(PIN_VERIFY_CLIENT_CEILING_MS / 1000),
375
229
  clientApprovalPollCeilingSeconds: Math.round(APPROVAL_WINDOW_MS / 1000),
376
230
  };
377
- const pinSteps = 'LINE PIN:\n\n' +
378
- ` ${pin}\n\n` +
379
- '0. This only works if the primary phone has 設定 > 我的帳號 > 允許自其他裝置登入 enabled — ' +
380
- 'without it LINE will not offer this device a sign-in prompt at all.\n' +
381
- '1. Open LINE on the primary phone. If a verification prompt does not appear immediately, ' +
382
- 'bringing the app to the foreground surfaces it.\n' +
383
- `2. Enter this PIN and tick the device that is signing in, then tap 「用戶確認」 — LINE gives ` +
384
- `you ${pinCodeLifetimeSeconds} seconds (about 3 minutes) from when the code was shown to do this.\n` +
385
- '3. LINE will then ask that same phone to approve this new device signing in — approve it there.\n\n';
386
- const pinText = pinSteps +
231
+ const pinText = pinSteps(pin) +
387
232
  `Call the \`login_complete\` tool (no arguments) IMMEDIATELY now — do not wait until you have ` +
388
233
  'entered the PIN or approved the device. `login_complete` blocks by itself through both of ' +
389
234
  `those steps: it keeps waiting up to about ${clientPinPollCeilingMinutes} minutes for step 2 and ` +
@@ -397,41 +242,50 @@ async function handleLoginNoElicitation(service, args, supportsUi) {
397
242
  };
398
243
  }
399
244
  /**
400
- * Handle `login` — dispatches to the elicitation flow when the connected
401
- * client supports it, otherwise the two-phase flow that finishes via
402
- * `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.
403
247
  *
404
- * @param server - MCP Server instance (for elicitInput/getClientCapabilities).
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.
254
+ *
255
+ * @param server - MCP Server instance (for elicitInput on the legacy path).
405
256
  * @param service - LineProtocolService (not yet authenticated).
406
257
  * @param args - Tool arguments.
407
- * @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.
408
261
  */
409
- export async function handleLogin(server, service, args, capabilities = server.getClientCapabilities(), allowPushElicitation = true) {
410
- if (allowPushElicitation && capabilities?.elicitation) {
411
- 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);
412
268
  }
413
269
  return handleLoginNoElicitation(service, args, supportsMcpApps(capabilities));
414
270
  }
415
271
  /**
416
272
  * Handle `login_complete` — finish a login started by a prior `login` call
417
- * on a non-elicitation client. Awaits the same in-flight `runPwlessLogin`
418
- * promise `login` started, so the real success/failure surfaces here.
419
- * 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.
420
276
  *
421
277
  * @returns MCP tool result describing the logged-in profile, or an honest error.
422
278
  */
423
279
  export async function handleLoginComplete() {
424
- if (!pendingLogin) {
280
+ const pending = getPendingLogin();
281
+ if (!pending) {
425
282
  return toolError('No login is pending. Call `login` first to start a passwordless login. ' +
426
283
  '(If you already called `login`, a previous attempt may have finished or its PIN code may have ' +
427
284
  `expired — LINE only gives the human about ${Math.round(LINE_PIN_CODE_LIFETIME_MS / 1000)} seconds ` +
428
285
  '(about 3 minutes) to act on a shown code. Calling `login` again is safe.)');
429
286
  }
430
- const pending = pendingLogin;
431
287
  try {
432
- const { mid, displayName } = await pending.promise;
433
- pendingLogin = null;
434
- log.info('login.complete', { mid });
288
+ const { mid, displayName } = await finishPendingLogin(pending);
435
289
  return {
436
290
  content: [
437
291
  {
@@ -442,7 +296,6 @@ export async function handleLoginComplete() {
442
296
  };
443
297
  }
444
298
  catch (error) {
445
- pendingLogin = null;
446
299
  return toolError(error?.message ?? String(error));
447
300
  }
448
301
  }
@@ -12,7 +12,9 @@ import { LineProtocolService } from '../line/core/service.js';
12
12
  import { startCapture } from '../search/capture.js';
13
13
  import { getDefaultEmbedder } from '../search/default-embedder.js';
14
14
  import { createCliLogger } from '../util/log.js';
15
+ import { checkForUpdate } from '../util/update-check.js';
15
16
  import { YOMI_VERSION } from '../version.js';
17
+ import { clientCapabilitiesOf } from './client-capabilities.js';
16
18
  import { handleAcceptInvitation, handleAddFriend, handleBlockContact, handleCancelReaction, handleCollectMessages, handleCreateGroup, handleExcludeChats, handleFindContact, handleGetChatMessages, handleGetGroupMembers, handleGetInsight, handleGetMessageImage, handleGetMessageMedia, handleGetScopePolicy, handleGetUnreadDigest, handleIncludeChats, handleInviteMember, handleKickMember, handleLeaveGroup, handleListContacts, handleListConversations, handleListExcludedChats, handleListStickers, handleLogin, handleLoginComplete, handleMarkRead, handlePreviewSticker, handleReactMessage, handleRenameGroup, handleSearchMessages, handleSearchStickers, handleSendAudio, handleSendContact, handleSendFile, handleSendImage, handleSendLocation, handleSendMessage, handleSendSticker, handleSendVideo, handleUnblockContact, handleUnsendMessage, NO_CREDENTIALS_MESSAGE, sessionExpiredError, sessionRequiredError, sessionRevokedError, toolError, } from './handlers/index.js';
17
19
  import { getPrivacyPolicyText } from './policy.js';
18
20
  import { TOOLS } from './tools.js';
@@ -42,12 +44,21 @@ async function main() {
42
44
  });
43
45
  });
44
46
  }
47
+ // Asked once per process, bounded, best-effort: a hand-installed .mcpb
48
+ // bundle never auto-updates, so this notice is the only way a GUI-only user
49
+ // learns a newer Yomi exists (see ../util/update-check.ts). Awaited here
50
+ // because `instructions` is built once, before any client connects.
51
+ const updateNotice = await checkForUpdate();
52
+ if (updateNotice) {
53
+ log.info('update.available', { current: YOMI_VERSION });
54
+ }
45
55
  // `instructions` is surfaced by the MCP SDK to the client/model on
46
56
  // initialize — a "TOS on connect" privacy disclosure. It is a consent
47
57
  // notice, not decoration. A short model directive precedes the canonical
48
58
  // policy prose, which lives ONLY in PRIVACY.md (see ./policy.ts) so the
49
59
  // disclosure is single-sourced and never drifts from get_scope_policy.
50
- const instructions = 'SESSION ERRORS — if a tool fails with a login-required or signed-out ' +
60
+ const instructions = (updateNotice ? `${updateNotice}\n\n` : '') +
61
+ 'SESSION ERRORS — if a tool fails with a login-required or signed-out ' +
51
62
  'error, the MCP server and its connection are healthy: LINE revoked this ' +
52
63
  "device's session (usually because the same account logged in somewhere " +
53
64
  'else). Tell the user that plainly and offer the `login` tool. Never ' +
@@ -101,7 +112,7 @@ async function main() {
101
112
  server.setRequestHandler('tools/list', async () => {
102
113
  return { tools: toolsForClient(TOOLS, true) };
103
114
  });
104
- server.setRequestHandler('tools/call', async (request) => {
115
+ server.setRequestHandler('tools/call', async (request, ctx) => {
105
116
  const { name, arguments: args } = request.params;
106
117
  // `login` is the one tool allowed without an existing session — it is
107
118
  // how a session gets created. `search_messages` also runs without a
@@ -136,7 +147,15 @@ async function main() {
136
147
  try {
137
148
  switch (name) {
138
149
  case 'login':
139
- return await handleLogin(server, service, (args ?? {}), server.getClientCapabilities(), era === 'legacy');
150
+ // `era` selects HOW the login talks to the human: the legacy era
151
+ // can push `elicitation/create`, the modern one answers with
152
+ // multi-round-trip `input_required` results the client fulfils
153
+ // and retries (`ctx.mcpReq.inputResponses` carries the answers).
154
+ return await handleLogin(server, service, (args ?? {}), {
155
+ era,
156
+ capabilities: clientCapabilitiesOf(server, ctx),
157
+ inputResponses: ctx.mcpReq.inputResponses,
158
+ });
140
159
  case 'login_complete':
141
160
  return await handleLoginComplete();
142
161
  case 'list_conversations':
package/dist/mcp/tools.js CHANGED
@@ -1,6 +1,7 @@
1
+ import { PRIMARY_DEVICE_SETTING_PATH } from './handlers/login-copy.js';
1
2
  export const TOOLS = [
2
3
  {
3
- description: 'Log in to LINE via the passwordless secondary-device flow. Requires 允許自其他裝置登入 enabled on the primary phone. On MCP clients with elicitation, this call prompts for phone/region and PIN and completes login by itself. On clients without it (e.g. Claude Desktop), phone/region come from the arguments or a persisted login, and this returns as soon as LINE issues the PIN (or reports none needed) — then call login_complete IMMEDIATELY (do not wait for the human). LINE gives ~3 minutes from PIN display to confirm on the phone; login_complete blocks past that, so calling it late only wastes that window.',
4
+ description: `Log in to LINE via the passwordless secondary-device flow. Requires ${PRIMARY_DEVICE_SETTING_PATH} enabled on the primary phone — with it off LINE never prompts that phone and NO login can succeed, so raise this with the human up front rather than after a failure. On MCP clients with form elicitation, this call first confirms that setting, then prompts for phone/region and PIN and completes login by itself; if the human says the setting is off, it returns the enabling steps without starting a login (relay them verbatim, then call \`login\` again). On clients without it (e.g. Claude Desktop), phone/region come from the arguments or a persisted login, and this returns as soon as LINE issues the PIN (or reports none needed) — then call login_complete IMMEDIATELY (do not wait for the human). LINE gives ~3 minutes from PIN display to confirm on the phone; login_complete blocks past that, so calling it late only wastes that window.`,
4
5
  inputSchema: {
5
6
  type: 'object',
6
7
  properties: {
@@ -17,7 +18,7 @@ export const TOOLS = [
17
18
  name: 'login',
18
19
  },
19
20
  {
20
- description: "Finish a passwordless login that `login` started on a client without elicitation (not needed on elicitation-capable clients). No arguments. Call immediately after `login` returns — do not wait for the human. Blocks while they enter the PIN (skipped if a stored certificate is valid) and approve the device, then returns the profile. LINE's real deadline is ~3 minutes from PIN display. Errors if no login is pending.",
21
+ description: "Finish a passwordless login that `login` started on a client without form elicitation (not needed on form-elicitation-capable clients). No arguments. Call immediately after `login` returns — do not wait for the human. Blocks while they enter the PIN (skipped if a stored certificate is valid) and approve the device, then returns the profile. LINE's real deadline is ~3 minutes from PIN display. Errors if no login is pending.",
21
22
  inputSchema: {
22
23
  type: 'object',
23
24
  properties: {},
@@ -0,0 +1,121 @@
1
+ /**
2
+ * "Is this copy of Yomi out of date?" — asked once at startup, answered to
3
+ * the model through the MCP `instructions` so it can tell the human.
4
+ *
5
+ * Why this exists: the drag-and-drop `.mcpb` bundle has NO auto-update. The
6
+ * MCPB manifest spec defines no update URL or endpoint (its `version` is
7
+ * plain metadata), and Claude Desktop only auto-updates extensions installed
8
+ * from the official directory — "for privately distributed extensions, users
9
+ * will need to install updated .mcpb files manually". Yomi's bundles are
10
+ * attached to GitHub Releases, so without this a user can sit on an old
11
+ * build indefinitely without ever knowing.
12
+ *
13
+ * It cannot self-update: a bundle carries native binaries (onnxruntime-node,
14
+ * sharp) and overwriting itself while running is neither safe nor portable.
15
+ * Telling the human, with the right link for how they installed, is the whole
16
+ * scope.
17
+ *
18
+ * PRIVACY. This is the ONE outbound request Yomi makes that is not to LINE,
19
+ * and it is disclosed in PRIVACY.md. It sends no message content and no
20
+ * account identifier — it is a plain GET of a public npm registry document.
21
+ * What it does reveal to the registry is that some IP is running Yomi, and
22
+ * (by what it does with the answer) roughly which version. `YOMI_NO_UPDATE_CHECK=1`
23
+ * turns it off completely.
24
+ */
25
+ import { YOMI_VERSION } from '../version.js';
26
+ /**
27
+ * The registry document for the published package. Queried directly rather
28
+ * than through `npm view`, which reads a CDN-cached view that lags behind a
29
+ * publish by minutes.
30
+ */
31
+ const LATEST_URL = 'https://registry.npmjs.org/@rikaidev/yomi/latest';
32
+ /** How long the check may take before startup gives up on it. */
33
+ const TIMEOUT_MS = 1500;
34
+ /**
35
+ * Compare two semver core versions (`major.minor.patch`).
36
+ *
37
+ * Pre-release suffixes are ignored on BOTH sides, which is deliberate: the
38
+ * only question here is "is a newer release available", and a local
39
+ * pre-release of the same core version should not nag.
40
+ *
41
+ * @param a - Left version.
42
+ * @param b - Right version.
43
+ * @returns Positive when `a` is newer, negative when older, 0 when equal.
44
+ */
45
+ export function compareVersions(a, b) {
46
+ const parse = (value) => value
47
+ .trim()
48
+ .replace(/^v/, '')
49
+ .split('-')[0]
50
+ .split('.')
51
+ .map((part) => Number.parseInt(part, 10) || 0);
52
+ const left = parse(a);
53
+ const right = parse(b);
54
+ for (let i = 0; i < 3; i++) {
55
+ const diff = (left[i] ?? 0) - (right[i] ?? 0);
56
+ if (diff !== 0) {
57
+ return diff;
58
+ }
59
+ }
60
+ return 0;
61
+ }
62
+ /**
63
+ * How this copy of Yomi was installed, which decides what "update" means.
64
+ * The `.mcpb` build stamps `YOMI_DISTRIBUTION=mcpb` into the manifest's
65
+ * server env (scripts/build-mcpb.mjs); anything else is an npm/npx install.
66
+ *
67
+ * @returns The distribution channel.
68
+ */
69
+ function distribution() {
70
+ return process.env.YOMI_DISTRIBUTION === 'mcpb' ? 'mcpb' : 'npm';
71
+ }
72
+ /**
73
+ * Build the notice handed to the model when a newer version exists.
74
+ *
75
+ * @param latest - The newest published version.
76
+ * @returns Notice text, phrased for how this copy was installed.
77
+ */
78
+ export function updateNotice(latest) {
79
+ const how = distribution() === 'mcpb'
80
+ ? 'Desktop Extension bundles do NOT auto-update when installed by hand: the human has to ' +
81
+ 'download the new .mcpb for their platform from ' +
82
+ 'https://github.com/RikaiDev/yomi/releases/latest and drag it into Claude Desktop again ' +
83
+ '(same extension, it replaces this one).'
84
+ : 'Update with `npx @rikaidev/yomi@latest` (or reinstall the package) to pick it up.';
85
+ return (`UPDATE AVAILABLE — this Yomi is ${YOMI_VERSION}; ${latest} has been published. ` +
86
+ `${how} Mention this ONCE, early, in your own words, then carry on with what the user ` +
87
+ 'asked — it is not urgent and never a reason to refuse work.');
88
+ }
89
+ /**
90
+ * Ask the registry whether a newer Yomi has been published.
91
+ *
92
+ * Best-effort by construction: offline, slow, rate-limited, or malformed
93
+ * answers all resolve to `null`. A version check must never delay or fail a
94
+ * server that is otherwise ready to work.
95
+ *
96
+ * @returns The notice text when an update exists, otherwise null.
97
+ */
98
+ export async function checkForUpdate() {
99
+ if (process.env.YOMI_NO_UPDATE_CHECK === '1') {
100
+ return null;
101
+ }
102
+ try {
103
+ const response = await fetch(LATEST_URL, {
104
+ signal: AbortSignal.timeout(TIMEOUT_MS),
105
+ headers: { accept: 'application/json' },
106
+ });
107
+ if (!response.ok) {
108
+ return null;
109
+ }
110
+ const body = (await response.json());
111
+ const latest = typeof body.version === 'string' ? body.version : null;
112
+ if (!latest || compareVersions(latest, YOMI_VERSION) <= 0) {
113
+ return null;
114
+ }
115
+ return updateNotice(latest);
116
+ }
117
+ catch {
118
+ // Offline is the normal case here, not an error worth surfacing.
119
+ return null;
120
+ }
121
+ }
package/dist/version.js CHANGED
@@ -4,4 +4,4 @@
4
4
  * virtual filesystem). MUST stay equal to the "version" field in
5
5
  * package.json — the test in version.test.ts enforces that.
6
6
  */
7
- export const YOMI_VERSION = '0.4.0';
7
+ export const YOMI_VERSION = '0.4.2';
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "engines": {
4
4
  "node": ">=22.13.0 <23.0.0 || >=23.4.0"
5
5
  },
6
- "version": "0.4.0",
6
+ "version": "0.4.2",
7
7
  "description": "Open-source personal LINE MCP server for AI agents. Read, reply, send images, and search LINE conversations locally with Letter-Sealing E2EE.",
8
8
  "type": "module",
9
9
  "license": "MIT",