gitflic-cli-mcp 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -153,7 +153,22 @@ GITFLIC_MCP_TOOLS="gitflic_mr_list,gitflic_mr_view,gitflic_mr_diff,gitflic_mr_cr
153
153
 
154
154
  > Можно поднять один и тот же сервер дважды под разными именами — напр. `gitflic` (полный) и `gitflic-ro` (`GITFLIC_MCP_READONLY=1`) — и держать оба подключёнными. Либо класть конфиг в проектный `.mcp.json` / `.cursor/mcp.json`, чтобы в каждом репозитории грузились свои группы.
155
155
 
156
- ## Все 76 тулзов
156
+ ## Сжатие ответов (экономия токенов)
157
+
158
+ GitFlic REST API отдаёт «сырой» JSON с кучей бесполезного для LLM шума: URL аватаров/обложек, `hexColor` лейблов, `status` из 9 полей, вложенные объекты юзеров целиком, HAL-обёртки `_embedded`/`_links`. По умолчанию MCP **сжимает** ответы перед отдачей модели: разворачивает list-обёртку в `{ items, total, matched, returned }`, проецирует каждую сущность до нужных полей и схлопывает мусор. На реальных данных — **≈70% меньше токенов** (на коммитах/ветках до 84%). CLI `--format json` при этом остаётся «сырым» — сжатие живёт только в MCP-слое.
159
+
160
+ | Переменная / аргумент | Значения | Эффект |
161
+ | --- | --- | --- |
162
+ | `GITFLIC_MCP_OUTPUT` | `slim` (по умолч.) / `full` (=`raw`) | глобальный режим: `full` = старый сырой ответ один-в-один |
163
+ | `GITFLIC_MCP_RAW` | `1`/`true`/`yes`/`on` | то же, что `OUTPUT=full` (булев алиас, побеждает при конфликте) |
164
+ | аргумент `raw: true` | на **любом** тулзе | вернуть полный несжатый ответ для **одного** вызова — если модели не хватило поля в slim-виде |
165
+ | аргумент `fields: [...]` | на read/list тулзах | вернуть только указанные поля (+ id-ключи) |
166
+
167
+ **Фильтрация списков на стороне MCP.** У GitFlic почти нет серверных фильтров для списков, поэтому list-тулзы принимают аргументы фильтра/сортировки (`label`, `q`, `assignee`, `status`, `author`, `name`, `sort`, `limit`, …). При их наличии хендлер автоматически дотягивает `--all` (полный набор), фильтрует и возвращает только совпадения + `matched`/`total`. Напр. «найди открытые issue с лейблом Security» вернёт 3 совпадения вместо всех 60.
168
+
169
+ **Секреты вебхуков.** В slim-режиме `secret` маскируется в `secretSet: true|false` — сырой ответ его раньше **утекал** в модель. Прочитать реальное значение можно тулзом `gitflic_webhook_reveal_secret` — он помечен деструктивным, поэтому MCP-хост спросит подтверждение перед выдачей.
170
+
171
+ ## Все 77 тулзов
157
172
 
158
173
  Полный список по группам. **R** = read-only (`readOnlyHint: true`, не меняет состояние — попадает в `GITFLIC_MCP_READONLY=1`); **W** = write/мутация.
159
174
 
@@ -187,10 +202,10 @@ GITFLIC_MCP_TOOLS="gitflic_mr_list,gitflic_mr_view,gitflic_mr_diff,gitflic_mr_cr
187
202
  ### `user` (5)
188
203
  `R` `gitflic_user_me` · `R` `gitflic_user_search` · `R` `gitflic_user_get` · `R` `gitflic_user_projects` · `R` `gitflic_user_followers`
189
204
 
190
- ### `webhook` (4)
191
- `R` `gitflic_webhook_list` · `R` `gitflic_webhook_get` · `W` `gitflic_webhook_create` · `W` `gitflic_webhook_delete`
205
+ ### `webhook` (5)
206
+ `R` `gitflic_webhook_list` · `R` `gitflic_webhook_get` · `W` `gitflic_webhook_create` · `W` `gitflic_webhook_delete` · `W` `gitflic_webhook_reveal_secret` (требует подтверждения хоста)
192
207
 
193
- Итого: **39 read / 37 write**. Каждый тул маппится 1-в-1 на подкоманду CLI (`gitflic …`).
208
+ Итого: **39 read / 38 write**. Каждый тул маппится 1-в-1 на подкоманду CLI (`gitflic …`).
194
209
 
195
210
  ## Как это работает
196
211
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitflic-cli-mcp",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "MCP server wrapping the gitflic CLI — exposes the GitFlic REST API as typed tools for AI agents (Claude Code, Cursor, etc.)",
5
5
  "type": "module",
6
6
  "bin": {
@@ -11,6 +11,7 @@
11
11
  "runner.mjs",
12
12
  "tools.mjs",
13
13
  "filter.mjs",
14
+ "slim.mjs",
14
15
  "project-resolver.mjs",
15
16
  "README.md"
16
17
  ],
@@ -45,7 +46,7 @@
45
46
  },
46
47
  "dependencies": {
47
48
  "@modelcontextprotocol/sdk": "^1.0.0",
48
- "gitflic-cli": "^0.3.0",
49
+ "gitflic-cli": "^0.3.3",
49
50
  "zod": "^3.25.76"
50
51
  }
51
52
  }
package/server.mjs CHANGED
@@ -23,6 +23,7 @@ import { runGitflic, runGitflicJson, GitflicError } from "./runner.mjs";
23
23
  import { TOOLS } from "./tools.mjs";
24
24
  import { selectTools } from "./filter.mjs";
25
25
  import { resolveProjectArg } from "./project-resolver.mjs";
26
+ import { slimResult } from "./slim.mjs";
26
27
 
27
28
  const server = new McpServer(
28
29
  {
@@ -113,8 +114,12 @@ for (const tool of selected) {
113
114
  }
114
115
  throw e;
115
116
  }
117
+ // Shape the response for the LLM (issue #1): flatten HAL envelopes,
118
+ // project per entity, drop noise, mask secrets. Opt out per-call with
119
+ // `raw:true` or globally with GITFLIC_MCP_RAW=1 / GITFLIC_MCP_OUTPUT=full.
120
+ const shaped = slimResult(tool.name, result, resolvedArgs, process.env);
116
121
  return {
117
- content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
122
+ content: [{ type: "text", text: JSON.stringify(shaped, null, 2) }],
118
123
  };
119
124
  } catch (err) {
120
125
  return {
package/slim.mjs ADDED
@@ -0,0 +1,298 @@
1
+ // Token-slimming layer for MCP responses (GitFlic issue #1).
2
+ //
3
+ // The MCP used to return the raw GitFlic API JSON verbatim (server.mjs did
4
+ // JSON.stringify(result)). That dumps LLM-useless noise — avatar/cover URLs,
5
+ // label hexColors, 9-field status sub-objects, full nested user objects, HAL
6
+ // `_embedded`/`_links` envelopes — costing the model a lot of tokens.
7
+ //
8
+ // This module shapes the response at the single MCP boundary. The CLI's own
9
+ // `--format json` stays byte-for-byte raw (humans/scripts depend on it) — all
10
+ // shaping lives here. Three controls, in precedence order:
11
+ // 1. per-call `raw: true` -> return the full, un-slimmed response
12
+ // 2. global GITFLIC_MCP_RAW=1 -> same, for every call
13
+ // or GITFLIC_MCP_OUTPUT=full
14
+ // 3. default (slim): flatten envelope + project per entity + drop noise.
15
+ // Plus a per-call `fields: [...]` to narrow the projection further.
16
+ //
17
+ // Pure + testable: every function takes explicit input; only outputMode reads env.
18
+
19
+ const TRUTHY = new Set(["1", "true", "yes", "on"]);
20
+ const truthy = (v) => v != null && TRUTHY.has(String(v).trim().toLowerCase());
21
+
22
+ // Resolve the output mode from env. RAW wins over OUTPUT if both are set.
23
+ export function outputMode(env = {}) {
24
+ if (truthy(env.GITFLIC_MCP_RAW)) return "full";
25
+ const o = String(env.GITFLIC_MCP_OUTPUT || "").trim().toLowerCase();
26
+ if (o === "full" || o === "raw") return "full";
27
+ return "slim";
28
+ }
29
+
30
+ // ---- collapse helpers --------------------------------------------------------
31
+ const userName = (u) =>
32
+ u && typeof u === "object" ? (u.username || u.fullName || u.name || null) : u;
33
+ // status sub-object -> its machine enum scalar (OPEN / OPENED / MERGED / ...).
34
+ // Prefer .id (the stable token an LLM filters/acts on) over .title (display).
35
+ const statusId = (s) => (s && typeof s === "object" ? (s.id ?? s.title ?? null) : s);
36
+ const labelTitles = (ls) =>
37
+ Array.isArray(ls)
38
+ ? ls.map((l) => (l && typeof l === "object" ? (l.title ?? l.name ?? null) : l)).filter((x) => x != null)
39
+ : ls;
40
+
41
+ // ---- generic denylist: provably-cosmetic keys, stripped at any depth ---------
42
+ // Deliberately NARROW. Addressing/meaningful keys (hash, alias, mode, isBinary)
43
+ // are NOT here — they are only collapsed inside the per-entity projectors.
44
+ const DENY = new Set([
45
+ "avatar", "cover", "hexColor", "color", "icon",
46
+ "_links", "_embedded", "_pagination", "avatarRadius", "createdLogo", "logoUrl",
47
+ ]);
48
+ export function deny(node) {
49
+ if (Array.isArray(node)) return node.map(deny);
50
+ if (node && typeof node === "object") {
51
+ const out = {};
52
+ for (const [k, v] of Object.entries(node)) {
53
+ if (DENY.has(k)) continue;
54
+ if (k === "secret") { out.secretSet = !!(v && v !== "no-secret"); continue; } // never leak
55
+ out[k] = deny(v);
56
+ }
57
+ return out;
58
+ }
59
+ return node;
60
+ }
61
+
62
+ // ---- per-entity projectors (keep-sets derived from lib/cmd/*.mjs printXxx) ---
63
+ const slimCommit = (c, { full = false } = {}) => ({
64
+ hash: c.hash,
65
+ shortMessage: c.shortMessage ?? (c.message ? c.message.split("\n")[0] : undefined),
66
+ ...(full && c.message ? { message: c.message } : {}), // full body only on `commit get`
67
+ author: userName(c.user) || c.authorIdent?.name,
68
+ authorEmail: c.authorIdent?.emailAddress,
69
+ createdAt: c.createdAt,
70
+ verified: !!c.verificationResult?.verified,
71
+ ...((c.parentCommitIds || []).length > 1 ? { parents: c.parentCommitIds } : {}),
72
+ });
73
+ const slimLastCommit = (c) =>
74
+ c ? { hash: c.hash, shortMessage: c.shortMessage, author: userName(c.user) || c.authorIdent?.name, date: c.authorIdent?.when ?? c.createdAt } : undefined;
75
+
76
+ const P = {
77
+ issue: (i) => ({
78
+ localId: i.localId, id: i.id, title: i.title,
79
+ status: statusId(i.status),
80
+ labels: labelTitles(i.labels) ?? [],
81
+ assignedUsers: (i.assignedUsers || []).map(userName).filter(Boolean),
82
+ author: i.userAlias ?? userName(i.createdBy),
83
+ ...(i.updatedBy ? { updatedBy: userName(i.updatedBy) } : {}),
84
+ createdAt: i.createdAt, updatedAt: i.updatedAt,
85
+ ...(i.description != null ? { description: i.description } : {}),
86
+ }),
87
+ note: (n) => ({
88
+ uuid: n.uuid, message: n.message, author: userName(n.author),
89
+ createdAt: n.createdAt, ...(n.updatedAt ? { updatedAt: n.updatedAt } : {}),
90
+ }),
91
+ mr: (m) => ({
92
+ localId: m.localId, id: m.id, title: m.title,
93
+ status: statusId(m.status),
94
+ author: userName(m.createdBy) || userName(m.author),
95
+ sourceBranch: m.sourceBranch?.alias || m.sourceBranch?.id || m.sourceBranch,
96
+ targetBranch: m.targetBranch?.alias || m.targetBranch?.id || m.targetBranch,
97
+ ...(m.canMerge !== undefined ? { canMerge: m.canMerge } : {}),
98
+ ...(m.hasConflicts !== undefined ? { hasConflicts: m.hasConflicts } : {}),
99
+ createdAt: m.createdAt, ...(m.updatedAt ? { updatedAt: m.updatedAt } : {}),
100
+ ...(m.projectAlias ? { projectAlias: m.projectAlias } : {}),
101
+ ...(m.description != null ? { description: m.description } : {}),
102
+ }),
103
+ discussion: (d) => ({
104
+ uuid: d.rootNote?.uuid ?? d.uuid,
105
+ resolved: d.rootNote?.resolved ?? d.resolved,
106
+ author: userName(d.rootNote?.author ?? d.author),
107
+ message: d.rootNote?.message ?? d.message,
108
+ createdAt: d.rootNote?.createdAt ?? d.createdAt,
109
+ ...(Array.isArray(d.replies)
110
+ ? { replies: d.replies.map((r) => ({ uuid: r.uuid, author: userName(r.author), message: r.message })) }
111
+ : {}),
112
+ }),
113
+ commit: (c) => slimCommit(c, { full: false }),
114
+ commitFull: (c) => slimCommit(c, { full: true }),
115
+ release: (r) => ({
116
+ id: r.id, tagName: r.tagName, title: r.title,
117
+ commitId: r.commitId, preRelease: r.preRelease,
118
+ createdAt: r.createdAt, updatedAt: r.updatedAt,
119
+ filesCount: (r.attachmentFiles || []).length,
120
+ ...(r.description != null ? { description: r.description } : {}),
121
+ }),
122
+ branch: (b) => ({ name: b.name, default: b.default, work: b.work, merged: b.merged, lastCommit: slimLastCommit(b.lastCommit) }),
123
+ tag: (t) => ({
124
+ name: t.name, lightWeight: t.lightWeight, commitId: t.commitId, shortMessage: t.shortMessage,
125
+ tagger: t.personIdent ? `${t.personIdent.name} <${t.personIdent.emailAddress}>` : undefined,
126
+ date: t.personIdent?.when,
127
+ }),
128
+ user: (u) => ({ id: u.id, username: u.username, fullName: u.fullName, ...(u.email ? { email: u.email } : {}) }),
129
+ project: (p) => ({
130
+ id: p.id, alias: p.alias, title: p.title,
131
+ owner: p.owner?.alias || userName(p.owner) || p.owner,
132
+ ...(p.defaultBranch ? { defaultBranch: p.defaultBranch } : {}),
133
+ private: p.private,
134
+ ...(p.language ? { language: p.language } : {}),
135
+ ...(p.description ? { description: String(p.description).split("\n")[0] } : {}),
136
+ ...(p.httpTransportUrl ? { httpTransportUrl: p.httpTransportUrl } : {}),
137
+ }),
138
+ member: (m) => ({ user: userName(m.user) ?? m.userAlias, role: m.role }),
139
+ webhook: (w) => ({
140
+ id: w.id, url: w.url || w.targetUrl,
141
+ events: Object.entries(w.events || {}).filter(([, on]) => on).map(([n]) => n),
142
+ secretSet: !!(w.secret && w.secret !== "no-secret"),
143
+ createdAt: w.createdAt,
144
+ }),
145
+ };
146
+
147
+ // tool name -> { list?: projector } for *_list/*_search, { single?: projector } otherwise.
148
+ // Tools absent here (diff/blob/compare, auth, deletes) fall through to deny() only —
149
+ // diff/blob bodies ARE the answer, so they must be light-stripped, never projected.
150
+ export const SHAPE = {
151
+ gitflic_issue_list: { list: P.issue }, gitflic_issue_view: { single: P.issue },
152
+ gitflic_issue_create: { single: P.issue }, gitflic_issue_edit: { single: P.issue },
153
+ gitflic_issue_close: { single: P.issue }, gitflic_issue_reopen: { single: P.issue },
154
+ gitflic_issue_comments_list: { list: P.note },
155
+ gitflic_mr_list: { list: P.mr }, gitflic_mr_by_commit: { list: P.mr },
156
+ gitflic_mr_view: { single: P.mr }, gitflic_mr_create: { single: P.mr },
157
+ gitflic_mr_edit: { single: P.mr }, gitflic_mr_approve: { single: P.mr },
158
+ gitflic_mr_merge: { single: P.mr }, gitflic_mr_close: { single: P.mr }, gitflic_mr_cancel: { single: P.mr },
159
+ gitflic_mr_discuss_list: { list: P.discussion },
160
+ gitflic_commit_list: { list: P.commit }, gitflic_commit_get: { single: P.commitFull },
161
+ gitflic_release_list: { list: P.release }, gitflic_release_get: { single: P.release },
162
+ gitflic_release_latest: { single: P.release }, gitflic_release_create: { single: P.release }, gitflic_release_edit: { single: P.release },
163
+ gitflic_branch_list: { list: P.branch }, gitflic_branch_get: { single: P.branch }, gitflic_branch_default: { single: P.branch },
164
+ gitflic_tag_list: { list: P.tag }, gitflic_tag_get: { single: P.tag },
165
+ gitflic_user_search: { list: P.user }, gitflic_user_followers: { list: P.user },
166
+ gitflic_user_me: { single: P.user }, gitflic_auth: { single: P.user }, gitflic_user_get: { single: P.user },
167
+ gitflic_user_projects: { list: P.project },
168
+ gitflic_project_list_my: { list: P.project },
169
+ gitflic_project_get: { single: P.project }, gitflic_project_create: { single: P.project }, gitflic_project_edit: { single: P.project },
170
+ gitflic_project_member_list: { list: P.member },
171
+ gitflic_webhook_list: { list: P.webhook }, gitflic_webhook_get: { single: P.webhook }, gitflic_webhook_create: { single: P.webhook },
172
+ };
173
+
174
+ // ---- `fields` projection override -------------------------------------------
175
+ const ID_KEYS = ["id", "localId", "uuid", "hash", "commitId", "name", "alias"];
176
+ function pickFields(item, fields) {
177
+ if (!item || typeof item !== "object") return item;
178
+ const out = {};
179
+ for (const k of new Set([...fields, ...ID_KEYS])) if (k in item) out[k] = item[k];
180
+ return out;
181
+ }
182
+
183
+ // ---- HAL envelope flatten ----------------------------------------------------
184
+ function listOf(data) {
185
+ const emb = data && data._embedded;
186
+ if (!emb || typeof emb !== "object") return null;
187
+ const key = Object.keys(emb).find((k) => Array.isArray(emb[k]));
188
+ if (!key) return null;
189
+ return { items: emb[key], total: data.page?.totalElements };
190
+ }
191
+
192
+ // ---- client-side list filters (run on RAW items, BEFORE projection) ----------
193
+ // GitFlic exposes almost no server-side list filters, so the slim layer filters
194
+ // the (auto-paginated) full set. Must read RAW items so labels[].title /
195
+ // assignedUsers[].username / branch.alias are still present to match on.
196
+ const ci = (s) => String(s ?? "").toLowerCase();
197
+ const globToRe = (g) =>
198
+ new RegExp("^" + String(g).split("*").map((s) => s.replace(/[.+?^${}()|[\]\\]/g, "\\$&")).join(".*") + "$", "i");
199
+ const branchName = (b) => (b && typeof b === "object" ? (b.alias || b.id) : b);
200
+
201
+ export const FILTER_KEYS = [
202
+ "label", "assignee", "author", "status", "q", "name", "tag", "event",
203
+ "targetBranch", "sourceBranch", "language", "merged", "preRelease", "private", "lightWeight", "sort", "limit",
204
+ ];
205
+ export const hasFilter = (args = {}) =>
206
+ FILTER_KEYS.some((k) => args[k] !== undefined && args[k] !== "" && args[k] !== null);
207
+
208
+ export function applyListFilters(items, args = {}) {
209
+ let out = items;
210
+ if (args.label) out = out.filter((i) => (labelTitles(i.labels) || []).some((t) => ci(t).includes(ci(args.label))));
211
+ if (args.assignee) out = out.filter((i) => (i.assignedUsers || []).some((u) => ci(userName(u)) === ci(args.assignee)));
212
+ if (args.author) out = out.filter((i) => ci(userName(i.createdBy) || userName(i.user) || i.userAlias || i.authorIdent?.name).includes(ci(args.author)));
213
+ if (args.status) out = out.filter((i) => ci(statusId(i.status)) === ci(args.status));
214
+ if (args.targetBranch) out = out.filter((i) => ci(branchName(i.targetBranch)).includes(ci(args.targetBranch)));
215
+ if (args.sourceBranch) out = out.filter((i) => ci(branchName(i.sourceBranch)).includes(ci(args.sourceBranch)));
216
+ if (args.tag) out = out.filter((i) => ci(i.tagName).includes(ci(args.tag)));
217
+ if (args.language) out = out.filter((i) => ci(i.language) === ci(args.language));
218
+ if (args.event) out = out.filter((i) => i.events && i.events[String(args.event).toUpperCase()] === true);
219
+ if (args.merged !== undefined) out = out.filter((i) => !!i.merged === !!args.merged);
220
+ if (args.preRelease !== undefined) out = out.filter((i) => !!i.preRelease === !!args.preRelease);
221
+ if (args.private !== undefined) out = out.filter((i) => !!i.private === !!args.private);
222
+ if (args.lightWeight !== undefined) out = out.filter((i) => !!i.lightWeight === !!args.lightWeight);
223
+ if (args.name) { const re = globToRe(args.name); out = out.filter((i) => re.test(i.name ?? "") || ci(i.name).includes(ci(args.name))); }
224
+ if (args.q) {
225
+ const q = ci(args.q);
226
+ out = out.filter((i) => `${i.title ?? ""} ${i.description ?? ""} ${i.shortMessage ?? ""} ${i.message ?? ""}`.toLowerCase().includes(q));
227
+ }
228
+ if (args.sort) {
229
+ const desc = String(args.sort)[0] === "-";
230
+ const k = String(args.sort).replace(/^-/, "");
231
+ out = [...out].sort((a, b) => { const av = a[k], bv = b[k]; return (av > bv ? 1 : av < bv ? -1 : 0) * (desc ? -1 : 1); });
232
+ }
233
+ if (args.limit !== undefined) { const n = Number(args.limit); if (Number.isFinite(n) && n >= 0) out = out.slice(0, n); }
234
+ return out;
235
+ }
236
+
237
+ // Mask every `secret` key (-> secretSet:bool) at any depth, preserving the rest
238
+ // byte-for-byte. Returns the SAME reference when no secret is present, so the
239
+ // raw/full path stays a true identity passthrough for secret-free payloads.
240
+ // This is the guard that keeps `raw:true` / GITFLIC_MCP_RAW from becoming an
241
+ // unprompted secret-exfiltration path — the approval-gated reveal tool is the
242
+ // ONLY exit for a real secret value.
243
+ export function maskSecrets(node) {
244
+ if (Array.isArray(node)) {
245
+ let changed = false;
246
+ const out = node.map((v) => { const m = maskSecrets(v); if (m !== v) changed = true; return m; });
247
+ return changed ? out : node;
248
+ }
249
+ if (node && typeof node === "object") {
250
+ let changed = false;
251
+ const out = {};
252
+ for (const [k, v] of Object.entries(node)) {
253
+ if (k === "secret") { out.secretSet = !!(v && v !== "no-secret"); changed = true; continue; }
254
+ const m = maskSecrets(v);
255
+ if (m !== v) changed = true;
256
+ out[k] = m;
257
+ }
258
+ return changed ? out : node;
259
+ }
260
+ return node;
261
+ }
262
+
263
+ // ---- entry point -------------------------------------------------------------
264
+ export function slimResult(toolName, result, args = {}, env = process.env) {
265
+ // The approval-gated reveal tool is the ONLY path to a real secret value.
266
+ if (toolName === "gitflic_webhook_reveal_secret") {
267
+ if (result == null || typeof result !== "object") return result;
268
+ return { id: result.id, url: result.url || result.targetUrl, secret: result.secret ?? null };
269
+ }
270
+ // Escape hatches: full fidelity for the LLM — EXCEPT secrets stay masked, so
271
+ // raw/full never becomes an unprompted secret leak (per-call raw, or global
272
+ // GITFLIC_MCP_RAW=1 / GITFLIC_MCP_OUTPUT=full).
273
+ if ((args && args.raw === true) || outputMode(env) === "full") return maskSecrets(result);
274
+ if (result == null || typeof result !== "object") return result;
275
+ if (result.raw != null && Object.keys(result).length === 1) return result; // {raw:text}
276
+
277
+ const fields = Array.isArray(args.fields) && args.fields.length ? args.fields : null;
278
+ const shape = SHAPE[toolName] || {};
279
+ // Treat known list tools as lists even when GitFlic omits `_embedded` on a
280
+ // zero-result response, so the {items,total,returned} contract is consistent.
281
+ let lst = listOf(result);
282
+ if (!lst && shape.list && result.page) lst = { items: [], total: result.page.totalElements };
283
+
284
+ if (lst) {
285
+ let items = Array.isArray(lst.items) ? lst.items : [];
286
+ if (hasFilter(args)) items = applyListFilters(items, args);
287
+ const matched = items.length;
288
+ const project = fields ? (it) => pickFields(it, fields) : (shape.list || ((x) => x));
289
+ const slimmed = items.map((it) => deny(project(it))); // deny() = final cosmetic+secret sweep
290
+ const res = { items: slimmed, ...(lst.total != null ? { total: lst.total } : {}), returned: slimmed.length };
291
+ if (lst.total != null && matched !== lst.total) res.matched = matched;
292
+ return res;
293
+ }
294
+
295
+ if (fields) return deny(pickFields(result, fields));
296
+ if (shape.single) return deny(shape.single(result));
297
+ return deny(result); // unmapped single entity -> light strip only
298
+ }
package/tools.mjs CHANGED
@@ -1376,6 +1376,24 @@ export const TOOLS = [
1376
1376
  openWorldHint: true,
1377
1377
  },
1378
1378
  },
1379
+ {
1380
+ name: "gitflic_webhook_reveal_secret",
1381
+ description:
1382
+ "Reveal the raw signing secret of a webhook. SENSITIVE: by default the MCP masks secrets (returns secretSet:true/false). This tool returns the actual secret value and is marked destructive so the MCP host prompts for approval before it runs. Use only when the user explicitly needs the secret value.",
1383
+ inputSchema: {
1384
+ type: "object",
1385
+ required: ["project", "uuid"],
1386
+ properties: { project: { type: "string" }, uuid: { type: "string" } },
1387
+ },
1388
+ handler: (a) => ["webhook", "get", a.uuid, "--project", a.project, "--format", "json"],
1389
+ annotations: {
1390
+ title: "Webhook Reveal Secret",
1391
+ readOnlyHint: false, // not read-only on purpose: triggers the host approval prompt
1392
+ destructiveHint: true,
1393
+ idempotentHint: true,
1394
+ openWorldHint: true,
1395
+ },
1396
+ },
1379
1397
 
1380
1398
  // ===== Project: get / create / edit / member list =====================
1381
1399
  {
@@ -1604,5 +1622,119 @@ export const TOOLS = [
1604
1622
  },
1605
1623
  ];
1606
1624
 
1625
+ // ===== issue #1: token-slimming support (applied uniformly, post-definition) =
1626
+ // Kept here (not inline on every tool) so the surface is one DRY block.
1627
+ //
1628
+ // - `raw` : per-call escape hatch on EVERY tool — returns the full,
1629
+ // un-slimmed GitFlic response for that call (the slim layer in
1630
+ // slim.mjs honors it). Complements GITFLIC_MCP_RAW (global).
1631
+ // - `fields` : on read/list tools — narrow the response to the named
1632
+ // top-level fields (plus id keys).
1633
+ // - filter/sort/limit args on list tools — consumed client-side by slim.mjs
1634
+ // (GitFlic has almost no server-side list filters). When any is
1635
+ // present, the handler also appends `--all` so the filter sees
1636
+ // the whole set, not just page 0.
1637
+ const STR = (description) => ({ type: "string", description });
1638
+ const NUM = (description) => ({ type: "number", description });
1639
+ const BOOL = (description) => ({ type: "boolean", description });
1640
+
1641
+ // Per-list-tool filter args (name -> JSON-schema prop).
1642
+ const FILTER_PROPS = {
1643
+ gitflic_issue_list: {
1644
+ label: STR("Keep only issues with a label whose title contains this (case-insensitive)."),
1645
+ assignee: STR("Keep only issues assigned to this username."),
1646
+ q: STR("Free-text filter over title + description."),
1647
+ sort: STR("Sort by a field; prefix '-' for descending (e.g. '-createdAt')."),
1648
+ limit: NUM("Return at most N issues after filtering/sorting."),
1649
+ },
1650
+ gitflic_mr_list: {
1651
+ status: STR("Filter by status (OPENED|MERGED|CLOSED|CANCELED|FAILED)."),
1652
+ author: STR("Keep only MRs by this author username."),
1653
+ targetBranch: STR("Keep only MRs targeting a branch matching this."),
1654
+ sourceBranch: STR("Keep only MRs from a source branch matching this."),
1655
+ q: STR("Free-text filter over title + description."),
1656
+ sort: STR("Sort field; prefix '-' for descending."),
1657
+ limit: NUM("Return at most N."),
1658
+ },
1659
+ gitflic_commit_list: {
1660
+ author: STR("Keep only commits by this author."),
1661
+ q: STR("Free-text filter over the commit message."),
1662
+ sort: STR("Sort field; prefix '-' for descending."),
1663
+ limit: NUM("Return at most N (e.g. last 10 commits)."),
1664
+ },
1665
+ gitflic_release_list: {
1666
+ preRelease: BOOL("true = only pre-releases, false = only stable."),
1667
+ tag: STR("Keep only releases whose tagName contains this."),
1668
+ q: STR("Free-text filter over title + description."),
1669
+ sort: STR("Sort field; prefix '-' for descending."),
1670
+ limit: NUM("Return at most N (e.g. last 3 releases)."),
1671
+ },
1672
+ gitflic_branch_list: {
1673
+ name: STR("Glob/substring filter on branch name (e.g. 'release/*')."),
1674
+ merged: BOOL("true = only merged branches, false = only unmerged."),
1675
+ sort: STR("Sort field; prefix '-' for descending."),
1676
+ limit: NUM("Return at most N."),
1677
+ },
1678
+ gitflic_tag_list: {
1679
+ name: STR("Glob/substring filter on tag name (e.g. 'v1.*')."),
1680
+ lightWeight: BOOL("true = only lightweight tags, false = only annotated."),
1681
+ sort: STR("Sort field; prefix '-' for descending."),
1682
+ limit: NUM("Return at most N."),
1683
+ },
1684
+ gitflic_webhook_list: {
1685
+ event: STR("Keep only webhooks subscribed to this event (e.g. MERGE)."),
1686
+ limit: NUM("Return at most N."),
1687
+ },
1688
+ gitflic_project_list_my: {
1689
+ q: STR("Name/text filter (forwarded to GitFlic server-side, plus client match)."),
1690
+ language: STR("Keep only projects in this language."),
1691
+ private: BOOL("true = only private, false = only public."),
1692
+ sort: STR("Sort field; prefix '-' for descending."),
1693
+ limit: NUM("Return at most N."),
1694
+ },
1695
+ gitflic_user_projects: {
1696
+ language: STR("Keep only projects in this language."),
1697
+ private: BOOL("true = only private, false = only public."),
1698
+ q: STR("Name/text filter."),
1699
+ sort: STR("Sort field; prefix '-' for descending."),
1700
+ limit: NUM("Return at most N."),
1701
+ },
1702
+ gitflic_user_followers: {
1703
+ q: STR("Username substring filter."),
1704
+ limit: NUM("Return at most N."),
1705
+ },
1706
+ };
1707
+ const LIST_TOOLS = new Set(Object.keys(FILTER_PROPS));
1708
+ // Args whose presence means "fetch everything, then filter client-side".
1709
+ const ALL_TRIGGER = [
1710
+ "label", "assignee", "author", "status", "targetBranch", "sourceBranch", "q",
1711
+ "name", "tag", "event", "language", "merged", "preRelease", "private", "lightWeight", "sort", "limit",
1712
+ ];
1713
+ const needsAll = (a = {}) => ALL_TRIGGER.some((k) => a[k] !== undefined && a[k] !== "" && a[k] !== null);
1714
+
1715
+ for (const t of TOOLS) {
1716
+ const props = t.inputSchema.properties || (t.inputSchema.properties = {});
1717
+ if (!props.raw) {
1718
+ props.raw = BOOL(
1719
+ "Return the full, un-slimmed GitFlic response for THIS call (no token compression). Use when the default slim view dropped a field you need.",
1720
+ );
1721
+ }
1722
+ if ((t.annotations?.readOnlyHint || LIST_TOOLS.has(t.name)) && !props.fields) {
1723
+ props.fields = { type: "array", description: "Return only these top-level fields (plus id keys), narrowing the response further." };
1724
+ }
1725
+ const fp = FILTER_PROPS[t.name];
1726
+ if (fp) {
1727
+ for (const [k, schema] of Object.entries(fp)) if (!props[k]) props[k] = schema;
1728
+ const orig = t.handler;
1729
+ t.handler = (a = {}) => {
1730
+ const argv = orig(a);
1731
+ // project_list_my: GitFlic supports server-side ?q= for project listings.
1732
+ if (t.name === "gitflic_project_list_my" && a.q && !argv.includes("--q")) argv.push("--q", String(a.q));
1733
+ if (needsAll(a) && !argv.includes("--all")) argv.push("--all");
1734
+ return argv;
1735
+ };
1736
+ }
1737
+ }
1738
+
1607
1739
  // Build the JSON Schema for ListTools request, just to help debugging.
1608
1740
  export const TOOL_NAMES = TOOLS.map((t) => t.name);