@mcp-shark/mcp-shark 1.6.0 → 1.7.2
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/README.md +140 -51
- package/core/cli/ListCommand.js +31 -0
- package/core/cli/data/rule-packs/aauth-visibility.json +117 -0
- package/core/configs/environment.js +3 -1
- package/core/configs/index.js +3 -64
- package/core/mcp-server/index.js +4 -1
- package/core/mcp-server/server/external/all.js +10 -3
- package/core/mcp-server/server/external/config.js +62 -5
- package/core/models/RequestFilters.js +3 -0
- package/core/services/ConfigService.js +9 -1
- package/core/services/ConfigTransformService.js +34 -2
- package/core/services/RequestService.js +58 -5
- package/core/services/ServerManagementService.js +59 -4
- package/core/services/security/aauthGraph.js +199 -0
- package/core/services/security/aauthParser.js +274 -0
- package/core/services/security/aauthSelfTest.js +346 -0
- package/package.json +2 -2
- package/ui/dist/assets/index-D6zDrtMV.js +81 -0
- package/ui/dist/index.html +1 -1
- package/ui/server/controllers/AauthController.js +279 -0
- package/ui/server/controllers/RequestController.js +3 -0
- package/ui/server/routes/aauth.js +18 -0
- package/ui/server/setup.js +222 -6
- package/ui/server/swagger/paths.js +0 -2
- package/ui/server/swagger/swagger.js +0 -1
- package/ui/server.js +1 -1
- package/ui/src/App.jsx +26 -52
- package/ui/src/PacketFilters.jsx +31 -1
- package/ui/src/PacketList.jsx +2 -2
- package/ui/src/TabNavigation.jsx +8 -0
- package/ui/src/components/AAuthBadge.jsx +92 -0
- package/ui/src/components/AauthExplorer/AauthExplorerGraph.jsx +231 -0
- package/ui/src/components/AauthExplorer/AauthExplorerView.jsx +387 -0
- package/ui/src/components/AauthExplorer/NodeDetailPanel.jsx +272 -0
- package/ui/src/components/App/ActionMenu.jsx +4 -31
- package/ui/src/components/App/ApiDocsButton.jsx +0 -1
- package/ui/src/components/App/ShutdownButton.jsx +0 -1
- package/ui/src/components/App/useAppState.js +19 -26
- package/ui/src/components/DetailsTab/AAuthIdentitySection.jsx +119 -0
- package/ui/src/components/DetailsTab/RequestDetailsSection.jsx +2 -0
- package/ui/src/components/DetailsTab/ResponseDetailsSection.jsx +2 -0
- package/ui/src/components/DetectedPathsList.jsx +1 -5
- package/ui/src/components/FileInput.jsx +0 -1
- package/ui/src/components/PacketFilters/AAuthPostureFilter.jsx +81 -0
- package/ui/src/components/RequestRow/RequestRowMain.jsx +7 -1
- package/ui/src/components/Security/AAuthPosturePanel.jsx +360 -0
- package/ui/src/components/Security/ScannerContent.jsx +3 -0
- package/ui/src/components/ServerControl.jsx +0 -1
- package/ui/src/components/TabNavigation/DesktopTabs.jsx +0 -11
- package/ui/src/components/TabNavigationIcons.jsx +5 -0
- package/ui/src/components/ViewModeTabs.jsx +0 -1
- package/ui/src/utils/animations.js +26 -9
- package/ui/dist/assets/index-Buah4fNS.js +0 -97
- package/ui/server/routes/help.js +0 -44
- package/ui/server/swagger/paths/help.js +0 -82
- package/ui/src/HelpGuide/HelpGuideContent.jsx +0 -118
- package/ui/src/HelpGuide/HelpGuideFooter.jsx +0 -59
- package/ui/src/HelpGuide/HelpGuideHeader.jsx +0 -57
- package/ui/src/HelpGuide.jsx +0 -78
- package/ui/src/IntroTour.jsx +0 -154
- package/ui/src/components/App/HelpButton.jsx +0 -90
- package/ui/src/components/TourOverlay.jsx +0 -117
- package/ui/src/components/TourTooltip/TourTooltipButtons.jsx +0 -120
- package/ui/src/components/TourTooltip/TourTooltipHeader.jsx +0 -71
- package/ui/src/components/TourTooltip/TourTooltipIcons.jsx +0 -54
- package/ui/src/components/TourTooltip/useTooltipPosition.js +0 -135
- package/ui/src/components/TourTooltip.jsx +0 -91
- package/ui/src/config/tourSteps.jsx +0 -140
|
@@ -4,6 +4,36 @@ import { ConfigParserFactory } from '#core/services/parsers/ConfigParserFactory.
|
|
|
4
4
|
|
|
5
5
|
const DEFAULT_TYPE = 'stdio';
|
|
6
6
|
|
|
7
|
+
// An upstream entry is considered a self-reference (and pruned) when:
|
|
8
|
+
// - its name is in this reserved set, OR
|
|
9
|
+
// - it is an HTTP entry whose host:port resolves to mcp-shark's own
|
|
10
|
+
// listening port. Including such an entry would loop the proxy back
|
|
11
|
+
// onto itself at startup and crash with RunAllExternalServersError.
|
|
12
|
+
const RESERVED_UPSTREAM_NAMES = new Set(['mcp-shark']);
|
|
13
|
+
|
|
14
|
+
function isSelfReferencingEntry(name, cfg, selfPort) {
|
|
15
|
+
if (RESERVED_UPSTREAM_NAMES.has(name)) {
|
|
16
|
+
return { selfRef: true, reason: `reserved name '${name}'` };
|
|
17
|
+
}
|
|
18
|
+
if (!selfPort || !cfg || typeof cfg.url !== 'string') {
|
|
19
|
+
return { selfRef: false };
|
|
20
|
+
}
|
|
21
|
+
let parsed;
|
|
22
|
+
try {
|
|
23
|
+
parsed = new URL(cfg.url);
|
|
24
|
+
} catch {
|
|
25
|
+
return { selfRef: false };
|
|
26
|
+
}
|
|
27
|
+
const port = Number.parseInt(parsed.port || (parsed.protocol === 'https:' ? '443' : '80'), 10);
|
|
28
|
+
if (port !== selfPort) return { selfRef: false };
|
|
29
|
+
const localHosts = new Set(['127.0.0.1', 'localhost', '0.0.0.0', '::1']);
|
|
30
|
+
if (!localHosts.has(parsed.hostname)) return { selfRef: false };
|
|
31
|
+
return {
|
|
32
|
+
selfRef: true,
|
|
33
|
+
reason: `URL ${parsed.hostname}:${port} points back at mcp-shark's own proxy`,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
7
37
|
export class ConfigError extends CompositeError {
|
|
8
38
|
constructor(message, error) {
|
|
9
39
|
super('ConfigError', message, error);
|
|
@@ -37,7 +67,8 @@ function parseConfig(configPath) {
|
|
|
37
67
|
}
|
|
38
68
|
}
|
|
39
69
|
|
|
40
|
-
export function normalizeConfig(configPath) {
|
|
70
|
+
export function normalizeConfig(configPath, options = {}) {
|
|
71
|
+
const { selfPort, logger, allowEmpty = false } = options;
|
|
41
72
|
const parsedConfigResult = parseConfig(configPath);
|
|
42
73
|
if (isError(parsedConfigResult)) {
|
|
43
74
|
return parsedConfigResult;
|
|
@@ -45,29 +76,55 @@ export function normalizeConfig(configPath) {
|
|
|
45
76
|
|
|
46
77
|
const normalized = parserFactory.normalizeToInternalFormat(parsedConfigResult, configPath);
|
|
47
78
|
if (!normalized) {
|
|
79
|
+
if (allowEmpty) return {};
|
|
48
80
|
return new ConfigError('No servers found in config');
|
|
49
81
|
}
|
|
50
82
|
|
|
51
83
|
// Convert to flat map format expected by MCP server
|
|
52
84
|
const out = new Map();
|
|
85
|
+
const pruned = [];
|
|
86
|
+
|
|
87
|
+
function addEntry(name, cfg) {
|
|
88
|
+
const { selfRef, reason } = isSelfReferencingEntry(name, cfg, selfPort);
|
|
89
|
+
if (selfRef) {
|
|
90
|
+
pruned.push({ name, reason });
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
const type = cfg.type ?? DEFAULT_TYPE;
|
|
94
|
+
out.set(name, { type, ...cfg });
|
|
95
|
+
}
|
|
53
96
|
|
|
54
97
|
// Handle normalized mcpServers format
|
|
55
98
|
if (normalized.mcpServers) {
|
|
56
99
|
for (const [name, cfg] of Object.entries(normalized.mcpServers)) {
|
|
57
|
-
|
|
58
|
-
out.set(name, { type, ...cfg });
|
|
100
|
+
addEntry(name, cfg);
|
|
59
101
|
}
|
|
60
102
|
}
|
|
61
103
|
|
|
62
104
|
// Handle normalized servers format (legacy)
|
|
63
105
|
if (normalized.servers) {
|
|
64
106
|
for (const [name, cfg] of Object.entries(normalized.servers)) {
|
|
65
|
-
|
|
66
|
-
|
|
107
|
+
addEntry(name, cfg);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (pruned.length > 0 && logger?.warn) {
|
|
112
|
+
for (const { name, reason } of pruned) {
|
|
113
|
+
logger.warn(
|
|
114
|
+
{ upstream: name, reason },
|
|
115
|
+
`[MCP-Shark] Skipping self-referencing upstream '${name}': ${reason}`
|
|
116
|
+
);
|
|
67
117
|
}
|
|
68
118
|
}
|
|
69
119
|
|
|
70
120
|
if (out.size === 0) {
|
|
121
|
+
if (allowEmpty) {
|
|
122
|
+
logger?.warn?.(
|
|
123
|
+
{ configPath },
|
|
124
|
+
'[MCP-Shark] No upstream MCP servers configured — running in monitoring-only mode'
|
|
125
|
+
);
|
|
126
|
+
return {};
|
|
127
|
+
}
|
|
71
128
|
return new ConfigError('No servers found in config');
|
|
72
129
|
}
|
|
73
130
|
|
|
@@ -19,6 +19,9 @@ export class RequestFilters {
|
|
|
19
19
|
this.limit = data.limit !== undefined ? Number.parseInt(data.limit) : Defaults.DEFAULT_LIMIT;
|
|
20
20
|
this.offset =
|
|
21
21
|
data.offset !== undefined ? Number.parseInt(data.offset) : Defaults.DEFAULT_OFFSET;
|
|
22
|
+
this.aauthPosture = data.aauthPosture || null;
|
|
23
|
+
this.aauthAgent = data.aauthAgent || null;
|
|
24
|
+
this.aauthMission = data.aauthMission || null;
|
|
22
25
|
}
|
|
23
26
|
|
|
24
27
|
/**
|
|
@@ -201,7 +201,15 @@ export class ConfigService {
|
|
|
201
201
|
? this.transformService.filterServers(baseConvertedConfig, selectedServices)
|
|
202
202
|
: baseConvertedConfig;
|
|
203
203
|
|
|
204
|
-
|
|
204
|
+
// A zero-upstream config is a legitimate state (monitoring-only mode):
|
|
205
|
+
// mcp-shark still runs the audit/UI/AAuth surfaces, just without any
|
|
206
|
+
// upstreams to forward to. We only fail here if the *original* config
|
|
207
|
+
// file declared no servers at all (different from "all servers were
|
|
208
|
+
// self-references and got filtered out").
|
|
209
|
+
const originalHadAnyServer =
|
|
210
|
+
Object.keys(originalConfig.mcpServers || {}).length > 0 ||
|
|
211
|
+
Object.keys(originalConfig.servers || {}).length > 0;
|
|
212
|
+
if (!originalHadAnyServer) {
|
|
205
213
|
return {
|
|
206
214
|
success: false,
|
|
207
215
|
error: 'No servers found in config',
|
|
@@ -1,3 +1,29 @@
|
|
|
1
|
+
// mcp-shark must never appear as an upstream of itself. The UI reads the
|
|
2
|
+
// user's editor MCP config (e.g. ~/.cursor/mcp.json) — which legitimately
|
|
3
|
+
// contains an `mcp-shark` entry pointing at the proxy port so the editor
|
|
4
|
+
// routes calls through us — and copies it into ~/.mcp-shark/mcps.json as
|
|
5
|
+
// the proxy's upstream list. Without this filter the proxy ends up with a
|
|
6
|
+
// recursive self-reference that crashes startup.
|
|
7
|
+
const RESERVED_PROXY_NAMES = new Set(['mcp-shark']);
|
|
8
|
+
function isProxySelfReference(name, cfg) {
|
|
9
|
+
if (RESERVED_PROXY_NAMES.has(name)) return true;
|
|
10
|
+
if (!cfg || typeof cfg.url !== 'string') return false;
|
|
11
|
+
let parsed;
|
|
12
|
+
try {
|
|
13
|
+
parsed = new URL(cfg.url);
|
|
14
|
+
} catch {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
const localHosts = new Set(['127.0.0.1', 'localhost', '0.0.0.0', '::1']);
|
|
18
|
+
if (!localHosts.has(parsed.hostname)) return false;
|
|
19
|
+
const port = Number.parseInt(parsed.port || (parsed.protocol === 'https:' ? '443' : '80'), 10);
|
|
20
|
+
// We don't have access to the runtime port here; the proxy default is
|
|
21
|
+
// 9851 and the UI always starts it on that port unless overridden via
|
|
22
|
+
// env. If the user runs on a different port they can still safely include
|
|
23
|
+
// a custom-named entry; the runtime config.js layer will catch true loops.
|
|
24
|
+
return port === 9851;
|
|
25
|
+
}
|
|
26
|
+
|
|
1
27
|
/**
|
|
2
28
|
* Service for configuration transformations
|
|
3
29
|
* Handles converting, filtering, and updating config structures
|
|
@@ -9,7 +35,9 @@ export class ConfigTransformService {
|
|
|
9
35
|
|
|
10
36
|
/**
|
|
11
37
|
* Convert MCP servers format to servers format
|
|
12
|
-
* Normalizes config first, then converts mcpServers to servers
|
|
38
|
+
* Normalizes config first, then converts mcpServers to servers.
|
|
39
|
+
* Self-referencing entries (mcp-shark itself) are dropped — they would
|
|
40
|
+
* create an infinite proxy loop if written to ~/.mcp-shark/mcps.json.
|
|
13
41
|
*/
|
|
14
42
|
convertMcpServersToServers(config) {
|
|
15
43
|
// Normalize config to ensure consistent format
|
|
@@ -22,12 +50,16 @@ export class ConfigTransformService {
|
|
|
22
50
|
|
|
23
51
|
// Handle normalized servers (legacy format)
|
|
24
52
|
if (normalized.servers) {
|
|
25
|
-
|
|
53
|
+
for (const [name, cfg] of Object.entries(normalized.servers)) {
|
|
54
|
+
if (isProxySelfReference(name, cfg)) continue;
|
|
55
|
+
converted.servers[name] = cfg;
|
|
56
|
+
}
|
|
26
57
|
}
|
|
27
58
|
|
|
28
59
|
// Convert mcpServers to servers format
|
|
29
60
|
if (normalized.mcpServers) {
|
|
30
61
|
for (const [name, cfg] of Object.entries(normalized.mcpServers)) {
|
|
62
|
+
if (isProxySelfReference(name, cfg)) continue;
|
|
31
63
|
const type = cfg.type || (cfg.url ? 'http' : cfg.command ? 'stdio' : 'stdio');
|
|
32
64
|
converted.servers[name] = { type, ...cfg };
|
|
33
65
|
}
|
|
@@ -5,6 +5,53 @@ import { Defaults } from '../constants/Defaults.js';
|
|
|
5
5
|
* HTTP-agnostic: accepts models, returns models
|
|
6
6
|
*/
|
|
7
7
|
import { RequestFilters } from '../models/RequestFilters.js';
|
|
8
|
+
import { parseAauthForPacket } from './security/aauthParser.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Attach an `aauth` field to a packet record by parsing its headers_json.
|
|
12
|
+
* Pure function; no DB writes. Always returns a new object.
|
|
13
|
+
*/
|
|
14
|
+
function enrichWithAauth(packet) {
|
|
15
|
+
if (!packet) {
|
|
16
|
+
return packet;
|
|
17
|
+
}
|
|
18
|
+
try {
|
|
19
|
+
return { ...packet, aauth: parseAauthForPacket(packet) };
|
|
20
|
+
} catch {
|
|
21
|
+
return packet;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Filter a list of packets according to AAuth-derived filters that are not
|
|
27
|
+
* supported by the repository (which only knows about DB columns).
|
|
28
|
+
*/
|
|
29
|
+
function applyAauthFilters(packets, filters) {
|
|
30
|
+
if (!filters) {
|
|
31
|
+
return packets;
|
|
32
|
+
}
|
|
33
|
+
const { aauthPosture = null, aauthAgent = null, aauthMission = null } = filters;
|
|
34
|
+
if (!aauthPosture && !aauthAgent && !aauthMission) {
|
|
35
|
+
return packets;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return packets.filter((p) => {
|
|
39
|
+
const aauth = p?.aauth;
|
|
40
|
+
if (!aauth) {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
if (aauthPosture && aauth.posture !== aauthPosture) {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
if (aauthAgent && aauth.agent !== aauthAgent) {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
if (aauthMission && aauth.mission !== aauthMission) {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
return true;
|
|
53
|
+
});
|
|
54
|
+
}
|
|
8
55
|
|
|
9
56
|
export class RequestService {
|
|
10
57
|
constructor(packetRepository) {
|
|
@@ -14,21 +61,27 @@ export class RequestService {
|
|
|
14
61
|
/**
|
|
15
62
|
* Get requests with filters
|
|
16
63
|
* @param {RequestFilters} filters - Typed filter model
|
|
17
|
-
* @returns {Array} Array of packet objects
|
|
64
|
+
* @returns {Array} Array of packet objects, each enriched with an `aauth` block.
|
|
18
65
|
*/
|
|
19
66
|
getRequests(filters) {
|
|
20
67
|
const repoFilters = filters.toRepositoryFilters();
|
|
21
|
-
|
|
68
|
+
const rows = this.packetRepository.queryRequests(repoFilters);
|
|
69
|
+
const enriched = rows.map(enrichWithAauth);
|
|
70
|
+
return applyAauthFilters(enriched, {
|
|
71
|
+
aauthPosture: filters.aauthPosture,
|
|
72
|
+
aauthAgent: filters.aauthAgent,
|
|
73
|
+
aauthMission: filters.aauthMission,
|
|
74
|
+
});
|
|
22
75
|
}
|
|
23
76
|
|
|
24
77
|
/**
|
|
25
78
|
* Get request by frame number
|
|
26
79
|
* @param {number} frameNumber - Frame number
|
|
27
|
-
* @returns {Object|null} Packet object
|
|
80
|
+
* @returns {Object|null} Packet object enriched with an `aauth` block, or null.
|
|
28
81
|
*/
|
|
29
82
|
getRequest(frameNumber) {
|
|
30
83
|
const parsedFrameNumber = Number.parseInt(frameNumber);
|
|
31
|
-
return this.packetRepository.getByFrameNumber(parsedFrameNumber);
|
|
84
|
+
return enrichWithAauth(this.packetRepository.getByFrameNumber(parsedFrameNumber));
|
|
32
85
|
}
|
|
33
86
|
|
|
34
87
|
/**
|
|
@@ -51,6 +104,6 @@ export class RequestService {
|
|
|
51
104
|
offset: Defaults.DEFAULT_OFFSET,
|
|
52
105
|
});
|
|
53
106
|
const repoFilters = exportFilters.toRepositoryFilters();
|
|
54
|
-
return this.packetRepository.queryRequests(repoFilters);
|
|
107
|
+
return this.packetRepository.queryRequests(repoFilters).map(enrichWithAauth);
|
|
55
108
|
}
|
|
56
109
|
}
|
|
@@ -1,6 +1,17 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
|
|
1
3
|
import { Defaults } from '#core/constants/Defaults.js';
|
|
2
4
|
import { initAuditLogger, startMcpSharkServer } from '#core/mcp-server/index.js';
|
|
3
5
|
|
|
6
|
+
function isSamePath(a, b) {
|
|
7
|
+
if (!a || !b) return false;
|
|
8
|
+
try {
|
|
9
|
+
return path.resolve(a) === path.resolve(b);
|
|
10
|
+
} catch {
|
|
11
|
+
return a === b;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
4
15
|
/**
|
|
5
16
|
* Service for managing MCP Shark server lifecycle
|
|
6
17
|
* Handles server startup, shutdown, and status
|
|
@@ -60,8 +71,34 @@ export class ServerManagementService {
|
|
|
60
71
|
const { fileData, convertedConfig, updatedConfig } = setupResult;
|
|
61
72
|
const mcpsJsonPath = this.configService.getMcpConfigPath();
|
|
62
73
|
|
|
63
|
-
//
|
|
64
|
-
|
|
74
|
+
// The user can legitimately point the Setup panel at the proxy's own
|
|
75
|
+
// config file (~/.mcp-shark/mcps.json) when they just want to (re)start
|
|
76
|
+
// the proxy with whatever is already there — for example after
|
|
77
|
+
// `npm run testbed:up` has written upstreams directly. In that case the
|
|
78
|
+
// input *is* the output and the "patch the editor config" step at the
|
|
79
|
+
// end would rewrite every upstream URL into the proxy-local form
|
|
80
|
+
// (http://localhost:9851/mcp/<name>), turning mcps.json into a
|
|
81
|
+
// self-referential file that the next startup has to strip.
|
|
82
|
+
const sourceIsProxyConfig = isSamePath(fileData.resolvedFilePath, mcpsJsonPath);
|
|
83
|
+
|
|
84
|
+
// If the conversion produced zero upstreams (e.g. the user picked an
|
|
85
|
+
// already-patched ~/.cursor/mcp.json that only contains the mcp-shark
|
|
86
|
+
// self-reference, and no backup was available to restore), don't blow
|
|
87
|
+
// away whatever the proxy already has. A healthy existing mcps.json is
|
|
88
|
+
// strictly more useful than an empty new one.
|
|
89
|
+
const convertedUpstreamCount = Object.keys(convertedConfig?.servers || {}).length;
|
|
90
|
+
const existingHasUpstreams = this._existingMcpsHasUpstreams(mcpsJsonPath);
|
|
91
|
+
const skipWriteToPreserveExisting =
|
|
92
|
+
convertedUpstreamCount === 0 && existingHasUpstreams && !sourceIsProxyConfig;
|
|
93
|
+
|
|
94
|
+
if (skipWriteToPreserveExisting) {
|
|
95
|
+
this.logger?.warn(
|
|
96
|
+
{ path: mcpsJsonPath, source: fileData.resolvedFilePath },
|
|
97
|
+
'Conversion yielded zero upstreams; preserving existing mcps.json instead of overwriting'
|
|
98
|
+
);
|
|
99
|
+
} else {
|
|
100
|
+
this.configService.writeConfigAsJson(mcpsJsonPath, convertedConfig);
|
|
101
|
+
}
|
|
65
102
|
|
|
66
103
|
// Stop existing server if running
|
|
67
104
|
if (this.serverInstance?.stop) {
|
|
@@ -86,9 +123,13 @@ export class ServerManagementService {
|
|
|
86
123
|
},
|
|
87
124
|
});
|
|
88
125
|
|
|
89
|
-
// Patch the original config file if it exists
|
|
126
|
+
// Patch the original config file if it exists. Skip this when the
|
|
127
|
+
// source IS our own proxy config — patching it would corrupt the
|
|
128
|
+
// upstream list we just wrote.
|
|
90
129
|
const patchWarning =
|
|
91
|
-
fileData.resolvedFilePath &&
|
|
130
|
+
fileData.resolvedFilePath &&
|
|
131
|
+
this.configService.fileExists(fileData.resolvedFilePath) &&
|
|
132
|
+
!sourceIsProxyConfig
|
|
92
133
|
? this.configPatchingService.patchConfigFile(fileData.resolvedFilePath, updatedConfig)
|
|
93
134
|
.warning || null
|
|
94
135
|
: null;
|
|
@@ -104,6 +145,20 @@ export class ServerManagementService {
|
|
|
104
145
|
};
|
|
105
146
|
}
|
|
106
147
|
|
|
148
|
+
_existingMcpsHasUpstreams(mcpsJsonPath) {
|
|
149
|
+
try {
|
|
150
|
+
if (!this.configService.fileExists(mcpsJsonPath)) return false;
|
|
151
|
+
const content = this.configService.readConfigFile(mcpsJsonPath);
|
|
152
|
+
const parsed = this.configService.tryParseJson(content, mcpsJsonPath);
|
|
153
|
+
if (!parsed) return false;
|
|
154
|
+
const count =
|
|
155
|
+
Object.keys(parsed.servers || {}).length + Object.keys(parsed.mcpServers || {}).length;
|
|
156
|
+
return count > 0;
|
|
157
|
+
} catch {
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
107
162
|
/**
|
|
108
163
|
* Start MCP Shark server
|
|
109
164
|
*/
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AAuth knowledge-graph builder.
|
|
3
|
+
*
|
|
4
|
+
* Walks the captured packets, extracts AAuth signals via aauthParser, and
|
|
5
|
+
* shapes a node/edge graph that mirrors the categories on
|
|
6
|
+
* https://mcp-shark.github.io/aauth-explorer/ — but every node here is
|
|
7
|
+
* grounded in observed traffic, not a static spec illustration.
|
|
8
|
+
*
|
|
9
|
+
* Categories (kept stable so the UI can color-code them):
|
|
10
|
+
* - agent — observed AAuth-Agent values
|
|
11
|
+
* - mission — observed AAuth-Mission values
|
|
12
|
+
* - resource — destination host/server names
|
|
13
|
+
* - signing — algorithms parsed from Signature-Input (`alg=...`)
|
|
14
|
+
* - access — access modes parsed from AAuth-Requirement (`mode=...`)
|
|
15
|
+
*
|
|
16
|
+
* Edges are weighted by observed packet count so the force layout settles
|
|
17
|
+
* naturally around the busiest nodes.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { parseAauthForPacket } from './aauthParser.js';
|
|
21
|
+
|
|
22
|
+
const ACCESS_MODE_REGEX = /mode\s*=\s*"?([A-Za-z0-9_-]+)"?/i;
|
|
23
|
+
|
|
24
|
+
function nodeKey(category, id) {
|
|
25
|
+
return `${category}::${id}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function ensureNode(map, category, id, extra = {}) {
|
|
29
|
+
const key = nodeKey(category, id);
|
|
30
|
+
if (!map.has(key)) {
|
|
31
|
+
map.set(key, {
|
|
32
|
+
id: key,
|
|
33
|
+
name: id,
|
|
34
|
+
category,
|
|
35
|
+
packet_count: 0,
|
|
36
|
+
sample_frame_numbers: [],
|
|
37
|
+
...extra,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
return map.get(key);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function bumpNode(node, frameNumber) {
|
|
44
|
+
node.packet_count += 1;
|
|
45
|
+
if (frameNumber != null && node.sample_frame_numbers.length < 5) {
|
|
46
|
+
node.sample_frame_numbers.push(frameNumber);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function ensureEdge(map, sourceKey, targetKey, kind) {
|
|
51
|
+
const key = `${sourceKey}->${targetKey}::${kind}`;
|
|
52
|
+
if (!map.has(key)) {
|
|
53
|
+
map.set(key, { id: key, source: sourceKey, target: targetKey, kind, weight: 0 });
|
|
54
|
+
}
|
|
55
|
+
return map.get(key);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function bumpEdge(edge) {
|
|
59
|
+
edge.weight += 1;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function extractAccessMode(requirement) {
|
|
63
|
+
if (!requirement || typeof requirement !== 'string') {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
const m = requirement.match(ACCESS_MODE_REGEX);
|
|
67
|
+
return m ? m[1] : null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function extractResource(packet) {
|
|
71
|
+
const host = packet?.host || packet?.remote_address || null;
|
|
72
|
+
if (host) {
|
|
73
|
+
return host;
|
|
74
|
+
}
|
|
75
|
+
const url = packet?.url;
|
|
76
|
+
if (typeof url === 'string') {
|
|
77
|
+
try {
|
|
78
|
+
return new URL(url).host;
|
|
79
|
+
} catch {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Build an AAuth knowledge graph from a list of packets.
|
|
88
|
+
*
|
|
89
|
+
* @param {Array} packets - packets as returned by RequestService.getRequests
|
|
90
|
+
* @returns {{
|
|
91
|
+
* categories: Array<{id:string,label:string}>,
|
|
92
|
+
* nodes: Array,
|
|
93
|
+
* edges: Array,
|
|
94
|
+
* stats: object,
|
|
95
|
+
* }}
|
|
96
|
+
*/
|
|
97
|
+
export function buildAauthGraph(packets) {
|
|
98
|
+
const nodes = new Map();
|
|
99
|
+
const edges = new Map();
|
|
100
|
+
|
|
101
|
+
let observedSignals = 0;
|
|
102
|
+
|
|
103
|
+
for (const packet of packets || []) {
|
|
104
|
+
const aauth = parseAauthForPacket(packet);
|
|
105
|
+
const hasAnySignal =
|
|
106
|
+
aauth.posture !== 'none' ||
|
|
107
|
+
aauth.agent ||
|
|
108
|
+
aauth.mission ||
|
|
109
|
+
aauth.requirement ||
|
|
110
|
+
aauth.sig_alg;
|
|
111
|
+
if (!hasAnySignal) {
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
observedSignals += 1;
|
|
115
|
+
|
|
116
|
+
const resourceId = extractResource(packet);
|
|
117
|
+
const frame = packet?.frame_number ?? null;
|
|
118
|
+
|
|
119
|
+
const resourceNode = resourceId
|
|
120
|
+
? ensureNode(nodes, 'resource', resourceId, { url: packet?.url || null })
|
|
121
|
+
: null;
|
|
122
|
+
if (resourceNode) {
|
|
123
|
+
bumpNode(resourceNode, frame);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
let agentNode = null;
|
|
127
|
+
if (aauth.agent) {
|
|
128
|
+
agentNode = ensureNode(nodes, 'agent', aauth.agent, {
|
|
129
|
+
last_keyid: aauth.sig_keyid,
|
|
130
|
+
posture: aauth.posture,
|
|
131
|
+
});
|
|
132
|
+
bumpNode(agentNode, frame);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
let missionNode = null;
|
|
136
|
+
if (aauth.mission) {
|
|
137
|
+
missionNode = ensureNode(nodes, 'mission', aauth.mission);
|
|
138
|
+
bumpNode(missionNode, frame);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
let signingNode = null;
|
|
142
|
+
if (aauth.sig_alg) {
|
|
143
|
+
signingNode = ensureNode(nodes, 'signing', aauth.sig_alg, {
|
|
144
|
+
keyid: aauth.sig_keyid,
|
|
145
|
+
});
|
|
146
|
+
bumpNode(signingNode, frame);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const accessMode = extractAccessMode(aauth.requirement);
|
|
150
|
+
let accessNode = null;
|
|
151
|
+
if (accessMode) {
|
|
152
|
+
accessNode = ensureNode(nodes, 'access', accessMode);
|
|
153
|
+
bumpNode(accessNode, frame);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (agentNode && resourceNode) {
|
|
157
|
+
bumpEdge(ensureEdge(edges, agentNode.id, resourceNode.id, 'calls'));
|
|
158
|
+
}
|
|
159
|
+
if (agentNode && missionNode) {
|
|
160
|
+
bumpEdge(ensureEdge(edges, agentNode.id, missionNode.id, 'pursues'));
|
|
161
|
+
}
|
|
162
|
+
if (missionNode && resourceNode) {
|
|
163
|
+
bumpEdge(ensureEdge(edges, missionNode.id, resourceNode.id, 'targets'));
|
|
164
|
+
}
|
|
165
|
+
if (agentNode && signingNode) {
|
|
166
|
+
bumpEdge(ensureEdge(edges, agentNode.id, signingNode.id, 'signs-with'));
|
|
167
|
+
}
|
|
168
|
+
if (resourceNode && accessNode) {
|
|
169
|
+
bumpEdge(ensureEdge(edges, resourceNode.id, accessNode.id, 'requires'));
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const categories = [
|
|
174
|
+
{ id: 'agent', label: 'Agent' },
|
|
175
|
+
{ id: 'mission', label: 'Mission' },
|
|
176
|
+
{ id: 'resource', label: 'Resource' },
|
|
177
|
+
{ id: 'signing', label: 'Signing' },
|
|
178
|
+
{ id: 'access', label: 'Access' },
|
|
179
|
+
];
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
categories,
|
|
183
|
+
nodes: [...nodes.values()],
|
|
184
|
+
edges: [...edges.values()],
|
|
185
|
+
stats: {
|
|
186
|
+
observed_packets: observedSignals,
|
|
187
|
+
node_counts: countByCategory([...nodes.values()]),
|
|
188
|
+
edge_count: edges.size,
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function countByCategory(nodes) {
|
|
194
|
+
const out = {};
|
|
195
|
+
for (const n of nodes) {
|
|
196
|
+
out[n.category] = (out[n.category] || 0) + 1;
|
|
197
|
+
}
|
|
198
|
+
return out;
|
|
199
|
+
}
|