Initial commit

This commit is contained in:
Chris Coutinho
2025-05-04 23:22:52 +02:00
commit 0d8666a2d7
12 changed files with 1681 additions and 0 deletions
View File
+143
View File
@@ -0,0 +1,143 @@
import os
import time # Import time for sleep
from httpx import (
Client,
Auth,
BasicAuth,
Headers,
Request,
Response,
HTTPStatusError,
) # Import HTTPStatusError
import logging
logger = logging.getLogger(__name__)
def log_request(request: Request):
logger.info(
"Request event hook ****: %s %s - Waiting for content",
request.method,
request.url,
)
logger.info("Request body: %s", request.content)
logger.info("Headers: %s", request.headers)
def log_response(response: Response):
response.read() # Explicitly read the stream before accessing .text
logger.info("Response [%s] %s", response.status_code, response.text)
class NextcloudClient:
def __init__(self, base_url: str, auth: Auth | None = None):
self._client = Client(
base_url=base_url,
auth=auth,
event_hooks={"request": [log_request], "response": [log_response]},
)
@classmethod
def from_env(cls):
logger.info("Creating NC Client using env vars")
host = os.environ["NEXTCLOUD_HOST"]
username = os.environ["NEXTCLOUD_USERNAME"]
password = os.environ["NEXTCLOUD_PASSWORD"]
return cls(base_url=host, auth=BasicAuth(username, password))
def capabilities(self):
response = self._client.get(
"/ocs/v2.php/cloud/capabilities",
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
)
response.raise_for_status()
return response.json()
def notes_get_settings(self):
response = self._client.get("index.php/apps/notes/api/v1/settings")
response.raise_for_status()
return response.json()
def notes_get_all(self):
response = self._client.get("index.php/apps/notes/api/v1/notes")
response.raise_for_status()
return response.json()
def notes_get_note(self, *, note_id: int):
response = self._client.get(f"index.php/apps/notes/api/v1/notes/{note_id}")
response.raise_for_status()
return response.json()
def notes_create_note(
self,
*,
title: str | None = None,
content: str | None = None,
category: str | None = None,
):
body = {}
if title:
body.update({"title": title})
if content:
body.update({"content": content})
if category:
body.update({"category": category})
response = self._client.post(
url="index.php/apps/notes/api/v1/notes",
json=body,
)
response.raise_for_status()
return response.json()
def notes_update_note(
self,
*,
note_id: int,
etag: str,
title: str | None = None,
content: str | None = None,
category: str | None = None,
):
# body = {"etag": etag} # Removed redundant line
body = {}
if title:
body.update({"title": title})
if content:
body.update({"content": content})
if category:
body.update({"category": category})
logger.info(
"Attempting to update note %s with etag %s. Body: %s",
note_id,
etag, # This was current_etag in the loop
body,
)
# Ensure conditional PUT using If-Match header is active
response = self._client.put(
url=f"index.php/apps/notes/api/v1/notes/{note_id}",
json=body,
headers={"If-Match": f'"{etag}"'}, # This was current_etag in the loop
)
logger.info(
"Update response for note %s: Status %s, Headers %s",
note_id,
response.status_code,
response.headers,
)
response.raise_for_status()
return response.json()
def notes_delete_note(self, *, note_id: int):
response = self._client.delete(f"index.php/apps/notes/api/v1/notes/{note_id}")
response.raise_for_status()
return response.json()
+40
View File
@@ -0,0 +1,40 @@
import logging.config
LOGGING_CONFIG = {
"version": 1,
"handlers": {
"default": {
"class": "logging.FileHandler",
"formatter": "http",
# "stream": "ext://sys.stderr"
"filename": "/tmp/nextcloud-mcp-server.log",
"mode": "a",
}
},
"formatters": {
"http": {
"format": "%(levelname)s [%(asctime)s] %(name)s - %(message)s",
"datefmt": "%Y-%m-%d %H:%M:%S",
}
},
"loggers": {
"": {
"handlers": ["default"],
"level": "INFO",
},
"httpx": {
"handlers": ["default"],
"level": "DEBUG",
"propagate": False, # Prevent propagation to root logger
},
"httpcore": {
"handlers": ["default"],
"level": "DEBUG",
"propagate": False, # Prevent propagation to root logger
},
},
}
def setup_logging():
logging.config.dictConfig(LOGGING_CONFIG)
+123
View File
@@ -0,0 +1,123 @@
# server.py
import logging
from nextcloud_mcp_server.config import setup_logging
from contextlib import asynccontextmanager
from dataclasses import dataclass
from mcp.server.fastmcp import FastMCP, Context
from mcp.server import Server
from collections.abc import AsyncIterator
from nextcloud_mcp_server.client import NextcloudClient
setup_logging()
logger = logging.getLogger(__name__)
@dataclass
class AppContext:
client: NextcloudClient
@asynccontextmanager
async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]:
"""Manage application lifecycle with type-safe context"""
# Initialize on startup
logger.info("Creating Nextcloud client")
client = NextcloudClient.from_env()
try:
yield AppContext(client=client)
finally:
# Cleanup on shutdown
client._client.close()
# Create an MCP server
mcp = FastMCP("Nextcloud MCP", lifespan=app_lifespan)
@mcp.resource("nc://capabilities")
def nc_get_capabilities():
"""Get the Nextcloud Host capabilities"""
# client = NextcloudClient.from_env()
ctx = (
mcp.get_context()
) # https://github.com/modelcontextprotocol/python-sdk/issues/244
client = ctx.request_context.lifespan_context.client
return client.capabilities()
@mcp.resource("notes://settings")
def notes_get_settings():
"""Get the Notes App settings"""
ctx = (
mcp.get_context()
) # https://github.com/modelcontextprotocol/python-sdk/issues/244
client = ctx.request_context.lifespan_context.client
return client.notes_get_settings()
@mcp.resource("notes://all")
def nc_notes_get_all():
"""Get all user notes"""
ctx = (
mcp.get_context()
) # https://github.com/modelcontextprotocol/python-sdk/issues/244
client = ctx.request_context.lifespan_context.client
return client.notes_get_all()
@mcp.resource("notes://{note_id}")
def nc_notes_get_note(note_id: int):
"""Get user note using note id"""
ctx = (
mcp.get_context()
) # https://github.com/modelcontextprotocol/python-sdk/issues/244
client = ctx.request_context.lifespan_context.client
return client.notes_get_note(note_id=note_id)
@mcp.tool()
def nc_notes_create_note(title: str, content: str, category: str, ctx: Context):
"""Create a new note"""
client = ctx.request_context.lifespan_context.client
return client.notes_create_note(
title=title,
content=content,
category=category,
)
@mcp.tool()
def nc_notes_update_note(
note_id: int,
etag: str,
title: str | None,
content: str | None,
category: str | None,
ctx: Context,
):
logger.info("Updating note %s", note_id)
client = ctx.request_context.lifespan_context.client
return client.notes_update_note(
note_id=note_id,
etag=etag,
title=title,
content=content,
category=category,
)
@mcp.tool()
def nc_notes_delete_note(note_id: int, ctx: Context):
logger.info("Deleting note %s", note_id)
client = ctx.request_context.lifespan_context.client
return client.notes_delete_note(note_id=note_id)
def run():
mcp.run()
if __name__ == "__main__":
logger.info("Starting now")
mcp.run()