Skip to content

Commit 49c1df9

Browse files
authored
Merge pull request #62 from nextcloud/feat/mcp-server
Feat: add mcp server
2 parents b250de6 + 7677952 commit 49c1df9

File tree

9 files changed

+681
-11
lines changed

9 files changed

+681
-11
lines changed

appinfo/info.xml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,5 +46,13 @@ Positive:
4646
<image>nextcloud/context_agent</image>
4747
<image-tag>1.2.2</image-tag>
4848
</docker-install>
49+
<routes>
50+
<route>
51+
<url>mcp</url>
52+
<verb>POST,GET,DELETE</verb>
53+
<access_level>USER</access_level>
54+
<headers_to_exclude>[]</headers_to_exclude>
55+
</route>
56+
</routes>
4957
</external-app>
5058
</info>

ex_app/lib/all_tools/audio2text.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ def transcribe_file(file_url: str) -> str:
1818
:param file_url: The file URL to the media file in nextcloud (The user can input this using the smart picker for example)
1919
:return: the transcription result
2020
"""
21+
2122
task_input = {
2223
'input': get_file_id_from_file_url(file_url),
2324
}

ex_app/lib/all_tools/calendar.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ def find_free_time_slot_in_calendar(participants: list[str], slot_duration: Opti
125125
: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)
126126
:return:
127127
"""
128+
128129
me = nc.ocs('GET', '/ocs/v2.php/cloud/user')
129130

130131
attendees = 'ORGANIZER:mailto:'+me['email']+'\n'

ex_app/lib/all_tools/files.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ def get_file_content(file_path: str):
1717
Get the content of a file
1818
:param file_path: the path of the file
1919
:return:
20-
"""
20+
"""
2121

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

@@ -34,7 +34,7 @@ def get_folder_tree(depth: int):
3434
Get the folder tree of the user
3535
:param depth: the depth of the returned folder tree
3636
:return:
37-
"""
37+
"""
3838

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

@@ -45,7 +45,7 @@ def create_public_sharing_link(path: str):
4545
Creates a public sharing link for a file or folder
4646
:param path: the path of the file or folder
4747
:return:
48-
"""
48+
"""
4949

5050
response = nc.ocs('POST', '/ocs/v2.php/apps/files_sharing/api/v1/shares', json={
5151
'path': path,

ex_app/lib/main.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,21 @@
2222

2323
from ex_app.lib.agent import react
2424
from ex_app.lib.logger import log
25+
from ex_app.lib.mcp_server import UserAuthMiddleware, ToolListMiddleware
2526
from ex_app.lib.provider import provider
2627
from ex_app.lib.tools import get_categories
2728

2829
from contextvars import ContextVar
2930
from gettext import translation
31+
from fastmcp import FastMCP
3032

33+
mcp = FastMCP(name="nextcloud")
34+
mcp.add_middleware(UserAuthMiddleware())
35+
mcp.add_middleware(ToolListMiddleware(mcp))
36+
mcp.stateless_http = True
37+
http_mcp_app = mcp.http_app("/", transport="http")
38+
39+
fast_app = FastAPI(lifespan=http_mcp_app.lifespan)
3140

3241
app_enabled = Event()
3342

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

4150
@asynccontextmanager
4251
async def lifespan(app: FastAPI):
52+
async with exapp_lifespan(app):
53+
async with http_mcp_app.lifespan(app):
54+
yield
55+
@asynccontextmanager
56+
async def exapp_lifespan(app: FastAPI):
4357
set_handlers(app, enabled_handler)
4458
start_bg_task()
4559
nc = NextcloudApp()
@@ -176,6 +190,8 @@ def start_bg_task():
176190
loop = asyncio.get_event_loop()
177191
loop.create_task(background_thread_task())
178192

193+
APP.mount("/mcp", http_mcp_app)
194+
179195
if __name__ == "__main__":
180196
# Wrapper around `uvicorn.run`.
181197
# You are free to call it directly, with just using the `APP_HOST` and `APP_PORT` variables from the environment.

ex_app/lib/mcp_server.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
# SPDX-FileCopyrightText: 2024 LangChain, Inc.
2+
# SPDX-License-Identifier: MIT
3+
import time
4+
from functools import wraps
5+
6+
from fastmcp.server.dependencies import get_context
7+
from nc_py_api import NextcloudApp
8+
from fastmcp.server.middleware import Middleware, MiddlewareContext, CallNext
9+
from fastmcp.tools import Tool
10+
from mcp import types as mt
11+
from ex_app.lib.tools import get_tools
12+
import requests
13+
14+
def get_user(authorization_header: str, nc: NextcloudApp) -> str:
15+
response = requests.get(
16+
f"{nc.app_cfg.endpoint}/ocs/v2.php/cloud/user",
17+
headers={
18+
"Accept": "application/json",
19+
"Ocs-Apirequest": "1",
20+
"Authorization": authorization_header,
21+
},
22+
)
23+
if response.status_code != 200:
24+
raise Exception("Failed to get user info")
25+
return response.json()["ocs"]["data"]["id"]
26+
27+
28+
class UserAuthMiddleware(Middleware):
29+
async def on_message(self, context: MiddlewareContext, call_next):
30+
# Middleware stores user info in context state
31+
authorization_header = context.fastmcp_context.request_context.request.headers.get("Authorization")
32+
if authorization_header is None:
33+
raise Exception("Authorization header is missing/invalid")
34+
nc = NextcloudApp()
35+
user = get_user(authorization_header, nc)
36+
nc.set_user(user)
37+
context.fastmcp_context.set_state("nextcloud", nc)
38+
return await call_next(context)
39+
40+
41+
LAST_MCP_TOOL_UPDATE = 0
42+
43+
44+
class ToolListMiddleware(Middleware):
45+
def __init__(self, mcp):
46+
self.mcp = mcp
47+
48+
async def on_message(
49+
self,
50+
context: MiddlewareContext[mt.ListToolsRequest],
51+
call_next: CallNext[mt.ListToolsRequest, list[Tool]],
52+
) -> list[Tool]:
53+
global LAST_MCP_TOOL_UPDATE
54+
if LAST_MCP_TOOL_UPDATE + 60 < time.time():
55+
safe, dangerous = await get_tools(context.fastmcp_context.get_state("nextcloud"))
56+
tools = await self.mcp.get_tools()
57+
if LAST_MCP_TOOL_UPDATE + 60 < time.time():
58+
for tool in tools.keys():
59+
self.mcp.remove_tool(tool)
60+
for tool in safe + dangerous:
61+
if not hasattr(tool, "func") or tool.func is None:
62+
continue
63+
self.mcp.tool()(mcp_tool(tool.func))
64+
LAST_MCP_TOOL_UPDATE = time.time()
65+
return await call_next(context)
66+
67+
# Regenerates the tools with the correct nc object
68+
def mcp_tool(tool):
69+
@wraps(tool)
70+
async def wrapper(*args, **kwargs):
71+
ctx = get_context()
72+
nc = ctx.get_state('nextcloud')
73+
safe, dangerous = await get_tools(nc)
74+
tools = safe + dangerous
75+
for t in tools:
76+
if hasattr(t, "func") and t.func and t.name == tool.__name__:
77+
return t.func(*args, **kwargs)
78+
raise RuntimeError("Tool not found")
79+
return wrapper

ex_app/lib/tools.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
import json
77
from os.path import dirname
88

9-
from langchain_mcp_adapters.client import MultiServerMCPClient
109
from nc_py_api import Nextcloud
1110
from ex_app.lib.all_tools.lib.decorator import timed_memoize
1211

0 commit comments

Comments
 (0)