From cfd61bdee3a4eb9c2da2d053575758191ee21987 Mon Sep 17 00:00:00 2001 From: "david.delagneau" Date: Wed, 20 May 2026 15:16:41 -0600 Subject: [PATCH] feat: add AI Workspace context MCP configuration and enhance communication channel filtering in server --- .agents/mcp_config.json | 8 ++++ opencode.json | 5 +++ profiles/fidelity/context-sources.json | 15 +++++++ scripts/mcp/aiw-context-mcp/README.md | 2 + scripts/mcp/aiw-context-mcp/server.py | 38 ++++++++++++---- scripts/mcp/aiw-context-mcp/test_server.py | 50 ++++++++++++++++++++++ 6 files changed, 109 insertions(+), 9 deletions(-) create mode 100644 .agents/mcp_config.json create mode 100644 profiles/fidelity/context-sources.json diff --git a/.agents/mcp_config.json b/.agents/mcp_config.json new file mode 100644 index 0000000..f72df93 --- /dev/null +++ b/.agents/mcp_config.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "aiw-context-mcp": { + "url": "http://127.0.0.1:8765/mcp", + "serverUrl": "http://127.0.0.1:8765/mcp" + } + } +} diff --git a/opencode.json b/opencode.json index 42617e4..6cc12f9 100644 --- a/opencode.json +++ b/opencode.json @@ -23,6 +23,11 @@ "OBSIDIAN_HOST": "127.0.0.1", "OBSIDIAN_PORT": "27124" } + }, + "aiw-context-mcp": { + "type": "remote", + "url": "http://127.0.0.1:8765/mcp", + "enabled": true } }, "permission": { diff --git a/profiles/fidelity/context-sources.json b/profiles/fidelity/context-sources.json new file mode 100644 index 0000000..42db32b --- /dev/null +++ b/profiles/fidelity/context-sources.json @@ -0,0 +1,15 @@ +{ + "profile": "fidelity", + "communication_sources": { + "mattermost": { + "type": "mattermost_mirror", + "context_channels": [ + "fidelity-preguntas", + "fidelity-standup", + "fidelity-code-review", + "fidelity-interface-meetings-on-calendar-outlook-team-etc", + "dm-david--jeff" + ] + } + } +} diff --git a/scripts/mcp/aiw-context-mcp/README.md b/scripts/mcp/aiw-context-mcp/README.md index 38ffcc8..a87f30b 100644 --- a/scripts/mcp/aiw-context-mcp/README.md +++ b/scripts/mcp/aiw-context-mcp/README.md @@ -46,6 +46,8 @@ python3 scripts/mcp/aiw-context-mcp/server.py --transport stdio All tools are read-only. Mattermost tools read `ai/inbox/mattermost-mirror/`; photo tools list local Photo Inbox files without embedding image data; project tools read canonical Markdown under `project-knowledge/`. +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. + ## Tests ```bash diff --git a/scripts/mcp/aiw-context-mcp/server.py b/scripts/mcp/aiw-context-mcp/server.py index 8b45008..441f63b 100644 --- a/scripts/mcp/aiw-context-mcp/server.py +++ b/scripts/mcp/aiw-context-mcp/server.py @@ -70,6 +70,18 @@ def mattermost_mirror_dir(profile: str) -> Path: return inbox_dir(profile) / "mattermost-mirror" +def profile_context_channels(profile: str, source: str = "mattermost") -> set[str]: + path = ROOT / "profiles" / profile / "context-sources.json" + if not path.is_file(): + return set() + try: + config = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError: + return set() + channels = (((config.get("communication_sources") or {}).get(source) or {}).get("context_channels") or []) + return {str(item).strip().lower() for item in channels if str(item).strip()} + + def photo_inbox_dir(profile: str) -> Path: configured = os.getenv("PHOTO_INBOX_DIR", "").strip() if configured: @@ -80,10 +92,17 @@ def photo_inbox_dir(profile: str) -> Path: return Path.home() / "Pictures" / "Photo Inbox" -def parse_channels(raw: str | None) -> set[str]: +def parse_channels(raw: str | None, profile: str | None = None) -> set[str]: if not raw: raw = os.getenv("AIW_MATTERMOST_CONTEXT_CHANNELS", "") or os.getenv("AIW_MATTERMOST_PROJECT_CHANNELS", "") - return {item.strip().lower() for item in (raw or "").split(",") if item.strip()} + channels = {item.strip().lower() for item in (raw or "").split(",") if item.strip()} + if channels: + return channels + return profile_context_channels(profile or "fidelity") if profile else set() + + +def should_use_all_channels(args: dict[str, Any]) -> bool: + return bool(args.get("include_all_channels") or args.get("all_channels")) def previous_workday(today: date) -> date: @@ -171,15 +190,16 @@ def list_profiles(_: dict[str, Any]) -> dict[str, Any]: def communication_latest(args: dict[str, Any]) -> dict[str, Any]: profile = str(args.get("profile") or "fidelity") limit = clamp_limit(args.get("limit"), default=50) - records = read_jsonl(mattermost_mirror_dir(profile) / "latest.jsonl", limit=limit) - return tool_result({"profile": profile, "source": "mattermost", "evidence_type": "communication", "canonical": False, "records": records}) + channels = set() if should_use_all_channels(args) else parse_channels(args.get("channels"), profile=profile) + records = filter_channels(read_jsonl(mattermost_mirror_dir(profile) / "latest.jsonl", limit=None), channels)[-limit:] + return tool_result({"profile": profile, "source": "mattermost", "evidence_type": "communication", "canonical": False, "channel_scope": "all" if not channels else "profile", "channels": sorted(channels), "records": records}) def communication_date_context(args: dict[str, Any]) -> dict[str, Any]: profile = str(args.get("profile") or "fidelity") day = as_date(args.get("date")) limit = clamp_limit(args.get("limit"), default=100) - channels = parse_channels(args.get("channels")) + channels = set() if should_use_all_channels(args) else parse_channels(args.get("channels"), profile=profile) records = filter_channels(read_jsonl(daily_by_date_path(profile, day), limit=None), channels)[-limit:] return tool_result({"profile": profile, "source": "mattermost", "date": day.isoformat(), "channels": sorted(channels), "records": records, "canonical": False}) @@ -189,7 +209,7 @@ def communication_standup_context(args: dict[str, Any]) -> dict[str, Any]: today = as_date(args.get("today") or args.get("date")) previous = previous_workday(today) limit = clamp_limit(args.get("limit"), default=80) - channels = parse_channels(args.get("channels")) + channels = set() if should_use_all_channels(args) else parse_channels(args.get("channels"), profile=profile) previous_records = filter_channels(read_jsonl(daily_by_date_path(profile, previous)), channels)[-limit:] today_records = filter_channels(read_jsonl(daily_by_date_path(profile, today)), channels)[-limit:] return tool_result({ @@ -277,9 +297,9 @@ def photos_latest(args: dict[str, Any]) -> dict[str, Any]: 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.", "properties": {"profile": {"type": "string"}, "limit": {"type": "integer"}}}, - "communication_date_context": {"handler": communication_date_context, "description": "Read Mattermost mirror evidence for one date, optionally filtered by channels.", "properties": {"profile": {"type": "string"}, "date": {"type": "string"}, "channels": {"type": "string"}, "limit": {"type": "integer"}}}, - "communication_standup_context": {"handler": communication_standup_context, "description": "Read previous-workday and today Mattermost evidence for standup drafting.", "properties": {"profile": {"type": "string"}, "today": {"type": "string"}, "channels": {"type": "string"}, "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"}}}, + "communication_date_context": {"handler": communication_date_context, "description": "Read Mattermost mirror evidence for one date, filtered to profile context channels by default unless include_all_channels is true.", "properties": {"profile": {"type": "string"}, "date": {"type": "string"}, "channels": {"type": "string"}, "include_all_channels": {"type": "boolean"}, "limit": {"type": "integer"}}}, + "communication_standup_context": {"handler": communication_standup_context, "description": "Read previous-workday and today Mattermost evidence for standup drafting, filtered to profile context channels by default.", "properties": {"profile": {"type": "string"}, "today": {"type": "string"}, "channels": {"type": "string"}, "include_all_channels": {"type": "boolean"}, "limit": {"type": "integer"}}}, "communication_channel_context": {"handler": communication_channel_context, "description": "Read Mattermost mirror evidence for a channel and date.", "properties": {"profile": {"type": "string"}, "channel": {"type": "string"}, "date": {"type": "string"}, "limit": {"type": "integer"}}}, "communication_thread_context": {"handler": communication_thread_context, "description": "Read Mattermost mirror evidence for a thread id.", "properties": {"profile": {"type": "string"}, "thread_id": {"type": "string"}, "limit": {"type": "integer"}}}, "project_current_context": {"handler": project_current_context, "description": "Read canonical current-work and work-items context.", "properties": {"profile": {"type": "string"}}}, diff --git a/scripts/mcp/aiw-context-mcp/test_server.py b/scripts/mcp/aiw-context-mcp/test_server.py index 0743743..4b2f84d 100644 --- a/scripts/mcp/aiw-context-mcp/test_server.py +++ b/scripts/mcp/aiw-context-mcp/test_server.py @@ -61,6 +61,56 @@ class ContextMCPTests(unittest.TestCase): self.assertEqual([item["message"] for item in result["records"]], ["m1", "m2"]) + def test_communication_latest_filters_to_profile_channels_by_default(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + latest = root / "ai" / "inbox" / "mattermost-mirror" / "latest.jsonl" + profile_config = root / "profiles" / "fidelity" / "context-sources.json" + latest.parent.mkdir(parents=True) + profile_config.parent.mkdir(parents=True) + profile_config.write_text(json.dumps({ + "communication_sources": { + "mattermost": {"context_channels": ["fidelity-code-review", "dm-david--jeff"]} + } + }), encoding="utf-8") + latest.write_text( + "\n".join([ + json.dumps({"post_id": "1", "channel_name": "design-team", "message": "ignore"}), + json.dumps({"post_id": "2", "channel_name": "fidelity-code-review", "message": "keep"}), + ]) + "\n", + encoding="utf-8", + ) + + with patch.object(server, "ROOT", root), patch.dict(server.os.environ, {"AIW_MATTERMOST_CONTEXT_CHANNELS": "", "AIW_MATTERMOST_PROJECT_CHANNELS": ""}, clear=False): + result = server.communication_latest({"profile": "fidelity", "limit": 10})["structuredContent"] + + self.assertEqual([item["message"] for item in result["records"]], ["keep"]) + self.assertEqual(result["channel_scope"], "profile") + + def test_communication_latest_can_include_all_channels(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + latest = root / "ai" / "inbox" / "mattermost-mirror" / "latest.jsonl" + profile_config = root / "profiles" / "fidelity" / "context-sources.json" + latest.parent.mkdir(parents=True) + profile_config.parent.mkdir(parents=True) + profile_config.write_text(json.dumps({ + "communication_sources": {"mattermost": {"context_channels": ["fidelity-code-review"]}} + }), encoding="utf-8") + latest.write_text( + "\n".join([ + json.dumps({"post_id": "1", "channel_name": "design-team", "message": "include"}), + json.dumps({"post_id": "2", "channel_name": "fidelity-code-review", "message": "keep"}), + ]) + "\n", + encoding="utf-8", + ) + + with patch.object(server, "ROOT", root), patch.dict(server.os.environ, {"AIW_MATTERMOST_CONTEXT_CHANNELS": "", "AIW_MATTERMOST_PROJECT_CHANNELS": ""}, clear=False): + result = server.communication_latest({"profile": "fidelity", "include_all_channels": True, "limit": 10})["structuredContent"] + + self.assertEqual([item["message"] for item in result["records"]], ["include", "keep"]) + self.assertEqual(result["channel_scope"], "all") + def test_project_search_skips_templates(self) -> None: with tempfile.TemporaryDirectory() as tmp: root = Path(tmp)