gitflic-cli-mcp 0.3.0 → 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
@@ -1,6 +1,6 @@
1
1
  # gitflic-cli MCP-сервер
2
2
 
3
- [Model Context Protocol](https://modelcontextprotocol.io/) сервер, оборачивающий CLI `gitflic` и отдающий **77 типизированных тулзов** поверх команд GitFlic REST для AI-агентов (Claude Code, Cursor и т.п.).
3
+ [Model Context Protocol](https://modelcontextprotocol.io/) сервер, оборачивающий CLI `gitflic` и отдающий **76 типизированных тулзов** поверх команд GitFlic REST для AI-агентов (Claude Code, Cursor и т.п.).
4
4
 
5
5
  ## Что внутри
6
6
 
@@ -66,7 +66,7 @@ claude mcp add gitflic --env GITFLIC_TOKEN=xxxxxxxx -- npx -y gitflic-cli-mcp
66
66
 
67
67
  ## Ограничение набора тулзов (экономия контекста)
68
68
 
69
- Все 77 тулзов уходят хосту по `tools/list` ещё до первого вызова — это ~6K токенов «налога» на контекст модели. Если агенту нужны не все, набор можно сузить через **env-переменные** — прописываются в том же `env`-блоке конфига, где `GITFLIC_TOKEN` (своего конфиг-файла у сервера нет, он читает `process.env` при старте).
69
+ Все 76 тулзов уходят хосту по `tools/list` ещё до первого вызова — это ~6K токенов «налога» на контекст модели. Если агенту нужны не все, набор можно сузить через **env-переменные** — прописываются в том же `env`-блоке конфига, где `GITFLIC_TOKEN` (своего конфиг-файла у сервера нет, он читает `process.env` при старте).
70
70
 
71
71
  | Переменная | Значение | Что делает |
72
72
  |---|---|---|
@@ -76,7 +76,7 @@ claude mcp add gitflic --env GITFLIC_TOKEN=xxxxxxxx -- npx -y gitflic-cli-mcp
76
76
  | `GITFLIC_MCP_TOOLS` | список имён тулзов | явный allowlist (имеет приоритет над `GROUPS`) |
77
77
  | `GITFLIC_MCP_EXCLUDE` | список имён тулзов | убрать конкретные тулзы (применяется последним) |
78
78
 
79
- Все — опциональные. Без них регистрируются все 77 (как и раньше). Неизвестные группы/имена не валят сервер — он пишет `WARN` в stderr и игнорирует их.
79
+ Все — опциональные. Без них регистрируются все 76 (как и раньше). Неизвестные группы/имена не валят сервер — он пишет `WARN` в stderr и игнорирует их.
80
80
 
81
81
  **Порядок применения (precedence):**
82
82
 
@@ -92,7 +92,7 @@ claude mcp add gitflic --env GITFLIC_TOKEN=xxxxxxxx -- npx -y gitflic-cli-mcp
92
92
  | Группа | Тулзов | Read / Write |
93
93
  |---|---|---|
94
94
  | `auth` | 8 | 3 read / 5 write |
95
- | `blob` | 3 | 3 read / 0 write |
95
+ | `blob` | 2 | 2 read / 0 write |
96
96
  | `branch` | 10 | 5 read / 5 write |
97
97
  | `commit` | 4 | 4 read / 0 write |
98
98
  | `issue` | 12 | 4 read / 8 write |
@@ -118,14 +118,14 @@ claude mcp add gitflic --env GITFLIC_TOKEN=xxxxxxxx -- npx -y gitflic-cli-mcp
118
118
  **Примеры:**
119
119
 
120
120
  ```bash
121
- # Только MR и issue (~27 тулзов вместо 77)
121
+ # Только MR и issue (~27 тулзов вместо 76)
122
122
  claude mcp add gitflic --env GITFLIC_TOKEN=xxxx \
123
123
  --env GITFLIC_MCP_GROUPS=mr,issue -- npx -y gitflic-cli-mcp
124
124
 
125
125
  # Read-only агент для код-ревью (только тулзы без мутаций)
126
126
  GITFLIC_MCP_READONLY=1 npx -y gitflic-cli-mcp
127
127
 
128
- # Может всё, кроме удаления (70 тулзов: создаёт/правит/мёрджит — но не удаляет)
128
+ # Может всё, кроме удаления (69 тулзов: создаёт/правит/мёрджит — но не удаляет)
129
129
  GITFLIC_MCP_NO_DESTRUCTIVE=1 npx -y gitflic-cli-mcp
130
130
 
131
131
  # Точный набор из четырёх тулзов
@@ -153,6 +153,21 @@ 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
+ ## Сжатие ответов (экономия токенов)
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
+
156
171
  ## Все 77 тулзов
157
172
 
158
173
  Полный список по группам. **R** = read-only (`readOnlyHint: true`, не меняет состояние — попадает в `GITFLIC_MCP_READONLY=1`); **W** = write/мутация.
@@ -160,8 +175,8 @@ GITFLIC_MCP_TOOLS="gitflic_mr_list,gitflic_mr_view,gitflic_mr_diff,gitflic_mr_cr
160
175
  ### `auth` (8)
161
176
  `R` `gitflic_auth` · `W` `gitflic_auth_login` · `W` `gitflic_auth_logout` · `R` `gitflic_auth_list` · `W` `gitflic_auth_switch` · `W` `gitflic_auth_bind` · `W` `gitflic_auth_unbind` · `R` `gitflic_auth_bindings`
162
177
 
163
- ### `blob` (3)
164
- `R` `gitflic_blob_get` · `R` `gitflic_blob_files` · `R` `gitflic_blob_archive`
178
+ ### `blob` (2)
179
+ `R` `gitflic_blob_get` · `R` `gitflic_blob_files`
165
180
 
166
181
  ### `branch` (10)
167
182
  `R` `gitflic_branch_list` · `W` `gitflic_branch_create` · `W` `gitflic_branch_delete` · `R` `gitflic_branch_get` · `R` `gitflic_branch_default` · `R` `gitflic_branch_protection_list` · `R` `gitflic_branch_protection_get` · `W` `gitflic_branch_protection_create` · `W` `gitflic_branch_protection_edit` · `W` `gitflic_branch_protection_delete`
@@ -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
- Итого: **40 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.0",
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
@@ -242,10 +242,10 @@ export const TOOLS = [
242
242
  {
243
243
  name: "gitflic_mr_create",
244
244
  description:
245
- "Create a new merge request. If --from is omitted, uses current git branch. If --to is omitted, uses project default branch.",
245
+ "Create a new merge request. `description` is required (GitFlic rejects empty MR descriptions). If `from` is omitted, uses current git branch; if `to` is omitted, uses project default branch.",
246
246
  inputSchema: {
247
247
  type: "object",
248
- required: ["project", "title"],
248
+ required: ["project", "title", "description"],
249
249
  properties: {
250
250
  project: { type: "string" },
251
251
  title: { type: "string" },
@@ -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
  {
@@ -1430,7 +1448,7 @@ export const TOOLS = [
1430
1448
  },
1431
1449
  {
1432
1450
  name: "gitflic_project_edit",
1433
- description: "Edit project metadata (title, description, default branch, visibility).",
1451
+ description: "Edit project title, description, or language (via change-setting). Visibility and default branch are not editable over the REST API.",
1434
1452
  inputSchema: {
1435
1453
  type: "object",
1436
1454
  required: ["project"],
@@ -1438,18 +1456,14 @@ export const TOOLS = [
1438
1456
  project: { type: "string" },
1439
1457
  title: { type: "string" },
1440
1458
  description: { type: "string" },
1441
- isPrivate: { type: "boolean" },
1442
1459
  language: { type: "string" },
1443
- defaultBranch: { type: "string" },
1444
1460
  },
1445
1461
  },
1446
1462
  handler: (a) => {
1447
1463
  const args = ["project", "edit", a.project, "--format", "json"];
1448
1464
  if (a.title) args.push("--title", a.title);
1449
1465
  if (a.description !== undefined) args.push("--description", a.description);
1450
- if (a.isPrivate !== undefined) args.push(a.isPrivate ? "--private" : "--public");
1451
1466
  if (a.language) args.push("--language", a.language);
1452
- if (a.defaultBranch) args.push("--default-branch", a.defaultBranch);
1453
1467
  return args;
1454
1468
  },
1455
1469
  annotations: {
@@ -1540,8 +1554,8 @@ export const TOOLS = [
1540
1554
  },
1541
1555
  },
1542
1556
  handler: (a) => {
1543
- const args = ["blob", "files", "--project", a.project, "--format", "json"];
1544
- if (a.path) args.push("--path", a.path);
1557
+ const args = ["blob", "recursive", "--project", a.project, "--format", "json"];
1558
+ if (a.path) args.push("--directory", a.path);
1545
1559
  if (a.branch) args.push("--branch", a.branch);
1546
1560
  return args;
1547
1561
  },
@@ -1553,23 +1567,6 @@ export const TOOLS = [
1553
1567
  openWorldHint: true,
1554
1568
  },
1555
1569
  },
1556
- {
1557
- name: "gitflic_blob_archive",
1558
- description: "Download a repo archive (tarball). Returns JSON metadata; use shell to stream the body.",
1559
- inputSchema: {
1560
- type: "object",
1561
- required: ["project", "branch"],
1562
- properties: { project: { type: "string" }, branch: { type: "string" } },
1563
- },
1564
- handler: (a) => ["blob", "archive", a.branch, "--project", a.project, "--format", "json"],
1565
- annotations: {
1566
- title: "Blob Archive",
1567
- readOnlyHint: true,
1568
- destructiveHint: false,
1569
- idempotentHint: false,
1570
- openWorldHint: true,
1571
- },
1572
- },
1573
1570
 
1574
1571
  // ===== User: get / projects / followers =================================
1575
1572
  {
@@ -1625,5 +1622,119 @@ export const TOOLS = [
1625
1622
  },
1626
1623
  ];
1627
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
+
1628
1739
  // Build the JSON Schema for ListTools request, just to help debugging.
1629
1740
  export const TOOL_NAMES = TOOLS.map((t) => t.name);