From f4f6ddede8b769e4b7c1b3f490299d9166e29f1a Mon Sep 17 00:00:00 2001 From: Latte Date: Sat, 27 Jun 2026 20:07:02 +0200 Subject: [PATCH] =?UTF-8?q?Phase=204:=20remaining=20reads=20=E2=80=94=20FW?= =?UTF-8?q?,=20projects,=20freelance,=20activities,=20access=20(#34,#35,#3?= =?UTF-8?q?6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add read-only tools: faction-warfare stats (char + corp), freelance jobs (char + corp), corp projects, character activities, and access lists. FW endpoints are stable; the newer projects/freelance/activities/access paths are best-effort and flagged for live confirmation. Closes #34, #35, #36. 106 tests, coverage 91%. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/familiar/misc.py | 106 +++++++++++++++++++++++++++++++++++++++++ src/familiar/server.py | 4 ++ tests/test_misc.py | 69 +++++++++++++++++++++++++++ 3 files changed, 179 insertions(+) create mode 100644 src/familiar/misc.py create mode 100644 tests/test_misc.py diff --git a/src/familiar/misc.py b/src/familiar/misc.py new file mode 100644 index 0000000..6368577 --- /dev/null +++ b/src/familiar/misc.py @@ -0,0 +1,106 @@ +"""Read-only tools for the remaining scopes: faction warfare, projects, +freelance jobs, activities, and access lists. + +The faction-warfare endpoints are stable. The projects / freelance / activities +/ access endpoints are newer; their exact ESI paths are best-effort and may need +a tweak once verified against live ESI (the per-character/corp plumbing is the +same regardless). +""" + +from __future__ import annotations + +from typing import Any + +from fastmcp import FastMCP + +from .charutil import run_per_character, run_per_corp, summarize +from .esi import client + + +async def get_fw_stats(character: str | int | None = None) -> dict[str, Any]: + """Faction-warfare stats for one character or all.""" + + async def fetch(token: str, cid: int) -> dict[str, Any]: + stats = await client().get(f"/characters/{cid}/fw/stats/", token=token) or {} + return {"stats": stats} + + return await run_per_character(character, fetch) + + +async def get_corp_fw_stats(character: str | int | None = None) -> dict[str, Any]: + """Corp faction-warfare stats (needs a corp role).""" + + async def fetch(token: str, corp_id: int, cid: int) -> dict[str, Any]: + stats = await client().get(f"/corporations/{corp_id}/fw/stats/", token=token) or {} + return {"stats": stats} + + return await run_per_corp(character, fetch) + + +async def get_freelance_jobs(character: str | int | None = None, limit: int = 20) -> dict[str, Any]: + """Freelance jobs available/accepted by one character or all.""" + + async def fetch(token: str, cid: int) -> dict[str, Any]: + jobs = await client().get_all_pages(f"/characters/{cid}/freelance/jobs/", token=token) + return summarize(jobs, limit) + + return await run_per_character(character, fetch) + + +async def get_corp_freelance_jobs( + character: str | int | None = None, limit: int = 20 +) -> dict[str, Any]: + """Freelance jobs posted by the corp (needs a corp role).""" + + async def fetch(token: str, corp_id: int, cid: int) -> dict[str, Any]: + jobs = await client().get_all_pages(f"/corporations/{corp_id}/freelance/jobs/", token=token) + return summarize(jobs, limit) + + return await run_per_corp(character, fetch) + + +async def get_corp_projects(character: str | int | None = None, limit: int = 20) -> dict[str, Any]: + """Corp projects (needs a corp role).""" + + async def fetch(token: str, corp_id: int, cid: int) -> dict[str, Any]: + projects = await client().get_all_pages(f"/corporations/{corp_id}/projects/", token=token) + return summarize(projects, limit) + + return await run_per_corp(character, fetch) + + +async def get_activities(character: str | int | None = None, limit: int = 20) -> dict[str, Any]: + """Character activity tracking for one character or all.""" + + async def fetch(token: str, cid: int) -> dict[str, Any]: + items = await client().get(f"/characters/{cid}/activities/", token=token) or [] + return summarize(items if isinstance(items, list) else [items], limit) + + return await run_per_character(character, fetch) + + +async def get_access_lists(character: str | int | None = None) -> dict[str, Any]: + """Access control lists (ACLs) visible to one character or all.""" + + async def fetch(token: str, cid: int) -> dict[str, Any]: + data = await client().get(f"/characters/{cid}/access/", token=token) or {} + return {"access": data} + + return await run_per_character(character, fetch) + + +TOOLS = [ + get_fw_stats, + get_corp_fw_stats, + get_freelance_jobs, + get_corp_freelance_jobs, + get_corp_projects, + get_activities, + get_access_lists, +] + + +def register(mcp: FastMCP) -> None: + """Register the remaining read-only tools onto the given server.""" + for fn in TOOLS: + mcp.tool(fn) diff --git a/src/familiar/server.py b/src/familiar/server.py index 7cb7bde..af9d2ed 100644 --- a/src/familiar/server.py +++ b/src/familiar/server.py @@ -19,6 +19,7 @@ from . import ( industry, killmails, market, + misc, profile, structures, trade, @@ -79,3 +80,6 @@ corp.register(mcp) # Phase 4: corp economy & facilities (read-only, needs corp roles). corp_economy.register(mcp) + +# Phase 4: faction warfare, projects, freelance, activities, access (read-only). +misc.register(mcp) diff --git a/tests/test_misc.py b/tests/test_misc.py new file mode 100644 index 0000000..a7b7eea --- /dev/null +++ b/tests/test_misc.py @@ -0,0 +1,69 @@ +"""Remaining read tools: FW, projects, freelance, activities, access.""" + +import asyncio + +import pytest + +from conftest import route +from familiar import auth, misc +from familiar.server import mcp + +CHARS = [{"character_id": 99, "character_name": "Alpha", "scopes": []}] + + +@pytest.fixture +def fake_auth(monkeypatch): + monkeypatch.setattr(auth, "list_characters", lambda: list(CHARS)) + + async def get_auth(cid, **kw): + return f"token-{cid}", cid + + monkeypatch.setattr(auth, "get_auth", get_auth) + monkeypatch.setattr(auth, "resolve_character", lambda i: 99) + return CHARS + + +def test_fw_stats(mock_esi, fake_auth): + mock_esi(lambda r: route(r, {"/fw/stats/": {"kills": {"total": 10}}})) + res = asyncio.run(misc.get_fw_stats()) + assert res["characters"][0]["stats"]["kills"]["total"] == 10 + + +def test_corp_fw_stats(mock_esi, fake_auth): + fixtures = {"/fw/stats/": {"pilots": 5}, "/characters/": {"corporation_id": 2000}} + mock_esi(lambda r: route(r, fixtures)) + res = asyncio.run(misc.get_corp_fw_stats()) + assert res["characters"][0]["corporation_id"] == 2000 + assert res["characters"][0]["stats"]["pilots"] == 5 + + +def test_freelance_jobs(mock_esi, fake_auth): + mock_esi(lambda r: route(r, {"/freelance/jobs/": [{"job_id": 1}]})) + res = asyncio.run(misc.get_freelance_jobs()) + assert res["characters"][0]["count"] == 1 + + +def test_corp_projects(mock_esi, fake_auth): + fixtures = {"/projects/": [{"id": 1}, {"id": 2}], "/characters/": {"corporation_id": 2000}} + mock_esi(lambda r: route(r, fixtures)) + res = asyncio.run(misc.get_corp_projects()) + assert res["characters"][0]["count"] == 2 + + +def test_access_lists(mock_esi, fake_auth): + mock_esi(lambda r: route(r, {"/access/": {"lists": [1, 2]}})) + res = asyncio.run(misc.get_access_lists()) + assert res["characters"][0]["access"]["lists"] == [1, 2] + + +def test_misc_tools_registered(): + tools = {t.name for t in asyncio.run(mcp.list_tools())} + assert { + "get_fw_stats", + "get_corp_fw_stats", + "get_freelance_jobs", + "get_corp_freelance_jobs", + "get_corp_projects", + "get_activities", + "get_access_lists", + } <= tools