@mcp-shark/mcp-shark 1.5.13 → 1.6.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.
Files changed (100) hide show
  1. package/README.md +396 -59
  2. package/bin/mcp-shark.js +146 -52
  3. package/core/cli/AutoFixEngine.js +93 -0
  4. package/core/cli/ConfigScanner.js +193 -0
  5. package/core/cli/DataLoader.js +200 -0
  6. package/core/cli/DeclarativeRuleEngine.js +363 -0
  7. package/core/cli/DoctorCommand.js +218 -0
  8. package/core/cli/FixHandlers.js +222 -0
  9. package/core/cli/HtmlReportGenerator.js +203 -0
  10. package/core/cli/IdeConfigPaths.js +175 -0
  11. package/core/cli/ListCommand.js +224 -0
  12. package/core/cli/LockCommand.js +164 -0
  13. package/core/cli/LockDiffEngine.js +152 -0
  14. package/core/cli/RuleRegistryConfig.js +131 -0
  15. package/core/cli/ScanCommand.js +244 -0
  16. package/core/cli/ScanService.js +200 -0
  17. package/core/cli/SecretDetector.js +92 -0
  18. package/core/cli/SharkScoreCalculator.js +109 -0
  19. package/core/cli/ToolClassifications.js +51 -0
  20. package/core/cli/ToxicFlowAnalyzer.js +212 -0
  21. package/core/cli/UpdateCommand.js +188 -0
  22. package/core/cli/WalkthroughGenerator.js +195 -0
  23. package/core/cli/WatchCommand.js +129 -0
  24. package/core/cli/YamlRuleEngine.js +197 -0
  25. package/core/cli/data/rule-packs/agentic-security-2026.json +180 -0
  26. package/core/cli/data/rule-packs/general-security.json +173 -0
  27. package/core/cli/data/rule-packs/owasp-mcp-2026.json +244 -0
  28. package/core/cli/data/rule-packs/toxic-flow-heuristics.json +21 -0
  29. package/core/cli/data/rule-sources.json +5 -0
  30. package/core/cli/data/secret-patterns.json +18 -0
  31. package/core/cli/data/tool-classifications.json +111 -0
  32. package/core/cli/data/toxic-flow-rules.json +47 -0
  33. package/core/cli/index.js +23 -0
  34. package/core/cli/output/Banner.js +52 -0
  35. package/core/cli/output/Formatter.js +183 -0
  36. package/core/cli/output/JsonFormatter.js +106 -0
  37. package/core/cli/output/index.js +16 -0
  38. package/core/cli/secureRegistryFetch.js +157 -0
  39. package/core/cli/symbols.js +16 -0
  40. package/core/container/DependencyContainer.js +4 -1
  41. package/core/repositories/PacketRepository.js +16 -0
  42. package/core/services/AuditService.js +2 -0
  43. package/core/services/security/StaticRulesService.js +69 -13
  44. package/core/services/security/TrafficAnalysisService.js +19 -1
  45. package/core/services/security/TrafficToxicFlowService.js +154 -0
  46. package/core/services/security/index.js +2 -1
  47. package/core/services/security/rules/index.js +25 -59
  48. package/core/services/security/rules/scans/configPermissions.js +91 -0
  49. package/core/services/security/rules/scans/duplicateToolNames.js +85 -0
  50. package/core/services/security/rules/scans/insecureTransport.js +148 -0
  51. package/core/services/security/rules/scans/missingContainment.js +123 -0
  52. package/core/services/security/rules/scans/shellEnvInjection.js +101 -0
  53. package/core/services/security/rules/scans/unsafeDefaults.js +99 -0
  54. package/core/services/security/toolsListFromTrafficParser.js +70 -0
  55. package/core/tui/App.js +144 -0
  56. package/core/tui/FindingsPanel.js +115 -0
  57. package/core/tui/FixPanel.js +132 -0
  58. package/core/tui/Header.js +51 -0
  59. package/core/tui/HelpBar.js +42 -0
  60. package/core/tui/ServersPanel.js +109 -0
  61. package/core/tui/ToxicFlowsPanel.js +100 -0
  62. package/core/tui/h.js +8 -0
  63. package/core/tui/index.js +11 -0
  64. package/core/tui/render.js +22 -0
  65. package/package.json +24 -16
  66. package/ui/dist/assets/{index-CiCSDYf-.js → index-Buah4fNS.js} +32 -32
  67. package/ui/dist/index.html +1 -1
  68. package/ui/server/controllers/RequestController.js +9 -1
  69. package/ui/server/controllers/SecurityFindingsController.js +46 -1
  70. package/ui/server/routes/requests.js +8 -1
  71. package/ui/server/routes/security.js +5 -1
  72. package/ui/server/setup.js +2 -0
  73. package/ui/server/swagger/paths/components.js +55 -0
  74. package/ui/server/swagger/paths/securityTrafficFlows.js +59 -0
  75. package/ui/server/swagger/paths.js +2 -0
  76. package/ui/server/swagger/swagger.js +5 -1
  77. package/ui/src/Security.jsx +10 -0
  78. package/ui/src/components/Security/ScannerContent.jsx +30 -1
  79. package/ui/src/components/Security/TrafficToxicFlowsPanel.jsx +253 -0
  80. package/ui/src/components/Security/securityApi.js +15 -0
  81. package/ui/src/components/Security/useSecurity.js +60 -3
  82. package/core/services/security/rules/scans/agentic01GoalHijack.js +0 -130
  83. package/core/services/security/rules/scans/agentic02ToolMisuse.js +0 -129
  84. package/core/services/security/rules/scans/agentic03IdentityAbuse.js +0 -130
  85. package/core/services/security/rules/scans/agentic04SupplyChain.js +0 -130
  86. package/core/services/security/rules/scans/agentic06MemoryPoisoning.js +0 -130
  87. package/core/services/security/rules/scans/agentic07InsecureCommunication.js +0 -135
  88. package/core/services/security/rules/scans/agentic08CascadingFailures.js +0 -135
  89. package/core/services/security/rules/scans/agentic09TrustExploitation.js +0 -135
  90. package/core/services/security/rules/scans/agentic10RogueAgent.js +0 -130
  91. package/core/services/security/rules/scans/hardcodedSecrets.js +0 -130
  92. package/core/services/security/rules/scans/mcp01TokenMismanagement.js +0 -127
  93. package/core/services/security/rules/scans/mcp02ScopeCreep.js +0 -130
  94. package/core/services/security/rules/scans/mcp03ToolPoisoning.js +0 -132
  95. package/core/services/security/rules/scans/mcp04SupplyChain.js +0 -131
  96. package/core/services/security/rules/scans/mcp06PromptInjection.js +0 -200
  97. package/core/services/security/rules/scans/mcp07InsufficientAuth.js +0 -130
  98. package/core/services/security/rules/scans/mcp08LackAudit.js +0 -129
  99. package/core/services/security/rules/scans/mcp09ShadowServers.js +0 -129
  100. package/core/services/security/rules/scans/mcp10ContextInjection.js +0 -130
@@ -0,0 +1,175 @@
1
+ /**
2
+ * IDE configuration paths for MCP server detection
3
+ * Maps IDE names to their known config file locations across platforms
4
+ */
5
+ import { homedir } from 'node:os';
6
+ import { join } from 'node:path';
7
+ import { Environment } from '#core/configs/environment.js';
8
+
9
+ const HOME = homedir();
10
+ const PLATFORM = process.platform;
11
+
12
+ function macAppSupport(appName) {
13
+ return join(HOME, 'Library', 'Application Support', appName);
14
+ }
15
+
16
+ function winAppData(appName) {
17
+ const appData = process.env.APPDATA || join(Environment.getUserProfile(), 'AppData', 'Roaming');
18
+ return join(appData, appName);
19
+ }
20
+
21
+ function linuxConfig(appName) {
22
+ const xdgConfig = process.env.XDG_CONFIG_HOME || join(HOME, '.config');
23
+ return join(xdgConfig, appName);
24
+ }
25
+
26
+ /**
27
+ * Build platform-aware config paths for an IDE
28
+ */
29
+ function buildPaths(mac, win, linux) {
30
+ const paths = [];
31
+ if (PLATFORM === 'darwin' && mac) {
32
+ paths.push(...(Array.isArray(mac) ? mac : [mac]));
33
+ }
34
+ if (PLATFORM === 'win32' && win) {
35
+ paths.push(...(Array.isArray(win) ? win : [win]));
36
+ }
37
+ if (PLATFORM === 'linux' && linux) {
38
+ paths.push(...(Array.isArray(linux) ? linux : [linux]));
39
+ }
40
+ return paths;
41
+ }
42
+
43
+ /**
44
+ * All known IDE config definitions
45
+ * Each entry: { name, paths: string[], parser: 'json' | 'toml' | 'jsonEmbedded' }
46
+ */
47
+ export const IDE_CONFIGS = [
48
+ {
49
+ name: 'Cursor',
50
+ parser: 'json',
51
+ paths: [
52
+ join(HOME, '.cursor', 'mcp.json'),
53
+ join(process.cwd(), '.cursor', 'mcp.json'),
54
+ ...buildPaths(null, join(Environment.getUserProfile(), '.cursor', 'mcp.json'), null),
55
+ ],
56
+ },
57
+ {
58
+ name: 'Claude Desktop',
59
+ parser: 'json',
60
+ paths: buildPaths(
61
+ join(macAppSupport('Claude'), 'claude_desktop_config.json'),
62
+ join(winAppData('Claude'), 'claude_desktop_config.json'),
63
+ join(linuxConfig('Claude'), 'claude_desktop_config.json')
64
+ ),
65
+ },
66
+ {
67
+ name: 'Claude Code',
68
+ parser: 'json',
69
+ paths: [join(HOME, '.claude.json'), join(HOME, '.claude', 'settings.json')],
70
+ },
71
+ {
72
+ name: 'VS Code',
73
+ parser: 'json',
74
+ paths: [join(process.cwd(), '.vscode', 'mcp.json'), join(HOME, '.vscode', 'mcp.json')],
75
+ },
76
+ {
77
+ name: 'Windsurf',
78
+ parser: 'json',
79
+ paths: [
80
+ join(HOME, '.codeium', 'windsurf', 'mcp_config.json'),
81
+ ...buildPaths(
82
+ null,
83
+ join(Environment.getUserProfile(), '.codeium', 'windsurf', 'mcp_config.json'),
84
+ null
85
+ ),
86
+ ],
87
+ },
88
+ {
89
+ name: 'Codex',
90
+ parser: 'toml',
91
+ paths: [
92
+ join(Environment.getCodexHome(), 'config.toml'),
93
+ join(HOME, '.codex', 'config.toml'),
94
+ ...buildPaths(null, join(Environment.getUserProfile(), '.codex', 'config.toml'), null),
95
+ ],
96
+ },
97
+ {
98
+ name: 'Gemini CLI',
99
+ parser: 'jsonEmbedded',
100
+ paths: [join(HOME, '.gemini', 'settings.json')],
101
+ },
102
+ {
103
+ name: 'Continue',
104
+ parser: 'jsonEmbedded',
105
+ paths: [join(HOME, '.continue', 'config.json')],
106
+ },
107
+ {
108
+ name: 'Cline',
109
+ parser: 'json',
110
+ paths: buildPaths(
111
+ join(
112
+ macAppSupport('Code'),
113
+ 'User',
114
+ 'globalStorage',
115
+ 'saoudrizwan.claude-dev',
116
+ 'settings',
117
+ 'cline_mcp_settings.json'
118
+ ),
119
+ join(
120
+ winAppData('Code'),
121
+ 'User',
122
+ 'globalStorage',
123
+ 'saoudrizwan.claude-dev',
124
+ 'settings',
125
+ 'cline_mcp_settings.json'
126
+ ),
127
+ join(
128
+ linuxConfig('Code'),
129
+ 'User',
130
+ 'globalStorage',
131
+ 'saoudrizwan.claude-dev',
132
+ 'settings',
133
+ 'cline_mcp_settings.json'
134
+ )
135
+ ),
136
+ },
137
+ {
138
+ name: 'Amp',
139
+ parser: 'json',
140
+ paths: [join(HOME, '.amp', 'mcp.json')],
141
+ },
142
+ {
143
+ name: 'Kiro',
144
+ parser: 'json',
145
+ paths: [join(HOME, '.kiro', 'mcp.json')],
146
+ },
147
+ {
148
+ name: 'Zed',
149
+ parser: 'jsonEmbedded',
150
+ paths: buildPaths(
151
+ join(HOME, '.config', 'zed', 'settings.json'),
152
+ null,
153
+ join(linuxConfig('zed'), 'settings.json')
154
+ ),
155
+ },
156
+ {
157
+ name: 'Augment',
158
+ parser: 'json',
159
+ paths: [join(HOME, '.augment', 'mcp.json')],
160
+ },
161
+ {
162
+ name: 'Roo Code',
163
+ parser: 'json',
164
+ paths: [join(HOME, '.roo-code', 'mcp.json')],
165
+ },
166
+ {
167
+ name: 'Project',
168
+ parser: 'json',
169
+ paths: [
170
+ join(process.cwd(), 'mcp.json'),
171
+ join(process.cwd(), '.mcp.json'),
172
+ join(process.cwd(), '.mcp', 'config.json'),
173
+ ],
174
+ },
175
+ ];
@@ -0,0 +1,224 @@
1
+ /**
2
+ * List Command
3
+ * Displays a beautiful inventory of all detected MCP servers,
4
+ * their transport type, tool count, and config source.
5
+ */
6
+ import Table from 'cli-table3';
7
+ import kleur from 'kleur';
8
+ import { getAllServers, scanIdeConfigs } from './ConfigScanner.js';
9
+ import { TOOL_CLASSIFICATIONS } from './ToolClassifications.js';
10
+ import { S } from './symbols.js';
11
+
12
+ const TRANSPORT_LABELS = {
13
+ stdio: kleur.cyan('stdio'),
14
+ sse: kleur.magenta('sse'),
15
+ http: kleur.yellow('http'),
16
+ streamable: kleur.green('streamable'),
17
+ unknown: kleur.dim('unknown'),
18
+ };
19
+
20
+ /**
21
+ * Execute the list command — server inventory
22
+ * @param {object} options
23
+ * @param {string} [options.format] - Output format: terminal, json
24
+ * @returns {number} Exit code
25
+ */
26
+ export function executeList(options = {}) {
27
+ const ideResults = scanIdeConfigs();
28
+ const servers = getAllServers(ideResults);
29
+
30
+ if (servers.length === 0) {
31
+ console.log(`\n ${kleur.yellow(S.warn)} No MCP servers found\n`);
32
+ console.log(kleur.dim(' Searched 15 IDEs. Install an MCP server to get started.'));
33
+ console.log('');
34
+ return 0;
35
+ }
36
+
37
+ if (options.format === 'json') {
38
+ return renderJsonInventory(servers, ideResults);
39
+ }
40
+
41
+ return renderTerminalInventory(servers, ideResults);
42
+ }
43
+
44
+ /**
45
+ * Render server inventory as a beautiful terminal table
46
+ */
47
+ function renderTerminalInventory(servers, ideResults) {
48
+ const foundIdes = ideResults.filter((r) => r.found);
49
+
50
+ console.log('');
51
+ console.log(kleur.bold(' MCP Server Inventory'));
52
+ console.log(kleur.dim(` Found ${servers.length} servers across ${foundIdes.length} IDEs`));
53
+ console.log('');
54
+
55
+ const table = new Table({
56
+ head: ['Server', 'IDE', 'Transport', 'Tools', 'Capabilities'].map((h) => kleur.bold(h)),
57
+ chars: {
58
+ top: '─',
59
+ 'top-mid': '┬',
60
+ 'top-left': '┌',
61
+ 'top-right': '┐',
62
+ bottom: '─',
63
+ 'bottom-mid': '┴',
64
+ 'bottom-left': '└',
65
+ 'bottom-right': '┘',
66
+ left: '│',
67
+ 'left-mid': '├',
68
+ mid: '─',
69
+ 'mid-mid': '┼',
70
+ right: '│',
71
+ 'right-mid': '┤',
72
+ middle: '│',
73
+ },
74
+ style: { head: [], border: [] },
75
+ });
76
+
77
+ for (const server of servers) {
78
+ const transport = detectTransport(server.config);
79
+ const toolCount = getToolCount(server);
80
+ const capabilities = getServerCapabilities(server.name);
81
+
82
+ table.push([
83
+ kleur.white(server.name),
84
+ kleur.dim(server.ide),
85
+ TRANSPORT_LABELS[transport] || TRANSPORT_LABELS.unknown,
86
+ toolCount > 0 ? kleur.bold(String(toolCount)) : kleur.dim('?'),
87
+ capabilities || kleur.dim('—'),
88
+ ]);
89
+ }
90
+
91
+ console.log(table.toString());
92
+ console.log('');
93
+
94
+ renderIdeSummary(ideResults);
95
+
96
+ return 0;
97
+ }
98
+
99
+ /**
100
+ * Render a summary of which IDEs were detected
101
+ */
102
+ function renderIdeSummary(ideResults) {
103
+ const found = ideResults.filter((r) => r.found);
104
+ const notFound = ideResults.filter((r) => !r.found);
105
+
106
+ console.log(kleur.bold(' IDE Detection'));
107
+ for (const ide of found) {
108
+ console.log(` ${kleur.green(S.pass)} ${ide.name} (${ide.serverCount} servers)`);
109
+ }
110
+ if (notFound.length > 0) {
111
+ console.log(` ${kleur.dim(`${notFound.length} IDEs not installed`)}`);
112
+ }
113
+ console.log('');
114
+ }
115
+
116
+ /**
117
+ * Render inventory as JSON
118
+ */
119
+ function renderJsonInventory(servers, ideResults) {
120
+ const output = {
121
+ total_servers: servers.length,
122
+ ides_found: ideResults.filter((r) => r.found).length,
123
+ servers: servers.map((s) => ({
124
+ name: s.name,
125
+ ide: s.ide,
126
+ config_path: s.configPath,
127
+ transport: detectTransport(s.config),
128
+ tool_count: getToolCount(s),
129
+ command: s.config?.command || null,
130
+ args_present: hasArgsPresent(s.config),
131
+ })),
132
+ };
133
+ console.log(JSON.stringify(output, null, 2));
134
+ return 0;
135
+ }
136
+
137
+ /**
138
+ * Detect transport type from server config
139
+ */
140
+ function detectTransport(config) {
141
+ if (!config) {
142
+ return 'unknown';
143
+ }
144
+ if (config.command) {
145
+ return 'stdio';
146
+ }
147
+ if (config.url?.includes('/sse')) {
148
+ return 'sse';
149
+ }
150
+ if (config.url) {
151
+ return config.transport || 'http';
152
+ }
153
+ if (config.transport) {
154
+ return config.transport;
155
+ }
156
+ return 'unknown';
157
+ }
158
+
159
+ /**
160
+ * Get number of tools for a server
161
+ */
162
+ function getToolCount(server) {
163
+ if (Array.isArray(server.tools)) {
164
+ return server.tools.length;
165
+ }
166
+ if (server.tools && typeof server.tools === 'object') {
167
+ return Object.keys(server.tools).length;
168
+ }
169
+ return 0;
170
+ }
171
+
172
+ /**
173
+ * Whether server config includes non-empty args (boolean only for JSON output — avoids leaking secrets).
174
+ */
175
+ export function hasArgsPresent(config) {
176
+ const args = config?.args;
177
+ if (args == null) {
178
+ return false;
179
+ }
180
+ if (Array.isArray(args)) {
181
+ return args.length > 0;
182
+ }
183
+ if (typeof args === 'object') {
184
+ return Object.keys(args).length > 0;
185
+ }
186
+ return String(args).length > 0;
187
+ }
188
+
189
+ /**
190
+ * Get known capabilities from the classification database
191
+ * (values are per-tool capability strings, not flat boolean flags)
192
+ */
193
+ export function getServerCapabilities(serverName) {
194
+ const classification = TOOL_CLASSIFICATIONS[serverName];
195
+ if (!classification || typeof classification !== 'object') {
196
+ return null;
197
+ }
198
+
199
+ const capSet = new Set();
200
+ for (const cap of Object.values(classification)) {
201
+ if (typeof cap === 'string' && cap) {
202
+ capSet.add(cap);
203
+ }
204
+ }
205
+
206
+ const caps = [];
207
+ if (capSet.has('reads_secrets')) {
208
+ caps.push(kleur.red('secrets'));
209
+ }
210
+ if (capSet.has('writes_code')) {
211
+ caps.push(kleur.yellow('code'));
212
+ }
213
+ if (capSet.has('sends_external')) {
214
+ caps.push(kleur.magenta('network'));
215
+ }
216
+ if (capSet.has('modifies_infra')) {
217
+ caps.push(kleur.red('infra'));
218
+ }
219
+ if (capSet.has('ingests_untrusted')) {
220
+ caps.push(kleur.cyan('untrusted'));
221
+ }
222
+
223
+ return caps.length > 0 ? caps.join(', ') : null;
224
+ }
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Lock / Diff Commands
3
+ * Creates and verifies .mcp-shark.lock — SHA-256 hashes of tool definitions
4
+ * Detects rug pull attacks (Catalog §1.5)
5
+ */
6
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
7
+ import { join } from 'node:path';
8
+ import kleur from 'kleur';
9
+ import { getAllServers, scanIdeConfigs } from './ConfigScanner.js';
10
+ import { computeDiff, countParameters, hashToolDefinition, renderDiff } from './LockDiffEngine.js';
11
+ import { S } from './symbols.js';
12
+
13
+ const LOCKFILE_NAME = '.mcp-shark.lock';
14
+
15
+ /**
16
+ * Execute the lock command — create or update lockfile
17
+ */
18
+ export function executeLock(options = {}) {
19
+ const ideResults = scanIdeConfigs();
20
+ const servers = getAllServers(ideResults);
21
+
22
+ if (servers.length === 0) {
23
+ console.log(` ${kleur.yellow(S.warn)} No MCP servers found to lock`);
24
+ return 1;
25
+ }
26
+
27
+ const lockData = buildLockData(servers);
28
+ const lockfilePath = join(process.cwd(), LOCKFILE_NAME);
29
+ const content = JSON.stringify(lockData, null, 2);
30
+
31
+ writeFileSync(lockfilePath, content, 'utf-8');
32
+ console.log('');
33
+ console.log(` ${kleur.green(S.pass)} Lockfile created: ${LOCKFILE_NAME}`);
34
+ console.log(` ${kleur.dim(`${Object.keys(lockData.servers).length} servers locked`)}`);
35
+
36
+ if (options.verify) {
37
+ return verifyLockfile(lockData);
38
+ }
39
+
40
+ console.log('');
41
+ console.log(kleur.dim(' Commit this file to detect future tool definition changes.'));
42
+ console.log(kleur.dim(' Verify: npx mcp-shark lock --verify'));
43
+ console.log('');
44
+
45
+ return 0;
46
+ }
47
+
48
+ /**
49
+ * Execute lock --verify — compare current state against lockfile
50
+ */
51
+ export function executeLockVerify() {
52
+ const lockfilePath = join(process.cwd(), LOCKFILE_NAME);
53
+
54
+ if (!existsSync(lockfilePath)) {
55
+ console.log(` ${kleur.red(S.fail)} No ${LOCKFILE_NAME} found`);
56
+ console.log(kleur.dim(' Run: npx mcp-shark lock'));
57
+ return 1;
58
+ }
59
+
60
+ const lockData = JSON.parse(readFileSync(lockfilePath, 'utf-8'));
61
+ return verifyLockfile(lockData);
62
+ }
63
+
64
+ /**
65
+ * Execute the diff command — show changes since last lock
66
+ */
67
+ export function executeDiff() {
68
+ const lockfilePath = join(process.cwd(), LOCKFILE_NAME);
69
+
70
+ if (!existsSync(lockfilePath)) {
71
+ console.log(` ${kleur.yellow(S.warn)} No ${LOCKFILE_NAME} found`);
72
+ console.log(kleur.dim(' Run: npx mcp-shark lock'));
73
+ return 1;
74
+ }
75
+
76
+ const lockData = JSON.parse(readFileSync(lockfilePath, 'utf-8'));
77
+ const ideResults = scanIdeConfigs();
78
+ const currentServers = getAllServers(ideResults);
79
+
80
+ const changes = computeDiff(lockData, currentServers);
81
+ renderDiff(changes);
82
+
83
+ return changes.length > 0 ? 1 : 0;
84
+ }
85
+
86
+ /**
87
+ * Build lockfile data structure
88
+ */
89
+ function buildLockData(servers) {
90
+ const now = new Date().toISOString();
91
+ const serverEntries = {};
92
+
93
+ for (const server of servers) {
94
+ const toolHashes = buildToolHashes(server, now);
95
+
96
+ serverEntries[server.name] = {
97
+ source: server.ide,
98
+ config_path: server.configPath,
99
+ tools: toolHashes,
100
+ tool_count: (Array.isArray(server.tools) ? server.tools : []).length,
101
+ locked_at: now,
102
+ };
103
+ }
104
+
105
+ return {
106
+ version: 1,
107
+ created: now,
108
+ updated: now,
109
+ shark_version: getSharkVersion(),
110
+ servers: serverEntries,
111
+ };
112
+ }
113
+
114
+ /**
115
+ * Build tool hash entries for a server
116
+ */
117
+ function buildToolHashes(server, now) {
118
+ const toolHashes = {};
119
+ const tools = Array.isArray(server.tools) ? server.tools : [];
120
+
121
+ for (const tool of tools) {
122
+ const toolObj = typeof tool === 'string' ? { name: tool } : tool;
123
+ const hash = hashToolDefinition(toolObj);
124
+ toolHashes[toolObj.name || 'unknown'] = {
125
+ hash: `sha256:${hash}`,
126
+ description_length: (toolObj.description || '').length,
127
+ parameter_count: countParameters(toolObj),
128
+ pinned_at: now,
129
+ };
130
+ }
131
+
132
+ return toolHashes;
133
+ }
134
+
135
+ /**
136
+ * Verify current state matches lockfile
137
+ */
138
+ function verifyLockfile(lockData) {
139
+ const ideResults = scanIdeConfigs();
140
+ const currentServers = getAllServers(ideResults);
141
+ const changes = computeDiff(lockData, currentServers);
142
+
143
+ if (changes.length === 0) {
144
+ console.log(` ${kleur.green(S.pass)} All definitions match lockfile`);
145
+ return 0;
146
+ }
147
+
148
+ console.log(` ${kleur.red(S.fail)} ${changes.length} changes detected`);
149
+ renderDiff(changes);
150
+ return 1;
151
+ }
152
+
153
+ /**
154
+ * Get shark version from package.json
155
+ */
156
+ function getSharkVersion() {
157
+ try {
158
+ const pkgPath = join(import.meta.dirname, '..', '..', 'package.json');
159
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
160
+ return pkg.version;
161
+ } catch (_err) {
162
+ return '1.0.0';
163
+ }
164
+ }
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Lock Diff Engine
3
+ * Computes and renders differences between lockfile and current MCP state.
4
+ * Used by both `lock --verify` and `diff` commands.
5
+ */
6
+ import { createHash } from 'node:crypto';
7
+ import kleur from 'kleur';
8
+ import { S } from './symbols.js';
9
+
10
+ /**
11
+ * Hash a tool definition using SHA-256
12
+ */
13
+ export function hashToolDefinition(tool) {
14
+ const canonical = JSON.stringify({
15
+ name: tool.name,
16
+ description: tool.description,
17
+ inputSchema: tool.inputSchema || tool.parameters,
18
+ });
19
+ return createHash('sha256').update(canonical).digest('hex');
20
+ }
21
+
22
+ /**
23
+ * Count parameters in a tool definition
24
+ */
25
+ export function countParameters(tool) {
26
+ const schema = tool.inputSchema || tool.parameters || {};
27
+ const properties = schema.properties || {};
28
+ return Object.keys(properties).length;
29
+ }
30
+
31
+ /**
32
+ * Normalize server.tools (array or name→definition map) to an array of tool objects
33
+ */
34
+ export function normalizeToolsList(tools) {
35
+ if (!tools) {
36
+ return [];
37
+ }
38
+ if (Array.isArray(tools)) {
39
+ return tools;
40
+ }
41
+ if (typeof tools === 'object') {
42
+ return Object.entries(tools).map(([name, def]) => {
43
+ if (def && typeof def === 'object' && !Array.isArray(def)) {
44
+ return { ...def, name: def.name || name };
45
+ }
46
+ return typeof def === 'string' ? { name, description: def } : { name };
47
+ });
48
+ }
49
+ return [];
50
+ }
51
+
52
+ /**
53
+ * Compute diff between lockfile and current state
54
+ * @param {object} lockData - Parsed lockfile data
55
+ * @param {Array} currentServers - Current servers from ConfigScanner
56
+ * @returns {Array} List of change objects
57
+ */
58
+ export function computeDiff(lockData, currentServers) {
59
+ const changes = [];
60
+
61
+ for (const server of currentServers) {
62
+ const locked = lockData.servers[server.name];
63
+ if (!locked) {
64
+ changes.push({ type: 'added_server', server: server.name, ide: server.ide });
65
+ continue;
66
+ }
67
+
68
+ const tools = normalizeToolsList(server.tools);
69
+ for (const tool of tools) {
70
+ const toolObj = typeof tool === 'string' ? { name: tool } : tool;
71
+ const toolName = toolObj.name || 'unknown';
72
+ const lockedTool = locked.tools[toolName];
73
+
74
+ if (!lockedTool) {
75
+ changes.push({ type: 'added_tool', server: server.name, tool: toolName });
76
+ continue;
77
+ }
78
+
79
+ const currentHash = `sha256:${hashToolDefinition(toolObj)}`;
80
+ if (currentHash !== lockedTool.hash) {
81
+ changes.push({ type: 'changed_tool', server: server.name, tool: toolName });
82
+ }
83
+ }
84
+
85
+ checkRemovedTools(locked, tools, server.name, changes);
86
+ }
87
+
88
+ checkRemovedServers(lockData, currentServers, changes);
89
+
90
+ return changes;
91
+ }
92
+
93
+ /**
94
+ * Check for tools that were in lockfile but removed from current state
95
+ */
96
+ function checkRemovedTools(locked, tools, serverName, changes) {
97
+ for (const lockedToolName of Object.keys(locked.tools)) {
98
+ const stillExists = tools.some((t) => {
99
+ const name = typeof t === 'string' ? t : t.name;
100
+ return name === lockedToolName;
101
+ });
102
+ if (!stillExists) {
103
+ changes.push({ type: 'removed_tool', server: serverName, tool: lockedToolName });
104
+ }
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Check for servers that were in lockfile but removed
110
+ */
111
+ function checkRemovedServers(lockData, currentServers, changes) {
112
+ for (const lockedServerName of Object.keys(lockData.servers)) {
113
+ const stillExists = currentServers.some((s) => s.name === lockedServerName);
114
+ if (!stillExists) {
115
+ changes.push({ type: 'removed_server', server: lockedServerName });
116
+ }
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Render diff changes to terminal
122
+ */
123
+ export function renderDiff(changes) {
124
+ if (changes.length === 0) {
125
+ console.log(` ${kleur.green(S.pass)} No changes detected`);
126
+ return;
127
+ }
128
+
129
+ console.log('');
130
+ for (const change of changes) {
131
+ if (change.type === 'added_server') {
132
+ console.log(
133
+ ` ${kleur.green('+')} Server added: ${kleur.bold(change.server)} (${change.ide})`
134
+ );
135
+ }
136
+ if (change.type === 'removed_server') {
137
+ console.log(` ${kleur.red('-')} Server removed: ${kleur.bold(change.server)}`);
138
+ }
139
+ if (change.type === 'added_tool') {
140
+ console.log(` ${kleur.green('+')} Tool added: ${change.server}/${kleur.bold(change.tool)}`);
141
+ }
142
+ if (change.type === 'removed_tool') {
143
+ console.log(` ${kleur.red('-')} Tool removed: ${change.server}/${kleur.bold(change.tool)}`);
144
+ }
145
+ if (change.type === 'changed_tool') {
146
+ console.log(
147
+ ` ${kleur.yellow('~')} Tool changed: ${change.server}/${kleur.bold(change.tool)} ${kleur.yellow(S.warn)}`
148
+ );
149
+ }
150
+ }
151
+ console.log('');
152
+ }