A Python 3.11+ toolkit for creating, reading, updating, and archiving items on a GitHub Projects v2 board via the GraphQL API.
It ships in three modes:
- CLI (
scripts/github_project_crud.py) — zero runtime dependencies, pipes JSON to stdout, integrates with CI and shell scripts. - MCP server / stdio (
scripts/mcp_server.py) — wraps the CLI as a local Model Context Protocol server so Claude Desktop can manage your project board in plain language. - MCP server / OAuth (
scripts/mcp_server.py --oauth+scripts/oauth_server.py) — publicly reachable MCP server with a standards-compliant OAuth 2.0 authorization server, registerable as a Claude claude.ai custom connector using only a Client ID and Client Secret.
Project identity (owner, type, and board number) is passed as CLI flags or per-call MCP parameters, so the same binary can target different boards without touching any config file.
- Features
- Requirements
- Installation
- Configuration
- Quick Start
- Commands
- MCP Server — Claude Desktop
- MCP Server — Claude Connector (OAuth)
- GitHub Actions Integration
- Using Multiple Projects
- Architecture
- Known Limitations
- Development
- Contributing
- Security
- Create draft project items
- List all project items with their field values
- List all project fields and single-select options
- Update text, number, and single-select fields
- Archive project items
- Link existing issues or pull requests to the project
- Zero runtime dependencies (Python standard library only)
- All output is newline-terminated JSON — easy to pipe into
jq - Best-effort JSON lifecycle events written to syslog (tokens and response bodies are never logged)
- Exposes every CLI operation as an MCP tool callable by Claude Desktop
- Default stdio transport — Claude Desktop spawns the process; no separate server to start
- Optional SSE mode (
--transport sse) for persistent or multi-client setups - Loads
.envautomatically so the token never appears in conversation history
- Standards-compliant OAuth 2.0 authorization server (
scripts/oauth_server.py) - Consent screen collects GitHub Personal Access Token; token is never written to disk
- Bearer token middleware (
oauth/middleware.py) gates all MCP requests - Authorization code and refresh token grant types; tokens stored in a local SQLite database
- Register in claude.ai with only Client ID and Client Secret — no manual token copying
- Python 3.11 or later
- A GitHub personal access token (classic or fine-grained) with project access
See CONTRIBUTORS.md.
Run directly without installing:
python scripts/github_project_crud.py --helpInstall as a package (exposes the github-project-toolkit command):
pip install -e .
github-project-toolkit --helpInstall dev dependencies for testing and linting:
pip install -e ".[dev]"Install MCP server dependencies (required only for scripts/mcp_server.py):
pip install -e ".[mcp]"Install OAuth server dependencies (required for the public Claude connector mode):
pip install -e ".[oauth]"| Variable | Required | Description |
|---|---|---|
GITHUB_TOKEN |
Yes | Personal access token (classic or fine-grained) |
GITHUB_API_URL |
No | GraphQL endpoint (default: https://api.github.com/graphql) |
Set GITHUB_TOKEN in your shell, a .env file (MCP server loads it automatically), or as a CI secret.
| CLI flag | Env var fallback | Description |
|---|---|---|
--owner |
GITHUB_OWNER |
Organization login or GitHub username that owns the project |
--owner-type |
GITHUB_OWNER_TYPE |
org for an organization, user for a personal account |
--project-number |
GITHUB_PROJECT_NUMBER |
Integer project number shown in the project URL |
CLI flags take precedence over environment variables when both are present. The MCP server receives these as explicit parameters on every tool call.
Fine-grained personal access tokens — grant access to the relevant owner and repositories, then enable:
- Projects: read and write
- Issues: read (when linking issues)
- Pull requests: read (when linking pull requests)
- Metadata: read
Classic personal access tokens — the project scope is required. Add repo when the project or linked content is in a private repository.
Organization projects may also require the organization to allow personal access token access under Settings > Third-party access.
export GITHUB_TOKEN=github_pat_...
# List all items on project board #1 in my-org
python scripts/github_project_crud.py \
--owner my-org --owner-type org --project-number 1 \
list-items
# Create a draft item
python scripts/github_project_crud.py \
--owner my-org --owner-type org --project-number 1 \
create-item --title "OR-0001: Example"
# Move an item to a different status
python scripts/github_project_crud.py \
--owner my-org --owner-type org --project-number 1 \
update-field \
--item-id PVTI_xxx \
--field "Status" \
--value "In Progress"Flags can go before or after the subcommand name. If you always work against the same board, set the env var equivalents and omit the flags.
All commands print JSON to stdout and exit 0 on success. On failure they print {"error": "..."} to stdout and exit 1.
Returns all visible project items with their field values.
python scripts/github_project_crud.py \
--owner my-org --owner-type org --project-number 1 \
list-items[
{
"content": {
"__typename": "Issue",
"id": "I_kwDO...",
"number": 42,
"state": "OPEN",
"title": "Fix login bug",
"url": "https://github.com/my-org/my-repo/issues/42"
},
"fields": {
"Estimate": 3.0,
"Status": { "name": "In Progress", "option_id": "abc123" },
"Title": "Fix login bug"
},
"id": "PVTI_lADOBdq...",
"is_archived": false,
"type": "ISSUE"
}
]Item types are ISSUE, PULL_REQUEST, or DRAFT_ISSUE. Draft items have a content object with id, title, and body; linked items include url, number, and state.
Returns all project fields keyed by name, including single-select option names and their IDs.
python scripts/github_project_crud.py \
--owner my-org --owner-type org --project-number 1 \
list-fields{
"Estimate": {
"data_type": "NUMBER",
"id": "PVTF_lADOBdq...",
"name": "Estimate",
"type": "ProjectV2Field"
},
"Status": {
"data_type": "SINGLE_SELECT",
"id": "PVTSSF_lADOBdq...",
"name": "Status",
"options": {
"Done": "opt_done_id",
"In Progress": "opt_in_progress_id",
"Todo": "opt_todo_id"
},
"type": "ProjectV2SingleSelectField"
}
}Use list-fields to find exact field names and option strings before calling update-field.
Creates a draft project item.
python scripts/github_project_crud.py \
--owner my-org --owner-type org --project-number 1 \
create-item --title "OR-0001: Example"
python scripts/github_project_crud.py \
--owner my-org --owner-type org --project-number 1 \
create-item --title "OR-0002: Example" --body "Initial notes"{
"content": {
"body": "Initial notes",
"id": "DI_kwDO...",
"title": "OR-0002: Example"
},
"id": "PVTI_lADOBdq...",
"isArchived": false,
"type": "DRAFT_ISSUE"
}Updates a single field on a project item. Use --type to specify the field type (default: single-select).
Single-select field (default):
python scripts/github_project_crud.py \
--owner my-org --owner-type org --project-number 1 \
update-field \
--item-id PVTI_lADOBdq... \
--field "Status" \
--value "Done"Text field:
python scripts/github_project_crud.py \
--owner my-org --owner-type org --project-number 1 \
update-field \
--item-id PVTI_lADOBdq... \
--field "Summary" \
--type text \
--value "Updated description"Number field:
python scripts/github_project_crud.py \
--owner my-org --owner-type org --project-number 1 \
update-field \
--item-id PVTI_lADOBdq... \
--field "Estimate" \
--type number \
--value 5Option matching is case-sensitive. Run list-fields to see exact option names.
Archives a project item by its node ID.
python scripts/github_project_crud.py \
--owner my-org --owner-type org --project-number 1 \
archive-item --item-id PVTI_lADOBdq...{
"id": "PVTI_lADOBdq...",
"isArchived": true
}Adds an existing issue to the project.
python scripts/github_project_crud.py \
--owner my-org --owner-type org --project-number 1 \
link-issue --issue-url "https://github.com/owner/repo/issues/123"Adds an existing pull request to the project.
python scripts/github_project_crud.py \
--owner my-org --owner-type org --project-number 1 \
link-pr --pr-url "https://github.com/owner/repo/pull/456"All error conditions produce JSON on stdout with a non-zero exit code:
{ "error": "Field not found: Statuss. Available fields: Estimate, Status, Title" }Common errors and their causes:
| Error message | Cause |
|---|---|
Missing required environment variable |
GITHUB_TOKEN is unset or empty |
Missing required environment variable(s): GITHUB_OWNER |
Flags not passed and env var not set |
Could not resolve GitHub Project v2 |
Wrong owner, project number, or insufficient token permissions |
GitHub API request failed with HTTP 401 |
Token missing, expired, or not permitted |
GitHub API request failed with HTTP 403 |
Token lacks project or repository access |
Field not found |
Field name does not match exactly — run list-fields |
Option not found |
Single-select value does not match exactly — run list-fields |
Could not resolve issue / pull request |
URL is incorrect or token cannot read that repository |
scripts/mcp_server.py wraps the toolkit as a Model Context Protocol server. Once running, Claude Desktop on the same machine can create items, update fields, link issues, and archive cards using plain English — no command-line required.
Connection model (stdio — default):
Claude Desktop (your workstation)
↓ spawns process, stdin/stdout
MCP server — scripts/mcp_server.py
↓
github_project_crud.py
↓
GitHub GraphQL API (api.github.com)
Claude Desktop manages the process lifecycle — no separate terminal needed.
Install the two extra dependencies (not needed for the plain CLI):
pip install "mcp[cli]>=1.0" "python-dotenv>=1.0"
# or via the package extra:
pip install -e ".[mcp]"The MCP server loads .env from the project root automatically. Only GITHUB_TOKEN is required there — owner and project context are passed as parameters on each tool call.
# .env
GITHUB_TOKEN=github_pat_...Add the server to your Claude Desktop config. Default locations by platform:
| Platform | Path |
|---|---|
| macOS | ~/Library/Application Support/Claude/claude_desktop_config.json |
| Windows | %APPDATA%\Claude\claude_desktop_config.json |
| Linux | ~/.config/Claude/claude_desktop_config.json |
{
"mcpServers": {
"github_projects": {
"command": "python",
"args": ["/full/path/to/scripts/mcp_server.py"],
"env": {
"GITHUB_TOKEN": "github_pat_..."
}
}
}
}Replace /full/path/to/scripts/mcp_server.py with the absolute path on your machine. The env block is the recommended way to pass the token — it avoids relying on a .env file being in the right place when Claude Desktop spawns the process.
Restart Claude Desktop after saving. You should see GitHub Projects appear in the tool list.
If you prefer to manage the server process yourself — for example to share it across multiple MCP clients — start it with --transport sse:
python scripts/mcp_server.py --transport sse
# listening on http://127.0.0.1:8765/sseThen point Claude Desktop at the HTTP endpoint instead:
{
"mcpServers": {
"github_projects": {
"url": "http://127.0.0.1:8765/sse"
}
}
}Every tool requires owner, owner_type, and project_number — pass them on every call.
| Tool | Description |
|---|---|
list_project_items |
Return all items on the board with their field values |
list_project_fields |
Return all fields and single-select options |
create_project_item |
Create a new draft item (title, optional body) |
update_project_item_field |
Update a field by item ID (field_type: text, number, single-select) |
archive_project_item |
Archive an item by its node ID |
link_issue_to_project |
Add an existing issue to the board by URL |
link_pr_to_project |
Add an existing pull request to the board by URL |
Once connected, you can say things like:
- "Show me everything on the AdsWireIO project board #1."
- "Create a task called 'Migrate auth service to OAuth 2.1' with a description of the acceptance criteria."
- "Move item PVTI_xxx to Done."
- "Archive all items with status Cancelled." (Claude will call
list_project_itemsthen loop over matching IDs) - "Link
https://github.com/my-org/my-repo/issues/99to the project and set its status to In Progress."
The server always returns structured JSON; Claude formats it in the conversation.
- In stdio mode the process is private to Claude Desktop — no network port is opened.
- In SSE mode the server binds to
127.0.0.1only; SSE connections are unauthenticated on localhost — do not change the bind address. GITHUB_TOKENis read from theenvblock inclaude_desktop_config.jsonor from a local.envfile and is never sent to Claude or logged.
This mode exposes the MCP server publicly so it can be registered as a Claude claude.ai custom connector using only an OAuth Client ID and Client Secret — no copying Bearer tokens, no config file editing.
Two processes run together:
claude.ai ──→ oauth.example.com:443 ──→ oauth_server.py (port 8766)
/oauth/authorize (consent screen)
/oauth/token (token exchange)
claude.ai ──→ mcp.example.com:443 ──→ mcp_server.py --oauth (port 8765)
/sse (MCP over SSE)
- User opens claude.ai → Settings → Connectors and clicks Connect.
- Claude redirects to your
/oauth/authorizeendpoint. - The consent screen asks for a GitHub Personal Access Token (never stored — held in memory only).
- On approval, the server issues an authorization code and redirects back to Claude.
- Claude exchanges the code for a Bearer access token at
/oauth/token. - All subsequent MCP requests carry the Bearer token; the middleware validates it and injects the corresponding GitHub token into the request so tool calls work transparently.
- Tokens expire after 1 hour; Claude refreshes them automatically using the refresh token (30-day lifetime).
pip install -e ".[oauth]"
# equivalent:
pip install starlette uvicorn jinja2 python-multipart itsdangerous "mcp[cli]" python-dotenvThe credentials in .env are generated automatically when you run make install or can be set manually:
# .env
# GitHub PAT (used in stdio mode; not needed when --oauth is active)
GITHUB_TOKEN=github_pat_...
# OAuth server credentials — generate with:
# python3 -c "import secrets; print(secrets.token_urlsafe(32))"
OAUTH_CLIENT_ID=<generated>
OAUTH_CLIENT_SECRET=<generated>
SESSION_SECRET=<generated>The .env file in this repository already contains generated values for OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET, and SESSION_SECRET. Regenerate them before any public deployment.
# Terminal 1 — OAuth authorization server
python scripts/oauth_server.py --host 0.0.0.0 --port 8766
# Terminal 2 — MCP server with Bearer token validation
python scripts/mcp_server.py --transport sse --oauth --host 0.0.0.0 --port 8765Both servers load .env automatically.
Each server needs its own virtual host with SSL. Below is a minimal nginx snippet; adapt domain names and certificate paths to your setup:
# OAuth authorization server
server {
listen 443 ssl;
server_name oauth.example.com;
ssl_certificate /etc/letsencrypt/live/oauth.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/oauth.example.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:8766;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
# MCP server
server {
listen 443 ssl;
server_name mcp.example.com;
ssl_certificate /etc/letsencrypt/live/mcp.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/mcp.example.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:8765;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Required for SSE
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 3600s;
}
}Both domains must be publicly reachable — Claude's servers need to reach the OAuth endpoints during the auth flow and send MCP requests afterward.
In claude.ai → Settings → Connectors → Add connector, fill in:
| Field | Value |
|---|---|
| MCP Server URL | https://mcp.example.com/sse |
| OAuth Client ID | Value of OAUTH_CLIENT_ID from .env |
| OAuth Client Secret | Value of OAUTH_CLIENT_SECRET from .env |
Click Connect. Claude opens the consent screen at https://oauth.example.com/oauth/authorize. Enter your GitHub Personal Access Token, click Connect →, and the connector is ready.
The same seven tools available in Claude Desktop mode are exposed:
| Tool | Description |
|---|---|
list_project_items |
Return all items on the board with their field values |
list_project_fields |
Return all fields and single-select options |
create_project_item |
Create a new draft item (title, optional body) |
update_project_item_field |
Update a field by item ID (field_type: text, number, single-select) |
archive_project_item |
Archive an item by its node ID |
link_issue_to_project |
Add an existing issue to the board by URL |
link_pr_to_project |
Add an existing pull request to the board by URL |
- The GitHub Personal Access Token entered in the consent screen is never written to disk. It is held in a process-level environment variable for the duration of the session and cleared when the server restarts.
- Access tokens expire after 1 hour; refresh tokens expire after 30 days. Both are stored in
.oauth.db(SQLite, gitignored). - The OAuth Client Secret should be treated like a password — rotate it by regenerating the value in
.envand restarting both servers. - The consent screen warns users they are connecting to a self-hosted server. Only share the connector credentials with users you trust.
GITHUB_TOKENin.envis not used when--oauthis active; each authenticated user supplies their own token.
Store your token as a repository or organization secret (e.g. GH_PROJECT_TOKEN), then use the script directly in a workflow step. Pass owner and project identity as CLI flags so each workflow controls its own target board.
Move an issue to "In Progress" when a pull request is opened:
name: Sync project status
on:
pull_request:
types: [opened, reopened]
jobs:
update-board:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Link PR to project
env:
GITHUB_TOKEN: ${{ secrets.GH_PROJECT_TOKEN }}
run: |
python scripts/github_project_crud.py \
--owner my-org --owner-type org --project-number 1 \
link-pr --pr-url "${{ github.event.pull_request.html_url }}"Create a draft item from a workflow input:
on:
workflow_dispatch:
inputs:
title:
required: true
description: Item title
jobs:
create-item:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Create project item
env:
GITHUB_TOKEN: ${{ secrets.GH_PROJECT_TOKEN }}
run: |
python scripts/github_project_crud.py \
--owner my-org --owner-type org --project-number 1 \
create-item --title "${{ inputs.title }}"Pipe output into jq to extract values for downstream steps:
ITEM_ID=$(python scripts/github_project_crud.py \
--owner my-org --owner-type org --project-number 1 \
create-item --title "New item" | jq -r '.id')
python scripts/github_project_crud.py \
--owner my-org --owner-type org --project-number 1 \
update-field --item-id "$ITEM_ID" --field "Status" --value "In Progress"Pass different flags per invocation — no env var changes required:
# Org project
python scripts/github_project_crud.py \
--owner my-org --owner-type org --project-number 1 \
list-items
# Another org, different board
python scripts/github_project_crud.py \
--owner another-org --owner-type org --project-number 7 \
list-items
# User-owned project
python scripts/github_project_crud.py \
--owner my-user --owner-type user --project-number 2 \
list-fieldsIf you always target the same board, set GITHUB_OWNER, GITHUB_OWNER_TYPE, and GITHUB_PROJECT_NUMBER as env vars and omit the flags entirely.
| File | Role |
|---|---|
scripts/github_project_crud.py |
Core library + CLI (argparse) |
scripts/mcp_server.py |
MCP server — stdio (Claude Desktop) and SSE+OAuth modes |
scripts/oauth_server.py |
OAuth 2.0 authorization server (Starlette, port 8766) |
oauth/models.py |
SQLite store for OAuth clients, auth codes, and tokens |
oauth/authorize.py |
/oauth/authorize — consent screen and code issuance |
oauth/token.py |
/oauth/token — authorization code and refresh token grants |
oauth/middleware.py |
Starlette middleware — validates Bearer tokens on MCP requests |
templates/oauth/authorize.html |
Jinja2 consent screen template |
- CLI arguments are parsed by
argparse;--owner,--owner-type, and--project-numberflags are applied to the environment before validation, overriding any corresponding env vars. - Every command resolves the project's node ID via
get_project_id(), which caches the result in-process so subsequent calls within the same invocation are free. - All GitHub API calls go through
graphql_request(), which handles authentication, JSON encoding, timeout (30 s), and error classification. get_project_items()andget_project_fields()implement cursor-based pagination and follow allhasNextPagesignals until the full result set is fetched.- Results are printed as indented JSON to stdout.
mcp_server.py imports github_project_crud directly and registers each public function as a FastMCP tool. Each tool call invokes _apply_context(), which sets the three project env vars and clears the in-process cache — making it safe to target different boards in the same server session. Tool return values are serialized JSON strings. Claude Desktop spawns the process over stdio and manages its lifecycle.
When started with --transport sse --oauth, mcp_server.py calls mcp.sse_app() to get FastMCP's internal Starlette application, then wraps it with BaseHTTPMiddleware backed by oauth/middleware.py. Every incoming SSE request must carry a valid Authorization: Bearer <token> header. The middleware looks up the token in .oauth.db, retrieves the associated GitHub PAT, sets GITHUB_TOKEN in the environment for the duration of the request, then restores the previous value — making the token injection transparent to all tool functions.
The OAuth server (oauth_server.py) is a separate Starlette process on port 8766. It implements the authorization code grant:
GET /oauth/authorize → render consent screen (Jinja2)
POST /oauth/authorize → validate GitHub token, issue auth code, redirect to Claude
POST /oauth/token → exchange auth code or refresh token for Bearer access token
Auth codes expire after 10 minutes. Access tokens expire after 1 hour. Refresh tokens expire after 30 days. All are stored in .oauth.db (SQLite, gitignored).
The CLI writes best-effort JSON events to syslog under the github-project-toolkit identity when syslog is available (Linux/macOS). Events cover command lifecycle, API error categories, and pagination warnings. The following are never logged: tokens, GraphQL variables, item titles, field values, issue or pull request URLs, and raw API response bodies.
| Limitation | Detail |
|---|---|
| Assignee and label pagination | Up to 20 assignees and 20 labels are returned per item field value. Items hitting this limit trigger a user_field_may_be_truncated or label_field_may_be_truncated syslog warning. |
| GitHub Projects v2 only | GitHub Projects v1 (classic) is not supported. |
| Supported field types | text, number, single-select. Date and iteration fields can be read but not written. |
| No bulk operations | Each command targets a single item. Loop in shell or CI for bulk updates. |
# Create and activate a virtual environment
python3 -m venv .venv
. .venv/bin/activate
# CLI only
pip install -e ".[dev]"
# CLI + MCP server (stdio)
pip install -e ".[dev,mcp]"
# CLI + MCP server + OAuth connector
pip install -e ".[dev,oauth]"
# Run the test suite (no network required)
python -m pytest tests/ -v
# Validate syntax without running tests
python -B -m py_compile \
scripts/github_project_crud.py \
scripts/mcp_server.py \
scripts/oauth_server.py \
oauth/models.py oauth/authorize.py oauth/token.py oauth/middleware.py
# Lint
ruff check .Tests use only unittest.mock — no real GitHub API calls are made. The test suite covers environment validation, URL parsing, API response parsing, error message sanitization, pagination truncation warnings, cache behaviour, and CLI argument handling.
See CONTRIBUTING.md for setup instructions and the contribution checklist.
See SECURITY.md for the security policy and responsible disclosure guidance.