nextcloud-agent 2.3.7__tar.gz → 2.4.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/PKG-INFO +2 -2
- {nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/nextcloud_agent/api/api_client_base.py +19 -60
- {nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/nextcloud_agent/api/api_client_files.py +73 -92
- {nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/nextcloud_agent/ontology/certification.json +1 -1
- {nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/nextcloud_agent.egg-info/PKG-INFO +2 -2
- {nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/nextcloud_agent.egg-info/SOURCES.txt +1 -0
- {nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/nextcloud_agent.egg-info/requires.txt +1 -1
- {nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/pyproject.toml +2 -2
- nextcloud_agent-2.4.0/tests/test_files_webdav.py +145 -0
- {nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/LICENSE +0 -0
- {nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/README.md +0 -0
- {nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/nextcloud_agent/__init__.py +0 -0
- {nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/nextcloud_agent/__main__.py +0 -0
- {nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/nextcloud_agent/api/__init__.py +0 -0
- {nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/nextcloud_agent/api/api_client_calendar.py +0 -0
- {nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/nextcloud_agent/api/api_client_contacts.py +0 -0
- {nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/nextcloud_agent/api/xml_security.py +0 -0
- {nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/nextcloud_agent/api_client.py +0 -0
- {nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/nextcloud_agent/auth.py +0 -0
- {nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/nextcloud_agent/connector_sources.py +0 -0
- {nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/nextcloud_agent/kg_ingest.py +0 -0
- {nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/nextcloud_agent/mcp_config.json +0 -0
- {nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/nextcloud_agent/mcp_server.py +0 -0
- {nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/nextcloud_agent/ontology/__init__.py +0 -0
- {nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/nextcloud_agent/ontology/fixtures/records.json +0 -0
- {nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/nextcloud_agent/ontology/mappings/source.yaml +0 -0
- {nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/nextcloud_agent/ontology/migrations/manifest.json +0 -0
- {nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/nextcloud_agent/ontology/nextcloud.ttl +0 -0
- {nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/nextcloud_agent/ontology/shapes/connector.shacl.ttl +0 -0
- {nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/nextcloud_agent/prompts/file_steward.json +0 -0
- {nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/nextcloud_agent/skills/nextcloud-agent-development/SKILL.md +0 -0
- {nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/nextcloud_agent/skills/nextcloud-agent-operations/SKILL.md +0 -0
- {nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/nextcloud_agent/skills/nextcloud-agent-operations/agents/openai.yaml +0 -0
- {nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/nextcloud_agent/skills/nextcloud-agent-operations/references/catalog.md +0 -0
- {nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/nextcloud_agent/skills/nextcloud-files/SKILL.md +0 -0
- {nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/nextcloud_agent/skills/nextcloud-files/WORKFLOW.md +0 -0
- {nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/nextcloud_agent/skills/nextcloud-ingest/SKILL.md +0 -0
- {nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/nextcloud_agent/skills/nextcloud-ingest/WORKFLOW.md +0 -0
- {nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/nextcloud_agent/skills/nextcloud-shares/SKILL.md +0 -0
- {nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/nextcloud_agent/skills/nextcloud-shares/WORKFLOW.md +0 -0
- {nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/nextcloud_agent.egg-info/dependency_links.txt +0 -0
- {nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/nextcloud_agent.egg-info/entry_points.txt +0 -0
- {nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/nextcloud_agent.egg-info/top_level.txt +0 -0
- {nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/setup.cfg +0 -0
- {nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/tests/conftest.py +0 -0
- {nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/tests/test_api_client.py +0 -0
- {nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/tests/test_auth.py +0 -0
- {nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/tests/test_fleet_name.py +0 -0
- {nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/tests/test_init.py +0 -0
- {nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/tests/test_init_dynamics.py +0 -0
- {nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/tests/test_kg_ingest.py +0 -0
- {nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/tests/test_mcp_server.py +0 -0
- {nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/tests/test_publication_workflow.py +0 -0
- {nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/tests/test_xml_security.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: nextcloud-agent
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.4.0
|
|
4
4
|
Summary: Nextcloud MCP Server for Agentic AI!
|
|
5
5
|
Author-email: Repository Maintainers <maintainers@knuckles.team>
|
|
6
6
|
License: MIT
|
|
@@ -12,7 +12,7 @@ Classifier: Programming Language :: Python :: 3
|
|
|
12
12
|
Requires-Python: >=3.12
|
|
13
13
|
Description-Content-Type: text/markdown
|
|
14
14
|
License-File: LICENSE
|
|
15
|
-
Requires-Dist: scion-sdk[mcp]<0.
|
|
15
|
+
Requires-Dist: scion-sdk[mcp]<0.9,>=0.8.0
|
|
16
16
|
Requires-Dist: requests>=2.34.2
|
|
17
17
|
Requires-Dist: defusedxml>=0.7.1
|
|
18
18
|
Requires-Dist: python-dateutil>=2.9.0.post0
|
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
import ipaddress
|
|
2
2
|
import json
|
|
3
3
|
import logging
|
|
4
|
-
import os
|
|
5
4
|
from collections.abc import Iterator
|
|
6
5
|
from typing import Any, Protocol
|
|
7
6
|
from urllib.parse import quote, urljoin, urlsplit
|
|
8
7
|
|
|
9
8
|
import requests
|
|
9
|
+
from scion_sdk.config import csv_values, setting
|
|
10
|
+
from scion_sdk.storage import WebDavBackend
|
|
10
11
|
from scion_sdk.tls.profile import ResolvedTLSProfile
|
|
11
12
|
from scion_sdk.tls.resolve import resolve_tls_profile
|
|
12
13
|
|
|
13
14
|
from nextcloud_agent.api.xml_security import (
|
|
14
15
|
MAX_XML_BYTES,
|
|
15
16
|
XmlSecurityError,
|
|
16
|
-
parse_untrusted_xml,
|
|
17
17
|
)
|
|
18
18
|
|
|
19
19
|
logger = logging.getLogger(__name__)
|
|
@@ -163,9 +163,26 @@ class BaseApiClient:
|
|
|
163
163
|
)
|
|
164
164
|
self.carddav_base = f"{self.base_url}/remote.php/dav/addressbooks/users/{quote(self.username, safe='')}"
|
|
165
165
|
self.ocs_base = f"{self.base_url}/ocs/v2.php"
|
|
166
|
+
self._files_dav: WebDavBackend | None = None
|
|
167
|
+
|
|
168
|
+
@property
|
|
169
|
+
def files_dav(self) -> WebDavBackend:
|
|
170
|
+
"""The SDK WebDAV backend on the files root (CONN-NEXTCLOUD-AGENT-R002)."""
|
|
171
|
+
if self._files_dav is None:
|
|
172
|
+
hosts = tuple(csv_values(setting("NEXTCLOUD_ALLOWED_PRIVATE_HOSTS", "")))
|
|
173
|
+
self._files_dav = WebDavBackend(
|
|
174
|
+
self.webdav_base,
|
|
175
|
+
username=self.username,
|
|
176
|
+
password=self.password,
|
|
177
|
+
tls=self.tls_profile,
|
|
178
|
+
allowed_private_hosts=hosts,
|
|
179
|
+
)
|
|
180
|
+
return self._files_dav
|
|
166
181
|
|
|
167
182
|
def close(self) -> None:
|
|
168
183
|
"""Close transport state and erase any profile materialized for this client."""
|
|
184
|
+
if self._files_dav is not None:
|
|
185
|
+
self._files_dav.close()
|
|
169
186
|
self._session.close()
|
|
170
187
|
if self._owns_tls_profile:
|
|
171
188
|
self.tls_profile.cleanup()
|
|
@@ -240,64 +257,6 @@ class BaseApiClient:
|
|
|
240
257
|
finally:
|
|
241
258
|
response.close()
|
|
242
259
|
|
|
243
|
-
def _parse_propfind_response(
|
|
244
|
-
self, xml_content: str | bytes | bytearray
|
|
245
|
-
) -> list[dict]:
|
|
246
|
-
"""Parse PROPFIND XML response into a list of file dictionaries."""
|
|
247
|
-
try:
|
|
248
|
-
root = parse_untrusted_xml(xml_content)
|
|
249
|
-
except XmlSecurityError:
|
|
250
|
-
return []
|
|
251
|
-
|
|
252
|
-
ns = {
|
|
253
|
-
"d": "DAV:",
|
|
254
|
-
"oc": "http://owncloud.org/ns",
|
|
255
|
-
"nc": "http://nextcloud.org/ns",
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
files = []
|
|
259
|
-
for response in root.findall("d:response", ns):
|
|
260
|
-
href = response.find("d:href", ns).text # type: ignore[union-attr]
|
|
261
|
-
propstat = response.find("d:propstat", ns)
|
|
262
|
-
if propstat is None:
|
|
263
|
-
continue
|
|
264
|
-
|
|
265
|
-
prop = propstat.find("d:prop", ns)
|
|
266
|
-
if prop is None:
|
|
267
|
-
continue
|
|
268
|
-
|
|
269
|
-
file_data = {
|
|
270
|
-
"href": href,
|
|
271
|
-
"name": os.path.basename(href.rstrip("/")), # type: ignore[union-attr]
|
|
272
|
-
"is_folder": href.endswith("/"), # type: ignore[union-attr]
|
|
273
|
-
"last_modified": prop.findtext(
|
|
274
|
-
"d:getlastmodified", default="", namespaces=ns
|
|
275
|
-
),
|
|
276
|
-
"etag": prop.findtext("d:getetag", default="", namespaces=ns),
|
|
277
|
-
"content_type": prop.findtext(
|
|
278
|
-
"d:getcontenttype", default="", namespaces=ns
|
|
279
|
-
),
|
|
280
|
-
"content_length": prop.findtext(
|
|
281
|
-
"d:getcontentlength", default="0", namespaces=ns
|
|
282
|
-
),
|
|
283
|
-
"permissions": prop.findtext(
|
|
284
|
-
"oc:permissions", default="", namespaces=ns
|
|
285
|
-
),
|
|
286
|
-
"favorite": prop.findtext("oc:favorite", default="0", namespaces=ns),
|
|
287
|
-
"file_id": prop.findtext("oc:fileid", default="", namespaces=ns),
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
resourcetype = prop.find("d:resourcetype", ns)
|
|
291
|
-
if (
|
|
292
|
-
resourcetype is not None
|
|
293
|
-
and resourcetype.find("d:collection", ns) is not None
|
|
294
|
-
):
|
|
295
|
-
file_data["is_folder"] = True
|
|
296
|
-
|
|
297
|
-
files.append(file_data)
|
|
298
|
-
|
|
299
|
-
return files
|
|
300
|
-
|
|
301
260
|
def _get_absolute_url(self, href: str) -> str:
|
|
302
261
|
"""Convert a WebDAV href to a full URL."""
|
|
303
262
|
absolute = urljoin(self.base_url + "/", href)
|
|
@@ -1,51 +1,44 @@
|
|
|
1
|
-
import
|
|
1
|
+
from scion_sdk.storage import AlreadyExistsError, DavEntry, NotFoundError
|
|
2
2
|
|
|
3
3
|
from nextcloud_agent.api.api_client_base import BaseApiClient
|
|
4
4
|
from nextcloud_agent.api.xml_security import parse_untrusted_xml
|
|
5
5
|
|
|
6
|
+
_OC = "http://owncloud.org/ns"
|
|
7
|
+
#: The Nextcloud properties a listing keeps beside the standard DAV fields.
|
|
8
|
+
NC_PROPS = ((_OC, "permissions"), (_OC, "favorite"), (_OC, "fileid"))
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _entry_dict(webdav_base: str, entry: DavEntry) -> dict:
|
|
12
|
+
"""One SDK listing entry in the connector's listing shape."""
|
|
13
|
+
suffix = "/" if entry.is_collection else ""
|
|
14
|
+
href = f"{webdav_base.split('://', 1)[-1].split('/', 1)[-1]}/{entry.path}".rstrip(
|
|
15
|
+
"/"
|
|
16
|
+
)
|
|
17
|
+
return {
|
|
18
|
+
"href": f"/{href}{suffix}",
|
|
19
|
+
"name": entry.name,
|
|
20
|
+
"is_folder": entry.is_collection,
|
|
21
|
+
"last_modified": entry.last_modified or "",
|
|
22
|
+
"etag": f'"{entry.etag}"' if entry.etag else "",
|
|
23
|
+
"content_type": entry.content_type or "",
|
|
24
|
+
"content_length": str(entry.size),
|
|
25
|
+
"permissions": entry.props.get("permissions", ""),
|
|
26
|
+
"favorite": entry.props.get("favorite") or "0",
|
|
27
|
+
"file_id": entry.props.get("fileid", ""),
|
|
28
|
+
}
|
|
6
29
|
|
|
7
|
-
class Api(BaseApiClient):
|
|
8
|
-
def list_contents(self, path: str = "") -> list[dict]:
|
|
9
|
-
"""List files and folders in a directory using PROPFIND."""
|
|
10
|
-
url = self._get_full_url(path)
|
|
11
|
-
|
|
12
|
-
body = """<?xml version="1.0" encoding="UTF-8"?>
|
|
13
|
-
<d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns" xmlns:nc="http://nextcloud.org/ns">
|
|
14
|
-
<d:prop>
|
|
15
|
-
<d:getlastmodified/>
|
|
16
|
-
<d:getcontentlength/>
|
|
17
|
-
<d:getcontenttype/>
|
|
18
|
-
<oc:permissions/>
|
|
19
|
-
<d:resourcetype/>
|
|
20
|
-
<d:getetag/>
|
|
21
|
-
<oc:favorite/>
|
|
22
|
-
<oc:fileid/>
|
|
23
|
-
</d:prop>
|
|
24
|
-
</d:propfind>"""
|
|
25
|
-
|
|
26
|
-
response = self._session.request(
|
|
27
|
-
"PROPFIND",
|
|
28
|
-
url,
|
|
29
|
-
data=body,
|
|
30
|
-
headers={"Depth": "1", "Content-Type": "application/xml"},
|
|
31
|
-
stream=True,
|
|
32
|
-
timeout=(10, 30),
|
|
33
|
-
)
|
|
34
|
-
|
|
35
|
-
if response.status_code == 404:
|
|
36
|
-
response.close()
|
|
37
|
-
raise FileNotFoundError("Configured remote path was not found")
|
|
38
30
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
filtered_files = []
|
|
42
|
-
for f in files:
|
|
43
|
-
if f["name"] and f["name"] != os.path.basename(path.strip("/")):
|
|
44
|
-
filtered_files.append(f)
|
|
45
|
-
elif not path.strip("/") and f["name"]:
|
|
46
|
-
filtered_files.append(f)
|
|
31
|
+
class Api(BaseApiClient):
|
|
32
|
+
"""File operations run through the SDK WebDAV backend (CONN-NEXTCLOUD-AGENT-R001)."""
|
|
47
33
|
|
|
48
|
-
|
|
34
|
+
def list_contents(self, path: str = "") -> list[dict]:
|
|
35
|
+
"""List files and folders in a directory (PROPFIND, depth 1)."""
|
|
36
|
+
self._get_full_url(path) # the shared path guard
|
|
37
|
+
try:
|
|
38
|
+
entries = self.files_dav.propfind(path.strip("/"), 1, extra_props=NC_PROPS)
|
|
39
|
+
except NotFoundError:
|
|
40
|
+
raise FileNotFoundError("Configured remote path was not found") from None
|
|
41
|
+
return [_entry_dict(self.webdav_base, entry) for entry in entries]
|
|
49
42
|
|
|
50
43
|
def list_files(self, path: str = "") -> list[dict]:
|
|
51
44
|
"""Alias for list_contents to support MCP server action."""
|
|
@@ -53,42 +46,34 @@ class Api(BaseApiClient):
|
|
|
53
46
|
|
|
54
47
|
def read_file(self, path: str) -> bytes:
|
|
55
48
|
"""Download a file."""
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
49
|
+
self._get_full_url(path)
|
|
50
|
+
try:
|
|
51
|
+
return self.files_dav.read_path(path.strip("/"))
|
|
52
|
+
except NotFoundError:
|
|
53
|
+
raise FileNotFoundError("Configured remote path was not found") from None
|
|
60
54
|
|
|
61
55
|
def write_file(
|
|
62
56
|
self, path: str, content: str | bytes, overwrite: bool = True
|
|
63
57
|
) -> bool:
|
|
64
|
-
"""Upload a file."""
|
|
65
|
-
|
|
66
|
-
|
|
58
|
+
"""Upload a file, creating missing parent folders."""
|
|
59
|
+
self._get_full_url(path)
|
|
67
60
|
if isinstance(content, str):
|
|
68
61
|
content = content.encode("utf-8")
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
headers = {}
|
|
74
|
-
|
|
75
|
-
response = self._session.put(url, data=content, headers=headers)
|
|
76
|
-
|
|
77
|
-
if not overwrite and response.status_code == 412:
|
|
78
|
-
raise FileExistsError("Configured remote file already exists")
|
|
79
|
-
|
|
80
|
-
response.raise_for_status()
|
|
62
|
+
try:
|
|
63
|
+
self.files_dav.write_path(path.strip("/"), content, overwrite=overwrite)
|
|
64
|
+
except AlreadyExistsError:
|
|
65
|
+
raise FileExistsError("Configured remote file already exists") from None
|
|
81
66
|
return True
|
|
82
67
|
|
|
83
68
|
def create_directory(self, path: str) -> bool:
|
|
84
69
|
"""Create a directory (MKCOL)."""
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
raise FileExistsError(
|
|
90
|
-
|
|
91
|
-
|
|
70
|
+
self._get_full_url(path)
|
|
71
|
+
try:
|
|
72
|
+
self.files_dav.make_collection(path.strip("/"), exist_ok=False)
|
|
73
|
+
except AlreadyExistsError:
|
|
74
|
+
raise FileExistsError(
|
|
75
|
+
"Configured remote directory likely already exists"
|
|
76
|
+
) from None
|
|
92
77
|
return True
|
|
93
78
|
|
|
94
79
|
def create_folder(self, path: str) -> bool:
|
|
@@ -97,30 +82,36 @@ class Api(BaseApiClient):
|
|
|
97
82
|
|
|
98
83
|
def delete_resource(self, path: str) -> bool:
|
|
99
84
|
"""Delete a file or directory."""
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
85
|
+
self._get_full_url(path)
|
|
86
|
+
try:
|
|
87
|
+
self.files_dav.delete_path(path.strip("/"))
|
|
88
|
+
except NotFoundError:
|
|
89
|
+
raise FileNotFoundError("Configured remote path was not found") from None
|
|
103
90
|
return True
|
|
104
91
|
|
|
105
92
|
def delete_item(self, path: str) -> bool:
|
|
106
93
|
"""Alias for delete_resource to support MCP server action."""
|
|
107
94
|
return self.delete_resource(path)
|
|
108
95
|
|
|
96
|
+
def _transfer(self, move: bool, source: str, dest: str, overwrite: bool) -> bool:
|
|
97
|
+
self._get_full_url(source)
|
|
98
|
+
self._get_full_url(dest)
|
|
99
|
+
method = self.files_dav.move_path if move else self.files_dav.copy_path
|
|
100
|
+
try:
|
|
101
|
+
method(source.strip("/"), dest.strip("/"), overwrite=overwrite)
|
|
102
|
+
except AlreadyExistsError:
|
|
103
|
+
raise FileExistsError(
|
|
104
|
+
"Configured remote destination already exists"
|
|
105
|
+
) from None
|
|
106
|
+
except NotFoundError:
|
|
107
|
+
raise FileNotFoundError("Configured remote path was not found") from None
|
|
108
|
+
return True
|
|
109
|
+
|
|
109
110
|
def move_resource(
|
|
110
111
|
self, source_path: str, dest_path: str, overwrite: bool = False
|
|
111
112
|
) -> bool:
|
|
112
113
|
"""Move a file or directory."""
|
|
113
|
-
|
|
114
|
-
dest_url = self._get_full_url(dest_path)
|
|
115
|
-
|
|
116
|
-
headers = {"Destination": dest_url, "Overwrite": "T" if overwrite else "F"}
|
|
117
|
-
response = self._session.request("MOVE", source_url, headers=headers)
|
|
118
|
-
|
|
119
|
-
if response.status_code == 412:
|
|
120
|
-
raise FileExistsError("Configured remote destination already exists")
|
|
121
|
-
|
|
122
|
-
response.raise_for_status()
|
|
123
|
-
return True
|
|
114
|
+
return self._transfer(True, source_path, dest_path, overwrite)
|
|
124
115
|
|
|
125
116
|
def move_item(
|
|
126
117
|
self, source_path: str, dest_path: str, overwrite: bool = False
|
|
@@ -132,17 +123,7 @@ class Api(BaseApiClient):
|
|
|
132
123
|
self, source_path: str, dest_path: str, overwrite: bool = False
|
|
133
124
|
) -> bool:
|
|
134
125
|
"""Copy a file or directory."""
|
|
135
|
-
|
|
136
|
-
dest_url = self._get_full_url(dest_path)
|
|
137
|
-
|
|
138
|
-
headers = {"Destination": dest_url, "Overwrite": "T" if overwrite else "F"}
|
|
139
|
-
response = self._session.request("COPY", source_url, headers=headers)
|
|
140
|
-
|
|
141
|
-
if response.status_code == 412:
|
|
142
|
-
raise FileExistsError("Configured remote destination already exists")
|
|
143
|
-
|
|
144
|
-
response.raise_for_status()
|
|
145
|
-
return True
|
|
126
|
+
return self._transfer(False, source_path, dest_path, overwrite)
|
|
146
127
|
|
|
147
128
|
def copy_item(
|
|
148
129
|
self, source_path: str, dest_path: str, overwrite: bool = False
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: nextcloud-agent
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.4.0
|
|
4
4
|
Summary: Nextcloud MCP Server for Agentic AI!
|
|
5
5
|
Author-email: Repository Maintainers <maintainers@knuckles.team>
|
|
6
6
|
License: MIT
|
|
@@ -12,7 +12,7 @@ Classifier: Programming Language :: Python :: 3
|
|
|
12
12
|
Requires-Python: >=3.12
|
|
13
13
|
Description-Content-Type: text/markdown
|
|
14
14
|
License-File: LICENSE
|
|
15
|
-
Requires-Dist: scion-sdk[mcp]<0.
|
|
15
|
+
Requires-Dist: scion-sdk[mcp]<0.9,>=0.8.0
|
|
16
16
|
Requires-Dist: requests>=2.34.2
|
|
17
17
|
Requires-Dist: defusedxml>=0.7.1
|
|
18
18
|
Requires-Dist: python-dateutil>=2.9.0.post0
|
|
@@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "nextcloud-agent"
|
|
7
|
-
version = "2.
|
|
7
|
+
version = "2.4.0"
|
|
8
8
|
description = "Nextcloud MCP Server for Agentic AI!"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
classifiers = [ "Development Status :: 3 - Alpha", "License :: OSI Approved :: MIT License", "Environment :: Console", "Operating System :: POSIX :: Linux", "Programming Language :: Python :: 3",]
|
|
11
11
|
requires-python = ">=3.12"
|
|
12
|
-
dependencies = [ "scion-sdk[mcp]>=0.
|
|
12
|
+
dependencies = [ "scion-sdk[mcp]>=0.8.0,<0.9", "requests>=2.34.2", "defusedxml>=0.7.1", "python-dateutil>=2.9.0.post0", "icalendar>=7.3.0", "vobject>=0.9.9",]
|
|
13
13
|
[[project.authors]]
|
|
14
14
|
name = "Repository Maintainers"
|
|
15
15
|
email = "maintainers@knuckles.team"
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""CONN-NEXTCLOUD-AGENT: the file tools run through the SDK WebDAV backend."""
|
|
2
|
+
|
|
3
|
+
from urllib.parse import unquote, urlsplit
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
import pytest
|
|
7
|
+
from scion_sdk.storage import WebDavBackend
|
|
8
|
+
|
|
9
|
+
from nextcloud_agent.api_client import NextcloudAPI
|
|
10
|
+
|
|
11
|
+
ROOT = "/remote.php/dav/files/svc"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class _Dav:
|
|
15
|
+
"""A minimal in-memory WebDAV double."""
|
|
16
|
+
|
|
17
|
+
def __init__(self):
|
|
18
|
+
self.files, self.cols, self.requests = {}, {""}, []
|
|
19
|
+
|
|
20
|
+
def _path(self, url):
|
|
21
|
+
return unquote(urlsplit(str(url)).path)[len(ROOT) :].strip("/")
|
|
22
|
+
|
|
23
|
+
def _resp(self, path):
|
|
24
|
+
href = f"{ROOT}/{path}".rstrip("/") + ("/" if path in self.cols else "")
|
|
25
|
+
if path in self.cols:
|
|
26
|
+
props = "<d:resourcetype><d:collection/></d:resourcetype>"
|
|
27
|
+
else:
|
|
28
|
+
props = f"<d:resourcetype/><d:getcontentlength>{len(self.files[path])}</d:getcontentlength>"
|
|
29
|
+
props += "<oc:permissions>RGDNVW</oc:permissions><oc:favorite>1</oc:favorite><oc:fileid>7</oc:fileid>"
|
|
30
|
+
return f"<d:response><d:href>{href}</d:href><d:propstat><d:prop>{props}</d:prop></d:propstat></d:response>"
|
|
31
|
+
|
|
32
|
+
def handle(self, request):
|
|
33
|
+
path, method = self._path(request.url), request.method
|
|
34
|
+
self.requests.append((method, path))
|
|
35
|
+
if method == "PROPFIND":
|
|
36
|
+
if path not in self.cols and path not in self.files:
|
|
37
|
+
return httpx.Response(404)
|
|
38
|
+
items = (
|
|
39
|
+
[path]
|
|
40
|
+
+ sorted(
|
|
41
|
+
p
|
|
42
|
+
for p in (*self.files, *self.cols)
|
|
43
|
+
if p and p.rsplit("/", 1)[0] == path and p != path
|
|
44
|
+
)
|
|
45
|
+
if path
|
|
46
|
+
else [path]
|
|
47
|
+
+ sorted(p for p in (*self.files, *self.cols) if p and "/" not in p)
|
|
48
|
+
)
|
|
49
|
+
body = (
|
|
50
|
+
'<d:multistatus xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">'
|
|
51
|
+
+ "".join(self._resp(p) for p in items)
|
|
52
|
+
+ "</d:multistatus>"
|
|
53
|
+
)
|
|
54
|
+
return httpx.Response(207, content=body.encode())
|
|
55
|
+
if method == "MKCOL":
|
|
56
|
+
if path in self.cols:
|
|
57
|
+
return httpx.Response(405)
|
|
58
|
+
self.cols.add(path)
|
|
59
|
+
return httpx.Response(201)
|
|
60
|
+
if method == "PUT":
|
|
61
|
+
if request.headers.get("If-None-Match") == "*" and path in self.files:
|
|
62
|
+
return httpx.Response(412)
|
|
63
|
+
self.files[path] = request.read()
|
|
64
|
+
return httpx.Response(201)
|
|
65
|
+
if method == "GET":
|
|
66
|
+
return (
|
|
67
|
+
httpx.Response(200, content=self.files[path])
|
|
68
|
+
if path in self.files
|
|
69
|
+
else httpx.Response(404)
|
|
70
|
+
)
|
|
71
|
+
if method == "DELETE":
|
|
72
|
+
return (
|
|
73
|
+
httpx.Response(204)
|
|
74
|
+
if self.files.pop(path, None) is not None
|
|
75
|
+
else httpx.Response(404)
|
|
76
|
+
)
|
|
77
|
+
if method in ("COPY", "MOVE"):
|
|
78
|
+
target = self._path(request.headers["Destination"])
|
|
79
|
+
self.files[target] = self.files[path]
|
|
80
|
+
if method == "MOVE":
|
|
81
|
+
del self.files[path]
|
|
82
|
+
return httpx.Response(201)
|
|
83
|
+
return httpx.Response(405)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _client(dav):
|
|
87
|
+
client = NextcloudAPI(
|
|
88
|
+
base_url="https://cloud.example", username="svc", password="pw"
|
|
89
|
+
)
|
|
90
|
+
client._files_dav = WebDavBackend(
|
|
91
|
+
client.webdav_base,
|
|
92
|
+
username="svc",
|
|
93
|
+
password="pw",
|
|
94
|
+
transport=httpx.MockTransport(dav.handle),
|
|
95
|
+
)
|
|
96
|
+
return client
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@pytest.mark.spec("CONN-NEXTCLOUD-AGENT-R001")
|
|
100
|
+
def test_file_methods_route_through_sdk_webdav_backend():
|
|
101
|
+
dav = _Dav()
|
|
102
|
+
client = _client(dav)
|
|
103
|
+
assert client.write_file("Docs/a.txt", "hello") is True
|
|
104
|
+
assert [m for m, _ in dav.requests][:2] == ["MKCOL", "PUT"]
|
|
105
|
+
with pytest.raises(FileExistsError):
|
|
106
|
+
client.write_file("Docs/a.txt", "again", overwrite=False)
|
|
107
|
+
assert client.read_file("Docs/a.txt") == b"hello"
|
|
108
|
+
listing = {e["name"]: e for e in client.list_files("Docs")}
|
|
109
|
+
entry = listing["a.txt"]
|
|
110
|
+
assert (
|
|
111
|
+
entry["permissions"] == "RGDNVW"
|
|
112
|
+
and entry["favorite"] == "1"
|
|
113
|
+
and entry["file_id"] == "7"
|
|
114
|
+
)
|
|
115
|
+
assert entry["is_folder"] is False and entry["content_length"] == "5"
|
|
116
|
+
client.copy_item("Docs/a.txt", "Docs/b.txt")
|
|
117
|
+
client.move_item("Docs/b.txt", "Docs/c.txt")
|
|
118
|
+
client.delete_item("Docs/c.txt")
|
|
119
|
+
assert set(dav.files) == {"Docs/a.txt"}
|
|
120
|
+
with pytest.raises(FileNotFoundError):
|
|
121
|
+
client.read_file("Docs/missing.txt")
|
|
122
|
+
with pytest.raises(ValueError):
|
|
123
|
+
client.read_file("../escape")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@pytest.mark.spec("CONN-NEXTCLOUD-AGENT-R002")
|
|
127
|
+
def test_backend_uses_files_root_and_private_host_setting(monkeypatch):
|
|
128
|
+
client = NextcloudAPI(
|
|
129
|
+
base_url="https://cloud.example", username="svc", password="pw"
|
|
130
|
+
)
|
|
131
|
+
assert client.webdav_base.endswith("/remote.php/dav/files/svc")
|
|
132
|
+
monkeypatch.delenv("NEXTCLOUD_ALLOWED_PRIVATE_HOSTS", raising=False)
|
|
133
|
+
private = NextcloudAPI(base_url="https://10.9.8.7", username="svc", password="pw")
|
|
134
|
+
with pytest.raises(httpx.TransportError, match="rejected"):
|
|
135
|
+
private.read_file("a.txt")
|
|
136
|
+
monkeypatch.setenv("NEXTCLOUD_ALLOWED_PRIVATE_HOSTS", "10.9.8.7")
|
|
137
|
+
allowed = NextcloudAPI(base_url="https://10.9.8.7", username="svc", password="pw")
|
|
138
|
+
assert allowed.files_dav is not None
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@pytest.mark.spec("CONN-NEXTCLOUD-AGENT-R003")
|
|
142
|
+
def test_sdk_pin_carries_storage_backend():
|
|
143
|
+
from scion_sdk.storage import WebDavBackend as Backend
|
|
144
|
+
|
|
145
|
+
assert Backend.backend_type == "webdav"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/nextcloud_agent/ontology/fixtures/records.json
RENAMED
|
File without changes
|
{nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/nextcloud_agent/ontology/mappings/source.yaml
RENAMED
|
File without changes
|
{nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/nextcloud_agent/ontology/migrations/manifest.json
RENAMED
|
File without changes
|
|
File without changes
|
{nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/nextcloud_agent/ontology/shapes/connector.shacl.ttl
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/nextcloud_agent/skills/nextcloud-files/SKILL.md
RENAMED
|
File without changes
|
{nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/nextcloud_agent/skills/nextcloud-files/WORKFLOW.md
RENAMED
|
File without changes
|
{nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/nextcloud_agent/skills/nextcloud-ingest/SKILL.md
RENAMED
|
File without changes
|
{nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/nextcloud_agent/skills/nextcloud-ingest/WORKFLOW.md
RENAMED
|
File without changes
|
{nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/nextcloud_agent/skills/nextcloud-shares/SKILL.md
RENAMED
|
File without changes
|
{nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/nextcloud_agent/skills/nextcloud-shares/WORKFLOW.md
RENAMED
|
File without changes
|
{nextcloud_agent-2.3.7 → nextcloud_agent-2.4.0}/nextcloud_agent.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|