reis-mobile 0.10.1 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,14 +5,14 @@
5
5
  },
6
6
  "metadata": {
7
7
  "description": "AI agents for mobile engineering",
8
- "version": "0.10.1"
8
+ "version": "1.0.1"
9
9
  },
10
10
  "plugins": [
11
11
  {
12
12
  "name": "reis-mobile",
13
13
  "source": "./",
14
14
  "description": "AI agents, skills and workflows for Flutter, Android, iOS and React Native development.",
15
- "version": "0.10.1",
15
+ "version": "1.0.1",
16
16
  "author": {
17
17
  "name": "Warleson Reis"
18
18
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reis-mobile",
3
- "version": "0.10.1",
3
+ "version": "1.0.1",
4
4
  "description": "AI agents, skills and workflows for Flutter, Android, iOS and React Native development. Use /reis-mobile:doctor, /reis-mobile:review and /reis-mobile:test.",
5
5
  "author": {
6
6
  "name": "Warleson Reis"
package/CHANGELOG.md CHANGED
@@ -4,6 +4,28 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). The pr
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [1.0.1] - 2026-11-11
8
+
9
+ ### Fixed
10
+
11
+ - Review instructions now prioritize applicable analyzer, linter and test results, separate confirmed tool diagnostics from additional findings, and disclose missing checks. Recurring defects with precise conditions should become regression tests or lint rules.
12
+ - README positioning now explains how AI review complements deterministic checks, replaces the lint-covered opening example, and distinguishes illustrative investigation targets from demonstrated detections.
13
+
14
+ ## [1.0.0] - 2026-11-04
15
+
16
+ ### Added
17
+
18
+ - `reis-mobile mcp`, a stdio MCP server for editors that cannot load the Claude Code plugin, such as Cursor. Seven tools (`reis_mobile_route`, `reis_mobile_review`, `reis_mobile_debug`, `reis_mobile_test`, `reis_mobile_release`, `reis_mobile_detect` and `reis_mobile_doctor`) return the same JSON as the matching `--json` command, and `reis_mobile_read_instructions` serves the agent, skill and reference files a route points to.
19
+ - Both MCP eras on one process: per-request `_meta` and `server/discover` from the 2026-07-28 revision, and the `initialize` handshake from 2025-11-25 back to 2024-11-05. Validated end to end with Claude Code 2.1.267; the Cursor and Codex configurations in the README follow each client's documented format.
20
+ - `--dir` on `reis-mobile mcp` sets the app directory for tool calls that pass none, so a client can pass its workspace folder.
21
+ - Every MCP tool is read-only and offline. `consult` and the commands that write are not exposed, and the instruction reader resolves symlinks and serves only `agents/`, `skills/` and `docs/agent-context.md`, up to 512 KB.
22
+ - iOS Keychain token storage guide in `mobile-security` (`references/ios/keychain.md`): accessibility classes and backups, biometric access control, access groups, reinstall persistence, migration out of `UserDefaults`, and a Swift token store with `OSStatus` handling. Contributed by @jay23606 in #21.
23
+
24
+ ### Changed
25
+
26
+ - 1.0.0 is the first stable release for Flutter, native Android, native iOS and React Native, as planned in the roadmap, and ships the MCP server previously planned for v0.11.0.
27
+ - The read-only commands build their `--json` output in `core/commands/payloads.mjs`, shared by the CLI and the MCP server. The output itself is unchanged.
28
+
7
29
  ## [0.10.1] - 2026-11-04
8
30
 
9
31
  ### Added
package/README.md CHANGED
@@ -1,9 +1,17 @@
1
1
  # ðŸ“ą reis-mobile
2
2
 
3
- A mobile app is not a generic project. A code review that does not know about `BuildContext` after `await`, `android:exported`, `NSAllowsArbitraryLoads` or `Podfile.lock` lets through exactly the bugs that only show up on the device. **reis-mobile** detects your project's stack, picks the right specialist and loads only the skills that apply to Flutter, Android, iOS, React Native or Kotlin Multiplatform.
3
+ **reis-mobile** gives AI assistants mobile-specific context and workflows for code review, debugging, tests and architecture. It detects your project's stack, picks the right specialist and loads only the skills that apply to Flutter, Android, iOS, React Native or Kotlin Multiplatform.
4
4
 
5
5
  **AI agents and skills for mobile development — Flutter, Android, iOS, React Native and Kotlin Multiplatform.** A plugin for Claude Code and Codex with specialist agents and skills for code review, debugging, tests, architecture, security and Firebase.
6
6
 
7
+ ### How review fits with linters and tests
8
+
9
+ Use configured analyzers, linters and tests first. For example, Dart's [`use_build_context_synchronously`](https://dart.dev/tools/linter-rules/use_build_context_synchronously) checks `BuildContext` use across asynchronous gaps; detecting that pattern is not a reason to spend tokens on a review.
10
+
11
+ The review instructions ask the assistant to reuse available check results, distinguish tool diagnostics from additional findings, and trace behavior across files and lifecycle transitions. Investigation targets include a pending request repopulating user data after logout, an older search response replacing newer results, or retries conflicting with an API's idempotency contract. These are illustrative targets, not demonstrated detections or guarantees: each finding needs a concrete trigger and code evidence. The CLI gathers context; it does not automatically run the app's analyzers or tests, and missing check results must be disclosed.
12
+
13
+ When a recurring defect has a precise, enforceable condition, capture it in a regression test or lint rule. AI can help write that check. A passing analyzer does not prove an app correct, and an AI review does not replace deterministic checks. This project does not yet provide a reproducible benchmark demonstrating additional defect detection beyond those checks.
14
+
7
15
  <p align="center">
8
16
  <a href="https://github.com/wrsilva/reis-mobile/actions/workflows/ci.yml"><img src="https://github.com/wrsilva/reis-mobile/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
9
17
  <img src="https://img.shields.io/npm/v/reis-mobile" alt="Latest npm version">
@@ -37,6 +45,8 @@ A mobile app is not a generic project. A code review that does not know about `B
37
45
 
38
46
  🔒 **Secrets never reach the model.** The diff sent for review goes through a layer that masks API keys, tokens, JWTs, private keys and keystore passwords.
39
47
 
48
+ 🔌 **Works in any MCP client.** `reis-mobile mcp` serves stack detection, routing and the review, debug, test and release context to Cursor and other editors, with the agent and skill instructions behind them. See [MCP server](#mcp-server).
49
+
40
50
  ðŸŠķ **Two commands to install.** `npm install -g reis-mobile` and `reis-mobile init eng` set it up in Claude Code and Codex, with zero npm dependencies and no external provider.
41
51
 
42
52
  ---
@@ -44,22 +54,12 @@ A mobile app is not a generic project. A code review that does not know about `B
44
54
  ## What's new
45
55
 
46
56
  <!-- reis-mobile:latest-release:start -->
47
- ### v0.10.1
48
-
49
- #### Added
50
-
51
- - `mobile-ui-ux-designer` agent and `mobile-ui-ux` topic skill for mobile journeys, wireframes, design tokens, component states and evidence-based interface reviews across all five stacks.
52
- - English and Portuguese `design` routing with platform skill selection and regression coverage for architecture, accessibility, tests and unrelated terms.
53
- - Apple design reading workflow linked to `dickwu/apple-design-skill` at a recorded revision and to official HIG topics. Upstream content is linked, not vendored, because the inspected revision declares no redistribution license.
54
-
55
- #### Changed
56
-
57
- - The debate now invokes `lead-mobile` for participant selection before the rounds and again for final synthesis. The candidate table includes every specialist, including UI/UX, accessibility and release, with explicit participant validation and a persisted selection rationale.
58
- - All five platform skills now route design work and link the UI/UX workflow and Apple-target reference.
57
+ ### v1.0.1
59
58
 
60
59
  #### Fixed
61
60
 
62
- - `lead-mobile` now includes the UI/UX designer in its delegation table. A registry test requires every other registered agent, including manual specialists, to appear and rejects unknown agents or self-delegation.
61
+ - Review instructions now prioritize applicable analyzer, linter and test results, separate confirmed tool diagnostics from additional findings, and disclose missing checks. Recurring defects with precise conditions should become regression tests or lint rules.
62
+ - README positioning now explains how AI review complements deterministic checks, replaces the lint-covered opening example, and distinguishes illustrative investigation targets from demonstrated detections.
63
63
 
64
64
  <!-- reis-mobile:latest-release:end -->
65
65
 
@@ -197,7 +197,7 @@ reis-mobile route "review architecture" --json
197
197
  reis-mobile review --json
198
198
  ```
199
199
 
200
- You can also ask Codex: *"Run `reis-mobile review --json`, read the instructions in the returned files and review the changes using that context."* The CLI selects the instructions and gathers the context; Codex does the analysis.
200
+ You can also ask Codex: *"Run `reis-mobile review --json`, read the instructions in the returned files and review the changes using that context."* The CLI selects the instructions and gathers the context; Codex does the analysis. To let Codex call these commands as tools instead, register the [MCP server](#mcp-server).
201
201
  </details>
202
202
 
203
203
  <details>
@@ -366,6 +366,46 @@ The router never invents a specialist: if no agent serves the intent, it warns y
366
366
 
367
367
  ---
368
368
 
369
+ ## MCP server
370
+
371
+ Editors that cannot load the Claude Code plugin, such as Cursor, can use reis-mobile through MCP. `reis-mobile mcp` is a stdio server shipped in the npm package, so install it with `npm install -g reis-mobile` first.
372
+
373
+ | Client | Register the server |
374
+ |---|---|
375
+ | Cursor | `.cursor/mcp.json` in the project, or `~/.cursor/mcp.json` for every project, as below |
376
+ | Claude Code | `claude mcp add reis-mobile -- reis-mobile mcp` |
377
+ | Codex | `codex mcp add reis-mobile -- reis-mobile mcp` |
378
+
379
+ ```json
380
+ {
381
+ "mcpServers": {
382
+ "reis-mobile": {
383
+ "command": "reis-mobile",
384
+ "args": ["mcp", "--dir", "${workspaceFolder}"]
385
+ }
386
+ }
387
+ }
388
+ ```
389
+
390
+ `--dir` is the app directory a tool uses when the call does not pass `dir`; Cursor replaces `${workspaceFolder}` with the project folder. In Claude Code the plugin already provides the agents and commands, so the server matters most in the other clients.
391
+
392
+ | Tool | Returns |
393
+ |---|---|
394
+ | `reis_mobile_route` | Intent, stack, agent and skill paths for a request |
395
+ | `reis_mobile_review` | The review route and the git diff, lock files out and secrets masked |
396
+ | `reis_mobile_debug` | The debug route and the installed toolchain versions |
397
+ | `reis_mobile_test` | The test route, the diff and the toolchain checks; runs no tests |
398
+ | `reis_mobile_release` | Candidate release targets and references; builds and uploads nothing |
399
+ | `reis_mobile_detect` | Stack, languages, platforms and the evidence for each |
400
+ | `reis_mobile_doctor` | SDK and project file checks for the detected stack |
401
+ | `reis_mobile_read_instructions` | The agent, skill or reference file a route points to |
402
+
403
+ The model calls a routing tool, reads the instructions it returns and follows them, the same flow the slash commands use. Each tool returns the same JSON as its `--json` command. Every tool is read-only and offline, so `consult`, which sends context to a third party, is not exposed. The server speaks both MCP eras: the 2026-07-28 revision with per-request metadata, and the `initialize` handshake of 2025-11-25 back to 2024-11-05.
404
+
405
+ Validated end to end with Claude Code 2.1.267. The Cursor and Codex entries follow each client's documented configuration but have not been run against those clients yet; if you try one, tell us in an issue.
406
+
407
+ ---
408
+
369
409
  ## Choose by goal
370
410
 
371
411
  | I want to... | Use | Status |
@@ -400,7 +440,7 @@ The router never invents a specialist: if no agent serves the intent, it warns y
400
440
  |---|---|---|
401
441
  | **Domain** | Generic | Flutter, Android, iOS, React Native, KMP |
402
442
  | **Project context** | You explain the stack | Deterministic detection of stack, platform and variant |
403
- | **Review** | Generic | Mobile checklists: lifecycle, `BuildContext`, MASVS, manifest, ATS |
443
+ | **Review** | General review instructions | Mobile workflows: existing check results, lifecycle and cross-file behavior, findings with code evidence |
404
444
  | **Secrets in the diff** | Sent as they are | Masked before reaching the model |
405
445
  | **Specialists** | None | 22 agents and 27 mobile skills |
406
446
  | **Context cost** | Zero | ~5,500 fixed tokens (agent and skill descriptions), no hooks |
@@ -491,7 +531,7 @@ Every skill starts with `mobile-`. **Topic** skills cover every stack with one r
491
531
  | `mobile-code-review` | Review process and checklists | review, performance | Flutter (PR review, widgets, project audit, static analysis), Android, iOS, React Native, KMP |
492
532
  | `mobile-debug` | Failing builds and runtime errors | debug, build, dependency, migration | Flutter (build, runtime, layout, packages), Android (Gradle, crashes, ANRs, memory leaks), iOS (Xcode, CocoaPods, crash logs), React Native (Metro, native modules), KMP, deep links |
493
533
  | `mobile-test` | Unit, UI, integration and end-to-end tests | test | Flutter (widget, integration, Patrol, mocks, coverage), Android, iOS, React Native, KMP |
494
- | `mobile-security` | OWASP MASVS security checks | review, security, release | Flutter (token storage), Android (intents), iOS, React Native, KMP, OAuth/OIDC and PKCE |
534
+ | `mobile-security` | OWASP MASVS security checks | review, security, release | Flutter (token storage), Android (intents), iOS (Keychain token storage), React Native, KMP, OAuth/OIDC and PKCE |
495
535
  | `mobile-ui-ux` | Journeys, wireframes, visual hierarchy, tokens, component states and Apple HIG reference workflow | design | Flutter, Android, iOS, React Native, KMP and external Apple design reading |
496
536
  | `mobile-accessibility` | Screen readers, touch targets, text scaling, contrast, localization | accessibility | Flutter (Semantics), Android (Compose and Views), iOS (SwiftUI and UIKit), React Native, KMP, localization and RTL |
497
537
  | `mobile-release` | Release readiness, store copy, billing and CI/CD | release, deployment | Flutter, Android, iOS, React Native, KMP, release notes and store listing, in-app purchases, CI/CD |
@@ -539,11 +579,11 @@ About 85 guides come from [flutter/skills](https://github.com/flutter/skills), [
539
579
 
540
580
  ## Trust, security and limits
541
581
 
542
- **Read-only CLI context.** `doctor`, `detect`, `route`, `review`, `test`, `release`, `providers` and `consult` do not modify your project. `/reis-mobile:release` inspects existing release evidence and remains read-only unless execution is explicitly requested. The `/reis-mobile:test` slash command can write or fix tests and execute runners when requested. `/reis-mobile:project` writes only the optional project profile. The `/reis-mobile:review` and `/reis-mobile:debate` commands instruct the model not to edit app code; the debate writes its rounds to `.reis-mobile/debates/` and a consult its briefing to `.reis-mobile/consults/`.
582
+ **Read-only CLI context.** `doctor`, `detect`, `route`, `review`, `test`, `release`, `providers`, `consult` and the `mcp` server's tools do not modify your project. `/reis-mobile:release` inspects existing release evidence and remains read-only unless execution is explicitly requested. The `/reis-mobile:test` slash command can write or fix tests and execute runners when requested. `/reis-mobile:project` writes only the optional project profile. The `/reis-mobile:review` and `/reis-mobile:debate` commands instruct the model not to edit app code; the debate writes its rounds to `.reis-mobile/debates/` and a consult its briefing to `.reis-mobile/consults/`.
543
583
 
544
584
  **Secrets masked.** The diff goes through [`core/security/redact.mjs`](core/security/redact.mjs) before reaching the model. Lock files and generated code (`*.g.dart`, `*.freezed.dart`, `*.pbxproj`) stay out of the diff. Redaction is a protection layer, not a guarantee. See [SECURITY.md](SECURITY.md).
545
585
 
546
- **No telemetry.** `detect`, `doctor`, `route`, `review`, `debug`, `test` and `providers` make no network calls (`providers --models` and `consult` do, by request). The update hook and `update-check` request the public npm package metadata; they send no project files or identifiers. Installation downloads the package and registers the plugin. The Google Play policy guide in `mobile-android` runs Python scripts that download your app's public Google Play listing when the model uses it. The AI analysis uses the model of the session you are working in, such as Claude Code or Codex.
586
+ **No telemetry.** `detect`, `doctor`, `route`, `review`, `debug`, `test`, `providers` and the `mcp` server make no network calls (`providers --models` and `consult` do, by request). The update hook and `update-check` request the public npm package metadata; they send no project files or identifiers. Installation downloads the package and registers the plugin. The Google Play policy guide in `mobile-android` runs Python scripts that download your app's public Google Play listing when the model uses it. The AI analysis uses the model of the session you are working in, such as Claude Code or Codex.
547
587
 
548
588
  **Exception: external providers.** Two workflows, and only these two, send project context to a third party: `/reis-mobile:debate --external` and `/reis-mobile:consult`. Both state which providers receive it, and both are opt-in — no other command calls a provider. What leaves is the briefing, including the code excerpts you include; each provider has its own data policy. The question and every answer pass through `redact.mjs`, which is a protection layer and not a guarantee: read the briefing before sending it. API keys are read from the environment by the CLI and never enter the conversation, and `providers:` in `.reis-mobile/config.yaml` lets a team restrict which providers a repository allows. CLI providers (`codex`, `gemini-cli`) are asked for an opinion, not for work: they run in read-only mode (`codex exec --sandbox read-only`, `gemini --approval-mode plan`) inside a temporary directory, never in your repository.
549
589
 
@@ -578,8 +618,7 @@ About 85 guides come from [flutter/skills](https://github.com/flutter/skills), [
578
618
  | v0.8.0 | `mobile-performance`, `mobile-offline-sync` and `mobile-observability` topic skills with a reference per stack, plus background execution, OAuth/PKCE, billing lifecycle and localization references | ✅ |
579
619
  | v0.9.0 | Multi-provider layer (OpenAI, Gemini, OpenRouter, Ollama and the Codex/Gemini CLIs), `reis-mobile providers`, `reis-mobile consult` and `/reis-mobile:consult` — previously planned for v0.10.0 | ✅ |
580
620
  | v0.10.0 | `mobile-privacy`, `mobile-networking`, `mobile-auth` and `mobile-notifications` topic skills with a reference per stack, plus the `privacy`, `networking`, `auth` and `notifications` intents | ✅ |
581
- | v0.11.0 | MCP server | âģ |
582
- | v1.0.0 | First stable version: Flutter, Android, iOS and React Native | âģ |
621
+ | v1.0.0 | First stable version: Flutter, Android, iOS and React Native, with the MCP server previously planned for v0.11.0 | ✅ |
583
622
 
584
623
  ---
585
624
 
@@ -607,10 +646,10 @@ Yes. Native Android, native iOS and React Native each have architect, performanc
607
646
  The plugin's skills live in the `reis-mobile:` namespace and do not conflict, but Claude Code loads both descriptions. To save context, remove the global copies the plugin already covers.
608
647
 
609
648
  **Does it work in Codex?**
610
- Yes: `reis-mobile init eng` installs the plugin in Codex too. See [installation](#install). Installation does not confirm parity with the Claude Code agents and commands; the `reis-mobile` CLI can also be run by Codex to get diagnostics and context.
649
+ Yes: `reis-mobile init eng` installs the plugin in Codex too. See [installation](#install). Installation does not confirm parity with the Claude Code agents and commands; the `reis-mobile` CLI can also be run by Codex to get diagnostics and context, and the [MCP server](#mcp-server) exposes the same commands as tools.
611
650
 
612
651
  **Does it work in Cursor?**
613
- The Cursor integration has not been validated yet. The MCP server remains on the roadmap for v0.11.0.
652
+ Through the [MCP server](#mcp-server): register `reis-mobile mcp` in `.cursor/mcp.json`. The configuration follows Cursor's documented format but has not been run against Cursor yet. Cursor gets the tools and the instructions, not the Claude Code agents, slash commands or hooks.
614
653
 
615
654
  **What does reis-mobile send outside my machine?**
616
655
  The project analysis commands do not make network requests. The update hook and `reis-mobile update-check` request the public npm package metadata without sending project files or identifiers. What the model reads during `/reis-mobile:review` follows the same rules as any Claude Code session.
@@ -18,6 +18,8 @@ Resolve the requested base/head or working-tree scope, including staged, unstage
18
18
 
19
19
  ## Prove a finding
20
20
 
21
+ Start with configured analyzer, linter and test results for the reviewed revision and scope. Reuse applicable results or run existing checks when available and permitted; disclose missing or stale evidence. Summarize confirmed tool diagnostics separately from additional review findings, including correctness and security diagnostics. A potentially applicable rule alone is not evidence that a defect is covered. Focus additional analysis on behavior across files, lifecycle transitions and API contracts.
22
+
21
23
  1. Read the complete changed function/type and its callers, tests and platform configuration. Identify the input, lifecycle transition or configuration that activates the suspected defect.
22
24
  2. Trace the consequence across the changed boundary: lost data, crash, stale state, permission failure, exposed secret or broken platform behavior. For async changes, identify who owns and cancels the work and whether a late result can still reach the UI.
23
25
  3. Compare with the base behavior and surrounding conventions. Separate newly introduced regressions from pre-existing issues, and check whether a wrapper, validation layer or existing test already handles the case.
@@ -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}` : ''}`);
@@ -41,6 +41,8 @@ Read with the Read tool, in this order:
41
41
 
42
42
  Follow the agent's process, applying the skills' checklists:
43
43
 
44
+ - First inspect available analyzer, linter and test results for this revision and scope. Run existing checks only when available and permitted; otherwise state what was not checked. The CLI gathers context and does not execute these checks automatically. Summarize confirmed tool diagnostics separately from additional review findings.
45
+
44
46
  - **working-tree / range:** review the changed files. Read the full file when the diff does not give enough context. `untracked` files do not appear in the diff: read them directly. If the diff was truncated, read the remaining files.
45
47
  - **project:** there are no changes; audit the project starting from the entry points and the data layer.
46
48
 
@@ -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
+ }
@@ -0,0 +1,226 @@
1
+ import { existsSync, realpathSync, statSync } from 'node:fs';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { isAbsolute, join, relative, resolve, sep } from 'node:path';
4
+
5
+ import {
6
+ debugPayload,
7
+ detectPayload,
8
+ doctorPayload,
9
+ releasePayload,
10
+ reviewPayload,
11
+ routePayload,
12
+ testPayload,
13
+ } from '../commands/payloads.mjs';
14
+ import { resolveProjectDir } from '../config/project.mjs';
15
+ import { PLUGIN_ROOT } from '../paths.mjs';
16
+ import { INTENT_IDS } from '../router/intents.mjs';
17
+
18
+ /** Agents, skills and the brief contract the agents link to; nothing else in the package. */
19
+ const READABLE = ['agents', 'skills', join('docs', 'agent-context.md')];
20
+ const MAX_READ_BYTES = 512 * 1024;
21
+
22
+ const DIR = {
23
+ type: 'string',
24
+ description:
25
+ 'Absolute path of the mobile app, or of a repository whose .reis-mobile/config.yaml points at it. ' +
26
+ 'Defaults to the --dir given to `reis-mobile mcp`, then to the server working directory.',
27
+ };
28
+ const BASE = { type: 'string', description: 'Git ref to compare as <base>...HEAD, such as main. Omit to use uncommitted changes.' };
29
+
30
+ // Every tool reads the project or the package and runs only local, read-only commands (git,
31
+ // toolchain --version). None edits files, builds, runs tests or calls a network service.
32
+ const READ_ONLY = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
33
+
34
+ const TOOLS = [
35
+ {
36
+ name: 'reis_mobile_route',
37
+ title: 'Route a mobile request',
38
+ description:
39
+ 'Resolve a request to the reis-mobile specialist agent and skills for the detected stack. ' +
40
+ 'Returns the intent, agent and skill instruction paths: read them with reis_mobile_read_instructions and follow them. ' +
41
+ 'Pass a prompt, an intent, or both.',
42
+ properties: {
43
+ prompt: { type: 'string', description: 'The request in English or Portuguese, such as "the Android build of my Flutter app broke".' },
44
+ intent: { type: 'string', enum: INTENT_IDS, description: 'Force an intent instead of detecting it from the prompt.' },
45
+ dir: DIR,
46
+ },
47
+ run: ({ project, prompt, intent }) => {
48
+ if (!prompt && !intent) throw new ToolInputError('Pass a prompt, an intent, or both.');
49
+ return routePayload({ project, prompt, intent });
50
+ },
51
+ },
52
+ {
53
+ name: 'reis_mobile_review',
54
+ title: 'Prepare a mobile code review',
55
+ description:
56
+ 'Route a code review to the review agent and skills and collect the git diff to review: uncommitted changes, ' +
57
+ 'or <base>...HEAD. Lock files and generated code are excluded and secrets are masked. Does not review by itself.',
58
+ properties: {
59
+ prompt: { type: 'string', description: 'Optional focus, such as "focus on security".' },
60
+ base: BASE,
61
+ dir: DIR,
62
+ },
63
+ run: ({ project, prompt, base }) => reviewPayload({ project, prompt, base }),
64
+ },
65
+ {
66
+ name: 'reis_mobile_debug',
67
+ title: 'Prepare a build or runtime debug',
68
+ description:
69
+ 'Route a build or runtime failure (Gradle, Xcode, CocoaPods, Metro, pub, crashes) to the debug agent and skills, ' +
70
+ 'with the doctor report of the installed toolchain versions. Does not change the project or its caches.',
71
+ properties: {
72
+ problem: { type: 'string', description: 'The error, failing command or symptom, such as "pod install fails on CI".' },
73
+ dir: DIR,
74
+ },
75
+ run: ({ project, problem }) => debugPayload({ project, prompt: problem }),
76
+ },
77
+ {
78
+ name: 'reis_mobile_test',
79
+ title: 'Prepare mobile test work',
80
+ description:
81
+ 'Route a test request to the test agent and skills for the stack, with the git diff and toolchain checks. ' +
82
+ 'Does not run tests: the agent runs, writes or audits them after reading the instructions.',
83
+ properties: {
84
+ request: { type: 'string', description: 'What to test, such as "write regression tests for the checkout cubit".' },
85
+ base: BASE,
86
+ dir: DIR,
87
+ },
88
+ run: ({ project, request, base }) => testPayload({ project, prompt: request, base }),
89
+ },
90
+ {
91
+ name: 'reis_mobile_release',
92
+ title: 'Prepare release readiness evidence',
93
+ description:
94
+ 'Route a release readiness audit to the release engineer with the candidate platforms, reference paths, ' +
95
+ 'git context and toolchain checks. Does not build, sign, upload or evaluate readiness: the verdict is null.',
96
+ properties: {
97
+ request: { type: 'string', description: 'What to audit, such as "Android production and iOS App Store".' },
98
+ dir: DIR,
99
+ },
100
+ run: ({ project, request }) => releasePayload({ project, prompt: request }),
101
+ },
102
+ {
103
+ name: 'reis_mobile_detect',
104
+ title: 'Detect the mobile stack',
105
+ description:
106
+ 'Detect the stack of a project from its build files: Flutter, React Native or Expo, Kotlin Multiplatform, ' +
107
+ 'native Android or native iOS, with languages, target platforms and the evidence for each.',
108
+ properties: { dir: DIR },
109
+ run: ({ project }) => detectPayload({ project }),
110
+ },
111
+ {
112
+ name: 'reis_mobile_doctor',
113
+ title: 'Diagnose the mobile environment',
114
+ description:
115
+ 'Check the SDKs and project files that matter for the detected stack: Flutter, Dart, Java, Android SDK, Xcode, ' +
116
+ 'CocoaPods, the Gradle wrapper and lock files. Reads versions only; installs nothing.',
117
+ properties: {
118
+ all: { type: 'boolean', description: 'Check every tool, not only those for the detected stack.' },
119
+ dir: DIR,
120
+ },
121
+ run: ({ project, all }) => doctorPayload({ project, all }),
122
+ },
123
+ {
124
+ name: 'reis_mobile_read_instructions',
125
+ title: 'Read reis-mobile instructions',
126
+ description:
127
+ 'Read an agent, skill or reference file shipped with reis-mobile: the agent and skill paths the other tools ' +
128
+ 'return, or a link inside one of those files resolved against that file\'s own path. Accepts an absolute path ' +
129
+ 'inside the package or a path relative to it, such as skills/mobile-debug/SKILL.md.',
130
+ properties: {
131
+ path: { type: 'string', description: 'The file to read, such as skills/mobile-debug/references/ios.md.' },
132
+ },
133
+ required: ['path'],
134
+ readsProject: false,
135
+ run: ({ path }) => readInstructions(path),
136
+ },
137
+ ];
138
+
139
+ const BY_NAME = new Map(TOOLS.map((tool) => [tool.name, tool]));
140
+
141
+ /** Thrown for arguments a model can correct; reported as a tool result with isError. */
142
+ class ToolInputError extends Error {}
143
+
144
+ export function listTools() {
145
+ return TOOLS.map(({ name, title, description, properties, required }) => ({
146
+ name,
147
+ title,
148
+ description,
149
+ inputSchema: { type: 'object', properties, ...(required && { required }), additionalProperties: false },
150
+ annotations: { title, ...READ_ONLY },
151
+ }));
152
+ }
153
+
154
+ export function hasTool(name) {
155
+ return BY_NAME.has(name);
156
+ }
157
+
158
+ /**
159
+ * Runs a tool and returns its MCP result. Mistakes the model can fix (wrong arguments, a missing
160
+ * directory) and failures inside the core come back with isError, so the model sees them.
161
+ */
162
+ export async function callTool(name, args = {}, { defaultDir = process.cwd() } = {}) {
163
+ const tool = BY_NAME.get(name);
164
+ if (!tool) return { content: [{ type: 'text', text: `Unknown tool: ${name}` }], isError: true };
165
+ try {
166
+ validateArguments(tool, args);
167
+ if (tool.readsProject === false) return { content: [{ type: 'text', text: await tool.run(args) }] };
168
+
169
+ const project = resolveToolProject(args.dir, defaultDir);
170
+ const payload = await tool.run({ ...args, project });
171
+ const warnings = project.warnings.map((warning) => ({ type: 'text', text: `! ${warning}` }));
172
+ return {
173
+ content: [...warnings, { type: 'text', text: JSON.stringify(payload, null, 2) }],
174
+ structuredContent: payload,
175
+ };
176
+ } catch (error) {
177
+ return { content: [{ type: 'text', text: error.message }], isError: true };
178
+ }
179
+ }
180
+
181
+ function validateArguments(tool, args) {
182
+ if (typeof args !== 'object' || args === null || Array.isArray(args)) {
183
+ throw new ToolInputError(`${tool.name} takes an object of arguments.`);
184
+ }
185
+ const allowed = Object.keys(tool.properties);
186
+ for (const [key, value] of Object.entries(args)) {
187
+ const schema = tool.properties[key];
188
+ if (!schema) throw new ToolInputError(`Unknown argument "${key}" for ${tool.name}. Allowed: ${allowed.join(', ')}.`);
189
+ if (value === undefined || value === null) continue;
190
+ if (typeof value !== schema.type) throw new ToolInputError(`"${key}" must be a ${schema.type}, got ${typeof value}.`);
191
+ if (schema.enum && !schema.enum.includes(value)) {
192
+ throw new ToolInputError(`"${key}" must be one of: ${schema.enum.join(', ')}. Got "${value}".`);
193
+ }
194
+ }
195
+ for (const key of tool.required ?? []) {
196
+ if (typeof args[key] !== 'string' || !args[key].trim()) throw new ToolInputError(`"${key}" is required.`);
197
+ }
198
+ }
199
+
200
+ function resolveToolProject(dir, defaultDir) {
201
+ const requested = resolve(defaultDir, dir ?? '.');
202
+ if (!existsSync(requested) || !statSync(requested).isDirectory()) {
203
+ throw new ToolInputError(`Directory not found: ${requested}. Pass the absolute path of the mobile app as "dir".`);
204
+ }
205
+ return resolveProjectDir(requested);
206
+ }
207
+
208
+ /** Resolves symlinks before the containment check, so a link cannot point outside the package. */
209
+ async function readInstructions(path) {
210
+ const requested = isAbsolute(path) ? path : join(PLUGIN_ROOT, path);
211
+ if (!existsSync(requested) || !statSync(requested).isFile()) {
212
+ throw new ToolInputError(`No file at ${requested}. Use a path returned by another reis-mobile tool, or one relative to ${PLUGIN_ROOT}.`);
213
+ }
214
+ const root = realpathSync(PLUGIN_ROOT);
215
+ const real = realpathSync(requested);
216
+ const inside = relative(root, real);
217
+ const readable = READABLE.some((entry) => inside === entry || inside.startsWith(`${entry}${sep}`));
218
+ if (!readable || inside.startsWith('..') || isAbsolute(inside)) {
219
+ throw new ToolInputError(`${path} is outside the readable instructions: ${READABLE.join(', ')} in ${root}.`);
220
+ }
221
+ const { size } = statSync(real);
222
+ if (size > MAX_READ_BYTES) throw new ToolInputError(`${inside} is ${size} bytes, above the ${MAX_READ_BYTES}-byte limit.`);
223
+
224
+ // Commands and skills written for Claude Code refer to the package as ${CLAUDE_PLUGIN_ROOT}.
225
+ return `File: ${real}\nPackage root (\${CLAUDE_PLUGIN_ROOT}): ${root}\n\n${await readFile(real, 'utf8')}`;
226
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "reis-mobile",
3
- "version": "0.10.1",
4
- "description": "AI agents and skills for Flutter, Android, iOS, React Native and Kotlin Multiplatform — a Claude Code and Codex plugin for reviews, debugging, tests, architecture, security and Firebase.",
3
+ "version": "1.0.1",
4
+ "description": "AI agents and skills for Flutter, Android, iOS, React Native and Kotlin Multiplatform — a Claude Code and Codex plugin and an MCP server for reviews, debugging, tests, architecture, security and Firebase.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "Warleson Reis",
@@ -55,6 +55,10 @@
55
55
  "agent-skills",
56
56
  "skills",
57
57
  "ai-agents",
58
- "code-review"
58
+ "code-review",
59
+ "mcp",
60
+ "mcp-server",
61
+ "model-context-protocol",
62
+ "cursor"
59
63
  ]
60
64
  }
@@ -32,7 +32,9 @@ In a cross-platform app, native code in `android/`, `ios/` or native modules get
32
32
 
33
33
  - Open the code and confirm every suspicion. A finding without evidence at `file:line` does not go in the report.
34
34
  - Do not invent APIs, versions or lint rules. Check the lock files and build files (`pubspec.lock`, version catalog, `Package.resolved`, `package.json`) before claiming something is missing, deprecated or unavailable.
35
- - Do not report style that a configured linter already enforces.
35
+ - Start with the project's configured analyzers, linters and relevant tests. Reuse results that match the reviewed revision and scope; run existing checks when available and permitted. If results are unavailable, stale or checks cannot run, disclose that limitation instead of assuming they passed.
36
+ - Keep confirmed tool diagnostics (including correctness and security diagnostics, not just style) in the checks summary; do not present them again as independent AI findings. Do not suppress a concrete defect merely because a rule might cover it: verify the rule is enabled and its output applies to the reviewed code. Trace any additional consequence beyond a diagnostic before claiming added value.
37
+ - Spend review effort on concrete behavior across files, lifecycle transitions and API contracts. Require a trigger and code evidence; illustrative scenarios are not findings. When a recurring defect has a precise condition, recommend a regression test or lint rule to enforce it.
36
38
  - When the project deliberately does something differently and it is coherent, say so instead of flagging it.
37
39
 
38
40
  ## 4. What always matters, on any stack
@@ -69,3 +71,5 @@ Prioritized list. For each item: file:line, problem, why it matters, fix.
69
71
  ```
70
72
 
71
73
  Write "No findings." in a section with nothing to report.
74
+
75
+ Include checks executed or reused, their revision/scope and results, confirmed tool diagnostics, and checks not run in the Summary. Keep additional review findings separate from those diagnostics; never imply that a clean review guarantees correctness.
@@ -0,0 +1,105 @@
1
+ # Store tokens in the iOS Keychain
2
+
3
+ Use Keychain Services for small secrets such as access and refresh tokens. Choose access and sharing rules from the app's actual lifecycle and threat model; a token in the Keychain is still a bearer credential and must be handled carefully after reading it.
4
+
5
+ ## Review checklist
6
+
7
+ 1. **Remove plaintext token copies.** Do not store tokens in `UserDefaults`, property lists, logs, analytics, crash reports or URLs. Find and delete the old copy after a successful migration.
8
+ 2. **Choose the accessibility class.** Use `kSecAttrAccessibleWhenUnlockedThisDeviceOnly` for foreground-only secrets that should not migrate to a different device. If background work needs the token after the first unlock, consider `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`. Select a class based on when the app needs access; avoid the deprecated `kSecAttrAccessibleAlways*` classes. Verify current behavior in Apple's documentation.
9
+ 3. **Decide backup and device migration behavior.** A `ThisDeviceOnly` class prevents migration when restoring a backup to another device; it can still be restored to the device that created the backup. Non-`ThisDeviceOnly` classes may migrate with encrypted backups. Do not assume this is equivalent to deleting an item during backup or restore.
10
+ 4. **Gate access with biometrics only when required.** A biometric prompt that returns a Boolean does not protect the stored token. When the product requires biometric gating, attach a `SecAccessControl` created with an appropriate flag such as `.biometryCurrentSet` to the item. Handle authentication cancellation and enrollment changes by requiring reauthentication. Do not add a prompt to every token read unless that is the intended user experience.
11
+ 5. **Share only with intended targets.** Use a Keychain access group only when an app extension or companion target must read the item. Configure the entitlement for each target and use the same explicit access-group identifier in the query. Keep unrelated targets out of the group.
12
+ 6. **Handle reinstall and logout deliberately.** Keychain credentials can remain available after an app is removed and reinstalled, so do not treat reinstall as logout. On logout, revoke the server-side session as appropriate and delete the item's Keychain entry. On launch, validate any recovered session with the server and provide an explicit sign-out path.
13
+
14
+ ## Basic token store
15
+
16
+ This example stores one generic-password item per service and account. It updates an existing item, returns `nil` when no token exists, treats deleting a missing item as success, and surfaces other `OSStatus` failures to the caller.
17
+
18
+ ```swift
19
+ import Foundation
20
+ import Security
21
+
22
+ enum TokenStoreError: Error {
23
+ case keychain(OSStatus)
24
+ case invalidData
25
+ }
26
+
27
+ struct TokenStore {
28
+ private let service = "com.example.app.auth"
29
+ private let account = "refresh-token"
30
+
31
+ private var identity: [String: Any] {
32
+ [
33
+ kSecClass as String: kSecClassGenericPassword,
34
+ kSecAttrService as String: service,
35
+ kSecAttrAccount as String: account
36
+ ]
37
+ }
38
+
39
+ func save(_ token: String) throws {
40
+ guard let data = token.data(using: .utf8) else {
41
+ throw TokenStoreError.invalidData
42
+ }
43
+
44
+ let query = identity
45
+ let update: [String: Any] = [kSecValueData as String: data]
46
+ let updateStatus = SecItemUpdate(query as CFDictionary, update as CFDictionary)
47
+
48
+ if updateStatus == errSecItemNotFound {
49
+ var item = query
50
+ item[kSecValueData as String] = data
51
+ item[kSecAttrAccessible as String] = kSecAttrAccessibleWhenUnlockedThisDeviceOnly
52
+ let addStatus = SecItemAdd(item as CFDictionary, nil)
53
+ guard addStatus == errSecSuccess else {
54
+ throw TokenStoreError.keychain(addStatus)
55
+ }
56
+ } else if updateStatus != errSecSuccess {
57
+ throw TokenStoreError.keychain(updateStatus)
58
+ }
59
+ }
60
+
61
+ func read() throws -> String? {
62
+ var query = identity
63
+ query[kSecReturnData as String] = true
64
+ query[kSecMatchLimit as String] = kSecMatchLimitOne
65
+
66
+ var result: CFTypeRef?
67
+ let status = SecItemCopyMatching(query as CFDictionary, &result)
68
+ if status == errSecItemNotFound {
69
+ return nil
70
+ }
71
+ guard status == errSecSuccess else {
72
+ throw TokenStoreError.keychain(status)
73
+ }
74
+ guard let data = result as? Data,
75
+ let token = String(data: data, encoding: .utf8) else {
76
+ throw TokenStoreError.invalidData
77
+ }
78
+ return token
79
+ }
80
+
81
+ func delete() throws {
82
+ let status = SecItemDelete(identity as CFDictionary)
83
+ guard status == errSecSuccess || status == errSecItemNotFound else {
84
+ throw TokenStoreError.keychain(status)
85
+ }
86
+ }
87
+ }
88
+ ```
89
+
90
+ When biometrics are required, create a `SecAccessControl` with the chosen accessibility and flags, then provide it as `kSecAttrAccessControl` when adding the item. Do not combine it with `kSecAttrAccessible`; the access-control object carries the protection class.
91
+
92
+ ## Migrate from `UserDefaults`
93
+
94
+ 1. Read the legacy value only if present.
95
+ 2. Save it to the Keychain and confirm the Keychain operation succeeded.
96
+ 3. Remove the legacy value and persist that removal.
97
+ 4. Keep the migration idempotent: if the Keychain already has a token, prefer it and remove any stale legacy copy. If migration fails, do not silently discard the only credential; prompt for sign-in if recovery is not safe.
98
+
99
+ ## Apple documentation
100
+
101
+ - [Restricting Keychain item accessibility](https://developer.apple.com/documentation/security/restricting-keychain-item-accessibility)
102
+ - [Sharing Keychain items among a collection of apps](https://developer.apple.com/documentation/security/sharing-access-to-keychain-items-among-a-collection-of-apps)
103
+ - [SecAccessControlCreateWithFlags](https://developer.apple.com/documentation/security/secaccesscontrolcreatewithflags(_:_:_:_:))
104
+ - [Updating and deleting Keychain items](https://developer.apple.com/documentation/security/updating-and-deleting-keychain-items)
105
+ - [Using the Keychain to manage user secrets](https://developer.apple.com/documentation/security/using-the-keychain-to-manage-user-secrets)
@@ -9,7 +9,7 @@ Apply after the general checks in `SKILL.md`, to native iOS apps and to the `ios
9
9
 
10
10
  ## Storage and credentials
11
11
 
12
- - [ ] Tokens in `UserDefaults`, property lists or files instead of the Keychain.
12
+ - [ ] Tokens in `UserDefaults`, property lists or files instead of the Keychain. Store and migrate them as in [ios/keychain.md](ios/keychain.md).
13
13
  - [ ] Keychain items with permissive accessibility (`kSecAttrAccessibleAlways*`, deprecated) instead of `...WhenUnlocked` or `...AfterFirstUnlock`; `ThisDeviceOnly` variants for secrets that must not migrate to other devices through backups.
14
14
  - [ ] Files with sensitive data written without an appropriate Data Protection class.
15
15
  - [ ] Biometrics checked with `LAContext.evaluatePolicy` only as a boolean when they guard credentials; protect the Keychain item with `SecAccessControl` and biometry instead.