ghostserver 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ghostserver/__init__.py +2 -0
- ghostserver/__main__.py +4 -0
- ghostserver/adapters/__init__.py +22 -0
- ghostserver/adapters/aws.py +111 -0
- ghostserver/adapters/cloudflare.py +121 -0
- ghostserver/adapters/gcal.py +178 -0
- ghostserver/adapters/github.py +142 -0
- ghostserver/adapters/gmail.py +187 -0
- ghostserver/boot.py +45 -0
- ghostserver/config.py +70 -0
- ghostserver/gate.py +40 -0
- ghostserver/google_auth.py +171 -0
- ghostserver/server.py +38 -0
- ghostserver/tokens.py +153 -0
- ghostserver-0.1.0.dist-info/METADATA +207 -0
- ghostserver-0.1.0.dist-info/RECORD +18 -0
- ghostserver-0.1.0.dist-info/WHEEL +4 -0
- ghostserver-0.1.0.dist-info/licenses/LICENSE +21 -0
ghostserver/__init__.py
ADDED
ghostserver/__main__.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Service adapters. Each module exposes a `server` FastMCP instance and a `SERVICE` name."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from types import ModuleType
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def discover() -> list[tuple[str, "ModuleType"]]:
|
|
11
|
+
"""Return (service_name, module) for each adapter that defines SERVICE and server."""
|
|
12
|
+
import importlib
|
|
13
|
+
|
|
14
|
+
adapters = []
|
|
15
|
+
for name in ["github", "gmail", "gcal", "cloudflare", "aws"]:
|
|
16
|
+
try:
|
|
17
|
+
mod = importlib.import_module(f"ghostserver.adapters.{name}")
|
|
18
|
+
if hasattr(mod, "server") and hasattr(mod, "SERVICE"):
|
|
19
|
+
adapters.append((mod.SERVICE, mod))
|
|
20
|
+
except ImportError:
|
|
21
|
+
pass
|
|
22
|
+
return adapters
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""AWS adapter — 4 tools for S3, EC2, and CloudWatch."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Annotated
|
|
5
|
+
|
|
6
|
+
import boto3
|
|
7
|
+
from fastmcp import FastMCP
|
|
8
|
+
from pydantic import Field
|
|
9
|
+
from spine import Core
|
|
10
|
+
|
|
11
|
+
from ghostserver.gate import check_gate
|
|
12
|
+
|
|
13
|
+
SERVICE = "aws"
|
|
14
|
+
|
|
15
|
+
server = FastMCP("AWS")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _region() -> str:
|
|
19
|
+
return Core.instance().get("config").aws.region
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@server.tool
|
|
23
|
+
async def aws_list_buckets() -> list[dict]:
|
|
24
|
+
"""List all S3 buckets in the AWS account."""
|
|
25
|
+
check_gate(SERVICE)
|
|
26
|
+
s3 = boto3.client("s3", region_name=_region())
|
|
27
|
+
response = s3.list_buckets()
|
|
28
|
+
return [
|
|
29
|
+
{
|
|
30
|
+
"name": b["Name"],
|
|
31
|
+
"created": b["CreationDate"].isoformat() if hasattr(b["CreationDate"], "isoformat") else str(b["CreationDate"]),
|
|
32
|
+
}
|
|
33
|
+
for b in response.get("Buckets", [])
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@server.tool
|
|
38
|
+
async def aws_list_objects(
|
|
39
|
+
bucket: Annotated[str, Field(description="S3 bucket name")],
|
|
40
|
+
prefix: Annotated[str, Field(description="Key prefix to filter objects")] = "",
|
|
41
|
+
max_keys: Annotated[int, Field(description="Maximum number of objects to return", ge=1, le=1000)] = 20,
|
|
42
|
+
) -> dict:
|
|
43
|
+
"""List objects in an S3 bucket, optionally filtered by prefix."""
|
|
44
|
+
check_gate(SERVICE)
|
|
45
|
+
s3 = boto3.client("s3", region_name=_region())
|
|
46
|
+
response = s3.list_objects_v2(Bucket=bucket, Prefix=prefix, MaxKeys=max_keys)
|
|
47
|
+
objects = [
|
|
48
|
+
{
|
|
49
|
+
"key": obj["Key"],
|
|
50
|
+
"size": obj["Size"],
|
|
51
|
+
"modified": obj["LastModified"].isoformat() if hasattr(obj["LastModified"], "isoformat") else str(obj["LastModified"]),
|
|
52
|
+
}
|
|
53
|
+
for obj in response.get("Contents", [])
|
|
54
|
+
]
|
|
55
|
+
return {
|
|
56
|
+
"count": len(objects),
|
|
57
|
+
"objects": objects,
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@server.tool
|
|
62
|
+
async def aws_describe_instances(
|
|
63
|
+
instance_ids: Annotated[list[str], Field(description="List of EC2 instance IDs to describe; empty list returns all instances")] = [],
|
|
64
|
+
) -> list[dict]:
|
|
65
|
+
"""Describe EC2 instances, returning id, name, type, state, and IPs."""
|
|
66
|
+
check_gate(SERVICE)
|
|
67
|
+
ec2 = boto3.client("ec2", region_name=_region())
|
|
68
|
+
kwargs = {}
|
|
69
|
+
if instance_ids:
|
|
70
|
+
kwargs["InstanceIds"] = instance_ids
|
|
71
|
+
response = ec2.describe_instances(**kwargs)
|
|
72
|
+
|
|
73
|
+
instances = []
|
|
74
|
+
for reservation in response.get("Reservations", []):
|
|
75
|
+
for inst in reservation.get("Instances", []):
|
|
76
|
+
name = ""
|
|
77
|
+
for tag in inst.get("Tags", []):
|
|
78
|
+
if tag.get("Key") == "Name":
|
|
79
|
+
name = tag.get("Value", "")
|
|
80
|
+
break
|
|
81
|
+
instances.append({
|
|
82
|
+
"id": inst.get("InstanceId", ""),
|
|
83
|
+
"name": name,
|
|
84
|
+
"type": inst.get("InstanceType", ""),
|
|
85
|
+
"state": inst.get("State", {}).get("Name", ""),
|
|
86
|
+
"public_ip": inst.get("PublicIpAddress", ""),
|
|
87
|
+
"private_ip": inst.get("PrivateIpAddress", ""),
|
|
88
|
+
})
|
|
89
|
+
return instances
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@server.tool
|
|
93
|
+
async def aws_cloudwatch_metrics(
|
|
94
|
+
namespace: Annotated[str, Field(description="CloudWatch namespace to list metrics for (e.g. 'AWS/EC2', 'AWS/S3')")],
|
|
95
|
+
) -> list[dict]:
|
|
96
|
+
"""List CloudWatch metrics for a given namespace, deduplicated by namespace+name."""
|
|
97
|
+
check_gate(SERVICE)
|
|
98
|
+
cw = boto3.client("cloudwatch", region_name=_region())
|
|
99
|
+
response = cw.list_metrics(Namespace=namespace)
|
|
100
|
+
|
|
101
|
+
seen: set[tuple[str, str]] = set()
|
|
102
|
+
metrics = []
|
|
103
|
+
for m in response.get("Metrics", []):
|
|
104
|
+
key = (m.get("Namespace", ""), m.get("MetricName", ""))
|
|
105
|
+
if key not in seen:
|
|
106
|
+
seen.add(key)
|
|
107
|
+
metrics.append({
|
|
108
|
+
"namespace": key[0],
|
|
109
|
+
"name": key[1],
|
|
110
|
+
})
|
|
111
|
+
return metrics
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Cloudflare adapter — 4 tools for zones, DNS, and Workers."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Annotated
|
|
5
|
+
from pydantic import Field
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
from fastmcp import FastMCP
|
|
9
|
+
from spine import Core
|
|
10
|
+
from ghostserver.gate import check_gate
|
|
11
|
+
|
|
12
|
+
SERVICE = "cloudflare"
|
|
13
|
+
API = "https://api.cloudflare.com/client/v4"
|
|
14
|
+
|
|
15
|
+
server = FastMCP("Cloudflare")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _headers() -> dict[str, str]:
|
|
19
|
+
tokens = Core.instance().get("tokens")
|
|
20
|
+
config = Core.instance().get("config")
|
|
21
|
+
token = tokens.get(config.cloudflare.token_ref)
|
|
22
|
+
return {
|
|
23
|
+
"Authorization": f"Bearer {token}",
|
|
24
|
+
"Content-Type": "application/json",
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@server.tool
|
|
29
|
+
async def cf_list_zones() -> list[dict]:
|
|
30
|
+
"""List all Cloudflare zones (domains) on the account."""
|
|
31
|
+
check_gate(SERVICE)
|
|
32
|
+
async with httpx.AsyncClient() as client:
|
|
33
|
+
resp = await client.get(
|
|
34
|
+
f"{API}/zones",
|
|
35
|
+
headers=_headers(),
|
|
36
|
+
params={"per_page": 50},
|
|
37
|
+
)
|
|
38
|
+
resp.raise_for_status()
|
|
39
|
+
data = resp.json()
|
|
40
|
+
return [
|
|
41
|
+
{"id": z["id"], "name": z["name"], "status": z["status"]}
|
|
42
|
+
for z in data["result"]
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@server.tool
|
|
47
|
+
async def cf_list_dns(
|
|
48
|
+
zone_id: Annotated[str, Field(description="Cloudflare zone ID")],
|
|
49
|
+
record_type: Annotated[str, Field(description="Optional DNS record type filter (e.g. A, CNAME, MX)")] = "",
|
|
50
|
+
) -> list[dict]:
|
|
51
|
+
"""List DNS records for a Cloudflare zone, with optional type filter."""
|
|
52
|
+
check_gate(SERVICE)
|
|
53
|
+
params: dict = {}
|
|
54
|
+
if record_type:
|
|
55
|
+
params["type"] = record_type
|
|
56
|
+
async with httpx.AsyncClient() as client:
|
|
57
|
+
resp = await client.get(
|
|
58
|
+
f"{API}/zones/{zone_id}/dns_records",
|
|
59
|
+
headers=_headers(),
|
|
60
|
+
params=params,
|
|
61
|
+
)
|
|
62
|
+
resp.raise_for_status()
|
|
63
|
+
data = resp.json()
|
|
64
|
+
return [
|
|
65
|
+
{
|
|
66
|
+
"id": r["id"],
|
|
67
|
+
"type": r["type"],
|
|
68
|
+
"name": r["name"],
|
|
69
|
+
"content": r["content"],
|
|
70
|
+
"ttl": r["ttl"],
|
|
71
|
+
}
|
|
72
|
+
for r in data["result"]
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@server.tool
|
|
77
|
+
async def cf_create_dns(
|
|
78
|
+
zone_id: Annotated[str, Field(description="Cloudflare zone ID")],
|
|
79
|
+
record_type: Annotated[str, Field(description="DNS record type (e.g. A, CNAME, TXT)")],
|
|
80
|
+
name: Annotated[str, Field(description="DNS record name (e.g. subdomain or @)")],
|
|
81
|
+
content: Annotated[str, Field(description="DNS record content (e.g. IP address or target hostname)")],
|
|
82
|
+
ttl: Annotated[int, Field(description="Time to live in seconds; 1 = automatic")] = 1,
|
|
83
|
+
proxied: Annotated[bool, Field(description="Whether to proxy traffic through Cloudflare")] = False,
|
|
84
|
+
) -> dict:
|
|
85
|
+
"""Create a new DNS record in a Cloudflare zone."""
|
|
86
|
+
check_gate(SERVICE)
|
|
87
|
+
payload = {
|
|
88
|
+
"type": record_type,
|
|
89
|
+
"name": name,
|
|
90
|
+
"content": content,
|
|
91
|
+
"ttl": ttl,
|
|
92
|
+
"proxied": proxied,
|
|
93
|
+
}
|
|
94
|
+
async with httpx.AsyncClient() as client:
|
|
95
|
+
resp = await client.post(
|
|
96
|
+
f"{API}/zones/{zone_id}/dns_records",
|
|
97
|
+
headers=_headers(),
|
|
98
|
+
json=payload,
|
|
99
|
+
)
|
|
100
|
+
resp.raise_for_status()
|
|
101
|
+
r = resp.json()["result"]
|
|
102
|
+
return {"id": r["id"], "type": r["type"], "name": r["name"], "content": r["content"]}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@server.tool
|
|
106
|
+
async def cf_list_workers(
|
|
107
|
+
account_id: Annotated[str, Field(description="Cloudflare account ID")],
|
|
108
|
+
) -> list[dict]:
|
|
109
|
+
"""List all Cloudflare Workers scripts for an account."""
|
|
110
|
+
check_gate(SERVICE)
|
|
111
|
+
async with httpx.AsyncClient() as client:
|
|
112
|
+
resp = await client.get(
|
|
113
|
+
f"{API}/accounts/{account_id}/workers/scripts",
|
|
114
|
+
headers=_headers(),
|
|
115
|
+
)
|
|
116
|
+
resp.raise_for_status()
|
|
117
|
+
data = resp.json()
|
|
118
|
+
return [
|
|
119
|
+
{"id": w["id"], "modified": w.get("modified_on", "")}
|
|
120
|
+
for w in data["result"]
|
|
121
|
+
]
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Google Calendar adapter — 4 tools for listing, creating, and querying events."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from datetime import datetime, timedelta, timezone
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
from fastmcp import FastMCP
|
|
9
|
+
from pydantic import Field
|
|
10
|
+
from spine import Core
|
|
11
|
+
|
|
12
|
+
from ghostserver.gate import check_gate
|
|
13
|
+
|
|
14
|
+
SERVICE = "google"
|
|
15
|
+
API = "https://www.googleapis.com/calendar/v3"
|
|
16
|
+
|
|
17
|
+
server = FastMCP("Google Calendar")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _get_access_token() -> str:
|
|
21
|
+
core = Core.instance()
|
|
22
|
+
config = core.get("config")
|
|
23
|
+
tokens = core.get("tokens")
|
|
24
|
+
|
|
25
|
+
client_id = tokens.get(config.google.client_id_ref)
|
|
26
|
+
client_secret = tokens.get(config.google.client_secret_ref)
|
|
27
|
+
refresh_token = tokens.get(config.google.refresh_token_ref)
|
|
28
|
+
|
|
29
|
+
return tokens.refresh_google(
|
|
30
|
+
client_id=client_id,
|
|
31
|
+
client_secret=client_secret,
|
|
32
|
+
refresh_token=refresh_token,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _auth_headers() -> dict[str, str]:
|
|
37
|
+
return {"Authorization": f"Bearer {_get_access_token()}"}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@server.tool
|
|
41
|
+
async def gcal_list_events(
|
|
42
|
+
days_ahead: Annotated[int, Field(description="Number of days ahead to fetch events", ge=1, le=90)] = 7,
|
|
43
|
+
calendar_id: Annotated[str, Field(description="Calendar ID to query (default: primary)")] = "primary",
|
|
44
|
+
) -> list[dict]:
|
|
45
|
+
"""List upcoming calendar events within the specified number of days."""
|
|
46
|
+
check_gate(SERVICE)
|
|
47
|
+
headers = _auth_headers()
|
|
48
|
+
|
|
49
|
+
now = datetime.now(timezone.utc)
|
|
50
|
+
time_max = now + timedelta(days=days_ahead)
|
|
51
|
+
|
|
52
|
+
async with httpx.AsyncClient() as client:
|
|
53
|
+
resp = await client.get(
|
|
54
|
+
f"{API}/calendars/{calendar_id}/events",
|
|
55
|
+
headers=headers,
|
|
56
|
+
params={
|
|
57
|
+
"timeMin": now.isoformat(),
|
|
58
|
+
"timeMax": time_max.isoformat(),
|
|
59
|
+
"singleEvents": "true",
|
|
60
|
+
"orderBy": "startTime",
|
|
61
|
+
},
|
|
62
|
+
)
|
|
63
|
+
resp.raise_for_status()
|
|
64
|
+
|
|
65
|
+
items = resp.json().get("items", [])
|
|
66
|
+
results = []
|
|
67
|
+
for item in items:
|
|
68
|
+
start = item.get("start", {})
|
|
69
|
+
end = item.get("end", {})
|
|
70
|
+
results.append({
|
|
71
|
+
"id": item.get("id", ""),
|
|
72
|
+
"summary": item.get("summary", ""),
|
|
73
|
+
"start": start.get("dateTime", start.get("date", "")),
|
|
74
|
+
"end": end.get("dateTime", end.get("date", "")),
|
|
75
|
+
"url": item.get("htmlLink", ""),
|
|
76
|
+
})
|
|
77
|
+
return results
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@server.tool
|
|
81
|
+
async def gcal_create_event(
|
|
82
|
+
summary: Annotated[str, Field(description="Event title/summary")],
|
|
83
|
+
start_time: Annotated[str, Field(description="Start datetime in ISO 8601 format (e.g. 2024-06-01T10:00:00Z)")],
|
|
84
|
+
end_time: Annotated[str, Field(description="End datetime in ISO 8601 format (e.g. 2024-06-01T11:00:00Z)")],
|
|
85
|
+
description: Annotated[str, Field(description="Optional event description")] = "",
|
|
86
|
+
calendar_id: Annotated[str, Field(description="Calendar ID to create the event in (default: primary)")] = "primary",
|
|
87
|
+
) -> dict:
|
|
88
|
+
"""Create a new calendar event."""
|
|
89
|
+
check_gate(SERVICE)
|
|
90
|
+
headers = {**_auth_headers(), "Content-Type": "application/json"}
|
|
91
|
+
|
|
92
|
+
body: dict = {
|
|
93
|
+
"summary": summary,
|
|
94
|
+
"start": {"dateTime": start_time, "timeZone": "UTC"},
|
|
95
|
+
"end": {"dateTime": end_time, "timeZone": "UTC"},
|
|
96
|
+
}
|
|
97
|
+
if description:
|
|
98
|
+
body["description"] = description
|
|
99
|
+
|
|
100
|
+
async with httpx.AsyncClient() as client:
|
|
101
|
+
resp = await client.post(
|
|
102
|
+
f"{API}/calendars/{calendar_id}/events",
|
|
103
|
+
headers=headers,
|
|
104
|
+
json=body,
|
|
105
|
+
)
|
|
106
|
+
resp.raise_for_status()
|
|
107
|
+
|
|
108
|
+
data = resp.json()
|
|
109
|
+
return {
|
|
110
|
+
"id": data.get("id", ""),
|
|
111
|
+
"summary": data.get("summary", ""),
|
|
112
|
+
"url": data.get("htmlLink", ""),
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@server.tool
|
|
117
|
+
async def gcal_free_busy(
|
|
118
|
+
days_ahead: Annotated[int, Field(description="Number of days ahead to check for busy slots", ge=1, le=30)] = 3,
|
|
119
|
+
) -> dict:
|
|
120
|
+
"""Query free/busy information for the primary calendar."""
|
|
121
|
+
check_gate(SERVICE)
|
|
122
|
+
headers = {**_auth_headers(), "Content-Type": "application/json"}
|
|
123
|
+
|
|
124
|
+
now = datetime.now(timezone.utc)
|
|
125
|
+
time_max = now + timedelta(days=days_ahead)
|
|
126
|
+
|
|
127
|
+
async with httpx.AsyncClient() as client:
|
|
128
|
+
resp = await client.post(
|
|
129
|
+
f"{API}/freeBusy",
|
|
130
|
+
headers=headers,
|
|
131
|
+
json={
|
|
132
|
+
"timeMin": now.isoformat(),
|
|
133
|
+
"timeMax": time_max.isoformat(),
|
|
134
|
+
"items": [{"id": "primary"}],
|
|
135
|
+
},
|
|
136
|
+
)
|
|
137
|
+
resp.raise_for_status()
|
|
138
|
+
|
|
139
|
+
data = resp.json()
|
|
140
|
+
busy_slots = data.get("calendars", {}).get("primary", {}).get("busy", [])
|
|
141
|
+
return {
|
|
142
|
+
"busy_slots": busy_slots,
|
|
143
|
+
"count": len(busy_slots),
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@server.tool
|
|
148
|
+
async def gcal_get_event(
|
|
149
|
+
event_id: Annotated[str, Field(description="Google Calendar event ID")],
|
|
150
|
+
calendar_id: Annotated[str, Field(description="Calendar ID containing the event (default: primary)")] = "primary",
|
|
151
|
+
) -> dict:
|
|
152
|
+
"""Fetch full details for a specific calendar event."""
|
|
153
|
+
check_gate(SERVICE)
|
|
154
|
+
headers = _auth_headers()
|
|
155
|
+
|
|
156
|
+
async with httpx.AsyncClient() as client:
|
|
157
|
+
resp = await client.get(
|
|
158
|
+
f"{API}/calendars/{calendar_id}/events/{event_id}",
|
|
159
|
+
headers=headers,
|
|
160
|
+
)
|
|
161
|
+
resp.raise_for_status()
|
|
162
|
+
|
|
163
|
+
data = resp.json()
|
|
164
|
+
start = data.get("start", {})
|
|
165
|
+
end = data.get("end", {})
|
|
166
|
+
attendees = [
|
|
167
|
+
{"email": a.get("email", ""), "status": a.get("responseStatus", "")}
|
|
168
|
+
for a in data.get("attendees", [])
|
|
169
|
+
]
|
|
170
|
+
return {
|
|
171
|
+
"id": data.get("id", ""),
|
|
172
|
+
"summary": data.get("summary", ""),
|
|
173
|
+
"description": data.get("description", ""),
|
|
174
|
+
"start": start.get("dateTime", start.get("date", "")),
|
|
175
|
+
"end": end.get("dateTime", end.get("date", "")),
|
|
176
|
+
"attendees": attendees,
|
|
177
|
+
"url": data.get("htmlLink", ""),
|
|
178
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""GitHub adapter — 5 tools for repos, issues, PRs, and code search."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Annotated
|
|
5
|
+
from pydantic import Field
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
from fastmcp import FastMCP
|
|
9
|
+
from spine import Core
|
|
10
|
+
from ghostserver.gate import check_gate
|
|
11
|
+
|
|
12
|
+
SERVICE = "github"
|
|
13
|
+
API = "https://api.github.com"
|
|
14
|
+
|
|
15
|
+
server = FastMCP("GitHub")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _headers() -> dict[str, str]:
|
|
19
|
+
tokens = Core.instance().get("tokens")
|
|
20
|
+
config = Core.instance().get("config")
|
|
21
|
+
token = tokens.get(config.github.token_ref)
|
|
22
|
+
return {
|
|
23
|
+
"Authorization": f"Bearer {token}",
|
|
24
|
+
"Accept": "application/vnd.github+json",
|
|
25
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@server.tool
|
|
30
|
+
async def github_list_repos(
|
|
31
|
+
sort: Annotated[str, Field(description="Sort by: updated, created, pushed, full_name")] = "updated",
|
|
32
|
+
per_page: Annotated[int, Field(description="Results per page (max 100)", ge=1, le=100)] = 30,
|
|
33
|
+
) -> list[dict]:
|
|
34
|
+
"""List your GitHub repositories, sorted by most recently updated."""
|
|
35
|
+
check_gate(SERVICE)
|
|
36
|
+
async with httpx.AsyncClient() as client:
|
|
37
|
+
resp = await client.get(
|
|
38
|
+
f"{API}/user/repos",
|
|
39
|
+
headers=_headers(),
|
|
40
|
+
params={"sort": sort, "per_page": per_page, "type": "owner"},
|
|
41
|
+
)
|
|
42
|
+
resp.raise_for_status()
|
|
43
|
+
return [
|
|
44
|
+
{"name": r["full_name"], "description": r["description"],
|
|
45
|
+
"url": r["html_url"], "stars": r["stargazers_count"], "language": r["language"]}
|
|
46
|
+
for r in resp.json()
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@server.tool
|
|
51
|
+
async def github_create_issue(
|
|
52
|
+
owner: Annotated[str, Field(description="Repository owner")],
|
|
53
|
+
repo: Annotated[str, Field(description="Repository name")],
|
|
54
|
+
title: Annotated[str, Field(description="Issue title")],
|
|
55
|
+
body: Annotated[str, Field(description="Issue body (markdown)")] = "",
|
|
56
|
+
labels: Annotated[list[str], Field(description="Labels to apply")] = [],
|
|
57
|
+
) -> dict:
|
|
58
|
+
"""Create a new issue in a GitHub repository."""
|
|
59
|
+
check_gate(SERVICE)
|
|
60
|
+
payload = {"title": title, "body": body}
|
|
61
|
+
if labels:
|
|
62
|
+
payload["labels"] = labels
|
|
63
|
+
async with httpx.AsyncClient() as client:
|
|
64
|
+
resp = await client.post(
|
|
65
|
+
f"{API}/repos/{owner}/{repo}/issues",
|
|
66
|
+
headers=_headers(),
|
|
67
|
+
json=payload,
|
|
68
|
+
)
|
|
69
|
+
resp.raise_for_status()
|
|
70
|
+
data = resp.json()
|
|
71
|
+
return {"number": data["number"], "title": data["title"], "url": data["html_url"]}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@server.tool
|
|
75
|
+
async def github_list_prs(
|
|
76
|
+
owner: Annotated[str, Field(description="Repository owner")],
|
|
77
|
+
repo: Annotated[str, Field(description="Repository name")],
|
|
78
|
+
state: Annotated[str, Field(description="Filter: open, closed, all")] = "open",
|
|
79
|
+
) -> list[dict]:
|
|
80
|
+
"""List pull requests for a GitHub repository."""
|
|
81
|
+
check_gate(SERVICE)
|
|
82
|
+
async with httpx.AsyncClient() as client:
|
|
83
|
+
resp = await client.get(
|
|
84
|
+
f"{API}/repos/{owner}/{repo}/pulls",
|
|
85
|
+
headers=_headers(),
|
|
86
|
+
params={"state": state, "per_page": 30},
|
|
87
|
+
)
|
|
88
|
+
resp.raise_for_status()
|
|
89
|
+
return [
|
|
90
|
+
{"number": p["number"], "title": p["title"], "state": p["state"],
|
|
91
|
+
"author": p["user"]["login"], "url": p["html_url"]}
|
|
92
|
+
for p in resp.json()
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@server.tool
|
|
97
|
+
async def github_get_pr(
|
|
98
|
+
owner: Annotated[str, Field(description="Repository owner")],
|
|
99
|
+
repo: Annotated[str, Field(description="Repository name")],
|
|
100
|
+
number: Annotated[int, Field(description="PR number")],
|
|
101
|
+
) -> dict:
|
|
102
|
+
"""Get details of a specific pull request."""
|
|
103
|
+
check_gate(SERVICE)
|
|
104
|
+
async with httpx.AsyncClient() as client:
|
|
105
|
+
resp = await client.get(
|
|
106
|
+
f"{API}/repos/{owner}/{repo}/pulls/{number}",
|
|
107
|
+
headers=_headers(),
|
|
108
|
+
)
|
|
109
|
+
resp.raise_for_status()
|
|
110
|
+
pr = resp.json()
|
|
111
|
+
return {
|
|
112
|
+
"number": pr["number"], "title": pr["title"], "state": pr["state"],
|
|
113
|
+
"body": pr.get("body", ""), "author": pr["user"]["login"],
|
|
114
|
+
"merged": pr["merged"], "additions": pr["additions"],
|
|
115
|
+
"deletions": pr["deletions"], "changed_files": pr["changed_files"],
|
|
116
|
+
"url": pr["html_url"],
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@server.tool
|
|
121
|
+
async def github_search_code(
|
|
122
|
+
query: Annotated[str, Field(description="Search query (GitHub code search syntax)")],
|
|
123
|
+
per_page: Annotated[int, Field(description="Results per page", ge=1, le=100)] = 10,
|
|
124
|
+
) -> dict:
|
|
125
|
+
"""Search code across GitHub repositories."""
|
|
126
|
+
check_gate(SERVICE)
|
|
127
|
+
async with httpx.AsyncClient() as client:
|
|
128
|
+
resp = await client.get(
|
|
129
|
+
f"{API}/search/code",
|
|
130
|
+
headers=_headers(),
|
|
131
|
+
params={"q": query, "per_page": per_page},
|
|
132
|
+
)
|
|
133
|
+
resp.raise_for_status()
|
|
134
|
+
data = resp.json()
|
|
135
|
+
return {
|
|
136
|
+
"total_count": data["total_count"],
|
|
137
|
+
"items": [
|
|
138
|
+
{"file": i["name"], "path": i["path"],
|
|
139
|
+
"repo": i["repository"]["full_name"], "url": i["html_url"]}
|
|
140
|
+
for i in data["items"]
|
|
141
|
+
],
|
|
142
|
+
}
|