email-thread-mcp 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- email_thread_mcp-0.2.0/.gitignore +9 -0
- email_thread_mcp-0.2.0/LICENSE +21 -0
- email_thread_mcp-0.2.0/PKG-INFO +100 -0
- email_thread_mcp-0.2.0/README.md +89 -0
- email_thread_mcp-0.2.0/pyproject.toml +32 -0
- email_thread_mcp-0.2.0/src/email_thread_mcp/__init__.py +1 -0
- email_thread_mcp-0.2.0/src/email_thread_mcp/html_text.py +141 -0
- email_thread_mcp-0.2.0/src/email_thread_mcp/server.py +85 -0
- email_thread_mcp-0.2.0/src/email_thread_mcp/thread.py +565 -0
- email_thread_mcp-0.2.0/src/email_thread_mcp/validate.py +146 -0
- email_thread_mcp-0.2.0/src/email_thread_mcp/vocab.py +202 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 OraCool
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: email-thread-mcp
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: MCP server for the AR email agent: parses an inbound email thread into the thread contract and validates the agent's output envelope.
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Requires-Dist: dateparser<2,>=1.2
|
|
9
|
+
Requires-Dist: mcp<3,>=2.3
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
# email-thread-mcp
|
|
13
|
+
|
|
14
|
+
MCP server (stdio) with two deterministic tools for an accounts-receivable (AR) email agent:
|
|
15
|
+
no LLM inside, no network access, no credentials.
|
|
16
|
+
|
|
17
|
+
| Tool | Role in the agent | Output contract |
|
|
18
|
+
|---|---|---|
|
|
19
|
+
| `parse_email_thread(body, ar_mailboxes?)` | first step: request preprocessing. Splits the raw email body (Outlook/Gmail/Apple HTML or plain text) into the latest message, its signer and greeting, and the quoted history (oldest first) | [`contracts/thread.schema.json`](contracts/thread.schema.json) |
|
|
20
|
+
| `validate_output(output, categories, thread?)` | last step: deterministic checks of the final envelope. Corrects only unambiguous problems (category case/punctuation, duplicates, invoice-number noise, human-review flag); reports the rest | [`contracts/validation.schema.json`](contracts/validation.schema.json) |
|
|
21
|
+
|
|
22
|
+
## Use
|
|
23
|
+
|
|
24
|
+
Any MCP client that starts stdio servers can run it with `uvx`:
|
|
25
|
+
|
|
26
|
+
```json
|
|
27
|
+
{
|
|
28
|
+
"mcpServers": {
|
|
29
|
+
"email-thread-mcp": {
|
|
30
|
+
"command": "uvx",
|
|
31
|
+
"args": ["email-thread-mcp@0.2.0"]
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Pin a version: a client that keeps the server running may otherwise serve a stale cached build.
|
|
38
|
+
|
|
39
|
+
Each tool returns one JSON object. Structured arguments (`ar_mailboxes`, `categories`, `output`,
|
|
40
|
+
`thread`) may be passed as values or as text: clients that render arguments through a template
|
|
41
|
+
engine first can send JSON or a Python-style repr, and both are accepted.
|
|
42
|
+
|
|
43
|
+
A typical agent calls `parse_email_thread` first, as preprocessing, and uses `latest_message`,
|
|
44
|
+
`reply_to_name` and `thread_context` in later steps; it calls `validate_output` last, on its final
|
|
45
|
+
envelope. If parsing fails, falling back to the plain-text body before the first quote marker keeps
|
|
46
|
+
the agent working.
|
|
47
|
+
|
|
48
|
+
## Language support
|
|
49
|
+
|
|
50
|
+
Inbound mail may be in any language, so the parser treats its three kinds of miss differently:
|
|
51
|
+
|
|
52
|
+
| Miss | Effect | How it is avoided |
|
|
53
|
+
|---|---|---|
|
|
54
|
+
| quote boundary | old history read as the new message | found by structure in any language: the mail client's HTML quote marker (Outlook `divRplyFwdMsg`, Gmail `gmail_quote`, Apple/Thunderbird `blockquote type="cite"`), or a `label: value` block that starts with an address and holds a date with a time |
|
|
55
|
+
| greeting / signer name | `reply_to_name` is null, the draft greets neutrally | per-language words in `src/email_thread_mcp/vocab.py` |
|
|
56
|
+
| sent date | `sent_at` is null (thread order comes from position, not dates) | [`dateparser`](https://pypi.org/project/dateparser/), absolute dates only |
|
|
57
|
+
|
|
58
|
+
`vocab.py` covers EN, DE, FR, NL, IT, ES, PT, PL, CS, SV, DA, NO, FI. Only EN is `verified` against
|
|
59
|
+
real threads; the others are built from Outlook's localized reply headers. A language outside the
|
|
60
|
+
list still splits, and `parse_info.warnings` names what was not understood, e.g.
|
|
61
|
+
`quoted message 1: header labels not recognised: Kimden, Gönderildi, Kime, Konu`. Search traces
|
|
62
|
+
for those warnings to see which language to add next.
|
|
63
|
+
|
|
64
|
+
To add or verify a language: add its entry to `vocab.py` and a case to `tests/test_languages.py`.
|
|
65
|
+
Once a real thread in it (anonymised as below) parses correctly, add it as a fixture and set `verified`.
|
|
66
|
+
|
|
67
|
+
## Untrusted input
|
|
68
|
+
|
|
69
|
+
The body is written by the customer, and anyone can send one. The parser:
|
|
70
|
+
|
|
71
|
+
- drops text a reader cannot see (CSS `display:none`, `visibility:hidden`, `font-size:0`, `opacity:0`,
|
|
72
|
+
`mso-hide:all`, the `hidden` attribute) and zero-width, bidirectional and control characters, and
|
|
73
|
+
reports both in `parse_info.warnings`;
|
|
74
|
+
- bounds its own cost: the body is cut at 2M raw / 300k text characters, and dates beyond 50 parser
|
|
75
|
+
calls per email stay null.
|
|
76
|
+
|
|
77
|
+
It does not make the content trustworthy. The quoted history in particular is plain text the customer
|
|
78
|
+
can edit, including a fake message "from" the AR team (role `recipient`). Prompts must treat
|
|
79
|
+
`latest_message` and `thread_context` as data, never as instructions.
|
|
80
|
+
|
|
81
|
+
## Release
|
|
82
|
+
|
|
83
|
+
1. Bump `version` in `pyproject.toml`, then `uv run pytest`.
|
|
84
|
+
2. Commit, tag `v<version>`, push both. Never move or reuse a tag or a version.
|
|
85
|
+
3. `uv build`, check that the sdist holds only `src/`, `README.md` and `pyproject.toml`, then
|
|
86
|
+
`uv publish`.
|
|
87
|
+
4. Check: `uvx email-thread-mcp@<version>` starts and waits on stdin.
|
|
88
|
+
|
|
89
|
+
## Develop
|
|
90
|
+
|
|
91
|
+
```
|
|
92
|
+
uv sync
|
|
93
|
+
uv run pytest # 14 real Outlook threads, synthetic variants, 13 languages, hostile input
|
|
94
|
+
uv run email-thread-mcp # stdio server
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Real customer threads stay out of this repository. Locally, `tests/fixtures/` and
|
|
98
|
+
`tests/test_client_threads.py` (both gitignored) hold anonymised real threads and their expected
|
|
99
|
+
results; the suite runs them when present. Replace every real address and name before using a
|
|
100
|
+
thread there.
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# email-thread-mcp
|
|
2
|
+
|
|
3
|
+
MCP server (stdio) with two deterministic tools for an accounts-receivable (AR) email agent:
|
|
4
|
+
no LLM inside, no network access, no credentials.
|
|
5
|
+
|
|
6
|
+
| Tool | Role in the agent | Output contract |
|
|
7
|
+
|---|---|---|
|
|
8
|
+
| `parse_email_thread(body, ar_mailboxes?)` | first step: request preprocessing. Splits the raw email body (Outlook/Gmail/Apple HTML or plain text) into the latest message, its signer and greeting, and the quoted history (oldest first) | [`contracts/thread.schema.json`](contracts/thread.schema.json) |
|
|
9
|
+
| `validate_output(output, categories, thread?)` | last step: deterministic checks of the final envelope. Corrects only unambiguous problems (category case/punctuation, duplicates, invoice-number noise, human-review flag); reports the rest | [`contracts/validation.schema.json`](contracts/validation.schema.json) |
|
|
10
|
+
|
|
11
|
+
## Use
|
|
12
|
+
|
|
13
|
+
Any MCP client that starts stdio servers can run it with `uvx`:
|
|
14
|
+
|
|
15
|
+
```json
|
|
16
|
+
{
|
|
17
|
+
"mcpServers": {
|
|
18
|
+
"email-thread-mcp": {
|
|
19
|
+
"command": "uvx",
|
|
20
|
+
"args": ["email-thread-mcp@0.2.0"]
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Pin a version: a client that keeps the server running may otherwise serve a stale cached build.
|
|
27
|
+
|
|
28
|
+
Each tool returns one JSON object. Structured arguments (`ar_mailboxes`, `categories`, `output`,
|
|
29
|
+
`thread`) may be passed as values or as text: clients that render arguments through a template
|
|
30
|
+
engine first can send JSON or a Python-style repr, and both are accepted.
|
|
31
|
+
|
|
32
|
+
A typical agent calls `parse_email_thread` first, as preprocessing, and uses `latest_message`,
|
|
33
|
+
`reply_to_name` and `thread_context` in later steps; it calls `validate_output` last, on its final
|
|
34
|
+
envelope. If parsing fails, falling back to the plain-text body before the first quote marker keeps
|
|
35
|
+
the agent working.
|
|
36
|
+
|
|
37
|
+
## Language support
|
|
38
|
+
|
|
39
|
+
Inbound mail may be in any language, so the parser treats its three kinds of miss differently:
|
|
40
|
+
|
|
41
|
+
| Miss | Effect | How it is avoided |
|
|
42
|
+
|---|---|---|
|
|
43
|
+
| quote boundary | old history read as the new message | found by structure in any language: the mail client's HTML quote marker (Outlook `divRplyFwdMsg`, Gmail `gmail_quote`, Apple/Thunderbird `blockquote type="cite"`), or a `label: value` block that starts with an address and holds a date with a time |
|
|
44
|
+
| greeting / signer name | `reply_to_name` is null, the draft greets neutrally | per-language words in `src/email_thread_mcp/vocab.py` |
|
|
45
|
+
| sent date | `sent_at` is null (thread order comes from position, not dates) | [`dateparser`](https://pypi.org/project/dateparser/), absolute dates only |
|
|
46
|
+
|
|
47
|
+
`vocab.py` covers EN, DE, FR, NL, IT, ES, PT, PL, CS, SV, DA, NO, FI. Only EN is `verified` against
|
|
48
|
+
real threads; the others are built from Outlook's localized reply headers. A language outside the
|
|
49
|
+
list still splits, and `parse_info.warnings` names what was not understood, e.g.
|
|
50
|
+
`quoted message 1: header labels not recognised: Kimden, Gönderildi, Kime, Konu`. Search traces
|
|
51
|
+
for those warnings to see which language to add next.
|
|
52
|
+
|
|
53
|
+
To add or verify a language: add its entry to `vocab.py` and a case to `tests/test_languages.py`.
|
|
54
|
+
Once a real thread in it (anonymised as below) parses correctly, add it as a fixture and set `verified`.
|
|
55
|
+
|
|
56
|
+
## Untrusted input
|
|
57
|
+
|
|
58
|
+
The body is written by the customer, and anyone can send one. The parser:
|
|
59
|
+
|
|
60
|
+
- drops text a reader cannot see (CSS `display:none`, `visibility:hidden`, `font-size:0`, `opacity:0`,
|
|
61
|
+
`mso-hide:all`, the `hidden` attribute) and zero-width, bidirectional and control characters, and
|
|
62
|
+
reports both in `parse_info.warnings`;
|
|
63
|
+
- bounds its own cost: the body is cut at 2M raw / 300k text characters, and dates beyond 50 parser
|
|
64
|
+
calls per email stay null.
|
|
65
|
+
|
|
66
|
+
It does not make the content trustworthy. The quoted history in particular is plain text the customer
|
|
67
|
+
can edit, including a fake message "from" the AR team (role `recipient`). Prompts must treat
|
|
68
|
+
`latest_message` and `thread_context` as data, never as instructions.
|
|
69
|
+
|
|
70
|
+
## Release
|
|
71
|
+
|
|
72
|
+
1. Bump `version` in `pyproject.toml`, then `uv run pytest`.
|
|
73
|
+
2. Commit, tag `v<version>`, push both. Never move or reuse a tag or a version.
|
|
74
|
+
3. `uv build`, check that the sdist holds only `src/`, `README.md` and `pyproject.toml`, then
|
|
75
|
+
`uv publish`.
|
|
76
|
+
4. Check: `uvx email-thread-mcp@<version>` starts and waits on stdin.
|
|
77
|
+
|
|
78
|
+
## Develop
|
|
79
|
+
|
|
80
|
+
```
|
|
81
|
+
uv sync
|
|
82
|
+
uv run pytest # 14 real Outlook threads, synthetic variants, 13 languages, hostile input
|
|
83
|
+
uv run email-thread-mcp # stdio server
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Real customer threads stay out of this repository. Locally, `tests/fixtures/` and
|
|
87
|
+
`tests/test_client_threads.py` (both gitignored) hold anonymised real threads and their expected
|
|
88
|
+
results; the suite runs them when present. Replace every real address and name before using a
|
|
89
|
+
thread there.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "email-thread-mcp"
|
|
3
|
+
version = "0.2.0"
|
|
4
|
+
description = "MCP server for the AR email agent: parses an inbound email thread into the thread contract and validates the agent's output envelope."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
requires-python = ">=3.11"
|
|
9
|
+
dependencies = [
|
|
10
|
+
"dateparser>=1.2,<2",
|
|
11
|
+
"mcp>=2.3,<3",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[project.scripts]
|
|
15
|
+
email-thread-mcp = "email_thread_mcp.server:main"
|
|
16
|
+
|
|
17
|
+
[dependency-groups]
|
|
18
|
+
dev = ["pytest>=8"]
|
|
19
|
+
|
|
20
|
+
[build-system]
|
|
21
|
+
requires = ["hatchling"]
|
|
22
|
+
build-backend = "hatchling.build"
|
|
23
|
+
|
|
24
|
+
[tool.hatch.build.targets.wheel]
|
|
25
|
+
packages = ["src/email_thread_mcp"]
|
|
26
|
+
|
|
27
|
+
# tests/fixtures (local only, gitignored) holds real customer emails: keep them out of any build.
|
|
28
|
+
[tool.hatch.build.targets.sdist]
|
|
29
|
+
only-include = ["src/email_thread_mcp", "README.md", "LICENSE", "pyproject.toml"]
|
|
30
|
+
|
|
31
|
+
[tool.pytest.ini_options]
|
|
32
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""MCP tools for the AR email agent: thread parsing (request preprocessing) and output validation."""
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""HTML email body -> plain text that keeps the line structure.
|
|
2
|
+
|
|
3
|
+
Line structure matters here: quote headers (`From:` / `Sent:` lines), greetings and sign-off blocks
|
|
4
|
+
are all detected line by line, so block elements and `<br>` become line breaks rather than spaces.
|
|
5
|
+
|
|
6
|
+
Text a human reader cannot see (hidden by CSS, zero-width or bidirectional control characters) is
|
|
7
|
+
dropped: it is the usual way to put instructions in an email for the LLM but not for the reviewer.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
from html.parser import HTMLParser
|
|
14
|
+
|
|
15
|
+
_BLOCK_TAGS = {
|
|
16
|
+
"address", "article", "blockquote", "br", "dd", "div", "dl", "dt", "footer", "h1", "h2", "h3",
|
|
17
|
+
"h4", "h5", "h6", "header", "hr", "li", "ol", "p", "pre", "section", "table", "tr", "ul",
|
|
18
|
+
}
|
|
19
|
+
_CELL_TAGS = {"td", "th"}
|
|
20
|
+
_SKIP_TAGS = {"head", "script", "style", "title"}
|
|
21
|
+
_VOID_TAGS = {"area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "source", "track", "wbr"}
|
|
22
|
+
_HIDDEN_STYLE = re.compile(
|
|
23
|
+
r"display\s*:\s*none|visibility\s*:\s*hidden|mso-hide\s*:\s*all"
|
|
24
|
+
r"|(?<![\w-])font-size\s*:\s*0*\.?0+(px|pt|em|rem|%)?\s*(;|!|$)|(?<![\w-])opacity\s*:\s*0*\.?0+\s*(;|!|$)",
|
|
25
|
+
re.IGNORECASE,
|
|
26
|
+
)
|
|
27
|
+
# Zero-width, bidirectional-control and other invisible format characters, plus C0 controls but tab/newline.
|
|
28
|
+
_INVISIBLE = re.compile("[\u00ad\u200b-\u200f\u202a-\u202e\u2060-\u2064\u2066-\u2069\ufeff\x01-\x08\x0b\x0c\x0e-\x1f\x7f]")
|
|
29
|
+
|
|
30
|
+
# Line emitted where a mail client marks the start of quoted history. The markup is the same in every
|
|
31
|
+
# UI language, so the thread parser can split on it even when it knows none of the header words.
|
|
32
|
+
QUOTE_MARKER = "\x00quote\x00"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _is_quote_marker(tag: str, attrs: list[tuple[str, str | None]]) -> bool:
|
|
36
|
+
a = {k.lower(): (v or "") for k, v in attrs}
|
|
37
|
+
classes = a.get("class", "").split()
|
|
38
|
+
return (
|
|
39
|
+
a.get("id") == "divRplyFwdMsg" # Outlook
|
|
40
|
+
or "gmail_quote" in classes # Gmail
|
|
41
|
+
or "moz-cite-prefix" in classes # Thunderbird
|
|
42
|
+
or (tag == "blockquote" and a.get("type", "").lower() == "cite") # Apple Mail, Thunderbird
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _is_hidden(attrs: list[tuple[str, str | None]]) -> bool:
|
|
47
|
+
return any(k == "hidden" or (k == "style" and v and _HIDDEN_STYLE.search(v)) for k, v in attrs)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class _TextExtractor(HTMLParser):
|
|
51
|
+
def __init__(self, drop_hidden: bool = True) -> None:
|
|
52
|
+
super().__init__(convert_charrefs=True)
|
|
53
|
+
self._parts: list[str] = []
|
|
54
|
+
self._skip_depth = 0
|
|
55
|
+
self._drop_hidden = drop_hidden
|
|
56
|
+
# A hidden element is left at its own closing tag: only that tag name is counted, so unclosed
|
|
57
|
+
# <p>/<li> inside it cannot keep the rest of the email hidden.
|
|
58
|
+
self._hidden_tag: str | None = None
|
|
59
|
+
self._hidden_depth = 0
|
|
60
|
+
self.hidden_chars = 0
|
|
61
|
+
|
|
62
|
+
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
|
63
|
+
if tag in _SKIP_TAGS:
|
|
64
|
+
self._skip_depth += 1
|
|
65
|
+
return
|
|
66
|
+
if self._hidden_tag:
|
|
67
|
+
self._hidden_depth += tag == self._hidden_tag
|
|
68
|
+
return
|
|
69
|
+
if self._drop_hidden and tag not in _VOID_TAGS and _is_hidden(attrs):
|
|
70
|
+
self._hidden_tag, self._hidden_depth = tag, 1
|
|
71
|
+
return
|
|
72
|
+
if not self._skip_depth and _is_quote_marker(tag, attrs):
|
|
73
|
+
self._parts.append(f"\n{QUOTE_MARKER}\n")
|
|
74
|
+
if tag in _BLOCK_TAGS:
|
|
75
|
+
self._parts.append("\n")
|
|
76
|
+
elif tag in _CELL_TAGS:
|
|
77
|
+
self._parts.append("\t")
|
|
78
|
+
|
|
79
|
+
def handle_endtag(self, tag: str) -> None:
|
|
80
|
+
if tag in _SKIP_TAGS:
|
|
81
|
+
self._skip_depth = max(0, self._skip_depth - 1)
|
|
82
|
+
elif self._hidden_tag:
|
|
83
|
+
if tag == self._hidden_tag:
|
|
84
|
+
self._hidden_depth -= 1
|
|
85
|
+
if not self._hidden_depth:
|
|
86
|
+
self._hidden_tag = None
|
|
87
|
+
elif tag in _BLOCK_TAGS:
|
|
88
|
+
self._parts.append("\n")
|
|
89
|
+
|
|
90
|
+
def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
|
91
|
+
if tag in _BLOCK_TAGS and not self._hidden_tag:
|
|
92
|
+
self._parts.append("\n")
|
|
93
|
+
|
|
94
|
+
def handle_data(self, data: str) -> None:
|
|
95
|
+
if self._skip_depth:
|
|
96
|
+
return
|
|
97
|
+
if self._hidden_tag:
|
|
98
|
+
self.hidden_chars += len(data.strip())
|
|
99
|
+
return
|
|
100
|
+
self._parts.append(data)
|
|
101
|
+
|
|
102
|
+
def text(self) -> str:
|
|
103
|
+
return "".join(self._parts)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def looks_like_html(body: str) -> bool:
|
|
107
|
+
return bool(re.search(r"<(html|body|div|p|br|span|table)[\s/>]", body, re.IGNORECASE))
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def html_to_text(body: str) -> tuple[str, int]:
|
|
111
|
+
"""Visible text of an HTML body, and how many characters of hidden text were dropped.
|
|
112
|
+
|
|
113
|
+
If the whole body is hidden (broken markup rather than an attack), the hidden text is kept: losing
|
|
114
|
+
the message would be worse, and the caller still sees the count."""
|
|
115
|
+
parser = _TextExtractor()
|
|
116
|
+
parser.feed(body)
|
|
117
|
+
parser.close()
|
|
118
|
+
text = normalise_text(parser.text())
|
|
119
|
+
if not text.replace(QUOTE_MARKER, "").strip() and parser.hidden_chars:
|
|
120
|
+
everything = _TextExtractor(drop_hidden=False)
|
|
121
|
+
everything.feed(body)
|
|
122
|
+
everything.close()
|
|
123
|
+
return normalise_text(everything.text()), parser.hidden_chars
|
|
124
|
+
return text, parser.hidden_chars
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def strip_invisible(text: str) -> tuple[str, int]:
|
|
128
|
+
"""Remove characters that change what an LLM reads without changing what a person sees."""
|
|
129
|
+
cleaned, count = _INVISIBLE.subn("", text)
|
|
130
|
+
return cleaned, count
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def normalise_text(text: str) -> str:
|
|
134
|
+
"""Unify whitespace: no-break spaces become spaces, each line is trimmed, blank runs collapse."""
|
|
135
|
+
text = text.replace("\r\n", "\n").replace("\r", "\n").replace(" ", " ")
|
|
136
|
+
lines = [re.sub(r"[ \t]+", " ", line).strip() for line in text.split("\n")]
|
|
137
|
+
out: list[str] = []
|
|
138
|
+
for line in lines:
|
|
139
|
+
if line or (out and out[-1]):
|
|
140
|
+
out.append(line)
|
|
141
|
+
return "\n".join(out).strip()
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""MCP server (stdio) exposing the AR email tools. MCP clients start it with `uvx email-thread-mcp`.
|
|
2
|
+
|
|
3
|
+
Some clients render structured arguments through a template engine first, so they may arrive as
|
|
4
|
+
text: JSON or a Python-style repr. Both are accepted.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import ast
|
|
10
|
+
import json
|
|
11
|
+
from importlib.metadata import version
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from mcp.server.mcpserver import MCPServer
|
|
15
|
+
from mcp.server.mcpserver.exceptions import ToolError
|
|
16
|
+
|
|
17
|
+
from .thread import parse_email_thread as _parse_email_thread
|
|
18
|
+
from .validate import validate_output as _validate_output
|
|
19
|
+
|
|
20
|
+
mcp = MCPServer("email-thread-mcp", version=version("email-thread-mcp"))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _coerce(value: Any, name: str = "argument") -> Any:
|
|
24
|
+
if not isinstance(value, str):
|
|
25
|
+
return value
|
|
26
|
+
text = value.strip()
|
|
27
|
+
if not text:
|
|
28
|
+
return None
|
|
29
|
+
try:
|
|
30
|
+
return json.loads(text)
|
|
31
|
+
except json.JSONDecodeError:
|
|
32
|
+
pass
|
|
33
|
+
except (RecursionError, MemoryError):
|
|
34
|
+
raise ToolError(f"{name} is nested too deeply") from None
|
|
35
|
+
try:
|
|
36
|
+
return ast.literal_eval(text)
|
|
37
|
+
except (ValueError, SyntaxError, TypeError):
|
|
38
|
+
# ToolError, not ValueError: the SDK hides a plain exception's text from the caller.
|
|
39
|
+
raise ToolError(f"{name} is neither JSON nor a Python literal") from None
|
|
40
|
+
except (RecursionError, MemoryError):
|
|
41
|
+
raise ToolError(f"{name} is nested too deeply") from None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@mcp.tool()
|
|
45
|
+
def parse_email_thread(body: str, ar_mailboxes: list[str] | str | None = None) -> dict[str, Any]:
|
|
46
|
+
"""Split an inbound email body (HTML or plain text, newest message on top) into the latest
|
|
47
|
+
message, its signer and greeting, and the quoted history (oldest first), per the thread contract.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
body: the raw email body (HTML or plain text), unchanged.
|
|
51
|
+
ar_mailboxes: addresses (or '@domain' entries) of the AR team; quoted messages from these are
|
|
52
|
+
role 'recipient' (us), messages sent to them are role 'sender' (the customer).
|
|
53
|
+
"""
|
|
54
|
+
mailboxes = _coerce(ar_mailboxes, "ar_mailboxes") if isinstance(ar_mailboxes, str) else ar_mailboxes
|
|
55
|
+
if isinstance(mailboxes, str):
|
|
56
|
+
mailboxes = [mailboxes]
|
|
57
|
+
return _parse_email_thread(body, mailboxes or [])
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@mcp.tool()
|
|
61
|
+
def validate_output(
|
|
62
|
+
output: dict[str, Any] | str,
|
|
63
|
+
categories: list[Any] | str,
|
|
64
|
+
thread: dict[str, Any] | str | None = None,
|
|
65
|
+
) -> dict[str, Any]:
|
|
66
|
+
"""Check the agent's output envelope: category names must come from the request's list,
|
|
67
|
+
invoice numbers clean, the draft greets the right person and marks promised attachments.
|
|
68
|
+
Safe corrections are applied to the returned `output`; everything else is reported in `issues`.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
output: the final envelope (classification, draft_response, extracted_data, ...).
|
|
72
|
+
categories: the allowed categories (objects with `name`, or plain names).
|
|
73
|
+
thread: the parse_email_thread result, used for the greeting check. Optional.
|
|
74
|
+
"""
|
|
75
|
+
return _validate_output(
|
|
76
|
+
_coerce(output, "output"), _coerce(categories, "categories") or [], _coerce(thread, "thread")
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def main() -> None:
|
|
81
|
+
mcp.run()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
if __name__ == "__main__":
|
|
85
|
+
main()
|