Phase 3: structure tools (search, info, markets, container logs)
CI / lint (pull_request) Successful in 52s
CI / lint (push) Successful in 50s
CI / test (pull_request) Successful in 59s
CI / publish (pull_request) Has been skipped
CI / test (push) Successful in 56s
CI / publish (push) Successful in 51s

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) <noreply@anthropic.com>
This commit is contained in:
LatteandClaude Opus 4.8 committed 2026-06-27 19:32:02 +02:00
1 parent eeebfaa684
commit 692c8e245a
4 files changed
+166 -2

No files matched your search

+9
View File
@@ -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}/")
+3 -2
View File
@@ -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)
+75
View File
@@ -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)
+79
View File
@@ -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