Compare commits
254 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 02a2c4a16f | |||
| f37008fdc3 | |||
| 7cb616c7ce | |||
| 34df5f5b9a | |||
| e26c5128b7 | |||
| ed813af45c | |||
| 1e071c83a9 | |||
| 76430bec21 | |||
| e81c2ad33d | |||
| 23360485a8 | |||
| 2ca6725fc6 | |||
| 4c7d1cfc8d | |||
| b68c704c4d | |||
| 849c67c32a | |||
| b3725dd2f5 | |||
| 6117aaaed3 | |||
| 403f8be429 | |||
| 2a1274d8a8 | |||
| e331544cee | |||
| 37b0b4a281 | |||
| f34366a260 | |||
| 529dc4616b | |||
| f739330341 | |||
| 136df2422b | |||
| eb8ca92bca | |||
| 0f03541486 | |||
| ef07b1a6c9 | |||
| 4f82357f24 | |||
| 9ef2311c71 | |||
| c4293b6750 | |||
| 72e4eb3d19 | |||
| 47dd2df7aa | |||
| 9fd2022151 | |||
| b99dc52c95 | |||
| 78b27fb5e9 | |||
| 03e39a3f94 | |||
| 5259658458 | |||
| e03a3c2e83 | |||
| 94cbd3015d | |||
| 49a961cbcc | |||
| e1aca04aff | |||
| 3b12e585ca | |||
| e647c87dd8 | |||
| cb74157d51 | |||
| 202058bdc8 | |||
| c312911538 | |||
| e602684743 | |||
| 8221046d8a | |||
| 3e45b6ca25 | |||
| 9ec7637579 | |||
| 670188f9e4 | |||
| 3878beaf65 | |||
| a5a0571bde | |||
| 0e7e74867f | |||
| a29045cca4 | |||
| b11c3ddfb6 | |||
| 562c102711 | |||
| 3c3646bec2 | |||
| dd636e6a08 | |||
| d7a8719d0e | |||
| 97fa9ef8a7 | |||
| 77dd17b3e1 | |||
| d56ec33b77 | |||
| a1c5acc1c2 | |||
| e0de2e17e9 | |||
| 4fc0cb5a41 | |||
| ff9cca716b | |||
| ef4a82e589 | |||
| 301c502e57 | |||
| d4d291d6d2 | |||
| e4b0ea5093 | |||
| 6833f7f117 | |||
| 7db2a5c586 | |||
| b76c10f18c | |||
| ab7411d9fd | |||
| d02fe3c3b6 | |||
| 49f9cead69 | |||
| 415b1c901b | |||
| 90b96a8afe | |||
| 57a2157c58 | |||
| bfdc33c390 | |||
| 8844c07ecb | |||
| 0a0ef10989 | |||
| 9414d9c9c3 | |||
| 8a52df4a8e | |||
| a36038422b | |||
| 2147fc1696 | |||
| a19017c686 | |||
| f0e5333e43 | |||
| 553e84e5f2 | |||
| ff20031601 | |||
| 04e0ab127a | |||
| 1117a83a52 | |||
| 01b43c96ba | |||
| c9db6afb59 | |||
| 50b69a2531 | |||
| 8e0a4d8ce5 | |||
| 72fce189d2 | |||
| 1e877f17f7 | |||
| 50a824155c | |||
| 0df9e41332 | |||
| 13f76a7734 | |||
| 3baf10662f | |||
| 81ca799410 | |||
| 2f1bd1bbe9 | |||
| d452684535 | |||
| bfbaed9a66 | |||
| ff32149220 | |||
| d55e5708c7 | |||
| d4ee5a74c2 | |||
| db79afacb9 | |||
| 261749fcdc | |||
| 6730dd4a4b | |||
| 8734c4b292 | |||
| 29df645d53 | |||
| bdb0e17401 | |||
| 8942f3119c | |||
| 3863cca2ed | |||
| a93e7a1e3b | |||
| f2d2dd8068 | |||
| d915efd3f6 | |||
| 053cf7798b | |||
| 87c6f077f3 | |||
| 38e12db46a | |||
| 1a7ce5b7a7 | |||
| 737780b417 | |||
| b4039e2e40 | |||
| 54e975198f | |||
| e9a16c43b5 | |||
| e48f5f3f30 | |||
| 3ebc468a09 | |||
| 1aecb099e6 | |||
| 2c35e07675 | |||
| 5cfdff0faf | |||
| eb7e15cac0 | |||
| 894723c525 | |||
| 8a3269f366 | |||
| c069d78f80 | |||
| e3436fecc0 | |||
| e3feb3eb2f | |||
| eedaa2e3f1 | |||
| d517fe09d8 | |||
| 98627593d5 | |||
| 64649c902d | |||
| 08ebab9f48 | |||
| f4f9548681 | |||
| 27bb0a4b56 | |||
| 7f5828390c | |||
| 8ad1937347 | |||
| 0d29048155 | |||
| 499429706c | |||
| 2903094d67 | |||
| 7abfa19d15 | |||
| c109626601 | |||
| a5a4e809c4 | |||
| 4984496d81 | |||
| 0e79ba06a9 | |||
| 48744e8a6c | |||
| 63b898c0e3 | |||
| e8f1340133 | |||
| fde68dac55 | |||
| 460e2e190c | |||
| 989b6de3c0 | |||
| aa0b6dc5dd | |||
| 7ae78d3a39 | |||
| 54326f9c64 | |||
| 6ba87e7e05 | |||
| 45bbf97033 | |||
| 14a0f166fe | |||
| 71f09a47ca | |||
| 61bb8cc048 | |||
| ad9b9f25a1 | |||
| f4dd68735c | |||
| c75f0c0a17 | |||
| a143123acc | |||
| 1dc2ddfdb7 | |||
| 92e18825bc | |||
| d398a8c8e6 | |||
| 39dfa13895 | |||
| cb7a609ec2 | |||
| b8d241b596 | |||
| 5395f8d3d6 | |||
| 198d7495f0 | |||
| c2f6c6ce0d | |||
| 5757f2582b | |||
| d5e6411c45 | |||
| f0c03ceede | |||
| 7818eb104e | |||
| b72514bb32 | |||
| f51d3a2101 | |||
| 5de4055f9f | |||
| 95da43ea0f | |||
| ae47c5f3e6 | |||
| 31ffeba69b | |||
| 963a504ae2 | |||
| ead298c132 | |||
| 2f805e54b7 | |||
| 6158a890af | |||
| 240ceb3808 | |||
| 1459fe9bc8 | |||
| 37164dbdbc | |||
| c3ff92a8c1 | |||
| 371d0c93a5 | |||
| 644c59bf78 | |||
| 056b6fc9d6 | |||
| 83917b3786 | |||
| 955ad78f13 | |||
| 3f04449a86 | |||
| 144a54c1ad | |||
| 90b4b2a038 | |||
| cdfab26c75 | |||
| a389f2940e | |||
| 5e829fc7e7 | |||
| 9c909b6e42 | |||
| 9b29eabfaa | |||
| 7549c988f4 | |||
| 0145be4bbd | |||
| b1207770ca | |||
| d694243723 | |||
| 8e7191e0ea | |||
| dbcf9d93ca | |||
| 27519d0f62 | |||
| 2999d4b65e | |||
| 0fd32ecd34 | |||
| 604a2065cb | |||
| 0aeef1b87e | |||
| b65f10ed8e | |||
| 038fcddd48 | |||
| 394b27ee4a | |||
| 9de59db718 | |||
| 6734de8389 | |||
| 3cb31d07f1 | |||
| 16b9123af3 | |||
| 51d1f075f5 | |||
| e0a68d47a5 | |||
| 832cb51dd3 | |||
| f6256c10db | |||
| 7b2002c1b5 | |||
| d150cf2e72 | |||
| 3921d9b982 | |||
| 9e4c20a4b1 | |||
| f26bca13f1 | |||
| 46c6f2f294 | |||
| 3ad9198f36 | |||
| dafac734e6 | |||
| 97bbc18121 | |||
| 46deb0f726 | |||
| daacf08a54 | |||
| 3ff6346c03 | |||
| c9a687171a | |||
| df5f85e0c6 | |||
| 76dce41ed9 | |||
| 642108ee91 | |||
| ce5724f05e |
@@ -0,0 +1,138 @@
|
|||||||
|
# Keycloak OAuth Configuration for Nextcloud MCP Server
|
||||||
|
#
|
||||||
|
# This configuration uses Keycloak as the OAuth/OIDC identity provider
|
||||||
|
# while still accessing Nextcloud APIs. Nextcloud's user_oidc app validates
|
||||||
|
# Keycloak bearer tokens and provisions users automatically.
|
||||||
|
#
|
||||||
|
# Architecture: Client → Keycloak (OAuth) → MCP Server → Nextcloud (user_oidc validates) → APIs
|
||||||
|
#
|
||||||
|
# This enables ADR-002 authentication patterns without admin credentials!
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# OAUTH PROVIDER SELECTION
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
# OAuth provider: "keycloak" or "nextcloud" (default)
|
||||||
|
OAUTH_PROVIDER=keycloak
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# KEYCLOAK CONFIGURATION
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
# Keycloak base URL (accessible from MCP server container)
|
||||||
|
KEYCLOAK_URL=http://keycloak:8080
|
||||||
|
|
||||||
|
# Keycloak realm name
|
||||||
|
KEYCLOAK_REALM=nextcloud-mcp
|
||||||
|
|
||||||
|
# OAuth client credentials (from Keycloak realm export or manual configuration)
|
||||||
|
KEYCLOAK_CLIENT_ID=nextcloud-mcp-server
|
||||||
|
KEYCLOAK_CLIENT_SECRET=mcp-secret-change-in-production
|
||||||
|
|
||||||
|
# OIDC discovery URL (auto-constructed from URL + realm, or specify explicitly)
|
||||||
|
KEYCLOAK_DISCOVERY_URL=http://keycloak:8080/realms/nextcloud-mcp/.well-known/openid-configuration
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# NEXTCLOUD CONFIGURATION
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
# Nextcloud URL (accessible from MCP server container)
|
||||||
|
# Used for API access - Keycloak tokens are validated by user_oidc app
|
||||||
|
NEXTCLOUD_HOST=http://app:80
|
||||||
|
|
||||||
|
# MCP server URL (for OAuth redirect URIs)
|
||||||
|
# This is the publicly accessible URL that OAuth clients connect to
|
||||||
|
NEXTCLOUD_MCP_SERVER_URL=http://localhost:8002
|
||||||
|
|
||||||
|
# Public Keycloak issuer URL (accessible from OAuth clients)
|
||||||
|
# If clients access Keycloak via a different URL than the internal one,
|
||||||
|
# set this to the public URL for OAuth flows
|
||||||
|
NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8888
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# REFRESH TOKEN STORAGE (ADR-002 Tier 1: Offline Access)
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
# Enable offline_access scope to get refresh tokens
|
||||||
|
ENABLE_OFFLINE_ACCESS=true
|
||||||
|
|
||||||
|
# Encryption key for storing refresh tokens (generate with instructions below)
|
||||||
|
# IMPORTANT: Keep this secret! Tokens are encrypted at rest using this key.
|
||||||
|
#
|
||||||
|
# Generate a key:
|
||||||
|
# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||||
|
#
|
||||||
|
# Example (DO NOT use this in production!):
|
||||||
|
# TOKEN_ENCRYPTION_KEY=your-base64-encoded-fernet-key-here
|
||||||
|
|
||||||
|
# Path to SQLite database for token storage
|
||||||
|
TOKEN_STORAGE_DB=/app/data/tokens.db
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# DOCKER COMPOSE NOTES
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
# When running via docker-compose, the mcp-keycloak service is pre-configured
|
||||||
|
# with these environment variables. See docker-compose.yml for the full config.
|
||||||
|
#
|
||||||
|
# Start services:
|
||||||
|
# docker-compose up -d keycloak app mcp-keycloak
|
||||||
|
#
|
||||||
|
# View logs:
|
||||||
|
# docker-compose logs -f mcp-keycloak
|
||||||
|
#
|
||||||
|
# Check Keycloak realm:
|
||||||
|
# curl http://localhost:8888/realms/nextcloud-mcp/.well-known/openid-configuration
|
||||||
|
#
|
||||||
|
# Check user_oidc provider:
|
||||||
|
# docker compose exec app php occ user_oidc:provider keycloak
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# KEYCLOAK SETUP VERIFICATION
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
# 1. Verify Keycloak is running and realm is imported:
|
||||||
|
# curl http://localhost:8888/realms/nextcloud-mcp/.well-known/openid-configuration
|
||||||
|
#
|
||||||
|
# 2. Verify Nextcloud user_oidc provider is configured:
|
||||||
|
# docker compose exec app php occ user_oidc:provider keycloak
|
||||||
|
#
|
||||||
|
# 3. Test OAuth flow manually:
|
||||||
|
# - Get token from Keycloak:
|
||||||
|
# curl -X POST "http://localhost:8888/realms/nextcloud-mcp/protocol/openid-connect/token" \
|
||||||
|
# -d "grant_type=password" \
|
||||||
|
# -d "client_id=nextcloud-mcp-server" \
|
||||||
|
# -d "client_secret=mcp-secret-change-in-production" \
|
||||||
|
# -d "username=admin" \
|
||||||
|
# -d "password=admin" \
|
||||||
|
# -d "scope=openid profile email offline_access"
|
||||||
|
#
|
||||||
|
# - Use token with Nextcloud API:
|
||||||
|
# curl -H "Authorization: Bearer <access_token>" \
|
||||||
|
# http://localhost:8080/ocs/v2.php/cloud/capabilities
|
||||||
|
#
|
||||||
|
# 4. Connect MCP client to server:
|
||||||
|
# - Point your MCP client to http://localhost:8002
|
||||||
|
# - Complete OAuth flow via Keycloak (credentials: admin/admin)
|
||||||
|
# - Client should receive access token and be able to call MCP tools
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# TROUBLESHOOTING
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
# If OAuth flow fails:
|
||||||
|
# - Check that Keycloak is accessible: curl http://localhost:8888
|
||||||
|
# - Check that user_oidc provider is configured: docker compose exec app php occ user_oidc:provider keycloak
|
||||||
|
# - Check MCP server logs: docker-compose logs mcp-keycloak
|
||||||
|
# - Verify redirect URIs match in Keycloak client configuration
|
||||||
|
#
|
||||||
|
# If token validation fails:
|
||||||
|
# - Verify user_oidc has bearer validation enabled (--check-bearer=1)
|
||||||
|
# - Check Nextcloud logs: docker compose exec app tail -f /var/www/html/data/nextcloud.log
|
||||||
|
# - Verify Keycloak discovery URL is accessible from Nextcloud container:
|
||||||
|
# docker compose exec app curl http://keycloak:8080/realms/nextcloud-mcp/.well-known/openid-configuration
|
||||||
|
#
|
||||||
|
# If offline_access/refresh tokens not working:
|
||||||
|
# - Verify TOKEN_ENCRYPTION_KEY is set and valid
|
||||||
|
# - Check token storage database: ls -lah /app/data/tokens.db (inside container)
|
||||||
|
# - Check that offline_access scope is requested in realm configuration
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
name: Release Charts
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- v*
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
# depending on default permission settings for your org (contents being read-only or read-write for workloads), you will have to add permissions
|
||||||
|
# see: https://docs.github.com/en/actions/security-guides/automatic-token-authentication#modifying-the-permissions-for-the-github_token
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
|
||||||
|
- name: Configure Git
|
||||||
|
run: |
|
||||||
|
git config user.name "$GITHUB_ACTOR"
|
||||||
|
git config user.email "$GITHUB_ACTOR@users.noreply.github.com"
|
||||||
|
|
||||||
|
- name: Run chart-releaser
|
||||||
|
uses: helm/chart-releaser-action@cae68fefc6b5f367a0275617c9f83181ba54714f # v1.7.0
|
||||||
|
env:
|
||||||
|
CR_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
|
||||||
|
|
||||||
|
- name: Update gh-pages with Chart README and Index
|
||||||
|
run: |
|
||||||
|
# Get the repository name
|
||||||
|
REPO_NAME="${GITHUB_REPOSITORY##*/}"
|
||||||
|
REPO_OWNER="${GITHUB_REPOSITORY%/*}"
|
||||||
|
|
||||||
|
# Switch to gh-pages branch
|
||||||
|
git fetch origin gh-pages
|
||||||
|
git checkout gh-pages
|
||||||
|
|
||||||
|
# Copy Chart README to root
|
||||||
|
git checkout ${GITHUB_REF#refs/tags/} -- charts/nextcloud-mcp-server/README.md
|
||||||
|
mv charts/nextcloud-mcp-server/README.md README.md || true
|
||||||
|
rm -rf charts 2>/dev/null || true
|
||||||
|
|
||||||
|
# Create index.html with installation instructions
|
||||||
|
cat > index.html <<'EOF'
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Nextcloud MCP Server Helm Chart</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 50px auto;
|
||||||
|
padding: 20px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
code {
|
||||||
|
background: #f4f4f4;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-family: "Monaco", "Courier New", monospace;
|
||||||
|
}
|
||||||
|
pre {
|
||||||
|
background: #f4f4f4;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 5px;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
h1, h2 { color: #0082c9; }
|
||||||
|
a { color: #0082c9; text-decoration: none; }
|
||||||
|
a:hover { text-decoration: underline; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Nextcloud MCP Server Helm Chart</h1>
|
||||||
|
|
||||||
|
<p>A Helm chart for deploying the Nextcloud MCP (Model Context Protocol) Server on Kubernetes, enabling AI assistants to interact with your Nextcloud instance.</p>
|
||||||
|
|
||||||
|
<h2>Installation</h2>
|
||||||
|
|
||||||
|
<p>Add the Helm repository:</p>
|
||||||
|
<pre><code>helm repo add nextcloud-mcp https://REPO_OWNER.github.io/REPO_NAME/
|
||||||
|
helm repo update</code></pre>
|
||||||
|
|
||||||
|
<p>Install the chart:</p>
|
||||||
|
<pre><code>helm install nextcloud-mcp nextcloud-mcp/nextcloud-mcp-server \
|
||||||
|
--set nextcloud.host=https://cloud.example.com \
|
||||||
|
--set auth.basic.username=myuser \
|
||||||
|
--set auth.basic.password=mypassword</code></pre>
|
||||||
|
|
||||||
|
<h2>Documentation</h2>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li><a href="README.md">Chart README</a> - Full documentation for the Helm chart</li>
|
||||||
|
<li><a href="https://github.com/REPO_OWNER/REPO_NAME">GitHub Repository</a> - Source code and issues</li>
|
||||||
|
<li><a href="index.yaml">Helm Repository Index</a> - Chart metadata</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>Quick Start</h2>
|
||||||
|
|
||||||
|
<p>See the <a href="README.md">full documentation</a> for detailed configuration options, examples, and troubleshooting guides.</p>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
<p><small>Generated by <a href="https://github.com/helm/chart-releaser">chart-releaser</a></small></p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Replace placeholders
|
||||||
|
sed -i "s/REPO_OWNER/$REPO_OWNER/g" index.html
|
||||||
|
sed -i "s/REPO_NAME/$REPO_NAME/g" index.html
|
||||||
|
|
||||||
|
# Commit changes
|
||||||
|
git add README.md index.html
|
||||||
|
git commit -m "Update README and index from chart release" || echo "No changes to commit"
|
||||||
|
git push origin gh-pages
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- v*
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
pypi:
|
||||||
|
name: Publish to PyPI
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
# Environment and permissions trusted publishing.
|
||||||
|
environment:
|
||||||
|
# Create this environment in the GitHub repository under Settings -> Environments
|
||||||
|
name: pypi
|
||||||
|
permissions:
|
||||||
|
id-token: write
|
||||||
|
contents: read
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||||
|
- name: Install uv
|
||||||
|
uses: astral-sh/setup-uv@85856786d1ce8acfbcc2f13a5f3fbd6b938f9f41 # v7.1.2
|
||||||
|
- name: Install Python 3.11
|
||||||
|
run: uv python install 3.11
|
||||||
|
- name: Build
|
||||||
|
run: uv build
|
||||||
|
- name: Smoke test (wheel)
|
||||||
|
run: uv run --isolated --no-project --with dist/*.whl nextcloud-mcp-server --help
|
||||||
|
- name: Smoke test (source distribution)
|
||||||
|
run: uv run --isolated --no-project --with dist/*.tar.gz nextcloud-mcp-server --help
|
||||||
|
- name: Publish
|
||||||
|
run: uv publish
|
||||||
@@ -11,7 +11,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||||
- name: Install the latest version of uv
|
- name: Install the latest version of uv
|
||||||
uses: astral-sh/setup-uv@3259c6206f993105e3a61b142c2d97bf4b9ef83d # v7.1.0
|
uses: astral-sh/setup-uv@85856786d1ce8acfbcc2f13a5f3fbd6b938f9f41 # v7.1.2
|
||||||
- name: Check format
|
- name: Check format
|
||||||
run: |
|
run: |
|
||||||
uv run --frozen ruff format --diff
|
uv run --frozen ruff format --diff
|
||||||
@@ -25,6 +25,25 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||||
|
with:
|
||||||
|
submodules: 'true'
|
||||||
|
|
||||||
|
|
||||||
|
###### Required to build OIDC App ######
|
||||||
|
|
||||||
|
- name: Set up php 8.4
|
||||||
|
uses: shivammathur/setup-php@bf6b4fbd49ca58e4608c9c89fba0b8d90bd2a39f # v2
|
||||||
|
with:
|
||||||
|
php-version: 8.4
|
||||||
|
coverage: none
|
||||||
|
|
||||||
|
- name: Install OIDC app composer dependencies
|
||||||
|
run: |
|
||||||
|
cd third_party/oidc
|
||||||
|
composer install --no-dev
|
||||||
|
|
||||||
|
###### Required to build OIDC App ######
|
||||||
|
|
||||||
|
|
||||||
- name: Run docker compose
|
- name: Run docker compose
|
||||||
uses: hoverkraft-tech/compose-action@3846bcd61da338e9eaaf83e7ed0234a12b099b72 # v2.4.1
|
uses: hoverkraft-tech/compose-action@3846bcd61da338e9eaaf83e7ed0234a12b099b72 # v2.4.1
|
||||||
@@ -33,11 +52,11 @@ jobs:
|
|||||||
up-flags: "--build"
|
up-flags: "--build"
|
||||||
|
|
||||||
- name: Install the latest version of uv
|
- name: Install the latest version of uv
|
||||||
uses: astral-sh/setup-uv@3259c6206f993105e3a61b142c2d97bf4b9ef83d # v7.1.0
|
uses: astral-sh/setup-uv@85856786d1ce8acfbcc2f13a5f3fbd6b938f9f41 # v7.1.2
|
||||||
|
|
||||||
- name: Install Playwright dependencies
|
- name: Install Playwright dependencies
|
||||||
run: |
|
run: |
|
||||||
uv run playwright install firefox --with-deps
|
uv run playwright install chromium --with-deps
|
||||||
|
|
||||||
- name: Wait for service to be ready
|
- name: Wait for service to be ready
|
||||||
run: |
|
run: |
|
||||||
@@ -62,4 +81,4 @@ jobs:
|
|||||||
NEXTCLOUD_USERNAME: "admin"
|
NEXTCLOUD_USERNAME: "admin"
|
||||||
NEXTCLOUD_PASSWORD: "admin"
|
NEXTCLOUD_PASSWORD: "admin"
|
||||||
run: |
|
run: |
|
||||||
uv run pytest -v --browser firefox
|
uv run pytest -v --log-cli-level=INFO
|
||||||
|
|||||||
+1
-1
@@ -6,4 +6,4 @@ __pycache__/
|
|||||||
.env.*.local
|
.env.*.local
|
||||||
|
|
||||||
# Generated by pytest used to login users
|
# Generated by pytest used to login users
|
||||||
.nextcloud_oauth_shared_test_client.json
|
.nextcloud_oauth_*.json
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
[submodule "oidc"]
|
||||||
|
path = third_party/oidc
|
||||||
|
url = https://github.com/cbcoutinho/oidc
|
||||||
|
[submodule "third_party/oidc"]
|
||||||
|
path = third_party/oidc
|
||||||
|
url = https://github.com/cbcoutinho/oidc
|
||||||
+206
@@ -1,3 +1,209 @@
|
|||||||
|
## v0.23.0 (2025-11-03)
|
||||||
|
|
||||||
|
### Feat
|
||||||
|
|
||||||
|
- Auto-configure impersonation role in Keycloak realm import
|
||||||
|
- Implement dual-tier token exchange (Standard V2 + Legacy V1 impersonation)
|
||||||
|
- Add Keycloak external IdP integration with custom scopes
|
||||||
|
- Implement RFC 8693 token exchange for Keycloak (ADR-002 Tier 2)
|
||||||
|
- Add Keycloak OAuth provider support with refresh token storage
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- Complete Keycloak external IdP integration with all tests passing
|
||||||
|
- Complete Keycloak external IdP integration with all tests passing
|
||||||
|
- Update DCR token_type tests for OIDC app changes
|
||||||
|
|
||||||
|
### Refactor
|
||||||
|
|
||||||
|
- Remove NEXTCLOUD_OIDC_CLIENT_STORAGE environment variable
|
||||||
|
- Remove unnecessary user_oidc patch - CORSMiddleware patch is sufficient
|
||||||
|
- Unify OAuth configuration to be provider-agnostic
|
||||||
|
|
||||||
|
## v0.22.7 (2025-10-29)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- **helm**: Remove image tag overide
|
||||||
|
|
||||||
|
## v0.22.6 (2025-10-29)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- **helm**: Update helm chart with extraArgs
|
||||||
|
|
||||||
|
## v0.22.5 (2025-10-29)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- Update helm chart variables
|
||||||
|
|
||||||
|
## v0.22.4 (2025-10-29)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- **helm**: Update helm version with release
|
||||||
|
- **helm**: Update helm version with release
|
||||||
|
|
||||||
|
## v0.22.3 (2025-10-29)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- **helm**: Update helm version with release
|
||||||
|
|
||||||
|
## v0.22.2 (2025-10-29)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- **helm**: Update helm version with release
|
||||||
|
|
||||||
|
## v0.22.1 (2025-10-29)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- Trigger release
|
||||||
|
|
||||||
|
## v0.22.0 (2025-10-29)
|
||||||
|
|
||||||
|
### Feat
|
||||||
|
|
||||||
|
- **server**: Add /live & /health endpoints
|
||||||
|
- Initialize helm chart
|
||||||
|
|
||||||
|
## v0.21.0 (2025-10-25)
|
||||||
|
|
||||||
|
### Feat
|
||||||
|
|
||||||
|
- Add text processing background worker for telling client about progress
|
||||||
|
|
||||||
|
### Refactor
|
||||||
|
|
||||||
|
- Transform document parsing into pluggable processor architecture
|
||||||
|
|
||||||
|
## v0.20.0 (2025-10-24)
|
||||||
|
|
||||||
|
### Feat
|
||||||
|
|
||||||
|
- **auth**: Add support for client registration deletion
|
||||||
|
- Split read/write scopes into app:read/write scopes
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- Add support for RFC 7592 client registration and deletion
|
||||||
|
- Update webdav models for proper serialization
|
||||||
|
|
||||||
|
## v0.19.1 (2025-10-24)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- **deps**: update dependency mcp to >=1.19,<1.20
|
||||||
|
|
||||||
|
## v0.19.0 (2025-10-23)
|
||||||
|
|
||||||
|
### Feat
|
||||||
|
|
||||||
|
- Enable token introspection for opaque tokens
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- Add CORS middleware to allow browser-based clients like MCP Inspector
|
||||||
|
|
||||||
|
## v0.18.0 (2025-10-23)
|
||||||
|
|
||||||
|
### Feat
|
||||||
|
|
||||||
|
- **server**: Add support for custom OIDC scopes and permissions via JWTs
|
||||||
|
- Initialize JWT-scoped tools
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- Use occ-created OAuth clients with allowed_scopes for all tests
|
||||||
|
- Separate OAuth fixtures for opaque vs JWT tokens
|
||||||
|
|
||||||
|
### Refactor
|
||||||
|
|
||||||
|
- Update JWT client to use DCR, re-enable tool filtering
|
||||||
|
|
||||||
|
## v0.17.1 (2025-10-20)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- **caldav**: Fix caldav search() due to missing todos
|
||||||
|
|
||||||
|
## v0.17.0 (2025-10-19)
|
||||||
|
|
||||||
|
### Feat
|
||||||
|
|
||||||
|
- **caldav**: Add support for tasks
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- **caldav**: Check that calendar exists after creation to avoid race condition
|
||||||
|
- **caldav**: Properly parse datetimes as vDDDTypes
|
||||||
|
|
||||||
|
### Refactor
|
||||||
|
|
||||||
|
- Migrate from internal CalendarClient to caldav library
|
||||||
|
|
||||||
|
## v0.16.0 (2025-10-19)
|
||||||
|
|
||||||
|
### Feat
|
||||||
|
|
||||||
|
- **webdav**: Add search and list favorite response tools
|
||||||
|
|
||||||
|
### Perf
|
||||||
|
|
||||||
|
- **notes**: Improve notes search performance using async iterators
|
||||||
|
|
||||||
|
## v0.15.2 (2025-10-17)
|
||||||
|
|
||||||
|
### Refactor
|
||||||
|
|
||||||
|
- Unify logging & remove factory deployment
|
||||||
|
|
||||||
|
## v0.15.1 (2025-10-17)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- Increase HTTP client timeout to 30s
|
||||||
|
- Handle RequestError in mcp tools
|
||||||
|
|
||||||
|
## v0.15.0 (2025-10-17)
|
||||||
|
|
||||||
|
### Feat
|
||||||
|
|
||||||
|
- **cookbook**: Add full Cookbook app support with 13 tools and 2 resources
|
||||||
|
|
||||||
|
## v0.14.3 (2025-10-17)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- **deps**: update dependency mcp to >=1.18,<1.19
|
||||||
|
|
||||||
|
## v0.14.2 (2025-10-16)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- **deps**: update dependency pillow to v12
|
||||||
|
|
||||||
|
## v0.14.1 (2025-10-15)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- **oauth**: Remove the option to force_register new clients
|
||||||
|
|
||||||
|
## v0.14.0 (2025-10-15)
|
||||||
|
|
||||||
|
### Feat
|
||||||
|
|
||||||
|
- Add Groups API client
|
||||||
|
- add sharing API client and server tools
|
||||||
|
- **users**: Initialize user API client
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- Update user/groups API to OCS v2
|
||||||
|
|
||||||
## v0.13.0 (2025-10-13)
|
## v0.13.0 (2025-10-13)
|
||||||
|
|
||||||
### Feat
|
### Feat
|
||||||
|
|||||||
@@ -5,20 +5,88 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
## Development Commands
|
## Development Commands
|
||||||
|
|
||||||
### Testing
|
### Testing
|
||||||
|
|
||||||
|
The test suite is organized in layers for fast feedback:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Run all tests
|
# FAST FEEDBACK (recommended for development)
|
||||||
|
# Unit tests only - ~5 seconds
|
||||||
|
uv run pytest tests/unit/ -v
|
||||||
|
|
||||||
|
# Smoke tests - critical path validation - ~30-60 seconds
|
||||||
|
uv run pytest -m smoke -v
|
||||||
|
|
||||||
|
# INTEGRATION TESTS
|
||||||
|
# Integration tests without OAuth - ~2-3 minutes
|
||||||
|
uv run pytest -m "integration and not oauth" -v
|
||||||
|
|
||||||
|
# Full test suite - ~4-5 minutes
|
||||||
uv run pytest
|
uv run pytest
|
||||||
|
|
||||||
# Run integration tests only
|
# OAuth tests only (slowest, requires Playwright) - ~3 minutes
|
||||||
uv run pytest -m integration
|
uv run pytest -m oauth -v
|
||||||
|
|
||||||
|
# COVERAGE
|
||||||
# Run tests with coverage
|
# Run tests with coverage
|
||||||
uv run pytest --cov
|
uv run pytest --cov
|
||||||
|
|
||||||
|
# LEGACY COMMANDS (still work)
|
||||||
|
# Run all integration tests
|
||||||
|
uv run pytest -m integration -v
|
||||||
|
|
||||||
# Skip integration tests
|
# Skip integration tests
|
||||||
uv run pytest -m "not integration"
|
uv run pytest -m "not integration" -v
|
||||||
```
|
```
|
||||||
|
|
||||||
|
! Hint: If the tests are failing due to missing environment variables, then usually the correct .env has not been created or not correctly configured yet.
|
||||||
|
|
||||||
|
### Load Testing
|
||||||
|
```bash
|
||||||
|
# Run benchmark with default settings (10 workers, 30 seconds)
|
||||||
|
uv run python -m tests.load.benchmark
|
||||||
|
|
||||||
|
# Quick test with custom concurrency and duration
|
||||||
|
uv run python -m tests.load.benchmark --concurrency 20 --duration 60
|
||||||
|
|
||||||
|
# Extended load test (50 workers for 5 minutes)
|
||||||
|
uv run python -m tests.load.benchmark -c 50 -d 300
|
||||||
|
|
||||||
|
# Export results to JSON for analysis
|
||||||
|
uv run python -m tests.load.benchmark -c 20 -d 60 --output results.json
|
||||||
|
|
||||||
|
# Test OAuth server on port 8001
|
||||||
|
uv run python -m tests.load.benchmark --url http://127.0.0.1:8001/mcp
|
||||||
|
|
||||||
|
# Verbose mode with detailed logging
|
||||||
|
uv run python -m tests.load.benchmark -c 10 -d 30 --verbose
|
||||||
|
```
|
||||||
|
|
||||||
|
**Load Testing Features:**
|
||||||
|
- **Mixed workload** simulating realistic MCP usage (40% reads, 20% writes, 15% search, 25% other operations)
|
||||||
|
- **Real-time progress** bar with live RPS and error counts
|
||||||
|
- **Detailed metrics**:
|
||||||
|
- Throughput (requests/second)
|
||||||
|
- Latency percentiles (p50, p90, p95, p99)
|
||||||
|
- Per-operation breakdown
|
||||||
|
- Error rates and types
|
||||||
|
- **Automatic cleanup** of test data
|
||||||
|
- **JSON export** for CI/CD integration
|
||||||
|
- **Server health checks** before starting
|
||||||
|
|
||||||
|
**Understanding Results:**
|
||||||
|
- **Requests/Second (RPS)**: Higher is better. Expected baseline: 50-200 RPS for mixed workload
|
||||||
|
- **Latency**:
|
||||||
|
- p50 (median): Should be <100ms for most operations
|
||||||
|
- p95: Should be <500ms
|
||||||
|
- p99: Should be <1000ms
|
||||||
|
- **Error Rate**: Should be <1% under normal load
|
||||||
|
|
||||||
|
**Common Bottlenecks:**
|
||||||
|
1. Nextcloud backend API response times (most common)
|
||||||
|
2. Database connection limits
|
||||||
|
3. HTTP client connection pooling
|
||||||
|
4. Network I/O between containers
|
||||||
|
|
||||||
### Code Quality
|
### Code Quality
|
||||||
```bash
|
```bash
|
||||||
# Format and lint code
|
# Format and lint code
|
||||||
@@ -42,16 +110,18 @@ docker-compose up
|
|||||||
# For basic auth changes (most common) - uses admin credentials
|
# For basic auth changes (most common) - uses admin credentials
|
||||||
docker-compose up --build -d mcp
|
docker-compose up --build -d mcp
|
||||||
|
|
||||||
# For OAuth changes - uses OAuth authentication flow
|
# For OAuth changes - uses OAuth authentication with JWT tokens
|
||||||
docker-compose up --build -d mcp-oauth
|
docker-compose up --build -d mcp-oauth
|
||||||
|
|
||||||
# Build Docker image
|
# Build Docker image
|
||||||
docker build -t nextcloud-mcp-server .
|
docker build -t nextcloud-mcp-server .
|
||||||
```
|
```
|
||||||
|
|
||||||
**Important: Two MCP Server Containers**
|
**Important: MCP Server Containers**
|
||||||
- **`mcp`** (port 8000): Uses basic auth with admin credentials. Use this for most development and testing.
|
- **`mcp`** (port 8000): Uses basic auth with admin credentials. Use this for most development and testing.
|
||||||
- **`mcp-oauth`** (port 8001): Uses OAuth authentication. Only use this when working on OAuth-specific features or tests.
|
- **`mcp-oauth`** (port 8001): Uses OAuth authentication with JWT tokens. Use this when working on OAuth-specific features or tests.
|
||||||
|
- JWT tokens are used for testing (faster validation, scopes embedded in token)
|
||||||
|
- The server can handle both JWT and opaque tokens via the token verifier
|
||||||
|
|
||||||
### Environment Setup
|
### Environment Setup
|
||||||
```bash
|
```bash
|
||||||
@@ -62,6 +132,36 @@ uv sync
|
|||||||
uv sync --group dev
|
uv sync --group dev
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Database Inspection
|
||||||
|
|
||||||
|
**Docker Compose Database Credentials:**
|
||||||
|
- Root user: `root` / password: `password`
|
||||||
|
- App user: `nextcloud` / password: `password`
|
||||||
|
- Database: `nextcloud`
|
||||||
|
|
||||||
|
**Common Database Commands:**
|
||||||
|
```bash
|
||||||
|
# Connect to database as root (most common for inspection)
|
||||||
|
docker compose exec db mariadb -u root -ppassword nextcloud
|
||||||
|
|
||||||
|
# Check OAuth clients
|
||||||
|
docker compose exec db mariadb -u root -ppassword nextcloud -e "SELECT id, name, token_type FROM oc_oidc_clients ORDER BY id DESC LIMIT 10;"
|
||||||
|
|
||||||
|
# Check OAuth client scopes
|
||||||
|
docker compose exec db mariadb -u root -ppassword nextcloud -e "SELECT c.id, c.name, s.scope FROM oc_oidc_clients c LEFT JOIN oc_oidc_client_scopes s ON c.id = s.client_id WHERE c.name LIKE '%MCP%';"
|
||||||
|
|
||||||
|
# Check OAuth access tokens
|
||||||
|
docker compose exec db mariadb -u root -ppassword nextcloud -e "SELECT id, client_id, user_id, created_at FROM oc_oidc_access_tokens ORDER BY created_at DESC LIMIT 10;"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Important Tables:**
|
||||||
|
- `oc_oidc_clients` - OAuth client registrations (DCR clients)
|
||||||
|
- `oc_oidc_client_scopes` - Client allowed scopes
|
||||||
|
- `oc_oidc_access_tokens` - Issued access tokens
|
||||||
|
- `oc_oidc_authorization_codes` - Authorization codes
|
||||||
|
- `oc_oidc_registration_tokens` - RFC 7592 registration tokens for client management
|
||||||
|
- `oc_oidc_redirect_uris` - Redirect URIs for each client
|
||||||
|
|
||||||
## Architecture Overview
|
## Architecture Overview
|
||||||
|
|
||||||
This is a Python MCP (Model Context Protocol) server that provides LLM integration with Nextcloud. The architecture follows a layered pattern:
|
This is a Python MCP (Model Context Protocol) server that provides LLM integration with Nextcloud. The architecture follows a layered pattern:
|
||||||
@@ -89,7 +189,17 @@ Each Nextcloud app has a corresponding server module that:
|
|||||||
### Supported Nextcloud Apps
|
### Supported Nextcloud Apps
|
||||||
|
|
||||||
- **Notes** - Full CRUD operations and search
|
- **Notes** - Full CRUD operations and search
|
||||||
- **Calendar** - CalDAV integration with events, recurring events, attendees
|
- **Calendar** - CalDAV integration with events, recurring events, attendees, and **tasks (VTODO)**
|
||||||
|
- **Calendar Operations**: List, create, delete calendars
|
||||||
|
- **Event Operations**: Full CRUD, recurring events, attendees, reminders, bulk operations
|
||||||
|
- **Task Operations (VTODO)**: Full CRUD for CalDAV tasks with:
|
||||||
|
- Status tracking (NEEDS-ACTION, IN-PROCESS, COMPLETED, CANCELLED)
|
||||||
|
- Priority levels (0-9, 1=highest, 9=lowest)
|
||||||
|
- Due dates, start dates, completion tracking
|
||||||
|
- Percent complete (0-100%)
|
||||||
|
- Categories and filtering
|
||||||
|
- Search across all calendars
|
||||||
|
- **Note**: Calendar implementation uses caldav library's AsyncDavClient
|
||||||
- **Contacts** - CardDAV integration with address book operations
|
- **Contacts** - CardDAV integration with address book operations
|
||||||
- **Tables** - Row-level operations on Nextcloud Tables
|
- **Tables** - Row-level operations on Nextcloud Tables
|
||||||
- **WebDAV** - Complete file system access
|
- **WebDAV** - Complete file system access
|
||||||
@@ -102,11 +212,57 @@ Each Nextcloud app has a corresponding server module that:
|
|||||||
4. **Context injection** - MCP context provides access to the authenticated client instance
|
4. **Context injection** - MCP context provides access to the authenticated client instance
|
||||||
5. **Modular design** - Each Nextcloud app is isolated in its own client/server pair
|
5. **Modular design** - Each Nextcloud app is isolated in its own client/server pair
|
||||||
|
|
||||||
|
### MCP Response Patterns
|
||||||
|
|
||||||
|
**CRITICAL: Never return raw `List[Dict]` from MCP tools - always wrap in Pydantic response models**
|
||||||
|
|
||||||
|
FastMCP serialization issue: raw lists get mangled into dicts with numeric string keys.
|
||||||
|
|
||||||
|
**Pattern:**
|
||||||
|
1. Client methods return `List[Dict]` (raw data)
|
||||||
|
2. MCP tools convert to Pydantic models and wrap in response object
|
||||||
|
3. Response models inherit from `BaseResponse`, include `results` field + metadata
|
||||||
|
|
||||||
|
**Reference implementations:**
|
||||||
|
- `SearchNotesResponse` in `nextcloud_mcp_server/models/notes.py:80`
|
||||||
|
- `SearchFilesResponse` in `nextcloud_mcp_server/models/webdav.py:113`
|
||||||
|
- Tool examples: `nextcloud_mcp_server/server/{notes,webdav}.py`
|
||||||
|
|
||||||
|
**Testing:** Extract `data["results"]` from MCP responses, not `data` directly.
|
||||||
|
|
||||||
### Testing Structure
|
### Testing Structure
|
||||||
|
|
||||||
- **Integration tests** in `tests/integration/` and `tests/client/`, `tests/server/` - Test real Nextcloud API interactions
|
The test suite follows a layered architecture for fast feedback:
|
||||||
- **Fixtures** in `tests/conftest.py` - Shared test setup and utilities
|
|
||||||
- Tests are marked with `@pytest.mark.integration` for selective running
|
```
|
||||||
|
tests/
|
||||||
|
├── unit/ # Fast unit tests (~5s total)
|
||||||
|
│ ├── test_scope_decorator.py
|
||||||
|
│ └── test_response_models.py
|
||||||
|
├── smoke/ # Critical path tests (~30-60s)
|
||||||
|
│ └── test_smoke.py
|
||||||
|
├── integration/
|
||||||
|
│ ├── client/ # Direct API layer tests
|
||||||
|
│ │ ├── notes/
|
||||||
|
│ │ ├── calendar/
|
||||||
|
│ │ └── ...
|
||||||
|
│ └── server/ # MCP tool layer tests
|
||||||
|
│ ├── oauth/ # OAuth-specific tests (slow, ~3min)
|
||||||
|
│ │ ├── test_oauth_core.py
|
||||||
|
│ │ ├── test_scope_authorization.py
|
||||||
|
│ │ └── ...
|
||||||
|
│ ├── test_mcp.py
|
||||||
|
│ └── ...
|
||||||
|
└── load/ # Performance tests
|
||||||
|
```
|
||||||
|
|
||||||
|
**Test Markers:**
|
||||||
|
- `@pytest.mark.unit` - Fast unit tests with mocked dependencies
|
||||||
|
- `@pytest.mark.integration` - Integration tests requiring Docker containers
|
||||||
|
- `@pytest.mark.oauth` - OAuth tests requiring Playwright (slowest)
|
||||||
|
- `@pytest.mark.smoke` - Critical path smoke tests
|
||||||
|
|
||||||
|
**Fixtures** in `tests/conftest.py` - Shared test setup and utilities
|
||||||
- **Important**: Integration tests run against live Docker containers. After making code changes:
|
- **Important**: Integration tests run against live Docker containers. After making code changes:
|
||||||
- For basic auth tests: rebuild with `docker-compose up --build -d mcp`
|
- For basic auth tests: rebuild with `docker-compose up --build -d mcp`
|
||||||
- For OAuth tests: rebuild with `docker-compose up --build -d mcp-oauth`
|
- For OAuth tests: rebuild with `docker-compose up --build -d mcp-oauth`
|
||||||
@@ -126,70 +282,264 @@ Each Nextcloud app has a corresponding server module that:
|
|||||||
- `temporary_addressbook` - Creates and cleans up test address books
|
- `temporary_addressbook` - Creates and cleans up test address books
|
||||||
- `temporary_contact` - Creates and cleans up test contacts
|
- `temporary_contact` - Creates and cleans up test contacts
|
||||||
- **Test specific functionality** after changes:
|
- **Test specific functionality** after changes:
|
||||||
- For Notes changes: `uv run pytest tests/integration/test_mcp.py -k "notes" -v`
|
- For Notes changes: `uv run pytest tests/server/test_mcp.py -k "notes" -v`
|
||||||
- For specific API changes: `uv run pytest tests/integration/test_notes_api.py -v`
|
- For specific API changes: `uv run pytest tests/client/notes/test_notes_api.py -v`
|
||||||
- For OAuth changes: `uv run pytest tests/server/test_oauth*.py -v` (remember to rebuild `mcp-oauth` container)
|
- For OAuth changes: `uv run pytest tests/server/test_oauth*.py -v` (remember to rebuild `mcp-oauth` container)
|
||||||
- **Avoid creating standalone test scripts** - use pytest with proper fixtures instead
|
- **Avoid creating standalone test scripts** - use pytest with proper fixtures instead
|
||||||
|
|
||||||
|
#### Writing Mocked Unit Tests
|
||||||
|
|
||||||
|
For client-layer tests that verify response parsing logic, use mocked HTTP responses instead of real network calls:
|
||||||
|
|
||||||
|
**Pattern:**
|
||||||
|
```python
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
from nextcloud_mcp_server.client.notes import NotesClient
|
||||||
|
from tests.conftest import create_mock_note_response
|
||||||
|
|
||||||
|
async def test_notes_api_get_note(mocker):
|
||||||
|
"""Test that get_note correctly parses the API response."""
|
||||||
|
# Create mock response using helper functions
|
||||||
|
mock_response = create_mock_note_response(
|
||||||
|
note_id=123,
|
||||||
|
title="Test Note",
|
||||||
|
content="Test content",
|
||||||
|
category="Test",
|
||||||
|
etag="abc123",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mock the _make_request method
|
||||||
|
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||||
|
mock_make_request = mocker.patch.object(
|
||||||
|
NotesClient, "_make_request", return_value=mock_response
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create client and test
|
||||||
|
client = NotesClient(mock_client, "testuser")
|
||||||
|
note = await client.get_note(note_id=123)
|
||||||
|
|
||||||
|
# Verify the response was parsed correctly
|
||||||
|
assert note["id"] == 123
|
||||||
|
assert note["title"] == "Test Note"
|
||||||
|
# Verify the correct API endpoint was called
|
||||||
|
mock_make_request.assert_called_once_with("GET", "/apps/notes/api/v1/notes/123")
|
||||||
|
```
|
||||||
|
|
||||||
|
**Mock Response Helpers in `tests/conftest.py`:**
|
||||||
|
- `create_mock_response()` - Generic HTTP response builder
|
||||||
|
- `create_mock_note_response()` - Pre-configured note response
|
||||||
|
- `create_mock_error_response()` - Error responses (404, 412, etc.)
|
||||||
|
|
||||||
|
**Benefits:**
|
||||||
|
- ⚡ Fast execution (~0.1s vs minutes for integration tests)
|
||||||
|
- 🔒 No Docker dependency
|
||||||
|
- 🎯 Tests focus on response parsing logic
|
||||||
|
- ♻️ Repeatable and deterministic
|
||||||
|
|
||||||
|
**When to use:**
|
||||||
|
- Testing client methods that parse JSON responses
|
||||||
|
- Testing error handling (404, 412, etc.)
|
||||||
|
- Testing request parameter building
|
||||||
|
|
||||||
|
**When NOT to use (keep as integration tests):**
|
||||||
|
- Complex protocol interactions (CalDAV, CardDAV, WebDAV)
|
||||||
|
- Multi-component workflows (Notes + WebDAV attachments)
|
||||||
|
- OAuth flows
|
||||||
|
- End-to-end MCP tool testing
|
||||||
|
|
||||||
|
**Reference Implementation:**
|
||||||
|
- See `tests/client/notes/test_notes_api.py` for complete examples
|
||||||
|
- Mark unit tests with `pytestmark = pytest.mark.unit`
|
||||||
|
- Run with: `uv run pytest tests/unit/ tests/client/notes/test_notes_api.py -v`
|
||||||
|
|
||||||
#### OAuth/OIDC Testing
|
#### OAuth/OIDC Testing
|
||||||
OAuth integration tests support both **automated** (Playwright) and **interactive** authentication flows:
|
OAuth integration tests use **automated Playwright browser automation** to complete the OAuth flow programmatically.
|
||||||
|
|
||||||
**Automated Testing (Default - Recommended for CI/CD):**
|
**OAuth Testing Setup:**
|
||||||
- **Default fixtures**: `nc_oauth_client`, `nc_mcp_oauth_client` now use Playwright automation by default
|
- **Main fixtures**: `nc_oauth_client`, `nc_mcp_oauth_client` - Use Playwright automation
|
||||||
- Uses Playwright headless browser automation to complete OAuth flow programmatically
|
- **Shared OAuth Client**: All test users authenticate using a single OAuth client
|
||||||
- **Shared OAuth Client**: All test users authenticate using a single OAuth client (matching MCP server behavior)
|
- **Created fresh for each test session** via Dynamic Client Registration (DCR)
|
||||||
- Single `client_id`/`client_secret` pair is registered and reused for all test users
|
- Matches production MCP server behavior (one client, multiple user tokens)
|
||||||
- Stored in `.nextcloud_oauth_shared_test_client.json` with `force_register=False` for reuse
|
- Each user gets their own unique access token
|
||||||
- Reduces OAuth client registrations and matches production MCP server architecture
|
- **Automatic cleanup**: Client is registered at session start, deleted at session end (RFC 7592)
|
||||||
- All Playwright fixtures: `playwright_oauth_token`, `nc_oauth_client`, `nc_mcp_oauth_client`, `nc_oauth_client_playwright`, `nc_mcp_oauth_client_playwright`
|
- Implementation: `shared_oauth_client_credentials` fixture in `tests/conftest.py`
|
||||||
- Multi-user fixtures: `alice_oauth_token`, `bob_oauth_token`, `charlie_oauth_token`, `diana_oauth_token`
|
- **Note**: Client deletion may fail due to Nextcloud middleware (logged as warning). This doesn't affect tests.
|
||||||
- All use `shared_oauth_client_credentials` fixture for consistent client credentials
|
- **Available fixtures**: `playwright_oauth_token`, `nc_oauth_client`, `nc_mcp_oauth_client`
|
||||||
- Each user gets unique access tokens via same OAuth client (like multiple users using the MCP server)
|
- **Multi-user fixtures**: `alice_oauth_token`, `bob_oauth_token`, `charlie_oauth_token`, `diana_oauth_token`
|
||||||
- Requires: `NEXTCLOUD_HOST`, `NEXTCLOUD_USERNAME`, `NEXTCLOUD_PASSWORD` environment variables
|
- **Requirements**: `NEXTCLOUD_HOST`, `NEXTCLOUD_USERNAME`, `NEXTCLOUD_PASSWORD` environment variables
|
||||||
- Uses `pytest-playwright-asyncio` for async Playwright fixtures
|
- Uses `pytest-playwright-asyncio` for async Playwright fixtures
|
||||||
- Playwright configuration: Use pytest CLI args like `--browser firefox --headed` to customize
|
- **Playwright configuration**: Use pytest CLI args like `--browser firefox --headed` to customize
|
||||||
- Install browsers: `uv run playwright install firefox` (or `chromium`, `webkit`)
|
- **Install browsers**: `uv run playwright install firefox` (or `chromium`, `webkit`)
|
||||||
- Example:
|
|
||||||
```bash
|
|
||||||
# Run all OAuth tests with automated Playwright flow using Firefox
|
|
||||||
uv run pytest tests/server/test_oauth*.py --browser firefox -v
|
|
||||||
|
|
||||||
# Run specific Playwright tests with visible browser for debugging
|
**Example Commands:**
|
||||||
uv run pytest tests/server/test_mcp_oauth.py --browser firefox --headed -v
|
```bash
|
||||||
|
# Run all OAuth tests with Playwright automation using Firefox
|
||||||
|
uv run pytest tests/server/oauth/ --browser firefox -v
|
||||||
|
|
||||||
# Run with Chromium (default)
|
# Run specific OAuth test file with visible browser for debugging
|
||||||
uv run pytest tests/server/test_oauth*.py -v
|
uv run pytest tests/server/oauth/test_oauth_core.py --browser firefox --headed -v
|
||||||
```
|
|
||||||
|
|
||||||
**Interactive Testing (Manual browser login):**
|
# Run with Chromium (default) - use -m oauth marker for all OAuth tests
|
||||||
- Opens system browser and waits for manual login/authorization
|
uv run pytest -m oauth -v
|
||||||
- Fixtures: `interactive_oauth_token`, `nc_oauth_client_interactive`, `nc_mcp_oauth_client_interactive`
|
```
|
||||||
- Requires: User to complete browser-based login when prompted
|
|
||||||
- Useful for: Debugging OAuth flows, testing with 2FA, local development
|
|
||||||
- **Automatically skipped in GitHub Actions CI** - Interactive fixtures check for `GITHUB_ACTIONS` environment variable
|
|
||||||
- Example:
|
|
||||||
```bash
|
|
||||||
# Run OAuth tests with interactive flow (will open browser and wait for manual login)
|
|
||||||
uv run pytest tests/client/test_oauth_interactive.py -v
|
|
||||||
```
|
|
||||||
|
|
||||||
**Test Environment Setup:**
|
**Test Environment:**
|
||||||
- **Two MCP server containers are available:**
|
- **Two MCP server containers are available:**
|
||||||
- `mcp` (port 8000): Uses basic auth with admin credentials - for most testing
|
- `mcp` (port 8000): Uses basic auth with admin credentials - for most testing
|
||||||
- `mcp-oauth` (port 8001): Uses OAuth authentication - for OAuth-specific testing
|
- `mcp-oauth` (port 8001): Uses OAuth authentication - for OAuth-specific testing
|
||||||
- Start OAuth MCP server: `docker-compose up --build -d mcp-oauth`
|
- Start OAuth MCP server: `docker-compose up --build -d mcp-oauth`
|
||||||
- **Important**: When working on OAuth functionality, always rebuild `mcp-oauth` container, not `mcp`
|
- **Important**: When working on OAuth functionality, always rebuild `mcp-oauth` container, not `mcp`
|
||||||
- Shared OAuth client is registered once and reused across test runs
|
|
||||||
- Client credentials cached in `.nextcloud_oauth_shared_test_client.json`
|
|
||||||
|
|
||||||
**CI/CD Considerations:**
|
**CI/CD Notes:**
|
||||||
- Interactive OAuth tests are automatically skipped when `GITHUB_ACTIONS` environment variable is set
|
- Playwright tests run in CI/CD environments
|
||||||
- Automated Playwright tests will run in CI/CD environments
|
|
||||||
- Use Firefox browser in CI: `--browser firefox` (Chromium may have issues with localhost redirects)
|
- Use Firefox browser in CI: `--browser firefox` (Chromium may have issues with localhost redirects)
|
||||||
- Shared client approach reduces test time and API calls to Nextcloud
|
|
||||||
|
#### Keycloak OAuth/OIDC Testing (ADR-002 Integration)
|
||||||
|
|
||||||
|
The MCP server supports using **Keycloak as an external OAuth/OIDC identity provider** instead of Nextcloud's built-in OIDC app. This validates the ADR-002 architecture for background jobs and external identity providers.
|
||||||
|
|
||||||
|
**Architecture:**
|
||||||
|
```
|
||||||
|
MCP Client → Keycloak (OAuth) → MCP Server → Nextcloud user_oidc (validates token) → APIs
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key Benefits:**
|
||||||
|
- ✅ **No admin credentials needed** - All API access uses user's Keycloak token
|
||||||
|
- ✅ **External identity provider** - Demonstrates integration with enterprise IdPs
|
||||||
|
- ✅ **ADR-002 validation** - Tests offline_access and refresh token patterns
|
||||||
|
- ✅ **User provisioning** - Nextcloud automatically provisions users from Keycloak
|
||||||
|
|
||||||
|
**Setup and Testing:**
|
||||||
|
```bash
|
||||||
|
# 1. Start Keycloak and MCP server with Keycloak OAuth
|
||||||
|
docker-compose up -d keycloak app mcp-keycloak
|
||||||
|
|
||||||
|
# 2. Verify Keycloak realm is available
|
||||||
|
curl http://localhost:8888/realms/nextcloud-mcp/.well-known/openid-configuration
|
||||||
|
|
||||||
|
# 3. Verify user_oidc provider is configured
|
||||||
|
docker compose exec app php occ user_oidc:provider keycloak
|
||||||
|
|
||||||
|
# 4. Generate encryption key for refresh token storage (optional, for offline access)
|
||||||
|
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||||
|
# Set in environment: export TOKEN_ENCRYPTION_KEY='<key>'
|
||||||
|
|
||||||
|
# 5. Test OAuth flow manually
|
||||||
|
# Get token from Keycloak:
|
||||||
|
TOKEN=$(curl -s -X POST "http://localhost:8888/realms/nextcloud-mcp/protocol/openid-connect/token" \
|
||||||
|
-d "grant_type=password" \
|
||||||
|
-d "client_id=mcp-client" \
|
||||||
|
-d "client_secret=mcp-secret-change-in-production" \
|
||||||
|
-d "username=admin" \
|
||||||
|
-d "password=admin" \
|
||||||
|
-d "scope=openid profile email offline_access" | jq -r .access_token)
|
||||||
|
|
||||||
|
# Use token with Nextcloud API (validated by user_oidc):
|
||||||
|
curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/ocs/v2.php/cloud/capabilities
|
||||||
|
|
||||||
|
# 6. Connect MCP client
|
||||||
|
# Point client to: http://localhost:8002
|
||||||
|
# Complete OAuth flow using Keycloak credentials: admin/admin
|
||||||
|
```
|
||||||
|
|
||||||
|
**Three MCP Server Containers:**
|
||||||
|
- **`mcp`** (port 8000): Basic auth with admin credentials
|
||||||
|
- **`mcp-oauth`** (port 8001): Nextcloud OIDC provider (JWT tokens)
|
||||||
|
- **`mcp-keycloak`** (port 8002): Keycloak OIDC provider (external IdP)
|
||||||
|
|
||||||
|
**Keycloak Configuration:**
|
||||||
|
- **Realm**: `nextcloud-mcp` (auto-imported from `keycloak/realm-export.json`)
|
||||||
|
- **Client**: `mcp-client` (pre-configured with PKCE, offline_access)
|
||||||
|
- **Admin user**: `admin/admin` (created in realm export)
|
||||||
|
- **Redirect URIs**: `http://localhost:*/callback`, `http://127.0.0.1:*/callback`
|
||||||
|
|
||||||
|
**Environment Variables** (Generic OIDC - works with any provider):
|
||||||
|
```bash
|
||||||
|
# Generic OIDC configuration (provider-agnostic)
|
||||||
|
OIDC_DISCOVERY_URL=http://keycloak:8080/realms/nextcloud-mcp/.well-known/openid-configuration
|
||||||
|
OIDC_CLIENT_ID=nextcloud-mcp-server # OAuth client ID
|
||||||
|
OIDC_CLIENT_SECRET=mcp-secret-... # OAuth client secret
|
||||||
|
|
||||||
|
# Nextcloud API configuration
|
||||||
|
NEXTCLOUD_HOST=http://app:80 # Nextcloud API (token validation in external IdP mode)
|
||||||
|
|
||||||
|
# Refresh tokens and token exchange (ADR-002)
|
||||||
|
ENABLE_OFFLINE_ACCESS=true # Enable refresh tokens
|
||||||
|
TOKEN_ENCRYPTION_KEY=<fernet-key> # Encrypt refresh tokens
|
||||||
|
TOKEN_STORAGE_DB=/app/data/tokens.db # Token storage path
|
||||||
|
|
||||||
|
# OAuth scopes (optional - uses defaults if not specified)
|
||||||
|
NEXTCLOUD_OIDC_SCOPES=openid profile email offline_access notes:read notes:write ...
|
||||||
|
```
|
||||||
|
|
||||||
|
**Provider Mode Detection:**
|
||||||
|
- **External IdP mode**: If `OIDC_DISCOVERY_URL` issuer ≠ `NEXTCLOUD_HOST` → Uses external provider (Keycloak, Auth0, Okta, etc.)
|
||||||
|
- **Integrated mode**: If `OIDC_DISCOVERY_URL` not set or issuer = `NEXTCLOUD_HOST` → Uses Nextcloud OIDC app
|
||||||
|
|
||||||
|
**Nextcloud user_oidc Configuration:**
|
||||||
|
The `user_oidc` app is automatically configured by `app-hooks/post-installation/15-setup-keycloak-provider.sh`:
|
||||||
|
```bash
|
||||||
|
# Configured with:
|
||||||
|
--check-bearer=1 # Validate bearer tokens
|
||||||
|
--bearer-provisioning=1 # Auto-provision users
|
||||||
|
--unique-uid=1 # Hash user IDs
|
||||||
|
--scope="openid profile email offline_access"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Troubleshooting:**
|
||||||
|
```bash
|
||||||
|
# Check Keycloak is running
|
||||||
|
docker-compose ps keycloak
|
||||||
|
docker-compose logs keycloak
|
||||||
|
|
||||||
|
# Check user_oidc provider configuration
|
||||||
|
docker compose exec app php occ user_oidc:provider keycloak
|
||||||
|
|
||||||
|
# Check MCP server logs
|
||||||
|
docker-compose logs -f mcp-keycloak
|
||||||
|
|
||||||
|
# Check Nextcloud logs for token validation
|
||||||
|
docker compose exec app tail -f /var/www/html/data/nextcloud.log
|
||||||
|
|
||||||
|
# Verify Keycloak is accessible from Nextcloud container
|
||||||
|
docker compose exec app curl http://keycloak:8080/realms/nextcloud-mcp/.well-known/openid-configuration
|
||||||
|
```
|
||||||
|
|
||||||
|
**ADR-002 Offline Access Testing:**
|
||||||
|
The Keycloak integration enables testing ADR-002's primary authentication pattern (offline access with refresh tokens):
|
||||||
|
|
||||||
|
1. **Refresh token storage**: Tokens stored encrypted in SQLite (`/app/data/tokens.db`)
|
||||||
|
2. **Token refresh**: Access tokens refreshed automatically when expired
|
||||||
|
3. **Background workers**: Can access APIs using stored refresh tokens
|
||||||
|
4. **No admin credentials**: All operations use user's OAuth tokens
|
||||||
|
|
||||||
|
**Note**: Service account tokens (client_credentials grant) were considered but rejected as they create Nextcloud user accounts and violate OAuth "act on-behalf-of" principles. See ADR-002 "Will Not Implement" section.
|
||||||
|
|
||||||
|
See `docs/ADR-002-vector-sync-authentication.md` for architectural details.
|
||||||
|
|
||||||
|
**Audience Validation:**
|
||||||
|
Tokens include `aud: ["mcp-server", "nextcloud"]` claims for proper security:
|
||||||
|
- MCP server validates tokens are intended for it
|
||||||
|
- Nextcloud validates tokens include it as audience
|
||||||
|
- Prevents token misuse across services
|
||||||
|
|
||||||
|
See `docs/audience-validation-setup.md` for configuration details and `docs/keycloak-multi-client-validation.md` for realm-level validation behavior.
|
||||||
|
|
||||||
### Configuration Files
|
### Configuration Files
|
||||||
|
|
||||||
- **`pyproject.toml`** - Python project configuration using uv for dependency management
|
- **`pyproject.toml`** - Python project configuration using uv for dependency management
|
||||||
- **`.env`** (from `env.sample`) - Environment variables for Nextcloud connection
|
- **`.env`** (from `env.sample`) - Environment variables for Nextcloud connection
|
||||||
- **`docker-compose.yml`** - Complete development environment with Nextcloud + database
|
- **`docker-compose.yml`** - Complete development environment with Nextcloud + database
|
||||||
|
|
||||||
|
## Integration testing with docker
|
||||||
|
|
||||||
|
### Nextcloud
|
||||||
|
|
||||||
|
- The `app` container is running nextcloud.
|
||||||
|
- Use `docker compose exec app php occ ...` to get a list of available commands
|
||||||
|
|
||||||
|
### Mariadb
|
||||||
|
|
||||||
|
- The `db` container is running mariadb
|
||||||
|
- Use `docker compose exec db mariadb -u [user] -p [password] [database]` to execute queries. Check the docker-compose file for credentials
|
||||||
|
|||||||
+4
-1
@@ -1,4 +1,7 @@
|
|||||||
FROM ghcr.io/astral-sh/uv:0.9.2-python3.11-alpine@sha256:59c7cb3e4a4fe9ccff6a5bf0d952a0b1b0101adda48e305c02beea3c22256208
|
FROM ghcr.io/astral-sh/uv:0.9.7-python3.11-alpine@sha256:0006b77df7ebf46e68959fdc8d3af9d19f1adfae8c2e7e77907ad257e5d05be4
|
||||||
|
|
||||||
|
# Install git (required for caldav dependency from git)
|
||||||
|
RUN apk add --no-cache git
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|||||||
@@ -6,19 +6,37 @@
|
|||||||
|
|
||||||
The Nextcloud MCP (Model Context Protocol) server allows Large Language Models like Claude, GPT, and Gemini to interact with your Nextcloud data through a secure API. Create notes, manage calendars, organize contacts, work with files, and more - all through natural language.
|
The Nextcloud MCP (Model Context Protocol) server allows Large Language Models like Claude, GPT, and Gemini to interact with your Nextcloud data through a secure API. Create notes, manage calendars, organize contacts, work with files, and more - all through natural language.
|
||||||
|
|
||||||
## Features
|
> [!NOTE]
|
||||||
|
> **Nextcloud has two ways to enable AI access:** Nextcloud provides [Context Agent](https://github.com/nextcloud/context_agent), an AI agent backend that powers the [Assistant](https://github.com/nextcloud/assistant) app and allows AI to interact with Nextcloud apps like Calendar, Talk, and Contacts. Context Agent runs as an ExApp inside Nextcloud and also _[exposes an MCP server](https://docs.nextcloud.com/server/stable/admin_manual/ai/app_context_agent.html#using-nextcloud-mcp-server)_ for external MCP clients.
|
||||||
|
>
|
||||||
|
> This project (Nextcloud MCP Server) is a **dedicated standalone MCP server** designed specifically for external MCP clients like Claude Code and IDEs, with deep CRUD operations and OAuth support. It does not require any additional AI-features to be enabled in Nextcloud beyond the apps that you intend to interact with.
|
||||||
|
|
||||||
### Supported Nextcloud Apps
|
### High-level Comparison: Nextcloud MCP Server vs. Nextcloud AI Stack
|
||||||
|
|
||||||
| App | Support | Features |
|
| Aspect | **Nextcloud MCP Server**<br/>(This Project) | **Nextcloud AI Stack**<br/>(Assistant + Context Agent) |
|
||||||
|-----|---------|----------|
|
|--------|---------------------------------------------|--------------------------------------------------------|
|
||||||
| **Notes** | ✅ Full | Create, read, update, delete, search notes. Handle attachments. |
|
| **Purpose** | External MCP client access to Nextcloud | AI assistance within Nextcloud UI |
|
||||||
| **Calendar** | ✅ Full | Manage events, recurring events, reminders, attendees via CalDAV. |
|
| **Deployment** | Standalone (Docker, VM, K8s) | Inside Nextcloud (ExApp via AppAPI) |
|
||||||
| **Contacts** | ✅ Full | CRUD operations for contacts and address books via CardDAV. |
|
| **Primary Users** | Claude Code, IDEs, external developers | Nextcloud end users via Assistant app |
|
||||||
| **Files (WebDAV)** | ✅ Full | Complete file system access - browse, read, write, organize files. |
|
| **Authentication** | OAuth2/OIDC or Basic Auth | Session-based (integrated) |
|
||||||
| **Deck** | ✅ Full | Project management - boards, stacks, cards, labels, assignments. |
|
| **Notes Support** | ✅ Full CRUD + search (7 tools) | ❌ Not implemented |
|
||||||
| **Tables** | ⚠️ Partial | Row-level operations. Table management not yet supported. |
|
| **Calendar** | ✅ Full CalDAV + tasks (20+ tools) | ✅ Events, free/busy, tasks (4 tools) |
|
||||||
| **Tasks** | ❌ Planned | [Issue #73](https://github.com/cbcoutinho/nextcloud-mcp-server/issues/73) |
|
| **Contacts** | ✅ Full CardDAV (8 tools) | ✅ Find person, current user (2 tools) |
|
||||||
|
| **Files (WebDAV)** | ✅ Full filesystem access (12 tools) | ✅ Read, folder tree, sharing (3 tools) |
|
||||||
|
| **Document Processing** | ✅ OCR with progress (PDF, DOCX, images) | ❌ Not implemented |
|
||||||
|
| **Deck** | ✅ Full project management (15 tools) | ✅ Basic board/card ops (2 tools) |
|
||||||
|
| **Tables** | ✅ Row operations (5 tools) | ❌ Not implemented |
|
||||||
|
| **Cookbook** | ✅ Full recipe management (13 tools) | ❌ Not implemented |
|
||||||
|
| **Talk** | ❌ Not implemented | ✅ Messages, conversations (4 tools) |
|
||||||
|
| **Mail** | ❌ Not implemented | ✅ Send email (2 tools) |
|
||||||
|
| **AI Features** | ❌ Not implemented | ✅ Image gen, transcription, doc gen (4 tools) |
|
||||||
|
| **Web/Maps** | ❌ Not implemented | ✅ Search, weather, transit (5 tools) |
|
||||||
|
| **MCP Resources** | ✅ Structured data URIs | ❌ Not supported |
|
||||||
|
| **External MCP** | ❌ Pure server | ✅ Consumes external MCP servers |
|
||||||
|
| **Safety Model** | Client-controlled | Built-in safe/dangerous distinction |
|
||||||
|
| **Best For** | • Deep CRUD operations<br/>• External integrations<br/>• OAuth security<br/>• IDE/editor integration | • AI-driven actions in Nextcloud UI<br/>• Multi-service orchestration<br/>• User task automation<br/>• MCP aggregation hub |
|
||||||
|
|
||||||
|
See our [detailed comparison](docs/comparison-context-agent.md) for architecture diagrams, workflow examples, and guidance on when to use each approach.
|
||||||
|
|
||||||
Want to see another Nextcloud app supported? [Open an issue](https://github.com/cbcoutinho/nextcloud-mcp-server/issues) or contribute a pull request!
|
Want to see another Nextcloud app supported? [Open an issue](https://github.com/cbcoutinho/nextcloud-mcp-server/issues) or contribute a pull request!
|
||||||
|
|
||||||
@@ -26,8 +44,17 @@ Want to see another Nextcloud app supported? [Open an issue](https://github.com/
|
|||||||
|
|
||||||
| Mode | Security | Best For |
|
| Mode | Security | Best For |
|
||||||
|------|----------|----------|
|
|------|----------|----------|
|
||||||
| **OAuth2/OIDC** ✅ | 🔒 High | Production, multi-user deployments |
|
| **OAuth2/OIDC** ⚠️ **Experimental** | 🔒 High | Testing, evaluation (requires patch for app-specific APIs) |
|
||||||
| **Basic Auth** ⚠️ | Lower | Development, testing |
|
| **Basic Auth** ✅ | Lower | Development, testing, production |
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> **OAuth is experimental** and requires a manual patch to the `user_oidc` app for full functionality:
|
||||||
|
> - **Required patch**: `user_oidc` app needs modifications for Bearer token support ([issue #1221](https://github.com/nextcloud/user_oidc/issues/1221))
|
||||||
|
> - **Impact**: Without the patch, most app-specific APIs (Notes, Calendar, Contacts, Deck, etc.) will fail with 401 errors
|
||||||
|
> - **What works without patches**: OAuth flow, PKCE support (with `oidc` v1.10.0+), OCS APIs
|
||||||
|
> - **Production use**: Wait for upstream patch to be merged into official releases
|
||||||
|
>
|
||||||
|
> See [OAuth Upstream Status](docs/oauth-upstream-status.md) for detailed information on required patches and workarounds.
|
||||||
|
|
||||||
OAuth2/OIDC provides secure, per-user authentication with access tokens. See [Authentication Guide](docs/authentication.md) for details.
|
OAuth2/OIDC provides secure, per-user authentication with access tokens. See [Authentication Guide](docs/authentication.md) for details.
|
||||||
|
|
||||||
@@ -45,9 +72,17 @@ uv sync
|
|||||||
|
|
||||||
# Or using Docker
|
# Or using Docker
|
||||||
docker pull ghcr.io/cbcoutinho/nextcloud-mcp-server:latest
|
docker pull ghcr.io/cbcoutinho/nextcloud-mcp-server:latest
|
||||||
|
|
||||||
|
# Or deploy to Kubernetes with Helm
|
||||||
|
helm repo add nextcloud-mcp https://cbcoutinho.github.io/nextcloud-mcp-server
|
||||||
|
helm repo update
|
||||||
|
helm install nextcloud-mcp nextcloud-mcp/nextcloud-mcp-server \
|
||||||
|
--set nextcloud.host=https://cloud.example.com \
|
||||||
|
--set auth.basic.username=myuser \
|
||||||
|
--set auth.basic.password=mypassword
|
||||||
```
|
```
|
||||||
|
|
||||||
See [Installation Guide](docs/installation.md) for detailed instructions.
|
See [Installation Guide](docs/installation.md) for detailed instructions, or [Helm Chart README](charts/nextcloud-mcp-server/README.md) for Kubernetes deployment.
|
||||||
|
|
||||||
### 2. Configure
|
### 2. Configure
|
||||||
|
|
||||||
@@ -58,29 +93,35 @@ Create a `.env` file:
|
|||||||
cp env.sample .env
|
cp env.sample .env
|
||||||
```
|
```
|
||||||
|
|
||||||
**For OAuth (recommended):**
|
**For Basic Auth (recommended for most users):**
|
||||||
```dotenv
|
|
||||||
NEXTCLOUD_HOST=https://your.nextcloud.instance.com
|
|
||||||
```
|
|
||||||
|
|
||||||
**For Basic Auth:**
|
|
||||||
```dotenv
|
```dotenv
|
||||||
NEXTCLOUD_HOST=https://your.nextcloud.instance.com
|
NEXTCLOUD_HOST=https://your.nextcloud.instance.com
|
||||||
NEXTCLOUD_USERNAME=your_username
|
NEXTCLOUD_USERNAME=your_username
|
||||||
NEXTCLOUD_PASSWORD=your_app_password
|
NEXTCLOUD_PASSWORD=your_app_password
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**For OAuth (experimental - requires patches):**
|
||||||
|
```dotenv
|
||||||
|
NEXTCLOUD_HOST=https://your.nextcloud.instance.com
|
||||||
|
```
|
||||||
|
|
||||||
See [Configuration Guide](docs/configuration.md) for all options.
|
See [Configuration Guide](docs/configuration.md) for all options.
|
||||||
|
|
||||||
### 3. Set Up Authentication
|
### 3. Set Up Authentication
|
||||||
|
|
||||||
**OAuth Setup (recommended):**
|
**Basic Auth Setup (recommended):**
|
||||||
1. Install Nextcloud OIDC apps (`oidc` + `user_oidc`)
|
1. Create an app password in Nextcloud (Settings → Security → Devices & sessions)
|
||||||
2. Enable dynamic client registration
|
2. Add credentials to `.env` file
|
||||||
3. Configure Bearer token validation
|
3. Start the server
|
||||||
4. Start the server
|
|
||||||
|
|
||||||
See [OAuth Quick Start](docs/quickstart-oauth.md) for 5-minute setup or [OAuth Setup Guide](docs/oauth-setup.md) for production deployment.
|
**OAuth Setup (experimental):**
|
||||||
|
1. Install Nextcloud OIDC apps (`oidc` v1.10.0+ + `user_oidc`)
|
||||||
|
2. **Apply required patch** to `user_oidc` app for Bearer token support (see [OAuth Upstream Status](docs/oauth-upstream-status.md))
|
||||||
|
3. Enable dynamic client registration or create an OIDC client with id & secret
|
||||||
|
4. Configure Bearer token validation in `user_oidc`
|
||||||
|
5. Start the server
|
||||||
|
|
||||||
|
See [OAuth Quick Start](docs/quickstart-oauth.md) for 5-minute setup or [OAuth Setup Guide](docs/oauth-setup.md) for detailed instructions.
|
||||||
|
|
||||||
### 4. Run the Server
|
### 4. Run the Server
|
||||||
|
|
||||||
@@ -88,12 +129,15 @@ See [OAuth Quick Start](docs/quickstart-oauth.md) for 5-minute setup or [OAuth S
|
|||||||
# Load environment variables
|
# Load environment variables
|
||||||
export $(grep -v '^#' .env | xargs)
|
export $(grep -v '^#' .env | xargs)
|
||||||
|
|
||||||
# Start the server
|
# Start with Basic Auth (default)
|
||||||
|
uv run nextcloud-mcp-server
|
||||||
|
|
||||||
|
# Or start with OAuth (experimental - requires patches)
|
||||||
uv run nextcloud-mcp-server --oauth
|
uv run nextcloud-mcp-server --oauth
|
||||||
|
|
||||||
# Or with Docker
|
# Or with Docker
|
||||||
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
|
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
|
||||||
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest --oauth
|
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
The server starts on `http://127.0.0.1:8000` by default.
|
The server starts on `http://127.0.0.1:8000` by default.
|
||||||
@@ -120,12 +164,15 @@ Or connect from:
|
|||||||
- **[Authentication](docs/authentication.md)** - OAuth vs BasicAuth
|
- **[Authentication](docs/authentication.md)** - OAuth vs BasicAuth
|
||||||
- **[Running the Server](docs/running.md)** - Start and manage the server
|
- **[Running the Server](docs/running.md)** - Start and manage the server
|
||||||
|
|
||||||
### OAuth Documentation
|
### Architecture
|
||||||
|
- **[Comparison with Context Agent](docs/comparison-context-agent.md)** - How this MCP server differs from Nextcloud's Context Agent
|
||||||
|
|
||||||
|
### OAuth Documentation (Experimental)
|
||||||
- **[OAuth Quick Start](docs/quickstart-oauth.md)** - 5-minute setup guide
|
- **[OAuth Quick Start](docs/quickstart-oauth.md)** - 5-minute setup guide
|
||||||
- **[OAuth Setup Guide](docs/oauth-setup.md)** - Production deployment
|
- **[OAuth Setup Guide](docs/oauth-setup.md)** - Detailed setup instructions
|
||||||
- **[OAuth Architecture](docs/oauth-architecture.md)** - How OAuth works
|
- **[OAuth Architecture](docs/oauth-architecture.md)** - How OAuth works
|
||||||
- **[OAuth Troubleshooting](docs/oauth-troubleshooting.md)** - OAuth-specific issues
|
- **[OAuth Troubleshooting](docs/oauth-troubleshooting.md)** - OAuth-specific issues
|
||||||
- **[Upstream Status](docs/oauth-upstream-status.md)** - Required patches and PRs
|
- **[Upstream Status](docs/oauth-upstream-status.md)** - **Required patches and PRs** ⚠️
|
||||||
|
|
||||||
### Reference
|
### Reference
|
||||||
- **[Troubleshooting](docs/troubleshooting.md)** - Common issues and solutions
|
- **[Troubleshooting](docs/troubleshooting.md)** - Common issues and solutions
|
||||||
@@ -134,6 +181,7 @@ Or connect from:
|
|||||||
- [Notes API](docs/notes.md)
|
- [Notes API](docs/notes.md)
|
||||||
- [Calendar (CalDAV)](docs/calendar.md)
|
- [Calendar (CalDAV)](docs/calendar.md)
|
||||||
- [Contacts (CardDAV)](docs/contacts.md)
|
- [Contacts (CardDAV)](docs/contacts.md)
|
||||||
|
- [Cookbook](docs/cookbook.md)
|
||||||
- [Deck](docs/deck.md)
|
- [Deck](docs/deck.md)
|
||||||
- [Tables](docs/table.md)
|
- [Tables](docs/table.md)
|
||||||
- [WebDAV](docs/webdav.md)
|
- [WebDAV](docs/webdav.md)
|
||||||
@@ -143,16 +191,90 @@ Or connect from:
|
|||||||
The server exposes Nextcloud functionality through MCP tools (for actions) and resources (for data browsing).
|
The server exposes Nextcloud functionality through MCP tools (for actions) and resources (for data browsing).
|
||||||
|
|
||||||
### Tools
|
### Tools
|
||||||
Tools enable AI assistants to perform actions:
|
|
||||||
|
The server provides 90+ tools across 8 Nextcloud apps. When using OAuth, tools are dynamically filtered based on your granted scopes.
|
||||||
|
|
||||||
|
For a complete list of all supported OAuth scopes and their descriptions, see [OAuth Scopes Documentation](docs/oauth-architecture.md#oauth-scopes).
|
||||||
|
|
||||||
|
#### Available Tool Categories
|
||||||
|
|
||||||
|
| App | Tools | Read Scope | Write Scope | Operations |
|
||||||
|
|-----|-------|-----------|-------------|------------|
|
||||||
|
| **Notes** | 7 | `notes:read` | `notes:write` | Create, read, update, delete, search notes |
|
||||||
|
| **Calendar** | 20+ | `calendar:read` `todo:read` | `calendar:write` `todo:write` | Events, todos (tasks), calendars, recurring events, attendees |
|
||||||
|
| **Contacts** | 8 | `contacts:read` | `contacts:write` | Create, read, update, delete contacts and address books |
|
||||||
|
| **Files (WebDAV)** | 12 | `files:read` | `files:write` | List, read, upload, delete, move files; **OCR/document processing** |
|
||||||
|
| **Deck** | 15 | `deck:read` | `deck:write` | Boards, stacks, cards, labels, assignments |
|
||||||
|
| **Cookbook** | 13 | `cookbook:read` | `cookbook:write` | Recipes, import from URLs, search, categories |
|
||||||
|
| **Tables** | 5 | `tables:read` | `tables:write` | Row operations on Nextcloud Tables |
|
||||||
|
| **Sharing** | 10+ | `sharing:read` | `sharing:write` | Create, manage, delete shares |
|
||||||
|
|
||||||
|
#### Document Processing (Optional)
|
||||||
|
|
||||||
|
The WebDAV file reading tool (`nc_webdav_read_file`) supports **automatic text extraction** from documents and images:
|
||||||
|
|
||||||
|
**Supported Formats:**
|
||||||
|
- **Documents**: PDF, DOCX, PPTX, XLSX, RTF, ODT, EPUB
|
||||||
|
- **Images**: PNG, JPEG, TIFF, BMP (with OCR)
|
||||||
|
- **Email**: EML, MSG files
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
- **Progress Notifications**: Long-running OCR operations (up to 120s) send progress updates every 10 seconds to prevent client timeouts
|
||||||
|
- **Pluggable Architecture**: Multiple processor backends (Unstructured.io, Tesseract, custom HTTP APIs)
|
||||||
|
- **Automatic Detection**: Files are processed based on MIME type
|
||||||
|
- **Graceful Fallback**: Returns base64-encoded content if processing fails
|
||||||
|
|
||||||
|
**Configuration:**
|
||||||
|
```dotenv
|
||||||
|
# Enable document processing (optional)
|
||||||
|
ENABLE_DOCUMENT_PROCESSING=true
|
||||||
|
|
||||||
|
# Unstructured.io processor (cloud/API-based, supports many formats)
|
||||||
|
ENABLE_UNSTRUCTURED=true
|
||||||
|
UNSTRUCTURED_API_URL=http://localhost:8002
|
||||||
|
UNSTRUCTURED_STRATEGY=auto # auto, fast, or hi_res
|
||||||
|
UNSTRUCTURED_LANGUAGES=eng,deu
|
||||||
|
PROGRESS_INTERVAL=10 # Progress update interval in seconds
|
||||||
|
|
||||||
|
# Tesseract processor (local OCR, images only)
|
||||||
|
ENABLE_TESSERACT=false
|
||||||
|
TESSERACT_LANG=eng
|
||||||
|
|
||||||
|
# Custom HTTP processor
|
||||||
|
ENABLE_CUSTOM_PROCESSOR=false
|
||||||
|
CUSTOM_PROCESSOR_URL=http://localhost:9000/process
|
||||||
|
CUSTOM_PROCESSOR_TYPES=application/pdf,image/jpeg
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example Usage:**
|
||||||
|
```
|
||||||
|
AI: "Read the contents of Documents/report.pdf"
|
||||||
|
→ Uses nc_webdav_read_file tool with automatic OCR processing
|
||||||
|
→ Returns extracted text with parsing metadata
|
||||||
|
→ Sends progress updates during long operations
|
||||||
|
```
|
||||||
|
|
||||||
|
See [env.sample](env.sample) for complete configuration options.
|
||||||
|
|
||||||
|
**Example Tools:**
|
||||||
- `nc_notes_create_note` - Create a new note
|
- `nc_notes_create_note` - Create a new note
|
||||||
|
- `nc_cookbook_import_recipe` - Import recipes from URLs with schema.org metadata
|
||||||
- `deck_create_card` - Create a Deck card
|
- `deck_create_card` - Create a Deck card
|
||||||
- `nc_calendar_create_event` - Create a calendar event
|
- `nc_calendar_create_event` - Create a calendar event
|
||||||
|
- `nc_calendar_create_todo` - Create a CalDAV task/todo
|
||||||
- `nc_contacts_create_contact` - Create a contact
|
- `nc_contacts_create_contact` - Create a contact
|
||||||
- And many more...
|
- `nc_webdav_upload_file` - Upload a file to Nextcloud
|
||||||
|
- And 80+ more...
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> **OAuth Scope Filtering**: When connecting via OAuth, MCP clients will only see tools for which you've granted access. For example, granting only `notes:read` and `notes:write` will show 7 Notes tools instead of all 90+ tools. See [OAuth Scopes Documentation](docs/oauth-architecture.md#oauth-scopes) for the complete scope reference, or [OAuth Troubleshooting - Limited Scopes](docs/oauth-troubleshooting.md#limited-scopes---only-seeing-notes-tools) if you're only seeing a subset of tools.
|
||||||
|
>
|
||||||
|
> **Known Issue**: Claude Code and some other MCP clients may only request/grant Notes scopes during initial connection. Track progress at [#234](https://github.com/cbcoutinho/nextcloud-mcp-server/issues/234).
|
||||||
|
|
||||||
### Resources
|
### Resources
|
||||||
Resources provide read-only access to Nextcloud data:
|
Resources provide read-only access to Nextcloud data:
|
||||||
- `nc://capabilities` - Server capabilities
|
- `nc://capabilities` - Server capabilities
|
||||||
|
- `cookbook://version` - Cookbook app version info
|
||||||
- `nc://Deck/boards/{board_id}` - Deck board data
|
- `nc://Deck/boards/{board_id}` - Deck board data
|
||||||
- `notes://settings` - Notes app settings
|
- `notes://settings` - Notes app settings
|
||||||
- And more...
|
- And more...
|
||||||
@@ -167,6 +289,12 @@ AI: "Create a note called 'Meeting Notes' with today's agenda"
|
|||||||
→ Uses nc_notes_create_note tool
|
→ Uses nc_notes_create_note tool
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Manage Recipes
|
||||||
|
```
|
||||||
|
AI: "Import the recipe from this URL: https://www.example.com/recipe/chocolate-cake"
|
||||||
|
→ Uses nc_cookbook_import_recipe tool to extract schema.org metadata
|
||||||
|
```
|
||||||
|
|
||||||
### Manage Calendar
|
### Manage Calendar
|
||||||
```
|
```
|
||||||
AI: "Schedule a team meeting for next Tuesday at 2pm"
|
AI: "Schedule a team meeting for next Tuesday at 2pm"
|
||||||
@@ -214,7 +342,8 @@ Contributions are welcome!
|
|||||||
[](https://mseep.ai/app/cbcoutinho-nextcloud-mcp-server)
|
[](https://mseep.ai/app/cbcoutinho-nextcloud-mcp-server)
|
||||||
|
|
||||||
This project takes security seriously:
|
This project takes security seriously:
|
||||||
- OAuth2/OIDC support for secure authentication
|
- OAuth2/OIDC support (experimental - requires upstream patches)
|
||||||
|
- Basic Auth with app-specific passwords (recommended)
|
||||||
- No credential storage with OAuth mode
|
- No credential storage with OAuth mode
|
||||||
- Per-user access tokens
|
- Per-user access tokens
|
||||||
- Regular security assessments
|
- Regular security assessments
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
diff --git a/lib/private/AppFramework/Middleware/Security/CORSMiddleware.php b/lib/private/AppFramework/Middleware/Security/CORSMiddleware.php
|
||||||
|
index 4453f5a7d4b..f1ca9b48d21 100644
|
||||||
|
--- a/lib/private/AppFramework/Middleware/Security/CORSMiddleware.php
|
||||||
|
+++ b/lib/private/AppFramework/Middleware/Security/CORSMiddleware.php
|
||||||
|
@@ -73,6 +73,13 @@ class CORSMiddleware extends Middleware {
|
||||||
|
$user = array_key_exists('PHP_AUTH_USER', $this->request->server) ? $this->request->server['PHP_AUTH_USER'] : null;
|
||||||
|
$pass = array_key_exists('PHP_AUTH_PW', $this->request->server) ? $this->request->server['PHP_AUTH_PW'] : null;
|
||||||
|
|
||||||
|
+ // Allow Bearer token authentication for CORS requests
|
||||||
|
+ // Bearer tokens are stateless and don't require CSRF protection
|
||||||
|
+ $authorizationHeader = $this->request->getHeader('Authorization');
|
||||||
|
+ if (!empty($authorizationHeader) && str_starts_with($authorizationHeader, 'Bearer ')) {
|
||||||
|
+ return;
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
// Allow to use the current session if a CSRF token is provided
|
||||||
|
if ($this->request->passesCSRFCheck()) {
|
||||||
|
return;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
set -euox pipefail
|
||||||
|
|
||||||
|
php /var/www/html/occ config:system:set trusted_domains 2 --value=host.docker.internal
|
||||||
-16
@@ -1,16 +0,0 @@
|
|||||||
diff --git a/lib/Util/DiscoveryGenerator.php b/lib/Util/DiscoveryGenerator.php
|
|
||||||
index ee3cd57..6429f94 100644
|
|
||||||
--- a/lib/Util/DiscoveryGenerator.php
|
|
||||||
+++ b/lib/Util/DiscoveryGenerator.php
|
|
||||||
@@ -171,6 +171,11 @@ class DiscoveryGenerator
|
|
||||||
$discoveryPayload['registration_endpoint'] = $host . $this->urlGenerator->linkToRoute('oidc.DynamicRegistration.registerClient', []);
|
|
||||||
}
|
|
||||||
|
|
||||||
+ // Add PKCE support if enabled
|
|
||||||
+ if ($this->appConfig->getAppValueBool('proof_key_for_code_exchange', false)) {
|
|
||||||
+ $discoveryPayload['code_challenge_methods_supported'] = ['S256'];
|
|
||||||
+ }
|
|
||||||
+
|
|
||||||
$this->logger->info('Request to Discovery Endpoint.');
|
|
||||||
|
|
||||||
$response = new JSONResponse($discoveryPayload);
|
|
||||||
+5
-1
@@ -6,14 +6,18 @@ echo "Installing and configuring Calendar app..."
|
|||||||
|
|
||||||
# Enable calendar app
|
# Enable calendar app
|
||||||
php /var/www/html/occ app:enable calendar
|
php /var/www/html/occ app:enable calendar
|
||||||
|
php /var/www/html/occ app:enable tasks
|
||||||
|
|
||||||
# Wait for calendar app to be fully initialized
|
# Wait for calendar app to be fully initialized
|
||||||
echo "Waiting for calendar app to initialize..."
|
echo "Waiting for calendar app to initialize..."
|
||||||
sleep 5
|
sleep 5
|
||||||
|
|
||||||
# Increase limits on calendar creation for integration tests (100 in 60s)
|
# Disable rate limits on calendar creation for integration tests
|
||||||
|
# Set to -1 to completely disable rate limiting
|
||||||
|
# Reference: https://docs.nextcloud.com/server/stable/admin_manual/groupware/calendar.html#rate-limits
|
||||||
php occ config:app:set dav rateLimitCalendarCreation --type=integer --value=100
|
php occ config:app:set dav rateLimitCalendarCreation --type=integer --value=100
|
||||||
php occ config:app:set dav rateLimitPeriodCalendarCreation --type=integer --value=60
|
php occ config:app:set dav rateLimitPeriodCalendarCreation --type=integer --value=60
|
||||||
|
php occ config:app:set dav maximumCalendarsSubscriptions --type=integer --value=-1
|
||||||
|
|
||||||
# Ensure maintenance mode is off before calendar operations
|
# Ensure maintenance mode is off before calendar operations
|
||||||
php /var/www/html/occ maintenance:mode --off
|
php /var/www/html/occ maintenance:mode --off
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
set -euox pipefail
|
||||||
|
|
||||||
|
php /var/www/html/occ app:enable cookbook
|
||||||
+38
@@ -0,0 +1,38 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
set -euox pipefail
|
||||||
|
|
||||||
|
echo "Installing and configuring OIDC app for testing..."
|
||||||
|
|
||||||
|
# Check if development OIDC app is mounted at /opt/apps/oidc
|
||||||
|
if [ -d /opt/apps/oidc ]; then
|
||||||
|
echo "Development OIDC app found at /opt/apps/oidc"
|
||||||
|
|
||||||
|
# Remove any existing OIDC app in custom_apps (from app store or old symlink)
|
||||||
|
if [ -e /var/www/html/custom_apps/oidc ]; then
|
||||||
|
echo "Removing existing OIDC in custom_apps..."
|
||||||
|
rm -rf /var/www/html/custom_apps/oidc
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create symlink from custom_apps to the mounted development version
|
||||||
|
# Per Nextcloud docs: apps outside server root need symlinks in server root
|
||||||
|
echo "Creating symlink: custom_apps/oidc -> /opt/apps/oidc"
|
||||||
|
ln -sf /opt/apps/oidc /var/www/html/custom_apps/oidc
|
||||||
|
|
||||||
|
echo "Enabling OIDC app from /opt/apps (development mode via symlink)"
|
||||||
|
php /var/www/html/occ app:enable oidc
|
||||||
|
elif [ -d /var/www/html/custom_apps/oidc ]; then
|
||||||
|
echo "OIDC app directory found in custom_apps (already installed)"
|
||||||
|
php /var/www/html/occ app:enable oidc
|
||||||
|
else
|
||||||
|
echo "OIDC app not found, installing from app store..."
|
||||||
|
php /var/www/html/occ app:install oidc
|
||||||
|
php /var/www/html/occ app:enable oidc
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Configure OIDC Identity Provider with dynamic client registration enabled
|
||||||
|
php /var/www/html/occ config:app:set oidc dynamic_client_registration --value='true'
|
||||||
|
php /var/www/html/occ config:app:set oidc proof_key_for_code_exchange --value=true --type=boolean
|
||||||
|
php /var/www/html/occ config:app:set oidc default_token_type --value='jwt'
|
||||||
|
|
||||||
|
echo "OIDC app installed and configured successfully"
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
set -euox pipefail
|
||||||
|
|
||||||
|
echo "Installing and configuring user_oidc app for testing..."
|
||||||
|
|
||||||
|
# Enable the user_oidc app (OIDC client for bearer token validation)
|
||||||
|
php /var/www/html/occ app:enable user_oidc
|
||||||
|
|
||||||
|
# Configure user_oidc to validate bearer tokens from the OIDC Identity Provider
|
||||||
|
php /var/www/html/occ config:system:set user_oidc oidc_provider_bearer_validation --value=true --type=boolean
|
||||||
|
php /var/www/html/occ config:system:set user_oidc httpclient.allowselfsigned --value=true --type=boolean
|
||||||
|
|
||||||
|
# Allow Nextcloud to connect to local/internal servers (required for external IdP mode)
|
||||||
|
# This enables user_oidc to fetch JWKS from internal Keycloak container
|
||||||
|
php /var/www/html/occ config:system:set allow_local_remote_servers --value=true --type=boolean
|
||||||
|
|
||||||
|
# Note: The user_oidc app_api session flag patch is NOT required when using the
|
||||||
|
# CORSMiddleware Bearer token patch (20-apply-cors-bearer-token-patch.sh).
|
||||||
|
# The CORSMiddleware patch fixes the root cause by allowing Bearer tokens to bypass
|
||||||
|
# CORS/CSRF checks at the framework level.
|
||||||
+100
@@ -0,0 +1,100 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
#
|
||||||
|
# Configure user_oidc to accept bearer tokens from Keycloak
|
||||||
|
#
|
||||||
|
# This script sets up Keycloak as an external OIDC provider for Nextcloud.
|
||||||
|
# It enables bearer token validation, allowing the MCP server to use Keycloak
|
||||||
|
# tokens to access Nextcloud APIs without admin credentials.
|
||||||
|
#
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "===================================================================="
|
||||||
|
echo "Configuring user_oidc provider for Keycloak..."
|
||||||
|
echo "===================================================================="
|
||||||
|
|
||||||
|
# Wait for Keycloak to be ready and realm to be available
|
||||||
|
echo "Waiting for Keycloak realm to be available..."
|
||||||
|
MAX_RETRIES=30
|
||||||
|
RETRY_COUNT=0
|
||||||
|
|
||||||
|
while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do
|
||||||
|
if curl -sf http://keycloak:8080/realms/nextcloud-mcp/.well-known/openid-configuration > /dev/null 2>&1; then
|
||||||
|
echo "✓ Keycloak realm is ready"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
echo " Waiting for Keycloak... (attempt $((RETRY_COUNT + 1))/$MAX_RETRIES)"
|
||||||
|
sleep 5
|
||||||
|
RETRY_COUNT=$((RETRY_COUNT + 1))
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ $RETRY_COUNT -eq $MAX_RETRIES ]; then
|
||||||
|
echo "⚠ Warning: Keycloak not available after $MAX_RETRIES attempts"
|
||||||
|
echo " Keycloak provider will not be configured"
|
||||||
|
echo " You can configure it manually using:"
|
||||||
|
echo " docker compose exec app php occ user_oidc:provider keycloak \\"
|
||||||
|
echo " --clientid='nextcloud' \\"
|
||||||
|
echo " --clientsecret='nextcloud-secret-change-in-production' \\"
|
||||||
|
echo " --discoveryuri='http://keycloak:8080/realms/nextcloud-mcp/.well-known/openid-configuration' \\"
|
||||||
|
echo " --check-bearer=1 \\"
|
||||||
|
echo " --bearer-provisioning=1 \\"
|
||||||
|
echo " --unique-uid=1"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if provider already exists
|
||||||
|
if php /var/www/html/occ user_oidc:provider keycloak 2>/dev/null | grep -q "Identifier"; then
|
||||||
|
echo " Keycloak provider already exists, updating configuration..."
|
||||||
|
|
||||||
|
# Update existing provider
|
||||||
|
php /var/www/html/occ user_oidc:provider keycloak \
|
||||||
|
--clientid="nextcloud" \
|
||||||
|
--clientsecret="nextcloud-secret-change-in-production" \
|
||||||
|
--discoveryuri="http://keycloak:8080/realms/nextcloud-mcp/.well-known/openid-configuration" \
|
||||||
|
--check-bearer=1 \
|
||||||
|
--bearer-provisioning=1 \
|
||||||
|
--unique-uid=1 \
|
||||||
|
--mapping-uid="sub" \
|
||||||
|
--mapping-display-name="name" \
|
||||||
|
--mapping-email="email" \
|
||||||
|
--scope="openid profile email offline_access"
|
||||||
|
|
||||||
|
echo "✓ Updated Keycloak provider configuration"
|
||||||
|
else
|
||||||
|
echo " Creating new Keycloak provider..."
|
||||||
|
|
||||||
|
# Create new provider
|
||||||
|
php /var/www/html/occ user_oidc:provider keycloak \
|
||||||
|
--clientid="nextcloud" \
|
||||||
|
--clientsecret="nextcloud-secret-change-in-production" \
|
||||||
|
--discoveryuri="http://keycloak:8080/realms/nextcloud-mcp/.well-known/openid-configuration" \
|
||||||
|
--check-bearer=1 \
|
||||||
|
--bearer-provisioning=1 \
|
||||||
|
--unique-uid=1 \
|
||||||
|
--mapping-uid="sub" \
|
||||||
|
--mapping-display-name="name" \
|
||||||
|
--mapping-email="email" \
|
||||||
|
--scope="openid profile email offline_access"
|
||||||
|
|
||||||
|
echo "✓ Created Keycloak provider"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Display provider details
|
||||||
|
echo ""
|
||||||
|
echo "Keycloak provider configuration:"
|
||||||
|
php /var/www/html/occ user_oidc:provider keycloak
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "===================================================================="
|
||||||
|
echo "✓ Keycloak provider configured successfully"
|
||||||
|
echo "===================================================================="
|
||||||
|
echo ""
|
||||||
|
echo "Key features enabled:"
|
||||||
|
echo " • Bearer token validation (--check-bearer=1)"
|
||||||
|
echo " • Automatic user provisioning (--bearer-provisioning=1)"
|
||||||
|
echo " • Unique user IDs (--unique-uid=1)"
|
||||||
|
echo " • Offline access scope (for refresh tokens)"
|
||||||
|
echo ""
|
||||||
|
echo "MCP server can now use Keycloak tokens to access Nextcloud APIs"
|
||||||
|
echo "without admin credentials (ADR-002 architecture)."
|
||||||
|
echo ""
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
#
|
||||||
|
# Apply upstream CORSMiddleware Bearer token authentication patch
|
||||||
|
#
|
||||||
|
# This patch allows Bearer tokens to bypass CORS/CSRF checks, fixing
|
||||||
|
# authentication issues with app-specific APIs (Notes, Calendar, etc.)
|
||||||
|
# when using OAuth/OIDC Bearer tokens.
|
||||||
|
#
|
||||||
|
# Upstream PR: https://github.com/nextcloud/server/pull/55878
|
||||||
|
# Commit: 8fb5e77db82 (fix(cors): Allow Bearer token authentication)
|
||||||
|
#
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
PATCH_FILE="/docker-entrypoint-hooks.d/patches/cors-bearer-token.patch"
|
||||||
|
TARGET_FILE="/var/www/html/lib/private/AppFramework/Middleware/Security/CORSMiddleware.php"
|
||||||
|
|
||||||
|
echo "===================================================================="
|
||||||
|
echo "Applying CORSMiddleware Bearer token authentication patch..."
|
||||||
|
echo "===================================================================="
|
||||||
|
|
||||||
|
# Check if patch file exists
|
||||||
|
if [ ! -f "$PATCH_FILE" ]; then
|
||||||
|
echo "⚠ Warning: Patch file not found: $PATCH_FILE"
|
||||||
|
echo " Skipping CORS Bearer token patch"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if target file exists
|
||||||
|
if [ ! -f "$TARGET_FILE" ]; then
|
||||||
|
echo "⚠ Warning: Target file not found: $TARGET_FILE"
|
||||||
|
echo " Skipping CORS Bearer token patch"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if already patched
|
||||||
|
if grep -q "Allow Bearer token authentication for CORS requests" "$TARGET_FILE"; then
|
||||||
|
echo "✓ CORSMiddleware already patched for Bearer token support"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Applying patch to CORSMiddleware.php..."
|
||||||
|
|
||||||
|
# Apply the patch
|
||||||
|
cd /var/www/html
|
||||||
|
if patch -p1 --dry-run < "$PATCH_FILE" > /dev/null 2>&1; then
|
||||||
|
patch -p1 < "$PATCH_FILE"
|
||||||
|
echo "✓ Patch applied successfully"
|
||||||
|
else
|
||||||
|
echo "⚠ Warning: Patch failed to apply (may already be applied or file changed)"
|
||||||
|
echo " This is expected if using a Nextcloud version that already includes the fix"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "===================================================================="
|
||||||
|
echo "✓ CORSMiddleware Bearer token patch applied"
|
||||||
|
echo "===================================================================="
|
||||||
|
echo ""
|
||||||
|
echo "Benefits:"
|
||||||
|
echo " • Bearer tokens now work with app-specific APIs (Notes, Calendar, etc.)"
|
||||||
|
echo " • OAuth/OIDC authentication works without CORS errors"
|
||||||
|
echo " • Stateless API authentication is properly supported"
|
||||||
|
echo ""
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
set -euox pipefail
|
|
||||||
|
|
||||||
echo "Installing and configuring OIDC apps for testing..."
|
|
||||||
|
|
||||||
# Enable the OIDC Identity Provider app
|
|
||||||
php /var/www/html/occ app:enable oidc
|
|
||||||
|
|
||||||
# Enable the user_oidc app (OIDC client for bearer token validation)
|
|
||||||
php /var/www/html/occ app:enable user_oidc
|
|
||||||
|
|
||||||
patch -u /var/www/html/custom_apps/user_oidc/lib/User/Backend.php -i /docker-entrypoint-hooks.d/post-installation/0001-Fix-Bearer-token-authentication-causing-session-logo.patch
|
|
||||||
patch -u /var/www/html/custom_apps/oidc/lib/Util/DiscoveryGenerator.php -i /docker-entrypoint-hooks.d/post-installation/0002-Add-PKCE-code-challenge-methods-to-discovery-documen.patch
|
|
||||||
|
|
||||||
# Configure OIDC Identity Provider with dynamic client registration enabled
|
|
||||||
php /var/www/html/occ config:app:set oidc dynamic_client_registration --value='true'
|
|
||||||
php /var/www/html/occ config:app:set oidc proof_key_for_code_exchange --value=true --type=boolean
|
|
||||||
|
|
||||||
# Configure user_oidc to validate bearer tokens from the OIDC Identity Provider
|
|
||||||
php /var/www/html/occ config:system:set user_oidc oidc_provider_bearer_validation --value=true --type=boolean
|
|
||||||
|
|
||||||
echo "OIDC apps installed and configured successfully"
|
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Patterns to ignore when building packages.
|
||||||
|
# This supports shell glob matching, relative path matching, and
|
||||||
|
# negation (prefixed with !). Only one pattern per line.
|
||||||
|
.DS_Store
|
||||||
|
# Common VCS dirs
|
||||||
|
.git/
|
||||||
|
.gitignore
|
||||||
|
.bzr/
|
||||||
|
.bzrignore
|
||||||
|
.hg/
|
||||||
|
.hgignore
|
||||||
|
.svn/
|
||||||
|
# Common backup files
|
||||||
|
*.swp
|
||||||
|
*.bak
|
||||||
|
*.tmp
|
||||||
|
*.orig
|
||||||
|
*~
|
||||||
|
# Various IDEs
|
||||||
|
.project
|
||||||
|
.idea/
|
||||||
|
*.tmproj
|
||||||
|
.vscode/
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
apiVersion: v2
|
||||||
|
name: nextcloud-mcp-server
|
||||||
|
description: A Helm chart for Nextcloud MCP Server - enables AI assistants to interact with Nextcloud
|
||||||
|
type: application
|
||||||
|
version: 0.23.0
|
||||||
|
appVersion: "0.23.0"
|
||||||
|
keywords:
|
||||||
|
- nextcloud
|
||||||
|
- mcp
|
||||||
|
- model-context-protocol
|
||||||
|
- llm
|
||||||
|
- ai
|
||||||
|
- claude
|
||||||
|
- webdav
|
||||||
|
- caldav
|
||||||
|
- carddav
|
||||||
|
maintainers:
|
||||||
|
- name: Chris Coutinho
|
||||||
|
email: chris@coutinho.io
|
||||||
|
home: https://github.com/cbcoutinho/nextcloud-mcp-server
|
||||||
|
sources:
|
||||||
|
- https://github.com/cbcoutinho/nextcloud-mcp-server
|
||||||
|
icon: https://raw.githubusercontent.com/nextcloud/server/master/core/img/logo/logo.svg
|
||||||
@@ -0,0 +1,489 @@
|
|||||||
|
# Nextcloud MCP Server Helm Chart
|
||||||
|
|
||||||
|
This Helm chart deploys the Nextcloud MCP (Model Context Protocol) Server on a Kubernetes cluster, enabling AI assistants to interact with your Nextcloud instance.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Kubernetes 1.19+
|
||||||
|
- Helm 3.0+
|
||||||
|
- A running Nextcloud instance (accessible from the Kubernetes cluster)
|
||||||
|
- Nextcloud credentials (username/password for basic auth OR OAuth client for OAuth mode)
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### Quick Start with Basic Authentication
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install with basic auth (recommended for most users)
|
||||||
|
helm install nextcloud-mcp ./helm/nextcloud-mcp-server \
|
||||||
|
--set nextcloud.host=https://cloud.example.com \
|
||||||
|
--set auth.basic.username=myuser \
|
||||||
|
--set auth.basic.password=mypassword
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using a values file
|
||||||
|
|
||||||
|
Create a `custom-values.yaml` file:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
nextcloud:
|
||||||
|
host: https://cloud.example.com
|
||||||
|
|
||||||
|
auth:
|
||||||
|
mode: basic
|
||||||
|
basic:
|
||||||
|
username: myuser
|
||||||
|
password: mypassword
|
||||||
|
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
cpu: 1000m
|
||||||
|
memory: 512Mi
|
||||||
|
requests:
|
||||||
|
cpu: 100m
|
||||||
|
memory: 128Mi
|
||||||
|
```
|
||||||
|
|
||||||
|
Install with your custom values:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
helm install nextcloud-mcp ./helm/nextcloud-mcp-server -f custom-values.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
### OAuth Authentication Mode (Experimental)
|
||||||
|
|
||||||
|
**Warning:** OAuth mode is experimental and requires patches to the Nextcloud `user_oidc` app. See the [Authentication Guide](https://github.com/cbcoutinho/nextcloud-mcp-server#authentication) for details.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
nextcloud:
|
||||||
|
host: https://cloud.example.com
|
||||||
|
mcpServerUrl: https://mcp.example.com
|
||||||
|
publicIssuerUrl: https://cloud.example.com
|
||||||
|
|
||||||
|
auth:
|
||||||
|
mode: oauth
|
||||||
|
oauth:
|
||||||
|
# Optional: provide pre-registered client credentials
|
||||||
|
# If not provided, will use Dynamic Client Registration
|
||||||
|
clientId: "your-client-id"
|
||||||
|
clientSecret: "your-client-secret"
|
||||||
|
persistence:
|
||||||
|
enabled: true
|
||||||
|
size: 100Mi
|
||||||
|
|
||||||
|
ingress:
|
||||||
|
enabled: true
|
||||||
|
className: nginx
|
||||||
|
hosts:
|
||||||
|
- host: mcp.example.com
|
||||||
|
paths:
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
tls:
|
||||||
|
- secretName: nextcloud-mcp-tls
|
||||||
|
hosts:
|
||||||
|
- mcp.example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Key Configuration Parameters
|
||||||
|
|
||||||
|
#### Nextcloud Connection
|
||||||
|
|
||||||
|
| Parameter | Description | Default |
|
||||||
|
|-----------|-------------|---------|
|
||||||
|
| `nextcloud.host` | URL of your Nextcloud instance (required) | `""` |
|
||||||
|
| `nextcloud.mcpServerUrl` | MCP server URL for OAuth callbacks (OAuth only, optional) | Smart default* |
|
||||||
|
| `nextcloud.publicIssuerUrl` | Public issuer URL for OAuth (OAuth only, optional) | Smart default** |
|
||||||
|
|
||||||
|
**Smart Defaults:**
|
||||||
|
- `*mcpServerUrl`: If not set, automatically uses ingress host (if enabled) or `http://localhost:8000` (for port-forward setups)
|
||||||
|
- `**publicIssuerUrl`: If not set, automatically defaults to `nextcloud.host` (which works when both clients and MCP server access Nextcloud at the same URL)
|
||||||
|
|
||||||
|
#### Authentication
|
||||||
|
|
||||||
|
| Parameter | Description | Default |
|
||||||
|
|-----------|-------------|---------|
|
||||||
|
| `auth.mode` | Authentication mode: `basic` or `oauth` | `basic` |
|
||||||
|
| `auth.basic.username` | Nextcloud username (basic auth) | `""` |
|
||||||
|
| `auth.basic.password` | Nextcloud password (basic auth) | `""` |
|
||||||
|
| `auth.basic.existingSecret` | Use existing secret for credentials | `""` |
|
||||||
|
| `auth.oauth.clientId` | OAuth client ID (OAuth mode, optional) | `""` |
|
||||||
|
| `auth.oauth.clientSecret` | OAuth client secret (OAuth mode, optional) | `""` |
|
||||||
|
| `auth.oauth.persistence.enabled` | Enable persistent storage for OAuth | `true` |
|
||||||
|
| `auth.oauth.persistence.size` | Size of OAuth storage PVC | `100Mi` |
|
||||||
|
|
||||||
|
#### MCP Server Configuration
|
||||||
|
|
||||||
|
| Parameter | Description | Default |
|
||||||
|
|-----------|-------------|---------|
|
||||||
|
| `mcp.transport` | Transport mode | `streamable-http` |
|
||||||
|
| `mcp.port` | Server port (used by both auth modes) | `8000` |
|
||||||
|
| `mcp.extraArgs` | Additional command-line arguments | `[]` |
|
||||||
|
|
||||||
|
The `extraArgs` parameter allows you to pass additional command-line arguments to the MCP server. This is useful for enabling debug logging, enabling specific apps, or other runtime configuration.
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```yaml
|
||||||
|
mcp:
|
||||||
|
extraArgs:
|
||||||
|
- "--log-level"
|
||||||
|
- "debug"
|
||||||
|
- "--enable-app"
|
||||||
|
- "notes"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Image Configuration
|
||||||
|
|
||||||
|
| Parameter | Description | Default |
|
||||||
|
|-----------|-------------|---------|
|
||||||
|
| `image.repository` | Container image repository | `ghcr.io/cbcoutinho/nextcloud-mcp-server` |
|
||||||
|
| `image.pullPolicy` | Image pull policy | `IfNotPresent` |
|
||||||
|
|
||||||
|
**Note:** Image tag is automatically set to the chart's `appVersion` and cannot be overridden.
|
||||||
|
|
||||||
|
#### Resources
|
||||||
|
|
||||||
|
| Parameter | Description | Default |
|
||||||
|
|-----------|-------------|---------|
|
||||||
|
| `resources.limits.cpu` | CPU limit | `1000m` |
|
||||||
|
| `resources.limits.memory` | Memory limit | `512Mi` |
|
||||||
|
| `resources.requests.cpu` | CPU request | `100m` |
|
||||||
|
| `resources.requests.memory` | Memory request | `128Mi` |
|
||||||
|
|
||||||
|
#### Service
|
||||||
|
|
||||||
|
| Parameter | Description | Default |
|
||||||
|
|-----------|-------------|---------|
|
||||||
|
| `service.type` | Service type | `ClusterIP` |
|
||||||
|
| `service.port` | Service port | `8000` |
|
||||||
|
|
||||||
|
#### Ingress
|
||||||
|
|
||||||
|
| Parameter | Description | Default |
|
||||||
|
|-----------|-------------|---------|
|
||||||
|
| `ingress.enabled` | Enable ingress | `false` |
|
||||||
|
| `ingress.className` | Ingress class name | `""` |
|
||||||
|
| `ingress.hosts` | Ingress host configuration | See values.yaml |
|
||||||
|
| `ingress.tls` | Ingress TLS configuration | `[]` |
|
||||||
|
|
||||||
|
#### Autoscaling
|
||||||
|
|
||||||
|
| Parameter | Description | Default |
|
||||||
|
|-----------|-------------|---------|
|
||||||
|
| `autoscaling.enabled` | Enable HPA | `false` |
|
||||||
|
| `autoscaling.minReplicas` | Minimum replicas | `1` |
|
||||||
|
| `autoscaling.maxReplicas` | Maximum replicas | `10` |
|
||||||
|
| `autoscaling.targetCPUUtilizationPercentage` | Target CPU % | `80` |
|
||||||
|
|
||||||
|
#### Health Probes
|
||||||
|
|
||||||
|
| Parameter | Description | Default |
|
||||||
|
|-----------|-------------|---------|
|
||||||
|
| `livenessProbe.httpGet.path` | Liveness probe endpoint | `/health/live` |
|
||||||
|
| `livenessProbe.initialDelaySeconds` | Initial delay for liveness | `30` |
|
||||||
|
| `livenessProbe.periodSeconds` | Check interval for liveness | `10` |
|
||||||
|
| `readinessProbe.httpGet.path` | Readiness probe endpoint | `/health/ready` |
|
||||||
|
| `readinessProbe.initialDelaySeconds` | Initial delay for readiness | `10` |
|
||||||
|
| `readinessProbe.periodSeconds` | Check interval for readiness | `5` |
|
||||||
|
|
||||||
|
The application exposes HTTP health check endpoints:
|
||||||
|
- `/health/live` - Liveness probe (checks if application is running)
|
||||||
|
- `/health/ready` - Readiness probe (checks if application is ready to serve traffic)
|
||||||
|
|
||||||
|
#### Document Processing (Optional)
|
||||||
|
|
||||||
|
| Parameter | Description | Default |
|
||||||
|
|-----------|-------------|---------|
|
||||||
|
| `documentProcessing.enabled` | Enable document processing | `false` |
|
||||||
|
| `documentProcessing.defaultProcessor` | Default processor | `unstructured` |
|
||||||
|
| `documentProcessing.unstructured.enabled` | Enable Unstructured.io processor | `false` |
|
||||||
|
| `documentProcessing.unstructured.apiUrl` | Unstructured API URL | `http://unstructured:8000` |
|
||||||
|
| `documentProcessing.tesseract.enabled` | Enable Tesseract OCR | `false` |
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
### Example 1: Basic Auth with Ingress
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
nextcloud:
|
||||||
|
host: https://cloud.example.com
|
||||||
|
|
||||||
|
auth:
|
||||||
|
mode: basic
|
||||||
|
basic:
|
||||||
|
username: admin
|
||||||
|
password: secure-password
|
||||||
|
|
||||||
|
ingress:
|
||||||
|
enabled: true
|
||||||
|
className: nginx
|
||||||
|
annotations:
|
||||||
|
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||||
|
hosts:
|
||||||
|
- host: mcp.example.com
|
||||||
|
paths:
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
tls:
|
||||||
|
- secretName: mcp-tls
|
||||||
|
hosts:
|
||||||
|
- mcp.example.com
|
||||||
|
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
cpu: 2000m
|
||||||
|
memory: 1Gi
|
||||||
|
requests:
|
||||||
|
cpu: 200m
|
||||||
|
memory: 256Mi
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example 2: Using Existing Secrets
|
||||||
|
|
||||||
|
#### Basic Auth with Existing Secret
|
||||||
|
|
||||||
|
Create a secret manually:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl create secret generic nextcloud-credentials \
|
||||||
|
--from-literal=username=myuser \
|
||||||
|
--from-literal=password=mypassword
|
||||||
|
```
|
||||||
|
|
||||||
|
Then reference it in your values:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
nextcloud:
|
||||||
|
host: https://cloud.example.com
|
||||||
|
|
||||||
|
auth:
|
||||||
|
mode: basic
|
||||||
|
basic:
|
||||||
|
existingSecret: nextcloud-credentials
|
||||||
|
usernameKey: username
|
||||||
|
passwordKey: password
|
||||||
|
```
|
||||||
|
|
||||||
|
#### OAuth with Existing Secret (Pre-registered Client)
|
||||||
|
|
||||||
|
If you have a pre-registered OAuth client:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl create secret generic nextcloud-oauth-creds \
|
||||||
|
--from-literal=clientId=my-oauth-client-id \
|
||||||
|
--from-literal=clientSecret=my-oauth-client-secret
|
||||||
|
```
|
||||||
|
|
||||||
|
Then reference it in your values:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
nextcloud:
|
||||||
|
host: https://cloud.example.com
|
||||||
|
# mcpServerUrl and publicIssuerUrl are optional!
|
||||||
|
# If not set, mcpServerUrl defaults to ingress host or localhost
|
||||||
|
# publicIssuerUrl defaults to nextcloud.host
|
||||||
|
|
||||||
|
auth:
|
||||||
|
mode: oauth
|
||||||
|
oauth:
|
||||||
|
existingSecret: nextcloud-oauth-creds
|
||||||
|
clientIdKey: clientId
|
||||||
|
clientSecretKey: clientSecret
|
||||||
|
persistence:
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
ingress:
|
||||||
|
enabled: true
|
||||||
|
hosts:
|
||||||
|
- host: mcp.example.com
|
||||||
|
paths:
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
tls:
|
||||||
|
- secretName: mcp-tls
|
||||||
|
hosts:
|
||||||
|
- mcp.example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example 3: OAuth with Document Processing and Dynamic Client Registration
|
||||||
|
|
||||||
|
This example shows OAuth without pre-registered credentials (using DCR) and optional URL values:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
nextcloud:
|
||||||
|
host: https://cloud.example.com
|
||||||
|
# mcpServerUrl will automatically use ingress host (https://mcp.example.com)
|
||||||
|
# publicIssuerUrl will automatically default to nextcloud.host
|
||||||
|
|
||||||
|
auth:
|
||||||
|
mode: oauth
|
||||||
|
oauth:
|
||||||
|
# No clientId/clientSecret - will use Dynamic Client Registration!
|
||||||
|
persistence:
|
||||||
|
enabled: true
|
||||||
|
storageClass: fast-ssd
|
||||||
|
size: 200Mi
|
||||||
|
|
||||||
|
documentProcessing:
|
||||||
|
enabled: true
|
||||||
|
defaultProcessor: unstructured
|
||||||
|
unstructured:
|
||||||
|
enabled: true
|
||||||
|
apiUrl: http://unstructured-api:8000
|
||||||
|
strategy: hi_res
|
||||||
|
languages: eng,deu,fra
|
||||||
|
|
||||||
|
ingress:
|
||||||
|
enabled: true
|
||||||
|
className: nginx
|
||||||
|
hosts:
|
||||||
|
- host: mcp.example.com
|
||||||
|
paths:
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example 4: High Availability with Autoscaling
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
replicaCount: 2
|
||||||
|
|
||||||
|
autoscaling:
|
||||||
|
enabled: true
|
||||||
|
minReplicas: 2
|
||||||
|
maxReplicas: 20
|
||||||
|
targetCPUUtilizationPercentage: 70
|
||||||
|
targetMemoryUtilizationPercentage: 80
|
||||||
|
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
cpu: 2000m
|
||||||
|
memory: 1Gi
|
||||||
|
requests:
|
||||||
|
cpu: 500m
|
||||||
|
memory: 512Mi
|
||||||
|
|
||||||
|
affinity:
|
||||||
|
podAntiAffinity:
|
||||||
|
preferredDuringSchedulingIgnoredDuringExecution:
|
||||||
|
- weight: 100
|
||||||
|
podAffinityTerm:
|
||||||
|
labelSelector:
|
||||||
|
matchExpressions:
|
||||||
|
- key: app.kubernetes.io/name
|
||||||
|
operator: In
|
||||||
|
values:
|
||||||
|
- nextcloud-mcp-server
|
||||||
|
topologyKey: kubernetes.io/hostname
|
||||||
|
```
|
||||||
|
|
||||||
|
## Upgrading
|
||||||
|
|
||||||
|
### To upgrade an existing deployment:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
helm upgrade nextcloud-mcp ./helm/nextcloud-mcp-server -f custom-values.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
### To upgrade with new values:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
helm upgrade nextcloud-mcp ./helm/nextcloud-mcp-server \
|
||||||
|
--set resources.limits.memory=1Gi
|
||||||
|
```
|
||||||
|
|
||||||
|
## Uninstalling
|
||||||
|
|
||||||
|
```bash
|
||||||
|
helm uninstall nextcloud-mcp
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** This will delete all resources including PVCs. If you want to preserve OAuth client data, backup the PVC before uninstalling.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Check pod status
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl get pods -l app.kubernetes.io/name=nextcloud-mcp-server
|
||||||
|
```
|
||||||
|
|
||||||
|
### View logs
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl logs -l app.kubernetes.io/name=nextcloud-mcp-server --tail=100 -f
|
||||||
|
```
|
||||||
|
|
||||||
|
### Check health endpoints
|
||||||
|
|
||||||
|
The application exposes health check endpoints for monitoring:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Port forward to the service
|
||||||
|
kubectl port-forward svc/nextcloud-mcp 8000:8000
|
||||||
|
|
||||||
|
# Check liveness (if app is running)
|
||||||
|
curl http://localhost:8000/health/live
|
||||||
|
|
||||||
|
# Check readiness (if app is ready to serve traffic)
|
||||||
|
curl http://localhost:8000/health/ready
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example responses:**
|
||||||
|
|
||||||
|
Liveness (always returns 200 if running):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "alive",
|
||||||
|
"mode": "basic"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Readiness (returns 200 if ready, 503 if not ready):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ready",
|
||||||
|
"checks": {
|
||||||
|
"nextcloud_configured": "ok",
|
||||||
|
"auth_mode": "basic",
|
||||||
|
"auth_configured": "ok"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Common Issues
|
||||||
|
|
||||||
|
1. **Connection refused to Nextcloud**
|
||||||
|
- Verify `nextcloud.host` is accessible from the Kubernetes cluster
|
||||||
|
- Check network policies and firewall rules
|
||||||
|
|
||||||
|
2. **Authentication failures**
|
||||||
|
- For basic auth: verify username/password are correct
|
||||||
|
- For OAuth: check that OIDC app is properly configured
|
||||||
|
|
||||||
|
3. **OAuth persistence issues**
|
||||||
|
- Verify PVC is bound: `kubectl get pvc`
|
||||||
|
- Check storage class exists: `kubectl get storageclass`
|
||||||
|
|
||||||
|
4. **Resource constraints**
|
||||||
|
- Increase memory limits if seeing OOM errors
|
||||||
|
- Adjust CPU requests based on load
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
1. **Secrets Management**: Consider using external secret management (e.g., Sealed Secrets, External Secrets Operator)
|
||||||
|
2. **TLS**: Always use TLS/HTTPS for production deployments
|
||||||
|
3. **Network Policies**: Restrict network access to necessary services only
|
||||||
|
4. **RBAC**: Review and customize ServiceAccount permissions as needed
|
||||||
|
5. **App Passwords**: For basic auth, use Nextcloud app passwords instead of main account passwords
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
- GitHub Issues: https://github.com/cbcoutinho/nextcloud-mcp-server/issues
|
||||||
|
- Documentation: https://github.com/cbcoutinho/nextcloud-mcp-server#readme
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
This chart is licensed under AGPL-3.0, consistent with the Nextcloud MCP Server project.
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
Thank you for installing {{ .Chart.Name }}!
|
||||||
|
|
||||||
|
Your Nextcloud MCP Server has been deployed in {{ .Values.auth.mode }} authentication mode.
|
||||||
|
|
||||||
|
1. Get the application URL by running these commands:
|
||||||
|
{{- if .Values.ingress.enabled }}
|
||||||
|
{{- range $host := .Values.ingress.hosts }}
|
||||||
|
{{- range .paths }}
|
||||||
|
http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}{{ .path }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
{{- else if contains "NodePort" .Values.service.type }}
|
||||||
|
export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "nextcloud-mcp-server.fullname" . }})
|
||||||
|
export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}")
|
||||||
|
echo http://$NODE_IP:$NODE_PORT
|
||||||
|
{{- else if contains "LoadBalancer" .Values.service.type }}
|
||||||
|
NOTE: It may take a few minutes for the LoadBalancer IP to be available.
|
||||||
|
You can watch the status of by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "nextcloud-mcp-server.fullname" . }}'
|
||||||
|
export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "nextcloud-mcp-server.fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}")
|
||||||
|
echo http://$SERVICE_IP:{{ .Values.service.port }}
|
||||||
|
{{- else if contains "ClusterIP" .Values.service.type }}
|
||||||
|
export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "nextcloud-mcp-server.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}")
|
||||||
|
export CONTAINER_PORT=$(kubectl get pod --namespace {{ .Release.Namespace }} $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}")
|
||||||
|
echo "Visit http://127.0.0.1:8080 to use your MCP server"
|
||||||
|
kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:$CONTAINER_PORT
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
2. Check the deployment status:
|
||||||
|
kubectl --namespace {{ .Release.Namespace }} get pods -l "app.kubernetes.io/name={{ include "nextcloud-mcp-server.name" . }},app.kubernetes.io/instance={{ .Release.Name }}"
|
||||||
|
|
||||||
|
{{- if eq .Values.auth.mode "basic" }}
|
||||||
|
|
||||||
|
3. Basic Authentication Mode:
|
||||||
|
{{- if .Values.auth.basic.existingSecret }}
|
||||||
|
- Credentials: (using existing secret {{ .Values.auth.basic.existingSecret }})
|
||||||
|
{{- else }}
|
||||||
|
- Username: {{ .Values.auth.basic.username }}
|
||||||
|
- Password: (stored in secret {{ include "nextcloud-mcp-server.basicAuthSecretName" . }})
|
||||||
|
{{- end }}
|
||||||
|
- Connected to: {{ .Values.nextcloud.host }}
|
||||||
|
{{- else if eq .Values.auth.mode "oauth" }}
|
||||||
|
|
||||||
|
3. OAuth Authentication Mode:
|
||||||
|
- Server URL: {{ include "nextcloud-mcp-server.mcpServerUrl" . }}
|
||||||
|
- Issuer URL: {{ include "nextcloud-mcp-server.publicIssuerUrl" . }}
|
||||||
|
- Connected to: {{ .Values.nextcloud.host }}
|
||||||
|
{{- if .Values.auth.oauth.existingSecret }}
|
||||||
|
- Using existing OAuth client secret: {{ .Values.auth.oauth.existingSecret }}
|
||||||
|
{{- else if and .Values.auth.oauth.clientId .Values.auth.oauth.clientSecret }}
|
||||||
|
- Using pre-registered OAuth client
|
||||||
|
{{- else }}
|
||||||
|
- Using Dynamic Client Registration (DCR)
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.auth.oauth.persistence.enabled }}
|
||||||
|
- OAuth client credentials are persisted in PVC: {{ include "nextcloud-mcp-server.oauthPvcName" . }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
IMPORTANT: OAuth mode is experimental and requires patches to the user_oidc app.
|
||||||
|
See: https://github.com/cbcoutinho/nextcloud-mcp-server#authentication
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{- if .Values.documentProcessing.enabled }}
|
||||||
|
|
||||||
|
4. Document Processing:
|
||||||
|
- Enabled: {{ .Values.documentProcessing.enabled }}
|
||||||
|
- Default processor: {{ .Values.documentProcessing.defaultProcessor }}
|
||||||
|
{{- if .Values.documentProcessing.unstructured.enabled }}
|
||||||
|
- Unstructured API: {{ .Values.documentProcessing.unstructured.apiUrl }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
For more information and documentation:
|
||||||
|
- GitHub: https://github.com/cbcoutinho/nextcloud-mcp-server
|
||||||
|
- Documentation: https://github.com/cbcoutinho/nextcloud-mcp-server#readme
|
||||||
|
|
||||||
|
To upgrade this deployment:
|
||||||
|
helm upgrade {{ .Release.Name }} nextcloud-mcp-server
|
||||||
|
|
||||||
|
To uninstall:
|
||||||
|
helm uninstall {{ .Release.Name }}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
{{/*
|
||||||
|
Expand the name of the chart.
|
||||||
|
*/}}
|
||||||
|
{{- define "nextcloud-mcp-server.name" -}}
|
||||||
|
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
Create a default fully qualified app name.
|
||||||
|
We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).
|
||||||
|
If release name contains chart name it will be used as a full name.
|
||||||
|
*/}}
|
||||||
|
{{- define "nextcloud-mcp-server.fullname" -}}
|
||||||
|
{{- if .Values.fullnameOverride }}
|
||||||
|
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
|
||||||
|
{{- else }}
|
||||||
|
{{- $name := default .Chart.Name .Values.nameOverride }}
|
||||||
|
{{- if contains $name .Release.Name }}
|
||||||
|
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
|
||||||
|
{{- else }}
|
||||||
|
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
Create chart name and version as used by the chart label.
|
||||||
|
*/}}
|
||||||
|
{{- define "nextcloud-mcp-server.chart" -}}
|
||||||
|
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
Common labels
|
||||||
|
*/}}
|
||||||
|
{{- define "nextcloud-mcp-server.labels" -}}
|
||||||
|
helm.sh/chart: {{ include "nextcloud-mcp-server.chart" . }}
|
||||||
|
{{ include "nextcloud-mcp-server.selectorLabels" . }}
|
||||||
|
{{- if .Chart.AppVersion }}
|
||||||
|
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
|
||||||
|
{{- end }}
|
||||||
|
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
Selector labels
|
||||||
|
*/}}
|
||||||
|
{{- define "nextcloud-mcp-server.selectorLabels" -}}
|
||||||
|
app.kubernetes.io/name: {{ include "nextcloud-mcp-server.name" . }}
|
||||||
|
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
Create the name of the service account to use
|
||||||
|
*/}}
|
||||||
|
{{- define "nextcloud-mcp-server.serviceAccountName" -}}
|
||||||
|
{{- if .Values.serviceAccount.create }}
|
||||||
|
{{- default (include "nextcloud-mcp-server.fullname" .) .Values.serviceAccount.name }}
|
||||||
|
{{- else }}
|
||||||
|
{{- default "default" .Values.serviceAccount.name }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
Create the name of the secret to use for basic auth
|
||||||
|
*/}}
|
||||||
|
{{- define "nextcloud-mcp-server.basicAuthSecretName" -}}
|
||||||
|
{{- if .Values.auth.basic.existingSecret }}
|
||||||
|
{{- .Values.auth.basic.existingSecret }}
|
||||||
|
{{- else }}
|
||||||
|
{{- include "nextcloud-mcp-server.fullname" . }}-basic-auth
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
Create the name of the secret to use for OAuth
|
||||||
|
*/}}
|
||||||
|
{{- define "nextcloud-mcp-server.oauthSecretName" -}}
|
||||||
|
{{- if .Values.auth.oauth.existingSecret }}
|
||||||
|
{{- .Values.auth.oauth.existingSecret }}
|
||||||
|
{{- else }}
|
||||||
|
{{- include "nextcloud-mcp-server.fullname" . }}-oauth
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
Create the name of the PVC to use for OAuth storage
|
||||||
|
*/}}
|
||||||
|
{{- define "nextcloud-mcp-server.oauthPvcName" -}}
|
||||||
|
{{- if .Values.auth.oauth.persistence.existingClaim }}
|
||||||
|
{{- .Values.auth.oauth.persistence.existingClaim }}
|
||||||
|
{{- else }}
|
||||||
|
{{- include "nextcloud-mcp-server.fullname" . }}-oauth-storage
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
Return the MCP server port
|
||||||
|
*/}}
|
||||||
|
{{- define "nextcloud-mcp-server.port" -}}
|
||||||
|
{{- .Values.mcp.port }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
Return the image tag (always uses chart appVersion)
|
||||||
|
*/}}
|
||||||
|
{{- define "nextcloud-mcp-server.imageTag" -}}
|
||||||
|
{{- .Chart.AppVersion }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
Return the public issuer URL for OAuth
|
||||||
|
Defaults to nextcloud.host if not specified
|
||||||
|
*/}}
|
||||||
|
{{- define "nextcloud-mcp-server.publicIssuerUrl" -}}
|
||||||
|
{{- if .Values.nextcloud.publicIssuerUrl }}
|
||||||
|
{{- .Values.nextcloud.publicIssuerUrl }}
|
||||||
|
{{- else }}
|
||||||
|
{{- .Values.nextcloud.host }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
Return the MCP server URL for OAuth callbacks
|
||||||
|
If not specified:
|
||||||
|
- Uses ingress host if ingress is enabled
|
||||||
|
- Otherwise defaults to http://localhost:8000 (for port-forward setups)
|
||||||
|
*/}}
|
||||||
|
{{- define "nextcloud-mcp-server.mcpServerUrl" -}}
|
||||||
|
{{- if .Values.nextcloud.mcpServerUrl }}
|
||||||
|
{{- .Values.nextcloud.mcpServerUrl }}
|
||||||
|
{{- else if .Values.ingress.enabled }}
|
||||||
|
{{- $host := index .Values.ingress.hosts 0 }}
|
||||||
|
{{- if .Values.ingress.tls }}
|
||||||
|
{{- printf "https://%s" $host.host }}
|
||||||
|
{{- else }}
|
||||||
|
{{- printf "http://%s" $host.host }}
|
||||||
|
{{- end }}
|
||||||
|
{{- else }}
|
||||||
|
{{- printf "http://localhost:%d" (int .Values.mcp.port) }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: {{ include "nextcloud-mcp-server.fullname" . }}
|
||||||
|
labels:
|
||||||
|
{{- include "nextcloud-mcp-server.labels" . | nindent 4 }}
|
||||||
|
spec:
|
||||||
|
{{- if not .Values.autoscaling.enabled }}
|
||||||
|
replicas: {{ .Values.replicaCount }}
|
||||||
|
{{- end }}
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
{{- include "nextcloud-mcp-server.selectorLabels" . | nindent 6 }}
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
annotations:
|
||||||
|
checksum/secret: {{ include (print $.Template.BasePath "/secret.yaml") . | sha256sum }}
|
||||||
|
{{- with .Values.podAnnotations }}
|
||||||
|
{{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
|
labels:
|
||||||
|
{{- include "nextcloud-mcp-server.labels" . | nindent 8 }}
|
||||||
|
{{- with .Values.podLabels }}
|
||||||
|
{{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
|
spec:
|
||||||
|
{{- with .Values.imagePullSecrets }}
|
||||||
|
imagePullSecrets:
|
||||||
|
{{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
|
serviceAccountName: {{ include "nextcloud-mcp-server.serviceAccountName" . }}
|
||||||
|
securityContext:
|
||||||
|
{{- toYaml .Values.podSecurityContext | nindent 8 }}
|
||||||
|
{{- with .Values.initContainers }}
|
||||||
|
initContainers:
|
||||||
|
{{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
|
containers:
|
||||||
|
- name: {{ .Chart.Name }}
|
||||||
|
securityContext:
|
||||||
|
{{- toYaml .Values.securityContext | nindent 12 }}
|
||||||
|
image: "{{ .Values.image.repository }}:{{ include "nextcloud-mcp-server.imageTag" . }}"
|
||||||
|
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
||||||
|
args:
|
||||||
|
- "--transport"
|
||||||
|
- "{{ .Values.mcp.transport }}"
|
||||||
|
{{- if eq .Values.auth.mode "oauth" }}
|
||||||
|
- "--oauth"
|
||||||
|
- "--oauth-token-type"
|
||||||
|
- "{{ .Values.auth.oauth.tokenType }}"
|
||||||
|
{{- end }}
|
||||||
|
{{- with .Values.mcp.extraArgs }}
|
||||||
|
{{- toYaml . | nindent 12 }}
|
||||||
|
{{- end }}
|
||||||
|
ports:
|
||||||
|
- name: http
|
||||||
|
containerPort: {{ include "nextcloud-mcp-server.port" . }}
|
||||||
|
protocol: TCP
|
||||||
|
env:
|
||||||
|
# Nextcloud connection
|
||||||
|
- name: NEXTCLOUD_HOST
|
||||||
|
value: {{ .Values.nextcloud.host | quote }}
|
||||||
|
{{- if eq .Values.auth.mode "basic" }}
|
||||||
|
# Basic auth mode
|
||||||
|
- name: NEXTCLOUD_USERNAME
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: {{ include "nextcloud-mcp-server.basicAuthSecretName" . }}
|
||||||
|
key: {{ .Values.auth.basic.usernameKey }}
|
||||||
|
- name: NEXTCLOUD_PASSWORD
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: {{ include "nextcloud-mcp-server.basicAuthSecretName" . }}
|
||||||
|
key: {{ .Values.auth.basic.passwordKey }}
|
||||||
|
{{- else if eq .Values.auth.mode "oauth" }}
|
||||||
|
# OAuth mode
|
||||||
|
- name: NEXTCLOUD_MCP_SERVER_URL
|
||||||
|
value: {{ include "nextcloud-mcp-server.mcpServerUrl" . | quote }}
|
||||||
|
- name: NEXTCLOUD_PUBLIC_ISSUER_URL
|
||||||
|
value: {{ include "nextcloud-mcp-server.publicIssuerUrl" . | quote }}
|
||||||
|
- name: NEXTCLOUD_OIDC_SCOPES
|
||||||
|
value: {{ .Values.auth.oauth.scopes | quote }}
|
||||||
|
{{- if .Values.auth.oauth.clientId }}
|
||||||
|
- name: NEXTCLOUD_OIDC_CLIENT_ID
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: {{ include "nextcloud-mcp-server.oauthSecretName" . }}
|
||||||
|
key: {{ .Values.auth.oauth.clientIdKey }}
|
||||||
|
- name: NEXTCLOUD_OIDC_CLIENT_SECRET
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: {{ include "nextcloud-mcp-server.oauthSecretName" . }}
|
||||||
|
key: {{ .Values.auth.oauth.clientSecretKey }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.documentProcessing.enabled }}
|
||||||
|
# Document processing
|
||||||
|
- name: ENABLE_DOCUMENT_PROCESSING
|
||||||
|
value: {{ .Values.documentProcessing.enabled | quote }}
|
||||||
|
- name: DOCUMENT_PROCESSOR
|
||||||
|
value: {{ .Values.documentProcessing.defaultProcessor | quote }}
|
||||||
|
- name: PROGRESS_INTERVAL
|
||||||
|
value: {{ .Values.documentProcessing.progressInterval | quote }}
|
||||||
|
{{- if .Values.documentProcessing.unstructured.enabled }}
|
||||||
|
- name: ENABLE_UNSTRUCTURED
|
||||||
|
value: "true"
|
||||||
|
- name: UNSTRUCTURED_API_URL
|
||||||
|
value: {{ .Values.documentProcessing.unstructured.apiUrl | quote }}
|
||||||
|
- name: UNSTRUCTURED_TIMEOUT
|
||||||
|
value: {{ .Values.documentProcessing.unstructured.timeout | quote }}
|
||||||
|
- name: UNSTRUCTURED_STRATEGY
|
||||||
|
value: {{ .Values.documentProcessing.unstructured.strategy | quote }}
|
||||||
|
- name: UNSTRUCTURED_LANGUAGES
|
||||||
|
value: {{ .Values.documentProcessing.unstructured.languages | quote }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.documentProcessing.tesseract.enabled }}
|
||||||
|
- name: ENABLE_TESSERACT
|
||||||
|
value: "true"
|
||||||
|
{{- if .Values.documentProcessing.tesseract.cmd }}
|
||||||
|
- name: TESSERACT_CMD
|
||||||
|
value: {{ .Values.documentProcessing.tesseract.cmd | quote }}
|
||||||
|
{{- end }}
|
||||||
|
- name: TESSERACT_LANG
|
||||||
|
value: {{ .Values.documentProcessing.tesseract.lang | quote }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.documentProcessing.custom.enabled }}
|
||||||
|
- name: ENABLE_CUSTOM_PROCESSOR
|
||||||
|
value: "true"
|
||||||
|
- name: CUSTOM_PROCESSOR_NAME
|
||||||
|
value: {{ .Values.documentProcessing.custom.name | quote }}
|
||||||
|
- name: CUSTOM_PROCESSOR_URL
|
||||||
|
value: {{ .Values.documentProcessing.custom.url | quote }}
|
||||||
|
{{- if .Values.documentProcessing.custom.apiKey }}
|
||||||
|
- name: CUSTOM_PROCESSOR_API_KEY
|
||||||
|
value: {{ .Values.documentProcessing.custom.apiKey | quote }}
|
||||||
|
{{- end }}
|
||||||
|
- name: CUSTOM_PROCESSOR_TIMEOUT
|
||||||
|
value: {{ .Values.documentProcessing.custom.timeout | quote }}
|
||||||
|
- name: CUSTOM_PROCESSOR_TYPES
|
||||||
|
value: {{ .Values.documentProcessing.custom.types | quote }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
{{- with .Values.extraEnv }}
|
||||||
|
{{- toYaml . | nindent 12 }}
|
||||||
|
{{- end }}
|
||||||
|
{{- with .Values.extraEnvFrom }}
|
||||||
|
envFrom:
|
||||||
|
{{- toYaml . | nindent 12 }}
|
||||||
|
{{- end }}
|
||||||
|
livenessProbe:
|
||||||
|
{{- toYaml .Values.livenessProbe | nindent 12 }}
|
||||||
|
readinessProbe:
|
||||||
|
{{- toYaml .Values.readinessProbe | nindent 12 }}
|
||||||
|
resources:
|
||||||
|
{{- toYaml .Values.resources | nindent 12 }}
|
||||||
|
volumeMounts:
|
||||||
|
- name: tmp
|
||||||
|
mountPath: /tmp
|
||||||
|
{{- if and (eq .Values.auth.mode "oauth") .Values.auth.oauth.persistence.enabled }}
|
||||||
|
- name: oauth-storage
|
||||||
|
mountPath: /app/.oauth
|
||||||
|
{{- end }}
|
||||||
|
{{- with .Values.volumeMounts }}
|
||||||
|
{{- toYaml . | nindent 12 }}
|
||||||
|
{{- end }}
|
||||||
|
volumes:
|
||||||
|
- name: tmp
|
||||||
|
emptyDir: {}
|
||||||
|
{{- if and (eq .Values.auth.mode "oauth") .Values.auth.oauth.persistence.enabled }}
|
||||||
|
- name: oauth-storage
|
||||||
|
persistentVolumeClaim:
|
||||||
|
claimName: {{ include "nextcloud-mcp-server.oauthPvcName" . }}
|
||||||
|
{{- end }}
|
||||||
|
{{- with .Values.volumes }}
|
||||||
|
{{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
|
{{- with .Values.nodeSelector }}
|
||||||
|
nodeSelector:
|
||||||
|
{{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
|
{{- with .Values.affinity }}
|
||||||
|
affinity:
|
||||||
|
{{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
|
{{- with .Values.tolerations }}
|
||||||
|
tolerations:
|
||||||
|
{{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
{{- if .Values.autoscaling.enabled }}
|
||||||
|
apiVersion: autoscaling/v2
|
||||||
|
kind: HorizontalPodAutoscaler
|
||||||
|
metadata:
|
||||||
|
name: {{ include "nextcloud-mcp-server.fullname" . }}
|
||||||
|
labels:
|
||||||
|
{{- include "nextcloud-mcp-server.labels" . | nindent 4 }}
|
||||||
|
spec:
|
||||||
|
scaleTargetRef:
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
name: {{ include "nextcloud-mcp-server.fullname" . }}
|
||||||
|
minReplicas: {{ .Values.autoscaling.minReplicas }}
|
||||||
|
maxReplicas: {{ .Values.autoscaling.maxReplicas }}
|
||||||
|
metrics:
|
||||||
|
{{- if .Values.autoscaling.targetCPUUtilizationPercentage }}
|
||||||
|
- type: Resource
|
||||||
|
resource:
|
||||||
|
name: cpu
|
||||||
|
target:
|
||||||
|
type: Utilization
|
||||||
|
averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.autoscaling.targetMemoryUtilizationPercentage }}
|
||||||
|
- type: Resource
|
||||||
|
resource:
|
||||||
|
name: memory
|
||||||
|
target:
|
||||||
|
type: Utilization
|
||||||
|
averageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
{{- if .Values.ingress.enabled -}}
|
||||||
|
{{- $fullName := include "nextcloud-mcp-server.fullname" . -}}
|
||||||
|
{{- $svcPort := .Values.service.port -}}
|
||||||
|
{{- if and .Values.ingress.className (not (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion)) }}
|
||||||
|
{{- if not (hasKey .Values.ingress.annotations "kubernetes.io/ingress.class") }}
|
||||||
|
{{- $_ := set .Values.ingress.annotations "kubernetes.io/ingress.class" .Values.ingress.className}}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if semverCompare ">=1.19-0" .Capabilities.KubeVersion.GitVersion -}}
|
||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
{{- else if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}}
|
||||||
|
apiVersion: networking.k8s.io/v1beta1
|
||||||
|
{{- else -}}
|
||||||
|
apiVersion: extensions/v1beta1
|
||||||
|
{{- end }}
|
||||||
|
kind: Ingress
|
||||||
|
metadata:
|
||||||
|
name: {{ $fullName }}
|
||||||
|
labels:
|
||||||
|
{{- include "nextcloud-mcp-server.labels" . | nindent 4 }}
|
||||||
|
{{- with .Values.ingress.annotations }}
|
||||||
|
annotations:
|
||||||
|
{{- toYaml . | nindent 4 }}
|
||||||
|
{{- end }}
|
||||||
|
spec:
|
||||||
|
{{- if and .Values.ingress.className (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion) }}
|
||||||
|
ingressClassName: {{ .Values.ingress.className }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.ingress.tls }}
|
||||||
|
tls:
|
||||||
|
{{- range .Values.ingress.tls }}
|
||||||
|
- hosts:
|
||||||
|
{{- range .hosts }}
|
||||||
|
- {{ . | quote }}
|
||||||
|
{{- end }}
|
||||||
|
secretName: {{ .secretName }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
rules:
|
||||||
|
{{- range .Values.ingress.hosts }}
|
||||||
|
- host: {{ .host | quote }}
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
{{- range .paths }}
|
||||||
|
- path: {{ .path }}
|
||||||
|
{{- if and .pathType (semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion) }}
|
||||||
|
pathType: {{ .pathType }}
|
||||||
|
{{- end }}
|
||||||
|
backend:
|
||||||
|
{{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }}
|
||||||
|
service:
|
||||||
|
name: {{ $fullName }}
|
||||||
|
port:
|
||||||
|
number: {{ $svcPort }}
|
||||||
|
{{- else }}
|
||||||
|
serviceName: {{ $fullName }}
|
||||||
|
servicePort: {{ $svcPort }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{{- if and (eq .Values.auth.mode "oauth") .Values.auth.oauth.persistence.enabled (not .Values.auth.oauth.persistence.existingClaim) }}
|
||||||
|
apiVersion: v1
|
||||||
|
kind: PersistentVolumeClaim
|
||||||
|
metadata:
|
||||||
|
name: {{ include "nextcloud-mcp-server.fullname" . }}-oauth-storage
|
||||||
|
labels:
|
||||||
|
{{- include "nextcloud-mcp-server.labels" . | nindent 4 }}
|
||||||
|
spec:
|
||||||
|
accessModes:
|
||||||
|
- {{ .Values.auth.oauth.persistence.accessMode }}
|
||||||
|
{{- if .Values.auth.oauth.persistence.storageClass }}
|
||||||
|
storageClassName: {{ .Values.auth.oauth.persistence.storageClass }}
|
||||||
|
{{- end }}
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: {{ .Values.auth.oauth.persistence.size }}
|
||||||
|
{{- end }}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
{{- if eq .Values.auth.mode "basic" }}
|
||||||
|
{{- if not .Values.auth.basic.existingSecret }}
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: {{ include "nextcloud-mcp-server.fullname" . }}-basic-auth
|
||||||
|
labels:
|
||||||
|
{{- include "nextcloud-mcp-server.labels" . | nindent 4 }}
|
||||||
|
type: Opaque
|
||||||
|
data:
|
||||||
|
{{ .Values.auth.basic.usernameKey }}: {{ .Values.auth.basic.username | b64enc | quote }}
|
||||||
|
{{ .Values.auth.basic.passwordKey }}: {{ .Values.auth.basic.password | b64enc | quote }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
---
|
||||||
|
{{- if eq .Values.auth.mode "oauth" }}
|
||||||
|
{{- if and .Values.auth.oauth.clientId (not .Values.auth.oauth.existingSecret) }}
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: {{ include "nextcloud-mcp-server.fullname" . }}-oauth
|
||||||
|
labels:
|
||||||
|
{{- include "nextcloud-mcp-server.labels" . | nindent 4 }}
|
||||||
|
type: Opaque
|
||||||
|
data:
|
||||||
|
{{ .Values.auth.oauth.clientIdKey }}: {{ .Values.auth.oauth.clientId | b64enc | quote }}
|
||||||
|
{{ .Values.auth.oauth.clientSecretKey }}: {{ .Values.auth.oauth.clientSecret | b64enc | quote }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: {{ include "nextcloud-mcp-server.fullname" . }}
|
||||||
|
labels:
|
||||||
|
{{- include "nextcloud-mcp-server.labels" . | nindent 4 }}
|
||||||
|
{{- with .Values.service.annotations }}
|
||||||
|
annotations:
|
||||||
|
{{- toYaml . | nindent 4 }}
|
||||||
|
{{- end }}
|
||||||
|
spec:
|
||||||
|
type: {{ .Values.service.type }}
|
||||||
|
ports:
|
||||||
|
- port: {{ .Values.service.port }}
|
||||||
|
targetPort: http
|
||||||
|
protocol: TCP
|
||||||
|
name: http
|
||||||
|
selector:
|
||||||
|
{{- include "nextcloud-mcp-server.selectorLabels" . | nindent 4 }}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{{- if .Values.serviceAccount.create -}}
|
||||||
|
apiVersion: v1
|
||||||
|
kind: ServiceAccount
|
||||||
|
metadata:
|
||||||
|
name: {{ include "nextcloud-mcp-server.serviceAccountName" . }}
|
||||||
|
labels:
|
||||||
|
{{- include "nextcloud-mcp-server.labels" . | nindent 4 }}
|
||||||
|
{{- with .Values.serviceAccount.annotations }}
|
||||||
|
annotations:
|
||||||
|
{{- toYaml . | nindent 4 }}
|
||||||
|
{{- end }}
|
||||||
|
automountServiceAccountToken: {{ .Values.serviceAccount.automount }}
|
||||||
|
{{- end }}
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
# Default values for nextcloud-mcp-server
|
||||||
|
# This is a YAML-formatted file.
|
||||||
|
# Declare variables to be passed into your templates.
|
||||||
|
|
||||||
|
# Number of replicas
|
||||||
|
replicaCount: 1
|
||||||
|
|
||||||
|
image:
|
||||||
|
repository: ghcr.io/cbcoutinho/nextcloud-mcp-server
|
||||||
|
pullPolicy: IfNotPresent
|
||||||
|
# Image tag is automatically set to chart appVersion
|
||||||
|
|
||||||
|
imagePullSecrets: []
|
||||||
|
nameOverride: ""
|
||||||
|
fullnameOverride: ""
|
||||||
|
|
||||||
|
# Nextcloud connection settings
|
||||||
|
nextcloud:
|
||||||
|
# URL of your Nextcloud instance (required)
|
||||||
|
# Example: https://cloud.example.com
|
||||||
|
host: ""
|
||||||
|
|
||||||
|
# MCP server URL for OAuth callbacks (OAuth mode only)
|
||||||
|
# If not specified, will be constructed from ingress.hosts[0] if ingress is enabled,
|
||||||
|
# or defaults to http://localhost:8000 (suitable for port-forward setups)
|
||||||
|
# Example: https://mcp.example.com
|
||||||
|
mcpServerUrl: ""
|
||||||
|
|
||||||
|
# Public issuer URL for OAuth (OAuth mode only)
|
||||||
|
# If not specified, defaults to nextcloud.host
|
||||||
|
# Only set this if your Nextcloud is accessible at a different URL for OAuth
|
||||||
|
# Example: https://cloud.example.com
|
||||||
|
publicIssuerUrl: ""
|
||||||
|
|
||||||
|
# Authentication configuration
|
||||||
|
# Choose either basic auth OR oauth (not both)
|
||||||
|
auth:
|
||||||
|
# Authentication mode: "basic" or "oauth"
|
||||||
|
# basic: Uses username/password (recommended for most users)
|
||||||
|
# oauth: Uses OAuth2/OIDC (experimental, requires patches)
|
||||||
|
mode: basic
|
||||||
|
|
||||||
|
# Basic authentication settings
|
||||||
|
basic:
|
||||||
|
# Nextcloud username (ignored if existingSecret is set)
|
||||||
|
username: ""
|
||||||
|
# Nextcloud password or app password (recommended) (ignored if existingSecret is set)
|
||||||
|
password: ""
|
||||||
|
# Use existing secret instead of creating one
|
||||||
|
# If set, username and password above are ignored
|
||||||
|
# Secret must contain keys specified in usernameKey and passwordKey
|
||||||
|
# Example:
|
||||||
|
# kubectl create secret generic my-nextcloud-creds \
|
||||||
|
# --from-literal=username=myuser \
|
||||||
|
# --from-literal=password=mypassword
|
||||||
|
existingSecret: ""
|
||||||
|
# Keys in the existing secret
|
||||||
|
usernameKey: "username"
|
||||||
|
passwordKey: "password"
|
||||||
|
|
||||||
|
# OAuth2/OIDC settings (experimental)
|
||||||
|
oauth:
|
||||||
|
# OAuth token type: "jwt" or "opaque"
|
||||||
|
tokenType: "jwt"
|
||||||
|
# Pre-registered OAuth client ID (optional, ignored if existingSecret is set)
|
||||||
|
# If not provided and no existingSecret, will use Dynamic Client Registration (DCR)
|
||||||
|
clientId: ""
|
||||||
|
# Pre-registered OAuth client secret (optional, ignored if existingSecret is set)
|
||||||
|
clientSecret: ""
|
||||||
|
# OAuth scopes to request (space-separated)
|
||||||
|
scopes: "openid profile email notes:read notes:write calendar:read calendar:write contacts:read contacts:write cookbook:read cookbook:write deck:read deck:write tables:read tables:write files:read files:write sharing:read sharing:write todo:read todo:write"
|
||||||
|
# Use existing secret for OAuth client credentials
|
||||||
|
# If set, clientId and clientSecret above are ignored
|
||||||
|
# Secret must contain keys specified in clientIdKey and clientSecretKey
|
||||||
|
# Example:
|
||||||
|
# kubectl create secret generic my-oauth-creds \
|
||||||
|
# --from-literal=clientId=my-client-id \
|
||||||
|
# --from-literal=clientSecret=my-client-secret
|
||||||
|
existingSecret: ""
|
||||||
|
# Keys in the existing secret
|
||||||
|
clientIdKey: "clientId"
|
||||||
|
clientSecretKey: "clientSecret"
|
||||||
|
# Persistent storage for OAuth client credentials
|
||||||
|
persistence:
|
||||||
|
enabled: true
|
||||||
|
# Storage class (leave empty for default)
|
||||||
|
storageClass: ""
|
||||||
|
accessMode: ReadWriteOnce
|
||||||
|
size: 100Mi
|
||||||
|
# Use existing PVC
|
||||||
|
existingClaim: ""
|
||||||
|
|
||||||
|
# MCP server configuration
|
||||||
|
mcp:
|
||||||
|
# Transport mode (default: streamable-http for SSE)
|
||||||
|
transport: "streamable-http"
|
||||||
|
# Port for MCP server (both basic auth and OAuth modes)
|
||||||
|
port: 8000
|
||||||
|
# Additional command-line arguments to pass to nextcloud-mcp-server
|
||||||
|
# Example: ["--log-level", "debug", "--enable-app", "notes"]
|
||||||
|
extraArgs: []
|
||||||
|
|
||||||
|
# Document processing configuration (optional)
|
||||||
|
documentProcessing:
|
||||||
|
# Enable document processing (PDF, DOCX, images, etc.)
|
||||||
|
enabled: false
|
||||||
|
# Default processor: unstructured, tesseract, or custom
|
||||||
|
defaultProcessor: "unstructured"
|
||||||
|
# Progress reporting interval in seconds
|
||||||
|
progressInterval: 10
|
||||||
|
|
||||||
|
# Unstructured.io processor
|
||||||
|
unstructured:
|
||||||
|
enabled: false
|
||||||
|
# Unstructured API endpoint
|
||||||
|
apiUrl: "http://unstructured:8000"
|
||||||
|
# Request timeout in seconds
|
||||||
|
timeout: 120
|
||||||
|
# Parsing strategy: auto, fast, or hi_res
|
||||||
|
strategy: "auto"
|
||||||
|
# OCR languages (comma-separated ISO 639-3 codes)
|
||||||
|
languages: "eng,deu"
|
||||||
|
|
||||||
|
# Tesseract processor (local OCR)
|
||||||
|
tesseract:
|
||||||
|
enabled: false
|
||||||
|
# Path to tesseract executable (optional, auto-detected if in PATH)
|
||||||
|
cmd: ""
|
||||||
|
# OCR language (e.g., eng, deu, eng+deu for multiple)
|
||||||
|
lang: "eng"
|
||||||
|
|
||||||
|
# Custom processor
|
||||||
|
custom:
|
||||||
|
enabled: false
|
||||||
|
# Unique name for your processor
|
||||||
|
name: "my_ocr"
|
||||||
|
# Custom processor API endpoint
|
||||||
|
url: ""
|
||||||
|
# Optional API key for authentication
|
||||||
|
apiKey: ""
|
||||||
|
# Request timeout in seconds
|
||||||
|
timeout: 60
|
||||||
|
# Comma-separated MIME types your processor supports
|
||||||
|
types: "application/pdf,image/jpeg,image/png"
|
||||||
|
|
||||||
|
serviceAccount:
|
||||||
|
# Specifies whether a service account should be created
|
||||||
|
create: true
|
||||||
|
# Automatically mount a ServiceAccount's API credentials?
|
||||||
|
automount: true
|
||||||
|
# Annotations to add to the service account
|
||||||
|
annotations: {}
|
||||||
|
# The name of the service account to use.
|
||||||
|
# If not set and create is true, a name is generated using the fullname template
|
||||||
|
name: ""
|
||||||
|
|
||||||
|
podAnnotations: {}
|
||||||
|
podLabels: {}
|
||||||
|
|
||||||
|
podSecurityContext:
|
||||||
|
fsGroup: 2000
|
||||||
|
|
||||||
|
securityContext:
|
||||||
|
capabilities:
|
||||||
|
drop:
|
||||||
|
- ALL
|
||||||
|
readOnlyRootFilesystem: true
|
||||||
|
runAsNonRoot: true
|
||||||
|
runAsUser: 1000
|
||||||
|
|
||||||
|
service:
|
||||||
|
type: ClusterIP
|
||||||
|
port: 8000
|
||||||
|
annotations: {}
|
||||||
|
|
||||||
|
ingress:
|
||||||
|
enabled: false
|
||||||
|
className: ""
|
||||||
|
annotations: {}
|
||||||
|
# kubernetes.io/ingress.class: nginx
|
||||||
|
# kubernetes.io/tls-acme: "true"
|
||||||
|
# cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||||
|
hosts:
|
||||||
|
- host: mcp.example.com
|
||||||
|
paths:
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
tls: []
|
||||||
|
# - secretName: nextcloud-mcp-tls
|
||||||
|
# hosts:
|
||||||
|
# - mcp.example.com
|
||||||
|
|
||||||
|
resources:
|
||||||
|
# We recommend setting resource requests and limits
|
||||||
|
limits:
|
||||||
|
cpu: 1000m
|
||||||
|
memory: 512Mi
|
||||||
|
requests:
|
||||||
|
cpu: 100m
|
||||||
|
memory: 128Mi
|
||||||
|
|
||||||
|
# Liveness probe configuration
|
||||||
|
# Checks if the application process is running
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /health/live
|
||||||
|
port: http
|
||||||
|
scheme: HTTP
|
||||||
|
initialDelaySeconds: 30
|
||||||
|
periodSeconds: 10
|
||||||
|
timeoutSeconds: 5
|
||||||
|
failureThreshold: 3
|
||||||
|
|
||||||
|
# Readiness probe configuration
|
||||||
|
# Checks if the application is ready to serve traffic
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /health/ready
|
||||||
|
port: http
|
||||||
|
scheme: HTTP
|
||||||
|
initialDelaySeconds: 10
|
||||||
|
periodSeconds: 5
|
||||||
|
timeoutSeconds: 3
|
||||||
|
failureThreshold: 3
|
||||||
|
|
||||||
|
# Autoscaling configuration
|
||||||
|
autoscaling:
|
||||||
|
enabled: false
|
||||||
|
minReplicas: 1
|
||||||
|
maxReplicas: 10
|
||||||
|
targetCPUUtilizationPercentage: 80
|
||||||
|
# targetMemoryUtilizationPercentage: 80
|
||||||
|
|
||||||
|
# Additional volumes on the output Deployment definition.
|
||||||
|
volumes: []
|
||||||
|
# - name: foo
|
||||||
|
# secret:
|
||||||
|
# secretName: mysecret
|
||||||
|
# optional: false
|
||||||
|
|
||||||
|
# Additional volumeMounts on the output Deployment definition.
|
||||||
|
volumeMounts: []
|
||||||
|
# - name: foo
|
||||||
|
# mountPath: "/etc/foo"
|
||||||
|
# readOnly: true
|
||||||
|
|
||||||
|
nodeSelector: {}
|
||||||
|
|
||||||
|
tolerations: []
|
||||||
|
|
||||||
|
affinity: {}
|
||||||
|
|
||||||
|
# Init containers
|
||||||
|
initContainers: []
|
||||||
|
|
||||||
|
# Additional environment variables
|
||||||
|
extraEnv: []
|
||||||
|
# - name: CUSTOM_VAR
|
||||||
|
# value: "custom_value"
|
||||||
|
|
||||||
|
# Additional environment variables from ConfigMaps or Secrets
|
||||||
|
extraEnvFrom: []
|
||||||
|
# - configMapRef:
|
||||||
|
# name: my-configmap
|
||||||
|
# - secretRef:
|
||||||
|
# name: my-secret
|
||||||
+108
-6
@@ -21,7 +21,7 @@ services:
|
|||||||
restart: always
|
restart: always
|
||||||
|
|
||||||
app:
|
app:
|
||||||
image: docker.io/library/nextcloud:32.0.0@sha256:3e70e4dfe882ef44738fdc30d9896fb07c12febb27c4a1177e3d63dc0004a0b4
|
image: docker.io/library/nextcloud:32.0.1@sha256:1e4eae55eebe094cae6f9e7b6e0b4bccf4a4fe7b7e6f6f8f57010994b3b2ee42
|
||||||
restart: always
|
restart: always
|
||||||
ports:
|
ports:
|
||||||
- 0.0.0.0:8080:80
|
- 0.0.0.0:8080:80
|
||||||
@@ -30,7 +30,10 @@ services:
|
|||||||
- db
|
- db
|
||||||
volumes:
|
volumes:
|
||||||
- nextcloud:/var/www/html
|
- nextcloud:/var/www/html
|
||||||
- ./app-hooks/post-installation:/docker-entrypoint-hooks.d/post-installation:ro
|
- ./app-hooks:/docker-entrypoint-hooks.d:ro
|
||||||
|
# Mount OIDC development directory outside /var/www/html to avoid rsync conflicts
|
||||||
|
# The post-installation hook will register /opt/apps as an additional app directory
|
||||||
|
- ./third_party:/opt/apps:ro
|
||||||
environment:
|
environment:
|
||||||
- NEXTCLOUD_TRUSTED_DOMAINS=app
|
- NEXTCLOUD_TRUSTED_DOMAINS=app
|
||||||
- NEXTCLOUD_ADMIN_USER=admin
|
- NEXTCLOUD_ADMIN_USER=admin
|
||||||
@@ -39,6 +42,29 @@ services:
|
|||||||
- MYSQL_DATABASE=nextcloud
|
- MYSQL_DATABASE=nextcloud
|
||||||
- MYSQL_USER=nextcloud
|
- MYSQL_USER=nextcloud
|
||||||
- MYSQL_HOST=db
|
- MYSQL_HOST=db
|
||||||
|
- REDIS_HOST=redis
|
||||||
|
#healthcheck:
|
||||||
|
#test: ["CMD-SHELL", "curl -Ss http://localhost/status.php | grep '\"installed\":true' || exit 1"]
|
||||||
|
#interval: 10s
|
||||||
|
#timeout: 30s
|
||||||
|
#retries: 30
|
||||||
|
|
||||||
|
recipes:
|
||||||
|
image: docker.io/library/nginx:alpine@sha256:b3c656d55d7ad751196f21b7fd2e8d4da9cb430e32f646adcf92441b72f82b14
|
||||||
|
restart: always
|
||||||
|
volumes:
|
||||||
|
- ./tests/fixtures/test_recipe.html:/usr/share/nginx/html/test_recipe.html:ro
|
||||||
|
- ./tests/fixtures/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||||
|
|
||||||
|
unstructured:
|
||||||
|
image: downloads.unstructured.io/unstructured-io/unstructured-api:latest@sha256:a43ab55898599157fb0e0e097dabb8ecdd1d8e3df1ae5b67c6e15a136b171a6c
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- 127.0.0.1:8002:8000
|
||||||
|
# Unstructured API runs on port 8000 internally
|
||||||
|
# We expose it on 8002 externally to avoid conflict
|
||||||
|
profiles:
|
||||||
|
- unstructured
|
||||||
|
|
||||||
mcp:
|
mcp:
|
||||||
build: .
|
build: .
|
||||||
@@ -55,21 +81,97 @@ services:
|
|||||||
|
|
||||||
mcp-oauth:
|
mcp-oauth:
|
||||||
build: .
|
build: .
|
||||||
command: ["--transport", "streamable-http", "--oauth", "--port", "8001"]
|
command: ["--transport", "streamable-http", "--oauth", "--port", "8001", "--oauth-token-type", "jwt"]
|
||||||
restart: always
|
restart: always
|
||||||
depends_on:
|
depends_on:
|
||||||
- app
|
- app
|
||||||
ports:
|
ports:
|
||||||
- 127.0.0.1:8001:8001
|
- 127.0.0.1:8001:8001
|
||||||
environment:
|
environment:
|
||||||
|
# Generic OIDC configuration (integrated mode - Nextcloud OIDC app)
|
||||||
|
# OIDC_DISCOVERY_URL not set - defaults to NEXTCLOUD_HOST/.well-known/openid-configuration
|
||||||
|
# OIDC_CLIENT_ID not set - uses Dynamic Client Registration (DCR)
|
||||||
- NEXTCLOUD_HOST=http://app:80
|
- NEXTCLOUD_HOST=http://app:80
|
||||||
- NEXTCLOUD_MCP_SERVER_URL=http://127.0.0.1:8001
|
- NEXTCLOUD_MCP_SERVER_URL=http://localhost:8001
|
||||||
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://127.0.0.1:8080
|
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8080
|
||||||
# No USERNAME/PASSWORD - will use OAuth
|
- NEXTCLOUD_OIDC_SCOPES=openid profile email notes:read notes:write calendar:read calendar:write contacts:read contacts:write cookbook:read cookbook:write deck:read deck:write tables:read tables:write files:read files:write sharing:read sharing:write todo:read todo:write
|
||||||
|
|
||||||
|
# Refresh token storage (ADR-002 Tier 1)
|
||||||
|
- ENABLE_OFFLINE_ACCESS=true
|
||||||
|
- TOKEN_ENCRYPTION_KEY=ESF1BvEQdGYsCluwMx9Cxvw3uh5pFowPH7Rg_nIliyo=
|
||||||
|
- TOKEN_STORAGE_DB=/app/data/tokens.db
|
||||||
|
|
||||||
|
# NO admin credentials - using OAuth with Dynamic Client Registration (DCR)
|
||||||
|
# Client credentials registered via RFC 7591 and stored in volume
|
||||||
|
# JWT token type is used for testing (faster validation, scopes embedded in token)
|
||||||
volumes:
|
volumes:
|
||||||
- oauth-client-storage:/app/.oauth
|
- oauth-client-storage:/app/.oauth
|
||||||
|
- oauth-tokens:/app/data
|
||||||
|
|
||||||
|
keycloak:
|
||||||
|
image: quay.io/keycloak/keycloak:26.4.2
|
||||||
|
command:
|
||||||
|
- "start-dev"
|
||||||
|
- "--import-realm"
|
||||||
|
- "--hostname=http://localhost:8888"
|
||||||
|
- "--hostname-strict=false"
|
||||||
|
- "--hostname-backchannel-dynamic=true"
|
||||||
|
- "--features=preview" # Enable Legacy V1 token exchange (supports both Standard V2 and Legacy V1)
|
||||||
|
ports:
|
||||||
|
- 127.0.0.1:8888:8080
|
||||||
|
environment:
|
||||||
|
- KC_BOOTSTRAP_ADMIN_USERNAME=admin
|
||||||
|
- KC_BOOTSTRAP_ADMIN_PASSWORD=admin
|
||||||
|
volumes:
|
||||||
|
- ./keycloak/realm-export.json:/opt/keycloak/data/import/realm.json:ro
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "exec 3<>/dev/tcp/localhost/8080 && echo -e 'GET /realms/nextcloud-mcp HTTP/1.1\\r\\nHost: localhost\\r\\nConnection: close\\r\\n\\r\\n' >&3 && cat <&3 | grep -q 'HTTP/1.1 200'"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 30
|
||||||
|
|
||||||
|
mcp-keycloak:
|
||||||
|
build: .
|
||||||
|
command: ["--transport", "streamable-http", "--oauth", "--port", "8002"]
|
||||||
|
restart: always
|
||||||
|
depends_on:
|
||||||
|
keycloak:
|
||||||
|
condition: service_healthy
|
||||||
|
app:
|
||||||
|
condition: service_started
|
||||||
|
ports:
|
||||||
|
- 127.0.0.1:8002:8002
|
||||||
|
environment:
|
||||||
|
# Generic OIDC configuration (external IdP mode - Keycloak)
|
||||||
|
# Provider auto-detected from OIDC_DISCOVERY_URL issuer
|
||||||
|
# Using internal Docker hostname for discovery to get consistent issuer
|
||||||
|
- OIDC_DISCOVERY_URL=http://keycloak:8080/realms/nextcloud-mcp/.well-known/openid-configuration
|
||||||
|
- OIDC_CLIENT_ID=nextcloud-mcp-server
|
||||||
|
- OIDC_CLIENT_SECRET=mcp-secret-change-in-production
|
||||||
|
- OIDC_JWKS_URI=http://keycloak:8080/realms/nextcloud-mcp/protocol/openid-connect/certs
|
||||||
|
|
||||||
|
# Nextcloud API endpoint (for accessing APIs with validated token)
|
||||||
|
- NEXTCLOUD_HOST=http://app:80
|
||||||
|
- NEXTCLOUD_MCP_SERVER_URL=http://localhost:8002
|
||||||
|
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8888/realms/nextcloud-mcp
|
||||||
|
|
||||||
|
# Refresh token storage (ADR-002 Tier 1 & 2)
|
||||||
|
- ENABLE_OFFLINE_ACCESS=true
|
||||||
|
- TOKEN_ENCRYPTION_KEY=ESF1BvEQdGYsCluwMx9Cxvw3uh5pFowPH7Rg_nIliyo=
|
||||||
|
- TOKEN_STORAGE_DB=/app/data/tokens.db
|
||||||
|
|
||||||
|
# OAuth scopes (optional - uses defaults if not specified)
|
||||||
|
- NEXTCLOUD_OIDC_SCOPES=openid profile email offline_access notes:read notes:write calendar:read calendar:write contacts:read contacts:write cookbook:read cookbook:write deck:read deck:write tables:read tables:write files:read files:write sharing:read sharing:write todo:read todo:write
|
||||||
|
|
||||||
|
# NO admin credentials - using external IdP OAuth only!
|
||||||
|
volumes:
|
||||||
|
- keycloak-tokens:/app/data
|
||||||
|
- keycloak-oauth-storage:/app/.oauth
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
nextcloud:
|
nextcloud:
|
||||||
db:
|
db:
|
||||||
oauth-client-storage:
|
oauth-client-storage:
|
||||||
|
oauth-tokens:
|
||||||
|
keycloak-tokens:
|
||||||
|
keycloak-oauth-storage:
|
||||||
|
|||||||
@@ -0,0 +1,959 @@
|
|||||||
|
# ADR-002: Vector Database Background Sync Authentication
|
||||||
|
|
||||||
|
## Status
|
||||||
|
Accepted - Tier 2 (Token Exchange with Delegation) Implemented
|
||||||
|
|
||||||
|
**Important**: Service account tokens (old Tier 1) have been rejected as they violate OAuth "act on-behalf-of" principles by creating Nextcloud user accounts for the MCP server.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
To enable semantic search capabilities, the MCP server needs to index user content (notes, files, calendar events) into a vector database. This requires a background sync worker that:
|
||||||
|
|
||||||
|
1. **Runs independently** of user requests (periodic or continuous operation)
|
||||||
|
2. **Accesses multiple users' content** to build a comprehensive search index
|
||||||
|
3. **Respects user permissions** - only index content users have access to
|
||||||
|
4. **Operates in OAuth mode** - where the MCP server doesn't have traditional admin credentials
|
||||||
|
|
||||||
|
### Current OAuth Architecture
|
||||||
|
|
||||||
|
The MCP server currently operates in two authentication modes:
|
||||||
|
|
||||||
|
1. **BasicAuth Mode**: Uses username/password credentials (typically admin account)
|
||||||
|
2. **OAuth Mode**: Single OAuth client, multiple user tokens
|
||||||
|
- Users authenticate via OAuth flow
|
||||||
|
- Each request includes user's access token
|
||||||
|
- Server creates per-request `NextcloudClient` with user's bearer token
|
||||||
|
- No tokens are stored server-side
|
||||||
|
|
||||||
|
### The Challenge
|
||||||
|
|
||||||
|
Background workers need long-lived authentication to:
|
||||||
|
- Index content continuously/periodically
|
||||||
|
- Process multiple users' data in batch operations
|
||||||
|
- Operate when users are not actively making requests
|
||||||
|
|
||||||
|
However, in OAuth mode:
|
||||||
|
- User access tokens are ephemeral (exist only during request)
|
||||||
|
- MCP server doesn't store user credentials
|
||||||
|
- Admin credentials defeat the purpose of OAuth
|
||||||
|
|
||||||
|
We need an OAuth-native solution that maintains security while enabling background operations.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
We will implement a **tiered OAuth authentication strategy** for background operations in OAuth mode. When OAuth authentication is not configured or available, the background sync feature is not available.
|
||||||
|
|
||||||
|
**Note**: This ADR applies only to **OAuth mode**. In BasicAuth mode (single-user deployments), credentials are already available via environment variables, and background operations work without additional configuration.
|
||||||
|
|
||||||
|
### OAuth "Act On-Behalf-Of" Principle
|
||||||
|
|
||||||
|
**Core Requirement**: The MCP server must NEVER create its own user identity in Nextcloud when operating in OAuth mode.
|
||||||
|
|
||||||
|
**Valid Patterns**:
|
||||||
|
- ✅ **Foreground operations**: Use user's access token from MCP request (currently implemented)
|
||||||
|
- ✅ **Background operations**: Token exchange to impersonate/delegate as user (requires provider support)
|
||||||
|
- ❌ **Service account**: Creates independent identity in Nextcloud (violates OAuth principles)
|
||||||
|
|
||||||
|
**Why This Matters**:
|
||||||
|
1. **Audit Trail**: All operations must be attributable to the actual user, not a service account
|
||||||
|
2. **Stateless Server**: MCP server should not have persistent identity/state in Nextcloud
|
||||||
|
3. **Security Model**: Avoid creating "admin by another name" with broad cross-user permissions
|
||||||
|
4. **OAuth Design**: OAuth tokens represent user authorization, not server authorization
|
||||||
|
|
||||||
|
**If Token Exchange Not Available**:
|
||||||
|
- Background operations simply cannot happen in OAuth mode
|
||||||
|
- This is correct behavior - not a limitation to work around
|
||||||
|
- Don't create service accounts as "workaround" - this defeats OAuth's purpose
|
||||||
|
- Use BasicAuth mode if background operations are critical to your deployment
|
||||||
|
|
||||||
|
### Tier 1: Token Exchange with Impersonation (RFC 8693) ⚠️ **NOT IMPLEMENTED**
|
||||||
|
|
||||||
|
**Better Security** - Requires provider support for user impersonation
|
||||||
|
|
||||||
|
- Service account exchanges token to impersonate specific users
|
||||||
|
- Each background operation runs as the target user
|
||||||
|
- Uses `requested_subject` parameter in token exchange
|
||||||
|
- Per-user permission enforcement at API level
|
||||||
|
|
||||||
|
**Requirements**:
|
||||||
|
- OIDC provider supports RFC 8693 token exchange
|
||||||
|
- Provider supports user impersonation (rare - requires Legacy Keycloak V1 with preview features)
|
||||||
|
- Service account has impersonation permissions
|
||||||
|
|
||||||
|
**Status**: ⚠️ Not implemented - Keycloak Standard V2 doesn't support impersonation
|
||||||
|
**Reference**: See `docs/oauth-impersonation-findings.md` for investigation details
|
||||||
|
|
||||||
|
### Tier 2: Token Exchange with Delegation (RFC 8693) ✅ **IMPLEMENTED**
|
||||||
|
|
||||||
|
**Best Security** - Requires provider support for delegation with `act` claim
|
||||||
|
|
||||||
|
- Service account exchanges token on behalf of users (delegation, not impersonation)
|
||||||
|
- Token includes `act` claim showing service account as actor
|
||||||
|
- API sees both the user (`sub`) and actor (`act`) in token
|
||||||
|
- Full audit trail of delegated operations
|
||||||
|
- **Implementation**: `KeycloakOAuthClient.exchange_token_for_user()` (keycloak_oauth.py:397-495)
|
||||||
|
- **Testing**: Manual test in `tests/manual/test_token_exchange.py`
|
||||||
|
- **Limitation**: Keycloak doesn't support `act` claim yet - [Issue #38279](https://github.com/keycloak/keycloak/issues/38279)
|
||||||
|
|
||||||
|
**Requirements**:
|
||||||
|
- OIDC provider supports RFC 8693 token exchange
|
||||||
|
- Provider supports delegation with `act` claim (very rare)
|
||||||
|
- Proper token exchange permissions configured
|
||||||
|
|
||||||
|
**Current Implementation**: Internal-to-internal token exchange with audience modification (without `act` claim)
|
||||||
|
|
||||||
|
### ❌ Will Not Implement
|
||||||
|
|
||||||
|
**1. Service Account with Independent Identity (client_credentials)**
|
||||||
|
- **Status**: Previously proposed as Tier 1, now rejected
|
||||||
|
- **Why Invalid**: Creates Nextcloud user account for MCP server (e.g., `service-account-nextcloud-mcp-server`)
|
||||||
|
- **Problems**:
|
||||||
|
- **Violates OAuth "act on-behalf-of" principle**: Actions attributed to service account instead of real user
|
||||||
|
- **Breaks audit trail**: Can't determine which user initiated the action
|
||||||
|
- **Creates stateful server identity**: MCP server has persistent identity/data in Nextcloud
|
||||||
|
- **Security risk**: Service account becomes "admin by another name" with broad cross-user permissions
|
||||||
|
- **User provisioning side effect**: Nextcloud's `user_oidc` app auto-provisions service account as real user
|
||||||
|
- **Code Status**: Implementation exists (`KeycloakOAuthClient.get_service_account_token()`) but marked with warnings
|
||||||
|
- **Alternative**: If service account pattern truly needed, use BasicAuth mode instead of OAuth mode
|
||||||
|
- **Reference**: See commit c12df98 for detailed analysis of why this approach was rejected
|
||||||
|
|
||||||
|
**2. Offline Access with Refresh Tokens**
|
||||||
|
- **MCP Protocol Architecture**: FastMCP SDK manages OAuth where MCP Client handles refresh tokens
|
||||||
|
- **Security Model**: Refresh tokens must never be shared between client and server (OAuth best practice)
|
||||||
|
- **Technical Impossibility**: MCP Server has no access to refresh tokens from the OAuth callback
|
||||||
|
- **Alternative**: Token exchange provides similar benefits without violating OAuth security model
|
||||||
|
|
||||||
|
**3. Admin Credentials Fallback**
|
||||||
|
- **Out of Scope**: This ADR focuses on OAuth mode only
|
||||||
|
- **Not Appropriate**: Admin credentials bypass OAuth security model
|
||||||
|
- **BasicAuth Mode**: For single-user deployments needing background operations, use BasicAuth mode instead
|
||||||
|
|
||||||
|
### Key Architectural Principles
|
||||||
|
|
||||||
|
1. **Capability Detection**: Automatically detect which OAuth methods are supported
|
||||||
|
2. **Dual-Phase Authorization**:
|
||||||
|
- Sync worker indexes with service credentials
|
||||||
|
- User requests verify access with user's OAuth token
|
||||||
|
3. **Defense in Depth**: Vector database is search accelerator, not security boundary
|
||||||
|
4. **Separation of Concerns**: Sync credentials ≠ Request credentials
|
||||||
|
|
||||||
|
## Implementation Details
|
||||||
|
|
||||||
|
### 1. Token Exchange with Impersonation (Tier 1) ✅ IMPLEMENTED (Legacy V1 only)
|
||||||
|
|
||||||
|
**Status**: Implemented and working with Keycloak Legacy V1 (`--features=preview`). Requires additional permission configuration. Recommended for advanced use cases only.
|
||||||
|
|
||||||
|
**When to Use**: When you need the exchanged token to have the exact same identity as the target user (sub claim changes). This provides the cleanest separation but requires preview features.
|
||||||
|
|
||||||
|
#### 1.1 Impersonation Flow
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def exchange_token_for_user(
|
||||||
|
subject_token: str,
|
||||||
|
target_user_id: str,
|
||||||
|
audience: str | None = None,
|
||||||
|
scopes: list[str] | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Exchange service token to impersonate specific user.
|
||||||
|
|
||||||
|
Requires Keycloak Legacy V1 (--features=preview) and impersonation permissions.
|
||||||
|
The returned token will have the target_user_id as the 'sub' claim.
|
||||||
|
"""
|
||||||
|
data = {
|
||||||
|
"grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
|
||||||
|
"subject_token": subject_token,
|
||||||
|
"subject_token_type": "urn:ietf:params:oauth:token-type:access_token",
|
||||||
|
"requested_token_type": "urn:ietf:params:oauth:token-type:access_token",
|
||||||
|
"requested_subject": target_user_id, # ← KEY: Impersonate this user
|
||||||
|
}
|
||||||
|
|
||||||
|
if audience:
|
||||||
|
data["audience"] = audience
|
||||||
|
if scopes:
|
||||||
|
data["scope"] = " ".join(scopes)
|
||||||
|
|
||||||
|
response = await self._http_client.post(
|
||||||
|
self.token_endpoint,
|
||||||
|
data=data,
|
||||||
|
auth=(self.client_id, self.client_secret),
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()
|
||||||
|
```
|
||||||
|
|
||||||
|
**Implementation Requirements**:
|
||||||
|
- ✅ Keycloak Legacy V1 with `--features=preview` flag
|
||||||
|
- ✅ Impersonation role granted to service account (see configuration below)
|
||||||
|
- ❌ NOT supported in Keycloak Standard V2 (rejects `requested_subject` parameter)
|
||||||
|
- ⚠️ Very few OIDC providers support user impersonation via token exchange
|
||||||
|
|
||||||
|
**Empirical Testing (2025-11-02)**:
|
||||||
|
|
||||||
|
Tested impersonation with `requested_subject` parameter against Keycloak 26.4.2:
|
||||||
|
|
||||||
|
**Test Command**: `uv run python tests/manual/test_impersonation.py`
|
||||||
|
|
||||||
|
**Keycloak Standard V2 Result**:
|
||||||
|
```
|
||||||
|
HTTP/1.1 400 Bad Request
|
||||||
|
{
|
||||||
|
"error": "invalid_request",
|
||||||
|
"error_description": "Parameter 'requested_subject' is not supported for standard token exchange"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Confirmation**: Keycloak explicitly rejects `requested_subject` in Standard V2, confirming this feature is unsupported. The error message is unambiguous - this parameter is not available in the current production token exchange implementation.
|
||||||
|
|
||||||
|
**Keycloak Legacy V1 Result - Initial Test** (with `--features=preview`):
|
||||||
|
```
|
||||||
|
HTTP/1.1 403 Forbidden
|
||||||
|
{
|
||||||
|
"error": "access_denied",
|
||||||
|
"error_description": "Client not allowed to exchange"
|
||||||
|
}
|
||||||
|
|
||||||
|
Keycloak logs:
|
||||||
|
reason="subject not allowed to impersonate"
|
||||||
|
impersonator="service-account-nextcloud-mcp-server"
|
||||||
|
requested_subject="admin"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Analysis**: Legacy V1 **accepts** the `requested_subject` parameter (error changed from "not supported" to "not allowed"), indicating the feature is present but requires permission configuration.
|
||||||
|
|
||||||
|
**Configuration Steps to Enable Impersonation**:
|
||||||
|
|
||||||
|
1. **Enable Keycloak preview features** (in docker-compose.yml):
|
||||||
|
```yaml
|
||||||
|
command:
|
||||||
|
- "start-dev"
|
||||||
|
- "--features=preview" # Required for Legacy V1 token exchange
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Grant impersonation role to service account** (using Keycloak CLI):
|
||||||
|
```bash
|
||||||
|
docker compose exec keycloak /opt/keycloak/bin/kcadm.sh config credentials \
|
||||||
|
--server http://localhost:8080 \
|
||||||
|
--realm master \
|
||||||
|
--user admin \
|
||||||
|
--password admin
|
||||||
|
|
||||||
|
docker compose exec keycloak /opt/keycloak/bin/kcadm.sh add-roles \
|
||||||
|
-r nextcloud-mcp \
|
||||||
|
--uusername service-account-nextcloud-mcp-server \
|
||||||
|
--cclientid realm-management \
|
||||||
|
--rolename impersonation
|
||||||
|
```
|
||||||
|
|
||||||
|
**Keycloak Legacy V1 Result - After Permission Grant**:
|
||||||
|
```
|
||||||
|
✅ Token exchange with impersonation SUCCEEDED!
|
||||||
|
|
||||||
|
📊 Response details:
|
||||||
|
Issued token type: urn:ietf:params:oauth:token-type:access_token
|
||||||
|
Token type: Bearer
|
||||||
|
Expires in: 300s
|
||||||
|
|
||||||
|
📋 Token claims analysis:
|
||||||
|
Subject (sub): 47c3ba5a-9104-45e0-b84e-0e39ab942c9c (admin user)
|
||||||
|
Preferred username: admin
|
||||||
|
Client ID (azp): nextcloud-mcp-server
|
||||||
|
|
||||||
|
✅ IMPERSONATION VERIFIED:
|
||||||
|
Original sub: service-account-nextcloud-mcp-server
|
||||||
|
New sub: 47c3ba5a-9104-45e0-b84e-0e39ab942c9c
|
||||||
|
➡️ The subject claim CHANGED - impersonation worked!
|
||||||
|
```
|
||||||
|
|
||||||
|
**Nextcloud API Validation**:
|
||||||
|
The impersonated token successfully authenticated with Nextcloud APIs, confirming the token is valid and properly represents the target user.
|
||||||
|
|
||||||
|
**Implementation Status**: Impersonation **IS IMPLEMENTED** and working with Keycloak Legacy V1. The implementation has been tested and verified to work correctly when properly configured.
|
||||||
|
|
||||||
|
**Production Considerations**:
|
||||||
|
- ⚠️ Requires preview features (`--features=preview`) - not production-ready
|
||||||
|
- ⚠️ Requires Legacy V1 token exchange (may be deprecated in future Keycloak versions)
|
||||||
|
- ⚠️ Requires manual CLI configuration for each service account
|
||||||
|
- ⚠️ More complex permission model compared to delegation
|
||||||
|
|
||||||
|
**When to Use Tier 1 (Impersonation)**:
|
||||||
|
- ✅ You need the exchanged token to have the exact same identity as the target user
|
||||||
|
- ✅ You want the cleanest separation (sub claim changes completely)
|
||||||
|
- ✅ Your environment can support preview features
|
||||||
|
- ✅ You have operational processes to manage impersonation permissions
|
||||||
|
|
||||||
|
**Recommendation**: For most use cases, use Tier 2 (Delegation) instead. It provides equivalent "act on-behalf-of" capability using production-ready Standard V2 token exchange. Use Tier 1 only when you specifically need identity impersonation.
|
||||||
|
|
||||||
|
**Test Scripts**:
|
||||||
|
- `tests/manual/test_impersonation.py` - Complete impersonation test with validation
|
||||||
|
- `tests/manual/configure_impersonation.py` - Automated permission configuration helper
|
||||||
|
- **See**: `docs/oauth-impersonation-findings.md` for detailed investigation
|
||||||
|
|
||||||
|
### 2. Token Exchange with Delegation (Tier 2) ✅ IMPLEMENTED (Standard V2)
|
||||||
|
|
||||||
|
**Status**: Implemented and working with Keycloak Standard V2 (production-ready). This is the **recommended** approach for most use cases.
|
||||||
|
|
||||||
|
**When to Use**: When you need "act on-behalf-of" functionality with production-ready features. The service account maintains its identity (sub claim unchanged) but acts on behalf of the user. Fully supported in Keycloak Standard V2 without preview features.
|
||||||
|
|
||||||
|
#### 2.1 Capability Detection
|
||||||
|
```python
|
||||||
|
async def check_token_exchange_support(discovery_url: str) -> bool:
|
||||||
|
"""Check if OIDC provider supports RFC 8693 token exchange"""
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
response = await client.get(discovery_url)
|
||||||
|
discovery = response.json()
|
||||||
|
|
||||||
|
# Check for token exchange grant type
|
||||||
|
grant_types = discovery.get("grant_types_supported", [])
|
||||||
|
return "urn:ietf:params:oauth:grant-type:token-exchange" in grant_types
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2.2 Delegation Token Exchange
|
||||||
|
```python
|
||||||
|
async def exchange_for_user_token(
|
||||||
|
service_token: str,
|
||||||
|
target_user_id: str,
|
||||||
|
audience: str,
|
||||||
|
scopes: list[str]
|
||||||
|
) -> str:
|
||||||
|
"""Exchange service token for user-scoped token via RFC 8693"""
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
response = await client.post(
|
||||||
|
token_endpoint,
|
||||||
|
data={
|
||||||
|
"grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
|
||||||
|
"subject_token": service_token,
|
||||||
|
"subject_token_type": "urn:ietf:params:oauth:token-type:access_token",
|
||||||
|
"requested_token_type": "urn:ietf:params:oauth:token-type:access_token",
|
||||||
|
"audience": audience, # Target resource server (e.g., "nextcloud")
|
||||||
|
"scope": " ".join(scopes)
|
||||||
|
},
|
||||||
|
auth=(client_id, client_secret)
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
logger.warning(f"Token exchange failed: {response.status_code}")
|
||||||
|
raise TokenExchangeNotSupportedError()
|
||||||
|
|
||||||
|
return response.json()["access_token"]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Implementation**: `KeycloakOAuthClient.exchange_token_for_user()` (keycloak_oauth.py:397-495)
|
||||||
|
|
||||||
|
**Note**: Full delegation with `act` claim requires provider support that is currently very rare. Keycloak tracking: [Issue #38279](https://github.com/keycloak/keycloak/issues/38279)
|
||||||
|
|
||||||
|
### 3. Comparison: When to Use Each Tier
|
||||||
|
|
||||||
|
| Feature | Tier 1: Impersonation | Tier 2: Delegation (Recommended) |
|
||||||
|
|---------|----------------------|-----------------------------------|
|
||||||
|
| **Status** | ✅ Implemented (Legacy V1) | ✅ Implemented (Standard V2) |
|
||||||
|
| **Token Identity** | Target user (`sub` changes) | Service account (`sub` unchanged) |
|
||||||
|
| **Keycloak Version** | Legacy V1 (`--features=preview`) | Standard V2 (production-ready) |
|
||||||
|
| **Setup Complexity** | High (manual permissions) | Low (automatic) |
|
||||||
|
| **Production Ready** | ⚠️ Preview features required | ✅ Fully production-ready |
|
||||||
|
| **Permission Grant** | Manual CLI per service account | Automatic via token exchange |
|
||||||
|
| **Audit Trail** | Shows as target user | Shows as service account acting for user |
|
||||||
|
| **Token Claims** | `sub: user-id` | `sub: service-account-id` |
|
||||||
|
| **Provider Support** | Rare (Keycloak Legacy V1 only) | Common (Keycloak, Auth0, Okta) |
|
||||||
|
| **Use Case** | Need exact user identity | Standard OAuth workflows |
|
||||||
|
| **Recommendation** | Advanced use only | **Default choice** |
|
||||||
|
|
||||||
|
**Decision Guide**:
|
||||||
|
- ✅ **Use Tier 2 (Delegation)** for:
|
||||||
|
- Production deployments
|
||||||
|
- Standard OAuth workflows
|
||||||
|
- Clear audit trails (service account visible)
|
||||||
|
- Maximum provider compatibility
|
||||||
|
|
||||||
|
- ⚠️ **Use Tier 1 (Impersonation)** only if:
|
||||||
|
- You specifically need exact user identity (sub claim must match)
|
||||||
|
- You can accept preview/experimental features
|
||||||
|
- You have operational processes for permission management
|
||||||
|
- Your IdP supports `requested_subject` parameter
|
||||||
|
|
||||||
|
### 4. Sync Worker with Tiered Authentication
|
||||||
|
|
||||||
|
```python
|
||||||
|
# nextcloud_mcp_server/sync_worker.py
|
||||||
|
class VectorSyncWorker:
|
||||||
|
"""Background worker for indexing content into vector database"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.auth_method = None
|
||||||
|
self.oauth_client = None # KeycloakOAuthClient or similar
|
||||||
|
self.vector_service = None
|
||||||
|
|
||||||
|
async def initialize(self):
|
||||||
|
"""Detect and configure authentication method"""
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.auth.keycloak_oauth import KeycloakOAuthClient
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.oauth_client = KeycloakOAuthClient.from_env()
|
||||||
|
await self.oauth_client.discover()
|
||||||
|
|
||||||
|
# Verify service account access (Tier 1)
|
||||||
|
service_token = await self.oauth_client.get_service_account_token()
|
||||||
|
logger.info("✓ Service account token acquired")
|
||||||
|
|
||||||
|
# Check if token exchange is supported (Tier 2/3)
|
||||||
|
if await check_token_exchange_support(self.oauth_client.discovery_url):
|
||||||
|
self.auth_method = "token_exchange_delegation"
|
||||||
|
logger.info(
|
||||||
|
"✓ Token exchange supported (RFC 8693) - will use delegation for user-scoped operations"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.auth_method = "service_account"
|
||||||
|
logger.info(
|
||||||
|
"ℹ Token exchange not supported - using service account token for all operations"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to initialize OAuth authentication: {e}")
|
||||||
|
raise RuntimeError(
|
||||||
|
"OAuth authentication is required for background sync. "
|
||||||
|
"Either configure OIDC_CLIENT_ID/OIDC_CLIENT_SECRET with service account enabled, "
|
||||||
|
"or use BasicAuth mode for single-user deployments."
|
||||||
|
) from e
|
||||||
|
|
||||||
|
async def get_user_client(self, user_id: str) -> NextcloudClient:
|
||||||
|
"""Get authenticated client for user based on auth method"""
|
||||||
|
|
||||||
|
if self.auth_method == "token_exchange_delegation":
|
||||||
|
# Tier 2/3: Get service token and exchange for user-scoped token
|
||||||
|
service_token_data = await self.oauth_client.get_service_account_token()
|
||||||
|
|
||||||
|
user_token_data = await self.oauth_client.exchange_token_for_user(
|
||||||
|
subject_token=service_token_data["access_token"],
|
||||||
|
target_user_id=user_id,
|
||||||
|
audience="nextcloud",
|
||||||
|
scopes=["notes:read", "files:read", "calendar:read"]
|
||||||
|
)
|
||||||
|
|
||||||
|
return NextcloudClient.from_token(
|
||||||
|
base_url=nextcloud_host,
|
||||||
|
token=user_token_data["access_token"],
|
||||||
|
username=user_id
|
||||||
|
)
|
||||||
|
|
||||||
|
elif self.auth_method == "service_account":
|
||||||
|
# Tier 1: Use service account token directly (no user scoping)
|
||||||
|
service_token_data = await self.oauth_client.get_service_account_token()
|
||||||
|
|
||||||
|
return NextcloudClient.from_token(
|
||||||
|
base_url=nextcloud_host,
|
||||||
|
token=service_token_data["access_token"],
|
||||||
|
username="service-account"
|
||||||
|
)
|
||||||
|
|
||||||
|
raise RuntimeError(f"Unknown auth method: {self.auth_method}")
|
||||||
|
|
||||||
|
async def sync_user_content(self, user_id: str):
|
||||||
|
"""Index a user's content into vector database"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get authenticated client for this user
|
||||||
|
client = await self.get_user_client(user_id)
|
||||||
|
|
||||||
|
# Sync notes
|
||||||
|
notes = await client.notes.list_notes()
|
||||||
|
for note in notes:
|
||||||
|
embedding = await self.vector_service.embed(note.content)
|
||||||
|
await self.vector_service.upsert(
|
||||||
|
collection="nextcloud_content",
|
||||||
|
id=f"note_{note.id}",
|
||||||
|
vector=embedding,
|
||||||
|
metadata={
|
||||||
|
"user_id": user_id,
|
||||||
|
"content_type": "note",
|
||||||
|
"note_id": note.id,
|
||||||
|
"title": note.title,
|
||||||
|
"category": note.category
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Synced {len(notes)} notes for user: {user_id}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to sync user {user_id}: {e}")
|
||||||
|
|
||||||
|
async def run(self):
|
||||||
|
"""Main sync loop"""
|
||||||
|
|
||||||
|
await self.initialize()
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
# Get list of users to sync
|
||||||
|
# Implementation depends on how you track authenticated users
|
||||||
|
# Options:
|
||||||
|
# - Audit logs of MCP authentication events
|
||||||
|
# - MCP session history
|
||||||
|
# - Configured user list
|
||||||
|
# - If using service account with broad permissions: list all users
|
||||||
|
user_ids = await self.get_active_users()
|
||||||
|
|
||||||
|
logger.info(f"Syncing content for {len(user_ids)} users")
|
||||||
|
|
||||||
|
for user_id in user_ids:
|
||||||
|
await self.sync_user_content(user_id)
|
||||||
|
|
||||||
|
logger.info("Sync complete, sleeping...")
|
||||||
|
await asyncio.sleep(300) # 5 minutes
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Sync failed: {e}")
|
||||||
|
await asyncio.sleep(60) # Retry after 1 minute
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. User Request Verification (Dual-Phase Authorization)
|
||||||
|
|
||||||
|
```python
|
||||||
|
@mcp.tool()
|
||||||
|
@require_scopes("notes:read")
|
||||||
|
async def nc_notes_semantic_search(
|
||||||
|
query: str,
|
||||||
|
ctx: Context,
|
||||||
|
limit: int = 10
|
||||||
|
) -> SemanticSearchResponse:
|
||||||
|
"""Semantic search with permission verification"""
|
||||||
|
|
||||||
|
# Get user's OAuth client (uses their access token from request)
|
||||||
|
user_client = get_client(ctx)
|
||||||
|
username = user_client.username
|
||||||
|
|
||||||
|
# Phase 1: Vector search (fast, may include false positives)
|
||||||
|
embedding = await vector_service.embed(query)
|
||||||
|
candidate_results = await qdrant.search(
|
||||||
|
collection_name="nextcloud_content",
|
||||||
|
query_vector=embedding,
|
||||||
|
query_filter={
|
||||||
|
"must": [
|
||||||
|
{
|
||||||
|
"should": [
|
||||||
|
{"key": "user_id", "match": {"value": username}},
|
||||||
|
{"key": "shared_with", "match": {"any": [username]}}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{"key": "content_type", "match": {"value": "note"}}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
limit=limit * 2 # Get extra candidates
|
||||||
|
)
|
||||||
|
|
||||||
|
# Phase 2: Verify access via Nextcloud API (authoritative)
|
||||||
|
verified_results = []
|
||||||
|
for candidate in candidate_results:
|
||||||
|
note_id = candidate.payload["note_id"]
|
||||||
|
try:
|
||||||
|
# This uses user's OAuth token - will fail if no access
|
||||||
|
note = await user_client.notes.get_note(note_id)
|
||||||
|
verified_results.append({
|
||||||
|
"note": note,
|
||||||
|
"score": candidate.score
|
||||||
|
})
|
||||||
|
if len(verified_results) >= limit:
|
||||||
|
break
|
||||||
|
except HTTPStatusError as e:
|
||||||
|
if e.response.status_code == 403:
|
||||||
|
# User doesn't have access - skip silently
|
||||||
|
logger.debug(f"Filtered out note {note_id} for {username}")
|
||||||
|
continue
|
||||||
|
raise
|
||||||
|
|
||||||
|
return SemanticSearchResponse(results=verified_results)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Security Implementation
|
||||||
|
|
||||||
|
#### 5.1 Service Account Credentials Protection
|
||||||
|
```python
|
||||||
|
# Store OAuth client credentials securely
|
||||||
|
# NEVER commit to source control
|
||||||
|
|
||||||
|
# Option 1: Environment variables (for development)
|
||||||
|
export OIDC_CLIENT_ID="nextcloud-mcp-server"
|
||||||
|
export OIDC_CLIENT_SECRET="<secure-secret>"
|
||||||
|
|
||||||
|
# Option 2: Secrets manager (for production)
|
||||||
|
import boto3
|
||||||
|
secrets = boto3.client('secretsmanager')
|
||||||
|
secret = secrets.get_secret_value(SecretId='nextcloud-mcp-oauth')
|
||||||
|
client_secret = json.loads(secret['SecretString'])['client_secret']
|
||||||
|
|
||||||
|
# Option 3: Encrypted storage (for self-hosted)
|
||||||
|
from nextcloud_mcp_server.auth.refresh_token_storage import RefreshTokenStorage
|
||||||
|
|
||||||
|
storage = RefreshTokenStorage.from_env()
|
||||||
|
await storage.initialize()
|
||||||
|
|
||||||
|
# Client credentials are encrypted at rest using Fernet
|
||||||
|
client_data = await storage.get_oauth_client()
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 5.2 Token Lifecycle Management
|
||||||
|
```python
|
||||||
|
async def manage_service_token_lifecycle():
|
||||||
|
"""Cache and refresh service account tokens"""
|
||||||
|
|
||||||
|
# Cache service token (avoid repeated requests)
|
||||||
|
cached_token = None
|
||||||
|
token_expires_at = 0
|
||||||
|
|
||||||
|
async def get_fresh_service_token() -> str:
|
||||||
|
nonlocal cached_token, token_expires_at
|
||||||
|
|
||||||
|
now = time.time()
|
||||||
|
|
||||||
|
# Return cached token if still valid (with 5-minute buffer)
|
||||||
|
if cached_token and now < (token_expires_at - 300):
|
||||||
|
return cached_token
|
||||||
|
|
||||||
|
# Request new token
|
||||||
|
token_data = await oauth_client.get_service_account_token()
|
||||||
|
|
||||||
|
cached_token = token_data["access_token"]
|
||||||
|
token_expires_at = now + token_data.get("expires_in", 3600)
|
||||||
|
|
||||||
|
logger.info("Service account token refreshed")
|
||||||
|
return cached_token
|
||||||
|
|
||||||
|
return get_fresh_service_token
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 5.3 Audit Logging
|
||||||
|
```python
|
||||||
|
async def audit_log(
|
||||||
|
event: str,
|
||||||
|
user_id: str,
|
||||||
|
resource_type: str,
|
||||||
|
resource_id: str,
|
||||||
|
auth_method: str
|
||||||
|
):
|
||||||
|
"""Log sync operations for audit trail"""
|
||||||
|
|
||||||
|
await audit_db.execute(
|
||||||
|
"INSERT INTO audit_logs VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(
|
||||||
|
int(time.time()),
|
||||||
|
event, # "index_note", "index_file"
|
||||||
|
user_id,
|
||||||
|
resource_type,
|
||||||
|
resource_id,
|
||||||
|
auth_method,
|
||||||
|
socket.gethostname()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Configuration
|
||||||
|
|
||||||
|
#### 6.1 Environment Variables
|
||||||
|
```bash
|
||||||
|
# OAuth Configuration (Required for Background Sync in OAuth Mode)
|
||||||
|
# Requires external OIDC provider with client_credentials support
|
||||||
|
OIDC_DISCOVERY_URL=http://keycloak:8080/realms/nextcloud-mcp/.well-known/openid-configuration
|
||||||
|
OIDC_CLIENT_ID=nextcloud-mcp-server
|
||||||
|
OIDC_CLIENT_SECRET=<secure-secret>
|
||||||
|
NEXTCLOUD_HOST=http://app:80
|
||||||
|
|
||||||
|
# Tier selection is automatic:
|
||||||
|
# - Tier 1 (service_account): Always available if client has service account enabled
|
||||||
|
# - Tier 2/3 (token_exchange): Used if provider supports RFC 8693 token exchange
|
||||||
|
|
||||||
|
# Vector Database
|
||||||
|
QDRANT_URL=http://qdrant:6333
|
||||||
|
QDRANT_API_KEY=<api-key>
|
||||||
|
|
||||||
|
# Sync Configuration
|
||||||
|
SYNC_INTERVAL_SECONDS=300
|
||||||
|
SYNC_BATCH_SIZE=100
|
||||||
|
|
||||||
|
# Note: For BasicAuth mode (single-user), background sync uses NEXTCLOUD_USERNAME/NEXTCLOUD_PASSWORD
|
||||||
|
# This ADR focuses on OAuth mode only
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 6.2 Keycloak Configuration (for Token Exchange)
|
||||||
|
|
||||||
|
**Client Settings** (`nextcloud-mcp-server`):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"clientId": "nextcloud-mcp-server",
|
||||||
|
"serviceAccountsEnabled": true,
|
||||||
|
"authorizationServicesEnabled": false,
|
||||||
|
"attributes": {
|
||||||
|
"token.exchange.grant.enabled": "true",
|
||||||
|
"client.token.exchange.standard.enabled": "true"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Service Account Roles**:
|
||||||
|
- Assign appropriate Nextcloud roles/scopes to the service account
|
||||||
|
- Configure token exchange permissions
|
||||||
|
|
||||||
|
#### 6.3 Docker Compose
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
mcp-sync:
|
||||||
|
build: .
|
||||||
|
command: ["python", "-m", "nextcloud_mcp_server.sync_worker"]
|
||||||
|
environment:
|
||||||
|
- NEXTCLOUD_HOST=http://app:80
|
||||||
|
|
||||||
|
# External OIDC provider (Keycloak)
|
||||||
|
- OIDC_DISCOVERY_URL=http://keycloak:8080/realms/nextcloud-mcp/.well-known/openid-configuration
|
||||||
|
- OIDC_CLIENT_ID=nextcloud-mcp-server
|
||||||
|
- OIDC_CLIENT_SECRET=${OIDC_CLIENT_SECRET}
|
||||||
|
|
||||||
|
# Vector database
|
||||||
|
- QDRANT_URL=http://qdrant:6333
|
||||||
|
- QDRANT_API_KEY=${QDRANT_API_KEY}
|
||||||
|
volumes:
|
||||||
|
- sync-data:/app/data # For OAuth client credential storage
|
||||||
|
depends_on:
|
||||||
|
- app
|
||||||
|
- keycloak
|
||||||
|
- qdrant
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
sync-data: # Persistent storage for encrypted OAuth client credentials
|
||||||
|
```
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
### Benefits
|
||||||
|
|
||||||
|
1. **OAuth-Native Authentication**
|
||||||
|
- Leverages standard OAuth flows (offline_access, token exchange)
|
||||||
|
- No reliance on admin passwords in production
|
||||||
|
- Compatible with enterprise OIDC providers
|
||||||
|
|
||||||
|
2. **User-Level Permissions**
|
||||||
|
- Each user's content indexed with their own credentials
|
||||||
|
- Respects sharing, permissions, and access controls
|
||||||
|
- Full audit trail of which user's token was used
|
||||||
|
|
||||||
|
3. **Security**
|
||||||
|
- Tokens encrypted at rest
|
||||||
|
- Short-lived access tokens (refreshed as needed)
|
||||||
|
- Token rotation support
|
||||||
|
- Defense in depth with dual-phase authorization
|
||||||
|
|
||||||
|
4. **Flexibility**
|
||||||
|
- Automatic capability detection
|
||||||
|
- Graceful degradation through authentication tiers
|
||||||
|
- Works with varying OIDC provider capabilities
|
||||||
|
|
||||||
|
5. **Operational**
|
||||||
|
- Background sync independent of user activity
|
||||||
|
- Efficient batch processing
|
||||||
|
- Clear separation of sync vs request credentials
|
||||||
|
|
||||||
|
### Limitations
|
||||||
|
|
||||||
|
1. **Complexity**
|
||||||
|
- Multiple authentication paths to maintain
|
||||||
|
- Token storage and encryption infrastructure
|
||||||
|
- More moving parts than simple admin auth
|
||||||
|
|
||||||
|
2. **User Experience**
|
||||||
|
- `offline_access` scope may require additional consent
|
||||||
|
- Users must authenticate at least once for indexing
|
||||||
|
- New users not automatically indexed
|
||||||
|
|
||||||
|
3. **OIDC Provider Dependency**
|
||||||
|
- Token exchange requires RFC 8693 support (rare)
|
||||||
|
- Refresh token rotation varies by provider
|
||||||
|
- Some providers may not support offline_access
|
||||||
|
|
||||||
|
4. **Operational Overhead**
|
||||||
|
- Token database maintenance
|
||||||
|
- Monitoring token expiration
|
||||||
|
- Handling revoked tokens gracefully
|
||||||
|
|
||||||
|
### Security Considerations
|
||||||
|
|
||||||
|
#### Threat Model
|
||||||
|
|
||||||
|
**Threat 1: Token Storage Breach**
|
||||||
|
- **Mitigation**: Encryption at rest using Fernet
|
||||||
|
- **Mitigation**: Secure key management (secrets manager)
|
||||||
|
- **Mitigation**: Minimal token lifetime
|
||||||
|
- **Detection**: Audit logs for unusual access patterns
|
||||||
|
|
||||||
|
**Threat 2: Token Replay**
|
||||||
|
- **Mitigation**: Short-lived access tokens (refreshed frequently)
|
||||||
|
- **Mitigation**: Token rotation on each refresh
|
||||||
|
- **Mitigation**: Revocation support
|
||||||
|
|
||||||
|
**Threat 3: Privilege Escalation**
|
||||||
|
- **Mitigation**: Dual-phase authorization (vector DB + Nextcloud API)
|
||||||
|
- **Mitigation**: Sync worker uses same scopes as user requests
|
||||||
|
- **Mitigation**: Per-user token isolation
|
||||||
|
|
||||||
|
**Threat 4: Vector Database Poisoning**
|
||||||
|
- **Mitigation**: User requests always verify via Nextcloud API
|
||||||
|
- **Mitigation**: Vector DB is cache/accelerator, not source of truth
|
||||||
|
- **Mitigation**: Sync operations audited per user
|
||||||
|
|
||||||
|
#### Security Best Practices
|
||||||
|
|
||||||
|
1. **OAuth Client Secret Management**
|
||||||
|
```bash
|
||||||
|
# Store in secrets manager (Vault, AWS Secrets Manager, etc.)
|
||||||
|
# Or use environment variable with restricted permissions
|
||||||
|
|
||||||
|
# For self-hosted: Use encrypted storage
|
||||||
|
# OAuth client credentials stored in SQLite with Fernet encryption
|
||||||
|
# Encryption key: TOKEN_ENCRYPTION_KEY environment variable
|
||||||
|
|
||||||
|
# Generate encryption key:
|
||||||
|
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Service Account Token Lifecycle**
|
||||||
|
- Cache service tokens to minimize requests (with expiry buffer)
|
||||||
|
- Automatically refresh expired tokens
|
||||||
|
- Use short-lived tokens (provider default, typically 1 hour)
|
||||||
|
- Monitor token request rates and failures
|
||||||
|
|
||||||
|
3. **Database Permissions (for Client Credential Storage)**
|
||||||
|
```bash
|
||||||
|
# Restrict database file permissions
|
||||||
|
chmod 600 /app/data/tokens.db
|
||||||
|
chown mcp-server:mcp-server /app/data/tokens.db
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Monitoring and Alerting**
|
||||||
|
- Alert on token exchange failures
|
||||||
|
- Monitor for unusual access patterns
|
||||||
|
- Track service account token usage
|
||||||
|
- Audit sync operations per user (if delegation supported)
|
||||||
|
|
||||||
|
### Future Enhancements
|
||||||
|
|
||||||
|
1. **Token Revocation Handling**
|
||||||
|
- Webhook endpoint for token revocation events
|
||||||
|
- Periodic validation of stored tokens
|
||||||
|
- Graceful handling of revoked tokens
|
||||||
|
|
||||||
|
2. **Selective Sync**
|
||||||
|
- Allow users to opt-in/opt-out of indexing
|
||||||
|
- Per-content-type sync preferences
|
||||||
|
- Privacy controls for sensitive content
|
||||||
|
|
||||||
|
3. **Multi-Tenant Token Storage**
|
||||||
|
- Separate token databases per tenant
|
||||||
|
- Key rotation per tenant
|
||||||
|
- Tenant isolation
|
||||||
|
|
||||||
|
4. **Token Lifecycle Management**
|
||||||
|
- Automatic cleanup of expired tokens
|
||||||
|
- Token usage analytics
|
||||||
|
- Token health dashboard
|
||||||
|
|
||||||
|
5. **Alternative OAuth Flows**
|
||||||
|
- Device flow for headless sync
|
||||||
|
- Resource owner password credentials (ROPC) as fallback
|
||||||
|
- SAML assertion grants
|
||||||
|
|
||||||
|
## Alternatives Considered
|
||||||
|
|
||||||
|
### Alternative 1: Admin BasicAuth Only
|
||||||
|
|
||||||
|
**Approach**: Background worker always uses admin credentials
|
||||||
|
|
||||||
|
**Pros**:
|
||||||
|
- Simple implementation
|
||||||
|
- No token storage complexity
|
||||||
|
- Works with any authentication backend
|
||||||
|
|
||||||
|
**Cons**:
|
||||||
|
- Violates principle of least privilege
|
||||||
|
- Single powerful credential
|
||||||
|
- No per-user audit trail
|
||||||
|
- Bypasses OAuth entirely
|
||||||
|
|
||||||
|
**Decision**: Rejected for production use; kept as fallback only
|
||||||
|
|
||||||
|
### Alternative 2: Client Credentials Grant Only
|
||||||
|
|
||||||
|
**Approach**: Service account with broad read permissions
|
||||||
|
|
||||||
|
**Pros**:
|
||||||
|
- OAuth-native pattern
|
||||||
|
- No user token storage
|
||||||
|
- Standard OAuth flow
|
||||||
|
|
||||||
|
**Cons**:
|
||||||
|
- Requires client_credentials support (may not be available)
|
||||||
|
- Still needs broad cross-user permissions
|
||||||
|
- Not well-suited for multi-user indexing
|
||||||
|
|
||||||
|
**Decision**: Rejected; token exchange is better fit for multi-user scenario
|
||||||
|
|
||||||
|
### Alternative 3: Per-User Access Token Storage
|
||||||
|
|
||||||
|
**Approach**: Store user access tokens (not refresh tokens)
|
||||||
|
|
||||||
|
**Pros**:
|
||||||
|
- Simpler than refresh token flow
|
||||||
|
- No token refresh logic needed
|
||||||
|
|
||||||
|
**Cons**:
|
||||||
|
- Access tokens are short-lived (1-24 hours)
|
||||||
|
- Requires frequent re-authentication
|
||||||
|
- Poor user experience
|
||||||
|
- Sync gaps when tokens expire
|
||||||
|
|
||||||
|
**Decision**: Rejected; refresh tokens provide better UX
|
||||||
|
|
||||||
|
### Alternative 4: On-Demand Indexing Only
|
||||||
|
|
||||||
|
**Approach**: Index content when user searches (no background worker)
|
||||||
|
|
||||||
|
**Pros**:
|
||||||
|
- Uses user's request token
|
||||||
|
- No background auth needed
|
||||||
|
- Simpler architecture
|
||||||
|
|
||||||
|
**Cons**:
|
||||||
|
- Very slow first search
|
||||||
|
- Poor user experience
|
||||||
|
- Incomplete index
|
||||||
|
- Can't pre-compute embeddings
|
||||||
|
|
||||||
|
**Decision**: Rejected; background indexing is essential for semantic search
|
||||||
|
|
||||||
|
### Alternative 5: Nextcloud App Tokens
|
||||||
|
|
||||||
|
**Approach**: Generate app-specific passwords for each user
|
||||||
|
|
||||||
|
**Pros**:
|
||||||
|
- Nextcloud-native feature
|
||||||
|
- User-controlled revocation
|
||||||
|
- Scoped per-application
|
||||||
|
|
||||||
|
**Cons**:
|
||||||
|
- Requires user interaction to create
|
||||||
|
- May not support programmatic creation
|
||||||
|
- Still requires secure storage
|
||||||
|
- Not standard OAuth
|
||||||
|
|
||||||
|
**Decision**: Rejected; not automatable for background worker
|
||||||
|
|
||||||
|
## Related Decisions
|
||||||
|
|
||||||
|
- ADR-001: Enhanced Note Search (establishes need for vector search)
|
||||||
|
- [Future] ADR-003: Vector Database Selection
|
||||||
|
- [Future] ADR-004: Embedding Model Strategy
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [RFC 8693: OAuth 2.0 Token Exchange](https://datatracker.ietf.org/doc/html/rfc8693)
|
||||||
|
- [RFC 6749: OAuth 2.0 - Refresh Tokens](https://datatracker.ietf.org/doc/html/rfc6749#section-1.5)
|
||||||
|
- [OpenID Connect Core - Offline Access](https://openid.net/specs/openid-connect-core-1_0.html#OfflineAccess)
|
||||||
|
- [OWASP: OAuth Security Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/OAuth2_Cheat_Sheet.html)
|
||||||
|
- [RFC 8707: Resource Indicators for OAuth 2.0](https://datatracker.ietf.org/doc/html/rfc8707)
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,521 @@
|
|||||||
|
# Audience Validation Setup
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This document explains the **separate clients architecture** for Keycloak → MCP Server → Nextcloud integration, following OAuth 2.0 best practices and RFC 8707 (Resource Indicators).
|
||||||
|
|
||||||
|
## Architecture: Separate Clients Pattern
|
||||||
|
|
||||||
|
```
|
||||||
|
Keycloak Realm: nextcloud-mcp
|
||||||
|
├── Client: "nextcloud" (Resource Server)
|
||||||
|
│ └── Represents Nextcloud as a protected resource
|
||||||
|
│ └── Used by user_oidc for bearer token validation
|
||||||
|
│ └── Validates tokens with aud="nextcloud"
|
||||||
|
│
|
||||||
|
└── Client: "nextcloud-mcp-server" (OAuth Client)
|
||||||
|
└── MCP Server uses this to REQUEST tokens
|
||||||
|
└── Issues tokens with aud="nextcloud" (targeting resource)
|
||||||
|
└── Future: aud=["nextcloud", "other-service"]
|
||||||
|
|
||||||
|
Token Flow:
|
||||||
|
MCP Server (client: nextcloud-mcp-server)
|
||||||
|
↓ requests token from Keycloak
|
||||||
|
Token issued:
|
||||||
|
- aud: "nextcloud" (intended for Nextcloud resource)
|
||||||
|
- azp: "nextcloud-mcp-server" (requested by MCP Server)
|
||||||
|
- preferred_username: "admin" (on behalf of user)
|
||||||
|
↓ sent to Nextcloud API
|
||||||
|
Nextcloud user_oidc (client: nextcloud)
|
||||||
|
✓ validates aud matches configured client_id
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key Benefits**:
|
||||||
|
- ✅ **Proper OAuth separation**: OAuth client ≠ resource server
|
||||||
|
- ✅ **Future extensibility**: MCP Server can request multi-resource tokens
|
||||||
|
- ✅ **RFC 8707 compliance**: Audience indicates intended resource
|
||||||
|
- ✅ **Clear requester identification**: azp claim identifies MCP Server
|
||||||
|
|
||||||
|
## Token Claims
|
||||||
|
|
||||||
|
Tokens issued by the `nextcloud-mcp-server` client contain:
|
||||||
|
|
||||||
|
- **`aud: "nextcloud"`** - Audience: Token intended for Nextcloud resource server (matches user_oidc client_id)
|
||||||
|
- **`azp: "nextcloud-mcp-server"`** - Authorized Party: Identifies MCP Server as the OAuth client that requested the token
|
||||||
|
- **`preferred_username: "admin"`** - User identifier (Keycloak uses this for password grant; `sub` for authorization_code grant)
|
||||||
|
- **`scope: "openid profile email offline_access"`** - Requested scopes including offline access for background jobs
|
||||||
|
|
||||||
|
**How user_oidc Validates**:
|
||||||
|
1. SelfEncodedValidator checks: `aud == user_oidc.client_id`?
|
||||||
|
- ✓ "nextcloud" == "nextcloud" → PASS
|
||||||
|
2. Fast JWT verification with JWKS (no HTTP call to userinfo endpoint)
|
||||||
|
3. User provisioned based on `preferred_username` or `sub` claim
|
||||||
|
|
||||||
|
**For Background Jobs**:
|
||||||
|
- MCP Server stores encrypted refresh tokens
|
||||||
|
- Refreshes access tokens when needed
|
||||||
|
- All tokens have `aud: "nextcloud"` → validated by user_oidc
|
||||||
|
- No admin credentials required
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
The configuration requires **two separate clients** in Keycloak:
|
||||||
|
|
||||||
|
1. **`nextcloud`** - Resource server client (for user_oidc validation)
|
||||||
|
2. **`nextcloud-mcp-server`** - OAuth client (for MCP Server to request tokens)
|
||||||
|
|
||||||
|
### 1. Keycloak - Create Resource Server Client
|
||||||
|
|
||||||
|
First, create the `nextcloud` client that represents Nextcloud as a resource server:
|
||||||
|
|
||||||
|
**Via Keycloak Admin API:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Get admin token
|
||||||
|
ADMIN_TOKEN=$(curl -X POST "http://localhost:8888/realms/master/protocol/openid-connect/token" \
|
||||||
|
-d "grant_type=password" \
|
||||||
|
-d "client_id=admin-cli" \
|
||||||
|
-d "username=admin" \
|
||||||
|
-d "password=admin" | jq -r '.access_token')
|
||||||
|
|
||||||
|
# Create 'nextcloud' resource server client
|
||||||
|
curl -X POST "http://localhost:8888/admin/realms/nextcloud-mcp/clients" \
|
||||||
|
-H "Authorization: Bearer $ADMIN_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"clientId": "nextcloud",
|
||||||
|
"name": "Nextcloud Resource Server",
|
||||||
|
"description": "Resource server for Nextcloud APIs - used by user_oidc for bearer token validation",
|
||||||
|
"enabled": true,
|
||||||
|
"clientAuthenticatorType": "client-secret",
|
||||||
|
"secret": "nextcloud-secret-change-in-production",
|
||||||
|
"bearerOnly": true,
|
||||||
|
"standardFlowEnabled": false,
|
||||||
|
"directAccessGrantsEnabled": false,
|
||||||
|
"serviceAccountsEnabled": false,
|
||||||
|
"publicClient": false
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Via Realm Export** (`keycloak/realm-export.json`):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"clients": [
|
||||||
|
{
|
||||||
|
"clientId": "nextcloud",
|
||||||
|
"name": "Nextcloud Resource Server",
|
||||||
|
"enabled": true,
|
||||||
|
"bearerOnly": true,
|
||||||
|
"secret": "nextcloud-secret-change-in-production"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Keycloak - Create OAuth Client with Audience Mapper
|
||||||
|
|
||||||
|
Next, create the `nextcloud-mcp-server` client that MCP Server uses to request tokens:
|
||||||
|
|
||||||
|
**Via Keycloak Admin API:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create 'nextcloud-mcp-server' OAuth client
|
||||||
|
curl -X POST "http://localhost:8888/admin/realms/nextcloud-mcp/clients" \
|
||||||
|
-H "Authorization: Bearer $ADMIN_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"clientId": "nextcloud-mcp-server",
|
||||||
|
"name": "Nextcloud MCP Server",
|
||||||
|
"enabled": true,
|
||||||
|
"clientAuthenticatorType": "client-secret",
|
||||||
|
"secret": "mcp-secret-change-in-production",
|
||||||
|
"standardFlowEnabled": true,
|
||||||
|
"directAccessGrantsEnabled": true,
|
||||||
|
"redirectUris": ["http://localhost:*/callback"]
|
||||||
|
}'
|
||||||
|
|
||||||
|
# Get client internal ID
|
||||||
|
CLIENT_ID=$(curl "http://localhost:8888/admin/realms/nextcloud-mcp/clients" \
|
||||||
|
-H "Authorization: Bearer $ADMIN_TOKEN" | jq -r '.[] | select(.clientId=="nextcloud-mcp-server") | .id')
|
||||||
|
|
||||||
|
# Add audience mapper targeting 'nextcloud' resource
|
||||||
|
curl -X POST "http://localhost:8888/admin/realms/nextcloud-mcp/clients/$CLIENT_ID/protocol-mappers/models" \
|
||||||
|
-H "Authorization: Bearer $ADMIN_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"name": "audience-nextcloud",
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"protocolMapper": "oidc-audience-mapper",
|
||||||
|
"consentRequired": false,
|
||||||
|
"config": {
|
||||||
|
"included.custom.audience": "nextcloud",
|
||||||
|
"access.token.claim": "true",
|
||||||
|
"id.token.claim": "false"
|
||||||
|
}
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Option B: Via Realm Export** (for infrastructure-as-code)
|
||||||
|
|
||||||
|
Update `keycloak/realm-export.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"clients": [
|
||||||
|
{
|
||||||
|
"clientId": "nextcloud-mcp-server",
|
||||||
|
"name": "Nextcloud MCP Server",
|
||||||
|
"protocolMappers": [
|
||||||
|
{
|
||||||
|
"name": "audience-nextcloud-mcp-server",
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"protocolMapper": "oidc-audience-mapper",
|
||||||
|
"consentRequired": false,
|
||||||
|
"config": {
|
||||||
|
"included.custom.audience": "nextcloud-mcp-server",
|
||||||
|
"access.token.claim": "true",
|
||||||
|
"id.token.claim": "false"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Then re-import realm or restart Keycloak.
|
||||||
|
|
||||||
|
**Option C: Via Keycloak Admin UI**
|
||||||
|
|
||||||
|
1. Go to Keycloak Admin Console → Realm → Clients → `nextcloud-mcp-server`
|
||||||
|
2. Click "Client scopes" tab
|
||||||
|
3. Click "Add client scope" → "Create dedicated scope"
|
||||||
|
4. Add protocol mapper: "Audience"
|
||||||
|
- Mapper Type: `Audience`
|
||||||
|
- Included Custom Audience: `nextcloud`
|
||||||
|
- Add to access token: ON
|
||||||
|
- Add to ID token: OFF
|
||||||
|
|
||||||
|
### 3. Nextcloud user_oidc - Configure Resource Server Client
|
||||||
|
|
||||||
|
Configure user_oidc to use the `nextcloud` resource server client:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec app php occ user_oidc:provider keycloak \
|
||||||
|
--clientid="nextcloud" \
|
||||||
|
--clientsecret="nextcloud-secret-change-in-production" \
|
||||||
|
--discoveryuri="http://keycloak:8080/realms/nextcloud-mcp/.well-known/openid-configuration" \
|
||||||
|
--check-bearer=1 \
|
||||||
|
--bearer-provisioning=1 \
|
||||||
|
--unique-uid=1 \
|
||||||
|
--mapping-uid="sub" \
|
||||||
|
--mapping-display-name="name" \
|
||||||
|
--mapping-email="email"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Result**: user_oidc validates tokens with `aud="nextcloud"` using SelfEncodedValidator (fast JWT verification).
|
||||||
|
|
||||||
|
### 3. Nextcloud user_oidc - Realm-Level Validation
|
||||||
|
|
||||||
|
Nextcloud's `user_oidc` app validates at **realm level** via userinfo endpoint:
|
||||||
|
|
||||||
|
- ✅ **No configuration needed** - works automatically
|
||||||
|
- ✅ Validates any token from Keycloak realm
|
||||||
|
- ✅ Audience check is **optional** (disabled by default)
|
||||||
|
|
||||||
|
**Optional: Disable strict audience checking** (if enabled):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec app php occ config:app:set user_oidc \
|
||||||
|
selfencoded_bearer_validation_audience_check --value=false --type=boolean
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
### 1. Check Token Claims
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Get token from Keycloak
|
||||||
|
TOKEN=$(curl -X POST "http://localhost:8888/realms/nextcloud-mcp/protocol/openid-connect/token" \
|
||||||
|
-d "grant_type=password" \
|
||||||
|
-d "client_id=nextcloud-mcp-server" \
|
||||||
|
-d "client_secret=mcp-secret-change-in-production" \
|
||||||
|
-d "username=admin" \
|
||||||
|
-d "password=admin" | jq -r '.access_token')
|
||||||
|
|
||||||
|
# Decode JWT
|
||||||
|
echo $TOKEN | cut -d'.' -f2 | base64 -d | jq '.'
|
||||||
|
|
||||||
|
# Should show:
|
||||||
|
{
|
||||||
|
"aud": "nextcloud", # ✓ Intended for Nextcloud
|
||||||
|
"azp": "nextcloud-mcp-server", # ✓ Requested by MCP Server
|
||||||
|
"iss": "http://localhost:8888/realms/nextcloud-mcp",
|
||||||
|
"scope": "openid email profile offline_access",
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Test with Nextcloud API
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Token should be accepted
|
||||||
|
curl -H "Authorization: Bearer $TOKEN" \
|
||||||
|
"http://localhost:8080/ocs/v2.php/cloud/capabilities"
|
||||||
|
|
||||||
|
# Should return HTTP 200 OK
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Test Audience Rejection
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Get token from different client (without audience mappers)
|
||||||
|
TOKEN_WRONG=$(curl -X POST "http://localhost:8888/realms/nextcloud-mcp/protocol/openid-connect/token" \
|
||||||
|
-d "grant_type=password" \
|
||||||
|
-d "client_id=test-client-b" \
|
||||||
|
-d "client_secret=test-secret-b" \
|
||||||
|
-d "username=admin" \
|
||||||
|
-d "password=admin" | jq -r '.access_token')
|
||||||
|
|
||||||
|
# This token has NO audience claim - should be rejected by MCP server
|
||||||
|
# (But accepted by Nextcloud user_oidc which validates at realm level)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Token Flow Example
|
||||||
|
|
||||||
|
### Successful Request (Background Job)
|
||||||
|
|
||||||
|
```
|
||||||
|
1. User authorizes MCP Client via OAuth
|
||||||
|
└─ MCP Server gets refresh token (stored encrypted)
|
||||||
|
|
||||||
|
2. Background worker needs to sync data
|
||||||
|
└─ MCP Server refreshes access token from Keycloak
|
||||||
|
└─ Token issued with aud: "nextcloud", azp: "nextcloud-mcp-server"
|
||||||
|
|
||||||
|
3. MCP Server → Nextcloud API (with token)
|
||||||
|
└─ user_oidc validates via userinfo endpoint ✓
|
||||||
|
└─ Nextcloud identifies:
|
||||||
|
- Token intended for Nextcloud (aud: "nextcloud")
|
||||||
|
- Request from MCP Server (azp: "nextcloud-mcp-server")
|
||||||
|
- On behalf of user (sub: "user-id")
|
||||||
|
|
||||||
|
4. Success! MCP Server can act on behalf of user in background.
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rejected Request
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Attacker gets token for different client
|
||||||
|
└─ Token has aud: "other-service"
|
||||||
|
|
||||||
|
2. Attacker → Nextcloud API (with wrong token)
|
||||||
|
└─ user_oidc validates via userinfo endpoint
|
||||||
|
└─ Token validation fails (invalid/expired/wrong realm)
|
||||||
|
└─ HTTP 401 Unauthorized
|
||||||
|
|
||||||
|
3. Request blocked - token not valid for this realm/service
|
||||||
|
```
|
||||||
|
|
||||||
|
## OAuth Flows and User Consent
|
||||||
|
|
||||||
|
### When Does the User Grant Consent?
|
||||||
|
|
||||||
|
User consent happens during the **Authorization Code Flow** (production OAuth):
|
||||||
|
|
||||||
|
```
|
||||||
|
1. User clicks "Connect" in MCP Client (e.g., Claude Desktop)
|
||||||
|
2. MCP Client initiates OAuth flow by opening browser to Keycloak:
|
||||||
|
https://keycloak/realms/nextcloud-mcp/protocol/openid-connect/auth?
|
||||||
|
client_id=nextcloud-mcp-server&
|
||||||
|
redirect_uri=<mcp-client-redirect-uri>&
|
||||||
|
response_type=code&
|
||||||
|
scope=openid profile email offline_access
|
||||||
|
|
||||||
|
3. Keycloak shows login screen (if not logged in)
|
||||||
|
4. **Keycloak shows consent screen:**
|
||||||
|
"Nextcloud MCP Server wants to access your Nextcloud data on your behalf"
|
||||||
|
Requested permissions:
|
||||||
|
- Access your profile (openid, profile, email)
|
||||||
|
- Offline access (background operations with refresh tokens)
|
||||||
|
|
||||||
|
5. User clicks "Allow" → grants consent
|
||||||
|
6. Keycloak redirects back to MCP Client with authorization code
|
||||||
|
7. MCP Client exchanges code for tokens (receives access + refresh tokens)
|
||||||
|
8. MCP Client shares tokens with MCP Server via MCP protocol
|
||||||
|
9. MCP Server stores refresh token encrypted for background operations
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key Architecture Notes:**
|
||||||
|
- **MCP Server is a protected resource** (requires OAuth to access)
|
||||||
|
- **MCP Client** (Claude Desktop) is the OAuth client that initiates the flow
|
||||||
|
- **MCP Client handles the redirect** and token exchange with Keycloak
|
||||||
|
- **MCP Client shares refresh token** with MCP Server so it can act on behalf of user in background
|
||||||
|
|
||||||
|
**Key Points:**
|
||||||
|
- ✅ **Explicit user consent** before any access
|
||||||
|
- ✅ **Scopes displayed** so user knows what's being requested
|
||||||
|
- ✅ **Offline access** must be explicitly granted (for background jobs)
|
||||||
|
- ✅ **Revocable** - user can revoke consent in Keycloak at any time
|
||||||
|
|
||||||
|
### Grant Types
|
||||||
|
|
||||||
|
Our architecture supports multiple OAuth grant types:
|
||||||
|
|
||||||
|
**1. Authorization Code + PKCE (Production)**
|
||||||
|
```
|
||||||
|
Use case: Interactive login from MCP clients
|
||||||
|
Consent: Yes - explicit user authorization
|
||||||
|
Tokens: Access token + Refresh token (if offline_access granted)
|
||||||
|
Security: PKCE prevents authorization code interception
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. Password Grant (Testing Only)**
|
||||||
|
```
|
||||||
|
Use case: Integration testing with docker-compose
|
||||||
|
Consent: No - username/password provided directly
|
||||||
|
Tokens: Access token + Refresh token
|
||||||
|
Security: NOT for production - exposes user credentials
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. Refresh Token Grant (Background Jobs)**
|
||||||
|
```
|
||||||
|
Use case: MCP Server refreshing expired access tokens
|
||||||
|
Consent: No new consent - uses previously granted refresh token
|
||||||
|
Tokens: New access token (refresh token may rotate)
|
||||||
|
Security: Refresh tokens stored encrypted, rotated on use
|
||||||
|
```
|
||||||
|
|
||||||
|
## Authentication Strategies for Background Jobs
|
||||||
|
|
||||||
|
> **Note on Service Account Tokens**: Service account tokens (`client_credentials` grant) were evaluated but **rejected** as they create Nextcloud user accounts (e.g., `service-account-{client_id}`) which violates OAuth "act on-behalf-of" principles. See ADR-002 "Will Not Implement" section for details.
|
||||||
|
|
||||||
|
### Current Approach: Offline Access with Refresh Tokens
|
||||||
|
|
||||||
|
The MCP server uses **offline_access** scope to enable background operations:
|
||||||
|
|
||||||
|
**How it works:**
|
||||||
|
1. User grants `offline_access` scope during OAuth consent
|
||||||
|
2. MCP Client receives refresh token from Keycloak
|
||||||
|
3. MCP Client shares refresh token with MCP Server via MCP protocol
|
||||||
|
4. MCP Server stores refresh token encrypted (see ADR-002)
|
||||||
|
5. Background jobs exchange refresh token for fresh access tokens as needed
|
||||||
|
|
||||||
|
**Benefits:**
|
||||||
|
- ✅ Works today with Keycloak and all OIDC providers
|
||||||
|
- ✅ Standard OAuth pattern (RFC 6749)
|
||||||
|
- ✅ Explicit user consent to `offline_access` scope
|
||||||
|
- ✅ MCP Server can act on behalf of user in background
|
||||||
|
|
||||||
|
**Limitations:**
|
||||||
|
- ⚠️ Requires secure token storage on MCP Server
|
||||||
|
- ⚠️ MCP Client must trust MCP Server with refresh token
|
||||||
|
- ⚠️ Weak audit trail - API requests appear to come from user directly
|
||||||
|
- ⚠️ No visibility that MCP Server is the actual actor
|
||||||
|
|
||||||
|
### Token Exchange with Delegation (ADR-002 Tier 2 - Implemented)
|
||||||
|
|
||||||
|
**RFC 8693 Delegation** would provide better audit trail and security:
|
||||||
|
|
||||||
|
**How it would work:**
|
||||||
|
1. User grants `may_act:nextcloud-mcp-server` scope during authentication
|
||||||
|
2. Subject token includes: `{ "may_act": { "client": "nextcloud-mcp-server" } }`
|
||||||
|
3. MCP Server has its own service account token (actor_token)
|
||||||
|
4. Background job requests token exchange:
|
||||||
|
- `subject_token` (user's token with may_act claim)
|
||||||
|
- `actor_token` (mcp-server's service token)
|
||||||
|
5. Keycloak validates actor matches may_act claim
|
||||||
|
6. Returns delegated token: `{ "sub": "user", "act": "nextcloud-mcp-server" }`
|
||||||
|
|
||||||
|
**Benefits:**
|
||||||
|
- ✅ Better audit trail - Nextcloud APIs see both user and actor
|
||||||
|
- ✅ No token storage needed (tokens generated on-demand)
|
||||||
|
- ✅ Fine-grained permissions via `may_act` claim
|
||||||
|
- ✅ User explicitly consents to MCP Server acting on their behalf
|
||||||
|
- ✅ RFC 8693 compliant
|
||||||
|
|
||||||
|
**Current Status:**
|
||||||
|
- ❌ **NOT implemented in Keycloak yet** ([Issue #38279](https://github.com/keycloak/keycloak/issues/38279))
|
||||||
|
- ❌ Would require custom implementation or waiting for upstream
|
||||||
|
- 📝 Proposal includes `act` claim and `may_act` consent mechanism
|
||||||
|
|
||||||
|
**Why Not Available:**
|
||||||
|
- Keycloak supports **impersonation** (changes `sub` claim), but not **delegation** (`act` claim)
|
||||||
|
- Impersonation has poor audit trail (actor invisible)
|
||||||
|
- Delegation proposal is open but not implemented yet
|
||||||
|
|
||||||
|
**Reference:** See `docs/ADR-002-vector-sync-authentication.md` for detailed comparison of authentication tiers.
|
||||||
|
|
||||||
|
## Security Benefits
|
||||||
|
|
||||||
|
1. **Intent Validation**: Tokens explicitly declare Nextcloud as the intended recipient via `aud` claim
|
||||||
|
2. **Requester Identification**: The `azp` claim identifies MCP Server as the requester
|
||||||
|
3. **User Context**: The `sub` claim preserves user identity for audit and authorization
|
||||||
|
4. **Background Jobs**: Refresh tokens enable MCP Server to act on behalf of users without admin credentials
|
||||||
|
5. **OAuth Standards**: Follows RFC 8707 (Resource Indicators) and RFC 6749 (OAuth 2.0)
|
||||||
|
|
||||||
|
**Current Limitations:**
|
||||||
|
- API requests from background jobs appear to come from user directly (no `act` claim yet)
|
||||||
|
- See "Authentication Strategies for Background Jobs" section for future delegation support
|
||||||
|
|
||||||
|
## Token Claims
|
||||||
|
|
||||||
|
### Key Claims
|
||||||
|
|
||||||
|
- **`aud: "nextcloud"`** - Audience: Token intended for Nextcloud APIs
|
||||||
|
- **`azp: "nextcloud-mcp-server"`** - Authorized Party: MCP Server requested the token
|
||||||
|
- **`sub: "user-id"`** - Subject: User on whose behalf the request is made
|
||||||
|
- **`scope: "openid profile email offline_access"`** - Requested scopes including offline access for background jobs
|
||||||
|
|
||||||
|
### Client Naming
|
||||||
|
|
||||||
|
The Keycloak client is named `nextcloud-mcp-server` to clarify:
|
||||||
|
- **MCP Server** uses this client to get tokens for Nextcloud
|
||||||
|
- **MCP Clients** (like Claude Desktop) connect to MCP Server via separate OAuth flows
|
||||||
|
- **Not** named "mcp-client" to avoid confusion about which component is the client
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Token Has No Audience
|
||||||
|
|
||||||
|
**Symptom**: `"aud": null` in decoded JWT
|
||||||
|
|
||||||
|
**Cause**: Protocol mappers not configured
|
||||||
|
|
||||||
|
**Solution**: Add audience mappers via Keycloak Admin API (see Configuration section)
|
||||||
|
|
||||||
|
### MCP Server Rejects Token
|
||||||
|
|
||||||
|
**Symptom**: HTTP 401 with "JWT validation failed"
|
||||||
|
|
||||||
|
**Cause**: Token audience doesn't match expected value
|
||||||
|
|
||||||
|
**Solution**:
|
||||||
|
1. Check token has correct `aud` claim
|
||||||
|
2. Verify MCP server expects correct audience value in code
|
||||||
|
3. Check logs for specific JWT validation error
|
||||||
|
|
||||||
|
### Nextcloud Rejects Token
|
||||||
|
|
||||||
|
**Symptom**: HTTP 401 from Nextcloud API
|
||||||
|
|
||||||
|
**Cause**: User not provisioned or token invalid
|
||||||
|
|
||||||
|
**Solution**:
|
||||||
|
1. Check user_oidc provider is configured: `php occ user_oidc:provider keycloak`
|
||||||
|
2. Check bearer validation enabled: `--check-bearer=1`
|
||||||
|
3. Test token with userinfo endpoint: `curl -H "Authorization: Bearer $TOKEN" http://keycloak/realms/.../userinfo`
|
||||||
|
|
||||||
|
## Related Documentation
|
||||||
|
|
||||||
|
- **Multi-client validation**: `docs/keycloak-multi-client-validation.md`
|
||||||
|
- **ADR-002**: `docs/ADR-002-vector-sync-authentication.md`
|
||||||
|
- **OAuth setup**: `docs/oauth-setup.md`
|
||||||
|
- **Keycloak integration**: `docs/keycloak-integration.md` (if created)
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [RFC 8707 - Resource Indicators for OAuth 2.0](https://datatracker.ietf.org/doc/html/rfc8707)
|
||||||
|
- [OIDC Core - ID Token aud claim](https://openid.net/specs/openid-connect-core-1_0.html#IDToken)
|
||||||
|
- [Keycloak Audience Protocol Mappers](https://www.keycloak.org/docs/latest/server_admin/#_audience)
|
||||||
@@ -0,0 +1,698 @@
|
|||||||
|
# MCP Server Comparison: Nextcloud MCP Server vs Context Agent
|
||||||
|
|
||||||
|
This document compares the two MCP server implementations in the Nextcloud ecosystem:
|
||||||
|
|
||||||
|
1. **Nextcloud MCP Server** (this project) - Standalone MCP server for external access to Nextcloud
|
||||||
|
2. **Context Agent MCP Server** - MCP server embedded within Nextcloud as an External App
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
Both projects expose Nextcloud functionality via the Model Context Protocol (MCP), but serve different purposes and audiences:
|
||||||
|
|
||||||
|
- **Nextcloud MCP Server**: Brings Nextcloud OUT to external MCP clients (Claude Code, etc.)
|
||||||
|
- **Context Agent**: Brings external MCP servers IN to Nextcloud's AI Assistant
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TB
|
||||||
|
subgraph External["External Clients"]
|
||||||
|
CC[Claude Code]
|
||||||
|
IDE[IDEs with MCP]
|
||||||
|
APP[Other MCP Clients]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph NMCP["Nextcloud MCP Server<br/>(This Project)"]
|
||||||
|
NMCP_Server[FastMCP Server]
|
||||||
|
NMCP_Client[HTTP Clients]
|
||||||
|
NMCP_Auth[OAuth/BasicAuth]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph NC["Nextcloud Instance"]
|
||||||
|
subgraph CA["Context Agent ExApp"]
|
||||||
|
CA_Agent[LangGraph Agent]
|
||||||
|
CA_MCP[MCP Server /mcp]
|
||||||
|
CA_Tools[Tool Loader]
|
||||||
|
end
|
||||||
|
|
||||||
|
NC_Apps[Nextcloud Apps<br/>Notes, Calendar, Files, etc.]
|
||||||
|
NC_Assistant[Assistant App]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph ExtMCP["External MCP Servers"]
|
||||||
|
Weather[Weather MCP]
|
||||||
|
Other[Other Services]
|
||||||
|
end
|
||||||
|
|
||||||
|
%% External clients connect to standalone MCP server
|
||||||
|
CC --> NMCP_Server
|
||||||
|
IDE --> NMCP_Server
|
||||||
|
APP --> NMCP_Server
|
||||||
|
|
||||||
|
%% Standalone MCP server talks to Nextcloud over HTTP
|
||||||
|
NMCP_Server --> NMCP_Auth
|
||||||
|
NMCP_Auth --> NMCP_Client
|
||||||
|
NMCP_Client -->|HTTP/HTTPS| NC_Apps
|
||||||
|
|
||||||
|
%% Context Agent is inside Nextcloud
|
||||||
|
CA_Agent --> CA_Tools
|
||||||
|
CA_Tools --> NC_Apps
|
||||||
|
CA_MCP -->|Exposes to| NC_Assistant
|
||||||
|
NC_Assistant -->|User requests| CA_Agent
|
||||||
|
|
||||||
|
%% Context Agent can consume external MCP servers
|
||||||
|
CA_Tools -->|Consumes| ExtMCP
|
||||||
|
|
||||||
|
%% Context Agent could consume Nextcloud MCP Server
|
||||||
|
CA_Tools -.->|Could consume| NMCP_Server
|
||||||
|
|
||||||
|
classDef external fill:#e1f5ff
|
||||||
|
classDef standalone fill:#fff4e1
|
||||||
|
classDef internal fill:#e8f5e9
|
||||||
|
|
||||||
|
class CC,IDE,APP external
|
||||||
|
class NMCP_Server,NMCP_Client,NMCP_Auth standalone
|
||||||
|
class CA_Agent,CA_MCP,CA_Tools,NC_Apps,NC_Assistant internal
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deployment Models
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph LR
|
||||||
|
subgraph Deploy1["Nextcloud MCP Server Deployment"]
|
||||||
|
direction TB
|
||||||
|
D1[Docker Container]
|
||||||
|
D2[Cloud VM]
|
||||||
|
D3[Local Machine]
|
||||||
|
D4[Kubernetes Pod]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Deploy2["Context Agent Deployment"]
|
||||||
|
direction TB
|
||||||
|
NC[Nextcloud Instance<br/>with AppAPI]
|
||||||
|
ExApp[External App Container<br/>Managed by Nextcloud]
|
||||||
|
end
|
||||||
|
|
||||||
|
Deploy1 -.->|HTTP/HTTPS| NC
|
||||||
|
ExApp -->|Integrated| NC
|
||||||
|
|
||||||
|
classDef deploy fill:#fff4e1
|
||||||
|
classDef integrated fill:#e8f5e9
|
||||||
|
|
||||||
|
class D1,D2,D3,D4 deploy
|
||||||
|
class NC,ExApp integrated
|
||||||
|
```
|
||||||
|
|
||||||
|
### Nextcloud MCP Server
|
||||||
|
- **Location**: Runs anywhere with network access to Nextcloud
|
||||||
|
- **Deployment**: Docker, VM, local machine, Kubernetes
|
||||||
|
- **Connection**: HTTP/HTTPS to Nextcloud APIs
|
||||||
|
- **Independence**: Fully standalone service
|
||||||
|
|
||||||
|
### Context Agent
|
||||||
|
- **Location**: Runs inside Nextcloud as External App
|
||||||
|
- **Deployment**: Managed by Nextcloud AppAPI
|
||||||
|
- **Connection**: Native nc-py-api integration
|
||||||
|
- **Integration**: Deep Nextcloud integration
|
||||||
|
|
||||||
|
## Authentication Architecture
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TB
|
||||||
|
subgraph NMCP_Auth["Nextcloud MCP Server Authentication"]
|
||||||
|
direction TB
|
||||||
|
Client1[MCP Client]
|
||||||
|
|
||||||
|
subgraph BasicAuth["BasicAuth Mode"]
|
||||||
|
BA_Shared[Shared NextcloudClient]
|
||||||
|
BA_Creds[Username + Password]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph OAuth["OAuth Mode"]
|
||||||
|
OAuth_Token[OAuth Token]
|
||||||
|
OAuth_Verify[Token Verifier]
|
||||||
|
OAuth_OIDC[OIDC Discovery]
|
||||||
|
OAuth_Client[Per-Request Client]
|
||||||
|
end
|
||||||
|
|
||||||
|
Client1 -->|Basic Auth| BasicAuth
|
||||||
|
Client1 -->|Bearer Token| OAuth
|
||||||
|
BA_Creds --> BA_Shared
|
||||||
|
OAuth_Token --> OAuth_Verify
|
||||||
|
OAuth_OIDC --> OAuth_Verify
|
||||||
|
OAuth_Verify --> OAuth_Client
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph CA_Auth["Context Agent Authentication"]
|
||||||
|
direction TB
|
||||||
|
Client2[MCP Client]
|
||||||
|
CA_Header[Authorization Header]
|
||||||
|
CA_OCS[OCS API Validation]
|
||||||
|
CA_User[User Context]
|
||||||
|
CA_NC[nc-py-api Client]
|
||||||
|
|
||||||
|
Client2 --> CA_Header
|
||||||
|
CA_Header --> CA_OCS
|
||||||
|
CA_OCS -->|Extract user_id| CA_User
|
||||||
|
CA_User -->|nc.set_user| CA_NC
|
||||||
|
end
|
||||||
|
|
||||||
|
classDef auth fill:#fff4e1
|
||||||
|
classDef user fill:#e1f5ff
|
||||||
|
|
||||||
|
class BasicAuth,OAuth auth
|
||||||
|
class CA_User user
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tool Registration & Loading
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant Startup
|
||||||
|
participant NMCP as Nextcloud MCP<br/>Server
|
||||||
|
participant CA as Context Agent
|
||||||
|
participant Request as Client Request
|
||||||
|
|
||||||
|
Note over Startup,NMCP: Nextcloud MCP Server (Static)
|
||||||
|
Startup->>NMCP: Server starts
|
||||||
|
NMCP->>NMCP: configure_notes_tools(mcp)
|
||||||
|
NMCP->>NMCP: configure_calendar_tools(mcp)
|
||||||
|
NMCP->>NMCP: configure_contacts_tools(mcp)
|
||||||
|
Note over NMCP: Tools registered once<br/>at startup
|
||||||
|
Request->>NMCP: Call tool
|
||||||
|
NMCP->>NMCP: Use pre-registered tool
|
||||||
|
|
||||||
|
Note over Startup,CA: Context Agent (Dynamic)
|
||||||
|
Startup->>CA: Server starts
|
||||||
|
CA->>CA: Install ToolListMiddleware
|
||||||
|
Request->>CA: List tools (or 60s elapsed)
|
||||||
|
CA->>CA: get_tools(nc)
|
||||||
|
CA->>CA: Import all_tools/*.py
|
||||||
|
CA->>CA: Call module.get_tools(nc)
|
||||||
|
CA->>CA: Regenerate tool functions
|
||||||
|
Note over CA: Tools refreshed every 60s<br/>or on demand
|
||||||
|
Request->>CA: Call tool
|
||||||
|
CA->>CA: Regenerate with fresh nc
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tool Definition Patterns
|
||||||
|
|
||||||
|
### Nextcloud MCP Server
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Static registration at startup
|
||||||
|
def configure_notes_tools(mcp: FastMCP):
|
||||||
|
@mcp.tool()
|
||||||
|
async def nc_notes_create_note(
|
||||||
|
title: str,
|
||||||
|
content: str,
|
||||||
|
category: str,
|
||||||
|
ctx: Context
|
||||||
|
) -> CreateNoteResponse:
|
||||||
|
"""Create a new note"""
|
||||||
|
client = get_client(ctx) # Auto-detects auth mode
|
||||||
|
note_data = await client.notes.create_note(
|
||||||
|
title=title,
|
||||||
|
content=content,
|
||||||
|
category=category
|
||||||
|
)
|
||||||
|
return CreateNoteResponse(
|
||||||
|
id=note_data["id"],
|
||||||
|
title=note_data["title"],
|
||||||
|
etag=note_data["etag"]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Resources for structured data access
|
||||||
|
@mcp.resource("nc://Notes/{note_id}")
|
||||||
|
async def nc_get_note_resource(note_id: int):
|
||||||
|
"""Get user note using note id"""
|
||||||
|
ctx = mcp.get_context()
|
||||||
|
client = get_client(ctx)
|
||||||
|
note_data = await client.notes.get_note(note_id)
|
||||||
|
return Note(**note_data)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key Features**:
|
||||||
|
- Native FastMCP `@mcp.tool()` decorator
|
||||||
|
- Pydantic models for type safety
|
||||||
|
- MCP Resources support
|
||||||
|
- Comprehensive error handling with McpError
|
||||||
|
- Context-based client resolution
|
||||||
|
|
||||||
|
### Context Agent
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Dynamic loading at runtime
|
||||||
|
async def get_tools(nc: Nextcloud):
|
||||||
|
@tool
|
||||||
|
@safe_tool
|
||||||
|
def list_calendars():
|
||||||
|
"""List all existing calendars by name"""
|
||||||
|
principal = nc.cal.principal()
|
||||||
|
calendars = principal.calendars()
|
||||||
|
return ", ".join([cal.name for cal in calendars])
|
||||||
|
|
||||||
|
@tool
|
||||||
|
@dangerous_tool
|
||||||
|
def schedule_event(
|
||||||
|
calendar_name: str,
|
||||||
|
title: str,
|
||||||
|
description: str,
|
||||||
|
start_date: str,
|
||||||
|
end_date: str,
|
||||||
|
attendees: list[str] | None,
|
||||||
|
start_time: str | None,
|
||||||
|
end_time: str | None
|
||||||
|
):
|
||||||
|
"""Create a new event or meeting in a calendar"""
|
||||||
|
# Parse dates and times
|
||||||
|
start_datetime = datetime.strptime(start_date, "%Y-%m-%d")
|
||||||
|
# ... event creation logic
|
||||||
|
principal = nc.cal.principal()
|
||||||
|
calendar = {cal.name: cal for cal in calendars}[calendar_name]
|
||||||
|
calendar.add_event(str(c))
|
||||||
|
return True
|
||||||
|
|
||||||
|
return [list_calendars, schedule_event, ...]
|
||||||
|
|
||||||
|
def get_category_name():
|
||||||
|
return "Calendar and Tasks"
|
||||||
|
|
||||||
|
def is_available(nc: Nextcloud):
|
||||||
|
return True # or check capabilities
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key Features**:
|
||||||
|
- LangChain `@tool` decorator
|
||||||
|
- `@safe_tool` / `@dangerous_tool` decorators
|
||||||
|
- Dynamic tool regeneration with fresh context
|
||||||
|
- Tools returned as list from async function
|
||||||
|
- Availability checking per module
|
||||||
|
|
||||||
|
## Client Architecture
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TB
|
||||||
|
subgraph NMCP_Client["Nextcloud MCP Server Clients"]
|
||||||
|
direction TB
|
||||||
|
NMCP_Main[NextcloudClient]
|
||||||
|
NMCP_Base[BaseNextcloudClient]
|
||||||
|
|
||||||
|
NMCP_Notes[NotesClient]
|
||||||
|
NMCP_Cal[CalendarClient]
|
||||||
|
NMCP_Contacts[ContactsClient]
|
||||||
|
NMCP_Tables[TablesClient]
|
||||||
|
NMCP_WebDAV[WebDAVClient]
|
||||||
|
NMCP_Deck[DeckClient]
|
||||||
|
|
||||||
|
NMCP_Main --> NMCP_Notes
|
||||||
|
NMCP_Main --> NMCP_Cal
|
||||||
|
NMCP_Main --> NMCP_Contacts
|
||||||
|
NMCP_Main --> NMCP_Tables
|
||||||
|
NMCP_Main --> NMCP_WebDAV
|
||||||
|
NMCP_Main --> NMCP_Deck
|
||||||
|
|
||||||
|
NMCP_Notes -.->|extends| NMCP_Base
|
||||||
|
NMCP_Cal -.->|extends| NMCP_Base
|
||||||
|
NMCP_Contacts -.->|extends| NMCP_Base
|
||||||
|
|
||||||
|
NMCP_Base --> HTTPX["httpx.AsyncClient"]
|
||||||
|
NMCP_Base --> Retry["@retry_on_429"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph CA_Client["Context Agent Client"]
|
||||||
|
direction TB
|
||||||
|
CA_NC["nc-py-api<br/>NextcloudApp"]
|
||||||
|
|
||||||
|
CA_NC --> CA_Cal["nc.cal<br/>CalDAV"]
|
||||||
|
CA_NC --> CA_Talk["nc.talk<br/>Talk API"]
|
||||||
|
CA_NC --> CA_OCS["nc.ocs<br/>OCS API"]
|
||||||
|
CA_NC --> CA_Session["nc._session<br/>HTTP Adapter"]
|
||||||
|
end
|
||||||
|
|
||||||
|
HTTPX -->|"HTTP/HTTPS"| NextcloudAPI["Nextcloud APIs"]
|
||||||
|
CA_Session -->|"HTTP/HTTPS"| NextcloudAPI
|
||||||
|
|
||||||
|
classDef custom fill:#fff4e1
|
||||||
|
classDef native fill:#e8f5e9
|
||||||
|
|
||||||
|
class NMCP_Main,NMCP_Base,NMCP_Notes,NMCP_Cal custom
|
||||||
|
class CA_NC,CA_Cal,CA_Talk,CA_OCS native
|
||||||
|
```
|
||||||
|
|
||||||
|
## Functionality Comparison
|
||||||
|
|
||||||
|
### Available Tools & Features
|
||||||
|
|
||||||
|
| Feature Category | Nextcloud MCP Server | Context Agent MCP |
|
||||||
|
|-----------------|---------------------|-------------------|
|
||||||
|
| **Notes** | ✅ Full CRUD, search, attachments (7 tools) | ❌ Not implemented |
|
||||||
|
| **Calendar** | ✅ Full CalDAV (events, recurring, attendees) | ✅ Schedule events, list calendars, free/busy, tasks (4 tools) |
|
||||||
|
| **Contacts** | ✅ Full CardDAV (address books, contacts) | ✅ Find person, current user details (2 tools) |
|
||||||
|
| **Files** | ✅ Full WebDAV (read, write, directories) | ✅ Get content, folder tree, sharing (3 tools) |
|
||||||
|
| **Tables** | ✅ Row CRUD operations | ❌ Not implemented |
|
||||||
|
| **Deck** | ✅ Boards, stacks, cards | ✅ Create board, add card (2 tools) |
|
||||||
|
| **Talk** | ❌ Not implemented | ✅ List/send messages, create conversation (4 tools) |
|
||||||
|
| **Mail** | ❌ Not implemented | ✅ Send email, list mailboxes (2 tools) |
|
||||||
|
| **AI Features** | ❌ Not implemented | ✅ Image gen, audio2text, doc-gen, context_chat (4 tools) |
|
||||||
|
| **Web Search** | ❌ Not implemented | ✅ DuckDuckGo, YouTube search (2 tools) |
|
||||||
|
| **Location** | ❌ Not implemented | ✅ OpenStreetMap, HERE transit, weather (3 tools) |
|
||||||
|
| **OpenProject** | ❌ Not implemented | ✅ Integration (2 tools) |
|
||||||
|
| **MCP Resources** | ✅ notes://, nc:// URIs | ❌ Not supported |
|
||||||
|
| **External MCP** | ❌ Pure server only | ✅ Consumes external MCP servers |
|
||||||
|
| **Sharing** | ✅ Share management API | ❌ Not implemented |
|
||||||
|
| **Capabilities** | ✅ Server info resource | ❌ Not exposed |
|
||||||
|
|
||||||
|
### Tool Count Summary
|
||||||
|
|
||||||
|
- **Nextcloud MCP Server**: ~50+ tools and resources
|
||||||
|
- Deep integration with specific apps
|
||||||
|
- Full CRUD operations
|
||||||
|
- MCP Resources for structured data
|
||||||
|
|
||||||
|
- **Context Agent**: ~28+ tools
|
||||||
|
- Broader feature coverage
|
||||||
|
- Action-oriented (agent tasks)
|
||||||
|
- Can aggregate external MCP servers
|
||||||
|
|
||||||
|
## Tool Safety & Confirmation
|
||||||
|
|
||||||
|
### Context Agent Safety Model
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
Request[User Request] --> Agent[LangGraph Agent]
|
||||||
|
Agent --> Model[LLM generates tool calls]
|
||||||
|
Model --> Check{Tool type?}
|
||||||
|
|
||||||
|
Check -->|"@safe_tool"| Execute[Execute immediately]
|
||||||
|
Check -->|"@dangerous_tool"| Queue[Queue for confirmation]
|
||||||
|
|
||||||
|
Queue --> UserNode[Request user confirmation]
|
||||||
|
UserNode -->|Approved| Execute
|
||||||
|
UserNode -->|Denied| Cancel[Cancel with reason]
|
||||||
|
|
||||||
|
Execute --> Result[Return result to agent]
|
||||||
|
Cancel --> Result
|
||||||
|
|
||||||
|
Result --> Agent
|
||||||
|
|
||||||
|
classDef safe fill:#e8f5e9
|
||||||
|
classDef danger fill:#ffe8e8
|
||||||
|
|
||||||
|
class Execute safe
|
||||||
|
class Queue,UserNode,Cancel danger
|
||||||
|
```
|
||||||
|
|
||||||
|
**Safe Tools** (read-only):
|
||||||
|
- `list_calendars`
|
||||||
|
- `find_person_in_contacts`
|
||||||
|
- `list_talk_conversations`
|
||||||
|
- `get_file_content`
|
||||||
|
- `get_folder_tree`
|
||||||
|
|
||||||
|
**Dangerous Tools** (write operations):
|
||||||
|
- `schedule_event`
|
||||||
|
- `send_message_to_conversation`
|
||||||
|
- `create_public_sharing_link`
|
||||||
|
- `send_email`
|
||||||
|
|
||||||
|
### Nextcloud MCP Server Safety
|
||||||
|
|
||||||
|
**No built-in safety classification**:
|
||||||
|
- All tools treated equally
|
||||||
|
- Relies on MCP client for validation
|
||||||
|
- OAuth scopes could control permissions
|
||||||
|
- User must review all actions
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
### Nextcloud MCP Server
|
||||||
|
|
||||||
|
```python
|
||||||
|
try:
|
||||||
|
note_data = await client.notes.create_note(...)
|
||||||
|
return CreateNoteResponse(...)
|
||||||
|
except HTTPStatusError as e:
|
||||||
|
if e.response.status_code == 403:
|
||||||
|
raise McpError(ErrorData(
|
||||||
|
code=-1,
|
||||||
|
message="Access denied: insufficient permissions"
|
||||||
|
))
|
||||||
|
elif e.response.status_code == 413:
|
||||||
|
raise McpError(ErrorData(
|
||||||
|
code=-1,
|
||||||
|
message="Note content too large"
|
||||||
|
))
|
||||||
|
elif e.response.status_code == 409:
|
||||||
|
raise McpError(ErrorData(
|
||||||
|
code=-1,
|
||||||
|
message="Note with this title already exists"
|
||||||
|
))
|
||||||
|
```
|
||||||
|
|
||||||
|
**Features**:
|
||||||
|
- Comprehensive HTTP status code handling
|
||||||
|
- User-friendly error messages
|
||||||
|
- Specific error codes
|
||||||
|
- Guidance on resolution
|
||||||
|
|
||||||
|
### Context Agent
|
||||||
|
|
||||||
|
```python
|
||||||
|
def schedule_event(...):
|
||||||
|
"""Create event"""
|
||||||
|
# ... implementation
|
||||||
|
calendar.add_event(str(c))
|
||||||
|
return True # Simple boolean return
|
||||||
|
```
|
||||||
|
|
||||||
|
**Features**:
|
||||||
|
- Minimal error handling
|
||||||
|
- Exceptions propagate to agent
|
||||||
|
- LangChain handles retries
|
||||||
|
- Agent interprets failures
|
||||||
|
|
||||||
|
## Use Cases
|
||||||
|
|
||||||
|
### When to Use Nextcloud MCP Server
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph LR
|
||||||
|
Root[Nextcloud MCP Server]
|
||||||
|
|
||||||
|
Root --> ExtAccess[External Access]
|
||||||
|
Root --> OAuth[OAuth Security]
|
||||||
|
Root --> DeepAPI[Deep API Access]
|
||||||
|
Root --> Deploy[Standalone Deployment]
|
||||||
|
|
||||||
|
ExtAccess --> EA1[Claude Code integration]
|
||||||
|
ExtAccess --> EA2[IDE plugins with MCP]
|
||||||
|
ExtAccess --> EA3[Custom MCP clients]
|
||||||
|
ExtAccess --> EA4[Cross-platform tools]
|
||||||
|
|
||||||
|
OAuth --> O1[Token-based auth]
|
||||||
|
OAuth --> O2[OIDC compliance]
|
||||||
|
OAuth --> O3[Per-user permissions]
|
||||||
|
OAuth --> O4[Secure external access]
|
||||||
|
|
||||||
|
DeepAPI --> DA1[Full CRUD operations]
|
||||||
|
DeepAPI --> DA2[Notes management]
|
||||||
|
DeepAPI --> DA3[Calendar CalDAV]
|
||||||
|
DeepAPI --> DA4[Contacts CardDAV]
|
||||||
|
DeepAPI --> DA5[File operations]
|
||||||
|
DeepAPI --> DA6[Table data]
|
||||||
|
|
||||||
|
Deploy --> D1[Docker containers]
|
||||||
|
Deploy --> D2[Cloud VMs]
|
||||||
|
Deploy --> D3[Kubernetes]
|
||||||
|
Deploy --> D4[On-premise servers]
|
||||||
|
|
||||||
|
classDef rootStyle fill:#4a90e2,stroke:#2e5c8a,color:#fff
|
||||||
|
classDef categoryStyle fill:#f39c12,stroke:#d68910,color:#fff
|
||||||
|
classDef itemStyle fill:#e8f5e9,stroke:#81c784
|
||||||
|
|
||||||
|
class Root rootStyle
|
||||||
|
class ExtAccess,OAuth,DeepAPI,Deploy categoryStyle
|
||||||
|
class EA1,EA2,EA3,EA4,O1,O2,O3,O4,DA1,DA2,DA3,DA4,DA5,DA6,D1,D2,D3,D4 itemStyle
|
||||||
|
```
|
||||||
|
|
||||||
|
**Best for**:
|
||||||
|
1. External clients accessing Nextcloud (Claude Code, IDEs)
|
||||||
|
2. OAuth/OIDC authentication requirements
|
||||||
|
3. Full CRUD on Notes, Calendar, Contacts, Tables
|
||||||
|
4. WebDAV file system access
|
||||||
|
5. MCP Resources for structured data
|
||||||
|
6. Flexible deployment scenarios
|
||||||
|
7. Building external integrations
|
||||||
|
|
||||||
|
### When to Use Context Agent MCP Server
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph LR
|
||||||
|
Root[Context Agent MCP]
|
||||||
|
|
||||||
|
Root --> Assistant[AI Assistant]
|
||||||
|
Root --> ActionOriented[Action-Oriented]
|
||||||
|
Root --> MCPAgg[MCP Aggregation]
|
||||||
|
Root --> Safety[Safety Features]
|
||||||
|
|
||||||
|
Assistant --> A1[Nextcloud UI integration]
|
||||||
|
Assistant --> A2[Task Processing API]
|
||||||
|
Assistant --> A3[User requests in Assistant]
|
||||||
|
Assistant --> A4[Human-in-the-loop]
|
||||||
|
|
||||||
|
ActionOriented --> AO1[Send emails]
|
||||||
|
ActionOriented --> AO2[Create calendar events]
|
||||||
|
ActionOriented --> AO3[Post Talk messages]
|
||||||
|
ActionOriented --> AO4[Generate images]
|
||||||
|
ActionOriented --> AO5[Search web]
|
||||||
|
|
||||||
|
MCPAgg --> M1[Consume external MCP servers]
|
||||||
|
MCPAgg --> M2[Weather services]
|
||||||
|
MCPAgg --> M3[Maps and transit]
|
||||||
|
MCPAgg --> M4[Custom integrations]
|
||||||
|
MCPAgg --> M5[Unified tool interface]
|
||||||
|
|
||||||
|
Safety --> S1[Read operations auto-execute]
|
||||||
|
Safety --> S2[Write operations require approval]
|
||||||
|
Safety --> S3[User confirmation flow]
|
||||||
|
Safety --> S4[Agent safety]
|
||||||
|
|
||||||
|
classDef rootStyle fill:#9b59b6,stroke:#6c3483,color:#fff
|
||||||
|
classDef categoryStyle fill:#e74c3c,stroke:#c0392b,color:#fff
|
||||||
|
classDef itemStyle fill:#fff4e1,stroke:#f39c12
|
||||||
|
|
||||||
|
class Root rootStyle
|
||||||
|
class Assistant,ActionOriented,MCPAgg,Safety categoryStyle
|
||||||
|
class A1,A2,A3,A4,AO1,AO2,AO3,AO4,AO5,M1,M2,M3,M4,M5,S1,S2,S3,S4 itemStyle
|
||||||
|
```
|
||||||
|
|
||||||
|
**Best for**:
|
||||||
|
1. AI-driven actions inside Nextcloud UI
|
||||||
|
2. Assistant app integration
|
||||||
|
3. Safe/dangerous tool distinction
|
||||||
|
4. Talk, Mail, Deck operations
|
||||||
|
5. AI features (image gen, audio2text)
|
||||||
|
6. Web search and maps
|
||||||
|
7. Aggregating external MCP servers
|
||||||
|
8. Agent acting on behalf of users
|
||||||
|
|
||||||
|
## Complementary Architecture
|
||||||
|
|
||||||
|
The two MCP servers can work together in complementary ways:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TB
|
||||||
|
User[User] -->|Requests AI assistance| Assistant[Nextcloud Assistant App]
|
||||||
|
|
||||||
|
Assistant --> ContextAgent[Context Agent]
|
||||||
|
|
||||||
|
subgraph ContextAgent["Context Agent (Inside Nextcloud)"]
|
||||||
|
direction TB
|
||||||
|
Agent[LangGraph Agent]
|
||||||
|
MCPServer[MCP Server /mcp]
|
||||||
|
ToolLoader[Tool Loader]
|
||||||
|
|
||||||
|
Agent --> ToolLoader
|
||||||
|
ToolLoader --> InternalTools[Internal Tools<br/>Talk, Mail, Calendar]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph ExternalMCP["External MCP Ecosystem"]
|
||||||
|
NextcloudMCP[Nextcloud MCP Server<br/>This Project]
|
||||||
|
WeatherMCP[Weather MCP]
|
||||||
|
CustomMCP[Custom MCP Services]
|
||||||
|
end
|
||||||
|
|
||||||
|
ToolLoader -->|Consumes| NextcloudMCP
|
||||||
|
ToolLoader -->|Consumes| WeatherMCP
|
||||||
|
ToolLoader -->|Consumes| CustomMCP
|
||||||
|
|
||||||
|
subgraph ExternalClients["External Clients"]
|
||||||
|
Claude[Claude Code]
|
||||||
|
IDE[IDEs with MCP]
|
||||||
|
end
|
||||||
|
|
||||||
|
Claude -->|Direct access| NextcloudMCP
|
||||||
|
IDE -->|Direct access| NextcloudMCP
|
||||||
|
|
||||||
|
NextcloudMCP -->|OAuth/HTTP| NextcloudApps[Nextcloud Apps<br/>Notes, Calendar, Files]
|
||||||
|
InternalTools -->|nc-py-api| NextcloudApps
|
||||||
|
|
||||||
|
classDef internal fill:#e8f5e9
|
||||||
|
classDef external fill:#e1f5ff
|
||||||
|
classDef mcp fill:#fff4e1
|
||||||
|
|
||||||
|
class Assistant,Agent,MCPServer,ToolLoader,InternalTools,NextcloudApps internal
|
||||||
|
class Claude,IDE external
|
||||||
|
class NextcloudMCP,WeatherMCP,CustomMCP mcp
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example Workflows
|
||||||
|
|
||||||
|
**Workflow 1: External Client → Nextcloud MCP Server**
|
||||||
|
```
|
||||||
|
Claude Code → Nextcloud MCP Server → Nextcloud Notes API
|
||||||
|
```
|
||||||
|
- User asks Claude Code to search notes
|
||||||
|
- Claude Code calls `nc_notes_search_notes` tool
|
||||||
|
- Returns results directly to user
|
||||||
|
|
||||||
|
**Workflow 2: Assistant → Context Agent → Internal Tools**
|
||||||
|
```
|
||||||
|
User → Assistant → Context Agent → Send Email Tool
|
||||||
|
```
|
||||||
|
- User asks Assistant to send an email
|
||||||
|
- Context Agent identifies "send_email" as dangerous
|
||||||
|
- Requests user confirmation
|
||||||
|
- Sends email via nc-py-api
|
||||||
|
|
||||||
|
**Workflow 3: Assistant → Context Agent → External MCP**
|
||||||
|
```
|
||||||
|
User → Assistant → Context Agent → Nextcloud MCP Server → Notes
|
||||||
|
```
|
||||||
|
- User asks Assistant about notes
|
||||||
|
- Context Agent consumes Nextcloud MCP Server as external MCP
|
||||||
|
- Gets notes data via MCP protocol
|
||||||
|
- Returns to user via Assistant
|
||||||
|
|
||||||
|
## Technical Comparison Matrix
|
||||||
|
|
||||||
|
| Aspect | Nextcloud MCP Server | Context Agent MCP |
|
||||||
|
|--------|---------------------|-------------------|
|
||||||
|
| **Framework** | FastMCP (native) | FastMCP + LangChain |
|
||||||
|
| **Tool Decorator** | `@mcp.tool()` | `@tool` from LangChain |
|
||||||
|
| **Tool Loading** | Static (startup) | Dynamic (runtime) |
|
||||||
|
| **Tool Refresh** | No (restart required) | Every 60 seconds |
|
||||||
|
| **Resources** | Yes (`@mcp.resource()`) | No |
|
||||||
|
| **Transports** | SSE, HTTP, Streamable-HTTP | Stateless HTTP only |
|
||||||
|
| **MCP Mode** | Server only | Server + Client (hybrid) |
|
||||||
|
| **Client Type** | httpx (custom HTTP) | nc-py-api (native) |
|
||||||
|
| **Deployment** | Standalone external | Inside Nextcloud (ExApp) |
|
||||||
|
| **Auth** | BasicAuth or OAuth/OIDC | Session-based (ExApp) |
|
||||||
|
| **User Context** | Shared or per-token | Per-request `nc.set_user()` |
|
||||||
|
| **Error Handling** | McpError with codes | Basic exceptions |
|
||||||
|
| **Type Safety** | Pydantic models | Python types |
|
||||||
|
| **Safety Model** | No built-in | Safe/Dangerous classification |
|
||||||
|
| **Dependencies** | FastMCP, httpx, Pydantic | nc-py-api, LangChain, LangGraph |
|
||||||
|
| **Integration** | HTTP APIs | AppAPI + Task Processing |
|
||||||
|
| **External MCP** | No | Yes (consumes) |
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Both MCP servers serve important but different roles in the Nextcloud ecosystem:
|
||||||
|
|
||||||
|
### Nextcloud MCP Server (This Project)
|
||||||
|
- **Purpose**: Expose Nextcloud to external MCP clients
|
||||||
|
- **Strength**: Deep CRUD operations, OAuth security, standalone deployment
|
||||||
|
- **Audience**: External developers, Claude Code users, integration builders
|
||||||
|
|
||||||
|
### Context Agent MCP Server
|
||||||
|
- **Purpose**: Bring AI agent capabilities to Nextcloud users
|
||||||
|
- **Strength**: Action-oriented, safe/dangerous tools, MCP aggregation
|
||||||
|
- **Audience**: Nextcloud users via Assistant app, AI-driven workflows
|
||||||
|
|
||||||
|
**Key Insight**: These are complementary, not competing. Context Agent could even consume Nextcloud MCP Server as one of its external MCP sources, creating a unified ecosystem where:
|
||||||
|
- External clients access Nextcloud via Nextcloud MCP Server
|
||||||
|
- Internal users leverage Context Agent for AI assistance
|
||||||
|
- Context Agent aggregates both internal tools and external MCP servers (including Nextcloud MCP Server)
|
||||||
+2
-11
@@ -45,8 +45,7 @@ NEXTCLOUD_HOST=https://your.nextcloud.instance.com
|
|||||||
NEXTCLOUD_OIDC_CLIENT_ID=your-client-id
|
NEXTCLOUD_OIDC_CLIENT_ID=your-client-id
|
||||||
NEXTCLOUD_OIDC_CLIENT_SECRET=your-client-secret
|
NEXTCLOUD_OIDC_CLIENT_SECRET=your-client-secret
|
||||||
|
|
||||||
# OAuth Storage and Callback Settings (optional)
|
# OAuth Callback Settings (optional)
|
||||||
NEXTCLOUD_OIDC_CLIENT_STORAGE=.nextcloud_oauth_client.json
|
|
||||||
NEXTCLOUD_MCP_SERVER_URL=http://localhost:8000
|
NEXTCLOUD_MCP_SERVER_URL=http://localhost:8000
|
||||||
|
|
||||||
# Leave these EMPTY for OAuth mode
|
# Leave these EMPTY for OAuth mode
|
||||||
@@ -61,7 +60,6 @@ NEXTCLOUD_PASSWORD=
|
|||||||
| `NEXTCLOUD_HOST` | ✅ Yes | - | Full URL of your Nextcloud instance (e.g., `https://cloud.example.com`) |
|
| `NEXTCLOUD_HOST` | ✅ Yes | - | Full URL of your Nextcloud instance (e.g., `https://cloud.example.com`) |
|
||||||
| `NEXTCLOUD_OIDC_CLIENT_ID` | ⚠️ Optional | - | OAuth client ID (auto-registers if empty) |
|
| `NEXTCLOUD_OIDC_CLIENT_ID` | ⚠️ Optional | - | OAuth client ID (auto-registers if empty) |
|
||||||
| `NEXTCLOUD_OIDC_CLIENT_SECRET` | ⚠️ Optional | - | OAuth client secret (auto-registers if empty) |
|
| `NEXTCLOUD_OIDC_CLIENT_SECRET` | ⚠️ Optional | - | OAuth client secret (auto-registers if empty) |
|
||||||
| `NEXTCLOUD_OIDC_CLIENT_STORAGE` | ⚠️ Optional | `.nextcloud_oauth_client.json` | Path to store auto-registered client credentials |
|
|
||||||
| `NEXTCLOUD_MCP_SERVER_URL` | ⚠️ Optional | `http://localhost:8000` | MCP server URL for OAuth callbacks |
|
| `NEXTCLOUD_MCP_SERVER_URL` | ⚠️ Optional | `http://localhost:8000` | MCP server URL for OAuth callbacks |
|
||||||
| `NEXTCLOUD_USERNAME` | ❌ Must be empty | - | Leave empty to enable OAuth mode |
|
| `NEXTCLOUD_USERNAME` | ❌ Must be empty | - | Leave empty to enable OAuth mode |
|
||||||
| `NEXTCLOUD_PASSWORD` | ❌ Must be empty | - | Leave empty to enable OAuth mode |
|
| `NEXTCLOUD_PASSWORD` | ❌ Must be empty | - | Leave empty to enable OAuth mode |
|
||||||
@@ -160,10 +158,6 @@ Options:
|
|||||||
NEXTCLOUD_OIDC_CLIENT_ID env var)
|
NEXTCLOUD_OIDC_CLIENT_ID env var)
|
||||||
--oauth-client-secret TEXT OAuth client secret (can also use
|
--oauth-client-secret TEXT OAuth client secret (can also use
|
||||||
NEXTCLOUD_OIDC_CLIENT_SECRET env var)
|
NEXTCLOUD_OIDC_CLIENT_SECRET env var)
|
||||||
--oauth-storage-path TEXT Path to store OAuth client credentials
|
|
||||||
(can also use
|
|
||||||
NEXTCLOUD_OIDC_CLIENT_STORAGE env var)
|
|
||||||
[default: .nextcloud_oauth_client.json]
|
|
||||||
--mcp-server-url TEXT MCP server URL for OAuth callbacks (can
|
--mcp-server-url TEXT MCP server URL for OAuth callbacks (can
|
||||||
also use NEXTCLOUD_MCP_SERVER_URL env
|
also use NEXTCLOUD_MCP_SERVER_URL env
|
||||||
var) [default: http://localhost:8000]
|
var) [default: http://localhost:8000]
|
||||||
@@ -225,10 +219,7 @@ uv run nextcloud-mcp-server --no-oauth \
|
|||||||
- Store OAuth client credentials securely
|
- Store OAuth client credentials securely
|
||||||
- Use environment variables from your deployment platform (Docker secrets, Kubernetes ConfigMaps, etc.)
|
- Use environment variables from your deployment platform (Docker secrets, Kubernetes ConfigMaps, etc.)
|
||||||
- Never commit credentials to version control
|
- Never commit credentials to version control
|
||||||
- Set appropriate file permissions on credential storage:
|
- SQLite database permissions are handled automatically by the server
|
||||||
```bash
|
|
||||||
chmod 600 .nextcloud_oauth_client.json
|
|
||||||
```
|
|
||||||
|
|
||||||
### For Docker
|
### For Docker
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
# Cookbook App
|
||||||
|
|
||||||
|
### Cookbook Tools
|
||||||
|
|
||||||
|
| Tool | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `nc_cookbook_import_recipe` | Import a recipe from a URL using schema.org metadata |
|
||||||
|
| `nc_cookbook_create_recipe` | Create a new recipe with all schema.org fields |
|
||||||
|
| `nc_cookbook_get_recipe` | Get a specific recipe by ID |
|
||||||
|
| `nc_cookbook_update_recipe` | Update an existing recipe |
|
||||||
|
| `nc_cookbook_delete_recipe` | Delete a recipe permanently |
|
||||||
|
| `nc_cookbook_list_recipes` | Get all recipes in the database |
|
||||||
|
| `nc_cookbook_search_recipes` | Search for recipes by keywords, tags, and categories |
|
||||||
|
| `nc_cookbook_list_categories` | Get all known recipe categories |
|
||||||
|
| `nc_cookbook_get_recipes_in_category` | Get all recipes in a specific category |
|
||||||
|
| `nc_cookbook_list_keywords` | Get all known recipe keywords/tags |
|
||||||
|
| `nc_cookbook_get_recipes_with_keywords` | Get all recipes that have specific keywords |
|
||||||
|
| `nc_cookbook_set_config` | Set Cookbook app configuration |
|
||||||
|
| `nc_cookbook_reindex` | Trigger a rescan of all recipes into the search database |
|
||||||
|
|
||||||
|
### Cookbook Resources
|
||||||
|
|
||||||
|
| Resource | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `cookbook://version` | Get Cookbook app and API version information |
|
||||||
|
| `cookbook://config` | Get Cookbook app configuration |
|
||||||
|
| `nc://Cookbook/{recipe_id}` | Get a specific recipe by ID |
|
||||||
|
|
||||||
|
## Recipe Management
|
||||||
|
|
||||||
|
The server provides complete Nextcloud Cookbook integration, enabling you to manage your recipe collection:
|
||||||
|
|
||||||
|
- **Import recipes from websites** using schema.org metadata
|
||||||
|
- Full CRUD operations for recipes
|
||||||
|
- Search and organize with categories and keywords
|
||||||
|
- Support for structured recipe data (ingredients, instructions, nutrition, etc.)
|
||||||
|
- Configure app settings and trigger reindexing
|
||||||
|
|
||||||
|
### Schema.org Recipe Format
|
||||||
|
|
||||||
|
The Cookbook app uses the [schema.org/Recipe](https://schema.org/Recipe) specification for structured recipe data. This standard format includes:
|
||||||
|
|
||||||
|
- **Basic info**: Name, description, image, URL
|
||||||
|
- **Timing**: Preparation time, cooking time, total time (ISO8601 format like `PT30M`)
|
||||||
|
- **Ingredients**: List of ingredients with quantities
|
||||||
|
- **Instructions**: Step-by-step cooking instructions
|
||||||
|
- **Metadata**: Category, keywords/tags, yield (servings)
|
||||||
|
- **Nutrition**: Optional nutrition information
|
||||||
|
|
||||||
|
### Usage Examples
|
||||||
|
|
||||||
|
#### Import Recipe from URL
|
||||||
|
|
||||||
|
Many recipe websites include schema.org metadata. The import tool automatically extracts this data:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Import from a recipe website
|
||||||
|
await nc_cookbook_import_recipe(
|
||||||
|
url="https://www.example.com/recipes/chocolate-cake"
|
||||||
|
)
|
||||||
|
# Returns: Recipe object with all extracted data
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Create Recipe Manually
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Create a new recipe from scratch
|
||||||
|
await nc_cookbook_create_recipe(
|
||||||
|
name="Homemade Pizza",
|
||||||
|
description="Classic homemade pizza with fresh ingredients",
|
||||||
|
ingredients=[
|
||||||
|
"500g pizza dough",
|
||||||
|
"200g tomato sauce",
|
||||||
|
"300g mozzarella cheese",
|
||||||
|
"Fresh basil leaves",
|
||||||
|
"Olive oil"
|
||||||
|
],
|
||||||
|
instructions=[
|
||||||
|
"Preheat oven to 250°C (480°F)",
|
||||||
|
"Roll out the pizza dough",
|
||||||
|
"Spread tomato sauce evenly",
|
||||||
|
"Add mozzarella cheese",
|
||||||
|
"Bake for 10-12 minutes",
|
||||||
|
"Top with fresh basil and olive oil"
|
||||||
|
],
|
||||||
|
category="Main Course",
|
||||||
|
keywords="italian,vegetarian,quick",
|
||||||
|
prep_time="PT20M", # 20 minutes
|
||||||
|
cook_time="PT12M", # 12 minutes
|
||||||
|
total_time="PT32M", # 32 minutes
|
||||||
|
recipe_yield=4 # 4 servings
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Update Recipe
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Update recipe details (only specified fields are changed)
|
||||||
|
await nc_cookbook_update_recipe(
|
||||||
|
recipe_id=123,
|
||||||
|
description="Updated: Classic homemade pizza - now with video tutorial!",
|
||||||
|
url="https://example.com/videos/pizza-tutorial",
|
||||||
|
keywords="italian,vegetarian,quick,video"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Search and Filter
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Search recipes by keyword
|
||||||
|
results = await nc_cookbook_search_recipes(query="chocolate")
|
||||||
|
|
||||||
|
# List all categories
|
||||||
|
categories = await nc_cookbook_list_categories()
|
||||||
|
# Returns: [{"name": "Desserts", "recipe_count": 15}, ...]
|
||||||
|
|
||||||
|
# Get recipes in a category
|
||||||
|
desserts = await nc_cookbook_get_recipes_in_category(category="Desserts")
|
||||||
|
|
||||||
|
# List all keywords/tags
|
||||||
|
keywords = await nc_cookbook_list_keywords()
|
||||||
|
# Returns: [{"name": "chocolate", "recipe_count": 8}, ...]
|
||||||
|
|
||||||
|
# Get recipes with specific tags
|
||||||
|
quick_meals = await nc_cookbook_get_recipes_with_keywords(keywords=["quick", "30min"])
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Manage Configuration
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Configure the Cookbook app
|
||||||
|
await nc_cookbook_set_config(
|
||||||
|
folder="Recipes", # Folder path in user's files
|
||||||
|
update_interval=15, # Auto-rescan every 15 minutes
|
||||||
|
print_image=True # Print images with recipes
|
||||||
|
)
|
||||||
|
|
||||||
|
# Trigger manual reindex after file changes
|
||||||
|
await nc_cookbook_reindex()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Time Format (ISO8601 Duration)
|
||||||
|
|
||||||
|
Recipe times use ISO8601 duration format:
|
||||||
|
|
||||||
|
| Duration | Format | Example |
|
||||||
|
|----------|--------|---------|
|
||||||
|
| 15 minutes | `PT15M` | Prep time |
|
||||||
|
| 1 hour | `PT1H` | Baking time |
|
||||||
|
| 1 hour 30 minutes | `PT1H30M` | Total time |
|
||||||
|
| 45 seconds | `PT45S` | Mixing time |
|
||||||
|
| 2 hours 15 minutes | `PT2H15M` | Slow cooking |
|
||||||
|
|
||||||
|
### Tips for Recipe Import
|
||||||
|
|
||||||
|
**Best practices for importing recipes from URLs:**
|
||||||
|
|
||||||
|
1. **Look for schema.org support**: Most modern recipe sites include schema.org metadata
|
||||||
|
2. **Check import quality**: Review imported recipes for completeness
|
||||||
|
3. **Handle duplicates**: The API prevents duplicate imports by recipe name
|
||||||
|
4. **Edit after import**: Update imported recipes with personal notes or adjustments
|
||||||
|
|
||||||
|
**Common recipe websites with good schema.org support:**
|
||||||
|
- AllRecipes
|
||||||
|
- Food Network
|
||||||
|
- BBC Good Food
|
||||||
|
- Serious Eats
|
||||||
|
- Bon Appétit
|
||||||
|
- Many food blogs using recipe plugins
|
||||||
|
|
||||||
|
### Organizing Your Recipes
|
||||||
|
|
||||||
|
**Categories**: Organize recipes by type (Appetizers, Main Course, Desserts, etc.)
|
||||||
|
- Use `nc_cookbook_list_categories` to see all categories
|
||||||
|
- Filter by category with `nc_cookbook_get_recipes_in_category`
|
||||||
|
|
||||||
|
**Keywords/Tags**: Tag recipes with searchable terms (vegetarian, quick, spicy, etc.)
|
||||||
|
- Use `nc_cookbook_list_keywords` to see all tags
|
||||||
|
- Filter by tags with `nc_cookbook_get_recipes_with_keywords`
|
||||||
|
- Search across all fields with `nc_cookbook_search_recipes`
|
||||||
|
|
||||||
|
**Reindexing**: The Cookbook app maintains a search index
|
||||||
|
- Automatically scans at configured intervals
|
||||||
|
- Manually trigger with `nc_cookbook_reindex` after bulk changes
|
||||||
|
- Required after modifying recipe files directly in WebDAV
|
||||||
|
|
||||||
|
## API Reference
|
||||||
|
|
||||||
|
For detailed API documentation, see the [Nextcloud Cookbook OpenAPI specification](https://github.com/nextcloud/cookbook/tree/master/docs/dev/api/0.1.2).
|
||||||
@@ -0,0 +1,898 @@
|
|||||||
|
# JWT OAuth Reference - Nextcloud MCP Server
|
||||||
|
|
||||||
|
**Last Updated:** 2025-10-23
|
||||||
|
**Status:** Production Ready
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
- [Overview](#overview)
|
||||||
|
- [JWT vs Opaque Tokens](#jwt-vs-opaque-tokens)
|
||||||
|
- [Scope-Based Authorization](#scope-based-authorization)
|
||||||
|
- [Configuration](#configuration)
|
||||||
|
- [Architecture](#architecture)
|
||||||
|
- [Testing](#testing)
|
||||||
|
- [Troubleshooting](#troubleshooting)
|
||||||
|
- [Production Deployment](#production-deployment)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The Nextcloud MCP Server supports OAuth authentication with both **JWT** (RFC 9068) and **opaque** tokens. JWT tokens are recommended for production use as they enable:
|
||||||
|
|
||||||
|
- **Faster validation** - No HTTP call needed for token verification
|
||||||
|
- **Direct scope extraction** - Scopes embedded in token claims
|
||||||
|
- **Dynamic tool filtering** - Users only see tools they have permission to use
|
||||||
|
- **Signature verification** - Cryptographic validation using JWKS
|
||||||
|
|
||||||
|
### Key Features
|
||||||
|
|
||||||
|
- ✅ **JWT Token Support** - RFC 9068 compliant access tokens with RS256 signatures
|
||||||
|
- ✅ **Custom Scopes** - `mcp:notes:read` and `mcp:notes:write` for read/write access control
|
||||||
|
- ✅ **Dynamic Tool Filtering** - Tools filtered based on user's token scopes
|
||||||
|
- ✅ **Scope Challenges** - RFC-compliant `WWW-Authenticate` headers for insufficient scopes
|
||||||
|
- ✅ **Protected Resource Metadata** - RFC 9728 endpoint for scope discovery
|
||||||
|
- ✅ **Backward Compatible** - BasicAuth mode bypasses all scope checks
|
||||||
|
|
||||||
|
### Supported Scopes
|
||||||
|
|
||||||
|
| Scope | Description | Tool Count |
|
||||||
|
|-------|-------------|------------|
|
||||||
|
| `mcp:notes:read` | Read-only access to Nextcloud data | 36 tools |
|
||||||
|
| `mcp:notes:write` | Write access to create/modify/delete data | 54 tools |
|
||||||
|
|
||||||
|
All MCP tools (90 total) require at least one of these scopes. Standard OIDC scopes (`openid`, `profile`, `email`) are also supported.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## JWT vs Opaque Tokens
|
||||||
|
|
||||||
|
The Nextcloud OIDC app supports two token formats, configured per-client:
|
||||||
|
|
||||||
|
### JWT Tokens (Recommended)
|
||||||
|
|
||||||
|
**Advantages:**
|
||||||
|
- ✅ Fast validation - JWT signature verified locally using JWKS
|
||||||
|
- ✅ Direct scope extraction from `scope` claim in payload
|
||||||
|
- ✅ Standard approach (RFC 9068)
|
||||||
|
- ✅ No additional HTTP calls for validation
|
||||||
|
|
||||||
|
**Disadvantages:**
|
||||||
|
- ⚠️ Larger size (~800-1200 chars vs 72 chars for opaque)
|
||||||
|
- ⚠️ Token payload visible to client (not an issue for access tokens)
|
||||||
|
|
||||||
|
**Token Structure:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"header": {
|
||||||
|
"typ": "at+JWT",
|
||||||
|
"alg": "RS256",
|
||||||
|
"kid": "..."
|
||||||
|
},
|
||||||
|
"payload": {
|
||||||
|
"iss": "http://localhost:8080",
|
||||||
|
"sub": "admin",
|
||||||
|
"aud": "client_id",
|
||||||
|
"exp": 1234567890,
|
||||||
|
"iat": 1234567890,
|
||||||
|
"scope": "openid profile email mcp:notes:read mcp:notes:write",
|
||||||
|
"client_id": "...",
|
||||||
|
"jti": "..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Opaque Tokens
|
||||||
|
|
||||||
|
**Advantages:**
|
||||||
|
- ✅ Smaller size (72 characters)
|
||||||
|
- ✅ No payload visible to client
|
||||||
|
- ✅ Direct scope access via introspection endpoint (RFC 7662)
|
||||||
|
|
||||||
|
**Disadvantages:**
|
||||||
|
- ❌ Higher latency - Requires HTTP call to introspection endpoint
|
||||||
|
- ❌ Slower than JWT signature verification (network roundtrip)
|
||||||
|
|
||||||
|
**Validation Method:**
|
||||||
|
Opaque tokens are validated using the **introspection endpoint** (`/apps/oidc/introspect`), which returns:
|
||||||
|
- Token active status
|
||||||
|
- Scope claim (direct access, no inference needed)
|
||||||
|
- User information (`sub`, `username`)
|
||||||
|
- Token metadata (`exp`, `iat`, `client_id`)
|
||||||
|
|
||||||
|
Falls back to userinfo endpoint only if introspection is unavailable.
|
||||||
|
|
||||||
|
**When to Use:**
|
||||||
|
- Use **JWT tokens** for production (better performance, no HTTP call)
|
||||||
|
- Use **opaque tokens** for compatibility with clients that don't support JWT
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scope-Based Authorization
|
||||||
|
|
||||||
|
### Scope Definitions
|
||||||
|
|
||||||
|
The MCP server uses **coarse-grained scopes** for simplicity:
|
||||||
|
|
||||||
|
| Scope | Operations | Examples |
|
||||||
|
|-------|------------|----------|
|
||||||
|
| `mcp:notes:read` | Read-only access | Get notes, search files, list calendars, read contacts |
|
||||||
|
| `mcp:notes:write` | Write operations | Create notes, update events, delete files, modify contacts |
|
||||||
|
|
||||||
|
### Standard OIDC Scopes
|
||||||
|
|
||||||
|
| Scope | Description | Required |
|
||||||
|
|-------|-------------|----------|
|
||||||
|
| `openid` | OIDC authentication | Yes |
|
||||||
|
| `profile` | User profile information | Recommended |
|
||||||
|
| `email` | Email address | Recommended |
|
||||||
|
|
||||||
|
### Recommended Configurations
|
||||||
|
|
||||||
|
**Full Access:**
|
||||||
|
```
|
||||||
|
openid profile email mcp:notes:read mcp:notes:write
|
||||||
|
```
|
||||||
|
|
||||||
|
**Read-Only:**
|
||||||
|
```
|
||||||
|
openid profile email mcp:notes:read
|
||||||
|
```
|
||||||
|
|
||||||
|
**No Custom Scopes (OIDC only):**
|
||||||
|
```
|
||||||
|
openid profile email
|
||||||
|
```
|
||||||
|
|
||||||
|
### Implementation
|
||||||
|
|
||||||
|
All 90 MCP tools are decorated with scope requirements:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@mcp.tool()
|
||||||
|
@require_scopes("mcp:notes:read")
|
||||||
|
async def nc_notes_get_note(note_id: int, ctx: Context):
|
||||||
|
"""Get a note by ID (requires mcp:notes:read scope)"""
|
||||||
|
...
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
@require_scopes("mcp:notes:write")
|
||||||
|
async def nc_notes_create_note(title: str, content: str, ctx: Context):
|
||||||
|
"""Create a note (requires mcp:notes:write scope)"""
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
**Coverage:**
|
||||||
|
- ✅ 36 read tools decorated with `@require_scopes("mcp:notes:read")`
|
||||||
|
- ✅ 54 write tools decorated with `@require_scopes("mcp:notes:write")`
|
||||||
|
- ✅ 90/90 tools covered (100%)
|
||||||
|
|
||||||
|
### Dynamic Tool Filtering
|
||||||
|
|
||||||
|
The MCP server implements **dynamic tool filtering** - users only see tools they have permission to use. This applies to **both JWT and Bearer (opaque) tokens** in OAuth mode:
|
||||||
|
|
||||||
|
**Token with `mcp:notes:read` only:**
|
||||||
|
- `list_tools()` returns 36 read-only tools
|
||||||
|
- Write tools are hidden from the tool list
|
||||||
|
|
||||||
|
**Token with `mcp:notes:write` only:**
|
||||||
|
- `list_tools()` returns 54 write-only tools
|
||||||
|
- Read tools are hidden from the tool list
|
||||||
|
|
||||||
|
**Token with both scopes:**
|
||||||
|
- `list_tools()` returns all 90 tools
|
||||||
|
|
||||||
|
**Token with no custom scopes:**
|
||||||
|
- `list_tools()` returns 0 tools (all require `mcp:notes:read` or `mcp:notes:write`)
|
||||||
|
|
||||||
|
**BasicAuth mode:**
|
||||||
|
- `list_tools()` returns all 90 tools (no filtering)
|
||||||
|
|
||||||
|
**Note:** JWT tokens include scopes in the token payload, while Bearer tokens retrieve scopes via the introspection endpoint. Both methods provide reliable scope information for filtering.
|
||||||
|
|
||||||
|
### Scope Challenges
|
||||||
|
|
||||||
|
When a tool is called without required scopes, the server returns a `403 Forbidden` response with a `WWW-Authenticate` header:
|
||||||
|
|
||||||
|
```http
|
||||||
|
HTTP/1.1 403 Forbidden
|
||||||
|
WWW-Authenticate: Bearer error="insufficient_scope",
|
||||||
|
scope="mcp:notes:write",
|
||||||
|
resource_metadata="http://server/.well-known/oauth-protected-resource/mcp"
|
||||||
|
```
|
||||||
|
|
||||||
|
This enables **step-up authorization** - clients can detect missing scopes and trigger re-authentication to obtain additional permissions.
|
||||||
|
|
||||||
|
### Protected Resource Metadata (PRM)
|
||||||
|
|
||||||
|
The server implements RFC 9728's Protected Resource Metadata endpoint:
|
||||||
|
|
||||||
|
**Endpoint:** `GET /.well-known/oauth-protected-resource/mcp`
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"resource": "http://localhost:8001/mcp",
|
||||||
|
"scopes_supported": ["mcp:notes:read", "mcp:notes:write"],
|
||||||
|
"authorization_servers": ["http://localhost:8080"],
|
||||||
|
"bearer_methods_supported": ["header"],
|
||||||
|
"resource_signing_alg_values_supported": ["RS256"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This allows OAuth clients to discover supported scopes before requesting authorization.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Docker Services
|
||||||
|
|
||||||
|
The development environment includes two MCP server variants:
|
||||||
|
|
||||||
|
| Service | Port | Auth Type | Token Type | Use Case |
|
||||||
|
|---------|------|-----------|------------|----------|
|
||||||
|
| `mcp` | 8000 | BasicAuth | N/A | Development, testing |
|
||||||
|
| `mcp-oauth` | 8001 | OAuth | JWT (configurable) | OAuth testing with JWT tokens |
|
||||||
|
|
||||||
|
### OAuth Service Configuration
|
||||||
|
|
||||||
|
The `mcp-oauth` service uses **Dynamic Client Registration (DCR)** by default and is configured to request JWT tokens:
|
||||||
|
|
||||||
|
**Default Configuration (DCR with JWT tokens):**
|
||||||
|
```yaml
|
||||||
|
mcp-oauth:
|
||||||
|
build: .
|
||||||
|
command: ["--transport", "streamable-http", "--oauth", "--port", "8001", "--oauth-token-type", "jwt"]
|
||||||
|
ports:
|
||||||
|
- 127.0.0.1:8001:8001
|
||||||
|
environment:
|
||||||
|
- NEXTCLOUD_HOST=http://app:80
|
||||||
|
- NEXTCLOUD_MCP_SERVER_URL=http://localhost:8001
|
||||||
|
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8080
|
||||||
|
- NEXTCLOUD_OIDC_SCOPES=openid profile email mcp:notes:read mcp:notes:write
|
||||||
|
volumes:
|
||||||
|
- oauth-client-storage:/app/.oauth # Persist DCR credentials
|
||||||
|
```
|
||||||
|
|
||||||
|
**With Pre-Configured Credentials:**
|
||||||
|
```yaml
|
||||||
|
mcp-oauth:
|
||||||
|
build: .
|
||||||
|
command: ["--transport", "streamable-http", "--oauth", "--port", "8001", "--oauth-token-type", "jwt"]
|
||||||
|
ports:
|
||||||
|
- 127.0.0.1:8001:8001
|
||||||
|
environment:
|
||||||
|
- NEXTCLOUD_HOST=http://app:80
|
||||||
|
- NEXTCLOUD_MCP_SERVER_URL=http://localhost:8001
|
||||||
|
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8080
|
||||||
|
- NEXTCLOUD_OIDC_CLIENT_ID=<your_client_id> # Skips DCR
|
||||||
|
- NEXTCLOUD_OIDC_CLIENT_SECRET=<your_client_secret> # Skips DCR
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key Points:**
|
||||||
|
- **No credentials needed** - DCR automatically registers the client on first start
|
||||||
|
- **Credentials persist** - Saved to SQLite database and reused
|
||||||
|
- **JWT tokens** - Use `--oauth-token-type jwt` for better performance
|
||||||
|
- **Token verifier supports both** - Can handle JWT and opaque tokens
|
||||||
|
- **Pre-configured credentials** - Providing `CLIENT_ID`/`CLIENT_SECRET` skips DCR
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
| Variable | Description | Default |
|
||||||
|
|----------|-------------|---------|
|
||||||
|
| `NEXTCLOUD_HOST` | Nextcloud base URL | `http://localhost:8080` |
|
||||||
|
| `NEXTCLOUD_MCP_SERVER_URL` | MCP server external URL for OAuth callbacks | (required in OAuth mode) |
|
||||||
|
| `NEXTCLOUD_PUBLIC_ISSUER_URL` | Public issuer URL for JWT validation | (uses `NEXTCLOUD_HOST`) |
|
||||||
|
| `NEXTCLOUD_OIDC_CLIENT_ID` | Pre-configured OAuth client ID | (optional - uses DCR if unset) |
|
||||||
|
| `NEXTCLOUD_OIDC_CLIENT_SECRET` | Pre-configured OAuth client secret | (optional - uses DCR if unset) |
|
||||||
|
| `NEXTCLOUD_OIDC_SCOPES` | Space-separated scopes to request | `"openid profile email mcp:notes:read mcp:notes:write"` |
|
||||||
|
| `NEXTCLOUD_OIDC_TOKEN_TYPE` | Token format: `"jwt"` or `"Bearer"` | `"Bearer"` |
|
||||||
|
|
||||||
|
### Dynamic Client Registration (DCR)
|
||||||
|
|
||||||
|
The MCP server supports **automatic OAuth client registration** using the OIDC Discovery registration endpoint. This eliminates the need for manual client creation in most cases.
|
||||||
|
|
||||||
|
**How It Works:**
|
||||||
|
|
||||||
|
When the MCP server starts in OAuth mode, it follows this **three-tier credential loading strategy**:
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Environment Variables (Highest Priority)
|
||||||
|
├─ NEXTCLOUD_OIDC_CLIENT_ID
|
||||||
|
└─ NEXTCLOUD_OIDC_CLIENT_SECRET
|
||||||
|
|
||||||
|
2. SQLite Database (Second Priority)
|
||||||
|
└─ OAuth client credentials table
|
||||||
|
|
||||||
|
3. Dynamic Client Registration (Automatic Fallback)
|
||||||
|
├─ Discovers registration endpoint from /.well-known/openid-configuration
|
||||||
|
├─ Registers new client with requested scopes and token type
|
||||||
|
├─ Saves credentials to storage file for future use
|
||||||
|
└─ Client credentials persist across restarts
|
||||||
|
```
|
||||||
|
|
||||||
|
**Configuration:**
|
||||||
|
|
||||||
|
DCR automatically configures the client based on environment variables:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Minimal DCR configuration (no credentials needed!)
|
||||||
|
export NEXTCLOUD_HOST=http://localhost:8080
|
||||||
|
export NEXTCLOUD_MCP_SERVER_URL=http://localhost:8000
|
||||||
|
export NEXTCLOUD_OIDC_SCOPES="openid profile email mcp:notes:read mcp:notes:write"
|
||||||
|
export NEXTCLOUD_OIDC_TOKEN_TYPE=jwt # or "Bearer" for opaque tokens
|
||||||
|
```
|
||||||
|
|
||||||
|
**Credential Storage:**
|
||||||
|
|
||||||
|
- Registered credentials are saved to SQLite database
|
||||||
|
- Database is encrypted and protected by file system permissions
|
||||||
|
- Credentials are reused on subsequent starts (no re-registration needed)
|
||||||
|
- Stored credentials are checked for expiration (auto-regenerates if expired)
|
||||||
|
|
||||||
|
**Format:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"client_id": "XBd2xqIisu3Kswg39Ub4BUhC36PEYjwwivx3G5nZdDgigvwKXrTHozs7m9DeoLSY",
|
||||||
|
"client_secret": "xNKcy0qpUSau36T60pGGdb03pMEVLXtqykxjK8YkDpoNxNcZ4ClyAT3IAEse2AKT",
|
||||||
|
"client_id_issued_at": 1761097039,
|
||||||
|
"client_secret_expires_at": 2076457039,
|
||||||
|
"redirect_uris": ["http://localhost:8000/oauth/callback"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Benefits:**
|
||||||
|
- ✅ Zero-configuration OAuth setup
|
||||||
|
- ✅ Automatic credential management
|
||||||
|
- ✅ Supports both JWT and opaque tokens
|
||||||
|
- ✅ Credentials persist across container restarts
|
||||||
|
- ✅ Automatic re-registration if credentials expire
|
||||||
|
- ✅ Properly sets `allowed_scopes` for JWT token validation
|
||||||
|
|
||||||
|
### Manual Client Creation
|
||||||
|
|
||||||
|
Manual client creation is **optional** but may be preferred when:
|
||||||
|
- You want explicit control over client configuration
|
||||||
|
- You're deploying to production environments with strict security policies
|
||||||
|
- You need to pre-provision OAuth clients before deployment
|
||||||
|
|
||||||
|
**Create Client via OCC Command:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec app php occ oidc:create \
|
||||||
|
--token_type=jwt \
|
||||||
|
--allowed_scopes="openid profile email mcp:notes:read mcp:notes:write" \
|
||||||
|
"Nextcloud MCP Server" \
|
||||||
|
"http://localhost:8000/oauth/callback"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Output:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"client_id": "XBd2xqIisu3Kswg39Ub4BUhC36PEYjwwivx3G5nZdDgigvwKXrTHozs7m9DeoLSY",
|
||||||
|
"client_secret": "xNKcy0qpUSau36T60pGGdb03pMEVLXtqykxjK8YkDpoNxNcZ4ClyAT3IAEse2AKT",
|
||||||
|
"token_type": "jwt",
|
||||||
|
"allowed_scopes": "openid profile email mcp:notes:read mcp:notes:write"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Configure MCP Server with Pre-Configured Credentials:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Option 1: Environment variables (highest priority)
|
||||||
|
export NEXTCLOUD_OIDC_CLIENT_ID="<client_id>"
|
||||||
|
export NEXTCLOUD_OIDC_CLIENT_SECRET="<client_secret>"
|
||||||
|
export NEXTCLOUD_OIDC_TOKEN_TYPE="jwt"
|
||||||
|
|
||||||
|
# Option 2: SQLite database (second priority)
|
||||||
|
# Credentials are automatically saved to the database after DCR
|
||||||
|
# Server will automatically load them on startup
|
||||||
|
```
|
||||||
|
|
||||||
|
When credentials are provided via environment variables or storage file, **DCR is skipped**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Component Overview
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────┐ OAuth Flow ┌──────────────────┐
|
||||||
|
│ OAuth Client │<─────────────────────>│ Nextcloud OIDC │
|
||||||
|
│ (Claude, etc) │ │ Server │
|
||||||
|
└────────┬─────────┘ └────────┬─────────┘
|
||||||
|
│ │
|
||||||
|
│ JWT Access Token │
|
||||||
|
│ { │
|
||||||
|
│ "scope": "openid mcp:notes:read mcp:notes:write" │
|
||||||
|
│ ... │
|
||||||
|
│ } │
|
||||||
|
│ │
|
||||||
|
v │
|
||||||
|
┌────────────────────────────────────────────────────────────┐
|
||||||
|
│ Nextcloud MCP Server │
|
||||||
|
│ ┌───────────────────────────────────────────────────┐ │
|
||||||
|
│ │ NextcloudTokenVerifier │ │
|
||||||
|
│ │ - JWT signature verification (JWKS) │ │
|
||||||
|
│ │ - Introspection endpoint (opaque tokens) │ │
|
||||||
|
│ │ - Userinfo fallback (last resort) │ │
|
||||||
|
│ └───────────────────┬───────────────────────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ v │
|
||||||
|
│ ┌───────────────────────────────────────────────────┐ │
|
||||||
|
│ │ Dynamic Tool Filtering (list_tools) │ │
|
||||||
|
│ │ - Get user scopes from verified token │ │
|
||||||
|
│ │ - Filter tools based on @require_scopes metadata │ │
|
||||||
|
│ │ - Return only accessible tools │ │
|
||||||
|
│ └───────────────────┬───────────────────────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ v │
|
||||||
|
│ ┌───────────────────────────────────────────────────┐ │
|
||||||
|
│ │ Tool Execution (@require_scopes decorator) │ │
|
||||||
|
│ │ - Check token scopes before execution │ │
|
||||||
|
│ │ - Raise InsufficientScopeError if missing │ │
|
||||||
|
│ │ - Return 403 with WWW-Authenticate header │ │
|
||||||
|
│ └───────────────────────────────────────────────────┘ │
|
||||||
|
└────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Components
|
||||||
|
|
||||||
|
**1. Token Verification** (`nextcloud_mcp_server/auth/token_verifier.py`)
|
||||||
|
- **Three-tier validation strategy:**
|
||||||
|
1. **JWT verification** (lines 116-124): JWKS signature validation for JWT tokens
|
||||||
|
2. **Introspection** (lines 126-134): RFC 7662 endpoint for opaque tokens
|
||||||
|
3. **Userinfo fallback** (lines 137-142): Last resort if introspection unavailable
|
||||||
|
- Scope extraction from token payload (JWT) or introspection response (opaque)
|
||||||
|
- Token caching with TTL to reduce repeated validations
|
||||||
|
- Supports both access token formats transparently
|
||||||
|
|
||||||
|
**2. Scope Authorization** (`nextcloud_mcp_server/auth/scope_authorization.py`)
|
||||||
|
- `@require_scopes()` decorator for tools
|
||||||
|
- `get_required_scopes()` - Extract scope requirements from functions
|
||||||
|
- `has_required_scopes()` - Check if user has necessary scopes
|
||||||
|
- `InsufficientScopeError` exception for WWW-Authenticate challenges
|
||||||
|
|
||||||
|
**3. Dynamic Filtering** (`nextcloud_mcp_server/app.py:473-516`)
|
||||||
|
- Overrides FastMCP's `list_tools()` method
|
||||||
|
- Filters based on user's OAuth token scopes (JWT and Bearer)
|
||||||
|
- Only active in OAuth mode
|
||||||
|
- Bypassed in BasicAuth mode
|
||||||
|
|
||||||
|
**4. PRM Endpoint** (`nextcloud_mcp_server/app.py:503-532`)
|
||||||
|
- `GET /.well-known/oauth-protected-resource/mcp`
|
||||||
|
- Advertises `["mcp:notes:read", "mcp:notes:write"]`
|
||||||
|
- RFC 9728 compliant
|
||||||
|
|
||||||
|
**5. Exception Handler** (`nextcloud_mcp_server/app.py:540-563`)
|
||||||
|
- Catches `InsufficientScopeError`
|
||||||
|
- Returns 403 with `WWW-Authenticate` header
|
||||||
|
- Includes missing scopes and PRM endpoint URL
|
||||||
|
|
||||||
|
### Token Validation Flow
|
||||||
|
|
||||||
|
The `NextcloudTokenVerifier` implements a **cascading validation strategy** that handles both JWT and opaque tokens efficiently:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ verify_token(token) │
|
||||||
|
│ (nextcloud_mcp_server/auth/token_verifier.py:88-142) │
|
||||||
|
└────────────────────────┬────────────────────────────────┘
|
||||||
|
│
|
||||||
|
├──> 1. Check cache (lines 106-109)
|
||||||
|
│ ├─ Hit: Return cached AccessToken
|
||||||
|
│ └─ Miss: Continue to validation
|
||||||
|
│
|
||||||
|
├──> 2. JWT Format Check (lines 112-124)
|
||||||
|
│ ├─ Token has 3 parts (header.payload.signature)?
|
||||||
|
│ │ └─ Yes: Attempt JWT verification
|
||||||
|
│ │ ├─ Verify signature with JWKS (RS256)
|
||||||
|
│ │ ├─ Validate issuer, expiration
|
||||||
|
│ │ ├─ Extract scopes from payload
|
||||||
|
│ │ └─ Success: Return AccessToken
|
||||||
|
│ └─ Fail/Not JWT: Continue to introspection
|
||||||
|
│
|
||||||
|
├──> 3. Introspection (lines 126-134)
|
||||||
|
│ ├─ POST to /apps/oidc/introspect
|
||||||
|
│ ├─ Authenticate with client credentials
|
||||||
|
│ ├─ Response contains:
|
||||||
|
│ │ • active: true/false
|
||||||
|
│ │ • scope: "openid mcp:notes:read mcp:notes:write"
|
||||||
|
│ │ • sub, exp, iat, client_id
|
||||||
|
│ ├─ Extract scopes from response
|
||||||
|
│ └─ Success: Return AccessToken
|
||||||
|
│
|
||||||
|
└──> 4. Userinfo Fallback (lines 137-142)
|
||||||
|
├─ GET /apps/oidc/userinfo
|
||||||
|
├─ Bearer token in Authorization header
|
||||||
|
├─ Infer scopes from response claims
|
||||||
|
└─ Return AccessToken or None
|
||||||
|
```
|
||||||
|
|
||||||
|
**Validation Priorities:**
|
||||||
|
|
||||||
|
| Token Type | Method | Performance | Scope Access | Code Reference |
|
||||||
|
|------------|--------|-------------|--------------|----------------|
|
||||||
|
| JWT | JWKS Signature | ⚡ Fastest (local) | Direct (`scope` claim) | `token_verifier.py:156-234` |
|
||||||
|
| Opaque | Introspection | 🔄 Medium (HTTP) | Direct (`scope` field) | `token_verifier.py:236-328` |
|
||||||
|
| Any | Userinfo | 🐌 Slowest (HTTP + inference) | Inferred (from claims) | `token_verifier.py:330-386` |
|
||||||
|
|
||||||
|
**Configuration** (`nextcloud_mcp_server/app.py:391-399`):
|
||||||
|
```python
|
||||||
|
token_verifier = NextcloudTokenVerifier(
|
||||||
|
nextcloud_host=nextcloud_host,
|
||||||
|
userinfo_uri=userinfo_uri,
|
||||||
|
jwks_uri=jwks_uri, # Enables JWT verification
|
||||||
|
issuer=jwt_validation_issuer, # For JWT issuer validation
|
||||||
|
introspection_uri=introspection_uri, # Enables introspection for opaque tokens
|
||||||
|
client_id=client_id, # Required for introspection auth
|
||||||
|
client_secret=client_secret, # Required for introspection auth
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Test Infrastructure
|
||||||
|
|
||||||
|
The test suite includes comprehensive coverage for JWT OAuth and scope authorization:
|
||||||
|
|
||||||
|
**Test Files:**
|
||||||
|
- `tests/server/test_scope_authorization.py` - Scope-based authorization tests (4 tests)
|
||||||
|
- `tests/server/test_mcp_oauth_jwt.py` - JWT OAuth integration tests
|
||||||
|
- `tests/conftest.py` - Shared fixtures for JWT testing
|
||||||
|
|
||||||
|
### Consent Scenario Tests
|
||||||
|
|
||||||
|
Four test scenarios verify scope-based tool filtering with different consent levels:
|
||||||
|
|
||||||
|
#### 1. No Custom Scopes (0 tools)
|
||||||
|
```bash
|
||||||
|
uv run pytest tests/server/test_scope_authorization.py::test_jwt_with_no_custom_scopes_returns_zero_tools -v
|
||||||
|
```
|
||||||
|
|
||||||
|
**Scenario:** JWT token with only OIDC defaults (`openid profile email`)
|
||||||
|
**Expected:** 0 tools returned (all require `mcp:notes:read` or `mcp:notes:write`)
|
||||||
|
**Verifies:** Security - users who decline custom scopes cannot access any MCP tools
|
||||||
|
|
||||||
|
#### 2. Read-Only Access (36 tools)
|
||||||
|
```bash
|
||||||
|
uv run pytest tests/server/test_scope_authorization.py::test_jwt_consent_scenarios_read_only -v
|
||||||
|
```
|
||||||
|
|
||||||
|
**Scenario:** JWT token with `mcp:notes:read` only
|
||||||
|
**Expected:** 36 read-only tools visible, write tools hidden
|
||||||
|
**Verifies:** Read tools accessible, write tools filtered out
|
||||||
|
|
||||||
|
#### 3. Write-Only Access (54 tools)
|
||||||
|
```bash
|
||||||
|
uv run pytest tests/server/test_scope_authorization.py::test_jwt_consent_scenarios_write_only -v
|
||||||
|
```
|
||||||
|
|
||||||
|
**Scenario:** JWT token with `mcp:notes:write` only
|
||||||
|
**Expected:** 54 write tools visible, read tools hidden
|
||||||
|
**Verifies:** Write tools accessible, read tools filtered out
|
||||||
|
|
||||||
|
#### 4. Full Access (90 tools)
|
||||||
|
```bash
|
||||||
|
uv run pytest tests/server/test_scope_authorization.py::test_jwt_consent_scenarios_full_access -v
|
||||||
|
```
|
||||||
|
|
||||||
|
**Scenario:** JWT token with both `mcp:notes:read` and `mcp:notes:write`
|
||||||
|
**Expected:** All 90 tools visible
|
||||||
|
**Verifies:** Full access when user grants all custom scopes
|
||||||
|
|
||||||
|
### Test Fixtures
|
||||||
|
|
||||||
|
**OAuth Client Fixtures:**
|
||||||
|
- `read_only_oauth_client_credentials` - Client with `mcp:notes:read` only
|
||||||
|
- `write_only_oauth_client_credentials` - Client with `mcp:notes:write` only
|
||||||
|
- `full_access_oauth_client_credentials` - Client with both scopes
|
||||||
|
- `no_custom_scopes_oauth_client_credentials` - Client with OIDC defaults only
|
||||||
|
|
||||||
|
**Token Fixtures:**
|
||||||
|
- `playwright_oauth_token_read_only` - Obtains token with `mcp:notes:read`
|
||||||
|
- `playwright_oauth_token_write_only` - Obtains token with `mcp:notes:write`
|
||||||
|
- `playwright_oauth_token_full_access` - Obtains token with both scopes
|
||||||
|
- `playwright_oauth_token_no_custom_scopes` - Obtains token with no custom scopes
|
||||||
|
|
||||||
|
**MCP Client Fixtures:**
|
||||||
|
- `nc_mcp_oauth_client_read_only` - MCP session with read-only token
|
||||||
|
- `nc_mcp_oauth_client_write_only` - MCP session with write-only token
|
||||||
|
- `nc_mcp_oauth_client_full_access` - MCP session with full access token
|
||||||
|
- `nc_mcp_oauth_client_no_custom_scopes` - MCP session with no custom scopes
|
||||||
|
|
||||||
|
### Running Tests
|
||||||
|
|
||||||
|
**All consent scenario tests:**
|
||||||
|
```bash
|
||||||
|
uv run pytest tests/server/test_scope_authorization.py -v
|
||||||
|
```
|
||||||
|
|
||||||
|
**JWT OAuth integration tests:**
|
||||||
|
```bash
|
||||||
|
uv run pytest tests/server/test_mcp_oauth_jwt.py -v --browser firefox
|
||||||
|
```
|
||||||
|
|
||||||
|
**With visible browser (debugging):**
|
||||||
|
```bash
|
||||||
|
uv run pytest tests/server/test_mcp_oauth_jwt.py -v --browser firefox --headed
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test Configuration
|
||||||
|
|
||||||
|
**Playwright Browser:**
|
||||||
|
- Default: Chromium
|
||||||
|
- Recommended for CI: Firefox (`--browser firefox`)
|
||||||
|
- Debugging: Add `--headed` flag
|
||||||
|
|
||||||
|
**OAuth Flow:**
|
||||||
|
- Uses automated Playwright browser automation
|
||||||
|
- Completes OAuth consent flow programmatically
|
||||||
|
- Creates separate OAuth client for each scenario
|
||||||
|
- Each user gets unique access token
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Issue: JWT Issuer Validation Failed
|
||||||
|
|
||||||
|
**Symptom:**
|
||||||
|
```
|
||||||
|
WARNING JWT issuer validation failed: Invalid issuer
|
||||||
|
WARNING JWT verification failed, will try other methods
|
||||||
|
✅ Extracted scopes from access token: {'openid', 'profile'}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cause:** Token's `iss` claim doesn't match expected issuer URL. This often happens when:
|
||||||
|
- Using `localhost` vs `127.0.0.1` inconsistently
|
||||||
|
- MCP server uses internal URL but clients use public URL
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
```bash
|
||||||
|
# Option 1: Use consistent URLs
|
||||||
|
export NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8080
|
||||||
|
# Ensure all test fixtures also use localhost:8080
|
||||||
|
|
||||||
|
# Option 2: Check discovery document
|
||||||
|
curl http://localhost:8080/.well-known/openid-configuration | jq .issuer
|
||||||
|
# Use this exact issuer in NEXTCLOUD_PUBLIC_ISSUER_URL
|
||||||
|
```
|
||||||
|
|
||||||
|
**Impact if not fixed:**
|
||||||
|
- JWT validation falls back to userinfo endpoint
|
||||||
|
- Scopes inferred from userinfo (only standard OIDC scopes, no custom scopes)
|
||||||
|
- Result: 0 tools visible or incorrect tool filtering
|
||||||
|
|
||||||
|
### Issue: Scopes Not Present in JWT
|
||||||
|
|
||||||
|
**Symptom:** JWT token doesn't contain `scope` claim or contains empty string
|
||||||
|
|
||||||
|
**Cause:** Client's `allowed_scopes` is empty or not configured
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
```bash
|
||||||
|
# Check client configuration
|
||||||
|
docker compose exec app php occ oidc:list
|
||||||
|
|
||||||
|
# Look for allowed_scopes in output
|
||||||
|
# If empty, recreate client with --allowed_scopes
|
||||||
|
docker compose exec app php occ oidc:create \
|
||||||
|
--token_type=jwt \
|
||||||
|
--allowed_scopes="openid profile email mcp:notes:read mcp:notes:write" \
|
||||||
|
"Client Name" \
|
||||||
|
"http://callback/url"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Issue: All Tools Visible Despite Read-Only Token
|
||||||
|
|
||||||
|
**Symptom:** User with `mcp:notes:read` token can see all 90 tools including write tools
|
||||||
|
|
||||||
|
**Cause:** Server running in BasicAuth mode, not OAuth mode
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
```bash
|
||||||
|
# Verify OAuth mode is active
|
||||||
|
docker compose logs mcp-oauth | grep "OAuth mode"
|
||||||
|
|
||||||
|
# Should see: "Running in OAuth mode"
|
||||||
|
|
||||||
|
# If not, check environment variables:
|
||||||
|
docker compose exec mcp-oauth env | grep NEXTCLOUD_OIDC
|
||||||
|
|
||||||
|
# Ensure no NEXTCLOUD_USERNAME or NEXTCLOUD_PASSWORD set
|
||||||
|
```
|
||||||
|
|
||||||
|
### Verifying DCR Scope Configuration
|
||||||
|
|
||||||
|
DCR **now properly sets `allowed_scopes`** when the `scope` parameter is provided during registration.
|
||||||
|
|
||||||
|
**To verify DCR scopes are working:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check the registered client's allowed_scopes via database
|
||||||
|
docker compose exec db mariadb -u nextcloud -ppassword nextcloud \
|
||||||
|
-e "SELECT name, allowed_scopes FROM oc_oauth2_clients WHERE name LIKE 'DCR-%' ORDER BY id DESC LIMIT 1;"
|
||||||
|
|
||||||
|
# Should show your requested scopes (e.g., "openid profile email mcp:notes:read mcp:notes:write")
|
||||||
|
```
|
||||||
|
|
||||||
|
**If scopes are missing:**
|
||||||
|
1. Ensure `NEXTCLOUD_OIDC_SCOPES` environment variable is set correctly
|
||||||
|
2. Check MCP server startup logs for the scopes being requested
|
||||||
|
3. Verify DCR is enabled in Nextcloud OIDC app settings
|
||||||
|
4. Clear the SQLite database OAuth client entry and restart to force re-registration
|
||||||
|
|
||||||
|
### Issue: Token Type Case Sensitivity
|
||||||
|
|
||||||
|
**Symptom:** JWT tokens not generated even though `token_type=JWT` set
|
||||||
|
|
||||||
|
**Cause:** OIDC app checks `token_type === 'jwt'` (lowercase)
|
||||||
|
|
||||||
|
**Solution:** Always use lowercase:
|
||||||
|
```bash
|
||||||
|
# Correct
|
||||||
|
export NEXTCLOUD_OIDC_TOKEN_TYPE=jwt
|
||||||
|
|
||||||
|
# Incorrect (will generate opaque tokens)
|
||||||
|
export NEXTCLOUD_OIDC_TOKEN_TYPE=JWT
|
||||||
|
```
|
||||||
|
|
||||||
|
### Issue: Missing WWW-Authenticate Header
|
||||||
|
|
||||||
|
**Symptom:** 403 error doesn't include `WWW-Authenticate` header
|
||||||
|
|
||||||
|
**Cause:** Server not in OAuth mode, or exception not being caught
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
```bash
|
||||||
|
# Check server logs for OAuth mode
|
||||||
|
docker compose logs mcp-oauth | grep "WWW-Authenticate scope challenges enabled"
|
||||||
|
|
||||||
|
# Should see this during startup
|
||||||
|
|
||||||
|
# Check exception handling
|
||||||
|
docker compose logs mcp-oauth | grep "InsufficientScopeError"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Debugging Tools
|
||||||
|
|
||||||
|
**Check JWT contents:**
|
||||||
|
```bash
|
||||||
|
# Decode JWT (base64 decode the payload)
|
||||||
|
echo "JWT_PAYLOAD_PART" | base64 -d | jq .
|
||||||
|
```
|
||||||
|
|
||||||
|
**Check database scopes:**
|
||||||
|
```bash
|
||||||
|
# View access tokens with scopes
|
||||||
|
docker compose exec db mariadb -u nextcloud -ppassword nextcloud \
|
||||||
|
-e "SELECT id, client_id, user_id, scope FROM oc_oidc_access_tokens ORDER BY id DESC LIMIT 5;"
|
||||||
|
|
||||||
|
# View user consents
|
||||||
|
docker compose exec db mariadb -u nextcloud -ppassword nextcloud \
|
||||||
|
-e "SELECT user_id, client_id, scopes_granted FROM oc_oidc_user_consents;"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Check server logs:**
|
||||||
|
```bash
|
||||||
|
# Follow JWT verification logs
|
||||||
|
docker compose logs -f mcp-oauth | grep -E "JWT|scope|tool"
|
||||||
|
|
||||||
|
# Check for issuer mismatches
|
||||||
|
docker compose logs mcp-oauth | grep -i issuer
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Production Deployment
|
||||||
|
|
||||||
|
### Deployment Checklist
|
||||||
|
|
||||||
|
✅ **Use JWT Tokens** - Enable `token_type=jwt` for better performance
|
||||||
|
✅ **Configure Allowed Scopes** - Always set `allowed_scopes` on OAuth clients
|
||||||
|
✅ **Use Pre-Configured Clients** - Avoid DCR limitation with manual client creation
|
||||||
|
✅ **Consistent URLs** - Use same URL for `NEXTCLOUD_HOST` and `PUBLIC_ISSUER_URL`
|
||||||
|
✅ **Secure Credentials** - Store client credentials securely (environment variables or secrets management)
|
||||||
|
✅ **Monitor Token Size** - JWT tokens are 10-15x larger than opaque (not usually an issue)
|
||||||
|
✅ **Enable Logging** - Configure appropriate log levels for JWT verification
|
||||||
|
|
||||||
|
### Production Configuration Example
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# docker-compose.yml (production)
|
||||||
|
mcp-oauth:
|
||||||
|
image: ghcr.io/yourusername/nextcloud-mcp-server:latest
|
||||||
|
command: ["--transport", "streamable-http", "--oauth", "--port", "8001", "--oauth-token-type", "jwt"]
|
||||||
|
environment:
|
||||||
|
- NEXTCLOUD_HOST=https://nextcloud.example.com
|
||||||
|
- NEXTCLOUD_MCP_SERVER_URL=https://mcp.example.com
|
||||||
|
- NEXTCLOUD_PUBLIC_ISSUER_URL=https://nextcloud.example.com
|
||||||
|
- NEXTCLOUD_OIDC_CLIENT_ID=${JWT_CLIENT_ID}
|
||||||
|
- NEXTCLOUD_OIDC_CLIENT_SECRET=${JWT_CLIENT_SECRET}
|
||||||
|
- NEXTCLOUD_OIDC_SCOPES=openid profile email mcp:notes:read mcp:notes:write
|
||||||
|
ports:
|
||||||
|
- "8001:8001"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Security Considerations
|
||||||
|
|
||||||
|
**Token Storage:**
|
||||||
|
- Never commit credentials to version control
|
||||||
|
- Use environment variables or secrets management
|
||||||
|
- Rotate client secrets periodically
|
||||||
|
|
||||||
|
**Scope Configuration:**
|
||||||
|
- Grant minimum necessary scopes to clients
|
||||||
|
- Use read-only tokens for AI assistants that don't need write access
|
||||||
|
- Review OAuth client list regularly
|
||||||
|
|
||||||
|
**Network Security:**
|
||||||
|
- Use HTTPS in production
|
||||||
|
- Ensure issuer URL matches public URL
|
||||||
|
- Configure proper CORS headers
|
||||||
|
|
||||||
|
### Monitoring
|
||||||
|
|
||||||
|
**Key Metrics:**
|
||||||
|
- JWT verification success/failure rate
|
||||||
|
- Scope challenge frequency (indicates clients with insufficient scopes)
|
||||||
|
- Token validation latency
|
||||||
|
- Tool execution by scope (identify unused scopes)
|
||||||
|
|
||||||
|
**Log Patterns:**
|
||||||
|
```bash
|
||||||
|
# Success
|
||||||
|
INFO JWT verified successfully for user: admin
|
||||||
|
INFO ✅ Extracted scopes from access token: {'openid', 'profile', 'email', 'mcp:notes:read', 'mcp:notes:write'}
|
||||||
|
|
||||||
|
# Failures
|
||||||
|
WARNING JWT issuer validation failed: Invalid issuer
|
||||||
|
WARNING Missing required scopes: mcp:notes:write
|
||||||
|
```
|
||||||
|
|
||||||
|
### Known Limitations
|
||||||
|
|
||||||
|
1. **No Fine-Grained Scopes** - Only coarse `mcp:notes:read` and `mcp:notes:write` (not per-app scopes)
|
||||||
|
2. **No Refresh Token Support** - Tokens must be reacquired when expired
|
||||||
|
|
||||||
|
### Future Enhancements
|
||||||
|
|
||||||
|
**Potential Improvements:**
|
||||||
|
- Per-app scopes (`nc:notes:read`, `nc:calendar:write`)
|
||||||
|
- Resource-level filtering (apply to MCP resources, not just tools)
|
||||||
|
- Automatic scope discovery from decorated tools
|
||||||
|
- Admin UI for scope management
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
### Standards
|
||||||
|
|
||||||
|
- [RFC 9068: JWT Profile for OAuth 2.0 Access Tokens](https://www.rfc-editor.org/rfc/rfc9068.html)
|
||||||
|
- [RFC 7519: JSON Web Token (JWT)](https://www.rfc-editor.org/rfc/rfc7519.html)
|
||||||
|
- [RFC 7517: JSON Web Key (JWK)](https://www.rfc-editor.org/rfc/rfc7517.html)
|
||||||
|
- [RFC 9728: OAuth 2.0 Protected Resource Metadata](https://www.rfc-editor.org/rfc/rfc9728.html)
|
||||||
|
- [RFC 7662: OAuth 2.0 Token Introspection](https://www.rfc-editor.org/rfc/rfc7662.html)
|
||||||
|
|
||||||
|
### Related Documentation
|
||||||
|
|
||||||
|
- [OAuth Setup Guide](oauth-setup.md) - Complete OAuth configuration guide
|
||||||
|
- [OAuth Architecture](oauth-architecture.md) - Detailed architecture documentation
|
||||||
|
- [OAuth Troubleshooting](oauth-troubleshooting.md) - Common OAuth issues and solutions
|
||||||
|
- [Authentication Guide](authentication.md) - BasicAuth vs OAuth comparison
|
||||||
|
|
||||||
|
### External Resources
|
||||||
|
|
||||||
|
- [Nextcloud OIDC App](https://github.com/H2CK/oidc) - OIDC identity provider for Nextcloud
|
||||||
|
- [PyJWT Documentation](https://pyjwt.readthedocs.io/) - JWT library used for verification
|
||||||
|
- [FastMCP Documentation](https://github.com/jlowin/fastmcp) - MCP server framework
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Implementation Date:** 2025-10-21 to 2025-10-23
|
||||||
|
**Version:** 1.0.0
|
||||||
|
**Status:** ✅ Production Ready
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
# Keycloak Multi-Client Token Validation
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
**Question**: Can Nextcloud's `user_oidc` app (configured with client A) validate bearer tokens from client B in the same Keycloak realm?
|
||||||
|
|
||||||
|
**Answer**: ✅ **YES** - user_oidc validates tokens at the **realm level**, not per-client.
|
||||||
|
|
||||||
|
## Test Results
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
- **Keycloak Realm**: `nextcloud-mcp`
|
||||||
|
- **Provider in user_oidc**: Configured with `mcp-client` credentials
|
||||||
|
- **Test**: Get token from `test-client-b`, validate via Nextcloud API
|
||||||
|
|
||||||
|
### Result
|
||||||
|
```bash
|
||||||
|
# Token from test-client-b (client B)
|
||||||
|
$ TOKEN=$(curl -X POST ".../token" -d "client_id=test-client-b" ...)
|
||||||
|
|
||||||
|
# Validated successfully by Nextcloud (configured with mcp-client = client A)
|
||||||
|
$ curl -H "Authorization: Bearer $TOKEN" "http://nextcloud/ocs/.../capabilities"
|
||||||
|
HTTP/1.1 200 OK
|
||||||
|
{"ocs":{"meta":{"status":"ok"}}}
|
||||||
|
```
|
||||||
|
|
||||||
|
✅ **Token from client B validated successfully!**
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
### Token Structure from Keycloak
|
||||||
|
|
||||||
|
**Access Token** (password grant):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"iss": "http://keycloak/realms/nextcloud-mcp",
|
||||||
|
"azp": "test-client-b", // Authorized party = client B
|
||||||
|
"typ": "Bearer",
|
||||||
|
"exp": 1234567890,
|
||||||
|
// NO "sub" claim
|
||||||
|
// NO "aud" claim
|
||||||
|
"scope": "openid profile email"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**ID Token** (for comparison):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"iss": "http://keycloak/realms/nextcloud-mcp",
|
||||||
|
"aud": "test-client-b", // Audience = client B
|
||||||
|
"sub": "923da741-7ebe-4cf9-baf2-37fcf2ecc95d",
|
||||||
|
"azp": "test-client-b"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key Observation**: Access tokens from Keycloak's password grant **do not contain** `sub` or `aud` claims!
|
||||||
|
|
||||||
|
### Validation Flow in user_oidc
|
||||||
|
|
||||||
|
From source code analysis (`~/Software/user_oidc/lib/User/Backend.php`):
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Request with Bearer token arrives
|
||||||
|
↓
|
||||||
|
2. user_oidc loops through providers with checkBearer=true
|
||||||
|
↓
|
||||||
|
3. Try SelfEncodedValidator (JWT/JWKS validation):
|
||||||
|
- Validates JWT signature using Keycloak's JWKS
|
||||||
|
- Tries to extract 'sub' claim → FAILS (no sub in access token)
|
||||||
|
↓
|
||||||
|
4. Fallback to UserInfoValidator:
|
||||||
|
- Calls Keycloak userinfo endpoint with bearer token
|
||||||
|
- Keycloak validates token server-side
|
||||||
|
- Returns userinfo with 'sub' claim
|
||||||
|
→ SUCCESS!
|
||||||
|
↓
|
||||||
|
5. User identified, request authorized
|
||||||
|
```
|
||||||
|
|
||||||
|
### Why This Works
|
||||||
|
|
||||||
|
**Realm-Level Trust**:
|
||||||
|
- Keycloak's userinfo endpoint validates ANY valid token from the realm
|
||||||
|
- It doesn't matter which client issued the token
|
||||||
|
- The token is validated by Keycloak itself (via userinfo call)
|
||||||
|
|
||||||
|
**No Audience Check**:
|
||||||
|
- Access tokens have no `aud` claim
|
||||||
|
- SelfEncodedValidator's audience check is bypassed (no audience to validate)
|
||||||
|
- UserInfoValidator doesn't check audience (delegates to Keycloak)
|
||||||
|
|
||||||
|
**Client Credentials Role**:
|
||||||
|
- The configured `client_id`/`client_secret` in user_oidc are **NOT used** for bearer token validation
|
||||||
|
- They're only used for OAuth login flows (authorization code exchange)
|
||||||
|
- Userinfo endpoint doesn't require client authentication
|
||||||
|
|
||||||
|
## Source Code Evidence
|
||||||
|
|
||||||
|
### SelfEncodedValidator - Audience Check
|
||||||
|
|
||||||
|
```php
|
||||||
|
// ~/Software/user_oidc/lib/User/Validator/SelfEncodedValidator.php:64-76
|
||||||
|
|
||||||
|
$checkAudience = !isset($oidcSystemConfig['selfencoded_bearer_validation_audience_check'])
|
||||||
|
|| !in_array($oidcSystemConfig['selfencoded_bearer_validation_audience_check'],
|
||||||
|
[false, 'false', 0, '0'], true);
|
||||||
|
|
||||||
|
if ($checkAudience) {
|
||||||
|
$tokenAudience = $payload->aud ?? null;
|
||||||
|
|
||||||
|
if ((is_string($tokenAudience) && $tokenAudience !== $providerClientId)
|
||||||
|
|| (is_array($tokenAudience) && !in_array($providerClientId, $tokenAudience))) {
|
||||||
|
$this->logger->debug('Audience does not match client ID');
|
||||||
|
return null; // REJECT
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If $tokenAudience is null (our case), both conditions are false → validation continues
|
||||||
|
```
|
||||||
|
|
||||||
|
### UserInfoValidator - No Client Auth
|
||||||
|
|
||||||
|
```php
|
||||||
|
// ~/Software/user_oidc/lib/Service/OIDCService.php:28-45
|
||||||
|
|
||||||
|
public function userinfo(Provider $provider, string $accessToken): array {
|
||||||
|
$url = $this->discoveryService->obtainDiscovery($provider)['userinfo_endpoint'];
|
||||||
|
|
||||||
|
// Bearer token passed directly - NO client credentials used
|
||||||
|
$options = ['headers' => ['Authorization' => 'Bearer ' . $accessToken]];
|
||||||
|
|
||||||
|
return json_decode($this->clientService->get($url, [], $options), true);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Keycloak Userinfo Response
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ curl -H "Authorization: Bearer $TOKEN_FROM_CLIENT_B" \
|
||||||
|
"http://keycloak/realms/nextcloud-mcp/protocol/openid-connect/userinfo"
|
||||||
|
|
||||||
|
{
|
||||||
|
"sub": "923da741-7ebe-4cf9-baf2-37fcf2ecc95d",
|
||||||
|
"email_verified": true,
|
||||||
|
"name": "Admin User",
|
||||||
|
"email": "admin@example.com"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Keycloak validates the token **regardless of which client issued it**, as long as it's from the same realm.
|
||||||
|
|
||||||
|
## Implications for Your Architecture
|
||||||
|
|
||||||
|
### Desired Architecture
|
||||||
|
```
|
||||||
|
MCP Server (client A) ← DCR with Keycloak
|
||||||
|
MCP Clients (client B, C, D...) ← DCR with Keycloak
|
||||||
|
Nextcloud user_oidc ← configured once with any client from realm
|
||||||
|
```
|
||||||
|
|
||||||
|
### What This Means
|
||||||
|
|
||||||
|
✅ **You can do exactly what you want!**
|
||||||
|
|
||||||
|
1. **Configure user_oidc once** with any client from the Keycloak realm (e.g., a dedicated `nextcloud-validator` client)
|
||||||
|
|
||||||
|
2. **MCP Server registers via DCR** as a unique client (e.g., `mcp-server-abc123`)
|
||||||
|
- Gets its own client credentials
|
||||||
|
- Issues tokens with `azp: "mcp-server-abc123"`
|
||||||
|
- These tokens will be validated by user_oidc!
|
||||||
|
|
||||||
|
3. **MCP Clients also use DCR** (each gets unique identity)
|
||||||
|
- Client A: `client-123`
|
||||||
|
- Client B: `client-456`
|
||||||
|
- Tokens from all clients validated by user_oidc!
|
||||||
|
|
||||||
|
4. **Tokens from ANY client** in the realm can access Nextcloud APIs
|
||||||
|
- user_oidc validates via Keycloak userinfo endpoint
|
||||||
|
- Realm-level trust (not per-client)
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
**Step 1: Configure user_oidc Provider**
|
||||||
|
```bash
|
||||||
|
php occ user_oidc:provider keycloak-realm \
|
||||||
|
--clientid="nextcloud-validator" \
|
||||||
|
--clientsecret="***" \
|
||||||
|
--discoveryuri="https://keycloak/realms/my-realm/.well-known/openid-configuration" \
|
||||||
|
--check-bearer=1 \
|
||||||
|
--bearer-provisioning=1
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: MCP Server Registers with Keycloak (DCR)**
|
||||||
|
```python
|
||||||
|
# MCP server startup
|
||||||
|
registration_response = await keycloak_client.register_client(
|
||||||
|
client_name="MCP Server Instance",
|
||||||
|
redirect_uris=["http://mcp-server/oauth/callback"]
|
||||||
|
)
|
||||||
|
# Store: client_id, client_secret
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Issue Tokens to Users**
|
||||||
|
- Users authenticate via Keycloak
|
||||||
|
- MCP server gets tokens issued to its `client_id`
|
||||||
|
- These tokens validated by user_oidc!
|
||||||
|
|
||||||
|
**Step 4: Background Operations (ADR-002)**
|
||||||
|
- Store user refresh tokens (encrypted)
|
||||||
|
- Refresh access tokens as needed
|
||||||
|
- All tokens validated by user_oidc regardless of issuing client
|
||||||
|
|
||||||
|
## Important Notes
|
||||||
|
|
||||||
|
### Token Grant Types Matter
|
||||||
|
|
||||||
|
**Password Grant** (what we tested):
|
||||||
|
- Access tokens have NO `sub` or `aud`
|
||||||
|
- Forces validation via userinfo endpoint
|
||||||
|
- Works with any client in realm
|
||||||
|
|
||||||
|
**Authorization Code Grant** (production):
|
||||||
|
- Tokens MAY include `aud` claim
|
||||||
|
- Need to verify behavior with real OAuth flows
|
||||||
|
- May require disabling audience check
|
||||||
|
|
||||||
|
### Recommendation for Production
|
||||||
|
|
||||||
|
**Option 1: Disable Audience Check (Simplest)**
|
||||||
|
```php
|
||||||
|
// config.php
|
||||||
|
'user_oidc' => [
|
||||||
|
'selfencoded_bearer_validation_audience_check' => false,
|
||||||
|
],
|
||||||
|
```
|
||||||
|
|
||||||
|
**Option 2: Rely on UserInfo Validation**
|
||||||
|
```php
|
||||||
|
// config.php
|
||||||
|
'user_oidc' => [
|
||||||
|
'userinfo_bearer_validation' => true, // Enable userinfo validation
|
||||||
|
],
|
||||||
|
```
|
||||||
|
|
||||||
|
**Option 3: Configure Keycloak to Not Include aud in Access Tokens**
|
||||||
|
- Keep default behavior (works as tested)
|
||||||
|
- Tokens validated via userinfo endpoint
|
||||||
|
|
||||||
|
## Testing Script
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
# Test multi-client validation
|
||||||
|
|
||||||
|
# Create second client in Keycloak
|
||||||
|
curl -X POST "http://keycloak/admin/realms/my-realm/clients" \
|
||||||
|
-H "Authorization: Bearer $ADMIN_TOKEN" \
|
||||||
|
-d '{
|
||||||
|
"clientId": "test-client-b",
|
||||||
|
"secret": "test-secret-b",
|
||||||
|
"standardFlowEnabled": true,
|
||||||
|
"directAccessGrantsEnabled": true
|
||||||
|
}'
|
||||||
|
|
||||||
|
# Get token from client B
|
||||||
|
TOKEN=$(curl -X POST "http://keycloak/realms/my-realm/protocol/openid-connect/token" \
|
||||||
|
-d "grant_type=password" \
|
||||||
|
-d "client_id=test-client-b" \
|
||||||
|
-d "client_secret=test-secret-b" \
|
||||||
|
-d "username=testuser" \
|
||||||
|
-d "password=password" | jq -r '.access_token')
|
||||||
|
|
||||||
|
# Test with Nextcloud (configured with client A)
|
||||||
|
curl -H "Authorization: Bearer $TOKEN" \
|
||||||
|
"http://nextcloud/ocs/v2.php/cloud/capabilities"
|
||||||
|
|
||||||
|
# Should return 200 OK!
|
||||||
|
```
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
✅ **Your proposed architecture is fully supported!**
|
||||||
|
|
||||||
|
- user_oidc configured once with ANY client from Keycloak realm
|
||||||
|
- MCP server registers dynamically via DCR
|
||||||
|
- MCP clients also register dynamically
|
||||||
|
- ALL tokens from realm validated successfully
|
||||||
|
- No per-client configuration needed
|
||||||
|
|
||||||
|
The key insight: **user_oidc validates tokens at the realm level** (via Keycloak's userinfo endpoint), not at the client level.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- Source code: `~/Software/user_oidc/lib/User/Backend.php:260-343`
|
||||||
|
- SelfEncodedValidator: `~/Software/user_oidc/lib/User/Validator/SelfEncodedValidator.php`
|
||||||
|
- UserInfoValidator: `~/Software/user_oidc/lib/User/Validator/UserInfoValidator.php`
|
||||||
|
- Test setup: `docker-compose.yml` (mcp-keycloak service)
|
||||||
|
- Configuration: `.env.keycloak.sample`
|
||||||
+549
-125
@@ -8,166 +8,463 @@ The Nextcloud MCP Server acts as an **OAuth 2.0 Resource Server**, protecting ac
|
|||||||
|
|
||||||
## Architecture Diagram
|
## Architecture Diagram
|
||||||
|
|
||||||
|
The complete OAuth flow includes server startup (with DCR), client discovery (with PRM), authorization (with PKCE), and API access phases:
|
||||||
|
|
||||||
```
|
```
|
||||||
|
═══════════════════════════════════════════════════════════════════════════════════
|
||||||
|
Phase 0: MCP Server Startup & Client Registration (DCR - RFC 7591)
|
||||||
|
═══════════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
┌──────────────────┐ ┌─────────────────┐
|
||||||
|
│ MCP Server │ │ Nextcloud │
|
||||||
|
│ (Resource │ │ (OIDC Provider)│
|
||||||
|
│ Server) │ │ │
|
||||||
|
└────────┬─────────┘ └────────┬────────┘
|
||||||
|
│ │
|
||||||
|
│ 0a. OIDC Discovery │
|
||||||
|
├────────────────────────────────────>│
|
||||||
|
│ GET │
|
||||||
|
| /.well-known/openid-configuration │
|
||||||
|
│ │
|
||||||
|
│ 0b. Discovery response │
|
||||||
|
│<────────────────────────────────────┤
|
||||||
|
│ {issuer, endpoints, PKCE methods} │
|
||||||
|
│ │
|
||||||
|
│ 0c. Register OAuth client (DCR) │
|
||||||
|
├────────────────────────────────────>│
|
||||||
|
│ POST /apps/oidc/register │
|
||||||
|
│ {client_name, redirect_uris, │
|
||||||
|
│ scopes, token_type} │
|
||||||
|
│ │
|
||||||
|
│ 0d. Client credentials │
|
||||||
|
│<────────────────────────────────────┤
|
||||||
|
│ {client_id, client_secret} │
|
||||||
|
│ → Saved to SQLite database │
|
||||||
|
│ │
|
||||||
|
│ ✓ Server ready for connections │
|
||||||
|
|
||||||
|
|
||||||
|
═══════════════════════════════════════════════════════════════════════════════════
|
||||||
|
Phase 1: Client Connection & Discovery (PRM - RFC 9728)
|
||||||
|
═══════════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
┌─────────────┐ ┌──────────────────┐ ┌─────────────────┐
|
┌─────────────┐ ┌──────────────────┐ ┌─────────────────┐
|
||||||
│ │ │ │ │ │
|
│ │ │ MCP Server │ │ Nextcloud │
|
||||||
│ MCP Client │ │ MCP Server │ │ Nextcloud │
|
│ MCP Client │ │ (Resource │ │ Instance │
|
||||||
│ (Claude, │ │ (Resource │ │ Instance │
|
│ (Claude) │ │ Server) │ │ │
|
||||||
│ etc.) │ │ Server) │ │ │
|
|
||||||
│ │ │ │ │ │
|
│ │ │ │ │ │
|
||||||
└──────┬──────┘ └────────┬─────────┘ └────────┬────────┘
|
└──────┬──────┘ └────────┬─────────┘ └────────┬────────┘
|
||||||
│ │ │
|
│ │ │
|
||||||
│ │ │
|
│ 1a. Connect to MCP │ │
|
||||||
│ 1. Connect to MCP │ │
|
|
||||||
├─────────────────────────────────>│ │
|
├─────────────────────────────────>│ │
|
||||||
│ │ │
|
│ │ │
|
||||||
│ 2. Return auth settings │ │
|
│ 1b. Return auth settings │ │
|
||||||
│ (issuer_url, scopes) │ │
|
|
||||||
│<─────────────────────────────────┤ │
|
│<─────────────────────────────────┤ │
|
||||||
|
│ {issuer_url, resource_url} │ │
|
||||||
│ │ │
|
│ │ │
|
||||||
│ │ │
|
│ 1c. PRM Discovery (RFC 9728) │ │
|
||||||
│ 3. Start OAuth flow (with PKCE) │ │
|
|
||||||
├──────────────────────────────────┼────────────────────────────────────>│
|
|
||||||
│ │ /apps/oidc/authorize │
|
|
||||||
│ │ │
|
|
||||||
│ 4. User authenticates in browser│ │
|
|
||||||
│<─────────────────────────────────┼─────────────────────────────────────┤
|
|
||||||
│ │ │
|
|
||||||
│ 5. Authorization code (redirect)│ │
|
|
||||||
│<─────────────────────────────────┤ │
|
|
||||||
│ │ │
|
|
||||||
│ 6. Exchange code for token │ │
|
|
||||||
├──────────────────────────────────┼────────────────────────────────────>│
|
|
||||||
│ │ /apps/oidc/token │
|
|
||||||
│ │ │
|
|
||||||
│ 7. Access token │ │
|
|
||||||
│<─────────────────────────────────┼─────────────────────────────────────┤
|
|
||||||
│ │ │
|
|
||||||
│ │ │
|
|
||||||
│ 8. API request with Bearer token│ │
|
|
||||||
├─────────────────────────────────>│ │
|
├─────────────────────────────────>│ │
|
||||||
│ Authorization: Bearer xxx │ │
|
│ GET /.well-known/oauth- │ │
|
||||||
|
│ protected-resource/mcp │ │
|
||||||
│ │ │
|
│ │ │
|
||||||
│ │ 9. Validate token via userinfo │
|
│ 1d. PRM response (scopes!) │ │
|
||||||
│ ├────────────────────────────────────>│
|
|
||||||
│ │ /apps/oidc/userinfo │
|
|
||||||
│ │ │
|
|
||||||
│ │ 10. User info (token valid) │
|
|
||||||
│ │<────────────────────────────────────┤
|
|
||||||
│ │ │
|
|
||||||
│ │ 11. Nextcloud API request │
|
|
||||||
│ ├────────────────────────────────────>│
|
|
||||||
│ │ Authorization: Bearer xxx │
|
|
||||||
│ │ (Notes, Calendar, etc.) │
|
|
||||||
│ │ │
|
|
||||||
│ │ 12. API response │
|
|
||||||
│ │<────────────────────────────────────┤
|
|
||||||
│ │ │
|
|
||||||
│ 13. MCP tool response │ │
|
|
||||||
│<─────────────────────────────────┤ │
|
│<─────────────────────────────────┤ │
|
||||||
|
│ {resource, scopes_supported, │ ← Dynamically discovered from │
|
||||||
|
│ authorization_servers} │ @require_scopes decorators │
|
||||||
|
│ │ │
|
||||||
|
|
||||||
|
|
||||||
|
═══════════════════════════════════════════════════════════════════════════════════
|
||||||
|
Phase 2: OAuth Authorization Flow (PKCE - RFC 7636)
|
||||||
|
═══════════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
│ │ │
|
||||||
|
│ 2a. Generate PKCE challenge │ │
|
||||||
|
│ code_verifier = random(43-128) │ │
|
||||||
|
│ code_challenge = SHA256(verif.) │ │
|
||||||
|
│ │ │
|
||||||
|
│ 2b. Authorization request │ │
|
||||||
|
├──────────────────────────────────┼────────────────────────────────────>│
|
||||||
|
│ /apps/oidc/authorize? │ │
|
||||||
|
│ client_id=xxx │ │
|
||||||
|
│ &code_challenge=abc... │ │
|
||||||
|
│ &code_challenge_method=S256 │ │
|
||||||
|
│ &scope=openid notes:read ... │ │
|
||||||
|
│ │ │
|
||||||
|
│ 2c. User consent page │ │
|
||||||
|
│<─────────────────────────────────┼─────────────────────────────────────┤
|
||||||
|
│ (Browser: Select scopes) │ │
|
||||||
|
│ │ │
|
||||||
|
│ 2d. User grants scopes │ │
|
||||||
|
├──────────────────────────────────┼────────────────────────────────────>│
|
||||||
|
│ │ │
|
||||||
|
│ 2e. Authorization code redirect │ │
|
||||||
|
│<─────────────────────────────────┼─────────────────────────────────────┤
|
||||||
|
│ callback?code=xyz123 │ │
|
||||||
|
│ │ │
|
||||||
|
│ 2f. Exchange code for token │ │
|
||||||
|
├──────────────────────────────────┼────────────────────────────────────>│
|
||||||
|
│ POST /apps/oidc/token │ │
|
||||||
|
│ {code, code_verifier, │ ← Validates PKCE challenge │
|
||||||
|
│ client_id, client_secret} │ │
|
||||||
|
│ │ │
|
||||||
|
│ 2g. Access token (JWT/opaque) │ │
|
||||||
|
│<─────────────────────────────────┼─────────────────────────────────────┤
|
||||||
|
│ {access_token, token_type, │ │
|
||||||
|
│ scope: "openid notes:read...") │ ← User's granted scopes │
|
||||||
|
│ │ │
|
||||||
|
|
||||||
|
|
||||||
|
═══════════════════════════════════════════════════════════════════════════════════
|
||||||
|
Phase 3: MCP Tool Access (Scope-based Authorization)
|
||||||
|
═══════════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
│ │ │
|
||||||
|
│ 3a. list_tools request │ │
|
||||||
|
├─────────────────────────────────>│ │
|
||||||
|
│ Authorization: Bearer <token> │ │
|
||||||
|
│ │ │
|
||||||
|
│ │ 3b. Validate token │
|
||||||
|
│ ├────────────────────────────────────>│
|
||||||
|
│ │ GET /apps/oidc/userinfo │
|
||||||
|
│ │ Authorization: Bearer <token> │
|
||||||
|
│ │ │
|
||||||
|
│ │ 3c. Token valid + scopes │
|
||||||
|
│ │<────────────────────────────────────┤
|
||||||
|
│ │ {sub, scopes, ...} │
|
||||||
|
│ │ ← Cached for 1 hour │
|
||||||
|
│ │ │
|
||||||
|
│ 3d. Filtered tool list │ │
|
||||||
|
│<─────────────────────────────────┤ ← Only tools matching user's │
|
||||||
|
│ [tools matching token scopes] │ token scopes (via @require_scopes)
|
||||||
|
│ │ │
|
||||||
|
│ 3e. Call tool │ │
|
||||||
|
├─────────────────────────────────>│ │
|
||||||
|
│ nc_notes_get_note(note_id=1) │ ← @require_scopes("notes:read") │
|
||||||
|
│ Authorization: Bearer <token> │ │
|
||||||
|
│ │ │
|
||||||
|
│ │ 3f. Scope check PASSED │
|
||||||
|
│ │ ✓ Token has notes:read │
|
||||||
|
│ │ │
|
||||||
|
│ │ 3g. Nextcloud API call │
|
||||||
|
│ ├────────────────────────────────────>│
|
||||||
|
│ │ GET /apps/notes/api/v1/notes/1 │
|
||||||
|
│ │ Authorization: Bearer <token> │
|
||||||
|
│ │ ← user_oidc validates Bearer token │
|
||||||
|
│ │ │
|
||||||
|
│ │ 3h. API response │
|
||||||
|
│ │<────────────────────────────────────┤
|
||||||
|
│ │ {id: 1, title: "Note", ...} │
|
||||||
|
│ │ │
|
||||||
|
│ 3i. MCP tool response │ │
|
||||||
|
│<─────────────────────────────────┤ │
|
||||||
|
│ {note data} │ │
|
||||||
|
│ │ │
|
||||||
|
|
||||||
|
|
||||||
|
═══════════════════════════════════════════════════════════════════════════════════
|
||||||
|
Insufficient Scope Example (Step-Up Authorization)
|
||||||
|
═══════════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
│ 4a. Call write tool │ │
|
||||||
|
├─────────────────────────────────>│ │
|
||||||
|
│ nc_notes_create_note(...) │ ← @require_scopes("notes:write") │
|
||||||
|
│ Authorization: Bearer <token> │ │
|
||||||
|
│ │ │
|
||||||
|
│ │ 4b. Scope check FAILED │
|
||||||
|
│ │ ✗ Token only has notes:read │
|
||||||
|
│ │ │
|
||||||
|
│ 4c. 403 Insufficient Scope │ │
|
||||||
|
│<─────────────────────────────────┤ │
|
||||||
|
│ WWW-Authenticate: Bearer │ │
|
||||||
|
│ error="insufficient_scope", │ │
|
||||||
|
│ scope="notes:write", │ │
|
||||||
|
│ resource_metadata="..." │ │
|
||||||
|
│ │ │
|
||||||
|
│ → Client can re-authorize with │ │
|
||||||
|
│ additional scopes (Step-Up) │ │
|
||||||
│ │ │
|
│ │ │
|
||||||
```
|
```
|
||||||
|
|
||||||
## Components
|
## Components
|
||||||
|
|
||||||
### 1. MCP Client
|
### 1. MCP Client (e.g., Claude Desktop, Claude Code)
|
||||||
- Any MCP-compatible client (Claude Desktop, Claude Code, custom clients)
|
|
||||||
|
**Capabilities**:
|
||||||
|
- Discovers OAuth configuration via MCP server
|
||||||
|
- Queries PRM endpoint for supported scopes
|
||||||
- Initiates OAuth flow with PKCE (Proof Key for Code Exchange)
|
- Initiates OAuth flow with PKCE (Proof Key for Code Exchange)
|
||||||
- Stores and sends access token with each request
|
- Stores and sends access token with each request
|
||||||
- **Example**: Claude Desktop, Claude Code
|
- Handles scope-based tool filtering
|
||||||
|
- Supports step-up authorization (re-auth for additional scopes)
|
||||||
|
|
||||||
### 2. MCP Server (Resource Server)
|
**Examples**: Claude Desktop, Claude Code, MCP Inspector, custom MCP clients
|
||||||
- **Role**: OAuth 2.0 Resource Server
|
|
||||||
- **Location**: This Nextcloud MCP Server implementation
|
### 2. MCP Server (Resource Server - This Implementation)
|
||||||
- **Responsibilities**:
|
|
||||||
- Validates Bearer tokens by calling Nextcloud's userinfo endpoint
|
**Role**: OAuth 2.0 Resource Server (RFC 6749)
|
||||||
- Caches validated tokens (default: 1 hour TTL)
|
|
||||||
- Creates authenticated Nextcloud client instances per-user
|
**Responsibilities**:
|
||||||
- Enforces PKCE requirements (S256 code challenge method)
|
|
||||||
- Exposes Nextcloud functionality via MCP tools
|
#### Startup Phase
|
||||||
|
- **OIDC Discovery**: Queries `/.well-known/openid-configuration` for OAuth endpoints
|
||||||
|
- **PKCE Validation**: Verifies server advertises S256 code challenge method
|
||||||
|
- **Dynamic Client Registration (DCR)**: Automatically registers OAuth client via `/apps/oidc/register` (RFC 7591)
|
||||||
|
- Or loads pre-configured client credentials
|
||||||
|
- Saves credentials to SQLite database
|
||||||
|
- **Tool Registration**: Loads all MCP tools with their `@require_scopes` decorators
|
||||||
|
|
||||||
|
#### Client Connection Phase
|
||||||
|
- **Auth Settings**: Returns OAuth issuer URL and resource URL
|
||||||
|
- **PRM Endpoint**: Exposes `/.well-known/oauth-protected-resource/mcp` (RFC 9728)
|
||||||
|
- Dynamically discovers scopes from all registered tools
|
||||||
|
- Returns `scopes_supported` list based on `@require_scopes` decorators
|
||||||
|
|
||||||
|
#### Request Processing Phase
|
||||||
|
- **Token Validation**: Validates Bearer tokens via Nextcloud userinfo endpoint
|
||||||
|
- Supports both JWT and opaque tokens
|
||||||
|
- Caches validation results (1-hour TTL)
|
||||||
|
- Extracts user identity and granted scopes
|
||||||
|
- **Scope Enforcement**:
|
||||||
|
- Filters `list_tools` based on user's token scopes
|
||||||
|
- Validates scopes before executing each tool
|
||||||
|
- Returns 403 with `WWW-Authenticate` header for insufficient scopes
|
||||||
|
- **Per-User Clients**: Creates authenticated `NextcloudClient` instance per user
|
||||||
|
- Uses Bearer token for all Nextcloud API requests
|
||||||
|
- User-specific permissions and audit trails
|
||||||
|
|
||||||
**Key Files**:
|
**Key Files**:
|
||||||
- [`app.py`](../nextcloud_mcp_server/app.py) - OAuth mode detection and configuration
|
- [`app.py`](../nextcloud_mcp_server/app.py) - OAuth mode, DCR, PRM endpoint
|
||||||
- [`auth/token_verifier.py`](../nextcloud_mcp_server/auth/token_verifier.py) - Token validation logic
|
- [`auth/token_verifier.py`](../nextcloud_mcp_server/auth/token_verifier.py) - Token validation (userinfo + introspection + JWT)
|
||||||
- [`auth/context_helper.py`](../nextcloud_mcp_server/auth/context_helper.py) - Per-user client creation
|
- [`auth/context_helper.py`](../nextcloud_mcp_server/auth/context_helper.py) - Per-user client creation
|
||||||
|
- [`auth/scope_authorization.py`](../nextcloud_mcp_server/auth/scope_authorization.py) - `@require_scopes` decorator, scope discovery
|
||||||
|
- [`auth/client_registration.py`](../nextcloud_mcp_server/auth/client_registration.py) - DCR implementation (RFC 7591)
|
||||||
|
|
||||||
### 3. Nextcloud OIDC Apps
|
### 3. Nextcloud OIDC Apps
|
||||||
|
|
||||||
#### a) `oidc` - OIDC Identity Provider
|
#### a) `oidc` - OIDC Identity Provider
|
||||||
- **Role**: OAuth 2.0 Authorization Server
|
|
||||||
- **Location**: Nextcloud app (`apps/oidc`)
|
**Role**: OAuth 2.0 Authorization Server + OIDC Provider
|
||||||
- **Endpoints**:
|
|
||||||
- `/.well-known/openid-configuration` - Discovery endpoint
|
**Location**: Nextcloud app (`apps/oidc`)
|
||||||
- `/apps/oidc/authorize` - Authorization endpoint
|
|
||||||
- `/apps/oidc/token` - Token endpoint
|
**Endpoints**:
|
||||||
- `/apps/oidc/userinfo` - User info endpoint (token validation)
|
- `/.well-known/openid-configuration` - OIDC Discovery (RFC 8414)
|
||||||
- `/apps/oidc/jwks` - JSON Web Key Set
|
- `/apps/oidc/authorize` - Authorization endpoint (OAuth 2.0 + PKCE)
|
||||||
- `/apps/oidc/register` - Dynamic client registration
|
- `/apps/oidc/token` - Token endpoint (issues JWT or opaque tokens)
|
||||||
|
- `/apps/oidc/userinfo` - UserInfo endpoint (OIDC Core, used for token validation)
|
||||||
|
- `/apps/oidc/jwks` - JSON Web Key Set (for JWT signature verification)
|
||||||
|
- `/apps/oidc/register` - Dynamic Client Registration endpoint (RFC 7591)
|
||||||
|
- `/apps/oidc/introspect` - Token Introspection endpoint (RFC 7662, optional)
|
||||||
|
|
||||||
|
**Token Types**:
|
||||||
|
- **JWT tokens**: Self-contained tokens with embedded scopes, validated via JWKS or userinfo
|
||||||
|
- **Opaque tokens**: Random strings, validated via userinfo or introspection endpoint
|
||||||
|
|
||||||
**Configuration**:
|
**Configuration**:
|
||||||
```bash
|
```bash
|
||||||
# Enable dynamic client registration (optional)
|
# Enable dynamic client registration (recommended for development)
|
||||||
# Settings → OIDC → "Allow dynamic client registration"
|
# Nextcloud Admin → Settings → OIDC → "Allow dynamic client registration"
|
||||||
|
|
||||||
|
# Enable token introspection (optional, for opaque token validation)
|
||||||
|
# Nextcloud Admin → Settings → OIDC → "Enable token introspection"
|
||||||
```
|
```
|
||||||
|
|
||||||
#### b) `user_oidc` - OpenID Connect User Backend
|
#### b) `user_oidc` - OpenID Connect User Backend
|
||||||
- **Role**: Bearer token validation middleware
|
|
||||||
- **Location**: Nextcloud app (`apps/user_oidc`)
|
**Role**: Bearer token validation middleware for Nextcloud APIs
|
||||||
- **Responsibilities**:
|
|
||||||
- Validates Bearer tokens for Nextcloud API requests
|
**Location**: Nextcloud app (`apps/user_oidc`)
|
||||||
- Creates user sessions from valid Bearer tokens
|
|
||||||
- Integrates with Nextcloud's authentication system
|
**Responsibilities**:
|
||||||
|
- Intercepts Nextcloud API requests with `Authorization: Bearer` header
|
||||||
|
- Validates tokens against OIDC provider (`oidc` app)
|
||||||
|
- Creates authenticated user sessions
|
||||||
|
- Enforces user-specific permissions on API requests
|
||||||
|
|
||||||
**Configuration**:
|
**Configuration**:
|
||||||
```bash
|
```bash
|
||||||
# Enable Bearer token validation (required)
|
# Enable Bearer token validation (required for OAuth mode)
|
||||||
php occ config:system:set user_oidc oidc_provider_bearer_validation --value=true --type=boolean
|
php occ config:system:set user_oidc oidc_provider_bearer_validation --value=true --type=boolean
|
||||||
```
|
```
|
||||||
|
|
||||||
> [!IMPORTANT]
|
> [!IMPORTANT]
|
||||||
> The `user_oidc` app requires a patch to properly support Bearer token authentication for non-OCS endpoints. See [Upstream Status](oauth-upstream-status.md) for details.
|
> The `user_oidc` app requires a patch to properly support Bearer token authentication for non-OCS endpoints (like Notes API, Calendar API). See [Upstream Status](oauth-upstream-status.md) for patch details and PR status.
|
||||||
|
|
||||||
### 4. Nextcloud Instance
|
### 4. Nextcloud Instance
|
||||||
- **Role**: Resource Owner / API Provider
|
|
||||||
- **Provides**: Notes, Calendar, Contacts, Deck, Files, etc.
|
**Role**: Resource Owner + API Provider
|
||||||
|
|
||||||
|
**APIs Exposed**:
|
||||||
|
- **Notes API**: `/apps/notes/api/v1/` - Note CRUD operations
|
||||||
|
- **Calendar (CalDAV)**: `/remote.php/dav/calendars/` - Events and todos
|
||||||
|
- **Contacts (CardDAV)**: `/remote.php/dav/addressbooks/` - Contact management
|
||||||
|
- **Cookbook API**: `/apps/cookbook/api/v1/` - Recipe management
|
||||||
|
- **Deck API**: `/apps/deck/api/v1.0/` - Kanban boards
|
||||||
|
- **Tables API**: `/apps/tables/api/2/` - Table row operations
|
||||||
|
- **WebDAV (Files)**: `/remote.php/dav/files/` - File operations
|
||||||
|
- **Sharing API**: `/ocs/v2.php/apps/files_sharing/api/v1/` - Share management
|
||||||
|
|
||||||
## Authentication Flow
|
## Authentication Flow
|
||||||
|
|
||||||
### Phase 1: OAuth Authorization (Steps 1-7)
|
The OAuth flow consists of four distinct phases (see diagram above for visual representation):
|
||||||
|
|
||||||
1. **Client Connects**: MCP client connects to MCP server
|
### Phase 0: MCP Server Startup (One-time Setup)
|
||||||
2. **Auth Settings**: MCP server returns OAuth settings:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"issuer_url": "https://nextcloud.example.com",
|
|
||||||
"resource_server_url": "http://localhost:8000",
|
|
||||||
"required_scopes": ["openid", "profile"]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
3. **OAuth Flow**: Client initiates OAuth flow with PKCE
|
|
||||||
- Generates `code_verifier` (random string)
|
|
||||||
- Calculates `code_challenge` = SHA256(code_verifier)
|
|
||||||
- Redirects user to `/apps/oidc/authorize` with `code_challenge`
|
|
||||||
4. **User Authentication**: User logs in to Nextcloud via browser
|
|
||||||
5. **Authorization Code**: Nextcloud redirects back with authorization code
|
|
||||||
6. **Token Exchange**: Client exchanges code for access token
|
|
||||||
- Sends `code` + `code_verifier` to `/apps/oidc/token`
|
|
||||||
- OIDC app validates PKCE challenge
|
|
||||||
7. **Access Token**: Client receives access token (JWT or opaque)
|
|
||||||
|
|
||||||
### Phase 2: API Access (Steps 8-13)
|
**Happens**: On MCP server first startup
|
||||||
|
|
||||||
8. **API Request**: Client sends MCP request with Bearer token
|
**Steps**:
|
||||||
9. **Token Validation**: MCP server validates token:
|
1. **OIDC Discovery** (`GET /.well-known/openid-configuration`)
|
||||||
- Checks cache (1-hour TTL by default)
|
- MCP server queries Nextcloud for OAuth endpoints
|
||||||
- If not cached, calls `/apps/oidc/userinfo` with Bearer token
|
- Validates PKCE support (requires `S256` code challenge method)
|
||||||
- Extracts username from `sub` or `preferred_username` claim
|
- Extracts endpoints: authorize, token, userinfo, jwks, register
|
||||||
10. **User Info**: Nextcloud returns user info if token is valid
|
|
||||||
11. **Nextcloud API Call**: MCP server calls Nextcloud API on behalf of user
|
2. **Dynamic Client Registration** (`POST /apps/oidc/register`)
|
||||||
- Creates `NextcloudClient` instance with Bearer token
|
- If no pre-configured client credentials exist
|
||||||
- User-specific permissions apply
|
- MCP server registers itself as OAuth client (RFC 7591)
|
||||||
12. **API Response**: Nextcloud returns data
|
- Provides: client name, redirect URIs, requested scopes, token type
|
||||||
13. **MCP Response**: MCP server returns formatted response to client
|
- Receives: `client_id`, `client_secret`
|
||||||
|
- Saves credentials to SQLite database
|
||||||
|
|
||||||
|
3. **Tool Registration**
|
||||||
|
- All MCP tools loaded with their `@require_scopes` decorators
|
||||||
|
- Scope metadata stored for later discovery
|
||||||
|
|
||||||
|
**Result**: MCP server ready to accept client connections
|
||||||
|
|
||||||
|
### Phase 1: Client Discovery (Per MCP Client Connection)
|
||||||
|
|
||||||
|
**Happens**: When MCP client first connects
|
||||||
|
|
||||||
|
**Steps**:
|
||||||
|
1. **MCP Connection**
|
||||||
|
- Client connects to MCP server
|
||||||
|
- Server returns OAuth auth settings (issuer URL, resource URL)
|
||||||
|
|
||||||
|
2. **PRM Discovery** (`GET /.well-known/oauth-protected-resource/mcp`)
|
||||||
|
- Client queries Protected Resource Metadata endpoint (RFC 9728)
|
||||||
|
- Server **dynamically discovers** scopes from all registered tools
|
||||||
|
- Returns: resource URL, `scopes_supported` list, authorization servers
|
||||||
|
- Client now knows which scopes are available
|
||||||
|
|
||||||
|
**Result**: Client knows OAuth configuration and available scopes
|
||||||
|
|
||||||
|
### Phase 2: OAuth Authorization (PKCE Flow - RFC 7636)
|
||||||
|
|
||||||
|
**Happens**: User authorizes access
|
||||||
|
|
||||||
|
**Steps**:
|
||||||
|
1. **PKCE Challenge Generation** (Client-side)
|
||||||
|
- Generate `code_verifier`: random 43-128 character string
|
||||||
|
- Calculate `code_challenge`: `BASE64URL(SHA256(code_verifier))`
|
||||||
|
|
||||||
|
2. **Authorization Request** (`GET /apps/oidc/authorize`)
|
||||||
|
- Client redirects user to Nextcloud consent page
|
||||||
|
- Parameters:
|
||||||
|
- `client_id`: OAuth client ID
|
||||||
|
- `code_challenge`: SHA256 hash of verifier
|
||||||
|
- `code_challenge_method`: `S256`
|
||||||
|
- `scope`: Requested scopes (e.g., `openid notes:read notes:write`)
|
||||||
|
- `redirect_uri`: MCP server callback URL
|
||||||
|
|
||||||
|
3. **User Consent**
|
||||||
|
- User authenticates to Nextcloud (if not already logged in)
|
||||||
|
- User reviews and approves/denies requested scopes
|
||||||
|
- Can select subset of requested scopes
|
||||||
|
|
||||||
|
4. **Authorization Code**
|
||||||
|
- Nextcloud redirects to `callback?code=xyz123`
|
||||||
|
- Code is bound to PKCE challenge
|
||||||
|
|
||||||
|
5. **Token Exchange** (`POST /apps/oidc/token`)
|
||||||
|
- Client sends:
|
||||||
|
- Authorization `code`
|
||||||
|
- `code_verifier` (proves possession of original challenge)
|
||||||
|
- `client_id` and `client_secret`
|
||||||
|
- Nextcloud validates PKCE challenge: `SHA256(code_verifier) == code_challenge`
|
||||||
|
- Nextcloud issues access token
|
||||||
|
|
||||||
|
6. **Access Token Response**
|
||||||
|
- Token type: JWT or opaque (configurable)
|
||||||
|
- Contains user's **granted scopes** (may be subset of requested)
|
||||||
|
- Client stores token for subsequent requests
|
||||||
|
|
||||||
|
**Result**: Client has valid access token with granted scopes
|
||||||
|
|
||||||
|
### Phase 3: MCP Tool Access (Scope-Based Authorization)
|
||||||
|
|
||||||
|
**Happens**: Every MCP tool invocation
|
||||||
|
|
||||||
|
**Steps**:
|
||||||
|
|
||||||
|
#### Tool Listing (`list_tools`)
|
||||||
|
1. **List Tools Request**
|
||||||
|
- Client sends `list_tools` with `Authorization: Bearer <token>`
|
||||||
|
|
||||||
|
2. **Token Validation**
|
||||||
|
- MCP server calls `/apps/oidc/userinfo` with Bearer token
|
||||||
|
- Nextcloud returns user info including **granted scopes**
|
||||||
|
- Result cached for 1 hour
|
||||||
|
|
||||||
|
3. **Dynamic Tool Filtering**
|
||||||
|
- Server compares token scopes with each tool's `@require_scopes`
|
||||||
|
- Only returns tools where user has all required scopes
|
||||||
|
- Example: Token with `notes:read` sees 4 read tools, not 3 write tools
|
||||||
|
|
||||||
|
4. **Filtered Tool List**
|
||||||
|
- Client receives only tools they can use
|
||||||
|
|
||||||
|
#### Tool Execution (e.g., `nc_notes_get_note`)
|
||||||
|
1. **Tool Call**
|
||||||
|
- Client invokes tool with `Authorization: Bearer <token>`
|
||||||
|
|
||||||
|
2. **Scope Validation**
|
||||||
|
- `@require_scopes` decorator extracts token scopes
|
||||||
|
- Verifies token contains required scope (e.g., `notes:read`)
|
||||||
|
- If missing → 403 with `WWW-Authenticate` header (step-up auth)
|
||||||
|
- If present → continues execution
|
||||||
|
|
||||||
|
3. **Nextcloud API Call**
|
||||||
|
- MCP server creates `NextcloudClient` with Bearer token
|
||||||
|
- Calls Nextcloud API (e.g., `GET /apps/notes/api/v1/notes/1`)
|
||||||
|
- `user_oidc` app validates Bearer token again
|
||||||
|
- Request executes as authenticated user
|
||||||
|
|
||||||
|
4. **Response**
|
||||||
|
- Nextcloud returns data
|
||||||
|
- MCP server formats response
|
||||||
|
- Returns to client
|
||||||
|
|
||||||
|
**Result**: User can only access tools and data they have permissions for
|
||||||
|
|
||||||
|
### Phase 4: Insufficient Scope Handling (Step-Up Authorization)
|
||||||
|
|
||||||
|
**Happens**: When user lacks required scopes
|
||||||
|
|
||||||
|
**Steps**:
|
||||||
|
1. **Tool Call with Insufficient Scopes**
|
||||||
|
- User calls `nc_notes_create_note` (requires `notes:write`)
|
||||||
|
- But token only has `notes:read`
|
||||||
|
|
||||||
|
2. **Scope Validation Fails**
|
||||||
|
- `@require_scopes("notes:write")` decorator checks token
|
||||||
|
- Finds `notes:write` missing
|
||||||
|
|
||||||
|
3. **403 Response with Challenge**
|
||||||
|
- Returns `403 Forbidden`
|
||||||
|
- Includes `WWW-Authenticate` header:
|
||||||
|
```
|
||||||
|
Bearer error="insufficient_scope",
|
||||||
|
scope="notes:write",
|
||||||
|
resource_metadata="http://localhost:8000/.well-known/oauth-protected-resource/mcp"
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Client Re-Authorization** (Optional)
|
||||||
|
- Client can initiate new OAuth flow requesting additional scopes
|
||||||
|
- User re-consents with expanded permissions
|
||||||
|
- New token includes both `notes:read` and `notes:write`
|
||||||
|
|
||||||
|
**Result**: User can dynamically upgrade permissions without full re-authentication
|
||||||
|
|
||||||
## Token Validation
|
## Token Validation
|
||||||
|
|
||||||
@@ -217,11 +514,12 @@ NEXTCLOUD_HOST=https://nextcloud.example.com
|
|||||||
|
|
||||||
**How it works**:
|
**How it works**:
|
||||||
1. Server checks `/.well-known/openid-configuration` for `registration_endpoint`
|
1. Server checks `/.well-known/openid-configuration` for `registration_endpoint`
|
||||||
2. Calls `/apps/oidc/register` to register new client
|
2. Calls `/apps/oidc/register` to register a client on first startup
|
||||||
3. Saves credentials to `.nextcloud_oauth_client.json`
|
3. Saves credentials to SQLite database
|
||||||
4. Re-registers if credentials expire
|
4. Reuses these credentials on subsequent startups
|
||||||
|
5. Re-registers only if credentials are missing or expired
|
||||||
|
|
||||||
**Best for**: Development, testing, short-lived deployments
|
**Best for**: Development, testing, quick deployments
|
||||||
|
|
||||||
### Pre-configured Client
|
### Pre-configured Client
|
||||||
|
|
||||||
@@ -271,14 +569,145 @@ client = get_client_from_context(ctx)
|
|||||||
- Protects against authorization code interception
|
- Protects against authorization code interception
|
||||||
|
|
||||||
### Scopes
|
### Scopes
|
||||||
- Required scopes: `openid`, `profile`
|
- Base required scopes: `openid`, `profile`, `email`
|
||||||
- Additional scopes inferred from userinfo response
|
- App-specific scopes control access to individual Nextcloud apps
|
||||||
|
- See [OAuth Scopes](#oauth-scopes) section for complete scope reference
|
||||||
|
|
||||||
### Token Validation
|
### Token Validation
|
||||||
- Every MCP request validates Bearer token
|
- Every MCP request validates Bearer token
|
||||||
- Cached for performance (1-hour default)
|
- Cached for performance (1-hour default)
|
||||||
- Calls userinfo endpoint for validation
|
- Calls userinfo endpoint for validation
|
||||||
|
|
||||||
|
## OAuth Scopes
|
||||||
|
|
||||||
|
The Nextcloud MCP Server implements fine-grained OAuth scopes for each Nextcloud app integration. Scopes control which tools are visible and accessible to users based on their granted permissions.
|
||||||
|
|
||||||
|
### Scope-Based Access Control
|
||||||
|
|
||||||
|
When using OAuth authentication:
|
||||||
|
1. **Dynamic Discovery**: The server automatically discovers all required scopes from `@require_scopes` decorators on MCP tools
|
||||||
|
2. **Tool Filtering**: Tools are dynamically filtered based on the user's token scopes - users only see tools they have permission to use
|
||||||
|
3. **Per-Tool Enforcement**: Each tool validates required scopes before execution, returning a 403 error if insufficient scopes are present
|
||||||
|
|
||||||
|
### Supported Scopes
|
||||||
|
|
||||||
|
The server supports the following OAuth scopes, organized by Nextcloud app:
|
||||||
|
|
||||||
|
#### Base OIDC Scopes
|
||||||
|
- `openid` - OpenID Connect authentication (required)
|
||||||
|
- `profile` - Access to user profile information (required)
|
||||||
|
- `email` - Access to user email address (required)
|
||||||
|
|
||||||
|
#### Notes App
|
||||||
|
- `notes:read` - Read notes, search notes, get note attachments
|
||||||
|
- `notes:write` - Create, update, append to, and delete notes
|
||||||
|
|
||||||
|
#### Calendar App
|
||||||
|
- `calendar:read` - List calendars, read events, search events
|
||||||
|
- `calendar:write` - Create, update, and delete calendars and events
|
||||||
|
|
||||||
|
#### Calendar Tasks (VTODO)
|
||||||
|
- `todo:read` - List and read CalDAV tasks
|
||||||
|
- `todo:write` - Create, update, and delete CalDAV tasks
|
||||||
|
|
||||||
|
#### Contacts App
|
||||||
|
- `contacts:read` - List address books and read contacts (CardDAV)
|
||||||
|
- `contacts:write` - Create, update, and delete address books and contacts
|
||||||
|
|
||||||
|
#### Cookbook App
|
||||||
|
- `cookbook:read` - Read recipes, search recipes
|
||||||
|
- `cookbook:write` - Create, update, and delete recipes
|
||||||
|
|
||||||
|
#### Deck App
|
||||||
|
- `deck:read` - List boards, stacks, cards, and labels
|
||||||
|
- `deck:write` - Create, update, and delete boards, stacks, cards, and labels
|
||||||
|
|
||||||
|
#### Tables App
|
||||||
|
- `tables:read` - List tables and read rows
|
||||||
|
- `tables:write` - Create, update, and delete rows in tables
|
||||||
|
|
||||||
|
#### Files (WebDAV)
|
||||||
|
- `files:read` - List files, read file contents, search files
|
||||||
|
- `files:write` - Upload, update, move, copy, and delete files
|
||||||
|
|
||||||
|
#### Sharing
|
||||||
|
- `sharing:read` - List shares and read share information
|
||||||
|
- `sharing:write` - Create, update, and delete shares
|
||||||
|
|
||||||
|
### Scope Discovery
|
||||||
|
|
||||||
|
The MCP server provides scope discovery through two mechanisms:
|
||||||
|
|
||||||
|
#### 1. Protected Resource Metadata (PRM) Endpoint
|
||||||
|
```bash
|
||||||
|
# Query the PRM endpoint
|
||||||
|
curl http://localhost:8000/.well-known/oauth-protected-resource/mcp
|
||||||
|
|
||||||
|
# Response includes dynamically discovered scopes
|
||||||
|
{
|
||||||
|
"resource": "http://localhost:8000/mcp",
|
||||||
|
"scopes_supported": ["openid", "profile", "email", "notes:read", ...],
|
||||||
|
"authorization_servers": ["https://nextcloud.example.com"],
|
||||||
|
"bearer_methods_supported": ["header"],
|
||||||
|
"resource_signing_alg_values_supported": ["RS256"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `scopes_supported` field is **dynamically generated** from all registered MCP tools, ensuring it always reflects the actual available scopes.
|
||||||
|
|
||||||
|
#### 2. Scope Enforcement via Decorators
|
||||||
|
|
||||||
|
Tools are decorated with `@require_scopes()` to declare their required permissions:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from nextcloud_mcp_server.auth import require_scopes
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
@require_scopes("notes:read")
|
||||||
|
async def nc_notes_get_note(ctx: Context, note_id: int):
|
||||||
|
"""Get a specific note by ID"""
|
||||||
|
# Implementation
|
||||||
|
```
|
||||||
|
|
||||||
|
### Client Registration Scopes
|
||||||
|
|
||||||
|
During OAuth client registration (dynamic or manual), clients request a set of scopes that define the **maximum allowed** scopes for that client. The actual per-tool enforcement is handled separately via decorators.
|
||||||
|
|
||||||
|
**Environment Variable**:
|
||||||
|
```bash
|
||||||
|
NEXTCLOUD_OIDC_SCOPES="openid profile email notes:read notes:write calendar:read calendar:write ..."
|
||||||
|
```
|
||||||
|
|
||||||
|
**Default**: All supported scopes (recommended for development)
|
||||||
|
|
||||||
|
> **Note**: Client registration scopes define the maximum permissions. The MCP server's PRM endpoint dynamically advertises the actual supported scopes based on registered tools.
|
||||||
|
|
||||||
|
### Step-Up Authorization
|
||||||
|
|
||||||
|
The server supports OAuth step-up authorization (RFC 8693). If a user attempts to use a tool requiring scopes they don't have:
|
||||||
|
|
||||||
|
1. Tool returns `403 Forbidden` with `InsufficientScopeError`
|
||||||
|
2. Response includes `WWW-Authenticate` header listing missing scopes:
|
||||||
|
```
|
||||||
|
WWW-Authenticate: Bearer error="insufficient_scope", scope="notes:write", resource_metadata="..."
|
||||||
|
```
|
||||||
|
3. Client can re-authorize with additional scopes
|
||||||
|
|
||||||
|
### Scope Validation
|
||||||
|
|
||||||
|
All scope enforcement happens at two levels:
|
||||||
|
|
||||||
|
1. **Tool Visibility**: During `list_tools` requests, only tools matching the user's token scopes are returned
|
||||||
|
2. **Execution Time**: When calling a tool, the `@require_scopes` decorator validates the token has necessary scopes
|
||||||
|
|
||||||
|
**Example**:
|
||||||
|
```python
|
||||||
|
# User token has: ["openid", "profile", "email", "notes:read"]
|
||||||
|
# They will see: 4 read-only notes tools
|
||||||
|
# They will NOT see: 3 write notes tools (notes:write required)
|
||||||
|
# Attempting to call a write tool returns 403 Forbidden
|
||||||
|
```
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
See [Configuration Guide](configuration.md) for all OAuth environment variables:
|
See [Configuration Guide](configuration.md) for all OAuth environment variables:
|
||||||
@@ -289,14 +718,12 @@ See [Configuration Guide](configuration.md) for all OAuth environment variables:
|
|||||||
| `NEXTCLOUD_OIDC_CLIENT_ID` | Pre-configured client ID (optional) |
|
| `NEXTCLOUD_OIDC_CLIENT_ID` | Pre-configured client ID (optional) |
|
||||||
| `NEXTCLOUD_OIDC_CLIENT_SECRET` | Pre-configured client secret (optional) |
|
| `NEXTCLOUD_OIDC_CLIENT_SECRET` | Pre-configured client secret (optional) |
|
||||||
| `NEXTCLOUD_MCP_SERVER_URL` | MCP server URL for OAuth callbacks |
|
| `NEXTCLOUD_MCP_SERVER_URL` | MCP server URL for OAuth callbacks |
|
||||||
| `NEXTCLOUD_OIDC_CLIENT_STORAGE` | Path for auto-registered credentials |
|
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
The integration test suite includes comprehensive OAuth testing:
|
The integration test suite includes comprehensive OAuth testing:
|
||||||
|
|
||||||
- **Automated tests** (Playwright): [`tests/integration/test_oauth_playwright.py`](../tests/integration/test_oauth_playwright.py)
|
- **Automated tests** (Playwright): [`tests/client/test_oauth_playwright.py`](../tests/client/test_oauth_playwright.py)
|
||||||
- **Interactive tests**: [`tests/integration/test_oauth_interactive.py`](../tests/integration/test_oauth_interactive.py)
|
|
||||||
- **Fixtures**: [`tests/conftest.py`](../tests/conftest.py)
|
- **Fixtures**: [`tests/conftest.py`](../tests/conftest.py)
|
||||||
|
|
||||||
Run OAuth tests:
|
Run OAuth tests:
|
||||||
@@ -305,10 +732,7 @@ Run OAuth tests:
|
|||||||
docker-compose up --build -d mcp-oauth
|
docker-compose up --build -d mcp-oauth
|
||||||
|
|
||||||
# Run automated tests
|
# Run automated tests
|
||||||
uv run pytest tests/integration/test_oauth_playwright.py --browser firefox -v
|
uv run pytest tests/client/test_oauth_playwright.py --browser firefox -v
|
||||||
|
|
||||||
# Run interactive tests (manual login)
|
|
||||||
uv run pytest tests/integration/test_oauth_interactive.py -v
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## See Also
|
## See Also
|
||||||
|
|||||||
@@ -0,0 +1,387 @@
|
|||||||
|
# OAuth Impersonation Investigation Findings
|
||||||
|
|
||||||
|
**Date**: 2025-11-02
|
||||||
|
**Last Updated**: 2025-11-02 (Token Exchange Resolution)
|
||||||
|
**Status**: Implementation Complete - Token Exchange Working
|
||||||
|
**Conclusion**: Keycloak Standard Token Exchange (RFC 8693) working for internal-to-internal token exchange. User impersonation requires Legacy V1.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ IMPORTANT UPDATE (2025-11-02)
|
||||||
|
|
||||||
|
**This document contains outdated information regarding service account tokens.**
|
||||||
|
|
||||||
|
After implementation and testing, we discovered that service account tokens (`client_credentials` grant) **violate OAuth "act on-behalf-of" principles** by creating Nextcloud user accounts (e.g., `service-account-nextcloud-mcp-server`). This approach has been **REJECTED** and moved to ADR-002's "Will Not Implement" section.
|
||||||
|
|
||||||
|
**Key Changes:**
|
||||||
|
- ❌ **Service account tokens (client_credentials) are INVALID** - Creates user accounts, breaks audit trail
|
||||||
|
- ✅ **Token exchange (RFC 8693) is the correct approach** - Implemented and working (ADR-002 Tier 2)
|
||||||
|
- ✅ **Offline access with refresh tokens** - Still valid for background operations (ADR-002 primary approach)
|
||||||
|
|
||||||
|
**For current architecture, see**: `docs/ADR-002-vector-sync-authentication.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
We investigated options for implementing user impersonation to enable background operations without requiring admin credentials (ADR-002 Tier 2). Here are the findings:
|
||||||
|
|
||||||
|
## 1. Keycloak Token Exchange (RFC 8693)
|
||||||
|
|
||||||
|
### What We Implemented
|
||||||
|
- ✅ Service account token acquisition (`client_credentials` grant)
|
||||||
|
- ✅ `get_service_account_token()` method in `KeycloakOAuthClient`
|
||||||
|
- ✅ `exchange_token_for_user()` method implementing RFC 8693
|
||||||
|
- ✅ Token exchange configuration in Keycloak realm
|
||||||
|
|
||||||
|
### What Works ✅
|
||||||
|
**Keycloak Standard V2 Token Exchange (RFC 8693) is WORKING**:
|
||||||
|
- ✅ Service account token acquisition via `client_credentials` grant
|
||||||
|
- ✅ Token exchange for internal-to-internal tokens
|
||||||
|
- ✅ Audience and scope modifications
|
||||||
|
- ✅ Integration with Nextcloud APIs using exchanged tokens
|
||||||
|
|
||||||
|
**Configuration Requirements**:
|
||||||
|
To enable Standard Token Exchange in Keycloak 26.2+, add to client attributes in `realm-export.json`:
|
||||||
|
```json
|
||||||
|
"attributes": {
|
||||||
|
"token.exchange.grant.enabled": "true",
|
||||||
|
"client.token.exchange.standard.enabled": "true"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Limitations
|
||||||
|
Keycloak Standard V2 does NOT support:
|
||||||
|
- ❌ User impersonation (`requested_subject` parameter)
|
||||||
|
- ❌ Cross-client delegation (limited to same realm)
|
||||||
|
|
||||||
|
These features require Legacy V1 with `--features=preview`
|
||||||
|
|
||||||
|
### Alternative: Keycloak Legacy V1
|
||||||
|
Keycloak Legacy Token Exchange (V1) WOULD support user impersonation, but:
|
||||||
|
- ❌ Requires `--features=preview --features=token-exchange` flag
|
||||||
|
- ❌ Not suitable for production
|
||||||
|
- ❌ Deprecated and being phased out
|
||||||
|
|
||||||
|
**Decision**: Not viable for production use.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Nextcloud OIDC App Token Exchange
|
||||||
|
|
||||||
|
### Discovery Endpoint Analysis
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"grant_types_supported": [
|
||||||
|
"authorization_code",
|
||||||
|
"implicit"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Findings
|
||||||
|
❌ **Nextcloud OIDC app does NOT support**:
|
||||||
|
- RFC 8693 token exchange
|
||||||
|
- `client_credentials` grant
|
||||||
|
- `refresh_token` grant (refresh tokens not issued)
|
||||||
|
- User impersonation APIs
|
||||||
|
|
||||||
|
The Nextcloud OIDC app is a basic OAuth 2.0 provider focused on:
|
||||||
|
- Authorization code flow for user login
|
||||||
|
- JWT tokens for API access
|
||||||
|
- Scope-based authorization
|
||||||
|
|
||||||
|
It is NOT designed for:
|
||||||
|
- Service accounts
|
||||||
|
- Token delegation
|
||||||
|
- Background operations
|
||||||
|
|
||||||
|
**Decision**: Not viable - missing required grant types.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Nextcloud Impersonate App
|
||||||
|
|
||||||
|
### What It Provides
|
||||||
|
✅ Admin users can impersonate other users via:
|
||||||
|
- UI: Settings → Users → Impersonate button
|
||||||
|
- API: `POST /apps/impersonate/user` with `userId` parameter
|
||||||
|
|
||||||
|
### How It Works
|
||||||
|
```php
|
||||||
|
// From SettingsController.php
|
||||||
|
public function impersonate(string $userId): JSONResponse {
|
||||||
|
// 1. Verify admin/delegated admin permissions
|
||||||
|
// 2. Check target user has logged in before
|
||||||
|
// 3. Set session: $this->userSession->setUser($impersonatee)
|
||||||
|
// 4. Return success
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Requirements
|
||||||
|
- ✅ Admin credentials
|
||||||
|
- ✅ Session-based authentication (cookies)
|
||||||
|
- ✅ CSRF token
|
||||||
|
- ✅ Target user must have logged in at least once
|
||||||
|
- ❌ Not compatible with encryption-enabled instances
|
||||||
|
|
||||||
|
### Limitations for Background Workers
|
||||||
|
❌ **Session-based, not stateless**:
|
||||||
|
- Requires maintaining HTTP session/cookies
|
||||||
|
- Not suitable for distributed workers
|
||||||
|
- Can't use with bearer tokens
|
||||||
|
- Requires re-authentication periodically
|
||||||
|
|
||||||
|
❌ **Security concerns**:
|
||||||
|
- Requires admin credentials stored on server
|
||||||
|
- All impersonated actions logged as target user
|
||||||
|
- Violates principle of least privilege
|
||||||
|
|
||||||
|
**Decision**: Not suitable for background operations - session-based architecture incompatible with stateless OAuth/bearer token model.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. What Actually Works
|
||||||
|
|
||||||
|
### Option A: Admin Credentials (Current Implementation)
|
||||||
|
✅ **BasicAuth mode with admin account**:
|
||||||
|
```python
|
||||||
|
client = NextcloudClient.from_env() # Uses NEXTCLOUD_USERNAME/PASSWORD
|
||||||
|
# Can access all APIs with admin permissions
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pros**:
|
||||||
|
- Simple, works immediately
|
||||||
|
- Full access to all APIs
|
||||||
|
|
||||||
|
**Cons**:
|
||||||
|
- Requires admin credentials stored on server
|
||||||
|
- No per-user permission scoping
|
||||||
|
- Security risk if credentials leaked
|
||||||
|
- Violates ADR-002 goals
|
||||||
|
|
||||||
|
**Status**: Available but not recommended for production.
|
||||||
|
|
||||||
|
### Option B: Service Account with Scoped Permissions
|
||||||
|
✅ **Create dedicated service account**:
|
||||||
|
1. Create `mcp-sync` user in Nextcloud
|
||||||
|
2. Grant specific permissions (group memberships, shares)
|
||||||
|
3. Use those credentials for background operations
|
||||||
|
|
||||||
|
**Pros**:
|
||||||
|
- Dedicated account, easier to audit
|
||||||
|
- Can limit permissions via Nextcloud groups
|
||||||
|
- Works with current BasicAuth implementation
|
||||||
|
|
||||||
|
**Cons**:
|
||||||
|
- Still requires credentials storage
|
||||||
|
- Can't truly act "as" individual users
|
||||||
|
- Limited by Nextcloud's permission model
|
||||||
|
|
||||||
|
**Status**: Best available option without OAuth delegation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Recommendations
|
||||||
|
|
||||||
|
### Short Term (Immediate)
|
||||||
|
**Use Service Account Pattern**:
|
||||||
|
```python
|
||||||
|
# Background worker configuration
|
||||||
|
SYNC_ACCOUNT_USERNAME=mcp-sync
|
||||||
|
SYNC_ACCOUNT_PASSWORD=<secure-password>
|
||||||
|
|
||||||
|
# Create service account with limited permissions
|
||||||
|
docker compose exec app php occ user:add mcp-sync
|
||||||
|
docker compose exec app php occ group:adduser <appropriate-group> mcp-sync
|
||||||
|
```
|
||||||
|
|
||||||
|
**Benefits**:
|
||||||
|
- Works with existing implementation
|
||||||
|
- Better than admin credentials
|
||||||
|
- Auditable
|
||||||
|
|
||||||
|
### Medium Term (If OAuth Delegation Required)
|
||||||
|
**Wait for proper standards support**:
|
||||||
|
- Monitor Keycloak for Standard V2 improvements
|
||||||
|
- Contribute to/request Nextcloud OIDC app enhancements
|
||||||
|
- Consider alternative identity providers (e.g., Authelia, Authentik)
|
||||||
|
|
||||||
|
### Long Term (Ideal Solution)
|
||||||
|
**Implement proper OAuth delegation**:
|
||||||
|
1. Use identity provider that supports RFC 8693 properly (e.g., Auth0, Okta)
|
||||||
|
2. Or implement custom delegation endpoint in Nextcloud
|
||||||
|
3. Or propose MCP protocol extension for refresh token sharing
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Updated ADR-002 Status
|
||||||
|
|
||||||
|
| Tier | Solution | Status | Viability |
|
||||||
|
|------|----------|--------|-----------|
|
||||||
|
| **Tier 0** | Admin BasicAuth | ✅ Implemented | ⚠️ Works but not recommended |
|
||||||
|
| **Tier 1** | Offline Access (Refresh Tokens) | ⚠️ Infrastructure ready | ❌ MCP protocol limitation |
|
||||||
|
| **Tier 2** | Token Exchange (RFC 8693) | ✅ **WORKING** | ✅ **Internal token exchange functional** |
|
||||||
|
| **Tier 3** | Service Account (NEW) | ✅ Available | ✅ **RECOMMENDED for background ops** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Implementation Status
|
||||||
|
|
||||||
|
### What Was Built
|
||||||
|
1. ✅ `RefreshTokenStorage` - SQLite + encryption (ready for future use)
|
||||||
|
2. ✅ `KeycloakOAuthClient.get_service_account_token()` - Works
|
||||||
|
3. ✅ `KeycloakOAuthClient.exchange_token_for_user()` - Implemented but non-functional
|
||||||
|
4. ✅ Token exchange configuration - Keycloak realm updated
|
||||||
|
5. ✅ Test scripts - Comprehensive testing completed
|
||||||
|
|
||||||
|
### What to Use
|
||||||
|
**For Background Operations**:
|
||||||
|
```python
|
||||||
|
# Use service account with BasicAuth
|
||||||
|
from nextcloud_mcp_server.client import NextcloudClient
|
||||||
|
|
||||||
|
# In background worker
|
||||||
|
sync_client = NextcloudClient(
|
||||||
|
base_url=os.getenv("NEXTCLOUD_HOST"),
|
||||||
|
username=os.getenv("SYNC_ACCOUNT_USERNAME"),
|
||||||
|
password=os.getenv("SYNC_ACCOUNT_PASSWORD"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Perform operations
|
||||||
|
notes = await sync_client.notes.search_notes("important")
|
||||||
|
# Index to vector database, etc.
|
||||||
|
```
|
||||||
|
|
||||||
|
**For User Requests**:
|
||||||
|
```python
|
||||||
|
# Continue using OAuth bearer tokens
|
||||||
|
# Per-request client creation as currently implemented
|
||||||
|
client = get_client_from_context(ctx, nextcloud_host)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Files Modified/Created
|
||||||
|
|
||||||
|
### Implementation
|
||||||
|
- `nextcloud_mcp_server/auth/keycloak_oauth.py` - Token exchange methods
|
||||||
|
- `nextcloud_mcp_server/auth/refresh_token_storage.py` - Token storage (ready for future)
|
||||||
|
- `nextcloud_mcp_server/app.py` - OAuth configuration updates
|
||||||
|
- `keycloak/realm-export.json` - Token exchange enabled
|
||||||
|
- `pyproject.toml` - Added aiosqlite dependency
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
- `docs/oauth-impersonation-findings.md` - This document
|
||||||
|
- `docs/ADR-002-vector-sync-authentication.md` - Original architecture decision
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
- `tests/manual/test_token_exchange.py` - Keycloak RFC 8693 testing
|
||||||
|
- `tests/manual/test_nextcloud_impersonate.py` - Nextcloud impersonate API testing
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Conclusion
|
||||||
|
|
||||||
|
**Neither Keycloak nor Nextcloud currently provide viable OAuth-based user impersonation for background operations.**
|
||||||
|
|
||||||
|
The infrastructure is ready (token storage, exchange methods), but provider limitations prevent use.
|
||||||
|
|
||||||
|
**Recommended approach**: Use dedicated service account with appropriate Nextcloud permissions for background operations until proper OAuth delegation becomes available.
|
||||||
|
|
||||||
|
The implemented code remains valuable:
|
||||||
|
- Ready for future when providers add support
|
||||||
|
- Demonstrates proper OAuth patterns
|
||||||
|
- Test infrastructure for validation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Appendix: Technical Details
|
||||||
|
|
||||||
|
### Keycloak Configuration Applied
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"clientId": "nextcloud-mcp-server",
|
||||||
|
"serviceAccountsEnabled": true,
|
||||||
|
"attributes": {
|
||||||
|
"token.exchange.grant.enabled": "true"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test Results - UPDATED (2025-11-02)
|
||||||
|
```
|
||||||
|
✅ Service account token acquisition: WORKS
|
||||||
|
✅ Token exchange discovery: SUPPORTED
|
||||||
|
✅ Token exchange configuration: ENABLED
|
||||||
|
✅ Actual token exchange: WORKS (after adding client.token.exchange.standard.enabled)
|
||||||
|
✅ Nextcloud API access: WORKS with exchanged tokens
|
||||||
|
```
|
||||||
|
|
||||||
|
**Resolution**: The realm-export.json was missing the `client.token.exchange.standard.enabled` attribute. After adding this attribute to keycloak/realm-export.json:128, token exchange works correctly on fresh Keycloak imports.
|
||||||
|
|
||||||
|
### Nextcloud Impersonate Results
|
||||||
|
```
|
||||||
|
✓ App installation: SUCCESS
|
||||||
|
✓ Admin can impersonate: YES (session-based)
|
||||||
|
✗ Bearer token impersonate: NO (requires session cookies)
|
||||||
|
✗ Stateless impersonate: NOT AVAILABLE
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Token Exchange Resolution (2025-11-02)
|
||||||
|
|
||||||
|
### Problem
|
||||||
|
Initial token exchange implementation was failing with:
|
||||||
|
```
|
||||||
|
"Standard token exchange is not enabled for the requested client"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Root Cause
|
||||||
|
The `realm-export.json` was missing a critical attribute for Keycloak 26.2+ Standard Token Exchange:
|
||||||
|
- Had: `"token.exchange.grant.enabled": "true"` ✓
|
||||||
|
- Missing: `"client.token.exchange.standard.enabled": "true"` ❌
|
||||||
|
|
||||||
|
### Fix Applied
|
||||||
|
Updated `keycloak/realm-export.json` at line 128 to include both attributes:
|
||||||
|
```json
|
||||||
|
"attributes": {
|
||||||
|
"pkce.code.challenge.method": "S256",
|
||||||
|
"use.refresh.tokens": "true",
|
||||||
|
"backchannel.logout.session.required": "true",
|
||||||
|
"backchannel.logout.url": "http://app:80/index.php/apps/user_oidc/backchannel-logout/keycloak",
|
||||||
|
"oauth2.device.authorization.grant.enabled": "false",
|
||||||
|
"oidc.ciba.grant.enabled": "false",
|
||||||
|
"client_credentials.use_refresh_token": "false",
|
||||||
|
"display.on.consent.screen": "false",
|
||||||
|
"token.exchange.grant.enabled": "true",
|
||||||
|
"client.token.exchange.standard.enabled": "true" // ADDED
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Verification
|
||||||
|
After recreating Keycloak with fresh realm import:
|
||||||
|
```bash
|
||||||
|
$ docker compose down -v keycloak && docker compose up -d keycloak
|
||||||
|
$ uv run python tests/manual/test_token_exchange.py
|
||||||
|
✅ Token Exchange Test PASSED
|
||||||
|
```
|
||||||
|
|
||||||
|
### Current Status
|
||||||
|
- ✅ RFC 8693 Token Exchange fully functional
|
||||||
|
- ✅ Service account token acquisition works
|
||||||
|
- ✅ Token exchange for internal tokens works
|
||||||
|
- ✅ Exchanged tokens validate with Nextcloud APIs
|
||||||
|
- ✅ Realm import automatically applies correct configuration
|
||||||
|
- ⚠️ User impersonation still requires Keycloak Legacy V1
|
||||||
|
|
||||||
|
### Files Modified
|
||||||
|
- `keycloak/realm-export.json` - Added `client.token.exchange.standard.enabled` attribute
|
||||||
|
- `docs/oauth-impersonation-findings.md` - Updated with resolution
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
Run the complete token exchange flow:
|
||||||
|
```bash
|
||||||
|
uv run python tests/manual/test_token_exchange.py
|
||||||
|
```
|
||||||
+12
-16
@@ -165,23 +165,23 @@ You have two options for managing OAuth clients:
|
|||||||
|
|
||||||
### Mode A: Automatic Registration (Dynamic Client Registration)
|
### Mode A: Automatic Registration (Dynamic Client Registration)
|
||||||
|
|
||||||
**Best for**: Development, testing, short-lived deployments
|
**Best for**: Development, testing, quick deployments
|
||||||
|
|
||||||
**How it works**:
|
**How it works**:
|
||||||
- MCP server automatically registers OAuth client at startup
|
- MCP server automatically registers an OAuth client on first startup
|
||||||
- Uses Nextcloud's dynamic client registration endpoint
|
- Uses Nextcloud's dynamic client registration endpoint
|
||||||
- Saves credentials to `.nextcloud_oauth_client.json`
|
- Saves credentials to SQLite database
|
||||||
|
- Reuses stored credentials on subsequent restarts
|
||||||
- Re-registers automatically if credentials expire
|
- Re-registers automatically if credentials expire
|
||||||
|
|
||||||
**Pros**:
|
**Pros**:
|
||||||
- Zero configuration required
|
- Zero configuration required
|
||||||
- Quick setup
|
- Quick setup
|
||||||
- No manual client management
|
- Automatic credential management
|
||||||
|
|
||||||
**Cons**:
|
**Cons**:
|
||||||
- Clients expire (default: 1 hour, configurable)
|
- Clients expire (default: 1 hour, configurable)
|
||||||
- Must re-register on restart if expired
|
- Must have dynamic client registration enabled on Nextcloud
|
||||||
- Not ideal for long-running production
|
|
||||||
|
|
||||||
**Configuration**: Skip to [Step 4](#step-4-configure-mcp-server) with minimal config.
|
**Configuration**: Skip to [Step 4](#step-4-configure-mcp-server) with minimal config.
|
||||||
|
|
||||||
@@ -192,8 +192,8 @@ You have two options for managing OAuth clients:
|
|||||||
**Best for**: Production, long-running deployments, stable environments
|
**Best for**: Production, long-running deployments, stable environments
|
||||||
|
|
||||||
**How it works**:
|
**How it works**:
|
||||||
- You manually register OAuth client via Nextcloud CLI
|
- You manually register an OAuth client via Nextcloud CLI
|
||||||
- Provide client credentials to MCP server
|
- Provide client credentials to MCP server via environment variables
|
||||||
- Credentials don't expire
|
- Credentials don't expire
|
||||||
|
|
||||||
**Pros**:
|
**Pros**:
|
||||||
@@ -253,9 +253,6 @@ NEXTCLOUD_PASSWORD=
|
|||||||
|
|
||||||
# Optional: MCP server URL (for OAuth callbacks)
|
# Optional: MCP server URL (for OAuth callbacks)
|
||||||
NEXTCLOUD_MCP_SERVER_URL=http://localhost:8000
|
NEXTCLOUD_MCP_SERVER_URL=http://localhost:8000
|
||||||
|
|
||||||
# Optional: Client storage path
|
|
||||||
NEXTCLOUD_OIDC_CLIENT_STORAGE=.nextcloud_oauth_client.json
|
|
||||||
EOF
|
EOF
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -291,7 +288,6 @@ EOF
|
|||||||
| `NEXTCLOUD_OIDC_CLIENT_ID` | ⚠️ Mode B only | - | OAuth client ID |
|
| `NEXTCLOUD_OIDC_CLIENT_ID` | ⚠️ Mode B only | - | OAuth client ID |
|
||||||
| `NEXTCLOUD_OIDC_CLIENT_SECRET` | ⚠️ Mode B only | - | OAuth client secret |
|
| `NEXTCLOUD_OIDC_CLIENT_SECRET` | ⚠️ Mode B only | - | OAuth client secret |
|
||||||
| `NEXTCLOUD_MCP_SERVER_URL` | ⚠️ Optional | `http://localhost:8000` | MCP server URL for callbacks |
|
| `NEXTCLOUD_MCP_SERVER_URL` | ⚠️ Optional | `http://localhost:8000` | MCP server URL for callbacks |
|
||||||
| `NEXTCLOUD_OIDC_CLIENT_STORAGE` | ⚠️ Optional | `.nextcloud_oauth_client.json` | Client credentials storage path |
|
|
||||||
| `NEXTCLOUD_USERNAME` | ❌ Must be empty | - | Leave empty for OAuth |
|
| `NEXTCLOUD_USERNAME` | ❌ Must be empty | - | Leave empty for OAuth |
|
||||||
| `NEXTCLOUD_PASSWORD` | ❌ Must be empty | - | Leave empty for OAuth |
|
| `NEXTCLOUD_PASSWORD` | ❌ Must be empty | - | Leave empty for OAuth |
|
||||||
|
|
||||||
@@ -334,7 +330,7 @@ INFO OIDC discovery successful
|
|||||||
INFO Attempting dynamic client registration...
|
INFO Attempting dynamic client registration...
|
||||||
INFO Dynamic client registration successful
|
INFO Dynamic client registration successful
|
||||||
INFO OAuth client ready: <client-id>...
|
INFO OAuth client ready: <client-id>...
|
||||||
INFO Saved OAuth client credentials to .nextcloud_oauth_client.json
|
INFO Saved OAuth client credentials to SQLite database
|
||||||
INFO OAuth initialization complete
|
INFO OAuth initialization complete
|
||||||
INFO MCP server ready at http://127.0.0.1:8000
|
INFO MCP server ready at http://127.0.0.1:8000
|
||||||
```
|
```
|
||||||
@@ -427,9 +423,9 @@ uv run nextcloud-mcp-server --oauth --log-level debug
|
|||||||
|
|
||||||
2. **Secure Credential Storage**
|
2. **Secure Credential Storage**
|
||||||
```bash
|
```bash
|
||||||
# Set restrictive permissions
|
# Set restrictive permissions on environment file
|
||||||
chmod 600 .nextcloud_oauth_client.json
|
|
||||||
chmod 600 .env
|
chmod 600 .env
|
||||||
|
# Database permissions are handled automatically
|
||||||
```
|
```
|
||||||
|
|
||||||
3. **Use HTTPS for MCP Server**
|
3. **Use HTTPS for MCP Server**
|
||||||
@@ -474,7 +470,7 @@ services:
|
|||||||
NEXTCLOUD_OIDC_CLIENT_SECRET: ${NEXTCLOUD_OIDC_CLIENT_SECRET}
|
NEXTCLOUD_OIDC_CLIENT_SECRET: ${NEXTCLOUD_OIDC_CLIENT_SECRET}
|
||||||
NEXTCLOUD_MCP_SERVER_URL: http://your-server:8000
|
NEXTCLOUD_MCP_SERVER_URL: http://your-server:8000
|
||||||
volumes:
|
volumes:
|
||||||
- ./oauth_client.json:/app/.nextcloud_oauth_client.json
|
- ./data:/app/data # For SQLite database persistence
|
||||||
command: ["--oauth", "--transport", "streamable-http"]
|
command: ["--oauth", "--transport", "streamable-http"]
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
```
|
```
|
||||||
|
|||||||
+108
-20
@@ -14,9 +14,10 @@ Start here to identify your issue:
|
|||||||
| "OAuth mode requires client credentials OR dynamic registration" | OIDC apps not configured | [Missing OIDC Apps](#missing-or-misconfigured-oidc-apps) |
|
| "OAuth mode requires client credentials OR dynamic registration" | OIDC apps not configured | [Missing OIDC Apps](#missing-or-misconfigured-oidc-apps) |
|
||||||
| "PKCE support validation failed" | OIDC app doesn't advertise PKCE | [PKCE Not Advertised](#pkce-not-advertised) |
|
| "PKCE support validation failed" | OIDC app doesn't advertise PKCE | [PKCE Not Advertised](#pkce-not-advertised) |
|
||||||
| "Stored client has expired" | Dynamic client expired | [Client Expired](#client-expired) |
|
| "Stored client has expired" | Dynamic client expired | [Client Expired](#client-expired) |
|
||||||
|
| Only seeing Notes tools (7 instead of 90+) | Limited OAuth scopes granted | [Limited Scopes](#limited-scopes---only-seeing-notes-tools) |
|
||||||
| HTTP 401 for Notes API | Bearer token patch missing | [Bearer Token Auth Fails](#bearer-token-authentication-fails) |
|
| HTTP 401 for Notes API | Bearer token patch missing | [Bearer Token Auth Fails](#bearer-token-authentication-fails) |
|
||||||
| "OIDC discovery failed" | Network or configuration issue | [Discovery Failed](#oidc-discovery-failed) |
|
| "OIDC discovery failed" | Network or configuration issue | [Discovery Failed](#oidc-discovery-failed) |
|
||||||
| "Permission denied" on .nextcloud_oauth_client.json | File permissions issue | [File Permission Error](#file-permission-error) |
|
| "Database error" on OAuth client storage | Database permissions issue | [Database Permission Error](#database-permission-error) |
|
||||||
|
|
||||||
## Configuration Issues
|
## Configuration Issues
|
||||||
|
|
||||||
@@ -160,39 +161,38 @@ php occ config:app:set oidc expire_time --value "86400" # 24 hours
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### File Permission Error
|
### Database Permission Error
|
||||||
|
|
||||||
**Error Message**:
|
**Error Message**:
|
||||||
```
|
```
|
||||||
Permission denied when reading/writing .nextcloud_oauth_client.json
|
Permission denied when accessing SQLite database
|
||||||
|
Database is locked
|
||||||
```
|
```
|
||||||
|
|
||||||
**Cause**: The server cannot access the OAuth client storage file.
|
**Cause**: The server cannot access the SQLite database file.
|
||||||
|
|
||||||
**Solution**:
|
**Solution**:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Check file permissions
|
# Check database directory permissions
|
||||||
ls -la .nextcloud_oauth_client.json
|
ls -la /app/data/
|
||||||
|
|
||||||
# Fix file permissions (owner read/write only)
|
|
||||||
chmod 600 .nextcloud_oauth_client.json
|
|
||||||
|
|
||||||
# Ensure directory is writable
|
# Ensure directory is writable
|
||||||
chmod 755 $(dirname .nextcloud_oauth_client.json)
|
chmod 755 /app/data
|
||||||
|
|
||||||
# If file doesn't exist, ensure directory is writable
|
# Check if database file exists and has correct permissions
|
||||||
mkdir -p $(dirname .nextcloud_oauth_client.json)
|
ls -la /app/data/tokens.db
|
||||||
|
chmod 644 /app/data/tokens.db
|
||||||
|
|
||||||
|
# If running in Docker, ensure volume is mounted correctly
|
||||||
|
docker compose logs mcp-oauth | grep -i "database\|sqlite"
|
||||||
```
|
```
|
||||||
|
|
||||||
For custom storage paths:
|
**For Docker deployments**:
|
||||||
```bash
|
Ensure the data directory is properly mounted as a volume:
|
||||||
# Set custom path in .env
|
```yaml
|
||||||
NEXTCLOUD_OIDC_CLIENT_STORAGE=/path/to/custom/oauth_client.json
|
volumes:
|
||||||
|
- ./data:/app/data # Persistent storage for SQLite database
|
||||||
# Ensure directory exists and is writable
|
|
||||||
mkdir -p $(dirname /path/to/custom/oauth_client.json)
|
|
||||||
chmod 755 $(dirname /path/to/custom/oauth_client.json)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -407,6 +407,94 @@ http://localhost:8000/oauth/callback
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### Limited Scopes - Only Seeing Notes Tools
|
||||||
|
|
||||||
|
**Symptoms**:
|
||||||
|
- MCP client (e.g., Claude Code) successfully connects via OAuth
|
||||||
|
- Only Notes tools are available (7 tools instead of 90+)
|
||||||
|
- Token scopes show only `mcp:notes:read` and `mcp:notes:write`
|
||||||
|
|
||||||
|
**Cause**: During the OAuth consent flow, the user only granted access to Notes scopes, or the client only requested those scopes.
|
||||||
|
|
||||||
|
**Diagnosis**:
|
||||||
|
|
||||||
|
Check what scopes the client has been granted:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# View registered clients and their allowed scopes
|
||||||
|
php occ oidc:list | jq '.[] | select(.name | contains("Claude Code")) | {name, allowed_scopes}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Look for the client's `allowed_scopes` field. If it's empty or only contains notes scopes, that's the issue.
|
||||||
|
|
||||||
|
**Solution**:
|
||||||
|
|
||||||
|
**Option 1: Delete Client and Reconnect** (Recommended for MCP clients)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Find the client ID
|
||||||
|
php occ oidc:list | jq '.[] | select(.name | contains("Claude Code")) | {name, client_id}'
|
||||||
|
|
||||||
|
# Delete the client
|
||||||
|
php occ oidc:delete <client_id>
|
||||||
|
|
||||||
|
# Reconnect from Claude Code
|
||||||
|
# This will trigger a new OAuth flow where you can grant all scopes
|
||||||
|
```
|
||||||
|
|
||||||
|
When reconnecting, you'll see a consent screen listing all available scopes. Make sure to approve all the scopes you want the client to access.
|
||||||
|
|
||||||
|
**Option 2: Update Client Scopes via CLI**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Update allowed scopes for an existing client
|
||||||
|
php occ oidc:update <client_id> \
|
||||||
|
--allowed-scopes "openid profile email mcp:notes:read mcp:notes:write mcp:calendar:read mcp:calendar:write mcp:contacts:read mcp:contacts:write mcp:cookbook:read mcp:cookbook:write mcp:deck:read mcp:deck:write mcp:tables:read mcp:tables:write mcp:files:read mcp:files:write mcp:sharing:read mcp:sharing:write"
|
||||||
|
|
||||||
|
# User will need to reconnect to get new token with updated scopes
|
||||||
|
```
|
||||||
|
|
||||||
|
**Verify Available Scopes**:
|
||||||
|
|
||||||
|
Check what scopes the MCP server advertises:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8001/.well-known/oauth-protected-resource | jq '.scopes_supported'
|
||||||
|
|
||||||
|
# Should show all 16 scope categories:
|
||||||
|
# - openid
|
||||||
|
# - mcp:notes:read, mcp:notes:write
|
||||||
|
# - mcp:calendar:read, mcp:calendar:write
|
||||||
|
# - mcp:contacts:read, mcp:contacts:write
|
||||||
|
# - mcp:cookbook:read, mcp:cookbook:write
|
||||||
|
# - mcp:deck:read, mcp:deck:write
|
||||||
|
# - mcp:tables:read, mcp:tables:write
|
||||||
|
# - mcp:files:read, mcp:files:write
|
||||||
|
# - mcp:sharing:read, mcp:sharing:write
|
||||||
|
```
|
||||||
|
|
||||||
|
**Understanding Scope Filtering**:
|
||||||
|
|
||||||
|
The MCP server dynamically filters tools based on the scopes in your access token:
|
||||||
|
- Check server logs for: `✂️ JWT scope filtering: X/90 tools available for scopes: {...}`
|
||||||
|
- This shows how many tools are visible vs total available
|
||||||
|
- Each tool requires specific scopes (read and/or write)
|
||||||
|
|
||||||
|
**Available Scope Categories**:
|
||||||
|
|
||||||
|
| Scope Prefix | Nextcloud App | Read Operations | Write Operations |
|
||||||
|
|--------------|---------------|-----------------|------------------|
|
||||||
|
| `mcp:notes:*` | Notes | Get, search, list | Create, update, delete, append |
|
||||||
|
| `mcp:calendar:*` | Calendar (CalDAV) | Get events, todos, calendars | Create/update/delete events, todos |
|
||||||
|
| `mcp:contacts:*` | Contacts (CardDAV) | Get contacts, address books | Create/update/delete contacts |
|
||||||
|
| `mcp:cookbook:*` | Cookbook | Get recipes, search | Create/update recipes |
|
||||||
|
| `mcp:deck:*` | Deck | Get boards, cards | Create/update boards, cards |
|
||||||
|
| `mcp:tables:*` | Tables | Get rows, tables | Create/update/delete rows |
|
||||||
|
| `mcp:files:*` | Files (WebDAV) | List, read files | Upload, delete, move files |
|
||||||
|
| `mcp:sharing:*` | Sharing | Get shares | Create/update shares |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Switching Authentication Modes
|
## Switching Authentication Modes
|
||||||
|
|
||||||
### From BasicAuth to OAuth
|
### From BasicAuth to OAuth
|
||||||
|
|||||||
+136
-62
@@ -16,64 +16,124 @@ While the core OAuth flow works, there are **pending upstream improvements** tha
|
|||||||
|
|
||||||
**Status**: 🟡 **Patch Required** (Pending Upstream)
|
**Status**: 🟡 **Patch Required** (Pending Upstream)
|
||||||
|
|
||||||
**Affected Component**: `user_oidc` app
|
**Affected Component**: **Nextcloud core server** (`CORSMiddleware`)
|
||||||
|
|
||||||
**Issue**: Bearer token authentication fails for app-specific APIs (Notes, Calendar, etc.) with `401 Unauthorized` errors, even though OCS APIs work correctly.
|
**Issue**: Bearer token authentication fails for app-specific APIs (Notes, Calendar, etc.) with `401 Unauthorized` errors, even though OCS APIs work correctly.
|
||||||
|
|
||||||
**Root Cause**: The `CORSMiddleware` in Nextcloud logs out sessions created by Bearer token authentication when CSRF tokens are missing, which breaks API requests.
|
**Root Cause**: The `CORSMiddleware` in Nextcloud core server logs out sessions when CSRF tokens are missing. Bearer token authentication creates a session (via `user_oidc` app), but doesn't include CSRF tokens (stateless authentication). The middleware detects the logged-in session without CSRF token and calls `session->logout()`, invalidating the request.
|
||||||
|
|
||||||
**Solution**: Set the `app_api` session flag during Bearer token authentication to bypass CSRF checks.
|
**Solution**: Allow Bearer token requests to bypass CORS/CSRF checks in `CORSMiddleware`, since Bearer tokens are stateless and don't require CSRF protection.
|
||||||
|
|
||||||
**Upstream PR**: [nextcloud/user_oidc#1221](https://github.com/nextcloud/user_oidc/issues/1221)
|
**Upstream PR**: [nextcloud/server#55878](https://github.com/nextcloud/server/pull/55878)
|
||||||
|
|
||||||
**Workaround**: Manually apply the patch to `lib/User/Backend.php` in the `user_oidc` app
|
**Workaround**: Manually apply the patch to `lib/private/AppFramework/Middleware/Security/CORSMiddleware.php` in Nextcloud core server
|
||||||
|
|
||||||
**Impact**:
|
**Impact**:
|
||||||
- ✅ **Works**: OCS APIs (`/ocs/v2.php/cloud/capabilities`)
|
- ✅ **Works**: OCS APIs (`/ocs/v2.php/cloud/capabilities`)
|
||||||
- ❌ **Requires Patch**: App APIs (`/apps/notes/api/`, `/apps/calendar/`, etc.)
|
- ❌ **Requires Patch**: App APIs (`/apps/notes/api/`, `/apps/calendar/`, etc.)
|
||||||
|
|
||||||
**Files Modified**: `lib/User/Backend.php` in `user_oidc` app
|
**Files Modified**: `lib/private/AppFramework/Middleware/Security/CORSMiddleware.php` in **Nextcloud core server**
|
||||||
|
|
||||||
**Patch Summary**:
|
**Patch Summary**:
|
||||||
```php
|
```php
|
||||||
// Add before successful Bearer token authentication returns
|
// Allow Bearer token authentication for CORS requests
|
||||||
$this->session->set('app_api', true);
|
// Bearer tokens are stateless and don't require CSRF protection
|
||||||
```
|
$authorizationHeader = $this->request->getHeader('Authorization');
|
||||||
|
if (!empty($authorizationHeader) && str_starts_with($authorizationHeader, 'Bearer ')) {
|
||||||
This is added at lines ~243, ~310, ~315, and ~337 in `Backend.php`.
|
return;
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2. PKCE Support Advertisement in Discovery
|
|
||||||
|
|
||||||
**Status**: 🟢 **PR Submitted** (Pending Review)
|
|
||||||
|
|
||||||
**Affected Component**: `oidc` app
|
|
||||||
|
|
||||||
**Issue**: The OIDC discovery endpoint (`/.well-known/openid-configuration`) does not advertise PKCE support in the `code_challenge_methods_supported` field.
|
|
||||||
|
|
||||||
**Why It Matters**:
|
|
||||||
- MCP specification requires PKCE with S256 code challenge method
|
|
||||||
- RFC 8414 states that absence of `code_challenge_methods_supported` means PKCE is **not supported**
|
|
||||||
- Some MCP clients may reject providers without proper PKCE advertisement
|
|
||||||
|
|
||||||
**Current Behavior**:
|
|
||||||
- PKCE **functionally works** (the OIDC app accepts and validates PKCE)
|
|
||||||
- PKCE just isn't **advertised** in discovery metadata
|
|
||||||
|
|
||||||
**Recommended Fix**: Update `oidc` app to include:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"code_challenge_methods_supported": ["S256"]
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Workaround**: The MCP server implements PKCE validation and logs a warning if not advertised. Functionality still works.
|
This is added before the CSRF check at line ~73 in `CORSMiddleware.php`.
|
||||||
|
|
||||||
**Upstream PR**: [H2CK/oidc#584](https://github.com/H2CK/oidc/pull/584) - Submitted 2025-10-13
|
---
|
||||||
- **Changes**: Adds `code_challenge_methods_supported: ["S256"]` to discovery document when PKCE is enabled
|
|
||||||
- **Size**: +5 lines added, 0 deleted
|
### 2. JWT Token Support, Introspection, and Scope Validation
|
||||||
- **Status**: Open, awaiting review
|
|
||||||
|
**Status**: ✅ **Complete** (Merged Upstream)
|
||||||
|
|
||||||
|
**Affected Component**: `oidc` app
|
||||||
|
|
||||||
|
**Issue**: The OIDC app needed support for JWT tokens, token introspection, and enhanced scope validation for fine-grained authorization.
|
||||||
|
|
||||||
|
**Resolution**: Complete JWT and scope validation support has been implemented and merged:
|
||||||
|
|
||||||
|
**Upstream PR**: [H2CK/oidc#585](https://github.com/H2CK/oidc/pull/585) - ✅ **Merged**
|
||||||
|
- **Changes**:
|
||||||
|
- JWT token generation and validation
|
||||||
|
- Token introspection endpoint (RFC 7662)
|
||||||
|
- Enhanced scope validation and parsing
|
||||||
|
- Custom scope support for Nextcloud apps
|
||||||
|
- **Status**: Merged and available in v1.10.0+ of the `oidc` app
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. User Consent Management
|
||||||
|
|
||||||
|
**Status**: ✅ **Complete** (Merged Upstream)
|
||||||
|
|
||||||
|
**Affected Component**: `oidc` app
|
||||||
|
|
||||||
|
**Issue**: The OIDC app needed proper user consent management for OAuth authorization flows.
|
||||||
|
|
||||||
|
**Resolution**: Complete user consent management has been implemented and merged:
|
||||||
|
|
||||||
|
**Upstream PR**: [H2CK/oidc#586](https://github.com/H2CK/oidc/pull/586) - ✅ **Merged**
|
||||||
|
- **Changes**:
|
||||||
|
- User consent UI for OAuth authorization
|
||||||
|
- Consent expiration and cleanup
|
||||||
|
- Admin control for user consent settings
|
||||||
|
- Consent tracking and management
|
||||||
|
- **Status**: Merged and available in v1.11.0+ of the `oidc` app
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. PKCE Support (RFC 7636)
|
||||||
|
|
||||||
|
**Status**: ✅ **Complete** (Merged Upstream)
|
||||||
|
|
||||||
|
**Affected Component**: `oidc` app
|
||||||
|
|
||||||
|
**Issue**: The OIDC app lacked PKCE (Proof Key for Code Exchange) implementation per RFC 7636.
|
||||||
|
|
||||||
|
**Resolution**: Full PKCE support has been implemented and merged upstream into the `oidc` app:
|
||||||
|
|
||||||
|
**Authorization Endpoint** (`/authorize`):
|
||||||
|
- Accepts `code_challenge` and `code_challenge_method` parameters
|
||||||
|
- Validates code_challenge format (43-128 characters, unreserved chars only)
|
||||||
|
- Supports both `S256` (SHA-256) and `plain` challenge methods
|
||||||
|
- Stores challenge and method in database for later verification
|
||||||
|
|
||||||
|
**Token Endpoint** (`/token`):
|
||||||
|
- Accepts `code_verifier` parameter
|
||||||
|
- Verifies code_verifier against stored code_challenge using proper algorithm
|
||||||
|
- Uses constant-time comparison to prevent timing attacks
|
||||||
|
- Enforces code_verifier requirement when PKCE was used in authorization
|
||||||
|
|
||||||
|
**Discovery Document**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code_challenge_methods_supported": ["S256", "plain"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Database**:
|
||||||
|
- New columns: `code_challenge` and `code_challenge_method` in `oc_oauth2_access_tokens`
|
||||||
|
- Migration included for existing installations
|
||||||
|
|
||||||
|
**Why It Mattered**:
|
||||||
|
- MCP specification requires PKCE with S256 code challenge method
|
||||||
|
- RFC 7636 PKCE provides security for public clients (no client secret)
|
||||||
|
- RFC 8414 states that absence of `code_challenge_methods_supported` means PKCE is **not supported**
|
||||||
|
- Prevents authorization code interception attacks
|
||||||
|
|
||||||
|
**Upstream PR**: [H2CK/oidc#584](https://github.com/H2CK/oidc/pull/584) - ✅ **Merged 2025-10-20**
|
||||||
|
- **Changes**: Complete PKCE implementation (+194 lines)
|
||||||
|
- Authorization flow with code_challenge validation
|
||||||
|
- Token exchange with code_verifier verification
|
||||||
|
- Database schema updates
|
||||||
|
- Discovery document updates
|
||||||
|
- **Status**: Merged and available in v1.10.0+ of the `oidc` app
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -81,24 +141,34 @@ This is added at lines ~243, ~310, ~315, and ~337 in `Backend.php`.
|
|||||||
|
|
||||||
| PR/Issue | Component | Status | Priority | Notes |
|
| PR/Issue | Component | Status | Priority | Notes |
|
||||||
|----------|-----------|--------|----------|-------|
|
|----------|-----------|--------|----------|-------|
|
||||||
| [user_oidc#1221](https://github.com/nextcloud/user_oidc/issues/1221) | `user_oidc` | 🟡 Open | High | Required for app-specific APIs |
|
| [server#55878](https://github.com/nextcloud/server/pull/55878) | Nextcloud core server | 🟡 Open | High | CORSMiddleware patch for Bearer tokens |
|
||||||
| [H2CK/oidc#584](https://github.com/H2CK/oidc/pull/584) | `oidc` | 🟢 PR Open | Medium | PKCE advertisement for standards compliance |
|
| [H2CK/oidc#586](https://github.com/H2CK/oidc/pull/586) | `oidc` | ✅ Merged | Medium | ✅ User consent complete (v1.11.0+) |
|
||||||
|
| [H2CK/oidc#585](https://github.com/H2CK/oidc/pull/585) | `oidc` | ✅ Merged | Medium | ✅ JWT tokens, introspection, scope validation (v1.10.0+) |
|
||||||
|
| [H2CK/oidc#584](https://github.com/H2CK/oidc/pull/584) | `oidc` | ✅ Merged | ~~High~~ | ✅ PKCE support (RFC 7636) (v1.10.0+) |
|
||||||
|
|
||||||
## What Works Without Patches
|
## What Works Without Patches
|
||||||
|
|
||||||
The following functionality works **out of the box** without any patches:
|
The following functionality works **out of the box** without any patches:
|
||||||
|
|
||||||
✅ **OAuth Flow**:
|
✅ **OAuth Flow** (requires `oidc` app v1.10.0+):
|
||||||
- OIDC discovery
|
- OIDC discovery with full PKCE support (RFC 7636)
|
||||||
- Dynamic client registration
|
- Dynamic client registration
|
||||||
- Authorization code flow with PKCE
|
- Authorization code flow with PKCE (S256 and plain methods)
|
||||||
- Token exchange
|
- Token exchange with code_verifier verification
|
||||||
|
- User consent management
|
||||||
- Userinfo endpoint
|
- Userinfo endpoint
|
||||||
|
|
||||||
|
✅ **Token Features** (requires `oidc` app v1.10.0+):
|
||||||
|
- JWT token generation and validation
|
||||||
|
- Token introspection endpoint (RFC 7662)
|
||||||
|
- Enhanced scope validation and parsing
|
||||||
|
- Custom scope support for Nextcloud apps
|
||||||
|
|
||||||
✅ **MCP Server as Resource Server**:
|
✅ **MCP Server as Resource Server**:
|
||||||
- Token validation via userinfo
|
- Token validation via userinfo
|
||||||
- Per-user client instances
|
- Per-user client instances
|
||||||
- Token caching
|
- Token caching
|
||||||
|
- Scope-based authorization
|
||||||
|
|
||||||
✅ **Nextcloud OCS APIs**:
|
✅ **Nextcloud OCS APIs**:
|
||||||
- Capabilities endpoint
|
- Capabilities endpoint
|
||||||
@@ -108,7 +178,7 @@ The following functionality works **out of the box** without any patches:
|
|||||||
|
|
||||||
The following functionality requires upstream patches:
|
The following functionality requires upstream patches:
|
||||||
|
|
||||||
🟡 **App-Specific APIs** (Requires user_oidc#1221):
|
🟡 **App-Specific APIs** (Requires Nextcloud core server CORSMiddleware patch):
|
||||||
- Notes API (`/apps/notes/api/`)
|
- Notes API (`/apps/notes/api/`)
|
||||||
- Calendar API (CalDAV)
|
- Calendar API (CalDAV)
|
||||||
- Contacts API (CardDAV)
|
- Contacts API (CardDAV)
|
||||||
@@ -116,9 +186,9 @@ The following functionality requires upstream patches:
|
|||||||
- Tables API
|
- Tables API
|
||||||
- Custom app APIs
|
- Custom app APIs
|
||||||
|
|
||||||
🟡 **Standards Compliance** (PKCE advertisement):
|
✅ **Standards Compliance**: Now complete with `oidc` app v1.10.0+
|
||||||
- Full RFC 8414 compliance
|
- ✅ Full RFC 8414 compliance (PKCE advertisement)
|
||||||
- MCP client compatibility guarantee
|
- ✅ MCP client compatibility guarantee
|
||||||
|
|
||||||
## Installation Instructions
|
## Installation Instructions
|
||||||
|
|
||||||
@@ -171,7 +241,7 @@ The integration test suite validates OAuth functionality:
|
|||||||
docker-compose up --build -d mcp-oauth
|
docker-compose up --build -d mcp-oauth
|
||||||
|
|
||||||
# Run comprehensive OAuth tests
|
# Run comprehensive OAuth tests
|
||||||
uv run pytest tests/integration/test_oauth_playwright.py --browser firefox -v
|
uv run pytest tests/client/test_oauth_playwright.py --browser firefox -v
|
||||||
|
|
||||||
# Tests verify:
|
# Tests verify:
|
||||||
# - OAuth flow completion
|
# - OAuth flow completion
|
||||||
@@ -182,19 +252,23 @@ uv run pytest tests/integration/test_oauth_playwright.py --browser firefox -v
|
|||||||
|
|
||||||
## Monitoring Upstream Progress
|
## Monitoring Upstream Progress
|
||||||
|
|
||||||
To track progress on these issues:
|
To track progress on remaining issues:
|
||||||
|
|
||||||
1. **Watch the upstream repositories**:
|
1. **Watch the upstream repository**:
|
||||||
- [nextcloud/user_oidc](https://github.com/nextcloud/user_oidc)
|
- [nextcloud/server](https://github.com/nextcloud/server)
|
||||||
- [nextcloud/oidc](https://github.com/nextcloud/oidc)
|
|
||||||
|
|
||||||
2. **Subscribe to specific issues**:
|
2. **Subscribe to the CORSMiddleware PR**:
|
||||||
- [user_oidc#1221](https://github.com/nextcloud/user_oidc/issues/1221) - Bearer token support
|
- [server#55878](https://github.com/nextcloud/server/pull/55878) - CORSMiddleware Bearer token support
|
||||||
|
|
||||||
3. **Check Nextcloud release notes** for mentions of:
|
3. **Check Nextcloud server release notes** for mentions of:
|
||||||
- Bearer token authentication improvements
|
- Bearer token authentication improvements
|
||||||
- OIDC/OAuth enhancements
|
- CORS middleware enhancements
|
||||||
- AppAPI compatibility
|
- OAuth/OIDC API compatibility
|
||||||
|
|
||||||
|
4. **Completed upstream work** (no monitoring needed):
|
||||||
|
- ✅ [H2CK/oidc#584](https://github.com/H2CK/oidc/pull/584) - PKCE support (v1.10.0+)
|
||||||
|
- ✅ [H2CK/oidc#585](https://github.com/H2CK/oidc/pull/585) - JWT, introspection, scopes (v1.10.0+)
|
||||||
|
- ✅ [H2CK/oidc#586](https://github.com/H2CK/oidc/pull/586) - User consent (v1.11.0+)
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
@@ -221,6 +295,6 @@ Want to help get these patches merged?
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Last Updated**: 2025-10-14
|
**Last Updated**: 2025-11-02
|
||||||
|
|
||||||
**Next Review**: When PR #584 or issue #1221 has activity
|
**Next Review**: When Nextcloud server CORSMiddleware PR has activity
|
||||||
|
|||||||
@@ -151,11 +151,11 @@ curl https://your.nextcloud.instance.com/.well-known/openid-configuration
|
|||||||
This quick start uses **automatic client registration** which is perfect for:
|
This quick start uses **automatic client registration** which is perfect for:
|
||||||
- Development
|
- Development
|
||||||
- Testing
|
- Testing
|
||||||
- Short-lived deployments
|
- Quick deployments
|
||||||
|
|
||||||
For **production deployments**, you should:
|
For **production deployments**, consider:
|
||||||
1. Pre-register OAuth clients manually
|
1. Pre-registering OAuth client manually
|
||||||
2. Use dedicated client credentials
|
2. Using dedicated client credentials that don't expire
|
||||||
3. See [OAuth Setup Guide](oauth-setup.md) for production configuration
|
3. See [OAuth Setup Guide](oauth-setup.md) for production configuration
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -0,0 +1,317 @@
|
|||||||
|
# Testing Client Sessions Architecture
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This document compares different approaches to managing MCP client sessions in integration tests, addressing the fundamental incompatibility between pytest-asyncio's fixture management and anyio's structured concurrency requirements.
|
||||||
|
|
||||||
|
## The Problem
|
||||||
|
|
||||||
|
When using pytest-asyncio with anyio-based libraries (like the MCP Python SDK), session-scoped async generator fixtures encounter a fundamental issue:
|
||||||
|
|
||||||
|
1. **pytest-asyncio** runs fixture teardown in a **new asyncio task** using `runner.run()`
|
||||||
|
2. **anyio** requires that cancel scopes be entered and exited in the **same task**
|
||||||
|
3. This causes `RuntimeError: Attempted to exit cancel scope in a different task than it was entered in`
|
||||||
|
|
||||||
|
This is a **known limitation** documented in the anyio project and is not a bug in either pytest-asyncio or anyio, but rather an inherent incompatibility between their design philosophies.
|
||||||
|
|
||||||
|
## Solution Comparison
|
||||||
|
|
||||||
|
### Solution 1: Native Async Context Managers with Surgical Exception Handling ✅ **IMPLEMENTED**
|
||||||
|
|
||||||
|
**Approach**: Use native `async with` statements for clean code structure, but add targeted exception handling at the pytest fixture level to handle the expected teardown errors.
|
||||||
|
|
||||||
|
**Implementation**:
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def create_mcp_client_session(
|
||||||
|
url: str,
|
||||||
|
token: str | None = None,
|
||||||
|
client_name: str = "MCP",
|
||||||
|
) -> AsyncGenerator[ClientSession, Any]:
|
||||||
|
"""Uses native async context managers for clean LIFO cleanup."""
|
||||||
|
headers = {"Authorization": f"Bearer {token}"} if token else None
|
||||||
|
|
||||||
|
async with streamablehttp_client(url, headers=headers) as (read_stream, write_stream, _):
|
||||||
|
async with ClientSession(read_stream, write_stream) as session:
|
||||||
|
await session.initialize()
|
||||||
|
yield session
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
async def nc_mcp_client() -> AsyncGenerator[ClientSession, Any]:
|
||||||
|
"""Fixture with surgical exception handling for pytest-asyncio incompatibility."""
|
||||||
|
try:
|
||||||
|
async for session in create_mcp_client_session(
|
||||||
|
url="http://localhost:8000/mcp", client_name="Basic MCP"
|
||||||
|
):
|
||||||
|
yield session
|
||||||
|
except RuntimeError as e:
|
||||||
|
# Only catch the specific expected error during pytest teardown
|
||||||
|
if "cancel scope" in str(e) and "different task" in str(e):
|
||||||
|
logger.debug(f"Ignoring expected pytest-asyncio teardown issue: {e}")
|
||||||
|
else:
|
||||||
|
# Unexpected RuntimeError - re-raise to fail the test
|
||||||
|
raise
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pros**:
|
||||||
|
- ✅ Clean, idiomatic code using native Python context managers
|
||||||
|
- ✅ Exception handling is surgical - only catches the specific expected error
|
||||||
|
- ✅ Unexpected errors still propagate and fail tests
|
||||||
|
- ✅ Can use session-scoped fixtures for performance
|
||||||
|
- ✅ Easy to understand and maintain
|
||||||
|
- ✅ Minimal code changes from original implementation
|
||||||
|
- ✅ No external dependencies required
|
||||||
|
|
||||||
|
**Cons**:
|
||||||
|
- ⚠️ Still requires exception suppression (though targeted)
|
||||||
|
- ⚠️ String-based exception matching is somewhat fragile
|
||||||
|
- ⚠️ Must apply the pattern to each session-scoped fixture
|
||||||
|
- ⚠️ Doesn't solve the root cause
|
||||||
|
|
||||||
|
**Verdict**: **Recommended** - Best balance of code clarity, maintainability, and pragmatism.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Solution 2: Task-Isolated Fixtures
|
||||||
|
|
||||||
|
**Approach**: Run each fixture's client session in an isolated anyio task group, allowing independent cleanup without cross-fixture interference.
|
||||||
|
|
||||||
|
**Implementation**:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
async def nc_mcp_client() -> AsyncGenerator[ClientSession, Any]:
|
||||||
|
"""Fixture with task isolation for clean teardown."""
|
||||||
|
import anyio
|
||||||
|
|
||||||
|
session_holder = {"session": None}
|
||||||
|
|
||||||
|
async def create_and_hold_session():
|
||||||
|
"""Runs in isolated task - creates session and keeps it alive."""
|
||||||
|
async with streamablehttp_client("http://localhost:8000/mcp") as (read_stream, write_stream, _):
|
||||||
|
async with ClientSession(read_stream, write_stream) as session:
|
||||||
|
await session.initialize()
|
||||||
|
session_holder["session"] = session
|
||||||
|
|
||||||
|
# Keep session alive until cancelled
|
||||||
|
try:
|
||||||
|
await anyio.sleep_forever()
|
||||||
|
except anyio.get_cancelled_exc_class():
|
||||||
|
pass # Expected cancellation
|
||||||
|
|
||||||
|
async with anyio.create_task_group() as tg:
|
||||||
|
tg.start_soon(create_and_hold_session)
|
||||||
|
|
||||||
|
# Wait for session to be ready
|
||||||
|
while session_holder["session"] is None:
|
||||||
|
await anyio.sleep(0.1)
|
||||||
|
|
||||||
|
yield session_holder["session"]
|
||||||
|
|
||||||
|
# Task group cancellation ensures clean LIFO cleanup
|
||||||
|
tg.cancel_scope.cancel()
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pros**:
|
||||||
|
- ✅ No exception suppression needed
|
||||||
|
- ✅ Each fixture has its own isolated task scope
|
||||||
|
- ✅ More theoretically correct approach
|
||||||
|
- ✅ Can use session-scoped fixtures
|
||||||
|
|
||||||
|
**Cons**:
|
||||||
|
- ❌ Significantly more complex code
|
||||||
|
- ❌ Harder to understand for developers unfamiliar with anyio
|
||||||
|
- ❌ Requires understanding of task groups and cancel scopes
|
||||||
|
- ❌ More boilerplate per fixture
|
||||||
|
- ❌ Still doesn't solve the fundamental pytest-asyncio incompatibility
|
||||||
|
- ❌ Polling for session readiness is inelegant
|
||||||
|
- ❌ Higher cognitive overhead for maintenance
|
||||||
|
|
||||||
|
**Verdict**: **Not Recommended** - Complexity outweighs benefits. Consider only if exception handling is completely unacceptable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Solution 3: Function-Scoped Fixtures with Nested Context Managers
|
||||||
|
|
||||||
|
**Approach**: Change fixtures to function scope and rely on Python's context manager nesting for guaranteed LIFO cleanup.
|
||||||
|
|
||||||
|
**Implementation**:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@pytest.fixture(scope="function") # Changed from session
|
||||||
|
async def nc_mcp_client() -> AsyncGenerator[ClientSession, Any]:
|
||||||
|
"""Function-scoped fixture with natural LIFO cleanup."""
|
||||||
|
async with streamablehttp_client("http://localhost:8000/mcp") as (read_stream, write_stream, _):
|
||||||
|
async with ClientSession(read_stream, write_stream) as session:
|
||||||
|
await session.initialize()
|
||||||
|
yield session
|
||||||
|
|
||||||
|
# For tests needing multiple clients:
|
||||||
|
@pytest.fixture(scope="function")
|
||||||
|
async def multi_mcp_clients() -> AsyncGenerator[tuple[ClientSession, ClientSession], Any]:
|
||||||
|
"""Multiple clients with guaranteed LIFO cleanup through nesting."""
|
||||||
|
async with streamablehttp_client("http://localhost:8000/mcp") as (read1, write1, _):
|
||||||
|
async with ClientSession(read1, write1) as session1:
|
||||||
|
await session1.initialize()
|
||||||
|
|
||||||
|
async with streamablehttp_client("http://localhost:8001/mcp") as (read2, write2, _):
|
||||||
|
async with ClientSession(read2, write2) as session2:
|
||||||
|
await session2.initialize()
|
||||||
|
yield session1, session2
|
||||||
|
# Cleanup: session2 -> stream2 -> session1 -> stream1 (LIFO guaranteed)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pros**:
|
||||||
|
- ✅ No exception handling needed
|
||||||
|
- ✅ Simplest to understand
|
||||||
|
- ✅ Natural LIFO cleanup through Python's context managers
|
||||||
|
- ✅ Each test gets fresh clients (better isolation)
|
||||||
|
- ✅ No workarounds or hacks required
|
||||||
|
|
||||||
|
**Cons**:
|
||||||
|
- ❌ Significantly slower tests (new clients per test)
|
||||||
|
- ❌ Cannot share client state across tests
|
||||||
|
- ❌ More resource intensive
|
||||||
|
- ❌ Higher overhead for test suite execution
|
||||||
|
- ❌ May not be practical for expensive fixtures (e.g., OAuth tokens)
|
||||||
|
- ❌ Nested context managers become unwieldy with many clients
|
||||||
|
|
||||||
|
**Verdict**: **Good Alternative** - Consider for specific fixtures where session scope isn't critical, or for new test files where performance isn't a concern.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Solution 4: Use pytest-trio Instead of pytest-asyncio (Future)
|
||||||
|
|
||||||
|
**Approach**: Replace pytest-asyncio with pytest-trio, which was designed with structured concurrency in mind.
|
||||||
|
|
||||||
|
**Implementation**:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# pyproject.toml
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
# Remove: asyncio_mode = "auto"
|
||||||
|
# Add: trio_mode = "auto"
|
||||||
|
|
||||||
|
# Fixtures work naturally with trio
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
async def nc_mcp_client() -> AsyncGenerator[ClientSession, Any]:
|
||||||
|
async with streamablehttp_client("http://localhost:8000/mcp") as (read, write, _):
|
||||||
|
async with ClientSession(read, write) as session:
|
||||||
|
await session.initialize()
|
||||||
|
yield session
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pros**:
|
||||||
|
- ✅ No workarounds needed
|
||||||
|
- ✅ Designed for structured concurrency
|
||||||
|
- ✅ Theoretically cleanest solution
|
||||||
|
- ✅ Can use session-scoped fixtures naturally
|
||||||
|
|
||||||
|
**Cons**:
|
||||||
|
- ❌ Requires switching from asyncio to trio backend
|
||||||
|
- ❌ Major refactoring required
|
||||||
|
- ❌ May break existing code that assumes asyncio
|
||||||
|
- ❌ Dependency changes throughout project
|
||||||
|
- ❌ Team needs to learn trio ecosystem
|
||||||
|
- ❌ Less ecosystem support than asyncio
|
||||||
|
|
||||||
|
**Verdict**: **Not Practical** - Too disruptive for existing projects. Consider only for greenfield projects or major rewrites.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decision Matrix
|
||||||
|
|
||||||
|
| Solution | Code Clarity | Maintenance | Performance | Safety | Effort |
|
||||||
|
|----------|--------------|-------------|-------------|--------|--------|
|
||||||
|
| **Solution 1** (Implemented) | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
|
||||||
|
| Solution 2 (Task-Isolated) | ⭐⭐ | ⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐ |
|
||||||
|
| Solution 3 (Function-Scoped) | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
|
||||||
|
| Solution 4 (pytest-trio) | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐ |
|
||||||
|
|
||||||
|
## Implementation Details
|
||||||
|
|
||||||
|
### What Changed in Solution 1
|
||||||
|
|
||||||
|
1. **`create_mcp_client_session` function** (conftest.py:61-110):
|
||||||
|
- Replaced manual `__aenter__`/`__aexit__` calls with native `async with` statements
|
||||||
|
- Removed blanket exception suppression from cleanup logic
|
||||||
|
- Added clear documentation about LIFO cleanup order
|
||||||
|
- Simplified from ~60 lines to ~40 lines
|
||||||
|
|
||||||
|
2. **Session-scoped MCP client fixtures** (conftest.py:148-1269):
|
||||||
|
- Added targeted exception handling wrapper
|
||||||
|
- Only catches specific "cancel scope" + "different task" RuntimeError
|
||||||
|
- All other exceptions propagate normally
|
||||||
|
- Applied to: `nc_mcp_client`, `nc_mcp_oauth_client`, `alice_mcp_client`, `bob_mcp_client`, `charlie_mcp_client`, `diana_mcp_client`
|
||||||
|
|
||||||
|
3. **Documentation**:
|
||||||
|
- Added comprehensive docstrings explaining the workaround
|
||||||
|
- Referenced MCP SDK issue #577 for context
|
||||||
|
- Documented why this is necessary and not a bug
|
||||||
|
|
||||||
|
### Benefits of This Implementation
|
||||||
|
|
||||||
|
1. **Clean Core Logic**: The `create_mcp_client_session` function is now clean, idiomatic Python with no workarounds
|
||||||
|
2. **Isolated Workaround**: Exception handling is confined to pytest fixture level where the issue actually occurs
|
||||||
|
3. **Surgical Exception Handling**: Only catches the specific expected error, not all RuntimeErrors
|
||||||
|
4. **Performance**: Maintains session-scoped fixtures for fast test execution
|
||||||
|
5. **Maintainability**: Easy to understand and modify
|
||||||
|
6. **Safety**: Real errors still cause test failures
|
||||||
|
|
||||||
|
## Testing Results
|
||||||
|
|
||||||
|
All tests pass cleanly with the implementation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ uv run pytest tests/server/test_mcp.py -v
|
||||||
|
============================================= test session starts ==============================================
|
||||||
|
tests/server/test_mcp.py::test_mcp_connectivity PASSED [ 16%]
|
||||||
|
tests/server/test_mcp.py::test_mcp_notes_crud_workflow PASSED [ 33%]
|
||||||
|
tests/server/test_mcp.py::test_mcp_notes_etag_conflict PASSED [ 50%]
|
||||||
|
tests/server/test_mcp.py::test_mcp_webdav_workflow PASSED [ 66%]
|
||||||
|
tests/server/test_mcp.py::test_mcp_resources_access PASSED [ 83%]
|
||||||
|
tests/server/test_mcp.py::test_mcp_calendar_workflow PASSED [100%]
|
||||||
|
============================================== 6 passed in 39.52s ==============================================
|
||||||
|
```
|
||||||
|
|
||||||
|
## Recommendations
|
||||||
|
|
||||||
|
### For This Project: Solution 1 ✅
|
||||||
|
|
||||||
|
The implemented solution (Solution 1) is the best fit because:
|
||||||
|
- Minimal disruption to existing tests
|
||||||
|
- Clean, maintainable code
|
||||||
|
- Good performance with session-scoped fixtures
|
||||||
|
- Targeted exception handling that doesn't hide real errors
|
||||||
|
|
||||||
|
### For New Test Files: Consider Solution 3
|
||||||
|
|
||||||
|
For new test files where performance isn't critical, consider using function-scoped fixtures (Solution 3):
|
||||||
|
- No workarounds needed
|
||||||
|
- Perfect code clarity
|
||||||
|
- Better test isolation
|
||||||
|
|
||||||
|
### For Greenfield Projects: Consider Solution 4
|
||||||
|
|
||||||
|
For new projects starting from scratch, consider pytest-trio instead of pytest-asyncio:
|
||||||
|
- Native structured concurrency support
|
||||||
|
- No workarounds needed
|
||||||
|
- Better alignment with modern async Python patterns
|
||||||
|
|
||||||
|
## Related Resources
|
||||||
|
|
||||||
|
- [MCP Python SDK Issue #577](https://github.com/modelcontextprotocol/python-sdk/issues/577) - Original issue report
|
||||||
|
- [Anyio Issue #345](https://github.com/agronholm/anyio/issues/345) - Discussion of fixture limitations
|
||||||
|
- [Nextcloud MCP Note 378555](nextcloud://notes/378555) - Detailed investigation notes
|
||||||
|
- pytest-asyncio documentation: https://pytest-asyncio.readthedocs.io/
|
||||||
|
- anyio structured concurrency guide: https://anyio.readthedocs.io/en/stable/basics.html
|
||||||
|
|
||||||
|
## Appendix: Why Can't This Be Fixed Upstream?
|
||||||
|
|
||||||
|
The incompatibility cannot be "fixed" in either pytest-asyncio or anyio without breaking their core design:
|
||||||
|
|
||||||
|
1. **pytest-asyncio** needs to manage fixture lifecycle across different scopes, requiring separate task creation for cleanup
|
||||||
|
2. **anyio** enforces structured concurrency guarantees by requiring same-task cancel scope entry/exit
|
||||||
|
3. These requirements are fundamentally incompatible
|
||||||
|
|
||||||
|
The maintainers of both projects are aware of this issue, and it's considered an acceptable trade-off given their respective design goals. The recommended approach is to handle it at the application level, as we've done here.
|
||||||
@@ -0,0 +1,412 @@
|
|||||||
|
# Testing OIDC Consent Feature
|
||||||
|
|
||||||
|
This guide explains how to test the OIDC consent feature using the development version of the OIDC app mounted into the Docker environment.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
### Volume Mount Configuration
|
||||||
|
|
||||||
|
The development OIDC app is mounted from `~/Software/oidc` into the container at `/opt/apps/oidc`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# docker-compose.yml
|
||||||
|
volumes:
|
||||||
|
- ../Software/oidc:/opt/apps/oidc:ro
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why mount outside `/var/www/html/`?**
|
||||||
|
- The Nextcloud container uses `rsync` to initialize `/var/www/html/` from the image
|
||||||
|
- Mounting inside that path causes conflicts (rsync tries to delete mounted directories)
|
||||||
|
- Mounting to `/opt/apps/oidc` avoids rsync entirely
|
||||||
|
- Nextcloud supports multiple app directories via the `apps_paths` configuration
|
||||||
|
|
||||||
|
**How multiple app paths work:**
|
||||||
|
- Nextcloud can load apps from multiple directories
|
||||||
|
- The post-installation hook registers `/opt/apps` as an additional app directory (index 2)
|
||||||
|
- Apps in default paths (index 0 and 1) are still available
|
||||||
|
- All directories are scanned for apps, but `/opt/apps` is read-only
|
||||||
|
|
||||||
|
This setup allows you to:
|
||||||
|
- Test changes without rebuilding containers
|
||||||
|
- Avoid needing npm/node in the container (JS already built on host)
|
||||||
|
- Iterate quickly on development
|
||||||
|
- Install other Nextcloud apps normally (custom_apps remains writable)
|
||||||
|
|
||||||
|
### How It Works
|
||||||
|
|
||||||
|
1. **Mount Development App**: Docker mounts `~/Software/oidc` to `/opt/apps/oidc` (outside Nextcloud's path)
|
||||||
|
2. **Register App Path**: The `10-install-oidc-app.sh` hook configures `/opt/apps` as an additional app directory
|
||||||
|
3. **Enable App**: The hook enables the OIDC app from `/opt/apps/oidc`
|
||||||
|
4. **Run Migrations**: Nextcloud detects pending migrations and runs them automatically
|
||||||
|
5. **Configure OIDC**: Dynamic client registration and PKCE are enabled
|
||||||
|
|
||||||
|
## Starting the Stack
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/Projects/nextcloud-mcp-server
|
||||||
|
|
||||||
|
# Start fresh (recommended for first test)
|
||||||
|
docker compose down -v
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
# Wait for initialization (check logs)
|
||||||
|
docker compose logs -f app
|
||||||
|
```
|
||||||
|
|
||||||
|
The post-installation hooks will:
|
||||||
|
1. Configure custom_apps path (already done)
|
||||||
|
2. Enable OIDC app from mounted directory
|
||||||
|
3. Run database migrations (including consent table creation)
|
||||||
|
4. Configure OIDC settings
|
||||||
|
|
||||||
|
## Verifying Installation
|
||||||
|
|
||||||
|
### Before Container Restart
|
||||||
|
|
||||||
|
Before running `docker compose up -d`, the consent feature will NOT be active:
|
||||||
|
- ❌ No `oc_oidc_user_consents` table in database
|
||||||
|
- ❌ Migration 0015 not applied yet
|
||||||
|
- ❌ ConsentController class not loaded
|
||||||
|
- ❌ Consent routes not registered
|
||||||
|
|
||||||
|
You can verify this with:
|
||||||
|
```bash
|
||||||
|
# Check migrations applied (should stop at 0014)
|
||||||
|
docker compose exec -T db mariadb -u nextcloud -ppassword nextcloud -e "SELECT version FROM oc_migrations WHERE app = 'oidc' ORDER BY version DESC LIMIT 3;" nextcloud
|
||||||
|
|
||||||
|
# Check for consent table (should return empty)
|
||||||
|
docker compose exec -T db mariadb -u nextcloud -ppassword nextcloud -e "SHOW TABLES LIKE 'oc_oidc_user_consents';" nextcloud
|
||||||
|
```
|
||||||
|
|
||||||
|
### After Container Restart
|
||||||
|
|
||||||
|
After `docker compose up -d` with the mounted OIDC directory, the consent feature should be active:
|
||||||
|
- ✅ `oc_oidc_user_consents` table exists
|
||||||
|
- ✅ Migration 0015 (Version0015Date20251123100100) applied
|
||||||
|
- ✅ ConsentController routes registered
|
||||||
|
- ✅ Consent screen appears during OAuth flows
|
||||||
|
|
||||||
|
### Check App Status
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec app php occ app:list | grep -A 2 oidc
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected output:
|
||||||
|
```
|
||||||
|
- oidc: 1.10.0 (enabled)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Verify App Paths Configuration
|
||||||
|
|
||||||
|
Verify that `/opt/apps` is registered as an additional app directory:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check configured app paths
|
||||||
|
docker compose exec app php occ config:system:get apps_paths
|
||||||
|
|
||||||
|
# Verify the mount is accessible
|
||||||
|
docker compose exec app ls -la /opt/apps/oidc/
|
||||||
|
|
||||||
|
# Verify custom_apps is writable (for normal app installation)
|
||||||
|
docker compose exec -u www-data app touch /var/www/html/custom_apps/.test && echo "✅ custom_apps is writable" || echo "❌ custom_apps NOT writable"
|
||||||
|
docker compose exec app rm -f /var/www/html/custom_apps/.test
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: Output should show multiple app paths including index 2 (/opt/apps).
|
||||||
|
|
||||||
|
### Verify Consent Files
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check controller exists in mounted location
|
||||||
|
docker compose exec app ls -la /opt/apps/oidc/lib/Controller/ConsentController.php
|
||||||
|
|
||||||
|
# Check Vue component exists
|
||||||
|
docker compose exec app ls -la /opt/apps/oidc/src/Consent.vue
|
||||||
|
|
||||||
|
# Check built JS exists
|
||||||
|
docker compose exec app ls -lh /opt/apps/oidc/js/oidc-consent.js
|
||||||
|
```
|
||||||
|
|
||||||
|
### Verify Database Migration
|
||||||
|
|
||||||
|
**Note**: These checks will only pass after restarting containers with the mounted OIDC app.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check if consent table exists
|
||||||
|
docker compose exec -T db mariadb -u nextcloud -ppassword nextcloud -e "SHOW TABLES LIKE 'oc_oidc_user_consents';"
|
||||||
|
|
||||||
|
# Check table structure
|
||||||
|
docker compose exec -T db mariadb -u nextcloud -ppassword nextcloud -e "DESCRIBE oc_oidc_user_consents;"
|
||||||
|
|
||||||
|
# Verify migration 0015 was applied
|
||||||
|
docker compose exec -T db mariadb -u nextcloud -ppassword nextcloud -e "SELECT app, version FROM oc_migrations WHERE app = 'oidc' AND version LIKE '%0015%';"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected table structure:
|
||||||
|
- id: int(10) unsigned, auto_increment, primary key
|
||||||
|
- user_id: varchar(256), not null
|
||||||
|
- client_id: int(10) unsigned, not null
|
||||||
|
- scopes_granted: varchar(512), not null
|
||||||
|
- created_at: int(10) unsigned, not null
|
||||||
|
- updated_at: int(10) unsigned, not null
|
||||||
|
- expires_at: int(10) unsigned, nullable
|
||||||
|
|
||||||
|
### Verify Routes
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec app php occ router:list | grep consent
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected output:
|
||||||
|
```
|
||||||
|
oidc.Consent.show GET apps/oidc/consent
|
||||||
|
oidc.Consent.grant POST apps/oidc/consent/grant
|
||||||
|
oidc.Consent.deny POST apps/oidc/consent/deny
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing the Consent Flow
|
||||||
|
|
||||||
|
### 1. Create an OAuth Client
|
||||||
|
|
||||||
|
The JWT client is automatically created by the post-installation hooks:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check if JWT client exists
|
||||||
|
docker compose exec app cat /var/www/html/.oauth-jwt/nextcloud_oauth_client.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Initiate Authorization Flow
|
||||||
|
|
||||||
|
You can test using the MCP OAuth container or manually:
|
||||||
|
|
||||||
|
**Option A: Using MCP OAuth container**
|
||||||
|
```bash
|
||||||
|
# The mcp-oauth container will trigger the OAuth flow
|
||||||
|
docker compose logs -f mcp-oauth
|
||||||
|
```
|
||||||
|
|
||||||
|
**Option B: Manual browser test**
|
||||||
|
1. Get client_id from the JWT client JSON
|
||||||
|
2. Visit in browser:
|
||||||
|
```
|
||||||
|
http://localhost:8080/apps/oidc/authorize?client_id=YOUR_CLIENT_ID&response_type=code&redirect_uri=http://localhost:8001/oauth/callback&scope=openid+profile+email+mcp:notes:read+mcp:notes:write&state=test123
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Expected Behavior
|
||||||
|
|
||||||
|
**First Authorization:**
|
||||||
|
1. User logs in (if not already authenticated)
|
||||||
|
2. **Consent screen appears** with:
|
||||||
|
- Application name: "Nextcloud MCP Server JWT"
|
||||||
|
- List of requested scopes with descriptions:
|
||||||
|
- ✓ Basic authentication (openid) - required, cannot deselect
|
||||||
|
- ✓ Profile information (profile)
|
||||||
|
- ✓ Email address (email)
|
||||||
|
- ✓ mcp:notes:read (custom scope, shown as-is)
|
||||||
|
- ✓ mcp:notes:write (custom scope, shown as-is)
|
||||||
|
- "Allow" and "Deny" buttons
|
||||||
|
3. User selects scopes and clicks "Allow"
|
||||||
|
4. Authorization proceeds with selected scopes
|
||||||
|
5. Consent is stored in database
|
||||||
|
|
||||||
|
**Subsequent Authorizations:**
|
||||||
|
- Same scopes → No consent screen (uses stored consent)
|
||||||
|
- Different scopes → Consent screen appears again
|
||||||
|
- If user clicks "Deny" → Returns `error=access_denied` to client
|
||||||
|
|
||||||
|
### 4. Verify Consent Stored
|
||||||
|
|
||||||
|
After granting consent:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# View all stored consents with formatted timestamps
|
||||||
|
docker compose exec -T db mariadb -u nextcloud -ppassword nextcloud -e "
|
||||||
|
SELECT
|
||||||
|
user_id,
|
||||||
|
client_id,
|
||||||
|
scopes_granted,
|
||||||
|
FROM_UNIXTIME(created_at) as created,
|
||||||
|
FROM_UNIXTIME(updated_at) as updated,
|
||||||
|
FROM_UNIXTIME(expires_at) as expires
|
||||||
|
FROM oc_oidc_user_consents;
|
||||||
|
" nextcloud
|
||||||
|
|
||||||
|
# Or for a compact view:
|
||||||
|
docker compose exec -T db mariadb -u nextcloud -ppassword nextcloud -e "SELECT * FROM oc_oidc_user_consents;" nextcloud
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Consent Screen Not Appearing
|
||||||
|
|
||||||
|
**Check browser console** (F12 → Console tab):
|
||||||
|
```
|
||||||
|
# Look for JS errors like:
|
||||||
|
Failed to load resource: js/oidc-consent.js
|
||||||
|
```
|
||||||
|
|
||||||
|
**Check Nextcloud logs:**
|
||||||
|
```bash
|
||||||
|
docker compose exec app tail -f /var/www/html/data/nextcloud.log | grep -i consent
|
||||||
|
```
|
||||||
|
|
||||||
|
**Verify JS file loaded:**
|
||||||
|
```bash
|
||||||
|
# Check file exists and has correct size (~73KB)
|
||||||
|
docker compose exec app ls -lh /opt/apps/oidc/js/oidc-consent.js
|
||||||
|
```
|
||||||
|
|
||||||
|
**Clear Nextcloud caches:**
|
||||||
|
```bash
|
||||||
|
docker compose exec app php occ maintenance:repair
|
||||||
|
docker compose restart app
|
||||||
|
```
|
||||||
|
|
||||||
|
### Migration Didn't Run
|
||||||
|
|
||||||
|
**Check which migrations have been applied:**
|
||||||
|
```bash
|
||||||
|
docker compose exec -T db mariadb -u nextcloud -ppassword nextcloud -e "SELECT app, version FROM oc_migrations WHERE app = 'oidc' ORDER BY version;" nextcloud
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected to see `Version0015Date20251123100100` in the list.
|
||||||
|
|
||||||
|
**Manually trigger migrations:**
|
||||||
|
```bash
|
||||||
|
# Disable and re-enable app (triggers all pending migrations)
|
||||||
|
docker compose exec app php occ app:disable oidc
|
||||||
|
docker compose exec app php occ app:enable oidc
|
||||||
|
|
||||||
|
# Verify migration 0015 was applied
|
||||||
|
docker compose exec -T db mariadb -u nextcloud -ppassword nextcloud -e "SELECT version FROM oc_migrations WHERE app = 'oidc' AND version LIKE '%0015%';" nextcloud
|
||||||
|
```
|
||||||
|
|
||||||
|
### Routes Not Registered
|
||||||
|
|
||||||
|
If `router:list` doesn't show consent routes:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# The autoloader might not have picked up new classes
|
||||||
|
# Restart the container
|
||||||
|
docker compose restart app
|
||||||
|
|
||||||
|
# Wait for it to be ready
|
||||||
|
sleep 10
|
||||||
|
|
||||||
|
# Try again
|
||||||
|
docker compose exec app php occ router:list | grep consent
|
||||||
|
```
|
||||||
|
|
||||||
|
If still not working, check if ConsentController is accessible:
|
||||||
|
```bash
|
||||||
|
docker compose exec app php -r "
|
||||||
|
require_once '/var/www/html/lib/base.php';
|
||||||
|
\$class = 'OCA\\OIDCIdentityProvider\\Controller\\ConsentController';
|
||||||
|
if (class_exists(\$class)) {
|
||||||
|
echo \"Class exists\n\";
|
||||||
|
} else {
|
||||||
|
echo \"Class not found\n\";
|
||||||
|
}
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Making Changes
|
||||||
|
|
||||||
|
### Frontend Changes (Vue.js)
|
||||||
|
|
||||||
|
1. Edit source file on host:
|
||||||
|
```bash
|
||||||
|
cd ~/Software/oidc
|
||||||
|
# Edit src/Consent.vue
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Rebuild JS:
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Refresh browser (container sees changes immediately via volume mount at /opt/apps/oidc)
|
||||||
|
|
||||||
|
### Backend Changes (PHP)
|
||||||
|
|
||||||
|
1. Edit files on host:
|
||||||
|
```bash
|
||||||
|
cd ~/Software/oidc
|
||||||
|
# Edit lib/Controller/ConsentController.php or other PHP files
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Changes are immediately visible (PHP is interpreted, no build step)
|
||||||
|
|
||||||
|
3. For new classes or major changes, restart container:
|
||||||
|
```bash
|
||||||
|
docker compose restart app
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database Schema Changes
|
||||||
|
|
||||||
|
If you modify the migration:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Changes won't be picked up if migration already ran
|
||||||
|
# Need to recreate the database:
|
||||||
|
docker compose down -v # Removes volumes
|
||||||
|
docker compose up -d # Fresh start with clean DB
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cleanup
|
||||||
|
|
||||||
|
### Reset Everything
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/Projects/nextcloud-mcp-server
|
||||||
|
docker compose down -v
|
||||||
|
```
|
||||||
|
|
||||||
|
This removes:
|
||||||
|
- All containers
|
||||||
|
- Database volume (all data)
|
||||||
|
- OAuth client credentials
|
||||||
|
|
||||||
|
### Keep Data, Restart App
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose restart app
|
||||||
|
```
|
||||||
|
|
||||||
|
This preserves:
|
||||||
|
- Database (consents, clients, users)
|
||||||
|
- OAuth client credentials
|
||||||
|
|
||||||
|
## Development Workflow Summary
|
||||||
|
|
||||||
|
1. **Make changes** in `~/Software/oidc`
|
||||||
|
2. **Build JS** if you changed Vue files: `npm run build`
|
||||||
|
3. **Test immediately** - refresh browser or restart container
|
||||||
|
4. **No need** to rebuild Docker images or reinstall app
|
||||||
|
5. **Iterate quickly** with instant feedback
|
||||||
|
|
||||||
|
## Production Deployment
|
||||||
|
|
||||||
|
When ready to deploy:
|
||||||
|
|
||||||
|
1. **Create patch file** (already done):
|
||||||
|
```bash
|
||||||
|
cd ~/Software/oidc
|
||||||
|
git format-patch master --stdout > user-consent-feature.patch
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Test patch** in clean environment:
|
||||||
|
```bash
|
||||||
|
# In a production-like environment
|
||||||
|
cd /path/to/production/oidc
|
||||||
|
git apply user-consent-feature.patch
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
php occ app:disable oidc
|
||||||
|
php occ app:enable oidc
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Verify migration** runs automatically on app enable
|
||||||
|
|
||||||
|
4. **Submit pull request** to upstream repository
|
||||||
+13
-10
@@ -136,24 +136,27 @@ A patch for the `user_oidc` app is required to fix Bearer token support. See [oa
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Issue: "Permission denied" when reading/writing OAuth client credentials file
|
### Issue: "Permission denied" or "Database is locked" when accessing OAuth client storage
|
||||||
|
|
||||||
**Cause:** The server cannot access the OAuth client storage file (default: `.nextcloud_oauth_client.json`).
|
**Cause:** The server cannot access the SQLite database for OAuth client credentials storage.
|
||||||
|
|
||||||
**Solution:**
|
**Solution:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Check file permissions
|
# Check database directory permissions
|
||||||
ls -la .nextcloud_oauth_client.json
|
ls -la data/
|
||||||
|
|
||||||
# Fix file permissions (should be 0600 - owner read/write only)
|
# Ensure directory is writable
|
||||||
chmod 600 .nextcloud_oauth_client.json
|
chmod 755 data/
|
||||||
|
|
||||||
# Ensure the directory is writable
|
# Check if database file exists and has correct permissions
|
||||||
chmod 755 $(dirname .nextcloud_oauth_client.json)
|
ls -la data/tokens.db
|
||||||
|
chmod 644 data/tokens.db
|
||||||
|
|
||||||
# If the file doesn't exist, ensure the directory is writable so it can be created
|
# For Docker deployments, ensure volume is mounted correctly:
|
||||||
mkdir -p $(dirname .nextcloud_oauth_client.json)
|
# docker-compose.yml should have:
|
||||||
|
# volumes:
|
||||||
|
# - ./data:/app/data
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
+82
-1
@@ -8,12 +8,19 @@ NEXTCLOUD_HOST=
|
|||||||
# - Requires Nextcloud OIDC app installed and configured
|
# - Requires Nextcloud OIDC app installed and configured
|
||||||
# - Admin must enable "Dynamic Client Registration" in OIDC app settings
|
# - Admin must enable "Dynamic Client Registration" in OIDC app settings
|
||||||
# - Leave NEXTCLOUD_USERNAME and NEXTCLOUD_PASSWORD empty to use OAuth mode
|
# - Leave NEXTCLOUD_USERNAME and NEXTCLOUD_PASSWORD empty to use OAuth mode
|
||||||
|
# - OAuth client credentials are stored encrypted in SQLite (TOKEN_STORAGE_DB)
|
||||||
# - Optional: Pre-register client and provide credentials (otherwise auto-registers)
|
# - Optional: Pre-register client and provide credentials (otherwise auto-registers)
|
||||||
NEXTCLOUD_OIDC_CLIENT_ID=
|
NEXTCLOUD_OIDC_CLIENT_ID=
|
||||||
NEXTCLOUD_OIDC_CLIENT_SECRET=
|
NEXTCLOUD_OIDC_CLIENT_SECRET=
|
||||||
NEXTCLOUD_OIDC_CLIENT_STORAGE=.nextcloud_oauth_client.json
|
|
||||||
NEXTCLOUD_MCP_SERVER_URL=http://localhost:8000
|
NEXTCLOUD_MCP_SERVER_URL=http://localhost:8000
|
||||||
|
|
||||||
|
# OAuth Storage Configuration (SQLite storage for OAuth clients and refresh tokens)
|
||||||
|
# TOKEN_ENCRYPTION_KEY: Required for encrypting OAuth client secrets and refresh tokens
|
||||||
|
# Generate with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||||
|
#TOKEN_ENCRYPTION_KEY=
|
||||||
|
# TOKEN_STORAGE_DB: Path to SQLite database (default: /app/data/tokens.db)
|
||||||
|
#TOKEN_STORAGE_DB=/app/data/tokens.db
|
||||||
|
|
||||||
# Option 2: Basic Authentication (LEGACY - Less Secure)
|
# Option 2: Basic Authentication (LEGACY - Less Secure)
|
||||||
# - Requires username and password
|
# - Requires username and password
|
||||||
# - Credentials stored in environment variables
|
# - Credentials stored in environment variables
|
||||||
@@ -21,3 +28,77 @@ NEXTCLOUD_MCP_SERVER_URL=http://localhost:8000
|
|||||||
# - If these are set, OAuth mode is disabled
|
# - If these are set, OAuth mode is disabled
|
||||||
NEXTCLOUD_USERNAME=
|
NEXTCLOUD_USERNAME=
|
||||||
NEXTCLOUD_PASSWORD=
|
NEXTCLOUD_PASSWORD=
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Document Processing Configuration
|
||||||
|
# ============================================
|
||||||
|
# Enable document processing (PDF, DOCX, images, etc.)
|
||||||
|
# Set to false to disable all document processing
|
||||||
|
ENABLE_DOCUMENT_PROCESSING=false
|
||||||
|
|
||||||
|
# Default processor to use when multiple are available
|
||||||
|
# Options: unstructured, tesseract, custom
|
||||||
|
DOCUMENT_PROCESSOR=unstructured
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Unstructured.io Processor
|
||||||
|
# ============================================
|
||||||
|
# Enable Unstructured processor (requires unstructured service in docker-compose)
|
||||||
|
# This is a cloud-based/API processor supporting many document types
|
||||||
|
ENABLE_UNSTRUCTURED=false
|
||||||
|
|
||||||
|
# Unstructured API endpoint
|
||||||
|
UNSTRUCTURED_API_URL=http://unstructured:8000
|
||||||
|
|
||||||
|
# Request timeout in seconds (default: 120)
|
||||||
|
# OCR operations can take 30-120 seconds for large documents
|
||||||
|
UNSTRUCTURED_TIMEOUT=120
|
||||||
|
|
||||||
|
# Parsing strategy: auto, fast, hi_res
|
||||||
|
# - auto: Automatically choose based on document type
|
||||||
|
# - fast: Fast parsing without OCR
|
||||||
|
# - hi_res: High-resolution with OCR (slowest, most accurate)
|
||||||
|
UNSTRUCTURED_STRATEGY=auto
|
||||||
|
|
||||||
|
# OCR languages (comma-separated ISO 639-3 codes)
|
||||||
|
# Common: eng=English, deu=German, fra=French, spa=Spanish
|
||||||
|
UNSTRUCTURED_LANGUAGES=eng,deu
|
||||||
|
|
||||||
|
# Progress reporting interval in seconds (default: 10)
|
||||||
|
# During long-running OCR operations, progress notifications are sent to the MCP client
|
||||||
|
# at this interval to prevent timeouts and provide status updates
|
||||||
|
PROGRESS_INTERVAL=10
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Tesseract Processor (Local OCR)
|
||||||
|
# ============================================
|
||||||
|
# Enable Tesseract processor (requires tesseract binary installed)
|
||||||
|
# This is a local, lightweight OCR solution for images only
|
||||||
|
ENABLE_TESSERACT=false
|
||||||
|
|
||||||
|
# Path to tesseract executable (optional, auto-detected if in PATH)
|
||||||
|
#TESSERACT_CMD=/usr/bin/tesseract
|
||||||
|
|
||||||
|
# OCR language (e.g., eng, deu, eng+deu for multiple)
|
||||||
|
TESSERACT_LANG=eng
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Custom Processor (Your own API)
|
||||||
|
# ============================================
|
||||||
|
# Enable custom document processor via HTTP API
|
||||||
|
ENABLE_CUSTOM_PROCESSOR=false
|
||||||
|
|
||||||
|
# Unique name for your processor
|
||||||
|
#CUSTOM_PROCESSOR_NAME=my_ocr
|
||||||
|
|
||||||
|
# Your custom processor API endpoint
|
||||||
|
#CUSTOM_PROCESSOR_URL=http://localhost:9000/process
|
||||||
|
|
||||||
|
# Optional API key for authentication
|
||||||
|
#CUSTOM_PROCESSOR_API_KEY=your-api-key-here
|
||||||
|
|
||||||
|
# Request timeout in seconds
|
||||||
|
#CUSTOM_PROCESSOR_TIMEOUT=60
|
||||||
|
|
||||||
|
# Comma-separated MIME types your processor supports
|
||||||
|
#CUSTOM_PROCESSOR_TYPES=application/pdf,image/jpeg,image/png
|
||||||
|
|||||||
@@ -0,0 +1,783 @@
|
|||||||
|
{
|
||||||
|
"id": "nextcloud-mcp",
|
||||||
|
"realm": "nextcloud-mcp",
|
||||||
|
"notBefore": 0,
|
||||||
|
"defaultSignatureAlgorithm": "RS256",
|
||||||
|
"revokeRefreshToken": false,
|
||||||
|
"refreshTokenMaxReuse": 0,
|
||||||
|
"accessTokenLifespan": 300,
|
||||||
|
"accessTokenLifespanForImplicitFlow": 900,
|
||||||
|
"ssoSessionIdleTimeout": 1800,
|
||||||
|
"ssoSessionMaxLifespan": 36000,
|
||||||
|
"offlineSessionIdleTimeout": 2592000,
|
||||||
|
"offlineSessionMaxLifespanEnabled": false,
|
||||||
|
"offlineSessionMaxLifespan": 5184000,
|
||||||
|
"accessCodeLifespan": 60,
|
||||||
|
"accessCodeLifespanUserAction": 300,
|
||||||
|
"accessCodeLifespanLogin": 1800,
|
||||||
|
"enabled": true,
|
||||||
|
"sslRequired": "external",
|
||||||
|
"registrationAllowed": false,
|
||||||
|
"loginWithEmailAllowed": true,
|
||||||
|
"duplicateEmailsAllowed": false,
|
||||||
|
"resetPasswordAllowed": false,
|
||||||
|
"editUsernameAllowed": false,
|
||||||
|
"bruteForceProtected": false,
|
||||||
|
"attributes": {
|
||||||
|
"frontendUrl": "http://localhost:8888"
|
||||||
|
},
|
||||||
|
"roles": {
|
||||||
|
"realm": [
|
||||||
|
{
|
||||||
|
"name": "offline_access",
|
||||||
|
"description": "${role_offline-access}",
|
||||||
|
"composite": false,
|
||||||
|
"clientRole": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "uma_authorization",
|
||||||
|
"description": "${role_uma_authorization}",
|
||||||
|
"composite": false,
|
||||||
|
"clientRole": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "default-roles-nextcloud-mcp",
|
||||||
|
"description": "${role_default-roles}",
|
||||||
|
"composite": true,
|
||||||
|
"composites": {
|
||||||
|
"realm": [
|
||||||
|
"offline_access",
|
||||||
|
"uma_authorization"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"clientRole": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"users": [
|
||||||
|
{
|
||||||
|
"username": "admin",
|
||||||
|
"enabled": true,
|
||||||
|
"email": "admin@example.com",
|
||||||
|
"emailVerified": true,
|
||||||
|
"firstName": "Admin",
|
||||||
|
"lastName": "User",
|
||||||
|
"credentials": [
|
||||||
|
{
|
||||||
|
"type": "password",
|
||||||
|
"value": "admin",
|
||||||
|
"temporary": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"realmRoles": [
|
||||||
|
"default-roles-nextcloud-mcp",
|
||||||
|
"offline_access"
|
||||||
|
],
|
||||||
|
"attributes": {
|
||||||
|
"quota": [
|
||||||
|
"1073741824"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "test_read_only",
|
||||||
|
"enabled": true,
|
||||||
|
"email": "readonly@example.com",
|
||||||
|
"emailVerified": true,
|
||||||
|
"firstName": "Read",
|
||||||
|
"lastName": "Only",
|
||||||
|
"credentials": [
|
||||||
|
{
|
||||||
|
"type": "password",
|
||||||
|
"value": "test123",
|
||||||
|
"temporary": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"realmRoles": [
|
||||||
|
"default-roles-nextcloud-mcp",
|
||||||
|
"offline_access"
|
||||||
|
],
|
||||||
|
"attributes": {
|
||||||
|
"quota": [
|
||||||
|
"1073741824"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "test_write_only",
|
||||||
|
"enabled": true,
|
||||||
|
"email": "writeonly@example.com",
|
||||||
|
"emailVerified": true,
|
||||||
|
"firstName": "Write",
|
||||||
|
"lastName": "Only",
|
||||||
|
"credentials": [
|
||||||
|
{
|
||||||
|
"type": "password",
|
||||||
|
"value": "test123",
|
||||||
|
"temporary": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"realmRoles": [
|
||||||
|
"default-roles-nextcloud-mcp",
|
||||||
|
"offline_access"
|
||||||
|
],
|
||||||
|
"attributes": {
|
||||||
|
"quota": [
|
||||||
|
"1073741824"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "test_no_scopes",
|
||||||
|
"enabled": true,
|
||||||
|
"email": "noscopes@example.com",
|
||||||
|
"emailVerified": true,
|
||||||
|
"firstName": "No",
|
||||||
|
"lastName": "Scopes",
|
||||||
|
"credentials": [
|
||||||
|
{
|
||||||
|
"type": "password",
|
||||||
|
"value": "test123",
|
||||||
|
"temporary": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"realmRoles": [
|
||||||
|
"default-roles-nextcloud-mcp",
|
||||||
|
"offline_access"
|
||||||
|
],
|
||||||
|
"attributes": {
|
||||||
|
"quota": [
|
||||||
|
"1073741824"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "service-account-nextcloud-mcp-server",
|
||||||
|
"enabled": true,
|
||||||
|
"serviceAccountClientId": "nextcloud-mcp-server",
|
||||||
|
"clientRoles": {
|
||||||
|
"realm-management": [
|
||||||
|
"impersonation"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"clients": [
|
||||||
|
{
|
||||||
|
"clientId": "nextcloud",
|
||||||
|
"name": "Nextcloud Resource Server",
|
||||||
|
"description": "Resource server for Nextcloud APIs - used by user_oidc app for bearer token validation",
|
||||||
|
"enabled": true,
|
||||||
|
"clientAuthenticatorType": "client-secret",
|
||||||
|
"secret": "nextcloud-secret-change-in-production",
|
||||||
|
"redirectUris": [],
|
||||||
|
"webOrigins": [],
|
||||||
|
"bearerOnly": true,
|
||||||
|
"consentRequired": false,
|
||||||
|
"standardFlowEnabled": false,
|
||||||
|
"implicitFlowEnabled": false,
|
||||||
|
"directAccessGrantsEnabled": false,
|
||||||
|
"serviceAccountsEnabled": false,
|
||||||
|
"publicClient": false,
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"attributes": {
|
||||||
|
"display.on.consent.screen": "false"
|
||||||
|
},
|
||||||
|
"fullScopeAllowed": true,
|
||||||
|
"nodeReRegistrationTimeout": -1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"clientId": "nextcloud-mcp-server",
|
||||||
|
"name": "Nextcloud MCP Server",
|
||||||
|
"enabled": true,
|
||||||
|
"clientAuthenticatorType": "client-secret",
|
||||||
|
"secret": "mcp-secret-change-in-production",
|
||||||
|
"redirectUris": [
|
||||||
|
"http://localhost:*",
|
||||||
|
"http://127.0.0.1:*",
|
||||||
|
"http://localhost:*/callback",
|
||||||
|
"http://127.0.0.1:*/callback"
|
||||||
|
],
|
||||||
|
"webOrigins": [
|
||||||
|
"+"
|
||||||
|
],
|
||||||
|
"bearerOnly": false,
|
||||||
|
"consentRequired": false,
|
||||||
|
"standardFlowEnabled": true,
|
||||||
|
"implicitFlowEnabled": false,
|
||||||
|
"directAccessGrantsEnabled": true,
|
||||||
|
"serviceAccountsEnabled": true,
|
||||||
|
"publicClient": false,
|
||||||
|
"frontchannelLogout": false,
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"attributes": {
|
||||||
|
"pkce.code.challenge.method": "S256",
|
||||||
|
"use.refresh.tokens": "true",
|
||||||
|
"backchannel.logout.session.required": "true",
|
||||||
|
"backchannel.logout.url": "http://app:80/index.php/apps/user_oidc/backchannel-logout/keycloak",
|
||||||
|
"oauth2.device.authorization.grant.enabled": "false",
|
||||||
|
"oidc.ciba.grant.enabled": "false",
|
||||||
|
"client_credentials.use_refresh_token": "false",
|
||||||
|
"display.on.consent.screen": "false",
|
||||||
|
"token.exchange.grant.enabled": "true",
|
||||||
|
"client.token.exchange.standard.enabled": "true"
|
||||||
|
},
|
||||||
|
"fullScopeAllowed": true,
|
||||||
|
"nodeReRegistrationTimeout": -1,
|
||||||
|
"protocolMappers": [
|
||||||
|
{
|
||||||
|
"name": "audience-nextcloud",
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"protocolMapper": "oidc-audience-mapper",
|
||||||
|
"consentRequired": false,
|
||||||
|
"config": {
|
||||||
|
"included.custom.audience": "nextcloud",
|
||||||
|
"access.token.claim": "true",
|
||||||
|
"id.token.claim": "false"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "sub",
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"protocolMapper": "oidc-usermodel-property-mapper",
|
||||||
|
"consentRequired": false,
|
||||||
|
"config": {
|
||||||
|
"userinfo.token.claim": "true",
|
||||||
|
"user.attribute": "username",
|
||||||
|
"id.token.claim": "true",
|
||||||
|
"access.token.claim": "true",
|
||||||
|
"claim.name": "sub",
|
||||||
|
"jsonType.label": "String"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "full name",
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"protocolMapper": "oidc-full-name-mapper",
|
||||||
|
"consentRequired": false,
|
||||||
|
"config": {
|
||||||
|
"id.token.claim": "true",
|
||||||
|
"access.token.claim": "true",
|
||||||
|
"userinfo.token.claim": "true"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "email",
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"protocolMapper": "oidc-usermodel-property-mapper",
|
||||||
|
"consentRequired": false,
|
||||||
|
"config": {
|
||||||
|
"userinfo.token.claim": "true",
|
||||||
|
"user.attribute": "email",
|
||||||
|
"id.token.claim": "true",
|
||||||
|
"access.token.claim": "true",
|
||||||
|
"claim.name": "email",
|
||||||
|
"jsonType.label": "String"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "preferred_username",
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"protocolMapper": "oidc-usermodel-property-mapper",
|
||||||
|
"consentRequired": false,
|
||||||
|
"config": {
|
||||||
|
"userinfo.token.claim": "true",
|
||||||
|
"user.attribute": "username",
|
||||||
|
"id.token.claim": "true",
|
||||||
|
"access.token.claim": "true",
|
||||||
|
"claim.name": "preferred_username",
|
||||||
|
"jsonType.label": "String"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "quota",
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"protocolMapper": "oidc-usermodel-attribute-mapper",
|
||||||
|
"consentRequired": false,
|
||||||
|
"config": {
|
||||||
|
"userinfo.token.claim": "true",
|
||||||
|
"user.attribute": "quota",
|
||||||
|
"id.token.claim": "true",
|
||||||
|
"access.token.claim": "true",
|
||||||
|
"claim.name": "quota",
|
||||||
|
"jsonType.label": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"defaultClientScopes": [
|
||||||
|
"web-origins",
|
||||||
|
"profile",
|
||||||
|
"roles",
|
||||||
|
"email"
|
||||||
|
],
|
||||||
|
"optionalClientScopes": [
|
||||||
|
"address",
|
||||||
|
"phone",
|
||||||
|
"offline_access",
|
||||||
|
"microprofile-jwt",
|
||||||
|
"notes:read",
|
||||||
|
"notes:write",
|
||||||
|
"calendar:read",
|
||||||
|
"calendar:write",
|
||||||
|
"contacts:read",
|
||||||
|
"contacts:write",
|
||||||
|
"cookbook:read",
|
||||||
|
"cookbook:write",
|
||||||
|
"deck:read",
|
||||||
|
"deck:write",
|
||||||
|
"tables:read",
|
||||||
|
"tables:write",
|
||||||
|
"files:read",
|
||||||
|
"files:write",
|
||||||
|
"sharing:read",
|
||||||
|
"sharing:write",
|
||||||
|
"todo:read",
|
||||||
|
"todo:write"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"clientScopes": [
|
||||||
|
{
|
||||||
|
"name": "offline_access",
|
||||||
|
"description": "OpenID Connect built-in scope: offline_access",
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"attributes": {
|
||||||
|
"consent.screen.text": "${offlineAccessScopeConsentText}",
|
||||||
|
"display.on.consent.screen": "true"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "profile",
|
||||||
|
"description": "OpenID Connect built-in scope: profile",
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"attributes": {
|
||||||
|
"include.in.token.scope": "true",
|
||||||
|
"display.on.consent.screen": "true"
|
||||||
|
},
|
||||||
|
"protocolMappers": [
|
||||||
|
{
|
||||||
|
"name": "full name",
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"protocolMapper": "oidc-full-name-mapper",
|
||||||
|
"consentRequired": false,
|
||||||
|
"config": {
|
||||||
|
"id.token.claim": "true",
|
||||||
|
"access.token.claim": "true",
|
||||||
|
"userinfo.token.claim": "true"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "username",
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"protocolMapper": "oidc-usermodel-property-mapper",
|
||||||
|
"consentRequired": false,
|
||||||
|
"config": {
|
||||||
|
"userinfo.token.claim": "true",
|
||||||
|
"user.attribute": "username",
|
||||||
|
"id.token.claim": "true",
|
||||||
|
"access.token.claim": "true",
|
||||||
|
"claim.name": "preferred_username",
|
||||||
|
"jsonType.label": "String"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "given name",
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"protocolMapper": "oidc-usermodel-property-mapper",
|
||||||
|
"consentRequired": false,
|
||||||
|
"config": {
|
||||||
|
"userinfo.token.claim": "true",
|
||||||
|
"user.attribute": "firstName",
|
||||||
|
"id.token.claim": "true",
|
||||||
|
"access.token.claim": "true",
|
||||||
|
"claim.name": "given_name",
|
||||||
|
"jsonType.label": "String"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "family name",
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"protocolMapper": "oidc-usermodel-property-mapper",
|
||||||
|
"consentRequired": false,
|
||||||
|
"config": {
|
||||||
|
"userinfo.token.claim": "true",
|
||||||
|
"user.attribute": "lastName",
|
||||||
|
"id.token.claim": "true",
|
||||||
|
"access.token.claim": "true",
|
||||||
|
"claim.name": "family_name",
|
||||||
|
"jsonType.label": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "email",
|
||||||
|
"description": "OpenID Connect built-in scope: email",
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"attributes": {
|
||||||
|
"include.in.token.scope": "true",
|
||||||
|
"display.on.consent.screen": "true"
|
||||||
|
},
|
||||||
|
"protocolMappers": [
|
||||||
|
{
|
||||||
|
"name": "email",
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"protocolMapper": "oidc-usermodel-property-mapper",
|
||||||
|
"consentRequired": false,
|
||||||
|
"config": {
|
||||||
|
"userinfo.token.claim": "true",
|
||||||
|
"user.attribute": "email",
|
||||||
|
"id.token.claim": "true",
|
||||||
|
"access.token.claim": "true",
|
||||||
|
"claim.name": "email",
|
||||||
|
"jsonType.label": "String"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "email verified",
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"protocolMapper": "oidc-usermodel-property-mapper",
|
||||||
|
"consentRequired": false,
|
||||||
|
"config": {
|
||||||
|
"userinfo.token.claim": "true",
|
||||||
|
"user.attribute": "emailVerified",
|
||||||
|
"id.token.claim": "true",
|
||||||
|
"access.token.claim": "true",
|
||||||
|
"claim.name": "email_verified",
|
||||||
|
"jsonType.label": "boolean"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "roles",
|
||||||
|
"description": "OpenID Connect scope for add user roles to the access token",
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"attributes": {
|
||||||
|
"include.in.token.scope": "false",
|
||||||
|
"display.on.consent.screen": "true"
|
||||||
|
},
|
||||||
|
"protocolMappers": [
|
||||||
|
{
|
||||||
|
"name": "realm roles",
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"protocolMapper": "oidc-usermodel-realm-role-mapper",
|
||||||
|
"consentRequired": false,
|
||||||
|
"config": {
|
||||||
|
"user.attribute": "foo",
|
||||||
|
"access.token.claim": "true",
|
||||||
|
"claim.name": "realm_access.roles",
|
||||||
|
"jsonType.label": "String",
|
||||||
|
"multivalued": "true"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "client roles",
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"protocolMapper": "oidc-usermodel-client-role-mapper",
|
||||||
|
"consentRequired": false,
|
||||||
|
"config": {
|
||||||
|
"user.attribute": "foo",
|
||||||
|
"access.token.claim": "true",
|
||||||
|
"claim.name": "resource_access.${client_id}.roles",
|
||||||
|
"jsonType.label": "String",
|
||||||
|
"multivalued": "true"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "web-origins",
|
||||||
|
"description": "OpenID Connect scope for add allowed web origins to the access token",
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"attributes": {
|
||||||
|
"include.in.token.scope": "false",
|
||||||
|
"display.on.consent.screen": "false"
|
||||||
|
},
|
||||||
|
"protocolMappers": [
|
||||||
|
{
|
||||||
|
"name": "allowed web origins",
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"protocolMapper": "oidc-allowed-origins-mapper",
|
||||||
|
"consentRequired": false,
|
||||||
|
"config": {}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "notes:read",
|
||||||
|
"description": "Nextcloud Notes read access",
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"attributes": {
|
||||||
|
"include.in.token.scope": "true",
|
||||||
|
"display.on.consent.screen": "true",
|
||||||
|
"consent.screen.text": "Read your notes"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "notes:write",
|
||||||
|
"description": "Nextcloud Notes write access",
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"attributes": {
|
||||||
|
"include.in.token.scope": "true",
|
||||||
|
"display.on.consent.screen": "true",
|
||||||
|
"consent.screen.text": "Create, update, and delete your notes"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "calendar:read",
|
||||||
|
"description": "Nextcloud Calendar read access",
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"attributes": {
|
||||||
|
"include.in.token.scope": "true",
|
||||||
|
"display.on.consent.screen": "true",
|
||||||
|
"consent.screen.text": "Read your calendars and events"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "calendar:write",
|
||||||
|
"description": "Nextcloud Calendar write access",
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"attributes": {
|
||||||
|
"include.in.token.scope": "true",
|
||||||
|
"display.on.consent.screen": "true",
|
||||||
|
"consent.screen.text": "Create, update, and delete calendars and events"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "contacts:read",
|
||||||
|
"description": "Nextcloud Contacts read access",
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"attributes": {
|
||||||
|
"include.in.token.scope": "true",
|
||||||
|
"display.on.consent.screen": "true",
|
||||||
|
"consent.screen.text": "Read your contacts"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "contacts:write",
|
||||||
|
"description": "Nextcloud Contacts write access",
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"attributes": {
|
||||||
|
"include.in.token.scope": "true",
|
||||||
|
"display.on.consent.screen": "true",
|
||||||
|
"consent.screen.text": "Create, update, and delete contacts"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "cookbook:read",
|
||||||
|
"description": "Nextcloud Cookbook read access",
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"attributes": {
|
||||||
|
"include.in.token.scope": "true",
|
||||||
|
"display.on.consent.screen": "true",
|
||||||
|
"consent.screen.text": "Read your recipes"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "cookbook:write",
|
||||||
|
"description": "Nextcloud Cookbook write access",
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"attributes": {
|
||||||
|
"include.in.token.scope": "true",
|
||||||
|
"display.on.consent.screen": "true",
|
||||||
|
"consent.screen.text": "Create, update, and delete recipes"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "deck:read",
|
||||||
|
"description": "Nextcloud Deck read access",
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"attributes": {
|
||||||
|
"include.in.token.scope": "true",
|
||||||
|
"display.on.consent.screen": "true",
|
||||||
|
"consent.screen.text": "Read your boards and cards"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "deck:write",
|
||||||
|
"description": "Nextcloud Deck write access",
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"attributes": {
|
||||||
|
"include.in.token.scope": "true",
|
||||||
|
"display.on.consent.screen": "true",
|
||||||
|
"consent.screen.text": "Create, update, and delete boards and cards"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "tables:read",
|
||||||
|
"description": "Nextcloud Tables read access",
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"attributes": {
|
||||||
|
"include.in.token.scope": "true",
|
||||||
|
"display.on.consent.screen": "true",
|
||||||
|
"consent.screen.text": "Read your tables and rows"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "tables:write",
|
||||||
|
"description": "Nextcloud Tables write access",
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"attributes": {
|
||||||
|
"include.in.token.scope": "true",
|
||||||
|
"display.on.consent.screen": "true",
|
||||||
|
"consent.screen.text": "Create, update, and delete tables and rows"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "files:read",
|
||||||
|
"description": "Nextcloud Files read access",
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"attributes": {
|
||||||
|
"include.in.token.scope": "true",
|
||||||
|
"display.on.consent.screen": "true",
|
||||||
|
"consent.screen.text": "Read your files"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "files:write",
|
||||||
|
"description": "Nextcloud Files write access",
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"attributes": {
|
||||||
|
"include.in.token.scope": "true",
|
||||||
|
"display.on.consent.screen": "true",
|
||||||
|
"consent.screen.text": "Upload, update, and delete files"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "sharing:read",
|
||||||
|
"description": "Nextcloud Sharing read access",
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"attributes": {
|
||||||
|
"include.in.token.scope": "true",
|
||||||
|
"display.on.consent.screen": "true",
|
||||||
|
"consent.screen.text": "View shared resources"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "sharing:write",
|
||||||
|
"description": "Nextcloud Sharing write access",
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"attributes": {
|
||||||
|
"include.in.token.scope": "true",
|
||||||
|
"display.on.consent.screen": "true",
|
||||||
|
"consent.screen.text": "Create and manage shares"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "todo:read",
|
||||||
|
"description": "Nextcloud Tasks/Todo read access",
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"attributes": {
|
||||||
|
"include.in.token.scope": "true",
|
||||||
|
"display.on.consent.screen": "true",
|
||||||
|
"consent.screen.text": "Read your tasks"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "todo:write",
|
||||||
|
"description": "Nextcloud Tasks/Todo write access",
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"attributes": {
|
||||||
|
"include.in.token.scope": "true",
|
||||||
|
"display.on.consent.screen": "true",
|
||||||
|
"consent.screen.text": "Create, update, and delete tasks"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "audience",
|
||||||
|
"description": "Audience scope for token validation",
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"attributes": {
|
||||||
|
"include.in.token.scope": "true",
|
||||||
|
"display.on.consent.screen": "false"
|
||||||
|
},
|
||||||
|
"protocolMappers": [
|
||||||
|
{
|
||||||
|
"name": "mcp-server-audience",
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"protocolMapper": "oidc-audience-mapper",
|
||||||
|
"consentRequired": false,
|
||||||
|
"config": {
|
||||||
|
"included.client.audience": "nextcloud-mcp-server",
|
||||||
|
"id.token.claim": "false",
|
||||||
|
"access.token.claim": "true"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "nextcloud-audience",
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"protocolMapper": "oidc-audience-mapper",
|
||||||
|
"consentRequired": false,
|
||||||
|
"config": {
|
||||||
|
"included.client.audience": "nextcloud",
|
||||||
|
"id.token.claim": "false",
|
||||||
|
"access.token.claim": "true"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"components": {
|
||||||
|
"org.keycloak.services.clientregistration.policy.ClientRegistrationPolicy": [
|
||||||
|
{
|
||||||
|
"name": "Trusted Hosts",
|
||||||
|
"providerId": "trusted-hosts",
|
||||||
|
"subType": "anonymous",
|
||||||
|
"subComponents": {},
|
||||||
|
"config": {
|
||||||
|
"trusted-hosts": [
|
||||||
|
"localhost",
|
||||||
|
"127.0.0.1",
|
||||||
|
"172.19.0.1"
|
||||||
|
],
|
||||||
|
"host-sending-registration-request-must-match": [
|
||||||
|
"false"
|
||||||
|
],
|
||||||
|
"client-uris-must-match": [
|
||||||
|
"true"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Max Clients",
|
||||||
|
"providerId": "max-clients",
|
||||||
|
"subType": "anonymous",
|
||||||
|
"subComponents": {},
|
||||||
|
"config": {
|
||||||
|
"max-clients": [
|
||||||
|
"200"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"defaultDefaultClientScopes": [
|
||||||
|
"profile",
|
||||||
|
"email",
|
||||||
|
"roles",
|
||||||
|
"web-origins",
|
||||||
|
"audience"
|
||||||
|
],
|
||||||
|
"defaultOptionalClientScopes": [
|
||||||
|
"offline_access",
|
||||||
|
"notes:read",
|
||||||
|
"notes:write",
|
||||||
|
"calendar:read",
|
||||||
|
"calendar:write",
|
||||||
|
"contacts:read",
|
||||||
|
"contacts:write",
|
||||||
|
"cookbook:read",
|
||||||
|
"cookbook:write",
|
||||||
|
"deck:read",
|
||||||
|
"deck:write",
|
||||||
|
"tables:read",
|
||||||
|
"tables:write",
|
||||||
|
"files:read",
|
||||||
|
"files:write",
|
||||||
|
"sharing:read",
|
||||||
|
"sharing:write",
|
||||||
|
"todo:read",
|
||||||
|
"todo:write"
|
||||||
|
]
|
||||||
|
}
|
||||||
+794
-186
File diff suppressed because it is too large
Load Diff
@@ -1,14 +1,34 @@
|
|||||||
"""OAuth authentication components for Nextcloud MCP server."""
|
"""OAuth authentication components for Nextcloud MCP server."""
|
||||||
|
|
||||||
from .bearer_auth import BearerAuth
|
from .bearer_auth import BearerAuth
|
||||||
from .client_registration import load_or_register_client, register_client
|
from .client_registration import ensure_oauth_client, register_client
|
||||||
from .context_helper import get_client_from_context
|
from .context_helper import get_client_from_context
|
||||||
|
from .scope_authorization import (
|
||||||
|
InsufficientScopeError,
|
||||||
|
ScopeAuthorizationError,
|
||||||
|
check_scopes,
|
||||||
|
discover_all_scopes,
|
||||||
|
get_access_token_scopes,
|
||||||
|
get_required_scopes,
|
||||||
|
has_required_scopes,
|
||||||
|
is_jwt_token,
|
||||||
|
require_scopes,
|
||||||
|
)
|
||||||
from .token_verifier import NextcloudTokenVerifier
|
from .token_verifier import NextcloudTokenVerifier
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"BearerAuth",
|
"BearerAuth",
|
||||||
"NextcloudTokenVerifier",
|
"NextcloudTokenVerifier",
|
||||||
"register_client",
|
"register_client",
|
||||||
"load_or_register_client",
|
"ensure_oauth_client",
|
||||||
"get_client_from_context",
|
"get_client_from_context",
|
||||||
|
"require_scopes",
|
||||||
|
"ScopeAuthorizationError",
|
||||||
|
"InsufficientScopeError",
|
||||||
|
"check_scopes",
|
||||||
|
"discover_all_scopes",
|
||||||
|
"get_access_token_scopes",
|
||||||
|
"get_required_scopes",
|
||||||
|
"has_required_scopes",
|
||||||
|
"is_jwt_token",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,19 +1,20 @@
|
|||||||
"""Dynamic client registration for Nextcloud OIDC."""
|
"""Dynamic client registration for Nextcloud OIDC."""
|
||||||
|
|
||||||
import json
|
import datetime as dt
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
import anyio
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.auth.refresh_token_storage import RefreshTokenStorage
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class ClientInfo:
|
class ClientInfo:
|
||||||
"""Client registration information."""
|
"""Client registration information with RFC 7592 support."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -22,12 +23,16 @@ class ClientInfo:
|
|||||||
client_id_issued_at: int,
|
client_id_issued_at: int,
|
||||||
client_secret_expires_at: int,
|
client_secret_expires_at: int,
|
||||||
redirect_uris: list[str],
|
redirect_uris: list[str],
|
||||||
|
registration_access_token: str | None = None,
|
||||||
|
registration_client_uri: str | None = None,
|
||||||
):
|
):
|
||||||
self.client_id = client_id
|
self.client_id = client_id
|
||||||
self.client_secret = client_secret
|
self.client_secret = client_secret
|
||||||
self.client_id_issued_at = client_id_issued_at
|
self.client_id_issued_at = client_id_issued_at
|
||||||
self.client_secret_expires_at = client_secret_expires_at
|
self.client_secret_expires_at = client_secret_expires_at
|
||||||
self.redirect_uris = redirect_uris
|
self.redirect_uris = redirect_uris
|
||||||
|
self.registration_access_token = registration_access_token
|
||||||
|
self.registration_client_uri = registration_client_uri
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_expired(self) -> bool:
|
def is_expired(self) -> bool:
|
||||||
@@ -41,13 +46,18 @@ class ClientInfo:
|
|||||||
|
|
||||||
def to_dict(self) -> dict[str, Any]:
|
def to_dict(self) -> dict[str, Any]:
|
||||||
"""Convert to dictionary for storage."""
|
"""Convert to dictionary for storage."""
|
||||||
return {
|
result = {
|
||||||
"client_id": self.client_id,
|
"client_id": self.client_id,
|
||||||
"client_secret": self.client_secret,
|
"client_secret": self.client_secret,
|
||||||
"client_id_issued_at": self.client_id_issued_at,
|
"client_id_issued_at": self.client_id_issued_at,
|
||||||
"client_secret_expires_at": self.client_secret_expires_at,
|
"client_secret_expires_at": self.client_secret_expires_at,
|
||||||
"redirect_uris": self.redirect_uris,
|
"redirect_uris": self.redirect_uris,
|
||||||
}
|
}
|
||||||
|
if self.registration_access_token:
|
||||||
|
result["registration_access_token"] = self.registration_access_token
|
||||||
|
if self.registration_client_uri:
|
||||||
|
result["registration_client_uri"] = self.registration_client_uri
|
||||||
|
return result
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, data: dict[str, Any]) -> "ClientInfo":
|
def from_dict(cls, data: dict[str, Any]) -> "ClientInfo":
|
||||||
@@ -58,6 +68,8 @@ class ClientInfo:
|
|||||||
client_id_issued_at=data["client_id_issued_at"],
|
client_id_issued_at=data["client_id_issued_at"],
|
||||||
client_secret_expires_at=data["client_secret_expires_at"],
|
client_secret_expires_at=data["client_secret_expires_at"],
|
||||||
redirect_uris=data["redirect_uris"],
|
redirect_uris=data["redirect_uris"],
|
||||||
|
registration_access_token=data.get("registration_access_token"),
|
||||||
|
registration_client_uri=data.get("registration_client_uri"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -67,6 +79,7 @@ async def register_client(
|
|||||||
client_name: str = "Nextcloud MCP Server",
|
client_name: str = "Nextcloud MCP Server",
|
||||||
redirect_uris: list[str] | None = None,
|
redirect_uris: list[str] | None = None,
|
||||||
scopes: str = "openid profile email",
|
scopes: str = "openid profile email",
|
||||||
|
token_type: str = "Bearer",
|
||||||
) -> ClientInfo:
|
) -> ClientInfo:
|
||||||
"""
|
"""
|
||||||
Register a new OAuth client with Nextcloud OIDC using dynamic client registration.
|
Register a new OAuth client with Nextcloud OIDC using dynamic client registration.
|
||||||
@@ -77,6 +90,7 @@ async def register_client(
|
|||||||
client_name: Name of the client application
|
client_name: Name of the client application
|
||||||
redirect_uris: List of redirect URIs (default: http://localhost:8000/oauth/callback)
|
redirect_uris: List of redirect URIs (default: http://localhost:8000/oauth/callback)
|
||||||
scopes: Space-separated list of scopes to request
|
scopes: Space-separated list of scopes to request
|
||||||
|
token_type: Type of access tokens to issue (default: "Bearer", also supports "JWT")
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
ClientInfo with registration details
|
ClientInfo with registration details
|
||||||
@@ -95,6 +109,7 @@ async def register_client(
|
|||||||
"grant_types": ["authorization_code", "refresh_token"],
|
"grant_types": ["authorization_code", "refresh_token"],
|
||||||
"response_types": ["code"],
|
"response_types": ["code"],
|
||||||
"scope": scopes,
|
"scope": scopes,
|
||||||
|
"token_type": token_type,
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(f"Registering OAuth client with Nextcloud: {client_name}")
|
logger.info(f"Registering OAuth client with Nextcloud: {client_name}")
|
||||||
@@ -113,11 +128,24 @@ async def register_client(
|
|||||||
logger.info(
|
logger.info(
|
||||||
f"Successfully registered client: {client_info.get('client_id')}"
|
f"Successfully registered client: {client_info.get('client_id')}"
|
||||||
)
|
)
|
||||||
|
expires_at = dt.datetime.fromtimestamp(
|
||||||
|
client_info.get("client_secret_expires_at")
|
||||||
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Client expires at: {client_info.get('client_secret_expires_at')} "
|
f"Client expires at: {expires_at} "
|
||||||
f"(in {client_info.get('client_secret_expires_at', 0) - int(time.time())} seconds)"
|
f"(in {client_info.get('client_secret_expires_at', 0) - int(time.time())} seconds)"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Log if RFC 7592 fields are present
|
||||||
|
has_reg_token = "registration_access_token" in client_info
|
||||||
|
has_reg_uri = "registration_client_uri" in client_info
|
||||||
|
if has_reg_token and has_reg_uri:
|
||||||
|
logger.info(
|
||||||
|
"RFC 7592 management fields received - client deletion will be supported"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.warning("RFC 7592 fields missing - client deletion may not work")
|
||||||
|
|
||||||
return ClientInfo(
|
return ClientInfo(
|
||||||
client_id=client_info["client_id"],
|
client_id=client_info["client_id"],
|
||||||
client_secret=client_info["client_secret"],
|
client_secret=client_info["client_secret"],
|
||||||
@@ -128,6 +156,8 @@ async def register_client(
|
|||||||
"client_secret_expires_at", int(time.time()) + 3600
|
"client_secret_expires_at", int(time.time()) + 3600
|
||||||
),
|
),
|
||||||
redirect_uris=client_info.get("redirect_uris", redirect_uris),
|
redirect_uris=client_info.get("redirect_uris", redirect_uris),
|
||||||
|
registration_access_token=client_info.get("registration_access_token"),
|
||||||
|
registration_client_uri=client_info.get("registration_client_uri"),
|
||||||
)
|
)
|
||||||
|
|
||||||
except httpx.HTTPStatusError as e:
|
except httpx.HTTPStatusError as e:
|
||||||
@@ -139,96 +169,158 @@ async def register_client(
|
|||||||
raise ValueError(f"Invalid registration response: missing {e}")
|
raise ValueError(f"Invalid registration response: missing {e}")
|
||||||
|
|
||||||
|
|
||||||
def load_client_from_file(storage_path: Path) -> ClientInfo | None:
|
async def delete_client(
|
||||||
|
nextcloud_url: str,
|
||||||
|
client_id: str,
|
||||||
|
registration_access_token: str | None = None,
|
||||||
|
client_secret: str | None = None,
|
||||||
|
registration_client_uri: str | None = None,
|
||||||
|
max_retries: int = 3,
|
||||||
|
) -> bool:
|
||||||
"""
|
"""
|
||||||
Load client credentials from storage file.
|
Delete a dynamically registered OAuth client using RFC 7592.
|
||||||
|
|
||||||
|
This implements RFC 7592 Section 2.3 (Client Delete Request).
|
||||||
|
Prefers Bearer token authentication (RFC 7592 standard) but falls back
|
||||||
|
to HTTP Basic Auth if registration_access_token is not available.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
storage_path: Path to the JSON file containing client credentials
|
nextcloud_url: Base URL of the Nextcloud instance
|
||||||
|
client_id: Client identifier to delete
|
||||||
|
registration_access_token: RFC 7592 registration access token (preferred)
|
||||||
|
client_secret: Client secret for fallback HTTP Basic Auth
|
||||||
|
registration_client_uri: RFC 7592 client configuration URI (optional)
|
||||||
|
max_retries: Maximum number of retries for 429 responses (default: 3)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
ClientInfo if file exists and is valid, None otherwise
|
True if deletion successful, False otherwise
|
||||||
|
|
||||||
|
Note:
|
||||||
|
RFC 7592 deletion endpoint: {registration_client_uri} or {nextcloud_url}/apps/oidc/register/{client_id}
|
||||||
|
|
||||||
|
Authentication methods (in order of preference):
|
||||||
|
1. Bearer token: Authorization: Bearer {registration_access_token} (RFC 7592 standard)
|
||||||
|
2. HTTP Basic Auth: client_id as username, client_secret as password (fallback)
|
||||||
"""
|
"""
|
||||||
if not storage_path.exists():
|
|
||||||
logger.debug(f"Client storage file not found: {storage_path}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
try:
|
# Determine deletion endpoint
|
||||||
with open(storage_path, "r") as f:
|
if registration_client_uri:
|
||||||
data = json.load(f)
|
deletion_endpoint = registration_client_uri
|
||||||
|
else:
|
||||||
|
deletion_endpoint = f"{nextcloud_url}/apps/oidc/register/{client_id}"
|
||||||
|
|
||||||
client_info = ClientInfo.from_dict(data)
|
logger.info(f"Deleting OAuth client: {client_id[:16]}...")
|
||||||
|
logger.debug(f"Deletion endpoint: {deletion_endpoint}")
|
||||||
|
|
||||||
if client_info.is_expired:
|
async with httpx.AsyncClient(timeout=30.0) as http_client:
|
||||||
logger.warning(
|
for attempt in range(max_retries):
|
||||||
f"Stored client has expired (expired at {client_info.client_secret_expires_at})"
|
try:
|
||||||
)
|
# Prefer RFC 7592 Bearer token authentication
|
||||||
return None
|
if registration_access_token:
|
||||||
|
logger.debug("Using RFC 7592 Bearer token authentication")
|
||||||
|
response = await http_client.delete(
|
||||||
|
deletion_endpoint,
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {registration_access_token}"
|
||||||
|
},
|
||||||
|
)
|
||||||
|
elif client_secret:
|
||||||
|
logger.debug(
|
||||||
|
"Falling back to HTTP Basic Auth (registration_access_token not available)"
|
||||||
|
)
|
||||||
|
response = await http_client.delete(
|
||||||
|
deletion_endpoint,
|
||||||
|
auth=(client_id, client_secret),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.error(
|
||||||
|
"Cannot delete client: no registration_access_token or client_secret provided"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
logger.info(f"Loaded client from storage: {client_info.client_id[:16]}...")
|
# RFC 7592: Successful deletion returns 204 No Content
|
||||||
if client_info.expires_soon:
|
if response.status_code == 204:
|
||||||
logger.warning("Client expires soon (within 5 minutes)")
|
logger.info(
|
||||||
|
f"Successfully deleted OAuth client: {client_id[:16]}..."
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
elif response.status_code == 429:
|
||||||
|
# Rate limited - retry with exponential backoff
|
||||||
|
if attempt < max_retries - 1:
|
||||||
|
retry_after = int(response.headers.get("Retry-After", 2))
|
||||||
|
wait_time = min(
|
||||||
|
retry_after, 2**attempt
|
||||||
|
) # Exponential backoff, max from header
|
||||||
|
logger.warning(
|
||||||
|
f"Rate limited (429) deleting client {client_id[:16]}..., "
|
||||||
|
f"retrying in {wait_time}s (attempt {attempt + 1}/{max_retries})"
|
||||||
|
)
|
||||||
|
await anyio.sleep(wait_time)
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
logger.error(
|
||||||
|
f"Failed to delete client {client_id[:16]}... after {max_retries} attempts: Rate limited (429)"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
elif response.status_code == 401:
|
||||||
|
logger.error(
|
||||||
|
f"Failed to delete client {client_id[:16]}...: Authentication failed (invalid credentials)"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
elif response.status_code == 403:
|
||||||
|
logger.error(
|
||||||
|
f"Failed to delete client {client_id[:16]}...: Not authorized (not a DCR client or wrong client)"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
logger.error(
|
||||||
|
f"Failed to delete client {client_id[:16]}...: HTTP {response.status_code}"
|
||||||
|
)
|
||||||
|
logger.debug(f"Response: {response.text}")
|
||||||
|
return False
|
||||||
|
|
||||||
return client_info
|
except httpx.HTTPStatusError as e:
|
||||||
|
logger.error(
|
||||||
|
f"HTTP error deleting client {client_id[:16]}...: {e.response.status_code}"
|
||||||
|
)
|
||||||
|
logger.debug(f"Response: {e.response.text}")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"Unexpected error deleting client {client_id[:16]}...: {e}"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
except (json.JSONDecodeError, KeyError, ValueError) as e:
|
# Should not reach here, but return False if we do
|
||||||
logger.error(f"Failed to load client from file: {e}")
|
return False
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def save_client_to_file(client_info: ClientInfo, storage_path: Path):
|
async def ensure_oauth_client(
|
||||||
"""
|
|
||||||
Save client credentials to storage file.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
client_info: Client information to save
|
|
||||||
storage_path: Path to save the JSON file
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
OSError: If file cannot be written
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# Create directory if it doesn't exist
|
|
||||||
storage_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
# Write client info
|
|
||||||
with open(storage_path, "w") as f:
|
|
||||||
json.dump(client_info.to_dict(), f, indent=2)
|
|
||||||
|
|
||||||
# Set restrictive permissions (owner read/write only)
|
|
||||||
os.chmod(storage_path, 0o600)
|
|
||||||
|
|
||||||
logger.info(f"Saved client credentials to {storage_path}")
|
|
||||||
|
|
||||||
except OSError as e:
|
|
||||||
logger.error(f"Failed to save client credentials: {e}")
|
|
||||||
raise
|
|
||||||
|
|
||||||
|
|
||||||
async def load_or_register_client(
|
|
||||||
nextcloud_url: str,
|
nextcloud_url: str,
|
||||||
registration_endpoint: str,
|
registration_endpoint: str,
|
||||||
storage_path: str | Path,
|
storage: RefreshTokenStorage,
|
||||||
client_name: str = "Nextcloud MCP Server",
|
client_name: str = "Nextcloud MCP Server",
|
||||||
redirect_uris: list[str] | None = None,
|
redirect_uris: list[str] | None = None,
|
||||||
force_register: bool = True,
|
scopes: str = "openid profile email",
|
||||||
|
token_type: str = "Bearer",
|
||||||
) -> ClientInfo:
|
) -> ClientInfo:
|
||||||
"""
|
"""
|
||||||
Load client from storage or register a new one if not found/expired.
|
Ensure OAuth client exists in SQLite storage.
|
||||||
|
|
||||||
This function:
|
This function:
|
||||||
1. Checks for existing client credentials in storage
|
1. Checks for existing client credentials in SQLite storage
|
||||||
2. Validates the credentials are not expired
|
2. Validates the credentials are not expired
|
||||||
3. Registers a new client if needed
|
3. Registers a new client if needed (no stored credentials or expired)
|
||||||
4. Saves the new client credentials
|
4. Saves the new client credentials to SQLite
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
nextcloud_url: Base URL of the Nextcloud instance
|
nextcloud_url: Base URL of the Nextcloud instance
|
||||||
registration_endpoint: Full URL to the registration endpoint
|
registration_endpoint: Full URL to the registration endpoint
|
||||||
storage_path: Path to store client credentials
|
storage: RefreshTokenStorage instance for SQLite storage
|
||||||
client_name: Name of the client application
|
client_name: Name of the client application
|
||||||
redirect_uris: List of redirect URIs
|
redirect_uris: List of redirect URIs
|
||||||
force_register: Force registration even if valid credentials exist
|
scopes: Space-separated list of scopes to request (default: "openid profile email")
|
||||||
|
token_type: Type of access tokens to issue (default: "Bearer", also supports "JWT")
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
ClientInfo with valid credentials
|
ClientInfo with valid credentials
|
||||||
@@ -237,13 +329,13 @@ async def load_or_register_client(
|
|||||||
httpx.HTTPStatusError: If registration fails
|
httpx.HTTPStatusError: If registration fails
|
||||||
ValueError: If response is invalid
|
ValueError: If response is invalid
|
||||||
"""
|
"""
|
||||||
storage_path = Path(storage_path)
|
# Try to load existing client from SQLite
|
||||||
|
client_data = await storage.get_oauth_client()
|
||||||
# Try to load existing client unless forced to register
|
if client_data:
|
||||||
if not force_register:
|
logger.info(
|
||||||
client_info = load_client_from_file(storage_path)
|
f"Loaded OAuth client from SQLite: {client_data['client_id'][:16]}..."
|
||||||
if client_info:
|
)
|
||||||
return client_info
|
return ClientInfo.from_dict(client_data)
|
||||||
|
|
||||||
# Register new client
|
# Register new client
|
||||||
logger.info("Registering new OAuth client...")
|
logger.info("Registering new OAuth client...")
|
||||||
@@ -252,9 +344,19 @@ async def load_or_register_client(
|
|||||||
registration_endpoint=registration_endpoint,
|
registration_endpoint=registration_endpoint,
|
||||||
client_name=client_name,
|
client_name=client_name,
|
||||||
redirect_uris=redirect_uris,
|
redirect_uris=redirect_uris,
|
||||||
|
scopes=scopes,
|
||||||
|
token_type=token_type,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Save to storage
|
# Save to SQLite storage
|
||||||
save_client_to_file(client_info, storage_path)
|
await storage.store_oauth_client(
|
||||||
|
client_id=client_info.client_id,
|
||||||
|
client_secret=client_info.client_secret,
|
||||||
|
client_id_issued_at=client_info.client_id_issued_at,
|
||||||
|
client_secret_expires_at=client_info.client_secret_expires_at,
|
||||||
|
redirect_uris=client_info.redirect_uris,
|
||||||
|
registration_access_token=client_info.registration_access_token,
|
||||||
|
registration_client_uri=client_info.registration_client_uri,
|
||||||
|
)
|
||||||
|
|
||||||
return client_info
|
return client_info
|
||||||
|
|||||||
@@ -0,0 +1,581 @@
|
|||||||
|
"""
|
||||||
|
Keycloak OAuth 2.0 / OIDC Client
|
||||||
|
|
||||||
|
Handles OAuth flows with Keycloak as the identity provider, including:
|
||||||
|
- OIDC Discovery
|
||||||
|
- Authorization Code Flow with PKCE
|
||||||
|
- Token refresh using refresh tokens (ADR-002 Tier 1)
|
||||||
|
- Integration with RefreshTokenStorage
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
from typing import Optional
|
||||||
|
from urllib.parse import urlencode, urlparse
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class KeycloakOAuthClient:
|
||||||
|
"""OAuth 2.0 client for Keycloak integration"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
keycloak_url: str,
|
||||||
|
realm: str,
|
||||||
|
client_id: str,
|
||||||
|
client_secret: str,
|
||||||
|
redirect_uri: str,
|
||||||
|
scopes: Optional[list[str]] = None,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize Keycloak OAuth client.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
keycloak_url: Base URL of Keycloak (e.g., http://keycloak:8080)
|
||||||
|
realm: Keycloak realm name
|
||||||
|
client_id: OAuth client ID
|
||||||
|
client_secret: OAuth client secret
|
||||||
|
redirect_uri: OAuth redirect URI
|
||||||
|
scopes: List of scopes to request (default: openid, profile, email, offline_access)
|
||||||
|
"""
|
||||||
|
self.keycloak_url = keycloak_url.rstrip("/")
|
||||||
|
self.realm = realm
|
||||||
|
self.client_id = client_id
|
||||||
|
self.client_secret = client_secret
|
||||||
|
self.redirect_uri = redirect_uri
|
||||||
|
self.scopes = scopes or ["openid", "profile", "email", "offline_access"]
|
||||||
|
|
||||||
|
# Discovered endpoints (populated by discover())
|
||||||
|
self.authorization_endpoint: Optional[str] = None
|
||||||
|
self.token_endpoint: Optional[str] = None
|
||||||
|
self.userinfo_endpoint: Optional[str] = None
|
||||||
|
self.jwks_uri: Optional[str] = None
|
||||||
|
self.end_session_endpoint: Optional[str] = None
|
||||||
|
|
||||||
|
self._http_client: Optional[httpx.AsyncClient] = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_env(cls) -> "KeycloakOAuthClient":
|
||||||
|
"""
|
||||||
|
Create client from environment variables.
|
||||||
|
|
||||||
|
Environment variables:
|
||||||
|
KEYCLOAK_URL: Keycloak base URL
|
||||||
|
KEYCLOAK_REALM: Realm name
|
||||||
|
KEYCLOAK_CLIENT_ID: Client ID
|
||||||
|
KEYCLOAK_CLIENT_SECRET: Client secret
|
||||||
|
NEXTCLOUD_MCP_SERVER_URL: MCP server URL (for redirect URI)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
KeycloakOAuthClient instance
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If required environment variables are missing
|
||||||
|
"""
|
||||||
|
keycloak_url = os.getenv("KEYCLOAK_URL")
|
||||||
|
realm = os.getenv("KEYCLOAK_REALM")
|
||||||
|
client_id = os.getenv("KEYCLOAK_CLIENT_ID")
|
||||||
|
client_secret = os.getenv("KEYCLOAK_CLIENT_SECRET")
|
||||||
|
server_url = os.getenv("NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000")
|
||||||
|
|
||||||
|
if not all([keycloak_url, realm, client_id, client_secret]):
|
||||||
|
raise ValueError(
|
||||||
|
"Missing required environment variables: "
|
||||||
|
"KEYCLOAK_URL, KEYCLOAK_REALM, KEYCLOAK_CLIENT_ID, KEYCLOAK_CLIENT_SECRET"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Parse server URL to construct redirect URI
|
||||||
|
parsed_url = urlparse(server_url)
|
||||||
|
redirect_uri = f"{parsed_url.scheme}://{parsed_url.netloc}/oauth/callback"
|
||||||
|
|
||||||
|
return cls(
|
||||||
|
keycloak_url=keycloak_url,
|
||||||
|
realm=realm,
|
||||||
|
client_id=client_id,
|
||||||
|
client_secret=client_secret,
|
||||||
|
redirect_uri=redirect_uri,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _get_http_client(self) -> httpx.AsyncClient:
|
||||||
|
"""Get or create HTTP client"""
|
||||||
|
if self._http_client is None:
|
||||||
|
self._http_client = httpx.AsyncClient(timeout=30.0)
|
||||||
|
return self._http_client
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
"""Close HTTP client"""
|
||||||
|
if self._http_client:
|
||||||
|
await self._http_client.aclose()
|
||||||
|
self._http_client = None
|
||||||
|
|
||||||
|
async def discover(self) -> None:
|
||||||
|
"""
|
||||||
|
Perform OIDC discovery to get endpoint URLs.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
httpx.HTTPError: If discovery fails
|
||||||
|
"""
|
||||||
|
discovery_url = (
|
||||||
|
f"{self.keycloak_url}/realms/{self.realm}/.well-known/openid-configuration"
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Discovering Keycloak endpoints at {discovery_url}")
|
||||||
|
|
||||||
|
client = await self._get_http_client()
|
||||||
|
response = await client.get(discovery_url)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
discovery_data = response.json()
|
||||||
|
|
||||||
|
self.authorization_endpoint = discovery_data["authorization_endpoint"]
|
||||||
|
self.token_endpoint = discovery_data["token_endpoint"]
|
||||||
|
self.userinfo_endpoint = discovery_data["userinfo_endpoint"]
|
||||||
|
self.jwks_uri = discovery_data.get("jwks_uri")
|
||||||
|
self.end_session_endpoint = discovery_data.get("end_session_endpoint")
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"✓ Discovered Keycloak endpoints:\n"
|
||||||
|
f" Authorization: {self.authorization_endpoint}\n"
|
||||||
|
f" Token: {self.token_endpoint}\n"
|
||||||
|
f" Userinfo: {self.userinfo_endpoint}\n"
|
||||||
|
f" JWKS: {self.jwks_uri}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def generate_pkce_challenge(self) -> tuple[str, str]:
|
||||||
|
"""
|
||||||
|
Generate PKCE code verifier and challenge.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (code_verifier, code_challenge)
|
||||||
|
"""
|
||||||
|
import base64
|
||||||
|
|
||||||
|
# Generate code verifier (43-128 characters)
|
||||||
|
code_verifier = secrets.token_urlsafe(32)
|
||||||
|
|
||||||
|
# Generate code challenge using S256 method (base64url-encoded SHA256)
|
||||||
|
digest = hashlib.sha256(code_verifier.encode()).digest()
|
||||||
|
code_challenge = base64.urlsafe_b64encode(digest).decode().rstrip("=")
|
||||||
|
|
||||||
|
return code_verifier, code_challenge
|
||||||
|
|
||||||
|
async def get_authorization_url(
|
||||||
|
self,
|
||||||
|
state: str,
|
||||||
|
code_challenge: str,
|
||||||
|
extra_params: Optional[dict[str, str]] = None,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Build authorization URL for OAuth flow.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
state: CSRF protection state parameter
|
||||||
|
code_challenge: PKCE code challenge
|
||||||
|
extra_params: Additional query parameters
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Authorization URL
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
RuntimeError: If discover() hasn't been called
|
||||||
|
"""
|
||||||
|
if not self.authorization_endpoint:
|
||||||
|
await self.discover()
|
||||||
|
|
||||||
|
if not self.authorization_endpoint:
|
||||||
|
raise RuntimeError("Authorization endpoint not discovered")
|
||||||
|
|
||||||
|
params = {
|
||||||
|
"client_id": self.client_id,
|
||||||
|
"response_type": "code",
|
||||||
|
"redirect_uri": self.redirect_uri,
|
||||||
|
"scope": " ".join(self.scopes),
|
||||||
|
"state": state,
|
||||||
|
"code_challenge": code_challenge,
|
||||||
|
"code_challenge_method": "S256",
|
||||||
|
}
|
||||||
|
|
||||||
|
if extra_params:
|
||||||
|
params.update(extra_params)
|
||||||
|
|
||||||
|
return f"{self.authorization_endpoint}?{urlencode(params)}"
|
||||||
|
|
||||||
|
async def exchange_authorization_code(
|
||||||
|
self,
|
||||||
|
code: str,
|
||||||
|
code_verifier: str,
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Exchange authorization code for tokens.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
code: Authorization code from OAuth callback
|
||||||
|
code_verifier: PKCE code verifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Token response dictionary with keys:
|
||||||
|
- access_token: Access token
|
||||||
|
- refresh_token: Refresh token (if offline_access scope requested)
|
||||||
|
- id_token: ID token (JWT)
|
||||||
|
- expires_in: Access token lifetime in seconds
|
||||||
|
- refresh_expires_in: Refresh token lifetime in seconds (optional)
|
||||||
|
- token_type: Token type (Bearer)
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
httpx.HTTPError: If token exchange fails
|
||||||
|
"""
|
||||||
|
if not self.token_endpoint:
|
||||||
|
await self.discover()
|
||||||
|
|
||||||
|
if not self.token_endpoint:
|
||||||
|
raise RuntimeError("Token endpoint not discovered")
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
f"Exchanging authorization code for tokens at {self.token_endpoint}"
|
||||||
|
)
|
||||||
|
|
||||||
|
client = await self._get_http_client()
|
||||||
|
response = await client.post(
|
||||||
|
self.token_endpoint,
|
||||||
|
data={
|
||||||
|
"grant_type": "authorization_code",
|
||||||
|
"code": code,
|
||||||
|
"redirect_uri": self.redirect_uri,
|
||||||
|
"code_verifier": code_verifier,
|
||||||
|
},
|
||||||
|
auth=(self.client_id, self.client_secret),
|
||||||
|
)
|
||||||
|
|
||||||
|
response.raise_for_status()
|
||||||
|
token_data = response.json()
|
||||||
|
|
||||||
|
logger.info("✓ Successfully exchanged authorization code for tokens")
|
||||||
|
|
||||||
|
if "refresh_token" in token_data:
|
||||||
|
logger.info(" Received refresh token (offline_access granted)")
|
||||||
|
|
||||||
|
return token_data
|
||||||
|
|
||||||
|
async def refresh_access_token(self, refresh_token: str) -> dict:
|
||||||
|
"""
|
||||||
|
Refresh access token using refresh token.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
refresh_token: Refresh token
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Token response dictionary (same format as exchange_authorization_code)
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
httpx.HTTPError: If token refresh fails
|
||||||
|
"""
|
||||||
|
if not self.token_endpoint:
|
||||||
|
await self.discover()
|
||||||
|
|
||||||
|
if not self.token_endpoint:
|
||||||
|
raise RuntimeError("Token endpoint not discovered")
|
||||||
|
|
||||||
|
logger.debug("Refreshing access token")
|
||||||
|
|
||||||
|
client = await self._get_http_client()
|
||||||
|
response = await client.post(
|
||||||
|
self.token_endpoint,
|
||||||
|
data={
|
||||||
|
"grant_type": "refresh_token",
|
||||||
|
"refresh_token": refresh_token,
|
||||||
|
},
|
||||||
|
auth=(self.client_id, self.client_secret),
|
||||||
|
)
|
||||||
|
|
||||||
|
response.raise_for_status()
|
||||||
|
token_data = response.json()
|
||||||
|
|
||||||
|
logger.debug("✓ Successfully refreshed access token")
|
||||||
|
|
||||||
|
return token_data
|
||||||
|
|
||||||
|
async def get_userinfo(self, access_token: str) -> dict:
|
||||||
|
"""
|
||||||
|
Get user information using access token.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
access_token: Access token
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Userinfo response dictionary with claims like:
|
||||||
|
- sub: Subject (user ID)
|
||||||
|
- name: Full name
|
||||||
|
- preferred_username: Username
|
||||||
|
- email: Email address
|
||||||
|
- email_verified: Email verification status
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
httpx.HTTPError: If userinfo request fails
|
||||||
|
"""
|
||||||
|
if not self.userinfo_endpoint:
|
||||||
|
await self.discover()
|
||||||
|
|
||||||
|
if not self.userinfo_endpoint:
|
||||||
|
raise RuntimeError("Userinfo endpoint not discovered")
|
||||||
|
|
||||||
|
logger.debug("Fetching user info")
|
||||||
|
|
||||||
|
client = await self._get_http_client()
|
||||||
|
response = await client.get(
|
||||||
|
self.userinfo_endpoint,
|
||||||
|
headers={"Authorization": f"Bearer {access_token}"},
|
||||||
|
)
|
||||||
|
|
||||||
|
response.raise_for_status()
|
||||||
|
userinfo = response.json()
|
||||||
|
|
||||||
|
logger.debug(f"✓ Retrieved user info for subject: {userinfo.get('sub')}")
|
||||||
|
|
||||||
|
return userinfo
|
||||||
|
|
||||||
|
async def get_service_account_token(self, scopes: list[str] | None = None) -> dict:
|
||||||
|
"""
|
||||||
|
Get a service account token using client_credentials grant.
|
||||||
|
|
||||||
|
⚠️ **WARNING: DO NOT USE FOR DIRECT API ACCESS IN OAUTH MODE** ⚠️
|
||||||
|
|
||||||
|
This method creates a service account user in Nextcloud which VIOLATES
|
||||||
|
OAuth "act on-behalf-of" principles. Using this token directly for API
|
||||||
|
access will:
|
||||||
|
- Create a Nextcloud user: `service-account-{client_id}`
|
||||||
|
- Attribute all actions to service account instead of real user
|
||||||
|
- Break audit trail and user attribution
|
||||||
|
- Create stateful server identity in Nextcloud
|
||||||
|
- Violate OAuth security model
|
||||||
|
|
||||||
|
**Valid Use Case**: ONLY as subject_token for RFC 8693 token exchange
|
||||||
|
(ADR-002 Tier 2) where it's immediately exchanged for a user token.
|
||||||
|
|
||||||
|
**Invalid Use Case**: Direct API access with this token (ADR-002 rejected
|
||||||
|
this as "Tier 1" - see docs/ADR-002-vector-sync-authentication.md).
|
||||||
|
|
||||||
|
**Alternative**: Use token exchange (impersonation/delegation) for
|
||||||
|
background operations, or use BasicAuth mode if truly need service account.
|
||||||
|
|
||||||
|
This requires the client to have serviceAccountsEnabled=true in provider.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
scopes: Optional list of scopes to request (default: openid profile email)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Token response dictionary with:
|
||||||
|
- access_token: Service account access token
|
||||||
|
- token_type: Bearer
|
||||||
|
- expires_in: Token lifetime in seconds
|
||||||
|
- scope: Granted scopes
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
httpx.HTTPError: If token request fails
|
||||||
|
|
||||||
|
See Also:
|
||||||
|
- ADR-002 "Will Not Implement" section for detailed critique
|
||||||
|
- exchange_token_for_user() for proper token exchange usage
|
||||||
|
"""
|
||||||
|
if not self.token_endpoint:
|
||||||
|
await self.discover()
|
||||||
|
|
||||||
|
if not self.token_endpoint:
|
||||||
|
raise RuntimeError("Token endpoint not discovered")
|
||||||
|
|
||||||
|
# Default scopes
|
||||||
|
if scopes is None:
|
||||||
|
scopes = ["openid", "profile", "email"]
|
||||||
|
|
||||||
|
scope_str = " ".join(scopes)
|
||||||
|
|
||||||
|
logger.info(f"Requesting service account token with scopes: {scope_str}")
|
||||||
|
|
||||||
|
client = await self._get_http_client()
|
||||||
|
response = await client.post(
|
||||||
|
self.token_endpoint,
|
||||||
|
data={
|
||||||
|
"grant_type": "client_credentials",
|
||||||
|
"scope": scope_str,
|
||||||
|
},
|
||||||
|
auth=(self.client_id, self.client_secret),
|
||||||
|
)
|
||||||
|
|
||||||
|
response.raise_for_status()
|
||||||
|
token_data = response.json()
|
||||||
|
|
||||||
|
logger.info("✓ Service account token acquired")
|
||||||
|
|
||||||
|
return token_data
|
||||||
|
|
||||||
|
async def exchange_token_for_user(
|
||||||
|
self,
|
||||||
|
subject_token: str,
|
||||||
|
target_user_id: str | None = None,
|
||||||
|
audience: str | None = None,
|
||||||
|
scopes: list[str] | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Exchange a token for a user-scoped token using RFC 8693 Token Exchange.
|
||||||
|
|
||||||
|
This allows the MCP server (with a service account token) to obtain
|
||||||
|
user-scoped access tokens for background operations without needing
|
||||||
|
refresh tokens.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
subject_token: The token being exchanged (service account or user token)
|
||||||
|
target_user_id: Optional user ID to impersonate/exchange for
|
||||||
|
audience: Optional target audience (client ID)
|
||||||
|
scopes: Optional list of scopes for the new token
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Token response dictionary with:
|
||||||
|
- access_token: User-scoped access token
|
||||||
|
- issued_token_type: urn:ietf:params:oauth:token-type:access_token
|
||||||
|
- token_type: Bearer
|
||||||
|
- expires_in: Token lifetime in seconds
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
httpx.HTTPError: If token exchange fails (403 if not authorized)
|
||||||
|
|
||||||
|
Example:
|
||||||
|
# Get service account token
|
||||||
|
service_token = await client.get_service_account_token()
|
||||||
|
|
||||||
|
# Exchange for user-scoped token
|
||||||
|
user_token = await client.exchange_token_for_user(
|
||||||
|
subject_token=service_token["access_token"],
|
||||||
|
target_user_id="admin", # Username or sub claim
|
||||||
|
audience="nextcloud",
|
||||||
|
scopes=["notes:read", "files:read"]
|
||||||
|
)
|
||||||
|
|
||||||
|
Note:
|
||||||
|
This implements BOTH ADR-002 tiers:
|
||||||
|
|
||||||
|
**Tier 2 (Delegation - Recommended)**: When target_user_id is None
|
||||||
|
- Uses Keycloak Standard V2 (production-ready)
|
||||||
|
- Service account maintains its identity (sub claim unchanged)
|
||||||
|
- No special permissions required
|
||||||
|
|
||||||
|
**Tier 1 (Impersonation - Advanced)**: When target_user_id is provided
|
||||||
|
- Requires Keycloak Legacy V1 (--features=preview)
|
||||||
|
- Subject claim changes to target user
|
||||||
|
- Requires impersonation role granted via Keycloak CLI:
|
||||||
|
```
|
||||||
|
kcadm.sh add-roles -r <realm> \
|
||||||
|
--uusername service-account-<client-id> \
|
||||||
|
--cclientid realm-management \
|
||||||
|
--rolename impersonation
|
||||||
|
```
|
||||||
|
|
||||||
|
Both tiers require:
|
||||||
|
- Client has token.exchange.grant.enabled=true
|
||||||
|
- Client has serviceAccountsEnabled=true
|
||||||
|
"""
|
||||||
|
if not self.token_endpoint:
|
||||||
|
await self.discover()
|
||||||
|
|
||||||
|
if not self.token_endpoint:
|
||||||
|
raise RuntimeError("Token endpoint not discovered")
|
||||||
|
|
||||||
|
# Build token exchange request
|
||||||
|
data = {
|
||||||
|
"grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
|
||||||
|
"subject_token": subject_token,
|
||||||
|
"subject_token_type": "urn:ietf:params:oauth:token-type:access_token",
|
||||||
|
"requested_token_type": "urn:ietf:params:oauth:token-type:access_token",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add optional parameters
|
||||||
|
if audience:
|
||||||
|
data["audience"] = audience
|
||||||
|
|
||||||
|
if scopes:
|
||||||
|
data["scope"] = " ".join(scopes)
|
||||||
|
|
||||||
|
if target_user_id:
|
||||||
|
# Tier 1: Impersonation (Legacy V1)
|
||||||
|
# Use requested_subject for user impersonation
|
||||||
|
data["requested_subject"] = target_user_id
|
||||||
|
logger.info(
|
||||||
|
f"Exchanging token with impersonation (Tier 1): target_user={target_user_id}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Tier 2: Delegation (Standard V2)
|
||||||
|
logger.info(
|
||||||
|
"Exchanging token with delegation (Tier 2): service account identity preserved"
|
||||||
|
)
|
||||||
|
|
||||||
|
client = await self._get_http_client()
|
||||||
|
response = await client.post(
|
||||||
|
self.token_endpoint,
|
||||||
|
data=data,
|
||||||
|
auth=(self.client_id, self.client_secret),
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
error_data = (
|
||||||
|
response.json()
|
||||||
|
if response.headers.get("content-type", "").startswith(
|
||||||
|
"application/json"
|
||||||
|
)
|
||||||
|
else {"error": "unknown"}
|
||||||
|
)
|
||||||
|
logger.error(f"Token exchange failed: {response.status_code}")
|
||||||
|
logger.error(f"Error response: {error_data}")
|
||||||
|
|
||||||
|
response.raise_for_status()
|
||||||
|
token_data = response.json()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"✓ Token exchange successful, issued_token_type: {token_data.get('issued_token_type')}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return token_data
|
||||||
|
|
||||||
|
async def check_token_exchange_support(self) -> bool:
|
||||||
|
"""
|
||||||
|
Check if Keycloak supports RFC 8693 token exchange.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if token exchange is supported
|
||||||
|
|
||||||
|
Note:
|
||||||
|
This is ADR-002 Tier 2. Most Keycloak installations don't
|
||||||
|
have token exchange enabled by default.
|
||||||
|
"""
|
||||||
|
if not self.token_endpoint:
|
||||||
|
await self.discover()
|
||||||
|
|
||||||
|
# Try to get discovery document and check for token exchange grant
|
||||||
|
discovery_url = (
|
||||||
|
f"{self.keycloak_url}/realms/{self.realm}/.well-known/openid-configuration"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = await self._get_http_client()
|
||||||
|
response = await client.get(discovery_url)
|
||||||
|
response.raise_for_status()
|
||||||
|
discovery_data = response.json()
|
||||||
|
|
||||||
|
grant_types = discovery_data.get("grant_types_supported", [])
|
||||||
|
supported = "urn:ietf:params:oauth:grant-type:token-exchange" in grant_types
|
||||||
|
|
||||||
|
if supported:
|
||||||
|
logger.info("✓ Token exchange (RFC 8693) is supported")
|
||||||
|
else:
|
||||||
|
logger.info("Token exchange (RFC 8693) is not supported")
|
||||||
|
|
||||||
|
return supported
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to check token exchange support: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["KeycloakOAuthClient"]
|
||||||
@@ -0,0 +1,628 @@
|
|||||||
|
"""
|
||||||
|
Refresh Token Storage for ADR-002 Tier 1: Offline Access
|
||||||
|
|
||||||
|
Securely stores and manages user refresh tokens for background operations.
|
||||||
|
Tokens are encrypted at rest using Fernet symmetric encryption.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import aiosqlite
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class RefreshTokenStorage:
|
||||||
|
"""Securely store and manage user refresh tokens"""
|
||||||
|
|
||||||
|
def __init__(self, db_path: str, encryption_key: bytes):
|
||||||
|
"""
|
||||||
|
Initialize refresh token storage.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db_path: Path to SQLite database file
|
||||||
|
encryption_key: Fernet encryption key (32 bytes, base64-encoded)
|
||||||
|
"""
|
||||||
|
self.db_path = db_path
|
||||||
|
self.cipher = Fernet(encryption_key)
|
||||||
|
self._initialized = False
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_env(cls) -> "RefreshTokenStorage":
|
||||||
|
"""
|
||||||
|
Create storage instance from environment variables.
|
||||||
|
|
||||||
|
Environment variables:
|
||||||
|
TOKEN_STORAGE_DB: Path to database file (default: /app/data/tokens.db)
|
||||||
|
TOKEN_ENCRYPTION_KEY: Base64-encoded Fernet key
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
RefreshTokenStorage instance
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If TOKEN_ENCRYPTION_KEY is not set
|
||||||
|
"""
|
||||||
|
db_path = os.getenv("TOKEN_STORAGE_DB", "/app/data/tokens.db")
|
||||||
|
encryption_key_b64 = os.getenv("TOKEN_ENCRYPTION_KEY")
|
||||||
|
|
||||||
|
if not encryption_key_b64:
|
||||||
|
raise ValueError(
|
||||||
|
"TOKEN_ENCRYPTION_KEY environment variable is required. "
|
||||||
|
"Generate one with: python -c 'from cryptography.fernet import Fernet; "
|
||||||
|
"print(Fernet.generate_key().decode())'"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fernet expects a base64url-encoded key as bytes, not decoded bytes
|
||||||
|
# The key from Fernet.generate_key() is already base64url-encoded
|
||||||
|
try:
|
||||||
|
# Convert string to bytes if needed
|
||||||
|
if isinstance(encryption_key_b64, str):
|
||||||
|
encryption_key = encryption_key_b64.encode()
|
||||||
|
else:
|
||||||
|
encryption_key = encryption_key_b64
|
||||||
|
|
||||||
|
# Validate the key by trying to create a Fernet instance
|
||||||
|
Fernet(encryption_key)
|
||||||
|
except Exception as e:
|
||||||
|
raise ValueError(
|
||||||
|
f"Invalid TOKEN_ENCRYPTION_KEY: {e}. "
|
||||||
|
"Must be a valid Fernet key (base64url-encoded 32 bytes)."
|
||||||
|
) from e
|
||||||
|
|
||||||
|
return cls(db_path=db_path, encryption_key=encryption_key)
|
||||||
|
|
||||||
|
async def initialize(self) -> None:
|
||||||
|
"""Initialize database schema"""
|
||||||
|
if self._initialized:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Ensure directory exists
|
||||||
|
db_dir = Path(self.db_path).parent
|
||||||
|
db_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Set restrictive permissions on database file
|
||||||
|
if Path(self.db_path).exists():
|
||||||
|
os.chmod(self.db_path, 0o600)
|
||||||
|
|
||||||
|
async with aiosqlite.connect(self.db_path) as db:
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||||
|
user_id TEXT PRIMARY KEY,
|
||||||
|
encrypted_token BLOB NOT NULL,
|
||||||
|
expires_at INTEGER,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS audit_logs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
timestamp INTEGER NOT NULL,
|
||||||
|
event TEXT NOT NULL,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
resource_type TEXT,
|
||||||
|
resource_id TEXT,
|
||||||
|
auth_method TEXT,
|
||||||
|
hostname TEXT
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create index on audit logs for efficient queries
|
||||||
|
await db.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_audit_user_timestamp "
|
||||||
|
"ON audit_logs(user_id, timestamp)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# OAuth client credentials storage
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS oauth_clients (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
client_id TEXT UNIQUE NOT NULL,
|
||||||
|
encrypted_client_secret BLOB NOT NULL,
|
||||||
|
client_id_issued_at INTEGER NOT NULL,
|
||||||
|
client_secret_expires_at INTEGER NOT NULL,
|
||||||
|
redirect_uris TEXT NOT NULL,
|
||||||
|
encrypted_registration_access_token BLOB,
|
||||||
|
registration_client_uri TEXT,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
# Set restrictive permissions after creation
|
||||||
|
os.chmod(self.db_path, 0o600)
|
||||||
|
|
||||||
|
self._initialized = True
|
||||||
|
logger.info(f"Initialized refresh token storage at {self.db_path}")
|
||||||
|
|
||||||
|
async def store_refresh_token(
|
||||||
|
self,
|
||||||
|
user_id: str,
|
||||||
|
refresh_token: str,
|
||||||
|
expires_at: Optional[int] = None,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Store encrypted refresh token for user.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User identifier (from OIDC 'sub' claim)
|
||||||
|
refresh_token: Refresh token to store
|
||||||
|
expires_at: Token expiration timestamp (Unix epoch), if known
|
||||||
|
|
||||||
|
"""
|
||||||
|
if not self._initialized:
|
||||||
|
await self.initialize()
|
||||||
|
|
||||||
|
encrypted_token = self.cipher.encrypt(refresh_token.encode())
|
||||||
|
now = int(time.time())
|
||||||
|
|
||||||
|
async with aiosqlite.connect(self.db_path) as db:
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
INSERT OR REPLACE INTO refresh_tokens
|
||||||
|
(user_id, encrypted_token, expires_at, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, COALESCE((SELECT created_at FROM refresh_tokens WHERE user_id = ?), ?), ?)
|
||||||
|
""",
|
||||||
|
(user_id, encrypted_token, expires_at, user_id, now, now),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Stored refresh token for user {user_id}"
|
||||||
|
+ (f" (expires at {expires_at})" if expires_at else "")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Audit log
|
||||||
|
await self._audit_log(
|
||||||
|
event="store_refresh_token",
|
||||||
|
user_id=user_id,
|
||||||
|
auth_method="offline_access",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_refresh_token(self, user_id: str) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Retrieve and decrypt refresh token for user.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Decrypted refresh token, or None if not found or expired
|
||||||
|
"""
|
||||||
|
if not self._initialized:
|
||||||
|
await self.initialize()
|
||||||
|
|
||||||
|
async with aiosqlite.connect(self.db_path) as db:
|
||||||
|
async with db.execute(
|
||||||
|
"SELECT encrypted_token, expires_at FROM refresh_tokens WHERE user_id = ?",
|
||||||
|
(user_id,),
|
||||||
|
) as cursor:
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
|
||||||
|
if not row:
|
||||||
|
logger.debug(f"No refresh token found for user {user_id}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
encrypted_token, expires_at = row
|
||||||
|
|
||||||
|
# Check expiration
|
||||||
|
if expires_at is not None and expires_at < time.time():
|
||||||
|
logger.warning(
|
||||||
|
f"Refresh token for user {user_id} has expired (expired at {expires_at})"
|
||||||
|
)
|
||||||
|
await self.delete_refresh_token(user_id)
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
decrypted_token = self.cipher.decrypt(encrypted_token).decode()
|
||||||
|
logger.debug(f"Retrieved refresh token for user {user_id}")
|
||||||
|
return decrypted_token
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to decrypt refresh token for user {user_id}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def delete_refresh_token(self, user_id: str) -> bool:
|
||||||
|
"""
|
||||||
|
Delete refresh token for user.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if token was deleted, False if not found
|
||||||
|
"""
|
||||||
|
if not self._initialized:
|
||||||
|
await self.initialize()
|
||||||
|
|
||||||
|
async with aiosqlite.connect(self.db_path) as db:
|
||||||
|
cursor = await db.execute(
|
||||||
|
"DELETE FROM refresh_tokens WHERE user_id = ?",
|
||||||
|
(user_id,),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
deleted = cursor.rowcount > 0
|
||||||
|
|
||||||
|
if deleted:
|
||||||
|
logger.info(f"Deleted refresh token for user {user_id}")
|
||||||
|
await self._audit_log(
|
||||||
|
event="delete_refresh_token",
|
||||||
|
user_id=user_id,
|
||||||
|
auth_method="offline_access",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.debug(f"No refresh token to delete for user {user_id}")
|
||||||
|
|
||||||
|
return deleted
|
||||||
|
|
||||||
|
async def get_all_user_ids(self) -> list[str]:
|
||||||
|
"""
|
||||||
|
Get list of all user IDs with stored refresh tokens.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of user IDs
|
||||||
|
"""
|
||||||
|
if not self._initialized:
|
||||||
|
await self.initialize()
|
||||||
|
|
||||||
|
async with aiosqlite.connect(self.db_path) as db:
|
||||||
|
async with db.execute(
|
||||||
|
"SELECT user_id FROM refresh_tokens ORDER BY updated_at DESC"
|
||||||
|
) as cursor:
|
||||||
|
rows = await cursor.fetchall()
|
||||||
|
|
||||||
|
user_ids = [row[0] for row in rows]
|
||||||
|
logger.debug(f"Found {len(user_ids)} users with refresh tokens")
|
||||||
|
return user_ids
|
||||||
|
|
||||||
|
async def cleanup_expired_tokens(self) -> int:
|
||||||
|
"""
|
||||||
|
Remove expired refresh tokens from storage.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of tokens deleted
|
||||||
|
"""
|
||||||
|
if not self._initialized:
|
||||||
|
await self.initialize()
|
||||||
|
|
||||||
|
now = int(time.time())
|
||||||
|
|
||||||
|
async with aiosqlite.connect(self.db_path) as db:
|
||||||
|
cursor = await db.execute(
|
||||||
|
"DELETE FROM refresh_tokens WHERE expires_at IS NOT NULL AND expires_at < ?",
|
||||||
|
(now,),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
deleted = cursor.rowcount
|
||||||
|
|
||||||
|
if deleted > 0:
|
||||||
|
logger.info(f"Cleaned up {deleted} expired refresh token(s)")
|
||||||
|
|
||||||
|
return deleted
|
||||||
|
|
||||||
|
async def store_oauth_client(
|
||||||
|
self,
|
||||||
|
client_id: str,
|
||||||
|
client_secret: str,
|
||||||
|
client_id_issued_at: int,
|
||||||
|
client_secret_expires_at: int,
|
||||||
|
redirect_uris: list[str],
|
||||||
|
registration_access_token: Optional[str] = None,
|
||||||
|
registration_client_uri: Optional[str] = None,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Store encrypted OAuth client credentials.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client_id: OAuth client identifier
|
||||||
|
client_secret: OAuth client secret (will be encrypted)
|
||||||
|
client_id_issued_at: Unix timestamp when client was issued
|
||||||
|
client_secret_expires_at: Unix timestamp when secret expires
|
||||||
|
redirect_uris: List of redirect URIs
|
||||||
|
registration_access_token: RFC 7592 registration token (will be encrypted)
|
||||||
|
registration_client_uri: RFC 7592 client management URI
|
||||||
|
"""
|
||||||
|
if not self._initialized:
|
||||||
|
await self.initialize()
|
||||||
|
|
||||||
|
# Encrypt sensitive data
|
||||||
|
encrypted_secret = self.cipher.encrypt(client_secret.encode())
|
||||||
|
encrypted_reg_token = (
|
||||||
|
self.cipher.encrypt(registration_access_token.encode())
|
||||||
|
if registration_access_token
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
# Serialize redirect_uris as JSON
|
||||||
|
redirect_uris_json = json.dumps(redirect_uris)
|
||||||
|
now = int(time.time())
|
||||||
|
|
||||||
|
async with aiosqlite.connect(self.db_path) as db:
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
INSERT OR REPLACE INTO oauth_clients
|
||||||
|
(id, client_id, encrypted_client_secret, client_id_issued_at,
|
||||||
|
client_secret_expires_at, redirect_uris, encrypted_registration_access_token,
|
||||||
|
registration_client_uri, created_at, updated_at)
|
||||||
|
VALUES (
|
||||||
|
1, ?, ?, ?, ?, ?, ?, ?,
|
||||||
|
COALESCE((SELECT created_at FROM oauth_clients WHERE id = 1), ?),
|
||||||
|
?
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
client_id,
|
||||||
|
encrypted_secret,
|
||||||
|
client_id_issued_at,
|
||||||
|
client_secret_expires_at,
|
||||||
|
redirect_uris_json,
|
||||||
|
encrypted_reg_token,
|
||||||
|
registration_client_uri,
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Stored OAuth client credentials (client_id: {client_id[:16]}..., "
|
||||||
|
f"expires at {client_secret_expires_at})"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Audit log
|
||||||
|
await self._audit_log(
|
||||||
|
event="store_oauth_client",
|
||||||
|
user_id="system",
|
||||||
|
auth_method="oauth",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_oauth_client(self) -> Optional[dict]:
|
||||||
|
"""
|
||||||
|
Retrieve and decrypt OAuth client credentials.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with client credentials, or None if not found or expired:
|
||||||
|
{
|
||||||
|
"client_id": str,
|
||||||
|
"client_secret": str,
|
||||||
|
"client_id_issued_at": int,
|
||||||
|
"client_secret_expires_at": int,
|
||||||
|
"redirect_uris": list[str],
|
||||||
|
"registration_access_token": str | None,
|
||||||
|
"registration_client_uri": str | None,
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
if not self._initialized:
|
||||||
|
await self.initialize()
|
||||||
|
|
||||||
|
async with aiosqlite.connect(self.db_path) as db:
|
||||||
|
async with db.execute(
|
||||||
|
"""
|
||||||
|
SELECT client_id, encrypted_client_secret, client_id_issued_at,
|
||||||
|
client_secret_expires_at, redirect_uris,
|
||||||
|
encrypted_registration_access_token, registration_client_uri
|
||||||
|
FROM oauth_clients WHERE id = 1
|
||||||
|
"""
|
||||||
|
) as cursor:
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
|
||||||
|
if not row:
|
||||||
|
logger.debug("No OAuth client credentials found in storage")
|
||||||
|
return None
|
||||||
|
|
||||||
|
(
|
||||||
|
client_id,
|
||||||
|
encrypted_secret,
|
||||||
|
issued_at,
|
||||||
|
expires_at,
|
||||||
|
redirect_uris_json,
|
||||||
|
encrypted_reg_token,
|
||||||
|
reg_client_uri,
|
||||||
|
) = row
|
||||||
|
|
||||||
|
# Check expiration
|
||||||
|
if expires_at < time.time():
|
||||||
|
logger.warning(
|
||||||
|
f"OAuth client has expired (expired at {expires_at}), deleting"
|
||||||
|
)
|
||||||
|
await self.delete_oauth_client()
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Decrypt sensitive data
|
||||||
|
client_secret = self.cipher.decrypt(encrypted_secret).decode()
|
||||||
|
reg_token = (
|
||||||
|
self.cipher.decrypt(encrypted_reg_token).decode()
|
||||||
|
if encrypted_reg_token
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
# Deserialize redirect_uris
|
||||||
|
redirect_uris = json.loads(redirect_uris_json)
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
f"Retrieved OAuth client credentials (client_id: {client_id[:16]}...)"
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"client_id": client_id,
|
||||||
|
"client_secret": client_secret,
|
||||||
|
"client_id_issued_at": issued_at,
|
||||||
|
"client_secret_expires_at": expires_at,
|
||||||
|
"redirect_uris": redirect_uris,
|
||||||
|
"registration_access_token": reg_token,
|
||||||
|
"registration_client_uri": reg_client_uri,
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to decrypt OAuth client credentials: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def delete_oauth_client(self) -> bool:
|
||||||
|
"""
|
||||||
|
Delete OAuth client credentials.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if client was deleted, False if not found
|
||||||
|
"""
|
||||||
|
if not self._initialized:
|
||||||
|
await self.initialize()
|
||||||
|
|
||||||
|
async with aiosqlite.connect(self.db_path) as db:
|
||||||
|
cursor = await db.execute("DELETE FROM oauth_clients WHERE id = 1")
|
||||||
|
await db.commit()
|
||||||
|
deleted = cursor.rowcount > 0
|
||||||
|
|
||||||
|
if deleted:
|
||||||
|
logger.info("Deleted OAuth client credentials from storage")
|
||||||
|
await self._audit_log(
|
||||||
|
event="delete_oauth_client",
|
||||||
|
user_id="system",
|
||||||
|
auth_method="oauth",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.debug("No OAuth client credentials to delete")
|
||||||
|
|
||||||
|
return deleted
|
||||||
|
|
||||||
|
async def has_oauth_client(self) -> bool:
|
||||||
|
"""
|
||||||
|
Check if OAuth client credentials exist (and are not expired).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if valid client exists, False otherwise
|
||||||
|
"""
|
||||||
|
if not self._initialized:
|
||||||
|
await self.initialize()
|
||||||
|
|
||||||
|
async with aiosqlite.connect(self.db_path) as db:
|
||||||
|
async with db.execute(
|
||||||
|
"SELECT client_secret_expires_at FROM oauth_clients WHERE id = 1"
|
||||||
|
) as cursor:
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
|
||||||
|
if not row:
|
||||||
|
return False
|
||||||
|
|
||||||
|
expires_at = row[0]
|
||||||
|
return expires_at >= time.time()
|
||||||
|
|
||||||
|
async def _audit_log(
|
||||||
|
self,
|
||||||
|
event: str,
|
||||||
|
user_id: str,
|
||||||
|
resource_type: Optional[str] = None,
|
||||||
|
resource_id: Optional[str] = None,
|
||||||
|
auth_method: Optional[str] = None,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Log operation to audit log.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
event: Event name (e.g., "store_refresh_token", "token_refresh")
|
||||||
|
user_id: User identifier
|
||||||
|
resource_type: Resource type (e.g., "note", "file")
|
||||||
|
resource_id: Resource identifier
|
||||||
|
auth_method: Authentication method used
|
||||||
|
"""
|
||||||
|
import socket
|
||||||
|
|
||||||
|
hostname = socket.gethostname()
|
||||||
|
timestamp = int(time.time())
|
||||||
|
|
||||||
|
async with aiosqlite.connect(self.db_path) as db:
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO audit_logs
|
||||||
|
(timestamp, event, user_id, resource_type, resource_id, auth_method, hostname)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
timestamp,
|
||||||
|
event,
|
||||||
|
user_id,
|
||||||
|
resource_type,
|
||||||
|
resource_id,
|
||||||
|
auth_method,
|
||||||
|
hostname,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
async def get_audit_logs(
|
||||||
|
self,
|
||||||
|
user_id: Optional[str] = None,
|
||||||
|
since: Optional[int] = None,
|
||||||
|
limit: int = 100,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""
|
||||||
|
Retrieve audit logs.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: Filter by user ID (optional)
|
||||||
|
since: Filter by timestamp (Unix epoch, optional)
|
||||||
|
limit: Maximum number of logs to return
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of audit log entries
|
||||||
|
"""
|
||||||
|
if not self._initialized:
|
||||||
|
await self.initialize()
|
||||||
|
|
||||||
|
query = "SELECT * FROM audit_logs WHERE 1=1"
|
||||||
|
params = []
|
||||||
|
|
||||||
|
if user_id:
|
||||||
|
query += " AND user_id = ?"
|
||||||
|
params.append(user_id)
|
||||||
|
|
||||||
|
if since:
|
||||||
|
query += " AND timestamp >= ?"
|
||||||
|
params.append(since)
|
||||||
|
|
||||||
|
query += " ORDER BY timestamp DESC LIMIT ?"
|
||||||
|
params.append(limit)
|
||||||
|
|
||||||
|
async with aiosqlite.connect(self.db_path) as db:
|
||||||
|
db.row_factory = aiosqlite.Row
|
||||||
|
async with db.execute(query, params) as cursor:
|
||||||
|
rows = await cursor.fetchall()
|
||||||
|
|
||||||
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
async def generate_encryption_key() -> str:
|
||||||
|
"""
|
||||||
|
Generate a new Fernet encryption key.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Base64-encoded encryption key suitable for TOKEN_ENCRYPTION_KEY env var
|
||||||
|
"""
|
||||||
|
return Fernet.generate_key().decode()
|
||||||
|
|
||||||
|
|
||||||
|
# Example usage
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
# Generate a key for testing
|
||||||
|
key = await generate_encryption_key()
|
||||||
|
print(f"Generated encryption key: {key}")
|
||||||
|
print(f"Set this in your environment: export TOKEN_ENCRYPTION_KEY='{key}'")
|
||||||
|
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
"""Scope-based authorization for MCP tools."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from functools import wraps
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
from mcp.server.auth.middleware.auth_context import get_access_token
|
||||||
|
from mcp.server.auth.provider import AccessToken
|
||||||
|
from mcp.server.fastmcp import Context
|
||||||
|
from mcp.server.fastmcp.utilities.context_injection import find_context_parameter
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ScopeAuthorizationError(Exception):
|
||||||
|
"""Raised when a request lacks required scopes."""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class InsufficientScopeError(ScopeAuthorizationError):
|
||||||
|
"""Raised when request lacks required scopes (enables step-up auth).
|
||||||
|
|
||||||
|
This exception triggers a 403 response with WWW-Authenticate header
|
||||||
|
containing the missing scopes, allowing clients to perform step-up
|
||||||
|
authorization to obtain additional permissions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, missing_scopes: list[str], message: str | None = None):
|
||||||
|
self.missing_scopes = missing_scopes
|
||||||
|
super().__init__(
|
||||||
|
message or f"Missing required scopes: {', '.join(missing_scopes)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def require_scopes(*required_scopes: str):
|
||||||
|
"""
|
||||||
|
Decorator to require specific OAuth scopes for MCP tool execution.
|
||||||
|
|
||||||
|
This decorator:
|
||||||
|
1. Stores scope requirements as function metadata (_required_scopes attribute)
|
||||||
|
2. Checks that the access token contains all required scopes before execution
|
||||||
|
3. Raises ScopeAuthorizationError if any required scope is missing
|
||||||
|
|
||||||
|
The stored metadata enables dynamic tool filtering - tools can be hidden from
|
||||||
|
users who lack the necessary scopes.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
*required_scopes: Variable number of scope strings required (e.g., "notes:read", "notes:write")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Decorated function that checks scopes before execution
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```python
|
||||||
|
@mcp.tool()
|
||||||
|
@require_scopes("notes:read")
|
||||||
|
async def nc_notes_get_note(ctx: Context, note_id: int):
|
||||||
|
# This tool requires the notes:read scope
|
||||||
|
...
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
@require_scopes("notes:write")
|
||||||
|
async def nc_notes_create_note(ctx: Context, ...):
|
||||||
|
# This tool requires the notes:write scope
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ScopeAuthorizationError: If required scopes are not present in the access token
|
||||||
|
"""
|
||||||
|
|
||||||
|
def decorator(func: Callable):
|
||||||
|
# Store scope requirements as function metadata for dynamic filtering
|
||||||
|
func._required_scopes = list(required_scopes) # type: ignore
|
||||||
|
|
||||||
|
# Find which parameter receives the Context (FastMCP injects it by name)
|
||||||
|
context_param_name = find_context_parameter(func)
|
||||||
|
|
||||||
|
@wraps(func)
|
||||||
|
async def wrapper(*args, **kwargs):
|
||||||
|
# Extract context from kwargs (where FastMCP injected it)
|
||||||
|
ctx: Context | None = (
|
||||||
|
kwargs.get(context_param_name) if context_param_name else None
|
||||||
|
)
|
||||||
|
|
||||||
|
if ctx is None:
|
||||||
|
# No context parameter found - likely BasicAuth mode
|
||||||
|
# In BasicAuth mode, all operations are allowed
|
||||||
|
logger.debug(
|
||||||
|
f"No context parameter for {func.__name__} - allowing (BasicAuth mode)"
|
||||||
|
)
|
||||||
|
return await func(*args, **kwargs)
|
||||||
|
|
||||||
|
# Check if we're in OAuth mode (access token available)
|
||||||
|
access_token: AccessToken | None = getattr(
|
||||||
|
ctx.request_context, "access_token", None
|
||||||
|
)
|
||||||
|
|
||||||
|
if access_token is None:
|
||||||
|
# Not in OAuth mode (BasicAuth or no auth)
|
||||||
|
# In BasicAuth mode, all operations are allowed
|
||||||
|
logger.debug(
|
||||||
|
f"No access token present for {func.__name__} - allowing (BasicAuth mode)"
|
||||||
|
)
|
||||||
|
return await func(*args, **kwargs)
|
||||||
|
|
||||||
|
# Extract scopes from access token
|
||||||
|
token_scopes = set(access_token.scopes or [])
|
||||||
|
required_scopes_set = set(required_scopes)
|
||||||
|
|
||||||
|
# Check if all required scopes are present
|
||||||
|
missing_scopes = required_scopes_set - token_scopes
|
||||||
|
if missing_scopes:
|
||||||
|
error_msg = (
|
||||||
|
f"Access denied to {func.__name__}: "
|
||||||
|
f"Missing required scopes: {', '.join(sorted(missing_scopes))}. "
|
||||||
|
f"Token has scopes: {', '.join(sorted(token_scopes)) if token_scopes else 'none'}"
|
||||||
|
)
|
||||||
|
logger.warning(error_msg)
|
||||||
|
raise InsufficientScopeError(list(missing_scopes), error_msg)
|
||||||
|
|
||||||
|
# All required scopes present - allow execution
|
||||||
|
logger.debug(
|
||||||
|
f"Scope authorization passed for {func.__name__}: {required_scopes}"
|
||||||
|
)
|
||||||
|
return await func(*args, **kwargs)
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
def get_access_token_scopes(ctx: Context | None = None) -> set[str]:
|
||||||
|
"""
|
||||||
|
Extract scopes from the authenticated user's access token.
|
||||||
|
|
||||||
|
This function uses MCP SDK's contextvar to access the token, which works
|
||||||
|
across all request types including list_tools.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ctx: FastMCP context object (unused, kept for compatibility)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Set of scope strings, empty set if no token or no scopes
|
||||||
|
"""
|
||||||
|
# Use MCP SDK's get_access_token() which uses contextvars
|
||||||
|
# This works for all request types, including list_tools
|
||||||
|
access_token: AccessToken | None = get_access_token()
|
||||||
|
|
||||||
|
if access_token is None:
|
||||||
|
logger.debug("No access token found in auth context (likely BasicAuth mode)")
|
||||||
|
return set()
|
||||||
|
|
||||||
|
scopes = set(access_token.scopes or [])
|
||||||
|
logger.info(f"✅ Extracted scopes from access token: {scopes}")
|
||||||
|
return scopes
|
||||||
|
|
||||||
|
|
||||||
|
def check_scopes(ctx: Context, *required_scopes: str) -> tuple[bool, set[str]]:
|
||||||
|
"""
|
||||||
|
Check if the request context has all required scopes.
|
||||||
|
|
||||||
|
Utility function for manual scope checking without decorator.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ctx: FastMCP context object
|
||||||
|
*required_scopes: Variable number of required scope strings
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (has_all_scopes: bool, missing_scopes: set[str])
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```python
|
||||||
|
async def my_tool(ctx: Context):
|
||||||
|
has_scopes, missing = check_scopes(ctx, "notes:read", "notes:write")
|
||||||
|
if not has_scopes:
|
||||||
|
# Handle missing scopes
|
||||||
|
...
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
token_scopes = get_access_token_scopes(ctx)
|
||||||
|
|
||||||
|
# If no access token, assume BasicAuth mode (all operations allowed)
|
||||||
|
if not token_scopes and getattr(ctx.request_context, "access_token", None) is None:
|
||||||
|
return True, set()
|
||||||
|
|
||||||
|
required_scopes_set = set(required_scopes)
|
||||||
|
missing_scopes = required_scopes_set - token_scopes
|
||||||
|
|
||||||
|
return len(missing_scopes) == 0, missing_scopes
|
||||||
|
|
||||||
|
|
||||||
|
def get_required_scopes(func: Callable) -> list[str]:
|
||||||
|
"""
|
||||||
|
Extract required scopes from a function decorated with @require_scopes.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
func: Function to check (may be decorated)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of required scope strings, empty list if no scopes required
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```python
|
||||||
|
@require_scopes("notes:read", "notes:write")
|
||||||
|
async def my_tool():
|
||||||
|
pass
|
||||||
|
|
||||||
|
scopes = get_required_scopes(my_tool) # ["notes:read", "notes:write"]
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
return getattr(func, "_required_scopes", [])
|
||||||
|
|
||||||
|
|
||||||
|
def is_jwt_token() -> bool:
|
||||||
|
"""
|
||||||
|
Check if the current access token is in JWT format.
|
||||||
|
|
||||||
|
JWT tokens have 3 parts separated by dots (header.payload.signature).
|
||||||
|
Opaque tokens are random strings without this structure.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if current token is JWT format, False if opaque or no token
|
||||||
|
"""
|
||||||
|
access_token: AccessToken | None = get_access_token()
|
||||||
|
|
||||||
|
if access_token is None:
|
||||||
|
logger.debug("No access token found - not JWT")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# JWT tokens have exactly 2 dots (3 parts)
|
||||||
|
token_string = access_token.token
|
||||||
|
is_jwt = "." in token_string and token_string.count(".") == 2
|
||||||
|
|
||||||
|
logger.debug(f"Token format check: is_jwt={is_jwt}")
|
||||||
|
return is_jwt
|
||||||
|
|
||||||
|
|
||||||
|
def has_required_scopes(func: Callable, user_scopes: set[str]) -> bool:
|
||||||
|
"""
|
||||||
|
Check if a user has all scopes required by a function.
|
||||||
|
|
||||||
|
Used for dynamic tool filtering - determines if a tool should be visible
|
||||||
|
to a user based on their token scopes.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
func: Function decorated with @require_scopes
|
||||||
|
user_scopes: Set of scopes the user possesses
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if user has all required scopes (or no scopes required), False otherwise
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```python
|
||||||
|
@require_scopes("notes:write")
|
||||||
|
async def create_note():
|
||||||
|
pass
|
||||||
|
|
||||||
|
user_scopes = {"notes:read", "notes:write"}
|
||||||
|
can_see = has_required_scopes(create_note, user_scopes) # True
|
||||||
|
|
||||||
|
limited_user_scopes = {"notes:read"}
|
||||||
|
can_see = has_required_scopes(create_note, limited_user_scopes) # False
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
required = get_required_scopes(func)
|
||||||
|
|
||||||
|
# No scopes required → always allow
|
||||||
|
if not required:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Empty user_scopes but scopes required → deny
|
||||||
|
if not user_scopes:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check if user has all required scopes
|
||||||
|
return set(required).issubset(user_scopes)
|
||||||
|
|
||||||
|
|
||||||
|
def discover_all_scopes(mcp) -> list[str]:
|
||||||
|
"""
|
||||||
|
Dynamically discover all OAuth scopes required by registered MCP tools.
|
||||||
|
|
||||||
|
This function inspects all registered tools and extracts their required scopes
|
||||||
|
from the @require_scopes decorator metadata. It provides a single source of truth
|
||||||
|
for available scopes based on the actual tool implementations.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
mcp: FastMCP instance with registered tools
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Sorted list of unique scope strings, including base OIDC scopes
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```python
|
||||||
|
from mcp.server.fastmcp import FastMCP
|
||||||
|
|
||||||
|
mcp = FastMCP("My Server")
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
@require_scopes("notes:read")
|
||||||
|
async def get_notes():
|
||||||
|
pass
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
@require_scopes("notes:write")
|
||||||
|
async def create_note():
|
||||||
|
pass
|
||||||
|
|
||||||
|
scopes = discover_all_scopes(mcp)
|
||||||
|
# Returns: ["notes:read", "notes:write", "openid", "profile", "email"]
|
||||||
|
```
|
||||||
|
|
||||||
|
Note:
|
||||||
|
- Base OIDC scopes (openid, profile, email) are always included
|
||||||
|
- Scopes are deduplicated and sorted alphabetically
|
||||||
|
- Only scopes from decorated tools are included
|
||||||
|
- Must be called after tools are registered
|
||||||
|
"""
|
||||||
|
# Start with base OIDC scopes that are always required
|
||||||
|
all_scopes = {"openid", "profile", "email"}
|
||||||
|
|
||||||
|
# Get all registered tools
|
||||||
|
try:
|
||||||
|
tools = mcp._tool_manager.list_tools()
|
||||||
|
except AttributeError:
|
||||||
|
logger.warning("FastMCP instance does not have _tool_manager attribute")
|
||||||
|
return sorted(all_scopes)
|
||||||
|
|
||||||
|
# Extract scopes from each tool
|
||||||
|
for tool in tools:
|
||||||
|
# Get the original function (tools have a .fn attribute)
|
||||||
|
func = getattr(tool, "fn", None)
|
||||||
|
if func is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Extract scopes using existing helper
|
||||||
|
tool_scopes = get_required_scopes(func)
|
||||||
|
all_scopes.update(tool_scopes)
|
||||||
|
|
||||||
|
# Return sorted list of unique scopes
|
||||||
|
return sorted(all_scopes)
|
||||||
@@ -5,6 +5,8 @@ import time
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
import jwt
|
||||||
|
from jwt import PyJWKClient
|
||||||
from mcp.server.auth.provider import AccessToken, TokenVerifier
|
from mcp.server.auth.provider import AccessToken, TokenVerifier
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -12,22 +14,33 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
class NextcloudTokenVerifier(TokenVerifier):
|
class NextcloudTokenVerifier(TokenVerifier):
|
||||||
"""
|
"""
|
||||||
Validates access tokens using Nextcloud OIDC userinfo endpoint.
|
Validates access tokens using JWT verification with JWKS or userinfo endpoint fallback.
|
||||||
|
|
||||||
This verifier:
|
This verifier supports both JWT and opaque tokens:
|
||||||
1. Calls the userinfo endpoint with the bearer token
|
1. For JWT tokens: Verifies signature with JWKS and extracts scopes from payload
|
||||||
2. Caches successful responses to avoid repeated API calls
|
2. For opaque tokens: Falls back to userinfo endpoint validation
|
||||||
3. Extracts username from the 'sub' or 'preferred_username' claim
|
3. Caches successful responses to avoid repeated API calls/verifications
|
||||||
4. Optionally supports JWT validation for performance (future enhancement)
|
|
||||||
|
|
||||||
The userinfo endpoint validates the token and returns user claims if valid,
|
JWT validation provides:
|
||||||
or returns HTTP 400/401 if the token is invalid or expired.
|
- Faster validation (no HTTP call needed)
|
||||||
|
- Direct scope extraction from token payload
|
||||||
|
- Signature verification using JWKS
|
||||||
|
|
||||||
|
Userinfo fallback provides:
|
||||||
|
- Support for opaque tokens
|
||||||
|
- Backward compatibility
|
||||||
|
- Additional validation layer
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
nextcloud_host: str,
|
nextcloud_host: str,
|
||||||
userinfo_uri: str,
|
userinfo_uri: str,
|
||||||
|
jwks_uri: str | None = None,
|
||||||
|
issuer: str | None = None,
|
||||||
|
introspection_uri: str | None = None,
|
||||||
|
client_id: str | None = None,
|
||||||
|
client_secret: str | None = None,
|
||||||
cache_ttl: int = 3600,
|
cache_ttl: int = 3600,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
@@ -36,26 +49,52 @@ class NextcloudTokenVerifier(TokenVerifier):
|
|||||||
Args:
|
Args:
|
||||||
nextcloud_host: Base URL of the Nextcloud instance (e.g., https://cloud.example.com)
|
nextcloud_host: Base URL of the Nextcloud instance (e.g., https://cloud.example.com)
|
||||||
userinfo_uri: Full URL to the userinfo endpoint
|
userinfo_uri: Full URL to the userinfo endpoint
|
||||||
|
jwks_uri: Full URL to the JWKS endpoint (for JWT verification)
|
||||||
|
issuer: Expected issuer claim value (for JWT verification)
|
||||||
|
introspection_uri: Full URL to the introspection endpoint (for opaque tokens)
|
||||||
|
client_id: OAuth client ID (required for introspection)
|
||||||
|
client_secret: OAuth client secret (required for introspection)
|
||||||
cache_ttl: Time-to-live for cached tokens in seconds (default: 3600)
|
cache_ttl: Time-to-live for cached tokens in seconds (default: 3600)
|
||||||
"""
|
"""
|
||||||
self.nextcloud_host = nextcloud_host.rstrip("/")
|
self.nextcloud_host = nextcloud_host.rstrip("/")
|
||||||
self.userinfo_uri = userinfo_uri
|
self.userinfo_uri = userinfo_uri
|
||||||
|
self.jwks_uri = jwks_uri
|
||||||
|
self.issuer = issuer
|
||||||
|
self.introspection_uri = introspection_uri
|
||||||
|
self.client_id = client_id
|
||||||
|
self.client_secret = client_secret
|
||||||
self.cache_ttl = cache_ttl
|
self.cache_ttl = cache_ttl
|
||||||
|
|
||||||
# Cache: token -> (userinfo, expiry_timestamp)
|
# Cache: token -> (userinfo, expiry_timestamp)
|
||||||
self._token_cache: dict[str, tuple[dict[str, Any], float]] = {}
|
self._token_cache: dict[str, tuple[dict[str, Any], float]] = {}
|
||||||
|
|
||||||
# HTTP client for userinfo requests
|
# HTTP client for userinfo/introspection requests
|
||||||
self._client = httpx.AsyncClient(timeout=10.0)
|
self._client = httpx.AsyncClient(timeout=10.0)
|
||||||
|
|
||||||
|
# PyJWKClient for JWT verification (lazy initialization)
|
||||||
|
self._jwks_client: PyJWKClient | None = None
|
||||||
|
if jwks_uri:
|
||||||
|
logger.info(f"JWT verification enabled with JWKS URI: {jwks_uri}")
|
||||||
|
self._jwks_client = PyJWKClient(jwks_uri, cache_keys=True)
|
||||||
|
|
||||||
|
# Introspection support
|
||||||
|
if introspection_uri and client_id and client_secret:
|
||||||
|
logger.info(f"Token introspection enabled: {introspection_uri}")
|
||||||
|
elif introspection_uri:
|
||||||
|
logger.warning(
|
||||||
|
"Introspection URI provided but missing client credentials - introspection disabled"
|
||||||
|
)
|
||||||
|
|
||||||
async def verify_token(self, token: str) -> AccessToken | None:
|
async def verify_token(self, token: str) -> AccessToken | None:
|
||||||
"""
|
"""
|
||||||
Verify a bearer token by calling the userinfo endpoint.
|
Verify a bearer token using JWT verification, introspection, or userinfo endpoint.
|
||||||
|
|
||||||
This method:
|
This method:
|
||||||
1. Checks the cache first for recent validations
|
1. Checks the cache first for recent validations
|
||||||
2. Calls the userinfo endpoint if not cached
|
2. Attempts JWT verification if JWKS is configured and token looks like JWT
|
||||||
3. Returns AccessToken with username stored in metadata
|
3. Falls back to introspection for opaque tokens (if configured)
|
||||||
|
4. Falls back to userinfo endpoint as last resort
|
||||||
|
5. Returns AccessToken with username and scopes
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
token: The bearer token to verify
|
token: The bearer token to verify
|
||||||
@@ -69,13 +108,241 @@ class NextcloudTokenVerifier(TokenVerifier):
|
|||||||
logger.debug("Token found in cache")
|
logger.debug("Token found in cache")
|
||||||
return cached
|
return cached
|
||||||
|
|
||||||
# Validate via userinfo endpoint
|
# Try JWT verification first if enabled and token looks like JWT
|
||||||
|
is_jwt_format = self._is_jwt_format(token)
|
||||||
|
logger.debug(
|
||||||
|
f"Token format check: is_jwt_format={is_jwt_format}, _jwks_client={self._jwks_client is not None}"
|
||||||
|
)
|
||||||
|
if self._jwks_client and is_jwt_format:
|
||||||
|
logger.debug("Attempting JWT verification...")
|
||||||
|
jwt_result = self._verify_jwt(token)
|
||||||
|
if jwt_result:
|
||||||
|
logger.info("Token validated via JWT verification")
|
||||||
|
return jwt_result
|
||||||
|
else:
|
||||||
|
logger.warning("JWT verification failed, will try other methods")
|
||||||
|
|
||||||
|
# For opaque tokens, try introspection if available
|
||||||
|
if self.introspection_uri and self.client_id and self.client_secret:
|
||||||
|
logger.debug("Attempting token introspection...")
|
||||||
|
try:
|
||||||
|
introspection_result = await self._verify_via_introspection(token)
|
||||||
|
if introspection_result:
|
||||||
|
logger.info("Token validated via introspection")
|
||||||
|
return introspection_result
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Introspection failed: {e}")
|
||||||
|
|
||||||
|
# Fall back to userinfo endpoint validation (last resort)
|
||||||
|
logger.debug("Attempting userinfo endpoint validation...")
|
||||||
try:
|
try:
|
||||||
return await self._verify_via_userinfo(token)
|
return await self._verify_via_userinfo(token)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Token verification failed: {e}")
|
logger.warning(f"Token verification failed: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def _is_jwt_format(self, token: str) -> bool:
|
||||||
|
"""
|
||||||
|
Check if token looks like a JWT (has 3 parts separated by dots).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
token: The token to check
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if token appears to be JWT format
|
||||||
|
"""
|
||||||
|
return "." in token and token.count(".") == 2
|
||||||
|
|
||||||
|
def _verify_jwt(self, token: str) -> AccessToken | None:
|
||||||
|
"""
|
||||||
|
Verify JWT token with signature validation using JWKS.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
token: The JWT token to verify
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
AccessToken if valid, None if invalid
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Get signing key from JWKS
|
||||||
|
signing_key = self._jwks_client.get_signing_key_from_jwt(token)
|
||||||
|
|
||||||
|
# Verify and decode JWT
|
||||||
|
# Accept tokens with audience: "mcp-server" or ["mcp-server", "nextcloud"]
|
||||||
|
# This allows:
|
||||||
|
# 1. Tokens from MCP clients (aud: "mcp-server")
|
||||||
|
# 2. Tokens for Nextcloud APIs (aud: "nextcloud")
|
||||||
|
# 3. Tokens for both (aud: ["mcp-server", "nextcloud"])
|
||||||
|
payload = jwt.decode(
|
||||||
|
token,
|
||||||
|
signing_key.key,
|
||||||
|
algorithms=["RS256"],
|
||||||
|
issuer=self.issuer,
|
||||||
|
audience=["mcp-server", "nextcloud"], # Accept either audience
|
||||||
|
options={
|
||||||
|
"verify_signature": True,
|
||||||
|
"verify_exp": True,
|
||||||
|
"verify_iat": True,
|
||||||
|
"verify_iss": True if self.issuer else False,
|
||||||
|
"verify_aud": True, # Enable audience validation
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.debug(f"JWT verified successfully for user: {payload.get('sub')}")
|
||||||
|
logger.debug(f"Full JWT payload: {payload}")
|
||||||
|
|
||||||
|
# Extract username (sub claim, with fallback to preferred_username)
|
||||||
|
# Some OIDC providers (like Keycloak) may not include sub in access tokens
|
||||||
|
username = payload.get("sub") or payload.get("preferred_username")
|
||||||
|
if not username:
|
||||||
|
logger.error(
|
||||||
|
"No 'sub' or 'preferred_username' claim found in JWT payload"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Extract scopes from scope claim (space-separated string)
|
||||||
|
scope_string = payload.get("scope", "")
|
||||||
|
scopes = scope_string.split() if scope_string else []
|
||||||
|
logger.debug(
|
||||||
|
f"Extracted scopes from JWT - scope claim: '{scope_string}' -> scopes list: {scopes}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Extract expiration
|
||||||
|
exp = payload.get("exp")
|
||||||
|
if not exp:
|
||||||
|
logger.warning("No 'exp' claim in JWT, using default TTL")
|
||||||
|
exp = int(time.time() + self.cache_ttl)
|
||||||
|
|
||||||
|
# Cache the result
|
||||||
|
userinfo = {
|
||||||
|
"sub": username,
|
||||||
|
"scope": scope_string,
|
||||||
|
**{k: v for k, v in payload.items() if k not in ["sub", "scope"]},
|
||||||
|
}
|
||||||
|
self._token_cache[token] = (userinfo, exp)
|
||||||
|
|
||||||
|
return AccessToken(
|
||||||
|
token=token,
|
||||||
|
client_id=payload.get("client_id", ""),
|
||||||
|
scopes=scopes,
|
||||||
|
expires_at=exp,
|
||||||
|
resource=username, # Store username in resource field (RFC 8707)
|
||||||
|
)
|
||||||
|
|
||||||
|
except jwt.ExpiredSignatureError:
|
||||||
|
logger.info("JWT token has expired")
|
||||||
|
return None
|
||||||
|
except jwt.InvalidIssuerError as e:
|
||||||
|
logger.warning(f"JWT issuer validation failed: {e}")
|
||||||
|
return None
|
||||||
|
except jwt.InvalidTokenError as e:
|
||||||
|
logger.warning(f"JWT validation failed: {e}")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Unexpected error during JWT verification: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _verify_via_introspection(self, token: str) -> AccessToken | None:
|
||||||
|
"""
|
||||||
|
Validate token by calling the introspection endpoint (RFC 7662).
|
||||||
|
|
||||||
|
This method validates opaque tokens and retrieves their scopes.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
token: The bearer token to introspect
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
AccessToken if active, None if inactive or invalid
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Introspection requires client authentication
|
||||||
|
response = await self._client.post(
|
||||||
|
self.introspection_uri,
|
||||||
|
data={"token": token},
|
||||||
|
auth=(self.client_id, self.client_secret),
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
introspection_data = response.json()
|
||||||
|
|
||||||
|
# Check if token is active
|
||||||
|
if not introspection_data.get("active", False):
|
||||||
|
logger.info("Token introspection returned inactive=false")
|
||||||
|
return None
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
f"Token introspected successfully for user: {introspection_data.get('sub')}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Extract username
|
||||||
|
username = introspection_data.get("sub") or introspection_data.get(
|
||||||
|
"username"
|
||||||
|
)
|
||||||
|
if not username:
|
||||||
|
logger.error("No username found in introspection response")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Extract scopes (space-separated string)
|
||||||
|
scope_string = introspection_data.get("scope", "")
|
||||||
|
scopes = scope_string.split() if scope_string else []
|
||||||
|
logger.debug(f"Extracted scopes from introspection: {scopes}")
|
||||||
|
|
||||||
|
# Extract expiration
|
||||||
|
exp = introspection_data.get("exp")
|
||||||
|
if exp:
|
||||||
|
expiry = float(exp)
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"No 'exp' in introspection response, using default TTL"
|
||||||
|
)
|
||||||
|
expiry = time.time() + self.cache_ttl
|
||||||
|
|
||||||
|
# Cache the result
|
||||||
|
cache_data = {
|
||||||
|
"sub": username,
|
||||||
|
"scope": scope_string,
|
||||||
|
**{
|
||||||
|
k: v
|
||||||
|
for k, v in introspection_data.items()
|
||||||
|
if k not in ["sub", "scope", "active"]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
self._token_cache[token] = (cache_data, expiry)
|
||||||
|
|
||||||
|
return AccessToken(
|
||||||
|
token=token,
|
||||||
|
client_id=introspection_data.get("client_id", ""),
|
||||||
|
scopes=scopes,
|
||||||
|
expires_at=int(expiry),
|
||||||
|
resource=username,
|
||||||
|
)
|
||||||
|
|
||||||
|
elif response.status_code in (400, 401, 403):
|
||||||
|
logger.warning(
|
||||||
|
f"Token introspection failed: HTTP {response.status_code}. "
|
||||||
|
f"This may indicate: (1) Client credentials mismatch - trying to introspect "
|
||||||
|
f"token issued to different OAuth client, (2) Expired client credentials, "
|
||||||
|
f"(3) Invalid token. Will fall back to userinfo endpoint. "
|
||||||
|
f"Response: {response.text[:200] if response.text else 'empty'}"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
f"Unexpected response from introspection: {response.status_code}. "
|
||||||
|
f"Response: {response.text[:200] if response.text else 'empty'}"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
except httpx.TimeoutException:
|
||||||
|
logger.error("Timeout while introspecting token")
|
||||||
|
return None
|
||||||
|
except httpx.RequestError as e:
|
||||||
|
logger.error(f"Network error while introspecting token: {e}")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Unexpected error during token introspection: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
async def _verify_via_userinfo(self, token: str) -> AccessToken | None:
|
async def _verify_via_userinfo(self, token: str) -> AccessToken | None:
|
||||||
"""
|
"""
|
||||||
Validate token by calling the userinfo endpoint.
|
Validate token by calling the userinfo endpoint.
|
||||||
@@ -169,15 +436,31 @@ class NextcloudTokenVerifier(TokenVerifier):
|
|||||||
"""
|
"""
|
||||||
Extract scopes from userinfo response.
|
Extract scopes from userinfo response.
|
||||||
|
|
||||||
Since the userinfo response doesn't include the original scopes,
|
First attempts to read actual scopes from the 'scope' field (RFC 8693).
|
||||||
we infer them from the claims present in the response.
|
If not present, infers scopes from the claims present in the response.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
userinfo: The userinfo response dictionary
|
userinfo: The userinfo response dictionary
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of inferred scopes
|
List of scopes (actual or inferred)
|
||||||
"""
|
"""
|
||||||
|
# Try to get actual scopes from userinfo response (if OIDC provider includes it)
|
||||||
|
scope_string = userinfo.get("scope")
|
||||||
|
if scope_string:
|
||||||
|
scopes = scope_string.split() if isinstance(scope_string, str) else []
|
||||||
|
if scopes:
|
||||||
|
logger.debug(
|
||||||
|
f"Using actual scopes from userinfo: {scopes} (scope field present)"
|
||||||
|
)
|
||||||
|
return scopes
|
||||||
|
|
||||||
|
# Fallback: Infer scopes from claims present in response
|
||||||
|
# This maintains backward compatibility with OIDC providers that don't
|
||||||
|
# include the scope field in userinfo responses
|
||||||
|
logger.debug(
|
||||||
|
"No scope field in userinfo response, inferring scopes from claims"
|
||||||
|
)
|
||||||
scopes = ["openid"] # Always present
|
scopes = ["openid"] # Always present
|
||||||
|
|
||||||
if "email" in userinfo:
|
if "email" in userinfo:
|
||||||
@@ -194,6 +477,7 @@ class NextcloudTokenVerifier(TokenVerifier):
|
|||||||
if "groups" in userinfo:
|
if "groups" in userinfo:
|
||||||
scopes.append("groups")
|
scopes.append("groups")
|
||||||
|
|
||||||
|
logger.debug(f"Inferred scopes from userinfo claims: {scopes}")
|
||||||
return scopes
|
return scopes
|
||||||
|
|
||||||
def clear_cache(self):
|
def clear_cache(self):
|
||||||
|
|||||||
@@ -14,13 +14,14 @@ from httpx import (
|
|||||||
from ..controllers.notes_search import NotesSearchController
|
from ..controllers.notes_search import NotesSearchController
|
||||||
from .calendar import CalendarClient
|
from .calendar import CalendarClient
|
||||||
from .contacts import ContactsClient
|
from .contacts import ContactsClient
|
||||||
|
from .cookbook import CookbookClient
|
||||||
from .deck import DeckClient
|
from .deck import DeckClient
|
||||||
from .groups import GroupsClient
|
from .groups import GroupsClient
|
||||||
from .notes import NotesClient
|
from .notes import NotesClient
|
||||||
from .sharing import SharingClient
|
from .sharing import SharingClient
|
||||||
from .tables import TablesClient
|
from .tables import TablesClient
|
||||||
from .webdav import WebDAVClient
|
|
||||||
from .users import UsersClient
|
from .users import UsersClient
|
||||||
|
from .webdav import WebDAVClient
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -71,8 +72,11 @@ class NextcloudClient:
|
|||||||
self.notes = NotesClient(self._client, username)
|
self.notes = NotesClient(self._client, username)
|
||||||
self.webdav = WebDAVClient(self._client, username)
|
self.webdav = WebDAVClient(self._client, username)
|
||||||
self.tables = TablesClient(self._client, username)
|
self.tables = TablesClient(self._client, username)
|
||||||
self.calendar = CalendarClient(self._client, username)
|
self.calendar = CalendarClient(
|
||||||
|
base_url, username, auth
|
||||||
|
) # Uses AsyncDavClient internally
|
||||||
self.contacts = ContactsClient(self._client, username)
|
self.contacts = ContactsClient(self._client, username)
|
||||||
|
self.cookbook = CookbookClient(self._client, username)
|
||||||
self.deck = DeckClient(self._client, username)
|
self.deck = DeckClient(self._client, username)
|
||||||
self.users = UsersClient(self._client, username)
|
self.users = UsersClient(self._client, username)
|
||||||
self.groups = GroupsClient(self._client, username)
|
self.groups = GroupsClient(self._client, username)
|
||||||
@@ -119,13 +123,14 @@ class NextcloudClient:
|
|||||||
|
|
||||||
async def notes_search_notes(self, *, query: str):
|
async def notes_search_notes(self, *, query: str):
|
||||||
"""Search notes using token-based matching with relevance ranking."""
|
"""Search notes using token-based matching with relevance ranking."""
|
||||||
all_notes = await self.notes.get_all_notes()
|
all_notes = self.notes.get_all_notes()
|
||||||
return self._notes_search.search_notes(all_notes, query)
|
return await self._notes_search.search_notes(all_notes, query)
|
||||||
|
|
||||||
def _get_webdav_base_path(self) -> str:
|
def _get_webdav_base_path(self) -> str:
|
||||||
"""Helper to get the base WebDAV path for the authenticated user."""
|
"""Helper to get the base WebDAV path for the authenticated user."""
|
||||||
return f"/remote.php/dav/files/{self.username}"
|
return f"/remote.php/dav/files/{self.username}"
|
||||||
|
|
||||||
async def close(self):
|
async def close(self):
|
||||||
"""Close the HTTP client."""
|
"""Close the HTTP client and CalDAV client."""
|
||||||
await self._client.aclose()
|
await self._client.aclose()
|
||||||
|
await self.calendar.close()
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,250 @@
|
|||||||
|
"""Client for Nextcloud Cookbook app operations."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
|
from httpx import Timeout
|
||||||
|
|
||||||
|
from .base import BaseNextcloudClient
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class CookbookClient(BaseNextcloudClient):
|
||||||
|
"""Client for Nextcloud Cookbook app operations."""
|
||||||
|
|
||||||
|
async def get_version(self) -> Dict[str, Any]:
|
||||||
|
"""Get Cookbook app and API version."""
|
||||||
|
response = await self._make_request("GET", "/apps/cookbook/api/version")
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
async def get_config(self) -> Dict[str, Any]:
|
||||||
|
"""Get current Cookbook app configuration."""
|
||||||
|
response = await self._make_request("GET", "/apps/cookbook/api/v1/config")
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
async def set_config(self, config: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Set Cookbook app configuration.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config: Configuration dictionary with fields like:
|
||||||
|
- folder: Recipe folder path
|
||||||
|
- update_interval: Auto-rescan interval in minutes
|
||||||
|
- print_image: Whether to print images with recipes
|
||||||
|
- visibleInfoBlocks: Visible info blocks configuration
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Response with status message
|
||||||
|
"""
|
||||||
|
response = await self._make_request(
|
||||||
|
"POST", "/apps/cookbook/api/v1/config", json=config
|
||||||
|
)
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
async def reindex(self) -> str:
|
||||||
|
"""Trigger a rescan of all recipes into the caching database.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Success message
|
||||||
|
"""
|
||||||
|
response = await self._make_request("POST", "/apps/cookbook/api/v1/reindex")
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
async def list_recipes(self) -> List[Dict[str, Any]]:
|
||||||
|
"""Get all recipes in the database.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of recipe stubs with basic information
|
||||||
|
"""
|
||||||
|
response = await self._make_request("GET", "/apps/cookbook/api/v1/recipes")
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
async def get_recipe(self, recipe_id: int) -> Dict[str, Any]:
|
||||||
|
"""Get a single recipe by ID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
recipe_id: The recipe ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Full recipe data
|
||||||
|
"""
|
||||||
|
response = await self._make_request(
|
||||||
|
"GET", f"/apps/cookbook/api/v1/recipes/{recipe_id}"
|
||||||
|
)
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
async def create_recipe(self, recipe_data: Dict[str, Any]) -> int:
|
||||||
|
"""Create a new recipe.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
recipe_data: Recipe data following schema.org/Recipe format.
|
||||||
|
Required: name
|
||||||
|
Optional: description, ingredients, instructions, etc.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ID of the newly created recipe
|
||||||
|
"""
|
||||||
|
response = await self._make_request(
|
||||||
|
"POST", "/apps/cookbook/api/v1/recipes", json=recipe_data
|
||||||
|
)
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
async def update_recipe(self, recipe_id: int, recipe_data: Dict[str, Any]) -> int:
|
||||||
|
"""Update an existing recipe.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
recipe_id: The recipe ID to update
|
||||||
|
recipe_data: Updated recipe data
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ID of the updated recipe
|
||||||
|
"""
|
||||||
|
response = await self._make_request(
|
||||||
|
"PUT", f"/apps/cookbook/api/v1/recipes/{recipe_id}", json=recipe_data
|
||||||
|
)
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
async def delete_recipe(self, recipe_id: int) -> str:
|
||||||
|
"""Delete a recipe.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
recipe_id: The recipe ID to delete
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Success message
|
||||||
|
"""
|
||||||
|
response = await self._make_request(
|
||||||
|
"DELETE", f"/apps/cookbook/api/v1/recipes/{recipe_id}"
|
||||||
|
)
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
async def import_recipe(self, url: str) -> Dict[str, Any]:
|
||||||
|
"""Import a recipe from a URL using schema.org metadata.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: URL of the recipe to import
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Full imported recipe data
|
||||||
|
"""
|
||||||
|
logger.info(f"Importing recipe from URL: {url}")
|
||||||
|
response = await self._make_request(
|
||||||
|
"POST",
|
||||||
|
"/apps/cookbook/api/v1/import",
|
||||||
|
json={"url": url},
|
||||||
|
timeout=Timeout(300.0),
|
||||||
|
)
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
async def get_recipe_image(self, recipe_id: int, size: str = "full") -> bytes:
|
||||||
|
"""Get the main image of a recipe.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
recipe_id: The recipe ID
|
||||||
|
size: Image size - "full", "thumb" (250px), or "thumb16" (16px)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Image bytes
|
||||||
|
"""
|
||||||
|
response = await self._make_request(
|
||||||
|
"GET",
|
||||||
|
f"/apps/cookbook/api/v1/recipes/{recipe_id}/image",
|
||||||
|
params={"size": size},
|
||||||
|
)
|
||||||
|
return response.content
|
||||||
|
|
||||||
|
async def search_recipes(self, query: str) -> List[Dict[str, Any]]:
|
||||||
|
"""Search for recipes by keywords, tags, and categories.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: Search string (URL-encoded, space/comma separated)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of matching recipe stubs
|
||||||
|
"""
|
||||||
|
# URL encode the query
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
encoded_query = quote(query)
|
||||||
|
response = await self._make_request(
|
||||||
|
"GET", f"/apps/cookbook/api/v1/search/{encoded_query}"
|
||||||
|
)
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
async def list_categories(self) -> List[Dict[str, Any]]:
|
||||||
|
"""Get all known categories.
|
||||||
|
|
||||||
|
Note: A category name of '*' indicates recipes with no category.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of categories with recipe counts
|
||||||
|
"""
|
||||||
|
response = await self._make_request("GET", "/apps/cookbook/api/v1/categories")
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
async def get_recipes_in_category(self, category: str) -> List[Dict[str, Any]]:
|
||||||
|
"""Get all recipes in a specific category.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
category: Category name (use "_" for recipes with no category)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of recipe stubs in the category
|
||||||
|
"""
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
encoded_category = quote(category)
|
||||||
|
response = await self._make_request(
|
||||||
|
"GET", f"/apps/cookbook/api/v1/category/{encoded_category}"
|
||||||
|
)
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
async def rename_category(self, old_name: str, new_name: str) -> str:
|
||||||
|
"""Rename a category.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
old_name: Current category name
|
||||||
|
new_name: New category name
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
New category name
|
||||||
|
"""
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
encoded_old_name = quote(old_name)
|
||||||
|
response = await self._make_request(
|
||||||
|
"PUT",
|
||||||
|
f"/apps/cookbook/api/v1/category/{encoded_old_name}",
|
||||||
|
json={"name": new_name},
|
||||||
|
)
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
async def list_keywords(self) -> List[Dict[str, Any]]:
|
||||||
|
"""Get all known keywords/tags.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of keywords with recipe counts
|
||||||
|
"""
|
||||||
|
response = await self._make_request("GET", "/apps/cookbook/api/v1/keywords")
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
async def get_recipes_with_keywords(
|
||||||
|
self, keywords: List[str]
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""Get all recipes associated with certain keywords.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
keywords: List of keywords to filter by
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of recipe stubs matching the keywords
|
||||||
|
"""
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
# Join keywords with commas
|
||||||
|
keywords_str = ",".join(keywords)
|
||||||
|
encoded_keywords = quote(keywords_str)
|
||||||
|
response = await self._make_request(
|
||||||
|
"GET", f"/apps/cookbook/api/v1/tags/{encoded_keywords}"
|
||||||
|
)
|
||||||
|
return response.json()
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Client for Nextcloud Notes app operations."""
|
"""Client for Nextcloud Notes app operations."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, AsyncIterator, Dict, Optional
|
||||||
|
|
||||||
from .base import BaseNextcloudClient
|
from .base import BaseNextcloudClient
|
||||||
|
|
||||||
@@ -16,24 +16,22 @@ class NotesClient(BaseNextcloudClient):
|
|||||||
response = await self._make_request("GET", "/apps/notes/api/v1/settings")
|
response = await self._make_request("GET", "/apps/notes/api/v1/settings")
|
||||||
return response.json()
|
return response.json()
|
||||||
|
|
||||||
async def get_all_notes(self) -> List[Dict[str, Any]]:
|
async def get_all_notes(self) -> AsyncIterator[Dict[str, Any]]:
|
||||||
"""Get all notes."""
|
"""Get all notes, yielding them one at a time."""
|
||||||
notes = []
|
|
||||||
cursor = ""
|
cursor = ""
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
response = await self._make_request(
|
response = await self._make_request(
|
||||||
"GET",
|
"GET",
|
||||||
"/apps/notes/api/v1/notes",
|
"/apps/notes/api/v1/notes",
|
||||||
params={"chunkSize": 50, "chunkCursor": cursor},
|
params={"chunkSize": 10, "chunkCursor": cursor},
|
||||||
)
|
)
|
||||||
notes.extend(response.json())
|
for note in response.json():
|
||||||
|
yield note
|
||||||
if "X-Notes-Chunk-Cursor" not in response.headers:
|
if "X-Notes-Chunk-Cursor" not in response.headers:
|
||||||
break
|
break
|
||||||
cursor = response.headers["X-Notes-Chunk-Cursor"]
|
cursor = response.headers["X-Notes-Chunk-Cursor"]
|
||||||
|
|
||||||
return notes
|
|
||||||
|
|
||||||
async def get_note(self, note_id: int) -> Dict[str, Any]:
|
async def get_note(self, note_id: int) -> Dict[str, Any]:
|
||||||
"""Get a specific note by ID."""
|
"""Get a specific note by ID."""
|
||||||
response = await self._make_request(
|
response = await self._make_request(
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from typing import List, Optional, Dict
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
from nextcloud_mcp_server.client.base import BaseNextcloudClient
|
from nextcloud_mcp_server.client.base import BaseNextcloudClient
|
||||||
from nextcloud_mcp_server.models.users import UserDetails
|
from nextcloud_mcp_server.models.users import UserDetails
|
||||||
|
|
||||||
|
|||||||
@@ -570,3 +570,379 @@ class WebDAVClient(BaseNextcloudClient):
|
|||||||
f"Unexpected error copying resource from '{source_path}' to '{destination_path}': {e}"
|
f"Unexpected error copying resource from '{source_path}' to '{destination_path}': {e}"
|
||||||
)
|
)
|
||||||
raise e
|
raise e
|
||||||
|
|
||||||
|
async def search_files(
|
||||||
|
self,
|
||||||
|
scope: str = "",
|
||||||
|
where_conditions: Optional[str] = None,
|
||||||
|
properties: Optional[List[str]] = None,
|
||||||
|
order_by: Optional[List[Tuple[str, str]]] = None,
|
||||||
|
limit: Optional[int] = None,
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""Search for files using WebDAV SEARCH method (RFC 5323).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
scope: Directory path to search in (empty string for user root)
|
||||||
|
where_conditions: XML string for where clause conditions
|
||||||
|
properties: List of property names to retrieve (defaults to basic set)
|
||||||
|
order_by: List of (property, direction) tuples for sorting, e.g. [("getlastmodified", "descending")]
|
||||||
|
limit: Maximum number of results to return
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of file/directory dictionaries with requested properties
|
||||||
|
"""
|
||||||
|
# Default properties if not specified
|
||||||
|
if properties is None:
|
||||||
|
properties = [
|
||||||
|
"displayname",
|
||||||
|
"getcontentlength",
|
||||||
|
"getcontenttype",
|
||||||
|
"getlastmodified",
|
||||||
|
"resourcetype",
|
||||||
|
"getetag",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Build the SEARCH request XML
|
||||||
|
search_body = self._build_search_xml(
|
||||||
|
scope=scope,
|
||||||
|
where_conditions=where_conditions,
|
||||||
|
properties=properties,
|
||||||
|
order_by=order_by,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
|
||||||
|
# The SEARCH endpoint is at the dav root
|
||||||
|
search_path = "/remote.php/dav/"
|
||||||
|
|
||||||
|
headers = {"Content-Type": "text/xml", "OCS-APIRequest": "true"}
|
||||||
|
|
||||||
|
logger.debug(f"Searching files in scope: {scope}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = await self._make_request(
|
||||||
|
"SEARCH", search_path, content=search_body, headers=headers
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
# Parse the XML response
|
||||||
|
results = self._parse_search_response(response.content, scope)
|
||||||
|
|
||||||
|
logger.debug(f"Search returned {len(results)} results")
|
||||||
|
return results
|
||||||
|
|
||||||
|
except HTTPStatusError as e:
|
||||||
|
logger.error(f"HTTP error during search: {e}")
|
||||||
|
raise e
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Unexpected error during search: {e}")
|
||||||
|
raise e
|
||||||
|
|
||||||
|
def _build_search_xml(
|
||||||
|
self,
|
||||||
|
scope: str,
|
||||||
|
where_conditions: Optional[str],
|
||||||
|
properties: List[str],
|
||||||
|
order_by: Optional[List[Tuple[str, str]]],
|
||||||
|
limit: Optional[int],
|
||||||
|
) -> str:
|
||||||
|
"""Build the XML body for a SEARCH request."""
|
||||||
|
# Construct the scope path
|
||||||
|
username = self.username
|
||||||
|
scope_path = f"/files/{username}"
|
||||||
|
if scope:
|
||||||
|
scope_path = f"{scope_path}/{scope.lstrip('/')}"
|
||||||
|
|
||||||
|
# Build property list
|
||||||
|
prop_xml = "\n".join([self._property_to_xml(prop) for prop in properties])
|
||||||
|
|
||||||
|
# Build where clause
|
||||||
|
where_xml = where_conditions if where_conditions else ""
|
||||||
|
|
||||||
|
# Build order by clause
|
||||||
|
orderby_xml = ""
|
||||||
|
if order_by:
|
||||||
|
order_elements = []
|
||||||
|
for prop, direction in order_by:
|
||||||
|
prop_element = self._property_to_xml(prop)
|
||||||
|
dir_element = (
|
||||||
|
"<d:ascending/>"
|
||||||
|
if direction.lower() == "ascending"
|
||||||
|
else "<d:descending/>"
|
||||||
|
)
|
||||||
|
order_elements.append(f"<d:order>{prop_element}{dir_element}</d:order>")
|
||||||
|
orderby_xml = "\n".join(order_elements)
|
||||||
|
else:
|
||||||
|
orderby_xml = ""
|
||||||
|
|
||||||
|
# Build limit clause
|
||||||
|
limit_xml = (
|
||||||
|
f"<d:limit><d:nresults>{limit}</d:nresults></d:limit>" if limit else ""
|
||||||
|
)
|
||||||
|
|
||||||
|
# Construct the full SEARCH XML
|
||||||
|
search_xml = f"""<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<d:searchrequest xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns" xmlns:nc="http://nextcloud.org/ns">
|
||||||
|
<d:basicsearch>
|
||||||
|
<d:select>
|
||||||
|
<d:prop>
|
||||||
|
{prop_xml}
|
||||||
|
</d:prop>
|
||||||
|
</d:select>
|
||||||
|
<d:from>
|
||||||
|
<d:scope>
|
||||||
|
<d:href>{scope_path}</d:href>
|
||||||
|
<d:depth>infinity</d:depth>
|
||||||
|
</d:scope>
|
||||||
|
</d:from>
|
||||||
|
<d:where>
|
||||||
|
{where_xml}
|
||||||
|
</d:where>
|
||||||
|
<d:orderby>
|
||||||
|
{orderby_xml}
|
||||||
|
</d:orderby>
|
||||||
|
{limit_xml}
|
||||||
|
</d:basicsearch>
|
||||||
|
</d:searchrequest>"""
|
||||||
|
|
||||||
|
return search_xml
|
||||||
|
|
||||||
|
def _property_to_xml(self, prop: str) -> str:
|
||||||
|
"""Convert a property name to its XML element."""
|
||||||
|
# Handle properties with namespace prefixes
|
||||||
|
if prop.startswith("{"):
|
||||||
|
# Already a full namespace
|
||||||
|
namespace_end = prop.index("}")
|
||||||
|
namespace = prop[1:namespace_end]
|
||||||
|
local_name = prop[namespace_end + 1 :]
|
||||||
|
|
||||||
|
# Map namespace URIs to prefixes
|
||||||
|
ns_map = {
|
||||||
|
"DAV:": "d",
|
||||||
|
"http://owncloud.org/ns": "oc",
|
||||||
|
"http://nextcloud.org/ns": "nc",
|
||||||
|
}
|
||||||
|
|
||||||
|
prefix = ns_map.get(namespace, "d")
|
||||||
|
return f"<{prefix}:{local_name}/>"
|
||||||
|
else:
|
||||||
|
# Guess namespace based on common properties
|
||||||
|
if prop in [
|
||||||
|
"displayname",
|
||||||
|
"getcontentlength",
|
||||||
|
"getcontenttype",
|
||||||
|
"getlastmodified",
|
||||||
|
"resourcetype",
|
||||||
|
"getetag",
|
||||||
|
"quota-available-bytes",
|
||||||
|
"quota-used-bytes",
|
||||||
|
]:
|
||||||
|
return f"<d:{prop}/>"
|
||||||
|
elif prop in [
|
||||||
|
"fileid",
|
||||||
|
"size",
|
||||||
|
"permissions",
|
||||||
|
"favorite",
|
||||||
|
"tags",
|
||||||
|
"owner-id",
|
||||||
|
"owner-display-name",
|
||||||
|
"share-types",
|
||||||
|
"checksums",
|
||||||
|
"comments-count",
|
||||||
|
"comments-unread",
|
||||||
|
]:
|
||||||
|
return f"<oc:{prop}/>"
|
||||||
|
else:
|
||||||
|
# Assume nc namespace for newer properties
|
||||||
|
return f"<nc:{prop}/>"
|
||||||
|
|
||||||
|
def _parse_search_response(
|
||||||
|
self, xml_content: bytes, scope: str
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""Parse the XML response from a SEARCH request."""
|
||||||
|
root = ET.fromstring(xml_content)
|
||||||
|
items = []
|
||||||
|
|
||||||
|
# Process each response element
|
||||||
|
responses = root.findall(".//{DAV:}response")
|
||||||
|
|
||||||
|
for response_elem in responses:
|
||||||
|
href = response_elem.find(".//{DAV:}href")
|
||||||
|
if href is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Extract file/directory path from href
|
||||||
|
href_text = href.text or ""
|
||||||
|
# Remove the /remote.php/dav/files/username/ prefix to get relative path
|
||||||
|
path_parts = href_text.split("/files/")
|
||||||
|
if len(path_parts) > 1:
|
||||||
|
# Get the path after username
|
||||||
|
path_after_user = "/".join(path_parts[1].split("/")[1:])
|
||||||
|
relative_path = path_after_user.rstrip("/")
|
||||||
|
else:
|
||||||
|
relative_path = href_text.rstrip("/").split("/")[-1]
|
||||||
|
|
||||||
|
# Get properties
|
||||||
|
propstat = response_elem.find(".//{DAV:}propstat")
|
||||||
|
if propstat is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
prop = propstat.find(".//{DAV:}prop")
|
||||||
|
if prop is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Build item dictionary
|
||||||
|
item = {"path": relative_path, "href": href_text}
|
||||||
|
|
||||||
|
# Extract all properties
|
||||||
|
for child in prop:
|
||||||
|
tag = child.tag
|
||||||
|
value = child.text
|
||||||
|
|
||||||
|
# Remove namespace from tag
|
||||||
|
if "}" in tag:
|
||||||
|
tag = tag.split("}", 1)[1]
|
||||||
|
|
||||||
|
# Handle special properties
|
||||||
|
if tag == "resourcetype":
|
||||||
|
item["is_directory"] = child.find(".//{DAV:}collection") is not None
|
||||||
|
elif tag == "getcontentlength":
|
||||||
|
item["size"] = int(value) if value else 0
|
||||||
|
elif tag == "displayname":
|
||||||
|
item["name"] = value
|
||||||
|
elif tag == "getcontenttype":
|
||||||
|
item["content_type"] = value
|
||||||
|
elif tag == "getlastmodified":
|
||||||
|
item["last_modified"] = value
|
||||||
|
elif tag == "getetag":
|
||||||
|
item["etag"] = value.strip('"') if value else None
|
||||||
|
elif tag == "fileid":
|
||||||
|
item["file_id"] = int(value) if value else None
|
||||||
|
elif tag == "favorite":
|
||||||
|
item["is_favorite"] = value == "1"
|
||||||
|
elif tag == "permissions":
|
||||||
|
item["permissions"] = value
|
||||||
|
elif tag == "size":
|
||||||
|
# oc:size includes folder sizes
|
||||||
|
item["total_size"] = int(value) if value else 0
|
||||||
|
else:
|
||||||
|
# Store other properties as-is
|
||||||
|
item[tag] = value
|
||||||
|
|
||||||
|
items.append(item)
|
||||||
|
|
||||||
|
return items
|
||||||
|
|
||||||
|
async def find_by_name(
|
||||||
|
self, pattern: str, scope: str = "", limit: Optional[int] = None
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""Find files by name pattern using LIKE matching.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
pattern: Name pattern to search for (supports % wildcard)
|
||||||
|
scope: Directory path to search in (empty string for user root)
|
||||||
|
limit: Maximum number of results to return
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of matching files/directories
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
# Find all .txt files
|
||||||
|
results = await find_by_name("%.txt")
|
||||||
|
|
||||||
|
# Find files starting with "report"
|
||||||
|
results = await find_by_name("report%")
|
||||||
|
"""
|
||||||
|
where_conditions = f"""
|
||||||
|
<d:like>
|
||||||
|
<d:prop>
|
||||||
|
<d:displayname/>
|
||||||
|
</d:prop>
|
||||||
|
<d:literal>{pattern}</d:literal>
|
||||||
|
</d:like>
|
||||||
|
"""
|
||||||
|
|
||||||
|
return await self.search_files(
|
||||||
|
scope=scope, where_conditions=where_conditions, limit=limit
|
||||||
|
)
|
||||||
|
|
||||||
|
async def find_by_type(
|
||||||
|
self, mime_type: str, scope: str = "", limit: Optional[int] = None
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""Find files by MIME type.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
mime_type: MIME type to search for (supports % wildcard, e.g., "image/%")
|
||||||
|
scope: Directory path to search in (empty string for user root)
|
||||||
|
limit: Maximum number of results to return
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of matching files
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
# Find all images
|
||||||
|
results = await find_by_type("image/%")
|
||||||
|
|
||||||
|
# Find all PDFs
|
||||||
|
results = await find_by_type("application/pdf")
|
||||||
|
"""
|
||||||
|
where_conditions = f"""
|
||||||
|
<d:like>
|
||||||
|
<d:prop>
|
||||||
|
<d:getcontenttype/>
|
||||||
|
</d:prop>
|
||||||
|
<d:literal>{mime_type}</d:literal>
|
||||||
|
</d:like>
|
||||||
|
"""
|
||||||
|
|
||||||
|
return await self.search_files(
|
||||||
|
scope=scope, where_conditions=where_conditions, limit=limit
|
||||||
|
)
|
||||||
|
|
||||||
|
async def list_favorites(
|
||||||
|
self, scope: str = "", limit: Optional[int] = None
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""List all favorite files.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
scope: Directory path to search in (empty string for user root)
|
||||||
|
limit: Maximum number of results to return
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of favorite files/directories
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
# List all favorites
|
||||||
|
results = await list_favorites()
|
||||||
|
|
||||||
|
# List favorites in a specific folder
|
||||||
|
results = await list_favorites(scope="Documents")
|
||||||
|
"""
|
||||||
|
# Use REPORT method for favorites as it's more efficient
|
||||||
|
# But we can also use SEARCH as fallback
|
||||||
|
where_conditions = """
|
||||||
|
<d:eq>
|
||||||
|
<d:prop>
|
||||||
|
<oc:favorite/>
|
||||||
|
</d:prop>
|
||||||
|
<d:literal>1</d:literal>
|
||||||
|
</d:eq>
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Request favorite property
|
||||||
|
properties = [
|
||||||
|
"displayname",
|
||||||
|
"getcontentlength",
|
||||||
|
"getcontenttype",
|
||||||
|
"getlastmodified",
|
||||||
|
"resourcetype",
|
||||||
|
"getetag",
|
||||||
|
"fileid",
|
||||||
|
"favorite",
|
||||||
|
]
|
||||||
|
|
||||||
|
return await self.search_files(
|
||||||
|
scope=scope,
|
||||||
|
where_conditions=where_conditions,
|
||||||
|
properties=properties,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,18 +1,21 @@
|
|||||||
import logging.config
|
import logging.config
|
||||||
|
import os
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
LOGGING_CONFIG = {
|
LOGGING_CONFIG = {
|
||||||
"version": 1,
|
"version": 1,
|
||||||
|
"disable_existing_loggers": False,
|
||||||
"handlers": {
|
"handlers": {
|
||||||
"default": {
|
"default": {
|
||||||
"class": "logging.StreamHandler",
|
"class": "logging.StreamHandler",
|
||||||
"formatter": "http",
|
"formatter": "http",
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
"formatters": {
|
"formatters": {
|
||||||
"http": {
|
"http": {
|
||||||
"format": "%(levelname)s [%(asctime)s] %(name)s - %(message)s",
|
"format": "%(levelname)s [%(asctime)s] %(name)s - %(message)s",
|
||||||
"datefmt": "%Y-%m-%d %H:%M:%S",
|
"datefmt": "%Y-%m-%d %H:%M:%S",
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
"loggers": {
|
"loggers": {
|
||||||
"": {
|
"": {
|
||||||
@@ -29,9 +32,89 @@ LOGGING_CONFIG = {
|
|||||||
"level": "INFO",
|
"level": "INFO",
|
||||||
"propagate": False, # Prevent propagation to root logger
|
"propagate": False, # Prevent propagation to root logger
|
||||||
},
|
},
|
||||||
|
"uvicorn": {
|
||||||
|
"handlers": ["default"],
|
||||||
|
"level": "INFO",
|
||||||
|
"propagate": False,
|
||||||
|
},
|
||||||
|
"uvicorn.access": {
|
||||||
|
"handlers": ["default"],
|
||||||
|
"level": "INFO",
|
||||||
|
"propagate": False,
|
||||||
|
},
|
||||||
|
"uvicorn.error": {
|
||||||
|
"handlers": ["default"],
|
||||||
|
"level": "INFO",
|
||||||
|
"propagate": False,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def setup_logging():
|
def setup_logging():
|
||||||
logging.config.dictConfig(LOGGING_CONFIG)
|
logging.config.dictConfig(LOGGING_CONFIG)
|
||||||
|
|
||||||
|
|
||||||
|
# Document Processing Configuration
|
||||||
|
|
||||||
|
|
||||||
|
def get_document_processor_config() -> dict[str, Any]:
|
||||||
|
"""Get document processor configuration from environment.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with processor configs:
|
||||||
|
{
|
||||||
|
"enabled": bool,
|
||||||
|
"default_processor": str,
|
||||||
|
"processors": {
|
||||||
|
"unstructured": {...},
|
||||||
|
"tesseract": {...},
|
||||||
|
"custom": {...},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
config: dict[str, Any] = {
|
||||||
|
"enabled": os.getenv("ENABLE_DOCUMENT_PROCESSING", "false").lower() == "true",
|
||||||
|
"default_processor": os.getenv("DOCUMENT_PROCESSOR", "unstructured"),
|
||||||
|
"processors": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Unstructured configuration
|
||||||
|
if os.getenv("ENABLE_UNSTRUCTURED", "false").lower() == "true":
|
||||||
|
config["processors"]["unstructured"] = {
|
||||||
|
"api_url": os.getenv("UNSTRUCTURED_API_URL", "http://unstructured:8000"),
|
||||||
|
"timeout": int(os.getenv("UNSTRUCTURED_TIMEOUT", "120")),
|
||||||
|
"strategy": os.getenv("UNSTRUCTURED_STRATEGY", "auto"),
|
||||||
|
"languages": [
|
||||||
|
lang.strip()
|
||||||
|
for lang in os.getenv("UNSTRUCTURED_LANGUAGES", "eng,deu").split(",")
|
||||||
|
if lang.strip()
|
||||||
|
],
|
||||||
|
"progress_interval": int(os.getenv("PROGRESS_INTERVAL", "10")),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Tesseract configuration
|
||||||
|
if os.getenv("ENABLE_TESSERACT", "false").lower() == "true":
|
||||||
|
config["processors"]["tesseract"] = {
|
||||||
|
"tesseract_cmd": os.getenv("TESSERACT_CMD"), # None = auto-detect
|
||||||
|
"lang": os.getenv("TESSERACT_LANG", "eng"),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Custom processor (via HTTP API)
|
||||||
|
if os.getenv("ENABLE_CUSTOM_PROCESSOR", "false").lower() == "true":
|
||||||
|
custom_url = os.getenv("CUSTOM_PROCESSOR_URL")
|
||||||
|
if custom_url:
|
||||||
|
supported_types_str = os.getenv("CUSTOM_PROCESSOR_TYPES", "application/pdf")
|
||||||
|
supported_types = {
|
||||||
|
t.strip() for t in supported_types_str.split(",") if t.strip()
|
||||||
|
}
|
||||||
|
|
||||||
|
config["processors"]["custom"] = {
|
||||||
|
"name": os.getenv("CUSTOM_PROCESSOR_NAME", "custom"),
|
||||||
|
"api_url": custom_url,
|
||||||
|
"api_key": os.getenv("CUSTOM_PROCESSOR_API_KEY"),
|
||||||
|
"timeout": int(os.getenv("CUSTOM_PROCESSOR_TIMEOUT", "60")),
|
||||||
|
"supported_types": supported_types,
|
||||||
|
}
|
||||||
|
|
||||||
|
return config
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
"""Controller for notes search functionality."""
|
"""Controller for notes search functionality."""
|
||||||
|
|
||||||
from typing import Any, Dict, List
|
from typing import Any, AsyncIterable, Dict, List
|
||||||
|
|
||||||
|
|
||||||
class NotesSearchController:
|
class NotesSearchController:
|
||||||
"""Handles notes search logic and scoring."""
|
"""Handles notes search logic and scoring."""
|
||||||
|
|
||||||
def search_notes(
|
async def search_notes(
|
||||||
self, notes: List[Dict[str, Any]], query: str
|
self, notes: AsyncIterable[Dict[str, Any]], query: str
|
||||||
) -> List[Dict[str, Any]]:
|
) -> List[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Search notes using token-based matching with relevance ranking.
|
Search notes using token-based matching with relevance ranking.
|
||||||
@@ -21,7 +21,7 @@ class NotesSearchController:
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
# Process and score each note
|
# Process and score each note
|
||||||
for note in notes:
|
async for note in notes:
|
||||||
title_tokens, content_tokens = self._process_note_content(note)
|
title_tokens, content_tokens = self._process_note_content(note)
|
||||||
score = self._calculate_score(query_tokens, title_tokens, content_tokens)
|
score = self._calculate_score(query_tokens, title_tokens, content_tokens)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
"""Document processing plugins for extracting text from various file formats."""
|
||||||
|
|
||||||
|
from .base import DocumentProcessor, ProcessingResult, ProcessorError
|
||||||
|
from .registry import ProcessorRegistry, get_registry
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"DocumentProcessor",
|
||||||
|
"ProcessingResult",
|
||||||
|
"ProcessorError",
|
||||||
|
"ProcessorRegistry",
|
||||||
|
"get_registry",
|
||||||
|
]
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
"""Abstract base class for document processing plugins."""
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class ProcessingResult(BaseModel):
|
||||||
|
"""Standardized result from any document processor."""
|
||||||
|
|
||||||
|
text: str
|
||||||
|
"""Extracted text content"""
|
||||||
|
|
||||||
|
metadata: dict[str, Any]
|
||||||
|
"""Processor-specific metadata"""
|
||||||
|
|
||||||
|
processor: str
|
||||||
|
"""Name of processor that handled this (e.g., 'unstructured', 'tesseract')"""
|
||||||
|
|
||||||
|
success: bool = True
|
||||||
|
"""Whether processing succeeded"""
|
||||||
|
|
||||||
|
error: Optional[str] = None
|
||||||
|
"""Error message if processing failed"""
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentProcessor(ABC):
|
||||||
|
"""Abstract base class for document processing plugins.
|
||||||
|
|
||||||
|
Document processors extract text from various file formats (PDF, DOCX, images, etc.).
|
||||||
|
Each processor implements this interface and can be registered with the ProcessorRegistry.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
class MyProcessor(DocumentProcessor):
|
||||||
|
@property
|
||||||
|
def name(self) -> str:
|
||||||
|
return "my_processor"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def supported_mime_types(self) -> set[str]:
|
||||||
|
return {"application/pdf", "image/jpeg"}
|
||||||
|
|
||||||
|
async def process(self, content: bytes, content_type: str, **kwargs) -> ProcessingResult:
|
||||||
|
# Extract text from content
|
||||||
|
return ProcessingResult(text="...", metadata={}, processor=self.name)
|
||||||
|
|
||||||
|
async def health_check(self) -> bool:
|
||||||
|
return True
|
||||||
|
"""
|
||||||
|
|
||||||
|
@property
|
||||||
|
@abstractmethod
|
||||||
|
def name(self) -> str:
|
||||||
|
"""Unique identifier for this processor (e.g., 'unstructured', 'tesseract')."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@property
|
||||||
|
@abstractmethod
|
||||||
|
def supported_mime_types(self) -> set[str]:
|
||||||
|
"""Set of MIME types this processor can handle.
|
||||||
|
|
||||||
|
Examples: {"application/pdf", "image/jpeg", "image/png"}
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def process(
|
||||||
|
self,
|
||||||
|
content: bytes,
|
||||||
|
content_type: str,
|
||||||
|
filename: Optional[str] = None,
|
||||||
|
options: Optional[dict[str, Any]] = None,
|
||||||
|
progress_callback: Optional[
|
||||||
|
Callable[[float, Optional[float], Optional[str]], Awaitable[None]]
|
||||||
|
] = None,
|
||||||
|
) -> ProcessingResult:
|
||||||
|
"""Process a document and extract text.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
content: Document bytes
|
||||||
|
content_type: MIME type of the document
|
||||||
|
filename: Optional filename for format detection
|
||||||
|
options: Processor-specific options (e.g., OCR language, strategy)
|
||||||
|
progress_callback: Optional async callback for progress updates.
|
||||||
|
Called as: await progress_callback(progress, total, message)
|
||||||
|
- progress: Current progress value (monotonically increasing)
|
||||||
|
- total: Optional total value (None if unknown)
|
||||||
|
- message: Optional human-readable status message
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ProcessingResult with extracted text and metadata
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ProcessorError: If processing fails
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def health_check(self) -> bool:
|
||||||
|
"""Check if processor is available and healthy.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if processor is ready to use, False otherwise
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
def supports(self, content_type: str) -> bool:
|
||||||
|
"""Check if this processor supports the given MIME type.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
content_type: MIME type (may include parameters like "application/pdf; charset=utf-8")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if this processor can handle the type
|
||||||
|
"""
|
||||||
|
# Strip parameters from content type
|
||||||
|
base_type = content_type.split(";")[0].strip().lower()
|
||||||
|
return base_type in self.supported_mime_types
|
||||||
|
|
||||||
|
|
||||||
|
class ProcessorError(Exception):
|
||||||
|
"""Raised when document processing fails."""
|
||||||
|
|
||||||
|
pass
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
"""Generic HTTP API processor wrapper for custom document processing services."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from .base import DocumentProcessor, ProcessingResult, ProcessorError
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class CustomHTTPProcessor(DocumentProcessor):
|
||||||
|
"""Generic HTTP API processor wrapper.
|
||||||
|
|
||||||
|
Allows integration with any custom document processing API that follows
|
||||||
|
a simple request/response pattern. This makes it easy to integrate your
|
||||||
|
own text extraction services without writing a full processor.
|
||||||
|
|
||||||
|
Expected API Contract:
|
||||||
|
- POST request with file as multipart/form-data
|
||||||
|
- Response: {"text": "extracted text", "metadata": {...}}
|
||||||
|
|
||||||
|
Example:
|
||||||
|
processor = CustomHTTPProcessor(
|
||||||
|
name="my_ocr",
|
||||||
|
api_url="https://my-ocr-service.com/process",
|
||||||
|
api_key="secret",
|
||||||
|
supported_types={"application/pdf", "image/jpeg"},
|
||||||
|
)
|
||||||
|
result = await processor.process(pdf_bytes, "application/pdf")
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
api_url: str,
|
||||||
|
api_key: Optional[str] = None,
|
||||||
|
timeout: int = 60,
|
||||||
|
supported_types: Optional[set[str]] = None,
|
||||||
|
name: str = "custom",
|
||||||
|
):
|
||||||
|
"""Initialize custom HTTP processor.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
api_url: Your API endpoint (should accept POST with multipart/form-data)
|
||||||
|
api_key: Optional API key for authentication (sent as Bearer token)
|
||||||
|
timeout: Request timeout in seconds (default: 60)
|
||||||
|
supported_types: MIME types your API supports
|
||||||
|
name: Unique name for this processor (default: "custom")
|
||||||
|
"""
|
||||||
|
self.api_url = api_url
|
||||||
|
self.api_key = api_key
|
||||||
|
self.timeout = timeout
|
||||||
|
self._name = name
|
||||||
|
self._supported_types = supported_types or set()
|
||||||
|
|
||||||
|
logger.info(f"Initialized CustomHTTPProcessor: {name} -> {api_url}")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self) -> str:
|
||||||
|
return self._name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def supported_mime_types(self) -> set[str]:
|
||||||
|
return self._supported_types
|
||||||
|
|
||||||
|
async def process(
|
||||||
|
self,
|
||||||
|
content: bytes,
|
||||||
|
content_type: str,
|
||||||
|
filename: Optional[str] = None,
|
||||||
|
options: Optional[dict[str, Any]] = None,
|
||||||
|
progress_callback: Optional[
|
||||||
|
Callable[[float, Optional[float], Optional[str]], Awaitable[None]]
|
||||||
|
] = None,
|
||||||
|
) -> ProcessingResult:
|
||||||
|
"""Process via custom HTTP API.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
content: Document bytes
|
||||||
|
content_type: MIME type
|
||||||
|
filename: Optional filename
|
||||||
|
options: Custom options (passed as form data to API)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ProcessingResult with extracted text and metadata
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ProcessorError: If API call fails
|
||||||
|
"""
|
||||||
|
options = options or {}
|
||||||
|
|
||||||
|
# Prepare request
|
||||||
|
files = {"file": (filename or "document", content, content_type)}
|
||||||
|
headers = {}
|
||||||
|
|
||||||
|
if self.api_key:
|
||||||
|
headers["Authorization"] = f"Bearer {self.api_key}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||||
|
response = await client.post(
|
||||||
|
self.api_url,
|
||||||
|
files=files,
|
||||||
|
headers=headers,
|
||||||
|
data=options, # Pass options as form data
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
# Parse response
|
||||||
|
result = response.json()
|
||||||
|
text = result.get("text", "")
|
||||||
|
metadata = result.get("metadata", {})
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
f"Custom processor '{self.name}' extracted {len(text)} characters"
|
||||||
|
)
|
||||||
|
|
||||||
|
return ProcessingResult(
|
||||||
|
text=text,
|
||||||
|
metadata=metadata,
|
||||||
|
processor=self.name,
|
||||||
|
success=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
logger.error(f"Custom processor '{self.name}' HTTP error: {e}")
|
||||||
|
raise ProcessorError(f"API call failed: {str(e)}") from e
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Custom processor '{self.name}' failed: {e}")
|
||||||
|
raise ProcessorError(f"Processing failed: {str(e)}") from e
|
||||||
|
|
||||||
|
async def health_check(self) -> bool:
|
||||||
|
"""Check if custom API is available.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if API responds with status < 500
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=5) as client:
|
||||||
|
# Try GET request to check availability
|
||||||
|
response = await client.get(
|
||||||
|
self.api_url,
|
||||||
|
headers={"User-Agent": "nextcloud-mcp-server"},
|
||||||
|
)
|
||||||
|
return response.status_code < 500
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Custom processor '{self.name}' health check failed: {e}")
|
||||||
|
return False
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
"""Central registry for document processors."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from .base import DocumentProcessor, ProcessingResult, ProcessorError
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ProcessorRegistry:
|
||||||
|
"""Central registry for document processors.
|
||||||
|
|
||||||
|
Manages registration and routing of document processing requests to
|
||||||
|
appropriate processors based on MIME types and priorities.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
registry = ProcessorRegistry()
|
||||||
|
registry.register(UnstructuredProcessor(...), priority=10)
|
||||||
|
registry.register(TesseractProcessor(...), priority=5)
|
||||||
|
|
||||||
|
# Auto-select processor based on MIME type
|
||||||
|
result = await registry.process(pdf_bytes, "application/pdf")
|
||||||
|
|
||||||
|
# Force specific processor
|
||||||
|
result = await registry.process(img_bytes, "image/png", processor_name="tesseract")
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._processors: dict[str, tuple[DocumentProcessor, int]] = {}
|
||||||
|
self._priority_order: list[str] = []
|
||||||
|
|
||||||
|
def register(self, processor: DocumentProcessor, priority: int = 0):
|
||||||
|
"""Register a document processor.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
processor: Processor instance to register
|
||||||
|
priority: Higher priority processors are tried first (default: 0)
|
||||||
|
"""
|
||||||
|
name = processor.name
|
||||||
|
|
||||||
|
if name in self._processors:
|
||||||
|
logger.warning(f"Processor '{name}' already registered, replacing")
|
||||||
|
|
||||||
|
self._processors[name] = (processor, priority)
|
||||||
|
|
||||||
|
# Update priority order
|
||||||
|
if name in self._priority_order:
|
||||||
|
self._priority_order.remove(name)
|
||||||
|
|
||||||
|
# Insert in priority order (higher priority first)
|
||||||
|
inserted = False
|
||||||
|
for i, existing_name in enumerate(self._priority_order):
|
||||||
|
existing_priority = self._processors[existing_name][1]
|
||||||
|
if priority > existing_priority:
|
||||||
|
self._priority_order.insert(i, name)
|
||||||
|
inserted = True
|
||||||
|
break
|
||||||
|
|
||||||
|
if not inserted:
|
||||||
|
self._priority_order.append(name)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Registered processor: {name} "
|
||||||
|
f"(priority={priority}, supports={len(processor.supported_mime_types)} types)"
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_processor(self, name: str) -> Optional[DocumentProcessor]:
|
||||||
|
"""Get a processor by name.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Processor name
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
DocumentProcessor instance or None if not found
|
||||||
|
"""
|
||||||
|
if name in self._processors:
|
||||||
|
return self._processors[name][0]
|
||||||
|
return None
|
||||||
|
|
||||||
|
def find_processor(self, content_type: str) -> Optional[DocumentProcessor]:
|
||||||
|
"""Find the first processor that supports the given MIME type.
|
||||||
|
|
||||||
|
Processors are checked in priority order (highest priority first).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
content_type: MIME type to match
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
First matching processor or None
|
||||||
|
"""
|
||||||
|
for name in self._priority_order:
|
||||||
|
processor = self._processors[name][0]
|
||||||
|
if processor.supports(content_type):
|
||||||
|
logger.debug(f"Found processor '{name}' for type '{content_type}'")
|
||||||
|
return processor
|
||||||
|
|
||||||
|
logger.debug(f"No processor found for type '{content_type}'")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def list_processors(self) -> list[str]:
|
||||||
|
"""List all registered processor names in priority order.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of processor names (highest priority first)
|
||||||
|
"""
|
||||||
|
return list(self._priority_order)
|
||||||
|
|
||||||
|
async def process(
|
||||||
|
self,
|
||||||
|
content: bytes,
|
||||||
|
content_type: str,
|
||||||
|
filename: Optional[str] = None,
|
||||||
|
processor_name: Optional[str] = None,
|
||||||
|
options: Optional[dict[str, Any]] = None,
|
||||||
|
progress_callback: Optional[
|
||||||
|
Callable[[float, Optional[float], Optional[str]], Awaitable[None]]
|
||||||
|
] = None,
|
||||||
|
) -> ProcessingResult:
|
||||||
|
"""Process a document using available processors.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
content: Document bytes
|
||||||
|
content_type: MIME type
|
||||||
|
filename: Optional filename for format detection
|
||||||
|
processor_name: Force specific processor (or None for auto-select)
|
||||||
|
options: Processing options passed to processor
|
||||||
|
progress_callback: Optional async callback for progress updates
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ProcessingResult with extracted text and metadata
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ProcessorError: If no processor found or processing fails
|
||||||
|
"""
|
||||||
|
# Find processor
|
||||||
|
if processor_name:
|
||||||
|
processor = self.get_processor(processor_name)
|
||||||
|
if not processor:
|
||||||
|
raise ProcessorError(
|
||||||
|
f"Processor '{processor_name}' not found. "
|
||||||
|
f"Available: {', '.join(self.list_processors())}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
processor = self.find_processor(content_type)
|
||||||
|
if not processor:
|
||||||
|
raise ProcessorError(
|
||||||
|
f"No processor found for type: {content_type}. "
|
||||||
|
f"Registered processors: {', '.join(self.list_processors())}"
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Processing with '{processor.name}' processor")
|
||||||
|
|
||||||
|
# Process
|
||||||
|
return await processor.process(
|
||||||
|
content, content_type, filename, options, progress_callback
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Global registry instance
|
||||||
|
_registry = ProcessorRegistry()
|
||||||
|
|
||||||
|
|
||||||
|
def get_registry() -> ProcessorRegistry:
|
||||||
|
"""Get the global processor registry.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Singleton ProcessorRegistry instance
|
||||||
|
"""
|
||||||
|
return _registry
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
"""Document processor using Tesseract OCR (local)."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import shutil
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from .base import DocumentProcessor, ProcessingResult, ProcessorError
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
try:
|
||||||
|
import io
|
||||||
|
|
||||||
|
import pytesseract
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
TESSERACT_AVAILABLE = True
|
||||||
|
except ImportError:
|
||||||
|
TESSERACT_AVAILABLE = False
|
||||||
|
|
||||||
|
|
||||||
|
class TesseractProcessor(DocumentProcessor):
|
||||||
|
"""Document processor using Tesseract OCR (local).
|
||||||
|
|
||||||
|
This processor runs OCR locally using the Tesseract engine, which is
|
||||||
|
faster and more lightweight than cloud-based solutions but requires
|
||||||
|
Tesseract to be installed on the system.
|
||||||
|
|
||||||
|
Requirements:
|
||||||
|
- tesseract binary installed (e.g., apt install tesseract-ocr)
|
||||||
|
- Python packages: pip install pytesseract pillow
|
||||||
|
|
||||||
|
Example:
|
||||||
|
processor = TesseractProcessor(default_lang="eng+deu")
|
||||||
|
result = await processor.process(image_bytes, "image/jpeg")
|
||||||
|
"""
|
||||||
|
|
||||||
|
SUPPORTED_TYPES = {
|
||||||
|
"image/jpeg",
|
||||||
|
"image/png",
|
||||||
|
"image/tiff",
|
||||||
|
"image/bmp",
|
||||||
|
"image/gif",
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
tesseract_cmd: Optional[str] = None,
|
||||||
|
default_lang: str = "eng",
|
||||||
|
):
|
||||||
|
"""Initialize Tesseract processor.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tesseract_cmd: Path to tesseract executable (None = auto-detect)
|
||||||
|
default_lang: Default OCR language (e.g., "eng", "deu", "eng+deu")
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ProcessorError: If Tesseract or required packages not available
|
||||||
|
"""
|
||||||
|
if not TESSERACT_AVAILABLE:
|
||||||
|
raise ProcessorError(
|
||||||
|
"Tesseract processor requires: pip install pytesseract pillow"
|
||||||
|
)
|
||||||
|
|
||||||
|
if tesseract_cmd:
|
||||||
|
pytesseract.pytesseract.tesseract_cmd = tesseract_cmd
|
||||||
|
elif not shutil.which("tesseract"):
|
||||||
|
raise ProcessorError(
|
||||||
|
"Tesseract not found in PATH. Install with: apt install tesseract-ocr"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.default_lang = default_lang
|
||||||
|
logger.info(f"Initialized TesseractProcessor: lang={default_lang}")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self) -> str:
|
||||||
|
return "tesseract"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def supported_mime_types(self) -> set[str]:
|
||||||
|
return self.SUPPORTED_TYPES
|
||||||
|
|
||||||
|
async def process(
|
||||||
|
self,
|
||||||
|
content: bytes,
|
||||||
|
content_type: str,
|
||||||
|
filename: Optional[str] = None,
|
||||||
|
options: Optional[dict[str, Any]] = None,
|
||||||
|
progress_callback: Optional[
|
||||||
|
Callable[[float, Optional[float], Optional[str]], Awaitable[None]]
|
||||||
|
] = None,
|
||||||
|
) -> ProcessingResult:
|
||||||
|
"""Process image via Tesseract OCR.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
content: Image bytes
|
||||||
|
content_type: Image MIME type
|
||||||
|
filename: Optional filename
|
||||||
|
options: Processing options:
|
||||||
|
- lang: OCR language(s) (default: from init)
|
||||||
|
- config: Tesseract config string
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ProcessingResult with extracted text and metadata
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ProcessorError: If OCR fails
|
||||||
|
"""
|
||||||
|
options = options or {}
|
||||||
|
lang = options.get("lang", self.default_lang)
|
||||||
|
config = options.get("config", "")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Load image
|
||||||
|
image = Image.open(io.BytesIO(content))
|
||||||
|
|
||||||
|
# Run OCR
|
||||||
|
text = pytesseract.image_to_string(image, lang=lang, config=config)
|
||||||
|
|
||||||
|
# Get additional data for confidence scores
|
||||||
|
data = pytesseract.image_to_data(
|
||||||
|
image, lang=lang, output_type=pytesseract.Output.DICT
|
||||||
|
)
|
||||||
|
|
||||||
|
# Calculate average confidence
|
||||||
|
confidences = [c for c in data["conf"] if c != -1]
|
||||||
|
avg_confidence = sum(confidences) / len(confidences) if confidences else 0
|
||||||
|
|
||||||
|
metadata = {
|
||||||
|
"text_length": len(text),
|
||||||
|
"language": lang,
|
||||||
|
"image_size": image.size,
|
||||||
|
"image_mode": image.mode,
|
||||||
|
"confidence": round(avg_confidence, 2),
|
||||||
|
"words_detected": len([c for c in data["conf"] if c != -1]),
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
f"Tesseract OCR completed: {len(text)} chars, "
|
||||||
|
f"confidence={avg_confidence:.1f}%"
|
||||||
|
)
|
||||||
|
|
||||||
|
return ProcessingResult(
|
||||||
|
text=text.strip(),
|
||||||
|
metadata=metadata,
|
||||||
|
processor=self.name,
|
||||||
|
success=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Tesseract processing failed: {e}")
|
||||||
|
raise ProcessorError(f"OCR failed: {str(e)}") from e
|
||||||
|
|
||||||
|
async def health_check(self) -> bool:
|
||||||
|
"""Check if Tesseract is available.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if Tesseract is installed and working
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
pytesseract.get_tesseract_version()
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
"""Document processor using Unstructured.io API."""
|
||||||
|
|
||||||
|
import io
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
import anyio
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from .base import DocumentProcessor, ProcessingResult, ProcessorError
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class UnstructuredProcessor(DocumentProcessor):
|
||||||
|
"""Document processor using Unstructured.io API.
|
||||||
|
|
||||||
|
The Unstructured API provides document parsing capabilities for various formats
|
||||||
|
including PDF, DOCX, images with OCR, and more.
|
||||||
|
|
||||||
|
API Documentation: https://docs.unstructured.io/api-reference/api-services/api-parameters
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Supported MIME types for Unstructured
|
||||||
|
SUPPORTED_TYPES = {
|
||||||
|
"application/pdf",
|
||||||
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
|
"application/msword",
|
||||||
|
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||||
|
"application/vnd.ms-powerpoint",
|
||||||
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
"application/vnd.ms-excel",
|
||||||
|
"application/rtf",
|
||||||
|
"text/rtf",
|
||||||
|
"application/vnd.oasis.opendocument.text",
|
||||||
|
"application/epub+zip",
|
||||||
|
"message/rfc822",
|
||||||
|
"application/vnd.ms-outlook",
|
||||||
|
"image/jpeg",
|
||||||
|
"image/png",
|
||||||
|
"image/tiff",
|
||||||
|
"image/bmp",
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
api_url: str,
|
||||||
|
timeout: int = 120,
|
||||||
|
default_strategy: str = "auto",
|
||||||
|
default_languages: Optional[list[str]] = None,
|
||||||
|
progress_interval: int = 10,
|
||||||
|
):
|
||||||
|
"""Initialize Unstructured processor.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
api_url: Unstructured API endpoint
|
||||||
|
timeout: Request timeout in seconds (default: 120)
|
||||||
|
default_strategy: Default parsing strategy - "auto", "fast", or "hi_res"
|
||||||
|
default_languages: Default OCR language codes (e.g., ["eng", "deu"])
|
||||||
|
progress_interval: Seconds between progress updates (default: 10)
|
||||||
|
"""
|
||||||
|
self.api_url = api_url
|
||||||
|
self.timeout = timeout
|
||||||
|
self.default_strategy = default_strategy
|
||||||
|
self.default_languages = default_languages or ["eng"]
|
||||||
|
self.progress_interval = progress_interval
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Initialized UnstructuredProcessor: {api_url}, "
|
||||||
|
f"strategy={default_strategy}, languages={self.default_languages}, "
|
||||||
|
f"progress_interval={progress_interval}s"
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self) -> str:
|
||||||
|
return "unstructured"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def supported_mime_types(self) -> set[str]:
|
||||||
|
return self.SUPPORTED_TYPES
|
||||||
|
|
||||||
|
async def _run_progress_poller(
|
||||||
|
self,
|
||||||
|
stop_event: anyio.Event,
|
||||||
|
progress_callback: Callable[
|
||||||
|
[float, Optional[float], Optional[str]], Awaitable[None]
|
||||||
|
],
|
||||||
|
start_time: float,
|
||||||
|
):
|
||||||
|
"""Run progress poller that reports status every N seconds.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
stop_event: Event to signal when processing is complete
|
||||||
|
progress_callback: Async callback to report progress
|
||||||
|
start_time: Time when processing started (from time.time())
|
||||||
|
"""
|
||||||
|
logger.debug("Starting progress poller")
|
||||||
|
while not stop_event.is_set():
|
||||||
|
try:
|
||||||
|
# Wait for the event to be set, with a timeout equal to progress_interval
|
||||||
|
with anyio.fail_after(self.progress_interval):
|
||||||
|
await stop_event.wait()
|
||||||
|
# If wait() finished, the event was set (processing complete)
|
||||||
|
break
|
||||||
|
except TimeoutError:
|
||||||
|
# Timeout occurred - time to send a progress update
|
||||||
|
if not stop_event.is_set(): # Double-check in case of race condition
|
||||||
|
elapsed = int(time.time() - start_time)
|
||||||
|
message = (
|
||||||
|
f"Processing document with unstructured... ({elapsed}s elapsed)"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await progress_callback(
|
||||||
|
progress=float(elapsed),
|
||||||
|
total=None, # Unknown total duration
|
||||||
|
message=message,
|
||||||
|
)
|
||||||
|
logger.debug(f"Progress update sent: {elapsed}s elapsed")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to send progress update: {e}")
|
||||||
|
logger.debug("Progress poller stopped")
|
||||||
|
|
||||||
|
async def _make_api_request(
|
||||||
|
self,
|
||||||
|
content: bytes,
|
||||||
|
content_type: str,
|
||||||
|
filename: Optional[str],
|
||||||
|
strategy: str,
|
||||||
|
languages: list[str],
|
||||||
|
extract_image_block_types: Optional[list[str]],
|
||||||
|
) -> ProcessingResult:
|
||||||
|
"""Make the actual API request to Unstructured.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
content: Document bytes
|
||||||
|
content_type: MIME type
|
||||||
|
filename: Optional filename
|
||||||
|
strategy: Processing strategy
|
||||||
|
languages: OCR languages
|
||||||
|
extract_image_block_types: Image element types to extract
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ProcessingResult with extracted text and metadata
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ProcessorError: If processing fails
|
||||||
|
"""
|
||||||
|
# Prepare multipart request
|
||||||
|
files = {
|
||||||
|
"files": (
|
||||||
|
filename or "document",
|
||||||
|
io.BytesIO(content),
|
||||||
|
content_type or "application/octet-stream",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"strategy": strategy,
|
||||||
|
"languages": ",".join(languages),
|
||||||
|
}
|
||||||
|
|
||||||
|
if extract_image_block_types:
|
||||||
|
data["extract_image_block_types"] = ",".join(extract_image_block_types)
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
f"Processing with Unstructured API: strategy={strategy}, languages={languages}"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||||
|
response = await client.post(
|
||||||
|
f"{self.api_url}/general/v0/general",
|
||||||
|
files=files,
|
||||||
|
data=data,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
# Parse response
|
||||||
|
elements = response.json()
|
||||||
|
|
||||||
|
# Extract text and metadata
|
||||||
|
texts = []
|
||||||
|
element_types: dict[str, int] = {}
|
||||||
|
|
||||||
|
for element in elements:
|
||||||
|
if "text" in element and element["text"]:
|
||||||
|
texts.append(element["text"])
|
||||||
|
|
||||||
|
el_type = element.get("type", "unknown")
|
||||||
|
element_types[el_type] = element_types.get(el_type, 0) + 1
|
||||||
|
|
||||||
|
parsed_text = "\n\n".join(texts)
|
||||||
|
|
||||||
|
metadata = {
|
||||||
|
"element_count": len(elements),
|
||||||
|
"text_length": len(parsed_text),
|
||||||
|
"element_types": element_types,
|
||||||
|
"strategy": strategy,
|
||||||
|
"languages": languages,
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
f"Successfully processed: {len(elements)} elements, "
|
||||||
|
f"{len(parsed_text)} characters"
|
||||||
|
)
|
||||||
|
|
||||||
|
return ProcessingResult(
|
||||||
|
text=parsed_text,
|
||||||
|
metadata=metadata,
|
||||||
|
processor=self.name,
|
||||||
|
success=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
logger.error(f"Unstructured API HTTP error: {e}")
|
||||||
|
raise ProcessorError(f"HTTP error: {str(e)}") from e
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Unstructured API processing failed: {e}")
|
||||||
|
raise ProcessorError(f"Processing failed: {str(e)}") from e
|
||||||
|
|
||||||
|
async def process(
|
||||||
|
self,
|
||||||
|
content: bytes,
|
||||||
|
content_type: str,
|
||||||
|
filename: Optional[str] = None,
|
||||||
|
options: Optional[dict[str, Any]] = None,
|
||||||
|
progress_callback: Optional[
|
||||||
|
Callable[[float, Optional[float], Optional[str]], Awaitable[None]]
|
||||||
|
] = None,
|
||||||
|
) -> ProcessingResult:
|
||||||
|
"""Process document via Unstructured API.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
content: Document bytes
|
||||||
|
content_type: MIME type
|
||||||
|
filename: Optional filename for format detection
|
||||||
|
options: Processing options:
|
||||||
|
- strategy: "auto", "fast", or "hi_res" (default: from init)
|
||||||
|
- languages: List of language codes (default: from init)
|
||||||
|
- extract_image_block_types: Types of image elements to extract
|
||||||
|
progress_callback: Optional async callback for progress updates
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ProcessingResult with extracted text and metadata
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ProcessorError: If processing fails
|
||||||
|
"""
|
||||||
|
options = options or {}
|
||||||
|
|
||||||
|
# Extract options with defaults
|
||||||
|
strategy = options.get("strategy", self.default_strategy)
|
||||||
|
languages = options.get("languages", self.default_languages)
|
||||||
|
extract_image_block_types = options.get("extract_image_block_types")
|
||||||
|
|
||||||
|
# If no progress callback, just make the request directly
|
||||||
|
if progress_callback is None:
|
||||||
|
return await self._make_api_request(
|
||||||
|
content=content,
|
||||||
|
content_type=content_type,
|
||||||
|
filename=filename,
|
||||||
|
strategy=strategy,
|
||||||
|
languages=languages,
|
||||||
|
extract_image_block_types=extract_image_block_types,
|
||||||
|
)
|
||||||
|
|
||||||
|
# With progress callback: run API request + progress poller concurrently
|
||||||
|
stop_event = anyio.Event()
|
||||||
|
start_time = time.time()
|
||||||
|
result = None
|
||||||
|
|
||||||
|
async def capture_result():
|
||||||
|
nonlocal result
|
||||||
|
try:
|
||||||
|
result = await self._make_api_request(
|
||||||
|
content=content,
|
||||||
|
content_type=content_type,
|
||||||
|
filename=filename,
|
||||||
|
strategy=strategy,
|
||||||
|
languages=languages,
|
||||||
|
extract_image_block_types=extract_image_block_types,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
# Signal poller to stop after API request completes
|
||||||
|
stop_event.set()
|
||||||
|
|
||||||
|
# Run both tasks concurrently using anyio task groups
|
||||||
|
async with anyio.create_task_group() as tg:
|
||||||
|
tg.start_soon(capture_result)
|
||||||
|
tg.start_soon(
|
||||||
|
self._run_progress_poller, stop_event, progress_callback, start_time
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def health_check(self) -> bool:
|
||||||
|
"""Check if Unstructured API is available.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if API is healthy, False otherwise
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=5) as client:
|
||||||
|
response = await client.get(f"{self.api_url}/healthcheck")
|
||||||
|
return response.status_code == 200
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Unstructured health check failed: {e}")
|
||||||
|
return False
|
||||||
@@ -65,11 +65,14 @@ from .tables import (
|
|||||||
|
|
||||||
# WebDAV models
|
# WebDAV models
|
||||||
from .webdav import (
|
from .webdav import (
|
||||||
|
CopyResourceResponse,
|
||||||
CreateDirectoryResponse,
|
CreateDirectoryResponse,
|
||||||
DeleteResourceResponse,
|
DeleteResourceResponse,
|
||||||
DirectoryListing,
|
DirectoryListing,
|
||||||
FileInfo,
|
FileInfo,
|
||||||
|
MoveResourceResponse,
|
||||||
ReadFileResponse,
|
ReadFileResponse,
|
||||||
|
SearchFilesResponse,
|
||||||
WriteFileResponse,
|
WriteFileResponse,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -133,4 +136,7 @@ __all__ = [
|
|||||||
"WriteFileResponse",
|
"WriteFileResponse",
|
||||||
"CreateDirectoryResponse",
|
"CreateDirectoryResponse",
|
||||||
"DeleteResourceResponse",
|
"DeleteResourceResponse",
|
||||||
|
"MoveResourceResponse",
|
||||||
|
"CopyResourceResponse",
|
||||||
|
"SearchFilesResponse",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -180,3 +180,71 @@ class ManageCalendarResponse(BaseResponse):
|
|||||||
None, description="List of calendars (for list action)"
|
None, description="List of calendars (for list action)"
|
||||||
)
|
)
|
||||||
message: str = Field(description="Success message")
|
message: str = Field(description="Success message")
|
||||||
|
|
||||||
|
|
||||||
|
# ============= Todo/Task Models =============
|
||||||
|
|
||||||
|
|
||||||
|
class Todo(BaseModel):
|
||||||
|
"""Model for a CalDAV todo/task (VTODO)."""
|
||||||
|
|
||||||
|
uid: str = Field(description="Todo UID")
|
||||||
|
summary: str = Field(description="Todo summary/title")
|
||||||
|
description: str = Field(default="", description="Todo description")
|
||||||
|
status: str = Field(
|
||||||
|
default="NEEDS-ACTION",
|
||||||
|
description="Todo status: NEEDS-ACTION, IN-PROCESS, COMPLETED, CANCELLED",
|
||||||
|
)
|
||||||
|
priority: int = Field(
|
||||||
|
default=0, description="Todo priority (0=undefined, 1=highest, 9=lowest)"
|
||||||
|
)
|
||||||
|
percent_complete: int = Field(default=0, description="Percentage complete (0-100)")
|
||||||
|
due: Optional[str] = Field(None, description="Due date/time (ISO format)")
|
||||||
|
dtstart: Optional[str] = Field(None, description="Start date/time (ISO format)")
|
||||||
|
completed: Optional[str] = Field(
|
||||||
|
None, description="Completion timestamp (ISO format)"
|
||||||
|
)
|
||||||
|
categories: str = Field(default="", description="Comma-separated categories")
|
||||||
|
href: str = Field(default="", description="CalDAV href")
|
||||||
|
etag: str = Field(default="", description="ETag for versioning")
|
||||||
|
calendar_name: Optional[str] = Field(
|
||||||
|
None, description="Calendar containing this todo"
|
||||||
|
)
|
||||||
|
calendar_display_name: Optional[str] = Field(
|
||||||
|
None, description="Display name of calendar containing this todo"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ListTodosResponse(BaseResponse):
|
||||||
|
"""Response model for listing todos."""
|
||||||
|
|
||||||
|
todos: List[Todo] = Field(description="List of todos/tasks")
|
||||||
|
calendar_name: Optional[str] = Field(
|
||||||
|
None, description="Calendar name (if filtered to one calendar)"
|
||||||
|
)
|
||||||
|
total_count: int = Field(description="Total number of todos found")
|
||||||
|
|
||||||
|
|
||||||
|
class CreateTodoResponse(BaseResponse):
|
||||||
|
"""Response model for todo creation."""
|
||||||
|
|
||||||
|
todo: Todo = Field(description="The created todo")
|
||||||
|
calendar_name: str = Field(
|
||||||
|
description="Name of the calendar the todo was created in"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateTodoResponse(BaseResponse):
|
||||||
|
"""Response model for todo updates."""
|
||||||
|
|
||||||
|
todo: Todo = Field(description="The updated todo")
|
||||||
|
calendar_name: str = Field(description="Name of the calendar the todo belongs to")
|
||||||
|
|
||||||
|
|
||||||
|
class DeleteTodoResponse(StatusResponse):
|
||||||
|
"""Response model for todo deletion."""
|
||||||
|
|
||||||
|
deleted_uid: str = Field(description="UID of the deleted todo")
|
||||||
|
calendar_name: str = Field(
|
||||||
|
description="Name of the calendar the todo was deleted from"
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,216 @@
|
|||||||
|
"""Pydantic models for Cookbook app responses."""
|
||||||
|
|
||||||
|
from typing import List, Optional, Union
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
from .base import BaseResponse, IdResponse, StatusResponse
|
||||||
|
|
||||||
|
|
||||||
|
class Nutrition(BaseModel):
|
||||||
|
"""Nutrition information following schema.org/NutritionInformation."""
|
||||||
|
|
||||||
|
type: str = Field(
|
||||||
|
default="NutritionInformation",
|
||||||
|
alias="@type",
|
||||||
|
description="Schema.org object type",
|
||||||
|
)
|
||||||
|
calories: Optional[str] = Field(None, description="Calories (e.g., '650 kcal')")
|
||||||
|
carbohydrateContent: Optional[str] = Field(
|
||||||
|
None, description="Carbohydrates (e.g., '300 g')"
|
||||||
|
)
|
||||||
|
cholesterolContent: Optional[str] = Field(
|
||||||
|
None, description="Cholesterol (e.g., '10 g')"
|
||||||
|
)
|
||||||
|
fatContent: Optional[str] = Field(None, description="Fat (e.g., '45 g')")
|
||||||
|
fiberContent: Optional[str] = Field(None, description="Fiber (e.g., '50 g')")
|
||||||
|
proteinContent: Optional[str] = Field(None, description="Protein (e.g., '80 g')")
|
||||||
|
saturatedFatContent: Optional[str] = Field(
|
||||||
|
None, description="Saturated fat (e.g., '5 g')"
|
||||||
|
)
|
||||||
|
servingSize: Optional[str] = Field(
|
||||||
|
None, description="Serving size description (e.g., 'One plate')"
|
||||||
|
)
|
||||||
|
sodiumContent: Optional[str] = Field(None, description="Sodium (e.g., '10 mg')")
|
||||||
|
sugarContent: Optional[str] = Field(None, description="Sugar (e.g., '5 g')")
|
||||||
|
transFatContent: Optional[str] = Field(None, description="Trans fat (e.g., '10 g')")
|
||||||
|
unsaturatedFatContent: Optional[str] = Field(
|
||||||
|
None, description="Unsaturated fat (e.g., '40 g')"
|
||||||
|
)
|
||||||
|
|
||||||
|
model_config = ConfigDict(populate_by_name=True)
|
||||||
|
|
||||||
|
|
||||||
|
class RecipeStub(BaseModel):
|
||||||
|
"""Stub of a recipe with basic information."""
|
||||||
|
|
||||||
|
id: str = Field(description="Recipe ID as string")
|
||||||
|
recipe_id: int = Field(description="Recipe ID as integer (deprecated)")
|
||||||
|
name: str = Field(description="Recipe name")
|
||||||
|
keywords: Optional[str] = Field(default="", description="Comma-separated keywords")
|
||||||
|
dateCreated: str = Field(description="Creation date (ISO8601)")
|
||||||
|
dateModified: Optional[str] = Field(
|
||||||
|
None, description="Last modified date (ISO8601)"
|
||||||
|
)
|
||||||
|
imageUrl: str = Field(default="", description="URL of the recipe image")
|
||||||
|
imagePlaceholderUrl: str = Field(default="", description="URL of placeholder image")
|
||||||
|
|
||||||
|
|
||||||
|
class Recipe(BaseModel):
|
||||||
|
"""Full recipe following schema.org/Recipe specification."""
|
||||||
|
|
||||||
|
type: str = Field(default="Recipe", alias="@type", description="Schema.org type")
|
||||||
|
id: Optional[str] = Field(None, description="Recipe ID")
|
||||||
|
name: str = Field(description="Recipe name")
|
||||||
|
description: str = Field(default="", description="Recipe description")
|
||||||
|
url: str = Field(default="", description="Original recipe URL")
|
||||||
|
image: str = Field(default="", description="URL of original recipe image")
|
||||||
|
imageUrl: Optional[str] = Field(
|
||||||
|
None, description="URL of the recipe image in Nextcloud"
|
||||||
|
)
|
||||||
|
imagePlaceholderUrl: Optional[str] = Field(
|
||||||
|
None, description="URL of placeholder image"
|
||||||
|
)
|
||||||
|
keywords: str = Field(default="", description="Comma-separated keywords")
|
||||||
|
dateCreated: Optional[str] = Field(None, description="Creation date (ISO8601)")
|
||||||
|
dateModified: Optional[str] = Field(
|
||||||
|
None, description="Last modified date (ISO8601)"
|
||||||
|
)
|
||||||
|
prepTime: Optional[str] = Field(None, description="Preparation time (ISO8601)")
|
||||||
|
cookTime: Optional[str] = Field(None, description="Cooking time (ISO8601)")
|
||||||
|
totalTime: Optional[str] = Field(None, description="Total time (ISO8601)")
|
||||||
|
recipeYield: Union[int, str] = Field(default=1, description="Number of servings")
|
||||||
|
recipeCategory: str = Field(default="", description="Recipe category")
|
||||||
|
tool: List[str] = Field(default_factory=list, description="Required tools")
|
||||||
|
recipeIngredient: List[str] = Field(
|
||||||
|
default_factory=list, description="List of ingredients"
|
||||||
|
)
|
||||||
|
recipeInstructions: List[str] = Field(
|
||||||
|
default_factory=list, description="Cooking instructions"
|
||||||
|
)
|
||||||
|
nutrition: Optional[Nutrition] = Field(None, description="Nutrition information")
|
||||||
|
|
||||||
|
model_config = ConfigDict(populate_by_name=True, extra="allow")
|
||||||
|
|
||||||
|
|
||||||
|
class Category(BaseModel):
|
||||||
|
"""A recipe category."""
|
||||||
|
|
||||||
|
name: str = Field(description="Category name")
|
||||||
|
recipe_count: int = Field(description="Number of recipes in category")
|
||||||
|
|
||||||
|
|
||||||
|
class Keyword(BaseModel):
|
||||||
|
"""A recipe keyword/tag."""
|
||||||
|
|
||||||
|
name: str = Field(description="Keyword name")
|
||||||
|
recipe_count: int = Field(description="Number of recipes with this keyword")
|
||||||
|
|
||||||
|
|
||||||
|
class VisibleInfoBlocks(BaseModel):
|
||||||
|
"""Configuration for visible information blocks in the UI."""
|
||||||
|
|
||||||
|
preparation_time: Optional[bool] = Field(
|
||||||
|
None, alias="preparation-time", description="Show preparation time"
|
||||||
|
)
|
||||||
|
cooking_time: Optional[bool] = Field(
|
||||||
|
None, alias="cooking-time", description="Show cooking time"
|
||||||
|
)
|
||||||
|
total_time: Optional[bool] = Field(
|
||||||
|
None, alias="total-time", description="Show total time"
|
||||||
|
)
|
||||||
|
nutrition_information: Optional[bool] = Field(
|
||||||
|
None, alias="nutrition-information", description="Show nutrition info"
|
||||||
|
)
|
||||||
|
tools: Optional[bool] = Field(None, description="Show tools list")
|
||||||
|
|
||||||
|
model_config = ConfigDict(populate_by_name=True)
|
||||||
|
|
||||||
|
|
||||||
|
class CookbookConfig(BaseModel):
|
||||||
|
"""Cookbook app configuration."""
|
||||||
|
|
||||||
|
folder: Optional[str] = Field(None, description="Recipe folder path")
|
||||||
|
update_interval: Optional[int] = Field(
|
||||||
|
None, description="Auto-rescan interval in minutes"
|
||||||
|
)
|
||||||
|
print_image: Optional[bool] = Field(None, description="Print images with recipes")
|
||||||
|
visibleInfoBlocks: Optional[VisibleInfoBlocks] = Field(
|
||||||
|
None, description="Visible info blocks configuration"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class APIVersion(BaseModel):
|
||||||
|
"""API version information."""
|
||||||
|
|
||||||
|
epoch: int = Field(description="API epoch")
|
||||||
|
major: int = Field(description="Major version")
|
||||||
|
minor: int = Field(description="Minor version")
|
||||||
|
|
||||||
|
|
||||||
|
class Version(BaseModel):
|
||||||
|
"""Version information for Cookbook app and API."""
|
||||||
|
|
||||||
|
cookbook_version: List[int] = Field(description="Cookbook app version")
|
||||||
|
api_version: APIVersion = Field(description="API version")
|
||||||
|
|
||||||
|
|
||||||
|
# Response models for MCP tools
|
||||||
|
|
||||||
|
|
||||||
|
class ImportRecipeResponse(BaseResponse):
|
||||||
|
"""Response model for recipe import."""
|
||||||
|
|
||||||
|
recipe: Recipe = Field(description="The imported recipe")
|
||||||
|
recipe_id: str = Field(description="ID of the imported recipe")
|
||||||
|
|
||||||
|
|
||||||
|
class CreateRecipeResponse(IdResponse):
|
||||||
|
"""Response model for recipe creation."""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateRecipeResponse(IdResponse):
|
||||||
|
"""Response model for recipe update."""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class DeleteRecipeResponse(StatusResponse):
|
||||||
|
"""Response model for recipe deletion."""
|
||||||
|
|
||||||
|
deleted_id: int = Field(description="ID of deleted recipe")
|
||||||
|
|
||||||
|
|
||||||
|
class ListRecipesResponse(BaseResponse):
|
||||||
|
"""Response model for listing recipes."""
|
||||||
|
|
||||||
|
recipes: List[RecipeStub] = Field(description="List of recipe stubs")
|
||||||
|
total_count: int = Field(description="Total number of recipes")
|
||||||
|
|
||||||
|
|
||||||
|
class SearchRecipesResponse(BaseResponse):
|
||||||
|
"""Response model for recipe search."""
|
||||||
|
|
||||||
|
recipes: List[RecipeStub] = Field(description="Matching recipes")
|
||||||
|
query: str = Field(description="Search query used")
|
||||||
|
total_found: int = Field(description="Number of recipes found")
|
||||||
|
|
||||||
|
|
||||||
|
class ListCategoriesResponse(BaseResponse):
|
||||||
|
"""Response model for listing categories."""
|
||||||
|
|
||||||
|
categories: List[Category] = Field(description="List of categories")
|
||||||
|
|
||||||
|
|
||||||
|
class ListKeywordsResponse(BaseResponse):
|
||||||
|
"""Response model for listing keywords."""
|
||||||
|
|
||||||
|
keywords: List[Keyword] = Field(description="List of keywords")
|
||||||
|
|
||||||
|
|
||||||
|
class ReindexResponse(StatusResponse):
|
||||||
|
"""Response model for reindex operation."""
|
||||||
|
|
||||||
|
pass
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
from typing import Any, Dict, List, Optional, Union
|
from typing import Any, Dict, List, Optional, Union
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ class FileInfo(BaseModel):
|
|||||||
None, description="Last modification time (ISO format)"
|
None, description="Last modification time (ISO format)"
|
||||||
)
|
)
|
||||||
etag: Optional[str] = Field(None, description="ETag for versioning")
|
etag: Optional[str] = Field(None, description="ETag for versioning")
|
||||||
|
file_id: Optional[int] = Field(None, description="Nextcloud file ID")
|
||||||
|
is_favorite: Optional[bool] = Field(None, description="Whether file is favorited")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def last_modified_datetime(self) -> Optional[datetime]:
|
def last_modified_datetime(self) -> Optional[datetime]:
|
||||||
@@ -38,7 +40,7 @@ class DirectoryListing(BaseResponse):
|
|||||||
"""Response model for directory listings."""
|
"""Response model for directory listings."""
|
||||||
|
|
||||||
path: str = Field(description="Directory path")
|
path: str = Field(description="Directory path")
|
||||||
items: List[FileInfo] = Field(description="Files and directories in the path")
|
files: List[FileInfo] = Field(description="Files and directories in the path")
|
||||||
total_count: int = Field(description="Total number of items")
|
total_count: int = Field(description="Total number of items")
|
||||||
directories_count: int = Field(description="Number of directories")
|
directories_count: int = Field(description="Number of directories")
|
||||||
files_count: int = Field(description="Number of files")
|
files_count: int = Field(description="Number of files")
|
||||||
@@ -106,3 +108,14 @@ class CopyResourceResponse(StatusResponse):
|
|||||||
overwrite: bool = Field(
|
overwrite: bool = Field(
|
||||||
description="Whether the destination was overwritten if it existed"
|
description="Whether the destination was overwritten if it existed"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SearchFilesResponse(BaseResponse):
|
||||||
|
"""Response model for WebDAV search operations."""
|
||||||
|
|
||||||
|
results: List[FileInfo] = Field(description="Search results")
|
||||||
|
total_found: int = Field(description="Total number of files found")
|
||||||
|
scope: str = Field(description="The scope/path that was searched")
|
||||||
|
filters_applied: Optional[dict] = Field(
|
||||||
|
None, description="Filters that were applied to the search"
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from .calendar import configure_calendar_tools
|
from .calendar import configure_calendar_tools
|
||||||
from .contacts import configure_contacts_tools
|
from .contacts import configure_contacts_tools
|
||||||
|
from .cookbook import configure_cookbook_tools
|
||||||
from .deck import configure_deck_tools
|
from .deck import configure_deck_tools
|
||||||
from .notes import configure_notes_tools
|
from .notes import configure_notes_tools
|
||||||
from .sharing import configure_sharing_tools
|
from .sharing import configure_sharing_tools
|
||||||
@@ -9,6 +10,7 @@ from .webdav import configure_webdav_tools
|
|||||||
__all__ = [
|
__all__ = [
|
||||||
"configure_calendar_tools",
|
"configure_calendar_tools",
|
||||||
"configure_contacts_tools",
|
"configure_contacts_tools",
|
||||||
|
"configure_cookbook_tools",
|
||||||
"configure_deck_tools",
|
"configure_deck_tools",
|
||||||
"configure_notes_tools",
|
"configure_notes_tools",
|
||||||
"configure_sharing_tools",
|
"configure_sharing_tools",
|
||||||
|
|||||||
@@ -4,8 +4,14 @@ from typing import Optional
|
|||||||
|
|
||||||
from mcp.server.fastmcp import Context, FastMCP
|
from mcp.server.fastmcp import Context, FastMCP
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.auth import require_scopes
|
||||||
from nextcloud_mcp_server.context import get_client
|
from nextcloud_mcp_server.context import get_client
|
||||||
from nextcloud_mcp_server.models.calendar import Calendar, ListCalendarsResponse
|
from nextcloud_mcp_server.models.calendar import (
|
||||||
|
Calendar,
|
||||||
|
ListCalendarsResponse,
|
||||||
|
ListTodosResponse,
|
||||||
|
Todo,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -13,6 +19,7 @@ logger = logging.getLogger(__name__)
|
|||||||
def configure_calendar_tools(mcp: FastMCP):
|
def configure_calendar_tools(mcp: FastMCP):
|
||||||
# Calendar tools
|
# Calendar tools
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
|
@require_scopes("calendar:read")
|
||||||
async def nc_calendar_list_calendars(ctx: Context) -> ListCalendarsResponse:
|
async def nc_calendar_list_calendars(ctx: Context) -> ListCalendarsResponse:
|
||||||
"""List all available calendars for the user"""
|
"""List all available calendars for the user"""
|
||||||
client = get_client(ctx)
|
client = get_client(ctx)
|
||||||
@@ -22,6 +29,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
|||||||
return ListCalendarsResponse(calendars=calendars, total_count=len(calendars))
|
return ListCalendarsResponse(calendars=calendars, total_count=len(calendars))
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
|
@require_scopes("calendar:write")
|
||||||
async def nc_calendar_create_event(
|
async def nc_calendar_create_event(
|
||||||
calendar_name: str,
|
calendar_name: str,
|
||||||
title: str,
|
title: str,
|
||||||
@@ -97,6 +105,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
|||||||
return await client.calendar.create_event(calendar_name, event_data)
|
return await client.calendar.create_event(calendar_name, event_data)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
|
@require_scopes("calendar:read")
|
||||||
async def nc_calendar_list_events(
|
async def nc_calendar_list_events(
|
||||||
calendar_name: str,
|
calendar_name: str,
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
@@ -198,6 +207,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
|||||||
return events
|
return events
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
|
@require_scopes("calendar:read")
|
||||||
async def nc_calendar_get_event(
|
async def nc_calendar_get_event(
|
||||||
calendar_name: str,
|
calendar_name: str,
|
||||||
event_uid: str,
|
event_uid: str,
|
||||||
@@ -209,6 +219,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
|||||||
return event_data
|
return event_data
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
|
@require_scopes("calendar:write")
|
||||||
async def nc_calendar_update_event(
|
async def nc_calendar_update_event(
|
||||||
calendar_name: str,
|
calendar_name: str,
|
||||||
event_uid: str,
|
event_uid: str,
|
||||||
@@ -281,6 +292,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
|
@require_scopes("calendar:write")
|
||||||
async def nc_calendar_delete_event(
|
async def nc_calendar_delete_event(
|
||||||
calendar_name: str,
|
calendar_name: str,
|
||||||
event_uid: str,
|
event_uid: str,
|
||||||
@@ -291,6 +303,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
|||||||
return await client.calendar.delete_event(calendar_name, event_uid)
|
return await client.calendar.delete_event(calendar_name, event_uid)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
|
@require_scopes("calendar:write")
|
||||||
async def nc_calendar_create_meeting(
|
async def nc_calendar_create_meeting(
|
||||||
title: str,
|
title: str,
|
||||||
date: str,
|
date: str,
|
||||||
@@ -356,6 +369,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
|||||||
return await client.calendar.create_event(calendar_name, event_data)
|
return await client.calendar.create_event(calendar_name, event_data)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
|
@require_scopes("calendar:read")
|
||||||
async def nc_calendar_get_upcoming_events(
|
async def nc_calendar_get_upcoming_events(
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
calendar_name: str = "", # Empty = all calendars
|
calendar_name: str = "", # Empty = all calendars
|
||||||
@@ -405,6 +419,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
|||||||
return all_events[:limit]
|
return all_events[:limit]
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
|
@require_scopes("calendar:read")
|
||||||
async def nc_calendar_find_availability(
|
async def nc_calendar_find_availability(
|
||||||
duration_minutes: int,
|
duration_minutes: int,
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
@@ -484,6 +499,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
|
@require_scopes("calendar:write")
|
||||||
async def nc_calendar_bulk_operations(
|
async def nc_calendar_bulk_operations(
|
||||||
operation: str, # "update", "delete", "move"
|
operation: str, # "update", "delete", "move"
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
@@ -732,6 +748,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
|||||||
}
|
}
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
|
@require_scopes("calendar:write")
|
||||||
async def nc_calendar_manage_calendar(
|
async def nc_calendar_manage_calendar(
|
||||||
action: str, # "create", "delete", "update", "list"
|
action: str, # "create", "delete", "update", "list"
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
@@ -796,3 +813,214 @@ def configure_calendar_tools(mcp: FastMCP):
|
|||||||
|
|
||||||
else:
|
else:
|
||||||
raise ValueError("Action must be 'create', 'delete', 'update', or 'list'")
|
raise ValueError("Action must be 'create', 'delete', 'update', or 'list'")
|
||||||
|
|
||||||
|
# ============= Todo/Task Tools =============
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
@require_scopes("todo:read", "calendar:read")
|
||||||
|
async def nc_calendar_list_todos(
|
||||||
|
calendar_name: str,
|
||||||
|
ctx: Context,
|
||||||
|
status: Optional[str] = None,
|
||||||
|
min_priority: Optional[int] = None,
|
||||||
|
categories: Optional[str] = None,
|
||||||
|
summary_contains: Optional[str] = None,
|
||||||
|
) -> ListTodosResponse:
|
||||||
|
"""List todos/tasks in a calendar with optional filtering.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
calendar_name: Name of the calendar to list todos from
|
||||||
|
ctx: MCP context
|
||||||
|
status: Filter by status (NEEDS-ACTION, IN-PROCESS, COMPLETED, CANCELLED)
|
||||||
|
min_priority: Filter by minimum priority (1=highest, 9=lowest)
|
||||||
|
categories: Filter by categories (comma-separated, e.g., "work,urgent")
|
||||||
|
summary_contains: Filter todos where summary contains this text
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of todos matching the filters
|
||||||
|
"""
|
||||||
|
client = get_client(ctx)
|
||||||
|
|
||||||
|
# Build filters dictionary
|
||||||
|
filters = {}
|
||||||
|
if status is not None:
|
||||||
|
filters["status"] = status
|
||||||
|
if min_priority is not None:
|
||||||
|
filters["min_priority"] = min_priority
|
||||||
|
if categories is not None:
|
||||||
|
filters["categories"] = [cat.strip() for cat in categories.split(",")]
|
||||||
|
if summary_contains is not None:
|
||||||
|
filters["summary_contains"] = summary_contains
|
||||||
|
|
||||||
|
todos_data = await client.calendar.list_todos(
|
||||||
|
calendar_name, filters if filters else None
|
||||||
|
)
|
||||||
|
|
||||||
|
todos = [Todo(**todo_data) for todo_data in todos_data]
|
||||||
|
return ListTodosResponse(
|
||||||
|
todos=todos, calendar_name=calendar_name, total_count=len(todos)
|
||||||
|
)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
@require_scopes("todo:write", "calendar:read")
|
||||||
|
async def nc_calendar_create_todo(
|
||||||
|
calendar_name: str,
|
||||||
|
summary: str,
|
||||||
|
ctx: Context,
|
||||||
|
description: str = "",
|
||||||
|
status: str = "NEEDS-ACTION",
|
||||||
|
priority: int = 0,
|
||||||
|
due: str = "",
|
||||||
|
dtstart: str = "",
|
||||||
|
categories: str = "",
|
||||||
|
):
|
||||||
|
"""Create a new todo/task in a calendar.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
calendar_name: Name of the calendar to create the todo in
|
||||||
|
summary: Todo title/summary
|
||||||
|
ctx: MCP context
|
||||||
|
description: Detailed description of the todo
|
||||||
|
status: Todo status (NEEDS-ACTION, IN-PROCESS, COMPLETED, CANCELLED)
|
||||||
|
priority: Priority (0=undefined, 1=highest, 9=lowest)
|
||||||
|
due: Due date/time (ISO format, e.g., "2025-01-15T14:00:00")
|
||||||
|
dtstart: Start date/time (ISO format)
|
||||||
|
categories: Comma-separated categories (e.g., "work,urgent")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with todo creation result
|
||||||
|
"""
|
||||||
|
client = get_client(ctx)
|
||||||
|
|
||||||
|
todo_data = {
|
||||||
|
"summary": summary,
|
||||||
|
"description": description,
|
||||||
|
"status": status,
|
||||||
|
"priority": priority,
|
||||||
|
"due": due,
|
||||||
|
"dtstart": dtstart,
|
||||||
|
"categories": categories,
|
||||||
|
}
|
||||||
|
|
||||||
|
return await client.calendar.create_todo(calendar_name, todo_data)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
@require_scopes("todo:write", "calendar:read")
|
||||||
|
async def nc_calendar_update_todo(
|
||||||
|
calendar_name: str,
|
||||||
|
todo_uid: str,
|
||||||
|
ctx: Context,
|
||||||
|
summary: Optional[str] = None,
|
||||||
|
description: Optional[str] = None,
|
||||||
|
status: Optional[str] = None,
|
||||||
|
priority: Optional[int] = None,
|
||||||
|
percent_complete: Optional[int] = None,
|
||||||
|
due: Optional[str] = None,
|
||||||
|
dtstart: Optional[str] = None,
|
||||||
|
completed: Optional[str] = None,
|
||||||
|
categories: Optional[str] = None,
|
||||||
|
):
|
||||||
|
"""Update an existing todo/task.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
calendar_name: Name of the calendar containing the todo
|
||||||
|
todo_uid: UID of the todo to update
|
||||||
|
ctx: MCP context
|
||||||
|
summary: New summary/title
|
||||||
|
description: New description
|
||||||
|
status: New status (NEEDS-ACTION, IN-PROCESS, COMPLETED, CANCELLED)
|
||||||
|
priority: New priority (0-9)
|
||||||
|
percent_complete: New completion percentage (0-100)
|
||||||
|
due: New due date/time (ISO format)
|
||||||
|
dtstart: New start date/time (ISO format)
|
||||||
|
completed: Completion timestamp (ISO format)
|
||||||
|
categories: New categories (comma-separated)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with todo update result
|
||||||
|
"""
|
||||||
|
client = get_client(ctx)
|
||||||
|
|
||||||
|
# Build update data with only non-None values
|
||||||
|
todo_data = {}
|
||||||
|
if summary is not None:
|
||||||
|
todo_data["summary"] = summary
|
||||||
|
if description is not None:
|
||||||
|
todo_data["description"] = description
|
||||||
|
if status is not None:
|
||||||
|
todo_data["status"] = status
|
||||||
|
if priority is not None:
|
||||||
|
todo_data["priority"] = priority
|
||||||
|
if percent_complete is not None:
|
||||||
|
todo_data["percent_complete"] = percent_complete
|
||||||
|
if due is not None:
|
||||||
|
todo_data["due"] = due
|
||||||
|
if dtstart is not None:
|
||||||
|
todo_data["dtstart"] = dtstart
|
||||||
|
if completed is not None:
|
||||||
|
todo_data["completed"] = completed
|
||||||
|
if categories is not None:
|
||||||
|
todo_data["categories"] = categories
|
||||||
|
|
||||||
|
return await client.calendar.update_todo(calendar_name, todo_uid, todo_data)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
@require_scopes("todo:write", "calendar:read")
|
||||||
|
async def nc_calendar_delete_todo(
|
||||||
|
calendar_name: str,
|
||||||
|
todo_uid: str,
|
||||||
|
ctx: Context,
|
||||||
|
):
|
||||||
|
"""Delete a todo/task from a calendar.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
calendar_name: Name of the calendar containing the todo
|
||||||
|
todo_uid: UID of the todo to delete
|
||||||
|
ctx: MCP context
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with deletion status
|
||||||
|
"""
|
||||||
|
client = get_client(ctx)
|
||||||
|
return await client.calendar.delete_todo(calendar_name, todo_uid)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
@require_scopes("todo:read", "calendar:read")
|
||||||
|
async def nc_calendar_search_todos(
|
||||||
|
ctx: Context,
|
||||||
|
status: Optional[str] = None,
|
||||||
|
min_priority: Optional[int] = None,
|
||||||
|
categories: Optional[str] = None,
|
||||||
|
summary_contains: Optional[str] = None,
|
||||||
|
):
|
||||||
|
"""Search todos across all calendars with optional filtering.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ctx: MCP context
|
||||||
|
status: Filter by status (NEEDS-ACTION, IN-PROCESS, COMPLETED, CANCELLED)
|
||||||
|
min_priority: Filter by minimum priority (1=highest, 9=lowest)
|
||||||
|
categories: Filter by categories (comma-separated, e.g., "work,urgent")
|
||||||
|
summary_contains: Filter todos where summary contains this text
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of todos matching the filters from all calendars
|
||||||
|
"""
|
||||||
|
client = get_client(ctx)
|
||||||
|
|
||||||
|
# Build filters dictionary
|
||||||
|
filters = {}
|
||||||
|
if status is not None:
|
||||||
|
filters["status"] = status
|
||||||
|
if min_priority is not None:
|
||||||
|
filters["min_priority"] = min_priority
|
||||||
|
if categories is not None:
|
||||||
|
filters["categories"] = [cat.strip() for cat in categories.split(",")]
|
||||||
|
if summary_contains is not None:
|
||||||
|
filters["summary_contains"] = summary_contains
|
||||||
|
|
||||||
|
todos_data = await client.calendar.search_todos_across_calendars(
|
||||||
|
filters if filters else None
|
||||||
|
)
|
||||||
|
|
||||||
|
todos = [Todo(**todo_data) for todo_data in todos_data]
|
||||||
|
return ListTodosResponse(todos=todos, total_count=len(todos))
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import logging
|
|||||||
|
|
||||||
from mcp.server.fastmcp import Context, FastMCP
|
from mcp.server.fastmcp import Context, FastMCP
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.auth import require_scopes
|
||||||
from nextcloud_mcp_server.context import get_client
|
from nextcloud_mcp_server.context import get_client
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -10,18 +11,21 @@ logger = logging.getLogger(__name__)
|
|||||||
def configure_contacts_tools(mcp: FastMCP):
|
def configure_contacts_tools(mcp: FastMCP):
|
||||||
# Contacts tools
|
# Contacts tools
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
|
@require_scopes("contacts:read")
|
||||||
async def nc_contacts_list_addressbooks(ctx: Context):
|
async def nc_contacts_list_addressbooks(ctx: Context):
|
||||||
"""List all addressbooks for the user."""
|
"""List all addressbooks for the user."""
|
||||||
client = get_client(ctx)
|
client = get_client(ctx)
|
||||||
return await client.contacts.list_addressbooks()
|
return await client.contacts.list_addressbooks()
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
|
@require_scopes("contacts:read")
|
||||||
async def nc_contacts_list_contacts(ctx: Context, *, addressbook: str):
|
async def nc_contacts_list_contacts(ctx: Context, *, addressbook: str):
|
||||||
"""List all contacts in the specified addressbook."""
|
"""List all contacts in the specified addressbook."""
|
||||||
client = get_client(ctx)
|
client = get_client(ctx)
|
||||||
return await client.contacts.list_contacts(addressbook=addressbook)
|
return await client.contacts.list_contacts(addressbook=addressbook)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
|
@require_scopes("contacts:write")
|
||||||
async def nc_contacts_create_addressbook(
|
async def nc_contacts_create_addressbook(
|
||||||
ctx: Context, *, name: str, display_name: str
|
ctx: Context, *, name: str, display_name: str
|
||||||
):
|
):
|
||||||
@@ -37,12 +41,14 @@ def configure_contacts_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
|
@require_scopes("contacts:write")
|
||||||
async def nc_contacts_delete_addressbook(ctx: Context, *, name: str):
|
async def nc_contacts_delete_addressbook(ctx: Context, *, name: str):
|
||||||
"""Delete an addressbook."""
|
"""Delete an addressbook."""
|
||||||
client = get_client(ctx)
|
client = get_client(ctx)
|
||||||
return await client.contacts.delete_addressbook(name=name)
|
return await client.contacts.delete_addressbook(name=name)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
|
@require_scopes("contacts:write")
|
||||||
async def nc_contacts_create_contact(
|
async def nc_contacts_create_contact(
|
||||||
ctx: Context, *, addressbook: str, uid: str, contact_data: dict
|
ctx: Context, *, addressbook: str, uid: str, contact_data: dict
|
||||||
):
|
):
|
||||||
@@ -59,12 +65,14 @@ def configure_contacts_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
|
@require_scopes("contacts:write")
|
||||||
async def nc_contacts_delete_contact(ctx: Context, *, addressbook: str, uid: str):
|
async def nc_contacts_delete_contact(ctx: Context, *, addressbook: str, uid: str):
|
||||||
"""Delete a contact."""
|
"""Delete a contact."""
|
||||||
client = get_client(ctx)
|
client = get_client(ctx)
|
||||||
return await client.contacts.delete_contact(addressbook=addressbook, uid=uid)
|
return await client.contacts.delete_contact(addressbook=addressbook, uid=uid)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
|
@require_scopes("contacts:write")
|
||||||
async def nc_contacts_update_contact(
|
async def nc_contacts_update_contact(
|
||||||
ctx: Context, *, addressbook: str, uid: str, contact_data: dict, etag: str = ""
|
ctx: Context, *, addressbook: str, uid: str, contact_data: dict, etag: str = ""
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -0,0 +1,608 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
from httpx import HTTPStatusError, RequestError
|
||||||
|
from mcp.server.fastmcp import Context, FastMCP
|
||||||
|
from mcp.shared.exceptions import McpError
|
||||||
|
from mcp.types import ErrorData
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.auth import require_scopes
|
||||||
|
from nextcloud_mcp_server.context import get_client
|
||||||
|
from nextcloud_mcp_server.models.cookbook import (
|
||||||
|
Category,
|
||||||
|
CookbookConfig,
|
||||||
|
CreateRecipeResponse,
|
||||||
|
DeleteRecipeResponse,
|
||||||
|
ImportRecipeResponse,
|
||||||
|
Keyword,
|
||||||
|
ListCategoriesResponse,
|
||||||
|
ListKeywordsResponse,
|
||||||
|
ListRecipesResponse,
|
||||||
|
Recipe,
|
||||||
|
RecipeStub,
|
||||||
|
ReindexResponse,
|
||||||
|
SearchRecipesResponse,
|
||||||
|
UpdateRecipeResponse,
|
||||||
|
Version,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def configure_cookbook_tools(mcp: FastMCP):
|
||||||
|
@mcp.resource("cookbook://version")
|
||||||
|
async def cookbook_get_version():
|
||||||
|
"""Get the Cookbook app and API version"""
|
||||||
|
ctx: Context = mcp.get_context()
|
||||||
|
client = get_client(ctx)
|
||||||
|
version_data = await client.cookbook.get_version()
|
||||||
|
return Version(**version_data)
|
||||||
|
|
||||||
|
@mcp.resource("cookbook://config")
|
||||||
|
async def cookbook_get_config():
|
||||||
|
"""Get the Cookbook app configuration"""
|
||||||
|
ctx: Context = mcp.get_context()
|
||||||
|
client = get_client(ctx)
|
||||||
|
config_data = await client.cookbook.get_config()
|
||||||
|
return CookbookConfig(**config_data)
|
||||||
|
|
||||||
|
@mcp.resource("nc://Cookbook/{recipe_id}")
|
||||||
|
async def nc_cookbook_get_recipe_resource(recipe_id: int):
|
||||||
|
"""Get a recipe by ID using resource URI"""
|
||||||
|
ctx: Context = mcp.get_context()
|
||||||
|
client = get_client(ctx)
|
||||||
|
try:
|
||||||
|
recipe_data = await client.cookbook.get_recipe(recipe_id)
|
||||||
|
return Recipe(**recipe_data)
|
||||||
|
except HTTPStatusError as e:
|
||||||
|
if e.response.status_code == 404:
|
||||||
|
raise McpError(
|
||||||
|
ErrorData(code=-1, message=f"Recipe {recipe_id} not found")
|
||||||
|
)
|
||||||
|
elif e.response.status_code == 403:
|
||||||
|
raise McpError(
|
||||||
|
ErrorData(code=-1, message=f"Access denied to recipe {recipe_id}")
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise McpError(
|
||||||
|
ErrorData(
|
||||||
|
code=-1,
|
||||||
|
message=f"Failed to retrieve recipe {recipe_id}: {e.response.reason_phrase}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
@require_scopes("cookbook:write")
|
||||||
|
async def nc_cookbook_import_recipe(url: str, ctx: Context) -> ImportRecipeResponse:
|
||||||
|
"""Import a recipe from a URL using schema.org metadata.
|
||||||
|
|
||||||
|
This extracts recipe data from websites that use schema.org Recipe markup.
|
||||||
|
Many popular recipe sites support this standard."""
|
||||||
|
client = get_client(ctx)
|
||||||
|
try:
|
||||||
|
recipe_data = await client.cookbook.import_recipe(url)
|
||||||
|
recipe = Recipe(**recipe_data)
|
||||||
|
return ImportRecipeResponse(
|
||||||
|
recipe=recipe,
|
||||||
|
recipe_id=recipe.id or "unknown",
|
||||||
|
)
|
||||||
|
except RequestError as e:
|
||||||
|
# RequestError can have empty str() - get details from exception attributes
|
||||||
|
error_detail = (
|
||||||
|
str(e)
|
||||||
|
or f"{type(e).__name__}: {getattr(e, '__cause__', 'unknown cause')}"
|
||||||
|
)
|
||||||
|
raise McpError(
|
||||||
|
ErrorData(
|
||||||
|
code=-1,
|
||||||
|
message=f"Network error importing recipe from {url}: {error_detail}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except HTTPStatusError as e:
|
||||||
|
if e.response.status_code == 400:
|
||||||
|
raise McpError(
|
||||||
|
ErrorData(
|
||||||
|
code=-1,
|
||||||
|
message=f"Invalid URL or missing 'url' field: {url}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif e.response.status_code == 409:
|
||||||
|
raise McpError(
|
||||||
|
ErrorData(
|
||||||
|
code=-1,
|
||||||
|
message="A recipe with this name already exists. Import aborted.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif e.response.status_code == 403:
|
||||||
|
raise McpError(
|
||||||
|
ErrorData(
|
||||||
|
code=-1,
|
||||||
|
message="Access denied: insufficient permissions to import recipes",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise McpError(
|
||||||
|
ErrorData(
|
||||||
|
code=-1,
|
||||||
|
message=f"Failed to import recipe from {url}: server error ({e.response.status_code})",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
@require_scopes("cookbook:read")
|
||||||
|
async def nc_cookbook_list_recipes(ctx: Context) -> ListRecipesResponse:
|
||||||
|
"""Get all recipes in the database"""
|
||||||
|
client = get_client(ctx)
|
||||||
|
try:
|
||||||
|
recipes_data = await client.cookbook.list_recipes()
|
||||||
|
recipes = [RecipeStub(**r) for r in recipes_data]
|
||||||
|
return ListRecipesResponse(recipes=recipes, total_count=len(recipes))
|
||||||
|
except HTTPStatusError as e:
|
||||||
|
if e.response.status_code == 403:
|
||||||
|
raise McpError(
|
||||||
|
ErrorData(
|
||||||
|
code=-1,
|
||||||
|
message="Access denied: insufficient permissions to list recipes",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise McpError(
|
||||||
|
ErrorData(
|
||||||
|
code=-1,
|
||||||
|
message=f"Failed to list recipes: server error ({e.response.status_code})",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
@require_scopes("cookbook:read")
|
||||||
|
async def nc_cookbook_get_recipe(recipe_id: int, ctx: Context) -> Recipe:
|
||||||
|
"""Get a specific recipe by its ID"""
|
||||||
|
client = get_client(ctx)
|
||||||
|
try:
|
||||||
|
recipe_data = await client.cookbook.get_recipe(recipe_id)
|
||||||
|
return Recipe(**recipe_data)
|
||||||
|
except HTTPStatusError as e:
|
||||||
|
if e.response.status_code == 404:
|
||||||
|
raise McpError(
|
||||||
|
ErrorData(code=-1, message=f"Recipe {recipe_id} not found")
|
||||||
|
)
|
||||||
|
elif e.response.status_code == 403:
|
||||||
|
raise McpError(
|
||||||
|
ErrorData(code=-1, message=f"Access denied to recipe {recipe_id}")
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise McpError(
|
||||||
|
ErrorData(
|
||||||
|
code=-1,
|
||||||
|
message=f"Failed to retrieve recipe {recipe_id}: {e.response.reason_phrase}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
@require_scopes("cookbook:write")
|
||||||
|
async def nc_cookbook_create_recipe(
|
||||||
|
name: str,
|
||||||
|
description: str | None = None,
|
||||||
|
ingredients: list[str] | None = None,
|
||||||
|
instructions: list[str] | None = None,
|
||||||
|
url: str | None = None,
|
||||||
|
prep_time: str | None = None,
|
||||||
|
cook_time: str | None = None,
|
||||||
|
total_time: str | None = None,
|
||||||
|
recipe_yield: int | None = None,
|
||||||
|
category: str | None = None,
|
||||||
|
keywords: str | None = None,
|
||||||
|
ctx: Context = None,
|
||||||
|
) -> CreateRecipeResponse:
|
||||||
|
"""Create a new recipe.
|
||||||
|
|
||||||
|
Required: name
|
||||||
|
Optional: All other recipe fields following schema.org/Recipe format.
|
||||||
|
|
||||||
|
Times should be in ISO8601 duration format (e.g., 'PT30M' for 30 minutes)."""
|
||||||
|
client = get_client(ctx)
|
||||||
|
|
||||||
|
recipe_data = {"name": name}
|
||||||
|
if description:
|
||||||
|
recipe_data["description"] = description
|
||||||
|
if ingredients:
|
||||||
|
recipe_data["recipeIngredient"] = ingredients
|
||||||
|
if instructions:
|
||||||
|
recipe_data["recipeInstructions"] = instructions
|
||||||
|
if url:
|
||||||
|
recipe_data["url"] = url
|
||||||
|
if prep_time:
|
||||||
|
recipe_data["prepTime"] = prep_time
|
||||||
|
if cook_time:
|
||||||
|
recipe_data["cookTime"] = cook_time
|
||||||
|
if total_time:
|
||||||
|
recipe_data["totalTime"] = total_time
|
||||||
|
if recipe_yield:
|
||||||
|
recipe_data["recipeYield"] = recipe_yield
|
||||||
|
if category:
|
||||||
|
recipe_data["recipeCategory"] = category
|
||||||
|
if keywords:
|
||||||
|
recipe_data["keywords"] = keywords
|
||||||
|
|
||||||
|
try:
|
||||||
|
recipe_id = await client.cookbook.create_recipe(recipe_data)
|
||||||
|
return CreateRecipeResponse(id=recipe_id)
|
||||||
|
except HTTPStatusError as e:
|
||||||
|
if e.response.status_code == 409:
|
||||||
|
raise McpError(
|
||||||
|
ErrorData(
|
||||||
|
code=-1,
|
||||||
|
message=f"A recipe with name '{name}' already exists",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif e.response.status_code == 422:
|
||||||
|
raise McpError(
|
||||||
|
ErrorData(
|
||||||
|
code=-1,
|
||||||
|
message="Recipe name is required and cannot be empty",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif e.response.status_code == 403:
|
||||||
|
raise McpError(
|
||||||
|
ErrorData(
|
||||||
|
code=-1,
|
||||||
|
message="Access denied: insufficient permissions to create recipes",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise McpError(
|
||||||
|
ErrorData(
|
||||||
|
code=-1,
|
||||||
|
message=f"Failed to create recipe: server error ({e.response.status_code})",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
@require_scopes("cookbook:write")
|
||||||
|
async def nc_cookbook_update_recipe(
|
||||||
|
recipe_id: int,
|
||||||
|
name: str | None = None,
|
||||||
|
description: str | None = None,
|
||||||
|
ingredients: list[str] | None = None,
|
||||||
|
instructions: list[str] | None = None,
|
||||||
|
url: str | None = None,
|
||||||
|
prep_time: str | None = None,
|
||||||
|
cook_time: str | None = None,
|
||||||
|
total_time: str | None = None,
|
||||||
|
recipe_yield: int | None = None,
|
||||||
|
category: str | None = None,
|
||||||
|
keywords: str | None = None,
|
||||||
|
ctx: Context = None,
|
||||||
|
) -> UpdateRecipeResponse:
|
||||||
|
"""Update an existing recipe.
|
||||||
|
|
||||||
|
Provide only the fields you want to update. Unspecified fields remain unchanged."""
|
||||||
|
client = get_client(ctx)
|
||||||
|
|
||||||
|
# First get the current recipe
|
||||||
|
try:
|
||||||
|
current_recipe = await client.cookbook.get_recipe(recipe_id)
|
||||||
|
except HTTPStatusError as e:
|
||||||
|
if e.response.status_code == 404:
|
||||||
|
raise McpError(
|
||||||
|
ErrorData(code=-1, message=f"Recipe {recipe_id} not found")
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise McpError(
|
||||||
|
ErrorData(
|
||||||
|
code=-1,
|
||||||
|
message=f"Failed to fetch recipe {recipe_id}: {e.response.reason_phrase}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update only specified fields
|
||||||
|
recipe_data = current_recipe.copy()
|
||||||
|
if name is not None:
|
||||||
|
recipe_data["name"] = name
|
||||||
|
if description is not None:
|
||||||
|
recipe_data["description"] = description
|
||||||
|
if ingredients is not None:
|
||||||
|
recipe_data["recipeIngredient"] = ingredients
|
||||||
|
if instructions is not None:
|
||||||
|
recipe_data["recipeInstructions"] = instructions
|
||||||
|
if url is not None:
|
||||||
|
recipe_data["url"] = url
|
||||||
|
if prep_time is not None:
|
||||||
|
recipe_data["prepTime"] = prep_time
|
||||||
|
if cook_time is not None:
|
||||||
|
recipe_data["cookTime"] = cook_time
|
||||||
|
if total_time is not None:
|
||||||
|
recipe_data["totalTime"] = total_time
|
||||||
|
if recipe_yield is not None:
|
||||||
|
recipe_data["recipeYield"] = recipe_yield
|
||||||
|
if category is not None:
|
||||||
|
recipe_data["recipeCategory"] = category
|
||||||
|
if keywords is not None:
|
||||||
|
recipe_data["keywords"] = keywords
|
||||||
|
|
||||||
|
try:
|
||||||
|
updated_id = await client.cookbook.update_recipe(recipe_id, recipe_data)
|
||||||
|
return UpdateRecipeResponse(id=updated_id)
|
||||||
|
except HTTPStatusError as e:
|
||||||
|
if e.response.status_code == 422:
|
||||||
|
raise McpError(
|
||||||
|
ErrorData(
|
||||||
|
code=-1,
|
||||||
|
message="Recipe name is required and cannot be empty",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif e.response.status_code == 403:
|
||||||
|
raise McpError(
|
||||||
|
ErrorData(
|
||||||
|
code=-1,
|
||||||
|
message=f"Access denied: insufficient permissions to update recipe {recipe_id}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise McpError(
|
||||||
|
ErrorData(
|
||||||
|
code=-1,
|
||||||
|
message=f"Failed to update recipe {recipe_id}: server error ({e.response.status_code})",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
@require_scopes("cookbook:write")
|
||||||
|
async def nc_cookbook_delete_recipe(
|
||||||
|
recipe_id: int, ctx: Context
|
||||||
|
) -> DeleteRecipeResponse:
|
||||||
|
"""Delete a recipe permanently"""
|
||||||
|
logger.info("Deleting recipe %s", recipe_id)
|
||||||
|
client = get_client(ctx)
|
||||||
|
try:
|
||||||
|
message = await client.cookbook.delete_recipe(recipe_id)
|
||||||
|
return DeleteRecipeResponse(
|
||||||
|
status_code=200,
|
||||||
|
message=message,
|
||||||
|
deleted_id=recipe_id,
|
||||||
|
)
|
||||||
|
except HTTPStatusError as e:
|
||||||
|
if e.response.status_code == 404:
|
||||||
|
raise McpError(
|
||||||
|
ErrorData(code=-1, message=f"Recipe {recipe_id} not found")
|
||||||
|
)
|
||||||
|
elif e.response.status_code == 403:
|
||||||
|
raise McpError(
|
||||||
|
ErrorData(
|
||||||
|
code=-1,
|
||||||
|
message=f"Access denied: insufficient permissions to delete recipe {recipe_id}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise McpError(
|
||||||
|
ErrorData(
|
||||||
|
code=-1,
|
||||||
|
message=f"Failed to delete recipe {recipe_id}: server error ({e.response.status_code})",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
@require_scopes("cookbook:read")
|
||||||
|
async def nc_cookbook_search_recipes(
|
||||||
|
query: str, ctx: Context
|
||||||
|
) -> SearchRecipesResponse:
|
||||||
|
"""Search for recipes by keywords, tags, and categories"""
|
||||||
|
client = get_client(ctx)
|
||||||
|
try:
|
||||||
|
recipes_data = await client.cookbook.search_recipes(query)
|
||||||
|
recipes = [RecipeStub(**r) for r in recipes_data]
|
||||||
|
return SearchRecipesResponse(
|
||||||
|
recipes=recipes, query=query, total_found=len(recipes)
|
||||||
|
)
|
||||||
|
except HTTPStatusError as e:
|
||||||
|
if e.response.status_code == 403:
|
||||||
|
raise McpError(
|
||||||
|
ErrorData(
|
||||||
|
code=-1,
|
||||||
|
message="Access denied: insufficient permissions to search recipes",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif e.response.status_code == 500:
|
||||||
|
raise McpError(
|
||||||
|
ErrorData(
|
||||||
|
code=-1,
|
||||||
|
message="Search failed: server error",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise McpError(
|
||||||
|
ErrorData(
|
||||||
|
code=-1,
|
||||||
|
message=f"Search failed: server error ({e.response.status_code})",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
@require_scopes("cookbook:read")
|
||||||
|
async def nc_cookbook_list_categories(ctx: Context) -> ListCategoriesResponse:
|
||||||
|
"""Get all known categories.
|
||||||
|
|
||||||
|
Note: A category name of '*' indicates recipes with no category."""
|
||||||
|
client = get_client(ctx)
|
||||||
|
try:
|
||||||
|
categories_data = await client.cookbook.list_categories()
|
||||||
|
categories = [Category(**c) for c in categories_data]
|
||||||
|
return ListCategoriesResponse(categories=categories)
|
||||||
|
except HTTPStatusError as e:
|
||||||
|
if e.response.status_code == 403:
|
||||||
|
raise McpError(
|
||||||
|
ErrorData(
|
||||||
|
code=-1,
|
||||||
|
message="Access denied: insufficient permissions to list categories",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise McpError(
|
||||||
|
ErrorData(
|
||||||
|
code=-1,
|
||||||
|
message=f"Failed to list categories: server error ({e.response.status_code})",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
@require_scopes("cookbook:read")
|
||||||
|
async def nc_cookbook_get_recipes_in_category(
|
||||||
|
category: str, ctx: Context
|
||||||
|
) -> ListRecipesResponse:
|
||||||
|
"""Get all recipes in a specific category.
|
||||||
|
|
||||||
|
Use '_' as the category name to get recipes with no category."""
|
||||||
|
client = get_client(ctx)
|
||||||
|
try:
|
||||||
|
recipes_data = await client.cookbook.get_recipes_in_category(category)
|
||||||
|
recipes = [RecipeStub(**r) for r in recipes_data]
|
||||||
|
return ListRecipesResponse(recipes=recipes, total_count=len(recipes))
|
||||||
|
except HTTPStatusError as e:
|
||||||
|
if e.response.status_code == 403:
|
||||||
|
raise McpError(
|
||||||
|
ErrorData(
|
||||||
|
code=-1,
|
||||||
|
message="Access denied: insufficient permissions to access recipes",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif e.response.status_code == 500:
|
||||||
|
raise McpError(
|
||||||
|
ErrorData(
|
||||||
|
code=-1,
|
||||||
|
message=f"Could not find category '{category}'",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise McpError(
|
||||||
|
ErrorData(
|
||||||
|
code=-1,
|
||||||
|
message=f"Failed to get recipes in category: server error ({e.response.status_code})",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
@require_scopes("cookbook:read")
|
||||||
|
async def nc_cookbook_list_keywords(ctx: Context) -> ListKeywordsResponse:
|
||||||
|
"""Get all known keywords/tags"""
|
||||||
|
client = get_client(ctx)
|
||||||
|
try:
|
||||||
|
keywords_data = await client.cookbook.list_keywords()
|
||||||
|
keywords = [Keyword(**k) for k in keywords_data]
|
||||||
|
return ListKeywordsResponse(keywords=keywords)
|
||||||
|
except HTTPStatusError as e:
|
||||||
|
if e.response.status_code == 403:
|
||||||
|
raise McpError(
|
||||||
|
ErrorData(
|
||||||
|
code=-1,
|
||||||
|
message="Access denied: insufficient permissions to list keywords",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise McpError(
|
||||||
|
ErrorData(
|
||||||
|
code=-1,
|
||||||
|
message=f"Failed to list keywords: server error ({e.response.status_code})",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
@require_scopes("cookbook:read")
|
||||||
|
async def nc_cookbook_get_recipes_with_keywords(
|
||||||
|
keywords: list[str], ctx: Context
|
||||||
|
) -> ListRecipesResponse:
|
||||||
|
"""Get all recipes that have specific keywords/tags"""
|
||||||
|
client = get_client(ctx)
|
||||||
|
try:
|
||||||
|
recipes_data = await client.cookbook.get_recipes_with_keywords(keywords)
|
||||||
|
recipes = [RecipeStub(**r) for r in recipes_data]
|
||||||
|
return ListRecipesResponse(recipes=recipes, total_count=len(recipes))
|
||||||
|
except HTTPStatusError as e:
|
||||||
|
if e.response.status_code == 403:
|
||||||
|
raise McpError(
|
||||||
|
ErrorData(
|
||||||
|
code=-1,
|
||||||
|
message="Access denied: insufficient permissions to access recipes",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif e.response.status_code == 500:
|
||||||
|
raise McpError(
|
||||||
|
ErrorData(
|
||||||
|
code=-1,
|
||||||
|
message="Failed to get recipes with keywords: server error",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise McpError(
|
||||||
|
ErrorData(
|
||||||
|
code=-1,
|
||||||
|
message=f"Failed to get recipes with keywords: server error ({e.response.status_code})",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
@require_scopes("cookbook:write")
|
||||||
|
async def nc_cookbook_set_config(
|
||||||
|
folder: str | None = None,
|
||||||
|
update_interval: int | None = None,
|
||||||
|
print_image: bool | None = None,
|
||||||
|
ctx: Context = None,
|
||||||
|
) -> ReindexResponse:
|
||||||
|
"""Set Cookbook app configuration.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
folder: Recipe folder path in user's files
|
||||||
|
update_interval: Automatic rescan interval in minutes
|
||||||
|
print_image: Whether to print images with recipes"""
|
||||||
|
client = get_client(ctx)
|
||||||
|
|
||||||
|
config_data = {}
|
||||||
|
if folder is not None:
|
||||||
|
config_data["folder"] = folder
|
||||||
|
if update_interval is not None:
|
||||||
|
config_data["update_interval"] = update_interval
|
||||||
|
if print_image is not None:
|
||||||
|
config_data["print_image"] = print_image
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await client.cookbook.set_config(config_data)
|
||||||
|
return ReindexResponse(status_code=200, message=str(result))
|
||||||
|
except HTTPStatusError as e:
|
||||||
|
if e.response.status_code == 403:
|
||||||
|
raise McpError(
|
||||||
|
ErrorData(
|
||||||
|
code=-1,
|
||||||
|
message="Access denied: insufficient permissions to set configuration",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise McpError(
|
||||||
|
ErrorData(
|
||||||
|
code=-1,
|
||||||
|
message=f"Failed to set configuration: server error ({e.response.status_code})",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
@require_scopes("cookbook:write")
|
||||||
|
async def nc_cookbook_reindex(ctx: Context) -> ReindexResponse:
|
||||||
|
"""Trigger a rescan of all recipes into the caching database.
|
||||||
|
|
||||||
|
This rebuilds the search index and should be used after manual file changes."""
|
||||||
|
client = get_client(ctx)
|
||||||
|
try:
|
||||||
|
message = await client.cookbook.reindex()
|
||||||
|
return ReindexResponse(status_code=200, message=message)
|
||||||
|
except HTTPStatusError as e:
|
||||||
|
if e.response.status_code == 403:
|
||||||
|
raise McpError(
|
||||||
|
ErrorData(
|
||||||
|
code=-1,
|
||||||
|
message="Access denied: insufficient permissions to reindex",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise McpError(
|
||||||
|
ErrorData(
|
||||||
|
code=-1,
|
||||||
|
message=f"Failed to reindex: server error ({e.response.status_code})",
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -3,6 +3,7 @@ from typing import Optional
|
|||||||
|
|
||||||
from mcp.server.fastmcp import Context, FastMCP
|
from mcp.server.fastmcp import Context, FastMCP
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.auth import require_scopes
|
||||||
from nextcloud_mcp_server.context import get_client
|
from nextcloud_mcp_server.context import get_client
|
||||||
from nextcloud_mcp_server.models.deck import (
|
from nextcloud_mcp_server.models.deck import (
|
||||||
CardOperationResponse,
|
CardOperationResponse,
|
||||||
@@ -116,6 +117,7 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
# Read Tools (converted from resources)
|
# Read Tools (converted from resources)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
|
@require_scopes("deck:read")
|
||||||
async def deck_get_boards(ctx: Context) -> list[DeckBoard]:
|
async def deck_get_boards(ctx: Context) -> list[DeckBoard]:
|
||||||
"""Get all Nextcloud Deck boards"""
|
"""Get all Nextcloud Deck boards"""
|
||||||
client = get_client(ctx)
|
client = get_client(ctx)
|
||||||
@@ -123,6 +125,7 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
return boards
|
return boards
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
|
@require_scopes("deck:read")
|
||||||
async def deck_get_board(ctx: Context, board_id: int) -> DeckBoard:
|
async def deck_get_board(ctx: Context, board_id: int) -> DeckBoard:
|
||||||
"""Get details of a specific Nextcloud Deck board"""
|
"""Get details of a specific Nextcloud Deck board"""
|
||||||
client = get_client(ctx)
|
client = get_client(ctx)
|
||||||
@@ -130,6 +133,7 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
return board
|
return board
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
|
@require_scopes("deck:read")
|
||||||
async def deck_get_stacks(ctx: Context, board_id: int) -> list[DeckStack]:
|
async def deck_get_stacks(ctx: Context, board_id: int) -> list[DeckStack]:
|
||||||
"""Get all stacks in a Nextcloud Deck board"""
|
"""Get all stacks in a Nextcloud Deck board"""
|
||||||
client = get_client(ctx)
|
client = get_client(ctx)
|
||||||
@@ -137,6 +141,7 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
return stacks
|
return stacks
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
|
@require_scopes("deck:read")
|
||||||
async def deck_get_stack(ctx: Context, board_id: int, stack_id: int) -> DeckStack:
|
async def deck_get_stack(ctx: Context, board_id: int, stack_id: int) -> DeckStack:
|
||||||
"""Get details of a specific Nextcloud Deck stack"""
|
"""Get details of a specific Nextcloud Deck stack"""
|
||||||
client = get_client(ctx)
|
client = get_client(ctx)
|
||||||
@@ -144,6 +149,7 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
return stack
|
return stack
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
|
@require_scopes("deck:read")
|
||||||
async def deck_get_cards(
|
async def deck_get_cards(
|
||||||
ctx: Context, board_id: int, stack_id: int
|
ctx: Context, board_id: int, stack_id: int
|
||||||
) -> list[DeckCard]:
|
) -> list[DeckCard]:
|
||||||
@@ -155,6 +161,7 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
|
@require_scopes("deck:read")
|
||||||
async def deck_get_card(
|
async def deck_get_card(
|
||||||
ctx: Context, board_id: int, stack_id: int, card_id: int
|
ctx: Context, board_id: int, stack_id: int, card_id: int
|
||||||
) -> DeckCard:
|
) -> DeckCard:
|
||||||
@@ -164,6 +171,7 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
return card
|
return card
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
|
@require_scopes("deck:read")
|
||||||
async def deck_get_labels(ctx: Context, board_id: int) -> list[DeckLabel]:
|
async def deck_get_labels(ctx: Context, board_id: int) -> list[DeckLabel]:
|
||||||
"""Get all labels in a Nextcloud Deck board"""
|
"""Get all labels in a Nextcloud Deck board"""
|
||||||
client = get_client(ctx)
|
client = get_client(ctx)
|
||||||
@@ -171,6 +179,7 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
return board.labels
|
return board.labels
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
|
@require_scopes("deck:read")
|
||||||
async def deck_get_label(ctx: Context, board_id: int, label_id: int) -> DeckLabel:
|
async def deck_get_label(ctx: Context, board_id: int, label_id: int) -> DeckLabel:
|
||||||
"""Get details of a specific Nextcloud Deck label"""
|
"""Get details of a specific Nextcloud Deck label"""
|
||||||
client = get_client(ctx)
|
client = get_client(ctx)
|
||||||
@@ -180,6 +189,7 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
# Create/Update/Delete Tools
|
# Create/Update/Delete Tools
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
|
@require_scopes("deck:write")
|
||||||
async def deck_create_board(
|
async def deck_create_board(
|
||||||
ctx: Context, title: str, color: str
|
ctx: Context, title: str, color: str
|
||||||
) -> CreateBoardResponse:
|
) -> CreateBoardResponse:
|
||||||
@@ -196,6 +206,7 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
# Stack Tools
|
# Stack Tools
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
|
@require_scopes("deck:write")
|
||||||
async def deck_create_stack(
|
async def deck_create_stack(
|
||||||
ctx: Context, board_id: int, title: str, order: int
|
ctx: Context, board_id: int, title: str, order: int
|
||||||
) -> CreateStackResponse:
|
) -> CreateStackResponse:
|
||||||
@@ -211,6 +222,7 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
return CreateStackResponse(id=stack.id, title=stack.title, order=stack.order)
|
return CreateStackResponse(id=stack.id, title=stack.title, order=stack.order)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
|
@require_scopes("deck:write")
|
||||||
async def deck_update_stack(
|
async def deck_update_stack(
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
board_id: int,
|
board_id: int,
|
||||||
@@ -236,6 +248,7 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
|
@require_scopes("deck:write")
|
||||||
async def deck_delete_stack(
|
async def deck_delete_stack(
|
||||||
ctx: Context, board_id: int, stack_id: int
|
ctx: Context, board_id: int, stack_id: int
|
||||||
) -> StackOperationResponse:
|
) -> StackOperationResponse:
|
||||||
@@ -256,6 +269,7 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
|
|
||||||
# Card Tools
|
# Card Tools
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
|
@require_scopes("deck:write")
|
||||||
async def deck_create_card(
|
async def deck_create_card(
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
board_id: int,
|
board_id: int,
|
||||||
@@ -289,6 +303,7 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
|
@require_scopes("deck:write")
|
||||||
async def deck_update_card(
|
async def deck_update_card(
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
board_id: int,
|
board_id: int,
|
||||||
@@ -341,6 +356,7 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
|
@require_scopes("deck:write")
|
||||||
async def deck_delete_card(
|
async def deck_delete_card(
|
||||||
ctx: Context, board_id: int, stack_id: int, card_id: int
|
ctx: Context, board_id: int, stack_id: int, card_id: int
|
||||||
) -> CardOperationResponse:
|
) -> CardOperationResponse:
|
||||||
@@ -362,6 +378,7 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
|
@require_scopes("deck:write")
|
||||||
async def deck_archive_card(
|
async def deck_archive_card(
|
||||||
ctx: Context, board_id: int, stack_id: int, card_id: int
|
ctx: Context, board_id: int, stack_id: int, card_id: int
|
||||||
) -> CardOperationResponse:
|
) -> CardOperationResponse:
|
||||||
@@ -383,6 +400,7 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
|
@require_scopes("deck:write")
|
||||||
async def deck_unarchive_card(
|
async def deck_unarchive_card(
|
||||||
ctx: Context, board_id: int, stack_id: int, card_id: int
|
ctx: Context, board_id: int, stack_id: int, card_id: int
|
||||||
) -> CardOperationResponse:
|
) -> CardOperationResponse:
|
||||||
@@ -404,6 +422,7 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
|
@require_scopes("deck:write")
|
||||||
async def deck_reorder_card(
|
async def deck_reorder_card(
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
board_id: int,
|
board_id: int,
|
||||||
@@ -435,6 +454,7 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
|
|
||||||
# Label Tools
|
# Label Tools
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
|
@require_scopes("deck:write")
|
||||||
async def deck_create_label(
|
async def deck_create_label(
|
||||||
ctx: Context, board_id: int, title: str, color: str
|
ctx: Context, board_id: int, title: str, color: str
|
||||||
) -> CreateLabelResponse:
|
) -> CreateLabelResponse:
|
||||||
@@ -450,6 +470,7 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
return CreateLabelResponse(id=label.id, title=label.title, color=label.color)
|
return CreateLabelResponse(id=label.id, title=label.title, color=label.color)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
|
@require_scopes("deck:write")
|
||||||
async def deck_update_label(
|
async def deck_update_label(
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
board_id: int,
|
board_id: int,
|
||||||
@@ -475,6 +496,7 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
|
@require_scopes("deck:write")
|
||||||
async def deck_delete_label(
|
async def deck_delete_label(
|
||||||
ctx: Context, board_id: int, label_id: int
|
ctx: Context, board_id: int, label_id: int
|
||||||
) -> LabelOperationResponse:
|
) -> LabelOperationResponse:
|
||||||
@@ -495,6 +517,7 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
|
|
||||||
# Card-Label Assignment Tools
|
# Card-Label Assignment Tools
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
|
@require_scopes("deck:write")
|
||||||
async def deck_assign_label_to_card(
|
async def deck_assign_label_to_card(
|
||||||
ctx: Context, board_id: int, stack_id: int, card_id: int, label_id: int
|
ctx: Context, board_id: int, stack_id: int, card_id: int, label_id: int
|
||||||
) -> CardOperationResponse:
|
) -> CardOperationResponse:
|
||||||
@@ -517,6 +540,7 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
|
@require_scopes("deck:write")
|
||||||
async def deck_remove_label_from_card(
|
async def deck_remove_label_from_card(
|
||||||
ctx: Context, board_id: int, stack_id: int, card_id: int, label_id: int
|
ctx: Context, board_id: int, stack_id: int, card_id: int, label_id: int
|
||||||
) -> CardOperationResponse:
|
) -> CardOperationResponse:
|
||||||
@@ -540,6 +564,7 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
|
|
||||||
# Card-User Assignment Tools
|
# Card-User Assignment Tools
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
|
@require_scopes("deck:write")
|
||||||
async def deck_assign_user_to_card(
|
async def deck_assign_user_to_card(
|
||||||
ctx: Context, board_id: int, stack_id: int, card_id: int, user_id: str
|
ctx: Context, board_id: int, stack_id: int, card_id: int, user_id: str
|
||||||
) -> CardOperationResponse:
|
) -> CardOperationResponse:
|
||||||
@@ -562,6 +587,7 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
|
@require_scopes("deck:write")
|
||||||
async def deck_unassign_user_from_card(
|
async def deck_unassign_user_from_card(
|
||||||
ctx: Context, board_id: int, stack_id: int, card_id: int, user_id: str
|
ctx: Context, board_id: int, stack_id: int, card_id: int, user_id: str
|
||||||
) -> CardOperationResponse:
|
) -> CardOperationResponse:
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
from httpx import HTTPStatusError
|
from httpx import HTTPStatusError, RequestError
|
||||||
from mcp.server.fastmcp import Context, FastMCP
|
from mcp.server.fastmcp import Context, FastMCP
|
||||||
from mcp.shared.exceptions import McpError
|
from mcp.shared.exceptions import McpError
|
||||||
from mcp.types import ErrorData
|
from mcp.types import ErrorData
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.auth import require_scopes
|
||||||
from nextcloud_mcp_server.context import get_client
|
from nextcloud_mcp_server.context import get_client
|
||||||
from nextcloud_mcp_server.models.notes import (
|
from nextcloud_mcp_server.models.notes import (
|
||||||
AppendContentResponse,
|
AppendContentResponse,
|
||||||
@@ -61,6 +62,13 @@ def configure_notes_tools(mcp: FastMCP):
|
|||||||
try:
|
try:
|
||||||
note_data = await client.notes.get_note(note_id)
|
note_data = await client.notes.get_note(note_id)
|
||||||
return Note(**note_data)
|
return Note(**note_data)
|
||||||
|
except RequestError as e:
|
||||||
|
raise McpError(
|
||||||
|
ErrorData(
|
||||||
|
code=-1,
|
||||||
|
message=f"Network error retrieving note {note_id}: {str(e)}",
|
||||||
|
)
|
||||||
|
)
|
||||||
except HTTPStatusError as e:
|
except HTTPStatusError as e:
|
||||||
if e.response.status_code == 404:
|
if e.response.status_code == 404:
|
||||||
raise McpError(ErrorData(code=-1, message=f"Note {note_id} not found"))
|
raise McpError(ErrorData(code=-1, message=f"Note {note_id} not found"))
|
||||||
@@ -77,10 +85,11 @@ def configure_notes_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
|
@require_scopes("notes:write")
|
||||||
async def nc_notes_create_note(
|
async def nc_notes_create_note(
|
||||||
title: str, content: str, category: str, ctx: Context
|
title: str, content: str, category: str, ctx: Context
|
||||||
) -> CreateNoteResponse:
|
) -> CreateNoteResponse:
|
||||||
"""Create a new note"""
|
"""Create a new note (requires notes:write scope)"""
|
||||||
client = get_client(ctx)
|
client = get_client(ctx)
|
||||||
try:
|
try:
|
||||||
note_data = await client.notes.create_note(
|
note_data = await client.notes.create_note(
|
||||||
@@ -92,6 +101,10 @@ def configure_notes_tools(mcp: FastMCP):
|
|||||||
return CreateNoteResponse(
|
return CreateNoteResponse(
|
||||||
id=note.id, title=note.title, category=note.category, etag=note.etag
|
id=note.id, title=note.title, category=note.category, etag=note.etag
|
||||||
)
|
)
|
||||||
|
except RequestError as e:
|
||||||
|
raise McpError(
|
||||||
|
ErrorData(code=-1, message=f"Network error creating note: {str(e)}")
|
||||||
|
)
|
||||||
except HTTPStatusError as e:
|
except HTTPStatusError as e:
|
||||||
if e.response.status_code == 403:
|
if e.response.status_code == 403:
|
||||||
raise McpError(
|
raise McpError(
|
||||||
@@ -118,6 +131,7 @@ def configure_notes_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
|
@require_scopes("notes:write")
|
||||||
async def nc_notes_update_note(
|
async def nc_notes_update_note(
|
||||||
note_id: int,
|
note_id: int,
|
||||||
etag: str,
|
etag: str,
|
||||||
@@ -126,7 +140,7 @@ def configure_notes_tools(mcp: FastMCP):
|
|||||||
category: str | None,
|
category: str | None,
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
) -> UpdateNoteResponse:
|
) -> UpdateNoteResponse:
|
||||||
"""Update an existing note's title, content, or category.
|
"""Update an existing note's title, content, or category (requires notes:write scope).
|
||||||
|
|
||||||
REQUIRED: etag parameter must be provided to prevent overwriting concurrent changes.
|
REQUIRED: etag parameter must be provided to prevent overwriting concurrent changes.
|
||||||
Get the current ETag by first retrieving the note using nc_notes_get_note tool.
|
Get the current ETag by first retrieving the note using nc_notes_get_note tool.
|
||||||
@@ -146,6 +160,12 @@ def configure_notes_tools(mcp: FastMCP):
|
|||||||
return UpdateNoteResponse(
|
return UpdateNoteResponse(
|
||||||
id=note.id, title=note.title, category=note.category, etag=note.etag
|
id=note.id, title=note.title, category=note.category, etag=note.etag
|
||||||
)
|
)
|
||||||
|
except RequestError as e:
|
||||||
|
raise McpError(
|
||||||
|
ErrorData(
|
||||||
|
code=-1, message=f"Network error updating note {note_id}: {str(e)}"
|
||||||
|
)
|
||||||
|
)
|
||||||
except HTTPStatusError as e:
|
except HTTPStatusError as e:
|
||||||
if e.response.status_code == 404:
|
if e.response.status_code == 404:
|
||||||
raise McpError(ErrorData(code=-1, message=f"Note {note_id} not found"))
|
raise McpError(ErrorData(code=-1, message=f"Note {note_id} not found"))
|
||||||
@@ -176,6 +196,7 @@ def configure_notes_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
|
@require_scopes("notes:write")
|
||||||
async def nc_notes_append_content(
|
async def nc_notes_append_content(
|
||||||
note_id: int, content: str, ctx: Context
|
note_id: int, content: str, ctx: Context
|
||||||
) -> AppendContentResponse:
|
) -> AppendContentResponse:
|
||||||
@@ -192,6 +213,13 @@ def configure_notes_tools(mcp: FastMCP):
|
|||||||
return AppendContentResponse(
|
return AppendContentResponse(
|
||||||
id=note.id, title=note.title, category=note.category, etag=note.etag
|
id=note.id, title=note.title, category=note.category, etag=note.etag
|
||||||
)
|
)
|
||||||
|
except RequestError as e:
|
||||||
|
raise McpError(
|
||||||
|
ErrorData(
|
||||||
|
code=-1,
|
||||||
|
message=f"Network error appending to note {note_id}: {str(e)}",
|
||||||
|
)
|
||||||
|
)
|
||||||
except HTTPStatusError as e:
|
except HTTPStatusError as e:
|
||||||
if e.response.status_code == 404:
|
if e.response.status_code == 404:
|
||||||
raise McpError(ErrorData(code=-1, message=f"Note {note_id} not found"))
|
raise McpError(ErrorData(code=-1, message=f"Note {note_id} not found"))
|
||||||
@@ -218,8 +246,9 @@ def configure_notes_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
|
@require_scopes("notes:read")
|
||||||
async def nc_notes_search_notes(query: str, ctx: Context) -> SearchNotesResponse:
|
async def nc_notes_search_notes(query: str, ctx: Context) -> SearchNotesResponse:
|
||||||
"""Search notes by title or content, returning only id, title, and category."""
|
"""Search notes by title or content, returning only id, title, and category (requires notes:read scope)."""
|
||||||
client = get_client(ctx)
|
client = get_client(ctx)
|
||||||
try:
|
try:
|
||||||
search_results_raw = await client.notes_search_notes(query=query)
|
search_results_raw = await client.notes_search_notes(query=query)
|
||||||
@@ -238,6 +267,10 @@ def configure_notes_tools(mcp: FastMCP):
|
|||||||
return SearchNotesResponse(
|
return SearchNotesResponse(
|
||||||
results=results, query=query, total_found=len(results)
|
results=results, query=query, total_found=len(results)
|
||||||
)
|
)
|
||||||
|
except RequestError as e:
|
||||||
|
raise McpError(
|
||||||
|
ErrorData(code=-1, message=f"Network error searching notes: {str(e)}")
|
||||||
|
)
|
||||||
except HTTPStatusError as e:
|
except HTTPStatusError as e:
|
||||||
if e.response.status_code == 403:
|
if e.response.status_code == 403:
|
||||||
raise McpError(
|
raise McpError(
|
||||||
@@ -259,12 +292,19 @@ def configure_notes_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
|
@require_scopes("notes:read")
|
||||||
async def nc_notes_get_note(note_id: int, ctx: Context) -> Note:
|
async def nc_notes_get_note(note_id: int, ctx: Context) -> Note:
|
||||||
"""Get a specific note by its ID"""
|
"""Get a specific note by its ID (requires notes:read scope)"""
|
||||||
client = get_client(ctx)
|
client = get_client(ctx)
|
||||||
try:
|
try:
|
||||||
note_data = await client.notes.get_note(note_id)
|
note_data = await client.notes.get_note(note_id)
|
||||||
return Note(**note_data)
|
return Note(**note_data)
|
||||||
|
except RequestError as e:
|
||||||
|
raise McpError(
|
||||||
|
ErrorData(
|
||||||
|
code=-1, message=f"Network error getting note {note_id}: {str(e)}"
|
||||||
|
)
|
||||||
|
)
|
||||||
except HTTPStatusError as e:
|
except HTTPStatusError as e:
|
||||||
if e.response.status_code == 404:
|
if e.response.status_code == 404:
|
||||||
raise McpError(ErrorData(code=-1, message=f"Note {note_id} not found"))
|
raise McpError(ErrorData(code=-1, message=f"Note {note_id} not found"))
|
||||||
@@ -281,6 +321,7 @@ def configure_notes_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
|
@require_scopes("notes:read")
|
||||||
async def nc_notes_get_attachment(
|
async def nc_notes_get_attachment(
|
||||||
note_id: int, attachment_filename: str, ctx: Context
|
note_id: int, attachment_filename: str, ctx: Context
|
||||||
) -> dict[str, str]:
|
) -> dict[str, str]:
|
||||||
@@ -295,6 +336,13 @@ def configure_notes_tools(mcp: FastMCP):
|
|||||||
"mimeType": mime_type,
|
"mimeType": mime_type,
|
||||||
"data": content,
|
"data": content,
|
||||||
}
|
}
|
||||||
|
except RequestError as e:
|
||||||
|
raise McpError(
|
||||||
|
ErrorData(
|
||||||
|
code=-1,
|
||||||
|
message=f"Network error getting attachment {attachment_filename} for note {note_id}: {str(e)}",
|
||||||
|
)
|
||||||
|
)
|
||||||
except HTTPStatusError as e:
|
except HTTPStatusError as e:
|
||||||
if e.response.status_code == 404:
|
if e.response.status_code == 404:
|
||||||
raise McpError(
|
raise McpError(
|
||||||
@@ -319,6 +367,7 @@ def configure_notes_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
|
@require_scopes("notes:write")
|
||||||
async def nc_notes_delete_note(note_id: int, ctx: Context) -> DeleteNoteResponse:
|
async def nc_notes_delete_note(note_id: int, ctx: Context) -> DeleteNoteResponse:
|
||||||
"""Delete a note permanently"""
|
"""Delete a note permanently"""
|
||||||
logger.info("Deleting note %s", note_id)
|
logger.info("Deleting note %s", note_id)
|
||||||
@@ -330,6 +379,12 @@ def configure_notes_tools(mcp: FastMCP):
|
|||||||
message=f"Note {note_id} deleted successfully",
|
message=f"Note {note_id} deleted successfully",
|
||||||
deleted_id=note_id,
|
deleted_id=note_id,
|
||||||
)
|
)
|
||||||
|
except RequestError as e:
|
||||||
|
raise McpError(
|
||||||
|
ErrorData(
|
||||||
|
code=-1, message=f"Network error deleting note {note_id}: {str(e)}"
|
||||||
|
)
|
||||||
|
)
|
||||||
except HTTPStatusError as e:
|
except HTTPStatusError as e:
|
||||||
if e.response.status_code == 404:
|
if e.response.status_code == 404:
|
||||||
raise McpError(ErrorData(code=-1, message=f"Note {note_id} not found"))
|
raise McpError(ErrorData(code=-1, message=f"Note {note_id} not found"))
|
||||||
|
|||||||
@@ -2,9 +2,11 @@
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
|
|
||||||
from nextcloud_mcp_server.context import get_client
|
|
||||||
from mcp.server.fastmcp import Context, FastMCP
|
from mcp.server.fastmcp import Context, FastMCP
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.auth import require_scopes
|
||||||
|
from nextcloud_mcp_server.context import get_client
|
||||||
|
|
||||||
|
|
||||||
def configure_sharing_tools(mcp: FastMCP):
|
def configure_sharing_tools(mcp: FastMCP):
|
||||||
"""Configure sharing-related MCP tools.
|
"""Configure sharing-related MCP tools.
|
||||||
@@ -14,6 +16,7 @@ def configure_sharing_tools(mcp: FastMCP):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
|
@require_scopes("sharing:write")
|
||||||
async def nc_share_create(
|
async def nc_share_create(
|
||||||
path: str,
|
path: str,
|
||||||
share_with: str,
|
share_with: str,
|
||||||
@@ -52,6 +55,7 @@ def configure_sharing_tools(mcp: FastMCP):
|
|||||||
return json.dumps(share_data, indent=2)
|
return json.dumps(share_data, indent=2)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
|
@require_scopes("sharing:write")
|
||||||
async def nc_share_delete(share_id: int, ctx: Context) -> str:
|
async def nc_share_delete(share_id: int, ctx: Context) -> str:
|
||||||
"""Delete a share by its ID.
|
"""Delete a share by its ID.
|
||||||
|
|
||||||
@@ -70,6 +74,7 @@ def configure_sharing_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
|
@require_scopes("sharing:write")
|
||||||
async def nc_share_get(share_id: int, ctx: Context) -> str:
|
async def nc_share_get(share_id: int, ctx: Context) -> str:
|
||||||
"""Get information about a specific share.
|
"""Get information about a specific share.
|
||||||
|
|
||||||
@@ -87,6 +92,7 @@ def configure_sharing_tools(mcp: FastMCP):
|
|||||||
return json.dumps(share_data, indent=2)
|
return json.dumps(share_data, indent=2)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
|
@require_scopes("sharing:write")
|
||||||
async def nc_share_list(
|
async def nc_share_list(
|
||||||
ctx: Context, path: str | None = None, shared_with_me: bool = False
|
ctx: Context, path: str | None = None, shared_with_me: bool = False
|
||||||
) -> str:
|
) -> str:
|
||||||
@@ -107,6 +113,7 @@ def configure_sharing_tools(mcp: FastMCP):
|
|||||||
return json.dumps(shares, indent=2)
|
return json.dumps(shares, indent=2)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
|
@require_scopes("sharing:write")
|
||||||
async def nc_share_update(share_id: int, permissions: int, ctx: Context) -> str:
|
async def nc_share_update(share_id: int, permissions: int, ctx: Context) -> str:
|
||||||
"""Update the permissions of an existing share.
|
"""Update the permissions of an existing share.
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import logging
|
|||||||
|
|
||||||
from mcp.server.fastmcp import Context, FastMCP
|
from mcp.server.fastmcp import Context, FastMCP
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.auth import require_scopes
|
||||||
from nextcloud_mcp_server.context import get_client
|
from nextcloud_mcp_server.context import get_client
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -10,18 +11,21 @@ logger = logging.getLogger(__name__)
|
|||||||
def configure_tables_tools(mcp: FastMCP):
|
def configure_tables_tools(mcp: FastMCP):
|
||||||
# Tables tools
|
# Tables tools
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
|
@require_scopes("tables:read")
|
||||||
async def nc_tables_list_tables(ctx: Context):
|
async def nc_tables_list_tables(ctx: Context):
|
||||||
"""List all tables available to the user"""
|
"""List all tables available to the user"""
|
||||||
client = get_client(ctx)
|
client = get_client(ctx)
|
||||||
return await client.tables.list_tables()
|
return await client.tables.list_tables()
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
|
@require_scopes("tables:read")
|
||||||
async def nc_tables_get_schema(table_id: int, ctx: Context):
|
async def nc_tables_get_schema(table_id: int, ctx: Context):
|
||||||
"""Get the schema/structure of a specific table including columns and views"""
|
"""Get the schema/structure of a specific table including columns and views"""
|
||||||
client = get_client(ctx)
|
client = get_client(ctx)
|
||||||
return await client.tables.get_table_schema(table_id)
|
return await client.tables.get_table_schema(table_id)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
|
@require_scopes("tables:read")
|
||||||
async def nc_tables_read_table(
|
async def nc_tables_read_table(
|
||||||
table_id: int,
|
table_id: int,
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
@@ -33,6 +37,7 @@ def configure_tables_tools(mcp: FastMCP):
|
|||||||
return await client.tables.get_table_rows(table_id, limit, offset)
|
return await client.tables.get_table_rows(table_id, limit, offset)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
|
@require_scopes("tables:write")
|
||||||
async def nc_tables_insert_row(table_id: int, data: dict, ctx: Context):
|
async def nc_tables_insert_row(table_id: int, data: dict, ctx: Context):
|
||||||
"""Insert a new row into a table.
|
"""Insert a new row into a table.
|
||||||
|
|
||||||
@@ -42,6 +47,7 @@ def configure_tables_tools(mcp: FastMCP):
|
|||||||
return await client.tables.create_row(table_id, data)
|
return await client.tables.create_row(table_id, data)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
|
@require_scopes("tables:write")
|
||||||
async def nc_tables_update_row(row_id: int, data: dict, ctx: Context):
|
async def nc_tables_update_row(row_id: int, data: dict, ctx: Context):
|
||||||
"""Update an existing row in a table.
|
"""Update an existing row in a table.
|
||||||
|
|
||||||
@@ -51,6 +57,7 @@ def configure_tables_tools(mcp: FastMCP):
|
|||||||
return await client.tables.update_row(row_id, data)
|
return await client.tables.update_row(row_id, data)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
|
@require_scopes("tables:write")
|
||||||
async def nc_tables_delete_row(row_id: int, ctx: Context):
|
async def nc_tables_delete_row(row_id: int, ctx: Context):
|
||||||
"""Delete a row from a table"""
|
"""Delete a row from a table"""
|
||||||
client = get_client(ctx)
|
client = get_client(ctx)
|
||||||
|
|||||||
@@ -2,7 +2,13 @@ import logging
|
|||||||
|
|
||||||
from mcp.server.fastmcp import Context, FastMCP
|
from mcp.server.fastmcp import Context, FastMCP
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.auth import require_scopes
|
||||||
from nextcloud_mcp_server.context import get_client
|
from nextcloud_mcp_server.context import get_client
|
||||||
|
from nextcloud_mcp_server.models import DirectoryListing, FileInfo, SearchFilesResponse
|
||||||
|
from nextcloud_mcp_server.utils.document_parser import (
|
||||||
|
is_parseable_document,
|
||||||
|
parse_document,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -10,26 +16,40 @@ logger = logging.getLogger(__name__)
|
|||||||
def configure_webdav_tools(mcp: FastMCP):
|
def configure_webdav_tools(mcp: FastMCP):
|
||||||
# WebDAV file system tools
|
# WebDAV file system tools
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def nc_webdav_list_directory(ctx: Context, path: str = ""):
|
@require_scopes("files:read")
|
||||||
|
async def nc_webdav_list_directory(
|
||||||
|
ctx: Context, path: str = ""
|
||||||
|
) -> DirectoryListing:
|
||||||
"""List files and directories in the specified NextCloud path.
|
"""List files and directories in the specified NextCloud path.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
path: Directory path to list (empty string for root directory)
|
path: Directory path to list (empty string for root directory)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of items with metadata including name, path, is_directory, size, content_type, last_modified
|
DirectoryListing with files, total_count, directories_count, files_count, and total_size
|
||||||
|
|
||||||
Examples:
|
|
||||||
# List root directory
|
|
||||||
await nc_webdav_list_directory("")
|
|
||||||
|
|
||||||
# List a specific folder
|
|
||||||
await nc_webdav_list_directory("Documents/Projects")
|
|
||||||
"""
|
"""
|
||||||
client = get_client(ctx)
|
client = get_client(ctx)
|
||||||
return await client.webdav.list_directory(path)
|
items = await client.webdav.list_directory(path)
|
||||||
|
|
||||||
|
# Convert to FileInfo models
|
||||||
|
file_infos = [FileInfo(**item) for item in items]
|
||||||
|
|
||||||
|
# Calculate metadata
|
||||||
|
directories_count = sum(1 for f in file_infos if f.is_directory)
|
||||||
|
files_count = sum(1 for f in file_infos if not f.is_directory)
|
||||||
|
total_size = sum(f.size or 0 for f in file_infos if not f.is_directory)
|
||||||
|
|
||||||
|
return DirectoryListing(
|
||||||
|
path=path,
|
||||||
|
files=file_infos,
|
||||||
|
total_count=len(file_infos),
|
||||||
|
directories_count=directories_count,
|
||||||
|
files_count=files_count,
|
||||||
|
total_size=total_size,
|
||||||
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
|
@require_scopes("files:read")
|
||||||
async def nc_webdav_read_file(path: str, ctx: Context):
|
async def nc_webdav_read_file(path: str, ctx: Context):
|
||||||
"""Read the content of a file from NextCloud.
|
"""Read the content of a file from NextCloud.
|
||||||
|
|
||||||
@@ -37,14 +57,21 @@ def configure_webdav_tools(mcp: FastMCP):
|
|||||||
path: Full path to the file to read
|
path: Full path to the file to read
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict with path, content, content_type, size, and encoding (if binary)
|
Dict with path, content, content_type, size, and optional parsing metadata
|
||||||
Text files are decoded to UTF-8, binary files are base64 encoded
|
- Text files are decoded to UTF-8
|
||||||
|
- Documents (PDF, DOCX, etc.) are parsed and text is extracted
|
||||||
|
- Other binary files are base64 encoded
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
# Read a text file
|
# Read a text file
|
||||||
result = await nc_webdav_read_file("Documents/readme.txt")
|
result = await nc_webdav_read_file("Documents/readme.txt")
|
||||||
logger.info(result['content']) # Decoded text content
|
logger.info(result['content']) # Decoded text content
|
||||||
|
|
||||||
|
# Read a PDF document (automatically parsed)
|
||||||
|
result = await nc_webdav_read_file("Documents/report.pdf")
|
||||||
|
logger.info(result['content']) # Extracted text from PDF
|
||||||
|
logger.info(result['parsing_metadata']) # Document parsing info
|
||||||
|
|
||||||
# Read a binary file
|
# Read a binary file
|
||||||
result = await nc_webdav_read_file("Images/photo.jpg")
|
result = await nc_webdav_read_file("Images/photo.jpg")
|
||||||
logger.info(result['encoding']) # 'base64'
|
logger.info(result['encoding']) # 'base64'
|
||||||
@@ -52,6 +79,31 @@ def configure_webdav_tools(mcp: FastMCP):
|
|||||||
client = get_client(ctx)
|
client = get_client(ctx)
|
||||||
content, content_type = await client.webdav.read_file(path)
|
content, content_type = await client.webdav.read_file(path)
|
||||||
|
|
||||||
|
# Check if this is a parseable document (PDF, DOCX, etc.)
|
||||||
|
# is_parseable_document() checks if document processing is enabled
|
||||||
|
if is_parseable_document(content_type):
|
||||||
|
try:
|
||||||
|
logger.info(f"Parsing document '{path}' of type '{content_type}'")
|
||||||
|
parsed_text, metadata = await parse_document(
|
||||||
|
content,
|
||||||
|
content_type,
|
||||||
|
filename=path,
|
||||||
|
progress_callback=ctx.report_progress,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"path": path,
|
||||||
|
"content": parsed_text,
|
||||||
|
"content_type": content_type,
|
||||||
|
"size": len(content),
|
||||||
|
"parsed": True,
|
||||||
|
"parsing_metadata": metadata,
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
f"Failed to parse document '{path}', falling back to base64: {e}"
|
||||||
|
)
|
||||||
|
# Fall through to base64 encoding on parse failure
|
||||||
|
|
||||||
# For text files, decode content for easier viewing
|
# For text files, decode content for easier viewing
|
||||||
if content_type and content_type.startswith("text/"):
|
if content_type and content_type.startswith("text/"):
|
||||||
try:
|
try:
|
||||||
@@ -77,6 +129,7 @@ def configure_webdav_tools(mcp: FastMCP):
|
|||||||
}
|
}
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
|
@require_scopes("files:write")
|
||||||
async def nc_webdav_write_file(
|
async def nc_webdav_write_file(
|
||||||
path: str, content: str, ctx: Context, content_type: str | None = None
|
path: str, content: str, ctx: Context, content_type: str | None = None
|
||||||
):
|
):
|
||||||
@@ -89,13 +142,6 @@ def configure_webdav_tools(mcp: FastMCP):
|
|||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict with status_code indicating success
|
Dict with status_code indicating success
|
||||||
|
|
||||||
Examples:
|
|
||||||
# Write a text file
|
|
||||||
await nc_webdav_write_file("Documents/notes.md", "# My Notes\nContent here...")
|
|
||||||
|
|
||||||
# Write binary data (base64 encoded)
|
|
||||||
await nc_webdav_write_file("files/data.bin", base64_content, "application/octet-stream;base64")
|
|
||||||
"""
|
"""
|
||||||
client = get_client(ctx)
|
client = get_client(ctx)
|
||||||
|
|
||||||
@@ -111,6 +157,7 @@ def configure_webdav_tools(mcp: FastMCP):
|
|||||||
return await client.webdav.write_file(path, content_bytes, content_type)
|
return await client.webdav.write_file(path, content_bytes, content_type)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
|
@require_scopes("files:write")
|
||||||
async def nc_webdav_create_directory(path: str, ctx: Context):
|
async def nc_webdav_create_directory(path: str, ctx: Context):
|
||||||
"""Create a directory in NextCloud.
|
"""Create a directory in NextCloud.
|
||||||
|
|
||||||
@@ -119,18 +166,12 @@ def configure_webdav_tools(mcp: FastMCP):
|
|||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict with status_code (201 for created, 405 if already exists)
|
Dict with status_code (201 for created, 405 if already exists)
|
||||||
|
|
||||||
Examples:
|
|
||||||
# Create a single directory
|
|
||||||
await nc_webdav_create_directory("NewProject")
|
|
||||||
|
|
||||||
# Create nested directories (parent must exist)
|
|
||||||
await nc_webdav_create_directory("Projects/MyApp/docs")
|
|
||||||
"""
|
"""
|
||||||
client = get_client(ctx)
|
client = get_client(ctx)
|
||||||
return await client.webdav.create_directory(path)
|
return await client.webdav.create_directory(path)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
|
@require_scopes("files:write")
|
||||||
async def nc_webdav_delete_resource(path: str, ctx: Context):
|
async def nc_webdav_delete_resource(path: str, ctx: Context):
|
||||||
"""Delete a file or directory in NextCloud.
|
"""Delete a file or directory in NextCloud.
|
||||||
|
|
||||||
@@ -139,18 +180,12 @@ def configure_webdav_tools(mcp: FastMCP):
|
|||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict with status_code indicating result (404 if not found)
|
Dict with status_code indicating result (404 if not found)
|
||||||
|
|
||||||
Examples:
|
|
||||||
# Delete a file
|
|
||||||
await nc_webdav_delete_resource("old_document.txt")
|
|
||||||
|
|
||||||
# Delete a directory (will delete all contents)
|
|
||||||
await nc_webdav_delete_resource("temp_folder")
|
|
||||||
"""
|
"""
|
||||||
client = get_client(ctx)
|
client = get_client(ctx)
|
||||||
return await client.webdav.delete_resource(path)
|
return await client.webdav.delete_resource(path)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
|
@require_scopes("files:write")
|
||||||
async def nc_webdav_move_resource(
|
async def nc_webdav_move_resource(
|
||||||
source_path: str, destination_path: str, ctx: Context, overwrite: bool = False
|
source_path: str, destination_path: str, ctx: Context, overwrite: bool = False
|
||||||
):
|
):
|
||||||
@@ -163,19 +198,6 @@ def configure_webdav_tools(mcp: FastMCP):
|
|||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict with status_code indicating result (404 if source not found, 412 if destination exists and overwrite is False)
|
Dict with status_code indicating result (404 if source not found, 412 if destination exists and overwrite is False)
|
||||||
|
|
||||||
Examples:
|
|
||||||
# Rename a file
|
|
||||||
await nc_webdav_move_resource("document.txt", "new_name.txt")
|
|
||||||
|
|
||||||
# Move a file to another directory
|
|
||||||
await nc_webdav_move_resource("document.txt", "Archive/document.txt")
|
|
||||||
|
|
||||||
# Move a directory
|
|
||||||
await nc_webdav_move_resource("Projects/OldProject", "Projects/NewProject")
|
|
||||||
|
|
||||||
# Move and overwrite if destination exists
|
|
||||||
await nc_webdav_move_resource("document.txt", "Archive/document.txt", overwrite=True)
|
|
||||||
"""
|
"""
|
||||||
client = get_client(ctx)
|
client = get_client(ctx)
|
||||||
return await client.webdav.move_resource(
|
return await client.webdav.move_resource(
|
||||||
@@ -183,6 +205,7 @@ def configure_webdav_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
|
@require_scopes("files:write")
|
||||||
async def nc_webdav_copy_resource(
|
async def nc_webdav_copy_resource(
|
||||||
source_path: str, destination_path: str, ctx: Context, overwrite: bool = False
|
source_path: str, destination_path: str, ctx: Context, overwrite: bool = False
|
||||||
):
|
):
|
||||||
@@ -195,21 +218,202 @@ def configure_webdav_tools(mcp: FastMCP):
|
|||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict with status_code indicating result (404 if source not found, 412 if destination exists and overwrite is False)
|
Dict with status_code indicating result (404 if source not found, 412 if destination exists and overwrite is False)
|
||||||
|
|
||||||
Examples:
|
|
||||||
# Copy a file
|
|
||||||
await nc_webdav_copy_resource("document.txt", "document_copy.txt")
|
|
||||||
|
|
||||||
# Copy a file to another directory
|
|
||||||
await nc_webdav_copy_resource("document.txt", "Backup/document.txt")
|
|
||||||
|
|
||||||
# Copy a directory
|
|
||||||
await nc_webdav_copy_resource("Projects/ProjectA", "Projects/ProjectA_Backup")
|
|
||||||
|
|
||||||
# Copy and overwrite if destination exists
|
|
||||||
await nc_webdav_copy_resource("document.txt", "Backup/document.txt", overwrite=True)
|
|
||||||
"""
|
"""
|
||||||
client = get_client(ctx)
|
client = get_client(ctx)
|
||||||
return await client.webdav.copy_resource(
|
return await client.webdav.copy_resource(
|
||||||
source_path, destination_path, overwrite
|
source_path, destination_path, overwrite
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
@require_scopes("files:read")
|
||||||
|
async def nc_webdav_search_files(
|
||||||
|
ctx: Context,
|
||||||
|
scope: str = "",
|
||||||
|
name_pattern: str | None = None,
|
||||||
|
mime_type: str | None = None,
|
||||||
|
only_favorites: bool = False,
|
||||||
|
limit: int | None = None,
|
||||||
|
) -> SearchFilesResponse:
|
||||||
|
"""Search for files in NextCloud using WebDAV SEARCH.
|
||||||
|
|
||||||
|
This is a high-level search tool that supports common search patterns.
|
||||||
|
For more complex queries, use the specific search tools.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
scope: Directory path to search in (empty string for user root)
|
||||||
|
name_pattern: File name pattern (supports % wildcard, e.g., "%.txt" for all text files)
|
||||||
|
mime_type: MIME type to filter by (supports % wildcard, e.g., "image/%" for all images)
|
||||||
|
only_favorites: If True, only return favorited files
|
||||||
|
limit: Maximum number of results to return
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
SearchFilesResponse with list of matching files
|
||||||
|
"""
|
||||||
|
client = get_client(ctx)
|
||||||
|
|
||||||
|
# Build where conditions based on filters
|
||||||
|
conditions = []
|
||||||
|
|
||||||
|
if name_pattern:
|
||||||
|
conditions.append(
|
||||||
|
f"""
|
||||||
|
<d:like>
|
||||||
|
<d:prop>
|
||||||
|
<d:displayname/>
|
||||||
|
</d:prop>
|
||||||
|
<d:literal>{name_pattern}</d:literal>
|
||||||
|
</d:like>
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
if mime_type:
|
||||||
|
conditions.append(
|
||||||
|
f"""
|
||||||
|
<d:like>
|
||||||
|
<d:prop>
|
||||||
|
<d:getcontenttype/>
|
||||||
|
</d:prop>
|
||||||
|
<d:literal>{mime_type}</d:literal>
|
||||||
|
</d:like>
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
if only_favorites:
|
||||||
|
conditions.append(
|
||||||
|
"""
|
||||||
|
<d:eq>
|
||||||
|
<d:prop>
|
||||||
|
<oc:favorite/>
|
||||||
|
</d:prop>
|
||||||
|
<d:literal>1</d:literal>
|
||||||
|
</d:eq>
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
# Combine conditions with AND if multiple
|
||||||
|
if len(conditions) > 1:
|
||||||
|
where_conditions = f"""
|
||||||
|
<d:and>
|
||||||
|
{"".join(conditions)}
|
||||||
|
</d:and>
|
||||||
|
"""
|
||||||
|
elif len(conditions) == 1:
|
||||||
|
where_conditions = conditions[0]
|
||||||
|
else:
|
||||||
|
where_conditions = None
|
||||||
|
|
||||||
|
# Include extended properties
|
||||||
|
properties = [
|
||||||
|
"displayname",
|
||||||
|
"getcontentlength",
|
||||||
|
"getcontenttype",
|
||||||
|
"getlastmodified",
|
||||||
|
"resourcetype",
|
||||||
|
"getetag",
|
||||||
|
"fileid",
|
||||||
|
"favorite",
|
||||||
|
]
|
||||||
|
|
||||||
|
results = await client.webdav.search_files(
|
||||||
|
scope=scope,
|
||||||
|
where_conditions=where_conditions,
|
||||||
|
properties=properties,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Convert to FileInfo models
|
||||||
|
file_infos = [FileInfo(**result) for result in results]
|
||||||
|
|
||||||
|
# Build filters applied dict
|
||||||
|
filters = {}
|
||||||
|
if name_pattern:
|
||||||
|
filters["name_pattern"] = name_pattern
|
||||||
|
if mime_type:
|
||||||
|
filters["mime_type"] = mime_type
|
||||||
|
if only_favorites:
|
||||||
|
filters["only_favorites"] = True
|
||||||
|
|
||||||
|
return SearchFilesResponse(
|
||||||
|
results=file_infos,
|
||||||
|
total_found=len(file_infos),
|
||||||
|
scope=scope,
|
||||||
|
filters_applied=filters if filters else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
@require_scopes("files:read")
|
||||||
|
async def nc_webdav_find_by_name(
|
||||||
|
pattern: str, ctx: Context, scope: str = "", limit: int | None = None
|
||||||
|
) -> SearchFilesResponse:
|
||||||
|
"""Find files by name pattern in NextCloud.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
pattern: Name pattern to search for (supports % wildcard)
|
||||||
|
scope: Directory path to search in (empty string for user root)
|
||||||
|
limit: Maximum number of results to return
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
SearchFilesResponse with list of matching files
|
||||||
|
"""
|
||||||
|
client = get_client(ctx)
|
||||||
|
results = await client.webdav.find_by_name(
|
||||||
|
pattern=pattern, scope=scope, limit=limit
|
||||||
|
)
|
||||||
|
file_infos = [FileInfo(**result) for result in results]
|
||||||
|
return SearchFilesResponse(
|
||||||
|
results=file_infos,
|
||||||
|
total_found=len(file_infos),
|
||||||
|
scope=scope,
|
||||||
|
filters_applied={"name_pattern": pattern},
|
||||||
|
)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
@require_scopes("files:read")
|
||||||
|
async def nc_webdav_find_by_type(
|
||||||
|
mime_type: str, ctx: Context, scope: str = "", limit: int | None = None
|
||||||
|
) -> SearchFilesResponse:
|
||||||
|
"""Find files by MIME type in NextCloud.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
mime_type: MIME type to search for (supports % wildcard)
|
||||||
|
scope: Directory path to search in (empty string for user root)
|
||||||
|
limit: Maximum number of results to return
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
SearchFilesResponse with list of matching files
|
||||||
|
"""
|
||||||
|
client = get_client(ctx)
|
||||||
|
results = await client.webdav.find_by_type(
|
||||||
|
mime_type=mime_type, scope=scope, limit=limit
|
||||||
|
)
|
||||||
|
file_infos = [FileInfo(**result) for result in results]
|
||||||
|
return SearchFilesResponse(
|
||||||
|
results=file_infos,
|
||||||
|
total_found=len(file_infos),
|
||||||
|
scope=scope,
|
||||||
|
filters_applied={"mime_type": mime_type},
|
||||||
|
)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
@require_scopes("files:read")
|
||||||
|
async def nc_webdav_list_favorites(
|
||||||
|
ctx: Context, scope: str = "", limit: int | None = None
|
||||||
|
) -> SearchFilesResponse:
|
||||||
|
"""List all favorite files in NextCloud.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
scope: Directory path to search in (empty string for all favorites)
|
||||||
|
limit: Maximum number of results to return
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
SearchFilesResponse with list of favorite files
|
||||||
|
"""
|
||||||
|
client = get_client(ctx)
|
||||||
|
results = await client.webdav.list_favorites(scope=scope, limit=limit)
|
||||||
|
file_infos = [FileInfo(**result) for result in results]
|
||||||
|
return SearchFilesResponse(
|
||||||
|
results=file_infos,
|
||||||
|
total_found=len(file_infos),
|
||||||
|
scope=scope,
|
||||||
|
filters_applied={"only_favorites": True},
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Utility functions for the Nextcloud MCP server."""
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
"""Document parsing utilities using pluggable processor registry."""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import logging
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.config import get_document_processor_config
|
||||||
|
from nextcloud_mcp_server.document_processors import (
|
||||||
|
ProcessorError,
|
||||||
|
get_registry,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def is_parseable_document(content_type: Optional[str]) -> bool:
|
||||||
|
"""Check if a document type can be parsed by any registered processor.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
content_type: The MIME type of the document
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if any processor can handle this type, False otherwise
|
||||||
|
"""
|
||||||
|
if not content_type:
|
||||||
|
return False
|
||||||
|
|
||||||
|
config = get_document_processor_config()
|
||||||
|
if not config["enabled"]:
|
||||||
|
return False
|
||||||
|
|
||||||
|
registry = get_registry()
|
||||||
|
processor = registry.find_processor(content_type)
|
||||||
|
return processor is not None
|
||||||
|
|
||||||
|
|
||||||
|
async def parse_document(
|
||||||
|
content: bytes,
|
||||||
|
content_type: Optional[str],
|
||||||
|
filename: Optional[str] = None,
|
||||||
|
progress_callback: Optional[
|
||||||
|
Callable[[float, Optional[float], Optional[str]], Awaitable[None]]
|
||||||
|
] = None,
|
||||||
|
) -> Tuple[str, dict]:
|
||||||
|
"""Parse a document using registered processors.
|
||||||
|
|
||||||
|
This function uses the processor registry to find an appropriate
|
||||||
|
processor for the given document type and extract text from it.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
content: The document content as bytes
|
||||||
|
content_type: The MIME type of the document
|
||||||
|
filename: Optional filename to help with format detection
|
||||||
|
progress_callback: Optional async callback for progress updates during long operations
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (parsed_text, metadata) where:
|
||||||
|
- parsed_text: The extracted text content
|
||||||
|
- metadata: Additional metadata about the parsing
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If the document type is not supported
|
||||||
|
Exception: If parsing fails
|
||||||
|
"""
|
||||||
|
if not content_type:
|
||||||
|
raise ValueError("Content type is required for document parsing")
|
||||||
|
|
||||||
|
config = get_document_processor_config()
|
||||||
|
if not config["enabled"]:
|
||||||
|
raise ValueError("Document processing is disabled")
|
||||||
|
|
||||||
|
registry = get_registry()
|
||||||
|
|
||||||
|
logger.debug(f"Parsing document of type '{content_type}'")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Process using registry (auto-selects processor based on MIME type)
|
||||||
|
result = await registry.process(
|
||||||
|
content=content,
|
||||||
|
content_type=content_type,
|
||||||
|
filename=filename,
|
||||||
|
progress_callback=progress_callback,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Successfully parsed document with '{result.processor}' processor")
|
||||||
|
|
||||||
|
return result.text, result.metadata
|
||||||
|
|
||||||
|
except ProcessorError as e:
|
||||||
|
logger.error(f"Document processing failed: {e}")
|
||||||
|
# Fallback to base64 with error metadata
|
||||||
|
parsed_text = f"Document could not be parsed. Base64 content: {base64.b64encode(content).decode('ascii')[:200]}..."
|
||||||
|
metadata = {
|
||||||
|
"mime_type": content_type,
|
||||||
|
"text_length": len(parsed_text),
|
||||||
|
"parsing_method": "fallback_base64",
|
||||||
|
"error": str(e),
|
||||||
|
}
|
||||||
|
return parsed_text, metadata
|
||||||
+68
-15
@@ -1,36 +1,64 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "nextcloud-mcp-server"
|
name = "nextcloud-mcp-server"
|
||||||
version = "0.13.0"
|
version = "0.23.0"
|
||||||
description = ""
|
description = "Model Context Protocol (MCP) server for Nextcloud integration - enables AI assistants to interact with Nextcloud data"
|
||||||
authors = [
|
authors = [
|
||||||
{name = "Chris Coutinho",email = "chris@coutinho.io"}
|
{name = "Chris Coutinho", email = "chris@coutinho.io"}
|
||||||
]
|
]
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
|
license = {text = "AGPL-3.0-only"}
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
|
keywords = ["nextcloud", "mcp", "model-context-protocol", "llm", "ai", "claude", "webdav", "caldav", "carddav"]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"mcp[cli] (>=1.17,<1.18)",
|
"mcp[cli] (>=1.19,<1.20)",
|
||||||
"httpx (>=0.28.1,<0.29.0)",
|
"httpx (>=0.28.1,<0.29.0)",
|
||||||
"pillow (>=11.2.1,<12.0.0)",
|
"pillow (>=12.0.0,<12.1.0)",
|
||||||
"icalendar (>=6.0.0,<7.0.0)",
|
"icalendar (>=6.0.0,<7.0.0)",
|
||||||
"pythonvcard4>=0.2.0",
|
"pythonvcard4>=0.2.0",
|
||||||
"pydantic>=2.11.4",
|
"pydantic>=2.11.4",
|
||||||
"click>=8.1.8",
|
"click>=8.1.8",
|
||||||
|
"caldav",
|
||||||
|
"pyjwt[crypto]>=2.8.0",
|
||||||
|
"aiosqlite>=0.20.0", # Async SQLite for refresh token storage
|
||||||
|
]
|
||||||
|
classifiers = [
|
||||||
|
"Development Status :: 4 - Beta",
|
||||||
|
"Intended Audience :: Developers",
|
||||||
|
"License :: OSI Approved :: GNU Affero General Public License v3",
|
||||||
|
"Programming Language :: Python :: 3",
|
||||||
|
"Programming Language :: Python :: 3.11",
|
||||||
|
"Programming Language :: Python :: 3.12",
|
||||||
|
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
||||||
|
"Topic :: Communications",
|
||||||
|
"Topic :: Internet :: WWW/HTTP",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[project.urls]
|
||||||
|
Homepage = "https://github.com/cbcoutinho/nextcloud-mcp-server"
|
||||||
|
Documentation = "https://github.com/cbcoutinho/nextcloud-mcp-server#readme"
|
||||||
|
Repository = "https://github.com/cbcoutinho/nextcloud-mcp-server"
|
||||||
|
"Bug Tracker" = "https://github.com/cbcoutinho/nextcloud-mcp-server/issues"
|
||||||
|
Changelog = "https://github.com/cbcoutinho/nextcloud-mcp-server/blob/master/CHANGELOG.md"
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
asyncio_mode = "auto"
|
anyio_mode = "auto"
|
||||||
asyncio_default_test_loop_scope = "session"
|
addopts = "-p no:asyncio -x" # Disable pytest-asyncio plugin, use only anyio
|
||||||
asyncio_default_fixture_loop_scope = "session"
|
|
||||||
log_cli = 1
|
log_cli = 1
|
||||||
log_cli_level = "INFO"
|
log_cli_level = "ERROR"
|
||||||
log_level = "INFO"
|
log_level = "ERROR"
|
||||||
markers = [
|
markers = [
|
||||||
"integration: marks tests as slow (deselect with '-m \"not slow\"')",
|
"unit: Fast unit tests with mocked dependencies",
|
||||||
"oauth: marks tests as oauth (deselect with '-m \"not oauth\"')"
|
"integration: Integration tests requiring Docker containers",
|
||||||
|
"oauth: OAuth tests requiring Playwright (slowest)",
|
||||||
|
"smoke: Critical path smoke tests for quick validation",
|
||||||
|
"keycloak: OAuth tests that utilize keycloak external identity provider",
|
||||||
]
|
]
|
||||||
testpaths = [
|
testpaths = [
|
||||||
"tests",
|
"tests",
|
||||||
]
|
]
|
||||||
|
# Timeout settings to prevent tests from hanging indefinitely
|
||||||
|
timeout = 180 # 3 minutes default timeout per test (includes fixture setup)
|
||||||
|
timeout_func_only = false # Timeout includes fixture setup/teardown
|
||||||
|
|
||||||
[tool.commitizen]
|
[tool.commitizen]
|
||||||
name = "cz_conventional_commits"
|
name = "cz_conventional_commits"
|
||||||
@@ -39,10 +67,27 @@ version_scheme = "pep440"
|
|||||||
version_provider = "uv"
|
version_provider = "uv"
|
||||||
update_changelog_on_bump = true
|
update_changelog_on_bump = true
|
||||||
major_version_zero = true
|
major_version_zero = true
|
||||||
|
version_files = [
|
||||||
|
"charts/nextcloud-mcp-server/Chart.yaml:appVersion",
|
||||||
|
"charts/nextcloud-mcp-server/Chart.yaml:version"
|
||||||
|
]
|
||||||
|
ignored_tag_formats = [
|
||||||
|
"nextcloud-mcp-server-*"
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
extend-select = ["I"]
|
||||||
|
|
||||||
|
[tool.uv.sources]
|
||||||
|
caldav = { git = "https://github.com/cbcoutinho/caldav", branch = "feature/httpx" }
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["poetry-core>=2.0.0,<3.0.0"]
|
requires = ["uv_build>=0.9.4,<0.10.0"]
|
||||||
build-backend = "poetry.core.masonry.api"
|
build-backend = "uv_build"
|
||||||
|
|
||||||
|
[tool.uv.build-backend]
|
||||||
|
module-name = "nextcloud_mcp_server"
|
||||||
|
module-root = ""
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = [
|
dev = [
|
||||||
@@ -50,11 +95,19 @@ dev = [
|
|||||||
"ipython>=9.2.0",
|
"ipython>=9.2.0",
|
||||||
"playwright>=1.49.1",
|
"playwright>=1.49.1",
|
||||||
"pytest>=8.3.5",
|
"pytest>=8.3.5",
|
||||||
"pytest-asyncio>=1.0.0",
|
|
||||||
"pytest-cov>=6.1.1",
|
"pytest-cov>=6.1.1",
|
||||||
|
"pytest-mock>=3.15.1",
|
||||||
"pytest-playwright-asyncio>=0.7.1",
|
"pytest-playwright-asyncio>=0.7.1",
|
||||||
|
"pytest-timeout>=2.3.1",
|
||||||
"ruff>=0.11.13",
|
"ruff>=0.11.13",
|
||||||
|
"reportlab>=4.0.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
nextcloud-mcp-server = "nextcloud_mcp_server.app:run"
|
nextcloud-mcp-server = "nextcloud_mcp_server.app:run"
|
||||||
|
|
||||||
|
[[tool.uv.index]]
|
||||||
|
name = "testpypi"
|
||||||
|
url = "https://test.pypi.org/simple/"
|
||||||
|
publish-url = "https://test.pypi.org/legacy/"
|
||||||
|
explicit = true
|
||||||
|
|||||||
@@ -0,0 +1,307 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Script to automatically add @require_scopes decorators to MCP tools.
|
||||||
|
|
||||||
|
This script parses server module files and adds appropriate scope decorators
|
||||||
|
based on the operation type (read vs write).
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python scripts/add_scope_decorators.py [--dry-run] [--file FILE]
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import ast
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Tuple
|
||||||
|
|
||||||
|
# Operation patterns for classification
|
||||||
|
READ_PATTERNS = [
|
||||||
|
r".*_get_.*",
|
||||||
|
r".*_get$",
|
||||||
|
r".*_list_.*",
|
||||||
|
r".*_list$",
|
||||||
|
r".*_search_.*",
|
||||||
|
r".*_search$",
|
||||||
|
r".*_read_.*",
|
||||||
|
r".*_read$",
|
||||||
|
r".*_find_.*",
|
||||||
|
r".*_find$",
|
||||||
|
r".*_fetch_.*",
|
||||||
|
r".*_fetch$",
|
||||||
|
r".*_retrieve_.*",
|
||||||
|
r".*_retrieve$",
|
||||||
|
]
|
||||||
|
|
||||||
|
WRITE_PATTERNS = [
|
||||||
|
r".*_create_.*",
|
||||||
|
r".*_create$",
|
||||||
|
r".*_update_.*",
|
||||||
|
r".*_update$",
|
||||||
|
r".*_delete_.*",
|
||||||
|
r".*_delete$",
|
||||||
|
r".*_append_.*",
|
||||||
|
r".*_append$",
|
||||||
|
r".*_modify_.*",
|
||||||
|
r".*_modify$",
|
||||||
|
r".*_set_.*",
|
||||||
|
r".*_set$",
|
||||||
|
r".*_add_.*",
|
||||||
|
r".*_add$",
|
||||||
|
r".*_remove_.*",
|
||||||
|
r".*_remove$",
|
||||||
|
r".*_edit_.*",
|
||||||
|
r".*_edit$",
|
||||||
|
r".*_move_.*",
|
||||||
|
r".*_move$",
|
||||||
|
r".*_copy_.*",
|
||||||
|
r".*_copy$",
|
||||||
|
r".*_upload_.*",
|
||||||
|
r".*_upload$",
|
||||||
|
r".*_download_.*",
|
||||||
|
r".*_download$",
|
||||||
|
r".*_share_.*",
|
||||||
|
r".*_share$",
|
||||||
|
r".*_unshare_.*",
|
||||||
|
r".*_unshare$",
|
||||||
|
r".*_bulk_.*", # Bulk operations are typically writes
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def classify_operation(func_name: str) -> str | None:
|
||||||
|
"""Classify a function as read or write operation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
func_name: Function name to classify
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
"nc:read", "nc:write", or None if cannot classify
|
||||||
|
"""
|
||||||
|
# Check write patterns first (more specific)
|
||||||
|
for pattern in WRITE_PATTERNS:
|
||||||
|
if re.match(pattern, func_name):
|
||||||
|
return "nc:write"
|
||||||
|
|
||||||
|
# Check read patterns
|
||||||
|
for pattern in READ_PATTERNS:
|
||||||
|
if re.match(pattern, func_name):
|
||||||
|
return "nc:read"
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def has_scope_decorator(decorators: List[ast.expr]) -> bool:
|
||||||
|
"""Check if function already has @require_scopes decorator."""
|
||||||
|
for decorator in decorators:
|
||||||
|
if isinstance(decorator, ast.Call):
|
||||||
|
if (
|
||||||
|
isinstance(decorator.func, ast.Name)
|
||||||
|
and decorator.func.id == "require_scopes"
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
elif isinstance(decorator, ast.Name) and decorator.name == "require_scopes":
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def has_mcp_tool_decorator(decorators: List[ast.expr]) -> bool:
|
||||||
|
"""Check if function has @mcp.tool() decorator."""
|
||||||
|
for decorator in decorators:
|
||||||
|
if isinstance(decorator, ast.Call):
|
||||||
|
if isinstance(decorator.func, ast.Attribute):
|
||||||
|
if decorator.func.attr == "tool":
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def find_tools_needing_decorators(
|
||||||
|
file_path: Path, verbose: bool = False
|
||||||
|
) -> List[Tuple[str, int, str]]:
|
||||||
|
"""Find all tools that need scope decorators.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of (function_name, line_number, required_scope)
|
||||||
|
"""
|
||||||
|
with open(file_path) as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
try:
|
||||||
|
tree = ast.parse(content)
|
||||||
|
except SyntaxError as e:
|
||||||
|
print(f" ⚠️ Syntax error in {file_path}: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
tools_to_update = []
|
||||||
|
total_functions = 0
|
||||||
|
mcp_tools = 0
|
||||||
|
already_has_scope = 0
|
||||||
|
cannot_classify = 0
|
||||||
|
|
||||||
|
for node in ast.walk(tree):
|
||||||
|
if isinstance(node, ast.FunctionDef):
|
||||||
|
total_functions += 1
|
||||||
|
|
||||||
|
if verbose and node.decorator_list:
|
||||||
|
decorators_str = [
|
||||||
|
ast.unparse(d) if hasattr(ast, "unparse") else str(d)
|
||||||
|
for d in node.decorator_list
|
||||||
|
]
|
||||||
|
print(f" Function {node.name} has decorators: {decorators_str}")
|
||||||
|
|
||||||
|
# Check if it's an MCP tool
|
||||||
|
if not has_mcp_tool_decorator(node.decorator_list):
|
||||||
|
continue
|
||||||
|
|
||||||
|
mcp_tools += 1
|
||||||
|
|
||||||
|
# Check if it already has scope decorator
|
||||||
|
if has_scope_decorator(node.decorator_list):
|
||||||
|
already_has_scope += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Classify operation
|
||||||
|
scope = classify_operation(node.name)
|
||||||
|
if scope:
|
||||||
|
tools_to_update.append((node.name, node.lineno, scope))
|
||||||
|
else:
|
||||||
|
cannot_classify += 1
|
||||||
|
if verbose:
|
||||||
|
print(f" ⚠️ Cannot classify: {node.name}")
|
||||||
|
|
||||||
|
if verbose:
|
||||||
|
print(
|
||||||
|
f" Debug: total_functions={total_functions}, mcp_tools={mcp_tools}, already_has_scope={already_has_scope}, cannot_classify={cannot_classify}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return tools_to_update
|
||||||
|
|
||||||
|
|
||||||
|
def add_decorator_to_file(
|
||||||
|
file_path: Path, dry_run: bool = False, verbose: bool = False
|
||||||
|
) -> int:
|
||||||
|
"""Add @require_scopes decorators to tools in a file.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of decorators added
|
||||||
|
"""
|
||||||
|
tools = find_tools_needing_decorators(file_path, verbose=verbose)
|
||||||
|
|
||||||
|
if not tools:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
print(f"\n📝 {file_path.relative_to(Path.cwd())}")
|
||||||
|
|
||||||
|
with open(file_path) as f:
|
||||||
|
lines = f.readlines()
|
||||||
|
|
||||||
|
# Check if require_scopes is already imported
|
||||||
|
has_import = False
|
||||||
|
import_line_idx = None
|
||||||
|
for i, line in enumerate(lines):
|
||||||
|
if "from nextcloud_mcp_server.auth import" in line and "require_scopes" in line:
|
||||||
|
has_import = True
|
||||||
|
break
|
||||||
|
elif "from nextcloud_mcp_server.auth import" in line:
|
||||||
|
import_line_idx = i
|
||||||
|
|
||||||
|
# Add import if needed
|
||||||
|
if not has_import:
|
||||||
|
if import_line_idx is not None:
|
||||||
|
# Add require_scopes to existing import
|
||||||
|
old_line = lines[import_line_idx]
|
||||||
|
if "(" in old_line:
|
||||||
|
# Multi-line import
|
||||||
|
print(
|
||||||
|
" ⚠️ Multi-line import detected, please add manually: from nextcloud_mcp_server.auth import require_scopes"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Single line import - add require_scopes
|
||||||
|
lines[import_line_idx] = (
|
||||||
|
old_line.rstrip().rstrip(")").rstrip() + ", require_scopes)\n"
|
||||||
|
)
|
||||||
|
print(" ✓ Added require_scopes to import")
|
||||||
|
else:
|
||||||
|
# No auth import exists, add new import
|
||||||
|
# Find first import line
|
||||||
|
for i, line in enumerate(lines):
|
||||||
|
if line.startswith("from nextcloud_mcp_server"):
|
||||||
|
lines.insert(
|
||||||
|
i, "from nextcloud_mcp_server.auth import require_scopes\n"
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
" ✓ Added import: from nextcloud_mcp_server.auth import require_scopes"
|
||||||
|
)
|
||||||
|
break
|
||||||
|
|
||||||
|
# Add decorators to tools (in reverse order to preserve line numbers)
|
||||||
|
for func_name, line_num, scope in reversed(tools):
|
||||||
|
# Find the @mcp.tool() decorator line
|
||||||
|
for i in range(line_num - 1, max(0, line_num - 10), -1):
|
||||||
|
if "@mcp.tool()" in lines[i]:
|
||||||
|
# Get indentation from @mcp.tool() line
|
||||||
|
indent = len(lines[i]) - len(lines[i].lstrip())
|
||||||
|
decorator_line = " " * indent + f'@require_scopes("{scope}")\n'
|
||||||
|
lines.insert(i + 1, decorator_line)
|
||||||
|
print(f' ✓ {func_name}:{line_num} → @require_scopes("{scope}")')
|
||||||
|
break
|
||||||
|
|
||||||
|
if not dry_run:
|
||||||
|
with open(file_path, "w") as f:
|
||||||
|
f.writelines(lines)
|
||||||
|
print(" 💾 Saved changes")
|
||||||
|
else:
|
||||||
|
print(" 🔍 DRY RUN - no changes written")
|
||||||
|
|
||||||
|
return len(tools)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Add @require_scopes decorators to MCP tools"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--dry-run",
|
||||||
|
action="store_true",
|
||||||
|
help="Show what would be changed without modifying files",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--file",
|
||||||
|
type=Path,
|
||||||
|
help="Process a single file instead of all server modules",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--verbose",
|
||||||
|
"-v",
|
||||||
|
action="store_true",
|
||||||
|
help="Show debug information",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
server_dir = Path(__file__).parent.parent / "nextcloud_mcp_server" / "server"
|
||||||
|
|
||||||
|
if args.file:
|
||||||
|
files = [args.file]
|
||||||
|
else:
|
||||||
|
files = sorted(server_dir.glob("*.py"))
|
||||||
|
files = [f for f in files if f.name != "__init__.py"]
|
||||||
|
|
||||||
|
print("🔍 Scanning for tools needing scope decorators...")
|
||||||
|
print(
|
||||||
|
f" {'DRY RUN MODE - No changes will be made' if args.dry_run else 'LIVE MODE - Files will be modified'}"
|
||||||
|
)
|
||||||
|
|
||||||
|
total_added = 0
|
||||||
|
for file_path in files:
|
||||||
|
added = add_decorator_to_file(
|
||||||
|
file_path, dry_run=args.dry_run, verbose=args.verbose
|
||||||
|
)
|
||||||
|
total_added += added
|
||||||
|
|
||||||
|
print(f"\n{'📊 Summary (dry run)' if args.dry_run else '✅ Complete'}")
|
||||||
|
print(f" Total decorators added: {total_added}")
|
||||||
|
|
||||||
|
if args.dry_run:
|
||||||
|
print("\n💡 Run without --dry-run to apply changes")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Simpler script to add @require_scopes decorators using regex.
|
||||||
|
|
||||||
|
This script uses regex patterns to find @mcp.tool() decorators and adds
|
||||||
|
the appropriate @require_scopes decorator based on function name patterns.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python scripts/add_scope_decorators_simple.py [--dry-run]
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Operation patterns for classification
|
||||||
|
READ_KEYWORDS = [
|
||||||
|
"get",
|
||||||
|
"list",
|
||||||
|
"search",
|
||||||
|
"read",
|
||||||
|
"find",
|
||||||
|
"fetch",
|
||||||
|
"retrieve",
|
||||||
|
"upcoming",
|
||||||
|
]
|
||||||
|
WRITE_KEYWORDS = [
|
||||||
|
"create",
|
||||||
|
"update",
|
||||||
|
"delete",
|
||||||
|
"append",
|
||||||
|
"modify",
|
||||||
|
"set",
|
||||||
|
"add",
|
||||||
|
"remove",
|
||||||
|
"edit",
|
||||||
|
"move",
|
||||||
|
"copy",
|
||||||
|
"upload",
|
||||||
|
"download",
|
||||||
|
"share",
|
||||||
|
"unshare",
|
||||||
|
"bulk",
|
||||||
|
"manage",
|
||||||
|
"import",
|
||||||
|
"reindex",
|
||||||
|
"archive",
|
||||||
|
"unarchive",
|
||||||
|
"reorder",
|
||||||
|
"assign",
|
||||||
|
"unassign",
|
||||||
|
"insert",
|
||||||
|
"write",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def classify_function(func_name: str) -> str | None:
|
||||||
|
"""Classify a function name as read or write operation."""
|
||||||
|
func_lower = func_name.lower()
|
||||||
|
|
||||||
|
# Check write keywords first (more specific)
|
||||||
|
for keyword in WRITE_KEYWORDS:
|
||||||
|
if f"_{keyword}_" in func_lower or func_lower.endswith(f"_{keyword}"):
|
||||||
|
return "nc:write"
|
||||||
|
|
||||||
|
# Check read keywords
|
||||||
|
for keyword in READ_KEYWORDS:
|
||||||
|
if f"_{keyword}_" in func_lower or func_lower.endswith(f"_{keyword}"):
|
||||||
|
return "nc:read"
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def process_file(file_path: Path, dry_run: bool = False) -> int:
|
||||||
|
"""Process a single file to add @require_scopes decorators.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of decorators added
|
||||||
|
"""
|
||||||
|
with open(file_path) as f:
|
||||||
|
lines = f.readlines()
|
||||||
|
|
||||||
|
# Check if require_scopes is already imported
|
||||||
|
has_import = False
|
||||||
|
import_line_idx = None
|
||||||
|
|
||||||
|
for i, line in enumerate(lines):
|
||||||
|
if "from nextcloud_mcp_server.auth import" in line:
|
||||||
|
if "require_scopes" in line:
|
||||||
|
has_import = True
|
||||||
|
else:
|
||||||
|
import_line_idx = i
|
||||||
|
|
||||||
|
modified = False
|
||||||
|
decorators_added = 0
|
||||||
|
|
||||||
|
# Find all @mcp.tool() decorators
|
||||||
|
i = 0
|
||||||
|
while i < len(lines):
|
||||||
|
line = lines[i]
|
||||||
|
|
||||||
|
# Look for @mcp.tool() decorator
|
||||||
|
if re.match(r"\s*@mcp\.tool\(\)", line):
|
||||||
|
# Check if next line already has @require_scopes
|
||||||
|
if i + 1 < len(lines) and "@require_scopes" in lines[i + 1]:
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Find the function definition (should be on next line or after other decorators)
|
||||||
|
func_line_idx = i + 1
|
||||||
|
while func_line_idx < len(lines) and not lines[
|
||||||
|
func_line_idx
|
||||||
|
].strip().startswith("async def"):
|
||||||
|
func_line_idx += 1
|
||||||
|
|
||||||
|
if func_line_idx >= len(lines):
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Extract function name
|
||||||
|
func_match = re.match(r"\s*async def (\w+)\(", lines[func_line_idx])
|
||||||
|
if not func_match:
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
func_name = func_match.group(1)
|
||||||
|
scope = classify_function(func_name)
|
||||||
|
|
||||||
|
if scope:
|
||||||
|
# Get indentation from @mcp.tool() line
|
||||||
|
indent = len(line) - len(line.lstrip())
|
||||||
|
decorator_line = " " * indent + f'@require_scopes("{scope}")\n'
|
||||||
|
|
||||||
|
# Insert after @mcp.tool()
|
||||||
|
lines.insert(i + 1, decorator_line)
|
||||||
|
decorators_added += 1
|
||||||
|
modified = True
|
||||||
|
print(f' ✓ {func_name} → @require_scopes("{scope}")')
|
||||||
|
else:
|
||||||
|
print(f" ⚠️ Cannot classify: {func_name}")
|
||||||
|
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
# Add import if needed and decorators were added
|
||||||
|
if decorators_added > 0 and not has_import:
|
||||||
|
if import_line_idx is not None:
|
||||||
|
# Add to existing import
|
||||||
|
old_line = lines[import_line_idx]
|
||||||
|
if old_line.rstrip().endswith(")"):
|
||||||
|
lines[import_line_idx] = old_line.rstrip()[:-1] + ", require_scopes)\n"
|
||||||
|
else:
|
||||||
|
lines[import_line_idx] = old_line.rstrip() + ", require_scopes\n"
|
||||||
|
print(" ✓ Added require_scopes to existing import")
|
||||||
|
modified = True
|
||||||
|
else:
|
||||||
|
# No auth import exists, add new import after last 'from nextcloud_mcp_server' import
|
||||||
|
last_nc_import_idx = None
|
||||||
|
for i, line in enumerate(lines):
|
||||||
|
if line.startswith("from nextcloud_mcp_server"):
|
||||||
|
last_nc_import_idx = i
|
||||||
|
|
||||||
|
if last_nc_import_idx is not None:
|
||||||
|
lines.insert(
|
||||||
|
last_nc_import_idx + 1,
|
||||||
|
"from nextcloud_mcp_server.auth import require_scopes\n",
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
" ✓ Added new import: from nextcloud_mcp_server.auth import require_scopes"
|
||||||
|
)
|
||||||
|
modified = True
|
||||||
|
else:
|
||||||
|
print(" ⚠️ Could not find place to add require_scopes import")
|
||||||
|
|
||||||
|
# Write changes
|
||||||
|
if modified and not dry_run:
|
||||||
|
with open(file_path, "w") as f:
|
||||||
|
f.writelines(lines)
|
||||||
|
print(f" 💾 Saved changes to {file_path.name}")
|
||||||
|
elif dry_run and decorators_added > 0:
|
||||||
|
print(f" 🔍 DRY RUN - would add {decorators_added} decorators")
|
||||||
|
|
||||||
|
return decorators_added
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Add @require_scopes decorators to MCP tools"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--dry-run",
|
||||||
|
action="store_true",
|
||||||
|
help="Show what would be changed without modifying files",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--file",
|
||||||
|
type=Path,
|
||||||
|
help="Process a single file instead of all server modules",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
server_dir = Path(__file__).parent.parent / "nextcloud_mcp_server" / "server"
|
||||||
|
|
||||||
|
if args.file:
|
||||||
|
files = [args.file]
|
||||||
|
else:
|
||||||
|
files = sorted(server_dir.glob("*.py"))
|
||||||
|
files = [f for f in files if f.name != "__init__.py"]
|
||||||
|
|
||||||
|
print("🔍 Scanning for tools needing scope decorators...")
|
||||||
|
print(
|
||||||
|
f" {'DRY RUN MODE - No changes will be made' if args.dry_run else 'LIVE MODE - Files will be modified'}"
|
||||||
|
)
|
||||||
|
|
||||||
|
total_added = 0
|
||||||
|
for file_path in files:
|
||||||
|
file_path = file_path.resolve() # Convert to absolute path
|
||||||
|
try:
|
||||||
|
display_path = file_path.relative_to(Path.cwd())
|
||||||
|
except ValueError:
|
||||||
|
display_path = file_path.name
|
||||||
|
print(f"\n📝 {display_path}")
|
||||||
|
added = process_file(file_path, dry_run=args.dry_run)
|
||||||
|
total_added += added
|
||||||
|
|
||||||
|
print(f"\n{'📊 Summary (dry run)' if args.dry_run else '✅ Complete'}")
|
||||||
|
print(f" Total decorators added: {total_added}")
|
||||||
|
|
||||||
|
if args.dry_run and total_added > 0:
|
||||||
|
print("\n💡 Run without --dry-run to apply changes")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Executable
+90
@@ -0,0 +1,90 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "=== Testing Separate Clients Architecture ==="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Check both clients exist in Keycloak
|
||||||
|
echo "1. Verifying Keycloak clients..."
|
||||||
|
docker compose exec -T app curl -s http://keycloak:8080/realms/nextcloud-mcp/.well-known/openid-configuration > /dev/null && echo "✓ Keycloak realm available"
|
||||||
|
|
||||||
|
# Check user_oidc provider configuration
|
||||||
|
echo ""
|
||||||
|
echo "2. Checking user_oidc provider..."
|
||||||
|
PROVIDER_INFO=$(docker compose exec -T app php occ user_oidc:provider keycloak)
|
||||||
|
echo "$PROVIDER_INFO" | grep -q "nextcloud" && echo "✓ user_oidc configured with 'nextcloud' client"
|
||||||
|
|
||||||
|
# Get token from nextcloud-mcp-server client
|
||||||
|
echo ""
|
||||||
|
echo "3. Getting token from 'nextcloud-mcp-server' client..."
|
||||||
|
TOKEN=$(curl -s -X POST "http://localhost:8888/realms/nextcloud-mcp/protocol/openid-connect/token" \
|
||||||
|
-d "grant_type=password" \
|
||||||
|
-d "client_id=nextcloud-mcp-server" \
|
||||||
|
-d "client_secret=mcp-secret-change-in-production" \
|
||||||
|
-d "username=admin" \
|
||||||
|
-d "password=admin" \
|
||||||
|
-d "scope=openid profile email offline_access" | jq -r '.access_token')
|
||||||
|
|
||||||
|
if [ "$TOKEN" = "null" ] || [ -z "$TOKEN" ]; then
|
||||||
|
echo "✗ Failed to get token"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "✓ Got token from nextcloud-mcp-server client"
|
||||||
|
|
||||||
|
# Check token claims
|
||||||
|
echo ""
|
||||||
|
echo "4. Inspecting token claims..."
|
||||||
|
CLAIMS=$(echo "$TOKEN" | cut -d'.' -f2 | base64 -d 2>/dev/null | jq '{aud, azp, iss, preferred_username}')
|
||||||
|
echo "$CLAIMS"
|
||||||
|
|
||||||
|
AUD=$(echo "$CLAIMS" | jq -r '.aud')
|
||||||
|
AZP=$(echo "$CLAIMS" | jq -r '.azp')
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Architecture validation:"
|
||||||
|
if [ "$AUD" = "nextcloud" ]; then
|
||||||
|
echo " ✓ aud='nextcloud' - Token intended for Nextcloud resource server"
|
||||||
|
else
|
||||||
|
echo " ✗ FAILED: aud='$AUD', expected 'nextcloud'"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$AZP" = "nextcloud-mcp-server" ]; then
|
||||||
|
echo " ✓ azp='nextcloud-mcp-server' - Token requested by MCP Server client"
|
||||||
|
else
|
||||||
|
echo " ✗ FAILED: azp='$AZP', expected 'nextcloud-mcp-server'"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Test with Nextcloud API
|
||||||
|
echo ""
|
||||||
|
echo "5. Testing token with Nextcloud API..."
|
||||||
|
HTTP_CODE=$(curl -s -w "%{http_code}" -o /tmp/nc_response.json \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
"http://localhost:8080/ocs/v2.php/cloud/capabilities?format=json")
|
||||||
|
|
||||||
|
echo "HTTP Status: $HTTP_CODE"
|
||||||
|
|
||||||
|
if [ "$HTTP_CODE" = "200" ]; then
|
||||||
|
echo "✓ Token validated successfully!"
|
||||||
|
echo ""
|
||||||
|
echo "===================================================================="
|
||||||
|
echo "SUCCESS: Separate Clients Architecture Working!"
|
||||||
|
echo "===================================================================="
|
||||||
|
echo ""
|
||||||
|
echo "Summary:"
|
||||||
|
echo " - MCP Server client: 'nextcloud-mcp-server' (requests tokens)"
|
||||||
|
echo " - Resource server: 'nextcloud' (validates tokens via user_oidc)"
|
||||||
|
echo " - Token audience: 'nextcloud' (proper resource targeting)"
|
||||||
|
echo " - Token azp: 'nextcloud-mcp-server' (identifies requester)"
|
||||||
|
echo ""
|
||||||
|
echo "This architecture supports:"
|
||||||
|
echo " - Future multi-resource tokens: aud=['nextcloud', 'other-service']"
|
||||||
|
echo " - Clear separation of OAuth client vs resource server"
|
||||||
|
echo " - RFC 8707 Resource Indicators compliance"
|
||||||
|
else
|
||||||
|
echo "✗ Token validation failed"
|
||||||
|
cat /tmp/nc_response.json
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user