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:
+54
-6
@@ -1,11 +1,12 @@
|
|||||||
"""MCP Contacts Server — reads and writes contacts via CardDAV."""
|
"""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
|
from typing import Annotated
|
||||||
|
|
||||||
import httpx, vobject
|
import httpx, vobject
|
||||||
from pydantic import Field
|
from pydantic import Field
|
||||||
from mcp.server.fastmcp import FastMCP
|
from mcp.server.fastmcp import FastMCP
|
||||||
|
from mcp.types import TextContent, ImageContent
|
||||||
from starlette.applications import Starlette
|
from starlette.applications import Starlette
|
||||||
from starlette.routing import Mount
|
from starlette.routing import Mount
|
||||||
|
|
||||||
@@ -107,18 +108,38 @@ def search_contacts(
|
|||||||
return "\n\n".join(results) if results else "Keine Kontakte gefunden"
|
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()
|
@mcp.tool()
|
||||||
def get_contact(
|
def get_contact(
|
||||||
uid: Annotated[str, Field(description="Contact UID from search_contacts results")],
|
uid: Annotated[str, Field(description="Contact UID from search_contacts results")],
|
||||||
) -> str:
|
) -> list[TextContent | ImageContent]:
|
||||||
"""Get full details of a contact by UID: name, all emails, all phones, addresses, company, birthday, notes."""
|
"""Get full details of a contact by UID: name, emails, phones, addresses, company, birthday, notes. Includes contact photo if available."""
|
||||||
user = get_current_user()
|
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 book in _discover_ab(user):
|
||||||
for c in _get_contacts(book["href"], _auth(user)):
|
for c in _get_contacts(book["href"], _auth(user)):
|
||||||
if hasattr(c, 'uid') and str(c.uid.value) == uid:
|
if hasattr(c, 'uid') and str(c.uid.value) == uid:
|
||||||
return _full(c)
|
result = [TextContent(type="text", text=_full(c))]
|
||||||
return f"Kontakt nicht gefunden: {uid}"
|
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()
|
@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}"
|
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():
|
def create_app():
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
mcp_app = mcp.streamable_http_app()
|
mcp_app = mcp.streamable_http_app()
|
||||||
|
|||||||
+20
-12
@@ -1,12 +1,13 @@
|
|||||||
"""MCP Files Server — browse and read files via WebDAV/oCIS."""
|
"""MCP Files Server — browse and read files via WebDAV/oCIS."""
|
||||||
|
|
||||||
import os, sys, contextlib
|
import os, sys, contextlib, base64
|
||||||
from xml.etree import ElementTree as ET
|
from xml.etree import ElementTree as ET
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from pydantic import Field
|
from pydantic import Field
|
||||||
from mcp.server.fastmcp import FastMCP
|
from mcp.server.fastmcp import FastMCP
|
||||||
|
from mcp.types import TextContent, ImageContent
|
||||||
from starlette.applications import Starlette
|
from starlette.applications import Starlette
|
||||||
from starlette.routing import Mount
|
from starlette.routing import Mount
|
||||||
|
|
||||||
@@ -68,20 +69,27 @@ def list_files(
|
|||||||
return "\n".join(lines) if lines else "Leeres Verzeichnis"
|
return "\n".join(lines) if lines else "Leeres Verzeichnis"
|
||||||
|
|
||||||
|
|
||||||
|
IMAGE_TYPES = {"image/jpeg", "image/png", "image/gif", "image/webp", "image/svg+xml"}
|
||||||
|
TEXT_HINTS = ["text/", "json", "xml", "csv", "yaml", "javascript", "markdown"]
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def read_file(
|
def read_file(
|
||||||
path: Annotated[str, Field(description="Full file path, e.g. '/Documents/notes.txt', '/config.yaml'")],
|
path: Annotated[str, Field(description="Full file path, e.g. '/Documents/notes.txt', '/Photos/pic.jpg'")],
|
||||||
) -> str:
|
) -> list[TextContent | ImageContent]:
|
||||||
"""Read a text file's content (txt, md, json, csv, yaml, xml, html). Binary files show only metadata."""
|
"""Read a file. Text files return content directly. Images (jpg/png/gif/webp) are displayed inline. Other binary files return only metadata."""
|
||||||
user = get_current_user()
|
user = get_current_user()
|
||||||
if not user: return "Error: not authenticated"
|
if not user: return [TextContent(type="text", text="Error: not authenticated")]
|
||||||
r = httpx.get(_dav(user, path), auth=_auth(user), timeout=30)
|
r = httpx.get(_dav(user, path), auth=_auth(user), timeout=60)
|
||||||
if r.status_code >= 400: return f"Fehler: HTTP {r.status_code}"
|
if r.status_code >= 400: return [TextContent(type="text", text=f"Fehler: HTTP {r.status_code}")]
|
||||||
ct = r.headers.get("content-type", "")
|
ct = r.headers.get("content-type", "").split(";")[0].strip()
|
||||||
if any(t in ct for t in ["text/", "json", "xml", "csv", "yaml", "javascript"]):
|
if ct in IMAGE_TYPES and len(r.content) < 10_000_000:
|
||||||
return r.text[:100000]
|
return [ImageContent(type="image", data=base64.b64encode(r.content).decode(), mimeType=ct)]
|
||||||
try: return r.content.decode("utf-8")[:100000]
|
if any(t in ct for t in TEXT_HINTS):
|
||||||
except: return f"Binaerdatei ({len(r.content)} bytes, Typ: {ct}). Kann nicht angezeigt werden."
|
return [TextContent(type="text", text=r.text[:100000])]
|
||||||
|
try:
|
||||||
|
return [TextContent(type="text", text=r.content.decode("utf-8")[:100000])]
|
||||||
|
except Exception:
|
||||||
|
return [TextContent(type="text", text=f"Binaerdatei ({len(r.content):,} bytes, Typ: {ct}). Nutze read_file nur fuer Text und Bilder.")]
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
|
|||||||
Reference in New Issue
Block a user