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.
@@ -0,0 +1,2 @@
1
+ """Ghostserver — Local MCP server connecting AI tools to real services."""
2
+ __version__ = "0.1.0"
@@ -0,0 +1,4 @@
1
+ """Allow running as: python -m ghostserver"""
2
+ from ghostserver.server import main
3
+
4
+ main()
@@ -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
+ }