reis-mobile 0.10.0 → 1.0.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.
@@ -3,19 +3,25 @@ import { readFile } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
4
  import { parseArgs } from 'node:util';
5
5
 
6
+ import {
7
+ debugPayload,
8
+ detectPayload,
9
+ doctorPayload,
10
+ releasePayload,
11
+ reviewPayload,
12
+ routePayload,
13
+ testPayload,
14
+ } from '../core/commands/payloads.mjs';
6
15
  import { LANGUAGES, configPath, removeConfig, resolveLanguage, saveLanguage } from '../core/config/language.mjs';
7
16
  import { resolveProjectDir } from '../core/config/project.mjs';
8
- import { collectReviewContext } from '../core/context/context-engine.mjs';
9
17
  import { detectStack } from '../core/detection/stack-detector.mjs';
10
- import { runDoctor } from '../core/diagnostics/doctor.mjs';
11
18
  import { runInit } from '../core/install/init.mjs';
19
+ import { serveStdio } from '../core/mcp/server.mjs';
12
20
  import { PLUGIN_ROOT } from '../core/paths.mjs';
13
21
  import { DEFAULT_TIMEOUT_MS, consult, listProviderModels } from '../core/providers/consult.mjs';
14
22
  import { parseProviderList, providerAllowlist, resolveProviders, selectProviders } from '../core/providers/providers.mjs';
15
23
  import { loadRegistry, validateRegistry } from '../core/registry/registry.mjs';
16
- import { releaseContext } from '../core/release/context.mjs';
17
24
  import { INTENT_IDS } from '../core/router/intents.mjs';
18
- import { route } from '../core/router/router.mjs';
19
25
  import { checkForUpdate, clearUpdateCache } from '../core/update/check.mjs';
20
26
 
21
27
  const USAGE = `reis-mobile — AI agents for mobile engineering
@@ -34,12 +40,13 @@ Commands:
34
40
  release [request...] Prepare release evidence context (does not build or publish)
35
41
  providers List the external model providers and what each one needs
36
42
  consult [question...] Ask the configured external providers the same question
43
+ mcp Serve the read-only commands to MCP clients over stdio (Cursor, Claude Code, Codex...)
37
44
  agents | skills | stacks List registered components
38
45
  validate Validate the plugin's agents, skills and stacks
39
46
  update-check Check npm for a newer reis-mobile release
40
47
 
41
48
  Options:
42
- --dir <path> Project directory (default: current directory, or "app" in .reis-mobile/config.yaml)
49
+ --dir <path> Project directory (default: current directory, or "app" in .reis-mobile/config.yaml); mcp: default for tool calls
43
50
  --intent <id> Force an intent (${INTENT_IDS.join(', ')})
44
51
  --base <ref> review/test: compare <ref>...HEAD instead of the working tree
45
52
  --all doctor: check every tool, not only the relevant ones
@@ -91,6 +98,7 @@ const COMMANDS = {
91
98
  release: commandRelease,
92
99
  providers: commandProviders,
93
100
  consult: commandConsult,
101
+ mcp: commandMcp,
94
102
  agents: (options) => commandList('agents', options),
95
103
  skills: (options) => commandList('skills', options),
96
104
  stacks: (options) => commandList('stacks', options),
@@ -182,10 +190,9 @@ async function commandLang({ prompt, json }) {
182
190
  return 0;
183
191
  }
184
192
 
185
- async function commandDetect({ dir, project, json, lang }) {
186
- const detection = await detectStack(dir);
187
- const language = await resolveLanguage({ flag: lang });
188
- if (json) return print({ ...detection, language, projectConfig: project.config?.path ?? null });
193
+ async function commandDetect({ project, json, lang }) {
194
+ const detection = await detectPayload({ project, lang });
195
+ if (json) return print(detection);
189
196
 
190
197
  printProject(project);
191
198
 
@@ -195,21 +202,20 @@ async function commandDetect({ dir, project, json, lang }) {
195
202
  for (const evidence of detection.evidence) console.log(` · ${evidence}`);
196
203
  const others = detection.candidates.slice(1).map((candidate) => candidate.stack);
197
204
  if (others.length) console.log(`Also matches ${others.join(', ')}`);
198
- printLanguage(language);
205
+ printLanguage(detection.language);
199
206
  return detection.stack === 'unknown' ? 2 : 0;
200
207
  }
201
208
 
202
- async function commandDoctor({ dir, project, all, strict, json, lang }) {
203
- const report = await runDoctor({ projectDir: dir, all });
204
- const language = await resolveLanguage({ flag: lang });
209
+ async function commandDoctor({ project, all, strict, json, lang }) {
210
+ const report = await doctorPayload({ project, all, lang });
205
211
  if (json) {
206
- print({ ...report, language, projectConfig: project.config?.path ?? null });
212
+ print(report);
207
213
  } else {
208
214
  console.log('reis-mobile doctor\n');
209
215
  printProject(project);
210
216
  printDoctorChecks(report);
211
217
  console.log(report.warnings ? `${report.warnings} warning(s)` : 'No issues found');
212
- printLanguage(language);
218
+ printLanguage(report.language);
213
219
  }
214
220
  return strict && report.warnings ? 1 : 0;
215
221
  }
@@ -226,30 +232,27 @@ function printDoctorChecks(report) {
226
232
 
227
233
  const SYMBOLS = { ok: '✓', warning: '✗', info: '-', skipped: '-' };
228
234
 
229
- async function commandRoute({ dir, project, intent, prompt, json, lang }) {
235
+ async function commandRoute({ project, intent, prompt, json, lang }) {
230
236
  if (!prompt && !intent) {
231
237
  console.error('route needs a prompt or --intent.');
232
238
  return 1;
233
239
  }
234
- const result = await route({ prompt, intent, projectDir: dir });
235
- const language = await resolveLanguage({ flag: lang });
236
- if (json) return print({ ...summarizeRoute(result), language, projectConfig: project.config?.path ?? null });
240
+ const result = await routePayload({ project, prompt, intent, lang });
241
+ if (json) return print(result);
237
242
  printProject(project);
238
243
  printRoute(result);
239
- printLanguage(language);
244
+ printLanguage(result.language);
240
245
  return 0;
241
246
  }
242
247
 
243
- async function commandReview({ dir, project, base, prompt, json, lang }) {
244
- const result = await route({ prompt, intent: 'review', projectDir: dir });
245
- const context = collectReviewContext({ projectDir: dir, base });
246
- const language = await resolveLanguage({ flag: lang });
247
- if (json) return print({ ...summarizeRoute(result), language, projectConfig: project.config?.path ?? null, context });
248
+ async function commandReview({ project, base, prompt, json, lang }) {
249
+ const result = await reviewPayload({ project, base, prompt, lang });
250
+ if (json) return print(result);
248
251
 
249
252
  printProject(project);
250
253
  printRoute(result);
251
- printLanguage(language);
252
- printContext(context);
254
+ printLanguage(result.language);
255
+ printContext(result.context);
253
256
  return 0;
254
257
  }
255
258
 
@@ -264,55 +267,57 @@ function printContext(context) {
264
267
  * Routes a failure to the debug agent and skills, and adds the doctor report: most build
265
268
  * failures are toolchain mismatches (JDK, Gradle, Xcode, CocoaPods), which the report shows.
266
269
  */
267
- async function commandDebug({ dir, project, prompt, json, lang }) {
268
- const result = await route({ prompt, intent: 'debug', projectDir: dir });
269
- const doctor = await runDoctor({ projectDir: dir });
270
- const language = await resolveLanguage({ flag: lang });
271
- if (json) return print({ ...summarizeRoute(result), language, projectConfig: project.config?.path ?? null, doctor });
270
+ async function commandDebug({ project, prompt, json, lang }) {
271
+ const result = await debugPayload({ project, prompt, lang });
272
+ if (json) return print(result);
272
273
 
273
274
  printProject(project);
274
275
  printRoute(result);
275
- printLanguage(language);
276
+ printLanguage(result.language);
276
277
  console.log('');
277
- printDoctorChecks(doctor);
278
+ printDoctorChecks(result.doctor);
278
279
  return 0;
279
280
  }
280
281
 
281
282
  /** Prepare evidence for the test agent; running the selected suite is the agent's job. */
282
- async function commandTest({ dir, project, base, prompt, json, lang }) {
283
- const result = await route({ prompt, intent: 'test', projectDir: dir });
284
- const context = collectReviewContext({ projectDir: dir, base });
285
- const doctor = await runDoctor({ projectDir: dir });
286
- const language = await resolveLanguage({ flag: lang });
287
- if (json) return print({ ...summarizeRoute(result), language, projectConfig: project.config?.path ?? null, context, doctor });
283
+ async function commandTest({ project, base, prompt, json, lang }) {
284
+ const result = await testPayload({ project, base, prompt, lang });
285
+ if (json) return print(result);
288
286
 
289
287
  // A test runner needs an explicit working directory even without a monorepo config.
290
288
  printProject(project, true);
291
289
  printRoute(result);
292
- printLanguage(language);
293
- printContext(context);
290
+ printLanguage(result.language);
291
+ printContext(result.context);
294
292
  console.log('\nTest context prepared; no test suites were executed.\n');
295
- printDoctorChecks(doctor);
293
+ printDoctorChecks(result.doctor);
296
294
  return 0;
297
295
  }
298
296
 
299
297
  /** Prepare release evidence; the specialist audits it before assigning a verdict. */
300
- async function commandRelease({ dir, project, prompt, json, lang }) {
301
- const result = await route({ prompt, intent: 'release', projectDir: dir });
302
- const context = collectReviewContext({ projectDir: dir });
303
- const doctor = await runDoctor({ projectDir: dir });
304
- const language = await resolveLanguage({ flag: lang });
305
- const release = releaseContext(result.detection);
306
- if (json) return print({ ...summarizeRoute(result), language, projectConfig: project.config?.path ?? null, context, doctor, release });
298
+ async function commandRelease({ project, prompt, json, lang }) {
299
+ const result = await releasePayload({ project, prompt, lang });
300
+ if (json) return print(result);
307
301
 
302
+ const { release } = result;
308
303
  printProject(project, true);
309
304
  printRoute(result);
310
- printLanguage(language);
311
- printContext(context);
305
+ printLanguage(result.language);
306
+ printContext(result.context);
312
307
  console.log(`\nRelease platforms (verify) ${release.platforms.join(', ') || 'unknown'}`);
313
308
  for (const reference of release.references) console.log(` Reference ${reference}`);
314
309
  console.log('\nRelease context prepared; readiness has not been evaluated. No builds, tests or uploads were executed.\n');
315
- printDoctorChecks(doctor);
310
+ printDoctorChecks(result.doctor);
311
+ return 0;
312
+ }
313
+
314
+ /**
315
+ * Serves the read-only commands to MCP clients over stdio. Nothing but protocol messages may
316
+ * reach stdout, so this command prints nothing itself; `--dir` is the default for tool calls.
317
+ */
318
+ async function commandMcp({ dir }) {
319
+ const pkg = JSON.parse(await readFile(join(PLUGIN_ROOT, 'package.json'), 'utf8'));
320
+ await serveStdio({ version: pkg.version, defaultDir: dir });
316
321
  return 0;
317
322
  }
318
323
 
@@ -450,11 +455,6 @@ async function commandValidate({ json }) {
450
455
  return errors.length ? 1 : 0;
451
456
  }
452
457
 
453
- function summarizeRoute(result) {
454
- const component = (item) => item && { name: item.name, description: item.description, path: item.path };
455
- return { ...result, agent: component(result.agent), skills: result.skills.map(component) };
456
- }
457
-
458
458
  function printRoute(result) {
459
459
  const confidence = result.intentSource === 'explicit' ? 'explicit' : `confidence ${result.confidence}`;
460
460
  console.log(`Intent ${result.intent ?? '-'} (${confidence})${result.area ? ` · area ${result.area}` : ''}`);
@@ -1,5 +1,5 @@
1
1
  ---
2
- description: Structured debate between the mobile specialists — each agent defends its perspective, rebuts the others, and lead-mobile decides
2
+ description: Structured debate between the mobile specialists — lead-mobile selects the participants, specialists defend and rebut positions, and lead-mobile returns to decide
3
3
  argument-hint: "[--rounds N] [--agents a,b,c] [--external] [--providers a,b] <question>"
4
4
  allowed-tools: ["Bash(node:*)", "Bash(mkdir:*)", "Agent", "Read", "Write", "Grep", "Glob"]
5
5
  ---
@@ -8,7 +8,7 @@ allowed-tools: ["Bash(node:*)", "Bash(mkdir:*)", "Agent", "Read", "Write", "Grep
8
8
 
9
9
  Arguments received: `$ARGUMENTS`
10
10
 
11
- Use this command for architecture decisions with real trade-offs, not for routine questions: every debate costs several agents across two rounds. If the question has a single answer that can be verified in the code, answer it directly and tell the user why it was not worth a debate.
11
+ Use this command for mobile decisions with real trade-offs, including architecture, UI/UX, accessibility and release readiness. A debate runs several specialists over the configured rounds; avoid it for routine questions. If the question has a single answer that can be verified in the code, answer it directly and tell the user why it was not worth a debate.
12
12
 
13
13
  ## 1. Parse the arguments
14
14
 
@@ -31,9 +31,11 @@ The JSON carries the stack and the `language` for the whole debate: `en` English
31
31
 
32
32
  If the question mentions files or directories, resolve the paths and read them now: participants receive the content in the briefing, not the path. If it fails, show the error and stop.
33
33
 
34
- ## 3. Pick the participants
34
+ ## 3. Lead selects the participants
35
35
 
36
- Choose **three** agents whose priorities genuinely conflict on the question. A debate between roles that agree produces no decision.
36
+ Invoke `Agent(subagent_type: "reis-mobile:lead-mobile", model: "opus")` with `phase: selection`, following the **Debate selection** section of `${CLAUDE_PLUGIN_ROOT}/agents/lead-mobile.md`. Pass the question, detected stack and native targets, language, relevant file contents, flags and the complete candidate/model table below. The lead owns participant selection; the command executes the returned plan and does not independently replace participants.
37
+
38
+ Ask the lead to choose **three** distinct specialists automatically, with relevant perspectives that can expose the question's trade-offs. Disagreement must come from evidence and competing constraints, not a requirement to invent conflict. Every specialist in the plugin is eligible; the lead itself is the selector and moderator, not a debater.
37
39
 
38
40
  | Agent | Defends | Model |
39
41
  |---|---|---|
@@ -55,19 +57,24 @@ Choose **three** agents whose priorities genuinely conflict on the question. A d
55
57
  | `reis-mobile:plugin-native-expert` | Flutter–native boundary, platform channels, lifecycle | `opus` |
56
58
  | `reis-mobile:mobile-staff-engineer` | Build, release, migration, cross-platform trade-offs | `opus` |
57
59
  | `reis-mobile:mobile-code-reviewer` | Concrete risk in the code that already exists | `sonnet` |
60
+ | `reis-mobile:mobile-ui-ux-designer` | User journeys, visual hierarchy, interaction cost and design consistency | `opus` |
61
+ | `reis-mobile:mobile-accessibility-auditor` | Assistive interaction, readable content, focus and inclusive task completion | `sonnet` |
62
+ | `reis-mobile:mobile-release-engineer` | Artifact provenance, release gates, rollout and production readiness | `opus` |
58
63
 
59
- Pass the `model` from the table on every `Agent` call. It overrides the `model: inherit` the agents declare, and that is what keeps the debate from turning into a monologue: with everyone on the same model, participants inherit the same biases and the same blind spots, and the disagreement stays on the surface of the role. The table pairs model strength with role — `opus` where the question is a long-term trade-off, `sonnet` where it is concrete implementation detail.
64
+ Pass the `model` from the table on each participant's `Agent` call. The lead uses `opus` for both selection and synthesis. Model diversity is a configured perspective check, not proof of independent reasoning or correct conclusions.
60
65
 
61
- Automatic selection must end with **at least two different models** among the participants. If the question leads to three agents on the same model, swap one of them for the most relevant adjacent agent that uses the other model, and tell the user you did so.
66
+ Automatic selection must end with **at least two different models** among the participants. The lead must choose a relevant adjacent specialist using the other model when needed and explain the trade-off. If no relevant mixed-model trio fits the evidence, the lead reports that limitation instead of adding an unrelated role; resolve the scope before launching rounds.
62
67
 
63
68
  Selection rules:
64
69
 
65
- - The detected stack rules. The `flutter-*` agents only join Flutter projects, `android-*` native Android, `ios-*` native iOS, `rn-*` React Native and `kmp-*` Kotlin Multiplatform. Pick the architect, performance and test specialists of the detected stack first; `mobile-staff-engineer` and `mobile-code-reviewer` fit any stack.
66
- - Without native code in the project, do not call `plugin-native-expert`.
67
- - `--agents` overrides the automatic choice, but not the models: each agent keeps the one from the table. Unknown name: warn and stop.
68
- - `reis-mobile:lead-mobile` never debates — it moderates in step 7, always on `opus`, because synthesizing conflicting positions is the heaviest work in the flow.
70
+ - The detected stack rules. Match `flutter-*` to Flutter, `android-*` to native Android, `ios-*` to native iOS, `rn-*` to React Native and `kmp-*` Kotlin Multiplatform. For architecture questions, consider the stack's architect, performance and test specialists. For design, accessibility or release questions, prioritize the corresponding cross-stack specialists. All `mobile-*` specialists fit any stack; native specialists may join cross-platform projects when the question implicates their native paths.
71
+ - Select `plugin-native-expert` only for Flutter projects with an implicated native bridge or plugin path.
72
+ - `--agents` overrides automatic membership, but not the models. The lead validates the supplied names (with or without the `reis-mobile:` prefix), requires at least two distinct specialists and preserves valid explicit choices. Unknown names, duplicates or the lead as a participant: explain the error and stop before rounds. An explicit same-model selection is allowed; disclose that limitation without substituting participants.
73
+ - `reis-mobile:lead-mobile` never debates: it selects in step 3 and returns to moderate in step 7. Neither lead call launches participants or external providers.
74
+
75
+ The lead returns a selection plan containing each participant's exact name, model, assigned question, rationale and evidence scope, plus unresolved constraints. Check the returned names/models against the table, uniqueness, explicit membership and the automatic model mix. If invalid, return the validation errors to the lead for correction before starting; do not silently repair its choices. If Agent delegation is unavailable, report that the debate could not run rather than claiming specialists participated.
69
76
 
70
- Before starting, tell the user in one line the stack, the participants with their models and the number of rounds.
77
+ Before starting, tell the user in one line the stack, the lead-selected participants with their models and the number of rounds.
71
78
 
72
79
  ## 4. Prepare the folder
73
80
 
@@ -75,11 +82,11 @@ Before starting, tell the user in one line the stack, the participants with thei
75
82
  mkdir -p ".reis-mobile/debates/<NNN-question-slug>/rounds"
76
83
  ```
77
84
 
78
- `NNN` is sequential within `.reis-mobile/debates/`. Write `context.md` in the folder with the question, the stack, the language, the participants, the flags and the context read in step 2.
85
+ `NNN` is sequential within `.reis-mobile/debates/`. Write `context.md` in the folder with the question, the stack, the language, the participants, the flags, the lead's selection plan with its rationale and the context read in step 2.
79
86
 
80
87
  ## 5. Round 1 — blind positions
81
88
 
82
- Launch the participants **in parallel**, each with `Agent(subagent_type: "<agent>", model: "<model from the table>", run_in_background: true)`, in a single tool block. Nobody sees anyone else's position in this round: that is what keeps the first to answer from anchoring the rest.
89
+ Launch the participants **in parallel**, each with `Agent(subagent_type: "<agent>", model: "<model from the table>", run_in_background: true)`, in a single tool block. Pass each specialist its assigned question and evidence scope from the lead's plan. Instruct every participant to return analysis only, without editing app code or launching subagents. Nobody sees anyone else's position in this round: that is what keeps the first to answer from anchoring the rest.
83
90
 
84
91
  Each briefing, self-contained:
85
92
 
@@ -140,7 +147,7 @@ Rebutting the generic thesis does not count: every rebuttal cites a concrete cla
140
147
 
141
148
  ## 7. Synthesis
142
149
 
143
- Invoke `reis-mobile:lead-mobile` with all rounds, following the **Debate moderation** section of `${CLAUDE_PLUGIN_ROOT}/agents/lead-mobile.md`. It resolves the disagreements and produces the action plan.
150
+ Invoke `Agent(subagent_type: "reis-mobile:lead-mobile", model: "opus")` again with `phase: synthesis`, the selection plan, original context, all completed rounds and any missing/failed participant statuses, following the **Debate moderation** section of `${CLAUDE_PLUGIN_ROOT}/agents/lead-mobile.md`. It reads the rounds, verifies decisive evidence, resolves the disagreements and delivers the decision with an ordered action plan. This call does not select new participants or start additional rounds.
144
151
 
145
152
  Before accepting the synthesis, confirm any decisive claim in the code yourself: participants do not verify each other. Write the result to `.reis-mobile/debates/<id>/synthesis.md`.
146
153
 
@@ -0,0 +1,63 @@
1
+ import { resolveLanguage } from '../config/language.mjs';
2
+ import { collectReviewContext } from '../context/context-engine.mjs';
3
+ import { detectStack } from '../detection/stack-detector.mjs';
4
+ import { runDoctor } from '../diagnostics/doctor.mjs';
5
+ import { releaseContext } from '../release/context.mjs';
6
+ import { route } from '../router/router.mjs';
7
+
8
+ // The `--json` output of the read-only commands. The CLI prints it and the MCP server returns
9
+ // it, so both surfaces describe a project the same way. `project` is resolveProjectDir's result.
10
+
11
+ export async function detectPayload({ project, lang }) {
12
+ const detection = await detectStack(project.projectDir);
13
+ return { ...detection, ...(await common(project, lang)) };
14
+ }
15
+
16
+ export async function doctorPayload({ project, all, lang }) {
17
+ const report = await runDoctor({ projectDir: project.projectDir, all });
18
+ return { ...report, ...(await common(project, lang)) };
19
+ }
20
+
21
+ export async function routePayload({ project, prompt, intent, lang }) {
22
+ const result = await route({ prompt, intent, projectDir: project.projectDir });
23
+ return { ...summarizeRoute(result), ...(await common(project, lang)) };
24
+ }
25
+
26
+ export async function reviewPayload({ project, base, prompt, lang }) {
27
+ const result = await route({ prompt, intent: 'review', projectDir: project.projectDir });
28
+ const context = collectReviewContext({ projectDir: project.projectDir, base });
29
+ return { ...summarizeRoute(result), ...(await common(project, lang)), context };
30
+ }
31
+
32
+ /** Most build failures are toolchain mismatches, so the doctor report travels with the route. */
33
+ export async function debugPayload({ project, prompt, lang }) {
34
+ const result = await route({ prompt, intent: 'debug', projectDir: project.projectDir });
35
+ const doctor = await runDoctor({ projectDir: project.projectDir });
36
+ return { ...summarizeRoute(result), ...(await common(project, lang)), doctor };
37
+ }
38
+
39
+ /** Evidence for the test agent; running the selected suite is the agent's job. */
40
+ export async function testPayload({ project, base, prompt, lang }) {
41
+ const result = await route({ prompt, intent: 'test', projectDir: project.projectDir });
42
+ const context = collectReviewContext({ projectDir: project.projectDir, base });
43
+ const doctor = await runDoctor({ projectDir: project.projectDir });
44
+ return { ...summarizeRoute(result), ...(await common(project, lang)), context, doctor };
45
+ }
46
+
47
+ /** Release evidence; the specialist audits it before assigning a verdict. */
48
+ export async function releasePayload({ project, prompt, lang }) {
49
+ const result = await route({ prompt, intent: 'release', projectDir: project.projectDir });
50
+ const context = collectReviewContext({ projectDir: project.projectDir });
51
+ const doctor = await runDoctor({ projectDir: project.projectDir });
52
+ const release = releaseContext(result.detection);
53
+ return { ...summarizeRoute(result), ...(await common(project, lang)), context, doctor, release };
54
+ }
55
+
56
+ async function common(project, lang) {
57
+ return { language: await resolveLanguage({ flag: lang }), projectConfig: project.config?.path ?? null };
58
+ }
59
+
60
+ function summarizeRoute(result) {
61
+ const component = (item) => item && { name: item.name, description: item.description, path: item.path };
62
+ return { ...result, agent: component(result.agent), skills: result.skills.map(component) };
63
+ }
@@ -0,0 +1,188 @@
1
+ import { once } from 'node:events';
2
+ import { createInterface } from 'node:readline';
3
+
4
+ import { callTool, hasTool, listTools } from './tools.mjs';
5
+
6
+ // A dual-era MCP server. Modern clients (2026-07-28) send the protocol version and their
7
+ // capabilities in every request's `_meta` and may probe with `server/discover`; legacy clients
8
+ // (2025-11-25 and earlier) open with an `initialize` handshake. Both are served on one process.
9
+ export const MODERN_VERSIONS = ['2026-07-28'];
10
+ export const LEGACY_VERSIONS = ['2025-11-25', '2025-06-18', '2025-03-26', '2024-11-05'];
11
+ const SUPPORTED_VERSIONS = [...MODERN_VERSIONS, ...LEGACY_VERSIONS];
12
+
13
+ const META_VERSION = 'io.modelcontextprotocol/protocolVersion';
14
+ const META_CAPABILITIES = 'io.modelcontextprotocol/clientCapabilities';
15
+ const META_SERVER_INFO = 'io.modelcontextprotocol/serverInfo';
16
+
17
+ export const ERROR_CODES = {
18
+ parse: -32700,
19
+ invalidRequest: -32600,
20
+ methodNotFound: -32601,
21
+ invalidParams: -32602,
22
+ internal: -32603,
23
+ unsupportedVersion: -32022,
24
+ };
25
+
26
+ const CAPABILITIES = { tools: {} };
27
+
28
+ const INSTRUCTIONS =
29
+ 'reis-mobile prepares context for Flutter, native Android, native iOS, React Native/Expo and Kotlin Multiplatform apps. ' +
30
+ 'Call reis_mobile_route, or reis_mobile_review, reis_mobile_debug, reis_mobile_test or reis_mobile_release for those tasks, ' +
31
+ 'with the app directory as "dir". Then read the returned agent and skill files with reis_mobile_read_instructions and follow them; ' +
32
+ 'open only the references that match the stack and the task. Every tool is read-only and offline: none edits the project, ' +
33
+ 'builds, runs tests or calls a network service.';
34
+
35
+ class RpcError extends Error {
36
+ constructor(code, message, data) {
37
+ super(message);
38
+ this.code = code;
39
+ this.data = data;
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Handles one decoded JSON-RPC message and resolves to the response, or to null for a
45
+ * notification. It never rejects: every failure becomes a JSON-RPC error or a tool error.
46
+ */
47
+ export function createMcpServer({ version, defaultDir = process.cwd() }) {
48
+ const serverInfo = { name: 'reis-mobile', title: 'reis-mobile', version };
49
+ // Set by `initialize`: from then on, requests without `_meta` follow that legacy revision.
50
+ let legacyVersion = null;
51
+
52
+ async function handle(message) {
53
+ if (typeof message !== 'object' || message === null || Array.isArray(message)) {
54
+ return failure(null, new RpcError(ERROR_CODES.invalidRequest, 'Expected one JSON-RPC 2.0 message object; batches are not supported.'));
55
+ }
56
+ const isRequest = Object.hasOwn(message, 'id');
57
+ if (message.jsonrpc !== '2.0' || typeof message.method !== 'string') {
58
+ return isRequest ? failure(requestId(message.id), new RpcError(ERROR_CODES.invalidRequest, 'Expected a JSON-RPC 2.0 request with a method.')) : null;
59
+ }
60
+ // notifications/initialized needs no reply; cancellation is handled by the transport.
61
+ if (!isRequest) return null;
62
+
63
+ const { id, method, params } = message;
64
+ if (requestId(id) === null) return failure(null, new RpcError(ERROR_CODES.invalidRequest, 'The request id must be a string or a number.'));
65
+ try {
66
+ if (params !== undefined && (typeof params !== 'object' || params === null || Array.isArray(params))) {
67
+ throw new RpcError(ERROR_CODES.invalidParams, 'params must be an object.');
68
+ }
69
+ if (method === 'initialize') return success(id, initialize(params ?? {}));
70
+
71
+ const modern = isModernRequest(method, params);
72
+ const result = await dispatch(method, params ?? {}, modern);
73
+ return success(id, modern ? { resultType: 'complete', ...result, _meta: { [META_SERVER_INFO]: serverInfo } } : result);
74
+ } catch (error) {
75
+ return failure(id, error instanceof RpcError ? error : new RpcError(ERROR_CODES.internal, `Internal error: ${error.message}`));
76
+ }
77
+ }
78
+
79
+ function initialize(params) {
80
+ const requested = params.protocolVersion;
81
+ legacyVersion = LEGACY_VERSIONS.includes(requested) ? requested : LEGACY_VERSIONS[0];
82
+ return { protocolVersion: legacyVersion, capabilities: CAPABILITIES, serverInfo, instructions: INSTRUCTIONS };
83
+ }
84
+
85
+ /** A request is modern when it carries a protocol version in `_meta`; legacy once initialized. */
86
+ function isModernRequest(method, params) {
87
+ const meta = params?._meta;
88
+ const requested = meta?.[META_VERSION];
89
+ if (requested !== undefined) {
90
+ if (!MODERN_VERSIONS.includes(requested)) {
91
+ throw new RpcError(ERROR_CODES.unsupportedVersion, 'Unsupported protocol version', { supported: SUPPORTED_VERSIONS, requested });
92
+ }
93
+ const capabilities = meta[META_CAPABILITIES];
94
+ if (typeof capabilities !== 'object' || capabilities === null || Array.isArray(capabilities)) {
95
+ throw new RpcError(ERROR_CODES.invalidParams, `Missing _meta["${META_CAPABILITIES}"]: every ${requested} request carries the client capabilities.`);
96
+ }
97
+ return true;
98
+ }
99
+ if (legacyVersion || method === 'ping') return false;
100
+ throw new RpcError(
101
+ ERROR_CODES.invalidParams,
102
+ `Missing _meta["${META_VERSION}"]. Send initialize first (protocol ${LEGACY_VERSIONS[0]} and earlier) or include the per-request _meta of ${MODERN_VERSIONS[0]}.`,
103
+ { supported: SUPPORTED_VERSIONS },
104
+ );
105
+ }
106
+
107
+ async function dispatch(method, params, modern) {
108
+ switch (method) {
109
+ case 'server/discover':
110
+ if (!modern) break;
111
+ return { supportedVersions: MODERN_VERSIONS, capabilities: CAPABILITIES, instructions: INSTRUCTIONS };
112
+ case 'ping':
113
+ return {};
114
+ case 'tools/list':
115
+ return { tools: listTools() };
116
+ case 'tools/call': {
117
+ const { name, arguments: args } = params;
118
+ if (typeof name !== 'string' || !hasTool(name)) throw new RpcError(ERROR_CODES.invalidParams, `Unknown tool: ${name}`);
119
+ if (args !== undefined && (typeof args !== 'object' || args === null || Array.isArray(args))) {
120
+ throw new RpcError(ERROR_CODES.invalidParams, 'tools/call arguments must be an object.');
121
+ }
122
+ return callTool(name, args ?? {}, { defaultDir });
123
+ }
124
+ }
125
+ throw new RpcError(ERROR_CODES.methodNotFound, `Method not found: ${method}`);
126
+ }
127
+
128
+ return { handle };
129
+ }
130
+
131
+ /**
132
+ * Serves newline-delimited JSON-RPC over stdio until the input closes. Only protocol messages
133
+ * are written to `output`; a cancelled request gets no reply, as the transport requires.
134
+ */
135
+ export async function serveStdio({ version, defaultDir, input = process.stdin, output = process.stdout }) {
136
+ const server = createMcpServer({ version, defaultDir });
137
+ const inFlight = new Set();
138
+ const cancelled = new Set();
139
+ const pending = new Set();
140
+ const lines = createInterface({ input, crlfDelay: Infinity });
141
+
142
+ const write = (message) => {
143
+ if (!output.writableEnded && !output.destroyed) output.write(`${JSON.stringify(message)}\n`);
144
+ };
145
+ // The client closing its end mid-reply is a shutdown, not a crash.
146
+ output.on('error', () => lines.close());
147
+
148
+ lines.on('line', (line) => {
149
+ if (!line.trim()) return;
150
+ let message;
151
+ try {
152
+ message = JSON.parse(line);
153
+ } catch {
154
+ write(failure(null, new RpcError(ERROR_CODES.parse, 'Parse error: each line must be one JSON-RPC message.')));
155
+ return;
156
+ }
157
+ if (message?.method === 'notifications/cancelled') {
158
+ const target = message.params?.requestId;
159
+ if (inFlight.has(target)) cancelled.add(target);
160
+ return;
161
+ }
162
+
163
+ const id = Object.hasOwn(message ?? {}, 'id') ? message.id : undefined;
164
+ if (id !== undefined) inFlight.add(id);
165
+ const task = server.handle(message).then((response) => {
166
+ inFlight.delete(id);
167
+ if (cancelled.delete(id) || !response) return;
168
+ write(response);
169
+ });
170
+ pending.add(task);
171
+ task.finally(() => pending.delete(task));
172
+ });
173
+
174
+ await once(lines, 'close');
175
+ await Promise.allSettled([...pending]);
176
+ }
177
+
178
+ function requestId(id) {
179
+ return typeof id === 'string' || typeof id === 'number' ? id : null;
180
+ }
181
+
182
+ function success(id, result) {
183
+ return { jsonrpc: '2.0', id, result };
184
+ }
185
+
186
+ function failure(id, error) {
187
+ return { jsonrpc: '2.0', id, error: { code: error.code, message: error.message, ...(error.data !== undefined && { data: error.data }) } };
188
+ }