Skip to content

Commit 87c7f5e

Browse files
authored
Merge pull request #26 from CodeKeanu/feature/issue-14-json-output-mode
feat: add optional JSON output format for all tools
2 parents 1da7858 + 730d6c0 commit 87c7f5e

5 files changed

Lines changed: 244 additions & 26 deletions

File tree

src/steam_mcp/endpoints/base.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ class EndpointTool:
5858
input_schema: dict[str, Any]
5959
handler: Callable[..., Coroutine[Any, Any, str]]
6060
endpoint_class: type["BaseEndpoint"]
61+
supports_json: bool = False
6162

6263

6364
@dataclass
@@ -206,6 +207,7 @@ def endpoint(
206207
name: str,
207208
description: str,
208209
params: dict[str, dict[str, Any] | EndpointParam] | None = None,
210+
supports_json: bool = False,
209211
) -> Callable[[F], F]:
210212
"""
211213
Decorator to register a method as an MCP tool endpoint.
@@ -219,6 +221,8 @@ def endpoint(
219221
description: Human-readable description of what the tool does
220222
params: Dictionary of parameter definitions. Each parameter can be
221223
a dict with keys: type, description, required, enum, default
224+
supports_json: If True, adds a 'format' parameter that allows switching
225+
between 'text' (default) and 'json' output formats
222226
223227
Returns:
224228
Decorated function
@@ -227,6 +231,7 @@ def endpoint(
227231
@endpoint(
228232
name="get_player_summary",
229233
description="Get Steam player profile",
234+
supports_json=True,
230235
params={
231236
"steam_id": {
232237
"type": "string",
@@ -241,10 +246,22 @@ def endpoint(
241246
},
242247
},
243248
)
244-
async def get_player_summary(self, steam_id: str, include_avatar: bool = True) -> str:
249+
async def get_player_summary(self, steam_id: str, include_avatar: bool = True, format: str = "text") -> str:
245250
...
246251
"""
247252
params = params or {}
253+
254+
# If supports_json, auto-add the format parameter
255+
if supports_json:
256+
params = dict(params) # Make a copy to avoid mutating the original
257+
params["format"] = {
258+
"type": "string",
259+
"description": "Output format: 'text' for human-readable output, 'json' for structured JSON",
260+
"enum": ["text", "json"],
261+
"default": "text",
262+
"required": False,
263+
}
264+
248265
input_schema = _build_input_schema(params)
249266

250267
def decorator(func: F) -> F:
@@ -255,6 +272,7 @@ def decorator(func: F) -> F:
255272
"description": description,
256273
"input_schema": input_schema,
257274
"params": params,
275+
"supports_json": supports_json,
258276
}
259277
return func
260278

@@ -289,6 +307,7 @@ def __new__(
289307
input_schema=meta["input_schema"],
290308
handler=attr_value,
291309
endpoint_class=cls, # type: ignore[arg-type]
310+
supports_json=meta.get("supports_json", False),
292311
)
293312
EndpointRegistry.register_tool(tool)
294313

src/steam_mcp/endpoints/player_service.py

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
"""
88

99
import asyncio
10+
import json
1011
from typing import Any
1112

1213
from steam_mcp.endpoints.base import BaseEndpoint, endpoint
@@ -27,6 +28,7 @@ class IPlayerService(BaseEndpoint):
2728
"Returns game names, App IDs, and total playtime. "
2829
"Note: Only works for public profiles unless querying your own profile."
2930
),
31+
supports_json=True,
3032
params={
3133
"steam_id": {
3234
"type": "string",
@@ -64,11 +66,14 @@ async def get_owned_games(
6466
include_free_games: bool = True,
6567
sort_by: str = "playtime",
6668
limit: int = 25,
69+
format: str = "text",
6770
) -> str:
6871
"""Get owned games for a Steam user."""
6972
# Handle "me" / "my" shortcut
7073
normalized_id = await self._resolve_steam_id(steam_id)
7174
if normalized_id.startswith("Error"):
75+
if format == "json":
76+
return json.dumps({"error": normalized_id})
7277
return normalized_id
7378

7479
result = await self.client.get(
@@ -87,10 +92,13 @@ async def get_owned_games(
8792
game_count = response.get("game_count", len(games))
8893

8994
if not games:
90-
return (
95+
error_msg = (
9196
f"No games found for Steam ID {normalized_id}.\n"
9297
"This may indicate a private profile or an account with no games."
9398
)
99+
if format == "json":
100+
return json.dumps({"error": error_msg})
101+
return error_msg
94102

95103
# Sort games
96104
if sort_by == "playtime":
@@ -107,6 +115,30 @@ async def get_owned_games(
107115
# Determine display limit (0 = show all)
108116
display_limit = limit if limit > 0 else len(games)
109117

118+
if format == "json":
119+
data = {
120+
"steam_id": normalized_id,
121+
"total_games": game_count,
122+
"total_playtime_hours": round(total_hours, 1),
123+
"sort_by": sort_by,
124+
"games": [
125+
{
126+
"app_id": g.get("appid"),
127+
"name": g.get("name", f"App {g.get('appid', 'Unknown')}"),
128+
"playtime_minutes": g.get("playtime_forever", 0),
129+
"playtime_hours": round(g.get("playtime_forever", 0) / 60, 1),
130+
"playtime_2weeks_minutes": g.get("playtime_2weeks", 0),
131+
"last_played": g.get("rtime_last_played"),
132+
}
133+
for g in games[:display_limit]
134+
],
135+
}
136+
if len(games) > display_limit:
137+
data["truncated"] = True
138+
data["remaining_games"] = len(games) - display_limit
139+
return json.dumps(data, indent=2)
140+
141+
# Text format
110142
output = [
111143
f"Game Library for {normalized_id}",
112144
f"Total Games: {game_count}",

src/steam_mcp/endpoints/steam_apps.py

Lines changed: 61 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
"""
1010

1111
import asyncio
12+
import json
1213
import re
1314
from dataclasses import dataclass
1415
from datetime import datetime
@@ -190,6 +191,7 @@ async def check_app_up_to_date(
190191
"Get detailed information about a Steam app including description, "
191192
"price, genres, release date, and more. Uses the Steam Store API."
192193
),
194+
supports_json=True,
193195
params={
194196
"app_id": {
195197
"type": "integer",
@@ -208,6 +210,7 @@ async def get_app_details(
208210
self,
209211
app_id: int,
210212
country_code: str = "us",
213+
format: str = "text",
211214
) -> str:
212215
"""Get detailed app information from the Store API."""
213216
try:
@@ -220,24 +223,33 @@ async def get_app_details(
220223
},
221224
)
222225
except Exception as e:
223-
return f"Error fetching app details: {e}"
226+
err = f"Error fetching app details: {e}"
227+
if format == "json":
228+
return json.dumps({"error": err})
229+
return err
224230

225231
app_data = result.get(str(app_id), {})
226232

227233
if not app_data.get("success", False):
228-
return f"App ID {app_id} not found or unavailable in region '{country_code}'."
234+
err = f"App ID {app_id} not found or unavailable in region '{country_code}'."
235+
if format == "json":
236+
return json.dumps({"error": err})
237+
return err
229238

230239
data = app_data.get("data", {})
231240

232241
if not data:
233-
return f"No data available for App ID {app_id}."
242+
err = f"No data available for App ID {app_id}."
243+
if format == "json":
244+
return json.dumps({"error": err})
245+
return err
234246

235247
name = data.get("name", "Unknown")
236248
app_type = data.get("type", "unknown")
237249
is_free = data.get("is_free", False)
238250
short_desc = data.get("short_description", "")
239-
developers = ", ".join(data.get("developers", ["Unknown"]))
240-
publishers = ", ".join(data.get("publishers", ["Unknown"]))
251+
developers = data.get("developers", [])
252+
publishers = data.get("publishers", [])
241253

242254
# Release date
243255
release_info = data.get("release_date", {})
@@ -279,6 +291,48 @@ async def get_app_details(
279291

280292
# Metacritic
281293
metacritic = data.get("metacritic", {})
294+
295+
if format == "json":
296+
json_data: dict[str, Any] = {
297+
"app_id": app_id,
298+
"name": name,
299+
"type": app_type,
300+
"is_free": is_free,
301+
"short_description": short_desc,
302+
"developers": developers,
303+
"publishers": publishers,
304+
"release_date": release_date,
305+
"coming_soon": release_info.get("coming_soon", False),
306+
"platforms": {
307+
"windows": platforms.get("windows", False),
308+
"mac": platforms.get("mac", False),
309+
"linux": platforms.get("linux", False),
310+
},
311+
"genres": genres,
312+
"categories": categories,
313+
"store_url": f"https://store.steampowered.com/app/{app_id}",
314+
}
315+
# Add price info
316+
if is_free:
317+
json_data["price"] = {"is_free": True}
318+
elif price_info:
319+
json_data["price"] = {
320+
"is_free": False,
321+
"currency": price_info.get("currency", ""),
322+
"initial": price_info.get("initial", 0),
323+
"final": price_info.get("final", 0),
324+
"discount_percent": price_info.get("discount_percent", 0),
325+
"final_formatted": price_info.get("final_formatted", ""),
326+
}
327+
# Add metacritic if available
328+
if metacritic:
329+
json_data["metacritic"] = {
330+
"score": metacritic.get("score"),
331+
"url": metacritic.get("url", ""),
332+
}
333+
return json.dumps(json_data, indent=2)
334+
335+
# Text format
282336
metacritic_str = ""
283337
if metacritic:
284338
score = metacritic.get("score", "N/A")
@@ -287,8 +341,8 @@ async def get_app_details(
287341
output = [
288342
f"{name}",
289343
f"App ID: {app_id} | Type: {app_type.title()}",
290-
f"Developer: {developers}",
291-
f"Publisher: {publishers}",
344+
f"Developer: {', '.join(developers) if developers else 'Unknown'}",
345+
f"Publisher: {', '.join(publishers) if publishers else 'Unknown'}",
292346
f"Release Date: {release_date}",
293347
f"Price: {price_str}",
294348
f"Platforms: {platforms_str}",

0 commit comments

Comments
 (0)