Add image support to Files and contact photos to Contacts

Files:
- read_file returns images inline (jpg/png/gif/webp up to 10MB)
- Text files returned as text, binary files as metadata only

Contacts:
- get_contact includes contact photo as inline image if available
- New tool: set_contact_photo (base64 jpeg/png → VCard PHOTO)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Stefan Lohmaier
2026-06-12 07:53:55 +02:00
parent 924366ac6c
commit a9359beead
2 changed files with 74 additions and 18 deletions
+54 -6
View File
@@ -1,11 +1,12 @@
"""MCP Contacts Server — reads and writes contacts via CardDAV."""
import os, sys, re, contextlib, uuid
import os, sys, re, contextlib, uuid, base64
from typing import Annotated
import httpx, vobject
from pydantic import Field
from mcp.server.fastmcp import FastMCP
from mcp.types import TextContent, ImageContent
from starlette.applications import Starlette
from starlette.routing import Mount
@@ -107,18 +108,38 @@ def search_contacts(
return "\n\n".join(results) if results else "Keine Kontakte gefunden"
def _get_photo_data(card):
if not hasattr(card, 'photo'):
return None, None
photo = card.photo
data = photo.value
if isinstance(data, str) and data.startswith("http"):
return None, None
if isinstance(data, bytes):
b64 = base64.b64encode(data).decode()
else:
b64 = data
ptype = (photo.params.get("TYPE", [""])[0] or "").lower() if hasattr(photo, 'params') and photo.params else ""
mime = {"jpeg": "image/jpeg", "png": "image/png", "gif": "image/gif"}.get(ptype, "image/jpeg")
return b64, mime
@mcp.tool()
def get_contact(
uid: Annotated[str, Field(description="Contact UID from search_contacts results")],
) -> str:
"""Get full details of a contact by UID: name, all emails, all phones, addresses, company, birthday, notes."""
) -> list[TextContent | ImageContent]:
"""Get full details of a contact by UID: name, emails, phones, addresses, company, birthday, notes. Includes contact photo if available."""
user = get_current_user()
if not user: return "Error: not authenticated"
if not user: return [TextContent(type="text", text="Error: not authenticated")]
for book in _discover_ab(user):
for c in _get_contacts(book["href"], _auth(user)):
if hasattr(c, 'uid') and str(c.uid.value) == uid:
return _full(c)
return f"Kontakt nicht gefunden: {uid}"
result = [TextContent(type="text", text=_full(c))]
b64, mime = _get_photo_data(c)
if b64 and mime:
result.append(ImageContent(type="image", data=b64, mimeType=mime))
return result
return [TextContent(type="text", text=f"Kontakt nicht gefunden: {uid}")]
@mcp.tool()
@@ -148,6 +169,33 @@ def create_contact(
return f"Kontakt erstellt: {name}" if r.status_code in (201, 204) else f"Fehler: HTTP {r.status_code}"
@mcp.tool()
def set_contact_photo(
uid: Annotated[str, Field(description="Contact UID from search_contacts or get_contact results")],
image_base64: Annotated[str, Field(description="Photo as base64-encoded JPEG or PNG data")],
image_type: Annotated[str, Field(description="Image format: 'jpeg' or 'png'")] = "jpeg",
) -> str:
"""Set or replace a contact's photo. Provide the image as base64-encoded data."""
user = get_current_user()
if not user: return "Error: not authenticated"
auth = _auth(user)
for book in _discover_ab(user):
for c in _get_contacts(book["href"], auth):
if hasattr(c, 'uid') and str(c.uid.value) == uid:
if hasattr(c, 'photo'):
c.remove(c.photo)
photo = c.add("photo")
photo.value = base64.b64decode(image_base64)
photo.params["ENCODING"] = ["b"]
photo.params["TYPE"] = [image_type.upper()]
href = book["href"] + uid + ".vcf"
r = httpx.put(RADICALE + href, content=c.serialize(), auth=auth, headers={"Content-Type": "text/vcard"}, timeout=15)
if r.status_code in (200, 201, 204):
return f"Foto gesetzt fuer {c.fn.value if hasattr(c, 'fn') else uid}"
return f"Fehler: HTTP {r.status_code}"
return f"Kontakt nicht gefunden: {uid}"
def create_app():
from contextlib import asynccontextmanager
mcp_app = mcp.streamable_http_app()