diff --git a/servers/linear/Dockerfile b/servers/linear/Dockerfile new file mode 100644 index 0000000..478aaf2 --- /dev/null +++ b/servers/linear/Dockerfile @@ -0,0 +1,16 @@ +FROM python:3.11-slim + +WORKDIR /app + +# Copy requirements and install dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy server code +COPY server.py . + +# Expose port +EXPOSE 8000 + +# Run the FastMCP server +CMD ["python", "-m", "fastmcp", "run", "server.py", "--transport", "streamable-http", "--port", "8000"] diff --git a/servers/linear/README.md b/servers/linear/README.md new file mode 100644 index 0000000..29f7f65 --- /dev/null +++ b/servers/linear/README.md @@ -0,0 +1,1043 @@ +# Linear MCP Server + +MCP server for Linear API. Streamlined issue tracking, project management, sprint planning, and team collaboration for modern development teams. + +## Features + +- **Issue Management**: Create, update, search, and track issues +- **Project Planning**: Manage projects with milestones and roadmaps +- **Sprint Cycles**: Track sprint progress and metrics +- **Team Collaboration**: Organize teams and assign work +- **Labels & Organization**: Custom labels and filtering +- **GraphQL API**: Powerful, flexible queries +- **Real-time Updates**: Live issue status tracking +- **Custom Fields**: Priority levels, states, and metadata +- **Search & Filter**: Advanced issue search capabilities +- **Roadmap Planning**: Long-term project visibility + +## Setup + +### Prerequisites + +- Linear account (Free or paid plan) +- API key with appropriate permissions + +### Environment Variables + +- `LINEAR_API_KEY` (required): Your Linear API key + +**How to get credentials:** +1. Go to [linear.app/settings/api](https://linear.app/settings/api) +2. Sign in to your Linear workspace +3. Click "Create new key" or "Personal API keys" +4. Give your key a descriptive name +5. Copy the generated key (starts with `lin_api_`) +6. Store as `LINEAR_API_KEY` + +**API Key Format:** +- Format: `lin_api_xxxxxxxxxxxxxxxxxxxxxxxx` +- Keep your key secure - it has full access to your workspace + +## Rate Limits + +**Standard Rate Limits:** +- 1500 requests per minute per IP address +- 50 requests per second per IP address +- Queries are counted by complexity (points system) +- Simple queries: 1-5 points +- Complex queries with relations: 10-50 points + +**Best Practices:** +- Batch operations when possible +- Use pagination for large datasets +- Cache frequently accessed data +- Implement exponential backoff for retries + +## GraphQL API + +Linear uses GraphQL, which means: +- All requests POST to single endpoint: `https://api.linear.app/graphql` +- Specify exactly what data you need +- Combine multiple operations efficiently +- Strong typing and introspection +- Nested data fetching in single request + +## Available Tools + +### Issue Management + +#### `list_issues` +List and filter issues across your workspace. + +**Parameters:** +- `team_id` (string, optional): Filter by team ID +- `project_id` (string, optional): Filter by project ID +- `assignee_id` (string, optional): Filter by assignee user ID +- `label_id` (string, optional): Filter by label ID +- `state` (string, optional): Filter by state name (backlog, unstarted, started, completed, canceled) +- `first` (int, optional): Number of issues to return (default: 50) + +**Example:** +```python +# List all issues +issues = await list_issues(first=20) + +# Filter by team +issues = await list_issues(team_id="team-123", first=10) + +# Filter by state +issues = await list_issues(state="started", first=25) + +# Multiple filters +issues = await list_issues( + team_id="team-123", + assignee_id="user-456", + state="unstarted" +) + +# Returns: +# { +# "data": { +# "issues": { +# "nodes": [ +# { +# "id": "issue-123", +# "title": "Implement user authentication", +# "description": "Add OAuth2 support...", +# "priority": 2, +# "state": { +# "name": "In Progress", +# "type": "started" +# }, +# "assignee": { +# "id": "user-456", +# "name": "Jane Smith" +# }, +# "labels": { +# "nodes": [ +# {"id": "label-789", "name": "backend"} +# ] +# }, +# "createdAt": "2025-10-01T10:00:00Z", +# "updatedAt": "2025-10-08T14:30:00Z" +# } +# ] +# } +# } +# } +``` + +#### `get_issue` +Get detailed information about a specific issue. + +**Parameters:** +- `issue_id` (string, required): Issue ID + +**Example:** +```python +issue = await get_issue(issue_id="issue-123") + +# Returns: +# { +# "data": { +# "issue": { +# "id": "issue-123", +# "title": "Fix login bug", +# "description": "Users unable to login with email...", +# "priority": 1, +# "estimate": 3, +# "state": { +# "name": "In Progress", +# "type": "started" +# }, +# "assignee": { +# "id": "user-456", +# "name": "Jane Smith", +# "email": "jane@company.com" +# }, +# "labels": { +# "nodes": [ +# {"id": "label-123", "name": "bug", "color": "#ff0000"} +# ] +# }, +# "project": { +# "id": "proj-789", +# "name": "Q4 Release" +# }, +# "team": { +# "id": "team-123", +# "name": "Engineering" +# }, +# "createdAt": "2025-10-05T09:00:00Z", +# "updatedAt": "2025-10-08T15:00:00Z", +# "url": "https://linear.app/company/issue/ENG-123" +# } +# } +# } +``` + +#### `create_issue` +Create a new issue in Linear. + +**Parameters:** +- `team_id` (string, required): Team ID +- `title` (string, required): Issue title +- `description` (string, optional): Issue description (markdown supported) +- `priority` (int, optional): Priority level (0=none, 1=urgent, 2=high, 3=medium, 4=low, default: 0) +- `assignee_id` (string, optional): Assignee user ID +- `project_id` (string, optional): Project ID +- `label_ids` (list of strings, optional): List of label IDs + +**Priority Levels:** +- `0`: No priority +- `1`: Urgent (red) +- `2`: High (orange) +- `3`: Medium (yellow) +- `4`: Low (blue) + +**Example:** +```python +# Simple issue +issue = await create_issue( + team_id="team-123", + title="Add dark mode support" +) + +# Full issue with all fields +issue = await create_issue( + team_id="team-123", + title="Implement user dashboard", + description="Create a personalized dashboard with:\n- Activity feed\n- Quick actions\n- Stats overview", + priority=2, + assignee_id="user-456", + project_id="proj-789", + label_ids=["label-123", "label-456"] +) + +# Returns: +# { +# "data": { +# "issueCreate": { +# "success": true, +# "issue": { +# "id": "issue-new-123", +# "title": "Implement user dashboard", +# "url": "https://linear.app/company/issue/ENG-124" +# } +# } +# } +# } +``` + +#### `update_issue` +Update an existing issue. + +**Parameters:** +- `issue_id` (string, required): Issue ID +- `title` (string, optional): Updated title +- `description` (string, optional): Updated description +- `priority` (int, optional): Updated priority (0-4) +- `state_id` (string, optional): Updated state ID +- `assignee_id` (string, optional): Updated assignee ID + +**Example:** +```python +# Update title +result = await update_issue( + issue_id="issue-123", + title="Fix critical login bug" +) + +# Update multiple fields +result = await update_issue( + issue_id="issue-123", + priority=1, + assignee_id="user-789", + description="Updated: Users getting 500 error on login" +) + +# Change state +result = await update_issue( + issue_id="issue-123", + state_id="state-completed" +) + +# Returns: +# { +# "data": { +# "issueUpdate": { +# "success": true, +# "issue": { +# "id": "issue-123", +# "title": "Fix critical login bug", +# "state": { +# "name": "Done" +# } +# } +# } +# } +# } +``` + +#### `delete_issue` +Delete an issue. + +**Parameters:** +- `issue_id` (string, required): Issue ID + +**Example:** +```python +result = await delete_issue(issue_id="issue-123") + +# Returns: +# { +# "data": { +# "issueDelete": { +# "success": true +# } +# } +# } +``` + +#### `add_comment` +Add a comment to an issue. + +**Parameters:** +- `issue_id` (string, required): Issue ID +- `body` (string, required): Comment body (markdown supported) + +**Example:** +```python +# Simple comment +comment = await add_comment( + issue_id="issue-123", + body="Working on this now" +) + +# Markdown comment +comment = await add_comment( + issue_id="issue-123", + body="""## Update + +- Completed API integration +- Testing authentication flow +- Need to review error handling + +**ETA:** End of day""" +) + +# Returns: +# { +# "data": { +# "commentCreate": { +# "success": true, +# "comment": { +# "id": "comment-123", +# "body": "Working on this now", +# "createdAt": "2025-10-08T16:00:00Z" +# } +# } +# } +# } +``` + +#### `search_issues` +Search issues with full-text query. + +**Parameters:** +- `query_text` (string, required): Search query +- `first` (int, optional): Number of results (default: 20) + +**Search Tips:** +- Search by title, description, or comments +- Use quotes for exact phrases: `"login bug"` +- Case-insensitive matching +- Returns most relevant results first + +**Example:** +```python +# Simple search +results = await search_issues(query_text="authentication") + +# Specific phrase +results = await search_issues(query_text="\"user login\"", first=10) + +# Returns: +# { +# "data": { +# "issueSearch": { +# "nodes": [ +# { +# "id": "issue-123", +# "title": "Fix authentication flow", +# "description": "User login not working...", +# "state": {"name": "In Progress"}, +# "assignee": {"name": "Jane Smith"}, +# "url": "https://linear.app/company/issue/ENG-123" +# } +# ] +# } +# } +# } +``` + +### Project Management + +#### `list_projects` +List all projects in your workspace. + +**Parameters:** +- `team_id` (string, optional): Filter by team ID +- `first` (int, optional): Number of projects to return (default: 50) + +**Example:** +```python +# All projects +projects = await list_projects(first=20) + +# Team projects +projects = await list_projects(team_id="team-123") + +# Returns: +# { +# "data": { +# "projects": { +# "nodes": [ +# { +# "id": "proj-123", +# "name": "Q4 2025 Release", +# "description": "Major feature release...", +# "state": "started", +# "progress": 0.65, +# "targetDate": "2025-12-31", +# "lead": { +# "id": "user-456", +# "name": "John Doe" +# }, +# "createdAt": "2025-09-01T00:00:00Z" +# } +# ] +# } +# } +# } +``` + +#### `get_project` +Get detailed project information. + +**Parameters:** +- `project_id` (string, required): Project ID + +**Example:** +```python +project = await get_project(project_id="proj-123") + +# Returns: +# { +# "data": { +# "project": { +# "id": "proj-123", +# "name": "Mobile App Redesign", +# "description": "Complete UI/UX overhaul...", +# "state": "started", +# "progress": 0.42, +# "targetDate": "2026-01-31", +# "startDate": "2025-10-01", +# "lead": { +# "id": "user-456", +# "name": "Jane Smith" +# }, +# "teams": { +# "nodes": [ +# {"id": "team-123", "name": "Design"}, +# {"id": "team-456", "name": "Engineering"} +# ] +# }, +# "url": "https://linear.app/company/project/redesign" +# } +# } +# } +``` + +#### `create_project` +Create a new project. + +**Parameters:** +- `name` (string, required): Project name +- `team_ids` (list of strings, required): List of team IDs +- `description` (string, optional): Project description +- `target_date` (string, optional): Target completion date (YYYY-MM-DD) +- `lead_id` (string, optional): Project lead user ID + +**Example:** +```python +# Simple project +project = await create_project( + name="API v2 Migration", + team_ids=["team-123"] +) + +# Full project +project = await create_project( + name="Customer Portal", + team_ids=["team-123", "team-456"], + description="Self-service customer dashboard with billing and support", + target_date="2026-03-31", + lead_id="user-789" +) + +# Returns: +# { +# "data": { +# "projectCreate": { +# "success": true, +# "project": { +# "id": "proj-new-123", +# "name": "Customer Portal", +# "url": "https://linear.app/company/project/customer-portal" +# } +# } +# } +# } +``` + +#### `list_milestones` +List project milestones. + +**Parameters:** +- `project_id` (string, optional): Filter by project ID +- `first` (int, optional): Number of milestones (default: 50) + +**Example:** +```python +# All milestones +milestones = await list_milestones(first=20) + +# Project milestones +milestones = await list_milestones(project_id="proj-123") + +# Returns: +# { +# "data": { +# "projectMilestones": { +# "nodes": [ +# { +# "id": "milestone-123", +# "name": "Beta Release", +# "description": "Feature-complete beta version", +# "targetDate": "2025-11-30", +# "project": { +# "id": "proj-123", +# "name": "Q4 Release" +# } +# } +# ] +# } +# } +# } +``` + +#### `get_roadmap` +Get roadmap view of all projects. + +**Parameters:** +- `first` (int, optional): Number of items (default: 50) + +**Example:** +```python +roadmap = await get_roadmap(first=30) + +# Returns: +# { +# "data": { +# "projects": { +# "nodes": [ +# { +# "id": "proj-123", +# "name": "Mobile App v2", +# "description": "Next generation mobile experience", +# "state": "started", +# "progress": 0.35, +# "targetDate": "2026-02-28", +# "startDate": "2025-10-01", +# "lead": {"name": "Jane Smith"} +# } +# ] +# } +# } +# } +``` + +### Team Management + +#### `list_teams` +List all teams in your workspace. + +**Example:** +```python +teams = await list_teams() + +# Returns: +# { +# "data": { +# "teams": { +# "nodes": [ +# { +# "id": "team-123", +# "name": "Engineering", +# "key": "ENG", +# "description": "Product development team", +# "private": false, +# "createdAt": "2025-01-01T00:00:00Z" +# } +# ] +# } +# } +# } +``` + +#### `get_team` +Get detailed team information. + +**Parameters:** +- `team_id` (string, required): Team ID + +**Example:** +```python +team = await get_team(team_id="team-123") + +# Returns: +# { +# "data": { +# "team": { +# "id": "team-123", +# "name": "Engineering", +# "key": "ENG", +# "description": "Product development team", +# "private": false, +# "members": { +# "nodes": [ +# { +# "id": "user-123", +# "name": "Jane Smith", +# "email": "jane@company.com" +# } +# ] +# }, +# "projects": { +# "nodes": [ +# {"id": "proj-123", "name": "Q4 Release"} +# ] +# } +# } +# } +# } +``` + +### Sprint Management + +#### `list_cycles` +List sprint cycles. + +**Parameters:** +- `team_id` (string, optional): Filter by team ID +- `first` (int, optional): Number of cycles (default: 20) + +**Example:** +```python +# All cycles +cycles = await list_cycles(first=10) + +# Team cycles +cycles = await list_cycles(team_id="team-123") + +# Returns: +# { +# "data": { +# "cycles": { +# "nodes": [ +# { +# "id": "cycle-123", +# "number": 42, +# "name": "Sprint 42", +# "startsAt": "2025-10-07T00:00:00Z", +# "endsAt": "2025-10-20T23:59:59Z", +# "progress": 0.58, +# "completedIssueCount": 12, +# "issueCount": 18, +# "team": { +# "id": "team-123", +# "name": "Engineering" +# } +# } +# ] +# } +# } +# } +``` + +#### `get_cycle` +Get detailed cycle information. + +**Parameters:** +- `cycle_id` (string, required): Cycle ID + +**Example:** +```python +cycle = await get_cycle(cycle_id="cycle-123") + +# Returns: +# { +# "data": { +# "cycle": { +# "id": "cycle-123", +# "number": 42, +# "name": "Sprint 42", +# "description": "Focus on authentication improvements", +# "startsAt": "2025-10-07T00:00:00Z", +# "endsAt": "2025-10-20T23:59:59Z", +# "progress": 0.58, +# "completedIssueCount": 12, +# "issueCount": 18, +# "team": { +# "id": "team-123", +# "name": "Engineering" +# }, +# "url": "https://linear.app/company/cycle/42" +# } +# } +# } +``` + +### Labels & Organization + +#### `list_labels` +List all labels. + +**Parameters:** +- `team_id` (string, optional): Filter by team ID + +**Example:** +```python +# All labels +labels = await list_labels() + +# Team labels +labels = await list_labels(team_id="team-123") + +# Returns: +# { +# "data": { +# "issueLabels": { +# "nodes": [ +# { +# "id": "label-123", +# "name": "bug", +# "description": "Something isn't working", +# "color": "#ff0000", +# "team": { +# "id": "team-123", +# "name": "Engineering" +# } +# } +# ] +# } +# } +# } +``` + +#### `create_label` +Create a new label. + +**Parameters:** +- `name` (string, required): Label name +- `team_id` (string, required): Team ID +- `color` (string, optional): Hex color code (e.g., "#FF0000") +- `description` (string, optional): Label description + +**Example:** +```python +# Simple label +label = await create_label( + name="security", + team_id="team-123" +) + +# Full label +label = await create_label( + name="performance", + team_id="team-123", + color="#FFA500", + description="Performance optimization tasks" +) + +# Returns: +# { +# "data": { +# "issueLabelCreate": { +# "success": true, +# "issueLabel": { +# "id": "label-new-123", +# "name": "performance", +# "color": "#FFA500" +# } +# } +# } +# } +``` + +## Common Workflows + +### Daily Standup Preparation +```python +# Get team's current sprint +cycles = await list_cycles(team_id="team-123", first=1) +current_cycle = cycles["data"]["cycles"]["nodes"][0] + +# Get my active issues +my_issues = await list_issues( + assignee_id="user-456", + state="started", + first=10 +) + +# Check recently completed issues +completed = await list_issues( + assignee_id="user-456", + state="completed", + first=5 +) +``` + +### Sprint Planning +```python +# Get upcoming cycle +cycle = await get_cycle(cycle_id="cycle-123") + +# Review backlog issues +backlog = await list_issues( + team_id="team-123", + state="backlog", + first=50 +) + +# Create sprint issues +for item in sprint_plan: + issue = await create_issue( + team_id="team-123", + title=item["title"], + description=item["description"], + priority=item["priority"], + assignee_id=item["assignee"] + ) +``` + +### Bug Triage +```python +# Get all bugs +bugs = await search_issues(query_text="bug", first=30) + +# Or use label filter +bugs = await list_issues(label_id="label-bug-123") + +# Prioritize urgent bugs +for bug in urgent_bugs: + await update_issue( + issue_id=bug["id"], + priority=1, + state_id="state-started" + ) + + await add_comment( + issue_id=bug["id"], + body="Escalated to urgent - investigating now" + ) +``` + +### Project Status Update +```python +# Get project details +project = await get_project(project_id="proj-123") + +# Get project milestones +milestones = await list_milestones(project_id="proj-123") + +# Get issues for project +issues = await list_issues(project_id="proj-123", first=100) + +# Calculate metrics +total = len(issues["data"]["issues"]["nodes"]) +completed = len([i for i in issues["data"]["issues"]["nodes"] + if i["state"]["type"] == "completed"]) +progress = completed / total if total > 0 else 0 +``` + +### Roadmap Planning +```python +# Get all active projects +roadmap = await get_roadmap(first=50) + +# Create new quarterly project +project = await create_project( + name="Q1 2026 Infrastructure", + team_ids=["team-123"], + description="Scale infrastructure for 10x growth", + target_date="2026-03-31", + lead_id="user-789" +) + +# Add milestones (would need milestone creation tool) +# Add initial issues +for initiative in initiatives: + await create_issue( + team_id="team-123", + project_id=project["data"]["projectCreate"]["project"]["id"], + title=initiative["title"], + description=initiative["description"] + ) +``` + +### Team Performance Metrics +```python +# Get team info +team = await get_team(team_id="team-123") + +# Get current cycle +cycles = await list_cycles(team_id="team-123", first=1) +cycle = cycles["data"]["cycles"]["nodes"][0] + +# Calculate velocity +completed = cycle["completedIssueCount"] +total = cycle["issueCount"] +velocity = completed / total if total > 0 else 0 + +# Get member contributions +for member in team["data"]["team"]["members"]["nodes"]: + member_issues = await list_issues( + assignee_id=member["id"], + first=100 + ) +``` + +## Issue States + +Linear uses a workflow with these standard state types: + +- **backlog**: Not yet scheduled +- **unstarted**: Planned but not started +- **started**: Currently in progress +- **completed**: Done and verified +- **canceled**: Won't be completed + +Teams can customize state names (e.g., "In Review", "Testing") while keeping these types. + +## Priority Levels + +| Level | Name | Color | Use Case | +|-------|------|-------|----------| +| 0 | None | Gray | Default, no urgency | +| 1 | Urgent | Red | Critical issues, production down | +| 2 | High | Orange | Important features, significant bugs | +| 3 | Medium | Yellow | Standard work, normal priority | +| 4 | Low | Blue | Nice-to-have, low impact | + +## Best Practices + +1. **Use team_id filters**: Narrow down results for better performance +2. **Pagination**: Use `first` parameter to limit results +3. **Specific queries**: Request only the fields you need +4. **Batch operations**: Group related changes together +5. **State management**: Follow your team's workflow states +6. **Labels**: Use consistent labeling for better filtering +7. **Search wisely**: Use specific terms for better search results +8. **Cache data**: Don't repeatedly fetch unchanged data +9. **Error handling**: Implement retries with backoff +10. **Monitor rate limits**: Track API usage + +## GraphQL Tips + +### Request Only Needed Fields +```python +# Good - minimal fields +query = """ + query { + issues(first: 10) { + nodes { id title } + } + } +""" + +# Avoid - too many unnecessary fields +query = """ + query { + issues(first: 10) { + nodes { + id title description priority + state { ... } + assignee { ... } + # many more fields + } + } + } +""" +``` + +### Use Filters Effectively +```python +# Good - specific filters +issues = await list_issues( + team_id="team-123", + state="started", + first=10 +) + +# Less efficient - fetch everything then filter +all_issues = await list_issues(first=1000) +# then filter in Python +``` + +### Pagination +```python +# For large datasets, use pagination +first_page = await list_issues(first=50) +# Get cursor from last item for next page +# Linear supports cursor-based pagination +``` + +## Error Handling + +Common GraphQL errors: + +- **Authentication failed**: Invalid or expired API key +- **Not found**: Resource ID doesn't exist +- **Rate limited**: Too many requests +- **Validation error**: Invalid input parameters +- **Insufficient permissions**: User lacks access + +All tools return GraphQL response format: +```json +{ + "data": { ... }, + "errors": [ + { + "message": "Error description", + "extensions": { "code": "ERROR_CODE" } + } + ] +} +``` + +## API Documentation + +- [Linear API Documentation](https://developers.linear.app/) +- [GraphQL API Reference](https://developers.linear.app/docs/graphql/working-with-the-graphql-api) +- [Schema Explorer](https://studio.apollographql.com/public/Linear-API/home) +- [Webhooks](https://developers.linear.app/docs/graphql/webhooks) +- [OAuth](https://developers.linear.app/docs/oauth/authentication) + +## Support + +- [Help Center](https://linear.app/help) +- [Community Slack](https://linear.app/join-slack) +- [GitHub Discussions](https://github.com/linearapp/linear/discussions) +- [Twitter](https://twitter.com/linear) diff --git a/servers/linear/requirements.txt b/servers/linear/requirements.txt new file mode 100644 index 0000000..42f0167 --- /dev/null +++ b/servers/linear/requirements.txt @@ -0,0 +1,4 @@ +fastmcp>=0.2.0 +httpx>=0.27.0 +python-dotenv>=1.0.0 +uvicorn>=0.30.0 diff --git a/servers/linear/server.json b/servers/linear/server.json new file mode 100644 index 0000000..1a55d79 --- /dev/null +++ b/servers/linear/server.json @@ -0,0 +1,86 @@ +{ + "$schema": "https://registry.nimbletools.ai/schemas/2025-09-22/nimbletools-server.schema.json", + "name": "ai.nimbletools/linear", + "version": "1.0.0", + "description": "Linear API: issue tracking, project management, sprints, roadmaps, and team collaboration", + "status": "active", + "repository": { + "url": "https://github.com/NimbleBrainInc/mcp-linear", + "source": "github", + "branch": "main" + }, + "websiteUrl": "https://linear.app/", + "packages": [ + { + "registryType": "oci", + "registryBaseUrl": "https://docker.io", + "identifier": "nimbletools/mcp-linear", + "version": "1.0.0", + "transport": { + "type": "streamable-http", + "url": "https://mcp.nimbletools.ai/mcp" + }, + "environmentVariables": [ + { + "name": "LINEAR_API_KEY", + "description": "Linear API key (get from https://linear.app/settings/api)", + "isRequired": true, + "isSecret": true, + "example": "lin_api_xxxxxxxxxxxxxxxxxxxxxxxx" + } + ] + } + ], + "_meta": { + "ai.nimbletools.mcp/v1": { + "container": { + "healthCheck": { + "path": "/health", + "port": 8000 + } + }, + "capabilities": { + "tools": true, + "resources": false, + "prompts": false + }, + "resources": { + "limits": { + "memory": "256Mi", + "cpu": "250m" + }, + "requests": { + "memory": "128Mi", + "cpu": "100m" + } + }, + "deployment": { + "protocol": "http", + "port": 8000, + "mcpPath": "/mcp" + }, + "display": { + "name": "Linear", + "category": "developer-tools", + "tags": [ + "linear", + "issue-tracking", + "project-management", + "agile", + "sprints", + "roadmap", + "collaboration", + "graphql", + "requires-api-key" + ], + "branding": { + "logoUrl": "https://static.nimbletools.ai/logos/linear.png", + "iconUrl": "https://static.nimbletools.ai/icons/linear.png" + }, + "documentation": { + "readmeUrl": "https://raw.githubusercontent.com/NimbleBrainInc/mcp-linear/main/README.md" + } + } + } + } +} diff --git a/servers/linear/server.py b/servers/linear/server.py new file mode 100644 index 0000000..a1bd8a7 --- /dev/null +++ b/servers/linear/server.py @@ -0,0 +1,708 @@ +import os +from typing import Optional, List, Dict, Any +import httpx +from fastmcp import FastMCP + +mcp = FastMCP("Linear") + +API_KEY = os.getenv("LINEAR_API_KEY") +BASE_URL = "https://api.linear.app/graphql" + + +def get_headers() -> dict: + """Get headers with API key authorization.""" + return { + "Authorization": API_KEY, + "Content-Type": "application/json" + } + + +async def graphql_query(query: str, variables: Optional[Dict[str, Any]] = None) -> dict: + """Execute GraphQL query.""" + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.post( + BASE_URL, + headers=get_headers(), + json={"query": query, "variables": variables or {}} + ) + response.raise_for_status() + return response.json() + + +@mcp.tool() +async def list_issues( + team_id: Optional[str] = None, + project_id: Optional[str] = None, + assignee_id: Optional[str] = None, + label_id: Optional[str] = None, + state: Optional[str] = None, + first: int = 50 +) -> dict: + """List issues with filters. + + Args: + team_id: Filter by team ID + project_id: Filter by project ID + assignee_id: Filter by assignee ID + label_id: Filter by label ID + state: Filter by state (backlog, unstarted, started, completed, canceled) + first: Number of issues to return (default: 50) + """ + filters = [] + if team_id: + filters.append(f'team: {{ id: {{ eq: "{team_id}" }} }}') + if project_id: + filters.append(f'project: {{ id: {{ eq: "{project_id}" }} }}') + if assignee_id: + filters.append(f'assignee: {{ id: {{ eq: "{assignee_id}" }} }}') + if label_id: + filters.append(f'labels: {{ id: {{ eq: "{label_id}" }} }}') + if state: + filters.append(f'state: {{ name: {{ eq: "{state}" }} }}') + + filter_str = ", ".join(filters) if filters else "" + + query = f""" + query {{ + issues(first: {first}, filter: {{ {filter_str} }}) {{ + nodes {{ + id + title + description + priority + state {{ + name + type + }} + assignee {{ + id + name + }} + labels {{ + nodes {{ + id + name + }} + }} + createdAt + updatedAt + }} + }} + }} + """ + + return await graphql_query(query) + + +@mcp.tool() +async def get_issue(issue_id: str) -> dict: + """Get issue details. + + Args: + issue_id: Issue ID + """ + query = f""" + query {{ + issue(id: "{issue_id}") {{ + id + title + description + priority + estimate + state {{ + name + type + }} + assignee {{ + id + name + email + }} + labels {{ + nodes {{ + id + name + color + }} + }} + project {{ + id + name + }} + team {{ + id + name + }} + createdAt + updatedAt + url + }} + }} + """ + + return await graphql_query(query) + + +@mcp.tool() +async def create_issue( + team_id: str, + title: str, + description: Optional[str] = None, + priority: int = 0, + assignee_id: Optional[str] = None, + project_id: Optional[str] = None, + label_ids: Optional[List[str]] = None +) -> dict: + """Create a new issue. + + Args: + team_id: Team ID (required) + title: Issue title (required) + description: Issue description in markdown + priority: Priority (0=none, 1=urgent, 2=high, 3=medium, 4=low) + assignee_id: Assignee user ID + project_id: Project ID + label_ids: List of label IDs + """ + input_fields = [ + f'teamId: "{team_id}"', + f'title: "{title}"', + f'priority: {priority}' + ] + + if description: + input_fields.append(f'description: "{description}"') + if assignee_id: + input_fields.append(f'assigneeId: "{assignee_id}"') + if project_id: + input_fields.append(f'projectId: "{project_id}"') + if label_ids: + label_ids_str = ", ".join([f'"{lid}"' for lid in label_ids]) + input_fields.append(f'labelIds: [{label_ids_str}]') + + input_str = ", ".join(input_fields) + + mutation = f""" + mutation {{ + issueCreate(input: {{ {input_str} }}) {{ + success + issue {{ + id + title + url + }} + }} + }} + """ + + return await graphql_query(mutation) + + +@mcp.tool() +async def update_issue( + issue_id: str, + title: Optional[str] = None, + description: Optional[str] = None, + priority: Optional[int] = None, + state_id: Optional[str] = None, + assignee_id: Optional[str] = None +) -> dict: + """Update issue details. + + Args: + issue_id: Issue ID + title: Updated title + description: Updated description + priority: Updated priority (0-4) + state_id: Updated state ID + assignee_id: Updated assignee ID + """ + updates = [] + if title: + updates.append(f'title: "{title}"') + if description: + updates.append(f'description: "{description}"') + if priority is not None: + updates.append(f'priority: {priority}') + if state_id: + updates.append(f'stateId: "{state_id}"') + if assignee_id: + updates.append(f'assigneeId: "{assignee_id}"') + + update_str = ", ".join(updates) if updates else "" + + mutation = f""" + mutation {{ + issueUpdate(id: "{issue_id}", input: {{ {update_str} }}) {{ + success + issue {{ + id + title + state {{ + name + }} + }} + }} + }} + """ + + return await graphql_query(mutation) + + +@mcp.tool() +async def delete_issue(issue_id: str) -> dict: + """Delete an issue. + + Args: + issue_id: Issue ID + """ + mutation = f""" + mutation {{ + issueDelete(id: "{issue_id}") {{ + success + }} + }} + """ + + return await graphql_query(mutation) + + +@mcp.tool() +async def add_comment( + issue_id: str, + body: str +) -> dict: + """Add comment to an issue. + + Args: + issue_id: Issue ID + body: Comment body in markdown + """ + mutation = f""" + mutation {{ + commentCreate(input: {{ issueId: "{issue_id}", body: "{body}" }}) {{ + success + comment {{ + id + body + createdAt + }} + }} + }} + """ + + return await graphql_query(mutation) + + +@mcp.tool() +async def list_projects( + team_id: Optional[str] = None, + first: int = 50 +) -> dict: + """List all projects. + + Args: + team_id: Filter by team ID + first: Number of projects to return (default: 50) + """ + filter_str = f'filter: {{ team: {{ id: {{ eq: "{team_id}" }} }} }}' if team_id else "" + + query = f""" + query {{ + projects(first: {first}, {filter_str}) {{ + nodes {{ + id + name + description + state + progress + targetDate + lead {{ + id + name + }} + createdAt + }} + }} + }} + """ + + return await graphql_query(query) + + +@mcp.tool() +async def get_project(project_id: str) -> dict: + """Get project details. + + Args: + project_id: Project ID + """ + query = f""" + query {{ + project(id: "{project_id}") {{ + id + name + description + state + progress + targetDate + startDate + lead {{ + id + name + }} + teams {{ + nodes {{ + id + name + }} + }} + url + }} + }} + """ + + return await graphql_query(query) + + +@mcp.tool() +async def create_project( + name: str, + team_ids: List[str], + description: Optional[str] = None, + target_date: Optional[str] = None, + lead_id: Optional[str] = None +) -> dict: + """Create a new project. + + Args: + name: Project name (required) + team_ids: List of team IDs (required) + description: Project description + target_date: Target completion date (YYYY-MM-DD) + lead_id: Project lead user ID + """ + team_ids_str = ", ".join([f'"{tid}"' for tid in team_ids]) + input_fields = [ + f'name: "{name}"', + f'teamIds: [{team_ids_str}]' + ] + + if description: + input_fields.append(f'description: "{description}"') + if target_date: + input_fields.append(f'targetDate: "{target_date}"') + if lead_id: + input_fields.append(f'leadId: "{lead_id}"') + + input_str = ", ".join(input_fields) + + mutation = f""" + mutation {{ + projectCreate(input: {{ {input_str} }}) {{ + success + project {{ + id + name + url + }} + }} + }} + """ + + return await graphql_query(mutation) + + +@mcp.tool() +async def list_teams() -> dict: + """List all teams.""" + query = """ + query { + teams { + nodes { + id + name + key + description + private + createdAt + } + } + } + """ + + return await graphql_query(query) + + +@mcp.tool() +async def get_team(team_id: str) -> dict: + """Get team details. + + Args: + team_id: Team ID + """ + query = f""" + query {{ + team(id: "{team_id}") {{ + id + name + key + description + private + members {{ + nodes {{ + id + name + email + }} + }} + projects {{ + nodes {{ + id + name + }} + }} + }} + }} + """ + + return await graphql_query(query) + + +@mcp.tool() +async def list_cycles( + team_id: Optional[str] = None, + first: int = 20 +) -> dict: + """List sprint cycles. + + Args: + team_id: Filter by team ID + first: Number of cycles to return (default: 20) + """ + filter_str = f'filter: {{ team: {{ id: {{ eq: "{team_id}" }} }} }}' if team_id else "" + + query = f""" + query {{ + cycles(first: {first}, {filter_str}) {{ + nodes {{ + id + number + name + startsAt + endsAt + progress + completedIssueCount + issueCount + team {{ + id + name + }} + }} + }} + }} + """ + + return await graphql_query(query) + + +@mcp.tool() +async def get_cycle(cycle_id: str) -> dict: + """Get cycle details. + + Args: + cycle_id: Cycle ID + """ + query = f""" + query {{ + cycle(id: "{cycle_id}") {{ + id + number + name + description + startsAt + endsAt + progress + completedIssueCount + issueCount + team {{ + id + name + }} + url + }} + }} + """ + + return await graphql_query(query) + + +@mcp.tool() +async def list_labels(team_id: Optional[str] = None) -> dict: + """List all labels. + + Args: + team_id: Filter by team ID + """ + filter_str = f'filter: {{ team: {{ id: {{ eq: "{team_id}" }} }} }}' if team_id else "" + + query = f""" + query {{ + issueLabels({filter_str}) {{ + nodes {{ + id + name + description + color + team {{ + id + name + }} + }} + }} + }} + """ + + return await graphql_query(query) + + +@mcp.tool() +async def create_label( + name: str, + team_id: str, + color: Optional[str] = None, + description: Optional[str] = None +) -> dict: + """Create a new label. + + Args: + name: Label name (required) + team_id: Team ID (required) + color: Hex color (e.g., "#FF0000") + description: Label description + """ + input_fields = [ + f'name: "{name}"', + f'teamId: "{team_id}"' + ] + + if color: + input_fields.append(f'color: "{color}"') + if description: + input_fields.append(f'description: "{description}"') + + input_str = ", ".join(input_fields) + + mutation = f""" + mutation {{ + issueLabelCreate(input: {{ {input_str} }}) {{ + success + issueLabel {{ + id + name + color + }} + }} + }} + """ + + return await graphql_query(mutation) + + +@mcp.tool() +async def search_issues( + query_text: str, + first: int = 20 +) -> dict: + """Search issues with query. + + Args: + query_text: Search query + first: Number of results (default: 20) + """ + query = f""" + query {{ + issueSearch(query: "{query_text}", first: {first}) {{ + nodes {{ + id + title + description + state {{ + name + }} + assignee {{ + name + }} + url + }} + }} + }} + """ + + return await graphql_query(query) + + +@mcp.tool() +async def get_roadmap(first: int = 50) -> dict: + """Get roadmap items. + + Args: + first: Number of items to return (default: 50) + """ + query = f""" + query {{ + projects(first: {first}) {{ + nodes {{ + id + name + description + state + progress + targetDate + startDate + lead {{ + name + }} + }} + }} + }} + """ + + return await graphql_query(query) + + +@mcp.tool() +async def list_milestones( + project_id: Optional[str] = None, + first: int = 50 +) -> dict: + """List project milestones. + + Args: + project_id: Filter by project ID + first: Number of milestones to return (default: 50) + """ + filter_str = f'filter: {{ project: {{ id: {{ eq: "{project_id}" }} }} }}' if project_id else "" + + query = f""" + query {{ + projectMilestones(first: {first}, {filter_str}) {{ + nodes {{ + id + name + description + targetDate + project {{ + id + name + }} + }} + }} + }} + """ + + return await graphql_query(query) + + +if __name__ == "__main__": + mcp.run() diff --git a/servers/linear/test.json b/servers/linear/test.json new file mode 100644 index 0000000..9b1b2b6 --- /dev/null +++ b/servers/linear/test.json @@ -0,0 +1,50 @@ +{ + "tests": [ + { + "name": "List Issues", + "tool": "list_issues", + "params": { + "first": 10 + }, + "expectedFields": [ + "data" + ], + "assertions": [ + { + "type": "exists", + "path": "data" + } + ] + }, + { + "name": "List Projects", + "tool": "list_projects", + "params": { + "first": 10 + }, + "expectedFields": [ + "data" + ], + "assertions": [ + { + "type": "exists", + "path": "data" + } + ] + }, + { + "name": "List Teams", + "tool": "list_teams", + "params": {}, + "expectedFields": [ + "data" + ], + "assertions": [ + { + "type": "exists", + "path": "data" + } + ] + } + ] +}