From b21889c4abfb123ba21885fb05120e76e5a22c00 Mon Sep 17 00:00:00 2001 From: "david.delagneau" Date: Wed, 20 May 2026 15:22:37 -0600 Subject: [PATCH] feat: add AI Workspace Menu Bar App design and enhance MCP server with resource definitions and read functionality --- core/services/menu-bar-app-design.md | 76 +++++++++++ scripts/aiw/README.md | 1 + scripts/aiw/services.py | 126 ++++++++++++------ scripts/aiw/test_services.py | 14 ++ scripts/mcp/aiw-context-mcp/README.md | 13 ++ scripts/mcp/aiw-context-mcp/client-configs.md | 69 ++++++++++ scripts/mcp/aiw-context-mcp/server.py | 81 ++++++++++- scripts/mcp/aiw-context-mcp/test_server.py | 31 +++++ 8 files changed, 368 insertions(+), 43 deletions(-) create mode 100644 core/services/menu-bar-app-design.md create mode 100644 scripts/mcp/aiw-context-mcp/client-configs.md diff --git a/core/services/menu-bar-app-design.md b/core/services/menu-bar-app-design.md new file mode 100644 index 0000000..0409df1 --- /dev/null +++ b/core/services/menu-bar-app-design.md @@ -0,0 +1,76 @@ +# AI Workspace Menu Bar App Design + +## Goal + +Provide a small native macOS control surface for the AI Workspace Service Manager. + +The app should not reimplement service logic. It should call the service manager/daemon API or CLI and display status, actions, and diagnostics. + +## Recommended Shape + +- SwiftUI `MenuBarExtra` app. +- Local-only, no cloud dependency. +- Start at login optional through `LaunchAgent` later. +- Read-only status by default; explicit user actions for start/stop/restart. + +## Initial UI + +```text +AI Workspace ▾ + Profile: Fidelity + + Services + ✓ Context MCP Running + ✓ Mattermost Proxy Running + ✓ Mattermost Desktop Launched + ✓ Photo Inbox Running + + Actions + Start Fidelity + Stop Fidelity + Restart Context MCP + Open Mattermost + Open Photo Inbox Folder + Copy Photo Inbox Upload URL + Open Project Knowledge + Open Logs + + Diagnostics + Run Doctor + Copy Doctor JSON + Show Recent Errors + + Settings + Start at Login + Open Config Folder +``` + +## Backend Contract + +The first version can shell out to: + +```bash +python3 scripts/aiw/services.py status --profile fidelity +python3 scripts/aiw/services.py doctor --profile fidelity --json +python3 scripts/aiw/services.py start --profile fidelity +python3 scripts/aiw/services.py stop --profile fidelity +python3 scripts/aiw/services.py restart aiw-context-mcp --profile fidelity +``` + +Longer term, prefer a small local daemon HTTP/Unix-socket API so the UI does not parse terminal text except `doctor --json`. + +## Production-Ready Rules + +- Do not store secrets in the app bundle. +- Do not expose services beyond localhost unless explicitly configured. +- Show whether a process is managed or externally running. +- Surface missing dependencies from doctor checks. +- Never let the app promote project memory automatically. +- Keep capture services and context MCP separate; the app only orchestrates lifecycle. + +## Implementation Phases + +1. CLI-backed SwiftUI menu bar app using `doctor --json` for status. +2. Add profile selector and service action buttons. +3. Add LaunchAgent support for start at login. +4. Replace shell parsing with a daemon API if daily use proves stable. diff --git a/scripts/aiw/README.md b/scripts/aiw/README.md index 6d60553..4c0e679 100644 --- a/scripts/aiw/README.md +++ b/scripts/aiw/README.md @@ -9,6 +9,7 @@ It reads `profiles//services.json`, starts/stops enabled services, reco ```bash python3 scripts/aiw/services.py status --profile fidelity python3 scripts/aiw/services.py doctor --profile fidelity +python3 scripts/aiw/services.py doctor --profile fidelity --json python3 scripts/aiw/services.py start --profile fidelity python3 scripts/aiw/services.py stop --profile fidelity python3 scripts/aiw/services.py logs mattermost-proxy --profile fidelity diff --git a/scripts/aiw/services.py b/scripts/aiw/services.py index e25b420..5dad729 100644 --- a/scripts/aiw/services.py +++ b/scripts/aiw/services.py @@ -220,6 +220,37 @@ def health_ok(config: dict[str, Any], timeout: float = 1.0) -> tuple[bool | None return None, f"unknown health type: {kind}" +def service_status(profile: str, ref: ServiceRef) -> dict[str, Any]: + enabled = ref.config.get("enabled", True) + kind = ref.config.get("kind", "process") + command = ref.config.get("command") or [] + pid = read_pid(profile, ref.name) if enabled and kind != "app-launcher" else None + running = is_running(pid) + ok, detail = health_ok(ref.config) if enabled else (None, "health skipped") + if not enabled: + label = "disabled" + elif kind == "app-launcher": + label = "launcher" + elif running and ok is not False: + label = "running" + elif running: + label = "unhealthy" + elif ok is True: + label = "externally running" + else: + label = "stopped" + return { + "name": ref.name, + "enabled": enabled, + "kind": kind, + "status": label, + "pid": pid, + "command": command, + "health": {"ok": ok, "detail": detail}, + "state": read_state(profile, ref.name), + } + + def wait_for_health(config: dict[str, Any], seconds: float = 8.0) -> tuple[bool | None, str]: deadline = time.time() + seconds last: tuple[bool | None, str] = (None, "no health check") @@ -319,29 +350,17 @@ def stop_service(profile: str, ref: ServiceRef) -> None: def status_service(profile: str, ref: ServiceRef) -> None: - enabled = ref.config.get("enabled", True) - kind = ref.config.get("kind", "process") - if not enabled: + status = service_status(profile, ref) + if not status["enabled"]: print(f"{ref.name}: disabled") return - if kind == "app-launcher": - state = read_state(profile, ref.name) + if status["kind"] == "app-launcher": + state = status["state"] launched = state.get("launched_at") suffix = f"last launched {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(launched))}" if launched else "not launched by manager" print(f"{ref.name}: launcher ({suffix})") return - pid = read_pid(profile, ref.name) - running = is_running(pid) - ok, detail = health_ok(ref.config) - if running and ok is not False: - label = "running" - elif running: - label = "unhealthy" - elif ok is True: - label = "externally running" - else: - label = "stopped" - print(f"{ref.name}: {label} pid={pid or '-'} ({detail})") + print(f"{ref.name}: {status['status']} pid={status['pid'] or '-'} ({status['health']['detail']})") def tail_log(profile: str, service: str, lines: int) -> None: @@ -354,42 +373,64 @@ def tail_log(profile: str, service: str, lines: int) -> None: print(line) -def run_doctor(profile: str, manifest: dict[str, Any]) -> None: +def doctor_report(profile: str, manifest: dict[str, Any]) -> dict[str, Any]: + errors = validate_manifest(manifest) + service_reports = [] + for ref in service_items(manifest, include_disabled=True): + command = ref.config.get("command") or [] + first = command[0] if command else "" + doctor = ref.config.get("doctor") or {} + checks = [] + for command_name in doctor.get("required_commands") or []: + checks.append({"type": "required_command", "name": command_name, "ok": command_exists(command_name)}) + for command_name in doctor.get("optional_commands") or []: + checks.append({"type": "optional_command", "name": command_name, "ok": command_exists(command_name)}) + for raw_path in doctor.get("required_paths") or []: + checks.append({"type": "required_path", "name": str(raw_path), "ok": resolve_workspace_path(str(raw_path)).exists()}) + for raw_path in doctor.get("optional_paths") or []: + checks.append({"type": "optional_path", "name": str(raw_path), "ok": resolve_workspace_path(str(raw_path)).exists()}) + status = service_status(profile, ref) + status["command_ok"] = command_exists(first) if first else False + status["checks"] = checks + service_reports.append(status) + return { + "profile": profile, + "workspace": str(ROOT), + "manifest": str(manifest_path(profile)), + "runtime": str(RUNTIME_DIR), + "manifest_ok": not errors, + "manifest_errors": errors, + "services": service_reports, + } + + +def run_doctor(profile: str, manifest: dict[str, Any], json_output: bool = False) -> None: + report = doctor_report(profile, manifest) + if json_output: + print(json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True)) + return print(f"AI Workspace doctor profile={profile}") print(f"workspace: {ROOT}") print(f"manifest: {manifest_path(profile)}") ensure_runtime() print(f"runtime: {RUNTIME_DIR}") - errors = validate_manifest(manifest) + errors = report["manifest_errors"] if errors: print("manifest: invalid") for error in errors: print(f" ! {error}") else: print("manifest: ok") - for ref in service_items(manifest, include_disabled=True): - enabled = ref.config.get("enabled", True) - command = ref.config.get("command") or [] - first = command[0] if command else "" - command_ok = command_exists(first) if first else False - enabled_text = "enabled" if enabled else "disabled" - if not enabled: - print(f"- {ref.name}: {enabled_text}; command={'ok' if command_ok else 'missing'}; health skipped") + for service in report["services"]: + enabled_text = "enabled" if service["enabled"] else "disabled" + if not service["enabled"]: + print(f"- {service['name']}: {enabled_text}; command={'ok' if service['command_ok'] else 'missing'}; health skipped") continue - ok, detail = health_ok(ref.config) - health_text = detail if ok is not None else "no health check" - print(f"- {ref.name}: {enabled_text}; command={'ok' if command_ok else 'missing'}; {health_text}") - doctor = ref.config.get("doctor") or {} - for command_name in doctor.get("required_commands") or []: - print(f" required command {command_name}: {'ok' if command_exists(command_name) else 'missing'}") - for command_name in doctor.get("optional_commands") or []: - print(f" optional command {command_name}: {'ok' if command_exists(command_name) else 'missing'}") - for raw_path in doctor.get("required_paths") or []: - path = resolve_workspace_path(str(raw_path)) - print(f" required path {raw_path}: {'ok' if path.exists() else 'missing'}") - for raw_path in doctor.get("optional_paths") or []: - path = resolve_workspace_path(str(raw_path)) - print(f" optional path {raw_path}: {'ok' if path.exists() else 'missing'}") + health_text = service["health"]["detail"] if service["health"]["ok"] is not None else "no health check" + print(f"- {service['name']}: {enabled_text}; command={'ok' if service['command_ok'] else 'missing'}; {health_text}") + for check in service["checks"]: + label = check["type"].replace("_", " ") + print(f" {label} {check['name']}: {'ok' if check['ok'] else 'missing'}") def shutil_which(command: str) -> str | None: @@ -408,13 +449,14 @@ def main() -> None: parser.add_argument("--profile", default=os.getenv("AIW_PROJECT_PROFILE", "fidelity")) parser.add_argument("--group", default="", help="Start/stop/status services in a group, e.g. communication or inbox.") parser.add_argument("--lines", type=int, default=80, help="Number of log lines for logs action.") + parser.add_argument("--json", action="store_true", help="Emit machine-readable JSON for supported actions such as doctor.") args = parser.parse_args() ensure_runtime() manifest = load_manifest(args.profile) if args.action == "doctor": - run_doctor(args.profile, manifest) + run_doctor(args.profile, manifest, json_output=args.json) return errors = validate_manifest(manifest) diff --git a/scripts/aiw/test_services.py b/scripts/aiw/test_services.py index f22ec8b..26a2c00 100644 --- a/scripts/aiw/test_services.py +++ b/scripts/aiw/test_services.py @@ -119,6 +119,20 @@ class ServiceManagerTests(unittest.TestCase): self.assertFalse(log.exists()) self.assertEqual(log.with_suffix(".log.1").read_text(encoding="utf-8"), "old") + def test_doctor_report_is_machine_readable(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + with patch.object(services, "ROOT", root), \ + patch.object(services, "RUNTIME_DIR", root / ".aiw" / "runtime"), \ + patch.object(services, "PID_DIR", root / ".aiw" / "runtime" / "pids"), \ + patch.object(services, "LOG_DIR", root / ".aiw" / "runtime" / "logs"), \ + patch.object(services, "STATE_DIR", root / ".aiw" / "runtime" / "state"): + report = services.doctor_report("test", sample_manifest()) + + self.assertTrue(report["manifest_ok"]) + self.assertEqual(report["services"][0]["name"], "alpha") + self.assertIn("health", report["services"][0]) + def test_read_pid_ignores_invalid_pid_file(self) -> None: with tempfile.TemporaryDirectory() as tmp: pid_dir = Path(tmp) / "pids" diff --git a/scripts/mcp/aiw-context-mcp/README.md b/scripts/mcp/aiw-context-mcp/README.md index a87f30b..f110d0a 100644 --- a/scripts/mcp/aiw-context-mcp/README.md +++ b/scripts/mcp/aiw-context-mcp/README.md @@ -48,6 +48,19 @@ All tools are read-only. Mattermost tools read `ai/inbox/mattermost-mirror/`; ph Mattermost latest/date/standup tools filter to the active profile's context channels by default. For Fidelity, that list lives in `profiles/fidelity/context-sources.json`. Pass explicit `channels` to override the profile list, or `include_all_channels: true` when broad unfiltered mirror evidence is intentionally needed. +## Resources + +The server also exposes profile resources such as: + +```text +aiw://profiles/fidelity/current-work +aiw://profiles/fidelity/work-items +aiw://profiles/fidelity/mattermost/latest +aiw://profiles/fidelity/photos/latest +``` + +See `client-configs.md` for client setup snippets and verification prompts. + ## Tests ```bash diff --git a/scripts/mcp/aiw-context-mcp/client-configs.md b/scripts/mcp/aiw-context-mcp/client-configs.md new file mode 100644 index 0000000..67f922f --- /dev/null +++ b/scripts/mcp/aiw-context-mcp/client-configs.md @@ -0,0 +1,69 @@ +# AIW Context MCP Client Config Notes + +The AIW Context MCP server supports both local HTTP and stdio. + +## Preferred HTTP endpoint + +When the Service Manager is running: + +```text +http://127.0.0.1:8765/mcp +``` + +Health check: + +```text +http://127.0.0.1:8765/health +``` + +Use this for clients that support MCP Streamable HTTP/local HTTP servers. + +## stdio command + +Use this for clients that launch MCP servers as subprocesses: + +```json +{ + "mcpServers": { + "aiw-context-mcp": { + "command": "python3", + "args": [ + "/Users/david/Developer/fidelity-ai-workspace/scripts/mcp/aiw-context-mcp/server.py", + "--transport", + "stdio" + ] + } + } +} +``` + +## Antigravity verification prompt + +```text +Use the AI Workspace MCP server. List available tools and resources. Then read the resource aiw://profiles/fidelity/current-work and call communication_latest with {"profile":"fidelity","limit":100}. Report the channel_scope and unique returned channel names. +``` + +## Codex/OpenAI-style verification prompt + +```text +Check whether the AIW Context MCP server is available. If available, list its tools/resources, then use project_current_context for profile fidelity. State which MCP calls were used. If unavailable, say so explicitly. +``` + +## Expected tool names + +- `context_profiles` +- `communication_latest` +- `communication_date_context` +- `communication_standup_context` +- `communication_channel_context` +- `communication_thread_context` +- `project_current_context` +- `project_search_memory` +- `photos_latest` + +## Expected resource URIs + +- `aiw://profiles/fidelity/current-work` +- `aiw://profiles/fidelity/work-items` +- `aiw://profiles/fidelity/mattermost/latest` +- `aiw://profiles/fidelity/photos/latest` diff --git a/scripts/mcp/aiw-context-mcp/server.py b/scripts/mcp/aiw-context-mcp/server.py index 441f63b..9d4f458 100644 --- a/scripts/mcp/aiw-context-mcp/server.py +++ b/scripts/mcp/aiw-context-mcp/server.py @@ -295,6 +295,75 @@ def photos_latest(args: dict[str, Any]) -> dict[str, Any]: return tool_result({"profile": profile, "source": "photo-inbox", "canonical": False, "photos": photos}) +def resource_definitions() -> list[dict[str, Any]]: + resources: list[dict[str, Any]] = [] + for profile in sorted(path.name for path in (ROOT / "profiles").iterdir() if (path / "profile.md").is_file()): + resources.extend([ + { + "uri": f"aiw://profiles/{profile}/current-work", + "name": f"{profile}-current-work", + "title": f"{profile} Current Work", + "description": "Canonical current work context.", + "mimeType": "text/markdown", + "annotations": {"audience": ["assistant"], "priority": 0.95}, + }, + { + "uri": f"aiw://profiles/{profile}/work-items", + "name": f"{profile}-work-items", + "title": f"{profile} Active Work Items", + "description": "Canonical active work-item summary.", + "mimeType": "text/markdown", + "annotations": {"audience": ["assistant"], "priority": 0.9}, + }, + { + "uri": f"aiw://profiles/{profile}/mattermost/latest", + "name": f"{profile}-mattermost-latest", + "title": f"{profile} Mattermost Latest", + "description": "Profile-filtered latest Mattermost mirror evidence as JSON.", + "mimeType": "application/json", + "annotations": {"audience": ["assistant"], "priority": 0.75}, + }, + { + "uri": f"aiw://profiles/{profile}/photos/latest", + "name": f"{profile}-photos-latest", + "title": f"{profile} Photo Inbox Latest", + "description": "Latest local Photo Inbox file metadata as JSON; image data is not embedded.", + "mimeType": "application/json", + "annotations": {"audience": ["assistant"], "priority": 0.45}, + }, + ]) + return resources + + +def read_resource(uri: str) -> dict[str, Any] | None: + parsed = urllib.parse.urlparse(uri) + if parsed.scheme != "aiw": + return None + parts = [part for part in parsed.path.split("/") if part] + if parsed.netloc != "profiles" or len(parts) < 2: + return None + profile = parts[0] + resource = "/".join(parts[1:]) + base = knowledge_dir(profile) + if resource == "current-work": + path = base / "01-current" / "current-work.md" + if not path.is_file(): + return None + return {"uri": uri, "mimeType": "text/markdown", "text": path.read_text(encoding="utf-8", errors="replace")} + if resource == "work-items": + path = base / "01-current" / "work-items.md" + if not path.is_file(): + return None + return {"uri": uri, "mimeType": "text/markdown", "text": path.read_text(encoding="utf-8", errors="replace")} + if resource == "mattermost/latest": + data = communication_latest({"profile": profile, "limit": 80})["structuredContent"] + return {"uri": uri, "mimeType": "application/json", "text": json.dumps(data, ensure_ascii=False, indent=2, sort_keys=True)} + if resource == "photos/latest": + data = photos_latest({"profile": profile, "limit": 30})["structuredContent"] + return {"uri": uri, "mimeType": "application/json", "text": json.dumps(data, ensure_ascii=False, indent=2, sort_keys=True)} + return None + + TOOLS: dict[str, dict[str, Any]] = { "context_profiles": {"handler": list_profiles, "description": "List AI Workspace project profiles.", "properties": {}}, "communication_latest": {"handler": communication_latest, "description": "Read bounded latest Mattermost mirror evidence, filtered to the profile's context channels by default.", "properties": {"profile": {"type": "string"}, "channels": {"type": "string"}, "include_all_channels": {"type": "boolean"}, "limit": {"type": "integer"}}}, @@ -342,7 +411,7 @@ def handle_request(message: dict[str, Any]) -> dict[str, Any] | None: requested = str(params.get("protocolVersion") or PROTOCOL_VERSION) return jsonrpc_response(request_id, { "protocolVersion": requested if requested in {PROTOCOL_VERSION, "2025-03-26"} else PROTOCOL_VERSION, - "capabilities": {"tools": {"listChanged": False}}, + "capabilities": {"tools": {"listChanged": False}, "resources": {"listChanged": False}}, "serverInfo": {"name": SERVER_NAME, "title": "AI Workspace Context MCP", "version": SERVER_VERSION}, "instructions": "Read-only local AI Workspace context. Evidence is not canonical memory unless promoted by the agent/user.", }) @@ -350,6 +419,16 @@ def handle_request(message: dict[str, Any]) -> dict[str, Any] | None: return jsonrpc_response(request_id, {}) if method == "tools/list": return jsonrpc_response(request_id, {"tools": tool_definitions()}) + if method == "resources/list": + return jsonrpc_response(request_id, {"resources": resource_definitions()}) + if method == "resources/read": + uri = str(params.get("uri") or "") + content = read_resource(uri) + if content is None: + return jsonrpc_error(request_id, -32002, "Resource not found", {"uri": uri}) + return jsonrpc_response(request_id, {"contents": [content]}) + if method == "resources/templates/list": + return jsonrpc_response(request_id, {"resourceTemplates": []}) if method == "tools/call": name = str(params.get("name") or "") arguments = params.get("arguments") or {} diff --git a/scripts/mcp/aiw-context-mcp/test_server.py b/scripts/mcp/aiw-context-mcp/test_server.py index 4b2f84d..d478a5f 100644 --- a/scripts/mcp/aiw-context-mcp/test_server.py +++ b/scripts/mcp/aiw-context-mcp/test_server.py @@ -34,6 +34,37 @@ class ContextMCPTests(unittest.TestCase): self.assertIn("project_search_memory", names) self.assertIn("communication_latest", names) + def test_initialize_response_declares_resources(self) -> None: + response = server.handle_request({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": server.PROTOCOL_VERSION}}) + + self.assertIn("resources", response["result"]["capabilities"]) + + def test_resources_list_includes_current_work(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + profile = root / "profiles" / "fidelity" / "profile.md" + profile.parent.mkdir(parents=True) + profile.write_text("# Fidelity", encoding="utf-8") + with patch.object(server, "ROOT", root): + response = server.handle_request({"jsonrpc": "2.0", "id": 1, "method": "resources/list"}) + + uris = {item["uri"] for item in response["result"]["resources"]} + self.assertIn("aiw://profiles/fidelity/current-work", uris) + + def test_resources_read_current_work(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + profile = root / "profiles" / "fidelity" / "profile.md" + current = root / "project-knowledge" / "01-current" / "current-work.md" + profile.parent.mkdir(parents=True) + current.parent.mkdir(parents=True) + profile.write_text("# Fidelity", encoding="utf-8") + current.write_text("# Current\nImportant", encoding="utf-8") + with patch.object(server, "ROOT", root): + response = server.handle_request({"jsonrpc": "2.0", "id": 1, "method": "resources/read", "params": {"uri": "aiw://profiles/fidelity/current-work"}}) + + self.assertEqual(response["result"]["contents"][0]["text"], "# Current\nImportant") + def test_unknown_tool_returns_protocol_error(self) -> None: response = server.handle_request({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "missing", "arguments": {}}})