Skip to content

forgejo_projects_mcp.client

Source: src/forgejo_projects_mcp/client.py

client.py is the integration layer. It speaks to Forgejo's browser-facing web routes using Playwright's asynchronous APIRequestContext, persists the authenticated cookie state, parses HTML, and exposes typed Python methods used by the MCP server.

Configuration constants

At import time the module computes:

CONFIG_DIR = <XDG_CONFIG_HOME or Path.home() / ".config"> / "forgejo_projects_mcp"
STATE_FILE = CONFIG_DIR / "storage_state.json"

It also reads FORGEJO_MCP_MAX_CONCURRENCY (default 8) and FORGEJO_MCP_RPS (default 5). See Configuration.

Exceptions

AuthError

class AuthError(RuntimeError)
AuthError(message: str, *, code: str = "AUTH_FAILED")

Raised when required credentials are absent or the login response does not establish a session. code is stable and is exposed by the server error boundary; missing variables use MISSING_CONFIG.

ForgejoError

class ForgejoError(RuntimeError)
ForgejoError(message: str, *, status: int | None = None, code: str | None = None)

Represents a Forgejo HTTP error, a transport failure, or a parsing/lookup failure. The optional status stores the HTTP status (None for transport failures), and code stores a stable label such as NETWORK_ERROR, ISSUE_NOT_FOUND, or HTTP_404.

ForgejoClient

ForgejoClient()

The constructor reads:

  • FORGEJO_URL, with trailing slashes removed;
  • FORGEJO_USERNAME;
  • FORGEJO_PASSWORD; and
  • the module-level throttling defaults.

It does not start Playwright or make a network request until an async operation needs it.

Authentication and lifecycle

ensure

async ensure() -> None

Ensures an authenticated request context exists. It validates credentials, starts Playwright, loads STATE_FILE when present, checks /user/settings, and logs in if needed. Calls are serialized with an async lock.

login

async login(force: bool = False) -> dict[str, Any]

Performs an explicit login and returns:

{
  "authenticated": true,
  "instance": "https://forge.example.com",
  "username": "your-username",
  "state_file": ".../storage_state.json"
}

With force=True, the existing request context/cache is ignored and a fresh login is performed.

status

async status() -> dict[str, Any]

Calls ensure() and returns authenticated session information including state_cached. AuthError and ForgejoError are converted into {authenticated: False, error, instance} rather than raised.

close

async close() -> None

Disposes the request context and stops Playwright. It is safe to call multiple times and suppresses teardown failures after logging them at debug level.

Discovery and projects

list_repositories

async list_repositories(query: str = "", limit: int = 50, page: int = 1) -> list[dict[str, Any]]

Requests /repo/search and normalizes each result to:

{
    "full_name": str | None,
    "owner": str,
    "name": str,
    "description": str,
    "private": bool | None,
    "archived": bool | None,
    "empty": bool | None,
    "fork": bool | None,
}

list_projects

async list_projects(owner: str, repo: str, state: str = "open") -> list[dict[str, Any]]

Returns {id, title} entries parsed from the repository projects page. state is validated against open, closed, and all. all makes separate open and closed requests and merges IDs.

create_project

async create_project(
    owner: str, repo: str, title: str,
    description: str = "", card_type: str = "text",
) -> dict[str, Any]

Posts a project form, maps text to Forgejo card type 1 and images_and_text to 2, then reads the open project list to recover the new ID. Returns {created: True, project: {id, title} | None}.

get_project

async get_project(owner: str, repo: str, project_id: int) -> dict[str, Any]

Parses the board page and returns:

{
    "id": project_id,
    "title": str,
    "columns": [
        {
            "id": int,
            "title": str,
            "cards": [
                {"issue_id": int, "number": int | None, "title": str}
            ],
        }
    ],
}

update_project

async update_project(
    owner: str, repo: str, project_id: int,
    title: str | None = None, description: str | None = None,
    card_type: str | None = None,
) -> dict[str, Any]

Reads the edit form to preserve an omitted title and card type. It sends description as an empty string when that argument is None, so callers should pass the desired description explicitly when updating only another field. Returns {updated: True, project_id}.

close_project, reopen_project, delete_project

async close_project(owner, repo, project_id) -> dict[str, Any]
async reopen_project(owner, repo, project_id) -> dict[str, Any]
async delete_project(owner, repo, project_id) -> dict[str, Any]

Use POST actions close, open, and delete. They return {close: True, project_id}, {open: True, project_id}, or {deleted: True, project_id} respectively. Project deletion does not delete issues.

Columns

async create_column(owner, repo, project_id, title, color: str = "") -> dict[str, Any]
async edit_column(owner, repo, project_id, column_id,
                  title: str | None = None, color: str | None = None) -> dict[str, Any]
async delete_column(owner, repo, project_id, column_id) -> dict[str, Any]
async set_default_column(owner, repo, project_id, column_id) -> dict[str, Any]
  • create_column posts the title/color and re-reads the board to recover the created column object.
  • edit_column uses PUT and sends only non-None fields.
  • delete_column uses DELETE. If the upstream rejects deletion, the raised error adds a hint that the default column must be changed first.
  • set_default_column posts the default action and returns {default_column, project_id}.

Cards and issues

resolve_issue_id

async resolve_issue_id(owner: str, repo: str, number: int) -> int

Fetches the issue page and extracts data-issue-id. A missing marker raises ForgejoError(code="ISSUE_NOT_FOUND", status=404). This is the boundary between repository issue numbers and Forgejo internal IDs.

add_issues_to_project and remove_issues_from_project

async add_issues_to_project(owner, repo, project_id, issue_numbers: list[int]) -> dict[str, Any]
async remove_issues_from_project(owner, repo, issue_numbers: list[int]) -> dict[str, Any]

Both resolve every repository issue number first, then post to /issues/projects. Attach uses the project ID; detach uses project ID 0. Detaching leaves the issue intact.

move_card

async move_card(owner, repo, project_id, column_id,
                issue_numbers: list[int]) -> dict[str, Any]

Resolves issue IDs and posts JSON shaped like:

{"issues": [{"issueID": 1042, "sorting": 0}, {"issueID": 1043, "sorting": 1}]}

The return value includes the original issue numbers, target column ID, and the upstream JSON body when available.

create_issue and delete_issue

async create_issue(
    owner, repo, title, body: str = "", project_id: int | None = None,
    milestone_id: int | None = None, label_ids: list[int] | None = None,
    assignee_ids: list[int] | None = None,
) -> dict[str, Any]
async delete_issue(owner, repo, number) -> dict[str, Any]

create_issue posts the issue form and extracts the repository number from Forgejo's JSON redirect when available. delete_issue permanently deletes the issue by repository number.

Full and bulk reads

_parse_issue output

Issue HTML is normalized to:

{
    "number": int | None,
    "title": str,
    "state": "open" | "closed",
    "body": str,
    "milestone": {"id": int, "title": str} | None,
    "comments": [{"author": str | None, "body": str}],
}

read_issue(owner, repo, number) fetches and parses one issue. If the page lacks a number marker, it uses the requested number as a fallback.

async read_issue(owner, repo, number: int) -> dict[str, Any]
async bulk_read_issues(owner, repo, numbers: list[int], state: str = "all") -> list[dict[str, Any]]

bulk_read_issues runs issue reads concurrently with asyncio.gather(..., return_exceptions=True), preserves input order, and represents an individual failure as {"number": n, "error": "..."}. state post-filters successful issue content.

Filtered readers

async read_column_content(
    owner, repo, project_id, column_id, state: str = "all",
    milestone: int | None = None, limit: int | None = None, offset: int = 0,
) -> dict[str, Any]

async read_milestone_content(
    owner, repo, milestone_id, state: str = "all",
    project: int | None = None, limit: int | None = None, offset: int = 0,
) -> dict[str, Any]

async read_project_content(
    owner, repo, project_id, state: str = "all",
    milestone: int | None = None, limit: int | None = None, offset: int = 0,
) -> dict[str, Any]

All validate state. They collect matching repository issue numbers, apply zero-based offset/limit, read full issue content, and report total, returned, truncated, and error_count. Project content preserves all board columns and groups selected issues beneath them. Missing columns and milestones raise COLUMN_NOT_FOUND and MILESTONE_NOT_FOUND.

bulk_move_cards

async bulk_move_cards(owner, repo, project_id,
                      moves: list[dict[str, int]]) -> dict[str, Any]

Each move has issue_number and column_id. Issue ID resolution runs concurrently. Moves are grouped by column; each column request preserves the order of its input group, and column requests run concurrently.

Milestones

async list_milestones(owner, repo, state: str = "open") -> list[dict[str, Any]]
async create_milestone(owner, repo, title, description: str = "",
                       deadline: str = "") -> dict[str, Any]
async edit_milestone(owner, repo, milestone_id, title: str | None = None,
                     description: str | None = None,
                     deadline: str | None = None) -> dict[str, Any]
async close_milestone(owner, repo, milestone_id) -> dict[str, Any]
async reopen_milestone(owner, repo, milestone_id) -> dict[str, Any]
async delete_milestone(owner, repo, milestone_id) -> dict[str, Any]

Milestone listing validates state and merges separate open/closed pages for all. Creation re-reads open milestones to recover the new ID. Editing currently sends empty strings for omitted fields. Deletion uses the collection route with id=N, matching the observed Forgejo behavior; it does not use /milestones/{id}/delete.

Private helpers worth knowing

The following methods are implementation details but explain operational behavior:

  • _request adds authentication, JSON/form encoding, throttling, 429/503 retries, session-bounce re-authentication, and HTTP error conversion;
  • _default_config_dir computes the OS-independent cache root;
  • _parse_projects_list, _parse_board, _parse_issue, and _parse_milestones scrape HTML with regular expressions;
  • _filtered_issue_numbers uses the repository issues page for server-side state/project/milestone filtering; and
  • _page performs the final in-memory slice and returns (selected, total).

The route contract and HTML anchors are documented separately in the automation reference.