@nestxapp/ato-mcp 0.0.0-stage → 1.5.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 +67 -2
- package/index.js +508 -0
- package/login.js +109 -0
- package/package.json +26 -4
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,68 @@
|
|
|
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
|
+
Keep `.ato/` and `.mcp.json` out of git: they hold your API key.
|
|
16
|
+
|
|
17
|
+
## Which tools you get
|
|
18
|
+
|
|
19
|
+
At start the server asks ATO who you are (`whoami`) and lists only the tools your role's permissions allow
|
|
20
|
+
(for example `task.claim` for `ato_claim`, `task.review` for the review tools, `proposal.decide` for
|
|
21
|
+
approving proposals). Roles are configured per company, so two AIs with different roles see different tools.
|
|
22
|
+
Tools open to every identity (`ato_whoami`, `ato_resume`, `ato_task_context`, comments, attachment reads) are
|
|
23
|
+
always listed. Servers that do not return permissions yet fall back to the legacy role families. If `whoami`
|
|
24
|
+
cannot be read (no key, bad key, network), only the open tools are listed until it works; call `ato_whoami`
|
|
25
|
+
to retry and see the reason.
|
|
26
|
+
|
|
27
|
+
## Configuration
|
|
28
|
+
|
|
29
|
+
| Variable | Meaning |
|
|
30
|
+
|---|---|
|
|
31
|
+
| `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
|
+
| `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`. |
|
|
34
|
+
|
|
35
|
+
## Claude Code (`.mcp.json` in the repo root)
|
|
36
|
+
|
|
37
|
+
```json
|
|
38
|
+
{
|
|
39
|
+
"mcpServers": {
|
|
40
|
+
"ato": {
|
|
41
|
+
"command": "npx",
|
|
42
|
+
"args": ["-y", "@nestxapp/ato-mcp"],
|
|
43
|
+
"env": {
|
|
44
|
+
"ATO_API_KEY": "<your-api-key>",
|
|
45
|
+
"ATO_API_BASE_URL": "https://<your-ato-gateway>/api/v1/ato",
|
|
46
|
+
"ATO_IDENTITY_DIR": "<absolute-path-to-repo>/.ato"
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Claude Desktop (`claude_desktop_config.json`) and Cursor (`.cursor/mcp.json`)
|
|
54
|
+
|
|
55
|
+
Same `mcpServers` block as above. On Windows, if `npx` is not found by the client, use
|
|
56
|
+
`"command": "cmd"` with `"args": ["/c", "npx", "-y", "@nestxapp/ato-mcp"]`.
|
|
57
|
+
|
|
58
|
+
## Verify
|
|
59
|
+
|
|
60
|
+
Restart the client; the `ato` server should list its tools (`ato_*`). The server speaks MCP over
|
|
61
|
+
stdio, so running it by hand just waits for input.
|
|
62
|
+
|
|
63
|
+
Releasing: push a tag `mcp-vX.Y.Z` matching the `version` in `package.json` (see
|
|
64
|
+
`.github/workflows/publish-ato-mcp.yml`).
|
|
65
|
+
|
|
66
|
+
## License
|
|
67
|
+
|
|
68
|
+
MIT, see `LICENSE`. This license covers only this MCP client package.
|
package/index.js
ADDED
|
@@ -0,0 +1,508 @@
|
|
|
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
|
+
|
|
18
|
+
const BASE_URL = process.env.ATO_API_BASE_URL || 'http://localhost:5000/api/v1/ato';
|
|
19
|
+
|
|
20
|
+
function loadIdentity() {
|
|
21
|
+
const dir = process.env.ATO_IDENTITY_DIR || path.join(process.cwd(), '.ato');
|
|
22
|
+
const file = path.join(dir, 'identity.json');
|
|
23
|
+
if (fs.existsSync(file)) {
|
|
24
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
25
|
+
}
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// The key written by `login` lives in <identity dir>/.env (git-ignored); env vars still win.
|
|
30
|
+
function loadDotEnvKey() {
|
|
31
|
+
const dir = process.env.ATO_IDENTITY_DIR || path.join(process.cwd(), '.ato');
|
|
32
|
+
const file = path.join(dir, '.env');
|
|
33
|
+
if (!fs.existsSync(file)) return null;
|
|
34
|
+
const line = fs.readFileSync(file, 'utf8').split(/\r?\n/).find(l => l.startsWith('ATO_API_KEY='));
|
|
35
|
+
return line ? line.slice('ATO_API_KEY='.length).trim() : null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const identity = loadIdentity();
|
|
39
|
+
const apiKey = process.env.ATO_API_KEY || (identity && identity.apiKeyEnvVar ? process.env[identity.apiKeyEnvVar] : null) || loadDotEnvKey() || '';
|
|
40
|
+
const role = (identity && identity.role) || process.env.ATO_ROLE || '';
|
|
41
|
+
|
|
42
|
+
async function api(method, p, { body } = {}) {
|
|
43
|
+
const res = await fetch(`${BASE_URL}${p}`, {
|
|
44
|
+
method,
|
|
45
|
+
headers: { 'Content-Type': 'application/json', 'X-API-Key': apiKey },
|
|
46
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
47
|
+
});
|
|
48
|
+
const text = await res.text();
|
|
49
|
+
let json; try { json = JSON.parse(text); } catch { json = { raw: text }; }
|
|
50
|
+
return { status: res.status, json };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Evidence files (v3.2): multipart upload and binary download, so these two bypass the JSON-only api() helper.
|
|
54
|
+
const ATTACH_TYPES = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'pdf', 'txt', 'csv', 'json', 'zip'];
|
|
55
|
+
const ATTACH_MAX_BYTES = 10 * 1024 * 1024;
|
|
56
|
+
async function uploadFile(p, filePath, fields) {
|
|
57
|
+
const abs = path.resolve(String(filePath));
|
|
58
|
+
if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) throw new Error(`filePath: '${abs}' is not a file.`);
|
|
59
|
+
const ext = path.extname(abs).slice(1).toLowerCase();
|
|
60
|
+
if (!ATTACH_TYPES.includes(ext)) throw new Error(`filePath: '.${ext}' is not allowed. Allowed: ${ATTACH_TYPES.join(', ')}.`);
|
|
61
|
+
const size = fs.statSync(abs).size;
|
|
62
|
+
if (size === 0) throw new Error('filePath: the file is empty.');
|
|
63
|
+
if (size > ATTACH_MAX_BYTES) throw new Error(`filePath: ${size} bytes exceeds the 10 MB limit.`);
|
|
64
|
+
const form = new FormData();
|
|
65
|
+
form.append('file', new Blob([fs.readFileSync(abs)]), path.basename(abs));
|
|
66
|
+
for (const [k, v] of Object.entries(fields)) if (v) form.append(k, v);
|
|
67
|
+
const res = await fetch(`${BASE_URL}${p}`, { method: 'POST', headers: { 'X-API-Key': apiKey }, body: form });
|
|
68
|
+
const text = await res.text();
|
|
69
|
+
let json; try { json = JSON.parse(text); } catch { json = { raw: text }; }
|
|
70
|
+
return { status: res.status, json };
|
|
71
|
+
}
|
|
72
|
+
async function downloadFile(fileId, dir) {
|
|
73
|
+
const res = await fetch(`${BASE_URL}/attachments/${encodeURIComponent(String(fileId).trim())}/content`, { headers: { 'X-API-Key': apiKey } });
|
|
74
|
+
if (!res.ok) { const t = await res.text(); let json; try { json = JSON.parse(t); } catch { json = { raw: t }; } return { status: res.status, json }; }
|
|
75
|
+
const disposition = res.headers.get('content-disposition') || '';
|
|
76
|
+
const star = /filename\*=UTF-8''([^;]+)/i.exec(disposition);
|
|
77
|
+
const name = path.basename(star ? decodeURIComponent(star[1]) : `${fileId}`);
|
|
78
|
+
const target = path.join(dir || path.join(os.tmpdir(), 'ato-attachments'), String(fileId), name);
|
|
79
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
80
|
+
fs.writeFileSync(target, Buffer.from(await res.arrayBuffer()));
|
|
81
|
+
return { status: 200, json: { savedTo: target, contentType: res.headers.get('content-type'), bytes: fs.statSync(target).size } };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// 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)
|
|
85
|
+
// or the legacy project-wide key ("AF-80", still resolves). The API resolves all three on every {id} route, so the value is
|
|
86
|
+
// only URL-encoded here. A typed key stops resolving if the task's kind changes (it gets a new number); the GUID never changes.
|
|
87
|
+
const TASK_REF = { type: 'string', description: 'Task key, typed (e.g. AF-HU-12) or legacy (e.g. AF-80), or GUID.' };
|
|
88
|
+
// Request bodies are deserialized as Guid: only {id} route segments resolve keys server-side, so the bodies below take GUIDs.
|
|
89
|
+
// Tools that accept a TASK_REF in a body field resolve it here first (GET /tasks/{ref}).
|
|
90
|
+
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).' };
|
|
91
|
+
const GUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
92
|
+
async function resolveId(ref, field) {
|
|
93
|
+
const v = String(ref).trim();
|
|
94
|
+
if (GUID_RE.test(v)) return v;
|
|
95
|
+
const { status, json } = await api('GET', `/tasks/${encodeURIComponent(v)}`);
|
|
96
|
+
const id = json && json.data && json.data.id;
|
|
97
|
+
if (status !== 200 || !id) throw new Error(`${field}: cannot resolve task reference '${v}' (HTTP ${status}${json && json.code ? ' ' + json.code : ''}).`);
|
|
98
|
+
return id;
|
|
99
|
+
}
|
|
100
|
+
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).';
|
|
101
|
+
// Planning fields shared by create, update and estimate. Estimate is in hours (decimal, e.g. 2.5); dates are YYYY-MM-DD.
|
|
102
|
+
const PLANNING = {
|
|
103
|
+
estimate: { type: 'number', description: 'Estimated effort in hours (e.g. 4 or 2.5).' },
|
|
104
|
+
startDate: { type: 'string', description: 'Planned start, YYYY-MM-DD.' },
|
|
105
|
+
dueDate: { type: 'string', description: 'Planned end, YYYY-MM-DD (on or after startDate).' },
|
|
106
|
+
};
|
|
107
|
+
const planningBody = (a) => {
|
|
108
|
+
const b = {};
|
|
109
|
+
if (a.estimate !== undefined && a.estimate !== null) b.estimate = a.estimate;
|
|
110
|
+
if (a.startDate) b.startDate = a.startDate;
|
|
111
|
+
if (a.dueDate) b.dueDate = a.dueDate;
|
|
112
|
+
return b;
|
|
113
|
+
};
|
|
114
|
+
const KINDS = ['epic', 'story', 'task', 'subtask', 'bug', 'test'];
|
|
115
|
+
const tid = (a) => encodeURIComponent(String(a.taskId).trim());
|
|
116
|
+
|
|
117
|
+
// One-line digest of a task DTO, prepended to tool output so key/kind/labels/reviewRounds are
|
|
118
|
+
// visible without reading the JSON. v2 fields are optional: a v1 server simply omits them.
|
|
119
|
+
function taskLine(t) {
|
|
120
|
+
if (!t || typeof t !== 'object' || !t.title || !t.status) return null;
|
|
121
|
+
const parts = [t.key || t.id, `[${t.status}]`];
|
|
122
|
+
if (t.legacyKey && t.legacyKey !== t.key) parts.push(`(also ${t.legacyKey})`);
|
|
123
|
+
if (t.kind) parts.push(`kind=${t.kind}`);
|
|
124
|
+
if (Array.isArray(t.labels) && t.labels.length) parts.push(`labels=${t.labels.join(',')}`);
|
|
125
|
+
if (typeof t.reviewRounds === 'number') parts.push(`reviewRounds=${t.reviewRounds}`);
|
|
126
|
+
parts.push(`- ${t.title}`);
|
|
127
|
+
return parts.join(' ');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function summarize(body) {
|
|
131
|
+
const data = body && typeof body === 'object' && 'data' in body ? body.data : body;
|
|
132
|
+
const items = Array.isArray(data) ? data : [data];
|
|
133
|
+
const lines = items.map(taskLine).filter(Boolean);
|
|
134
|
+
return lines.length ? lines.join('\n') : null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ── Tool registry: name -> { desc, schema, perms, roles, fn } ──────────────────
|
|
138
|
+
// perms: capabilities (any one is enough; null = open to every identity). roles: legacy role families, only used
|
|
139
|
+
// as a fallback when the server does not return `permissions` from whoami (older ATO servers).
|
|
140
|
+
const TOOLS = [
|
|
141
|
+
{
|
|
142
|
+
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.',
|
|
144
|
+
schema: { type: 'object', properties: {} },
|
|
145
|
+
fn: async () => api('GET', '/ai/whoami'),
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
name: 'ato_resume', roles: ['dev', 'code_review', 'qa', 'pmo'], perms: null,
|
|
149
|
+
desc: 'Resumes this assignee open work: in_progress/changes_requested/qa_rejected tasks + last 10 events.',
|
|
150
|
+
schema: { type: 'object', properties: {} },
|
|
151
|
+
fn: async () => api('GET', '/ai/resume'),
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
name: 'ato_backlog', roles: ['dev', 'pmo'], perms: ['task.view'],
|
|
155
|
+
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.',
|
|
156
|
+
schema: { type: 'object', properties: {
|
|
157
|
+
projectId: { type: 'string' },
|
|
158
|
+
priority: { type: 'string', enum: ['critical', 'high', 'medium', 'low'] },
|
|
159
|
+
type: { type: 'string', enum: ['backend', 'frontend', 'infra', 'docs', 'test', 'bug'] },
|
|
160
|
+
} },
|
|
161
|
+
fn: async (a) => {
|
|
162
|
+
const q = new URLSearchParams({ status: 'backlog' });
|
|
163
|
+
if (a.projectId) q.set('projectId', a.projectId);
|
|
164
|
+
if (a.priority) q.set('priority', a.priority);
|
|
165
|
+
if (a.type) q.set('type', a.type);
|
|
166
|
+
return api('GET', `/ai/tasks?${q.toString()}`);
|
|
167
|
+
},
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
name: 'ato_tasks', roles: ['dev', 'pmo'], perms: ['task.view'],
|
|
171
|
+
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
|
+
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.' },
|
|
174
|
+
projectId: { type: 'string' },
|
|
175
|
+
priority: { type: 'string', enum: ['critical', 'high', 'medium', 'low'] },
|
|
176
|
+
type: { type: 'string', enum: ['backend', 'frontend', 'infra', 'docs', 'test', 'bug'] },
|
|
177
|
+
} },
|
|
178
|
+
fn: async (a) => {
|
|
179
|
+
const q = new URLSearchParams();
|
|
180
|
+
if (a.status) q.set('status', a.status);
|
|
181
|
+
if (a.projectId) q.set('projectId', a.projectId);
|
|
182
|
+
if (a.priority) q.set('priority', a.priority);
|
|
183
|
+
if (a.type) q.set('type', a.type);
|
|
184
|
+
return api('GET', `/ai/tasks?${q.toString()}`);
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
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.',
|
|
190
|
+
schema: { type: 'object', properties: { taskId: TASK_REF }, required: ['taskId'] },
|
|
191
|
+
fn: async (a) => api('GET', `/ai/tasks/${tid(a)}`),
|
|
192
|
+
},
|
|
193
|
+
{
|
|
194
|
+
name: 'ato_comment', roles: ['dev', 'code_review', 'qa', 'pmo'], perms: null,
|
|
195
|
+
desc: 'Post a comment/suggestion/question/objection/reply on a task (never changes status).',
|
|
196
|
+
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'] },
|
|
197
|
+
fn: async (a) => api('POST', `/tasks/${tid(a)}/comments`, { body: { body: a.body, kind: a.kind, parentCommentId: a.parentCommentId || null } }),
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
name: 'ato_debate', roles: ['dev', 'code_review', 'qa', 'pmo'], perms: null,
|
|
201
|
+
desc: 'List the comment/debate thread for a task.',
|
|
202
|
+
schema: { type: 'object', properties: { taskId: TASK_REF }, required: ['taskId'] },
|
|
203
|
+
fn: async (a) => api('GET', `/tasks/${tid(a)}/comments`),
|
|
204
|
+
},
|
|
205
|
+
{
|
|
206
|
+
name: 'ato_claim', roles: ['dev'], perms: ['task.claim'],
|
|
207
|
+
desc: 'Atomically claim a backlog task. Second concurrent claim gets a clean 409.',
|
|
208
|
+
schema: { type: 'object', properties: { taskId: TASK_REF, rowVersion: { type: 'string' }, chatLabel: { type: 'string' } }, required: ['taskId', 'rowVersion'] },
|
|
209
|
+
fn: async (a) => api('PATCH', `/ai/tasks/${tid(a)}/claim`, { body: { rowVersion: a.rowVersion, chatLabel: a.chatLabel || null } }),
|
|
210
|
+
},
|
|
211
|
+
{
|
|
212
|
+
name: 'ato_progress', roles: ['dev'], perms: ['task.progress'],
|
|
213
|
+
desc: 'Report progress (bumps heartbeat; commitHash required before review).',
|
|
214
|
+
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
|
+
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
|
+
},
|
|
217
|
+
{
|
|
218
|
+
name: 'ato_handoff', roles: ['dev'], perms: ['task.progress'],
|
|
219
|
+
desc: 'Hand off context (does NOT release the claim).',
|
|
220
|
+
schema: { type: 'object', properties: { taskId: TASK_REF, pendingSteps: { type: 'array', items: { type: 'string' } }, reason: { type: 'string' }, contextSnapshot: { type: 'object' } }, required: ['taskId'] },
|
|
221
|
+
fn: async (a) => api('POST', `/ai/tasks/${tid(a)}/handoff`, { body: { pendingSteps: a.pendingSteps || null, reason: a.reason || 'session_end', contextSnapshot: a.contextSnapshot || null } }),
|
|
222
|
+
},
|
|
223
|
+
{
|
|
224
|
+
name: 'ato_block', roles: ['dev'], perms: ['task.progress'],
|
|
225
|
+
desc: 'Block a task (in_progress → blocked).',
|
|
226
|
+
schema: { type: 'object', properties: { taskId: TASK_REF, reason: { type: 'string' }, blockedByTaskIds: { type: 'array', items: BODY_REF } }, required: ['taskId'] },
|
|
227
|
+
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 } }),
|
|
228
|
+
},
|
|
229
|
+
{
|
|
230
|
+
name: 'ato_unblock', roles: ['dev'], perms: ['task.progress'],
|
|
231
|
+
desc: 'Unblock a task (blocked → in_progress).',
|
|
232
|
+
schema: { type: 'object', properties: { taskId: TASK_REF }, required: ['taskId'] },
|
|
233
|
+
fn: async (a) => api('PATCH', `/ai/tasks/${tid(a)}/unblock`, { body: {} }),
|
|
234
|
+
},
|
|
235
|
+
{
|
|
236
|
+
name: 'ato_submit_for_review', roles: ['dev'], perms: ['task.submit'],
|
|
237
|
+
desc: 'Submit for review (requires VerificationEvidence + a progress event with commitHash).',
|
|
238
|
+
schema: { type: 'object', properties: { taskId: TASK_REF, verificationEvidence: { type: 'string' } }, required: ['taskId', 'verificationEvidence'] },
|
|
239
|
+
fn: async (a) => api('PATCH', `/ai/tasks/${tid(a)}/submit-for-review`, { body: { verificationEvidence: a.verificationEvidence } }),
|
|
240
|
+
},
|
|
241
|
+
{
|
|
242
|
+
name: 'ato_propose_subtask', roles: ['dev'], perms: ['task.progress'],
|
|
243
|
+
desc: 'Propose a subtask (proposed status; PMO approves). Never creates a task directly. taskId is the parent.' + BUG_NOTE,
|
|
244
|
+
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'] },
|
|
245
|
+
fn: async (a) => {
|
|
246
|
+
if (!a.type && !a.kind) throw new Error('Provide kind (v2) or type (legacy).');
|
|
247
|
+
const body = { title: a.title, priority: a.priority, description: a.description || null, reason: a.reason || null };
|
|
248
|
+
if (a.type) body.type = a.type;
|
|
249
|
+
if (a.kind) body.kind = a.kind;
|
|
250
|
+
if (a.labels) body.labels = a.labels;
|
|
251
|
+
return api('POST', `/ai/tasks/${tid(a)}/propose-subtask`, { body });
|
|
252
|
+
},
|
|
253
|
+
},
|
|
254
|
+
{
|
|
255
|
+
name: 'ato_review_queue', roles: ['code_review'], perms: ['task.review'],
|
|
256
|
+
desc: 'Tasks in in_review, oldest first.',
|
|
257
|
+
schema: { type: 'object', properties: { projectId: { type: 'string' } } },
|
|
258
|
+
fn: async (a) => api('GET', `/review/queue${a.projectId ? `?projectId=${a.projectId}` : ''}`),
|
|
259
|
+
},
|
|
260
|
+
{
|
|
261
|
+
name: 'ato_review_approve', roles: ['code_review'], perms: ['task.review'],
|
|
262
|
+
desc: 'Approve code review (in_review → qa_testing).',
|
|
263
|
+
schema: { type: 'object', properties: { taskId: TASK_REF, notes: { type: 'string' } }, required: ['taskId'] },
|
|
264
|
+
fn: async (a) => api('PATCH', `/review/tasks/${tid(a)}/approve`, { body: { notes: a.notes || null } }),
|
|
265
|
+
},
|
|
266
|
+
{
|
|
267
|
+
name: 'ato_review_request_changes', roles: ['code_review'], perms: ['task.review'],
|
|
268
|
+
desc: 'Request changes (in_review → changes_requested; notes must name file/line/problem).',
|
|
269
|
+
schema: { type: 'object', properties: { taskId: TASK_REF, notes: { type: 'string' } }, required: ['taskId', 'notes'] },
|
|
270
|
+
fn: async (a) => api('PATCH', `/review/tasks/${tid(a)}/request-changes`, { body: { notes: a.notes } }),
|
|
271
|
+
},
|
|
272
|
+
{
|
|
273
|
+
name: 'ato_qa_queue', roles: ['qa'], perms: ['task.qa'],
|
|
274
|
+
desc: 'Tasks in qa_testing, oldest first.',
|
|
275
|
+
schema: { type: 'object', properties: { projectId: { type: 'string' } } },
|
|
276
|
+
fn: async (a) => api('GET', `/qa/queue${a.projectId ? `?projectId=${a.projectId}` : ''}`),
|
|
277
|
+
},
|
|
278
|
+
{
|
|
279
|
+
name: 'ato_qa_approve', roles: ['qa'], perms: ['task.qa'],
|
|
280
|
+
desc: 'Approve QA (qa_testing → done). QaEvidence must be independently derived.',
|
|
281
|
+
schema: { type: 'object', properties: { taskId: TASK_REF, qaEvidence: { type: 'string' } }, required: ['taskId', 'qaEvidence'] },
|
|
282
|
+
fn: async (a) => api('PATCH', `/qa/tasks/${tid(a)}/approve`, { body: { qaEvidence: a.qaEvidence } }),
|
|
283
|
+
},
|
|
284
|
+
{
|
|
285
|
+
name: 'ato_qa_reject', roles: ['qa'], perms: ['task.qa'],
|
|
286
|
+
desc: 'Reject QA (qa_testing → qa_rejected; qaNotes must describe the observed failure).',
|
|
287
|
+
schema: { type: 'object', properties: { taskId: TASK_REF, qaNotes: { type: 'string' } }, required: ['taskId', 'qaNotes'] },
|
|
288
|
+
fn: async (a) => api('PATCH', `/qa/tasks/${tid(a)}/reject`, { body: { qaNotes: a.qaNotes } }),
|
|
289
|
+
},
|
|
290
|
+
{
|
|
291
|
+
name: 'ato_qa_claim_test', roles: ['qa'], perms: ['task.qa'],
|
|
292
|
+
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.',
|
|
293
|
+
schema: { type: 'object', properties: { taskId: TASK_REF, rowVersion: { type: 'string' }, chatLabel: { type: 'string' } }, required: ['taskId', 'rowVersion'] },
|
|
294
|
+
fn: async (a) => api('PATCH', `/qa/tasks/${tid(a)}/claim-test`, { body: { rowVersion: a.rowVersion, chatLabel: a.chatLabel || null } }),
|
|
295
|
+
},
|
|
296
|
+
{
|
|
297
|
+
name: 'ato_qa_start_testing', roles: ['qa'], perms: ['task.qa'],
|
|
298
|
+
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.',
|
|
299
|
+
schema: { type: 'object', properties: { taskId: TASK_REF }, required: ['taskId'] },
|
|
300
|
+
fn: async (a) => api('PATCH', `/qa/tasks/${tid(a)}/start-testing`, { body: {} }),
|
|
301
|
+
},
|
|
302
|
+
{
|
|
303
|
+
name: 'ato_pmo_create_task', roles: ['pmo'], perms: ['task.create'],
|
|
304
|
+
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,
|
|
305
|
+
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'] },
|
|
306
|
+
fn: async (a) => {
|
|
307
|
+
if (!a.type && !a.kind) throw new Error('Provide kind (v2) or type (legacy).');
|
|
308
|
+
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 };
|
|
309
|
+
if (a.type) body.type = a.type;
|
|
310
|
+
if (a.kind) body.kind = a.kind;
|
|
311
|
+
if (a.labels) body.labels = a.labels;
|
|
312
|
+
if (a.parentTaskId) body.parentTaskId = await resolveId(a.parentTaskId, 'parentTaskId');
|
|
313
|
+
Object.assign(body, planningBody(a));
|
|
314
|
+
if (a.assigneeMemberId) body.assigneeMemberId = a.assigneeMemberId;
|
|
315
|
+
return api('POST', '/pmo/tasks', { body });
|
|
316
|
+
},
|
|
317
|
+
},
|
|
318
|
+
{
|
|
319
|
+
name: 'ato_pmo_approve_proposal', roles: ['pmo'], perms: ['proposal.decide'],
|
|
320
|
+
desc: 'Approve a dev proposal (proposed → backlog).',
|
|
321
|
+
schema: { type: 'object', properties: { taskId: TASK_REF }, required: ['taskId'] },
|
|
322
|
+
fn: async (a) => api('PATCH', `/pmo/tasks/${tid(a)}/approve-proposal`, { body: {} }),
|
|
323
|
+
},
|
|
324
|
+
{
|
|
325
|
+
name: 'ato_pmo_reject_proposal', roles: ['pmo'], perms: ['proposal.decide'],
|
|
326
|
+
desc: 'Reject a dev proposal (proposed → rejected).',
|
|
327
|
+
schema: { type: 'object', properties: { taskId: TASK_REF }, required: ['taskId'] },
|
|
328
|
+
fn: async (a) => api('PATCH', `/pmo/tasks/${tid(a)}/reject-proposal`, { body: {} }),
|
|
329
|
+
},
|
|
330
|
+
{
|
|
331
|
+
name: 'ato_update_task', roles: ['pmo'], perms: ['task.edit'],
|
|
332
|
+
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,
|
|
333
|
+
schema: { type: 'object', properties: {
|
|
334
|
+
taskId: TASK_REF,
|
|
335
|
+
title: { type: 'string' }, description: { type: 'string' }, acceptanceCriteria: { type: 'string' },
|
|
336
|
+
priority: { type: 'string', enum: ['critical', 'high', 'medium', 'low'] },
|
|
337
|
+
kind: { type: 'string', enum: KINDS }, labels: { type: 'array', items: { type: 'string' } },
|
|
338
|
+
parentTaskId: BODY_REF, clearParent: { type: 'boolean' },
|
|
339
|
+
sprintId: { type: 'string' }, clearSprint: { type: 'boolean' },
|
|
340
|
+
assigneeMemberId: { type: 'string', description: 'Member id (GUID).' }, clearAssignee: { type: 'boolean' },
|
|
341
|
+
projectId: { type: 'string', description: 'Move to another project (no children, not done).' },
|
|
342
|
+
...PLANNING,
|
|
343
|
+
}, required: ['taskId'] },
|
|
344
|
+
fn: async (a) => {
|
|
345
|
+
const body = {};
|
|
346
|
+
for (const k of ['title', 'description', 'acceptanceCriteria', 'priority', 'kind', 'labels', 'clearParent', 'sprintId', 'clearSprint', 'assigneeMemberId', 'clearAssignee', 'projectId'])
|
|
347
|
+
if (a[k] !== undefined && a[k] !== null) body[k] = a[k];
|
|
348
|
+
if (a.parentTaskId) body.parentTaskId = await resolveId(a.parentTaskId, 'parentTaskId');
|
|
349
|
+
Object.assign(body, planningBody(a));
|
|
350
|
+
return api('PUT', `/tasks/${tid(a)}`, { body });
|
|
351
|
+
},
|
|
352
|
+
},
|
|
353
|
+
{
|
|
354
|
+
name: 'ato_estimate', roles: ['dev', 'pmo'], perms: ['task.edit', 'task.claim'],
|
|
355
|
+
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.',
|
|
356
|
+
schema: { type: 'object', properties: { taskId: TASK_REF, ...PLANNING }, required: ['taskId'] },
|
|
357
|
+
fn: async (a) => {
|
|
358
|
+
const body = planningBody(a);
|
|
359
|
+
if (Object.keys(body).length === 0) throw new Error('Pass estimate, startDate and/or dueDate.');
|
|
360
|
+
return api('PATCH', `/tasks/${tid(a)}/estimate`, { body });
|
|
361
|
+
},
|
|
362
|
+
},
|
|
363
|
+
{
|
|
364
|
+
name: 'ato_pmo_backlog_health', roles: ['pmo'], perms: ['task.view'],
|
|
365
|
+
desc: 'Backlog health: ready backlog vs active dev capacity.',
|
|
366
|
+
schema: { type: 'object', properties: { projectId: { type: 'string' } }, required: ['projectId'] },
|
|
367
|
+
fn: async (a) => api('GET', `/pmo/projects/${a.projectId}/backlog-health`),
|
|
368
|
+
},
|
|
369
|
+
{
|
|
370
|
+
name: 'ato_pmo_link_dependency', roles: ['pmo'], perms: ['task.edit'],
|
|
371
|
+
desc: 'Declares that a backlog/proposed task depends on (is blocked by) another task (BlockedByTaskIds). Only applies to tasks in backlog/proposed.',
|
|
372
|
+
schema: { type: 'object', properties: { taskId: TASK_REF, blockedByTaskIds: { type: 'array', items: BODY_REF } }, required: ['taskId', 'blockedByTaskIds'] },
|
|
373
|
+
fn: async (a) => api('PATCH', `/pmo/tasks/${tid(a)}/blocked-by`, { body: { blockedByTaskIds: await Promise.all(a.blockedByTaskIds.map(r => resolveId(r, 'blockedByTaskIds'))) } }),
|
|
374
|
+
},
|
|
375
|
+
{
|
|
376
|
+
name: 'ato_attach_evidence', roles: ['dev', 'code_review', 'qa', 'pmo'], perms: ['attachment.upload'],
|
|
377
|
+
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).',
|
|
378
|
+
schema: { type: 'object', properties: {
|
|
379
|
+
taskId: TASK_REF,
|
|
380
|
+
filePath: { type: 'string', description: 'Path of the local file to upload.' },
|
|
381
|
+
purpose: { type: 'string', enum: ['evidence', 'spec', 'other'] },
|
|
382
|
+
reviewId: { type: 'string', description: 'Optional review (code review or QA round) of the same task this file backs.' },
|
|
383
|
+
}, required: ['taskId', 'filePath'] },
|
|
384
|
+
fn: async (a) => uploadFile(`/tasks/${tid(a)}/attachments`, a.filePath, { purpose: a.purpose || 'evidence', reviewId: a.reviewId }),
|
|
385
|
+
},
|
|
386
|
+
{
|
|
387
|
+
name: 'ato_attachments', roles: ['dev', 'code_review', 'qa', 'pmo'], perms: null,
|
|
388
|
+
desc: 'List the files attached to a task (name, type, size, purpose, who uploaded it, fileId). Use ato_fetch_attachment to read one.',
|
|
389
|
+
schema: { type: 'object', properties: { taskId: TASK_REF }, required: ['taskId'] },
|
|
390
|
+
fn: async (a) => api('GET', `/tasks/${tid(a)}/attachments`),
|
|
391
|
+
},
|
|
392
|
+
{
|
|
393
|
+
name: 'ato_fetch_attachment', roles: ['dev', 'code_review', 'qa', 'pmo'], perms: null,
|
|
394
|
+
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.',
|
|
395
|
+
schema: { type: 'object', properties: { fileId: { type: 'string' }, saveDir: { type: 'string', description: 'Optional folder to save into.' } }, required: ['fileId'] },
|
|
396
|
+
fn: async (a) => downloadFile(a.fileId, a.saveDir),
|
|
397
|
+
},
|
|
398
|
+
];
|
|
399
|
+
|
|
400
|
+
// ── Permission state ─────────────────────────────────────────────────────
|
|
401
|
+
// Tools are filtered by the capabilities whoami returns (roles are configurable per company, so role names
|
|
402
|
+
// mean nothing here). Servers that do not return `permissions` get the legacy role mapping (tech_lead = all).
|
|
403
|
+
// If whoami cannot be read the identity is unknown: fail closed, only the open tools (perms: null) are listed.
|
|
404
|
+
const auth = { ready: false, permissions: null, role, roleName: null, error: null };
|
|
405
|
+
|
|
406
|
+
function applyWhoami(json) {
|
|
407
|
+
const d = (json && json.data) || json || {};
|
|
408
|
+
if (d.role) auth.role = d.role;
|
|
409
|
+
auth.roleName = d.roleName || null;
|
|
410
|
+
auth.permissions = Array.isArray(d.permissions) ? d.permissions : null;
|
|
411
|
+
auth.error = null;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
async function loadAuth() {
|
|
415
|
+
if (!apiKey) { auth.error = 'No API key configured (ATO_API_KEY or <identity dir>/.env).'; auth.ready = true; return; }
|
|
416
|
+
try {
|
|
417
|
+
const { status, json } = await api('GET', '/ai/whoami');
|
|
418
|
+
if (status === 200) applyWhoami(json);
|
|
419
|
+
else auth.error = `whoami answered HTTP ${status}${json && json.code ? ' ' + json.code : ''}.`;
|
|
420
|
+
} catch (e) {
|
|
421
|
+
auth.error = `whoami failed: ${e.message}`;
|
|
422
|
+
}
|
|
423
|
+
auth.ready = true;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const allowed = (t) => {
|
|
427
|
+
if (!t.perms) return true;
|
|
428
|
+
if (auth.error) return false;
|
|
429
|
+
if (auth.permissions) return t.perms.some((p) => auth.permissions.includes(p));
|
|
430
|
+
return auth.role === 'tech_lead' || t.roles.includes(auth.role);
|
|
431
|
+
};
|
|
432
|
+
const visibleTools = () => TOOLS.filter(allowed).map(t => ({ name: t.name, description: t.desc, inputSchema: t.schema }));
|
|
433
|
+
|
|
434
|
+
function respond(id, result) {
|
|
435
|
+
process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id, result }) + '\n');
|
|
436
|
+
}
|
|
437
|
+
function respondError(id, code, message) {
|
|
438
|
+
process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id, error: { code, message } }) + '\n');
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// Calling ato_whoami re-reads the permissions; tell the client if the visible tool set changed.
|
|
442
|
+
async function refreshAfterWhoami(status, json) {
|
|
443
|
+
const before = visibleTools().map(t => t.name).join();
|
|
444
|
+
if (status === 200) applyWhoami(json); else auth.error = `whoami answered HTTP ${status}.`;
|
|
445
|
+
if (visibleTools().map(t => t.name).join() !== before) process.stdout.write(JSON.stringify({ jsonrpc: '2.0', method: 'notifications/tools/list_changed' }) + String.fromCharCode(10));
|
|
446
|
+
if (auth.error && json && typeof json === 'object') json.mcpStatus = `Identity not verified: ${auth.error} Only tools open to every identity are listed.`;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
async function handle(msg) {
|
|
450
|
+
if (msg.method === 'initialize') {
|
|
451
|
+
return respond(msg.id, {
|
|
452
|
+
protocolVersion: '2024-11-05',
|
|
453
|
+
capabilities: { tools: { listChanged: true } },
|
|
454
|
+
serverInfo: { name: 'ato-mcp', version: '1.5.0' },
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
if (msg.method === 'notifications/initialized') return;
|
|
458
|
+
if (msg.method === 'tools/list') {
|
|
459
|
+
if (!auth.ready || auth.error) await loadAuth();
|
|
460
|
+
return respond(msg.id, { tools: visibleTools() });
|
|
461
|
+
}
|
|
462
|
+
if (msg.method === 'tools/call') {
|
|
463
|
+
const { name, arguments: args } = msg.params || {};
|
|
464
|
+
const tool = TOOLS.find(t => t.name === name);
|
|
465
|
+
if (!tool) return respondError(msg.id, -32601, `Unknown tool: ${name}`);
|
|
466
|
+
if (!auth.ready || auth.error) await loadAuth();
|
|
467
|
+
if (!allowed(tool)) {
|
|
468
|
+
const why = auth.error ? `identity not verified (${auth.error})` : `your role${auth.roleName ? ` '${auth.roleName}'` : ''} lacks ${tool.perms.join(' or ')}`;
|
|
469
|
+
return respondError(msg.id, -32602, `Tool '${name}' is not available: ${why}`);
|
|
470
|
+
}
|
|
471
|
+
try {
|
|
472
|
+
const { status, json } = await tool.fn(args || {});
|
|
473
|
+
if (tool.name === 'ato_whoami') await refreshAfterWhoami(status, json);
|
|
474
|
+
const content = [{ type: 'text', text: JSON.stringify({ httpStatus: status, body: json }, null, 2) }];
|
|
475
|
+
const digest = summarize(json);
|
|
476
|
+
if (digest) content.unshift({ type: 'text', text: digest });
|
|
477
|
+
return respond(msg.id, { content, isError: false });
|
|
478
|
+
} catch (e) {
|
|
479
|
+
return respond(msg.id, { content: [{ type: 'text', text: 'ERROR: ' + e.message }], isError: true });
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
if (msg.method && msg.method.startsWith('notifications/')) return;
|
|
483
|
+
return respondError(msg.id, -32601, `Method not found: ${msg.method}`);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// `node index.js login ...` runs the "Connect my AI" device flow instead of the MCP server.
|
|
487
|
+
if (process.argv[2] === 'login') {
|
|
488
|
+
require('./login').run(process.argv.slice(3))
|
|
489
|
+
.then(() => process.exit(0))
|
|
490
|
+
.catch((e) => { console.error(' ' + e.message); process.exit(1); });
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
loadAuth();
|
|
495
|
+
|
|
496
|
+
let buffer = '';
|
|
497
|
+
process.stdin.setEncoding('utf8');
|
|
498
|
+
process.stdin.on('data', chunk => {
|
|
499
|
+
buffer += chunk;
|
|
500
|
+
let idx;
|
|
501
|
+
while ((idx = buffer.indexOf('\n')) >= 0) {
|
|
502
|
+
const line = buffer.slice(0, idx).trim();
|
|
503
|
+
buffer = buffer.slice(idx + 1);
|
|
504
|
+
if (!line) continue;
|
|
505
|
+
let msg; try { msg = JSON.parse(line); } catch { continue; }
|
|
506
|
+
handle(msg).catch(e => console.error('mcp handler error', e));
|
|
507
|
+
}
|
|
508
|
+
});
|
package/login.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
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
|
+
|
|
16
|
+
function parseArgs(argv) {
|
|
17
|
+
const out = {};
|
|
18
|
+
for (let i = 0; i < argv.length; i++) {
|
|
19
|
+
const a = argv[i];
|
|
20
|
+
if (!a.startsWith('--')) continue;
|
|
21
|
+
const k = a.slice(2);
|
|
22
|
+
const v = argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[++i] : 'true';
|
|
23
|
+
out[k] = v;
|
|
24
|
+
}
|
|
25
|
+
return out;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
29
|
+
|
|
30
|
+
async function post(baseUrl, p, body) {
|
|
31
|
+
const res = await fetch(`${baseUrl}${p}`, {
|
|
32
|
+
method: 'POST',
|
|
33
|
+
headers: { 'Content-Type': 'application/json' },
|
|
34
|
+
body: JSON.stringify(body || {}),
|
|
35
|
+
});
|
|
36
|
+
const text = await res.text();
|
|
37
|
+
let json; try { json = JSON.parse(text); } catch { json = { raw: text }; }
|
|
38
|
+
return { status: res.status, json };
|
|
39
|
+
}
|
|
40
|
+
|
|
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
|
+
async function run(argv, env = process.env, log = console.log) {
|
|
47
|
+
const args = parseArgs(argv);
|
|
48
|
+
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'));
|
|
50
|
+
|
|
51
|
+
const start = await post(baseUrl, '/connect/device', {
|
|
52
|
+
name: args.name || null,
|
|
53
|
+
role: args.role || null,
|
|
54
|
+
aiModel: args.model || null,
|
|
55
|
+
});
|
|
56
|
+
if (start.status !== 200 || !start.json || !start.json.data) {
|
|
57
|
+
throw new Error(`Could not start the connection (HTTP ${start.status}): ${start.json && start.json.message || 'no details'}`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const d = start.json.data;
|
|
61
|
+
log('');
|
|
62
|
+
log(' Connect this AI to ATO');
|
|
63
|
+
log(` 1. Open: ${d.verificationUriComplete}`);
|
|
64
|
+
log(` 2. Check the code matches: ${d.userCode}`);
|
|
65
|
+
log(` 3. Approve it (you choose the agent name and role). It expires in ${Math.round(d.expiresIn / 60)} minutes.`);
|
|
66
|
+
log('');
|
|
67
|
+
|
|
68
|
+
let interval = Math.max(1, d.interval || 5) * 1000;
|
|
69
|
+
const deadline = Date.now() + d.expiresIn * 1000;
|
|
70
|
+
|
|
71
|
+
while (Date.now() < deadline) {
|
|
72
|
+
await sleep(interval);
|
|
73
|
+
const r = await post(baseUrl, '/connect/token', { deviceCode: d.deviceCode });
|
|
74
|
+
const code = r.json && r.json.code;
|
|
75
|
+
|
|
76
|
+
if (r.status === 200 && r.json.data && r.json.data.apiKey) {
|
|
77
|
+
const t = r.json.data;
|
|
78
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
79
|
+
writeSecretFile(path.join(dir, '.env'), `ATO_API_KEY=${t.apiKey}\n`);
|
|
80
|
+
fs.writeFileSync(path.join(dir, 'identity.json'), JSON.stringify({
|
|
81
|
+
assigneeId: t.handle,
|
|
82
|
+
memberId: t.memberId,
|
|
83
|
+
role: t.role,
|
|
84
|
+
tenantId: t.tenantId,
|
|
85
|
+
aiModel: args.model || null,
|
|
86
|
+
apiKeyEnvVar: 'ATO_API_KEY',
|
|
87
|
+
}, null, 2) + '\n', 'utf8');
|
|
88
|
+
|
|
89
|
+
log(` Connected as ${t.handle} (${t.role}). Key stored in ${path.join(dir, '.env')} (not printed).`);
|
|
90
|
+
try {
|
|
91
|
+
const who = await fetch(`${baseUrl}/ai/whoami`, { headers: { 'X-API-Key': t.apiKey } });
|
|
92
|
+
const wj = who.ok ? await who.json() : null;
|
|
93
|
+
const wd = wj && wj.data;
|
|
94
|
+
if (wd && Array.isArray(wd.permissions)) log(` Role: ${wd.roleName || wd.role}. ${wd.permissions.length} permissions; the MCP tools follow them.`);
|
|
95
|
+
} catch { /* the MCP reads whoami again on start */ }
|
|
96
|
+
log(' Make sure .ato/ is in .gitignore, then restart your MCP client so it picks up the key.');
|
|
97
|
+
return t;
|
|
98
|
+
}
|
|
99
|
+
if (code === 'CONNECT_AUTHORIZATION_PENDING') continue;
|
|
100
|
+
if (code === 'CONNECT_SLOW_DOWN') { interval += 5000; continue; }
|
|
101
|
+
if (code === 'CONNECT_ACCESS_DENIED') throw new Error('The request was denied in the portal.');
|
|
102
|
+
if (code === 'CONNECT_EXPIRED_TOKEN') throw new Error('The code expired or was already used. Run login again.');
|
|
103
|
+
if (r.status === 429) { interval += 5000; continue; }
|
|
104
|
+
throw new Error(`Unexpected response while waiting (HTTP ${r.status}): ${r.json && r.json.message || 'no details'}`);
|
|
105
|
+
}
|
|
106
|
+
throw new Error('The code expired before it was approved. Run login again.');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
module.exports = { run, parseArgs };
|
package/package.json
CHANGED
|
@@ -1,6 +1,28 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nestxapp/ato-mcp",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.5.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
|
+
"LICENSE",
|
|
14
|
+
"README.md"
|
|
15
|
+
],
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=18"
|
|
18
|
+
},
|
|
19
|
+
"publishConfig": {
|
|
20
|
+
"access": "public"
|
|
21
|
+
},
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "git+https://github.com/NestX-Software/AtoApi.git",
|
|
25
|
+
"directory": "ato-mcp"
|
|
26
|
+
},
|
|
27
|
+
"keywords": ["mcp", "ato", "ai", "tasks"]
|
|
28
|
+
}
|