@lifeaitools/rdc-skills 0.17.0 → 0.17.1

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.
Files changed (2) hide show
  1. package/package.json +3 -1
  2. package/tests/mcp.test.mjs +219 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lifeaitools/rdc-skills",
3
- "version": "0.17.0",
3
+ "version": "0.17.1",
4
4
  "description": "RDC typed-agent dispatch skill suite for Claude Code - plan, build, review, overnight builds",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -34,6 +34,8 @@
34
34
  "validate": "node tests/validate-skills.js",
35
35
  "rdc-design": "node scripts/rdc-design-cli.mjs",
36
36
  "test:hooks": "node scripts/test-rdc-hooks.mjs",
37
+ "test:mcp": "node tests/mcp.test.mjs",
38
+ "test:mcp:remote": "node tests/mcp.test.mjs --remote",
37
39
  "mcp": "node bin/rdc-skills-mcp.mjs",
38
40
  "rebuild-mcp": "node scripts/rebuild-mcp.mjs",
39
41
  "prepack": "node scripts/validate-publish-manifests.js --mode warn || true && node scripts/validate-place-histories.js --mode warn || true"
@@ -0,0 +1,219 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * tests/mcp.test.mjs — layered test for the rdc-skills MCP server.
4
+ *
5
+ * node tests/mcp.test.mjs # unit + integration (spawns the server)
6
+ * REMOTE=1 node tests/mcp.test.mjs # ALSO sweep the live tunnel endpoint
7
+ *
8
+ * Coverage is 100% of skills: every skill in the catalog is fetched through the
9
+ * MCP in BOTH variants and asserted against its on-disk source, and the cloud
10
+ * contract (no `127.0.0.1:52437` daemon URL) is checked for EVERY skill.
11
+ *
12
+ * Layers:
13
+ * 1. unit — catalog + cloud-rewrite pure functions.
14
+ * 2. integration — spawn `bin/rdc-skills-mcp.mjs` on a test port; exercise
15
+ * /health and the full MCP (initialize, tools/list, every
16
+ * tool, every skill, error paths) over HTTP, comparing each
17
+ * rendered body byte-for-byte to the library's own output.
18
+ * 3. systems — (REMOTE=1) the same cloud-contract sweep against
19
+ * https://rdc-skills.regendevcorp.com/mcp through Cloudflare.
20
+ *
21
+ * Dependency-free: node builtins + the project's own lib + global fetch.
22
+ */
23
+
24
+ import { spawn } from 'node:child_process';
25
+ import path from 'node:path';
26
+ import { fileURLToPath } from 'node:url';
27
+
28
+ import {
29
+ listSkills,
30
+ getSkill,
31
+ skillNames,
32
+ getSkillBody,
33
+ getCloudOverride,
34
+ searchSkills,
35
+ } from '../lib/catalog.mjs';
36
+ import { toCloudBody } from '../lib/cloud-rewrite.mjs';
37
+
38
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
39
+ const REPO_ROOT = path.resolve(__dirname, '..');
40
+ const BIN = path.join(REPO_ROOT, 'bin', 'rdc-skills-mcp.mjs');
41
+ const TEST_PORT = parseInt(process.env.TEST_PORT || '3197', 10);
42
+ const LOCAL = `http://127.0.0.1:${TEST_PORT}`;
43
+ const REMOTE_URL = 'https://rdc-skills.regendevcorp.com/mcp';
44
+ const UA = 'Mozilla/5.0 (rdc-skills-test)';
45
+ const DAEMON = '127.0.0.1:52437';
46
+
47
+ // ── tiny assert harness ──────────────────────────────────────────────────────
48
+ let pass = 0;
49
+ let fail = 0;
50
+ const failures = [];
51
+ function check(name, cond, detail = '') {
52
+ if (cond) { pass++; }
53
+ else { fail++; failures.push(`${name}${detail ? ` — ${detail}` : ''}`); }
54
+ }
55
+ function strip(text) {
56
+ // The server prepends `<!-- rdc-skills: ... -->\n\n` to a rendered body.
57
+ return text.replace(/^<!--[^\n]*-->\n\n/, '');
58
+ }
59
+
60
+ // ── MCP-over-HTTP helper (stateless; SSE or JSON response) ───────────────────
61
+ async function mcp(url, body) {
62
+ const res = await fetch(url, {
63
+ method: 'POST',
64
+ headers: {
65
+ 'Content-Type': 'application/json',
66
+ Accept: 'application/json, text/event-stream',
67
+ 'User-Agent': UA,
68
+ },
69
+ body: JSON.stringify(body),
70
+ });
71
+ const raw = await res.text();
72
+ if (raw.includes('data:')) {
73
+ let out = null;
74
+ for (const line of raw.split('\n')) {
75
+ if (line.startsWith('data:')) out = JSON.parse(line.slice(5).trim());
76
+ }
77
+ return { status: res.status, json: out };
78
+ }
79
+ return { status: res.status, json: raw ? JSON.parse(raw) : null };
80
+ }
81
+ function callText(json) {
82
+ return json?.result?.content?.[0]?.text ?? '';
83
+ }
84
+
85
+ // ── 1. UNIT ──────────────────────────────────────────────────────────────────
86
+ function unitTests() {
87
+ const cat = listSkills();
88
+ check('unit: catalog non-empty', cat.length >= 20, `got ${cat.length}`);
89
+ check('unit: every entry has name+slash+category+summary field',
90
+ cat.every((s) => s.name && s.slash && s.category && 'summary' in s));
91
+ check('unit: every slash is rdc:<name> shape', cat.every((s) => /^rdc:/.test(s.slash)));
92
+ check('unit: getSkill(deploy) resolves', !!getSkill('deploy'));
93
+ check('unit: getSkill(nonexistent) is null', getSkill('___nope___') === null);
94
+ check('unit: search ranks exact name first', searchSkills('deploy')[0]?.name === 'deploy');
95
+ check('unit: empty search returns []', searchSkills('').length === 0);
96
+
97
+ // cloud-rewrite contract
98
+ const sample = '```bash\n_T=$(curl -s http://127.0.0.1:52437/v/coolify-api)\npm2 restart app\n```';
99
+ const rw = toCloudBody(sample);
100
+ check('unit: rewrite strips daemon URL', !rw.includes(DAEMON));
101
+ check('unit: rewrite mentions clauth MCP', rw.includes('clauth MCP'));
102
+ check('unit: rewrite flags CLI-only pm2', rw.includes('CLI-only'));
103
+ check('unit: rewrite guards empty input', toCloudBody('') === '');
104
+ }
105
+
106
+ // ── 2 & 3. shared per-skill sweep over an MCP endpoint ───────────────────────
107
+ async function sweep(url, label, { compareSource }) {
108
+ // initialize
109
+ const init = await mcp(url, {
110
+ jsonrpc: '2.0', id: 1, method: 'initialize',
111
+ params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'rdc-skills-test', version: '1' } },
112
+ });
113
+ check(`${label}: initialize 200`, init.status === 200, `status ${init.status}`);
114
+ check(`${label}: serverInfo name`, init.json?.result?.serverInfo?.name === 'rdc-skills');
115
+
116
+ // tools/list
117
+ const tl = await mcp(url, { jsonrpc: '2.0', id: 2, method: 'tools/list' });
118
+ const toolNames = (tl.json?.result?.tools || []).map((t) => t.name).sort();
119
+ check(`${label}: exactly 3 expected tools`,
120
+ JSON.stringify(toolNames) === JSON.stringify(['rdc_skill_get', 'rdc_skill_list', 'rdc_skill_search']),
121
+ toolNames.join(','));
122
+
123
+ // rdc_skill_list
124
+ const sl = await mcp(url, { jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'rdc_skill_list', arguments: {} } });
125
+ let listed;
126
+ try { listed = JSON.parse(callText(sl.json)); } catch { listed = { count: 0, skills: [] }; }
127
+ const names = (listed.skills || []).map((s) => s.name);
128
+ check(`${label}: rdc_skill_list count matches`, listed.count === names.length && names.length >= 20, `count ${listed.count}`);
129
+ if (compareSource) {
130
+ check(`${label}: list names == local catalog`, JSON.stringify([...names].sort()) === JSON.stringify(skillNames().sort()));
131
+ }
132
+
133
+ // EVERY skill: fetch both variants, assert correctness + cloud contract.
134
+ let rewrittenCount = 0;
135
+ for (const name of names) {
136
+ const cloud = await mcp(url, { jsonrpc: '2.0', id: 10, method: 'tools/call', params: { name: 'rdc_skill_get', arguments: { name, variant: 'cloud' } } });
137
+ const cloudBody = strip(callText(cloud.json));
138
+ const cli = await mcp(url, { jsonrpc: '2.0', id: 11, method: 'tools/call', params: { name: 'rdc_skill_get', arguments: { name, variant: 'cli' } } });
139
+ const cliBody = strip(callText(cli.json));
140
+
141
+ check(`${label}: [${name}] cloud body non-empty`, cloudBody.length > 0);
142
+ check(`${label}: [${name}] cli body non-empty`, cliBody.length > 0);
143
+ // CLOUD CONTRACT — no daemon URL, for EVERY skill.
144
+ check(`${label}: [${name}] cloud has NO daemon URL`, !cloudBody.includes(DAEMON),
145
+ cloudBody.includes(DAEMON) ? 'leaked 127.0.0.1:52437' : '');
146
+
147
+ if (compareSource) {
148
+ // Correctness: the rendered bodies must equal the library's own output.
149
+ const expectedCli = getSkillBody(name);
150
+ const expectedCloud = getCloudOverride(name) ?? toCloudBody(expectedCli);
151
+ check(`${label}: [${name}] cli body == on-disk source`, cliBody === expectedCli);
152
+ check(`${label}: [${name}] cloud body == toCloudBody(source)/override`, cloudBody === expectedCloud);
153
+ if (expectedCli && expectedCli.includes(DAEMON)) rewrittenCount++;
154
+ }
155
+ }
156
+ if (compareSource) {
157
+ check(`${label}: at least one skill actually had a daemon URL rewritten`, rewrittenCount > 0, `rewritten ${rewrittenCount}`);
158
+ }
159
+
160
+ // error path
161
+ const unk = await mcp(url, { jsonrpc: '2.0', id: 20, method: 'tools/call', params: { name: 'rdc_skill_get', arguments: { name: '___nope___' } } });
162
+ check(`${label}: unknown skill returns helpful error`, /unknown skill/i.test(callText(unk.json)));
163
+
164
+ // search
165
+ const se = await mcp(url, { jsonrpc: '2.0', id: 21, method: 'tools/call', params: { name: 'rdc_skill_search', arguments: { query: 'deploy' } } });
166
+ let sr; try { sr = JSON.parse(callText(se.json)); } catch { sr = { results: [] }; }
167
+ check(`${label}: search returns ranked results`, (sr.results || []).length > 0 && sr.results[0].name);
168
+
169
+ return names.length;
170
+ }
171
+
172
+ // ── server lifecycle for integration ─────────────────────────────────────────
173
+ async function waitHealth(url, tries = 40) {
174
+ for (let i = 0; i < tries; i++) {
175
+ try {
176
+ const r = await fetch(`${url}/health`);
177
+ if (r.status === 200) return await r.json();
178
+ } catch { /* not up yet */ }
179
+ await new Promise((r) => setTimeout(r, 150));
180
+ }
181
+ throw new Error('server did not become healthy');
182
+ }
183
+
184
+ async function main() {
185
+ console.log('── unit ──');
186
+ unitTests();
187
+
188
+ console.log('── integration (spawned server) ──');
189
+ const proc = spawn('node', [BIN], { env: { ...process.env, PORT: String(TEST_PORT) }, stdio: 'ignore' });
190
+ let swept = 0;
191
+ try {
192
+ const health = await waitHealth(LOCAL);
193
+ check('integration: /health status ok', health.status === 'ok');
194
+ check('integration: /health skill count == catalog', health.skills === listSkills().length, `health ${health.skills} vs ${listSkills().length}`);
195
+ swept = await sweep(`${LOCAL}/mcp`, 'integration', { compareSource: true });
196
+ console.log(` swept ${swept} skills (both variants)`);
197
+ } finally {
198
+ proc.kill();
199
+ }
200
+
201
+ if (process.env.REMOTE || process.argv.includes('--remote')) {
202
+ console.log('── systems (live tunnel) ──');
203
+ const n = await sweep(REMOTE_URL, 'systems', { compareSource: false });
204
+ console.log(` swept ${n} skills over ${REMOTE_URL}`);
205
+ } else {
206
+ console.log('── systems skipped (set REMOTE=1 to sweep the live endpoint) ──');
207
+ }
208
+
209
+ console.log(`\nRESULTS: ${pass} passed, ${fail} failed`);
210
+ if (fail) {
211
+ console.log('FAILURES:');
212
+ for (const f of failures.slice(0, 40)) console.log(` ✗ ${f}`);
213
+ process.exit(1);
214
+ }
215
+ console.log('✓ all green');
216
+ process.exit(0);
217
+ }
218
+
219
+ main().catch((e) => { console.error('test harness error:', e); process.exit(1); });