Linear MCP Server
Overview
Section titled “Overview”The Linear MCP server exposes Linear’s project management capabilities — issues, projects, teams, and cycles — as MCP tools that AI agents can call. Authgent handles authentication and authorization, so your MCP server only validates JWTs.
What agents can do:
- Read issues, projects, teams, and cycles
- Create issues and projects
- Update issue status, priority, and assignments
- All operations are scoped to the authenticated user’s permissions
Scopes
Section titled “Scopes”Configure these scopes in Authgent for your Linear MCP server:
| Scope | Grants |
|---|---|
linear:read | Read issues, projects, teams, cycles |
linear:write | Create and update issues, create projects |
linear:admin | Delete issues, manage team settings |
Register the scopes in Authgent when creating the resource:
curl -X POST http://localhost:8080/admin/resources \ -H "Content-Type: application/json" \ -d '{ "resource": "http://localhost:8000", "name": "Linear MCP Server", "allowed_scopes": ["linear:read", "linear:write", "linear:admin"] }'Token Vault for Linear OAuth
Section titled “Token Vault for Linear OAuth”Token Vault is an enterprise Authgent feature that manages upstream OAuth tokens. Instead of storing Linear API keys in your MCP server, Authgent exchanges its own tokens for Linear OAuth tokens at request time.
MCP Client → Authgent (issues JWT with linear:read scope) │ └─ Token Vault exchanges JWT for Linear OAuth token │MCP Server ← Linear OAuth token attached to request contextConfigure Token Vault in Authgent:
curl -X POST http://localhost:8080/admin/token-vault/providers \ -H "Content-Type: application/json" \ -d '{ "provider": "linear", "client_id": "YOUR_LINEAR_OAUTH_CLIENT_ID", "client_secret": "YOUR_LINEAR_OAUTH_CLIENT_SECRET", "authorization_url": "https://linear.app/oauth/authorize", "token_url": "https://api.linear.app/oauth/token", "scopes": ["read", "write", "issues:create"] }'Without Token Vault, use a Linear API key directly in your MCP server environment.
Complete server implementation
Section titled “Complete server implementation”# server.py — Linear MCP Server with Authgentimport osimport httpxfrom fastmcp import FastMCPfrom fastmcp.server.auth import RemoteAuthProvider
AUTHGENT_ISSUER = os.environ.get("AUTHGENT_ISSUER", "http://localhost:8080")MCP_AUDIENCE = os.environ.get("MCP_AUDIENCE", "http://localhost:8000")LINEAR_API_KEY = os.environ.get("LINEAR_API_KEY")
auth = RemoteAuthProvider( authorization_server_url=AUTHGENT_ISSUER, audience=MCP_AUDIENCE, required_scopes=["linear:read"],)
mcp = FastMCP(name="Linear MCP Server", auth=auth)
LINEAR_API = "https://api.linear.app/graphql"HEADERS = { "Authorization": f"{LINEAR_API_KEY}", "Content-Type": "application/json",}
async def graphql(query: str, variables: dict | None = None) -> dict: """Execute a GraphQL query against the Linear API.""" async with httpx.AsyncClient() as client: response = await client.post( LINEAR_API, json={"query": query, "variables": variables or {}}, headers=HEADERS, ) response.raise_for_status() result = response.json() if "errors" in result: raise Exception(f"Linear API error: {result['errors']}") return result["data"]
@mcp.tool()async def get_issues( team_key: str | None = None, status: str | None = None, limit: int = 25,) -> list[dict]: """List issues, optionally filtered by team and status. Requires linear:read scope.""" filters = [] if team_key: filters.append(f'team: {{ key: {{ eq: "{team_key}" }} }}') if status: filters.append(f'state: {{ name: {{ eq: "{status}" }} }}')
filter_str = ", ".join(filters) filter_clause = f"filter: {{ {filter_str} }}" if filters else ""
query = f""" query {{ issues(first: {limit}, {filter_clause}) {{ nodes {{ id identifier title description priority state {{ name }} assignee {{ name email }} team {{ key name }} createdAt updatedAt }} }} }} """ data = await graphql(query) return data["issues"]["nodes"]
@mcp.tool()async def get_issue(issue_id: str) -> dict: """Get a single issue by ID or identifier (e.g. ENG-123). Requires linear:read scope.""" query = """ query($id: String!) { issue(id: $id) { id identifier title description priority priorityLabel state { name } assignee { name email } team { key name } project { name } cycle { name number } labels { nodes { name color } } createdAt updatedAt } } """ data = await graphql(query, {"id": issue_id}) return data["issue"]
@mcp.tool(scopes=["linear:write"])async def create_issue( team_key: str, title: str, description: str | None = None, priority: int = 0, assignee_email: str | None = None,) -> dict: """Create a new issue. Requires linear:write scope.""" # Resolve team ID from key team_query = """ query($key: String!) { teams(filter: { key: { eq: $key } }) { nodes { id } } } """ team_data = await graphql(team_query, {"key": team_key}) teams = team_data["teams"]["nodes"] if not teams: raise Exception(f"Team with key '{team_key}' not found") team_id = teams[0]["id"]
# Resolve assignee if provided assignee_id = None if assignee_email: user_query = """ query($email: String!) { users(filter: { email: { eq: $email } }) { nodes { id } } } """ user_data = await graphql(user_query, {"email": assignee_email}) users = user_data["users"]["nodes"] if users: assignee_id = users[0]["id"]
mutation = """ mutation($input: IssueCreateInput!) { issueCreate(input: $input) { success issue { id identifier title url } } } """ variables = { "input": { "teamId": team_id, "title": title, "description": description, "priority": priority, } } if assignee_id: variables["input"]["assigneeId"] = assignee_id
data = await graphql(mutation, variables) return data["issueCreate"]["issue"]
@mcp.tool(scopes=["linear:write"])async def update_issue( issue_id: str, title: str | None = None, description: str | None = None, status: str | None = None, priority: int | None = None,) -> dict: """Update an existing issue. Requires linear:write scope.""" input_fields = {} if title is not None: input_fields["title"] = title if description is not None: input_fields["description"] = description if priority is not None: input_fields["priority"] = priority
if status: # Resolve the state ID from the state name state_query = """ query($name: String!) { workflowStates(filter: { name: { eq: $name } }) { nodes { id } } } """ state_data = await graphql(state_query, {"name": status}) states = state_data["workflowStates"]["nodes"] if states: input_fields["stateId"] = states[0]["id"]
mutation = """ mutation($id: String!, $input: IssueUpdateInput!) { issueUpdate(id: $id, input: $input) { success issue { id identifier title state { name } url } } } """ data = await graphql(mutation, {"id": issue_id, "input": input_fields}) return data["issueUpdate"]["issue"]
@mcp.tool()async def get_teams() -> list[dict]: """List all teams. Requires linear:read scope.""" query = """ query { teams { nodes { id key name description members { nodes { name email } } states { nodes { name type } } } } } """ data = await graphql(query) return data["teams"]["nodes"]
@mcp.tool()async def get_projects( team_key: str | None = None, limit: int = 25,) -> list[dict]: """List projects, optionally filtered by team. Requires linear:read scope.""" filter_clause = "" if team_key: filter_clause = f'filter: {{ accessibleTeams: {{ key: {{ eq: "{team_key}" }} }} }}'
query = f""" query {{ projects(first: {limit}, {filter_clause}) {{ nodes {{ id name description state progress startDate targetDate teams {{ nodes {{ key name }} }} lead {{ name email }} }} }} }} """ data = await graphql(query) return data["projects"]["nodes"]
@mcp.tool(scopes=["linear:write"])async def create_project( name: str, description: str | None = None, team_keys: list[str] | None = None,) -> dict: """Create a new project. Requires linear:write scope.""" # Resolve team IDs team_ids = [] if team_keys: for key in team_keys: query = """ query($key: String!) { teams(filter: { key: { eq: $key } }) { nodes { id } } } """ data = await graphql(query, {"key": key}) teams = data["teams"]["nodes"] if teams: team_ids.append(teams[0]["id"])
mutation = """ mutation($input: ProjectCreateInput!) { projectCreate(input: $input) { success project { id name url } } } """ variables = { "input": { "name": name, "description": description, "teamIds": team_ids if team_ids else [], } } data = await graphql(mutation, variables) return data["projectCreate"]["project"]
if __name__ == "__main__": mcp.run(transport="http", host="0.0.0.0", port=8000, path="/mcp")Available tools
Section titled “Available tools”| Tool | Scope | Description |
|---|---|---|
get_issues | linear:read | List issues with optional team and status filters |
get_issue | linear:read | Get a single issue by ID or identifier |
create_issue | linear:write | Create a new issue with title, description, priority |
update_issue | linear:write | Update issue title, description, status, or priority |
get_teams | linear:read | List all teams with members and workflow states |
get_projects | linear:read | List projects with optional team filter |
create_project | linear:write | Create a new project and assign to teams |
Claude Desktop configuration
Section titled “Claude Desktop configuration”Add to ~/Library/Application Support/Claude/claude_desktop_config.json:
{ "mcpServers": { "linear": { "url": "http://localhost:8000/mcp", "transport": "http" } }}Claude will automatically:
- Discover
/.well-known/oauth-protected-resourceon your MCP server - Redirect to Authgent’s authorization endpoint
- Complete the OAuth flow (Authorization Code + PKCE)
- Call Linear tools with authenticated requests
Example prompts:
- “Show me all open issues in the ENG team”
- “Create a bug report for the login page crash”
- “Move ENG-456 to In Progress and assign it to alice@company.com”
Testing with MCP Inspector
Section titled “Testing with MCP Inspector”# Start Authgentdocker run -d --name authgent -p 8080:8080 \ -v $(pwd)/ec-private.pem:/keys/ec-private.pem:ro \ -e AUTHGENT_ISSUER=http://localhost:8080 \ -e AUTHGENT_SIGNING_KEY=/keys/ec-private.pem \ -e AUTHGENT_SIGNING_ALG=ES256 \ -e AUTHGENT_DB_DSN=sqlite:///data/authgent.db \ authgent/authgent:latest
# Start the Linear MCP serverLINEAR_API_KEY=lin_api_xxxxx \AUTHGENT_ISSUER=http://localhost:8080 \MCP_AUDIENCE=http://localhost:8000 \python server.py
# Test with MCP Inspectornpx @modelcontextprotocol/inspector http://localhost:8000/mcpThe inspector handles the full OAuth flow and lets you call each tool interactively.