Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
81 changes: 81 additions & 0 deletions ex_app/lib/mcp_server.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# 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:
print(f"http://{nc.app_cfg.endpoint}/ocs/v2.php/cloud/user")
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)
print(user)
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