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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ gcontext = server:main
3
+ gcontext-mcp = server:main
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()