loadout-ai 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/MASTER_PLAN.md +1711 -0
- package/README.md +453 -0
- package/SECURITY.md +18 -0
- package/SIMPLE_PLAN.md +44 -0
- package/catalog/discovered.json +10469 -0
- package/catalog/packages.json +1150 -0
- package/dashboard/app.js +607 -0
- package/dashboard/index.html +249 -0
- package/dashboard/styles.css +384 -0
- package/dist/src/cli.js +2735 -0
- package/dist/src/core/active-policy.js +233 -0
- package/dist/src/core/active-set.js +382 -0
- package/dist/src/core/adapters.js +254 -0
- package/dist/src/core/adopt.js +92 -0
- package/dist/src/core/agent-health-score.js +475 -0
- package/dist/src/core/agent-inspection.js +152 -0
- package/dist/src/core/agent-versions.js +131 -0
- package/dist/src/core/api.js +90 -0
- package/dist/src/core/atomic-file.js +22 -0
- package/dist/src/core/audit.js +174 -0
- package/dist/src/core/benchmark-campaign.js +496 -0
- package/dist/src/core/benchmark-evidence.js +458 -0
- package/dist/src/core/benchmark-fixtures.js +1157 -0
- package/dist/src/core/benchmark-runner.js +301 -0
- package/dist/src/core/benchmark-trust.js +795 -0
- package/dist/src/core/canary.js +77 -0
- package/dist/src/core/candidate-intelligence.js +661 -0
- package/dist/src/core/catalog-coverage.js +85 -0
- package/dist/src/core/catalog-install.js +186 -0
- package/dist/src/core/catalog-release.js +286 -0
- package/dist/src/core/catalog.js +327 -0
- package/dist/src/core/codex-mcp.js +122 -0
- package/dist/src/core/community.js +86 -0
- package/dist/src/core/compatibility-intelligence.js +465 -0
- package/dist/src/core/completion.js +160 -0
- package/dist/src/core/components.js +138 -0
- package/dist/src/core/conversion.js +56 -0
- package/dist/src/core/credentials.js +377 -0
- package/dist/src/core/demo.js +136 -0
- package/dist/src/core/diff.js +83 -0
- package/dist/src/core/discovery-connector.js +165 -0
- package/dist/src/core/doctor.js +95 -0
- package/dist/src/core/ecosystem-import.js +1072 -0
- package/dist/src/core/evaluate.js +72 -0
- package/dist/src/core/file-lock.js +140 -0
- package/dist/src/core/freshness-alerts.js +230 -0
- package/dist/src/core/github-discovery.js +97 -0
- package/dist/src/core/github.js +148 -0
- package/dist/src/core/head-to-head.js +437 -0
- package/dist/src/core/health-score-evidence.js +169 -0
- package/dist/src/core/health.js +129 -0
- package/dist/src/core/improve.js +223 -0
- package/dist/src/core/install.js +233 -0
- package/dist/src/core/intelligence-feed-build.js +59 -0
- package/dist/src/core/intelligence-feed.js +353 -0
- package/dist/src/core/loadout-badge.js +55 -0
- package/dist/src/core/loadout-card.js +87 -0
- package/dist/src/core/manifest.js +307 -0
- package/dist/src/core/mcp-recipes.js +419 -0
- package/dist/src/core/mcp-registry-discovery.js +455 -0
- package/dist/src/core/mcp.js +287 -0
- package/dist/src/core/model-config.js +102 -0
- package/dist/src/core/observations.js +87 -0
- package/dist/src/core/outcomes.js +164 -0
- package/dist/src/core/package.js +225 -0
- package/dist/src/core/paths.js +214 -0
- package/dist/src/core/portable.js +130 -0
- package/dist/src/core/private-discovery.js +45 -0
- package/dist/src/core/profiles.js +255 -0
- package/dist/src/core/provenance.js +278 -0
- package/dist/src/core/ranking.js +104 -0
- package/dist/src/core/recommend.js +181 -0
- package/dist/src/core/registry-api.js +97 -0
- package/dist/src/core/registry.js +316 -0
- package/dist/src/core/release-claims.js +175 -0
- package/dist/src/core/remove.js +122 -0
- package/dist/src/core/review-queue.js +200 -0
- package/dist/src/core/runtime-tool-recipe.js +315 -0
- package/dist/src/core/runtime-tools.js +453 -0
- package/dist/src/core/safety.js +234 -0
- package/dist/src/core/sandbox.js +78 -0
- package/dist/src/core/scheduler.js +264 -0
- package/dist/src/core/share-report.js +85 -0
- package/dist/src/core/signing.js +115 -0
- package/dist/src/core/skill-compare.js +239 -0
- package/dist/src/core/skill-inventory.js +193 -0
- package/dist/src/core/skill-security.js +340 -0
- package/dist/src/core/skills-sh-discovery.js +336 -0
- package/dist/src/core/skills.js +208 -0
- package/dist/src/core/snapshot.js +210 -0
- package/dist/src/core/source.js +305 -0
- package/dist/src/core/state.js +300 -0
- package/dist/src/core/sync.js +348 -0
- package/dist/src/core/terminal.js +53 -0
- package/dist/src/core/transaction.js +211 -0
- package/dist/src/core/update-watch.js +39 -0
- package/dist/src/core/update.js +216 -0
- package/dist/src/core/upgrade.js +207 -0
- package/dist/src/dashboard.js +418 -0
- package/dist/src/shared/schemas.js +336 -0
- package/dist/src/shared/types.js +1 -0
- package/docs/ACTIVE_SET.md +53 -0
- package/docs/ACTIVE_SET_POLICY.md +45 -0
- package/docs/CANDIDATE_INTELLIGENCE.md +139 -0
- package/docs/CATALOG.md +78 -0
- package/docs/CATALOG_POLICY.md +41 -0
- package/docs/COMMUNITY_DISCOVERY.md +14 -0
- package/docs/COMPATIBILITY_POLICY.md +22 -0
- package/docs/CONVERSION_AND_SANDBOX.md +27 -0
- package/docs/CREDENTIAL_AND_UPDATE_POLICY.md +31 -0
- package/docs/DISCOVERED.md +280 -0
- package/docs/EVALUATION_PROTOCOL.md +40 -0
- package/docs/EVALUATION_PROTOCOL_V1.md +300 -0
- package/docs/FEATURE_TEST_MATRIX.md +802 -0
- package/docs/GITHUB_AUTHORIZATION.md +38 -0
- package/docs/HEAD_TO_HEAD_EVALUATION.md +79 -0
- package/docs/PROVENANCE_AND_COMPARISON.md +72 -0
- package/docs/PROVIDER_CONFIGURATION.md +45 -0
- package/docs/RELEASE_REVIEW.md +117 -0
- package/docs/SAFE_UPDATE_DEMO.md +25 -0
- package/docs/SCHEMA_DECISIONS.md +25 -0
- package/docs/TEAM_POLICY.md +18 -0
- package/docs/TESTING.md +190 -0
- package/package.json +82 -0
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { createCredentialResolver } from "./credentials.js";
|
|
5
|
+
import { planMcpConfig } from "./mcp.js";
|
|
6
|
+
/**
|
|
7
|
+
* Small, source-linked recipes. These configure a connection only; authorizing
|
|
8
|
+
* a service remains an explicit user action outside Loadout.
|
|
9
|
+
*/
|
|
10
|
+
export const REVIEWED_MCP_RECIPES = [
|
|
11
|
+
{
|
|
12
|
+
id: "playwright",
|
|
13
|
+
displayName: "Playwright MCP",
|
|
14
|
+
source: "https://github.com/microsoft/playwright-mcp/tree/5f8fc00210b27b4407c375b59cda4838045d429c",
|
|
15
|
+
serverName: "playwright",
|
|
16
|
+
command: "npx",
|
|
17
|
+
args: ["-y", "@playwright/mcp@0.0.78"],
|
|
18
|
+
environment: [],
|
|
19
|
+
fixedEnvironment: {},
|
|
20
|
+
permissions: [
|
|
21
|
+
"browser automation",
|
|
22
|
+
"local browser profile as configured by the user",
|
|
23
|
+
],
|
|
24
|
+
connection: "stdio",
|
|
25
|
+
reviewedCommit: "5f8fc00210b27b4407c375b59cda4838045d429c",
|
|
26
|
+
reviewedAt: "2026-07-16T00:00:00Z",
|
|
27
|
+
artifact: "npm:@playwright/mcp@0.0.78#sha512-XLTUeA6mEN9sQ+hJ4dfG8EIkDbxS0K3Trc2RBkUJuf02TgE2FQRNTMtq/aJfhyRMINsRl/Ybc4sxcWLtFn4/TQ==",
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
id: "github-readonly",
|
|
31
|
+
displayName: "GitHub MCP Server (read-only)",
|
|
32
|
+
source: "https://github.com/github/github-mcp-server/tree/dc3ee11f4c1c9541a2e918acfc7e20c8332c9a36",
|
|
33
|
+
serverName: "github",
|
|
34
|
+
command: "docker",
|
|
35
|
+
args: [
|
|
36
|
+
"run",
|
|
37
|
+
"-i",
|
|
38
|
+
"--rm",
|
|
39
|
+
"-e",
|
|
40
|
+
"GITHUB_PERSONAL_ACCESS_TOKEN",
|
|
41
|
+
"-e",
|
|
42
|
+
"GITHUB_READ_ONLY=1",
|
|
43
|
+
"ghcr.io/github/github-mcp-server@sha256:7b1384cdd6d025c09256af2fb6cb79bc5e87aedc957c8826b5e50d8cb82f0be3",
|
|
44
|
+
],
|
|
45
|
+
environment: ["GITHUB_PERSONAL_ACCESS_TOKEN"],
|
|
46
|
+
fixedEnvironment: { GITHUB_READ_ONLY: "1" },
|
|
47
|
+
permissions: ["read GitHub repositories, issues, pull requests, and users"],
|
|
48
|
+
connection: "stdio",
|
|
49
|
+
reviewedCommit: "dc3ee11f4c1c9541a2e918acfc7e20c8332c9a36",
|
|
50
|
+
reviewedAt: "2026-07-16T00:00:00Z",
|
|
51
|
+
artifact: "oci:ghcr.io/github/github-mcp-server@sha256:7b1384cdd6d025c09256af2fb6cb79bc5e87aedc957c8826b5e50d8cb82f0be3",
|
|
52
|
+
},
|
|
53
|
+
];
|
|
54
|
+
export function findMcpRecipe(id) {
|
|
55
|
+
const recipe = REVIEWED_MCP_RECIPES.find((item) => item.id === id);
|
|
56
|
+
if (!recipe) {
|
|
57
|
+
throw new Error(`Unknown MCP recipe '${id}'. Available: ${REVIEWED_MCP_RECIPES.map((item) => item.id).join(", ")}`);
|
|
58
|
+
}
|
|
59
|
+
return recipe;
|
|
60
|
+
}
|
|
61
|
+
function recipeServer(recipe, sourcePath) {
|
|
62
|
+
return {
|
|
63
|
+
name: recipe.serverName,
|
|
64
|
+
command: recipe.command,
|
|
65
|
+
args: recipe.args,
|
|
66
|
+
// References retain variable names without storing or printing their values.
|
|
67
|
+
env: Object.fromEntries([
|
|
68
|
+
...recipe.environment.map((name) => [name, `\${${name}}`]),
|
|
69
|
+
...Object.entries(recipe.fixedEnvironment),
|
|
70
|
+
]),
|
|
71
|
+
sourcePath,
|
|
72
|
+
warnings: [],
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
export async function planMcpRecipe(recipeId, configPath) {
|
|
76
|
+
const recipe = findMcpRecipe(recipeId);
|
|
77
|
+
const config = await planMcpConfig(configPath, recipeServer(recipe, recipe.source));
|
|
78
|
+
return {
|
|
79
|
+
recipe,
|
|
80
|
+
config,
|
|
81
|
+
authorization: recipe.environment.length
|
|
82
|
+
? [
|
|
83
|
+
`Set these environment variables outside Loadout before starting the host: ${recipe.environment.join(", ")}.`,
|
|
84
|
+
]
|
|
85
|
+
: ["No credential reference is required by this recipe."],
|
|
86
|
+
safety: [
|
|
87
|
+
"Only the displayed server entry will be added or replaced; unrelated JSON keys are preserved.",
|
|
88
|
+
"Loadout does not start the server, fetch packages, or authorize the service during recipe setup.",
|
|
89
|
+
],
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
const MCP_PROTOCOL_VERSION = "2025-06-18";
|
|
93
|
+
const DEFAULT_CONNECTION_TIMEOUT_MS = 8_000;
|
|
94
|
+
const DEFAULT_CONNECTION_OUTPUT_BYTES = 256 * 1024;
|
|
95
|
+
const INITIALIZE_ID = "loadout-initialize";
|
|
96
|
+
function validateConnectionBounds(timeoutMs, maxOutputBytes) {
|
|
97
|
+
if (!Number.isInteger(timeoutMs) || timeoutMs < 250 || timeoutMs > 30_000)
|
|
98
|
+
throw new Error("MCP verification timeout must be 250-30000ms");
|
|
99
|
+
if (!Number.isInteger(maxOutputBytes) ||
|
|
100
|
+
maxOutputBytes < 4_096 ||
|
|
101
|
+
maxOutputBytes > 1024 * 1024)
|
|
102
|
+
throw new Error("MCP verification output limit must be 4096-1048576 bytes");
|
|
103
|
+
}
|
|
104
|
+
function assertExactReviewedArtifact(recipe) {
|
|
105
|
+
if (!/^[a-f0-9]{40}$/.test(recipe.reviewedCommit) ||
|
|
106
|
+
!recipe.source.includes(recipe.reviewedCommit) ||
|
|
107
|
+
/@latest|:latest/.test(JSON.stringify(recipe)))
|
|
108
|
+
throw new Error("MCP recipe does not have an immutable reviewed source");
|
|
109
|
+
if (recipe.artifact.startsWith("oci:")) {
|
|
110
|
+
const pinnedImage = recipe.artifact.slice("oci:".length);
|
|
111
|
+
if (!/@sha256:[a-f0-9]{64}$/.test(pinnedImage) ||
|
|
112
|
+
!recipe.args.includes(pinnedImage))
|
|
113
|
+
throw new Error("MCP recipe executable does not match its reviewed OCI digest");
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
const npmArtifact = /^npm:(.+@[^#]+)#sha512-[A-Za-z0-9+/=]+$/.exec(recipe.artifact);
|
|
117
|
+
if (!npmArtifact || !recipe.args.includes(npmArtifact[1]))
|
|
118
|
+
throw new Error("MCP recipe executable does not match its reviewed npm artifact");
|
|
119
|
+
}
|
|
120
|
+
function runtimeEnvironment() {
|
|
121
|
+
const allowed = [
|
|
122
|
+
"PATH",
|
|
123
|
+
"HOME",
|
|
124
|
+
"USERPROFILE",
|
|
125
|
+
"SystemRoot",
|
|
126
|
+
"WINDIR",
|
|
127
|
+
"TMPDIR",
|
|
128
|
+
"TMP",
|
|
129
|
+
"TEMP",
|
|
130
|
+
];
|
|
131
|
+
return Object.fromEntries(allowed.flatMap((name) => process.env[name] === undefined ? [] : [[name, process.env[name]]]));
|
|
132
|
+
}
|
|
133
|
+
const defaultMcpSubprocessFactory = (command, args, options) => spawn(command, [...args], {
|
|
134
|
+
env: options.env,
|
|
135
|
+
shell: false,
|
|
136
|
+
windowsHide: true,
|
|
137
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
138
|
+
});
|
|
139
|
+
function safeServerText(value) {
|
|
140
|
+
return typeof value === "string" &&
|
|
141
|
+
value.length >= 1 &&
|
|
142
|
+
value.length <= 128 &&
|
|
143
|
+
[...value].every((character) => {
|
|
144
|
+
const code = character.charCodeAt(0);
|
|
145
|
+
return code >= 32 && code !== 127;
|
|
146
|
+
})
|
|
147
|
+
? value
|
|
148
|
+
: undefined;
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Explicitly launch one canonical reviewed server and perform only the MCP
|
|
152
|
+
* initialize handshake. Secrets are resolved just-in-time, injected only into
|
|
153
|
+
* the child environment, and never included in results or failure messages.
|
|
154
|
+
*/
|
|
155
|
+
export async function verifyMcpRecipeConnection(recipeId, options) {
|
|
156
|
+
if (!options.approveRisk)
|
|
157
|
+
throw new Error("Real MCP verification requires explicit approveRisk");
|
|
158
|
+
if (options.signal?.aborted)
|
|
159
|
+
throw new Error("MCP verification was aborted");
|
|
160
|
+
const recipe = findMcpRecipe(recipeId);
|
|
161
|
+
assertExactReviewedArtifact(recipe);
|
|
162
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_CONNECTION_TIMEOUT_MS;
|
|
163
|
+
const maxOutputBytes = options.maxOutputBytes ?? DEFAULT_CONNECTION_OUTPUT_BYTES;
|
|
164
|
+
validateConnectionBounds(timeoutMs, maxOutputBytes);
|
|
165
|
+
const resolveCredential = options.resolveCredential ?? createCredentialResolver();
|
|
166
|
+
const credentialEnvironment = {};
|
|
167
|
+
try {
|
|
168
|
+
for (const name of recipe.environment) {
|
|
169
|
+
const reference = options.credentialReferences?.[name] ?? {
|
|
170
|
+
kind: "environment",
|
|
171
|
+
name,
|
|
172
|
+
};
|
|
173
|
+
const value = await resolveCredential(reference);
|
|
174
|
+
if (!value)
|
|
175
|
+
throw new Error("missing");
|
|
176
|
+
credentialEnvironment[name] = value;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
throw new Error("An MCP credential reference could not be resolved");
|
|
181
|
+
}
|
|
182
|
+
if (options.signal?.aborted)
|
|
183
|
+
throw new Error("MCP verification was aborted");
|
|
184
|
+
const env = {
|
|
185
|
+
...runtimeEnvironment(),
|
|
186
|
+
...recipe.fixedEnvironment,
|
|
187
|
+
...credentialEnvironment,
|
|
188
|
+
};
|
|
189
|
+
let child;
|
|
190
|
+
try {
|
|
191
|
+
child = (options.subprocessFactory ?? defaultMcpSubprocessFactory)(recipe.command, recipe.args, { env });
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
throw new Error("The reviewed MCP server could not be started");
|
|
195
|
+
}
|
|
196
|
+
return new Promise((resolvePromise, reject) => {
|
|
197
|
+
let settled = false;
|
|
198
|
+
let stdout = "";
|
|
199
|
+
let outputBytes = 0;
|
|
200
|
+
const onAbort = () => rejectSafely("MCP verification was aborted");
|
|
201
|
+
const onError = () => rejectSafely("The reviewed MCP server failed during verification");
|
|
202
|
+
const onClose = () => rejectSafely("The reviewed MCP server exited before initialization");
|
|
203
|
+
const onStdinError = () => rejectSafely("The MCP initialize request could not be sent");
|
|
204
|
+
const cleanup = () => {
|
|
205
|
+
clearTimeout(timer);
|
|
206
|
+
options.signal?.removeEventListener("abort", onAbort);
|
|
207
|
+
child.stdout.removeListener("data", onStdout);
|
|
208
|
+
child.stderr.removeListener("data", onStderr);
|
|
209
|
+
child.stdin.removeListener("error", onStdinError);
|
|
210
|
+
child.removeListener("error", onError);
|
|
211
|
+
child.removeListener("close", onClose);
|
|
212
|
+
try {
|
|
213
|
+
child.stdin.end();
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
// Already closed.
|
|
217
|
+
}
|
|
218
|
+
try {
|
|
219
|
+
child.kill("SIGTERM");
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
// Never forward a backend exception that may contain environment data.
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
function rejectSafely(message) {
|
|
226
|
+
if (settled)
|
|
227
|
+
return;
|
|
228
|
+
settled = true;
|
|
229
|
+
cleanup();
|
|
230
|
+
reject(new Error(message));
|
|
231
|
+
}
|
|
232
|
+
const finish = (result) => {
|
|
233
|
+
if (settled)
|
|
234
|
+
return;
|
|
235
|
+
settled = true;
|
|
236
|
+
try {
|
|
237
|
+
child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" })}\n`);
|
|
238
|
+
}
|
|
239
|
+
catch {
|
|
240
|
+
// The initialize response is authoritative; cleanup remains mandatory.
|
|
241
|
+
}
|
|
242
|
+
cleanup();
|
|
243
|
+
resolvePromise(result);
|
|
244
|
+
};
|
|
245
|
+
const onStderr = (chunk) => {
|
|
246
|
+
outputBytes += Buffer.byteLength(chunk);
|
|
247
|
+
if (outputBytes > maxOutputBytes)
|
|
248
|
+
rejectSafely("MCP verification exceeded its output limit");
|
|
249
|
+
};
|
|
250
|
+
const onStdout = (chunk) => {
|
|
251
|
+
outputBytes += Buffer.byteLength(chunk);
|
|
252
|
+
if (outputBytes > maxOutputBytes) {
|
|
253
|
+
rejectSafely("MCP verification exceeded its output limit");
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
stdout += chunk.toString();
|
|
257
|
+
for (;;) {
|
|
258
|
+
const newline = stdout.indexOf("\n");
|
|
259
|
+
if (newline === -1)
|
|
260
|
+
return;
|
|
261
|
+
const line = stdout.slice(0, newline).replace(/\r$/, "");
|
|
262
|
+
stdout = stdout.slice(newline + 1);
|
|
263
|
+
if (!line)
|
|
264
|
+
continue;
|
|
265
|
+
let message;
|
|
266
|
+
try {
|
|
267
|
+
message = JSON.parse(line);
|
|
268
|
+
}
|
|
269
|
+
catch {
|
|
270
|
+
rejectSafely("The MCP server returned an invalid JSON-RPC response");
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
if (!message || typeof message !== "object")
|
|
274
|
+
continue;
|
|
275
|
+
const record = message;
|
|
276
|
+
if (record.id !== INITIALIZE_ID)
|
|
277
|
+
continue;
|
|
278
|
+
if (record.jsonrpc !== "2.0" || record.error || !record.result) {
|
|
279
|
+
rejectSafely("The MCP server rejected initialization");
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
const result = record.result;
|
|
283
|
+
const protocolVersion = safeServerText(result.protocolVersion);
|
|
284
|
+
if (!protocolVersion) {
|
|
285
|
+
rejectSafely("The MCP initialize result omitted its protocol version");
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
const server = result.serverInfo && typeof result.serverInfo === "object"
|
|
289
|
+
? result.serverInfo
|
|
290
|
+
: undefined;
|
|
291
|
+
const name = safeServerText(server?.name);
|
|
292
|
+
const version = safeServerText(server?.version);
|
|
293
|
+
finish({
|
|
294
|
+
recipeId: recipe.id,
|
|
295
|
+
connected: true,
|
|
296
|
+
protocolVersion,
|
|
297
|
+
...(name
|
|
298
|
+
? { serverInfo: { name, ...(version ? { version } : {}) } }
|
|
299
|
+
: {}),
|
|
300
|
+
checks: [
|
|
301
|
+
"launched exact reviewed artifact",
|
|
302
|
+
"received valid JSON-RPC initialize result",
|
|
303
|
+
"stopped verification subprocess",
|
|
304
|
+
],
|
|
305
|
+
});
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
};
|
|
309
|
+
const timer = setTimeout(() => rejectSafely("MCP verification timed out"), timeoutMs);
|
|
310
|
+
timer.unref();
|
|
311
|
+
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
312
|
+
child.stdout.on("data", onStdout);
|
|
313
|
+
child.stderr.on("data", onStderr);
|
|
314
|
+
child.stdin.on("error", onStdinError);
|
|
315
|
+
child.once("error", onError);
|
|
316
|
+
child.once("close", onClose);
|
|
317
|
+
if (options.signal?.aborted) {
|
|
318
|
+
rejectSafely("MCP verification was aborted");
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
try {
|
|
322
|
+
child.stdin.write(`${JSON.stringify({
|
|
323
|
+
jsonrpc: "2.0",
|
|
324
|
+
id: INITIALIZE_ID,
|
|
325
|
+
method: "initialize",
|
|
326
|
+
params: {
|
|
327
|
+
protocolVersion: MCP_PROTOCOL_VERSION,
|
|
328
|
+
capabilities: {},
|
|
329
|
+
clientInfo: { name: "loadout", version: "0.1.0" },
|
|
330
|
+
},
|
|
331
|
+
})}\n`);
|
|
332
|
+
}
|
|
333
|
+
catch {
|
|
334
|
+
rejectSafely("The MCP initialize request could not be sent");
|
|
335
|
+
}
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
/** Verify the configured transport and references without launching a server. */
|
|
339
|
+
export async function verifyMcpRecipe(recipeId, configPath) {
|
|
340
|
+
const recipe = findMcpRecipe(recipeId);
|
|
341
|
+
const path = resolve(configPath);
|
|
342
|
+
const checks = [];
|
|
343
|
+
const warnings = [];
|
|
344
|
+
let value;
|
|
345
|
+
try {
|
|
346
|
+
value = JSON.parse(await readFile(path, "utf8"));
|
|
347
|
+
}
|
|
348
|
+
catch (error) {
|
|
349
|
+
return {
|
|
350
|
+
recipeId,
|
|
351
|
+
configPath: path,
|
|
352
|
+
configured: false,
|
|
353
|
+
checks,
|
|
354
|
+
warnings: [
|
|
355
|
+
`Cannot read JSON MCP config: ${error instanceof Error ? error.message : String(error)}`,
|
|
356
|
+
],
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
const server = value && typeof value === "object"
|
|
360
|
+
? value.mcpServers?.[recipe.serverName]
|
|
361
|
+
: undefined;
|
|
362
|
+
if (!server || typeof server !== "object" || Array.isArray(server)) {
|
|
363
|
+
warnings.push(`Server '${recipe.serverName}' is not configured.`);
|
|
364
|
+
}
|
|
365
|
+
else {
|
|
366
|
+
const record = server;
|
|
367
|
+
if (record.command === recipe.command)
|
|
368
|
+
checks.push("command matches recipe");
|
|
369
|
+
else
|
|
370
|
+
warnings.push("configured command does not match recipe");
|
|
371
|
+
if (Array.isArray(record.args) &&
|
|
372
|
+
JSON.stringify(record.args) === JSON.stringify(recipe.args))
|
|
373
|
+
checks.push("arguments match recipe");
|
|
374
|
+
else
|
|
375
|
+
warnings.push("configured arguments do not match recipe");
|
|
376
|
+
const env = record.env;
|
|
377
|
+
for (const name of recipe.environment) {
|
|
378
|
+
if (typeof env?.[name] === "string")
|
|
379
|
+
checks.push(`environment reference present: ${name}`);
|
|
380
|
+
else
|
|
381
|
+
warnings.push(`missing environment reference: ${name}`);
|
|
382
|
+
}
|
|
383
|
+
for (const [name, expected] of Object.entries(recipe.fixedEnvironment)) {
|
|
384
|
+
if (env?.[name] === expected)
|
|
385
|
+
checks.push(`fixed environment matches recipe: ${name}`);
|
|
386
|
+
else
|
|
387
|
+
warnings.push(`fixed environment does not match recipe: ${name}`);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
return {
|
|
391
|
+
recipeId,
|
|
392
|
+
configPath: path,
|
|
393
|
+
configured: warnings.length === 0,
|
|
394
|
+
checks,
|
|
395
|
+
warnings: [
|
|
396
|
+
...warnings,
|
|
397
|
+
"Configuration verification does not launch the MCP server. Start it from the target host after completing authorization.",
|
|
398
|
+
],
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
export function formatMcpRecipePlan(plan) {
|
|
402
|
+
return [
|
|
403
|
+
`${plan.recipe.displayName} (${plan.recipe.id})`,
|
|
404
|
+
`Source: ${plan.recipe.source}`,
|
|
405
|
+
`Reviewed artifact: ${plan.recipe.artifact}`,
|
|
406
|
+
`Reviewed at: ${plan.recipe.reviewedAt}`,
|
|
407
|
+
`Connection: ${plan.recipe.command} ${plan.recipe.args.join(" ")}`,
|
|
408
|
+
`Permissions: ${plan.recipe.permissions.join("; ")}`,
|
|
409
|
+
`Environment names: ${plan.recipe.environment.length ? plan.recipe.environment.join(", ") : "none"}`,
|
|
410
|
+
`Fixed non-secret environment: ${Object.keys(plan.recipe.fixedEnvironment).length
|
|
411
|
+
? Object.entries(plan.recipe.fixedEnvironment)
|
|
412
|
+
.map(([name, value]) => `${name}=${value}`)
|
|
413
|
+
.join(", ")
|
|
414
|
+
: "none"}`,
|
|
415
|
+
`Target config: ${plan.config.path}`,
|
|
416
|
+
...plan.authorization,
|
|
417
|
+
...plan.safety,
|
|
418
|
+
].join("\n");
|
|
419
|
+
}
|