+1
-1
.github/workflows/publish-mcp.yml
+1
-1
.github/workflows/publish-mcp.yml
···
36
36
37
37
- name: install mcp publisher
38
38
run: |
39
-
curl -L "https://github.com/modelcontextprotocol/registry/releases/download/v1.2.3/mcp-publisher_1.2.3_linux_amd64.tar.gz" | tar xz
39
+
curl -L "https://github.com/modelcontextprotocol/registry/releases/download/v1.3.3/mcp-publisher_linux_amd64.tar.gz" | tar xz
40
40
sudo mv mcp-publisher /usr/local/bin/
41
41
42
42
- name: login to mcp registry (github oidc)
+4
-1
README.md
+4
-1
README.md
···
102
102
- `create_repo_issue(repo, title, body, labels)` - create an issue with optional labels
103
103
- `update_repo_issue(repo, issue_id, title, body, labels)` - update an issue's title, body, and/or labels
104
104
- `delete_repo_issue(repo, issue_id)` - delete an issue
105
-
- `list_repo_issues(repo, limit, cursor)` - list issues for a repository
105
+
- `list_repo_issues(repo, limit)` - list issues for a repository
106
106
- `list_repo_labels(repo)` - list available labels for a repository
107
+
108
+
### pull requests
109
+
- `list_repo_pulls(repo, limit)` - list PRs targeting a repository (only shows PRs you created)
107
110
108
111
## development
109
112
+3
-3
server.json
+3
-3
server.json
···
1
1
{
2
-
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-09-29/server.schema.json",
2
+
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-10-17/server.schema.json",
3
3
"name": "io.github.zzstoatzz/tangled-mcp",
4
4
"title": "Tangled MCP",
5
5
"description": "MCP server for Tangled git platform. Manage repositories, branches, and issues on tangled.org.",
6
-
"version": "0.0.10",
6
+
"version": "0.0.14",
7
7
"packages": [
8
8
{
9
9
"registryType": "pypi",
10
10
"identifier": "tangled-mcp",
11
-
"version": "0.0.10",
11
+
"version": "0.0.14",
12
12
"transport": {
13
13
"type": "stdio"
14
14
}
+2
src/tangled_mcp/_tangled/__init__.py
+2
src/tangled_mcp/_tangled/__init__.py
···
13
13
list_repo_labels,
14
14
update_issue,
15
15
)
16
+
from tangled_mcp._tangled._pulls import list_repo_pulls
16
17
17
18
__all__ = [
18
19
"_get_authenticated_client",
···
23
24
"delete_issue",
24
25
"list_repo_issues",
25
26
"list_repo_labels",
27
+
"list_repo_pulls",
26
28
"resolve_repo_identifier",
27
29
]
+25
-7
src/tangled_mcp/_tangled/_client.py
+25
-7
src/tangled_mcp/_tangled/_client.py
···
8
8
from tangled_mcp.settings import TANGLED_DID, settings
9
9
10
10
11
+
def _extract_error_message(e: Exception) -> str:
12
+
"""extract a clean, concise error message from an exception"""
13
+
# handle atproto RequestErrorBase (BadRequestError, UnauthorizedError, etc.)
14
+
# structure: e.response.content.message
15
+
if hasattr(e, "response") and e.response:
16
+
content = getattr(e.response, "content", None)
17
+
if content and hasattr(content, "message"):
18
+
return content.message
19
+
# handle httpx errors
20
+
if hasattr(e, "response") and hasattr(e.response, "text"):
21
+
return e.response.text[:200]
22
+
# fallback to string but limit length
23
+
msg = str(e)
24
+
if len(msg) > 200:
25
+
return msg[:200] + "..."
26
+
return msg
27
+
28
+
11
29
def resolve_repo_identifier(owner_slash_repo: str) -> tuple[str, str]:
12
30
"""resolve owner/repo format to (knot, did/repo) for tangled XRPC
13
31
···
44
62
)
45
63
owner_did = response.did
46
64
except Exception as e:
47
-
raise ValueError(f"failed to resolve handle '{owner}': {e}") from e
65
+
# extract clean error message
66
+
msg = _extract_error_message(e)
67
+
raise ValueError(f"failed to resolve handle '{owner}': {msg}") from e
48
68
49
69
# query owner's repo collection to find repo and get knot
50
70
try:
···
56
76
)
57
77
)
58
78
except Exception as e:
59
-
raise ValueError(f"failed to list repos for '{owner}': {e}") from e
79
+
msg = _extract_error_message(e)
80
+
raise ValueError(f"failed to list repos for '{owner}': {msg}") from e
60
81
61
82
# find repo with matching name and extract knot
62
83
for record in records.records:
···
86
107
try:
87
108
client.login(settings.tangled_handle, settings.tangled_password)
88
109
except Exception as e:
89
-
raise RuntimeError(
90
-
f"failed to authenticate with handle '{settings.tangled_handle}'. "
91
-
f"verify TANGLED_HANDLE and TANGLED_PASSWORD are correct. "
92
-
f"error: {e}"
93
-
) from e
110
+
msg = _extract_error_message(e)
111
+
raise RuntimeError(f"auth failed for '{settings.tangled_handle}': {msg}") from e
94
112
95
113
return client
96
114
+92
src/tangled_mcp/_tangled/_pulls.py
+92
src/tangled_mcp/_tangled/_pulls.py
···
1
+
"""pull request operations for tangled"""
2
+
3
+
from typing import Any
4
+
5
+
from atproto import models
6
+
7
+
from tangled_mcp._tangled._client import _get_authenticated_client
8
+
9
+
10
+
def list_repo_pulls(repo_id: str, limit: int = 50) -> dict[str, Any]:
11
+
"""list pull requests created by the authenticated user for a repository
12
+
13
+
note: this only returns PRs that the authenticated user created.
14
+
tangled stores PRs in the creator's repo, so we can only see our own PRs.
15
+
16
+
Args:
17
+
repo_id: repository identifier in "did/repo" format
18
+
limit: maximum number of pulls to return
19
+
20
+
Returns:
21
+
dict containing pulls list
22
+
"""
23
+
client = _get_authenticated_client()
24
+
25
+
if not client.me:
26
+
raise RuntimeError("client not authenticated")
27
+
28
+
# parse repo_id to get owner_did and repo_name
29
+
if "/" not in repo_id:
30
+
raise ValueError(f"invalid repo_id format: {repo_id}")
31
+
32
+
owner_did, repo_name = repo_id.split("/", 1)
33
+
34
+
# get the repo AT-URI by querying the repo collection
35
+
records = client.com.atproto.repo.list_records(
36
+
models.ComAtprotoRepoListRecords.Params(
37
+
repo=owner_did,
38
+
collection="sh.tangled.repo",
39
+
limit=100,
40
+
)
41
+
)
42
+
43
+
repo_at_uri = None
44
+
for record in records.records:
45
+
if (name := getattr(record.value, "name", None)) is not None and name == repo_name:
46
+
repo_at_uri = record.uri
47
+
break
48
+
49
+
if not repo_at_uri:
50
+
raise ValueError(f"repo not found: {repo_id}")
51
+
52
+
# list pull records from the authenticated user's collection
53
+
response = client.com.atproto.repo.list_records(
54
+
models.ComAtprotoRepoListRecords.Params(
55
+
repo=client.me.did,
56
+
collection="sh.tangled.repo.pull",
57
+
limit=limit,
58
+
)
59
+
)
60
+
61
+
# filter pulls by target repo and convert to dict format
62
+
pulls = []
63
+
for record in response.records:
64
+
value = record.value
65
+
target = getattr(value, "target", None)
66
+
if not target:
67
+
continue
68
+
69
+
target_repo = getattr(target, "repo", None)
70
+
if target_repo != repo_at_uri:
71
+
continue
72
+
73
+
source = getattr(value, "source", {})
74
+
pulls.append(
75
+
{
76
+
"uri": record.uri,
77
+
"cid": record.cid,
78
+
"title": getattr(value, "title", ""),
79
+
"source": {
80
+
"sha": getattr(source, "sha", ""),
81
+
"branch": getattr(source, "branch", ""),
82
+
"repo": getattr(source, "repo", None),
83
+
},
84
+
"target": {
85
+
"repo": target_repo,
86
+
"branch": getattr(target, "branch", ""),
87
+
},
88
+
"createdAt": getattr(value, "createdAt", ""),
89
+
}
90
+
)
91
+
92
+
return {"pulls": pulls}
+36
-3
src/tangled_mcp/server.py
+36
-3
src/tangled_mcp/server.py
···
11
11
DeleteIssueResult,
12
12
ListBranchesResult,
13
13
ListIssuesResult,
14
+
ListPullsResult,
14
15
UpdateIssueResult,
15
16
)
16
17
···
104
105
# create_issue doesn't need knot (uses atproto putRecord, not XRPC)
105
106
response = _tangled.create_issue(repo_id, title, body, labels)
106
107
107
-
return CreateIssueResult(repo=repo, issue_id=response["issueId"])
108
+
return CreateIssueResult(repo=repo, id=response["issueId"])
108
109
109
110
110
111
@tangled_mcp.tool
···
143
144
# update_issue doesn't need knot (uses atproto putRecord, not XRPC)
144
145
_tangled.update_issue(repo_id, issue_id, title, body, labels)
145
146
146
-
return UpdateIssueResult(repo=repo, issue_id=issue_id)
147
+
return UpdateIssueResult(repo=repo, id=issue_id)
147
148
148
149
149
150
@tangled_mcp.tool
···
172
173
# delete_issue doesn't need knot (uses atproto deleteRecord, not XRPC)
173
174
_tangled.delete_issue(repo_id, issue_id)
174
175
175
-
return DeleteIssueResult(issue_id=issue_id)
176
+
return DeleteIssueResult(id=issue_id)
176
177
177
178
178
179
@tangled_mcp.tool
···
225
226
_, repo_id = _tangled.resolve_repo_identifier(repo)
226
227
# list_repo_labels doesn't need knot (queries atproto records, not XRPC)
227
228
return _tangled.list_repo_labels(repo_id)
229
+
230
+
231
+
@tangled_mcp.tool
232
+
def list_repo_pulls(
233
+
repo: Annotated[
234
+
str,
235
+
Field(
236
+
description="repository identifier in 'owner/repo' format (e.g., 'zzstoatzz/tangled-mcp')"
237
+
),
238
+
],
239
+
limit: Annotated[
240
+
int, Field(ge=1, le=100, description="maximum number of pulls to return")
241
+
] = 20,
242
+
) -> ListPullsResult:
243
+
"""list pull requests created by the authenticated user for a repository
244
+
245
+
note: only returns PRs that the authenticated user created (tangled stores
246
+
PRs in the creator's repo, so we can only see our own PRs).
247
+
248
+
Args:
249
+
repo: repository identifier in 'owner/repo' format
250
+
limit: maximum number of pulls to return (1-100)
251
+
252
+
Returns:
253
+
ListPullsResult with list of pull requests
254
+
"""
255
+
# resolve owner/repo to (knot, did/repo)
256
+
_, repo_id = _tangled.resolve_repo_identifier(repo)
257
+
# list_repo_pulls doesn't need knot (queries atproto records, not XRPC)
258
+
response = _tangled.list_repo_pulls(repo_id, limit)
259
+
260
+
return ListPullsResult.from_api_response(response["pulls"])
+5
src/tangled_mcp/types/__init__.py
+5
src/tangled_mcp/types/__init__.py
···
9
9
ListIssuesResult,
10
10
UpdateIssueResult,
11
11
)
12
+
from tangled_mcp.types._pulls import ListPullsResult, PullInfo, PullSource, PullTarget
12
13
13
14
__all__ = [
14
15
"BranchInfo",
···
17
18
"IssueInfo",
18
19
"ListBranchesResult",
19
20
"ListIssuesResult",
21
+
"ListPullsResult",
22
+
"PullInfo",
23
+
"PullSource",
24
+
"PullTarget",
20
25
"RepoIdentifier",
21
26
"UpdateIssueResult",
22
27
]
+14
-9
src/tangled_mcp/types/_issues.py
+14
-9
src/tangled_mcp/types/_issues.py
···
18
18
19
19
uri: str
20
20
cid: str
21
-
issue_id: int = Field(alias="issueId")
21
+
id: int = Field(alias="issueId")
22
22
title: str
23
23
body: str | None = None
24
24
created_at: str = Field(alias="createdAt")
···
28
28
class CreateIssueResult(BaseModel):
29
29
"""result of creating an issue"""
30
30
31
-
repo: RepoIdentifier
32
-
issue_id: int
31
+
repo: RepoIdentifier = Field(exclude=True)
32
+
id: int
33
33
34
34
@computed_field
35
35
@property
36
36
def url(self) -> str:
37
37
"""construct clickable tangled.org URL"""
38
-
return _tangled_issue_url(self.repo, self.issue_id)
38
+
return _tangled_issue_url(self.repo, self.id)
39
39
40
40
41
41
class UpdateIssueResult(BaseModel):
42
42
"""result of updating an issue"""
43
43
44
-
repo: RepoIdentifier
45
-
issue_id: int
44
+
repo: RepoIdentifier = Field(exclude=True)
45
+
id: int
46
46
47
47
@computed_field
48
48
@property
49
49
def url(self) -> str:
50
50
"""construct clickable tangled.org URL"""
51
-
return _tangled_issue_url(self.repo, self.issue_id)
51
+
return _tangled_issue_url(self.repo, self.id)
52
52
53
53
54
54
class DeleteIssueResult(BaseModel):
55
55
"""result of deleting an issue"""
56
56
57
-
issue_id: int
57
+
id: int
58
58
59
59
60
60
class ListIssuesResult(BaseModel):
···
85
85
Returns:
86
86
ListIssuesResult with parsed issues
87
87
"""
88
-
issues = [IssueInfo(**issue_data) for issue_data in response.get("issues", [])]
88
+
issues = []
89
+
for issue_data in response.get("issues", []):
90
+
# skip malformed issues (e.g., missing issueId)
91
+
if issue_data.get("issueId") is None:
92
+
continue
93
+
issues.append(IssueInfo(**issue_data))
89
94
return cls(issues=issues)
+70
src/tangled_mcp/types/_pulls.py
+70
src/tangled_mcp/types/_pulls.py
···
1
+
"""pull request types"""
2
+
3
+
from typing import Any
4
+
5
+
from pydantic import BaseModel, Field
6
+
7
+
8
+
class PullSource(BaseModel):
9
+
"""source branch info for a pull request"""
10
+
11
+
sha: str
12
+
branch: str
13
+
repo: str | None = None # AT-URI of source repo (for cross-repo PRs)
14
+
15
+
16
+
class PullTarget(BaseModel):
17
+
"""target branch info for a pull request"""
18
+
19
+
repo: str # AT-URI of target repo
20
+
branch: str
21
+
22
+
23
+
class PullInfo(BaseModel):
24
+
"""pull request information"""
25
+
26
+
uri: str
27
+
cid: str
28
+
title: str
29
+
source: PullSource
30
+
target: PullTarget
31
+
created_at: str = Field(alias="createdAt")
32
+
33
+
34
+
class ListPullsResult(BaseModel):
35
+
"""result of listing pull requests"""
36
+
37
+
pulls: list[PullInfo]
38
+
39
+
@classmethod
40
+
def from_api_response(cls, pulls_data: list[dict[str, Any]]) -> "ListPullsResult":
41
+
"""construct from pre-filtered pull data
42
+
43
+
Args:
44
+
pulls_data: list of pull dicts already filtered by target repo
45
+
46
+
Returns:
47
+
ListPullsResult with parsed pulls
48
+
"""
49
+
pulls = []
50
+
for pull in pulls_data:
51
+
source = pull.get("source", {})
52
+
target = pull.get("target", {})
53
+
pulls.append(
54
+
PullInfo(
55
+
uri=pull["uri"],
56
+
cid=pull["cid"],
57
+
title=pull.get("title", ""),
58
+
source=PullSource(
59
+
sha=source.get("sha", ""),
60
+
branch=source.get("branch", ""),
61
+
repo=source.get("repo"),
62
+
),
63
+
target=PullTarget(
64
+
repo=target.get("repo", ""),
65
+
branch=target.get("branch", ""),
66
+
),
67
+
createdAt=pull.get("createdAt", ""),
68
+
)
69
+
)
70
+
return cls(pulls=pulls)
+43
tests/test_resolver.py
+43
tests/test_resolver.py
···
2
2
3
3
import pytest
4
4
5
+
from tangled_mcp._tangled._client import _extract_error_message
6
+
7
+
8
+
class TestExtractErrorMessage:
9
+
"""test error message extraction from various exception types"""
10
+
11
+
def test_extracts_from_atproto_response(self):
12
+
"""extracts message from atproto RequestErrorBase structure"""
13
+
14
+
class MockContent:
15
+
message = "handle must be a valid handle"
16
+
17
+
class MockResponse:
18
+
content = MockContent()
19
+
20
+
class MockException(Exception):
21
+
response = MockResponse()
22
+
23
+
msg = _extract_error_message(MockException())
24
+
assert msg == "handle must be a valid handle"
25
+
26
+
def test_truncates_long_messages(self):
27
+
"""truncates messages longer than 200 chars"""
28
+
long_msg = "x" * 300
29
+
30
+
class MockException(Exception):
31
+
pass
32
+
33
+
e = MockException(long_msg)
34
+
msg = _extract_error_message(e)
35
+
assert len(msg) == 203 # 200 + "..."
36
+
assert msg.endswith("...")
37
+
38
+
def test_handles_none_response(self):
39
+
"""handles exception with None response"""
40
+
41
+
class MockException(Exception):
42
+
response = None
43
+
44
+
e = MockException("fallback message")
45
+
msg = _extract_error_message(e)
46
+
assert msg == "fallback message"
47
+
5
48
6
49
class TestRepoIdentifierParsing:
7
50
"""test repository identifier format validation"""
+2
-1
tests/test_server.py
+2
-1
tests/test_server.py
···
22
22
async with Client(tangled_mcp) as client:
23
23
tools = await client.list_tools()
24
24
25
-
assert len(tools) == 6
25
+
assert len(tools) == 7
26
26
27
27
tool_names = {tool.name for tool in tools}
28
28
assert "list_repo_branches" in tool_names
···
31
31
assert "delete_repo_issue" in tool_names
32
32
assert "list_repo_issues" in tool_names
33
33
assert "list_repo_labels" in tool_names
34
+
assert "list_repo_pulls" in tool_names
34
35
35
36
async def test_list_repo_branches_tool_schema(self):
36
37
"""test list_repo_branches tool has correct schema"""
+14
-6
tests/test_types.py
+14
-6
tests/test_types.py
···
15
15
16
16
def test_strips_at_prefix(self):
17
17
"""@ prefix is stripped during validation"""
18
-
result = CreateIssueResult(repo="@owner/repo", issue_id=1)
18
+
result = CreateIssueResult(repo="@owner/repo", id=1)
19
19
assert result.repo == "owner/repo"
20
20
21
21
def test_accepts_without_at_prefix(self):
22
22
"""repo identifier without @ works"""
23
-
result = CreateIssueResult(repo="owner/repo", issue_id=1)
23
+
result = CreateIssueResult(repo="owner/repo", id=1)
24
24
assert result.repo == "owner/repo"
25
25
26
26
def test_rejects_invalid_format(self):
27
27
"""repo identifier without slash is rejected"""
28
28
with pytest.raises(ValidationError, match="invalid repo format"):
29
-
CreateIssueResult(repo="invalid", issue_id=1)
29
+
CreateIssueResult(repo="invalid", id=1)
30
30
31
31
32
32
class TestIssueResultURLs:
···
34
34
35
35
def test_create_issue_url(self):
36
36
"""create result generates correct tangled.org URL"""
37
-
result = CreateIssueResult(repo="owner/repo", issue_id=42)
37
+
result = CreateIssueResult(repo="owner/repo", id=42)
38
38
assert result.url == "https://tangled.org/@owner/repo/issues/42"
39
39
40
40
def test_update_issue_url(self):
41
41
"""update result generates correct tangled.org URL"""
42
-
result = UpdateIssueResult(repo="owner/repo", issue_id=42)
42
+
result = UpdateIssueResult(repo="owner/repo", id=42)
43
43
assert result.url == "https://tangled.org/@owner/repo/issues/42"
44
44
45
45
def test_url_handles_at_prefix_input(self):
46
46
"""URL is correct even when input has @ prefix"""
47
-
result = CreateIssueResult(repo="@owner/repo", issue_id=42)
47
+
result = CreateIssueResult(repo="@owner/repo", id=42)
48
48
assert result.url == "https://tangled.org/@owner/repo/issues/42"
49
+
50
+
def test_repo_excluded_from_serialization(self):
51
+
"""repo field is excluded from JSON output"""
52
+
result = CreateIssueResult(repo="owner/repo", id=42)
53
+
data = result.model_dump()
54
+
assert "repo" not in data
55
+
assert data["id"] == 42
56
+
assert "url" in data
49
57
50
58
51
59
class TestListBranchesFromAPIResponse: