MCP server exposing 25 tools for managing Kwork freelance marketplace via Claude. Wraps pykwork (kwork>=0.2.0) with FastMCP 3.x.
uv run ruff check .— lintuv run ruff format --check .— format checkuv run python -m pytest tests/ -x -v— run testsuv run python -m pytest tests/ -x -v --cov=kwork_mcp --cov-report=term-missing— tests + coverageuv run python -m kwork_mcp— start server (stdio)
src/kwork_mcp/
__init__.py — main() entry point (loguru -> stderr, then server.run stdio)
__main__.py — python -m kwork_mcp
config.py — KworkConfig (pydantic-settings, KWORK_ env prefix)
session.py — KworkSessionManager (lazy auth, token persistence, web login, get_exchange_info)
server.py — FastMCP("kwork", lifespan=lifespan), tool registration
errors.py — api_guard() context manager (KworkException -> ToolError, auto-relogin on 401)
rate_limiter.py — InProcessRateLimiter (sliding window, deque + asyncio.Lock)
utils.py — shared utilities (formatting, validation, response helpers, tool annotations)
tools/ — 8 modules, 28 tools (profile, projects, offers, orders, dialogs, kworks, categories, notifications)
tests/ — config, rate_limiter, session, errors, utils, tools tests (97 tests)
- FastMCP 3.x lifespan yields
{"session": KworkSessionManager}— tools access viactx.lifespan_context["session"] - Auth priority: KWORK_TOKEN env ->
~/.kwork_tokenfile -> fresh login (KWORK_LOGIN + KWORK_PASSWORD) - Rate limiter: in-process sliding window (no Redis) — call
session.rate_limit()before every API call - Web flow:
submit_offerrequiresensure_web_client()(triggersweb_login("/exchange")once) - Transport: stdio only — all logging to stderr via loguru (stdout = MCP JSON-RPC)
- pykwork direct: use
kwork.Kwork(akaKworkClient) directly, NOT Pikka's wrapper - Auto-relogin:
api_guard(session=session)catches 401 and callssession.relogin() - Error sanitization: error details go to loguru (stderr), not to the agent;
mask_error_details=Trueon FastMCP - Tool annotations: ANNO_READ / ANNO_WRITE / ANNO_DESTRUCTIVE from
utils.py - Input validation:
validate_positive_int()(aliasvalidate_positive_id),validate_page(),validate_not_empty()fromutils.py
Every tool follows this exact structure:
from kwork_mcp.utils import ANNO_READ, validate_positive_id
@mcp.tool(annotations=ANNO_READ)
async def tool_name(param: int, ctx: Context) -> str:
validate_positive_id(param, "param")
session: KworkSessionManager = ctx.lifespan_context["session"]
client = await session.ensure_client() # or ensure_web_client() for submit
await session.rate_limit()
async with api_guard("operation_name", session=session):
result = await client.some_method(param_name=param)
return "formatted Cyrillic string"- Create
src/kwork_mcp/tools/new_module.pywithdef register(mcp: FastMCP)containing@mcp.tool()functions - Add import +
new_module.register(mcp)call insrc/kwork_mcp/tools/__init__.py
- Typed (
get_me,get_user,get_connects,get_projects,get_categories,get_dialogs_page,get_dialog_with_user_page,send_message) — return dataclass/model objects, access fields directly (e.g.,actor.username) - Generic (
project(),offer(),start_kwork(),kworks_status_list(), etc.) — accept**params, return rawdict, response typically nested under"response"key
Kwork(login, password)— both are required positional args, even for token-based auth (pass empty strings)client.get_token()takes NO arguments — usesself._login/self._passwordfrom constructor- pykwork generic methods (
project(),offer(),start_kwork()) accept**paramspassed directly as HTTP POST params — param names must match Kwork API exactly (e.g.,kwork_id=, NOTid=orkworkId=) KworkHTTPExceptionhas.statusand.response_json— use these for error classification, NOT string matchingsubmit_exchange_offeris a 4-step web flow (open page -> CSRF -> draft -> create) — any step can fail- Captcha (error code 118 in
response_json): no interactive recovery in MCP, raise ToolError with instructions - Token file: write with
os.open(..., 0o600)for atomic permissions, never write-then-chmod - Private pykwork fields: use
_set_client_token()/_get_client_token()helpers fromsession.py
- NEVER: log to stdout (breaks MCP JSON-RPC), commit .env or .kwork_token
- ALWAYS: use native Cyrillic in all user-facing strings, call
rate_limit()before API calls, wrap API calls inapi_guard(session=session) - ALWAYS: pass
session=sessiontoapi_guard()for auto-relogin on 401 - ALWAYS: add input validation (
validate_positive_id,validate_page,validate_not_empty) to new tools - ALWAYS: run
uv run ruff check . && uv run python -m pytest tests/ -xbefore commit
- NEVER add
Co-Authored-Bylines to commit messages - NEVER mention Claude, AI, or co-authorship in commit messages or PR descriptions
- Commit messages should be written as if authored solely by the developer