@nonbot/mcp 0.0.0-stage → 0.1.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.
@@ -0,0 +1,160 @@
1
+ import { z } from 'zod';
2
+ import { api, expectJson } from '../http.js';
3
+ const GATE = 'The Run feature is gated behind NONBOT_RUN_FEATURE_ENABLED on the server. Ask the non.bot operator to enable it.';
4
+ const READ = { readOnlyHint: true, openWorldHint: true };
5
+ async function monReq(ctx, method, path, body) {
6
+ const init = { method };
7
+ if (body !== undefined)
8
+ init.body = JSON.stringify(body);
9
+ const res = await api(ctx, path, init);
10
+ if (res.status === 503)
11
+ throw new Error(GATE);
12
+ return expectJson(res, `${method} ${path}`);
13
+ }
14
+ function textResult(data) {
15
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
16
+ }
17
+ function cancelled(message) {
18
+ return { content: [{ type: 'text', text: message }], isError: true };
19
+ }
20
+ async function confirmAction(server, confirmArg, message) {
21
+ if (confirmArg === true)
22
+ return { ok: true };
23
+ try {
24
+ const el = await server.server.elicitInput({
25
+ message,
26
+ requestedSchema: {
27
+ type: 'object',
28
+ properties: { confirm: { type: 'boolean', description: 'true to proceed' } },
29
+ required: ['confirm'],
30
+ },
31
+ });
32
+ if (el.action === 'accept' && el.content?.confirm === true)
33
+ return { ok: true };
34
+ return { ok: false, reply: cancelled('Cancelled — the action was not confirmed. Nothing happened.') };
35
+ }
36
+ catch {
37
+ return {
38
+ ok: false,
39
+ reply: cancelled(`Needs confirmation — re-call with confirm: true. About to: ${message}`),
40
+ };
41
+ }
42
+ }
43
+ export function registerMonitorTools(server, ctx) {
44
+ server.registerTool('run_feed', {
45
+ title: 'Read the run command-center feed',
46
+ description: 'The unified run feed — daemon heartbeat, fleet (active + last-24h runs with status/health/progress), needs-you items, and reconcile chips. The single answer to "what are my runs doing right now?". Pass slim:true for the lightweight cut, since:<ms> for deltas.',
47
+ inputSchema: {
48
+ slim: z.boolean().optional().describe('Lightweight cut (counts + glance fields).'),
49
+ include_digest: z.boolean().optional().describe('Include the digest sidebar block.'),
50
+ since: z.number().int().optional().describe('Delta cursor — only rows active since this ms timestamp.'),
51
+ },
52
+ annotations: READ,
53
+ }, async (a) => {
54
+ const q = new URLSearchParams();
55
+ if (a.slim)
56
+ q.set('slim', '1');
57
+ if (a.include_digest)
58
+ q.set('include', 'digest');
59
+ if (a.since !== undefined)
60
+ q.set('since', String(a.since));
61
+ const qs = q.toString();
62
+ return textResult(await monReq(ctx, 'GET', `/api/run/feed${qs ? `?${qs}` : ''}`));
63
+ });
64
+ server.registerTool('run_daemon_status', {
65
+ title: 'Check daemon health',
66
+ description: 'Is the operator\'s daemon connected? Returns connected/lastSeenAt, CLI version, terminal kind + launchability, tmux session, last launch-failure reason, and (daemon ≥0.9.10) load stats: running/done/failed counts, open prompt count, poll cadence.',
67
+ inputSchema: {},
68
+ annotations: READ,
69
+ }, async () => textResult(await monReq(ctx, 'GET', '/api/cli/daemon-status')));
70
+ server.registerTool('run_machines', {
71
+ title: 'List online machines',
72
+ description: 'The operator\'s machines with a live daemon (multi-machine runs): machine id, name, terminal kind, last-seen, and (≥0.9.10) per-machine load stats. A machine absent here is offline.',
73
+ inputSchema: {},
74
+ annotations: READ,
75
+ }, async () => textResult(await monReq(ctx, 'GET', '/api/cli/machines')));
76
+ server.registerTool('run_activations_recent', {
77
+ title: 'List recent launches',
78
+ description: 'The last 10 activations with launch traces (spawn steps, launcher stderr/stdout/exit code), derived status incl. staleness, terminal used, and tmux pane. Rate-limited pool — poll no more than every 30s.',
79
+ inputSchema: {},
80
+ annotations: READ,
81
+ }, async () => textResult(await monReq(ctx, 'GET', '/api/cli/activations/recent')));
82
+ server.registerTool('run_prompts_list', {
83
+ title: 'List waiting run prompts',
84
+ description: 'Mid-run prompts (a run paused on a menu/question). Default open = waiting on a human or supervisor right now. Answer via run_prompt_answer.',
85
+ inputSchema: {
86
+ status: z.enum(['open', 'answered', 'closed', 'cancelled']).optional().describe('Filter (default open).'),
87
+ },
88
+ annotations: READ,
89
+ }, async (a) => textResult(await monReq(ctx, 'GET', `/api/run/prompts${a.status ? `?status=${a.status}` : ''}`)));
90
+ server.registerTool('run_snapshot_get', {
91
+ title: 'Read a pane-output snapshot',
92
+ description: 'Fetch the result of a run_snapshot_request. status "ready" carries the scrubbed pane tail; "pending" = the daemon hasn\'t served it yet (re-poll in ~5s, up to ~45s total); "none" = expired or the daemon is offline/pre-0.9.10. Requires the runs:control scope on scoped tokens.',
93
+ inputSchema: {
94
+ activation_id: z.string(),
95
+ request_id: z.string().describe('The requestId returned by run_snapshot_request.'),
96
+ },
97
+ annotations: READ,
98
+ }, async (a) => textResult(await monReq(ctx, 'GET', `/api/run/activations/${encodeURIComponent(a.activation_id)}/snapshot?requestId=${encodeURIComponent(a.request_id)}`)));
99
+ if (ctx.readonly)
100
+ return;
101
+ server.registerTool('run_snapshot_request', {
102
+ title: 'Request a pane-output snapshot',
103
+ description: 'Ask the operator\'s daemon to capture the last lines of a RUNNING run\'s terminal pane (secrets scrubbed, 16KB tail cap, stored server-side for 5 minutes only). Answers "what is this run actually doing?". Then call run_snapshot_get with the returned requestId after ~5s. Requires runs:control scope; needs confirmation.',
104
+ inputSchema: {
105
+ activation_id: z.string(),
106
+ max_lines: z.number().int().min(1).max(200).optional().describe('Pane lines to capture (default 100).'),
107
+ confirm: z.boolean().optional().describe('Pass true to confirm when the client lacks elicitation.'),
108
+ },
109
+ annotations: { readOnlyHint: false, idempotentHint: true, openWorldHint: true },
110
+ }, async (a) => {
111
+ const gate = await confirmAction(server, a.confirm, `capture the last ${a.max_lines ?? 100} lines of terminal output from run ${a.activation_id} and store them server-side for 5 minutes.`);
112
+ if (!gate.ok)
113
+ return gate.reply;
114
+ return textResult(await monReq(ctx, 'POST', `/api/run/activations/${encodeURIComponent(a.activation_id)}/snapshot-request`, {
115
+ ...(a.max_lines !== undefined ? { maxLines: a.max_lines } : {}),
116
+ }));
117
+ });
118
+ server.registerTool('run_prompt_answer', {
119
+ title: 'Answer a waiting run prompt',
120
+ description: 'Answer a mid-run prompt on the operator\'s behalf — the daemon types the answer into the live pane. POWERFUL: this unblocks (or misdirects) a real agent. Requires a supervisor token minted with the EXPLICIT runs:respond scope; the run\'s own daemon token is always refused (no-self-answer). Needs confirmation.',
121
+ inputSchema: {
122
+ prompt_id: z.string(),
123
+ answer_index: z.number().int().min(0).optional().describe('Numbered-option choice.'),
124
+ answer_text: z.string().max(500).optional().describe('Free-text answer (typed into the pane).'),
125
+ confirm: z.boolean().optional().describe('Pass true to confirm when the client lacks elicitation.'),
126
+ },
127
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
128
+ }, async (a) => {
129
+ if (a.answer_index === undefined && !a.answer_text) {
130
+ return cancelled('Provide answer_index or answer_text.');
131
+ }
132
+ const what = a.answer_index !== undefined ? `option ${a.answer_index}` : `"${a.answer_text}"`;
133
+ const gate = await confirmAction(server, a.confirm, `answer prompt ${a.prompt_id} with ${what} — the daemon will type this into the live run.`);
134
+ if (!gate.ok)
135
+ return gate.reply;
136
+ return textResult(await monReq(ctx, 'POST', `/api/run/prompts/${encodeURIComponent(a.prompt_id)}/answer`, {
137
+ ...(a.answer_index !== undefined ? { answerIndex: a.answer_index } : {}),
138
+ ...(a.answer_text ? { answerText: a.answer_text } : {}),
139
+ }));
140
+ });
141
+ server.registerTool('run_stop_daemon', {
142
+ title: 'Stop the operator\'s daemon',
143
+ description: 'The panic button — queue a remote shutdown for the operator\'s daemon (CLI ≥0.9.12). KILL-EVERYTHING semantics: the daemon kills every run pane on that machine, kills its own tmux session, and exits, picked up on its next poll (≤30s). Works even when the terminal window hosting the daemon is lost (headless tmux). Pass machine_id (see run_machines) to stop one machine, or all:true to stop every machine\'s daemon. The 200 means QUEUED, not stopped — verify with run_daemon_status until connected flips false. Requires runs:control scope; needs confirmation.',
144
+ inputSchema: {
145
+ machine_id: z.string().optional().describe('Stop this machine\'s daemon (from run_machines).'),
146
+ all: z.boolean().optional().describe('Stop EVERY machine\'s daemon.'),
147
+ confirm: z.boolean().optional().describe('Pass true to confirm when the client lacks elicitation.'),
148
+ },
149
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
150
+ }, async (a) => {
151
+ if (Boolean(a.all) === Boolean(a.machine_id)) {
152
+ return cancelled('Provide exactly one of machine_id or all:true.');
153
+ }
154
+ const target = a.all ? 'EVERY machine\'s daemon' : `the daemon on machine ${a.machine_id}`;
155
+ const gate = await confirmAction(server, a.confirm, `stop ${target} — every run pane it owns will be killed, then the daemon exits.`);
156
+ if (!gate.ok)
157
+ return gate.reply;
158
+ return textResult(await monReq(ctx, 'POST', '/api/run/daemon/stop', a.all ? { all: true } : { machineId: a.machine_id }));
159
+ });
160
+ }
@@ -0,0 +1,3 @@
1
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import type { ApiContext } from '../config.js';
3
+ export declare function registerPortfolioTools(server: McpServer, ctx: ApiContext): void;