ticketlens 0.21.12 → 0.22.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 CHANGED
@@ -411,6 +411,8 @@ Every note is scanned before saving — anything shaped like a real secret (API
411
411
 
412
412
  **Removing a note:** `ticketlens note delete --id="..." [--ticket=KEY]` removes a note from your local vault. Local only — if it was already pushed to a team, teammates who pulled it keep their copy; deleting it there too is a manager action from the Console (Admin > Recall).
413
413
 
414
+ **Any MCP-capable AI harness:** `ticketlens mcp` starts a stdio [MCP](https://modelcontextprotocol.io) server exposing `recall_add` and `recall_search` as native tools — any MCP-compatible AI assistant, not just Claude Code, can call them directly instead of constructing a shell command. It's a thin adapter over the exact same code as the CLI commands above — same Pro gate, same secret scan, same local vault, same team sync — nothing is reimplemented. Point your harness's MCP config at it: `{ "command": "ticketlens", "args": ["mcp"] }`.
415
+
414
416
  `note add`'s save confirmation and `recall`'s search results are styled by default in a terminal; add `--plain` to either for bare, pipe-safe output. `recall` always shows each note's file ID (e.g. `[1784135399545-fe01c4.md]`) so you can open it directly (`cat ~/.ticketlens/recall/<PREFIX>/<id>`), or pass `--full` to print the full body content inline instead.
415
417
 
416
418
  **Gaps** — every `ticketlens PROJ-123` brief also diffs the ticket's own description against its linked tickets (from the depth traversal you already requested) and its own downloaded attachments, looking for requirements mentioned there but missing here. Anything uncovered shows up under a `## Gaps` section, citing exactly where it came from — a linked ticket key or an attachment filename — as evidence, never an instruction to act on. Nothing is saved anywhere; it's recomputed fresh on every fetch. Requires a Pro license, same as Recall. No network call beyond what the brief already made.
@@ -690,6 +692,7 @@ ticketlens recall CNV1-2 # Search saved notes by ticket key
690
692
  ticketlens recall "retry backoff" # Free-text search across all notes [Pro]
691
693
  ticketlens recall sync # Retry any notes stuck in the local queue [Team+]
692
694
  ticketlens recall settings # Show effective retry-queue settings, fetched live [Team+]
695
+ ticketlens mcp # Start the MCP stdio server (recall_add/recall_search tools) [Pro]
693
696
 
694
697
  # ── Stats ──────────────────────────────────────────────────────────────────────
695
698
  ticketlens stats # Response-time metrics from local history
@@ -771,6 +774,7 @@ ticketlens note delete --id="..." # Remove a note from your local vault
771
774
  ticketlens recall <query|TICKET-KEY> # Search your saved Recall notes
772
775
  ticketlens recall sync # Retry any notes stuck in the local queue
773
776
  ticketlens recall settings # Show effective retry-queue settings, fetched live
777
+ ticketlens mcp # Start the MCP stdio server (recall_add/recall_search)
774
778
  ticketlens activate YOUR-LICENSE-KEY # Activate Pro license
775
779
  ```
776
780
 
@@ -27,7 +27,7 @@ import {
27
27
  printComplianceHelp, printLedgerHelp, printPrHelp, printInstallHooksHelp,
28
28
  printCollisionsHelp, printStatsHelp,
29
29
  printCloudKeysHelp,
30
- printNoteHelp, printRecallHelp,
30
+ printNoteHelp, printRecallHelp, printMcpHelp,
31
31
  } from '../skills/jtb/scripts/lib/help.mjs';
32
32
  import { runStats } from '../skills/jtb/scripts/lib/run-stats.mjs';
33
33
  import { createStyler } from '../skills/jtb/scripts/lib/ansi.mjs';
@@ -722,6 +722,15 @@ switch (command) {
722
722
  break;
723
723
  }
724
724
 
725
+ case 'mcp': {
726
+ if (cmdArgs.includes('--help') || cmdArgs.includes('-h')) { printMcpHelp(); break; }
727
+ // Long-lived stdio server, not a one-shot command — no .then()-exit-code
728
+ // pattern; it exits when the client closes stdin (spec's stdio lifecycle).
729
+ const { runMcpServer } = await import('../skills/jtb/scripts/lib/mcp-server.mjs');
730
+ await runMcpServer({});
731
+ break;
732
+ }
733
+
725
734
  case 'help':
726
735
  default: {
727
736
  const isInteractive = args.length === 0 && process.stdin.isTTY && process.stdout.isTTY && !process.env.CI;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ticketlens",
3
- "version": "0.21.12",
3
+ "version": "0.22.1",
4
4
  "description": "Jira CLI for developers — fetch ticket context, triage your queue, and stop tab-switching. Zero dependencies, all local.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,4 +1,4 @@
1
- <!-- jtb-skill-version: 0.21.0 -->
1
+ <!-- jtb-skill-version: 0.22.1 -->
2
2
  ---
3
3
  name: jtb
4
4
  description: Fetch a Jira ticket's full context (description, comments, linked issues, code references) and assemble a structured TicketBrief for implementation planning. Use when user types /jtb, mentions a Jira ticket key, or wants to plan work from a Jira ticket.
@@ -232,6 +232,8 @@ echo "The body text of the note, one or more paragraphs." | \
232
232
 
233
233
  To search saved notes directly (outside of automatic brief injection): `ticketlens recall "<query>"`.
234
234
 
235
+ **Pick exactly one path per capture — never both.** If this harness has TicketLens's MCP server configured (`ticketlens mcp` — `recall_add`/`recall_search` as native tools, see `ticketlens mcp --help`), prefer calling those tools directly over the bash commands above — same license gate, same secret scan, same vault, same team sync, just no shell command to construct. Fall back to the bash form only when the MCP tools aren't available. Calling both for the same insight creates two near-duplicate notes (no dedup exists between the two paths) and, with team sync on, two separate pushes for a manager to review.
236
+
235
237
  ### Quality loop (Pro, in-session only)
236
238
 
237
239
  Only when `note add` above was dispatched *by you, inside this skill*, and it printed a saved note id (e.g. `Saved note "Retry gotcha" (1784135399545-fe01c4.md)`) — never for a note a user typed directly into a bare shell, which has no Task/Agent tool available. If there's no such tool in your environment, skip this whole section silently: no warning, no degraded fallback, the note is already saved and that's a complete, correct outcome on its own.
@@ -126,6 +126,10 @@ export function parseCommand(args) {
126
126
  return { command: 'recall', args: args.slice(1) };
127
127
  }
128
128
 
129
+ if (first === 'mcp') {
130
+ return { command: 'mcp', args: args.slice(1) };
131
+ }
132
+
129
133
  // Anything that looks like a ticket key or any non-flag arg → fetch
130
134
  return { command: 'fetch', args };
131
135
  }
@@ -53,6 +53,7 @@ export function printHelp({ stream = process.stdout } = {}) {
53
53
  ` ${s.brand('ticketlens')} recall ${s.dim('<query|TICKET-KEY>')} Search your saved Recall notes ${s.dim('[Pro]')}`,
54
54
  ` ${s.brand('ticketlens')} recall sync Retry any notes stuck in the local queue ${s.dim('[Team+]')}`,
55
55
  ` ${s.brand('ticketlens')} recall settings Show effective retry-queue settings, fetched live ${s.dim('[Team+]')}`,
56
+ ` ${s.brand('ticketlens')} mcp Start the MCP stdio server for Recall ${s.dim('[Pro]')}`,
56
57
  '',
57
58
  ` ${s.brand('ticketlens')} delete ${s.dim('<PROFILE-NAME>')} Remove a profile`,
58
59
  ` ${s.brand('ticketlens')} activate ${s.dim('<KEY>')} Activate a license key`,
@@ -585,6 +586,34 @@ export function printRecallHelp({ stream = process.stdout } = {}) {
585
586
  stream.write(lines.join('\n') + '\n');
586
587
  }
587
588
 
589
+ export function printMcpHelp({ stream = process.stdout } = {}) {
590
+ const s = createStyler({ isTTY: stream.isTTY });
591
+ const lines = [
592
+ '',
593
+ ` ${s.bold(s.brand('ticketlens'))} ${s.bold('mcp')} ${s.dim('[Pro]')}`,
594
+ '',
595
+ ` Start an MCP (Model Context Protocol) stdio server exposing Recall as`,
596
+ ` native tools — ${s.cyan('recall_add')} and ${s.cyan('recall_search')} — for any MCP-compatible AI`,
597
+ ` harness, not just Claude Code. Thin adapter over the same code as`,
598
+ ` ${s.cyan('note add')}/${s.cyan('recall')} above: same Pro gate, same secret scan, same local`,
599
+ ` vault, same team sync. Long-running — exits when the client closes stdin.`,
600
+ '',
601
+ ` ${s.bold('OPTIONS')}`,
602
+ '',
603
+ ` ${s.brand('-h')}, ${s.brand('--help')} Show this help`,
604
+ '',
605
+ ` ${s.bold('HARNESS CONFIG')}`,
606
+ '',
607
+ ` ${s.dim('{ "command": "ticketlens", "args": ["mcp"] }')}`,
608
+ '',
609
+ ` ${s.bold('EXAMPLES')}`,
610
+ '',
611
+ ` ${s.dim('$')} ticketlens mcp`,
612
+ '',
613
+ ];
614
+ stream.write(lines.join('\n') + '\n');
615
+ }
616
+
588
617
  export function printSwitchHelp({ stream = process.stdout } = {}) {
589
618
  const s = createStyler({ isTTY: stream.isTTY });
590
619
  const lines = [
@@ -0,0 +1,202 @@
1
+ /**
2
+ * MCP (Model Context Protocol) stdio server — `ticketlens mcp`.
3
+ *
4
+ * A pure transport adapter: parses JSON-RPC 2.0 off stdin, translates
5
+ * `tools/call` arguments into the exact args/dependency shape `runNoteAdd`/
6
+ * `runRecall` already accept, and captures their human-readable `stream`
7
+ * output into the JSON-RPC response instead of a real stream. Zero new
8
+ * validation, licensing, or vault logic — both tools funnel through the
9
+ * same functions the CLI's `note add`/`recall` commands already use, so
10
+ * every existing gate (license, secret scan, structural check, retry
11
+ * queue) applies identically here.
12
+ *
13
+ * Per the MCP stdio transport spec, the server MUST NOT write anything to
14
+ * stdout that isn't a valid MCP message — every wrapped function's output
15
+ * is captured into a buffer and returned as the tool result, never piped
16
+ * to the real stdout that also carries the JSON-RPC channel.
17
+ */
18
+
19
+ import readline from 'node:readline';
20
+ import { DEFAULT_CONFIG_DIR, getVersion } from './config.mjs';
21
+ import { runNoteAdd } from './note-command.mjs';
22
+ import { runRecall } from './recall-command.mjs';
23
+
24
+ const PROTOCOL_VERSION = '2025-11-25';
25
+
26
+ const TOOLS = [
27
+ {
28
+ name: 'recall_add',
29
+ description: 'Save a Recall note — a gotcha, root cause, or non-obvious decision learned this session. Requires a TicketLens Pro license.',
30
+ inputSchema: {
31
+ type: 'object',
32
+ properties: {
33
+ title: { type: 'string', description: 'Short one-line title.' },
34
+ ticket: { type: 'string', description: 'Optional ticket key, e.g. PROJ-123.' },
35
+ tags: { type: 'array', items: { type: 'string' }, description: 'Optional tags.' },
36
+ body: { type: 'string', description: 'The note body — one or more paragraphs.' },
37
+ },
38
+ required: ['title', 'body'],
39
+ },
40
+ },
41
+ {
42
+ name: 'recall_search',
43
+ description: 'Search saved Recall notes by free-text query or ticket key. Requires a TicketLens Pro license.',
44
+ inputSchema: {
45
+ type: 'object',
46
+ properties: {
47
+ query: { type: 'string', description: 'Free-text query or a ticket key like PROJ-123.' },
48
+ },
49
+ required: ['query'],
50
+ },
51
+ },
52
+ ];
53
+
54
+ function jsonRpcResult(id, result) {
55
+ return JSON.stringify({ jsonrpc: '2.0', id, result }) + '\n';
56
+ }
57
+
58
+ function jsonRpcError(id, code, message) {
59
+ return JSON.stringify({ jsonrpc: '2.0', id, error: { code, message } }) + '\n';
60
+ }
61
+
62
+ /** Buffers stream.write() calls instead of touching a real stream. */
63
+ function capturingStream() {
64
+ const parts = [];
65
+ return {
66
+ write(s) { parts.push(s); return true; },
67
+ get text() { return parts.join(''); },
68
+ };
69
+ }
70
+
71
+ /**
72
+ * Builds runNoteAdd's cmdArgs array. Each `--flag=value` MUST stay a single,
73
+ * discrete array element — runNoteAdd's parseFlag matches per-element via
74
+ * startsWith/includes, which is only safe as long as this array is never
75
+ * joined into a string and re-split/re-tokenized. A title/body containing
76
+ * `--ticket=EVIL-999` stays inert precisely because it's never anything
77
+ * but one opaque array element.
78
+ */
79
+ function buildNoteAddArgs({ title, ticket, tags }) {
80
+ const args = [`--title=${title}`];
81
+ if (ticket) args.push(`--ticket=${ticket}`);
82
+ if (Array.isArray(tags) && tags.length > 0) args.push(`--tags=${tags.join(',')}`);
83
+ return args;
84
+ }
85
+
86
+ async function callRecallAdd(args, { configDir, runNoteAddFn }) {
87
+ // runNoteAdd's own `if (!rawTitle)` guard only rejects an empty string —
88
+ // `--title=${title}` with title===undefined template-stringifies to the
89
+ // truthy 4-char string "undefined", which would pass that guard and get
90
+ // persisted as a real note title. Reject before it ever reaches cmdArgs.
91
+ if (!args.title) {
92
+ return { isError: true, content: [{ type: 'text', text: 'Missing required argument: title' }] };
93
+ }
94
+ const capture = capturingStream();
95
+ const { written } = await runNoteAddFn(buildNoteAddArgs(args), {
96
+ configDir,
97
+ stream: capture,
98
+ readStdin: async () => args.body ?? '',
99
+ });
100
+ const content = [{ type: 'text', text: capture.text }];
101
+ return written ? { content } : { isError: true, content };
102
+ }
103
+
104
+ async function callRecallSearch(args, { configDir, runRecallFn }) {
105
+ const capture = capturingStream();
106
+ const { ok } = await runRecallFn([args.query ?? ''], {
107
+ configDir,
108
+ stream: capture,
109
+ errorStream: capture,
110
+ });
111
+ const content = [{ type: 'text', text: capture.text }];
112
+ return ok ? { content } : { isError: true, content };
113
+ }
114
+
115
+ async function handleToolsCall(params, deps) {
116
+ const { name, arguments: args = {} } = params ?? {};
117
+ if (name === 'recall_add') return callRecallAdd(args, deps);
118
+ if (name === 'recall_search') return callRecallSearch(args, deps);
119
+ return { isError: true, content: [{ type: 'text', text: `Unknown tool: ${name}` }] };
120
+ }
121
+
122
+ async function handleMessage(raw, { configDir, runNoteAddFn, runRecallFn }) {
123
+ let msg;
124
+ try {
125
+ msg = JSON.parse(raw);
126
+ } catch {
127
+ return jsonRpcError(null, -32700, 'Parse error');
128
+ }
129
+
130
+ if (typeof msg !== 'object' || msg === null || Array.isArray(msg)) {
131
+ return jsonRpcError(null, -32600, 'Invalid Request');
132
+ }
133
+
134
+ const { id, method, params } = msg;
135
+
136
+ if (method === 'notifications/initialized') return null; // notification, no response
137
+
138
+ if (method === 'initialize') {
139
+ return jsonRpcResult(id, {
140
+ protocolVersion: PROTOCOL_VERSION,
141
+ capabilities: { tools: {} },
142
+ serverInfo: { name: 'ticketlens', version: getVersion() },
143
+ });
144
+ }
145
+
146
+ if (method === 'tools/list') {
147
+ return jsonRpcResult(id, { tools: TOOLS });
148
+ }
149
+
150
+ if (method === 'tools/call') {
151
+ try {
152
+ const result = await handleToolsCall(params, { configDir, runNoteAddFn, runRecallFn });
153
+ return jsonRpcResult(id, result);
154
+ } catch (err) {
155
+ return jsonRpcError(id ?? null, -32603, `Internal error: ${err.message}`);
156
+ }
157
+ }
158
+
159
+ return jsonRpcError(id ?? null, -32601, `Method not found: ${method}`);
160
+ }
161
+
162
+ /**
163
+ * Runs the stdio JSON-RPC loop until stdin closes (the client closing
164
+ * stdin to end the session, per the stdio transport's lifecycle). Each
165
+ * line is fully processed — awaited — before the next is handled: a
166
+ * long-lived process (unlike the one-shot CLI) must not let a burst of
167
+ * rapid messages spawn unbounded concurrent tool calls.
168
+ */
169
+ export function runMcpServer({
170
+ configDir = DEFAULT_CONFIG_DIR,
171
+ stdin = process.stdin,
172
+ stdout = process.stdout,
173
+ runNoteAddFn = runNoteAdd,
174
+ runRecallFn = runRecall,
175
+ } = {}) {
176
+ // A client can disconnect mid-write (EPIPE) at any time on a long-lived
177
+ // process — an unhandled 'error' event on either stream would otherwise
178
+ // throw and crash the whole server via Node's default EventEmitter
179
+ // behavior. Swallow here; the process ends naturally when stdin closes.
180
+ stdin.on('error', () => {});
181
+ stdout.on('error', () => {});
182
+
183
+ const rl = readline.createInterface({ input: stdin, terminal: false });
184
+ let queue = Promise.resolve();
185
+
186
+ rl.on('line', (line) => {
187
+ if (!line.trim()) return;
188
+ // .catch() here, not left to propagate: an unrejected chain would
189
+ // otherwise poison every later .then() forever (one bad line kills
190
+ // all subsequent messages) and leave `queue.then(resolve)` below
191
+ // never resolving (a dropped rejection isn't a resolution) — the
192
+ // server would hang on shutdown instead of exiting.
193
+ queue = queue.then(async () => {
194
+ const response = await handleMessage(line, { configDir, runNoteAddFn, runRecallFn });
195
+ if (response) stdout.write(response);
196
+ }).catch(() => {});
197
+ });
198
+
199
+ return new Promise((resolve) => {
200
+ rl.on('close', () => { queue.then(resolve); });
201
+ });
202
+ }
@@ -134,7 +134,7 @@ export async function pushNote(note, {
134
134
  signal: AbortSignal.timeout(timeoutMs),
135
135
  });
136
136
  } catch {
137
- warn(` ${yellow('⚠')} Could not sync note to your team (network error) — saved locally only.\n`);
137
+ warn(` ${yellow('⚠')} Could not sync note to your team (network error) — saved locally and queued to retry automatically.\n`);
138
138
  return { ok: false };
139
139
  }
140
140