From b4cf8b7a114c8eb267177b5c8c54e003cafc92f7 Mon Sep 17 00:00:00 2001 From: Eliott Reich Date: Sun, 21 Jun 2026 15:17:35 +0300 Subject: [PATCH] 0.1.5: MCP Registry (server.json), agent skill, MCP CTA, SARIF helpUri, discovery, README Distribution release synced from the monorepo (no scanner-rule changes). - server.json for the official MCP Registry (npm stdio `npx -y taskbounty-check@0.1.5 mcp`, no auth/env/secrets, honest scope) + package.json mcpName; validation tests. - scan_repo appends one quiet review CTA on findings (shown once, mcp_registry utm). - SARIF helpUri links the methodology per-rule (#rule-, github/sarif utm). - npm keywords: mcp, mcp-server, claude-code, codex, devsecops, github-security, github-actions-security, ci-security, software-supply-chain. - skills/taskbounty-security/SKILL.md (skills.sh discovery). - README first screen reworked (scope, trust, three install choices, job-summary screenshot, works-with, methodology/privacy/limitations links); pinned-version guidance, no @latest-as-safest. - CI: + populated demo summary, + MCP-init-from-packed-artifact, + skill discovery shape. Version 0.1.5; repository.url preserved. Not yet published. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/ci.yml | 46 ++++++++++++++- README.md | 76 +++++++++++++++--------- package.json | 14 ++++- server.json | 27 +++++++++ skills/taskbounty-security/SKILL.md | 77 ++++++++++++++++++++++++ src/mcp.js | 26 +++++++-- src/sarif.js | 16 ++++- test/mcp.test.ts | 31 +++++++++- test/sarif.test.ts | 13 +++++ test/server-json.test.ts | 91 +++++++++++++++++++++++++++++ 10 files changed, 379 insertions(+), 38 deletions(-) create mode 100644 server.json create mode 100644 skills/taskbounty-security/SKILL.md create mode 100644 test/server-json.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ee2148f..7854293 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,9 @@ jobs: - run: npm test - run: npm pack --dry-run - # Dogfood: run the bundled Action on this repo and write a sanitized summary. + # Dogfood: run the bundled Action on this repo and write a sanitized summary, + # plus a populated demo summary (scanning a fixture with findings) so the run + # Summary page shows a real, non-empty maintenance table. self-check: runs-on: ubuntu-latest permissions: @@ -32,3 +34,45 @@ jobs: with: node-version: "20" - run: node "$GITHUB_WORKSPACE/src/index.js" . --github-summary --no-network + - name: Demo summary (populated, for docs/screenshot) + run: | + mkdir -p /tmp/demo/.github/workflows + printf 'on: push\npermissions: write-all\njobs:\n build:\n runs-on: ubuntu-latest\n steps:\n - uses: tj-actions/changed-files@v47\n' > /tmp/demo/.github/workflows/ci.yml + node "$GITHUB_WORKSPACE/src/index.js" /tmp/demo --github-summary --no-network + + # Verify the packed npm artifact: MCP initializes from it, and the agent skill + # has a valid skills.sh discovery shape. + artifact-checks: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + with: + node-version: "20" + - run: npm ci --no-audit --no-fund + - name: MCP initialization from the packed artifact + run: | + set -euo pipefail + npm pack + TGZ="$(ls taskbounty-check-*.tgz)" + VER="$(node -p "require('$GITHUB_WORKSPACE/package.json').version")" + mkdir -p /tmp/consume && cd /tmp/consume && npm init -y >/dev/null 2>&1 + npm install "$GITHUB_WORKSPACE/$TGZ" >/dev/null 2>&1 + OUT="$(printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' | npx --no-install taskbounty-check mcp)" + echo "$OUT" + echo "$OUT" | grep -q '"serverInfo"' || { echo "::error::MCP did not initialize from the packed artifact"; exit 1; } + echo "$OUT" | grep -q "\"version\":\"$VER\"" || { echo "::error::MCP serverInfo version != package version $VER"; exit 1; } + - name: Agent skill discovery shape (skills.sh) + run: | + set -euo pipefail + test -f skills/taskbounty-security/SKILL.md || { echo "::error::SKILL.md missing"; exit 1; } + node -e ' + const fs = require("fs"); + const s = fs.readFileSync("skills/taskbounty-security/SKILL.md", "utf8"); + const m = s.match(/^---\n([\s\S]*?)\n---/); + if (!m) { console.error("missing frontmatter"); process.exit(1); } + const fm = "\n" + m[1]; + if (!/\nname:\s*\S/.test(fm)) { console.error("missing name"); process.exit(1); } + if (!/\ndescription:\s*\S/.test(fm)) { console.error("missing description"); process.exit(1); } + console.log("SKILL.md frontmatter OK"); + ' diff --git a/README.md b/README.md index 59602e8..b5295ba 100644 --- a/README.md +++ b/README.md @@ -1,39 +1,58 @@ # taskbounty-check -Pre-launch safety check for AI-built apps. Built it with **Lovable, Bolt, Replit, Cursor, or v0**? -This scans your **GitHub Actions + CI hygiene locally** before you ship. Your source code and -workflow contents **never leave your machine**. The default code path makes **no outbound -requests**; `fetch` is additionally blocked as defense in depth (this is not a complete network -sandbox). Only `--gh-org` intentionally uses the network. +**A local check for GitHub Actions and CI maintenance hygiene** (third-party action pinning, +workflow token permissions, and update automation), built for apps shipped with Lovable, Bolt, +Replit, Cursor, or v0. -> **Scope, honestly:** this checks GitHub Actions workflow + update-automation hygiene. It does -> **not** check exposed secrets, auth, payments, webhooks, or runtime behavior — those need a -> manual review. It is a maintenance check, not a full security audit. +**Local by default. No uploads. No telemetry.** It reads only your workflow files, on your machine. +The default code path makes no outbound network requests, writes its report locally, and sends +nothing anywhere. There is no analytics or phone-home of any kind. Only the opt-in `--gh-org` mode +uses the network (through your own `gh` session). -> **`--share` uploads nothing.** It writes a sanitized, counts-only local file for you to submit -> **manually**; network stays off under `--share`. +**Works with Cursor, Claude Code, and Codex** (local MCP server, below). -## Quick start (60 seconds) +## Three ways to use it -```bash -# 1. Scan the current repo locally (no network, writes a local report) -npx taskbounty-check@latest . +**1. GitHub Action** — add a maintenance check to CI that writes a summary to the run (no PR +comments, no source upload): -# 2. SARIF for GitHub Code Scanning -npx taskbounty-check@latest . --format sarif --output taskbounty.sarif +```yaml +permissions: + contents: read +steps: + - uses: actions/checkout@v4 + - run: npx taskbounty-check@0.1.5 . --github-summary --no-network +``` -# 3. Scaffold a least-privilege CI workflow (previews; never overwrites) -npx taskbounty-check@latest init +**2. Agent / MCP** — a local stdio server for Cursor, Claude Code, and Codex: -# 4. Local MCP server for Codex / Claude Code / Cursor -npx taskbounty-check@latest mcp +```bash +npx -y taskbounty-check@0.1.5 mcp ``` -Reproducible, pinned invocation (recommended): `npx taskbounty-check@0.1.4 .` +**3. One-off CLI** — scan the current repo locally and write a report: + +```bash +npx -y taskbounty-check@0.1.5 . +``` + +> Pin a version (`@0.1.5`) in committed config and CI for reproducibility. `@latest` is convenient +> for a quick one-off, but a pinned version is the reproducible choice. + +### A real GitHub job summary + +The Action writes a counts-only maintenance summary to the workflow run (categories and next steps, +no filenames, line numbers, or repo source). Example from this repo's own CI: + +![TaskBounty check: GitHub Actions job summary](docs/job-summary.png) + +See it live: the **self-check** job in [this repository's Actions runs](https://github.com/eliottreich/taskbounty-check/actions). + +### Learn more -**Privacy:** the scan runs locally and **sends nothing by default**. Source code, workflow -contents, filenames, line numbers, and evidence never leave your machine. The only thing that can -ever be transmitted is a sanitized counts-only summary, and only when you explicitly choose to. +- [Methodology](https://www.task-bounty.com/github-actions-security-check/methodology) — exactly what it reviews and how findings are labeled. +- [Privacy and scope](https://www.task-bounty.com/ai-app-security-check) — local-by-default data handling. +- [Limitations](#supported-checks-and-honest-limitations) — what it does NOT check (below). ## Supported checks (and honest limitations) @@ -118,7 +137,7 @@ permissions: security-events: write steps: - uses: actions/checkout@v4 - - run: npx taskbounty-check@latest . --format sarif --output taskbounty.sarif + - run: npx taskbounty-check@0.1.5 . --format sarif --output taskbounty.sarif - uses: github/codeql-action/upload-sarif@v3 with: sarif_file: taskbounty.sarif @@ -157,6 +176,7 @@ args = ["-y", "taskbounty-check@latest", "mcp"] ## Security -Zero runtime dependencies. Published with npm provenance; verify checksums. See the threat model -(`design-docs/security-cli-threat-model.md`) and external-review packet -(`design-docs/security-expansion/external-review-packet.md`) in the project repository. +Zero runtime dependencies. Published to npm with provenance (verify on the package's npm page). The +default run makes no outbound requests and uploads nothing; see the +[methodology](https://www.task-bounty.com/github-actions-security-check/methodology) for the full +data-handling and scope boundaries. diff --git a/package.json b/package.json index b1328d6..df9f93d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,7 @@ { "name": "taskbounty-check", - "version": "0.1.4", + "version": "0.1.5", + "mcpName": "io.github.eliottreich/taskbounty-check", "description": "Pre-launch safety check for AI-built apps (Lovable, Bolt, Replit, Cursor, v0). Scans your GitHub Actions + CI hygiene locally. Source code and workflow contents never leave your machine.", "type": "module", "bin": { @@ -37,7 +38,16 @@ "v0", "github-actions", "security", - "local-scanner" + "local-scanner", + "mcp", + "mcp-server", + "claude-code", + "codex", + "devsecops", + "github-security", + "github-actions-security", + "ci-security", + "software-supply-chain" ], "homepage": "https://www.task-bounty.com/ai-app-security-check", "repository": { diff --git a/server.json b/server.json new file mode 100644 index 0000000..5a03975 --- /dev/null +++ b/server.json @@ -0,0 +1,27 @@ +{ + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", + "name": "io.github.eliottreich/taskbounty-check", + "description": "Local GitHub Actions/CI maintenance check (action pinning, token perms). Not a full security audit.", + "repository": { + "url": "https://github.com/eliottreich/taskbounty-check", + "source": "github" + }, + "version": "0.1.5", + "packages": [ + { + "registryType": "npm", + "identifier": "taskbounty-check", + "version": "0.1.5", + "transport": { + "type": "stdio" + }, + "packageArguments": [ + { + "type": "positional", + "value": "mcp", + "valueHint": "mcp" + } + ] + } + ] +} diff --git a/skills/taskbounty-security/SKILL.md b/skills/taskbounty-security/SKILL.md new file mode 100644 index 0000000..8c4d0f0 --- /dev/null +++ b/skills/taskbounty-security/SKILL.md @@ -0,0 +1,77 @@ +--- +name: taskbounty-security +description: Run TaskBounty's local GitHub Actions / CI maintenance-hygiene check, interpret the findings, and draft a fix plan. Use when a user wants to review a repo's GitHub Actions workflows for third-party action pinning, workflow token permissions, or update-automation gaps before shipping. Scope is CI/workflow hygiene only, not a full application-security audit. Runs locally, uploads nothing, and never changes files without explicit approval. +--- + +# TaskBounty security check (GitHub Actions / CI hygiene) + +`taskbounty-check` is a local, zero-dependency checker for **GitHub Actions and CI maintenance hygiene**: +third-party action pinning, workflow token permissions, and dependency-update automation. It reads +workflow files on disk. By default it makes **no outbound network requests** and **uploads nothing**. + +**Scope, stated honestly.** This covers GitHub Actions, CI workflow permissions, action pinning, and +update automation. It is **not a complete application-security audit** - it does not check exposed +secrets, authentication, payments, webhooks, or runtime behavior. Say so when you report results. + +## Hard rules for the agent + +- **Never upload the user's source code or scan results anywhere.** The tool keeps everything local; + keep it that way. +- **Never modify files without explicit user approval.** Findings and fix plans are proposals. +- **Never commit, push, open pull requests, or post comments automatically.** The user does that. +- Do not add the `--gh-org` flag unless the user explicitly asks to scan an organization (it is the + only mode that uses the network, via the user's own `gh` session). + +## 1. Run the checker locally (primary) + +```bash +npx -y taskbounty-check@0.1.5 . +``` + +This writes a local report and prints a summary. No network, nothing uploaded. Run it from the repo +root (or pass a path). To see what it would do without writing files, add `--dry-run`. + +## 2. Interpret the findings + +Each finding has a rule id and a confidence label (`confirmed` vs `review`). Common rules: + +- **unpinned-action** - a third-party action uses a movable tag/branch (e.g. `@v4`) instead of a + full commit SHA. Movable refs can be re-pointed upstream. +- **broad-permissions** / **no-permissions-block** - the workflow grants (or defaults to) broad + `GITHUB_TOKEN` permissions instead of least privilege. +- **prt-checkout-untrusted** / **secrets-in-prt** - a `pull_request_target` workflow runs untrusted + PR code and/or exposes secrets to it. +- **script-injection** - untrusted `${{ github.event.* }}` input is interpolated into a run step. + +Explain each finding in plain language and why it matters. Do not overstate: a `review` item is a +candidate to check, not a confirmed vulnerability. + +## 3. Propose a fix plan (do not apply it silently) + +Draft concrete, minimal edits - for example, replacing `uses: owner/action@v4` with +`uses: owner/action@ # v4`, or adding a top-level `permissions: { contents: read }` +block. Present the plan and let the user approve before changing any file. Never commit or push. + +## SARIF mode (GitHub Code Scanning) + +```bash +npx -y taskbounty-check@0.1.5 . --format sarif --output taskbounty.sarif +``` + +Produces SARIF 2.1.0 the user can upload to **their own** repo's Code Scanning. Each rule links to +the public methodology for context. + +## MCP mode (Cursor, Claude Code, Codex) + +```bash +npx -y taskbounty-check@0.1.5 mcp +``` + +Starts a local stdio MCP server exposing `scan_repo`, `explain_finding`, and `generate_fix_plan`. +It is local-only, makes no outbound requests, and never modifies files - fix plans are returned as +text for the user to apply explicitly. + +## A note on pinning + +For reproducibility, prefer a pinned version (`taskbounty-check@0.1.5`) over `@latest` in committed +config and CI. diff --git a/src/mcp.js b/src/mcp.js index edab8eb..d7dc9c1 100644 --- a/src/mcp.js +++ b/src/mcp.js @@ -75,21 +75,39 @@ function textResult(text) { return { content: [{ type: "text", text }] }; } +// Product-led CTA. STATIC by construction — never contains repo names, paths, findings, or counts. +// Shown at most ONCE per server process, and only when scan_repo actually surfaced something. +const REVIEW_CTA = + "Need a human second opinion or fix plan?\n" + + "https://www.task-bounty.com/ai-app-security-check/review?utm_source=mcp_registry&utm_medium=integration&utm_campaign=agent_distribution"; +let ctaShown = false; +function maybeAppendCta(text, hasFindings) { + if (!hasFindings || ctaShown) return text; + ctaShown = true; + return `${text}\n\n${REVIEW_CTA}`; +} +// Test-only: reset the show-once latch so ordering-independent tests can assert the behavior. +export function __resetCtaForTest() { ctaShown = false; } +export { REVIEW_CTA }; + /** Pure tool dispatch — returns an MCP tool result. No network, no writes. */ export function callMcpTool(name, args = {}) { if (name === "scan_repo") { const path = typeof args.path === "string" && args.path.trim() ? args.path : "."; const result = scanInput(path); // network already guarded off const cats = (result.maintenanceCandidates || []).map((c) => `- ${c.category}: ${c.count} (${c.confidence})`).join("\n") || "- none"; - const items = (result.localEvidence || []).slice(0, 50) + const evidence = result.localEvidence || []; + const items = evidence.slice(0, 50) .map((e) => ` • [${e.confirmed ? "confirmed" : "review"}] ${e.rule} — ${e.file}${e.line ? ":" + e.line : ""}`).join("\n") || " • none"; - return textResult( + const hasFindings = evidence.length > 0 || (result.privateReviewCount || 0) > 0 || + (result.maintenanceCandidates || []).some((c) => (c.count || 0) > 0); + const body = `Local scan of "${path}" (no network, nothing uploaded):\n` + `Repos: ${result.repoCount} · workflow files: ${result.workflowFilesReviewed} · items for private review: ${result.privateReviewCount}\n\n` + `Maintenance candidates by category:\n${cats}\n\nFindings (local detail; confirmed vs review):\n${items}\n\n` + `Scope: GitHub Actions + update-automation hygiene only — not a full security audit (secrets/auth/payments/webhooks/runtime need manual review). ` + - `Use explain_finding and generate_fix_plan for next steps.`, - ); + `Use explain_finding and generate_fix_plan for next steps.`; + return textResult(maybeAppendCta(body, hasFindings)); } if (name === "explain_finding") { const k = KB[String(args.rule || "").trim()]; diff --git a/src/sarif.js b/src/sarif.js index d4cc445..4d877e7 100644 --- a/src/sarif.js +++ b/src/sarif.js @@ -17,8 +17,20 @@ const RULES = { const RULE_PREFIX = "taskbounty"; const DEFAULT_LEVEL = "note"; +// Rule-help destination. Links to the public methodology, with a per-rule anchor when the rule is +// a documented one. Channel-only utm — no repo name or finding data ever goes in the URL. +const METHODOLOGY_URL = + "https://www.task-bounty.com/github-actions-security-check/methodology?utm_source=github&utm_medium=sarif&utm_campaign=agent_distribution"; + +function ruleSlug(rule) { + return String(rule || "finding").replace(/[^a-z0-9_-]/gi, "-"); +} function ruleId(rule) { - return `${RULE_PREFIX}/${String(rule || "finding").replace(/[^a-z0-9_-]/gi, "-")}`; + return `${RULE_PREFIX}/${ruleSlug(rule)}`; +} +// Anchor to the specific rule section on the methodology page for documented rules. +function ruleHelpUri(rule) { + return RULES[rule] ? `${METHODOLOGY_URL}#rule-${ruleSlug(rule)}` : METHODOLOGY_URL; } // Map our severity to a SARIF level when the rule has no explicit one. @@ -45,7 +57,7 @@ export function renderSarif(result) { shortDescription: { text: meta.name }, fullDescription: { text: meta.help }, defaultConfiguration: { level: meta.level || DEFAULT_LEVEL }, - helpUri: "https://www.task-bounty.com/github-actions-security-check/methodology", + helpUri: ruleHelpUri(e.rule), properties: { category: e.category || "other" }, }); } diff --git a/test/mcp.test.ts b/test/mcp.test.ts index 825f05f..71a9f42 100644 --- a/test/mcp.test.ts +++ b/test/mcp.test.ts @@ -5,7 +5,7 @@ import { join } from "node:path"; import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { dirname } from "node:path"; -import { MCP_TOOLS, callMcpTool, PKG_VERSION } from "../src/mcp.js"; +import { MCP_TOOLS, callMcpTool, PKG_VERSION, REVIEW_CTA, __resetCtaForTest } from "../src/mcp.js"; let repo: string; beforeAll(() => { @@ -48,3 +48,32 @@ describe("local MCP tools", () => { expect(r.content[0].text).toMatch(/Unknown rule/); }); }); + +describe("scan_repo product-led CTA (quiet, shown once, on findings only)", () => { + const cleanRepo = mkdtempSync(join(tmpdir(), "tbclean-")); + beforeAll(() => { + __resetCtaForTest(); + mkdirSync(join(cleanRepo, ".github", "workflows"), { recursive: true }); + // a clean workflow: pinned action + explicit least-privilege permissions => no findings + writeFileSync(join(cleanRepo, ".github", "workflows", "ci.yml"), + "on: push\npermissions:\n contents: read\njobs:\n b:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683\n"); + }); + afterAll(() => rmSync(cleanRepo, { recursive: true, force: true })); + + it("never shows the CTA when there are no findings", () => { + const r = callMcpTool("scan_repo", { path: cleanRepo }); + expect(r.content[0].text).not.toContain("Need a human second opinion"); + }); + + it("shows the CTA once when there are findings, with the mcp_registry utm and no private data", () => { + const first = callMcpTool("scan_repo", { path: repo }).content[0].text; + expect(first).toContain("Need a human second opinion or fix plan?"); + expect(first).toContain("utm_source=mcp_registry&utm_medium=integration&utm_campaign=agent_distribution"); + // the CTA is the static constant; it carries no repo name, path, finding, or count + expect(REVIEW_CTA).not.toMatch(/owner|\.github|\.yml|\/tmp|count/i); + + // shown ONCE: a second findings scan in the same process does not repeat it + const second = callMcpTool("scan_repo", { path: repo }).content[0].text; + expect(second).not.toContain("Need a human second opinion"); + }); +}); diff --git a/test/sarif.test.ts b/test/sarif.test.ts index 231883a..00e4dca 100644 --- a/test/sarif.test.ts +++ b/test/sarif.test.ts @@ -54,6 +54,19 @@ describe("renderSarif", () => { expect(blob).not.toMatch(/ghp_[A-Za-z0-9]{20,}|-----BEGIN|process\.env|AWS_SECRET|password=/i); }); + it("rules link to the methodology via helpUri with the sarif channel and a per-rule anchor", () => { + const rules = sarif.runs[0].tool.driver.rules as { id: string; helpUri: string }[]; + expect(rules.length).toBeGreaterThan(0); + for (const r of rules) { + expect(r.helpUri).toContain("/github-actions-security-check/methodology"); + expect(r.helpUri).toContain("utm_source=github&utm_medium=sarif&utm_campaign=agent_distribution"); + const slug = r.id.replace(/^taskbounty\//, ""); + expect(r.helpUri).toContain(`#rule-${slug}`); + } + // never any repo name or finding data in the help URL + expect(rules.every((r) => !/owner\/repo/.test(r.helpUri))).toBe(true); + }); + it("is deterministic for the same input", () => { const a = JSON.stringify(renderSarif(resultWith("on: push\npermissions: write-all"))); const b = JSON.stringify(renderSarif(resultWith("on: push\npermissions: write-all"))); diff --git a/test/server-json.test.ts b/test/server-json.test.ts new file mode 100644 index 0000000..37e6991 --- /dev/null +++ b/test/server-json.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import { spawn } from "node:child_process"; +import { MCP_TOOLS } from "../src/mcp.js"; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); +const pkg = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8")); +const server = JSON.parse(readFileSync(join(ROOT, "server.json"), "utf8")); +const npmPkg = server.packages.find((p: { registryType: string }) => p.registryType === "npm"); + +describe("server.json (official MCP Registry shape)", () => { + it("uses an official modelcontextprotocol schema", () => { + expect(String(server.$schema)).toMatch(/modelcontextprotocol\.io\/schemas\/.*server\.schema\.json$/); + }); + + it("registry name matches package.json#mcpName", () => { + expect(server.name).toBe(pkg.mcpName); + expect(server.name).toBe("io.github.eliottreich/taskbounty-check"); + }); + + it("registry package identifier + version match the npm package", () => { + expect(npmPkg).toBeTruthy(); + expect(npmPkg.identifier).toBe(pkg.name); + expect(npmPkg.version).toBe(pkg.version); + expect(server.version).toBe(pkg.version); + }); + + it("transport is local stdio and runs `mcp`", () => { + expect(npmPkg.transport.type).toBe("stdio"); + const args = (npmPkg.packageArguments || []).map((a: { value: string }) => a.value); + expect(args).toContain("mcp"); + }); + + it("requires no auth, secrets, or environment variables", () => { + expect(npmPkg.environmentVariables ?? []).toHaveLength(0); + // no hosted/remote endpoint of any kind + expect(server.remotes ?? []).toHaveLength(0); + expect(npmPkg.transport.url).toBeUndefined(); + }); + + it("description is honest about scope and within the 100-char limit", () => { + expect(server.description.length).toBeLessThanOrEqual(100); + expect(server.description).toMatch(/GitHub Actions/i); + expect(server.description).toMatch(/not a full security audit/i); + }); +}); + +// Spawn the published entrypoint and speak MCP over stdio. +function rpc(messages: object[]): Promise[]> { + return new Promise((resolvePromise, reject) => { + const child = spawn("node", [join(ROOT, "src", "index.js"), "mcp"], { stdio: ["pipe", "pipe", "ignore"] }); + let buf = ""; + const out: Record[] = []; + const timer = setTimeout(() => { child.kill(); reject(new Error("mcp init timed out")); }, 10000); + child.stdout.on("data", (chunk) => { + buf += chunk.toString(); + let nl; + while ((nl = buf.indexOf("\n")) >= 0) { + const line = buf.slice(0, nl).trim(); + buf = buf.slice(nl + 1); + if (line) out.push(JSON.parse(line)); + if (out.length >= messages.length) { clearTimeout(timer); child.kill(); resolvePromise(out); } + } + }); + child.on("error", reject); + for (const m of messages) child.stdin.write(JSON.stringify(m) + "\n"); + }); +} + +describe("MCP initialization + tools parity (from the entrypoint)", () => { + it("initialize succeeds and reports the package version", async () => { + const [init] = await rpc([{ jsonrpc: "2.0", id: 1, method: "initialize", params: {} }]); + const result = init.result as { serverInfo: { name: string; version: string }; protocolVersion: string }; + expect(result.serverInfo.name).toBe("taskbounty-check"); + expect(result.serverInfo.version).toBe(pkg.version); + expect(result.protocolVersion).toBeTruthy(); + }); + + it("the advertised tools match the implemented tools", async () => { + const replies = await rpc([ + { jsonrpc: "2.0", id: 1, method: "initialize", params: {} }, + { jsonrpc: "2.0", id: 2, method: "tools/list", params: {} }, + ]); + const list = replies.find((r) => r.id === 2)!.result as { tools: { name: string }[] }; + const advertised = list.tools.map((t) => t.name).sort(); + const implemented = MCP_TOOLS.map((t) => t.name).sort(); + expect(advertised).toEqual(implemented); + }); +});