From 692c8e245abd4dc2c4573b685dd185ccb274a1ee Mon Sep 17 00:00:00 2001 From: Latte Date: Sat, 27 Jun 2026 19:32:02 +0200 Subject: [PATCH] Phase 3: structure tools (search, info, markets, container logs) Add read-only structure tools: search_structures (per character), get_structure and get_structure_market (single structure via one token), and get_corp container logs. New charutil.one_token helper picks a single authenticated token (named character or first). 31 tools total, 75 tests, coverage 92%. Closes #21. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/familiar/charutil.py | 9 +++++ src/familiar/server.py | 5 ++- src/familiar/structures.py | 75 ++++++++++++++++++++++++++++++++++++ tests/test_structures.py | 79 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 166 insertions(+), 2 deletions(-) create mode 100644 src/familiar/structures.py create mode 100644 tests/test_structures.py diff --git a/src/familiar/charutil.py b/src/familiar/charutil.py index a714e4f..0872a19 100644 --- a/src/familiar/charutil.py +++ b/src/familiar/charutil.py @@ -48,6 +48,15 @@ async def run_per_character(character: str | int | None, fetch: CharFetcher) -> return {"characters": results} +async def one_token(character: str | int | None = None) -> tuple[str, int]: + """A single ``(access_token, character_id)`` — the named character, or the first. + + For lookups that are about a *thing* (e.g. a structure) rather than per + character, but still need an authenticated token to access it. + """ + return await auth.get_auth(_targets(character)[0]) + + async def corporation_id(character_id: int) -> int: """Look up a character's corporation id (public endpoint, no token needed).""" info = await client().get(f"/characters/{character_id}/") diff --git a/src/familiar/server.py b/src/familiar/server.py index 33147ef..081f762 100644 --- a/src/familiar/server.py +++ b/src/familiar/server.py @@ -11,7 +11,7 @@ import datetime as _dt from fastmcp import FastMCP -from . import __version__, account, industry, market, trade, universe +from . import __version__, account, industry, market, structures, trade, universe mcp: FastMCP = FastMCP( name="familiar", @@ -47,6 +47,7 @@ market.register(mcp) # Phase 2: authenticated, read-only account lookups (requires `familiar login`). account.register(mcp) -# Phase 3: industry & trade (character + corp), read-only. +# Phase 3: industry, trade & structures (character + corp), read-only. industry.register(mcp) trade.register(mcp) +structures.register(mcp) diff --git a/src/familiar/structures.py b/src/familiar/structures.py new file mode 100644 index 0000000..f2b4e4a --- /dev/null +++ b/src/familiar/structures.py @@ -0,0 +1,75 @@ +"""Read-only structure tools: search, info, structure markets, container logs. + +Structures (player-owned citadels/engineering complexes) are only visible with +a token that has access, so these need a logged-in character. Lookups about a +single structure use one character's token; the search is run per character. +""" + +from __future__ import annotations + +from typing import Any + +from fastmcp import FastMCP + +from .charutil import one_token, run_per_character, run_per_corp, summarize +from .esi import client + + +async def search_structures(query: str, character: str | int | None = None) -> dict[str, Any]: + """Search structures by name for one character or all (needs structure access).""" + + async def fetch(token: str, cid: int) -> dict[str, Any]: + res = ( + await client().get( + f"/characters/{cid}/search/", + params={"categories": "structure", "search": query}, + token=token, + ) + or {} + ) + return {"matches": res.get("structure", [])} + + return await run_per_character(character, fetch) + + +async def get_structure(structure_id: int, character: str | int | None = None) -> dict[str, Any]: + """Info for one structure (name, system, type, owner). Uses one character's token.""" + token, _ = await one_token(character) + data = await client().get(f"/universe/structures/{structure_id}/", token=token) + return { + "structure_id": structure_id, + "name": data.get("name"), + "solar_system_id": data.get("solar_system_id"), + "type_id": data.get("type_id"), + "owner_id": data.get("owner_id"), + } + + +async def get_structure_market( + structure_id: int, character: str | int | None = None, limit: int = 20 +) -> dict[str, Any]: + """Market orders inside a player structure. Uses one character's token.""" + token, _ = await one_token(character) + orders = await client().get_all_pages(f"/markets/structures/{structure_id}/", token=token) + return {"structure_id": structure_id, **summarize(orders, limit)} + + +async def get_container_logs(character: str | int | None = None, limit: int = 20) -> dict[str, Any]: + """Corp container audit logs (needs a corp role) for one character's corp or all.""" + + async def fetch(token: str, corp_id: int, cid: int) -> dict[str, Any]: + logs = await client().get_all_pages( + f"/corporations/{corp_id}/containers/logs/", token=token + ) + return summarize(logs, limit) + + return await run_per_corp(character, fetch) + + +TOOLS = [search_structures, get_structure, get_structure_market, get_container_logs] + + +def register(mcp: FastMCP) -> None: + """Register the structure tools onto the given server.""" + for fn in TOOLS: + mcp.tool(fn) diff --git a/tests/test_structures.py b/tests/test_structures.py new file mode 100644 index 0000000..76b3b8b --- /dev/null +++ b/tests/test_structures.py @@ -0,0 +1,79 @@ +"""Structure tools (search, info, market, container logs), auth & ESI mocked.""" + +import asyncio + +import pytest + +from conftest import route +from familiar import auth, structures +from familiar.server import mcp + +CHARS = [ + {"character_id": 99, "character_name": "Alpha", "scopes": []}, + {"character_id": 100, "character_name": "Beta", "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) + + def resolve(identifier): + for c in CHARS: + if ( + str(c["character_id"]) == str(identifier) + or (c["character_name"] or "").lower() == str(identifier).lower() + ): + return c["character_id"] + raise auth.NotAuthenticated("no such character") + + monkeypatch.setattr(auth, "resolve_character", resolve) + return CHARS + + +def test_search_structures_per_character(mock_esi, fake_auth): + mock_esi(lambda r: route(r, {"/search/": {"structure": [1001, 1002]}})) + res = asyncio.run(structures.search_structures("Sotiyo")) + assert len(res["characters"]) == 2 + assert res["characters"][0]["matches"] == [1001, 1002] + + +def test_get_structure_uses_one_token(mock_esi, fake_auth): + body = {"name": "Jita Trade Hub Citadel", "solar_system_id": 30000142, "type_id": 35834} + mock_esi(lambda r: route(r, {"/universe/structures/": body})) + res = asyncio.run(structures.get_structure(1001)) + assert res["structure_id"] == 1001 + assert res["solar_system_id"] == 30000142 + + +def test_get_structure_market(mock_esi, fake_auth): + orders = [{"order_id": 1, "price": 5.0}, {"order_id": 2, "price": 6.0}] + mock_esi(lambda r: route(r, {"/markets/structures/": orders})) + res = asyncio.run(structures.get_structure_market(1001, character="Alpha")) + assert res["count"] == 2 + + +def test_container_logs_resolves_corp(mock_esi, fake_auth): + fixtures = { + "/containers/logs/": [{"action": "add"}], + "/characters/": {"corporation_id": 2000}, + } + mock_esi(lambda r: route(r, fixtures)) + res = asyncio.run(structures.get_container_logs(character=99)) + assert res["characters"][0]["corporation_id"] == 2000 + assert res["characters"][0]["count"] == 1 + + +def test_structure_tools_registered(): + tools = {t.name for t in asyncio.run(mcp.list_tools())} + assert { + "search_structures", + "get_structure", + "get_structure_market", + "get_container_logs", + } <= tools