diff --git a/.gitignore b/.gitignore index 56137961..494d723f 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ Thumbs.db __pycache__/ *.pyc +mcp-*/.venv/ diff --git a/mcp-message-assistant/.env.example b/mcp-message-assistant/.env.example new file mode 100644 index 00000000..1f2153fa --- /dev/null +++ b/mcp-message-assistant/.env.example @@ -0,0 +1,7 @@ +HY3_API_BASE=http://127.0.0.1:8000/v1 +HY3_API_KEY=EMPTY +HY3_MODEL=hy3 +HY3_TIMEOUT_SECONDS=120 +HY3_MAX_RETRIES=2 +HY3_MAX_PROMPT_CHARS=120000 +MESSAGE_ASSISTANT_ALLOWED_ROOTS=/absolute/path/to/message-data diff --git a/mcp-message-assistant/README.md b/mcp-message-assistant/README.md new file mode 100644 index 00000000..f72f00bf --- /dev/null +++ b/mcp-message-assistant/README.md @@ -0,0 +1,185 @@ +# Hy3 Email & Message Assistant MCP Server + +[中文说明](./README_CN.md) + +A local stdio MCP server that uses Hy3 to turn email and message files into a prioritized inbox, explicit action items, safe reply drafts, and a concise digest. + +The server only reads paths explicitly allowed by the user. It never sends, deletes, or modifies messages. + +## Workflow + +```text +local files -> load_messages -> Hy3 triage + |-> action items + |-> reply drafts + `-> inbox digest +``` + +## Tools + +| Tool | Purpose | +| --- | --- | +| `load_messages` | Read `.eml`, `.json`, `.jsonl`, `.txt`, or `.md` files into normalized JSON. | +| `triage_messages_with_hy3` | Classify messages, assign priority, identify reply needs, and preserve explicit deadlines. | +| `extract_action_items_with_hy3` | Extract tasks, owners, deadlines, waiting states, and unresolved questions. | +| `draft_replies_with_hy3` | Draft replies without sending them and flag details that need confirmation. | +| `generate_inbox_digest_with_hy3` | Create a Markdown digest organized by urgency and next action. | + +Every tool has a typed MCP input schema with parameter descriptions. The Hy3 tools support `no_think`, `low`, and `high` reasoning modes. + +## Requirements + +- Python 3.10+ +- A Hy3 OpenAI-compatible endpoint +- Cursor, CodeBuddy, or another MCP client with stdio support + +## One-command install + +From this directory: + +```bash +./scripts/install.sh +``` + +The executable is created at: + +```text +.venv/bin/hy3-message-assistant-mcp +``` + +You can also install without the script: + +```bash +python3 -m venv .venv +.venv/bin/pip install . +``` + +## Environment + +Copy the variable names from [`.env.example`](./.env.example) into your MCP client configuration or shell environment. + +| Variable | Required | Description | +| --- | --- | --- | +| `HY3_API_KEY` | Yes | API credential. Use `EMPTY` only for a local endpoint that does not require authentication. | +| `HY3_API_BASE` | No | OpenAI-compatible endpoint. Defaults to `http://127.0.0.1:8000/v1`. | +| `HY3_MODEL` | No | Served model name. Defaults to `hy3`. | +| `MESSAGE_ASSISTANT_ALLOWED_ROOTS` | Recommended | Allowed input roots, separated by the operating system path separator. Defaults to the server working directory. | +| `HY3_DRY_RUN` | No | Set to `1` to preview prompts without calling Hy3. | +| `HY3_TIMEOUT_SECONDS` | No | API timeout. Defaults to 120 seconds. | +| `HY3_MAX_PROMPT_CHARS` | No | Maximum prompt size. Defaults to 120000 characters. | + +No real API key is stored in the repository. + +## Input formats + +### JSON + +Use either a message list or an object containing `messages`: + +```json +{ + "messages": [ + { + "id": "msg-001", + "channel": "email", + "sender": "sender@example.com", + "recipients": ["me@example.com"], + "subject": "Confirm the demo", + "timestamp": "2026-07-25T09:00:00+08:00", + "body": "Please confirm by noon." + } + ] +} +``` + +JSONL uses one message object per line. EML uses the standard email format. TXT and Markdown files are treated as one message each. + +## Cursor + +Cursor project-level MCP configuration lives at `.cursor/mcp.json`. Start from [the provided example](./configs/cursor.project.mcp.json), replace the absolute executable and allowed-root paths, and set `HY3_API_KEY` in the local Cursor configuration. + +Dry-run demo request: + +```text +Use hy3-message-assistant.load_messages to read +/absolute/path/to/Hy3/mcp-message-assistant/examples/sample_messages.json. +Then pass the returned JSON to triage_messages_with_hy3 with +user_context="Project lead preparing an MCP demo". +Do not use the terminal or write a script. +``` + +## CodeBuddy + +CodeBuddy project configuration lives at `/.mcp.json`. Copy [the provided project example](./configs/codebuddy.project.mcp.json) to that location, then export the local values before starting CodeBuddy: + +```bash +export HY3_API_KEY=EMPTY +export MESSAGE_ASSISTANT_ALLOWED_ROOTS=/absolute/path/to/Hy3/mcp-message-assistant/examples +codebuddy --settings '{"enableAllProjectMcpServers":true}' \ + -p "Use hy3-message-assistant to load examples/sample_messages.json and triage the inbox." +``` + +The equivalent project-scoped CLI registration is: + +```bash +codebuddy mcp add-json --scope project hy3-message-assistant \ + '{"type":"stdio","command":"./mcp-message-assistant/.venv/bin/hy3-message-assistant-mcp","args":[],"env":{"HY3_API_BASE":"http://127.0.0.1:8000/v1","HY3_API_KEY":"EMPTY","HY3_MODEL":"hy3","MESSAGE_ASSISTANT_ALLOWED_ROOTS":"."}}' +``` + +Use environment expansion in the committed `.mcp.json`; the literal `EMPTY` above is only for a local no-auth Hy3 endpoint. + +## Demo and verification + +Run all tests: + +```bash +.venv/bin/python -m unittest discover -s tests -v +``` + +Run the local workflow without a live model: + +```bash +HY3_DRY_RUN=1 .venv/bin/python examples/demo_workflow.py +``` + +Start the stdio server, list all tools, load the sample inbox, and call all four Hy3 tools through an MCP client: + +```bash +.venv/bin/python examples/protocol_smoke_test.py +``` + +Expected summary: + +```text +tools=draft_replies_with_hy3,extract_action_items_with_hy3,generate_inbox_digest_with_hy3,load_messages,triage_messages_with_hy3 +load_messages=ok +hy3_tools=ok +``` + +## Privacy and safety + +- Message content is sent to the configured Hy3 endpoint. Use a trusted endpoint for private data. +- `MESSAGE_ASSISTANT_ALLOWED_ROOTS` limits which local paths can be read. +- The server does not connect to IMAP, Gmail, chat platforms, or sending APIs. +- Reply output is always a draft. The user remains responsible for verification and sending. +- The prompts explicitly prohibit invented deadlines, commitments, approvals, prices, or claims that a message was sent. + +## Limitations + +- Attachments are not parsed. +- HTML email is converted to plain text; complex layouts may lose structure. +- Large bodies and prompts are truncated at configured limits. +- Hy3 output quality depends on the deployed model and endpoint. +- The current implementation reads exported or local message files rather than a live mailbox. + +## Troubleshooting + +- **Path rejected:** add the parent directory to `MESSAGE_ASSISTANT_ALLOWED_ROOTS`. +- **Missing API key:** set `HY3_API_KEY`; use `EMPTY` only for a local no-auth endpoint. +- **502 or connection error:** verify that the Hy3 endpoint is running and `HY3_API_BASE` is correct. +- **Client cannot start the server:** use an absolute executable path and run the protocol smoke test first. +- **Need to test without Hy3:** set `HY3_DRY_RUN=1`. + +## License + +Apache 2.0. See the repository root license. diff --git a/mcp-message-assistant/README_CN.md b/mcp-message-assistant/README_CN.md new file mode 100644 index 00000000..84ff3c7a --- /dev/null +++ b/mcp-message-assistant/README_CN.md @@ -0,0 +1,100 @@ +# Hy3 邮件与消息处理 MCP Server + +[English](./README.md) + +这是一个本地运行的 MCP Server。它读取用户主动提供的邮件或消息文件,使用 Hy3 完成优先级判断、行动项提取、回复草稿和收件箱摘要。 + +它不会发送、删除或修改任何消息。 + +## 提供的工具 + +| 工具 | 功能 | +| --- | --- | +| `load_messages` | 读取 `.eml`、`.json`、`.jsonl`、`.txt` 和 `.md` 文件,并统一为消息列表。 | +| `triage_messages_with_hy3` | 判断消息类别、优先级、是否需要回复和明确截止时间。 | +| `extract_action_items_with_hy3` | 提取任务、负责人、截止时间、等待状态和未解决问题。 | +| `draft_replies_with_hy3` | 生成回复草稿,并标出必须由用户确认的细节。 | +| `generate_inbox_digest_with_hy3` | 生成按照紧急程度和下一步行动组织的摘要。 | + +## 安装 + +在当前目录运行: + +```bash +./scripts/install.sh +``` + +安装后启动命令位于: + +```text +.venv/bin/hy3-message-assistant-mcp +``` + +## 必要配置 + +`HY3_API_KEY` 必须通过环境变量或 MCP 客户端配置传入。使用不需要鉴权的本地 Hy3 服务时,可以设为 `EMPTY`。 + +```bash +export HY3_API_BASE=http://127.0.0.1:8000/v1 +export HY3_API_KEY=EMPTY +export HY3_MODEL=hy3 +export MESSAGE_ASSISTANT_ALLOWED_ROOTS=/absolute/path/to/message-data +``` + +其他可选变量见 [`.env.example`](./.env.example)。 + +## Cursor 配置 + +将 [`configs/cursor.project.mcp.json`](./configs/cursor.project.mcp.json) 的内容放到项目的 `.cursor/mcp.json`,替换其中的绝对路径和本地凭据。 + +可直接在 Cursor Agent 中输入: + +```text +直接使用 hy3-message-assistant.load_messages 读取 +/absolute/path/to/mcp-message-assistant/examples/sample_messages.json, +然后把返回结果交给 triage_messages_with_hy3。 +不要运行终端,不要另写脚本。 +``` + +## CodeBuddy 配置 + +将 [`configs/codebuddy.project.mcp.json`](./configs/codebuddy.project.mcp.json) 复制到项目根目录的 `.mcp.json`。该配置通过环境变量引用凭据,不保存真实密钥。 + +项目级命令示例: + +```bash +codebuddy mcp add-json --scope project hy3-message-assistant \ + '{"type":"stdio","command":"./mcp-message-assistant/.venv/bin/hy3-message-assistant-mcp","args":[],"env":{"HY3_API_BASE":"http://127.0.0.1:8000/v1","HY3_API_KEY":"EMPTY","HY3_MODEL":"hy3","MESSAGE_ASSISTANT_ALLOWED_ROOTS":"."}}' +``` + +## 测试与演示 + +```bash +.venv/bin/python -m unittest discover -s tests -v +HY3_DRY_RUN=1 .venv/bin/python examples/demo_workflow.py +.venv/bin/python examples/protocol_smoke_test.py +``` + +样例数据位于: + +- `examples/sample_messages.json` +- `examples/sample_email.eml` + +## 隐私与限制 + +- 消息内容会发送到用户配置的 Hy3 服务,请只使用可信服务处理私人数据。 +- Server 只能读取 `MESSAGE_ASSISTANT_ALLOWED_ROOTS` 指定的目录。 +- 当前不连接真实邮箱或聊天平台,也不会发送消息。 +- 附件暂不解析,复杂 HTML 邮件会转为纯文本。 +- 大段正文会按照配置限制截断。 + +## 常见问题 + +- 路径无法读取:检查 `MESSAGE_ASSISTANT_ALLOWED_ROOTS`。 +- 提示缺少凭据:设置 `HY3_API_KEY`。 +- 接口返回 502:检查 Hy3 服务和 `HY3_API_BASE`。 +- 没有可用 Hy3 服务:设置 `HY3_DRY_RUN=1` 验证完整 MCP 调用链。 + +## 许可证 + +Apache 2.0,参见仓库根目录许可证。 diff --git a/mcp-message-assistant/configs/codebuddy.project.mcp.json b/mcp-message-assistant/configs/codebuddy.project.mcp.json new file mode 100644 index 00000000..7cfe0307 --- /dev/null +++ b/mcp-message-assistant/configs/codebuddy.project.mcp.json @@ -0,0 +1,16 @@ +{ + "mcpServers": { + "hy3-message-assistant": { + "type": "stdio", + "command": "./mcp-message-assistant/.venv/bin/hy3-message-assistant-mcp", + "args": [], + "env": { + "HY3_API_BASE": "${HY3_API_BASE:-http://127.0.0.1:8000/v1}", + "HY3_API_KEY": "${HY3_API_KEY}", + "HY3_MODEL": "${HY3_MODEL:-hy3}", + "MESSAGE_ASSISTANT_ALLOWED_ROOTS": "${MESSAGE_ASSISTANT_ALLOWED_ROOTS:-.}" + }, + "description": "Hy3-powered email and message triage, action extraction, reply drafting, and digest generation" + } + } +} diff --git a/mcp-message-assistant/configs/cursor.project.mcp.json b/mcp-message-assistant/configs/cursor.project.mcp.json new file mode 100644 index 00000000..4e922eb2 --- /dev/null +++ b/mcp-message-assistant/configs/cursor.project.mcp.json @@ -0,0 +1,14 @@ +{ + "mcpServers": { + "hy3-message-assistant": { + "command": "/absolute/path/to/Hy3/mcp-message-assistant/.venv/bin/hy3-message-assistant-mcp", + "args": [], + "env": { + "HY3_API_BASE": "http://127.0.0.1:8000/v1", + "HY3_API_KEY": "REPLACE_WITH_KEY_OR_EMPTY", + "HY3_MODEL": "hy3", + "MESSAGE_ASSISTANT_ALLOWED_ROOTS": "/absolute/path/to/message-data" + } + } + } +} diff --git a/mcp-message-assistant/examples/demo_workflow.py b/mcp-message-assistant/examples/demo_workflow.py new file mode 100644 index 00000000..f397bce1 --- /dev/null +++ b/mcp-message-assistant/examples/demo_workflow.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +import os +from pathlib import Path + +from hy3_message_assistant_mcp.core import ( + draft_replies_with_hy3, + extract_action_items_with_hy3, + generate_inbox_digest_with_hy3, + triage_messages_with_hy3, +) +from hy3_message_assistant_mcp.loaders import load_messages + + +def main() -> None: + root = Path(__file__).resolve().parents[1] + sample = root / "examples" / "sample_messages.json" + os.environ.setdefault("MESSAGE_ASSISTANT_ALLOWED_ROOTS", str(root)) + os.environ.setdefault("HY3_DRY_RUN", "1") + + messages = load_messages(str(sample)) + outputs = [ + ("triage", triage_messages_with_hy3(messages, user_context="Project lead preparing an MCP demo.")), + ("actions", extract_action_items_with_hy3(messages)), + ("drafts", draft_replies_with_hy3(messages, tone="friendly and concise")), + ("digest", generate_inbox_digest_with_hy3(messages)), + ] + + for name, output in outputs: + print(f"\n=== {name} ===\n") + print(output) + + +if __name__ == "__main__": + main() diff --git a/mcp-message-assistant/examples/protocol_smoke_test.py b/mcp-message-assistant/examples/protocol_smoke_test.py new file mode 100644 index 00000000..b24a4ab3 --- /dev/null +++ b/mcp-message-assistant/examples/protocol_smoke_test.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +import asyncio +import os +import sys +from pathlib import Path + +from mcp.client.session import ClientSession +from mcp.client.stdio import StdioServerParameters, stdio_client + + +EXPECTED_TOOLS = { + "load_messages", + "triage_messages_with_hy3", + "extract_action_items_with_hy3", + "draft_replies_with_hy3", + "generate_inbox_digest_with_hy3", +} + + +async def _smoke_test() -> None: + root = Path(__file__).resolve().parents[1] + command = str(root / ".venv" / "bin" / "hy3-message-assistant-mcp") + if not Path(command).exists(): + command = "hy3-message-assistant-mcp" + + env = { + **os.environ, + "HY3_DRY_RUN": "1", + "HY3_API_KEY": "EMPTY", + "MESSAGE_ASSISTANT_ALLOWED_ROOTS": str(root), + } + params = StdioServerParameters(command=command, args=[], env=env, cwd=str(root)) + + async with stdio_client(params) as (read_stream, write_stream): + async with ClientSession(read_stream, write_stream) as session: + init_result = await session.initialize() + listed = await session.list_tools() + tool_names = {tool.name for tool in listed.tools} + missing = EXPECTED_TOOLS.difference(tool_names) + if missing: + raise RuntimeError(f"Missing tools: {sorted(missing)}") + for tool in listed.tools: + if not tool.description: + raise RuntimeError(f"{tool.name} has no tool description") + properties = tool.inputSchema.get("properties", {}) + missing_parameter_descriptions = [ + name for name, schema in properties.items() if not schema.get("description") + ] + if missing_parameter_descriptions: + raise RuntimeError( + f"{tool.name} has parameters without descriptions: {missing_parameter_descriptions}" + ) + + loaded = await session.call_tool( + "load_messages", + {"source_path": str(root / "examples" / "sample_messages.json")}, + ) + if loaded.isError: + raise RuntimeError(loaded.content[0].text) + messages_json = loaded.content[0].text + + for tool_name, arguments in [ + ( + "triage_messages_with_hy3", + {"messages_json": messages_json, "user_context": "MCP protocol smoke test"}, + ), + ("extract_action_items_with_hy3", {"messages_json": messages_json}), + ("draft_replies_with_hy3", {"messages_json": messages_json}), + ("generate_inbox_digest_with_hy3", {"messages_json": messages_json}), + ]: + result = await session.call_tool(tool_name, arguments) + if result.isError or "[dry-run]" not in result.content[0].text: + raise RuntimeError(f"{tool_name} did not return a dry-run result") + + print(f"protocol={init_result.protocolVersion}") + print(f"tools={','.join(sorted(tool_names))}") + print("tool_descriptions=ok") + print("parameter_descriptions=ok") + print("load_messages=ok") + print("hy3_tools=ok") + + +def main() -> None: + asyncio.run(_smoke_test()) + + +if __name__ == "__main__": + try: + main() + except Exception as exc: # noqa: BLE001 + print(f"protocol smoke test failed: {exc}", file=sys.stderr) + raise diff --git a/mcp-message-assistant/examples/sample_email.eml b/mcp-message-assistant/examples/sample_email.eml new file mode 100644 index 00000000..acf40c7a --- /dev/null +++ b/mcp-message-assistant/examples/sample_email.eml @@ -0,0 +1,10 @@ +From: Mei Lin +To: Alex Chen +Date: Sat, 25 Jul 2026 09:05:00 +0800 +Message-ID: +Subject: Confirm Monday MCP demo +MIME-Version: 1.0 +Content-Type: text/plain; charset="utf-8" + +Can you confirm whether the MCP demo is ready for Monday at 10:00? +Please reply before noon so I can book the room. diff --git a/mcp-message-assistant/examples/sample_messages.json b/mcp-message-assistant/examples/sample_messages.json new file mode 100644 index 00000000..efbd77f8 --- /dev/null +++ b/mcp-message-assistant/examples/sample_messages.json @@ -0,0 +1,49 @@ +{ + "messages": [ + { + "id": "msg-001", + "channel": "email", + "sender": "security@example.com", + "recipients": ["alex@example.com"], + "subject": "Action required: confirm unusual sign-in", + "timestamp": "2026-07-25T08:15:00+08:00", + "body": "We detected a sign-in from a new device. Please review the activity before 18:00 today. If this was not you, reset your password through the company security portal." + }, + { + "id": "msg-002", + "channel": "chat", + "sender": "Mei", + "recipients": ["Project Alpha"], + "subject": "Demo time confirmation", + "timestamp": "2026-07-25T09:05:00+08:00", + "body": "Can you confirm whether the MCP demo is ready for Monday at 10:00? Please reply before noon so I can book the room." + }, + { + "id": "msg-003", + "channel": "email", + "sender": "billing@example.com", + "recipients": ["alex@example.com"], + "subject": "Invoice 2026-0718 needs purchase order number", + "timestamp": "2026-07-24T16:40:00+08:00", + "body": "We cannot process invoice 2026-0718 because the purchase order number is missing. Please send the number or let us know who should approve it." + }, + { + "id": "msg-004", + "channel": "email", + "sender": "weekly@example.com", + "recipients": ["alex@example.com"], + "subject": "Weekly product newsletter", + "timestamp": "2026-07-25T07:00:00+08:00", + "body": "This week: product updates, community events, and five articles selected by our editors. No action is required." + }, + { + "id": "msg-005", + "channel": "chat", + "sender": "Rui", + "recipients": ["Project Alpha"], + "subject": "Waiting for test data", + "timestamp": "2026-07-25T09:30:00+08:00", + "body": "The integration test is blocked until the sample message files are available. I can continue as soon as they are uploaded." + } + ] +} diff --git a/mcp-message-assistant/pyproject.toml b/mcp-message-assistant/pyproject.toml new file mode 100644 index 00000000..be3a7e56 --- /dev/null +++ b/mcp-message-assistant/pyproject.toml @@ -0,0 +1,21 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "hy3-message-assistant-mcp" +version = "0.1.0" +description = "MCP server for Hy3-powered email and message triage" +readme = "README.md" +license = { text = "Apache-2.0" } +requires-python = ">=3.10" +dependencies = [ + "mcp>=1.0.0", + "openai>=1.0.0", +] + +[project.scripts] +hy3-message-assistant-mcp = "hy3_message_assistant_mcp.server:main" + +[tool.hatch.build.targets.wheel] +packages = ["src/hy3_message_assistant_mcp"] diff --git a/mcp-message-assistant/scripts/install.sh b/mcp-message-assistant/scripts/install.sh new file mode 100755 index 00000000..7da5a306 --- /dev/null +++ b/mcp-message-assistant/scripts/install.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env sh +set -eu + +PYTHON_BIN="${PYTHON_BIN:-python3}" +PROJECT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) + +"$PYTHON_BIN" -m venv "$PROJECT_DIR/.venv" +"$PROJECT_DIR/.venv/bin/python" -m pip install --upgrade pip +"$PROJECT_DIR/.venv/bin/python" -m pip install "$PROJECT_DIR" + +echo "Installed: $PROJECT_DIR/.venv/bin/hy3-message-assistant-mcp" diff --git a/mcp-message-assistant/src/hy3_message_assistant_mcp/__init__.py b/mcp-message-assistant/src/hy3_message_assistant_mcp/__init__.py new file mode 100644 index 00000000..80644baf --- /dev/null +++ b/mcp-message-assistant/src/hy3_message_assistant_mcp/__init__.py @@ -0,0 +1,3 @@ +"""Hy3 email and message assistant MCP server.""" + +__version__ = "0.1.0" diff --git a/mcp-message-assistant/src/hy3_message_assistant_mcp/core.py b/mcp-message-assistant/src/hy3_message_assistant_mcp/core.py new file mode 100644 index 00000000..3c0443c8 --- /dev/null +++ b/mcp-message-assistant/src/hy3_message_assistant_mcp/core.py @@ -0,0 +1,221 @@ +from __future__ import annotations + +import json +import os +from typing import Any, Literal + + +Reasoning = Literal["no_think", "low", "high"] +DEFAULT_API_BASE = "http://127.0.0.1:8000/v1" +DEFAULT_MODEL = "hy3" +DEFAULT_MAX_PROMPT_CHARS = 120_000 + + +def normalize_reasoning(reasoning: str | None) -> Reasoning: + if reasoning in {"no_think", "low", "high"}: + return reasoning + return "no_think" + + +def _dry_run_enabled() -> bool: + return os.environ.get("HY3_DRY_RUN", "").lower() in {"1", "true", "yes", "on"} + + +def _truncate_prompt(prompt: str) -> tuple[str, bool]: + max_chars = int(os.environ.get("HY3_MAX_PROMPT_CHARS", str(DEFAULT_MAX_PROMPT_CHARS))) + if max_chars < 1_000: + raise ValueError("HY3_MAX_PROMPT_CHARS must be at least 1000") + if len(prompt) <= max_chars: + return prompt, False + marker = "\n\n[input truncated to fit HY3_MAX_PROMPT_CHARS]" + return prompt[: max_chars - len(marker)] + marker, True + + +def _hy3_client(): + from openai import OpenAI + + api_key = os.environ.get("HY3_API_KEY") + if not api_key: + raise RuntimeError("HY3_API_KEY is required. Use EMPTY for a local endpoint without authentication.") + + return OpenAI( + base_url=os.environ.get("HY3_API_BASE", DEFAULT_API_BASE), + api_key=api_key, + timeout=float(os.environ.get("HY3_TIMEOUT_SECONDS", "120")), + max_retries=int(os.environ.get("HY3_MAX_RETRIES", "2")), + ) + + +def call_hy3(prompt: str, reasoning: str | None = "low") -> str: + reasoning_mode = normalize_reasoning(reasoning) + prompt, was_truncated = _truncate_prompt(prompt) + if was_truncated: + prompt += "\nThe input was truncated. State that the result may be incomplete." + + if _dry_run_enabled(): + preview = prompt[:8_000] + suffix = "\n\n[dry-run preview truncated]" if len(prompt) > len(preview) else "" + return f"[dry-run] Hy3 call skipped. reasoning={reasoning_mode}\n\n{preview}{suffix}" + + response = _hy3_client().chat.completions.create( + model=os.environ.get("HY3_MODEL", DEFAULT_MODEL), + messages=[{"role": "user", "content": prompt}], + temperature=float(os.environ.get("HY3_TEMPERATURE", "0.3")), + top_p=float(os.environ.get("HY3_TOP_P", "1.0")), + extra_body={"chat_template_kwargs": {"reasoning_effort": reasoning_mode}}, + ) + return response.choices[0].message.content or "" + + +def _parse_messages_json(messages_json: str) -> tuple[list[dict[str, Any]], dict[str, Any]]: + try: + payload = json.loads(messages_json) + except json.JSONDecodeError as exc: + raise ValueError(f"messages_json is not valid JSON: {exc.msg}") from exc + + if isinstance(payload, list): + messages = payload + envelope: dict[str, Any] = {"messages": payload} + elif isinstance(payload, dict) and isinstance(payload.get("messages"), list): + envelope = payload + messages = payload["messages"] + else: + raise ValueError("messages_json must be a message list or an object containing a messages list") + + if not messages: + raise ValueError("messages_json contains no messages") + if not all(isinstance(message, dict) for message in messages): + raise ValueError("every message must be a JSON object") + return messages, envelope + + +def _parse_optional_json(value: str, field_name: str) -> Any: + if not value.strip(): + return {} + + candidates = [value.strip()] + lines = value.strip().splitlines() + if len(lines) >= 3 and lines[0].strip().startswith("```") and lines[-1].strip() == "```": + candidates.append("\n".join(lines[1:-1]).strip()) + + first_object = value.find("{") + last_object = value.rfind("}") + if first_object >= 0 and last_object > first_object: + candidates.append(value[first_object : last_object + 1]) + + first_array = value.find("[") + last_array = value.rfind("]") + if first_array >= 0 and last_array > first_array: + candidates.append(value[first_array : last_array + 1]) + + last_error: json.JSONDecodeError | None = None + for candidate in candidates: + try: + return json.loads(candidate) + except json.JSONDecodeError as exc: + last_error = exc + continue + + message = last_error.msg if last_error else "unknown JSON error" + raise ValueError(f"{field_name} is not valid JSON: {message}") from last_error + + +def _json_block(value: Any) -> str: + return json.dumps(value, ensure_ascii=False, indent=2) + + +def triage_messages_with_hy3( + messages_json: str, + user_context: str = "", + categories: str = "action_required, waiting, notification, newsletter, suspicious", + reasoning: str | None = "high", +) -> str: + messages, _ = _parse_messages_json(messages_json) + prompt = ( + "You are an inbox triage assistant. Analyze the messages using only the supplied content.\n\n" + f"User context:\n{user_context or 'No additional user context.'}\n\n" + f"Allowed categories:\n{categories}\n\n" + f"Messages:\n{_json_block(messages)}\n\n" + "Return valid JSON with this exact top-level shape:\n" + '{"messages":[{"id":"...","category":"...","priority":"urgent|high|normal|low",' + '"reply_needed":true,"reason":"...","deadline":"... or null","summary":"..."}],' + '"urgent_count":0,"reply_count":0}\n' + "Do not invent deadlines, commitments, senders, or facts. Explain uncertainty in reason." + ) + return call_hy3(prompt, reasoning=reasoning) + + +def extract_action_items_with_hy3( + messages_json: str, + triage_json: str = "", + default_owner: str = "me", + reasoning: str | None = "high", +) -> str: + messages, _ = _parse_messages_json(messages_json) + triage = _parse_optional_json(triage_json, "triage_json") + prompt = ( + "Extract concrete action items from these messages. Use only explicit evidence.\n\n" + f"Default owner when an action is clearly assigned to the inbox owner:\n{default_owner}\n\n" + f"Messages:\n{_json_block(messages)}\n\n" + f"Optional triage results:\n{_json_block(triage)}\n\n" + "Return valid JSON with this exact top-level shape:\n" + '{"actions":[{"source_message_id":"...","task":"...","owner":"...",' + '"deadline":"... or null","status":"open|waiting","confidence":"high|medium|low"}],' + '"unresolved_questions":["..."]}\n' + "Do not turn advertisements or general information into tasks. Never invent a deadline." + ) + return call_hy3(prompt, reasoning=reasoning) + + +def draft_replies_with_hy3( + messages_json: str, + triage_json: str = "", + tone: str = "professional and concise", + language: str = "match each message", + reply_constraints: str = "", + reasoning: str | None = "low", +) -> str: + messages, _ = _parse_messages_json(messages_json) + triage = _parse_optional_json(triage_json, "triage_json") + prompt = ( + "Draft replies only for messages that reasonably need a response.\n\n" + f"Tone:\n{tone}\n\n" + f"Language:\n{language}\n\n" + f"Additional constraints:\n{reply_constraints or 'No additional constraints.'}\n\n" + f"Messages:\n{_json_block(messages)}\n\n" + f"Optional triage results:\n{_json_block(triage)}\n\n" + "Return valid JSON with this exact top-level shape:\n" + '{"drafts":[{"source_message_id":"...","subject":"...","body":"...",' + '"needs_user_confirmation":["..."]}],"skipped":[{"source_message_id":"...","reason":"..."}]}\n' + "Never claim that a message was sent. Do not invent dates, prices, approvals, or commitments. " + "Put uncertain details in needs_user_confirmation." + ) + return call_hy3(prompt, reasoning=reasoning) + + +def generate_inbox_digest_with_hy3( + messages_json: str, + triage_json: str = "", + action_items_json: str = "", + audience: str = "inbox owner", + reasoning: str | None = "low", +) -> str: + messages, envelope = _parse_messages_json(messages_json) + triage = _parse_optional_json(triage_json, "triage_json") + action_items = _parse_optional_json(action_items_json, "action_items_json") + prompt = ( + f"Create a concise Markdown inbox digest for the {audience}.\n\n" + f"Source metadata:\n{_json_block({key: value for key, value in envelope.items() if key != 'messages'})}\n\n" + f"Messages:\n{_json_block(messages)}\n\n" + f"Optional triage results:\n{_json_block(triage)}\n\n" + f"Optional action items:\n{_json_block(action_items)}\n\n" + "Use these sections:\n" + "# Inbox Digest\n" + "## Handle First\n" + "## Replies Needed\n" + "## Action Items\n" + "## Waiting and Follow-up\n" + "## Informational\n\n" + "Omit empty sections. Preserve uncertainty and do not invent facts." + ) + return call_hy3(prompt, reasoning=reasoning) diff --git a/mcp-message-assistant/src/hy3_message_assistant_mcp/loaders.py b/mcp-message-assistant/src/hy3_message_assistant_mcp/loaders.py new file mode 100644 index 00000000..92c4df4f --- /dev/null +++ b/mcp-message-assistant/src/hy3_message_assistant_mcp/loaders.py @@ -0,0 +1,210 @@ +from __future__ import annotations + +import json +import os +from email import policy +from email.message import Message +from email.parser import BytesParser +from html.parser import HTMLParser +from pathlib import Path +from typing import Any + + +SUPPORTED_SUFFIXES = {".eml", ".json", ".jsonl", ".md", ".txt"} +DEFAULT_MAX_MESSAGES = 50 +DEFAULT_MAX_BODY_CHARS = 12_000 + + +class _HTMLTextExtractor(HTMLParser): + def __init__(self) -> None: + super().__init__() + self._parts: list[str] = [] + + def handle_data(self, data: str) -> None: + if data.strip(): + self._parts.append(data.strip()) + + def text(self) -> str: + return "\n".join(self._parts) + + +def _allowed_roots() -> list[Path]: + configured = os.environ.get("MESSAGE_ASSISTANT_ALLOWED_ROOTS", "") + if configured.strip(): + roots = [Path(item).expanduser().resolve() for item in configured.split(os.pathsep) if item.strip()] + else: + roots = [Path.cwd().resolve()] + return roots + + +def resolve_source_path(source_path: str) -> Path: + source = Path(source_path).expanduser().resolve() + if not source.exists(): + raise FileNotFoundError(f"source_path does not exist: {source}") + + if not any(source == root or root in source.parents for root in _allowed_roots()): + allowed = ", ".join(str(root) for root in _allowed_roots()) + raise PermissionError(f"source_path is outside MESSAGE_ASSISTANT_ALLOWED_ROOTS: {allowed}") + return source + + +def _html_to_text(value: str) -> str: + parser = _HTMLTextExtractor() + parser.feed(value) + return parser.text() + + +def _message_body(message: Message) -> str: + body = message.get_body(preferencelist=("plain", "html")) + if body is not None: + content = body.get_content() + return _html_to_text(content) if body.get_content_type() == "text/html" else str(content) + + if message.is_multipart(): + parts: list[str] = [] + for part in message.walk(): + if part.get_content_maintype() == "text" and part.get_content_disposition() != "attachment": + content = part.get_content() + parts.append(_html_to_text(content) if part.get_content_type() == "text/html" else str(content)) + return "\n".join(parts) + return str(message.get_content()) + + +def _as_recipients(value: Any) -> list[str]: + if value is None: + return [] + if isinstance(value, list): + return [str(item).strip() for item in value if str(item).strip()] + return [item.strip() for item in str(value).split(",") if item.strip()] + + +def _normalize_message(raw: dict[str, Any], fallback_id: str, source: str) -> dict[str, Any]: + body = raw.get("body", raw.get("content", raw.get("text", ""))) + return { + "id": str(raw.get("id") or fallback_id), + "channel": str(raw.get("channel") or "message"), + "sender": str(raw.get("sender") or raw.get("from") or ""), + "recipients": _as_recipients(raw.get("recipients", raw.get("to"))), + "subject": str(raw.get("subject") or raw.get("title") or ""), + "timestamp": str(raw.get("timestamp") or raw.get("date") or ""), + "body": str(body or ""), + "source": source, + } + + +def _load_eml(path: Path) -> list[dict[str, Any]]: + with path.open("rb") as file: + message = BytesParser(policy=policy.default).parse(file) + return [ + { + "id": message.get("Message-ID") or path.stem, + "channel": "email", + "sender": str(message.get("From") or ""), + "recipients": _as_recipients(message.get("To")), + "subject": str(message.get("Subject") or ""), + "timestamp": str(message.get("Date") or ""), + "body": _message_body(message), + "source": str(path), + } + ] + + +def _load_json(path: Path) -> list[dict[str, Any]]: + payload = json.loads(path.read_text(encoding="utf-8")) + if isinstance(payload, dict): + payload = payload.get("messages", [payload]) + if not isinstance(payload, list): + raise ValueError(f"JSON source must be an object, a list, or contain a messages list: {path}") + + messages: list[dict[str, Any]] = [] + for index, raw in enumerate(payload, start=1): + if not isinstance(raw, dict): + raise ValueError(f"Message {index} in {path} is not an object") + messages.append(_normalize_message(raw, f"{path.stem}-{index}", str(path))) + return messages + + +def _load_jsonl(path: Path) -> list[dict[str, Any]]: + messages: list[dict[str, Any]] = [] + for line_number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1): + if not line.strip(): + continue + raw = json.loads(line) + if not isinstance(raw, dict): + raise ValueError(f"Line {line_number} in {path} is not a JSON object") + messages.append(_normalize_message(raw, f"{path.stem}-{line_number}", str(path))) + return messages + + +def _load_text(path: Path) -> list[dict[str, Any]]: + return [ + { + "id": path.stem, + "channel": "message", + "sender": "", + "recipients": [], + "subject": path.stem.replace("_", " ").replace("-", " "), + "timestamp": "", + "body": path.read_text(encoding="utf-8"), + "source": str(path), + } + ] + + +def _load_file(path: Path) -> list[dict[str, Any]]: + suffix = path.suffix.lower() + if suffix == ".eml": + return _load_eml(path) + if suffix == ".json": + return _load_json(path) + if suffix == ".jsonl": + return _load_jsonl(path) + if suffix in {".md", ".txt"}: + return _load_text(path) + raise ValueError(f"Unsupported message file type: {path.suffix or ''}") + + +def load_messages( + source_path: str, + recursive: bool = False, + max_messages: int = DEFAULT_MAX_MESSAGES, + max_body_chars: int = DEFAULT_MAX_BODY_CHARS, +) -> str: + """Load email and message files into a normalized JSON envelope.""" + if not 1 <= max_messages <= 500: + raise ValueError("max_messages must be between 1 and 500") + if not 200 <= max_body_chars <= 100_000: + raise ValueError("max_body_chars must be between 200 and 100000") + + source = resolve_source_path(source_path) + if source.is_file(): + files = [source] + else: + iterator = source.rglob("*") if recursive else source.glob("*") + files = sorted(path for path in iterator if path.is_file() and path.suffix.lower() in SUPPORTED_SUFFIXES) + + messages: list[dict[str, Any]] = [] + truncated_bodies = 0 + for path in files: + for message in _load_file(path): + if len(messages) >= max_messages: + break + body = message["body"] + if len(body) > max_body_chars: + message["body"] = body[:max_body_chars] + "\n\n[body truncated]" + message["body_truncated"] = True + truncated_bodies += 1 + messages.append(message) + if len(messages) >= max_messages: + break + + return json.dumps( + { + "source_path": str(source), + "message_count": len(messages), + "truncated_bodies": truncated_bodies, + "messages": messages, + }, + ensure_ascii=False, + indent=2, + ) diff --git a/mcp-message-assistant/src/hy3_message_assistant_mcp/server.py b/mcp-message-assistant/src/hy3_message_assistant_mcp/server.py new file mode 100644 index 00000000..a31e81cf --- /dev/null +++ b/mcp-message-assistant/src/hy3_message_assistant_mcp/server.py @@ -0,0 +1,174 @@ +from __future__ import annotations + +from typing import Annotated + +from mcp.server.fastmcp import FastMCP +from pydantic import Field + +from hy3_message_assistant_mcp.core import ( + Reasoning, + draft_replies_with_hy3 as draft_replies_core, + extract_action_items_with_hy3 as extract_action_items_core, + generate_inbox_digest_with_hy3 as generate_inbox_digest_core, + triage_messages_with_hy3 as triage_messages_core, +) +from hy3_message_assistant_mcp.loaders import load_messages as load_messages_core + + +mcp = FastMCP("hy3-message-assistant") +server = mcp + + +@mcp.tool( + description=( + "Read local .eml, .json, .jsonl, .txt, or .md files and normalize them into a JSON message list. " + "Use this first before the Hy3 analysis tools." + ) +) +def load_messages( + source_path: Annotated[str, Field(description="File or directory containing local email or message data.")], + recursive: Annotated[bool, Field(description="Whether to scan subdirectories.")] = False, + max_messages: Annotated[int, Field(description="Maximum messages to load.", ge=1, le=500)] = 50, + max_body_chars: Annotated[ + int, + Field(description="Maximum body characters retained per message.", ge=200, le=100_000), + ] = 12_000, +) -> str: + return load_messages_core( + source_path=source_path, + recursive=recursive, + max_messages=max_messages, + max_body_chars=max_body_chars, + ) + + +@mcp.tool( + description=( + "Ask Hy3 to classify messages by category and priority, decide whether a reply is needed, " + "and identify explicit deadlines." + ) +) +def triage_messages_with_hy3( + messages_json: Annotated[str, Field(description="JSON returned by load_messages, or a JSON message list.")], + user_context: Annotated[ + str, + Field(description="Optional role, project, or preference context used to judge priority."), + ] = "", + categories: Annotated[ + str, + Field(description="Comma-separated categories Hy3 may assign."), + ] = "action_required, waiting, notification, newsletter, suspicious", + reasoning: Annotated[ + Reasoning, + Field(description="Hy3 reasoning effort: no_think, low, or high."), + ] = "high", +) -> str: + return triage_messages_core( + messages_json=messages_json, + user_context=user_context, + categories=categories, + reasoning=reasoning, + ) + + +@mcp.tool( + description=( + "Ask Hy3 to extract concrete tasks, owners, deadlines, waiting states, and unresolved questions " + "from loaded messages." + ) +) +def extract_action_items_with_hy3( + messages_json: Annotated[str, Field(description="JSON returned by load_messages, or a JSON message list.")], + triage_json: Annotated[ + str, + Field(description="Optional JSON returned by triage_messages_with_hy3."), + ] = "", + default_owner: Annotated[ + str, + Field(description="Owner label used when the inbox owner is explicitly responsible."), + ] = "me", + reasoning: Annotated[ + Reasoning, + Field(description="Hy3 reasoning effort: no_think, low, or high."), + ] = "high", +) -> str: + return extract_action_items_core( + messages_json=messages_json, + triage_json=triage_json, + default_owner=default_owner, + reasoning=reasoning, + ) + + +@mcp.tool( + description=( + "Ask Hy3 to create safe reply drafts for messages that need responses. " + "The tool never sends messages and highlights details requiring user confirmation." + ) +) +def draft_replies_with_hy3( + messages_json: Annotated[str, Field(description="JSON returned by load_messages, or a JSON message list.")], + triage_json: Annotated[ + str, + Field(description="Optional JSON returned by triage_messages_with_hy3."), + ] = "", + tone: Annotated[str, Field(description="Desired reply tone.")] = "professional and concise", + language: Annotated[ + str, + Field(description="Reply language, such as Chinese, English, or match each message."), + ] = "match each message", + reply_constraints: Annotated[ + str, + Field(description="Facts, promises, wording, or topics that replies must include or avoid."), + ] = "", + reasoning: Annotated[ + Reasoning, + Field(description="Hy3 reasoning effort: no_think, low, or high."), + ] = "low", +) -> str: + return draft_replies_core( + messages_json=messages_json, + triage_json=triage_json, + tone=tone, + language=language, + reply_constraints=reply_constraints, + reasoning=reasoning, + ) + + +@mcp.tool( + description=( + "Ask Hy3 to turn messages, triage results, and action items into a concise Markdown inbox digest." + ) +) +def generate_inbox_digest_with_hy3( + messages_json: Annotated[str, Field(description="JSON returned by load_messages, or a JSON message list.")], + triage_json: Annotated[ + str, + Field(description="Optional JSON returned by triage_messages_with_hy3."), + ] = "", + action_items_json: Annotated[ + str, + Field(description="Optional JSON returned by extract_action_items_with_hy3."), + ] = "", + audience: Annotated[str, Field(description="Intended reader of the digest.")] = "inbox owner", + reasoning: Annotated[ + Reasoning, + Field(description="Hy3 reasoning effort: no_think, low, or high."), + ] = "low", +) -> str: + return generate_inbox_digest_core( + messages_json=messages_json, + triage_json=triage_json, + action_items_json=action_items_json, + audience=audience, + reasoning=reasoning, + ) + + +def main() -> None: + mcp.run() + + +if __name__ == "__main__": + main() diff --git a/mcp-message-assistant/tests/test_core.py b/mcp-message-assistant/tests/test_core.py new file mode 100644 index 00000000..ad216803 --- /dev/null +++ b/mcp-message-assistant/tests/test_core.py @@ -0,0 +1,88 @@ +import json +import os +import sys +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "src")) + +from hy3_message_assistant_mcp.core import ( # noqa: E402 + call_hy3, + draft_replies_with_hy3, + extract_action_items_with_hy3, + generate_inbox_digest_with_hy3, + normalize_reasoning, + triage_messages_with_hy3, +) + + +MESSAGES = json.dumps( + { + "message_count": 1, + "messages": [ + { + "id": "m1", + "sender": "mei@example.com", + "subject": "Confirm demo", + "body": "Please confirm by noon.", + } + ], + } +) + + +class CoreTests(unittest.TestCase): + def setUp(self) -> None: + self.old_dry_run = os.environ.get("HY3_DRY_RUN") + os.environ["HY3_DRY_RUN"] = "1" + + def tearDown(self) -> None: + if self.old_dry_run is None: + os.environ.pop("HY3_DRY_RUN", None) + else: + os.environ["HY3_DRY_RUN"] = self.old_dry_run + + def test_normalize_reasoning(self) -> None: + self.assertEqual(normalize_reasoning("high"), "high") + self.assertEqual(normalize_reasoning("unexpected"), "no_think") + + def test_all_hy3_workflows_build_dry_run_prompts(self) -> None: + outputs = [ + triage_messages_with_hy3(MESSAGES, user_context="project lead"), + extract_action_items_with_hy3(MESSAGES), + draft_replies_with_hy3(MESSAGES, tone="friendly"), + generate_inbox_digest_with_hy3(MESSAGES), + ] + + self.assertTrue(all("[dry-run]" in output for output in outputs)) + self.assertIn("project lead", outputs[0]) + self.assertIn("friendly", outputs[2]) + self.assertIn("# Inbox Digest", outputs[3]) + + def test_rejects_invalid_messages_json(self) -> None: + with self.assertRaisesRegex(ValueError, "not valid JSON"): + triage_messages_with_hy3("{not-json") + + def test_accepts_fenced_triage_json(self) -> None: + output = draft_replies_with_hy3( + MESSAGES, + triage_json='```json\n{"messages":[{"id":"m1","reply_needed":true}]}\n```', + ) + + self.assertIn('"reply_needed": true', output) + + def test_live_call_requires_api_key(self) -> None: + os.environ.pop("HY3_DRY_RUN", None) + old_api_key = os.environ.pop("HY3_API_KEY", None) + try: + with self.assertRaisesRegex(RuntimeError, "HY3_API_KEY is required"): + call_hy3("hello") + finally: + if old_api_key is not None: + os.environ["HY3_API_KEY"] = old_api_key + + +if __name__ == "__main__": + unittest.main() diff --git a/mcp-message-assistant/tests/test_loaders.py b/mcp-message-assistant/tests/test_loaders.py new file mode 100644 index 00000000..107cf3a3 --- /dev/null +++ b/mcp-message-assistant/tests/test_loaders.py @@ -0,0 +1,66 @@ +import json +import os +import sys +import tempfile +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "src")) + +from hy3_message_assistant_mcp.loaders import load_messages # noqa: E402 + + +class LoaderTests(unittest.TestCase): + def setUp(self) -> None: + self.old_allowed_roots = os.environ.get("MESSAGE_ASSISTANT_ALLOWED_ROOTS") + + def tearDown(self) -> None: + if self.old_allowed_roots is None: + os.environ.pop("MESSAGE_ASSISTANT_ALLOWED_ROOTS", None) + else: + os.environ["MESSAGE_ASSISTANT_ALLOWED_ROOTS"] = self.old_allowed_roots + + def test_load_json_messages(self) -> None: + os.environ["MESSAGE_ASSISTANT_ALLOWED_ROOTS"] = str(ROOT) + result = json.loads(load_messages(str(ROOT / "examples" / "sample_messages.json"))) + + self.assertEqual(result["message_count"], 5) + self.assertEqual(result["messages"][0]["id"], "msg-001") + self.assertEqual(result["messages"][0]["channel"], "email") + + def test_load_eml_message(self) -> None: + os.environ["MESSAGE_ASSISTANT_ALLOWED_ROOTS"] = str(ROOT) + result = json.loads(load_messages(str(ROOT / "examples" / "sample_email.eml"))) + + self.assertEqual(result["message_count"], 1) + self.assertEqual(result["messages"][0]["channel"], "email") + self.assertIn("Monday MCP demo", result["messages"][0]["subject"]) + self.assertIn("reply before noon", result["messages"][0]["body"]) + + def test_directory_scan_honors_limit_and_truncates_body(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + os.environ["MESSAGE_ASSISTANT_ALLOWED_ROOTS"] = str(root) + (root / "a.txt").write_text("a" * 300, encoding="utf-8") + (root / "b.md").write_text("b" * 300, encoding="utf-8") + + result = json.loads(load_messages(str(root), max_messages=1, max_body_chars=200)) + + self.assertEqual(result["message_count"], 1) + self.assertEqual(result["truncated_bodies"], 1) + self.assertTrue(result["messages"][0]["body_truncated"]) + + def test_rejects_path_outside_allowed_roots(self) -> None: + with tempfile.TemporaryDirectory() as allowed, tempfile.TemporaryDirectory() as outside: + os.environ["MESSAGE_ASSISTANT_ALLOWED_ROOTS"] = allowed + path = Path(outside) / "message.txt" + path.write_text("secret", encoding="utf-8") + + with self.assertRaises(PermissionError): + load_messages(str(path)) + + +if __name__ == "__main__": + unittest.main()