Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions mcp/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,11 @@ dependencies = [

[project.scripts]
sts2-mcp = "server:main"

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.hatch.build.targets.wheel]
packages = ["."]
exclude = ["tests"]
10 changes: 10 additions & 0 deletions mcp/server_next.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
"""MCP server bridge for Slay the Spire 2.

Connects to the STS2_MCP mod's HTTP server and exposes game actions
as MCP tools for Claude Desktop / Claude Code.
"""





3 changes: 3 additions & 0 deletions mcp/states/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from states.game import GameState

__all__ = ["GameState"]
67 changes: 67 additions & 0 deletions mcp/states/common/card.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
from pydantic import BaseModel

from states.common.keyword import Keywords


class Card(BaseModel):
"""The card object."""

index: int
id: str
name: str
type: str
cost: str # can be int or "X" (variable cost)
star_cost: str | None = None
description: str
is_upgraded: bool
keywords: Keywords

def _get_cost_str(self) -> str:
"""Get the cost of the card"""
star_cost_str = f" + {self.star_cost} star" if self.star_cost else ""
return f"{self.cost} energy{star_cost_str}"

def _get_base_markdown_str(self) -> str:
"""Get the base markdown string for the card, without description and keywords."""
cost_str = self._get_cost_str()
return f"[{self.index}] **{self.name}** ({cost_str}) [{self.type}]"

def to_markdown(self) -> str:
"""Convert the card to a markdown string."""
base_markdown_str = self._get_base_markdown_str()
keyword_names_str = self.keywords.get_keyword_names_str()
return f"{base_markdown_str}{keyword_names_str} - {self.description}"


class HandCard(Card):
"""The card in the player's hand."""

target_type: str
can_play: bool
unplayable_reason: str | None = None

def to_markdown(self) -> str:
"""Convert the hand card to a markdown string."""
if self.can_play:
playability_str = "\u2713" # check mark
else:
unplayable_reason_str = f" ({self.unplayable_reason})" if self.unplayable_reason else ""
playability_str = f"\u2717{unplayable_reason_str}" # cross mark

target_str = f"(target: {self.target_type})"

base_markdown_str = self._get_base_markdown_str()
keyword_names_str = self.keywords.get_keyword_names_str()
return f"{base_markdown_str} {playability_str}{keyword_names_str} - {self.description} {target_str}"


class RewardCard(Card):
"""The card in the reward screen."""

rarity: str

def to_markdown(self) -> str:
"""Convert the reward card to a markdown string."""
base_markdown_str = self._get_base_markdown_str()
keyword_names_str = self.keywords.get_keyword_names_str()
return f"{base_markdown_str} {self.rarity}{keyword_names_str} - {self.description}"
74 changes: 74 additions & 0 deletions mcp/states/common/keyword.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
from pydantic import BaseModel, model_validator


class Keyword(BaseModel):
"""The keyword object."""

name: str
description: str

def to_markdown(self):
"""Convert the keyword to a markdown string."""
return f"**{self.name}**: {self.description}"


class Keywords(BaseModel):
"""The keywords object, which is a collection of keywords."""

keywords: dict[str, Keyword] # keyword name -> keyword

def __add__(self, other_keywords: "Keywords") -> "Keywords":
"""Combine two sets of keywords."""
combined_keywords = self.keywords.copy()
for name, keyword in other_keywords.keywords.items():
if name not in combined_keywords:
combined_keywords[name] = keyword
return Keywords.model_construct(keywords=combined_keywords)

def get_keyword_names_str(self) -> str:
"""Get the names of the keywords, separated by commas."""
if not self.keywords:
return ""
keyword_names = [name for name in self.keywords]
return f" [{', '.join(keyword_names)}]"

def to_markdown(self) -> str:
"""Convert the keywords to a markdown string."""
if not self.keywords:
return ""
lines = []
for keyword in self.keywords.values():
lines.append(f"- {keyword.to_markdown()}\n")
return "".join(lines)

@model_validator(mode="before")
@classmethod
def from_keyword_list(cls, keyword_list: list[dict]) -> dict:
"""Create a keywords dict from a list of keyword dicts."""
keywords = dict()
for keyword in keyword_list:
keywords[keyword["name"]] = Keyword.model_validate(keyword)
return {"keywords": keywords}


def _get_keywords(state, keywords: Keywords) -> Keywords:
"""Get the keywords from the state."""
if isinstance(state, Keywords):
keywords += state
elif isinstance(state, BaseModel):
# iterate field values directly to preserve pydantic types
for _, value in state:
keywords = _get_keywords(value, keywords)
elif isinstance(state, (list, tuple)):
for item in state:
keywords = _get_keywords(item, keywords)
elif isinstance(state, dict):
for item in state.values():
keywords = _get_keywords(item, keywords)
return keywords


def collect_keywords(state: BaseModel) -> Keywords:
"""Collect all keywords from the state."""
keywords = Keywords.model_construct(keywords=dict())
return _get_keywords(state, keywords)
76 changes: 76 additions & 0 deletions mcp/states/common/multiplayer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
from pydantic import BaseModel

from states.common.pet import Pet


class PlayerSummary(BaseModel):
"""The summary of a player in multiplayer mode."""

character: str
hp: int
max_hp: int
block: int
gold: int
is_local: bool
is_alive: bool
is_ready_to_end_turn: bool | None = None
pets: list[Pet] | None = None

def to_markdown(self) -> str:
"""Convert the player summary to a markdown string."""
local_str = " **(YOU)**" if self.is_local else ""
alive_str = " [DEAD]" if not self.is_alive else ""
ready_str = " [READY]" if self.is_ready_to_end_turn else ""
detail_str = f"HP: {self.hp}/{self.max_hp} | Block: {self.block} | Gold: {self.gold}"

lines = [f"**{self.character}**{local_str}{alive_str}{ready_str} - {detail_str}\n"]
if self.pets:
lines.append("Pets:\n")
for pet in self.pets:
lines.append(f" - {pet.to_markdown(indent=2)}\n")

return "".join(lines)


class MultiplayerState(BaseModel):
"""The state of the multiplayer game."""

net_type: str
player_count: int
local_player_slot: int
players: list[PlayerSummary]

def to_markdown(self) -> str:
"""Convert the multiplayer state to a markdown string."""
lines = []
for player in self.players:
lines.append(f"- {player.to_markdown()}\n")
return "".join(lines)


class BaseVote(BaseModel):
"""The base class for a vote in multiplayer mode."""

player: str
is_local: bool
voted: bool


class MapVote(BaseVote):
"""A vote for the next map in multiplayer mode."""

vote_col: int | None = None
vote_row: int | None = None


class EventVote(BaseVote):
"""A vote for the next event in multiplayer mode."""

vote_option_index: int | None = None


class TreasureVote(BaseVote):
"""A vote for the next treasure in multiplayer mode."""

vote_relic_index: int | None = None

33 changes: 33 additions & 0 deletions mcp/states/common/pet.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
from pydantic import BaseModel

from states.common.status_effect import StatusEffect


class Pet(BaseModel):
"""The pet object."""

id: str
name: str
alive: bool | None = None
hp: int | None = None
max_hp: int | None = None
block: int | None = None
status: list[StatusEffect] | None = None

def to_markdown(self, indent: int = 0) -> str:
indent_str = " " * indent
"""Convert the pet to a markdown string."""
if self.alive is None:
alive_str = "Unknown"
else:
hp = self.hp or 0
max_hp = self.max_hp or 0
block = self.block or 0
alive_str = f"HP: {hp}/{max_hp} | Block: {block}" if self.alive else "Dead"

lines = [f"**{self.name}** - (`{self.id}`) - {alive_str}\n"]
if self.status:
lines.append("Status Effects:\n")
for status_effect in self.status:
lines.append(f" - {status_effect.to_markdown()}\n")
return indent_str.join(lines)
21 changes: 21 additions & 0 deletions mcp/states/common/potion.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
from pydantic import BaseModel

from states.common.keyword import Keywords


class Potion(BaseModel):
"""The potion object."""

id: str
name: str
description: str
slot: int
can_use_in_combat: bool
target_type: str
keywords: Keywords

def to_markdown(self) -> str:
"""Convert the potion to markdown string."""
keyword_names_str = self.keywords.get_keyword_names_str()
target_str = f"(target: {self.target_type})"
return f"[{self.slot}] **{self.name}**{keyword_names_str} - {self.description} {target_str}"
67 changes: 67 additions & 0 deletions mcp/states/common/relic.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
from pydantic import BaseModel, model_validator

from states.common.keyword import Keywords


class Relic(BaseModel):
"""The relic object."""

id: str
name: str
description: str
counter: int | None = None # number if relic shows a counter, null otherwise
keywords: Keywords

def _get_base_markdown_str(self) -> str:
"""Get the base markdown string for the relic, without description and keywords."""
counter_str = f" [{self.counter}]" if self.counter is not None else ""
return f"**{self.name}**{counter_str}"

def to_markdown(self) -> str:
"""Convert the relic to a markdown string."""
base_markdown_str = self._get_base_markdown_str()
keyword_names_str = self.keywords.get_keyword_names_str()
return f"{base_markdown_str}{keyword_names_str} - {self.description}"


class RewardRelic(Relic):
"""The relic in the reward screen."""

index: int
rarity: str

def to_markdown(self) -> str:
"""Convert the reward relic to a markdown string."""
base_markdown_str = self._get_base_markdown_str()
keyword_names_str = self.keywords.get_keyword_names_str()
return f"[{self.index}] {base_markdown_str} {self.rarity}{keyword_names_str} - {self.description}"


class Relics(BaseModel):
"""The state of the player's relics."""

relics: dict[str, list[int | Relic]] # relic markdown -> [count, relic]

def to_markdown(self) -> str:
"""Convert the relics to a markdown string."""
if not self.relics:
return "No relics.\n"

lines = []
for (count, relic) in self.relics.values():
lines.append(f"- {count}x {relic.to_markdown()}\n")
return "".join(lines)

@model_validator(mode="before")
@classmethod
def from_relic_list(cls, relic_list: list[dict]) -> dict:
"""Create a relics dict from a list of relic dicts."""
relics = dict()
for relic in relic_list:
relic_obj = Relic.model_validate(relic)
relic_markdown = relic_obj.to_markdown()
if relic_markdown not in relics:
relics[relic_markdown] = [0, relic_obj]
else:
relics[relic_markdown][0] += 1
return {"relics": relics}
26 changes: 26 additions & 0 deletions mcp/states/common/status_effect.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
from typing import Literal

from pydantic import BaseModel

from states.common.keyword import Keywords


class StatusEffect(BaseModel):
"""The status effect object."""

id: str
name: str
amount: int | None = None
type: Literal["Buff", "Debuff"]
description: str
keywords: Keywords

def to_markdown(self) -> str:
"""Convert the status effect to a markdown string."""
if self.amount == -1 or self.amount is None:
amount_str = "indefinite"
else:
amount_str = str(self.amount)

keyword_names_str = self.keywords.get_keyword_names_str()
return f"**{self.name}** ({amount_str}){keyword_names_str} - {self.description}"
Loading