forkfeed-mcp 1.0.16 → 1.3.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/dist/index.js CHANGED
@@ -1,12 +1,15 @@
1
1
  #!/usr/bin/env node
2
2
  import { mkdir, writeFile } from 'node:fs/promises';
3
- import { join } from 'node:path';
3
+ import { execFile } from 'node:child_process';
4
+ import { promisify } from 'node:util';
5
+ import { join, basename } from 'node:path';
4
6
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
5
7
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
6
8
  import { z } from 'zod';
7
9
  import QRCode from 'qrcode';
8
10
  import { GUIDE_CONTENT } from './guide-content.js';
9
- import { IMAGE_CATALOG } from './image-catalog.js';
11
+ import { IMAGE_CATALOG, resolveImageId } from './image-catalog.js';
12
+ const execFileAsync = promisify(execFile);
10
13
  const APP_SERVER_URL = (process.env.APP_SERVER_URL || 'https://api.forkfeed.link').replace(/\/+$/, '');
11
14
  const TOKEN = process.env.FORKFEED_TOKEN || '';
12
15
  // ── Manifest schemas ──────────────────────────────────────────────────
@@ -14,7 +17,7 @@ const forkSchema = z.object({
14
17
  _id: z.string().min(1, 'Fork _id is required'),
15
18
  title: z.string().min(1, 'Fork title is required'),
16
19
  description: z.string(),
17
- imageSrc: z.string(),
20
+ imageSrc: z.string().min(1, 'Fork imageSrc is required'),
18
21
  feedIds: z.array(z.string().min(1)).min(1, 'Fork must reference at least one feed'),
19
22
  actionLabel: z.string().optional(),
20
23
  actionUrl: z.string().optional(),
@@ -23,7 +26,7 @@ const feedSchema = z.object({
23
26
  _id: z.string().min(1, 'Feed _id is required'),
24
27
  title: z.string().min(1, 'Feed title is required').max(60, 'Feed title max 60 chars'),
25
28
  description: z.string().optional(),
26
- imageSrc: z.string(),
29
+ imageSrc: z.string().min(1, 'Feed imageSrc is required'),
27
30
  mode: z.string(),
28
31
  scrollDirection: z.string(),
29
32
  engagement: z.boolean().optional(),
@@ -40,14 +43,13 @@ const contentBlockSchema = z.discriminatedUnion('type', [
40
43
  z.object({ type: z.literal('CONTENT_TITLE'), title: z.string().min(1) }).passthrough(),
41
44
  z.object({ type: z.literal('CONTENT_TEXT'), text: z.string().min(1) }).passthrough(),
42
45
  z.object({ type: z.literal('CONTENT_CODE'), code: z.string().min(1), language: z.string().optional() }).passthrough(),
43
- z.object({ type: z.literal('CONTENT_SOCIAL'), name: z.string().min(1), avatarSrc: z.string(), source: socialSourceEnum }).passthrough(),
46
+ z.object({ type: z.literal('CONTENT_SOCIAL'), name: z.string().min(1), subtitle: z.string().optional(), avatarSrc: z.string(), source: socialSourceEnum }).passthrough(),
44
47
  z.object({ type: z.literal('CONTENT_SUBTEXT'), text: z.string().min(1) }).passthrough(),
45
48
  z.object({ type: z.literal('CONTENT_QUIZ'), question: z.string().min(1), options: z.array(quizOptionSchema).min(2), explanation: z.string().optional() }).passthrough(),
46
49
  z.object({ type: z.literal('CONTENT_BUTTON'), label: z.string().min(1), action: buttonActionEnum, target: z.string().min(1) }).passthrough(),
47
50
  ]);
48
51
  const variantSchema = z.discriminatedUnion('type', [
49
- z.object({ type: z.literal('FULL_IMAGE'), imageSrc: z.string().min(1, 'FULL_IMAGE requires imageSrc'), title: z.string().optional(), subtitle: z.string().optional() }).passthrough(),
50
- z.object({ type: z.literal('FULL_VIDEO'), videoSrc: z.string().min(1), title: z.string().optional() }).passthrough(),
52
+ z.object({ type: z.literal('FULL_IMAGE'), imageSrc: z.string().min(1, 'FULL_IMAGE requires imageSrc'), title: z.string().optional(), subtitle: z.string().max(200, 'FULL_IMAGE subtitle max 200 chars').optional() }).passthrough(),
51
53
  z.object({ type: z.literal('CONTENT'), blocks: z.array(contentBlockSchema).min(1, 'CONTENT variant needs at least one block'), backgroundSrc: z.string().optional() }).passthrough(),
52
54
  ]);
53
55
  const cardSchema = z.object({
@@ -96,76 +98,334 @@ function authHeaders() {
96
98
  Authorization: `Bearer ${TOKEN}`,
97
99
  };
98
100
  }
99
- // ── MCP Server ─────────────────────────────────────────────────────────
100
- const server = new McpServer({
101
- name: 'forkfeed',
102
- version: '1.0.0',
103
- });
104
- // ── Tool: forkfeed_guide ───────────────────────────────────────────────
105
- server.tool('forkfeed_guide', 'Get the complete guide for generating forkfeed content from GitHub commits. Call this first to learn the format before generating content.', {}, async () => ({
106
- content: [{ type: 'text', text: GUIDE_CONTENT }],
107
- }));
108
- // ── Tool: forkfeed_images ──────────────────────────────────────────────
109
- server.tool('forkfeed_images', 'Get the IT Scenes image catalog (200 scene images + 30 backgrounds). Use this to pick images that match your content by tags and name. Call after forkfeed_guide.', {}, async () => {
110
- const scenes = IMAGE_CATALOG.filter((i) => i.id.startsWith('img'));
111
- const bgs = IMAGE_CATALOG.filter((i) => i.id.startsWith('bg'));
112
- const lines = [
113
- '# IT Scenes Image Catalog',
114
- '',
115
- 'Match images to content by tags and name. Use img* for covers and inline images, bg* for card backgrounds.',
116
- '',
117
- 'Tags: deploy, git, disaster, debug, hype, victory, beginner, language, lifestyle, workplace, sarcastic, general',
118
- '',
119
- '## Scene Images (covers + inline)',
120
- '',
121
- ...scenes.map((i) => `${i.id} | ${i.name} | ${i.tags} | ${i.url}`),
122
- '',
123
- '## Background Images',
124
- '',
125
- ...bgs.map((i) => `${i.id} | ${i.name} | ${i.tags} | ${i.url}`),
126
- ];
127
- return { content: [{ type: 'text', text: lines.join('\n') }] };
128
- });
129
- // ── Tool: forkfeed_push ────────────────────────────────────────────────
130
- server.tool('forkfeed_push', 'Push a generated manifest (forks, feeds, cards) to forkfeed. The manifest JSON must conform to the structure described in forkfeed_guide.', {
131
- manifest: z
132
- .object({
133
- forks: z.array(z.any()),
134
- feeds: z.array(z.any()),
135
- cards: z.array(z.any()),
136
- })
137
- .describe('The complete manifest with forks, feeds, and cards arrays'),
138
- }, async ({ manifest }) => {
139
- if (!TOKEN) {
101
+ // ── Git helpers ───────────────────────────────────────────────────────
102
+ async function gitExec(args, cwd) {
103
+ try {
104
+ const { stdout } = await execFileAsync('git', args, {
105
+ cwd,
106
+ maxBuffer: 5 * 1024 * 1024,
107
+ timeout: 15_000,
108
+ });
109
+ return stdout;
110
+ }
111
+ catch (err) {
112
+ const e = err;
113
+ if (e.code === 'ENOENT')
114
+ throw new Error('git command not found. Ensure git is installed and in PATH.');
115
+ if (e.killed)
116
+ throw new Error('git command timed out after 15s.');
117
+ throw new Error(e.stderr?.trim() || e.message || 'git command failed');
118
+ }
119
+ }
120
+ async function isGitRepo(cwd) {
121
+ try {
122
+ await gitExec(['rev-parse', '--is-inside-work-tree'], cwd);
123
+ return true;
124
+ }
125
+ catch {
126
+ return false;
127
+ }
128
+ }
129
+ async function getRepoInfo(cwd) {
130
+ try {
131
+ const url = (await gitExec(['remote', 'get-url', 'origin'], cwd)).trim();
132
+ const m = url.match(/github\.com[/:]([^/]+)\/([^/.]+)/);
133
+ if (m)
134
+ return { owner: m[1], repo: m[2], isLocal: false };
135
+ }
136
+ catch { /* no remote */ }
137
+ return { owner: null, repo: basename(cwd), isLocal: true };
138
+ }
139
+ const SEP = '\x1e'; // ASCII record separator (safe in commit messages)
140
+ async function getCommitList(cwd, count = 15) {
141
+ const fmt = `%H${SEP}%h${SEP}%s${SEP}%an${SEP}%aI`;
142
+ const raw = await gitExec(['log', '--no-merges', `-${count}`, `--format=${fmt}`], cwd);
143
+ return raw.trim().split('\n').filter(Boolean).map((line) => {
144
+ const [sha, shortSha, message, author, date] = line.split(SEP);
145
+ return { sha, shortSha, message, author, date: date?.slice(0, 10) || '' };
146
+ });
147
+ }
148
+ function truncateDiff(diff, maxLinesPerFile = 200) {
149
+ const fileDiffs = diff.split(/^(?=diff --git )/m);
150
+ return fileDiffs.map((fd) => {
151
+ if (/^Binary files/.test(fd) || fd.includes('Binary files')) {
152
+ const path = fd.match(/diff --git a\/(.+?) b\//)?.[1] || 'unknown';
153
+ return `Binary file: ${path} (skipped)`;
154
+ }
155
+ const lines = fd.split('\n');
156
+ if (lines.length <= maxLinesPerFile)
157
+ return fd;
158
+ return lines.slice(0, maxLinesPerFile).join('\n') + `\n... [truncated, ${lines.length - maxLinesPerFile} more lines]`;
159
+ }).join('\n');
160
+ }
161
+ async function getCommitDetail(cwd, sha) {
162
+ const [meta, stats, diff] = await Promise.all([
163
+ gitExec(['show', sha, '--format=%H|%h|%s|%an|%aI', '--no-patch'], cwd),
164
+ gitExec(['diff', `${sha}^..${sha}`, '--stat'], cwd),
165
+ gitExec(['diff', `${sha}^..${sha}`, '-U3'], cwd),
166
+ ]);
167
+ const [fullSha, shortSha, message, author, date] = meta.trim().split('|');
168
+ return {
169
+ sha: fullSha,
170
+ shortSha,
171
+ message,
172
+ author,
173
+ date: date?.slice(0, 10) || '',
174
+ stats: stats.trim(),
175
+ diff: truncateDiff(diff),
176
+ };
177
+ }
178
+ const TAG_KEYWORDS = {
179
+ debug: ['fix', 'bug', 'patch', 'hotfix', 'error', 'crash', 'test', 'spec'],
180
+ deploy: ['deploy', 'release', 'ci', 'cd', 'docker', 'build', 'publish', 'pipeline'],
181
+ disaster: ['break', 'crash', 'fail', 'revert', 'rollback', 'incident', 'down'],
182
+ git: ['merge', 'conflict', 'branch', 'rebase', 'cherry-pick'],
183
+ hype: ['feat', 'feature', 'add', 'new', 'launch', 'ship', 'initial'],
184
+ victory: ['complete', 'finish', 'done', 'success', 'milestone'],
185
+ language: ['typescript', 'javascript', 'python', 'rust', 'go', 'java'],
186
+ general: ['refactor', 'clean', 'update', 'chore', 'docs', 'style'],
187
+ sarcastic: ['hack', 'workaround', 'todo', 'fixme', 'wtf'],
188
+ workplace: ['meeting', 'standup', 'review', 'sprint'],
189
+ lifestyle: ['config', 'setup', 'env', 'tooling'],
190
+ };
191
+ const PATH_TAGS = [
192
+ [/\.(test|spec)\.[jt]sx?$/, 'debug'],
193
+ [/\.github\/workflows|Dockerfile|docker|\.ci|Jenkinsfile/, 'deploy'],
194
+ [/\.env|config/, 'lifestyle'],
195
+ ];
196
+ function detectTags(message, filePaths) {
197
+ const tags = new Set();
198
+ const lower = message.toLowerCase();
199
+ for (const [tag, keywords] of Object.entries(TAG_KEYWORDS)) {
200
+ if (keywords.some((kw) => lower.includes(kw)))
201
+ tags.add(tag);
202
+ }
203
+ for (const fp of filePaths) {
204
+ for (const [re, tag] of PATH_TAGS) {
205
+ if (re.test(fp))
206
+ tags.add(tag);
207
+ }
208
+ }
209
+ if (tags.size === 0)
210
+ tags.add('general');
211
+ return [...tags];
212
+ }
213
+ function parseFilePathsFromStats(stats) {
214
+ const matches = stats.match(/^\s*(.+?)\s+\|/gm) || [];
215
+ return matches.map((m) => m.trim().replace(/\s+\|$/, ''));
216
+ }
217
+ const BG_PREFS = [
218
+ ['bg10', 'bg27'], // 0: ELI5
219
+ ['bg11', 'bg1'], // 1: Roast
220
+ ['bg20', 'bg11'], // 2: Decoded
221
+ ['bg25', 'bg24'], // 3: LinkedIn
222
+ ['bg9', 'bg3'], // 4: Stats
223
+ ['bg12', 'bg13', 'bg14', 'bg15', 'bg17'], // 5: Learning (tech-match)
224
+ ['bg25', 'bg30'], // 6: Alternatives
225
+ ['bg18', 'bg5'], // 7: Quiz
226
+ ];
227
+ function assignBackgrounds(_tags) {
228
+ const used = new Set();
229
+ return BG_PREFS.map((prefs) => {
230
+ const pick = prefs.find((bg) => !used.has(bg)) || prefs[0];
231
+ used.add(pick);
232
+ return pick;
233
+ });
234
+ }
235
+ function filterImagesByTags(tags) {
236
+ const tagSet = new Set(tags);
237
+ function score(entry) {
238
+ return entry.tags.split(',').map((t) => t.trim()).filter((t) => tagSet.has(t)).length;
239
+ }
240
+ const scenes = IMAGE_CATALOG
241
+ .filter((i) => i.id.startsWith('img'))
242
+ .map((i) => ({ ...i, score: score(i) }))
243
+ .sort((a, b) => b.score - a.score)
244
+ .slice(0, 30)
245
+ .map((i) => `${i.id} | ${i.name} | ${i.tags}`);
246
+ const backgrounds = IMAGE_CATALOG
247
+ .filter((i) => i.id.startsWith('bg'))
248
+ .map((i) => ({ ...i, score: score(i) }))
249
+ .sort((a, b) => b.score - a.score)
250
+ .slice(0, 10)
251
+ .map((i) => `${i.id} | ${i.name} | ${i.tags}`);
252
+ return { scenes, backgrounds };
253
+ }
254
+ async function fetchStatusData() {
255
+ const res = await fetch(`${APP_SERVER_URL}/api/content/status`, {
256
+ headers: authHeaders(),
257
+ signal: AbortSignal.timeout(10_000),
258
+ });
259
+ const data = await res.json();
260
+ if (!res.ok)
261
+ throw new Error(`Status check failed (${res.status}): ${data.error || JSON.stringify(data)}`);
262
+ return data;
263
+ }
264
+ // ── Cover titles (fixed per card index) ──────────────────────────────
265
+ const COVERS = [
266
+ { title: "Explain Like I'm 5", subtitle: "Hopefully now you'll understand what you pushed" },
267
+ { title: "The Roast", subtitle: "Your code had it coming" },
268
+ { title: "Commit Message, Decoded", subtitle: "What you wrote vs what you meant vs what actually happened" },
269
+ { title: "The LinkedIn Post", subtitle: "Mass cringe, freshly generated" },
270
+ { title: "Statistics", subtitle: "The numbers don't lie, but they do judge" },
271
+ { title: "Learning Moment", subtitle: "Something useful buried in your chaos" },
272
+ { title: "Alternatives", subtitle: "What you could have done instead" },
273
+ { title: "Quiz", subtitle: "Let's see if you even understand your own code" },
274
+ ];
275
+ // ── Simplified block inference ───────────────────────────────────────
276
+ function inferBlock(block) {
277
+ if ('question' in block) {
140
278
  return {
141
- content: [
142
- {
143
- type: 'text',
144
- text: 'Error: FORKFEED_TOKEN not set. Get your API token from forkfeed.link/admin/user/token and add it to your .mcp.json env.',
145
- },
146
- ],
147
- isError: true,
279
+ type: 'CONTENT_QUIZ',
280
+ question: block.question,
281
+ options: block.options,
282
+ ...(block.explanation != null ? { explanation: block.explanation } : {}),
148
283
  };
149
284
  }
150
- // Validate manifest structure before pushing
151
- const parsed = manifestSchema.safeParse(manifest);
152
- if (!parsed.success) {
153
- const issues = parsed.error.issues
154
- .map((i) => ` ${i.path.join('.')}: ${i.message}`)
155
- .join('\n');
285
+ if ('name' in block && 'avatar' in block) {
286
+ const avatar = block.avatar;
156
287
  return {
157
- content: [{ type: 'text', text: `Manifest validation failed:\n${issues}\n\nFix the issues above and try again.` }],
158
- isError: true,
288
+ type: 'CONTENT_SOCIAL',
289
+ name: block.name,
290
+ avatarSrc: avatar.startsWith('http') ? avatar : resolveImageId(avatar),
291
+ source: block.source,
292
+ ...(block.subtitle != null ? { subtitle: block.subtitle } : {}),
159
293
  };
160
294
  }
161
- const crossErr = crossValidate(parsed.data);
162
- if (crossErr) {
295
+ if ('label' in block && 'action' in block && 'target' in block) {
163
296
  return {
164
- content: [{ type: 'text', text: `Manifest cross-reference errors:\n${crossErr}\n\nFix the issues above and try again.` }],
297
+ type: 'CONTENT_BUTTON',
298
+ label: block.label,
299
+ action: block.action,
300
+ target: block.target,
301
+ };
302
+ }
303
+ if ('img' in block) {
304
+ return {
305
+ type: 'CONTENT_IMAGE',
306
+ imageSrc: resolveImageId(block.img),
307
+ sizing: (block.sizing || 'wide'),
308
+ };
309
+ }
310
+ if ('code' in block) {
311
+ return {
312
+ type: 'CONTENT_CODE',
313
+ code: block.code,
314
+ ...(block.lang != null ? { language: block.lang } : {}),
315
+ };
316
+ }
317
+ if ('subtext' in block) {
318
+ return { type: 'CONTENT_SUBTEXT', text: block.subtext };
319
+ }
320
+ if ('title' in block) {
321
+ return { type: 'CONTENT_TITLE', title: block.title };
322
+ }
323
+ if ('text' in block) {
324
+ return { type: 'CONTENT_TEXT', text: block.text };
325
+ }
326
+ throw new Error(`Cannot infer block type from fields: ${Object.keys(block).join(', ')}`);
327
+ }
328
+ // ── Build input schema ───────────────────────────────────────────────
329
+ const buildInputSchema = z.object({
330
+ sha: z.string().min(1),
331
+ feedTitle: z.string().min(1).max(60),
332
+ feedDescription: z.string().min(1),
333
+ forkTitle: z.string().min(1),
334
+ forkDescription: z.string().min(1),
335
+ cards: z.array(z.object({
336
+ variants: z.array(z.object({
337
+ blocks: z.array(z.record(z.string(), z.unknown())).min(1),
338
+ })).min(1),
339
+ })).length(8),
340
+ cwd: z.string().optional().describe('Working directory (defaults to process.cwd())'),
341
+ bgOverride: z.array(z.string()).length(8).optional().describe('Override auto-assigned background IDs'),
342
+ coverOverride: z.array(z.string()).length(8).optional().describe('Override cover image IDs (defaults to backgrounds)'),
343
+ });
344
+ // ── Manifest builder ─────────────────────────────────────────────────
345
+ async function buildManifest(input) {
346
+ const cwd = input.cwd || process.cwd();
347
+ // Auto-detect repo info
348
+ const repoInfo = await getRepoInfo(cwd);
349
+ const owner = repoInfo.owner || 'local';
350
+ const repo = repoInfo.repo;
351
+ const sha7 = input.sha.slice(0, 7);
352
+ const forkId = `tfip-${owner}-${repo}`;
353
+ const feedId = `tfip-${owner}-${repo}-${sha7}`;
354
+ // Auto-assign backgrounds (or use override)
355
+ let bgs;
356
+ if (input.bgOverride) {
357
+ bgs = input.bgOverride;
358
+ }
359
+ else {
360
+ const meta = await gitExec(['show', input.sha, '--format=%s', '--no-patch'], cwd);
361
+ const statOutput = await gitExec(['diff', `${input.sha}^..${input.sha}`, '--stat'], cwd);
362
+ const filePaths = parseFilePathsFromStats(statOutput);
363
+ const tags = detectTags(meta.trim(), filePaths);
364
+ bgs = assignBackgrounds(tags);
365
+ }
366
+ // Auto-fetch existing feed IDs (best-effort)
367
+ let existingFeedIds = [];
368
+ if (TOKEN) {
369
+ try {
370
+ const status = await fetchStatusData();
371
+ existingFeedIds = (status.feeds || []).map((f) => f.externalFeedId);
372
+ }
373
+ catch { /* best-effort */ }
374
+ }
375
+ // Resolve image IDs
376
+ const bgUrls = bgs.map(resolveImageId);
377
+ const coverUrls = input.coverOverride
378
+ ? input.coverOverride.map(resolveImageId)
379
+ : bgUrls;
380
+ const actionUrl = repoInfo.owner
381
+ ? `https://github.com/${repoInfo.owner}/${repoInfo.repo}`
382
+ : undefined;
383
+ const fork = {
384
+ _id: forkId,
385
+ title: input.forkTitle,
386
+ description: input.forkDescription,
387
+ imageSrc: coverUrls[0],
388
+ feedIds: [...existingFeedIds, feedId],
389
+ ...(actionUrl ? { actionLabel: 'View on GitHub', actionUrl } : {}),
390
+ };
391
+ const feed = {
392
+ _id: feedId,
393
+ title: input.feedTitle,
394
+ description: input.feedDescription,
395
+ imageSrc: coverUrls[0],
396
+ mode: 'sequential',
397
+ scrollDirection: 'vertical',
398
+ engagement: true,
399
+ };
400
+ const cards = input.cards.map((card, i) => {
401
+ const coverVariant = {
402
+ type: 'FULL_IMAGE',
403
+ imageSrc: coverUrls[i],
404
+ title: COVERS[i].title,
405
+ subtitle: COVERS[i].subtitle,
406
+ };
407
+ const detailVariants = card.variants.map((v) => ({
408
+ type: 'CONTENT',
409
+ backgroundSrc: bgUrls[i],
410
+ blocks: v.blocks.map((b) => inferBlock(b)),
411
+ }));
412
+ return {
413
+ _id: crypto.randomUUID(),
414
+ feedId,
415
+ order: i,
416
+ variants: [coverVariant, ...detailVariants],
417
+ };
418
+ });
419
+ return { forks: [fork], feeds: [feed], cards };
420
+ }
421
+ async function pushManifestToServer(manifest) {
422
+ if (!TOKEN) {
423
+ return {
424
+ content: [{ type: 'text', text: 'Error: FORKFEED_TOKEN not set. Get your API token from forkfeed.link/admin/user/token and add it to your .mcp.json env.' }],
165
425
  isError: true,
166
426
  };
167
427
  }
168
- // Save manifest locally before uploading (best-effort, doesn't block push)
428
+ // Save manifest locally (best-effort)
169
429
  const forkId = manifest.forks?.[0]?._id;
170
430
  const filename = `${forkId || `manifest-${Date.now()}`}.json`;
171
431
  const dir = join(process.cwd(), 'forkfeed');
@@ -183,43 +443,31 @@ server.tool('forkfeed_push', 'Push a generated manifest (forks, feeds, cards) to
183
443
  method: 'POST',
184
444
  headers: authHeaders(),
185
445
  body: JSON.stringify({ manifest }),
446
+ signal: AbortSignal.timeout(15_000),
186
447
  });
187
448
  const data = await res.json();
188
449
  if (!res.ok) {
189
450
  return {
190
- content: [
191
- {
192
- type: 'text',
193
- text: `Push failed (${res.status}): ${data.error || JSON.stringify(data)}`,
194
- },
195
- ],
451
+ content: [{ type: 'text', text: `Push failed (${res.status}): ${data.error || JSON.stringify(data)}` }],
196
452
  isError: true,
197
453
  };
198
454
  }
199
455
  const forkSummary = data.forks
200
456
  ?.map((f) => ` - ${f.title} (${f.feeds} feeds)`)
201
457
  .join('\n');
202
- // Generate QR code for the first fork's deep link
203
458
  let qrBlock = '';
204
459
  if (forkId) {
205
460
  const url = `https://forkfeed.link/fork/${forkId}`;
206
461
  try {
207
462
  const qr = await QRCode.toString(url, { type: 'utf8', errorCorrectionLevel: 'L' });
208
- qrBlock = [
209
- '',
210
- 'Scan to open in forkfeed:',
211
- '',
212
- qr,
213
- url,
214
- ].join('\n');
463
+ qrBlock = ['', 'Scan to open in forkfeed:', '', qr, url].join('\n');
215
464
  }
216
465
  catch {
217
466
  qrBlock = `\nLink: ${url}`;
218
467
  }
219
468
  }
220
469
  return {
221
- content: [
222
- {
470
+ content: [{
223
471
  type: 'text',
224
472
  text: [
225
473
  'Content pushed successfully!',
@@ -235,21 +483,192 @@ server.tool('forkfeed_push', 'Push a generated manifest (forks, feeds, cards) to
235
483
  'To make it public, change visibility in the app (requires admin approval).',
236
484
  qrBlock,
237
485
  ].join('\n'),
238
- },
239
- ],
486
+ }],
240
487
  };
241
488
  }
242
489
  catch (err) {
243
490
  return {
244
- content: [
245
- {
246
- type: 'text',
247
- text: `Push failed: ${err instanceof Error ? err.message : String(err)}`,
248
- },
249
- ],
491
+ content: [{ type: 'text', text: `Push failed: ${err instanceof Error ? err.message : String(err)}` }],
492
+ isError: true,
493
+ };
494
+ }
495
+ }
496
+ // ── MCP Server ─────────────────────────────────────────────────────────
497
+ const server = new McpServer({
498
+ name: 'forkfeed',
499
+ version: '1.0.0',
500
+ });
501
+ // ── Tool: forkfeed_guide ───────────────────────────────────────────────
502
+ server.tool('forkfeed_guide', 'Get the complete guide for generating forkfeed content from GitHub commits. Call this first to learn the format before generating content.', {}, async () => ({
503
+ content: [{ type: 'text', text: GUIDE_CONTENT }],
504
+ }));
505
+ // ── Tool: forkfeed_commits ─────────────────────────────────────────────
506
+ server.tool('forkfeed_commits', 'Read git commits from the current repo. Without sha: lists recent commits with published status. With sha: returns structured commit details + pre-filtered images for content generation.', {
507
+ sha: z.string().optional().describe('Full or short SHA to analyze in detail. Omit to list recent commits.'),
508
+ cwd: z.string().optional().describe('Working directory. Defaults to process.cwd().'),
509
+ }, async ({ sha, cwd: inputCwd }) => {
510
+ const cwd = inputCwd || process.cwd();
511
+ if (!(await isGitRepo(cwd))) {
512
+ return { content: [{ type: 'text', text: 'Not in a git repo. Navigate to a git repository first.' }], isError: true };
513
+ }
514
+ const repoInfo = await getRepoInfo(cwd);
515
+ const repoLabel = repoInfo.owner ? `${repoInfo.owner}/${repoInfo.repo}` : `local/${repoInfo.repo}`;
516
+ // ── List mode ──
517
+ if (!sha) {
518
+ let commits;
519
+ try {
520
+ commits = await getCommitList(cwd);
521
+ }
522
+ catch (err) {
523
+ return { content: [{ type: 'text', text: `Failed to read git log: ${err instanceof Error ? err.message : String(err)}` }], isError: true };
524
+ }
525
+ if (commits.length === 0) {
526
+ return { content: [{ type: 'text', text: 'No commits found in this repository.' }] };
527
+ }
528
+ // Fetch published status (best-effort)
529
+ let publishedShas = new Set();
530
+ let existingFeedIds = [];
531
+ let statusWarning = '';
532
+ if (TOKEN) {
533
+ try {
534
+ const status = await fetchStatusData();
535
+ existingFeedIds = (status.feeds || []).map((f) => f.externalFeedId);
536
+ for (const feedId of existingFeedIds) {
537
+ const parts = feedId.split('-');
538
+ const feedSha = parts[parts.length - 1];
539
+ if (feedSha)
540
+ publishedShas.add(feedSha);
541
+ }
542
+ }
543
+ catch {
544
+ statusWarning = '\nNote: could not check published status (network error or token issue).';
545
+ }
546
+ }
547
+ const lines = [
548
+ `Repo: ${repoLabel}`,
549
+ '',
550
+ '| # | SHA | Message | Author | Date | Published |',
551
+ '|---|-----|---------|--------|------|-----------|',
552
+ ];
553
+ for (let i = 0; i < commits.length; i++) {
554
+ const c = commits[i];
555
+ const pub = publishedShas.has(c.shortSha) ? 'yes' : '';
556
+ lines.push(`| ${i + 1} | ${c.shortSha} | ${c.message.slice(0, 60)} | ${c.author} | ${c.date} | ${pub} |`);
557
+ }
558
+ if (existingFeedIds.length > 0) {
559
+ lines.push('', `Existing feed IDs (include ALL in fork.feedIds): ${existingFeedIds.join(', ')}`);
560
+ }
561
+ if (statusWarning)
562
+ lines.push(statusWarning);
563
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
564
+ }
565
+ // ── Detail mode ──
566
+ let detail;
567
+ try {
568
+ detail = await getCommitDetail(cwd, sha);
569
+ }
570
+ catch (err) {
571
+ return { content: [{ type: 'text', text: `Failed to read commit ${sha}: ${err instanceof Error ? err.message : String(err)}` }], isError: true };
572
+ }
573
+ // Extract file paths from stats for tag detection
574
+ const filePathMatches = detail.stats.match(/^\s*(.+?)\s+\|/gm) || [];
575
+ const filePaths = filePathMatches.map((m) => m.trim().replace(/\s+\|$/, ''));
576
+ const tags = detectTags(detail.message, filePaths);
577
+ const images = filterImagesByTags(tags);
578
+ const lines = [
579
+ `## Commit: ${detail.shortSha} - ${detail.message}`,
580
+ `Author: ${detail.author} | Date: ${detail.date}`,
581
+ `Repo: ${repoLabel}`,
582
+ repoInfo.owner ? `GitHub: https://github.com/${repoInfo.owner}/${repoInfo.repo}` : '',
583
+ '',
584
+ '## File Stats',
585
+ detail.stats,
586
+ '',
587
+ '## Diff',
588
+ detail.diff,
589
+ '',
590
+ `## Suggested Images (tags: ${tags.join(', ')})`,
591
+ '',
592
+ 'Scene images (for covers + inline):',
593
+ ...images.scenes,
594
+ '',
595
+ 'Background images (for card backgrounds):',
596
+ ...images.backgrounds,
597
+ ].filter(Boolean);
598
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
599
+ });
600
+ // ── Tool: forkfeed_push ────────────────────────────────────────────────
601
+ server.tool('forkfeed_push', 'Push a generated manifest (forks, feeds, cards) to forkfeed. The manifest JSON must conform to the structure described in forkfeed_guide.', {
602
+ manifest: z
603
+ .object({
604
+ forks: z.array(z.any()),
605
+ feeds: z.array(z.any()),
606
+ cards: z.array(z.any()),
607
+ })
608
+ .describe('The complete manifest with forks, feeds, and cards arrays'),
609
+ }, async ({ manifest }) => {
610
+ // Validate manifest structure before pushing
611
+ const parsed = manifestSchema.safeParse(manifest);
612
+ if (!parsed.success) {
613
+ const issues = parsed.error.issues
614
+ .map((i) => ` ${i.path.join('.')}: ${i.message}`)
615
+ .join('\n');
616
+ return {
617
+ content: [{ type: 'text', text: `Manifest validation failed:\n${issues}\n\nFix the issues above and try again.` }],
618
+ isError: true,
619
+ };
620
+ }
621
+ const crossErr = crossValidate(parsed.data);
622
+ if (crossErr) {
623
+ return {
624
+ content: [{ type: 'text', text: `Manifest cross-reference errors:\n${crossErr}\n\nFix the issues above and try again.` }],
625
+ isError: true,
626
+ };
627
+ }
628
+ return pushManifestToServer(parsed.data);
629
+ });
630
+ // ── Tool: forkfeed_build ──────────────────────────────────────────────
631
+ server.tool('forkfeed_build', 'Build a forkfeed manifest from simplified content and push it. Auto-detects repo info, assigns backgrounds, fetches existing feeds. Use this instead of forkfeed_push.', {
632
+ content: buildInputSchema.describe('Simplified content: sha, titles, descriptions, and 8 cards with blocks'),
633
+ push: z.boolean().optional().describe('Push immediately after building (default: true)'),
634
+ }, async ({ content, push }) => {
635
+ const shouldPush = push !== false;
636
+ // Build the full manifest from simplified input
637
+ let manifest;
638
+ try {
639
+ manifest = await buildManifest(content);
640
+ }
641
+ catch (err) {
642
+ return {
643
+ content: [{ type: 'text', text: `Build failed: ${err instanceof Error ? err.message : String(err)}` }],
644
+ isError: true,
645
+ };
646
+ }
647
+ // Validate the assembled manifest
648
+ const parsed = manifestSchema.safeParse(manifest);
649
+ if (!parsed.success) {
650
+ const issues = parsed.error.issues
651
+ .map((i) => ` ${i.path.join('.')}: ${i.message}`)
652
+ .join('\n');
653
+ return {
654
+ content: [{ type: 'text', text: `Built manifest failed validation:\n${issues}` }],
655
+ isError: true,
656
+ };
657
+ }
658
+ const crossErr = crossValidate(parsed.data);
659
+ if (crossErr) {
660
+ return {
661
+ content: [{ type: 'text', text: `Built manifest cross-reference errors:\n${crossErr}` }],
250
662
  isError: true,
251
663
  };
252
664
  }
665
+ if (shouldPush) {
666
+ return pushManifestToServer(parsed.data);
667
+ }
668
+ // Return the manifest for review
669
+ return {
670
+ content: [{ type: 'text', text: `Manifest built successfully (${manifest.cards.length} cards). Review below, then call forkfeed_push to publish.\n\n${JSON.stringify(manifest, null, 2)}` }],
671
+ };
253
672
  });
254
673
  // ── Tool: forkfeed_status ──────────────────────────────────────────────
255
674
  server.tool('forkfeed_status', 'Check your current forkfeed content: which forks and feeds you have published.', {}, async () => {
@@ -265,27 +684,13 @@ server.tool('forkfeed_status', 'Check your current forkfeed content: which forks
265
684
  };
266
685
  }
267
686
  try {
268
- const res = await fetch(`${APP_SERVER_URL}/api/content/status`, {
269
- headers: authHeaders(),
270
- });
271
- const data = await res.json();
272
- if (!res.ok) {
273
- return {
274
- content: [
275
- {
276
- type: 'text',
277
- text: `Status check failed (${res.status}): ${data.error || JSON.stringify(data)}`,
278
- },
279
- ],
280
- isError: true,
281
- };
282
- }
687
+ const data = await fetchStatusData();
283
688
  if (!data.forks?.length) {
284
689
  return {
285
690
  content: [
286
691
  {
287
692
  type: 'text',
288
- text: 'No forks published yet. Use forkfeed_guide to learn how to generate content, then push it with forkfeed_push.',
693
+ text: 'No forks published yet. Use forkfeed_guide to learn how to generate content, then push it with forkfeed_build.',
289
694
  },
290
695
  ],
291
696
  };
@@ -327,13 +732,13 @@ server.prompt('forkfeed', 'Turn GitHub commits into swipeable forkfeed content.
327
732
  type: 'text',
328
733
  text: `Turn the commits in this repo into forkfeed content. Follow these steps exactly:
329
734
 
330
- 1. Call **forkfeed_guide**, **forkfeed_images**, and **forkfeed_status** to load the guide, image catalog, and existing content.
331
- 2. Detect the repo from the working directory. Show recent commits in a table with a column indicating whether each already has a published feed (match 7-char SHA from status feed IDs). Ask which ONE commit to process (default: latest). One commit at a time, never more. Do NOT ask about image style.
332
- 3. Fetch the commit diff and stats via git.
333
- 4. Generate the complete manifest JSON directly. Do NOT use any scripts, tools, or commands to generate UUIDs or content. Generate everything (card UUIDs, all text, all JSON) purely in your response. Include ALL existing feed IDs in the fork's feedIds array (see "Incremental updates" in guide). Output the full manifest as a single JSON block.
334
- 5. Call **forkfeed_push** with the complete manifest.
735
+ 1. Call **forkfeed_guide** and **forkfeed_commits()** in parallel (no arguments for commits = list mode).
736
+ 2. Show the commits table from forkfeed_commits. It already indicates which commits have published feeds. Ask which ONE commit to process. One commit at a time, never more. Do NOT ask about image style.
737
+ 3. Call **forkfeed_commits** with the selected commit SHA. This returns the diff, file stats, and suggested scene images. Do NOT run git commands yourself.
738
+ 4. Generate the simplified content JSON: sha, feedTitle, feedDescription, forkTitle, forkDescription, and 8 cards with blocks. Use short image IDs (img47) for inline images. Do NOT provide owner, repo, backgrounds, existingFeedIds, UUIDs, covers, or type wrappers. The builder auto-detects and generates all of that.
739
+ 5. Call **forkfeed_build** with the simplified content (push defaults to true).
335
740
 
336
- Start now. Load the guide, images, and status, then detect the repo.`,
741
+ Start now.`,
337
742
  },
338
743
  },
339
744
  ],