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:
+73
-1
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user