@motionode/cli 0.1.2 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +47 -0
- package/bin/motionode.js +55 -3
- package/lib/agent_api.js +79 -0
- package/lib/agent_setup.js +86 -0
- package/lib/bridge.js +15 -1
- package/lib/commands.js +80 -9
- package/lib/git.js +22 -1
- package/lib/git_credential.js +152 -0
- package/lib/inbox.js +75 -0
- package/lib/mcp.js +327 -0
- package/lib/project_cmds.js +116 -0
- package/lib/signals_hook.js +58 -0
- package/lib/signals_watch.js +168 -0
- package/package.json +1 -1
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Git access to Motionode-hosted repositories, through Motionode.
|
|
5
|
+
*
|
|
6
|
+
* A Motionode-hosted repository lives privately in Motionode's GitHub organization, so your own GitHub account does not
|
|
7
|
+
* reach it. `motionode clone` clones it from this Motionode's git proxy (<api>/git/<owner>/<repo>.git) and sets this
|
|
8
|
+
* CLI as the clone's git credential helper: each time git needs to authenticate, the helper asks Motionode for a
|
|
9
|
+
* short-lived credential for that one repository, as the connected computer. Plain git (and Cursor, VS Code, Claude
|
|
10
|
+
* Code) then just works in that folder. Nothing is written to your global git config.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const path = require('path');
|
|
14
|
+
const { spawn } = require('child_process');
|
|
15
|
+
const { client } = require('./api');
|
|
16
|
+
|
|
17
|
+
/** git credential protocol: key=value lines. */
|
|
18
|
+
function parseCredentialInput(raw) {
|
|
19
|
+
const out = {};
|
|
20
|
+
for (const line of String(raw || '').split(/\r?\n/)) {
|
|
21
|
+
const i = line.indexOf('=');
|
|
22
|
+
if (i > 0) out[line.slice(0, i)] = line.slice(i + 1);
|
|
23
|
+
}
|
|
24
|
+
return out;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function readStdin() {
|
|
28
|
+
return new Promise((resolve) => {
|
|
29
|
+
let data = '';
|
|
30
|
+
process.stdin.setEncoding('utf8');
|
|
31
|
+
process.stdin.on('data', (chunk) => { data += chunk; });
|
|
32
|
+
process.stdin.on('end', () => resolve(data));
|
|
33
|
+
process.stdin.on('error', () => resolve(data));
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Whether a git credential request is for this Motionode's git proxy. */
|
|
38
|
+
function isForThisMotionode(config, input) {
|
|
39
|
+
try {
|
|
40
|
+
const api = new URL(config.apiUrl);
|
|
41
|
+
return String(input.host || '').toLowerCase() === api.host.toLowerCase()
|
|
42
|
+
&& String(input.protocol || '').toLowerCase() === api.protocol.replace(/:$/, '');
|
|
43
|
+
} catch (_e) {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* `motionode git-credential <get|store|erase>` — git runs this as the clone's credential helper. Only `get` answers;
|
|
50
|
+
* a request for any other host gets no answer, so git falls back to whatever else is configured.
|
|
51
|
+
*/
|
|
52
|
+
async function helper(config, operation, { input = null, out = process.stdout } = {}) {
|
|
53
|
+
if (operation !== 'get') return null;
|
|
54
|
+
const fields = parseCredentialInput(input == null ? await readStdin() : input);
|
|
55
|
+
if (!isForThisMotionode(config, fields) || !config.token) return null;
|
|
56
|
+
const pathname = String(fields.path || '').replace(/^\/+/, '');
|
|
57
|
+
if (!pathname.startsWith('git/')) return null;
|
|
58
|
+
try {
|
|
59
|
+
const { credential } = await client(config).post('/git-credential', { path: pathname });
|
|
60
|
+
out.write(`username=${credential.username}\npassword=${credential.password}\n`);
|
|
61
|
+
} catch (err) {
|
|
62
|
+
// git shows helper stderr; say why, then give no credential (git then reports the refusal).
|
|
63
|
+
process.stderr.write(`motionode: ${err.message}\n`);
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The proxy address of a Motionode-hosted repository on this Motionode. */
|
|
69
|
+
function proxyUrl(config, { owner, repo }) {
|
|
70
|
+
return `${String(config.apiUrl).replace(/\/+$/, '')}/git/${owner}/${String(repo).replace(/\.git$/, '')}.git`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** owner/repo of a github.com URL or an "owner/repo" string. */
|
|
74
|
+
function ownerRepoOf(value) {
|
|
75
|
+
const m = String(value || '').trim().match(/(?:github\.com[/:])?([^/\s:]+)\/([^/\s]+?)(?:\.git)?\/?$/);
|
|
76
|
+
return m ? { owner: m[1], repo: m[2] } : null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* owner/repo behind a Motionode git-proxy URL on this Motionode
|
|
81
|
+
* (`<api>/git/<owner>/<repo>.git`), or null for any other remote. The clone
|
|
82
|
+
* `motionode clone` makes has this remote, and `motionode link` needs the
|
|
83
|
+
* repository it stands for (QA-20261023T234000Z).
|
|
84
|
+
*/
|
|
85
|
+
function proxiedRepoOf(config, remoteUrl) {
|
|
86
|
+
try {
|
|
87
|
+
const remote = new URL(String(remoteUrl || ''));
|
|
88
|
+
const api = new URL(String(config?.apiUrl || ''));
|
|
89
|
+
if (remote.host.toLowerCase() !== api.host.toLowerCase()) return null;
|
|
90
|
+
const m = remote.pathname.match(/^\/git\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/);
|
|
91
|
+
return m ? { owner: decodeURIComponent(m[1]), repo: decodeURIComponent(m[2]) } : null;
|
|
92
|
+
} catch (_e) {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The git config that makes this CLI the credential helper for one folder (never global). */
|
|
98
|
+
function helperConfig(config) {
|
|
99
|
+
const bin = path.resolve(__dirname, '..', 'bin', 'motionode.js');
|
|
100
|
+
const quote = (s) => `'${String(s).replace(/'/g, "'\\''")}'`;
|
|
101
|
+
return [
|
|
102
|
+
['credential.helper', ''],
|
|
103
|
+
['credential.helper', `!${quote(process.execPath)} ${quote(bin)} git-credential --api ${quote(config.apiUrl)}`],
|
|
104
|
+
['credential.useHttpPath', 'true'],
|
|
105
|
+
];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function git(args, { cwd = process.cwd() } = {}) {
|
|
109
|
+
return new Promise((resolve, reject) => {
|
|
110
|
+
const proc = spawn('git', args, { cwd, stdio: 'inherit' });
|
|
111
|
+
proc.on('error', reject);
|
|
112
|
+
proc.on('close', (code) => (code === 0 ? resolve() : reject(new Error(`git ${args[0]} failed (exit ${code}).`))));
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* `motionode clone <service name | owner/repo> [dir]` — clone a Motionode-hosted repository through Motionode and
|
|
118
|
+
* keep this CLI as that folder's credential helper.
|
|
119
|
+
*/
|
|
120
|
+
async function clone(config, target, dir = null) {
|
|
121
|
+
if (!config.token) throw new Error('Connect this computer first: run `motionode connect`.');
|
|
122
|
+
if (!target) throw new Error('Usage: motionode clone <service name | owner/repo> [folder]');
|
|
123
|
+
let ref = String(target).includes('/') ? ownerRepoOf(target) : null;
|
|
124
|
+
if (!ref) {
|
|
125
|
+
const me = await client(config).get('/me');
|
|
126
|
+
const wanted = String(target).trim().toLowerCase();
|
|
127
|
+
const services = (me.projects || []).flatMap((p) => (p.services || []).map((s) => ({ ...s, project: p.name })));
|
|
128
|
+
const hits = services.filter((s) => s.hosting === 'motionode' && [s.name, s.uuid].map((v) => String(v || '').toLowerCase()).includes(wanted));
|
|
129
|
+
if (hits.length !== 1) {
|
|
130
|
+
throw new Error(hits.length
|
|
131
|
+
? `More than one service is called "${target}": ${hits.map((s) => `${s.name} (${s.project})`).join(', ')}. Use owner/repo.`
|
|
132
|
+
: `No Motionode-hosted service called "${target}" on your projects.`);
|
|
133
|
+
}
|
|
134
|
+
ref = ownerRepoOf(hits[0].repoUrl);
|
|
135
|
+
if (!ref) throw new Error(`"${target}" has no repository yet.`);
|
|
136
|
+
}
|
|
137
|
+
const folder = dir || ref.repo;
|
|
138
|
+
const settings = helperConfig(config);
|
|
139
|
+
const cloneArgs = [...settings.flatMap(([k, v]) => ['-c', `${k}=${v}`]), 'clone'];
|
|
140
|
+
await git([...cloneArgs, proxyUrl(config, ref), folder]);
|
|
141
|
+
// Keep the helper in the clone's own config, so later fetches and pulls authenticate the same way.
|
|
142
|
+
const cwd = path.resolve(folder);
|
|
143
|
+
await git(['config', '--local', '--unset-all', 'credential.helper'], { cwd }).catch(() => {});
|
|
144
|
+
for (const [k, v] of settings) {
|
|
145
|
+
// eslint-disable-next-line no-await-in-loop
|
|
146
|
+
await git(['config', '--local', '--add', k, v], { cwd });
|
|
147
|
+
}
|
|
148
|
+
console.log(`✓ Cloned ${ref.owner}/${ref.repo} into ${folder} through Motionode. Run \`motionode link\` inside it to tie it to your tickets.`);
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
module.exports = { helper, clone, parseCredentialInput, isForThisMotionode, proxyUrl, ownerRepoOf, proxiedRepoOf, helperConfig };
|
package/lib/inbox.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The local signal inbox: <repo>/.motionode/inbox.json.
|
|
5
|
+
* `motionode signals watch` writes signals here the moment Motionode pushes them;
|
|
6
|
+
* the hook and the MCP server read it locally (instant, no network, no model).
|
|
7
|
+
* A signal leaves the inbox when it is acknowledged (MCP ack_signal).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
const { execFileSync } = require('child_process');
|
|
13
|
+
|
|
14
|
+
function repoRoot(dir = process.cwd()) {
|
|
15
|
+
try {
|
|
16
|
+
return execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd: dir, stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim() || dir;
|
|
17
|
+
} catch (_e) {
|
|
18
|
+
return dir;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function inboxPath(dir) {
|
|
23
|
+
return path.join(repoRoot(dir), '.motionode', 'inbox.json');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Keep .motionode/ out of git without touching the repo's tracked .gitignore. */
|
|
27
|
+
function ensureExcluded(dir) {
|
|
28
|
+
try {
|
|
29
|
+
const root = repoRoot(dir);
|
|
30
|
+
const exclude = path.join(root, '.git', 'info', 'exclude');
|
|
31
|
+
if (!fs.existsSync(path.dirname(exclude))) return;
|
|
32
|
+
const current = fs.existsSync(exclude) ? fs.readFileSync(exclude, 'utf8') : '';
|
|
33
|
+
if (!/^\.motionode\/?$/m.test(current)) fs.appendFileSync(exclude, `${current.endsWith('\n') || !current ? '' : '\n'}.motionode/\n`);
|
|
34
|
+
} catch (_e) { /* best effort */ }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function read(dir) {
|
|
38
|
+
try {
|
|
39
|
+
const data = JSON.parse(fs.readFileSync(inboxPath(dir), 'utf8'));
|
|
40
|
+
return Array.isArray(data?.signals) ? data.signals : [];
|
|
41
|
+
} catch (_e) {
|
|
42
|
+
return [];
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function write(dir, signals) {
|
|
47
|
+
const file = inboxPath(dir);
|
|
48
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
49
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
50
|
+
fs.writeFileSync(tmp, JSON.stringify({ updatedAt: new Date().toISOString(), signals }, null, 2));
|
|
51
|
+
fs.renameSync(tmp, file);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function ticketRefOf(signal) {
|
|
55
|
+
if (signal?.toRoom?.kind === 'ticket') return signal.toRoom.uuid;
|
|
56
|
+
if (signal?.fromRoom?.kind === 'ticket') return signal.fromRoom.uuid;
|
|
57
|
+
return signal?.ticketRef || null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function add(dir, signal) {
|
|
61
|
+
const list = read(dir);
|
|
62
|
+
if (list.some((s) => s.id === signal.id)) return false;
|
|
63
|
+
list.push({ ...signal, ticketRef: ticketRefOf(signal), receivedAt: new Date().toISOString() });
|
|
64
|
+
write(dir, list.slice(-200));
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function remove(dir, id) {
|
|
69
|
+
const list = read(dir);
|
|
70
|
+
const next = list.filter((s) => Number(s.id) !== Number(id));
|
|
71
|
+
if (next.length !== list.length) write(dir, next);
|
|
72
|
+
return next.length !== list.length;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
module.exports = { repoRoot, inboxPath, ensureExcluded, read, write, add, remove, ticketRefOf };
|
package/lib/mcp.js
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `motionode mcp`: a stdio MCP server so Claude Code, Cursor or any MCP client
|
|
5
|
+
* can work a Motionode ticket as a first-class worker (team-coordination spec §4.3).
|
|
6
|
+
* Every tool calls Motionode's /api/v1/agent as the connected person; Motionode
|
|
7
|
+
* applies the same permission checks as the web app.
|
|
8
|
+
*
|
|
9
|
+
* Transport: newline-delimited JSON-RPC 2.0 on stdin/stdout. Logs go to stderr only.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const readline = require('readline');
|
|
13
|
+
const pkg = require('../package.json');
|
|
14
|
+
const { agentClient } = require('./agent_api');
|
|
15
|
+
const Inbox = require('./inbox');
|
|
16
|
+
|
|
17
|
+
const FALLBACK_PROTOCOL = '2025-06-18';
|
|
18
|
+
const DISCOVER_VERSIONS = ['2026-07-28'];
|
|
19
|
+
|
|
20
|
+
const TOOLS = [
|
|
21
|
+
{
|
|
22
|
+
name: 'my_work',
|
|
23
|
+
description: 'Tickets assigned to me in Motionode, with their current step and how many signals are waiting.',
|
|
24
|
+
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
|
25
|
+
run: (api) => api.get('/my_work'),
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
name: 'get_ticket',
|
|
29
|
+
description: 'One ticket: description, acceptance criteria, current step, branches per service, and pending signals. ref = ticket id like T11-604 or its uuid.',
|
|
30
|
+
inputSchema: { type: 'object', properties: { ref: { type: 'string' } }, required: ['ref'], additionalProperties: false },
|
|
31
|
+
run: (api, a) => api.get(`/ticket/${encodeURIComponent(a.ref)}`),
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
name: 'get_signals',
|
|
35
|
+
description: 'Signals for a ticket that I have not resolved yet (requirement changes, questions answered, overlaps, conflicts, failed checks). Check these at every task boundary.',
|
|
36
|
+
inputSchema: { type: 'object', properties: { ref: { type: 'string' } }, required: ['ref'], additionalProperties: false },
|
|
37
|
+
run: (api, a) => api.get(`/ticket/${encodeURIComponent(a.ref)}/signals`),
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
name: 'ack_signal',
|
|
41
|
+
description: 'Acknowledge a signal. Set incorporated=true (with how = one sentence) only once your work actually reflects it.',
|
|
42
|
+
inputSchema: {
|
|
43
|
+
type: 'object',
|
|
44
|
+
properties: { ref: { type: 'string' }, id: { type: 'number' }, incorporated: { type: 'boolean' }, how: { type: 'string' } },
|
|
45
|
+
required: ['ref', 'id'],
|
|
46
|
+
additionalProperties: false,
|
|
47
|
+
},
|
|
48
|
+
run: async (api, a) => {
|
|
49
|
+
const out = await api.post(`/ticket/${encodeURIComponent(a.ref)}/signals/${Number(a.id)}/ack`, { incorporated: !!a.incorporated, how: a.how || null });
|
|
50
|
+
Inbox.remove(process.cwd(), a.id);
|
|
51
|
+
return out;
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
name: 'start_step',
|
|
56
|
+
description: 'Start my assigned step on the ticket and register this session as its worker.',
|
|
57
|
+
inputSchema: { type: 'object', properties: { ref: { type: 'string' } }, required: ['ref'], additionalProperties: false },
|
|
58
|
+
run: (api, a) => api.post(`/ticket/${encodeURIComponent(a.ref)}/start_step`, {}),
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
name: 'report_progress',
|
|
62
|
+
description: 'One short line about what you are doing now (shown to the team; not a log).',
|
|
63
|
+
inputSchema: { type: 'object', properties: { ref: { type: 'string' }, text: { type: 'string' }, percent: { type: 'number' } }, required: ['ref', 'text'], additionalProperties: false },
|
|
64
|
+
run: (api, a) => api.post(`/ticket/${encodeURIComponent(a.ref)}/progress`, { text: a.text, percent: a.percent ?? null }),
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
name: 'ask_question',
|
|
68
|
+
description: 'Ask a question you cannot answer from the ticket. Motionode routes it (rules, the project manager, or the client) and the answer arrives as a signal.',
|
|
69
|
+
inputSchema: { type: 'object', properties: { ref: { type: 'string' }, question: { type: 'string' } }, required: ['ref', 'question'], additionalProperties: false },
|
|
70
|
+
run: (api, a) => api.post(`/ticket/${encodeURIComponent(a.ref)}/question`, { question: a.question }),
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
name: 'report_discovery',
|
|
74
|
+
description: 'You found something that may affect other work (a changed contract, a new status, a shared function). Motionode tells the tickets that change the same files, and the topic the work came from.',
|
|
75
|
+
inputSchema: { type: 'object', properties: { ref: { type: 'string' }, text: { type: 'string' }, files: { type: 'array', items: { type: 'string' } } }, required: ['ref', 'text'], additionalProperties: false },
|
|
76
|
+
run: (api, a) => api.post(`/ticket/${encodeURIComponent(a.ref)}/discovery`, { text: a.text, files: a.files || [] }),
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
name: 'request_review',
|
|
80
|
+
description: 'You finished the step: propose completion with a short summary. A person accepts it in Motionode.',
|
|
81
|
+
inputSchema: { type: 'object', properties: { ref: { type: 'string' }, summary: { type: 'string' } }, required: ['ref', 'summary'], additionalProperties: false },
|
|
82
|
+
run: (api, a) => api.post(`/ticket/${encodeURIComponent(a.ref)}/review`, { summary: a.summary }),
|
|
83
|
+
},
|
|
84
|
+
// ---- C1 coordination records: claims, observations, decisions, blockers, evidence (team-coordination C1) ----
|
|
85
|
+
{
|
|
86
|
+
name: 'claim_step',
|
|
87
|
+
description: 'Claim the ticket\'s current step before working on it (and start it). Exclusive: if another worker holds it you get who and until when. The claim is a lease (30 min default) that every call you make on the ticket renews; release it when you stop. Needs MOTIONODE_AGENT (your agent name).',
|
|
88
|
+
inputSchema: { type: 'object', properties: { ref: { type: 'string' }, lease_min: { type: 'number' }, note: { type: 'string' } }, required: ['ref'], additionalProperties: false },
|
|
89
|
+
run: (api, a) => api.post(`/ticket/${encodeURIComponent(a.ref)}/claim`, { lease_min: a.lease_min ?? null, note: a.note || null }),
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
name: 'release_step',
|
|
93
|
+
description: 'Release your claim on the ticket\'s step: reason "done" (your part is finished) or "handoff" (someone else should take it; say why in note).',
|
|
94
|
+
inputSchema: { type: 'object', properties: { ref: { type: 'string' }, reason: { type: 'string', enum: ['done', 'handoff'] }, note: { type: 'string' } }, required: ['ref', 'reason'], additionalProperties: false },
|
|
95
|
+
run: (api, a) => api.post(`/ticket/${encodeURIComponent(a.ref)}/release`, { reason: a.reason, note: a.note || null }),
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
name: 'report_observation',
|
|
99
|
+
description: 'Record something you or a tester SAW (a symptom), with its evidence: steps, env, sha, screenshots, preview url. It stays on this ticket; the coordinator links it to the one root-cause ticket.',
|
|
100
|
+
inputSchema: { type: 'object', properties: { ref: { type: 'string' }, text: { type: 'string' }, title: { type: 'string' }, evidence: { type: 'object' } }, required: ['ref', 'text'], additionalProperties: false },
|
|
101
|
+
run: (api, a) => api.post(`/ticket/${encodeURIComponent(a.ref)}/observation`, { text: a.text, title: a.title || null, evidence: a.evidence || null }),
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
name: 'report_finding',
|
|
105
|
+
description: 'Record a cause. verified=true only when established by evidence that holds (not suspected); root_cause=true when it names the one accountable problem.',
|
|
106
|
+
inputSchema: { type: 'object', properties: { ref: { type: 'string' }, text: { type: 'string' }, title: { type: 'string' }, verified: { type: 'boolean' }, root_cause: { type: 'boolean' }, evidence: { type: 'object' } }, required: ['ref', 'text'], additionalProperties: false },
|
|
107
|
+
run: (api, a) => api.post(`/ticket/${encodeURIComponent(a.ref)}/finding`, { text: a.text, title: a.title || null, verified: a.verified === true, root_cause: a.root_cause === true, evidence: a.evidence || null }),
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
name: 'link_observation',
|
|
111
|
+
description: 'Coordinator/manager: this ticket is the root cause of that observation (its uuid, on any ticket of the project). note = why. undo=true reverses it.',
|
|
112
|
+
inputSchema: { type: 'object', properties: { ref: { type: 'string' }, observation: { type: 'string' }, note: { type: 'string' }, undo: { type: 'boolean' } }, required: ['ref', 'observation', 'note'], additionalProperties: false },
|
|
113
|
+
run: (api, a) => api.post(`/ticket/${encodeURIComponent(a.ref)}/link_observation`, { observation: a.observation, note: a.note, undo: a.undo === true }),
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
name: 'relate_work',
|
|
117
|
+
description: 'Coordinator/manager: kind "duplicate_of" (this ticket is the same problem as other ticket) or "affects" (this root cause also affects other: a ticket, request or topic, whose people are told). note = why. undo=true reverses it.',
|
|
118
|
+
inputSchema: {
|
|
119
|
+
type: 'object',
|
|
120
|
+
properties: { ref: { type: 'string' }, kind: { type: 'string', enum: ['duplicate_of', 'affects'] }, other_type: { type: 'string', enum: ['ticket', 'request', 'topic'] }, other_ref: { type: 'string' }, note: { type: 'string' }, undo: { type: 'boolean' } },
|
|
121
|
+
required: ['ref', 'kind', 'other_type', 'other_ref', 'note'],
|
|
122
|
+
additionalProperties: false,
|
|
123
|
+
},
|
|
124
|
+
run: (api, a) => api.post(`/ticket/${encodeURIComponent(a.ref)}/relate`, { kind: a.kind, other: { type: a.other_type, ref: a.other_ref }, note: a.note, undo: a.undo === true }),
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
name: 'ask_decision',
|
|
128
|
+
description: 'A decision only a person can take, with at least two options (label + consequence). One owner (a user uuid; default the project manager), due in due_hours (default 24). Past due it is re-sent. The choice arrives as a "decided" signal.',
|
|
129
|
+
inputSchema: {
|
|
130
|
+
type: 'object',
|
|
131
|
+
properties: {
|
|
132
|
+
ref: { type: 'string' },
|
|
133
|
+
question: { type: 'string' },
|
|
134
|
+
options: { type: 'array', items: { type: 'object', properties: { key: { type: 'string' }, label: { type: 'string' }, consequence: { type: 'string' } }, required: ['label'] } },
|
|
135
|
+
owner: { type: 'string' },
|
|
136
|
+
backup: { type: 'string' },
|
|
137
|
+
due_hours: { type: 'number' },
|
|
138
|
+
},
|
|
139
|
+
required: ['ref', 'question', 'options'],
|
|
140
|
+
additionalProperties: false,
|
|
141
|
+
},
|
|
142
|
+
run: (api, a) => api.post(`/ticket/${encodeURIComponent(a.ref)}/decision`, { question: a.question, options: a.options, owner: a.owner || null, backup: a.backup || null, due_hours: a.due_hours ?? null }),
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
name: 'choose_decision',
|
|
146
|
+
description: 'Record the option taken on a decision (its uuid) -- only its owner, an addressee or a project decider may.',
|
|
147
|
+
inputSchema: { type: 'object', properties: { decision: { type: 'string' }, option: { type: 'string' }, note: { type: 'string' } }, required: ['decision', 'option'], additionalProperties: false },
|
|
148
|
+
run: (api, a) => api.post(`/decision/${encodeURIComponent(a.decision)}/choose`, { option: a.option, note: a.note || null }),
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
name: 'report_blocker',
|
|
152
|
+
description: 'The current step cannot go on. It goes Waiting with your reason. Say what it waits on: blocking_ticket (another ticket, a real dependency) or decision {question, options} (a person\'s choice). category: CLIENT, VENDOR, INTERNAL, DEPENDENCY or OTHER.',
|
|
153
|
+
inputSchema: {
|
|
154
|
+
type: 'object',
|
|
155
|
+
properties: {
|
|
156
|
+
ref: { type: 'string' },
|
|
157
|
+
reason: { type: 'string' },
|
|
158
|
+
category: { type: 'string', enum: ['CLIENT', 'VENDOR', 'INTERNAL', 'DEPENDENCY', 'OTHER'] },
|
|
159
|
+
blocking_ticket: { type: 'string' },
|
|
160
|
+
decision: { type: 'object', properties: { question: { type: 'string' }, options: { type: 'array' }, owner: { type: 'string' }, due_hours: { type: 'number' } }, required: ['question', 'options'] },
|
|
161
|
+
},
|
|
162
|
+
required: ['ref', 'reason'],
|
|
163
|
+
additionalProperties: false,
|
|
164
|
+
},
|
|
165
|
+
run: (api, a) => api.post(`/ticket/${encodeURIComponent(a.ref)}/blocker`, { reason: a.reason, category: a.category || null, ticket: a.blocking_ticket || null, decision: a.decision || null }),
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
name: 'submit_evidence',
|
|
169
|
+
description: 'Evidence your fix rests on, bound to a commit. kind fails_without: data {test, command, without:{sha, output}, with:{sha, output}}. suite_baseline: sha + data {suites, before:{passed,failed}, after:{passed,failed}}. preview_check: record = the browser-check run id. tester_recheck: record = a person\'s QA result id (you cannot be the tester). Returns the ticket\'s readiness.',
|
|
170
|
+
inputSchema: {
|
|
171
|
+
type: 'object',
|
|
172
|
+
properties: { ref: { type: 'string' }, kind: { type: 'string', enum: ['fails_without', 'suite_baseline', 'preview_check', 'tester_recheck'] }, sha: { type: 'string' }, record: { type: 'string' }, note: { type: 'string' }, data: { type: 'object' } },
|
|
173
|
+
required: ['ref', 'kind'],
|
|
174
|
+
additionalProperties: false,
|
|
175
|
+
},
|
|
176
|
+
run: (api, a) => api.post(`/ticket/${encodeURIComponent(a.ref)}/evidence`, { kind: a.kind, sha: a.sha || null, ref: a.record || null, note: a.note || null, data: a.data || null }),
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
name: 'open_hotfix',
|
|
180
|
+
description: 'Project manager / owner only: ship one verified fix to staging ahead of the batch (a one-member release, built at once). reason = why it cannot wait. Needs fails_without + a clean suite_baseline; the person\'s re-check may follow the ship.',
|
|
181
|
+
inputSchema: { type: 'object', properties: { ref: { type: 'string' }, reason: { type: 'string' } }, required: ['ref', 'reason'], additionalProperties: false },
|
|
182
|
+
run: (api, a) => api.post(`/ticket/${encodeURIComponent(a.ref)}/hotfix`, { reason: a.reason }),
|
|
183
|
+
},
|
|
184
|
+
// C1-031: file work and keep project notes (project = uuid or exact name).
|
|
185
|
+
{
|
|
186
|
+
name: 'list_deliverables',
|
|
187
|
+
description: 'A project\'s deliverables (display id, title, uuid), to file work under. project = its uuid or exact name.',
|
|
188
|
+
inputSchema: { type: 'object', properties: { project: { type: 'string' } }, required: ['project'], additionalProperties: false },
|
|
189
|
+
run: (api, a) => api.get(`/project/${encodeURIComponent(a.project)}/deliverables`),
|
|
190
|
+
},
|
|
191
|
+
{
|
|
192
|
+
name: 'create_work_item',
|
|
193
|
+
description: 'File a work item (a ticket in setup; nothing starts). An open ticket with the same title refuses with that ticket: add to it instead, or pass confirmed_new if this is genuinely separate. Related open work comes back as similar. deliverable = display id, uuid or title (default: the project\'s Interrupt deliverable); new_deliverable = a deliverable by name (reused if the project has one).',
|
|
194
|
+
inputSchema: {
|
|
195
|
+
type: 'object',
|
|
196
|
+
properties: { project: { type: 'string' }, title: { type: 'string' }, description: { type: 'string' }, deliverable: { type: 'string' }, new_deliverable: { type: 'string' }, acceptance_criteria: { type: 'array', items: { type: 'string' } }, confirmed_new: { type: 'boolean' } },
|
|
197
|
+
required: ['project', 'title'],
|
|
198
|
+
additionalProperties: false,
|
|
199
|
+
},
|
|
200
|
+
run: (api, a) => api.post(`/project/${encodeURIComponent(a.project)}/work_items`, {
|
|
201
|
+
title: a.title, description: a.description || '', deliverable: a.deliverable || null, newDeliverable: a.new_deliverable || null,
|
|
202
|
+
acceptanceCriteria: a.acceptance_criteria || [], confirmedNew: a.confirmed_new === true,
|
|
203
|
+
}),
|
|
204
|
+
},
|
|
205
|
+
{
|
|
206
|
+
name: 'update_work_item',
|
|
207
|
+
description: 'Change a work item\'s title and/or description, as me (I need edit rights on its project). ref = its code (T2-21) or uuid. Status changes aren\'t available yet: Motionode has no cancel or close for a work item.',
|
|
208
|
+
inputSchema: {
|
|
209
|
+
type: 'object',
|
|
210
|
+
properties: { project: { type: 'string' }, ref: { type: 'string' }, title: { type: 'string' }, description: { type: 'string' } },
|
|
211
|
+
required: ['project', 'ref'],
|
|
212
|
+
additionalProperties: false,
|
|
213
|
+
},
|
|
214
|
+
run: (api, a) => api.patch(`/project/${encodeURIComponent(a.project)}/work_items/${encodeURIComponent(a.ref)}`, {
|
|
215
|
+
...(typeof a.title === 'string' ? { title: a.title } : {}),
|
|
216
|
+
...(typeof a.description === 'string' ? { description: a.description } : {}),
|
|
217
|
+
}),
|
|
218
|
+
},
|
|
219
|
+
{
|
|
220
|
+
name: 'list_notes',
|
|
221
|
+
description: 'The project notes I may read: uuid, title (first line), where attached, size.',
|
|
222
|
+
inputSchema: { type: 'object', properties: { project: { type: 'string' } }, required: ['project'], additionalProperties: false },
|
|
223
|
+
run: (api, a) => api.get(`/project/${encodeURIComponent(a.project)}/notes`),
|
|
224
|
+
},
|
|
225
|
+
{
|
|
226
|
+
name: 'read_note',
|
|
227
|
+
description: 'One project note: its text and HTML.',
|
|
228
|
+
inputSchema: { type: 'object', properties: { project: { type: 'string' }, uuid: { type: 'string' } }, required: ['project', 'uuid'], additionalProperties: false },
|
|
229
|
+
run: (api, a) => api.get(`/project/${encodeURIComponent(a.project)}/notes/${encodeURIComponent(a.uuid)}`),
|
|
230
|
+
},
|
|
231
|
+
{
|
|
232
|
+
name: 'write_note',
|
|
233
|
+
description: 'Create a project note from markdown, or replace one (uuid given; you must have written it or have it shared with you). deliverable or ticket makes it a team note the project team sees; otherwise it is a board note, shared with share_with (emails or user uuids of the project).',
|
|
234
|
+
inputSchema: {
|
|
235
|
+
type: 'object',
|
|
236
|
+
properties: { project: { type: 'string' }, uuid: { type: 'string' }, markdown: { type: 'string' }, category: { type: 'string' }, deliverable: { type: 'string' }, ticket: { type: 'string' }, share_with: { type: 'array', items: { type: 'string' } } },
|
|
237
|
+
required: ['project', 'markdown'],
|
|
238
|
+
additionalProperties: false,
|
|
239
|
+
},
|
|
240
|
+
run: (api, a) => (a.uuid
|
|
241
|
+
? api.patch(`/project/${encodeURIComponent(a.project)}/notes/${encodeURIComponent(a.uuid)}`, { markdown: a.markdown, category: a.category || null })
|
|
242
|
+
: api.post(`/project/${encodeURIComponent(a.project)}/notes`, { markdown: a.markdown, category: a.category || null, deliverable: a.deliverable || null, ticket: a.ticket || null, shareWith: a.share_with || [] })),
|
|
243
|
+
},
|
|
244
|
+
];
|
|
245
|
+
|
|
246
|
+
/** Tools a Motionode older than the CLI may not serve yet (a 404 from them means "not offered", not "not found"). */
|
|
247
|
+
const NEWER_TOOLS = new Set(TOOLS.map((t) => t.name).filter((n) => !['my_work', 'get_ticket', 'get_signals', 'ack_signal'].includes(n)));
|
|
248
|
+
|
|
249
|
+
function send(msg) {
|
|
250
|
+
process.stdout.write(`${JSON.stringify(msg)}\n`);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async function handle(msg, api) {
|
|
254
|
+
const { id, method, params } = msg || {};
|
|
255
|
+
if (method === 'initialize') {
|
|
256
|
+
return {
|
|
257
|
+
protocolVersion: params?.protocolVersion || FALLBACK_PROTOCOL,
|
|
258
|
+
capabilities: { tools: {} },
|
|
259
|
+
serverInfo: { name: 'motionode', version: pkg.version },
|
|
260
|
+
instructions: 'Motionode tickets. claim_step before working a step and release_step when you stop. Call get_signals at every task boundary and before finishing; ack_signal with incorporated=true once your work reflects a change. Report what you saw with report_observation, causes with report_finding, blockers with report_blocker, and back every fix with submit_evidence (fails_without + suite_baseline).',
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
// Newer MCP handshake (protocol 2026-07-28): server/discover instead of initialize.
|
|
264
|
+
if (method === 'server/discover') {
|
|
265
|
+
return {
|
|
266
|
+
resultType: 'complete',
|
|
267
|
+
supportedVersions: DISCOVER_VERSIONS,
|
|
268
|
+
capabilities: { tools: {} },
|
|
269
|
+
_meta: { 'io.modelcontextprotocol/serverInfo': { name: 'motionode', version: pkg.version } },
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
if (method === 'ping') return {};
|
|
273
|
+
if (method === 'tools/list') {
|
|
274
|
+
return { resultType: 'complete', tools: TOOLS.map(({ name, description, inputSchema }) => ({ name, description, inputSchema })) };
|
|
275
|
+
}
|
|
276
|
+
if (method === 'tools/call') {
|
|
277
|
+
const tool = TOOLS.find((t) => t.name === params?.name);
|
|
278
|
+
if (!tool) return { resultType: 'complete', content: [{ type: 'text', text: `Unknown tool ${params?.name}.`, isError: true }], isError: true };
|
|
279
|
+
try {
|
|
280
|
+
const out = await tool.run(api, params?.arguments || {});
|
|
281
|
+
const pending = Inbox.read(process.cwd()).length;
|
|
282
|
+
const text = JSON.stringify({ ...out, signals_pending: pending }, null, 2);
|
|
283
|
+
return { resultType: 'complete', content: [{ type: 'text', text }] };
|
|
284
|
+
} catch (err) {
|
|
285
|
+
const newer = NEWER_TOOLS.has(tool.name);
|
|
286
|
+
const msgText = err.status === 404 && newer && /cannot (get|post)|not found$/i.test(err.message || '')
|
|
287
|
+
? 'This Motionode does not offer that yet.'
|
|
288
|
+
: err.message;
|
|
289
|
+
return { resultType: 'complete', content: [{ type: 'text', text: msgText, isError: true }], isError: true };
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
if (id === undefined || id === null) return undefined; // notification (e.g. notifications/initialized)
|
|
293
|
+
const e = new Error(`Method not found: ${method}`);
|
|
294
|
+
e.code = -32601;
|
|
295
|
+
throw e;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function serve(config) {
|
|
299
|
+
const api = agentClient(config);
|
|
300
|
+
const rl = readline.createInterface({ input: process.stdin, terminal: false });
|
|
301
|
+
const inflight = new Set();
|
|
302
|
+
rl.on('line', (line) => {
|
|
303
|
+
const job = onLine(line);
|
|
304
|
+
inflight.add(job);
|
|
305
|
+
job.finally(() => inflight.delete(job));
|
|
306
|
+
});
|
|
307
|
+
async function onLine(line) {
|
|
308
|
+
const trimmed = line.trim();
|
|
309
|
+
if (!trimmed) return;
|
|
310
|
+
let msg;
|
|
311
|
+
try { msg = JSON.parse(trimmed); } catch (_e) {
|
|
312
|
+
send({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } });
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
try {
|
|
316
|
+
const result = await handle(msg, api);
|
|
317
|
+
if (msg.id !== undefined && msg.id !== null && result !== undefined) send({ jsonrpc: '2.0', id: msg.id, result });
|
|
318
|
+
} catch (err) {
|
|
319
|
+
if (msg.id !== undefined && msg.id !== null) send({ jsonrpc: '2.0', id: msg.id, error: { code: err.code || -32603, message: err.message } });
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
// Input closed: answer what is still running, then exit.
|
|
323
|
+
rl.on('close', async () => { await Promise.allSettled([...inflight]); process.exit(0); });
|
|
324
|
+
process.stderr.write(`motionode mcp ${pkg.version} serving ${api.base}\n`);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
module.exports = { serve, handle, TOOLS };
|