feat: add AI Workspace Menu Bar App design and enhance MCP server with resource definitions and read functionality
This commit is contained in:
76
core/services/menu-bar-app-design.md
Normal file
76
core/services/menu-bar-app-design.md
Normal 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.
|
||||||
@@ -9,6 +9,7 @@ It reads `profiles/<profile>/services.json`, starts/stops enabled services, reco
|
|||||||
```bash
|
```bash
|
||||||
python3 scripts/aiw/services.py status --profile fidelity
|
python3 scripts/aiw/services.py status --profile fidelity
|
||||||
python3 scripts/aiw/services.py doctor --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 start --profile fidelity
|
||||||
python3 scripts/aiw/services.py stop --profile fidelity
|
python3 scripts/aiw/services.py stop --profile fidelity
|
||||||
python3 scripts/aiw/services.py logs mattermost-proxy --profile fidelity
|
python3 scripts/aiw/services.py logs mattermost-proxy --profile fidelity
|
||||||
|
|||||||
@@ -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}"
|
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]:
|
def wait_for_health(config: dict[str, Any], seconds: float = 8.0) -> tuple[bool | None, str]:
|
||||||
deadline = time.time() + seconds
|
deadline = time.time() + seconds
|
||||||
last: tuple[bool | None, str] = (None, "no health check")
|
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:
|
def status_service(profile: str, ref: ServiceRef) -> None:
|
||||||
enabled = ref.config.get("enabled", True)
|
status = service_status(profile, ref)
|
||||||
kind = ref.config.get("kind", "process")
|
if not status["enabled"]:
|
||||||
if not enabled:
|
|
||||||
print(f"{ref.name}: disabled")
|
print(f"{ref.name}: disabled")
|
||||||
return
|
return
|
||||||
if kind == "app-launcher":
|
if status["kind"] == "app-launcher":
|
||||||
state = read_state(profile, ref.name)
|
state = status["state"]
|
||||||
launched = state.get("launched_at")
|
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"
|
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})")
|
print(f"{ref.name}: launcher ({suffix})")
|
||||||
return
|
return
|
||||||
pid = read_pid(profile, ref.name)
|
print(f"{ref.name}: {status['status']} pid={status['pid'] or '-'} ({status['health']['detail']})")
|
||||||
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})")
|
|
||||||
|
|
||||||
|
|
||||||
def tail_log(profile: str, service: str, lines: int) -> None:
|
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)
|
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"AI Workspace doctor profile={profile}")
|
||||||
print(f"workspace: {ROOT}")
|
print(f"workspace: {ROOT}")
|
||||||
print(f"manifest: {manifest_path(profile)}")
|
print(f"manifest: {manifest_path(profile)}")
|
||||||
ensure_runtime()
|
ensure_runtime()
|
||||||
print(f"runtime: {RUNTIME_DIR}")
|
print(f"runtime: {RUNTIME_DIR}")
|
||||||
errors = validate_manifest(manifest)
|
errors = report["manifest_errors"]
|
||||||
if errors:
|
if errors:
|
||||||
print("manifest: invalid")
|
print("manifest: invalid")
|
||||||
for error in errors:
|
for error in errors:
|
||||||
print(f" ! {error}")
|
print(f" ! {error}")
|
||||||
else:
|
else:
|
||||||
print("manifest: ok")
|
print("manifest: ok")
|
||||||
for ref in service_items(manifest, include_disabled=True):
|
for service in report["services"]:
|
||||||
enabled = ref.config.get("enabled", True)
|
enabled_text = "enabled" if service["enabled"] else "disabled"
|
||||||
command = ref.config.get("command") or []
|
if not service["enabled"]:
|
||||||
first = command[0] if command else ""
|
print(f"- {service['name']}: {enabled_text}; command={'ok' if service['command_ok'] else 'missing'}; health skipped")
|
||||||
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")
|
|
||||||
continue
|
continue
|
||||||
ok, detail = health_ok(ref.config)
|
health_text = service["health"]["detail"] if service["health"]["ok"] is not None else "no health check"
|
||||||
health_text = detail if ok is not None else "no health check"
|
print(f"- {service['name']}: {enabled_text}; command={'ok' if service['command_ok'] else 'missing'}; {health_text}")
|
||||||
print(f"- {ref.name}: {enabled_text}; command={'ok' if command_ok else 'missing'}; {health_text}")
|
for check in service["checks"]:
|
||||||
doctor = ref.config.get("doctor") or {}
|
label = check["type"].replace("_", " ")
|
||||||
for command_name in doctor.get("required_commands") or []:
|
print(f" {label} {check['name']}: {'ok' if check['ok'] else 'missing'}")
|
||||||
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'}")
|
|
||||||
|
|
||||||
|
|
||||||
def shutil_which(command: str) -> str | None:
|
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("--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("--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("--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()
|
args = parser.parse_args()
|
||||||
|
|
||||||
ensure_runtime()
|
ensure_runtime()
|
||||||
manifest = load_manifest(args.profile)
|
manifest = load_manifest(args.profile)
|
||||||
|
|
||||||
if args.action == "doctor":
|
if args.action == "doctor":
|
||||||
run_doctor(args.profile, manifest)
|
run_doctor(args.profile, manifest, json_output=args.json)
|
||||||
return
|
return
|
||||||
|
|
||||||
errors = validate_manifest(manifest)
|
errors = validate_manifest(manifest)
|
||||||
|
|||||||
@@ -119,6 +119,20 @@ class ServiceManagerTests(unittest.TestCase):
|
|||||||
self.assertFalse(log.exists())
|
self.assertFalse(log.exists())
|
||||||
self.assertEqual(log.with_suffix(".log.1").read_text(encoding="utf-8"), "old")
|
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:
|
def test_read_pid_ignores_invalid_pid_file(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
pid_dir = Path(tmp) / "pids"
|
pid_dir = Path(tmp) / "pids"
|
||||||
|
|||||||
@@ -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.
|
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
|
## Tests
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
69
scripts/mcp/aiw-context-mcp/client-configs.md
Normal file
69
scripts/mcp/aiw-context-mcp/client-configs.md
Normal 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`
|
||||||
@@ -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})
|
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]] = {
|
TOOLS: dict[str, dict[str, Any]] = {
|
||||||
"context_profiles": {"handler": list_profiles, "description": "List AI Workspace project profiles.", "properties": {}},
|
"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"}}},
|
"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)
|
requested = str(params.get("protocolVersion") or PROTOCOL_VERSION)
|
||||||
return jsonrpc_response(request_id, {
|
return jsonrpc_response(request_id, {
|
||||||
"protocolVersion": requested if requested in {PROTOCOL_VERSION, "2025-03-26"} else PROTOCOL_VERSION,
|
"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},
|
"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.",
|
"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, {})
|
return jsonrpc_response(request_id, {})
|
||||||
if method == "tools/list":
|
if method == "tools/list":
|
||||||
return jsonrpc_response(request_id, {"tools": tool_definitions()})
|
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":
|
if method == "tools/call":
|
||||||
name = str(params.get("name") or "")
|
name = str(params.get("name") or "")
|
||||||
arguments = params.get("arguments") or {}
|
arguments = params.get("arguments") or {}
|
||||||
|
|||||||
@@ -34,6 +34,37 @@ class ContextMCPTests(unittest.TestCase):
|
|||||||
self.assertIn("project_search_memory", names)
|
self.assertIn("project_search_memory", names)
|
||||||
self.assertIn("communication_latest", 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:
|
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": {}}})
|
response = server.handle_request({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "missing", "arguments": {}}})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user