gcontext-mcp 0.1.1__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,71 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: gcontext-mcp
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: gcontext connector — local MCP bridge: cloud structure, local secret values
|
|
5
|
+
Project-URL: Homepage, https://gcontext.ai
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Requires-Dist: mcp<2,>=1
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
11
|
+
# mcp-minimal
|
|
12
|
+
|
|
13
|
+
A single local Python MCP server: a files/folders tree + a secret-name registry in
|
|
14
|
+
SQLite, plus on-the-fly Python script execution with the local `.env` injected.
|
|
15
|
+
Secret VALUES never leave this machine and never enter the database.
|
|
16
|
+
|
|
17
|
+
## Setup
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
cd apps/mcp-minimal
|
|
21
|
+
cp .env.example .env # fill in your secret values
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Add to Claude Code
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
claude mcp add mcp-minimal -- uv run --directory /ABS/PATH/TO/apps/mcp-minimal python server.py
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Tools
|
|
31
|
+
|
|
32
|
+
- `tool_list_dir(path="/")`, `tool_read_file(path)`, `tool_write_file(path, content)`,
|
|
33
|
+
`tool_create_folder(path)`, `tool_delete(path)`
|
|
34
|
+
- `tool_list_secrets()`, `tool_register_secret(name, description)`, `tool_unregister_secret(name)`,
|
|
35
|
+
`tool_scaffold_env()`
|
|
36
|
+
- `tool_run_script(code)` - runs `uv run --env-file .env python -c "<code>"`
|
|
37
|
+
|
|
38
|
+
## The secret registry
|
|
39
|
+
|
|
40
|
+
The registry holds secret NAMES + descriptions only — it is for **setup and
|
|
41
|
+
verification**, not runtime. It does NOT gate `tool_run_script`, which injects the
|
|
42
|
+
whole `.env` regardless of what's registered.
|
|
43
|
+
|
|
44
|
+
1. `tool_register_secret(name, description)` - declare a required secret.
|
|
45
|
+
2. `tool_scaffold_env()` - append blank `NAME=` lines to `.env` for any registered
|
|
46
|
+
secret not yet present, so the user just fills in the values.
|
|
47
|
+
3. `tool_list_secrets()` - shows `present_locally` per name so you can confirm setup.
|
|
48
|
+
|
|
49
|
+
## How it works
|
|
50
|
+
|
|
51
|
+
1. Write a file describing a 3rd-party operation and which secret NAMES it needs;
|
|
52
|
+
declare those names with `tool_register_secret`.
|
|
53
|
+
2. To act, read the file, generate Python, and call `tool_run_script`.
|
|
54
|
+
3. Secret values resolve from the local `.env` at run time - never stored in the DB.
|
|
55
|
+
|
|
56
|
+
## Security / trust model
|
|
57
|
+
|
|
58
|
+
`tool_run_script` runs **arbitrary Python locally with your real `.env` injected** —
|
|
59
|
+
there is no sandbox. It is exactly as trusted as whatever drives the server. Run it
|
|
60
|
+
on your own machine only; never expose this server remotely.
|
|
61
|
+
|
|
62
|
+
## Script contract
|
|
63
|
+
|
|
64
|
+
- Read secrets via `os.environ["VAR"]` - never hardcode, never `load_dotenv`.
|
|
65
|
+
- Use only registered names that show `present_locally: true`.
|
|
66
|
+
- Exit codes: `0` OK, `2` missing secret (`KeyError`), `1` any other failure.
|
|
67
|
+
|
|
68
|
+
## Config (env vars)
|
|
69
|
+
|
|
70
|
+
- `MCP_MINIMAL_DB` - SQLite path (default `db.sqlite` next to `server.py`).
|
|
71
|
+
- `MCP_MINIMAL_ENV_FILE` - secret-values file (default `.env` next to `server.py`).
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
server.py,sha256=nFRLxA_pMK4TIAZda4wfBwezfvWby8d0APANQ0Qdtzk,19833
|
|
2
|
+
gcontext_mcp-0.1.1.dist-info/METADATA,sha256=h8fJyzf3neGpPzgNtOsyLDEGUKjEd01YXzCFoEuHB-E,2655
|
|
3
|
+
gcontext_mcp-0.1.1.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
4
|
+
gcontext_mcp-0.1.1.dist-info/entry_points.txt,sha256=MbwKdNapqvlXulgeFjIW-B_v1gaOX2HGClkIwqX4u_w,68
|
|
5
|
+
gcontext_mcp-0.1.1.dist-info/RECORD,,
|
server.py
ADDED
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
import os, sqlite3, subprocess, datetime
|
|
2
|
+
|
|
3
|
+
APP_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
4
|
+
|
|
5
|
+
# Local home for this machine's data. Secret VALUES never leave here.
|
|
6
|
+
GCONTEXT_HOME = os.path.expanduser("~/.gcontext")
|
|
7
|
+
# Baked default cloud endpoint for the packaged connector; override with GCONTEXT_API_URL.
|
|
8
|
+
DEFAULT_API_URL = "https://api.gcontext.ai"
|
|
9
|
+
|
|
10
|
+
def _db_path():
|
|
11
|
+
return os.environ.get("MCP_MINIMAL_DB", os.path.join(GCONTEXT_HOME, "db.sqlite"))
|
|
12
|
+
|
|
13
|
+
def _env_file():
|
|
14
|
+
return os.environ.get("MCP_MINIMAL_ENV_FILE", os.path.join(GCONTEXT_HOME, ".env"))
|
|
15
|
+
|
|
16
|
+
# --- cloud mode -------------------------------------------------------------
|
|
17
|
+
# When GCONTEXT_API_URL is set, structure (files/folders/secret NAMES) is read
|
|
18
|
+
# from and written to the cloud over a single /rpc endpoint. Secret VALUES and
|
|
19
|
+
# run_script ALWAYS stay local. Unset = pure local SQLite (the default).
|
|
20
|
+
import json as _json, urllib.request as _urlreq
|
|
21
|
+
|
|
22
|
+
def _cloud():
|
|
23
|
+
url = os.environ.get("GCONTEXT_API_URL")
|
|
24
|
+
return (url.rstrip("/"), os.environ.get("GCONTEXT_TOKEN", "")) if url else None
|
|
25
|
+
|
|
26
|
+
def _rpc(fn, **args):
|
|
27
|
+
base, token = _cloud()
|
|
28
|
+
req = _urlreq.Request(
|
|
29
|
+
base + "/rpc",
|
|
30
|
+
data=_json.dumps({"fn": fn, "args": args}).encode(),
|
|
31
|
+
method="POST",
|
|
32
|
+
headers={"Authorization": "Bearer " + token, "Content-Type": "application/json"},
|
|
33
|
+
)
|
|
34
|
+
with _urlreq.urlopen(req, timeout=30) as r:
|
|
35
|
+
d = _json.loads(r.read() or b"null")
|
|
36
|
+
if isinstance(d, dict) and "error" in d:
|
|
37
|
+
raise ValueError(d["error"])
|
|
38
|
+
return d["result"]
|
|
39
|
+
|
|
40
|
+
def secret_names():
|
|
41
|
+
"""[{name, description, present_locally}] from the registry — cloud or local.
|
|
42
|
+
In cloud mode present_locally is the value the connector last reported (see
|
|
43
|
+
report_presence); the cloud never sees secret VALUES, only this boolean."""
|
|
44
|
+
if _cloud():
|
|
45
|
+
return _rpc("secret_names")
|
|
46
|
+
rows = db().execute("select name,description,present_locally from secrets order by name").fetchall()
|
|
47
|
+
return [{"name": n, "description": d, "present_locally": bool(p)} for (n, d, p) in rows]
|
|
48
|
+
|
|
49
|
+
def report_presence(present):
|
|
50
|
+
"""Record which secret NAMES have a value in the local .env. Stores booleans only —
|
|
51
|
+
never values. Called by the connector; proxied to the cloud when in cloud mode."""
|
|
52
|
+
if _cloud():
|
|
53
|
+
return _rpc("report_presence", present=present)
|
|
54
|
+
conn = db()
|
|
55
|
+
conn.execute("update secrets set present_locally=0")
|
|
56
|
+
for name in present:
|
|
57
|
+
conn.execute("update secrets set present_locally=1 where name=?", (name,))
|
|
58
|
+
conn.commit()
|
|
59
|
+
return {"ok": True, "present": present}
|
|
60
|
+
|
|
61
|
+
def push_presence():
|
|
62
|
+
"""Connector → cloud: report which registered secret names are set in the local .env.
|
|
63
|
+
Fire-and-forget: swallows errors so a cloud hiccup never breaks a tool call."""
|
|
64
|
+
if not _cloud():
|
|
65
|
+
return
|
|
66
|
+
try:
|
|
67
|
+
local = env_keys()
|
|
68
|
+
report_presence([s["name"] for s in secret_names() if s["name"] in local])
|
|
69
|
+
except Exception:
|
|
70
|
+
pass
|
|
71
|
+
|
|
72
|
+
def all_files():
|
|
73
|
+
"""All file paths — cloud when configured, else local."""
|
|
74
|
+
if _cloud():
|
|
75
|
+
return _rpc("all_files")
|
|
76
|
+
return [p for (p,) in db().execute("select path from nodes where type='file' order by path")]
|
|
77
|
+
|
|
78
|
+
def stamp():
|
|
79
|
+
return datetime.datetime.now(datetime.timezone.utc).isoformat()
|
|
80
|
+
|
|
81
|
+
def norm(p):
|
|
82
|
+
parts = [x for x in p.split("/") if x]
|
|
83
|
+
return "/" + "/".join(parts)
|
|
84
|
+
|
|
85
|
+
def parent(path):
|
|
86
|
+
parts = [x for x in path.split("/") if x]
|
|
87
|
+
return "/" + "/".join(parts[:-1])
|
|
88
|
+
|
|
89
|
+
def db():
|
|
90
|
+
conn = sqlite3.connect(_db_path())
|
|
91
|
+
conn.execute(
|
|
92
|
+
"create table if not exists nodes("
|
|
93
|
+
"path text primary key,"
|
|
94
|
+
"type text not null check(type in ('file','folder')),"
|
|
95
|
+
"content text not null default '',"
|
|
96
|
+
"updated_at text not null)"
|
|
97
|
+
)
|
|
98
|
+
conn.execute(
|
|
99
|
+
"create table if not exists secrets("
|
|
100
|
+
"name text primary key,"
|
|
101
|
+
"description text not null default '',"
|
|
102
|
+
"present_locally integer not null default 0)" # presence reported by the connector
|
|
103
|
+
)
|
|
104
|
+
try: # migrate older dbs that lack the column
|
|
105
|
+
conn.execute("alter table secrets add column present_locally integer not null default 0")
|
|
106
|
+
except sqlite3.OperationalError:
|
|
107
|
+
pass
|
|
108
|
+
return conn
|
|
109
|
+
|
|
110
|
+
def ensure_parents(conn, path, now):
|
|
111
|
+
parts = [x for x in path.split("/") if x]
|
|
112
|
+
for i in range(1, len(parts)):
|
|
113
|
+
anc = "/" + "/".join(parts[:i])
|
|
114
|
+
row = conn.execute("select type from nodes where path=?", (anc,)).fetchone()
|
|
115
|
+
if row is None:
|
|
116
|
+
conn.execute(
|
|
117
|
+
"insert into nodes(path,type,content,updated_at) values(?,?,?,?)",
|
|
118
|
+
(anc, "folder", "", now),
|
|
119
|
+
)
|
|
120
|
+
elif row[0] == "file":
|
|
121
|
+
raise ValueError(f"path is a file, cannot contain children: {anc}")
|
|
122
|
+
|
|
123
|
+
def list_dir(path="/"):
|
|
124
|
+
if _cloud():
|
|
125
|
+
return _rpc("list_dir", path=path)
|
|
126
|
+
path = norm(path)
|
|
127
|
+
conn = db()
|
|
128
|
+
if path != "/":
|
|
129
|
+
row = conn.execute("select type from nodes where path=?", (path,)).fetchone()
|
|
130
|
+
if row is None:
|
|
131
|
+
raise ValueError(f"no such folder: {path}")
|
|
132
|
+
if row[0] != "folder":
|
|
133
|
+
raise ValueError(f"path is a file: {path}")
|
|
134
|
+
rows = conn.execute("select path,type from nodes").fetchall()
|
|
135
|
+
return [{"path": p, "type": t} for (p, t) in rows if parent(p) == path]
|
|
136
|
+
|
|
137
|
+
def read_file(path):
|
|
138
|
+
if _cloud():
|
|
139
|
+
return _rpc("read_file", path=path)
|
|
140
|
+
path = norm(path)
|
|
141
|
+
row = db().execute("select type,content from nodes where path=?", (path,)).fetchone()
|
|
142
|
+
if row is None:
|
|
143
|
+
raise ValueError(f"no such file: {path}")
|
|
144
|
+
if row[0] != "file":
|
|
145
|
+
raise ValueError(f"path is a folder: {path}")
|
|
146
|
+
return {"path": path, "content": row[1]}
|
|
147
|
+
|
|
148
|
+
def write_file(path, content):
|
|
149
|
+
if _cloud():
|
|
150
|
+
return _rpc("write_file", path=path, content=content)
|
|
151
|
+
path = norm(path)
|
|
152
|
+
if path == "/":
|
|
153
|
+
raise ValueError("cannot write to root")
|
|
154
|
+
now = stamp()
|
|
155
|
+
conn = db()
|
|
156
|
+
existing = conn.execute("select type from nodes where path=?", (path,)).fetchone()
|
|
157
|
+
if existing and existing[0] == "folder":
|
|
158
|
+
raise ValueError(f"path is a folder: {path}")
|
|
159
|
+
ensure_parents(conn, path, now)
|
|
160
|
+
conn.execute(
|
|
161
|
+
"insert into nodes(path,type,content,updated_at) values(?,?,?,?) "
|
|
162
|
+
"on conflict(path) do update set content=excluded.content, updated_at=excluded.updated_at",
|
|
163
|
+
(path, "file", content, now),
|
|
164
|
+
)
|
|
165
|
+
conn.commit()
|
|
166
|
+
return {"path": path, "ok": True}
|
|
167
|
+
|
|
168
|
+
def create_folder(path):
|
|
169
|
+
if _cloud():
|
|
170
|
+
return _rpc("create_folder", path=path)
|
|
171
|
+
path = norm(path)
|
|
172
|
+
if path == "/":
|
|
173
|
+
return {"path": "/", "ok": True}
|
|
174
|
+
now = stamp()
|
|
175
|
+
conn = db()
|
|
176
|
+
existing = conn.execute("select type from nodes where path=?", (path,)).fetchone()
|
|
177
|
+
if existing:
|
|
178
|
+
if existing[0] == "file":
|
|
179
|
+
raise ValueError(f"path is a file: {path}")
|
|
180
|
+
return {"path": path, "ok": True}
|
|
181
|
+
ensure_parents(conn, path, now)
|
|
182
|
+
conn.execute(
|
|
183
|
+
"insert into nodes(path,type,content,updated_at) values(?,?,?,?)",
|
|
184
|
+
(path, "folder", "", now),
|
|
185
|
+
)
|
|
186
|
+
conn.commit()
|
|
187
|
+
return {"path": path, "ok": True}
|
|
188
|
+
|
|
189
|
+
def delete(path):
|
|
190
|
+
if _cloud():
|
|
191
|
+
return _rpc("delete", path=path)
|
|
192
|
+
path = norm(path)
|
|
193
|
+
if path == "/":
|
|
194
|
+
raise ValueError("cannot delete root")
|
|
195
|
+
conn = db()
|
|
196
|
+
row = conn.execute("select type from nodes where path=?", (path,)).fetchone()
|
|
197
|
+
if row is None:
|
|
198
|
+
raise ValueError(f"no such path: {path}")
|
|
199
|
+
if row[0] == "folder":
|
|
200
|
+
conn.execute("delete from nodes where path=? or path like ?", (path, path + "/%"))
|
|
201
|
+
else:
|
|
202
|
+
conn.execute("delete from nodes where path=?", (path,))
|
|
203
|
+
conn.commit()
|
|
204
|
+
return {"path": path, "deleted": True}
|
|
205
|
+
|
|
206
|
+
def env_keys():
|
|
207
|
+
# Parse .env the way `uv run --env-file` does, so present_locally never lies
|
|
208
|
+
# about what run_script will actually see. ponytail: handles `export ` and
|
|
209
|
+
# quoted keys; ceiling = no multiline/backslash-continued values (rare). Swap
|
|
210
|
+
# in python-dotenv only if that ever bites.
|
|
211
|
+
keys = set()
|
|
212
|
+
path = _env_file()
|
|
213
|
+
if os.path.exists(path):
|
|
214
|
+
with open(path) as f:
|
|
215
|
+
for line in f:
|
|
216
|
+
line = line.strip()
|
|
217
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
218
|
+
continue
|
|
219
|
+
key = line.split("=", 1)[0].strip()
|
|
220
|
+
if key.startswith("export "):
|
|
221
|
+
key = key[len("export "):].strip()
|
|
222
|
+
key = key.strip("'\"")
|
|
223
|
+
if key:
|
|
224
|
+
keys.add(key)
|
|
225
|
+
return keys
|
|
226
|
+
|
|
227
|
+
def list_secrets():
|
|
228
|
+
# Cloud mode: presence is what the connector reported to the cloud (so a HOSTED
|
|
229
|
+
# dashboard, which can't see the local .env, still shows readiness).
|
|
230
|
+
# Local mode: compute presence directly from the local .env.
|
|
231
|
+
if _cloud():
|
|
232
|
+
return secret_names() # already carries present_locally from the cloud
|
|
233
|
+
local = env_keys()
|
|
234
|
+
return [
|
|
235
|
+
{"name": s["name"], "description": s["description"], "present_locally": s["name"] in local}
|
|
236
|
+
for s in secret_names()
|
|
237
|
+
]
|
|
238
|
+
|
|
239
|
+
def register_secret(name, description=""):
|
|
240
|
+
if _cloud():
|
|
241
|
+
return _rpc("register_secret", name=name, description=description)
|
|
242
|
+
conn = db()
|
|
243
|
+
conn.execute(
|
|
244
|
+
"insert into secrets(name,description) values(?,?) "
|
|
245
|
+
"on conflict(name) do update set description=excluded.description",
|
|
246
|
+
(name, description),
|
|
247
|
+
)
|
|
248
|
+
conn.commit()
|
|
249
|
+
return {"name": name, "ok": True}
|
|
250
|
+
|
|
251
|
+
def unregister_secret(name):
|
|
252
|
+
if _cloud():
|
|
253
|
+
return _rpc("unregister_secret", name=name)
|
|
254
|
+
conn = db()
|
|
255
|
+
conn.execute("delete from secrets where name=?", (name,))
|
|
256
|
+
conn.commit()
|
|
257
|
+
return {"name": name, "removed": True}
|
|
258
|
+
|
|
259
|
+
def scaffold_env():
|
|
260
|
+
"""Append placeholder `NAME=` lines to .env for every registered secret not
|
|
261
|
+
already present. Never writes values, never reads existing values. Idempotent."""
|
|
262
|
+
rows = secret_names() # cloud or local
|
|
263
|
+
present = env_keys()
|
|
264
|
+
missing = [(s["name"], s["description"]) for s in rows if s["name"] not in present]
|
|
265
|
+
if missing:
|
|
266
|
+
path = _env_file()
|
|
267
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
268
|
+
head = "" if (os.path.exists(path) and os.path.getsize(path)) else \
|
|
269
|
+
"# Secret VALUES live here, on the client only. Fill in the blanks.\n"
|
|
270
|
+
with open(path, "a") as f:
|
|
271
|
+
if head:
|
|
272
|
+
f.write(head)
|
|
273
|
+
for name, desc in missing:
|
|
274
|
+
if desc:
|
|
275
|
+
f.write(f"# {desc}\n")
|
|
276
|
+
f.write(f"{name}=\n")
|
|
277
|
+
return {"added": [n for (n, _) in missing], "already_present": sorted(present)}
|
|
278
|
+
|
|
279
|
+
def overview():
|
|
280
|
+
"""Live snapshot: counts of integrations, folders, files, secrets, plus a path tree."""
|
|
281
|
+
if _cloud():
|
|
282
|
+
return _rpc("overview")
|
|
283
|
+
conn = db()
|
|
284
|
+
rows = conn.execute("select path,type from nodes order by path").fetchall()
|
|
285
|
+
files = [p for p, t in rows if t == "file"]
|
|
286
|
+
folders = [p for p, t in rows if t == "folder"]
|
|
287
|
+
integrations = [p for p in folders if parent(p) == "/integrations"]
|
|
288
|
+
secrets = conn.execute("select count(*) from secrets").fetchone()[0]
|
|
289
|
+
tree = "\n".join(
|
|
290
|
+
(" " * (p.count("/") - 1)) + p.rsplit("/", 1)[-1] + ("/" if t == "folder" else "")
|
|
291
|
+
for p, t in rows
|
|
292
|
+
)
|
|
293
|
+
return {
|
|
294
|
+
"integrations": len(integrations),
|
|
295
|
+
"integration_names": [p.rsplit("/", 1)[-1] for p in integrations],
|
|
296
|
+
"folders": len(folders),
|
|
297
|
+
"files": len(files),
|
|
298
|
+
"secrets": secrets,
|
|
299
|
+
"tree": tree or "(empty)",
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
def run_script(code):
|
|
303
|
+
"""Run Python locally with the local .env injected via `uv run --env-file`.
|
|
304
|
+
Scripts read secrets from os.environ. Returns exit_code/stdout/stderr."""
|
|
305
|
+
cmd = ["uv", "run"]
|
|
306
|
+
# ponytail: skip --env-file when there's no .env, else uv errors on the missing
|
|
307
|
+
# file instead of running the script. Means run_script works with zero secrets too.
|
|
308
|
+
if os.path.exists(_env_file()):
|
|
309
|
+
cmd += ["--env-file", _env_file()]
|
|
310
|
+
cmd += ["python", "-c", code]
|
|
311
|
+
try:
|
|
312
|
+
proc = subprocess.run(
|
|
313
|
+
cmd,
|
|
314
|
+
cwd=APP_DIR,
|
|
315
|
+
capture_output=True,
|
|
316
|
+
text=True,
|
|
317
|
+
timeout=300,
|
|
318
|
+
)
|
|
319
|
+
return {"exit_code": proc.returncode, "stdout": proc.stdout, "stderr": proc.stderr}
|
|
320
|
+
except subprocess.TimeoutExpired as e:
|
|
321
|
+
return {"exit_code": 124, "stdout": e.stdout or "", "stderr": "timeout after 300s"}
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
from mcp.server.fastmcp import FastMCP, Context
|
|
325
|
+
|
|
326
|
+
def build_instructions():
|
|
327
|
+
"""On-connect intro built from the current workspace. Static for the session —
|
|
328
|
+
live state comes from tool_overview + resources/list_changed."""
|
|
329
|
+
try:
|
|
330
|
+
o = overview()
|
|
331
|
+
except Exception:
|
|
332
|
+
# cloud unreachable at boot — don't block startup; tools still work once it's up.
|
|
333
|
+
return ("This is your gcontext workspace (cloud structure + local secret values). "
|
|
334
|
+
"Call tool_overview to see what's available.")
|
|
335
|
+
names = ", ".join(o["integration_names"]) or "none yet"
|
|
336
|
+
return (
|
|
337
|
+
"This is your gcontext workspace: a virtual filesystem of context docs plus a "
|
|
338
|
+
"registry of secret NAMES (values stay in the local .env, never here).\n"
|
|
339
|
+
f"Right now: {o['integrations']} integration(s) ({names}), "
|
|
340
|
+
f"{o['files']} file(s), {o['secrets']} secret name(s).\n"
|
|
341
|
+
"To see everything live, call the tool_overview tool. "
|
|
342
|
+
"To start working with an integration, use the /use-integration prompt. "
|
|
343
|
+
"To load a specific file into context, @-mention its mcpfs:// resource."
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
# Static placeholder at import time (no DB/network side effects). The real, workspace-
|
|
347
|
+
# aware instructions are set in main() once cloud defaults are applied.
|
|
348
|
+
server = FastMCP("gcontext", instructions=(
|
|
349
|
+
"Your gcontext workspace: cloud structure + local secret values. "
|
|
350
|
+
"Call tool_overview to see what's available, or use the /use-integration prompt."))
|
|
351
|
+
|
|
352
|
+
@server.tool()
|
|
353
|
+
def tool_list_dir(path: str = "/") -> list:
|
|
354
|
+
"""List the direct children (files and folders) of a folder path."""
|
|
355
|
+
return list_dir(path)
|
|
356
|
+
|
|
357
|
+
@server.tool()
|
|
358
|
+
def tool_read_file(path: str) -> dict:
|
|
359
|
+
"""Read a file's text content by path."""
|
|
360
|
+
return read_file(path)
|
|
361
|
+
|
|
362
|
+
@server.tool()
|
|
363
|
+
async def tool_write_file(path: str, content: str, ctx: Context) -> dict:
|
|
364
|
+
"""Create or overwrite a file; missing parent folders are auto-created."""
|
|
365
|
+
r = write_file(path, content)
|
|
366
|
+
await ctx.session.send_resource_list_changed()
|
|
367
|
+
return r
|
|
368
|
+
|
|
369
|
+
@server.tool()
|
|
370
|
+
async def tool_create_folder(path: str, ctx: Context) -> dict:
|
|
371
|
+
"""Create an (optionally empty) folder; missing parents are auto-created."""
|
|
372
|
+
r = create_folder(path)
|
|
373
|
+
await ctx.session.send_resource_list_changed()
|
|
374
|
+
return r
|
|
375
|
+
|
|
376
|
+
@server.tool()
|
|
377
|
+
async def tool_delete(path: str, ctx: Context) -> dict:
|
|
378
|
+
"""Delete a file, or a folder and all its descendants (recursive)."""
|
|
379
|
+
r = delete(path)
|
|
380
|
+
await ctx.session.send_resource_list_changed()
|
|
381
|
+
return r
|
|
382
|
+
|
|
383
|
+
@server.tool()
|
|
384
|
+
def tool_list_secrets() -> list:
|
|
385
|
+
"""List registered secret NAMES with descriptions and whether each is present in the local .env. Values are never stored or returned."""
|
|
386
|
+
return list_secrets()
|
|
387
|
+
|
|
388
|
+
@server.tool()
|
|
389
|
+
def tool_register_secret(name: str, description: str = "") -> dict:
|
|
390
|
+
"""Declare a required secret NAME (no value). The registry is for setup & verification only — it does NOT affect run_script, which injects the whole .env regardless."""
|
|
391
|
+
r = register_secret(name, description)
|
|
392
|
+
push_presence() # re-evaluate the new name against the local .env
|
|
393
|
+
return r
|
|
394
|
+
|
|
395
|
+
@server.tool()
|
|
396
|
+
def tool_refresh_secrets() -> list:
|
|
397
|
+
"""Re-check the local .env and report which secrets are set (booleans only) to the cloud, then return the updated list. Use after editing your .env so the dashboard badges update without reconnecting."""
|
|
398
|
+
push_presence()
|
|
399
|
+
return list_secrets()
|
|
400
|
+
|
|
401
|
+
@server.tool()
|
|
402
|
+
def tool_unregister_secret(name: str) -> dict:
|
|
403
|
+
"""Remove a secret name from the registry. Does not touch the local .env."""
|
|
404
|
+
return unregister_secret(name)
|
|
405
|
+
|
|
406
|
+
@server.tool()
|
|
407
|
+
def tool_check_secrets() -> dict:
|
|
408
|
+
"""Verify each registered secret has a value in the local .env. Returns {all_set, secrets:[{name, present}]}. Honest even when the dashboard is cloud-hosted, since this runs locally. Values are never read or returned."""
|
|
409
|
+
secrets = [{"name": r["name"], "present": r["present_locally"]} for r in list_secrets()]
|
|
410
|
+
return {"all_set": all(s["present"] for s in secrets), "secrets": secrets}
|
|
411
|
+
|
|
412
|
+
@server.tool()
|
|
413
|
+
def tool_scaffold_env() -> dict:
|
|
414
|
+
"""Append placeholder `NAME=` lines to the local .env for every registered secret not yet present, so the user just fills in the blanks. Never writes or reads values."""
|
|
415
|
+
r = scaffold_env()
|
|
416
|
+
push_presence() # reflect the (still-empty) names so the cloud knows what's pending
|
|
417
|
+
return r
|
|
418
|
+
|
|
419
|
+
@server.tool()
|
|
420
|
+
def tool_run_script(code: str) -> dict:
|
|
421
|
+
"""Run arbitrary Python LOCALLY with the whole local .env injected. Read secrets via os.environ['NAME']. Trusted-caller only — never expose this server remotely. Returns exit_code/stdout/stderr."""
|
|
422
|
+
return run_script(code)
|
|
423
|
+
|
|
424
|
+
@server.tool()
|
|
425
|
+
def tool_overview() -> dict:
|
|
426
|
+
"""Live snapshot of everything available: counts of integrations, folders, files, and secrets, plus a path tree. Call this first to orient."""
|
|
427
|
+
return overview()
|
|
428
|
+
|
|
429
|
+
# --- Resources: expose every file so the user can @-mention it to load context ---
|
|
430
|
+
# Dynamic handlers on the low-level server read the DB on each request, so the
|
|
431
|
+
# resource list always reflects current state (FastMCP's resource registry is static).
|
|
432
|
+
import mcp.types as _t
|
|
433
|
+
|
|
434
|
+
_SCHEME = "mcpfs"
|
|
435
|
+
|
|
436
|
+
def _to_uri(path):
|
|
437
|
+
return f"{_SCHEME}://{path}" # path starts with "/" -> mcpfs:///integrations/...
|
|
438
|
+
|
|
439
|
+
def _from_uri(uri):
|
|
440
|
+
return norm(str(uri).split("://", 1)[1])
|
|
441
|
+
|
|
442
|
+
@server._mcp_server.list_resources()
|
|
443
|
+
async def _list_resources():
|
|
444
|
+
return [
|
|
445
|
+
_t.Resource(
|
|
446
|
+
uri=_to_uri(p),
|
|
447
|
+
name=p,
|
|
448
|
+
description=f"file at {p}",
|
|
449
|
+
mimeType="text/markdown" if p.endswith(".md") else "text/plain",
|
|
450
|
+
)
|
|
451
|
+
for p in all_files()
|
|
452
|
+
]
|
|
453
|
+
|
|
454
|
+
@server._mcp_server.read_resource()
|
|
455
|
+
async def _read_resource(uri):
|
|
456
|
+
return read_file(_from_uri(uri))["content"]
|
|
457
|
+
|
|
458
|
+
@server.prompt()
|
|
459
|
+
def use_integration(name: str) -> str:
|
|
460
|
+
"""Load an integration's doc + registered secret names to start working with it."""
|
|
461
|
+
doc = read_file(f"/integrations/{name}/info.md")["content"]
|
|
462
|
+
secs = ", ".join(s["name"] for s in list_secrets()) or "(none registered)"
|
|
463
|
+
return (
|
|
464
|
+
f"You are about to work with the '{name}' integration.\n\n"
|
|
465
|
+
f"--- /integrations/{name}/info.md ---\n{doc}\n\n"
|
|
466
|
+
f"Registered secret names: {secs}\n"
|
|
467
|
+
"Use the run_script tool to perform the requested operation; secrets are "
|
|
468
|
+
"available via os.environ['NAME']."
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
@server.prompt()
|
|
472
|
+
def check_secrets() -> str:
|
|
473
|
+
"""Check whether every registered secret is set in the local .env."""
|
|
474
|
+
return (
|
|
475
|
+
"Call the tool_check_secrets tool. Report one line per secret as "
|
|
476
|
+
"`NAME ✓` if present or `NAME ✗` if missing. If any are missing, suggest "
|
|
477
|
+
"running tool_scaffold_env to stub them into the local .env."
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
def main():
|
|
481
|
+
# Packaged entry point (`uvx gcontext`). Default to the hosted cloud for structure;
|
|
482
|
+
# secret VALUES stay in the local .env. Set GCONTEXT_API_URL to override (e.g. dev).
|
|
483
|
+
os.environ.setdefault("GCONTEXT_API_URL", DEFAULT_API_URL)
|
|
484
|
+
os.makedirs(os.path.dirname(_env_file()), exist_ok=True)
|
|
485
|
+
try:
|
|
486
|
+
push_presence() # tell the cloud which secrets are set locally (booleans only)
|
|
487
|
+
except Exception:
|
|
488
|
+
pass # cloud unreachable at boot — non-fatal
|
|
489
|
+
server._mcp_server.instructions = build_instructions() # now reflects live state
|
|
490
|
+
server.run()
|
|
491
|
+
|
|
492
|
+
if __name__ == "__main__":
|
|
493
|
+
main()
|