Shortcut MCP Server
by zekus
- src
- shortcut_mcp
import asyncio
import os
from typing import Any, Optional
import httpx
from mcp.server import Server, NotificationOptions
from mcp.server.models import InitializationOptions
import mcp.types as types
import mcp.server.stdio
# Initialize server
server = Server("shortcut")
# Constants
API_BASE_URL = "https://api.app.shortcut.com/api/v3"
SHORTCUT_API_TOKEN = os.getenv("SHORTCUT_API_TOKEN")
# Helper functions
async def make_shortcut_request(
method: str,
endpoint: str,
json: Optional[dict] = None,
params: Optional[dict] = None
) -> dict[str, Any]:
"""Make an authenticated request to the Shortcut API with safety checks"""
# Safety check: Only allow GET and POST methods
if method not in ["GET", "POST"]:
raise ValueError(f"Method {method} is not allowed for safety reasons. Only GET and POST are permitted.")
# Safety check: POST requests are only allowed for creation endpoints
if method == "POST" and not any(endpoint.endswith(x) for x in ["stories", "epics", "objectives"]):
raise ValueError(f"POST requests are only allowed for creation endpoints, not for {endpoint}")
if not SHORTCUT_API_TOKEN:
raise ValueError("SHORTCUT_API_TOKEN environment variable not set")
headers = {
"Content-Type": "application/json",
"Shortcut-Token": SHORTCUT_API_TOKEN
}
async with httpx.AsyncClient() as client:
response = await client.request(
method=method,
url=f"{API_BASE_URL}/{endpoint}",
headers=headers,
json=json,
params=params,
timeout=30.0
)
response.raise_for_status()
return response.json()
def format_objective(objective: dict) -> str:
"""Format an objective into a readable string"""
return (
f"Objective: {objective['name']}\n"
f"Status: {objective.get('status', 'Unknown')}\n"
f"Description: {objective.get('description', 'No description')}\n"
f"URL: {objective.get('app_url', '')}\n"
"---"
)
def format_epic(epic: dict) -> str:
"""Format an epic into a readable string"""
return (
f"Epic: {epic['name']}\n"
f"Status: {epic.get('state', 'Unknown')}\n"
f"Description: {epic.get('description', 'No description')}\n"
f"Milestone: {epic.get('milestone', {}).get('name', 'None')}\n"
f"URL: {epic.get('app_url', '')}\n"
"---"
)
def format_story(story: dict) -> str:
"""Format a story into a readable string"""
return (
f"Story {story['id']}: {story['name']}\n"
f"Status: {story.get('workflow_state', {}).get('name', 'Unknown')}\n"
f"Type: {story.get('story_type', 'Unknown')}\n"
f"Description: {story.get('description', 'No description')}\n"
f"URL: {story.get('app_url', '')}\n"
"---"
)
@shortcut_server.server.list_tools()
async def handle_list_tools() -> list[types.Tool]:
"""List available Shortcut tools - Read-only operations with safe creation"""
user_info = f" (Will be created as {shortcut_server.authenticated_user.get('name')})"
return [
types.Tool(
name="search-stories",
description="Search for stories in Shortcut",
inputSchema={
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Search query (e.g. story title, description, or ID)",
},
},
"required": ["query"],
},
),
types.Tool(
name="create-story",
description=f"Create a new story in Shortcut{user_info}",
inputSchema={
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Story title",
},
"description": {
"type": "string",
"description": "Story description",
},
"story_type": {
"type": "string",
"description": "Story type (feature, bug, chore)",
"enum": ["feature", "bug", "chore"],
},
"project_id": {
"type": "number",
"description": "Project ID to create the story in",
},
},
"required": ["name", "description", "story_type", "project_id"],
},
),
types.Tool(
types.Tool(
name="list-projects",
description="List all projects in Shortcut",
inputSchema={
"type": "object",
"properties": {},
},
),
types.Tool(
name= "list-workflows",
description="List all workflows and their states",
inputSchema={
"type": "object",
"properties": {},
},
),
types.Tool(
name="list-objectives",
description="List all objectives in Shortcut",
inputSchema={
"type": "object",
"properties": {
"status": {
"type": "string",
"description": "Filter by status (active, draft, closed)",
"enum": ["active", "draft", "closed"],
},
},
},
),
types.Tool(
name="create-objective",
description=f"Create a new objective in Shortcut{user_info}",
inputSchema={
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Objective name",
},
"description": {
"type": "string",
"description": "Objective description",
},
"status": {
"type": "string",
"description": "Objective status",
"enum": ["active", "draft", "closed"],
},
},
"required": ["name", "description", "status"],
},
),
types.Tool(
name="list-epics",
description="List epics in Shortcut",
inputSchema={
"type": "object",
"properties": {
"status": {
"type": "string",
"description": "Filter by status (to do, in progress, done)",
"enum": ["to do", "in progress", "done"],
},
},
},
),
types.Tool(
name="create-epic",
description=f"Create a new epic in Shortcut{user_info}",
inputSchema={
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Epic name",
},
"description": {
"type": "string",
"description": "Epic description",
},
"milestone_id": {
"type": "number",
"description": "Optional milestone ID to associate with the epic",
},
},
"required": ["name", "description"],
},
),
types.Tool(
]
@shortcut_server.server.call_tool()
async def handle_call_tool(
name: str,
arguments: dict | None
) -> list[types.TextContent | types.ImageContent | types.EmbeddedResource]:
"""Handle tool execution requests"""
if not arguments:
raise ValueError("Missing arguments")
try:
if name == "search-stories":
query = arguments.get("query")
search_results = await make_shortcut_request(
"GET",
"search/stories",
params={"query": query}
)
stories = search_results.get("data", [])
if not stories:
return [types.TextContent(
type="text",
text=f"No stories found matching query: {query}"
)]
formatted_stories = [format_story(story) for story in stories[:10]]
return [types.TextContent(
type="text",
text="Found stories:\n\n" + "\n".join(formatted_stories)
)]
elif name == "create-story":
story_data = {
"name": arguments["name"],
"description": arguments["description"],
"story_type": arguments["story_type"],
"project_id": arguments["project_id"],
}
new_story = await make_shortcut_request(
"POST",
"stories",
json=story_data
)
return [types.TextContent(
type="text",
text=f"Created new story:\n\n{format_story(new_story)}"
)]
elif name == "list-projects":
projects = await make_shortcut_request("GET", "projects")
formatted_projects = []
for project in projects:
formatted_projects.append(
f"Project ID: {project['id']}\n"
f"Name: {project['name']}\n"
f"Description: {project.get('description', 'No description')}\n"
"---"
)
return [types.TextContent(
type="text",
text="Available projects:\n\n" + "\n".join(formatted_projects)
)]
elif name == "list-workflows":
workflows = await make_shortcut_request("GET", "workflows")
formatted_workflows = []
for workflow in workflows:
states = [
f"- {state['name']} (ID: {state['id']})"
for state in workflow.get("states", [])
]
formatted_workflows.append(
f"Workflow: {workflow['name']}\n"
f"States:\n" + "\n".join(states) + "\n"
"---"
)
return [types.TextContent(
type="text",
text="Available workflows and states:\n\n" + "\n".join(formatted_workflows)
)]
else:
elif name == "list-objectives":
params = {}
if status := arguments.get("status"):
params["status"] = status
objectives = await make_shortcut_request("GET", "objectives", params=params)
if not objectives:
return [types.TextContent(
type="text",
text="No objectives found"
)]
formatted_objectives = [format_objective(obj) for obj in objectives]
return [types.TextContent(
type="text",
text="Objectives:\n\n" + "\n".join(formatted_objectives)
)]
elif name == "create-objective":
objective_data = {
"name": arguments["name"],
"description": arguments["description"],
"status": arguments["status"],
}
new_objective = await make_shortcut_request(
"POST",
"objectives",
json=objective_data
)
return [types.TextContent(
type="text",
text=f"Created new objective:\n\n{format_objective(new_objective)}"
)]
elif name == "list-epics":
params = {}
if status := arguments.get("status"):
params["status"] = status
epics = await make_shortcut_request("GET", "epics", params=params)
if not epics:
return [types.TextContent(
type="text",
text="No epics found"
)]
formatted_epics = [format_epic(epic) for epic in epics]
return [types.TextContent(
type="text",
text="Epics:\n\n" + "\n".join(formatted_epics)
)]
elif name == "create-epic":
epic_data = {
"name": arguments["name"],
"description": arguments["description"],
}
if milestone_id := arguments.get("milestone_id"):
epic_data["milestone_id"] = milestone_id
new_epic = await make_shortcut_request(
"POST",
"epics",
json=epic_data
)
return [types.TextContent(
type="text",
text=f"Created new epic:\n\n{format_epic(new_epic)}"
)]
else:
raise ValueError(f"Unknown tool: {name}")
except httpx.HTTPError as e:
return [types.TextContent(
type="text",
text=f"API request failed: {str(e)}"
)]
async def main():
"""Run the server using stdin/stdout streams"""
# Initialize server and authenticate
await shortcut_server.initialize()
async with mcp.server.stdio.stdio_server() as (read_stream, write_stream):
await shortcut_server.server.run(
read_stream,
write_stream,
InitializationOptions(
server_name="shortcut",
server_version="0.1.0",
capabilities=shortcut_server.server.get_capabilities(
notification_options=NotificationOptions(),
experimental_capabilities={},
),
),
)
if __name__ == "__main__":
asyncio.run(main())