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-dotenv| Variable | Required | Description |
|---|---|---|
OAUTH_CLIENT_ID |
Yes | Arbitrary string used as the OAuth client identifier when registering in claude.ai |
OAUTH_CLIENT_SECRET |
Yes | Secret paired with OAUTH_CLIENT_ID; treat like a password |
SESSION_SECRET |
Yes | Secret key for server-side session signing; must be stable across restarts |
MCP_SERVER_URL |
No | Public base URL of the MCP server (default: https://mcp.moisesjafet.com) — used in WWW-Authenticate headers and protected-resource metadata |
OAUTH_SERVER_URL |
No | Public base URL of the OAuth server (default: https://oauth.moisesjafet.com) — used in authorization-server metadata |
OAUTH_DB_PATH |
No | Path to the SQLite token database (default: .oauth.db at the project root) — set this to a persistent directory in containerized deployments |
Generate secrets:
python3 -c "import secrets; print(secrets.token_urlsafe(32))"Minimal .env for a self-hosted deployment:
OAUTH_CLIENT_ID=github-projects-mcp
OAUTH_CLIENT_SECRET=<generated>
SESSION_SECRET=<generated>
MCP_SERVER_URL=https://mcp.yourdomain.com
OAUTH_SERVER_URL=https://oauth.yourdomain.com# 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.
A Dockerfile at the project root builds the complete .[oauth] environment. The compose stack runs the MCP server and OAuth server as separate services sharing a named volume for the SQLite token database.
docker-compose.yml:
services:
mcp:
build: .
restart: unless-stopped
ports:
- "8765:8765"
env_file: .env
environment:
MCP_SERVER_URL: https://mcp.yourdomain.com
OAUTH_SERVER_URL: https://oauth.yourdomain.com
OAUTH_DB_PATH: /app/data/.oauth.db
volumes:
- oauth_db:/app/data
command: python scripts/mcp_server.py --transport sse --host 0.0.0.0 --port 8765 --oauth
oauth:
build: .
restart: unless-stopped
ports:
- "8766:8766"
env_file: .env
environment:
MCP_SERVER_URL: https://mcp.yourdomain.com
OAUTH_SERVER_URL: https://oauth.yourdomain.com
OAUTH_DB_PATH: /app/data/.oauth.db
volumes:
- oauth_db:/app/data
command: python scripts/oauth_server.py --host 0.0.0.0 --port 8766
volumes:
oauth_db:OAUTH_DB_PATH must point to the shared volume so both containers access the same token database. The restart: unless-stopped policy keeps both services running after crashes and host reboots.
docker compose build
docker compose up -dImportant — proxied deployments: if the MCP server is behind a reverse proxy (nginx, Cloudflare, etc.), the FastMCP constructor in mcp_server.py must use host="0.0.0.0" (not 127.0.0.1). When 127.0.0.1 is passed, FastMCP auto-enables DNS-rebinding protection and rejects incoming requests whose Host header doesn't match 127.0.0.1:* — blocking all proxied connections.
Each server needs its own virtual host with SSL terminated at the nginx edge. The backends can be local (127.0.0.1) or remote (e.g. a VPN address when the application servers run on a different machine).
# OAuth authorization server
server {
listen 443 ssl;
server_name oauth.yourdomain.com;
ssl_certificate /etc/letsencrypt/live/oauth.yourdomain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/oauth.yourdomain.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
location / {
proxy_pass http://127.0.0.1:8766; # or VPN IP, e.g. http://10.10.0.x:8766
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# HTTP → HTTPS redirect
listen 80;
}
# MCP server
server {
listen 443 ssl;
server_name mcp.yourdomain.com;
ssl_certificate /etc/letsencrypt/live/mcp.yourdomain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/mcp.yourdomain.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
location / {
proxy_pass http://127.0.0.1:8765; # or VPN IP, e.g. http://10.10.0.x:8765
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# Required for SSE — disable all buffering
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 3600s;
}
listen 80;
}Both domains must be publicly reachable — Claude's servers reach the OAuth endpoints during the auth flow and send MCP requests afterward.
Cloudflare users: set the DNS record to DNS-only (grey cloud) while issuing the Let's Encrypt certificate via HTTP-01 challenge; re-enable the proxy (orange cloud) once the cert is issued.
In claude.ai → Settings → Connectors → Add connector, fill in:
| Field | Value |
|---|---|
| MCP Server URL | https://mcp.yourdomain.com/sse |
| OAuth Client ID | Value of OAUTH_CLIENT_ID from .env |
| OAuth Client Secret | Value of OAUTH_CLIENT_SECRET from .env |
Auto-registration prompt: claude.ai first attempts RFC 7591 dynamic client registration. This server does not implement that endpoint, so claude.ai will display "Automatic client registration isn't supported — add an OAuth Client ID." This is expected. Edit the connector and supply the
OAUTH_CLIENT_IDandOAUTH_CLIENT_SECRETvalues manually.
Click Connect. Claude opens the consent screen at https://oauth.yourdomain.com/oauth/authorize. Enter your GitHub Personal Access Token, click Connect →, and the connector is ready.
The servers expose two auto-discovery endpoints required by claude.ai:
| Endpoint | Server | RFC | Purpose |
|---|---|---|---|
GET /.well-known/oauth-protected-resource |
MCP (port 8765) | RFC 9728 | Declares the resource and points to the authorization server |
GET /.well-known/oauth-authorization-server |
OAuth (port 8766) | RFC 8414 | Lists authorization_endpoint, token_endpoint, and supported grant types |
These are public (no authentication required) and are fetched by claude.ai before redirecting the user to the consent screen. MCP_SERVER_URL and OAUTH_SERVER_URL env vars control the URLs embedded in these responses.
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 stored in
.oauth.db(theaccess_tokens.github_tokencolumn) for the lifetime of the access token (1 hour). It is deleted when the token expires or the database is cleared. Protect.oauth.dbaccordingly — set its permissions to 600 and keep it outside of any web-accessible path. - Access tokens expire after 1 hour; refresh tokens expire after 30 days. The associated GitHub token is carried forward on each refresh so users are not re-prompted.
- The OAuth Client Secret should be treated like a password — rotate it by regenerating the value in
.envand restarting both servers. All active sessions will be invalidated. - Only share the connector credentials (
OAUTH_CLIENT_ID/OAUTH_CLIENT_SECRET) with users you trust. Anyone with those values can initiate the OAuth flow and register their own GitHub token against your server. GITHUB_TOKENin.envis not used when--oauthis active; each authenticated user supplies their own token via the consent screen.- In containerized deployments, the SQLite database lives on a named Docker volume. Restrict access to that volume at the host level.
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, tokens, and GitHub PATs |
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, injects GitHub PAT per request |
templates/oauth/authorize.html |
Jinja2 consent screen template |
Dockerfile |
Container image — python:3.11-slim, installs .[oauth], exposes 8765+8766 |
- 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, wraps it with BaseHTTPMiddleware backed by oauth/middleware.py, and mounts the whole thing under a top-level Starlette app that also serves the /.well-known/oauth-protected-resource discovery endpoint.
Every incoming SSE request must carry a valid Authorization: Bearer <token> header. The middleware queries OAUTH_DB_PATH (.oauth.db by default) directly — this is what allows the MCP process and the OAuth process to share token state even when they run in separate containers. The middleware retrieves the associated GitHub PAT from the access_tokens.github_token column, sets GITHUB_TOKEN in the environment for the duration of the request, then restores the previous value — making the injection transparent to all tool functions.
Unauthenticated requests receive a 401 with a WWW-Authenticate header pointing to the protected-resource metadata URL, from which claude.ai discovers the authorization server.
The OAuth server (oauth_server.py) is a separate Starlette process on port 8766. It implements the authorization code grant:
GET /.well-known/oauth-authorization-server → RFC 8414 metadata (issuer, endpoints, grant types)
GET /oauth/authorize → render consent screen (Jinja2 template)
POST /oauth/authorize → validate GitHub PAT, issue auth code, redirect to Claude callback
POST /oauth/token → exchange auth code or refresh token for Bearer access token
The GitHub PAT entered on the consent screen is stored in process memory (os.environ) only until the token exchange completes (≤ 10 minutes). After the exchange it moves to .oauth.db. Auth codes expire after 10 minutes. Access tokens expire after 1 hour. Refresh tokens expire after 30 days.
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.