dot-mcp 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.
dot_mcp/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """dot-mcp: single-file personal MCP agent with shell, files, memory,
2
+ goals, inbox, scheduler, capability URL + self-hosted OAuth."""
3
+ __version__ = "0.1.0"
dot_mcp/agent_test.py ADDED
@@ -0,0 +1,43 @@
1
+ #!/usr/bin/env python3
2
+ """Quick client test: list tools + call list_dir, get_state, remember/recall."""
3
+ import asyncio, json, os, sys
4
+ from mcp.client.streamable_http import streamable_http_client
5
+ from mcp.client.session import ClientSession
6
+
7
+ URL = sys.argv[1] if len(sys.argv) > 1 else "http://127.0.0.1:33041/mcp/"
8
+
9
+ def _secret() -> str:
10
+ if os.environ.get("MCP_PATH_SECRET", "").strip():
11
+ return os.environ["MCP_PATH_SECRET"].strip()
12
+ base = os.path.join(os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")))
13
+ for name in ("owner_secret", "path_secret"): # latter is legacy
14
+ try:
15
+ return open(os.path.join(base, "dot-mcp", name), encoding="utf-8").read().strip()
16
+ except Exception:
17
+ pass
18
+ return ""
19
+
20
+ async def main():
21
+ try:
22
+ import httpx
23
+ except ImportError:
24
+ import httpx2 as httpx # .venv/LiteLLM-era rename
25
+ sec = _secret()
26
+ client = httpx.AsyncClient(headers={"Authorization": f"Bearer {sec}"}) if sec else None
27
+ async with streamable_http_client(URL, http_client=client) as streams:
28
+ rs, ws = streams[0], streams[1]
29
+ async with ClientSession(rs, ws) as s:
30
+ await s.initialize()
31
+ tools = await s.list_tools()
32
+ names = [t.name for t in tools.tools]
33
+ print("TOOLS:", names)
34
+ r = await s.call_tool("get_state", {})
35
+ print("STATE:", r.content[0].text[:500] if r.content else r)
36
+ r = await s.call_tool("list_dir", {"path": "~", "limit": 5})
37
+ print("LS:", r.content[0].text[:800] if r.content else r)
38
+ r = await s.call_tool("remember", {"note": "agent test ok", "tag": "test"})
39
+ print("REMEMBER:", r.content[0].text[:300] if r.content else r)
40
+ r = await s.call_tool("recall", {"query": "agent test", "limit": 3})
41
+ print("RECALL:", r.content[0].text[:800] if r.content else r)
42
+
43
+ asyncio.run(main())
dot_mcp/cli.py ADDED
@@ -0,0 +1,244 @@
1
+ #!/usr/bin/env python3
2
+ """dot-mcp CLI: `dot-mcp start`, `dot-mcp status`, `dot-mcp stop`.
3
+
4
+ Works both from a source checkout (src/ layout) and as an installed package
5
+ (`pip install dot-mcp`). Defaults: port 33041, data in ~/.dot-mcp.
6
+ """
7
+ from __future__ import annotations
8
+ import argparse, json, os, secrets, subprocess, sys
9
+ from pathlib import Path
10
+
11
+ from dot_mcp import orchestrator as ui
12
+
13
+
14
+ def _repo_root() -> Path:
15
+ """Source checkout root if present (for .venv + logs), else the data dir."""
16
+ here = Path(__file__).resolve() # <repo>/src/dot_mcp/cli.py
17
+ repo = here.parent.parent.parent # <repo> in a source checkout
18
+ if (repo / "pyproject.toml").exists() or (repo / "dot_mcp.py").exists() or (repo / "src").is_dir():
19
+ return repo
20
+ data = Path(os.environ.get("DOT_MCP_DATA", str(Path.home() / ".dot-mcp")))
21
+ data.mkdir(parents=True, exist_ok=True)
22
+ return data
23
+
24
+
25
+ REPO = _repo_root()
26
+ STATE_DIR = Path(os.environ.get("DOT_MCP_STATE_DIR",
27
+ os.environ.get("XDG_CONFIG_HOME", str(Path.home() / ".config"))) ) / "dot-mcp"
28
+ SECRET_FILE = STATE_DIR / "owner_secret"
29
+ DEFAULT_PORT = 33041
30
+ DEFAULT_DATA = str(Path.home() / ".dot-mcp")
31
+
32
+ try:
33
+ from rich.console import Console
34
+ from rich.panel import Panel
35
+ from rich.table import Table
36
+ from rich import box
37
+ console = Console()
38
+ HAS_RICH = True
39
+ except Exception: # plain fallback
40
+ HAS_RICH = False
41
+ class _C:
42
+ def print(self, *a, **k):
43
+ print(*[str(x).replace("[", "").replace("]", "") for x in a])
44
+ console = _C() # type: ignore
45
+ Panel = Table = None # type: ignore
46
+
47
+ ACCENT = "cyan"
48
+ WARN = "yellow"
49
+ ERR = "red"
50
+ OK = "green"
51
+
52
+
53
+ def banner(sub: str = "") -> None:
54
+ title = "[bold cyan]dot-mcp[/] [dim]personal MCP agent[/]"
55
+ if sub:
56
+ title += f" [bold white]{sub}[/]"
57
+ if HAS_RICH:
58
+ console.print(Panel(title, box=box.ROUNDED, border_style=ACCENT))
59
+ else:
60
+ console.print(f"== dot-mcp {sub} ==")
61
+
62
+
63
+ def get_secret() -> str:
64
+ """Per-machine owner secret: read from disk, else generate + save (0600)."""
65
+ try:
66
+ s = SECRET_FILE.read_text(encoding="utf-8").strip()
67
+ if s:
68
+ return s
69
+ except Exception:
70
+ pass
71
+ s = secrets.token_urlsafe(32)
72
+ STATE_DIR.mkdir(parents=True, exist_ok=True)
73
+ os.chmod(STATE_DIR, 0o700)
74
+ tmp = SECRET_FILE.with_suffix(".tmp")
75
+ tmp.write_text(s + "\n", encoding="utf-8")
76
+ os.replace(tmp, SECRET_FILE)
77
+ os.chmod(SECRET_FILE, 0o600)
78
+ return s
79
+
80
+
81
+ def server_python() -> tuple[str, str | None]:
82
+ """(python, src_dir). Source checkout: .venv if present + src on path.
83
+ Installed package: this interpreter (dot_mcp is importable)."""
84
+ src = REPO / "src"
85
+ if (src / "dot_mcp" / "server.py").exists():
86
+ venv = REPO / ".venv" / "bin" / "python"
87
+ py = str(venv) if (venv.is_file() and os.access(venv, os.X_OK)) else sys.executable
88
+ return py, str(src)
89
+ return sys.executable, None
90
+
91
+
92
+ def _start_oauth_client(data_dir: str) -> dict:
93
+ """Read the pre-provisioned OAuth client the server writes at boot."""
94
+ try:
95
+ st = json.loads((Path(data_dir) / "oauth.json").read_text(encoding="utf-8"))
96
+ sc = st.get("static_client")
97
+ if isinstance(sc, dict) and sc.get("client_id"):
98
+ return sc
99
+ except Exception:
100
+ pass
101
+ return {}
102
+
103
+
104
+ def cmd_start(args: argparse.Namespace) -> int:
105
+ port = args.port or DEFAULT_PORT
106
+ data_dir = args.data_dir or DEFAULT_DATA
107
+ # Owner secret: stable per machine (env wins, else file, else generate+save).
108
+ secret = (os.environ.get("MCP_PATH_SECRET", "").strip() or get_secret())
109
+ py, src_dir = server_python()
110
+ env = dict(os.environ, MCP_PORT=str(port), MCP_HOST="127.0.0.1",
111
+ DOT_MCP_DATA=data_dir, MCP_PATH_SECRET=secret)
112
+ if src_dir: # source checkout: make `dot_mcp` importable for -m
113
+ env["PYTHONPATH"] = src_dir + os.pathsep + env.get("PYTHONPATH", "")
114
+ try:
115
+ v = subprocess.run([py, "-c", "import mcp; print('mcp-ok')"],
116
+ capture_output=True, text=True, timeout=30, env=env).stdout.strip()
117
+ assert "mcp-ok" in v
118
+ except Exception:
119
+ print(f"no mcp package in {py} — run ./setup.sh first", file=sys.stderr)
120
+ return 1
121
+ ctx = ui.StartCtx(port=port, data_dir=data_dir, secret=secret, py=py,
122
+ env=env, repo=REPO)
123
+
124
+ if args.json:
125
+ # script mode: full flow, JSON ONLY to stdout, server left running
126
+ import contextlib
127
+ import io
128
+ buf = io.StringIO()
129
+ with contextlib.redirect_stdout(buf):
130
+ res = ui.plain_flow(ctx, STATE_DIR, console, leave_running=True)
131
+ oauth = _start_oauth_client(data_dir)
132
+ sys.stdout.write(ui.json_result(res.ok, ctx, res, oauth) + "\n")
133
+ return 0 if res.ok else 1
134
+
135
+ banner("start")
136
+ if ui.port_in_use(port):
137
+ console.print(f"[{ERR}]port {port} is already in use — run dot-mcp status or dot-mcp stop first[/]")
138
+ return 1
139
+
140
+ # Run the full flow quietly (no spinner, no live output) — then show results once.
141
+ # leave_running=True: the verified server stays up as a detached background
142
+ # process, so we can return to the shell instead of blocking it.
143
+ import contextlib, io
144
+ buf = io.StringIO()
145
+ with contextlib.redirect_stdout(buf):
146
+ res = ui.plain_flow(ctx, STATE_DIR, console, leave_running=True)
147
+ if not res.ok:
148
+ return 1
149
+
150
+ # Show all info in one shot
151
+ host = res.host
152
+ public_mcp = res.public_url
153
+ oauth = _start_oauth_client(data_dir)
154
+ t = Table("", "", box=box.SIMPLE, show_header=False)
155
+ t.add_row("Public URL", public_mcp)
156
+ t.add_row("Owner secret", secret)
157
+ t.add_row("OAuth Client ID", oauth.get("client_id", "—"))
158
+ t.add_row("OAuth Client secret", oauth.get("client_secret", "—"))
159
+ console.print(t)
160
+ console.print(Panel(
161
+ f"[bold green]Running.[/] Paste the public URL into your connector (auth = OAuth).",
162
+ title="ready", border_style=OK))
163
+ console.print("[dim]Server running in the background — dots status / dots stop[/]\n")
164
+ return 0
165
+
166
+
167
+ def cmd_status(args: argparse.Namespace) -> int:
168
+ port = args.port or DEFAULT_PORT
169
+ try:
170
+ import urllib.request
171
+ with urllib.request.urlopen(f"http://127.0.0.1:{port}/health", timeout=5) as r:
172
+ info = json.loads(r.read().decode())
173
+ console.print(f"[{OK}]● running[/] port {port} · tools {info.get('tools')} · home {info.get('home')}")
174
+ return 0
175
+ except Exception:
176
+ console.print(f"[{ERR}]○ stopped[/] port {port} (no server)")
177
+ return 1
178
+
179
+
180
+ def cmd_stop(_: argparse.Namespace) -> int:
181
+ import signal as _sig
182
+ killed = 0
183
+ me = os.getpid()
184
+ try:
185
+ # matches both `dot_mcp.py` (legacy) and `-m dot_mcp.server` (packaged)
186
+ out = subprocess.run(["pgrep", "-f", "dot_mcp[.]"],
187
+ capture_output=True, text=True).stdout
188
+ for pid in out.split():
189
+ try:
190
+ pid_i = int(pid)
191
+ except ValueError:
192
+ continue
193
+ if pid_i == me:
194
+ continue
195
+ try:
196
+ with open(f"/proc/{pid_i}/cmdline", "rb") as fh:
197
+ cmd = fh.read().replace(b"\x00", b" ").decode(errors="replace")
198
+ if "dot_mcp.cli" in cmd: # never kill our own CLI
199
+ continue
200
+ except Exception:
201
+ continue
202
+ try:
203
+ os.kill(pid_i, _sig.SIGTERM)
204
+ killed += 1
205
+ except Exception:
206
+ pass
207
+ except Exception:
208
+ pass
209
+ pidfile = REPO / "logs" / "tunnl.pid"
210
+ try:
211
+ if pidfile.is_file():
212
+ os.kill(int(pidfile.read_text().strip()), _sig.SIGTERM)
213
+ pidfile.unlink()
214
+ console.print("Tunnel stopped.")
215
+ except Exception:
216
+ pass
217
+ console.print(f"Stopped {killed} server(s). " if killed else "No server running.")
218
+ return 0
219
+
220
+
221
+ def main(argv: list[str] | None = None) -> int:
222
+ ap = argparse.ArgumentParser(prog="dot-mcp", description="dot-mcp personal agent CLI")
223
+ sub = ap.add_subparsers(dest="cmd")
224
+ p = sub.add_parser("start", help="run MCP server + tunnel (runs in background)")
225
+ p.add_argument("--port", type=int, default=0, help="override port (default 33041)")
226
+ p.add_argument("--data-dir", default="", help="override data dir (default ~/.dot-mcp)")
227
+ p.add_argument("--json", action="store_true", help="script mode: JSON result, server left running")
228
+ q = sub.add_parser("status", help="check if the server is running")
229
+ q.add_argument("--port", type=int, default=0, help="override port (default 33041)")
230
+ sub.add_parser("stop", help="stop server + tunnel")
231
+ args = ap.parse_args(argv)
232
+ if args.cmd == "start":
233
+ return cmd_start(args)
234
+ if args.cmd == "status":
235
+ return cmd_status(args)
236
+ if args.cmd == "stop":
237
+ return cmd_stop(args)
238
+ banner()
239
+ console.print("Commands: [bold]start[/] · [bold]status[/] · [bold]stop[/]\nExample: [cyan]dot-mcp start[/]")
240
+ return 0
241
+
242
+
243
+ if __name__ == "__main__":
244
+ raise SystemExit(main())
dot_mcp/local_agent.py ADDED
@@ -0,0 +1,107 @@
1
+ #!/usr/bin/env python3
2
+ """Local-only dot agent: ChatGPT model (API key) + MCP server on this machine.
3
+ No tunnel, no sub needed. Server: http://127.0.0.1:33041/mcp
4
+
5
+ export OPENAI_API_KEY=sk-...
6
+ .venv/bin/python local_agent.py # chat loop
7
+ .venv/bin/python local_agent.py --list # list MCP tools only (no key needed)
8
+ OPENAI_MODEL=gpt-4o-mini .venv/bin/python local_agent.py
9
+ """
10
+ from __future__ import annotations
11
+ import asyncio, json, os, sys
12
+
13
+ MCP_URL = os.environ.get("MCP_URL", "http://127.0.0.1:33041/mcp/")
14
+ MODEL = os.environ.get("OPENAI_MODEL", "gpt-4o-mini")
15
+
16
+ def _owner_secret() -> str:
17
+ if os.environ.get("MCP_PATH_SECRET", "").strip():
18
+ return os.environ["MCP_PATH_SECRET"].strip()
19
+ base = os.environ.get("DOT_MCP_STATE_DIR", "") or os.path.join(
20
+ os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")))
21
+ for name in ("owner_secret", "path_secret"): # latter is legacy
22
+ try:
23
+ s = open(os.path.join(base, "dot-mcp", name), encoding="utf-8").read().strip()
24
+ if s:
25
+ return s
26
+ except Exception:
27
+ pass
28
+ return ""
29
+
30
+ def _http_client():
31
+ try:
32
+ import httpx
33
+ except ImportError:
34
+ import httpx2 as httpx # .venv/LiteLLM-era rename
35
+ sec = _owner_secret()
36
+ if not sec: return None
37
+ # NOTE: no custom User-Agent — httpx2 in .venv duplicates it and the v2 server rejects the request.
38
+ return httpx.AsyncClient(headers={"Authorization": f"Bearer {sec}"})
39
+ SYSTEM = ("You are the user's dot on their own machine. "
40
+ "At start call get_state, poll_inbox, recall. "
41
+ "Save important facts with remember, progress with add_update, "
42
+ "visible results with notify_user. "
43
+ "Relative paths resolve from $HOME.")
44
+
45
+ async def mcp_tools():
46
+ from mcp.client.streamable_http import streamable_http_client
47
+ from mcp.client.session import ClientSession
48
+ async with streamable_http_client(MCP_URL, http_client=_http_client()) as streams:
49
+ rs, ws = streams[0], streams[1]
50
+ async with ClientSession(rs, ws) as s:
51
+ await s.initialize()
52
+ t = await s.list_tools()
53
+ return [{"name": x.name, "desc": x.description or "",
54
+ "schema": getattr(x, "input_schema", None) or getattr(x, "inputSchema", None)} for x in t.tools]
55
+
56
+ async def main():
57
+ if "--list" in sys.argv:
58
+ for t in await mcp_tools():
59
+ print(f"- {t['name']}: {t['desc'][:80]}")
60
+ return
61
+ key = os.environ.get("OPENAI_API_KEY", "")
62
+ if not key:
63
+ print("need OPENAI_API_KEY"); raise SystemExit(1)
64
+ from openai import OpenAI
65
+ client = OpenAI(base_url=os.environ.get("OPENAI_BASE_URL") or None)
66
+ defs = await mcp_tools()
67
+ tools = [{"type": "function", "function": {
68
+ "name": d["name"], "description": d["desc"][:500],
69
+ "parameters": d["schema"] or {"type": "object", "properties": {}}}}
70
+ for d in defs]
71
+ from mcp.client.streamable_http import streamable_http_client
72
+ from mcp.client.session import ClientSession
73
+ async with streamable_http_client(MCP_URL, http_client=_http_client()) as streams:
74
+ rs, ws = streams[0], streams[1]
75
+ async with ClientSession(rs, ws) as s:
76
+ await s.initialize()
77
+ msgs = [{"role": "system", "content": SYSTEM}]
78
+ print(f"dot local ready [{MODEL}] ({len(tools)} tools). exit to quit.")
79
+ while True:
80
+ try: q = input("\n> ").strip()
81
+ except (EOFError, KeyboardInterrupt): break
82
+ if q in ("exit", "quit"): break
83
+ if not q: continue
84
+ msgs.append({"role": "user", "content": q})
85
+ for _ in range(10):
86
+ r = client.chat.completions.create(model=MODEL, messages=msgs, tools=tools or None)
87
+ m = r.choices[0].message
88
+ if not m.tool_calls:
89
+ print(m.content); msgs.append({"role": "assistant", "content": m.content or ""}); break
90
+ msgs.append({"role": "assistant", "content": m.content or "", "tool_calls": [
91
+ {"id": c.id, "type": "function",
92
+ "function": {"name": c.function.name, "arguments": c.function.arguments}}
93
+ for c in m.tool_calls]})
94
+ for c in m.tool_calls:
95
+ name = c.function.name
96
+ try: args = json.loads(c.function.arguments or "{}")
97
+ except Exception: args = {}
98
+ try:
99
+ res = await s.call_tool(name, args)
100
+ out = "\n".join(
101
+ getattr(b, "text", str(b)) for b in (res.content or []))[:6000]
102
+ except Exception as e:
103
+ out = f"ERROR: {e}"
104
+ print(f"[{name}] {out[:500]}")
105
+ msgs.append({"role": "tool", "tool_call_id": c.id, "content": out})
106
+
107
+ asyncio.run(main())