gcontext-mcp 0.1.1__tar.gz
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.
- gcontext_mcp-0.1.1/.env.example +6 -0
- gcontext_mcp-0.1.1/.gitignore +8 -0
- gcontext_mcp-0.1.1/Dockerfile +9 -0
- gcontext_mcp-0.1.1/Makefile +48 -0
- gcontext_mcp-0.1.1/PKG-INFO +71 -0
- gcontext_mcp-0.1.1/README.md +61 -0
- gcontext_mcp-0.1.1/cloud_api.py +350 -0
- gcontext_mcp-0.1.1/dashboard.html +243 -0
- gcontext_mcp-0.1.1/dashboard.py +98 -0
- gcontext_mcp-0.1.1/pyproject.toml +29 -0
- gcontext_mcp-0.1.1/server.py +493 -0
- gcontext_mcp-0.1.1/test_phase1.py +62 -0
- gcontext_mcp-0.1.1/test_phase2.py +58 -0
- gcontext_mcp-0.1.1/uv.lock +805 -0
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# Secret VALUES live here, on the client only. This file is gitignored as `.env`.
|
|
2
|
+
# Copy to `.env` and fill in. Register the NAMES via the tool_register_secret tool;
|
|
3
|
+
# never put values in the database.
|
|
4
|
+
# STRIPE_KEY=sk_live_...
|
|
5
|
+
# SUPABASE_URL=https://xxxx.supabase.co
|
|
6
|
+
# SUPABASE_KEY=...
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# gcontext CLOUD api (cloud_api.py). The connector (server.py) is NOT deployed — it runs
|
|
2
|
+
# on each user's machine. Build context is apps/mcp-minimal (Coolify base_directory).
|
|
3
|
+
FROM python:3.12-slim
|
|
4
|
+
WORKDIR /app
|
|
5
|
+
RUN pip install --no-cache-dir "psycopg[binary]>=3"
|
|
6
|
+
COPY cloud_api.py .
|
|
7
|
+
ENV HOST=0.0.0.0 PORT=8770
|
|
8
|
+
EXPOSE 8770
|
|
9
|
+
CMD ["python", "cloud_api.py"]
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# macOS ships GNU Make 3.81, which ignores .ONESHELL — so the `dev` recipe is one
|
|
2
|
+
# shell line (\-joined) to keep the trap + background jobs in a single shell.
|
|
3
|
+
SHELL := /bin/bash
|
|
4
|
+
|
|
5
|
+
TOKEN ?= devtoken
|
|
6
|
+
CLOUD_PORT ?= 8770
|
|
7
|
+
DASH_PORT ?= 8765
|
|
8
|
+
CLOUD_URL := http://127.0.0.1:$(CLOUD_PORT)
|
|
9
|
+
# cloud_api.py is Postgres-backed (Phase 3). `make pg` spins a throwaway local one.
|
|
10
|
+
DATABASE_URL ?= postgres://postgres:pw@127.0.0.1:5432/gcontext
|
|
11
|
+
|
|
12
|
+
.PHONY: dev cloud dashboard pg test help
|
|
13
|
+
|
|
14
|
+
help:
|
|
15
|
+
@echo "make pg - run a throwaway local Postgres in docker (port 5432)"
|
|
16
|
+
@echo "make dev - run cloud API + dashboard together (dashboard is cloud-backed)"
|
|
17
|
+
@echo "make cloud - run only the cloud API ($(CLOUD_URL)) [needs DATABASE_URL]"
|
|
18
|
+
@echo "make dashboard - run only the dashboard (http://127.0.0.1:$(DASH_PORT))"
|
|
19
|
+
@echo "make test - run the phase verify checks against DATABASE_URL"
|
|
20
|
+
@echo
|
|
21
|
+
@echo "To connect your AI client in cloud mode, set in its MCP config env:"
|
|
22
|
+
@echo " GCONTEXT_API_URL=$(CLOUD_URL) GCONTEXT_TOKEN=$(TOKEN)"
|
|
23
|
+
|
|
24
|
+
# Throwaway local Postgres for dev/tests. Data is ephemeral.
|
|
25
|
+
pg:
|
|
26
|
+
docker run -d --name gcontext-pg -e POSTGRES_PASSWORD=pw -e POSTGRES_DB=gcontext \
|
|
27
|
+
-p 5432:5432 postgres:16
|
|
28
|
+
|
|
29
|
+
# One command to bring the whole local stack up. Ctrl-C stops both.
|
|
30
|
+
dev:
|
|
31
|
+
@echo "cloud API -> $(CLOUD_URL)"
|
|
32
|
+
@echo "dashboard -> http://127.0.0.1:$(DASH_PORT)"
|
|
33
|
+
@echo "(Ctrl-C to stop both)"
|
|
34
|
+
@trap 'kill 0' EXIT; \
|
|
35
|
+
DATABASE_URL=$(DATABASE_URL) GCONTEXT_DEV_TOKEN=$(TOKEN) PORT=$(CLOUD_PORT) uv run cloud_api.py & \
|
|
36
|
+
sleep 1; \
|
|
37
|
+
GCONTEXT_API_URL=$(CLOUD_URL) GCONTEXT_TOKEN=$(TOKEN) uv run dashboard.py & \
|
|
38
|
+
wait
|
|
39
|
+
|
|
40
|
+
cloud:
|
|
41
|
+
DATABASE_URL=$(DATABASE_URL) GCONTEXT_DEV_TOKEN=$(TOKEN) PORT=$(CLOUD_PORT) uv run cloud_api.py
|
|
42
|
+
|
|
43
|
+
dashboard:
|
|
44
|
+
GCONTEXT_API_URL=$(CLOUD_URL) GCONTEXT_TOKEN=$(TOKEN) uv run dashboard.py
|
|
45
|
+
|
|
46
|
+
test:
|
|
47
|
+
DATABASE_URL=$(DATABASE_URL) uv run test_phase1.py
|
|
48
|
+
DATABASE_URL=$(DATABASE_URL) uv run test_phase2.py
|
|
@@ -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,61 @@
|
|
|
1
|
+
# mcp-minimal
|
|
2
|
+
|
|
3
|
+
A single local Python MCP server: a files/folders tree + a secret-name registry in
|
|
4
|
+
SQLite, plus on-the-fly Python script execution with the local `.env` injected.
|
|
5
|
+
Secret VALUES never leave this machine and never enter the database.
|
|
6
|
+
|
|
7
|
+
## Setup
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
cd apps/mcp-minimal
|
|
11
|
+
cp .env.example .env # fill in your secret values
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Add to Claude Code
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
claude mcp add mcp-minimal -- uv run --directory /ABS/PATH/TO/apps/mcp-minimal python server.py
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Tools
|
|
21
|
+
|
|
22
|
+
- `tool_list_dir(path="/")`, `tool_read_file(path)`, `tool_write_file(path, content)`,
|
|
23
|
+
`tool_create_folder(path)`, `tool_delete(path)`
|
|
24
|
+
- `tool_list_secrets()`, `tool_register_secret(name, description)`, `tool_unregister_secret(name)`,
|
|
25
|
+
`tool_scaffold_env()`
|
|
26
|
+
- `tool_run_script(code)` - runs `uv run --env-file .env python -c "<code>"`
|
|
27
|
+
|
|
28
|
+
## The secret registry
|
|
29
|
+
|
|
30
|
+
The registry holds secret NAMES + descriptions only — it is for **setup and
|
|
31
|
+
verification**, not runtime. It does NOT gate `tool_run_script`, which injects the
|
|
32
|
+
whole `.env` regardless of what's registered.
|
|
33
|
+
|
|
34
|
+
1. `tool_register_secret(name, description)` - declare a required secret.
|
|
35
|
+
2. `tool_scaffold_env()` - append blank `NAME=` lines to `.env` for any registered
|
|
36
|
+
secret not yet present, so the user just fills in the values.
|
|
37
|
+
3. `tool_list_secrets()` - shows `present_locally` per name so you can confirm setup.
|
|
38
|
+
|
|
39
|
+
## How it works
|
|
40
|
+
|
|
41
|
+
1. Write a file describing a 3rd-party operation and which secret NAMES it needs;
|
|
42
|
+
declare those names with `tool_register_secret`.
|
|
43
|
+
2. To act, read the file, generate Python, and call `tool_run_script`.
|
|
44
|
+
3. Secret values resolve from the local `.env` at run time - never stored in the DB.
|
|
45
|
+
|
|
46
|
+
## Security / trust model
|
|
47
|
+
|
|
48
|
+
`tool_run_script` runs **arbitrary Python locally with your real `.env` injected** —
|
|
49
|
+
there is no sandbox. It is exactly as trusted as whatever drives the server. Run it
|
|
50
|
+
on your own machine only; never expose this server remotely.
|
|
51
|
+
|
|
52
|
+
## Script contract
|
|
53
|
+
|
|
54
|
+
- Read secrets via `os.environ["VAR"]` - never hardcode, never `load_dotenv`.
|
|
55
|
+
- Use only registered names that show `present_locally: true`.
|
|
56
|
+
- Exit codes: `0` OK, `2` missing secret (`KeyError`), `1` any other failure.
|
|
57
|
+
|
|
58
|
+
## Config (env vars)
|
|
59
|
+
|
|
60
|
+
- `MCP_MINIMAL_DB` - SQLite path (default `db.sqlite` next to `server.py`).
|
|
61
|
+
- `MCP_MINIMAL_ENV_FILE` - secret-values file (default `.env` next to `server.py`).
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
"""gcontext CLOUD backend — Postgres-backed, hosted (Phase 3).
|
|
2
|
+
|
|
3
|
+
Holds workspace STRUCTURE (files/folders/secret NAMES) in Postgres, guarded by tokens.
|
|
4
|
+
The local gcontext connector talks to this over a single /rpc endpoint. Secret VALUES,
|
|
5
|
+
the .env, and run_script NEVER touch this service — that is the whole privacy point.
|
|
6
|
+
|
|
7
|
+
Accounts (Phase 2): POST /signup -> user + workspace + token; POST /login -> fresh token.
|
|
8
|
+
Data (Phase 1): /rpc dispatches whitelisted STRUCTURE ops, scoped to the token's workspace.
|
|
9
|
+
|
|
10
|
+
Tenant isolation is now a workspace_id column (Phase 1's per-file SQLite model doesn't
|
|
11
|
+
map to a single shared Postgres). The token -> workspace_id is resolved server-side and
|
|
12
|
+
injected as the first arg of every data fn, so a client cannot escape its own tenant.
|
|
13
|
+
|
|
14
|
+
Env:
|
|
15
|
+
DATABASE_URL required — postgres connection string
|
|
16
|
+
PORT default 8770
|
|
17
|
+
HOST default 127.0.0.1 (set 0.0.0.0 in the container)
|
|
18
|
+
GCONTEXT_DEV_TOKEN optional — seeds a 'dev' workspace + token for local testing
|
|
19
|
+
|
|
20
|
+
Run locally: DATABASE_URL=postgres://... uv run cloud_api.py
|
|
21
|
+
|
|
22
|
+
# ponytail: one psycopg connection per request (autocommit). Thread-safe and simple for
|
|
23
|
+
# a low-traffic API; add psycopg_pool only if connection latency measurably bites.
|
|
24
|
+
# ponytail: scrypt from hashlib (no argon2/bcrypt dep). Memory-hard, not weakened.
|
|
25
|
+
"""
|
|
26
|
+
import json, os, hashlib, secrets, datetime
|
|
27
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
28
|
+
import psycopg
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def conn():
|
|
32
|
+
return psycopg.connect(os.environ["DATABASE_URL"], autocommit=True)
|
|
33
|
+
|
|
34
|
+
def stamp():
|
|
35
|
+
return datetime.datetime.now(datetime.timezone.utc).isoformat()
|
|
36
|
+
|
|
37
|
+
def norm(p):
|
|
38
|
+
parts = [x for x in p.split("/") if x]
|
|
39
|
+
return "/" + "/".join(parts)
|
|
40
|
+
|
|
41
|
+
def parent(path):
|
|
42
|
+
parts = [x for x in path.split("/") if x]
|
|
43
|
+
return "/" + "/".join(parts[:-1])
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
DDL = [
|
|
47
|
+
"create table if not exists workspaces("
|
|
48
|
+
"id text primary key, name text not null, created_at text not null)",
|
|
49
|
+
"create table if not exists tokens("
|
|
50
|
+
"token text primary key, workspace_id text not null)",
|
|
51
|
+
"create table if not exists users("
|
|
52
|
+
"id text primary key, email text unique not null, password_hash text not null,"
|
|
53
|
+
" workspace_id text not null, created_at text not null)",
|
|
54
|
+
"create table if not exists nodes("
|
|
55
|
+
"workspace_id text not null,"
|
|
56
|
+
"path text not null,"
|
|
57
|
+
"type text not null check(type in ('file','folder')),"
|
|
58
|
+
"content text not null default '',"
|
|
59
|
+
"updated_at text not null,"
|
|
60
|
+
"primary key (workspace_id, path))",
|
|
61
|
+
"create table if not exists secrets("
|
|
62
|
+
"workspace_id text not null,"
|
|
63
|
+
"name text not null,"
|
|
64
|
+
"description text not null default '',"
|
|
65
|
+
"present_locally boolean not null default false,"
|
|
66
|
+
"primary key (workspace_id, name))",
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
def init_db():
|
|
70
|
+
with conn() as c:
|
|
71
|
+
for stmt in DDL:
|
|
72
|
+
c.execute(stmt)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# --- accounts ----------------------------------------------------------------
|
|
76
|
+
def ensure_workspace(ws_id, name):
|
|
77
|
+
with conn() as c:
|
|
78
|
+
c.execute("insert into workspaces(id,name,created_at) values(%s,%s,%s) "
|
|
79
|
+
"on conflict(id) do nothing", (ws_id, name, stamp()))
|
|
80
|
+
|
|
81
|
+
def issue_token(token, ws_id):
|
|
82
|
+
with conn() as c:
|
|
83
|
+
c.execute("insert into tokens(token,workspace_id) values(%s,%s) "
|
|
84
|
+
"on conflict(token) do nothing", (token, ws_id))
|
|
85
|
+
|
|
86
|
+
def resolve(token):
|
|
87
|
+
with conn() as c:
|
|
88
|
+
row = c.execute("select workspace_id from tokens where token=%s", (token,)).fetchone()
|
|
89
|
+
return row[0] if row else None
|
|
90
|
+
|
|
91
|
+
def hash_pw(pw):
|
|
92
|
+
salt = secrets.token_bytes(16)
|
|
93
|
+
h = hashlib.scrypt(pw.encode(), salt=salt, n=2**14, r=8, p=1)
|
|
94
|
+
return salt.hex() + "$" + h.hex()
|
|
95
|
+
|
|
96
|
+
def verify_pw(pw, stored):
|
|
97
|
+
salt_hex, h_hex = stored.split("$", 1)
|
|
98
|
+
h = hashlib.scrypt(pw.encode(), salt=bytes.fromhex(salt_hex), n=2**14, r=8, p=1)
|
|
99
|
+
return secrets.compare_digest(h.hex(), h_hex)
|
|
100
|
+
|
|
101
|
+
def signup(email, password):
|
|
102
|
+
email = (email or "").strip().lower()
|
|
103
|
+
if not email or not password:
|
|
104
|
+
raise ValueError("email and password required")
|
|
105
|
+
with conn() as c:
|
|
106
|
+
if c.execute("select 1 from users where email=%s", (email,)).fetchone():
|
|
107
|
+
raise ValueError("email already registered")
|
|
108
|
+
ws_id = secrets.token_hex(8)
|
|
109
|
+
token = secrets.token_urlsafe(32)
|
|
110
|
+
c.execute("insert into workspaces(id,name,created_at) values(%s,%s,%s)",
|
|
111
|
+
(ws_id, email, stamp()))
|
|
112
|
+
c.execute("insert into tokens(token,workspace_id) values(%s,%s)", (token, ws_id))
|
|
113
|
+
c.execute("insert into users(id,email,password_hash,workspace_id,created_at) "
|
|
114
|
+
"values(%s,%s,%s,%s,%s)",
|
|
115
|
+
(secrets.token_hex(8), email, hash_pw(password), ws_id, stamp()))
|
|
116
|
+
return {"token": token, "workspace_id": ws_id}
|
|
117
|
+
|
|
118
|
+
def login(email, password):
|
|
119
|
+
email = (email or "").strip().lower()
|
|
120
|
+
with conn() as c:
|
|
121
|
+
row = c.execute("select password_hash, workspace_id from users where email=%s",
|
|
122
|
+
(email,)).fetchone()
|
|
123
|
+
if not row or not verify_pw(password, row[0]):
|
|
124
|
+
raise ValueError("bad credentials")
|
|
125
|
+
token = secrets.token_urlsafe(32) # fresh token per login (multiple devices ok)
|
|
126
|
+
issue_token(token, row[1])
|
|
127
|
+
return {"token": token, "workspace_id": row[1]}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# --- structure (workspace-scoped) -------------------------------------------
|
|
131
|
+
def _ensure_parents(c, workspace_id, path, now):
|
|
132
|
+
parts = [x for x in path.split("/") if x]
|
|
133
|
+
for i in range(1, len(parts)):
|
|
134
|
+
anc = "/" + "/".join(parts[:i])
|
|
135
|
+
row = c.execute("select type from nodes where workspace_id=%s and path=%s",
|
|
136
|
+
(workspace_id, anc)).fetchone()
|
|
137
|
+
if row is None:
|
|
138
|
+
c.execute("insert into nodes(workspace_id,path,type,content,updated_at) "
|
|
139
|
+
"values(%s,%s,'folder','',%s)", (workspace_id, anc, now))
|
|
140
|
+
elif row[0] == "file":
|
|
141
|
+
raise ValueError(f"path is a file, cannot contain children: {anc}")
|
|
142
|
+
|
|
143
|
+
def list_dir(workspace_id, path="/"):
|
|
144
|
+
path = norm(path)
|
|
145
|
+
with conn() as c:
|
|
146
|
+
if path != "/":
|
|
147
|
+
row = c.execute("select type from nodes where workspace_id=%s and path=%s",
|
|
148
|
+
(workspace_id, path)).fetchone()
|
|
149
|
+
if row is None:
|
|
150
|
+
raise ValueError(f"no such folder: {path}")
|
|
151
|
+
if row[0] != "folder":
|
|
152
|
+
raise ValueError(f"path is a file: {path}")
|
|
153
|
+
rows = c.execute("select path,type from nodes where workspace_id=%s",
|
|
154
|
+
(workspace_id,)).fetchall()
|
|
155
|
+
return [{"path": p, "type": t} for (p, t) in rows if parent(p) == path]
|
|
156
|
+
|
|
157
|
+
def read_file(workspace_id, path):
|
|
158
|
+
path = norm(path)
|
|
159
|
+
with conn() as c:
|
|
160
|
+
row = c.execute("select type,content from nodes where workspace_id=%s and path=%s",
|
|
161
|
+
(workspace_id, path)).fetchone()
|
|
162
|
+
if row is None:
|
|
163
|
+
raise ValueError(f"no such file: {path}")
|
|
164
|
+
if row[0] != "file":
|
|
165
|
+
raise ValueError(f"path is a folder: {path}")
|
|
166
|
+
return {"path": path, "content": row[1]}
|
|
167
|
+
|
|
168
|
+
def write_file(workspace_id, path, content):
|
|
169
|
+
path = norm(path)
|
|
170
|
+
if path == "/":
|
|
171
|
+
raise ValueError("cannot write to root")
|
|
172
|
+
now = stamp()
|
|
173
|
+
with conn() as c:
|
|
174
|
+
existing = c.execute("select type from nodes where workspace_id=%s and path=%s",
|
|
175
|
+
(workspace_id, path)).fetchone()
|
|
176
|
+
if existing and existing[0] == "folder":
|
|
177
|
+
raise ValueError(f"path is a folder: {path}")
|
|
178
|
+
_ensure_parents(c, workspace_id, path, now)
|
|
179
|
+
c.execute("insert into nodes(workspace_id,path,type,content,updated_at) "
|
|
180
|
+
"values(%s,%s,'file',%s,%s) "
|
|
181
|
+
"on conflict(workspace_id,path) do update set "
|
|
182
|
+
"content=excluded.content, updated_at=excluded.updated_at",
|
|
183
|
+
(workspace_id, path, content, now))
|
|
184
|
+
return {"path": path, "ok": True}
|
|
185
|
+
|
|
186
|
+
def create_folder(workspace_id, path):
|
|
187
|
+
path = norm(path)
|
|
188
|
+
if path == "/":
|
|
189
|
+
return {"path": "/", "ok": True}
|
|
190
|
+
now = stamp()
|
|
191
|
+
with conn() as c:
|
|
192
|
+
existing = c.execute("select type from nodes where workspace_id=%s and path=%s",
|
|
193
|
+
(workspace_id, path)).fetchone()
|
|
194
|
+
if existing:
|
|
195
|
+
if existing[0] == "file":
|
|
196
|
+
raise ValueError(f"path is a file: {path}")
|
|
197
|
+
return {"path": path, "ok": True}
|
|
198
|
+
_ensure_parents(c, workspace_id, path, now)
|
|
199
|
+
c.execute("insert into nodes(workspace_id,path,type,content,updated_at) "
|
|
200
|
+
"values(%s,%s,'folder','',%s)", (workspace_id, path, now))
|
|
201
|
+
return {"path": path, "ok": True}
|
|
202
|
+
|
|
203
|
+
def delete(workspace_id, path):
|
|
204
|
+
path = norm(path)
|
|
205
|
+
if path == "/":
|
|
206
|
+
raise ValueError("cannot delete root")
|
|
207
|
+
with conn() as c:
|
|
208
|
+
row = c.execute("select type from nodes where workspace_id=%s and path=%s",
|
|
209
|
+
(workspace_id, path)).fetchone()
|
|
210
|
+
if row is None:
|
|
211
|
+
raise ValueError(f"no such path: {path}")
|
|
212
|
+
if row[0] == "folder":
|
|
213
|
+
c.execute("delete from nodes where workspace_id=%s and (path=%s or path like %s)",
|
|
214
|
+
(workspace_id, path, path + "/%"))
|
|
215
|
+
else:
|
|
216
|
+
c.execute("delete from nodes where workspace_id=%s and path=%s", (workspace_id, path))
|
|
217
|
+
return {"path": path, "deleted": True}
|
|
218
|
+
|
|
219
|
+
def all_files(workspace_id):
|
|
220
|
+
with conn() as c:
|
|
221
|
+
rows = c.execute("select path from nodes where workspace_id=%s and type='file' "
|
|
222
|
+
"order by path", (workspace_id,)).fetchall()
|
|
223
|
+
return [p for (p,) in rows]
|
|
224
|
+
|
|
225
|
+
def secret_names(workspace_id):
|
|
226
|
+
with conn() as c:
|
|
227
|
+
rows = c.execute("select name,description,present_locally from secrets "
|
|
228
|
+
"where workspace_id=%s order by name", (workspace_id,)).fetchall()
|
|
229
|
+
return [{"name": n, "description": d, "present_locally": bool(p)} for (n, d, p) in rows]
|
|
230
|
+
|
|
231
|
+
def register_secret(workspace_id, name, description=""):
|
|
232
|
+
with conn() as c:
|
|
233
|
+
c.execute("insert into secrets(workspace_id,name,description) values(%s,%s,%s) "
|
|
234
|
+
"on conflict(workspace_id,name) do update set description=excluded.description",
|
|
235
|
+
(workspace_id, name, description))
|
|
236
|
+
return {"name": name, "ok": True}
|
|
237
|
+
|
|
238
|
+
def unregister_secret(workspace_id, name):
|
|
239
|
+
with conn() as c:
|
|
240
|
+
c.execute("delete from secrets where workspace_id=%s and name=%s", (workspace_id, name))
|
|
241
|
+
return {"name": name, "removed": True}
|
|
242
|
+
|
|
243
|
+
def report_presence(workspace_id, present):
|
|
244
|
+
with conn() as c:
|
|
245
|
+
c.execute("update secrets set present_locally=false where workspace_id=%s", (workspace_id,))
|
|
246
|
+
for name in present:
|
|
247
|
+
c.execute("update secrets set present_locally=true where workspace_id=%s and name=%s",
|
|
248
|
+
(workspace_id, name))
|
|
249
|
+
return {"ok": True, "present": present}
|
|
250
|
+
|
|
251
|
+
def overview(workspace_id):
|
|
252
|
+
with conn() as c:
|
|
253
|
+
rows = c.execute("select path,type from nodes where workspace_id=%s order by path",
|
|
254
|
+
(workspace_id,)).fetchall()
|
|
255
|
+
nsec = c.execute("select count(*) from secrets where workspace_id=%s",
|
|
256
|
+
(workspace_id,)).fetchone()[0]
|
|
257
|
+
files = [p for p, t in rows if t == "file"]
|
|
258
|
+
folders = [p for p, t in rows if t == "folder"]
|
|
259
|
+
integrations = [p for p in folders if parent(p) == "/integrations"]
|
|
260
|
+
tree = "\n".join(
|
|
261
|
+
(" " * (p.count("/") - 1)) + p.rsplit("/", 1)[-1] + ("/" if t == "folder" else "")
|
|
262
|
+
for p, t in rows
|
|
263
|
+
)
|
|
264
|
+
return {
|
|
265
|
+
"integrations": len(integrations),
|
|
266
|
+
"integration_names": [p.rsplit("/", 1)[-1] for p in integrations],
|
|
267
|
+
"folders": len(folders),
|
|
268
|
+
"files": len(files),
|
|
269
|
+
"secrets": nsec,
|
|
270
|
+
"tree": tree or "(empty)",
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
# Whitelist = STRUCTURE only. run_script / scaffold_env / env_keys are deliberately
|
|
275
|
+
# absent: execution and secret values stay on the user's machine.
|
|
276
|
+
ALLOWED = {
|
|
277
|
+
fn.__name__: fn for fn in (
|
|
278
|
+
list_dir, read_file, write_file, create_folder, delete,
|
|
279
|
+
overview, secret_names, all_files, register_secret, unregister_secret,
|
|
280
|
+
report_presence, # connector reports which secrets are set locally (booleans only)
|
|
281
|
+
)
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
class Handler(BaseHTTPRequestHandler):
|
|
286
|
+
def _send(self, code, payload):
|
|
287
|
+
data = json.dumps(payload).encode()
|
|
288
|
+
self.send_response(code)
|
|
289
|
+
self.send_header("Content-Type", "application/json")
|
|
290
|
+
self.send_header("Content-Length", str(len(data)))
|
|
291
|
+
self.end_headers()
|
|
292
|
+
self.wfile.write(data)
|
|
293
|
+
|
|
294
|
+
def _body(self):
|
|
295
|
+
n = int(self.headers.get("Content-Length") or 0)
|
|
296
|
+
return json.loads(self.rfile.read(n) or b"{}")
|
|
297
|
+
|
|
298
|
+
def do_POST(self):
|
|
299
|
+
# Account endpoints issue tokens; everything else is token-gated /rpc.
|
|
300
|
+
if self.path in ("/signup", "/login"):
|
|
301
|
+
fn = signup if self.path == "/signup" else login
|
|
302
|
+
try:
|
|
303
|
+
body = self._body()
|
|
304
|
+
return self._send(200, fn(body.get("email", ""), body.get("password", "")))
|
|
305
|
+
except ValueError as e:
|
|
306
|
+
return self._send(401 if self.path == "/login" else 400, {"error": str(e)})
|
|
307
|
+
if self.path != "/rpc":
|
|
308
|
+
return self._send(404, {"error": "not found"})
|
|
309
|
+
auth = self.headers.get("Authorization", "")
|
|
310
|
+
token = auth[len("Bearer "):] if auth.startswith("Bearer ") else ""
|
|
311
|
+
ws = resolve(token)
|
|
312
|
+
if not ws:
|
|
313
|
+
return self._send(401, {"error": "bad token"})
|
|
314
|
+
req = self._body()
|
|
315
|
+
fn = ALLOWED.get(req.get("fn"))
|
|
316
|
+
if not fn:
|
|
317
|
+
return self._send(400, {"error": f"not allowed: {req.get('fn')}"})
|
|
318
|
+
# workspace_id is injected from the resolved token; strip any client-sent value
|
|
319
|
+
# so args can never override which tenant is touched.
|
|
320
|
+
args = {k: v for k, v in (req.get("args") or {}).items() if k != "workspace_id"}
|
|
321
|
+
try:
|
|
322
|
+
self._send(200, {"result": fn(ws, **args)})
|
|
323
|
+
except ValueError as e:
|
|
324
|
+
self._send(200, {"error": str(e)}) # surfaced as ValueError on the client
|
|
325
|
+
|
|
326
|
+
def log_message(self, *a): # quiet
|
|
327
|
+
pass
|
|
328
|
+
|
|
329
|
+
def do_GET(self):
|
|
330
|
+
# Liveness probe for Coolify health checks.
|
|
331
|
+
if self.path in ("/", "/health"):
|
|
332
|
+
return self._send(200, {"ok": True})
|
|
333
|
+
return self._send(404, {"error": "not found"})
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def main():
|
|
337
|
+
host = os.environ.get("HOST", "127.0.0.1")
|
|
338
|
+
port = int(os.environ.get("PORT", "8770"))
|
|
339
|
+
init_db()
|
|
340
|
+
dev_token = os.environ.get("GCONTEXT_DEV_TOKEN")
|
|
341
|
+
if dev_token: # local dev convenience; production issues tokens via /signup
|
|
342
|
+
ensure_workspace("dev", "Dev Workspace")
|
|
343
|
+
issue_token(dev_token, "dev")
|
|
344
|
+
srv = ThreadingHTTPServer((host, port), Handler)
|
|
345
|
+
print(f"cloud api on http://{host}:{port} (db: set, dev token: {bool(dev_token)})")
|
|
346
|
+
srv.serve_forever()
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
if __name__ == "__main__":
|
|
350
|
+
main()
|