Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions appinfo/info.xml
Original file line number Diff line number Diff line change
Expand Up @@ -46,5 +46,13 @@ Positive:
<image>nextcloud/context_agent</image>
<image-tag>1.2.2</image-tag>
</docker-install>
<routes>
<route>
<url>mcp</url>
<verb>POST,GET,DELETE</verb>
<access_level>USER</access_level>
<headers_to_exclude>[]</headers_to_exclude>
</route>
</routes>
</external-app>
</info>
1 change: 1 addition & 0 deletions ex_app/lib/all_tools/audio2text.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ def transcribe_file(file_url: str) -> str:
:param file_url: The file URL to the media file in nextcloud (The user can input this using the smart picker for example)
:return: the transcription result
"""

task_input = {
'input': get_file_id_from_file_url(file_url),
}
Expand Down
1 change: 1 addition & 0 deletions ex_app/lib/all_tools/calendar.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ def find_free_time_slot_in_calendar(participants: list[str], slot_duration: Opti
:param end_time: the end time of the range within which to check for free slots (by default this will be 7 days after start_time; use the following format: 2025-01-31)
:return:
"""

me = nc.ocs('GET', '/ocs/v2.php/cloud/user')

attendees = 'ORGANIZER:mailto:'+me['email']+'\n'
Expand Down
6 changes: 3 additions & 3 deletions ex_app/lib/all_tools/files.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ def get_file_content(file_path: str):
Get the content of a file
:param file_path: the path of the file
:return:
"""
"""

user_id = nc.ocs('GET', '/ocs/v2.php/cloud/user')["id"]

Expand All @@ -34,7 +34,7 @@ def get_folder_tree(depth: int):
Get the folder tree of the user
:param depth: the depth of the returned folder tree
:return:
"""
"""

return nc.ocs('GET', '/ocs/v2.php/apps/files/api/v1/folder-tree', json={'depth': depth}, response_type='json')

Expand All @@ -45,7 +45,7 @@ def create_public_sharing_link(path: str):
Creates a public sharing link for a file or folder
:param path: the path of the file or folder
:return:
"""
"""

response = nc.ocs('POST', '/ocs/v2.php/apps/files_sharing/api/v1/shares', json={
'path': path,
Expand Down
16 changes: 16 additions & 0 deletions ex_app/lib/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,21 @@

from ex_app.lib.agent import react
from ex_app.lib.logger import log
from ex_app.lib.mcp_server import UserAuthMiddleware, ToolListMiddleware
from ex_app.lib.provider import provider
from ex_app.lib.tools import get_categories

from contextvars import ContextVar
from gettext import translation
from fastmcp import FastMCP

mcp = FastMCP(name="nextcloud")
mcp.add_middleware(UserAuthMiddleware())
mcp.add_middleware(ToolListMiddleware(mcp))
mcp.stateless_http = True
http_mcp_app = mcp.http_app("/", transport="http")

fast_app = FastAPI(lifespan=http_mcp_app.lifespan)

app_enabled = Event()

Expand All @@ -40,6 +49,11 @@ def _(text):

@asynccontextmanager
async def lifespan(app: FastAPI):
async with exapp_lifespan(app):
async with http_mcp_app.lifespan(app):
yield
@asynccontextmanager
async def exapp_lifespan(app: FastAPI):
set_handlers(app, enabled_handler)
start_bg_task()
nc = NextcloudApp()
Expand Down Expand Up @@ -176,6 +190,8 @@ def start_bg_task():
loop = asyncio.get_event_loop()
loop.create_task(background_thread_task())

APP.mount("/mcp", http_mcp_app)

if __name__ == "__main__":
# Wrapper around `uvicorn.run`.
# You are free to call it directly, with just using the `APP_HOST` and `APP_PORT` variables from the environment.
Expand Down
79 changes: 79 additions & 0 deletions ex_app/lib/mcp_server.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# SPDX-FileCopyrightText: 2024 LangChain, Inc.
# SPDX-License-Identifier: MIT
import time
from functools import wraps

from fastmcp.server.dependencies import get_context
from nc_py_api import NextcloudApp
from fastmcp.server.middleware import Middleware, MiddlewareContext, CallNext
from fastmcp.tools import Tool
from mcp import types as mt
from ex_app.lib.tools import get_tools
import requests

def get_user(authorization_header: str, nc: NextcloudApp) -> str:
response = requests.get(
f"{nc.app_cfg.endpoint}/ocs/v2.php/cloud/user",
headers={
"Accept": "application/json",
"Ocs-Apirequest": "1",
"Authorization": authorization_header,
},
)
if response.status_code != 200:
raise Exception("Failed to get user info")
return response.json()["ocs"]["data"]["id"]


class UserAuthMiddleware(Middleware):
async def on_message(self, context: MiddlewareContext, call_next):
# Middleware stores user info in context state
authorization_header = context.fastmcp_context.request_context.request.headers.get("Authorization")
if authorization_header is None:
raise Exception("Authorization header is missing/invalid")
nc = NextcloudApp()
user = get_user(authorization_header, nc)
nc.set_user(user)
context.fastmcp_context.set_state("nextcloud", nc)
return await call_next(context)


LAST_MCP_TOOL_UPDATE = 0


class ToolListMiddleware(Middleware):
def __init__(self, mcp):
self.mcp = mcp

async def on_message(
self,
context: MiddlewareContext[mt.ListToolsRequest],
call_next: CallNext[mt.ListToolsRequest, list[Tool]],
) -> list[Tool]:
global LAST_MCP_TOOL_UPDATE
if LAST_MCP_TOOL_UPDATE + 60 < time.time():
safe, dangerous = await get_tools(context.fastmcp_context.get_state("nextcloud"))
tools = await self.mcp.get_tools()
if LAST_MCP_TOOL_UPDATE + 60 < time.time():
for tool in tools.keys():
self.mcp.remove_tool(tool)
for tool in safe + dangerous:
if not hasattr(tool, "func") or tool.func is None:
continue
self.mcp.tool()(mcp_tool(tool.func))
LAST_MCP_TOOL_UPDATE = time.time()
return await call_next(context)

# Regenerates the tools with the correct nc object
def mcp_tool(tool):
@wraps(tool)
async def wrapper(*args, **kwargs):
ctx = get_context()
nc = ctx.get_state('nextcloud')
safe, dangerous = await get_tools(nc)
tools = safe + dangerous
for t in tools:
if hasattr(t, "func") and t.func and t.name == tool.__name__:
return t.func(*args, **kwargs)
raise RuntimeError("Tool not found")
return wrapper
1 change: 0 additions & 1 deletion ex_app/lib/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
import json
from os.path import dirname

from langchain_mcp_adapters.client import MultiServerMCPClient
from nc_py_api import Nextcloud
from ex_app.lib.all_tools.lib.decorator import timed_memoize

Expand Down
Loading
Loading