@shipeasy/mcp
Advanced tools
@@ -188,2 +188,51 @@ import { describe, it, expect, vi, afterEach } from "vitest"; | ||
| }); | ||
| // ── auth_logout: the one tool that can strand its own caller ───────────────── | ||
| /** | ||
| * Deleting the config file is machine-wide (the CLI and every MCP client share | ||
| * it) and cannot be undone from MCP — `auth_login` explicitly refuses on stdio, | ||
| * because a browser round-trip needs a terminal. So an agent that calls this | ||
| * while troubleshooting takes out its own session plus every other tool's, and | ||
| * has to hand a login URL back to a human. It must not be a one-call action. | ||
| */ | ||
| describe("auth_logout requires explicit confirmation", () => { | ||
| afterEach(() => vi.clearAllMocks()); | ||
| it("refuses without confirm, and does NOT touch the config file", async () => { | ||
| const { handleAuthLogout } = await import("../tools/shared/auth.js"); | ||
| const { clearConfig } = await import("../auth/config.js"); | ||
| const res = await handleAuthLogout({}); | ||
| expect(res.isError).toBe(true); | ||
| expect(clearConfig).not.toHaveBeenCalled(); | ||
| const text = JSON.stringify(res.content); | ||
| expect(text).toContain("confirm: true"); | ||
| // The refusal has to redirect the likely intent, or the agent just retries | ||
| // with confirm:true and we've achieved nothing. | ||
| expect(text).toMatch(/401\/403/); | ||
| expect(text).toContain("cannot be restored from MCP"); | ||
| }); | ||
| it("refuses a truthy-but-not-true confirm (no accidental coercion)", async () => { | ||
| const { handleAuthLogout } = await import("../tools/shared/auth.js"); | ||
| const { clearConfig } = await import("../auth/config.js"); | ||
| for (const confirm of ["true", 1, {}]) { | ||
| const res = await handleAuthLogout({ confirm }); | ||
| expect(res.isError, `confirm=${JSON.stringify(confirm)} must be refused`).toBe(true); | ||
| } | ||
| expect(clearConfig).not.toHaveBeenCalled(); | ||
| }); | ||
| it("signs out with confirm: true, and says how to get back in", async () => { | ||
| const { handleAuthLogout } = await import("../tools/shared/auth.js"); | ||
| const { clearConfig } = await import("../auth/config.js"); | ||
| const res = await handleAuthLogout({ confirm: true }); | ||
| expect(res.isError).toBeUndefined(); | ||
| expect(clearConfig).toHaveBeenCalledTimes(1); | ||
| expect(JSON.stringify(res.content)).toMatch(/shipeasy login|shipeasy-mcp install/); | ||
| }); | ||
| it("declares confirm as required in its schema", async () => { | ||
| const { TOOLS } = await import("../tools/schema.js"); | ||
| const tool = TOOLS.find((t) => t.name === "auth_logout"); | ||
| expect(tool.inputSchema.required).toEqual(["confirm"]); | ||
| expect(tool.annotations?.destructiveHint).toBe(true); | ||
| // The description carries the blast radius — clients surface it verbatim. | ||
| expect(tool.description).toContain("not restorable from MCP"); | ||
| }); | ||
| }); | ||
| // ── custom (non-spec) tools ────────────────────────────────────────────────── | ||
@@ -190,0 +239,0 @@ describe("custom tools", () => { |
@@ -32,2 +32,11 @@ export interface ShipeasyConfig { | ||
| export declare function diagnoseMissingConfig(): Promise<string>; | ||
| /** | ||
| * Write the session ATOMICALLY — temp file in the same directory, then rename. | ||
| * | ||
| * `writeFile` truncates first, so a concurrent writer (the CLI and any number of | ||
| * MCP clients share this one path) or an interrupted write leaves a torn or | ||
| * empty file. Readers then fail to parse it and report "not authenticated", | ||
| * which is indistinguishable from a deleted session and just as unrecoverable | ||
| * without a browser. `rename` within a directory is atomic. | ||
| */ | ||
| export declare function writeConfig(cfg: ShipeasyConfig): Promise<void>; | ||
@@ -34,0 +43,0 @@ export declare function clearConfig(): Promise<boolean>; |
+19
-2
@@ -1,2 +0,2 @@ | ||
| import { mkdir, readFile, unlink, writeFile } from "node:fs/promises"; | ||
| import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises"; | ||
| import { homedir } from "node:os"; | ||
@@ -72,6 +72,23 @@ import { dirname, join } from "node:path"; | ||
| } | ||
| /** | ||
| * Write the session ATOMICALLY — temp file in the same directory, then rename. | ||
| * | ||
| * `writeFile` truncates first, so a concurrent writer (the CLI and any number of | ||
| * MCP clients share this one path) or an interrupted write leaves a torn or | ||
| * empty file. Readers then fail to parse it and report "not authenticated", | ||
| * which is indistinguishable from a deleted session and just as unrecoverable | ||
| * without a browser. `rename` within a directory is atomic. | ||
| */ | ||
| export async function writeConfig(cfg) { | ||
| const path = configPath(); | ||
| await mkdir(dirname(path), { recursive: true, mode: 0o700 }); | ||
| await writeFile(path, JSON.stringify(cfg, null, 2) + "\n", { mode: 0o600 }); | ||
| const tmp = `${path}.${process.pid}.tmp`; | ||
| try { | ||
| await writeFile(tmp, JSON.stringify(cfg, null, 2) + "\n", { mode: 0o600 }); | ||
| await rename(tmp, path); | ||
| } | ||
| catch (err) { | ||
| await unlink(tmp).catch(() => { }); | ||
| throw err; | ||
| } | ||
| } | ||
@@ -78,0 +95,0 @@ export async function clearConfig() { |
+1
-1
@@ -137,3 +137,3 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js"; | ||
| if (toolName === "auth_logout") | ||
| return handleAuthLogout(); | ||
| return handleAuthLogout((params.arguments ?? {})); | ||
| // Everything else — every CRUD/read/docs surface incl. the i18n admin API | ||
@@ -140,0 +140,0 @@ // (`i18n_profiles_*`/`i18n_keys_*`/`i18n_drafts_list`), alert rules, the |
+18
-3
@@ -90,5 +90,20 @@ import { REGISTRY_TOOLS } from "./registry.js"; | ||
| name: "auth_logout", | ||
| description: "Delete ~/.config/shipeasy/config.json. No network call.", | ||
| inputSchema: { type: "object", properties: {} }, | ||
| // Deletes the local CLI token. | ||
| description: "Delete ~/.config/shipeasy/config.json — the ONE session shared by the `shipeasy` CLI and " + | ||
| "every MCP client on this machine. No network call. **This is not restorable from MCP**: " + | ||
| "re-authenticating needs a browser sign-in in a terminal (`shipeasy login`), which this " + | ||
| "transport cannot perform, so calling this strands the current task and every other tool " + | ||
| "until a human signs in again. Do NOT call it to 'reset' or troubleshoot a failing " + | ||
| "call — a 401/403 is fixed by signing in, not by deleting the credential first. Only call " + | ||
| "it when the user explicitly asked to sign out, and pass confirm: true to acknowledge that.", | ||
| inputSchema: { | ||
| type: "object", | ||
| properties: { | ||
| confirm: { | ||
| type: "boolean", | ||
| description: "Must be true. Acknowledges that this deletes the machine-wide session and that only a human at a terminal can restore it.", | ||
| }, | ||
| }, | ||
| required: ["confirm"], | ||
| }, | ||
| // Deletes the local CLI token — machine-wide, and unrecoverable over stdio. | ||
| annotations: { | ||
@@ -95,0 +110,0 @@ title: "Auth Logout", |
@@ -12,3 +12,16 @@ /** | ||
| }>; | ||
| export declare function handleAuthLogout(): Promise<{ | ||
| /** | ||
| * `auth_logout` MCP tool handler. | ||
| * | ||
| * Schema-level `required: ["confirm"]` is advisory — a client that ignores it | ||
| * still reaches this handler — so the guard is enforced here too. The cost of a | ||
| * stray call is high and asymmetric: the deleted file is the session the CLI and | ||
| * every MCP client share, and `auth_login` cannot run over stdio, so nothing on | ||
| * this transport can put it back. A refusal costs one retry; a wrong deletion | ||
| * costs a human a browser round-trip. | ||
| */ | ||
| export declare function handleAuthLogout(args?: { | ||
| confirm?: unknown; | ||
| }): Promise<{ | ||
| isError: boolean; | ||
| content: { | ||
@@ -18,2 +31,8 @@ type: "text"; | ||
| }[]; | ||
| } | { | ||
| content: { | ||
| type: "text"; | ||
| text: string; | ||
| }[]; | ||
| isError?: undefined; | ||
| }>; |
@@ -30,7 +30,39 @@ import { configPath, readConfig } from "../../auth/config.js"; | ||
| } | ||
| export async function handleAuthLogout() { | ||
| /** | ||
| * `auth_logout` MCP tool handler. | ||
| * | ||
| * Schema-level `required: ["confirm"]` is advisory — a client that ignores it | ||
| * still reaches this handler — so the guard is enforced here too. The cost of a | ||
| * stray call is high and asymmetric: the deleted file is the session the CLI and | ||
| * every MCP client share, and `auth_login` cannot run over stdio, so nothing on | ||
| * this transport can put it back. A refusal costs one retry; a wrong deletion | ||
| * costs a human a browser round-trip. | ||
| */ | ||
| export async function handleAuthLogout(args = {}) { | ||
| if (args.confirm !== true) { | ||
| return { | ||
| isError: true, | ||
| content: [ | ||
| { | ||
| type: "text", | ||
| text: `Refused: auth_logout deletes ${configPath()}, the session shared by the ` + | ||
| "`shipeasy` CLI and every MCP client on this machine, and it cannot be " + | ||
| "restored from MCP (browser sign-in only runs in a terminal).\n\n" + | ||
| "If a call is failing with 401/403, sign in — do not log out first; the " + | ||
| "credential you would delete is the one you still need.\n\n" + | ||
| "If the user explicitly asked to sign out, call again with confirm: true.", | ||
| }, | ||
| ], | ||
| }; | ||
| } | ||
| await runLogout(); | ||
| return { | ||
| content: [{ type: "text", text: "Signed out locally." }], | ||
| content: [ | ||
| { | ||
| type: "text", | ||
| text: "Signed out locally. Restoring the session needs `shipeasy login` (or " + | ||
| "`shipeasy-mcp install`) in a terminal — a browser sign-in this transport cannot do.", | ||
| }, | ||
| ], | ||
| }; | ||
| } |
+1
-1
| { | ||
| "name": "@shipeasy/mcp", | ||
| "version": "2.5.0", | ||
| "version": "2.6.0", | ||
| "description": "Feature flags, A/B experiments, kill switches, dynamic config & i18n — Shipeasy MCP server.", | ||
@@ -5,0 +5,0 @@ "mcpName": "ai.shipeasy/mcp", |
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
397775
1.84%4218
3.46%