@fruition/fcp-mcp-server 1.16.2 → 1.18.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
@@ -34,6 +34,8 @@ catch {
34
34
  }
35
35
  }
36
36
  import { execSync } from 'child_process';
37
+ import { runSkillsCli } from './skills-sync-cli.js';
38
+ import { runBackgroundSync } from './skills-sync.js';
37
39
  // Configuration
38
40
  const FCP_API_URL = process.env.FCP_API_URL || 'https://fcp.fru.io';
39
41
  const FCP_API_TOKEN = process.env.FCP_API_TOKEN || '';
@@ -61,6 +63,7 @@ function detectUserEmail() {
61
63
  catch {
62
64
  // Not in a git repo or no email configured
63
65
  }
66
+ console.error('[MCP Server] WARNING: FCP_USER_EMAIL not set and git email not detected. Unroo task updates will be attributed to the FCP API key owner, not you. Set FCP_USER_EMAIL in your MCP server env config.');
64
67
  return '';
65
68
  }
66
69
  const FCP_USER_EMAIL = detectUserEmail();
@@ -2264,6 +2267,26 @@ const TOOLS = [
2264
2267
  type: 'number',
2265
2268
  description: 'UptimeRobot monitor ID for this site. Used by scheduler to pause/resume monitors on scale events.',
2266
2269
  },
2270
+ pvc_name: {
2271
+ type: 'string',
2272
+ description: 'PVC name in the K8s namespace (e.g., "public-files-nfs"). Update after RWO→RWX migration so the snapshot scheduler targets the new volume.',
2273
+ },
2274
+ pvc_storage_class: {
2275
+ type: 'string',
2276
+ description: 'PVC storage class (e.g., "openebs-kernel-nfs", "do-block-storage-xfs-retain"). Update after a storage migration.',
2277
+ },
2278
+ pvc_size: {
2279
+ type: 'string',
2280
+ description: 'PVC size with unit (e.g., "10Gi"). Update after expanding the volume.',
2281
+ },
2282
+ pvc_mount_path: {
2283
+ type: 'string',
2284
+ description: 'Where the PVC is mounted inside the container (e.g., "/var/www/html/wp-content/uploads").',
2285
+ },
2286
+ pvc_sidecar_detected: {
2287
+ type: 'boolean',
2288
+ description: 'Whether a backup sidecar was auto-detected on this PVC.',
2289
+ },
2267
2290
  },
2268
2291
  required: ['website_id'],
2269
2292
  },
@@ -4023,16 +4046,66 @@ async function main() {
4023
4046
  initializeProjectDetection();
4024
4047
  // Check for updates (non-blocking)
4025
4048
  checkForUpdates();
4026
- // Handle graceful shutdown
4027
- const shutdown = async () => {
4028
- console.error('Shutting down MCP server...');
4029
- await sessionTracker.endSession();
4049
+ // Sync ~/.claude/skills with the shared Unroo KG (non-blocking, opt-in).
4050
+ // First run on a fresh workstation is dry-run only; user opts in via
4051
+ // `fcp-mcp-server sync-skills --enable`.
4052
+ runBackgroundSync({
4053
+ unrooApiKey: process.env.UNROO_API_KEY ?? "",
4054
+ userEmail: FCP_USER_EMAIL,
4055
+ unrooApiUrl: process.env.UNROO_API_URL,
4056
+ });
4057
+ // Handle graceful shutdown — idempotent, safe to call from any signal/event
4058
+ let shuttingDown = false;
4059
+ const shutdown = async (reason) => {
4060
+ if (shuttingDown)
4061
+ return;
4062
+ shuttingDown = true;
4063
+ console.error(`Shutting down MCP server (${reason})...`);
4064
+ try {
4065
+ await sessionTracker.endSession();
4066
+ }
4067
+ catch (err) {
4068
+ console.error('Error ending session during shutdown:', err);
4069
+ }
4030
4070
  process.exit(0);
4031
4071
  };
4032
- process.on('SIGINT', shutdown);
4033
- process.on('SIGTERM', shutdown);
4072
+ process.on('SIGINT', () => void shutdown('SIGINT'));
4073
+ process.on('SIGTERM', () => void shutdown('SIGTERM'));
4074
+ process.on('SIGHUP', () => void shutdown('SIGHUP'));
4075
+ // Stdin closure means the parent (Claude Code) closed the pipe — exit.
4076
+ // Without this, the server lingers as a zombie when the parent restarts
4077
+ // or crashes, and accumulates over a long workstation session.
4078
+ process.stdin.on('end', () => void shutdown('stdin end'));
4079
+ process.stdin.on('close', () => void shutdown('stdin close'));
4080
+ process.stdin.on('error', (err) => {
4081
+ console.error('stdin error:', err);
4082
+ void shutdown('stdin error');
4083
+ });
4084
+ // Orphan watchdog: if our parent process dies and we get reparented to
4085
+ // init (PID 1), no signal fires and stdin may stay technically open. Poll
4086
+ // for ppid change and shut down if we're orphaned.
4087
+ const initialPpid = process.ppid;
4088
+ setInterval(() => {
4089
+ const currentPpid = process.ppid;
4090
+ if (currentPpid === 1 && initialPpid !== 1) {
4091
+ void shutdown(`orphaned (ppid ${initialPpid} -> 1)`);
4092
+ }
4093
+ }, 30_000).unref();
4094
+ }
4095
+ // Subcommand: `fcp-mcp-server sync-skills [...]` runs the skills sync CLI
4096
+ // instead of starting the MCP server. This lets the same binary serve both
4097
+ // the long-running stdio MCP role and the one-shot sync-skills tool.
4098
+ if (process.argv[2] === 'sync-skills') {
4099
+ runSkillsCli(process.argv.slice(3))
4100
+ .then((code) => process.exit(code))
4101
+ .catch((err) => {
4102
+ console.error('[skills-sync] fatal:', err);
4103
+ process.exit(1);
4104
+ });
4105
+ }
4106
+ else {
4107
+ main().catch((error) => {
4108
+ console.error('Fatal error:', error);
4109
+ process.exit(1);
4110
+ });
4034
4111
  }
4035
- main().catch((error) => {
4036
- console.error('Fatal error:', error);
4037
- process.exit(1);
4038
- });
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Skills sync CLI subcommand handler.
3
+ *
4
+ * Invoked when the MCP server binary is called with the `sync-skills` argv:
5
+ * fcp-mcp-server sync-skills [--push|--pull|--sync] [--dry-run] [--enable] [--once]
6
+ *
7
+ * Designed for both:
8
+ * - Direct user invocation (one-shot, exit when done).
9
+ * - Background invocation from main() at MCP server startup (non-blocking,
10
+ * never throws).
11
+ */
12
+ interface CliArgs {
13
+ mode: 'push' | 'pull' | 'sync';
14
+ dryRun?: boolean;
15
+ enable: boolean;
16
+ help: boolean;
17
+ }
18
+ export declare function parseArgs(argv: string[]): CliArgs;
19
+ /**
20
+ * Main entry. Returns process exit code.
21
+ */
22
+ export declare function runSkillsCli(argv: string[]): Promise<number>;
23
+ export {};
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Skills sync CLI subcommand handler.
3
+ *
4
+ * Invoked when the MCP server binary is called with the `sync-skills` argv:
5
+ * fcp-mcp-server sync-skills [--push|--pull|--sync] [--dry-run] [--enable] [--once]
6
+ *
7
+ * Designed for both:
8
+ * - Direct user invocation (one-shot, exit when done).
9
+ * - Background invocation from main() at MCP server startup (non-blocking,
10
+ * never throws).
11
+ */
12
+ import { pushSkills, pullSkills, syncSkills, recordOptIn, defaultSkillsDir, defaultStateFile, } from './skills-sync.js';
13
+ export function parseArgs(argv) {
14
+ const args = { mode: 'sync', enable: false, help: false };
15
+ for (const a of argv) {
16
+ if (a === '--push')
17
+ args.mode = 'push';
18
+ else if (a === '--pull')
19
+ args.mode = 'pull';
20
+ else if (a === '--sync')
21
+ args.mode = 'sync';
22
+ else if (a === '--dry-run')
23
+ args.dryRun = true;
24
+ else if (a === '--no-dry-run')
25
+ args.dryRun = false;
26
+ else if (a === '--enable')
27
+ args.enable = true;
28
+ else if (a === '--help' || a === '-h')
29
+ args.help = true;
30
+ }
31
+ return args;
32
+ }
33
+ function printHelp() {
34
+ const help = `
35
+ fcp-mcp-server sync-skills — sync ~/.claude/skills/<slug>/SKILL.md with the
36
+ shared Fruition Unroo knowledge graph.
37
+
38
+ Usage:
39
+ fcp-mcp-server sync-skills [options]
40
+
41
+ Options:
42
+ --push Only push local skills up to Unroo
43
+ --pull Only pull team skills from Unroo down to local
44
+ --sync Pull then push (default)
45
+ --dry-run Print what would happen without making changes/network calls
46
+ --no-dry-run Force real sync even before --enable opt-in (for scripting)
47
+ --enable Persist the one-time opt-in flag and run a real sync
48
+ --help, -h Show this help
49
+
50
+ Environment:
51
+ UNROO_API_KEY Required. Same key the FCP MCP server uses.
52
+ FCP_USER_EMAIL Required. Your Fruition team email; gates access to the KG.
53
+ UNROO_API_URL Override (default https://app.unroo.io).
54
+
55
+ State file: ${defaultStateFile(defaultSkillsDir())}
56
+ `.trim();
57
+ console.error(help);
58
+ }
59
+ function summarize(result, prefix) {
60
+ const tag = result.dryRun ? `${prefix} (dry run)` : prefix;
61
+ if (result.reason) {
62
+ console.error(`${tag} ${result.reason}`);
63
+ return;
64
+ }
65
+ console.error(`${tag} pulled=${result.pulled.length} pushed=${result.pushed.length} ` +
66
+ `skipped=${result.skipped.length} conflicts=${result.conflicts.length} ` +
67
+ `refused=${result.refused.length}`);
68
+ for (const c of result.conflicts) {
69
+ console.error(` conflict: ${c.slug} — ${c.why}`);
70
+ }
71
+ for (const r of result.refused) {
72
+ console.error(` refused: ${r.slug} — ${r.why}`);
73
+ }
74
+ if (result.dryRun && (result.pulled.length || result.pushed.length)) {
75
+ console.error('\nTo enable real sync, run: fcp-mcp-server sync-skills --enable');
76
+ }
77
+ }
78
+ /**
79
+ * Main entry. Returns process exit code.
80
+ */
81
+ export async function runSkillsCli(argv) {
82
+ const args = parseArgs(argv);
83
+ if (args.help) {
84
+ printHelp();
85
+ return 0;
86
+ }
87
+ // --enable flips the opt-in flag, then runs a real sync in the requested
88
+ // mode (--push, --pull, or --sync). Previously --enable always ran full
89
+ // sync; that turned `--enable --pull` into a surprise push for the user.
90
+ if (args.enable) {
91
+ recordOptIn();
92
+ console.error(`[skills-sync] Opt-in recorded. Running real sync (mode=${args.mode})...`);
93
+ }
94
+ const opts = {
95
+ unrooApiKey: process.env.UNROO_API_KEY ?? '',
96
+ userEmail: process.env.FCP_USER_EMAIL ?? '',
97
+ unrooApiUrl: process.env.UNROO_API_URL,
98
+ // --enable forces a real run; otherwise let lib decide based on opt-in flag
99
+ dryRun: args.enable ? false : args.dryRun,
100
+ };
101
+ let result;
102
+ if (args.mode === 'push') {
103
+ result = await pushSkills(opts);
104
+ }
105
+ else if (args.mode === 'pull') {
106
+ result = await pullSkills(opts);
107
+ }
108
+ else {
109
+ result = await syncSkills(opts);
110
+ }
111
+ summarize(result, '[skills-sync]');
112
+ return result.ok ? 0 : 1;
113
+ }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Skills Sync — bidirectional sync between local ~/.claude/skills/<slug>/SKILL.md
3
+ * files and the Fruition team's shared knowledge graph hosted in Unroo.
4
+ *
5
+ * Distribution model: every developer's workstation runs `@fruition/fcp-mcp-server`
6
+ * via `npx` on each `claude` startup. This module hooks into that startup to
7
+ * converge skills across the team — push local additions/edits up, pull team
8
+ * additions down — without ever clobbering local edits.
9
+ *
10
+ * Endpoints (all gated by X-API-Key + X-FCP-User-Email Fruition-org check):
11
+ * GET https://app.unroo.io/api/external/fcp/knowledge/search?node_type=skill
12
+ * POST https://app.unroo.io/api/external/fcp/knowledge/capture-skill
13
+ *
14
+ * State file: ~/.claude/skills/.sync-state.json — tracks last-pushed mtime and
15
+ * last-pulled body hash per slug so we can detect three-way conflicts.
16
+ *
17
+ * Safety:
18
+ * - First invocation on a fresh workstation is dry-run; user must opt in once.
19
+ * - Bodies containing apparent secrets are refused (loud warning, no upload).
20
+ * - Skills under directories starting with a digit, dot, or underscore are
21
+ * scratch/private and never pushed.
22
+ * - `private: true` in frontmatter also opts out.
23
+ * - Network failures and missing auth never block startup — we log + continue.
24
+ */
25
+ export interface SkillFrontmatter {
26
+ name?: string;
27
+ description?: string;
28
+ triggers?: string[];
29
+ private?: boolean;
30
+ [key: string]: unknown;
31
+ }
32
+ export interface ParsedSkill {
33
+ slug: string;
34
+ filePath: string;
35
+ frontmatter: SkillFrontmatter;
36
+ body: string;
37
+ mtimeMs: number;
38
+ }
39
+ export interface SyncStateEntry {
40
+ lastPushedMtimeMs?: number;
41
+ lastPulledBodyHash?: string;
42
+ lastPulledRemoteUpdatedAt?: string;
43
+ }
44
+ export interface SyncState {
45
+ optedIn?: boolean;
46
+ skills: Record<string, SyncStateEntry>;
47
+ version: number;
48
+ }
49
+ export interface SyncOptions {
50
+ skillsDir?: string;
51
+ stateFile?: string;
52
+ unrooApiUrl?: string;
53
+ unrooApiKey?: string;
54
+ userEmail?: string;
55
+ dryRun?: boolean;
56
+ log?: (msg: string) => void;
57
+ fetchImpl?: typeof fetch;
58
+ now?: () => Date;
59
+ }
60
+ export interface SyncResult {
61
+ ok: boolean;
62
+ reason?: string;
63
+ pushed: string[];
64
+ pulled: string[];
65
+ skipped: Array<{
66
+ slug: string;
67
+ why: string;
68
+ }>;
69
+ conflicts: Array<{
70
+ slug: string;
71
+ why: string;
72
+ }>;
73
+ refused: Array<{
74
+ slug: string;
75
+ why: string;
76
+ }>;
77
+ dryRun: boolean;
78
+ }
79
+ export declare function defaultSkillsDir(): string;
80
+ export declare function defaultStateFile(skillsDir: string): string;
81
+ export declare function loadState(stateFile: string): SyncState;
82
+ export declare function saveState(stateFile: string, state: SyncState): void;
83
+ /**
84
+ * Parse the YAML-ish frontmatter block from a SKILL.md.
85
+ *
86
+ * We support exactly the shape used in ~/.claude/skills/*\/SKILL.md today:
87
+ * - `key: value` scalars (string, boolean)
88
+ * - `key: |` multi-line block scalars (folded into one space-joined string)
89
+ * - `key:` followed by ` - item` list entries (parsed as string[])
90
+ * - `triggers: a, b, c` inline comma list (also parsed as string[])
91
+ *
92
+ * Anything fancier (anchors, nested maps, JSON-style flow) is out of scope —
93
+ * if a skill author needs that we'd pull in `js-yaml`, but every existing
94
+ * SKILL.md in the team library fits this subset.
95
+ */
96
+ export declare function parseFrontmatter(source: string): {
97
+ frontmatter: SkillFrontmatter;
98
+ body: string;
99
+ };
100
+ export declare function discoverLocalSkills(skillsDir: string): Promise<ParsedSkill[]>;
101
+ /**
102
+ * Decide whether a skill should be pushed.
103
+ * Returns null if pushable, or a reason string if not.
104
+ */
105
+ export declare function pushSkipReason(skill: ParsedSkill): string | null;
106
+ /**
107
+ * Detect control characters (other than tab, LF, CR) in the body. The Unroo
108
+ * capture-skill endpoint 500s on null bytes — and they're never legitimate
109
+ * skill content anyway. Returns a short description of the first hit, or
110
+ * null if clean.
111
+ */
112
+ export declare function detectControlChars(body: string): string | null;
113
+ /**
114
+ * Detect apparent secrets in a skill body. Returns the matched substring
115
+ * (truncated) for logging, or null if clean.
116
+ */
117
+ export declare function detectSecret(body: string): string | null;
118
+ export declare function hashBody(body: string): string;
119
+ /** Push local skills up to Unroo. */
120
+ export declare function pushSkills(opts?: SyncOptions): Promise<SyncResult>;
121
+ /** Pull team skills from Unroo down to local. */
122
+ export declare function pullSkills(opts?: SyncOptions): Promise<SyncResult>;
123
+ /** Bidirectional sync: pull then push. */
124
+ export declare function syncSkills(opts?: SyncOptions): Promise<SyncResult>;
125
+ /**
126
+ * Persist the user's opt-in. Called by the CLI when the user runs
127
+ * `fcp-mcp-server sync-skills --enable` for the first time.
128
+ */
129
+ export declare function recordOptIn(stateFile?: string, skillsDir?: string): void;
130
+ /**
131
+ * Background entry called from the MCP server's main(). Never throws — any
132
+ * failure is logged and swallowed. Default behavior on a fresh workstation:
133
+ * dry-run only. The user opts in by running `fcp-mcp-server sync-skills --enable`.
134
+ */
135
+ export declare function runBackgroundSync(opts?: SyncOptions): Promise<void>;
136
+ export declare const __test__: {
137
+ parseFrontmatter: typeof parseFrontmatter;
138
+ detectSecret: typeof detectSecret;
139
+ pushSkipReason: typeof pushSkipReason;
140
+ hashBody: typeof hashBody;
141
+ basename: (path: string, suffix?: string) => string;
142
+ };
@@ -0,0 +1,605 @@
1
+ /**
2
+ * Skills Sync — bidirectional sync between local ~/.claude/skills/<slug>/SKILL.md
3
+ * files and the Fruition team's shared knowledge graph hosted in Unroo.
4
+ *
5
+ * Distribution model: every developer's workstation runs `@fruition/fcp-mcp-server`
6
+ * via `npx` on each `claude` startup. This module hooks into that startup to
7
+ * converge skills across the team — push local additions/edits up, pull team
8
+ * additions down — without ever clobbering local edits.
9
+ *
10
+ * Endpoints (all gated by X-API-Key + X-FCP-User-Email Fruition-org check):
11
+ * GET https://app.unroo.io/api/external/fcp/knowledge/search?node_type=skill
12
+ * POST https://app.unroo.io/api/external/fcp/knowledge/capture-skill
13
+ *
14
+ * State file: ~/.claude/skills/.sync-state.json — tracks last-pushed mtime and
15
+ * last-pulled body hash per slug so we can detect three-way conflicts.
16
+ *
17
+ * Safety:
18
+ * - First invocation on a fresh workstation is dry-run; user must opt in once.
19
+ * - Bodies containing apparent secrets are refused (loud warning, no upload).
20
+ * - Skills under directories starting with a digit, dot, or underscore are
21
+ * scratch/private and never pushed.
22
+ * - `private: true` in frontmatter also opts out.
23
+ * - Network failures and missing auth never block startup — we log + continue.
24
+ */
25
+ import { promises as fs, existsSync, readFileSync, writeFileSync, mkdirSync, } from 'fs';
26
+ import { homedir } from 'os';
27
+ import { join, basename } from 'path';
28
+ import { createHash } from 'crypto';
29
+ // ---------------------------------------------------------------------------
30
+ // Constants
31
+ // ---------------------------------------------------------------------------
32
+ const STATE_VERSION = 1;
33
+ const DEFAULT_UNROO_URL = 'https://app.unroo.io';
34
+ const SEARCH_PATH = '/api/external/fcp/knowledge/search';
35
+ const CAPTURE_PATH = '/api/external/fcp/knowledge/capture-skill';
36
+ // Refuse to upload bodies containing strings that look like a baked-in secret.
37
+ // We capture the leading "label" and the candidate "value" separately so we
38
+ // can reject obvious placeholder shapes (env-var names, template variables,
39
+ // angle-bracket / curly-brace placeholders) instead of false-positiving on them.
40
+ const SECRET_PATTERN = /(api[_-]?key|token|secret|password|client[_-]?secret)\s*[:=]\s*['"]?([\w\-+/]{16,})/i;
41
+ // Shapes that are obviously placeholders — never real secrets.
42
+ // We discriminate primarily by underscore presence: real high-entropy keys
43
+ // (AWS access keys "AKIA…", GitHub PATs "ghp_…" once stripped of the prefix,
44
+ // Stripe keys "sk_live_…" — wait, those *do* have underscores in the prefix —
45
+ // see exceptions below) typically don't have *only* uppercase letters with
46
+ // underscores. Env-var placeholder names always do.
47
+ // - M2M_CLIENT_SECRET → placeholder
48
+ // - JWT_SECRET → placeholder
49
+ // - YOUR_API_KEY → placeholder
50
+ // - AKIAIOSFODNN7EXAMPLE → real-shape (no underscores) — flag
51
+ // - aB3xY9zQ7mN2pK4LvR8t → real-shape (mixed case) — flag
52
+ function looksLikePlaceholder(value) {
53
+ // Must contain at least one underscore to be considered a placeholder.
54
+ if (!value.includes('_'))
55
+ return false;
56
+ // All uppercase + digits + underscores → env-var name shape.
57
+ if (/^[A-Z][A-Z0-9_]+$/.test(value))
58
+ return true;
59
+ // Allow lowercase too if the SHAPE is still env-var-like (snake_case_var).
60
+ // But require it to look like a name, not a real key. Real keys are usually
61
+ // long contiguous runs without underscores between every 3-5 characters.
62
+ if (/^[A-Za-z][A-Za-z0-9_]+$/.test(value) && /_[A-Za-z]/.test(value)) {
63
+ // Average segment length between underscores. Real secrets that happen to
64
+ // contain underscores (rare) will have one segment >= 16 chars.
65
+ const segments = value.split('_');
66
+ const longest = Math.max(...segments.map((s) => s.length));
67
+ if (longest < 16)
68
+ return true;
69
+ }
70
+ return false;
71
+ }
72
+ // Skills under dirs whose name starts with one of these are treated as scratch
73
+ // or user-private and never synced.
74
+ const SCRATCH_PREFIXES = ['_', '.'];
75
+ // ---------------------------------------------------------------------------
76
+ // State helpers
77
+ // ---------------------------------------------------------------------------
78
+ export function defaultSkillsDir() {
79
+ return join(homedir(), '.claude', 'skills');
80
+ }
81
+ export function defaultStateFile(skillsDir) {
82
+ return join(skillsDir, '.sync-state.json');
83
+ }
84
+ export function loadState(stateFile) {
85
+ try {
86
+ if (!existsSync(stateFile)) {
87
+ return { version: STATE_VERSION, skills: {} };
88
+ }
89
+ const raw = readFileSync(stateFile, 'utf-8');
90
+ const parsed = JSON.parse(raw);
91
+ return {
92
+ version: typeof parsed.version === 'number' ? parsed.version : STATE_VERSION,
93
+ optedIn: parsed.optedIn === true,
94
+ skills: parsed.skills && typeof parsed.skills === 'object' ? parsed.skills : {},
95
+ };
96
+ }
97
+ catch {
98
+ // Corrupt state file shouldn't break startup; reset.
99
+ return { version: STATE_VERSION, skills: {} };
100
+ }
101
+ }
102
+ export function saveState(stateFile, state) {
103
+ const dir = stateFile.replace(/\/[^/]+$/, '');
104
+ if (dir && !existsSync(dir)) {
105
+ mkdirSync(dir, { recursive: true });
106
+ }
107
+ writeFileSync(stateFile, JSON.stringify(state, null, 2), 'utf-8');
108
+ }
109
+ // ---------------------------------------------------------------------------
110
+ // Frontmatter parsing — minimal YAML (no external deps)
111
+ // ---------------------------------------------------------------------------
112
+ /**
113
+ * Parse the YAML-ish frontmatter block from a SKILL.md.
114
+ *
115
+ * We support exactly the shape used in ~/.claude/skills/*\/SKILL.md today:
116
+ * - `key: value` scalars (string, boolean)
117
+ * - `key: |` multi-line block scalars (folded into one space-joined string)
118
+ * - `key:` followed by ` - item` list entries (parsed as string[])
119
+ * - `triggers: a, b, c` inline comma list (also parsed as string[])
120
+ *
121
+ * Anything fancier (anchors, nested maps, JSON-style flow) is out of scope —
122
+ * if a skill author needs that we'd pull in `js-yaml`, but every existing
123
+ * SKILL.md in the team library fits this subset.
124
+ */
125
+ export function parseFrontmatter(source) {
126
+ if (!source.startsWith('---')) {
127
+ return { frontmatter: {}, body: source };
128
+ }
129
+ const end = source.indexOf('\n---', 3);
130
+ if (end === -1) {
131
+ return { frontmatter: {}, body: source };
132
+ }
133
+ const block = source.slice(3, end).replace(/^\r?\n/, '');
134
+ const after = source.slice(end + 4).replace(/^\r?\n/, '');
135
+ const lines = block.split(/\r?\n/);
136
+ const fm = {};
137
+ let i = 0;
138
+ while (i < lines.length) {
139
+ const line = lines[i];
140
+ if (!line.trim() || line.trim().startsWith('#')) {
141
+ i++;
142
+ continue;
143
+ }
144
+ const m = line.match(/^([A-Za-z_][\w-]*)\s*:\s*(.*)$/);
145
+ if (!m) {
146
+ i++;
147
+ continue;
148
+ }
149
+ const key = m[1];
150
+ const rest = m[2];
151
+ // Block scalar: `key: |` -> read indented continuation lines
152
+ if (rest === '|' || rest === '>') {
153
+ i++;
154
+ const collected = [];
155
+ while (i < lines.length && /^\s+/.test(lines[i])) {
156
+ collected.push(lines[i].replace(/^\s+/, ''));
157
+ i++;
158
+ }
159
+ fm[key] = collected.join(' ').trim();
160
+ continue;
161
+ }
162
+ // List form: `key:` then ` - item`
163
+ if (rest === '') {
164
+ i++;
165
+ const items = [];
166
+ while (i < lines.length && /^\s+-\s+/.test(lines[i])) {
167
+ items.push(lines[i].replace(/^\s+-\s+/, '').trim());
168
+ i++;
169
+ }
170
+ fm[key] = items;
171
+ continue;
172
+ }
173
+ // Scalar
174
+ let value = rest.trim();
175
+ if (typeof value === 'string') {
176
+ // Strip wrapping quotes
177
+ if ((value.startsWith('"') && value.endsWith('"')) ||
178
+ (value.startsWith("'") && value.endsWith("'"))) {
179
+ value = value.slice(1, -1);
180
+ }
181
+ // Boolean coercion for `private`
182
+ if (value === 'true')
183
+ value = true;
184
+ else if (value === 'false')
185
+ value = false;
186
+ // Inline comma list for `triggers`
187
+ else if (key === 'triggers' && value.includes(',')) {
188
+ value = value
189
+ .split(',')
190
+ .map((t) => t.trim())
191
+ .filter(Boolean);
192
+ }
193
+ }
194
+ fm[key] = value;
195
+ i++;
196
+ }
197
+ // Normalize: triggers should always be string[] if provided
198
+ if (typeof fm.triggers === 'string') {
199
+ fm.triggers = fm.triggers
200
+ .split(',')
201
+ .map((t) => t.trim())
202
+ .filter(Boolean);
203
+ }
204
+ return { frontmatter: fm, body: after };
205
+ }
206
+ // ---------------------------------------------------------------------------
207
+ // Skill discovery
208
+ // ---------------------------------------------------------------------------
209
+ export async function discoverLocalSkills(skillsDir) {
210
+ if (!existsSync(skillsDir))
211
+ return [];
212
+ const entries = await fs.readdir(skillsDir, { withFileTypes: true });
213
+ const skills = [];
214
+ for (const entry of entries) {
215
+ if (!entry.isDirectory())
216
+ continue;
217
+ const slug = entry.name;
218
+ const skillFile = join(skillsDir, slug, 'SKILL.md');
219
+ if (!existsSync(skillFile))
220
+ continue;
221
+ try {
222
+ const stat = await fs.stat(skillFile);
223
+ const source = await fs.readFile(skillFile, 'utf-8');
224
+ const { frontmatter } = parseFrontmatter(source);
225
+ skills.push({
226
+ slug,
227
+ filePath: skillFile,
228
+ frontmatter,
229
+ body: source,
230
+ mtimeMs: stat.mtimeMs,
231
+ });
232
+ }
233
+ catch {
234
+ // Unreadable SKILL.md — skip silently. We don't want a single bad file
235
+ // to break startup for the whole team.
236
+ }
237
+ }
238
+ return skills;
239
+ }
240
+ /**
241
+ * Decide whether a skill should be pushed.
242
+ * Returns null if pushable, or a reason string if not.
243
+ */
244
+ export function pushSkipReason(skill) {
245
+ // Slug starts with digit -> user-private experiment
246
+ if (/^\d/.test(skill.slug))
247
+ return 'slug starts with digit (private experiment)';
248
+ // Slug starts with _ or . -> scratch
249
+ if (SCRATCH_PREFIXES.some((p) => skill.slug.startsWith(p))) {
250
+ return 'slug starts with scratch prefix';
251
+ }
252
+ // Frontmatter `private: true`
253
+ if (skill.frontmatter.private === true)
254
+ return 'frontmatter private:true';
255
+ // Skill has no name/description — not enough metadata to share usefully
256
+ if (!skill.frontmatter.name || !skill.frontmatter.description) {
257
+ return 'missing name or description in frontmatter';
258
+ }
259
+ return null;
260
+ }
261
+ /**
262
+ * Detect control characters (other than tab, LF, CR) in the body. The Unroo
263
+ * capture-skill endpoint 500s on null bytes — and they're never legitimate
264
+ * skill content anyway. Returns a short description of the first hit, or
265
+ * null if clean.
266
+ */
267
+ export function detectControlChars(body) {
268
+ // Allow \t (0x09), \n (0x0A), \r (0x0D); reject everything else < 0x20 and 0x7F.
269
+ const re = /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/;
270
+ const m = body.match(re);
271
+ if (!m)
272
+ return null;
273
+ const code = m[0].charCodeAt(0);
274
+ const hex = code.toString(16).padStart(2, '0');
275
+ const offset = m.index ?? 0;
276
+ return `control char 0x${hex} at offset ${offset}`;
277
+ }
278
+ /**
279
+ * Detect apparent secrets in a skill body. Returns the matched substring
280
+ * (truncated) for logging, or null if clean.
281
+ */
282
+ export function detectSecret(body) {
283
+ // Use a /g regex so we can iterate; the first non-placeholder match wins.
284
+ const re = new RegExp(SECRET_PATTERN.source, 'gi');
285
+ let m;
286
+ while ((m = re.exec(body)) !== null) {
287
+ const value = m[2];
288
+ if (looksLikePlaceholder(value))
289
+ continue;
290
+ return m[0].slice(0, 60) + (m[0].length > 60 ? '…' : '');
291
+ }
292
+ return null;
293
+ }
294
+ export function hashBody(body) {
295
+ return createHash('sha256').update(body, 'utf-8').digest('hex');
296
+ }
297
+ async function postCapture(baseUrl, apiKey, email, payload, fetchImpl) {
298
+ const res = await fetchImpl(`${baseUrl}${CAPTURE_PATH}`, {
299
+ method: 'POST',
300
+ headers: {
301
+ 'Content-Type': 'application/json',
302
+ Accept: 'application/json',
303
+ 'X-API-Key': apiKey,
304
+ 'X-FCP-User-Email': email,
305
+ },
306
+ body: JSON.stringify(payload),
307
+ });
308
+ const body = await res.text();
309
+ return { ok: res.ok, status: res.status, body };
310
+ }
311
+ async function getSearch(baseUrl, apiKey, email, fetchImpl) {
312
+ const all = [];
313
+ let cursor = null;
314
+ // Defensive cap: 50 pages × 100 = 5000 skills, far more than the team will
315
+ // ever have. Prevents a runaway loop if the server forgets to clear nextCursor.
316
+ for (let page = 0; page < 50; page++) {
317
+ const qs = new URLSearchParams({ node_type: 'skill', limit: '100' });
318
+ if (cursor)
319
+ qs.set('cursor', cursor);
320
+ const res = await fetchImpl(`${baseUrl}${SEARCH_PATH}?${qs.toString()}`, {
321
+ headers: {
322
+ Accept: 'application/json',
323
+ 'X-API-Key': apiKey,
324
+ 'X-FCP-User-Email': email,
325
+ },
326
+ });
327
+ if (!res.ok) {
328
+ throw new Error(`Unroo search HTTP ${res.status}`);
329
+ }
330
+ const data = (await res.json());
331
+ if (data.results)
332
+ all.push(...data.results);
333
+ if (!data.nextCursor)
334
+ return all;
335
+ cursor = data.nextCursor;
336
+ }
337
+ return all;
338
+ }
339
+ async function pushOnce(ctx, state, result) {
340
+ const skills = await discoverLocalSkills(ctx.skillsDir);
341
+ for (const skill of skills) {
342
+ const skipReason = pushSkipReason(skill);
343
+ if (skipReason) {
344
+ result.skipped.push({ slug: skill.slug, why: skipReason });
345
+ continue;
346
+ }
347
+ // mtime-based short-circuit (cheap; covers the common no-change case)
348
+ const prev = state.skills[skill.slug] ?? {};
349
+ if (prev.lastPushedMtimeMs && prev.lastPushedMtimeMs >= skill.mtimeMs) {
350
+ result.skipped.push({ slug: skill.slug, why: 'unchanged since last push' });
351
+ continue;
352
+ }
353
+ // Control-char guard — null bytes etc. trip the Unroo capture endpoint
354
+ // and are never legitimate skill content. Refuse before we even try.
355
+ const ctrl = detectControlChars(skill.body);
356
+ if (ctrl) {
357
+ ctx.log(`[skills-sync] REFUSED to push ${skill.slug}: body contains ${ctrl}. ` +
358
+ `Strip control characters from SKILL.md, then retry.`);
359
+ result.refused.push({ slug: skill.slug, why: `control char: ${ctrl}` });
360
+ continue;
361
+ }
362
+ // Secret guard
363
+ const secret = detectSecret(skill.body);
364
+ if (secret) {
365
+ ctx.log(`[skills-sync] REFUSED to push ${skill.slug}: body contains apparent secret (${secret}). ` +
366
+ `Edit SKILL.md to redact, then retry.`);
367
+ result.refused.push({ slug: skill.slug, why: `secret detected: ${secret}` });
368
+ continue;
369
+ }
370
+ const payload = {
371
+ skillSlug: skill.slug,
372
+ name: String(skill.frontmatter.name ?? skill.slug),
373
+ description: String(skill.frontmatter.description ?? ''),
374
+ triggers: Array.isArray(skill.frontmatter.triggers)
375
+ ? skill.frontmatter.triggers
376
+ : undefined,
377
+ bodyMarkdown: skill.body,
378
+ sourceRepo: 'claude-skills-local',
379
+ };
380
+ if (ctx.dryRun) {
381
+ ctx.log(`[skills-sync] DRY RUN: would push ${skill.slug}`);
382
+ result.pushed.push(skill.slug);
383
+ // Still update state under dry run? No — we want a real push to update.
384
+ continue;
385
+ }
386
+ try {
387
+ const res = await postCapture(ctx.baseUrl, ctx.apiKey, ctx.email, payload, ctx.fetchImpl);
388
+ if (!res.ok) {
389
+ ctx.log(`[skills-sync] push ${skill.slug} failed (HTTP ${res.status}): ${res.body.slice(0, 200)}`);
390
+ continue;
391
+ }
392
+ result.pushed.push(skill.slug);
393
+ state.skills[skill.slug] = {
394
+ ...prev,
395
+ lastPushedMtimeMs: skill.mtimeMs,
396
+ };
397
+ }
398
+ catch (err) {
399
+ ctx.log(`[skills-sync] push ${skill.slug} threw: ${err.message}`);
400
+ }
401
+ }
402
+ }
403
+ async function pullOnce(ctx, state, result) {
404
+ let remoteSkills;
405
+ try {
406
+ remoteSkills = await getSearch(ctx.baseUrl, ctx.apiKey, ctx.email, ctx.fetchImpl);
407
+ }
408
+ catch (err) {
409
+ ctx.log(`[skills-sync] pull failed: ${err.message}`);
410
+ return;
411
+ }
412
+ for (const node of remoteSkills) {
413
+ const slug = node.source_id || node.name;
414
+ if (!slug)
415
+ continue;
416
+ const props = node.properties ?? {};
417
+ const bodyMarkdown = typeof props.bodyMarkdown === 'string' ? props.bodyMarkdown : '';
418
+ if (!bodyMarkdown) {
419
+ result.skipped.push({ slug, why: 'remote has no bodyMarkdown' });
420
+ continue;
421
+ }
422
+ const localPath = join(ctx.skillsDir, slug, 'SKILL.md');
423
+ const existed = existsSync(localPath);
424
+ const prev = state.skills[slug] ?? {};
425
+ // Local doesn't exist — straight pull (clean install or new team skill)
426
+ if (!existed) {
427
+ if (ctx.dryRun) {
428
+ ctx.log(`[skills-sync] DRY RUN: would create ${slug}`);
429
+ result.pulled.push(slug);
430
+ continue;
431
+ }
432
+ const dir = join(ctx.skillsDir, slug);
433
+ if (!existsSync(dir))
434
+ mkdirSync(dir, { recursive: true });
435
+ writeFileSync(localPath, bodyMarkdown, 'utf-8');
436
+ state.skills[slug] = {
437
+ ...prev,
438
+ lastPulledBodyHash: hashBody(bodyMarkdown),
439
+ lastPulledRemoteUpdatedAt: node.updated_at,
440
+ };
441
+ result.pulled.push(slug);
442
+ continue;
443
+ }
444
+ // Local exists — only overwrite if remote is strictly newer AND user
445
+ // hasn't edited locally since the last pull. This is the three-way
446
+ // merge: lastPulledBodyHash + current local hash + remote.
447
+ const localBody = readFileSync(localPath, 'utf-8');
448
+ const localHash = hashBody(localBody);
449
+ const remoteUpdated = node.updated_at;
450
+ const remoteIsNewer = !prev.lastPulledRemoteUpdatedAt ||
451
+ Date.parse(remoteUpdated) > Date.parse(prev.lastPulledRemoteUpdatedAt);
452
+ if (!remoteIsNewer) {
453
+ result.skipped.push({ slug, why: 'remote not newer' });
454
+ continue;
455
+ }
456
+ const localUntouched = prev.lastPulledBodyHash !== undefined && prev.lastPulledBodyHash === localHash;
457
+ if (!localUntouched) {
458
+ ctx.log(`[skills-sync] skipping ${slug}: local edits since last pull (conflict). ` +
459
+ `Remote updated_at=${remoteUpdated}; resolve by either pushing your edits or ` +
460
+ `deleting local SKILL.md to accept remote.`);
461
+ result.conflicts.push({ slug, why: 'local edits since last pull' });
462
+ continue;
463
+ }
464
+ if (ctx.dryRun) {
465
+ ctx.log(`[skills-sync] DRY RUN: would update ${slug} from remote`);
466
+ result.pulled.push(slug);
467
+ continue;
468
+ }
469
+ writeFileSync(localPath, bodyMarkdown, 'utf-8');
470
+ state.skills[slug] = {
471
+ ...prev,
472
+ lastPulledBodyHash: hashBody(bodyMarkdown),
473
+ lastPulledRemoteUpdatedAt: remoteUpdated,
474
+ };
475
+ result.pulled.push(slug);
476
+ }
477
+ }
478
+ // ---------------------------------------------------------------------------
479
+ // Public entrypoints
480
+ // ---------------------------------------------------------------------------
481
+ function makeCtx(opts, dryRunOverride) {
482
+ const skillsDir = opts.skillsDir ?? defaultSkillsDir();
483
+ const stateFile = opts.stateFile ?? defaultStateFile(skillsDir);
484
+ return {
485
+ skillsDir,
486
+ stateFile,
487
+ baseUrl: opts.unrooApiUrl ?? DEFAULT_UNROO_URL,
488
+ apiKey: opts.unrooApiKey ?? '',
489
+ email: opts.userEmail ?? '',
490
+ fetchImpl: opts.fetchImpl ?? fetch,
491
+ log: opts.log ?? ((m) => console.error(m)),
492
+ dryRun: dryRunOverride,
493
+ };
494
+ }
495
+ function emptyResult(dryRun) {
496
+ return {
497
+ ok: true,
498
+ pushed: [],
499
+ pulled: [],
500
+ skipped: [],
501
+ conflicts: [],
502
+ refused: [],
503
+ dryRun,
504
+ };
505
+ }
506
+ /** Push local skills up to Unroo. */
507
+ export async function pushSkills(opts = {}) {
508
+ const state = loadState(opts.stateFile ?? defaultStateFile(opts.skillsDir ?? defaultSkillsDir()));
509
+ // Fresh workstation -> force dry-run unless explicitly opted in OR caller
510
+ // passed dryRun:false meaning "I know what I'm doing".
511
+ const dryRun = opts.dryRun !== undefined ? opts.dryRun : state.optedIn !== true;
512
+ const ctx = makeCtx(opts, dryRun);
513
+ const result = emptyResult(dryRun);
514
+ if (!ctx.apiKey || !ctx.email) {
515
+ ctx.log('[skills-sync] skipping push: UNROO_API_KEY or FCP_USER_EMAIL not set');
516
+ result.ok = false;
517
+ result.reason = 'missing auth';
518
+ return result;
519
+ }
520
+ await pushOnce(ctx, state, result);
521
+ saveState(ctx.stateFile, state);
522
+ return result;
523
+ }
524
+ /** Pull team skills from Unroo down to local. */
525
+ export async function pullSkills(opts = {}) {
526
+ const state = loadState(opts.stateFile ?? defaultStateFile(opts.skillsDir ?? defaultSkillsDir()));
527
+ const dryRun = opts.dryRun !== undefined ? opts.dryRun : state.optedIn !== true;
528
+ const ctx = makeCtx(opts, dryRun);
529
+ const result = emptyResult(dryRun);
530
+ if (!ctx.apiKey || !ctx.email) {
531
+ ctx.log('[skills-sync] skipping pull: UNROO_API_KEY or FCP_USER_EMAIL not set');
532
+ result.ok = false;
533
+ result.reason = 'missing auth';
534
+ return result;
535
+ }
536
+ await pullOnce(ctx, state, result);
537
+ saveState(ctx.stateFile, state);
538
+ return result;
539
+ }
540
+ /** Bidirectional sync: pull then push. */
541
+ export async function syncSkills(opts = {}) {
542
+ const state = loadState(opts.stateFile ?? defaultStateFile(opts.skillsDir ?? defaultSkillsDir()));
543
+ const dryRun = opts.dryRun !== undefined ? opts.dryRun : state.optedIn !== true;
544
+ const ctx = makeCtx(opts, dryRun);
545
+ const result = emptyResult(dryRun);
546
+ if (!ctx.apiKey || !ctx.email) {
547
+ ctx.log('[skills-sync] skipping sync: UNROO_API_KEY or FCP_USER_EMAIL not set');
548
+ result.ok = false;
549
+ result.reason = 'missing auth';
550
+ return result;
551
+ }
552
+ // Pull first so a brand-new workstation gets the team library before
553
+ // pushing anything; on a long-lived workstation the pull is mostly no-ops.
554
+ await pullOnce(ctx, state, result);
555
+ await pushOnce(ctx, state, result);
556
+ saveState(ctx.stateFile, state);
557
+ return result;
558
+ }
559
+ /**
560
+ * Persist the user's opt-in. Called by the CLI when the user runs
561
+ * `fcp-mcp-server sync-skills --enable` for the first time.
562
+ */
563
+ export function recordOptIn(stateFile, skillsDir) {
564
+ const dir = skillsDir ?? defaultSkillsDir();
565
+ const file = stateFile ?? defaultStateFile(dir);
566
+ if (!existsSync(dir))
567
+ mkdirSync(dir, { recursive: true });
568
+ const state = loadState(file);
569
+ state.optedIn = true;
570
+ saveState(file, state);
571
+ }
572
+ /**
573
+ * Background entry called from the MCP server's main(). Never throws — any
574
+ * failure is logged and swallowed. Default behavior on a fresh workstation:
575
+ * dry-run only. The user opts in by running `fcp-mcp-server sync-skills --enable`.
576
+ */
577
+ export async function runBackgroundSync(opts = {}) {
578
+ try {
579
+ const result = await syncSkills(opts);
580
+ const log = opts.log ?? ((m) => console.error(m));
581
+ const tag = result.dryRun ? '[skills-sync] (dry run)' : '[skills-sync]';
582
+ if (result.reason) {
583
+ log(`${tag} ${result.reason}`);
584
+ return;
585
+ }
586
+ log(`${tag} pulled=${result.pulled.length} pushed=${result.pushed.length} ` +
587
+ `skipped=${result.skipped.length} conflicts=${result.conflicts.length} ` +
588
+ `refused=${result.refused.length}`);
589
+ if (result.dryRun && (result.pulled.length || result.pushed.length)) {
590
+ log('[skills-sync] To enable real sync, run: fcp-mcp-server sync-skills --enable');
591
+ }
592
+ }
593
+ catch (err) {
594
+ const log = opts.log ?? ((m) => console.error(m));
595
+ log(`[skills-sync] background sync error (ignored): ${err.message}`);
596
+ }
597
+ }
598
+ // Exported for testing
599
+ export const __test__ = {
600
+ parseFrontmatter,
601
+ detectSecret,
602
+ pushSkipReason,
603
+ hashBody,
604
+ basename, // re-export so tests can verify path handling without importing path
605
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fruition/fcp-mcp-server",
3
- "version": "1.16.2",
3
+ "version": "1.18.0",
4
4
  "description": "MCP Server for FCP Launch Coordination System - enables Claude Code to interact with FCP launches and track development time",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",