Skip to content

Linear MCP Server

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

Configure these scopes in Authgent for your Linear MCP server:

ScopeGrants
linear:readRead issues, projects, teams, cycles
linear:writeCreate and update issues, create projects
linear:adminDelete issues, manage team settings

Register the scopes in Authgent when creating the resource:

Terminal window
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 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 context

Configure Token Vault in Authgent:

Terminal window
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.

# server.py — Linear MCP Server with Authgent
import os
import httpx
from fastmcp import FastMCP
from 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")
ToolScopeDescription
get_issueslinear:readList issues with optional team and status filters
get_issuelinear:readGet a single issue by ID or identifier
create_issuelinear:writeCreate a new issue with title, description, priority
update_issuelinear:writeUpdate issue title, description, status, or priority
get_teamslinear:readList all teams with members and workflow states
get_projectslinear:readList projects with optional team filter
create_projectlinear:writeCreate a new project and assign to teams

Add to ~/Library/Application Support/Claude/claude_desktop_config.json:

{
"mcpServers": {
"linear": {
"url": "http://localhost:8000/mcp",
"transport": "http"
}
}
}

Claude will automatically:

  1. Discover /.well-known/oauth-protected-resource on your MCP server
  2. Redirect to Authgent’s authorization endpoint
  3. Complete the OAuth flow (Authorization Code + PKCE)
  4. 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
Terminal window
# Start Authgent
docker 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 server
LINEAR_API_KEY=lin_api_xxxxx \
AUTHGENT_ISSUER=http://localhost:8080 \
MCP_AUDIENCE=http://localhost:8000 \
python server.py
# Test with MCP Inspector
npx @modelcontextprotocol/inspector http://localhost:8000/mcp

The inspector handles the full OAuth flow and lets you call each tool interactively.