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.
@@ -0,0 +1,9 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ .pytest_cache/
5
+ dist/
6
+ # Real customer emails and their ground truth: local only, never pushed.
7
+ tests/fixtures/
8
+ tests/test_client_threads.py
9
+ .DS_Store
@@ -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()