@luquimbo/bi-superpowers 2.0.1 → 3.0.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.
- package/.claude-plugin/marketplace.json +2 -24
- package/.claude-plugin/plugin.json +1 -1
- package/.claude-plugin/skill-manifest.json +2 -178
- package/.mcp.json +0 -16
- package/.plugin/plugin.json +1 -1
- package/AGENTS.md +37 -55
- package/CHANGELOG.md +44 -0
- package/README.md +74 -191
- package/bin/cli.js +42 -43
- package/bin/commands/install.js +59 -8
- package/bin/commands/install.test.js +77 -0
- package/bin/lib/generators/claude-plugin.js +6 -31
- package/bin/lib/generators/claude-plugin.test.js +12 -11
- package/bin/lib/mcp-config.js +287 -0
- package/bin/lib/mcp-config.test.js +273 -0
- package/bin/lib/microsoft-mcp.js +6 -20
- package/bin/lib/microsoft-mcp.test.js +25 -21
- package/bin/postinstall.js +18 -23
- package/bin/utils/mcp-detect.js +4 -20
- package/bin/utils/mcp-detect.test.js +9 -33
- package/package.json +1 -1
- package/skills/pbi-connect/SKILL.md +1 -1
- package/skills/project-kickoff/SKILL.md +1 -1
- package/commands/contributions.md +0 -265
- package/commands/data-model-design.md +0 -468
- package/commands/dax-doctor.md +0 -248
- package/commands/fabric-scripts.md +0 -452
- package/commands/migration-assistant.md +0 -290
- package/commands/model-documenter.md +0 -242
- package/commands/report-layout.md +0 -296
- package/commands/rls-design.md +0 -533
- package/commands/theme-tweaker.md +0 -624
- package/skills/contributions/SKILL.md +0 -267
- package/skills/data-model-design/SKILL.md +0 -470
- package/skills/data-modeling/SKILL.md +0 -280
- package/skills/data-quality/SKILL.md +0 -664
- package/skills/dax/SKILL.md +0 -746
- package/skills/dax-doctor/SKILL.md +0 -250
- package/skills/dax-udf/SKILL.md +0 -489
- package/skills/deployment/SKILL.md +0 -320
- package/skills/excel-formulas/SKILL.md +0 -463
- package/skills/fabric-scripts/SKILL.md +0 -454
- package/skills/fast-standard/SKILL.md +0 -509
- package/skills/governance/SKILL.md +0 -258
- package/skills/migration-assistant/SKILL.md +0 -292
- package/skills/model-documenter/SKILL.md +0 -244
- package/skills/power-query/SKILL.md +0 -406
- package/skills/query-performance/SKILL.md +0 -480
- package/skills/report-design/SKILL.md +0 -207
- package/skills/report-layout/SKILL.md +0 -298
- package/skills/rls-design/SKILL.md +0 -535
- package/skills/semantic-model/SKILL.md +0 -237
- package/skills/testing-validation/SKILL.md +0 -643
- package/skills/theme-tweaker/SKILL.md +0 -626
- package/src/content/skills/contributions.md +0 -259
- package/src/content/skills/data-model-design.md +0 -462
- package/src/content/skills/data-modeling.md +0 -272
- package/src/content/skills/data-quality.md +0 -656
- package/src/content/skills/dax-doctor.md +0 -242
- package/src/content/skills/dax-udf.md +0 -481
- package/src/content/skills/dax.md +0 -738
- package/src/content/skills/deployment.md +0 -312
- package/src/content/skills/excel-formulas.md +0 -455
- package/src/content/skills/fabric-scripts.md +0 -446
- package/src/content/skills/fast-standard.md +0 -501
- package/src/content/skills/governance.md +0 -250
- package/src/content/skills/migration-assistant.md +0 -284
- package/src/content/skills/model-documenter.md +0 -236
- package/src/content/skills/power-query.md +0 -398
- package/src/content/skills/query-performance.md +0 -472
- package/src/content/skills/report-design.md +0 -199
- package/src/content/skills/report-layout.md +0 -290
- package/src/content/skills/rls-design.md +0 -527
- package/src/content/skills/semantic-model.md +0 -229
- package/src/content/skills/testing-validation.md +0 -635
- package/src/content/skills/theme-tweaker.md +0 -618
|
@@ -38,8 +38,6 @@ describe('claude-plugin generator', () => {
|
|
|
38
38
|
const skills = [
|
|
39
39
|
makeSkill('project-kickoff', 'Project Kickoff'),
|
|
40
40
|
makeSkill('pbi-connect', 'PBI Connect'),
|
|
41
|
-
makeSkill('dax', 'DAX'),
|
|
42
|
-
makeSkill('excel-formulas', 'Excel Formulas'),
|
|
43
41
|
];
|
|
44
42
|
|
|
45
43
|
await claudePlugin.generate(tempDir, skills, {
|
|
@@ -52,7 +50,7 @@ describe('claude-plugin generator', () => {
|
|
|
52
50
|
const pluginManifestPath = path.join(tempDir, '.claude-plugin', 'plugin.json');
|
|
53
51
|
const pluginMcpPath = path.join(tempDir, '.mcp.json');
|
|
54
52
|
const commandPath = path.join(tempDir, 'commands', 'project-kickoff.md');
|
|
55
|
-
const skillPath = path.join(tempDir, 'skills', '
|
|
53
|
+
const skillPath = path.join(tempDir, 'skills', 'pbi-connect', 'SKILL.md');
|
|
56
54
|
|
|
57
55
|
assert.ok(fs.existsSync(pluginManifestPath));
|
|
58
56
|
assert.ok(fs.existsSync(pluginMcpPath));
|
|
@@ -67,21 +65,21 @@ describe('claude-plugin generator', () => {
|
|
|
67
65
|
assert.strictEqual(pluginManifest.name, 'bi-superpowers');
|
|
68
66
|
assert.strictEqual(pluginManifest.version, '9.9.9');
|
|
69
67
|
|
|
70
|
-
|
|
71
|
-
assert.
|
|
68
|
+
// Only 2 MCPs ship: local Power BI Modeling and Microsoft Learn
|
|
69
|
+
assert.strictEqual(Object.keys(pluginMcp).length, 2);
|
|
72
70
|
assert.ok(pluginMcp['powerbi-modeling-mcp']);
|
|
73
|
-
assert.
|
|
74
|
-
assert.strictEqual(pluginMcp['fabric-mcp-server'].command, 'npx');
|
|
71
|
+
assert.ok(pluginMcp['microsoft-learn']);
|
|
75
72
|
assert.strictEqual(pluginMcp['powerbi-modeling-mcp'].command, 'node');
|
|
76
73
|
assert.ok(
|
|
77
74
|
pluginMcp['powerbi-modeling-mcp'].args[0].includes(
|
|
78
75
|
'${CLAUDE_PLUGIN_ROOT}/bin/mcp/powerbi-modeling-launcher.js'
|
|
79
76
|
)
|
|
80
77
|
);
|
|
78
|
+
assert.strictEqual(pluginMcp['microsoft-learn'].type, 'http');
|
|
79
|
+
assert.strictEqual(pluginMcp['microsoft-learn'].url, 'https://learn.microsoft.com/api/mcp');
|
|
81
80
|
|
|
82
|
-
assert.ok(commandContent.includes('description: "Project analysis and planning"'));
|
|
83
81
|
assert.ok(commandContent.includes('Generated by BI Agent Superpowers'));
|
|
84
|
-
assert.ok(skillContent.includes('name: "
|
|
82
|
+
assert.ok(skillContent.includes('name: "pbi-connect"'));
|
|
85
83
|
assert.ok(skillContent.includes('version: "9.9.9"'));
|
|
86
84
|
} finally {
|
|
87
85
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
@@ -92,7 +90,7 @@ describe('claude-plugin generator', () => {
|
|
|
92
90
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'bi-superpowers-plugin-'));
|
|
93
91
|
|
|
94
92
|
try {
|
|
95
|
-
const skills = [makeSkill('
|
|
93
|
+
const skills = [makeSkill('pbi-connect', 'PBI Connect')];
|
|
96
94
|
|
|
97
95
|
await claudePlugin.generate(tempDir, skills, {
|
|
98
96
|
packageDir: '/tmp/bi-superpowers',
|
|
@@ -100,7 +98,10 @@ describe('claude-plugin generator', () => {
|
|
|
100
98
|
libraryPrefix: '.bi-superpowers/library',
|
|
101
99
|
});
|
|
102
100
|
|
|
103
|
-
const skillContent = fs.readFileSync(
|
|
101
|
+
const skillContent = fs.readFileSync(
|
|
102
|
+
path.join(tempDir, 'skills', 'pbi-connect', 'SKILL.md'),
|
|
103
|
+
'utf8'
|
|
104
|
+
);
|
|
104
105
|
|
|
105
106
|
assert.ok(skillContent.includes('.bi-superpowers/library/snippets/example.md'));
|
|
106
107
|
} finally {
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multi-Agent MCP Configuration Writer
|
|
3
|
+
* =====================================
|
|
4
|
+
*
|
|
5
|
+
* Writes the bi-superpowers MCP config (2 servers: powerbi-modeling-mcp
|
|
6
|
+
* + microsoft-learn) into each supported agent's expected config location
|
|
7
|
+
* at the user level (home directory).
|
|
8
|
+
*
|
|
9
|
+
* Each agent has its own config format and path. This module normalizes
|
|
10
|
+
* the write operation across all 5 supported agents:
|
|
11
|
+
*
|
|
12
|
+
* | Agent | Path | Format |
|
|
13
|
+
* |-----------------|---------------------------------------|-------------------|
|
|
14
|
+
* | Claude Code | ~/.claude.json | JSON mcpServers |
|
|
15
|
+
* | GitHub Copilot | ~/.copilot/mcp-config.json | JSON servers |
|
|
16
|
+
* | Codex | ~/.codex/config.toml | TOML mcp_servers |
|
|
17
|
+
* | Gemini CLI | ~/.gemini/settings.json | JSON mcpServers |
|
|
18
|
+
* | Kilo Code | ~/.kilocode/mcp_settings.json | JSON mcpServers |
|
|
19
|
+
*
|
|
20
|
+
* The launcher path for the Power BI Modeling MCP is resolved to an
|
|
21
|
+
* absolute path at install time so it works in agents that don't
|
|
22
|
+
* support Claude Code's `${CLAUDE_PLUGIN_ROOT}` variable.
|
|
23
|
+
*
|
|
24
|
+
* @module lib/mcp-config
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const fs = require('fs');
|
|
28
|
+
const path = require('path');
|
|
29
|
+
const os = require('os');
|
|
30
|
+
|
|
31
|
+
const MICROSOFT_LEARN_URL = 'https://learn.microsoft.com/api/mcp';
|
|
32
|
+
const MODELING_SERVER_NAME = 'powerbi-modeling';
|
|
33
|
+
const LEARN_SERVER_NAME = 'microsoft-learn';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Resolves the absolute path to the Power BI Modeling MCP launcher
|
|
37
|
+
* inside the installed npm package.
|
|
38
|
+
*/
|
|
39
|
+
function getLauncherAbsolutePath(packageDir) {
|
|
40
|
+
return path.join(packageDir, 'bin', 'mcp', 'powerbi-modeling-launcher.js');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Safely read a JSON file. Returns null if missing or unparseable.
|
|
45
|
+
*/
|
|
46
|
+
function readJsonSafe(filePath) {
|
|
47
|
+
if (!fs.existsSync(filePath)) return null;
|
|
48
|
+
try {
|
|
49
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
50
|
+
} catch (_) {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Reject symlink attacks before writing to a file. We use lstatSync so
|
|
57
|
+
* we detect the link itself (not what it points to). If a user home has
|
|
58
|
+
* `~/.claude.json` pointing to `/etc/passwd`, we refuse to write.
|
|
59
|
+
* @throws {Error} if filePath exists and is a symbolic link
|
|
60
|
+
*/
|
|
61
|
+
function assertNotSymlink(filePath) {
|
|
62
|
+
if (!fs.existsSync(filePath)) return;
|
|
63
|
+
if (fs.lstatSync(filePath).isSymbolicLink()) {
|
|
64
|
+
throw new Error(
|
|
65
|
+
`Refusing to write MCP config: ${filePath} is a symbolic link. ` +
|
|
66
|
+
'Remove the symlink and re-run the install.'
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Write a JSON file, creating parent dirs if needed.
|
|
73
|
+
* Refuses to overwrite symbolic links for safety.
|
|
74
|
+
*/
|
|
75
|
+
function writeJson(filePath, data) {
|
|
76
|
+
assertNotSymlink(filePath);
|
|
77
|
+
const dir = path.dirname(filePath);
|
|
78
|
+
if (!fs.existsSync(dir)) {
|
|
79
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
80
|
+
}
|
|
81
|
+
fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Escape a string for use inside TOML double-quoted strings.
|
|
86
|
+
* Covers backslash, quote, and the control characters that TOML spec
|
|
87
|
+
* requires escaping in basic strings: \b \t \n \f \r
|
|
88
|
+
*
|
|
89
|
+
* Note: `\b` in a JS regex is word-boundary, NOT backspace (0x08).
|
|
90
|
+
* We use \x08 explicitly to match the backspace character itself.
|
|
91
|
+
*/
|
|
92
|
+
function tomlEscape(str) {
|
|
93
|
+
return (
|
|
94
|
+
String(str)
|
|
95
|
+
.replace(/\\/g, '\\\\')
|
|
96
|
+
.replace(/"/g, '\\"')
|
|
97
|
+
// eslint-disable-next-line no-control-regex
|
|
98
|
+
.replace(/\x08/g, '\\b')
|
|
99
|
+
.replace(/\t/g, '\\t')
|
|
100
|
+
.replace(/\n/g, '\\n')
|
|
101
|
+
.replace(/\f/g, '\\f')
|
|
102
|
+
.replace(/\r/g, '\\r')
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Escape a string for safe use inside a regex pattern (as a literal).
|
|
108
|
+
*/
|
|
109
|
+
function escapeRegex(str) {
|
|
110
|
+
return String(str).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ============================================
|
|
114
|
+
// CLAUDE CODE
|
|
115
|
+
// ============================================
|
|
116
|
+
// Writes ~/.claude.json, adding to `mcpServers`. Preserves other user-level
|
|
117
|
+
// config in that file (projects, settings, etc.).
|
|
118
|
+
function writeClaudeCodeConfig(packageDir) {
|
|
119
|
+
const configPath = path.join(os.homedir(), '.claude.json');
|
|
120
|
+
const existing = readJsonSafe(configPath) || {};
|
|
121
|
+
const mcpServers = { ...(existing.mcpServers || {}) };
|
|
122
|
+
|
|
123
|
+
mcpServers[MODELING_SERVER_NAME] = {
|
|
124
|
+
command: 'node',
|
|
125
|
+
args: [getLauncherAbsolutePath(packageDir)],
|
|
126
|
+
};
|
|
127
|
+
mcpServers[LEARN_SERVER_NAME] = {
|
|
128
|
+
type: 'http',
|
|
129
|
+
url: MICROSOFT_LEARN_URL,
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
writeJson(configPath, { ...existing, mcpServers });
|
|
133
|
+
return configPath;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ============================================
|
|
137
|
+
// GITHUB COPILOT CLI
|
|
138
|
+
// ============================================
|
|
139
|
+
// Writes ~/.copilot/mcp-config.json. Copilot uses `servers` (NOT mcpServers)
|
|
140
|
+
// and each server has an explicit `type` field.
|
|
141
|
+
function writeCopilotConfig(packageDir) {
|
|
142
|
+
const configPath = path.join(os.homedir(), '.copilot', 'mcp-config.json');
|
|
143
|
+
const existing = readJsonSafe(configPath) || {};
|
|
144
|
+
const servers = { ...(existing.servers || {}) };
|
|
145
|
+
|
|
146
|
+
servers[MODELING_SERVER_NAME] = {
|
|
147
|
+
type: 'stdio',
|
|
148
|
+
command: 'node',
|
|
149
|
+
args: [getLauncherAbsolutePath(packageDir)],
|
|
150
|
+
};
|
|
151
|
+
servers[LEARN_SERVER_NAME] = {
|
|
152
|
+
type: 'http',
|
|
153
|
+
url: MICROSOFT_LEARN_URL,
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
writeJson(configPath, { ...existing, servers });
|
|
157
|
+
return configPath;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ============================================
|
|
161
|
+
// CODEX (OpenAI) — TOML format
|
|
162
|
+
// ============================================
|
|
163
|
+
// Writes ~/.codex/config.toml, appending [mcp_servers.*] sections.
|
|
164
|
+
// Preserves existing content by removing only our own sections before
|
|
165
|
+
// appending the fresh ones.
|
|
166
|
+
function writeCodexConfig(packageDir) {
|
|
167
|
+
const configPath = path.join(os.homedir(), '.codex', 'config.toml');
|
|
168
|
+
assertNotSymlink(configPath);
|
|
169
|
+
const launcher = getLauncherAbsolutePath(packageDir);
|
|
170
|
+
|
|
171
|
+
let existing = '';
|
|
172
|
+
if (fs.existsSync(configPath)) {
|
|
173
|
+
existing = fs.readFileSync(configPath, 'utf8');
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Remove any previous bi-superpowers MCP sections so re-running install
|
|
177
|
+
// doesn't duplicate them. Strips each section from its header to the
|
|
178
|
+
// next [ header or EOF. Server names are escaped for regex safety even
|
|
179
|
+
// though current values are literal — defensive against future renames.
|
|
180
|
+
const namePattern = [MODELING_SERVER_NAME, LEARN_SERVER_NAME].map(escapeRegex).join('|');
|
|
181
|
+
const stripPattern = new RegExp(
|
|
182
|
+
`\\n?\\[mcp_servers\\.(${namePattern})\\][\\s\\S]*?(?=\\n\\[|$)`,
|
|
183
|
+
'g'
|
|
184
|
+
);
|
|
185
|
+
existing = existing.replace(stripPattern, '');
|
|
186
|
+
|
|
187
|
+
const newSections =
|
|
188
|
+
`\n\n[mcp_servers.${MODELING_SERVER_NAME}]\n` +
|
|
189
|
+
'command = "node"\n' +
|
|
190
|
+
`args = ["${tomlEscape(launcher)}"]\n` +
|
|
191
|
+
`\n[mcp_servers.${LEARN_SERVER_NAME}]\n` +
|
|
192
|
+
`url = "${MICROSOFT_LEARN_URL}"\n`;
|
|
193
|
+
|
|
194
|
+
const content = existing.trimEnd() + newSections;
|
|
195
|
+
|
|
196
|
+
const dir = path.dirname(configPath);
|
|
197
|
+
if (!fs.existsSync(dir)) {
|
|
198
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
199
|
+
}
|
|
200
|
+
fs.writeFileSync(configPath, content);
|
|
201
|
+
return configPath;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ============================================
|
|
205
|
+
// GEMINI CLI
|
|
206
|
+
// ============================================
|
|
207
|
+
// Writes ~/.gemini/settings.json. Gemini uses `mcpServers` and `httpUrl`
|
|
208
|
+
// (not `url`) for HTTP transports.
|
|
209
|
+
function writeGeminiConfig(packageDir) {
|
|
210
|
+
const configPath = path.join(os.homedir(), '.gemini', 'settings.json');
|
|
211
|
+
const existing = readJsonSafe(configPath) || {};
|
|
212
|
+
const mcpServers = { ...(existing.mcpServers || {}) };
|
|
213
|
+
|
|
214
|
+
mcpServers[MODELING_SERVER_NAME] = {
|
|
215
|
+
command: 'node',
|
|
216
|
+
args: [getLauncherAbsolutePath(packageDir)],
|
|
217
|
+
};
|
|
218
|
+
mcpServers[LEARN_SERVER_NAME] = {
|
|
219
|
+
httpUrl: MICROSOFT_LEARN_URL,
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
writeJson(configPath, { ...existing, mcpServers });
|
|
223
|
+
return configPath;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ============================================
|
|
227
|
+
// KILO CODE
|
|
228
|
+
// ============================================
|
|
229
|
+
// Writes ~/.kilocode/mcp_settings.json. Kilo uses `mcpServers` and `url`
|
|
230
|
+
// for HTTP transports.
|
|
231
|
+
function writeKiloConfig(packageDir) {
|
|
232
|
+
const configPath = path.join(os.homedir(), '.kilocode', 'mcp_settings.json');
|
|
233
|
+
const existing = readJsonSafe(configPath) || {};
|
|
234
|
+
const mcpServers = { ...(existing.mcpServers || {}) };
|
|
235
|
+
|
|
236
|
+
mcpServers[MODELING_SERVER_NAME] = {
|
|
237
|
+
command: 'node',
|
|
238
|
+
args: [getLauncherAbsolutePath(packageDir)],
|
|
239
|
+
};
|
|
240
|
+
mcpServers[LEARN_SERVER_NAME] = {
|
|
241
|
+
url: MICROSOFT_LEARN_URL,
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
writeJson(configPath, { ...existing, mcpServers });
|
|
245
|
+
return configPath;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Registry mapping agent IDs to their MCP config writers.
|
|
250
|
+
*/
|
|
251
|
+
const MCP_WRITERS = {
|
|
252
|
+
'claude-code': writeClaudeCodeConfig,
|
|
253
|
+
'github-copilot': writeCopilotConfig,
|
|
254
|
+
codex: writeCodexConfig,
|
|
255
|
+
'gemini-cli': writeGeminiConfig,
|
|
256
|
+
kilo: writeKiloConfig,
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Write MCP config for a specific agent.
|
|
261
|
+
* @param {string} agentId - One of the supported agent IDs
|
|
262
|
+
* @param {string} packageDir - Absolute path to the installed package
|
|
263
|
+
* @returns {string|null} The config path written, or null if unknown agent
|
|
264
|
+
* @throws {Error} If the write fails
|
|
265
|
+
*/
|
|
266
|
+
function writeMcpConfigForAgent(agentId, packageDir) {
|
|
267
|
+
const writer = MCP_WRITERS[agentId];
|
|
268
|
+
if (!writer) {
|
|
269
|
+
return null;
|
|
270
|
+
}
|
|
271
|
+
return writer(packageDir);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
module.exports = {
|
|
275
|
+
writeMcpConfigForAgent,
|
|
276
|
+
MCP_WRITERS,
|
|
277
|
+
MODELING_SERVER_NAME,
|
|
278
|
+
LEARN_SERVER_NAME,
|
|
279
|
+
MICROSOFT_LEARN_URL,
|
|
280
|
+
// Exported for testing
|
|
281
|
+
readJsonSafe,
|
|
282
|
+
writeJson,
|
|
283
|
+
tomlEscape,
|
|
284
|
+
escapeRegex,
|
|
285
|
+
assertNotSymlink,
|
|
286
|
+
getLauncherAbsolutePath,
|
|
287
|
+
};
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the multi-agent MCP config writer.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const { test, describe, beforeEach, afterEach } = require('node:test');
|
|
6
|
+
const assert = require('node:assert');
|
|
7
|
+
const fs = require('node:fs');
|
|
8
|
+
const os = require('node:os');
|
|
9
|
+
const path = require('node:path');
|
|
10
|
+
|
|
11
|
+
const {
|
|
12
|
+
MCP_WRITERS,
|
|
13
|
+
MODELING_SERVER_NAME,
|
|
14
|
+
LEARN_SERVER_NAME,
|
|
15
|
+
MICROSOFT_LEARN_URL,
|
|
16
|
+
tomlEscape,
|
|
17
|
+
escapeRegex,
|
|
18
|
+
assertNotSymlink,
|
|
19
|
+
getLauncherAbsolutePath,
|
|
20
|
+
} = require('./mcp-config');
|
|
21
|
+
|
|
22
|
+
describe('mcp-config helpers', () => {
|
|
23
|
+
test('getLauncherAbsolutePath returns absolute launcher path', () => {
|
|
24
|
+
const pkgDir = '/tmp/fake-pkg';
|
|
25
|
+
const launcher = getLauncherAbsolutePath(pkgDir);
|
|
26
|
+
assert.strictEqual(launcher, path.join(pkgDir, 'bin', 'mcp', 'powerbi-modeling-launcher.js'));
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('tomlEscape escapes backslashes and quotes', () => {
|
|
30
|
+
assert.strictEqual(tomlEscape('plain'), 'plain');
|
|
31
|
+
assert.strictEqual(tomlEscape('C:\\path\\to\\file'), 'C:\\\\path\\\\to\\\\file');
|
|
32
|
+
assert.strictEqual(tomlEscape('say "hello"'), 'say \\"hello\\"');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('tomlEscape escapes TOML control characters', () => {
|
|
36
|
+
assert.strictEqual(tomlEscape('line1\nline2'), 'line1\\nline2');
|
|
37
|
+
assert.strictEqual(tomlEscape('tab\there'), 'tab\\there');
|
|
38
|
+
assert.strictEqual(tomlEscape('cr\rhere'), 'cr\\rhere');
|
|
39
|
+
// \x08 = backspace, \f = form feed
|
|
40
|
+
assert.strictEqual(tomlEscape('\x08\f'), '\\b\\f');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('escapeRegex escapes all regex metacharacters', () => {
|
|
44
|
+
assert.strictEqual(escapeRegex('plain'), 'plain');
|
|
45
|
+
assert.strictEqual(escapeRegex('a.b'), 'a\\.b');
|
|
46
|
+
assert.strictEqual(escapeRegex('a[b]c'), 'a\\[b\\]c');
|
|
47
|
+
assert.strictEqual(escapeRegex('a+b*c?'), 'a\\+b\\*c\\?');
|
|
48
|
+
assert.strictEqual(escapeRegex('(hello)'), '\\(hello\\)');
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe('assertNotSymlink', () => {
|
|
53
|
+
let tempDir;
|
|
54
|
+
|
|
55
|
+
beforeEach(() => {
|
|
56
|
+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'bi-mcp-symlink-'));
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
afterEach(() => {
|
|
60
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('allows non-existent paths', () => {
|
|
64
|
+
assert.doesNotThrow(() => {
|
|
65
|
+
assertNotSymlink(path.join(tempDir, 'nope.json'));
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('allows regular files', () => {
|
|
70
|
+
const file = path.join(tempDir, 'regular.json');
|
|
71
|
+
fs.writeFileSync(file, '{}');
|
|
72
|
+
assert.doesNotThrow(() => assertNotSymlink(file));
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('throws on symbolic links', () => {
|
|
76
|
+
const target = path.join(tempDir, 'target.json');
|
|
77
|
+
const link = path.join(tempDir, 'link.json');
|
|
78
|
+
fs.writeFileSync(target, '{}');
|
|
79
|
+
fs.symlinkSync(target, link);
|
|
80
|
+
|
|
81
|
+
assert.throws(() => assertNotSymlink(link), /is a symbolic link/);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe('MCP_WRITERS registry', () => {
|
|
86
|
+
test('covers all 5 supported agents', () => {
|
|
87
|
+
const ids = Object.keys(MCP_WRITERS).sort();
|
|
88
|
+
assert.deepStrictEqual(ids, ['claude-code', 'codex', 'gemini-cli', 'github-copilot', 'kilo']);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('each writer is a function', () => {
|
|
92
|
+
for (const [id, writer] of Object.entries(MCP_WRITERS)) {
|
|
93
|
+
assert.strictEqual(typeof writer, 'function', `${id} writer should be a function`);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe('MCP writers — JSON agents', () => {
|
|
99
|
+
let tempHome;
|
|
100
|
+
let origHome;
|
|
101
|
+
const fakePkgDir = '/tmp/fake-bi-superpowers';
|
|
102
|
+
|
|
103
|
+
beforeEach(() => {
|
|
104
|
+
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'bi-mcp-home-'));
|
|
105
|
+
origHome = os.homedir;
|
|
106
|
+
os.homedir = () => tempHome;
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
afterEach(() => {
|
|
110
|
+
os.homedir = origHome;
|
|
111
|
+
fs.rmSync(tempHome, { recursive: true, force: true });
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('claude-code writer produces mcpServers JSON at ~/.claude.json', () => {
|
|
115
|
+
const configPath = MCP_WRITERS['claude-code'](fakePkgDir);
|
|
116
|
+
|
|
117
|
+
assert.strictEqual(configPath, path.join(tempHome, '.claude.json'));
|
|
118
|
+
assert.ok(fs.existsSync(configPath));
|
|
119
|
+
|
|
120
|
+
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
121
|
+
assert.ok(config.mcpServers);
|
|
122
|
+
assert.ok(config.mcpServers[MODELING_SERVER_NAME]);
|
|
123
|
+
assert.strictEqual(config.mcpServers[MODELING_SERVER_NAME].command, 'node');
|
|
124
|
+
assert.ok(
|
|
125
|
+
config.mcpServers[MODELING_SERVER_NAME].args[0].includes('powerbi-modeling-launcher.js')
|
|
126
|
+
);
|
|
127
|
+
assert.deepStrictEqual(config.mcpServers[LEARN_SERVER_NAME], {
|
|
128
|
+
type: 'http',
|
|
129
|
+
url: MICROSOFT_LEARN_URL,
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('github-copilot writer uses "servers" key (not mcpServers)', () => {
|
|
134
|
+
const configPath = MCP_WRITERS['github-copilot'](fakePkgDir);
|
|
135
|
+
|
|
136
|
+
assert.strictEqual(configPath, path.join(tempHome, '.copilot', 'mcp-config.json'));
|
|
137
|
+
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
138
|
+
assert.ok(config.servers);
|
|
139
|
+
assert.strictEqual(config.mcpServers, undefined);
|
|
140
|
+
assert.strictEqual(config.servers[MODELING_SERVER_NAME].type, 'stdio');
|
|
141
|
+
assert.strictEqual(config.servers[LEARN_SERVER_NAME].type, 'http');
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('gemini-cli writer uses httpUrl (not url)', () => {
|
|
145
|
+
const configPath = MCP_WRITERS['gemini-cli'](fakePkgDir);
|
|
146
|
+
|
|
147
|
+
assert.strictEqual(configPath, path.join(tempHome, '.gemini', 'settings.json'));
|
|
148
|
+
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
149
|
+
assert.ok(config.mcpServers);
|
|
150
|
+
assert.strictEqual(config.mcpServers[LEARN_SERVER_NAME].httpUrl, MICROSOFT_LEARN_URL);
|
|
151
|
+
assert.strictEqual(config.mcpServers[LEARN_SERVER_NAME].url, undefined);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('kilo writer uses url (standard)', () => {
|
|
155
|
+
const configPath = MCP_WRITERS.kilo(fakePkgDir);
|
|
156
|
+
|
|
157
|
+
assert.strictEqual(configPath, path.join(tempHome, '.kilocode', 'mcp_settings.json'));
|
|
158
|
+
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
159
|
+
assert.ok(config.mcpServers);
|
|
160
|
+
assert.strictEqual(config.mcpServers[LEARN_SERVER_NAME].url, MICROSOFT_LEARN_URL);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test('claude-code writer preserves existing config', () => {
|
|
164
|
+
const configPath = path.join(tempHome, '.claude.json');
|
|
165
|
+
fs.writeFileSync(
|
|
166
|
+
configPath,
|
|
167
|
+
JSON.stringify({
|
|
168
|
+
otherSetting: 'value',
|
|
169
|
+
mcpServers: { 'existing-mcp': { command: 'echo' } },
|
|
170
|
+
})
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
MCP_WRITERS['claude-code'](fakePkgDir);
|
|
174
|
+
|
|
175
|
+
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
176
|
+
assert.strictEqual(config.otherSetting, 'value');
|
|
177
|
+
assert.ok(config.mcpServers['existing-mcp']);
|
|
178
|
+
assert.ok(config.mcpServers[MODELING_SERVER_NAME]);
|
|
179
|
+
assert.ok(config.mcpServers[LEARN_SERVER_NAME]);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// Re-install de-duplication for JSON writers — each should replace its
|
|
183
|
+
// own entries cleanly without duplicating them on repeated runs.
|
|
184
|
+
for (const agentId of ['claude-code', 'github-copilot', 'gemini-cli', 'kilo']) {
|
|
185
|
+
test(`${agentId} re-install replaces entries without duplicating`, () => {
|
|
186
|
+
// First install
|
|
187
|
+
MCP_WRITERS[agentId](fakePkgDir);
|
|
188
|
+
// Second install (same args)
|
|
189
|
+
const configPath = MCP_WRITERS[agentId](fakePkgDir);
|
|
190
|
+
|
|
191
|
+
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
192
|
+
const serversKey = agentId === 'github-copilot' ? 'servers' : 'mcpServers';
|
|
193
|
+
const servers = config[serversKey];
|
|
194
|
+
|
|
195
|
+
// Our 2 servers should each appear exactly once (via object keys)
|
|
196
|
+
assert.ok(servers[MODELING_SERVER_NAME], 'modeling server missing after re-install');
|
|
197
|
+
assert.ok(servers[LEARN_SERVER_NAME], 'learn server missing after re-install');
|
|
198
|
+
|
|
199
|
+
// And no stray duplicates of our keys
|
|
200
|
+
const ourKeys = Object.keys(servers).filter(
|
|
201
|
+
(k) => k === MODELING_SERVER_NAME || k === LEARN_SERVER_NAME
|
|
202
|
+
);
|
|
203
|
+
assert.strictEqual(ourKeys.length, 2, `expected 2 of our keys, found ${ourKeys.length}`);
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
test('writeJson refuses to overwrite symlinked target', () => {
|
|
208
|
+
const realFile = path.join(tempHome, 'real-claude.json');
|
|
209
|
+
const linkFile = path.join(tempHome, '.claude.json');
|
|
210
|
+
fs.writeFileSync(realFile, '{"mcpServers":{}}');
|
|
211
|
+
fs.symlinkSync(realFile, linkFile);
|
|
212
|
+
|
|
213
|
+
assert.throws(() => MCP_WRITERS['claude-code'](fakePkgDir), /symbolic link/);
|
|
214
|
+
|
|
215
|
+
// The real file content must not have been touched.
|
|
216
|
+
const content = JSON.parse(fs.readFileSync(realFile, 'utf8'));
|
|
217
|
+
assert.deepStrictEqual(content, { mcpServers: {} });
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
describe('codex writer — TOML', () => {
|
|
222
|
+
let tempHome;
|
|
223
|
+
let origHome;
|
|
224
|
+
|
|
225
|
+
beforeEach(() => {
|
|
226
|
+
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'bi-mcp-codex-'));
|
|
227
|
+
origHome = os.homedir;
|
|
228
|
+
os.homedir = () => tempHome;
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
afterEach(() => {
|
|
232
|
+
os.homedir = origHome;
|
|
233
|
+
fs.rmSync(tempHome, { recursive: true, force: true });
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test('writes TOML with [mcp_servers.*] sections', () => {
|
|
237
|
+
const configPath = MCP_WRITERS.codex('/tmp/fake-pkg');
|
|
238
|
+
|
|
239
|
+
assert.strictEqual(configPath, path.join(tempHome, '.codex', 'config.toml'));
|
|
240
|
+
const content = fs.readFileSync(configPath, 'utf8');
|
|
241
|
+
|
|
242
|
+
assert.ok(content.includes('[mcp_servers.powerbi-modeling]'));
|
|
243
|
+
assert.ok(content.includes('command = "node"'));
|
|
244
|
+
assert.ok(content.includes('[mcp_servers.microsoft-learn]'));
|
|
245
|
+
assert.ok(content.includes(`url = "${MICROSOFT_LEARN_URL}"`));
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test('preserves existing non-MCP TOML content', () => {
|
|
249
|
+
const configPath = path.join(tempHome, '.codex', 'config.toml');
|
|
250
|
+
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
251
|
+
fs.writeFileSync(configPath, '[profile]\nname = "default"\n');
|
|
252
|
+
|
|
253
|
+
MCP_WRITERS.codex('/tmp/fake-pkg');
|
|
254
|
+
|
|
255
|
+
const content = fs.readFileSync(configPath, 'utf8');
|
|
256
|
+
assert.ok(content.includes('[profile]'));
|
|
257
|
+
assert.ok(content.includes('name = "default"'));
|
|
258
|
+
assert.ok(content.includes('[mcp_servers.powerbi-modeling]'));
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test('re-running install replaces MCP sections, does not duplicate', () => {
|
|
262
|
+
MCP_WRITERS.codex('/tmp/fake-pkg');
|
|
263
|
+
MCP_WRITERS.codex('/tmp/fake-pkg');
|
|
264
|
+
|
|
265
|
+
const content = fs.readFileSync(path.join(tempHome, '.codex', 'config.toml'), 'utf8');
|
|
266
|
+
|
|
267
|
+
// Each section name should appear exactly once
|
|
268
|
+
const modelingCount = (content.match(/\[mcp_servers\.powerbi-modeling\]/g) || []).length;
|
|
269
|
+
const learnCount = (content.match(/\[mcp_servers\.microsoft-learn\]/g) || []).length;
|
|
270
|
+
assert.strictEqual(modelingCount, 1);
|
|
271
|
+
assert.strictEqual(learnCount, 1);
|
|
272
|
+
});
|
|
273
|
+
});
|
package/bin/lib/microsoft-mcp.js
CHANGED
|
@@ -2,21 +2,20 @@
|
|
|
2
2
|
* Official Microsoft MCP Configuration Helpers
|
|
3
3
|
* ============================================
|
|
4
4
|
*
|
|
5
|
-
* Single source of truth for the
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* Single source of truth for the MCP servers that bi-superpowers ships.
|
|
6
|
+
* Currently ships 2 servers, both from Microsoft:
|
|
7
|
+
*
|
|
8
|
+
* - powerbi-modeling-mcp: local stdio launcher that talks to Power BI Desktop
|
|
9
|
+
* via XMLA on localhost. Requires Power BI Desktop running with a model open.
|
|
10
|
+
* - microsoft-learn: HTTP MCP that pipes Microsoft Learn docs into the agent.
|
|
8
11
|
*
|
|
9
12
|
* @module lib/microsoft-mcp
|
|
10
13
|
*/
|
|
11
14
|
|
|
12
15
|
const path = require('path');
|
|
13
16
|
|
|
14
|
-
const REMOTE_POWERBI_URL = 'https://api.fabric.microsoft.com/v1/mcp/powerbi';
|
|
15
|
-
const FABRIC_MCP_PACKAGE = '@microsoft/fabric-mcp@latest';
|
|
16
17
|
const MICROSOFT_LEARN_URL = 'https://learn.microsoft.com/api/mcp';
|
|
17
18
|
const MODELING_SERVER_NAME = 'powerbi-modeling-mcp';
|
|
18
|
-
const REMOTE_SERVER_NAME = 'powerbi-remote';
|
|
19
|
-
const FABRIC_SERVER_NAME = 'fabric-mcp-server';
|
|
20
19
|
const LEARN_SERVER_NAME = 'microsoft-learn';
|
|
21
20
|
const ABSOLUTE_LAUNCHER_MODE = 'absolute';
|
|
22
21
|
const PLUGIN_ROOT_LAUNCHER_MODE = 'plugin-root';
|
|
@@ -51,15 +50,6 @@ function createPluginMcpConfig(options = {}) {
|
|
|
51
50
|
const launcherPath = resolveModelingLauncherPath(options);
|
|
52
51
|
|
|
53
52
|
return {
|
|
54
|
-
[REMOTE_SERVER_NAME]: {
|
|
55
|
-
type: 'http',
|
|
56
|
-
url: REMOTE_POWERBI_URL,
|
|
57
|
-
},
|
|
58
|
-
[FABRIC_SERVER_NAME]: {
|
|
59
|
-
type: 'stdio',
|
|
60
|
-
command: 'npx',
|
|
61
|
-
args: ['-y', FABRIC_MCP_PACKAGE, 'server', 'start', '--mode', 'all'],
|
|
62
|
-
},
|
|
63
53
|
[MODELING_SERVER_NAME]: {
|
|
64
54
|
type: 'stdio',
|
|
65
55
|
command: 'node',
|
|
@@ -170,12 +160,8 @@ function mergeMcpConfig(existingConfig, newConfig, format) {
|
|
|
170
160
|
module.exports = {
|
|
171
161
|
ABSOLUTE_LAUNCHER_MODE,
|
|
172
162
|
PLUGIN_ROOT_LAUNCHER_MODE,
|
|
173
|
-
REMOTE_POWERBI_URL,
|
|
174
|
-
FABRIC_MCP_PACKAGE,
|
|
175
163
|
MICROSOFT_LEARN_URL,
|
|
176
164
|
MODELING_SERVER_NAME,
|
|
177
|
-
REMOTE_SERVER_NAME,
|
|
178
|
-
FABRIC_SERVER_NAME,
|
|
179
165
|
LEARN_SERVER_NAME,
|
|
180
166
|
resolveModelingLauncherPath,
|
|
181
167
|
createPluginMcpConfig,
|