@bli-cockpit/mcp 0.1.1 → 0.1.2
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 +64 -8
- package/dist/jarvis-answer-envelope.d.ts +107 -0
- package/dist/jarvis-answer-envelope.js +82 -0
- package/dist/jarvis-door.d.ts +95 -0
- package/dist/jarvis-door.js +163 -0
- package/dist/jarvis-tools.d.ts +50 -0
- package/dist/jarvis-tools.js +248 -0
- package/dist/jarvis-turn-bookmark.d.ts +25 -0
- package/dist/jarvis-turn-bookmark.js +43 -0
- package/dist/server.d.ts +1 -1
- package/dist/server.js +6 -1
- package/dist/verb-census.d.ts +41 -0
- package/dist/verb-census.js +108 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -3,20 +3,76 @@
|
|
|
3
3
|
Two, deliberately separate, tool families over one MCP server (bin
|
|
4
4
|
`bli-cockpit-mcp`, server id `bli-tower`):
|
|
5
5
|
|
|
6
|
-
1.
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
6
|
+
1. **The agent doors** — the SAME routes the `cockpit` CLI and the browser
|
|
7
|
+
call, authenticated with this machine's collector device token
|
|
8
|
+
(`~/.config/bli-cockpit/session.json`, written by `cockpit login`). This is
|
|
9
|
+
the live, actively-developed half: `docs_*`/`msg_*` (BLI-3706),
|
|
10
|
+
`work_*` (BLI-3716) and `jarvis_*` (BLI-3732).
|
|
10
11
|
2. **`emit_event`/`get_ticket_timeline`/`get_active_tickets`** — the legacy
|
|
11
12
|
event-stream write path, authenticated with `BLI_OPERATOR_TOKEN` or the
|
|
12
13
|
`bli-event` session helper. See "Legacy event-stream tools" below.
|
|
13
14
|
|
|
14
15
|
The two halves use DIFFERENT credentials and DIFFERENT base URLs on purpose
|
|
15
|
-
(
|
|
16
|
+
(the agent doors talk to the production Tower dashboard; the event-stream
|
|
16
17
|
tools talk to `BLI_API_BASE_URL`, historically a local worker) — see
|
|
17
|
-
`docs/architecture/cli-caller-identity.md` for why
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
`docs/architecture/cli-caller-identity.md` for why they needed no new
|
|
19
|
+
server-side door: every one of those routes already accepts a collector device
|
|
20
|
+
token.
|
|
21
|
+
|
|
22
|
+
**Every `cockpit <noun> <verb>` that talks to Tower is meant to have a twin
|
|
23
|
+
here** (BLI-3706: "a surface an agent cannot reach conveniently is as good as
|
|
24
|
+
dead"). `src/verb-census.test.ts` is that rule as a test — it reads the CLI's
|
|
25
|
+
own decision tables and fails when a verb is in neither the twin map, the
|
|
26
|
+
terminal-only list, nor the written follow-up queue.
|
|
27
|
+
|
|
28
|
+
## `jarvis_*` tools (BLI-3732)
|
|
29
|
+
|
|
30
|
+
JARVIS itself — the same assistant Tower web chat, the Slack DM and
|
|
31
|
+
`cockpit jarvis` reach, over the same two doors with the same device token.
|
|
32
|
+
|
|
33
|
+
| Tool | Wraps | Notes |
|
|
34
|
+
| --- | --- | --- |
|
|
35
|
+
| `jarvis_ask` | `POST /api/jarvis/cli` | One turn. Returns the answer, its `Source:` lines, `turn_id` and `thread_id`. |
|
|
36
|
+
| `jarvis_trace` | `GET /api/ops/trace/[id]` | That turn's step tree — model steps, tool calls, timings, failures. Metadata only. Takes a `turn_id` or `"last"`. |
|
|
37
|
+
| `jarvis_dispatch` | `POST /api/jarvis/cli` | The coding arm (BLI-2981). Two calls: once for the plan, once with the person's approval code. |
|
|
38
|
+
| `jarvis_check` | `POST /api/jarvis/cli` | Where a dispatched coding task got to. Spends no approval. |
|
|
39
|
+
|
|
40
|
+
`jarvis_ask` answers with the SAME object `cockpit jarvis --json` prints —
|
|
41
|
+
`answer`, `sources`, `turn_id`, `thread_id`, `trace_thread_id`, `degraded`,
|
|
42
|
+
`degraded_reasons` — so a script and an agent read one contract. A worked
|
|
43
|
+
example:
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
jarvis_ask { "question": "what did Brandon ship yesterday?" }
|
|
47
|
+
-> { "answer": "Two pull requests went in…\n\nSource: BLI-3654 — …",
|
|
48
|
+
"sources": ["Source: BLI-3654 — …"],
|
|
49
|
+
"turn_id": "0f3c…", "thread_id": "main",
|
|
50
|
+
"degraded": false, "degraded_reasons": [] }
|
|
51
|
+
|
|
52
|
+
jarvis_trace { "turn_id": "0f3c…" } # or "last"
|
|
53
|
+
-> Turn 0f3c…
|
|
54
|
+
6 steps · 4.2 s · 1 failed
|
|
55
|
+
jarvis-chat 4.2s openai:gpt-5.6-terra
|
|
56
|
+
readIssues 0.2s
|
|
57
|
+
searchMemory 1.1s
|
|
58
|
+
readScout 0.3s FAILED
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
`degraded` is never a failure — the answer is still returned. It names how the
|
|
62
|
+
turn fell short: `model_fallback` (a fallback model answered),
|
|
63
|
+
`answer_revised` (the grounding gate took words back), `tool_step_failed`, or
|
|
64
|
+
`no_turn_id` (no trace id came back, so the tree cannot be opened later).
|
|
65
|
+
|
|
66
|
+
**The coding arm's approval code.** `jarvis_dispatch` takes `approval_code` as
|
|
67
|
+
a parameter and never derives, guesses or fabricates one. Call it once with no
|
|
68
|
+
code to get the plan and its 8-character code, show both to the person, then
|
|
69
|
+
call it again with the code THEY hand back. The gate's first lock (an HMAC
|
|
70
|
+
over the exact plan, this account and a server secret) is untouched by this
|
|
71
|
+
surface — this server holds no secret and cannot compute a code. Its second
|
|
72
|
+
lock ("the code came from the human's own message") is weaker here than in a
|
|
73
|
+
terminal, because nothing on the wire can tell a person-relayed code from one
|
|
74
|
+
the model lifted out of the previous answer; `src/jarvis-tools.ts` says so in
|
|
75
|
+
full at the top of the file.
|
|
20
76
|
|
|
21
77
|
## `docs_*` / `msg_*` tools (BLI-3706)
|
|
22
78
|
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ONE shape a JARVIS answer wears when nobody is reading it with their
|
|
3
|
+
* eyes (BLI-3732).
|
|
4
|
+
*
|
|
5
|
+
* `cockpit jarvis --json` and the `bli-tower` MCP server's `jarvis_ask` reach
|
|
6
|
+
* the same door (`POST /api/jarvis/cli`) with the same device token and get
|
|
7
|
+
* the same reply body back. Before this file they described that body two
|
|
8
|
+
* different ways, so a script and an agent read two contracts for one turn.
|
|
9
|
+
* Now both build this envelope, and the five fields a consumer actually asks
|
|
10
|
+
* for — the answer, where it came from, which turn it was, which conversation
|
|
11
|
+
* it belongs to, and whether anything about it was second-rate — have one
|
|
12
|
+
* spelling each.
|
|
13
|
+
*
|
|
14
|
+
* ## Where the other copy is, and why
|
|
15
|
+
*
|
|
16
|
+
* This file is a deliberate copy of
|
|
17
|
+
* `packages/cockpit-local-collector/src/commands/jarvis-answer-envelope.ts`,
|
|
18
|
+
* which is the original. This package declares no dependency on that one — see
|
|
19
|
+
* `agent-door-session.ts` next door, whose own header makes the same call for
|
|
20
|
+
* the same reason — so the shape is duplicated rather than imported. Both
|
|
21
|
+
* copies are pinned by a test on the same literal key list
|
|
22
|
+
* (`JARVIS_ANSWER_ENVELOPE_KEYS` below), so a field added on one side and not
|
|
23
|
+
* the other fails a suite instead of drifting quietly.
|
|
24
|
+
*
|
|
25
|
+
* ## What `jarvis_ask` returns beside it
|
|
26
|
+
*
|
|
27
|
+
* The MCP tool returns exactly this envelope as its `structuredContent`, plus
|
|
28
|
+
* a human-readable text block. It does NOT carry the `clientLatency` /
|
|
29
|
+
* `latency` / `model` keys `cockpit jarvis --json` also prints: those describe
|
|
30
|
+
* a terminal process's own stopwatch, and this server has no such stopwatch to
|
|
31
|
+
* report honestly.
|
|
32
|
+
*/
|
|
33
|
+
/**
|
|
34
|
+
* Every key of the envelope, in the order it is written. The literal list IS
|
|
35
|
+
* the contract: both copies assert against it, so a drift is a red suite.
|
|
36
|
+
*/
|
|
37
|
+
export declare const JARVIS_ANSWER_ENVELOPE_KEYS: readonly ["ok", "answer", "sources", "turn_id", "thread_id", "trace_thread_id", "degraded", "degraded_reasons"];
|
|
38
|
+
/**
|
|
39
|
+
* The closed set of ways an answer can be second-rate without being a failure.
|
|
40
|
+
* A degraded answer is still an answer — it is printed, returned and counted —
|
|
41
|
+
* but the consumer is told which way it fell short rather than being left to
|
|
42
|
+
* guess from a shorter reply.
|
|
43
|
+
*/
|
|
44
|
+
export type JarvisDegradedReason =
|
|
45
|
+
/** The requested model refused or failed and a fallback answered instead. */
|
|
46
|
+
"model_fallback"
|
|
47
|
+
/** The grounding gate took back words the model had already produced. */
|
|
48
|
+
| "answer_revised"
|
|
49
|
+
/** At least one tool the turn reached for came back failed. */
|
|
50
|
+
| "tool_step_failed"
|
|
51
|
+
/** No trace id came back, so this turn's step tree cannot be opened later. */
|
|
52
|
+
| "no_turn_id";
|
|
53
|
+
export interface JarvisAnswerEnvelope {
|
|
54
|
+
ok: true;
|
|
55
|
+
/**
|
|
56
|
+
* The answer verbatim, exactly as the terminal prints it — `Source:` lines
|
|
57
|
+
* included. Nothing is stripped: `sources` below is an index into this
|
|
58
|
+
* string, not a slice taken out of it.
|
|
59
|
+
*/
|
|
60
|
+
answer: string;
|
|
61
|
+
/** Every `Source: …` line found in `answer`, in the order it appears. */
|
|
62
|
+
sources: string[];
|
|
63
|
+
/**
|
|
64
|
+
* This turn's trace id — what `cockpit jarvis --trace <id>` and the
|
|
65
|
+
* `jarvis_trace` MCP tool open. `null` against a dashboard that predates
|
|
66
|
+
* BLI-3560, which is also a `no_turn_id` degradation.
|
|
67
|
+
*/
|
|
68
|
+
turn_id: string | null;
|
|
69
|
+
/**
|
|
70
|
+
* The conversation NAME to hand back to continue this thread (`--thread`,
|
|
71
|
+
* or `jarvis_ask`'s `thread`). Not a uuid — `main` is the usual value.
|
|
72
|
+
*/
|
|
73
|
+
thread_id: string | null;
|
|
74
|
+
/**
|
|
75
|
+
* The Mastra thread id the turn's spans were recorded under, which is what
|
|
76
|
+
* `GET /api/ops/trace?thread=` takes. A different identifier from
|
|
77
|
+
* `thread_id` above and deliberately named apart from it.
|
|
78
|
+
*/
|
|
79
|
+
trace_thread_id: string | null;
|
|
80
|
+
degraded: boolean;
|
|
81
|
+
degraded_reasons: JarvisDegradedReason[];
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* The `Source:` lines inside an answer. Pure string work on what the server
|
|
85
|
+
* already sent — this side never decides what a source IS, it only finds the
|
|
86
|
+
* lines the server wrote, so a new citation kind needs no change here.
|
|
87
|
+
*/
|
|
88
|
+
export declare function extractSourceLines(answer: string): string[];
|
|
89
|
+
export interface JarvisAnswerEnvelopeInput {
|
|
90
|
+
/** The reply text the door returned. */
|
|
91
|
+
reply: string;
|
|
92
|
+
/** `body.thread`, or the thread the caller asked for when the door omitted it. */
|
|
93
|
+
thread?: string | null;
|
|
94
|
+
/** `body.traceId` — BLI-3560's per-turn id. */
|
|
95
|
+
traceId?: string | null;
|
|
96
|
+
/** `body.traceThread` — the Mastra thread the spans landed in. */
|
|
97
|
+
traceThread?: string | null;
|
|
98
|
+
/** `body.model.fallback` — whether a fallback model answered. */
|
|
99
|
+
modelFallback?: boolean;
|
|
100
|
+
/** `body.revised` — whether the grounding gate took back streamed words. */
|
|
101
|
+
revised?: boolean;
|
|
102
|
+
/** The settled trace, for the failed-step check. */
|
|
103
|
+
trace?: ReadonlyArray<{
|
|
104
|
+
status?: string;
|
|
105
|
+
}> | null;
|
|
106
|
+
}
|
|
107
|
+
export declare function buildJarvisAnswerEnvelope(input: JarvisAnswerEnvelopeInput): JarvisAnswerEnvelope;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ONE shape a JARVIS answer wears when nobody is reading it with their
|
|
3
|
+
* eyes (BLI-3732).
|
|
4
|
+
*
|
|
5
|
+
* `cockpit jarvis --json` and the `bli-tower` MCP server's `jarvis_ask` reach
|
|
6
|
+
* the same door (`POST /api/jarvis/cli`) with the same device token and get
|
|
7
|
+
* the same reply body back. Before this file they described that body two
|
|
8
|
+
* different ways, so a script and an agent read two contracts for one turn.
|
|
9
|
+
* Now both build this envelope, and the five fields a consumer actually asks
|
|
10
|
+
* for — the answer, where it came from, which turn it was, which conversation
|
|
11
|
+
* it belongs to, and whether anything about it was second-rate — have one
|
|
12
|
+
* spelling each.
|
|
13
|
+
*
|
|
14
|
+
* ## Where the other copy is, and why
|
|
15
|
+
*
|
|
16
|
+
* This file is a deliberate copy of
|
|
17
|
+
* `packages/cockpit-local-collector/src/commands/jarvis-answer-envelope.ts`,
|
|
18
|
+
* which is the original. This package declares no dependency on that one — see
|
|
19
|
+
* `agent-door-session.ts` next door, whose own header makes the same call for
|
|
20
|
+
* the same reason — so the shape is duplicated rather than imported. Both
|
|
21
|
+
* copies are pinned by a test on the same literal key list
|
|
22
|
+
* (`JARVIS_ANSWER_ENVELOPE_KEYS` below), so a field added on one side and not
|
|
23
|
+
* the other fails a suite instead of drifting quietly.
|
|
24
|
+
*
|
|
25
|
+
* ## What `jarvis_ask` returns beside it
|
|
26
|
+
*
|
|
27
|
+
* The MCP tool returns exactly this envelope as its `structuredContent`, plus
|
|
28
|
+
* a human-readable text block. It does NOT carry the `clientLatency` /
|
|
29
|
+
* `latency` / `model` keys `cockpit jarvis --json` also prints: those describe
|
|
30
|
+
* a terminal process's own stopwatch, and this server has no such stopwatch to
|
|
31
|
+
* report honestly.
|
|
32
|
+
*/
|
|
33
|
+
/**
|
|
34
|
+
* Every key of the envelope, in the order it is written. The literal list IS
|
|
35
|
+
* the contract: both copies assert against it, so a drift is a red suite.
|
|
36
|
+
*/
|
|
37
|
+
export const JARVIS_ANSWER_ENVELOPE_KEYS = [
|
|
38
|
+
"ok",
|
|
39
|
+
"answer",
|
|
40
|
+
"sources",
|
|
41
|
+
"turn_id",
|
|
42
|
+
"thread_id",
|
|
43
|
+
"trace_thread_id",
|
|
44
|
+
"degraded",
|
|
45
|
+
"degraded_reasons",
|
|
46
|
+
];
|
|
47
|
+
/** A `Source:` line, as the grounding gate renders it into the answer. */
|
|
48
|
+
const SOURCE_LINE = /^\s*Source:\s*\S/;
|
|
49
|
+
/**
|
|
50
|
+
* The `Source:` lines inside an answer. Pure string work on what the server
|
|
51
|
+
* already sent — this side never decides what a source IS, it only finds the
|
|
52
|
+
* lines the server wrote, so a new citation kind needs no change here.
|
|
53
|
+
*/
|
|
54
|
+
export function extractSourceLines(answer) {
|
|
55
|
+
return answer
|
|
56
|
+
.split("\n")
|
|
57
|
+
.map((line) => line.trim())
|
|
58
|
+
.filter((line) => SOURCE_LINE.test(line));
|
|
59
|
+
}
|
|
60
|
+
export function buildJarvisAnswerEnvelope(input) {
|
|
61
|
+
const reasons = [];
|
|
62
|
+
if (input.modelFallback === true)
|
|
63
|
+
reasons.push("model_fallback");
|
|
64
|
+
if (input.revised === true)
|
|
65
|
+
reasons.push("answer_revised");
|
|
66
|
+
if ((input.trace ?? []).some((step) => step?.status === "failed")) {
|
|
67
|
+
reasons.push("tool_step_failed");
|
|
68
|
+
}
|
|
69
|
+
const turnId = input.traceId ?? null;
|
|
70
|
+
if (!turnId)
|
|
71
|
+
reasons.push("no_turn_id");
|
|
72
|
+
return {
|
|
73
|
+
ok: true,
|
|
74
|
+
answer: input.reply,
|
|
75
|
+
sources: extractSourceLines(input.reply),
|
|
76
|
+
turn_id: turnId,
|
|
77
|
+
thread_id: input.thread ?? null,
|
|
78
|
+
trace_thread_id: input.traceThread ?? null,
|
|
79
|
+
degraded: reasons.length > 0,
|
|
80
|
+
degraded_reasons: reasons,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One JARVIS turn, and one trace read, for the `jarvis_*` MCP tools
|
|
3
|
+
* (BLI-3732). Everything the four tools in `jarvis-tools.ts` share lives here
|
|
4
|
+
* and nothing else does: the session gate, the request the dashboard actually
|
|
5
|
+
* receives, the envelope it becomes, and what `last` resolves to.
|
|
6
|
+
*
|
|
7
|
+
* The split exists so `jarvis-tools.ts` reads as four tool descriptions —
|
|
8
|
+
* which is the part a person and a model both have to understand — and the
|
|
9
|
+
* wire mechanics have one owner. Both halves talk to the same doors
|
|
10
|
+
* `cockpit jarvis` calls, with this machine's device token.
|
|
11
|
+
*/
|
|
12
|
+
import { loadAgentDoorSession } from "./agent-door-session.js";
|
|
13
|
+
import { type FetchImpl } from "./agent-door.js";
|
|
14
|
+
import { type JarvisAnswerEnvelope } from "./jarvis-answer-envelope.js";
|
|
15
|
+
import { readCollectorTurnBookmark } from "./jarvis-turn-bookmark.js";
|
|
16
|
+
/** The dashboard's own turn ceiling is 120 s; this is `TURN_DEADLINE_MS` from the CLI, verbatim. */
|
|
17
|
+
export declare const TURN_TIMEOUT_MS = 125000;
|
|
18
|
+
/** A trace read touches no model. Same 30 s the CLI gives it. */
|
|
19
|
+
export declare const READ_TIMEOUT_MS = 30000;
|
|
20
|
+
/**
|
|
21
|
+
* Hex, 8 characters — `CONFIRMATION_CODE_PATTERN` in
|
|
22
|
+
* `apps/dashboard/src/lib/jarvis/dispatch/proposal.ts`, anchored here because
|
|
23
|
+
* this side validates a whole argument rather than scanning a sentence.
|
|
24
|
+
*/
|
|
25
|
+
export declare const APPROVAL_CODE: RegExp;
|
|
26
|
+
export interface JarvisDeps {
|
|
27
|
+
fetchImpl: FetchImpl;
|
|
28
|
+
/** Injectable for tests; defaults to reading `~/.config/bli-cockpit/session.json`. */
|
|
29
|
+
loadSession?: typeof loadAgentDoorSession;
|
|
30
|
+
/** Injectable for tests; defaults to the collector's own `--trace last` bookmark. */
|
|
31
|
+
readBookmark?: typeof readCollectorTurnBookmark;
|
|
32
|
+
/** Metadata-only, stderr. stdout belongs to the MCP transport. */
|
|
33
|
+
log?: (line: string) => void;
|
|
34
|
+
}
|
|
35
|
+
export declare const TAG = "[bli-cockpit-mcp jarvis]";
|
|
36
|
+
export declare function defaultLog(line: string): void;
|
|
37
|
+
export declare function textResult(text: string, structured?: Record<string, unknown>): {
|
|
38
|
+
structuredContent?: Record<string, unknown> | undefined;
|
|
39
|
+
content: {
|
|
40
|
+
type: "text";
|
|
41
|
+
text: string;
|
|
42
|
+
}[];
|
|
43
|
+
};
|
|
44
|
+
export declare function errorResult(text: string): {
|
|
45
|
+
isError: boolean;
|
|
46
|
+
content: {
|
|
47
|
+
type: "text";
|
|
48
|
+
text: string;
|
|
49
|
+
}[];
|
|
50
|
+
};
|
|
51
|
+
export type ToolResult = ReturnType<typeof textResult> | ReturnType<typeof errorResult>;
|
|
52
|
+
export declare function withSession(deps: JarvisDeps, run: (session: {
|
|
53
|
+
dashboardUrl: string;
|
|
54
|
+
deviceToken: string;
|
|
55
|
+
}) => Promise<ToolResult>): Promise<ToolResult>;
|
|
56
|
+
export declare function doorFailureText(door: string, response: {
|
|
57
|
+
transportError: string | null;
|
|
58
|
+
status: number;
|
|
59
|
+
body: Record<string, unknown>;
|
|
60
|
+
}): string;
|
|
61
|
+
/** What the last successful turn on THIS server was, for `jarvis_trace last`. */
|
|
62
|
+
export interface LastTurn {
|
|
63
|
+
turnId: string | null;
|
|
64
|
+
traceThreadId: string | null;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* One turn, and the envelope it becomes. Every `jarvis_*` tool that talks to a
|
|
68
|
+
* model goes through here, so the ask, the dispatch and the check cannot drift
|
|
69
|
+
* into three different request shapes.
|
|
70
|
+
*/
|
|
71
|
+
export declare function takeTurn(deps: JarvisDeps, session: {
|
|
72
|
+
dashboardUrl: string;
|
|
73
|
+
deviceToken: string;
|
|
74
|
+
}, door: string, body: Record<string, unknown>, remember: (turn: LastTurn) => void): Promise<{
|
|
75
|
+
result: ToolResult;
|
|
76
|
+
envelope: JarvisAnswerEnvelope | null;
|
|
77
|
+
}>;
|
|
78
|
+
/**
|
|
79
|
+
* Which turn `last` means. Three sources, newest knowledge first: this
|
|
80
|
+
* server's own memory of the turn it just took, then the bookmark
|
|
81
|
+
* `cockpit jarvis` writes on the same machine, then — when the bookmark knew
|
|
82
|
+
* only the conversation — that conversation's newest recorded turn.
|
|
83
|
+
*/
|
|
84
|
+
export declare function resolveLastTurn(deps: JarvisDeps, session: {
|
|
85
|
+
dashboardUrl: string;
|
|
86
|
+
deviceToken: string;
|
|
87
|
+
}, lastTurn: LastTurn): Promise<{
|
|
88
|
+
ok: true;
|
|
89
|
+
traceId: string;
|
|
90
|
+
from: "server_memory" | "bookmark" | "thread";
|
|
91
|
+
} | {
|
|
92
|
+
ok: false;
|
|
93
|
+
reason: string;
|
|
94
|
+
message: string;
|
|
95
|
+
}>;
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One JARVIS turn, and one trace read, for the `jarvis_*` MCP tools
|
|
3
|
+
* (BLI-3732). Everything the four tools in `jarvis-tools.ts` share lives here
|
|
4
|
+
* and nothing else does: the session gate, the request the dashboard actually
|
|
5
|
+
* receives, the envelope it becomes, and what `last` resolves to.
|
|
6
|
+
*
|
|
7
|
+
* The split exists so `jarvis-tools.ts` reads as four tool descriptions —
|
|
8
|
+
* which is the part a person and a model both have to understand — and the
|
|
9
|
+
* wire mechanics have one owner. Both halves talk to the same doors
|
|
10
|
+
* `cockpit jarvis` calls, with this machine's device token.
|
|
11
|
+
*/
|
|
12
|
+
import { loadAgentDoorSession } from "./agent-door-session.js";
|
|
13
|
+
import { callAgentDoor } from "./agent-door.js";
|
|
14
|
+
import { buildJarvisAnswerEnvelope, } from "./jarvis-answer-envelope.js";
|
|
15
|
+
import { readCollectorTurnBookmark } from "./jarvis-turn-bookmark.js";
|
|
16
|
+
/** The dashboard's own turn ceiling is 120 s; this is `TURN_DEADLINE_MS` from the CLI, verbatim. */
|
|
17
|
+
export const TURN_TIMEOUT_MS = 125_000;
|
|
18
|
+
/** A trace read touches no model. Same 30 s the CLI gives it. */
|
|
19
|
+
export const READ_TIMEOUT_MS = 30_000;
|
|
20
|
+
/**
|
|
21
|
+
* Hex, 8 characters — `CONFIRMATION_CODE_PATTERN` in
|
|
22
|
+
* `apps/dashboard/src/lib/jarvis/dispatch/proposal.ts`, anchored here because
|
|
23
|
+
* this side validates a whole argument rather than scanning a sentence.
|
|
24
|
+
*/
|
|
25
|
+
export const APPROVAL_CODE = /^[0-9a-f]{8}$/i;
|
|
26
|
+
export const TAG = "[bli-cockpit-mcp jarvis]";
|
|
27
|
+
export function defaultLog(line) {
|
|
28
|
+
process.stderr.write(`${line}\n`);
|
|
29
|
+
}
|
|
30
|
+
export function textResult(text, structured) {
|
|
31
|
+
return {
|
|
32
|
+
content: [{ type: "text", text }],
|
|
33
|
+
...(structured ? { structuredContent: structured } : {}),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
export function errorResult(text) {
|
|
37
|
+
return { isError: true, content: [{ type: "text", text }] };
|
|
38
|
+
}
|
|
39
|
+
export async function withSession(deps, run) {
|
|
40
|
+
const loadSession = deps.loadSession ?? loadAgentDoorSession;
|
|
41
|
+
const loaded = loadSession();
|
|
42
|
+
if (!loaded.ok) {
|
|
43
|
+
(deps.log ?? defaultLog)(`${TAG} refused ${JSON.stringify({ reason: loaded.reason })}`);
|
|
44
|
+
return errorResult(`This machine is not paired with Tower (${loaded.reason}). ${loaded.message}`);
|
|
45
|
+
}
|
|
46
|
+
return run(loaded.session);
|
|
47
|
+
}
|
|
48
|
+
export function doorFailureText(door, response) {
|
|
49
|
+
if (response.transportError) {
|
|
50
|
+
return `Tower could not be reached for ${door} (${response.transportError}). Nothing was asked or written.`;
|
|
51
|
+
}
|
|
52
|
+
const reason = typeof response.body.error === "string"
|
|
53
|
+
? response.body.error
|
|
54
|
+
: typeof response.body.reason === "string"
|
|
55
|
+
? response.body.reason
|
|
56
|
+
: "unknown_error";
|
|
57
|
+
const message = typeof response.body.reply === "string"
|
|
58
|
+
? response.body.reply
|
|
59
|
+
: typeof response.body.message === "string"
|
|
60
|
+
? response.body.message
|
|
61
|
+
: `Tower answered ${response.status}.`;
|
|
62
|
+
return `Tower refused ${door} (${reason}): ${message}`;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* One turn, and the envelope it becomes. Every `jarvis_*` tool that talks to a
|
|
66
|
+
* model goes through here, so the ask, the dispatch and the check cannot drift
|
|
67
|
+
* into three different request shapes.
|
|
68
|
+
*/
|
|
69
|
+
export async function takeTurn(deps, session, door, body, remember) {
|
|
70
|
+
const log = deps.log ?? defaultLog;
|
|
71
|
+
const startedAt = Date.now();
|
|
72
|
+
// `accept: application/json` (which `callAgentDoor` always sends) is what
|
|
73
|
+
// makes the dashboard answer one JSON body instead of the NDJSON stream the
|
|
74
|
+
// terminal takes — an MCP tool has nobody to show live trace lines to.
|
|
75
|
+
const response = await callAgentDoor(session, deps.fetchImpl, "POST", "/api/jarvis/cli", body, TURN_TIMEOUT_MS);
|
|
76
|
+
if (!response.ok) {
|
|
77
|
+
log(`${TAG} turn failed ${JSON.stringify({
|
|
78
|
+
door,
|
|
79
|
+
status: response.status,
|
|
80
|
+
transport: response.transportError !== null,
|
|
81
|
+
elapsed_ms: Date.now() - startedAt,
|
|
82
|
+
})}`);
|
|
83
|
+
return { result: errorResult(doorFailureText(door, response)), envelope: null };
|
|
84
|
+
}
|
|
85
|
+
const reply = response.body;
|
|
86
|
+
if (reply.ok === false || typeof reply.reply !== "string" || reply.reply.length === 0) {
|
|
87
|
+
log(`${TAG} turn empty ${JSON.stringify({
|
|
88
|
+
door,
|
|
89
|
+
reason: reply.error ?? "no_reply",
|
|
90
|
+
elapsed_ms: Date.now() - startedAt,
|
|
91
|
+
})}`);
|
|
92
|
+
return {
|
|
93
|
+
result: errorResult(`JARVIS did not answer ${door} (${reply.error ?? "no_reply"}). ${reply.reply ?? "Tower returned no text."}`),
|
|
94
|
+
envelope: null,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
const envelope = buildJarvisAnswerEnvelope({
|
|
98
|
+
reply: reply.reply,
|
|
99
|
+
thread: reply.thread ?? (typeof body.thread === "string" ? body.thread : null),
|
|
100
|
+
traceId: reply.traceId,
|
|
101
|
+
traceThread: reply.traceThread,
|
|
102
|
+
modelFallback: reply.model?.fallback,
|
|
103
|
+
revised: reply.revised,
|
|
104
|
+
trace: reply.trace,
|
|
105
|
+
});
|
|
106
|
+
remember({ turnId: envelope.turn_id, traceThreadId: envelope.trace_thread_id });
|
|
107
|
+
log(`${TAG} answered ${JSON.stringify({
|
|
108
|
+
door,
|
|
109
|
+
reply_chars: envelope.answer.length,
|
|
110
|
+
sources: envelope.sources.length,
|
|
111
|
+
trace_steps: reply.trace?.length ?? 0,
|
|
112
|
+
degraded: envelope.degraded,
|
|
113
|
+
degraded_reasons: envelope.degraded_reasons,
|
|
114
|
+
has_turn_id: envelope.turn_id !== null,
|
|
115
|
+
elapsed_ms: Date.now() - startedAt,
|
|
116
|
+
})}`);
|
|
117
|
+
const footer = [
|
|
118
|
+
envelope.turn_id ? `turn_id: ${envelope.turn_id} (open it with jarvis_trace)` : null,
|
|
119
|
+
envelope.thread_id ? `thread_id: ${envelope.thread_id}` : null,
|
|
120
|
+
envelope.degraded ? `degraded: ${envelope.degraded_reasons.join(", ")}` : null,
|
|
121
|
+
]
|
|
122
|
+
.filter((line) => line !== null)
|
|
123
|
+
.join("\n");
|
|
124
|
+
return {
|
|
125
|
+
result: textResult(`${envelope.answer}\n\n---\n${footer}`, { ...envelope }),
|
|
126
|
+
envelope,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Which turn `last` means. Three sources, newest knowledge first: this
|
|
131
|
+
* server's own memory of the turn it just took, then the bookmark
|
|
132
|
+
* `cockpit jarvis` writes on the same machine, then — when the bookmark knew
|
|
133
|
+
* only the conversation — that conversation's newest recorded turn.
|
|
134
|
+
*/
|
|
135
|
+
export async function resolveLastTurn(deps, session, lastTurn) {
|
|
136
|
+
if (lastTurn.turnId)
|
|
137
|
+
return { ok: true, traceId: lastTurn.turnId, from: "server_memory" };
|
|
138
|
+
const bookmark = await (deps.readBookmark ?? readCollectorTurnBookmark)();
|
|
139
|
+
const threadId = lastTurn.traceThreadId ?? bookmark?.threadId ?? null;
|
|
140
|
+
if (bookmark?.traceId)
|
|
141
|
+
return { ok: true, traceId: bookmark.traceId, from: "bookmark" };
|
|
142
|
+
if (threadId) {
|
|
143
|
+
const response = await callAgentDoor(session, deps.fetchImpl, "GET", `/api/ops/trace?thread=${encodeURIComponent(threadId)}&limit=1`, undefined, READ_TIMEOUT_MS);
|
|
144
|
+
const traces = response.ok
|
|
145
|
+
? (response.body.traces ?? [])
|
|
146
|
+
: [];
|
|
147
|
+
const newest = traces[0]?.traceId;
|
|
148
|
+
if (newest)
|
|
149
|
+
return { ok: true, traceId: newest, from: "thread" };
|
|
150
|
+
return {
|
|
151
|
+
ok: false,
|
|
152
|
+
reason: "no_traces_in_thread",
|
|
153
|
+
message: "That conversation has no recorded turns inside the 30-day retention. Ask something with "
|
|
154
|
+
+ "jarvis_ask and trace that turn, or pass a turn_id.",
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
return {
|
|
158
|
+
ok: false,
|
|
159
|
+
reason: "no_remembered_turn",
|
|
160
|
+
message: 'Nothing has been asked on this machine yet, so "last" names no turn. Call jarvis_ask first, '
|
|
161
|
+
+ "then pass the turn_id it returns.",
|
|
162
|
+
};
|
|
163
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `jarvis_*` MCP tools (BLI-3732) — JARVIS on the `bli-tower` server, over the
|
|
3
|
+
* exact doors `cockpit jarvis` calls with this machine's device token.
|
|
4
|
+
*
|
|
5
|
+
* The point of this file in one sentence: an agent on Codex or Claude Code
|
|
6
|
+
* should be able to ask JARVIS a question, read how it answered, and spend a
|
|
7
|
+
* human's coding-arm approval, without a browser and without a second
|
|
8
|
+
* vocabulary. Four tools, three doors, no new server-side surface:
|
|
9
|
+
*
|
|
10
|
+
* jarvis_ask POST /api/jarvis/cli one turn, the same one
|
|
11
|
+
* `cockpit jarvis` takes
|
|
12
|
+
* jarvis_trace GET /api/ops/trace/<id> that turn's step tree, rendered
|
|
13
|
+
* SERVER-side and printed verbatim
|
|
14
|
+
* jarvis_dispatch POST /api/jarvis/cli a coding-arm turn, carrying the
|
|
15
|
+
* jarvis_check person's own approval code
|
|
16
|
+
*
|
|
17
|
+
* The wire mechanics they share — the session gate, the request the dashboard
|
|
18
|
+
* actually receives, the envelope it becomes, and what `last` resolves to —
|
|
19
|
+
* live in `jarvis-door.ts`, so this file reads as four tool descriptions,
|
|
20
|
+
* which is the part a person and a model both have to understand.
|
|
21
|
+
*
|
|
22
|
+
* ## The one contract
|
|
23
|
+
*
|
|
24
|
+
* `jarvis_ask` answers with `jarvis-answer-envelope.ts` — the same object
|
|
25
|
+
* `cockpit jarvis --json` prints. A script and an agent read one shape.
|
|
26
|
+
*
|
|
27
|
+
* ## The approval code, and what this surface can and cannot prove
|
|
28
|
+
*
|
|
29
|
+
* The coding arm's gate (BLI-2981) has two locks: the code is an HMAC over the
|
|
30
|
+
* exact plan folded with the asking account, and it must have come out of the
|
|
31
|
+
* HUMAN's own message. Lock 1 is untouched here — this server holds no secret
|
|
32
|
+
* and cannot compute a code for any plan, so an invented code is refused by
|
|
33
|
+
* the dashboard exactly as it always was.
|
|
34
|
+
*
|
|
35
|
+
* Lock 2 is weaker on THIS surface than it is in a terminal, and saying so is
|
|
36
|
+
* the honest thing to do. In a terminal the person types the code themselves.
|
|
37
|
+
* Through MCP the code arrives as a tool argument, and the dashboard cannot
|
|
38
|
+
* tell a code a person handed their agent from one the agent lifted out of the
|
|
39
|
+
* previous answer by itself. So: this tool takes the code as a parameter, it
|
|
40
|
+
* NEVER derives, guesses or fabricates one, its description tells the model in
|
|
41
|
+
* plain words that the code must come from the person, and every relay is
|
|
42
|
+
* logged (presence and length only, never the code). Anything stronger — a
|
|
43
|
+
* per-code single use, an out-of-band confirmation — is a server-side change
|
|
44
|
+
* to the gate itself and belongs with the gate, not here.
|
|
45
|
+
*/
|
|
46
|
+
import { type JarvisDeps } from "./jarvis-door.js";
|
|
47
|
+
export type { JarvisDeps } from "./jarvis-door.js";
|
|
48
|
+
export declare function registerJarvisTools(server: {
|
|
49
|
+
registerTool: (...args: never[]) => unknown;
|
|
50
|
+
}, deps: JarvisDeps): void;
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `jarvis_*` MCP tools (BLI-3732) — JARVIS on the `bli-tower` server, over the
|
|
3
|
+
* exact doors `cockpit jarvis` calls with this machine's device token.
|
|
4
|
+
*
|
|
5
|
+
* The point of this file in one sentence: an agent on Codex or Claude Code
|
|
6
|
+
* should be able to ask JARVIS a question, read how it answered, and spend a
|
|
7
|
+
* human's coding-arm approval, without a browser and without a second
|
|
8
|
+
* vocabulary. Four tools, three doors, no new server-side surface:
|
|
9
|
+
*
|
|
10
|
+
* jarvis_ask POST /api/jarvis/cli one turn, the same one
|
|
11
|
+
* `cockpit jarvis` takes
|
|
12
|
+
* jarvis_trace GET /api/ops/trace/<id> that turn's step tree, rendered
|
|
13
|
+
* SERVER-side and printed verbatim
|
|
14
|
+
* jarvis_dispatch POST /api/jarvis/cli a coding-arm turn, carrying the
|
|
15
|
+
* jarvis_check person's own approval code
|
|
16
|
+
*
|
|
17
|
+
* The wire mechanics they share — the session gate, the request the dashboard
|
|
18
|
+
* actually receives, the envelope it becomes, and what `last` resolves to —
|
|
19
|
+
* live in `jarvis-door.ts`, so this file reads as four tool descriptions,
|
|
20
|
+
* which is the part a person and a model both have to understand.
|
|
21
|
+
*
|
|
22
|
+
* ## The one contract
|
|
23
|
+
*
|
|
24
|
+
* `jarvis_ask` answers with `jarvis-answer-envelope.ts` — the same object
|
|
25
|
+
* `cockpit jarvis --json` prints. A script and an agent read one shape.
|
|
26
|
+
*
|
|
27
|
+
* ## The approval code, and what this surface can and cannot prove
|
|
28
|
+
*
|
|
29
|
+
* The coding arm's gate (BLI-2981) has two locks: the code is an HMAC over the
|
|
30
|
+
* exact plan folded with the asking account, and it must have come out of the
|
|
31
|
+
* HUMAN's own message. Lock 1 is untouched here — this server holds no secret
|
|
32
|
+
* and cannot compute a code for any plan, so an invented code is refused by
|
|
33
|
+
* the dashboard exactly as it always was.
|
|
34
|
+
*
|
|
35
|
+
* Lock 2 is weaker on THIS surface than it is in a terminal, and saying so is
|
|
36
|
+
* the honest thing to do. In a terminal the person types the code themselves.
|
|
37
|
+
* Through MCP the code arrives as a tool argument, and the dashboard cannot
|
|
38
|
+
* tell a code a person handed their agent from one the agent lifted out of the
|
|
39
|
+
* previous answer by itself. So: this tool takes the code as a parameter, it
|
|
40
|
+
* NEVER derives, guesses or fabricates one, its description tells the model in
|
|
41
|
+
* plain words that the code must come from the person, and every relay is
|
|
42
|
+
* logged (presence and length only, never the code). Anything stronger — a
|
|
43
|
+
* per-code single use, an out-of-band confirmation — is a server-side change
|
|
44
|
+
* to the gate itself and belongs with the gate, not here.
|
|
45
|
+
*/
|
|
46
|
+
import { z } from "zod";
|
|
47
|
+
import { callAgentDoor } from "./agent-door.js";
|
|
48
|
+
import { APPROVAL_CODE, defaultLog, doorFailureText, errorResult, READ_TIMEOUT_MS, resolveLastTurn, TAG, takeTurn, textResult, withSession, } from "./jarvis-door.js";
|
|
49
|
+
export function registerJarvisTools(server, deps) {
|
|
50
|
+
const register = server.registerTool.bind(server);
|
|
51
|
+
// The newest turn this SERVER took, so `jarvis_trace last` works in a fresh
|
|
52
|
+
// process that has asked something but never opened a terminal. It is only
|
|
53
|
+
// ever read here; the collector's own bookmark file is read as a fallback
|
|
54
|
+
// and deliberately never written, so a background agent cannot clobber the
|
|
55
|
+
// turn a person is in the middle of tracing.
|
|
56
|
+
let lastTurn = { turnId: null, traceThreadId: null };
|
|
57
|
+
const remember = (turn) => {
|
|
58
|
+
if (turn.turnId || turn.traceThreadId)
|
|
59
|
+
lastTurn = turn;
|
|
60
|
+
};
|
|
61
|
+
register("jarvis_ask", {
|
|
62
|
+
title: "Ask JARVIS",
|
|
63
|
+
description: "Asks JARVIS one question and returns its answer, the Source: lines behind it, this turn's "
|
|
64
|
+
+ "turn_id and the conversation's thread_id. Same JARVIS, same tool belt and same evidence "
|
|
65
|
+
+ "rules as Tower web chat, the Slack DM and `cockpit jarvis`. Pass thread to continue an "
|
|
66
|
+
+ "earlier conversation; pass the returned turn_id to jarvis_trace to see how the answer was "
|
|
67
|
+
+ "reached. JARVIS speaks as the person this machine is paired to and cannot be made to "
|
|
68
|
+
+ "speak as anyone else.",
|
|
69
|
+
inputSchema: {
|
|
70
|
+
question: z.string().min(1).max(50_000).describe("What to ask, in plain words."),
|
|
71
|
+
thread: z
|
|
72
|
+
.string()
|
|
73
|
+
.min(1)
|
|
74
|
+
.max(200)
|
|
75
|
+
.optional()
|
|
76
|
+
.describe('The conversation to continue. Omit for "main", the default terminal thread.'),
|
|
77
|
+
subject: z
|
|
78
|
+
.string()
|
|
79
|
+
.min(1)
|
|
80
|
+
.max(200)
|
|
81
|
+
.optional()
|
|
82
|
+
.describe("Whose person page the question is ABOUT (a name or email). Never who is authenticated — "
|
|
83
|
+
+ "the server decides what this caller may read about them."),
|
|
84
|
+
date: z
|
|
85
|
+
.string()
|
|
86
|
+
.min(1)
|
|
87
|
+
.max(20)
|
|
88
|
+
.optional()
|
|
89
|
+
.describe('YYYY-MM-DD, "today" or "yesterday" — binds that day\'s page. Sent verbatim.'),
|
|
90
|
+
model: z
|
|
91
|
+
.string()
|
|
92
|
+
.min(1)
|
|
93
|
+
.max(120)
|
|
94
|
+
.optional()
|
|
95
|
+
.describe("A provider:model key to request. An unknown key is refused by the dashboard."),
|
|
96
|
+
},
|
|
97
|
+
}, async (args) => withSession(deps, async (session) => {
|
|
98
|
+
const { result } = await takeTurn(deps, session, "jarvis_ask", {
|
|
99
|
+
question: String(args.question ?? ""),
|
|
100
|
+
thread: typeof args.thread === "string" ? args.thread : "main",
|
|
101
|
+
...(typeof args.subject === "string" ? { subject: args.subject } : {}),
|
|
102
|
+
...(typeof args.date === "string" ? { date: args.date } : {}),
|
|
103
|
+
...(typeof args.model === "string" ? { model: args.model } : {}),
|
|
104
|
+
}, remember);
|
|
105
|
+
return result;
|
|
106
|
+
}));
|
|
107
|
+
register("jarvis_trace", {
|
|
108
|
+
title: "How JARVIS answered",
|
|
109
|
+
description: "The step tree of one JARVIS turn — every model step, tool call and server-side memory step, "
|
|
110
|
+
+ "with how long each took, which model ran it, what it spent and what failed. This is the "
|
|
111
|
+
+ "same tree `cockpit jarvis --trace` prints, rendered by the server and relayed verbatim. "
|
|
112
|
+
+ 'Metadata only: no prompt, no answer, no tool argument. Pass "last" for the newest turn '
|
|
113
|
+
+ "this server asked, or the turn_id jarvis_ask returned.",
|
|
114
|
+
inputSchema: {
|
|
115
|
+
turn_id: z
|
|
116
|
+
.string()
|
|
117
|
+
.min(1)
|
|
118
|
+
.max(200)
|
|
119
|
+
.describe('A turn_id from jarvis_ask, or "last".'),
|
|
120
|
+
},
|
|
121
|
+
}, async (args) => withSession(deps, async (session) => {
|
|
122
|
+
const log = deps.log ?? defaultLog;
|
|
123
|
+
const requested = String(args.turn_id ?? "");
|
|
124
|
+
let traceId = requested;
|
|
125
|
+
let resolvedFrom = "argument";
|
|
126
|
+
if (requested.toLowerCase() === "last") {
|
|
127
|
+
const resolved = await resolveLastTurn(deps, session, lastTurn);
|
|
128
|
+
if (!resolved.ok) {
|
|
129
|
+
log(`${TAG} trace unavailable ${JSON.stringify({ reason: resolved.reason })}`);
|
|
130
|
+
return errorResult(resolved.message);
|
|
131
|
+
}
|
|
132
|
+
traceId = resolved.traceId;
|
|
133
|
+
resolvedFrom = resolved.from;
|
|
134
|
+
}
|
|
135
|
+
const response = await callAgentDoor(session, deps.fetchImpl, "GET", `/api/ops/trace/${encodeURIComponent(traceId)}`, undefined, READ_TIMEOUT_MS);
|
|
136
|
+
if (!response.ok)
|
|
137
|
+
return errorResult(doorFailureText("jarvis_trace", response));
|
|
138
|
+
const body = response.body;
|
|
139
|
+
if (body.ok === false) {
|
|
140
|
+
log(`${TAG} trace unavailable ${JSON.stringify({ reason: body.error ?? "trace_unavailable" })}`);
|
|
141
|
+
return errorResult(`Tower has no step tree for that turn (${body.error ?? "trace_unavailable"}). `
|
|
142
|
+
+ (body.message ?? "Traces are kept for 30 days."));
|
|
143
|
+
}
|
|
144
|
+
const lines = body.lines ?? [];
|
|
145
|
+
log(`${TAG} trace read ${JSON.stringify({
|
|
146
|
+
resolved_from: resolvedFrom,
|
|
147
|
+
lines: lines.length,
|
|
148
|
+
truncated: body.truncated ?? false,
|
|
149
|
+
})}`);
|
|
150
|
+
const rendered = [
|
|
151
|
+
`Turn ${traceId}`,
|
|
152
|
+
...(body.headline ? [body.headline] : []),
|
|
153
|
+
"",
|
|
154
|
+
...(lines.length > 0 ? lines : ["That turn recorded no steps."]),
|
|
155
|
+
...(body.truncated ? ["", "Only the first 1000 spans of this turn are shown."] : []),
|
|
156
|
+
].join("\n");
|
|
157
|
+
return textResult(rendered, {
|
|
158
|
+
turn_id: traceId,
|
|
159
|
+
resolved_from: resolvedFrom,
|
|
160
|
+
headline: body.headline ?? null,
|
|
161
|
+
lines,
|
|
162
|
+
truncated: body.truncated ?? false,
|
|
163
|
+
...(body.trace ? { trace: body.trace } : {}),
|
|
164
|
+
});
|
|
165
|
+
}));
|
|
166
|
+
register("jarvis_dispatch", {
|
|
167
|
+
title: "Dispatch a coding task to JARVIS's coding arm",
|
|
168
|
+
description: "Asks JARVIS to run a coding task on the runner: it clones the repo, works, pushes a branch "
|
|
169
|
+
+ "and opens a pull request. It never merges. TWO CALLS, always. Call this once WITHOUT "
|
|
170
|
+
+ "approval_code to get the plan and the 8-character confirmation code back; show both to the "
|
|
171
|
+
+ "person; call it again with the code THEY give you and the same instruction. Never derive, "
|
|
172
|
+
+ "guess, or reuse a code the person has not just handed you — a code you produce yourself is "
|
|
173
|
+
+ "exactly what the approval gate exists to refuse.",
|
|
174
|
+
inputSchema: {
|
|
175
|
+
instruction: z
|
|
176
|
+
.string()
|
|
177
|
+
.min(1)
|
|
178
|
+
.max(50_000)
|
|
179
|
+
.describe("What the coding task should do, in full. This is the birth prompt the arm runs."),
|
|
180
|
+
repo: z
|
|
181
|
+
.string()
|
|
182
|
+
.min(1)
|
|
183
|
+
.max(200)
|
|
184
|
+
.optional()
|
|
185
|
+
.describe("Which repo, if the instruction does not already name one. Server-side allowlist applies."),
|
|
186
|
+
approval_code: z
|
|
187
|
+
.string()
|
|
188
|
+
.min(1)
|
|
189
|
+
.max(64)
|
|
190
|
+
.optional()
|
|
191
|
+
.describe("The 8-character hex confirmation code THE PERSON read back to you from the previous "
|
|
192
|
+
+ "call's plan. Omit it on the first call."),
|
|
193
|
+
thread: z.string().min(1).max(200).optional().describe('Defaults to "main".'),
|
|
194
|
+
},
|
|
195
|
+
}, async (args) => withSession(deps, async (session) => {
|
|
196
|
+
const log = deps.log ?? defaultLog;
|
|
197
|
+
const code = typeof args.approval_code === "string" ? args.approval_code.trim() : "";
|
|
198
|
+
if (code.length > 0 && !APPROVAL_CODE.test(code)) {
|
|
199
|
+
// Refused HERE rather than relayed: a malformed code cannot be the
|
|
200
|
+
// one JARVIS issued, and pasting it into the question would put
|
|
201
|
+
// unvalidated text where the server scans for approvals.
|
|
202
|
+
log(`${TAG} dispatch refused ${JSON.stringify({ reason: "approval_code_malformed", chars: code.length })}`);
|
|
203
|
+
return errorResult("That approval code is not a confirmation code (it must be exactly 8 hex characters, "
|
|
204
|
+
+ "e.g. 3f9c1a02). Ask the person for the code JARVIS printed with the plan — do not "
|
|
205
|
+
+ "construct one.");
|
|
206
|
+
}
|
|
207
|
+
log(`${TAG} dispatch relay ${JSON.stringify({
|
|
208
|
+
approval_code_present: code.length > 0,
|
|
209
|
+
instruction_chars: String(args.instruction ?? "").length,
|
|
210
|
+
repo_named: typeof args.repo === "string",
|
|
211
|
+
})}`);
|
|
212
|
+
const question = [
|
|
213
|
+
code.length > 0
|
|
214
|
+
? "Dispatch this coding task to the coding arm now."
|
|
215
|
+
: "Propose a coding task for the coding arm. Do not dispatch it yet — read back the plan and the confirmation code so the person can approve it.",
|
|
216
|
+
...(typeof args.repo === "string" ? [`Repo: ${args.repo}`] : []),
|
|
217
|
+
"",
|
|
218
|
+
String(args.instruction ?? ""),
|
|
219
|
+
...(code.length > 0
|
|
220
|
+
? ["", `The person approved this plan. Confirmation code: ${code.toLowerCase()}`]
|
|
221
|
+
: []),
|
|
222
|
+
].join("\n");
|
|
223
|
+
const { result } = await takeTurn(deps, session, "jarvis_dispatch", { question, thread: typeof args.thread === "string" ? args.thread : "main" }, remember);
|
|
224
|
+
return result;
|
|
225
|
+
}));
|
|
226
|
+
register("jarvis_check", {
|
|
227
|
+
title: "Check a dispatched coding task",
|
|
228
|
+
description: "Asks JARVIS where a coding task it dispatched has got to — queued, running, pushed, or "
|
|
229
|
+
+ "failed, and the pull request when there is one. Takes no approval: reading a task's state "
|
|
230
|
+
+ "starts nothing.",
|
|
231
|
+
inputSchema: {
|
|
232
|
+
task: z
|
|
233
|
+
.string()
|
|
234
|
+
.min(1)
|
|
235
|
+
.max(200)
|
|
236
|
+
.optional()
|
|
237
|
+
.describe("The task id JARVIS named when it dispatched. Omit to ask about the recent ones."),
|
|
238
|
+
thread: z.string().min(1).max(200).optional().describe('Defaults to "main".'),
|
|
239
|
+
},
|
|
240
|
+
}, async (args) => withSession(deps, async (session) => {
|
|
241
|
+
const task = typeof args.task === "string" ? args.task.trim() : "";
|
|
242
|
+
const question = task.length > 0
|
|
243
|
+
? `Check the coding task ${task} and tell me its status, its branch and its pull request if it has one.`
|
|
244
|
+
: "Check the coding tasks dispatched for me recently and tell me the status of each.";
|
|
245
|
+
const { result } = await takeTurn(deps, session, "jarvis_check", { question, thread: typeof args.thread === "string" ? args.thread : "main" }, remember);
|
|
246
|
+
return result;
|
|
247
|
+
}));
|
|
248
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The turn `cockpit jarvis --trace last` would open, read from the same
|
|
3
|
+
* bookmark the collector writes (BLI-3560, read here for BLI-3732).
|
|
4
|
+
*
|
|
5
|
+
* `jarvis_trace last` prefers the turn THIS server just took. This file is the
|
|
6
|
+
* fallback for the other case: a fresh MCP process that has asked nothing yet,
|
|
7
|
+
* on a machine where the person has been talking to JARVIS in a terminal. Both
|
|
8
|
+
* surfaces then mean the same turn by "last", which is the whole point.
|
|
9
|
+
*
|
|
10
|
+
* READ ONLY, deliberately. A background agent asking questions must not
|
|
11
|
+
* overwrite the bookmark a person is in the middle of tracing, so this server
|
|
12
|
+
* never writes the file — only `cockpit jarvis` does.
|
|
13
|
+
*
|
|
14
|
+
* The file holds two opaque ids and a timestamp. No question, no answer, no
|
|
15
|
+
* name. Nothing here ever throws: a missing or unreadable bookmark is "nothing
|
|
16
|
+
* is remembered", which is a real answer and the caller says so in a sentence.
|
|
17
|
+
*/
|
|
18
|
+
/** The collector's own name for it — `jarvis-trace.ts` `LAST_TURN_FILE`. */
|
|
19
|
+
export declare const LAST_TURN_FILE = "last-turn-trace.json";
|
|
20
|
+
export interface CollectorTurnBookmark {
|
|
21
|
+
traceId: string | null;
|
|
22
|
+
threadId: string | null;
|
|
23
|
+
}
|
|
24
|
+
export declare function collectorTurnBookmarkPath(homeDir?: string): string;
|
|
25
|
+
export declare function readCollectorTurnBookmark(homeDir?: string): Promise<CollectorTurnBookmark | null>;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The turn `cockpit jarvis --trace last` would open, read from the same
|
|
3
|
+
* bookmark the collector writes (BLI-3560, read here for BLI-3732).
|
|
4
|
+
*
|
|
5
|
+
* `jarvis_trace last` prefers the turn THIS server just took. This file is the
|
|
6
|
+
* fallback for the other case: a fresh MCP process that has asked nothing yet,
|
|
7
|
+
* on a machine where the person has been talking to JARVIS in a terminal. Both
|
|
8
|
+
* surfaces then mean the same turn by "last", which is the whole point.
|
|
9
|
+
*
|
|
10
|
+
* READ ONLY, deliberately. A background agent asking questions must not
|
|
11
|
+
* overwrite the bookmark a person is in the middle of tracing, so this server
|
|
12
|
+
* never writes the file — only `cockpit jarvis` does.
|
|
13
|
+
*
|
|
14
|
+
* The file holds two opaque ids and a timestamp. No question, no answer, no
|
|
15
|
+
* name. Nothing here ever throws: a missing or unreadable bookmark is "nothing
|
|
16
|
+
* is remembered", which is a real answer and the caller says so in a sentence.
|
|
17
|
+
*/
|
|
18
|
+
import { readFile } from "node:fs/promises";
|
|
19
|
+
import os from "node:os";
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
import { getUserLocalCockpitPaths } from "@bli-cockpit/telemetry-core";
|
|
22
|
+
/** The collector's own name for it — `jarvis-trace.ts` `LAST_TURN_FILE`. */
|
|
23
|
+
export const LAST_TURN_FILE = "last-turn-trace.json";
|
|
24
|
+
export function collectorTurnBookmarkPath(homeDir = os.homedir()) {
|
|
25
|
+
return path.join(getUserLocalCockpitPaths(homeDir).state_dir, LAST_TURN_FILE);
|
|
26
|
+
}
|
|
27
|
+
export async function readCollectorTurnBookmark(homeDir) {
|
|
28
|
+
try {
|
|
29
|
+
const raw = await readFile(collectorTurnBookmarkPath(homeDir), "utf8");
|
|
30
|
+
const parsed = JSON.parse(raw);
|
|
31
|
+
const traceId = typeof parsed.traceId === "string" ? parsed.traceId : null;
|
|
32
|
+
const threadId = typeof parsed.threadId === "string" ? parsed.threadId : null;
|
|
33
|
+
if (!traceId && !threadId)
|
|
34
|
+
return null;
|
|
35
|
+
return { traceId, threadId };
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
// Missing, unreadable, or not JSON — all three mean the same thing to the
|
|
39
|
+
// one caller, which names it (`no_remembered_turn`) rather than printing
|
|
40
|
+
// nothing.
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
package/dist/server.d.ts
CHANGED
|
@@ -19,7 +19,7 @@ export interface ServerDeps {
|
|
|
19
19
|
fetchImpl: FetchImpl;
|
|
20
20
|
}
|
|
21
21
|
export declare const PACKAGE_NAME = "@bli-cockpit/mcp";
|
|
22
|
-
export declare const PACKAGE_VERSION = "0.1.
|
|
22
|
+
export declare const PACKAGE_VERSION = "0.1.2";
|
|
23
23
|
export declare const emitEventInput: {
|
|
24
24
|
ticket_id: z.ZodString;
|
|
25
25
|
event_type: z.ZodString;
|
package/dist/server.js
CHANGED
|
@@ -7,9 +7,10 @@
|
|
|
7
7
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
8
8
|
import { z } from "zod";
|
|
9
9
|
import { registerDocsMsgTools } from "./docs-msg-tools.js";
|
|
10
|
+
import { registerJarvisTools } from "./jarvis-tools.js";
|
|
10
11
|
import { registerWorkTools } from "./work-tools.js";
|
|
11
12
|
export const PACKAGE_NAME = "@bli-cockpit/mcp";
|
|
12
|
-
export const PACKAGE_VERSION = "0.1.
|
|
13
|
+
export const PACKAGE_VERSION = "0.1.2";
|
|
13
14
|
// ---- input schemas (Zod raw shapes) -----------------------------------------
|
|
14
15
|
export const emitEventInput = {
|
|
15
16
|
ticket_id: z
|
|
@@ -375,5 +376,9 @@ export function createServer(deps) {
|
|
|
375
376
|
// the docs/msg tools above, for the same reason: a coding session should
|
|
376
377
|
// file and move a Tower issue the way it files and moves a Linear one.
|
|
377
378
|
registerWorkTools(server, { fetchImpl: deps.fetchImpl });
|
|
379
|
+
// BLI-3732: `jarvis_*` — the assistant itself, on the same device-token path
|
|
380
|
+
// as the three families above, over the doors `cockpit jarvis` already
|
|
381
|
+
// calls. It is the last Tower surface that had a CLI door and no MCP one.
|
|
382
|
+
registerJarvisTools(server, { fetchImpl: deps.fetchImpl });
|
|
378
383
|
return server;
|
|
379
384
|
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every `cockpit <noun> <verb>` that talks to Tower, read off the collector's
|
|
3
|
+
* own decision tables (BLI-3732).
|
|
4
|
+
*
|
|
5
|
+
* This is a test helper with no runtime consumer, kept out of the suite file
|
|
6
|
+
* so the census itself — the three lists a person actually reviews — reads as
|
|
7
|
+
* a list and not as a parser. It reads SOURCE TEXT rather than importing the
|
|
8
|
+
* collector, because this package declares no dependency on it (the same
|
|
9
|
+
* reason `agent-door-session.ts` copies a session reader instead of importing
|
|
10
|
+
* one).
|
|
11
|
+
*
|
|
12
|
+
* ## Where each half comes from
|
|
13
|
+
*
|
|
14
|
+
* - The NOUNS are the `parse<Noun>Args` functions `local-args-tower.ts`
|
|
15
|
+
* re-exports. That file is the authoritative "these are the commands that
|
|
16
|
+
* talk to Tower" table — its siblings are a filing decision, not a contract.
|
|
17
|
+
* - The VERBS are each noun's `action:` union in `LocalCommand`
|
|
18
|
+
* (`local-args.ts`), with an `XAction` alias resolved from whichever
|
|
19
|
+
* `local-args-tower-*.ts` declares it. A noun with no `action` field is one
|
|
20
|
+
* verb wearing the noun's own name (`jarvis`, `correct`, `workbook`).
|
|
21
|
+
*
|
|
22
|
+
* ## What it deliberately does NOT see
|
|
23
|
+
*
|
|
24
|
+
* Mode FLAGS. `cockpit jarvis --trace`, `--threads` and `--history` are three
|
|
25
|
+
* different acts behind one noun, and no decision table calls them verbs, so
|
|
26
|
+
* the mechanical rule cannot find them. They are covered by hand in the
|
|
27
|
+
* census's own twin map (`jarvis --trace` → `jarvis_trace`); a flag that grows
|
|
28
|
+
* into a verb will show up here the moment it becomes an `action`.
|
|
29
|
+
*/
|
|
30
|
+
export declare const COLLECTOR_COMMANDS_DIR: string;
|
|
31
|
+
export interface TowerVerb {
|
|
32
|
+
noun: string;
|
|
33
|
+
/** The action word, or "" for a noun that is its own single verb. */
|
|
34
|
+
action: string;
|
|
35
|
+
/** How a person types it: `docs read`, or just `jarvis`. */
|
|
36
|
+
spelling: string;
|
|
37
|
+
}
|
|
38
|
+
/** `parseJarvisArgs` → `jarvis`. The re-export table IS the noun list. */
|
|
39
|
+
export declare function towerNouns(): string[];
|
|
40
|
+
/** Every Tower verb a person may type today. */
|
|
41
|
+
export declare function towerVerbs(): TowerVerb[];
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every `cockpit <noun> <verb>` that talks to Tower, read off the collector's
|
|
3
|
+
* own decision tables (BLI-3732).
|
|
4
|
+
*
|
|
5
|
+
* This is a test helper with no runtime consumer, kept out of the suite file
|
|
6
|
+
* so the census itself — the three lists a person actually reviews — reads as
|
|
7
|
+
* a list and not as a parser. It reads SOURCE TEXT rather than importing the
|
|
8
|
+
* collector, because this package declares no dependency on it (the same
|
|
9
|
+
* reason `agent-door-session.ts` copies a session reader instead of importing
|
|
10
|
+
* one).
|
|
11
|
+
*
|
|
12
|
+
* ## Where each half comes from
|
|
13
|
+
*
|
|
14
|
+
* - The NOUNS are the `parse<Noun>Args` functions `local-args-tower.ts`
|
|
15
|
+
* re-exports. That file is the authoritative "these are the commands that
|
|
16
|
+
* talk to Tower" table — its siblings are a filing decision, not a contract.
|
|
17
|
+
* - The VERBS are each noun's `action:` union in `LocalCommand`
|
|
18
|
+
* (`local-args.ts`), with an `XAction` alias resolved from whichever
|
|
19
|
+
* `local-args-tower-*.ts` declares it. A noun with no `action` field is one
|
|
20
|
+
* verb wearing the noun's own name (`jarvis`, `correct`, `workbook`).
|
|
21
|
+
*
|
|
22
|
+
* ## What it deliberately does NOT see
|
|
23
|
+
*
|
|
24
|
+
* Mode FLAGS. `cockpit jarvis --trace`, `--threads` and `--history` are three
|
|
25
|
+
* different acts behind one noun, and no decision table calls them verbs, so
|
|
26
|
+
* the mechanical rule cannot find them. They are covered by hand in the
|
|
27
|
+
* census's own twin map (`jarvis --trace` → `jarvis_trace`); a flag that grows
|
|
28
|
+
* into a verb will show up here the moment it becomes an `action`.
|
|
29
|
+
*/
|
|
30
|
+
import { readFileSync, readdirSync } from "node:fs";
|
|
31
|
+
import { dirname, join, resolve } from "node:path";
|
|
32
|
+
import { fileURLToPath } from "node:url";
|
|
33
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
34
|
+
export const COLLECTOR_COMMANDS_DIR = resolve(HERE, "../../cockpit-local-collector/src/commands");
|
|
35
|
+
function read(file) {
|
|
36
|
+
return readFileSync(join(COLLECTOR_COMMANDS_DIR, file), "utf8");
|
|
37
|
+
}
|
|
38
|
+
/** Every `local-args-tower*.ts` in the collector, so a new sibling is read too. */
|
|
39
|
+
function towerFiles() {
|
|
40
|
+
return readdirSync(COLLECTOR_COMMANDS_DIR).filter((name) => name.startsWith("local-args-tower") && name.endsWith(".ts") && !name.includes(".test."));
|
|
41
|
+
}
|
|
42
|
+
/** `parseJarvisArgs` → `jarvis`. The re-export table IS the noun list. */
|
|
43
|
+
export function towerNouns() {
|
|
44
|
+
const source = read("local-args-tower.ts");
|
|
45
|
+
const nouns = new Set();
|
|
46
|
+
for (const match of source.matchAll(/\bparse([A-Z][A-Za-z]*)Args\b/g)) {
|
|
47
|
+
nouns.add(match[1].replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase());
|
|
48
|
+
}
|
|
49
|
+
return [...nouns].sort();
|
|
50
|
+
}
|
|
51
|
+
/** `export type NotesAction = "list" | "show" | …` across every tower sibling. */
|
|
52
|
+
function actionAliases() {
|
|
53
|
+
const aliases = new Map();
|
|
54
|
+
for (const file of towerFiles()) {
|
|
55
|
+
const source = read(file);
|
|
56
|
+
for (const match of source.matchAll(/export type (\w+Action)\s*=\s*([\s\S]*?);/g)) {
|
|
57
|
+
aliases.set(match[1], [...match[2].matchAll(/"([a-z-]+)"/g)].map((m) => m[1]));
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return aliases;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* The `LocalCommand` variants, split the way the union is written: one chunk
|
|
64
|
+
* per ` | {` at the top level of the type.
|
|
65
|
+
*/
|
|
66
|
+
function commandVariants() {
|
|
67
|
+
const source = readFileSync(join(COLLECTOR_COMMANDS_DIR, "local-args.ts"), "utf8");
|
|
68
|
+
const start = source.indexOf("export type LocalCommand =");
|
|
69
|
+
if (start < 0)
|
|
70
|
+
throw new Error("LocalCommand union not found in local-args.ts");
|
|
71
|
+
const end = source.indexOf("\nexport ", start + 10);
|
|
72
|
+
return source.slice(start, end < 0 ? undefined : end).split(/\n\s{2}\|\s/);
|
|
73
|
+
}
|
|
74
|
+
/** Every Tower verb a person may type today. */
|
|
75
|
+
export function towerVerbs() {
|
|
76
|
+
const nouns = new Set(towerNouns());
|
|
77
|
+
const aliases = actionAliases();
|
|
78
|
+
const verbs = [];
|
|
79
|
+
const seenNouns = new Set();
|
|
80
|
+
for (const variant of commandVariants()) {
|
|
81
|
+
const kind = /kind:\s*"([a-z-]+)"/.exec(variant)?.[1];
|
|
82
|
+
if (!kind || !nouns.has(kind) || seenNouns.has(kind))
|
|
83
|
+
continue;
|
|
84
|
+
seenNouns.add(kind);
|
|
85
|
+
const action = /\n\s*action:\s*([^;]+);/.exec(variant)?.[1]?.trim();
|
|
86
|
+
if (!action) {
|
|
87
|
+
verbs.push({ noun: kind, action: "", spelling: kind });
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
const words = action.endsWith("Action")
|
|
91
|
+
? (aliases.get(action) ?? [])
|
|
92
|
+
: [...action.matchAll(/"([a-z-]+)"/g)].map((m) => m[1]);
|
|
93
|
+
if (words.length === 0) {
|
|
94
|
+
throw new Error(`No action vocabulary found for cockpit ${kind} (read "${action}")`);
|
|
95
|
+
}
|
|
96
|
+
for (const word of words) {
|
|
97
|
+
verbs.push({ noun: kind, action: word, spelling: `${kind} ${word}` });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const missing = [...nouns].filter((noun) => !seenNouns.has(noun));
|
|
101
|
+
if (missing.length > 0) {
|
|
102
|
+
// A parser with no `LocalCommand` variant means the extraction has drifted
|
|
103
|
+
// from the source, and a census that quietly reads fewer verbs than exist
|
|
104
|
+
// is worse than no census at all.
|
|
105
|
+
throw new Error(`Tower nouns with no LocalCommand variant: ${missing.join(", ")}`);
|
|
106
|
+
}
|
|
107
|
+
return verbs.sort((a, b) => a.spelling.localeCompare(b.spelling));
|
|
108
|
+
}
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bli-cockpit/mcp",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"private": false,
|
|
5
|
-
"description": "bli-tower — an MCP server over BLI Cockpit's
|
|
5
|
+
"description": "bli-tower — an MCP server over BLI Cockpit's agent doors: JARVIS (jarvis_*), documents (docs_*), channels (msg_*), issues (work_*), plus the legacy event-stream tools (emit_event, get_ticket_timeline, get_active_tickets).",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
8
8
|
"bli-cockpit-mcp": "./dist/index.js"
|