@ship-safe/mcp 0.2.0 → 0.3.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/README.md +8 -5
- package/dist/index.js +222 -15
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -11,6 +11,9 @@ It runs ShipSafe's fast local pattern scan for free (secrets, injection, broken
|
|
|
11
11
|
| `shipsafe_scan` | Scan a directory; returns plain‑English findings with the exact fix for each, plus structured output (see below). | Free (local). AI analysis needs Growth/Shield. |
|
|
12
12
|
| `shipsafe_fix_prompt` | Scan, then return one paste‑ready prompt that fixes everything, tailored to the detected AI builder. | Growth / Shield |
|
|
13
13
|
| `shipsafe_status` | Show login state, plan, and remaining AI scan quota. | — |
|
|
14
|
+
| `shipsafe_login` | Log in from the editor: call once for a browser URL + code, then again with the returned `device_code` to finish. | — |
|
|
15
|
+
|
|
16
|
+
Secrets that show up in findings are masked (`[REDACTED]`) before anything is echoed back to the agent, so a model can't quote a real credential into its transcript.
|
|
14
17
|
|
|
15
18
|
### `shipsafe_scan` inputs
|
|
16
19
|
|
|
@@ -21,6 +24,7 @@ It runs ShipSafe's fast local pattern scan for free (secrets, injection, broken
|
|
|
21
24
|
| `ai` | boolean | Run AI deep analysis (needs Growth/Shield). On by default for paid users. |
|
|
22
25
|
| `paths` | string[] | Scan only these files/dirs (inside the project) — e.g. the files you just edited. Fast inner loop. |
|
|
23
26
|
| `changedOnly` | boolean | Scan only files changed vs git HEAD + new untracked files. Falls back to a full scan if not a git repo. |
|
|
27
|
+
| `upload` | boolean | Save the scan to your ShipSafe dashboard (needs login) and return a shareable `dashboardUrl`. Off by default. |
|
|
24
28
|
|
|
25
29
|
### Structured output
|
|
26
30
|
|
|
@@ -35,13 +39,12 @@ Failures (no source files, generation errors, not logged in) are returned with `
|
|
|
35
39
|
|
|
36
40
|
## Login (for AI analysis)
|
|
37
41
|
|
|
38
|
-
The
|
|
42
|
+
The free local + dependency scan works with no login. To unlock AI deep analysis and fix prompts, log in either way:
|
|
39
43
|
|
|
40
|
-
|
|
41
|
-
npx @ship-safe/cli login
|
|
42
|
-
```
|
|
44
|
+
- **From the editor:** ask your agent to log in — it calls the `shipsafe_login` tool, which gives you a browser URL + code and finishes once you authorize.
|
|
45
|
+
- **From a terminal:** `npx @ship-safe/cli login`.
|
|
43
46
|
|
|
44
|
-
|
|
47
|
+
Both write `~/.shipsafe/token.json`, which this MCP server reads, so a single login covers the CLI and the MCP.
|
|
45
48
|
|
|
46
49
|
## Setup
|
|
47
50
|
|
package/dist/index.js
CHANGED
|
@@ -4003,10 +4003,11 @@ function collectDependencyFiles(rootDir) {
|
|
|
4003
4003
|
}
|
|
4004
4004
|
|
|
4005
4005
|
// src/lib/config.ts
|
|
4006
|
-
import { readFileSync as readFileSync2, existsSync as existsSync2 } from "fs";
|
|
4006
|
+
import { readFileSync as readFileSync2, writeFileSync, mkdirSync, existsSync as existsSync2 } from "fs";
|
|
4007
4007
|
import { homedir } from "os";
|
|
4008
4008
|
import { join } from "path";
|
|
4009
|
-
var
|
|
4009
|
+
var CONFIG_DIR = join(homedir(), ".shipsafe");
|
|
4010
|
+
var TOKEN_FILE = join(CONFIG_DIR, "token.json");
|
|
4010
4011
|
function getStoredToken() {
|
|
4011
4012
|
try {
|
|
4012
4013
|
if (!existsSync2(TOKEN_FILE)) return null;
|
|
@@ -4017,6 +4018,10 @@ function getStoredToken() {
|
|
|
4017
4018
|
return null;
|
|
4018
4019
|
}
|
|
4019
4020
|
}
|
|
4021
|
+
function storeToken(data) {
|
|
4022
|
+
if (!existsSync2(CONFIG_DIR)) mkdirSync(CONFIG_DIR, { recursive: true, mode: 448 });
|
|
4023
|
+
writeFileSync(TOKEN_FILE, JSON.stringify(data, null, 2), { mode: 384 });
|
|
4024
|
+
}
|
|
4020
4025
|
function apiUrl() {
|
|
4021
4026
|
return (process.env.SHIPSAFE_API_URL || "https://ship-safe.co").replace(/\/+$/, "");
|
|
4022
4027
|
}
|
|
@@ -4096,6 +4101,48 @@ async function getProfile(token) {
|
|
|
4096
4101
|
return null;
|
|
4097
4102
|
}
|
|
4098
4103
|
}
|
|
4104
|
+
async function uploadScan(token, payload) {
|
|
4105
|
+
try {
|
|
4106
|
+
const res = await fetch(`${apiUrl()}/api/cli/scan`, {
|
|
4107
|
+
method: "POST",
|
|
4108
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
|
4109
|
+
body: JSON.stringify(payload),
|
|
4110
|
+
signal: AbortSignal.timeout(6e4)
|
|
4111
|
+
});
|
|
4112
|
+
if (!res.ok) return { error: await errorMessage(res) };
|
|
4113
|
+
const data = await res.json();
|
|
4114
|
+
return { dashboardUrl: data.dashboardUrl };
|
|
4115
|
+
} catch (e) {
|
|
4116
|
+
return { error: e instanceof Error ? e.message : "upload failed" };
|
|
4117
|
+
}
|
|
4118
|
+
}
|
|
4119
|
+
async function requestDeviceCode() {
|
|
4120
|
+
try {
|
|
4121
|
+
const res = await fetch(`${apiUrl()}/api/cli/device-code`, {
|
|
4122
|
+
method: "POST",
|
|
4123
|
+
headers: { "Content-Type": "application/json" },
|
|
4124
|
+
signal: AbortSignal.timeout(3e4)
|
|
4125
|
+
});
|
|
4126
|
+
if (!res.ok) return { error: await errorMessage(res) };
|
|
4127
|
+
return await res.json();
|
|
4128
|
+
} catch (e) {
|
|
4129
|
+
return { error: e instanceof Error ? e.message : "could not reach ShipSafe" };
|
|
4130
|
+
}
|
|
4131
|
+
}
|
|
4132
|
+
async function pollDeviceToken(deviceCode) {
|
|
4133
|
+
try {
|
|
4134
|
+
const res = await fetch(`${apiUrl()}/api/cli/device-token`, {
|
|
4135
|
+
method: "POST",
|
|
4136
|
+
headers: { "Content-Type": "application/json" },
|
|
4137
|
+
body: JSON.stringify({ deviceCode }),
|
|
4138
|
+
signal: AbortSignal.timeout(3e4)
|
|
4139
|
+
});
|
|
4140
|
+
if (!res.ok) return { error: await errorMessage(res) };
|
|
4141
|
+
return await res.json();
|
|
4142
|
+
} catch (e) {
|
|
4143
|
+
return { error: e instanceof Error ? e.message : "poll failed" };
|
|
4144
|
+
}
|
|
4145
|
+
}
|
|
4099
4146
|
|
|
4100
4147
|
// src/lib/format.ts
|
|
4101
4148
|
var SEVERITY_RANK = { critical: 0, high: 1, medium: 2, low: 3, info: 4 };
|
|
@@ -4183,6 +4230,75 @@ function toStructuredFindings(findings) {
|
|
|
4183
4230
|
fixSuggestion: f.fixSuggestion
|
|
4184
4231
|
}));
|
|
4185
4232
|
}
|
|
4233
|
+
var SECRET_PATTERNS = [
|
|
4234
|
+
/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]{0,8192}?-----END [A-Z ]*PRIVATE KEY-----/g,
|
|
4235
|
+
/\beyJ[A-Za-z0-9_-]{8,1024}\.[A-Za-z0-9_-]{8,2048}\.[A-Za-z0-9_-]{8,2048}\b/g,
|
|
4236
|
+
// JWT
|
|
4237
|
+
/\b(?:sk|pk|rk)[-_](?:live|test|proj)?[-_]?[A-Za-z0-9]{16,256}\b/g,
|
|
4238
|
+
// Stripe / OpenAI-style
|
|
4239
|
+
/\bgithub_pat_[A-Za-z0-9_]{20,256}\b/g,
|
|
4240
|
+
// GitHub fine-grained PAT
|
|
4241
|
+
/\bgh[posru]_[A-Za-z0-9]{20,256}\b/g,
|
|
4242
|
+
// GitHub classic tokens
|
|
4243
|
+
/\bglpat-[A-Za-z0-9_-]{16,256}\b/g,
|
|
4244
|
+
// GitLab PAT
|
|
4245
|
+
/\bxox[baprs]-[A-Za-z0-9-]{10,256}\b/g,
|
|
4246
|
+
// Slack
|
|
4247
|
+
/\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g,
|
|
4248
|
+
// AWS access key id
|
|
4249
|
+
/\bSG\.[A-Za-z0-9_-]{16,128}\.[A-Za-z0-9_-]{16,128}\b/g,
|
|
4250
|
+
// SendGrid
|
|
4251
|
+
/\b(?:AC|SK)[0-9a-f]{32}\b/g,
|
|
4252
|
+
// Twilio SIDs
|
|
4253
|
+
/\bAIza[0-9A-Za-z_-]{35}\b/g,
|
|
4254
|
+
// Google API key
|
|
4255
|
+
/\bya29\.[0-9A-Za-z_-]{20,512}/g,
|
|
4256
|
+
// Google OAuth
|
|
4257
|
+
/\bBearer\s+[A-Za-z0-9._~+/=-]{16,512}/gi,
|
|
4258
|
+
// bearer / authorization tokens
|
|
4259
|
+
// AWS secret access key: 40-char base64 with mixed case + a digit (distinguishes
|
|
4260
|
+
// it from a hex SHA, which is lowercase-only and would over-redact commit hashes).
|
|
4261
|
+
/\b(?=[A-Za-z0-9/+]{40}(?![A-Za-z0-9/+]))(?=[A-Za-z0-9/+]*[A-Z])(?=[A-Za-z0-9/+]*[a-z])(?=[A-Za-z0-9/+]*[0-9])[A-Za-z0-9/+]{40}\b/g
|
|
4262
|
+
];
|
|
4263
|
+
var URL_CREDENTIALS = /(:\/\/[^:@/\s]{1,128}:)([^@/\s]{3,256})(@)/g;
|
|
4264
|
+
var SECRET_ASSIGNMENT = /(\b[A-Za-z0-9_.-]{0,40}(?:secret|token|password|passwd|api[_-]?key|apikey|access[_-]?key|private[_-]?key|client[_-]?secret|auth[_-]?token)[A-Za-z0-9_.-]{0,40}\s*[:=]\s*["'`]?)([^\s"'`]{5,256})/gi;
|
|
4265
|
+
var MAX_REDACT_LEN = 2e4;
|
|
4266
|
+
function redactSecrets(text2) {
|
|
4267
|
+
if (!text2) return text2;
|
|
4268
|
+
let out = text2.length > MAX_REDACT_LEN ? `${text2.slice(0, MAX_REDACT_LEN)}\u2026 [truncated]` : text2;
|
|
4269
|
+
for (const re of SECRET_PATTERNS) out = out.replace(re, "[REDACTED]");
|
|
4270
|
+
out = out.replace(URL_CREDENTIALS, (_m, prefix, _pw, at) => `${prefix}[REDACTED]${at}`);
|
|
4271
|
+
out = out.replace(SECRET_ASSIGNMENT, (_m, prefix) => `${prefix}[REDACTED]`);
|
|
4272
|
+
return out;
|
|
4273
|
+
}
|
|
4274
|
+
function redactFinding(f) {
|
|
4275
|
+
return {
|
|
4276
|
+
...f,
|
|
4277
|
+
title: redactSecrets(f.title),
|
|
4278
|
+
plainEnglish: redactSecrets(f.plainEnglish),
|
|
4279
|
+
description: redactSecrets(f.description),
|
|
4280
|
+
fixDescription: redactSecrets(f.fixDescription),
|
|
4281
|
+
fixSuggestion: redactSecrets(f.fixSuggestion),
|
|
4282
|
+
snippet: redactSecrets(f.snippet)
|
|
4283
|
+
};
|
|
4284
|
+
}
|
|
4285
|
+
function toUploadFindings(findings) {
|
|
4286
|
+
return findings.map((f) => ({
|
|
4287
|
+
title: f.title,
|
|
4288
|
+
description: f.description,
|
|
4289
|
+
plainEnglish: f.plainEnglish || f.description,
|
|
4290
|
+
severity: f.severity,
|
|
4291
|
+
confidence: f.confidence,
|
|
4292
|
+
source: f.source,
|
|
4293
|
+
filePath: f.file,
|
|
4294
|
+
lineNumber: f.line,
|
|
4295
|
+
codeSnippet: f.snippet || void 0,
|
|
4296
|
+
fixDescription: f.fixDescription || void 0,
|
|
4297
|
+
fixSuggestion: f.fixSuggestion || void 0,
|
|
4298
|
+
cwe: f.cwe || void 0,
|
|
4299
|
+
owasp: f.owasp || void 0
|
|
4300
|
+
}));
|
|
4301
|
+
}
|
|
4186
4302
|
function toFixPromptPayload(findings) {
|
|
4187
4303
|
return findings.map((f) => ({
|
|
4188
4304
|
title: f.title,
|
|
@@ -4232,7 +4348,7 @@ function formatFindings(findings, opts) {
|
|
|
4232
4348
|
}
|
|
4233
4349
|
|
|
4234
4350
|
// src/lib/history.ts
|
|
4235
|
-
import { mkdirSync, readFileSync as readFileSync3, writeFileSync } from "fs";
|
|
4351
|
+
import { mkdirSync as mkdirSync2, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
|
|
4236
4352
|
import { homedir as homedir2 } from "os";
|
|
4237
4353
|
import { join as join2, resolve } from "path";
|
|
4238
4354
|
import { createHash as createHash2 } from "crypto";
|
|
@@ -4252,8 +4368,8 @@ function loadSnapshot(dir) {
|
|
|
4252
4368
|
}
|
|
4253
4369
|
function saveSnapshot(dir, keys, now) {
|
|
4254
4370
|
try {
|
|
4255
|
-
|
|
4256
|
-
|
|
4371
|
+
mkdirSync2(HISTORY_DIR, { recursive: true });
|
|
4372
|
+
writeFileSync2(snapshotPath(dir), JSON.stringify({ keys, at: now }));
|
|
4257
4373
|
} catch {
|
|
4258
4374
|
}
|
|
4259
4375
|
}
|
|
@@ -4269,7 +4385,11 @@ function diffSnapshots(previous, current) {
|
|
|
4269
4385
|
}
|
|
4270
4386
|
|
|
4271
4387
|
// src/index.ts
|
|
4272
|
-
var VERSION = "0.
|
|
4388
|
+
var VERSION = "0.3.0";
|
|
4389
|
+
var THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1e3;
|
|
4390
|
+
function sleep(ms) {
|
|
4391
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
4392
|
+
}
|
|
4273
4393
|
function text(t) {
|
|
4274
4394
|
return { content: [{ type: "text", text: t }] };
|
|
4275
4395
|
}
|
|
@@ -4356,7 +4476,8 @@ server.registerTool(
|
|
|
4356
4476
|
severity: z2.enum(["critical", "high", "medium", "low"]).optional().describe("Only return findings at or above this severity."),
|
|
4357
4477
|
ai: z2.boolean().optional().describe("Run AI deep analysis (needs a Growth/Shield login via `shipsafe login`). Defaults on for paid users, off otherwise."),
|
|
4358
4478
|
paths: z2.array(z2.string()).optional().describe("Scan only these files/dirs (relative to path, inside the project) \u2014 e.g. the files you just edited. Faster than a full scan."),
|
|
4359
|
-
changedOnly: z2.boolean().optional().describe("Scan only files changed vs git HEAD plus new untracked files. Falls back to a full scan if the directory isn't a git repo.")
|
|
4479
|
+
changedOnly: z2.boolean().optional().describe("Scan only files changed vs git HEAD plus new untracked files. Falls back to a full scan if the directory isn't a git repo."),
|
|
4480
|
+
upload: z2.boolean().optional().describe("Save this scan to the user's ShipSafe dashboard (needs login) and return a shareable dashboardUrl. Off by default.")
|
|
4360
4481
|
},
|
|
4361
4482
|
outputSchema: {
|
|
4362
4483
|
clean: z2.boolean().describe("True when no findings were returned at the requested severity."),
|
|
@@ -4397,10 +4518,11 @@ server.registerTool(
|
|
|
4397
4518
|
fixSuggestion: z2.string().optional()
|
|
4398
4519
|
})
|
|
4399
4520
|
),
|
|
4400
|
-
notes: z2.array(z2.string())
|
|
4521
|
+
notes: z2.array(z2.string()),
|
|
4522
|
+
dashboardUrl: z2.string().optional().describe("Set when upload:true succeeded \u2014 a shareable link to this scan in the dashboard.")
|
|
4401
4523
|
}
|
|
4402
4524
|
},
|
|
4403
|
-
async ({ path: path3, severity, ai, paths, changedOnly }) => {
|
|
4525
|
+
async ({ path: path3, severity, ai, paths, changedOnly, upload }) => {
|
|
4404
4526
|
const dir = path3 ?? process.cwd();
|
|
4405
4527
|
const r = await runScan(dir, { ai, paths, changedOnly });
|
|
4406
4528
|
if (!r.ok) return errorResult(r.error);
|
|
@@ -4412,8 +4534,35 @@ server.registerTool(
|
|
|
4412
4534
|
saveSnapshot(dir, r.localKeys, Date.now());
|
|
4413
4535
|
}
|
|
4414
4536
|
const notes = [r.scope.gitNote, r.aiNote, r.depWarning].filter((n) => Boolean(n));
|
|
4537
|
+
let dashboardUrl;
|
|
4538
|
+
let uploadNote;
|
|
4539
|
+
if (upload) {
|
|
4540
|
+
const token = getStoredToken();
|
|
4541
|
+
if (!token) {
|
|
4542
|
+
uploadNote = "Couldn't save to the dashboard: not logged in. Run shipsafe_login first.";
|
|
4543
|
+
} else {
|
|
4544
|
+
const res = await uploadScan(token.token, {
|
|
4545
|
+
findings: toUploadFindings(r.findings),
|
|
4546
|
+
summary: {
|
|
4547
|
+
total: r.findings.length,
|
|
4548
|
+
filesScanned: r.scope.sourceFiles,
|
|
4549
|
+
linesScanned: 0,
|
|
4550
|
+
bySeverity: buildScanVerdict(r.findings).counts
|
|
4551
|
+
},
|
|
4552
|
+
repoPath: dir
|
|
4553
|
+
});
|
|
4554
|
+
if ("error" in res) {
|
|
4555
|
+
uploadNote = `Dashboard upload failed: ${res.error}.`;
|
|
4556
|
+
} else {
|
|
4557
|
+
dashboardUrl = res.dashboardUrl;
|
|
4558
|
+
uploadNote = res.dashboardUrl ? `Saved to your dashboard: ${res.dashboardUrl}` : "Saved to your dashboard.";
|
|
4559
|
+
}
|
|
4560
|
+
}
|
|
4561
|
+
if (uploadNote) notes.push(uploadNote);
|
|
4562
|
+
}
|
|
4563
|
+
const display = filtered.map(redactFinding);
|
|
4415
4564
|
const reportParts = [
|
|
4416
|
-
formatFindings(
|
|
4565
|
+
formatFindings(display, {
|
|
4417
4566
|
dir,
|
|
4418
4567
|
fileCount: r.scope.sourceFiles,
|
|
4419
4568
|
depFileCount: r.scope.depFiles,
|
|
@@ -4434,6 +4583,10 @@ ${r.scope.gitNote}`);
|
|
|
4434
4583
|
Pattern-scan diff vs your last scan of this directory: ${diff.resolved} resolved, ${diff.stillOpen} still open, ${diff.introduced} new.`
|
|
4435
4584
|
);
|
|
4436
4585
|
}
|
|
4586
|
+
if (uploadNote) {
|
|
4587
|
+
reportParts.push(`
|
|
4588
|
+
${uploadNote}`);
|
|
4589
|
+
}
|
|
4437
4590
|
return {
|
|
4438
4591
|
content: [{ type: "text", text: reportParts.join("\n") }],
|
|
4439
4592
|
structuredContent: {
|
|
@@ -4442,8 +4595,9 @@ Pattern-scan diff vs your last scan of this directory: ${diff.resolved} resolved
|
|
|
4442
4595
|
counts: verdict.counts,
|
|
4443
4596
|
scanned: { mode: r.scope.mode, sourceFiles: r.scope.sourceFiles, dependencyFiles: r.scope.depFiles },
|
|
4444
4597
|
diff,
|
|
4445
|
-
findings: toStructuredFindings(
|
|
4446
|
-
notes
|
|
4598
|
+
findings: toStructuredFindings(display),
|
|
4599
|
+
notes,
|
|
4600
|
+
...dashboardUrl ? { dashboardUrl } : {}
|
|
4447
4601
|
}
|
|
4448
4602
|
};
|
|
4449
4603
|
}
|
|
@@ -4466,12 +4620,13 @@ server.registerTool(
|
|
|
4466
4620
|
const r = await runScan(dir, { ai: true });
|
|
4467
4621
|
if (!r.ok) return errorResult(r.error);
|
|
4468
4622
|
if (r.findings.length === 0) return text(`Nothing to fix \u2014 no issues found in ${r.scope.sourceFiles} file(s).`);
|
|
4469
|
-
const
|
|
4623
|
+
const safeFindings = sortBySeverity(r.findings).map(redactFinding);
|
|
4624
|
+
const res = await generateFixPrompt(token.token, toFixPromptPayload(safeFindings), r.platform ?? "manual");
|
|
4470
4625
|
if ("error" in res) return errorResult(`Couldn't generate the fix prompt: ${res.error}`);
|
|
4471
4626
|
const target = res.platform === "manual" ? "your AI assistant" : res.platform;
|
|
4472
4627
|
return text(`Paste this into ${target} to fix all ${r.findings.length} issue(s):
|
|
4473
4628
|
|
|
4474
|
-
${res.prompt}`);
|
|
4629
|
+
${redactSecrets(res.prompt)}`);
|
|
4475
4630
|
}
|
|
4476
4631
|
);
|
|
4477
4632
|
server.registerTool(
|
|
@@ -4484,7 +4639,7 @@ server.registerTool(
|
|
|
4484
4639
|
async () => {
|
|
4485
4640
|
const token = getStoredToken();
|
|
4486
4641
|
if (!token) {
|
|
4487
|
-
return text("Not logged in.
|
|
4642
|
+
return text("Not logged in. Use the shipsafe_login tool (or run `shipsafe login` in a terminal) to enable AI deep analysis (Growth or Shield plan).");
|
|
4488
4643
|
}
|
|
4489
4644
|
const profile = await getProfile(token.token);
|
|
4490
4645
|
if (!profile) {
|
|
@@ -4497,6 +4652,58 @@ Plan: ${plan}
|
|
|
4497
4652
|
AI scans: ${q.used}/${q.limit} used this month (${q.remaining} left)`);
|
|
4498
4653
|
}
|
|
4499
4654
|
);
|
|
4655
|
+
var LOGIN_POLL_WINDOW_MS = 25e3;
|
|
4656
|
+
var LOGIN_POLL_INTERVAL_MS = 2e3;
|
|
4657
|
+
server.registerTool(
|
|
4658
|
+
"shipsafe_login",
|
|
4659
|
+
{
|
|
4660
|
+
title: "Log in to ShipSafe from your editor",
|
|
4661
|
+
description: "Log in to ShipSafe without leaving the editor, to unlock AI deep analysis and fix prompts. Two steps: call with NO arguments to start \u2014 you'll get a URL and a code to authorize in the browser. After authorizing, call again with the returned `device_code` to finish. The free local + dependency scan works without logging in.",
|
|
4662
|
+
inputSchema: {
|
|
4663
|
+
device_code: z2.string().optional().describe("The device_code returned by the first (no-argument) call. Pass it on the second call, after authorizing in the browser, to complete login.")
|
|
4664
|
+
}
|
|
4665
|
+
},
|
|
4666
|
+
async ({ device_code }) => {
|
|
4667
|
+
if (!device_code) {
|
|
4668
|
+
const dc = await requestDeviceCode();
|
|
4669
|
+
if ("error" in dc) return errorResult(`Couldn't start login: ${dc.error}`);
|
|
4670
|
+
return text(
|
|
4671
|
+
`To log in, open this URL in your browser and enter the code:
|
|
4672
|
+
|
|
4673
|
+
${dc.verificationUrl}
|
|
4674
|
+
Code: ${dc.userCode}
|
|
4675
|
+
|
|
4676
|
+
After you authorize it, call shipsafe_login again with device_code: "${dc.deviceCode}" to finish.`
|
|
4677
|
+
);
|
|
4678
|
+
}
|
|
4679
|
+
const deadline = Date.now() + LOGIN_POLL_WINDOW_MS;
|
|
4680
|
+
while (Date.now() < deadline) {
|
|
4681
|
+
const poll = await pollDeviceToken(device_code);
|
|
4682
|
+
if (!("error" in poll)) {
|
|
4683
|
+
if (poll.status === "complete" && poll.token) {
|
|
4684
|
+
const tokenData = {
|
|
4685
|
+
token: poll.token,
|
|
4686
|
+
email: poll.email ?? "",
|
|
4687
|
+
expiresAt: Date.now() + THIRTY_DAYS_MS
|
|
4688
|
+
};
|
|
4689
|
+
const profile = await getProfile(poll.token);
|
|
4690
|
+
if (profile) {
|
|
4691
|
+
tokenData.tier = profile.tier;
|
|
4692
|
+
tokenData.aiQuota = profile.aiQuota;
|
|
4693
|
+
}
|
|
4694
|
+
storeToken(tokenData);
|
|
4695
|
+
const plan = profile?.tier && profile.tier !== "free" ? ` (${profile.tier})` : "";
|
|
4696
|
+
return text(`Logged in as ${tokenData.email || "your account"}${plan}. AI deep analysis is now available \u2014 re-run shipsafe_scan to use it.`);
|
|
4697
|
+
}
|
|
4698
|
+
if (poll.status === "expired") {
|
|
4699
|
+
return errorResult("That login link expired. Call shipsafe_login with no arguments to start again.");
|
|
4700
|
+
}
|
|
4701
|
+
}
|
|
4702
|
+
await sleep(LOGIN_POLL_INTERVAL_MS);
|
|
4703
|
+
}
|
|
4704
|
+
return text("Still waiting for authorization. Finish in the browser, then call shipsafe_login again with the same device_code.");
|
|
4705
|
+
}
|
|
4706
|
+
);
|
|
4500
4707
|
async function main() {
|
|
4501
4708
|
const transport = new StdioServerTransport();
|
|
4502
4709
|
await server.connect(transport);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ship-safe/mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "ShipSafe MCP server — let your AI coding agent scan the code it writes for security vulnerabilities, in-loop",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -45,8 +45,8 @@
|
|
|
45
45
|
"@types/node": "^22",
|
|
46
46
|
"tsup": "^8",
|
|
47
47
|
"typescript": "^5.7",
|
|
48
|
-
"@shipsafe/
|
|
49
|
-
"@shipsafe/
|
|
48
|
+
"@shipsafe/shared": "0.1.0",
|
|
49
|
+
"@shipsafe/scanner": "0.1.0"
|
|
50
50
|
},
|
|
51
51
|
"scripts": {
|
|
52
52
|
"build": "tsup",
|