@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 +44 -4
- package/i18n.js +85 -0
- package/index.js +85 -20
- package/login.js +41 -31
- package/package.json +3 -1
- package/platform.js +75 -0
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
|
-
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
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 =
|
|
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
|
|
238
|
-
schema: { type: 'object', properties: { taskId: TASK_REF, verificationEvidence: { type: 'string' } },
|
|
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 (
|
|
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.
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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(
|
|
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(
|
|
63
|
-
log(` 1.
|
|
64
|
-
log(` 2.
|
|
65
|
-
log(` 3.
|
|
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
|
|
81
|
+
const tk = r.json.data;
|
|
78
82
|
fs.mkdirSync(dir, { recursive: true });
|
|
79
|
-
|
|
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:
|
|
82
|
-
memberId:
|
|
83
|
-
role:
|
|
84
|
-
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(`
|
|
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':
|
|
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(`
|
|
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('
|
|
97
|
-
|
|
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('
|
|
102
|
-
if (code === 'CONNECT_EXPIRED_TOKEN') throw new Error('
|
|
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(
|
|
114
|
+
throw new Error(t('error.unexpected', { status: r.status, detail: detail(r) }));
|
|
105
115
|
}
|
|
106
|
-
throw new Error('
|
|
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.
|
|
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 };
|