@nestxapp/ato-mcp 1.5.0 → 1.6.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.
package/README.md CHANGED
@@ -12,7 +12,39 @@ key is written once to `<dir>/.env` (never printed).
12
12
  npx -y @nestxapp/ato-mcp login --name my-ai --role dev --model claude --dir ./.ato
13
13
  ```
14
14
 
15
- Keep `.ato/` and `.mcp.json` out of git: they hold your API key.
15
+ The command prints a code `XXXX-XXXX` and the portal URL, waits for approval (the code is valid 10 minutes,
16
+ polled every 5 seconds) and then writes `<dir>/.env` (one line, `ATO_API_KEY`) and `<dir>/identity.json`. At the
17
+ end it prints the `mcpServers` block to paste into your client, correct for your operating system. The same block is
18
+ available at any time with `npx -y @nestxapp/ato-mcp config` (`--platform win32|darwin|linux` previews another OS).
19
+
20
+ `login` (the AI side) and **Connect AI** in the portal (the human side, where the name, the real company role and
21
+ the model are adjusted) are the two halves of one flow. The `--role` you pass is only a hint of a role family.
22
+ An AI identity can never approve connections; only a signed-in person can.
23
+
24
+ Keep `.ato/` and `.mcp.json` out of git: they hold your API key. `login` also creates `<dir>/.gitignore` with `*`,
25
+ so the key cannot be committed by accident.
26
+
27
+ ### Language
28
+
29
+ Messages of the command line (login, errors, expiry) are shown in English or Spanish. The language is chosen,
30
+ in order, by `--lang es|en`, the `ATO_LANG` variable, `LC_ALL` / `LC_MESSAGES` / `LANG`, and finally the system
31
+ locale; anything else is English. Tool descriptions and API errors are not translated.
32
+
33
+ ```bash
34
+ npx -y @nestxapp/ato-mcp login --name my-ai --lang es
35
+ ATO_LANG=es npx -y @nestxapp/ato-mcp login --name my-ai
36
+ ```
37
+
38
+ ## Windows, macOS and Linux
39
+
40
+ | | Windows | macOS / Linux |
41
+ |---|---|---|
42
+ | `.mcp.json` command | `"command": "cmd", "args": ["/c", "npx", "-y", "@nestxapp/ato-mcp"]` (clients cannot start the `npx.cmd` shim directly) | `"command": "npx", "args": ["-y", "@nestxapp/ato-mcp"]` |
43
+ | Key file `<dir>/.env` | Inherited permissions are replaced by a single grant to your user (`icacls`); a warning is printed if that fails | Mode `0600` (owner only) |
44
+ | `ATO_IDENTITY_DIR` | Absolute path with backslashes (escaped in JSON) | Absolute path; `~` is expanded by ato-mcp |
45
+
46
+ Use an absolute `ATO_IDENTITY_DIR`: an MCP client may start the server from any folder. Files with a UTF-8 BOM
47
+ or CRLF line endings (common on Windows) are read correctly.
16
48
 
17
49
  ## Which tools you get
18
50
 
@@ -24,13 +56,22 @@ always listed. Servers that do not return permissions yet fall back to the legac
24
56
  cannot be read (no key, bad key, network), only the open tools are listed until it works; call `ato_whoami`
25
57
  to retry and see the reason.
26
58
 
59
+ ## Working guide of your role
60
+
61
+ At start the server also reads your role's working guide (`GET /ai/playbook`: how to do the job with rigor,
62
+ evidence, severities, when to escalate) and sends it as the MCP `instructions` of `initialize`, which clients
63
+ such as Claude Code add to the model's context. Each company can edit its guides in the portal (Team, AI
64
+ guides); otherwise the ATO default applies. `ato_playbook` returns it again at any time. If the guide cannot
65
+ be read at start (no key, network), a short default text is sent instead and the server starts normally.
66
+
27
67
  ## Configuration
28
68
 
29
69
  | Variable | Meaning |
30
70
  |---|---|
31
71
  | `ATO_API_KEY` | Plaintext API key of the identity (or name another variable in `ATO_API_KEY_VAR`). If absent, `<identity dir>/.env` is used. |
32
72
  | `ATO_API_BASE_URL` | API base including the version prefix, e.g. `https://<your-ato-gateway>/api/v1/ato`. Default `http://localhost:5000/api/v1/ato`. |
33
- | `ATO_IDENTITY_DIR` | Directory with `identity.json` (and `.env`). Default `./.ato`. |
73
+ | `ATO_IDENTITY_DIR` | Directory with `identity.json` (and `.env`). Default `./.ato`. Prefer an absolute path. |
74
+ | `ATO_LANG` | `es` or `en`: language of the command-line messages. |
34
75
 
35
76
  ## Claude Code (`.mcp.json` in the repo root)
36
77
 
@@ -52,8 +93,7 @@ to retry and see the reason.
52
93
 
53
94
  ## Claude Desktop (`claude_desktop_config.json`) and Cursor (`.cursor/mcp.json`)
54
95
 
55
- Same `mcpServers` block as above. On Windows, if `npx` is not found by the client, use
56
- `"command": "cmd"` with `"args": ["/c", "npx", "-y", "@nestxapp/ato-mcp"]`.
96
+ Same `mcpServers` block as above. On Windows use `"command": "cmd"` with `"args": ["/c", "npx", "-y", "@nestxapp/ato-mcp"]`.
57
97
 
58
98
  ## Verify
59
99
 
package/i18n.js ADDED
@@ -0,0 +1,85 @@
1
+ // Messages of the ato-mcp command line (login, errors, expiry) in English and Spanish. No dependencies.
2
+ //
3
+ // Language: --lang, then ATO_LANG, then LC_ALL / LC_MESSAGES / LANG, then the system locale (Intl), then English.
4
+ // Anything that is not Spanish is shown in English. The MCP tool descriptions and API errors are not translated.
5
+
6
+ 'use strict';
7
+
8
+ const SUPPORTED = ['en', 'es'];
9
+
10
+ /** "es_CO.UTF-8", "es-CO", "ES" -> "es"; "C", "POSIX", "" and unsupported languages -> null. */
11
+ function normalize(value) {
12
+ if (!value || typeof value !== 'string') return null;
13
+ const tag = value.trim().split(/[.@:]/)[0].split(/[-_]/)[0].toLowerCase();
14
+ if (!tag || tag === 'c' || tag === 'posix') return null;
15
+ return SUPPORTED.includes(tag) ? tag : null;
16
+ }
17
+
18
+ /** Whether a value names a language at all (so "fr" stops the search and falls back to English). */
19
+ function names(value) {
20
+ if (!value || typeof value !== 'string') return false;
21
+ const tag = value.trim().split(/[.@:]/)[0].split(/[-_]/)[0].toLowerCase();
22
+ return !!tag && tag !== 'c' && tag !== 'posix';
23
+ }
24
+
25
+ function systemLocale() {
26
+ try { return Intl.DateTimeFormat().resolvedOptions().locale; } catch { return null; }
27
+ }
28
+
29
+ /** The first source that names a language decides; an unsupported one means English. */
30
+ function detectLang({ flag, env = process.env, locale = systemLocale() } = {}) {
31
+ for (const value of [flag, env.ATO_LANG, env.LC_ALL, env.LC_MESSAGES, env.LANG, locale]) {
32
+ if (names(value)) return normalize(value) || 'en';
33
+ }
34
+ return 'en';
35
+ }
36
+
37
+ const MESSAGES = {
38
+ en: {
39
+ 'login.header': 'Connect this AI to ATO',
40
+ 'login.step_open': 'Open:',
41
+ 'login.step_code': 'Check the code matches:',
42
+ 'login.step_approve': 'Approve it (you choose the agent name and role). It expires in {minutes} minutes.',
43
+ 'login.connected': 'Connected as {handle} ({role}). Key stored in {file} (not printed).',
44
+ 'login.role': 'Role: {role}. {count} permissions; the MCP tools follow them.',
45
+ 'login.restart': 'Make sure .ato/ is in .gitignore, then restart your MCP client so it picks up the key.',
46
+ 'login.config_intro': 'Add this to .mcp.json (Claude Code) or .cursor/mcp.json (Cursor); the key is read from {file}:',
47
+ 'login.acl_warning': 'Could not restrict {file} to your user; check its permissions.',
48
+ 'error.start_failed': 'Could not start the connection (HTTP {status}): {detail}',
49
+ 'error.denied': 'The request was denied in the portal.',
50
+ 'error.expired_used': 'The code expired or was already used. Run login again.',
51
+ 'error.expired_unapproved': 'The code expired before it was approved. Run login again.',
52
+ 'error.unexpected': 'Unexpected response while waiting (HTTP {status}): {detail}',
53
+ 'error.no_details': 'no details',
54
+ 'error.network': 'Could not reach ATO at {url}: {detail}',
55
+ 'config.title': 'MCP client configuration for {platform}:',
56
+ },
57
+ es: {
58
+ 'login.header': 'Conectar esta IA a ATO',
59
+ 'login.step_open': 'Abre:',
60
+ 'login.step_code': 'Verifica que el codigo coincida:',
61
+ 'login.step_approve': 'Apruebalo (tu eliges el nombre y el rol del agente). Caduca en {minutes} minutos.',
62
+ 'login.connected': 'Conectado como {handle} ({role}). La clave se guardo en {file} (no se muestra).',
63
+ 'login.role': 'Rol: {role}. {count} permisos; las herramientas del MCP los siguen.',
64
+ 'login.restart': 'Verifica que .ato/ este en .gitignore y reinicia tu cliente MCP para que cargue la clave.',
65
+ 'login.config_intro': 'Agrega esto a .mcp.json (Claude Code) o .cursor/mcp.json (Cursor); la clave se lee de {file}:',
66
+ 'login.acl_warning': 'No se pudo restringir {file} a tu usuario; revisa sus permisos.',
67
+ 'error.start_failed': 'No se pudo iniciar la conexion (HTTP {status}): {detail}',
68
+ 'error.denied': 'La solicitud fue rechazada en el portal.',
69
+ 'error.expired_used': 'El codigo caduco o ya se uso. Ejecuta login de nuevo.',
70
+ 'error.expired_unapproved': 'El codigo caduco antes de ser aprobado. Ejecuta login de nuevo.',
71
+ 'error.unexpected': 'Respuesta inesperada mientras se esperaba (HTTP {status}): {detail}',
72
+ 'error.no_details': 'sin detalles',
73
+ 'error.network': 'No se pudo contactar a ATO en {url}: {detail}',
74
+ 'config.title': 'Configuracion del cliente MCP para {platform}:',
75
+ },
76
+ };
77
+
78
+ /** Returns a translator for a language: t('key', { name: value }). Unknown keys come back as the key. */
79
+ function makeT(lang) {
80
+ const table = MESSAGES[SUPPORTED.includes(lang) ? lang : 'en'];
81
+ return (key, params = {}) => String(table[key] ?? MESSAGES.en[key] ?? key)
82
+ .replace(/\{(\w+)\}/g, (m, name) => (name in params ? String(params[name]) : m));
83
+ }
84
+
85
+ module.exports = { detectLang, makeT, normalize, SUPPORTED, MESSAGES };
package/index.js CHANGED
@@ -14,25 +14,27 @@
14
14
  const fs = require('fs');
15
15
  const os = require('os');
16
16
  const path = require('path');
17
+ const platformLib = require('./platform');
17
18
 
18
19
  const BASE_URL = process.env.ATO_API_BASE_URL || 'http://localhost:5000/api/v1/ato';
19
20
 
21
+ // "~" is expanded here because MCP clients pass the value as is. Prefer an absolute path: a client may start the server from any folder.
22
+ const identityDir = () => path.resolve(platformLib.expandHome(process.env.ATO_IDENTITY_DIR || path.join(process.cwd(), '.ato')));
23
+
20
24
  function loadIdentity() {
21
- const dir = process.env.ATO_IDENTITY_DIR || path.join(process.cwd(), '.ato');
22
- const file = path.join(dir, 'identity.json');
25
+ const file = path.join(identityDir(), 'identity.json');
23
26
  if (fs.existsSync(file)) {
24
- return JSON.parse(fs.readFileSync(file, 'utf8'));
27
+ return JSON.parse(platformLib.readText(file));
25
28
  }
26
29
  return null;
27
30
  }
28
31
 
29
32
  // The key written by `login` lives in <identity dir>/.env (git-ignored); env vars still win.
30
33
  function loadDotEnvKey() {
31
- const dir = process.env.ATO_IDENTITY_DIR || path.join(process.cwd(), '.ato');
32
- const file = path.join(dir, '.env');
34
+ const file = path.join(identityDir(), '.env');
33
35
  if (!fs.existsSync(file)) return null;
34
- const line = fs.readFileSync(file, 'utf8').split(/\r?\n/).find(l => l.startsWith('ATO_API_KEY='));
35
- return line ? line.slice('ATO_API_KEY='.length).trim() : null;
36
+ const line = platformLib.readText(file).split(/\r?\n/).find(l => l.startsWith('ATO_API_KEY='));
37
+ return line ? line.slice('ATO_API_KEY='.length).trim().replace(/^(["'])(.*)\1$/, '$2') : null;
36
38
  }
37
39
 
38
40
  const identity = loadIdentity();
@@ -140,10 +142,17 @@ function summarize(body) {
140
142
  const TOOLS = [
141
143
  {
142
144
  name: 'ato_whoami', roles: ['dev', 'code_review', 'qa', 'pmo'], perms: null,
143
- desc: 'Returns the caller identity (AssigneeId/Role/AiModel) resolved from the configured API key.',
145
+ desc: 'Returns the caller identity (AssigneeId/Role/AiModel) resolved from the configured API key. Before working on any task, read your role working guide (ato_playbook; also sent as this server\'s instructions) and follow it.',
144
146
  schema: { type: 'object', properties: {} },
145
147
  fn: async () => api('GET', '/ai/whoami'),
146
148
  },
149
+ {
150
+ name: 'ato_playbook', roles: ['dev', 'code_review', 'qa', 'pmo'], perms: null,
151
+ desc: 'Returns the working guide of your role (how to do your job with rigor: evidence, severities, when to escalate), as set by your company or the ATO default. Read it before your first task and whenever you are unsure.',
152
+ schema: { type: 'object', properties: {} },
153
+ fn: async () => api('GET', '/ai/playbook'),
154
+ text: (json) => (json && json.data && json.data.content) || null,
155
+ },
147
156
  {
148
157
  name: 'ato_resume', roles: ['dev', 'code_review', 'qa', 'pmo'], perms: null,
149
158
  desc: 'Resumes this assignee open work: in_progress/changes_requested/qa_rejected tasks + last 10 events.',
@@ -170,7 +179,7 @@ const TOOLS = [
170
179
  name: 'ato_tasks', roles: ['dev', 'pmo'], perms: ['task.view'],
171
180
  desc: 'List project tasks by status (any lifecycle state — e.g. "done", "in_review,qa_testing" — comma-separated, or omit for all statuses). Same endpoint as ato_backlog but not locked to status=backlog — use it to see what has already shipped, not just what is claimable.',
172
181
  schema: { type: 'object', properties: {
173
- status: { type: 'string', description: 'Comma-separated TaskStatus names (proposed, backlog, in_progress, blocked, in_review, changes_requested, qa_testing, qa_rejected, done, rejected). Omit for every status.' },
182
+ status: { type: 'string', description: 'Comma-separated TaskStatus names (proposed, backlog, in_progress, blocked, in_review, changes_requested, qa_testing, qa_rejected, ready_to_deploy, ready_to_close, done, rejected). Omit for every status.' },
174
183
  projectId: { type: 'string' },
175
184
  priority: { type: 'string', enum: ['critical', 'high', 'medium', 'low'] },
176
185
  type: { type: 'string', enum: ['backend', 'frontend', 'infra', 'docs', 'test', 'bug'] },
@@ -186,7 +195,7 @@ const TOOLS = [
186
195
  },
187
196
  {
188
197
  name: 'ato_task_context', roles: ['dev', 'code_review', 'qa', 'pmo'], perms: null,
189
- desc: 'Full detail for a task: acceptance criteria, ContextSnapshot, timeline, evidence, comments.',
198
+ desc: 'Full detail for a task: acceptance criteria, ContextSnapshot, timeline, evidence, comments, and the project flow: task.workflow (useCodeReview, useQa, useReadyToDeploy, ordered stages) and task.nextStatusOnSubmit (where your submit will send the work).',
190
199
  schema: { type: 'object', properties: { taskId: TASK_REF }, required: ['taskId'] },
191
200
  fn: async (a) => api('GET', `/ai/tasks/${tid(a)}`),
192
201
  },
@@ -210,7 +219,7 @@ const TOOLS = [
210
219
  },
211
220
  {
212
221
  name: 'ato_progress', roles: ['dev'], perms: ['task.progress'],
213
- desc: 'Report progress (bumps heartbeat; commitHash required before review).',
222
+ desc: 'Report progress (bumps heartbeat; a commitHash is required before review unless you submit with noCodeChanges).',
214
223
  schema: { type: 'object', properties: { taskId: TASK_REF, summary: { type: 'string' }, commitHash: { type: 'string' }, filesChanged: { type: 'array', items: { type: 'string' } }, tokensEstimateUsed: { type: 'integer' } }, required: ['taskId'] },
215
224
  fn: async (a) => api('POST', `/ai/tasks/${tid(a)}/progress`, { body: { summary: a.summary || null, commitHash: a.commitHash || null, filesChanged: a.filesChanged || null, tokensEstimateUsed: a.tokensEstimateUsed ?? null } }),
216
225
  },
@@ -234,9 +243,9 @@ const TOOLS = [
234
243
  },
235
244
  {
236
245
  name: 'ato_submit_for_review', roles: ['dev'], perms: ['task.submit'],
237
- desc: 'Submit for review (requires VerificationEvidence + a progress event with commitHash).',
238
- schema: { type: 'object', properties: { taskId: TASK_REF, verificationEvidence: { type: 'string' } }, required: ['taskId', 'verificationEvidence'] },
239
- fn: async (a) => api('PATCH', `/ai/tasks/${tid(a)}/submit-for-review`, { body: { verificationEvidence: a.verificationEvidence } }),
246
+ desc: 'Submit your finished work (requires verificationEvidence + a progress event with commitHash). When the task truly needed no code change (already implemented, documentation only...), set noCodeChanges=true with a noCodeChangesReason instead of a commit: the reason is shown to QA on the timeline, and verificationEvidence becomes optional. Never declare it to dodge the commit rule. The destination depends on the project flow: in_review when code review is on; otherwise qa_testing when QA is on; otherwise ready_to_deploy or done. Check task.nextStatusOnSubmit in ato_task_context first; the event payload lists skippedStages.',
247
+ schema: { type: 'object', properties: { taskId: TASK_REF, verificationEvidence: { type: 'string' }, noCodeChanges: { type: 'boolean', description: 'true = the work needed no code change; requires noCodeChangesReason and waives the commitHash rule.' }, noCodeChangesReason: { type: 'string', description: 'Why no commit was needed (max 2000 chars), e.g. "Already implemented in AF-TK-12; only the docs changed". Required when noCodeChanges is true; rejected otherwise.' } }, required: ['taskId'] },
248
+ fn: async (a) => api('PATCH', `/ai/tasks/${tid(a)}/submit-for-review`, { body: { verificationEvidence: a.verificationEvidence || null, noCodeChanges: a.noCodeChanges === true, noCodeChangesReason: a.noCodeChangesReason || null } }),
240
249
  },
241
250
  {
242
251
  name: 'ato_propose_subtask', roles: ['dev'], perms: ['task.progress'],
@@ -253,13 +262,13 @@ const TOOLS = [
253
262
  },
254
263
  {
255
264
  name: 'ato_review_queue', roles: ['code_review'], perms: ['task.review'],
256
- desc: 'Tasks in in_review, oldest first.',
265
+ desc: 'Tasks in in_review, oldest first. Empty for projects without the code review stage (except tasks that were already in review when it was switched off).',
257
266
  schema: { type: 'object', properties: { projectId: { type: 'string' } } },
258
267
  fn: async (a) => api('GET', `/review/queue${a.projectId ? `?projectId=${a.projectId}` : ''}`),
259
268
  },
260
269
  {
261
270
  name: 'ato_review_approve', roles: ['code_review'], perms: ['task.review'],
262
- desc: 'Approve code review (in_review → qa_testing).',
271
+ desc: 'Approve code review. The destination depends on the project flow: qa_testing when QA is on; otherwise ready_to_deploy (deploy gate on) or done.',
263
272
  schema: { type: 'object', properties: { taskId: TASK_REF, notes: { type: 'string' } }, required: ['taskId'] },
264
273
  fn: async (a) => api('PATCH', `/review/tasks/${tid(a)}/approve`, { body: { notes: a.notes || null } }),
265
274
  },
@@ -271,16 +280,22 @@ const TOOLS = [
271
280
  },
272
281
  {
273
282
  name: 'ato_qa_queue', roles: ['qa'], perms: ['task.qa'],
274
- desc: 'Tasks in qa_testing, oldest first.',
283
+ desc: 'Tasks in qa_testing, oldest first. Empty for projects without the QA stage (except tasks that were already in QA when it was switched off).',
275
284
  schema: { type: 'object', properties: { projectId: { type: 'string' } } },
276
285
  fn: async (a) => api('GET', `/qa/queue${a.projectId ? `?projectId=${a.projectId}` : ''}`),
277
286
  },
278
287
  {
279
288
  name: 'ato_qa_approve', roles: ['qa'], perms: ['task.qa'],
280
- desc: 'Approve QA (qa_testing → done). QaEvidence must be independently derived.',
289
+ desc: 'Approve QA (qa_testing → done, or → ready_to_deploy when the project enables the ready-to-deploy gate). QaEvidence must be independently derived.',
281
290
  schema: { type: 'object', properties: { taskId: TASK_REF, qaEvidence: { type: 'string' } }, required: ['taskId', 'qaEvidence'] },
282
291
  fn: async (a) => api('PATCH', `/qa/tasks/${tid(a)}/approve`, { body: { qaEvidence: a.qaEvidence } }),
283
292
  },
293
+ {
294
+ name: 'ato_qa_mark_deployed', roles: ['qa'], perms: ['task.qa'],
295
+ desc: 'Mark a QA-approved task as deployed (ready_to_deploy → done). Only projects with the ready-to-deploy gate have tasks in ready_to_deploy.',
296
+ schema: { type: 'object', properties: { taskId: TASK_REF }, required: ['taskId'] },
297
+ fn: async (a) => api('PATCH', `/qa/tasks/${tid(a)}/mark-deployed`),
298
+ },
284
299
  {
285
300
  name: 'ato_qa_reject', roles: ['qa'], perms: ['task.qa'],
286
301
  desc: 'Reject QA (qa_testing → qa_rejected; qaNotes must describe the observed failure).',
@@ -423,6 +438,40 @@ async function loadAuth() {
423
438
  auth.ready = true;
424
439
  }
425
440
 
441
+ // ── Working guide (playbook) ─────────────────────────────────────────────
442
+ // Fetched once at start with the key and sent as the `instructions` of initialize (InitializeResult.instructions exists
443
+ // since protocol 2024-11-05). Never blocks the start for long and never breaks it: on failure a short default is sent.
444
+ const PLAYBOOK_TIMEOUT_MS = 4000;
445
+ const FALLBACK_INSTRUCTIONS = [
446
+ 'You are an AI teammate in ATO. Your role working guide could not be loaded now; call ato_playbook to read it before you start.',
447
+ 'Until then: read the whole task and its acceptance criteria before acting; back every claim with verifiable evidence (HTTP response,',
448
+ 'database query, log, screenshot); only blockers (security, wrong data, unmet criterion, something broken) send a task back, should/nit',
449
+ 'go as comments; record out-of-scope findings as new tasks; after 2 rounds of changes without closing, escalate to a human with a comment.',
450
+ ].join(' ');
451
+ const playbook = { content: null, family: null, customized: false, error: null };
452
+
453
+ async function loadPlaybook() {
454
+ if (!apiKey) { playbook.error = 'no API key configured'; return; }
455
+ try {
456
+ const res = await fetch(`${BASE_URL}/ai/playbook`, { headers: { 'X-API-Key': apiKey }, signal: AbortSignal.timeout(PLAYBOOK_TIMEOUT_MS) });
457
+ const json = await res.json().catch(() => null);
458
+ const d = json && json.data;
459
+ if (res.ok && d && typeof d.content === 'string' && d.content.trim()) {
460
+ Object.assign(playbook, { content: d.content, family: d.family || null, customized: !!d.isCustomized, error: null });
461
+ } else {
462
+ playbook.error = `HTTP ${res.status}${json && json.code ? ' ' + json.code : ''}`;
463
+ }
464
+ } catch (e) {
465
+ playbook.error = e.message;
466
+ }
467
+ }
468
+
469
+ function instructions() {
470
+ if (!playbook.content) return FALLBACK_INSTRUCTIONS;
471
+ const source = playbook.customized ? 'set by your company' : 'the ATO default';
472
+ return `You are connected to ATO as the '${playbook.family}' role. Follow this working guide (${source}) on every task; re-read it any time with ato_playbook.\n\n${playbook.content}`;
473
+ }
474
+
426
475
  const allowed = (t) => {
427
476
  if (!t.perms) return true;
428
477
  if (auth.error) return false;
@@ -448,10 +497,12 @@ async function refreshAfterWhoami(status, json) {
448
497
 
449
498
  async function handle(msg) {
450
499
  if (msg.method === 'initialize') {
500
+ await Promise.race([playbookReady, new Promise((r) => setTimeout(r, PLAYBOOK_TIMEOUT_MS))]);
451
501
  return respond(msg.id, {
452
502
  protocolVersion: '2024-11-05',
453
503
  capabilities: { tools: { listChanged: true } },
454
- serverInfo: { name: 'ato-mcp', version: '1.5.0' },
504
+ serverInfo: { name: 'ato-mcp', version: '1.6.0' },
505
+ instructions: instructions(),
455
506
  });
456
507
  }
457
508
  if (msg.method === 'notifications/initialized') return;
@@ -472,7 +523,7 @@ async function handle(msg) {
472
523
  const { status, json } = await tool.fn(args || {});
473
524
  if (tool.name === 'ato_whoami') await refreshAfterWhoami(status, json);
474
525
  const content = [{ type: 'text', text: JSON.stringify({ httpStatus: status, body: json }, null, 2) }];
475
- const digest = summarize(json);
526
+ const digest = tool.text ? tool.text(json) : summarize(json);
476
527
  if (digest) content.unshift({ type: 'text', text: digest });
477
528
  return respond(msg.id, { content, isError: false });
478
529
  } catch (e) {
@@ -483,6 +534,19 @@ async function handle(msg) {
483
534
  return respondError(msg.id, -32601, `Method not found: ${msg.method}`);
484
535
  }
485
536
 
537
+ // `ato-mcp config [--platform win32|darwin|linux] [--lang es|en]` prints the .mcp.json block for this machine (or the given OS).
538
+ if (process.argv[2] === 'config') {
539
+ const { parseArgs } = require('./login');
540
+ const { detectLang, makeT } = require('./i18n');
541
+ const args = parseArgs(process.argv.slice(3));
542
+ const t = makeT(detectLang({ flag: args.lang }));
543
+ const target = args.platform || process.platform;
544
+ const cfg = platformLib.mcpConfig({ baseUrl: BASE_URL, identityDir: identityDir(), platform: target });
545
+ console.log(t('config.title', { platform: target }));
546
+ console.log(JSON.stringify(cfg, null, 2));
547
+ process.exit(0);
548
+ }
549
+
486
550
  // `node index.js login ...` runs the "Connect my AI" device flow instead of the MCP server.
487
551
  if (process.argv[2] === 'login') {
488
552
  require('./login').run(process.argv.slice(3))
@@ -492,6 +556,7 @@ if (process.argv[2] === 'login') {
492
556
  }
493
557
 
494
558
  loadAuth();
559
+ const playbookReady = loadPlaybook();
495
560
 
496
561
  let buffer = '';
497
562
  process.stdin.setEncoding('utf8');
package/login.js CHANGED
@@ -12,6 +12,8 @@
12
12
  'use strict';
13
13
  const fs = require('fs');
14
14
  const path = require('path');
15
+ const { detectLang, makeT } = require('./i18n');
16
+ const platformLib = require('./platform');
15
17
 
16
18
  function parseArgs(argv) {
17
19
  const out = {};
@@ -38,31 +40,33 @@ async function post(baseUrl, p, body) {
38
40
  return { status: res.status, json };
39
41
  }
40
42
 
41
- function writeSecretFile(file, content) {
42
- fs.writeFileSync(file, content, { encoding: 'utf8', mode: 0o600 });
43
- try { fs.chmodSync(file, 0o600); } catch { /* not supported on this platform */ }
44
- }
45
-
46
43
  async function run(argv, env = process.env, log = console.log) {
47
44
  const args = parseArgs(argv);
45
+ const t = makeT(detectLang({ flag: args.lang, env }));
48
46
  const baseUrl = (args['base-url'] || env.ATO_API_BASE_URL || 'http://localhost:5000/api/v1/ato').replace(/\/+$/, '');
49
- const dir = path.resolve(args.dir || env.ATO_IDENTITY_DIR || path.join(process.cwd(), '.ato'));
47
+ const dir = path.resolve(platformLib.expandHome(args.dir || env.ATO_IDENTITY_DIR || path.join(process.cwd(), '.ato')));
50
48
 
51
- const start = await post(baseUrl, '/connect/device', {
52
- name: args.name || null,
53
- role: args.role || null,
54
- aiModel: args.model || null,
55
- });
49
+ let start;
50
+ try {
51
+ start = await post(baseUrl, '/connect/device', {
52
+ name: args.name || null,
53
+ role: args.role || null,
54
+ aiModel: args.model || null,
55
+ });
56
+ } catch (e) {
57
+ throw new Error(t('error.network', { url: baseUrl, detail: e.message }));
58
+ }
59
+ const detail = (r) => (r.json && r.json.message) || t('error.no_details');
56
60
  if (start.status !== 200 || !start.json || !start.json.data) {
57
- throw new Error(`Could not start the connection (HTTP ${start.status}): ${start.json && start.json.message || 'no details'}`);
61
+ throw new Error(t('error.start_failed', { status: start.status, detail: detail(start) }));
58
62
  }
59
63
 
60
64
  const d = start.json.data;
61
65
  log('');
62
- log(' Connect this AI to ATO');
63
- log(` 1. Open: ${d.verificationUriComplete}`);
64
- log(` 2. Check the code matches: ${d.userCode}`);
65
- log(` 3. Approve it (you choose the agent name and role). It expires in ${Math.round(d.expiresIn / 60)} minutes.`);
66
+ log(` ${t('login.header')}`);
67
+ log(` 1. ${t('login.step_open')} ${d.verificationUriComplete}`);
68
+ log(` 2. ${t('login.step_code')} ${d.userCode}`);
69
+ log(` 3. ${t('login.step_approve', { minutes: Math.round(d.expiresIn / 60) })}`);
66
70
  log('');
67
71
 
68
72
  let interval = Math.max(1, d.interval || 5) * 1000;
@@ -74,36 +78,42 @@ async function run(argv, env = process.env, log = console.log) {
74
78
  const code = r.json && r.json.code;
75
79
 
76
80
  if (r.status === 200 && r.json.data && r.json.data.apiKey) {
77
- const t = r.json.data;
81
+ const tk = r.json.data;
78
82
  fs.mkdirSync(dir, { recursive: true });
79
- writeSecretFile(path.join(dir, '.env'), `ATO_API_KEY=${t.apiKey}\n`);
83
+ platformLib.ensureIgnored(dir);
84
+ const envFile = path.join(dir, '.env');
85
+ const saved = platformLib.writeSecretFile(envFile, `ATO_API_KEY=${tk.apiKey}\n`);
86
+ if (!saved.ok) log(` ${t('login.acl_warning', { file: envFile })}`);
80
87
  fs.writeFileSync(path.join(dir, 'identity.json'), JSON.stringify({
81
- assigneeId: t.handle,
82
- memberId: t.memberId,
83
- role: t.role,
84
- tenantId: t.tenantId,
88
+ assigneeId: tk.handle,
89
+ memberId: tk.memberId,
90
+ role: tk.role,
91
+ tenantId: tk.tenantId,
85
92
  aiModel: args.model || null,
86
93
  apiKeyEnvVar: 'ATO_API_KEY',
87
94
  }, null, 2) + '\n', 'utf8');
88
95
 
89
- log(` Connected as ${t.handle} (${t.role}). Key stored in ${path.join(dir, '.env')} (not printed).`);
96
+ log(` ${t('login.connected', { handle: tk.handle, role: tk.role, file: envFile })}`);
90
97
  try {
91
- const who = await fetch(`${baseUrl}/ai/whoami`, { headers: { 'X-API-Key': t.apiKey } });
98
+ const who = await fetch(`${baseUrl}/ai/whoami`, { headers: { 'X-API-Key': tk.apiKey } });
92
99
  const wj = who.ok ? await who.json() : null;
93
100
  const wd = wj && wj.data;
94
- if (wd && Array.isArray(wd.permissions)) log(` Role: ${wd.roleName || wd.role}. ${wd.permissions.length} permissions; the MCP tools follow them.`);
101
+ if (wd && Array.isArray(wd.permissions)) log(` ${t('login.role', { role: wd.roleName || wd.role, count: wd.permissions.length })}`);
95
102
  } catch { /* the MCP reads whoami again on start */ }
96
- log(' Make sure .ato/ is in .gitignore, then restart your MCP client so it picks up the key.');
97
- return t;
103
+ log(` ${t('login.restart')}`);
104
+ log('');
105
+ log(` ${t('login.config_intro', { file: envFile })}`);
106
+ log(JSON.stringify(platformLib.mcpConfig({ baseUrl, identityDir: dir }), null, 2).replace(/^/gm, ' '));
107
+ return tk;
98
108
  }
99
109
  if (code === 'CONNECT_AUTHORIZATION_PENDING') continue;
100
110
  if (code === 'CONNECT_SLOW_DOWN') { interval += 5000; continue; }
101
- if (code === 'CONNECT_ACCESS_DENIED') throw new Error('The request was denied in the portal.');
102
- if (code === 'CONNECT_EXPIRED_TOKEN') throw new Error('The code expired or was already used. Run login again.');
111
+ if (code === 'CONNECT_ACCESS_DENIED') throw new Error(t('error.denied'));
112
+ if (code === 'CONNECT_EXPIRED_TOKEN') throw new Error(t('error.expired_used'));
103
113
  if (r.status === 429) { interval += 5000; continue; }
104
- throw new Error(`Unexpected response while waiting (HTTP ${r.status}): ${r.json && r.json.message || 'no details'}`);
114
+ throw new Error(t('error.unexpected', { status: r.status, detail: detail(r) }));
105
115
  }
106
- throw new Error('The code expired before it was approved. Run login again.');
116
+ throw new Error(t('error.expired_unapproved'));
107
117
  }
108
118
 
109
119
  module.exports = { run, parseArgs };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nestxapp/ato-mcp",
3
- "version": "1.5.0",
3
+ "version": "1.6.0",
4
4
  "description": "MCP server for ATO (AI Task Orchestrator): lets an AI client take, work and report tasks.",
5
5
  "license": "MIT",
6
6
  "main": "index.js",
@@ -10,6 +10,8 @@
10
10
  "files": [
11
11
  "index.js",
12
12
  "login.js",
13
+ "i18n.js",
14
+ "platform.js",
13
15
  "LICENSE",
14
16
  "README.md"
15
17
  ],
package/platform.js ADDED
@@ -0,0 +1,75 @@
1
+ // Operating-system details of ato-mcp: how to launch it from an MCP client, where it keeps things and how the key file is protected.
2
+ // Everything takes the platform as a parameter (default process.platform) so it can be exercised for any OS from any OS.
3
+
4
+ 'use strict';
5
+ const fs = require('fs');
6
+ const os = require('os');
7
+ const path = require('path');
8
+ const { execFileSync } = require('child_process');
9
+
10
+ const PACKAGE = '@nestxapp/ato-mcp';
11
+
12
+ /** Path module of the target platform (so Windows paths can be built and checked from Linux and the other way around). */
13
+ const pathFor = (platform) => (platform === 'win32' ? path.win32 : path.posix);
14
+
15
+ /** "~" and "~/x" -> home directory. MCP clients do not expand it, shells do not either inside JSON. */
16
+ function expandHome(p, home = os.homedir()) {
17
+ if (typeof p !== 'string') return p;
18
+ return p === '~' || /^~[\\/]/.test(p) ? path.join(home, p.slice(1)) : p;
19
+ }
20
+
21
+ /**
22
+ * How an MCP client must launch the server. On Windows `npx` is a .cmd shim that clients cannot start directly,
23
+ * so it goes through cmd; macOS and Linux run npx as is.
24
+ */
25
+ function launchCommand(platform = process.platform) {
26
+ return platform === 'win32'
27
+ ? { command: 'cmd', args: ['/c', 'npx', '-y', PACKAGE] }
28
+ : { command: 'npx', args: ['-y', PACKAGE] };
29
+ }
30
+
31
+ /**
32
+ * The "mcpServers" block for .mcp.json / .cursor/mcp.json. The key itself is not included: it stays in
33
+ * <identityDir>/.env, which the server reads.
34
+ */
35
+ function mcpConfig({ baseUrl, identityDir, platform = process.platform }) {
36
+ const p = pathFor(platform);
37
+ // A path of another OS cannot be resolved here: show a placeholder to fill in instead of a wrong path.
38
+ const dir = p.isAbsolute(identityDir) ? p.normalize(identityDir) : platform === process.platform ? p.resolve(identityDir) : '<absolute-path-to-repo>/.ato';
39
+ const env = { ATO_API_BASE_URL: baseUrl, ATO_IDENTITY_DIR: dir };
40
+ return { mcpServers: { ato: { ...launchCommand(platform), env } } };
41
+ }
42
+
43
+ /** The identity folder is git-ignored by itself: a `*` .gitignore inside it keeps the key out of commits. */
44
+ function ensureIgnored(dir) {
45
+ const file = path.join(dir, '.gitignore');
46
+ if (!fs.existsSync(file)) fs.writeFileSync(file, '*\n', 'utf8');
47
+ }
48
+
49
+ /**
50
+ * Writes a file only its owner can read. POSIX: mode 0600 (also fixed afterwards in case the file existed).
51
+ * Windows has no mode bits: the inherited access list is replaced with a single grant to the current user
52
+ * (icacls). Returns { ok, how }; ok is false when Windows could not restrict it (the caller warns, the key is still written).
53
+ */
54
+ function writeSecretFile(file, content, { platform = process.platform, run = execFileSync, user = safeUser() } = {}) {
55
+ fs.writeFileSync(file, content, { encoding: 'utf8', mode: 0o600 });
56
+ if (platform === 'win32') {
57
+ if (!user) return { ok: false, how: 'icacls' };
58
+ try {
59
+ run('icacls', [file, '/inheritance:r', '/grant:r', `${user}:F`], { stdio: 'ignore', windowsHide: true });
60
+ return { ok: true, how: 'icacls' };
61
+ } catch {
62
+ return { ok: false, how: 'icacls' };
63
+ }
64
+ }
65
+ try { fs.chmodSync(file, 0o600); return { ok: true, how: 'chmod' }; } catch { return { ok: false, how: 'chmod' }; }
66
+ }
67
+
68
+ function safeUser() {
69
+ try { return os.userInfo().username; } catch { return process.env.USERNAME || null; }
70
+ }
71
+
72
+ /** Text of a file without the UTF-8 BOM some Windows editors add. */
73
+ const readText = (file) => fs.readFileSync(file, 'utf8').replace(/^/, '');
74
+
75
+ module.exports = { PACKAGE, pathFor, expandHome, launchCommand, mcpConfig, ensureIgnored, writeSecretFile, readText };