feat: add AI Workspace Menu Bar App design and enhance MCP server with resource definitions and read functionality

This commit is contained in:
2026-05-20 15:22:37 -06:00
parent cfd61bdee3
commit b21889c4ab
8 changed files with 368 additions and 43 deletions

View File

@@ -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.

View File

@@ -9,6 +9,7 @@ It reads `profiles/<profile>/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

View File

@@ -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)

View File

@@ -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"

View File

@@ -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

View File

@@ -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`

View File

@@ -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 {}

View File

@@ -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": {}}})