@nestxapp/ato-mcp 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +107 -2
- package/i18n.js +85 -0
- package/index.js +573 -0
- package/login.js +119 -0
- package/package.json +28 -4
- package/platform.js +75 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 NestX Software
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,108 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @nestxapp/ato-mcp
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
MCP server for ATO (AI Task Orchestrator). It exposes the ATO tools valid for one role identity
|
|
4
|
+
(dev, qa, ...) to any MCP client. No dependencies; requires Node 18 or newer.
|
|
5
|
+
|
|
6
|
+
## Connect your AI
|
|
7
|
+
|
|
8
|
+
The simplest way is the device login: it asks ATO for a code, you approve it in the portal, and the
|
|
9
|
+
key is written once to `<dir>/.env` (never printed).
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npx -y @nestxapp/ato-mcp login --name my-ai --role dev --model claude --dir ./.ato
|
|
13
|
+
```
|
|
14
|
+
|
|
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.
|
|
48
|
+
|
|
49
|
+
## Which tools you get
|
|
50
|
+
|
|
51
|
+
At start the server asks ATO who you are (`whoami`) and lists only the tools your role's permissions allow
|
|
52
|
+
(for example `task.claim` for `ato_claim`, `task.review` for the review tools, `proposal.decide` for
|
|
53
|
+
approving proposals). Roles are configured per company, so two AIs with different roles see different tools.
|
|
54
|
+
Tools open to every identity (`ato_whoami`, `ato_resume`, `ato_task_context`, comments, attachment reads) are
|
|
55
|
+
always listed. Servers that do not return permissions yet fall back to the legacy role families. If `whoami`
|
|
56
|
+
cannot be read (no key, bad key, network), only the open tools are listed until it works; call `ato_whoami`
|
|
57
|
+
to retry and see the reason.
|
|
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
|
+
|
|
67
|
+
## Configuration
|
|
68
|
+
|
|
69
|
+
| Variable | Meaning |
|
|
70
|
+
|---|---|
|
|
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. |
|
|
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`. |
|
|
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. |
|
|
75
|
+
|
|
76
|
+
## Claude Code (`.mcp.json` in the repo root)
|
|
77
|
+
|
|
78
|
+
```json
|
|
79
|
+
{
|
|
80
|
+
"mcpServers": {
|
|
81
|
+
"ato": {
|
|
82
|
+
"command": "npx",
|
|
83
|
+
"args": ["-y", "@nestxapp/ato-mcp"],
|
|
84
|
+
"env": {
|
|
85
|
+
"ATO_API_KEY": "<your-api-key>",
|
|
86
|
+
"ATO_API_BASE_URL": "https://<your-ato-gateway>/api/v1/ato",
|
|
87
|
+
"ATO_IDENTITY_DIR": "<absolute-path-to-repo>/.ato"
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Claude Desktop (`claude_desktop_config.json`) and Cursor (`.cursor/mcp.json`)
|
|
95
|
+
|
|
96
|
+
Same `mcpServers` block as above. On Windows use `"command": "cmd"` with `"args": ["/c", "npx", "-y", "@nestxapp/ato-mcp"]`.
|
|
97
|
+
|
|
98
|
+
## Verify
|
|
99
|
+
|
|
100
|
+
Restart the client; the `ato` server should list its tools (`ato_*`). The server speaks MCP over
|
|
101
|
+
stdio, so running it by hand just waits for input.
|
|
102
|
+
|
|
103
|
+
Releasing: push a tag `mcp-vX.Y.Z` matching the `version` in `package.json` (see
|
|
104
|
+
`.github/workflows/publish-ato-mcp.yml`).
|
|
105
|
+
|
|
106
|
+
## License
|
|
107
|
+
|
|
108
|
+
MIT, see `LICENSE`. This license covers only this MCP client package.
|
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
ADDED
|
@@ -0,0 +1,573 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// ato-mcp — MCP server for ATO. One instance per role identity.
|
|
3
|
+
// Reads .ato/identity.json (assigneeId/role/apiKeyEnvVar) + the API key from env, asks whoami for the
|
|
4
|
+
// identity's permissions and exposes ONLY the tools those permissions allow. Talks to the ATO REST API over HTTP with X-API-Key.
|
|
5
|
+
//
|
|
6
|
+
// Config via env:
|
|
7
|
+
// ATO_IDENTITY_DIR (dir containing identity.json, default cwd/.ato)
|
|
8
|
+
// ATO_API_KEY (the role's plaintext key; or set ATO_API_KEY_VAR to name an env var)
|
|
9
|
+
// ATO_API_BASE_URL (full API base incl. version prefix — gateway …/api/v1/ato, or raw …:5008/api/v1)
|
|
10
|
+
//
|
|
11
|
+
// Connect an AI without copying keys by hand: node index.js login --name claude-dev-1 --role dev --model claude
|
|
12
|
+
|
|
13
|
+
'use strict';
|
|
14
|
+
const fs = require('fs');
|
|
15
|
+
const os = require('os');
|
|
16
|
+
const path = require('path');
|
|
17
|
+
const platformLib = require('./platform');
|
|
18
|
+
|
|
19
|
+
const BASE_URL = process.env.ATO_API_BASE_URL || 'http://localhost:5000/api/v1/ato';
|
|
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
|
+
|
|
24
|
+
function loadIdentity() {
|
|
25
|
+
const file = path.join(identityDir(), 'identity.json');
|
|
26
|
+
if (fs.existsSync(file)) {
|
|
27
|
+
return JSON.parse(platformLib.readText(file));
|
|
28
|
+
}
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// The key written by `login` lives in <identity dir>/.env (git-ignored); env vars still win.
|
|
33
|
+
function loadDotEnvKey() {
|
|
34
|
+
const file = path.join(identityDir(), '.env');
|
|
35
|
+
if (!fs.existsSync(file)) return 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;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const identity = loadIdentity();
|
|
41
|
+
const apiKey = process.env.ATO_API_KEY || (identity && identity.apiKeyEnvVar ? process.env[identity.apiKeyEnvVar] : null) || loadDotEnvKey() || '';
|
|
42
|
+
const role = (identity && identity.role) || process.env.ATO_ROLE || '';
|
|
43
|
+
|
|
44
|
+
async function api(method, p, { body } = {}) {
|
|
45
|
+
const res = await fetch(`${BASE_URL}${p}`, {
|
|
46
|
+
method,
|
|
47
|
+
headers: { 'Content-Type': 'application/json', 'X-API-Key': apiKey },
|
|
48
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
49
|
+
});
|
|
50
|
+
const text = await res.text();
|
|
51
|
+
let json; try { json = JSON.parse(text); } catch { json = { raw: text }; }
|
|
52
|
+
return { status: res.status, json };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Evidence files (v3.2): multipart upload and binary download, so these two bypass the JSON-only api() helper.
|
|
56
|
+
const ATTACH_TYPES = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'pdf', 'txt', 'csv', 'json', 'zip'];
|
|
57
|
+
const ATTACH_MAX_BYTES = 10 * 1024 * 1024;
|
|
58
|
+
async function uploadFile(p, filePath, fields) {
|
|
59
|
+
const abs = path.resolve(String(filePath));
|
|
60
|
+
if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) throw new Error(`filePath: '${abs}' is not a file.`);
|
|
61
|
+
const ext = path.extname(abs).slice(1).toLowerCase();
|
|
62
|
+
if (!ATTACH_TYPES.includes(ext)) throw new Error(`filePath: '.${ext}' is not allowed. Allowed: ${ATTACH_TYPES.join(', ')}.`);
|
|
63
|
+
const size = fs.statSync(abs).size;
|
|
64
|
+
if (size === 0) throw new Error('filePath: the file is empty.');
|
|
65
|
+
if (size > ATTACH_MAX_BYTES) throw new Error(`filePath: ${size} bytes exceeds the 10 MB limit.`);
|
|
66
|
+
const form = new FormData();
|
|
67
|
+
form.append('file', new Blob([fs.readFileSync(abs)]), path.basename(abs));
|
|
68
|
+
for (const [k, v] of Object.entries(fields)) if (v) form.append(k, v);
|
|
69
|
+
const res = await fetch(`${BASE_URL}${p}`, { method: 'POST', headers: { 'X-API-Key': apiKey }, body: form });
|
|
70
|
+
const text = await res.text();
|
|
71
|
+
let json; try { json = JSON.parse(text); } catch { json = { raw: text }; }
|
|
72
|
+
return { status: res.status, json };
|
|
73
|
+
}
|
|
74
|
+
async function downloadFile(fileId, dir) {
|
|
75
|
+
const res = await fetch(`${BASE_URL}/attachments/${encodeURIComponent(String(fileId).trim())}/content`, { headers: { 'X-API-Key': apiKey } });
|
|
76
|
+
if (!res.ok) { const t = await res.text(); let json; try { json = JSON.parse(t); } catch { json = { raw: t }; } return { status: res.status, json }; }
|
|
77
|
+
const disposition = res.headers.get('content-disposition') || '';
|
|
78
|
+
const star = /filename\*=UTF-8''([^;]+)/i.exec(disposition);
|
|
79
|
+
const name = path.basename(star ? decodeURIComponent(star[1]) : `${fileId}`);
|
|
80
|
+
const target = path.join(dir || path.join(os.tmpdir(), 'ato-attachments'), String(fileId), name);
|
|
81
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
82
|
+
fs.writeFileSync(target, Buffer.from(await res.arrayBuffer()));
|
|
83
|
+
return { status: 200, json: { savedTo: target, contentType: res.headers.get('content-type'), bytes: fs.statSync(target).size } };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// A task reference is the task GUID, its typed key ("AF-HU-12": project, kind prefix EP/HU/TK/ST/BG/TS, number per kind)
|
|
87
|
+
// or the legacy project-wide key ("AF-80", still resolves). The API resolves all three on every {id} route, so the value is
|
|
88
|
+
// only URL-encoded here. A typed key stops resolving if the task's kind changes (it gets a new number); the GUID never changes.
|
|
89
|
+
const TASK_REF = { type: 'string', description: 'Task key, typed (e.g. AF-HU-12) or legacy (e.g. AF-80), or GUID.' };
|
|
90
|
+
// Request bodies are deserialized as Guid: only {id} route segments resolve keys server-side, so the bodies below take GUIDs.
|
|
91
|
+
// Tools that accept a TASK_REF in a body field resolve it here first (GET /tasks/{ref}).
|
|
92
|
+
const BODY_REF = { type: 'string', description: 'Task key, typed (e.g. AF-HU-12) or legacy (e.g. AF-80), or GUID (resolved by this MCP before sending).' };
|
|
93
|
+
const GUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
94
|
+
async function resolveId(ref, field) {
|
|
95
|
+
const v = String(ref).trim();
|
|
96
|
+
if (GUID_RE.test(v)) return v;
|
|
97
|
+
const { status, json } = await api('GET', `/tasks/${encodeURIComponent(v)}`);
|
|
98
|
+
const id = json && json.data && json.data.id;
|
|
99
|
+
if (status !== 200 || !id) throw new Error(`${field}: cannot resolve task reference '${v}' (HTTP ${status}${json && json.code ? ' ' + json.code : ''}).`);
|
|
100
|
+
return id;
|
|
101
|
+
}
|
|
102
|
+
const BUG_NOTE = ' kind=bug requires a parent that is an epic, or a story/task whose parent is an epic; otherwise the API answers 422 BUG_REQUIRES_EPIC (returned unchanged in the tool output).';
|
|
103
|
+
// Planning fields shared by create, update and estimate. Estimate is in hours (decimal, e.g. 2.5); dates are YYYY-MM-DD.
|
|
104
|
+
const PLANNING = {
|
|
105
|
+
estimate: { type: 'number', description: 'Estimated effort in hours (e.g. 4 or 2.5).' },
|
|
106
|
+
startDate: { type: 'string', description: 'Planned start, YYYY-MM-DD.' },
|
|
107
|
+
dueDate: { type: 'string', description: 'Planned end, YYYY-MM-DD (on or after startDate).' },
|
|
108
|
+
};
|
|
109
|
+
const planningBody = (a) => {
|
|
110
|
+
const b = {};
|
|
111
|
+
if (a.estimate !== undefined && a.estimate !== null) b.estimate = a.estimate;
|
|
112
|
+
if (a.startDate) b.startDate = a.startDate;
|
|
113
|
+
if (a.dueDate) b.dueDate = a.dueDate;
|
|
114
|
+
return b;
|
|
115
|
+
};
|
|
116
|
+
const KINDS = ['epic', 'story', 'task', 'subtask', 'bug', 'test'];
|
|
117
|
+
const tid = (a) => encodeURIComponent(String(a.taskId).trim());
|
|
118
|
+
|
|
119
|
+
// One-line digest of a task DTO, prepended to tool output so key/kind/labels/reviewRounds are
|
|
120
|
+
// visible without reading the JSON. v2 fields are optional: a v1 server simply omits them.
|
|
121
|
+
function taskLine(t) {
|
|
122
|
+
if (!t || typeof t !== 'object' || !t.title || !t.status) return null;
|
|
123
|
+
const parts = [t.key || t.id, `[${t.status}]`];
|
|
124
|
+
if (t.legacyKey && t.legacyKey !== t.key) parts.push(`(also ${t.legacyKey})`);
|
|
125
|
+
if (t.kind) parts.push(`kind=${t.kind}`);
|
|
126
|
+
if (Array.isArray(t.labels) && t.labels.length) parts.push(`labels=${t.labels.join(',')}`);
|
|
127
|
+
if (typeof t.reviewRounds === 'number') parts.push(`reviewRounds=${t.reviewRounds}`);
|
|
128
|
+
parts.push(`- ${t.title}`);
|
|
129
|
+
return parts.join(' ');
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function summarize(body) {
|
|
133
|
+
const data = body && typeof body === 'object' && 'data' in body ? body.data : body;
|
|
134
|
+
const items = Array.isArray(data) ? data : [data];
|
|
135
|
+
const lines = items.map(taskLine).filter(Boolean);
|
|
136
|
+
return lines.length ? lines.join('\n') : null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ── Tool registry: name -> { desc, schema, perms, roles, fn } ──────────────────
|
|
140
|
+
// perms: capabilities (any one is enough; null = open to every identity). roles: legacy role families, only used
|
|
141
|
+
// as a fallback when the server does not return `permissions` from whoami (older ATO servers).
|
|
142
|
+
const TOOLS = [
|
|
143
|
+
{
|
|
144
|
+
name: 'ato_whoami', roles: ['dev', 'code_review', 'qa', 'pmo'], perms: null,
|
|
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.',
|
|
146
|
+
schema: { type: 'object', properties: {} },
|
|
147
|
+
fn: async () => api('GET', '/ai/whoami'),
|
|
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
|
+
},
|
|
156
|
+
{
|
|
157
|
+
name: 'ato_resume', roles: ['dev', 'code_review', 'qa', 'pmo'], perms: null,
|
|
158
|
+
desc: 'Resumes this assignee open work: in_progress/changes_requested/qa_rejected tasks + last 10 events.',
|
|
159
|
+
schema: { type: 'object', properties: {} },
|
|
160
|
+
fn: async () => api('GET', '/ai/resume'),
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
name: 'ato_backlog', roles: ['dev', 'pmo'], perms: ['task.view'],
|
|
164
|
+
desc: 'List the project backlog (status=backlog). Returns each task with its key (e.g. AF-TK-42), id, rowVersion, projectId, epicLabel, priority and title so you can pick work and claim it with ato_claim.',
|
|
165
|
+
schema: { type: 'object', properties: {
|
|
166
|
+
projectId: { type: 'string' },
|
|
167
|
+
priority: { type: 'string', enum: ['critical', 'high', 'medium', 'low'] },
|
|
168
|
+
type: { type: 'string', enum: ['backend', 'frontend', 'infra', 'docs', 'test', 'bug'] },
|
|
169
|
+
} },
|
|
170
|
+
fn: async (a) => {
|
|
171
|
+
const q = new URLSearchParams({ status: 'backlog' });
|
|
172
|
+
if (a.projectId) q.set('projectId', a.projectId);
|
|
173
|
+
if (a.priority) q.set('priority', a.priority);
|
|
174
|
+
if (a.type) q.set('type', a.type);
|
|
175
|
+
return api('GET', `/ai/tasks?${q.toString()}`);
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
name: 'ato_tasks', roles: ['dev', 'pmo'], perms: ['task.view'],
|
|
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.',
|
|
181
|
+
schema: { type: 'object', properties: {
|
|
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.' },
|
|
183
|
+
projectId: { type: 'string' },
|
|
184
|
+
priority: { type: 'string', enum: ['critical', 'high', 'medium', 'low'] },
|
|
185
|
+
type: { type: 'string', enum: ['backend', 'frontend', 'infra', 'docs', 'test', 'bug'] },
|
|
186
|
+
} },
|
|
187
|
+
fn: async (a) => {
|
|
188
|
+
const q = new URLSearchParams();
|
|
189
|
+
if (a.status) q.set('status', a.status);
|
|
190
|
+
if (a.projectId) q.set('projectId', a.projectId);
|
|
191
|
+
if (a.priority) q.set('priority', a.priority);
|
|
192
|
+
if (a.type) q.set('type', a.type);
|
|
193
|
+
return api('GET', `/ai/tasks?${q.toString()}`);
|
|
194
|
+
},
|
|
195
|
+
},
|
|
196
|
+
{
|
|
197
|
+
name: 'ato_task_context', roles: ['dev', 'code_review', 'qa', 'pmo'], perms: null,
|
|
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).',
|
|
199
|
+
schema: { type: 'object', properties: { taskId: TASK_REF }, required: ['taskId'] },
|
|
200
|
+
fn: async (a) => api('GET', `/ai/tasks/${tid(a)}`),
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
name: 'ato_comment', roles: ['dev', 'code_review', 'qa', 'pmo'], perms: null,
|
|
204
|
+
desc: 'Post a comment/suggestion/question/objection/reply on a task (never changes status).',
|
|
205
|
+
schema: { type: 'object', properties: { taskId: TASK_REF, body: { type: 'string' }, kind: { type: 'string', enum: ['suggestion', 'question', 'objection', 'reply'] }, parentCommentId: { type: 'string' } }, required: ['taskId', 'body', 'kind'] },
|
|
206
|
+
fn: async (a) => api('POST', `/tasks/${tid(a)}/comments`, { body: { body: a.body, kind: a.kind, parentCommentId: a.parentCommentId || null } }),
|
|
207
|
+
},
|
|
208
|
+
{
|
|
209
|
+
name: 'ato_debate', roles: ['dev', 'code_review', 'qa', 'pmo'], perms: null,
|
|
210
|
+
desc: 'List the comment/debate thread for a task.',
|
|
211
|
+
schema: { type: 'object', properties: { taskId: TASK_REF }, required: ['taskId'] },
|
|
212
|
+
fn: async (a) => api('GET', `/tasks/${tid(a)}/comments`),
|
|
213
|
+
},
|
|
214
|
+
{
|
|
215
|
+
name: 'ato_claim', roles: ['dev'], perms: ['task.claim'],
|
|
216
|
+
desc: 'Atomically claim a backlog task. Second concurrent claim gets a clean 409.',
|
|
217
|
+
schema: { type: 'object', properties: { taskId: TASK_REF, rowVersion: { type: 'string' }, chatLabel: { type: 'string' } }, required: ['taskId', 'rowVersion'] },
|
|
218
|
+
fn: async (a) => api('PATCH', `/ai/tasks/${tid(a)}/claim`, { body: { rowVersion: a.rowVersion, chatLabel: a.chatLabel || null } }),
|
|
219
|
+
},
|
|
220
|
+
{
|
|
221
|
+
name: 'ato_progress', roles: ['dev'], perms: ['task.progress'],
|
|
222
|
+
desc: 'Report progress (bumps heartbeat; a commitHash is required before review unless you submit with noCodeChanges).',
|
|
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'] },
|
|
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 } }),
|
|
225
|
+
},
|
|
226
|
+
{
|
|
227
|
+
name: 'ato_handoff', roles: ['dev'], perms: ['task.progress'],
|
|
228
|
+
desc: 'Hand off context (does NOT release the claim).',
|
|
229
|
+
schema: { type: 'object', properties: { taskId: TASK_REF, pendingSteps: { type: 'array', items: { type: 'string' } }, reason: { type: 'string' }, contextSnapshot: { type: 'object' } }, required: ['taskId'] },
|
|
230
|
+
fn: async (a) => api('POST', `/ai/tasks/${tid(a)}/handoff`, { body: { pendingSteps: a.pendingSteps || null, reason: a.reason || 'session_end', contextSnapshot: a.contextSnapshot || null } }),
|
|
231
|
+
},
|
|
232
|
+
{
|
|
233
|
+
name: 'ato_block', roles: ['dev'], perms: ['task.progress'],
|
|
234
|
+
desc: 'Block a task (in_progress → blocked).',
|
|
235
|
+
schema: { type: 'object', properties: { taskId: TASK_REF, reason: { type: 'string' }, blockedByTaskIds: { type: 'array', items: BODY_REF } }, required: ['taskId'] },
|
|
236
|
+
fn: async (a) => api('PATCH', `/ai/tasks/${tid(a)}/block`, { body: { reason: a.reason || null, blockedByTaskIds: a.blockedByTaskIds ? await Promise.all(a.blockedByTaskIds.map(r => resolveId(r, 'blockedByTaskIds'))) : null } }),
|
|
237
|
+
},
|
|
238
|
+
{
|
|
239
|
+
name: 'ato_unblock', roles: ['dev'], perms: ['task.progress'],
|
|
240
|
+
desc: 'Unblock a task (blocked → in_progress).',
|
|
241
|
+
schema: { type: 'object', properties: { taskId: TASK_REF }, required: ['taskId'] },
|
|
242
|
+
fn: async (a) => api('PATCH', `/ai/tasks/${tid(a)}/unblock`, { body: {} }),
|
|
243
|
+
},
|
|
244
|
+
{
|
|
245
|
+
name: 'ato_submit_for_review', roles: ['dev'], perms: ['task.submit'],
|
|
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 } }),
|
|
249
|
+
},
|
|
250
|
+
{
|
|
251
|
+
name: 'ato_propose_subtask', roles: ['dev'], perms: ['task.progress'],
|
|
252
|
+
desc: 'Propose a subtask (proposed status; PMO approves). Never creates a task directly. taskId is the parent.' + BUG_NOTE,
|
|
253
|
+
schema: { type: 'object', properties: { taskId: TASK_REF, title: { type: 'string' }, type: { type: 'string', enum: ['backend', 'frontend', 'infra', 'docs', 'test', 'bug'], description: 'Legacy discipline, kept for compatibility. Prefer kind + labels.' }, kind: { type: 'string', enum: KINDS }, labels: { type: 'array', items: { type: 'string' } }, priority: { type: 'string', enum: ['critical', 'high', 'medium', 'low'] }, description: { type: 'string' }, reason: { type: 'string' } }, required: ['taskId', 'title', 'priority'] },
|
|
254
|
+
fn: async (a) => {
|
|
255
|
+
if (!a.type && !a.kind) throw new Error('Provide kind (v2) or type (legacy).');
|
|
256
|
+
const body = { title: a.title, priority: a.priority, description: a.description || null, reason: a.reason || null };
|
|
257
|
+
if (a.type) body.type = a.type;
|
|
258
|
+
if (a.kind) body.kind = a.kind;
|
|
259
|
+
if (a.labels) body.labels = a.labels;
|
|
260
|
+
return api('POST', `/ai/tasks/${tid(a)}/propose-subtask`, { body });
|
|
261
|
+
},
|
|
262
|
+
},
|
|
263
|
+
{
|
|
264
|
+
name: 'ato_review_queue', roles: ['code_review'], perms: ['task.review'],
|
|
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).',
|
|
266
|
+
schema: { type: 'object', properties: { projectId: { type: 'string' } } },
|
|
267
|
+
fn: async (a) => api('GET', `/review/queue${a.projectId ? `?projectId=${a.projectId}` : ''}`),
|
|
268
|
+
},
|
|
269
|
+
{
|
|
270
|
+
name: 'ato_review_approve', roles: ['code_review'], perms: ['task.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.',
|
|
272
|
+
schema: { type: 'object', properties: { taskId: TASK_REF, notes: { type: 'string' } }, required: ['taskId'] },
|
|
273
|
+
fn: async (a) => api('PATCH', `/review/tasks/${tid(a)}/approve`, { body: { notes: a.notes || null } }),
|
|
274
|
+
},
|
|
275
|
+
{
|
|
276
|
+
name: 'ato_review_request_changes', roles: ['code_review'], perms: ['task.review'],
|
|
277
|
+
desc: 'Request changes (in_review → changes_requested; notes must name file/line/problem).',
|
|
278
|
+
schema: { type: 'object', properties: { taskId: TASK_REF, notes: { type: 'string' } }, required: ['taskId', 'notes'] },
|
|
279
|
+
fn: async (a) => api('PATCH', `/review/tasks/${tid(a)}/request-changes`, { body: { notes: a.notes } }),
|
|
280
|
+
},
|
|
281
|
+
{
|
|
282
|
+
name: 'ato_qa_queue', roles: ['qa'], perms: ['task.qa'],
|
|
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).',
|
|
284
|
+
schema: { type: 'object', properties: { projectId: { type: 'string' } } },
|
|
285
|
+
fn: async (a) => api('GET', `/qa/queue${a.projectId ? `?projectId=${a.projectId}` : ''}`),
|
|
286
|
+
},
|
|
287
|
+
{
|
|
288
|
+
name: 'ato_qa_approve', roles: ['qa'], perms: ['task.qa'],
|
|
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.',
|
|
290
|
+
schema: { type: 'object', properties: { taskId: TASK_REF, qaEvidence: { type: 'string' } }, required: ['taskId', 'qaEvidence'] },
|
|
291
|
+
fn: async (a) => api('PATCH', `/qa/tasks/${tid(a)}/approve`, { body: { qaEvidence: a.qaEvidence } }),
|
|
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
|
+
},
|
|
299
|
+
{
|
|
300
|
+
name: 'ato_qa_reject', roles: ['qa'], perms: ['task.qa'],
|
|
301
|
+
desc: 'Reject QA (qa_testing → qa_rejected; qaNotes must describe the observed failure).',
|
|
302
|
+
schema: { type: 'object', properties: { taskId: TASK_REF, qaNotes: { type: 'string' } }, required: ['taskId', 'qaNotes'] },
|
|
303
|
+
fn: async (a) => api('PATCH', `/qa/tasks/${tid(a)}/reject`, { body: { qaNotes: a.qaNotes } }),
|
|
304
|
+
},
|
|
305
|
+
{
|
|
306
|
+
name: 'ato_qa_claim_test', roles: ['qa'], perms: ['task.qa'],
|
|
307
|
+
desc: 'Claim a Type=Test task for QA (backlog → in_progress). QA owns test tasks end to end — no dev/code review. Rejects non-Test tasks with TASK_TYPE_MISMATCH.',
|
|
308
|
+
schema: { type: 'object', properties: { taskId: TASK_REF, rowVersion: { type: 'string' }, chatLabel: { type: 'string' } }, required: ['taskId', 'rowVersion'] },
|
|
309
|
+
fn: async (a) => api('PATCH', `/qa/tasks/${tid(a)}/claim-test`, { body: { rowVersion: a.rowVersion, chatLabel: a.chatLabel || null } }),
|
|
310
|
+
},
|
|
311
|
+
{
|
|
312
|
+
name: 'ato_qa_start_testing', roles: ['qa'], perms: ['task.qa'],
|
|
313
|
+
desc: 'Move a Type=Test task straight to QA testing (in_progress → qa_testing), skipping code review (VerificationEvidence/CodeReviewStatus stay null). Rejects non-Test tasks with TASK_TYPE_MISMATCH.',
|
|
314
|
+
schema: { type: 'object', properties: { taskId: TASK_REF }, required: ['taskId'] },
|
|
315
|
+
fn: async (a) => api('PATCH', `/qa/tasks/${tid(a)}/start-testing`, { body: {} }),
|
|
316
|
+
},
|
|
317
|
+
{
|
|
318
|
+
name: 'ato_pmo_create_task', roles: ['pmo'], perms: ['task.create'],
|
|
319
|
+
desc: 'Create a task directly (subject to the MVP gate). Pass kind (+ labels) for the v2 model, or type for the legacy discipline; at least one of kind/type is required. parentTaskId accepts a key or GUID.' + BUG_NOTE,
|
|
320
|
+
schema: { type: 'object', properties: { projectId: { type: 'string' }, title: { type: 'string' }, type: { type: 'string', enum: ['backend', 'frontend', 'infra', 'docs', 'test', 'bug'], description: 'Legacy discipline, kept for compatibility. Prefer kind + labels.' }, kind: { type: 'string', enum: KINDS }, labels: { type: 'array', items: { type: 'string' }, description: 'Discipline and free labels, e.g. ["backend"].' }, priority: { type: 'string', enum: ['critical', 'high', 'medium', 'low'] }, sprintId: { type: 'string' }, epicLabel: { type: 'string' }, parentTaskId: BODY_REF, description: { type: 'string' }, acceptanceCriteria: { type: 'string' }, isUrgentOverride: { type: 'boolean' }, humanApprovedBy: { type: 'string' }, ...PLANNING, assigneeMemberId: { type: 'string', description: 'Member id (GUID) responsible for the task; optional.' } }, required: ['projectId', 'title', 'priority'] },
|
|
321
|
+
fn: async (a) => {
|
|
322
|
+
if (!a.type && !a.kind) throw new Error('Provide kind (v2) or type (legacy).');
|
|
323
|
+
const body = { projectId: a.projectId, title: a.title, priority: a.priority, sprintId: a.sprintId || null, epicLabel: a.epicLabel || null, description: a.description || null, acceptanceCriteria: a.acceptanceCriteria || null, isUrgentOverride: !!a.isUrgentOverride, humanApprovedBy: a.humanApprovedBy || null };
|
|
324
|
+
if (a.type) body.type = a.type;
|
|
325
|
+
if (a.kind) body.kind = a.kind;
|
|
326
|
+
if (a.labels) body.labels = a.labels;
|
|
327
|
+
if (a.parentTaskId) body.parentTaskId = await resolveId(a.parentTaskId, 'parentTaskId');
|
|
328
|
+
Object.assign(body, planningBody(a));
|
|
329
|
+
if (a.assigneeMemberId) body.assigneeMemberId = a.assigneeMemberId;
|
|
330
|
+
return api('POST', '/pmo/tasks', { body });
|
|
331
|
+
},
|
|
332
|
+
},
|
|
333
|
+
{
|
|
334
|
+
name: 'ato_pmo_approve_proposal', roles: ['pmo'], perms: ['proposal.decide'],
|
|
335
|
+
desc: 'Approve a dev proposal (proposed → backlog).',
|
|
336
|
+
schema: { type: 'object', properties: { taskId: TASK_REF }, required: ['taskId'] },
|
|
337
|
+
fn: async (a) => api('PATCH', `/pmo/tasks/${tid(a)}/approve-proposal`, { body: {} }),
|
|
338
|
+
},
|
|
339
|
+
{
|
|
340
|
+
name: 'ato_pmo_reject_proposal', roles: ['pmo'], perms: ['proposal.decide'],
|
|
341
|
+
desc: 'Reject a dev proposal (proposed → rejected).',
|
|
342
|
+
schema: { type: 'object', properties: { taskId: TASK_REF }, required: ['taskId'] },
|
|
343
|
+
fn: async (a) => api('PATCH', `/pmo/tasks/${tid(a)}/reject-proposal`, { body: {} }),
|
|
344
|
+
},
|
|
345
|
+
{
|
|
346
|
+
name: 'ato_update_task', roles: ['pmo'], perms: ['task.edit'],
|
|
347
|
+
desc: 'Edit a task (pmo/tech_lead): title, description, acceptance criteria, priority, kind, labels, parent (epic/story), sprint, assignee, project, estimate (hours) and planned dates. Only the fields you pass change; the history records one "updated" event.' + BUG_NOTE,
|
|
348
|
+
schema: { type: 'object', properties: {
|
|
349
|
+
taskId: TASK_REF,
|
|
350
|
+
title: { type: 'string' }, description: { type: 'string' }, acceptanceCriteria: { type: 'string' },
|
|
351
|
+
priority: { type: 'string', enum: ['critical', 'high', 'medium', 'low'] },
|
|
352
|
+
kind: { type: 'string', enum: KINDS }, labels: { type: 'array', items: { type: 'string' } },
|
|
353
|
+
parentTaskId: BODY_REF, clearParent: { type: 'boolean' },
|
|
354
|
+
sprintId: { type: 'string' }, clearSprint: { type: 'boolean' },
|
|
355
|
+
assigneeMemberId: { type: 'string', description: 'Member id (GUID).' }, clearAssignee: { type: 'boolean' },
|
|
356
|
+
projectId: { type: 'string', description: 'Move to another project (no children, not done).' },
|
|
357
|
+
...PLANNING,
|
|
358
|
+
}, required: ['taskId'] },
|
|
359
|
+
fn: async (a) => {
|
|
360
|
+
const body = {};
|
|
361
|
+
for (const k of ['title', 'description', 'acceptanceCriteria', 'priority', 'kind', 'labels', 'clearParent', 'sprintId', 'clearSprint', 'assigneeMemberId', 'clearAssignee', 'projectId'])
|
|
362
|
+
if (a[k] !== undefined && a[k] !== null) body[k] = a[k];
|
|
363
|
+
if (a.parentTaskId) body.parentTaskId = await resolveId(a.parentTaskId, 'parentTaskId');
|
|
364
|
+
Object.assign(body, planningBody(a));
|
|
365
|
+
return api('PUT', `/tasks/${tid(a)}`, { body });
|
|
366
|
+
},
|
|
367
|
+
},
|
|
368
|
+
{
|
|
369
|
+
name: 'ato_estimate', roles: ['dev', 'pmo'], perms: ['task.edit', 'task.claim'],
|
|
370
|
+
desc: 'Plan a task you own (or any task as pmo/tech_lead): estimated effort in hours and planned start/end dates. Use it right after claiming a task to say how long it will take.',
|
|
371
|
+
schema: { type: 'object', properties: { taskId: TASK_REF, ...PLANNING }, required: ['taskId'] },
|
|
372
|
+
fn: async (a) => {
|
|
373
|
+
const body = planningBody(a);
|
|
374
|
+
if (Object.keys(body).length === 0) throw new Error('Pass estimate, startDate and/or dueDate.');
|
|
375
|
+
return api('PATCH', `/tasks/${tid(a)}/estimate`, { body });
|
|
376
|
+
},
|
|
377
|
+
},
|
|
378
|
+
{
|
|
379
|
+
name: 'ato_pmo_backlog_health', roles: ['pmo'], perms: ['task.view'],
|
|
380
|
+
desc: 'Backlog health: ready backlog vs active dev capacity.',
|
|
381
|
+
schema: { type: 'object', properties: { projectId: { type: 'string' } }, required: ['projectId'] },
|
|
382
|
+
fn: async (a) => api('GET', `/pmo/projects/${a.projectId}/backlog-health`),
|
|
383
|
+
},
|
|
384
|
+
{
|
|
385
|
+
name: 'ato_pmo_link_dependency', roles: ['pmo'], perms: ['task.edit'],
|
|
386
|
+
desc: 'Declares that a backlog/proposed task depends on (is blocked by) another task (BlockedByTaskIds). Only applies to tasks in backlog/proposed.',
|
|
387
|
+
schema: { type: 'object', properties: { taskId: TASK_REF, blockedByTaskIds: { type: 'array', items: BODY_REF } }, required: ['taskId', 'blockedByTaskIds'] },
|
|
388
|
+
fn: async (a) => api('PATCH', `/pmo/tasks/${tid(a)}/blocked-by`, { body: { blockedByTaskIds: await Promise.all(a.blockedByTaskIds.map(r => resolveId(r, 'blockedByTaskIds'))) } }),
|
|
389
|
+
},
|
|
390
|
+
{
|
|
391
|
+
name: 'ato_attach_evidence', roles: ['dev', 'code_review', 'qa', 'pmo'], perms: ['attachment.upload'],
|
|
392
|
+
desc: 'Attach a local file to a task as evidence (a QA screenshot, a test log as .txt, a spec mockup). Types: png, jpg, gif, webp, pdf, txt, csv, json, zip; at most 10 MB per file and 30 per task. purpose: evidence (default), spec or other; reviewId optionally ties the file to a review round of the same task. Returns the stored attachment (fileId, url).',
|
|
393
|
+
schema: { type: 'object', properties: {
|
|
394
|
+
taskId: TASK_REF,
|
|
395
|
+
filePath: { type: 'string', description: 'Path of the local file to upload.' },
|
|
396
|
+
purpose: { type: 'string', enum: ['evidence', 'spec', 'other'] },
|
|
397
|
+
reviewId: { type: 'string', description: 'Optional review (code review or QA round) of the same task this file backs.' },
|
|
398
|
+
}, required: ['taskId', 'filePath'] },
|
|
399
|
+
fn: async (a) => uploadFile(`/tasks/${tid(a)}/attachments`, a.filePath, { purpose: a.purpose || 'evidence', reviewId: a.reviewId }),
|
|
400
|
+
},
|
|
401
|
+
{
|
|
402
|
+
name: 'ato_attachments', roles: ['dev', 'code_review', 'qa', 'pmo'], perms: null,
|
|
403
|
+
desc: 'List the files attached to a task (name, type, size, purpose, who uploaded it, fileId). Use ato_fetch_attachment to read one.',
|
|
404
|
+
schema: { type: 'object', properties: { taskId: TASK_REF }, required: ['taskId'] },
|
|
405
|
+
fn: async (a) => api('GET', `/tasks/${tid(a)}/attachments`),
|
|
406
|
+
},
|
|
407
|
+
{
|
|
408
|
+
name: 'ato_fetch_attachment', roles: ['dev', 'code_review', 'qa', 'pmo'], perms: null,
|
|
409
|
+
desc: 'Download an attached file by fileId into a local folder (default: a temp folder) and return its path, so you can open a mockup or read an evidence file.',
|
|
410
|
+
schema: { type: 'object', properties: { fileId: { type: 'string' }, saveDir: { type: 'string', description: 'Optional folder to save into.' } }, required: ['fileId'] },
|
|
411
|
+
fn: async (a) => downloadFile(a.fileId, a.saveDir),
|
|
412
|
+
},
|
|
413
|
+
];
|
|
414
|
+
|
|
415
|
+
// ── Permission state ─────────────────────────────────────────────────────
|
|
416
|
+
// Tools are filtered by the capabilities whoami returns (roles are configurable per company, so role names
|
|
417
|
+
// mean nothing here). Servers that do not return `permissions` get the legacy role mapping (tech_lead = all).
|
|
418
|
+
// If whoami cannot be read the identity is unknown: fail closed, only the open tools (perms: null) are listed.
|
|
419
|
+
const auth = { ready: false, permissions: null, role, roleName: null, error: null };
|
|
420
|
+
|
|
421
|
+
function applyWhoami(json) {
|
|
422
|
+
const d = (json && json.data) || json || {};
|
|
423
|
+
if (d.role) auth.role = d.role;
|
|
424
|
+
auth.roleName = d.roleName || null;
|
|
425
|
+
auth.permissions = Array.isArray(d.permissions) ? d.permissions : null;
|
|
426
|
+
auth.error = null;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
async function loadAuth() {
|
|
430
|
+
if (!apiKey) { auth.error = 'No API key configured (ATO_API_KEY or <identity dir>/.env).'; auth.ready = true; return; }
|
|
431
|
+
try {
|
|
432
|
+
const { status, json } = await api('GET', '/ai/whoami');
|
|
433
|
+
if (status === 200) applyWhoami(json);
|
|
434
|
+
else auth.error = `whoami answered HTTP ${status}${json && json.code ? ' ' + json.code : ''}.`;
|
|
435
|
+
} catch (e) {
|
|
436
|
+
auth.error = `whoami failed: ${e.message}`;
|
|
437
|
+
}
|
|
438
|
+
auth.ready = true;
|
|
439
|
+
}
|
|
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
|
+
|
|
475
|
+
const allowed = (t) => {
|
|
476
|
+
if (!t.perms) return true;
|
|
477
|
+
if (auth.error) return false;
|
|
478
|
+
if (auth.permissions) return t.perms.some((p) => auth.permissions.includes(p));
|
|
479
|
+
return auth.role === 'tech_lead' || t.roles.includes(auth.role);
|
|
480
|
+
};
|
|
481
|
+
const visibleTools = () => TOOLS.filter(allowed).map(t => ({ name: t.name, description: t.desc, inputSchema: t.schema }));
|
|
482
|
+
|
|
483
|
+
function respond(id, result) {
|
|
484
|
+
process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id, result }) + '\n');
|
|
485
|
+
}
|
|
486
|
+
function respondError(id, code, message) {
|
|
487
|
+
process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id, error: { code, message } }) + '\n');
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// Calling ato_whoami re-reads the permissions; tell the client if the visible tool set changed.
|
|
491
|
+
async function refreshAfterWhoami(status, json) {
|
|
492
|
+
const before = visibleTools().map(t => t.name).join();
|
|
493
|
+
if (status === 200) applyWhoami(json); else auth.error = `whoami answered HTTP ${status}.`;
|
|
494
|
+
if (visibleTools().map(t => t.name).join() !== before) process.stdout.write(JSON.stringify({ jsonrpc: '2.0', method: 'notifications/tools/list_changed' }) + String.fromCharCode(10));
|
|
495
|
+
if (auth.error && json && typeof json === 'object') json.mcpStatus = `Identity not verified: ${auth.error} Only tools open to every identity are listed.`;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
async function handle(msg) {
|
|
499
|
+
if (msg.method === 'initialize') {
|
|
500
|
+
await Promise.race([playbookReady, new Promise((r) => setTimeout(r, PLAYBOOK_TIMEOUT_MS))]);
|
|
501
|
+
return respond(msg.id, {
|
|
502
|
+
protocolVersion: '2024-11-05',
|
|
503
|
+
capabilities: { tools: { listChanged: true } },
|
|
504
|
+
serverInfo: { name: 'ato-mcp', version: '1.6.0' },
|
|
505
|
+
instructions: instructions(),
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
if (msg.method === 'notifications/initialized') return;
|
|
509
|
+
if (msg.method === 'tools/list') {
|
|
510
|
+
if (!auth.ready || auth.error) await loadAuth();
|
|
511
|
+
return respond(msg.id, { tools: visibleTools() });
|
|
512
|
+
}
|
|
513
|
+
if (msg.method === 'tools/call') {
|
|
514
|
+
const { name, arguments: args } = msg.params || {};
|
|
515
|
+
const tool = TOOLS.find(t => t.name === name);
|
|
516
|
+
if (!tool) return respondError(msg.id, -32601, `Unknown tool: ${name}`);
|
|
517
|
+
if (!auth.ready || auth.error) await loadAuth();
|
|
518
|
+
if (!allowed(tool)) {
|
|
519
|
+
const why = auth.error ? `identity not verified (${auth.error})` : `your role${auth.roleName ? ` '${auth.roleName}'` : ''} lacks ${tool.perms.join(' or ')}`;
|
|
520
|
+
return respondError(msg.id, -32602, `Tool '${name}' is not available: ${why}`);
|
|
521
|
+
}
|
|
522
|
+
try {
|
|
523
|
+
const { status, json } = await tool.fn(args || {});
|
|
524
|
+
if (tool.name === 'ato_whoami') await refreshAfterWhoami(status, json);
|
|
525
|
+
const content = [{ type: 'text', text: JSON.stringify({ httpStatus: status, body: json }, null, 2) }];
|
|
526
|
+
const digest = tool.text ? tool.text(json) : summarize(json);
|
|
527
|
+
if (digest) content.unshift({ type: 'text', text: digest });
|
|
528
|
+
return respond(msg.id, { content, isError: false });
|
|
529
|
+
} catch (e) {
|
|
530
|
+
return respond(msg.id, { content: [{ type: 'text', text: 'ERROR: ' + e.message }], isError: true });
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
if (msg.method && msg.method.startsWith('notifications/')) return;
|
|
534
|
+
return respondError(msg.id, -32601, `Method not found: ${msg.method}`);
|
|
535
|
+
}
|
|
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
|
+
|
|
550
|
+
// `node index.js login ...` runs the "Connect my AI" device flow instead of the MCP server.
|
|
551
|
+
if (process.argv[2] === 'login') {
|
|
552
|
+
require('./login').run(process.argv.slice(3))
|
|
553
|
+
.then(() => process.exit(0))
|
|
554
|
+
.catch((e) => { console.error(' ' + e.message); process.exit(1); });
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
loadAuth();
|
|
559
|
+
const playbookReady = loadPlaybook();
|
|
560
|
+
|
|
561
|
+
let buffer = '';
|
|
562
|
+
process.stdin.setEncoding('utf8');
|
|
563
|
+
process.stdin.on('data', chunk => {
|
|
564
|
+
buffer += chunk;
|
|
565
|
+
let idx;
|
|
566
|
+
while ((idx = buffer.indexOf('\n')) >= 0) {
|
|
567
|
+
const line = buffer.slice(0, idx).trim();
|
|
568
|
+
buffer = buffer.slice(idx + 1);
|
|
569
|
+
if (!line) continue;
|
|
570
|
+
let msg; try { msg = JSON.parse(line); } catch { continue; }
|
|
571
|
+
handle(msg).catch(e => console.error('mcp handler error', e));
|
|
572
|
+
}
|
|
573
|
+
});
|
package/login.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// ato-mcp login — "Connect my AI" (device authorization grant, RFC 8628 style).
|
|
2
|
+
//
|
|
3
|
+
// node ato-mcp/index.js login [--name claude-dev-1] [--role dev] [--model claude]
|
|
4
|
+
// [--base-url https://.../api/v1/ato] [--dir ./.ato]
|
|
5
|
+
//
|
|
6
|
+
// 1. Asks ATO for a device code and a short user code.
|
|
7
|
+
// 2. Prints the code and the portal URL; a logged-in human approves it there (and picks the final
|
|
8
|
+
// name and role, never above their own).
|
|
9
|
+
// 3. Polls until approved, then writes the key ONCE to <dir>/.env (ATO_API_KEY=...) and the identity
|
|
10
|
+
// to <dir>/identity.json. The key is never printed. Both files must stay git-ignored.
|
|
11
|
+
|
|
12
|
+
'use strict';
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
const { detectLang, makeT } = require('./i18n');
|
|
16
|
+
const platformLib = require('./platform');
|
|
17
|
+
|
|
18
|
+
function parseArgs(argv) {
|
|
19
|
+
const out = {};
|
|
20
|
+
for (let i = 0; i < argv.length; i++) {
|
|
21
|
+
const a = argv[i];
|
|
22
|
+
if (!a.startsWith('--')) continue;
|
|
23
|
+
const k = a.slice(2);
|
|
24
|
+
const v = argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[++i] : 'true';
|
|
25
|
+
out[k] = v;
|
|
26
|
+
}
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
31
|
+
|
|
32
|
+
async function post(baseUrl, p, body) {
|
|
33
|
+
const res = await fetch(`${baseUrl}${p}`, {
|
|
34
|
+
method: 'POST',
|
|
35
|
+
headers: { 'Content-Type': 'application/json' },
|
|
36
|
+
body: JSON.stringify(body || {}),
|
|
37
|
+
});
|
|
38
|
+
const text = await res.text();
|
|
39
|
+
let json; try { json = JSON.parse(text); } catch { json = { raw: text }; }
|
|
40
|
+
return { status: res.status, json };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function run(argv, env = process.env, log = console.log) {
|
|
44
|
+
const args = parseArgs(argv);
|
|
45
|
+
const t = makeT(detectLang({ flag: args.lang, env }));
|
|
46
|
+
const baseUrl = (args['base-url'] || env.ATO_API_BASE_URL || 'http://localhost:5000/api/v1/ato').replace(/\/+$/, '');
|
|
47
|
+
const dir = path.resolve(platformLib.expandHome(args.dir || env.ATO_IDENTITY_DIR || path.join(process.cwd(), '.ato')));
|
|
48
|
+
|
|
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');
|
|
60
|
+
if (start.status !== 200 || !start.json || !start.json.data) {
|
|
61
|
+
throw new Error(t('error.start_failed', { status: start.status, detail: detail(start) }));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const d = start.json.data;
|
|
65
|
+
log('');
|
|
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) })}`);
|
|
70
|
+
log('');
|
|
71
|
+
|
|
72
|
+
let interval = Math.max(1, d.interval || 5) * 1000;
|
|
73
|
+
const deadline = Date.now() + d.expiresIn * 1000;
|
|
74
|
+
|
|
75
|
+
while (Date.now() < deadline) {
|
|
76
|
+
await sleep(interval);
|
|
77
|
+
const r = await post(baseUrl, '/connect/token', { deviceCode: d.deviceCode });
|
|
78
|
+
const code = r.json && r.json.code;
|
|
79
|
+
|
|
80
|
+
if (r.status === 200 && r.json.data && r.json.data.apiKey) {
|
|
81
|
+
const tk = r.json.data;
|
|
82
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
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 })}`);
|
|
87
|
+
fs.writeFileSync(path.join(dir, 'identity.json'), JSON.stringify({
|
|
88
|
+
assigneeId: tk.handle,
|
|
89
|
+
memberId: tk.memberId,
|
|
90
|
+
role: tk.role,
|
|
91
|
+
tenantId: tk.tenantId,
|
|
92
|
+
aiModel: args.model || null,
|
|
93
|
+
apiKeyEnvVar: 'ATO_API_KEY',
|
|
94
|
+
}, null, 2) + '\n', 'utf8');
|
|
95
|
+
|
|
96
|
+
log(` ${t('login.connected', { handle: tk.handle, role: tk.role, file: envFile })}`);
|
|
97
|
+
try {
|
|
98
|
+
const who = await fetch(`${baseUrl}/ai/whoami`, { headers: { 'X-API-Key': tk.apiKey } });
|
|
99
|
+
const wj = who.ok ? await who.json() : null;
|
|
100
|
+
const wd = wj && wj.data;
|
|
101
|
+
if (wd && Array.isArray(wd.permissions)) log(` ${t('login.role', { role: wd.roleName || wd.role, count: wd.permissions.length })}`);
|
|
102
|
+
} catch { /* the MCP reads whoami again on start */ }
|
|
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;
|
|
108
|
+
}
|
|
109
|
+
if (code === 'CONNECT_AUTHORIZATION_PENDING') continue;
|
|
110
|
+
if (code === 'CONNECT_SLOW_DOWN') { interval += 5000; continue; }
|
|
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'));
|
|
113
|
+
if (r.status === 429) { interval += 5000; continue; }
|
|
114
|
+
throw new Error(t('error.unexpected', { status: r.status, detail: detail(r) }));
|
|
115
|
+
}
|
|
116
|
+
throw new Error(t('error.expired_unapproved'));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
module.exports = { run, parseArgs };
|
package/package.json
CHANGED
|
@@ -1,6 +1,30 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nestxapp/ato-mcp",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.6.0",
|
|
4
|
+
"description": "MCP server for ATO (AI Task Orchestrator): lets an AI client take, work and report tasks.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"main": "index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"ato-mcp": "index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"index.js",
|
|
12
|
+
"login.js",
|
|
13
|
+
"i18n.js",
|
|
14
|
+
"platform.js",
|
|
15
|
+
"LICENSE",
|
|
16
|
+
"README.md"
|
|
17
|
+
],
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=18"
|
|
20
|
+
},
|
|
21
|
+
"publishConfig": {
|
|
22
|
+
"access": "public"
|
|
23
|
+
},
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "git+https://github.com/NestX-Software/AtoApi.git",
|
|
27
|
+
"directory": "ato-mcp"
|
|
28
|
+
},
|
|
29
|
+
"keywords": ["mcp", "ato", "ai", "tasks"]
|
|
30
|
+
}
|
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 };
|