Joplin via Data API + Mail attachments

Notes server rewritten to use Joplin CLI Data API (joplin-cli sync
clients on host, ports 41184/41185). Clean fast search, proper
resource handling. New tools: list_note_resources, read_resource
(attachments as inline image/document/text).

Mail server: read_mail now lists attachments; new read_attachment
tool returns images inline, PDFs/docs as EmbeddedResource, text directly.

Tests: 54 total. Notes now real (notebooks, list, create+read).
Mail attachment listing + fetch tested.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Stefan Lohmaier
2026-06-12 12:26:26 +02:00
parent 38cf147eec
commit c06e6d6b4c
4 changed files with 290 additions and 52 deletions
+73 -1
View File
@@ -2,6 +2,7 @@
import os
import sys
import base64
import contextlib
import imaplib
import mailbox
@@ -13,6 +14,7 @@ from typing import Annotated
from pydantic import Field
from mcp.server.fastmcp import FastMCP
from mcp.types import TextContent, ImageContent, EmbeddedResource, BlobResourceContents
from starlette.applications import Starlette
from starlette.routing import Mount
@@ -76,6 +78,35 @@ def _get_body(msg):
return ""
def _get_attachments(msg):
"""Return list of (index, filename, mimetype, size, part) for attachments."""
attachments = []
if not msg.is_multipart():
return attachments
idx = 0
for part in msg.walk():
if part.get_content_maintype() == "multipart":
continue
disp = str(part.get("Content-Disposition", ""))
filename = part.get_filename()
ctype = part.get_content_type()
# Attachment = has filename, or explicit attachment disposition, or non-text inline
is_attachment = (
"attachment" in disp.lower()
or (filename is not None)
or (ctype not in ("text/plain", "text/html") and part.get_content_maintype() != "multipart")
)
if not is_attachment:
continue
payload = part.get_payload(decode=True)
if payload is None:
continue
idx += 1
name = _decode_hdr(filename) if filename else f"attachment_{idx}.{ctype.split('/')[-1]}"
attachments.append({"index": idx, "filename": name, "mime": ctype, "size": len(payload), "payload": payload})
return attachments
def _discover_accounts(user):
root = MAIL_ROOTS.get(user)
if not root or not os.path.isdir(root):
@@ -183,7 +214,7 @@ def read_mail(
msg = md.get(key)
if not msg:
return f"Message not found: {key}"
return (
out = (
f"From: {_decode_hdr(msg.get('From', ''))}\n"
f"To: {_decode_hdr(msg.get('To', ''))}\n"
f"Cc: {_decode_hdr(msg.get('Cc', ''))}\n"
@@ -191,6 +222,47 @@ def read_mail(
f"Subject: {_decode_hdr(msg.get('Subject', ''))}\n\n"
f"{_get_body(msg)[:50000]}"
)
attachments = _get_attachments(msg)
if attachments:
out += "\n\nAnhaenge (nutze read_attachment mit dem Index):\n"
for a in attachments:
out += f" {a['index']}. {a['filename']} ({a['mime']}, {a['size']:,} bytes)\n"
return out
@mcp.tool()
def read_attachment(
account: Annotated[str, Field(description="Account name from search results")],
folder: Annotated[str, Field(description="Folder name from search results")],
key: Annotated[str, Field(description="Message key from search results")],
attachment_index: Annotated[int, Field(description="Attachment number from the read_mail attachment list (1-based)")],
) -> list[TextContent | ImageContent | EmbeddedResource]:
"""Read an email attachment. Images shown inline, documents (PDF/docx) as binary, text directly. Get the index from read_mail."""
user = get_current_user()
if not user:
return [TextContent(type="text", text="Error: not authenticated")]
acct_path = _discover_accounts(user).get(account)
if not acct_path:
return [TextContent(type="text", text=f"Account not found: {account}")]
md = _open_folder(acct_path, folder)
if not md:
return [TextContent(type="text", text=f"Folder not found: {folder}")]
msg = md.get(key)
if not msg:
return [TextContent(type="text", text=f"Message not found: {key}")]
attachments = _get_attachments(msg)
att = next((a for a in attachments if a["index"] == attachment_index), None)
if not att:
return [TextContent(type="text", text=f"Anhang {attachment_index} nicht gefunden. {len(attachments)} Anhaenge vorhanden.")]
mime = att["mime"]
payload = att["payload"]
if mime in ("image/jpeg", "image/png", "image/gif", "image/webp") and len(payload) < 10_000_000:
return [ImageContent(type="image", data=base64.b64encode(payload).decode(), mimeType=mime)]
if mime.startswith("text/"):
return [TextContent(type="text", text=payload.decode("utf-8", errors="replace")[:100000])]
return [EmbeddedResource(type="resource", resource=BlobResourceContents(
uri=f"mail://attachment/{att['filename']}",
blob=base64.b64encode(payload).decode(), mimeType=mime))]
@mcp.tool()