@moabpro/recap-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,26 @@
1
+ # @moabpro/recap-mcp
2
+
3
+ MCP-сервер **MoabRecap** для Claude Code: команды `/moabrecap` и `/switch-project`. Авторизация — через портал (Keycloak device-flow), доступ строго по роли **crew**.
4
+
5
+ ## Установка
6
+ 1. Пропиши MCP-сервер и SessionStart-хук в конфиг Claude Code:
7
+ ```bash
8
+ npx -y @moabpro/recap-mcp init
9
+ ```
10
+ 2. Перезапусти Claude Code.
11
+ 3. Выполни инструмент **`login`** (device-flow) — откроется ссылка авторизации на auth.moab.tools; подтверди под учёткой с портальной ролью **crew**.
12
+
13
+ ## Использование
14
+ - `/switch-project` — выбрать/сменить проект, закреплённый за сессией.
15
+ - `/moabrecap` — сделать recap за период с прошлого рекапа.
16
+ - `login` / `auth_status` / `auth_logout` — управление входом.
17
+
18
+ ## Обновление
19
+ `npx` тянет свежую версию автоматически — отдельных действий не нужно.
20
+
21
+ ## Конфигурация (опц., env)
22
+ - `RECAP_URL` (по умолчанию `https://recap.moab.tools`)
23
+ - `KEYCLOAK_AUTHORITY` (по умолчанию `https://auth.moab.tools/realms/moab`)
24
+ - `KEYCLOAK_CLIENT` (по умолчанию `share-cli`)
25
+
26
+ Лицензия: UNLICENSED (внутренний инструмент moab.tools).
@@ -0,0 +1,33 @@
1
+ import { ApiError } from './errors.js';
2
+ export class RecapApi {
3
+ cfg;
4
+ auth;
5
+ constructor(cfg, auth) {
6
+ this.cfg = cfg;
7
+ this.auth = auth;
8
+ }
9
+ async authed(path, init) {
10
+ const token = await this.auth.getAccessToken();
11
+ const withAuth = (t) => ({ ...init, headers: { ...init.headers, Authorization: `Bearer ${t}` } });
12
+ let res = await fetch(this.cfg.apiBase + path, withAuth(token));
13
+ if (res.status === 401) {
14
+ const fresh = await this.auth.invalidateAndRefresh();
15
+ res = await fetch(this.cfg.apiBase + path, withAuth(fresh));
16
+ }
17
+ return res;
18
+ }
19
+ async postRecap(body) {
20
+ const r = await this.authed('/api/recaps', {
21
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
22
+ });
23
+ if (!r.ok)
24
+ throw new ApiError(r.status, await r.text());
25
+ return (await r.json());
26
+ }
27
+ async myProjects() {
28
+ const r = await this.authed('/api/recaps/my-projects', { method: 'GET', headers: {} });
29
+ if (!r.ok)
30
+ throw new ApiError(r.status, await r.text());
31
+ return (await r.json());
32
+ }
33
+ }
@@ -0,0 +1,8 @@
1
+ /** Нет валидного токена — нужно запустить login. */
2
+ export class AuthRequiredError extends Error {
3
+ constructor(message) { super(message); this.name = 'AuthRequiredError'; }
4
+ }
5
+ /** Device flow стартован, но вход ещё не подтверждён в браузере. */
6
+ export class AuthPendingError extends Error {
7
+ constructor(message) { super(message); this.name = 'AuthPendingError'; }
8
+ }
package/dist/auth.js ADDED
@@ -0,0 +1,129 @@
1
+ import { mkdir, readFile, writeFile, rm } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { AuthRequiredError, AuthPendingError } from './auth-errors.js';
4
+ /** Хранит AuthState в <dir>/credentials.json. На Windows POSIX-mode (0600/0700) — no-op; защита = ACL профиля. */
5
+ export class FileTokenStore {
6
+ dir;
7
+ file;
8
+ constructor(dir) {
9
+ this.dir = dir;
10
+ this.file = join(dir, 'credentials.json');
11
+ }
12
+ async load() {
13
+ try {
14
+ return JSON.parse(await readFile(this.file, 'utf8'));
15
+ }
16
+ catch (e) {
17
+ if (e.code === 'ENOENT')
18
+ return null;
19
+ throw e;
20
+ }
21
+ }
22
+ async save(state) {
23
+ await mkdir(this.dir, { recursive: true, mode: 0o700 });
24
+ await writeFile(this.file, JSON.stringify(state, null, 2), { mode: 0o600 });
25
+ }
26
+ async clear() { await rm(this.file, { force: true }); }
27
+ }
28
+ const EXPIRY_SKEW_MS = 30_000;
29
+ const DEVICE_GRANT = 'urn:ietf:params:oauth:grant-type:device_code';
30
+ export class Authenticator {
31
+ cfg;
32
+ store;
33
+ fetchFn;
34
+ now;
35
+ constructor(cfg, store, fetchFn = fetch, now = () => Date.now()) {
36
+ this.cfg = cfg;
37
+ this.store = store;
38
+ this.fetchFn = fetchFn;
39
+ this.now = now;
40
+ }
41
+ deviceUrl() { return `${this.cfg.authority}/protocol/openid-connect/auth/device`; }
42
+ tokenUrl() { return `${this.cfg.authority}/protocol/openid-connect/token`; }
43
+ async startLogin() {
44
+ const res = await this.fetchFn(this.deviceUrl(), {
45
+ method: 'POST',
46
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
47
+ body: new URLSearchParams({ client_id: this.cfg.client, scope: 'openid offline_access' }),
48
+ });
49
+ if (!res.ok)
50
+ throw new Error(`device flow start failed: ${res.status}`);
51
+ const dc = (await res.json());
52
+ await this.store.save({ pending: { deviceCode: dc.device_code, interval: dc.interval, deadline: this.now() + dc.expires_in * 1000 } });
53
+ return dc;
54
+ }
55
+ async getAccessToken() {
56
+ const state = await this.store.load();
57
+ if (!state)
58
+ throw new AuthRequiredError('Не авторизовано. Запустите инструмент login.');
59
+ if (state.tokens && state.tokens.expiresAt - this.now() > EXPIRY_SKEW_MS)
60
+ return state.tokens.accessToken;
61
+ if (state.tokens)
62
+ return this.refreshOrThrow(state.tokens.refreshToken);
63
+ if (state.pending)
64
+ return this.completePendingOrThrow(state.pending);
65
+ throw new AuthRequiredError('Не авторизовано. Запустите инструмент login.');
66
+ }
67
+ async invalidateAndRefresh() {
68
+ const state = await this.store.load();
69
+ if (!state?.tokens)
70
+ throw new AuthRequiredError('Не авторизовано. Запустите инструмент login.');
71
+ return this.refreshOrThrow(state.tokens.refreshToken);
72
+ }
73
+ async status() {
74
+ const state = await this.store.load();
75
+ if (!state?.tokens)
76
+ return { loggedIn: false };
77
+ const username = decodeUsername(state.tokens.accessToken);
78
+ return username ? { loggedIn: true, username } : { loggedIn: true };
79
+ }
80
+ async logout() { await this.store.clear(); }
81
+ async refreshOrThrow(refreshToken) {
82
+ const res = await this.fetchFn(this.tokenUrl(), {
83
+ method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' },
84
+ body: new URLSearchParams({ grant_type: 'refresh_token', refresh_token: refreshToken, client_id: this.cfg.client }),
85
+ });
86
+ if (!res.ok) {
87
+ await this.store.clear();
88
+ throw new AuthRequiredError('Сессия истекла. Запустите инструмент login заново.');
89
+ }
90
+ const b = (await res.json());
91
+ const tokens = { accessToken: b.access_token, refreshToken: b.refresh_token, expiresAt: this.now() + b.expires_in * 1000 };
92
+ await this.store.save({ tokens });
93
+ return tokens.accessToken;
94
+ }
95
+ async completePendingOrThrow(pending) {
96
+ if (this.now() > pending.deadline) {
97
+ await this.store.clear();
98
+ throw new AuthRequiredError('Код подтверждения истёк. Запустите login заново.');
99
+ }
100
+ const res = await this.fetchFn(this.tokenUrl(), {
101
+ method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' },
102
+ body: new URLSearchParams({ grant_type: DEVICE_GRANT, device_code: pending.deviceCode, client_id: this.cfg.client }),
103
+ });
104
+ if (res.ok) {
105
+ const b = (await res.json());
106
+ const tokens = { accessToken: b.access_token, refreshToken: b.refresh_token, expiresAt: this.now() + b.expires_in * 1000 };
107
+ await this.store.save({ tokens });
108
+ return tokens.accessToken;
109
+ }
110
+ const err = (await res.json().catch(() => ({})));
111
+ if (err.error === 'authorization_pending' || err.error === 'slow_down')
112
+ throw new AuthPendingError('Вход ещё не подтверждён. Подтвердите в браузере и повторите запрос.');
113
+ await this.store.clear();
114
+ throw new AuthRequiredError('Не удалось войти. Запустите инструмент login заново.');
115
+ }
116
+ }
117
+ /** preferred_username/email из payload JWT (без верификации — только для отображения). */
118
+ function decodeUsername(jwt) {
119
+ const parts = jwt.split('.');
120
+ if (parts.length < 2)
121
+ return undefined;
122
+ try {
123
+ const p = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
124
+ return p.preferred_username ?? p.email ?? undefined;
125
+ }
126
+ catch {
127
+ return undefined;
128
+ }
129
+ }
package/dist/config.js ADDED
@@ -0,0 +1,16 @@
1
+ import { homedir } from 'node:os';
2
+ import { join } from 'node:path';
3
+ export function loadConfig(env = process.env) {
4
+ const base = (env.RECAP_URL ?? '').replace(/\/+$/, '');
5
+ const rootDir = env.MOAB_CONFIG_DIR ?? join(homedir(), '.moab');
6
+ return {
7
+ apiBase: base,
8
+ authority: env.KEYCLOAK_AUTHORITY ?? 'https://auth.moab.tools/realms/moab',
9
+ client: env.KEYCLOAK_CLIENT ?? 'share-cli',
10
+ configDir: join(rootDir, 'recap-mcp'),
11
+ userEmail: env.RECAP_USER_EMAIL,
12
+ userName: env.RECAP_USER_NAME,
13
+ stateDir: homedir(),
14
+ claudeDir: join(homedir(), '.claude'),
15
+ };
16
+ }
package/dist/errors.js ADDED
@@ -0,0 +1,20 @@
1
+ export class ApiError extends Error {
2
+ status;
3
+ bodyText;
4
+ constructor(status, bodyText) {
5
+ super(`API ${status}`);
6
+ this.status = status;
7
+ this.bodyText = bodyText;
8
+ }
9
+ }
10
+ /** Дружелюбный русский текст по HTTP-коду ответа /api/recaps. */
11
+ export function ruRecapError(status, body) {
12
+ switch (status) {
13
+ case 401: return '✗ Вход не принят или истёк. Выполни инструмент login (device-flow) и повтори.';
14
+ case 403: return '✗ Доступ запрещён: нужна портальная роль crew. Обратись к администратору портала.';
15
+ case 409: return '⚠ Сервер счёл recap дубликатом (409). Если ты не отправлял его только что повторно — возможно ушло пустое тело; проверь дашборд. Памятка НЕ обновлена.';
16
+ case 429: return '✗ Rate limit превышен, попробуй позже.';
17
+ case 400: return `✗ Сервер отверг recap (400): ${body.slice(0, 300)}`;
18
+ default: return `✗ Recap не доставлен (HTTP ${status}): ${body.slice(0, 300)}`;
19
+ }
20
+ }
package/dist/git.js ADDED
@@ -0,0 +1,50 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ /** Парсер shortstat-лога: commits = строки с хешем; суммы из "N files changed, …". */
3
+ export function parseGitStats(log) {
4
+ let commitsCount = 0, filesChanged = 0, linesAdded = 0, linesRemoved = 0;
5
+ for (const line of log.split('\n')) {
6
+ if (/^[0-9a-f]{7,40}\s/.test(line))
7
+ commitsCount++;
8
+ let m;
9
+ if ((m = line.match(/(\d+) files? changed/)))
10
+ filesChanged += Number(m[1]);
11
+ if ((m = line.match(/(\d+) insertions?\(\+\)/)))
12
+ linesAdded += Number(m[1]);
13
+ if ((m = line.match(/(\d+) deletions?\(-\)/)))
14
+ linesRemoved += Number(m[1]);
15
+ }
16
+ return { commitsCount, filesChanged, linesAdded, linesRemoved };
17
+ }
18
+ function git(cwd, args) {
19
+ return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
20
+ }
21
+ /** Собрать git-статистику окна. since — ISO или "12 hours ago"; authorEmail для фильтра log. */
22
+ export function collectGit(cwd, since, authorEmail) {
23
+ let isRepo = true;
24
+ try {
25
+ git(cwd, ['rev-parse', '--is-inside-work-tree']);
26
+ }
27
+ catch {
28
+ isRepo = false;
29
+ }
30
+ if (!isRepo) {
31
+ return { isRepo: false, raw: null, commitsCount: 0, filesChanged: 0, linesAdded: 0, linesRemoved: 0 };
32
+ }
33
+ let diffStat = '', log = '';
34
+ try {
35
+ diffStat = git(cwd, ['diff', '--stat', 'HEAD']);
36
+ }
37
+ catch { /* пусто */ }
38
+ const logArgs = ['log', `--since=${since}`, '--no-merges', '--shortstat', '--pretty=tformat:%h %s'];
39
+ if (authorEmail)
40
+ logArgs.push(`--author=${authorEmail}`); // пустой author → без фильтра (иначе git матчит ВСЕ коммиты); окно ограничено --since
41
+ try {
42
+ log = git(cwd, logArgs);
43
+ }
44
+ catch { /* пусто */ }
45
+ const stats = parseGitStats(log);
46
+ let raw = `${diffStat}\n${log}`.trim() || null;
47
+ if (raw && raw.length > 4800)
48
+ raw = raw.slice(0, 4800) + '\n…(обрезано)';
49
+ return { isRepo: true, raw, ...stats };
50
+ }
package/dist/index.js ADDED
@@ -0,0 +1,24 @@
1
+ #!/usr/bin/env node
2
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
+ import { loadConfig } from './config.js';
4
+ import { createServer } from './server.js';
5
+ import { runInit } from './init.js';
6
+ import { runSessionStart } from './session-start.js';
7
+ async function main() {
8
+ const sub = process.argv[2];
9
+ if (sub === 'init') {
10
+ await runInit();
11
+ return;
12
+ }
13
+ if (sub === 'session-start') {
14
+ await runSessionStart();
15
+ return;
16
+ }
17
+ const server = createServer({ config: loadConfig() });
18
+ await server.connect(new StdioServerTransport());
19
+ }
20
+ main().catch((e) => {
21
+ // stdout зарезервирован под MCP-протокол — диагностика только в stderr.
22
+ console.error('moab-recap fatal:', e);
23
+ process.exit(1);
24
+ });
package/dist/init.js ADDED
@@ -0,0 +1,60 @@
1
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, copyFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+ function readJson(file) {
5
+ if (!existsSync(file))
6
+ return {};
7
+ try {
8
+ return JSON.parse(readFileSync(file, 'utf8'));
9
+ }
10
+ catch {
11
+ return {};
12
+ }
13
+ }
14
+ /**
15
+ * `recap-mcp init`: прописать MCP-сервер (~/.claude.json) и SessionStart-хук (~/.claude/settings.json).
16
+ * Идемпотентно. Перед перезаписью ~/.claude.json делает бэкап (критичный файл, управляется Claude Code).
17
+ */
18
+ export async function runInit() {
19
+ const home = homedir();
20
+ const claudeJson = join(home, '.claude.json'); // user-scope MCP servers — ЗДЕСЬ
21
+ const settingsJson = join(home, '.claude', 'settings.json'); // hooks — ЗДЕСЬ
22
+ mkdirSync(join(home, '.claude'), { recursive: true });
23
+ // 1) MCP-сервер в ~/.claude.json (type:stdio; литеральные дефолты — без ${...}-интерполяции)
24
+ const cj = readJson(claudeJson);
25
+ if (existsSync(claudeJson)) {
26
+ try {
27
+ copyFileSync(claudeJson, claudeJson + '.recap-bak');
28
+ }
29
+ catch { /* бэкап best-effort */ }
30
+ }
31
+ cj.mcpServers ??= {};
32
+ cj.mcpServers['moab-recap'] = {
33
+ type: 'stdio',
34
+ command: 'npx',
35
+ args: ['-y', '@moabpro/recap-mcp'],
36
+ env: {
37
+ RECAP_URL: 'https://recap.moab.tools',
38
+ KEYCLOAK_AUTHORITY: 'https://auth.moab.tools/realms/moab',
39
+ KEYCLOAK_CLIENT: 'share-cli',
40
+ },
41
+ };
42
+ writeFileSync(claudeJson, JSON.stringify(cj, null, 2));
43
+ // 2) SessionStart-хук в ~/.claude/settings.json (command → npx recap-mcp session-start)
44
+ const sj = readJson(settingsJson);
45
+ sj.hooks ??= {};
46
+ sj.hooks.SessionStart ??= [];
47
+ const already = JSON.stringify(sj.hooks.SessionStart).includes('recap-mcp');
48
+ if (!already) {
49
+ sj.hooks.SessionStart.push({
50
+ matcher: '*',
51
+ hooks: [{ type: 'command', command: 'npx -y @moabpro/recap-mcp session-start', timeout: 15 }],
52
+ });
53
+ }
54
+ writeFileSync(settingsJson, JSON.stringify(sj, null, 2));
55
+ process.stdout.write('✓ recap-mcp прописан:\n' +
56
+ ` • MCP-сервер → ${claudeJson} (mcpServers.moab-recap, type=stdio)\n` +
57
+ ` • SessionStart хук → ${settingsJson} (hooks.SessionStart)\n` +
58
+ 'После перезапуска Claude Code выполните инструмент login (device-flow) — нужна портальная роль crew.\n' +
59
+ 'Перезапусти Claude Code.\n');
60
+ }
package/dist/meta.js ADDED
@@ -0,0 +1,58 @@
1
+ import { hostname } from 'node:os';
2
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
3
+ import { join, basename } from 'node:path';
4
+ import { execFileSync } from 'node:child_process';
5
+ import { sessionsDir } from './paths.js';
6
+ function gitConfig(cwd, key) {
7
+ try {
8
+ return execFileSync('git', ['config', key], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() || null;
9
+ }
10
+ catch {
11
+ return null;
12
+ }
13
+ }
14
+ /** Текущий session_id: env CLAUDE_RECAP_SESSION_ID, иначе имя свежайшего session-state-файла. */
15
+ export function currentSessionId(env = process.env) {
16
+ if (env.CLAUDE_RECAP_SESSION_ID)
17
+ return env.CLAUDE_RECAP_SESSION_ID;
18
+ const dir = sessionsDir();
19
+ if (!existsSync(dir))
20
+ return null;
21
+ let best = null;
22
+ for (const f of readdirSync(dir)) {
23
+ if (!f.endsWith('.json'))
24
+ continue;
25
+ try {
26
+ const m = statSync(join(dir, f)).mtimeMs;
27
+ if (!best || m > best.m)
28
+ best = { id: basename(f, '.json'), m };
29
+ }
30
+ catch { /* skip */ }
31
+ }
32
+ return best?.id ?? null;
33
+ }
34
+ /** Закреплённый проект сессии (project=null + research_mode при research). */
35
+ export function readSessionState(sid) {
36
+ if (!sid)
37
+ return { project: null, research_mode: false };
38
+ const file = join(sessionsDir(), `${sid}.json`);
39
+ if (!existsSync(file))
40
+ return { project: null, research_mode: false };
41
+ try {
42
+ const d = JSON.parse(readFileSync(file, 'utf8'));
43
+ return { project: d.project ?? null, research_mode: !!d.research_mode };
44
+ }
45
+ catch {
46
+ return { project: null, research_mode: false };
47
+ }
48
+ }
49
+ export function collectMeta(cfg, cwd) {
50
+ return {
51
+ userEmail: cfg.userEmail ?? gitConfig(cwd, 'user.email') ?? 'unknown',
52
+ userName: cfg.userName ?? gitConfig(cwd, 'user.name') ?? cfg.userEmail ?? 'unknown',
53
+ projectPath: cwd,
54
+ clientHost: hostname(),
55
+ gitRemoteUrl: gitConfig(cwd, 'remote.origin.url'),
56
+ clientTimestamp: new Date().toISOString(),
57
+ };
58
+ }
package/dist/paths.js ADDED
@@ -0,0 +1,17 @@
1
+ import { homedir } from 'node:os';
2
+ import { join } from 'node:path';
3
+ /** Стабильный ключ пути для памятки: lowercase, forward slashes, /c/x → c:/x, без хвостовых /. */
4
+ export function normalizeCwdKey(p) {
5
+ let s = p.replace(/\\/g, '/').toLowerCase();
6
+ // /c/proj → c:/proj
7
+ const m = s.match(/^\/([a-z])\/(.*)$/);
8
+ if (m)
9
+ s = `${m[1]}:/${m[2]}`;
10
+ s = s.replace(/\/+$/, '');
11
+ return s;
12
+ }
13
+ export const lastRecapFile = () => join(homedir(), '.claude-recap-last.json');
14
+ export const sessionsDir = () => join(homedir(), '.claude-recap-sessions');
15
+ export const projectsDir = () => join(homedir(), '.claude', 'projects');
16
+ /** Корень проекта: Claude Code выставляет CLAUDE_PROJECT_DIR в env MCP-сервера; иначе текущий cwd. */
17
+ export const projectDir = () => process.env.CLAUDE_PROJECT_DIR || process.cwd();
@@ -0,0 +1,6 @@
1
+ import { registerMoabrecapPrompt } from './moabrecap.js';
2
+ import { registerSwitchProjectPrompt } from './switch-project.js';
3
+ export function registerPrompts(server) {
4
+ registerMoabrecapPrompt(server);
5
+ registerSwitchProjectPrompt(server);
6
+ }
@@ -0,0 +1,36 @@
1
+ import { z } from 'zod';
2
+ const RULES = `Сделай recap текущей работы и отправь его инструментом send_recap.
3
+
4
+ ОКНО: описывай только то, что сделано С МОМЕНТА ПРОШЛОГО РЕКАПА (инструмент сам посчитает окно
5
+ по git и времени; ты отрази в summary суть именно этого периода — поможет git log/diff и контекст).
6
+
7
+ SUMMARY (поле summary) — для РУКОВОДИТЕЛЯ, НЕ для разработчика:
8
+ - 3-5 предложений по-русски, жёсткий потолок 1800 символов.
9
+ - Простым языком, БЕЗ техники: не «refactor/bundle/endpoint/Mongo/.NET/имена файлов», а бизнес-результат
10
+ («сделали так, что у каждого свой ключ доступа», «дашборд теперь грузится в портал»).
11
+ - ОБЯЗАТЕЛЬНО в конце 1-2 предложения про «обратную сторону»: что НЕ успели, где застряли, какой
12
+ долг вскрылся. Если реально всё гладко — одно предложение «Открытых хвостов в этом окне нет.»
13
+
14
+ TAGS (1-3 из): feature, bugfix, refactor, docs, tests, setup, infra, research, review, planning, other.
15
+ COMPLEXITY (1-5): 1=опечатка/2=мелкий CRUD/3=многокомпонентная фича или нетривиальный дебаг/4=многофайловый
16
+ рефактор или сложная гонка/5=архитектура.
17
+ EFFORT (1-5): 1<15мин/2~30мин/3=1-2ч/4=полдня/5=день+.
18
+
19
+ ПРОВЕРКА «маловато»: если complexity ≤ 3 — сначала спроси пользователя через AskUserQuestion
20
+ «Маловато изменений для рекапа. Может, ещё поработаем?» (варианты: «Да, поработаем» / «Нет, сделать рекап
21
+ сейчас»). Если «Да» — НЕ вызывай send_recap, заверши строкой «✓ Ок, продолжаем — recap не отправлен.»
22
+ Если complexity ≥ 4 — пропусти проверку и сразу вызывай send_recap.
23
+
24
+ Если пользователь дал подсказку-аргумент — учти её и передай как userHint.`;
25
+ export function registerMoabrecapPrompt(server) {
26
+ server.registerPrompt('moabrecap', {
27
+ title: 'Сделать moabrecap',
28
+ description: 'Описать сделанное с прошлого рекапа и отправить через moabrecap (send_recap).',
29
+ argsSchema: { hint: z.string().optional().describe('Опц. подсказка, о чём был период') },
30
+ }, ({ hint }) => ({
31
+ messages: [{
32
+ role: 'user',
33
+ content: { type: 'text', text: hint ? `${RULES}\n\nПодсказка пользователя: ${hint}` : RULES },
34
+ }],
35
+ }));
36
+ }
@@ -0,0 +1,36 @@
1
+ import { z } from 'zod';
2
+ const RULES = `Сделай recap текущей работы и отправь его инструментом send_recap.
3
+
4
+ ОКНО: описывай только то, что сделано С МОМЕНТА ПРОШЛОГО РЕКАПА (инструмент сам посчитает окно
5
+ по git и времени; ты отрази в summary суть именно этого периода — поможет git log/diff и контекст).
6
+
7
+ SUMMARY (поле summary) — для РУКОВОДИТЕЛЯ, НЕ для разработчика:
8
+ - 3-5 предложений по-русски, жёсткий потолок 1800 символов.
9
+ - Простым языком, БЕЗ техники: не «refactor/bundle/endpoint/Mongo/.NET/имена файлов», а бизнес-результат
10
+ («сделали так, что у каждого свой ключ доступа», «дашборд теперь грузится в портал»).
11
+ - ОБЯЗАТЕЛЬНО в конце 1-2 предложения про «обратную сторону»: что НЕ успели, где застряли, какой
12
+ долг вскрылся. Если реально всё гладко — одно предложение «Открытых хвостов в этом окне нет.»
13
+
14
+ TAGS (1-3 из): feature, bugfix, refactor, docs, tests, setup, infra, research, review, planning, other.
15
+ COMPLEXITY (1-5): 1=опечатка/2=мелкий CRUD/3=многокомпонентная фича или нетривиальный дебаг/4=многофайловый
16
+ рефактор или сложная гонка/5=архитектура.
17
+ EFFORT (1-5): 1<15мин/2~30мин/3=1-2ч/4=полдня/5=день+.
18
+
19
+ ПРОВЕРКА «маловато»: если complexity ≤ 3 — сначала спроси пользователя через AskUserQuestion
20
+ «Маловато изменений для рекапа. Может, ещё поработаем?» (варианты: «Да, поработаем» / «Нет, сделать рекап
21
+ сейчас»). Если «Да» — НЕ вызывай send_recap, заверши строкой «✓ Ок, продолжаем — recap не отправлен.»
22
+ Если complexity ≥ 4 — пропусти проверку и сразу вызывай send_recap.
23
+
24
+ Если пользователь дал подсказку-аргумент — учти её и передай как userHint.`;
25
+ export function registerRecapPrompt(server) {
26
+ server.registerPrompt('recap', {
27
+ title: 'Сделать recap',
28
+ description: 'Описать сделанное с прошлого рекапа и отправить (через send_recap).',
29
+ argsSchema: { hint: z.string().optional().describe('Опц. подсказка, о чём был период') },
30
+ }, ({ hint }) => ({
31
+ messages: [{
32
+ role: 'user',
33
+ content: { type: 'text', text: hint ? `${RULES}\n\nПодсказка пользователя: ${hint}` : RULES },
34
+ }],
35
+ }));
36
+ }
@@ -0,0 +1,20 @@
1
+ import { z } from 'zod';
2
+ const RULES = `Смени проект, закреплённый за этой сессией.
3
+
4
+ 1. Если у текущей сессии уже закреплён проект и это не research — сначала предложи пользователю
5
+ сделать /moabrecap по текущему проекту (через AskUserQuestion). Если согласен — выполни /moabrecap, потом продолжай.
6
+ 2. Если дан аргумент-имя — используй его напрямую как имя проекта. Иначе вызови инструмент list_projects,
7
+ покажи нумерованный список (плюс «0. Research-сессия без проекта» и «N. Создать новый проект»), жди цифру.
8
+ 3. Сопоставь цифру: 0 → pin_project с "__research__"; существующий → pin_project с этим именем;
9
+ «создать новый» → спроси имя и pin_project с ним.
10
+ 4. После закрепления — подтверди и покажи готовую строку «/rename <имя>» (её пользователь скопирует сам;
11
+ сам /rename вызвать нельзя).`;
12
+ export function registerSwitchProjectPrompt(server) {
13
+ server.registerPrompt('switch-project', {
14
+ title: 'Сменить проект сессии',
15
+ description: 'Выбрать/сменить проект, закреплённый за текущей сессией рекапов.',
16
+ argsSchema: { name: z.string().optional().describe('Имя проекта (если уже известно)') },
17
+ }, ({ name }) => ({
18
+ messages: [{ role: 'user', content: { type: 'text', text: name ? `${RULES}\n\nИмя проекта: ${name}` : RULES } }],
19
+ }));
20
+ }
package/dist/server.js ADDED
@@ -0,0 +1,13 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { RecapApi } from './api-client.js';
3
+ import { FileTokenStore, Authenticator } from './auth.js';
4
+ import { registerTools } from './tools/index.js';
5
+ import { registerPrompts } from './prompts/index.js';
6
+ export function createServer(deps) {
7
+ const server = new McpServer({ name: 'moab-recap', version: '0.1.0' });
8
+ const auth = new Authenticator({ authority: deps.config.authority, client: deps.config.client }, new FileTokenStore(deps.config.configDir));
9
+ const api = new RecapApi(deps.config, auth);
10
+ registerTools(server, { config: deps.config, api, auth });
11
+ registerPrompts(server);
12
+ return server;
13
+ }
@@ -0,0 +1,44 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { sessionsDir } from './paths.js';
4
+ import { writeSessionStateFile } from './state.js';
5
+ import { readSessionState } from './meta.js';
6
+ /**
7
+ * Хук SessionStart (`recap-mcp session-start`): stdin = JSON Claude Code (session_id, cwd, source...).
8
+ * Пишем session-state-файл (session_id + cwd, не затирая закреплённый проект) и просим закрепить проект,
9
+ * если он ещё не выбран. additionalContext отдаём в stdout как JSON (форма подтверждена по докам CC).
10
+ */
11
+ export async function runSessionStart() {
12
+ let raw = '';
13
+ try {
14
+ raw = readFileSync(0, 'utf8');
15
+ }
16
+ catch { /* нет stdin */ }
17
+ let sid = '', cwd = '';
18
+ try {
19
+ const j = JSON.parse(raw);
20
+ sid = j.session_id ?? '';
21
+ cwd = j.cwd ?? '';
22
+ }
23
+ catch { /* ignore */ }
24
+ // 1) записать/смержить session-state (session_id + cwd); resume/clear/compact не затирают project
25
+ if (sid) {
26
+ writeSessionStateFile(join(sessionsDir(), `${sid}.json`), {
27
+ session_id: sid, cwd, started_at: new Date().toISOString(),
28
+ });
29
+ }
30
+ // 2) подсказка в контекст — нудж к /switch-project только если проект ещё не закреплён
31
+ const sess = sid ? readSessionState(sid) : { project: null, research_mode: false };
32
+ let ctx;
33
+ if (sess.research_mode) {
34
+ ctx = 'Эта сессия в research-режиме (рекапы выключены). /switch-project — выбрать проект.';
35
+ }
36
+ else if (sess.project) {
37
+ ctx = `Проект «${sess.project}» закреплён за этой сессией (рекапы). /moabrecap — сделать recap, /switch-project — сменить.`;
38
+ }
39
+ else {
40
+ ctx = 'Прежде чем работать — закрепи проект для рекапов: вызови команду /switch-project '
41
+ + '(она покажет список проектов и закрепит выбор за сессией). Без проекта /moabrecap не отправится.';
42
+ }
43
+ process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: ctx } }));
44
+ }
package/dist/state.js ADDED
@@ -0,0 +1,43 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { normalizeCwdKey, sessionsDir } from './paths.js';
4
+ function readMap(file) {
5
+ if (!existsSync(file))
6
+ return {};
7
+ try {
8
+ const parsed = JSON.parse(readFileSync(file, 'utf8'));
9
+ return parsed && typeof parsed === 'object' ? parsed : {};
10
+ }
11
+ catch {
12
+ return {}; // битый файл — трактуем как пустой, не падаем
13
+ }
14
+ }
15
+ /** ISO-таймстамп последнего рекапа для cwd, либо null. Ключ нормализуется. */
16
+ export function readLastRecap(file, cwd) {
17
+ const map = readMap(file);
18
+ return map[normalizeCwdKey(cwd)] ?? null;
19
+ }
20
+ /** Записать таймстамп последнего рекапа для cwd (merge, не затирая другие). */
21
+ export function writeLastRecap(file, cwd, isoTs) {
22
+ const map = readMap(file);
23
+ map[normalizeCwdKey(cwd)] = isoTs;
24
+ mkdirSync(dirname(file), { recursive: true });
25
+ writeFileSync(file, JSON.stringify(map, null, 2));
26
+ }
27
+ /** Низкоуровневое слияние патча в session-state-файл (создаёт папку, переживает битый JSON). */
28
+ export function writeSessionStateFile(file, patch) {
29
+ mkdirSync(dirname(file), { recursive: true });
30
+ const prev = (() => { try {
31
+ return JSON.parse(readFileSync(file, 'utf8'));
32
+ }
33
+ catch {
34
+ return {};
35
+ } })();
36
+ writeFileSync(file, JSON.stringify({ ...prev, ...patch }, null, 2));
37
+ }
38
+ /** Закрепить проект (или research) за сессией: пишет ~/.claude-recap-sessions/<sid>.json. */
39
+ export function writeSessionProject(sid, project, research) {
40
+ writeSessionStateFile(join(sessionsDir(), `${sid}.json`), {
41
+ project, research_mode: research, pinned_at: new Date().toISOString(),
42
+ });
43
+ }
@@ -0,0 +1,31 @@
1
+ import { run, ok } from './index.js';
2
+ export function registerAuthTools(server, deps) {
3
+ server.registerTool('login', {
4
+ title: 'Войти',
5
+ description: 'Вход в MoabRecap через auth.moab.tools (device flow). Возвращает ссылку и код для подтверждения в браузере. Нужна портальная роль crew.',
6
+ inputSchema: {},
7
+ }, async () => run(async () => {
8
+ const dc = await deps.auth.startLogin();
9
+ return [
10
+ 'Чтобы войти, откройте ссылку и подтвердите вход:',
11
+ dc.verification_uri_complete,
12
+ `Код подтверждения: ${dc.user_code}`,
13
+ '',
14
+ 'После подтверждения просто повторите нужную операцию (/moabrecap или /switch-project) — вход применится автоматически.',
15
+ ].join('\n');
16
+ }));
17
+ server.registerTool('auth_status', {
18
+ title: 'Статус входа',
19
+ description: 'Показать, выполнен ли вход и под каким пользователем.',
20
+ inputSchema: {},
21
+ annotations: { readOnlyHint: true },
22
+ }, async () => run(async () => {
23
+ const s = await deps.auth.status();
24
+ return s.loggedIn ? `Вы вошли как ${s.username ?? '(неизвестно)'}.` : 'Вход не выполнен. Запустите инструмент login.';
25
+ }));
26
+ server.registerTool('auth_logout', {
27
+ title: 'Выйти',
28
+ description: 'Удалить сохранённые токены входа.',
29
+ inputSchema: {},
30
+ }, async () => { await deps.auth.logout(); return ok('Вы вышли. Сохранённые токены удалены.'); });
31
+ }
@@ -0,0 +1,24 @@
1
+ import { ApiError, ruRecapError } from '../errors.js';
2
+ import { AuthRequiredError, AuthPendingError } from '../auth-errors.js';
3
+ import { registerSendRecap } from './send-recap.js';
4
+ import { registerSwitchProject } from './switch-project.js';
5
+ import { registerAuthTools } from './auth-tools.js';
6
+ export const ok = (text) => ({ content: [{ type: 'text', text }] });
7
+ export const fail = (text) => ({ content: [{ type: 'text', text }], isError: true });
8
+ export async function run(fn) {
9
+ try {
10
+ return ok(await fn());
11
+ }
12
+ catch (e) {
13
+ if (e instanceof AuthRequiredError || e instanceof AuthPendingError)
14
+ return fail(e.message);
15
+ if (e instanceof ApiError)
16
+ return fail(ruRecapError(e.status, e.bodyText));
17
+ return fail(`✗ Ошибка: ${e instanceof Error ? e.message : String(e)}`);
18
+ }
19
+ }
20
+ export function registerTools(server, deps) {
21
+ registerAuthTools(server, deps);
22
+ registerSendRecap(server, deps);
23
+ registerSwitchProject(server, deps);
24
+ }
@@ -0,0 +1,63 @@
1
+ import { z } from 'zod';
2
+ import { run } from './index.js';
3
+ import { collectMeta, currentSessionId, readSessionState } from '../meta.js';
4
+ import { lastRecapFile, projectDir } from '../paths.js';
5
+ import { readLastRecap, writeLastRecap } from '../state.js';
6
+ import { latestTranscriptPath, readTranscript, firstUserTimestamp, activeMinutes } from '../transcript.js';
7
+ import { computeSince } from '../window.js';
8
+ import { collectGit } from '../git.js';
9
+ import { ApiError } from '../errors.js';
10
+ const TAGS = ['feature', 'bugfix', 'refactor', 'docs', 'tests', 'setup', 'infra', 'research', 'review', 'planning', 'other'];
11
+ export function registerSendRecap(server, deps) {
12
+ server.registerTool('send_recap', {
13
+ title: 'Отправить recap',
14
+ description: 'Отправляет recap на сервер MoabRecap. Вызывать ТОЛЬКО из команды /moabrecap, передав уже написанное ' +
15
+ 'summary (3-5 предложений для руководства, простым языком, с «обратной стороной»), tags, complexity, effort. ' +
16
+ 'Метаданные, окно «с прошлого рекапа», git-стату и активные минуты тул соберёт сам.',
17
+ inputSchema: {
18
+ summary: z.string().min(1).max(1800).describe('Готовое описание для руководства, простым языком'),
19
+ tags: z.array(z.enum(TAGS)).min(1).max(3).describe('1-3 тега из словаря'),
20
+ complexityRating: z.number().int().min(1).max(5),
21
+ effortRating: z.number().int().min(1).max(5),
22
+ userHint: z.string().max(500).optional().describe('Опц. подсказка от пользователя'),
23
+ },
24
+ }, async ({ summary, tags, complexityRating, effortRating, userHint }) => run(async () => {
25
+ if (!deps.config.apiBase)
26
+ throw new Error('Не настроен RECAP_URL (запусти `recap-mcp init`).');
27
+ const cwd = projectDir();
28
+ const sid = currentSessionId();
29
+ const sess = readSessionState(sid);
30
+ if (sess.research_mode)
31
+ throw new Error('Сессия в research-режиме — recap не отправляется. /switch-project чтобы выбрать проект.');
32
+ if (!sess.project)
33
+ throw new Error('Проект для сессии не закреплён. Вызови /switch-project.');
34
+ await deps.auth.getAccessToken(); // бросит AuthRequiredError → run() покажет «запустите login»
35
+ const meta = collectMeta(deps.config, cwd);
36
+ const tPath = latestTranscriptPath();
37
+ const transcript = readTranscript(tPath);
38
+ const sessionStart = firstUserTimestamp(transcript);
39
+ const lastRecap = readLastRecap(lastRecapFile(), cwd);
40
+ const win = computeSince({ lastRecap, sessionStart });
41
+ const git = collectGit(cwd, win.gitSince, meta.userEmail);
42
+ const active = transcript ? activeMinutes(transcript, win.sinceIso, meta.clientTimestamp) : null;
43
+ const body = {
44
+ project: sess.project,
45
+ projectPath: meta.projectPath, clientHost: meta.clientHost, gitRemoteUrl: meta.gitRemoteUrl,
46
+ summary, userHint: userHint ?? null, tags,
47
+ complexityRating, effortRating,
48
+ filesChanged: git.filesChanged, linesAdded: git.linesAdded,
49
+ linesRemoved: git.linesRemoved, commitsCount: git.commitsCount,
50
+ gitStatsRaw: git.raw, source: 'manual',
51
+ clientTimestamp: meta.clientTimestamp,
52
+ sessionStart, windowStart: win.sinceIso, activeMinutes: active,
53
+ };
54
+ const res = await deps.api.postRecap(body);
55
+ // успех → обновляем памятку
56
+ writeLastRecap(lastRecapFile(), cwd, new Date().toISOString());
57
+ const head = summary.slice(0, 80);
58
+ return `✓ Recap отправлен · ${head} · ${complexityRating}/5C · ${effortRating}/5E (id=${res.id})`;
59
+ }));
60
+ }
61
+ // ApiError (409 и др.) пробрасывается в run() и превращается в дружелюбный текст;
62
+ // памятка при ошибке НЕ обновляется (writeLastRecap не достигается).
63
+ void ApiError;
@@ -0,0 +1,31 @@
1
+ import { z } from 'zod';
2
+ import { run } from './index.js';
3
+ import { currentSessionId } from '../meta.js';
4
+ import { writeSessionProject } from '../state.js';
5
+ export function registerSwitchProject(server, deps) {
6
+ server.registerTool('list_projects', {
7
+ title: 'Список проектов',
8
+ description: 'Вернуть проекты пользователя с сервера (имя + число рекапов) для выбора в /switch-project.',
9
+ inputSchema: {},
10
+ annotations: { readOnlyHint: true },
11
+ }, async () => run(async () => {
12
+ const ps = await deps.api.myProjects();
13
+ if (ps.length === 0)
14
+ return 'Проектов пока нет.';
15
+ return ps.map((p, i) => `${i + 1}. ${p.project} (${p.count} recap-ов)`).join('\n');
16
+ }));
17
+ server.registerTool('pin_project', {
18
+ title: 'Закрепить проект',
19
+ description: 'Закрепить проект за текущей сессией. project="__research__" — research-режим без рекапов.',
20
+ inputSchema: { project: z.string().min(1).describe('Имя проекта или "__research__"') },
21
+ }, async ({ project }) => run(async () => {
22
+ const sid = currentSessionId();
23
+ if (!sid)
24
+ throw new Error('Не удалось определить session_id (нет CLAUDE_RECAP_SESSION_ID и session-state).');
25
+ const research = project === '__research__';
26
+ writeSessionProject(sid, research ? null : project, research);
27
+ return research
28
+ ? '✓ Research-сессия активирована (без recap-ов). → /rename Research'
29
+ : `✓ Проект «${project}» закреплён за сессией. → /rename ${project}`;
30
+ }));
31
+ }
@@ -0,0 +1,85 @@
1
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { projectsDir } from './paths.js';
4
+ const IDLE_MIN = 10;
5
+ /** Первый ивент type='user' с timestamp (служебные строки без timestamp пропускаем). */
6
+ export function firstUserTimestamp(content) {
7
+ for (const line of content.split('\n')) {
8
+ if (!line)
9
+ continue;
10
+ try {
11
+ const j = JSON.parse(line);
12
+ if (j.type === 'user' && j.timestamp)
13
+ return j.timestamp;
14
+ }
15
+ catch { /* пропускаем битую строку */ }
16
+ }
17
+ return null;
18
+ }
19
+ /** Сумма интервалов между событиями в окне [winStartIso..nowIso], интервал >IDLE_MIN не считаем. */
20
+ export function activeMinutes(content, winStartIso, nowIso) {
21
+ const ws = winStartIso ? Date.parse(winStartIso) : 0; // winStartIso=null → нижняя граница = epoch (учитываем весь транскрипт)
22
+ const now = Date.parse(nowIso);
23
+ const ts = [];
24
+ for (const line of content.split('\n')) {
25
+ if (!line)
26
+ continue;
27
+ try {
28
+ const j = JSON.parse(line);
29
+ if (j.timestamp) {
30
+ const t = Date.parse(j.timestamp);
31
+ if (!isNaN(t) && t >= ws && t <= now)
32
+ ts.push(t);
33
+ }
34
+ }
35
+ catch { /* skip */ }
36
+ }
37
+ ts.push(now);
38
+ ts.sort((a, b) => a - b);
39
+ let active = 0;
40
+ for (let i = 1; i < ts.length; i++) {
41
+ const gap = (ts[i] - ts[i - 1]) / 60000;
42
+ if (gap <= IDLE_MIN)
43
+ active += gap;
44
+ }
45
+ return Math.round(active);
46
+ }
47
+ /** Путь к самому свежему .jsonl-транскрипту во всех проектах, либо null. */
48
+ export function latestTranscriptPath(dir = projectsDir()) {
49
+ if (!existsSync(dir))
50
+ return null;
51
+ let best = null;
52
+ for (const proj of readdirSync(dir)) {
53
+ const pdir = join(dir, proj);
54
+ let entries;
55
+ try {
56
+ entries = readdirSync(pdir);
57
+ }
58
+ catch {
59
+ continue;
60
+ }
61
+ for (const f of entries) {
62
+ if (!f.endsWith('.jsonl'))
63
+ continue;
64
+ const full = join(pdir, f);
65
+ try {
66
+ const m = statSync(full).mtimeMs;
67
+ if (!best || m > best.mtime)
68
+ best = { path: full, mtime: m };
69
+ }
70
+ catch { /* skip */ }
71
+ }
72
+ }
73
+ return best?.path ?? null;
74
+ }
75
+ /** Прочитать транскрипт (или '' если нет файла). */
76
+ export function readTranscript(path) {
77
+ if (!path || !existsSync(path))
78
+ return '';
79
+ try {
80
+ return readFileSync(path, 'utf8');
81
+ }
82
+ catch {
83
+ return '';
84
+ }
85
+ }
package/dist/window.js ADDED
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Окно «с прошлого рекапа» (v2):
3
+ * - last_recap есть → since = last_recap (ВСЕГДА, даже из прошлой сессии — переживает компакт/clear/рестарт)
4
+ * - иначе session_start
5
+ * - иначе "12 hours ago" (gitSince), windowStart=null
6
+ */
7
+ export function computeSince(input) {
8
+ if (input.lastRecap)
9
+ return { sinceIso: input.lastRecap, gitSince: input.lastRecap };
10
+ if (input.sessionStart)
11
+ return { sinceIso: input.sessionStart, gitSince: input.sessionStart };
12
+ return { sinceIso: null, gitSince: '12 hours ago' };
13
+ }
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "@moabpro/recap-mcp",
3
+ "version": "0.1.0",
4
+ "description": "MCP server for MoabRecap — manual session recaps with window-since-last-recap.",
5
+ "type": "module",
6
+ "bin": { "recap-mcp": "dist/index.js" },
7
+ "files": ["dist"],
8
+ "engines": { "node": ">=20" },
9
+ "scripts": {
10
+ "build": "tsc -p tsconfig.json",
11
+ "test": "vitest run",
12
+ "prepublishOnly": "npm run build"
13
+ },
14
+ "dependencies": {
15
+ "@modelcontextprotocol/sdk": "^1.12.0",
16
+ "zod": "^3.23.8"
17
+ },
18
+ "devDependencies": {
19
+ "@types/node": "^20.14.0",
20
+ "typescript": "^5.5.0",
21
+ "vitest": "^3.2.4"
22
+ },
23
+ "license": "UNLICENSED",
24
+ "publishConfig": { "access": "public" },
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "git+https://github.com/moab-org/Recap.git",
28
+ "directory": "recap-mcp"
29
+ },
30
+ "keywords": ["mcp", "claude-code", "moabrecap", "recap"]
31
+ }