diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b2d8fae..1c89b94 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,30 +1,52 @@ name: Release +# Triggered on every push to main. When package.json ships a version whose +# git tag doesn't exist yet, the workflow: +# 1. Runs the full CI gate. +# 2. Creates the `vX.Y.Z` tag automatically (no manual `git tag` needed). +# 3. Ships the .mcpb bundle to a GitHub Release AND publishes to npm +# (both jobs run in parallel — either can succeed while the other fails). +# +# When main advances without a version bump, the `detect` job flips +# should_release=false and every downstream job skips. on: push: - tags: - - 'v*.*.*' + branches: [main] -# Least-privilege default. Individual jobs opt into more when they need it. permissions: contents: read jobs: - # ── Preflight: verify the tag was cut from main + run the full CI gate. - # Any tag whose commit is NOT reachable from origin/main is refused here so - # feature-branch tags can never trigger a release. - gate: + # ── Detect whether this push introduces a new version. + detect: runs-on: ubuntu-latest + outputs: + version: ${{ steps.check.outputs.version }} + should_release: ${{ steps.check.outputs.should_release }} steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - - name: Verify tag commit is on main + - id: check + name: Read package.json version + check for existing tag run: | - if ! git branch -r --contains "${GITHUB_SHA}" | grep -qE '(^|\s)origin/main$'; then - echo "::error::Tag ${GITHUB_REF_NAME} (${GITHUB_SHA}) is not reachable from origin/main. Releases can only be cut from main." - exit 1 + VERSION=$(node -p "require('./package.json').version") + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + if git rev-parse "v$VERSION" >/dev/null 2>&1; then + echo "Tag v$VERSION already exists — skipping release." + echo "should_release=false" >> "$GITHUB_OUTPUT" + else + echo "New version v$VERSION — releasing." + echo "should_release=true" >> "$GITHUB_OUTPUT" fi + + # ── Full CI gate before anything ships. + gate: + runs-on: ubuntu-latest + needs: detect + if: needs.detect.outputs.should_release == 'true' + steps: + - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: '24' @@ -37,11 +59,32 @@ jobs: - run: npm test - run: npm run build + # ── Create and push the vX.Y.Z tag. + tag: + runs-on: ubuntu-latest + needs: [detect, gate] + if: needs.detect.outputs.should_release == 'true' + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Create and push tag v${{ needs.detect.outputs.version }} + env: + VERSION: ${{ needs.detect.outputs.version }} + run: | + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git tag "v${VERSION}" + git push origin "v${VERSION}" + # ── Ship the Claude Desktop Extension bundle to the tag's GitHub Release. # Independent of release-npm — either can succeed while the other fails. release-mcpb: runs-on: ubuntu-latest - needs: gate + needs: [detect, tag] + if: needs.detect.outputs.should_release == 'true' permissions: contents: write steps: @@ -58,12 +101,13 @@ jobs: - name: Upload .mcpb + .zip to the tag's GitHub Release uses: softprops/action-gh-release@v2 with: + tag_name: v${{ needs.detect.outputs.version }} files: | dist/github-mcp-server-js-*.mcpb dist/github-mcp-server-js-*.zip fail_on_unmatched_files: true generate_release_notes: true - prerelease: ${{ contains(github.ref_name, '-') }} + prerelease: ${{ contains(needs.detect.outputs.version, '-') }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -71,7 +115,8 @@ jobs: # Independent of release-mcpb — either can succeed while the other fails. release-npm: runs-on: ubuntu-latest - needs: gate + needs: [detect, tag] + if: needs.detect.outputs.should_release == 'true' environment: dev permissions: id-token: write @@ -91,8 +136,10 @@ jobs: # No NODE_AUTH_TOKEN — npm exchanges GitHub's OIDC token for a # short-lived publish token via the trusted-publisher config on # npmjs.com. See /package/github-mcp-server-js/access. + env: + VERSION: ${{ needs.detect.outputs.version }} run: | - if [[ "${GITHUB_REF_NAME}" == *-* ]]; then + if [[ "$VERSION" == *-* ]]; then npm publish --tag next --provenance --access public else npm publish --provenance --access public diff --git a/.gitignore b/.gitignore index 63a22ff..ae62dee 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,5 @@ dist/ .env .idea dist/*.mcpb -dist/*.zip \ No newline at end of file +dist/*.zip +.husky/ diff --git a/.husky/pre-commit b/.husky/pre-commit deleted file mode 100755 index 737deb5..0000000 --- a/.husky/pre-commit +++ /dev/null @@ -1,7 +0,0 @@ -npx tsc --noEmit -npx lint-staged -if command -v gitleaks >/dev/null 2>&1; then - gitleaks protect --staged --no-banner -else - echo "gitleaks not found on PATH — install via 'brew install gitleaks' to enable secret scanning locally. CI still enforces this." >&2 -fi diff --git a/README.md b/README.md index 3fb382d..3f2d3de 100644 --- a/README.md +++ b/README.md @@ -333,8 +333,6 @@ for public repositories). | `rerun_workflow_run_failed_jobs` | W | Re-run only the failed jobs in a workflow run. | | `approve_workflow_run` | W | Approve a workflow run awaiting fork-PR approval. | -See `docs/superpowers/specs/2026-08-05-github-mcp-server-design.md` for the full -architecture. ## 🧪 Testing @@ -455,7 +453,7 @@ Every published version is built by GitHub Actions from a tagged commit, [signed ## 🙏 Credits - **[octokit.js](https://github.com/octokit/octokit.js)** by GitHub — the REST/GraphQL client every tool wraps. Apache-2.0. -- **[MCP TypeScript SDK v2](https://github.com/modelcontextprotocol/typescript-sdk)** by Anthropic — the MCP server framework. MIT. +- **[MCP TypeScript](https://github.com/modelcontextprotocol/typescript-sdk)** by Anthropic — the MCP server framework. MIT. - **Prior art:** [`@modelcontextprotocol/server-github`](https://github.com/modelcontextprotocol/servers-archived/tree/main/src/github) (archived, original Anthropic reference server) and [`github/github-mcp-server`](https://github.com/github/github-mcp-server) (GitHub's official Go / Docker implementation). Both remain excellent choices where their constraints fit. ## 📄 License diff --git a/docs/superpowers/plans/2026-08-05-github-mcp-server-actions.md b/docs/superpowers/plans/2026-08-05-github-mcp-server-actions.md deleted file mode 100644 index 759c625..0000000 --- a/docs/superpowers/plans/2026-08-05-github-mcp-server-actions.md +++ /dev/null @@ -1,360 +0,0 @@ -# github-mcp-server-js — `actions` Toolset Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development or superpowers:executing-plans. Checkboxes tracked `- [x]` / `- [x]`. - -**Goal:** Add the `actions` toolset (12 tools: 7 read for workflow/run/job/artifact/check introspection + 5 write for triggering/canceling/rerunning workflow runs) to `github-mcp-server-js`. - -**Architecture:** Same pattern as `issues`/`gists`/`orgs_teams`: single `registerActionsTools(server, octokit, permission): void` function; writes gated behind `if (permission === 'read-write')`; raw JSON passthrough. Cross-namespace: uses `actions.*` and one `checks.listForRef` — the design row lists both under `actions, checks`. - -**Tech Stack:** No new dependencies. - -## Global Constraints - -- 12 tools EXACT. 7 read + 5 write. -- Signature: `registerActionsTools(server, octokit, permission): void`. -- Raw JSON passthrough; errors via `toToolError`. -- Write tools inside `if (permission === 'read-write')`. -- List tools use `paginationSchema`. -- Zero collision with 92 existing tool names (verified: `workflow`/`artifact`/`job`/`check_run` prefixes). -- `run_workflow` (createWorkflowDispatch), `cancel_workflow_run`, `rerun_workflow_run`, `rerun_workflow_run_failed_jobs`, `approve_workflow_run` all return 201/204 with no body — synthesize `{ triggered: true }` / `{ cancelled: true }` / `{ rerun_started: true }` / `{ approved: true }` on success (per lock/unlock/star precedent). -- TypeScript only. - ---- - -## File Structure - -``` -src/toolsets/actions.ts, test/unit/toolsets/actions.test.ts, src/server.ts, README.md -``` - ---- - -## Reference: verified octokit shapes - -Confirmed against `octokit.rest.actions.*` / `octokit.rest.checks.*` and `@octokit/openapi-types/types.d.ts`. - -| Tool | octokit method | HTTP | -|---|---|---| -| `list_workflows` | `actions.listRepoWorkflows` | GET `/repos/{owner}/{repo}/actions/workflows` | -| `get_workflow` | `actions.getWorkflow` | GET `/repos/{owner}/{repo}/actions/workflows/{workflow_id}` | -| `list_workflow_runs` | `actions.listWorkflowRuns` | GET `/repos/{owner}/{repo}/actions/workflows/{workflow_id}/runs` | -| `get_workflow_run` | `actions.getWorkflowRun` | GET `/repos/{owner}/{repo}/actions/runs/{run_id}` | -| `list_workflow_run_jobs` | `actions.listJobsForWorkflowRun` | GET `/repos/{owner}/{repo}/actions/runs/{run_id}/jobs` | -| `list_workflow_run_artifacts` | `actions.listWorkflowRunArtifacts` | GET `/repos/{owner}/{repo}/actions/runs/{run_id}/artifacts` | -| `list_check_runs_for_ref` | `checks.listForRef` | GET `/repos/{owner}/{repo}/commits/{ref}/check-runs` | -| `run_workflow` | `actions.createWorkflowDispatch` | POST `/repos/{owner}/{repo}/actions/workflows/{workflow_id}/dispatches` | -| `cancel_workflow_run` | `actions.cancelWorkflowRun` | POST `/repos/{owner}/{repo}/actions/runs/{run_id}/cancel` | -| `rerun_workflow_run` | `actions.reRunWorkflow` | POST `/repos/{owner}/{repo}/actions/runs/{run_id}/rerun` | -| `rerun_workflow_run_failed_jobs` | `actions.reRunWorkflowFailedJobs` | POST `/repos/{owner}/{repo}/actions/runs/{run_id}/rerun-failed-jobs` | -| `approve_workflow_run` | `actions.approveWorkflowRun` | POST `/repos/{owner}/{repo}/actions/runs/{run_id}/approve` | - -**Deliberate scope decisions:** - -1. **All secrets/variables management excluded** (~30 methods): actions.createOrUpdate*Secret, actions.deleteOrgSecret, all `*Secret*`/`*Variable*`/`*PublicKey*` methods. Reason: secret/variable management is high-risk, warrants a dedicated plan. -2. **All runner management excluded** (~20 methods): actions.*SelfHostedRunner*, actions.*HostedRunner*, actions.createRegistrationToken*, actions.createRemoveToken*. Reason: runner lifecycle is admin surface; not aligned with the design's runtime-workflow focus. -3. **Cache management excluded**: actions.deleteActionsCache*, actions.getActionsCache*, actions.getActionsCacheUsage. Reason: cache introspection is niche; keep the workflow-focused surface at 12 tools. -4. **Permissions/OIDC/settings excluded**: actions.getGithubActionsPermissions*, actions.setGithubActionsPermissions*, actions.getCustomOidcSubClaimForRepo, actions.updateOidcCustomSubClaimForRepo. Reason: admin/security-config surface, high risk to expose via LLM. -5. **Log download tools excluded**: actions.downloadJobLogsForWorkflowRun, actions.downloadWorkflowRunLogs, actions.downloadWorkflowRunAttemptLogs. Reason: response bodies are binary (zip archives) which don't fit the raw-JSON passthrough model; would need special-case handling like `render_markdown` — could be a follow-up plan if needed. -6. **Artifact download excluded**: `downloadArtifact`. Reason: same binary-response problem; use the artifact's `archive_download_url` field from `get_artifact` externally. -7. **Delete-workflow-run excluded**: `deleteWorkflowRun`, `deleteWorkflowRunLogs`, `deleteArtifact`. Reason: destructive operations, safer as human-driven. -8. **`enable_workflow`/`disable_workflow` excluded**: `actions.enableWorkflow`, `actions.disableWorkflow`. Reason: rarely-needed admin actions; keep the toolset at 12 tools. -9. **`checks.create`/`checks.update`/`checks.createSuite` excluded**: creating checks is a bot/CI-integration concern, not typical LLM workflow. - ---- - -## Task 1: Implement read tools (7 tools) + tests - -- [x] **Step 1: Write failing tests** - -See `test/unit/toolsets/actions.test.ts` at the bottom of this plan — includes tests for all 12 tools. - -- [x] **Step 2: Create `src/toolsets/actions.ts`** with read tools (register 7 tools before the `if (permission === 'read-write')` block). - -- [x] **Step 3: Run tests** → PASS all read tests + skip-registration tests. - -- [x] **Step 4: Commit**: `git commit -m "feat: add actions toolset read tools"` - -## Task 2: Add write tools + tests - -- [x] **Step 1: Add write tool tests to the same test file.** - -- [x] **Step 2: Add write tools inside the `if (permission === 'read-write')` block in `src/toolsets/actions.ts`.** - -- [x] **Step 3: Run full suite** → 12 tools registered in read-write, 7 in read-only. - -- [x] **Step 4: Commit**: `git commit -m "feat: add actions toolset write tools"` - -## Task 3: Wire + README - -- [x] Alphabetical import for `registerActionsTools`. Add call after `registerCodeSecurityTools`. -- [x] README bullet after `code_security`: `- \`actions\` — GitHub Actions workflows, runs, jobs, artifacts, and check runs` -- [x] `npm test && npm run typecheck && npm run lint && npm run build` all PASS. -- [x] `git commit -m "feat: wire actions toolset into buildServer"` - ---- - -## Full verbatim code for `src/toolsets/actions.ts`: - -```typescript -import type { McpServer } from '@modelcontextprotocol/server'; -import type { Octokit } from 'octokit'; -import { z } from 'zod'; -import { ownerRepoSchema, paginationSchema, toToolResult, toToolError } from './common.js'; - -const workflowIdSchema = { - workflow_id: z.union([z.number().int(), z.string()]).describe('Workflow ID (number) or file name (e.g. "ci.yml").'), -}; - -const runIdSchema = { - run_id: z.number().int().describe('Workflow run ID.'), -}; - -export function registerActionsTools( - server: McpServer, - octokit: Octokit, - permission: 'read-only' | 'read-write', -): void { - server.registerTool( - 'list_workflows', - { - description: 'List workflows in a repository.', - inputSchema: z.object({ ...ownerRepoSchema, ...paginationSchema }), - }, - async ({ owner, repo, page, per_page }) => { - try { - const response = await octokit.rest.actions.listRepoWorkflows({ owner, repo, page, per_page }); - return toToolResult(response.data); - } catch (error) { - return toToolError(error); - } - }, - ); - - server.registerTool( - 'get_workflow', - { - description: 'Get a workflow by ID or filename.', - inputSchema: z.object({ ...ownerRepoSchema, ...workflowIdSchema }), - }, - async ({ owner, repo, workflow_id }) => { - try { - const response = await octokit.rest.actions.getWorkflow({ owner, repo, workflow_id }); - return toToolResult(response.data); - } catch (error) { - return toToolError(error); - } - }, - ); - - server.registerTool( - 'list_workflow_runs', - { - description: 'List runs for a workflow.', - inputSchema: z.object({ - ...ownerRepoSchema, - ...workflowIdSchema, - actor: z.string().optional(), - branch: z.string().optional(), - event: z.string().optional(), - status: z.enum([ - 'completed', 'action_required', 'cancelled', 'failure', 'neutral', - 'skipped', 'stale', 'success', 'timed_out', 'in_progress', 'queued', - 'requested', 'waiting', 'pending', - ]).optional(), - ...paginationSchema, - }), - }, - async ({ owner, repo, workflow_id, actor, branch, event, status, page, per_page }) => { - try { - const response = await octokit.rest.actions.listWorkflowRuns({ - owner, repo, workflow_id, actor, branch, event, status, page, per_page, - }); - return toToolResult(response.data); - } catch (error) { - return toToolError(error); - } - }, - ); - - server.registerTool( - 'get_workflow_run', - { - description: 'Get a workflow run by ID.', - inputSchema: z.object({ ...ownerRepoSchema, ...runIdSchema }), - }, - async ({ owner, repo, run_id }) => { - try { - const response = await octokit.rest.actions.getWorkflowRun({ owner, repo, run_id }); - return toToolResult(response.data); - } catch (error) { - return toToolError(error); - } - }, - ); - - server.registerTool( - 'list_workflow_run_jobs', - { - description: 'List jobs for a workflow run.', - inputSchema: z.object({ - ...ownerRepoSchema, - ...runIdSchema, - filter: z.enum(['latest', 'all']).optional(), - ...paginationSchema, - }), - }, - async ({ owner, repo, run_id, filter, page, per_page }) => { - try { - const response = await octokit.rest.actions.listJobsForWorkflowRun({ - owner, repo, run_id, filter, page, per_page, - }); - return toToolResult(response.data); - } catch (error) { - return toToolError(error); - } - }, - ); - - server.registerTool( - 'list_workflow_run_artifacts', - { - description: 'List artifacts produced by a workflow run.', - inputSchema: z.object({ ...ownerRepoSchema, ...runIdSchema, ...paginationSchema }), - }, - async ({ owner, repo, run_id, page, per_page }) => { - try { - const response = await octokit.rest.actions.listWorkflowRunArtifacts({ - owner, repo, run_id, page, per_page, - }); - return toToolResult(response.data); - } catch (error) { - return toToolError(error); - } - }, - ); - - server.registerTool( - 'list_check_runs_for_ref', - { - description: 'List check runs for a Git ref (SHA, branch, or tag).', - inputSchema: z.object({ - ...ownerRepoSchema, - ref: z.string().describe('SHA, branch, or tag to list check runs for.'), - check_name: z.string().optional(), - status: z.enum(['queued', 'in_progress', 'completed']).optional(), - filter: z.enum(['latest', 'all']).optional(), - ...paginationSchema, - }), - }, - async ({ owner, repo, ref, check_name, status, filter, page, per_page }) => { - try { - const response = await octokit.rest.checks.listForRef({ - owner, repo, ref, check_name, status, filter, page, per_page, - }); - return toToolResult(response.data); - } catch (error) { - return toToolError(error); - } - }, - ); - - if (permission === 'read-write') { - server.registerTool( - 'run_workflow', - { - description: 'Trigger a workflow_dispatch event for a workflow.', - inputSchema: z.object({ - ...ownerRepoSchema, - ...workflowIdSchema, - ref: z.string().describe('Git ref (branch or tag) to run the workflow on.'), - inputs: z.record(z.string(), z.unknown()).optional().describe('Workflow inputs (max 10 keys).'), - }), - }, - async ({ owner, repo, workflow_id, ref, inputs }) => { - try { - await octokit.rest.actions.createWorkflowDispatch({ owner, repo, workflow_id, ref, inputs }); - return toToolResult({ triggered: true }); - } catch (error) { - return toToolError(error); - } - }, - ); - - server.registerTool( - 'cancel_workflow_run', - { - description: 'Cancel a workflow run.', - inputSchema: z.object({ ...ownerRepoSchema, ...runIdSchema }), - }, - async ({ owner, repo, run_id }) => { - try { - await octokit.rest.actions.cancelWorkflowRun({ owner, repo, run_id }); - return toToolResult({ cancelled: true }); - } catch (error) { - return toToolError(error); - } - }, - ); - - server.registerTool( - 'rerun_workflow_run', - { - description: 'Re-run a workflow run.', - inputSchema: z.object({ - ...ownerRepoSchema, - ...runIdSchema, - enable_debug_logging: z.boolean().optional(), - }), - }, - async ({ owner, repo, run_id, enable_debug_logging }) => { - try { - await octokit.rest.actions.reRunWorkflow({ - owner, repo, run_id, enable_debug_logging, - }); - return toToolResult({ rerun_started: true }); - } catch (error) { - return toToolError(error); - } - }, - ); - - server.registerTool( - 'rerun_workflow_run_failed_jobs', - { - description: 'Re-run only the failed jobs in a workflow run.', - inputSchema: z.object({ - ...ownerRepoSchema, - ...runIdSchema, - enable_debug_logging: z.boolean().optional(), - }), - }, - async ({ owner, repo, run_id, enable_debug_logging }) => { - try { - await octokit.rest.actions.reRunWorkflowFailedJobs({ - owner, repo, run_id, enable_debug_logging, - }); - return toToolResult({ rerun_started: true }); - } catch (error) { - return toToolError(error); - } - }, - ); - - server.registerTool( - 'approve_workflow_run', - { - description: 'Approve a workflow run that is awaiting fork-PR approval.', - inputSchema: z.object({ ...ownerRepoSchema, ...runIdSchema }), - }, - async ({ owner, repo, run_id }) => { - try { - await octokit.rest.actions.approveWorkflowRun({ owner, repo, run_id }); - return toToolResult({ approved: true }); - } catch (error) { - return toToolError(error); - } - }, - ); - } -} -``` diff --git a/docs/superpowers/plans/2026-08-05-github-mcp-server-activity.md b/docs/superpowers/plans/2026-08-05-github-mcp-server-activity.md deleted file mode 100644 index ce5b441..0000000 --- a/docs/superpowers/plans/2026-08-05-github-mcp-server-activity.md +++ /dev/null @@ -1,860 +0,0 @@ -# github-mcp-server-js — `activity` Toolset Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax for tracking. - -**Goal:** Add the `activity` toolset (5 tools covering notifications and repo starring) to `github-mcp-server-js`, following the exact structural, permission-gating, and response/error pattern established by the `repos`, `issues`, `pull_requests`, `search`, `users`, and `gists` toolsets. - -**Architecture:** Same pattern as prior toolsets: one `registerActivityTools(server, octokit, permission)` function registers each tool with `server.registerTool(name, {description, inputSchema}, handler)`; every handler wraps its octokit call in try/catch, returning raw JSON via `toToolResult`/`toToolError`; write tools are registered only when `permission === 'read-write'`; `server.ts` gains one more registration call. All octokit calls use the `activity` namespace (`octokit.rest.activity.*`). - -**Tech Stack:** Same as prior plans — TypeScript, `@modelcontextprotocol/server@^2.0.0`, `octokit@^5.0.5`, `zod@^4.2.0`, `vitest`, `nock`. No new dependencies. - -## Global Constraints - -- Tool responses return the raw octokit response body as JSON, unmodified. No field trimming, no text summarization. -- Errors propagate unmodified: catch the octokit `RequestError`, surface `error.message` in an MCP tool error result. No normalization layer. -- List tools take explicit `page` (default `1`) and `per_page` (default `30`, max `100`) parameters. No auto-pagination. -- `GITHUB_PERMISSION=read-only` must prevent write-tool handlers from ever being registered with `McpServer` — write tools must be inside `if (permission === 'read-write') { ... }`, not gated at call time. -- `star_repo` and `unstar_repo` return `204 No Content` (confirmed: `responses: { 204: { content: never } }` in `@octokit/openapi-types`). Both handlers return a synthetic result — `{ starred: true }` and `{ starred: false }` respectively — following the same deliberate exception used by `lock_issue`/`unlock_issue`/`delete_gist`. There is no response body to pass through. -- `check_repo_starred` returns `204 No Content` (starred) **or** `404 Not Found` (not starred) — both are valid non-error outcomes. The handler must catch 404 without surfacing it as an MCP tool error; instead return `{ starred: false }`. Any other error (401, 403, etc.) is still surfaced via `toToolError`. This dual-status pattern is unique to this toolset. -- `list_notifications` has a GitHub-documented cap of `per_page` max 50 (not 100 as in other endpoints). The Zod schema must reflect `.max(50)` to avoid a GitHub 422 validation error. -- No modification to `common.ts` — all parameters in this toolset are activity-specific and no other existing toolset shares them. -- TypeScript only, no new runtime dependencies. - ---- - -## Tool Selection and Collision Check - -**Tools chosen (5 total):** - -| Tool | Read/Write | octokit method | HTTP | -|---|---|---|---| -| `list_notifications` | read | `activity.listNotificationsForAuthenticatedUser` | GET `/notifications` | -| `list_starred_repos` | read | `activity.listReposStarredByAuthenticatedUser` | GET `/user/starred` | -| `check_repo_starred` | read | `activity.checkRepoIsStarredByAuthenticatedUser` | GET `/user/starred/{owner}/{repo}` | -| `star_repo` | write | `activity.starRepoForAuthenticatedUser` | PUT `/user/starred/{owner}/{repo}` | -| `unstar_repo` | write | `activity.unstarRepoForAuthenticatedUser` | DELETE `/user/starred/{owner}/{repo}` | - -**Read/write split:** 3 read tools + 2 write tools. - -**Collision check against all 45 existing tool names** (8 `repos` + 12 `issues` + 10 `pull_requests` + 5 `search` + 5 `users` + 5 `gists`): - -Existing names: `add_comment`, `add_labels`, `create_gist`, `create_issue`, `create_or_update_file`, `create_pull_request`, `create_pull_request_review`, `delete_gist`, `get_authenticated_user`, `get_branch`, `get_commit`, `get_file_contents`, `get_gist`, `get_issue`, `get_pull_request`, `get_repository`, `get_user_by_username`, `get_user_hovercard`, `list_branches`, `list_comments`, `list_commits`, `list_gists`, `list_issues`, `list_labels`, `list_labels_on_issue`, `list_pull_request_commits`, `list_pull_request_files`, `list_pull_request_reviews`, `list_pull_requests`, `list_tags`, `list_user_followers`, `list_user_following`, `lock_issue`, `merge_pull_request`, `remove_label`, `request_reviewers`, `search_code`, `search_commits`, `search_issues`, `search_repos`, `search_users`, `unlock_issue`, `update_gist`, `update_issue`, `update_pull_request`. - -**Result: zero collisions.** `list_notifications`, `list_starred_repos`, `check_repo_starred`, `star_repo`, and `unstar_repo` are all distinct from every existing name. The `star_repo`/`unstar_repo` names deliberately differ from `list_starred_repos` to avoid ambiguity (the action vs. the list), and from `check_repo_starred` to distinguish mutation from inspection. - ---- - -## Verified octokit `activity` namespace shapes - -Confirmed directly against the installed `@octokit/plugin-rest-endpoint-methods` endpoint table and `@octokit/openapi-types/types.d.ts`. - -| Tool | octokit call | Key parameters | Response shape | -|---|---|---|---| -| `list_notifications` | `activity.listNotificationsForAuthenticatedUser({ all?, participating?, since?, before?, page?, per_page? })` | `page`, `per_page` (max **50**), `all`, `participating` | `thread[]` (200) | -| `list_starred_repos` | `activity.listReposStarredByAuthenticatedUser({ sort?, direction?, page?, per_page? })` | `sort` (`created`/`updated`), `direction` (`asc`/`desc`), pagination | `repository[]` (200) | -| `check_repo_starred` | `activity.checkRepoIsStarredByAuthenticatedUser({ owner, repo })` | `owner`, `repo` | 204 (starred) OR 404 (not starred) | -| `star_repo` | `activity.starRepoForAuthenticatedUser({ owner, repo })` | `owner`, `repo` | 204 No Content | -| `unstar_repo` | `activity.unstarRepoForAuthenticatedUser({ owner, repo })` | `owner`, `repo` | 204 No Content | - -**Octokit method naming notes (verified via `octokit.rest.activity[name].endpoint.DEFAULTS`):** -- `listNotificationsForAuthenticatedUser` (not `listNotifications`) — the method name includes "ForAuthenticatedUser" suffix. -- `listReposStarredByAuthenticatedUser` (not `listStarred` or `listStarredRepos`) — full descriptive name. -- `checkRepoIsStarredByAuthenticatedUser` (not `checkStarred`) — full descriptive name. -- `starRepoForAuthenticatedUser` / `unstarRepoForAuthenticatedUser` — both include "ForAuthenticatedUser" suffix. - -**`per_page` cap for `list_notifications`:** GitHub's OpenAPI spec defines the notifications endpoint `per_page` as a max-50 field (not the standard max-100 used by most list endpoints). This is confirmed in `@octokit/openapi-types/types.d.ts` line 88615: `/** @description The number of results per page (max 50). */`. The Zod schema must use `.max(50)` for this tool only — do NOT use `paginationSchema` spread for `list_notifications`. - -**`list_starred_repos` content negotiation note:** The `200` response in `@octokit/openapi-types` shows two content types: `application/json` → `repository[]` and `application/vnd.github.v3.star+json` → `starred-repository[]`. Octokit's default Accept header resolves to `application/json`, so `response.data` will be the `repository[]` shape (full repository objects with `id`, `name`, `full_name`, `owner`, `description`, `html_url`, `stargazers_count`, etc.). No special Accept header manipulation is needed. - -**`check_repo_starred` dual-status handling:** The endpoint communicates its answer entirely via HTTP status code: 204 = starred, 404 = not starred. Both are valid, non-error outcomes. The implementation must: -1. Call `octokit.rest.activity.checkRepoIsStarredByAuthenticatedUser({ owner, repo })`. -2. On success (204): return `toToolResult({ starred: true })`. -3. In the catch block: inspect the error. If it is an `RequestError` with `status === 404`, return `toToolResult({ starred: false })` — do NOT call `toToolError`. Re-throw (or call `toToolError`) for any other status code. - -This is the only tool in the `activity` toolset with catch-block branching logic. The pattern follows the spec requirement: "handle both without throwing (catch 404, return `{ starred: false }`)". - ---- - -## File Structure - -``` -github-mcp-server-js/ - src/ - toolsets/ - activity.ts # NEW: registerActivityTools(server, octokit, permission) - server.ts # MODIFIED: calls registerActivityTools - test/ - unit/ - toolsets/ - activity.test.ts # NEW: mirrors gists.test.ts's 3-task structure -``` - -`common.ts` is NOT modified — no parameter in this toolset is shared with 2+ other toolsets. `ownerRepoSchema` from `common.ts` covers `owner`/`repo` but is already used only by `repos.ts`, `issues.ts`, and `pull_requests.ts`. Since `activity.ts` needs `owner`/`repo` too, this is exactly 2 toolsets already using it if we count activity — however the prompt constraint says "Zero modification to common.ts unless truly shared with 2+ toolsets". Since `ownerRepoSchema` is already exported from `common.ts` and already shared, `activity.ts` **may** import and reuse `ownerRepoSchema` from `./common.js` without modifying `common.ts`. No new exports to `common.ts` are added. - ---- - -## Task 1: Implement the `activity` toolset — read tools (`list_notifications`, `list_starred_repos`, `check_repo_starred`) and their tests - -**Files:** -- Create: `src/toolsets/activity.ts` (read tools only; write tools stubbed as empty `if` block with comment) -- Create: `test/unit/toolsets/activity.test.ts` (read-tool tests only) - -**Interfaces:** -- Consumes: `ownerRepoSchema`, `toToolResult`, `toToolError` from `./common.js` (already exist, no modification needed). `paginationSchema` is NOT used for `list_notifications` (different `per_page` cap); it IS used for `list_starred_repos`. -- Produces (for Tasks 2 and 3 to extend): `registerActivityTools(server: McpServer, octokit: Octokit, permission: 'read-only' | 'read-write'): void` - -- [x] **Step 1: Write the failing tests for the read tools** - -Create `test/unit/toolsets/activity.test.ts`: - -```typescript -import nock from 'nock'; -import { afterEach, describe, expect, it } from 'vitest'; -import { registerActivityTools } from '../../../src/toolsets/activity.js'; -import { connectedClient } from './test-helpers.js'; - -describe('registerActivityTools', () => { - afterEach(() => { - nock.cleanAll(); - }); - - it('registers list_notifications and returns the raw GitHub response as JSON', async () => { - nock('https://api.github.com') - .get('/notifications') - .query({ page: '1', per_page: '30', all: 'false' }) - .reply(200, [ - { - id: '1', - unread: true, - reason: 'subscribed', - updated_at: '2024-09-25T07:54:00Z', - subject: { - title: 'Greetings', - type: 'Issue', - url: 'https://api.github.com/repos/octokit/octokit.rb/issues/123', - }, - repository: { - id: 1296269, - name: 'Hello-World', - full_name: 'octocat/Hello-World', - }, - }, - ]); - - const client = await connectedClient(registerActivityTools, 'read-write'); - const result = await client.callTool({ - name: 'list_notifications', - arguments: {}, - }); - - expect(result.isError).toBeFalsy(); - const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ''; - const parsed = JSON.parse(text) as Array<{ id: string; unread: boolean }>; - expect(parsed).toHaveLength(1); - expect(parsed[0]).toMatchObject({ id: '1', unread: true, reason: 'subscribed' }); - }); - - it('forwards explicit page and per_page to list_notifications on the wire', async () => { - const scope = nock('https://api.github.com') - .get('/notifications') - .query({ page: '2', per_page: '10', all: 'false' }) - .reply(200, []); - - const client = await connectedClient(registerActivityTools, 'read-write'); - const result = await client.callTool({ - name: 'list_notifications', - arguments: { page: 2, per_page: 10 }, - }); - - expect(result.isError).toBeFalsy(); - expect(scope.isDone()).toBe(true); - }); - - it('forwards all=true to list_notifications on the wire', async () => { - const scope = nock('https://api.github.com') - .get('/notifications') - .query({ page: '1', per_page: '30', all: 'true' }) - .reply(200, []); - - const client = await connectedClient(registerActivityTools, 'read-write'); - const result = await client.callTool({ - name: 'list_notifications', - arguments: { all: true }, - }); - - expect(result.isError).toBeFalsy(); - expect(scope.isDone()).toBe(true); - }); - - it('propagates a 401 from list_notifications as an MCP tool error', async () => { - nock('https://api.github.com') - .get('/notifications') - .query(true) - .reply(401, { - message: 'Requires authentication', - documentation_url: 'https://docs.github.com/rest', - }); - - const client = await connectedClient(registerActivityTools, 'read-write'); - const result = await client.callTool({ - name: 'list_notifications', - arguments: {}, - }); - - expect(result.isError).toBe(true); - const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ''; - expect(text).toContain('Requires authentication'); - }); - - it('registers list_starred_repos and returns the raw GitHub response as JSON', async () => { - nock('https://api.github.com') - .get('/user/starred') - .query({ page: '1', per_page: '30' }) - .reply(200, [ - { - id: 1296269, - name: 'Hello-World', - full_name: 'octocat/Hello-World', - html_url: 'https://github.com/octocat/Hello-World', - description: 'This your first repo!', - stargazers_count: 80, - private: false, - }, - ]); - - const client = await connectedClient(registerActivityTools, 'read-write'); - const result = await client.callTool({ - name: 'list_starred_repos', - arguments: {}, - }); - - expect(result.isError).toBeFalsy(); - const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ''; - expect(JSON.parse(text)).toMatchObject([ - { id: 1296269, full_name: 'octocat/Hello-World' }, - ]); - }); - - it('forwards sort and direction to list_starred_repos on the wire', async () => { - const scope = nock('https://api.github.com') - .get('/user/starred') - .query({ page: '1', per_page: '30', sort: 'updated', direction: 'asc' }) - .reply(200, []); - - const client = await connectedClient(registerActivityTools, 'read-write'); - const result = await client.callTool({ - name: 'list_starred_repos', - arguments: { sort: 'updated', direction: 'asc' }, - }); - - expect(result.isError).toBeFalsy(); - expect(scope.isDone()).toBe(true); - }); - - it('registers check_repo_starred and returns { starred: true } when the repo is starred', async () => { - nock('https://api.github.com') - .get('/user/starred/octocat/Hello-World') - .reply(204); - - const client = await connectedClient(registerActivityTools, 'read-write'); - const result = await client.callTool({ - name: 'check_repo_starred', - arguments: { owner: 'octocat', repo: 'Hello-World' }, - }); - - expect(result.isError).toBeFalsy(); - const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ''; - expect(JSON.parse(text)).toEqual({ starred: true }); - }); - - it('returns { starred: false } (not an error) when the repo is not starred (404)', async () => { - nock('https://api.github.com') - .get('/user/starred/octocat/Hello-World') - .reply(404, { - message: 'Not Found', - documentation_url: 'https://docs.github.com/rest', - }); - - const client = await connectedClient(registerActivityTools, 'read-write'); - const result = await client.callTool({ - name: 'check_repo_starred', - arguments: { owner: 'octocat', repo: 'Hello-World' }, - }); - - expect(result.isError).toBeFalsy(); - const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ''; - expect(JSON.parse(text)).toEqual({ starred: false }); - }); - - it('propagates a 401 from check_repo_starred as an MCP tool error', async () => { - nock('https://api.github.com') - .get('/user/starred/octocat/Hello-World') - .reply(401, { - message: 'Requires authentication', - documentation_url: 'https://docs.github.com/rest', - }); - - const client = await connectedClient(registerActivityTools, 'read-write'); - const result = await client.callTool({ - name: 'check_repo_starred', - arguments: { owner: 'octocat', repo: 'Hello-World' }, - }); - - expect(result.isError).toBe(true); - const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ''; - expect(text).toContain('Requires authentication'); - }); - - it('registers exactly 3 read tools in read-only mode', async () => { - const client = await connectedClient(registerActivityTools, 'read-only'); - const { tools } = await client.listTools(); - expect(tools.map((t) => t.name).sort()).toEqual([ - 'check_repo_starred', - 'list_notifications', - 'list_starred_repos', - ]); - }); -}); -``` - -- [x] **Step 2: Run tests to verify they fail** - -Run: `npm test -- activity` -Expected: FAIL with a module-not-found error for `../../../src/toolsets/activity.js`. - -- [x] **Step 3: Create `src/toolsets/activity.ts` with the 3 read tools** - -```typescript -import type { McpServer } from '@modelcontextprotocol/server'; -import type { RequestError } from '@octokit/request-error'; -import type { Octokit } from 'octokit'; -import { z } from 'zod'; -import { ownerRepoSchema, paginationSchema, toToolError, toToolResult } from './common.js'; - -export function registerActivityTools( - server: McpServer, - octokit: Octokit, - permission: 'read-only' | 'read-write', -): void { - server.registerTool( - 'list_notifications', - { - description: - 'List notifications for the authenticated user. Returns an array of thread objects representing unread (or all) notifications. Each thread includes the subject (title, type, URL), repository, reason, and updated_at timestamp. Use all=true to include already-read notifications. Paginate with page and per_page (max 50 per page — GitHub caps this endpoint at 50, not the usual 100).', - inputSchema: z.object({ - all: z - .boolean() - .optional() - .default(false) - .describe( - 'If true, return all notifications including already-read ones. If false (default), return only unread notifications.', - ), - participating: z - .boolean() - .optional() - .describe( - 'If true, return only notifications in which the authenticated user is directly participating or mentioned.', - ), - since: z - .string() - .optional() - .describe( - 'Only show notifications updated after the given time. ISO 8601 format: YYYY-MM-DDTHH:MM:SSZ.', - ), - before: z - .string() - .optional() - .describe( - 'Only show notifications updated before the given time. ISO 8601 format: YYYY-MM-DDTHH:MM:SSZ.', - ), - page: z.number().int().min(1).default(1), - per_page: z - .number() - .int() - .min(1) - .max(50) - .default(30) - .describe('Number of results per page. Maximum 50 (GitHub cap for this endpoint).'), - }), - }, - async ({ all, participating, since, before, page, per_page }) => { - try { - const response = await octokit.rest.activity.listNotificationsForAuthenticatedUser({ - all, - participating, - since, - before, - page, - per_page, - }); - return toToolResult(response.data); - } catch (error) { - return toToolError(error); - } - }, - ); - - server.registerTool( - 'list_starred_repos', - { - description: - 'List repositories starred by the authenticated user. Returns an array of repository objects including id, name, full_name, html_url, description, stargazers_count, language, and owner. Sort by created (when the user starred it) or updated (when the repo was last pushed to). Paginate with page and per_page.', - inputSchema: z.object({ - sort: z - .enum(['created', 'updated']) - .optional() - .describe( - 'Sort starred repositories by created (date the authenticated user starred the repo, default) or updated (date the repo was last pushed to).', - ), - direction: z - .enum(['asc', 'desc']) - .optional() - .describe('Sort direction: asc or desc. Default is desc.'), - ...paginationSchema, - }), - }, - async ({ sort, direction, page, per_page }) => { - try { - const response = await octokit.rest.activity.listReposStarredByAuthenticatedUser({ - sort, - direction, - page, - per_page, - }); - return toToolResult(response.data); - } catch (error) { - return toToolError(error); - } - }, - ); - - server.registerTool( - 'check_repo_starred', - { - description: - 'Check whether the authenticated user has starred a given repository. Returns { starred: true } if the repository is starred, or { starred: false } if it is not. Never returns an error for a 404 (not-starred) response — only errors on authentication failures (401/403) or truly unexpected conditions.', - inputSchema: z.object({ - ...ownerRepoSchema, - }), - }, - async ({ owner, repo }) => { - try { - await octokit.rest.activity.checkRepoIsStarredByAuthenticatedUser({ owner, repo }); - return toToolResult({ starred: true }); - } catch (error) { - const reqError = error as RequestError; - if (reqError.status === 404) { - return toToolResult({ starred: false }); - } - return toToolError(error); - } - }, - ); - - if (permission === 'read-write') { - // Write tools added in Task 2. - } -} -``` - -**Note on the empty `if` block:** If `eslint` reports a `no-empty` warning for the placeholder comment block, remove the `if` block entirely and re-add it in Task 2 when the write-tool bodies are inserted. Do not add an ESLint disable comment — remove and restore instead. - -**Note on `RequestError` import:** `@octokit/request-error` is a transitive dependency of `octokit` and is available without adding it to `package.json`. The type import (`import type { RequestError }`) is TypeScript-only and does not appear in the compiled output. If the TypeScript compiler cannot resolve this import, use `(error as { status?: number }).status === 404` inline as a fallback — this is equivalent and avoids the import. - -- [x] **Step 4: Run tests to verify they pass** - -Run: `npm test -- activity` -Expected: PASS (9 tests). - -- [x] **Step 5: Typecheck and lint** - -Run: `npm run typecheck && npm run lint` -Expected: both PASS with zero errors. - -- [x] **Step 6: Stage `cspell.json` if any test fixture strings trigger cspell errors** - -The test file uses `'octocat'` (already in `cspell.json`), `'subscribed'` (common English word), `'stargazers'` (compound, may be flagged), and `'unread'` (common). If cspell flags `stargazers` or any other fixture string at commit time, add it to the `words` array in `cspell.json` and stage it in the same commit. The current `cspell.json` already has `"octocat"` in its `words` array. - -- [x] **Step 7: Commit** - -```bash -git add src/toolsets/activity.ts test/unit/toolsets/activity.test.ts -git commit -m "feat: add activity toolset read tools (list_notifications, list_starred_repos, check_repo_starred)" -``` - ---- - -## Task 2: Implement the `activity` toolset — write tools (`star_repo`, `unstar_repo`) and their tests - -**Files:** -- Modify: `src/toolsets/activity.ts` (replace the `if (permission === 'read-write') { }` placeholder with real write-tool bodies) -- Modify: `test/unit/toolsets/activity.test.ts` (append write-tool and permission-gate tests inside the existing `describe` block) - -**Interfaces:** -- Consumes: same `registerActivityTools` function from Task 1 — this task adds write tools to it, not replaces it. -- Produces: `star_repo` and `unstar_repo` are registered only when `permission === 'read-write'`. - -- [x] **Step 1: Write the failing tests for the write tools** - -Append the following tests inside the existing `describe('registerActivityTools', ...)` block in `test/unit/toolsets/activity.test.ts`, right before the closing `});`: - -```typescript - it('registers star_repo and returns { starred: true }', async () => { - nock('https://api.github.com') - .put('/user/starred/octocat/Hello-World') - .reply(204); - - const client = await connectedClient(registerActivityTools, 'read-write'); - const result = await client.callTool({ - name: 'star_repo', - arguments: { owner: 'octocat', repo: 'Hello-World' }, - }); - - expect(result.isError).toBeFalsy(); - const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ''; - expect(JSON.parse(text)).toEqual({ starred: true }); - }); - - it('propagates a 404 from star_repo as an MCP tool error', async () => { - nock('https://api.github.com') - .put('/user/starred/octocat/nonexistent-repo-xyz') - .reply(404, { - message: 'Not Found', - documentation_url: 'https://docs.github.com/rest', - }); - - const client = await connectedClient(registerActivityTools, 'read-write'); - const result = await client.callTool({ - name: 'star_repo', - arguments: { owner: 'octocat', repo: 'nonexistent-repo-xyz' }, - }); - - expect(result.isError).toBe(true); - const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ''; - expect(text).toContain('Not Found'); - }); - - it('registers unstar_repo and returns { starred: false }', async () => { - nock('https://api.github.com') - .delete('/user/starred/octocat/Hello-World') - .reply(204); - - const client = await connectedClient(registerActivityTools, 'read-write'); - const result = await client.callTool({ - name: 'unstar_repo', - arguments: { owner: 'octocat', repo: 'Hello-World' }, - }); - - expect(result.isError).toBeFalsy(); - const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ''; - expect(JSON.parse(text)).toEqual({ starred: false }); - }); - - it('propagates a 401 from unstar_repo as an MCP tool error', async () => { - nock('https://api.github.com') - .delete('/user/starred/octocat/Hello-World') - .reply(401, { - message: 'Requires authentication', - documentation_url: 'https://docs.github.com/rest', - }); - - const client = await connectedClient(registerActivityTools, 'read-write'); - const result = await client.callTool({ - name: 'unstar_repo', - arguments: { owner: 'octocat', repo: 'Hello-World' }, - }); - - expect(result.isError).toBe(true); - const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ''; - expect(text).toContain('Requires authentication'); - }); - - it('does not register any write tool in read-only mode', async () => { - const client = await connectedClient(registerActivityTools, 'read-only'); - const { tools } = await client.listTools(); - const names = tools.map((t) => t.name); - expect(names).not.toContain('star_repo'); - expect(names).not.toContain('unstar_repo'); - }); - - it('registers all 5 activity tools in read-write mode', async () => { - const client = await connectedClient(registerActivityTools, 'read-write'); - const { tools } = await client.listTools(); - expect(tools.map((t) => t.name).sort()).toEqual([ - 'check_repo_starred', - 'list_notifications', - 'list_starred_repos', - 'star_repo', - 'unstar_repo', - ]); - }); -``` - -- [x] **Step 2: Run tests to verify they fail** - -Run: `npm test -- activity` -Expected: FAIL — the 4 write-tool tests fail with "Tool not found" (`ProtocolError`); the permission/count tests may fail depending on assertion direction. Re-run after Step 3. - -- [x] **Step 3: Add the write tools to `src/toolsets/activity.ts`** - -Replace the `if (permission === 'read-write') { // Write tools added in Task 2. }` placeholder (or add the block at the end of `registerActivityTools` if Task 1's lint step removed it) with: - -```typescript - if (permission === 'read-write') { - server.registerTool( - 'star_repo', - { - description: - 'Star a repository on behalf of the authenticated user. Starring marks a repository as interesting and adds it to the authenticated user\'s starred list (visible via list_starred_repos). Returns { starred: true } on success. Returns an error if the repository does not exist or the token lacks sufficient scope.', - inputSchema: z.object({ - ...ownerRepoSchema, - }), - }, - async ({ owner, repo }) => { - try { - await octokit.rest.activity.starRepoForAuthenticatedUser({ owner, repo }); - return toToolResult({ starred: true }); - } catch (error) { - return toToolError(error); - } - }, - ); - - server.registerTool( - 'unstar_repo', - { - description: - 'Unstar a repository that the authenticated user has previously starred. Removes the repository from the authenticated user\'s starred list. Returns { starred: false } on success. This is a no-op if the repository was not already starred (GitHub returns 204 either way), so the result is always { starred: false } on a 204 response.', - inputSchema: z.object({ - ...ownerRepoSchema, - }), - }, - async ({ owner, repo }) => { - try { - await octokit.rest.activity.unstarRepoForAuthenticatedUser({ owner, repo }); - return toToolResult({ starred: false }); - } catch (error) { - return toToolError(error); - } - }, - ); - } -``` - -- [x] **Step 4: Run tests to verify they pass** - -Run: `npm test -- activity` -Expected: PASS (15 tests: 9 from Task 1 + 6 new). - -- [x] **Step 5: Typecheck, lint, and full suite** - -Run: `npm run typecheck && npm run lint && npm test` -Expected: all PASS. The full suite total is 94 (prior total after `gists`) + 15 = 109 tests. - -- [x] **Step 6: Stage `cspell.json` if needed** - -If any string in the test file triggers a cspell failure at commit time, add the offending word to `cspell.json`'s `words` array and stage it alongside the source files: - -```bash -git add cspell.json # only if cspell.json was modified -git add src/toolsets/activity.ts test/unit/toolsets/activity.test.ts -git commit -m "feat: add activity toolset write tools (star_repo, unstar_repo)" -``` - -If `cspell.json` was not modified, omit it from the staging command. - ---- - -## Task 3: Wire `registerActivityTools` into `server.ts` and update README - -**Files:** -- Modify: `src/server.ts` -- Modify: `README.md` - -**Interfaces:** -- Consumes: `registerActivityTools(server, octokit, permission)` from Task 2. -- Produces: nothing new — this is the final integration point. - -- [x] **Step 1: Modify `src/server.ts`** - -Current content: - -```typescript -import { McpServer } from '@modelcontextprotocol/server'; -import type { Octokit } from 'octokit'; -import { registerGistsTools } from './toolsets/gists.js'; -import { registerIssuesTools } from './toolsets/issues.js'; -import { registerPullRequestsTools } from './toolsets/pull_requests.js'; -import { registerReposTools } from './toolsets/repos.js'; -import { registerSearchTools } from './toolsets/search.js'; -import { registerUsersTools } from './toolsets/users.js'; - -const SERVER_NAME = 'github-mcp-server-js'; -const SERVER_VERSION = '0.1.0'; - -export function buildServer( - octokit: Octokit, - permission: 'read-only' | 'read-write', -): McpServer { - const server = new McpServer({ name: SERVER_NAME, version: SERVER_VERSION }); - - registerReposTools(server, octokit, permission); - registerIssuesTools(server, octokit, permission); - registerPullRequestsTools(server, octokit, permission); - registerSearchTools(server, octokit, permission); - registerUsersTools(server, octokit, permission); - registerGistsTools(server, octokit, permission); - - return server; -} -``` - -Replace with (new import sorted alphabetically alongside existing imports): - -```typescript -import { McpServer } from '@modelcontextprotocol/server'; -import type { Octokit } from 'octokit'; -import { registerActivityTools } from './toolsets/activity.js'; -import { registerGistsTools } from './toolsets/gists.js'; -import { registerIssuesTools } from './toolsets/issues.js'; -import { registerPullRequestsTools } from './toolsets/pull_requests.js'; -import { registerReposTools } from './toolsets/repos.js'; -import { registerSearchTools } from './toolsets/search.js'; -import { registerUsersTools } from './toolsets/users.js'; - -const SERVER_NAME = 'github-mcp-server-js'; -const SERVER_VERSION = '0.1.0'; - -export function buildServer( - octokit: Octokit, - permission: 'read-only' | 'read-write', -): McpServer { - const server = new McpServer({ name: SERVER_NAME, version: SERVER_VERSION }); - - registerReposTools(server, octokit, permission); - registerIssuesTools(server, octokit, permission); - registerPullRequestsTools(server, octokit, permission); - registerSearchTools(server, octokit, permission); - registerUsersTools(server, octokit, permission); - registerGistsTools(server, octokit, permission); - registerActivityTools(server, octokit, permission); - - return server; -} -``` - -- [x] **Step 2: Run the full test suite** - -Run: `npm test` -Expected: PASS, same 109 tests as after Task 2. - -- [x] **Step 3: Typecheck, lint, and build** - -Run: `npm run typecheck && npm run lint && npm run build` -Expected: all PASS. The build step confirms `dist/cli.js`'s bundled dependency graph transitively includes `activity.ts`. - -- [x] **Step 4: Update `README.md`'s Toolsets section** - -Append to the "Currently implemented" list (after the existing `gists` bullet): - -```markdown -- `activity` — list notifications, list starred repos, check/star/unstar a repository -``` - -- [x] **Step 5: Manual end-to-end smoke test** - -Run: -```bash -GITHUB_TOKEN=fake-token node dist/cli.js --transport=http --port=3996 & -sleep 1 -curl -s -D /tmp/mcp-activity-init-headers.txt -X POST http://localhost:3996/mcp \ - -H 'Content-Type: application/json' \ - -H 'Accept: application/json, text/event-stream' \ - -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"smoke-test","version":"0.0.0"}}}' -SESSION=$(grep -i mcp-session-id /tmp/mcp-activity-init-headers.txt | awk '{print $2}' | tr -d '\r') -curl -s -X POST http://localhost:3996/mcp \ - -H 'Content-Type: application/json' \ - -H 'Accept: application/json, text/event-stream' \ - -H "Mcp-Session-Id: $SESSION" \ - -d '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' -kill %1 -``` - -Expected: the `tools/list` response includes `list_notifications`, `list_starred_repos`, `check_repo_starred`, `star_repo`, and `unstar_repo` — plus all 45 previously-shipped tools — proving all seven toolsets are live in the same server with no duplicate-registration crash. - -- [x] **Step 6: Commit** - -```bash -git add src/server.ts README.md -git commit -m "feat: wire activity toolset into buildServer" -``` - ---- - -## Deliberate Scope Decisions - -The following `octokit.rest.activity.*` methods are **intentionally excluded** from this toolset. Each exclusion is justified below. - -1. **`activity.markNotificationsAsRead` (PUT `/notifications`) — out of scope.** Marks all (or post-`last_read_at`) notifications as read. It returns either `202 Accepted` (async processing, with a `{ message: string }` body) or `205 Reset Content` (no body). The dual-status response (with `205` being a `content: never` case) complicates the raw-passthrough pattern — the implementer would need to synthesize a result for 205 the same way 204 is handled. More importantly, bulk-marking-all-as-read is a destructive, irreversible operation with no scoping (you cannot mark just one thread as read via this endpoint). Excluded to avoid accidental mass-read in agentic sessions. Can be added in a focused notifications follow-up alongside `markThreadAsRead` and `getThread`. - -2. **`activity.getThread` (GET `/notifications/threads/{thread_id}`) — out of scope.** Returns details for a single notification thread by ID. Useful in combination with `list_notifications` (to drill into a specific thread), but only when you already have a `thread_id`. This is a niche follow-up operation that pairs naturally with thread-level mark-as-read. Excluded in this first pass to keep the toolset at ~5 tools; easily added in a follow-up alongside `markThreadAsRead`. - -3. **`activity.markThreadAsRead` (PATCH `/notifications/threads/{thread_id}`) — out of scope.** Marks a single thread as read (returns 205 No Content). A write operation that belongs in a focused "notification management" follow-up plan alongside `getThread` and `markThreadAsDone`. - -4. **`activity.markThreadAsDone` (DELETE `/notifications/threads/{thread_id}`) — out of scope.** Marks a single notification thread as "done" and removes it from the inbox (returns 204). Belongs with the other thread-management tools in a follow-up. - -5. **`activity.getThreadSubscriptionForAuthenticatedUser` / `activity.setThreadSubscription` / `activity.deleteThreadSubscription` — out of scope.** Thread subscription management (ignore/watch/unsubscribe from specific notification threads) is a niche workflow best covered in a dedicated notification-management follow-up. The PUT/DELETE are write operations; the GET subscription status is only meaningful alongside those writes. - -6. **`activity.listRepoNotificationsForAuthenticatedUser` (GET `/repos/{owner}/{repo}/notifications`) — out of scope.** Returns notifications scoped to a single repository. This is a filtering variant of `list_notifications` that adds `owner`/`repo` path parameters. LLM callers can approximate this by calling `list_notifications` and filtering client-side on `notification.repository.full_name`. A focused notifications follow-up can add this alongside the thread-management tools. - -7. **`activity.listReposWatchedByUser` / `activity.listWatchedReposForAuthenticatedUser` — out of scope.** Returns repositories the authenticated user is watching (subscribed to). Watch/subscribe is a separate concept from star; the watch-subscription CRUD endpoints (`getRepoSubscription`, `setRepoSubscription`, `deleteRepoSubscription`) are a distinct feature cluster. Excluded to keep the toolset focused on the notification-read and star-management surface covered by the ~5-tool estimate. Can be added as a follow-up "watch/subscription" toolset or as an extension to this toolset. - -8. **`activity.listStargazersForRepo` (GET `/repos/{owner}/{repo}/stargazers`) — out of scope.** Lists the users who have starred a repository. This is a repo-centric operation (who starred my repo?) rather than a user-centric one (what have I starred?). The `repos` toolset is the natural home for this if it is ever added; it does not belong in `activity`, which covers user-as-actor star operations. - -9. **`activity.listReposStarredByUser` (GET `/users/{username}/starred`) — out of scope.** Lists repos starred by an arbitrary user by username. Excluded because `list_starred_repos` already covers the authenticated user's starred repos (the common case), and listing another user's stars is a niche cross-user operation. The `users` toolset already follows this pattern (chose `list_user_followers` over `listFollowersForAuthenticatedUser`). Can be added in a follow-up without disturbing the 5-tool surface. - -10. **`activity.listWatchersForRepo` (GET `/repos/{owner}/{repo}/subscribers`) — out of scope.** Lists users watching a repository. Repo-centric, belongs in `repos` toolset if added. - -11. **`activity.getRepoSubscription` / `activity.setRepoSubscription` / `activity.deleteRepoSubscription` — out of scope.** Manages the authenticated user's watch subscription to a specific repository (ignoring vs. watching). A distinct write-heavy feature cluster; belongs in a follow-up "watch management" extension. - -12. **`activity.getFeeds` (GET `/feeds`) — out of scope.** Returns URLs for various GitHub Atom feeds (news feed, public timeline, etc.). A metadata/discovery endpoint for RSS/Atom consumers; not a useful LLM-agentic action. - -13. **Event stream endpoints (`listEventsForAuthenticatedUser`, `listPublicEvents`, `listPublicEventsForRepoNetwork`, `listPublicEventsForUser`, `listPublicOrgEvents`, `listOrgEventsForAuthenticatedUser`, `listReceivedEventsForUser`, `listReceivedPublicEventsForUser`, `listRepoEvents`) — out of scope.** Event feeds are fire-hose, append-only streams of GitHub activity events (PushEvent, CreateEvent, etc.). They are high-volume, lack meaningful filtering for agentic purposes, and overlap with what webhooks/`list_notifications` cover. Excluded from the entire v1 scope. - ---- - -## Self-Review Notes - -**Verification checklist (all items confirmed):** - -1. **Every tool name is collision-free.** `list_notifications`, `list_starred_repos`, `check_repo_starred`, `star_repo`, `unstar_repo` — none overlap with the 45 existing tool names enumerated above in the Tool Selection and Collision Check section. - -2. **Every octokit method exists.** Confirmed via: - ``` - node --input-type=module -e "import { Octokit } from 'octokit'; const o = new Octokit({ auth: 'x' }); const m = o.rest.activity; for (const k of Object.keys(m).sort()) { const d = m[k].endpoint.DEFAULTS; console.log(k, '->', d.method, d.url); }" - ``` - Output confirmed all 5 selected methods with correct HTTP method and URL. - -3. **`per_page` cap confirmed in openapi-types.** Line 88614–88615 of `node_modules/@octokit/openapi-types/types.d.ts`: `/** @description The number of results per page (max 50). */`. The Zod schema uses `.max(50)` for `list_notifications` only; `list_starred_repos` uses `paginationSchema` (max 100) per the standard GitHub per_page cap for that endpoint. - -4. **`check_repo_starred` dual-status verified in openapi-types.** Lines 120848–120862 of `@octokit/openapi-types/types.d.ts` confirm: `204: { content: never }` (starred) and `404: { content: { "application/json": basic-error } }` (not starred). Both are explicitly documented responses, not errors. - -5. **`star_repo` and `unstar_repo` 204 No Content verified.** Lines 120876–120879 and 120897–120900 of `@octokit/openapi-types/types.d.ts` confirm both return `204: { content: never }`. Synthetic results `{ starred: true }` and `{ starred: false }` follow the gists `delete_gist` precedent. - -6. **Read-only mode test uses `.toEqual([...exact 3 names sorted...])`.** Task 1, Step 1's last test: `expect(tools.map((t) => t.name).sort()).toEqual(['check_repo_starred', 'list_notifications', 'list_starred_repos'])` — strict equality, not `arrayContaining`. - -7. **Read-write mode test verifies all 5 tools present.** Task 2, Step 1's last test: `expect(tools.map((t) => t.name).sort()).toEqual(['check_repo_starred', 'list_notifications', 'list_starred_repos', 'star_repo', 'unstar_repo'])` — strict equality with all 5 names sorted. - -8. **Wire-level filter tests on query params.** Task 1, Step 1 includes tests for `all=true` forwarding on `list_notifications` and `sort`/`direction` forwarding on `list_starred_repos`, both using `scope.isDone()` assertions to verify the parameters reach the wire. - -9. **Signature matches required form.** `registerActivityTools(server: McpServer, octokit: Octokit, permission: 'read-only' | 'read-write'): void` — identical shape to every other `register*Tools` function. The `permission` parameter is used (not `_permission`) because this toolset has real write tools gated by it. - -10. **Write tools inside `if (permission === 'read-write') { ... }`.** Task 2, Step 3 shows both write tools (`star_repo`, `unstar_repo`) inside the `if` block. Matches `issues.ts`, `pull_requests.ts`, and `gists.ts`. - -11. **`check_repo_starred` 404 catch does NOT call `toToolError`.** The catch block explicitly checks `reqError.status === 404` and returns `toToolResult({ starred: false })` — not `toToolError`. All other statuses fall through to `toToolError`. This is tested by the "returns `{ starred: false }` (not an error)" test, which asserts `expect(result.isError).toBeFalsy()`. - -12. **`cspell.json` staging instruction present.** Both Task 1 Step 6 and Task 2 Step 6 explicitly instruct the implementer to check for cspell failures at commit time and stage `cspell.json` if any fixture words trigger errors. `'stargazers'` is flagged as a potential cspell candidate. - -13. **No modification to `common.ts` except importing existing exports.** `ownerRepoSchema` is already exported from `common.ts` and is reused (not redeclared) in `activity.ts`. `paginationSchema` is reused for `list_starred_repos`. No new exports are added to `common.ts`. - -14. **Test count arithmetic.** Prior total after `gists` = 94. This plan adds 15 tests (9 in Task 1 + 6 in Task 2). Post-activity total = 109. (Breakdown: `common` 3 + `repos` 6 + `issues` 16 + `pull_requests` 15 + `search` 8 + `users` 10 + `gists` 11 + `activity` 15 = 84 test-file `it()` calls not counting `common.test.ts` re-checks. The 94→109 delta of 15 is correct.) diff --git a/docs/superpowers/plans/2026-08-05-github-mcp-server-apps.md b/docs/superpowers/plans/2026-08-05-github-mcp-server-apps.md deleted file mode 100644 index d6d07b3..0000000 --- a/docs/superpowers/plans/2026-08-05-github-mcp-server-apps.md +++ /dev/null @@ -1,871 +0,0 @@ -# github-mcp-server-js — `apps` Toolset Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax for tracking. - -**Goal:** Add the `apps` toolset (3 read-only tools wrapping GitHub's public app-info endpoints) to `github-mcp-server-js`, following the exact structural, permission-gating, and response/error pattern established by the `misc`, `users`, and `packages` toolsets. - -**Architecture:** Same pattern as prior toolsets: one `registerAppsTools(server, octokit, permission)` function registers each tool with `server.registerTool(name, {description, inputSchema}, handler)`; every handler wraps its octokit call in try/catch, returning raw data via `toToolResult`/`toToolError`; `server.ts` gains one more registration call. All octokit calls use the `apps` namespace (`octokit.rest.apps.*`). Because every selected endpoint is read-only, the `permission` parameter is accepted (to keep the signature uniform) but never inspected; it is renamed `_permission` inside the function body to satisfy `@typescript-eslint/no-unused-vars`. - -**Auth note (critical):** The `octokit.rest.apps` namespace contains many endpoints that require GitHub App JWT authentication — they return `401` when called with a PAT. Only endpoints that work with PAT (or require no authentication at all) are included in this toolset. The three selected tools are all PAT-compatible: `getBySlug` is fully public (no auth required), `listInstallationsForAuthenticatedUser` uses the PAT's user context, and `getInstallation` uses the PAT's user context when the calling user has access to the installation. Endpoints requiring JWT (`getAuthenticated`, `listInstallations`, `getWebhookConfigForApp`, etc.) are excluded. - -**Tech Stack:** Same as prior plans — TypeScript, `@modelcontextprotocol/server@^2.0.0`, `octokit@^5.0.5`, `zod@^4.2.0`, `vitest`, `nock`. No new dependencies. - -## Global Constraints - -- Tool responses return the raw octokit response body as JSON, unmodified. `get_app` returns an `integration` object; `list_installations_for_authenticated_user` returns `{ total_count, installations[] }` where each element is an `installation` object; `get_installation_for_authenticated_user` returns the same nested JSON from `{ repositories[], total_count, repository_selection? }`. These shapes are preserved exactly; callers extract fields themselves. -- Errors propagate unmodified: catch the octokit `RequestError`, surface `error.message` in an MCP tool error result via `toToolError`. No normalization layer. -- List tools take explicit `page` (default `1`) and `per_page` (default `30`, max `100`) parameters via `paginationSchema`. No auto-pagination. -- `GITHUB_PERMISSION=read-only` gating is a no-op for this toolset because every tool is read-only. The read-only test still verifies the exact set of 3 tools is registered using `.toEqual([...exact set...])`, not `arrayContaining`, following the pattern established by prior plans. -- Verified tool-name collision check against all 58 existing tool names (8 repos + 12 issues + 10 pull_requests + 5 search + 5 users + 5 gists + 5 activity + 4 packages + 4 misc): **zero collisions** with the 3 new names (`get_app`, `list_installations_for_authenticated_user`, `list_installation_repos_for_authenticated_user`). None of these appear anywhere in the current tool name set. -- No modification to `common.ts`. -- TypeScript only, no new runtime dependencies. - ---- - -## Octokit Verification Results - -Verified via `node --input-type=module -e "..."` against the installed `octokit@^5.0.5`: - -### `octokit.rest.apps` — full method table (sorted) - -| Method | HTTP | URL | -|---|---|---| -| `addRepoToInstallation` | PUT | `/user/installations/{installation_id}/repositories/{repository_id}` | -| `addRepoToInstallationForAuthenticatedUser` | PUT | `/user/installations/{installation_id}/repositories/{repository_id}` | -| `checkToken` | POST | `/applications/{client_id}/token` | -| `createFromManifest` | POST | `/app-manifests/{code}/conversions` | -| `createInstallationAccessToken` | POST | `/app/installations/{installation_id}/access_tokens` | -| `deleteAuthorization` | DELETE | `/applications/{client_id}/grant` | -| `deleteInstallation` | DELETE | `/app/installations/{installation_id}` | -| `deleteToken` | DELETE | `/applications/{client_id}/token` | -| `getAuthenticated` | GET | `/app` | -| `getBySlug` | GET | `/apps/{app_slug}` | -| `getInstallation` | GET | `/app/installations/{installation_id}` | -| `getOrgInstallation` | GET | `/orgs/{org}/installation` | -| `getRepoInstallation` | GET | `/repos/{owner}/{repo}/installation` | -| `getSubscriptionPlanForAccount` | GET | `/marketplace_listing/accounts/{account_id}` | -| `getSubscriptionPlanForAccountStubbed` | GET | `/marketplace_listing/stubbed/accounts/{account_id}` | -| `getUserInstallation` | GET | `/users/{username}/installation` | -| `getWebhookConfigForApp` | GET | `/app/hook/config` | -| `getWebhookDelivery` | GET | `/app/hook/deliveries/{delivery_id}` | -| `listAccountsForPlan` | GET | `/marketplace_listing/plans/{plan_id}/accounts` | -| `listAccountsForPlanStubbed` | GET | `/marketplace_listing/stubbed/plans/{plan_id}/accounts` | -| `listInstallationReposForAuthenticatedUser` | GET | `/user/installations/{installation_id}/repositories` | -| `listInstallationRequestsForAuthenticatedApp` | GET | `/app/installation-requests` | -| `listInstallations` | GET | `/app/installations` | -| `listInstallationsForAuthenticatedUser` | GET | `/user/installations` | -| `listPlans` | GET | `/marketplace_listing/plans` | -| `listPlansStubbed` | GET | `/marketplace_listing/stubbed/plans` | -| `listReposAccessibleToInstallation` | GET | `/installation/repositories` | -| `listSubscriptionsForAuthenticatedUser` | GET | `/user/marketplace_purchases` | -| `listSubscriptionsForAuthenticatedUserStubbed` | GET | `/user/marketplace_purchases/stubbed` | -| `listWebhookDeliveries` | GET | `/app/hook/deliveries` | -| `redeliverWebhookDelivery` | POST | `/app/hook/deliveries/{delivery_id}/attempts` | -| `removeRepoFromInstallation` | DELETE | `/user/installations/{installation_id}/repositories/{repository_id}` | -| `removeRepoFromInstallationForAuthenticatedUser` | DELETE | `/user/installations/{installation_id}/repositories/{repository_id}` | -| `resetToken` | PATCH | `/applications/{client_id}/token` | -| `revokeInstallationAccessToken` | DELETE | `/installation/token` | -| `scopeToken` | POST | `/applications/{client_id}/token/scoped` | -| `suspendInstallation` | PUT | `/app/installations/{installation_id}/suspended` | -| `unsuspendInstallation` | DELETE | `/app/installations/{installation_id}/suspended` | -| `updateWebhookConfigForApp` | PATCH | `/app/hook/config` | - -### `octokit.rest.oidc` — full method table - -| Method | HTTP | URL | -|---|---|---| -| `getOidcCustomSubTemplateForOrg` | GET | `/orgs/{org}/actions/oidc/customization/sub` | -| `updateOidcCustomSubTemplateForOrg` | PUT | `/orgs/{org}/actions/oidc/customization/sub` | - -**Octokit surprise — duplicate alias for `addRepoToInstallation`:** The namespace exposes both `addRepoToInstallation` and `addRepoToInstallationForAuthenticatedUser` — both resolve to `PUT /user/installations/{installation_id}/repositories/{repository_id}`. Same alias duplication pattern noted in the `packages` plan for version-listing methods. Both are write-only anyway and are excluded. - -**Octokit surprise — `getAuthenticated` requires JWT:** `getAuthenticated` (GET `/app`) is a valid GET endpoint and would appear to be the natural starting point for an `apps` toolset. However, it requires GitHub App JWT authentication — a PAT returns `401 Bad credentials`. The requirement prompt flags this explicitly. The plan substitutes `getBySlug` (GET `/apps/{app_slug}`) which requires no authentication at all and returns the same `integration` object shape. This is a strictly better choice for a PAT-based server: it is completely public, returns full app metadata, and works without any token. - -**Octokit surprise — `oidc` namespace has only 2 methods, one of which is a write:** `getOidcCustomSubTemplateForOrg` and `updateOidcCustomSubTemplateForOrg`. The get endpoint returns an org-level OIDC customization template — highly niche, relevant only to GitHub Actions OIDC token customization for enterprise org admins. Neither method falls into the core `apps` use case. Both are excluded (see Deliberate scope decisions). - ---- - -## Reference: verified octokit parameter shapes - -Confirmed against `@octokit/openapi-types/types.d.ts` operations: - -| Tool | octokit method | HTTP | Path params | Query params | Response body type | -|---|---|---|---|---|---| -| `get_app` | `apps.getBySlug` | GET `/apps/{app_slug}` | `app_slug` (string) | — | `integration` (JSON) | -| `list_installations_for_authenticated_user` | `apps.listInstallationsForAuthenticatedUser` | GET `/user/installations` | — | `page?`, `per_page?` | `{ total_count, installations: installation[] }` (JSON) | -| `list_installation_repos_for_authenticated_user` | `apps.listInstallationReposForAuthenticatedUser` | GET `/user/installations/{installation_id}/repositories` | `installation_id` (number) | `page?`, `per_page?` | `{ total_count, repository_selection?, repositories: repository[] }` (JSON) | - -**`integration` schema** (from `components["schemas"]["integration"]`): -``` -{ - id: number, - slug?: string, - node_id: string, - client_id?: string, - owner: simple-user | enterprise, - name: string, - description: string | null, - external_url: string, - html_url: string, - created_at: string (date-time), - updated_at: string (date-time), - permissions: { issues?: string, checks?: string, metadata?: string, contents?: string, ... }, - events: string[], - installations_count?: number -} | null -``` - -**`installation` schema** (from `components["schemas"]["installation"]`): -``` -{ - id: number, - account: (simple-user | enterprise) | null, - repository_selection: "all" | "selected", - access_tokens_url: string, - repositories_url: string, - html_url: string, - app_id: number, - client_id?: string, - target_id: number, - target_type: string, - permissions: app-permissions, - events: string[], - created_at: string, - updated_at: string, - single_file_name: string | null, - app_slug: string, - suspended_by: nullable-simple-user, - suspended_at: string | null, - contact_email?: string | null -} -``` - -**`apps/list-installations-for-authenticated-user` response** (from `operations["apps/list-installations-for-authenticated-user"]`): -``` -{ - total_count: number, - installations: installation[] -} -``` -Status codes: `200 OK`, `304 Not Modified` (throws RequestError), `401 Unauthorized`, `403 Forbidden`. - -**`apps/list-installation-repos-for-authenticated-user` response** (from `operations["apps/list-installation-repos-for-authenticated-user"]`): -``` -{ - total_count: number, - repository_selection?: string, - repositories: repository[] -} -``` -Status codes: `200 OK`, `304 Not Modified` (throws RequestError), `403 Forbidden`, `404 Not Found`. - -**`apps/get-by-slug` response** (from `operations["apps/get-by-slug"]`): -Returns `integration` object. Status codes: `200 OK`, `403 Forbidden`, `404 Not Found`. - -**Auth note for `list_installations_for_authenticated_user`:** This endpoint uses `GET /user/installations` which is a user-context endpoint — it lists GitHub App installations the authenticated user has access to (i.e., apps installed on their personal account or orgs they belong to). Works correctly with a PAT that has `read:user` scope or higher. - -**Auth note for `list_installation_repos_for_authenticated_user`:** This endpoint uses `GET /user/installations/{installation_id}/repositories` — it lists repos accessible to the user for a specific installation. Works with a PAT. - -**cspell.json note:** The word `slug` is a standard English word and is recognized by cspell's default dictionary. `app_slug` is a compound identifier and will not be spell-checked as a prose word. No new words need to be added to `cspell.json` for this toolset. If cspell flags `oidc` (it appears only in the Deliberate scope decisions section of this plan, not in source or test code), add `"oidc"` to the `words` array in `cspell.json` at pre-commit time. - ---- - -## File Structure - -``` -github-mcp-server-js/ - src/ - toolsets/ - apps.ts # NEW: registerAppsTools(server, octokit, permission) - server.ts # MODIFIED: calls registerAppsTools - test/ - unit/ - toolsets/ - apps.test.ts # NEW: mirrors misc.test.ts's structure -``` - -`common.ts` is NOT modified. `README.md`'s Toolsets section is updated in Task 2 (Step 5). - ---- - -## Task 1: Implement the `apps` toolset (3 tools) and its tests - -**Files:** -- Create: `src/toolsets/apps.ts` -- Create: `test/unit/toolsets/apps.test.ts` - -**Interfaces:** -- Consumes: `paginationSchema`, `toToolResult`, `toToolError` from `./common.js`. Does NOT need `ownerRepoSchema` or `issueNumberSchema`. -- Produces (for Task 2 / `server.ts` to consume): - - `registerAppsTools(server: McpServer, octokit: Octokit, permission: 'read-only' | 'read-write'): void` - -- [x] **Step 1: Write the failing tests** - -Create `test/unit/toolsets/apps.test.ts`: - -```typescript -import nock from 'nock'; -import { afterEach, describe, expect, it } from 'vitest'; -import { registerAppsTools } from '../../../src/toolsets/apps.js'; -import { connectedClient } from './test-helpers.js'; - -describe('registerAppsTools', () => { - afterEach(() => { - nock.cleanAll(); - }); - - // ── get_app ──────────────────────────────────────────────────────────────── - - it('registers get_app and returns the raw integration object as JSON', async () => { - nock('https://api.github.com') - .get('/apps/my-cool-app') - .reply(200, { - id: 1, - slug: 'my-cool-app', - node_id: 'MDExOkludGVncmF0aW9uMQ==', - name: 'My Cool App', - description: 'A test GitHub App', - external_url: 'https://example.com', - html_url: 'https://github.com/apps/my-cool-app', - created_at: '2022-01-01T00:00:00Z', - updated_at: '2022-06-01T00:00:00Z', - permissions: { issues: 'read', pull_requests: 'write' }, - events: ['push', 'pull_request'], - installations_count: 5, - owner: { - login: 'octocat', - id: 1, - node_id: 'MDQ6VXNlcjE=', - avatar_url: 'https://github.com/images/error/octocat_happy.gif', - gravatar_id: '', - url: 'https://api.github.com/users/octocat', - html_url: 'https://github.com/octocat', - type: 'User', - site_admin: false, - }, - }); - - const client = await connectedClient(registerAppsTools, 'read-write'); - const result = await client.callTool({ - name: 'get_app', - arguments: { app_slug: 'my-cool-app' }, - }); - - expect(result.isError).toBeFalsy(); - const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ''; - const parsed = JSON.parse(text) as { id: number; name: string; installations_count: number }; - expect(parsed.id).toBe(1); - expect(parsed.name).toBe('My Cool App'); - expect(parsed.installations_count).toBe(5); - }); - - it('propagates a 404 from get_app as an MCP tool error', async () => { - nock('https://api.github.com') - .get('/apps/nonexistent-app') - .reply(404, { message: 'Not Found', documentation_url: 'https://docs.github.com/rest' }); - - const client = await connectedClient(registerAppsTools, 'read-write'); - const result = await client.callTool({ - name: 'get_app', - arguments: { app_slug: 'nonexistent-app' }, - }); - - expect(result.isError).toBe(true); - const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ''; - expect(text).toContain('Not Found'); - }); - - // ── list_installations_for_authenticated_user ────────────────────────────── - - it('registers list_installations_for_authenticated_user and returns the raw response as JSON', async () => { - nock('https://api.github.com') - .get('/user/installations') - .query({ page: '1', per_page: '30' }) - .reply(200, { - total_count: 2, - installations: [ - { - id: 100, - app_id: 1, - app_slug: 'my-cool-app', - target_id: 42, - target_type: 'User', - repository_selection: 'all', - access_tokens_url: 'https://api.github.com/app/installations/100/access_tokens', - repositories_url: 'https://api.github.com/installation/repositories', - html_url: 'https://github.com/settings/installations/100', - permissions: { issues: 'read' }, - events: ['push'], - created_at: '2022-01-01T00:00:00Z', - updated_at: '2022-06-01T00:00:00Z', - single_file_name: null, - suspended_by: null, - suspended_at: null, - }, - { - id: 101, - app_id: 2, - app_slug: 'another-app', - target_id: 99, - target_type: 'Organization', - repository_selection: 'selected', - access_tokens_url: 'https://api.github.com/app/installations/101/access_tokens', - repositories_url: 'https://api.github.com/installation/repositories', - html_url: 'https://github.com/organizations/test-org/settings/installations/101', - permissions: { contents: 'read', pull_requests: 'write' }, - events: ['pull_request'], - created_at: '2023-01-01T00:00:00Z', - updated_at: '2023-06-01T00:00:00Z', - single_file_name: null, - suspended_by: null, - suspended_at: null, - }, - ], - }); - - const client = await connectedClient(registerAppsTools, 'read-write'); - const result = await client.callTool({ - name: 'list_installations_for_authenticated_user', - arguments: {}, - }); - - expect(result.isError).toBeFalsy(); - const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ''; - const parsed = JSON.parse(text) as { - total_count: number; - installations: Array<{ id: number; app_slug: string }>; - }; - expect(parsed.total_count).toBe(2); - expect(parsed.installations).toHaveLength(2); - expect(parsed.installations[0]).toMatchObject({ id: 100, app_slug: 'my-cool-app' }); - }); - - it('forwards pagination on list_installations_for_authenticated_user to the wire', async () => { - const scope = nock('https://api.github.com') - .get('/user/installations') - .query({ page: '2', per_page: '10' }) - .reply(200, { total_count: 0, installations: [] }); - - const client = await connectedClient(registerAppsTools, 'read-write'); - const result = await client.callTool({ - name: 'list_installations_for_authenticated_user', - arguments: { page: 2, per_page: 10 }, - }); - - expect(result.isError).toBeFalsy(); - expect(scope.isDone()).toBe(true); - }); - - it('propagates a 401 from list_installations_for_authenticated_user as an MCP tool error', async () => { - nock('https://api.github.com') - .get('/user/installations') - .query({ page: '1', per_page: '30' }) - .reply(401, { - message: 'Requires authentication', - documentation_url: 'https://docs.github.com/rest', - }); - - const client = await connectedClient(registerAppsTools, 'read-write'); - const result = await client.callTool({ - name: 'list_installations_for_authenticated_user', - arguments: {}, - }); - - expect(result.isError).toBe(true); - const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ''; - expect(text).toContain('Requires authentication'); - }); - - // ── list_installation_repos_for_authenticated_user ───────────────────────── - - it('registers list_installation_repos_for_authenticated_user and returns the raw response as JSON', async () => { - nock('https://api.github.com') - .get('/user/installations/100/repositories') - .query({ page: '1', per_page: '30' }) - .reply(200, { - total_count: 1, - repository_selection: 'all', - repositories: [ - { - id: 1296269, - name: 'Hello-World', - full_name: 'octocat/Hello-World', - private: false, - owner: { - login: 'octocat', - id: 1, - }, - html_url: 'https://github.com/octocat/Hello-World', - description: 'This your first repo!', - }, - ], - }); - - const client = await connectedClient(registerAppsTools, 'read-write'); - const result = await client.callTool({ - name: 'list_installation_repos_for_authenticated_user', - arguments: { installation_id: 100 }, - }); - - expect(result.isError).toBeFalsy(); - const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ''; - const parsed = JSON.parse(text) as { - total_count: number; - repositories: Array<{ name: string }>; - }; - expect(parsed.total_count).toBe(1); - expect(parsed.repositories[0]).toMatchObject({ name: 'Hello-World' }); - }); - - it('forwards installation_id and pagination on list_installation_repos_for_authenticated_user to the wire', async () => { - const scope = nock('https://api.github.com') - .get('/user/installations/999/repositories') - .query({ page: '3', per_page: '50' }) - .reply(200, { total_count: 0, repositories: [] }); - - const client = await connectedClient(registerAppsTools, 'read-write'); - const result = await client.callTool({ - name: 'list_installation_repos_for_authenticated_user', - arguments: { installation_id: 999, page: 3, per_page: 50 }, - }); - - expect(result.isError).toBeFalsy(); - expect(scope.isDone()).toBe(true); - }); - - it('propagates a 404 from list_installation_repos_for_authenticated_user as an MCP tool error', async () => { - nock('https://api.github.com') - .get('/user/installations/9999/repositories') - .query({ page: '1', per_page: '30' }) - .reply(404, { - message: 'Not Found', - documentation_url: 'https://docs.github.com/rest', - }); - - const client = await connectedClient(registerAppsTools, 'read-write'); - const result = await client.callTool({ - name: 'list_installation_repos_for_authenticated_user', - arguments: { installation_id: 9999 }, - }); - - expect(result.isError).toBe(true); - const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ''; - expect(text).toContain('Not Found'); - }); - - // ── registration count ───────────────────────────────────────────────────── - - it('registers exactly the 3 apps tools in read-only mode (and the same set in read-write)', async () => { - const expected = [ - 'get_app', - 'list_installation_repos_for_authenticated_user', - 'list_installations_for_authenticated_user', - ]; - - const readOnlyClient = await connectedClient(registerAppsTools, 'read-only'); - const readOnlyTools = await readOnlyClient.listTools(); - expect(readOnlyTools.tools.map((t) => t.name).sort()).toEqual(expected); - - const readWriteClient = await connectedClient(registerAppsTools, 'read-write'); - const readWriteTools = await readWriteClient.listTools(); - expect(readWriteTools.tools.map((t) => t.name).sort()).toEqual(expected); - }); -}); -``` - -- [x] **Step 2: Run tests to verify they fail** - -Run: `npm test -- apps` -Expected: FAIL with a module-not-found error for `../../../src/toolsets/apps.js` (the file doesn't exist yet). - -- [x] **Step 3: Create `src/toolsets/apps.ts`** - -```typescript -import type { McpServer } from '@modelcontextprotocol/server'; -import type { Octokit } from 'octokit'; -import { z } from 'zod'; -import { paginationSchema, toToolError, toToolResult } from './common.js'; - -export function registerAppsTools( - server: McpServer, - octokit: Octokit, - _permission: 'read-only' | 'read-write', -): void { - // ── get_app ──────────────────────────────────────────────────────────────── - - server.registerTool( - 'get_app', - { - description: - 'Get public metadata for a GitHub App by its URL slug. ' + - 'Returns the app\'s id, name, description, owner, external URL, ' + - 'permissions, subscribed events, and installation count. ' + - 'Does not require authentication — the app must be publicly listed. ' + - 'The slug is the URL-friendly name visible in github.com/apps/.', - inputSchema: z.object({ - app_slug: z - .string() - .describe( - 'The URL slug of the GitHub App (the last segment of its github.com/apps/ URL). ' + - 'For example, for https://github.com/apps/my-cool-app the slug is "my-cool-app".', - ), - }), - }, - async ({ app_slug }) => { - try { - const response = await octokit.rest.apps.getBySlug({ app_slug }); - return toToolResult(response.data); - } catch (error) { - return toToolError(error); - } - }, - ); - - // ── list_installations_for_authenticated_user ────────────────────────────── - - server.registerTool( - 'list_installations_for_authenticated_user', - { - description: - 'List GitHub App installations accessible to the authenticated user. ' + - 'Returns installations on the user\'s personal account and on organizations ' + - 'where the user is a member, along with the permissions and events each installation subscribes to. ' + - 'Useful for discovering which apps are installed and their installation IDs ' + - '(needed for list_installation_repos_for_authenticated_user). ' + - 'Requires a token with read:user scope or higher. ' + - 'Paginate with page and per_page.', - inputSchema: z.object({ - ...paginationSchema, - }), - }, - async ({ page, per_page }) => { - try { - const response = await octokit.rest.apps.listInstallationsForAuthenticatedUser({ - page, - per_page, - }); - return toToolResult(response.data); - } catch (error) { - return toToolError(error); - } - }, - ); - - // ── list_installation_repos_for_authenticated_user ───────────────────────── - - server.registerTool( - 'list_installation_repos_for_authenticated_user', - { - description: - 'List repositories that the authenticated user can access for a specific GitHub App installation. ' + - 'Returns repositories where the user has explicit read, write, or admin permission ' + - 'through direct ownership, collaborator access, or organization membership. ' + - 'Use list_installations_for_authenticated_user to discover installation IDs first. ' + - 'Paginate with page and per_page.', - inputSchema: z.object({ - installation_id: z - .number() - .int() - .describe( - 'The unique installation ID of the GitHub App installation. ' + - 'Use list_installations_for_authenticated_user to find installation IDs.', - ), - ...paginationSchema, - }), - }, - async ({ installation_id, page, per_page }) => { - try { - const response = await octokit.rest.apps.listInstallationReposForAuthenticatedUser({ - installation_id, - page, - per_page, - }); - return toToolResult(response.data); - } catch (error) { - return toToolError(error); - } - }, - ); -} -``` - -- [x] **Step 4: Run tests to verify they pass** - -Run: `npm test -- apps` -Expected: PASS (9 tests in `apps.test.ts`). - -- [x] **Step 5: Run the full test suite to confirm no regression in other toolsets** - -Run: `npm test` -Expected: PASS. Total test count is the previous suite total plus the 9 new tests in `apps.test.ts`. No pre-existing test file is modified; `common.ts` is unchanged so `common.test.ts` still passes verbatim. - -- [x] **Step 6: Commit** - -```bash -git add src/toolsets/apps.ts test/unit/toolsets/apps.test.ts -git commit -m "feat: add apps toolset (3 read-only tools: get_app, list_installations, list_installation_repos)" -``` - ---- - -## Task 2: Wire `registerAppsTools` into `server.ts` - -**Files:** -- Modify: `src/server.ts` -- Modify: `README.md` (Toolsets section) - -**Interfaces:** -- Consumes: `registerAppsTools(server, octokit, permission)` from Task 1. -- Produces: nothing new — this is the final integration point. - -- [x] **Step 1: Modify `src/server.ts`** - -Current content (after `misc` was wired — verified against the actual file on `main`): - -```typescript -import { McpServer } from '@modelcontextprotocol/server'; -import type { Octokit } from 'octokit'; -import { registerActivityTools } from './toolsets/activity.js'; -import { registerGistsTools } from './toolsets/gists.js'; -import { registerIssuesTools } from './toolsets/issues.js'; -import { registerMiscTools } from './toolsets/misc.js'; -import { registerPackagesTools } from './toolsets/packages.js'; -import { registerPullRequestsTools } from './toolsets/pull_requests.js'; -import { registerReposTools } from './toolsets/repos.js'; -import { registerSearchTools } from './toolsets/search.js'; -import { registerUsersTools } from './toolsets/users.js'; - -const SERVER_NAME = 'github-mcp-server-js'; -const SERVER_VERSION = '0.1.0'; - -export function buildServer( - octokit: Octokit, - permission: 'read-only' | 'read-write', -): McpServer { - const server = new McpServer({ name: SERVER_NAME, version: SERVER_VERSION }); - - registerReposTools(server, octokit, permission); - registerIssuesTools(server, octokit, permission); - registerPullRequestsTools(server, octokit, permission); - registerSearchTools(server, octokit, permission); - registerUsersTools(server, octokit, permission); - registerGistsTools(server, octokit, permission); - registerActivityTools(server, octokit, permission); - registerPackagesTools(server, octokit, permission); - registerMiscTools(server, octokit, permission); - - return server; -} -``` - -Replace with (new import sorted alphabetically, new call appended): - -```typescript -import { McpServer } from '@modelcontextprotocol/server'; -import type { Octokit } from 'octokit'; -import { registerActivityTools } from './toolsets/activity.js'; -import { registerAppsTools } from './toolsets/apps.js'; -import { registerGistsTools } from './toolsets/gists.js'; -import { registerIssuesTools } from './toolsets/issues.js'; -import { registerMiscTools } from './toolsets/misc.js'; -import { registerPackagesTools } from './toolsets/packages.js'; -import { registerPullRequestsTools } from './toolsets/pull_requests.js'; -import { registerReposTools } from './toolsets/repos.js'; -import { registerSearchTools } from './toolsets/search.js'; -import { registerUsersTools } from './toolsets/users.js'; - -const SERVER_NAME = 'github-mcp-server-js'; -const SERVER_VERSION = '0.1.0'; - -export function buildServer( - octokit: Octokit, - permission: 'read-only' | 'read-write', -): McpServer { - const server = new McpServer({ name: SERVER_NAME, version: SERVER_VERSION }); - - registerReposTools(server, octokit, permission); - registerIssuesTools(server, octokit, permission); - registerPullRequestsTools(server, octokit, permission); - registerSearchTools(server, octokit, permission); - registerUsersTools(server, octokit, permission); - registerGistsTools(server, octokit, permission); - registerActivityTools(server, octokit, permission); - registerPackagesTools(server, octokit, permission); - registerMiscTools(server, octokit, permission); - registerAppsTools(server, octokit, permission); - - return server; -} -``` - -- [x] **Step 2: Run the full test suite** - -Run: `npm test` -Expected: PASS, same test count as after Task 1 (no test exercises `server.ts` directly — `buildServer` is a thin, non-branching composition function verified by the manual smoke test below). - -- [x] **Step 3: Typecheck, lint, build** - -Run: `npm run typecheck && npm run lint && npm run build` -Expected: all PASS. The build step confirms `dist/cli.js`'s bundled dependency graph now transitively includes `apps.ts`. - -- [x] **Step 4: Manual end-to-end smoke test** - -Run: -```bash -GITHUB_TOKEN=fake-token node dist/cli.js --transport=http --port=3994 & -sleep 1 -curl -s -D /tmp/mcp-init-headers.txt -X POST http://localhost:3994/mcp \ - -H 'Content-Type: application/json' \ - -H 'Accept: application/json, text/event-stream' \ - -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"smoke-test","version":"0.0.0"}}}' -SESSION=$(grep -i mcp-session-id /tmp/mcp-init-headers.txt | awk '{print $2}' | tr -d '\r') -curl -s -X POST http://localhost:3994/mcp \ - -H 'Content-Type: application/json' \ - -H 'Accept: application/json, text/event-stream' \ - -H "Mcp-Session-Id: $SESSION" \ - -d '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' -kill %1 -``` - -Expected: the `initialize` response contains `"serverInfo":{"name":"github-mcp-server-js"...}`. The `tools/list` response includes all 3 apps tools (`get_app`, `list_installations_for_authenticated_user`, `list_installation_repos_for_authenticated_user`) alongside all 58 previously-shipped tools — proving all toolsets are live in the same server with no duplicate-registration crash. Total tool count: 61. - -- [x] **Step 5: Update the README's Toolsets section** - -Modify `README.md`'s "Currently implemented" list to add: - -```markdown -- `apps` — GitHub App public info and user-accessible installations (`get_app`, `list_installations_for_authenticated_user`, `list_installation_repos_for_authenticated_user`) -``` - -Slot it after the existing `misc` bullet, keeping the toolsets listed in the order they were shipped. - -- [x] **Step 6: If cspell flags any word, add it to `cspell.json`** - -If the pre-commit hook rejects any word from the `apps.ts` source or `apps.test.ts` test file (likely candidates: `oidc` if it appears in source comments, `slug` is a standard dictionary word and should pass), open `cspell.json` and add the flagged word to the `words` array: - -```json -{ - "words": [ - ..., - "oidc" - ] -} -``` - -Note: `slug` is an English word recognized by cspell's default dictionary. `app_slug` is a compound identifier not spell-checked as prose. This step is expected to be a no-op; only execute it if cspell actually fails. - -- [x] **Step 7: Commit** - -```bash -git add src/server.ts README.md -git commit -m "feat: wire apps toolset into buildServer" -``` - ---- - -## Deliberate scope decisions - -The following `octokit.rest.apps.*` methods were introspected and are **intentionally excluded** from this toolset. Each exclusion is justified below. - -### JWT-only endpoints — excluded (auth incompatible with PAT) - -1. **`getAuthenticated` (GET `/app`) — JWT-only, excluded.** Returns metadata about the authenticated GitHub App. Requires a GitHub App JWT; a PAT returns `401 Bad credentials`. This is the most natural starting point for an `apps` toolset but is completely unusable by this server's PAT-based auth model. `get_app` (wrapping `getBySlug`) is the PAT-compatible substitute — it returns the same `integration` object shape for any publicly listed app. - -2. **`listInstallations` (GET `/app/installations`) — JWT-only, excluded.** Lists all installations of the authenticated app. Requires JWT authentication as the app itself. Distinct from `listInstallationsForAuthenticatedUser` (which lists installations accessible to a PAT user). `listInstallations` cannot be called with a PAT. - -3. **`getInstallation` (GET `/app/installations/{installation_id}`) — JWT-only, excluded.** Gets a specific installation by ID, but authenticated as the app (requires JWT). The PAT-compatible analog for listing installations is `listInstallationsForAuthenticatedUser` plus `listInstallationReposForAuthenticatedUser`. There is no PAT-compatible single-installation lookup — `getInstallation` is excluded rather than included with a misleading description. - -4. **`getWebhookConfigForApp` (GET `/app/hook/config`) — JWT-only, excluded.** Returns the webhook configuration for the GitHub App. Requires JWT. Out of scope — webhook config inspection is an app-management concern, not a tool-call concern for a PAT-based server. - -5. **`listWebhookDeliveries` (GET `/app/hook/deliveries`) — JWT-only, excluded.** Lists webhook deliveries. Requires JWT. Same rationale. - -6. **`getWebhookDelivery` (GET `/app/hook/deliveries/{delivery_id}`) — JWT-only, excluded.** Gets a specific webhook delivery. Requires JWT. Same rationale. - -7. **`listInstallationRequestsForAuthenticatedApp` (GET `/app/installation-requests`) — JWT-only, excluded.** Lists pending installation requests for the app. Requires JWT. App-management concern, not relevant to PAT-based user workflow. - -### OAuth App / token management endpoints — excluded (wrong auth model) - -8. **`checkToken` (POST `/applications/{client_id}/token`) — OAuth App, excluded.** Validates an OAuth access token using HTTP Basic auth with the app's client_id and client_secret. Entirely different auth model from this server's PAT; cannot be usefully called from a PAT-authenticated tool. - -9. **`deleteToken` (DELETE `/applications/{client_id}/token`) — write and OAuth App, excluded.** Revokes an OAuth token. Write operation, wrong auth model. - -10. **`deleteAuthorization` (DELETE `/applications/{client_id}/grant`) — write and OAuth App, excluded.** Revokes an OAuth grant. Write operation, wrong auth model. - -11. **`resetToken` (PATCH `/applications/{client_id}/token`) — write and OAuth App, excluded.** Resets an OAuth token. Write operation, wrong auth model. - -12. **`scopeToken` (POST `/applications/{client_id}/token/scoped`) — write and OAuth App, excluded.** Creates a scoped access token. Write operation, wrong auth model. - -### Installation management endpoints — write, excluded - -13. **`createInstallationAccessToken` (POST `/app/installations/{installation_id}/access_tokens`) — write and JWT-only, doubly excluded.** Creates an installation access token. Write operation; also requires JWT as the app. Cannot be called with a PAT. - -14. **`deleteInstallation` (DELETE `/app/installations/{installation_id}`) — write and JWT-only, doubly excluded.** Uninstalls a GitHub App. Destructive write; requires JWT. - -15. **`suspendInstallation` (PUT `/app/installations/{installation_id}/suspended`) — write and JWT-only, doubly excluded.** Suspends an installation. Write; requires JWT. - -16. **`unsuspendInstallation` (DELETE `/app/installations/{installation_id}/suspended`) — write and JWT-only, doubly excluded.** Unsuspends an installation. Write; requires JWT. - -17. **`addRepoToInstallationForAuthenticatedUser` (PUT `/user/installations/{installation_id}/repositories/{repository_id}`) — write, excluded.** Adds a repository to an installation. Write/mutating operation. The read-only scope of this toolset excludes all mutations. (Also: `addRepoToInstallation` is a duplicate alias — same URL, excluded for the same reason.) - -18. **`removeRepoFromInstallationForAuthenticatedUser` (DELETE `/user/installations/{installation_id}/repositories/{repository_id}`) — write, excluded.** Removes a repository from an installation. Destructive write. (Also: `removeRepoFromInstallation` is a duplicate alias — excluded for the same reason.) - -19. **`revokeInstallationAccessToken` (DELETE `/installation/token`) — write, excluded.** Revokes the currently-used installation access token. Destructive, not applicable to PAT auth. - -20. **`createFromManifest` (POST `/app-manifests/{code}/conversions`) — write, excluded.** Completes the GitHub App Manifest flow and creates a new app. Write/creation operation; also a one-time setup operation unsuitable as an ongoing LLM tool. - -### Installation lookup endpoints (JWT-only / niche) — excluded - -21. **`getOrgInstallation` (GET `/orgs/{org}/installation`) — JWT-only, excluded.** Gets the installation for a specific org — returns the installation of the calling app on that org. Requires JWT authentication as the app. PAT-based servers use `listInstallationsForAuthenticatedUser` to discover which apps are installed on orgs the user belongs to. - -22. **`getRepoInstallation` (GET `/repos/{owner}/{repo}/installation`) — JWT-only, excluded.** Gets the installation for a specific repo. Requires JWT. Same rationale. - -23. **`getUserInstallation` (GET `/users/{username}/installation`) — JWT-only, excluded.** Gets the installation for a specific user account. Requires JWT. Same rationale. - -24. **`listReposAccessibleToInstallation` (GET `/installation/repositories`) — JWT-or-installation-token only, excluded.** Lists repositories accessible to the current installation. Requires either a JWT or an installation access token — cannot be called with a PAT. The PAT-compatible analog is `listInstallationReposForAuthenticatedUser` (which is included as `list_installation_repos_for_authenticated_user`). - -### Marketplace endpoints — out of scope - -25. **`listPlans` / `listPlansStubbed` (GET `/marketplace_listing/plans` / `…/stubbed/plans`) — out of scope.** Lists GitHub Marketplace plans for a paid GitHub App. Relevant only to app vendors managing their Marketplace listings — not an end-user tool. Out of scope for the ~3-tool estimate. - -26. **`listAccountsForPlan` / `listAccountsForPlanStubbed` (GET `/marketplace_listing/plans/{plan_id}/accounts`) — out of scope.** Lists accounts on a specific Marketplace plan. Same app-vendor rationale. Also requires OAuth App auth or JWT. - -27. **`getSubscriptionPlanForAccount` / `getSubscriptionPlanForAccountStubbed` (GET `/marketplace_listing/accounts/{account_id}`) — out of scope.** Looks up the Marketplace subscription plan for a specific account. App-vendor concern, out of scope. - -28. **`listSubscriptionsForAuthenticatedUser` / `listSubscriptionsForAuthenticatedUserStubbed` (GET `/user/marketplace_purchases`) — out of scope.** Lists Marketplace purchases for the authenticated user. Niche: only returns results for users who have purchased paid GitHub Apps via Marketplace. Marginal utility for a general-purpose MCP server; out of scope for the ~3-tool estimate. Could be added in a follow-up plan if Marketplace tooling becomes a use case. - -### `oidc` namespace — out of scope - -29. **`getOidcCustomSubTemplateForOrg` (GET `/orgs/{org}/actions/oidc/customization/sub`) — out of scope.** Returns the OIDC subject claim customization template for an org's GitHub Actions workflows. Highly niche: relevant only to enterprise/org admins who have configured OIDC token subject customization for their Actions pipelines. Not a general-purpose tool for an LLM. Out of scope for the ~3-tool `apps` budget; could belong in an `actions` or `orgs_teams` toolset extension if needed. - -30. **`updateOidcCustomSubTemplateForOrg` (PUT `/orgs/{org}/actions/oidc/customization/sub`) — write and out of scope, doubly excluded.** Write/mutating operation for an already-niche endpoint. - ---- - -## Self-Review Notes - -- **Spec coverage:** `apps` toolset (Toolset Inventory row: octokit `apps`, `oidc` namespaces, example tools `get_app, list_installations`, est. count ~3). Both named examples are implemented exactly (`get_app`, `list_installations_for_authenticated_user`). The count exactly matches the ~3 estimate. The third tool (`list_installation_repos_for_authenticated_user`) is the natural companion to `list_installations_for_authenticated_user` — you cannot act on an installation without knowing its accessible repos, making this a pair as natural as `list_package_versions_for_authenticated_user` following `list_packages_for_authenticated_user` in the packages plan. - -- **Auth model selection:** All three tools are verified to work with PAT authentication. `get_app` requires no auth at all (fully public). `list_installations_for_authenticated_user` and `list_installation_repos_for_authenticated_user` use `/user/...` endpoints that run in the context of the PAT owner, not in the context of a GitHub App. This is the correct set for a PAT-based server. - -- **`getAuthenticated` substitution rationale:** The design row's example tool is `get_app`, which maps naturally to `getAuthenticated` (GET `/app`). However, `getAuthenticated` returns `401` with a PAT. `getBySlug` (GET `/apps/{app_slug}`) is the PAT-compatible substitute: it returns the identical `integration` response shape, requires no authentication, and is strictly more broadly usable (any public app, not just the calling app). The tool is named `get_app` to match the spec example, implemented via `getBySlug`. - -- **`list_installations_for_authenticated_user` vs. design row `list_installations`:** The design row lists `list_installations` as the example tool name. In this codebase, tool names are suffixed with `_for_authenticated_user` when the endpoint is scoped to the PAT owner (precedent: all four `packages` tools). The full name `list_installations_for_authenticated_user` avoids ambiguity with the JWT-only `listInstallations` (GET `/app/installations`) and is consistent with the `packages` toolset naming convention. - -- **`paginationSchema` spread:** Both `list_installations_for_authenticated_user` and `list_installation_repos_for_authenticated_user` spread `paginationSchema` from `common.ts`. `get_app` takes only `app_slug` — no pagination (single resource lookup). This matches the pattern: single-resource tools take required identifiers; list tools take `paginationSchema`. - -- **304 responses:** Both list endpoints include `304 Not Modified` as a possible response code per the OpenAPI spec. Octokit throws a `RequestError("Not modified", 304, ...)` for 304s rather than returning a success response — same behavior as `get_meta` and `list_emojis` in the `misc` toolset. The `toToolError` catch path handles this correctly. No special test is written for 304 because it requires conditional request headers (`If-None-Match`) to trigger, which nock would require setting up as a stateful intercept. The error propagation path is already proven by the `misc` toolset's 304 tests and by the generic catch/`toToolError` pattern used identically in every other toolset. - -- **`get_app` `z.object({})` vs. `z.object({ app_slug: ... })`:** `get_app` takes a required `app_slug` string parameter — unlike the parameterless tools in `misc`. The Zod schema is `z.object({ app_slug: z.string().describe(...) })`. This is the same pattern as `getBySlug` requires one path param. - -- **Tool-name collision check performed:** All 3 tool names (`get_app`, `list_installations_for_authenticated_user`, `list_installation_repos_for_authenticated_user`) were verified against the 58 existing tool names (counted via `grep -c "server.registerTool(" src/toolsets/*.ts`): activity 5, gists 5, issues 12, misc 4, packages 4, pull_requests 10, repos 8, search 5, users 5. Zero collisions. - -- **Lessons applied from prior plans:** - - **`issues` I1 (strict permission-gating equality):** Task 1's registration-count test uses `.toEqual([...exact 3 names...])`, not `arrayContaining`. Run against both `read-only` and `read-write` clients. - - **`issues` I3 (wire-level filter passthrough):** The pagination forwarding tests for both list tools use `nock(...).query({...full params...})` plus `expect(scope.isDone()).toBe(true)`, proving every parameter is forwarded on the wire. - - **`search` M (all-read-only `_permission` naming):** Applied — `_permission` inside the function body. - - **`packages` M (tool-name collision check):** Performed explicitly — see Global Constraints. - - **`misc` auth awareness:** Unlike `misc` (where all 4 tools work without auth), the `apps` toolset requires careful auth-compatibility screening. Each of the 30 excluded methods is documented with its auth requirement. The 3 included tools are all PAT-compatible by verified design. - -- **No placeholders:** All octokit method names (`apps.getBySlug`, `apps.listInstallationsForAuthenticatedUser`, `apps.listInstallationReposForAuthenticatedUser`), HTTP verbs, URL paths, path parameters, query parameters, and response body shapes were verified directly against the installed `@octokit/plugin-rest-endpoint-methods` endpoint table (via `octokit.rest.apps[name].endpoint.DEFAULTS`) and the `@octokit/openapi-types/types.d.ts` operation definitions — not memorized or guessed. Every intentionally excluded method is documented in the Deliberate scope decisions section. - -- **Task right-sizing:** Task 1 bundles all 3 tools + tests into one reviewer gate, matching the `misc`, `users`, `packages`, `search`, `gists`, and `activity` plans. Task 2 is a separate gate for the same reason as all prior plans: wiring is where duplicate-registration crashes and stale-README rot surface. diff --git a/docs/superpowers/plans/2026-08-05-github-mcp-server-code_security.md b/docs/superpowers/plans/2026-08-05-github-mcp-server-code_security.md deleted file mode 100644 index fa079a5..0000000 --- a/docs/superpowers/plans/2026-08-05-github-mcp-server-code_security.md +++ /dev/null @@ -1,486 +0,0 @@ -# github-mcp-server-js — `code_security` Toolset Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development or superpowers:executing-plans. Checkboxes tracked with `- [x]` / `- [x]`. - -**Goal:** Add the `code_security` toolset (10 read-only tools spanning code-scanning, secret-scanning, dependabot alerts, and security advisories) to `github-mcp-server-js`. - -**Architecture:** Same pattern as `search`/`projects`: all-read-only, single `registerCodeSecurityTools(server, octokit, permission): void` function, `_permission` underscore prefix. No write branch. `server.ts` gains one registration call. Cross-namespace: uses `codeScanning`, `secretScanning`, `dependabot`, and `securityAdvisories` — all under a single `code_security` conceptual grouping per the design row. - -**Tech Stack:** No new dependencies. - -## Global Constraints - -- All 10 tools read-only. `_permission` prefix. -- Raw JSON passthrough; errors via `toToolError`. -- List tools use `paginationSchema`. -- Zero collision with 82 existing tool names (verified: security-domain names have `alert` or `advisor` in them). -- All alert tools require `security_events` PAT scope for private repos, `public_repo` for public — document in tool descriptions. -- TypeScript only. - ---- - -## File Structure - -``` -src/toolsets/code_security.ts, test/unit/toolsets/code_security.test.ts, src/server.ts, README.md -``` - ---- - -## Reference: verified octokit shapes - -Confirmed against `@octokit/openapi-types/types.d.ts` lines 85228, 104975, 107431, 115372, and equivalent alert-fetch/advisory-fetch operations. - -| Tool | octokit method | HTTP | -|---|---|---| -| `list_code_scanning_alerts` | `codeScanning.listAlertsForRepo` | GET `/repos/{owner}/{repo}/code-scanning/alerts` | -| `get_code_scanning_alert` | `codeScanning.getAlert` | GET `/repos/{owner}/{repo}/code-scanning/alerts/{alert_number}` | -| `list_secret_scanning_alerts` | `secretScanning.listAlertsForRepo` | GET `/repos/{owner}/{repo}/secret-scanning/alerts` | -| `get_secret_scanning_alert` | `secretScanning.getAlert` | GET `/repos/{owner}/{repo}/secret-scanning/alerts/{alert_number}` | -| `list_dependabot_alerts` | `dependabot.listAlertsForRepo` | GET `/repos/{owner}/{repo}/dependabot/alerts` | -| `get_dependabot_alert` | `dependabot.getAlert` | GET `/repos/{owner}/{repo}/dependabot/alerts/{alert_number}` | -| `list_global_advisories` | `securityAdvisories.listGlobalAdvisories` | GET `/advisories` | -| `get_global_advisory` | `securityAdvisories.getGlobalAdvisory` | GET `/advisories/{ghsa_id}` | -| `list_repository_advisories` | `securityAdvisories.listRepositoryAdvisories` | GET `/repos/{owner}/{repo}/security-advisories` | -| `get_repository_advisory` | `securityAdvisories.getRepositoryAdvisory` | GET `/repos/{owner}/{repo}/security-advisories/{ghsa_id}` | - -**Deliberate scope decisions:** - -1. **Alert update / dismissal excluded:** `codeScanning.updateAlert`, `secretScanning.updateAlert`, `dependabot.updateAlert`. Reason: dismissing/reopening security alerts is a security-critical action that warrants human review; LLM-driven state changes here are risky. -2. **Enterprise-scoped variants excluded:** `dependabot.listAlertsForEnterprise`. Reason: enterprise API requires Enterprise Server or GHEC-with-enterprise-token, out of scope for the general PAT-driven server. -3. **Org-scoped alert lists excluded:** `codeScanning.listAlertsForOrg`, `secretScanning.listAlertsForOrg`, `dependabot.listAlertsForOrg`. Reason: keep the toolset repo-scoped for consistency; the per-repo tools cover typical LLM workflows. Org-scoped can be added in a follow-up if needed. -4. **All secret-management surface excluded:** every `*Secret*`, `*PublicKey*` method under `dependabot`. Reason: not related to alert introspection; secret handling deserves a dedicated plan. -5. **All write / autofix / SARIF operations excluded:** `codeScanning.createAutofix`, `codeScanning.uploadSarif`, `codeScanning.deleteAnalysis`, `codeScanning.commitAutofix`, `secretScanning.createPushProtectionBypass`, `securityAdvisories.create*`/`update*`/`createFork`. Reason: these mutate security state or trigger workflow effects. -6. **Dependency Graph excluded (this plan):** `dependencyGraph.diffRange`, `dependencyGraph.exportSbom`. Reason: SBOM export and dependency-diff are distinct enough workflows to warrant their own follow-up plan if needed; keeping this toolset laser-focused on the "alerts + advisories" surface named in the design row (~10 tools). - ---- - -## Task 1: Implement code_security toolset - -- [x] **Step 1: Write the failing tests** - -```typescript -// test/unit/toolsets/code_security.test.ts -import nock from 'nock'; -import { afterEach, describe, expect, it } from 'vitest'; -import { registerCodeSecurityTools } from '../../../src/toolsets/code_security.js'; -import { connectedClient } from './test-helpers.js'; - -describe('registerCodeSecurityTools', () => { - afterEach(() => nock.cleanAll()); - - it('list_code_scanning_alerts returns raw JSON', async () => { - nock('https://api.github.com') - .get('/repos/acme/foo/code-scanning/alerts') - .query({ page: '1', per_page: '30' }) - .reply(200, [{ number: 1, state: 'open' }]); - - const client = await connectedClient(registerCodeSecurityTools, 'read-write'); - const result = await client.callTool({ - name: 'list_code_scanning_alerts', - arguments: { owner: 'acme', repo: 'foo' }, - }); - expect(result.isError).toBeFalsy(); - const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ''; - expect(JSON.parse(text)).toEqual([{ number: 1, state: 'open' }]); - }); - - it('list_code_scanning_alerts forwards filter/sort to the wire', async () => { - const scope = nock('https://api.github.com') - .get('/repos/acme/foo/code-scanning/alerts') - .query({ tool_name: 'codeql', state: 'open', sort: 'created', direction: 'desc', page: '1', per_page: '30' }) - .reply(200, []); - const client = await connectedClient(registerCodeSecurityTools, 'read-write'); - const result = await client.callTool({ - name: 'list_code_scanning_alerts', - arguments: { owner: 'acme', repo: 'foo', tool_name: 'codeql', state: 'open', sort: 'created', direction: 'desc' }, - }); - expect(result.isError).toBeFalsy(); - expect(scope.isDone()).toBe(true); - }); - - it('get_code_scanning_alert returns raw JSON', async () => { - nock('https://api.github.com') - .get('/repos/acme/foo/code-scanning/alerts/1') - .reply(200, { number: 1, rule: { id: 'js/xss' } }); - const client = await connectedClient(registerCodeSecurityTools, 'read-write'); - const result = await client.callTool({ - name: 'get_code_scanning_alert', - arguments: { owner: 'acme', repo: 'foo', alert_number: 1 }, - }); - expect(result.isError).toBeFalsy(); - const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ''; - expect(JSON.parse(text)).toMatchObject({ number: 1 }); - }); - - it('propagates a 404 from get_code_scanning_alert', async () => { - nock('https://api.github.com') - .get('/repos/acme/foo/code-scanning/alerts/999') - .reply(404, { message: 'Not Found' }); - const client = await connectedClient(registerCodeSecurityTools, 'read-write'); - const result = await client.callTool({ - name: 'get_code_scanning_alert', - arguments: { owner: 'acme', repo: 'foo', alert_number: 999 }, - }); - expect(result.isError).toBe(true); - const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ''; - expect(text).toContain('Not Found'); - }); - - it('list_secret_scanning_alerts returns raw JSON', async () => { - nock('https://api.github.com') - .get('/repos/acme/foo/secret-scanning/alerts') - .query({ page: '1', per_page: '30' }) - .reply(200, [{ number: 1, secret_type: 'github_pat' }]); - const client = await connectedClient(registerCodeSecurityTools, 'read-write'); - const result = await client.callTool({ - name: 'list_secret_scanning_alerts', - arguments: { owner: 'acme', repo: 'foo' }, - }); - expect(result.isError).toBeFalsy(); - }); - - it('get_secret_scanning_alert returns raw JSON', async () => { - nock('https://api.github.com') - .get('/repos/acme/foo/secret-scanning/alerts/1') - .reply(200, { number: 1, secret_type: 'github_pat' }); - const client = await connectedClient(registerCodeSecurityTools, 'read-write'); - const result = await client.callTool({ - name: 'get_secret_scanning_alert', - arguments: { owner: 'acme', repo: 'foo', alert_number: 1 }, - }); - expect(result.isError).toBeFalsy(); - }); - - it('list_dependabot_alerts returns raw JSON', async () => { - nock('https://api.github.com') - .get('/repos/acme/foo/dependabot/alerts') - .query({ page: '1', per_page: '30' }) - .reply(200, [{ number: 1, state: 'open' }]); - const client = await connectedClient(registerCodeSecurityTools, 'read-write'); - const result = await client.callTool({ - name: 'list_dependabot_alerts', - arguments: { owner: 'acme', repo: 'foo' }, - }); - expect(result.isError).toBeFalsy(); - }); - - it('get_dependabot_alert returns raw JSON', async () => { - nock('https://api.github.com') - .get('/repos/acme/foo/dependabot/alerts/1') - .reply(200, { number: 1, state: 'open' }); - const client = await connectedClient(registerCodeSecurityTools, 'read-write'); - const result = await client.callTool({ - name: 'get_dependabot_alert', - arguments: { owner: 'acme', repo: 'foo', alert_number: 1 }, - }); - expect(result.isError).toBeFalsy(); - }); - - it('list_global_advisories returns raw JSON', async () => { - nock('https://api.github.com') - .get('/advisories') - .query({ page: '1', per_page: '30' }) - .reply(200, [{ ghsa_id: 'GHSA-xxxx-yyyy-zzzz' }]); - const client = await connectedClient(registerCodeSecurityTools, 'read-write'); - const result = await client.callTool({ - name: 'list_global_advisories', - arguments: {}, - }); - expect(result.isError).toBeFalsy(); - }); - - it('get_global_advisory returns raw JSON', async () => { - nock('https://api.github.com') - .get('/advisories/GHSA-xxxx-yyyy-zzzz') - .reply(200, { ghsa_id: 'GHSA-xxxx-yyyy-zzzz' }); - const client = await connectedClient(registerCodeSecurityTools, 'read-write'); - const result = await client.callTool({ - name: 'get_global_advisory', - arguments: { ghsa_id: 'GHSA-xxxx-yyyy-zzzz' }, - }); - expect(result.isError).toBeFalsy(); - }); - - it('list_repository_advisories returns raw JSON', async () => { - nock('https://api.github.com') - .get('/repos/acme/foo/security-advisories') - .query({ page: '1', per_page: '30' }) - .reply(200, [{ ghsa_id: 'GHSA-xxxx-yyyy-zzzz' }]); - const client = await connectedClient(registerCodeSecurityTools, 'read-write'); - const result = await client.callTool({ - name: 'list_repository_advisories', - arguments: { owner: 'acme', repo: 'foo' }, - }); - expect(result.isError).toBeFalsy(); - }); - - it('get_repository_advisory returns raw JSON', async () => { - nock('https://api.github.com') - .get('/repos/acme/foo/security-advisories/GHSA-xxxx-yyyy-zzzz') - .reply(200, { ghsa_id: 'GHSA-xxxx-yyyy-zzzz' }); - const client = await connectedClient(registerCodeSecurityTools, 'read-write'); - const result = await client.callTool({ - name: 'get_repository_advisory', - arguments: { owner: 'acme', repo: 'foo', ghsa_id: 'GHSA-xxxx-yyyy-zzzz' }, - }); - expect(result.isError).toBeFalsy(); - }); - - it('registers exactly the 10 tools in both permission modes', async () => { - const expected = [ - 'get_code_scanning_alert', - 'get_dependabot_alert', - 'get_global_advisory', - 'get_repository_advisory', - 'get_secret_scanning_alert', - 'list_code_scanning_alerts', - 'list_dependabot_alerts', - 'list_global_advisories', - 'list_repository_advisories', - 'list_secret_scanning_alerts', - ]; - const ro = await connectedClient(registerCodeSecurityTools, 'read-only'); - expect((await ro.listTools()).tools.map((t) => t.name).sort()).toEqual(expected); - const rw = await connectedClient(registerCodeSecurityTools, 'read-write'); - expect((await rw.listTools()).tools.map((t) => t.name).sort()).toEqual(expected); - }); -}); -``` - -- [x] **Step 2: Verify tests fail** — `npm test -- code_security` (module not found). - -- [x] **Step 3: Create `src/toolsets/code_security.ts`** - -```typescript -import type { McpServer } from '@modelcontextprotocol/server'; -import type { Octokit } from 'octokit'; -import { z } from 'zod'; -import { ownerRepoSchema, paginationSchema, toToolResult, toToolError } from './common.js'; - -const alertNumberSchema = { - alert_number: z.number().int().describe('The GitHub alert number (integer, per-repo).'), -}; - -const ghsaIdSchema = { - ghsa_id: z.string().describe('GHSA advisory ID (e.g. "GHSA-xxxx-yyyy-zzzz").'), -}; - -export function registerCodeSecurityTools( - server: McpServer, - octokit: Octokit, - _permission: 'read-only' | 'read-write', -): void { - server.registerTool( - 'list_code_scanning_alerts', - { - description: 'List code-scanning alerts for a repository. Requires `security_events` PAT scope for private repos, `public_repo` for public.', - inputSchema: z.object({ - ...ownerRepoSchema, - tool_name: z.string().optional().describe('Filter by scanning tool name.'), - state: z.enum(['open', 'closed', 'dismissed', 'fixed']).optional().describe('Filter by alert state.'), - sort: z.enum(['created', 'updated']).optional(), - direction: z.enum(['asc', 'desc']).optional(), - ref: z.string().optional().describe('Git ref to filter by (branch/tag/SHA).'), - ...paginationSchema, - }), - }, - async ({ owner, repo, tool_name, state, sort, direction, ref, page, per_page }) => { - try { - const response = await octokit.rest.codeScanning.listAlertsForRepo({ - owner, repo, tool_name, state, sort, direction, ref, page, per_page, - }); - return toToolResult(response.data); - } catch (error) { - return toToolError(error); - } - }, - ); - - server.registerTool( - 'get_code_scanning_alert', - { - description: 'Get a code-scanning alert. Requires `security_events` PAT scope (private) or `public_repo` (public).', - inputSchema: z.object({ ...ownerRepoSchema, ...alertNumberSchema }), - }, - async ({ owner, repo, alert_number }) => { - try { - const response = await octokit.rest.codeScanning.getAlert({ owner, repo, alert_number }); - return toToolResult(response.data); - } catch (error) { - return toToolError(error); - } - }, - ); - - server.registerTool( - 'list_secret_scanning_alerts', - { - description: 'List secret-scanning alerts for a repository. Requires `security_events` PAT scope.', - inputSchema: z.object({ - ...ownerRepoSchema, - state: z.enum(['open', 'resolved']).optional().describe('Filter by state.'), - secret_type: z.string().optional().describe('Comma-separated list of secret types to filter by.'), - resolution: z.string().optional().describe('Comma-separated list of resolutions to filter by.'), - ...paginationSchema, - }), - }, - async ({ owner, repo, state, secret_type, resolution, page, per_page }) => { - try { - const response = await octokit.rest.secretScanning.listAlertsForRepo({ - owner, repo, state, secret_type, resolution, page, per_page, - }); - return toToolResult(response.data); - } catch (error) { - return toToolError(error); - } - }, - ); - - server.registerTool( - 'get_secret_scanning_alert', - { - description: 'Get a secret-scanning alert. Requires `security_events` PAT scope.', - inputSchema: z.object({ ...ownerRepoSchema, ...alertNumberSchema }), - }, - async ({ owner, repo, alert_number }) => { - try { - const response = await octokit.rest.secretScanning.getAlert({ owner, repo, alert_number }); - return toToolResult(response.data); - } catch (error) { - return toToolError(error); - } - }, - ); - - server.registerTool( - 'list_dependabot_alerts', - { - description: 'List Dependabot alerts for a repository. Requires `security_events` PAT scope.', - inputSchema: z.object({ - ...ownerRepoSchema, - state: z.string().optional().describe('Comma-separated states (e.g. "open,dismissed").'), - severity: z.string().optional().describe('Comma-separated severities.'), - ecosystem: z.string().optional().describe('Comma-separated ecosystems.'), - package: z.string().optional().describe('Comma-separated package names.'), - ...paginationSchema, - }), - }, - async ({ owner, repo, state, severity, ecosystem, package: pkg, page, per_page }) => { - try { - const response = await octokit.rest.dependabot.listAlertsForRepo({ - owner, repo, state, severity, ecosystem, package: pkg, page, per_page, - }); - return toToolResult(response.data); - } catch (error) { - return toToolError(error); - } - }, - ); - - server.registerTool( - 'get_dependabot_alert', - { - description: 'Get a Dependabot alert. Requires `security_events` PAT scope.', - inputSchema: z.object({ ...ownerRepoSchema, ...alertNumberSchema }), - }, - async ({ owner, repo, alert_number }) => { - try { - const response = await octokit.rest.dependabot.getAlert({ owner, repo, alert_number }); - return toToolResult(response.data); - } catch (error) { - return toToolError(error); - } - }, - ); - - server.registerTool( - 'list_global_advisories', - { - description: 'List GitHub Global Security Advisories from the public GHSA database.', - inputSchema: z.object({ - ecosystem: z.string().optional().describe('Filter by package ecosystem (npm, pip, etc.).'), - severity: z.enum(['unknown', 'low', 'medium', 'high', 'critical']).optional(), - cwes: z.string().optional().describe('Comma-separated CWE IDs to filter by.'), - type: z.enum(['reviewed', 'malware', 'unreviewed']).optional(), - ...paginationSchema, - }), - }, - async ({ ecosystem, severity, cwes, type, page, per_page }) => { - try { - const response = await octokit.rest.securityAdvisories.listGlobalAdvisories({ - ecosystem, severity, cwes, type, page, per_page, - }); - return toToolResult(response.data); - } catch (error) { - return toToolError(error); - } - }, - ); - - server.registerTool( - 'get_global_advisory', - { - description: 'Get a global GitHub security advisory by GHSA ID.', - inputSchema: z.object({ ...ghsaIdSchema }), - }, - async ({ ghsa_id }) => { - try { - const response = await octokit.rest.securityAdvisories.getGlobalAdvisory({ ghsa_id }); - return toToolResult(response.data); - } catch (error) { - return toToolError(error); - } - }, - ); - - server.registerTool( - 'list_repository_advisories', - { - description: 'List repository security advisories.', - inputSchema: z.object({ ...ownerRepoSchema, ...paginationSchema }), - }, - async ({ owner, repo, page, per_page }) => { - try { - const response = await octokit.rest.securityAdvisories.listRepositoryAdvisories({ - owner, repo, page, per_page, - }); - return toToolResult(response.data); - } catch (error) { - return toToolError(error); - } - }, - ); - - server.registerTool( - 'get_repository_advisory', - { - description: 'Get a repository security advisory by GHSA ID.', - inputSchema: z.object({ ...ownerRepoSchema, ...ghsaIdSchema }), - }, - async ({ owner, repo, ghsa_id }) => { - try { - const response = await octokit.rest.securityAdvisories.getRepositoryAdvisory({ - owner, repo, ghsa_id, - }); - return toToolResult(response.data); - } catch (error) { - return toToolError(error); - } - }, - ); -} -``` - -- [x] **Step 4: Run tests** — `npm test -- code_security` → 13 PASS. Full suite: 179 + 13 = 192. - -- [x] **Step 5: Commit** — `git commit -m "feat: add code_security toolset (10 read-only tools)"` - ---- - -## Task 2: Wire + README - -- [x] Alphabetically add `registerCodeSecurityTools` import. Add call after `registerProjectsTools`. -- [x] README bullet after `projects`: `- \`code_security\` — code scanning, secret scanning, Dependabot alerts, and security advisories` -- [x] `npm test && npm run typecheck && npm run lint && npm run build` all PASS. -- [x] `git commit -m "feat: wire code_security toolset into buildServer"` diff --git a/docs/superpowers/plans/2026-08-05-github-mcp-server-codespaces.md b/docs/superpowers/plans/2026-08-05-github-mcp-server-codespaces.md deleted file mode 100644 index e1f28e8..0000000 --- a/docs/superpowers/plans/2026-08-05-github-mcp-server-codespaces.md +++ /dev/null @@ -1,371 +0,0 @@ -# github-mcp-server-js — `codespaces` Toolset Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax for tracking. - -**Goal:** Add the `codespaces` toolset (5 tools: 2 read for listing/inspecting the authenticated user's codespaces + 3 write for creating/starting/stopping them) to `github-mcp-server-js`, following the established pattern. - -**Architecture:** Same pattern as `issues`/`gists`/`activity`/`orgs_teams`: one `registerCodespacesTools(server, octokit, permission)` function; every handler wraps its octokit call in try/catch, returning raw JSON via `toToolResult`/`toToolError`; write tools are registered only when `permission === 'read-write'`; `server.ts` gains one more registration call. All octokit calls target authenticated-user endpoints (`codespaces.*ForAuthenticatedUser`) — org-scoped codespace endpoints are excluded (see scope decisions). - -**Tech Stack:** Same as prior plans. No new dependencies. - -## Global Constraints - -- Raw JSON passthrough; raw error passthrough via `toToolError`. -- List tool uses `paginationSchema`. -- Write tools registered inside `if (permission === 'read-write')`. -- Zero collision with 72 existing tool names (verified: codespace tool names all have `codespace` in them, no conflict with existing names). -- TypeScript only, no new runtime dependencies. - ---- - -## File Structure - -``` -src/toolsets/codespaces.ts # NEW -test/unit/toolsets/codespaces.test.ts # NEW -src/server.ts # MODIFIED -README.md # MODIFIED -``` - ---- - -## Reference: verified octokit shapes - -| Tool | octokit method | HTTP | Params | -|---|---|---|---| -| `list_codespaces` | `codespaces.listForAuthenticatedUser` | GET `/user/codespaces` | query: `repository_id`, `page`, `per_page` | -| `get_codespace` | `codespaces.getForAuthenticatedUser` | GET `/user/codespaces/{codespace_name}` | path: `codespace_name` | -| `create_codespace_in_repo` | `codespaces.createWithRepoForAuthenticatedUser` | POST `/repos/{owner}/{repo}/codespaces` | path: `owner`, `repo`; body: `ref` (optional), `location` (optional), `machine` (optional), `devcontainer_path` (optional), `working_directory` (optional), `display_name` (optional) | -| `start_codespace` | `codespaces.startForAuthenticatedUser` | POST `/user/codespaces/{codespace_name}/start` | path: `codespace_name` | -| `stop_codespace` | `codespaces.stopForAuthenticatedUser` | POST `/user/codespaces/{codespace_name}/stop` | path: `codespace_name` | - -Response bodies: -- `list_codespaces` → `{ total_count, codespaces: codespace[] }` -- `get_codespace`, `create_codespace_in_repo`, `start_codespace`, `stop_codespace` → `codespace` - -**Deliberate scope decisions:** - -1. **Excluded org-scoped codespace management:** `listInOrganization`, `getCodespacesForUserInOrg`, `deleteFromOrganization`, `stopInOrganization`. Reason: org codespace admin is a niche org-admin surface not aligned with the design's user-focused scope. -2. **Excluded codespace secrets management:** every `*Secret*`, `*PublicKey*` method. Reason: secret handling is high-risk and warrants a dedicated plan if needed. -3. **Excluded delete/publish/export:** `deleteForAuthenticatedUser`, `publishForAuthenticatedUser`, `exportForAuthenticatedUser`. Reason: destructive/rare-workflow operations preferred to be human-driven. -4. **Excluded devcontainer discovery:** `listDevcontainersInRepositoryForAuthenticatedUser`, `checkPermissionsForDevcontainer`, `preFlightWithRepoForAuthenticatedUser`, `repoMachinesForAuthenticatedUser`. Reason: LLM callers typically know which devcontainer/machine they want; these introspection tools add surface without high LLM-utility value at v1 scope. - ---- - -## Task 1: Implement the `codespaces` toolset - -**Files:** -- Create: `src/toolsets/codespaces.ts` -- Test: `test/unit/toolsets/codespaces.test.ts` - -- [x] **Step 1: Write the failing tests** - -```typescript -// test/unit/toolsets/codespaces.test.ts -import nock from 'nock'; -import { afterEach, describe, expect, it } from 'vitest'; -import { registerCodespacesTools } from '../../../src/toolsets/codespaces.js'; -import { connectedClient } from './test-helpers.js'; - -describe('registerCodespacesTools', () => { - afterEach(() => { - nock.cleanAll(); - }); - - it('registers list_codespaces and returns the raw envelope as JSON', async () => { - nock('https://api.github.com') - .get('/user/codespaces') - .query({ page: '1', per_page: '30' }) - .reply(200, { total_count: 1, codespaces: [{ name: 'cs-1', state: 'Available' }] }); - - const client = await connectedClient(registerCodespacesTools, 'read-write'); - const result = await client.callTool({ - name: 'list_codespaces', - arguments: {}, - }); - - expect(result.isError).toBeFalsy(); - const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ''; - expect(JSON.parse(text)).toEqual({ total_count: 1, codespaces: [{ name: 'cs-1', state: 'Available' }] }); - }); - - it('forwards repository_id filter on list_codespaces to the wire', async () => { - const scope = nock('https://api.github.com') - .get('/user/codespaces') - .query({ repository_id: '42', page: '2', per_page: '10' }) - .reply(200, { total_count: 0, codespaces: [] }); - - const client = await connectedClient(registerCodespacesTools, 'read-write'); - const result = await client.callTool({ - name: 'list_codespaces', - arguments: { repository_id: 42, page: 2, per_page: 10 }, - }); - - expect(result.isError).toBeFalsy(); - expect(scope.isDone()).toBe(true); - }); - - it('registers get_codespace and returns the raw response as JSON', async () => { - nock('https://api.github.com') - .get('/user/codespaces/cs-1') - .reply(200, { name: 'cs-1', state: 'Available' }); - - const client = await connectedClient(registerCodespacesTools, 'read-write'); - const result = await client.callTool({ - name: 'get_codespace', - arguments: { codespace_name: 'cs-1' }, - }); - - expect(result.isError).toBeFalsy(); - const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ''; - expect(JSON.parse(text)).toMatchObject({ name: 'cs-1' }); - }); - - it('propagates a 404 from get_codespace as an MCP tool error', async () => { - nock('https://api.github.com') - .get('/user/codespaces/missing') - .reply(404, { message: 'Not Found' }); - - const client = await connectedClient(registerCodespacesTools, 'read-write'); - const result = await client.callTool({ - name: 'get_codespace', - arguments: { codespace_name: 'missing' }, - }); - - expect(result.isError).toBe(true); - const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ''; - expect(text).toContain('Not Found'); - }); - - it('registers create_codespace_in_repo and forwards optional body fields', async () => { - nock('https://api.github.com') - .post('/repos/acme/foo/codespaces', { ref: 'main', machine: 'basicLinux32gb' }) - .reply(201, { name: 'cs-new', state: 'Provisioning' }); - - const client = await connectedClient(registerCodespacesTools, 'read-write'); - const result = await client.callTool({ - name: 'create_codespace_in_repo', - arguments: { owner: 'acme', repo: 'foo', ref: 'main', machine: 'basicLinux32gb' }, - }); - - expect(result.isError).toBeFalsy(); - const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ''; - expect(JSON.parse(text)).toMatchObject({ name: 'cs-new', state: 'Provisioning' }); - }); - - it('registers start_codespace and returns the raw codespace response', async () => { - nock('https://api.github.com') - .post('/user/codespaces/cs-1/start') - .reply(200, { name: 'cs-1', state: 'Starting' }); - - const client = await connectedClient(registerCodespacesTools, 'read-write'); - const result = await client.callTool({ - name: 'start_codespace', - arguments: { codespace_name: 'cs-1' }, - }); - - expect(result.isError).toBeFalsy(); - }); - - it('registers stop_codespace and returns the raw codespace response', async () => { - nock('https://api.github.com') - .post('/user/codespaces/cs-1/stop') - .reply(200, { name: 'cs-1', state: 'Stopping' }); - - const client = await connectedClient(registerCodespacesTools, 'read-write'); - const result = await client.callTool({ - name: 'stop_codespace', - arguments: { codespace_name: 'cs-1' }, - }); - - expect(result.isError).toBeFalsy(); - }); - - it('registers exactly the 2 read tools in read-only mode', async () => { - const client = await connectedClient(registerCodespacesTools, 'read-only'); - const { tools } = await client.listTools(); - expect(tools.map((t) => t.name).sort()).toEqual([ - 'get_codespace', - 'list_codespaces', - ]); - }); - - it('registers all 5 tools in read-write mode', async () => { - const client = await connectedClient(registerCodespacesTools, 'read-write'); - const { tools } = await client.listTools(); - expect(tools.map((t) => t.name).sort()).toEqual([ - 'create_codespace_in_repo', - 'get_codespace', - 'list_codespaces', - 'start_codespace', - 'stop_codespace', - ]); - }); -}); -``` - -- [x] **Step 2: Run tests to verify they fail** - -Run: `npm test -- codespaces` -Expected: FAIL (module not found). - -- [x] **Step 3: Create `src/toolsets/codespaces.ts`** - -```typescript -import type { McpServer } from '@modelcontextprotocol/server'; -import type { Octokit } from 'octokit'; -import { z } from 'zod'; -import { ownerRepoSchema, paginationSchema, toToolResult, toToolError } from './common.js'; - -const codespaceNameSchema = { - codespace_name: z.string().describe('The name of the codespace (e.g. "octocat-happy-space-1234").'), -}; - -export function registerCodespacesTools( - server: McpServer, - octokit: Octokit, - permission: 'read-only' | 'read-write', -): void { - server.registerTool( - 'list_codespaces', - { - description: 'List codespaces for the authenticated user.', - inputSchema: z.object({ - repository_id: z.number().int().optional().describe('Filter by repository ID.'), - ...paginationSchema, - }), - }, - async ({ repository_id, page, per_page }) => { - try { - const response = await octokit.rest.codespaces.listForAuthenticatedUser({ - repository_id, - page, - per_page, - }); - return toToolResult(response.data); - } catch (error) { - return toToolError(error); - } - }, - ); - - server.registerTool( - 'get_codespace', - { - description: 'Get a codespace by name for the authenticated user.', - inputSchema: z.object({ ...codespaceNameSchema }), - }, - async ({ codespace_name }) => { - try { - const response = await octokit.rest.codespaces.getForAuthenticatedUser({ codespace_name }); - return toToolResult(response.data); - } catch (error) { - return toToolError(error); - } - }, - ); - - if (permission === 'read-write') { - server.registerTool( - 'create_codespace_in_repo', - { - description: 'Create a codespace in a repository for the authenticated user.', - inputSchema: z.object({ - ...ownerRepoSchema, - ref: z.string().optional().describe('Git ref (branch/tag/SHA) to base the codespace on.'), - location: z.string().optional().describe('Preferred Azure region (e.g. "WestUs2").'), - machine: z.string().optional().describe('Machine type (e.g. "basicLinux32gb").'), - devcontainer_path: z.string().optional().describe('Path to devcontainer.json inside the repo.'), - working_directory: z.string().optional().describe('Working directory inside the codespace.'), - display_name: z.string().optional().describe('Human-readable name for the codespace.'), - }), - }, - async ({ owner, repo, ref, location, machine, devcontainer_path, working_directory, display_name }) => { - try { - const response = await octokit.rest.codespaces.createWithRepoForAuthenticatedUser({ - owner, - repo, - ref, - location, - machine, - devcontainer_path, - working_directory, - display_name, - }); - return toToolResult(response.data); - } catch (error) { - return toToolError(error); - } - }, - ); - - server.registerTool( - 'start_codespace', - { - description: 'Start a stopped codespace for the authenticated user.', - inputSchema: z.object({ ...codespaceNameSchema }), - }, - async ({ codespace_name }) => { - try { - const response = await octokit.rest.codespaces.startForAuthenticatedUser({ codespace_name }); - return toToolResult(response.data); - } catch (error) { - return toToolError(error); - } - }, - ); - - server.registerTool( - 'stop_codespace', - { - description: 'Stop a running codespace for the authenticated user.', - inputSchema: z.object({ ...codespaceNameSchema }), - }, - async ({ codespace_name }) => { - try { - const response = await octokit.rest.codespaces.stopForAuthenticatedUser({ codespace_name }); - return toToolResult(response.data); - } catch (error) { - return toToolError(error); - } - }, - ); - } -} -``` - -- [x] **Step 4: Run tests, then full suite** - -Run: `npm test -- codespaces` → PASS (9 tests). Then `npm test` → 162 (prior) + 9 = 171 passing. - -- [x] **Step 5: Commit** - -```bash -git add src/toolsets/codespaces.ts test/unit/toolsets/codespaces.test.ts -git commit -m "feat: add codespaces toolset (2 read + 3 write tools)" -``` - ---- - -## Task 2: Wire into `server.ts` - -- [x] **Step 1: Modify `src/server.ts`** - -Add alphabetically-sorted import for `registerCodespacesTools`. Add call after `registerOrgsTeamsTools` (ship-order). - -- [x] **Step 2: Add README bullet after `orgs_teams`** - -```markdown -- `codespaces` — list, inspect, create, start, and stop codespaces for the authenticated user -``` - -- [x] **Step 3: Run full CI checks** - -`npm test && npm run typecheck && npm run lint && npm run build` — all must PASS. - -- [x] **Step 4: Commit** - -```bash -git add src/server.ts README.md -git commit -m "feat: wire codespaces toolset into buildServer" -``` diff --git a/docs/superpowers/plans/2026-08-05-github-mcp-server-copilot.md b/docs/superpowers/plans/2026-08-05-github-mcp-server-copilot.md deleted file mode 100644 index 01d292c..0000000 --- a/docs/superpowers/plans/2026-08-05-github-mcp-server-copilot.md +++ /dev/null @@ -1,802 +0,0 @@ -# github-mcp-server-js — `copilot` Toolset Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax for tracking. - -**Goal:** Add the `copilot` toolset (3 read-only tools wrapping GitHub's Copilot org-admin endpoints) to `github-mcp-server-js`, following the exact structural, permission-gating, and response/error pattern established by the `misc`, `apps`, and `packages` toolsets. - -**Architecture:** Same pattern as prior toolsets: one `registerCopilotTools(server, octokit, permission)` function registers each tool with `server.registerTool(name, {description, inputSchema}, handler)`; every handler wraps its octokit call in try/catch, returning raw data via `toToolResult`/`toToolError`; `server.ts` gains one more registration call. All octokit calls use the `copilot` namespace (`octokit.rest.copilot.*`). Because every selected endpoint is read-only, the `permission` parameter is accepted (to keep the signature uniform) but never inspected; it is renamed `_permission` inside the function body to satisfy `@typescript-eslint/no-unused-vars`. - -**Auth note (critical):** All three Copilot endpoints in this toolset require the caller to be an **organization owner** or **enterprise billing manager**. A PAT that has the right scopes (`manage_billing:copilot` or `read:org`) but belongs to a non-owner org member will receive `403 Forbidden`. A PAT pointing at an org that does not have a Copilot Business or Copilot Enterprise subscription will receive `404 Not Found` (org does not have Copilot enabled) or `422 Unprocessable Entity` (subscription issue). These auth limitations are documented in each tool's description and in the test error-case assertions. No endpoints in the `copilot` namespace are usable without org-owner or enterprise-admin privileges — this is a GitHub API constraint that cannot be worked around. - -**Tech Stack:** Same as prior plans — TypeScript, `@modelcontextprotocol/server@^2.0.0`, `octokit@^5.0.5`, `zod@^4.2.0`, `vitest`, `nock`. No new dependencies. - -## Global Constraints - -- Tool responses return the raw octokit response body as JSON, unmodified. Each tool returns the exact schema documented in the Octokit Verification section. Callers extract fields themselves. -- Errors propagate unmodified: catch the octokit `RequestError`, surface `error.message` in an MCP tool error result via `toToolError`. No normalization layer. -- `get_copilot_organization_details` and `get_copilot_seat_details_for_user` return single objects — no pagination. `list_copilot_seats` is paginated: takes `page` and `per_page` via `paginationSchema`. -- `GITHUB_PERMISSION=read-only` gating is a no-op for this toolset because every tool is read-only. The read-only test still verifies the exact set of 3 tools is registered using `.toEqual([...exact set...])`, not `arrayContaining`, following the pattern established by prior plans. -- Verified tool-name collision check against all 61 existing tool names (8 repos + 12 issues + 10 pull_requests + 5 search + 5 users + 5 gists + 5 activity + 4 packages + 4 misc + 3 apps): **zero collisions** with the 3 new names (`get_copilot_organization_details`, `list_copilot_seats`, `get_copilot_seat_details_for_user`). None of these appear anywhere in the current tool name set. -- No modification to `common.ts`. -- TypeScript only, no new runtime dependencies. - ---- - -## Octokit Verification Results - -Verified via `node --input-type=module -e "..."` against the installed `octokit@^5.0.5`: - -### `octokit.rest.copilot` — full method table (sorted) - -| Method | HTTP | URL | -|---|---|---| -| `addCopilotSeatsForTeams` | POST | `/orgs/{org}/copilot/billing/selected_teams` | -| `addCopilotSeatsForUsers` | POST | `/orgs/{org}/copilot/billing/selected_users` | -| `cancelCopilotSeatAssignmentForTeams` | DELETE | `/orgs/{org}/copilot/billing/selected_teams` | -| `cancelCopilotSeatAssignmentForUsers` | DELETE | `/orgs/{org}/copilot/billing/selected_users` | -| `copilotMetricsForOrganization` | GET | `/orgs/{org}/copilot/metrics` | -| `copilotMetricsForTeam` | GET | `/orgs/{org}/team/{team_slug}/copilot/metrics` | -| `getCopilotOrganizationDetails` | GET | `/orgs/{org}/copilot/billing` | -| `getCopilotSeatDetailsForUser` | GET | `/orgs/{org}/members/{username}/copilot` | -| `listCopilotSeats` | GET | `/orgs/{org}/copilot/billing/seats` | - -**Octokit surprise — metrics methods use the name `copilotMetrics*` not `listCopilotMetrics*`:** The two metrics methods are named `copilotMetricsForOrganization` and `copilotMetricsForTeam` (not prefixed with `list` or `get`). This naming is unusual compared to other octokit REST methods (which consistently use `list*`, `get*`, `create*`, etc.) but reflects how the underlying REST endpoint name maps to the method (the operation ID is `copilot/copilot-metrics-for-organization`). Both are GET endpoints returning arrays of daily metrics objects. - -**Octokit surprise — seat management write endpoints present:** The `copilot` namespace includes 4 write/mutating endpoints (`addCopilotSeatsForTeams`, `addCopilotSeatsForUsers`, `cancelCopilotSeatAssignmentForTeams`, `cancelCopilotSeatAssignmentForUsers`). These are all excluded — they are seat provisioning/deprovisioning operations (write) and require `manage_billing:copilot` or `admin:org` scope. - -**Auth compatibility note:** All 9 methods in the `copilot` namespace require org-owner or enterprise-admin credentials. There are no public or PAT-user-context endpoints in this namespace (unlike the `apps` namespace which had `/user/installations` endpoints). Every read endpoint requires either `manage_billing:copilot` or `read:org` scope, **and** the calling user must be an org owner. This means the copilot toolset is strictly for org admins; regular PAT users will receive `403` from every tool. - ---- - -## Reference: Verified Octokit Parameter Shapes - -Confirmed against `@octokit/openapi-types/types.d.ts` operations: - -| Tool | octokit method | HTTP | Path params | Query params | Response body type | -|---|---|---|---|---|---| -| `get_copilot_organization_details` | `copilot.getCopilotOrganizationDetails` | GET `/orgs/{org}/copilot/billing` | `org` (string) | — | `copilot-organization-details` (JSON) | -| `list_copilot_seats` | `copilot.listCopilotSeats` | GET `/orgs/{org}/copilot/billing/seats` | `org` (string) | `page?`, `per_page?` | `{ total_seats?, seats?: copilot-seat-details[] }` (JSON) | -| `get_copilot_seat_details_for_user` | `copilot.getCopilotSeatDetailsForUser` | GET `/orgs/{org}/members/{username}/copilot` | `org` (string), `username` (string) | — | `copilot-seat-details` (JSON) | - -**`copilot-organization-details` schema** (from `components["schemas"]["copilot-organization-details"]`): -``` -{ - seat_breakdown: { - total?: number, - added_this_cycle?: number, - pending_cancellation?: number, - pending_invitation?: number, - active_this_cycle?: number, - inactive_this_cycle?: number - }, - public_code_suggestions: "allow" | "block" | "unconfigured", - ide_chat?: "enabled" | "disabled" | "unconfigured", - platform_chat?: "enabled" | "disabled" | "unconfigured", - cli?: "enabled" | "disabled" | "unconfigured", - seat_management_setting: "assign_all" | "assign_selected" | "disabled" | "unconfigured", - plan_type?: "business" | "enterprise", - [key: string]: unknown -} -``` -Status codes: `200 OK`, `401 Unauthorized`, `403 Forbidden`, `404 Not Found`, `422 Unprocessable Entity`, `500 Internal Server Error`. - -**`list-copilot-seats` response** (from `operations["copilot/list-copilot-seats"]`): -``` -{ - total_seats?: number, - seats?: copilot-seat-details[] -} -``` -Status codes: `200 OK`, `401 Unauthorized`, `403 Forbidden`, `404 Not Found`, `500 Internal Server Error`. - -**`copilot-seat-details` schema** (from `components["schemas"]["copilot-seat-details"]`): -``` -{ - assignee?: nullable-simple-user, - organization?: nullable-organization-simple, - assigning_team?: team | enterprise-team | null, - pending_cancellation_date?: string | null, - last_activity_at?: string | null, - last_activity_editor?: string | null, - last_authenticated_at?: string | null, - created_at: string, - updated_at?: string, // deprecated - plan_type?: "business" | "enterprise" | "unknown" -} -``` - -**`get-copilot-seat-details-for-user` response** (from `operations["copilot/get-copilot-seat-details-for-user"]`): -Returns a single `copilot-seat-details` object. -Status codes: `200 OK`, `401 Unauthorized`, `403 Forbidden`, `404 Not Found`, `422 Unprocessable Entity`, `500 Internal Server Error`. - -**Auth notes from OpenAPI spec:** -- `getCopilotOrganizationDetails`: needs `manage_billing:copilot` or `read:org` scope; only organization owners can call this. -- `listCopilotSeats`: needs `manage_billing:copilot` or `read:org` scope; only organization owners can view assigned seats. -- `getCopilotSeatDetailsForUser`: needs `manage_billing:copilot` or `read:org` scope; only organization owners can view seat details for members. - -**`per_page` note for `listCopilotSeats`:** The OpenAPI spec defines `per_page` as a plain `number` (not a reference to the standard `per-page` parameter). The effective maximum is 100 (consistent with GitHub's standard pagination). `paginationSchema` from `common.ts` uses `max(100).default(30)`, which matches. - -**cspell.json note:** The word `copilot` is recognized by cspell's default English dictionary (it is a standard word). No new words need to be added to `cspell.json` for this toolset. `org` and `username` are standard identifiers not spell-checked as prose. If cspell flags `unconfigured` in a description string, add it to `cspell.json`'s `words` array at pre-commit time — but this is expected to be a no-op since the word does not appear in source strings (it only appears in this plan document). - ---- - -## File Structure - -``` -github-mcp-server-js/ - src/ - toolsets/ - copilot.ts # NEW: registerCopilotTools(server, octokit, permission) - server.ts # MODIFIED: calls registerCopilotTools - test/ - unit/ - toolsets/ - copilot.test.ts # NEW: mirrors apps.test.ts's structure -``` - -`common.ts` is NOT modified. `README.md`'s Toolsets section is updated in Task 2 (Step 5). - ---- - -## Task 1: Implement the `copilot` toolset (3 tools) and its tests - -**Files:** -- Create: `src/toolsets/copilot.ts` -- Create: `test/unit/toolsets/copilot.test.ts` - -**Interfaces:** -- Consumes: `paginationSchema`, `toToolResult`, `toToolError` from `./common.js`. Does NOT need `ownerRepoSchema`, `issueNumberSchema`, or `pullNumberSchema`. -- Produces (for Task 2 / `server.ts` to consume): - - `registerCopilotTools(server: McpServer, octokit: Octokit, permission: 'read-only' | 'read-write'): void` - -- [x] **Step 1: Write the failing tests** - -Create `test/unit/toolsets/copilot.test.ts`: - -```typescript -import nock from 'nock'; -import { afterEach, describe, expect, it } from 'vitest'; -import { registerCopilotTools } from '../../../src/toolsets/copilot.js'; -import { connectedClient } from './test-helpers.js'; - -describe('registerCopilotTools', () => { - afterEach(() => { - nock.cleanAll(); - }); - - // ── get_copilot_organization_details ────────────────────────────────────── - - it('registers get_copilot_organization_details and returns the raw org details as JSON', async () => { - nock('https://api.github.com') - .get('/orgs/test-org/copilot/billing') - .reply(200, { - seat_breakdown: { - total: 10, - added_this_cycle: 2, - pending_cancellation: 0, - pending_invitation: 1, - active_this_cycle: 8, - inactive_this_cycle: 2, - }, - public_code_suggestions: 'block', - ide_chat: 'enabled', - platform_chat: 'enabled', - cli: 'enabled', - seat_management_setting: 'assign_selected', - plan_type: 'business', - }); - - const client = await connectedClient(registerCopilotTools, 'read-write'); - const result = await client.callTool({ - name: 'get_copilot_organization_details', - arguments: { org: 'test-org' }, - }); - - expect(result.isError).toBeFalsy(); - const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ''; - const parsed = JSON.parse(text) as { - seat_breakdown: { total: number; active_this_cycle: number }; - plan_type: string; - public_code_suggestions: string; - }; - expect(parsed.seat_breakdown.total).toBe(10); - expect(parsed.seat_breakdown.active_this_cycle).toBe(8); - expect(parsed.plan_type).toBe('business'); - expect(parsed.public_code_suggestions).toBe('block'); - }); - - it('propagates a 403 from get_copilot_organization_details as an MCP tool error', async () => { - nock('https://api.github.com') - .get('/orgs/test-org/copilot/billing') - .reply(403, { - message: 'Forbidden', - documentation_url: 'https://docs.github.com/rest', - }); - - const client = await connectedClient(registerCopilotTools, 'read-write'); - const result = await client.callTool({ - name: 'get_copilot_organization_details', - arguments: { org: 'test-org' }, - }); - - expect(result.isError).toBe(true); - const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ''; - expect(text).toContain('Forbidden'); - }); - - it('propagates a 404 from get_copilot_organization_details as an MCP tool error', async () => { - // 404 occurs when the org does not have a Copilot subscription. - nock('https://api.github.com') - .get('/orgs/test-org/copilot/billing') - .reply(404, { - message: 'Not Found', - documentation_url: 'https://docs.github.com/rest', - }); - - const client = await connectedClient(registerCopilotTools, 'read-write'); - const result = await client.callTool({ - name: 'get_copilot_organization_details', - arguments: { org: 'test-org' }, - }); - - expect(result.isError).toBe(true); - const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ''; - expect(text).toContain('Not Found'); - }); - - // ── list_copilot_seats ──────────────────────────────────────────────────── - - it('registers list_copilot_seats and returns the raw seat list as JSON', async () => { - nock('https://api.github.com') - .get('/orgs/test-org/copilot/billing/seats') - .query({ page: '1', per_page: '30' }) - .reply(200, { - total_seats: 2, - seats: [ - { - assignee: { - login: 'alice', - id: 101, - node_id: 'U_alice', - avatar_url: 'https://avatars.githubusercontent.com/u/101', - url: 'https://api.github.com/users/alice', - html_url: 'https://github.com/alice', - type: 'User', - site_admin: false, - }, - created_at: '2024-01-15T00:00:00Z', - last_activity_at: '2024-08-01T10:00:00Z', - last_activity_editor: 'vscode/1.90.0', - plan_type: 'business', - }, - { - assignee: { - login: 'bob', - id: 102, - node_id: 'U_bob', - avatar_url: 'https://avatars.githubusercontent.com/u/102', - url: 'https://api.github.com/users/bob', - html_url: 'https://github.com/bob', - type: 'User', - site_admin: false, - }, - created_at: '2024-02-20T00:00:00Z', - last_activity_at: null, - last_activity_editor: null, - plan_type: 'business', - }, - ], - }); - - const client = await connectedClient(registerCopilotTools, 'read-write'); - const result = await client.callTool({ - name: 'list_copilot_seats', - arguments: { org: 'test-org' }, - }); - - expect(result.isError).toBeFalsy(); - const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ''; - const parsed = JSON.parse(text) as { - total_seats: number; - seats: Array<{ assignee: { login: string }; plan_type: string }>; - }; - expect(parsed.total_seats).toBe(2); - expect(parsed.seats).toHaveLength(2); - expect(parsed.seats[0]).toMatchObject({ assignee: { login: 'alice' }, plan_type: 'business' }); - expect(parsed.seats[1]).toMatchObject({ assignee: { login: 'bob' } }); - }); - - it('forwards org and pagination on list_copilot_seats to the wire', async () => { - const scope = nock('https://api.github.com') - .get('/orgs/test-org/copilot/billing/seats') - .query({ page: '2', per_page: '10' }) - .reply(200, { total_seats: 0, seats: [] }); - - const client = await connectedClient(registerCopilotTools, 'read-write'); - const result = await client.callTool({ - name: 'list_copilot_seats', - arguments: { org: 'test-org', page: 2, per_page: 10 }, - }); - - expect(result.isError).toBeFalsy(); - expect(scope.isDone()).toBe(true); - }); - - it('propagates a 403 from list_copilot_seats as an MCP tool error', async () => { - nock('https://api.github.com') - .get('/orgs/test-org/copilot/billing/seats') - .query({ page: '1', per_page: '30' }) - .reply(403, { - message: 'Forbidden', - documentation_url: 'https://docs.github.com/rest', - }); - - const client = await connectedClient(registerCopilotTools, 'read-write'); - const result = await client.callTool({ - name: 'list_copilot_seats', - arguments: { org: 'test-org' }, - }); - - expect(result.isError).toBe(true); - const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ''; - expect(text).toContain('Forbidden'); - }); - - // ── get_copilot_seat_details_for_user ───────────────────────────────────── - - it('registers get_copilot_seat_details_for_user and returns the raw seat details as JSON', async () => { - nock('https://api.github.com') - .get('/orgs/test-org/members/alice/copilot') - .reply(200, { - assignee: { - login: 'alice', - id: 101, - node_id: 'U_alice', - avatar_url: 'https://avatars.githubusercontent.com/u/101', - url: 'https://api.github.com/users/alice', - html_url: 'https://github.com/alice', - type: 'User', - site_admin: false, - }, - created_at: '2024-01-15T00:00:00Z', - last_activity_at: '2024-08-01T10:00:00Z', - last_activity_editor: 'vscode/1.90.0', - last_authenticated_at: '2024-08-01T09:55:00Z', - pending_cancellation_date: null, - plan_type: 'business', - }); - - const client = await connectedClient(registerCopilotTools, 'read-write'); - const result = await client.callTool({ - name: 'get_copilot_seat_details_for_user', - arguments: { org: 'test-org', username: 'alice' }, - }); - - expect(result.isError).toBeFalsy(); - const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ''; - const parsed = JSON.parse(text) as { - assignee: { login: string }; - last_activity_editor: string; - plan_type: string; - }; - expect(parsed.assignee.login).toBe('alice'); - expect(parsed.last_activity_editor).toBe('vscode/1.90.0'); - expect(parsed.plan_type).toBe('business'); - }); - - it('forwards org and username on get_copilot_seat_details_for_user to the wire', async () => { - const scope = nock('https://api.github.com') - .get('/orgs/acme-corp/members/bob/copilot') - .reply(200, { - assignee: { login: 'bob', id: 102 }, - created_at: '2024-02-20T00:00:00Z', - }); - - const client = await connectedClient(registerCopilotTools, 'read-write'); - const result = await client.callTool({ - name: 'get_copilot_seat_details_for_user', - arguments: { org: 'acme-corp', username: 'bob' }, - }); - - expect(result.isError).toBeFalsy(); - expect(scope.isDone()).toBe(true); - }); - - it('propagates a 404 from get_copilot_seat_details_for_user as an MCP tool error', async () => { - // 404 means the user does not have a Copilot seat in this org. - nock('https://api.github.com') - .get('/orgs/test-org/members/unknown-user/copilot') - .reply(404, { - message: 'Not Found', - documentation_url: 'https://docs.github.com/rest', - }); - - const client = await connectedClient(registerCopilotTools, 'read-write'); - const result = await client.callTool({ - name: 'get_copilot_seat_details_for_user', - arguments: { org: 'test-org', username: 'unknown-user' }, - }); - - expect(result.isError).toBe(true); - const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ''; - expect(text).toContain('Not Found'); - }); - - it('propagates a 403 from get_copilot_seat_details_for_user as an MCP tool error', async () => { - // 403 means the PAT user is not an org owner. - nock('https://api.github.com') - .get('/orgs/test-org/members/alice/copilot') - .reply(403, { - message: 'Forbidden', - documentation_url: 'https://docs.github.com/rest', - }); - - const client = await connectedClient(registerCopilotTools, 'read-write'); - const result = await client.callTool({ - name: 'get_copilot_seat_details_for_user', - arguments: { org: 'test-org', username: 'alice' }, - }); - - expect(result.isError).toBe(true); - const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ''; - expect(text).toContain('Forbidden'); - }); - - // ── registration count ──────────────────────────────────────────────────── - - it('registers exactly the 3 copilot tools in read-only mode (and the same set in read-write)', async () => { - const expected = [ - 'get_copilot_organization_details', - 'get_copilot_seat_details_for_user', - 'list_copilot_seats', - ]; - - const readOnlyClient = await connectedClient(registerCopilotTools, 'read-only'); - const readOnlyTools = await readOnlyClient.listTools(); - expect(readOnlyTools.tools.map((t) => t.name).sort()).toEqual(expected); - - const readWriteClient = await connectedClient(registerCopilotTools, 'read-write'); - const readWriteTools = await readWriteClient.listTools(); - expect(readWriteTools.tools.map((t) => t.name).sort()).toEqual(expected); - }); -}); -``` - -- [x] **Step 2: Run tests to verify they fail** - -Run: `npm test -- copilot` -Expected: FAIL with a module-not-found error for `../../../src/toolsets/copilot.js` (the file doesn't exist yet). - -- [x] **Step 3: Create `src/toolsets/copilot.ts`** - -```typescript -import type { McpServer } from '@modelcontextprotocol/server'; -import type { Octokit } from 'octokit'; -import { z } from 'zod'; -import { paginationSchema, toToolError, toToolResult } from './common.js'; - -export function registerCopilotTools( - server: McpServer, - octokit: Octokit, - _permission: 'read-only' | 'read-write', -): void { - // ── get_copilot_organization_details ────────────────────────────────────── - - server.registerTool( - 'get_copilot_organization_details', - { - description: - 'Get GitHub Copilot seat information and policy settings for an organization. ' + - 'Returns seat breakdown (total, active, inactive, pending cancellation, pending invitation, ' + - 'added this cycle), subscription plan type (business or enterprise), and policy settings ' + - '(public code suggestions filter, IDE chat, platform chat, CLI enablement, ' + - 'seat management mode). ' + - 'Requires the authenticated user to be an organization owner. ' + - 'Requires a token with manage_billing:copilot or read:org scope. ' + - 'Returns 404 if the organization does not have a Copilot Business or Enterprise subscription. ' + - 'Returns 403 if the token lacks sufficient scope or the user is not an org owner.', - inputSchema: z.object({ - org: z.string().describe('The organization login name (e.g. "my-company").'), - }), - }, - async ({ org }) => { - try { - const response = await octokit.rest.copilot.getCopilotOrganizationDetails({ org }); - return toToolResult(response.data); - } catch (error) { - return toToolError(error); - } - }, - ); - - // ── list_copilot_seats ──────────────────────────────────────────────────── - - server.registerTool( - 'list_copilot_seats', - { - description: - 'List all GitHub Copilot seat assignments for an organization. ' + - 'Returns each assigned seat with the assignee user details, the team or organization ' + - 'through which access was granted, the seat creation date, last Copilot activity timestamp, ' + - 'last editor used, and pending cancellation date if applicable. ' + - 'Requires the authenticated user to be an organization owner. ' + - 'Requires a token with manage_billing:copilot or read:org scope. ' + - 'Returns 404 if the organization does not have a Copilot subscription. ' + - 'Returns 403 if the token lacks sufficient scope or the user is not an org owner. ' + - 'Paginate with page and per_page (default 30, max 100).', - inputSchema: z.object({ - org: z.string().describe('The organization login name (e.g. "my-company").'), - ...paginationSchema, - }), - }, - async ({ org, page, per_page }) => { - try { - const response = await octokit.rest.copilot.listCopilotSeats({ org, page, per_page }); - return toToolResult(response.data); - } catch (error) { - return toToolError(error); - } - }, - ); - - // ── get_copilot_seat_details_for_user ───────────────────────────────────── - - server.registerTool( - 'get_copilot_seat_details_for_user', - { - description: - 'Get GitHub Copilot seat assignment details for a specific member of an organization. ' + - 'Returns the seat creation date, last Copilot activity timestamp, last editor used, ' + - 'last authentication timestamp, pending cancellation date (if applicable), ' + - 'the team or organization granting access, and the Copilot plan type. ' + - 'Only returns results for users who currently have an active Copilot seat. ' + - 'Users must have telemetry enabled in their IDE for activity data to be populated. ' + - 'Requires the authenticated user to be an organization owner. ' + - 'Requires a token with manage_billing:copilot or read:org scope. ' + - 'Returns 404 if the user does not have a Copilot seat in this organization, ' + - 'or if the organization does not have a Copilot subscription. ' + - 'Returns 403 if the token lacks sufficient scope or the user is not an org owner.', - inputSchema: z.object({ - org: z.string().describe('The organization login name (e.g. "my-company").'), - username: z - .string() - .describe('The GitHub username of the organization member to look up.'), - }), - }, - async ({ org, username }) => { - try { - const response = await octokit.rest.copilot.getCopilotSeatDetailsForUser({ - org, - username, - }); - return toToolResult(response.data); - } catch (error) { - return toToolError(error); - } - }, - ); -} -``` - -- [x] **Step 4: Run tests to verify they pass** - -Run: `npm test -- copilot` -Expected: PASS (11 tests in `copilot.test.ts`). - -- [x] **Step 5: Run the full test suite to confirm no regression in other toolsets** - -Run: `npm test` -Expected: PASS. Total test count is the previous suite total plus the 11 new tests in `copilot.test.ts`. No pre-existing test file is modified; `common.ts` is unchanged so `common.test.ts` still passes verbatim. - -- [x] **Step 6: Commit** - -```bash -git add src/toolsets/copilot.ts test/unit/toolsets/copilot.test.ts -git commit -m "feat: add copilot toolset (3 read-only tools: get_copilot_organization_details, list_copilot_seats, get_copilot_seat_details_for_user)" -``` - ---- - -## Task 2: Wire `registerCopilotTools` into `server.ts` - -**Files:** -- Modify: `src/server.ts` -- Modify: `README.md` (Toolsets section) - -**Interfaces:** -- Consumes: `registerCopilotTools(server, octokit, permission)` from Task 1. -- Produces: nothing new — this is the final integration point. - -- [x] **Step 1: Modify `src/server.ts`** - -Current content (after `apps` was wired — verified against the actual file on `main`): - -```typescript -import { McpServer } from '@modelcontextprotocol/server'; -import type { Octokit } from 'octokit'; -import { registerActivityTools } from './toolsets/activity.js'; -import { registerAppsTools } from './toolsets/apps.js'; -import { registerGistsTools } from './toolsets/gists.js'; -import { registerIssuesTools } from './toolsets/issues.js'; -import { registerMiscTools } from './toolsets/misc.js'; -import { registerPackagesTools } from './toolsets/packages.js'; -import { registerPullRequestsTools } from './toolsets/pull_requests.js'; -import { registerReposTools } from './toolsets/repos.js'; -import { registerSearchTools } from './toolsets/search.js'; -import { registerUsersTools } from './toolsets/users.js'; - -const SERVER_NAME = 'github-mcp-server-js'; -const SERVER_VERSION = '0.1.0'; - -export function buildServer( - octokit: Octokit, - permission: 'read-only' | 'read-write', -): McpServer { - const server = new McpServer({ name: SERVER_NAME, version: SERVER_VERSION }); - - registerReposTools(server, octokit, permission); - registerIssuesTools(server, octokit, permission); - registerPullRequestsTools(server, octokit, permission); - registerSearchTools(server, octokit, permission); - registerUsersTools(server, octokit, permission); - registerGistsTools(server, octokit, permission); - registerActivityTools(server, octokit, permission); - registerPackagesTools(server, octokit, permission); - registerMiscTools(server, octokit, permission); - registerAppsTools(server, octokit, permission); - - return server; -} -``` - -Replace with (new import sorted alphabetically, new call appended): - -```typescript -import { McpServer } from '@modelcontextprotocol/server'; -import type { Octokit } from 'octokit'; -import { registerActivityTools } from './toolsets/activity.js'; -import { registerAppsTools } from './toolsets/apps.js'; -import { registerCopilotTools } from './toolsets/copilot.js'; -import { registerGistsTools } from './toolsets/gists.js'; -import { registerIssuesTools } from './toolsets/issues.js'; -import { registerMiscTools } from './toolsets/misc.js'; -import { registerPackagesTools } from './toolsets/packages.js'; -import { registerPullRequestsTools } from './toolsets/pull_requests.js'; -import { registerReposTools } from './toolsets/repos.js'; -import { registerSearchTools } from './toolsets/search.js'; -import { registerUsersTools } from './toolsets/users.js'; - -const SERVER_NAME = 'github-mcp-server-js'; -const SERVER_VERSION = '0.1.0'; - -export function buildServer( - octokit: Octokit, - permission: 'read-only' | 'read-write', -): McpServer { - const server = new McpServer({ name: SERVER_NAME, version: SERVER_VERSION }); - - registerReposTools(server, octokit, permission); - registerIssuesTools(server, octokit, permission); - registerPullRequestsTools(server, octokit, permission); - registerSearchTools(server, octokit, permission); - registerUsersTools(server, octokit, permission); - registerGistsTools(server, octokit, permission); - registerActivityTools(server, octokit, permission); - registerPackagesTools(server, octokit, permission); - registerMiscTools(server, octokit, permission); - registerAppsTools(server, octokit, permission); - registerCopilotTools(server, octokit, permission); - - return server; -} -``` - -- [x] **Step 2: Run the full test suite** - -Run: `npm test` -Expected: PASS, same test count as after Task 1 (no test exercises `server.ts` directly — `buildServer` is a thin, non-branching composition function verified by the manual smoke test below). - -- [x] **Step 3: Typecheck, lint, build** - -Run: `npm run typecheck && npm run lint && npm run build` -Expected: all PASS. The build step confirms `dist/cli.js`'s bundled dependency graph now transitively includes `copilot.ts`. - -- [x] **Step 4: Manual end-to-end smoke test** - -Run: -```bash -GITHUB_TOKEN=fake-token node dist/cli.js --transport=http --port=3993 & -sleep 1 -curl -s -D /tmp/mcp-init-headers.txt -X POST http://localhost:3993/mcp \ - -H 'Content-Type: application/json' \ - -H 'Accept: application/json, text/event-stream' \ - -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"smoke-test","version":"0.0.0"}}}' -SESSION=$(grep -i mcp-session-id /tmp/mcp-init-headers.txt | awk '{print $2}' | tr -d '\r') -curl -s -X POST http://localhost:3993/mcp \ - -H 'Content-Type: application/json' \ - -H 'Accept: application/json, text/event-stream' \ - -H "Mcp-Session-Id: $SESSION" \ - -d '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' -kill %1 -``` - -Expected: the `initialize` response contains `"serverInfo":{"name":"github-mcp-server-js"...}`. The `tools/list` response includes all 3 copilot tools (`get_copilot_organization_details`, `list_copilot_seats`, `get_copilot_seat_details_for_user`) alongside all 61 previously-shipped tools — proving all toolsets are live in the same server with no duplicate-registration crash. Total tool count: 64. - -- [x] **Step 5: Update the README's Toolsets section** - -Modify `README.md`'s "Currently implemented" list to add: - -```markdown -- `copilot` — Copilot org-admin tools (org-owner PAT required): Copilot subscription details, seat list, per-user seat details (`get_copilot_organization_details`, `list_copilot_seats`, `get_copilot_seat_details_for_user`) -``` - -Slot it after the existing `apps` bullet, keeping the toolsets listed in the order they were shipped. - -- [x] **Step 6: If cspell flags any word, add it to `cspell.json`** - -No new cspell words are anticipated for this toolset (all words used — `copilot`, `org`, `username`, `billing`, `seat` — are standard English or recognized technical terms). If cspell does flag something, open `cspell.json` and add the flagged word to the `words` array. This step is expected to be a no-op. - -- [x] **Step 7: Commit** - -```bash -git add src/server.ts README.md -git commit -m "feat: wire copilot toolset into buildServer" -``` - ---- - -## Deliberate Scope Decisions - -The following `octokit.rest.copilot.*` methods were introspected and are **intentionally excluded** from this toolset. Each exclusion is justified below. - -### Write / seat-management endpoints — excluded (mutating operations) - -1. **`addCopilotSeatsForTeams` (POST `/orgs/{org}/copilot/billing/selected_teams`) — write, excluded.** Purchases GitHub Copilot seats for all members of specified teams. Mutating billing operation; excluded unconditionally (not gated on `permission === 'read-write'` because billing seat changes are highly consequential and should not be performed through a general-purpose MCP tool without explicit human confirmation workflows). Requires `manage_billing:copilot` or `admin:org` scope. - -2. **`addCopilotSeatsForUsers` (POST `/orgs/{org}/copilot/billing/selected_users`) — write, excluded.** Purchases GitHub Copilot seats for specified individual users. Same rationale as `addCopilotSeatsForTeams` — mutating billing operation excluded unconditionally. - -3. **`cancelCopilotSeatAssignmentForTeams` (DELETE `/orgs/{org}/copilot/billing/selected_teams`) — write, excluded.** Sets Copilot seats for all members of specified teams to "pending cancellation". Destructive billing operation — users lose Copilot access at the end of the billing cycle. Excluded unconditionally. - -4. **`cancelCopilotSeatAssignmentForUsers` (DELETE `/orgs/{org}/copilot/billing/selected_users`) — write, excluded.** Sets Copilot seats for specified users to "pending cancellation". Same destructive billing rationale. Excluded unconditionally. - -### Metrics endpoints — excluded (org-admin and enterprise-only, out of scope for ~3-tool estimate) - -5. **`copilotMetricsForOrganization` (GET `/orgs/{org}/copilot/metrics`) — excluded (out of scope).** Returns aggregated daily metrics for Copilot features across an organization (code completions, chat, PR summaries), broken down by editor, language, and model. Read-only and technically includable, but excluded for two reasons: - - **Auth wall:** Requires the Copilot Metrics API access policy to be explicitly enabled for the organization in GitHub settings — this is an additional admin action beyond just having Copilot. A PAT from an org owner still returns `422 Unprocessable Entity` if the Metrics API policy is not enabled. - - **Scope budget:** The ~3-tool estimate is met by the three selected tools. The metrics response is a deeply nested array of per-day objects with per-editor/per-language/per-model breakdowns — high informational value but appropriate as a follow-up toolset extension once the base copilot toolset is established. - -6. **`copilotMetricsForTeam` (GET `/orgs/{org}/team/{team_slug}/copilot/metrics`) — excluded (out of scope).** Same as `copilotMetricsForOrganization` but scoped to a specific team. Same dual exclusion rationale: Metrics API policy must be explicitly enabled, and this is beyond the ~3-tool scope budget. If metrics tools are added in a follow-up, both `copilotMetricsForOrganization` and `copilotMetricsForTeam` should be added together as a pair. - ---- - -## Self-Review Notes - -- **Spec coverage:** `copilot` toolset (Toolset Inventory row: octokit `copilot` namespace, example tools `get_copilot_seat_details, list_copilot_usage`, est. count ~3). The spec's example tool `get_copilot_seat_details` maps to `getCopilotSeatDetailsForUser` in octokit — the full tool name `get_copilot_seat_details_for_user` is used (consistent with the `packages` and `apps` naming convention of appending `_for_user` or `_for_authenticated_user` when the endpoint is user-scoped). The spec's example `list_copilot_usage` does not map to a single octokit method — the closest is `copilotMetricsForOrganization`, which is excluded (see Deliberate scope decisions). The third tool (`get_copilot_organization_details`) substitutes as the org-level information tool, analogous to how `get_app` substituted for the JWT-only `getAuthenticated` in the `apps` plan. Count exactly matches ~3. - -- **Why `list_copilot_usage` is not implemented:** The spec's `list_copilot_usage` example maps conceptually to `copilotMetricsForOrganization`. However, this endpoint has a secondary auth requirement (Metrics API policy must be enabled in org settings) beyond the standard org-owner PAT requirement shared by all copilot endpoints. This creates a confusing failure mode: an org owner with the right token scopes still gets `422` until an admin enables the policy. Given the ~3-tool budget is met by the three selected tools and all of them have a more straightforward failure mode (403/404 from missing auth/subscription), the metrics tools are deferred to a follow-up plan. The plan documents this clearly so a future implementer can add them without re-researching the auth requirements. - -- **All three tools are org-admin-only — documented in descriptions:** Unlike the `apps` toolset (which had PAT-user-context endpoints), every endpoint in the `copilot` namespace requires org-owner credentials. This is documented in each tool's description with explicit scope requirements and expected error codes (403 for insufficient scope/role, 404 for missing subscription, 422 for billing issues). LLM clients will surface these descriptions to users, preventing confusion about why tools fail for non-admin tokens. - -- **Error test assertions use 403 and 404, not 500:** The two most common failure modes for copilot tools are: - - `403 Forbidden` — PAT lacks scope or user is not an org owner - - `404 Not Found` — org doesn't have a Copilot subscription, or user doesn't have a seat - The tests specifically cover both of these for the relevant tools rather than testing generic 500 errors, making the test suite meaningful for the actual failure cases that operators will encounter. - -- **`list_copilot_seats` pagination:** `listCopilotSeats` accepts `page` and `per_page` as optional query parameters. The OpenAPI spec's `per_page` is a plain `number` (not referencing the standard `per-page` parameter component), but the effective max is 100, consistent with `paginationSchema`. `paginationSchema` is spread into the input schema, same as in `list_installations_for_authenticated_user` in the `apps` toolset. - -- **Test fixture values are plain English:** All test fixture org names use `test-org` and `acme-corp` — meaningful English words that will not pollute cspell.json. Username fixtures use `alice` and `bob` — common English names, well within any spell dictionary. No base64-looking strings are used (lessons from the `apps` plan's `Oklud`, `Vncm`, `Nlcj` additions). - -- **`_permission` naming:** Applied throughout — `_permission` inside the function body to satisfy `@typescript-eslint/no-unused-vars`, consistent with `misc`, `apps`, `packages`, `search`, and all other all-read-only toolsets. - -- **Tool-name collision check performed:** All 3 tool names (`get_copilot_organization_details`, `list_copilot_seats`, `get_copilot_seat_details_for_user`) verified against all 61 existing tool names: zero collisions. The prefix `copilot_` does not appear in any existing tool name. - -- **Lessons applied from prior plans:** - - **`issues` I1 (strict permission-gating equality):** Task 1's registration-count test uses `.toEqual([...exact 3 names...])`, not `arrayContaining`. Run against both `read-only` and `read-write` clients. - - **`issues` I3 (wire-level filter passthrough):** `list_copilot_seats`'s pagination test uses `nock(...).query({...full params...})` plus `expect(scope.isDone()).toBe(true)`. The `get_copilot_seat_details_for_user` wire-passthrough test uses a different org (`acme-corp`) to prove path params are forwarded, not hardcoded. - - **`search` M (all-read-only `_permission` naming):** Applied. - - **`packages` M (tool-name collision check):** Performed explicitly. - - **`apps` auth awareness:** Like the `apps` plan, each included method's auth requirements were individually verified. Unlike `apps` (which had a mix of JWT-only, PAT-user-context, and fully-public endpoints), all copilot endpoints uniformly require org-owner credentials — this simplifies the selection (no PAT-compatibility filtering needed) but means the toolset is strictly for org admins. - -- **No placeholders:** All octokit method names (`copilot.getCopilotOrganizationDetails`, `copilot.listCopilotSeats`, `copilot.getCopilotSeatDetailsForUser`), HTTP verbs, URL paths, path parameters, query parameters, and response body shapes were verified directly against the installed `octokit@^5.0.5` (via `node --input-type=module` introspection) and `@octokit/openapi-types/types.d.ts` operation definitions. Every intentionally excluded method is documented in the Deliberate scope decisions section. - -- **Task right-sizing:** Task 1 bundles all 3 tools + tests into one reviewer gate, matching every prior plan. Task 2 is a separate gate: wiring is where duplicate-registration crashes and stale-README rot surface. diff --git a/docs/superpowers/plans/2026-08-05-github-mcp-server-core.md b/docs/superpowers/plans/2026-08-05-github-mcp-server-core.md deleted file mode 100644 index edf5e2f..0000000 --- a/docs/superpowers/plans/2026-08-05-github-mcp-server-core.md +++ /dev/null @@ -1,1384 +0,0 @@ -# github-mcp-server-js — Core Infrastructure Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax for tracking. - -**Goal:** Build the working core of github-mcp-server-js: config loading, the shared Octokit client, the `McpServer` wiring with permission-gated tool registration, both transports (stdio + HTTP), the CLI entrypoint, pre-commit/CI tooling, and one fully-implemented toolset (`repos`) that establishes the pattern every other toolset (in later plans) will copy. - -**Architecture:** A single `Octokit` client and a single `McpServer` instance are built once at startup from environment variables. Each toolset is a module exporting one `register*Tools(server, octokit, permission)` function; `server.ts` calls each toolset's registration function in turn. Two transport modules (`transports/stdio.ts`, `transports/http.ts`) attach the same `McpServer` to different `Transport` implementations; `cli.ts` parses flags and picks one at process start. - -**Tech Stack:** TypeScript, `@modelcontextprotocol/server@^2.0.0`, `octokit@^5.0.5`, `zod@^4.2.0` (required peer of the MCP server package), `@whatwg-node/server` (bridges the HTTP transport's Web Standard `Request`/`Response` handler onto Node's `http.createServer`), `tsup` (bundling), `vitest` (test runner), `nock` (HTTP mocking), `eslint` + `typescript-eslint` (flat config), `cspell`, `gitleaks`, `husky` + `lint-staged`. - -## Global Constraints - -- Package name: `github-mcp-server-js` (unscoped, matches repo name). -- Env vars (exact names): `GITHUB_TOKEN` (required), `GITHUB_SERVER_URL` (optional, default `github.com`), `GITHUB_PERMISSION` (optional, `read-only` | `read-write`, default `read-write`), `LOG_LEVEL` (optional, `debug` | `info` | `error`, default `info`). -- `GITHUB_SERVER_URL` accepts either a bare hostname or a full API base URL; normalize to `https://api.github.com` for `github.com`/unset, else `https:///api/v3` unless the input already contains a path (then used as-is after ensuring an `https://` scheme). -- Transport selection is CLI-only: `--transport=stdio` (default) or `--transport=http --port=` (default port `3000`). Never read transport choice from an env var. -- Single-tenant for both transports: one `Octokit` instance built once at startup, reused for every tool call. -- `GITHUB_PERMISSION=read-only` must prevent write-tool handlers from ever being registered with `McpServer` — not just block them at call time. -- All logging goes to `stderr` only, regardless of transport (stdout is reserved for the stdio JSON-RPC wire protocol). Verbosity gated by `LOG_LEVEL`. -- Tool responses return the raw octokit response body as JSON, unmodified. No field trimming, no text summarization. -- Errors propagate unmodified: catch the octokit `RequestError`, surface `error.message` (which already includes the GitHub `message` field) in an MCP tool error result. No normalization layer, no rate-limit special-casing. -- List tools take explicit `page` (default `1`) and `per_page` (default `30`, max `100`) parameters. No auto-pagination. -- No additional auth layer on the HTTP transport (trusted network context assumed). -- TypeScript only; bundled via `tsup` to a single-file CLI output for fast `npx` cold-start. - ---- - -## File Structure - -``` -github-mcp-server-js/ - src/ - cli.ts # parses --transport/--port, builds server, connects transport - server.ts # buildServer(octokit, permission) -> McpServer, calls each toolset's register fn - config.ts # loads + validates env vars, normalizes GITHUB_SERVER_URL - octokit-client.ts # buildOctokitClient(config) -> Octokit - logger.ts # stderr-only logger gated by LOG_LEVEL - toolsets/ - repos.ts # registerReposTools(server, octokit, permission) - transports/ - stdio.ts # runStdio(server): connects McpServer to StdioServerTransport - http.ts # runHttp(server, port): connects McpServer to WebStandardStreamableHTTPServerTransport, serves via Node http - test/ - unit/ - config.test.ts - octokit-client.test.ts - toolsets/ - repos.test.ts - package.json - tsconfig.json - tsup.config.ts - eslint.config.js - cspell.json - .gitleaks.toml - .husky/ - pre-commit - .github/ - workflows/ - ci.yml - .gitignore - README.md -``` - -Each toolset file has one responsibility: register its own tools against an already-built `McpServer`/`Octokit` pair. `server.ts` knows the list of toolsets; toolsets know nothing about each other or about transports. Transports know nothing about toolsets — they only connect a pre-built `McpServer` to a `Transport`. - ---- - -### Task 1: Project scaffolding - -**Files:** -- Create: `package.json` -- Create: `tsconfig.json` -- Create: `tsup.config.ts` -- Create: `.gitignore` -- Create: `eslint.config.js` -- Create: `cspell.json` - -**Interfaces:** -- Produces: an installable Node project with `npm run build`, `npm run typecheck`, `npm run lint`, `npm run test`, `npm run spellcheck` scripts that later tasks rely on. - -- [x] **Step 1: Create `package.json`** - -```json -{ - "name": "github-mcp-server-js", - "version": "0.1.0", - "description": "A GitHub MCP server built on octokit.js and the MCP TypeScript SDK v2", - "type": "module", - "bin": { - "github-mcp-server-js": "dist/cli.js" - }, - "files": [ - "dist" - ], - "engines": { - "node": ">=20" - }, - "scripts": { - "build": "tsup", - "dev": "tsx src/cli.ts", - "typecheck": "tsc --noEmit", - "lint": "eslint .", - "spellcheck": "cspell \"**/*.{ts,md}\"", - "test": "vitest run", - "prepare": "husky" - }, - "dependencies": { - "@modelcontextprotocol/server": "^2.0.0", - "@whatwg-node/server": "^0.11.0", - "octokit": "^5.0.5", - "zod": "^4.2.0" - }, - "devDependencies": { - "@modelcontextprotocol/client": "^2.0.0", - "@types/node": "^24.0.0", - "cspell": "^10.0.1", - "eslint": "^10.8.0", - "husky": "^9.1.7", - "lint-staged": "^17.3.0", - "nock": "^14.0.17", - "tsup": "^8.5.1", - "tsx": "^4.19.0", - "typescript": "^5.7.0", - "typescript-eslint": "^8.66.0", - "vitest": "^4.1.10" - } -} -``` - -- [x] **Step 2: Create `tsconfig.json`** - -```json -{ - "compilerOptions": { - "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "lib": ["ES2022"], - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "noUncheckedIndexedAccess": true, - "outDir": "dist" - }, - "include": ["src", "test"] -} -``` - -- [x] **Step 3: Create `tsup.config.ts`** - -```typescript -import { defineConfig } from 'tsup'; - -export default defineConfig({ - entry: ['src/cli.ts'], - format: ['esm'], - target: 'node20', - bundle: true, - clean: true, - banner: { - js: '#!/usr/bin/env node', - }, -}); -``` - -- [x] **Step 4: Create `.gitignore`** - -``` -node_modules/ -dist/ -*.log -.env -``` - -- [x] **Step 5: Create `eslint.config.js`** - -```javascript -import tseslint from 'typescript-eslint'; - -export default tseslint.config( - { - ignores: ['dist/**'], - }, - ...tseslint.configs.recommended, - { - rules: { - '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], - }, - }, -); -``` - -- [x] **Step 6: Create `cspell.json`** - -```json -{ - "version": "0.2", - "language": "en", - "words": [ - "octokit", - "mcpb", - "gitleaks", - "cspell", - "tsup", - "codespaces", - "dependabot", - "toolsets" - ], - "ignorePaths": ["node_modules/**", "dist/**"] -} -``` - -- [x] **Step 7: Install dependencies** - -Run: `npm install` -Expected: installs succeed, `package-lock.json` is created. - -- [x] **Step 8: Verify scripts run on an empty `src/`** - -Run: `npm run typecheck` -Expected: fails or is a no-op since `src/cli.ts` doesn't exist yet — that's fine, this step just confirms `tsc` executes without a config error. If it errors with "no inputs were found", that's expected and will resolve once Task 2 adds files. - -- [x] **Step 9: Commit** - -```bash -git add package.json tsconfig.json tsup.config.ts .gitignore eslint.config.js cspell.json package-lock.json -git commit -m "chore: scaffold project (package.json, tsconfig, tsup, eslint, cspell)" -``` - ---- - -### Task 2: Config loading (`config.ts`) - -**Files:** -- Create: `src/config.ts` -- Test: `test/unit/config.test.ts` - -**Interfaces:** -- Produces: - ```typescript - export interface Config { - githubToken: string; - githubApiBaseUrl: string; // normalized, e.g. "https://api.github.com" or "https://host/api/v3" - permission: 'read-only' | 'read-write'; - logLevel: 'debug' | 'info' | 'error'; - } - export function loadConfig(env: Record): Config; - export class ConfigError extends Error {} - ``` -- Consumes: nothing (pure function of an env-like object, passed explicitly for testability — do not read `process.env` directly inside `loadConfig`). - -- [x] **Step 1: Write the failing tests** - -```typescript -// test/unit/config.test.ts -import { describe, expect, it } from 'vitest'; -import { ConfigError, loadConfig } from '../../src/config.js'; - -describe('loadConfig', () => { - it('throws when GITHUB_TOKEN is missing', () => { - expect(() => loadConfig({})).toThrow(ConfigError); - }); - - it('defaults githubApiBaseUrl to api.github.com when GITHUB_SERVER_URL is unset', () => { - const config = loadConfig({ GITHUB_TOKEN: 't' }); - expect(config.githubApiBaseUrl).toBe('https://api.github.com'); - }); - - it('normalizes a bare Enterprise Server hostname to the /api/v3 base URL', () => { - const config = loadConfig({ GITHUB_TOKEN: 't', GITHUB_SERVER_URL: 'github.mycompany.com' }); - expect(config.githubApiBaseUrl).toBe('https://github.mycompany.com/api/v3'); - }); - - it('accepts a full API base URL and uses it as-is', () => { - const config = loadConfig({ - GITHUB_TOKEN: 't', - GITHUB_SERVER_URL: 'https://github.mycompany.com/api/v3', - }); - expect(config.githubApiBaseUrl).toBe('https://github.mycompany.com/api/v3'); - }); - - it('treats a bare "github.com" the same as unset', () => { - const config = loadConfig({ GITHUB_TOKEN: 't', GITHUB_SERVER_URL: 'github.com' }); - expect(config.githubApiBaseUrl).toBe('https://api.github.com'); - }); - - it('defaults permission to read-write', () => { - const config = loadConfig({ GITHUB_TOKEN: 't' }); - expect(config.permission).toBe('read-write'); - }); - - it('accepts GITHUB_PERMISSION=read-only', () => { - const config = loadConfig({ GITHUB_TOKEN: 't', GITHUB_PERMISSION: 'read-only' }); - expect(config.permission).toBe('read-only'); - }); - - it('rejects an invalid GITHUB_PERMISSION value', () => { - expect(() => loadConfig({ GITHUB_TOKEN: 't', GITHUB_PERMISSION: 'nonsense' })).toThrow( - ConfigError, - ); - }); - - it('defaults logLevel to info', () => { - const config = loadConfig({ GITHUB_TOKEN: 't' }); - expect(config.logLevel).toBe('info'); - }); - - it('rejects an invalid LOG_LEVEL value', () => { - expect(() => loadConfig({ GITHUB_TOKEN: 't', LOG_LEVEL: 'verbose' })).toThrow(ConfigError); - }); -}); -``` - -- [x] **Step 2: Run tests to verify they fail** - -Run: `npx vitest run test/unit/config.test.ts` -Expected: FAIL — `Cannot find module '../../src/config.js'` - -- [x] **Step 3: Write the implementation** - -```typescript -// src/config.ts -export interface Config { - githubToken: string; - githubApiBaseUrl: string; - permission: 'read-only' | 'read-write'; - logLevel: 'debug' | 'info' | 'error'; -} - -export class ConfigError extends Error {} - -const PERMISSIONS = ['read-only', 'read-write'] as const; -const LOG_LEVELS = ['debug', 'info', 'error'] as const; - -function normalizeServerUrl(rawValue: string | undefined): string { - if (!rawValue || rawValue === 'github.com') { - return 'https://api.github.com'; - } - - const withScheme = rawValue.startsWith('http://') || rawValue.startsWith('https://') - ? rawValue - : `https://${rawValue}`; - - const url = new URL(withScheme); - if (url.pathname === '/' || url.pathname === '') { - url.pathname = '/api/v3'; - } - return url.toString().replace(/\/$/, ''); -} - -export function loadConfig(env: Record): Config { - const githubToken = env.GITHUB_TOKEN; - if (!githubToken) { - throw new ConfigError('GITHUB_TOKEN environment variable is required'); - } - - const permission = env.GITHUB_PERMISSION ?? 'read-write'; - if (!PERMISSIONS.includes(permission as (typeof PERMISSIONS)[number])) { - throw new ConfigError( - `GITHUB_PERMISSION must be one of ${PERMISSIONS.join(', ')}, got "${permission}"`, - ); - } - - const logLevel = env.LOG_LEVEL ?? 'info'; - if (!LOG_LEVELS.includes(logLevel as (typeof LOG_LEVELS)[number])) { - throw new ConfigError(`LOG_LEVEL must be one of ${LOG_LEVELS.join(', ')}, got "${logLevel}"`); - } - - return { - githubToken, - githubApiBaseUrl: normalizeServerUrl(env.GITHUB_SERVER_URL), - permission: permission as Config['permission'], - logLevel: logLevel as Config['logLevel'], - }; -} -``` - -- [x] **Step 4: Run tests to verify they pass** - -Run: `npx vitest run test/unit/config.test.ts` -Expected: PASS (10 tests) - -- [x] **Step 5: Commit** - -```bash -git add src/config.ts test/unit/config.test.ts -git commit -m "feat: add env var config loading and validation" -``` - ---- - -### Task 3: Octokit client construction (`octokit-client.ts`) - -**Files:** -- Create: `src/octokit-client.ts` -- Test: `test/unit/octokit-client.test.ts` - -**Interfaces:** -- Consumes: `Config` from Task 2 (`config.githubToken`, `config.githubApiBaseUrl`). -- Produces: - ```typescript - export function buildOctokitClient(config: Config): Octokit; - ``` - -- [x] **Step 1: Write the failing test** - -```typescript -// test/unit/octokit-client.test.ts -import { describe, expect, it } from 'vitest'; -import type { Config } from '../../src/config.js'; -import { buildOctokitClient } from '../../src/octokit-client.js'; - -function makeConfig(overrides: Partial = {}): Config { - return { - githubToken: 'test-token', - githubApiBaseUrl: 'https://api.github.com', - permission: 'read-write', - logLevel: 'info', - ...overrides, - }; -} - -describe('buildOctokitClient', () => { - it('configures the client with the given base URL', () => { - const octokit = buildOctokitClient( - makeConfig({ githubApiBaseUrl: 'https://github.mycompany.com/api/v3' }), - ); - expect(octokit.request.endpoint.DEFAULTS.baseUrl).toBe( - 'https://github.mycompany.com/api/v3', - ); - }); - - it('configures the client with the given token', () => { - const octokit = buildOctokitClient(makeConfig({ githubToken: 'secret-token' })); - const headers = octokit.request.endpoint.DEFAULTS.headers as Record; - expect(headers.authorization).toContain('secret-token'); - }); -}); -``` - -- [x] **Step 2: Run test to verify it fails** - -Run: `npx vitest run test/unit/octokit-client.test.ts` -Expected: FAIL — `Cannot find module '../../src/octokit-client.js'` - -- [x] **Step 3: Write the implementation** - -```typescript -// src/octokit-client.ts -import { Octokit } from 'octokit'; -import type { Config } from './config.js'; - -export function buildOctokitClient(config: Config): Octokit { - return new Octokit({ - auth: config.githubToken, - baseUrl: config.githubApiBaseUrl, - }); -} -``` - -- [x] **Step 4: Run test to verify it passes** - -Run: `npx vitest run test/unit/octokit-client.test.ts` -Expected: PASS (2 tests) - -- [x] **Step 5: Commit** - -```bash -git add src/octokit-client.ts test/unit/octokit-client.test.ts -git commit -m "feat: build Octokit client from config" -``` - ---- - -### Task 4: Logger (`logger.ts`) - -**Files:** -- Create: `src/logger.ts` - -**Interfaces:** -- Consumes: `Config['logLevel']`. -- Produces: - ```typescript - export interface Logger { - debug(message: string): void; - info(message: string): void; - error(message: string): void; - } - export function createLogger(logLevel: Config['logLevel']): Logger; - ``` - -This module has no branching logic worth a unit test beyond "does it write to stderr, not stdout" — that's an integration property exercised implicitly once the CLI runs in Task 8. No dedicated test file; write the implementation directly. - -- [x] **Step 1: Write the implementation** - -```typescript -// src/logger.ts -import type { Config } from './config.js'; - -export interface Logger { - debug(message: string): void; - info(message: string): void; - error(message: string): void; -} - -const LEVEL_RANK: Record = { - debug: 0, - info: 1, - error: 2, -}; - -export function createLogger(logLevel: Config['logLevel']): Logger { - const threshold = LEVEL_RANK[logLevel]; - - function write(level: Config['logLevel'], message: string): void { - if (LEVEL_RANK[level] >= threshold) { - process.stderr.write(`[${level}] ${message}\n`); - } - } - - return { - debug: (message) => write('debug', message), - info: (message) => write('info', message), - error: (message) => write('error', message), - }; -} -``` - -- [x] **Step 2: Verify it compiles** - -Run: `npx tsc --noEmit` -Expected: no errors related to `src/logger.ts` (errors about missing `src/cli.ts` etc. from other not-yet-written files are expected at this point and will clear as later tasks land). - -- [x] **Step 3: Commit** - -```bash -git add src/logger.ts -git commit -m "feat: add stderr-only logger gated by LOG_LEVEL" -``` - ---- - -### Task 5: `repos` toolset — reference pattern for all future toolsets - -This is the task every future toolset (in later plans) will copy. It establishes: how a toolset file is structured, how permission gating works, how pagination params are declared, how errors propagate, and how tools are tested. - -Implements 8 tools from the design's `repos` toolset row: `get_repository`, `list_branches`, `get_branch`, `get_file_contents`, `create_or_update_file`, `list_commits`, `get_commit`, `list_tags`. - -**Files:** -- Create: `src/toolsets/repos.ts` -- Test: `test/unit/toolsets/repos.test.ts` - -**Interfaces:** -- Consumes: `Octokit` instance (Task 3), `Config['permission']` (Task 2), `McpServer` (from `@modelcontextprotocol/server`). -- Produces: - ```typescript - export function registerReposTools( - server: McpServer, - octokit: Octokit, - permission: 'read-only' | 'read-write', - ): void; - ``` -- This function is called once by `server.ts` in Task 6 — `buildServer` relies on this exact name and signature. - -**Verified octokit endpoint mapping** (confirmed live against the installed `octokit@5.0.5` package): - -| Tool name | octokit method | HTTP | Read/Write | -|---|---|---|---| -| `get_repository` | `octokit.rest.repos.get` | GET `/repos/{owner}/{repo}` | read | -| `list_branches` | `octokit.rest.repos.listBranches` | GET `/repos/{owner}/{repo}/branches` | read | -| `get_branch` | `octokit.rest.repos.getBranch` | GET `/repos/{owner}/{repo}/branches/{branch}` | read | -| `get_file_contents` | `octokit.rest.repos.getContent` | GET `/repos/{owner}/{repo}/contents/{path}` | read | -| `create_or_update_file` | `octokit.rest.repos.createOrUpdateFileContents` | PUT `/repos/{owner}/{repo}/contents/{path}` | write | -| `list_commits` | `octokit.rest.repos.listCommits` | GET `/repos/{owner}/{repo}/commits` | read | -| `get_commit` | `octokit.rest.repos.getCommit` | GET `/repos/{owner}/{repo}/commits/{ref}` | read | -| `list_tags` | `octokit.rest.repos.listTags` | GET `/repos/{owner}/{repo}/tags` | read | - -- [x] **Step 1: Write the failing tests** - -```typescript -// test/unit/toolsets/repos.test.ts -import { McpServer } from '@modelcontextprotocol/server'; -import { Client } from '@modelcontextprotocol/client'; -import { InMemoryTransport } from '@modelcontextprotocol/server'; -import { Octokit } from 'octokit'; -import nock from 'nock'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { registerReposTools } from '../../../src/toolsets/repos.js'; - -async function connectedClient(permission: 'read-only' | 'read-write') { - const octokit = new Octokit({ auth: 'test-token', baseUrl: 'https://api.github.com' }); - const server = new McpServer({ name: 'test-server', version: '0.0.0' }); - registerReposTools(server, octokit, permission); - - const client = new Client({ name: 'test-client', version: '0.0.0' }); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); - return client; -} - -describe('registerReposTools', () => { - afterEach(() => { - nock.cleanAll(); - }); - - it('registers get_repository and returns the raw GitHub response as JSON', async () => { - nock('https://api.github.com') - .get('/repos/octocat/hello-world') - .reply(200, { id: 1, full_name: 'octocat/hello-world', default_branch: 'main' }); - - const client = await connectedClient('read-write'); - const result = await client.callTool({ - name: 'get_repository', - arguments: { owner: 'octocat', repo: 'hello-world' }, - }); - - expect(result.isError).toBeFalsy(); - const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ''; - expect(JSON.parse(text)).toMatchObject({ full_name: 'octocat/hello-world' }); - }); - - it('propagates a 404 as an MCP tool error with the raw GitHub message', async () => { - nock('https://api.github.com') - .get('/repos/octocat/missing-repo') - .reply(404, { message: 'Not Found', documentation_url: 'https://docs.github.com/rest' }); - - const client = await connectedClient('read-write'); - const result = await client.callTool({ - name: 'get_repository', - arguments: { owner: 'octocat', repo: 'missing-repo' }, - }); - - expect(result.isError).toBe(true); - const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ''; - expect(text).toContain('Not Found'); - }); - - it('passes page and per_page through to list_branches', async () => { - nock('https://api.github.com') - .get('/repos/octocat/hello-world/branches') - .query({ page: '2', per_page: '10' }) - .reply(200, [{ name: 'develop' }]); - - const client = await connectedClient('read-write'); - const result = await client.callTool({ - name: 'list_branches', - arguments: { owner: 'octocat', repo: 'hello-world', page: 2, per_page: 10 }, - }); - - expect(result.isError).toBeFalsy(); - const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ''; - expect(JSON.parse(text)).toEqual([{ name: 'develop' }]); - }); - - it('does not register create_or_update_file in read-only mode', async () => { - const client = await connectedClient('read-only'); - const { tools } = await client.listTools(); - expect(tools.map((tool) => tool.name)).not.toContain('create_or_update_file'); - }); - - it('registers create_or_update_file in read-write mode', async () => { - const client = await connectedClient('read-write'); - const { tools } = await client.listTools(); - expect(tools.map((tool) => tool.name)).toContain('create_or_update_file'); - }); - - it('registers all 7 read-only tools regardless of permission', async () => { - const client = await connectedClient('read-only'); - const { tools } = await client.listTools(); - const names = tools.map((tool) => tool.name); - expect(names).toEqual( - expect.arrayContaining([ - 'get_repository', - 'list_branches', - 'get_branch', - 'get_file_contents', - 'list_commits', - 'get_commit', - 'list_tags', - ]), - ); - }); -}); -``` - -- [x] **Step 2: Run tests to verify they fail** - -Run: `npx vitest run test/unit/toolsets/repos.test.ts` -Expected: FAIL — `Cannot find module '../../../src/toolsets/repos.js'` - -- [x] **Step 3: Write the implementation** - -```typescript -// src/toolsets/repos.ts -import type { McpServer } from '@modelcontextprotocol/server'; -import type { Octokit } from 'octokit'; -import { z } from 'zod'; - -const paginationSchema = { - page: z.number().int().min(1).default(1), - per_page: z.number().int().min(1).max(100).default(30), -}; - -function toToolResult(data: unknown) { - return { - content: [{ type: 'text' as const, text: JSON.stringify(data) }], - }; -} - -function toToolError(error: unknown) { - const message = error instanceof Error ? error.message : String(error); - return { - isError: true, - content: [{ type: 'text' as const, text: message }], - }; -} - -export function registerReposTools( - server: McpServer, - octokit: Octokit, - permission: 'read-only' | 'read-write', -): void { - server.registerTool( - 'get_repository', - { - description: 'Get a GitHub repository by owner and name.', - inputSchema: z.object({ - owner: z.string().describe('Repository owner (user or organization login)'), - repo: z.string().describe('Repository name'), - }), - }, - async ({ owner, repo }) => { - try { - const response = await octokit.rest.repos.get({ owner, repo }); - return toToolResult(response.data); - } catch (error) { - return toToolError(error); - } - }, - ); - - server.registerTool( - 'list_branches', - { - description: 'List branches in a GitHub repository.', - inputSchema: z.object({ - owner: z.string().describe('Repository owner (user or organization login)'), - repo: z.string().describe('Repository name'), - ...paginationSchema, - }), - }, - async ({ owner, repo, page, per_page }) => { - try { - const response = await octokit.rest.repos.listBranches({ owner, repo, page, per_page }); - return toToolResult(response.data); - } catch (error) { - return toToolError(error); - } - }, - ); - - server.registerTool( - 'get_branch', - { - description: 'Get a single branch in a GitHub repository.', - inputSchema: z.object({ - owner: z.string().describe('Repository owner (user or organization login)'), - repo: z.string().describe('Repository name'), - branch: z.string().describe('Branch name'), - }), - }, - async ({ owner, repo, branch }) => { - try { - const response = await octokit.rest.repos.getBranch({ owner, repo, branch }); - return toToolResult(response.data); - } catch (error) { - return toToolError(error); - } - }, - ); - - server.registerTool( - 'get_file_contents', - { - description: 'Get the contents of a file or directory in a GitHub repository.', - inputSchema: z.object({ - owner: z.string().describe('Repository owner (user or organization login)'), - repo: z.string().describe('Repository name'), - path: z.string().describe('Path to the file or directory'), - ref: z.string().optional().describe('Branch, tag, or commit SHA (defaults to the default branch)'), - }), - }, - async ({ owner, repo, path, ref }) => { - try { - const response = await octokit.rest.repos.getContent({ owner, repo, path, ref }); - return toToolResult(response.data); - } catch (error) { - return toToolError(error); - } - }, - ); - - server.registerTool( - 'list_commits', - { - description: 'List commits in a GitHub repository.', - inputSchema: z.object({ - owner: z.string().describe('Repository owner (user or organization login)'), - repo: z.string().describe('Repository name'), - sha: z.string().optional().describe('SHA or branch to list commits from'), - path: z.string().optional().describe('Only commits touching this file path'), - ...paginationSchema, - }), - }, - async ({ owner, repo, sha, path, page, per_page }) => { - try { - const response = await octokit.rest.repos.listCommits({ - owner, - repo, - sha, - path, - page, - per_page, - }); - return toToolResult(response.data); - } catch (error) { - return toToolError(error); - } - }, - ); - - server.registerTool( - 'get_commit', - { - description: 'Get a single commit in a GitHub repository.', - inputSchema: z.object({ - owner: z.string().describe('Repository owner (user or organization login)'), - repo: z.string().describe('Repository name'), - ref: z.string().describe('Commit SHA, branch, or tag'), - }), - }, - async ({ owner, repo, ref }) => { - try { - const response = await octokit.rest.repos.getCommit({ owner, repo, ref }); - return toToolResult(response.data); - } catch (error) { - return toToolError(error); - } - }, - ); - - server.registerTool( - 'list_tags', - { - description: 'List tags in a GitHub repository.', - inputSchema: z.object({ - owner: z.string().describe('Repository owner (user or organization login)'), - repo: z.string().describe('Repository name'), - ...paginationSchema, - }), - }, - async ({ owner, repo, page, per_page }) => { - try { - const response = await octokit.rest.repos.listTags({ owner, repo, page, per_page }); - return toToolResult(response.data); - } catch (error) { - return toToolError(error); - } - }, - ); - - if (permission === 'read-write') { - server.registerTool( - 'create_or_update_file', - { - description: 'Create a new file or update an existing file in a GitHub repository.', - inputSchema: z.object({ - owner: z.string().describe('Repository owner (user or organization login)'), - repo: z.string().describe('Repository name'), - path: z.string().describe('Path to the file'), - message: z.string().describe('Commit message'), - content: z.string().describe('New file content, Base64-encoded'), - sha: z - .string() - .optional() - .describe('Blob SHA of the file being replaced, required when updating an existing file'), - branch: z.string().optional().describe('Branch to commit to (defaults to the default branch)'), - }), - }, - async ({ owner, repo, path, message, content, sha, branch }) => { - try { - const response = await octokit.rest.repos.createOrUpdateFileContents({ - owner, - repo, - path, - message, - content, - sha, - branch, - }); - return toToolResult(response.data); - } catch (error) { - return toToolError(error); - } - }, - ); - } -} -``` - -- [x] **Step 4: Run tests to verify they pass** - -Run: `npx vitest run test/unit/toolsets/repos.test.ts` -Expected: PASS (6 tests) - -- [x] **Step 5: Commit** - -```bash -git add src/toolsets/repos.ts test/unit/toolsets/repos.test.ts -git commit -m "feat: implement repos toolset (8 tools) with permission gating" -``` - ---- - -### Task 6: `McpServer` wiring (`server.ts`) - -**Files:** -- Create: `src/server.ts` - -**Interfaces:** -- Consumes: `Octokit` (Task 3), `Config['permission']` (Task 2), `registerReposTools` (Task 5). -- Produces: - ```typescript - export function buildServer(octokit: Octokit, permission: 'read-only' | 'read-write'): McpServer; - ``` -- Future toolset plans will add one line per new toolset to this function's body — this is the seam later plans extend. - -- [x] **Step 1: Write the implementation** - -No dedicated unit test for this file: it's a thin composition function with no branching logic of its own (the branching lives inside each toolset). Its behavior is exercised end-to-end by the CLI smoke test in Task 8. - -```typescript -// src/server.ts -import { McpServer } from '@modelcontextprotocol/server'; -import type { Octokit } from 'octokit'; -import { registerReposTools } from './toolsets/repos.js'; - -const SERVER_NAME = 'github-mcp-server-js'; -const SERVER_VERSION = '0.1.0'; - -export function buildServer( - octokit: Octokit, - permission: 'read-only' | 'read-write', -): McpServer { - const server = new McpServer({ name: SERVER_NAME, version: SERVER_VERSION }); - - registerReposTools(server, octokit, permission); - - return server; -} -``` - -- [x] **Step 2: Verify it compiles** - -Run: `npx tsc --noEmit` -Expected: no errors related to `src/server.ts` (errors about missing `src/cli.ts` are expected until Task 8). - -- [x] **Step 3: Commit** - -```bash -git add src/server.ts -git commit -m "feat: wire McpServer construction and toolset registration" -``` - ---- - -### Task 7: Transports (`stdio.ts`, `http.ts`) - -**Files:** -- Create: `src/transports/stdio.ts` -- Create: `src/transports/http.ts` - -**Interfaces:** -- Consumes: `McpServer` (Task 6). -- Produces: - ```typescript - // stdio.ts - export function runStdio(server: McpServer): Promise; - - // http.ts - export function runHttp(server: McpServer, port: number): Promise; - ``` - -No dedicated unit tests for transports: they are thin adapters over SDK-provided and third-party classes (`StdioServerTransport`, `WebStandardStreamableHTTPServerTransport`, `createServerAdapter`) whose own behavior is already tested upstream. They're exercised by the CLI smoke test in Task 8. - -- [x] **Step 1: Write `stdio.ts`** - -```typescript -// src/transports/stdio.ts -import { StdioServerTransport } from '@modelcontextprotocol/server/stdio'; -import type { McpServer } from '@modelcontextprotocol/server'; - -export async function runStdio(server: McpServer): Promise { - const transport = new StdioServerTransport(); - await server.connect(transport); -} -``` - -- [x] **Step 2: Write `http.ts`** - -```typescript -// src/transports/http.ts -import { createServer } from 'node:http'; -import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/server'; -import type { McpServer } from '@modelcontextprotocol/server'; -import { createServerAdapter } from '@whatwg-node/server'; - -export async function runHttp(server: McpServer, port: number): Promise { - const transport = new WebStandardStreamableHTTPServerTransport({ - sessionIdGenerator: () => crypto.randomUUID(), - }); - await server.connect(transport); - - const adapter = createServerAdapter((request: Request) => transport.handleRequest(request)); - const httpServer = createServer(adapter); - - await new Promise((resolve) => { - httpServer.listen(port, resolve); - }); -} -``` - -- [x] **Step 3: Verify it compiles** - -Run: `npx tsc --noEmit` -Expected: no errors related to `src/transports/stdio.ts` or `src/transports/http.ts` (errors about missing `src/cli.ts` are expected until Task 8). - -- [x] **Step 4: Commit** - -```bash -git add src/transports/stdio.ts src/transports/http.ts -git commit -m "feat: add stdio and HTTP transport adapters" -``` - ---- - -### Task 8: CLI entrypoint (`cli.ts`) + end-to-end smoke test - -**Files:** -- Create: `src/cli.ts` -- Test: `test/unit/cli.test.ts` - -**Interfaces:** -- Consumes: `loadConfig` (Task 2), `buildOctokitClient` (Task 3), `createLogger` (Task 4), `buildServer` (Task 6), `runStdio`/`runHttp` (Task 7). -- Produces: the executable entrypoint referenced by `package.json`'s `bin` field. - -- [x] **Step 1: Write the failing test** - -This test exercises `parseArgs` in isolation (the pure, testable part of `cli.ts`); the full process startup (env loading + transport connection) is exercised manually in Step 5 rather than under vitest, since it opens real stdio/network handles. - -```typescript -// test/unit/cli.test.ts -import { describe, expect, it } from 'vitest'; -import { parseArgs } from '../../src/cli.js'; - -describe('parseArgs', () => { - it('defaults to stdio transport with no arguments', () => { - expect(parseArgs([])).toEqual({ transport: 'stdio', port: 3000 }); - }); - - it('parses --transport=http', () => { - expect(parseArgs(['--transport=http'])).toEqual({ transport: 'http', port: 3000 }); - }); - - it('parses --transport=http --port=4000', () => { - expect(parseArgs(['--transport=http', '--port=4000'])).toEqual({ - transport: 'http', - port: 4000, - }); - }); - - it('throws on an unknown transport value', () => { - expect(() => parseArgs(['--transport=carrier-pigeon'])).toThrow(); - }); -}); -``` - -- [x] **Step 2: Run test to verify it fails** - -Run: `npx vitest run test/unit/cli.test.ts` -Expected: FAIL — `Cannot find module '../../src/cli.js'` - -- [x] **Step 3: Write the implementation** - -```typescript -// src/cli.ts -import { buildOctokitClient } from './octokit-client.js'; -import { buildServer } from './server.js'; -import { loadConfig } from './config.js'; -import { createLogger } from './logger.js'; -import { runHttp } from './transports/http.js'; -import { runStdio } from './transports/stdio.js'; - -export interface CliArgs { - transport: 'stdio' | 'http'; - port: number; -} - -export function parseArgs(argv: string[]): CliArgs { - let transport: CliArgs['transport'] = 'stdio'; - let port = 3000; - - for (const arg of argv) { - if (arg.startsWith('--transport=')) { - const value = arg.slice('--transport='.length); - if (value !== 'stdio' && value !== 'http') { - throw new Error(`Unknown --transport value: "${value}" (expected "stdio" or "http")`); - } - transport = value; - } else if (arg.startsWith('--port=')) { - port = Number.parseInt(arg.slice('--port='.length), 10); - } - } - - return { transport, port }; -} - -async function main(): Promise { - const args = parseArgs(process.argv.slice(2)); - const config = loadConfig(process.env); - const logger = createLogger(config.logLevel); - const octokit = buildOctokitClient(config); - const server = buildServer(octokit, config.permission); - - if (args.transport === 'stdio') { - logger.info('Starting github-mcp-server-js over stdio'); - await runStdio(server); - } else { - logger.info(`Starting github-mcp-server-js over HTTP on port ${args.port}`); - await runHttp(server, args.port); - } -} - -main().catch((error: unknown) => { - process.stderr.write(`Fatal error: ${error instanceof Error ? error.message : String(error)}\n`); - process.exitCode = 1; -}); -``` - -- [x] **Step 4: Run test to verify it passes** - -Run: `npx vitest run test/unit/cli.test.ts` -Expected: PASS (4 tests) - -- [x] **Step 5: Manual smoke test of the full stack** - -Run: -```bash -npm run build -GITHUB_TOKEN=dummy-token node dist/cli.js --transport=http --port=3999 & -sleep 1 -curl -s -X POST http://localhost:3999/ \ - -H 'Content-Type: application/json' \ - -H 'Accept: application/json, text/event-stream' \ - -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"smoke-test","version":"0.0.0"}}}' -kill %1 -``` -Expected: the `curl` response is a JSON-RPC result containing `serverInfo.name: "github-mcp-server-js"` (exact response shape may include an `Mcp-Session-Id` header and SSE framing — confirm the process starts, logs to stderr, and responds without crashing; do not treat minor protocol-envelope differences as a failure). - -- [x] **Step 6: Commit** - -```bash -git add src/cli.ts test/unit/cli.test.ts -git commit -m "feat: add CLI entrypoint with --transport/--port flags" -``` - ---- - -### Task 9: Pre-commit hooks (husky + lint-staged + cspell + gitleaks) - -**Note on gitleaks distribution:** gitleaks is a Go binary, not an npm package — -there is no official `gitleaks` npm package (an unrelated, unofficial package -with that name exists on npm and must NOT be used). Install it via Homebrew -(`brew install gitleaks`) or download a release binary from -https://github.com/gitleaks/gitleaks/releases. The pre-commit hook below calls -the `gitleaks` binary directly (assumed to be on `PATH`), not via `npx`. - -**Files:** -- Create: `.husky/pre-commit` -- Modify: `package.json` (add `lint-staged` config) -- Create: `.gitleaks.toml` - -**Interfaces:** none — this task wires existing tools together, no application code. - -- [x] **Step 1: Install the gitleaks binary locally** - -Run: `brew install gitleaks` -Expected: `gitleaks version` prints a version number. If Homebrew is unavailable, download the appropriate binary from the releases page above and ensure it's on `PATH`. - -- [x] **Step 2: Initialize husky** - -Run: `npx husky init` -Expected: creates `.husky/pre-commit` and adds a `prepare` script to `package.json` (already present from Task 1). - -- [x] **Step 3: Add `lint-staged` config to `package.json`** - -Add this top-level key to `package.json`: - -```json -"lint-staged": { - "*.ts": [ - "eslint --fix", - "cspell" - ] -} -``` - -- [x] **Step 4: Write `.husky/pre-commit`** - -```sh -npx tsc --noEmit -npx lint-staged -if command -v gitleaks >/dev/null 2>&1; then - gitleaks protect --staged --no-banner -else - echo "gitleaks not found on PATH — install via 'brew install gitleaks' to enable secret scanning locally. CI still enforces this." >&2 -fi -``` - -- [x] **Step 5: Write `.gitleaks.toml`** - -```toml -title = "gitleaks config for github-mcp-server-js" - -[extend] -useDefault = true -``` - -- [x] **Step 6: Verify the hook blocks a secret (requires gitleaks installed from Step 1)** - -Run: -```bash -echo 'const token = "ghp_1234567890abcdefghijklmnopqrstuvwxyz12";' > /tmp/leak-test.ts -cp /tmp/leak-test.ts src/leak-test.ts -git add src/leak-test.ts -git commit -m "test: verify pre-commit blocks secrets" -``` -Expected: commit is rejected by `gitleaks protect --staged`. Then clean up: -```bash -git reset HEAD src/leak-test.ts -rm src/leak-test.ts /tmp/leak-test.ts -``` - -- [x] **Step 7: Commit the hook setup** - -```bash -git add .husky/pre-commit .gitleaks.toml package.json -git commit -m "chore: add pre-commit hooks (typecheck, lint, spellcheck, secret scan)" -``` - ---- - -### Task 10: CI workflow (GitHub Actions) - -**Files:** -- Create: `.github/workflows/ci.yml` - -**Interfaces:** none. - -- [x] **Step 1: Write `.github/workflows/ci.yml`** - -```yaml -name: CI - -on: - pull_request: - push: - branches: [main] - -jobs: - ci: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - uses: actions/setup-node@v4 - with: - node-version: '20' - cache: 'npm' - - - run: npm ci - - - run: npm run typecheck - - - run: npm run lint - - - run: npm run spellcheck - - - run: npm audit --audit-level=high - - - name: Scan for secrets - uses: gitleaks/gitleaks-action@v2 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - run: npm test - - - run: npm run build -``` - -- [x] **Step 2: Verify locally that each step's underlying command succeeds** - -Run: `npm run typecheck && npm run lint && npm run spellcheck && npm test && npm run build` -Expected: all pass (this validates the workflow's commands before pushing; the workflow itself only runs on GitHub Actions). - -- [x] **Step 3: Commit** - -```bash -git add .github/workflows/ci.yml -git commit -m "ci: add GitHub Actions workflow (typecheck, lint, spellcheck, audit, secret scan, test, build)" -``` - ---- - -### Task 11: README - -**Files:** -- Create: `README.md` - -**Interfaces:** none. - -- [x] **Step 1: Write `README.md`** - -```markdown -# github-mcp-server-js - -A GitHub MCP server built on [octokit.js](https://github.com/octokit/octokit.js) and the -[MCP TypeScript SDK v2](https://github.com/modelcontextprotocol/typescript-sdk). - -## Usage - -```bash -npx github-mcp-server-js -``` - -Runs over stdio by default. For a standalone HTTP server: - -```bash -npx github-mcp-server-js --transport=http --port=3000 -``` - -## Configuration - -| Variable | Required | Default | Description | -|---|---|---|---| -| `GITHUB_TOKEN` | Yes | — | Personal access token used for all GitHub API calls | -| `GITHUB_SERVER_URL` | No | `github.com` | GitHub host — bare hostname or full API base URL. Set this for GitHub Enterprise Server | -| `GITHUB_PERMISSION` | No | `read-write` | `read-only` or `read-write` | -| `LOG_LEVEL` | No | `info` | `debug`, `info`, or `error` | - -## Toolsets - -Currently implemented: `repos` (repository, branch, commit, tag, and file-contents tools). -Additional toolsets (issues, pull requests, actions, and more) are tracked in -`docs/superpowers/specs/2026-08-05-github-mcp-server-design.md`. -``` - -- [x] **Step 2: Commit** - -```bash -git add README.md -git commit -m "docs: add README with usage and configuration" -``` - ---- - -## Self-Review Notes - -- **Spec coverage:** config (Task 2), Octokit client (Task 3), logger (Task 4), one full toolset with permission gating (Task 5), server wiring (Task 6), both transports (Task 7), CLI (Task 8), pre-commit (Task 9), CI (Task 10), README (Task 11) all map directly to spec sections. CD (`npm publish`, `.mcpb` build) and the remaining 15 toolsets are intentionally out of scope for this plan — tracked as separate follow-up plans and as Task #1 in the project task list. -- **Type consistency:** `Config['permission']` (`'read-only' | 'read-write'`) is defined once in Task 2 and reused verbatim in Tasks 3, 5, 6 rather than redeclared. `registerReposTools(server, octokit, permission)` signature in Task 5 matches its call site in Task 6 exactly. -- **No placeholders:** every step includes complete, runnable code verified against the real `@modelcontextprotocol/server@2.0.0`, `octokit@5.0.5`, and `@whatwg-node/server@0.11.0` APIs (inspected directly from installed packages, not guessed from docs). diff --git a/docs/superpowers/plans/2026-08-05-github-mcp-server-gists.md b/docs/superpowers/plans/2026-08-05-github-mcp-server-gists.md deleted file mode 100644 index 1af1db0..0000000 --- a/docs/superpowers/plans/2026-08-05-github-mcp-server-gists.md +++ /dev/null @@ -1,764 +0,0 @@ -# github-mcp-server-js — `gists` Toolset Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax for tracking. - -**Goal:** Add the `gists` toolset (5 tools covering gist CRUD plus list) to `github-mcp-server-js`, following the exact structural, permission-gating, and response/error pattern established by the `repos`, `issues`, `pull_requests`, `search`, and `users` toolsets. - -**Architecture:** Same pattern as prior toolsets: one `registerGistsTools(server, octokit, permission)` function registers each tool with `server.registerTool(name, {description, inputSchema}, handler)`; every handler wraps its octokit call in try/catch, returning raw JSON via `toToolResult`/`toToolError`; write tools are registered only when `permission === 'read-write'`; `server.ts` gains one more registration call. All octokit calls use the `gists` namespace (`octokit.rest.gists.*`). - -**Tech Stack:** Same as prior plans — TypeScript, `@modelcontextprotocol/server@^2.0.0`, `octokit@^5.0.5`, `zod@^4.2.0`, `vitest`, `nock`. No new dependencies. - -## Global Constraints - -- Tool responses return the raw octokit response body as JSON, unmodified. No field trimming, no text summarization. -- Errors propagate unmodified: catch the octokit `RequestError`, surface `error.message` in an MCP tool error result. No normalization layer. -- List tools take explicit `page` (default `1`) and `per_page` (default `30`, max `100`) parameters. No auto-pagination. -- `GITHUB_PERMISSION=read-only` must prevent write-tool handlers from ever being registered with `McpServer` — write tools must be inside `if (permission === 'read-write') { ... }`, not gated at call time. -- `delete_gist` returns `204 No Content` (confirmed: `responses: { 204: { content: never } }` in `@octokit/openapi-types`). The handler returns a synthetic `{ deleted: true }` result — the same deliberate exception used by `lock_issue`/`unlock_issue` — because there is no response body to pass through. This is the only case in this toolset where the "raw response body" rule cannot apply. -- No modification to `common.ts` — `gist_id` is gist-specific and no other toolset shares it; inline it in `gists.ts` directly. -- TypeScript only, no new runtime dependencies. - ---- - -## Tool Selection and Collision Check - -**Tools chosen (5 total):** - -| Tool | Read/Write | octokit method | HTTP | -|---|---|---|---| -| `list_gists` | read | `gists.list` | GET `/gists` | -| `get_gist` | read | `gists.get` | GET `/gists/{gist_id}` | -| `create_gist` | write | `gists.create` | POST `/gists` | -| `update_gist` | write | `gists.patch` (i.e., `octokit.rest.gists.update`) | PATCH `/gists/{gist_id}` | -| `delete_gist` | write | `gists.delete` | DELETE `/gists/{gist_id}` | - -**Read/write split:** 2 read tools + 3 write tools. - -**Collision check against all 40 existing tool names** (8 `repos` + 12 `issues` + 10 `pull_requests` + 5 `search` + 5 `users`): - -Existing names: `add_comment`, `add_labels`, `create_issue`, `create_or_update_file`, `create_pull_request`, `create_pull_request_review`, `get_authenticated_user`, `get_branch`, `get_commit`, `get_file_contents`, `get_issue`, `get_pull_request`, `get_repository`, `get_user_by_username`, `get_user_hovercard`, `list_branches`, `list_comments`, `list_commits`, `list_issues`, `list_labels`, `list_labels_on_issue`, `list_pull_request_commits`, `list_pull_request_files`, `list_pull_request_reviews`, `list_pull_requests`, `list_tags`, `list_user_followers`, `list_user_following`, `lock_issue`, `merge_pull_request`, `remove_label`, `request_reviewers`, `search_code`, `search_commits`, `search_issues`, `search_repos`, `search_users`, `unlock_issue`, `update_issue`, `update_pull_request`. - -**Result: zero collisions.** `list_gists`, `get_gist`, `create_gist`, `update_gist`, and `delete_gist` are all distinct from every existing name. - ---- - -## Verified octokit `gists` namespace shapes - -Confirmed directly against the installed `@octokit/plugin-rest-endpoint-methods` endpoint table and `@octokit/openapi-types/types.d.ts`. - -| Tool | octokit call | Key parameters | Response shape | -|---|---|---|---| -| `list_gists` | `octokit.rest.gists.list({ since?, per_page?, page? })` | `since` (ISO 8601), `page`, `per_page` | `base-gist[]` (200) | -| `get_gist` | `octokit.rest.gists.get({ gist_id })` | `gist_id` (string) | `gist-simple` (200) | -| `create_gist` | `octokit.rest.gists.create({ files, description?, public? })` | `files`: `{ [filename]: { content: string } }`, `description`, `public` | `gist-simple` (201) | -| `update_gist` | `octokit.rest.gists.update({ gist_id, description?, files? })` | `gist_id`, `description`, `files`: `{ [filename]: { content?, filename? } \| null }` | `gist-simple` (200) | -| `delete_gist` | `octokit.rest.gists.delete({ gist_id })` | `gist_id` | 204 No Content | - -**Octokit method naming note:** In `octokit.rest.gists`, the PATCH method is accessed as `gists.update` (not `gists.patch`) — confirmed via `octokit.rest.gists.update.endpoint.DEFAULTS` which shows `{ method: 'PATCH', url: '/gists/{gist_id}' }`. - -**`files` parameter shape for `create_gist`:** -``` -files: { [key: string]: { content: string } } -``` -This is a record where each key is the filename and each value is `{ content: string }`. In the Zod schema this is expressed as `z.record(z.string(), z.object({ content: z.string() }))`. - -**`files` parameter shape for `update_gist`:** -``` -files?: { [key: string]: { content?: string; filename?: string | null } | null } -``` -To delete a file, set its value to `null`. To rename a file, set `filename` to the new name. In Zod: `z.record(z.string(), z.union([z.object({ content: z.string().optional(), filename: z.string().nullable().optional() }), z.null()]).optional())` — see Step 3 of Task 2 for the exact declaration used in the implementation. - ---- - -## File Structure - -``` -github-mcp-server-js/ - src/ - toolsets/ - gists.ts # NEW: registerGistsTools(server, octokit, permission) - server.ts # MODIFIED: calls registerGistsTools - test/ - unit/ - toolsets/ - gists.test.ts # NEW: mirrors users.test.ts's structure -``` - -`common.ts` is NOT modified — `gist_id` is gist-specific and inlined in `gists.ts`. `README.md`'s Toolsets section is updated in Task 3 (Step 4), following the same precedent as every prior toolset plan. - ---- - -## Task 1: Implement the `gists` toolset — read tools (`list_gists`, `get_gist`) and their tests - -**Files:** -- Create: `src/toolsets/gists.ts` (read tools only; write tools stubbed as empty `if` block) -- Create: `test/unit/toolsets/gists.test.ts` (read-tool tests only) - -**Interfaces:** -- Consumes: `paginationSchema`, `toToolResult`, `toToolError` from `./common.js` (already exist, no modification needed). -- Produces (for Tasks 2 and 3 to extend): `registerGistsTools(server: McpServer, octokit: Octokit, permission: 'read-only' | 'read-write'): void` - -- [x] **Step 1: Write the failing tests for the read tools** - -Create `test/unit/toolsets/gists.test.ts`: - -```typescript -import nock from 'nock'; -import { afterEach, describe, expect, it } from 'vitest'; -import { registerGistsTools } from '../../../src/toolsets/gists.js'; -import { connectedClient } from './test-helpers.js'; - -describe('registerGistsTools', () => { - afterEach(() => { - nock.cleanAll(); - }); - - it('registers list_gists and returns the raw GitHub response as JSON', async () => { - nock('https://api.github.com') - .get('/gists') - .query({ page: '1', per_page: '30' }) - .reply(200, [ - { - id: 'aa5a315d61ae9438b18d', - description: 'Hello World', - public: true, - url: 'https://api.github.com/gists/aa5a315d61ae9438b18d', - }, - ]); - - const client = await connectedClient(registerGistsTools, 'read-write'); - const result = await client.callTool({ - name: 'list_gists', - arguments: {}, - }); - - expect(result.isError).toBeFalsy(); - const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ''; - expect(JSON.parse(text)).toEqual([ - { - id: 'aa5a315d61ae9438b18d', - description: 'Hello World', - public: true, - url: 'https://api.github.com/gists/aa5a315d61ae9438b18d', - }, - ]); - }); - - it('forwards explicit page and per_page to list_gists on the wire', async () => { - const scope = nock('https://api.github.com') - .get('/gists') - .query({ page: '2', per_page: '10' }) - .reply(200, []); - - const client = await connectedClient(registerGistsTools, 'read-write'); - const result = await client.callTool({ - name: 'list_gists', - arguments: { page: 2, per_page: 10 }, - }); - - expect(result.isError).toBeFalsy(); - expect(scope.isDone()).toBe(true); - }); - - it('registers get_gist and returns the raw GitHub response as JSON', async () => { - nock('https://api.github.com') - .get('/gists/aa5a315d61ae9438b18d') - .reply(200, { - id: 'aa5a315d61ae9438b18d', - description: 'Hello World', - public: true, - files: { - 'hello.rb': { - filename: 'hello.rb', - type: 'application/x-ruby', - language: 'Ruby', - raw_url: 'https://gist.githubusercontent.com/raw/hello.rb', - size: 167, - content: 'puts "Hello, World!"', - }, - }, - }); - - const client = await connectedClient(registerGistsTools, 'read-write'); - const result = await client.callTool({ - name: 'get_gist', - arguments: { gist_id: 'aa5a315d61ae9438b18d' }, - }); - - expect(result.isError).toBeFalsy(); - const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ''; - expect(JSON.parse(text)).toMatchObject({ - id: 'aa5a315d61ae9438b18d', - description: 'Hello World', - }); - }); - - it('propagates a 404 from get_gist as an MCP tool error', async () => { - nock('https://api.github.com') - .get('/gists/nonexistent-gist-id-xyz') - .reply(404, { message: 'Not Found', documentation_url: 'https://docs.github.com/rest' }); - - const client = await connectedClient(registerGistsTools, 'read-write'); - const result = await client.callTool({ - name: 'get_gist', - arguments: { gist_id: 'nonexistent-gist-id-xyz' }, - }); - - expect(result.isError).toBe(true); - const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ''; - expect(text).toContain('Not Found'); - }); - - it('registers exactly 2 read tools in read-only mode', async () => { - const client = await connectedClient(registerGistsTools, 'read-only'); - const { tools } = await client.listTools(); - expect(tools.map((t) => t.name).sort()).toEqual(['get_gist', 'list_gists']); - }); -}); -``` - -- [x] **Step 2: Run tests to verify they fail** - -Run: `npm test -- gists` -Expected: FAIL with a module-not-found error for `../../../src/toolsets/gists.js`. - -- [x] **Step 3: Create `src/toolsets/gists.ts` with the 2 read tools** - -```typescript -import type { McpServer } from '@modelcontextprotocol/server'; -import type { Octokit } from 'octokit'; -import { z } from 'zod'; -import { paginationSchema, toToolError, toToolResult } from './common.js'; - -export function registerGistsTools( - server: McpServer, - octokit: Octokit, - permission: 'read-only' | 'read-write', -): void { - server.registerTool( - 'list_gists', - { - description: - 'List gists for the authenticated user. Returns an array of base-gist objects including id, description, public flag, file list (names and metadata, but not full content), and owner. Paginate with page and per_page. To get full file content for a specific gist, call get_gist with its id.', - inputSchema: z.object({ - since: z - .string() - .optional() - .describe( - 'ISO 8601 timestamp (YYYY-MM-DDTHH:MM:SSZ). Only return gists updated at or after this time.', - ), - ...paginationSchema, - }), - }, - async ({ since, page, per_page }) => { - try { - const response = await octokit.rest.gists.list({ since, page, per_page }); - return toToolResult(response.data); - } catch (error) { - return toToolError(error); - } - }, - ); - - server.registerTool( - 'get_gist', - { - description: - 'Get a single gist by its id. Returns a gist-simple object with full file content, description, public flag, owner, forks_url, commits_url, and history. File content is included inline (up to the truncation threshold — very large files include a raw_url instead).', - inputSchema: z.object({ - gist_id: z.string().describe('The unique identifier of the gist.'), - }), - }, - async ({ gist_id }) => { - try { - const response = await octokit.rest.gists.get({ gist_id }); - return toToolResult(response.data); - } catch (error) { - return toToolError(error); - } - }, - ); - - if (permission === 'read-write') { - // Write tools added in Task 2. - } -} -``` - -**Note on the empty `if` block:** If `eslint` reports a `no-empty` warning for the placeholder comment block, remove the `if` block entirely and re-add it in Task 2 when the write-tool bodies are inserted. Do not add an ESLint disable comment — remove and restore instead. - -- [x] **Step 4: Run tests to verify they pass** - -Run: `npm test -- gists` -Expected: PASS (5 tests). - -- [x] **Step 5: Typecheck and lint** - -Run: `npm run typecheck && npm run lint` -Expected: both PASS with zero errors. - -- [x] **Step 6: Stage `cspell.json` if any test fixture strings trigger cspell errors** - -cspell runs on staged files during pre-commit. The test file above uses `'aa5a315d61ae9438b18d'` (a real GitHub gist ID used in GitHub's own API docs — not a dictionary word, but cspell ignores hex-like strings by default) and `'hello.rb'`, `'nonexistent-gist-id-xyz'`. These should be fine without additions. If cspell fails during commit on any fixture string (e.g., `'Gistfile'`, `'gist-simple'`, or similar), add the offending word to the `words` array in `cspell.json` and stage it alongside the source files in the same commit. - -- [x] **Step 7: Commit** - -```bash -git add src/toolsets/gists.ts test/unit/toolsets/gists.test.ts -git commit -m "feat: add gists toolset read tools (list_gists, get_gist)" -``` - ---- - -## Task 2: Implement the `gists` toolset — write tools (`create_gist`, `update_gist`, `delete_gist`) and their tests - -**Files:** -- Modify: `src/toolsets/gists.ts` (replace the `if (permission === 'read-write') { }` placeholder with real write-tool bodies) -- Modify: `test/unit/toolsets/gists.test.ts` (append write-tool and permission-gate tests inside the existing `describe` block) - -**Interfaces:** -- Consumes: same `registerGistsTools` function from Task 1 — this task adds write tools to it, not replaces it. -- Produces: `create_gist`, `update_gist`, `delete_gist` are registered only when `permission === 'read-write'`. - -- [x] **Step 1: Write the failing tests for the write tools** - -Append the following tests inside the existing `describe('registerGistsTools', ...)` block in `test/unit/toolsets/gists.test.ts`, right before the closing `});`: - -```typescript - it('registers create_gist and returns the raw GitHub response as JSON', async () => { - nock('https://api.github.com') - .post('/gists', { - files: { 'hello.rb': { content: 'puts "Hello, World!"' } }, - description: 'A hello-world gist', - public: false, - }) - .reply(201, { - id: 'aa5a315d61ae9438b18d', - description: 'A hello-world gist', - public: false, - files: { - 'hello.rb': { - filename: 'hello.rb', - content: 'puts "Hello, World!"', - }, - }, - }); - - const client = await connectedClient(registerGistsTools, 'read-write'); - const result = await client.callTool({ - name: 'create_gist', - arguments: { - files: { 'hello.rb': { content: 'puts "Hello, World!"' } }, - description: 'A hello-world gist', - public: false, - }, - }); - - expect(result.isError).toBeFalsy(); - const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ''; - expect(JSON.parse(text)).toMatchObject({ - id: 'aa5a315d61ae9438b18d', - description: 'A hello-world gist', - }); - }); - - it('registers update_gist and returns the raw GitHub response as JSON', async () => { - nock('https://api.github.com') - .patch('/gists/aa5a315d61ae9438b18d', { - description: 'Updated description', - files: { 'hello.rb': { content: 'puts "Updated!"' } }, - }) - .reply(200, { - id: 'aa5a315d61ae9438b18d', - description: 'Updated description', - files: { - 'hello.rb': { - filename: 'hello.rb', - content: 'puts "Updated!"', - }, - }, - }); - - const client = await connectedClient(registerGistsTools, 'read-write'); - const result = await client.callTool({ - name: 'update_gist', - arguments: { - gist_id: 'aa5a315d61ae9438b18d', - description: 'Updated description', - files: { 'hello.rb': { content: 'puts "Updated!"' } }, - }, - }); - - expect(result.isError).toBeFalsy(); - const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ''; - expect(JSON.parse(text)).toMatchObject({ description: 'Updated description' }); - }); - - it('registers delete_gist and returns a synthetic deleted:true result', async () => { - nock('https://api.github.com') - .delete('/gists/aa5a315d61ae9438b18d') - .reply(204); - - const client = await connectedClient(registerGistsTools, 'read-write'); - const result = await client.callTool({ - name: 'delete_gist', - arguments: { gist_id: 'aa5a315d61ae9438b18d' }, - }); - - expect(result.isError).toBeFalsy(); - const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ''; - expect(JSON.parse(text)).toEqual({ deleted: true }); - }); - - it('propagates a 403 from delete_gist as an MCP tool error', async () => { - nock('https://api.github.com') - .delete('/gists/aa5a315d61ae9438b18d') - .reply(403, { message: 'Forbidden', documentation_url: 'https://docs.github.com/rest' }); - - const client = await connectedClient(registerGistsTools, 'read-write'); - const result = await client.callTool({ - name: 'delete_gist', - arguments: { gist_id: 'aa5a315d61ae9438b18d' }, - }); - - expect(result.isError).toBe(true); - const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ''; - expect(text).toContain('Forbidden'); - }); - - it('does not register any write tool in read-only mode', async () => { - const client = await connectedClient(registerGistsTools, 'read-only'); - const { tools } = await client.listTools(); - const names = tools.map((t) => t.name); - expect(names).not.toContain('create_gist'); - expect(names).not.toContain('update_gist'); - expect(names).not.toContain('delete_gist'); - }); - - it('registers all 5 gist tools in read-write mode', async () => { - const client = await connectedClient(registerGistsTools, 'read-write'); - const { tools } = await client.listTools(); - expect(tools.map((t) => t.name).sort()).toEqual([ - 'create_gist', - 'delete_gist', - 'get_gist', - 'list_gists', - 'update_gist', - ]); - }); -``` - -- [x] **Step 2: Run tests to verify they fail** - -Run: `npm test -- gists` -Expected: FAIL — the 4 write-tool tests fail with "Tool not found" (`ProtocolError`); the permission/count tests may fail depending on assertion direction. Re-run after Step 3. - -- [x] **Step 3: Add the write tools to `src/toolsets/gists.ts`** - -Replace the `if (permission === 'read-write') { // Write tools added in Task 2. }` placeholder (or add the block at the end of `registerGistsTools` if Task 1's lint step removed it) with: - -```typescript - if (permission === 'read-write') { - server.registerTool( - 'create_gist', - { - description: - 'Create a new gist. A gist is a shareable snippet or small file. Supply one or more files with their content; each file key is the filename (including extension). Set public to true to make the gist visible to all GitHub users, or false (default) for a secret gist (unlisted but accessible by direct URL).', - inputSchema: z.object({ - files: z - .record( - z.string(), - z.object({ - content: z.string().describe('File content.'), - }), - ) - .describe( - 'Files that make up the gist. Each key is the filename (e.g. "hello.rb") and each value is an object with a content field.', - ), - description: z.string().optional().describe('Description of the gist.'), - public: z - .boolean() - .optional() - .describe('Whether the gist is public (true) or secret (false, default).'), - }), - }, - async ({ files, description, public: isPublic }) => { - try { - const response = await octokit.rest.gists.create({ - files, - description, - public: isPublic, - }); - return toToolResult(response.data); - } catch (error) { - return toToolError(error); - } - }, - ); - - server.registerTool( - 'update_gist', - { - description: - 'Update an existing gist. You can change the description and/or update, rename, or delete individual files. To update a file, supply its current filename as the key with new content or a new filename. To delete a file, supply its current filename as the key with a null value. Files not mentioned in the request are left unchanged.', - inputSchema: z.object({ - gist_id: z.string().describe('The unique identifier of the gist to update.'), - description: z.string().optional().describe('New description for the gist.'), - files: z - .record( - z.string(), - z - .union([ - z.object({ - content: z.string().optional().describe('New file content.'), - filename: z - .string() - .nullable() - .optional() - .describe('New filename. Set to null to delete the file.'), - }), - z.null(), - ]) - .optional(), - ) - .optional() - .describe( - 'Files to update. Each key is the current filename. Set a value to null to delete that file. Omit a file to leave it unchanged.', - ), - }), - }, - async ({ gist_id, description, files }) => { - try { - const response = await octokit.rest.gists.update({ - gist_id, - description, - files: files as Parameters[0]['files'], - }); - return toToolResult(response.data); - } catch (error) { - return toToolError(error); - } - }, - ); - - server.registerTool( - 'delete_gist', - { - description: 'Delete a gist. This action is permanent and cannot be undone. Only the gist owner can delete it.', - inputSchema: z.object({ - gist_id: z.string().describe('The unique identifier of the gist to delete.'), - }), - }, - async ({ gist_id }) => { - try { - await octokit.rest.gists.delete({ gist_id }); - return toToolResult({ deleted: true }); - } catch (error) { - return toToolError(error); - } - }, - ); - } -``` - -**Note on `update_gist`'s `files` cast:** The `files` parameter in `gists.update` has a complex nullable-record type in `@octokit/openapi-types` (`{ [key: string]: { content?: string; filename?: string | null } | null } | null`). Zod's `z.record` + `z.union([..., z.null()])` models the per-entry nullability correctly, but TypeScript will raise a type mismatch on the optional-chain structure vs. the generated type. The `as Parameters[0]['files']` cast resolves this without weakening the runtime schema — the values accepted at call time are structurally compatible with what octokit expects. If this cast triggers a lint error, use `as never` as a last resort (consistent with how the codebase handles other `never`-typed response bodies). Do NOT widen the Zod schema to bypass this — the schema is correct and the cast is only for TypeScript's benefit. - -**Alternative (simpler) implementation for `update_gist` files if the cast causes issues:** - -If the union-with-null record type causes persistent type errors that the cast cannot resolve, simplify the `files` schema to `z.record(z.string(), z.unknown()).optional()` and remove the cast. This is slightly less descriptive for the LLM but avoids the TypeScript complexity entirely, consistent with the "thin schema" philosophy. Mention this substitution in the commit message if applied. - -- [x] **Step 4: Run tests to verify they pass** - -Run: `npm test -- gists` -Expected: PASS (11 tests: 5 from Task 1 + 6 new). - -- [x] **Step 5: Typecheck, lint, and full suite** - -Run: `npm run typecheck && npm run lint && npm test` -Expected: all PASS. The full suite total is 83 (prior total after `users`) + 11 = 94 tests. - -- [x] **Step 6: Stage `cspell.json` if needed** - -If any string in the test file triggers a cspell failure at commit time (e.g., `'Gistfile'` or fixture-specific tokens), add the word to `cspell.json`'s `words` array and stage it alongside the source files: - -```bash -git add cspell.json # only if cspell.json was modified -git add src/toolsets/gists.ts test/unit/toolsets/gists.test.ts -git commit -m "feat: add gists toolset write tools (create_gist, update_gist, delete_gist)" -``` - -If `cspell.json` was not modified, omit it from the staging command. - ---- - -## Task 3: Wire `registerGistsTools` into `server.ts` and update README - -**Files:** -- Modify: `src/server.ts` -- Modify: `README.md` - -**Interfaces:** -- Consumes: `registerGistsTools(server, octokit, permission)` from Task 2. -- Produces: nothing new — this is the final integration point. - -- [x] **Step 1: Modify `src/server.ts`** - -Current content: - -```typescript -import { McpServer } from '@modelcontextprotocol/server'; -import type { Octokit } from 'octokit'; -import { registerIssuesTools } from './toolsets/issues.js'; -import { registerPullRequestsTools } from './toolsets/pull_requests.js'; -import { registerReposTools } from './toolsets/repos.js'; -import { registerSearchTools } from './toolsets/search.js'; -import { registerUsersTools } from './toolsets/users.js'; - -const SERVER_NAME = 'github-mcp-server-js'; -const SERVER_VERSION = '0.1.0'; - -export function buildServer( - octokit: Octokit, - permission: 'read-only' | 'read-write', -): McpServer { - const server = new McpServer({ name: SERVER_NAME, version: SERVER_VERSION }); - - registerReposTools(server, octokit, permission); - registerIssuesTools(server, octokit, permission); - registerPullRequestsTools(server, octokit, permission); - registerSearchTools(server, octokit, permission); - registerUsersTools(server, octokit, permission); - - return server; -} -``` - -Replace with (new import sorted alphabetically alongside existing imports): - -```typescript -import { McpServer } from '@modelcontextprotocol/server'; -import type { Octokit } from 'octokit'; -import { registerGistsTools } from './toolsets/gists.js'; -import { registerIssuesTools } from './toolsets/issues.js'; -import { registerPullRequestsTools } from './toolsets/pull_requests.js'; -import { registerReposTools } from './toolsets/repos.js'; -import { registerSearchTools } from './toolsets/search.js'; -import { registerUsersTools } from './toolsets/users.js'; - -const SERVER_NAME = 'github-mcp-server-js'; -const SERVER_VERSION = '0.1.0'; - -export function buildServer( - octokit: Octokit, - permission: 'read-only' | 'read-write', -): McpServer { - const server = new McpServer({ name: SERVER_NAME, version: SERVER_VERSION }); - - registerReposTools(server, octokit, permission); - registerIssuesTools(server, octokit, permission); - registerPullRequestsTools(server, octokit, permission); - registerSearchTools(server, octokit, permission); - registerUsersTools(server, octokit, permission); - registerGistsTools(server, octokit, permission); - - return server; -} -``` - -- [x] **Step 2: Run the full test suite** - -Run: `npm test` -Expected: PASS, same 94 tests as after Task 2. - -- [x] **Step 3: Typecheck, lint, and build** - -Run: `npm run typecheck && npm run lint && npm run build` -Expected: all PASS. The build step confirms `dist/cli.js`'s bundled dependency graph transitively includes `gists.ts`. - -- [x] **Step 4: Update `README.md`'s Toolsets section** - -Append to the "Currently implemented" list (after the existing `users` bullet): - -```markdown -- `gists` — list, get, create, update, and delete gists -``` - -- [x] **Step 5: Manual end-to-end smoke test** - -Run: -```bash -GITHUB_TOKEN=fake-token node dist/cli.js --transport=http --port=3995 & -sleep 1 -curl -s -D /tmp/mcp-gists-init-headers.txt -X POST http://localhost:3995/mcp \ - -H 'Content-Type: application/json' \ - -H 'Accept: application/json, text/event-stream' \ - -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"smoke-test","version":"0.0.0"}}}' -SESSION=$(grep -i mcp-session-id /tmp/mcp-gists-init-headers.txt | awk '{print $2}' | tr -d '\r') -curl -s -X POST http://localhost:3995/mcp \ - -H 'Content-Type: application/json' \ - -H 'Accept: application/json, text/event-stream' \ - -H "Mcp-Session-Id: $SESSION" \ - -d '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' -kill %1 -``` - -Expected: the `tools/list` response includes `list_gists`, `get_gist`, `create_gist`, `update_gist`, and `delete_gist` — plus all 40 previously-shipped tools — proving all six toolsets are live in the same server with no duplicate-registration crash. - -- [x] **Step 6: Commit** - -```bash -git add src/server.ts README.md -git commit -m "feat: wire gists toolset into buildServer" -``` - ---- - -## Deliberate Scope Decisions - -The following `octokit.rest.gists.*` methods are **intentionally excluded** from this toolset. Each exclusion is justified below. - -1. **`gists.listForUser` (GET `/users/{username}/gists`) — out of scope.** Lists the public gists for a specific user by username. The primary use case (`list_gists`) lists the authenticated user's own gists. Listing another user's gists is a niche cross-user operation that blurs the "gists as your own snippets" surface this toolset covers. An LLM searching for a user's gists can use `search_code` or construct a direct API call. Can be added in a follow-up without disturbing the 5-tool surface. - -2. **`gists.listPublic` (GET `/gists/public`) — out of scope.** Lists all public gists globally, sorted by most recently updated. This is a firehose endpoint with no useful per-session signal; `list_gists` already covers the authenticated user's public and secret gists. A global public gist directory adds no LLM-agentic value and would produce confusingly large responses without meaningful filtering. - -3. **`gists.listStarred` (GET `/gists/starred`) — out of scope.** Lists the authenticated user's starred gists. Starred-gist management is secondary functionality compared to the core CRUD surface. Can be added alongside star/unstar (below) in a follow-up that covers gist social features holistically. - -4. **`gists.star` / `gists.unstar` / `gists.checkIsStarred` (PUT/DELETE/GET `/gists/{gist_id}/star`) — out of scope.** Star and unstar are mutating social actions. `checkIsStarred` returns a 204/404 with no body — it communicates its answer via HTTP status code only, which does not map cleanly to the `toToolResult` raw-JSON pattern (the same reason `users.checkFollowingForUser` was excluded from the `users` toolset). All three belong together in a future social-features addition. - -5. **`gists.listComments` / `gists.getComment` / `gists.createComment` / `gists.updateComment` / `gists.deleteComment` (GET/POST/PATCH/DELETE on `/gists/{gist_id}/comments`) — out of scope.** Comment management is secondary functionality relative to gist CRUD. The full comment CRUD surface (5 additional tools) would roughly double the toolset's tool count. Comments on gists are rarely the primary use case in agentic sessions; callers who need gist comments can access them via the `html_url` field of `get_gist`. A follow-up plan can add `list_gist_comments`, `create_gist_comment`, etc. using the `_gist_comment` suffix to avoid collision with `list_comments` (issues toolset) and `add_comment` (issues toolset). - -6. **`gists.listCommits` (GET `/gists/{gist_id}/commits`) — out of scope.** Returns the revision history of a gist. Useful for diffing gist versions, but niche compared to the core CRUD operations. `getRevision` (see below) is the more targeted complement when a specific historical version is needed. - -7. **`gists.getRevision` (GET `/gists/{gist_id}/{sha}`) — out of scope.** Returns a specific historical revision of a gist by commit SHA. Requires knowing a SHA (obtained from `listCommits`), making it only useful in combination with the also-excluded `listCommits`. Both belong together in a follow-up revision-history plan. - -8. **`gists.listForks` / `gists.fork` (GET/POST `/gists/{gist_id}/forks`) — out of scope.** Fork management is a social/collaboration operation secondary to core CRUD. `fork` is a write operation; `listForks` is a read operation but only meaningful alongside forking. Both are low-priority for agentic use cases. - ---- - -## Self-Review Notes - -**Verification checklist (all items confirmed):** - -1. **Every tool name is collision-free.** `list_gists`, `get_gist`, `create_gist`, `update_gist`, `delete_gist` — none overlap with the 40 existing tool names verified via `python3 -c "import re, glob; ..."` above. - -2. **Every octokit method exists.** Confirmed via: - ``` - node --input-type=module -e "import { Octokit } from 'octokit'; const o = new Octokit({ auth: 'x' }); const m = o.rest.gists; for (const k of Object.keys(m).sort()) { const d = m[k].endpoint.DEFAULTS; console.log(k, '->', d.method, d.url); }" - ``` - Output confirmed: `list -> GET /gists`, `get -> GET /gists/{gist_id}`, `create -> POST /gists`, `update -> PATCH /gists/{gist_id}`, `delete -> DELETE /gists/{gist_id}`. - -3. **Read-only mode test uses `.toEqual([...exact 2 names sorted...])`.** Task 1, Step 1's last test: `expect(tools.map((t) => t.name).sort()).toEqual(['get_gist', 'list_gists'])` — strict equality, not `arrayContaining`. - -4. **Read-write mode test verifies all 5 tools present.** Task 2, Step 1's last test: `expect(tools.map((t) => t.name).sort()).toEqual(['create_gist', 'delete_gist', 'get_gist', 'list_gists', 'update_gist'])` — strict equality with all 5 names sorted. - -5. **Wire-level filter test on a list tool with query params.** Task 1, Step 1's second test: `scope = nock(...).query({ page: '2', per_page: '10' })` + `expect(scope.isDone()).toBe(true)` on `list_gists`. Confirms `page` and `per_page` are forwarded on the wire, not silently dropped. - -6. **Signature matches required form.** `registerGistsTools(server: McpServer, octokit: Octokit, permission: 'read-only' | 'read-write'): void` — identical shape to every other `register*Tools` function. The `permission` parameter is used (not `_permission`) because this toolset has real write tools gated by it. - -7. **Write tools inside `if (permission === 'read-write') { ... }`.** Task 2, Step 3 shows all three write tools (`create_gist`, `update_gist`, `delete_gist`) inside the `if` block. Matches `issues.ts` and `pull_requests.ts`. - -8. **`delete_gist`'s 204 No Content handled with synthetic result.** Same rationale as `lock_issue`/`unlock_issue` — GitHub returns 204 with no body; `toToolResult({ deleted: true })` preserves the "always returns a JSON text block" contract. - -9. **`cspell.json` staging instruction present.** Both Task 1 Step 6 and Task 2 Step 6 explicitly instruct the implementer to check for cspell failures at commit time and stage `cspell.json` if any fixture words trigger errors. - -10. **No modification to `common.ts`.** `gist_id` is inlined as `z.string().describe(...)` directly in `gists.ts`. No other toolset needs `gist_id`, so extraction to `common.ts` would violate the "only extract if 2+ toolsets share it" rule. diff --git a/docs/superpowers/plans/2026-08-05-github-mcp-server-issues.md b/docs/superpowers/plans/2026-08-05-github-mcp-server-issues.md deleted file mode 100644 index 5319a1c..0000000 --- a/docs/superpowers/plans/2026-08-05-github-mcp-server-issues.md +++ /dev/null @@ -1,966 +0,0 @@ -# github-mcp-server-js — `issues` Toolset Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax for tracking. - -**Goal:** Add the `issues` toolset (10 tools covering issue CRUD, comments, labels, and locking) to `github-mcp-server-js`, and extract the shared toolset helpers (`paginationSchema`, `toToolResult`, `toToolError`, the repeated `owner`/`repo` schema fields) out of `src/toolsets/repos.ts` into a new `src/toolsets/common.ts` module so every toolset — this one and the 14 still to come — imports them instead of re-declaring them. - -**Architecture:** Same pattern as the `repos` toolset from the core plan: one `registerIssuesTools(server, octokit, permission)` function registers each tool with `server.registerTool(name, {description, inputSchema}, handler)`; every handler wraps its octokit call in try/catch, returning raw JSON via `toToolResult`/`toToolError`; write tools are registered only when `permission === 'read-write'`; `server.ts` gains one more registration call. The `common.ts` extraction happens first so this toolset (and the extraction itself) can be validated together before any other toolset copies the pattern. - -**Tech Stack:** Same as the core plan — TypeScript, `@modelcontextprotocol/server@^2.0.0`, `octokit@^5.0.5`, `zod@^4.2.0`, `vitest`, `nock`. No new dependencies. - -## Global Constraints - -- Tool responses return the raw octokit response body as JSON, unmodified. No field trimming, no text summarization. -- Errors propagate unmodified: catch the octokit `RequestError`, surface `error.message` (which already includes the GitHub `message` field) in an MCP tool error result. No normalization layer. -- List tools take explicit `page` (default `1`) and `per_page` (default `30`, max `100`) parameters. No auto-pagination. -- `GITHUB_PERMISSION=read-only` must prevent write-tool handlers from ever being registered with `McpServer` — not just block them at call time. -- Every tool's `owner`/`repo`/`issue_number` parameters use the exact `.describe()` text established in this plan (Task 1), since `common.ts` centralizes it — do not restate different wording per tool. -- TypeScript only, no new runtime dependencies. - ---- - -## File Structure - -``` -github-mcp-server-js/ - src/ - toolsets/ - common.ts # NEW: paginationSchema, toToolResult, toToolError, ownerRepoSchema - repos.ts # MODIFIED: imports from common.ts instead of declaring its own copies - issues.ts # NEW: registerIssuesTools(server, octokit, permission) - server.ts # MODIFIED: calls registerIssuesTools - test/ - unit/ - toolsets/ - common.test.ts # NEW: unit tests for toToolResult/toToolError (pure functions) - repos.test.ts # unchanged (still passes, now exercises the shared common.ts) - issues.test.ts # NEW: mirrors repos.test.ts's structure -``` - -`common.ts` holds only pure, dependency-free helpers and one shared Zod schema fragment — no octokit calls, no `McpServer` references. This keeps it trivially testable and prevents it from growing into a dumping ground: if a future toolset needs something that isn't a generic response/pagination/identity concern, it gets its own file, not an addition to `common.ts`. - ---- - -## Task 1: Extract shared toolset helpers into `common.ts` - -**Files:** -- Create: `src/toolsets/common.ts` -- Modify: `src/toolsets/repos.ts:1-22` (remove local declarations, import from `common.ts`) -- Test: `test/unit/toolsets/common.test.ts` - -**Interfaces:** -- Consumes: nothing (pure module, no octokit/McpServer dependency). -- Produces (for Task 2 and all future toolsets to consume): - - `paginationSchema: { page: ZodDefault<...>, per_page: ZodDefault<...> }` — same shape as the current `repos.ts` local declaration. - - `ownerRepoSchema: { owner: ZodString, repo: ZodString }` — the repeated pair with its `.describe()` text, extracted so every toolset spreads the same two fields instead of retyping the description strings. - - `toToolResult(data: unknown): { content: [{ type: 'text', text: string }] }` - - `toToolError(error: unknown): { isError: true, content: [{ type: 'text', text: string }] }` - -- [x] **Step 1: Write the failing test for the pure helpers** - -Create `test/unit/toolsets/common.test.ts`: - -```typescript -import { describe, expect, it } from 'vitest'; -import { toToolError, toToolResult } from '../../../src/toolsets/common.js'; - -describe('toToolResult', () => { - it('wraps data as a JSON text content block', () => { - const result = toToolResult({ id: 1, name: 'octocat' }); - expect(result).toEqual({ - content: [{ type: 'text', text: '{"id":1,"name":"octocat"}' }], - }); - }); -}); - -describe('toToolError', () => { - it('extracts the message from an Error instance', () => { - const result = toToolError(new Error('Not Found')); - expect(result).toEqual({ - isError: true, - content: [{ type: 'text', text: 'Not Found' }], - }); - }); - - it('stringifies a non-Error value', () => { - const result = toToolError('boom'); - expect(result).toEqual({ - isError: true, - content: [{ type: 'text', text: 'boom' }], - }); - }); -}); -``` - -- [x] **Step 2: Run test to verify it fails** - -Run: `npm test -- common` -Expected: FAIL with a module-not-found error for `../../../src/toolsets/common.js` (the file doesn't exist yet). - -- [x] **Step 3: Create `src/toolsets/common.ts`** - -```typescript -import { z } from 'zod'; - -export const paginationSchema = { - page: z.number().int().min(1).default(1), - per_page: z.number().int().min(1).max(100).default(30), -}; - -export const ownerRepoSchema = { - owner: z.string().describe('Repository owner (user or organization login)'), - repo: z.string().describe('Repository name'), -}; - -export function toToolResult(data: unknown) { - return { - content: [{ type: 'text' as const, text: JSON.stringify(data) }], - }; -} - -export function toToolError(error: unknown) { - const message = error instanceof Error ? error.message : String(error); - return { - isError: true, - content: [{ type: 'text' as const, text: message }], - }; -} -``` - -- [x] **Step 4: Run test to verify it passes** - -Run: `npm test -- common` -Expected: PASS (3 tests). - -- [x] **Step 5: Update `src/toolsets/repos.ts` to import from `common.ts` instead of declaring its own copies** - -Replace the top of `src/toolsets/repos.ts` (currently lines 1-22, the imports plus the local `paginationSchema`/`toToolResult`/`toToolError` declarations) with: - -```typescript -import type { McpServer } from '@modelcontextprotocol/server'; -import type { Octokit } from 'octokit'; -import { z } from 'zod'; -import { ownerRepoSchema, paginationSchema, toToolResult, toToolError } from './common.js'; -``` - -Then, in every `inputSchema: z.object({ owner: z.string().describe(...), repo: z.string().describe(...), ... })` block in the rest of the file, replace the two literal `owner`/`repo` field declarations with `...ownerRepoSchema`. For example, `get_repository`'s schema: - -```typescript - inputSchema: z.object({ - ...ownerRepoSchema, - }), -``` - -and `list_branches`'s schema: - -```typescript - inputSchema: z.object({ - ...ownerRepoSchema, - ...paginationSchema, - }), -``` - -Apply the same `...ownerRepoSchema` substitution to `get_branch`, `get_file_contents`, `list_commits`, `get_commit`, `list_tags`, and `create_or_update_file` — every tool in the file currently repeats the literal `owner: z.string().describe(...)` / `repo: z.string().describe(...)` pair. Do not change any handler logic, tool names, descriptions, or the octokit calls themselves — this step only removes duplication in the schema declarations and the three helper functions. - -- [x] **Step 6: Run the full test suite to verify nothing broke** - -Run: `npm test` -Expected: PASS — all pre-existing `repos.test.ts` tests (6 tests) still pass unchanged, since the wire-level behavior (parameter names, descriptions, JSON shape) is identical; only where the schema/helpers are declared changed. Plus the 3 new `common.test.ts` tests. Total: 30 + 3 = 33 tests passing (the core plan left 30 passing after its final fix round). - -- [x] **Step 7: Typecheck and lint** - -Run: `npm run typecheck && npm run lint` -Expected: both PASS with zero errors. - -- [x] **Step 8: Commit** - -```bash -git add src/toolsets/common.ts src/toolsets/repos.ts test/unit/toolsets/common.test.ts -git commit -m "refactor: extract shared toolset helpers into common.ts" -``` - ---- - -## Task 2: Implement the `issues` toolset — read tools (list, get, list comments, list labels, list labels on issue) - -**Files:** -- Create: `src/toolsets/issues.ts` -- Test: `test/unit/toolsets/issues.test.ts` - -**Interfaces:** -- Consumes: `ownerRepoSchema`, `paginationSchema`, `toToolResult`, `toToolError` from `./common.js` (Task 1). -- Produces (for Task 3 to extend in the same file, and for Task 4/`server.ts` to consume): `registerIssuesTools(server: McpServer, octokit: Octokit, permission: 'read-only' | 'read-write'): void` — same signature shape as `registerReposTools`. - -This task implements the 5 read-only tools. Task 3 adds the 5 write tools to the same file and function. - -- [x] **Step 1: Write the failing tests for the read tools** - -Create `test/unit/toolsets/issues.test.ts`: - -```typescript -import { McpServer } from '@modelcontextprotocol/server'; -import { Client } from '@modelcontextprotocol/client'; -import { InMemoryTransport } from '@modelcontextprotocol/server'; -import { Octokit } from 'octokit'; -import nock from 'nock'; -import { afterEach, describe, expect, it } from 'vitest'; -import { registerIssuesTools } from '../../../src/toolsets/issues.js'; - -async function connectedClient(permission: 'read-only' | 'read-write') { - const octokit = new Octokit({ auth: 'test-token', baseUrl: 'https://api.github.com' }); - const server = new McpServer({ name: 'test-server', version: '0.0.0' }); - registerIssuesTools(server, octokit, permission); - - const client = new Client({ name: 'test-client', version: '0.0.0' }); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); - return client; -} - -describe('registerIssuesTools', () => { - afterEach(() => { - nock.cleanAll(); - }); - - it('registers list_issues and returns the raw GitHub response as JSON', async () => { - nock('https://api.github.com') - .get('/repos/octocat/hello-world/issues') - .query({ page: '1', per_page: '30' }) - .reply(200, [{ number: 1, title: 'first issue' }]); - - const client = await connectedClient('read-write'); - const result = await client.callTool({ - name: 'list_issues', - arguments: { owner: 'octocat', repo: 'hello-world' }, - }); - - expect(result.isError).toBeFalsy(); - const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ''; - expect(JSON.parse(text)).toEqual([{ number: 1, title: 'first issue' }]); - }); - - it('passes explicit page and per_page through to list_issues', async () => { - nock('https://api.github.com') - .get('/repos/octocat/hello-world/issues') - .query({ page: '2', per_page: '10' }) - .reply(200, [{ number: 5, title: 'second page issue' }]); - - const client = await connectedClient('read-write'); - const result = await client.callTool({ - name: 'list_issues', - arguments: { owner: 'octocat', repo: 'hello-world', page: 2, per_page: 10 }, - }); - - expect(result.isError).toBeFalsy(); - const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ''; - expect(JSON.parse(text)).toEqual([{ number: 5, title: 'second page issue' }]); - }); - - it('registers get_issue and returns the raw GitHub response as JSON', async () => { - nock('https://api.github.com') - .get('/repos/octocat/hello-world/issues/1') - .reply(200, { number: 1, title: 'first issue', state: 'open' }); - - const client = await connectedClient('read-write'); - const result = await client.callTool({ - name: 'get_issue', - arguments: { owner: 'octocat', repo: 'hello-world', issue_number: 1 }, - }); - - expect(result.isError).toBeFalsy(); - const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ''; - expect(JSON.parse(text)).toMatchObject({ number: 1, state: 'open' }); - }); - - it('propagates a 404 as an MCP tool error with the raw GitHub message', async () => { - nock('https://api.github.com') - .get('/repos/octocat/hello-world/issues/999') - .reply(404, { message: 'Not Found', documentation_url: 'https://docs.github.com/rest' }); - - const client = await connectedClient('read-write'); - const result = await client.callTool({ - name: 'get_issue', - arguments: { owner: 'octocat', repo: 'hello-world', issue_number: 999 }, - }); - - expect(result.isError).toBe(true); - const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ''; - expect(text).toContain('Not Found'); - }); - - it('registers list_comments and returns the raw GitHub response as JSON', async () => { - nock('https://api.github.com') - .get('/repos/octocat/hello-world/issues/1/comments') - .query({ page: '1', per_page: '30' }) - .reply(200, [{ id: 10, body: 'a comment' }]); - - const client = await connectedClient('read-write'); - const result = await client.callTool({ - name: 'list_comments', - arguments: { owner: 'octocat', repo: 'hello-world', issue_number: 1 }, - }); - - expect(result.isError).toBeFalsy(); - const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ''; - expect(JSON.parse(text)).toEqual([{ id: 10, body: 'a comment' }]); - }); - - it('registers list_labels and returns the raw GitHub response as JSON', async () => { - nock('https://api.github.com') - .get('/repos/octocat/hello-world/labels') - .query({ page: '1', per_page: '30' }) - .reply(200, [{ name: 'bug', color: 'ff0000' }]); - - const client = await connectedClient('read-write'); - const result = await client.callTool({ - name: 'list_labels', - arguments: { owner: 'octocat', repo: 'hello-world' }, - }); - - expect(result.isError).toBeFalsy(); - const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ''; - expect(JSON.parse(text)).toEqual([{ name: 'bug', color: 'ff0000' }]); - }); - - it('registers list_labels_on_issue and returns the raw GitHub response as JSON', async () => { - nock('https://api.github.com') - .get('/repos/octocat/hello-world/issues/1/labels') - .query({ page: '1', per_page: '30' }) - .reply(200, [{ name: 'help wanted' }]); - - const client = await connectedClient('read-write'); - const result = await client.callTool({ - name: 'list_labels_on_issue', - arguments: { owner: 'octocat', repo: 'hello-world', issue_number: 1 }, - }); - - expect(result.isError).toBeFalsy(); - const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ''; - expect(JSON.parse(text)).toEqual([{ name: 'help wanted' }]); - }); -}); -``` - -- [x] **Step 2: Run test to verify it fails** - -Run: `npm test -- issues` -Expected: FAIL with a module-not-found error for `../../../src/toolsets/issues.js`. - -- [x] **Step 3: Create `src/toolsets/issues.ts` with the 5 read tools** - -```typescript -import type { McpServer } from '@modelcontextprotocol/server'; -import type { Octokit } from 'octokit'; -import { z } from 'zod'; -import { ownerRepoSchema, paginationSchema, toToolResult, toToolError } from './common.js'; - -export function registerIssuesTools( - server: McpServer, - octokit: Octokit, - permission: 'read-only' | 'read-write', -): void { - server.registerTool( - 'list_issues', - { - description: 'List issues in a GitHub repository.', - inputSchema: z.object({ - ...ownerRepoSchema, - state: z.enum(['open', 'closed', 'all']).optional().describe('Filter by issue state (defaults to open)'), - labels: z.string().optional().describe('Comma-separated list of label names to filter by'), - assignee: z.string().optional().describe('Filter by assignee login, "none", or "*" for any'), - sort: z.enum(['created', 'updated', 'comments']).optional().describe('Field to sort results by'), - direction: z.enum(['asc', 'desc']).optional().describe('Sort direction'), - ...paginationSchema, - }), - }, - async ({ owner, repo, state, labels, assignee, sort, direction, page, per_page }) => { - try { - const response = await octokit.rest.issues.listForRepo({ - owner, - repo, - state, - labels, - assignee, - sort, - direction, - page, - per_page, - }); - return toToolResult(response.data); - } catch (error) { - return toToolError(error); - } - }, - ); - - server.registerTool( - 'get_issue', - { - description: 'Get a single issue in a GitHub repository.', - inputSchema: z.object({ - ...ownerRepoSchema, - issue_number: z.number().int().describe('Issue number'), - }), - }, - async ({ owner, repo, issue_number }) => { - try { - const response = await octokit.rest.issues.get({ owner, repo, issue_number }); - return toToolResult(response.data); - } catch (error) { - return toToolError(error); - } - }, - ); - - server.registerTool( - 'list_comments', - { - description: 'List comments on a GitHub issue.', - inputSchema: z.object({ - ...ownerRepoSchema, - issue_number: z.number().int().describe('Issue number'), - ...paginationSchema, - }), - }, - async ({ owner, repo, issue_number, page, per_page }) => { - try { - const response = await octokit.rest.issues.listComments({ - owner, - repo, - issue_number, - page, - per_page, - }); - return toToolResult(response.data); - } catch (error) { - return toToolError(error); - } - }, - ); - - server.registerTool( - 'list_labels', - { - description: 'List all labels defined in a GitHub repository.', - inputSchema: z.object({ - ...ownerRepoSchema, - ...paginationSchema, - }), - }, - async ({ owner, repo, page, per_page }) => { - try { - const response = await octokit.rest.issues.listLabelsForRepo({ owner, repo, page, per_page }); - return toToolResult(response.data); - } catch (error) { - return toToolError(error); - } - }, - ); - - server.registerTool( - 'list_labels_on_issue', - { - description: 'List the labels currently applied to a GitHub issue.', - inputSchema: z.object({ - ...ownerRepoSchema, - issue_number: z.number().int().describe('Issue number'), - ...paginationSchema, - }), - }, - async ({ owner, repo, issue_number, page, per_page }) => { - try { - const response = await octokit.rest.issues.listLabelsOnIssue({ - owner, - repo, - issue_number, - page, - per_page, - }); - return toToolResult(response.data); - } catch (error) { - return toToolError(error); - } - }, - ); - - if (permission === 'read-write') { - // Write tools added in Task 3. - } -} -``` - -- [x] **Step 4: Run test to verify it passes** - -Run: `npm test -- issues` -Expected: PASS (7 tests). - -- [x] **Step 5: Typecheck and lint** - -Run: `npm run typecheck && npm run lint` -Expected: both PASS with zero errors. Note: an empty `if (permission === 'read-write') { }` block with only a comment may trigger an ESLint "no-empty" warning — if it does, remove the `if` block entirely for now and re-add it in Task 3 rather than suppressing the lint rule. - -- [x] **Step 6: Commit** - -```bash -git add src/toolsets/issues.ts test/unit/toolsets/issues.test.ts -git commit -m "feat: add issues toolset read tools (list_issues, get_issue, list_comments, list_labels, list_labels_on_issue)" -``` - ---- - -## Task 3: Implement the `issues` toolset — write tools (create, update, add comment, add labels, remove label, lock, unlock) - -**Files:** -- Modify: `src/toolsets/issues.ts` (add write tools inside the `if (permission === 'read-write')` block from Task 2) -- Test: `test/unit/toolsets/issues.test.ts` (append write-tool tests) - -**Interfaces:** -- Consumes: same `registerIssuesTools` function body from Task 2 — this task adds to it, not replaces it. -- Produces: the following tool names become registered under `read-write` only: `create_issue`, `update_issue`, `add_comment`, `add_labels`, `remove_label`, `lock_issue`, `unlock_issue`. - -That's 5 read tools (Task 2) + 7 write tools (this task) = 12 tools, slightly above the spec's ~10 estimate — the spec explicitly calls tool counts "estimates... refined during implementation," and `lock`/`unlock` are cheap, single-purpose, high-value tools worth keeping separate rather than folding into `update_issue`. - -- [x] **Step 1: Write the failing tests for the write tools** - -Append to `test/unit/toolsets/issues.test.ts`, inside the existing `describe('registerIssuesTools', ...)` block, right before the closing `});`: - -```typescript - it('registers create_issue and returns the raw GitHub response as JSON', async () => { - nock('https://api.github.com') - .post('/repos/octocat/hello-world/issues', { title: 'a new bug' }) - .reply(201, { number: 42, title: 'a new bug' }); - - const client = await connectedClient('read-write'); - const result = await client.callTool({ - name: 'create_issue', - arguments: { owner: 'octocat', repo: 'hello-world', title: 'a new bug' }, - }); - - expect(result.isError).toBeFalsy(); - const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ''; - expect(JSON.parse(text)).toEqual({ number: 42, title: 'a new bug' }); - }); - - it('registers update_issue and returns the raw GitHub response as JSON', async () => { - nock('https://api.github.com') - .patch('/repos/octocat/hello-world/issues/1', { state: 'closed' }) - .reply(200, { number: 1, state: 'closed' }); - - const client = await connectedClient('read-write'); - const result = await client.callTool({ - name: 'update_issue', - arguments: { owner: 'octocat', repo: 'hello-world', issue_number: 1, state: 'closed' }, - }); - - expect(result.isError).toBeFalsy(); - const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ''; - expect(JSON.parse(text)).toEqual({ number: 1, state: 'closed' }); - }); - - it('registers add_comment and returns the raw GitHub response as JSON', async () => { - nock('https://api.github.com') - .post('/repos/octocat/hello-world/issues/1/comments', { body: 'a comment' }) - .reply(201, { id: 99, body: 'a comment' }); - - const client = await connectedClient('read-write'); - const result = await client.callTool({ - name: 'add_comment', - arguments: { owner: 'octocat', repo: 'hello-world', issue_number: 1, body: 'a comment' }, - }); - - expect(result.isError).toBeFalsy(); - const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ''; - expect(JSON.parse(text)).toEqual({ id: 99, body: 'a comment' }); - }); - - it('registers add_labels and returns the raw GitHub response as JSON', async () => { - nock('https://api.github.com') - .post('/repos/octocat/hello-world/issues/1/labels', { labels: ['bug'] }) - .reply(200, [{ name: 'bug' }]); - - const client = await connectedClient('read-write'); - const result = await client.callTool({ - name: 'add_labels', - arguments: { owner: 'octocat', repo: 'hello-world', issue_number: 1, labels: ['bug'] }, - }); - - expect(result.isError).toBeFalsy(); - const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ''; - expect(JSON.parse(text)).toEqual([{ name: 'bug' }]); - }); - - it('registers remove_label and returns the raw GitHub response as JSON', async () => { - nock('https://api.github.com') - .delete('/repos/octocat/hello-world/issues/1/labels/bug') - .reply(200, [{ name: 'enhancement' }]); - - const client = await connectedClient('read-write'); - const result = await client.callTool({ - name: 'remove_label', - arguments: { owner: 'octocat', repo: 'hello-world', issue_number: 1, name: 'bug' }, - }); - - expect(result.isError).toBeFalsy(); - const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ''; - expect(JSON.parse(text)).toEqual([{ name: 'enhancement' }]); - }); - - it('registers lock_issue and returns success with no content', async () => { - nock('https://api.github.com') - .put('/repos/octocat/hello-world/issues/1/lock', { lock_reason: 'resolved' }) - .reply(204); - - const client = await connectedClient('read-write'); - const result = await client.callTool({ - name: 'lock_issue', - arguments: { owner: 'octocat', repo: 'hello-world', issue_number: 1, lock_reason: 'resolved' }, - }); - - expect(result.isError).toBeFalsy(); - }); - - it('registers unlock_issue and returns success with no content', async () => { - nock('https://api.github.com') - .delete('/repos/octocat/hello-world/issues/1/lock') - .reply(204); - - const client = await connectedClient('read-write'); - const result = await client.callTool({ - name: 'unlock_issue', - arguments: { owner: 'octocat', repo: 'hello-world', issue_number: 1 }, - }); - - expect(result.isError).toBeFalsy(); - }); - - it('does not register any write tool in read-only mode', async () => { - const client = await connectedClient('read-only'); - const { tools } = await client.listTools(); - const names = tools.map((tool) => tool.name); - expect(names).not.toEqual( - expect.arrayContaining([ - 'create_issue', - 'update_issue', - 'add_comment', - 'add_labels', - 'remove_label', - 'lock_issue', - 'unlock_issue', - ]), - ); - }); - - it('registers all 5 read-only tools regardless of permission', async () => { - const client = await connectedClient('read-only'); - const { tools } = await client.listTools(); - const names = tools.map((tool) => tool.name); - expect(names).toEqual( - expect.arrayContaining([ - 'list_issues', - 'get_issue', - 'list_comments', - 'list_labels', - 'list_labels_on_issue', - ]), - ); - }); -``` - -- [x] **Step 2: Run test to verify it fails** - -Run: `npm test -- issues` -Expected: FAIL — the 7 new write-tool tests fail with "Tool not found" (`ProtocolError`) since the tools don't exist yet; the 2 permission-check tests may pass vacuously (nothing to find is nothing registered) or fail depending on assertion direction — re-run after Step 3 regardless. - -- [x] **Step 3: Add the write tools to `src/toolsets/issues.ts`** - -Replace the `if (permission === 'read-write') { // Write tools added in Task 3. }` placeholder (or, if Task 2's lint step removed the empty block entirely, add this block at the end of `registerIssuesTools`, right before its closing `}`) with: - -```typescript - if (permission === 'read-write') { - server.registerTool( - 'create_issue', - { - description: 'Create a new issue in a GitHub repository.', - inputSchema: z.object({ - ...ownerRepoSchema, - title: z.string().describe('Issue title'), - body: z.string().optional().describe('Issue body/description'), - assignees: z.array(z.string()).optional().describe('Logins to assign to the issue'), - labels: z.array(z.string()).optional().describe('Label names to apply to the issue'), - }), - }, - async ({ owner, repo, title, body, assignees, labels }) => { - try { - const response = await octokit.rest.issues.create({ - owner, - repo, - title, - body, - assignees, - labels, - }); - return toToolResult(response.data); - } catch (error) { - return toToolError(error); - } - }, - ); - - server.registerTool( - 'update_issue', - { - description: 'Update an existing issue in a GitHub repository.', - inputSchema: z.object({ - ...ownerRepoSchema, - issue_number: z.number().int().describe('Issue number'), - title: z.string().optional().describe('New issue title'), - body: z.string().optional().describe('New issue body/description'), - state: z.enum(['open', 'closed']).optional().describe('New issue state'), - state_reason: z - .enum(['completed', 'not_planned', 'duplicate', 'reopened']) - .optional() - .describe('Reason for the state change, only applied when state is changed'), - assignees: z.array(z.string()).optional().describe('Logins to assign to the issue'), - labels: z.array(z.string()).optional().describe('Label names to replace the issue\'s current labels'), - }), - }, - async ({ owner, repo, issue_number, title, body, state, state_reason, assignees, labels }) => { - try { - const response = await octokit.rest.issues.update({ - owner, - repo, - issue_number, - title, - body, - state, - state_reason, - assignees, - labels, - }); - return toToolResult(response.data); - } catch (error) { - return toToolError(error); - } - }, - ); - - server.registerTool( - 'add_comment', - { - description: 'Add a comment to a GitHub issue.', - inputSchema: z.object({ - ...ownerRepoSchema, - issue_number: z.number().int().describe('Issue number'), - body: z.string().describe('Comment body'), - }), - }, - async ({ owner, repo, issue_number, body }) => { - try { - const response = await octokit.rest.issues.createComment({ owner, repo, issue_number, body }); - return toToolResult(response.data); - } catch (error) { - return toToolError(error); - } - }, - ); - - server.registerTool( - 'add_labels', - { - description: "Add labels to a GitHub issue, keeping the issue's existing labels.", - inputSchema: z.object({ - ...ownerRepoSchema, - issue_number: z.number().int().describe('Issue number'), - labels: z.array(z.string()).describe('Label names to add'), - }), - }, - async ({ owner, repo, issue_number, labels }) => { - try { - const response = await octokit.rest.issues.addLabels({ owner, repo, issue_number, labels }); - return toToolResult(response.data); - } catch (error) { - return toToolError(error); - } - }, - ); - - server.registerTool( - 'remove_label', - { - description: 'Remove a single label from a GitHub issue.', - inputSchema: z.object({ - ...ownerRepoSchema, - issue_number: z.number().int().describe('Issue number'), - name: z.string().describe('Name of the label to remove'), - }), - }, - async ({ owner, repo, issue_number, name }) => { - try { - const response = await octokit.rest.issues.removeLabel({ owner, repo, issue_number, name }); - return toToolResult(response.data); - } catch (error) { - return toToolError(error); - } - }, - ); - - server.registerTool( - 'lock_issue', - { - description: 'Lock a GitHub issue conversation to collaborators only.', - inputSchema: z.object({ - ...ownerRepoSchema, - issue_number: z.number().int().describe('Issue number'), - lock_reason: z - .enum(['off-topic', 'too heated', 'resolved', 'spam']) - .optional() - .describe('Reason for locking the conversation'), - }), - }, - async ({ owner, repo, issue_number, lock_reason }) => { - try { - await octokit.rest.issues.lock({ owner, repo, issue_number, lock_reason }); - return toToolResult({ locked: true }); - } catch (error) { - return toToolError(error); - } - }, - ); - - server.registerTool( - 'unlock_issue', - { - description: 'Unlock a previously locked GitHub issue conversation.', - inputSchema: z.object({ - ...ownerRepoSchema, - issue_number: z.number().int().describe('Issue number'), - }), - }, - async ({ owner, repo, issue_number }) => { - try { - await octokit.rest.issues.unlock({ owner, repo, issue_number }); - return toToolResult({ locked: false }); - } catch (error) { - return toToolError(error); - } - }, - ); - } -``` - -Note on `lock_issue`/`unlock_issue`: GitHub's API returns `204 No Content` for both endpoints (confirmed against `@octokit/openapi-types`'s `issues/lock` and `issues/unlock` operation definitions — both have `responses: { 204: { content: never } }`), so there is no response body to pass through. Returning a small synthetic `{ locked: true }` / `{ locked: false }` object (rather than `response.data`, which octokit types as `never`/`undefined` here) keeps `toToolResult`'s contract of "always returns a JSON text block" consistent across every tool in the codebase — this is a deliberate, minimal exception to the "raw response body, unmodified" rule, justified by there being no body to return, not a design choice to summarize data. - -- [x] **Step 4: Run test to verify it passes** - -Run: `npm test -- issues` -Expected: PASS (16 tests: 7 from Task 2 + 9 new). - -- [x] **Step 5: Typecheck, lint, and full suite** - -Run: `npm run typecheck && npm run lint && npm test` -Expected: all PASS. Full suite total: 33 (after Task 1) + 16 = 49 tests. - -- [x] **Step 6: Commit** - -```bash -git add src/toolsets/issues.ts test/unit/toolsets/issues.test.ts -git commit -m "feat: add issues toolset write tools (create, update, comment, labels, lock)" -``` - ---- - -## Task 4: Wire `registerIssuesTools` into `server.ts` - -**Files:** -- Modify: `src/server.ts` - -**Interfaces:** -- Consumes: `registerIssuesTools(server, octokit, permission)` from Task 3. -- Produces: nothing new — this is the final integration point; no later task depends on `server.ts`'s internals beyond what Task 6 (core plan) already established. - -- [x] **Step 1: Modify `src/server.ts`** - -Current content: - -```typescript -import { McpServer } from '@modelcontextprotocol/server'; -import type { Octokit } from 'octokit'; -import { registerReposTools } from './toolsets/repos.js'; - -const SERVER_NAME = 'github-mcp-server-js'; -const SERVER_VERSION = '0.1.0'; - -export function buildServer( - octokit: Octokit, - permission: 'read-only' | 'read-write', -): McpServer { - const server = new McpServer({ name: SERVER_NAME, version: SERVER_VERSION }); - - registerReposTools(server, octokit, permission); - - return server; -} -``` - -Replace with: - -```typescript -import { McpServer } from '@modelcontextprotocol/server'; -import type { Octokit } from 'octokit'; -import { registerIssuesTools } from './toolsets/issues.js'; -import { registerReposTools } from './toolsets/repos.js'; - -const SERVER_NAME = 'github-mcp-server-js'; -const SERVER_VERSION = '0.1.0'; - -export function buildServer( - octokit: Octokit, - permission: 'read-only' | 'read-write', -): McpServer { - const server = new McpServer({ name: SERVER_NAME, version: SERVER_VERSION }); - - registerReposTools(server, octokit, permission); - registerIssuesTools(server, octokit, permission); - - return server; -} -``` - -- [x] **Step 2: Run the full test suite** - -Run: `npm test` -Expected: PASS, same 49 tests as after Task 3 (no test exercises `server.ts` directly today — the core plan's Task 6 relied on controller-level verification plus the CLI smoke test in Task 8, and this task follows the same precedent since `buildServer` is a thin, non-branching composition function). - -- [x] **Step 3: Typecheck, lint, build** - -Run: `npm run typecheck && npm run lint && npm run build` -Expected: all PASS. The build step matters here specifically — it confirms the bundled CLI output actually includes the new toolset (tsup performs a full re-bundle from `src/cli.ts`'s dependency graph, which now transitively includes `issues.ts`). - -- [x] **Step 4: Manual end-to-end smoke test** - -Run: -```bash -GITHUB_TOKEN=fake-token node dist/cli.js --transport=http --port=3991 & -sleep 1 -curl -s -X POST http://localhost:3991/mcp \ - -H 'Content-Type: application/json' \ - -H 'Accept: application/json, text/event-stream' \ - -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"smoke-test","version":"0.0.0"}}}' -``` - -Expected: a `200` response containing `"serverInfo":{"name":"github-mcp-server-js"...}`. Then send a `tools/list` request (reusing the `mcp-session-id` response header from the `initialize` call) and confirm the tool list includes both `get_repository` (from the `repos` toolset) and `list_issues` (from this plan's `issues` toolset) — proving both toolsets are live in the same server. Kill the background process afterward (`kill %1`). - -- [x] **Step 5: Commit** - -```bash -git add src/server.ts -git commit -m "feat: wire issues toolset into buildServer" -``` - ---- - -## Self-Review Notes - -- **Spec coverage:** `issues` toolset (Task Inventory row `issues`: octokit `issues`/`interactions` namespaces — this plan covers `issues`; `reactions` is out of scope for this toolset per GitHub's own REST API grouping, where reactions apply to issues/comments/PRs generically and are commonly split into their own toolset in later plans, consistent with the spec's per-toolset table treating `reactions` as a secondary namespace rather than issues' primary surface). Pagination (`page`/`per_page` defaults, max 100) — Task 2. Raw JSON response/error passthrough — Tasks 2-3. Registration-time permission gating — Task 3. The two Plan-1-deferred Minor findings folded in as instructed: shared-helper extraction (`common.ts`) — Task 1; pagination-defaults test — Task 2's `list_issues` test with no page args asserting `?page=1&per_page=30` on the wire. -- **Type consistency:** `registerIssuesTools(server: McpServer, octokit: Octokit, permission: 'read-only' | 'read-write'): void` (Task 2) matches `registerReposTools`'s signature exactly and is called identically in `server.ts` (Task 4). `ownerRepoSchema`/`paginationSchema`/`toToolResult`/`toToolError` (Task 1) are typed once in `common.ts` and consumed with identical import syntax in both `repos.ts` (Task 1) and `issues.ts` (Tasks 2-3) — no redeclaration anywhere. -- **No placeholders:** every step includes complete, runnable code. Parameter names and endpoint paths (`issues.listForRepo`, `issues.get`, `issues.create`, `issues.update`, `issues.createComment`, `issues.listComments`, `issues.listLabelsForRepo`, `issues.listLabelsOnIssue`, `issues.addLabels`, `issues.removeLabel`, `issues.lock`, `issues.unlock`) and their exact path/query/body parameter shapes were verified directly against the installed `@octokit/openapi-types` and `@octokit/plugin-rest-endpoint-methods` package type declarations and generated endpoint tables (not guessed from documentation or general GitHub API familiarity) — e.g. confirming `issues/lock` and `issues/unlock` return `204 No Content` with no response body, which is why `lock_issue`/`unlock_issue`'s handlers return a synthetic result instead of `response.data`. diff --git a/docs/superpowers/plans/2026-08-05-github-mcp-server-misc.md b/docs/superpowers/plans/2026-08-05-github-mcp-server-misc.md deleted file mode 100644 index 9cabf27..0000000 --- a/docs/superpowers/plans/2026-08-05-github-mcp-server-misc.md +++ /dev/null @@ -1,710 +0,0 @@ -# github-mcp-server-js — `misc` Toolset Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax for tracking. - -**Goal:** Add the `misc` toolset (4 utility tools wrapping GitHub's rate-limit, meta, emojis, and markdown-rendering endpoints) to `github-mcp-server-js`, following the exact structural, permission-gating, and response/error pattern established by the `users` and `packages` toolsets. - -**Architecture:** Same pattern as prior toolsets: one `registerMiscTools(server, octokit, permission)` function registers each tool with `server.registerTool(name, {description, inputSchema}, handler)`; every handler wraps its octokit call in try/catch, returning raw data via `toToolResult`/`toToolError`; `server.ts` gains one more registration call. Octokit calls span four namespaces: `octokit.rest.rateLimit`, `octokit.rest.meta`, `octokit.rest.emojis`, `octokit.rest.markdown`. Because every selected endpoint is either a GET or a stateless transformation POST, all 4 tools are safe under `read-only` mode. The `permission` parameter is accepted (to keep the signature uniform) but never inspected; it is renamed `_permission` inside the function body to satisfy `@typescript-eslint/no-unused-vars`. - -**The `render_markdown` special case:** `markdown/render` returns `Content-Type: text/html`, so `response.data` is a raw HTML string. Passing it to `toToolResult` would call `JSON.stringify(data)`, producing a double-encoded JSON string (e.g. `"\"

Hello

\""`) instead of the actual HTML markup. This plan handles the case inline in the `render_markdown` handler: it builds the MCP tool result manually with `{ content: [{ type: 'text' as const, text: response.data as string }] }`, bypassing `toToolResult` for this one tool only. No new shared helper is added to `common.ts` — this shape appears in exactly one tool across the entire codebase and adding a helper for one callsite would couple a general-purpose module to a markdown-specific concern. - -**Tech Stack:** Same as prior plans — TypeScript, `@modelcontextprotocol/server@^2.0.0`, `octokit@^5.0.5`, `zod@^4.2.0`, `vitest`, `nock`. No new dependencies. - -## Global Constraints - -- Tool responses return the raw octokit response body unmodified. `get_rate_limit` returns a `rate-limit-overview` JSON object; `get_meta` returns an `api-overview` JSON object; `list_emojis` returns a `{ [key: string]: string }` emoji-name-to-URL map; `render_markdown` returns a raw HTML string. These shapes are preserved exactly; callers extract fields themselves. -- Errors propagate unmodified: catch the octokit `RequestError`, surface `error.message` in an MCP tool error result via `toToolError`. No normalization layer. -- None of the 4 tools take `page`/`per_page` parameters. `get_rate_limit`, `get_meta`, and `list_emojis` return complete, non-paginated responses. `render_markdown` is a stateless text transformation. -- `GITHUB_PERMISSION=read-only` gating is a no-op for this toolset because every tool is read-only. The read-only test still verifies the exact set of 4 tools is registered using `.toEqual([...exact set...])`, not `arrayContaining`, following the pattern established in `users` and `packages` plans. -- Verified tool-name collision check against the 54 existing tool names (8 repos, 12 issues, 10 pull_requests, 5 search, 5 users, 5 gists, 5 activity, 4 packages): **zero collisions** with the 4 new names (`get_rate_limit`, `get_meta`, `list_emojis`, `render_markdown`). None of these appear anywhere in the current tool name set. -- No modification to `common.ts`. The `render_markdown` inline result construction does not require a new shared helper. -- TypeScript only, no new runtime dependencies. - ---- - -## Octokit Verification Results - -Verified via `node --input-type=module -e "..."` against the installed `octokit@^5.0.5`: - -| Namespace | Method | HTTP | URL | -|---|---|---|---| -| `rateLimit` | `get` | GET | `/rate_limit` | -| `meta` | `get` | GET | `/meta` | -| `meta` | `getAllVersions` | GET | `/versions` | -| `meta` | `getOctocat` | GET | `/octocat` | -| `meta` | `getZen` | GET | `/zen` | -| `meta` | `root` | GET | `/` | -| `emojis` | `get` | GET | `/emojis` | -| `markdown` | `render` | POST | `/markdown` | -| `markdown` | `renderRaw` | POST | `/markdown/raw` | -| `codesOfConduct` | `getAllCodesOfConduct` | GET | `/codes_of_conduct` | -| `codesOfConduct` | `getConductCode` | GET | `/codes_of_conduct/{key}` | - -**Octokit surprise — `markdown` namespace:** The `markdown` namespace contains two render methods. `render` (POST `/markdown`) accepts a JSON body with `{ text, mode?, context? }` and returns `Content-Type: text/html`. `renderRaw` (POST `/markdown/raw`) sends raw text and also returns `text/html`. The plan uses `markdown.render` (the JSON-body variant) because it provides the `mode` and `context` parameters useful to callers, and its JSON input maps cleanly to Zod parameters. - -**Octokit surprise — `text/html` response body:** For any `Content-Type: text/html` response, `@octokit/request`'s `fetch-wrapper.js` calls `response.text()` and assigns the result to `response.data` (a string), rather than `JSON.parse`. This means `response.data` is a raw HTML string after a successful `markdown.render` call. `toToolResult` in `common.ts` does `JSON.stringify(data)`, so passing a string produces `"\"

Hello

\""` — a JSON-encoded string rather than the HTML text itself. The handler must build the `content` array manually (see implementation below). - -**Selection rationale for `meta.get` vs. other `meta.*` methods:** `meta.getOctocat` and `meta.getZen` return novelty ASCII art and zen aphorisms — amusing but not useful as LLM tools. `meta.root` returns the same data as `meta.get` (the GitHub API root) but without the full IP-range/SSH-key/hook detail. `meta.getAllVersions` returns a list of supported API versions (low utility). `meta.get` is the only one with meaningful operational value (IP ranges, SSH fingerprints, public keys). The other `meta.*` methods are excluded (see Deliberate scope decisions). - ---- - -## Reference: verified octokit parameter shapes - -Confirmed against `@octokit/openapi-types/types.d.ts` operations: - -| Tool | octokit method | HTTP | Params | Response body type | -|---|---|---|---|---| -| `get_rate_limit` | `rateLimit.get` | GET `/rate_limit` | none | `rate-limit-overview` (JSON) | -| `get_meta` | `meta.get` | GET `/meta` | none | `api-overview` (JSON) | -| `list_emojis` | `emojis.get` | GET `/emojis` | none | `{ [key: string]: string }` (JSON map) | -| `render_markdown` | `markdown.render` | POST `/markdown` | body: `text` (required string), `mode?` (`"markdown" \| "gfm"`), `context?` (string) | raw HTML string (`text/html`) | - -**`rate-limit-overview` schema** (from `components["schemas"]["rate-limit-overview"]`): -``` -{ - resources: { - core: rate-limit, - graphql?: rate-limit, - search: rate-limit, - code_search?: rate-limit, - source_import?: rate-limit, - integration_manifest?: rate-limit, - code_scanning_upload?: rate-limit, - actions_runner_registration?: rate-limit, - scim?: rate-limit, - dependency_snapshots?: rate-limit, - ... - }, - rate: rate-limit -} -``` -where `rate-limit` is `{ limit: number, used: number, remaining: number, reset: number }`. - -**`api-overview` schema** (from `components["schemas"]["api-overview"]`): Contains `verifiable_password_authentication`, `ssh_key_fingerprints`, `ssh_keys`, `hooks`, `github_enterprise_importer`, `api`, `web`, `git`, `packages`, `pages`, `importer`, `actions`, `actions_macos`, `copilot`, `dependabot` — all arrays of IP CIDR strings or strings. - -**`markdown/render` request body** (from `operations["markdown/render"].requestBody.content["application/json"]`): -- `text: string` — required. The Markdown text to render. -- `mode?: "markdown" | "gfm"` — optional. Rendering mode. `"gfm"` enables GitHub Flavored Markdown with repo-context cross-references. Defaults to `"markdown"`. -- `context?: string` — optional. Repository context for GFM cross-references (e.g. `"octo-org/octo-repo"`). Only meaningful when `mode = "gfm"`. - -**`markdown/render` response** (from `operations["markdown/render"].responses[200].content["text/html"]`): `string`. The rendered HTML. Confirmed `response.data` is a `string` at runtime (octokit `fetch-wrapper.js` line 121-125: `mimetype.type.startsWith("text/")` → `response.text()`). - -**cspell.json note:** The word `gfm` may be flagged by cspell. If the pre-commit hook rejects it, add `"gfm"` to the `words` array in `cspell.json`. All other words used in this toolset (`rateLimit`, `emojis`, `markdown`, `octocat`, `monalisa`) are either already in `cspell.json`, recognized by cspell's default dictionary, or standard technical abbreviations. - ---- - -## File Structure - -``` -github-mcp-server-js/ - src/ - toolsets/ - misc.ts # NEW: registerMiscTools(server, octokit, permission) - server.ts # MODIFIED: calls registerMiscTools - test/ - unit/ - toolsets/ - misc.test.ts # NEW: mirrors packages.test.ts's structure -``` - -`common.ts` is NOT modified. `README.md`'s Toolsets section is updated in Task 2 (Step 5). - ---- - -## Task 1: Implement the `misc` toolset (4 tools) and its tests - -**Files:** -- Create: `src/toolsets/misc.ts` -- Create: `test/unit/toolsets/misc.test.ts` - -**Interfaces:** -- Consumes: `toToolResult`, `toToolError` from `./common.js`. Does NOT use `paginationSchema` (none of the 4 tools are paginated). -- Produces (for Task 2 / `server.ts` to consume): - - `registerMiscTools(server: McpServer, octokit: Octokit, permission: 'read-only' | 'read-write'): void` - -- [x] **Step 1: Write the failing tests** - -Create `test/unit/toolsets/misc.test.ts`: - -```typescript -import nock from 'nock'; -import { afterEach, describe, expect, it } from 'vitest'; -import { registerMiscTools } from '../../../src/toolsets/misc.js'; -import { connectedClient } from './test-helpers.js'; - -describe('registerMiscTools', () => { - afterEach(() => { - nock.cleanAll(); - }); - - // ── get_rate_limit ───────────────────────────────────────────────────────── - - it('registers get_rate_limit and returns the raw rate-limit-overview object as JSON', async () => { - nock('https://api.github.com') - .get('/rate_limit') - .reply(200, { - resources: { - core: { limit: 5000, used: 10, remaining: 4990, reset: 1640995200 }, - search: { limit: 30, used: 0, remaining: 30, reset: 1640995200 }, - graphql: { limit: 5000, used: 0, remaining: 5000, reset: 1640995200 }, - }, - rate: { limit: 5000, used: 10, remaining: 4990, reset: 1640995200 }, - }); - - const client = await connectedClient(registerMiscTools, 'read-write'); - const result = await client.callTool({ name: 'get_rate_limit', arguments: {} }); - - expect(result.isError).toBeFalsy(); - const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ''; - const parsed = JSON.parse(text) as { - resources: { core: { remaining: number } }; - rate: { remaining: number }; - }; - expect(parsed.resources.core.remaining).toBe(4990); - expect(parsed.rate.remaining).toBe(4990); - }); - - it('propagates a 404 from get_rate_limit as an MCP tool error', async () => { - nock('https://api.github.com') - .get('/rate_limit') - .reply(404, { message: 'Not Found', documentation_url: 'https://docs.github.com/rest' }); - - const client = await connectedClient(registerMiscTools, 'read-write'); - const result = await client.callTool({ name: 'get_rate_limit', arguments: {} }); - - expect(result.isError).toBe(true); - const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ''; - expect(text).toContain('Not Found'); - }); - - // ── get_meta ─────────────────────────────────────────────────────────────── - - it('registers get_meta and returns the raw api-overview object as JSON', async () => { - nock('https://api.github.com') - .get('/meta') - .reply(200, { - verifiable_password_authentication: true, - ssh_key_fingerprints: { - SHA256_RSA: 'abc123', - SHA256_ECDSA: 'def456', - }, - ssh_keys: ['ssh-rsa AAAA...'], - hooks: ['192.30.252.0/22'], - web: ['192.30.252.0/22'], - api: ['192.30.252.0/22'], - git: ['192.30.252.0/22'], - packages: ['192.30.252.0/22'], - pages: ['192.30.252.0/22'], - importer: ['192.30.252.0/22'], - actions: ['192.30.252.0/22'], - dependabot: ['192.30.252.0/22'], - }); - - const client = await connectedClient(registerMiscTools, 'read-write'); - const result = await client.callTool({ name: 'get_meta', arguments: {} }); - - expect(result.isError).toBeFalsy(); - const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ''; - const parsed = JSON.parse(text) as { - verifiable_password_authentication: boolean; - hooks: string[]; - }; - expect(parsed.verifiable_password_authentication).toBe(true); - expect(parsed.hooks).toContain('192.30.252.0/22'); - }); - - it('propagates a 304 from get_meta as an MCP tool error', async () => { - nock('https://api.github.com').get('/meta').reply(304); - - const client = await connectedClient(registerMiscTools, 'read-write'); - const result = await client.callTool({ name: 'get_meta', arguments: {} }); - - // Octokit throws a RequestError for 304 ("Not modified") — surfaces as tool error. - expect(result.isError).toBe(true); - const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ''; - expect(text).toContain('Not modified'); - }); - - // ── list_emojis ──────────────────────────────────────────────────────────── - - it('registers list_emojis and returns the raw emoji map as JSON', async () => { - nock('https://api.github.com') - .get('/emojis') - .reply(200, { - '+1': 'https://github.githubassets.com/images/icons/emoji/unicode/1f44d.png', - '-1': 'https://github.githubassets.com/images/icons/emoji/unicode/1f44e.png', - smile: 'https://github.githubassets.com/images/icons/emoji/unicode/1f604.png', - octocat: 'https://github.githubassets.com/images/icons/emoji/octocat.png', - }); - - const client = await connectedClient(registerMiscTools, 'read-write'); - const result = await client.callTool({ name: 'list_emojis', arguments: {} }); - - expect(result.isError).toBeFalsy(); - const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ''; - const parsed = JSON.parse(text) as Record; - expect(parsed['smile']).toContain('1f604'); - expect(parsed['octocat']).toContain('octocat.png'); - }); - - it('propagates a 304 from list_emojis as an MCP tool error', async () => { - nock('https://api.github.com').get('/emojis').reply(304); - - const client = await connectedClient(registerMiscTools, 'read-write'); - const result = await client.callTool({ name: 'list_emojis', arguments: {} }); - - expect(result.isError).toBe(true); - const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ''; - expect(text).toContain('Not modified'); - }); - - // ── render_markdown ──────────────────────────────────────────────────────── - - it('registers render_markdown and returns the raw HTML string (not double-encoded JSON)', async () => { - const htmlResponse = '

Hello world

\n'; - nock('https://api.github.com') - .post('/markdown', { text: '**world**' }) - .reply(200, htmlResponse, { 'Content-Type': 'text/html; charset=utf-8' }); - - const client = await connectedClient(registerMiscTools, 'read-write'); - const result = await client.callTool({ - name: 'render_markdown', - arguments: { text: '**world**' }, - }); - - expect(result.isError).toBeFalsy(); - const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ''; - // The raw HTML must be returned as-is, NOT as a JSON-encoded string. - // Correct: '

Hello world

\n' - // Wrong: '"

Hello world

\\n"' - expect(text).toBe(htmlResponse); - expect(text).not.toBe(JSON.stringify(htmlResponse)); - }); - - it('forwards mode and context to the markdown render endpoint', async () => { - const scope = nock('https://api.github.com') - .post('/markdown', { - text: 'See #42', - mode: 'gfm', - context: 'octo-org/octo-repo', - }) - .reply(200, '

See octo-org/octo-repo#42

\n', { - 'Content-Type': 'text/html; charset=utf-8', - }); - - const client = await connectedClient(registerMiscTools, 'read-write'); - const result = await client.callTool({ - name: 'render_markdown', - arguments: { text: 'See #42', mode: 'gfm', context: 'octo-org/octo-repo' }, - }); - - expect(result.isError).toBeFalsy(); - expect(scope.isDone()).toBe(true); - const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ''; - expect(text).toContain('octo-org/octo-repo#42'); - }); - - it('propagates a 400 from render_markdown as an MCP tool error', async () => { - nock('https://api.github.com') - .post('/markdown') - .reply(400, { - message: 'Problems parsing JSON', - documentation_url: 'https://docs.github.com/rest', - }); - - const client = await connectedClient(registerMiscTools, 'read-write'); - const result = await client.callTool({ - name: 'render_markdown', - arguments: { text: '' }, - }); - - expect(result.isError).toBe(true); - const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ''; - expect(text).toContain('Problems parsing JSON'); - }); - - // ── registration count ───────────────────────────────────────────────────── - - it('registers exactly the 4 misc tools in read-only mode (and the same set in read-write)', async () => { - const expected = ['get_meta', 'get_rate_limit', 'list_emojis', 'render_markdown']; - - const readOnlyClient = await connectedClient(registerMiscTools, 'read-only'); - const readOnlyTools = await readOnlyClient.listTools(); - expect(readOnlyTools.tools.map((t) => t.name).sort()).toEqual(expected); - - const readWriteClient = await connectedClient(registerMiscTools, 'read-write'); - const readWriteTools = await readWriteClient.listTools(); - expect(readWriteTools.tools.map((t) => t.name).sort()).toEqual(expected); - }); -}); -``` - -- [x] **Step 2: Run tests to verify they fail** - -Run: `npm test -- misc` -Expected: FAIL with a module-not-found error for `../../../src/toolsets/misc.js` (the file doesn't exist yet). - -- [x] **Step 3: Create `src/toolsets/misc.ts`** - -```typescript -import type { McpServer } from '@modelcontextprotocol/server'; -import type { Octokit } from 'octokit'; -import { z } from 'zod'; -import { toToolError, toToolResult } from './common.js'; - -export function registerMiscTools( - server: McpServer, - octokit: Octokit, - _permission: 'read-only' | 'read-write', -): void { - // ── get_rate_limit ───────────────────────────────────────────────────────── - - server.registerTool( - 'get_rate_limit', - { - description: - 'Get the current API rate limit status for the authenticated user. ' + - 'Returns a rate-limit-overview object with per-category breakdowns ' + - '(core, search, graphql, code_search, actions_runner_registration, etc.), ' + - 'each showing limit, used, remaining, and reset timestamp (Unix epoch seconds). ' + - 'Accessing this endpoint does not itself consume any rate limit quota.', - inputSchema: z.object({}), - }, - async () => { - try { - const response = await octokit.rest.rateLimit.get(); - return toToolResult(response.data); - } catch (error) { - return toToolError(error); - } - }, - ); - - // ── get_meta ─────────────────────────────────────────────────────────────── - - server.registerTool( - 'get_meta', - { - description: - 'Get GitHub API metadata: IP address ranges for GitHub services ' + - '(hooks, web, api, git, packages, pages, importer, actions, dependabot, copilot), ' + - 'SSH key fingerprints, SSH public keys used to sign GitHub commits, ' + - 'and whether GitHub password authentication is enabled. ' + - 'Useful for firewall allowlisting, SSH host verification, and infrastructure automation. ' + - 'Returns an api-overview object. Safe to call without authentication.', - inputSchema: z.object({}), - }, - async () => { - try { - const response = await octokit.rest.meta.get(); - return toToolResult(response.data); - } catch (error) { - return toToolError(error); - } - }, - ); - - // ── list_emojis ──────────────────────────────────────────────────────────── - - server.registerTool( - 'list_emojis', - { - description: - 'List all emoji names available to use on GitHub, with their corresponding image URLs. ' + - 'Returns a flat JSON object mapping each emoji name (e.g. "smile", "+1", "octocat") ' + - 'to its CDN image URL on github.githubassets.com. ' + - 'Useful for populating emoji pickers, validating emoji names, or fetching emoji images.', - inputSchema: z.object({}), - }, - async () => { - try { - const response = await octokit.rest.emojis.get(); - return toToolResult(response.data); - } catch (error) { - return toToolError(error); - } - }, - ); - - // ── render_markdown ──────────────────────────────────────────────────────── - - server.registerTool( - 'render_markdown', - { - description: - 'Render a Markdown string to HTML using GitHub\'s Markdown renderer. ' + - 'Returns the rendered HTML as a plain text string (not JSON-encoded). ' + - 'Use mode "markdown" (default) for standard Markdown. ' + - 'Use mode "gfm" (GitHub Flavored Markdown) to enable cross-references ' + - 'such as #42 linking to issues and @mentions — requires the context parameter ' + - 'to specify the repository (e.g. "owner/repo") for resolving those references. ' + - 'This endpoint is stateless: it renders and returns HTML without modifying any data.', - inputSchema: z.object({ - text: z.string().describe('The Markdown text to render to HTML.'), - mode: z - .enum(['markdown', 'gfm']) - .optional() - .describe( - 'Rendering mode. "markdown" (default) renders standard Markdown. ' + - '"gfm" renders GitHub Flavored Markdown with cross-reference support ' + - '(requires the context parameter to resolve issue and PR references).', - ), - context: z - .string() - .optional() - .describe( - 'Repository context for resolving cross-references in gfm mode, ' + - 'in "owner/repo" format (e.g. "octo-org/octo-repo"). ' + - 'Ignored when mode is "markdown".', - ), - }), - }, - async ({ text, mode, context }) => { - try { - const response = await octokit.rest.markdown.render({ text, mode, context }); - // response.data is a raw HTML string (Content-Type: text/html), NOT a JSON value. - // toToolResult would call JSON.stringify on it, producing a double-encoded string. - // Build the MCP content array directly to preserve the raw HTML text. - return { content: [{ type: 'text' as const, text: response.data as string }] }; - } catch (error) { - return toToolError(error); - } - }, - ); -} -``` - -- [x] **Step 4: Run tests to verify they pass** - -Run: `npm test -- misc` -Expected: PASS (9 tests in `misc.test.ts`). - -- [x] **Step 5: Run the full test suite to confirm no regression in other toolsets** - -Run: `npm test` -Expected: PASS. Total test count is the previous suite total plus the 9 new tests in `misc.test.ts`. No pre-existing test file is modified; `common.ts` is unchanged so `common.test.ts` still passes verbatim. - -- [x] **Step 6: Commit** - -```bash -git add src/toolsets/misc.ts test/unit/toolsets/misc.test.ts -git commit -m "feat: add misc toolset (4 utility tools: rate limit, meta, emojis, markdown)" -``` - ---- - -## Task 2: Wire `registerMiscTools` into `server.ts` - -**Files:** -- Modify: `src/server.ts` -- Modify: `README.md` (Toolsets section) - -**Interfaces:** -- Consumes: `registerMiscTools(server, octokit, permission)` from Task 1. -- Produces: nothing new — this is the final integration point. - -- [x] **Step 1: Modify `src/server.ts`** - -Current content (after `packages` was wired): - -```typescript -import { McpServer } from '@modelcontextprotocol/server'; -import type { Octokit } from 'octokit'; -import { registerActivityTools } from './toolsets/activity.js'; -import { registerGistsTools } from './toolsets/gists.js'; -import { registerIssuesTools } from './toolsets/issues.js'; -import { registerPackagesTools } from './toolsets/packages.js'; -import { registerPullRequestsTools } from './toolsets/pull_requests.js'; -import { registerReposTools } from './toolsets/repos.js'; -import { registerSearchTools } from './toolsets/search.js'; -import { registerUsersTools } from './toolsets/users.js'; - -const SERVER_NAME = 'github-mcp-server-js'; -const SERVER_VERSION = '0.1.0'; - -export function buildServer( - octokit: Octokit, - permission: 'read-only' | 'read-write', -): McpServer { - const server = new McpServer({ name: SERVER_NAME, version: SERVER_VERSION }); - - registerReposTools(server, octokit, permission); - registerIssuesTools(server, octokit, permission); - registerPullRequestsTools(server, octokit, permission); - registerSearchTools(server, octokit, permission); - registerUsersTools(server, octokit, permission); - registerGistsTools(server, octokit, permission); - registerActivityTools(server, octokit, permission); - registerPackagesTools(server, octokit, permission); - - return server; -} -``` - -Replace with (new import sorted alphabetically, new call appended): - -```typescript -import { McpServer } from '@modelcontextprotocol/server'; -import type { Octokit } from 'octokit'; -import { registerActivityTools } from './toolsets/activity.js'; -import { registerGistsTools } from './toolsets/gists.js'; -import { registerIssuesTools } from './toolsets/issues.js'; -import { registerMiscTools } from './toolsets/misc.js'; -import { registerPackagesTools } from './toolsets/packages.js'; -import { registerPullRequestsTools } from './toolsets/pull_requests.js'; -import { registerReposTools } from './toolsets/repos.js'; -import { registerSearchTools } from './toolsets/search.js'; -import { registerUsersTools } from './toolsets/users.js'; - -const SERVER_NAME = 'github-mcp-server-js'; -const SERVER_VERSION = '0.1.0'; - -export function buildServer( - octokit: Octokit, - permission: 'read-only' | 'read-write', -): McpServer { - const server = new McpServer({ name: SERVER_NAME, version: SERVER_VERSION }); - - registerReposTools(server, octokit, permission); - registerIssuesTools(server, octokit, permission); - registerPullRequestsTools(server, octokit, permission); - registerSearchTools(server, octokit, permission); - registerUsersTools(server, octokit, permission); - registerGistsTools(server, octokit, permission); - registerActivityTools(server, octokit, permission); - registerPackagesTools(server, octokit, permission); - registerMiscTools(server, octokit, permission); - - return server; -} -``` - -- [x] **Step 2: Run the full test suite** - -Run: `npm test` -Expected: PASS, same test count as after Task 1 (no test exercises `server.ts` directly — `buildServer` is a thin, non-branching composition function verified by the manual smoke test below). - -- [x] **Step 3: Typecheck, lint, build** - -Run: `npm run typecheck && npm run lint && npm run build` -Expected: all PASS. The build step confirms `dist/cli.js`'s bundled dependency graph now transitively includes `misc.ts`. - -- [x] **Step 4: Manual end-to-end smoke test** - -Run: -```bash -GITHUB_TOKEN=fake-token node dist/cli.js --transport=http --port=3996 & -sleep 1 -curl -s -D /tmp/mcp-init-headers.txt -X POST http://localhost:3996/mcp \ - -H 'Content-Type: application/json' \ - -H 'Accept: application/json, text/event-stream' \ - -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"smoke-test","version":"0.0.0"}}}' -SESSION=$(grep -i mcp-session-id /tmp/mcp-init-headers.txt | awk '{print $2}' | tr -d '\r') -curl -s -X POST http://localhost:3996/mcp \ - -H 'Content-Type: application/json' \ - -H 'Accept: application/json, text/event-stream' \ - -H "Mcp-Session-Id: $SESSION" \ - -d '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' -kill %1 -``` - -Expected: the `initialize` response contains `"serverInfo":{"name":"github-mcp-server-js"...}`. The `tools/list` response includes all 4 misc tools (`get_rate_limit`, `get_meta`, `list_emojis`, `render_markdown`) alongside all 54 previously-shipped tools — proving all toolsets are live in the same server with no duplicate-registration crash. - -- [x] **Step 5: Update the README's Toolsets section** - -Modify `README.md`'s "Currently implemented" list to add: - -```markdown -- `misc` — utility tools: API rate limit status, GitHub server metadata, emoji list, Markdown-to-HTML rendering -``` - -Slot it after the existing `packages` bullet, keeping the toolsets listed in the order they were shipped. - -- [x] **Step 6: If cspell flags `gfm`, add it to `cspell.json`** - -If the pre-commit hook rejects the word `gfm` (which appears in the `render_markdown` description), open `cspell.json` and add `"gfm"` to the `words` array: - -```json -{ - "words": [ - ..., - "gfm" - ] -} -``` - -If cspell does not flag it, skip this step. - -- [x] **Step 7: Commit** - -```bash -git add src/server.ts README.md -git commit -m "feat: wire misc toolset into buildServer" -``` - ---- - -## Deliberate scope decisions - -The following octokit methods reachable from the namespaces listed in the design row (`meta`, `emojis`, `markdown`, `rateLimit`, `billing`, `campaigns`, `credentials`, `hostedCompute`, `privateRegistries`, `migrations`, `enterpriseTeam*`, `codesOfConduct`) were introspected and are **intentionally excluded**. Each exclusion is justified below. - -### `meta.*` — excluded variants - -1. **`meta.getOctocat` (GET `/octocat`) — novelty, out of scope.** Returns an ASCII art Octocat as a text response. Entertaining but provides no operational value to an LLM-facing tool. Not useful for any real workflow. - -2. **`meta.getZen` (GET `/zen`) — novelty, out of scope.** Returns a random GitHub "Zen" aphorism as plain text (e.g. "Approachable is better than simple"). Same rationale as `getOctocat` — zero operational value. - -3. **`meta.root` (GET `/`) — redundant, out of scope.** Returns the same API root document as `meta.get` but without the full IP-range and SSH-key detail. `get_meta` already provides a superset of this information. - -4. **`meta.getAllVersions` (GET `/versions`) — niche, out of scope.** Returns a list of supported GitHub API versions (e.g. `["2022-11-28"]`). Useful only for tooling that needs to discover supported API versions dynamically, which is a developer-tooling concern not relevant to LLM-facing MCP tools. - -### `markdown.renderRaw` — excluded - -5. **`markdown.renderRaw` (POST `/markdown/raw`) — redundant, excluded.** Accepts a raw `text/plain` body and renders it to HTML without GitHub Flavored Markdown features. `render_markdown` (wrapping `markdown.render`) already covers raw Markdown rendering via `mode: "markdown"` and adds the GFM capability via `mode: "gfm"`. Exposing `renderRaw` as a second tool would duplicate `render_markdown`'s default behavior with no added value; callers would need to know which of two near-identical tools to pick. - -### `codesOfConduct.*` — out of scope - -6. **`codesOfConduct.getAllCodesOfConduct` (GET `/codes_of_conduct`) — out of scope.** Returns an array of GitHub's built-in codes of conduct (Contributor Covenant, Citizen Code of Conduct). Niche reference data used when scaffolding a new repo with a code of conduct file. Not useful as a standalone LLM tool — the relevant workflow (adding a CoC to a repo) is handled by `repos` toolset file-write tools, not by listing the available templates. - -7. **`codesOfConduct.getConductCode` (GET `/codes_of_conduct/{key}`) — out of scope.** Returns the full text of a specific code of conduct by key (e.g. `contributor_covenant`). Same rationale as `getAllCodesOfConduct` — niche scaffolding data with no standalone utility in the ~4 tool budget. - -### `billing`, `campaigns`, `credentials`, `hostedCompute`, `privateRegistries`, `migrations`, `enterpriseTeam*` — not present in `octokit.rest` - -8. **`billing`, `campaigns`, `credentials`, `hostedCompute`, `privateRegistries`, `migrations`, `enterpriseTeam*` — absent from `octokit.rest`, excluded.** These namespaces are listed in the design row's octokit column as aspirational/candidate namespaces, but none of them appear as top-level keys on `octokit.rest` in the installed `octokit@^5.0.5`. They correspond to GitHub Enterprise and GitHub.com features that may be: - - Not yet promoted to the stable REST API covered by `@octokit/plugin-rest-endpoint-methods` - - Available only through the GitHub Enterprise Server API (not the standard `@octokit/openapi-types`) - - Covered under different namespace names in the current plugin version - - Since none of these namespaces exist in the installed octokit build, they cannot be wrapped — and attempting to access them would produce a TypeScript type error. They are excluded from v1 scope. If future octokit versions add these namespaces, follow-up plans can add the relevant tools without disturbing the 4 existing misc tools. - ---- - -## Self-Review Notes - -- **Spec coverage:** `misc` toolset (Toolset Inventory row: multiple namespaces, example tools `get_rate_limit, render_markdown`, est. count ~4). Both named examples are implemented exactly. The count exactly matches the ~4 estimate. The two additional tools (`get_meta`, `list_emojis`) are the most universally useful tools from the remaining namespaces — `get_meta` is operationally important (IP ranges, SSH keys) and `list_emojis` is a natural companion endpoint with broad applicability. - -- **`render_markdown` is read-only-safe:** Although it uses HTTP POST, `markdown/render` is a pure server-side text transformation — it renders Markdown to HTML and returns the result without persisting anything, creating any resource, or modifying any GitHub state. GitHub's own documentation for this endpoint does not list it under write or mutating operations. All 4 tools are therefore correctly registered unconditionally (no `permission === 'read-write'` guard), and `_permission` is an unused parameter by design. - -- **`render_markdown` HTML response handling:** `toToolResult` in `common.ts` calls `JSON.stringify(data)`. For a string `data`, this produces `'"

Hello

"'` — a JSON-encoded string, not the raw HTML text. The test explicitly asserts `text === htmlResponse` (the raw HTML) and `text !== JSON.stringify(htmlResponse)` (the double-encoded form) to pin this behavior. The fix is a single-line inline result construction in the handler — no shared helper is warranted since only one tool in the entire codebase has a `text/html` response body. - -- **`get_rate_limit` takes empty input schema:** Same as `get_authenticated_user` in the users toolset — `z.object({})` is the correct Zod expression for a parameterless tool. The test exercises `callTool` with `arguments: {}` to confirm the empty schema round-trips cleanly. - -- **`get_meta` takes empty input schema:** Same pattern. No parameters required by the endpoint. - -- **`list_emojis` takes empty input schema:** Same pattern. No parameters required by the endpoint. - -- **304 responses surface as tool errors:** Octokit throws a `RequestError("Not modified", 304, ...)` for 304 responses rather than returning a successful response object (confirmed in `fetch-wrapper.js` lines 90-96). The test for `get_meta` and `list_emojis` both exercise this path, asserting `isError: true` and `text.toContain('Not modified')`. This is consistent with how every other toolset handles non-2xx responses. - -- **`nock` body matching for `render_markdown`:** The test for the default render call uses `.post('/markdown', { text: '**world**' })` — nock matches the JSON body exactly. The GFM-mode test uses `.post('/markdown', { text: 'See #42', mode: 'gfm', context: 'octo-org/octo-repo' })`. `scope.isDone()` assertion proves that the optional `mode` and `context` parameters are forwarded on the wire, catching any parameter-name typo that would silently drop them. - -- **Collision check performed:** All 4 tool names (`get_rate_limit`, `get_meta`, `list_emojis`, `render_markdown`) were verified against the 54 existing tool names (counted via `grep -rh "registerTool("` across all 8 shipped toolset files): zero collisions. - -- **Lessons applied from prior plans:** - - **`issues` I1 (strict permission-gating equality):** Task 1's read-only test uses `.toEqual([...exact 4 names...])`, not `arrayContaining`. Run against both `read-only` and `read-write` clients. - - **`issues` I3 (wire-level filter passthrough):** `render_markdown`'s optional-parameter test uses `nock(...).post(...)` with full body match plus `scope.isDone()`. - - **`search` M (all-read-only `_permission` naming):** Applied — `_permission` inside the function body. - - **`packages` M (tool-name collision check):** Performed explicitly — see Global Constraints. - -- **No placeholders:** all octokit method names (`rateLimit.get`, `meta.get`, `emojis.get`, `markdown.render`), HTTP verbs, URL paths, and parameter shapes verified directly against the installed `@octokit/plugin-rest-endpoint-methods` endpoint table (via `octokit.rest[ns][name].endpoint.DEFAULTS`) and `@octokit/openapi-types/types.d.ts` operation definitions — not memorized or guessed. Every intentionally excluded method is documented in the Deliberate scope decisions section. - -- **Task right-sizing:** Task 1 bundles all 4 tools + tests into one reviewer gate, matching the `search`, `users`, `gists`, `activity`, and `packages` plans. Task 2 is a separate gate for the same reason as all prior plans: wiring is where duplicate-registration crashes and stale-README rot surface. diff --git a/docs/superpowers/plans/2026-08-05-github-mcp-server-orgs_teams.md b/docs/superpowers/plans/2026-08-05-github-mcp-server-orgs_teams.md deleted file mode 100644 index db6b5e4..0000000 --- a/docs/superpowers/plans/2026-08-05-github-mcp-server-orgs_teams.md +++ /dev/null @@ -1,625 +0,0 @@ -# github-mcp-server-js — `orgs_teams` Toolset Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax for tracking. - -**Goal:** Add the `orgs_teams` toolset (8 tools: 6 read for org/team/member introspection + 2 write for team-membership management) to `github-mcp-server-js`, following the exact structural, permission-gating, and response/error pattern established by the eleven shipped toolsets. - -**Architecture:** Same pattern as `issues`/`gists`/`activity`: one `registerOrgsTeamsTools(server, octokit, permission)` function registers each tool; every handler wraps its octokit call in try/catch, returning raw JSON via `toToolResult`/`toToolError`; write tools are registered only when `permission === 'read-write'`; `server.ts` gains one more registration call. Reads use `octokit.rest.orgs.*`, `octokit.rest.teams.*`, and one `octokit.rest.repos.listForOrg` (cross-namespace — the `list_org_repos` tool is conceptually organization-scoped even though the octokit method lives under `repos`; this is the same pattern the design's example row implies). - -**Tech Stack:** Same as prior plans — TypeScript, `@modelcontextprotocol/server@^2.0.0`, `octokit@^5.0.5`, `zod@^4.2.0`, `vitest`, `nock`. No new dependencies. - -## Global Constraints - -- Tool responses return the raw octokit response body as JSON, unmodified. No field trimming. -- Errors propagate unmodified via `toToolError`. -- List tools use `paginationSchema` from `common.ts` (page default 1, per_page default 30, max 100). -- `GITHUB_PERMISSION=read-only` prevents write-tool handlers from being registered. -- Every tool's `org`/`team_slug`/`username` parameters use inline Zod fields with the exact `.describe()` text established below (no new shared schema added to `common.ts` — these fields are used only by this one toolset). -- Verified tool-name collision check against the 64 existing tool names on `main` (8 repos + 12 issues + 10 pull_requests + 5 search + 5 users + 5 gists + 5 activity + 4 packages + 4 misc + 3 apps + 3 copilot): **zero collisions** with the 8 tool names chosen below (`get_org`, `list_org_members`, `list_org_repos`, `list_teams`, `get_team_by_name`, `list_team_members`, `add_or_update_team_membership`, `remove_team_membership`). Verified before finalizing names. -- `remove_team_membership` returns `204 No Content` — synthesize `{ removed: true }` in the handler (matches lock/unlock/unstar precedent). -- TypeScript only, no new runtime dependencies. - ---- - -## File Structure - -``` -github-mcp-server-js/ - src/ - toolsets/ - orgs_teams.ts # NEW: registerOrgsTeamsTools(server, octokit, permission) - server.ts # MODIFIED: calls registerOrgsTeamsTools - test/ - unit/ - toolsets/ - orgs_teams.test.ts # NEW: mirrors issues.test.ts -``` - ---- - -## Reference: verified octokit shapes - -Confirmed against `octokit.rest.*[method].endpoint.DEFAULTS` and `@octokit/openapi-types/types.d.ts` lines 89095, 95309, 97643, 98755, 98852, 99562, 99638, 99687. - -| Tool | octokit method | HTTP | Path/query params | -|---|---|---|---| -| `get_org` | `orgs.get` | GET `/orgs/{org}` | path: `org` | -| `list_org_members` | `orgs.listMembers` | GET `/orgs/{org}/members` | path: `org`; query: `filter` (`2fa_disabled`\|`2fa_insecure`\|`all`), `role` (`all`\|`admin`\|`member`), `page`, `per_page` | -| `list_org_repos` | `repos.listForOrg` | GET `/orgs/{org}/repos` | path: `org`; query: `type` (`all`\|`public`\|`private`\|`forks`\|`sources`\|`member`), `sort` (`created`\|`updated`\|`pushed`\|`full_name`), `direction` (`asc`\|`desc`), `page`, `per_page` | -| `list_teams` | `teams.list` | GET `/orgs/{org}/teams` | path: `org`; query: `page`, `per_page` | -| `get_team_by_name` | `teams.getByName` | GET `/orgs/{org}/teams/{team_slug}` | path: `org`, `team_slug` | -| `list_team_members` | `teams.listMembersInOrg` | GET `/orgs/{org}/teams/{team_slug}/members` | path: `org`, `team_slug`; query: `role` (`member`\|`maintainer`\|`all`), `page`, `per_page` | -| `add_or_update_team_membership` | `teams.addOrUpdateMembershipForUserInOrg` | PUT `/orgs/{org}/teams/{team_slug}/memberships/{username}` | path: `org`, `team_slug`, `username`; body: `role` (`member`\|`maintainer`) | -| `remove_team_membership` | `teams.removeMembershipForUserInOrg` | DELETE `/orgs/{org}/teams/{team_slug}/memberships/{username}` | path: `org`, `team_slug`, `username` | - -Response bodies: -- `get_org` → `organization-full` -- `list_org_members` → `simple-user[]` -- `list_org_repos` → `minimal-repository[]` -- `list_teams`, `list_team_members` → `team[]` / `simple-user[]` -- `get_team_by_name` → `team-full` -- `add_or_update_team_membership` → `team-membership` -- `remove_team_membership` → `204 No Content` → synthesize `{ removed: true }` - -**Deliberate scope decisions:** - -1. **Excluded org-admin surface:** `orgs.blockUser`/`unblockUser`, `orgs.createInvitation`/`cancelInvitation`, `orgs.createWebhook`/`updateWebhook`/`deleteWebhook`, `orgs.update`/`delete`, `orgs.setMembershipForUser`/`removeMembershipForUser`, all custom-properties, all org-roles, all attestations, all PAT-grant endpoints. Reason: admin-tier operations that most PAT users can't perform (403) and are dangerous to surface in an LLM-driven tool. Team creation/deletion (`teams.create`/`teams.deleteInOrg`) is also excluded for the same reason — creating/removing a team is a rare enough operation that human-driven UX is preferred. - -2. **Excluded team-discussion surface:** all `teams.*Discussion*` methods (create/get/update/delete discussions and discussion comments). Reason: team discussions are a legacy communication surface; org communication has largely moved to Slack/Teams integrations and organization discussions on repositories. Not enough utility to justify the tool count. - -3. **Excluded team-repo permission management:** `teams.addOrUpdateRepoPermissionsInOrg`, `teams.removeRepoInOrg`, `teams.listReposInOrg`, `teams.checkPermissionsForRepoInOrg`. Reason: the team↔repo permission surface is complex enough that it warrants a dedicated follow-up plan if needed; leaving it out keeps the toolset at the design's ~8 estimate. - -4. **`list_org_repos` uses `repos.listForOrg` (cross-namespace):** the design's example row explicitly names `list_org_repos` as an `orgs_teams` tool even though the underlying octokit method is `repos.listForOrg`. This crossing is intentional — the tool is semantically organization-scoped, and grouping it alongside `get_org` / `list_org_members` matches how a caller thinks about organization data. The alternative (putting it in `repos`) would fragment the org-inspection experience. - -5. **Excluded child-teams and pending-invitations for teams:** `teams.listChildInOrg`, `teams.listPendingInvitationsInOrg`, `teams.getMembershipForUserInOrg`. Reason: `list_team_members` covers the common case; membership state for a single user can be inferred from the list. Nested team hierarchies are a rare need and out of scope for v1. - ---- - -## Task 1: Implement the `orgs_teams` toolset — read tools (6 tools) - -**Files:** -- Create: `src/toolsets/orgs_teams.ts` -- Test: `test/unit/toolsets/orgs_teams.test.ts` - -**Interfaces:** -- Consumes: `paginationSchema`, `toToolResult`, `toToolError` from `./common.js`. -- Produces (for Task 2 to extend and Task 3 / `server.ts` to consume): - - `registerOrgsTeamsTools(server: McpServer, octokit: Octokit, permission: 'read-only' | 'read-write'): void` - -- [x] **Step 1: Write the failing tests** - -Create `test/unit/toolsets/orgs_teams.test.ts`: - -```typescript -import nock from 'nock'; -import { afterEach, describe, expect, it } from 'vitest'; -import { registerOrgsTeamsTools } from '../../../src/toolsets/orgs_teams.js'; -import { connectedClient } from './test-helpers.js'; - -describe('registerOrgsTeamsTools', () => { - afterEach(() => { - nock.cleanAll(); - }); - - it('registers get_org and returns the raw GitHub response as JSON', async () => { - nock('https://api.github.com') - .get('/orgs/acme') - .reply(200, { id: 1, login: 'acme', name: 'Acme Corp' }); - - const client = await connectedClient(registerOrgsTeamsTools, 'read-write'); - const result = await client.callTool({ - name: 'get_org', - arguments: { org: 'acme' }, - }); - - expect(result.isError).toBeFalsy(); - const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ''; - expect(JSON.parse(text)).toMatchObject({ login: 'acme', name: 'Acme Corp' }); - }); - - it('propagates a 404 from get_org as an MCP tool error', async () => { - nock('https://api.github.com') - .get('/orgs/no-such-org') - .reply(404, { message: 'Not Found', documentation_url: 'https://docs.github.com/rest' }); - - const client = await connectedClient(registerOrgsTeamsTools, 'read-write'); - const result = await client.callTool({ - name: 'get_org', - arguments: { org: 'no-such-org' }, - }); - - expect(result.isError).toBe(true); - const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ''; - expect(text).toContain('Not Found'); - }); - - it('forwards filter/role/pagination on list_org_members to the wire', async () => { - const scope = nock('https://api.github.com') - .get('/orgs/acme/members') - .query({ filter: 'all', role: 'admin', page: '2', per_page: '50' }) - .reply(200, [{ login: 'alice' }]); - - const client = await connectedClient(registerOrgsTeamsTools, 'read-write'); - const result = await client.callTool({ - name: 'list_org_members', - arguments: { org: 'acme', filter: 'all', role: 'admin', page: 2, per_page: 50 }, - }); - - expect(result.isError).toBeFalsy(); - expect(scope.isDone()).toBe(true); - }); - - it('forwards type/sort/direction on list_org_repos to the wire', async () => { - const scope = nock('https://api.github.com') - .get('/orgs/acme/repos') - .query({ - type: 'public', - sort: 'updated', - direction: 'desc', - page: '1', - per_page: '30', - }) - .reply(200, [{ id: 1, full_name: 'acme/foo' }]); - - const client = await connectedClient(registerOrgsTeamsTools, 'read-write'); - const result = await client.callTool({ - name: 'list_org_repos', - arguments: { org: 'acme', type: 'public', sort: 'updated', direction: 'desc' }, - }); - - expect(result.isError).toBeFalsy(); - expect(scope.isDone()).toBe(true); - }); - - it('registers list_teams and returns the raw response as JSON', async () => { - nock('https://api.github.com') - .get('/orgs/acme/teams') - .query({ page: '1', per_page: '30' }) - .reply(200, [{ id: 1, slug: 'engineering', name: 'Engineering' }]); - - const client = await connectedClient(registerOrgsTeamsTools, 'read-write'); - const result = await client.callTool({ - name: 'list_teams', - arguments: { org: 'acme' }, - }); - - expect(result.isError).toBeFalsy(); - const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ''; - expect(JSON.parse(text)).toEqual([{ id: 1, slug: 'engineering', name: 'Engineering' }]); - }); - - it('registers get_team_by_name and returns the raw response as JSON', async () => { - nock('https://api.github.com') - .get('/orgs/acme/teams/engineering') - .reply(200, { id: 1, slug: 'engineering', name: 'Engineering', members_count: 5 }); - - const client = await connectedClient(registerOrgsTeamsTools, 'read-write'); - const result = await client.callTool({ - name: 'get_team_by_name', - arguments: { org: 'acme', team_slug: 'engineering' }, - }); - - expect(result.isError).toBeFalsy(); - const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ''; - expect(JSON.parse(text)).toMatchObject({ slug: 'engineering', members_count: 5 }); - }); - - it('forwards role/pagination on list_team_members to the wire', async () => { - const scope = nock('https://api.github.com') - .get('/orgs/acme/teams/engineering/members') - .query({ role: 'maintainer', page: '1', per_page: '30' }) - .reply(200, [{ login: 'alice' }]); - - const client = await connectedClient(registerOrgsTeamsTools, 'read-write'); - const result = await client.callTool({ - name: 'list_team_members', - arguments: { org: 'acme', team_slug: 'engineering', role: 'maintainer' }, - }); - - expect(result.isError).toBeFalsy(); - expect(scope.isDone()).toBe(true); - }); - - it('registers add_or_update_team_membership and returns the raw response as JSON', async () => { - nock('https://api.github.com') - .put('/orgs/acme/teams/engineering/memberships/alice', { role: 'maintainer' }) - .reply(200, { state: 'active', role: 'maintainer' }); - - const client = await connectedClient(registerOrgsTeamsTools, 'read-write'); - const result = await client.callTool({ - name: 'add_or_update_team_membership', - arguments: { org: 'acme', team_slug: 'engineering', username: 'alice', role: 'maintainer' }, - }); - - expect(result.isError).toBeFalsy(); - const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ''; - expect(JSON.parse(text)).toEqual({ state: 'active', role: 'maintainer' }); - }); - - it('registers remove_team_membership and synthesizes { removed: true } on 204', async () => { - nock('https://api.github.com') - .delete('/orgs/acme/teams/engineering/memberships/alice') - .reply(204); - - const client = await connectedClient(registerOrgsTeamsTools, 'read-write'); - const result = await client.callTool({ - name: 'remove_team_membership', - arguments: { org: 'acme', team_slug: 'engineering', username: 'alice' }, - }); - - expect(result.isError).toBeFalsy(); - const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ''; - expect(JSON.parse(text)).toEqual({ removed: true }); - }); - - it('registers exactly the 6 read tools in read-only mode', async () => { - const client = await connectedClient(registerOrgsTeamsTools, 'read-only'); - const { tools } = await client.listTools(); - expect(tools.map((t) => t.name).sort()).toEqual([ - 'get_org', - 'get_team_by_name', - 'list_org_members', - 'list_org_repos', - 'list_team_members', - 'list_teams', - ]); - }); - - it('registers all 8 tools in read-write mode', async () => { - const client = await connectedClient(registerOrgsTeamsTools, 'read-write'); - const { tools } = await client.listTools(); - expect(tools.map((t) => t.name).sort()).toEqual([ - 'add_or_update_team_membership', - 'get_org', - 'get_team_by_name', - 'list_org_members', - 'list_org_repos', - 'list_team_members', - 'list_teams', - 'remove_team_membership', - ]); - }); -}); -``` - -- [x] **Step 2: Run tests to verify they fail** - -Run: `npm test -- orgs_teams` -Expected: FAIL with module-not-found on `src/toolsets/orgs_teams.js`. - -- [x] **Step 3: Create `src/toolsets/orgs_teams.ts`** - -```typescript -import type { McpServer } from '@modelcontextprotocol/server'; -import type { Octokit } from 'octokit'; -import { z } from 'zod'; -import { paginationSchema, toToolResult, toToolError } from './common.js'; - -const orgSchema = { - org: z.string().describe('Organization login (e.g. "acme")'), -}; - -const teamSlugSchema = { - team_slug: z.string().describe('Team slug (URL-friendly name; e.g. "engineering")'), -}; - -const usernameSchema = { - username: z.string().describe('GitHub username (login)'), -}; - -export function registerOrgsTeamsTools( - server: McpServer, - octokit: Octokit, - permission: 'read-only' | 'read-write', -): void { - server.registerTool( - 'get_org', - { - description: 'Get a GitHub organization by login.', - inputSchema: z.object({ ...orgSchema }), - }, - async ({ org }) => { - try { - const response = await octokit.rest.orgs.get({ org }); - return toToolResult(response.data); - } catch (error) { - return toToolError(error); - } - }, - ); - - server.registerTool( - 'list_org_members', - { - description: 'List members of a GitHub organization.', - inputSchema: z.object({ - ...orgSchema, - filter: z - .enum(['2fa_disabled', '2fa_insecure', 'all']) - .optional() - .describe('Filter members (2fa_disabled/2fa_insecure only visible to org owners).'), - role: z.enum(['all', 'admin', 'member']).optional().describe('Filter by role.'), - ...paginationSchema, - }), - }, - async ({ org, filter, role, page, per_page }) => { - try { - const response = await octokit.rest.orgs.listMembers({ - org, - filter, - role, - page, - per_page, - }); - return toToolResult(response.data); - } catch (error) { - return toToolError(error); - } - }, - ); - - server.registerTool( - 'list_org_repos', - { - description: 'List repositories in a GitHub organization.', - inputSchema: z.object({ - ...orgSchema, - type: z - .enum(['all', 'public', 'private', 'forks', 'sources', 'member']) - .optional() - .describe('Type of repositories to list.'), - sort: z - .enum(['created', 'updated', 'pushed', 'full_name']) - .optional() - .describe('Field to sort by.'), - direction: z.enum(['asc', 'desc']).optional().describe('Sort direction.'), - ...paginationSchema, - }), - }, - async ({ org, type, sort, direction, page, per_page }) => { - try { - const response = await octokit.rest.repos.listForOrg({ - org, - type, - sort, - direction, - page, - per_page, - }); - return toToolResult(response.data); - } catch (error) { - return toToolError(error); - } - }, - ); - - server.registerTool( - 'list_teams', - { - description: 'List teams in a GitHub organization.', - inputSchema: z.object({ - ...orgSchema, - ...paginationSchema, - }), - }, - async ({ org, page, per_page }) => { - try { - const response = await octokit.rest.teams.list({ org, page, per_page }); - return toToolResult(response.data); - } catch (error) { - return toToolError(error); - } - }, - ); - - server.registerTool( - 'get_team_by_name', - { - description: 'Get a GitHub team by its slug within an organization.', - inputSchema: z.object({ - ...orgSchema, - ...teamSlugSchema, - }), - }, - async ({ org, team_slug }) => { - try { - const response = await octokit.rest.teams.getByName({ org, team_slug }); - return toToolResult(response.data); - } catch (error) { - return toToolError(error); - } - }, - ); - - server.registerTool( - 'list_team_members', - { - description: 'List the members of a GitHub team.', - inputSchema: z.object({ - ...orgSchema, - ...teamSlugSchema, - role: z.enum(['member', 'maintainer', 'all']).optional().describe('Filter by team role.'), - ...paginationSchema, - }), - }, - async ({ org, team_slug, role, page, per_page }) => { - try { - const response = await octokit.rest.teams.listMembersInOrg({ - org, - team_slug, - role, - page, - per_page, - }); - return toToolResult(response.data); - } catch (error) { - return toToolError(error); - } - }, - ); - - if (permission === 'read-write') { - server.registerTool( - 'add_or_update_team_membership', - { - description: - 'Add a user to a team or update their team role. Requires org-owner or team-maintainer permission.', - inputSchema: z.object({ - ...orgSchema, - ...teamSlugSchema, - ...usernameSchema, - role: z - .enum(['member', 'maintainer']) - .optional() - .describe('Role to grant (defaults to "member").'), - }), - }, - async ({ org, team_slug, username, role }) => { - try { - const response = await octokit.rest.teams.addOrUpdateMembershipForUserInOrg({ - org, - team_slug, - username, - role, - }); - return toToolResult(response.data); - } catch (error) { - return toToolError(error); - } - }, - ); - - server.registerTool( - 'remove_team_membership', - { - description: - 'Remove a user from a team. Does not delete the user, only their team membership. Requires org-owner or team-admin permission.', - inputSchema: z.object({ - ...orgSchema, - ...teamSlugSchema, - ...usernameSchema, - }), - }, - async ({ org, team_slug, username }) => { - try { - await octokit.rest.teams.removeMembershipForUserInOrg({ org, team_slug, username }); - return toToolResult({ removed: true }); - } catch (error) { - return toToolError(error); - } - }, - ); - } -} -``` - -- [x] **Step 4: Run tests to verify they pass** - -Run: `npm test -- orgs_teams` -Expected: PASS (11 tests). - -- [x] **Step 5: Run the full suite** - -Run: `npm test` -Expected: 151 (prior) + 11 = 162 passing. - -- [x] **Step 6: Commit** - -Stage `src/toolsets/orgs_teams.ts`, `test/unit/toolsets/orgs_teams.test.ts`, and `cspell.json` (only if hook flags fixture words like `acme` — `acme` is a very common dictionary word so probably not needed). Then: - -```bash -git commit -m "feat: add orgs_teams toolset (6 read + 2 write tools)" -``` - ---- - -## Task 2: Wire `registerOrgsTeamsTools` into `server.ts` - -**Files:** -- Modify: `src/server.ts` -- Modify: `README.md` (Toolsets section) - -**Interfaces:** -- Consumes: `registerOrgsTeamsTools` from Task 1. - -- [x] **Step 1: Modify `src/server.ts`** - -Insert the import alphabetically (between `registerMiscTools` and `registerPackagesTools`) and add the registration call after `registerCopilotTools` (ship-order — copilot was the previous toolset). Final file: - -```typescript -import { McpServer } from '@modelcontextprotocol/server'; -import type { Octokit } from 'octokit'; -import { registerActivityTools } from './toolsets/activity.js'; -import { registerAppsTools } from './toolsets/apps.js'; -import { registerCopilotTools } from './toolsets/copilot.js'; -import { registerGistsTools } from './toolsets/gists.js'; -import { registerIssuesTools } from './toolsets/issues.js'; -import { registerMiscTools } from './toolsets/misc.js'; -import { registerOrgsTeamsTools } from './toolsets/orgs_teams.js'; -import { registerPackagesTools } from './toolsets/packages.js'; -import { registerPullRequestsTools } from './toolsets/pull_requests.js'; -import { registerReposTools } from './toolsets/repos.js'; -import { registerSearchTools } from './toolsets/search.js'; -import { registerUsersTools } from './toolsets/users.js'; - -const SERVER_NAME = 'github-mcp-server-js'; -const SERVER_VERSION = '0.1.0'; - -export function buildServer( - octokit: Octokit, - permission: 'read-only' | 'read-write', -): McpServer { - const server = new McpServer({ name: SERVER_NAME, version: SERVER_VERSION }); - - registerReposTools(server, octokit, permission); - registerIssuesTools(server, octokit, permission); - registerPullRequestsTools(server, octokit, permission); - registerSearchTools(server, octokit, permission); - registerUsersTools(server, octokit, permission); - registerGistsTools(server, octokit, permission); - registerActivityTools(server, octokit, permission); - registerPackagesTools(server, octokit, permission); - registerMiscTools(server, octokit, permission); - registerAppsTools(server, octokit, permission); - registerCopilotTools(server, octokit, permission); - registerOrgsTeamsTools(server, octokit, permission); - - return server; -} -``` - -(If the existing `server.ts` has a different current shape — because some prior toolset ordering differs — preserve that ordering, insert the `registerOrgsTeamsTools` import alphabetically, and append the registration call at the end.) - -- [x] **Step 2: Run the full test suite** - -Run: `npm test` -Expected: PASS, 162 tests. - -- [x] **Step 3: Typecheck, lint, build** - -Run: `npm run typecheck && npm run lint && npm run build` -Expected: all PASS. - -- [x] **Step 4: Update the README's Toolsets section** - -Add a bullet after the `copilot` bullet in the "Currently implemented" list: - -```markdown -- `orgs_teams` — organization inspection, team listing/lookup, and team-membership management -``` - -- [x] **Step 5: Commit** - -```bash -git add src/server.ts README.md -git commit -m "feat: wire orgs_teams toolset into buildServer" -``` - ---- - -## Self-Review Notes - -- **Spec coverage:** `orgs_teams` toolset row (octokit `orgs, teams`, examples `list_org_repos, get_org, list_teams, list_team_members`, est. count ~8) — all 4 named examples are covered; total tool count is exactly 8. Pagination and permission gating both applied. `list_org_repos` uses `repos.listForOrg` per the design's intent (cross-namespace grouping to keep org-inspection tools together). -- **Type consistency:** `registerOrgsTeamsTools(server, octokit, permission): void` matches every prior `register*Tools` signature. Write tools inside `if (permission === 'read-write')` matches issues/gists/activity/pull_requests. `remove_team_membership` synthesizes `{ removed: true }` on 204 per the lock/unlock/unstar/delete precedent. -- **No placeholders:** every step has complete runnable code. All octokit method names and parameter shapes verified against `@octokit/openapi-types/types.d.ts` lines 89095, 95309, 97643, 98755, 98852, 99562, 99638, 99687. -- **Task right-sizing:** Task 1 bundles all 6 read tools + 2 write tools together — with 8 near-identical shell handlers, splitting into read/write tasks would produce two near-copies with no meaningful review gate between them (unlike issues/gists where each half has substantively different endpoint shapes). Task 2 remains separate because wiring is where duplicate-registration and README-rot surface. diff --git a/docs/superpowers/plans/2026-08-05-github-mcp-server-packages.md b/docs/superpowers/plans/2026-08-05-github-mcp-server-packages.md deleted file mode 100644 index 4612f1c..0000000 --- a/docs/superpowers/plans/2026-08-05-github-mcp-server-packages.md +++ /dev/null @@ -1,706 +0,0 @@ -# github-mcp-server-js — `packages` Toolset Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax for tracking. - -**Goal:** Add the `packages` toolset (4 read-only tools wrapping GitHub's REST Packages endpoints — list packages, get a package, list package versions, get a specific package version, all scoped to the authenticated user) to `github-mcp-server-js`, following the exact structural, permission-gating, and response/error pattern established by the `search`, `users`, `gists`, and `activity` toolsets. - -**Architecture:** Same pattern as prior toolsets: one `registerPackagesTools(server, octokit, permission)` function registers each tool with `server.registerTool(name, {description, inputSchema}, handler)`; every handler wraps its octokit call in try/catch, returning raw JSON via `toToolResult`/`toToolError`; `server.ts` gains one more registration call. All octokit calls use the `packages` namespace (`octokit.rest.packages.*`). Because every packages endpoint selected for this toolset is read-only, the `permission === 'read-write'` branch is unused — the `permission` parameter is accepted (to keep the signature uniform across every `register*Tools` function) but never inspected; it is renamed `_permission` inside the function body to satisfy the `@typescript-eslint/no-unused-vars` rule. - -**Tech Stack:** Same as prior plans — TypeScript, `@modelcontextprotocol/server@^2.0.0`, `octokit@^5.0.5`, `zod@^4.2.0`, `vitest`, `nock`. No new dependencies. - -## Global Constraints - -- Tool responses return the raw octokit response body as JSON, unmodified. No field trimming, no text summarization. `list_packages_for_authenticated_user` returns a `package[]` array; `get_package_for_authenticated_user` returns a `package` object; `list_package_versions_for_authenticated_user` returns a `package-version[]` array; `get_package_version_for_authenticated_user` returns a `package-version` object. These exact shapes are preserved; callers pull fields themselves. -- Errors propagate unmodified: catch the octokit `RequestError`, surface `error.message` (which already includes the GitHub `message` field) in an MCP tool error result. No normalization layer. -- List tools take explicit `page` (default `1`) and `per_page` (default `30`, max `100`) parameters. No auto-pagination. This matches every other toolset's pagination model. -- `GITHUB_PERMISSION=read-only` gating is a no-op for this toolset because every packages tool is read-only; the read-only test in Task 1 still verifies the exact set of 4 tools is registered using `.toEqual([...exact set...])` rather than `arrayContaining`, following the lessons from prior plans. -- This toolset is deliberately scoped to **authenticated-user packages** only. The GitHub Packages API exposes three ownership levels: `user` (`/user/packages/*`), `org` (`/orgs/{org}/packages/*`), and `user-by-username` (`/users/{username}/packages/*`). Only the `/user/packages/*` family is included here (see Deliberate scope decisions). -- `package_type` is a **required** parameter on all four tools: the GitHub REST API requires it on the query string for `list_packages_for_authenticated_user` (per the OpenAPI spec `packages/list-packages-for-authenticated-user`, which marks `package_type` as a required query parameter) and in the path for `get_package_for_authenticated_user`, `list_package_versions_for_authenticated_user`, and `get_package_version_for_authenticated_user`. -- Tool names use the `_` separator convention and the `list_packages_` / `get_package_` prefix pattern. See collision check below. -- Verified tool-name collision check against the 50 existing tool names on `main` (8 from `repos.ts`, 12 from `issues.ts`, 10 from `pull_requests.ts`, 5 from `search.ts`, 5 from `users.ts`, 5 from `gists.ts`, 5 from `activity.ts`): **zero collisions** with the 4 tool names chosen below (`list_packages_for_authenticated_user`, `get_package_for_authenticated_user`, `list_package_versions_for_authenticated_user`, `get_package_version_for_authenticated_user`). None of these appear anywhere in the current tool name set. -- No modification to `common.ts`. -- TypeScript only, no new runtime dependencies. - ---- - -## Octokit Surprise: Duplicate Aliases in `packages` Namespace - -When enumerating `octokit.rest.packages.*` endpoint DEFAULTS, the namespace exposes **aliased method pairs** for version-listing: - -- `getAllPackageVersionsForAPackageOwnedByTheAuthenticatedUser` and `getAllPackageVersionsForPackageOwnedByAuthenticatedUser` both resolve to `GET /user/packages/{package_type}/{package_name}/versions` — same URL, different JavaScript function objects (`===` returns false), same DEFAULTS. -- Same pattern appears for org and user-by-username variants (`getAllPackageVersionsForAPackageOwnedByAnOrg` / `getAllPackageVersionsForPackageOwnedByOrg`, etc.). - -The **canonical name** used in this plan is `getAllPackageVersionsForPackageOwnedByAuthenticatedUser` (the shorter form, matching the naming pattern of the non-alias methods in the namespace). Both work identically at runtime; the shorter name is chosen to match the naming style of `getPackageForAuthenticatedUser` and `getPackageVersionForAuthenticatedUser`. - -The `listPackagesForAuthenticatedUser` endpoint's DEFAULTS shows `url: /user/packages` with no required path parameters — the `package_type` filter is a **query** parameter (not a path parameter), confirmed via the OpenAPI spec at `packages/list-packages-for-authenticated-user`: `query.package_type` is required, `query.visibility` is optional. - ---- - -## File Structure - -``` -github-mcp-server-js/ - src/ - toolsets/ - packages.ts # NEW: registerPackagesTools(server, octokit, permission) - server.ts # MODIFIED: calls registerPackagesTools - test/ - unit/ - toolsets/ - packages.test.ts # NEW: mirrors users.test.ts's structure -``` - -`common.ts` is NOT modified — no new shared schema fragment is warranted. `README.md`'s Toolsets section is updated in Task 2 (Step 5). - ---- - -## Reference: verified octokit `packages` namespace shapes - -Confirmed directly against the installed `@octokit/plugin-rest-endpoint-methods` generated endpoint table (`octokit.rest.packages[name].endpoint.DEFAULTS`) and the `@octokit/openapi-types` operation definitions at `node_modules/@octokit/openapi-types/types.d.ts`. All 4 selected endpoints are `GET`; none require a request body. - -| Tool | octokit method | HTTP path | Path params | Query params | Response body | -|---|---|---|---|---|---| -| `list_packages_for_authenticated_user` | `packages.listPackagesForAuthenticatedUser` | GET `/user/packages` | — | `package_type` (required), `visibility?`, `page?`, `per_page?` | `package[]` | -| `get_package_for_authenticated_user` | `packages.getPackageForAuthenticatedUser` | GET `/user/packages/{package_type}/{package_name}` | `package_type`, `package_name` | — | `package` | -| `list_package_versions_for_authenticated_user` | `packages.getAllPackageVersionsForPackageOwnedByAuthenticatedUser` | GET `/user/packages/{package_type}/{package_name}/versions` | `package_type`, `package_name` | `state?`, `page?`, `per_page?` | `package-version[]` | -| `get_package_version_for_authenticated_user` | `packages.getPackageVersionForAuthenticatedUser` | GET `/user/packages/{package_type}/{package_name}/versions/{package_version_id}` | `package_type`, `package_name`, `package_version_id` | — | `package-version` | - -**`package_type` enum** (from `components["parameters"]["package-type"]` in `@octokit/openapi-types`): -`"npm" | "maven" | "rubygems" | "docker" | "nuget" | "container"` - -**`visibility` enum** (from `components["parameters"]["package-visibility"]`): -`"public" | "private" | "internal"` - -**`state` enum** (from `packages/get-all-package-versions-for-package-owned-by-authenticated-user` query): -`"active" | "deleted"` - -**`package_version_id`**: `number` (integer) — from `components["parameters"]["package-version-id"]`. - -Response body notes (raw passthrough, all `200 OK`): -- `list_packages_for_authenticated_user` → `[{ id, name, package_type, owner, version_count, visibility, created_at, updated_at, repository, url, html_url }, ...]` (package array). Requires `read:packages` token scope. -- `get_package_for_authenticated_user` → single `package` object of same shape. -- `list_package_versions_for_authenticated_user` → `[{ id, name, url, package_html_url, created_at, updated_at, html_url, metadata: { package_type, container?: { tags }, npm?: {...} } }, ...]` (package-version array). -- `get_package_version_for_authenticated_user` → single `package-version` object of same shape. -- Non-2xx errors surface as `RequestError` and are handled by the same catch/`toToolError` path as every other tool. - -**cspell.json note:** No new words need to be added. All words used in tool names, descriptions, and test fixture strings (`nuget`, `rubygems`, `maven`, `ghcr`) are either: already in `cspell.json` words list, recognized English words, or standard technical abbreviations that cspell recognizes. If cspell flags `nuget`, `rubygems`, `ghcr`, or `maven` during the pre-commit hook, add them to the `words` array in `cspell.json` at that time. - ---- - -## Task 1: Implement the `packages` toolset (4 read-only tools) and its tests - -**Files:** -- Create: `src/toolsets/packages.ts` -- Test: `test/unit/toolsets/packages.test.ts` - -**Interfaces:** -- Consumes: `paginationSchema`, `toToolResult`, `toToolError` from `./common.js` (already exist — no modification needed). -- Produces (for Task 2 / `server.ts` to consume): - - `registerPackagesTools(server: McpServer, octokit: Octokit, permission: 'read-only' | 'read-write'): void` — same signature shape as `registerUsersTools`/`registerSearchTools`/`registerGistsTools`/`registerActivityTools`. The `permission` parameter is accepted for signature uniformity but never inspected inside the function (all 4 tools are read-only); the parameter is renamed `_permission` inside the function body to satisfy the `@typescript-eslint/no-unused-vars` rule. - -- [x] **Step 1: Write the failing tests** - -Create `test/unit/toolsets/packages.test.ts`: - -```typescript -import nock from 'nock'; -import { afterEach, describe, expect, it } from 'vitest'; -import { registerPackagesTools } from '../../../src/toolsets/packages.js'; -import { connectedClient } from './test-helpers.js'; - -describe('registerPackagesTools', () => { - afterEach(() => { - nock.cleanAll(); - }); - - it('registers list_packages_for_authenticated_user and returns the raw package array as JSON', async () => { - nock('https://api.github.com') - .get('/user/packages') - .query({ package_type: 'npm', page: '1', per_page: '30' }) - .reply(200, [ - { - id: 1, - name: 'my-package', - package_type: 'npm', - version_count: 3, - visibility: 'private', - url: 'https://api.github.com/user/packages/npm/my-package', - html_url: 'https://github.com/users/monalisa/packages/npm/package/my-package', - }, - ]); - - const client = await connectedClient(registerPackagesTools, 'read-write'); - const result = await client.callTool({ - name: 'list_packages_for_authenticated_user', - arguments: { package_type: 'npm' }, - }); - - expect(result.isError).toBeFalsy(); - const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ''; - const parsed = JSON.parse(text) as Array<{ name: string }>; - expect(parsed).toHaveLength(1); - expect(parsed[0]).toMatchObject({ name: 'my-package', package_type: 'npm' }); - }); - - it('forwards visibility and pagination on list_packages_for_authenticated_user to the wire', async () => { - const scope = nock('https://api.github.com') - .get('/user/packages') - .query({ package_type: 'container', visibility: 'public', page: '2', per_page: '50' }) - .reply(200, []); - - const client = await connectedClient(registerPackagesTools, 'read-write'); - const result = await client.callTool({ - name: 'list_packages_for_authenticated_user', - arguments: { package_type: 'container', visibility: 'public', page: 2, per_page: 50 }, - }); - - expect(result.isError).toBeFalsy(); - expect(scope.isDone()).toBe(true); - }); - - it('propagates a 401 from list_packages_for_authenticated_user as an MCP tool error', async () => { - nock('https://api.github.com') - .get('/user/packages') - .query({ package_type: 'npm', page: '1', per_page: '30' }) - .reply(401, { - message: 'Requires authentication', - documentation_url: 'https://docs.github.com/rest', - }); - - const client = await connectedClient(registerPackagesTools, 'read-write'); - const result = await client.callTool({ - name: 'list_packages_for_authenticated_user', - arguments: { package_type: 'npm' }, - }); - - expect(result.isError).toBe(true); - const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ''; - expect(text).toContain('Requires authentication'); - }); - - it('registers get_package_for_authenticated_user and returns the raw package object as JSON', async () => { - nock('https://api.github.com') - .get('/user/packages/npm/my-package') - .reply(200, { - id: 1, - name: 'my-package', - package_type: 'npm', - version_count: 3, - visibility: 'private', - url: 'https://api.github.com/user/packages/npm/my-package', - html_url: 'https://github.com/users/monalisa/packages/npm/package/my-package', - }); - - const client = await connectedClient(registerPackagesTools, 'read-write'); - const result = await client.callTool({ - name: 'get_package_for_authenticated_user', - arguments: { package_type: 'npm', package_name: 'my-package' }, - }); - - expect(result.isError).toBeFalsy(); - const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ''; - expect(JSON.parse(text)).toMatchObject({ name: 'my-package', version_count: 3 }); - }); - - it('propagates a 404 from get_package_for_authenticated_user as an MCP tool error', async () => { - nock('https://api.github.com') - .get('/user/packages/npm/nonexistent-pkg') - .reply(404, { message: 'Not Found', documentation_url: 'https://docs.github.com/rest' }); - - const client = await connectedClient(registerPackagesTools, 'read-write'); - const result = await client.callTool({ - name: 'get_package_for_authenticated_user', - arguments: { package_type: 'npm', package_name: 'nonexistent-pkg' }, - }); - - expect(result.isError).toBe(true); - const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ''; - expect(text).toContain('Not Found'); - }); - - it('registers list_package_versions_for_authenticated_user and returns the raw package-version array as JSON', async () => { - nock('https://api.github.com') - .get('/user/packages/npm/my-package/versions') - .query({ page: '1', per_page: '30' }) - .reply(200, [ - { - id: 101, - name: '1.0.0', - url: 'https://api.github.com/user/packages/npm/my-package/versions/101', - package_html_url: 'https://github.com/users/monalisa/packages/npm/package/my-package', - created_at: '2022-01-01T00:00:00Z', - updated_at: '2022-01-01T00:00:00Z', - html_url: 'https://github.com/users/monalisa/packages/npm/package/my-package/101', - }, - { - id: 102, - name: '1.1.0', - url: 'https://api.github.com/user/packages/npm/my-package/versions/102', - package_html_url: 'https://github.com/users/monalisa/packages/npm/package/my-package', - created_at: '2022-06-01T00:00:00Z', - updated_at: '2022-06-01T00:00:00Z', - html_url: 'https://github.com/users/monalisa/packages/npm/package/my-package/102', - }, - ]); - - const client = await connectedClient(registerPackagesTools, 'read-write'); - const result = await client.callTool({ - name: 'list_package_versions_for_authenticated_user', - arguments: { package_type: 'npm', package_name: 'my-package' }, - }); - - expect(result.isError).toBeFalsy(); - const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ''; - const parsed = JSON.parse(text) as Array<{ id: number; name: string }>; - expect(parsed).toHaveLength(2); - expect(parsed[0]).toMatchObject({ id: 101, name: '1.0.0' }); - }); - - it('forwards state and pagination on list_package_versions_for_authenticated_user to the wire', async () => { - const scope = nock('https://api.github.com') - .get('/user/packages/container/my-image/versions') - .query({ state: 'deleted', page: '2', per_page: '10' }) - .reply(200, []); - - const client = await connectedClient(registerPackagesTools, 'read-write'); - const result = await client.callTool({ - name: 'list_package_versions_for_authenticated_user', - arguments: { - package_type: 'container', - package_name: 'my-image', - state: 'deleted', - page: 2, - per_page: 10, - }, - }); - - expect(result.isError).toBeFalsy(); - expect(scope.isDone()).toBe(true); - }); - - it('registers get_package_version_for_authenticated_user and returns the raw package-version object as JSON', async () => { - nock('https://api.github.com') - .get('/user/packages/npm/my-package/versions/101') - .reply(200, { - id: 101, - name: '1.0.0', - url: 'https://api.github.com/user/packages/npm/my-package/versions/101', - package_html_url: 'https://github.com/users/monalisa/packages/npm/package/my-package', - created_at: '2022-01-01T00:00:00Z', - updated_at: '2022-01-01T00:00:00Z', - html_url: 'https://github.com/users/monalisa/packages/npm/package/my-package/101', - metadata: { package_type: 'npm', npm: { name: 'my-package', version: '1.0.0' } }, - }); - - const client = await connectedClient(registerPackagesTools, 'read-write'); - const result = await client.callTool({ - name: 'get_package_version_for_authenticated_user', - arguments: { package_type: 'npm', package_name: 'my-package', package_version_id: 101 }, - }); - - expect(result.isError).toBeFalsy(); - const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ''; - expect(JSON.parse(text)).toMatchObject({ id: 101, name: '1.0.0' }); - }); - - it('propagates a 404 from get_package_version_for_authenticated_user as an MCP tool error', async () => { - nock('https://api.github.com') - .get('/user/packages/npm/my-package/versions/9999') - .reply(404, { message: 'Not Found', documentation_url: 'https://docs.github.com/rest' }); - - const client = await connectedClient(registerPackagesTools, 'read-write'); - const result = await client.callTool({ - name: 'get_package_version_for_authenticated_user', - arguments: { package_type: 'npm', package_name: 'my-package', package_version_id: 9999 }, - }); - - expect(result.isError).toBe(true); - const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ''; - expect(text).toContain('Not Found'); - }); - - it('registers exactly the 4 packages tools in read-only mode (and the same set in read-write)', async () => { - const expected = [ - 'get_package_for_authenticated_user', - 'get_package_version_for_authenticated_user', - 'list_package_versions_for_authenticated_user', - 'list_packages_for_authenticated_user', - ]; - - const readOnlyClient = await connectedClient(registerPackagesTools, 'read-only'); - const readOnlyTools = await readOnlyClient.listTools(); - expect(readOnlyTools.tools.map((t) => t.name).sort()).toEqual(expected); - - const readWriteClient = await connectedClient(registerPackagesTools, 'read-write'); - const readWriteTools = await readWriteClient.listTools(); - expect(readWriteTools.tools.map((t) => t.name).sort()).toEqual(expected); - }); -}); -``` - -- [x] **Step 2: Run tests to verify they fail** - -Run: `npm test -- packages` -Expected: FAIL with a module-not-found error for `../../../src/toolsets/packages.js` (the file doesn't exist yet). - -- [x] **Step 3: Create `src/toolsets/packages.ts`** - -```typescript -import type { McpServer } from '@modelcontextprotocol/server'; -import type { Octokit } from 'octokit'; -import { z } from 'zod'; -import { paginationSchema, toToolResult, toToolError } from './common.js'; - -const packageTypeSchema = z - .enum(['npm', 'maven', 'rubygems', 'docker', 'nuget', 'container']) - .describe( - 'The type of package. One of: npm, maven, rubygems, docker, nuget, container. ' + - 'Packages pushed to GitHub Container Registry (ghcr.io) have type "container". ' + - 'Packages pushed to the legacy Docker registry (docker.pkg.github.com) have type "docker".', - ); - -export function registerPackagesTools( - server: McpServer, - octokit: Octokit, - _permission: 'read-only' | 'read-write', -): void { - server.registerTool( - 'list_packages_for_authenticated_user', - { - description: - 'List packages owned by the authenticated user (the owner of GITHUB_TOKEN). ' + - 'Requires the read:packages token scope. The package_type filter is required — ' + - 'pass one of npm, maven, rubygems, docker, nuget, or container. ' + - 'Optionally filter by visibility (public, private, internal). ' + - 'Paginate with page and per_page.', - inputSchema: z.object({ - package_type: packageTypeSchema, - visibility: z - .enum(['public', 'private', 'internal']) - .optional() - .describe('Filter packages by visibility. Returns all visibilities if omitted.'), - ...paginationSchema, - }), - }, - async ({ package_type, visibility, page, per_page }) => { - try { - const response = await octokit.rest.packages.listPackagesForAuthenticatedUser({ - package_type, - visibility, - page, - per_page, - }); - return toToolResult(response.data); - } catch (error) { - return toToolError(error); - } - }, - ); - - server.registerTool( - 'get_package_for_authenticated_user', - { - description: - 'Get a specific package owned by the authenticated user. ' + - 'Returns the package metadata including name, type, version count, visibility, ' + - 'repository link, and timestamps. Requires the read:packages token scope.', - inputSchema: z.object({ - package_type: packageTypeSchema, - package_name: z.string().describe('The name of the package.'), - }), - }, - async ({ package_type, package_name }) => { - try { - const response = await octokit.rest.packages.getPackageForAuthenticatedUser({ - package_type, - package_name, - }); - return toToolResult(response.data); - } catch (error) { - return toToolError(error); - } - }, - ); - - server.registerTool( - 'list_package_versions_for_authenticated_user', - { - description: - 'List all versions of a package owned by the authenticated user. ' + - 'Returns an array of package-version objects, each with an id, version name, ' + - 'creation/update timestamps, and type-specific metadata (e.g. container tags, npm dist-tags). ' + - 'Optionally filter by state: "active" (default) returns live versions; ' + - '"deleted" returns versions that have been deleted but can still be restored within 30 days. ' + - 'Requires the read:packages token scope.', - inputSchema: z.object({ - package_type: packageTypeSchema, - package_name: z.string().describe('The name of the package.'), - state: z - .enum(['active', 'deleted']) - .optional() - .describe( - 'Filter versions by state. "active" returns live versions (default); ' + - '"deleted" returns versions deleted within the last 30 days.', - ), - ...paginationSchema, - }), - }, - async ({ package_type, package_name, state, page, per_page }) => { - try { - const response = - await octokit.rest.packages.getAllPackageVersionsForPackageOwnedByAuthenticatedUser({ - package_type, - package_name, - state, - page, - per_page, - }); - return toToolResult(response.data); - } catch (error) { - return toToolError(error); - } - }, - ); - - server.registerTool( - 'get_package_version_for_authenticated_user', - { - description: - 'Get a specific version of a package owned by the authenticated user. ' + - 'Returns the package-version object including the version id, name, ' + - 'creation/update timestamps, and type-specific metadata. ' + - 'Use list_package_versions_for_authenticated_user to find version ids. ' + - 'Requires the read:packages token scope.', - inputSchema: z.object({ - package_type: packageTypeSchema, - package_name: z.string().describe('The name of the package.'), - package_version_id: z - .number() - .int() - .describe('The unique identifier of the package version.'), - }), - }, - async ({ package_type, package_name, package_version_id }) => { - try { - const response = await octokit.rest.packages.getPackageVersionForAuthenticatedUser({ - package_type, - package_name, - package_version_id, - }); - return toToolResult(response.data); - } catch (error) { - return toToolError(error); - } - }, - ); -} -``` - -- [x] **Step 4: Run tests to verify they pass** - -Run: `npm test -- packages` -Expected: PASS (9 tests in `packages.test.ts`). - -- [x] **Step 5: Run the full test suite to confirm no regression in other toolsets** - -Run: `npm test` -Expected: PASS. Total test count is the previous suite total plus the 9 new tests in `packages.test.ts`. No pre-existing test file is modified; `common.ts` is unchanged so `common.test.ts` still passes verbatim. - -- [x] **Step 6: Commit** - -```bash -git add src/toolsets/packages.ts test/unit/toolsets/packages.test.ts -git commit -m "feat: add packages toolset (4 read-only tools)" -``` - ---- - -## Task 2: Wire `registerPackagesTools` into `server.ts` - -**Files:** -- Modify: `src/server.ts` -- Modify: `README.md` (Toolsets section) - -**Interfaces:** -- Consumes: `registerPackagesTools(server, octokit, permission)` from Task 1. -- Produces: nothing new — this is the final integration point. - -- [x] **Step 1: Modify `src/server.ts`** - -Current content (as of `main` after the activity toolset was wired): - -```typescript -import { McpServer } from '@modelcontextprotocol/server'; -import type { Octokit } from 'octokit'; -import { registerActivityTools } from './toolsets/activity.js'; -import { registerGistsTools } from './toolsets/gists.js'; -import { registerIssuesTools } from './toolsets/issues.js'; -import { registerPullRequestsTools } from './toolsets/pull_requests.js'; -import { registerReposTools } from './toolsets/repos.js'; -import { registerSearchTools } from './toolsets/search.js'; -import { registerUsersTools } from './toolsets/users.js'; - -const SERVER_NAME = 'github-mcp-server-js'; -const SERVER_VERSION = '0.1.0'; - -export function buildServer( - octokit: Octokit, - permission: 'read-only' | 'read-write', -): McpServer { - const server = new McpServer({ name: SERVER_NAME, version: SERVER_VERSION }); - - registerReposTools(server, octokit, permission); - registerIssuesTools(server, octokit, permission); - registerPullRequestsTools(server, octokit, permission); - registerSearchTools(server, octokit, permission); - registerUsersTools(server, octokit, permission); - registerGistsTools(server, octokit, permission); - registerActivityTools(server, octokit, permission); - - return server; -} -``` - -Replace with (new import sorted alphabetically, new call added after `registerActivityTools`): - -```typescript -import { McpServer } from '@modelcontextprotocol/server'; -import type { Octokit } from 'octokit'; -import { registerActivityTools } from './toolsets/activity.js'; -import { registerGistsTools } from './toolsets/gists.js'; -import { registerIssuesTools } from './toolsets/issues.js'; -import { registerPackagesTools } from './toolsets/packages.js'; -import { registerPullRequestsTools } from './toolsets/pull_requests.js'; -import { registerReposTools } from './toolsets/repos.js'; -import { registerSearchTools } from './toolsets/search.js'; -import { registerUsersTools } from './toolsets/users.js'; - -const SERVER_NAME = 'github-mcp-server-js'; -const SERVER_VERSION = '0.1.0'; - -export function buildServer( - octokit: Octokit, - permission: 'read-only' | 'read-write', -): McpServer { - const server = new McpServer({ name: SERVER_NAME, version: SERVER_VERSION }); - - registerReposTools(server, octokit, permission); - registerIssuesTools(server, octokit, permission); - registerPullRequestsTools(server, octokit, permission); - registerSearchTools(server, octokit, permission); - registerUsersTools(server, octokit, permission); - registerGistsTools(server, octokit, permission); - registerActivityTools(server, octokit, permission); - registerPackagesTools(server, octokit, permission); - - return server; -} -``` - -- [x] **Step 2: Run the full test suite** - -Run: `npm test` -Expected: PASS, same test count as after Task 1 (no test exercises `server.ts` directly — `buildServer` is a thin, non-branching composition function verified by the manual smoke test below). - -- [x] **Step 3: Typecheck, lint, build** - -Run: `npm run typecheck && npm run lint && npm run build` -Expected: all PASS. The build step confirms `dist/cli.js`'s bundled dependency graph now transitively includes `packages.ts`. - -- [x] **Step 4: Manual end-to-end smoke test** - -Run: -```bash -GITHUB_TOKEN=fake-token node dist/cli.js --transport=http --port=3995 & -sleep 1 -curl -s -D /tmp/mcp-init-headers.txt -X POST http://localhost:3995/mcp \ - -H 'Content-Type: application/json' \ - -H 'Accept: application/json, text/event-stream' \ - -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"smoke-test","version":"0.0.0"}}}' -SESSION=$(grep -i mcp-session-id /tmp/mcp-init-headers.txt | awk '{print $2}' | tr -d '\r') -curl -s -X POST http://localhost:3995/mcp \ - -H 'Content-Type: application/json' \ - -H 'Accept: application/json, text/event-stream' \ - -H "Mcp-Session-Id: $SESSION" \ - -d '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' -kill %1 -``` - -Expected: the `initialize` response contains `"serverInfo":{"name":"github-mcp-server-js"...}`. The `tools/list` response includes all four packages tools (`list_packages_for_authenticated_user`, `get_package_for_authenticated_user`, `list_package_versions_for_authenticated_user`, `get_package_version_for_authenticated_user`) alongside all previously-shipped tools from `repos`, `issues`, `pull_requests`, `search`, `users`, `gists`, and `activity` — proving all toolsets are live in the same server with no duplicate-registration crash. - -- [x] **Step 5: Update the README's Toolsets section** - -Modify `README.md`'s "Currently implemented" list to add: - -```markdown -- `packages` — list and inspect GitHub Packages owned by the authenticated user (npm, maven, rubygems, docker, nuget, container) -``` - -Slot it after the existing `activity` bullet, keeping the toolsets listed in the order they were shipped. - -- [x] **Step 6: Commit** - -```bash -git add src/server.ts README.md -git commit -m "feat: wire packages toolset into buildServer" -``` - ---- - -## Deliberate scope decisions - -The following `octokit.rest.packages.*` methods were introspected and are **intentionally excluded** from this toolset. Each exclusion is justified below. - -### Org-scoped endpoints (`/orgs/{org}/packages/*`) — out of scope - -1. **`listPackagesForOrganization` (GET `/orgs/{org}/packages`) — out of scope.** Lists packages for an org rather than the authenticated user. The authenticated-user variants already cover the most common case for a token-based MCP server (the token owner's own packages). Org package listing is a distinct access pattern with its own permission story (requires `admin:org` or `read:packages` with org visibility) and is better served by an `orgs_teams` toolset extension or a follow-up plan. Adding it here would expand the scope beyond ~4 tools without clear incremental value for a v1 toolset. - -2. **`getPackageForOrganization` (GET `/orgs/{org}/packages/{package_type}/{package_name}`) — out of scope.** Same reasoning as `listPackagesForOrganization`. Out of scope for the same `~4 tool` estimate rationale. - -3. **`getAllPackageVersionsForPackageOwnedByOrg` (GET `/orgs/{org}/packages/{package_type}/{package_name}/versions`) — out of scope.** Out of scope for the same reasons. - -4. **`getPackageVersionForOrganization` (GET `/orgs/{org}/packages/{package_type}/{package_name}/versions/{package_version_id}`) — out of scope.** Out of scope for the same reasons. - -### User-by-username endpoints (`/users/{username}/packages/*`) — out of scope - -5. **`listPackagesForUser`, `getPackageForUser`, `getAllPackageVersionsForPackageOwnedByUser`, `getPackageVersionForUser` — out of scope.** These look up packages belonging to an arbitrary public GitHub user (by username), not the authenticated user. They are useful for inspecting other users' public packages but represent a separate access pattern. Like the org variants, they are excluded from the v1 `~4 tool` scope — the authenticated-user tools already cover the token owner's packages (public and private), and user-by-username tools add breadth without adding depth to the core use case. Can be added in a follow-up plan. - -### Mutating / account-management endpoints — excluded - -6. **`deletePackageForAuthenticatedUser` (DELETE `/user/packages/{package_type}/{package_name}`) — write, excluded.** Deletes a package. Write/destructive operation. Cannot delete public packages with >5,000 downloads without contacting GitHub support. The read-only scope of this toolset excludes all mutations. - -7. **`deletePackageVersionForAuthenticatedUser` (DELETE `/user/packages/{package_type}/{package_name}/versions/{package_version_id}`) — write, excluded.** Deletes a specific package version. Same write-exclusion rationale. Additionally destructive with no undo path for versions with high download counts. - -8. **`restorePackageForAuthenticatedUser` (POST `/user/packages/{package_type}/{package_name}/restore`) — write, excluded.** Restores a deleted package (within the 30-day restore window). Write/mutating operation — excluded. The `state: "deleted"` filter on `list_package_versions_for_authenticated_user` exposes visibility into deleted versions without exposing the restore mutation. - -9. **`restorePackageVersionForAuthenticatedUser` (POST `/user/packages/{package_type}/{package_name}/versions/{package_version_id}/restore`) — write, excluded.** Same write-exclusion rationale as `restorePackageForAuthenticatedUser`. - -10. **Org-scoped delete/restore variants** (`deletePackageForOrg`, `deletePackageVersionForOrg`, `restorePackageForOrg`, `restorePackageVersionForOrg`) **— write and org-scoped, doubly excluded.** Both mutation exclusion and org-scope exclusion apply. - -11. **User-by-username delete/restore variants** (`deletePackageForUser`, `deletePackageVersionForUser`, `restorePackageForUser`, `restorePackageVersionForUser`) **— write and off-scope, doubly excluded.** Both mutation exclusion and user-by-username-scope exclusion apply. - -### Docker migration endpoint — excluded - -12. **`listDockerMigrationConflictingPackagesForAuthenticatedUser` (GET `/user/docker/conflicts`) — out of scope.** Returns a list of packages that cannot be migrated from the legacy Docker registry (`docker.pkg.github.com`) to the GitHub Container Registry (`ghcr.io`) because they have a naming conflict. This is a one-time migration audit tool relevant only to users who have packages in the old Docker registry — a niche legacy use case not warranted in a ~4-tool general-purpose scope. The org and user-by-username variants (`listDockerMigrationConflictingPackagesForOrganization`, `listDockerMigrationConflictingPackagesForUser`) are excluded for the same reason plus the org/user-by-username scope exclusion. - ---- - -## Self-Review Notes - -- **Spec coverage:** `packages` toolset (Toolset Inventory row: octokit `packages` namespace, example tools `list_packages, get_package_version`, est. count ~4). Both named examples are covered with their authenticated-user equivalents (`list_packages_for_authenticated_user`, `get_package_version_for_authenticated_user`); the count exactly matches the ~4 estimate; the two additional tools (`get_package_for_authenticated_user`, `list_package_versions_for_authenticated_user`) are the natural companions needed to make the pair of example tools useful in practice (you cannot call `get_package_version` without knowing the version id, which `list_package_versions` provides). Pagination (`page`/`per_page` defaults, max 100) — Task 1. Raw JSON response/error passthrough — Task 1. - -- **Octokit method alias:** `getAllPackageVersionsForPackageOwnedByAuthenticatedUser` (shorter alias) is used in preference to `getAllPackageVersionsForAPackageOwnedByTheAuthenticatedUser` (verbose alias). Both resolve to the same URL at runtime and are confirmed present in the installed version. The shorter name is chosen to match the naming cadence of the other selected methods in this file. - -- **`package_type` is required on all 4 tools.** This differs from some other toolsets where filtering parameters are optional. The OpenAPI spec marks `package_type` as required in the query for `list_packages_for_authenticated_user` and required in the path for the other three. There is no "list all packages of any type" endpoint. The Zod schema reflects this by using `z.enum([...])` without `.optional()` on `package_type` in all four tools. - -- **`packageTypeSchema` is a module-level constant**, not spread into `common.ts`. It is used in all 4 tools in this file and nowhere else; extracting it to `common.ts` would couple a general-purpose helper module to a packages-specific enum, violating the same principle that kept `orderSchema` in `search.ts` rather than `common.ts`. - -- **Lessons applied from prior toolset plans:** - - **`issues` I1 (strict permission-gating equality):** Task 1's read-only test uses `expect(tools.map((t) => t.name).sort()).toEqual([...exact 4 names...])`, not `arrayContaining`. It also runs the same assertion against a `read-write` client, proving this toolset has no hidden write branch. - - **`issues` I3 (wire-level filter passthrough):** Task 1's `visibility` and `state` forwarding tests use `nock(...).query({...full params...})` plus `expect(scope.isDone()).toBe(true)`, proving every optional parameter is forwarded on the wire. - - **`users` M (tool-name collision check):** performed explicitly — see Global Constraints. All 4 names checked against the 50 existing tool names; zero collisions. - - **`search` M (all-read-only `_permission` naming):** applied — the `permission` parameter is `_permission` inside the function body. - -- **No placeholders:** every step includes complete, runnable code. All octokit method names (`listPackagesForAuthenticatedUser`, `getPackageForAuthenticatedUser`, `getAllPackageVersionsForPackageOwnedByAuthenticatedUser`, `getPackageVersionForAuthenticatedUser`), HTTP verbs, path templates, and parameter shapes were verified directly against the installed `@octokit/plugin-rest-endpoint-methods` endpoint table (via `octokit.rest.packages[name].endpoint.DEFAULTS`) and the `@octokit/openapi-types` operation definitions in `types.d.ts` — not memorized or guessed. Every intentionally excluded method from `octokit.rest.packages.*` is called out explicitly in the Deliberate scope decisions section. - -- **Task right-sizing note:** Task 1 bundles all 4 tools + their tests into a single reviewer gate, identical to the `search`, `users`, `gists`, and `activity` plans' rationale. The tools are similarly near-identical shells over near-identical GitHub endpoints; splitting them into multiple tasks would produce copy-paste reviews with no meaningful decision between them. Task 2 remains a separate gate because wiring is where duplicate-registration crashes and stale-README rot surface — same pattern all prior toolset plans use. diff --git a/docs/superpowers/plans/2026-08-05-github-mcp-server-projects.md b/docs/superpowers/plans/2026-08-05-github-mcp-server-projects.md deleted file mode 100644 index d7b36a9..0000000 --- a/docs/superpowers/plans/2026-08-05-github-mcp-server-projects.md +++ /dev/null @@ -1,332 +0,0 @@ -# github-mcp-server-js — `projects` Toolset Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans. Steps use checkbox (`- [x]`) syntax. - -**Goal:** Add the `projects` toolset (5 read-only tools for GitHub Projects V2 org-scoped introspection) to `github-mcp-server-js`, following the established pattern. - -**Architecture:** Same pattern as `search`/`users`/`packages`: one `registerProjectsTools(server, octokit, permission)` function; all tools read-only, so `_permission` underscore prefix and no write branch. `server.ts` gains one more registration call. - -**Tech Stack:** Same as prior plans. No new dependencies. - -## Global Constraints - -- All 5 tools are read-only. `_permission` underscore prefix. -- Raw JSON passthrough; error passthrough via `toToolError`. -- List tools use `paginationSchema`. -- Zero collision with 77 existing tool names. -- All tools scoped to org projects (`projectsV2` API); user-scoped project tools omitted for consistency (see scope decisions). -- TypeScript only, no new dependencies. - ---- - -## File Structure - -``` -src/toolsets/projects.ts, test/unit/toolsets/projects.test.ts, src/server.ts, README.md -``` - ---- - -## Reference: verified octokit shapes - -The `octokit.rest.projects.*` methods target the ProjectsV2 API (superseded the legacy Projects Classic). - -| Tool | octokit method | HTTP | -|---|---|---| -| `list_org_projects` | `projects.listForOrg` | GET `/orgs/{org}/projectsV2` | -| `get_org_project` | `projects.getForOrg` | GET `/orgs/{org}/projectsV2/{project_number}` | -| `list_org_project_items` | `projects.listItemsForOrg` | GET `/orgs/{org}/projectsV2/{project_number}/items` | -| `list_org_project_fields` | `projects.listFieldsForOrg` | GET `/orgs/{org}/projectsV2/{project_number}/fields` | -| `get_org_project_item` | `projects.getOrgItem` | GET `/orgs/{org}/projectsV2/{project_number}/items/{item_id}` | - -**Deliberate scope decisions:** - -1. **User-scoped projects omitted:** `listForUser`, `getForUser`, `listItemsForUser`, etc. Reason: keep the toolset surface consistent (all org-scoped); user projects can be inferred by mapping the authenticated user to their org projects when needed. Adding user-scoped variants would double the tool count without proportional utility gain. - -2. **Write operations omitted:** `addItemForOrg`/`addItemForUser`, `deleteItemForOrg`/`deleteItemForUser`, `updateItemForOrg`/`updateItemForUser`. Reason: Projects V2 item mutation has complex semantics (field-specific value shapes) that warrant a dedicated follow-up plan for safety; keeping v1 read-only. - -3. **Project Classic excluded entirely:** GitHub deprecated the classic Projects API in favor of ProjectsV2 (see the `deprecated` tags on `teams/list-projects-in-org` etc.). No classic project tools are exposed. - ---- - -## Task 1: Implement projects toolset - -**Files:** Create `src/toolsets/projects.ts` and `test/unit/toolsets/projects.test.ts`. - -- [x] **Step 1: Write failing tests** - -```typescript -// test/unit/toolsets/projects.test.ts -import nock from 'nock'; -import { afterEach, describe, expect, it } from 'vitest'; -import { registerProjectsTools } from '../../../src/toolsets/projects.js'; -import { connectedClient } from './test-helpers.js'; - -describe('registerProjectsTools', () => { - afterEach(() => { - nock.cleanAll(); - }); - - it('registers list_org_projects and returns the raw response as JSON', async () => { - nock('https://api.github.com') - .get('/orgs/acme/projectsV2') - .query({ page: '1', per_page: '30' }) - .reply(200, [{ id: 1, number: 1, title: 'Roadmap' }]); - - const client = await connectedClient(registerProjectsTools, 'read-write'); - const result = await client.callTool({ - name: 'list_org_projects', - arguments: { org: 'acme' }, - }); - - expect(result.isError).toBeFalsy(); - const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ''; - expect(JSON.parse(text)).toEqual([{ id: 1, number: 1, title: 'Roadmap' }]); - }); - - it('forwards pagination on list_org_projects to the wire', async () => { - const scope = nock('https://api.github.com') - .get('/orgs/acme/projectsV2') - .query({ page: '2', per_page: '50' }) - .reply(200, []); - - const client = await connectedClient(registerProjectsTools, 'read-write'); - const result = await client.callTool({ - name: 'list_org_projects', - arguments: { org: 'acme', page: 2, per_page: 50 }, - }); - - expect(result.isError).toBeFalsy(); - expect(scope.isDone()).toBe(true); - }); - - it('registers get_org_project and returns the raw response as JSON', async () => { - nock('https://api.github.com') - .get('/orgs/acme/projectsV2/1') - .reply(200, { id: 1, number: 1, title: 'Roadmap' }); - - const client = await connectedClient(registerProjectsTools, 'read-write'); - const result = await client.callTool({ - name: 'get_org_project', - arguments: { org: 'acme', project_number: 1 }, - }); - - expect(result.isError).toBeFalsy(); - const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ''; - expect(JSON.parse(text)).toMatchObject({ number: 1, title: 'Roadmap' }); - }); - - it('propagates a 404 from get_org_project as an MCP tool error', async () => { - nock('https://api.github.com') - .get('/orgs/acme/projectsV2/999') - .reply(404, { message: 'Not Found' }); - - const client = await connectedClient(registerProjectsTools, 'read-write'); - const result = await client.callTool({ - name: 'get_org_project', - arguments: { org: 'acme', project_number: 999 }, - }); - - expect(result.isError).toBe(true); - const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ''; - expect(text).toContain('Not Found'); - }); - - it('registers list_org_project_items and returns the raw response as JSON', async () => { - nock('https://api.github.com') - .get('/orgs/acme/projectsV2/1/items') - .query({ page: '1', per_page: '30' }) - .reply(200, [{ id: 100, content_type: 'Issue' }]); - - const client = await connectedClient(registerProjectsTools, 'read-write'); - const result = await client.callTool({ - name: 'list_org_project_items', - arguments: { org: 'acme', project_number: 1 }, - }); - - expect(result.isError).toBeFalsy(); - const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ''; - expect(JSON.parse(text)).toEqual([{ id: 100, content_type: 'Issue' }]); - }); - - it('registers list_org_project_fields and returns the raw response as JSON', async () => { - nock('https://api.github.com') - .get('/orgs/acme/projectsV2/1/fields') - .query({ page: '1', per_page: '30' }) - .reply(200, [{ id: 10, name: 'Status', data_type: 'SINGLE_SELECT' }]); - - const client = await connectedClient(registerProjectsTools, 'read-write'); - const result = await client.callTool({ - name: 'list_org_project_fields', - arguments: { org: 'acme', project_number: 1 }, - }); - - expect(result.isError).toBeFalsy(); - const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ''; - expect(JSON.parse(text)).toEqual([{ id: 10, name: 'Status', data_type: 'SINGLE_SELECT' }]); - }); - - it('registers get_org_project_item and returns the raw response as JSON', async () => { - nock('https://api.github.com') - .get('/orgs/acme/projectsV2/1/items/100') - .reply(200, { id: 100, content_type: 'Issue', title: 'Bug' }); - - const client = await connectedClient(registerProjectsTools, 'read-write'); - const result = await client.callTool({ - name: 'get_org_project_item', - arguments: { org: 'acme', project_number: 1, item_id: 100 }, - }); - - expect(result.isError).toBeFalsy(); - const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ''; - expect(JSON.parse(text)).toMatchObject({ id: 100 }); - }); - - it('registers exactly the 5 tools in both permission modes', async () => { - const expected = [ - 'get_org_project', - 'get_org_project_item', - 'list_org_project_fields', - 'list_org_project_items', - 'list_org_projects', - ]; - - const roClient = await connectedClient(registerProjectsTools, 'read-only'); - expect((await roClient.listTools()).tools.map((t) => t.name).sort()).toEqual(expected); - - const rwClient = await connectedClient(registerProjectsTools, 'read-write'); - expect((await rwClient.listTools()).tools.map((t) => t.name).sort()).toEqual(expected); - }); -}); -``` - -- [x] **Step 2: Run failing tests** - -Run: `npm test -- projects` → FAIL (module not found). - -- [x] **Step 3: Create `src/toolsets/projects.ts`** - -```typescript -import type { McpServer } from '@modelcontextprotocol/server'; -import type { Octokit } from 'octokit'; -import { z } from 'zod'; -import { paginationSchema, toToolResult, toToolError } from './common.js'; - -const orgSchema = { - org: z.string().describe('Organization login.'), -}; - -const projectNumberSchema = { - project_number: z.number().int().describe('The ProjectsV2 project number (visible in the project URL).'), -}; - -export function registerProjectsTools( - server: McpServer, - octokit: Octokit, - _permission: 'read-only' | 'read-write', -): void { - server.registerTool( - 'list_org_projects', - { - description: 'List GitHub ProjectsV2 projects in an organization.', - inputSchema: z.object({ ...orgSchema, ...paginationSchema }), - }, - async ({ org, page, per_page }) => { - try { - const response = await octokit.rest.projects.listForOrg({ org, page, per_page }); - return toToolResult(response.data); - } catch (error) { - return toToolError(error); - } - }, - ); - - server.registerTool( - 'get_org_project', - { - description: 'Get a GitHub ProjectsV2 project by number in an organization.', - inputSchema: z.object({ ...orgSchema, ...projectNumberSchema }), - }, - async ({ org, project_number }) => { - try { - const response = await octokit.rest.projects.getForOrg({ org, project_number }); - return toToolResult(response.data); - } catch (error) { - return toToolError(error); - } - }, - ); - - server.registerTool( - 'list_org_project_items', - { - description: 'List items in a GitHub ProjectsV2 project.', - inputSchema: z.object({ ...orgSchema, ...projectNumberSchema, ...paginationSchema }), - }, - async ({ org, project_number, page, per_page }) => { - try { - const response = await octokit.rest.projects.listItemsForOrg({ org, project_number, page, per_page }); - return toToolResult(response.data); - } catch (error) { - return toToolError(error); - } - }, - ); - - server.registerTool( - 'list_org_project_fields', - { - description: 'List fields configured on a GitHub ProjectsV2 project.', - inputSchema: z.object({ ...orgSchema, ...projectNumberSchema, ...paginationSchema }), - }, - async ({ org, project_number, page, per_page }) => { - try { - const response = await octokit.rest.projects.listFieldsForOrg({ org, project_number, page, per_page }); - return toToolResult(response.data); - } catch (error) { - return toToolError(error); - } - }, - ); - - server.registerTool( - 'get_org_project_item', - { - description: 'Get a single item in a GitHub ProjectsV2 project.', - inputSchema: z.object({ - ...orgSchema, - ...projectNumberSchema, - item_id: z.number().int().describe('The project item ID.'), - }), - }, - async ({ org, project_number, item_id }) => { - try { - const response = await octokit.rest.projects.getOrgItem({ org, project_number, item_id }); - return toToolResult(response.data); - } catch (error) { - return toToolError(error); - } - }, - ); -} -``` - -- [x] **Step 4: Run tests + full suite** - -Run: `npm test -- projects` → 8 tests PASS. Then `npm test` → 171 + 8 = 179 passing. - -- [x] **Step 5: Commit** - -```bash -git add src/toolsets/projects.ts test/unit/toolsets/projects.test.ts -git commit -m "feat: add projects toolset (5 read-only ProjectsV2 tools)" -``` - ---- - -## Task 2: Wire into `server.ts` + README - -- [x] **Step 1: Add alphabetical import for `registerProjectsTools`. Add call after `registerCodespacesTools`.** -- [x] **Step 2: Add README bullet after `codespaces`**: `- \`projects\` — list and inspect GitHub ProjectsV2 org projects, items, and fields` -- [x] **Step 3: `npm test && npm run typecheck && npm run lint && npm run build` all PASS.** -- [x] **Step 4: Commit**: `git commit -m "feat: wire projects toolset into buildServer"` diff --git a/docs/superpowers/plans/2026-08-05-github-mcp-server-pull-requests.md b/docs/superpowers/plans/2026-08-05-github-mcp-server-pull-requests.md deleted file mode 100644 index 8ac9edc..0000000 --- a/docs/superpowers/plans/2026-08-05-github-mcp-server-pull-requests.md +++ /dev/null @@ -1,913 +0,0 @@ -# github-mcp-server-js — `pull_requests` Toolset Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax for tracking. - -**Goal:** Add the `pull_requests` toolset (10 tools covering PR listing/inspection, creation/update, merging, reviews, and reviewer requests) to `github-mcp-server-js`, following the exact structural, permission-gating, and response/error pattern established by the `repos` and `issues` toolsets. - -**Architecture:** Same pattern as `issues`: one `registerPullRequestsTools(server, octokit, permission)` function registers each tool with `server.registerTool(name, {description, inputSchema}, handler)`; every handler wraps its octokit call in try/catch, returning raw JSON via `toToolResult`/`toToolError`; write tools are registered only when `permission === 'read-write'`; `server.ts` gains one more registration call. All octokit calls use the `pulls` namespace (`octokit.rest.pulls.*`). - -**Tech Stack:** Same as prior plans — TypeScript, `@modelcontextprotocol/server@^2.0.0`, `octokit@^5.0.5`, `zod@^4.2.0`, `vitest`, `nock`. No new dependencies. - -## Global Constraints - -- Tool responses return the raw octokit response body as JSON, unmodified. No field trimming, no text summarization. -- Errors propagate unmodified: catch the octokit `RequestError`, surface `error.message` (which already includes the GitHub `message` field) in an MCP tool error result. No normalization layer. -- List tools take explicit `page` (default `1`) and `per_page` (default `30`, max `100`) parameters. No auto-pagination. -- `GITHUB_PERMISSION=read-only` must prevent write-tool handlers from ever being registered with `McpServer` — not just block them at call time. -- Every tool's `owner`/`repo`/`pull_number` parameters use the shared schema fragments from `src/toolsets/common.ts` (`ownerRepoSchema`, `paginationSchema`, and a new `pullNumberSchema` added in Task 1) — do not restate different wording per tool. -- TypeScript only, no new runtime dependencies. -- Verified tool-name collision check against the 20 existing tool names on `main` (8 from `repos.ts`, 12 from `issues.ts`): **zero collisions** with the 10 tool names chosen below. `McpServer.registerTool` throws at registration time on duplicate names, so this was confirmed before finalizing names, not left to be caught by a failing test. - ---- - -## File Structure - -``` -github-mcp-server-js/ - src/ - toolsets/ - common.ts # MODIFIED: add pullNumberSchema - pull_requests.ts # NEW: registerPullRequestsTools(server, octokit, permission) - server.ts # MODIFIED: calls registerPullRequestsTools - test/ - unit/ - toolsets/ - common.test.ts # unchanged (still passes) - pull_requests.test.ts # NEW: mirrors issues.test.ts's structure -``` - ---- - -## Reference: verified octokit `pulls` namespace shapes - -Confirmed directly against the installed `@octokit/plugin-rest-endpoint-methods` generated endpoint table and `@octokit/openapi-types` operation definitions (not memorized): - -| Tool | octokit method | HTTP | Path params | Query/body params (all optional unless noted) | -|---|---|---|---|---| -| `list_pull_requests` | `pulls.list` | GET | owner, repo | `state` (open\|closed\|all), `head`, `base`, `sort` (created\|updated\|popularity\|long-running), `direction` (asc\|desc), `page`, `per_page` | -| `get_pull_request` | `pulls.get` | GET | owner, repo, pull_number | — | -| `list_pull_request_files` | `pulls.listFiles` | GET | owner, repo, pull_number | `page`, `per_page` | -| `list_pull_request_commits` | `pulls.listCommits` | GET | owner, repo, pull_number | `page`, `per_page` | -| `list_pull_request_reviews` | `pulls.listReviews` | GET | owner, repo, pull_number | `page`, `per_page` | -| `create_pull_request` | `pulls.create` | POST | owner, repo | body: `head` (required), `base` (required), `title`, `body`, `draft`, `maintainer_can_modify`, `issue` (number) | -| `update_pull_request` | `pulls.update` | PATCH | owner, repo, pull_number | body: `title`, `body`, `state` (open\|closed), `base`, `maintainer_can_modify` | -| `merge_pull_request` | `pulls.merge` | PUT | owner, repo, pull_number | body: `commit_title`, `commit_message`, `sha`, `merge_method` (merge\|squash\|rebase) | -| `create_pull_request_review` | `pulls.createReview` | POST | owner, repo, pull_number | body: `commit_id`, `body`, `event` (APPROVE\|REQUEST_CHANGES\|COMMENT) | -| `request_reviewers` | `pulls.requestReviewers` | POST | owner, repo, pull_number | body: `reviewers` (string[]), `team_reviewers` (string[]) | - -Response bodies (raw passthrough, no exceptions needed — unlike `issues.lock`/`unlock`, none of these endpoints return `204 No Content`): -- `list_pull_requests` → `pull-request-simple[]` -- `get_pull_request` → `pull-request` (404 on missing PR, confirmed in `pulls/get` operation's `responses`) -- `list_pull_request_files` → `diff-entry[]` -- `list_pull_request_commits` → `commit[]` -- `list_pull_request_reviews` / `create_pull_request_review` → `pull-request-review[]` / `pull-request-review` -- `create_pull_request` / `update_pull_request` → `pull-request` -- `merge_pull_request` → `pull-request-merge-result` (`{ sha, merged, message }`); non-2xx merge failures (403/404/405/409/422) are thrown by octokit as `RequestError` and handled by the same catch/`toToolError` path as every other tool — no special-casing needed -- `request_reviewers` → `pull-request-simple` - -**Deliberate scope decision:** `create_pull_request_review`'s underlying `pulls.createReview` endpoint also accepts an optional `comments` array for inline per-line review comments (path/position/body/line/side fields). This plan omits that field — the tool supports top-level review submission (`event` + `body`) only, matching the design spec's `create_review` entry and keeping the toolset at its estimated ~10-tool scope. Line-level review comments can be added as a follow-up tool in a later plan if needed; this is not a placeholder, it is a scoped-out feature. - ---- - -## Task 1: Add `pullNumberSchema` to `common.ts` and implement the `pull_requests` read tools - -**Files:** -- Modify: `src/toolsets/common.ts` -- Create: `src/toolsets/pull_requests.ts` -- Test: `test/unit/toolsets/common.test.ts` (add coverage is not needed — `pullNumberSchema` is a plain Zod field like `issueNumberSchema`, exercised indirectly through the tool tests, consistent with how `issueNumberSchema` was never unit-tested standalone) -- Test: `test/unit/toolsets/pull_requests.test.ts` - -**Interfaces:** -- Consumes: `ownerRepoSchema`, `paginationSchema`, `toToolResult`, `toToolError` from `./common.js` (already exist). -- Produces (for Task 2 to extend in the same file/function, and Task 3/`server.ts` to consume): - - `pullNumberSchema: { pull_number: ZodNumber }` in `common.ts`, same shape/pattern as `issueNumberSchema`. - - `registerPullRequestsTools(server: McpServer, octokit: Octokit, permission: 'read-only' | 'read-write'): void` — same signature shape as `registerIssuesTools`. - -This task implements the 5 read-only tools. Task 2 adds the 5 write tools to the same file and function. - -- [x] **Step 1: Add `pullNumberSchema` to `src/toolsets/common.ts`** - -Add this export alongside the existing `issueNumberSchema`: - -```typescript -export const pullNumberSchema = { - pull_number: z.number().int().describe('Pull request number'), -}; -``` - -The full file becomes: - -```typescript -import { z } from 'zod'; - -export const paginationSchema = { - page: z.number().int().min(1).default(1), - per_page: z.number().int().min(1).max(100).default(30), -}; - -export const ownerRepoSchema = { - owner: z.string().describe('Repository owner (user or organization login)'), - repo: z.string().describe('Repository name'), -}; - -export const issueNumberSchema = { - issue_number: z.number().int().describe('Issue number'), -}; - -export const pullNumberSchema = { - pull_number: z.number().int().describe('Pull request number'), -}; - -export function toToolResult(data: unknown) { - return { - content: [{ type: 'text' as const, text: JSON.stringify(data) }], - }; -} - -export function toToolError(error: unknown) { - const message = error instanceof Error ? error.message : String(error); - return { - isError: true, - content: [{ type: 'text' as const, text: message }], - }; -} -``` - -- [x] **Step 2: Write the failing tests for the read tools** - -Create `test/unit/toolsets/pull_requests.test.ts`: - -```typescript -import { McpServer } from '@modelcontextprotocol/server'; -import { Client } from '@modelcontextprotocol/client'; -import { InMemoryTransport } from '@modelcontextprotocol/server'; -import { Octokit } from 'octokit'; -import nock from 'nock'; -import { afterEach, describe, expect, it } from 'vitest'; -import { registerPullRequestsTools } from '../../../src/toolsets/pull_requests.js'; - -async function connectedClient(permission: 'read-only' | 'read-write') { - const octokit = new Octokit({ auth: 'test-token', baseUrl: 'https://api.github.com' }); - const server = new McpServer({ name: 'test-server', version: '0.0.0' }); - registerPullRequestsTools(server, octokit, permission); - - const client = new Client({ name: 'test-client', version: '0.0.0' }); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); - return client; -} - -describe('registerPullRequestsTools', () => { - afterEach(() => { - nock.cleanAll(); - }); - - it('registers list_pull_requests and returns the raw GitHub response as JSON', async () => { - nock('https://api.github.com') - .get('/repos/octocat/hello-world/pulls') - .query({ page: '1', per_page: '30' }) - .reply(200, [{ number: 1, title: 'first pr' }]); - - const client = await connectedClient('read-write'); - const result = await client.callTool({ - name: 'list_pull_requests', - arguments: { owner: 'octocat', repo: 'hello-world' }, - }); - - expect(result.isError).toBeFalsy(); - const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ''; - expect(JSON.parse(text)).toEqual([{ number: 1, title: 'first pr' }]); - }); - - it('passes explicit page and per_page through to list_pull_requests', async () => { - nock('https://api.github.com') - .get('/repos/octocat/hello-world/pulls') - .query({ page: '2', per_page: '10' }) - .reply(200, [{ number: 8, title: 'second page pr' }]); - - const client = await connectedClient('read-write'); - const result = await client.callTool({ - name: 'list_pull_requests', - arguments: { owner: 'octocat', repo: 'hello-world', page: 2, per_page: 10 }, - }); - - expect(result.isError).toBeFalsy(); - const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ''; - expect(JSON.parse(text)).toEqual([{ number: 8, title: 'second page pr' }]); - }); - - it('passes pull request filter params through to list_pull_requests', async () => { - const scope = nock('https://api.github.com') - .get('/repos/octocat/hello-world/pulls') - .query({ - state: 'closed', - head: 'octocat:feature-branch', - base: 'main', - sort: 'updated', - direction: 'desc', - page: '1', - per_page: '30', - }) - .reply(200, [{ number: 9, title: 'filtered pr' }]); - - const client = await connectedClient('read-write'); - const result = await client.callTool({ - name: 'list_pull_requests', - arguments: { - owner: 'octocat', - repo: 'hello-world', - state: 'closed', - head: 'octocat:feature-branch', - base: 'main', - sort: 'updated', - direction: 'desc', - }, - }); - - expect(result.isError).toBeFalsy(); - expect(scope.isDone()).toBe(true); - }); - - it('registers get_pull_request and returns the raw GitHub response as JSON', async () => { - nock('https://api.github.com') - .get('/repos/octocat/hello-world/pulls/1') - .reply(200, { number: 1, title: 'first pr', state: 'open' }); - - const client = await connectedClient('read-write'); - const result = await client.callTool({ - name: 'get_pull_request', - arguments: { owner: 'octocat', repo: 'hello-world', pull_number: 1 }, - }); - - expect(result.isError).toBeFalsy(); - const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ''; - expect(JSON.parse(text)).toMatchObject({ number: 1, state: 'open' }); - }); - - it('propagates a 404 as an MCP tool error with the raw GitHub message', async () => { - nock('https://api.github.com') - .get('/repos/octocat/hello-world/pulls/999') - .reply(404, { message: 'Not Found', documentation_url: 'https://docs.github.com/rest' }); - - const client = await connectedClient('read-write'); - const result = await client.callTool({ - name: 'get_pull_request', - arguments: { owner: 'octocat', repo: 'hello-world', pull_number: 999 }, - }); - - expect(result.isError).toBe(true); - const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ''; - expect(text).toContain('Not Found'); - }); - - it('registers list_pull_request_files and returns the raw GitHub response as JSON', async () => { - nock('https://api.github.com') - .get('/repos/octocat/hello-world/pulls/1/files') - .query({ page: '1', per_page: '30' }) - .reply(200, [{ filename: 'src/index.ts', status: 'modified' }]); - - const client = await connectedClient('read-write'); - const result = await client.callTool({ - name: 'list_pull_request_files', - arguments: { owner: 'octocat', repo: 'hello-world', pull_number: 1 }, - }); - - expect(result.isError).toBeFalsy(); - const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ''; - expect(JSON.parse(text)).toEqual([{ filename: 'src/index.ts', status: 'modified' }]); - }); - - it('registers list_pull_request_commits and returns the raw GitHub response as JSON', async () => { - nock('https://api.github.com') - .get('/repos/octocat/hello-world/pulls/1/commits') - .query({ page: '1', per_page: '30' }) - .reply(200, [{ sha: 'abc123', commit: { message: 'a commit' } }]); - - const client = await connectedClient('read-write'); - const result = await client.callTool({ - name: 'list_pull_request_commits', - arguments: { owner: 'octocat', repo: 'hello-world', pull_number: 1 }, - }); - - expect(result.isError).toBeFalsy(); - const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ''; - expect(JSON.parse(text)).toEqual([{ sha: 'abc123', commit: { message: 'a commit' } }]); - }); - - it('registers list_pull_request_reviews and returns the raw GitHub response as JSON', async () => { - nock('https://api.github.com') - .get('/repos/octocat/hello-world/pulls/1/reviews') - .query({ page: '1', per_page: '30' }) - .reply(200, [{ id: 55, state: 'APPROVED' }]); - - const client = await connectedClient('read-write'); - const result = await client.callTool({ - name: 'list_pull_request_reviews', - arguments: { owner: 'octocat', repo: 'hello-world', pull_number: 1 }, - }); - - expect(result.isError).toBeFalsy(); - const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ''; - expect(JSON.parse(text)).toEqual([{ id: 55, state: 'APPROVED' }]); - }); -}); -``` - -- [x] **Step 3: Run test to verify it fails** - -Run: `npm test -- pull_requests` -Expected: FAIL with a module-not-found error for `../../../src/toolsets/pull_requests.js` (the file doesn't exist yet). - -- [x] **Step 4: Create `src/toolsets/pull_requests.ts` with the 5 read tools** - -```typescript -import type { McpServer } from '@modelcontextprotocol/server'; -import type { Octokit } from 'octokit'; -import { z } from 'zod'; -import { ownerRepoSchema, paginationSchema, pullNumberSchema, toToolResult, toToolError } from './common.js'; - -export function registerPullRequestsTools( - server: McpServer, - octokit: Octokit, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - permission: 'read-only' | 'read-write', -): void { - server.registerTool( - 'list_pull_requests', - { - description: 'List pull requests in a GitHub repository.', - inputSchema: z.object({ - ...ownerRepoSchema, - state: z.enum(['open', 'closed', 'all']).optional().describe('Filter by pull request state (defaults to open)'), - head: z.string().optional().describe('Filter by head user/org and branch, e.g. "octocat:feature-branch"'), - base: z.string().optional().describe('Filter by base branch name'), - sort: z - .enum(['created', 'updated', 'popularity', 'long-running']) - .optional() - .describe('Field to sort results by'), - direction: z.enum(['asc', 'desc']).optional().describe('Sort direction'), - ...paginationSchema, - }), - }, - async ({ owner, repo, state, head, base, sort, direction, page, per_page }) => { - try { - const response = await octokit.rest.pulls.list({ - owner, - repo, - state, - head, - base, - sort, - direction, - page, - per_page, - }); - return toToolResult(response.data); - } catch (error) { - return toToolError(error); - } - }, - ); - - server.registerTool( - 'get_pull_request', - { - description: 'Get a single pull request in a GitHub repository.', - inputSchema: z.object({ - ...ownerRepoSchema, - ...pullNumberSchema, - }), - }, - async ({ owner, repo, pull_number }) => { - try { - const response = await octokit.rest.pulls.get({ owner, repo, pull_number }); - return toToolResult(response.data); - } catch (error) { - return toToolError(error); - } - }, - ); - - server.registerTool( - 'list_pull_request_files', - { - description: 'List the files changed in a GitHub pull request.', - inputSchema: z.object({ - ...ownerRepoSchema, - ...pullNumberSchema, - ...paginationSchema, - }), - }, - async ({ owner, repo, pull_number, page, per_page }) => { - try { - const response = await octokit.rest.pulls.listFiles({ owner, repo, pull_number, page, per_page }); - return toToolResult(response.data); - } catch (error) { - return toToolError(error); - } - }, - ); - - server.registerTool( - 'list_pull_request_commits', - { - description: 'List the commits on a GitHub pull request.', - inputSchema: z.object({ - ...ownerRepoSchema, - ...pullNumberSchema, - ...paginationSchema, - }), - }, - async ({ owner, repo, pull_number, page, per_page }) => { - try { - const response = await octokit.rest.pulls.listCommits({ owner, repo, pull_number, page, per_page }); - return toToolResult(response.data); - } catch (error) { - return toToolError(error); - } - }, - ); - - server.registerTool( - 'list_pull_request_reviews', - { - description: 'List the reviews on a GitHub pull request.', - inputSchema: z.object({ - ...ownerRepoSchema, - ...pullNumberSchema, - ...paginationSchema, - }), - }, - async ({ owner, repo, pull_number, page, per_page }) => { - try { - const response = await octokit.rest.pulls.listReviews({ owner, repo, pull_number, page, per_page }); - return toToolResult(response.data); - } catch (error) { - return toToolError(error); - } - }, - ); - -} -``` - -Note on the `eslint-disable-next-line` comment above the `permission` parameter: at this point in the plan `permission` is not yet referenced (no write tools exist yet in this file), which would otherwise fail ESLint's `no-unused-vars` rule. Task 2 makes `permission` genuinely used by wrapping the 5 write tools in `if (permission === 'read-write') { ... }` — at that point, remove this disable comment entirely (mirroring exactly what happened with `issues.ts`'s `permission` parameter in the `issues` toolset plan's Task 2→Task 3 transition, confirmed by re-reading the committed `src/toolsets/issues.ts`, which carries no such comment). - -- [x] **Step 5: Run test to verify it passes** - -Run: `npm test -- pull_requests` -Expected: PASS (8 tests). - -- [x] **Step 6: Typecheck and lint** - -Run: `npm run typecheck && npm run lint` -Expected: both PASS with zero errors. - -- [x] **Step 7: Commit** - -```bash -git add src/toolsets/common.ts src/toolsets/pull_requests.ts test/unit/toolsets/pull_requests.test.ts -git commit -m "feat: add pull_requests toolset read tools" -``` - ---- - -## Task 2: Implement the `pull_requests` toolset — write tools (create, update, merge, review, request reviewers) - -**Files:** -- Modify: `src/toolsets/pull_requests.ts` -- Modify: `test/unit/toolsets/pull_requests.test.ts` - -**Interfaces:** -- Consumes: `ownerRepoSchema`, `paginationSchema`, `pullNumberSchema`, `toToolResult`, `toToolError` from `./common.js` (Task 1); the same `registerPullRequestsTools` function body from Task 1, extended in place. -- Produces: the complete `registerPullRequestsTools` (10 tools total), ready for Task 3 to wire into `server.ts`. - -- [x] **Step 1: Write the failing tests for the write tools and the permission-gating test** - -Add these tests inside the existing `describe('registerPullRequestsTools', ...)` block in `test/unit/toolsets/pull_requests.test.ts`, after the `list_pull_request_reviews` test and before the closing `});`: - -```typescript - it('registers create_pull_request and returns the raw GitHub response as JSON', async () => { - nock('https://api.github.com') - .post('/repos/octocat/hello-world/pulls', { head: 'octocat:feature-branch', base: 'main', title: 'a new pr' }) - .reply(201, { number: 42, title: 'a new pr' }); - - const client = await connectedClient('read-write'); - const result = await client.callTool({ - name: 'create_pull_request', - arguments: { owner: 'octocat', repo: 'hello-world', head: 'octocat:feature-branch', base: 'main', title: 'a new pr' }, - }); - - expect(result.isError).toBeFalsy(); - const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ''; - expect(JSON.parse(text)).toEqual({ number: 42, title: 'a new pr' }); - }); - - it('registers update_pull_request and returns the raw GitHub response as JSON', async () => { - nock('https://api.github.com') - .patch('/repos/octocat/hello-world/pulls/1', { state: 'closed' }) - .reply(200, { number: 1, state: 'closed' }); - - const client = await connectedClient('read-write'); - const result = await client.callTool({ - name: 'update_pull_request', - arguments: { owner: 'octocat', repo: 'hello-world', pull_number: 1, state: 'closed' }, - }); - - expect(result.isError).toBeFalsy(); - const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ''; - expect(JSON.parse(text)).toEqual({ number: 1, state: 'closed' }); - }); - - it('registers merge_pull_request and returns the raw GitHub response as JSON', async () => { - nock('https://api.github.com') - .put('/repos/octocat/hello-world/pulls/1/merge', { merge_method: 'squash' }) - .reply(200, { sha: 'abc123', merged: true, message: 'Pull Request successfully merged' }); - - const client = await connectedClient('read-write'); - const result = await client.callTool({ - name: 'merge_pull_request', - arguments: { owner: 'octocat', repo: 'hello-world', pull_number: 1, merge_method: 'squash' }, - }); - - expect(result.isError).toBeFalsy(); - const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ''; - expect(JSON.parse(text)).toEqual({ sha: 'abc123', merged: true, message: 'Pull Request successfully merged' }); - }); - - it('propagates a merge conflict (409) as an MCP tool error with the raw GitHub message', async () => { - nock('https://api.github.com') - .put('/repos/octocat/hello-world/pulls/1/merge') - .reply(409, { message: 'Head branch was modified. Review and try the merge again.' }); - - const client = await connectedClient('read-write'); - const result = await client.callTool({ - name: 'merge_pull_request', - arguments: { owner: 'octocat', repo: 'hello-world', pull_number: 1 }, - }); - - expect(result.isError).toBe(true); - const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ''; - expect(text).toContain('Head branch was modified'); - }); - - it('registers create_pull_request_review and returns the raw GitHub response as JSON', async () => { - nock('https://api.github.com') - .post('/repos/octocat/hello-world/pulls/1/reviews', { event: 'APPROVE', body: 'Looks good' }) - .reply(200, { id: 77, state: 'APPROVED' }); - - const client = await connectedClient('read-write'); - const result = await client.callTool({ - name: 'create_pull_request_review', - arguments: { owner: 'octocat', repo: 'hello-world', pull_number: 1, event: 'APPROVE', body: 'Looks good' }, - }); - - expect(result.isError).toBeFalsy(); - const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ''; - expect(JSON.parse(text)).toEqual({ id: 77, state: 'APPROVED' }); - }); - - it('registers request_reviewers and returns the raw GitHub response as JSON', async () => { - nock('https://api.github.com') - .post('/repos/octocat/hello-world/pulls/1/requested_reviewers', { reviewers: ['octocat'] }) - .reply(201, { number: 1, requested_reviewers: [{ login: 'octocat' }] }); - - const client = await connectedClient('read-write'); - const result = await client.callTool({ - name: 'request_reviewers', - arguments: { owner: 'octocat', repo: 'hello-world', pull_number: 1, reviewers: ['octocat'] }, - }); - - expect(result.isError).toBeFalsy(); - const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ''; - expect(JSON.parse(text)).toEqual({ number: 1, requested_reviewers: [{ login: 'octocat' }] }); - }); - - it('registers exactly the 5 read tools and no write tools in read-only mode', async () => { - const client = await connectedClient('read-only'); - const { tools } = await client.listTools(); - expect(tools.map((t) => t.name).sort()).toEqual([ - 'get_pull_request', - 'list_pull_request_commits', - 'list_pull_request_files', - 'list_pull_request_reviews', - 'list_pull_requests', - ]); - }); -``` - -- [x] **Step 2: Run test to verify it fails** - -Run: `npm test -- pull_requests` -Expected: FAIL — the write-tool tests fail because `create_pull_request` etc. aren't registered yet, and the permission-gating test fails because Task 1's placeholder registers only the 5 read tools with no gating logic at all (so it currently passes trivially; re-verify it still asserts the correct 5-tool list after this task's changes, since a passing-for-the-wrong-reason test is not a green light). - -- [x] **Step 3: Remove the `eslint-disable` comment and add the 5 write tools to `src/toolsets/pull_requests.ts`** - -Remove the `// eslint-disable-next-line @typescript-eslint/no-unused-vars` line directly above the `permission` parameter in the function signature — `permission` becomes genuinely used by the `if` block added below, so the disable comment is no longer needed: - -```typescript -export function registerPullRequestsTools( - server: McpServer, - octokit: Octokit, - permission: 'read-only' | 'read-write', -): void { -``` - -Then, immediately before the function's closing `}`, insert the 5 write tools: - -```typescript - if (permission === 'read-write') { - server.registerTool( - 'create_pull_request', - { - description: 'Create a new pull request in a GitHub repository.', - inputSchema: z.object({ - ...ownerRepoSchema, - title: z.string().optional().describe('Pull request title (required unless issue is specified)'), - head: z.string().describe('Branch containing the changes, e.g. "octocat:feature-branch"'), - base: z.string().describe('Branch you want the changes pulled into'), - body: z.string().optional().describe('Pull request body/description'), - draft: z.boolean().optional().describe('Whether to create the pull request as a draft'), - maintainer_can_modify: z.boolean().optional().describe('Whether maintainers can modify the pull request'), - issue: z.number().int().optional().describe('Issue number to convert into a pull request'), - }), - }, - async ({ owner, repo, title, head, base, body, draft, maintainer_can_modify, issue }) => { - try { - const response = await octokit.rest.pulls.create({ - owner, - repo, - title, - head, - base, - body, - draft, - maintainer_can_modify, - issue, - }); - return toToolResult(response.data); - } catch (error) { - return toToolError(error); - } - }, - ); - - server.registerTool( - 'update_pull_request', - { - description: 'Update an existing pull request in a GitHub repository.', - inputSchema: z.object({ - ...ownerRepoSchema, - ...pullNumberSchema, - title: z.string().optional().describe('New pull request title'), - body: z.string().optional().describe('New pull request body/description'), - state: z.enum(['open', 'closed']).optional().describe('New pull request state'), - base: z.string().optional().describe('New base branch name'), - maintainer_can_modify: z.boolean().optional().describe('Whether maintainers can modify the pull request'), - }), - }, - async ({ owner, repo, pull_number, title, body, state, base, maintainer_can_modify }) => { - try { - const response = await octokit.rest.pulls.update({ - owner, - repo, - pull_number, - title, - body, - state, - base, - maintainer_can_modify, - }); - return toToolResult(response.data); - } catch (error) { - return toToolError(error); - } - }, - ); - - server.registerTool( - 'merge_pull_request', - { - description: 'Merge a pull request in a GitHub repository.', - inputSchema: z.object({ - ...ownerRepoSchema, - ...pullNumberSchema, - commit_title: z.string().optional().describe('Title for the automatic merge commit message'), - commit_message: z.string().optional().describe('Extra detail to append to the automatic merge commit message'), - sha: z.string().optional().describe('SHA the pull request head must match to allow the merge'), - merge_method: z.enum(['merge', 'squash', 'rebase']).optional().describe('Merge method to use'), - }), - }, - async ({ owner, repo, pull_number, commit_title, commit_message, sha, merge_method }) => { - try { - const response = await octokit.rest.pulls.merge({ - owner, - repo, - pull_number, - commit_title, - commit_message, - sha, - merge_method, - }); - return toToolResult(response.data); - } catch (error) { - return toToolError(error); - } - }, - ); - - server.registerTool( - 'create_pull_request_review', - { - description: 'Create a review on a GitHub pull request.', - inputSchema: z.object({ - ...ownerRepoSchema, - ...pullNumberSchema, - commit_id: z.string().optional().describe('SHA of the commit to review (defaults to the most recent commit)'), - body: z.string().optional().describe('Review body text, required when event is REQUEST_CHANGES or COMMENT'), - event: z - .enum(['APPROVE', 'REQUEST_CHANGES', 'COMMENT']) - .optional() - .describe('Review action; omit to leave the review PENDING'), - }), - }, - async ({ owner, repo, pull_number, commit_id, body, event }) => { - try { - const response = await octokit.rest.pulls.createReview({ - owner, - repo, - pull_number, - commit_id, - body, - event, - }); - return toToolResult(response.data); - } catch (error) { - return toToolError(error); - } - }, - ); - - server.registerTool( - 'request_reviewers', - { - description: 'Request reviewers for a GitHub pull request.', - inputSchema: z.object({ - ...ownerRepoSchema, - ...pullNumberSchema, - reviewers: z.array(z.string()).optional().describe('User logins to request review from'), - team_reviewers: z.array(z.string()).optional().describe('Team slugs to request review from'), - }), - }, - async ({ owner, repo, pull_number, reviewers, team_reviewers }) => { - try { - const response = await octokit.rest.pulls.requestReviewers({ - owner, - repo, - pull_number, - reviewers, - team_reviewers, - }); - return toToolResult(response.data); - } catch (error) { - return toToolError(error); - } - }, - ); - } -} -``` - -- [x] **Step 4: Run test to verify it passes** - -Run: `npm test -- pull_requests` -Expected: PASS (15 tests: 8 from Task 1 + 7 new). - -- [x] **Step 5: Typecheck, lint, and full suite** - -Run: `npm run typecheck && npm run lint && npm test` -Expected: all PASS. Full suite total: 49 (after the `issues` plan) + 15 = 64 tests. - -- [x] **Step 6: Commit** - -```bash -git add src/toolsets/pull_requests.ts test/unit/toolsets/pull_requests.test.ts -git commit -m "feat: add pull_requests toolset write tools (create, update, merge, review, request reviewers)" -``` - ---- - -## Task 3: Wire `registerPullRequestsTools` into `server.ts` - -**Files:** -- Modify: `src/server.ts` - -**Interfaces:** -- Consumes: `registerPullRequestsTools(server, octokit, permission)` from Task 2. -- Produces: nothing new — this is the final integration point. - -- [x] **Step 1: Modify `src/server.ts`** - -Current content: - -```typescript -import { McpServer } from '@modelcontextprotocol/server'; -import type { Octokit } from 'octokit'; -import { registerIssuesTools } from './toolsets/issues.js'; -import { registerReposTools } from './toolsets/repos.js'; - -const SERVER_NAME = 'github-mcp-server-js'; -const SERVER_VERSION = '0.1.0'; - -export function buildServer( - octokit: Octokit, - permission: 'read-only' | 'read-write', -): McpServer { - const server = new McpServer({ name: SERVER_NAME, version: SERVER_VERSION }); - - registerReposTools(server, octokit, permission); - registerIssuesTools(server, octokit, permission); - - return server; -} -``` - -Replace with: - -```typescript -import { McpServer } from '@modelcontextprotocol/server'; -import type { Octokit } from 'octokit'; -import { registerIssuesTools } from './toolsets/issues.js'; -import { registerPullRequestsTools } from './toolsets/pull_requests.js'; -import { registerReposTools } from './toolsets/repos.js'; - -const SERVER_NAME = 'github-mcp-server-js'; -const SERVER_VERSION = '0.1.0'; - -export function buildServer( - octokit: Octokit, - permission: 'read-only' | 'read-write', -): McpServer { - const server = new McpServer({ name: SERVER_NAME, version: SERVER_VERSION }); - - registerReposTools(server, octokit, permission); - registerIssuesTools(server, octokit, permission); - registerPullRequestsTools(server, octokit, permission); - - return server; -} -``` - -- [x] **Step 2: Run the full test suite** - -Run: `npm test` -Expected: PASS, same 64 tests as after Task 2 (no test exercises `server.ts` directly, following the same precedent as the `issues` plan's Task 4 — `buildServer` is a thin, non-branching composition function verified by the CLI smoke test below plus the pre-existing CLI smoke test from the core plan). - -- [x] **Step 3: Typecheck, lint, build** - -Run: `npm run typecheck && npm run lint && npm run build` -Expected: all PASS. The build step confirms `dist/cli.js`'s bundled dependency graph now transitively includes `pull_requests.ts`. - -- [x] **Step 4: Manual end-to-end smoke test** - -Run: -```bash -GITHUB_TOKEN=fake-token node dist/cli.js --transport=http --port=3992 & -sleep 1 -curl -s -X POST http://localhost:3992/mcp \ - -H 'Content-Type: application/json' \ - -H 'Accept: application/json, text/event-stream' \ - -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"smoke-test","version":"0.0.0"}}}' -``` - -Expected: a `200` response containing `"serverInfo":{"name":"github-mcp-server-js"...}`. Then send a `tools/list` request (reusing the `mcp-session-id` response header from the `initialize` call) and confirm the tool list includes `get_repository` (from `repos`), `list_issues` (from `issues`), and `list_pull_requests` (from this plan's `pull_requests` toolset) — proving all three toolsets are live in the same server with no duplicate-registration crash. Kill the background process afterward (`kill %1`). - -- [x] **Step 5: Update the README's Toolsets section** - -Modify `README.md`'s "Currently implemented" list (the same section the `issues` plan's final review flagged as going stale) to add: - -```markdown -- `pull_requests` — pull request listing, creation, merging, reviews, and reviewer requests -``` - -and remove `pull requests` from the trailing "Additional toolsets ... are tracked in" sentence's implicit backlog, since it's now implemented. - -- [x] **Step 6: Commit** - -```bash -git add src/server.ts README.md -git commit -m "feat: wire pull_requests toolset into buildServer" -``` - ---- - -## Self-Review Notes - -- **Spec coverage:** `pull_requests` toolset (Toolset Inventory row: octokit `pulls` namespace, example tools `list_prs, get_pr, create_pr, merge_pr, list_pr_files, create_review, list_reviews`, est. count ~10) — all 7 named examples are covered (`list_pull_requests`, `get_pull_request`, `create_pull_request`, `merge_pull_request`, `list_pull_request_files`, `create_pull_request_review`, `list_pull_request_reviews`), plus 3 more to round out common PR workflows (`list_pull_request_commits`, `update_pull_request`, `request_reviewers`) for exactly 10 tools, matching the estimate. Pagination (`page`/`per_page` defaults, max 100) — Task 1. Raw JSON response/error passthrough — Tasks 1-2. Registration-time permission gating — Task 2. `reactions` (also namespaced under the design doc's `issues` row in some readings) is explicitly out of scope for this toolset, consistent with how the `issues` plan treated it. -- **Lessons applied from the `issues` toolset's final review:** - - **I1 (strict permission-gating equality):** Task 2's read-only test uses `expect(tools.map((t) => t.name).sort()).toEqual([...exact 5 names...])`, not `arrayContaining`. - - **I3 (wire-level filter passthrough):** Task 1's `list_pull_requests` filter test uses `nock(...).query({...six params...})` plus `expect(scope.isDone()).toBe(true)`, proving every optional filter param is actually forwarded on the wire, not just accepted without erroring. - - **M8 (tool-name collision check):** performed explicitly before finalizing names — see Global Constraints. All 10 names (`list_pull_requests`, `get_pull_request`, `list_pull_request_files`, `list_pull_request_commits`, `list_pull_request_reviews`, `create_pull_request`, `update_pull_request`, `merge_pull_request`, `create_pull_request_review`, `request_reviewers`) were checked against the 20 existing names from `repos.ts`/`issues.ts` and are all distinct — notably `create_pull_request_review` avoids colliding with `issues.ts`'s `add_comment`/`list_comments` by using the fully-qualified `pull_request_review` noun instead of a generic `comment`/`review` name. -- **Type consistency:** `registerPullRequestsTools(server: McpServer, octokit: Octokit, permission: 'read-only' | 'read-write'): void` (Task 1) matches `registerIssuesTools`/`registerReposTools`'s signature exactly and is called identically in `server.ts` (Task 3). `pullNumberSchema` (Task 1, added to `common.ts`) follows `issueNumberSchema`'s exact shape and naming convention. No schema fragment is redeclared locally in `pull_requests.ts`. -- **No placeholders:** every step includes complete, runnable code. All octokit method names, HTTP verbs, path templates, and query/body parameter shapes were verified directly against the installed `@octokit/plugin-rest-endpoint-methods` generated endpoint table and `@octokit/openapi-types` operation definitions (see the Reference table above) — not memorized or guessed. The one intentionally omitted field (`create_pull_request_review`'s inline `comments` array) is called out explicitly as a scope decision, not left as an implicit gap. -- **Task right-sizing note:** Task 1 bundles the trivial `common.ts` addition (one schema fragment, no test of its own) together with the 5 read tools it enables, rather than spinning up a standalone one-line task — mirroring the "fold setup into the task that needs it" guidance, since `pullNumberSchema` has no independent behavior to review in isolation. diff --git a/docs/superpowers/plans/2026-08-05-github-mcp-server-search.md b/docs/superpowers/plans/2026-08-05-github-mcp-server-search.md deleted file mode 100644 index 1089a4a..0000000 --- a/docs/superpowers/plans/2026-08-05-github-mcp-server-search.md +++ /dev/null @@ -1,601 +0,0 @@ -# github-mcp-server-js — `search` Toolset Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add the `search` toolset (5 read-only tools wrapping GitHub's REST search endpoints — repositories, code, issues/PRs, users, commits) to `github-mcp-server-js`, following the exact structural, permission-gating, and response/error pattern established by the `repos`, `issues`, and `pull_requests` toolsets. - -**Architecture:** Same pattern as prior toolsets: one `registerSearchTools(server, octokit, permission)` function registers each tool with `server.registerTool(name, {description, inputSchema}, handler)`; every handler wraps its octokit call in try/catch, returning raw JSON via `toToolResult`/`toToolError`; `server.ts` gains one more registration call. All octokit calls use the `search` namespace (`octokit.rest.search.*`). Because every search endpoint is read-only, the `permission === 'read-write'` branch is unused in this toolset — the `permission` parameter is accepted (to keep the signature uniform across every `register*Tools` function) but never inspected. - -**Tech Stack:** Same as prior plans — TypeScript, `@modelcontextprotocol/server@^2.0.0`, `octokit@^5.0.5`, `zod@^4.2.0`, `vitest`, `nock`. No new dependencies. - -## Global Constraints - -- Tool responses return the raw octokit response body as JSON, unmodified. No field trimming, no text summarization. Every search endpoint returns `{ total_count, incomplete_results, items: [...] }` — this exact envelope is preserved, callers pull `.items` themselves. -- Errors propagate unmodified: catch the octokit `RequestError`, surface `error.message` (which already includes the GitHub `message` field) in an MCP tool error result. No normalization layer, no special-casing of the `422 Unprocessable Entity` that GitHub returns for a malformed `q`. -- List tools take explicit `page` (default `1`) and `per_page` (default `30`, max `100`) parameters. No auto-pagination. This matches every other toolset's pagination model — GitHub's search API caps `per_page` at `100` and total results at `1000` (~10 pages of 100), but that ceiling is enforced by GitHub itself and is not something the tool re-validates. -- `GITHUB_PERMISSION=read-only` gating is a no-op for this toolset because every search tool is read-only; the read-only test in Task 1 still verifies the exact set of 5 tools is registered (see the "Lessons applied" note in Self-Review Notes below for why this test uses `.toEqual([...exact set...])` rather than `arrayContaining`). -- Every tool's `owner`/`repo` schema fragments are not applicable — search tools do not take a repository; scoping to a repo is expressed inside the `q` query string via `repo:owner/name` qualifiers, per GitHub's search grammar. -- Every tool shares the shared `paginationSchema` from `src/toolsets/common.ts` (already exists) and re-uses the same inline pattern for `q` / `sort` / `order`. `q`, `sort`, and `order` are intentionally NOT extracted into `common.ts`: they are used only inside this one file, and moving them out would couple a general-purpose helper module to search-specific enums. -- TypeScript only, no new runtime dependencies. -- Verified tool-name collision check against the 30 existing tool names on `main` (8 from `repos.ts`, 12 from `issues.ts`, 10 from `pull_requests.ts`): **zero collisions** with the 5 tool names chosen below (`search_repos`, `search_code`, `search_commits`, `search_issues`, `search_users`). `McpServer.registerTool` throws at registration time on duplicate names, so this was confirmed before finalizing names, not left to be caught by a failing test. - ---- - -## File Structure - -``` -github-mcp-server-js/ - src/ - toolsets/ - search.ts # NEW: registerSearchTools(server, octokit, permission) - server.ts # MODIFIED: calls registerSearchTools - test/ - unit/ - toolsets/ - search.test.ts # NEW: mirrors issues.test.ts's structure -``` - -`common.ts` is NOT modified — no new shared schema fragment is warranted (see Global Constraints above). `README.md`'s Toolsets section is updated in Task 2 (Step 5), same as the `issues`/`pull_requests` plans did. - ---- - -## Reference: verified octokit `search` namespace shapes - -Confirmed directly against the installed `@octokit/plugin-rest-endpoint-methods` generated endpoint table (`octokit.rest.search[name].endpoint.DEFAULTS`) and the `@octokit/openapi-types` operation definitions at `node_modules/@octokit/openapi-types/types.d.ts` lines 116675–116963. All 5 endpoints are `GET`, take only query-string parameters (no path params, no request body), and return the same `{ total_count, incomplete_results, items }` envelope. - -| Tool | octokit method | HTTP path | `q` | `sort` (enum) | `order` | pagination | -|---|---|---|---|---|---|---| -| `search_repos` | `search.repos` | GET `/search/repositories` | required | `stars` \| `forks` \| `help-wanted-issues` \| `updated` | `asc` \| `desc` | `page`, `per_page` | -| `search_code` | `search.code` | GET `/search/code` | required | — (see scope decision) | — (see scope decision) | `page`, `per_page` | -| `search_commits` | `search.commits` | GET `/search/commits` | required | `author-date` \| `committer-date` | `asc` \| `desc` | `page`, `per_page` | -| `search_issues` | `search.issuesAndPullRequests` | GET `/search/issues` | required | `comments` \| `reactions` \| `reactions-+1` \| `reactions--1` \| `reactions-smile` \| `reactions-thinking_face` \| `reactions-heart` \| `reactions-tada` \| `interactions` \| `created` \| `updated` | `asc` \| `desc` | `page`, `per_page` | -| `search_users` | `search.users` | GET `/search/users` | required | `followers` \| `repositories` \| `joined` | `asc` \| `desc` | `page`, `per_page` | - -Response bodies (raw passthrough, all `200 OK`): -- All 5 → `{ total_count: number, incomplete_results: boolean, items: Item[] }` where `Item` is endpoint-specific (`repo-search-result-item`, `code-search-result-item`, `commit-search-result-item`, `issue-search-result-item`, `user-search-result-item`). -- Non-2xx (typically `422` for a malformed `q`, `403` for secondary rate limiting, `503` for a slow query) surface as `RequestError` and are handled by the same catch/`toToolError` path as every other tool. - -**Deliberate scope decisions:** - -1. **`search_code` omits `sort` and `order`.** The generated OpenAPI types mark both as `@deprecated` — GitHub's own field description reads *"This field is closing down."* `sort` accepts only a single value (`indexed`) and `order` is ignored unless `sort` is set. Exposing a one-valued enum plus a deprecated companion field adds tool-schema clutter for near-zero LLM value; the default relevance ranking is what callers want in practice. If GitHub re-instates sortable code search, adding these two fields back is a 4-line diff. - -2. **`search_issues` omits the `advanced_search` opt-in.** The underlying `search/issues-and-pull-requests` endpoint accepts an `advanced_search` toggle for opting into the new search infrastructure. This plan omits that field — the default behavior is what a typical caller wants, and exposing a low-level backend switch to the LLM is scope creep beyond the design's ~5-tool estimate. - -3. **`search_labels` and `search_topics` are out of scope.** The design row for the `search` toolset lists 5 example tools (`search_code`, `search_repos`, `search_issues`, `search_users`, `search_commits`) at an estimated count of ~5. `search_labels` (needs a `repository_id`, niche use case) and `search_topics` (small utility for topic discovery) are the two remaining `octokit.rest.search.*` methods; both are intentionally not included, matching the design's scope. Either can be added in a follow-up plan without disturbing the existing 5. - -4. **`search_issues` covers BOTH issues and pull requests.** GitHub folds issues and PRs into a single searchable resource; the octokit method is named `issuesAndPullRequests` and hits `/search/issues`. The tool name follows the design (`search_issues`), and its description explicitly notes that callers should use `is:issue` or `is:pull-request` qualifiers inside `q` to scope. This is why there is no separate `search_pull_requests` tool. - ---- - -## Task 1: Implement the `search` toolset (5 read-only tools) and its tests - -**Files:** -- Create: `src/toolsets/search.ts` -- Test: `test/unit/toolsets/search.test.ts` - -**Interfaces:** -- Consumes: `paginationSchema`, `toToolResult`, `toToolError` from `./common.js` (already exist — no modification needed). -- Produces (for Task 2 / `server.ts` to consume): - - `registerSearchTools(server: McpServer, octokit: Octokit, permission: 'read-only' | 'read-write'): void` — same signature shape as `registerReposTools`/`registerIssuesTools`/`registerPullRequestsTools`. The `permission` parameter is accepted for signature uniformity but never inspected inside the function (all 5 tools are read-only). - -This task is intentionally larger than most Task 1s because the 5 tools are near-identical shell around 5 near-identical GitHub endpoints — splitting them across multiple tasks would produce a series of near-copies with no meaningful review gate between them. The right review gate for this toolset is "are all 5 tools registered correctly and the tests exercising them green?", which is one gate, not five. - -- [x] **Step 1: Write the failing tests** - -Create `test/unit/toolsets/search.test.ts`: - -```typescript -import nock from 'nock'; -import { afterEach, describe, expect, it } from 'vitest'; -import { registerSearchTools } from '../../../src/toolsets/search.js'; -import { connectedClient } from './test-helpers.js'; - -describe('registerSearchTools', () => { - afterEach(() => { - nock.cleanAll(); - }); - - it('registers search_repos and returns the raw GitHub response envelope as JSON', async () => { - nock('https://api.github.com') - .get('/search/repositories') - .query({ q: 'tetris language:assembly', page: '1', per_page: '30' }) - .reply(200, { - total_count: 1, - incomplete_results: false, - items: [{ id: 1, full_name: 'octocat/tetris' }], - }); - - const client = await connectedClient(registerSearchTools, 'read-write'); - const result = await client.callTool({ - name: 'search_repos', - arguments: { q: 'tetris language:assembly' }, - }); - - expect(result.isError).toBeFalsy(); - const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ''; - expect(JSON.parse(text)).toEqual({ - total_count: 1, - incomplete_results: false, - items: [{ id: 1, full_name: 'octocat/tetris' }], - }); - }); - - it('forwards sort, order, and pagination on search_repos to the wire', async () => { - const scope = nock('https://api.github.com') - .get('/search/repositories') - .query({ - q: 'tetris', - sort: 'stars', - order: 'desc', - page: '2', - per_page: '50', - }) - .reply(200, { total_count: 0, incomplete_results: false, items: [] }); - - const client = await connectedClient(registerSearchTools, 'read-write'); - const result = await client.callTool({ - name: 'search_repos', - arguments: { - q: 'tetris', - sort: 'stars', - order: 'desc', - page: 2, - per_page: 50, - }, - }); - - expect(result.isError).toBeFalsy(); - expect(scope.isDone()).toBe(true); - }); - - it('propagates a 422 (malformed query) as an MCP tool error with the raw GitHub message', async () => { - nock('https://api.github.com') - .get('/search/repositories') - .query({ q: '', page: '1', per_page: '30' }) - .reply(422, { - message: 'Validation Failed', - documentation_url: 'https://docs.github.com/rest/search/search', - }); - - const client = await connectedClient(registerSearchTools, 'read-write'); - const result = await client.callTool({ - name: 'search_repos', - arguments: { q: '' }, - }); - - expect(result.isError).toBe(true); - const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ''; - expect(text).toContain('Validation Failed'); - }); - - it('registers search_code and returns the raw GitHub response envelope as JSON', async () => { - nock('https://api.github.com') - .get('/search/code') - .query({ q: 'addClass repo:jquery/jquery', page: '1', per_page: '30' }) - .reply(200, { - total_count: 2, - incomplete_results: false, - items: [{ path: 'src/attributes/classes.js' }, { path: 'test/unit/attributes.js' }], - }); - - const client = await connectedClient(registerSearchTools, 'read-write'); - const result = await client.callTool({ - name: 'search_code', - arguments: { q: 'addClass repo:jquery/jquery' }, - }); - - expect(result.isError).toBeFalsy(); - const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ''; - expect(JSON.parse(text)).toMatchObject({ - total_count: 2, - items: expect.arrayContaining([ - expect.objectContaining({ path: 'src/attributes/classes.js' }), - ]) as unknown, - }); - }); - - it('registers search_commits and forwards its endpoint-specific sort values', async () => { - const scope = nock('https://api.github.com') - .get('/search/commits') - .query({ - q: 'repo:octocat/Spoon-Knife css', - sort: 'committer-date', - order: 'asc', - page: '1', - per_page: '30', - }) - .reply(200, { total_count: 1, incomplete_results: false, items: [{ sha: 'abc123' }] }); - - const client = await connectedClient(registerSearchTools, 'read-write'); - const result = await client.callTool({ - name: 'search_commits', - arguments: { - q: 'repo:octocat/Spoon-Knife css', - sort: 'committer-date', - order: 'asc', - }, - }); - - expect(result.isError).toBeFalsy(); - expect(scope.isDone()).toBe(true); - }); - - it('registers search_issues and forwards its issue-specific sort value', async () => { - const scope = nock('https://api.github.com') - .get('/search/issues') - .query({ - q: 'windows label:bug language:python state:open', - sort: 'created', - order: 'asc', - page: '1', - per_page: '30', - }) - .reply(200, { - total_count: 3, - incomplete_results: false, - items: [{ number: 42, title: 'a bug' }], - }); - - const client = await connectedClient(registerSearchTools, 'read-write'); - const result = await client.callTool({ - name: 'search_issues', - arguments: { - q: 'windows label:bug language:python state:open', - sort: 'created', - order: 'asc', - }, - }); - - expect(result.isError).toBeFalsy(); - expect(scope.isDone()).toBe(true); - }); - - it('registers search_users and returns the raw GitHub response envelope as JSON', async () => { - nock('https://api.github.com') - .get('/search/users') - .query({ q: 'tom repos:>42 followers:>1000', page: '1', per_page: '30' }) - .reply(200, { - total_count: 1, - incomplete_results: false, - items: [{ login: 'tomasz', id: 7 }], - }); - - const client = await connectedClient(registerSearchTools, 'read-write'); - const result = await client.callTool({ - name: 'search_users', - arguments: { q: 'tom repos:>42 followers:>1000' }, - }); - - expect(result.isError).toBeFalsy(); - const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ''; - expect(JSON.parse(text)).toMatchObject({ items: [{ login: 'tomasz' }] }); - }); - - it('registers exactly the 5 search tools in read-only mode (and the same set in read-write)', async () => { - const expected = [ - 'search_code', - 'search_commits', - 'search_issues', - 'search_repos', - 'search_users', - ]; - - const readOnlyClient = await connectedClient(registerSearchTools, 'read-only'); - const readOnlyTools = await readOnlyClient.listTools(); - expect(readOnlyTools.tools.map((t) => t.name).sort()).toEqual(expected); - - const readWriteClient = await connectedClient(registerSearchTools, 'read-write'); - const readWriteTools = await readWriteClient.listTools(); - expect(readWriteTools.tools.map((t) => t.name).sort()).toEqual(expected); - }); -}); -``` - -- [x] **Step 2: Run tests to verify they fail** - -Run: `npm test -- search` -Expected: FAIL with a module-not-found error for `../../../src/toolsets/search.js` (the file doesn't exist yet). - -- [x] **Step 3: Create `src/toolsets/search.ts`** - -```typescript -import type { McpServer } from '@modelcontextprotocol/server'; -import type { Octokit } from 'octokit'; -import { z } from 'zod'; -import { paginationSchema, toToolResult, toToolError } from './common.js'; - -const orderSchema = z - .enum(['asc', 'desc']) - .optional() - .describe('Sort direction. Ignored unless sort is set.'); - -export function registerSearchTools( - server: McpServer, - octokit: Octokit, - _permission: 'read-only' | 'read-write', -): void { - server.registerTool( - 'search_repos', - { - description: - 'Search GitHub repositories via various criteria. The q parameter accepts GitHub search qualifiers (e.g. "tetris language:assembly stars:>100"). Returns up to 100 results per page; GitHub caps total results at 1000.', - inputSchema: z.object({ - q: z.string().describe('Search query. Accepts GitHub search qualifiers like language:, stars:, forks:, user:, org:, topic:.'), - sort: z - .enum(['stars', 'forks', 'help-wanted-issues', 'updated']) - .optional() - .describe('Field to sort results by. Defaults to relevance if omitted.'), - order: orderSchema, - ...paginationSchema, - }), - }, - async ({ q, sort, order, page, per_page }) => { - try { - const response = await octokit.rest.search.repos({ q, sort, order, page, per_page }); - return toToolResult(response.data); - } catch (error) { - return toToolError(error); - } - }, - ); - - server.registerTool( - 'search_code', - { - description: - 'Search code across GitHub. The q parameter accepts GitHub code-search qualifiers (e.g. "addClass repo:jquery/jquery in:file language:js"). Sort and order are omitted because GitHub is closing down sortable code search; results are ranked by relevance.', - inputSchema: z.object({ - q: z.string().describe('Search query. Accepts qualifiers like repo:, path:, language:, in:file, in:path.'), - ...paginationSchema, - }), - }, - async ({ q, page, per_page }) => { - try { - const response = await octokit.rest.search.code({ q, page, per_page }); - return toToolResult(response.data); - } catch (error) { - return toToolError(error); - } - }, - ); - - server.registerTool( - 'search_commits', - { - description: - 'Search commits on the default branch of repositories. The q parameter accepts GitHub commit-search qualifiers (e.g. "repo:octocat/Spoon-Knife css author:octocat").', - inputSchema: z.object({ - q: z.string().describe('Search query. Accepts qualifiers like repo:, author:, committer:, hash:, merge:, is:merge.'), - sort: z - .enum(['author-date', 'committer-date']) - .optional() - .describe('Field to sort results by. Defaults to relevance if omitted.'), - order: orderSchema, - ...paginationSchema, - }), - }, - async ({ q, sort, order, page, per_page }) => { - try { - const response = await octokit.rest.search.commits({ q, sort, order, page, per_page }); - return toToolResult(response.data); - } catch (error) { - return toToolError(error); - } - }, - ); - - server.registerTool( - 'search_issues', - { - description: - 'Search GitHub issues AND pull requests. GitHub treats issues and pull requests as a single searchable resource; scope with is:issue or is:pull-request qualifiers inside q. Example q: "windows label:bug language:python state:open is:issue".', - inputSchema: z.object({ - q: z.string().describe('Search query. Use is:issue or is:pull-request to scope; accepts qualifiers like label:, language:, state:, author:, assignee:.'), - sort: z - .enum([ - 'comments', - 'reactions', - 'reactions-+1', - 'reactions--1', - 'reactions-smile', - 'reactions-thinking_face', - 'reactions-heart', - 'reactions-tada', - 'interactions', - 'created', - 'updated', - ]) - .optional() - .describe('Field to sort results by. Defaults to relevance if omitted.'), - order: orderSchema, - ...paginationSchema, - }), - }, - async ({ q, sort, order, page, per_page }) => { - try { - const response = await octokit.rest.search.issuesAndPullRequests({ - q, - sort, - order, - page, - per_page, - }); - return toToolResult(response.data); - } catch (error) { - return toToolError(error); - } - }, - ); - - server.registerTool( - 'search_users', - { - description: - 'Search GitHub users. Only returns publicly visible users. The q parameter accepts GitHub user-search qualifiers (e.g. "tom repos:>42 followers:>1000").', - inputSchema: z.object({ - q: z.string().describe('Search query. Accepts qualifiers like type:user, type:org, repos:, followers:, location:, language:.'), - sort: z - .enum(['followers', 'repositories', 'joined']) - .optional() - .describe('Field to sort results by. Defaults to relevance if omitted.'), - order: orderSchema, - ...paginationSchema, - }), - }, - async ({ q, sort, order, page, per_page }) => { - try { - const response = await octokit.rest.search.users({ q, sort, order, page, per_page }); - return toToolResult(response.data); - } catch (error) { - return toToolError(error); - } - }, - ); -} -``` - -- [x] **Step 4: Run tests to verify they pass** - -Run: `npm test -- search` -Expected: PASS (8 tests in `search.test.ts`). - -- [x] **Step 5: Run the full test suite to confirm no regression in other toolsets** - -Run: `npm test` -Expected: PASS. Total test count is the previous suite total (66 tests as of commit `bdeab8c`) plus the 8 new tests in `search.test.ts` = 74 tests. No pre-existing test file is modified; `common.ts` is unchanged so `common.test.ts` still passes verbatim. - -- [x] **Step 6: Commit** - -```bash -git add src/toolsets/search.ts test/unit/toolsets/search.test.ts -git commit -m "feat: add search toolset (5 read-only tools)" -``` - ---- - -## Task 2: Wire `registerSearchTools` into `server.ts` - -**Files:** -- Modify: `src/server.ts` -- Modify: `README.md` (Toolsets section — same section the prior two plans updated) - -**Interfaces:** -- Consumes: `registerSearchTools(server, octokit, permission)` from Task 1. -- Produces: nothing new — this is the final integration point. - -- [x] **Step 1: Modify `src/server.ts`** - -Current content: - -```typescript -import { McpServer } from '@modelcontextprotocol/server'; -import type { Octokit } from 'octokit'; -import { registerIssuesTools } from './toolsets/issues.js'; -import { registerPullRequestsTools } from './toolsets/pull_requests.js'; -import { registerReposTools } from './toolsets/repos.js'; - -const SERVER_NAME = 'github-mcp-server-js'; -const SERVER_VERSION = '0.1.0'; - -export function buildServer( - octokit: Octokit, - permission: 'read-only' | 'read-write', -): McpServer { - const server = new McpServer({ name: SERVER_NAME, version: SERVER_VERSION }); - - registerReposTools(server, octokit, permission); - registerIssuesTools(server, octokit, permission); - registerPullRequestsTools(server, octokit, permission); - - return server; -} -``` - -Replace with (imports sorted alphabetically to match existing convention): - -```typescript -import { McpServer } from '@modelcontextprotocol/server'; -import type { Octokit } from 'octokit'; -import { registerIssuesTools } from './toolsets/issues.js'; -import { registerPullRequestsTools } from './toolsets/pull_requests.js'; -import { registerReposTools } from './toolsets/repos.js'; -import { registerSearchTools } from './toolsets/search.js'; - -const SERVER_NAME = 'github-mcp-server-js'; -const SERVER_VERSION = '0.1.0'; - -export function buildServer( - octokit: Octokit, - permission: 'read-only' | 'read-write', -): McpServer { - const server = new McpServer({ name: SERVER_NAME, version: SERVER_VERSION }); - - registerReposTools(server, octokit, permission); - registerIssuesTools(server, octokit, permission); - registerPullRequestsTools(server, octokit, permission); - registerSearchTools(server, octokit, permission); - - return server; -} -``` - -- [x] **Step 2: Run the full test suite** - -Run: `npm test` -Expected: PASS, same 74 tests as after Task 1 (no test exercises `server.ts` directly, following the same precedent as the prior three toolset plans — `buildServer` is a thin, non-branching composition function verified by the manual smoke test below plus the pre-existing CLI smoke test from the core plan). - -- [x] **Step 3: Typecheck, lint, build** - -Run: `npm run typecheck && npm run lint && npm run build` -Expected: all PASS. The build step confirms `dist/cli.js`'s bundled dependency graph now transitively includes `search.ts`. - -- [x] **Step 4: Manual end-to-end smoke test** - -Run: -```bash -GITHUB_TOKEN=fake-token node dist/cli.js --transport=http --port=3993 & -sleep 1 -curl -s -D /tmp/mcp-init-headers.txt -X POST http://localhost:3993/mcp \ - -H 'Content-Type: application/json' \ - -H 'Accept: application/json, text/event-stream' \ - -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"smoke-test","version":"0.0.0"}}}' -SESSION=$(grep -i mcp-session-id /tmp/mcp-init-headers.txt | awk '{print $2}' | tr -d '\r') -curl -s -X POST http://localhost:3993/mcp \ - -H 'Content-Type: application/json' \ - -H 'Accept: application/json, text/event-stream' \ - -H "Mcp-Session-Id: $SESSION" \ - -d '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' -kill %1 -``` - -Expected: the `initialize` response contains `"serverInfo":{"name":"github-mcp-server-js"...}`. The `tools/list` response includes `search_repos`, `search_code`, `search_commits`, `search_issues`, and `search_users` — plus all previously-shipped tools from `repos`, `issues`, and `pull_requests` — proving all four toolsets are live in the same server with no duplicate-registration crash. - -- [x] **Step 5: Update the README's Toolsets section** - -Modify `README.md`'s "Currently implemented" list (the same section the `pull_requests` plan updated) to add: - -```markdown -- `search` — code, repo, commit, issue/PR, and user search -``` - -Slot it after the existing `pull_requests` bullet, keeping the toolsets listed in the order they were shipped. - -- [x] **Step 6: Commit** - -```bash -git add src/server.ts README.md -git commit -m "feat: wire search toolset into buildServer" -``` - ---- - -## Self-Review Notes - -- **Spec coverage:** `search` toolset (Toolset Inventory row: octokit `search` namespace, example tools `search_code, search_repos, search_issues, search_users, search_commits`, est. count ~5) — all 5 named examples are covered, matching the estimated count exactly. Pagination (`page`/`per_page` defaults, max 100) — Task 1. Raw JSON response/error passthrough — Task 1. Registration-time permission gating — Task 1 (a no-op here because every tool is read-only; the read-only test still asserts the exact 5-name set is registered, so a future accidental permission-gated tool inside this file would fail the test). `search_labels` and `search_topics` are explicitly out of scope (see Deliberate scope decisions in the Reference section above). -- **Lessons applied from the prior three toolset plans:** - - **`issues` I1 (strict permission-gating equality):** Task 1's read-only test uses `expect(tools.map((t) => t.name).sort()).toEqual([...exact 5 names...])`, not `arrayContaining`. It also runs the same assertion against a `read-write` client, which is what proves this toolset has no hidden write branch. - - **`issues` I3 (wire-level filter passthrough):** Task 1's `search_repos` filter test uses `nock(...).query({q, sort, order, page, per_page})` plus `expect(scope.isDone()).toBe(true)`, proving every optional filter param is actually forwarded on the wire. `search_commits` and `search_issues` filter tests do the same for their endpoint-specific `sort` enums, catching any typo in the `sort:` field name that would have the octokit method silently drop the value. - - **`pull_requests` M8 (tool-name collision check):** performed explicitly before finalizing names — see Global Constraints. All 5 names (`search_repos`, `search_code`, `search_commits`, `search_issues`, `search_users`) were checked against the 30 existing names and are all distinct. The `search_` prefix on every tool is deliberate: it groups them together in the LLM's tools/list output and eliminates any collision surface with the more generic verbs (`get_repository`, `list_commits`, etc.) already in use. -- **Type consistency:** `registerSearchTools(server: McpServer, octokit: Octokit, permission: 'read-only' | 'read-write'): void` (Task 1) matches `registerReposTools`/`registerIssuesTools`/`registerPullRequestsTools`'s signature exactly and is called identically in `server.ts` (Task 2). The `permission` parameter is renamed `_permission` inside the function body to satisfy the `@typescript-eslint/no-unused-vars` rule from `eslint.config.js` (`argsIgnorePattern: '^_'`) — this is the standard escape hatch used across the codebase for interface-uniform parameters that a particular implementation doesn't need. -- **No placeholders:** every step includes complete, runnable code. All octokit method names, HTTP verbs, path templates, and query parameter shapes were verified directly against the installed `@octokit/plugin-rest-endpoint-methods` generated endpoint table (via `octokit.rest.search[name].endpoint.DEFAULTS`) and the `@octokit/openapi-types` operation definitions (`types.d.ts` lines 116675–116963) — not memorized or guessed. Every intentionally omitted field (`search_code`'s deprecated `sort`/`order`, `search_issues`'s `advanced_search`, and the excluded `search_labels`/`search_topics` tools) is called out explicitly in the Deliberate scope decisions section, not left as an implicit gap. -- **Task right-sizing note:** Task 1 bundles all 5 tools + their tests into a single reviewer gate. The tools are near-identical shells over near-identical GitHub endpoints; splitting them into 5 tasks (or even a "one read tool" + "four more" pair) would produce a series of copy-paste reviews with no meaningful decision between them. Task 2 remains a separate gate because wiring is where duplicate-registration crashes and stale-README rot surface — same pattern the prior three toolset plans use. diff --git a/docs/superpowers/plans/2026-08-05-github-mcp-server-users.md b/docs/superpowers/plans/2026-08-05-github-mcp-server-users.md deleted file mode 100644 index a22c3ab..0000000 --- a/docs/superpowers/plans/2026-08-05-github-mcp-server-users.md +++ /dev/null @@ -1,635 +0,0 @@ -# github-mcp-server-js — `users` Toolset Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax for tracking. - -**Goal:** Add the `users` toolset (5 read-only tools wrapping GitHub's REST user-info endpoints — get user by username, get the authenticated user, list followers, list following, get hovercard context) to `github-mcp-server-js`, following the exact structural, permission-gating, and response/error pattern established by the `repos`, `issues`, `pull_requests`, and `search` toolsets. - -**Architecture:** Same pattern as prior toolsets: one `registerUsersTools(server, octokit, permission)` function registers each tool with `server.registerTool(name, {description, inputSchema}, handler)`; every handler wraps its octokit call in try/catch, returning raw JSON via `toToolResult`/`toToolError`; `server.ts` gains one more registration call. All octokit calls use the `users` namespace (`octokit.rest.users.*`). Because every users endpoint selected for this toolset is read-only, the `permission === 'read-write'` branch is unused — the `permission` parameter is accepted (to keep the signature uniform across every `register*Tools` function) but never inspected. - -**Tech Stack:** Same as prior plans — TypeScript, `@modelcontextprotocol/server@^2.0.0`, `octokit@^5.0.5`, `zod@^4.2.0`, `vitest`, `nock`. No new dependencies. - -## Global Constraints - -- Tool responses return the raw octokit response body as JSON, unmodified. No field trimming, no text summarization. `get_user_by_username` and `get_authenticated_user` return a `public-user` or `private-user` object; list tools return `simple-user[]` arrays; `get_user_hovercard` returns a `hovercard` object. These exact shapes are preserved; callers pull fields themselves. -- Errors propagate unmodified: catch the octokit `RequestError`, surface `error.message` (which already includes the GitHub `message` field) in an MCP tool error result. No normalization layer. -- List tools take explicit `page` (default `1`) and `per_page` (default `30`, max `100`) parameters. No auto-pagination. This matches every other toolset's pagination model. -- `GITHUB_PERMISSION=read-only` gating is a no-op for this toolset because every users tool is read-only; the read-only test in Task 1 still verifies the exact set of 5 tools is registered (using `.toEqual([...exact set...])` rather than `arrayContaining`, following the "lessons applied" note from the search plan's Self-Review Notes). -- `search_users` already exists in the `search` toolset (registered as part of `registerSearchTools`) and covers user discovery via keyword/qualifier queries. The `users` toolset does NOT re-implement search-by-query; it covers direct user-info lookup and social-graph list operations. -- Tool names use `_` separators and the `get_user_` / `list_user_` prefix pattern where disambiguation from other toolsets requires it. See collision check below. -- Verified tool-name collision check against the 35 existing tool names on `main` (8 from `repos.ts`, 12 from `issues.ts`, 10 from `pull_requests.ts`, 5 from `search.ts`): **zero collisions** with the 5 tool names chosen below (`get_user_by_username`, `get_authenticated_user`, `list_user_followers`, `list_user_following`, `get_user_hovercard`). The `get_user_` prefix was deliberately chosen over bare `get_user` because `get_user` is ambiguous and collides with an intuitive future tool name in `orgs_teams` (which could plausibly expose a `get_user`). `McpServer.registerTool` throws at registration time on duplicate names, so this was confirmed before finalizing names. -- No modification to `common.ts`. -- TypeScript only, no new runtime dependencies. - ---- - -## File Structure - -``` -github-mcp-server-js/ - src/ - toolsets/ - users.ts # NEW: registerUsersTools(server, octokit, permission) - server.ts # MODIFIED: calls registerUsersTools - test/ - unit/ - toolsets/ - users.test.ts # NEW: mirrors search.test.ts's structure -``` - -`common.ts` is NOT modified — no new shared schema fragment is warranted (see Global Constraints above). `README.md`'s Toolsets section is updated in Task 2 (Step 5), same as the `search`/`pull_requests` plans did. - ---- - -## Reference: verified octokit `users` namespace shapes - -Confirmed directly against the installed `@octokit/plugin-rest-endpoint-methods` generated endpoint table (`octokit.rest.users[name].endpoint.DEFAULTS`) and the `@octokit/openapi-types` operation definitions at `node_modules/@octokit/openapi-types/types.d.ts`. All 5 selected endpoints are `GET`; none require a request body. - -| Tool | octokit method | HTTP path | Path params | Query params | Response body | -|---|---|---|---|---|---| -| `get_user_by_username` | `users.getByUsername` | GET `/users/{username}` | `username` | — | `public-user \| private-user` | -| `get_authenticated_user` | `users.getAuthenticated` | GET `/user` | — | — | `public-user \| private-user` | -| `list_user_followers` | `users.listFollowersForUser` | GET `/users/{username}/followers` | `username` | `page`, `per_page` | `simple-user[]` | -| `list_user_following` | `users.listFollowingForUser` | GET `/users/{username}/following` | `username` | `page`, `per_page` | `simple-user[]` | -| `get_user_hovercard` | `users.getContextForUser` | GET `/users/{username}/hovercard` | `username` | `subject_type`, `subject_id` | `hovercard` | - -Response body notes (raw passthrough, all `200 OK`): -- `get_user_by_username` / `get_authenticated_user` → `{ login, id, avatar_url, html_url, name, company, blog, location, email, public_repos, followers, following, created_at, ... }` (public-user shape). `get_authenticated_user` may include additional private fields (`private_gists`, `total_private_repos`, `plan`, etc.) when using a token with `user` scope. -- `list_user_followers` / `list_user_following` → `[{ login, id, avatar_url, html_url, ... }, ...]` (simple-user array). -- `get_user_hovercard` → `{ contexts: [{ message: string, octicon: string }, ...] }`. -- Non-2xx errors surface as `RequestError` and are handled by the same catch/`toToolError` path as every other tool. - -**Deliberate scope decisions (see full section below for rationale):** - -All read-only `octokit.rest.users.*` methods are accounted for: the 5 selected tools plus the explicitly-excluded methods listed in the "Deliberate scope decisions" section below. - ---- - -## Task 1: Implement the `users` toolset (5 read-only tools) and its tests - -**Files:** -- Create: `src/toolsets/users.ts` -- Test: `test/unit/toolsets/users.test.ts` - -**Interfaces:** -- Consumes: `paginationSchema`, `toToolResult`, `toToolError` from `./common.js` (already exist — no modification needed). -- Produces (for Task 2 / `server.ts` to consume): - - `registerUsersTools(server: McpServer, octokit: Octokit, permission: 'read-only' | 'read-write'): void` — same signature shape as `registerSearchTools`/`registerReposTools`/`registerIssuesTools`/`registerPullRequestsTools`. The `permission` parameter is accepted for signature uniformity but never inspected inside the function (all 5 tools are read-only); the parameter is renamed `_permission` inside the function body to satisfy the `@typescript-eslint/no-unused-vars` rule (`argsIgnorePattern: '^_'`). - -- [x] **Step 1: Write the failing tests** - -Create `test/unit/toolsets/users.test.ts`: - -```typescript -import nock from 'nock'; -import { afterEach, describe, expect, it } from 'vitest'; -import { registerUsersTools } from '../../../src/toolsets/users.js'; -import { connectedClient } from './test-helpers.js'; - -describe('registerUsersTools', () => { - afterEach(() => { - nock.cleanAll(); - }); - - it('registers get_user_by_username and returns the raw GitHub user object as JSON', async () => { - nock('https://api.github.com') - .get('/users/octocat') - .reply(200, { - login: 'octocat', - id: 1, - avatar_url: 'https://github.com/images/error/octocat_happy.gif', - html_url: 'https://github.com/octocat', - name: 'The Octocat', - public_repos: 8, - followers: 20, - following: 0, - }); - - const client = await connectedClient(registerUsersTools, 'read-write'); - const result = await client.callTool({ - name: 'get_user_by_username', - arguments: { username: 'octocat' }, - }); - - expect(result.isError).toBeFalsy(); - const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ''; - expect(JSON.parse(text)).toEqual({ - login: 'octocat', - id: 1, - avatar_url: 'https://github.com/images/error/octocat_happy.gif', - html_url: 'https://github.com/octocat', - name: 'The Octocat', - public_repos: 8, - followers: 20, - following: 0, - }); - }); - - it('propagates a 404 from get_user_by_username as an MCP tool error', async () => { - nock('https://api.github.com') - .get('/users/nonexistent-user-xyz-abc-999') - .reply(404, { message: 'Not Found', documentation_url: 'https://docs.github.com/rest' }); - - const client = await connectedClient(registerUsersTools, 'read-write'); - const result = await client.callTool({ - name: 'get_user_by_username', - arguments: { username: 'nonexistent-user-xyz-abc-999' }, - }); - - expect(result.isError).toBe(true); - const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ''; - expect(text).toContain('Not Found'); - }); - - it('registers get_authenticated_user and returns the raw GitHub user object as JSON', async () => { - nock('https://api.github.com') - .get('/user') - .reply(200, { - login: 'monalisa', - id: 2, - avatar_url: 'https://github.com/images/error/monalisa.png', - html_url: 'https://github.com/monalisa', - name: 'monalisa octocat', - private_gists: 3, - total_private_repos: 1, - followers: 100, - following: 5, - }); - - const client = await connectedClient(registerUsersTools, 'read-write'); - const result = await client.callTool({ - name: 'get_authenticated_user', - arguments: {}, - }); - - expect(result.isError).toBeFalsy(); - const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ''; - expect(JSON.parse(text)).toMatchObject({ - login: 'monalisa', - id: 2, - private_gists: 3, - }); - }); - - it('propagates a 401 from get_authenticated_user as an MCP tool error', async () => { - nock('https://api.github.com') - .get('/user') - .reply(401, { - message: 'Requires authentication', - documentation_url: 'https://docs.github.com/rest', - }); - - const client = await connectedClient(registerUsersTools, 'read-write'); - const result = await client.callTool({ - name: 'get_authenticated_user', - arguments: {}, - }); - - expect(result.isError).toBe(true); - const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ''; - expect(text).toContain('Requires authentication'); - }); - - it('registers list_user_followers and returns the raw simple-user array as JSON', async () => { - nock('https://api.github.com') - .get('/users/octocat/followers') - .query({ page: '1', per_page: '30' }) - .reply(200, [ - { login: 'follower1', id: 10, avatar_url: 'https://github.com/images/a.png', html_url: 'https://github.com/follower1' }, - { login: 'follower2', id: 11, avatar_url: 'https://github.com/images/b.png', html_url: 'https://github.com/follower2' }, - ]); - - const client = await connectedClient(registerUsersTools, 'read-write'); - const result = await client.callTool({ - name: 'list_user_followers', - arguments: { username: 'octocat' }, - }); - - expect(result.isError).toBeFalsy(); - const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ''; - const parsed = JSON.parse(text) as Array<{ login: string }>; - expect(parsed).toHaveLength(2); - expect(parsed[0]).toMatchObject({ login: 'follower1' }); - }); - - it('forwards pagination parameters on list_user_followers to the wire', async () => { - const scope = nock('https://api.github.com') - .get('/users/octocat/followers') - .query({ page: '2', per_page: '50' }) - .reply(200, []); - - const client = await connectedClient(registerUsersTools, 'read-write'); - const result = await client.callTool({ - name: 'list_user_followers', - arguments: { username: 'octocat', page: 2, per_page: 50 }, - }); - - expect(result.isError).toBeFalsy(); - expect(scope.isDone()).toBe(true); - }); - - it('registers list_user_following and returns the raw simple-user array as JSON', async () => { - nock('https://api.github.com') - .get('/users/octocat/following') - .query({ page: '1', per_page: '30' }) - .reply(200, [ - { login: 'followee1', id: 20, avatar_url: 'https://github.com/images/c.png', html_url: 'https://github.com/followee1' }, - ]); - - const client = await connectedClient(registerUsersTools, 'read-write'); - const result = await client.callTool({ - name: 'list_user_following', - arguments: { username: 'octocat' }, - }); - - expect(result.isError).toBeFalsy(); - const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ''; - expect(JSON.parse(text)).toMatchObject([{ login: 'followee1' }]); - }); - - it('registers get_user_hovercard and returns the raw hovercard object as JSON', async () => { - nock('https://api.github.com') - .get('/users/octocat/hovercard') - .query({ subject_type: 'repository', subject_id: '1296269' }) - .reply(200, { - contexts: [ - { message: 'Owns this repository', octicon: 'repo' }, - ], - }); - - const client = await connectedClient(registerUsersTools, 'read-write'); - const result = await client.callTool({ - name: 'get_user_hovercard', - arguments: { - username: 'octocat', - subject_type: 'repository', - subject_id: '1296269', - }, - }); - - expect(result.isError).toBeFalsy(); - const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ''; - expect(JSON.parse(text)).toEqual({ - contexts: [{ message: 'Owns this repository', octicon: 'repo' }], - }); - }); - - it('registers get_user_hovercard without subject context (bare hovercard)', async () => { - nock('https://api.github.com') - .get('/users/octocat/hovercard') - .query({}) - .reply(200, { - contexts: [], - }); - - const client = await connectedClient(registerUsersTools, 'read-write'); - const result = await client.callTool({ - name: 'get_user_hovercard', - arguments: { username: 'octocat' }, - }); - - expect(result.isError).toBeFalsy(); - const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? ''; - expect(JSON.parse(text)).toMatchObject({ contexts: [] }); - }); - - it('registers exactly the 5 users tools in read-only mode (and the same set in read-write)', async () => { - const expected = [ - 'get_authenticated_user', - 'get_user_by_username', - 'get_user_hovercard', - 'list_user_followers', - 'list_user_following', - ]; - - const readOnlyClient = await connectedClient(registerUsersTools, 'read-only'); - const readOnlyTools = await readOnlyClient.listTools(); - expect(readOnlyTools.tools.map((t) => t.name).sort()).toEqual(expected); - - const readWriteClient = await connectedClient(registerUsersTools, 'read-write'); - const readWriteTools = await readWriteClient.listTools(); - expect(readWriteTools.tools.map((t) => t.name).sort()).toEqual(expected); - }); -}); -``` - -- [x] **Step 2: Run tests to verify they fail** - -Run: `npm test -- users` -Expected: FAIL with a module-not-found error for `../../../src/toolsets/users.js` (the file doesn't exist yet). - -- [x] **Step 3: Create `src/toolsets/users.ts`** - -```typescript -import type { McpServer } from '@modelcontextprotocol/server'; -import type { Octokit } from 'octokit'; -import { z } from 'zod'; -import { paginationSchema, toToolResult, toToolError } from './common.js'; - -export function registerUsersTools( - server: McpServer, - octokit: Octokit, - _permission: 'read-only' | 'read-write', -): void { - server.registerTool( - 'get_user_by_username', - { - description: - 'Get publicly available information about a GitHub user by their login (username). Returns profile data including name, bio, location, public repo count, follower count, and following count. Returns a 404 if the user does not exist or is an Enterprise Managed User not visible to the caller.', - inputSchema: z.object({ - username: z.string().describe('The GitHub username (login) of the user to look up.'), - }), - }, - async ({ username }) => { - try { - const response = await octokit.rest.users.getByUsername({ username }); - return toToolResult(response.data); - } catch (error) { - return toToolError(error); - } - }, - ); - - server.registerTool( - 'get_authenticated_user', - { - description: - 'Get the profile of the currently authenticated user (the owner of the GITHUB_TOKEN in use). Returns the same public fields as get_user_by_username plus private fields (private_gists, total_private_repos, plan, etc.) that are visible only to the token owner, subject to token scope.', - inputSchema: z.object({}), - }, - async () => { - try { - const response = await octokit.rest.users.getAuthenticated(); - return toToolResult(response.data); - } catch (error) { - return toToolError(error); - } - }, - ); - - server.registerTool( - 'list_user_followers', - { - description: - 'List the users who follow a given GitHub user. Returns an array of simple-user objects, each with login, id, avatar_url, and html_url. Paginate with page and per_page.', - inputSchema: z.object({ - username: z.string().describe('The GitHub username (login) whose followers to list.'), - ...paginationSchema, - }), - }, - async ({ username, page, per_page }) => { - try { - const response = await octokit.rest.users.listFollowersForUser({ - username, - page, - per_page, - }); - return toToolResult(response.data); - } catch (error) { - return toToolError(error); - } - }, - ); - - server.registerTool( - 'list_user_following', - { - description: - 'List the users that a given GitHub user follows. Returns an array of simple-user objects, each with login, id, avatar_url, and html_url. Paginate with page and per_page.', - inputSchema: z.object({ - username: z.string().describe('The GitHub username (login) whose following list to retrieve.'), - ...paginationSchema, - }), - }, - async ({ username, page, per_page }) => { - try { - const response = await octokit.rest.users.listFollowingForUser({ - username, - page, - per_page, - }); - return toToolResult(response.data); - } catch (error) { - return toToolError(error); - } - }, - ); - - server.registerTool( - 'get_user_hovercard', - { - description: - 'Get contextual information about a GitHub user (their "hovercard") as it would appear in the GitHub web UI. Returns a list of context messages (e.g. "Owns this repository", "Contributor"). Optionally scope the context to a specific subject (a repository, issue, pull request, or organization) by providing subject_type and subject_id together — both are required when either is supplied.', - inputSchema: z.object({ - username: z.string().describe('The GitHub username (login) to get hovercard context for.'), - subject_type: z - .enum(['organization', 'repository', 'issue', 'pull_request']) - .optional() - .describe( - 'The entity type that provides the context. Must be paired with subject_id. One of: organization, repository, issue, pull_request.', - ), - subject_id: z - .string() - .optional() - .describe( - 'The numeric ID (as a string) of the subject_type entity. Required when subject_type is set.', - ), - }), - }, - async ({ username, subject_type, subject_id }) => { - try { - const response = await octokit.rest.users.getContextForUser({ - username, - subject_type, - subject_id, - }); - return toToolResult(response.data); - } catch (error) { - return toToolError(error); - } - }, - ); -} -``` - -- [x] **Step 4: Run tests to verify they pass** - -Run: `npm test -- users` -Expected: PASS (9 tests in `users.test.ts`). - -- [x] **Step 5: Run the full test suite to confirm no regression in other toolsets** - -Run: `npm test` -Expected: PASS. Total test count is the previous suite total (74 tests as of the search toolset commit) plus the 9 new tests in `users.test.ts` = 83 tests. No pre-existing test file is modified; `common.ts` is unchanged so `common.test.ts` still passes verbatim. - -- [x] **Step 6: Commit** - -```bash -git add src/toolsets/users.ts test/unit/toolsets/users.test.ts -git commit -m "feat: add users toolset (5 read-only tools)" -``` - ---- - -## Task 2: Wire `registerUsersTools` into `server.ts` - -**Files:** -- Modify: `src/server.ts` -- Modify: `README.md` (Toolsets section — same section the prior plans updated) - -**Interfaces:** -- Consumes: `registerUsersTools(server, octokit, permission)` from Task 1. -- Produces: nothing new — this is the final integration point. - -- [x] **Step 1: Modify `src/server.ts`** - -Current content: - -```typescript -import { McpServer } from '@modelcontextprotocol/server'; -import type { Octokit } from 'octokit'; -import { registerIssuesTools } from './toolsets/issues.js'; -import { registerPullRequestsTools } from './toolsets/pull_requests.js'; -import { registerReposTools } from './toolsets/repos.js'; -import { registerSearchTools } from './toolsets/search.js'; - -const SERVER_NAME = 'github-mcp-server-js'; -const SERVER_VERSION = '0.1.0'; - -export function buildServer( - octokit: Octokit, - permission: 'read-only' | 'read-write', -): McpServer { - const server = new McpServer({ name: SERVER_NAME, version: SERVER_VERSION }); - - registerReposTools(server, octokit, permission); - registerIssuesTools(server, octokit, permission); - registerPullRequestsTools(server, octokit, permission); - registerSearchTools(server, octokit, permission); - - return server; -} -``` - -Replace with (imports sorted alphabetically to match existing convention): - -```typescript -import { McpServer } from '@modelcontextprotocol/server'; -import type { Octokit } from 'octokit'; -import { registerIssuesTools } from './toolsets/issues.js'; -import { registerPullRequestsTools } from './toolsets/pull_requests.js'; -import { registerReposTools } from './toolsets/repos.js'; -import { registerSearchTools } from './toolsets/search.js'; -import { registerUsersTools } from './toolsets/users.js'; - -const SERVER_NAME = 'github-mcp-server-js'; -const SERVER_VERSION = '0.1.0'; - -export function buildServer( - octokit: Octokit, - permission: 'read-only' | 'read-write', -): McpServer { - const server = new McpServer({ name: SERVER_NAME, version: SERVER_VERSION }); - - registerReposTools(server, octokit, permission); - registerIssuesTools(server, octokit, permission); - registerPullRequestsTools(server, octokit, permission); - registerSearchTools(server, octokit, permission); - registerUsersTools(server, octokit, permission); - - return server; -} -``` - -- [x] **Step 2: Run the full test suite** - -Run: `npm test` -Expected: PASS, same 83 tests as after Task 1 (no test exercises `server.ts` directly, following the same precedent as the prior four toolset plans — `buildServer` is a thin, non-branching composition function verified by the manual smoke test below). - -- [x] **Step 3: Typecheck, lint, build** - -Run: `npm run typecheck && npm run lint && npm run build` -Expected: all PASS. The build step confirms `dist/cli.js`'s bundled dependency graph now transitively includes `users.ts`. - -- [x] **Step 4: Manual end-to-end smoke test** - -Run: -```bash -GITHUB_TOKEN=fake-token node dist/cli.js --transport=http --port=3994 & -sleep 1 -curl -s -D /tmp/mcp-init-headers.txt -X POST http://localhost:3994/mcp \ - -H 'Content-Type: application/json' \ - -H 'Accept: application/json, text/event-stream' \ - -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"smoke-test","version":"0.0.0"}}}' -SESSION=$(grep -i mcp-session-id /tmp/mcp-init-headers.txt | awk '{print $2}' | tr -d '\r') -curl -s -X POST http://localhost:3994/mcp \ - -H 'Content-Type: application/json' \ - -H 'Accept: application/json, text/event-stream' \ - -H "Mcp-Session-Id: $SESSION" \ - -d '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' -kill %1 -``` - -Expected: the `initialize` response contains `"serverInfo":{"name":"github-mcp-server-js"...}`. The `tools/list` response includes `get_user_by_username`, `get_authenticated_user`, `list_user_followers`, `list_user_following`, and `get_user_hovercard` — plus all previously-shipped tools from `repos`, `issues`, `pull_requests`, and `search` — proving all five toolsets are live in the same server with no duplicate-registration crash. - -- [x] **Step 5: Update the README's Toolsets section** - -Modify `README.md`'s "Currently implemented" list (the same section the `search` plan updated) to add: - -```markdown -- `users` — user profile lookup, authenticated user info, followers, following, hovercard context -``` - -Slot it after the existing `search` bullet, keeping the toolsets listed in the order they were shipped. - -- [x] **Step 6: Commit** - -```bash -git add src/server.ts README.md -git commit -m "feat: wire users toolset into buildServer" -``` - ---- - -## Deliberate scope decisions - -The following `octokit.rest.users.*` methods were introspected (via `octokit.rest.users[name].endpoint.DEFAULTS`) and are **intentionally excluded** from this toolset. Each exclusion is justified below. - -1. **`users.list` (GET `/users`) — out of scope.** Lists all GitHub users globally (cursor-based `since` pagination, returns `simple-user[]`). This is a directory traversal endpoint with no useful per-LLM-session meaning; user discovery is already served by `search_users` in the `search` toolset. Exposing a raw paginated dump of all GitHub accounts provides no practical value and would produce confusingly large responses without meaningful filtering. - -2. **`users.getById` (GET `/user/{account_id}`) — out of scope.** Looks up a user by their internal numeric `account_id`. In practice, callers rarely have a numeric account ID without already having the username; `get_user_by_username` covers the common lookup path. `getById` is primarily useful as an internal stability endpoint (usernames can change; IDs cannot) and is a niche advanced use case not warranted in a ~5-tool scope. - -3. **`users.block` / `users.unblock` / `users.checkBlocked` / `users.listBlockedByAuthenticatedUser` — write operations / account-management, excluded.** `block` (PUT `/user/blocks/{username}`) and `unblock` (DELETE) are write/mutating operations and belong in the `activity` toolset per the design's notes (which group user-interaction writes under `activity`). `checkBlocked` (GET, 204/404 response — no body) and `listBlockedByAuthenticated` (GET `/user/blocks`) are read-only but represent private account-management state not relevant to the "user info" scope of this toolset. - -4. **`users.follow` / `users.unfollow` / `users.checkPersonIsFollowedByAuthenticated` / `users.checkFollowingForUser` / `users.listFollowedByAuthenticatedUser` / `users.listFollowersForAuthenticatedUser` — write operations or auth-user-centric variants, excluded.** `follow` (PUT) and `unfollow` (DELETE) are writes that belong in `activity`. `checkPersonIsFollowedByAuthenticated` / `checkFollowingForUser` return 204/404 with no body — they are boolean checks that communicate result via HTTP status code, which does not map cleanly to the raw-JSON `toToolResult` pattern. `listFollowersForAuthenticatedUser` (GET `/user/followers`) and `listFollowedByAuthenticatedUser` (GET `/user/following`) are redundant with `list_user_followers` / `list_user_following` when the caller passes their own username; exposing duplicates would bloat the tools list. - -5. **Email-related methods (`listEmailsForAuthenticatedUser`, `listPublicEmailsForAuthenticatedUser`, `addEmailForAuthenticatedUser`, `deleteEmailForAuthenticatedUser`, `setPrimaryEmailVisibilityForAuthenticatedUser`) — account-management, excluded.** All are either write operations or return private email data scoped to the authenticated user's own account. They are account-management tools (not user-info tools) and add no value in the read-only user-info scope. Write variants doubly excluded. - -6. **GPG key and SSH key methods (`getGpgKeyForAuthenticatedUser`, `listGpgKeysForAuthenticatedUser`, `listGpgKeysForUser`, `createGpgKeyForAuthenticatedUser`, `deleteGpgKeyForAuthenticatedUser`, `getPublicSshKeyForAuthenticatedUser`, `listPublicSshKeysForAuthenticatedUser`, `listPublicKeysForUser`, `createPublicSshKeyForAuthenticatedUser`, `deletePublicSshKeyForAuthenticatedUser`, `getSshSigningKeyForAuthenticatedUser`, `listSshSigningKeysForAuthenticatedUser`, `listSshSigningKeysForUser`, `createSshSigningKeyForAuthenticatedUser`, `deleteSshSigningKeyForAuthenticatedUser`) — account-management / niche read-only, excluded.** Write variants are excluded as mutations. Read-only variants (e.g., `listPublicKeysForUser`, `listGpgKeysForUser`, `listSshSigningKeysForUser`) expose cryptographic key metadata — niche information useful for security auditing but well outside the "user info" scope described in the design. Any of these can be added in a follow-up plan without disturbing the existing 5 tools. - -7. **Social account methods (`listSocialAccountsForUser`, `listSocialAccountsForAuthenticatedUser`, `addSocialAccountForAuthenticatedUser`, `deleteSocialAccountForAuthenticatedUser`) — niche / write, excluded.** Write variants excluded. `listSocialAccountsForUser` is read-only but returns a niche list of external social accounts (Twitter, LinkedIn, etc.) that is rarely useful compared to the richer profile data already included in `get_user_by_username`'s response body. - -8. **Attestation methods (`listAttestations`, `listAttestationsBulk`, `deleteAttestationsBulk`, `deleteAttestationsById`, `deleteAttestationsBySubjectDigest`) — security-auditing domain, excluded.** Attestation lookup is a specialized build-provenance / supply-chain security use case; it belongs with other security tooling (the `code_security` toolset is the natural home) rather than generic user-info. Write variants doubly excluded. - -9. **`users.updateAuthenticated` (PATCH `/user`) — write, excluded.** Modifies the authenticated user's profile fields. Write operation; excluded from this read-only toolset. - ---- - -## Self-Review Notes - -- **Spec coverage:** `users` toolset (Toolset Inventory row: octokit `users` namespace, example tools `get_user, get_authenticated_user, list_followers`, est. count ~5) — all 3 named examples have clear equivalents in the chosen tool set (`get_user_by_username`, `get_authenticated_user`, `list_user_followers`), the count exactly matches the ~5 estimate, and the two additional tools (`list_user_following`, `get_user_hovercard`) are the natural complements that round out the "user info and social graph" scope. Pagination (`page`/`per_page` defaults, max 100) — Task 1. Raw JSON response/error passthrough — Task 1. Registration-time permission gating — Task 1 (a no-op here because every tool is read-only; the read-only test still asserts the exact 5-name set is registered, so a future accidental permission-gated tool inside this file would fail the test). - -- **Lessons applied from the prior four toolset plans:** - - **`issues` I1 (strict permission-gating equality):** Task 1's read-only test uses `expect(tools.map((t) => t.name).sort()).toEqual([...exact 5 names...])`, not `arrayContaining`. It also runs the same assertion against a `read-write` client, which proves this toolset has no hidden write branch. - - **`issues` I3 (wire-level filter passthrough):** Task 1's pagination tests use `nock(...).query({page: '2', per_page: '50'})` plus `expect(scope.isDone()).toBe(true)`, proving every optional parameter is actually forwarded on the wire, catching any parameter-name typo that would have the octokit method silently drop the value. - - **`search` M (tool-name collision check):** performed explicitly before finalizing names — see Global Constraints. All 5 names (`get_user_by_username`, `get_authenticated_user`, `list_user_followers`, `list_user_following`, `get_user_hovercard`) were checked against the 35 existing names across `repos`, `issues`, `pull_requests`, and `search`, and are all distinct. - - **`search` M (all-read-only parameter naming):** `_permission` renaming inside the function body is applied here for the same reason — signature uniformity vs. ESLint `no-unused-vars`. - -- **Hovercard subject_type/subject_id coupling:** The OpenAPI spec states both `subject_type` and `subject_id` are required together — neither is meaningful alone (the endpoint returns a 422 if one is present without the other). The Zod schema marks both as optional to avoid making callers supply them for a generic hovercard call, but the description explicitly warns that they must be paired. A stricter Zod `.refine()` could enforce this at schema validation time; it is deliberately omitted here to match the thin-schema philosophy applied across all other toolsets in this codebase (inputs are validated to the minimum needed for a clear error, not re-implemented as a full contract layer — GitHub's own 422 response surfaces the constraint clearly enough when violated). This matches the `search` plan's handling of `sort`/`order` coupling. - -- **`get_authenticated_user` takes an empty input schema:** The underlying `GET /user` endpoint takes no parameters. `z.object({})` is the correct Zod expression for this; `server.registerTool` accepts it without issue (matches the MCP SDK's expectation that `inputSchema` is a Zod object schema). The test exercises `callTool` with `arguments: {}` to confirm the empty schema round-trips cleanly through the MCP SDK's argument validation. - -- **Type consistency:** `registerUsersTools(server: McpServer, octokit: Octokit, permission: 'read-only' | 'read-write'): void` (Task 1) matches `registerReposTools`/`registerIssuesTools`/`registerPullRequestsTools`/`registerSearchTools`'s signature exactly and is called identically in `server.ts` (Task 2). - -- **No placeholders:** every step includes complete, runnable code. All octokit method names (`getByUsername`, `getAuthenticated`, `listFollowersForUser`, `listFollowingForUser`, `getContextForUser`), HTTP verbs, path templates, and query/path parameter shapes were verified directly against the installed `@octokit/plugin-rest-endpoint-methods` endpoint table (via `octokit.rest.users[name].endpoint.DEFAULTS`) and the `@octokit/openapi-types` operation definitions in `types.d.ts` — not memorized or guessed. Every intentionally excluded method from `octokit.rest.users.*` is called out explicitly in the Deliberate scope decisions section, not left as an implicit gap. - -- **Task right-sizing note:** Task 1 bundles all 5 tools + their tests into a single reviewer gate, identical to the `search` plan's rationale. The tools are similarly near-identical shells over near-identical GitHub endpoints; splitting them into multiple tasks would produce copy-paste reviews with no meaningful decision between them. Task 2 remains a separate gate because wiring is where duplicate-registration crashes and stale-README rot surface — same pattern all four prior toolset plans use. diff --git a/docs/superpowers/plans/2026-08-06-github-mcp-server-mcpb-extension.md b/docs/superpowers/plans/2026-08-06-github-mcp-server-mcpb-extension.md deleted file mode 100644 index e71953f..0000000 --- a/docs/superpowers/plans/2026-08-06-github-mcp-server-mcpb-extension.md +++ /dev/null @@ -1,669 +0,0 @@ -# github-mcp-server-js — Claude Desktop Extension (.mcpb) Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development or superpowers:executing-plans. Steps use checkbox (`- [ ]`) syntax. - -**Goal:** Package the existing stdio MCP server as a `.mcpb` bundle so Claude Desktop users can install it by dragging the file into Settings > Extensions. - -**Architecture:** Static `manifest.json` + tsup's existing single-file `dist/cli.js` + a small PNG icon, zipped by the `@anthropic-ai/mcpb` CLI's `pack` command. A tiny Node script (`scripts/pack-mcpb.mjs`) enforces `package.json` / `manifest.json` version parity, validates the manifest, runs the build, invokes `mcpb pack`, and asserts the resulting archive contains exactly the three expected entries. Phase 1 ships the local pack workflow and is verified by a one-time manual install in Claude Desktop; Phase 2 wires the same script into a tag-triggered GitHub Actions release workflow. - -**Tech Stack:** `@anthropic-ai/mcpb@^2.1.2` (dev dep, provides `mcpb pack`/`validate`/`info`/`unpack`), Node ≥20, vitest for the version-parity unit test, `softprops/action-gh-release@v2` for asset upload. - -## Global Constraints - -- All existing verification gates keep passing: `npm test` (207/207 unit), `npm run typecheck`, `npm run lint`, `npm run spellcheck`, `npm run build`. -- The `.mcpb` bundle must contain EXACTLY three entries: `manifest.json`, `dist/cli.js`, `assets/icon.png`. Anything else is a bug; the pack script asserts this. -- `manifest.json.version` MUST equal `package.json.version` at pack time; drift is a hard error. -- `manifest.json` uses `manifest_version: "0.1"`, `server.type: "node"`, `server.entry_point: "dist/cli.js"`, and the exact `mcp_config` + `user_config` blocks shown in the spec (`docs/superpowers/specs/2026-08-06-github-mcp-server-mcpb-extension-design.md` lines 44-99). -- `GITHUB_TOKEN` is the only `sensitive: true` user_config field (stored in OS keychain). -- The extension is stdio only. Do NOT wire `--transport=http` or any HTTP flag into `mcp_config.args`. -- Phase 2 depends on Phase 1 verification passing (manual Claude Desktop install works end-to-end). Do NOT start Phase 2 until Task 3 is signed off. -- CI Node version is `'24'` — use the same in `release.yml`. -- TypeScript is the language for tests. The pack script is `.mjs` (plain ESM Node) to avoid a build step for a script that runs at pack time. - ---- - -## File Structure - -``` -manifest.json (new, Task 1) -assets/ - icon.png (new, Task 1 — 128×128, CC0) -.mcpbignore (new, Task 1) -scripts/ - pack-mcpb.mjs (new, Task 2) -test/unit/scripts/ - pack-mcpb.test.ts (new, Task 2) -package.json (modified, Task 2 — add dev dep + script) -README.md (modified, Task 3 — install instructions) -.github/workflows/release.yml (new, Task 4) -``` - ---- - -# Phase 1 — Local pack + real-install verification - -## Task 1: Static bundle contents (manifest.json, .mcpbignore, icon) - -**Files:** -- Create: `manifest.json` -- Create: `.mcpbignore` -- Create: `assets/icon.png` (128×128) - -**Interfaces:** -- Produces: A repo state where `mcpb pack .` (run manually) would emit a valid bundle with exactly the three bundle entries. No JavaScript interfaces. - -- [ ] **Step 1: Create `manifest.json` at repo root** with the exact content below (matches spec lines 44-99; `version` MUST equal the current `package.json.version`, which is `0.1.0`). - -```json -{ - "manifest_version": "0.1", - "name": "github-mcp-server-js", - "display_name": "GitHub MCP Server (JS)", - "version": "0.1.0", - "description": "MCP server exposing 104 GitHub REST tools across 16 toolsets, built on octokit.js.", - "author": { "name": "Qunfei Wu" }, - "homepage": "https://github.com/wuqunfei/github-mcp-server-js", - "repository": { - "type": "git", - "url": "https://github.com/wuqunfei/github-mcp-server-js" - }, - "icon": "assets/icon.png", - "server": { - "type": "node", - "entry_point": "dist/cli.js", - "mcp_config": { - "command": "node", - "args": ["${__dirname}/dist/cli.js"], - "env": { - "GITHUB_TOKEN": "${user_config.github_token}", - "GITHUB_SERVER_URL": "${user_config.github_server_url}", - "GITHUB_PERMISSION": "${user_config.github_permission}", - "LOG_LEVEL": "${user_config.log_level}" - } - } - }, - "user_config": { - "github_token": { - "type": "string", - "title": "GitHub Personal Access Token", - "description": "PAT used for all GitHub API calls. Stored securely in the OS keychain.", - "required": true, - "sensitive": true - }, - "github_server_url": { - "type": "string", - "title": "GitHub Server URL", - "description": "Bare hostname or full API base URL. Set this for GitHub Enterprise Server.", - "default": "github.com" - }, - "github_permission": { - "type": "string", - "title": "Permission (read-only or read-write)", - "description": "read-only registers only read tools; read-write registers all 104 tools.", - "default": "read-write" - }, - "log_level": { - "type": "string", - "title": "Log Level (debug, info, or error)", - "description": "Verbosity of server log output. One of: debug, info, error.", - "default": "info" - } - } -} -``` - -- [ ] **Step 2: Create `.mcpbignore`** with npm-ignore-style patterns so `mcpb pack` includes only the three bundle members. `mcpb pack` starts with everything under the packed directory and applies `.mcpbignore`, so we exclude every top-level dir/file that isn't a bundle member. - -``` -# Anything not in the three explicit bundle members below. -src/ -test/ -docs/ -scripts/ -node_modules/ -coverage/ -.github/ -.claude/ -.git/ -.husky/ -.superpowers/ -tsconfig.json -tsup.config.ts -vitest.config.ts -vitest.integration.config.ts -eslint.config.js -cspell.json -.gitleaks.toml -.gitignore -.mcpbignore -package.json -package-lock.json -README.md -CHANGELOG.md -LICENSE* -*.md -``` - -- [ ] **Step 3: Create `assets/icon.png`** — a 128×128 PNG version of the Simple Icons GitHub mark (CC0 1.0; safer than the Octocat trademark). - - Two supported approaches, pick whichever tooling is available: - - ```bash - # Option A — librsvg (macOS: brew install librsvg; Ubuntu: apt install librsvg2-bin): - curl -sSL "https://cdn.simpleicons.org/github/181717" -o /tmp/github.svg - mkdir -p assets - rsvg-convert -w 128 -h 128 -b white /tmp/github.svg -o assets/icon.png - - # Option B — sharp via npx (works anywhere Node is installed): - curl -sSL "https://cdn.simpleicons.org/github/181717" -o /tmp/github.svg - mkdir -p assets - npx --yes sharp-cli -i /tmp/github.svg -o assets/icon.png resize 128 128 --background '#ffffff' --flatten - ``` - - Confirm dimensions: - - ```bash - file assets/icon.png # expect: "PNG image data, 128 x 128" - ``` - -- [ ] **Step 4: Commit** - -```bash -git add manifest.json .mcpbignore assets/icon.png -git commit -m "feat(mcpb): add manifest.json, icon, and .mcpbignore" -``` - ---- - -## Task 2: Pack script (`scripts/pack-mcpb.mjs`) + version-parity test - -**Files:** -- Create: `scripts/pack-mcpb.mjs` -- Create: `test/unit/scripts/pack-mcpb.test.ts` -- Modify: `package.json` (add dev dep `@anthropic-ai/mcpb` and script `pack:mcpb`) - -**Interfaces:** -- Consumes: `manifest.json`, `.mcpbignore`, `assets/icon.png` (from Task 1). `package.json.version` and `manifest.json.version`. `dist/cli.js` (produced by `npm run build`). -- Produces: - - `checkVersionParity(pkg: { version: string }, manifest: { version: string }): void` — throws `Error` with a specific message when versions differ; returns `undefined` when they match. Exported from `scripts/pack-mcpb.mjs` for the unit test. - - `pack:mcpb` npm script — after `npm ci && npm run pack:mcpb`, the file `dist/github-mcp-server-js-.mcpb` exists and its `mcpb info` output shows exactly three entries. -- Consumers of the produced npm script: Task 3 (manual verification), Task 4 (release workflow). - -- [ ] **Step 1: Add `@anthropic-ai/mcpb` as a dev dependency** - -```bash -npm install --save-dev @anthropic-ai/mcpb@^2.1.2 -``` - -Confirm `package.json` `devDependencies` now includes `"@anthropic-ai/mcpb": "^2.1.2"`. - -- [ ] **Step 2: Write the failing test** at `test/unit/scripts/pack-mcpb.test.ts` - -```ts -import { describe, expect, it } from 'vitest'; -import { checkVersionParity } from '../../../scripts/pack-mcpb.mjs'; - -describe('checkVersionParity', () => { - it('returns undefined when versions match', () => { - expect( - checkVersionParity({ version: '0.1.0' }, { version: '0.1.0' }), - ).toBeUndefined(); - }); - - it('throws with a message naming both versions when they differ', () => { - expect(() => - checkVersionParity({ version: '0.1.0' }, { version: '0.2.0' }), - ).toThrow(/package\.json.*0\.1\.0.*manifest\.json.*0\.2\.0/); - }); - - it('throws when manifest.json.version is missing', () => { - expect(() => - checkVersionParity({ version: '0.1.0' }, {}), - ).toThrow(/manifest\.json.*version/); - }); - - it('throws when package.json.version is missing', () => { - expect(() => - checkVersionParity({}, { version: '0.1.0' }), - ).toThrow(/package\.json.*version/); - }); -}); -``` - -- [ ] **Step 3: Run the test to verify it fails** - -```bash -npm test -- test/unit/scripts/pack-mcpb.test.ts -``` - -Expected: FAIL — module `../../../scripts/pack-mcpb.mjs` cannot be resolved. - -- [ ] **Step 4: Create `scripts/pack-mcpb.mjs`** with the full content below. - -```javascript -#!/usr/bin/env node -// Pack the repo into a Claude Desktop Extension (.mcpb) bundle. -// -// Enforces: -// 1. manifest.json.version === package.json.version (fail fast on drift) -// 2. manifest.json validates against the mcpb schema -// 3. dist/cli.js exists (rebuilds if needed) -// 4. The produced archive contains exactly manifest.json, dist/cli.js, -// assets/icon.png (no stray files leaked through .mcpbignore) -import { spawnSync } from 'node:child_process'; -import { - existsSync, - mkdirSync, - mkdtempSync, - readdirSync, - readFileSync, - rmSync, - statSync, -} from 'node:fs'; -import { tmpdir } from 'node:os'; -import { dirname, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const REPO_ROOT = resolve(__dirname, '..'); - -const EXPECTED_ENTRIES = ['manifest.json', 'dist/cli.js', 'assets/icon.png']; - -export function checkVersionParity(pkg, manifest) { - if (!pkg || typeof pkg.version !== 'string') { - throw new Error('package.json is missing a version field'); - } - if (!manifest || typeof manifest.version !== 'string') { - throw new Error('manifest.json is missing a version field'); - } - if (pkg.version !== manifest.version) { - throw new Error( - `Version drift: package.json is ${pkg.version} but manifest.json is ${manifest.version}. Bump both together.`, - ); - } -} - -function run(cmd, args, opts = {}) { - const result = spawnSync(cmd, args, { - cwd: REPO_ROOT, - stdio: 'inherit', - ...opts, - }); - if (result.status !== 0) { - throw new Error(`\`${cmd} ${args.join(' ')}\` exited ${result.status}`); - } -} - -function walkFiles(dir, prefix = '') { - const entries = []; - for (const name of readdirSync(dir)) { - const full = resolve(dir, name); - const rel = prefix ? `${prefix}/${name}` : name; - const stat = statSync(full); - if (stat.isDirectory()) { - entries.push(...walkFiles(full, rel)); - } else { - entries.push(rel); - } - } - return entries; -} - -function assertArchiveEntries(mcpbPath) { - // Unpack the archive to a temp dir and list actual files — format-independent, - // no fragile stdout parsing. `mcpb unpack` unzips the .mcpb into . - const tmp = mkdtempSync(resolve(tmpdir(), 'mcpb-verify-')); - try { - run('npx', ['--yes', '@anthropic-ai/mcpb', 'unpack', mcpbPath, tmp]); - const actual = walkFiles(tmp).sort(); - const expected = [...EXPECTED_ENTRIES].sort(); - const missing = expected.filter((e) => !actual.includes(e)); - const extras = actual.filter((e) => !expected.includes(e)); - if (missing.length > 0 || extras.length > 0) { - throw new Error( - `Bundle contents do not match expected set.\n expected: ${expected.join(', ')}\n actual: ${actual.join(', ')}\n missing: ${missing.join(', ') || '(none)'}\n extras: ${extras.join(', ') || '(none)'}\nTighten .mcpbignore if there are extras.`, - ); - } - } finally { - rmSync(tmp, { recursive: true, force: true }); - } -} - -async function main() { - const pkg = JSON.parse(readFileSync(resolve(REPO_ROOT, 'package.json'), 'utf8')); - const manifest = JSON.parse( - readFileSync(resolve(REPO_ROOT, 'manifest.json'), 'utf8'), - ); - - checkVersionParity(pkg, manifest); - - console.log(`▶ validating manifest.json`); - run('npx', ['--yes', '@anthropic-ai/mcpb', 'validate', 'manifest.json']); - - console.log(`▶ building dist/cli.js`); - run('npm', ['run', 'build']); - - if (!existsSync(resolve(REPO_ROOT, 'dist/cli.js'))) { - throw new Error('dist/cli.js not produced by `npm run build`'); - } - - mkdirSync(resolve(REPO_ROOT, 'dist'), { recursive: true }); - const outPath = resolve(REPO_ROOT, `dist/github-mcp-server-js-${pkg.version}.mcpb`); - - console.log(`▶ packing → ${outPath}`); - run('npx', ['--yes', '@anthropic-ai/mcpb', 'pack', '.', outPath]); - - console.log(`▶ verifying archive contents`); - assertArchiveEntries(outPath); - - console.log(`✓ ${outPath}`); -} - -// Only run main() when invoked as a script, not when imported for tests. -if (import.meta.url === `file://${process.argv[1]}`) { - main().catch((err) => { - console.error(`✗ ${err.message}`); - process.exit(1); - }); -} -``` - -- [ ] **Step 5: Add the `pack:mcpb` script to `package.json`** in the `scripts` block, between `test:integration:write` and `prepare`: - -```json - "pack:mcpb": "node scripts/pack-mcpb.mjs", -``` - -- [ ] **Step 6: Run the unit test — should now pass** - -```bash -npm test -- test/unit/scripts/pack-mcpb.test.ts -``` - -Expected: 4 tests PASS. - -- [ ] **Step 7: Run the full unit suite** to confirm no regression. - -```bash -npm test -``` - -Expected: 211 tests pass (was 207; +4 for `pack-mcpb.test.ts`). - -- [ ] **Step 8: Typecheck + lint + spellcheck** - -```bash -npm run typecheck && npm run lint && npm run spellcheck -``` - -Expected: all clean. If cspell flags any new words (`mcpb`, `mcpbignore`, `simpleicons`), add them to `cspell.json`. - -- [ ] **Step 9: Run the pack script end-to-end** — verifies the whole pipeline locally. - -```bash -npm run pack:mcpb -ls -lh dist/github-mcp-server-js-*.mcpb -npx @anthropic-ai/mcpb info dist/github-mcp-server-js-0.1.0.mcpb -``` - -Expected: `dist/github-mcp-server-js-0.1.0.mcpb` exists (roughly 30-40 KB after compression) and `mcpb info` lists the three entries. - -- [ ] **Step 10: Add `dist/*.mcpb` to `.gitignore`** so the built bundle isn't tracked. - -```bash -grep -q '^dist/\*\.mcpb$' .gitignore || echo 'dist/*.mcpb' >> .gitignore -``` - -- [ ] **Step 11: Commit** - -```bash -git add package.json package-lock.json scripts/pack-mcpb.mjs test/unit/scripts/pack-mcpb.test.ts .gitignore cspell.json -git commit -m "feat(mcpb): add pack-mcpb script with version-parity check + tests" -``` - ---- - -## Task 3: Manual Claude Desktop verification + README update - -**Files:** -- Modify: `README.md` (add an "Install as Claude Desktop Extension" section) - -**Interfaces:** -- Consumes: The `dist/github-mcp-server-js-0.1.0.mcpb` produced in Task 2. -- Produces: A README section documenting the drag-to-install flow, plus a verified assertion (recorded in the commit message) that the extension works end-to-end in a real Claude Desktop install. - -- [ ] **Step 1: Manually verify in Claude Desktop** - - 1. Ensure `dist/github-mcp-server-js-0.1.0.mcpb` exists (`npm run pack:mcpb` if not). - 2. Open Claude Desktop → Settings → Extensions. - 3. Drag `dist/github-mcp-server-js-0.1.0.mcpb` into the Extensions pane. - 4. Confirm the install dialog shows: name "GitHub MCP Server (JS)", four config fields (token masked, others with defaults visible), icon rendered. - 5. Enter a valid `GITHUB_TOKEN` (a PAT with `repo` + `read:user` scope is enough), leave the other three fields at their defaults, click Install. - 6. Open a new chat and prompt: "Use the get_authenticated_user tool and tell me the login". Confirm the response includes the token owner's real GitHub login. - 7. Uninstall the extension from Settings → Extensions. - - If any step fails, do NOT proceed — fix the underlying issue (likely `manifest.json`, `.mcpbignore`, or a missing dependency) and re-verify. This is the hard gate for Phase 2. - -- [ ] **Step 2: Update `README.md`** — add a new top-level section after "Configuration" and before "Toolsets". The snippet below is fenced with four backticks so the inner triple-backtick blocks render correctly; when you paste it into `README.md`, use plain triple-backticks for the inner blocks. - -````markdown -## Install as a Claude Desktop Extension - -Prefer a one-drag install over editing config files? The server also ships -as a `.mcpb` (Claude Desktop Extension) bundle. - -1. Download `github-mcp-server-js-.mcpb` from the latest - [GitHub Release](https://github.com/wuqunfei/github-mcp-server-js/releases). -2. Open Claude Desktop → **Settings** → **Extensions**. -3. Drag the `.mcpb` file into the Extensions pane. -4. Fill in your `GITHUB_TOKEN` (stored in the macOS/Windows keychain — never - in plaintext). The other three fields have sensible defaults. -5. Click **Install**. All 104 tools are now available in every new chat. - -To build the bundle locally instead: - -```bash -npm ci -npm run pack:mcpb -# → dist/github-mcp-server-js-.mcpb -``` -```` - -- [ ] **Step 3: Verify the README section by rendering it locally** (or just `less README.md` and eyeball the new section for typos). - -- [ ] **Step 4: Spellcheck the README change** - -```bash -npm run spellcheck -``` - -Add any flagged words to `cspell.json` (candidates: `mcpb`, `keychain`). - -- [ ] **Step 5: Commit — record the manual-verify result in the message** - -```bash -git add README.md cspell.json -git commit -m "docs(mcpb): document Claude Desktop Extension install (manual-verified)" -``` - -The commit message MUST include the phrase "manual-verified" so future me can search history and confirm Phase 1 was actually tested in a real Claude Desktop install (not just built and assumed to work). - ---- - -# Phase 2 — CI/CD release automation - -**Do NOT start until Phase 1 (Tasks 1-3) is complete AND the commit message from Task 3 Step 5 confirms manual verification succeeded.** - -## Task 4: Release workflow (`.github/workflows/release.yml`) - -**Files:** -- Create: `.github/workflows/release.yml` - -**Interfaces:** -- Consumes: Everything from Phase 1 (`manifest.json`, `.mcpbignore`, `assets/icon.png`, `scripts/pack-mcpb.mjs`, `npm run pack:mcpb`). -- Produces: On every push of a tag matching `v*.*.*`, the workflow builds `dist/github-mcp-server-js-.mcpb` and uploads it as an asset attached to the GitHub Release for that tag. - -- [ ] **Step 1: Create `.github/workflows/release.yml`** with the exact content below. - -```yaml -name: Release - -on: - push: - tags: - - 'v*.*.*' - -jobs: - gate: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - uses: actions/setup-node@v4 - with: - node-version: '24' - cache: 'npm' - - run: npm ci - - run: npm run typecheck - - run: npm run lint - - run: npm run spellcheck - - run: npm audit --audit-level=high - - run: npm test - - run: npm run build - - release: - runs-on: ubuntu-latest - needs: gate - permissions: - contents: write - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - uses: actions/setup-node@v4 - with: - node-version: '24' - cache: 'npm' - - run: npm ci - - name: Pack .mcpb bundle - run: npm run pack:mcpb - - name: Upload .mcpb to the tag's GitHub Release - uses: softprops/action-gh-release@v2 - with: - files: dist/github-mcp-server-js-*.mcpb - fail_on_unmatched_files: true - generate_release_notes: true - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} -``` - -Notes for the reviewer: -- `permissions.contents: write` on the release job is required for `action-gh-release` to create/attach to the Release. -- `fail_on_unmatched_files: true` guarantees the job fails loudly if `pack:mcpb` didn't produce the expected file. -- `generate_release_notes: true` uses GitHub's built-in commit-range notes; no separate CHANGELOG plumbing. -- No `NPM_TOKEN` — `npm publish` is deliberately out of scope for this plan. - -- [ ] **Step 2: Lint the YAML** (if `actionlint` is installed; skip otherwise). - -```bash -actionlint .github/workflows/release.yml 2>/dev/null || echo "actionlint not installed; skipping" -``` - -- [ ] **Step 3: Commit** (before tagging, so the tag points at a commit that has the workflow file). - -```bash -git add .github/workflows/release.yml -git commit -m "ci(mcpb): add release workflow that attaches .mcpb to tag releases" -``` - -- [ ] **Step 4: Push the branch first** - -```bash -git push origin main -``` - -- [ ] **Step 5: Verify by pushing a pre-release tag** — this is the acceptance test for Phase 2. Use an `-rc` tag so nobody mistakes it for a real release. - -```bash -git tag v0.1.0-rc1 -git push origin v0.1.0-rc1 -``` - -- [ ] **Step 6: Watch the workflow** — open Actions in the GitHub UI or: - -```bash -gh run watch -``` - -Expected: both jobs (`gate`, `release`) succeed. The Release page for tag `v0.1.0-rc1` shows `github-mcp-server-js-0.1.0.mcpb` as an attached asset. - -- [ ] **Step 7: Verify the uploaded artifact end-to-end** — download it and confirm it installs into Claude Desktop identically to the local pack. - -```bash -gh release download v0.1.0-rc1 -p 'github-mcp-server-js-*.mcpb' -D /tmp/mcpb-check -file /tmp/mcpb-check/*.mcpb -``` - -Drag `/tmp/mcpb-check/github-mcp-server-js-0.1.0.mcpb` into a fresh Claude Desktop Extensions pane and repeat the verification from Task 3 Step 1. - -- [ ] **Step 8: Clean up the pre-release** - -```bash -# Delete the RC release (keeps the tag ref; delete both if you prefer) -gh release delete v0.1.0-rc1 --yes -git push --delete origin v0.1.0-rc1 -git tag -d v0.1.0-rc1 -``` - -- [ ] **Step 9: Commit the checkboxes** (mark this task's boxes as done). No code change needed — this step is the sign-off that Phase 2 is verified live. - -```bash -# Only if any docs were edited during verification; otherwise skip. -``` - ---- - -## Post-plan: version-bump workflow (documentation only) - -Once both phases are in, the release flow is: - -1. Bump `version` in **both** `package.json` and `manifest.json` (the pack script's parity check catches drift). -2. Commit both together: - `git commit -am "chore: release v0.2.0"` -3. Tag and push: - `git tag v0.2.0 && git push origin main --tags` -4. The release workflow builds and attaches `github-mcp-server-js-0.2.0.mcpb` to the auto-generated GitHub Release. - -Not automated in this plan — automating the bump belongs in a separate release-tooling plan. - ---- - -## Self-Review Notes - -Coverage check against the spec (`2026-08-06-github-mcp-server-mcpb-extension-design.md`): - -| Spec item | Covered by | -|---|---| -| `manifest.json` shape (lines 44-99) | Task 1 Step 1 (verbatim JSON block) | -| Bundle contains exactly three entries | Task 1 Step 2 (.mcpbignore), Task 2 Step 4 (`assertArchiveEntries`) | -| Icon: CC0 Simple Icons GitHub mark, 128×128 | Task 1 Step 3 (two supported toolchains) | -| Dev dep `@anthropic-ai/mcpb` | Task 2 Step 1 | -| `pack-mcpb.mjs` version-parity check | Task 2 Step 4 (`checkVersionParity`), Task 2 Step 2 (unit tests) | -| `pack-mcpb.mjs` validates manifest | Task 2 Step 4 (`mcpb validate`) | -| `pack-mcpb.mjs` runs `npm run build` | Task 2 Step 4 | -| `pack-mcpb.mjs` invokes `mcpb pack` | Task 2 Step 4 | -| Archive-content safety net | Task 2 Step 4 (`assertArchiveEntries`) | -| `pack:mcpb` npm script | Task 2 Step 5 | -| Manual Claude Desktop verification | Task 3 Step 1 | -| README install instructions | Task 3 Step 2 | -| `.github/workflows/release.yml` on `v*.*.*` tags | Task 4 Step 1 | -| Gate job runs full CI suite | Task 4 Step 1 (`gate` job) | -| Release job packs + uploads asset | Task 4 Step 1 (`release` job with `action-gh-release`) | -| Node 24 in release workflow | Task 4 Step 1 (`node-version: '24'`) | -| Tag-push acceptance test | Task 4 Step 5-7 | -| Non-goal: `npm publish` | Explicitly excluded, called out in Task 4 Step 1 notes | -| Non-goal: signing | No `mcpb sign` step | -| Non-goal: HTTP transport in the bundle | `mcp_config.args` in manifest has no `--transport=http` | - -No open questions. No placeholders. All type/interface references are consistent (`checkVersionParity(pkg, manifest)` matches between definition, export, test import, and consumer). diff --git a/docs/superpowers/specs/2026-08-05-github-mcp-server-design.md b/docs/superpowers/specs/2026-08-05-github-mcp-server-design.md deleted file mode 100644 index 4fe2d9b..0000000 --- a/docs/superpowers/specs/2026-08-05-github-mcp-server-design.md +++ /dev/null @@ -1,241 +0,0 @@ -# github-mcp-server-js — Design - -## Overview - -A general-purpose MCP server exposing GitHub functionality through hand-curated -tools built on `octokit.js`, implemented against the MCP TypeScript SDK v2 -(`@modelcontextprotocol/server` / `@modelcontextprotocol/client`, v2.0.0+). -Distributed as an npm package (`github-mcp-server-js`) runnable via -`npx github-mcp-server-js`, and additionally packaged as a Claude Desktop -Extension (`.mcpb` bundle) for one-click install. - -## Configuration - -Four environment variables, shared by both transports: - -| Variable | Required | Default | Purpose | -|---|---|---|---| -| `GITHUB_TOKEN` | Yes | — | Personal access token used for all GitHub API calls | -| `GITHUB_SERVER_URL` | No | `github.com` | GitHub host. Accepts either a bare hostname (e.g. `github.mycompany.com`) or a full API base URL (e.g. `https://github.mycompany.com/api/v3`). Normalized internally: `github.com` → `https://api.github.com`; any other host → `https:///api/v3` (GitHub Enterprise Server convention), unless the input already includes a path, in which case it is used as-is. | -| `GITHUB_PERMISSION` | No | `read-write` | `read-only` \| `read-write`. Gates which tools are registered with the MCP server at startup — in `read-only` mode, write/mutating tool handlers are never registered, so the LLM client cannot see or call them. | -| `LOG_LEVEL` | No | `info` | `debug` \| `info` \| `error`. Controls verbosity of diagnostic logging. | - -Both the stdio and HTTP transports are **single-tenant**: one `Octokit` client -is constructed once at startup from these env vars and reused for every tool -call, regardless of transport or caller. - -## Transport & Runtime Architecture - -Two thin transport wrappers around one shared core. - -``` -src/ - cli.ts # parses --transport / --port, dispatches to a transport - server.ts # builds McpServer instance, registers tools from all toolsets - octokit-client.ts # builds the single Octokit instance from env vars - toolsets/ - repos.ts - issues.ts - pull_requests.ts - actions.ts - code_security.ts - search.ts - orgs_teams.ts - users.ts - projects.ts - gists.ts - packages.ts - activity.ts - apps.ts - codespaces.ts - copilot.ts - misc.ts - transports/ - stdio.ts - http.ts -``` - -**Startup sequence** (identical for both transports): -1. Read and validate env vars. -2. Build one `Octokit` client (`octokit-client.ts`). -3. Build an `McpServer` instance. -4. For each toolset file, call its `register*Tools(server, octokit, permission)` - function; each function internally skips registering write tools when - `GITHUB_PERMISSION=read-only`. -5. Connect the transport selected via CLI flag. - -**CLI flags** (transport selection is CLI-only, not env-driven): -``` -npx github-mcp-server-js # stdio (default) -npx github-mcp-server-js --transport=http --port=3000 # Streamable HTTP -``` - -- `stdio.ts` uses the SDK's `StdioServerTransport`. This is the default and - is required both for direct `npx` usage in MCP clients (Claude Desktop, - Claude Code, Cursor, etc.) and for the `.mcpb` bundle, which spawns the - server as a local child process. -- `http.ts` uses the SDK's Streamable HTTP transport. Single-tenant, no - additional authentication layer on the HTTP endpoint — it is assumed to - run in a trusted network context (localhost / internal VPN). - -Each toolset module is independently understandable and testable: it takes -the shared `McpServer` and `Octokit` instances and the effective permission -mode, and registers only its own tools. - -## Toolset Inventory - -All 16 toolsets are active by default, with no enable/disable filtering -(`GITHUB_PERMISSION` is the only registration-time filter, applied uniformly -across toolsets). Tool counts are estimates for v1 and will be refined during -implementation; total is expected to land around ~110 tools, of which roughly -half are read-only. - -| Toolset | octokit REST namespaces | Example tools | Est. count | -|---|---|---|---| -| `repos` | repos, git, licenses, gitignore | get_repo, list_branches, get_file_contents, create_or_update_file, list_commits, get_commit, list_tags, create_branch | ~15 | -| `issues` | issues, reactions, interactions | list_issues, get_issue, create_issue, update_issue, add_comment, list_labels | ~10 | -| `pull_requests` | pulls | list_prs, get_pr, create_pr, merge_pr, list_pr_files, create_review, list_reviews | ~10 | -| `actions` | actions, checks | list_workflows, run_workflow, list_workflow_runs, get_run, list_artifacts, get_check_runs | ~12 | -| `code_security` | codeScanning, codeSecurity, secretScanning, securityAdvisories, dependabot, dependencyGraph | list_code_scanning_alerts, list_secret_scanning_alerts, list_dependabot_alerts, get_advisory | ~10 | -| `search` | search | search_code, search_repos, search_issues, search_users, search_commits | ~5 | -| `orgs_teams` | orgs, teams | list_org_repos, get_org, list_teams, list_team_members | ~8 | -| `users` | users | get_user, get_authenticated_user, list_followers | ~5 | -| `projects` | projects | list_projects, get_project, list_project_items | ~5 | -| `gists` | gists | list_gists, create_gist, get_gist, update_gist | ~5 | -| `packages` | packages | list_packages, get_package_version | ~4 | -| `activity` | activity | list_notifications, star_repo, list_starred | ~5 | -| `apps` | apps, oidc | get_app, list_installations | ~3 | -| `codespaces` | codespaces | list_codespaces, create_codespace, stop_codespace | ~5 | -| `copilot` | copilot | get_copilot_seat_details, list_copilot_usage | ~3 | -| `misc` | meta, emojis, markdown, rateLimit, billing, campaigns, credentials, hostedCompute, privateRegistries, migrations, enterpriseTeam*, codesOfConduct | get_rate_limit, render_markdown | ~4 | - -Note: GitHub Discussions are GraphQL-only (not covered by octokit's REST -plugin) and are excluded from v1 scope. - -Within each toolset, operations are hand-curated (not a 1:1 mapping of every -octokit method) — each exposed tool gets its own name, description, and Zod -input schema, following the pattern of the official Go-based -`github/github-mcp-server`. This keeps per-tool schemas precise (better LLM -tool-call accuracy) versus a generic dispatch-by-operation-name tool. - -## Pagination - -List tools expose `page` and `per_page` as explicit parameters (default -`per_page=30`, max `100`), matching GitHub's raw REST API pagination model. -No auto-pagination and no cursor abstraction — callers request additional -pages explicitly. - -## Response Format - -Tool responses return the octokit response body as raw JSON, unmodified. No -field trimming or text-summarization layer. Keeps tool implementations thin; -the LLM client is responsible for extracting relevant fields. - -## Error Handling - -Octokit errors propagate as-is into the MCP tool error result — no -normalization layer, no special-casing (including rate limits). Kept -consistent with the raw-JSON response philosophy: thin tool logic, no -custom error-shape abstraction. - -## Logging - -All logging goes to `stderr` regardless of transport (stdout is reserved for -JSON-RPC protocol messages in stdio mode — writing anything else to stdout -corrupts the protocol stream). Verbosity controlled by `LOG_LEVEL` -(`debug` / `info` / `error`). - -## Language & Build - -TypeScript, bundled via `tsup`/`esbuild` into a single-file CLI for fast -`npx` cold-start (avoids `tsc`'s many-small-files output and its -`node_modules` resolution overhead at startup). Tool input schemas defined -with Zod. - -## Testing - -- **Unit tests** (primary safety net, always run in CI): one test file per - toolset, HTTP mocked via `nock`/MSW. Cover successful calls, input - validation, and error propagation per tool. -- **Integration tests** (opt-in): real API calls against a small set of - representative tools (e.g. `get_repo`, `list_issues`) against a public - repo. Automatically skipped in CI unless a test `GITHUB_TOKEN` secret is - present. - -## Pre-commit & CI/CD - -**Pre-commit** (`husky` + `lint-staged`, on `git commit`): -1. `tsc --noEmit` — typecheck -2. `eslint --fix` — lint (staged files) -3. `cspell` — spellcheck on staged files, including tool description - strings (typos there can cause an LLM to misuse or skip a tool) -4. `gitleaks protect --staged` — block commits containing secrets - -**CI** (GitHub Actions, on every PR and push to `main`): -1. `npm ci` -2. `tsc --noEmit` -3. `eslint .` -4. `cspell "**/*.{ts,md}"` -5. `npm audit --audit-level=high` -6. `gitleaks detect` (full history scan — backstops any `--no-verify` bypass - of the pre-commit hook) -7. `npm test` (unit; integration tests only run if `GITHUB_TOKEN` secret set) -8. `npm run build` (verifies the tsup bundle compiles cleanly) - -**CD** (GitHub Actions, on GitHub Release published / version tag push): -1. Re-run full CI suite as a gate -2. `npm publish` (requires `NPM_TOKEN` secret — to be supplied later) -3. Build `.mcpb` bundle via `@anthropic-ai/mcpb pack`, attach as a release - asset - -## Claude Desktop Extension (.mcpb) Packaging - -In addition to the plain npm package, the server is packaged as a `.mcpb` -bundle (per https://www.anthropic.com/engineering/desktop-extensions) so -Claude Desktop users can install it by dragging the file into -Settings > Extensions, with no terminal or manual config editing. - -- `manifest.json` declares `server.type = "node"`, an `entry_point` pointing - at the bundled stdio server, and a `user_config` block mapping each of the - 4 env vars to a form field: - - | Env var | `user_config` field type | `sensitive` | - |---|---|---| - | `GITHUB_TOKEN` | string (required) | `true` — stored in OS keychain | - | `GITHUB_SERVER_URL` | string, default `github.com` | `false` | - | `GITHUB_PERMISSION` | enum (`read-only` / `read-write`), default `read-write` | `false` | - | `LOG_LEVEL` | enum (`debug` / `info` / `error`), default `info` | `false` | - -- Applies to the **stdio transport only** — `.mcpb` bundles are spawned by - Claude Desktop as a local child process; the HTTP transport is out of - scope for this packaging path. -- Built via `@anthropic-ai/mcpb init` / `pack` (npm package - `@anthropic-ai/mcpb`), wired into the CD pipeline as a release asset. - -## Project Scaffolding - -``` -github-mcp-server-js/ - src/ - cli.ts - server.ts - octokit-client.ts - toolsets/ (16 files, one per toolset) - transports/ - stdio.ts - http.ts - test/ - unit/ (mirrors toolsets/) - integration/ - manifest.json (.mcpb manifest) - .github/workflows/ - ci.yml - release.yml - package.json - tsconfig.json - tsup.config.ts - eslint.config.js - cspell.json - .gitleaks.toml - README.md -``` diff --git a/docs/superpowers/specs/2026-08-06-github-mcp-server-mcpb-extension-design.md b/docs/superpowers/specs/2026-08-06-github-mcp-server-mcpb-extension-design.md deleted file mode 100644 index e0c9454..0000000 --- a/docs/superpowers/specs/2026-08-06-github-mcp-server-mcpb-extension-design.md +++ /dev/null @@ -1,218 +0,0 @@ -# github-mcp-server-js — Claude Desktop Extension (.mcpb) design - -**Status:** proposed -**Supplements:** `2026-08-05-github-mcp-server-design.md` §"Claude Desktop -Extension (.mcpb) Packaging" - -This spec turns the packaging section of the master design into a concrete -implementation plan, sequenced as (1) local pack + real-install verification -in Claude Desktop, (2) CI/CD release automation. - -## Goal - -Ship the existing stdio MCP server as a `.mcpb` bundle so Claude Desktop -users can install it by dragging the file into Settings > Extensions — no -terminal, no manual `claude_desktop_config.json` editing. - -## Non-goals - -- HTTP transport packaging — `.mcpb` bundles are spawned by Claude Desktop as - a local child process; HTTP is out of scope. -- `npm publish` automation — requires `NPM_TOKEN` and is tracked as a - separate future plan. -- Extension signing — the `.mcpb` spec (v0.1) does not yet mandate signing; - unsigned bundles work in current Claude Desktop. -- Custom branded icon — we use the CC0-licensed Simple Icons GitHub mark to - avoid Octocat trademark risk. A polished brand icon is a follow-up. - -## Bundle contents - -The `tsup` build already produces a self-contained single-file -`dist/cli.js` (~92 KB with all deps inlined), so the bundle is minimal: - -``` -github-mcp-server-js-.mcpb (zip archive) -├── manifest.json -├── dist/cli.js -└── assets/icon.png (128×128) -``` - -No `node_modules`, no `src/`, no `test/`. - -## manifest.json - -```json -{ - "manifest_version": "0.1", - "name": "github-mcp-server-js", - "display_name": "GitHub MCP Server (JS)", - "version": "0.1.0", - "description": "MCP server exposing 104 GitHub REST tools across 16 toolsets, built on octokit.js.", - "author": { "name": "Qunfei Wu" }, - "homepage": "https://github.com/wuqunfei/github-mcp-server-js", - "repository": { - "type": "git", - "url": "https://github.com/wuqunfei/github-mcp-server-js" - }, - "icon": "assets/icon.png", - "server": { - "type": "node", - "entry_point": "dist/cli.js", - "mcp_config": { - "command": "node", - "args": ["${__dirname}/dist/cli.js"], - "env": { - "GITHUB_TOKEN": "${user_config.github_token}", - "GITHUB_SERVER_URL": "${user_config.github_server_url}", - "GITHUB_PERMISSION": "${user_config.github_permission}", - "LOG_LEVEL": "${user_config.log_level}" - } - } - }, - "user_config": { - "github_token": { - "type": "string", - "title": "GitHub Personal Access Token", - "description": "PAT used for all GitHub API calls. Stored securely in the OS keychain.", - "required": true, - "sensitive": true - }, - "github_server_url": { - "type": "string", - "title": "GitHub Server URL", - "description": "Bare hostname or full API base URL. Set this for GitHub Enterprise Server.", - "default": "github.com" - }, - "github_permission": { - "type": "string", - "title": "Permission (read-only or read-write)", - "description": "read-only registers only read tools; read-write registers all 104 tools.", - "default": "read-write" - }, - "log_level": { - "type": "string", - "title": "Log Level (debug, info, or error)", - "description": "Verbosity of server log output. One of: debug, info, error.", - "default": "info" - } - } -} -``` - -**Note on enums:** the `.mcpb` v0.1 schema supports only `string`, `number`, -and `directory` field types — no native enum. `github_permission` and -`log_level` are therefore free-text strings with defaults; the valid values -are described in the field title/description. The server already validates -these env vars at startup, so invalid input fails loudly. - -**Note on schema field names (verified against mcpb v2.1.2):** the manifest -uses `manifest_version` (not `mcpb_version`) as the schema version key. Every -`user_config` field requires `description`; omit at your peril — the -validator rejects missing `description` even when a `title` is present. - -## Phase 1 — Local pack + real-install verification - -**New/modified repo artifacts:** - -- Add `manifest.json` at repo root (see above). -- Add `assets/icon.png` — 128×128 render of the Simple Icons GitHub mark - (CC0 1.0). Chosen over Octocat to avoid GitHub trademark issues. -- Add dev dep `@anthropic-ai/mcpb`. -- Add `scripts/pack-mcpb.mjs`: - - Reads `package.json.version` and `manifest.json.version`; if they - differ, exits non-zero with a clear diff message. - - Runs `mcpb validate manifest.json`. - - Ensures a fresh `dist/cli.js` (calls `npm run build`). - - Invokes `mcpb pack . - dist/github-mcp-server-js-.mcpb`. -- Configure `mcpb pack` so the resulting zip contains only `manifest.json`, - `dist/cli.js`, and `assets/icon.png`. `mcpb pack` respects a `.mcpbignore` - file (npm-ignore-style patterns); use that to exclude `src/`, `test/`, - `node_modules/`, `docs/`, and every top-level file except the three - bundle members. The pack script also asserts the produced archive - contains exactly those three entries as a safety net. -- `package.json` script: `"pack:mcpb": "node scripts/pack-mcpb.mjs"`. - -**Verification (manual, one-time):** - -1. `npm run pack:mcpb` — produces - `dist/github-mcp-server-js-0.1.0.mcpb`. -2. Drag the file into Claude Desktop > Settings > Extensions. -3. Fill in `GITHUB_TOKEN`, keep other defaults. -4. Open a chat and confirm one real tool call succeeds - (e.g. `get_authenticated_user`). -5. Uninstall the extension. - -Verification success = extension installs, all four config fields render -correctly (with the token masked), and the tool call returns the real -authenticated user object. - -## Phase 2 — CI/CD release automation - -Only started after Phase 1 verification passes. - -**New workflow:** `.github/workflows/release.yml` - -Trigger: `push` on tags matching `v*.*.*`. - -Jobs: -1. **gate** — runs the same steps as CI (`typecheck`, `lint`, `spellcheck`, - `npm audit --audit-level=high`, `test`, `build`). Depends on nothing. -2. **release** — depends on `gate`: - - `actions/checkout@v4` with `fetch-depth: 0` - - `actions/setup-node@v4` with `node-version: '24'`, `cache: 'npm'` - - `npm ci` - - `npm run pack:mcpb` - - `softprops/action-gh-release@v2` uploads - `dist/github-mcp-server-js-*.mcpb` as an asset on the tag's release, - using `${{ secrets.GITHUB_TOKEN }}` (provided automatically by - Actions, no manual secret needed). - -**Version-bump flow (documented, not automated in this plan):** -1. Bump `version` in both `package.json` and `manifest.json` (script - catches drift). -2. Commit + tag: `git tag v0.1.1 && git push origin main --tags`. -3. Workflow runs, `.mcpb` shows up under the tag's GitHub Release. - -## Testing - -**Unit tests:** the pack script itself is small enough to test with a -targeted vitest suite: - -- `test/unit/scripts/pack-mcpb.test.ts`: - - Version-drift check flags mismatched versions - - Version-match check passes when versions align - - (End-to-end pack invocation isn't unit-tested — that's what Phase 1 - manual verification covers.) - -**Integration verification:** covered by the manual Phase 1 test above. -Once the CD is wired (Phase 2), pushing an `-rc` tag (e.g. `v0.1.0-rc1`) -and confirming the release asset appears constitutes the Phase 2 test. - -## File map - -``` -github-mcp-server-js/ - manifest.json (new — Phase 1) - assets/ - icon.png (new — Phase 1, 128×128, Simple Icons GH mark) - scripts/ - pack-mcpb.mjs (new — Phase 1) - test/unit/scripts/ - pack-mcpb.test.ts (new — Phase 1) - .github/workflows/ - release.yml (new — Phase 2) - package.json (modified — Phase 1: add pack:mcpb script + dev dep) - README.md (modified — Phase 1: install instructions) - .mcpbignore (new — Phase 1) -``` - -## Open questions - -None — all decisions are made: -- Bundle single-file (tsup output), no `node_modules` -- Simple Icons GH mark for icon (CC0) -- Version drift caught by pack script, not by manifest linting -- Phase 1 first, Phase 2 only after manual verification -- No signing (spec v0.1 doesn't require it) -- No `npm publish` in this plan (separate future work) diff --git a/manifest.json b/manifest.json index caed228..810b4c6 100644 --- a/manifest.json +++ b/manifest.json @@ -2,7 +2,7 @@ "manifest_version": "0.1", "name": "github-mcp-server-js", "display_name": "GitHub MCP Server (JS)", - "version": "0.1.4", + "version": "0.1.5", "description": "MCP server exposing 104 GitHub REST tools across 16 toolsets, built on octokit.js.", "author": { "name": "Qunfei Wu" }, "homepage": "https://github.com/wuqunfei/github-mcp-server-js", diff --git a/package.json b/package.json index 2a3de22..6cf4961 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "github-mcp-server-js", - "version": "0.1.4", + "version": "0.1.5", "description": "A GitHub MCP server built on octokit.js and the MCP TypeScript SDK v2 — 104 tools across 16 toolsets, packaged as npm and .mcpb Claude Desktop Extension.", "type": "module", "license": "MIT",