Compare commits
915 Commits
v0.3.0
...
rag-evaluation
| Author | SHA1 | Date | |
|---|---|---|---|
| 02700a8e2c | |||
| 529daf2b48 | |||
| 137d1d6c75 | |||
| c8d9cc24e0 | |||
| 98d1c2de8e | |||
| 30a4d84458 | |||
| fca8ab0cfd | |||
| 7e7d861797 | |||
| 4fa2edf4c7 | |||
| defa8db18e | |||
| c9506da2d2 | |||
| c272ddd82d | |||
| eaeb8eae28 | |||
| 42376483ab | |||
| ed0825e661 | |||
| e3153822f7 | |||
| 2b35dd729f | |||
| eb32bbbc6b | |||
| 916af1c8f3 | |||
| 9a62c8478f | |||
| 2a078093ed | |||
| 682923dcc8 | |||
| b1a756145e | |||
| b5b03bfd78 | |||
| f3bdb8b885 | |||
| 11e620f2d1 | |||
| 56bd85c0f7 | |||
| 5e67277049 | |||
| 66a7109130 | |||
| 00e72d24a6 | |||
| dc78d92e5b | |||
| 86891173b2 | |||
| 73b3d80026 | |||
| 26099d643d | |||
| 56a5c63994 | |||
| 92c8e1e41d | |||
| dd12c957f6 | |||
| 74e2ab2440 | |||
| d124144424 | |||
| 39259ef282 | |||
| 3648d478f1 | |||
| 14a59fdff3 | |||
| 2f138e7539 | |||
| 2baacc0ae8 | |||
| c3023d2cc3 | |||
| 6253faee19 | |||
| c97f12d47e | |||
| a667d7c59c | |||
| bd76902932 | |||
| ff3123a190 | |||
| da65155cde | |||
| 4e43d15153 | |||
| 15951c38fa | |||
| 2de0590839 | |||
| 4ea5ed72d4 | |||
| d1829fbbd6 | |||
| 8332542959 | |||
| 2c37ad165e | |||
| 619ba5684d | |||
| 747d297008 | |||
| ba8486b73b | |||
| 6812e1aca7 | |||
| 49a9dd43c6 | |||
| f6656fee06 | |||
| 7e93097137 | |||
| 0eae33a918 | |||
| 3430b2409d | |||
| adde0e5623 | |||
| 12c96af819 | |||
| d86a185e04 | |||
| f4759e424d | |||
| 1bced88c97 | |||
| b58e7238ae | |||
| 0005e0dce0 | |||
| 636e5105c3 | |||
| ee7080afb3 | |||
| b52f482a51 | |||
| ce666934f2 | |||
| cdf69b3ea8 | |||
| a6e5f3d8ff | |||
| f44bf3e8f2 | |||
| 37141003d8 | |||
| c787abf2f3 | |||
| b32324cb76 | |||
| 640a7818f9 | |||
| 8e5d0b5df1 | |||
| 851d21f56e | |||
| fb1af697f7 | |||
| bf4eed6007 | |||
| 3a41860d27 | |||
| 126b5a7626 | |||
| 4d3ff1abe1 | |||
| d80e54ff97 | |||
| 157e433d65 | |||
| 94d16092c0 | |||
| cb39b3fca4 | |||
| f3050e9b45 | |||
| e575c8e57b | |||
| a0576aa9a2 | |||
| 4a6c60113b | |||
| a0cb1ac9fe | |||
| de4f1032aa | |||
| 178be5da6d | |||
| 61d8c851c9 | |||
| a8c63c8379 | |||
| 3147180ccd | |||
| 380578dd2e | |||
| 10c5557aea | |||
| 7772b1ac2e | |||
| 0513bec105 | |||
| 4e89e92b65 | |||
| af96378cb6 | |||
| c5da11aa4c | |||
| 5e4667a643 | |||
| 093ac5b5ba | |||
| ae81f0334e | |||
| 23f3a231a5 | |||
| 7be40a33e1 | |||
| 578de4d7d6 | |||
| 8f0f989c6d | |||
| f8a2935c22 | |||
| 137dc80075 | |||
| 725ac65e6a | |||
| f51edff25d | |||
| 50ba6ccc88 | |||
| 538bbc375e | |||
| d4c686eba7 | |||
| 167e49788e | |||
| 857d8f2152 | |||
| 72232f937a | |||
| 4b026e9aa0 | |||
| 31799ffd9a | |||
| 5cc598e1b1 | |||
| a6c76c5cc1 | |||
| a854656d3c | |||
| bb5d4f464f | |||
| e32c8f4aec | |||
| ee183e1c1c | |||
| 1a57f97d3a | |||
| e96c02e4d4 | |||
| 7b8c3f93a8 | |||
| fdd82f59e2 | |||
| 4dbb2eb468 | |||
| 8f45e996e8 | |||
| dc93da2ea0 | |||
| 31ff8a71bf | |||
| bd012831cf | |||
| 4ceaf45ffd | |||
| 21b878a2e7 | |||
| 218f0bd366 | |||
| afee3e8bb4 | |||
| 050a00d8c8 | |||
| f59b6a6cfb | |||
| a766f4be32 | |||
| ee053d559c | |||
| 71326384da | |||
| 11cdab475f | |||
| 281d28c7cd | |||
| 0c9a9ea24d | |||
| dfa6d08ba7 | |||
| c5395041d3 | |||
| c1e135c4a2 | |||
| 50cda2209f | |||
| d34e17a68b | |||
| 77e491beea | |||
| 7812ac0ee7 | |||
| 659087e4c7 | |||
| bdb1ba2051 | |||
| 7d9ab5559c | |||
| 877c4c91e0 | |||
| 5deb3132c3 | |||
| 9fab6cb550 | |||
| 28c2debf3e | |||
| 461971a1a8 | |||
| 3485b55e2d | |||
| 4adb9de5f0 | |||
| bfa944d0e8 | |||
| 01569497d7 | |||
| 6cccd92b3b | |||
| 9dcda0cd6a | |||
| 7c2f39930a | |||
| 205c3b013c | |||
| ed9a8677fe | |||
| e8c499938f | |||
| 4d8b6fca49 | |||
| 67eb4455fd | |||
| 7052c19de0 | |||
| 921854ce87 | |||
| 3e988acb97 | |||
| f587a4e31f | |||
| 6e95447272 | |||
| 8983f25eaf | |||
| 1675fc521b | |||
| dec02f17d1 | |||
| 881b0ba03c | |||
| 942fe35719 | |||
| 723eb57060 | |||
| 619d0e4be6 | |||
| dc7abcbd48 | |||
| 3d4dfcbb35 | |||
| de99296779 | |||
| 10dffd0c10 | |||
| 737d62fe91 | |||
| 192c4bf009 | |||
| 01d1cf9190 | |||
| 0ff85dbe4f | |||
| 96789db29d | |||
| b20c9c6203 | |||
| 15113dbb03 | |||
| 615f345928 | |||
| d14f2f666d | |||
| d92945a388 | |||
| 42426b4597 | |||
| c2dcb06fe1 | |||
| 95b73019ab | |||
| 6a0f537d66 | |||
| 71e77e95bc | |||
| 636bfd416f | |||
| 64864db736 | |||
| 027fc0b2d6 | |||
| d768909fd4 | |||
| 3b4606b798 | |||
| 63b457380a | |||
| b41bbd6c65 | |||
| 9adfc72612 | |||
| c896a2de63 | |||
| d16bcdcfbb | |||
| 6c3997b24c | |||
| 9d514f52b0 | |||
| 4e1d143e54 | |||
| 02a2c4a16f | |||
| f37008fdc3 | |||
| 0d45120470 | |||
| babd60e08b | |||
| f48e039e9e | |||
| 14a8f70503 | |||
| bf8120682e | |||
| f2af5a39a8 | |||
| 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 | |||
| cc2a5c9d58 | |||
| 26f8deff17 | |||
| fb3063e94e | |||
| 83f89e9394 | |||
| 5db02313a1 | |||
| b50e212f05 | |||
| 85f8522085 | |||
| a38c795124 | |||
| 7004104873 | |||
| 7a4a31b52d | |||
| 898c2e72ae | |||
| 8652684466 | |||
| 72ace9da9e | |||
| ab40127811 | |||
| 52044ef053 | |||
| 7103a795a1 | |||
| 3ed24bd5e3 | |||
| 1023a7d9c7 | |||
| afc82ce3dc | |||
| 057e25b653 | |||
| 3c4535da75 | |||
| a4ca3e00a0 | |||
| d879904540 | |||
| 2ae3c423e9 | |||
| e886eff4ed | |||
| 23688f3f85 | |||
| 13e4915e38 | |||
| f48d3714d2 | |||
| 558f5ab6a4 | |||
| 23cffc606b | |||
| 949d383606 | |||
| 6ce411094c | |||
| 37b0577bfd | |||
| 4b19964817 | |||
| ea468889ce | |||
| bcf8daaa5d | |||
| 9ef9fff2b0 | |||
| 2489a714b8 | |||
| a4a7fb48d6 | |||
| f58a9883a6 | |||
| b3b7c90bd0 | |||
| b26ff4f9bc | |||
| e42cabb6ed | |||
| 4fae78a090 | |||
| b7b83880c0 | |||
| 879cd58db1 | |||
| 0c5d9a46bd | |||
| 605c8afacd | |||
| 17979accb6 | |||
| 7d8ba39434 | |||
| 2b11718c43 | |||
| 33b962a7fc | |||
| 4d7e4b9a4b | |||
| fafede2282 | |||
| bad04573b5 | |||
| ec503e3f73 | |||
| 55f326aa9a | |||
| b19eb37ee2 | |||
| 0fdbd56cf0 | |||
| 31b218f174 | |||
| 34daaa380e | |||
| 8d3a7775c9 | |||
| af7deff836 | |||
| 7695fbca0c | |||
| f16af39b97 | |||
| 3340a63f86 | |||
| 5cda32098f | |||
| df09fff11c | |||
| 391f418934 | |||
| e1f17c3386 | |||
| 2e6f31ed41 | |||
| 900d1bb462 | |||
| d7f2f2b302 | |||
| 1402da0ac0 | |||
| 6b50495c1d | |||
| 0f7f5171a4 | |||
| f943fba432 | |||
| 0d98d9dfa0 | |||
| 5b3baa5959 | |||
| a8784993b2 | |||
| 431644fff6 | |||
| fb2632e044 | |||
| 3be62a095c | |||
| 3ff6346c03 | |||
| aead059eaa | |||
| 90eb43b926 | |||
| 5f3ff60531 | |||
| 60743a9f1c | |||
| 669f678d63 | |||
| c9a687171a | |||
| df5f85e0c6 | |||
| 76dce41ed9 | |||
| 642108ee91 | |||
| ce5724f05e | |||
| 1cf783d062 | |||
| 7463234ccb | |||
| b60da57597 | |||
| 0c9645bb3c | |||
| b10fba0678 | |||
| b23ccb57d5 | |||
| 0faa32fd10 | |||
| 8a9fa2a3c4 | |||
| 8075d5fd9f | |||
| 9be03ef0de | |||
| eda6753253 | |||
| 360a15959c | |||
| e6dc14c31f | |||
| bcc909bb83 | |||
| e5fe7c6d84 | |||
| 1a2a1f065f | |||
| 7c677205bb | |||
| 91cc76be8c | |||
| 593c84345e | |||
| 71fd823d84 | |||
| 3723bf9a52 | |||
| 7e3c2c9774 | |||
| 0e0bfd9f98 | |||
| 752c22147c | |||
| 4c07ca9f0a | |||
| 55945c6c0f | |||
| 3f8312e6f3 | |||
| c39b69d08c | |||
| 290ad2edc2 | |||
| 144c08c339 | |||
| b461af8aa1 | |||
| 4bdf67b042 | |||
| 93b109e5b9 | |||
| 0c5ebd5d84 | |||
| 79e6250377 | |||
| a5ec712b88 | |||
| cc9650b077 | |||
| 1a37a6c1fe | |||
| 4572287870 | |||
| 67617d7fcc | |||
| 22811f29f6 | |||
| 71da620099 | |||
| de7c848aa6 | |||
| 8d4303a624 | |||
| 4c7880a4e5 | |||
| 0a307b87ae | |||
| 48eced80fb | |||
| aafac732c6 | |||
| 12d48bb920 | |||
| 0600cea87b | |||
| 145141e1d8 | |||
| 948e7a4d91 | |||
| 39ff811d1a | |||
| cfd03a761b | |||
| e7b37312a7 | |||
| 4ad47b4fa3 | |||
| ffbb86df57 | |||
| 7a57247a9c | |||
| 4ea6ce3477 | |||
| fad2cd8dcb | |||
| 06042357f8 | |||
| 5bdf840098 | |||
| 9711d1d161 | |||
| 2d802483e5 | |||
| b3cd2ace34 | |||
| 2cd91ceee7 | |||
| 84106a059e | |||
| c1c5a61952 | |||
| e7c4eb0842 | |||
| 2f60dec90d | |||
| 59633017b0 | |||
| 961f23b5ea | |||
| 6fa59621bf | |||
| c2284298ce | |||
| 7498b501eb | |||
| 652c58d1fb | |||
| e7a5caa0d6 | |||
| d2d413afcd | |||
| 3c3df0d3a5 | |||
| c59bcca053 | |||
| 18973e061a | |||
| 167053578d | |||
| 2633b63a04 | |||
| 5d4902a73e | |||
| b55b9640c6 | |||
| b1eb4d2497 | |||
| 6c580fec01 | |||
| bbd8d1cf63 | |||
| d01c6ee0d0 | |||
| d48b93e8fc | |||
| 7b663c5476 | |||
| 73257e749f | |||
| d66faa9533 | |||
| 58fd0283ea | |||
| 3feac952da | |||
| 6a2ed9815a | |||
| c1c01196a4 | |||
| 930bb280fe | |||
| e36d020f6b | |||
| c13240819a | |||
| c2c2a71c4b | |||
| 21f6164e07 | |||
| 420fa9173d | |||
| da4d48c493 | |||
| 404abe8695 | |||
| 28dd24510d | |||
| f72bb7e996 | |||
| 9c0a0e9bf3 | |||
| 78b96177bd | |||
| 70b0754a19 | |||
| f034012101 | |||
| 7c4c0284f3 | |||
| 892340fb66 | |||
| f79b957644 | |||
| ef1fb9e9aa | |||
| d712b5487c | |||
| 892a8d2d23 | |||
| daeb95f3c3 | |||
| 36d44d1781 | |||
| 949fb7124b | |||
| 6c4f071d2b | |||
| 53b11f7fbb | |||
| 336bc45637 | |||
| 6c587bb265 | |||
| 6b1f5c12c8 | |||
| f8dc1f060b | |||
| 4cf5f2a95a | |||
| 1cc65f0160 | |||
| 9b00530e8e | |||
| 938376425b | |||
| 0484167a22 | |||
| 84ad1958af | |||
| fa002296ff | |||
| 464ff2c8b2 | |||
| 0804ff8d17 | |||
| 4f7023a16e | |||
| 8f6656c546 | |||
| 741c58d9a3 | |||
| e7b79d0316 | |||
| 0e4cc8e56f | |||
| 16da7a9a76 | |||
| 520e515f2b | |||
| fd6ce7b294 | |||
| 8063059f5f | |||
| 20c5046b20 | |||
| 68126640d8 | |||
| af617e3869 | |||
| 04e5f7beca | |||
| 6ed1efab24 | |||
| cffa002364 | |||
| 951a7095b2 | |||
| ee31f33038 | |||
| 0fdbfae198 | |||
| 315f918d88 | |||
| 96a8491a4c | |||
| 0a311766f2 | |||
| d28c249f8d | |||
| ab6cac8799 | |||
| 7127b9953f | |||
| 49c9af3c76 | |||
| 823151f42e | |||
| 2bbd56e1cd | |||
| 8a36a120a7 | |||
| 9df8cc937d | |||
| 325dcdf654 | |||
| 945eb1eb4e | |||
| 088343d003 | |||
| 94d553985f | |||
| 982dbd18ca | |||
| 054fa38e3a | |||
| 3836534205 | |||
| f852a18b12 | |||
| 0450c5cc52 | |||
| f48fd0be60 | |||
| ee29194bc9 | |||
| fc32fa2852 | |||
| b7d6548741 | |||
| a9ffd49815 | |||
| 538f861414 | |||
| b784651f7f | |||
| 6f0baf5fca | |||
| 664254ed95 | |||
| b976494ca2 | |||
| 061f667e00 | |||
| 3319c35798 | |||
| 52c9293c37 | |||
| af6863a764 | |||
| 77181f7c6f | |||
| 61f3beac01 | |||
| 49aaf24363 | |||
| 4edd31ee28 | |||
| 9ae2a0fc6f | |||
| 8386644dfd | |||
| 1dfdad5fad | |||
| 72cb62a101 | |||
| 21fc55320b | |||
| ad3e288203 | |||
| 0a97357a9c | |||
| 70f01bf40a | |||
| 37b1057d2a | |||
| ad95140416 | |||
| 73fb56f73d | |||
| 9cc5300aa8 | |||
| be466abc0c | |||
| 8956945e9d | |||
| a9f3e1b00d | |||
| a5e3f949c2 | |||
| acc505aa01 | |||
| 69fccb496a | |||
| 6bdbb6ea6c | |||
| 0b8a3aa646 | |||
| ed270bb926 | |||
| 56e5298cce | |||
| 2bcfd3d7ee | |||
| 75235d6013 | |||
| 19631838bb | |||
| 3cab343416 | |||
| 1a253af1c0 | |||
| b81fe6dfa0 | |||
| 2a5b12343c | |||
| 66d306708d | |||
| e7598a5467 | |||
| fb6aa954b6 | |||
| 02ad283a01 | |||
| 13ba9ef2e6 | |||
| 4767e88d2b | |||
| e38d0a8bdc | |||
| 1dca929983 | |||
| 6a2bd4d274 | |||
| c91001d7e1 | |||
| 83748a27da | |||
| 3ddeeab67f | |||
| a2c78ee1ef | |||
| 1e19061ee0 | |||
| 2e078498b1 | |||
| 7291c930c4 | |||
| b8191c134a | |||
| 09061d9e4f | |||
| 2d3cb85fb2 | |||
| 3ad07d05dd | |||
| 50c1215676 | |||
| bf5879d408 | |||
| 442e82e994 | |||
| 9e96999f02 | |||
| e983693534 | |||
| b8a14a2229 | |||
| 508f83dfad | |||
| ce8d5f92b1 | |||
| ca32ff39b8 | |||
| 9da53e51f0 | |||
| 2cbac7c4be | |||
| d2394465d7 | |||
| c2615ac24d | |||
| 62e21f1f94 | |||
| 9bd95a8b17 | |||
| bfd2eed97b | |||
| 8a0b964add | |||
| 59bab51090 | |||
| 12fa550b60 | |||
| 85cdf75a5b | |||
| 0ee2b5b034 | |||
| 0c4d140bb9 | |||
| f515d74a4d | |||
| 79835b3439 | |||
| d518b76878 | |||
| 5179db40db | |||
| 9cbeecae64 | |||
| c5af81c94f | |||
| ae966710a9 | |||
| 9b14135dd3 | |||
| 6f92cd8157 | |||
| 6545f8165f | |||
| 4a742442fb | |||
| f84144fcaa | |||
| e09f373f84 | |||
| e50be7db07 | |||
| f03ab4ef55 | |||
| 3d26c6c145 | |||
| a4b0c84f79 | |||
| e67e7c4246 | |||
| e0c4cc5d77 | |||
| b43ffad708 | |||
| cab7a59d2b | |||
| ca5bbb783a | |||
| d47e2bb8f0 | |||
| a1c186aa95 | |||
| 57440f845f | |||
| a57c12591a | |||
| 5b512f83bd | |||
| 4a2fd67e51 | |||
| da3a0049a0 | |||
| bb53ba6275 | |||
| 7a6c7c6efa | |||
| 266d2dac8d | |||
| d64c6e112e | |||
| 167517b95d | |||
| 33aa778713 | |||
| 251c9aaae6 | |||
| ded48acd31 | |||
| 0dacd84cc2 | |||
| c0782dc69e | |||
| 4a8f9f7f7e | |||
| db9f2cad43 | |||
| d52860c86d | |||
| 4992f700c6 | |||
| cc2777210b | |||
| ad1320319b | |||
| 9d9f1e1eaa | |||
| 7b3b624403 | |||
| 5c908bf8d2 | |||
| fe16f4db54 | |||
| 7b10296058 | |||
| e6890ab24d | |||
| cf49866a87 | |||
| d8e7d0b465 | |||
| c336c5d2a2 | |||
| 45c0622459 | |||
| 7dfbe9dd62 | |||
| 3d5da56d83 | |||
| 2b1dbfef39 | |||
| 2e016080fd | |||
| e0a966b4a6 | |||
| 07a8b6e704 | |||
| 659da9a770 | |||
| 18f8b73982 | |||
| 2bc0988e8d | |||
| 74235ed8bb | |||
| 89a9af7c25 | |||
| d247a07643 | |||
| 794d4184d2 | |||
| cc17b28eab | |||
| 5626f6fd6f | |||
| 79a466d16c | |||
| 6aa06b4c9d | |||
| c993872ab5 | |||
| e69819a49b | |||
| 49868d2bb5 |
+1
-2
@@ -1,8 +1,7 @@
|
||||
*
|
||||
|
||||
!pyproject.toml
|
||||
!poetry.lock
|
||||
!README.md
|
||||
!uv.lock
|
||||
|
||||
!nextcloud_mcp_server/
|
||||
!nextcloud_mcp_server/**/*.py
|
||||
|
||||
@@ -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
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
packages: write
|
||||
steps:
|
||||
- name: Check out
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: "${{ secrets.PERSONAL_ACCESS_TOKEN }}"
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
github_token: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
|
||||
changelog_increment_filename: body.md
|
||||
- name: Release
|
||||
uses: softprops/action-gh-release@da05d552573ad5aba039eaac05058a918a7bf631 # v2
|
||||
uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2
|
||||
with:
|
||||
body_path: "body.md"
|
||||
tag_name: v${{ env.REVISION }}
|
||||
|
||||
@@ -12,11 +12,11 @@ jobs:
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5
|
||||
uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5
|
||||
with:
|
||||
# list of Docker images to use as base name for tags
|
||||
images: |
|
||||
@@ -33,11 +33,11 @@ jobs:
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
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: Install Helm
|
||||
uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # v4.3.1
|
||||
with:
|
||||
version: v3.16.0
|
||||
|
||||
- name: Add Helm repositories and update dependencies
|
||||
run: |
|
||||
helm repo add qdrant https://qdrant.github.io/qdrant-helm
|
||||
helm repo add ollama https://otwld.github.io/ollama-helm
|
||||
helm repo update
|
||||
helm dependency build charts/nextcloud-mcp-server
|
||||
|
||||
- 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@5a7eac68fb9809dea845d802897dc5c723910fa3 # v7.1.3
|
||||
- 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
|
||||
@@ -9,29 +9,58 @@ jobs:
|
||||
linting:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- name: Install the latest version of uv
|
||||
uses: astral-sh/setup-uv@f0ec1fc3b38f5e7cd731bb6ce540c5af426746bb # v6
|
||||
uses: astral-sh/setup-uv@5a7eac68fb9809dea845d802897dc5c723910fa3 # v7.1.3
|
||||
- name: Check format
|
||||
run: |
|
||||
uv run --frozen ruff format --diff
|
||||
- name: Linting
|
||||
run: |
|
||||
uv run --frozen ruff check
|
||||
- name: Linting
|
||||
run: |
|
||||
uv run --frozen ty check -- nextcloud_mcp_server
|
||||
|
||||
|
||||
integration-test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- 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
|
||||
uses: hoverkraft-tech/compose-action@8be2d741e891ac9b8ac20825e6f3904149599925 # v2.2.0
|
||||
uses: hoverkraft-tech/compose-action@3846bcd61da338e9eaaf83e7ed0234a12b099b72 # v2.4.1
|
||||
with:
|
||||
compose-file: "./docker-compose.yml"
|
||||
#compose-flags: "--profile qdrant"
|
||||
up-flags: "--build"
|
||||
|
||||
- name: Install the latest version of uv
|
||||
uses: astral-sh/setup-uv@f0ec1fc3b38f5e7cd731bb6ce540c5af426746bb # v6
|
||||
uses: astral-sh/setup-uv@5a7eac68fb9809dea845d802897dc5c723910fa3 # v7.1.3
|
||||
|
||||
- name: Install Playwright dependencies
|
||||
run: |
|
||||
uv run playwright install chromium --with-deps
|
||||
|
||||
- name: Wait for service to be ready
|
||||
run: |
|
||||
@@ -56,4 +85,4 @@ jobs:
|
||||
NEXTCLOUD_USERNAME: "admin"
|
||||
NEXTCLOUD_PASSWORD: "admin"
|
||||
run: |
|
||||
uv run --frozen python -m pytest
|
||||
uv run pytest -v --log-cli-level=WARN -m smoke
|
||||
|
||||
+16
@@ -1,2 +1,18 @@
|
||||
__pycache__/
|
||||
.coverage
|
||||
.env
|
||||
*.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Git
|
||||
worktrees/
|
||||
|
||||
docker-compose.override.yml
|
||||
|
||||
# Generated by pytest used to login users
|
||||
.nextcloud_oauth_*.json
|
||||
.playwright-mcp/
|
||||
|
||||
# RAG Evaluation
|
||||
tests/rag_evaluation/fixtures/
|
||||
|
||||
@@ -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
|
||||
+21
-3
@@ -1,8 +1,26 @@
|
||||
repos:
|
||||
- hooks:
|
||||
- repo: https://github.com/commitizen-tools/commitizen
|
||||
rev: v4.9.0
|
||||
hooks:
|
||||
- id: commitizen
|
||||
- id: commitizen-branch
|
||||
stages:
|
||||
- pre-push
|
||||
repo: https://github.com/commitizen-tools/commitizen
|
||||
rev: v4.8.2
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: ruff-check
|
||||
name: ruff-check
|
||||
entry: uv run ruff check
|
||||
language: system
|
||||
types: [python]
|
||||
- id: ruff-format
|
||||
name: ruff-format
|
||||
entry: uv run ruff format
|
||||
language: system
|
||||
types: [python]
|
||||
- id: ty-check
|
||||
name: ty-check
|
||||
language: system
|
||||
types: [python]
|
||||
exclude: tests/.*
|
||||
entry: uv run ty check
|
||||
|
||||
+636
@@ -1,3 +1,639 @@
|
||||
## v0.35.0 (2025-11-15)
|
||||
|
||||
### Feat
|
||||
|
||||
- Enable SSE transport for mcp service and update test fixtures
|
||||
|
||||
## v0.34.2 (2025-11-13)
|
||||
|
||||
### Fix
|
||||
|
||||
- Use NEXTCLOUD_OIDC_CLIENT_ID/SECRET env vars consistently
|
||||
|
||||
## v0.34.1 (2025-11-13)
|
||||
|
||||
### Fix
|
||||
|
||||
- return all notes when search query is empty
|
||||
|
||||
## v0.34.0 (2025-11-13)
|
||||
|
||||
### Feat
|
||||
|
||||
- Complete Phase 5 - Instrument all 93 MCP tools
|
||||
- Add instrumentation decorator and apply to notes tools (Phase 5)
|
||||
- Add OAuth token and database metrics (Phases 3-4)
|
||||
- Add metrics instrumentation for queue, health, and database operations
|
||||
|
||||
## v0.33.1 (2025-11-13)
|
||||
|
||||
### Fix
|
||||
|
||||
- Move grafana_folder from labels to annotations
|
||||
|
||||
## v0.33.0 (2025-11-13)
|
||||
|
||||
### Feat
|
||||
|
||||
- Add Grafana dashboard and vector sync metric instrumentation
|
||||
|
||||
## v0.32.1 (2025-11-12)
|
||||
|
||||
### Fix
|
||||
|
||||
- add dynamic dimension detection for Ollama embedding models
|
||||
|
||||
## v0.32.0 (2025-11-11)
|
||||
|
||||
### Feat
|
||||
|
||||
- **ollama**: Pull model on startup if not available in ollama
|
||||
- add dynamic vector sync status updates with htmx polling
|
||||
- add webhook management UI and BeforeNodeDeletedEvent support
|
||||
- validate Nextcloud webhook schemas and document findings
|
||||
|
||||
### Fix
|
||||
|
||||
- improve webapp tab UI with CSS Grid and viewport-filling container
|
||||
|
||||
### Refactor
|
||||
|
||||
- move webapp from /user/page to /app
|
||||
- consolidate database storage for webhooks and OAuth tokens
|
||||
|
||||
## v0.31.1 (2025-11-10)
|
||||
|
||||
### Refactor
|
||||
|
||||
- simplify OpenTelemetry tracing configuration
|
||||
|
||||
## v0.31.0 (2025-11-10)
|
||||
|
||||
### Feat
|
||||
|
||||
- skip tracing for health and metrics endpoints
|
||||
|
||||
### Fix
|
||||
|
||||
- add retry logic for ETag conflicts in category change test
|
||||
- optimize Notes API pagination with pruneBefore parameter
|
||||
|
||||
## v0.30.0 (2025-11-10)
|
||||
|
||||
### Feat
|
||||
|
||||
- **helm**: Add document chunking configuration
|
||||
- **vector**: Add configurable chunk size and overlap for document embedding
|
||||
- **vector**: Support multiple embedding models with auto-generated collection names
|
||||
|
||||
### Fix
|
||||
|
||||
- Support in-memory Qdrant for CI testing
|
||||
|
||||
## v0.29.2 (2025-11-09)
|
||||
|
||||
### Fix
|
||||
|
||||
- **helm**: Set default strategy to Recreate
|
||||
|
||||
## v0.29.1 (2025-11-09)
|
||||
|
||||
### Fix
|
||||
|
||||
- **observability**: isolate metrics endpoint to dedicated port
|
||||
|
||||
## v0.29.0 (2025-11-09)
|
||||
|
||||
### Feat
|
||||
|
||||
- **helm**: Add observability support with ServiceMonitor and Grafana dashboard
|
||||
|
||||
### Fix
|
||||
|
||||
- **readiness**: Only check external Qdrant in network mode
|
||||
|
||||
## v0.28.0 (2025-11-09)
|
||||
|
||||
### Feat
|
||||
|
||||
- **observability**: Add comprehensive monitoring with Prometheus and OpenTelemetry
|
||||
|
||||
### Fix
|
||||
|
||||
- **vector**: Handle missing 'modified' field in notes gracefully
|
||||
|
||||
## v0.27.3 (2025-11-09)
|
||||
|
||||
### Fix
|
||||
|
||||
- **ci**: Use helm dependency build instead of update to use Chart.lock
|
||||
|
||||
## v0.27.2 (2025-11-09)
|
||||
|
||||
### Fix
|
||||
|
||||
- **helm**: update Qdrant dependency condition to match new mode structure
|
||||
|
||||
## v0.27.1 (2025-11-09)
|
||||
|
||||
### Fix
|
||||
|
||||
- **ci**: add Helm repository setup to chart release workflow
|
||||
|
||||
## v0.27.0 (2025-11-09)
|
||||
|
||||
### Feat
|
||||
|
||||
- **helm**: add Qdrant local mode support with three deployment options [skip ci]
|
||||
- add Qdrant local mode support with in-memory and persistent storage
|
||||
- implement ADR-009 - refactor semantic search to use generic semantic:read scope
|
||||
- implement MCP sampling for semantic search RAG (ADR-008)
|
||||
- add optional vector database and semantic search to helm chart
|
||||
- add vector sync processing status to /app endpoint
|
||||
- implement semantic search tool and fix vector sync issues (ADR-007 Phase 3)
|
||||
- implement vector sync scanner and processor (ADR-007 Phase 2)
|
||||
|
||||
### Fix
|
||||
|
||||
- implement deletion grace period and vector sync status tool
|
||||
- remove unnecessary urllib3<2.0 constraint
|
||||
- integrate vector sync tasks with Starlette lifespan for streamable-http
|
||||
|
||||
### Refactor
|
||||
|
||||
- migrate vector sync from asyncio.Queue to anyio memory object streams
|
||||
- update to Qdrant query_points API and fix Playwright Keycloak login
|
||||
|
||||
## v0.26.1 (2025-11-08)
|
||||
|
||||
### Fix
|
||||
|
||||
- **deps**: update dependency mcp to >=1.21,<1.22
|
||||
|
||||
## v0.26.0 (2025-11-08)
|
||||
|
||||
### Feat
|
||||
|
||||
- add real elicitation integration test with python-sdk MCP client
|
||||
- unify session architecture and enhance login status visibility
|
||||
|
||||
### Fix
|
||||
|
||||
- Consolidate OAuth callbacks and implement PKCE for all flows
|
||||
|
||||
## v0.25.0 (2025-11-05)
|
||||
|
||||
### BREAKING CHANGE
|
||||
|
||||
- All OAuth deployments must be reconfigured to specify
|
||||
resource URIs (NEXTCLOUD_MCP_SERVER_URL and NEXTCLOUD_RESOURCE_URI) and
|
||||
choose between multi-audience or token exchange mode.
|
||||
|
||||
### Feat
|
||||
|
||||
- Implement ADR-005 unified token verifier to eliminate token passthrough vulnerability
|
||||
|
||||
### Fix
|
||||
|
||||
- Implement proper OAuth resource parameters and PRM-based discovery
|
||||
- Simplify token verifier to be RFC 7519 compliant
|
||||
- Use Keycloak client ID for NEXTCLOUD_RESOURCE_URI in token exchange
|
||||
- Correct OAuth token audience validation for multi-audience mode
|
||||
|
||||
### Refactor
|
||||
|
||||
- Eliminate duplicate validation logic in UnifiedTokenVerifier
|
||||
|
||||
## v0.24.1 (2025-11-04)
|
||||
|
||||
### Fix
|
||||
|
||||
- **deps**: update dependency mcp to >=1.20,<1.21
|
||||
|
||||
## v0.24.0 (2025-11-04)
|
||||
|
||||
### Feat
|
||||
|
||||
- add scope protection to OAuth provisioning tools
|
||||
- enable authorization services for token exchange in Keycloak
|
||||
- implement scope-based audience mapping and RFC 9728 support
|
||||
- integrate token exchange into MCP server application
|
||||
- implement RFC 8693 Standard Token Exchange for Keycloak
|
||||
- Add userinfo route/page
|
||||
- add browser-based user info page with separate OAuth flow
|
||||
- Implement ADR-004 Progressive Consent foundation (partial)
|
||||
- Complete ADR-004 Progressive Consent OAuth flows implementation
|
||||
- Implement ADR-004 Progressive Consent foundation components
|
||||
- Implement ADR-004 Hybrid Flow with comprehensive integration tests
|
||||
|
||||
### Fix
|
||||
|
||||
- add missing await for get_nextcloud_client in capabilities resource
|
||||
- use valid Fernet encryption keys in token exchange tests
|
||||
- accept resource URL in token audience for Nextcloud JWT tokens
|
||||
- remove token-exchange-nextcloud scope and accept tokens without audience
|
||||
- move audience mapper from scope to nextcloud-mcp-server client
|
||||
- move token-exchange-nextcloud from default to optional scopes
|
||||
- restructure routes to prevent SessionAuthBackend from interfering with FastMCP OAuth
|
||||
- allow OAuth Bearer tokens on /mcp endpoint by excluding from session auth
|
||||
- correct OAuth token audience validation using RFC 8707 resource parameter
|
||||
- remove remaining references to deleted oauth_callback and oauth_token
|
||||
- remove Hybrid Flow, make Progressive Consent default (ADR-004)
|
||||
- browser OAuth userinfo endpoint and refresh token rotation
|
||||
- make ENABLE_PROGRESSIVE_CONSENT consistently opt-in (default false)
|
||||
- make provisioning checks opt-in (default false)
|
||||
- Disable Progressive Consent for mcp-oauth to enable Hybrid Flow tests
|
||||
|
||||
### Refactor
|
||||
|
||||
- integrate token exchange into unified get_client() pattern
|
||||
|
||||
## 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)
|
||||
|
||||
### Feat
|
||||
|
||||
- **server**: Experimental support for OAuth2/OIDC authentication
|
||||
|
||||
## v0.12.6 (2025-10-11)
|
||||
|
||||
### Fix
|
||||
|
||||
- **deps**: update dependency mcp to >=1.17,<1.18
|
||||
|
||||
## v0.12.5 (2025-10-03)
|
||||
|
||||
### Fix
|
||||
|
||||
- **deps**: update dependency mcp to >=1.16,<1.17
|
||||
|
||||
## v0.12.4 (2025-09-25)
|
||||
|
||||
### Fix
|
||||
|
||||
- **deps**: update dependency mcp to >=1.15,<1.16
|
||||
|
||||
## v0.12.3 (2025-09-23)
|
||||
|
||||
### Refactor
|
||||
|
||||
- Add tools for all resources to enable tool-only workflows
|
||||
|
||||
## v0.12.2 (2025-09-20)
|
||||
|
||||
### Refactor
|
||||
|
||||
- Add `http` to --transport option
|
||||
|
||||
## v0.12.1 (2025-09-11)
|
||||
|
||||
### Fix
|
||||
|
||||
- **docker**: Provide --host 0.0.0.0 in default docker image
|
||||
|
||||
## v0.12.0 (2025-09-11)
|
||||
|
||||
### Feat
|
||||
|
||||
- **server**: Add support for `streamable-http` transport type
|
||||
|
||||
## v0.11.1 (2025-09-11)
|
||||
|
||||
### Fix
|
||||
|
||||
- **deps**: update dependency mcp to >=1.13,<1.14
|
||||
|
||||
## v0.11.0 (2025-09-11)
|
||||
|
||||
### Feat
|
||||
|
||||
- **deck**: Add support for stack, cards, labels
|
||||
- **deck**: Initialize Deck app client/server
|
||||
|
||||
## v0.10.0 (2025-09-10)
|
||||
|
||||
### Feat
|
||||
|
||||
- Add WebDAV resource copy functionality
|
||||
- Add WebDAV resource move/rename functionality
|
||||
|
||||
## v0.9.0 (2025-09-10)
|
||||
|
||||
### BREAKING CHANGE
|
||||
|
||||
- FASTMCP_-prefixed env vars have been replaced by CLI
|
||||
arguments. Refer to the README for updated usage.
|
||||
|
||||
### Feat
|
||||
|
||||
- **cli**: Replace `mcp run` with click CLI and runtime options
|
||||
|
||||
## v0.8.3 (2025-08-31)
|
||||
|
||||
### Fix
|
||||
|
||||
- **server**: Replace ErrorResponses with standard McpErrors
|
||||
- **notes**: Include ETags in responses to avoid accidently updates
|
||||
|
||||
## v0.8.2 (2025-08-31)
|
||||
|
||||
### Fix
|
||||
|
||||
- **notes**: Remove note contents from responses to reduce token usage
|
||||
|
||||
## v0.8.1 (2025-08-30)
|
||||
|
||||
### Fix
|
||||
|
||||
- **model**: Serialize timestamps in RFC3339 format
|
||||
|
||||
## v0.8.0 (2025-08-30)
|
||||
|
||||
### Feat
|
||||
|
||||
- **client**: Preserve fields when modifying contacts/calendar resources
|
||||
- **server**: Add structured output to all tool/resource output
|
||||
|
||||
### Refactor
|
||||
|
||||
- Use _make_request where available
|
||||
|
||||
## v0.7.2 (2025-08-30)
|
||||
|
||||
### Fix
|
||||
|
||||
- **client**: Use paging to fetch all notes
|
||||
|
||||
## v0.7.1 (2025-08-08)
|
||||
|
||||
### Fix
|
||||
|
||||
- **client**: Strip cookies from responses to avoid falsely raising CSRF errors
|
||||
|
||||
## v0.7.0 (2025-08-03)
|
||||
|
||||
### Feat
|
||||
|
||||
- **contacts**: Initialize Contacts App
|
||||
|
||||
## v0.6.1 (2025-08-01)
|
||||
|
||||
### Fix
|
||||
|
||||
- **calendar**: Fix iCalendar date vs datetime format
|
||||
- **calendar**: Remove try/except in calendar API
|
||||
|
||||
## v0.6.0 (2025-07-29)
|
||||
|
||||
### Feat
|
||||
|
||||
- **calendar**: add comprehensive Calendar app support via CalDAV protocol
|
||||
|
||||
### Fix
|
||||
|
||||
- apply ruff formatting to pass CI checks
|
||||
- **calendar**: address PR feedback from maintainer
|
||||
|
||||
### Refactor
|
||||
|
||||
- **calendar**: optimize logging for production readiness
|
||||
|
||||
## v0.5.0 (2025-07-26)
|
||||
|
||||
### Feat
|
||||
|
||||
- Update webdav client create_directory method to handle recursive directories
|
||||
- **webdav**: add complete file system support
|
||||
|
||||
### Fix
|
||||
|
||||
- apply ruff formatting to test_webdav_operations.py
|
||||
|
||||
## v0.4.1 (2025-07-10)
|
||||
|
||||
### Fix
|
||||
|
||||
- **deps**: update dependency mcp to >=1.10,<1.11
|
||||
|
||||
## v0.4.0 (2025-07-06)
|
||||
|
||||
### Feat
|
||||
|
||||
- Add TablesClient and associated tools
|
||||
|
||||
### Fix
|
||||
|
||||
- update tests
|
||||
|
||||
### Refactor
|
||||
|
||||
- Modularize NC and Notes app client
|
||||
|
||||
## v0.3.0 (2025-06-06)
|
||||
|
||||
### Feat
|
||||
|
||||
@@ -0,0 +1,399 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Coding Conventions
|
||||
|
||||
### async/await Patterns
|
||||
- **Use anyio for all async operations** - Provides structured concurrency
|
||||
- pytest runs in `anyio` mode (`anyio_mode = "auto"` in pyproject.toml)
|
||||
- Use `anyio.create_task_group()` for concurrent execution (NOT `asyncio.gather()`)
|
||||
- Use `anyio.Lock()` for synchronization primitives (NOT `asyncio.Lock()`)
|
||||
- Use `anyio.run()` for entry points (NOT `asyncio.run()`)
|
||||
- Prefer standard async/await syntax without explicit library imports when possible
|
||||
- Examples: app.py, search/hybrid.py, search/verification.py, auth/token_broker.py
|
||||
|
||||
### Type Hints
|
||||
- **Use Python 3.10+ union syntax**: `str | None` instead of `Optional[str]`
|
||||
- **Use lowercase generics**: `dict[str, Any]` instead of `Dict[str, Any]`
|
||||
- **Type all function signatures** - Parameters and return types
|
||||
- **No explicit type checker configured** - Ruff handles linting only
|
||||
|
||||
### Code Quality
|
||||
- **Run ruff before committing**:
|
||||
```bash
|
||||
uv run ruff check
|
||||
uv run ruff format
|
||||
```
|
||||
- **Ruff configuration** in pyproject.toml (extends select: ["I"] for import sorting)
|
||||
|
||||
### Error Handling
|
||||
- **Use custom decorators**: `@retry_on_429` for rate limiting (see base_client.py)
|
||||
- **Standard exceptions**: `HTTPStatusError` from httpx, `McpError` for MCP-specific errors
|
||||
- **Logging patterns**:
|
||||
- `logger.debug()` for expected 404s and normal operations
|
||||
- `logger.warning()` for retries and non-critical issues
|
||||
- `logger.error()` for actual errors
|
||||
|
||||
### Testing Patterns
|
||||
- **Use existing fixtures** from `tests/conftest.py` (2888 lines of test infrastructure)
|
||||
- **Session-scoped fixtures** handle anyio/pytest-asyncio incompatibility
|
||||
- **Mocked unit tests** use `mocker.AsyncMock(spec=httpx.AsyncClient)`
|
||||
- **pytest-timeout**: 180s default per test
|
||||
- **Mark tests appropriately**: `@pytest.mark.unit`, `@pytest.mark.integration`, `@pytest.mark.oauth`, `@pytest.mark.smoke`
|
||||
|
||||
### Architectural Patterns
|
||||
- **Base classes**: `BaseNextcloudClient` for all API clients
|
||||
- **Pydantic responses**: All MCP tools return Pydantic models inheriting from `BaseResponse`
|
||||
- **Decorators**: `@require_scopes`, `@require_provisioning` for access control
|
||||
- **Context pattern**: `await get_client(ctx)` to access authenticated NextcloudClient (async!)
|
||||
- **FastMCP decorators**: `@mcp.tool()`, `@mcp.resource()`
|
||||
- **Token acquisition**: `get_client()` handles both pass-through and token exchange modes
|
||||
- Pass-through (default): Simple, stateless (ENABLE_TOKEN_EXCHANGE=false)
|
||||
- Token exchange (opt-in): RFC 8693 delegation (ENABLE_TOKEN_EXCHANGE=true)
|
||||
|
||||
### Project Structure
|
||||
- `nextcloud_mcp_server/client/` - HTTP clients for Nextcloud APIs
|
||||
- `nextcloud_mcp_server/server/` - MCP tool/resource definitions
|
||||
- `nextcloud_mcp_server/auth/` - OAuth/OIDC authentication
|
||||
- `nextcloud_mcp_server/models/` - Pydantic response models
|
||||
- `tests/` - Layered test suite (unit, smoke, integration, load)
|
||||
|
||||
## Development Commands (Quick Reference)
|
||||
|
||||
### Testing
|
||||
```bash
|
||||
# Fast feedback (recommended)
|
||||
uv run pytest tests/unit/ -v # Unit tests (~5s)
|
||||
uv run pytest -m smoke -v # Smoke tests (~30-60s)
|
||||
|
||||
# Integration tests
|
||||
uv run pytest -m "integration and not oauth" -v # Without OAuth (~2-3min)
|
||||
uv run pytest -m oauth -v # OAuth only (~3min)
|
||||
uv run pytest # Full suite (~4-5min)
|
||||
|
||||
# Coverage
|
||||
uv run pytest --cov
|
||||
|
||||
# Specific tests after changes
|
||||
uv run pytest tests/server/test_mcp.py -k "notes" -v
|
||||
uv run pytest tests/client/notes/test_notes_api.py -v
|
||||
```
|
||||
|
||||
**Important**: After code changes, rebuild the correct container:
|
||||
- Single-user tests: `docker-compose up --build -d mcp`
|
||||
- OAuth tests: `docker-compose up --build -d mcp-oauth`
|
||||
- Keycloak tests: `docker-compose up --build -d mcp-keycloak`
|
||||
|
||||
### Running the Server
|
||||
```bash
|
||||
# Local development
|
||||
export $(grep -v '^#' .env | xargs)
|
||||
mcp run --transport sse nextcloud_mcp_server.app:mcp
|
||||
|
||||
# Docker development (rebuilds after code changes)
|
||||
docker-compose up --build -d mcp # Single-user (port 8000)
|
||||
docker-compose up --build -d mcp-oauth # Nextcloud OAuth (port 8001)
|
||||
docker-compose up --build -d mcp-keycloak # Keycloak OAuth (port 8002)
|
||||
```
|
||||
|
||||
### Environment Setup
|
||||
```bash
|
||||
uv sync # Install dependencies
|
||||
uv sync --group dev # Install with dev dependencies
|
||||
```
|
||||
|
||||
### Load Testing
|
||||
```bash
|
||||
# Quick test (default: 10 workers, 30 seconds)
|
||||
uv run python -m tests.load.benchmark
|
||||
|
||||
# Custom concurrency and duration
|
||||
uv run python -m tests.load.benchmark -c 20 -d 60
|
||||
|
||||
# Export results for analysis
|
||||
uv run python -m tests.load.benchmark --output results.json --verbose
|
||||
```
|
||||
|
||||
**Expected Performance**: 50-200 RPS for mixed workload, p50 <100ms, p95 <500ms, p99 <1000ms.
|
||||
|
||||
## Database Inspection
|
||||
|
||||
**Credentials**: root/password, nextcloud/password, database: `nextcloud`
|
||||
|
||||
```bash
|
||||
# Connect to database
|
||||
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)
|
||||
- `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
|
||||
- `oc_oidc_redirect_uris` - Redirect URIs
|
||||
|
||||
## Architecture Quick Reference
|
||||
|
||||
**For detailed architecture, see:**
|
||||
- `docs/comparison-context-agent.md` - Overall architecture
|
||||
- `docs/oauth-architecture.md` - OAuth integration patterns
|
||||
- `docs/ADR-004-progressive-consent.md` - Progressive consent implementation
|
||||
|
||||
**Core Components**:
|
||||
- `nextcloud_mcp_server/app.py` - FastMCP server entry point
|
||||
- `nextcloud_mcp_server/client/` - HTTP clients (Notes, Calendar, Contacts, Tables, WebDAV)
|
||||
- `nextcloud_mcp_server/server/` - MCP tool/resource definitions
|
||||
- `nextcloud_mcp_server/auth/` - OAuth/OIDC authentication
|
||||
|
||||
**Supported Apps**: Notes, Calendar (CalDAV + VTODO tasks), Contacts (CardDAV), Tables, WebDAV, Deck, Cookbook
|
||||
|
||||
**Key Patterns**:
|
||||
1. `NextcloudClient` orchestrates all app-specific clients
|
||||
2. `BaseNextcloudClient` provides common HTTP functionality + retry logic
|
||||
3. MCP tools use context pattern: `get_client(ctx)` → `NextcloudClient`
|
||||
4. All operations are async using httpx
|
||||
|
||||
### Progressive Consent Architecture (ADR-004)
|
||||
|
||||
**Important**: Progressive consent is a *mechanism* for granting access, not a feature flag. The architecture is always present in OAuth mode. Whether provisioning tools are available is controlled by `ENABLE_OFFLINE_ACCESS`.
|
||||
|
||||
**What is Progressive Consent?**
|
||||
- Dual OAuth flow architecture that separates client authentication (Flow 1) from resource provisioning (Flow 2)
|
||||
- Flow 1: MCP client authenticates directly to IdP with resource scopes (notes:*, calendar:*, etc.)
|
||||
- Token audience: "mcp-server"
|
||||
- Client receives resource-scoped token for MCP session
|
||||
- Flow 2: Server explicitly provisions Nextcloud access via separate login (only when `ENABLE_OFFLINE_ACCESS=true`)
|
||||
- Server requests: openid, profile, email, offline_access
|
||||
- Token audience: "nextcloud"
|
||||
- Server receives refresh token for offline access
|
||||
- Client never sees this token
|
||||
- Provides clear separation between session tokens and offline access tokens
|
||||
|
||||
**Modes:**
|
||||
- **Pass-through mode** (`ENABLE_OFFLINE_ACCESS=false`, default):
|
||||
- No Flow 2 provisioning
|
||||
- Server uses client's token to access Nextcloud (pass-through)
|
||||
- No provisioning tools available
|
||||
- Suitable for stateless, client-driven operations
|
||||
- **Offline access mode** (`ENABLE_OFFLINE_ACCESS=true`):
|
||||
- Flow 2 provisioning available
|
||||
- Server stores refresh tokens for background operations
|
||||
- Provisioning tools available: `provision_nextcloud_access`, `check_logged_in`
|
||||
- Suitable for background jobs and server-initiated operations
|
||||
|
||||
**When to use OAuth mode:**
|
||||
- Multi-user deployments
|
||||
- Background jobs requiring offline access (with `ENABLE_OFFLINE_ACCESS=true`)
|
||||
- Enhanced security with separate authorization contexts
|
||||
- Explicit user control over resource access
|
||||
|
||||
**When to use BasicAuth instead:**
|
||||
- Simple single-user deployments
|
||||
- Local development and testing
|
||||
|
||||
**Key features:**
|
||||
- No scope escalation - client gets exactly what it requests
|
||||
- User explicitly authorizes via `provision_nextcloud_access` tool
|
||||
- Clear security boundaries between MCP session and Nextcloud access
|
||||
|
||||
## MCP Response Patterns (CRITICAL)
|
||||
|
||||
**Never return raw `List[Dict]` from MCP tools** - FastMCP mangles them into dicts with numeric string keys.
|
||||
|
||||
**Correct 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**:
|
||||
- `nextcloud_mcp_server/models/notes.py:80` - `SearchNotesResponse`
|
||||
- `nextcloud_mcp_server/models/webdav.py:113` - `SearchFilesResponse`
|
||||
- `nextcloud_mcp_server/server/{notes,webdav}.py` - Tool examples
|
||||
|
||||
**Testing**: Extract `data["results"]` from MCP responses, not `data` directly.
|
||||
|
||||
## MCP Sampling for RAG (ADR-008)
|
||||
|
||||
**What is MCP Sampling?**
|
||||
MCP sampling allows servers to request LLM completions from their clients. This enables Retrieval-Augmented Generation (RAG) patterns where the server retrieves context and the client's LLM generates answers.
|
||||
|
||||
**When to use sampling:**
|
||||
- Generating natural language answers from retrieved documents
|
||||
- Synthesizing information from multiple sources
|
||||
- Creating summaries with citations
|
||||
|
||||
**Implementation Pattern** (see ADR-008 for details):
|
||||
|
||||
```python
|
||||
from mcp.types import ModelHint, ModelPreferences, SamplingMessage, TextContent
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("notes:read")
|
||||
async def nc_notes_semantic_search_answer(
|
||||
query: str, ctx: Context, limit: int = 5, max_answer_tokens: int = 500
|
||||
) -> SamplingSearchResponse:
|
||||
# 1. Retrieve documents
|
||||
search_response = await nc_notes_semantic_search(query, ctx, limit)
|
||||
|
||||
# 2. Check for no results (don't waste sampling call)
|
||||
if not search_response.results:
|
||||
return SamplingSearchResponse(
|
||||
query=query,
|
||||
generated_answer="No relevant documents found.",
|
||||
sources=[], total_found=0, success=True
|
||||
)
|
||||
|
||||
# 3. Construct prompt with retrieved context
|
||||
prompt = f"{query}\n\nDocuments:\n{format_sources(search_response.results)}\n\nProvide answer with citations."
|
||||
|
||||
# 4. Request LLM completion via sampling
|
||||
try:
|
||||
result = await ctx.session.create_message(
|
||||
messages=[SamplingMessage(role="user", content=TextContent(type="text", text=prompt))],
|
||||
max_tokens=max_answer_tokens,
|
||||
temperature=0.7,
|
||||
model_preferences=ModelPreferences(
|
||||
hints=[ModelHint(name="claude-3-5-sonnet")],
|
||||
intelligencePriority=0.8,
|
||||
speedPriority=0.5,
|
||||
),
|
||||
include_context="thisServer",
|
||||
)
|
||||
|
||||
return SamplingSearchResponse(
|
||||
query=query,
|
||||
generated_answer=result.content.text,
|
||||
sources=search_response.results,
|
||||
model_used=result.model,
|
||||
stop_reason=result.stopReason,
|
||||
success=True
|
||||
)
|
||||
except Exception as e:
|
||||
# Fallback: Return documents without generated answer
|
||||
return SamplingSearchResponse(
|
||||
query=query,
|
||||
generated_answer=f"[Sampling unavailable: {e}]\n\nFound {len(search_response.results)} documents.",
|
||||
sources=search_response.results,
|
||||
search_method="semantic_sampling_fallback",
|
||||
success=True
|
||||
)
|
||||
```
|
||||
|
||||
**Key Points**:
|
||||
- **No server-side LLM**: Server has no API keys, client controls which model is used
|
||||
- **Graceful degradation**: Tool always returns useful results even if sampling fails
|
||||
- **User control**: MCP clients SHOULD prompt users to approve sampling requests
|
||||
- **No results optimization**: Skip sampling call when no documents found
|
||||
- **Fixed prompts**: Prompts are not user-configurable to avoid injection risks
|
||||
|
||||
**Reference**: See `nc_notes_semantic_search_answer` in `nextcloud_mcp_server/server/notes.py:517` and ADR-008 for complete implementation.
|
||||
|
||||
## Testing Best Practices (MANDATORY)
|
||||
|
||||
### Always Run Tests
|
||||
- **Run tests to completion** before considering any task complete
|
||||
- **Rebuild the correct container** after code changes (see Development Commands above)
|
||||
- **If tests require modifications**, ask for permission before proceeding
|
||||
|
||||
### Use Existing Fixtures
|
||||
See `tests/conftest.py` for 2888 lines of test infrastructure:
|
||||
- `nc_mcp_client` - MCP client for tool/resource testing (uses `mcp` container)
|
||||
- `nc_mcp_oauth_client` - MCP client for OAuth testing (uses `mcp-oauth` container)
|
||||
- `nc_client` - Direct NextcloudClient for setup/cleanup
|
||||
- `temporary_note`, `temporary_addressbook`, `temporary_contact` - Auto-cleanup
|
||||
|
||||
### Writing Mocked Unit Tests
|
||||
For client-layer response parsing tests, use mocked HTTP responses:
|
||||
|
||||
```python
|
||||
async def test_notes_api_get_note(mocker):
|
||||
"""Test that get_note correctly parses the API response."""
|
||||
mock_response = create_mock_note_response(
|
||||
note_id=123, title="Test Note", content="Test content",
|
||||
category="Test", etag="abc123"
|
||||
)
|
||||
|
||||
mock_make_request = mocker.patch.object(
|
||||
NotesClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = NotesClient(mocker.AsyncMock(spec=httpx.AsyncClient), "testuser")
|
||||
note = await client.get_note(note_id=123)
|
||||
|
||||
assert note["id"] == 123
|
||||
mock_make_request.assert_called_once_with("GET", "/apps/notes/api/v1/notes/123")
|
||||
```
|
||||
|
||||
**Mock helpers in `tests/conftest.py`**: `create_mock_response()`, `create_mock_note_response()`, `create_mock_error_response()`
|
||||
|
||||
**When to use**: Response parsing, error handling, request parameter building
|
||||
**When NOT to use**: CalDAV/CardDAV/WebDAV protocols, OAuth flows, end-to-end MCP testing
|
||||
|
||||
### OAuth Testing
|
||||
OAuth tests use **Playwright browser automation** to complete flows programmatically.
|
||||
|
||||
**Test Environment**:
|
||||
- Three MCP containers: `mcp` (single-user), `mcp-oauth` (Nextcloud OIDC), `mcp-keycloak` (external IdP)
|
||||
- OAuth tests require `NEXTCLOUD_HOST`, `NEXTCLOUD_USERNAME`, `NEXTCLOUD_PASSWORD` environment variables
|
||||
- Playwright configuration: `--browser firefox --headed` for debugging
|
||||
- Install browsers: `uv run playwright install firefox`
|
||||
|
||||
**OAuth fixtures**: `nc_oauth_client`, `nc_mcp_oauth_client`, `alice_oauth_token`, `bob_oauth_token`, etc.
|
||||
|
||||
**Shared OAuth Client**: All test users authenticate using a single OAuth client (created via DCR, deleted at session end via RFC 7592). Matches production behavior.
|
||||
|
||||
**Run OAuth tests**:
|
||||
```bash
|
||||
uv run pytest -m oauth -v # All OAuth tests
|
||||
uv run pytest tests/server/oauth/ --browser firefox -v
|
||||
uv run pytest tests/server/oauth/test_oauth_core.py --browser firefox --headed -v
|
||||
```
|
||||
|
||||
### Keycloak OAuth Testing
|
||||
**Validates ADR-002 architecture** for external identity providers and offline access patterns.
|
||||
|
||||
**Architecture**: `MCP Client → Keycloak (OAuth) → MCP Server → Nextcloud user_oidc (validates token) → APIs`
|
||||
|
||||
**Setup**:
|
||||
```bash
|
||||
docker-compose up -d keycloak app mcp-keycloak
|
||||
curl http://localhost:8888/realms/nextcloud-mcp/.well-known/openid-configuration
|
||||
docker compose exec app php occ user_oidc:provider keycloak
|
||||
```
|
||||
|
||||
**Credentials**: admin/admin (Keycloak realm: `nextcloud-mcp`)
|
||||
|
||||
**For detailed Keycloak setup, see**:
|
||||
- `docs/oauth-setup.md` - OAuth configuration
|
||||
- `docs/ADR-002-vector-sync-authentication.md` - Offline access architecture
|
||||
- `docs/audience-validation-setup.md` - Token audience validation
|
||||
- `docs/keycloak-multi-client-validation.md` - Realm-level validation
|
||||
|
||||
## Integration Testing with Docker
|
||||
|
||||
**Nextcloud**: `docker compose exec app php occ ...` for occ commands
|
||||
**MariaDB**: `docker compose exec db mariadb -u [user] -p [password] [database]` for queries
|
||||
|
||||
**For detailed setup, see**:
|
||||
- `docs/installation.md` - Installation guide
|
||||
- `docs/configuration.md` - Configuration options
|
||||
- `docs/authentication.md` - Authentication modes
|
||||
- `docs/running.md` - Running the server
|
||||
|
||||
**For additional information regarding MCP during development, see**:
|
||||
- `../../Software/modelcontextprotocol/` - MCP spec
|
||||
- `../../Software/python-sdk/` - Python MCP SDK
|
||||
+11
-3
@@ -1,9 +1,17 @@
|
||||
FROM ghcr.io/astral-sh/uv:0.7.11-python3.11-alpine@sha256:66d4d13288afecfeb2173b267a6c0765957d2122935c447d6963ea7b38929a99
|
||||
FROM ghcr.io/astral-sh/uv:0.9.9-python3.11-alpine@sha256:0faa7934fac1db7f5056f159c1224d144bab864fd2677a4066d25a686ae32edd
|
||||
|
||||
# Install dependencies
|
||||
# 1. git (required for caldav dependency from git)
|
||||
# 2. sqlite for development with token db
|
||||
RUN apk add --no-cache git sqlite
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN uv sync --locked --no-dev
|
||||
RUN uv sync --locked --no-dev --no-editable
|
||||
|
||||
CMD ["/app/.venv/bin/mcp", "run", "--transport", "sse", "/app/nextcloud_mcp_server/server.py:mcp"]
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV VIRTUAL_ENV=/app/.venv
|
||||
|
||||
ENTRYPOINT ["/app/.venv/bin/nextcloud-mcp-server", "--host", "0.0.0.0"]
|
||||
|
||||
@@ -2,119 +2,200 @@
|
||||
|
||||
[](https://github.com/cbcoutinho/nextcloud-mcp-server/pkgs/container/nextcloud-mcp-server)
|
||||
|
||||
The Nextcloud MCP (Model Context Protocol) server allows Large Language Models (LLMs) like OpenAI's GPT, Google's Gemini, or Anthropic's Claude to interact with your Nextcloud instance. This enables automation of various Nextcloud actions, starting with the Notes API.
|
||||
**A production-ready MCP server that connects AI assistants to your Nextcloud instance.**
|
||||
|
||||
## Features
|
||||
Enable 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 conversations.
|
||||
|
||||
Currently, the server primarily interacts with the Nextcloud Notes API, providing tools and resources to manage notes.
|
||||
This is a **dedicated standalone MCP server** designed for external MCP clients like Claude Code and IDEs. It runs independently of Nextcloud (Docker, VM, Kubernetes, or local) and provides deep CRUD operations across Nextcloud apps.
|
||||
|
||||
### Available Tools
|
||||
> [!NOTE]
|
||||
> **Looking for AI features inside Nextcloud?** Nextcloud also provides [Context Agent](https://github.com/nextcloud/context_agent), which powers the Assistant app and runs as an ExApp inside Nextcloud. See [docs/comparison-context-agent.md](docs/comparison-context-agent.md) for a detailed comparison of use cases.
|
||||
|
||||
* `nc_notes_create_note`: Create a new note.
|
||||
* `nc_notes_update_note`: Update an existing note by ID.
|
||||
* `nc_notes_append_content`: Append content to an existing note with a clear separator.
|
||||
* `nc_notes_delete_note`: Delete a note by ID.
|
||||
* `nc_notes_search_notes`: Search notes by title or content.
|
||||
* `nc_get_note`: Get a specific note by ID.
|
||||
## Quick Start
|
||||
|
||||
### Available Resources
|
||||
Get up and running in 60 seconds using Docker:
|
||||
|
||||
* `notes://{note_id}`: Access a specific note by its ID.
|
||||
* `notes://all`: Access all notes.
|
||||
* `notes://settings`: Access note settings.
|
||||
* `nc://capabilities`: Access Nextcloud server capabilities.
|
||||
* `nc://Notes/{note_id}/attachments/{attachment_filename}`: Access attachments for notes.
|
||||
|
||||
### Note Attachments
|
||||
|
||||
This server supports adding and retrieving note attachments via WebDAV. Please note the following behavior regarding attachments:
|
||||
|
||||
* When a note is deleted, its attachments remain in the system. This matches the behavior of the official Nextcloud Notes app.
|
||||
* Orphaned attachments (attachments whose parent notes have been deleted) may accumulate over time.
|
||||
* WebDAV permissions must be properly configured for attachment operations to work correctly.
|
||||
|
||||
## Installation
|
||||
|
||||
### Prerequisites
|
||||
|
||||
* Python 3.8+
|
||||
* Access to a Nextcloud instance
|
||||
|
||||
### Local Installation
|
||||
|
||||
1. Clone the repository (if running from source):
|
||||
```bash
|
||||
git clone https://github.com/cbcoutinho/nextcloud-mcp-server.git
|
||||
cd nextcloud-mcp-server
|
||||
```
|
||||
2. Install the package (if running as a library):
|
||||
```bash
|
||||
poetry install
|
||||
```
|
||||
|
||||
### Docker
|
||||
|
||||
A pre-built Docker image is available: `ghcr.io/cbcoutinho/nextcloud-mcp-server`
|
||||
|
||||
## Configuration
|
||||
|
||||
The server requires credentials to connect to your Nextcloud instance. Create a file named `.env` (or any name you prefer) in the directory where you'll run the server, based on the `env.sample` file:
|
||||
|
||||
```dotenv
|
||||
# .env
|
||||
```bash
|
||||
# 1. Create a minimal configuration
|
||||
cat > .env << EOF
|
||||
NEXTCLOUD_HOST=https://your.nextcloud.instance.com
|
||||
NEXTCLOUD_USERNAME=your_nextcloud_username
|
||||
NEXTCLOUD_PASSWORD=your_nextcloud_app_password_or_login_password
|
||||
NEXTCLOUD_USERNAME=your_username
|
||||
NEXTCLOUD_PASSWORD=your_app_password
|
||||
EOF
|
||||
|
||||
# 2. Start the server
|
||||
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
|
||||
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest
|
||||
|
||||
# 3. Test the connection
|
||||
curl http://127.0.0.1:8000/health/ready
|
||||
```
|
||||
|
||||
* `NEXTCLOUD_HOST`: The full URL of your Nextcloud instance.
|
||||
* `NEXTCLOUD_USERNAME`: Your Nextcloud username.
|
||||
* `NEXTCLOUD_PASSWORD`: **Important:** It is highly recommended to use a dedicated Nextcloud App Password for security. You can generate one in your Nextcloud Security settings. Alternatively, you can use your regular login password, but this is less secure.
|
||||
**Next Steps:**
|
||||
- Create an app password in Nextcloud: Settings → Security → Devices & sessions
|
||||
- Connect your MCP client (Claude Desktop, IDEs, `mcp dev`, etc.)
|
||||
- See [docs/installation.md](docs/installation.md) for other deployment options (local, Kubernetes)
|
||||
|
||||
## Running the Server
|
||||
## Key Features
|
||||
|
||||
### Locally
|
||||
- **90+ MCP Tools** - Comprehensive API coverage across 8 Nextcloud apps
|
||||
- **MCP Resources** - Structured data URIs for browsing Nextcloud data
|
||||
- **Semantic Search (Experimental)** - Optional vector-powered search for Notes (requires Qdrant + Ollama)
|
||||
- **Document Processing** - OCR and text extraction from PDFs, DOCX, images with progress notifications
|
||||
- **Flexible Deployment** - Docker, Kubernetes (Helm), VM, or local installation
|
||||
- **Production-Ready Auth** - Basic Auth with app passwords (recommended) or OAuth2/OIDC (experimental)
|
||||
- **Multiple Transports** - SSE, HTTP, and streamable-http support
|
||||
|
||||
Ensure your environment variables are loaded, then run the server using `mcp run`:
|
||||
## Supported Apps
|
||||
|
||||
```bash
|
||||
# Load environment variables from your .env file
|
||||
export $(grep -v '^#' .env | xargs)
|
||||
| App | Tools | Capabilities |
|
||||
|-----|-------|--------------|
|
||||
| **Notes** | 7 | Full CRUD, keyword search, semantic search |
|
||||
| **Calendar** | 20+ | Events, todos (tasks), recurring events, attendees, availability |
|
||||
| **Contacts** | 8 | Full CardDAV support, address books |
|
||||
| **Files (WebDAV)** | 12 | Filesystem access, OCR/document processing |
|
||||
| **Deck** | 15 | Boards, stacks, cards, labels, assignments |
|
||||
| **Cookbook** | 13 | Recipe management, URL import (schema.org) |
|
||||
| **Tables** | 5 | Row operations on Nextcloud Tables |
|
||||
| **Sharing** | 10+ | Create and manage shares |
|
||||
| **Semantic Search** | 2+ | Vector search for Notes (experimental, opt-in, requires infrastructure) |
|
||||
|
||||
# Run the server
|
||||
mcp run --transport sse nextcloud_mcp_server.server:mcp
|
||||
Want to see another Nextcloud app supported? [Open an issue](https://github.com/cbcoutinho/nextcloud-mcp-server/issues) or contribute a pull request!
|
||||
|
||||
## Authentication
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **OAuth2/OIDC is experimental** and requires a manual patch to the `user_oidc` app:
|
||||
> - **Required patch**: Bearer token support ([issue #1221](https://github.com/nextcloud/user_oidc/issues/1221))
|
||||
> - **Impact**: Without the patch, most app-specific APIs fail with 401 errors
|
||||
> - **Recommendation**: Use Basic Auth for production until upstream patches are merged
|
||||
>
|
||||
> See [docs/oauth-upstream-status.md](docs/oauth-upstream-status.md) for patch status and workarounds.
|
||||
|
||||
**Recommended:** Basic Auth with app-specific passwords provides secure, production-ready authentication. See [docs/authentication.md](docs/authentication.md) for setup details and OAuth configuration.
|
||||
|
||||
### Authentication Modes
|
||||
|
||||
The server supports two authentication modes:
|
||||
|
||||
**Single-User Mode (BasicAuth):**
|
||||
- One set of credentials shared by all MCP clients
|
||||
- Simple setup: username + app password in environment variables
|
||||
- All clients access Nextcloud as the same user
|
||||
- Best for: Personal use, development, single-user deployments
|
||||
|
||||
**Multi-User Mode (OAuth):**
|
||||
- Each MCP client authenticates separately with their own Nextcloud account
|
||||
- Per-user scopes and permissions (clients only see tools they're authorized for)
|
||||
- More secure: tokens expire, credentials never shared with server
|
||||
- Best for: Teams, multi-user deployments, production environments with multiple users
|
||||
|
||||
See [docs/authentication.md](docs/authentication.md) for detailed setup instructions.
|
||||
|
||||
## Semantic Search
|
||||
|
||||
The server provides an experimental RAG pipeline to enable _Semantic Search_ that enables MCP clients to find information in Nextcloud based on **meaning** rather than just keywords. Instead of matching "machine learning" only when those exact words appear, it understands that "neural networks," "AI models," and "deep learning" are semantically related concepts.
|
||||
|
||||
**Example:**
|
||||
- **Keyword search**: Query "car" only finds notes containing "car"
|
||||
- **Semantic search**: Query "car" also finds notes about "automobile," "vehicle," "sedan," "transportation"
|
||||
|
||||
This enables natural language queries and helps discover related content across your Nextcloud notes.
|
||||
|
||||
> [!NOTE]
|
||||
> **Semantic Search is experimental and opt-in:**
|
||||
> - Disabled by default (`VECTOR_SYNC_ENABLED=false`)
|
||||
> - Currently supports Notes app only (multi-app support planned)
|
||||
> - Requires additional infrastructure: vector database + embedding service
|
||||
> - Answer generation (`nc_semantic_search_answer`) requires MCP client sampling support
|
||||
>
|
||||
> See [docs/semantic-search-architecture.md](docs/semantic-search-architecture.md) for architecture details and [docs/configuration.md](docs/configuration.md) for setup instructions.
|
||||
|
||||
## Documentation
|
||||
|
||||
### Getting Started
|
||||
- **[Installation](docs/installation.md)** - Docker, Kubernetes, local, or VM deployment
|
||||
- **[Configuration](docs/configuration.md)** - Environment variables and advanced options
|
||||
- **[Authentication](docs/authentication.md)** - Basic Auth vs OAuth2/OIDC setup
|
||||
- **[Running the Server](docs/running.md)** - Start, manage, and troubleshoot
|
||||
|
||||
### Features
|
||||
- **[App Documentation](docs/)** - Notes, Calendar, Contacts, WebDAV, Deck, Cookbook, Tables
|
||||
- **[Document Processing](docs/configuration.md#document-processing)** - OCR and text extraction setup
|
||||
- **[Semantic Search Architecture](docs/semantic-search-architecture.md)** - Experimental vector search (Notes only, opt-in)
|
||||
|
||||
### Advanced Topics
|
||||
- **[OAuth Architecture](docs/oauth-architecture.md)** - How OAuth works (experimental)
|
||||
- **[OAuth Quick Start](docs/quickstart-oauth.md)** - 5-minute OAuth setup
|
||||
- **[OAuth Setup Guide](docs/oauth-setup.md)** - Detailed OAuth configuration
|
||||
- **[Troubleshooting](docs/troubleshooting.md)** - Common issues and solutions
|
||||
- **[Comparison with Context Agent](docs/comparison-context-agent.md)** - When to use each approach
|
||||
|
||||
## Examples
|
||||
|
||||
### Create a Note
|
||||
```
|
||||
AI: "Create a note called 'Meeting Notes' with today's agenda"
|
||||
→ Uses nc_notes_create_note tool
|
||||
```
|
||||
|
||||
The server will start, typically listening on `http://0.0.0.0:8000`.
|
||||
|
||||
### Using Docker
|
||||
|
||||
Mount your environment file when running the container:
|
||||
|
||||
```bash
|
||||
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm ghcr.io/cbcoutinho/nextcloud-mcp-server:latest
|
||||
### Import Recipes
|
||||
```
|
||||
AI: "Import the recipe from https://www.example.com/recipe/chocolate-cake"
|
||||
→ Uses nc_cookbook_import_recipe tool with schema.org metadata extraction
|
||||
```
|
||||
|
||||
This will start the server and expose it on port 8000 of your local machine.
|
||||
|
||||
## Usage
|
||||
|
||||
Once the server is running, you can connect to it using an MCP client like `uvx`. Add the server to your `uvx` configuration:
|
||||
|
||||
```bash
|
||||
uvx mcp add nextcloud-mcp http://localhost:8000 --default-transport sse
|
||||
### Schedule Meetings
|
||||
```
|
||||
AI: "Schedule a team meeting for next Tuesday at 2pm"
|
||||
→ Uses nc_calendar_create_event tool
|
||||
```
|
||||
|
||||
You can then interact with the server's tools and resources through your LLM interface connected to `uvx`.
|
||||
### Manage Files
|
||||
```
|
||||
AI: "Create a folder called 'Project X' and move all PDFs there"
|
||||
→ Uses nc_webdav_create_directory and nc_webdav_move tools
|
||||
```
|
||||
|
||||
## References:
|
||||
### Semantic Search (Experimental, Opt-in)
|
||||
```
|
||||
AI: "Find notes related to machine learning concepts"
|
||||
→ Uses nc_semantic_search to find semantically similar notes (requires Qdrant + Ollama setup)
|
||||
```
|
||||
|
||||
- https://github.com/modelcontextprotocol/python-sdk
|
||||
**Note:** For AI-generated answers with citations, use `nc_semantic_search_answer` (requires MCP client with sampling support).
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! Please feel free to submit issues or pull requests on the [GitHub repository](https://github.com/cbcoutinho/nextcloud-mcp-server).
|
||||
Contributions are welcome!
|
||||
|
||||
- Report bugs or request features: [GitHub Issues](https://github.com/cbcoutinho/nextcloud-mcp-server/issues)
|
||||
- Submit improvements: [Pull Requests](https://github.com/cbcoutinho/nextcloud-mcp-server/pulls)
|
||||
- Development guidelines: [CLAUDE.md](CLAUDE.md)
|
||||
|
||||
## Security
|
||||
|
||||
[](https://mseep.ai/app/cbcoutinho-nextcloud-mcp-server)
|
||||
|
||||
This project takes security seriously:
|
||||
- Production-ready Basic Auth with app-specific passwords
|
||||
- OAuth2/OIDC support (experimental, requires upstream patches)
|
||||
- Per-user access tokens
|
||||
- No credential storage in OAuth mode
|
||||
- Regular security assessments
|
||||
|
||||
Found a security issue? Please report it privately to the maintainers.
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the MIT License. See the LICENSE file for details.
|
||||
This project is licensed under the AGPL-3.0 License. See [LICENSE](./LICENSE) for details.
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://www.star-history.com/#cbcoutinho/nextcloud-mcp-server&Date)
|
||||
|
||||
## References
|
||||
|
||||
- [Model Context Protocol](https://github.com/modelcontextprotocol)
|
||||
- [MCP Python SDK](https://github.com/modelcontextprotocol/python-sdk)
|
||||
- [Nextcloud](https://nextcloud.com/)
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
From deab2dac3d73d25f20a95c18103f327ab48f837a Mon Sep 17 00:00:00 2001
|
||||
From: Chris Coutinho <chris@coutinho.io>
|
||||
Date: Sun, 12 Oct 2025 21:09:29 +0200
|
||||
Subject: [PATCH 1/1] Fix Bearer token authentication causing session logout
|
||||
|
||||
When using Bearer token authentication with OIDC, API requests to
|
||||
endpoints with @CORS annotations (like Notes API) were failing with
|
||||
401 Unauthorized errors. This occurred because:
|
||||
|
||||
1. Bearer token validation successfully authenticated the user
|
||||
2. A session was created for the authenticated user
|
||||
3. Nextcloud's CORSMiddleware detected the logged-in session but no
|
||||
CSRF token, causing it to call session->logout()
|
||||
4. The logout invalidated the session, breaking the API request
|
||||
|
||||
This fix sets the 'app_api' session flag during Bearer token
|
||||
authentication, which instructs CORSMiddleware to skip the CSRF check
|
||||
and logout logic. This is the same mechanism used by Nextcloud's
|
||||
AppAPI framework for external application authentication.
|
||||
|
||||
The flag is set at all successful Bearer token authentication points:
|
||||
- Line 243: After OIDC Identity Provider validation
|
||||
- Line 310: After auto-provisioning with bearer provisioning
|
||||
- Line 315: After existing user authentication
|
||||
- Line 337: After LDAP user sync
|
||||
|
||||
Fixes: Bearer token authentication for all Nextcloud APIs
|
||||
Tested-with: nextcloud-mcp-server integration tests
|
||||
Signed-off-by: Chris Coutinho <chris@coutinho.io>
|
||||
---
|
||||
lib/User/Backend.php | 4 ++++
|
||||
1 file changed, 4 insertions(+)
|
||||
|
||||
diff --git a/lib/User/Backend.php b/lib/User/Backend.php
|
||||
index 23cfb18..65665cc 100644
|
||||
--- a/lib/User/Backend.php
|
||||
+++ b/lib/User/Backend.php
|
||||
@@ -240,6 +240,7 @@ class Backend extends ABackend implements IPasswordConfirmationBackend, IGetDisp
|
||||
$this->eventDispatcher->dispatchTyped($validationEvent);
|
||||
$oidcProviderUserId = $validationEvent->getUserId();
|
||||
if ($oidcProviderUserId !== null) {
|
||||
+ $this->session->set('app_api', true);
|
||||
return $oidcProviderUserId;
|
||||
} else {
|
||||
$this->logger->debug('[NextcloudOidcProviderValidator] The bearer token validation has failed');
|
||||
@@ -306,10 +307,12 @@ class Backend extends ABackend implements IPasswordConfirmationBackend, IGetDisp
|
||||
}
|
||||
|
||||
$this->session->set('last-password-confirm', strtotime('+4 year', time()));
|
||||
+ $this->session->set('app_api', true);
|
||||
return $userId;
|
||||
} elseif ($this->userExists($tokenUserId)) {
|
||||
$this->checkFirstLogin($tokenUserId);
|
||||
$this->session->set('last-password-confirm', strtotime('+4 year', time()));
|
||||
+ $this->session->set('app_api', true);
|
||||
return $tokenUserId;
|
||||
} else {
|
||||
// check if the user exists locally
|
||||
@@ -331,6 +334,7 @@ class Backend extends ABackend implements IPasswordConfirmationBackend, IGetDisp
|
||||
}
|
||||
$this->checkFirstLogin($tokenUserId);
|
||||
$this->session->set('last-password-confirm', strtotime('+4 year', time()));
|
||||
+ $this->session->set('app_api', true);
|
||||
return $tokenUserId;
|
||||
}
|
||||
}
|
||||
--
|
||||
2.51.0
|
||||
|
||||
@@ -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
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euox pipefail
|
||||
|
||||
echo "Installing and configuring Calendar app..."
|
||||
|
||||
# Enable calendar app
|
||||
php /var/www/html/occ app:enable calendar
|
||||
php /var/www/html/occ app:enable tasks
|
||||
|
||||
# Wait for calendar app to be fully initialized
|
||||
echo "Waiting for calendar app to initialize..."
|
||||
sleep 5
|
||||
|
||||
# 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 rateLimitPeriodCalendarCreation --type=integer --value=60
|
||||
php occ config:app:set dav maximumCalendarsSubscriptions --type=integer --value=-1
|
||||
|
||||
# Ensure maintenance mode is off before calendar operations
|
||||
php /var/www/html/occ maintenance:mode --off
|
||||
|
||||
# Sync DAV system to ensure proper initialization
|
||||
echo "Syncing DAV system..."
|
||||
php /var/www/html/occ dav:sync-system-addressbook
|
||||
|
||||
# Repair calendar app to ensure proper setup
|
||||
echo "Repairing calendar app..."
|
||||
php /var/www/html/occ maintenance:repair --include-expensive
|
||||
|
||||
# Final wait to ensure CalDAV service is fully ready
|
||||
echo "Final CalDAV initialization wait..."
|
||||
sleep 5
|
||||
|
||||
echo "Calendar app installation complete!"
|
||||
@@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euox pipefail
|
||||
|
||||
php /var/www/html/occ app:enable contacts
|
||||
@@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euox pipefail
|
||||
|
||||
php /var/www/html/occ app:enable cookbook
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euox pipefail
|
||||
|
||||
php /var/www/html/occ app:enable deck
|
||||
+2
@@ -1,3 +1,5 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euox pipefail
|
||||
|
||||
php /var/www/html/occ app:enable notes
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
#!/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' # NOTE: String
|
||||
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 allow_user_settings --value='enabled'
|
||||
php /var/www/html/occ config:app:set oidc default_token_type --value='jwt'
|
||||
php /var/www/html/occ config:app:set oidc default_resource_identifier --value='http://localhost:8080'
|
||||
|
||||
echo "OIDC app installed and configured successfully"
|
||||
@@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euox pipefail
|
||||
|
||||
php /var/www/html/occ app:enable tables
|
||||
+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 ""
|
||||
@@ -0,0 +1,3 @@
|
||||
#!/bin/bash
|
||||
|
||||
php /var/www/html/occ config:app:set --value false firstrunwizard wizard_enabled
|
||||
@@ -0,0 +1 @@
|
||||
charts/
|
||||
@@ -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,9 @@
|
||||
dependencies:
|
||||
- name: qdrant
|
||||
repository: https://qdrant.github.io/qdrant-helm
|
||||
version: 1.15.5
|
||||
- name: ollama
|
||||
repository: https://otwld.github.io/ollama-helm
|
||||
version: 1.34.0
|
||||
digest: sha256:d51c97d05be2614b751c0dd7267ef7dc959eff5ebef859c5f895c5c554b7a874
|
||||
generated: "2025-11-09T17:08:02.86648061Z"
|
||||
@@ -0,0 +1,36 @@
|
||||
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.35.0
|
||||
appVersion: "0.35.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
|
||||
annotations:
|
||||
# Grafana dashboard support
|
||||
grafana_dashboard: "true"
|
||||
grafana_dashboard_folder: "Nextcloud MCP"
|
||||
dependencies:
|
||||
- name: qdrant
|
||||
version: "1.15.5"
|
||||
repository: https://qdrant.github.io/qdrant-helm
|
||||
condition: qdrant.networkMode.deploySubchart
|
||||
- name: ollama
|
||||
version: "1.34.0"
|
||||
repository: https://otwld.github.io/ollama-helm
|
||||
condition: ollama.enabled
|
||||
@@ -0,0 +1,721 @@
|
||||
# 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
|
||||
# Add the Helm repository
|
||||
helm repo add nextcloud-mcp https://cbcoutinho.github.io/nextcloud-mcp-server
|
||||
helm repo update
|
||||
|
||||
# Install with basic auth (recommended for most users)
|
||||
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
|
||||
```
|
||||
|
||||
### 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 nextcloud-mcp/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` |
|
||||
|
||||
#### Vector Search & Semantic Capabilities (Optional)
|
||||
|
||||
Enable semantic search capabilities by deploying a vector database (Qdrant) and embedding service (Ollama or OpenAI).
|
||||
|
||||
**Vector Sync Configuration:**
|
||||
|
||||
| Parameter | Description | Default |
|
||||
|-----------|-------------|---------|
|
||||
| `vectorSync.enabled` | Enable background vector synchronization | `false` |
|
||||
| `vectorSync.scanInterval` | Scan interval in seconds | `3600` |
|
||||
| `vectorSync.processorWorkers` | Number of concurrent processor workers | `3` |
|
||||
| `vectorSync.queueMaxSize` | Maximum queue size for pending documents | `10000` |
|
||||
|
||||
**Document Chunking Configuration:**
|
||||
|
||||
| Parameter | Description | Default |
|
||||
|-----------|-------------|---------|
|
||||
| `documentChunking.chunkSize` | Number of words per chunk for embedding | `512` |
|
||||
| `documentChunking.chunkOverlap` | Number of overlapping words between chunks | `50` |
|
||||
|
||||
**Chunking Strategy:**
|
||||
- **Small chunks (256-384)**: Better precision for searches, more storage overhead
|
||||
- **Medium chunks (512-768)**: Balanced approach (recommended for most use cases)
|
||||
- **Large chunks (1024+)**: Better context preservation, less precise matching
|
||||
- **Overlap**: Should be 10-20% of chunk size to preserve context across boundaries
|
||||
|
||||
**Qdrant Vector Database:**
|
||||
|
||||
Qdrant is deployed as a subchart when `qdrant.enabled` is `true`. All configuration values are passed through to the [qdrant/qdrant](https://github.com/qdrant/qdrant-helm) chart.
|
||||
|
||||
| Parameter | Description | Default |
|
||||
|-----------|-------------|---------|
|
||||
| `qdrant.enabled` | Deploy Qdrant as a subchart | `false` |
|
||||
| `qdrant.replicaCount` | Number of Qdrant replicas | `1` |
|
||||
| `qdrant.image.tag` | Qdrant version | `v1.12.5` |
|
||||
| `qdrant.apiKey` | Optional API key for authentication | `""` |
|
||||
| `qdrant.persistence.size` | Storage size for vector data | `10Gi` |
|
||||
| `qdrant.persistence.storageClass` | Storage class | `""` |
|
||||
| `qdrant.resources.requests.cpu` | CPU request | `200m` |
|
||||
| `qdrant.resources.requests.memory` | Memory request | `512Mi` |
|
||||
| `qdrant.resources.limits.cpu` | CPU limit | `1000m` |
|
||||
| `qdrant.resources.limits.memory` | Memory limit | `2Gi` |
|
||||
|
||||
**Ollama Embedding Service:**
|
||||
|
||||
Ollama is deployed as a subchart when `ollama.enabled` is `true`. All configuration values are passed through to the [ollama/ollama](https://github.com/otwld/ollama-helm) chart. Alternatively, set `ollama.url` to use an external Ollama instance.
|
||||
|
||||
| Parameter | Description | Default |
|
||||
|-----------|-------------|---------|
|
||||
| `ollama.enabled` | Deploy Ollama as a subchart | `false` |
|
||||
| `ollama.url` | External Ollama URL (use with `enabled: false`) | `""` |
|
||||
| `ollama.embeddingModel` | Embedding model to use | `nomic-embed-text` |
|
||||
| `ollama.verifySsl` | Verify SSL certificates | `true` |
|
||||
| `ollama.replicaCount` | Number of Ollama replicas | `1` |
|
||||
| `ollama.ollama.models.pull` | Models to pull on startup | `["nomic-embed-text"]` |
|
||||
| `ollama.persistentVolume.enabled` | Enable persistent storage | `true` |
|
||||
| `ollama.persistentVolume.size` | Storage size for models | `20Gi` |
|
||||
| `ollama.resources.requests.cpu` | CPU request | `500m` |
|
||||
| `ollama.resources.requests.memory` | Memory request | `1Gi` |
|
||||
| `ollama.resources.limits.cpu` | CPU limit | `2000m` |
|
||||
| `ollama.resources.limits.memory` | Memory limit | `4Gi` |
|
||||
|
||||
**OpenAI Embedding Provider (Alternative):**
|
||||
|
||||
Use OpenAI or any OpenAI-compatible API instead of Ollama.
|
||||
|
||||
| Parameter | Description | Default |
|
||||
|-----------|-------------|---------|
|
||||
| `openai.enabled` | Enable OpenAI embedding provider | `false` |
|
||||
| `openai.apiKey` | OpenAI API key | `""` |
|
||||
| `openai.existingSecret` | Use existing secret for API key | `""` |
|
||||
| `openai.secretKey` | Key in secret containing API key | `api-key` |
|
||||
| `openai.baseUrl` | Custom API endpoint (optional) | `""` |
|
||||
|
||||
#### Observability & Monitoring
|
||||
|
||||
The chart includes comprehensive observability features including Prometheus metrics, OpenTelemetry tracing, and Grafana dashboards.
|
||||
|
||||
**Metrics Configuration:**
|
||||
|
||||
| Parameter | Description | Default |
|
||||
|-----------|-------------|---------|
|
||||
| `observability.metrics.enabled` | Enable Prometheus metrics | `true` |
|
||||
| `observability.metrics.port` | Metrics port | `9090` |
|
||||
| `observability.metrics.path` | Metrics endpoint path | `/metrics` |
|
||||
|
||||
**Tracing Configuration:**
|
||||
|
||||
| Parameter | Description | Default |
|
||||
|-----------|-------------|---------|
|
||||
| `observability.tracing.enabled` | Enable OpenTelemetry tracing | `false` |
|
||||
| `observability.tracing.endpoint` | OTLP collector endpoint | `""` |
|
||||
| `observability.tracing.serviceName` | Service name in traces | `nextcloud-mcp-server` |
|
||||
| `observability.tracing.samplingRate` | Trace sampling rate (0.0-1.0) | `1.0` |
|
||||
|
||||
**Logging Configuration:**
|
||||
|
||||
| Parameter | Description | Default |
|
||||
|-----------|-------------|---------|
|
||||
| `observability.logging.format` | Log format (json or text) | `json` |
|
||||
| `observability.logging.level` | Log level | `INFO` |
|
||||
| `observability.logging.includeTraceContext` | Include trace IDs in logs | `true` |
|
||||
|
||||
**ServiceMonitor (Prometheus Operator):**
|
||||
|
||||
| Parameter | Description | Default |
|
||||
|-----------|-------------|---------|
|
||||
| `serviceMonitor.enabled` | Create ServiceMonitor resource | `false` |
|
||||
| `serviceMonitor.interval` | Scrape interval | `30s` |
|
||||
| `serviceMonitor.scrapeTimeout` | Scrape timeout | `10s` |
|
||||
| `serviceMonitor.labels` | Additional labels for ServiceMonitor | `{}` |
|
||||
|
||||
**PrometheusRule (Prometheus Operator):**
|
||||
|
||||
| Parameter | Description | Default |
|
||||
|-----------|-------------|---------|
|
||||
| `prometheusRule.enabled` | Create PrometheusRule with alert rules | `false` |
|
||||
| `prometheusRule.labels` | Additional labels for PrometheusRule | `{}` |
|
||||
|
||||
**Grafana Dashboards:**
|
||||
|
||||
| Parameter | Description | Default |
|
||||
|-----------|-------------|---------|
|
||||
| `dashboards.enabled` | Enable automatic dashboard provisioning | `false` |
|
||||
| `dashboards.grafanaFolder` | Grafana folder name for dashboards | `Nextcloud MCP` |
|
||||
| `dashboards.labels` | Additional labels for dashboard ConfigMap | `{}` |
|
||||
| `dashboards.annotations` | Additional annotations for dashboard ConfigMap | `{}` |
|
||||
|
||||
When `dashboards.enabled` is `true`, a ConfigMap with the Grafana dashboard is created with the `grafana_dashboard: "1"` label. This enables automatic discovery by Grafana sidecar containers (commonly used with kube-prometheus-stack).
|
||||
|
||||
The dashboard provides comprehensive monitoring including:
|
||||
- HTTP request metrics (RED pattern: Rate, Errors, Duration)
|
||||
- MCP tool performance and errors
|
||||
- Nextcloud API performance by app (notes, calendar, contacts, etc.)
|
||||
- OAuth token operations and cache hit rates
|
||||
- External dependency health (Nextcloud, Qdrant, Keycloak, Unstructured API)
|
||||
- Vector sync processing pipeline (when enabled)
|
||||
|
||||
For manual import or more details, see `charts/nextcloud-mcp-server/dashboards/README.md`.
|
||||
|
||||
## 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
|
||||
```
|
||||
|
||||
### Example 5: Semantic Search with Qdrant and Ollama
|
||||
|
||||
Deploy with vector search capabilities using embedded Qdrant and Ollama:
|
||||
|
||||
```yaml
|
||||
nextcloud:
|
||||
host: https://cloud.example.com
|
||||
|
||||
auth:
|
||||
mode: basic
|
||||
basic:
|
||||
username: admin
|
||||
password: secure-password
|
||||
|
||||
# Enable vector sync
|
||||
vectorSync:
|
||||
enabled: true
|
||||
scanInterval: 1800 # Scan every 30 minutes
|
||||
processorWorkers: 5
|
||||
|
||||
# Deploy Qdrant as a subchart
|
||||
qdrant:
|
||||
enabled: true
|
||||
persistence:
|
||||
size: 20Gi
|
||||
storageClass: fast-ssd
|
||||
resources:
|
||||
requests:
|
||||
cpu: 500m
|
||||
memory: 1Gi
|
||||
limits:
|
||||
cpu: 2000m
|
||||
memory: 4Gi
|
||||
|
||||
# Deploy Ollama as a subchart
|
||||
ollama:
|
||||
enabled: true
|
||||
embeddingModel: nomic-embed-text
|
||||
persistentVolume:
|
||||
size: 30Gi
|
||||
storageClass: standard
|
||||
resources:
|
||||
requests:
|
||||
cpu: 1000m
|
||||
memory: 2Gi
|
||||
limits:
|
||||
cpu: 4000m
|
||||
memory: 8Gi
|
||||
```
|
||||
|
||||
Or use an external Ollama instance:
|
||||
|
||||
```yaml
|
||||
vectorSync:
|
||||
enabled: true
|
||||
|
||||
qdrant:
|
||||
enabled: true
|
||||
|
||||
# Use external Ollama instead of deploying subchart
|
||||
ollama:
|
||||
enabled: false
|
||||
url: "http://ollama.ai-services.svc.cluster.local:11434"
|
||||
embeddingModel: nomic-embed-text
|
||||
```
|
||||
|
||||
Or use OpenAI for embeddings:
|
||||
|
||||
```yaml
|
||||
vectorSync:
|
||||
enabled: true
|
||||
|
||||
qdrant:
|
||||
enabled: true
|
||||
|
||||
# Use OpenAI instead of Ollama
|
||||
openai:
|
||||
enabled: true
|
||||
apiKey: "sk-..."
|
||||
# Or use existing secret:
|
||||
# existingSecret: openai-api-key
|
||||
# secretKey: api-key
|
||||
```
|
||||
|
||||
## Upgrading
|
||||
|
||||
### To upgrade an existing deployment:
|
||||
|
||||
```bash
|
||||
# Update the repository
|
||||
helm repo update
|
||||
|
||||
# Upgrade with your custom values
|
||||
helm upgrade nextcloud-mcp nextcloud-mcp/nextcloud-mcp-server -f custom-values.yaml
|
||||
```
|
||||
|
||||
### To upgrade with new values:
|
||||
|
||||
```bash
|
||||
helm upgrade nextcloud-mcp nextcloud-mcp/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,161 @@
|
||||
# Grafana Dashboards
|
||||
|
||||
This directory contains example Grafana dashboards for monitoring the Nextcloud MCP Server.
|
||||
|
||||
## Dashboards
|
||||
|
||||
### nextcloud-mcp-server.json
|
||||
|
||||
All-in-one Operations Dashboard with comprehensive monitoring across all system components.
|
||||
|
||||
#### Overview Row
|
||||
High-level metrics for quick health assessment:
|
||||
- **Request Rate** (stat): Total requests per second
|
||||
- **Error Rate** (stat): Percentage of 5xx errors with color thresholds
|
||||
- **P95 Latency** (stat): 95th percentile request latency
|
||||
- **Active Requests** (stat): Current in-flight requests
|
||||
|
||||
#### HTTP Metrics (RED Pattern)
|
||||
Core request/error/duration metrics:
|
||||
- **Request Rate by Endpoint** (timeseries): RPS breakdown by endpoint
|
||||
- **Error Rate by Status Code** (timeseries): Error rates for 4xx/5xx codes
|
||||
- **Latency Percentiles** (timeseries): P50, P95, P99 latency trends
|
||||
- **Status Code Distribution** (piechart): Percentage breakdown of all status codes
|
||||
|
||||
#### MCP Tools Row
|
||||
MCP-specific tool performance:
|
||||
- **Top Tools by Call Volume** (bargauge): Top 10 most-called tools
|
||||
- **Tool Error Rate** (timeseries): Error rates per tool
|
||||
- **Tool Execution Duration** (timeseries): P95 latency by tool
|
||||
|
||||
#### Nextcloud API Row
|
||||
Backend API performance metrics:
|
||||
- **API Calls by App** (timeseries): Request rate per Nextcloud app (notes, calendar, contacts, etc.)
|
||||
- **API Latency by App** (timeseries): P95 latency per app
|
||||
- **API Retries by Reason** (timeseries): Retry patterns (429, timeout, connection errors)
|
||||
- **API Error Rate** (stat): Overall API error percentage
|
||||
|
||||
#### OAuth & Authentication Row
|
||||
OAuth token operations and caching:
|
||||
- **Token Validations** (timeseries): Success/failure rates for token validation
|
||||
- **Token Exchange Operations** (timeseries): RFC 8693 token exchange operations
|
||||
- **Token Cache Hit Rate** (stat): Percentage of cache hits (color-coded: red<50%, yellow<80%, green≥80%)
|
||||
- **Refresh Token Operations** (timeseries): Refresh token storage operations by type
|
||||
|
||||
#### Dependencies & Health Row
|
||||
External dependency status monitoring:
|
||||
- **Nextcloud Health** (stat): UP/DOWN status with color coding
|
||||
- **Qdrant Health** (stat): Vector database health status
|
||||
- **Keycloak Health** (stat): Identity provider health status
|
||||
- **Unstructured API Health** (stat): Document processing API status
|
||||
- **Health Check Duration** (timeseries): Health check latency by dependency
|
||||
- **Database Operation Latency** (timeseries): P95 latency for DB operations (SQLite, Qdrant)
|
||||
|
||||
#### Vector Sync Row (when enabled)
|
||||
Document processing pipeline metrics:
|
||||
- **Documents Processed Rate** (timeseries): Processing throughput by status (success/failure)
|
||||
- **Processing Queue Depth** (gauge): Current queue size with thresholds (yellow>50, red>100)
|
||||
- **Qdrant Operations** (timeseries): Vector database operations by type
|
||||
- **Document Processing Duration** (timeseries): P95 processing latency
|
||||
|
||||
## Importing to Grafana
|
||||
|
||||
### Manual Import
|
||||
|
||||
1. Open Grafana UI
|
||||
2. Navigate to Dashboards → Import
|
||||
3. Upload `nextcloud-mcp-server.json`
|
||||
4. Select your Prometheus data source
|
||||
5. Click "Import"
|
||||
|
||||
### Automated Import (Helm Chart)
|
||||
|
||||
The Helm chart now supports automatic dashboard provisioning via Grafana sidecar pattern.
|
||||
|
||||
#### Option 1: Using Helm Chart (Recommended)
|
||||
|
||||
Enable dashboard provisioning in your Helm values:
|
||||
|
||||
```yaml
|
||||
# values.yaml for nextcloud-mcp-server chart
|
||||
dashboards:
|
||||
enabled: true
|
||||
grafanaFolder: "Nextcloud MCP" # Folder name in Grafana
|
||||
labels: {} # Additional labels if needed
|
||||
```
|
||||
|
||||
Then deploy or upgrade:
|
||||
|
||||
```bash
|
||||
helm upgrade --install nextcloud-mcp nextcloud-mcp-server \
|
||||
--set dashboards.enabled=true
|
||||
```
|
||||
|
||||
The dashboard will be automatically imported by Grafana if the sidecar is configured
|
||||
to watch for ConfigMaps with label `grafana_dashboard: "1"`.
|
||||
|
||||
#### Option 2: Using kube-prometheus-stack
|
||||
|
||||
If using kube-prometheus-stack with Grafana sidecar enabled, the dashboard will be
|
||||
automatically discovered and imported. Ensure your Grafana deployment has:
|
||||
|
||||
```yaml
|
||||
# kube-prometheus-stack values
|
||||
grafana:
|
||||
sidecar:
|
||||
dashboards:
|
||||
enabled: true
|
||||
label: grafana_dashboard
|
||||
folder: /tmp/dashboards
|
||||
provider:
|
||||
foldersFromFilesStructure: true
|
||||
```
|
||||
|
||||
#### Option 3: Manual ConfigMap Creation
|
||||
|
||||
For other Grafana setups, create a ConfigMap manually:
|
||||
|
||||
```bash
|
||||
kubectl create configmap nextcloud-mcp-dashboard \
|
||||
--from-file=nextcloud-mcp-server.json \
|
||||
-n monitoring
|
||||
|
||||
# Add sidecar discovery label
|
||||
kubectl label configmap nextcloud-mcp-dashboard \
|
||||
grafana_dashboard=1 \
|
||||
-n monitoring
|
||||
|
||||
# Add folder annotation (annotations support spaces, unlike labels)
|
||||
kubectl annotate configmap nextcloud-mcp-dashboard \
|
||||
grafana_folder="Nextcloud MCP" \
|
||||
-n monitoring
|
||||
```
|
||||
|
||||
## Dashboard Variables
|
||||
|
||||
The dashboard includes four template variables for dynamic filtering:
|
||||
|
||||
- **datasource**: Select your Prometheus data source
|
||||
- **namespace**: Filter metrics by Kubernetes namespace (supports "All")
|
||||
- **pod**: Filter by specific pod(s) - multi-select enabled (supports "All")
|
||||
- **interval**: Query interval for rate calculations (1m, 5m, 10m, 30m, 1h - default: 5m)
|
||||
|
||||
## Customization
|
||||
|
||||
You can customize the dashboard by:
|
||||
|
||||
1. Adjusting refresh rate (default: 30s)
|
||||
2. Modifying time range (default: last 6 hours)
|
||||
3. Adding new panels for specific metrics
|
||||
4. Adjusting thresholds in existing panels
|
||||
|
||||
## Metrics Reference
|
||||
|
||||
All metrics are documented in `/docs/observability.md`. Key metric prefixes:
|
||||
|
||||
- `mcp_http_*` - HTTP server metrics
|
||||
- `mcp_tool_*` - MCP tool invocation metrics
|
||||
- `mcp_nextcloud_api_*` - Nextcloud API call metrics
|
||||
- `mcp_oauth_*` - OAuth token validation metrics
|
||||
- `mcp_vector_sync_*` - Vector database sync metrics
|
||||
- `mcp_db_*` - Database operation metrics
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,131 @@
|
||||
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 }}
|
||||
|
||||
{{- if .Values.vectorSync.enabled }}
|
||||
|
||||
5. Vector Search & Semantic Capabilities:
|
||||
- Vector Sync: Enabled
|
||||
- Scan Interval: {{ .Values.vectorSync.scanInterval }}s
|
||||
- Processor Workers: {{ .Values.vectorSync.processorWorkers }}
|
||||
{{- if .Values.qdrant.enabled }}
|
||||
- Qdrant: Deployed as subchart ({{ .Release.Name }}-qdrant:6333)
|
||||
{{- else }}
|
||||
- Qdrant: Not deployed (configure external instance)
|
||||
{{- end }}
|
||||
{{- if .Values.ollama.enabled }}
|
||||
- Ollama: Deployed as subchart ({{ .Release.Name }}-ollama:11434)
|
||||
- Embedding Model: {{ .Values.ollama.embeddingModel }}
|
||||
{{- else if .Values.ollama.url }}
|
||||
- Ollama: Using external instance at {{ .Values.ollama.url }}
|
||||
- Embedding Model: {{ .Values.ollama.embeddingModel }}
|
||||
{{- else if .Values.openai.enabled }}
|
||||
- OpenAI: Enabled for embeddings
|
||||
{{- else }}
|
||||
- WARNING: No embedding provider configured (Ollama or OpenAI required)
|
||||
{{- end }}
|
||||
|
||||
Check vector sync status:
|
||||
kubectl --namespace {{ .Release.Namespace }} exec -it deploy/{{ include "nextcloud-mcp-server.fullname" . }} -- curl -s http://localhost:{{ include "nextcloud-mcp-server.port" . }}/user/page | grep "Vector Sync"
|
||||
{{- end }}
|
||||
|
||||
{{- if .Values.dashboards.enabled }}
|
||||
|
||||
6. Grafana Dashboards:
|
||||
- Dashboard provisioning: Enabled
|
||||
- ConfigMap: {{ include "nextcloud-mcp-server.fullname" . }}-dashboard
|
||||
- Grafana Folder: {{ .Values.dashboards.grafanaFolder }}
|
||||
|
||||
The dashboard will be automatically imported by Grafana if the sidecar is configured
|
||||
to watch for ConfigMaps with label "grafana_dashboard: 1".
|
||||
|
||||
To manually import the dashboard:
|
||||
kubectl --namespace {{ .Release.Namespace }} get configmap {{ include "nextcloud-mcp-server.fullname" . }}-dashboard -o jsonpath='{.data.nextcloud-mcp-server\.json}' | jq . > dashboard.json
|
||||
|
||||
Then import dashboard.json via Grafana UI (Dashboards → Import).
|
||||
{{- else }}
|
||||
|
||||
6. Grafana Dashboards:
|
||||
- Dashboard provisioning: Disabled
|
||||
- To enable automatic dashboard provisioning, set: dashboards.enabled=true
|
||||
|
||||
Manual import option:
|
||||
The dashboard JSON is available in the chart at charts/nextcloud-mcp-server/dashboards/nextcloud-mcp-server.json
|
||||
{{- 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,153 @@
|
||||
{{/*
|
||||
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 }}
|
||||
|
||||
{{/*
|
||||
Create the name of the PVC to use for Qdrant local persistent storage
|
||||
*/}}
|
||||
{{- define "nextcloud-mcp-server.qdrantPvcName" -}}
|
||||
{{- if .Values.qdrant.localPersistence.existingClaim }}
|
||||
{{- .Values.qdrant.localPersistence.existingClaim }}
|
||||
{{- else }}
|
||||
{{- include "nextcloud-mcp-server.fullname" . }}-qdrant-data
|
||||
{{- 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,25 @@
|
||||
{{- if .Values.dashboards.enabled }}
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: {{ include "nextcloud-mcp-server.fullname" . }}-dashboard
|
||||
namespace: {{ .Release.Namespace }}
|
||||
labels:
|
||||
{{- include "nextcloud-mcp-server.labels" . | nindent 4 }}
|
||||
{{- with .Values.dashboards.labels }}
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
# Grafana sidecar discovery label
|
||||
grafana_dashboard: "1"
|
||||
annotations:
|
||||
{{- with .Values.dashboards.annotations }}
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
# Grafana folder name (annotations support spaces, unlike labels)
|
||||
{{- if .Values.dashboards.grafanaFolder }}
|
||||
grafana_folder: {{ .Values.dashboards.grafanaFolder | quote }}
|
||||
{{- end }}
|
||||
data:
|
||||
nextcloud-mcp-server.json: |-
|
||||
{{ .Files.Get "dashboards/nextcloud-mcp-server.json" | indent 4 }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,288 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ include "nextcloud-mcp-server.fullname" . }}
|
||||
labels:
|
||||
{{- include "nextcloud-mcp-server.labels" . | nindent 4 }}
|
||||
spec:
|
||||
strategy:
|
||||
type: Recreate
|
||||
{{- 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
|
||||
{{- if .Values.observability.metrics.enabled }}
|
||||
- name: metrics
|
||||
containerPort: {{ .Values.observability.metrics.port }}
|
||||
protocol: TCP
|
||||
{{- end }}
|
||||
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 }}
|
||||
# Vector Sync
|
||||
- name: VECTOR_SYNC_ENABLED
|
||||
value: {{ .Values.vectorSync.enabled | quote }}
|
||||
{{- if .Values.vectorSync.enabled }}
|
||||
- name: VECTOR_SYNC_SCAN_INTERVAL
|
||||
value: {{ .Values.vectorSync.scanInterval | quote }}
|
||||
- name: VECTOR_SYNC_PROCESSOR_WORKERS
|
||||
value: {{ .Values.vectorSync.processorWorkers | quote }}
|
||||
- name: VECTOR_SYNC_QUEUE_MAX_SIZE
|
||||
value: {{ .Values.vectorSync.queueMaxSize | quote }}
|
||||
{{- end }}
|
||||
# Document Chunking (always set, used by vector sync processor)
|
||||
- name: DOCUMENT_CHUNK_SIZE
|
||||
value: {{ .Values.documentChunking.chunkSize | quote }}
|
||||
- name: DOCUMENT_CHUNK_OVERLAP
|
||||
value: {{ .Values.documentChunking.chunkOverlap | quote }}
|
||||
# Qdrant Vector Database
|
||||
{{- if eq .Values.qdrant.mode "network" }}
|
||||
# Network mode: Use dedicated Qdrant service
|
||||
{{- if .Values.qdrant.networkMode.deploySubchart }}
|
||||
- name: QDRANT_URL
|
||||
value: "http://{{ .Release.Name }}-qdrant:6333"
|
||||
{{- else if .Values.qdrant.networkMode.externalUrl }}
|
||||
- name: QDRANT_URL
|
||||
value: {{ .Values.qdrant.networkMode.externalUrl | quote }}
|
||||
{{- end }}
|
||||
{{- if or .Values.qdrant.networkMode.apiKey .Values.qdrant.networkMode.existingSecret }}
|
||||
- name: QDRANT_API_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ .Values.qdrant.networkMode.existingSecret | default (printf "%s-qdrant" .Release.Name) }}
|
||||
key: {{ .Values.qdrant.networkMode.secretKey }}
|
||||
{{- end }}
|
||||
{{- else if eq .Values.qdrant.mode "persistent" }}
|
||||
# Persistent local mode: File-based storage
|
||||
- name: QDRANT_LOCATION
|
||||
value: {{ .Values.qdrant.localPersistence.dataPath | quote }}
|
||||
{{- else }}
|
||||
# In-memory mode (default): Ephemeral storage
|
||||
- name: QDRANT_LOCATION
|
||||
value: ":memory:"
|
||||
{{- end }}
|
||||
- name: QDRANT_COLLECTION
|
||||
value: {{ .Values.qdrant.collection | quote }}
|
||||
# Ollama Embedding Service
|
||||
{{- if or .Values.ollama.enabled .Values.ollama.url }}
|
||||
- name: OLLAMA_BASE_URL
|
||||
value: {{ .Values.ollama.url | default (printf "http://%s-ollama:11434" .Release.Name) | quote }}
|
||||
- name: OLLAMA_EMBEDDING_MODEL
|
||||
value: {{ .Values.ollama.embeddingModel | quote }}
|
||||
- name: OLLAMA_VERIFY_SSL
|
||||
value: {{ .Values.ollama.verifySsl | quote }}
|
||||
{{- end }}
|
||||
# OpenAI Embedding Provider (alternative to Ollama)
|
||||
{{- if .Values.openai.enabled }}
|
||||
- name: OPENAI_API_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ .Values.openai.existingSecret | default (printf "%s-openai" (include "nextcloud-mcp-server.fullname" .)) }}
|
||||
key: {{ .Values.openai.secretKey }}
|
||||
{{- if .Values.openai.baseUrl }}
|
||||
- name: OPENAI_BASE_URL
|
||||
value: {{ .Values.openai.baseUrl | quote }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
# Observability
|
||||
- name: METRICS_ENABLED
|
||||
value: {{ .Values.observability.metrics.enabled | quote }}
|
||||
- name: METRICS_PORT
|
||||
value: {{ .Values.observability.metrics.port | quote }}
|
||||
{{- if .Values.observability.tracing.enabled }}
|
||||
- name: OTEL_EXPORTER_OTLP_ENDPOINT
|
||||
value: {{ .Values.observability.tracing.endpoint | quote }}
|
||||
- name: OTEL_SERVICE_NAME
|
||||
value: {{ .Values.observability.tracing.serviceName | quote }}
|
||||
- name: OTEL_TRACES_SAMPLER_ARG
|
||||
value: {{ .Values.observability.tracing.samplingRate | quote }}
|
||||
{{- end }}
|
||||
- name: LOG_FORMAT
|
||||
value: {{ .Values.observability.logging.format | quote }}
|
||||
- name: LOG_LEVEL
|
||||
value: {{ .Values.observability.logging.level | quote }}
|
||||
- name: LOG_INCLUDE_TRACE_CONTEXT
|
||||
value: {{ .Values.observability.logging.includeTraceContext | quote }}
|
||||
{{- 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 }}
|
||||
{{- if and (eq .Values.qdrant.mode "persistent") .Values.qdrant.localPersistence.enabled }}
|
||||
- name: qdrant-data
|
||||
mountPath: /app/data
|
||||
{{- 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 }}
|
||||
{{- if and (eq .Values.qdrant.mode "persistent") .Values.qdrant.localPersistence.enabled }}
|
||||
- name: qdrant-data
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ include "nextcloud-mcp-server.qdrantPvcName" . }}
|
||||
{{- 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,11 @@
|
||||
{{- if and .Values.openai.enabled (not .Values.openai.existingSecret) }}
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: {{ include "nextcloud-mcp-server.fullname" . }}-openai
|
||||
labels:
|
||||
{{- include "nextcloud-mcp-server.labels" . | nindent 4 }}
|
||||
type: Opaque
|
||||
data:
|
||||
{{ .Values.openai.secretKey }}: {{ .Values.openai.apiKey | b64enc | quote }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,92 @@
|
||||
{{- if and .Values.observability.metrics.enabled .Values.prometheusRule.enabled }}
|
||||
apiVersion: monitoring.coreos.com/v1
|
||||
kind: PrometheusRule
|
||||
metadata:
|
||||
name: {{ include "nextcloud-mcp-server.fullname" . }}
|
||||
namespace: {{ .Release.Namespace }}
|
||||
labels:
|
||||
{{- include "nextcloud-mcp-server.labels" . | nindent 4 }}
|
||||
{{- with .Values.prometheusRule.labels }}
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
groups:
|
||||
- name: nextcloud-mcp-server.critical
|
||||
interval: 30s
|
||||
rules:
|
||||
- alert: NextcloudMCPServerDown
|
||||
expr: up{job="{{ include "nextcloud-mcp-server.fullname" . }}"} == 0
|
||||
for: 5m
|
||||
labels:
|
||||
severity: critical
|
||||
annotations:
|
||||
summary: "Nextcloud MCP Server is down"
|
||||
description: "{{ `{{` }} $labels.pod {{ `}}` }} has been down for more than 5 minutes."
|
||||
|
||||
- alert: NextcloudMCPHighErrorRate
|
||||
expr: |
|
||||
sum(rate(mcp_http_requests_total{status_code=~"5..", job="{{ include "nextcloud-mcp-server.fullname" . }}"}[5m]))
|
||||
/ sum(rate(mcp_http_requests_total{job="{{ include "nextcloud-mcp-server.fullname" . }}"}[5m])) > 0.05
|
||||
for: 5m
|
||||
labels:
|
||||
severity: critical
|
||||
annotations:
|
||||
summary: "High error rate on Nextcloud MCP Server"
|
||||
description: "Error rate is {{ `{{` }} printf \"%.2f%%\" (mul $value 100) {{ `}}` }} (threshold: 5%)"
|
||||
|
||||
- alert: NextcloudMCPHighLatency
|
||||
expr: |
|
||||
histogram_quantile(0.95,
|
||||
sum(rate(mcp_http_request_duration_seconds_bucket{job="{{ include "nextcloud-mcp-server.fullname" . }}"}[5m])) by (le, endpoint)
|
||||
) > 1
|
||||
for: 5m
|
||||
labels:
|
||||
severity: critical
|
||||
annotations:
|
||||
summary: "High latency on Nextcloud MCP Server"
|
||||
description: "P95 latency is {{ `{{` }} printf \"%.2fs\" $value {{ `}}` }} on {{ `{{` }} $labels.endpoint {{ `}}` }} (threshold: 1s)"
|
||||
|
||||
- alert: NextcloudMCPDependencyDown
|
||||
expr: mcp_dependency_health{job="{{ include "nextcloud-mcp-server.fullname" . }}"} == 0
|
||||
for: 2m
|
||||
labels:
|
||||
severity: critical
|
||||
annotations:
|
||||
summary: "Nextcloud MCP dependency is down"
|
||||
description: "Dependency {{ `{{` }} $labels.dependency {{ `}}` }} has been down for more than 2 minutes."
|
||||
|
||||
- name: nextcloud-mcp-server.warning
|
||||
interval: 30s
|
||||
rules:
|
||||
- alert: NextcloudMCPTokenValidationErrors
|
||||
expr: |
|
||||
sum(rate(mcp_oauth_token_validations_total{result="error", job="{{ include "nextcloud-mcp-server.fullname" . }}"}[10m]))
|
||||
/ sum(rate(mcp_oauth_token_validations_total{job="{{ include "nextcloud-mcp-server.fullname" . }}"}[10m])) > 0.01
|
||||
for: 10m
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "High token validation error rate"
|
||||
description: "Token validation error rate is {{ `{{` }} printf \"%.2f%%\" (mul $value 100) {{ `}}` }} (threshold: 1%)"
|
||||
|
||||
- alert: NextcloudMCPVectorSyncQueueHigh
|
||||
expr: mcp_vector_sync_queue_size{job="{{ include "nextcloud-mcp-server.fullname" . }}"} > 100
|
||||
for: 15m
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "Vector sync queue is high"
|
||||
description: "Vector sync queue size is {{ `{{` }} $value {{ `}}` }} (threshold: 100)"
|
||||
|
||||
- alert: NextcloudMCPQdrantSlowQueries
|
||||
expr: |
|
||||
histogram_quantile(0.95,
|
||||
sum(rate(mcp_db_operation_duration_seconds_bucket{db="qdrant", job="{{ include "nextcloud-mcp-server.fullname" . }}"}[10m])) by (le)
|
||||
) > 0.5
|
||||
for: 10m
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "Qdrant queries are slow"
|
||||
description: "P95 Qdrant query latency is {{ `{{` }} printf \"%.2fs\" $value {{ `}}` }} (threshold: 0.5s)"
|
||||
{{- end }}
|
||||
@@ -0,0 +1,35 @@
|
||||
{{- 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 }}
|
||||
---
|
||||
{{- if and (eq .Values.qdrant.mode "persistent") .Values.qdrant.localPersistence.enabled (not .Values.qdrant.localPersistence.existingClaim) }}
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: {{ include "nextcloud-mcp-server.fullname" . }}-qdrant-data
|
||||
labels:
|
||||
{{- include "nextcloud-mcp-server.labels" . | nindent 4 }}
|
||||
spec:
|
||||
accessModes:
|
||||
- {{ .Values.qdrant.localPersistence.accessMode }}
|
||||
{{- if .Values.qdrant.localPersistence.storageClass }}
|
||||
storageClassName: {{ .Values.qdrant.localPersistence.storageClass }}
|
||||
{{- end }}
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ .Values.qdrant.localPersistence.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,25 @@
|
||||
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
|
||||
{{- if .Values.observability.metrics.enabled }}
|
||||
- port: {{ .Values.observability.metrics.port }}
|
||||
targetPort: metrics
|
||||
protocol: TCP
|
||||
name: metrics
|
||||
{{- end }}
|
||||
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,32 @@
|
||||
{{- if and .Values.observability.metrics.enabled .Values.serviceMonitor.enabled }}
|
||||
apiVersion: monitoring.coreos.com/v1
|
||||
kind: ServiceMonitor
|
||||
metadata:
|
||||
name: {{ include "nextcloud-mcp-server.fullname" . }}
|
||||
namespace: {{ .Release.Namespace }}
|
||||
labels:
|
||||
{{- include "nextcloud-mcp-server.labels" . | nindent 4 }}
|
||||
{{- with .Values.serviceMonitor.labels }}
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "nextcloud-mcp-server.selectorLabels" . | nindent 6 }}
|
||||
endpoints:
|
||||
- port: metrics
|
||||
path: {{ .Values.observability.metrics.path }}
|
||||
interval: {{ .Values.serviceMonitor.interval }}
|
||||
scrapeTimeout: {{ .Values.serviceMonitor.scrapeTimeout }}
|
||||
scheme: http
|
||||
relabelings:
|
||||
# Add namespace label
|
||||
- sourceLabels: [__meta_kubernetes_namespace]
|
||||
targetLabel: namespace
|
||||
# Add pod label
|
||||
- sourceLabels: [__meta_kubernetes_pod_name]
|
||||
targetLabel: pod
|
||||
# Add service label
|
||||
- sourceLabels: [__meta_kubernetes_service_name]
|
||||
targetLabel: service
|
||||
{{- end }}
|
||||
@@ -0,0 +1,465 @@
|
||||
# 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
|
||||
|
||||
# Observability Configuration
|
||||
observability:
|
||||
# Prometheus metrics
|
||||
metrics:
|
||||
enabled: true
|
||||
port: 9090
|
||||
path: /metrics
|
||||
|
||||
# OpenTelemetry tracing
|
||||
tracing:
|
||||
enabled: false
|
||||
endpoint: "" # e.g., "http://opentelemetry-collector:4317"
|
||||
serviceName: "nextcloud-mcp-server"
|
||||
samplingRate: 1.0
|
||||
|
||||
# Logging configuration
|
||||
logging:
|
||||
format: json # "json" or "text"
|
||||
level: INFO
|
||||
includeTraceContext: true
|
||||
|
||||
# Prometheus ServiceMonitor (requires Prometheus Operator)
|
||||
serviceMonitor:
|
||||
enabled: false
|
||||
interval: 30s
|
||||
scrapeTimeout: 10s
|
||||
labels: {}
|
||||
# Additional labels for ServiceMonitor (e.g., for Prometheus selector)
|
||||
# Example: { prometheus: kube-prometheus }
|
||||
|
||||
# Prometheus alert rules (requires Prometheus Operator)
|
||||
prometheusRule:
|
||||
enabled: false
|
||||
labels: {}
|
||||
# Additional labels for PrometheusRule (e.g., for Prometheus selector)
|
||||
# Example: { prometheus: kube-prometheus }
|
||||
|
||||
# Grafana dashboards (requires Grafana with sidecar enabled)
|
||||
dashboards:
|
||||
# Enable automatic dashboard provisioning via ConfigMap
|
||||
enabled: false
|
||||
# Grafana folder name where dashboards will be imported
|
||||
# The grafana-sidecar looks for ConfigMaps with label "grafana_dashboard: 1"
|
||||
# and reads the folder name from annotation "grafana_folder" (supports spaces)
|
||||
grafanaFolder: "Nextcloud MCP"
|
||||
# Additional labels for dashboard ConfigMap
|
||||
# These will be added alongside the required "grafana_dashboard: 1" label
|
||||
labels: {}
|
||||
# Additional annotations for dashboard ConfigMap
|
||||
annotations: {}
|
||||
|
||||
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
|
||||
|
||||
# Vector Sync Configuration
|
||||
# Background synchronization of Nextcloud content into vector database for semantic search
|
||||
vectorSync:
|
||||
# Enable background vector synchronization
|
||||
enabled: false
|
||||
# Scan interval in seconds (how often to check for changes)
|
||||
scanInterval: 3600
|
||||
# Number of concurrent processor workers
|
||||
processorWorkers: 3
|
||||
# Maximum queue size for documents pending indexing
|
||||
queueMaxSize: 10000
|
||||
|
||||
# Document Chunking Configuration
|
||||
# Controls how documents are split into chunks before embedding
|
||||
# Only relevant when vectorSync.enabled is true
|
||||
documentChunking:
|
||||
# Number of words per chunk (default: 512)
|
||||
# Smaller chunks (256-384): Better for precise searches, more chunks to store
|
||||
# Medium chunks (512-768): Balanced approach (recommended for most use cases)
|
||||
# Larger chunks (1024+): Better for context, less precise matching
|
||||
chunkSize: 512
|
||||
# Number of overlapping words between chunks (default: 50)
|
||||
# Recommended: 10-20% of chunkSize for context preservation across boundaries
|
||||
# Must be less than chunkSize
|
||||
chunkOverlap: 50
|
||||
|
||||
# Qdrant Vector Database Configuration
|
||||
# Three deployment modes available:
|
||||
# 1. Local In-Memory: Fast, ephemeral, zero-config (mode: "memory")
|
||||
# 2. Local Persistent: File-based, survives restarts (mode: "persistent")
|
||||
# 3. Network: Dedicated Qdrant service, production-ready (mode: "network")
|
||||
qdrant:
|
||||
# Qdrant mode: "memory", "persistent", or "network"
|
||||
# - memory: In-memory storage (:memory:) - default, zero config, data lost on restart
|
||||
# - persistent: Local file storage - data persists across restarts, suitable for small/medium deployments
|
||||
# - network: Dedicated Qdrant service (see networkMode below)
|
||||
mode: "memory"
|
||||
|
||||
# Collection name for vector data
|
||||
collection: "nextcloud_content"
|
||||
|
||||
# Local persistent mode configuration (only used when mode: "persistent")
|
||||
localPersistence:
|
||||
# Enable persistent volume for local Qdrant data
|
||||
enabled: true
|
||||
# Storage class (leave empty for default)
|
||||
storageClass: ""
|
||||
accessMode: ReadWriteOnce
|
||||
# Size for local Qdrant storage
|
||||
size: 1Gi
|
||||
# Path where Qdrant data is stored (relative to /app/data)
|
||||
# Default: /app/data/qdrant
|
||||
dataPath: "/app/data/qdrant"
|
||||
# Use existing PVC
|
||||
existingClaim: ""
|
||||
|
||||
# Network mode configuration (only used when mode: "network")
|
||||
networkMode:
|
||||
# Deploy Qdrant as a subchart (if true) or use external Qdrant (if false)
|
||||
deploySubchart: false
|
||||
# External Qdrant URL (used when deploySubchart: false)
|
||||
# Example: "http://qdrant.default.svc.cluster.local:6333"
|
||||
externalUrl: ""
|
||||
# Optional API key for Qdrant authentication
|
||||
apiKey: ""
|
||||
# Use existing secret for API key
|
||||
existingSecret: ""
|
||||
secretKey: "api-key"
|
||||
|
||||
# Qdrant subchart configuration (only used when mode: "network" and networkMode.deploySubchart: true)
|
||||
# All values are passed through to the qdrant/qdrant chart.
|
||||
# See https://github.com/qdrant/qdrant-helm for full configuration options.
|
||||
subchart:
|
||||
# Number of Qdrant replicas
|
||||
replicaCount: 1
|
||||
image:
|
||||
# Qdrant version
|
||||
tag: v1.12.5
|
||||
config:
|
||||
cluster:
|
||||
# Enable distributed cluster mode
|
||||
enabled: false
|
||||
# Persistent storage for vector data
|
||||
persistence:
|
||||
size: 10Gi
|
||||
storageClass: ""
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
# Resource limits and requests
|
||||
resources:
|
||||
requests:
|
||||
cpu: 200m
|
||||
memory: 512Mi
|
||||
limits:
|
||||
cpu: 1000m
|
||||
memory: 2Gi
|
||||
|
||||
# Ollama Embedding Service
|
||||
# Deployed as a subchart when enabled. All values are passed through to the ollama/ollama chart.
|
||||
# See https://github.com/otwld/ollama-helm for full configuration options.
|
||||
ollama:
|
||||
# Enable Ollama subchart deployment
|
||||
# Set to true to deploy Ollama as a subchart, or false to use an external Ollama instance
|
||||
enabled: false
|
||||
# External Ollama URL (use this if you have Ollama deployed elsewhere)
|
||||
# When set, use enabled: false to prevent deploying the subchart
|
||||
# Example: "http://ollama.default.svc.cluster.local:11434"
|
||||
url: ""
|
||||
# Embedding model to use
|
||||
embeddingModel: "nomic-embed-text"
|
||||
# Verify SSL certificates when connecting to Ollama
|
||||
verifySsl: true
|
||||
# Number of Ollama replicas (only used when subchart is deployed)
|
||||
replicaCount: 1
|
||||
# Ollama configuration (only used when subchart is deployed)
|
||||
ollama:
|
||||
# Models to automatically pull on startup
|
||||
models:
|
||||
pull:
|
||||
- nomic-embed-text
|
||||
# Persistent storage for models (only used when subchart is deployed)
|
||||
persistentVolume:
|
||||
enabled: true
|
||||
size: 20Gi
|
||||
storageClass: ""
|
||||
# Resource limits and requests (only used when subchart is deployed)
|
||||
resources:
|
||||
requests:
|
||||
cpu: 500m
|
||||
memory: 1Gi
|
||||
limits:
|
||||
cpu: 2000m
|
||||
memory: 4Gi
|
||||
|
||||
# OpenAI-compatible Embedding Provider
|
||||
# Alternative to Ollama for embedding generation. Can be used with OpenAI or any compatible API.
|
||||
openai:
|
||||
# Enable OpenAI embedding provider
|
||||
enabled: false
|
||||
# OpenAI API key (only used if existingSecret is not set)
|
||||
apiKey: ""
|
||||
# Name of existing secret containing the API key
|
||||
existingSecret: ""
|
||||
# Key in the secret that contains the API key
|
||||
secretKey: "api-key"
|
||||
# Optional custom API endpoint (e.g., for Azure OpenAI or local compatible services)
|
||||
baseUrl: ""
|
||||
+205
-10
@@ -3,7 +3,7 @@ services:
|
||||
# https://hub.docker.com/_/mariadb
|
||||
db:
|
||||
# Note: Check the recommend version here: https://docs.nextcloud.com/server/latest/admin_manual/installation/system_requirements.html#server
|
||||
image: mariadb:lts@sha256:1d18f91deb21136d1881705720071d1b474a9904ecca827058bf1c0fc64d3118
|
||||
image: docker.io/library/mariadb:lts@sha256:6b848cb24fbbd87429917f6c4422ac53c343e85692eb0fef86553e99e4f422f3
|
||||
restart: always
|
||||
command: --transaction-isolation=READ-COMMITTED
|
||||
volumes:
|
||||
@@ -17,24 +17,24 @@ services:
|
||||
# Note: Redis is an external service. You can find more information about the configuration here:
|
||||
# https://hub.docker.com/_/redis
|
||||
redis:
|
||||
image: redis:alpine@sha256:48501c5ad00d5563bc30c075c7bcef41d7d98de3e9a1e6c752068c66f0a8463b
|
||||
image: docker.io/library/redis:alpine@sha256:28c9c4d7596949a24b183eaaab6455f8e5d55ecbf72d02ff5e2c17fe72671d31
|
||||
restart: always
|
||||
|
||||
app:
|
||||
image: nextcloud:31.0.5@sha256:3f71577339ef1db0d1900c8574853d11fa7100452bf24f0a06fae5d9ee019cb4
|
||||
#user: www-data:www-data
|
||||
image: docker.io/library/nextcloud:32.0.1@sha256:5b043f7ea2f609d5ff5635f475c30d303bec17775a5c3f7fa435e3818e669120
|
||||
restart: always
|
||||
#post_start:
|
||||
#- command: chown -R www-data:www-data /var/www/html && while ! nc -z db 3306; do sleep 1; echo sleeping; done
|
||||
#user: root
|
||||
ports:
|
||||
- 8080:80
|
||||
- 0.0.0.0:8080:80
|
||||
depends_on:
|
||||
- redis
|
||||
- db
|
||||
- keycloak
|
||||
volumes:
|
||||
- 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:
|
||||
- NEXTCLOUD_TRUSTED_DOMAINS=app
|
||||
- NEXTCLOUD_ADMIN_USER=admin
|
||||
@@ -43,16 +43,211 @@ services:
|
||||
- MYSQL_DATABASE=nextcloud
|
||||
- MYSQL_USER=nextcloud
|
||||
- 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:54282d3a25f33fd6cf69bc45b3d37770f213593f58b6dfe5e85fe546376b2807
|
||||
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:
|
||||
build: .
|
||||
restart: always
|
||||
command: ["--transport", "streamable-http"]
|
||||
depends_on:
|
||||
app:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- 8000:8000
|
||||
- 127.0.0.1:8000:8000
|
||||
- 127.0.0.1:9090:9090
|
||||
volumes:
|
||||
- mcp-data:/app/data
|
||||
environment:
|
||||
- NEXTCLOUD_HOST=http://app:80
|
||||
- NEXTCLOUD_USERNAME=admin
|
||||
- NEXTCLOUD_PASSWORD=admin
|
||||
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8080
|
||||
|
||||
# Vector sync configuration (ADR-007)
|
||||
- VECTOR_SYNC_ENABLED=true
|
||||
- VECTOR_SYNC_SCAN_INTERVAL=60
|
||||
- VECTOR_SYNC_PROCESSOR_WORKERS=1
|
||||
|
||||
#- LOG_FORMAT=json
|
||||
|
||||
# Qdrant configuration (three modes):
|
||||
# 1. Network mode: Set QDRANT_URL=http://qdrant:6333 (requires qdrant service)
|
||||
# 2. In-memory mode: Set QDRANT_LOCATION=:memory: (default if nothing set)
|
||||
# 3. Persistent local: Set QDRANT_LOCATION=/app/data/qdrant (stored in mcp-data volume)
|
||||
#- QDRANT_LOCATION=/app/data/qdrant # In-memory mode used if not set
|
||||
#- QDRANT_URL=http://qdrant:6333 # Uncomment for network mode
|
||||
#- QDRANT_API_KEY=${QDRANT_API_KEY:-my_secret_api_key} # Only for network mode
|
||||
|
||||
# Observability
|
||||
#- OTEL_SERVICE_NAME=nextcloud-mcp-docker-compose
|
||||
#- OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317
|
||||
|
||||
# Collection naming: Auto-generated as {deployment-id}-{model-name}
|
||||
# - Deployment ID: OTEL_SERVICE_NAME (if set) or hostname (fallback)
|
||||
# - Model name: OLLAMA_EMBEDDING_MODEL
|
||||
# - Example: "nextcloud-mcp-server-nomic-embed-text"
|
||||
# - Changing models creates new collection (requires re-embedding)
|
||||
# - Set QDRANT_COLLECTION to override auto-generation:
|
||||
#- QDRANT_COLLECTION=nextcloud_content
|
||||
|
||||
# Ollama configuration (optional - uses SimpleEmbeddingProvider if not set)
|
||||
# - OLLAMA_BASE_URL=http://ollama:11434
|
||||
# - OLLAMA_EMBEDDING_MODEL=nomic-embed-text # Changing this creates new collection
|
||||
# - OLLAMA_VERIFY_SSL=false
|
||||
|
||||
# Document chunking configuration (for vector embeddings)
|
||||
# Tune these based on your embedding model and content type
|
||||
# - DOCUMENT_CHUNK_SIZE=512 # Words per chunk (default: 512)
|
||||
# - DOCUMENT_CHUNK_OVERLAP=50 # Overlapping words (default: 50, recommended: 10-20% of chunk size)
|
||||
|
||||
mcp-oauth:
|
||||
build: .
|
||||
command: ["--transport", "streamable-http", "--oauth", "--port", "8001", "--oauth-token-type", "jwt"]
|
||||
restart: always
|
||||
depends_on:
|
||||
app:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- 127.0.0.1:8001:8001
|
||||
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_MCP_SERVER_URL=http://localhost:8001
|
||||
- NEXTCLOUD_RESOURCE_URI=http://localhost:8080 # ADR-005: Nextcloud resource identifier for audience validation
|
||||
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8080
|
||||
- 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
|
||||
|
||||
# ADR-005: Multi-audience mode (default - ENABLE_TOKEN_EXCHANGE=false)
|
||||
# Tokens must contain BOTH MCP and Nextcloud audiences
|
||||
# No token exchange needed - tokens work for both MCP auth and Nextcloud APIs
|
||||
|
||||
# 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:
|
||||
- oauth-client-storage:/app/.oauth
|
||||
- oauth-tokens:/app/data
|
||||
|
||||
keycloak:
|
||||
image: quay.io/keycloak/keycloak:26.4.5@sha256:653852bfdea2be6e958b9e90a976eff1c6de34edd55f2f679bdc48ef16bc528e
|
||||
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
|
||||
- NEXTCLOUD_OIDC_CLIENT_ID=nextcloud-mcp-server
|
||||
- NEXTCLOUD_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_RESOURCE_URI=nextcloud # ADR-005: Keycloak uses client IDs as audiences, not URLs
|
||||
- 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
|
||||
|
||||
# ADR-005: Token exchange mode (RFC 8693)
|
||||
# Exchange MCP tokens (aud: nextcloud-mcp-server) for Nextcloud tokens (aud: http://localhost:8080)
|
||||
# Provides strict audience separation between MCP session and Nextcloud API access
|
||||
- ENABLE_TOKEN_EXCHANGE=true
|
||||
- TOKEN_EXCHANGE_CACHE_TTL=300 # Cache exchanged tokens for 5 minutes (default)
|
||||
|
||||
# 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
|
||||
|
||||
qdrant:
|
||||
image: qdrant/qdrant:v1.15.5@sha256:0fb8897412abc81d1c0430a899b9a81eb8328aa634e7242d1bc804c1fe8fe863
|
||||
restart: always
|
||||
ports:
|
||||
- 127.0.0.1:6333:6333 # REST API
|
||||
- 127.0.0.1:6334:6334 # gRPC (optional)
|
||||
volumes:
|
||||
- qdrant-data:/qdrant/storage
|
||||
environment:
|
||||
- QDRANT__SERVICE__API_KEY=${QDRANT_API_KEY:-my_secret_api_key}
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "test -f /qdrant/.qdrant-initialized"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
profiles:
|
||||
- qdrant
|
||||
|
||||
volumes:
|
||||
nextcloud:
|
||||
db:
|
||||
oauth-client-storage:
|
||||
oauth-tokens:
|
||||
keycloak-tokens:
|
||||
keycloak-oauth-storage:
|
||||
qdrant-data:
|
||||
mcp-data:
|
||||
|
||||
@@ -0,0 +1,964 @@
|
||||
# ADR-002: Vector Database Background Sync Authentication
|
||||
|
||||
> **⚠️ DEPRECATED**: This ADR has been superseded by [ADR-004: MCP Server as OAuth Client for Offline Access](./ADR-004-mcp-application-oauth.md).
|
||||
>
|
||||
> **Reason for Deprecation**: This ADR fundamentally misunderstood the MCP protocol's authentication architecture. The MCP server receives tokens from clients but cannot initiate OAuth flows or store refresh tokens, making the proposed solutions ineffective for true offline access. ADR-004 provides the correct architectural pattern where the MCP server acts as its own OAuth client.
|
||||
|
||||
## Status
|
||||
~~Accepted - Tier 2 (Token Exchange with Delegation) Implemented~~
|
||||
**Superseded by ADR-004** - The token exchange implementation exists but doesn't solve the offline access problem.
|
||||
|
||||
**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,65 @@
|
||||
Excellent and incredibly thorough work on ADR-004. It outlines a robust, secure, and modern approach to federated authentication that aligns with industry best practices. The Progressive Consent architecture with dual OAuth flows is the right direction for a system with these requirements.
|
||||
|
||||
Here is a review of the current implementation in light of the architecture proposed in the ADR.
|
||||
|
||||
### High-Level Assessment
|
||||
|
||||
The project is in a good state, with a clear vision for its authentication architecture. The current implementation provides a backward-compatible "Hybrid Flow" while also containing the scaffolding for the target "Progressive Consent" flow. The hybrid flow is well-tested, which is a great foundation.
|
||||
|
||||
The following points are intended to help bridge the gap between the current implementation and the final vision outlined in ADR-004.
|
||||
|
||||
### Critical Security Review
|
||||
|
||||
#### 1. Missing Token Audience (`aud`) Validation
|
||||
|
||||
This is the most critical issue. The `require_scopes` decorator currently checks for scopes but does not validate the `audience` (`aud` claim) of the incoming JWT.
|
||||
|
||||
* **Risk:** This creates a "confused deputy" vulnerability. An access token issued for a different application could be used to access the MCP server, as long as the scope names happen to match.
|
||||
* **ADR Reference:** The ADR correctly identifies this and proposes an `MCPTokenVerifier` that validates `aud: "mcp-server"`.
|
||||
* **Recommendation:** Implement the audience validation as a central part of your token verification middleware. An incoming token should be rejected immediately if its audience is not `mcp-server`. This check should happen before any tool-specific scope checks.
|
||||
|
||||
### Architecture and Implementation Review
|
||||
|
||||
#### 2. Progressive Consent Flow is Untested
|
||||
|
||||
The code for the Progressive Consent flow (behind the `ENABLE_PROGRESSIVE_CONSENT` flag) exists in `oauth_routes.py` and `oauth_tools.py`. However, there are no integration tests to validate it.
|
||||
|
||||
* **Risk:** Given the complexity of OAuth flows, it's likely there are bugs in the untested implementation.
|
||||
* **Recommendation:** Create a new test file, `test_adr004_progressive_flow.py`, that uses Playwright to test the dual-flow architecture end-to-end:
|
||||
1. **Flow 1:** A test MCP client authenticates directly with the IdP to get an `mcp-server` token.
|
||||
2. **Provisioning Check:** The test verifies that calling a Nextcloud tool fails with a `ProvisioningRequiredError`.
|
||||
3. **Flow 2:** The test calls the `provision_nextcloud_access` tool and automates the second OAuth flow to grant the server offline access.
|
||||
4. **Tool Execution:** The test verifies that Nextcloud tools can now be successfully called.
|
||||
|
||||
#### 3. Inconsistent Authorization URL Generation
|
||||
|
||||
There is duplicated and inconsistent logic for generating the IdP authorization URL.
|
||||
|
||||
* **Location 1:** `oauth_tools.py` in `generate_oauth_url_for_flow2` hardcodes the authorization endpoint path.
|
||||
* **Location 2:** `oauth_routes.py` in `oauth_authorize_nextcloud` correctly uses the OIDC discovery document to find the `authorization_endpoint`.
|
||||
* **Risk:** The hardcoded path is brittle and will break with IdPs that use different endpoint paths (like Keycloak).
|
||||
* **Recommendation:** Consolidate this logic. The `provision_nextcloud_access` tool should not build the URL itself. Instead, it should return a URL pointing to the MCP server's own `/oauth/authorize-nextcloud` endpoint. This endpoint (which you've already created as `oauth_authorize_nextcloud` in `oauth_routes.py`) can then be the single source of truth for generating the IdP redirect.
|
||||
|
||||
#### 4. Poor User Experience due to Missing Token Refresh
|
||||
|
||||
The `/oauth/token` endpoint does not implement the `refresh_token` grant type. This means that when the client's `mcp-server` access token expires (e.g., after one hour), the user must go through the entire browser-based login flow again.
|
||||
|
||||
* **Risk:** This creates a frustrating user experience, especially for long-lived desktop clients.
|
||||
* **ADR Reference:** A proper Flow 1 should result in the MCP client receiving both an access token and a refresh token from the IdP.
|
||||
* **Recommendation:**
|
||||
1. Ensure the IdP is configured to issue refresh tokens to the MCP client for Flow 1.
|
||||
2. The MCP client should securely store this refresh token.
|
||||
3. The client should use the refresh token to get new `mcp-server` access tokens directly from the IdP, without involving the MCP server or the user. The MCP server should not be involved in the client's session management with the IdP.
|
||||
|
||||
### Summary
|
||||
|
||||
The project is on the right track. The ADR is a solid plan, and the initial implementation is a good starting point.
|
||||
|
||||
My recommendations in order of priority are:
|
||||
|
||||
1. **Implement Audience Validation** to close the security gap.
|
||||
2. **Add Integration Tests** for the Progressive Consent flow.
|
||||
3. **Refactor the client-side token refresh** to improve user experience.
|
||||
4. **Consolidate the URL generation** logic to fix the inconsistency.
|
||||
|
||||
Addressing these points will align the implementation with the excellent vision in ADR-004 and result in a secure, robust, and user-friendly system.
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,865 @@
|
||||
# ADR-006: Progressive Consent via URL Elicitation (SEP-1036)
|
||||
|
||||
**Status**: Partially Implemented (Interim Workaround)
|
||||
**Date**: 2025-01-05 (Updated: 2025-01-07)
|
||||
**Related**: [SEP-1036](https://github.com/modelcontextprotocol/specification/pull/887), ADR-004
|
||||
**Depends On**: ADR-005 (token validation)
|
||||
|
||||
## Context
|
||||
|
||||
### What is Progressive Consent?
|
||||
|
||||
**Progressive consent is a mechanism, not a feature**. It describes HOW users grant the MCP server access to Nextcloud resources through OAuth elicitation. The server can operate in two modes:
|
||||
|
||||
1. **Pass-through mode (ENABLE_OFFLINE_ACCESS=false)**:
|
||||
- No refresh tokens requested or stored
|
||||
- Server passes through client's access token to Nextcloud
|
||||
- No provisioning tools available
|
||||
- Suitable for stateless, client-driven operations
|
||||
|
||||
2. **Offline access mode (ENABLE_OFFLINE_ACCESS=true)**:
|
||||
- Server requests `offline_access` scope and stores refresh tokens
|
||||
- Enables background operations and server-initiated API calls
|
||||
- Provisioning tools available (`provision_nextcloud_access`, `check_logged_in`)
|
||||
- Requires explicit user consent via OAuth Flow 2
|
||||
|
||||
**Single-user mode (BasicAuth)** doesn't use progressive consent at all - credentials are directly available.
|
||||
|
||||
### Current User Experience Issues
|
||||
|
||||
The current offline access provisioning flow (ADR-004) requires users to manually visit OAuth URLs returned by MCP tools. This creates a poor user experience:
|
||||
|
||||
1. User calls `provision_nextcloud_access` tool
|
||||
2. Tool returns a URL as text in the response
|
||||
3. User must manually copy URL and open in browser
|
||||
4. No indication when provisioning is complete
|
||||
5. User must retry the original operation manually
|
||||
|
||||
### SEP-1036: URL Mode Elicitation
|
||||
|
||||
The MCP specification now supports **URL mode elicitation** ([SEP-1036](https://github.com/modelcontextprotocol/specification/pull/887)), which enables servers to:
|
||||
|
||||
- Request out-of-band user interactions via secure URLs
|
||||
- Handle sensitive operations like OAuth flows without exposing credentials to the client
|
||||
- Provide progress tracking for async operations
|
||||
- Return errors that automatically trigger elicitation flows
|
||||
|
||||
**Key benefits for progressive consent**:
|
||||
- **Automatic URL Opening**: Client opens URL in browser automatically (with user consent)
|
||||
- **Progress Tracking**: Server can notify client when provisioning is complete
|
||||
- **Error-Triggered Flows**: Server can return `ElicitationRequired` error to trigger provisioning
|
||||
- **Better UX**: User doesn't manually copy/paste URLs
|
||||
|
||||
### Current Implementation Limitations
|
||||
|
||||
The current progressive consent flow in `nextcloud_mcp_server/server/oauth_tools.py`:
|
||||
|
||||
```python
|
||||
@mcp.tool(name="provision_nextcloud_access")
|
||||
async def tool_provision_access(ctx: Context) -> ProvisioningResult:
|
||||
"""Returns OAuth URL as text - user must manually open it."""
|
||||
return ProvisioningResult(
|
||||
success=True,
|
||||
authorization_url=auth_url, # User must copy this
|
||||
message="Please visit the authorization URL..."
|
||||
)
|
||||
```
|
||||
|
||||
**Problems**:
|
||||
1. Manual URL handling (copy/paste)
|
||||
2. No progress tracking
|
||||
3. No automatic retry after provisioning
|
||||
4. Tool call required just to get URL
|
||||
5. No client integration (URL just displayed as text)
|
||||
|
||||
## Decision
|
||||
|
||||
We will **migrate progressive consent from manual tools to URL mode elicitation**, leveraging SEP-1036 for better user experience and OAuth security.
|
||||
|
||||
### New Architecture: Elicitation-Driven Consent
|
||||
|
||||
Instead of explicit tools, use **automatic elicitation** triggered by authorization errors:
|
||||
|
||||
```
|
||||
User → Calls Nextcloud Tool → Server Checks Provisioning
|
||||
↓ Not Provisioned
|
||||
Error: ElicitationRequired
|
||||
↓
|
||||
Client Shows Consent UI
|
||||
↓ User Accepts
|
||||
Client Opens OAuth URL
|
||||
↓
|
||||
User Completes OAuth
|
||||
↓
|
||||
Server Sends Progress Update
|
||||
↓
|
||||
Original Tool Call Auto-Retries
|
||||
```
|
||||
|
||||
### Mode 1: Elicitation-Required Error (Primary)
|
||||
|
||||
When a tool requires provisioning, return an **ElicitationRequired error** (-32000):
|
||||
|
||||
```python
|
||||
# In any Nextcloud tool decorated with @require_provisioning
|
||||
@mcp.tool()
|
||||
@require_provisioning # New decorator
|
||||
async def nc_notes_list_notes(ctx: Context):
|
||||
"""List notes - auto-triggers provisioning if needed."""
|
||||
# If not provisioned, decorator returns ElicitationRequired error
|
||||
# If provisioned, continues normally
|
||||
client = await get_client(ctx)
|
||||
return await client.notes.list_notes()
|
||||
```
|
||||
|
||||
**Error response structure**:
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"error": {
|
||||
"code": -32000,
|
||||
"message": "Nextcloud access provisioning required",
|
||||
"data": {
|
||||
"elicitations": [
|
||||
{
|
||||
"mode": "url",
|
||||
"elicitationId": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"url": "https://mcp.example.com/oauth/provision?id=550e8400...",
|
||||
"message": "Grant the MCP server access to your Nextcloud account to continue."
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Client behavior**:
|
||||
1. Receives error with elicitation
|
||||
2. Shows consent UI: "App wants to access Nextcloud. Open authorization page?"
|
||||
3. On user acceptance, opens URL in browser
|
||||
4. Optionally tracks progress via `elicitation/track`
|
||||
5. Auto-retries original tool call when complete
|
||||
|
||||
### Mode 2: Explicit Elicitation Request (Fallback)
|
||||
|
||||
For clients that don't support error-triggered elicitation, provide explicit tool:
|
||||
|
||||
```python
|
||||
@mcp.tool(name="request_nextcloud_access")
|
||||
async def request_access(ctx: Context) -> ElicitationResponse:
|
||||
"""Explicitly request provisioning via elicitation."""
|
||||
# Send elicitation/create request
|
||||
return await create_elicitation(
|
||||
mode="url",
|
||||
url=generate_oauth_url(),
|
||||
message="Grant access to Nextcloud",
|
||||
elicitation_id=generate_id()
|
||||
)
|
||||
```
|
||||
|
||||
**Note**: This is a fallback for compatibility. Primary flow uses error-triggered elicitation.
|
||||
|
||||
## Implementation
|
||||
|
||||
### 1. New Decorator: `@require_provisioning`
|
||||
|
||||
Replace explicit provisioning checks with a decorator that returns `ElicitationRequired`:
|
||||
|
||||
```python
|
||||
# nextcloud_mcp_server/auth/provisioning_decorator.py
|
||||
|
||||
def require_provisioning(func):
|
||||
"""
|
||||
Decorator that ensures user has provisioned Nextcloud access.
|
||||
|
||||
If not provisioned, returns ElicitationRequired error with OAuth URL.
|
||||
Otherwise, proceeds with normal tool execution.
|
||||
"""
|
||||
@functools.wraps(func)
|
||||
async def wrapper(ctx: Context, *args, **kwargs):
|
||||
# Extract user ID from token
|
||||
user_id = get_user_id_from_context(ctx)
|
||||
|
||||
# Check if provisioned
|
||||
storage = RefreshTokenStorage.from_env()
|
||||
await storage.initialize()
|
||||
|
||||
if not await storage.has_refresh_token(user_id):
|
||||
# Not provisioned - return ElicitationRequired error
|
||||
elicitation_id = str(uuid.uuid4())
|
||||
oauth_url = await generate_oauth_url_for_provisioning(
|
||||
user_id=user_id,
|
||||
elicitation_id=elicitation_id,
|
||||
ctx=ctx
|
||||
)
|
||||
|
||||
# Store elicitation for tracking
|
||||
await storage.store_elicitation(
|
||||
elicitation_id=elicitation_id,
|
||||
user_id=user_id,
|
||||
status="pending",
|
||||
created_at=datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
raise McpError(
|
||||
code=ErrorCode.ELICITATION_REQUIRED, # -32000
|
||||
message="Nextcloud access provisioning required",
|
||||
data={
|
||||
"elicitations": [
|
||||
{
|
||||
"mode": "url",
|
||||
"elicitationId": elicitation_id,
|
||||
"url": oauth_url,
|
||||
"message": (
|
||||
"Grant the MCP server access to your Nextcloud "
|
||||
"account to continue. This is a one-time setup."
|
||||
)
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
# Already provisioned - proceed normally
|
||||
return await func(ctx, *args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
```
|
||||
|
||||
### 2. Elicitation Tracking Endpoint
|
||||
|
||||
Implement `elicitation/track` to provide progress updates:
|
||||
|
||||
```python
|
||||
# nextcloud_mcp_server/server/elicitation.py
|
||||
|
||||
@mcp.request_handler("elicitation/track")
|
||||
async def track_elicitation(
|
||||
elicitation_id: str,
|
||||
_meta: dict = None
|
||||
) -> dict:
|
||||
"""
|
||||
Track progress of an elicitation request.
|
||||
|
||||
Returns when elicitation is complete or times out.
|
||||
"""
|
||||
progress_token = _meta.get("progressToken") if _meta else None
|
||||
|
||||
storage = RefreshTokenStorage.from_env()
|
||||
await storage.initialize()
|
||||
|
||||
# Poll for completion (with timeout)
|
||||
timeout = 300 # 5 minutes
|
||||
start_time = datetime.now(timezone.utc)
|
||||
|
||||
while (datetime.now(timezone.utc) - start_time).seconds < timeout:
|
||||
elicitation = await storage.get_elicitation(elicitation_id)
|
||||
|
||||
if not elicitation:
|
||||
raise McpError(
|
||||
code=-32602, # Invalid params
|
||||
message=f"Unknown elicitation ID: {elicitation_id}"
|
||||
)
|
||||
|
||||
# Send progress notification if token provided
|
||||
if progress_token and elicitation["status"] == "pending":
|
||||
await send_progress_notification(
|
||||
progress_token=progress_token,
|
||||
progress=50,
|
||||
message="Waiting for OAuth authorization..."
|
||||
)
|
||||
|
||||
# Check if complete
|
||||
if elicitation["status"] == "complete":
|
||||
return {"status": "complete"}
|
||||
|
||||
# Check if failed
|
||||
if elicitation["status"] == "failed":
|
||||
return {
|
||||
"status": "failed",
|
||||
"error": elicitation.get("error_message")
|
||||
}
|
||||
|
||||
# Wait before polling again
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# Timeout
|
||||
raise McpError(
|
||||
code=-32000,
|
||||
message="Elicitation timed out - user did not complete authorization"
|
||||
)
|
||||
```
|
||||
|
||||
### 3. OAuth Callback Updates
|
||||
|
||||
Update the OAuth callback to mark elicitations as complete:
|
||||
|
||||
```python
|
||||
# nextcloud_mcp_server/auth/oauth_routes.py
|
||||
|
||||
async def oauth_callback(request: Request) -> Response:
|
||||
"""Handle OAuth callback and mark elicitation complete."""
|
||||
code = request.query_params.get("code")
|
||||
state = request.query_params.get("state")
|
||||
|
||||
# Validate and exchange code for tokens
|
||||
tokens = await exchange_authorization_code(code)
|
||||
|
||||
# Store refresh token
|
||||
await storage.store_refresh_token(
|
||||
user_id=user_id,
|
||||
refresh_token=tokens["refresh_token"]
|
||||
)
|
||||
|
||||
# Mark elicitation as complete
|
||||
elicitation_id = request.query_params.get("elicitation_id")
|
||||
if elicitation_id:
|
||||
await storage.update_elicitation(
|
||||
elicitation_id=elicitation_id,
|
||||
status="complete",
|
||||
completed_at=datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
return Response(
|
||||
content="<h1>Authorization Complete!</h1>"
|
||||
"<p>You can close this window and return to the application.</p>",
|
||||
media_type="text/html"
|
||||
)
|
||||
```
|
||||
|
||||
### 4. Update All Nextcloud Tools
|
||||
|
||||
Add `@require_provisioning` decorator to all Nextcloud tools:
|
||||
|
||||
```python
|
||||
# nextcloud_mcp_server/server/notes.py
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("notes:read")
|
||||
@require_provisioning # NEW: Auto-triggers provisioning
|
||||
async def nc_notes_list_notes(
|
||||
ctx: Context,
|
||||
category: Optional[str] = None
|
||||
) -> NotesListResponse:
|
||||
"""List all notes - automatically handles provisioning."""
|
||||
client = await get_client(ctx)
|
||||
# Tool logic proceeds only if provisioned
|
||||
notes = await client.notes.list_notes(category=category)
|
||||
return NotesListResponse(results=notes)
|
||||
```
|
||||
|
||||
### 5. Capability Declaration
|
||||
|
||||
Declare URL elicitation support during initialization:
|
||||
|
||||
```python
|
||||
# nextcloud_mcp_server/app.py
|
||||
|
||||
capabilities = {
|
||||
"elicitation": {
|
||||
"url": {} # Declare URL mode support
|
||||
# Note: We don't support "form" mode (in-band data collection)
|
||||
},
|
||||
# ... other capabilities
|
||||
}
|
||||
```
|
||||
|
||||
### 6. Environment Variables
|
||||
|
||||
**Primary control**:
|
||||
```bash
|
||||
# ENABLE_OFFLINE_ACCESS: Controls whether server requests refresh tokens and enables provisioning tools
|
||||
# Default: false (pass-through mode)
|
||||
# Set to true to enable offline access mode with Flow 2 provisioning
|
||||
ENABLE_OFFLINE_ACCESS=true
|
||||
```
|
||||
|
||||
**Future variables** (when URL elicitation is implemented):
|
||||
```bash
|
||||
# ELICITATION_CALLBACK_URL: Base URL for OAuth callbacks with elicitation tracking
|
||||
# Default: NEXTCLOUD_MCP_SERVER_URL + /oauth/callback
|
||||
ELICITATION_CALLBACK_URL=http://localhost:8000/oauth/callback
|
||||
|
||||
# ELICITATION_TIMEOUT_SECONDS: How long to wait for user to complete OAuth
|
||||
# Default: 300 (5 minutes)
|
||||
ELICITATION_TIMEOUT_SECONDS=300
|
||||
```
|
||||
|
||||
**Removed variables**:
|
||||
```bash
|
||||
# ENABLE_PROGRESSIVE_CONSENT - Removed. Progressive consent is a mechanism, not a feature toggle.
|
||||
# Use ENABLE_OFFLINE_ACCESS to control whether provisioning tools are available.
|
||||
# MCP_SERVER_CLIENT_ID - merged into OIDC_CLIENT_ID
|
||||
```
|
||||
|
||||
## User Experience Comparison
|
||||
|
||||
### Before (ADR-004 Manual Tools)
|
||||
|
||||
```
|
||||
User: "List my notes"
|
||||
Assistant: *calls nc_notes_list_notes*
|
||||
Server: Error - not provisioned
|
||||
Assistant: "You need to provision access first. Let me do that."
|
||||
Assistant: *calls provision_nextcloud_access*
|
||||
Server: {authorization_url: "https://..."}
|
||||
Assistant: "Please visit this URL: https://..."
|
||||
User: *copies URL, opens browser, completes OAuth*
|
||||
User: "OK, I'm done"
|
||||
Assistant: *calls nc_notes_list_notes again*
|
||||
Server: Success! [notes...]
|
||||
```
|
||||
|
||||
**Issues**: 4 interactions, manual URL handling, no automation
|
||||
|
||||
### After (ADR-006 Elicitation)
|
||||
|
||||
```
|
||||
User: "List my notes"
|
||||
Assistant: *calls nc_notes_list_notes*
|
||||
Server: ElicitationRequired error
|
||||
Client: Shows dialog: "Grant access to Nextcloud? [Yes] [No]"
|
||||
User: *clicks Yes*
|
||||
Client: Opens OAuth URL in browser automatically
|
||||
User: *completes OAuth*
|
||||
Server: Sends progress notification "Complete!"
|
||||
Client: Auto-retries nc_notes_list_notes
|
||||
Server: Success! [notes...]
|
||||
Assistant: "Here are your notes: ..."
|
||||
```
|
||||
|
||||
**Benefits**: 1 interaction, automatic URL opening, seamless retry
|
||||
|
||||
## Migration Path
|
||||
|
||||
### Phase 1: Add Elicitation Support (v0.26.0)
|
||||
|
||||
- Implement `@require_provisioning` decorator
|
||||
- Add `elicitation/track` endpoint
|
||||
- Keep existing tools (`provision_nextcloud_access`) for compatibility
|
||||
- Update OAuth callback to track elicitations
|
||||
- Add capability declaration
|
||||
|
||||
**Breaking changes**: None (additive)
|
||||
|
||||
### Phase 2: Update Documentation (v0.27.0)
|
||||
|
||||
- Document elicitation-based flow as primary
|
||||
- Mark manual tools as deprecated
|
||||
- Update examples and guides
|
||||
|
||||
**Breaking changes**: None (documentation only)
|
||||
|
||||
### Phase 3: Remove Manual Tools (v0.28.0)
|
||||
|
||||
- Remove `provision_nextcloud_access` tool
|
||||
- Remove `check_provisioning_status` tool (status in error message)
|
||||
- Remove `revoke_nextcloud_access` (or keep for explicit revocation?)
|
||||
|
||||
**Breaking changes**: Yes (removed tools)
|
||||
|
||||
### Phase 4: Optimize (v0.29.0+)
|
||||
|
||||
- Add elicitation result caching
|
||||
- Implement retry strategies
|
||||
- Add metrics and monitoring
|
||||
|
||||
## Testing
|
||||
|
||||
### Test Cases
|
||||
|
||||
1. **First-Time User Flow**
|
||||
```python
|
||||
@pytest.mark.oauth
|
||||
async def test_elicitation_first_time_user(nc_mcp_oauth_client):
|
||||
"""Test that first tool call triggers elicitation."""
|
||||
# User has no provisioning
|
||||
with pytest.raises(McpError) as exc:
|
||||
await nc_mcp_oauth_client.call_tool("nc_notes_list_notes")
|
||||
|
||||
# Should get ElicitationRequired error
|
||||
assert exc.value.code == -32000
|
||||
assert "elicitations" in exc.value.data
|
||||
assert exc.value.data["elicitations"][0]["mode"] == "url"
|
||||
|
||||
# Verify URL is valid OAuth URL
|
||||
url = exc.value.data["elicitations"][0]["url"]
|
||||
assert "oauth" in url
|
||||
assert "elicitationId" in url
|
||||
```
|
||||
|
||||
2. **Progress Tracking**
|
||||
```python
|
||||
@pytest.mark.oauth
|
||||
async def test_elicitation_progress_tracking(nc_mcp_oauth_client):
|
||||
"""Test progress tracking during OAuth flow."""
|
||||
# Trigger elicitation
|
||||
elicitation_id = trigger_elicitation()
|
||||
|
||||
# Start tracking
|
||||
track_task = asyncio.create_task(
|
||||
nc_mcp_oauth_client.track_elicitation(
|
||||
elicitation_id=elicitation_id,
|
||||
progress_token="test-token"
|
||||
)
|
||||
)
|
||||
|
||||
# Simulate OAuth completion
|
||||
await asyncio.sleep(1)
|
||||
await complete_oauth_flow(elicitation_id)
|
||||
|
||||
# Track should complete
|
||||
result = await track_task
|
||||
assert result["status"] == "complete"
|
||||
```
|
||||
|
||||
3. **Auto-Retry After Provisioning**
|
||||
```python
|
||||
@pytest.mark.oauth
|
||||
async def test_auto_retry_after_provisioning(nc_mcp_oauth_client):
|
||||
"""Test that client auto-retries after elicitation."""
|
||||
# Mock client that auto-retries on ElicitationRequired
|
||||
client = AutoRetryMcpClient(nc_mcp_oauth_client)
|
||||
|
||||
# First call triggers elicitation, client handles it, retries
|
||||
result = await client.call_tool_with_elicitation("nc_notes_list_notes")
|
||||
|
||||
# Should succeed after provisioning
|
||||
assert result.success
|
||||
assert "notes" in result.data
|
||||
```
|
||||
|
||||
4. **Timeout Handling**
|
||||
```python
|
||||
@pytest.mark.oauth
|
||||
async def test_elicitation_timeout(nc_mcp_oauth_client):
|
||||
"""Test timeout if user doesn't complete OAuth."""
|
||||
elicitation_id = trigger_elicitation()
|
||||
|
||||
# Track with short timeout
|
||||
with pytest.raises(McpError, match="timed out"):
|
||||
await nc_mcp_oauth_client.track_elicitation(
|
||||
elicitation_id=elicitation_id,
|
||||
timeout=5 # 5 seconds
|
||||
)
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Out-of-Band OAuth Flow
|
||||
|
||||
**Benefit**: OAuth credentials never pass through MCP client
|
||||
- User enters credentials directly on IdP page
|
||||
- MCP server receives only authorization code
|
||||
- Client never sees passwords or refresh tokens
|
||||
|
||||
**Threat mitigation**:
|
||||
- **Credential theft**: Client can't intercept credentials (out-of-band)
|
||||
- **Token exposure**: Client never receives Nextcloud refresh tokens
|
||||
- **CSRF**: State parameter validates OAuth callback
|
||||
- **URL tampering**: Elicitation ID ties OAuth flow to user session
|
||||
|
||||
### Elicitation ID as Security Token
|
||||
|
||||
The `elicitationId` serves as a capability token:
|
||||
- Cryptographically random (UUID v4)
|
||||
- Single-use (invalidated after completion)
|
||||
- Time-limited (expires after timeout)
|
||||
- User-scoped (tied to user session)
|
||||
|
||||
**Validation**:
|
||||
```python
|
||||
async def validate_elicitation_id(elicitation_id: str, user_id: str) -> bool:
|
||||
"""Validate that elicitation belongs to user and is still valid."""
|
||||
elicitation = await storage.get_elicitation(elicitation_id)
|
||||
|
||||
if not elicitation:
|
||||
return False
|
||||
|
||||
# Check ownership
|
||||
if elicitation["user_id"] != user_id:
|
||||
logger.warning(f"Elicitation ID mismatch: {elicitation_id}")
|
||||
return False
|
||||
|
||||
# Check expiry
|
||||
if elicitation["expires_at"] < datetime.now(timezone.utc):
|
||||
return False
|
||||
|
||||
# Check not already used
|
||||
if elicitation["status"] != "pending":
|
||||
return False
|
||||
|
||||
return True
|
||||
```
|
||||
|
||||
### Progress Tracking Security
|
||||
|
||||
**Risk**: Progress token reuse across users
|
||||
|
||||
**Mitigation**:
|
||||
- Progress tokens tied to elicitation ID
|
||||
- Elicitation ID tied to user session
|
||||
- Server validates ownership before sending updates
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
1. **Better UX**: Automatic URL opening, no manual copy/paste
|
||||
2. **Seamless Flow**: Auto-retry after provisioning
|
||||
3. **Progress Feedback**: User knows when OAuth is complete
|
||||
4. **Spec Compliance**: Implements SEP-1036 correctly
|
||||
5. **Secure by Design**: Out-of-band OAuth prevents credential exposure
|
||||
6. **Simpler API**: No explicit provisioning tools needed
|
||||
|
||||
### Negative
|
||||
|
||||
1. **Client Dependency**: Requires client support for URL elicitation
|
||||
2. **Complexity**: More moving parts (elicitation tracking, callbacks)
|
||||
3. **Polling**: Progress tracking uses polling (not ideal)
|
||||
4. **Breaking Change**: Removes manual provisioning tools (in v0.28.0)
|
||||
|
||||
### Neutral
|
||||
|
||||
1. **Storage Requirements**: Need to store elicitation state
|
||||
2. **Timeout Management**: Must handle long-running OAuth flows
|
||||
3. **Fallback Support**: Still need compatibility for older clients
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
### 1. Keep Manual Tools Only (Rejected)
|
||||
|
||||
**Pros**: Simple, no client changes needed
|
||||
**Cons**: Poor UX, doesn't leverage SEP-1036
|
||||
|
||||
**Rejection reason**: SEP-1036 provides better UX and security
|
||||
|
||||
### 2. Form Mode Elicitation (Rejected)
|
||||
|
||||
**Pros**: No browser redirect needed
|
||||
**Cons**: Would expose OAuth credentials to client (security violation)
|
||||
|
||||
**Rejection reason**: Form mode only for non-sensitive data per SEP-1036
|
||||
|
||||
### 3. Hybrid: Both Tools and Elicitation (Considered)
|
||||
|
||||
**Pros**: Maximum compatibility, gradual migration
|
||||
**Cons**: API duplication, maintenance burden, confusing for users
|
||||
|
||||
**Decision**: Support during migration (v0.26-0.27), remove in v0.28
|
||||
|
||||
### 4. WebSocket for Progress (Rejected)
|
||||
|
||||
**Pros**: Real-time updates instead of polling
|
||||
**Cons**: MCP spec uses polling pattern, adds complexity
|
||||
|
||||
**Rejection reason**: Follow spec pattern (polling via elicitation/track)
|
||||
|
||||
## Interim Implementation: Inline Form Elicitation (Pre-SEP-1036)
|
||||
|
||||
**Note**: SEP-1036 (URL mode elicitation) is not yet available in the stable MCP Python SDK. As a temporary workaround, we've implemented a simplified version using the current **inline form elicitation** API.
|
||||
|
||||
### What Changed
|
||||
|
||||
Instead of waiting for URL mode elicitation, we implemented a `check_logged_in` tool that:
|
||||
|
||||
1. Checks if the user has completed Flow 2 (resource provisioning)
|
||||
2. If logged in, returns `"yes"`
|
||||
3. If not logged in, uses **inline form elicitation** to prompt the user
|
||||
|
||||
### Implementation Details
|
||||
|
||||
**New Tool**: `check_logged_in`
|
||||
|
||||
```python
|
||||
# nextcloud_mcp_server/server/oauth_tools.py
|
||||
|
||||
class LoginConfirmation(BaseModel):
|
||||
"""Schema for login confirmation elicitation."""
|
||||
acknowledged: bool = Field(
|
||||
default=False,
|
||||
description="Check this box after completing login at the provided URL",
|
||||
)
|
||||
|
||||
@mcp.tool(name="check_logged_in")
|
||||
@require_scopes("openid")
|
||||
async def tool_check_logged_in(ctx: Context, user_id: Optional[str] = None) -> str:
|
||||
"""Check if user is logged in and elicit login if needed."""
|
||||
# Check if already logged in
|
||||
status = await get_provisioning_status(ctx, user_id)
|
||||
if status.is_provisioned:
|
||||
return "yes"
|
||||
|
||||
# Generate OAuth URL for Flow 2
|
||||
auth_url = generate_oauth_url_for_flow2(...)
|
||||
|
||||
# Use inline form elicitation (current MCP API)
|
||||
result = await ctx.elicit(
|
||||
message=f"Please log in to Nextcloud at the following URL:\n\n{auth_url}\n\nAfter completing the login, check the box below and click OK.",
|
||||
schema=LoginConfirmation,
|
||||
)
|
||||
|
||||
if result.action == "accept":
|
||||
# Verify login succeeded
|
||||
status = await get_provisioning_status(ctx, user_id)
|
||||
return "yes" if status.is_provisioned else "Login not detected"
|
||||
elif result.action == "decline":
|
||||
return "Login declined by user."
|
||||
else:
|
||||
return "Login cancelled by user."
|
||||
```
|
||||
|
||||
**OAuth Routes** (added to `app.py`):
|
||||
|
||||
```python
|
||||
# Flow 2 routes for resource provisioning
|
||||
routes.append(
|
||||
Route("/oauth/authorize-nextcloud", oauth_authorize_nextcloud, methods=["GET"])
|
||||
)
|
||||
routes.append(
|
||||
Route("/oauth/callback-nextcloud", oauth_callback_nextcloud, methods=["GET"])
|
||||
)
|
||||
```
|
||||
|
||||
### User Experience
|
||||
|
||||
```
|
||||
User: *calls check_logged_in tool*
|
||||
|
||||
MCP Client: Displays form elicitation
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Please log in to Nextcloud at the following URL: │
|
||||
│ │
|
||||
│ http://localhost:8000/oauth/authorize-nextcloud?... │
|
||||
│ │
|
||||
│ After completing the login, check the box below and │
|
||||
│ click OK. │
|
||||
│ │
|
||||
│ ☐ Check this box after completing login │
|
||||
│ │
|
||||
│ [Accept] [Decline] [Cancel] │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
|
||||
User: *copies URL, opens in browser, completes OAuth*
|
||||
User: *checks box and clicks Accept*
|
||||
|
||||
MCP Server: Verifies login and returns "yes"
|
||||
```
|
||||
|
||||
### Limitations of Interim Approach
|
||||
|
||||
1. **Manual URL Handling**: User must manually copy and paste the URL (not clickable)
|
||||
2. **No Automatic Browser Opening**: Client doesn't automatically open the URL
|
||||
3. **No Progress Tracking**: Can't track OAuth completion status in real-time
|
||||
4. **URL in Message Text**: Login URL embedded in plain text message (not as structured field)
|
||||
5. **Client-Side Confirmation**: Relies on user clicking "OK" after OAuth (honor system)
|
||||
|
||||
### Why Not Use URL Mode Now?
|
||||
|
||||
The current stable MCP Python SDK (`main` branch) only supports **inline form elicitation**:
|
||||
|
||||
```python
|
||||
# Current API (no 'mode' parameter)
|
||||
class ElicitRequestParams(RequestParams):
|
||||
message: str
|
||||
requestedSchema: ElicitRequestedSchema
|
||||
# No 'mode', 'url', or 'elicitationId' fields
|
||||
```
|
||||
|
||||
URL mode elicitation (`mode: "url"`) is only available in the SEP-1036 branch, which has not been merged to `main` yet.
|
||||
|
||||
### Migration to URL Mode (When SEP-1036 Lands)
|
||||
|
||||
Once SEP-1036 is merged and available in the stable SDK, we will migrate to URL mode elicitation:
|
||||
|
||||
**Before (Current Workaround)**:
|
||||
```python
|
||||
result = await ctx.elicit(
|
||||
message=f"Please log in at: {auth_url}\n\nClick OK after login.",
|
||||
schema=LoginConfirmation,
|
||||
)
|
||||
```
|
||||
|
||||
**After (URL Mode)**:
|
||||
```python
|
||||
result = await ctx.session.elicit_url(
|
||||
message="Please log in to Nextcloud to authorize this MCP server.",
|
||||
url=auth_url,
|
||||
elicitation_id=elicitation_id,
|
||||
)
|
||||
```
|
||||
|
||||
**Benefits of migration**:
|
||||
- Automatic URL opening (with user consent)
|
||||
- Clickable URLs in client UI
|
||||
- Progress tracking via `elicitation/track`
|
||||
- Better security (URL not in message text)
|
||||
- Auto-retry support
|
||||
|
||||
### Testing
|
||||
|
||||
Integration tests validate the current inline form elicitation:
|
||||
|
||||
```python
|
||||
# tests/server/oauth/test_login_elicitation.py
|
||||
|
||||
async def test_check_logged_in_already_authenticated(nc_mcp_oauth_client):
|
||||
"""Test immediate 'yes' for authenticated users."""
|
||||
result = await nc_mcp_oauth_client.call_tool("check_logged_in", arguments={})
|
||||
assert "yes" in result.content[0].text.lower()
|
||||
|
||||
async def test_check_logged_in_url_format(nc_mcp_oauth_client):
|
||||
"""Test that login URL (when needed) contains correct OAuth parameters."""
|
||||
result = await nc_mcp_oauth_client.call_tool("check_logged_in", arguments={})
|
||||
response_text = result.content[0].text
|
||||
|
||||
# If URL present, validate OAuth parameters
|
||||
if "http" in response_text:
|
||||
assert "response_type=code" in response_text
|
||||
assert "client_id=" in response_text
|
||||
assert "redirect_uri=" in response_text
|
||||
assert "openid" in response_text
|
||||
```
|
||||
|
||||
### Future Work
|
||||
|
||||
- **Monitor SEP-1036**: Watch for merge to MCP Python SDK `main` branch
|
||||
- **Implement URL Mode**: Once available, migrate `check_logged_in` to use `ctx.session.elicit_url()`
|
||||
- **Add Progress Tracking**: Implement `elicitation/track` endpoint for OAuth completion status
|
||||
- **Implement Error-Triggered Elicitation**: Use `@require_provisioning` decorator to return `ElicitationRequired` errors
|
||||
- **Remove Manual Workaround**: Deprecate inline form approach once URL mode is stable
|
||||
|
||||
## References
|
||||
|
||||
- [SEP-1036: URL Mode Elicitation](https://github.com/modelcontextprotocol/specification/pull/887)
|
||||
- [MCP Elicitation Specification](https://modelcontextprotocol.io/specification/draft/client/elicitation)
|
||||
- [ADR-004: Federated Authentication Architecture](./ADR-004-mcp-application-oauth.md)
|
||||
- [ADR-005: Token Audience Validation](./ADR-005-token-audience-validation.md)
|
||||
- [RFC 8252: OAuth 2.0 for Native Apps](https://datatracker.ietf.org/doc/html/rfc8252)
|
||||
|
||||
## Implementation Checklist
|
||||
|
||||
### Interim Implementation (Inline Form Elicitation)
|
||||
|
||||
- [x] Create `check_logged_in` tool with inline form elicitation
|
||||
- [x] Register Flow 2 OAuth routes (`/oauth/authorize-nextcloud`, `/oauth/callback-nextcloud`)
|
||||
- [x] Write integration tests for login elicitation flow
|
||||
- [x] Update ADR-006 with interim implementation documentation
|
||||
- [x] Add `LoginConfirmation` schema for elicitation
|
||||
- [ ] Run tests to validate implementation
|
||||
|
||||
### Future Work (URL Mode Elicitation - Post SEP-1036)
|
||||
|
||||
- [ ] Implement `@require_provisioning` decorator with ElicitationRequired error
|
||||
- [ ] Add `elicitation/track` request handler
|
||||
- [ ] Update OAuth callback to mark elicitations complete
|
||||
- [ ] Add elicitation storage (ID, user, status, timestamps)
|
||||
- [ ] Update all Nextcloud tools with `@require_provisioning`
|
||||
- [ ] Add URL elicitation capability declaration
|
||||
- [ ] Write tests for progress tracking
|
||||
- [ ] Update documentation with URL mode examples
|
||||
- [ ] Add migration guide for manual tools → elicitation
|
||||
- [ ] Migrate `check_logged_in` from inline form to URL mode
|
||||
- [ ] Keep manual tools with deprecation warnings (v0.26-0.27)
|
||||
- [ ] Remove manual tools (v0.28.0)
|
||||
- [ ] Update CHANGELOG.md with migration timeline
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,647 @@
|
||||
# ADR-008: MCP Sampling for Multi-App Semantic Search with RAG
|
||||
|
||||
**Status**: Proposed
|
||||
**Date**: 2025-01-11
|
||||
**Depends On**: ADR-007 (Background Vector Sync)
|
||||
|
||||
## Context
|
||||
|
||||
ADR-007 established a background synchronization architecture that maintains a vector database of Nextcloud content across multiple apps (notes, calendar, deck, files, contacts), enabling semantic search via the `nc_semantic_search` tool. This tool returns a list of relevant documents with excerpts, similarity scores, and metadata—providing the raw materials for answering user questions.
|
||||
|
||||
However, users typically don't want a list of documents—they want answers to their questions. When a user asks "What are my project goals?" or "When is my next dentist appointment?", they expect a natural language response that synthesizes information from multiple sources and document types, not a ranked list of excerpts. This is the pattern of Retrieval-Augmented Generation (RAG): retrieve relevant context from all Nextcloud apps, then generate a cohesive answer.
|
||||
|
||||
The challenge is: who should generate the answer, and how?
|
||||
|
||||
**Option 1: Server-side LLM**
|
||||
The MCP server could maintain its own LLM connection (OpenAI API, Ollama, etc.), construct prompts from retrieved documents, and return generated answers directly. This approach has significant drawbacks:
|
||||
|
||||
- **Duplicate infrastructure**: MCP clients (like Claude Desktop) already have LLM capabilities. The server would duplicate this with its own LLM integration, API keys, and configuration.
|
||||
- **Cost and billing**: The server operator bears LLM costs for all users, creating billing and quota management challenges.
|
||||
- **Limited model choice**: Users are locked into whatever LLM the server configures. They cannot choose their preferred model or provider.
|
||||
- **Privacy concerns**: User queries and document contents flow through a server-controlled LLM, creating a potential privacy boundary.
|
||||
- **Configuration complexity**: Server operators must configure embedding services (for search) AND generation models (for answers), each with different API keys, rate limits, and failure modes.
|
||||
|
||||
**Option 2: Return documents, let client generate**
|
||||
The server could simply return retrieved documents and rely on the MCP client's existing LLM to generate answers. The user would call `nc_notes_semantic_search`, receive documents, and then the client would include those documents in its context when responding to the user's original question. This approach also has limitations:
|
||||
|
||||
- **Context window waste**: The client must include all document content in its context window, even if only small excerpts are relevant. For 5-10 documents, this can consume significant context space.
|
||||
- **Inconsistent behavior**: Whether the client synthesizes an answer or just displays documents depends on the client's implementation and the user's conversational style. There's no guaranteed answer generation.
|
||||
- **Poor citations**: The client may generate an answer but fail to cite which specific documents were used, making it hard to verify claims.
|
||||
- **User confusion**: Users see a tool that returns "search results" rather than "answers", requiring them to explicitly ask for synthesis.
|
||||
|
||||
**Option 3: MCP Sampling**
|
||||
The Model Context Protocol specification includes a **sampling** capability that allows MCP servers to request LLM completions from their clients. The server constructs a prompt with retrieved context, sends it to the client via `sampling/createMessage`, and the client's LLM generates a response that the server can return as a tool result.
|
||||
|
||||
This approach combines the best of both options:
|
||||
|
||||
- **No server-side LLM**: The server has no API keys, no LLM configuration, no billing concerns.
|
||||
- **User choice**: The MCP client controls which LLM is used (Claude, GPT-4, local Ollama) and who pays for it.
|
||||
- **User transparency**: MCP clients SHOULD present sampling requests to users for approval, making it clear when the server is requesting an LLM call.
|
||||
- **Consistent citations**: The server constructs a prompt that explicitly includes document references, ensuring generated answers cite sources.
|
||||
- **Single tool call**: Users call one tool (`nc_notes_semantic_search_answer`) and receive a complete answer with citations—no multi-turn conversation needed.
|
||||
|
||||
The sampling approach shifts responsibility appropriately: the MCP server is responsible for information retrieval and context construction (its expertise), while the MCP client is responsible for LLM access and user preferences (its expertise). This follows the MCP design philosophy of separating concerns between servers (data access) and clients (user interaction).
|
||||
|
||||
However, sampling introduces new considerations:
|
||||
|
||||
**Client compatibility**: Not all MCP clients implement sampling. The server must gracefully degrade when sampling is unavailable, falling back to returning documents without generated answers.
|
||||
|
||||
**Latency**: Sampling adds a full round-trip to the client and back, plus LLM generation time. A typical flow involves: (1) client calls tool, (2) server retrieves documents, (3) server requests sampling from client, (4) client generates answer, (5) server returns answer to client. This can take 2-5 seconds depending on LLM speed, compared to 100-500ms for document retrieval alone.
|
||||
|
||||
**User approval**: MCP clients SHOULD prompt users to approve sampling requests, allowing users to review the prompt before sending it to their LLM. This is a privacy and security feature (prevents servers from making arbitrary LLM requests) but adds interaction friction.
|
||||
|
||||
**Prompt engineering**: The server must construct effective prompts that guide the LLM to generate useful, well-cited answers. Unlike Option 1 where the server controls the LLM directly, the server has less control over how the prompt is interpreted.
|
||||
|
||||
Despite these considerations, MCP sampling provides the most principled solution for RAG-enhanced semantic search. It respects the client-server boundary, avoids duplicate infrastructure, and delivers the user experience users expect from semantic search tools.
|
||||
|
||||
This ADR proposes adding a new tool, `nc_semantic_search_answer`, that uses MCP sampling to generate natural language answers from retrieved Nextcloud content across all indexed apps (notes, calendar, deck, files, contacts).
|
||||
|
||||
## Decision
|
||||
|
||||
We will implement a new MCP tool `nc_semantic_search_answer` that retrieves relevant documents via vector similarity search across all indexed Nextcloud apps and uses MCP sampling to generate natural language answers. The tool will construct a prompt that includes the user's original query and excerpts from retrieved documents (notes, calendar events, deck cards, files, contacts), request an LLM completion via `ctx.session.create_message()`, and return the generated answer along with source citations.
|
||||
|
||||
The existing `nc_semantic_search` tool will remain unchanged, providing users with a choice: call the original tool for raw document results, or call the new sampling-enhanced tool for generated answers. This dual-tool approach respects different use cases—some users want to browse documents, others want direct answers.
|
||||
|
||||
### API Design
|
||||
|
||||
**Tool Signature**:
|
||||
```python
|
||||
@mcp.tool()
|
||||
@require_scopes("semantic:read")
|
||||
async def nc_semantic_search_answer(
|
||||
query: str,
|
||||
ctx: Context,
|
||||
limit: int = 5,
|
||||
score_threshold: float = 0.7,
|
||||
max_answer_tokens: int = 500,
|
||||
) -> SamplingSearchResponse
|
||||
```
|
||||
|
||||
**Parameters**:
|
||||
- `query`: The user's natural language question
|
||||
- `ctx`: MCP context for session access
|
||||
- `limit`: Maximum documents to retrieve (default 5)
|
||||
- `score_threshold`: Minimum similarity score 0-1 (default 0.7)
|
||||
- `max_answer_tokens`: Maximum tokens for generated answer (default 500)
|
||||
|
||||
**Response Model**:
|
||||
```python
|
||||
class SamplingSearchResponse(BaseResponse):
|
||||
query: str # Original user query
|
||||
generated_answer: str # LLM-generated answer
|
||||
sources: list[SemanticSearchResult] # Supporting documents
|
||||
total_found: int # Total matching documents
|
||||
search_method: str = "semantic_sampling"
|
||||
model_used: str | None = None # Model that generated answer
|
||||
stop_reason: str | None = None # Why generation stopped
|
||||
```
|
||||
|
||||
The response includes both the generated answer (for direct user consumption) and the source documents (for verification and citation). The `model_used` field records which LLM generated the answer, allowing users to understand which model provided the response.
|
||||
|
||||
### Sampling API Usage
|
||||
|
||||
The tool uses the MCP Python SDK's `ServerSession.create_message()` API:
|
||||
|
||||
```python
|
||||
from mcp.types import SamplingMessage, TextContent, ModelPreferences, ModelHint
|
||||
|
||||
# Construct prompt with retrieved context
|
||||
prompt = (
|
||||
f"{query}\n\n"
|
||||
f"Here are relevant documents from Nextcloud (notes, calendar events, deck cards, files, contacts):\n\n"
|
||||
f"{context}\n\n"
|
||||
f"Based on the documents above, please provide a comprehensive answer. "
|
||||
f"Cite the document numbers when referencing specific information."
|
||||
)
|
||||
|
||||
# Request LLM completion via MCP sampling
|
||||
sampling_result = await ctx.session.create_message(
|
||||
messages=[
|
||||
SamplingMessage(
|
||||
role="user",
|
||||
content=TextContent(type="text", text=prompt),
|
||||
)
|
||||
],
|
||||
max_tokens=max_answer_tokens,
|
||||
temperature=0.7,
|
||||
model_preferences=ModelPreferences(
|
||||
hints=[ModelHint(name="claude-3-5-sonnet")],
|
||||
intelligencePriority=0.8,
|
||||
speedPriority=0.5,
|
||||
),
|
||||
include_context="thisServer",
|
||||
)
|
||||
|
||||
# Extract answer from response
|
||||
if sampling_result.content.type == "text":
|
||||
generated_answer = sampling_result.content.text
|
||||
```
|
||||
|
||||
**Key parameters**:
|
||||
- `messages`: Chat-style messages with role ("user" or "assistant") and content
|
||||
- `max_tokens`: Limits response length to control costs and latency
|
||||
- `temperature`: 0.7 balances creativity with consistency for factual answers
|
||||
- `model_preferences`: Hints suggest Claude Sonnet for balanced intelligence/speed
|
||||
- `include_context`: "thisServer" includes MCP server context in client's LLM call
|
||||
|
||||
The `include_context` parameter is particularly important. When set to "thisServer", the MCP client provides its LLM with context about the server's capabilities, tools, and resources. This allows the LLM to reference the Nextcloud MCP server when generating answers, creating more contextually appropriate responses. For example, the LLM might say "Based on your Nextcloud Notes..." rather than generic phrasing.
|
||||
|
||||
### Prompt Construction
|
||||
|
||||
The prompt construction follows a structured template:
|
||||
|
||||
```
|
||||
[User's original query]
|
||||
|
||||
Here are relevant documents from Nextcloud (notes, calendar events, deck cards, files, contacts):
|
||||
|
||||
[Document 1]
|
||||
Type: note
|
||||
Title: Project Kickoff Notes
|
||||
Category: Work
|
||||
Excerpt: The primary goal for Q1 2025 is to improve semantic search...
|
||||
Relevance Score: 0.92
|
||||
|
||||
[Document 2]
|
||||
Type: calendar_event
|
||||
Title: Team Planning Meeting
|
||||
Location: Conference Room A
|
||||
Excerpt: Scheduled for Jan 15 at 2pm. Agenda: Discuss Q1 objectives and timeline...
|
||||
Relevance Score: 0.88
|
||||
|
||||
[Document 3]
|
||||
Type: deck_card
|
||||
Title: Implement semantic search
|
||||
Labels: feature, high-priority
|
||||
Excerpt: This card tracks the semantic search implementation. Due: Jan 30...
|
||||
Relevance Score: 0.85
|
||||
|
||||
Based on the documents above, please provide a comprehensive answer.
|
||||
Cite the document numbers when referencing specific information.
|
||||
```
|
||||
|
||||
This structure ensures:
|
||||
- The user's original query is preserved verbatim
|
||||
- Documents are clearly delineated and numbered for citation
|
||||
- Metadata (title, category, score) provides context
|
||||
- Explicit instruction to cite sources encourages proper attribution
|
||||
|
||||
The prompt is intentionally simple and fixed (not configurable). Allowing users to customize the prompt would complicate the API and introduce prompt injection risks. The fixed structure ensures consistent, well-cited answers across all users.
|
||||
|
||||
### Fallback Behavior
|
||||
|
||||
Sampling may fail for several reasons:
|
||||
- Client doesn't support sampling (e.g., MCP Inspector without callbacks)
|
||||
- User declines the sampling request
|
||||
- Network errors during sampling round-trip
|
||||
- LLM generation errors
|
||||
|
||||
The tool handles all failures gracefully by falling back to returning documents without a generated answer:
|
||||
|
||||
```python
|
||||
try:
|
||||
sampling_result = await ctx.session.create_message(...)
|
||||
generated_answer = sampling_result.content.text
|
||||
except Exception as e:
|
||||
logger.warning(f"Sampling failed: {e}, returning search results only")
|
||||
generated_answer = (
|
||||
f"[Sampling unavailable: {str(e)}]\n\n"
|
||||
f"Found {total_found} relevant documents. Please review the sources below."
|
||||
)
|
||||
```
|
||||
|
||||
This ensures the tool always returns useful information—either a generated answer or the underlying documents—rather than failing completely. The user knows sampling was attempted (via the `[Sampling unavailable]` prefix) and can still access the retrieved context.
|
||||
|
||||
### No Results Handling
|
||||
|
||||
When semantic search finds no relevant documents (all below `score_threshold`), the tool returns a clear message without attempting sampling:
|
||||
|
||||
```python
|
||||
if not search_response.results:
|
||||
return SamplingSearchResponse(
|
||||
query=query,
|
||||
generated_answer="No relevant documents found in your Nextcloud content for this query.",
|
||||
sources=[],
|
||||
total_found=0,
|
||||
search_method="semantic_sampling",
|
||||
success=True,
|
||||
)
|
||||
```
|
||||
|
||||
This avoids wasting a sampling call (and user approval) when there's no content to base an answer on.
|
||||
|
||||
### User Experience Flow
|
||||
|
||||
**Typical successful flow**:
|
||||
1. User calls `nc_semantic_search_answer` with query "What are my Q1 2025 objectives?"
|
||||
2. Server retrieves 5 relevant documents via vector search (2 notes, 2 calendar events, 1 deck card)
|
||||
3. Server constructs prompt with document excerpts showing mixed content types
|
||||
4. Server sends `sampling/createMessage` request to client
|
||||
5. Client prompts user: "MCP server wants to generate an answer using these documents. Allow?"
|
||||
6. User approves (or client auto-approves based on configuration)
|
||||
7. Client sends prompt to LLM (Claude, GPT-4, etc.)
|
||||
8. LLM generates answer with citations: "Based on Document 1 (note: Project Kickoff), Document 2 (calendar: Team Planning Meeting), and Document 3 (deck card: Implement semantic search)..."
|
||||
9. Client returns answer to server
|
||||
10. Server returns `SamplingSearchResponse` with answer and sources
|
||||
11. User sees complete answer with citations across multiple Nextcloud apps
|
||||
|
||||
**Fallback flow** (sampling unavailable):
|
||||
1-3. Same as above
|
||||
4. Server attempts `ctx.session.create_message()`
|
||||
5. Client raises exception: "Sampling not supported"
|
||||
6. Server catches exception, logs warning
|
||||
7. Server returns `SamplingSearchResponse` with documents and "[Sampling unavailable]" message
|
||||
8. User sees raw documents instead of generated answer
|
||||
|
||||
**No results flow**:
|
||||
1-2. Same as above but no documents match threshold
|
||||
3. Server returns `SamplingSearchResponse` with "No relevant documents" message
|
||||
4. No sampling attempted (no prompt sent)
|
||||
5. User sees clear "not found" message
|
||||
|
||||
This three-tier approach (answer → documents → error message) ensures users always receive useful feedback appropriate to the situation.
|
||||
|
||||
## Implementation
|
||||
|
||||
### Response Model
|
||||
|
||||
Add to `nextcloud_mcp_server/models/semantic.py` (new file for semantic search models):
|
||||
|
||||
```python
|
||||
from pydantic import Field
|
||||
|
||||
class SamplingSearchResponse(BaseResponse):
|
||||
"""Response from semantic search with LLM-generated answer via MCP sampling.
|
||||
|
||||
This response includes both a generated natural language answer (created by
|
||||
the MCP client's LLM via sampling) and the source documents used to generate
|
||||
that answer. Users can read the answer for quick information and review
|
||||
sources for verification and deeper exploration.
|
||||
|
||||
Attributes:
|
||||
query: The original user query
|
||||
generated_answer: Natural language answer generated by client's LLM
|
||||
sources: List of semantic search results used as context
|
||||
total_found: Total number of matching documents found
|
||||
search_method: Always "semantic_sampling" for this response type
|
||||
model_used: Name of model that generated the answer (e.g., "claude-3-5-sonnet")
|
||||
stop_reason: Why generation stopped ("endTurn", "maxTokens", etc.)
|
||||
"""
|
||||
|
||||
query: str = Field(..., description="Original user query")
|
||||
generated_answer: str = Field(
|
||||
...,
|
||||
description="LLM-generated answer based on retrieved documents"
|
||||
)
|
||||
sources: list[SemanticSearchResult] = Field(
|
||||
default_factory=list,
|
||||
description="Source documents with excerpts and relevance scores"
|
||||
)
|
||||
total_found: int = Field(..., description="Total matching documents")
|
||||
search_method: str = Field(
|
||||
default="semantic_sampling",
|
||||
description="Search method used"
|
||||
)
|
||||
model_used: str | None = Field(
|
||||
default=None,
|
||||
description="Model that generated the answer"
|
||||
)
|
||||
stop_reason: str | None = Field(
|
||||
default=None,
|
||||
description="Reason generation stopped"
|
||||
)
|
||||
```
|
||||
|
||||
### Tool Implementation
|
||||
|
||||
Add to `nextcloud_mcp_server/server/semantic.py` (new file for semantic search tools):
|
||||
|
||||
```python
|
||||
import logging
|
||||
from mcp.types import ModelHint, ModelPreferences, SamplingMessage, TextContent
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("semantic:read")
|
||||
async def nc_semantic_search_answer(
|
||||
query: str,
|
||||
ctx: Context,
|
||||
limit: int = 5,
|
||||
score_threshold: float = 0.7,
|
||||
max_answer_tokens: int = 500,
|
||||
) -> SamplingSearchResponse:
|
||||
"""
|
||||
Semantic search with LLM-generated answer using MCP sampling.
|
||||
|
||||
Retrieves relevant documents from Nextcloud across all indexed apps (notes,
|
||||
calendar, deck, files, contacts) using vector similarity search, then uses
|
||||
MCP sampling to request the client's LLM to generate a natural language
|
||||
answer based on the retrieved context.
|
||||
|
||||
This tool combines the power of semantic search (finding relevant content
|
||||
across all your Nextcloud apps) with LLM generation (synthesizing that
|
||||
content into coherent answers). The generated answer includes citations
|
||||
to specific documents with their types, allowing users to verify claims
|
||||
and explore sources.
|
||||
|
||||
The LLM generation happens client-side via MCP sampling. The MCP client
|
||||
controls which model is used, who pays for it, and whether to prompt the
|
||||
user for approval. This keeps the server simple (no LLM API keys needed)
|
||||
while giving users full control over their LLM interactions.
|
||||
|
||||
Args:
|
||||
query: Natural language question to answer (e.g., "What are my Q1 objectives?" or "When is my next dentist appointment?")
|
||||
ctx: MCP context for session access
|
||||
limit: Maximum number of documents to retrieve (default: 5)
|
||||
score_threshold: Minimum similarity score 0-1 (default: 0.7)
|
||||
max_answer_tokens: Maximum tokens for generated answer (default: 500)
|
||||
|
||||
Returns:
|
||||
SamplingSearchResponse containing:
|
||||
- generated_answer: Natural language answer with citations
|
||||
- sources: List of documents with excerpts and relevance scores
|
||||
- model_used: Which model generated the answer
|
||||
- stop_reason: Why generation stopped
|
||||
|
||||
Note: Requires MCP client to support sampling. If sampling is unavailable,
|
||||
the tool gracefully degrades to returning documents with an explanation.
|
||||
The client may prompt the user to approve the sampling request.
|
||||
|
||||
Examples:
|
||||
>>> # Query about objectives across multiple apps
|
||||
>>> result = await nc_semantic_search_answer(
|
||||
... query="What are my Q1 2025 project goals?",
|
||||
... ctx=ctx
|
||||
... )
|
||||
>>> print(result.generated_answer)
|
||||
"Based on Document 1 (note: Project Kickoff), Document 2 (calendar event:
|
||||
Q1 Planning Meeting), and Document 3 (deck card: Implement semantic search),
|
||||
your main goals are: 1) Improve semantic search accuracy by 20%,
|
||||
2) Deploy new embedding model, 3) Reduce indexing latency..."
|
||||
|
||||
>>> # Query about appointments
|
||||
>>> result = await nc_semantic_search_answer(
|
||||
... query="When is my next dentist appointment?",
|
||||
... ctx=ctx,
|
||||
... limit=10
|
||||
... )
|
||||
>>> len(result.sources) # Calendar events and related notes
|
||||
3
|
||||
"""
|
||||
# 1. Retrieve relevant documents via existing semantic search
|
||||
search_response = await nc_semantic_search(
|
||||
query=query,
|
||||
ctx=ctx,
|
||||
limit=limit,
|
||||
score_threshold=score_threshold,
|
||||
)
|
||||
|
||||
# 2. Handle no results case - don't waste a sampling call
|
||||
if not search_response.results:
|
||||
logger.debug(f"No documents found for query: {query}")
|
||||
return SamplingSearchResponse(
|
||||
query=query,
|
||||
generated_answer="No relevant documents found in your Nextcloud content for this query.",
|
||||
sources=[],
|
||||
total_found=0,
|
||||
search_method="semantic_sampling",
|
||||
success=True,
|
||||
)
|
||||
|
||||
# 3. Construct context from retrieved documents
|
||||
context_parts = []
|
||||
for idx, result in enumerate(search_response.results, 1):
|
||||
context_parts.append(
|
||||
f"[Document {idx}]\n"
|
||||
f"Title: {result.title}\n"
|
||||
f"Category: {result.category}\n"
|
||||
f"Excerpt: {result.excerpt}\n"
|
||||
f"Relevance Score: {result.score:.2f}\n"
|
||||
)
|
||||
|
||||
context = "\n".join(context_parts)
|
||||
|
||||
# 4. Construct prompt - reuse user's query, add context and instructions
|
||||
prompt = (
|
||||
f"{query}\n\n"
|
||||
f"Here are relevant documents from Nextcloud (notes, calendar events, deck cards, files, contacts):\n\n"
|
||||
f"{context}\n\n"
|
||||
f"Based on the documents above, please provide a comprehensive answer. "
|
||||
f"Cite the document numbers when referencing specific information."
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f"Requesting sampling for query: {query} "
|
||||
f"({len(search_response.results)} documents retrieved)"
|
||||
)
|
||||
|
||||
# 5. Request LLM completion via MCP sampling
|
||||
try:
|
||||
sampling_result = await ctx.session.create_message(
|
||||
messages=[
|
||||
SamplingMessage(
|
||||
role="user",
|
||||
content=TextContent(type="text", text=prompt),
|
||||
)
|
||||
],
|
||||
max_tokens=max_answer_tokens,
|
||||
temperature=0.7,
|
||||
model_preferences=ModelPreferences(
|
||||
hints=[ModelHint(name="claude-3-5-sonnet")],
|
||||
intelligencePriority=0.8,
|
||||
speedPriority=0.5,
|
||||
),
|
||||
include_context="thisServer",
|
||||
)
|
||||
|
||||
# 6. Extract answer from sampling response
|
||||
if sampling_result.content.type == "text":
|
||||
generated_answer = sampling_result.content.text
|
||||
else:
|
||||
# Handle non-text responses (shouldn't happen for text prompts)
|
||||
generated_answer = (
|
||||
f"Received non-text response of type: {sampling_result.content.type}"
|
||||
)
|
||||
logger.warning(
|
||||
f"Unexpected content type from sampling: {sampling_result.content.type}"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Sampling successful: model={sampling_result.model}, "
|
||||
f"stop_reason={sampling_result.stopReason}"
|
||||
)
|
||||
|
||||
return SamplingSearchResponse(
|
||||
query=query,
|
||||
generated_answer=generated_answer,
|
||||
sources=search_response.results,
|
||||
total_found=search_response.total_found,
|
||||
search_method="semantic_sampling",
|
||||
model_used=sampling_result.model,
|
||||
stop_reason=sampling_result.stopReason,
|
||||
success=True,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
# Fallback: Return documents without generated answer
|
||||
logger.warning(
|
||||
f"Sampling failed ({type(e).__name__}: {e}), "
|
||||
f"returning search results only"
|
||||
)
|
||||
|
||||
return SamplingSearchResponse(
|
||||
query=query,
|
||||
generated_answer=(
|
||||
f"[Sampling unavailable: {str(e)}]\n\n"
|
||||
f"Found {search_response.total_found} relevant documents. "
|
||||
f"Please review the sources below."
|
||||
),
|
||||
sources=search_response.results,
|
||||
total_found=search_response.total_found,
|
||||
search_method="semantic_sampling_fallback",
|
||||
success=True,
|
||||
)
|
||||
```
|
||||
|
||||
### Import Updates
|
||||
|
||||
Add to top of `nextcloud_mcp_server/server/semantic.py`:
|
||||
|
||||
```python
|
||||
from mcp.types import ModelHint, ModelPreferences, SamplingMessage, TextContent
|
||||
```
|
||||
|
||||
Add to `nextcloud_mcp_server/models/semantic.py` exports:
|
||||
|
||||
```python
|
||||
__all__ = [
|
||||
"SemanticSearchResult",
|
||||
"SemanticSearchResponse",
|
||||
"SamplingSearchResponse",
|
||||
]
|
||||
```
|
||||
|
||||
## Consequences
|
||||
|
||||
### Benefits
|
||||
|
||||
**Improved User Experience**: Users receive direct answers to questions rather than lists of documents, matching expectations from modern AI interfaces.
|
||||
|
||||
**Proper Attribution**: Generated answers include citations to source documents, allowing users to verify claims and explore deeper.
|
||||
|
||||
**No Server-Side LLM**: The server has no LLM dependencies, API keys, or billing concerns. All LLM interactions happen client-side.
|
||||
|
||||
**User Control**: MCP clients control which model is used and may prompt users to approve sampling requests, maintaining transparency and user agency.
|
||||
|
||||
**Graceful Degradation**: The tool works even when sampling is unavailable, falling back to returning documents. Existing clients continue working without changes.
|
||||
|
||||
**Consistent Architecture**: Follows MCP's client-server separation: servers provide data access, clients provide user interaction and LLM capabilities.
|
||||
|
||||
### Limitations
|
||||
|
||||
**Sampling Support Required**: Not all MCP clients implement sampling. Users with basic clients see fallback behavior (documents without answers).
|
||||
|
||||
**Added Latency**: Sampling adds 2-5 seconds to tool execution due to client round-trip and LLM generation time. Users must wait longer for answers than for raw search results.
|
||||
|
||||
**User Approval Friction**: MCP clients SHOULD prompt users to approve sampling requests. This adds an extra interaction step before answers are generated.
|
||||
|
||||
**Limited Prompt Control**: The server cannot fully control how the client's LLM interprets the prompt. Different models may generate different quality answers.
|
||||
|
||||
**No Caching**: Each query requires a new sampling call. The server doesn't cache generated answers (clients may cache if they choose).
|
||||
|
||||
**Token Costs**: LLM generation consumes tokens from the user's or client's quota. Heavy users may incur costs or hit rate limits.
|
||||
|
||||
### Performance Characteristics
|
||||
|
||||
**Typical latency**:
|
||||
- Document retrieval (vector search): 100-300ms
|
||||
- Sampling round-trip (client communication): 50-200ms
|
||||
- LLM generation (client-side): 1-4 seconds
|
||||
- **Total**: 2-5 seconds end-to-end
|
||||
|
||||
**Throughput**: Sampling is fully async. The server can handle multiple concurrent sampling requests (limited by MCP client's concurrency, not server capacity).
|
||||
|
||||
**Resource usage**: Minimal server-side. No GPU, no LLM model loading, no large memory requirements. Sampling happens entirely client-side.
|
||||
|
||||
### Security Considerations
|
||||
|
||||
**Prompt Injection Risk**: If user queries contain adversarial text designed to manipulate LLM behavior, those queries are included verbatim in the sampling prompt. Mitigation: The structured prompt format and explicit instructions ("based on documents above") constrain LLM behavior.
|
||||
|
||||
**Data Privacy**: User queries and document excerpts are sent to the client's LLM. For cloud LLMs (OpenAI, Anthropic), this means data leaves the server's control. Mitigation: MCP clients SHOULD present sampling requests to users for approval, making data flows transparent. Users choose their LLM provider.
|
||||
|
||||
**Sampling Abuse**: A malicious server could spam sampling requests to drain user quotas. Mitigation: MCP clients control approval and can rate-limit or block sampling from misbehaving servers.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
### Server-Side LLM Integration
|
||||
|
||||
**Approach**: Configure the MCP server with OpenAI API key or local Ollama instance. Generate answers server-side.
|
||||
|
||||
**Rejected Because**:
|
||||
- Duplicates LLM infrastructure that MCP clients already have
|
||||
- Creates billing and API key management burden for server operators
|
||||
- Locks users into server-configured models
|
||||
- Violates MCP's client-server separation principle
|
||||
|
||||
### Multi-Turn Conversation Pattern
|
||||
|
||||
**Approach**: `nc_notes_semantic_search` returns documents. User asks follow-up question. Client's LLM uses previous tool results as context.
|
||||
|
||||
**Rejected Because**:
|
||||
- Requires users to know to ask follow-up questions
|
||||
- Consumes context window with full document content
|
||||
- Inconsistent behavior across clients
|
||||
- Poor citation (LLM may not reference which documents it used)
|
||||
|
||||
### Pre-Generated Summaries
|
||||
|
||||
**Approach**: Generate and cache summaries during indexing. Return summaries instead of excerpts.
|
||||
|
||||
**Rejected Because**:
|
||||
- Summaries become stale as documents change
|
||||
- Summary quality depends on server-side LLM (same problems as server-side generation)
|
||||
- Summaries are generic, not tailored to specific queries
|
||||
|
||||
### Streaming Responses
|
||||
|
||||
**Approach**: Use MCP sampling with streaming to return incremental answer chunks.
|
||||
|
||||
**Deferred Because**:
|
||||
- MCP sampling streaming support unclear in current specification
|
||||
- Adds significant implementation complexity
|
||||
- Tool responses in MCP are typically atomic
|
||||
- Can be added later without breaking changes
|
||||
|
||||
## Related Decisions
|
||||
|
||||
**ADR-007**: Background Vector Sync provides the semantic search infrastructure that this ADR enhances with LLM generation.
|
||||
|
||||
**ADR-004**: Progressive Consent architecture applies to sampling—users consent to sampling requests via MCP client approval prompts.
|
||||
|
||||
## References
|
||||
|
||||
- [MCP Specification - Sampling](https://modelcontextprotocol.io/docs/specification/2025-06-18/client/sampling)
|
||||
- [MCP Python SDK - ServerSession.create_message](https://github.com/modelcontextprotocol/python-sdk/blob/main/src/mcp/server/session.py#L215)
|
||||
- [MCP Python SDK - Sampling Example](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/sampling.py)
|
||||
- [MCP Types - SamplingMessage](https://github.com/modelcontextprotocol/python-sdk/blob/main/src/mcp/types.py#L1038)
|
||||
- [MCP Types - CreateMessageResult](https://github.com/modelcontextprotocol/python-sdk/blob/main/src/mcp/types.py#L1073)
|
||||
- [Retrieval-Augmented Generation (RAG) - Lewis et al. 2020](https://arxiv.org/abs/2005.11401)
|
||||
|
||||
## Implementation Checklist
|
||||
|
||||
- [ ] Create ADR-008 document (this file)
|
||||
- [ ] Create `nextcloud_mcp_server/models/semantic.py` for semantic search models
|
||||
- [ ] Add `SamplingSearchResponse` model to `nextcloud_mcp_server/models/semantic.py`
|
||||
- [ ] Create `nextcloud_mcp_server/server/semantic.py` for semantic search tools
|
||||
- [ ] Implement `nc_semantic_search_answer` tool in `nextcloud_mcp_server/server/semantic.py`
|
||||
- [ ] Add MCP sampling type imports (`SamplingMessage`, `TextContent`, etc.)
|
||||
- [ ] Write unit tests with mocked sampling (`tests/unit/server/test_semantic.py`)
|
||||
- [ ] Create integration tests (`tests/integration/test_sampling.py`)
|
||||
- [ ] Update `README.md` with new tool documentation in dedicated Semantic Search section
|
||||
- [ ] Update `CLAUDE.md` with sampling pattern guidance
|
||||
- [ ] Test with MCP client supporting sampling (Claude Desktop, MCP Inspector with callbacks)
|
||||
- [ ] Document client requirements and fallback behavior
|
||||
- [ ] Update oauth-architecture.md to add semantic:read scope
|
||||
- [ ] Create ADR-009 to document semantic:read scope decision
|
||||
@@ -0,0 +1,268 @@
|
||||
# ADR-009: Generic `semantic:read` OAuth Scope for Multi-App Vector Search
|
||||
|
||||
**Status**: Proposed
|
||||
**Date**: 2025-01-11
|
||||
**Depends On**: ADR-007 (Background Vector Sync), ADR-008 (MCP Sampling for Semantic Search)
|
||||
|
||||
## Context
|
||||
|
||||
ADR-007 established a background vector synchronization architecture that indexes content from multiple Nextcloud apps (notes, calendar events, deck cards, files, contacts) into a unified vector database. ADR-008 introduced semantic search tools (`nc_semantic_search`, `nc_semantic_search_answer`) that query this vector database and use MCP sampling to generate natural language answers.
|
||||
|
||||
The question is: **What OAuth scopes should protect semantic search operations?**
|
||||
|
||||
### Option 1: App-Specific Scopes
|
||||
|
||||
Require users to have scopes for each app they want to search:
|
||||
|
||||
```python
|
||||
@mcp.tool()
|
||||
@require_scopes("notes:read", "calendar:read", "deck:read", "files:read", "contacts:read")
|
||||
async def nc_semantic_search(query: str, ctx: Context) -> SemanticSearchResponse:
|
||||
"""Search across all indexed apps"""
|
||||
```
|
||||
|
||||
**Advantages**:
|
||||
- Granular control - users explicitly consent to searching each app
|
||||
- Aligns with app-specific authorization model
|
||||
- Clear security boundary - can only search apps you can access
|
||||
|
||||
**Disadvantages**:
|
||||
- **Brittle user experience**: If a user grants only `notes:read` but the tool requires all 5 scopes, the tool becomes invisible/unusable
|
||||
- **All-or-nothing enforcement**: Can't search notes alone - must grant all scopes or none
|
||||
- **Poor progressive consent**: User can't start with notes search and later add calendar
|
||||
- **Scope inflation**: Every new app adds another required scope
|
||||
- **Mismatched semantics**: User thinks "I want to search my notes" but must grant calendar, deck, files, contacts just to make the tool appear
|
||||
|
||||
### Option 2: Single Generic Scope (Chosen)
|
||||
|
||||
Introduce a new semantic search-specific scope:
|
||||
|
||||
```python
|
||||
@mcp.tool()
|
||||
@require_scopes("semantic:read")
|
||||
async def nc_semantic_search(query: str, ctx: Context) -> SemanticSearchResponse:
|
||||
"""Search across all indexed apps"""
|
||||
```
|
||||
|
||||
**Advantages**:
|
||||
- **Simple authorization**: One scope grants semantic search capability
|
||||
- **Progressive enablement**: User grants `semantic:read`, searches notes initially, then enables calendar indexing later
|
||||
- **Logical grouping**: Semantic search is a cross-app feature, deserving its own scope
|
||||
- **Future-proof**: New apps can be added to vector sync without changing OAuth scopes
|
||||
- **Matches user mental model**: "I want semantic search" → grant `semantic:read` (not "I want semantic search" → grant 5 unrelated app scopes)
|
||||
|
||||
**Considerations**:
|
||||
- User could search apps they can't directly access via app-specific tools
|
||||
- **Mitigation**: Dual-phase authorization (Phase 1: scope check passes with `semantic:read`, Phase 2: verify user can access each returned document via app-specific permissions)
|
||||
- Less granular than app-specific scopes
|
||||
- **Counterpoint**: Semantic search is inherently cross-app - forcing per-app authorization defeats its purpose
|
||||
|
||||
### Option 3: Hybrid Approach (Rejected)
|
||||
|
||||
Support both: semantic search works with either `semantic:read` OR all app-specific scopes:
|
||||
|
||||
```python
|
||||
@mcp.tool()
|
||||
@require_scopes("semantic:read", alternative_scopes=["notes:read", "calendar:read", ...])
|
||||
async def nc_semantic_search(query: str, ctx: Context) -> SemanticSearchResponse:
|
||||
"""Search across all indexed apps"""
|
||||
```
|
||||
|
||||
**Rejected Because**:
|
||||
- Adds complexity to scope validation logic
|
||||
- Unclear to users which scopes they should grant
|
||||
- Alternative scopes still suffer from all-or-nothing problem
|
||||
- No significant benefit over Option 2 with dual-phase authorization
|
||||
|
||||
## Decision
|
||||
|
||||
We will introduce two new OAuth scopes specifically for semantic search operations:
|
||||
|
||||
- **`semantic:read`**: Query vector database, perform semantic search, generate answers
|
||||
- **`semantic:write`**: Enable/disable background vector synchronization, manage indexing settings
|
||||
|
||||
These scopes are **independent** of app-specific scopes (notes:read, calendar:read, etc.).
|
||||
|
||||
### Tool Scope Assignments
|
||||
|
||||
**Read Operations**:
|
||||
```python
|
||||
@mcp.tool()
|
||||
@require_scopes("semantic:read")
|
||||
async def nc_semantic_search(query: str, ctx: Context, limit: int = 10, score_threshold: float = 0.7) -> SemanticSearchResponse:
|
||||
"""Semantic search across all indexed Nextcloud apps"""
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("semantic:read")
|
||||
async def nc_semantic_search_answer(query: str, ctx: Context, limit: int = 5, max_answer_tokens: int = 500) -> SamplingSearchResponse:
|
||||
"""Semantic search with LLM-generated answer via MCP sampling"""
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("semantic:read")
|
||||
async def nc_get_vector_sync_status(ctx: Context) -> VectorSyncStatusResponse:
|
||||
"""Get current vector synchronization status (indexed count, pending count, status)"""
|
||||
```
|
||||
|
||||
**Write Operations**:
|
||||
```python
|
||||
@mcp.tool()
|
||||
@require_scopes("semantic:write")
|
||||
async def nc_enable_vector_sync(ctx: Context) -> VectorSyncResponse:
|
||||
"""Enable background vector synchronization for this user"""
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("semantic:write")
|
||||
async def nc_disable_vector_sync(ctx: Context) -> VectorSyncResponse:
|
||||
"""Disable background vector synchronization"""
|
||||
```
|
||||
|
||||
### Dual-Phase Authorization
|
||||
|
||||
To ensure users can only access documents they have permission to view, semantic search implements **dual-phase authorization**:
|
||||
|
||||
**Phase 1: Scope Check** (MCP Server)
|
||||
- User must have `semantic:read` scope to call semantic search tools
|
||||
- This grants permission to query the vector database
|
||||
|
||||
**Phase 2: Document Verification** (Per-Result Filtering)
|
||||
- For each returned document, verify user has access via app-specific permissions
|
||||
- Uses `DocumentVerifier` interface per app:
|
||||
- Notes: Call `/apps/notes/api/v1/notes/{id}` - if 404/403, exclude from results
|
||||
- Calendar: Call `/remote.php/dav/calendars/username/calendar/event.ics` - if 404/403, exclude
|
||||
- Deck: Call `/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}/cards/{card_id}` - if 404/403, exclude
|
||||
- Files: Call `/remote.php/dav/files/username/path` with PROPFIND - if 404/403, exclude
|
||||
- Contacts: Call `/remote.php/dav/addressbooks/username/addressbook/contact.vcf` - if 404/403, exclude
|
||||
|
||||
This two-phase approach ensures:
|
||||
1. Semantic search is a **distinct capability** (like "global search") requiring explicit consent
|
||||
2. Results are **filtered** to only include documents the user can access
|
||||
3. No privilege escalation - users can't discover content they shouldn't see
|
||||
|
||||
**Implementation**: See ADR-007 Phase 3 (Document Verification) and `DocumentVerifier` interface.
|
||||
|
||||
### Scope Discovery
|
||||
|
||||
The new scopes will be:
|
||||
- **Advertised** via PRM endpoint (`/.well-known/oauth-protected-resource/mcp`)
|
||||
- **Dynamically discovered** from `@require_scopes` decorators on semantic search tools
|
||||
- **Documented** in OAuth architecture (oauth-architecture.md)
|
||||
- **Included** in default client registration scopes
|
||||
|
||||
## Consequences
|
||||
|
||||
### Benefits
|
||||
|
||||
**User Experience**:
|
||||
- Simple authorization: one scope for semantic search capability
|
||||
- Progressive enablement: grant `semantic:read`, enable indexing for apps later
|
||||
- Natural mental model: "semantic search" is a distinct feature deserving its own scope
|
||||
|
||||
**Security**:
|
||||
- Dual-phase authorization prevents privilege escalation
|
||||
- Users explicitly consent to cross-app search capability
|
||||
- Per-document verification ensures users only see accessible content
|
||||
|
||||
**Maintainability**:
|
||||
- Adding new apps to vector sync doesn't require OAuth scope changes
|
||||
- Clear separation between app access (notes:read) and search capability (semantic:read)
|
||||
- Logical grouping of related operations (search, sync status, enable/disable)
|
||||
|
||||
**Future-Proof**:
|
||||
- Can add new document types without breaking existing OAuth flows
|
||||
- Supports future semantic features (recommendations, clustering) under same scope
|
||||
- Aligns with potential future Nextcloud semantic capabilities
|
||||
|
||||
### Trade-offs
|
||||
|
||||
**Less Granular Than App-Specific Scopes**:
|
||||
- User can't grant "semantic search notes only"
|
||||
- Semantic search is all-or-nothing across enabled apps
|
||||
- **Mitigation**: Dual-phase verification ensures users only see documents they can access
|
||||
|
||||
**New Scope to Learn**:
|
||||
- Users must understand `semantic:read` is distinct from app scopes
|
||||
- MCP clients must present scope clearly during consent
|
||||
- **Mitigation**: Clear scope descriptions in OAuth consent UI and documentation
|
||||
|
||||
**Backend Complexity**:
|
||||
- Requires dual-phase authorization implementation
|
||||
- DocumentVerifier interface needed for each app
|
||||
- **Benefit**: Enforces proper security regardless of scope model
|
||||
|
||||
### Migration Impact
|
||||
|
||||
**Breaking Change**: Existing deployments using notes-specific semantic search will break.
|
||||
|
||||
**Before (OLD - Breaking)**:
|
||||
```python
|
||||
@mcp.tool()
|
||||
@require_scopes("notes:read")
|
||||
async def nc_notes_semantic_search(query: str, ctx: Context) -> SemanticSearchResponse:
|
||||
"""Semantic search notes"""
|
||||
```
|
||||
|
||||
**After (NEW)**:
|
||||
```python
|
||||
@mcp.tool()
|
||||
@require_scopes("semantic:read")
|
||||
async def nc_semantic_search(query: str, ctx: Context) -> SemanticSearchResponse:
|
||||
"""Semantic search across all apps"""
|
||||
```
|
||||
|
||||
**Migration Path**:
|
||||
1. Deploy server with new `semantic:read` scope
|
||||
2. Users re-authenticate, granting `semantic:read` scope
|
||||
3. Semantic search tools become visible/usable again
|
||||
4. **No data loss**: Vector database and indexed documents remain unchanged
|
||||
|
||||
**Backward Compatibility**: None. This is an intentional breaking change to correct the scope model before broader adoption.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
### Keep Notes-Specific Scopes
|
||||
|
||||
**Approach**: Continue using `notes:read` for semantic search, even when searching other apps.
|
||||
|
||||
**Rejected Because**:
|
||||
- Semantically incorrect - searching calendar events is not "reading notes"
|
||||
- Confuses users - why does searching calendar require notes:read?
|
||||
- Doesn't scale - what scope for multi-app search?
|
||||
|
||||
### Create Per-App Semantic Scopes
|
||||
|
||||
**Approach**: Introduce `notes:semantic`, `calendar:semantic`, `deck:semantic`, etc.
|
||||
|
||||
**Rejected Because**:
|
||||
- Scope proliferation - doubles the number of scopes
|
||||
- Defeats purpose of unified vector search
|
||||
- Users would need to grant 5+ scopes for cross-app search
|
||||
- No clear benefit over dual-phase authorization with `semantic:read`
|
||||
|
||||
### Require All App Scopes (Already Rejected in Option 1)
|
||||
|
||||
**Approach**: Require `notes:read AND calendar:read AND deck:read AND files:read AND contacts:read`
|
||||
|
||||
**Rejected Because**: Unusable UX (see Option 1 disadvantages above)
|
||||
|
||||
## Related Decisions
|
||||
|
||||
**ADR-007**: Background Vector Sync provides the indexing architecture that semantic scopes protect. The DocumentVerifier interface from ADR-007 Phase 3 implements dual-phase authorization.
|
||||
|
||||
**ADR-008**: MCP Sampling for semantic search uses `semantic:read` to protect the sampling-enhanced search tool.
|
||||
|
||||
**ADR-004**: Progressive Consent architecture supports users granting `semantic:read` initially, then enabling per-app indexing via `semantic:write` (enable_vector_sync with app selection).
|
||||
|
||||
## Implementation Checklist
|
||||
|
||||
- [ ] Create ADR-009 document (this file)
|
||||
- [ ] Update `oauth-architecture.md` to document `semantic:read` and `semantic:write` scopes ✅
|
||||
- [ ] Update `README.md` to show Semantic Search as separate tool category ✅
|
||||
- [ ] Update ADR-007 to reference `semantic:*` scopes instead of `sync:*` ✅
|
||||
- [ ] Update ADR-008 to use `semantic:read` instead of `notes:read` ✅
|
||||
- [ ] Implement DocumentVerifier interface for all apps (notes, calendar, deck, files, contacts)
|
||||
- [ ] Update semantic search tools to use `@require_scopes("semantic:read")`
|
||||
- [ ] Update vector sync tools to use `@require_scopes("semantic:write")`
|
||||
- [ ] Add dual-phase authorization to semantic search implementation
|
||||
- [ ] Test OAuth flow with `semantic:read` scope
|
||||
- [ ] Update scope discovery in PRM endpoint
|
||||
- [ ] Document migration path for existing deployments
|
||||
@@ -0,0 +1,661 @@
|
||||
# ADR-010: Webhook-Based Vector Database Synchronization
|
||||
|
||||
**Status**: Proposed
|
||||
**Date**: 2025-01-10
|
||||
**Depends On**: ADR-007 (Background Vector Sync)
|
||||
|
||||
## Context
|
||||
|
||||
ADR-007 established a background synchronization architecture for maintaining the vector database using periodic polling. The scanner task runs on a configurable interval (default 3600 seconds / 1 hour) to detect changed documents across Nextcloud apps. While this polling approach is simple and reliable, it introduces significant latency between content changes and vector database updates.
|
||||
|
||||
### Current Polling Architecture
|
||||
|
||||
The existing scanner implementation in `nextcloud_mcp_server/vector/scanner.py` operates as follows:
|
||||
|
||||
1. **Periodic Scanning**: The scanner task sleeps for `vector_sync_scan_interval` seconds between runs
|
||||
2. **Change Detection**: For each scan, it:
|
||||
- Fetches all documents from Nextcloud (notes, calendar events, etc.)
|
||||
- Queries Qdrant for the last indexed timestamp of each document
|
||||
- Compares modification timestamps to detect changes
|
||||
- Queues changed documents for processing
|
||||
3. **Document Processing**: Processor tasks pull from the queue, generate embeddings, and update Qdrant
|
||||
|
||||
This architecture works but has fundamental limitations:
|
||||
|
||||
**Latency**: With a 1-hour scan interval, content changes can take up to 1 hour to appear in semantic search results. For time-sensitive use cases (e.g., "What's on my calendar today?"), this delay is problematic.
|
||||
|
||||
**API Load**: Every scan fetches *all* documents for *all* enabled users, regardless of whether anything changed. For large deployments with thousands of documents, this generates significant unnecessary API traffic to Nextcloud.
|
||||
|
||||
**Resource Waste**: The scanner and processors consume compute resources even when no content has changed. During periods of low activity, the system performs wasteful polling.
|
||||
|
||||
**Scalability**: As the number of users and documents grows, the time required to complete a full scan increases. Eventually, the scan duration may exceed the scan interval, causing scans to run continuously without idle periods.
|
||||
|
||||
**Rate Limiting**: Fetching all documents for all users in rapid succession can trigger Nextcloud's rate limiting, especially on shared hosting environments with restrictive API quotas.
|
||||
|
||||
These limitations are inherent to any polling-based architecture. Reducing the scan interval (e.g., to 5 minutes) reduces latency but exacerbates API load, resource waste, and rate limiting issues. The fundamental problem is that the system has no way to know *when* content changes occur—it must repeatedly check to find out.
|
||||
|
||||
### Nextcloud Webhook Listeners
|
||||
|
||||
Nextcloud provides a webhook_listeners app (bundled with Nextcloud 30+) that enables push-based change notifications. Instead of polling for changes, external services can register webhook endpoints and receive HTTP POST requests when specific events occur. Administrators register these webhooks using Nextcloud's OCS API or occ commands.
|
||||
|
||||
The webhook_listeners app supports events for all Nextcloud apps relevant to this MCP server's vector database:
|
||||
|
||||
**Files/Notes Events** (notes are stored as files):
|
||||
- `OCP\Files\Events\Node\NodeCreatedEvent`
|
||||
- `OCP\Files\Events\Node\NodeWrittenEvent`
|
||||
- `OCP\Files\Events\Node\BeforeNodeDeletedEvent` ⭐ **Use this for deletion (includes node.id)**
|
||||
- `OCP\Files\Events\Node\NodeDeletedEvent` (missing node.id - file already deleted)
|
||||
- `OCP\Files\Events\Node\NodeRenamedEvent`
|
||||
- `OCP\Files\Events\Node\NodeCopiedEvent`
|
||||
|
||||
**Calendar Events**:
|
||||
- `OCP\Calendar\Events\CalendarObjectCreatedEvent`
|
||||
- `OCP\Calendar\Events\CalendarObjectUpdatedEvent`
|
||||
- `OCP\Calendar\Events\CalendarObjectDeletedEvent`
|
||||
- `OCP\Calendar\Events\CalendarObjectMovedEvent`
|
||||
|
||||
**Tables Events**:
|
||||
- `OCA\Tables\Event\RowAddedEvent`
|
||||
- `OCA\Tables\Event\RowUpdatedEvent`
|
||||
- `OCA\Tables\Event\RowDeletedEvent`
|
||||
|
||||
**Deck Events** (via file events since cards are stored as files in some configurations)
|
||||
|
||||
Each webhook notification includes rich metadata:
|
||||
- User ID who triggered the event
|
||||
- Timestamp of the event
|
||||
- Document ID and metadata
|
||||
- Operation type (create, update, delete)
|
||||
- Path information (for files)
|
||||
|
||||
Webhook notifications are dispatched via background jobs, with configurable delivery guarantees. Administrators can set up dedicated webhook worker processes to achieve near-real-time delivery (within seconds of the triggering event).
|
||||
|
||||
### Why Not Replace Polling Entirely?
|
||||
|
||||
While webhooks provide superior latency and efficiency, they cannot fully replace polling:
|
||||
|
||||
**Missed Events**: If the MCP server is down when a webhook fires, the notification is lost. Nextcloud's background job system processes webhooks asynchronously, but does not queue failed deliveries indefinitely.
|
||||
|
||||
**Administrator Setup**: Webhooks must be registered by Nextcloud administrators using the OCS API or occ commands. This is an optional optimization that administrators can enable when they want to reduce polling frequency.
|
||||
|
||||
**Filter Configuration**: Webhook filters must be carefully configured to avoid notification floods. A poorly configured filter could send thousands of notifications for bulk operations (e.g., importing a calendar with hundreds of events).
|
||||
|
||||
**Graceful Degradation**: In environments where webhooks are not configured, the system continues using polling without any degradation in functionality.
|
||||
|
||||
**Deletion Detection**: Nextcloud's webhook system does not guarantee delivery of deletion events if the user's account is removed or the app is uninstalled. Periodic polling provides a safety mechanism to detect orphaned documents.
|
||||
|
||||
A complementary architecture where webhooks supplement (but don't replace) polling provides low-latency updates when configured, with polling ensuring reliability.
|
||||
|
||||
### Design Considerations
|
||||
|
||||
**Push vs Pull Trade-offs**:
|
||||
Webhooks introduce new failure modes (network issues, endpoint unavailability, notification floods) that polling avoids. The webhook endpoint must handle failures gracefully without blocking semantic search functionality.
|
||||
|
||||
**Webhook Endpoint Security**:
|
||||
The MCP server exposes an HTTP endpoint to receive webhooks. Authentication is optional—in production deployments, administrators can configure Nextcloud to send an `Authorization` header that the MCP server validates. For local development, authentication can be disabled for simplicity.
|
||||
|
||||
**Idempotency**:
|
||||
The system may receive duplicate notifications (webhook + next scan) or out-of-order notifications (update fires before create completes). Document processing must be idempotent—processing the same document multiple times produces the same result.
|
||||
|
||||
**Asynchronous Processing**:
|
||||
Nextcloud processes webhooks via background jobs, introducing delivery latency (typically seconds to minutes depending on background job configuration). This affects testing strategies—integration tests cannot rely on immediate webhook delivery.
|
||||
|
||||
**Deployment Patterns**:
|
||||
The MCP server webhook endpoint is accessible at the same host/port as the MCP server itself. Administrators configure Nextcloud to POST to `https://<mcp-server-host>:<port>/webhooks/nextcloud` when registering webhook listeners.
|
||||
|
||||
## Decision
|
||||
|
||||
We will add a webhook endpoint to the MCP server that receives change notifications from Nextcloud and queues documents for vector database processing. This complements the existing polling architecture from ADR-007 without replacing it—webhooks provide low-latency updates when configured, while polling ensures reliability regardless of webhook availability.
|
||||
|
||||
The architecture is intentionally simple: the webhook endpoint is just another producer of `DocumentTask` objects that feed into the existing processor queue. The scanner task, processor pool, and queue management remain unchanged from ADR-007.
|
||||
|
||||
### Architecture Components
|
||||
|
||||
**1. Webhook Endpoint**
|
||||
|
||||
A new Starlette HTTP route will be added to receive webhook notifications from Nextcloud:
|
||||
|
||||
```python
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
@app.route("/webhooks/nextcloud", methods=["POST"])
|
||||
async def handle_nextcloud_webhook(request: Request) -> JSONResponse:
|
||||
"""
|
||||
Receive webhook notifications from Nextcloud.
|
||||
|
||||
Parses event payload, extracts document metadata, and queues
|
||||
changed documents for processing using the same queue as the scanner.
|
||||
"""
|
||||
# 1. Optional authentication validation
|
||||
if settings.webhook_secret:
|
||||
auth_header = request.headers.get("authorization", "")
|
||||
if not auth_header.startswith("Bearer ") or \
|
||||
auth_header[7:] != settings.webhook_secret:
|
||||
logger.warning("Webhook authentication failed")
|
||||
return JSONResponse(
|
||||
{"status": "error", "message": "Unauthorized"},
|
||||
status_code=401
|
||||
)
|
||||
|
||||
# 2. Parse webhook payload
|
||||
payload = await request.json()
|
||||
event_class = payload["event"]["class"]
|
||||
user_id = payload["user"]["uid"]
|
||||
|
||||
# 3. Extract document metadata from event
|
||||
doc_task = extract_document_task(event_class, payload)
|
||||
if not doc_task:
|
||||
return JSONResponse({"status": "ignored", "reason": "unsupported event"})
|
||||
|
||||
# 4. Send to processor queue (same queue as scanner)
|
||||
try:
|
||||
await webhook_send_stream.send(doc_task)
|
||||
logger.info(f"Queued document from webhook: {doc_task}")
|
||||
return JSONResponse({"status": "queued"})
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to queue webhook document: {e}")
|
||||
return JSONResponse(
|
||||
{"status": "error", "message": str(e)},
|
||||
status_code=500
|
||||
)
|
||||
```
|
||||
|
||||
The endpoint:
|
||||
- Validates optional authentication via `Authorization: Bearer <secret>` header
|
||||
- Parses various event types (calendar, files, tables) into `DocumentTask` objects
|
||||
- Sends to the same processing queue that the scanner uses
|
||||
- Returns quickly (<50ms) to avoid blocking Nextcloud's webhook workers
|
||||
- Handles errors gracefully (invalid payload, queue full, etc.)
|
||||
|
||||
**2. Webhook Registration Helper (Development Only)**
|
||||
|
||||
For development and testing purposes, a helper method will be added to `NextcloudClient` for registering webhooks via the OCS API. This is NOT exposed as an MCP tool—administrators register webhooks manually using Nextcloud's admin interface or the OCS API directly.
|
||||
|
||||
```python
|
||||
class NextcloudClient:
|
||||
async def register_webhook(
|
||||
self,
|
||||
event_type: str,
|
||||
uri: str,
|
||||
http_method: str = "POST",
|
||||
auth_method: str = "none",
|
||||
headers: dict[str, str] | None = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Register a webhook with Nextcloud (requires admin credentials).
|
||||
|
||||
Used for development/testing. Production admins should register
|
||||
webhooks using Nextcloud's admin UI or occ commands.
|
||||
"""
|
||||
# Implementation uses OCS API: POST /ocs/v2.php/apps/webhook_listeners/api/v1/webhooks
|
||||
...
|
||||
```
|
||||
|
||||
This keeps webhook registration out of the MCP tool surface while providing a convenient API for integration tests.
|
||||
|
||||
**3. Event Parsing**
|
||||
|
||||
A helper function extracts `DocumentTask` from various Nextcloud event types:
|
||||
|
||||
```python
|
||||
def extract_document_task(event_class: str, payload: dict) -> DocumentTask | None:
|
||||
"""Extract DocumentTask from webhook event payload."""
|
||||
user_id = payload["user"]["uid"]
|
||||
event_data = payload["event"]
|
||||
|
||||
# File/Note events
|
||||
if "NodeCreatedEvent" in event_class or "NodeWrittenEvent" in event_class:
|
||||
# Only process markdown files (notes)
|
||||
path = event_data["node"]["path"]
|
||||
if not path.endswith(".md"):
|
||||
return None
|
||||
return DocumentTask(
|
||||
user_id=user_id,
|
||||
doc_id=event_data["node"]["id"],
|
||||
doc_type="note",
|
||||
operation="index",
|
||||
modified_at=payload["time"],
|
||||
)
|
||||
|
||||
# Calendar events
|
||||
elif "CalendarObjectCreatedEvent" in event_class or \
|
||||
"CalendarObjectUpdatedEvent" in event_class:
|
||||
return DocumentTask(
|
||||
user_id=user_id,
|
||||
doc_id=str(event_data["objectData"]["id"]),
|
||||
doc_type="calendar_event",
|
||||
operation="index",
|
||||
modified_at=event_data["objectData"]["lastmodified"],
|
||||
)
|
||||
|
||||
# Deletion events (use BeforeNodeDeletedEvent for files to get node.id)
|
||||
elif "BeforeNodeDeletedEvent" in event_class or \
|
||||
"NodeDeletedEvent" in event_class or \
|
||||
"CalendarObjectDeletedEvent" in event_class:
|
||||
# Similar logic for delete operations
|
||||
...
|
||||
|
||||
return None # Unsupported event type
|
||||
```
|
||||
|
||||
**4. No Changes to Scanner or Processors**
|
||||
|
||||
The existing scanner task from ADR-007 continues operating unchanged. It polls Nextcloud on its configured interval (`VECTOR_SYNC_SCAN_INTERVAL`), discovers changed documents, and queues them for processing. The scanner is unaware of webhooks—it simply adds `DocumentTask` objects to the queue.
|
||||
|
||||
Similarly, the processor pool continues pulling `DocumentTask` objects from the queue, generating embeddings, and updating Qdrant. Processors don't know or care whether a task came from the scanner or a webhook.
|
||||
|
||||
This design keeps concerns separated: webhooks and scanner are independent producers, processors are independent consumers, and the queue mediates between them.
|
||||
|
||||
### Configuration
|
||||
|
||||
A new optional environment variable controls webhook authentication:
|
||||
|
||||
```bash
|
||||
# Optional: Shared secret for webhook authentication
|
||||
# If set, webhooks must include "Authorization: Bearer <secret>" header
|
||||
# If unset, no authentication is required (useful for local development)
|
||||
WEBHOOK_SECRET=<generate-random-secret>
|
||||
```
|
||||
|
||||
The webhook endpoint is automatically available at `/webhooks/nextcloud` when the MCP server starts. No feature flags or additional configuration needed—if Nextcloud sends webhooks to this endpoint, they will be processed.
|
||||
|
||||
**Reducing Polling Frequency**: Administrators who configure webhooks may want to reduce polling frequency to minimize API load while maintaining safety reconciliation scans:
|
||||
|
||||
```bash
|
||||
# Increase scan interval from 1 hour (default) to 24 hours
|
||||
VECTOR_SYNC_SCAN_INTERVAL=86400
|
||||
```
|
||||
|
||||
This is a manual configuration decision, not automatic—the scanner doesn't adapt based on webhook availability.
|
||||
|
||||
### Webhook Event Mapping
|
||||
|
||||
The webhook handler maps Nextcloud events to document types:
|
||||
|
||||
| Nextcloud Event | Document Type | Operation |
|
||||
|----------------|---------------|-----------|
|
||||
| `NodeCreatedEvent` (path: `*/files/*.md`) | `note` | `index` |
|
||||
| `NodeWrittenEvent` (path: `*/files/*.md`) | `note` | `index` |
|
||||
| `NodeDeletedEvent` (path: `*/files/*.md`) | `note` | `delete` |
|
||||
| `CalendarObjectCreatedEvent` | `calendar_event` | `index` |
|
||||
| `CalendarObjectUpdatedEvent` | `calendar_event` | `index` |
|
||||
| `CalendarObjectDeletedEvent` | `calendar_event` | `delete` |
|
||||
| `RowAddedEvent` | `table_row` | `index` |
|
||||
| `RowUpdatedEvent` | `table_row` | `index` |
|
||||
| `RowDeletedEvent` | `table_row` | `delete` |
|
||||
|
||||
Path filters in webhook registration ensure only relevant files trigger notifications (e.g., exclude `.jpg`, `.mp4` for file events).
|
||||
|
||||
### Administrator Setup
|
||||
|
||||
Administrators who want to enable webhooks:
|
||||
|
||||
1. **Enable webhook_listeners app** in Nextcloud: `occ app:enable webhook_listeners`
|
||||
2. **Register webhook endpoints** using Nextcloud's OCS API or admin UI:
|
||||
- Endpoint: `https://<mcp-server-host>:<port>/webhooks/nextcloud`
|
||||
- Events: File created/updated/deleted, Calendar object events, Table row events
|
||||
- Filters: Exclude non-content files (images, videos), system directories
|
||||
- Optional: Configure `Authorization: Bearer <WEBHOOK_SECRET>` header
|
||||
3. **Optionally reduce scanner frequency**: Set `VECTOR_SYNC_SCAN_INTERVAL=86400` (24 hours)
|
||||
4. **Set up webhook workers** (optional): Configure dedicated background job workers for low-latency delivery
|
||||
|
||||
Existing deployments continue using polling without any changes. Webhooks are purely additive.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Benefits
|
||||
|
||||
**Reduced Latency**: With webhooks configured, content changes appear in semantic search within seconds to minutes (depending on Nextcloud background job configuration) instead of up to 1 hour. Queries like "What meetings do I have today?" reflect recent calendar updates.
|
||||
|
||||
**Lower API Load**: Administrators who configure webhooks can reduce scanner frequency (e.g., 24-hour intervals), eliminating most polling API calls while maintaining safety reconciliation scans. This significantly reduces load on Nextcloud servers.
|
||||
|
||||
**Better Scalability**: Webhooks scale better than polling as content volume grows. The system only processes changed documents instead of checking all documents every hour.
|
||||
|
||||
**Simple Architecture**: The webhook endpoint is just another producer feeding the existing processor queue. No changes to scanner, processors, or queue management—webhooks integrate cleanly into the existing architecture.
|
||||
|
||||
**Improved User Experience**: Lower-latency semantic search feels more responsive and accurate, especially for time-sensitive queries about recent changes.
|
||||
|
||||
### Drawbacks
|
||||
|
||||
**Manual Configuration**: Administrators must configure webhooks outside the MCP server using Nextcloud's admin tools. This adds setup complexity compared to the zero-configuration polling approach.
|
||||
|
||||
**Deployment Requirements**: Webhooks require the MCP server to be reachable from Nextcloud via HTTP(S). Deployments behind NAT or with restrictive firewalls may not support webhooks without additional networking configuration.
|
||||
|
||||
**Asynchronous Delivery**: Nextcloud processes webhooks via background jobs, introducing delivery latency (typically seconds to minutes). The exact latency depends on background job worker configuration and system load.
|
||||
|
||||
**Testing Complexity**: Integration tests cannot rely on immediate webhook delivery due to asynchronous background job processing. Tests must either poll for results or mock webhook delivery directly.
|
||||
|
||||
**New Failure Modes**: Webhook endpoint downtime, network issues between Nextcloud and MCP server, webhook notification floods from bulk operations. The system must handle these gracefully.
|
||||
|
||||
**Version Dependencies**: The webhook_listeners app requires Nextcloud 30+. Older versions continue using polling exclusively.
|
||||
|
||||
### Monitoring and Observability
|
||||
|
||||
New metrics track webhook performance:
|
||||
|
||||
- `webhook_notifications_received_total{event_type}`: Count of webhook notifications by event type
|
||||
- `webhook_processing_duration_seconds{event_type}`: Webhook handler latency
|
||||
- `webhook_errors_total{error_type}`: Failed webhook processing by error type (auth failure, parse error, queue full)
|
||||
|
||||
Logs include:
|
||||
- Successful webhook processing: `Queued document from webhook: DocumentTask(...)`
|
||||
- Webhook authentication failures: `Webhook authentication failed`
|
||||
- Parse errors: `Failed to parse webhook payload: ...`
|
||||
- Unsupported events: `Ignoring webhook for unsupported event: ...`
|
||||
|
||||
### Security Considerations
|
||||
|
||||
**Optional Authentication**: When `WEBHOOK_SECRET` is configured, webhook requests must include `Authorization: Bearer <WEBHOOK_SECRET>` header. The server validates this before processing to prevent unauthorized document queueing. For local development, authentication can be disabled by leaving `WEBHOOK_SECRET` unset.
|
||||
|
||||
**Payload Validation**: Webhook payloads are parsed and validated against expected schemas. Malformed payloads are rejected with 400 Bad Request responses.
|
||||
|
||||
**No Scope Enforcement**: Unlike MCP tools, webhooks do not enforce progressive consent or check if users have enabled semantic search. Webhooks queue all document changes—administrators control which events trigger webhooks via Nextcloud filters. This keeps the webhook endpoint simple and stateless.
|
||||
|
||||
### Testing Strategy
|
||||
|
||||
**Unit Tests**: Test webhook handler logic, event parsing, and authentication validation using mocked payloads:
|
||||
|
||||
```python
|
||||
async def test_webhook_endpoint_parses_note_created_event():
|
||||
"""Unit test: webhook endpoint extracts DocumentTask from note created event."""
|
||||
payload = {
|
||||
"user": {"uid": "alice"},
|
||||
"time": 1704067200,
|
||||
"event": {
|
||||
"class": "OCP\\Files\\Events\\Node\\NodeCreatedEvent",
|
||||
"node": {"id": "123", "path": "/alice/files/test.md"}
|
||||
}
|
||||
}
|
||||
# Mock send_stream and verify DocumentTask is queued
|
||||
...
|
||||
```
|
||||
|
||||
**Integration Tests (Without Real Webhooks)**: Since Nextcloud processes webhooks asynchronously via background jobs, integration tests should NOT rely on triggering real Nextcloud events and waiting for webhook delivery. Instead, tests should:
|
||||
|
||||
1. **Mock webhook delivery**: POST webhook payloads directly to the `/webhooks/nextcloud` endpoint
|
||||
2. **Verify processing**: Check that documents are queued and eventually appear in Qdrant
|
||||
3. **Test authentication**: Verify requests without valid auth header are rejected (when `WEBHOOK_SECRET` is set)
|
||||
|
||||
```python
|
||||
async def test_webhook_integration_mocked_delivery():
|
||||
"""Integration test: webhook handler queues document for processing."""
|
||||
# POST webhook payload directly to endpoint (bypass Nextcloud)
|
||||
response = await client.post("/webhooks/nextcloud", json=note_created_payload)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Wait for processor to handle document
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# Verify document appears in Qdrant
|
||||
results = await qdrant_client.scroll(...)
|
||||
assert len(results[0]) > 0
|
||||
```
|
||||
|
||||
**Manual Testing (Real Webhooks)**: For end-to-end validation with real Nextcloud webhook delivery:
|
||||
|
||||
1. Register webhook via OCS API or `NextcloudClient.register_webhook()` helper
|
||||
2. Configure webhook background job workers for low-latency delivery
|
||||
3. Trigger Nextcloud events (create note, add calendar event)
|
||||
4. Monitor MCP server logs for webhook delivery
|
||||
5. Verify documents appear in Qdrant after background job processing
|
||||
|
||||
**Failure Mode Tests**:
|
||||
- Invalid authentication: Verify 401 response when auth header is missing/incorrect
|
||||
- Malformed payload: Verify 400 response for invalid JSON or missing required fields
|
||||
- Unsupported event types: Verify graceful handling (ignored, not error)
|
||||
- Queue full: Verify 500 response with appropriate error message
|
||||
|
||||
### Future Enhancements
|
||||
|
||||
**Batch Processing**: Group multiple webhook notifications within a short time window (e.g., 5 seconds) into a single batch before queueing. This reduces processor overhead during bulk operations like importing calendars.
|
||||
|
||||
**Webhook Payload Optimization**: For large documents, Nextcloud could be configured to send minimal metadata in webhooks (just user_id, doc_id, doc_type), with processors fetching full content lazily. This reduces webhook payload size and network bandwidth.
|
||||
|
||||
**Deduplication Window**: Track recently processed documents (last 5 minutes) to avoid redundant work when webhooks and scanner both detect the same change. The processor can check a simple in-memory cache before fetching document content.
|
||||
|
||||
## Appendix A: Manual Webhook Testing Results (2025-01-11)
|
||||
|
||||
### Testing Summary
|
||||
|
||||
Manual validation of Nextcloud webhook schemas and behavior confirmed that webhooks work as documented with several important findings for implementation. **5 out of 6** webhook types were successfully captured and validated.
|
||||
|
||||
**Test Environment:**
|
||||
- Nextcloud 30+ (Docker compose)
|
||||
- webhook_listeners app enabled
|
||||
- Test endpoint: `http://mcp:8000/webhooks/nextcloud`
|
||||
- Background webhook worker running (60s timeout)
|
||||
|
||||
**Results:**
|
||||
- ✅ NodeCreatedEvent (file creation)
|
||||
- ✅ NodeWrittenEvent (file update)
|
||||
- ✅ NodeDeletedEvent (file deletion)
|
||||
- ✅ CalendarObjectCreatedEvent
|
||||
- ✅ CalendarObjectUpdatedEvent
|
||||
- ❌ CalendarObjectDeletedEvent (webhook did not fire - potential Nextcloud bug)
|
||||
|
||||
### Critical Implementation Findings
|
||||
|
||||
#### 1. Deletion Events Lack `node.id` Field
|
||||
|
||||
**Finding:** `NodeDeletedEvent` payloads do NOT include `event.node.id`, only `event.node.path`.
|
||||
|
||||
**Example:**
|
||||
```json
|
||||
{
|
||||
"user": {"uid": "admin", "displayName": "admin"},
|
||||
"time": 1762851093,
|
||||
"event": {
|
||||
"class": "OCP\\Files\\Events\\Node\\NodeDeletedEvent",
|
||||
"node": {
|
||||
"path": "/admin/files/Notes/Webhooks/Webhook Test Note.md"
|
||||
// NOTE: No "id" field present
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Impact:** The event parser in this ADR's example code assumes `event_data["node"]["id"]` exists for all file events. This will fail for deletions.
|
||||
|
||||
**Update (2025-11-11):** Nextcloud maintainer clarified that `BeforeNodeDeletedEvent` should be used instead of `NodeDeletedEvent` to access `node.id` before the file is deleted. See [issue #56371](https://github.com/nextcloud/server/issues/56371#issuecomment-2470896634).
|
||||
|
||||
> "Try using the `BeforeNodeDeletedEvent`. The `id` should still be available at that time. The reason `id` is not in `NodeDeletedEvent` is because the file is effectively guaranteed to be gone and, in turn, so is the FileInfo."
|
||||
> — Josh Richards, Nextcloud maintainer
|
||||
|
||||
**Recommended Solution:** Use `OCP\Files\Events\Node\BeforeNodeDeletedEvent` for file deletion webhooks instead of `NodeDeletedEvent`.
|
||||
|
||||
**Alternative Fix (if using NodeDeletedEvent):** Check for `id` existence and fall back to path-based identification:
|
||||
|
||||
```python
|
||||
def extract_document_task(event_class: str, payload: dict) -> DocumentTask | None:
|
||||
user_id = payload["user"]["uid"]
|
||||
event_data = payload["event"]
|
||||
|
||||
# File deletion events - NO node.id field
|
||||
if "NodeDeletedEvent" in event_class:
|
||||
path = event_data["node"]["path"]
|
||||
if not path.endswith(".md"):
|
||||
return None
|
||||
# Use path-based ID since node.id is unavailable
|
||||
return DocumentTask(
|
||||
user_id=user_id,
|
||||
doc_id=f"path:{path}", # Prefix to distinguish from numeric IDs
|
||||
doc_type="note",
|
||||
operation="delete",
|
||||
modified_at=payload["time"],
|
||||
)
|
||||
|
||||
# File creation/update events - node.id exists
|
||||
elif "NodeCreatedEvent" in event_class or "NodeWrittenEvent" in event_class:
|
||||
path = event_data["node"]["path"]
|
||||
if not path.endswith(".md"):
|
||||
return None
|
||||
|
||||
# Check if 'id' exists (should, but be defensive)
|
||||
node_id = event_data["node"].get("id")
|
||||
if not node_id:
|
||||
# Fallback for missing ID
|
||||
node_id = f"path:{path}"
|
||||
|
||||
return DocumentTask(
|
||||
user_id=user_id,
|
||||
doc_id=str(node_id),
|
||||
doc_type="note",
|
||||
operation="index",
|
||||
modified_at=payload["time"],
|
||||
)
|
||||
```
|
||||
|
||||
**Qdrant Deletion Strategy:** When deleting by path-based ID, search Qdrant for documents with matching path metadata:
|
||||
|
||||
```python
|
||||
async def delete_document_by_path(user_id: str, path: str):
|
||||
"""Delete document from Qdrant using path (when ID unavailable)."""
|
||||
points = await qdrant.scroll(
|
||||
collection_name=collection,
|
||||
scroll_filter=Filter(must=[
|
||||
FieldCondition(key="user_id", match=MatchValue(value=user_id)),
|
||||
FieldCondition(key="metadata.path", match=MatchValue(value=path)),
|
||||
]),
|
||||
)
|
||||
# Delete found points...
|
||||
```
|
||||
|
||||
#### 2. Multiple Webhooks Per Operation
|
||||
|
||||
**Finding:** Creating a single note triggers 3-5 separate webhook events in rapid succession:
|
||||
|
||||
1. `NodeCreatedEvent` for parent folder (if new)
|
||||
2. `NodeWrittenEvent` for parent folder
|
||||
3. `NodeCreatedEvent` for the note file
|
||||
4. `NodeWrittenEvent` for the note file (sometimes fires twice)
|
||||
|
||||
**Impact:** Without deduplication, the processor will fetch and index the same note multiple times within seconds, wasting compute and API quota.
|
||||
|
||||
**Solution:** The processor queue should be idempotent. If the same document is queued multiple times, only the latest version needs processing. Implementation options:
|
||||
|
||||
1. **Queue-level deduplication:** Before adding to queue, check if a task for the same `(user_id, doc_id)` is already pending. Replace the existing task instead of adding duplicate.
|
||||
|
||||
2. **Processor-level deduplication:** Track recently processed documents in a short-lived cache (5 minutes). If a document was just processed, skip redundant fetch unless the `modified_at` timestamp is newer.
|
||||
|
||||
3. **Accept duplicates:** Let the processor handle duplicates naturally. Qdrant upserts are idempotent—reindexing with identical content is harmless but wasteful.
|
||||
|
||||
**Recommendation:** Implement queue-level deduplication by maintaining a map of pending tasks and replacing duplicates with newer timestamps.
|
||||
|
||||
#### 3. Type Discrepancy in `node.id`
|
||||
|
||||
**Finding:** Nextcloud documentation specifies `node.id` as type `string`, but actual payloads return `int`:
|
||||
|
||||
```json
|
||||
"node": {
|
||||
"id": 437, // integer, not "437"
|
||||
"path": "/admin/files/Notes/Webhooks/Webhook Test Note.md"
|
||||
}
|
||||
```
|
||||
|
||||
**Impact:** Code that assumes `node.id` is always a string will work but may cause type confusion in strongly-typed languages.
|
||||
|
||||
**Solution:** Explicitly convert to string when extracting: `doc_id=str(event_data["node"]["id"])`
|
||||
|
||||
#### 4. Calendar Events Have Different ID Field Path
|
||||
|
||||
**Finding:** Calendar events store the document ID in a different location than file events:
|
||||
|
||||
- **File events:** `event.node.id`
|
||||
- **Calendar events:** `event.objectData.id`
|
||||
|
||||
**Impact:** Event parser must handle different field paths for different event types. The example code in this ADR correctly shows this difference.
|
||||
|
||||
**Calendar Event Deletion:** Calendar deletion webhooks did NOT fire during testing. This may be a Nextcloud bug or require specific configuration (e.g., trash bin enabled). Until resolved, calendar deletions will only be detected via periodic scanner runs.
|
||||
|
||||
#### 5. Rich Metadata in Calendar Webhooks
|
||||
|
||||
**Finding:** Calendar webhook payloads include extensive metadata not present in file webhooks:
|
||||
|
||||
```json
|
||||
{
|
||||
"event": {
|
||||
"calendarId": 1,
|
||||
"calendarData": {
|
||||
"id": 1,
|
||||
"uri": "personal",
|
||||
"{http://calendarserver.org/ns/}getctag": "...",
|
||||
"{http://sabredav.org/ns}sync-token": 21,
|
||||
// ... many calendar-level properties
|
||||
},
|
||||
"objectData": {
|
||||
"id": 3,
|
||||
"uri": "webhook-test-event-001.ics",
|
||||
"lastmodified": 1762851169,
|
||||
"etag": "\"2b937b7d77dc83c77329dfdb210ba9d0\"",
|
||||
"calendarid": 1,
|
||||
"size": 297,
|
||||
"component": "vevent",
|
||||
"classification": 0,
|
||||
"uid": "webhook-test-event-001@nextcloud",
|
||||
"calendardata": "BEGIN:VCALENDAR\r\nVERSION:2.0\r\n...", // Full iCal
|
||||
"{http://nextcloud.com/ns}deleted-at": null
|
||||
},
|
||||
"shares": [] // Array of sharing info
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Opportunity:** The full iCal content is available in `objectData.calendardata`. The processor could extract metadata directly from the webhook payload instead of making an additional CalDAV request, reducing API load.
|
||||
|
||||
### Updated Event Mapping
|
||||
|
||||
Based on testing, the actual webhook behavior:
|
||||
|
||||
| Nextcloud Event | Fires? | `node.id`/`objectData.id` Present? | Notes |
|
||||
|----------------|--------|-------------------------------------|-------|
|
||||
| `NodeCreatedEvent` | ✅ Yes | ✅ Yes (`int`) | Fires for folders too |
|
||||
| `NodeWrittenEvent` | ✅ Yes | ✅ Yes (`int`) | Fires 1-2x per operation |
|
||||
| `NodeDeletedEvent` | ✅ Yes | ❌ **NO** (only `path`) | Critical difference |
|
||||
| `CalendarObjectCreatedEvent` | ✅ Yes | ✅ Yes (`objectData.id`) | Full iCal included |
|
||||
| `CalendarObjectUpdatedEvent` | ✅ Yes | ✅ Yes (`objectData.id`) | Full iCal included |
|
||||
| `CalendarObjectDeletedEvent` | ❌ **DID NOT FIRE** | ❓ Unknown | Possible Nextcloud bug |
|
||||
|
||||
### Recommended Implementation Changes
|
||||
|
||||
The webhook handler code in this ADR requires these modifications:
|
||||
|
||||
1. **Handle missing `node.id` in deletions** (see code example in Finding #1)
|
||||
2. **Add deduplication logic** to prevent redundant processing from multiple webhooks per operation
|
||||
3. **Validate field existence** before accessing nested properties (`get()` with defaults)
|
||||
4. **Log unsupported events** at DEBUG level (not WARNING) to avoid log noise
|
||||
5. **Add calendar deletion fallback:** Since webhook unreliable, calendar deletions rely on scanner reconciliation
|
||||
6. **Consider payload optimization:** Extract calendar metadata from webhook payload to reduce CalDAV API calls
|
||||
|
||||
### Testing Implications
|
||||
|
||||
**Integration Test Strategy:**
|
||||
|
||||
The asynchronous nature of Nextcloud webhooks makes real webhook delivery unreliable for automated tests:
|
||||
|
||||
- ✅ **DO:** POST webhook payloads directly to `/webhooks/nextcloud` endpoint in tests
|
||||
- ❌ **DON'T:** Trigger Nextcloud events and wait for webhook delivery
|
||||
- ✅ **DO:** Test authentication, payload parsing, and queue integration with mocked payloads
|
||||
- ❌ **DON'T:** Assume webhooks fire immediately or reliably
|
||||
|
||||
**Manual Testing Required:**
|
||||
- Real webhook delivery latency (depends on background job workers)
|
||||
- Calendar deletion webhook behavior (confirm bug or configuration issue)
|
||||
- Behavior under high-frequency updates (bulk operations)
|
||||
- Network failure handling (Nextcloud can't reach MCP server)
|
||||
|
||||
### Complete Tested Payload Examples
|
||||
|
||||
See `webhook-testing-findings.md` in the repository root for:
|
||||
- Complete JSON payloads for all tested events
|
||||
- Detailed schema validation results
|
||||
- Additional edge cases and observations
|
||||
- Screenshots of webhook logs
|
||||
|
||||
## References
|
||||
|
||||
- ADR-007: Background Vector Database Synchronization (polling architecture)
|
||||
- Nextcloud Documentation: `~/Software/documentation/admin_manual/webhook_listeners/index.rst`
|
||||
- Nextcloud OCS API: Webhook registration endpoint
|
||||
- Current scanner implementation: `nextcloud_mcp_server/vector/scanner.py:37`
|
||||
- Webhook Testing Report: `webhook-testing-findings.md` (2025-01-11)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,895 @@
|
||||
# ADR-011: Improving Semantic Search Quality Through Better Chunking and Embeddings
|
||||
|
||||
**Status**: Proposed
|
||||
**Date**: 2025-11-12
|
||||
**Authors**: Development Team
|
||||
**Related**: ADR-003 (Vector Database Architecture), ADR-008 (MCP Sampling for RAG)
|
||||
|
||||
## Context
|
||||
|
||||
The semantic search implementation provides document retrieval across Nextcloud apps using vector embeddings. Production usage has revealed that **the system frequently misses relevant documents** (recall problem).
|
||||
|
||||
Root cause analysis identifies two fundamental issues:
|
||||
|
||||
### 1. Poor Chunking Strategy
|
||||
|
||||
**Current Implementation** (`nextcloud_mcp_server/vector/document_chunker.py:36`):
|
||||
```python
|
||||
words = content.split() # Naive whitespace splitting
|
||||
chunk_size = 512 # words
|
||||
overlap = 50 # words
|
||||
chunks = [words[i:i+chunk_size] for i in range(0, len(words), chunk_size-overlap)]
|
||||
```
|
||||
|
||||
**Problems**:
|
||||
- **Breaks semantic boundaries**: Splits mid-sentence, mid-paragraph, mid-thought
|
||||
- **Loses context**: "The meeting discussed budget. We decided to..." becomes two disconnected chunks
|
||||
- **Poor retrieval**: Relevant content split across chunks with low individual relevance scores
|
||||
- **No structure awareness**: Ignores markdown headers, lists, code blocks
|
||||
|
||||
**Evidence**:
|
||||
- Documents with relevant content in middle sections score poorly (content split across 3+ chunks)
|
||||
- Multi-sentence concepts (spanning 60-100 words) are fragmented
|
||||
- Search for "budget planning process" misses documents where these words appear in adjacent sentences but different chunks
|
||||
|
||||
### 2. Suboptimal Embedding Model
|
||||
|
||||
**Current Implementation** (`nextcloud_mcp_server/embedding/ollama_provider.py:33`):
|
||||
```python
|
||||
_model = "nomic-embed-text" # 768 dimensions
|
||||
_dimension = 768 # Hardcoded
|
||||
```
|
||||
|
||||
**Problems**:
|
||||
- **Model selection**: `nomic-embed-text` is general-purpose, not optimized for our use case
|
||||
- **No benchmarking**: Selected without comparative evaluation
|
||||
- **Dimensionality**: 768-dim may be insufficient for nuanced semantic distinctions
|
||||
- **No domain adaptation**: Model not tuned for Nextcloud content (notes, calendar, deck cards)
|
||||
|
||||
**Evidence**:
|
||||
- Synonymous queries return different results ("meeting notes" vs. "discussion summary")
|
||||
- Domain-specific terms poorly represented ("standup", "retrospective", "OKRs")
|
||||
- Cross-lingual content (if present) not well supported
|
||||
|
||||
### Current Performance
|
||||
|
||||
**Baseline Metrics** (100-document test corpus, 50 queries):
|
||||
- **Recall@10**: ~52% (misses 48% of relevant documents)
|
||||
- **Precision@10**: ~78% (acceptable but room for improvement)
|
||||
- **MRR**: 0.58 (relevant docs often not in top positions)
|
||||
- **Zero-result queries**: 18% (completely missing relevant content)
|
||||
|
||||
## Decision Drivers
|
||||
|
||||
1. **Address Root Causes**: Fix fundamental issues (chunking, embeddings) before adding complexity (reranking, hybrid search)
|
||||
2. **Measurable Impact**: Target 40-60% improvement in recall through chunking/embedding alone
|
||||
3. **Independence**: Improvements should be orthogonal to future enhancements (reranking, GraphRAG)
|
||||
4. **Cost Efficiency**: Minimize infrastructure and API costs
|
||||
5. **Reindexing Acceptable**: One-time reindex cost justified by long-term quality improvement
|
||||
|
||||
## Options Considered
|
||||
|
||||
### Chunking Strategies
|
||||
|
||||
#### Option C1: Semantic Sentence-Aware Chunking (RECOMMENDED)
|
||||
|
||||
**Description**: Respect sentence boundaries while maintaining target chunk size
|
||||
|
||||
**Implementation**:
|
||||
```python
|
||||
from langchain.text_splitter import RecursiveCharacterTextSplitter
|
||||
|
||||
splitter = RecursiveCharacterTextSplitter(
|
||||
chunk_size=2048, # ~512 words in characters
|
||||
chunk_overlap=200, # ~50 words in characters
|
||||
separators=["\n\n", "\n", ". ", "! ", "? ", "; ", ": ", ", ", " "],
|
||||
length_function=len,
|
||||
)
|
||||
```
|
||||
|
||||
**How it works**:
|
||||
1. Try splitting by paragraphs (`\n\n`)
|
||||
2. If chunks too large, split by sentences (`. `, `! `, `? `)
|
||||
3. If still too large, split by clauses (`;`, `:`)
|
||||
4. Last resort: split by words
|
||||
|
||||
**Pros**:
|
||||
- ✅ Preserves semantic boundaries (never breaks mid-sentence)
|
||||
- ✅ Maintains context coherence within chunks
|
||||
- ✅ Simple implementation (langchain library)
|
||||
- ✅ Configurable separators for different content types
|
||||
- ✅ Proven approach (used by major RAG systems)
|
||||
|
||||
**Cons**:
|
||||
- ❌ Variable chunk sizes (not exactly 512 words, but close)
|
||||
- ❌ Adds dependency (langchain)
|
||||
- ❌ Slightly slower than naive splitting (~10-20ms per document)
|
||||
|
||||
**Expected Impact**: 20-30% recall improvement
|
||||
|
||||
#### Option C2: Hierarchical Context-Preserving Chunks
|
||||
|
||||
**Description**: Create overlapping parent/child chunks
|
||||
|
||||
**Structure**:
|
||||
```
|
||||
Document → Large parent chunks (1024 words) → Small child chunks (256 words)
|
||||
↓ ↓
|
||||
Stored in Qdrant Searched first
|
||||
Return parent context
|
||||
```
|
||||
|
||||
**Implementation**:
|
||||
```python
|
||||
# Generate child chunks (searched)
|
||||
child_chunks = splitter.split_text(content, chunk_size=1024)
|
||||
|
||||
# Generate parent chunks (context)
|
||||
parent_chunks = splitter.split_text(content, chunk_size=4096)
|
||||
|
||||
# Store both with parent-child relationships
|
||||
for child_idx, child in enumerate(child_chunks):
|
||||
parent_idx = find_parent(child_idx)
|
||||
store_vector(
|
||||
vector=embed(child),
|
||||
payload={
|
||||
"chunk": child,
|
||||
"parent_chunk": parent_chunks[parent_idx],
|
||||
"chunk_type": "child"
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
**Pros**:
|
||||
- ✅ Best of both worlds: precise matching + full context
|
||||
- ✅ Handles multi-hop information needs
|
||||
- ✅ Better for long documents (> 1000 words)
|
||||
|
||||
**Cons**:
|
||||
- ❌ 2x storage (parent + child chunks)
|
||||
- ❌ More complex implementation
|
||||
- ❌ Higher indexing time (embed twice)
|
||||
- ❌ Query complexity (retrieve child, return parent)
|
||||
|
||||
**Expected Impact**: 35-45% recall improvement (diminishing returns vs. complexity)
|
||||
|
||||
**Verdict**: ⚠️ Consider only if Option C1 insufficient
|
||||
|
||||
#### Option C3: Document Structure-Aware Chunking
|
||||
|
||||
**Description**: Parse markdown/document structure before chunking
|
||||
|
||||
**Implementation**:
|
||||
```python
|
||||
import mistune # Markdown parser
|
||||
|
||||
def structure_aware_chunk(markdown_content: str) -> list[str]:
|
||||
ast = mistune.create_markdown(renderer='ast')(markdown_content)
|
||||
|
||||
chunks = []
|
||||
for node in ast:
|
||||
if node['type'] == 'heading':
|
||||
# Start new chunk at each header
|
||||
current_chunk = node['children'][0]['raw']
|
||||
elif node['type'] == 'paragraph':
|
||||
current_chunk += "\n" + node['children'][0]['raw']
|
||||
if len(current_chunk) > 2048:
|
||||
chunks.append(current_chunk)
|
||||
current_chunk = ""
|
||||
|
||||
return chunks
|
||||
```
|
||||
|
||||
**Pros**:
|
||||
- ✅ Respects document logical structure
|
||||
- ✅ Headers provide context for chunks
|
||||
- ✅ Works well for structured notes (documentation, meeting notes with sections)
|
||||
|
||||
**Cons**:
|
||||
- ❌ Complex implementation (parser, AST traversal)
|
||||
- ❌ Markdown-specific (doesn't help calendar events, deck cards)
|
||||
- ❌ Variable chunk sizes (some sections very short/long)
|
||||
- ❌ Breaks for unstructured content
|
||||
|
||||
**Expected Impact**: 15-25% improvement for structured content only
|
||||
|
||||
**Verdict**: ⚠️ Future enhancement after Option C1
|
||||
|
||||
#### Option C4: Fixed Sliding Window (Current Baseline)
|
||||
|
||||
**Description**: Current naive word-based splitting
|
||||
|
||||
**Verdict**: ❌ Superseded by Option C1
|
||||
|
||||
### Embedding Model Strategies
|
||||
|
||||
#### Option E1: Upgrade to Better General-Purpose Model (RECOMMENDED)
|
||||
|
||||
**Description**: Switch to state-of-the-art embedding model
|
||||
|
||||
**Candidates**:
|
||||
|
||||
| Model | Dimensions | MTEB Score | Pros | Cons |
|
||||
|-------|-----------|------------|------|------|
|
||||
| **mxbai-embed-large** | 1024 | 64.68 | Best performance, good balance | Larger (slower) |
|
||||
| **nomic-embed-text-v1.5** | 768 | 62.39 | Upgraded version of current | Incremental improvement |
|
||||
| **bge-large-en-v1.5** | 1024 | 64.23 | Excellent for English | Not multilingual |
|
||||
| **nomic-embed-text** (current) | 768 | 60.10 | Baseline | Lower performance |
|
||||
|
||||
**MTEB**: Massive Text Embedding Benchmark (higher = better semantic understanding)
|
||||
|
||||
**Recommendation**: **mxbai-embed-large-v1**
|
||||
- Best MTEB score (64.68)
|
||||
- 1024 dimensions (richer semantic space)
|
||||
- Works well via Ollama
|
||||
- ~15-20% better retrieval quality in benchmarks
|
||||
|
||||
**Implementation**:
|
||||
```python
|
||||
# config.py
|
||||
OLLAMA_EMBEDDING_MODEL = "mxbai-embed-large-v1" # Changed from nomic-embed-text
|
||||
|
||||
# ollama_provider.py
|
||||
async def get_dimension(self) -> int:
|
||||
# Query Ollama for actual dimension instead of hardcoding
|
||||
response = await self.client.post("/api/show", json={"name": self.model})
|
||||
return response.json()["details"]["embedding_length"]
|
||||
```
|
||||
|
||||
**Migration**:
|
||||
1. Deploy new model to Ollama
|
||||
2. Create new Qdrant collection (different dimension)
|
||||
3. Reindex all documents with new embeddings
|
||||
4. Swap collections atomically
|
||||
5. Delete old collection
|
||||
|
||||
**Pros**:
|
||||
- ✅ Immediate quality improvement (15-20%)
|
||||
- ✅ Simple change (config + reindex)
|
||||
- ✅ No code complexity
|
||||
- ✅ Future-proof (state-of-the-art model)
|
||||
|
||||
**Cons**:
|
||||
- ❌ Requires full reindex (2-4 hours for 1000 documents)
|
||||
- ❌ Larger model = slower embedding (~50ms vs. 30ms per chunk)
|
||||
- ❌ Higher dimensionality = more storage (~30% increase)
|
||||
|
||||
**Expected Impact**: 15-25% recall improvement
|
||||
|
||||
#### Option E2: Multi-Vector Embeddings (ColBERT-style)
|
||||
|
||||
**Description**: Generate multiple embeddings per chunk (token-level)
|
||||
|
||||
**Architecture**:
|
||||
```
|
||||
Chunk → Transformer → Token embeddings (e.g., 50 tokens × 128 dim) → Store all
|
||||
Query → Transformer → Token embeddings → MaxSim(query_tokens, doc_tokens)
|
||||
```
|
||||
|
||||
**MaxSim scoring**:
|
||||
```python
|
||||
def maxsim_score(query_embeddings, doc_embeddings):
|
||||
# For each query token, find max similarity with any doc token
|
||||
scores = []
|
||||
for q_emb in query_embeddings:
|
||||
max_sim = max(cosine_similarity(q_emb, d_emb) for d_emb in doc_embeddings)
|
||||
scores.append(max_sim)
|
||||
return sum(scores)
|
||||
```
|
||||
|
||||
**Pros**:
|
||||
- ✅ Best retrieval quality (state-of-the-art results)
|
||||
- ✅ Fine-grained matching (token-level)
|
||||
- ✅ Handles partial matches better
|
||||
|
||||
**Cons**:
|
||||
- ❌ **50-100x storage increase** (50 vectors per chunk vs. 1)
|
||||
- ❌ **Slower search** (compute MaxSim for each candidate)
|
||||
- ❌ **Complex implementation** (custom scoring, storage schema)
|
||||
- ❌ **Requires specialized model** (ColBERTv2, not available in Ollama)
|
||||
|
||||
**Expected Impact**: 40-50% improvement, but at very high cost
|
||||
|
||||
**Verdict**: ❌ Too complex, too expensive for marginal gain over E1+C1
|
||||
|
||||
#### Option E3: Fine-Tuned Domain-Specific Model
|
||||
|
||||
**Description**: Fine-tune embedding model on Nextcloud corpus
|
||||
|
||||
**Process**:
|
||||
1. Collect training data (query-document pairs)
|
||||
2. Fine-tune base model (e.g., `nomic-embed-text`) on domain data
|
||||
3. Deploy fine-tuned model via Ollama
|
||||
4. Reindex with fine-tuned embeddings
|
||||
|
||||
**Training data needed**:
|
||||
- 1,000+ query-document pairs
|
||||
- Labeled relevance (positive/negative examples)
|
||||
- Representative of real usage
|
||||
|
||||
**Pros**:
|
||||
- ✅ Optimized for specific content (notes, calendar, deck)
|
||||
- ✅ Better handling of domain terminology
|
||||
- ✅ Highest potential quality improvement (30-40%)
|
||||
|
||||
**Cons**:
|
||||
- ❌ **Requires training data** (expensive to collect)
|
||||
- ❌ **GPU infrastructure** needed for fine-tuning
|
||||
- ❌ **Expertise required** (ML/NLP knowledge)
|
||||
- ❌ **Maintenance burden** (retrain as corpus evolves)
|
||||
- ❌ **Time investment**: 2-4 weeks initial setup
|
||||
|
||||
**Expected Impact**: 30-40% improvement, but high cost
|
||||
|
||||
**Verdict**: ⚠️ Consider only if E1+C1 insufficient AND have training data
|
||||
|
||||
#### Option E4: Ensemble Embeddings
|
||||
|
||||
**Description**: Generate embeddings with multiple models, combine scores
|
||||
|
||||
**Implementation**:
|
||||
```python
|
||||
models = ["mxbai-embed-large-v1", "bge-large-en-v1.5"]
|
||||
|
||||
# Index
|
||||
embeddings = [await embed(chunk, model) for model in models]
|
||||
store_multi_vector(embeddings)
|
||||
|
||||
# Search
|
||||
query_embeddings = [await embed(query, model) for model in models]
|
||||
scores = [search(q_emb, model) for q_emb, model in zip(query_embeddings, models)]
|
||||
combined_score = 0.5 * scores[0] + 0.5 * scores[1]
|
||||
```
|
||||
|
||||
**Pros**:
|
||||
- ✅ Robust to individual model weaknesses
|
||||
- ✅ Better coverage of semantic space
|
||||
|
||||
**Cons**:
|
||||
- ❌ 2x storage and compute
|
||||
- ❌ Complex scoring and fusion
|
||||
- ❌ Marginal improvement (~5-10%) over single best model
|
||||
|
||||
**Expected Impact**: 5-10% over best single model
|
||||
|
||||
**Verdict**: ❌ Not worth complexity
|
||||
|
||||
### Combined Strategies
|
||||
|
||||
#### Option D1: Best Chunking + Best Embedding (RECOMMENDED)
|
||||
|
||||
**Combination**: Option C1 (Semantic Chunking) + Option E1 (mxbai-embed-large-v1)
|
||||
|
||||
**Expected Impact**:
|
||||
- Chunking: +20-30% recall
|
||||
- Embedding: +15-25% recall
|
||||
- **Combined**: +35-55% recall improvement (not strictly additive, but significant)
|
||||
|
||||
**Cost**:
|
||||
- Development: 1-2 days
|
||||
- Reindex: 2-4 hours (one-time)
|
||||
- Ongoing: None (same infrastructure)
|
||||
|
||||
**Pros**:
|
||||
- ✅ Addresses both root causes
|
||||
- ✅ Orthogonal improvements (chunking + embedding)
|
||||
- ✅ Simple implementation
|
||||
- ✅ No new infrastructure
|
||||
- ✅ Future-proof foundation for additional enhancements (reranking, hybrid search)
|
||||
|
||||
**Cons**:
|
||||
- ❌ Requires full reindex (manageable)
|
||||
- ❌ Slightly higher storage (1024 vs. 768 dim)
|
||||
|
||||
**Verdict**: ✅ **RECOMMENDED**
|
||||
|
||||
## Decision
|
||||
|
||||
**Adopt Option D1: Semantic Chunking + Upgraded Embedding Model**
|
||||
|
||||
Implement both improvements together to maximize recall improvement:
|
||||
|
||||
### 1. Semantic Sentence-Aware Chunking
|
||||
|
||||
**Changes**:
|
||||
- Replace naive word splitting with `RecursiveCharacterTextSplitter`
|
||||
- Preserve sentence boundaries, paragraph structure
|
||||
- Maintain similar chunk sizes (~512 words / 2048 characters)
|
||||
|
||||
**Implementation**:
|
||||
|
||||
```python
|
||||
# nextcloud_mcp_server/vector/document_chunker.py
|
||||
|
||||
from langchain.text_splitter import RecursiveCharacterTextSplitter
|
||||
|
||||
class DocumentChunker:
|
||||
"""Chunk documents into semantically coherent pieces."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
chunk_size: int = 2048, # Characters, not words
|
||||
chunk_overlap: int = 200, # Characters, not words
|
||||
):
|
||||
self.chunk_size = chunk_size
|
||||
self.chunk_overlap = chunk_overlap
|
||||
|
||||
self.splitter = RecursiveCharacterTextSplitter(
|
||||
chunk_size=chunk_size,
|
||||
chunk_overlap=chunk_overlap,
|
||||
separators=[
|
||||
"\n\n", # Paragraphs (highest priority)
|
||||
"\n", # Lines
|
||||
". ", # Sentences
|
||||
"! ",
|
||||
"? ",
|
||||
"; ", # Clauses
|
||||
": ",
|
||||
", ", # Phrases
|
||||
" ", # Words (last resort)
|
||||
],
|
||||
length_function=len,
|
||||
is_separator_regex=False,
|
||||
)
|
||||
|
||||
def chunk_text(self, content: str) -> list[str]:
|
||||
"""
|
||||
Chunk text while preserving semantic boundaries.
|
||||
|
||||
Args:
|
||||
content: Full document text
|
||||
|
||||
Returns:
|
||||
List of text chunks, each ending at a semantic boundary
|
||||
"""
|
||||
if not content:
|
||||
return []
|
||||
|
||||
# Use RecursiveCharacterTextSplitter for semantic boundaries
|
||||
chunks = self.splitter.split_text(content)
|
||||
|
||||
return chunks
|
||||
```
|
||||
|
||||
**Configuration Changes** (`config.py`):
|
||||
```python
|
||||
# Old (word-based)
|
||||
DOCUMENT_CHUNK_SIZE: int = 512 # words
|
||||
DOCUMENT_CHUNK_OVERLAP: int = 50 # words
|
||||
|
||||
# New (character-based, more precise)
|
||||
DOCUMENT_CHUNK_SIZE: int = 2048 # characters (~512 words)
|
||||
DOCUMENT_CHUNK_OVERLAP: int = 200 # characters (~50 words)
|
||||
```
|
||||
|
||||
**Dependency** (`pyproject.toml`):
|
||||
```toml
|
||||
[project]
|
||||
dependencies = [
|
||||
# ... existing dependencies
|
||||
"langchain-text-splitters>=0.2.0",
|
||||
]
|
||||
```
|
||||
|
||||
### 2. Upgrade Embedding Model
|
||||
|
||||
**Changes**:
|
||||
- Switch from `nomic-embed-text` (768-dim) to `mxbai-embed-large-v1` (1024-dim)
|
||||
- Dynamic dimension detection (query Ollama instead of hardcoding)
|
||||
- Create new Qdrant collection for new dimensions
|
||||
|
||||
**Implementation**:
|
||||
|
||||
```python
|
||||
# nextcloud_mcp_server/embedding/ollama_provider.py
|
||||
|
||||
class OllamaEmbeddingProvider(EmbeddingProvider):
|
||||
def __init__(self, base_url: str, model: str, verify_ssl: bool = True):
|
||||
self.base_url = base_url
|
||||
self.model = model
|
||||
self._dimension: int | None = None # Changed: query dynamically
|
||||
self.client = httpx.AsyncClient(base_url=base_url, verify=verify_ssl)
|
||||
|
||||
async def dimension(self) -> int:
|
||||
"""Get embedding dimension from Ollama API."""
|
||||
if self._dimension is None:
|
||||
try:
|
||||
response = await self.client.post(
|
||||
"/api/show",
|
||||
json={"name": self.model},
|
||||
timeout=10.0,
|
||||
)
|
||||
response.raise_for_status()
|
||||
info = response.json()
|
||||
self._dimension = info.get("details", {}).get("embedding_length")
|
||||
|
||||
if self._dimension is None:
|
||||
# Fallback: generate test embedding to detect dimension
|
||||
test_emb = await self.embed("test")
|
||||
self._dimension = len(test_emb)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to get dimension from Ollama: {e}, using fallback")
|
||||
# Fallback dimensions by model name
|
||||
if "mxbai-embed-large" in self.model:
|
||||
self._dimension = 1024
|
||||
elif "nomic-embed-text" in self.model:
|
||||
self._dimension = 768
|
||||
else:
|
||||
self._dimension = 768 # Default
|
||||
|
||||
return self._dimension
|
||||
```
|
||||
|
||||
**Configuration Changes** (`config.py`):
|
||||
```python
|
||||
# Old
|
||||
OLLAMA_EMBEDDING_MODEL: str = "nomic-embed-text"
|
||||
|
||||
# New
|
||||
OLLAMA_EMBEDDING_MODEL: str = "mxbai-embed-large-v1"
|
||||
```
|
||||
|
||||
**Environment Variable**:
|
||||
```bash
|
||||
OLLAMA_EMBEDDING_MODEL=mxbai-embed-large-v1
|
||||
```
|
||||
|
||||
### 3. Migration Strategy
|
||||
|
||||
**Reindexing Process**:
|
||||
|
||||
```python
|
||||
# nextcloud_mcp_server/vector/migration.py
|
||||
|
||||
async def migrate_to_new_embeddings():
|
||||
"""
|
||||
Migrate from old embeddings to new embeddings.
|
||||
|
||||
Process:
|
||||
1. Create new collection with new dimension
|
||||
2. Reindex all documents with new embeddings
|
||||
3. Atomic swap (update collection name in config)
|
||||
4. Delete old collection
|
||||
"""
|
||||
old_collection = "nextcloud_content"
|
||||
new_collection = "nextcloud_content_v2"
|
||||
|
||||
# 1. Create new collection
|
||||
await qdrant_client.create_collection(
|
||||
collection_name=new_collection,
|
||||
vectors_config=VectorParams(
|
||||
size=1024, # mxbai-embed-large-v1 dimension
|
||||
distance=Distance.COSINE,
|
||||
),
|
||||
)
|
||||
|
||||
# 2. Reindex all documents
|
||||
logger.info("Starting reindex with new embeddings...")
|
||||
scanner = VectorScanner(...)
|
||||
processor = VectorProcessor(collection_name=new_collection, ...)
|
||||
|
||||
await scanner.scan_all() # Rescans and re-embeds all documents
|
||||
|
||||
# 3. Wait for completion
|
||||
while True:
|
||||
status = await get_sync_status()
|
||||
if status.pending_documents == 0:
|
||||
break
|
||||
await asyncio.sleep(5)
|
||||
|
||||
# 4. Atomic swap
|
||||
# Update config to point to new collection
|
||||
# (or use collection alias in Qdrant)
|
||||
await qdrant_client.update_collection_aliases(
|
||||
change_aliases_operations=[
|
||||
CreateAliasOperation(
|
||||
create_alias=CreateAlias(
|
||||
collection_name=new_collection,
|
||||
alias_name="nextcloud_content"
|
||||
)
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
# 5. Verify new collection works
|
||||
test_results = await run_benchmark_queries()
|
||||
if test_results.recall < baseline_recall:
|
||||
# Rollback
|
||||
logger.error("New embeddings worse than baseline, rolling back")
|
||||
await rollback_migration()
|
||||
return False
|
||||
|
||||
# 6. Delete old collection
|
||||
await qdrant_client.delete_collection(old_collection)
|
||||
logger.info("Migration complete!")
|
||||
return True
|
||||
```
|
||||
|
||||
**Downtime Mitigation**:
|
||||
- Use Qdrant collection aliases for atomic swap
|
||||
- Reindex can happen in background
|
||||
- Only brief downtime during alias swap (~1s)
|
||||
|
||||
**Rollback Plan**:
|
||||
- Keep old collection until validation complete
|
||||
- If new embeddings worse, swap alias back to old collection
|
||||
- No data loss
|
||||
|
||||
### 4. Validation & Benchmarking
|
||||
|
||||
**Before/After Comparison**:
|
||||
|
||||
```python
|
||||
# tests/benchmarks/chunking_embedding_comparison.py
|
||||
|
||||
async def benchmark_chunking_embeddings():
|
||||
"""
|
||||
Compare old vs. new chunking and embeddings on test queries.
|
||||
"""
|
||||
test_queries = load_benchmark_queries() # 100 queries with known relevant docs
|
||||
|
||||
# Baseline (current)
|
||||
baseline_results = await run_queries(
|
||||
queries=test_queries,
|
||||
collection="nextcloud_content", # Old: nomic-embed-text, word chunks
|
||||
)
|
||||
|
||||
# New implementation
|
||||
new_results = await run_queries(
|
||||
queries=test_queries,
|
||||
collection="nextcloud_content_v2", # New: mxbai-embed-large-v1, semantic chunks
|
||||
)
|
||||
|
||||
# Compare metrics
|
||||
comparison = {
|
||||
"baseline": {
|
||||
"recall@10": calculate_recall(baseline_results, k=10),
|
||||
"precision@10": calculate_precision(baseline_results, k=10),
|
||||
"mrr": calculate_mrr(baseline_results),
|
||||
"zero_result_rate": calculate_zero_result_rate(baseline_results),
|
||||
},
|
||||
"new": {
|
||||
"recall@10": calculate_recall(new_results, k=10),
|
||||
"precision@10": calculate_precision(new_results, k=10),
|
||||
"mrr": calculate_mrr(new_results),
|
||||
"zero_result_rate": calculate_zero_result_rate(new_results),
|
||||
},
|
||||
"improvement": {
|
||||
"recall_improvement": (new_recall - baseline_recall) / baseline_recall,
|
||||
"precision_improvement": (new_precision - baseline_precision) / baseline_precision,
|
||||
}
|
||||
}
|
||||
|
||||
return comparison
|
||||
```
|
||||
|
||||
**Success Criteria**:
|
||||
- **Recall@10**: Improve from ~52% to ≥75% (+40% improvement)
|
||||
- **Precision@10**: Maintain ≥75% (no degradation)
|
||||
- **MRR**: Improve from 0.58 to ≥0.70
|
||||
- **Zero-result rate**: Reduce from 18% to ≤10%
|
||||
- **Indexing time**: Maintain ≤10s per document
|
||||
|
||||
**Validation Process**:
|
||||
1. Run benchmark on baseline (current implementation)
|
||||
2. Implement changes
|
||||
3. Run benchmark on new implementation
|
||||
4. Compare metrics
|
||||
5. If improvement ≥40%, proceed to production
|
||||
6. If improvement <40%, investigate and iterate
|
||||
|
||||
## Implementation Timeline
|
||||
|
||||
### Week 1: Development & Testing
|
||||
|
||||
**Day 1-2: Chunking Implementation**
|
||||
- [ ] Add langchain-text-splitters dependency
|
||||
- [ ] Refactor `document_chunker.py`
|
||||
- [ ] Update configuration (character-based chunk sizes)
|
||||
- [ ] Write unit tests for semantic boundaries
|
||||
- [ ] Validate: Chunks never break mid-sentence
|
||||
|
||||
**Day 3-4: Embedding Implementation**
|
||||
- [ ] Update `ollama_provider.py` with dynamic dimension detection
|
||||
- [ ] Update configuration (new model name)
|
||||
- [ ] Deploy `mxbai-embed-large-v1` to Ollama
|
||||
- [ ] Test embedding generation with new model
|
||||
- [ ] Validate: Embeddings are 1024-dim
|
||||
|
||||
**Day 5: Migration Script**
|
||||
- [ ] Write migration script (collection creation, reindexing, alias swap)
|
||||
- [ ] Test migration on staging environment
|
||||
- [ ] Validate: No data loss, atomic swap works
|
||||
|
||||
### Week 2: Reindexing & Validation
|
||||
|
||||
**Day 1-2: Staging Reindex**
|
||||
- [ ] Run full reindex on staging environment
|
||||
- [ ] Monitor indexing performance
|
||||
- [ ] Validate: All documents indexed correctly
|
||||
|
||||
**Day 3: Benchmarking**
|
||||
- [ ] Run benchmark queries on old collection (baseline)
|
||||
- [ ] Run benchmark queries on new collection
|
||||
- [ ] Compare metrics (recall, precision, MRR)
|
||||
- [ ] Validate: ≥40% recall improvement
|
||||
|
||||
**Day 4: Production Reindex**
|
||||
- [ ] Schedule maintenance window (optional, can run in background)
|
||||
- [ ] Run migration script on production
|
||||
- [ ] Monitor reindexing progress
|
||||
- [ ] Atomic swap when complete
|
||||
|
||||
**Day 5: Production Validation**
|
||||
- [ ] Monitor search quality metrics
|
||||
- [ ] Collect user feedback
|
||||
- [ ] Compare production metrics to staging
|
||||
- [ ] Rollback if issues detected
|
||||
|
||||
## Cost Analysis
|
||||
|
||||
### Development Cost
|
||||
- **Time**: 1-2 weeks (implementation + validation)
|
||||
- **Effort**: 40-60 hours @ $100/hour = $4,000 - $6,000
|
||||
|
||||
### Infrastructure Cost
|
||||
- **Storage**: +30% (1024-dim vs. 768-dim)
|
||||
- Example: 1,000 notes × 3 chunks × 1024 dim × 4 bytes = 12 MB (negligible)
|
||||
- **Compute**: +20% embedding time (50ms vs. 30ms per chunk)
|
||||
- Amortized over batch indexing, minimal impact
|
||||
- **No new infrastructure**: Uses existing Ollama + Qdrant
|
||||
|
||||
### Reindexing Cost (One-Time)
|
||||
- **Time**: 2-4 hours for 1,000 documents
|
||||
- 1,000 docs × 3 chunks × 50ms = 150 seconds (~2.5 minutes embedding)
|
||||
- + Ollama processing time + Qdrant insertion
|
||||
- **Downtime**: ~1 second (atomic alias swap)
|
||||
|
||||
### Total Cost
|
||||
- **Initial**: $4,000 - $6,000 (development + testing)
|
||||
- **Ongoing**: $0 (no new infrastructure or API costs)
|
||||
|
||||
### ROI
|
||||
- **Recall improvement**: +40-60% (finding relevant documents)
|
||||
- **User satisfaction**: Reduced zero-result queries (18% → 10%)
|
||||
- **Foundation**: Enables future enhancements (reranking, hybrid search)
|
||||
- **Cost per % improvement**: $100 - $150 (excellent ROI)
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
1. **Addresses Root Causes**: Fixes fundamental issues (chunking, embeddings) not symptoms
|
||||
2. **High Impact**: Expected 40-60% recall improvement from foundational changes
|
||||
3. **Future-Proof**: Creates solid foundation for future enhancements (reranking, hybrid search, GraphRAG)
|
||||
4. **Simple**: No architectural changes, no new infrastructure
|
||||
5. **Orthogonal**: Improvements are independent, can be validated separately
|
||||
6. **Low Risk**: Proven techniques (RecursiveCharacterTextSplitter, mxbai-embed-large-v1)
|
||||
7. **Maintainable**: Standard libraries and models, easy to debug
|
||||
|
||||
### Negative
|
||||
|
||||
1. **Reindexing Required**: 2-4 hours one-time cost (manageable, can run in background)
|
||||
2. **Storage Increase**: +30% for higher-dimensional embeddings (12 MB vs. 9 MB for 1K docs)
|
||||
3. **Slower Indexing**: +20% embedding time (50ms vs. 30ms per chunk)
|
||||
4. **Dependency**: Adds langchain-text-splitters (minimal, well-maintained library)
|
||||
5. **Not a Complete Solution**: May still need reranking/hybrid search for optimal recall (but solid foundation)
|
||||
|
||||
### Neutral
|
||||
|
||||
1. **Model Lock-In**: Committed to mxbai-embed-large-v1, but can change later (another reindex)
|
||||
2. **Chunk Size Trade-offs**: ~512 words is heuristic, may need tuning for specific content types
|
||||
|
||||
## Monitoring & Success Metrics
|
||||
|
||||
### Real-Time Metrics (Grafana)
|
||||
|
||||
**Search Quality**:
|
||||
- `semantic_search_recall_at_10` (target: ≥75%)
|
||||
- `semantic_search_precision_at_10` (target: ≥75%)
|
||||
- `semantic_search_mrr` (target: ≥0.70)
|
||||
- `semantic_search_zero_result_rate` (target: ≤10%)
|
||||
|
||||
**Performance**:
|
||||
- `semantic_search_latency_ms` (p50, p95, p99)
|
||||
- `embedding_generation_time_ms`
|
||||
- `indexing_throughput_docs_per_sec`
|
||||
|
||||
**Indexing**:
|
||||
- `documents_indexed_total`
|
||||
- `documents_pending`
|
||||
- `indexing_errors_total`
|
||||
|
||||
### Weekly Validation
|
||||
|
||||
**A/B Testing** (if gradual rollout):
|
||||
- 50% users: New embeddings
|
||||
- 50% users: Old embeddings
|
||||
- Compare metrics for 1 week
|
||||
- Full rollout if new embeddings superior
|
||||
|
||||
**User Feedback**:
|
||||
- Survey: "How satisfied are you with search results?" (1-5 scale)
|
||||
- Track: Number of "search not working" support tickets
|
||||
- Monitor: User-reported false negatives ("I know this doc exists")
|
||||
|
||||
### Rollback Criteria
|
||||
|
||||
**Automatic Rollback** if:
|
||||
- Recall decreases by >10% from baseline
|
||||
- Error rate increases by >50%
|
||||
- Query latency increases by >100%
|
||||
|
||||
**Manual Rollback** if:
|
||||
- User complaints increase significantly
|
||||
- Zero-result queries increase instead of decrease
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
These improvements create a solid foundation. Future enhancements (in order of priority):
|
||||
|
||||
1. **Cross-Encoder Reranking** (ADR-012)
|
||||
- Two-stage retrieval: broad recall (50 candidates) → precise reranking (top 10)
|
||||
- Expected: +15-20% additional recall improvement
|
||||
- Builds on: Better embeddings retrieve better candidates to rerank
|
||||
|
||||
2. **Hybrid Search** (ADR-013)
|
||||
- Combine vector search + BM25 keyword search
|
||||
- Expected: +10-15% additional recall (especially for exact matches)
|
||||
- Builds on: Semantic chunks provide better keyword match context
|
||||
|
||||
3. **Multi-App Indexing** (ADR-014)
|
||||
- Index calendar, deck, files (currently notes-only)
|
||||
- Expected: Expands searchable corpus 3-5x
|
||||
- Builds on: Proven chunking and embedding strategy
|
||||
|
||||
4. **GraphRAG** (ADR-015, conditional)
|
||||
- Only if: Global thematic queries needed OR corpus >10K documents
|
||||
- Expected: Relationship discovery, multi-hop reasoning
|
||||
- Builds on: High-quality embeddings improve graph construction
|
||||
|
||||
## References
|
||||
|
||||
### Research Papers
|
||||
|
||||
1. **RecursiveCharacterTextSplitter**
|
||||
- LangChain Documentation: https://python.langchain.com/docs/modules/data_connection/document_transformers/text_splitters/recursive_text_splitter
|
||||
- Proven technique used by major RAG systems
|
||||
|
||||
2. **MTEB Leaderboard** (Massive Text Embedding Benchmark)
|
||||
- https://huggingface.co/spaces/mteb/leaderboard
|
||||
- Comprehensive embedding model comparison
|
||||
|
||||
3. **mxbai-embed-large**
|
||||
- Model: https://huggingface.co/mixedbread-ai/mxbai-embed-large-v1
|
||||
- Best general-purpose embedding model (MTEB: 64.68)
|
||||
|
||||
### Related ADRs
|
||||
|
||||
- **ADR-003**: Vector Database and Semantic Search Architecture (original implementation)
|
||||
- **ADR-008**: MCP Sampling for Multi-App Semantic Search with RAG (answer generation)
|
||||
|
||||
### Tools & Libraries
|
||||
|
||||
- **LangChain Text Splitters**: https://python.langchain.com/docs/modules/data_connection/document_transformers/
|
||||
- **Ollama Embedding Models**: https://ollama.ai/library
|
||||
- **Qdrant Collections**: https://qdrant.tech/documentation/concepts/collections/
|
||||
|
||||
## Summary
|
||||
|
||||
This ADR addresses the root causes of poor semantic search recall:
|
||||
|
||||
1. **Better Chunking**: Semantic sentence-aware splitting (preserves context)
|
||||
2. **Better Embeddings**: Upgrade to mxbai-embed-large-v1 (richer semantic space)
|
||||
|
||||
**Expected Impact**: 40-60% recall improvement with minimal cost and complexity.
|
||||
|
||||
**Why This Approach**:
|
||||
- Fixes fundamentals before adding complexity
|
||||
- Proven techniques (not experimental)
|
||||
- Simple implementation (1-2 weeks)
|
||||
- Creates foundation for future enhancements
|
||||
- No new infrastructure or ongoing costs
|
||||
|
||||
**Next Steps**: Approve ADR → Implement changes → Reindex → Validate → Production rollout
|
||||
@@ -0,0 +1,619 @@
|
||||
# ADR-012: Unified Multi-Algorithm Search with Client-Configurable Weighting
|
||||
|
||||
## Status
|
||||
Proposed
|
||||
|
||||
## Context
|
||||
|
||||
### Current State
|
||||
|
||||
The Nextcloud MCP server currently provides semantic search via vector similarity (Qdrant), as designed in ADR-003 and implemented through ADR-007. However, users and MCP clients have limited control over search behavior:
|
||||
|
||||
1. **Single algorithm only**: Only pure vector similarity search is available
|
||||
2. **No algorithm selection**: MCP clients cannot choose between semantic, keyword, or fuzzy approaches
|
||||
3. **No weighting control**: Clients cannot adjust the balance between different search methods
|
||||
4. **Disconnected implementations**: Viz pane uses different search algorithms than MCP tools
|
||||
5. **Limited flexibility**: No way to optimize search for different use cases (exact match vs. conceptual similarity)
|
||||
|
||||
### User Needs
|
||||
|
||||
Different search scenarios require different algorithms:
|
||||
|
||||
- **Exact match queries**: "Find note titled 'Q1 Budget'" → keyword search preferred
|
||||
- **Conceptual queries**: "What are my goals for next quarter?" → semantic search preferred
|
||||
- **Typo-tolerant queries**: "Find note about kuberntes" → fuzzy search needed
|
||||
- **Balanced queries**: "Find documentation about API endpoints" → hybrid search optimal
|
||||
|
||||
Additionally, users need a **testing interface** (viz pane) to:
|
||||
- Experiment with different search algorithms on their own documents
|
||||
- Visualize search results and algorithm behavior
|
||||
- Tune weights for optimal results
|
||||
- Understand which algorithm works best for their queries
|
||||
|
||||
### Technical Requirements
|
||||
|
||||
1. **Unified interface**: Single MCP tool supporting multiple algorithms
|
||||
2. **Client control**: MCP clients specify algorithm and weights via tool parameters
|
||||
3. **Backward compatibility**: Existing `nc_semantic_search()` behavior preserved
|
||||
4. **Shared implementation**: Viz pane and MCP tools use identical search algorithms
|
||||
5. **User accessibility**: Viz pane available to all logged-in users with vector sync enabled
|
||||
6. **Performance**: Minimal overhead for algorithm selection
|
||||
|
||||
## Decision
|
||||
|
||||
We will implement a **unified multi-algorithm search architecture** with the following components:
|
||||
|
||||
### Architecture Diagram
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ MCP Client / User Browser │
|
||||
│ │
|
||||
│ ┌──────────────────────────┐ ┌──────────────────────────────────┐ │
|
||||
│ │ MCP Tool Call │ │ Viz Pane (Browser UI) │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ nc_semantic_search( │ │ - Algorithm selector dropdown │ │
|
||||
│ │ query="kubernetes", │ │ - Weight adjustment sliders │ │
|
||||
│ │ algorithm="hybrid", │ │ - Interactive 2D scatter plot │ │
|
||||
│ │ semantic_weight=0.5, │ │ - Side-by-side comparison │ │
|
||||
│ │ keyword_weight=0.3, │ │ - Real-time search testing │ │
|
||||
│ │ fuzzy_weight=0.2 │ │ │ │
|
||||
│ │ ) │ │ │ │
|
||||
│ └───────────┬──────────────┘ └────────────┬─────────────────────┘ │
|
||||
└──────────────┼─────────────────────────────────────┼────────────────────────┘
|
||||
│ │
|
||||
│ MCP Protocol │ HTTPS (htmx)
|
||||
│ │
|
||||
┌──────────────▼──────────────────────────────────────▼────────────────────────┐
|
||||
│ MCP Server (/app endpoint) │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ Unified Search Interface (server/semantic.py) │ │
|
||||
│ │ │ │
|
||||
│ │ @mcp.tool() nc_semantic_search(algorithm, weights...) │ │
|
||||
│ │ ├─ Validate parameters (weights sum ≤1.0) │ │
|
||||
│ │ ├─ Dispatch to algorithm selector │ │
|
||||
│ │ └─ Return ranked SearchResponse │ │
|
||||
│ └────────────────────────────┬────────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌────────────────────────────▼────────────────────────────────────────────┐ │
|
||||
│ │ Algorithm Dispatcher (search/algorithms.py) │ │
|
||||
│ │ │ │
|
||||
│ │ if algorithm == "semantic": → semantic.py │ │
|
||||
│ │ if algorithm == "keyword": → keyword.py │ │
|
||||
│ │ if algorithm == "fuzzy": → fuzzy.py │ │
|
||||
│ │ if algorithm == "hybrid": → hybrid.py (RRF fusion) │ │
|
||||
│ └─────────────────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ │
|
||||
│ │ semantic.py │ │ keyword.py │ │ fuzzy.py │ │
|
||||
│ │ │ │ │ │ │ │
|
||||
│ │ • Query Qdrant │ │ • Token matching │ │ • Char overlap │ │
|
||||
│ │ • Cosine dist │ │ • Title weight │ │ • 70% threshold │ │
|
||||
│ │ • Score ≥0.7 │ │ • ADR-001 logic │ │ • Simple impl │ │
|
||||
│ └────────┬─────────┘ └────────┬─────────┘ └────────┬─────────┘ │
|
||||
│ │ │ │ │
|
||||
│ └─────────────────────┼──────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌──────────────────────────────▼──────────────────────────────────────────┐ │
|
||||
│ │ hybrid.py (Reciprocal Rank Fusion) │ │
|
||||
│ │ │ │
|
||||
│ │ 1. Run algorithms in parallel (semantic, keyword, fuzzy) │ │
|
||||
│ │ 2. Collect ranked results from each │ │
|
||||
│ │ 3. Apply RRF formula: score = weight / (k + rank) │ │
|
||||
│ │ 4. Combine scores across algorithms │ │
|
||||
│ │ 5. Re-rank by combined score │ │
|
||||
│ └─────────────────────────────────────────────────────────────────────────┘ │
|
||||
└───────────────────────────────────┬───────────────────────────────────────────┘
|
||||
│
|
||||
┌───────────────┴───────────────┐
|
||||
│ │
|
||||
┌──────────▼──────────┐ ┌─────────▼────────────┐
|
||||
│ Qdrant Vector DB │ │ Nextcloud APIs │
|
||||
│ │ │ │
|
||||
│ • Vector search │ │ • Access verification│
|
||||
│ • user_id filter │ │ • Full metadata fetch│
|
||||
│ • Score threshold │ │ • Permission checks │
|
||||
│ • 768-dim embeddings│ │ │
|
||||
└─────────────────────┘ └──────────────────────┘
|
||||
```
|
||||
|
||||
### Data Flow
|
||||
|
||||
#### MCP Tool Request
|
||||
```
|
||||
1. Client calls nc_semantic_search(query, algorithm="hybrid", weights...)
|
||||
2. Server validates parameters (weights sum ≤1.0)
|
||||
3. Dispatcher routes to hybrid.py
|
||||
4. Hybrid search runs semantic, keyword, fuzzy in parallel
|
||||
5. RRF combines results with weighted scores
|
||||
6. Access verification via Nextcloud API
|
||||
7. Return ranked SearchResponse to client
|
||||
```
|
||||
|
||||
#### Viz Pane Request (Server-Side Processing)
|
||||
```
|
||||
1. User navigates to /app (Vector Visualization tab)
|
||||
2. Browser loads vector-viz fragment via htmx
|
||||
3. User enters query and adjusts algorithm/weights
|
||||
4. htmx sends request to /app/vector-viz endpoint
|
||||
5. Server executes search via search/algorithms.py:
|
||||
- Filters by user_id (multi-tenant security)
|
||||
- Applies selected algorithm (semantic/keyword/fuzzy/hybrid)
|
||||
- Filters by document type (notes/files/calendar/contacts)
|
||||
- Retrieves matching results + metadata
|
||||
6. Server performs PCA reduction (768-dim → 2D):
|
||||
- Converts matching results to 2D coordinates
|
||||
- Only sends coordinates + metadata (not full vectors)
|
||||
- Dramatically reduces bandwidth (e.g., 768 floats → 2 floats per doc)
|
||||
7. Server returns JSON: {results: [...], coordinates_2d: [...], stats: {...}}
|
||||
8. Browser receives lightweight response
|
||||
9. Plotly.js renders interactive scatter plot
|
||||
10. Matching results highlighted (blue), non-matches grayed (40% opacity)
|
||||
```
|
||||
|
||||
**Performance Benefits of Server-Side Processing**:
|
||||
- **Bandwidth reduction**: ~384x less data (2 floats vs 768 floats per document)
|
||||
- **Client efficiency**: Browser only handles visualization, not computation
|
||||
- **Scalability**: Can visualize 10,000+ documents without client-side lag
|
||||
- **Security**: Raw vectors never leave server
|
||||
- **Consistency**: Same search logic as MCP tool (no drift)
|
||||
|
||||
### 1. Core Search Algorithms
|
||||
|
||||
Four search algorithms will be available:
|
||||
|
||||
#### a) Semantic Search (Vector Similarity)
|
||||
- **Method**: Cosine distance in 768-dimensional embedding space
|
||||
- **Implementation**: Qdrant `query_points` with user_id filtering
|
||||
- **Use case**: Conceptual queries, finding related content
|
||||
- **Current status**: Implemented in `nextcloud_mcp_server/server/semantic.py`
|
||||
|
||||
#### b) Keyword Search (Token-Based)
|
||||
- **Method**: Token matching with weighted scoring (from ADR-001)
|
||||
- **Implementation**: Title matches weighted 3x higher than content
|
||||
- **Use case**: Exact phrase matching, known titles
|
||||
- **Current status**: Designed in ADR-001, not implemented
|
||||
|
||||
#### c) Fuzzy Search (Character Overlap)
|
||||
- **Method**: Simple character-based similarity (70% threshold)
|
||||
- **Implementation**: Character set comparison (current viz pane approach)
|
||||
- **Use case**: Typo tolerance, approximate matching
|
||||
- **Current status**: Implemented in viz pane only
|
||||
|
||||
#### d) Hybrid Search (Multi-Algorithm Fusion)
|
||||
- **Method**: Reciprocal Rank Fusion (RRF) from ADR-003
|
||||
- **Implementation**: Parallel execution + score combination
|
||||
- **Use case**: Balanced queries, general-purpose search
|
||||
- **Current status**: Designed in ADR-003, not implemented
|
||||
|
||||
### 2. Unified MCP Tool Interface
|
||||
|
||||
```python
|
||||
@mcp.tool()
|
||||
@require_scopes("semantic:read")
|
||||
async def nc_semantic_search(
|
||||
query: str,
|
||||
ctx: Context,
|
||||
limit: int = 10,
|
||||
score_threshold: float = 0.7,
|
||||
algorithm: Literal["semantic", "keyword", "fuzzy", "hybrid"] = "hybrid",
|
||||
semantic_weight: float = 0.5,
|
||||
keyword_weight: float = 0.3,
|
||||
fuzzy_weight: float = 0.2,
|
||||
) -> SearchResponse:
|
||||
"""
|
||||
Search Nextcloud content using configurable algorithms.
|
||||
|
||||
Args:
|
||||
query: Natural language search query
|
||||
ctx: MCP context for authentication
|
||||
limit: Maximum results to return
|
||||
score_threshold: Minimum similarity score (semantic/hybrid only)
|
||||
algorithm: Search algorithm to use
|
||||
semantic_weight: Weight for semantic results (hybrid only, default: 0.5)
|
||||
keyword_weight: Weight for keyword results (hybrid only, default: 0.3)
|
||||
fuzzy_weight: Weight for fuzzy results (hybrid only, default: 0.2)
|
||||
|
||||
Returns:
|
||||
Ranked search results with scores and excerpts
|
||||
"""
|
||||
```
|
||||
|
||||
**Key decisions**:
|
||||
- **Single tool name**: Keep `nc_semantic_search` for backward compatibility
|
||||
- **Algorithm parameter**: Explicit selection via enum
|
||||
- **Weight parameters**: Client-configurable, only apply to hybrid mode
|
||||
- **Validation**: Weights must sum to ≤1.0, enforced server-side
|
||||
- **Defaults**: Hybrid mode with balanced weights (semantic 50%, keyword 30%, fuzzy 20%)
|
||||
|
||||
### 3. Shared Algorithm Implementation
|
||||
|
||||
Extract search algorithms into reusable module:
|
||||
|
||||
```
|
||||
nextcloud_mcp_server/
|
||||
├── search/
|
||||
│ ├── __init__.py
|
||||
│ ├── algorithms.py # Core search implementations
|
||||
│ ├── semantic.py # Vector similarity search
|
||||
│ ├── keyword.py # Token-based search (ADR-001)
|
||||
│ ├── fuzzy.py # Character overlap search
|
||||
│ └── hybrid.py # RRF fusion (ADR-003)
|
||||
└── server/
|
||||
└── semantic.py # MCP tool wrapper
|
||||
```
|
||||
|
||||
**Benefits**:
|
||||
- Viz pane and MCP tools share identical implementations
|
||||
- Testable in isolation
|
||||
- Easy to add new algorithms (e.g., BM25, neural reranking)
|
||||
- Clear separation of concerns
|
||||
|
||||
### 4. Viz Pane Integration
|
||||
|
||||
Update viz pane (`nextcloud_mcp_server/auth/userinfo_routes.py`) to:
|
||||
|
||||
1. **Use shared algorithms**: Import from `search/algorithms.py`
|
||||
2. **Server-side filtering**: All search and filtering operations happen server-side
|
||||
- Query execution via shared search backend
|
||||
- Document type filtering (notes, files, calendar, contacts)
|
||||
- User ID filtering for multi-tenant security
|
||||
- Only matching results + metadata sent to client
|
||||
- Reduces bandwidth and improves performance
|
||||
3. **PCA reduction**: Server performs dimensionality reduction (768-dim → 2D)
|
||||
- Only 2D coordinates sent to browser for visualization
|
||||
- Dramatically reduces data transfer vs sending full vectors
|
||||
- Enables visualization of large document collections
|
||||
4. **User accessibility**: Available to all users with vector sync enabled
|
||||
5. **Security**: Filter results by `user_id` (only show user's own documents)
|
||||
6. **Interactive testing**: Allow users to:
|
||||
- Select algorithm type
|
||||
- Adjust weights (hybrid mode)
|
||||
- Compare results across algorithms
|
||||
- Visualize result distribution in 2D space
|
||||
|
||||
#### Viz Pane UI Components
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────────────┐
|
||||
│ Vector Visualization [Status] │
|
||||
├────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌──────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ Search Configuration │ │
|
||||
│ │ │ │
|
||||
│ │ Query: [_______________________________________________] [Search]│ │
|
||||
│ │ │ │
|
||||
│ │ Algorithm: [Hybrid ▼] [Semantic] [Keyword] [Fuzzy] │ │
|
||||
│ │ │ │
|
||||
│ │ Weights (Hybrid Mode): │ │
|
||||
│ │ Semantic: [========50========] 0.5 │ │
|
||||
│ │ Keyword: [======30====== ] 0.3 │ │
|
||||
│ │ Fuzzy: [====20==== ] 0.2 │ │
|
||||
│ │ │ │
|
||||
│ │ Document Types: ☑ Notes ☑ Files ☑ Calendar ☑ Contacts │ │
|
||||
│ └──────────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌──────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ Vector Space Visualization (PCA 2D Projection) │ │
|
||||
│ │ │ │
|
||||
│ │ ▲ │ │
|
||||
│ │ PC2 │ ● ● ● 🔵 Matching results (full opacity) │ │
|
||||
│ │ │ ● ● ● ⚪ Non-matching results (40% opacity) │ │
|
||||
│ │ │ 🔵 ● ● │ │
|
||||
│ │ │ ● 🔵 ● Hover: Show document title + excerpt │ │
|
||||
│ │ │ ● ● 🔵 ● Click: Open document in Nextcloud │ │
|
||||
│ │ ────┼──●─🔵──●─●────► PC1 │ │
|
||||
│ │ │ ● ● ● │ │
|
||||
│ │ │ 🔵 ● ● Explained Variance: │ │
|
||||
│ │ │ ● ● ● PC1: 23.4% | PC2: 18.7% │ │
|
||||
│ │ │ ● ● │ │
|
||||
│ │ │ │
|
||||
│ └──────────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌──────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ Search Results (12 matching documents) │ │
|
||||
│ │ │ │
|
||||
│ │ 🔵 Kubernetes Setup Guide Score: 0.87 │ │
|
||||
│ │ "...configure kubectl to connect to cluster..." │ │
|
||||
│ │ [Open in Nextcloud] │ │
|
||||
│ │ │ │
|
||||
│ │ 🔵 Container Orchestration Notes Score: 0.82 │ │
|
||||
│ │ "...deployment strategies for kubernetes..." │ │
|
||||
│ │ [Open in Nextcloud] │ │
|
||||
│ │ │ │
|
||||
│ │ 🔵 K8s Troubleshooting Score: 0.79 │ │
|
||||
│ │ "...common kuberntes errors and solutions..." │ │
|
||||
│ │ [Open in Nextcloud] │ │
|
||||
│ │ │ │
|
||||
│ │ [Show More Results...] │ │
|
||||
│ └──────────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌──────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ Algorithm Performance Comparison │ │
|
||||
│ │ │ │
|
||||
│ │ Algorithm │ Results │ Avg Score │ Time (ms) │ Precision │ │
|
||||
│ │ ─────────────┼─────────┼───────────┼───────────┼─────────── │ │
|
||||
│ │ Semantic │ 45 │ 0.78 │ 145ms │ ████░ 0.82 │ │
|
||||
│ │ Keyword │ 23 │ 0.91 │ 42ms │ ███░░ 0.67 │ │
|
||||
│ │ Fuzzy │ 67 │ 0.72 │ 89ms │ ██░░░ 0.45 │ │
|
||||
│ │ Hybrid (RRF) │ 52 │ 0.84 │ 198ms │ █████ 0.89 │ │
|
||||
│ └──────────────────────────────────────────────────────────────────┘ │
|
||||
└────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Key UI Features**:
|
||||
|
||||
1. **Search Input**: Real-time query testing with instant visualization
|
||||
2. **Algorithm Selector**: Dropdown + quick-select buttons
|
||||
3. **Weight Sliders**: Visual adjustment with live preview (hybrid mode only)
|
||||
4. **Document Type Filters**: Checkboxes for notes, files, calendar, contacts
|
||||
5. **2D Scatter Plot**: Interactive Plotly.js visualization
|
||||
- Blue dots = matching documents (full opacity)
|
||||
- Gray dots = non-matching documents (40% opacity)
|
||||
- Hover = show title + excerpt tooltip
|
||||
- Click = open document in Nextcloud
|
||||
- Zoom/pan controls for exploration
|
||||
6. **Results Panel**: Ranked list with scores and excerpts
|
||||
7. **Performance Table**: Compare algorithm speed and accuracy
|
||||
8. **Explained Variance**: Show how much information PCA preserves
|
||||
|
||||
**Technology Stack**:
|
||||
- **Frontend**: htmx for dynamic loading, Alpine.js for reactivity
|
||||
- **Visualization**: Plotly.js for interactive scatter plots
|
||||
- **Styling**: Tailwind CSS (consistent with existing /app UI)
|
||||
- **Backend**: Shared `search/algorithms.py` implementation
|
||||
|
||||
### 5. Reciprocal Rank Fusion (RRF) for Hybrid Search
|
||||
|
||||
Following ADR-003's design:
|
||||
|
||||
```python
|
||||
def reciprocal_rank_fusion(
|
||||
results: dict[str, list[SearchResult]],
|
||||
weights: dict[str, float],
|
||||
k: int = 60
|
||||
) -> list[SearchResult]:
|
||||
"""
|
||||
Combine multiple ranked result lists using RRF.
|
||||
|
||||
Args:
|
||||
results: Dict of algorithm_name -> ranked results
|
||||
weights: Dict of algorithm_name -> weight (0-1)
|
||||
k: RRF constant (default: 60, standard value)
|
||||
|
||||
Returns:
|
||||
Combined and re-ranked results
|
||||
"""
|
||||
scores = defaultdict(float)
|
||||
|
||||
for algo_name, algo_results in results.items():
|
||||
weight = weights.get(algo_name, 0.0)
|
||||
for rank, result in enumerate(algo_results, start=1):
|
||||
# RRF formula: 1 / (k + rank)
|
||||
rrf_score = weight / (k + rank)
|
||||
scores[result.doc_id] += rrf_score
|
||||
|
||||
# Sort by combined score, return top results
|
||||
return sorted(scores.items(), key=lambda x: x[1], reverse=True)
|
||||
```
|
||||
|
||||
**RRF properties**:
|
||||
- **Rank-based**: Uses position, not raw scores (handles score scale differences)
|
||||
- **Proven effective**: Standard approach in information retrieval
|
||||
- **Configurable**: `k` parameter controls rank decay (default: 60)
|
||||
- **Weight support**: Allows algorithm-specific importance
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1: Extract and Unify Algorithms (Week 1)
|
||||
|
||||
1. Create `nextcloud_mcp_server/search/` module
|
||||
2. Implement `algorithms.py` with base interface
|
||||
3. Extract semantic search logic from `server/semantic.py`
|
||||
4. Implement keyword search from ADR-001 design
|
||||
5. Extract fuzzy search from viz pane
|
||||
6. Implement RRF hybrid search from ADR-003
|
||||
7. Add comprehensive unit tests for each algorithm
|
||||
|
||||
### Phase 2: Update MCP Tool (Week 1-2)
|
||||
|
||||
1. Add `algorithm` parameter to `nc_semantic_search()`
|
||||
2. Add weight parameters (`semantic_weight`, etc.)
|
||||
3. Implement algorithm dispatcher
|
||||
4. Add parameter validation (weights sum ≤1.0)
|
||||
5. Update response model to include algorithm metadata
|
||||
6. Maintain backward compatibility (default: hybrid)
|
||||
7. Add integration tests for all algorithm modes
|
||||
|
||||
### Phase 3: Update Viz Pane (Week 2)
|
||||
|
||||
**Critical: All processing must happen server-side**
|
||||
|
||||
1. **Remove client-side search filtering**
|
||||
- Delete JavaScript-based keyword/fuzzy matching
|
||||
- Remove client-side document type filtering
|
||||
- No search logic in browser
|
||||
2. **Implement server-side endpoint** (`/app/vector-viz`)
|
||||
- Accept query, algorithm, weights, doc_type filters
|
||||
- Execute search via `search/algorithms.py`
|
||||
- Filter results by user_id (security)
|
||||
- Perform PCA reduction (768-dim → 2D)
|
||||
- Return JSON with 2D coordinates + metadata only
|
||||
3. **Update frontend**
|
||||
- htmx form submission to `/app/vector-viz`
|
||||
- Algorithm selector dropdown
|
||||
- Weight adjustment sliders (htmx updates on change)
|
||||
- Document type checkboxes
|
||||
- Plotly.js visualization of server response
|
||||
4. **Performance optimization**
|
||||
- Limit results to user's documents only
|
||||
- Cache PCA transformation (invalidate on new vectors)
|
||||
- Stream large result sets if needed
|
||||
- Add loading indicators for server processing
|
||||
|
||||
### Phase 4: Documentation and Testing (Week 2-3)
|
||||
|
||||
1. Update MCP tool documentation
|
||||
2. Add algorithm selection guide
|
||||
3. Document weight tuning recommendations
|
||||
4. Add end-to-end tests (MCP + viz pane)
|
||||
5. Performance benchmarks for each algorithm
|
||||
6. Update CLAUDE.md with search patterns
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
1. **Flexibility**: MCP clients can optimize search for their use case
|
||||
2. **Unified implementation**: Single source of truth for search algorithms
|
||||
3. **User empowerment**: Viz pane enables query testing and tuning
|
||||
4. **Backward compatible**: Existing semantic search behavior preserved
|
||||
5. **Extensible**: Easy to add new algorithms (BM25, neural reranking)
|
||||
6. **Testable**: Each algorithm can be unit tested independently
|
||||
7. **Standards-based**: RRF is proven in production systems
|
||||
|
||||
### Negative
|
||||
|
||||
1. **Complexity**: More parameters for clients to understand
|
||||
2. **API surface**: Larger tool signature (8 parameters)
|
||||
3. **Performance**: Hybrid search requires multiple queries
|
||||
4. **Validation overhead**: Weight validation adds processing
|
||||
5. **Documentation burden**: Need to explain when to use each algorithm
|
||||
|
||||
### Neutral
|
||||
|
||||
1. **Weight defaults**: May need tuning based on user feedback
|
||||
2. **Algorithm performance**: Will vary by content type and query
|
||||
3. **Viz pane adoption**: Unknown if users will utilize testing interface
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
### Alternative 1: Separate Tools Per Algorithm
|
||||
|
||||
```python
|
||||
@mcp.tool()
|
||||
async def nc_semantic_search(query: str, ctx: Context, ...) -> SearchResponse:
|
||||
"""Pure vector similarity search."""
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_keyword_search(query: str, ctx: Context, ...) -> SearchResponse:
|
||||
"""Pure keyword matching."""
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_hybrid_search(query: str, ctx: Context, weights: dict, ...) -> SearchResponse:
|
||||
"""Hybrid search with weights."""
|
||||
```
|
||||
|
||||
**Rejected because**:
|
||||
- API proliferation (3+ tools instead of 1)
|
||||
- Harder to discover capabilities
|
||||
- Backward compatibility issues
|
||||
- DRY violation (repeated parameters)
|
||||
|
||||
### Alternative 2: Server-Wide Configuration Only
|
||||
|
||||
```python
|
||||
# .env configuration
|
||||
SEARCH_ALGORITHM=hybrid
|
||||
SEMANTIC_WEIGHT=0.5
|
||||
KEYWORD_WEIGHT=0.3
|
||||
FUZZY_WEIGHT=0.2
|
||||
```
|
||||
|
||||
**Rejected because**:
|
||||
- No per-query flexibility
|
||||
- MCP clients cannot optimize for different tasks
|
||||
- Requires server restart for changes
|
||||
- User's requirement: "expose a way for users to override the default weights"
|
||||
|
||||
### Alternative 3: Production-Grade Fuzzy (Levenshtein/RapidFuzz)
|
||||
|
||||
**Rejected because**:
|
||||
- Adds external dependency
|
||||
- Simple character overlap performs adequately
|
||||
- Can always upgrade later if needed
|
||||
- User's preference: "Keep simple character overlap"
|
||||
|
||||
## Related ADRs
|
||||
|
||||
- **ADR-001**: Enhanced Note Search (keyword algorithm design)
|
||||
- **ADR-003**: Vector Database and Semantic Search (hybrid search + RRF design)
|
||||
- **ADR-007**: Background Vector Sync (semantic search implementation)
|
||||
- **ADR-008**: MCP Sampling for RAG (uses semantic search results)
|
||||
- **ADR-009**: Semantic Search OAuth Scope (security model)
|
||||
- **ADR-011**: Improving Semantic Search Quality (mentions future "ADR-013" for hybrid search)
|
||||
|
||||
**This ADR supersedes**:
|
||||
- ADR-011's placeholder for "ADR-013: Hybrid Search"
|
||||
|
||||
**This ADR implements**:
|
||||
- ADR-003's hybrid search design (previously unimplemented)
|
||||
- ADR-001's keyword search design (previously unimplemented)
|
||||
|
||||
## References
|
||||
|
||||
- **Reciprocal Rank Fusion**: Cormack, G. V., Clarke, C. L., & Buettcher, S. (2009). "Reciprocal rank fusion outperforms condorcet and individual rank learning methods." SIGIR '09.
|
||||
- **Vector Search**: Malkov, Y. A., & Yashunin, D. A. (2018). "Efficient and robust approximate nearest neighbor search using Hierarchical Navigable Small World graphs." TPAMI.
|
||||
- **Hybrid Search Best Practices**: Qdrant documentation on hybrid search patterns
|
||||
- **MCP Protocol**: Model Context Protocol specification for tool design
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
### Weight Validation
|
||||
|
||||
```python
|
||||
def validate_weights(
|
||||
semantic_weight: float,
|
||||
keyword_weight: float,
|
||||
fuzzy_weight: float
|
||||
) -> None:
|
||||
"""Validate hybrid search weights."""
|
||||
if semantic_weight < 0 or keyword_weight < 0 or fuzzy_weight < 0:
|
||||
raise ValueError("Weights must be non-negative")
|
||||
|
||||
total = semantic_weight + keyword_weight + fuzzy_weight
|
||||
if total > 1.0:
|
||||
raise ValueError(f"Weights sum to {total:.2f}, must be ≤1.0")
|
||||
|
||||
if total == 0.0:
|
||||
raise ValueError("At least one weight must be > 0")
|
||||
```
|
||||
|
||||
### Backward Compatibility
|
||||
|
||||
The default behavior (`algorithm="hybrid"` with balanced weights) provides better results than current pure semantic search, while maintaining the same tool name and signature structure. Existing clients will automatically benefit from hybrid search without code changes.
|
||||
|
||||
### Performance Considerations
|
||||
|
||||
- **Semantic search**: ~50-200ms (vector DB query)
|
||||
- **Keyword search**: ~10-50ms (in-memory token matching)
|
||||
- **Fuzzy search**: ~20-100ms (character comparison)
|
||||
- **Hybrid search**: ~100-300ms (parallel execution + fusion)
|
||||
|
||||
Parallel execution of algorithms minimizes hybrid search latency.
|
||||
|
||||
### Security Model
|
||||
|
||||
All algorithms respect the same security boundaries:
|
||||
1. **User filtering**: Qdrant queries filter by `user_id`
|
||||
2. **Access verification**: Results verified via Nextcloud API
|
||||
3. **OAuth scope**: `semantic:read` required for all algorithms
|
||||
4. **Viz pane**: Shows only current user's documents
|
||||
|
||||
## Success Metrics
|
||||
|
||||
1. **Adoption**: % of MCP clients using algorithm parameter
|
||||
2. **Performance**: Search latency percentiles (p50, p95, p99)
|
||||
3. **Quality**: User satisfaction with result relevance
|
||||
4. **Viz pane usage**: % of users accessing testing interface
|
||||
5. **Weight distribution**: Most common weight configurations
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
1. **Additional algorithms**: BM25, TF-IDF, neural reranking
|
||||
2. **Auto-tuning**: Learn optimal weights per user
|
||||
3. **Query analysis**: Automatic algorithm selection based on query
|
||||
4. **Cross-app search**: Extend beyond notes to calendar, files, etc.
|
||||
5. **Feedback loop**: Use click-through rate to improve weights
|
||||
@@ -0,0 +1,254 @@
|
||||
## ADR-013: RAG Evaluation Testing Framework
|
||||
|
||||
**Status:** Proposed
|
||||
|
||||
**Date:** 2025-11-15
|
||||
|
||||
### Context
|
||||
|
||||
The `nc_semantic_search_answer` tool implements a Retrieval-Augmented Generation (RAG) system where:
|
||||
1. **Retrieval**: Vector sync pipeline indexes Nextcloud documents (notes, calendar, contacts, etc.) into a vector database
|
||||
2. **Generation**: MCP client's LLM synthesizes answers from retrieved documents via MCP sampling (ADR-008)
|
||||
|
||||
We need a testing framework to evaluate RAG system performance and identify whether failures occur in retrieval (wrong documents found) or generation (poor answer quality). This framework must use industry-standard evaluation methodologies while remaining practical to implement and maintain.
|
||||
|
||||
To establish a baseline, we will use the **BeIR/nfcorpus** dataset (medical/biomedical corpus) with ~5,000 documents and established query/answer pairs.
|
||||
|
||||
Homepage: https://www.cl.uni-heidelberg.de/statnlpgroup/nfcorpus/
|
||||
Download: https://public.ukp.informatik.tu-darmstadt.de/thakur/BEIR/datasets/nfcorpus.zip
|
||||
|
||||
### Decision
|
||||
|
||||
We will implement a **two-part evaluation framework** that independently tests retrieval and generation quality using pytest fixtures.
|
||||
|
||||
#### In Scope
|
||||
|
||||
**1. Retrieval Evaluation**
|
||||
Tests the vector sync/embedding pipeline's ability to find relevant documents.
|
||||
|
||||
- **Metric: Context Recall** (Did we retrieve documents containing the answer?)
|
||||
- **Evaluation method**: Heuristic - Check if ground-truth document IDs appear in top-k retrieval results
|
||||
- **Test**: Query → Semantic search → Assert expected doc IDs present
|
||||
|
||||
**2. Generation Evaluation**
|
||||
Tests the MCP client LLM's ability to synthesize correct answers from retrieved context.
|
||||
|
||||
- **Metric: Answer Correctness** (Is the generated answer factually correct?)
|
||||
- **Evaluation method**: LLM-as-judge - Compare RAG answer against ground-truth answer
|
||||
- **Test**: Query → `nc_semantic_search_answer` → LLM evaluates answer vs. ground truth (binary true/false)
|
||||
|
||||
#### Out of Scope (Initial Implementation)
|
||||
|
||||
- **Context Relevance/Precision**: Measuring irrelevant documents in retrieval results
|
||||
- **Faithfulness/Groundedness**: Detecting hallucinations not supported by retrieved context
|
||||
- **Answer Relevance**: Whether answer addresses the specific question asked
|
||||
- **Out-of-Scope Handling**: Testing "I don't know" responses when answer isn't in context
|
||||
- **Continuous benchmarking**: Automated tracking of metric trends over time
|
||||
- **Custom domain datasets**: Production-specific test data (medical corpus used initially)
|
||||
|
||||
These remain valuable for future iterations but add complexity beyond our initial goals.
|
||||
|
||||
#### Implementation
|
||||
|
||||
**Test Structure**
|
||||
|
||||
Location: `tests/rag_evaluation/`
|
||||
- `test_retrieval_quality.py` - Retrieval evaluation tests
|
||||
- `test_generation_quality.py` - Generation evaluation tests
|
||||
- `conftest.py` - Fixtures for test data, MCP clients, and evaluation LLMs
|
||||
|
||||
**Required Pytest Fixtures**
|
||||
|
||||
1. **`nfcorpus_test_data`** (session-scoped)
|
||||
- Downloads/caches BeIR nfcorpus dataset at runtime
|
||||
- Loads 5 pre-selected test queries with:
|
||||
- Query text
|
||||
- Pre-generated ground-truth answer (from `tests/rag_evaluation/fixtures/ground_truth.json`)
|
||||
- Expected document IDs (from qrels with score=2)
|
||||
- Uploads all corpus documents as notes in test Nextcloud instance
|
||||
- Triggers vector sync to index documents
|
||||
- Waits for indexing completion
|
||||
- Returns test case data structure
|
||||
|
||||
2. **`mcp_sampling_client`** (session-scoped)
|
||||
- Creates MCP client that supports sampling
|
||||
- Configurable LLM provider (ollama or anthropic) via environment:
|
||||
- `RAG_EVAL_PROVIDER=ollama` (default) or `anthropic`
|
||||
- `RAG_EVAL_OLLAMA_BASE_URL=http://localhost:11434`
|
||||
- `RAG_EVAL_OLLAMA_MODEL=llama3.1:8b`
|
||||
- `RAG_EVAL_ANTHROPIC_API_KEY=sk-...`
|
||||
- `RAG_EVAL_ANTHROPIC_MODEL=claude-3-5-sonnet-20241022`
|
||||
- Returns configured MCP client fixture
|
||||
|
||||
3. **`evaluation_llm`** (session-scoped)
|
||||
- Separate LLM instance for evaluation (independent from MCP client)
|
||||
- Same provider configuration as `mcp_sampling_client`
|
||||
- Returns callable: `async def evaluate(prompt: str) -> str`
|
||||
|
||||
**Test Implementation Examples**
|
||||
|
||||
```python
|
||||
# tests/rag_evaluation/test_retrieval_quality.py
|
||||
async def test_retrieval_recall(nc_client, nfcorpus_test_data):
|
||||
"""Test that semantic search retrieves documents containing the answer."""
|
||||
for test_case in nfcorpus_test_data:
|
||||
# Perform semantic search (retrieval only, no generation)
|
||||
results = await nc_client.notes.semantic_search(
|
||||
query=test_case.query,
|
||||
limit=10
|
||||
)
|
||||
|
||||
retrieved_doc_ids = {r.document_id for r in results}
|
||||
expected_doc_ids = set(test_case.expected_document_ids)
|
||||
|
||||
# Context Recall: Are expected documents in top-k results?
|
||||
recall = len(expected_doc_ids & retrieved_doc_ids) / len(expected_doc_ids)
|
||||
assert recall >= 0.8, f"Recall {recall} below threshold for query: {test_case.query}"
|
||||
|
||||
|
||||
# tests/rag_evaluation/test_generation_quality.py
|
||||
async def test_answer_correctness(mcp_sampling_client, evaluation_llm, nfcorpus_test_data):
|
||||
"""Test that RAG system generates factually correct answers."""
|
||||
for test_case in nfcorpus_test_data:
|
||||
# Execute full RAG pipeline (retrieval + generation)
|
||||
result = await mcp_sampling_client.call_tool(
|
||||
"nc_semantic_search_answer",
|
||||
arguments={"query": test_case.query, "limit": 5}
|
||||
)
|
||||
|
||||
rag_answer = result["generated_answer"]
|
||||
|
||||
# LLM-as-judge evaluation
|
||||
evaluation_prompt = f"""Compare these two answers and respond with only TRUE or FALSE.
|
||||
|
||||
Question: {test_case.query}
|
||||
|
||||
Generated Answer: {rag_answer}
|
||||
|
||||
Ground Truth Answer: {test_case.ground_truth}
|
||||
|
||||
Are these answers semantically equivalent (do they convey the same factual information)?
|
||||
Respond with only: TRUE or FALSE"""
|
||||
|
||||
evaluation_result = await evaluation_llm(evaluation_prompt)
|
||||
|
||||
assert evaluation_result.strip().upper() == "TRUE", \
|
||||
f"Answer mismatch for query: {test_case.query}\nGot: {rag_answer}\nExpected: {test_case.ground_truth}"
|
||||
```
|
||||
|
||||
**Dataset Integration**
|
||||
|
||||
The BeIR nfcorpus dataset structure:
|
||||
- **corpus.jsonl**: 3,633 medical/biomedical documents (articles from PubMed)
|
||||
- **queries.jsonl**: 3,237 queries (questions)
|
||||
- **qrels/*.tsv**: Relevance judgments mapping query IDs to document IDs with scores (2=highly relevant, 1=somewhat relevant)
|
||||
|
||||
**Important**: The dataset provides relevance judgments (which documents answer which queries) but does NOT include ground truth answers. We must generate synthetic ground truth offline.
|
||||
|
||||
**Selected Test Queries** (5 diverse candidates):
|
||||
|
||||
1. **PLAIN-2630**: "Alkylphenol Endocrine Disruptors and Allergies" (5 words, 21 highly relevant docs)
|
||||
2. **PLAIN-2660**: "How Long to Detox From Fish Before Pregnancy?" (8 words, 20 highly relevant docs)
|
||||
3. **PLAIN-2510**: "Coffee and Artery Function" (4 words, 16 highly relevant docs)
|
||||
4. **PLAIN-2430**: "Preventing Brain Loss with B Vitamins?" (6 words, 15 highly relevant docs)
|
||||
5. **PLAIN-2690**: "Chronic Headaches and Pork Tapeworms" (5 words, 14 highly relevant docs)
|
||||
|
||||
**Ground Truth Generation** (offline, pre-test):
|
||||
|
||||
Ground truth answers will be generated offline using a script that:
|
||||
1. Loads nfcorpus dataset
|
||||
2. For each selected query, extracts top 3-5 highly relevant documents
|
||||
3. Uses an LLM (ollama/anthropic) to synthesize a reference answer
|
||||
4. Stores ground truth in `tests/rag_evaluation/fixtures/ground_truth.json`
|
||||
|
||||
```python
|
||||
# tools/generate_rag_ground_truth.py
|
||||
async def generate_ground_truth(query: str, relevant_docs: List[dict], llm: LLMProvider) -> str:
|
||||
"""Generate synthetic ground truth answer from highly relevant documents."""
|
||||
context = "\n\n".join([
|
||||
f"Document {i+1}:\nTitle: {doc['title']}\n{doc['text']}"
|
||||
for i, doc in enumerate(relevant_docs[:5])
|
||||
])
|
||||
|
||||
prompt = f"""Based on the following documents, provide a comprehensive answer to this question:
|
||||
|
||||
Question: {query}
|
||||
|
||||
{context}
|
||||
|
||||
Provide a factual, well-structured answer that synthesizes information from the documents.
|
||||
Focus on accuracy and completeness."""
|
||||
|
||||
return await llm.generate(prompt, max_tokens=500)
|
||||
```
|
||||
|
||||
**Dataset Loading at Test Runtime** (in `nfcorpus_test_data` fixture):
|
||||
|
||||
1. Download nfcorpus dataset (cached in pytest temp directory)
|
||||
2. Load corpus, queries, and qrels (relevance judgments)
|
||||
3. Load pre-generated ground truth from `tests/rag_evaluation/fixtures/ground_truth.json`
|
||||
4. Upload all corpus documents as Nextcloud notes
|
||||
5. Trigger vector sync to index documents
|
||||
6. Wait for indexing completion
|
||||
7. Return test cases with query, ground truth, and expected doc IDs
|
||||
|
||||
**LLM Provider Abstraction**
|
||||
|
||||
```python
|
||||
# tests/rag_evaluation/llm_providers.py
|
||||
class LLMProvider(Protocol):
|
||||
async def generate(self, prompt: str, max_tokens: int = 100) -> str: ...
|
||||
|
||||
class OllamaProvider:
|
||||
def __init__(self, base_url: str, model: str):
|
||||
self.base_url = base_url
|
||||
self.model = model
|
||||
|
||||
async def generate(self, prompt: str, max_tokens: int = 100) -> str:
|
||||
# Use httpx to call Ollama API
|
||||
...
|
||||
|
||||
class AnthropicProvider:
|
||||
def __init__(self, api_key: str, model: str):
|
||||
self.client = anthropic.AsyncAnthropic(api_key=api_key)
|
||||
self.model = model
|
||||
|
||||
async def generate(self, prompt: str, max_tokens: int = 100) -> str:
|
||||
message = await self.client.messages.create(
|
||||
model=self.model,
|
||||
max_tokens=max_tokens,
|
||||
messages=[{"role": "user", "content": prompt}]
|
||||
)
|
||||
return message.content[0].text
|
||||
```
|
||||
|
||||
### Consequences
|
||||
|
||||
**Positive:**
|
||||
|
||||
* **Actionable debugging**: Separate retrieval/generation tests pinpoint failure location
|
||||
* **Industry-standard metrics**: Context Recall and Answer Correctness are recognized RAG evaluation metrics
|
||||
* **Simple initial implementation**: Binary LLM evaluation (true/false) is straightforward to implement and interpret
|
||||
* **Extensible framework**: Easy to add more metrics (faithfulness, relevance) later
|
||||
* **Standardized benchmark**: nfcorpus provides objective comparison against published RAG systems
|
||||
* **Hybrid evaluation**: Combines efficiency (heuristics for retrieval) with quality (LLM-as-judge for generation)
|
||||
* **Provider flexibility**: Supports both local (Ollama) and cloud (Anthropic) LLM evaluation
|
||||
|
||||
**Negative:**
|
||||
|
||||
* **Medical domain bias**: nfcorpus is medical/biomedical content, may not represent production use cases (personal notes, calendar events, etc.)
|
||||
* **Manual test execution**: Tests require external LLM access and are not integrated into CI pipeline
|
||||
* **Limited initial coverage**: Starting with only 5 queries provides limited statistical confidence
|
||||
* **Evaluation cost**: LLM-as-judge for generation evaluation incurs API costs (Anthropic) or requires local inference (Ollama)
|
||||
* **Single metric per component**: Initial scope tests only one metric per component, missing other important quality dimensions
|
||||
* **Synthetic ground truth**: Ground truth answers are LLM-generated, not human-validated, which may introduce evaluation bias
|
||||
* **Large corpus upload**: Uploading 3,633 documents at test runtime may be slow; caching strategy needed
|
||||
|
||||
**Future Work:**
|
||||
|
||||
* Expand to 50-100 queries for statistical significance
|
||||
* Add custom test dataset with production-representative documents (meeting notes, task lists, etc.)
|
||||
* Implement additional metrics (faithfulness, context relevance, answer relevance)
|
||||
* Create automated benchmarking dashboard to track metric trends
|
||||
* Test multi-hop reasoning (synthesis questions requiring multiple documents)
|
||||
* Evaluate out-of-scope handling ("I don't know" responses)
|
||||
@@ -0,0 +1,348 @@
|
||||
# Token Acquisition Patterns for ADR-004 Progressive Consent
|
||||
|
||||
## Overview
|
||||
|
||||
ADR-004 Progressive Consent establishes the authorization architecture (Flow 1 for client auth, Flow 2 for resource provisioning). This document describes **how tokens are acquired for different operational contexts** within that architecture.
|
||||
|
||||
**Key Principle**: Refresh tokens from Flow 2 (Progressive Consent) should **NEVER** be used for MCP tool calls - they are exclusively for background jobs.
|
||||
|
||||
## Implementation Status
|
||||
|
||||
**Current Status**: ✅ Token exchange infrastructure implemented, available as opt-in feature
|
||||
|
||||
The MCP server supports two token acquisition modes:
|
||||
1. **Pass-through mode** (default, `ENABLE_TOKEN_EXCHANGE=false`): Simple, stateless
|
||||
2. **Token exchange mode** (opt-in, `ENABLE_TOKEN_EXCHANGE=true`): Enhanced security with token delegation
|
||||
|
||||
Both modes maintain the critical separation: **refresh tokens are never used for tool calls**.
|
||||
|
||||
## Current Default (Pass-Through Mode)
|
||||
|
||||
### What Happens (ENABLE_TOKEN_EXCHANGE=false):
|
||||
1. Client gets Flow 1 token (`aud: "mcp-server"`)
|
||||
2. Client calls MCP tool
|
||||
3. Server validates Flow 1 token
|
||||
4. Server passes Flow 1 token to Nextcloud
|
||||
5. Nextcloud validates token with IdP
|
||||
6. Refresh tokens (from Flow 2) used **only** for background jobs
|
||||
|
||||
### Characteristics:
|
||||
- ✅ Simple, stateless operation
|
||||
- ✅ Clear separation: Flow 1 tokens for sessions, refresh tokens for background
|
||||
- ✅ Lower latency (no token exchange round-trip)
|
||||
- ✅ Works with any OAuth IdP
|
||||
|
||||
## Optional Token Exchange Mode
|
||||
|
||||
### Token Exchange Pattern (ENABLE_TOKEN_EXCHANGE=true)
|
||||
|
||||
**MCP Session (Foreground Operations)**:
|
||||
|
||||
```
|
||||
┌─────────────┐ Flow 1 Token ┌──────────────┐
|
||||
│ MCP Client │ ───(aud: mcp-server)──> │ MCP Server │
|
||||
└─────────────┘ └──────────────┘
|
||||
│
|
||||
Tool Call │
|
||||
"search_notes()" │
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ Token Exchange │
|
||||
│ 1. Validate Flow 1 │
|
||||
│ 2. Check permission │
|
||||
│ 3. Request delegated│
|
||||
│ Nextcloud token │
|
||||
└─────────────────────┘
|
||||
│
|
||||
│ Exchange Request
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ IdP Token Endpoint │
|
||||
│ (Token Exchange) │
|
||||
└─────────────────────┘
|
||||
│
|
||||
│ Delegated Token
|
||||
│ (aud: nextcloud)
|
||||
│ (limited scopes)
|
||||
│ (short-lived)
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ Nextcloud API Call │
|
||||
│ GET /notes │
|
||||
└─────────────────────┘
|
||||
```
|
||||
|
||||
**Key Properties of Session Tokens:**
|
||||
- ✅ Generated **on-demand** during tool execution
|
||||
- ✅ **Ephemeral** - used only for current operation
|
||||
- ✅ **NOT stored** - discarded after use
|
||||
- ✅ **Limited scopes** - only what tool needs (e.g., `notes:read` for search)
|
||||
- ✅ **Short-lived** - expires quickly (e.g., 5 minutes)
|
||||
|
||||
**Background Jobs (Offline Operations)**:
|
||||
|
||||
```
|
||||
┌─────────────────┐ Scheduled Job ┌──────────────┐
|
||||
│ Background │ ──────────────────────> │ Worker │
|
||||
│ Scheduler │ │ Process │
|
||||
└─────────────────┘ └──────────────┘
|
||||
│
|
||||
│ Use stored
|
||||
│ refresh token
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ Refresh Token Store │
|
||||
│ (Flow 2 provisioned)│
|
||||
└─────────────────────┘
|
||||
│
|
||||
│ Refresh Token
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ IdP Token Endpoint │
|
||||
│ (Refresh Grant) │
|
||||
└─────────────────────┘
|
||||
│
|
||||
│ Background Token
|
||||
│ (aud: nextcloud)
|
||||
│ (different scopes)
|
||||
│ (longer-lived)
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ Nextcloud API │
|
||||
│ (Background Sync) │
|
||||
└─────────────────────┘
|
||||
```
|
||||
|
||||
**Key Properties of Background Tokens:**
|
||||
- ✅ Obtained from **stored refresh token** (Flow 2)
|
||||
- ✅ **Different scopes** than session tokens (e.g., `notes:sync`, `files:sync`)
|
||||
- ✅ **Longer-lived** for background operations
|
||||
- ✅ **Never used for MCP sessions**
|
||||
- ✅ **Only for offline/background jobs**
|
||||
|
||||
## Implementation Requirements
|
||||
|
||||
### 1. Token Exchange Endpoint
|
||||
|
||||
Implement RFC 8693 Token Exchange:
|
||||
|
||||
```python
|
||||
# nextcloud_mcp_server/auth/token_exchange.py
|
||||
|
||||
async def exchange_token_for_delegation(
|
||||
flow1_token: str,
|
||||
requested_audience: str = "nextcloud",
|
||||
requested_scopes: list[str] | None = None
|
||||
) -> tuple[str, int]:
|
||||
"""
|
||||
Exchange Flow 1 MCP token for delegated Nextcloud token.
|
||||
|
||||
This implements RFC 8693 Token Exchange for on-behalf-of delegation.
|
||||
|
||||
IMPORTANT: Nextcloud doesn't support OAuth scopes natively. Scopes are
|
||||
soft-scopes enforced by the MCP server via @require_scopes decorator,
|
||||
not by the IdP or Nextcloud. Therefore, requested_scopes are not passed
|
||||
to the IdP during token exchange.
|
||||
|
||||
Args:
|
||||
flow1_token: The MCP session token (aud: "mcp-server")
|
||||
requested_audience: Target audience (usually "nextcloud")
|
||||
requested_scopes: Ignored (Nextcloud doesn't support scopes)
|
||||
|
||||
Returns:
|
||||
Tuple of (delegated_token, expires_in)
|
||||
"""
|
||||
# 1. Validate Flow 1 token (audience check)
|
||||
# 2. Check user has provisioned Nextcloud access (Flow 2)
|
||||
# 3. Request token exchange from IdP (without scopes - Nextcloud doesn't support them)
|
||||
# 4. Return ephemeral delegated token
|
||||
```
|
||||
|
||||
### 2. Unified get_client() Pattern
|
||||
|
||||
The token acquisition mode is handled transparently by `get_client()`:
|
||||
|
||||
```python
|
||||
# nextcloud_mcp_server/context.py
|
||||
|
||||
async def get_client(ctx: Context) -> NextcloudClient:
|
||||
"""
|
||||
Get the appropriate Nextcloud client based on authentication mode.
|
||||
|
||||
This function handles three modes:
|
||||
1. BasicAuth mode: Returns shared client from lifespan context
|
||||
2. OAuth pass-through mode (ENABLE_TOKEN_EXCHANGE=false, default):
|
||||
Verifies Flow 1 token and passes it to Nextcloud
|
||||
3. OAuth token exchange mode (ENABLE_TOKEN_EXCHANGE=true):
|
||||
Exchanges Flow 1 token for ephemeral Nextcloud token via RFC 8693
|
||||
"""
|
||||
settings = get_settings()
|
||||
lifespan_ctx = ctx.request_context.lifespan_context
|
||||
|
||||
# BasicAuth mode - use shared client (no token exchange)
|
||||
if hasattr(lifespan_ctx, "client"):
|
||||
return lifespan_ctx.client
|
||||
|
||||
# OAuth mode (has 'nextcloud_host' attribute)
|
||||
if hasattr(lifespan_ctx, "nextcloud_host"):
|
||||
# Check if token exchange is enabled
|
||||
if settings.enable_token_exchange:
|
||||
# Token exchange mode: Exchange Flow 1 token for ephemeral Nextcloud token
|
||||
return await get_session_client_from_context(
|
||||
ctx, lifespan_ctx.nextcloud_host
|
||||
)
|
||||
else:
|
||||
# Pass-through mode (default): Verify and pass Flow 1 token to Nextcloud
|
||||
return get_client_from_context(ctx, lifespan_ctx.nextcloud_host)
|
||||
```
|
||||
|
||||
### 3. MCP Tool Pattern (No Changes Required!)
|
||||
|
||||
Tools use the same pattern regardless of token acquisition mode:
|
||||
|
||||
```python
|
||||
@mcp.tool()
|
||||
@require_scopes("notes:read") # Soft-scope enforced by MCP server, not Nextcloud
|
||||
@require_provisioning
|
||||
async def nc_notes_search_notes(query: str, ctx: Context) -> SearchNotesResponse:
|
||||
"""Search notes by title or content."""
|
||||
|
||||
# get_client() handles both pass-through and token exchange modes
|
||||
client = await get_client(ctx)
|
||||
|
||||
# Execute operation
|
||||
results = await client.notes.search_notes(query=query)
|
||||
|
||||
# In token exchange mode, ephemeral token is automatically discarded
|
||||
# In pass-through mode, Flow 1 token was validated and passed through
|
||||
return SearchNotesResponse(results=results)
|
||||
```
|
||||
|
||||
**Key Benefit**: Tools don't need to know which mode is active. The token acquisition pattern is configured at the server level via `ENABLE_TOKEN_EXCHANGE`.
|
||||
|
||||
### 4. Background Job Pattern
|
||||
|
||||
Background jobs use a **different token acquisition pattern** - they use refresh tokens from Flow 2:
|
||||
|
||||
```python
|
||||
# Background worker
|
||||
async def sync_notes_job(user_id: str):
|
||||
"""Background job to sync notes."""
|
||||
|
||||
# Get refresh token stored during Flow 2 (Progressive Consent)
|
||||
token_storage = get_token_storage()
|
||||
refresh_token = await token_storage.get_refresh_token(user_id)
|
||||
|
||||
if not refresh_token:
|
||||
logger.warning(f"No refresh token for user {user_id}")
|
||||
return
|
||||
|
||||
# Use refresh token to get Nextcloud access token
|
||||
idp_client = get_idp_client()
|
||||
response = await idp_client.refresh_token(
|
||||
refresh_token=refresh_token,
|
||||
audience='nextcloud'
|
||||
)
|
||||
|
||||
# Create client with background token (can be cached)
|
||||
client = NextcloudClient.from_token(
|
||||
base_url=NEXTCLOUD_HOST,
|
||||
token=response.access_token,
|
||||
username=user_id
|
||||
)
|
||||
|
||||
# Perform background sync
|
||||
await client.notes.sync_all()
|
||||
```
|
||||
|
||||
**Key differences from tool calls:**
|
||||
- Uses refresh tokens from Flow 2 (Progressive Consent provisioning)
|
||||
- Tokens can be cached for efficiency (longer-lived operations)
|
||||
- No user interaction possible (offline)
|
||||
- Never triggered during MCP tool execution
|
||||
|
||||
## Security Benefits
|
||||
|
||||
### Proper Token Exchange:
|
||||
1. ✅ **Least Privilege**: Each operation gets only needed scopes
|
||||
2. ✅ **Time-Limited**: Session tokens expire quickly
|
||||
3. ✅ **Audit Trail**: Each exchange can be logged
|
||||
4. ✅ **Token Isolation**: Session ≠ Background tokens
|
||||
5. ✅ **Revocation**: Can revoke background access without affecting active sessions
|
||||
|
||||
### Current Incorrect Pattern:
|
||||
1. ❌ **Over-Privileged**: Refresh token has all scopes
|
||||
2. ❌ **Long-Lived**: Same token reused indefinitely
|
||||
3. ❌ **No Separation**: Sessions and background jobs use same credential
|
||||
4. ❌ **Revocation Issues**: Revoking affects everything
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### Phase 1: Token Exchange (High Priority)
|
||||
1. Implement RFC 8693 token exchange endpoint
|
||||
2. Update Token Broker with `get_session_token()` vs `get_background_token()`
|
||||
3. Modify tool pattern to use token exchange
|
||||
|
||||
### Phase 2: Scope Separation (High Priority)
|
||||
1. Define session scopes vs background scopes
|
||||
2. Update provisioning flow to request appropriate scopes
|
||||
3. Validate scopes in token exchange
|
||||
|
||||
### Phase 3: Background Jobs (Medium Priority)
|
||||
1. Implement background worker pattern
|
||||
2. Create scheduled jobs (note sync, etc.)
|
||||
3. Use background token pattern
|
||||
|
||||
### Phase 4: Testing (High Priority)
|
||||
1. Test token exchange flow end-to-end
|
||||
2. Verify session tokens are ephemeral
|
||||
3. Verify background tokens are separate
|
||||
4. Load test token exchange performance
|
||||
|
||||
## References
|
||||
|
||||
- **RFC 8693**: OAuth 2.0 Token Exchange
|
||||
- **RFC 9068**: JSON Web Token (JWT) Profile for OAuth 2.0 Access Tokens
|
||||
- **ADR-004**: Progressive Consent OAuth Flows
|
||||
- **OAuth 2.0 Delegation**: On-Behalf-Of vs Impersonation patterns
|
||||
|
||||
## Status
|
||||
|
||||
**Current Status**: ✅ Token exchange infrastructure implemented, available as opt-in feature
|
||||
**Modes Available**:
|
||||
- ✅ Pass-through mode (default, `ENABLE_TOKEN_EXCHANGE=false`): Simple, stateless
|
||||
- ✅ Token exchange mode (opt-in, `ENABLE_TOKEN_EXCHANGE=true`): Enhanced security
|
||||
|
||||
**Implementation Complete**:
|
||||
- ✅ `token_exchange.py` module with RFC 8693 support
|
||||
- ✅ Fallback to refresh grant when RFC 8693 not supported
|
||||
- ✅ `get_client()` unified pattern (handles both modes transparently)
|
||||
- ✅ Tokens never cached in token exchange mode (ephemeral)
|
||||
- ✅ Background jobs use separate pattern (refresh tokens from Flow 2)
|
||||
|
||||
## Configuration
|
||||
|
||||
To enable token exchange mode:
|
||||
|
||||
```bash
|
||||
# docker-compose.yml or .env
|
||||
ENABLE_TOKEN_EXCHANGE=true
|
||||
```
|
||||
|
||||
When enabled, all MCP tool calls will use token exchange (RFC 8693) to obtain ephemeral Nextcloud tokens. When disabled (default), Flow 1 tokens are passed through to Nextcloud.
|
||||
|
||||
## Nextcloud Scope Limitation
|
||||
|
||||
**IMPORTANT**: Nextcloud does not support OAuth scopes natively. Scopes like "notes:read" are **soft-scopes** enforced by the MCP server via `@require_scopes` decorator, not by the IdP or Nextcloud.
|
||||
|
||||
This means:
|
||||
- Token exchange provides audit and delegation benefits, not scope restriction
|
||||
- All Nextcloud tokens have equivalent permissions at the Nextcloud level
|
||||
- Fine-grained access control is enforced by MCP server, not Nextcloud
|
||||
|
||||
## Next Actions (Optional Enhancements)
|
||||
|
||||
1. [ ] Add integration tests for token exchange mode with actual MCP tools
|
||||
2. [ ] Document background job patterns for scheduled sync operations
|
||||
3. [ ] Add metrics for token exchange performance
|
||||
4. [ ] Consider making token exchange the default in future major version
|
||||
@@ -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,161 @@
|
||||
# Authentication
|
||||
|
||||
The Nextcloud MCP server supports two authentication modes for connecting to your Nextcloud instance.
|
||||
|
||||
## Authentication Modes Comparison
|
||||
|
||||
| Mode | Status | Security | Use Case |
|
||||
|------|--------|----------|----------|
|
||||
| **OAuth2/OIDC** | ✅ Recommended | 🔒 High | Production deployments, multi-user scenarios |
|
||||
| **Basic Auth** | ⚠️ Legacy | ⚠️ Lower | Development, backward compatibility |
|
||||
|
||||
## OAuth2/OIDC (Recommended)
|
||||
|
||||
OAuth2/OIDC authentication provides secure, token-based authentication following modern security standards.
|
||||
|
||||
### Architecture
|
||||
|
||||
The Nextcloud MCP Server acts as an **OAuth 2.0 Resource Server**, protecting access to Nextcloud resources:
|
||||
|
||||
```
|
||||
MCP Client ←→ MCP Server (Resource Server) ←→ Nextcloud (Authorization Server + APIs)
|
||||
OAuth Flow with PKCE Bearer Token Auth
|
||||
```
|
||||
|
||||
**Key Components**:
|
||||
- **MCP Server**: OAuth Resource Server (validates tokens, provides MCP tools)
|
||||
- **Nextcloud `oidc` app**: OAuth Authorization Server (issues tokens)
|
||||
- **Nextcloud `user_oidc` app**: Token validation middleware
|
||||
- **MCP Client**: Any MCP-compatible client (Claude, custom clients)
|
||||
|
||||
For detailed architecture, see [OAuth Architecture](oauth-architecture.md).
|
||||
|
||||
### Required Nextcloud Apps
|
||||
|
||||
OAuth authentication requires **two Nextcloud apps** to work together:
|
||||
|
||||
#### 1. `oidc` - OIDC Identity Provider
|
||||
**Purpose:** Makes Nextcloud an OAuth2/OIDC authorization server
|
||||
|
||||
**Provides:**
|
||||
- OAuth2 authorization endpoint (`/apps/oidc/authorize`)
|
||||
- Token endpoint (`/apps/oidc/token`)
|
||||
- User info endpoint (`/apps/oidc/userinfo`)
|
||||
- JWKS endpoint for token validation (`/apps/oidc/jwks`)
|
||||
- Dynamic client registration endpoint (`/apps/oidc/register`)
|
||||
|
||||
**Installation:** Available in Nextcloud App Store under "Security"
|
||||
|
||||
#### 2. `user_oidc` - OpenID Connect User Backend
|
||||
**Purpose:** Authenticates users and validates Bearer tokens
|
||||
|
||||
**Provides:**
|
||||
- Bearer token validation against the OIDC provider
|
||||
- User authentication via OIDC
|
||||
- Session management for authenticated users
|
||||
|
||||
**Installation:** Available in Nextcloud App Store under "Security"
|
||||
|
||||
**Important:** The `user_oidc` app requires a patch for Bearer token support on non-OCS endpoints (like Notes API). See [Upstream Status](oauth-upstream-status.md) for details.
|
||||
|
||||
### Benefits
|
||||
- **Zero-config deployment** via dynamic client registration
|
||||
- **No credential storage** in environment variables
|
||||
- **Per-user authentication** with access tokens
|
||||
- **Per-user permissions** - each user has their own Nextcloud client
|
||||
- **Automatic token validation** via Nextcloud OIDC userinfo endpoint
|
||||
- **Token caching** for performance (default: 1 hour TTL)
|
||||
- **PKCE required** for enhanced security (S256 code challenge)
|
||||
- **Secure by design** following OAuth 2.0 and OpenID Connect standards
|
||||
|
||||
### Current Implementation Limitations
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **Tested Configuration:**
|
||||
> - ✅ Nextcloud `oidc` app (OIDC Identity Provider) + `user_oidc` app (OIDC User Backend)
|
||||
> - ✅ Nextcloud acting as its own identity provider (self-hosted OIDC)
|
||||
> - ✅ MCP server as OAuth Resource Server
|
||||
> - ✅ PKCE with S256 code challenge method
|
||||
>
|
||||
> **Not Tested:**
|
||||
> - ❌ External identity providers (Azure AD, Keycloak, Okta, etc.)
|
||||
> - ❌ Using `user_oidc` with external OIDC providers
|
||||
>
|
||||
> **Known Requirements:**
|
||||
> - 🔧 The `user_oidc` app requires a patch for Bearer token support on non-OCS endpoints (see [Upstream Status](oauth-upstream-status.md))
|
||||
> - ⏱️ Dynamic client registration credentials expire (default: 1 hour) - use pre-configured clients for production
|
||||
> - 🔐 PKCE must be advertised in OIDC discovery (see [Upstream Status](oauth-upstream-status.md))
|
||||
|
||||
### How OAuth Works
|
||||
|
||||
The MCP server implements the OAuth 2.0 Resource Server pattern:
|
||||
|
||||
**Phase 1: Authorization (OAuth Flow with PKCE)**
|
||||
1. MCP client connects and receives OAuth settings (issuer URL, scopes)
|
||||
2. Client initiates OAuth flow with PKCE (Proof Key for Code Exchange)
|
||||
3. User authenticates via browser to Nextcloud
|
||||
4. Nextcloud redirects back with authorization code
|
||||
5. Client exchanges code + code_verifier for access token
|
||||
|
||||
**Phase 2: API Access (Bearer Token Validation)**
|
||||
6. Client sends MCP requests with `Authorization: Bearer <token>` header
|
||||
7. MCP server validates token by calling Nextcloud's userinfo endpoint
|
||||
8. Server creates per-user NextcloudClient instance with the token
|
||||
9. All Nextcloud API requests use the user's Bearer token
|
||||
10. User-specific permissions and audit trails apply
|
||||
|
||||
This ensures:
|
||||
- Each user has their own authenticated session
|
||||
- Actions appear from the correct user in Nextcloud logs
|
||||
- Proper permission boundaries are maintained
|
||||
- No shared credentials between users
|
||||
|
||||
### See Also
|
||||
- [OAuth Quick Start](quickstart-oauth.md) - 5-minute setup for development
|
||||
- [OAuth Setup Guide](oauth-setup.md) - Detailed production setup
|
||||
- [OAuth Architecture](oauth-architecture.md) - Technical details
|
||||
- [Upstream Status](oauth-upstream-status.md) - Required patches and PR status
|
||||
- [OAuth Troubleshooting](oauth-troubleshooting.md) - OAuth-specific issues
|
||||
- [Configuration](configuration.md) - Environment variables
|
||||
|
||||
## Basic Authentication (Legacy)
|
||||
|
||||
Basic Authentication uses username and password credentials directly.
|
||||
|
||||
### Benefits
|
||||
- **Simple setup** with username/password
|
||||
- **Single-user** server instances
|
||||
- **Quick for development** and testing
|
||||
|
||||
### Limitations
|
||||
- **Credentials in environment** (less secure)
|
||||
- **Single user only** - all requests use the same account
|
||||
- **No audit trail** - all actions appear from the same user
|
||||
- **Maintained for compatibility** - will be deprecated in future versions
|
||||
|
||||
> [!WARNING]
|
||||
> **Security Notice:** Basic Authentication stores credentials in environment variables and is less secure than OAuth. It's maintained for backward compatibility only and may be deprecated in future versions. Use OAuth for production deployments.
|
||||
|
||||
### See Also
|
||||
- [Configuration](configuration.md#basic-authentication-legacy) - BasicAuth environment variables
|
||||
- [Running the Server](running.md#basicauth-mode-legacy) - BasicAuth examples
|
||||
|
||||
## Mode Detection
|
||||
|
||||
The server automatically detects the authentication mode:
|
||||
|
||||
- **OAuth mode**: When `NEXTCLOUD_USERNAME` and `NEXTCLOUD_PASSWORD` are NOT set
|
||||
- **BasicAuth mode**: When both username and password are provided
|
||||
|
||||
You can also force a specific mode using CLI flags:
|
||||
```bash
|
||||
# Force OAuth mode
|
||||
uv run nextcloud-mcp-server --oauth
|
||||
|
||||
# Force BasicAuth mode
|
||||
uv run nextcloud-mcp-server --no-oauth
|
||||
```
|
||||
|
||||
## Switching Between Modes
|
||||
|
||||
See [Troubleshooting: Switching Between OAuth and BasicAuth](troubleshooting.md#switching-between-oauth-and-basicauth) for instructions.
|
||||
@@ -0,0 +1,109 @@
|
||||
# Calendar App
|
||||
|
||||
### Calendar Tools
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `nc_calendar_list_calendars` | List all available calendars for the user |
|
||||
| `nc_calendar_create_event` | Create a comprehensive calendar event with full feature support (recurring, reminders, attendees, etc.) |
|
||||
| `nc_calendar_list_events` | **Enhanced:** List events with advanced filtering (min attendees, duration, categories, status, search across all calendars) |
|
||||
| `nc_calendar_get_event` | Get detailed information about a specific event |
|
||||
| `nc_calendar_update_event` | Update any aspect of an existing event |
|
||||
| `nc_calendar_delete_event` | Delete a calendar event |
|
||||
| `nc_calendar_create_meeting` | Quick meeting creation with smart defaults |
|
||||
| `nc_calendar_get_upcoming_events` | Get upcoming events in the next N days |
|
||||
| `nc_calendar_find_availability` | **New:** Intelligent availability finder - find free time slots for meetings with attendee conflict detection |
|
||||
| `nc_calendar_bulk_operations` | **New:** Bulk update, delete, or move events matching filter criteria |
|
||||
| `nc_calendar_manage_calendar` | **New:** Create, delete, and manage calendar properties |
|
||||
|
||||
### Calendar Integration
|
||||
|
||||
The server provides comprehensive calendar integration through CalDAV, enabling you to:
|
||||
|
||||
- List all available calendars
|
||||
- Create, read, update, and delete calendar events
|
||||
- Handle recurring events with RRULE support
|
||||
- Manage event reminders and notifications
|
||||
- Support all-day and timed events
|
||||
- Handle attendees and meeting invitations
|
||||
- Organize events with categories and priorities
|
||||
|
||||
**Usage Examples:**
|
||||
|
||||
```python
|
||||
# List available calendars
|
||||
calendars = await nc_calendar_list_calendars()
|
||||
|
||||
# Create a simple event
|
||||
await nc_calendar_create_event(
|
||||
calendar_name="personal",
|
||||
title="Team Meeting",
|
||||
start_datetime="2025-07-28T14:00:00",
|
||||
end_datetime="2025-07-28T15:00:00",
|
||||
description="Weekly team sync",
|
||||
location="Conference Room A"
|
||||
)
|
||||
|
||||
# Create a recurring weekly meeting
|
||||
await nc_calendar_create_event(
|
||||
calendar_name="work",
|
||||
title="Weekly Standup",
|
||||
start_datetime="2025-07-28T09:00:00",
|
||||
end_datetime="2025-07-28T09:30:00",
|
||||
recurring=True,
|
||||
recurrence_rule="FREQ=WEEKLY;BYDAY=MO"
|
||||
)
|
||||
|
||||
# Quick meeting creation
|
||||
await nc_calendar_create_meeting(
|
||||
title="Client Call",
|
||||
date="2025-07-28",
|
||||
time="15:00",
|
||||
duration_minutes=60,
|
||||
attendees="client@example.com,colleague@company.com"
|
||||
)
|
||||
|
||||
# Get upcoming events
|
||||
events = await nc_calendar_get_upcoming_events(days_ahead=7)
|
||||
|
||||
# Advanced search - find all meetings with 5+ attendees lasting 2+ hours
|
||||
long_meetings = await nc_calendar_list_events(
|
||||
calendar_name="", # Search all calendars
|
||||
search_all_calendars=True,
|
||||
start_date="2025-07-01",
|
||||
end_date="2025-07-31",
|
||||
min_attendees=5,
|
||||
min_duration_minutes=120,
|
||||
title_contains="meeting"
|
||||
)
|
||||
|
||||
# Find availability for a 1-hour meeting with specific attendees
|
||||
availability = await nc_calendar_find_availability(
|
||||
duration_minutes=60,
|
||||
attendees="sarah@company.com,mike@company.com",
|
||||
date_range_start="2025-07-28",
|
||||
date_range_end="2025-08-04",
|
||||
business_hours_only=True,
|
||||
exclude_weekends=True,
|
||||
preferred_times="09:00-12:00,14:00-17:00"
|
||||
)
|
||||
|
||||
# Bulk update all team meetings to new location
|
||||
bulk_result = await nc_calendar_bulk_operations(
|
||||
operation="update",
|
||||
title_contains="team meeting",
|
||||
start_date="2025-08-01",
|
||||
end_date="2025-08-31",
|
||||
new_location="Conference Room B",
|
||||
new_reminder_minutes=15
|
||||
)
|
||||
|
||||
# Create a new project calendar
|
||||
new_calendar = await nc_calendar_manage_calendar(
|
||||
action="create",
|
||||
calendar_name="project-alpha",
|
||||
display_name="Project Alpha Calendar",
|
||||
description="Calendar for Project Alpha team",
|
||||
color="#FF5722"
|
||||
)
|
||||
```
|
||||
@@ -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)
|
||||
@@ -0,0 +1,555 @@
|
||||
# Configuration
|
||||
|
||||
The Nextcloud MCP server requires configuration to connect to your Nextcloud instance. Configuration is provided through environment variables, typically stored in a `.env` file.
|
||||
|
||||
## Quick Start
|
||||
|
||||
Create a `.env` file based on `env.sample`:
|
||||
|
||||
```bash
|
||||
cp env.sample .env
|
||||
# Edit .env with your Nextcloud details
|
||||
```
|
||||
|
||||
Then choose your authentication mode:
|
||||
|
||||
- [OAuth2/OIDC Configuration](#oauth2oidc-configuration) (Recommended)
|
||||
- [Basic Authentication Configuration](#basic-authentication-legacy)
|
||||
|
||||
---
|
||||
|
||||
## OAuth2/OIDC Configuration
|
||||
|
||||
OAuth2/OIDC is the recommended authentication mode for production deployments.
|
||||
|
||||
### Minimal Configuration (Auto-registration)
|
||||
|
||||
```dotenv
|
||||
# .env file for OAuth with auto-registration
|
||||
NEXTCLOUD_HOST=https://your.nextcloud.instance.com
|
||||
|
||||
# Leave these EMPTY for OAuth mode
|
||||
NEXTCLOUD_USERNAME=
|
||||
NEXTCLOUD_PASSWORD=
|
||||
```
|
||||
|
||||
This minimal configuration uses dynamic client registration to automatically register an OAuth client at startup.
|
||||
|
||||
### Full Configuration (Pre-configured Client)
|
||||
|
||||
```dotenv
|
||||
# .env file for OAuth with pre-configured client
|
||||
NEXTCLOUD_HOST=https://your.nextcloud.instance.com
|
||||
|
||||
# OAuth Client Credentials (optional - auto-registers if not provided)
|
||||
NEXTCLOUD_OIDC_CLIENT_ID=your-client-id
|
||||
NEXTCLOUD_OIDC_CLIENT_SECRET=your-client-secret
|
||||
|
||||
# OAuth Callback Settings (optional)
|
||||
NEXTCLOUD_MCP_SERVER_URL=http://localhost:8000
|
||||
|
||||
# Leave these EMPTY for OAuth mode
|
||||
NEXTCLOUD_USERNAME=
|
||||
NEXTCLOUD_PASSWORD=
|
||||
```
|
||||
|
||||
### Environment Variables Reference
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
|----------|----------|---------|-------------|
|
||||
| `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_SECRET` | ⚠️ Optional | - | OAuth client secret (auto-registers if empty) |
|
||||
| `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_PASSWORD` | ❌ Must be empty | - | Leave empty to enable OAuth mode |
|
||||
|
||||
### Prerequisites
|
||||
|
||||
Before using OAuth configuration:
|
||||
|
||||
1. **Install required Nextcloud apps** (both are required):
|
||||
- **`oidc`** - OIDC Identity Provider (Apps → Security)
|
||||
- **`user_oidc`** - OpenID Connect user backend (Apps → Security)
|
||||
|
||||
2. **Configure the apps**:
|
||||
- Enable dynamic client registration (if using auto-registration) - Settings → OIDC
|
||||
- Enable Bearer token validation: `php occ config:system:set user_oidc oidc_provider_bearer_validation --value=true --type=boolean`
|
||||
|
||||
3. **Apply Bearer token patch** - The `user_oidc` app requires a patch for non-OCS endpoints - See [Upstream Status](oauth-upstream-status.md) for details
|
||||
|
||||
See the [OAuth Setup Guide](oauth-setup.md) for detailed step-by-step instructions, or [OAuth Quick Start](quickstart-oauth.md) for a 5-minute setup.
|
||||
|
||||
---
|
||||
|
||||
## Basic Authentication (Legacy)
|
||||
|
||||
Basic Authentication is maintained for backward compatibility. It uses username and password credentials.
|
||||
|
||||
> [!WARNING]
|
||||
> **Security Notice:** Basic Authentication stores credentials in environment variables and is less secure than OAuth. Use OAuth for production deployments.
|
||||
|
||||
### Configuration
|
||||
|
||||
```dotenv
|
||||
# .env file for BasicAuth mode
|
||||
NEXTCLOUD_HOST=https://your.nextcloud.instance.com
|
||||
NEXTCLOUD_USERNAME=your_nextcloud_username
|
||||
NEXTCLOUD_PASSWORD=your_app_password_or_password
|
||||
```
|
||||
|
||||
### Environment Variables Reference
|
||||
|
||||
| Variable | Required | Description |
|
||||
|----------|----------|-------------|
|
||||
| `NEXTCLOUD_HOST` | ✅ Yes | Full URL of your Nextcloud instance |
|
||||
| `NEXTCLOUD_USERNAME` | ✅ Yes | Your Nextcloud username |
|
||||
| `NEXTCLOUD_PASSWORD` | ✅ Yes | **Recommended:** Use a dedicated [Nextcloud App Password](https://docs.nextcloud.com/server/latest/user_manual/en/session_management.html#managing-devices). Generate one in Nextcloud Security settings. Alternatively, use your login password (less secure). |
|
||||
|
||||
---
|
||||
|
||||
## Semantic Search Configuration (Optional)
|
||||
|
||||
The MCP server includes semantic search capabilities powered by vector embeddings. This feature requires a vector database (Qdrant) and an embedding service.
|
||||
|
||||
### Qdrant Vector Database Modes
|
||||
|
||||
The server supports three Qdrant deployment modes:
|
||||
|
||||
1. **In-Memory Mode** (Default) - Simplest for development and testing
|
||||
2. **Persistent Local Mode** - For single-instance deployments with persistence
|
||||
3. **Network Mode** - For production with dedicated Qdrant service
|
||||
|
||||
#### 1. In-Memory Mode (Default)
|
||||
|
||||
No configuration needed! If neither `QDRANT_URL` nor `QDRANT_LOCATION` is set, the server defaults to in-memory mode:
|
||||
|
||||
```dotenv
|
||||
# No Qdrant configuration needed - defaults to :memory:
|
||||
VECTOR_SYNC_ENABLED=true
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
- Zero configuration
|
||||
- Fast startup
|
||||
- Perfect for testing
|
||||
|
||||
**Cons:**
|
||||
- Data lost on restart
|
||||
- Limited to available RAM
|
||||
|
||||
#### 2. Persistent Local Mode
|
||||
|
||||
For single-instance deployments that need persistence without a separate Qdrant service:
|
||||
|
||||
```dotenv
|
||||
# Local persistent storage
|
||||
QDRANT_LOCATION=/app/data/qdrant # Or any writable path
|
||||
VECTOR_SYNC_ENABLED=true
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
- Data persists across restarts
|
||||
- No separate service needed
|
||||
- Suitable for small/medium deployments
|
||||
|
||||
**Cons:**
|
||||
- Limited to single instance
|
||||
- Shares resources with MCP server
|
||||
|
||||
#### 3. Network Mode
|
||||
|
||||
For production deployments with a dedicated Qdrant service:
|
||||
|
||||
```dotenv
|
||||
# Network mode configuration
|
||||
QDRANT_URL=http://qdrant:6333
|
||||
QDRANT_API_KEY=your-secret-api-key # Optional
|
||||
QDRANT_COLLECTION=nextcloud_content # Optional
|
||||
VECTOR_SYNC_ENABLED=true
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
- Scalable and performant
|
||||
- Can be shared across multiple MCP instances
|
||||
- Supports clustering and replication
|
||||
|
||||
**Cons:**
|
||||
- Requires separate Qdrant service
|
||||
- More complex deployment
|
||||
|
||||
### Qdrant Collection Naming
|
||||
|
||||
Collection names are automatically generated to include the embedding model, ensuring safe model switching and preventing dimension mismatches.
|
||||
|
||||
#### Auto-Generated Naming (Default)
|
||||
|
||||
**Format:** `{deployment-id}-{model-name}`
|
||||
|
||||
**Components:**
|
||||
- **Deployment ID:** `OTEL_SERVICE_NAME` (if configured) or `hostname` (fallback)
|
||||
- **Model name:** `OLLAMA_EMBEDDING_MODEL`
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
# With OTEL service name configured
|
||||
OTEL_SERVICE_NAME=my-mcp-server
|
||||
OLLAMA_EMBEDDING_MODEL=nomic-embed-text
|
||||
# → Collection: "my-mcp-server-nomic-embed-text"
|
||||
|
||||
# Simple Docker deployment (OTEL not configured)
|
||||
# hostname=mcp-container
|
||||
OLLAMA_EMBEDDING_MODEL=all-minilm
|
||||
# → Collection: "mcp-container-all-minilm"
|
||||
```
|
||||
|
||||
#### Switching Embedding Models
|
||||
|
||||
When you change `OLLAMA_EMBEDDING_MODEL`, a new collection is automatically created:
|
||||
|
||||
```bash
|
||||
# Initial setup
|
||||
OLLAMA_EMBEDDING_MODEL=nomic-embed-text
|
||||
# Collection: "my-server-nomic-embed-text" (768 dimensions)
|
||||
|
||||
# Change model
|
||||
OLLAMA_EMBEDDING_MODEL=all-minilm
|
||||
# Collection: "my-server-all-minilm" (384 dimensions)
|
||||
# → New collection created, full re-embedding occurs
|
||||
```
|
||||
|
||||
**Important:**
|
||||
- **Collections are mutually exclusive** - vectors cannot be shared between different embedding models
|
||||
- **Switching models requires re-embedding** all documents (may take time for large note collections)
|
||||
- **Old collection remains** in Qdrant and can be deleted manually if no longer needed
|
||||
|
||||
#### Explicit Override
|
||||
|
||||
Set `QDRANT_COLLECTION` to use a specific collection name:
|
||||
|
||||
```bash
|
||||
QDRANT_COLLECTION=my-custom-collection # Bypasses auto-generation
|
||||
```
|
||||
|
||||
**Use cases:**
|
||||
- Backward compatibility with existing deployments
|
||||
- Custom naming schemes
|
||||
- Sharing a collection across deployments (advanced)
|
||||
|
||||
#### Multi-Server Deployments
|
||||
|
||||
Each server should have a unique deployment ID to avoid collection collisions:
|
||||
|
||||
```bash
|
||||
# Server 1 (Production)
|
||||
OTEL_SERVICE_NAME=mcp-prod
|
||||
OLLAMA_EMBEDDING_MODEL=nomic-embed-text
|
||||
# → Collection: "mcp-prod-nomic-embed-text"
|
||||
|
||||
# Server 2 (Staging)
|
||||
OTEL_SERVICE_NAME=mcp-staging
|
||||
OLLAMA_EMBEDDING_MODEL=nomic-embed-text
|
||||
# → Collection: "mcp-staging-nomic-embed-text"
|
||||
|
||||
# Server 3 (Different model)
|
||||
OTEL_SERVICE_NAME=mcp-experimental
|
||||
OLLAMA_EMBEDDING_MODEL=bge-large
|
||||
# → Collection: "mcp-experimental-bge-large"
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- Multiple MCP servers can share one Qdrant instance safely
|
||||
- No naming collisions between deployments
|
||||
- Clear collection ownership (can see which deployment and model)
|
||||
|
||||
#### Dimension Validation
|
||||
|
||||
The server validates collection dimensions on startup:
|
||||
|
||||
```
|
||||
Dimension mismatch for collection 'my-server-nomic-embed-text':
|
||||
Expected: 384 (from embedding model 'all-minilm')
|
||||
Found: 768
|
||||
This usually means you changed the embedding model.
|
||||
Solutions:
|
||||
1. Delete the old collection: Collection will be recreated with new dimensions
|
||||
2. Set QDRANT_COLLECTION to use a different collection name
|
||||
3. Revert OLLAMA_EMBEDDING_MODEL to the original model
|
||||
```
|
||||
|
||||
**What this prevents:**
|
||||
- Runtime errors from dimension mismatches
|
||||
- Data corruption in Qdrant
|
||||
- Confusing error messages during indexing
|
||||
|
||||
### Vector Sync Configuration
|
||||
|
||||
Control background indexing behavior:
|
||||
|
||||
```dotenv
|
||||
# Vector sync settings (ADR-007)
|
||||
VECTOR_SYNC_ENABLED=true # Enable background indexing
|
||||
VECTOR_SYNC_SCAN_INTERVAL=300 # Scan interval in seconds (default: 5 minutes)
|
||||
VECTOR_SYNC_PROCESSOR_WORKERS=3 # Concurrent indexing workers (default: 3)
|
||||
VECTOR_SYNC_QUEUE_MAX_SIZE=10000 # Max queued documents (default: 10000)
|
||||
|
||||
# Document chunking settings (for vector embeddings)
|
||||
DOCUMENT_CHUNK_SIZE=512 # Words per chunk (default: 512)
|
||||
DOCUMENT_CHUNK_OVERLAP=50 # Overlapping words between chunks (default: 50)
|
||||
```
|
||||
|
||||
### Embedding Service Configuration
|
||||
|
||||
The server uses an embedding service to generate vector representations. Two options are available:
|
||||
|
||||
#### Ollama (Recommended)
|
||||
|
||||
Use a local Ollama instance for embeddings:
|
||||
|
||||
```dotenv
|
||||
OLLAMA_BASE_URL=http://ollama:11434
|
||||
OLLAMA_EMBEDDING_MODEL=nomic-embed-text # Default model
|
||||
OLLAMA_VERIFY_SSL=true # Verify SSL certificates
|
||||
```
|
||||
|
||||
#### Simple Embedding Provider (Fallback)
|
||||
|
||||
If `OLLAMA_BASE_URL` is not set, the server uses a simple random embedding provider for testing. This is **not suitable for production** as it generates random embeddings with no semantic meaning.
|
||||
|
||||
### Document Chunking Configuration
|
||||
|
||||
The server chunks documents before embedding to handle documents larger than the embedding model's context window. Chunk size and overlap can be tuned based on your embedding model and content type.
|
||||
|
||||
#### Choosing Chunk Size
|
||||
|
||||
**Smaller chunks (256-384 words)**:
|
||||
- More precise matching
|
||||
- Less context per chunk
|
||||
- Better for finding specific information
|
||||
- Higher storage requirements (more vectors)
|
||||
|
||||
**Larger chunks (768-1024 words)**:
|
||||
- More context per chunk
|
||||
- Less precise matching
|
||||
- Better for understanding broader topics
|
||||
- Lower storage requirements (fewer vectors)
|
||||
|
||||
**Default (512 words)**:
|
||||
- Balanced approach suitable for most use cases
|
||||
- Works well with typical note lengths
|
||||
- Good compromise between precision and context
|
||||
|
||||
#### Choosing Overlap
|
||||
|
||||
Overlap preserves context across chunk boundaries. Recommended settings:
|
||||
|
||||
- **10-20% of chunk size** (e.g., 50-100 words for 512-word chunks)
|
||||
- **Too small** (<10%): May lose context at boundaries
|
||||
- **Too large** (>20%): Redundant storage, diminishing returns
|
||||
|
||||
**Examples**:
|
||||
```dotenv
|
||||
# Precise matching for short notes
|
||||
DOCUMENT_CHUNK_SIZE=256
|
||||
DOCUMENT_CHUNK_OVERLAP=25
|
||||
|
||||
# Default balanced configuration
|
||||
DOCUMENT_CHUNK_SIZE=512
|
||||
DOCUMENT_CHUNK_OVERLAP=50
|
||||
|
||||
# More context for long documents
|
||||
DOCUMENT_CHUNK_SIZE=1024
|
||||
DOCUMENT_CHUNK_OVERLAP=100
|
||||
```
|
||||
|
||||
**Important**: Changing chunk size requires re-embedding all documents. The collection naming strategy (see "Qdrant Collection Naming" above) helps manage this by creating separate collections for different configurations.
|
||||
|
||||
### Environment Variables Reference
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
|----------|----------|---------|-------------|
|
||||
| `QDRANT_URL` | ⚠️ Optional | - | Qdrant service URL (network mode) - mutually exclusive with `QDRANT_LOCATION` |
|
||||
| `QDRANT_LOCATION` | ⚠️ Optional | `:memory:` | Local Qdrant path (`:memory:` or `/path/to/data`) - mutually exclusive with `QDRANT_URL` |
|
||||
| `QDRANT_API_KEY` | ⚠️ Optional | - | Qdrant API key (network mode only) |
|
||||
| `QDRANT_COLLECTION` | ⚠️ Optional | `nextcloud_content` | Qdrant collection name |
|
||||
| `VECTOR_SYNC_ENABLED` | ⚠️ Optional | `false` | Enable background vector indexing |
|
||||
| `VECTOR_SYNC_SCAN_INTERVAL` | ⚠️ Optional | `300` | Document scan interval (seconds) |
|
||||
| `VECTOR_SYNC_PROCESSOR_WORKERS` | ⚠️ Optional | `3` | Concurrent indexing workers |
|
||||
| `VECTOR_SYNC_QUEUE_MAX_SIZE` | ⚠️ Optional | `10000` | Max queued documents |
|
||||
| `OLLAMA_BASE_URL` | ⚠️ Optional | - | Ollama API endpoint for embeddings |
|
||||
| `OLLAMA_EMBEDDING_MODEL` | ⚠️ Optional | `nomic-embed-text` | Embedding model to use |
|
||||
| `OLLAMA_VERIFY_SSL` | ⚠️ Optional | `true` | Verify SSL certificates |
|
||||
| `DOCUMENT_CHUNK_SIZE` | ⚠️ Optional | `512` | Words per chunk for document embedding |
|
||||
| `DOCUMENT_CHUNK_OVERLAP` | ⚠️ Optional | `50` | Overlapping words between chunks (must be < chunk size) |
|
||||
|
||||
### Docker Compose Example
|
||||
|
||||
Enable network mode Qdrant with docker-compose:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
mcp:
|
||||
environment:
|
||||
- QDRANT_URL=http://qdrant:6333
|
||||
- VECTOR_SYNC_ENABLED=true
|
||||
|
||||
qdrant:
|
||||
image: qdrant/qdrant:latest
|
||||
ports:
|
||||
- 127.0.0.1:6333:6333
|
||||
volumes:
|
||||
- qdrant-data:/qdrant/storage
|
||||
profiles:
|
||||
- qdrant # Optional service
|
||||
|
||||
volumes:
|
||||
qdrant-data:
|
||||
```
|
||||
|
||||
Start with Qdrant service:
|
||||
```bash
|
||||
docker-compose --profile qdrant up
|
||||
```
|
||||
|
||||
Or use default in-memory mode (no `--profile` needed):
|
||||
```bash
|
||||
docker-compose up
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Loading Environment Variables
|
||||
|
||||
After creating your `.env` file, load the environment variables:
|
||||
|
||||
### On Linux/macOS
|
||||
|
||||
```bash
|
||||
# Load all variables from .env
|
||||
export $(grep -v '^#' .env | xargs)
|
||||
```
|
||||
|
||||
### On Windows (PowerShell)
|
||||
|
||||
```powershell
|
||||
# Load variables from .env
|
||||
Get-Content .env | ForEach-Object {
|
||||
if ($_ -match '^\s*([^#][^=]*)\s*=\s*(.*)$') {
|
||||
[Environment]::SetEnvironmentVariable($matches[1].Trim(), $matches[2].Trim(), "Process")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Via Docker
|
||||
|
||||
```bash
|
||||
# Docker automatically loads .env when using --env-file
|
||||
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
|
||||
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CLI Configuration
|
||||
|
||||
Some configuration options can also be provided via CLI arguments. CLI arguments take precedence over environment variables.
|
||||
|
||||
### OAuth-related CLI Options
|
||||
|
||||
```bash
|
||||
uv run nextcloud-mcp-server --help
|
||||
|
||||
Options:
|
||||
--oauth / --no-oauth Force OAuth mode (if enabled) or
|
||||
BasicAuth mode (if disabled). By default,
|
||||
auto-detected based on environment
|
||||
variables.
|
||||
--oauth-client-id TEXT OAuth client ID (can also use
|
||||
NEXTCLOUD_OIDC_CLIENT_ID env var)
|
||||
--oauth-client-secret TEXT OAuth client secret (can also use
|
||||
NEXTCLOUD_OIDC_CLIENT_SECRET env var)
|
||||
--mcp-server-url TEXT MCP server URL for OAuth callbacks (can
|
||||
also use NEXTCLOUD_MCP_SERVER_URL env
|
||||
var) [default: http://localhost:8000]
|
||||
```
|
||||
|
||||
### Server Options
|
||||
|
||||
```bash
|
||||
Options:
|
||||
-h, --host TEXT Server host [default: 127.0.0.1]
|
||||
-p, --port INTEGER Server port [default: 8000]
|
||||
-w, --workers INTEGER Number of worker processes
|
||||
-r, --reload Enable auto-reload
|
||||
-l, --log-level [critical|error|warning|info|debug|trace]
|
||||
Logging level [default: info]
|
||||
-t, --transport [sse|streamable-http|http]
|
||||
MCP transport protocol [default: sse]
|
||||
```
|
||||
|
||||
### App Selection
|
||||
|
||||
```bash
|
||||
Options:
|
||||
-e, --enable-app [notes|tables|webdav|calendar|contacts|deck]
|
||||
Enable specific Nextcloud app APIs. Can
|
||||
be specified multiple times. If not
|
||||
specified, all apps are enabled.
|
||||
```
|
||||
|
||||
### Example CLI Usage
|
||||
|
||||
```bash
|
||||
# OAuth mode with custom client and port
|
||||
uv run nextcloud-mcp-server --oauth \
|
||||
--oauth-client-id abc123 \
|
||||
--oauth-client-secret xyz789 \
|
||||
--port 8080
|
||||
|
||||
# BasicAuth mode with specific apps only
|
||||
uv run nextcloud-mcp-server --no-oauth \
|
||||
--enable-app notes \
|
||||
--enable-app calendar
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuration Best Practices
|
||||
|
||||
### For Development
|
||||
|
||||
- Use BasicAuth for quick setup and testing
|
||||
- Or use OAuth with auto-registration (dynamic client registration)
|
||||
- Store `.env` file in your project directory
|
||||
- Add `.env` to `.gitignore`
|
||||
|
||||
### For Production
|
||||
|
||||
- **Always use OAuth2/OIDC** with pre-configured clients
|
||||
- Store OAuth client credentials securely
|
||||
- Use environment variables from your deployment platform (Docker secrets, Kubernetes ConfigMaps, etc.)
|
||||
- Never commit credentials to version control
|
||||
- SQLite database permissions are handled automatically by the server
|
||||
|
||||
### For Docker
|
||||
|
||||
- Mount OAuth client storage as a volume for persistence:
|
||||
```bash
|
||||
docker run -v $(pwd)/.oauth:/app/.oauth --env-file .env \
|
||||
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest
|
||||
```
|
||||
- Use Docker secrets for sensitive values in production
|
||||
|
||||
---
|
||||
|
||||
## See Also
|
||||
|
||||
- [OAuth Quick Start](quickstart-oauth.md) - 5-minute OAuth setup for development
|
||||
- [OAuth Setup Guide](oauth-setup.md) - Detailed OAuth configuration for production
|
||||
- [OAuth Architecture](oauth-architecture.md) - How OAuth works in the MCP server
|
||||
- [Upstream Status](oauth-upstream-status.md) - Required patches and upstream PRs
|
||||
- [Authentication](authentication.md) - Authentication modes comparison
|
||||
- [Running the Server](running.md) - Starting the server with different configurations
|
||||
- [Troubleshooting](troubleshooting.md) - Common configuration issues
|
||||
- [OAuth Troubleshooting](oauth-troubleshooting.md) - OAuth-specific troubleshooting
|
||||
@@ -0,0 +1,12 @@
|
||||
# Contacts App
|
||||
|
||||
### Contacts Tools
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `nc_contacts_list_addressbooks` | List all available addressbooks for the user |
|
||||
| `nc_contacts_list_contacts` | List all contacts in a specific addressbook |
|
||||
| `nc_contacts_create_addressbook` | Create a new addressbook |
|
||||
| `nc_contacts_delete_addressbook` | Delete an addressbook |
|
||||
| `nc_contacts_create_contact` | Create a new contact in an addressbook |
|
||||
| `nc_contacts_delete_contact` | Delete a contact from an addressbook |
|
||||
@@ -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).
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
# Deck App
|
||||
|
||||
### Deck Tools
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `deck_create_board` | Create a new Deck board with title and color |
|
||||
| `deck_create_stack` | Create a new stack in a board |
|
||||
| `deck_update_stack` | Update stack title and order |
|
||||
| `deck_delete_stack` | Delete a stack and all its cards |
|
||||
| `deck_create_card` | Create a new card in a stack with full options (title, description, due date, etc.) |
|
||||
| `deck_update_card` | Update any aspect of a card (title, description, owner, order, etc.) |
|
||||
| `deck_delete_card` | Delete a card |
|
||||
| `deck_archive_card` | Archive a card |
|
||||
| `deck_unarchive_card` | Unarchive a card |
|
||||
| `deck_reorder_card` | Move/reorder cards within or between stacks |
|
||||
| `deck_create_label` | Create a new label in a board |
|
||||
| `deck_update_label` | Update label title and color |
|
||||
| `deck_delete_label` | Delete a label |
|
||||
| `deck_assign_label_to_card` | Assign a label to a card |
|
||||
| `deck_remove_label_from_card` | Remove a label from a card |
|
||||
| `deck_assign_user_to_card` | Assign a user to a card |
|
||||
| `deck_unassign_user_from_card` | Remove a user assignment from a card |
|
||||
|
||||
### Deck Resources
|
||||
| Resource | Description |
|
||||
|----------|-------------|
|
||||
| `nc://Deck/boards` | List all deck boards |
|
||||
| `nc://Deck/boards/{board_id}` | Get details of a specific board |
|
||||
| `nc://Deck/boards/{board_id}/stacks` | List all stacks in a board |
|
||||
| `nc://Deck/boards/{board_id}/stacks/{stack_id}` | Get details of a specific stack |
|
||||
| `nc://Deck/boards/{board_id}/stacks/{stack_id}/cards` | List all cards in a stack |
|
||||
| `nc://Deck/boards/{board_id}/stacks/{stack_id}/cards/{card_id}` | Get details of a specific card |
|
||||
| `nc://Deck/boards/{board_id}/labels` | List all labels in a board |
|
||||
| `nc://Deck/boards/{board_id}/labels/{label_id}` | Get details of a specific label |
|
||||
|
||||
|
||||
|
||||
### Deck Project Management
|
||||
|
||||
The server provides complete Nextcloud Deck integration, enabling you to manage projects, tasks, and workflows:
|
||||
|
||||
- Create and manage boards, stacks, and cards
|
||||
- Organize tasks with labels and user assignments
|
||||
- Archive/unarchive cards and reorder within or between stacks
|
||||
- Full CRUD operations on all Deck entities
|
||||
- Browse project structure through hierarchical resources
|
||||
|
||||
**Usage Examples:**
|
||||
|
||||
```python
|
||||
# Create a new project board
|
||||
await deck_create_board(title="Website Redesign", color="1976D2")
|
||||
|
||||
# Create workflow stacks
|
||||
await deck_create_stack(board_id=1, title="To Do", order=1)
|
||||
await deck_create_stack(board_id=1, title="In Progress", order=2)
|
||||
await deck_create_stack(board_id=1, title="Done", order=3)
|
||||
|
||||
# Create task cards with details
|
||||
await deck_create_card(
|
||||
board_id=1,
|
||||
stack_id=1,
|
||||
title="Design new homepage",
|
||||
description="Create mockups for the new homepage layout",
|
||||
type="plain",
|
||||
order=1,
|
||||
duedate="2025-08-15T17:00:00"
|
||||
)
|
||||
|
||||
# Create and assign labels for organization
|
||||
await deck_create_label(board_id=1, title="High Priority", color="F44336")
|
||||
await deck_create_label(board_id=1, title="UI/UX", color="9C27B0")
|
||||
|
||||
# Assign labels and users to cards
|
||||
await deck_assign_label_to_card(board_id=1, stack_id=1, card_id=1, label_id=1)
|
||||
await deck_assign_user_to_card(board_id=1, stack_id=1, card_id=1, user_id="designer")
|
||||
|
||||
# Move cards through workflow
|
||||
await deck_reorder_card(
|
||||
board_id=1,
|
||||
stack_id=1, # From "To Do"
|
||||
card_id=1,
|
||||
order=1,
|
||||
target_stack_id=2 # To "In Progress"
|
||||
)
|
||||
|
||||
# Update task progress
|
||||
await deck_update_card(
|
||||
board_id=1,
|
||||
stack_id=2,
|
||||
card_id=1,
|
||||
description="Homepage mockups completed, starting development",
|
||||
order=1
|
||||
)
|
||||
|
||||
# Complete tasks
|
||||
await deck_reorder_card(
|
||||
board_id=1,
|
||||
stack_id=2, # From "In Progress"
|
||||
card_id=1,
|
||||
order=1,
|
||||
target_stack_id=3 # To "Done"
|
||||
)
|
||||
|
||||
# Archive completed cards
|
||||
await deck_archive_card(board_id=1, stack_id=3, card_id=1)
|
||||
```
|
||||
@@ -0,0 +1,215 @@
|
||||
# Installation
|
||||
|
||||
This guide covers installing the Nextcloud MCP server on your system.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Python 3.11+** - Check with `python3 --version`
|
||||
- **Access to a Nextcloud instance** - Self-hosted or cloud-hosted
|
||||
- **Administrator access** (for OAuth setup) - Required to install OIDC app
|
||||
|
||||
## Installation Methods
|
||||
|
||||
Choose one of the following installation methods:
|
||||
|
||||
- [From Source (Recommended)](#from-source-recommended)
|
||||
- [Using Docker](#using-docker)
|
||||
|
||||
---
|
||||
|
||||
## From Source (Recommended)
|
||||
|
||||
Install from the GitHub repository using uv or pip.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
Install [uv](https://github.com/astral-sh/uv) (recommended) or ensure pip is available:
|
||||
|
||||
```bash
|
||||
# Install uv (recommended)
|
||||
# On macOS/Linux
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
|
||||
# On Windows
|
||||
powershell -c "irm https://astral.sh/uv/install.ps1 | iex"
|
||||
```
|
||||
|
||||
### Clone the Repository
|
||||
|
||||
```bash
|
||||
git clone https://github.com/cbcoutinho/nextcloud-mcp-server.git
|
||||
cd nextcloud-mcp-server
|
||||
```
|
||||
|
||||
### Install Dependencies
|
||||
|
||||
#### Using uv (Recommended)
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
uv sync
|
||||
|
||||
# Install development dependencies (optional)
|
||||
uv sync --group dev
|
||||
```
|
||||
|
||||
#### Using pip
|
||||
|
||||
```bash
|
||||
# Create virtual environment
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate # On Windows: venv\Scripts\activate
|
||||
|
||||
# Install in development mode
|
||||
pip install -e .
|
||||
|
||||
# Install development dependencies (optional)
|
||||
pip install -e ".[dev]"
|
||||
```
|
||||
|
||||
### Verify Installation
|
||||
|
||||
```bash
|
||||
# With uv
|
||||
uv run nextcloud-mcp-server --help
|
||||
|
||||
# With pip/venv
|
||||
nextcloud-mcp-server --help
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Using Docker
|
||||
|
||||
A pre-built Docker image is available for easy deployment.
|
||||
|
||||
### Pull the Image
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/cbcoutinho/nextcloud-mcp-server:latest
|
||||
```
|
||||
|
||||
### Run the Container
|
||||
|
||||
```bash
|
||||
# Prepare your .env file first (see Configuration guide)
|
||||
|
||||
# Run with environment file
|
||||
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
|
||||
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest
|
||||
```
|
||||
|
||||
### Docker Compose
|
||||
|
||||
Create a `docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
mcp:
|
||||
image: ghcr.io/cbcoutinho/nextcloud-mcp-server:latest
|
||||
ports:
|
||||
- "127.0.0.1:8000:8000"
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
# For persistent OAuth client storage
|
||||
- ./oauth-storage:/app/.oauth
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
Start the service:
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
After installation:
|
||||
|
||||
1. **Configure the server** - See [Configuration Guide](configuration.md)
|
||||
2. **Set up authentication** - See [OAuth Setup Guide](oauth-setup.md) or [Authentication](authentication.md)
|
||||
3. **Run the server** - See [Running the Server](running.md)
|
||||
|
||||
## Updating
|
||||
|
||||
### Update from Source
|
||||
|
||||
```bash
|
||||
cd nextcloud-mcp-server
|
||||
git pull origin master
|
||||
|
||||
# Using uv
|
||||
uv sync
|
||||
|
||||
# Or using pip
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
### Update Docker Image
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/cbcoutinho/nextcloud-mcp-server:latest
|
||||
|
||||
# If using docker-compose
|
||||
docker-compose up -d # Restart with new image
|
||||
|
||||
# If using docker run
|
||||
# Stop the old container and start a new one with the updated image
|
||||
```
|
||||
|
||||
## Troubleshooting Installation
|
||||
|
||||
### Issue: "Python version too old"
|
||||
|
||||
**Cause:** Python 3.11+ is required.
|
||||
|
||||
**Solution:**
|
||||
```bash
|
||||
# Check your Python version
|
||||
python3 --version
|
||||
|
||||
# Install Python 3.11+ from:
|
||||
# - https://www.python.org/downloads/
|
||||
# - Or use your system package manager (apt, brew, etc.)
|
||||
```
|
||||
|
||||
### Issue: "Command not found: nextcloud-mcp-server"
|
||||
|
||||
**Cause:** The package is not in your PATH.
|
||||
|
||||
**Solution:**
|
||||
```bash
|
||||
# Ensure your virtual environment is activated
|
||||
source venv/bin/activate
|
||||
|
||||
# Or use uv run
|
||||
uv run nextcloud-mcp-server --help
|
||||
|
||||
# Or use python -m
|
||||
python -m nextcloud_mcp_server.app --help
|
||||
```
|
||||
|
||||
### Issue: Docker permission denied
|
||||
|
||||
**Cause:** Docker requires elevated permissions.
|
||||
|
||||
**Solution:**
|
||||
```bash
|
||||
# Add your user to the docker group (Linux)
|
||||
sudo usermod -aG docker $USER
|
||||
# Log out and back in
|
||||
|
||||
# Or use sudo
|
||||
sudo docker run ...
|
||||
```
|
||||
|
||||
## See Also
|
||||
|
||||
- [Configuration Guide](configuration.md) - Environment variables and settings
|
||||
- [OAuth Setup Guide](oauth-setup.md) - OAuth authentication setup
|
||||
- [Running the Server](running.md) - Starting and managing the server
|
||||
@@ -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`
|
||||
@@ -0,0 +1,21 @@
|
||||
# Notes App
|
||||
|
||||
### Notes Tools
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `nc_notes_create_note` | Create a new note with title, content, and category |
|
||||
| `nc_notes_update_note` | Update an existing note by ID |
|
||||
| `nc_notes_append_content` | Append content to an existing note with a clear separator |
|
||||
| `nc_notes_delete_note` | Delete a note by ID |
|
||||
| `nc_notes_search_notes` | Search notes by title or content (keyword search) |
|
||||
| `nc_notes_semantic_search` | Search notes by meaning using vector embeddings (requires vector sync) |
|
||||
| `nc_notes_semantic_search_answer` | Search notes semantically and generate a natural language answer via MCP sampling (requires vector sync and sampling-capable MCP client) |
|
||||
|
||||
### Note Attachments
|
||||
|
||||
This server supports adding and retrieving note attachments via WebDAV. Please note the following behavior regarding attachments:
|
||||
|
||||
* When a note is deleted, its attachments remain in the system. This matches the behavior of the official Nextcloud Notes app.
|
||||
* Orphaned attachments (attachments whose parent notes have been deleted) may accumulate over time.
|
||||
* WebDAV permissions must be properly configured for attachment operations to work correctly.
|
||||
@@ -0,0 +1,323 @@
|
||||
# OAuth Architecture Comparison: MCP Server Authentication Patterns
|
||||
|
||||
This document compares three authentication architectures for the MCP server, explaining the evolution from pass-through authentication to true offline access capabilities.
|
||||
|
||||
## Pattern 1: Pass-Through Authentication (Current Implementation)
|
||||
|
||||
### Architecture
|
||||
```
|
||||
┌─────────────┐ OAuth Flow ┌─────────────┐
|
||||
│ MCP Client │◄──────────────────│ OAuth │
|
||||
│ (Claude) │ │ Provider │
|
||||
└──────┬──────┘ └─────────────┘
|
||||
│
|
||||
│ Access Token
|
||||
│ (per request)
|
||||
▼
|
||||
┌─────────────┐ ┌─────────────┐
|
||||
│ MCP Server │───────────────────►│ Nextcloud │
|
||||
│(Pass-through) │ APIs │
|
||||
└─────────────┘ └─────────────┘
|
||||
```
|
||||
|
||||
### Characteristics
|
||||
| Aspect | Description |
|
||||
|--------|-------------|
|
||||
| **Token Flow** | MCP Client → MCP Server → Nextcloud |
|
||||
| **Token Storage** | None (tokens exist only during request) |
|
||||
| **Offline Access** | ❌ Impossible |
|
||||
| **Background Workers** | ❌ Not supported |
|
||||
| **User Consent** | Single OAuth flow (client-managed) |
|
||||
| **Complexity** | Low |
|
||||
| **Security** | High (no token persistence) |
|
||||
|
||||
### How It Works
|
||||
1. MCP Client performs OAuth with provider
|
||||
2. Client includes access token in each MCP request
|
||||
3. MCP Server validates token and forwards to Nextcloud
|
||||
4. Token discarded after request completes
|
||||
|
||||
### Limitations
|
||||
- No operations possible without active MCP session
|
||||
- Background sync/indexing impossible
|
||||
- Cannot refresh tokens independently
|
||||
|
||||
---
|
||||
|
||||
## Pattern 2: Token Exchange Delegation (ADR-002 - Flawed)
|
||||
|
||||
### Architecture
|
||||
```
|
||||
┌─────────────┐ ┌─────────────┐
|
||||
│ MCP Client │────────────────────│ OAuth │
|
||||
│ (Claude) │ │ Provider │
|
||||
└──────┬──────┘ └──────┬──────┘
|
||||
│ │
|
||||
│ Access Token │ Service Account Token
|
||||
▼ ▼
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ MCP Server │
|
||||
│ ┌────────────────────────────────────┐ │
|
||||
│ │ Token Exchange (RFC 8693) │ │
|
||||
│ │ Subject: Service Account │ │
|
||||
│ │ Target: User │ │
|
||||
│ └────────────────────────────────────┘ │
|
||||
└───────────────┬─────────────────────────────┘
|
||||
│ Exchanged Token
|
||||
▼
|
||||
┌─────────────┐
|
||||
│ Nextcloud │
|
||||
│ APIs │
|
||||
└─────────────┘
|
||||
```
|
||||
|
||||
### Characteristics
|
||||
| Aspect | Description |
|
||||
|--------|-------------|
|
||||
| **Token Flow** | Service Account → Exchange → User Token |
|
||||
| **Token Storage** | None (MCP server still stateless) |
|
||||
| **Offline Access** | ❌ Still impossible (circular dependency) |
|
||||
| **Background Workers** | ❌ Requires service account (rejected) |
|
||||
| **User Consent** | Implicit through service account |
|
||||
| **Complexity** | High |
|
||||
| **Security** | ⚠️ Service accounts violate OAuth principles |
|
||||
|
||||
### Why It Fails
|
||||
1. **Circular Dependency**: To exchange tokens, you need a token to exchange
|
||||
2. **Service Account Problem**: Creates Nextcloud user identity for service
|
||||
3. **OAuth Violation**: Service acts as itself, not on behalf of users
|
||||
4. **No Bootstrap**: Still can't obtain initial tokens offline
|
||||
|
||||
### The Fatal Flaw
|
||||
```
|
||||
Q: How does background worker get tokens?
|
||||
A: Use token exchange with service account
|
||||
|
||||
Q: How does service account get authorized?
|
||||
A: Client credentials grant creates user account (violates OAuth)
|
||||
|
||||
Q: Can we use user's refresh token?
|
||||
A: MCP server never sees refresh tokens (by design)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pattern 3: Sign-in with Nextcloud (Previous ADR-004 Draft)
|
||||
|
||||
### Architecture
|
||||
```
|
||||
┌─────────────┐ ┌─────────────────┐ ┌────────────┐
|
||||
│ MCP Client ├───────────────────> │ MCP Server ├────────────────────>│ Nextcloud │
|
||||
│ (Claude) │ (MCP Protocol) │ (OAuth Client) │ (OIDC + APIs) │ (IdP) │
|
||||
└─────────────┘ └─────────────────┘ └────────────┘
|
||||
│
|
||||
┌──────▼────────┐
|
||||
│ Token Storage │
|
||||
│ (NC Tokens) │
|
||||
└───────────────┘
|
||||
```
|
||||
|
||||
### Characteristics
|
||||
| Aspect | Description |
|
||||
|--------|-------------|
|
||||
| **Token Flow** | MCP Server uses Nextcloud as identity provider |
|
||||
| **Token Storage** | ✅ Encrypted Nextcloud refresh tokens |
|
||||
| **Offline Access** | ✅ Full support |
|
||||
| **Background Workers** | ✅ Use stored refresh tokens |
|
||||
| **User Consent** | Single OAuth flow (Nextcloud only) |
|
||||
| **Complexity** | Medium |
|
||||
| **Security** | High (with token rotation) |
|
||||
|
||||
### How It Works
|
||||
1. **Initial Setup**:
|
||||
- User tries to use MCP tool
|
||||
- MCP server returns auth required
|
||||
- User authenticates with Nextcloud's OIDC endpoint
|
||||
- Nextcloud may use user_oidc to delegate to external IdP (Keycloak, etc.)
|
||||
- MCP server stores Nextcloud-issued refresh token (encrypted)
|
||||
|
||||
2. **Subsequent Requests**:
|
||||
- MCP server uses stored Nextcloud tokens
|
||||
- Refreshes automatically when expired
|
||||
- No client involvement needed
|
||||
|
||||
3. **Background Operations**:
|
||||
- Worker retrieves stored refresh token
|
||||
- Refreshes with Nextcloud directly
|
||||
- Performs operations independently
|
||||
|
||||
### Advantages
|
||||
- ✅ Single sign-on with Nextcloud
|
||||
- ✅ True offline access capability
|
||||
- ✅ OAuth-compliant with proper consent
|
||||
- ✅ Supports external IdPs via user_oidc
|
||||
- ✅ Simpler integration - only one OAuth endpoint
|
||||
|
||||
### Trade-offs
|
||||
- Authentication flows through Nextcloud
|
||||
- Nextcloud manages IdP relationships (via user_oidc)
|
||||
- MCP server only knows about Nextcloud, not the underlying IdP
|
||||
|
||||
---
|
||||
|
||||
## Pattern 4: Federated Authentication Architecture (ADR-004 - Solution)
|
||||
|
||||
### Architecture
|
||||
```
|
||||
┌─────────────┐ ┌─────────────────┐ ┌──────────────┐ ┌────────────┐
|
||||
│ MCP Client │◄──────401──────│ MCP Server │◄────OAuth──────│ Shared IdP │──Validates──►│ Nextcloud │
|
||||
│ (Claude) │ │ (OAuth Client) │ (On-Behalf) │ (Keycloak) │ Tokens │(Resource) │
|
||||
└─────────────┘ └─────────────────┘ └──────────────┘ └────────────┘
|
||||
│
|
||||
┌───────▼────────┐
|
||||
│ Token Storage │
|
||||
│ (IdP Tokens) │
|
||||
└────────────────┘
|
||||
```
|
||||
|
||||
### Characteristics
|
||||
| Aspect | Description |
|
||||
|--------|-------------|
|
||||
| **Token Flow** | Shared IdP issues tokens for Nextcloud access |
|
||||
| **Token Storage** | ✅ Encrypted IdP refresh tokens |
|
||||
| **Offline Access** | ✅ Full support |
|
||||
| **Background Workers** | ✅ Use stored IdP refresh tokens |
|
||||
| **User Consent** | Single OAuth flow (IdP manages consent) |
|
||||
| **Complexity** | Medium-High |
|
||||
| **Security** | Highest (enterprise-grade IdP) |
|
||||
|
||||
### How It Works
|
||||
1. **Initial Setup**:
|
||||
- MCP client connects, receives 401
|
||||
- Browser opens MCP server OAuth URL
|
||||
- MCP server redirects to shared IdP
|
||||
- User authenticates once to IdP
|
||||
- IdP shows consent for both identity and Nextcloud access
|
||||
- MCP server stores IdP refresh token (encrypted)
|
||||
- MCP server issues session token to client
|
||||
|
||||
2. **Subsequent Requests**:
|
||||
- MCP server validates session token
|
||||
- Uses stored IdP token for Nextcloud
|
||||
- Refreshes with IdP when expired
|
||||
- No client involvement needed
|
||||
|
||||
3. **Background Operations**:
|
||||
- Worker retrieves stored IdP refresh token
|
||||
- Gets new access token from IdP
|
||||
- Uses token to access Nextcloud
|
||||
- Performs operations independently
|
||||
|
||||
### Advantages
|
||||
- ✅ True single sign-on (SSO)
|
||||
- ✅ Enterprise-ready with SAML/LDAP support
|
||||
- ✅ OAuth-compliant with proper delegation
|
||||
- ✅ Direct IdP relationship - no intermediary
|
||||
- ✅ Flexible - can swap resource servers
|
||||
- ✅ Industry-standard federated pattern
|
||||
|
||||
### Trade-offs
|
||||
- Requires shared IdP infrastructure
|
||||
- More complex initial setup
|
||||
- Token validation overhead
|
||||
|
||||
---
|
||||
|
||||
## Comparison Matrix
|
||||
|
||||
| Feature | Pass-Through | Token Exchange | Sign-in with NC | Federated Auth |
|
||||
|---------|--------------|----------------|-----------------|----------------|
|
||||
| **Offline Access** | ❌ No | ❌ No | ✅ Yes | ✅ Yes |
|
||||
| **Background Workers** | ❌ No | ❌ No* | ✅ Yes | ✅ Yes |
|
||||
| **Token Storage** | None | None | NC refresh tokens | IdP refresh tokens |
|
||||
| **OAuth Compliance** | ✅ Full | ⚠️ Violates | ✅ Full | ✅ Full |
|
||||
| **User Consent** | Once | Implicit | Once (NC) | Once (IdP) |
|
||||
| **Implementation Complexity** | Low | High | Medium | Medium-High |
|
||||
| **Security** | High | Medium | High | Highest |
|
||||
| **Enterprise Ready** | ❌ No | ❌ No | ⚠️ Indirect | ✅ Yes |
|
||||
| **Identity Provider** | Client-managed | N/A | Nextcloud (+user_oidc) | Shared IdP |
|
||||
| **Suitable For** | Interactive only | N/A (flawed) | Small teams | Enterprise |
|
||||
|
||||
\* *Requires service accounts that violate OAuth principles*
|
||||
|
||||
---
|
||||
|
||||
## Evolution Summary
|
||||
|
||||
### Stage 1: Simple Pass-Through ✅
|
||||
- **Goal**: Basic MCP functionality
|
||||
- **Result**: Works well for interactive use
|
||||
- **Limitation**: No offline capabilities
|
||||
|
||||
### Stage 2: Attempted Delegation ❌
|
||||
- **Goal**: Enable offline access without changing architecture
|
||||
- **Result**: Circular dependencies, OAuth violations
|
||||
- **Learning**: MCP protocol constraints are fundamental
|
||||
|
||||
### Stage 3: Sign-in with Nextcloud ⚠️
|
||||
- **Goal**: True offline access with OAuth compliance
|
||||
- **Result**: MCP server uses Nextcloud as identity provider
|
||||
- **Limitation**: Tight coupling to Nextcloud, no enterprise IdP
|
||||
|
||||
### Stage 4: Federated Pattern ✅
|
||||
- **Goal**: Enterprise-ready offline access
|
||||
- **Result**: Shared IdP for both MCP server and Nextcloud
|
||||
- **Trade-off**: Additional infrastructure justified by enterprise needs
|
||||
|
||||
---
|
||||
|
||||
## Key Insights
|
||||
|
||||
1. **Pattern 3 vs Pattern 4**: Both support external IdPs, but differ in integration approach:
|
||||
- Pattern 3: MCP → Nextcloud OIDC → (user_oidc) → External IdP
|
||||
- Pattern 4: MCP → External IdP directly (Nextcloud also uses same IdP)
|
||||
- Choose Pattern 3 for Nextcloud-centric deployments, Pattern 4 for IdP-centric enterprises
|
||||
|
||||
2. **The MCP Protocol Boundary**: The MCP protocol creates a fundamental boundary between client and server token management. Attempting to breach this boundary (ADR-002) leads to architectural contradictions.
|
||||
|
||||
3. **Service Accounts Don't Solve User Problems**: Using service accounts for user operations violates OAuth's core principle of acting on behalf of users, not as a service identity.
|
||||
|
||||
4. **Double OAuth is Industry Standard**: Major platforms (Zapier, IFTTT, Microsoft Power Automate) use this pattern - the integration platform is an OAuth client that maintains its own relationships with upstream services.
|
||||
|
||||
5. **Refresh Tokens Are The Solution**: The OAuth spec designed refresh tokens specifically for offline access. Rejecting them (as ADR-002 did) means rejecting the standard solution.
|
||||
|
||||
6. **Complexity is Justified**: The additional complexity of managing OAuth flows is acceptable when offline access is a requirement. The alternative is no offline access at all.
|
||||
|
||||
---
|
||||
|
||||
## Recommendations
|
||||
|
||||
### For Simple Deployments
|
||||
Use **Pattern 1 (Pass-Through)** if:
|
||||
- Offline access not needed
|
||||
- Only interactive operations required
|
||||
- Simplicity is priority
|
||||
|
||||
### For Teams Using Nextcloud
|
||||
Use **Pattern 3 (Sign-in with Nextcloud)** if:
|
||||
- Background sync/indexing required
|
||||
- Nextcloud manages your authentication
|
||||
- Can use external IdPs via user_oidc
|
||||
- Prefer single integration point through Nextcloud
|
||||
|
||||
### For Enterprise Deployments
|
||||
Use **Pattern 4 (Federated Authentication)** if:
|
||||
- Enterprise IdP already exists (Keycloak, Okta, Azure AD)
|
||||
- Multiple resource servers beyond Nextcloud
|
||||
- Compliance requirements for centralized auth
|
||||
- Building platform for multiple organizations
|
||||
|
||||
### Never Use Pattern 2
|
||||
Token Exchange with service accounts should not be used as it:
|
||||
- Doesn't enable true offline access
|
||||
- Violates OAuth principles
|
||||
- Adds complexity without solving the problem
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- [ADR-002: Vector Database Background Sync Authentication (Deprecated)](./ADR-002-vector-sync-authentication.md)
|
||||
- [ADR-004: MCP Server as OAuth Client for Offline Access](./ADR-004-mcp-application-oauth.md)
|
||||
- [RFC 6749: OAuth 2.0 Framework](https://datatracker.ietf.org/doc/html/rfc6749)
|
||||
- [RFC 8693: OAuth 2.0 Token Exchange](https://datatracker.ietf.org/doc/html/rfc8693)
|
||||
@@ -0,0 +1,752 @@
|
||||
# OAuth Architecture
|
||||
|
||||
This document explains how OAuth2/OIDC authentication works in the Nextcloud MCP Server implementation.
|
||||
|
||||
## Overview
|
||||
|
||||
The Nextcloud MCP Server acts as an **OAuth 2.0 Resource Server**, protecting access to Nextcloud resources. It relies on Nextcloud's OIDC Identity Provider for user authentication and token validation.
|
||||
|
||||
## 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 │ │ (Resource │ │ Instance │
|
||||
│ (Claude) │ │ Server) │ │ │
|
||||
│ │ │ │ │ │
|
||||
└──────┬──────┘ └────────┬─────────┘ └────────┬────────┘
|
||||
│ │ │
|
||||
│ 1a. Connect to MCP │ │
|
||||
├─────────────────────────────────>│ │
|
||||
│ │ │
|
||||
│ 1b. Return auth settings │ │
|
||||
│<─────────────────────────────────┤ │
|
||||
│ {issuer_url, resource_url} │ │
|
||||
│ │ │
|
||||
│ 1c. PRM Discovery (RFC 9728) │ │
|
||||
├─────────────────────────────────>│ │
|
||||
│ GET /.well-known/oauth- │ │
|
||||
│ protected-resource/mcp │ │
|
||||
│ │ │
|
||||
│ 1d. PRM response (scopes!) │ │
|
||||
│<─────────────────────────────────┤ │
|
||||
│ {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
|
||||
|
||||
### 1. MCP Client (e.g., Claude Desktop, Claude Code)
|
||||
|
||||
**Capabilities**:
|
||||
- Discovers OAuth configuration via MCP server
|
||||
- Queries PRM endpoint for supported scopes
|
||||
- Initiates OAuth flow with PKCE (Proof Key for Code Exchange)
|
||||
- Stores and sends access token with each request
|
||||
- Handles scope-based tool filtering
|
||||
- Supports step-up authorization (re-auth for additional scopes)
|
||||
|
||||
**Examples**: Claude Desktop, Claude Code, MCP Inspector, custom MCP clients
|
||||
|
||||
### 2. MCP Server (Resource Server - This Implementation)
|
||||
|
||||
**Role**: OAuth 2.0 Resource Server (RFC 6749)
|
||||
|
||||
**Responsibilities**:
|
||||
|
||||
#### 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**:
|
||||
- [`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 (userinfo + introspection + JWT)
|
||||
- [`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
|
||||
|
||||
#### a) `oidc` - OIDC Identity Provider
|
||||
|
||||
**Role**: OAuth 2.0 Authorization Server + OIDC Provider
|
||||
|
||||
**Location**: Nextcloud app (`apps/oidc`)
|
||||
|
||||
**Endpoints**:
|
||||
- `/.well-known/openid-configuration` - OIDC Discovery (RFC 8414)
|
||||
- `/apps/oidc/authorize` - Authorization endpoint (OAuth 2.0 + PKCE)
|
||||
- `/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**:
|
||||
```bash
|
||||
# Enable dynamic client registration (recommended for development)
|
||||
# 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
|
||||
|
||||
**Role**: Bearer token validation middleware for Nextcloud APIs
|
||||
|
||||
**Location**: Nextcloud app (`apps/user_oidc`)
|
||||
|
||||
**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**:
|
||||
```bash
|
||||
# Enable Bearer token validation (required for OAuth mode)
|
||||
php occ config:system:set user_oidc oidc_provider_bearer_validation --value=true --type=boolean
|
||||
```
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 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
|
||||
|
||||
**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
|
||||
|
||||
The OAuth flow consists of four distinct phases (see diagram above for visual representation):
|
||||
|
||||
### Phase 0: MCP Server Startup (One-time Setup)
|
||||
|
||||
**Happens**: On MCP server first startup
|
||||
|
||||
**Steps**:
|
||||
1. **OIDC Discovery** (`GET /.well-known/openid-configuration`)
|
||||
- MCP server queries Nextcloud for OAuth endpoints
|
||||
- Validates PKCE support (requires `S256` code challenge method)
|
||||
- Extracts endpoints: authorize, token, userinfo, jwks, register
|
||||
|
||||
2. **Dynamic Client Registration** (`POST /apps/oidc/register`)
|
||||
- If no pre-configured client credentials exist
|
||||
- MCP server registers itself as OAuth client (RFC 7591)
|
||||
- Provides: client name, redirect URIs, requested scopes, token type
|
||||
- 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
|
||||
|
||||
The MCP server validates tokens using the **userinfo endpoint approach**:
|
||||
|
||||
### Why Userinfo (vs JWT Validation)?
|
||||
|
||||
**Advantages**:
|
||||
- Works with both JWT and opaque tokens
|
||||
- No need to manage JWKS rotation
|
||||
- Always up-to-date (respects token revocation)
|
||||
- Simpler implementation
|
||||
|
||||
**Caching Strategy**:
|
||||
- Validated tokens cached for 1 hour (configurable)
|
||||
- Cache keyed by token string
|
||||
- Expired tokens re-validated automatically
|
||||
|
||||
**Implementation**: See [`NextcloudTokenVerifier`](../nextcloud_mcp_server/auth/token_verifier.py)
|
||||
|
||||
## PKCE Requirement
|
||||
|
||||
The MCP server **requires** PKCE with S256 code challenge method:
|
||||
|
||||
1. Server validates OIDC discovery advertises PKCE support
|
||||
2. Checks for `code_challenge_methods_supported` field
|
||||
3. Verifies `S256` is included in supported methods
|
||||
4. Logs error if PKCE not properly advertised
|
||||
|
||||
**Why PKCE?**:
|
||||
- Required by MCP specification
|
||||
- Protects against authorization code interception
|
||||
- Essential for public clients (desktop apps, CLI tools)
|
||||
|
||||
**Implementation**: See [`validate_pkce_support()`](../nextcloud_mcp_server/app.py#L31-L93)
|
||||
|
||||
## Client Registration
|
||||
|
||||
The MCP server supports two client registration modes:
|
||||
|
||||
### Automatic Registration (Dynamic Client Registration)
|
||||
|
||||
```bash
|
||||
# No client credentials needed
|
||||
NEXTCLOUD_HOST=https://nextcloud.example.com
|
||||
```
|
||||
|
||||
**How it works**:
|
||||
1. Server checks `/.well-known/openid-configuration` for `registration_endpoint`
|
||||
2. Calls `/apps/oidc/register` to register a client on first startup
|
||||
3. Saves credentials to SQLite database
|
||||
4. Reuses these credentials on subsequent startups
|
||||
5. Re-registers only if credentials are missing or expired
|
||||
|
||||
**Best for**: Development, testing, quick deployments
|
||||
|
||||
### Pre-configured Client
|
||||
|
||||
```bash
|
||||
# Manual client registration via CLI
|
||||
php occ oidc:create --name="MCP Server" --type=confidential --redirect-uri="http://localhost:8000/oauth/callback"
|
||||
|
||||
# Configure MCP server
|
||||
NEXTCLOUD_HOST=https://nextcloud.example.com
|
||||
NEXTCLOUD_OIDC_CLIENT_ID=abc123
|
||||
NEXTCLOUD_OIDC_CLIENT_SECRET=xyz789
|
||||
```
|
||||
|
||||
**Best for**: Production, long-running deployments
|
||||
|
||||
## Per-User Client Instances
|
||||
|
||||
Each authenticated user gets their own `NextcloudClient` instance:
|
||||
|
||||
```python
|
||||
# From MCP context (contains validated token)
|
||||
client = get_client_from_context(ctx)
|
||||
|
||||
# Creates NextcloudClient with:
|
||||
# - username: from token's 'sub' or 'preferred_username' claim
|
||||
# - auth: BearerAuth(token)
|
||||
```
|
||||
|
||||
**Benefits**:
|
||||
- User-specific permissions
|
||||
- Audit trail (actions appear from correct user)
|
||||
- No shared credentials
|
||||
- Multi-user support
|
||||
|
||||
**Implementation**: See [`get_client_from_context()`](../nextcloud_mcp_server/auth/context_helper.py)
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Token Storage
|
||||
- MCP client stores access token
|
||||
- MCP server does NOT store tokens (validates per-request)
|
||||
- Token validation results cached in-memory only
|
||||
|
||||
### PKCE Protection
|
||||
- Server validates PKCE is advertised
|
||||
- Client MUST use PKCE with S256
|
||||
- Protects against authorization code interception
|
||||
|
||||
### Scopes
|
||||
- Base required scopes: `openid`, `profile`, `email`
|
||||
- App-specific scopes control access to individual Nextcloud apps
|
||||
- See [OAuth Scopes](#oauth-scopes) section for complete scope reference
|
||||
|
||||
### Token Validation
|
||||
- Every MCP request validates Bearer token
|
||||
- Cached for performance (1-hour default)
|
||||
- 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
|
||||
|
||||
#### Semantic Search (Multi-App Vector Database)
|
||||
- `semantic:read` - Query vector database, perform semantic search across all indexed Nextcloud apps (notes, calendar, deck, files, contacts)
|
||||
- `semantic:write` - Enable/disable background vector synchronization, manage indexing settings
|
||||
|
||||
> **Note**: Semantic search scopes provide access to the vector database that indexes content across **all** Nextcloud apps. Unlike app-specific scopes (e.g., `notes:read`), semantic scopes grant cross-app search capabilities powered by background vector synchronization (ADR-007).
|
||||
|
||||
### 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
|
||||
|
||||
See [Configuration Guide](configuration.md) for all OAuth environment variables:
|
||||
|
||||
| Variable | Purpose |
|
||||
|----------|---------|
|
||||
| `NEXTCLOUD_HOST` | Nextcloud instance URL |
|
||||
| `NEXTCLOUD_OIDC_CLIENT_ID` | Pre-configured client ID (optional) |
|
||||
| `NEXTCLOUD_OIDC_CLIENT_SECRET` | Pre-configured client secret (optional) |
|
||||
| `NEXTCLOUD_MCP_SERVER_URL` | MCP server URL for OAuth callbacks |
|
||||
|
||||
## Testing
|
||||
|
||||
The integration test suite includes comprehensive OAuth testing:
|
||||
|
||||
- **Automated tests** (Playwright): [`tests/client/test_oauth_playwright.py`](../tests/client/test_oauth_playwright.py)
|
||||
- **Fixtures**: [`tests/conftest.py`](../tests/conftest.py)
|
||||
|
||||
Run OAuth tests:
|
||||
```bash
|
||||
# Start OAuth-enabled MCP server
|
||||
docker-compose up --build -d mcp-oauth
|
||||
|
||||
# Run automated tests
|
||||
uv run pytest tests/client/test_oauth_playwright.py --browser firefox -v
|
||||
```
|
||||
|
||||
## See Also
|
||||
|
||||
- [OAuth Setup Guide](oauth-setup.md) - Configuration steps
|
||||
- [OAuth Quick Start](quickstart-oauth.md) - Get started quickly
|
||||
- [Upstream Status](oauth-upstream-status.md) - Required upstream patches
|
||||
- [OAuth Troubleshooting](oauth-troubleshooting.md) - Common issues
|
||||
- [RFC 6749](https://www.rfc-editor.org/rfc/rfc6749) - OAuth 2.0 Authorization Framework
|
||||
- [RFC 7636](https://www.rfc-editor.org/rfc/rfc7636) - PKCE
|
||||
- [OpenID Connect Core 1.0](https://openid.net/specs/openid-connect-core-1_0.html)
|
||||
@@ -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
|
||||
```
|
||||
@@ -0,0 +1,541 @@
|
||||
# OAuth Setup Guide
|
||||
|
||||
This guide walks you through setting up OAuth2/OIDC authentication for the Nextcloud MCP server in production.
|
||||
|
||||
> **Quick Start?** If you want a 5-minute setup for development, see [OAuth Quick Start](quickstart-oauth.md).
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [Architecture Overview](#architecture-overview)
|
||||
- [Step 1: Install Nextcloud Apps](#step-1-install-nextcloud-apps)
|
||||
- [Step 2: Configure OIDC Apps](#step-2-configure-oidc-apps)
|
||||
- [Step 3: Choose Deployment Mode](#step-3-choose-deployment-mode)
|
||||
- [Step 4: Configure MCP Server](#step-4-configure-mcp-server)
|
||||
- [Step 5: Start and Verify](#step-5-start-and-verify)
|
||||
- [Testing Authentication](#testing-authentication)
|
||||
- [Production Recommendations](#production-recommendations)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before beginning, ensure you have:
|
||||
|
||||
- **Nextcloud instance** with administrator access
|
||||
- **Nextcloud version** 28 or later
|
||||
- **SSH/CLI access** to Nextcloud server (for `occ` commands)
|
||||
- **Python 3.11+** installed on MCP server host
|
||||
- **MCP server installed** (see [Installation Guide](installation.md))
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
The OAuth implementation uses the following components:
|
||||
|
||||
```
|
||||
MCP Client ←→ MCP Server (Resource Server) ←→ Nextcloud (Authorization Server + APIs)
|
||||
OAuth Flow Bearer Token Auth
|
||||
```
|
||||
|
||||
**Key Roles**:
|
||||
- **MCP Server**: OAuth Resource Server (validates tokens, provides MCP tools)
|
||||
- **Nextcloud `oidc` app**: OAuth Authorization Server (issues tokens)
|
||||
- **Nextcloud `user_oidc` app**: Token validation middleware
|
||||
|
||||
For detailed architecture, see [OAuth Architecture](oauth-architecture.md).
|
||||
|
||||
## Step 1: Install Nextcloud Apps
|
||||
|
||||
OAuth authentication requires **two Nextcloud apps** to work together.
|
||||
|
||||
### Required Apps
|
||||
|
||||
#### 1. `oidc` - OIDC Identity Provider
|
||||
|
||||
**Purpose**: Makes Nextcloud an OAuth2/OIDC authorization server
|
||||
|
||||
**Installation**:
|
||||
1. Open Nextcloud as administrator
|
||||
2. Navigate to **Apps** → **Security**
|
||||
3. Find **"OIDC"** (full name: "OIDC Identity Provider")
|
||||
4. Click **Enable** or **Download and enable**
|
||||
|
||||
**Provides**:
|
||||
- OAuth2 authorization endpoint
|
||||
- Token endpoint
|
||||
- User info endpoint
|
||||
- JWKS endpoint
|
||||
- Dynamic client registration endpoint (optional)
|
||||
|
||||
#### 2. `user_oidc` - OpenID Connect User Backend
|
||||
|
||||
**Purpose**: Authenticates users and validates Bearer tokens
|
||||
|
||||
**Installation**:
|
||||
1. In **Apps** → **Security**
|
||||
2. Find **"OpenID Connect user backend"** (app ID: `user_oidc`)
|
||||
3. Click **Enable** or **Download and enable**
|
||||
|
||||
**Provides**:
|
||||
- Bearer token validation against OIDC provider
|
||||
- User authentication via OIDC
|
||||
- Session management for authenticated users
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **Upstream Patch Required**: The `user_oidc` app needs a patch for Bearer token support with app-specific APIs (Notes, Calendar, etc.). The patch is pending upstream review.
|
||||
>
|
||||
> **Status**: See [Upstream Status](oauth-upstream-status.md) for current PR status and workarounds.
|
||||
>
|
||||
> **Impact**: OCS APIs work without patch, but app-specific APIs require the patch.
|
||||
|
||||
### Verify Installation
|
||||
|
||||
```bash
|
||||
# Check both apps are installed and enabled
|
||||
php occ app:list | grep -E "oidc|user_oidc"
|
||||
|
||||
# Expected output:
|
||||
# - oidc: enabled
|
||||
# - user_oidc: enabled
|
||||
```
|
||||
|
||||
## Step 2: Configure OIDC Apps
|
||||
|
||||
### Configure `oidc` App (Identity Provider)
|
||||
|
||||
#### Option A: Dynamic Client Registration (Development)
|
||||
|
||||
**Best for**: Development, testing, auto-registration
|
||||
|
||||
1. Navigate to **Settings** → **OIDC** (Administration settings)
|
||||
2. Enable **"Allow dynamic client registration"**
|
||||
3. (Optional) Configure client expiration:
|
||||
```bash
|
||||
# Default: 3600 seconds (1 hour)
|
||||
php occ config:app:set oidc expire_time --value "86400" # 24 hours
|
||||
```
|
||||
|
||||
#### Option B: Pre-configured Clients (Production)
|
||||
|
||||
**Best for**: Production, long-running deployments
|
||||
|
||||
Skip the dynamic registration setting. You'll manually register clients via CLI in Step 3.
|
||||
|
||||
### Configure `user_oidc` App (Token Validation)
|
||||
|
||||
**Required**: Enable Bearer token validation:
|
||||
|
||||
```bash
|
||||
# SSH into Nextcloud server
|
||||
php occ config:system:set user_oidc oidc_provider_bearer_validation --value=true --type=boolean
|
||||
```
|
||||
|
||||
This tells `user_oidc` to validate Bearer tokens against Nextcloud's OIDC Identity Provider.
|
||||
|
||||
### Verify OIDC Discovery
|
||||
|
||||
Test that OIDC discovery endpoint is accessible:
|
||||
|
||||
```bash
|
||||
curl https://your.nextcloud.instance.com/.well-known/openid-configuration | jq
|
||||
```
|
||||
|
||||
Expected response:
|
||||
```json
|
||||
{
|
||||
"issuer": "https://your.nextcloud.instance.com",
|
||||
"authorization_endpoint": "https://your.nextcloud.instance.com/apps/oidc/authorize",
|
||||
"token_endpoint": "https://your.nextcloud.instance.com/apps/oidc/token",
|
||||
"userinfo_endpoint": "https://your.nextcloud.instance.com/apps/oidc/userinfo",
|
||||
"jwks_uri": "https://your.nextcloud.instance.com/apps/oidc/jwks",
|
||||
"registration_endpoint": "https://your.nextcloud.instance.com/apps/oidc/register",
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
### PKCE Support
|
||||
|
||||
The MCP server **requires PKCE** (Proof Key for Code Exchange) with S256 code challenge method.
|
||||
|
||||
**Validation**: The MCP server automatically validates PKCE support at startup by checking the discovery response for `code_challenge_methods_supported`.
|
||||
|
||||
**Note**: If PKCE is not advertised in discovery metadata, the server logs a warning but continues (PKCE still works, it's just not advertised). See [Upstream Status](oauth-upstream-status.md) for tracking.
|
||||
|
||||
## Step 3: Choose Deployment Mode
|
||||
|
||||
You have two options for managing OAuth clients:
|
||||
|
||||
### Mode A: Automatic Registration (Dynamic Client Registration)
|
||||
|
||||
**Best for**: Development, testing, quick deployments
|
||||
|
||||
**How it works**:
|
||||
- MCP server automatically registers an OAuth client on first startup
|
||||
- Uses Nextcloud's dynamic client registration endpoint
|
||||
- Saves credentials to SQLite database
|
||||
- Reuses stored credentials on subsequent restarts
|
||||
- Re-registers automatically if credentials expire
|
||||
|
||||
**Pros**:
|
||||
- Zero configuration required
|
||||
- Quick setup
|
||||
- Automatic credential management
|
||||
|
||||
**Cons**:
|
||||
- Clients expire (default: 1 hour, configurable)
|
||||
- Must have dynamic client registration enabled on Nextcloud
|
||||
|
||||
**Configuration**: Skip to [Step 4](#step-4-configure-mcp-server) with minimal config.
|
||||
|
||||
---
|
||||
|
||||
### Mode B: Pre-configured Client (Production)
|
||||
|
||||
**Best for**: Production, long-running deployments, stable environments
|
||||
|
||||
**How it works**:
|
||||
- You manually register an OAuth client via Nextcloud CLI
|
||||
- Provide client credentials to MCP server via environment variables
|
||||
- Credentials don't expire
|
||||
|
||||
**Pros**:
|
||||
- Credentials don't expire
|
||||
- Stable for production
|
||||
- More control over client configuration
|
||||
- Better for audit trails
|
||||
|
||||
**Cons**:
|
||||
- Requires manual setup
|
||||
- Needs SSH/CLI access to Nextcloud server
|
||||
|
||||
**Setup**: Register a client via CLI:
|
||||
|
||||
```bash
|
||||
# SSH into Nextcloud server
|
||||
php occ oidc:create \
|
||||
--name="Nextcloud MCP Server" \
|
||||
--type=confidential \
|
||||
--redirect-uri="http://localhost:8000/oauth/callback"
|
||||
|
||||
# Example output:
|
||||
# Client ID: abc123xyz789
|
||||
# Client Secret: secret456def012
|
||||
|
||||
# Save these credentials for Step 4
|
||||
```
|
||||
|
||||
**Important**: Adjust `--redirect-uri` to match your MCP server URL:
|
||||
- Local: `http://localhost:8000/oauth/callback`
|
||||
- Remote: `http://your-server:8000/oauth/callback`
|
||||
- Custom port: `http://your-server:PORT/oauth/callback`
|
||||
|
||||
The redirect URI **must** be:
|
||||
```
|
||||
{NEXTCLOUD_MCP_SERVER_URL}/oauth/callback
|
||||
```
|
||||
|
||||
## Step 4: Configure MCP Server
|
||||
|
||||
Create or update your `.env` file with OAuth configuration.
|
||||
|
||||
### For Mode A (Automatic Registration)
|
||||
|
||||
```bash
|
||||
# Copy sample if needed
|
||||
cp env.sample .env
|
||||
|
||||
# Edit .env
|
||||
cat > .env << 'EOF'
|
||||
# Nextcloud Instance
|
||||
NEXTCLOUD_HOST=https://your.nextcloud.instance.com
|
||||
|
||||
# Leave EMPTY for OAuth mode (do not set USERNAME/PASSWORD)
|
||||
NEXTCLOUD_USERNAME=
|
||||
NEXTCLOUD_PASSWORD=
|
||||
|
||||
# Optional: MCP server URL (for OAuth callbacks)
|
||||
NEXTCLOUD_MCP_SERVER_URL=http://localhost:8000
|
||||
EOF
|
||||
```
|
||||
|
||||
### For Mode B (Pre-configured Client)
|
||||
|
||||
```bash
|
||||
# Copy sample if needed
|
||||
cp env.sample .env
|
||||
|
||||
# Edit .env
|
||||
cat > .env << 'EOF'
|
||||
# Nextcloud Instance
|
||||
NEXTCLOUD_HOST=https://your.nextcloud.instance.com
|
||||
|
||||
# OAuth Client Credentials (from Step 3)
|
||||
NEXTCLOUD_OIDC_CLIENT_ID=abc123xyz789
|
||||
NEXTCLOUD_OIDC_CLIENT_SECRET=secret456def012
|
||||
|
||||
# MCP server URL (must match redirect URI)
|
||||
NEXTCLOUD_MCP_SERVER_URL=http://localhost:8000
|
||||
|
||||
# Leave EMPTY for OAuth mode
|
||||
NEXTCLOUD_USERNAME=
|
||||
NEXTCLOUD_PASSWORD=
|
||||
EOF
|
||||
```
|
||||
|
||||
### Environment Variables Reference
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
|----------|----------|---------|-------------|
|
||||
| `NEXTCLOUD_HOST` | ✅ Yes | - | Full URL of Nextcloud instance |
|
||||
| `NEXTCLOUD_OIDC_CLIENT_ID` | ⚠️ Mode B only | - | OAuth client ID |
|
||||
| `NEXTCLOUD_OIDC_CLIENT_SECRET` | ⚠️ Mode B only | - | OAuth client secret |
|
||||
| `NEXTCLOUD_MCP_SERVER_URL` | ⚠️ Optional | `http://localhost:8000` | MCP server URL for callbacks |
|
||||
| `NEXTCLOUD_USERNAME` | ❌ Must be empty | - | Leave empty for OAuth |
|
||||
| `NEXTCLOUD_PASSWORD` | ❌ Must be empty | - | Leave empty for OAuth |
|
||||
|
||||
See [Configuration Guide](configuration.md) for all options.
|
||||
|
||||
## Step 5: Start and Verify
|
||||
|
||||
### Load Environment Variables
|
||||
|
||||
```bash
|
||||
# Load from .env file
|
||||
export $(grep -v '^#' .env | xargs)
|
||||
|
||||
# Verify key variables are set
|
||||
echo "NEXTCLOUD_HOST: $NEXTCLOUD_HOST"
|
||||
echo "NEXTCLOUD_MCP_SERVER_URL: $NEXTCLOUD_MCP_SERVER_URL"
|
||||
```
|
||||
|
||||
### Start MCP Server
|
||||
|
||||
```bash
|
||||
# Start with OAuth mode
|
||||
uv run nextcloud-mcp-server --oauth
|
||||
|
||||
# Or with custom options
|
||||
uv run nextcloud-mcp-server --oauth --port 8000 --log-level info
|
||||
```
|
||||
|
||||
### Verify Startup
|
||||
|
||||
Look for these success messages:
|
||||
|
||||
**For Mode A (Auto-registration)**:
|
||||
```
|
||||
INFO OAuth mode detected (NEXTCLOUD_USERNAME/PASSWORD not set)
|
||||
INFO Configuring MCP server for OAuth mode
|
||||
INFO Performing OIDC discovery: https://your.nextcloud.instance.com/.well-known/openid-configuration
|
||||
✓ PKCE support validated: ['S256']
|
||||
INFO OIDC discovery successful
|
||||
INFO Attempting dynamic client registration...
|
||||
INFO Dynamic client registration successful
|
||||
INFO OAuth client ready: <client-id>...
|
||||
INFO Saved OAuth client credentials to SQLite database
|
||||
INFO OAuth initialization complete
|
||||
INFO MCP server ready at http://127.0.0.1:8000
|
||||
```
|
||||
|
||||
**For Mode B (Pre-configured)**:
|
||||
```
|
||||
INFO OAuth mode detected (NEXTCLOUD_USERNAME/PASSWORD not set)
|
||||
INFO Configuring MCP server for OAuth mode
|
||||
INFO Performing OIDC discovery: https://your.nextcloud.instance.com/.well-known/openid-configuration
|
||||
✓ PKCE support validated: ['S256']
|
||||
INFO OIDC discovery successful
|
||||
INFO Using pre-configured OAuth client: abc123xyz789
|
||||
INFO OAuth initialization complete
|
||||
INFO MCP server ready at http://127.0.0.1:8000
|
||||
```
|
||||
|
||||
### Common Startup Issues
|
||||
|
||||
| Issue | Solution |
|
||||
|-------|----------|
|
||||
| "OAuth mode requires NEXTCLOUD_HOST" | Set `NEXTCLOUD_HOST` in `.env` |
|
||||
| "OIDC discovery failed" | Verify Nextcloud URL and network connectivity |
|
||||
| "Dynamic registration failed" | Enable dynamic registration in OIDC app settings |
|
||||
| "PKCE validation failed" | See [Upstream Status](oauth-upstream-status.md) |
|
||||
|
||||
See [OAuth Troubleshooting](oauth-troubleshooting.md) for detailed solutions.
|
||||
|
||||
## Testing Authentication
|
||||
|
||||
### Test with MCP Inspector
|
||||
|
||||
The MCP Inspector provides a web UI for testing:
|
||||
|
||||
```bash
|
||||
# In a new terminal
|
||||
uv run mcp dev
|
||||
|
||||
# Opens browser at http://localhost:6272
|
||||
```
|
||||
|
||||
In the MCP Inspector UI:
|
||||
1. Enter server URL: `http://localhost:8000/mcp`
|
||||
2. Click **Connect**
|
||||
3. Complete OAuth flow in browser popup:
|
||||
- Login to Nextcloud
|
||||
- Authorize MCP server access
|
||||
- Redirected back to MCP Inspector
|
||||
4. Test tools:
|
||||
- Try `nc_notes_create_note`
|
||||
- Try `nc_notes_search_notes`
|
||||
- Try `nc_calendar_list_events`
|
||||
|
||||
### Test from Command Line
|
||||
|
||||
```bash
|
||||
# Get an OAuth token (you'll need to implement client flow or extract from browser)
|
||||
TOKEN="your_access_token_here"
|
||||
|
||||
# Test OCS API (should work)
|
||||
curl -H "Authorization: Bearer $TOKEN" \
|
||||
"$NEXTCLOUD_HOST/ocs/v2.php/cloud/capabilities?format=json" \
|
||||
-H "OCS-APIRequest: true"
|
||||
|
||||
# Test Notes API (requires upstream patch)
|
||||
curl -H "Authorization: Bearer $TOKEN" \
|
||||
"$NEXTCLOUD_HOST/apps/notes/api/v1/notes"
|
||||
```
|
||||
|
||||
### Verify Token Validation
|
||||
|
||||
Check MCP server logs for token validation:
|
||||
|
||||
```bash
|
||||
# Start server with debug logging
|
||||
uv run nextcloud-mcp-server --oauth --log-level debug
|
||||
|
||||
# Look for:
|
||||
# DEBUG Token validation via userinfo endpoint
|
||||
# DEBUG Token validated successfully for user: username
|
||||
```
|
||||
|
||||
## Production Recommendations
|
||||
|
||||
### Security Best Practices
|
||||
|
||||
1. **Use Pre-configured Clients** (Mode B)
|
||||
- More stable
|
||||
- Better audit trails
|
||||
- No expiration issues
|
||||
|
||||
2. **Secure Credential Storage**
|
||||
```bash
|
||||
# Set restrictive permissions on environment file
|
||||
chmod 600 .env
|
||||
# Database permissions are handled automatically
|
||||
```
|
||||
|
||||
3. **Use HTTPS for MCP Server**
|
||||
- Especially important for remote access
|
||||
- Use reverse proxy (nginx, Apache) with SSL
|
||||
|
||||
4. **Restrict Redirect URIs**
|
||||
- Only register necessary redirect URIs
|
||||
- Use specific URLs (not wildcards)
|
||||
|
||||
### Deployment Considerations
|
||||
|
||||
1. **MCP Server URL**
|
||||
- Must be accessible to OAuth clients
|
||||
- Must match redirect URI registered with Nextcloud
|
||||
- For Docker: expose port and use correct host
|
||||
|
||||
2. **Network Configuration**
|
||||
- MCP server must reach Nextcloud (OIDC endpoints)
|
||||
- OAuth clients must reach MCP server (callbacks)
|
||||
- OAuth clients must reach Nextcloud (authorization flow)
|
||||
|
||||
3. **Process Management**
|
||||
- Use systemd, supervisord, or Docker for MCP server
|
||||
- Ensure automatic restart on failure
|
||||
- Monitor logs for OAuth errors
|
||||
|
||||
### Example Production Configs
|
||||
|
||||
#### Docker Compose
|
||||
|
||||
```yaml
|
||||
version: '3'
|
||||
services:
|
||||
nextcloud-mcp:
|
||||
image: ghcr.io/cbcoutinho/nextcloud-mcp-server:latest
|
||||
ports:
|
||||
- "127.0.0.1:8000:8000"
|
||||
environment:
|
||||
NEXTCLOUD_HOST: https://your.nextcloud.instance.com
|
||||
NEXTCLOUD_OIDC_CLIENT_ID: ${NEXTCLOUD_OIDC_CLIENT_ID}
|
||||
NEXTCLOUD_OIDC_CLIENT_SECRET: ${NEXTCLOUD_OIDC_CLIENT_SECRET}
|
||||
NEXTCLOUD_MCP_SERVER_URL: http://your-server:8000
|
||||
volumes:
|
||||
- ./data:/app/data # For SQLite database persistence
|
||||
command: ["--oauth", "--transport", "streamable-http"]
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
#### Systemd Service
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Nextcloud MCP Server (OAuth)
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=mcp
|
||||
WorkingDirectory=/opt/nextcloud-mcp-server
|
||||
Environment="NEXTCLOUD_HOST=https://your.nextcloud.instance.com"
|
||||
Environment="NEXTCLOUD_OIDC_CLIENT_ID=abc123xyz789"
|
||||
Environment="NEXTCLOUD_OIDC_CLIENT_SECRET=secret456def012"
|
||||
Environment="NEXTCLOUD_MCP_SERVER_URL=http://your-server:8000"
|
||||
ExecStart=/opt/nextcloud-mcp-server/.venv/bin/nextcloud-mcp-server --oauth
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
### Monitoring and Maintenance
|
||||
|
||||
1. **Log Monitoring**
|
||||
```bash
|
||||
# Watch for OAuth errors
|
||||
tail -f /var/log/nextcloud-mcp/server.log | grep -i "oauth\|token"
|
||||
```
|
||||
|
||||
2. **Token Expiration** (Mode A only)
|
||||
- Monitor for "Stored client has expired" messages
|
||||
- Consider increasing expiration or switching to Mode B
|
||||
|
||||
3. **Upstream Patches**
|
||||
- Subscribe to [Upstream Status](oauth-upstream-status.md)
|
||||
- Plan to update when patches are merged
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
For OAuth-specific issues, see [OAuth Troubleshooting](oauth-troubleshooting.md).
|
||||
|
||||
Common issues:
|
||||
- [OIDC discovery failed](oauth-troubleshooting.md#oidc-discovery-failed)
|
||||
- [Bearer token auth fails](oauth-troubleshooting.md#bearer-token-authentication-fails)
|
||||
- [Client expired](oauth-troubleshooting.md#client-expired)
|
||||
- [PKCE errors](oauth-troubleshooting.md#pkce-not-advertised)
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [OAuth Architecture](oauth-architecture.md) - Understand how OAuth works
|
||||
- [OAuth Troubleshooting](oauth-troubleshooting.md) - Solve common issues
|
||||
- [Upstream Status](oauth-upstream-status.md) - Track required patches
|
||||
- [Configuration](configuration.md) - All environment variables
|
||||
- [Running the Server](running.md) - Additional server options
|
||||
|
||||
## See Also
|
||||
|
||||
- [Authentication Overview](authentication.md) - OAuth vs BasicAuth comparison
|
||||
- [Quick Start Guide](quickstart-oauth.md) - 5-minute setup for development
|
||||
- [MCP Specification](https://spec.modelcontextprotocol.io/) - MCP protocol details
|
||||
- [RFC 6749](https://www.rfc-editor.org/rfc/rfc6749) - OAuth 2.0 Framework
|
||||
- [RFC 7636](https://www.rfc-editor.org/rfc/rfc7636) - PKCE Extension
|
||||
@@ -0,0 +1,642 @@
|
||||
# OAuth Troubleshooting
|
||||
|
||||
This guide covers OAuth-specific issues and solutions for the Nextcloud MCP server.
|
||||
|
||||
For general troubleshooting, see [Troubleshooting Guide](troubleshooting.md).
|
||||
|
||||
## Quick Diagnosis
|
||||
|
||||
Start here to identify your issue:
|
||||
|
||||
| Symptom | Likely Cause | Quick Fix Link |
|
||||
|---------|--------------|----------------|
|
||||
| "OAuth mode requires NEXTCLOUD_HOST" | Missing environment variable | [Missing NEXTCLOUD_HOST](#missing-nextcloud_host) |
|
||||
| "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) |
|
||||
| "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) |
|
||||
| "OIDC discovery failed" | Network or configuration issue | [Discovery Failed](#oidc-discovery-failed) |
|
||||
| "Database error" on OAuth client storage | Database permissions issue | [Database Permission Error](#database-permission-error) |
|
||||
|
||||
## Configuration Issues
|
||||
|
||||
### Missing NEXTCLOUD_HOST
|
||||
|
||||
**Error Message**:
|
||||
```
|
||||
OAuth mode requires NEXTCLOUD_HOST environment variable
|
||||
```
|
||||
|
||||
**Cause**: The `NEXTCLOUD_HOST` environment variable is not set or empty.
|
||||
|
||||
**Solution**:
|
||||
|
||||
1. Add to your `.env` file:
|
||||
```bash
|
||||
NEXTCLOUD_HOST=https://your.nextcloud.instance.com
|
||||
```
|
||||
|
||||
2. Reload environment variables:
|
||||
```bash
|
||||
export $(grep -v '^#' .env | xargs)
|
||||
```
|
||||
|
||||
3. Verify it's set:
|
||||
```bash
|
||||
echo $NEXTCLOUD_HOST
|
||||
# Should output: https://your.nextcloud.instance.com
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Missing or Misconfigured OIDC Apps
|
||||
|
||||
**Error Message**:
|
||||
```
|
||||
OAuth mode requires either client credentials OR dynamic client registration
|
||||
```
|
||||
|
||||
**Cause**: The required Nextcloud OIDC apps are either:
|
||||
- Not installed
|
||||
- Not enabled
|
||||
- Missing configuration
|
||||
|
||||
**Solution**:
|
||||
|
||||
**Step 1**: Verify both apps are installed:
|
||||
|
||||
```bash
|
||||
# Check installed apps
|
||||
php occ app:list | grep -E "oidc|user_oidc"
|
||||
|
||||
# Should show:
|
||||
# - oidc: enabled
|
||||
# - user_oidc: enabled
|
||||
```
|
||||
|
||||
If not installed:
|
||||
1. Open Nextcloud as administrator
|
||||
2. Navigate to **Apps** → **Security**
|
||||
3. Install **"OIDC"** (OIDC Identity Provider)
|
||||
4. Install **"OpenID Connect user backend"** (user_oidc)
|
||||
5. Enable both apps
|
||||
|
||||
**Step 2**: Enable dynamic client registration:
|
||||
|
||||
1. Go to **Settings** → **OIDC** (Administration)
|
||||
2. Enable **"Allow dynamic client registration"**
|
||||
|
||||
**Step 3**: Configure Bearer token validation:
|
||||
|
||||
```bash
|
||||
php occ config:system:set user_oidc oidc_provider_bearer_validation --value=true --type=boolean
|
||||
```
|
||||
|
||||
**Step 4**: Verify discovery endpoint:
|
||||
|
||||
```bash
|
||||
curl https://your.nextcloud.instance.com/.well-known/openid-configuration | jq '.registration_endpoint'
|
||||
|
||||
# Should output:
|
||||
# "https://your.nextcloud.instance.com/apps/oidc/register"
|
||||
```
|
||||
|
||||
**Alternative**: Use pre-configured client credentials:
|
||||
|
||||
```bash
|
||||
# Register client via CLI
|
||||
php occ oidc:create \
|
||||
--name="Nextcloud MCP Server" \
|
||||
--type=confidential \
|
||||
--redirect-uri="http://localhost:8000/oauth/callback"
|
||||
|
||||
# Add to .env
|
||||
echo "NEXTCLOUD_OIDC_CLIENT_ID=<client-id>" >> .env
|
||||
echo "NEXTCLOUD_OIDC_CLIENT_SECRET=<client-secret>" >> .env
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Client Expired
|
||||
|
||||
**Error Message**:
|
||||
```
|
||||
Stored client has expired
|
||||
```
|
||||
|
||||
**Cause**: Dynamically registered OAuth clients expire (default: 1 hour).
|
||||
|
||||
**Solution**:
|
||||
|
||||
**Option 1: Restart the Server** (Automatic re-registration)
|
||||
|
||||
```bash
|
||||
uv run nextcloud-mcp-server --oauth
|
||||
# Server automatically re-registers if credentials expired
|
||||
```
|
||||
|
||||
**Option 2: Use Pre-configured Credentials** (Recommended for production)
|
||||
|
||||
```bash
|
||||
# Register permanent client via Nextcloud CLI
|
||||
php occ oidc:create \
|
||||
--name="Nextcloud MCP Server" \
|
||||
--type=confidential \
|
||||
--redirect-uri="http://localhost:8000/oauth/callback"
|
||||
|
||||
# Add to .env
|
||||
NEXTCLOUD_OIDC_CLIENT_ID=<from-output>
|
||||
NEXTCLOUD_OIDC_CLIENT_SECRET=<from-output>
|
||||
```
|
||||
|
||||
Pre-configured clients don't expire.
|
||||
|
||||
**Option 3: Increase Expiration Time**
|
||||
|
||||
```bash
|
||||
# Via Nextcloud CLI (default: 3600 seconds = 1 hour)
|
||||
php occ config:app:set oidc expire_time --value "86400" # 24 hours
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Database Permission Error
|
||||
|
||||
**Error Message**:
|
||||
```
|
||||
Permission denied when accessing SQLite database
|
||||
Database is locked
|
||||
```
|
||||
|
||||
**Cause**: The server cannot access the SQLite database file.
|
||||
|
||||
**Solution**:
|
||||
|
||||
```bash
|
||||
# Check database directory permissions
|
||||
ls -la /app/data/
|
||||
|
||||
# Ensure directory is writable
|
||||
chmod 755 /app/data
|
||||
|
||||
# Check if database file exists and has correct permissions
|
||||
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 Docker deployments**:
|
||||
Ensure the data directory is properly mounted as a volume:
|
||||
```yaml
|
||||
volumes:
|
||||
- ./data:/app/data # Persistent storage for SQLite database
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Discovery and Connection Issues
|
||||
|
||||
### OIDC Discovery Failed
|
||||
|
||||
**Error Message**:
|
||||
```
|
||||
OIDC discovery failed
|
||||
Cannot reach OIDC discovery endpoint
|
||||
```
|
||||
|
||||
**Cause**: The server cannot reach the Nextcloud OIDC discovery endpoint.
|
||||
|
||||
**Solution**:
|
||||
|
||||
**Step 1**: Verify Nextcloud URL is correct:
|
||||
|
||||
```bash
|
||||
echo $NEXTCLOUD_HOST
|
||||
# Should be full URL: https://your.nextcloud.instance.com
|
||||
```
|
||||
|
||||
**Step 2**: Test discovery endpoint manually:
|
||||
|
||||
```bash
|
||||
curl https://your.nextcloud.instance.com/.well-known/openid-configuration
|
||||
|
||||
# Should return JSON with OIDC configuration
|
||||
# {
|
||||
# "issuer": "https://your.nextcloud.instance.com",
|
||||
# "authorization_endpoint": "https://your.nextcloud.instance.com/apps/oidc/authorize",
|
||||
# ...
|
||||
# }
|
||||
```
|
||||
|
||||
**Step 3**: Check network connectivity:
|
||||
|
||||
```bash
|
||||
# Test basic connectivity
|
||||
ping your.nextcloud.instance.com
|
||||
|
||||
# Test HTTPS
|
||||
curl -I https://your.nextcloud.instance.com
|
||||
```
|
||||
|
||||
**Step 4**: Verify both OIDC apps are enabled:
|
||||
|
||||
```bash
|
||||
php occ app:list | grep -E "oidc|user_oidc"
|
||||
```
|
||||
|
||||
**Step 5**: Check firewall rules (if using Docker):
|
||||
|
||||
```bash
|
||||
# Check if MCP server can reach Nextcloud
|
||||
docker exec nextcloud-mcp-server curl https://your.nextcloud.instance.com/.well-known/openid-configuration
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Authentication Issues
|
||||
|
||||
### Bearer Token Authentication Fails
|
||||
|
||||
**Error Message**:
|
||||
```
|
||||
HTTP 401 Unauthorized when calling Nextcloud APIs
|
||||
```
|
||||
|
||||
**Symptoms**:
|
||||
- OCS APIs work (`/ocs/v2.php/cloud/capabilities`)
|
||||
- App APIs fail (`/apps/notes/api/`, `/apps/calendar/`, etc.)
|
||||
|
||||
**Cause**: The `user_oidc` app's CORS middleware interferes with Bearer token authentication for non-OCS endpoints.
|
||||
|
||||
**Solution**: Apply the Bearer token patch to `user_oidc` app.
|
||||
|
||||
See [Upstream Status](oauth-upstream-status.md#1-bearer-token-support-for-non-ocs-endpoints) for details.
|
||||
|
||||
**Quick Patch**:
|
||||
|
||||
```bash
|
||||
# SSH into Nextcloud server
|
||||
cd /path/to/nextcloud/apps/user_oidc
|
||||
|
||||
# Edit lib/User/Backend.php
|
||||
# Add this line before each return statement in getCurrentUserId() method:
|
||||
$this->session->set('app_api', true);
|
||||
|
||||
# Lines to modify: ~243, ~310, ~315, ~337
|
||||
```
|
||||
|
||||
**Test the fix**:
|
||||
|
||||
```bash
|
||||
# Get an OAuth token (from MCP client or test)
|
||||
TOKEN="your_access_token"
|
||||
|
||||
# Test Notes API
|
||||
curl -H "Authorization: Bearer $TOKEN" \
|
||||
https://your.nextcloud.instance.com/apps/notes/api/v1/notes
|
||||
|
||||
# Should return notes JSON (not 401)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### PKCE Not Advertised
|
||||
|
||||
**Error Message**:
|
||||
```
|
||||
ERROR: OIDC CONFIGURATION ERROR - Missing PKCE Support Advertisement
|
||||
⚠️ MCP clients (like Claude Code) WILL REJECT this provider!
|
||||
```
|
||||
|
||||
**Cause**: The OIDC discovery endpoint doesn't include `code_challenge_methods_supported` field.
|
||||
|
||||
**Impact**:
|
||||
- Some MCP clients may refuse to connect
|
||||
- Standards compliance issue (RFC 8414)
|
||||
- **Functionality still works** (PKCE is accepted, just not advertised)
|
||||
|
||||
**Solution**:
|
||||
|
||||
**Short-term**: The MCP server logs a warning but continues. OAuth flow still works.
|
||||
|
||||
**Long-term**: Update the `oidc` app to advertise PKCE support.
|
||||
|
||||
See [Upstream Status](oauth-upstream-status.md#2-pkce-support-advertisement-in-discovery) for tracking.
|
||||
|
||||
**Verify**:
|
||||
|
||||
```bash
|
||||
curl https://your.nextcloud.instance.com/.well-known/openid-configuration | jq '.code_challenge_methods_supported'
|
||||
|
||||
# Should return:
|
||||
# ["S256", "plain"]
|
||||
|
||||
# If null, PKCE isn't advertised (but still works)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Runtime Issues
|
||||
|
||||
### MCP Client Can't Authenticate
|
||||
|
||||
**Symptoms**:
|
||||
- Client connects but OAuth flow fails
|
||||
- Authorization redirects don't work
|
||||
- Token exchange fails
|
||||
|
||||
**Diagnosis**:
|
||||
|
||||
**Step 1**: Verify OAuth is configured correctly:
|
||||
|
||||
```bash
|
||||
uv run nextcloud-mcp-server --oauth --log-level debug
|
||||
```
|
||||
|
||||
Look for:
|
||||
```
|
||||
INFO OAuth initialization complete
|
||||
INFO MCP server ready at http://127.0.0.1:8000
|
||||
```
|
||||
|
||||
**Step 2**: Check OIDC discovery:
|
||||
|
||||
```bash
|
||||
curl https://your.nextcloud.instance.com/.well-known/openid-configuration
|
||||
```
|
||||
|
||||
**Step 3**: Verify MCP server URL matches client expectations:
|
||||
|
||||
```bash
|
||||
echo $NEXTCLOUD_MCP_SERVER_URL
|
||||
# Should match the URL clients use to connect
|
||||
# Default: http://localhost:8000
|
||||
```
|
||||
|
||||
If MCP server is on a different host/port, update:
|
||||
```bash
|
||||
NEXTCLOUD_MCP_SERVER_URL=http://actual-host:actual-port
|
||||
```
|
||||
|
||||
**Step 4**: Check redirect URI configuration:
|
||||
|
||||
For pre-configured clients, ensure redirect URI matches:
|
||||
```bash
|
||||
# Client redirect URI should be:
|
||||
http://your-mcp-server-url/oauth/callback
|
||||
|
||||
# Example for local server:
|
||||
http://localhost:8000/oauth/callback
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Tools Return 401 Errors
|
||||
|
||||
**Symptoms**:
|
||||
- OAuth flow completes successfully
|
||||
- Token is valid
|
||||
- MCP tools return 401 errors
|
||||
|
||||
**Cause**: Bearer token not working with Nextcloud APIs.
|
||||
|
||||
**Solution**: See [Bearer Token Authentication Fails](#bearer-token-authentication-fails) above.
|
||||
|
||||
---
|
||||
|
||||
### 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
|
||||
|
||||
### From BasicAuth to OAuth
|
||||
|
||||
```bash
|
||||
# 1. Remove or comment out USERNAME/PASSWORD in .env
|
||||
sed -i 's/^NEXTCLOUD_USERNAME/#NEXTCLOUD_USERNAME/' .env
|
||||
sed -i 's/^NEXTCLOUD_PASSWORD/#NEXTCLOUD_PASSWORD/' .env
|
||||
|
||||
# 2. Ensure NEXTCLOUD_HOST is set
|
||||
grep NEXTCLOUD_HOST .env
|
||||
|
||||
# 3. Restart server with OAuth
|
||||
export $(grep -v '^#' .env | xargs)
|
||||
uv run nextcloud-mcp-server --oauth
|
||||
```
|
||||
|
||||
### From OAuth to BasicAuth
|
||||
|
||||
```bash
|
||||
# 1. Add USERNAME/PASSWORD to .env
|
||||
echo "NEXTCLOUD_USERNAME=your-username" >> .env
|
||||
echo "NEXTCLOUD_PASSWORD=your-password" >> .env
|
||||
|
||||
# 2. Restart server (BasicAuth auto-detected)
|
||||
export $(grep -v '^#' .env | xargs)
|
||||
uv run nextcloud-mcp-server --no-oauth
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Advanced Debugging
|
||||
|
||||
### Enable Debug Logging
|
||||
|
||||
```bash
|
||||
uv run nextcloud-mcp-server --oauth --log-level debug
|
||||
```
|
||||
|
||||
Look for:
|
||||
- OIDC discovery details
|
||||
- Client registration attempts
|
||||
- Token validation logs
|
||||
- API request/response details
|
||||
|
||||
### Test Discovery Endpoint
|
||||
|
||||
```bash
|
||||
# Full discovery response
|
||||
curl https://your.nextcloud.instance.com/.well-known/openid-configuration | jq
|
||||
|
||||
# Check specific fields
|
||||
curl https://your.nextcloud.instance.com/.well-known/openid-configuration | jq '{
|
||||
issuer,
|
||||
authorization_endpoint,
|
||||
token_endpoint,
|
||||
userinfo_endpoint,
|
||||
registration_endpoint,
|
||||
code_challenge_methods_supported
|
||||
}'
|
||||
```
|
||||
|
||||
### Test Token Validation
|
||||
|
||||
```bash
|
||||
# Get userinfo with token
|
||||
curl -H "Authorization: Bearer $TOKEN" \
|
||||
https://your.nextcloud.instance.com/apps/oidc/userinfo
|
||||
|
||||
# Should return user info:
|
||||
# {
|
||||
# "sub": "username",
|
||||
# "preferred_username": "username",
|
||||
# "name": "Display Name",
|
||||
# ...
|
||||
# }
|
||||
```
|
||||
|
||||
### Test Nextcloud API Access
|
||||
|
||||
```bash
|
||||
# Test OCS API (should work)
|
||||
curl -H "Authorization: Bearer $TOKEN" \
|
||||
"$NEXTCLOUD_HOST/ocs/v2.php/cloud/capabilities?format=json" \
|
||||
-H "OCS-APIRequest: true"
|
||||
|
||||
# Test app API (requires patch)
|
||||
curl -H "Authorization: Bearer $TOKEN" \
|
||||
"$NEXTCLOUD_HOST/apps/notes/api/v1/notes"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Getting Help
|
||||
|
||||
If you continue to experience issues:
|
||||
|
||||
### 1. Collect Diagnostic Information
|
||||
|
||||
```bash
|
||||
# Server version
|
||||
uv run nextcloud-mcp-server --version
|
||||
|
||||
# Python version
|
||||
python3 --version
|
||||
|
||||
# Server logs with debug
|
||||
uv run nextcloud-mcp-server --oauth --log-level debug 2>&1 | tee mcp-server.log
|
||||
|
||||
# OIDC discovery
|
||||
curl https://your.nextcloud.instance.com/.well-known/openid-configuration > oidc-discovery.json
|
||||
|
||||
# Nextcloud version
|
||||
# Check in Nextcloud admin panel or:
|
||||
php occ -V
|
||||
```
|
||||
|
||||
### 2. Check Documentation
|
||||
|
||||
- [OAuth Architecture](oauth-architecture.md) - How OAuth works
|
||||
- [OAuth Setup Guide](oauth-setup.md) - Configuration steps
|
||||
- [Upstream Status](oauth-upstream-status.md) - Required patches
|
||||
- [Configuration](configuration.md) - Environment variables
|
||||
|
||||
### 3. Open an Issue
|
||||
|
||||
If problems persist, [open an issue](https://github.com/cbcoutinho/nextcloud-mcp-server/issues) with:
|
||||
|
||||
- **Error messages** (full text)
|
||||
- **Server logs** (with `--log-level debug`)
|
||||
- **OIDC discovery response** (from curl command above)
|
||||
- **Nextcloud version**
|
||||
- **OIDC app versions** (`oidc` and `user_oidc`)
|
||||
- **Steps to reproduce**
|
||||
- **Environment details** (OS, Python version, Docker vs local)
|
||||
|
||||
---
|
||||
|
||||
## See Also
|
||||
|
||||
- [OAuth Quick Start](quickstart-oauth.md) - Get started quickly
|
||||
- [OAuth Setup Guide](oauth-setup.md) - Detailed configuration
|
||||
- [OAuth Architecture](oauth-architecture.md) - Technical details
|
||||
- [Upstream Status](oauth-upstream-status.md) - Required patches
|
||||
- [General Troubleshooting](troubleshooting.md) - Non-OAuth issues
|
||||
@@ -0,0 +1,300 @@
|
||||
# OAuth Upstream Status
|
||||
|
||||
This document tracks the status of upstream patches and pull requests required for full OAuth functionality.
|
||||
|
||||
## Overview
|
||||
|
||||
The Nextcloud MCP Server's OAuth implementation relies on two Nextcloud apps:
|
||||
- **`oidc`** - OIDC Identity Provider (Authorization Server)
|
||||
- **`user_oidc`** - OpenID Connect user backend (Token validation)
|
||||
|
||||
While the core OAuth flow works, there are **pending upstream improvements** that enhance functionality and standards compliance.
|
||||
|
||||
## Required Patches
|
||||
|
||||
### 1. Bearer Token Support for Non-OCS Endpoints
|
||||
|
||||
**Status**: 🟡 **Patch Required** (Pending Upstream)
|
||||
|
||||
**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.
|
||||
|
||||
**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**: 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/server#55878](https://github.com/nextcloud/server/pull/55878)
|
||||
|
||||
**Workaround**: Manually apply the patch to `lib/private/AppFramework/Middleware/Security/CORSMiddleware.php` in Nextcloud core server
|
||||
|
||||
**Impact**:
|
||||
- ✅ **Works**: OCS APIs (`/ocs/v2.php/cloud/capabilities`)
|
||||
- ❌ **Requires Patch**: App APIs (`/apps/notes/api/`, `/apps/calendar/`, etc.)
|
||||
|
||||
**Files Modified**: `lib/private/AppFramework/Middleware/Security/CORSMiddleware.php` in **Nextcloud core server**
|
||||
|
||||
**Patch Summary**:
|
||||
```php
|
||||
// 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;
|
||||
}
|
||||
```
|
||||
|
||||
This is added before the CSRF check at line ~73 in `CORSMiddleware.php`.
|
||||
|
||||
---
|
||||
|
||||
### 2. JWT Token Support, Introspection, and Scope Validation
|
||||
|
||||
**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
|
||||
|
||||
---
|
||||
|
||||
## Upstream PRs Status
|
||||
|
||||
| PR/Issue | Component | Status | Priority | Notes |
|
||||
|----------|-----------|--------|----------|-------|
|
||||
| [server#55878](https://github.com/nextcloud/server/pull/55878) | Nextcloud core server | 🟡 Open | High | CORSMiddleware patch for Bearer tokens |
|
||||
| [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
|
||||
|
||||
The following functionality works **out of the box** without any patches:
|
||||
|
||||
✅ **OAuth Flow** (requires `oidc` app v1.10.0+):
|
||||
- OIDC discovery with full PKCE support (RFC 7636)
|
||||
- Dynamic client registration
|
||||
- Authorization code flow with PKCE (S256 and plain methods)
|
||||
- Token exchange with code_verifier verification
|
||||
- User consent management
|
||||
- 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**:
|
||||
- Token validation via userinfo
|
||||
- Per-user client instances
|
||||
- Token caching
|
||||
- Scope-based authorization
|
||||
|
||||
✅ **Nextcloud OCS APIs**:
|
||||
- Capabilities endpoint
|
||||
- All OCS-based APIs
|
||||
|
||||
## What Requires Patches
|
||||
|
||||
The following functionality requires upstream patches:
|
||||
|
||||
🟡 **App-Specific APIs** (Requires Nextcloud core server CORSMiddleware patch):
|
||||
- Notes API (`/apps/notes/api/`)
|
||||
- Calendar API (CalDAV)
|
||||
- Contacts API (CardDAV)
|
||||
- Deck API
|
||||
- Tables API
|
||||
- Custom app APIs
|
||||
|
||||
✅ **Standards Compliance**: Now complete with `oidc` app v1.10.0+
|
||||
- ✅ Full RFC 8414 compliance (PKCE advertisement)
|
||||
- ✅ MCP client compatibility guarantee
|
||||
|
||||
## Installation Instructions
|
||||
|
||||
### For Development/Testing
|
||||
|
||||
If the upstream PRs are not yet merged, you can apply patches manually:
|
||||
|
||||
#### 1. Apply Bearer Token Patch
|
||||
|
||||
```bash
|
||||
# SSH into Nextcloud server
|
||||
cd /path/to/nextcloud/apps/user_oidc
|
||||
|
||||
# Download and apply patch
|
||||
# (Patch file to be created once PR is ready)
|
||||
wget https://github.com/nextcloud/user_oidc/pull/XXXX.patch
|
||||
git apply XXXX.patch
|
||||
|
||||
# Or manually edit lib/User/Backend.php
|
||||
# Add this line before each return statement in getCurrentUserId():
|
||||
# $this->session->set('app_api', true);
|
||||
```
|
||||
|
||||
#### 2. Verify Installation
|
||||
|
||||
```bash
|
||||
# Test with OAuth token
|
||||
curl -H "Authorization: Bearer YOUR_TOKEN" \
|
||||
https://your.nextcloud.com/apps/notes/api/v1/notes
|
||||
|
||||
# Should return notes JSON (not 401)
|
||||
```
|
||||
|
||||
### For Production
|
||||
|
||||
**Recommendation**: Wait for upstream PRs to be merged and included in official Nextcloud releases before deploying OAuth in production.
|
||||
|
||||
**Alternative**: Use a patched version of `user_oidc` app in your deployment:
|
||||
1. Fork the `user_oidc` app
|
||||
2. Apply the required patches
|
||||
3. Install your patched version
|
||||
4. Document the changes for your team
|
||||
|
||||
## Testing
|
||||
|
||||
The integration test suite validates OAuth functionality:
|
||||
|
||||
```bash
|
||||
# Start OAuth-enabled MCP server
|
||||
docker-compose up --build -d mcp-oauth
|
||||
|
||||
# Run comprehensive OAuth tests
|
||||
uv run pytest tests/client/test_oauth_playwright.py --browser firefox -v
|
||||
|
||||
# Tests verify:
|
||||
# - OAuth flow completion
|
||||
# - Token validation
|
||||
# - MCP tool calls with Bearer tokens
|
||||
# - Notes API access (requires patch)
|
||||
```
|
||||
|
||||
## Monitoring Upstream Progress
|
||||
|
||||
To track progress on remaining issues:
|
||||
|
||||
1. **Watch the upstream repository**:
|
||||
- [nextcloud/server](https://github.com/nextcloud/server)
|
||||
|
||||
2. **Subscribe to the CORSMiddleware PR**:
|
||||
- [server#55878](https://github.com/nextcloud/server/pull/55878) - CORSMiddleware Bearer token support
|
||||
|
||||
3. **Check Nextcloud server release notes** for mentions of:
|
||||
- Bearer token authentication improvements
|
||||
- CORS middleware enhancements
|
||||
- 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
|
||||
|
||||
Want to help get these patches merged?
|
||||
|
||||
1. **Test the patches**: Run the integration tests and report results
|
||||
2. **Review PRs**: Provide feedback on upstream pull requests
|
||||
3. **Document issues**: Report any problems or edge cases
|
||||
4. **Contribute code**: Submit improvements or fixes to upstream
|
||||
|
||||
## Timeline Expectations
|
||||
|
||||
**Best Case**: PRs merged in next Nextcloud minor release (est. 3-6 months)
|
||||
|
||||
**Realistic**: PRs reviewed and merged within 6-12 months
|
||||
|
||||
**Meanwhile**: Use the workarounds documented in this guide
|
||||
|
||||
## See Also
|
||||
|
||||
- [OAuth Architecture](oauth-architecture.md) - How OAuth works in this implementation
|
||||
- [OAuth Troubleshooting](oauth-troubleshooting.md) - Common issues and solutions
|
||||
- [OAuth Setup Guide](oauth-setup.md) - Configuration instructions
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: 2025-11-02
|
||||
|
||||
**Next Review**: When Nextcloud server CORSMiddleware PR has activity
|
||||
@@ -0,0 +1,258 @@
|
||||
# Observability and Monitoring
|
||||
|
||||
The Nextcloud MCP Server includes comprehensive observability features for production deployments:
|
||||
|
||||
- **Prometheus metrics** for monitoring performance and health
|
||||
- **OpenTelemetry distributed tracing** for debugging request flows
|
||||
- **Structured JSON logging** with trace correlation
|
||||
- **Kubernetes integration** via ServiceMonitor and PrometheusRule
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Local Development with Prometheus
|
||||
|
||||
```bash
|
||||
# Enable metrics (enabled by default)
|
||||
export METRICS_ENABLED=true
|
||||
export METRICS_PORT=9090
|
||||
|
||||
# Enable tracing (optional - tracing is enabled when OTEL_EXPORTER_OTLP_ENDPOINT is set)
|
||||
export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
|
||||
|
||||
# Start the server
|
||||
docker-compose up -d mcp
|
||||
```
|
||||
|
||||
Access metrics at: `http://localhost:9090/metrics`
|
||||
|
||||
### Kubernetes Deployment
|
||||
|
||||
Metrics are automatically scraped if you have Prometheus Operator installed:
|
||||
|
||||
```bash
|
||||
helm install nextcloud-mcp charts/nextcloud-mcp-server \
|
||||
--set observability.metrics.enabled=true \
|
||||
--set observability.tracing.enabled=true \
|
||||
--set observability.tracing.endpoint=http://opentelemetry-collector:4317 \
|
||||
--set serviceMonitor.enabled=true
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `METRICS_ENABLED` | `true` | Enable Prometheus metrics |
|
||||
| `METRICS_PORT` | `9090` | Port for metrics endpoint |
|
||||
| `OTEL_EXPORTER_OTLP_ENDPOINT` | - | OTLP gRPC endpoint (e.g., `http://otel-collector:4317`). Tracing is enabled when this is set. |
|
||||
| `OTEL_SERVICE_NAME` | `nextcloud-mcp-server` | Service name in traces |
|
||||
| `OTEL_TRACES_SAMPLER` | `always_on` | Trace sampling strategy |
|
||||
| `OTEL_TRACES_SAMPLER_ARG` | `1.0` | Sampling rate (0.0-1.0) |
|
||||
| `LOG_FORMAT` | `json` | Log format (`json` or `text`) |
|
||||
| `LOG_LEVEL` | `INFO` | Minimum log level |
|
||||
| `LOG_INCLUDE_TRACE_CONTEXT` | `true` | Include trace IDs in logs |
|
||||
|
||||
### Helm Chart Configuration
|
||||
|
||||
```yaml
|
||||
observability:
|
||||
metrics:
|
||||
enabled: true
|
||||
port: 9090
|
||||
path: /metrics
|
||||
|
||||
tracing:
|
||||
enabled: true
|
||||
endpoint: "http://opentelemetry-collector:4317"
|
||||
samplingRate: 1.0
|
||||
|
||||
logging:
|
||||
format: json
|
||||
level: INFO
|
||||
includeTraceContext: true
|
||||
|
||||
serviceMonitor:
|
||||
enabled: true
|
||||
interval: 30s
|
||||
scrapeTimeout: 10s
|
||||
```
|
||||
|
||||
## Metrics
|
||||
|
||||
### HTTP Server Metrics (RED)
|
||||
|
||||
- `mcp_http_requests_total` - Total HTTP requests
|
||||
- `mcp_http_request_duration_seconds` - Request latency histogram
|
||||
- `mcp_http_requests_in_progress` - In-flight requests gauge
|
||||
|
||||
### MCP Tool Metrics
|
||||
|
||||
- `mcp_tool_calls_total` - Tool invocation count by status
|
||||
- `mcp_tool_duration_seconds` - Tool execution latency
|
||||
- `mcp_tool_errors_total` - Tool errors by type
|
||||
|
||||
### Nextcloud API Metrics
|
||||
|
||||
- `mcp_nextcloud_api_requests_total` - API calls by app and status
|
||||
- `mcp_nextcloud_api_duration_seconds` - API latency by app
|
||||
- `mcp_nextcloud_api_retries_total` - Retry count (429, timeout, etc.)
|
||||
|
||||
### OAuth Flow Metrics
|
||||
|
||||
- `mcp_oauth_token_validations_total` - Token validation count
|
||||
- `mcp_oauth_token_exchange_total` - Token exchange operations
|
||||
- `mcp_oauth_token_cache_hits_total` - Cache hit/miss rate
|
||||
- `mcp_oauth_refresh_token_operations_total` - Refresh token storage ops
|
||||
|
||||
### Vector Sync Metrics (when enabled)
|
||||
|
||||
- `mcp_vector_sync_documents_scanned_total` - Documents discovered
|
||||
- `mcp_vector_sync_documents_processed_total` - Processing results
|
||||
- `mcp_vector_sync_processing_duration_seconds` - Processing latency
|
||||
- `mcp_vector_sync_queue_size` - Current queue depth
|
||||
- `mcp_qdrant_operations_total` - Qdrant DB operations
|
||||
|
||||
### Database Metrics
|
||||
|
||||
- `mcp_db_operations_total` - DB operations (SQLite, Qdrant)
|
||||
- `mcp_db_operation_duration_seconds` - DB latency
|
||||
|
||||
### Dependency Health
|
||||
|
||||
- `mcp_dependency_health` - External dependency status (1=up, 0=down)
|
||||
- `mcp_dependency_check_duration_seconds` - Health check latency
|
||||
|
||||
## Distributed Tracing
|
||||
|
||||
### Span Hierarchy
|
||||
|
||||
```
|
||||
HTTP POST /messages
|
||||
├── mcp.tool.nc_notes_create_note
|
||||
│ └── nextcloud.api.notes.POST
|
||||
│ └── httpx request (auto-instrumented)
|
||||
└── oauth.token.validate (if OAuth mode)
|
||||
└── httpx request to IdP
|
||||
```
|
||||
|
||||
### Span Attributes
|
||||
|
||||
- **MCP tools**: `mcp.tool.name`, `mcp.tool.args` (sanitized)
|
||||
- **Nextcloud API**: `nextcloud.app`, `http.method`, `http.status_code`
|
||||
- **OAuth**: `oauth.operation`, `oauth.method`
|
||||
- **Vector sync**: `vector_sync.operation`, `vector_sync.document_count`
|
||||
|
||||
### Trace Context in Logs
|
||||
|
||||
When tracing is enabled, all logs include `trace_id` and `span_id`:
|
||||
|
||||
```json
|
||||
{
|
||||
"timestamp": "2025-01-09T12:34:56.789Z",
|
||||
"level": "INFO",
|
||||
"logger": "nextcloud_mcp_server.server.notes",
|
||||
"message": "Note created successfully",
|
||||
"trace_id": "a1b2c3d4e5f6...",
|
||||
"span_id": "123456789abc...",
|
||||
"note_id": 42
|
||||
}
|
||||
```
|
||||
|
||||
## Dashboards
|
||||
|
||||
### Prometheus Queries
|
||||
|
||||
**Request Rate (req/s)**:
|
||||
```promql
|
||||
sum(rate(mcp_http_requests_total[5m])) by (method, endpoint)
|
||||
```
|
||||
|
||||
**Error Rate (%)**:
|
||||
```promql
|
||||
sum(rate(mcp_http_requests_total{status_code=~"5.."}[5m]))
|
||||
/ sum(rate(mcp_http_requests_total[5m])) * 100
|
||||
```
|
||||
|
||||
**P95 Latency**:
|
||||
```promql
|
||||
histogram_quantile(0.95,
|
||||
sum(rate(mcp_http_request_duration_seconds_bucket[5m])) by (le, endpoint)
|
||||
)
|
||||
```
|
||||
|
||||
**Top Tools by Volume**:
|
||||
```promql
|
||||
topk(10, sum(rate(mcp_tool_calls_total[5m])) by (tool_name))
|
||||
```
|
||||
|
||||
**Nextcloud API Health**:
|
||||
```promql
|
||||
sum(rate(mcp_nextcloud_api_requests_total{status_code!~"2.."}[5m])) by (app)
|
||||
```
|
||||
|
||||
## Alerts
|
||||
|
||||
### Recommended Alert Rules
|
||||
|
||||
**Critical**:
|
||||
- Server down for >5min
|
||||
- Error rate >5% for >5min
|
||||
- P95 latency >1s for >5min
|
||||
- Dependency down for >2min
|
||||
|
||||
**Warning**:
|
||||
- Token validation errors >1% for >10min
|
||||
- Vector sync queue >100 for >15min
|
||||
- Qdrant slow (p95 >500ms) for >10min
|
||||
|
||||
See `charts/nextcloud-mcp-server/templates/prometheusrule.yaml` for complete definitions.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Metrics Not Appearing
|
||||
|
||||
1. Check metrics are enabled: `curl http://localhost:9090/metrics`
|
||||
2. Verify ServiceMonitor labels match Prometheus selector
|
||||
3. Check Prometheus target status: `http://prometheus:9090/targets`
|
||||
|
||||
### Traces Not Appearing
|
||||
|
||||
1. Verify OTLP endpoint is reachable: `curl http://otel-collector:4317`
|
||||
2. Check collector logs for errors
|
||||
3. Verify sampling rate is not 0.0
|
||||
4. Check trace backend (Jaeger/Tempo) connectivity
|
||||
|
||||
### High Cardinality Metrics
|
||||
|
||||
If you see cardinality warnings:
|
||||
- Middleware normalizes endpoints (e.g., `/user/123` → `/user/*`)
|
||||
- OAuth tokens are never included in metric labels
|
||||
- User IDs are not tracked (use tracing for per-user debugging)
|
||||
|
||||
## Performance Impact
|
||||
|
||||
- **Metrics**: <1% overhead (counters/histograms are very fast)
|
||||
- **Tracing**: ~2-5% overhead at 100% sampling
|
||||
- **JSON logging**: <1% overhead vs text logging
|
||||
|
||||
**Recommendation**: Always enable metrics. Enable tracing in staging/production with 10-50% sampling.
|
||||
|
||||
## Architecture
|
||||
|
||||
The observability stack integrates at multiple layers:
|
||||
|
||||
1. **HTTP Layer**: `ObservabilityMiddleware` tracks all HTTP requests
|
||||
2. **MCP Layer**: Tools use `@trace_mcp_tool` for span creation
|
||||
3. **Client Layer**: `BaseNextcloudClient` tracks all API calls
|
||||
4. **OAuth Layer**: Token operations are traced and metered
|
||||
5. **Background Tasks**: Vector sync operations emit metrics/traces
|
||||
|
||||
All components use shared Prometheus `Registry` and OpenTelemetry `TracerProvider`.
|
||||
|
||||
## References
|
||||
|
||||
- [Prometheus Best Practices](https://prometheus.io/docs/practices/)
|
||||
- [OpenTelemetry Python SDK](https://opentelemetry.io/docs/languages/python/)
|
||||
- [Prometheus Operator](https://prometheus-operator.dev/)
|
||||
- [Grafana Dashboards](https://grafana.com/docs/grafana/latest/dashboards/)
|
||||
@@ -0,0 +1,163 @@
|
||||
# OAuth Quick Start Guide
|
||||
|
||||
Get up and running with OAuth authentication in 5 minutes.
|
||||
|
||||
## Prerequisites Checklist
|
||||
|
||||
Before you begin, ensure you have:
|
||||
|
||||
- [ ] Nextcloud instance with **administrator access**
|
||||
- [ ] Nextcloud version 28 or later
|
||||
- [ ] Python 3.11+ installed
|
||||
- [ ] `uv` package manager installed ([installation instructions](https://docs.astral.sh/uv/getting-started/installation/))
|
||||
|
||||
## Step 1: Install Nextcloud Apps
|
||||
|
||||
Install **both** required apps in your Nextcloud instance:
|
||||
|
||||
1. Open Nextcloud as administrator
|
||||
2. Navigate to **Apps** → **Security**
|
||||
3. Install:
|
||||
- **OIDC** (OIDC Identity Provider app)
|
||||
- **OpenID Connect user backend** (user_oidc app)
|
||||
4. Enable both apps
|
||||
|
||||
> [!IMPORTANT]
|
||||
> The `user_oidc` app requires an upstream patch for Bearer token support. See [Upstream Status](oauth-upstream-status.md) for details. The functionality works, but the PR is pending.
|
||||
|
||||
## Step 2: Configure Nextcloud OIDC
|
||||
|
||||
Enable dynamic client registration and Bearer token validation:
|
||||
|
||||
### Via Web UI
|
||||
|
||||
1. Go to **Settings** → **OIDC** (Administration settings)
|
||||
2. Enable **"Allow dynamic client registration"**
|
||||
|
||||
### Via CLI (Required)
|
||||
|
||||
SSH into your Nextcloud server and run:
|
||||
|
||||
```bash
|
||||
# Enable Bearer token validation
|
||||
php occ config:system:set user_oidc oidc_provider_bearer_validation --value=true --type=boolean
|
||||
```
|
||||
|
||||
## Step 3: Install MCP Server
|
||||
|
||||
Clone and install the MCP server:
|
||||
|
||||
```bash
|
||||
# Clone repository
|
||||
git clone https://github.com/cbcoutinho/nextcloud-mcp-server.git
|
||||
cd nextcloud-mcp-server
|
||||
|
||||
# Install dependencies
|
||||
uv sync
|
||||
```
|
||||
|
||||
## Step 4: Configure Environment
|
||||
|
||||
Create a `.env` file with minimal configuration:
|
||||
|
||||
```bash
|
||||
# Copy sample
|
||||
cp env.sample .env
|
||||
|
||||
# Edit .env and set:
|
||||
NEXTCLOUD_HOST=https://your.nextcloud.instance.com
|
||||
|
||||
# IMPORTANT: Leave these EMPTY for OAuth mode
|
||||
NEXTCLOUD_USERNAME=
|
||||
NEXTCLOUD_PASSWORD=
|
||||
```
|
||||
|
||||
## Step 5: Start the Server
|
||||
|
||||
Load environment variables and start the server:
|
||||
|
||||
```bash
|
||||
# Load environment
|
||||
export $(grep -v '^#' .env | xargs)
|
||||
|
||||
# Start server with OAuth
|
||||
uv run nextcloud-mcp-server --oauth
|
||||
```
|
||||
|
||||
Look for this success message:
|
||||
|
||||
```
|
||||
✓ PKCE support validated: ['S256']
|
||||
INFO OAuth initialization complete
|
||||
INFO MCP server ready at http://127.0.0.1:8000
|
||||
```
|
||||
|
||||
## Step 6: Test with MCP Inspector
|
||||
|
||||
Open a new terminal and test the connection:
|
||||
|
||||
```bash
|
||||
# Start MCP Inspector
|
||||
uv run mcp dev
|
||||
```
|
||||
|
||||
This opens your browser. In the MCP Inspector UI:
|
||||
|
||||
1. Enter server URL: `http://127.0.0.1:8000/mcp`
|
||||
2. Click **Connect**
|
||||
3. Complete the OAuth flow in the browser popup
|
||||
4. After authorization, you'll see available tools and resources
|
||||
|
||||
Test a tool by trying:
|
||||
- **Tool**: `nc_notes_create_note`
|
||||
- **Title**: "Test Note"
|
||||
- **Content**: "Hello from MCP!"
|
||||
- **Category**: "Notes"
|
||||
|
||||
## Troubleshooting Quick Fixes
|
||||
|
||||
### PKCE Error
|
||||
|
||||
If you see:
|
||||
```
|
||||
ERROR: OIDC CONFIGURATION ERROR - Missing PKCE Support Advertisement
|
||||
```
|
||||
|
||||
**Fix**: The Nextcloud OIDC app needs to be updated to advertise PKCE support. See [Upstream Status](oauth-upstream-status.md) for the required PR.
|
||||
|
||||
### 401 Unauthorized for Notes API
|
||||
|
||||
If OAuth works but Notes API returns 401:
|
||||
|
||||
**Fix**: The `user_oidc` app needs the Bearer token patch. See [Upstream Status](oauth-upstream-status.md) for details.
|
||||
|
||||
### Can't Reach OIDC Discovery Endpoint
|
||||
|
||||
**Fix**: Verify your Nextcloud URL is correct and accessible:
|
||||
|
||||
```bash
|
||||
curl https://your.nextcloud.instance.com/.well-known/openid-configuration
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [OAuth Setup Guide](oauth-setup.md) - Detailed configuration options
|
||||
- [OAuth Architecture](oauth-architecture.md) - How it works under the hood
|
||||
- [OAuth Troubleshooting](oauth-troubleshooting.md) - Common issues and solutions
|
||||
- [Configuration](configuration.md) - All environment variables
|
||||
|
||||
## Development vs Production
|
||||
|
||||
This quick start uses **automatic client registration** which is perfect for:
|
||||
- Development
|
||||
- Testing
|
||||
- Quick deployments
|
||||
|
||||
For **production deployments**, consider:
|
||||
1. Pre-registering OAuth client manually
|
||||
2. Using dedicated client credentials that don't expire
|
||||
3. See [OAuth Setup Guide](oauth-setup.md) for production configuration
|
||||
|
||||
---
|
||||
|
||||
**Need help?** Check [OAuth Troubleshooting](oauth-troubleshooting.md) or [open an issue](https://github.com/cbcoutinho/nextcloud-mcp-server/issues).
|
||||
+440
@@ -0,0 +1,440 @@
|
||||
# Running the Server
|
||||
|
||||
This guide covers different ways to start and run the Nextcloud MCP server.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before running the server:
|
||||
|
||||
1. **Install the server** - See [Installation Guide](installation.md)
|
||||
2. **Configure environment** - See [Configuration Guide](configuration.md)
|
||||
3. **Set up authentication** - See [OAuth Setup](oauth-setup.md) or [Authentication](authentication.md)
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
Load your environment variables and start the server:
|
||||
|
||||
```bash
|
||||
# Load environment variables from .env
|
||||
export $(grep -v '^#' .env | xargs)
|
||||
|
||||
# Start the server
|
||||
uv run nextcloud-mcp-server
|
||||
```
|
||||
|
||||
The server will start on `http://127.0.0.1:8000` by default.
|
||||
|
||||
---
|
||||
|
||||
## Running Locally
|
||||
|
||||
### Method 1: Using nextcloud-mcp-server CLI (Recommended)
|
||||
|
||||
The CLI provides a simple interface with built-in defaults:
|
||||
|
||||
#### OAuth Mode
|
||||
|
||||
```bash
|
||||
# Auto-detected when NEXTCLOUD_USERNAME/PASSWORD not set
|
||||
uv run nextcloud-mcp-server
|
||||
|
||||
# Explicitly force OAuth mode
|
||||
uv run nextcloud-mcp-server --oauth
|
||||
|
||||
# OAuth with custom host and port
|
||||
uv run nextcloud-mcp-server --oauth --host 0.0.0.0 --port 8080
|
||||
|
||||
# OAuth with pre-configured client
|
||||
uv run nextcloud-mcp-server --oauth \
|
||||
--oauth-client-id abc123 \
|
||||
--oauth-client-secret xyz789
|
||||
|
||||
# OAuth with specific apps only
|
||||
uv run nextcloud-mcp-server --oauth \
|
||||
--enable-app notes \
|
||||
--enable-app calendar
|
||||
```
|
||||
|
||||
#### BasicAuth Mode (Legacy)
|
||||
|
||||
```bash
|
||||
# Auto-detected when NEXTCLOUD_USERNAME/PASSWORD are set
|
||||
uv run nextcloud-mcp-server
|
||||
|
||||
# Explicitly force BasicAuth mode
|
||||
uv run nextcloud-mcp-server --no-oauth
|
||||
|
||||
# BasicAuth with specific apps
|
||||
uv run nextcloud-mcp-server --no-oauth \
|
||||
--enable-app notes \
|
||||
--enable-app webdav
|
||||
```
|
||||
|
||||
### Method 2: Using uvicorn
|
||||
|
||||
For more control over server options (workers, reload, etc.):
|
||||
|
||||
```bash
|
||||
# Load environment variables
|
||||
export $(grep -v '^#' .env | xargs)
|
||||
|
||||
# Run with uvicorn
|
||||
uv run uvicorn nextcloud_mcp_server.app:get_app \
|
||||
--factory \
|
||||
--host 127.0.0.1 \
|
||||
--port 8000 \
|
||||
--reload # Enable auto-reload for development
|
||||
```
|
||||
|
||||
See all uvicorn options at [https://www.uvicorn.org/settings/](https://www.uvicorn.org/settings/)
|
||||
|
||||
### Method 3: Using Python Module
|
||||
|
||||
```bash
|
||||
# Load environment variables
|
||||
export $(grep -v '^#' .env | xargs)
|
||||
|
||||
# Run as Python module
|
||||
python -m nextcloud_mcp_server.app --oauth --port 8000
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Running with Docker
|
||||
|
||||
### Basic Docker Run
|
||||
|
||||
```bash
|
||||
# OAuth mode
|
||||
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
|
||||
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest --oauth
|
||||
|
||||
# BasicAuth mode
|
||||
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
|
||||
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest
|
||||
```
|
||||
|
||||
### Docker with Persistent OAuth Storage
|
||||
|
||||
```bash
|
||||
docker run -p 127.0.0.1:8000:8000 --env-file .env \
|
||||
-v $(pwd)/.oauth:/app/.oauth \
|
||||
--rm ghcr.io/cbcoutinho/nextcloud-mcp-server:latest --oauth
|
||||
```
|
||||
|
||||
### Docker Compose
|
||||
|
||||
Create `docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
mcp:
|
||||
image: ghcr.io/cbcoutinho/nextcloud-mcp-server:latest
|
||||
command: --oauth --enable-app notes --enable-app calendar
|
||||
ports:
|
||||
- "127.0.0.1:8000:8000"
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
- ./oauth-storage:/app/.oauth
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
Start the service:
|
||||
|
||||
```bash
|
||||
# Start in foreground
|
||||
docker-compose up
|
||||
|
||||
# Start in background
|
||||
docker-compose up -d
|
||||
|
||||
# View logs
|
||||
docker-compose logs -f
|
||||
|
||||
# Stop the service
|
||||
docker-compose down
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Server Options
|
||||
|
||||
### Host and Port
|
||||
|
||||
```bash
|
||||
# Bind to all interfaces (accessible from network)
|
||||
uv run nextcloud-mcp-server --host 0.0.0.0 --port 8000
|
||||
|
||||
# Bind to localhost only (default, more secure)
|
||||
uv run nextcloud-mcp-server --host 127.0.0.1 --port 8000
|
||||
|
||||
# Use a different port
|
||||
uv run nextcloud-mcp-server --port 8080
|
||||
```
|
||||
|
||||
**Security Note:** Using `--host 0.0.0.0` exposes the server to your network. Only use this if you understand the security implications.
|
||||
|
||||
### Transport Protocols
|
||||
|
||||
The server supports multiple MCP transport protocols:
|
||||
|
||||
```bash
|
||||
# Streamable HTTP (recommended)
|
||||
uv run nextcloud-mcp-server --transport streamable-http
|
||||
|
||||
# SSE - Server-Sent Events (default, deprecated)
|
||||
uv run nextcloud-mcp-server --transport sse
|
||||
|
||||
# HTTP
|
||||
uv run nextcloud-mcp-server --transport http
|
||||
```
|
||||
|
||||
> [!WARNING]
|
||||
> SSE transport is deprecated and will be removed in a future version of the MCP spec. Please migrate to `streamable-http`.
|
||||
|
||||
### Logging
|
||||
|
||||
```bash
|
||||
# Set log level (critical, error, warning, info, debug, trace)
|
||||
uv run nextcloud-mcp-server --log-level debug
|
||||
|
||||
# Production: use warning or error
|
||||
uv run nextcloud-mcp-server --log-level warning
|
||||
```
|
||||
|
||||
### Selective App Enablement
|
||||
|
||||
By default, all supported Nextcloud apps are enabled. You can enable specific apps only:
|
||||
|
||||
```bash
|
||||
# Available apps: notes, tables, webdav, calendar, contacts, deck
|
||||
|
||||
# Enable all apps (default)
|
||||
uv run nextcloud-mcp-server
|
||||
|
||||
# Enable only Notes
|
||||
uv run nextcloud-mcp-server --enable-app notes
|
||||
|
||||
# Enable multiple apps
|
||||
uv run nextcloud-mcp-server \
|
||||
--enable-app notes \
|
||||
--enable-app calendar \
|
||||
--enable-app contacts
|
||||
|
||||
# Enable only WebDAV for file operations
|
||||
uv run nextcloud-mcp-server --enable-app webdav
|
||||
```
|
||||
|
||||
**Use cases:**
|
||||
- Reduce memory usage and startup time
|
||||
- Limit functionality for security/organizational reasons
|
||||
- Test specific app integrations
|
||||
- Run lightweight instances with only needed features
|
||||
|
||||
---
|
||||
|
||||
## Development Mode
|
||||
|
||||
For active development with auto-reload:
|
||||
|
||||
```bash
|
||||
# Using uvicorn with reload
|
||||
uv run uvicorn nextcloud_mcp_server.app:get_app \
|
||||
--factory \
|
||||
--reload \
|
||||
--host 127.0.0.1 \
|
||||
--port 8000 \
|
||||
--log-level debug
|
||||
```
|
||||
|
||||
Or use the CLI with reload flag:
|
||||
|
||||
```bash
|
||||
uv run nextcloud-mcp-server --reload --log-level debug
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Connecting to the Server
|
||||
|
||||
### Using MCP Inspector
|
||||
|
||||
MCP Inspector is a browser-based tool for testing MCP servers:
|
||||
|
||||
```bash
|
||||
# Start MCP Inspector
|
||||
uv run mcp dev
|
||||
|
||||
# In the browser:
|
||||
# 1. Enter server URL: http://localhost:8000
|
||||
# 2. Complete OAuth flow (if using OAuth)
|
||||
# 3. Explore tools and resources
|
||||
```
|
||||
|
||||
### Using MCP Clients
|
||||
|
||||
MCP clients (like Claude Desktop, LLM IDEs) can connect to your server:
|
||||
|
||||
1. Configure the client with your server URL
|
||||
2. Complete OAuth authentication (if enabled)
|
||||
3. Start interacting with Nextcloud through the LLM
|
||||
|
||||
---
|
||||
|
||||
## Verifying Server Status
|
||||
|
||||
### Check Server Health
|
||||
|
||||
```bash
|
||||
# Test if server is responding
|
||||
curl http://localhost:8000/health
|
||||
|
||||
# Expected response: HTTP 200 OK
|
||||
```
|
||||
|
||||
### Check OAuth Configuration
|
||||
|
||||
Look for these log messages on startup:
|
||||
|
||||
**OAuth mode:**
|
||||
```
|
||||
INFO OAuth mode detected (NEXTCLOUD_USERNAME/PASSWORD not set)
|
||||
INFO Configuring MCP server for OAuth mode
|
||||
INFO OIDC discovery successful
|
||||
INFO OAuth client ready: <client-id>...
|
||||
INFO OAuth initialization complete
|
||||
```
|
||||
|
||||
**BasicAuth mode:**
|
||||
```
|
||||
INFO BasicAuth mode detected (NEXTCLOUD_USERNAME/PASSWORD set)
|
||||
INFO Initializing Nextcloud client with BasicAuth
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Process Management
|
||||
|
||||
### Running as a Background Service
|
||||
|
||||
#### Using systemd (Linux)
|
||||
|
||||
Create `/etc/systemd/system/nextcloud-mcp.service`:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Nextcloud MCP Server
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=your-user
|
||||
WorkingDirectory=/path/to/nextcloud-mcp-server
|
||||
EnvironmentFile=/path/to/.env
|
||||
ExecStart=/path/to/uv run nextcloud-mcp-server --oauth
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
Enable and start:
|
||||
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable nextcloud-mcp
|
||||
sudo systemctl start nextcloud-mcp
|
||||
sudo systemctl status nextcloud-mcp
|
||||
```
|
||||
|
||||
#### Using Docker Compose
|
||||
|
||||
See [Docker Compose section](#docker-compose) above - includes `restart: unless-stopped`.
|
||||
|
||||
### Monitoring Logs
|
||||
|
||||
```bash
|
||||
# Local installation with systemd
|
||||
sudo journalctl -u nextcloud-mcp -f
|
||||
|
||||
# Docker
|
||||
docker logs -f <container-name>
|
||||
|
||||
# Docker Compose
|
||||
docker-compose logs -f mcp
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Performance Tuning
|
||||
|
||||
### Multiple Workers
|
||||
|
||||
For production deployments with higher load:
|
||||
|
||||
```bash
|
||||
# Using CLI (if supported)
|
||||
uv run nextcloud-mcp-server --workers 4
|
||||
|
||||
# Using uvicorn
|
||||
uv run uvicorn nextcloud_mcp_server.app:get_app \
|
||||
--factory \
|
||||
--workers 4 \
|
||||
--host 0.0.0.0 \
|
||||
--port 8000
|
||||
```
|
||||
|
||||
### Production Settings
|
||||
|
||||
```bash
|
||||
# Recommended production configuration
|
||||
uv run nextcloud-mcp-server \
|
||||
--oauth \
|
||||
--host 127.0.0.1 \
|
||||
--port 8000 \
|
||||
--log-level warning \
|
||||
--transport streamable-http \
|
||||
--workers 2
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Server won't start
|
||||
|
||||
Check logs for errors:
|
||||
```bash
|
||||
uv run nextcloud-mcp-server --log-level debug
|
||||
```
|
||||
|
||||
Common issues:
|
||||
- Environment variables not loaded - See [Configuration](configuration.md#loading-environment-variables)
|
||||
- Port already in use - Try a different port with `--port`
|
||||
- OAuth configuration errors - See [Troubleshooting](troubleshooting.md)
|
||||
|
||||
### Can't connect to server
|
||||
|
||||
1. Verify server is running: `curl http://localhost:8000/health`
|
||||
2. Check firewall settings
|
||||
3. Verify host binding (use `0.0.0.0` to allow network access)
|
||||
4. Check OAuth authentication if enabled
|
||||
|
||||
### OAuth authentication fails
|
||||
|
||||
See [Troubleshooting OAuth](troubleshooting.md) for detailed OAuth troubleshooting.
|
||||
|
||||
---
|
||||
|
||||
## See Also
|
||||
|
||||
- [Configuration Guide](configuration.md) - Environment variables
|
||||
- [OAuth Setup](oauth-setup.md) - OAuth authentication setup
|
||||
- [Troubleshooting](troubleshooting.md) - Common issues and solutions
|
||||
- [Installation](installation.md) - Installing the server
|
||||
@@ -0,0 +1,921 @@
|
||||
# Semantic Search Architecture
|
||||
|
||||
This document explains the architecture of the semantic search feature in the Nextcloud MCP Server, including background synchronization, vector search, and optional AI-generated answers via MCP sampling.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **Status: Experimental**
|
||||
> - Disabled by default (`VECTOR_SYNC_ENABLED=false`)
|
||||
> - Currently supports **Notes app only** (multi-app architecture ready, additional apps planned)
|
||||
> - Requires additional infrastructure (Qdrant vector database + Ollama embedding service)
|
||||
> - RAG answer generation requires MCP client sampling support
|
||||
|
||||
## Overview
|
||||
|
||||
### What is Semantic Search?
|
||||
|
||||
**Semantic search** finds information based on **meaning** rather than exact keyword matches. It uses vector embeddings to understand that "car" and "automobile" are similar, or that "bread recipe" matches "how to bake bread."
|
||||
|
||||
**Traditional keyword search:**
|
||||
```
|
||||
Query: "machine learning"
|
||||
Matches: Only notes containing "machine learning" exactly
|
||||
Misses: Notes with "neural networks", "AI models", "deep learning"
|
||||
```
|
||||
|
||||
**Semantic search:**
|
||||
```
|
||||
Query: "machine learning"
|
||||
Matches: Notes about machine learning, neural networks, AI, deep learning, etc.
|
||||
Understanding: Semantic similarity via vector embeddings
|
||||
```
|
||||
|
||||
### Why It Matters
|
||||
|
||||
Semantic search enables:
|
||||
- **Natural language queries** - Ask questions in plain language
|
||||
- **Conceptual discovery** - Find related content even with different terminology
|
||||
- **Cross-reference insights** - Connect ideas across your knowledge base
|
||||
- **AI-powered answers** - Generate summaries with citations (optional, requires MCP sampling)
|
||||
|
||||
### Current Support
|
||||
|
||||
- **Supported Apps**: Notes (fully implemented)
|
||||
- **Planned Apps**: Calendar events, Calendar tasks, Deck cards, Files (with text extraction), Contacts
|
||||
- **Architecture**: Multi-app plugin system ready, awaiting implementation
|
||||
|
||||
## System Components
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "MCP Client"
|
||||
Client[Claude Desktop, IDEs, etc.]
|
||||
end
|
||||
|
||||
subgraph "Nextcloud MCP Server"
|
||||
MCP[MCP Server]
|
||||
Scanner[Background Scanner<br/>Hourly Change Detection]
|
||||
Queue[Document Queue]
|
||||
Processor[Embedding Processors<br/>Concurrent Workers]
|
||||
end
|
||||
|
||||
subgraph "Infrastructure"
|
||||
Qdrant[(Qdrant<br/>Vector Database)]
|
||||
Ollama[Ollama<br/>Embedding Service]
|
||||
NC[Nextcloud<br/>Notes API, CalDAV, etc.]
|
||||
end
|
||||
|
||||
Client <-->|MCP Protocol| MCP
|
||||
Scanner -->|Fetch Changes| NC
|
||||
Scanner -->|Enqueue Documents| Queue
|
||||
Queue -->|Process Batch| Processor
|
||||
Processor -->|Generate Embeddings| Ollama
|
||||
Processor -->|Store Vectors| Qdrant
|
||||
MCP -->|Search Queries| Qdrant
|
||||
MCP -->|Verify Access| NC
|
||||
```
|
||||
|
||||
**Component Roles:**
|
||||
|
||||
- **MCP Server**: Exposes semantic search tools (`nc_semantic_search`, `nc_semantic_search_answer`, `nc_get_vector_sync_status`)
|
||||
- **Background Scanner**: Discovers changed documents every hour using ETag-based change detection
|
||||
- **Document Queue**: Holds pending documents for embedding generation
|
||||
- **Embedding Processors**: Generate vector embeddings via Ollama (concurrent workers)
|
||||
- **Qdrant Vector Database**: Stores document vectors with metadata and user_id filtering
|
||||
- **Ollama Embedding Service**: Converts text to 768-dimensional vectors (default: `nomic-embed-text` model)
|
||||
- **Nextcloud APIs**: Source of truth for documents and access control verification
|
||||
|
||||
## How It Works: Background Synchronization
|
||||
|
||||
Background synchronization runs automatically when `VECTOR_SYNC_ENABLED=true`, discovering changes and indexing documents without user intervention.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Timer
|
||||
participant Scanner
|
||||
participant NC as Nextcloud API
|
||||
participant Queue
|
||||
participant Processor
|
||||
participant Ollama
|
||||
participant Qdrant
|
||||
|
||||
Timer->>Scanner: Trigger (hourly)
|
||||
Scanner->>NC: Fetch all notes<br/>(Notes API)
|
||||
NC-->>Scanner: Notes with ETags
|
||||
Scanner->>Qdrant: Check indexed documents
|
||||
Qdrant-->>Scanner: Existing ETags
|
||||
Scanner->>Scanner: Identify changes<br/>(new/modified/deleted)
|
||||
Scanner->>Queue: Enqueue changed docs
|
||||
|
||||
loop Continuous Processing
|
||||
Processor->>Queue: Fetch batch
|
||||
Queue-->>Processor: Documents
|
||||
Processor->>Ollama: Generate embeddings
|
||||
Ollama-->>Processor: 768-dim vectors
|
||||
Processor->>Qdrant: Upsert vectors<br/>(with user_id, doc_type)
|
||||
end
|
||||
```
|
||||
|
||||
### Scanner Behavior
|
||||
|
||||
**Hourly Trigger:**
|
||||
- Runs every hour (configurable)
|
||||
- Fetches all notes from Nextcloud Notes API
|
||||
- Compares ETags with Qdrant's indexed state
|
||||
- Enqueues new/modified documents
|
||||
|
||||
**Change Detection:**
|
||||
- **New documents**: No entry in Qdrant → enqueue for indexing
|
||||
- **Modified documents**: ETag mismatch → enqueue for re-indexing
|
||||
- **Deleted documents**: In Qdrant but not in Nextcloud → delete from Qdrant
|
||||
|
||||
**Multi-App Plugin Architecture:**
|
||||
```python
|
||||
# Each app implements DocumentScanner interface
|
||||
class NotesScanner(DocumentScanner):
|
||||
async def scan(self) -> list[Document]:
|
||||
# Fetch notes, detect changes, return documents
|
||||
```
|
||||
|
||||
Currently only `NotesScanner` is implemented. Future: `CalendarScanner`, `DeckScanner`, `FilesScanner`, etc.
|
||||
|
||||
### Queue Processing
|
||||
|
||||
**Document Queue:**
|
||||
- In-memory FIFO queue (not persistent across restarts)
|
||||
- Holds documents pending embedding generation
|
||||
- Batch processing for efficiency
|
||||
|
||||
**Processor Pool:**
|
||||
- Concurrent workers using `anyio.TaskGroup`
|
||||
- Process documents in parallel (default: 4 workers)
|
||||
- Each worker: fetch document → generate embedding → store in Qdrant
|
||||
|
||||
**Backpressure Handling:**
|
||||
- Queue size limits prevent memory exhaustion
|
||||
- Slow consumers (Ollama) naturally pace the system
|
||||
|
||||
### Vector Storage
|
||||
|
||||
**Qdrant Collection Schema:**
|
||||
```
|
||||
{
|
||||
"id": "note_123",
|
||||
"vector": [768 dimensions],
|
||||
"payload": {
|
||||
"user_id": "alice",
|
||||
"doc_type": "note",
|
||||
"doc_id": "123",
|
||||
"title": "Machine Learning Notes",
|
||||
"content": "Neural networks are...",
|
||||
"etag": "abc123",
|
||||
"last_modified": "2025-01-15T10:30:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Key Fields:**
|
||||
- `user_id`: Multi-tenancy filtering (each user's vectors isolated)
|
||||
- `doc_type`: App identifier ("note", "event", "card", etc.)
|
||||
- `etag`: Change detection for incremental updates
|
||||
- `chunk_index`: Position of this chunk within the document (0-indexed)
|
||||
- `total_chunks`: Total number of chunks for this document
|
||||
- `excerpt`: First 200 characters of chunk (for display)
|
||||
|
||||
### Document Chunking Strategy
|
||||
|
||||
Documents are chunked before embedding to handle content larger than the embedding model's context window and to improve search precision.
|
||||
|
||||
**Configuration:**
|
||||
```dotenv
|
||||
DOCUMENT_CHUNK_SIZE=512 # Words per chunk (default)
|
||||
DOCUMENT_CHUNK_OVERLAP=50 # Overlapping words between chunks (default)
|
||||
```
|
||||
|
||||
**Chunking Process:**
|
||||
1. **Text combination**: Document title + content (e.g., `"Note Title\n\nNote content..."`)
|
||||
2. **Word-based splitting**: Simple whitespace tokenization
|
||||
3. **Sliding window**: Create overlapping chunks
|
||||
4. **Individual embedding**: Each chunk gets its own vector
|
||||
5. **Separate storage**: Each chunk stored as distinct point in Qdrant
|
||||
|
||||
**Example:**
|
||||
```
|
||||
Document (1000 words):
|
||||
→ Chunk 0: words 0-511
|
||||
→ Chunk 1: words 462-973 (overlaps by 50 words)
|
||||
→ Chunk 2: words 924-999 (last chunk, partial)
|
||||
|
||||
Each chunk stored as separate vector with metadata:
|
||||
- chunk_index: 0, 1, 2
|
||||
- total_chunks: 3
|
||||
- excerpt: First 200 chars of each chunk
|
||||
```
|
||||
|
||||
**Search Behavior:**
|
||||
- **Vector search** operates on chunks (not whole documents)
|
||||
- **Deduplication** collapses multiple matching chunks from same document
|
||||
- **Best match** returns highest-scoring chunk's excerpt
|
||||
- **Access verification** still performed at document level
|
||||
|
||||
**Tuning Recommendations:**
|
||||
- **Small chunks (256-384 words)**: More precise, less context, more storage
|
||||
- **Large chunks (768-1024 words)**: More context, less precise, less storage
|
||||
- **Overlap (10-20% of chunk size)**: Preserves context across boundaries
|
||||
- **Match to embedding model**: Consider model's context window when sizing
|
||||
|
||||
**Important**: Changing chunk size requires re-embedding all documents. Use the collection naming strategy to manage different chunking configurations.
|
||||
|
||||
### Collection Naming and Model Switching
|
||||
|
||||
**Auto-generated collection names:**
|
||||
- **Format:** `{deployment-id}-{model-name}`
|
||||
- **Deployment ID:** `OTEL_SERVICE_NAME` (if configured) or `hostname` (fallback)
|
||||
- **Model name:** `OLLAMA_EMBEDDING_MODEL`
|
||||
- **Example:** `"my-mcp-server-nomic-embed-text"`, `"mcp-container-all-minilm"`
|
||||
|
||||
**Why model-based naming:**
|
||||
- Ensures each embedding model gets its own collection
|
||||
- Prevents dimension mismatches when switching models
|
||||
- Enables safe model experimentation (new model = new collection)
|
||||
- Supports multi-server deployments (different deployment IDs)
|
||||
|
||||
**Switching embedding models:**
|
||||
|
||||
Collections are **mutually exclusive** - vectors from one embedding model cannot be used with another. When you change the embedding model:
|
||||
|
||||
1. **New collection is created** with the new model's dimensions
|
||||
2. **Full re-embedding occurs** - scanner processes all documents again
|
||||
3. **Old collection remains** - can be deleted manually if no longer needed
|
||||
4. **Dimension validation** - server fails fast if collection dimension doesn't match model
|
||||
|
||||
**Example workflow:**
|
||||
```bash
|
||||
# Start with nomic-embed-text (768 dimensions)
|
||||
OLLAMA_EMBEDDING_MODEL=nomic-embed-text
|
||||
# Collection: "my-server-nomic-embed-text"
|
||||
# → Scanner indexes 1000 notes → 1000 vectors in collection
|
||||
|
||||
# Switch to all-minilm (384 dimensions)
|
||||
OLLAMA_EMBEDDING_MODEL=all-minilm
|
||||
# Collection: "my-server-all-minilm"
|
||||
# → Scanner detects 0 indexed documents → re-embeds 1000 notes
|
||||
# → Old collection "my-server-nomic-embed-text" still exists in Qdrant
|
||||
```
|
||||
|
||||
**Re-embedding performance:**
|
||||
- CPU-only: 1-5 notes/second
|
||||
- With GPU: 50-200 notes/second
|
||||
- 1000 notes: 3-16 minutes (CPU) or 5-20 seconds (GPU)
|
||||
|
||||
**Multi-server deployments:**
|
||||
|
||||
Multiple MCP servers can share one Qdrant instance safely:
|
||||
|
||||
```bash
|
||||
# Server 1 (Production)
|
||||
OTEL_SERVICE_NAME=mcp-prod
|
||||
OLLAMA_EMBEDDING_MODEL=nomic-embed-text
|
||||
# → Collection: "mcp-prod-nomic-embed-text"
|
||||
|
||||
# Server 2 (Staging with different model)
|
||||
OTEL_SERVICE_NAME=mcp-staging
|
||||
OLLAMA_EMBEDDING_MODEL=all-minilm
|
||||
# → Collection: "mcp-staging-all-minilm"
|
||||
```
|
||||
|
||||
Each deployment gets its own collection - no naming collisions or dimension conflicts.
|
||||
|
||||
## How It Works: Semantic Search
|
||||
|
||||
Semantic search converts user queries into vectors and finds similar documents using cosine similarity.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User
|
||||
participant MCP as MCP Server
|
||||
participant Ollama
|
||||
participant Qdrant
|
||||
participant NC as Nextcloud API
|
||||
|
||||
User->>MCP: nc_semantic_search("machine learning")
|
||||
MCP->>MCP: Check OAuth scope<br/>(semantic:read)
|
||||
MCP->>Ollama: Generate query embedding
|
||||
Ollama-->>MCP: Query vector (768-dim)
|
||||
MCP->>Qdrant: Search similar vectors<br/>(filter: user_id=alice)
|
||||
Qdrant-->>MCP: Top K results<br/>(with similarity scores)
|
||||
|
||||
loop For each result
|
||||
MCP->>NC: Verify access<br/>(fetch note by ID)
|
||||
alt Access granted
|
||||
NC-->>MCP: Note metadata
|
||||
else Access denied (404/401)
|
||||
MCP->>MCP: Filter out result
|
||||
end
|
||||
end
|
||||
|
||||
MCP-->>User: Search results<br/>(with scores, excerpts)
|
||||
```
|
||||
|
||||
### Dual-Phase Authorization
|
||||
|
||||
**Phase 1: OAuth Scope Check**
|
||||
- Verify user has `semantic:read` scope
|
||||
- Rejects unauthorized users immediately
|
||||
|
||||
**Phase 2: Per-Document Verification**
|
||||
- For each search result, fetch document via app API (Notes, Calendar, etc.)
|
||||
- If fetch succeeds (200 OK), user has access
|
||||
- If fetch fails (404 Not Found, 401 Unauthorized), filter out result
|
||||
- **Security**: Prevents information leakage from vector search alone
|
||||
|
||||
**Rationale:**
|
||||
- Vector database doesn't know about sharing, permissions changes, or deleted documents
|
||||
- App APIs are source of truth for access control
|
||||
- Verification ensures users only see documents they can access
|
||||
|
||||
### Search Flow
|
||||
|
||||
1. **Query Embedding**: Convert user query to 768-dimensional vector via Ollama
|
||||
2. **Vector Search**: Find top K similar vectors in Qdrant (cosine similarity)
|
||||
3. **User Filtering**: Qdrant pre-filters by `user_id` (multi-tenancy)
|
||||
4. **Access Verification**: Fetch each document via app API to verify current access
|
||||
5. **Result Ranking**: Return results sorted by similarity score
|
||||
6. **Response**: Include document excerpts, metadata, and similarity scores
|
||||
|
||||
### Performance
|
||||
|
||||
- **Query latency**: 50-200ms typical (embedding + vector search + verification)
|
||||
- **Accuracy**: Depends on embedding model quality (`nomic-embed-text` recommended)
|
||||
- **Scalability**: Qdrant handles millions of vectors efficiently
|
||||
|
||||
## How It Works: RAG with MCP Sampling (Optional)
|
||||
|
||||
The `nc_semantic_search_answer` tool generates AI-powered answers with citations using **MCP sampling** - requesting the MCP client's LLM to generate text.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User
|
||||
participant MCP as MCP Server
|
||||
participant Client as MCP Client<br/>(Claude Desktop)
|
||||
participant LLM as Client's LLM<br/>(Claude, GPT, etc.)
|
||||
|
||||
User->>MCP: nc_semantic_search_answer("What are my Q1 goals?")
|
||||
MCP->>MCP: Semantic search<br/>(find relevant notes)
|
||||
MCP->>MCP: Construct prompt<br/>(query + documents + instructions)
|
||||
MCP->>Client: Sampling request<br/>(MCP Protocol)
|
||||
Client->>User: Prompt for approval<br/>(optional, client-controlled)
|
||||
User-->>Client: Approve
|
||||
Client->>LLM: Generate answer<br/>(with context)
|
||||
LLM-->>Client: Answer with citations
|
||||
Client-->>MCP: Sampling response
|
||||
MCP-->>User: Generated answer<br/>(with source documents)
|
||||
```
|
||||
|
||||
### MCP Sampling Architecture
|
||||
|
||||
**Why MCP Sampling?**
|
||||
- **No server-side LLM**: MCP server has no API keys, doesn't call LLMs directly
|
||||
- **Client controls everything**: Which model, who pays, user approval prompts
|
||||
- **Privacy**: Documents stay with the client's LLM provider, not a third-party
|
||||
- **Flexibility**: Works with any MCP client that supports sampling (Claude Desktop, future clients)
|
||||
|
||||
**Prompt Construction:**
|
||||
```
|
||||
User Query: {query}
|
||||
|
||||
Relevant Documents:
|
||||
1. Document: {title} (Note)
|
||||
Content: {excerpt}
|
||||
|
||||
2. Document: {title} (Note)
|
||||
Content: {excerpt}
|
||||
|
||||
Instructions:
|
||||
- Provide a comprehensive answer to the user's query
|
||||
- Use the documents above as context
|
||||
- Include citations: "According to Document 1 (title)..."
|
||||
- If documents don't contain enough information, say so
|
||||
```
|
||||
|
||||
**Graceful Fallback:**
|
||||
```python
|
||||
try:
|
||||
result = await ctx.session.create_message(...)
|
||||
return answer_with_citations
|
||||
except Exception as e:
|
||||
# Fallback: Return documents without generated answer
|
||||
return SearchResponse(
|
||||
generated_answer=f"[Sampling unavailable: {e}]",
|
||||
sources=search_results
|
||||
)
|
||||
```
|
||||
|
||||
**Client Support:**
|
||||
- **Requires**: MCP client with sampling capability
|
||||
- **Known support**: Claude Desktop (as of Claude 3.5+)
|
||||
- **Graceful degradation**: Returns raw documents if sampling unavailable
|
||||
|
||||
## Authentication & Security
|
||||
|
||||
### OAuth Scopes
|
||||
|
||||
**`semantic:read`** - Search permission
|
||||
- Allows using `nc_semantic_search` and `nc_semantic_search_answer` tools
|
||||
- Does NOT grant access to documents (verified via app APIs)
|
||||
- Required for any semantic search operation
|
||||
|
||||
**`semantic:write`** - Sync control permission
|
||||
- Allows enabling/disabling background sync (`provision_vector_sync`, `deprovision_vector_sync`)
|
||||
- Controls whether user's documents are indexed
|
||||
- Currently not implemented in OAuth mode (BasicAuth only)
|
||||
|
||||
### Dual-Phase Authorization Pattern
|
||||
|
||||
**Phase 1: Scope Check** (semantic:read)
|
||||
- Verifies user authorized to search
|
||||
- Prevents unauthorized vector database access
|
||||
|
||||
**Phase 2: Document Verification** (app-specific APIs)
|
||||
- For each search result, fetch via Notes API, CalDAV, etc.
|
||||
- If user can fetch → include in results
|
||||
- If user cannot fetch (404/401) → filter out
|
||||
- **Security**: Vector search cannot leak documents user shouldn't see
|
||||
|
||||
**Example Scenario:**
|
||||
1. Alice creates note "Secret Project X"
|
||||
2. Background sync indexes note with `user_id=alice`
|
||||
3. Bob searches for "project"
|
||||
4. Vector search finds "Secret Project X" (vector similarity)
|
||||
5. Qdrant filters by `user_id=bob` → no match (Alice's note excluded)
|
||||
6. Even if Bob somehow got the doc_id, Phase 2 verification would fail (404 Not Found)
|
||||
|
||||
### Offline Access for Background Sync
|
||||
|
||||
**Why needed:**
|
||||
- Background scanner runs hourly without user interaction
|
||||
- Requires valid access tokens to fetch documents from Nextcloud APIs
|
||||
- User's session token expires after hours/days
|
||||
|
||||
**OAuth Mode (ADR-004 Flow 2):**
|
||||
- User explicitly provisions offline access via `provision_nextcloud_access` tool
|
||||
- Server requests `offline_access` scope → receives refresh token
|
||||
- Refresh token stored securely (database, encrypted)
|
||||
- Background sync uses refresh tokens to obtain access tokens
|
||||
|
||||
**BasicAuth Mode:**
|
||||
- Username/password stored in environment variables
|
||||
- Always available for background operations
|
||||
- Simpler but less secure (credentials never expire)
|
||||
|
||||
## Deployment Modes
|
||||
|
||||
### Authentication Modes
|
||||
|
||||
| Mode | Security | Offline Access | Background Sync | Best For |
|
||||
|------|----------|----------------|-----------------|----------|
|
||||
| **BasicAuth** | Lower (credentials in env) | Always available | ✅ Works immediately | Single-user, development, testing |
|
||||
| **OAuth** | Higher (tokens, scopes) | User must provision | ⚠️ Not yet implemented | Multi-user, production |
|
||||
|
||||
**BasicAuth:**
|
||||
- Set `NEXTCLOUD_USERNAME` and `NEXTCLOUD_PASSWORD`
|
||||
- Background sync works immediately when `VECTOR_SYNC_ENABLED=true`
|
||||
- Credentials stored in `.env` file (secure server access required)
|
||||
|
||||
**OAuth:**
|
||||
- Client authenticates with `semantic:read` scope
|
||||
- User must explicitly provision offline access (future: `provision_vector_sync` tool)
|
||||
- Background sync only works for users who provisioned access
|
||||
- More secure: tokens expire, user controls access
|
||||
|
||||
### Qdrant Deployment Modes
|
||||
|
||||
| Mode | Configuration | Persistence | Scalability | Best For |
|
||||
|------|---------------|-------------|-------------|----------|
|
||||
| **In-Memory** (default) | `QDRANT_LOCATION=:memory:` | ❌ Lost on restart | Single instance | Testing, development |
|
||||
| **Persistent Local** | `QDRANT_LOCATION=/data/qdrant` | ✅ Survives restarts | Single instance | Small deployments |
|
||||
| **Network** | `QDRANT_URL=http://qdrant:6333` | ✅ Dedicated service | ✅ Horizontal scaling | Production |
|
||||
|
||||
**In-Memory Mode:**
|
||||
```bash
|
||||
VECTOR_SYNC_ENABLED=true
|
||||
# QDRANT_LOCATION not set → defaults to :memory:
|
||||
```
|
||||
- Fastest startup
|
||||
- No disk I/O
|
||||
- **Warning**: All vectors lost when server restarts (must re-index)
|
||||
|
||||
**Persistent Local Mode:**
|
||||
```bash
|
||||
VECTOR_SYNC_ENABLED=true
|
||||
QDRANT_LOCATION=/var/lib/qdrant
|
||||
```
|
||||
- Vectors survive restarts
|
||||
- Single server only (no distributed setup)
|
||||
- Disk I/O for durability
|
||||
|
||||
**Network Mode (Recommended for Production):**
|
||||
```bash
|
||||
VECTOR_SYNC_ENABLED=true
|
||||
QDRANT_URL=http://qdrant:6333
|
||||
QDRANT_API_KEY=secret # optional
|
||||
```
|
||||
- Dedicated Qdrant service (Docker, Kubernetes)
|
||||
- Horizontal scaling (multiple MCP servers → one Qdrant)
|
||||
- High availability options
|
||||
|
||||
### Embedding Service Options
|
||||
|
||||
| Service | Configuration | Cost | Performance | Best For |
|
||||
|---------|---------------|------|-------------|----------|
|
||||
| **Ollama** (recommended) | `OLLAMA_BASE_URL=http://ollama:11434` | Free (self-hosted) | Fast (local GPU) | Production, development |
|
||||
| **OpenAI** (future) | `OPENAI_API_KEY=sk-...` | Paid (API) | Fast (cloud) | Cloud deployments |
|
||||
| **Fallback** | No config | Free | Slow (random) | Testing only (not production) |
|
||||
|
||||
**Ollama Setup (Recommended):**
|
||||
```bash
|
||||
# docker-compose.yml
|
||||
services:
|
||||
ollama:
|
||||
image: ollama/ollama
|
||||
volumes:
|
||||
- ollama-data:/root/.ollama
|
||||
ports:
|
||||
- "11434:11434"
|
||||
|
||||
# Pull embedding model
|
||||
docker compose exec ollama ollama pull nomic-embed-text
|
||||
```
|
||||
|
||||
**Environment Configuration:**
|
||||
```bash
|
||||
OLLAMA_BASE_URL=http://ollama:11434
|
||||
OLLAMA_EMBEDDING_MODEL=nomic-embed-text # 768-dimensional vectors
|
||||
```
|
||||
|
||||
**Model Options:**
|
||||
- `nomic-embed-text` (default): 768-dim, optimized for semantic search
|
||||
- `all-minilm`: Smaller, faster, slightly less accurate
|
||||
- `mxbai-embed-large`: Larger, more accurate, slower
|
||||
|
||||
## Configuration Overview
|
||||
|
||||
### Key Environment Variables
|
||||
|
||||
**Enable Semantic Search:**
|
||||
```bash
|
||||
VECTOR_SYNC_ENABLED=true # Default: false (opt-in)
|
||||
```
|
||||
|
||||
**Qdrant Vector Database:**
|
||||
```bash
|
||||
# In-memory mode (default if VECTOR_SYNC_ENABLED=true)
|
||||
# QDRANT_LOCATION not set → uses :memory:
|
||||
|
||||
# Persistent local mode
|
||||
QDRANT_LOCATION=/var/lib/qdrant
|
||||
|
||||
# Network mode (production)
|
||||
QDRANT_URL=http://qdrant:6333
|
||||
QDRANT_API_KEY=secret # optional
|
||||
```
|
||||
|
||||
**Ollama Embedding Service:**
|
||||
```bash
|
||||
OLLAMA_BASE_URL=http://ollama:11434
|
||||
OLLAMA_EMBEDDING_MODEL=nomic-embed-text # Default
|
||||
```
|
||||
|
||||
**Scanner Configuration:**
|
||||
```bash
|
||||
VECTOR_SYNC_INTERVAL=3600 # Scan interval in seconds (default: 1 hour)
|
||||
```
|
||||
|
||||
### Resource Requirements
|
||||
|
||||
**Qdrant:**
|
||||
- **Memory**: ~100-200 MB base + ~1 KB per vector (1M vectors ≈ 1 GB)
|
||||
- **Disk**: Persistent mode only, ~200 bytes per vector
|
||||
- **CPU**: Low (indexing) to moderate (search)
|
||||
|
||||
**Ollama:**
|
||||
- **Memory**: 2-4 GB for `nomic-embed-text` model
|
||||
- **CPU**: High during embedding generation, idle otherwise
|
||||
- **GPU**: Optional but recommended (10-100x faster)
|
||||
|
||||
**MCP Server:**
|
||||
- **Memory**: +50-100 MB for background sync workers
|
||||
- **CPU**: Moderate during scanning/processing, low otherwise
|
||||
|
||||
### Trade-offs
|
||||
|
||||
| Consideration | In-Memory Qdrant | Persistent Qdrant | Network Qdrant |
|
||||
|---------------|------------------|-------------------|----------------|
|
||||
| Setup complexity | ✅ Minimal | ✅ Easy | ⚠️ Requires separate service |
|
||||
| Durability | ❌ Lost on restart | ✅ Survives restarts | ✅ Survives restarts |
|
||||
| Scalability | ❌ Single instance | ❌ Single instance | ✅ Horizontal scaling |
|
||||
| Performance | ✅ Fastest | ✅ Fast | ⚠️ Network latency |
|
||||
|
||||
## Operational Behavior
|
||||
|
||||
### What Happens When VECTOR_SYNC_ENABLED=true
|
||||
|
||||
**Immediate (Server Startup):**
|
||||
1. MCP server connects to Qdrant (creates collection if needed)
|
||||
2. MCP server connects to Ollama (verifies embedding model available)
|
||||
3. Background scanner starts (schedules hourly runs)
|
||||
4. Document queue and processors initialize
|
||||
|
||||
**First Scan (Within 1 hour):**
|
||||
1. Scanner fetches all notes from Nextcloud
|
||||
2. Compares with Qdrant (likely empty on first run)
|
||||
3. Enqueues all notes for indexing
|
||||
4. Processors generate embeddings (may take minutes for large note collections)
|
||||
5. Vectors stored in Qdrant with user_id filtering
|
||||
|
||||
**Hourly Thereafter:**
|
||||
1. Scanner fetches all notes
|
||||
2. Identifies new/modified/deleted notes (ETag comparison)
|
||||
3. Enqueues changes only
|
||||
4. Incremental updates processed
|
||||
|
||||
### Performance Expectations
|
||||
|
||||
**Embedding Generation:**
|
||||
- **Without GPU**: 1-5 notes/second (CPU-bound)
|
||||
- **With GPU**: 50-200 notes/second (highly parallel)
|
||||
- **Initial indexing**: 100 notes ≈ 20-100 seconds (CPU), 1-2 seconds (GPU)
|
||||
|
||||
**Search Query:**
|
||||
- **Embedding generation**: 50-100ms
|
||||
- **Vector search**: 10-50ms (depends on collection size)
|
||||
- **Access verification**: 20-100ms per document (Nextcloud API calls)
|
||||
- **Total latency**: 100-300ms typical
|
||||
|
||||
**Resource Usage:**
|
||||
- **Idle**: Minimal (background scanner sleeps)
|
||||
- **Scanning**: Moderate CPU (ETag checks, API calls)
|
||||
- **Processing**: High CPU/GPU (embedding generation)
|
||||
- **Searching**: Low to moderate (depends on query frequency)
|
||||
|
||||
### Background Sync Behavior
|
||||
|
||||
**Scanner Triggers:**
|
||||
- Hourly (configurable via `VECTOR_SYNC_INTERVAL`)
|
||||
- Manual trigger via `nc_trigger_vector_sync` (future)
|
||||
|
||||
**Queue Processing:**
|
||||
- Continuous (workers always running)
|
||||
- Batch processing (fetch 10 documents at a time)
|
||||
- Concurrent workers (4 by default)
|
||||
|
||||
**Error Handling:**
|
||||
- Individual document failures logged but don't stop scanning
|
||||
- Retries for transient errors (network timeouts, rate limits)
|
||||
- Failed documents skipped, re-attempted on next scan
|
||||
|
||||
**What Gets Indexed:**
|
||||
- **Notes**: All notes accessible to the authenticated user
|
||||
- **Future**: Calendar events, tasks, deck cards, files with text extraction, contacts
|
||||
|
||||
## Monitoring & Observability
|
||||
|
||||
### MCP Tools
|
||||
|
||||
**`nc_get_vector_sync_status`** - Check sync status
|
||||
```python
|
||||
{
|
||||
"total_documents": 1234,
|
||||
"indexed_documents": 1200,
|
||||
"pending_documents": 34,
|
||||
"sync_enabled": true,
|
||||
"last_scan": "2025-01-15T14:30:00Z",
|
||||
"status": "syncing" # idle | syncing | error
|
||||
}
|
||||
```
|
||||
|
||||
**Interpreting Status:**
|
||||
- `idle`: No pending work, last scan completed successfully
|
||||
- `syncing`: Currently processing documents
|
||||
- `error`: Last scan failed (check logs)
|
||||
|
||||
### Logs to Check
|
||||
|
||||
**Scanner Logs:**
|
||||
```
|
||||
[INFO] Vector sync scanner started (interval: 3600s)
|
||||
[INFO] Scanning notes: found 150 documents
|
||||
[INFO] Changes detected: 5 new, 2 modified, 1 deleted
|
||||
[INFO] Enqueued 7 documents for processing
|
||||
```
|
||||
|
||||
**Processor Logs:**
|
||||
```
|
||||
[INFO] Processing document: note_123
|
||||
[DEBUG] Generated embedding (768 dimensions)
|
||||
[INFO] Stored vector in Qdrant: note_123
|
||||
```
|
||||
|
||||
**Error Logs:**
|
||||
```
|
||||
[ERROR] Failed to generate embedding for note_123: Connection timeout
|
||||
[WARN] Qdrant connection lost, retrying...
|
||||
[ERROR] Ollama embedding failed: Model not found
|
||||
```
|
||||
|
||||
**Log Locations:**
|
||||
- **Docker**: `docker compose logs mcp`
|
||||
- **Local**: stdout (redirect to file if needed)
|
||||
- **Kubernetes**: `kubectl logs -f deployment/nextcloud-mcp-server`
|
||||
|
||||
### Metrics to Monitor
|
||||
|
||||
**Indexing Progress:**
|
||||
- Total documents vs indexed documents
|
||||
- Pending queue size
|
||||
- Processing rate (docs/second)
|
||||
|
||||
**Search Performance:**
|
||||
- Query latency (p50, p95, p99)
|
||||
- Results per query
|
||||
- Verification overhead (API calls per query)
|
||||
|
||||
**Resource Usage:**
|
||||
- Qdrant memory/disk usage
|
||||
- Ollama CPU/GPU usage
|
||||
- MCP server memory
|
||||
|
||||
For detailed observability setup, see [docs/observability.md](observability.md).
|
||||
|
||||
## Troubleshooting from Architecture Perspective
|
||||
|
||||
### Documents Not Appearing in Search
|
||||
|
||||
**Diagnosis Flow:**
|
||||
1. Check sync status: `nc_get_vector_sync_status`
|
||||
- `sync_enabled: false` → Enable with `VECTOR_SYNC_ENABLED=true`
|
||||
- `status: error` → Check scanner logs for failures
|
||||
2. Check queue size:
|
||||
- `pending_documents > 0` → Processing in progress, wait
|
||||
- `pending_documents == 0` but `indexed_documents` low → Scan hasn't run yet (wait up to 1 hour)
|
||||
3. Check Qdrant:
|
||||
- Connection errors in logs → Verify `QDRANT_URL` or `QDRANT_LOCATION`
|
||||
- Collection empty → First scan hasn't completed
|
||||
4. Check Ollama:
|
||||
- Embedding errors in logs → Verify `OLLAMA_BASE_URL`
|
||||
- Model not found → Pull model: `ollama pull nomic-embed-text`
|
||||
|
||||
**Common Causes:**
|
||||
- Sync disabled (default): Enable `VECTOR_SYNC_ENABLED=true`
|
||||
- Ollama not running: Start Ollama service
|
||||
- Qdrant not accessible: Check network/URL
|
||||
- First scan in progress: Wait up to 1 hour + processing time
|
||||
|
||||
### Slow Search Performance
|
||||
|
||||
**Diagnosis:**
|
||||
1. **Query embedding slow (>500ms)**:
|
||||
- Ollama overloaded or CPU-bound
|
||||
- Solution: Use GPU, upgrade CPU, or reduce concurrent requests
|
||||
2. **Vector search slow (>200ms)**:
|
||||
- Large collection (millions of vectors)
|
||||
- Solution: Use network Qdrant with SSDs, add indexing
|
||||
3. **Verification slow (>500ms)**:
|
||||
- Many results to verify (10+ documents)
|
||||
- Nextcloud API slow or overloaded
|
||||
- Solution: Reduce `limit` parameter, optimize Nextcloud
|
||||
|
||||
**Performance Tuning:**
|
||||
- Reduce search `limit` (default: 10 results)
|
||||
- Use network Qdrant for large collections
|
||||
- Enable Ollama GPU acceleration
|
||||
- Check Nextcloud API response times
|
||||
|
||||
### Background Sync Stopped
|
||||
|
||||
**Diagnosis:**
|
||||
1. Check logs for errors:
|
||||
- Authentication failures (401/403) → Token expired (OAuth) or credentials invalid (BasicAuth)
|
||||
- Connection timeouts → Network issues with Nextcloud/Qdrant/Ollama
|
||||
- Rate limiting (429) → Reduce scan frequency
|
||||
2. Check `nc_get_vector_sync_status`:
|
||||
- `status: error` → See logs for details
|
||||
- `last_scan` timestamp old (>2 hours) → Scanner may have crashed
|
||||
3. Verify services:
|
||||
- Qdrant accessible: `curl http://qdrant:6333/`
|
||||
- Ollama accessible: `curl http://ollama:11434/api/tags`
|
||||
- Nextcloud accessible: Check API health
|
||||
|
||||
**OAuth Mode (Future):**
|
||||
- Offline access token expired → Re-provision via `provision_vector_sync`
|
||||
- User deprovisioned access → Sync stops intentionally
|
||||
|
||||
### Out of Memory
|
||||
|
||||
**Diagnosis:**
|
||||
1. Check Qdrant mode:
|
||||
- In-memory mode with large collection → Switch to persistent or network mode
|
||||
2. Check embedding batch size:
|
||||
- Too many documents processed simultaneously → Reduce worker count
|
||||
3. Check Ollama memory:
|
||||
- Large models loaded → Use smaller embedding model
|
||||
|
||||
**Solutions:**
|
||||
- Use persistent or network Qdrant (frees server memory)
|
||||
- Reduce concurrent processor workers
|
||||
- Use smaller embedding model (`all-minilm` instead of `nomic-embed-text`)
|
||||
- Increase server memory allocation
|
||||
|
||||
## Limitations & Future Work
|
||||
|
||||
### Current Limitations
|
||||
|
||||
1. **Notes App Only**
|
||||
- Architecture supports multiple apps (plugin system ready)
|
||||
- Only `NotesScanner` and `NotesProcessor` implemented
|
||||
- Future: Calendar, Deck, Files, Contacts
|
||||
|
||||
2. **MCP Sampling Support**
|
||||
- `nc_semantic_search_answer` requires client sampling capability
|
||||
- Not all MCP clients support sampling yet
|
||||
- Graceful fallback: Returns documents without generated answer
|
||||
|
||||
3. **OAuth Background Sync**
|
||||
- User-controlled background jobs not yet implemented
|
||||
- Currently works in BasicAuth mode only
|
||||
- Future: Users opt-in via `provision_vector_sync` tool
|
||||
|
||||
4. **No Incremental Updates**
|
||||
- Document changes trigger full re-embedding
|
||||
- Cannot update just modified paragraphs
|
||||
- Future: Paragraph-level chunking and incremental updates
|
||||
|
||||
5. **No Query Caching**
|
||||
- Each search generates new query embedding
|
||||
- Repeated queries re-search Qdrant
|
||||
- Future: Cache recent query embeddings and results
|
||||
|
||||
6. **Single Embedding Model**
|
||||
- Uses one model for all documents and queries
|
||||
- Cannot customize per app or user
|
||||
- Future: App-specific or user-selected models
|
||||
|
||||
### Future Enhancements
|
||||
|
||||
**Multi-App Support** (In Progress):
|
||||
- Scanner plugins for Calendar, Deck, Files, Contacts
|
||||
- Unified vector search across all apps
|
||||
- App-specific metadata in vector payloads
|
||||
|
||||
**User-Controlled Sync (OAuth Mode)**:
|
||||
- `provision_vector_sync` and `deprovision_vector_sync` tools
|
||||
- Per-user background job scheduling
|
||||
- User dashboard for sync status and controls
|
||||
|
||||
**Advanced Search Features**:
|
||||
- Hybrid search (vector + keyword combined)
|
||||
- Filtering by date range, app type, tags
|
||||
- Aggregations and faceted search
|
||||
- Search result explanations (why this matched)
|
||||
|
||||
**Performance Optimizations**:
|
||||
- Query caching for repeated searches
|
||||
- Incremental document updates (paragraph-level)
|
||||
- Batch query processing
|
||||
- Qdrant HNSW indexing tuning
|
||||
|
||||
**Embedding Improvements**:
|
||||
- Support for OpenAI embeddings (ada-002, text-embedding-3)
|
||||
- Multi-language embedding models
|
||||
- Fine-tuned models for Nextcloud content
|
||||
- Paragraph-level chunking for long documents
|
||||
|
||||
## References
|
||||
|
||||
### Architecture Decision Records (ADRs)
|
||||
|
||||
- **[ADR-003: Vector Database Semantic Search](ADR-003-vector-database-semantic-search.md)** - Qdrant selection rationale, embedding strategy, hybrid search (superseded by ADR-007 but technical decisions remain valid)
|
||||
- **[ADR-007: Background Vector Sync Job Management](ADR-007-background-vector-sync-job-management.md)** - Current implementation, Scanner-Queue-Processor architecture, plugin system
|
||||
- **[ADR-008: MCP Sampling for Semantic Search](ADR-008-mcp-sampling-for-semantic-search.md)** - RAG with MCP sampling, client-server separation, prompt construction
|
||||
- **[ADR-009: Semantic Search OAuth Scope](ADR-009-semantic-search-oauth-scope.md)** - OAuth scope model, dual-phase authorization, security rationale
|
||||
|
||||
### Configuration & Setup
|
||||
|
||||
- **[Configuration Guide](configuration.md)** - Environment variables, Qdrant setup, Ollama setup, detailed configuration options
|
||||
- **[Installation Guide](installation.md)** - Deployment options (Docker, Kubernetes, local)
|
||||
- **[Running the Server](running.md)** - Starting the server, transport options, testing
|
||||
|
||||
### Monitoring & Troubleshooting
|
||||
|
||||
- **[Observability Guide](observability.md)** - Logging, metrics, tracing, debugging
|
||||
- **[Troubleshooting](troubleshooting.md)** - General issues and solutions
|
||||
|
||||
### Related Documentation
|
||||
|
||||
- **[OAuth Architecture](oauth-architecture.md)** - OAuth flows, scopes, token management
|
||||
- **[Comparison with Context Agent](comparison-context-agent.md)** - When to use Nextcloud MCP Server vs Context Agent
|
||||
|
||||
---
|
||||
|
||||
**Questions or Issues?**
|
||||
- [Open an issue](https://github.com/cbcoutinho/nextcloud-mcp-server/issues)
|
||||
- [Contribute improvements](https://github.com/cbcoutinho/nextcloud-mcp-server/pulls)
|
||||
@@ -0,0 +1,12 @@
|
||||
# Tables App
|
||||
|
||||
### Tables Tools
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `nc_tables_list_tables` | List all tables available to the user |
|
||||
| `nc_tables_get_schema` | Get the schema/structure of a specific table including columns and views |
|
||||
| `nc_tables_read_table` | Read rows from a table with optional pagination |
|
||||
| `nc_tables_insert_row` | Insert a new row into a table |
|
||||
| `nc_tables_update_row` | Update an existing row in a table |
|
||||
| `nc_tables_delete_row` | Delete a row from a table |
|
||||
@@ -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
|
||||
@@ -0,0 +1,559 @@
|
||||
# Troubleshooting
|
||||
|
||||
This guide covers common issues and solutions for the Nextcloud MCP server.
|
||||
|
||||
> **OAuth-specific issues?** See the dedicated [OAuth Troubleshooting Guide](oauth-troubleshooting.md) for OAuth authentication problems, OIDC discovery issues, token validation failures, and more.
|
||||
|
||||
## OAuth Issues (Quick Reference)
|
||||
|
||||
### Issue: "OAuth mode requires NEXTCLOUD_HOST environment variable"
|
||||
|
||||
**Cause:** The `NEXTCLOUD_HOST` environment variable is not set or empty.
|
||||
|
||||
**Solution:**
|
||||
|
||||
```bash
|
||||
# Ensure NEXTCLOUD_HOST is set in your .env file
|
||||
echo "NEXTCLOUD_HOST=https://your.nextcloud.instance.com" >> .env
|
||||
|
||||
# Load environment variables
|
||||
export $(grep -v '^#' .env | xargs)
|
||||
|
||||
# Verify it's set
|
||||
echo $NEXTCLOUD_HOST
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Issue: "OAuth mode requires either client credentials OR dynamic client registration"
|
||||
|
||||
**Cause:** The required Nextcloud OIDC apps are either:
|
||||
1. Not installed (both `oidc` and `user_oidc` apps are required)
|
||||
2. Don't have dynamic client registration enabled
|
||||
3. Aren't providing a registration endpoint
|
||||
|
||||
**Solution:**
|
||||
|
||||
**Option 1: Enable dynamic client registration**
|
||||
|
||||
1. Verify **both** OIDC apps are installed:
|
||||
- Navigate to Nextcloud **Apps** → **Security**
|
||||
- Install **"OIDC"** (OIDC Identity Provider app) if not present
|
||||
- Install **"OpenID Connect user backend"** (user_oidc app) if not present
|
||||
|
||||
2. Enable dynamic client registration:
|
||||
- Go to **Settings** → **OIDC** (Administration)
|
||||
- Enable "Allow dynamic client registration"
|
||||
|
||||
3. Configure Bearer token validation:
|
||||
```bash
|
||||
# Required for user_oidc app to validate tokens
|
||||
php occ config:system:set user_oidc oidc_provider_bearer_validation --value=true --type=boolean
|
||||
```
|
||||
|
||||
3. Verify the registration endpoint exists:
|
||||
```bash
|
||||
curl https://your.nextcloud.instance.com/.well-known/openid-configuration | jq '.registration_endpoint'
|
||||
# Should output: "https://your.nextcloud.instance.com/apps/oidc/register"
|
||||
```
|
||||
|
||||
**Option 2: Provide pre-configured credentials**
|
||||
|
||||
Register a client and add credentials to `.env`:
|
||||
|
||||
```bash
|
||||
# On your Nextcloud server
|
||||
php occ oidc:create \
|
||||
--name="Nextcloud MCP Server" \
|
||||
--type=confidential \
|
||||
--redirect-uri="http://localhost:8000/oauth/callback"
|
||||
|
||||
# Add to .env
|
||||
echo "NEXTCLOUD_OIDC_CLIENT_ID=<from-output>" >> .env
|
||||
echo "NEXTCLOUD_OIDC_CLIENT_SECRET=<from-output>" >> .env
|
||||
```
|
||||
|
||||
See [OAuth Setup Guide](oauth-setup.md) for detailed instructions.
|
||||
|
||||
---
|
||||
|
||||
### Issue: "Stored client has expired"
|
||||
|
||||
**Cause:** Dynamically registered OAuth clients expire (default: 1 hour).
|
||||
|
||||
**Solution:**
|
||||
|
||||
**Option 1: Restart the server** (automatic re-registration)
|
||||
|
||||
```bash
|
||||
# Server checks credentials at startup and re-registers if expired
|
||||
uv run nextcloud-mcp-server --oauth
|
||||
```
|
||||
|
||||
**Option 2: Use pre-configured credentials** (recommended for production)
|
||||
|
||||
```bash
|
||||
# Register permanent client via Nextcloud CLI
|
||||
php occ oidc:create \
|
||||
--name="Nextcloud MCP Server" \
|
||||
--type=confidential \
|
||||
--redirect-uri="http://localhost:8000/oauth/callback"
|
||||
|
||||
# Add to .env
|
||||
NEXTCLOUD_OIDC_CLIENT_ID=<from-output>
|
||||
NEXTCLOUD_OIDC_CLIENT_SECRET=<from-output>
|
||||
```
|
||||
|
||||
**Option 3: Increase expiration time**
|
||||
|
||||
```bash
|
||||
# Via Nextcloud occ command (default: 3600 seconds)
|
||||
php occ config:app:set oidc expire_time --value "86400" # 24 hours
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Issue: "HTTP 401 Unauthorized" when calling Nextcloud APIs
|
||||
|
||||
**Cause:** OAuth Bearer tokens may not work with certain Nextcloud endpoints due to session handling in the CORS middleware.
|
||||
|
||||
**Background:** The `user_oidc` app's CORS middleware interferes with Bearer token authentication for non-OCS endpoints (like Notes API). This affects app-specific APIs but not OCS APIs.
|
||||
|
||||
**Solution:**
|
||||
|
||||
A patch for the `user_oidc` app is required to fix Bearer token support. See [oauth2-bearer-token-session-issue.md](oauth2-bearer-token-session-issue.md) for:
|
||||
- Detailed explanation of the issue
|
||||
- Patch to apply to the `user_oidc` app
|
||||
- Link to upstream pull request
|
||||
|
||||
**Affected endpoints:**
|
||||
- Notes API (`/apps/notes/api/`)
|
||||
- Other app-specific endpoints
|
||||
|
||||
**Unaffected endpoints:**
|
||||
- OCS APIs (`/ocs/v2.php/`)
|
||||
- Capabilities endpoint
|
||||
|
||||
---
|
||||
|
||||
### Issue: "Permission denied" or "Database is locked" when accessing OAuth client storage
|
||||
|
||||
**Cause:** The server cannot access the SQLite database for OAuth client credentials storage.
|
||||
|
||||
**Solution:**
|
||||
|
||||
```bash
|
||||
# Check database directory permissions
|
||||
ls -la data/
|
||||
|
||||
# Ensure directory is writable
|
||||
chmod 755 data/
|
||||
|
||||
# Check if database file exists and has correct permissions
|
||||
ls -la data/tokens.db
|
||||
chmod 644 data/tokens.db
|
||||
|
||||
# For Docker deployments, ensure volume is mounted correctly:
|
||||
# docker-compose.yml should have:
|
||||
# volumes:
|
||||
# - ./data:/app/data
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Issue: "OIDC discovery failed" or "Cannot reach OIDC discovery endpoint"
|
||||
|
||||
**Cause:** The server cannot reach the Nextcloud OIDC discovery endpoint.
|
||||
|
||||
**Solution:**
|
||||
|
||||
1. Verify the Nextcloud URL is correct:
|
||||
```bash
|
||||
echo $NEXTCLOUD_HOST
|
||||
# Should be the full URL: https://your.nextcloud.instance.com
|
||||
```
|
||||
|
||||
2. Test the discovery endpoint manually:
|
||||
```bash
|
||||
curl https://your.nextcloud.instance.com/.well-known/openid-configuration
|
||||
# Should return JSON with OIDC configuration
|
||||
```
|
||||
|
||||
3. Check network connectivity:
|
||||
```bash
|
||||
ping your.nextcloud.instance.com
|
||||
```
|
||||
|
||||
4. Verify **both** OIDC apps are installed and enabled in Nextcloud:
|
||||
- `oidc` - OIDC Identity Provider
|
||||
- `user_oidc` - OpenID Connect user backend
|
||||
|
||||
5. Check firewall rules if using Docker
|
||||
|
||||
---
|
||||
|
||||
### Switching Between OAuth and BasicAuth
|
||||
|
||||
#### To switch from BasicAuth to OAuth:
|
||||
|
||||
```bash
|
||||
# 1. Remove or comment out USERNAME/PASSWORD in .env
|
||||
sed -i 's/^NEXTCLOUD_USERNAME/#NEXTCLOUD_USERNAME/' .env
|
||||
sed -i 's/^NEXTCLOUD_PASSWORD/#NEXTCLOUD_PASSWORD/' .env
|
||||
|
||||
# 2. Ensure NEXTCLOUD_HOST is set
|
||||
grep NEXTCLOUD_HOST .env
|
||||
|
||||
# 3. Restart server with OAuth
|
||||
export $(grep -v '^#' .env | xargs)
|
||||
uv run nextcloud-mcp-server --oauth
|
||||
```
|
||||
|
||||
#### To switch from OAuth to BasicAuth:
|
||||
|
||||
```bash
|
||||
# 1. Add USERNAME/PASSWORD to .env
|
||||
echo "NEXTCLOUD_USERNAME=your-username" >> .env
|
||||
echo "NEXTCLOUD_PASSWORD=your-password" >> .env
|
||||
|
||||
# 2. Restart server (BasicAuth auto-detected, or use --no-oauth)
|
||||
export $(grep -v '^#' .env | xargs)
|
||||
uv run nextcloud-mcp-server --no-oauth
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### For More OAuth Help
|
||||
|
||||
See the dedicated **[OAuth Troubleshooting Guide](oauth-troubleshooting.md)** for:
|
||||
- Bearer token authentication failures
|
||||
- PKCE validation errors
|
||||
- Token validation issues
|
||||
- Client registration problems
|
||||
- Advanced OAuth debugging
|
||||
- And much more...
|
||||
|
||||
---
|
||||
|
||||
## Configuration Issues
|
||||
|
||||
### Issue: Environment variables not loaded
|
||||
|
||||
**Cause:** Environment variables from `.env` file are not loaded into the shell.
|
||||
|
||||
**Solution:**
|
||||
|
||||
**On Linux/macOS:**
|
||||
```bash
|
||||
# Load all variables from .env
|
||||
export $(grep -v '^#' .env | xargs)
|
||||
|
||||
# Verify variables are set
|
||||
env | grep NEXTCLOUD
|
||||
```
|
||||
|
||||
**On Windows (PowerShell):**
|
||||
```powershell
|
||||
# Load variables from .env
|
||||
Get-Content .env | ForEach-Object {
|
||||
if ($_ -match '^\s*([^#][^=]*)\s*=\s*(.*)$') {
|
||||
[Environment]::SetEnvironmentVariable($matches[1].Trim(), $matches[2].Trim(), "Process")
|
||||
}
|
||||
}
|
||||
|
||||
# Verify variables are set
|
||||
Get-ChildItem Env:NEXTCLOUD*
|
||||
```
|
||||
|
||||
**With Docker:**
|
||||
```bash
|
||||
# Docker automatically loads .env when using --env-file
|
||||
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
|
||||
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Issue: ".env file not found"
|
||||
|
||||
**Cause:** The `.env` file doesn't exist or is in the wrong location.
|
||||
|
||||
**Solution:**
|
||||
|
||||
```bash
|
||||
# Create .env from sample
|
||||
cp env.sample .env
|
||||
|
||||
# Edit with your Nextcloud details
|
||||
nano .env # or vim, code, etc.
|
||||
|
||||
# Ensure you're in the correct directory when running commands
|
||||
pwd # Should be in the project directory containing .env
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Issue: "Invalid Nextcloud credentials"
|
||||
|
||||
**Cause:** BasicAuth credentials are incorrect or the app password has been revoked.
|
||||
|
||||
**Solution:**
|
||||
|
||||
1. **Verify username:**
|
||||
```bash
|
||||
# Username should match your Nextcloud login
|
||||
echo $NEXTCLOUD_USERNAME
|
||||
```
|
||||
|
||||
2. **Generate a new app password:**
|
||||
- Log in to Nextcloud
|
||||
- Go to **Settings** → **Security**
|
||||
- Under "Devices & sessions", create a new app password
|
||||
- Update `.env` with the new password
|
||||
|
||||
3. **Test credentials manually:**
|
||||
```bash
|
||||
curl -u "$NEXTCLOUD_USERNAME:$NEXTCLOUD_PASSWORD" \
|
||||
"$NEXTCLOUD_HOST/ocs/v2.php/cloud/capabilities" \
|
||||
-H "OCS-APIRequest: true"
|
||||
# Should return XML with capabilities
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Server Issues
|
||||
|
||||
### Issue: "Address already in use" / Port conflict
|
||||
|
||||
**Cause:** Another process is using port 8000.
|
||||
|
||||
**Solution:**
|
||||
|
||||
**Option 1: Use a different port**
|
||||
```bash
|
||||
uv run nextcloud-mcp-server --port 8080
|
||||
```
|
||||
|
||||
**Option 2: Find and kill the process using the port**
|
||||
```bash
|
||||
# On Linux/macOS
|
||||
lsof -ti:8000 | xargs kill -9
|
||||
|
||||
# On Windows
|
||||
netstat -ano | findstr :8000
|
||||
taskkill /PID <pid> /F
|
||||
```
|
||||
|
||||
**Option 3: Stop other MCP server instances**
|
||||
```bash
|
||||
# Check for running instances
|
||||
ps aux | grep nextcloud-mcp-server
|
||||
|
||||
# Kill specific process
|
||||
kill <pid>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Issue: Server starts but can't connect
|
||||
|
||||
**Cause:** Server is bound to localhost only, or firewall is blocking connections.
|
||||
|
||||
**Solution:**
|
||||
|
||||
1. **Check server binding:**
|
||||
```bash
|
||||
# Bind to all interfaces to allow network access
|
||||
uv run nextcloud-mcp-server --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
2. **Test connectivity:**
|
||||
```bash
|
||||
# Test from same machine
|
||||
curl http://localhost:8000/health
|
||||
|
||||
# Test from network (if using --host 0.0.0.0)
|
||||
curl http://<server-ip>:8000/health
|
||||
```
|
||||
|
||||
3. **Check firewall:**
|
||||
```bash
|
||||
# Linux (ufw)
|
||||
sudo ufw allow 8000/tcp
|
||||
|
||||
# Linux (firewalld)
|
||||
sudo firewall-cmd --add-port=8000/tcp --permanent
|
||||
sudo firewall-cmd --reload
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Issue: Server crashes or restarts frequently
|
||||
|
||||
**Cause:** Various issues including memory limits, uncaught exceptions, or OAuth token expiration.
|
||||
|
||||
**Solution:**
|
||||
|
||||
1. **Check logs with debug level:**
|
||||
```bash
|
||||
uv run nextcloud-mcp-server --log-level debug
|
||||
```
|
||||
|
||||
2. **Monitor resource usage:**
|
||||
```bash
|
||||
# Check memory and CPU
|
||||
top -p $(pgrep -f nextcloud-mcp-server)
|
||||
```
|
||||
|
||||
3. **Use process manager for automatic restart:**
|
||||
```bash
|
||||
# With systemd (see Running guide for full config)
|
||||
sudo systemctl restart nextcloud-mcp
|
||||
|
||||
# With Docker Compose (includes restart: unless-stopped)
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
4. **Check for OAuth credential expiration** (if using dynamic registration):
|
||||
- See ["Stored client has expired"](#issue-stored-client-has-expired) above
|
||||
|
||||
---
|
||||
|
||||
## Connection Issues
|
||||
|
||||
### Issue: MCP client can't authenticate
|
||||
|
||||
**Cause:** OAuth flow failing or credentials invalid.
|
||||
|
||||
**Solution:**
|
||||
|
||||
**For OAuth:**
|
||||
1. Verify OAuth is configured correctly:
|
||||
```bash
|
||||
uv run nextcloud-mcp-server --oauth --log-level debug
|
||||
# Look for "OAuth initialization complete"
|
||||
```
|
||||
|
||||
2. Check that OIDC app is accessible:
|
||||
```bash
|
||||
curl https://your.nextcloud.instance.com/.well-known/openid-configuration
|
||||
```
|
||||
|
||||
3. Verify MCP_SERVER_URL matches your setup:
|
||||
```bash
|
||||
echo $NEXTCLOUD_MCP_SERVER_URL
|
||||
# Should match the URL clients use to connect
|
||||
```
|
||||
|
||||
**For BasicAuth:**
|
||||
1. Verify credentials work:
|
||||
```bash
|
||||
curl -u "$NEXTCLOUD_USERNAME:$NEXTCLOUD_PASSWORD" \
|
||||
"$NEXTCLOUD_HOST/ocs/v2.php/cloud/capabilities" \
|
||||
-H "OCS-APIRequest: true"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Issue: Tools return errors or don't work
|
||||
|
||||
**Cause:** Missing Nextcloud apps, incorrect permissions, or API issues.
|
||||
|
||||
**Solution:**
|
||||
|
||||
1. **Verify required Nextcloud apps are installed:**
|
||||
- Notes: Install "Notes" app
|
||||
- Calendar: Ensure CalDAV is enabled
|
||||
- Contacts: Ensure CardDAV is enabled
|
||||
- Deck: Install "Deck" app
|
||||
|
||||
2. **Check user permissions:**
|
||||
- Ensure the authenticated user has access to the resources
|
||||
- Check sharing permissions for shared resources
|
||||
|
||||
3. **Test API directly:**
|
||||
```bash
|
||||
# Test Notes API
|
||||
curl -u "$NEXTCLOUD_USERNAME:$NEXTCLOUD_PASSWORD" \
|
||||
"$NEXTCLOUD_HOST/apps/notes/api/v1/notes"
|
||||
|
||||
# Test with OAuth Bearer token
|
||||
curl -H "Authorization: Bearer $TOKEN" \
|
||||
"$NEXTCLOUD_HOST/apps/notes/api/v1/notes"
|
||||
```
|
||||
|
||||
4. **Check server logs for specific errors:**
|
||||
```bash
|
||||
uv run nextcloud-mcp-server --log-level debug
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Getting Help
|
||||
|
||||
If you continue to experience issues:
|
||||
|
||||
### 1. Enable Debug Logging
|
||||
|
||||
```bash
|
||||
uv run nextcloud-mcp-server --log-level debug
|
||||
```
|
||||
|
||||
Review the logs for specific error messages.
|
||||
|
||||
### 2. Verify OIDC Configuration (OAuth mode)
|
||||
|
||||
```bash
|
||||
# Check OIDC discovery
|
||||
curl https://your.nextcloud.instance.com/.well-known/openid-configuration
|
||||
|
||||
# Check registration endpoint exists
|
||||
curl https://your.nextcloud.instance.com/.well-known/openid-configuration | jq '.registration_endpoint'
|
||||
```
|
||||
|
||||
### 3. Test Nextcloud API Access
|
||||
|
||||
```bash
|
||||
# Test OCS API (should work with OAuth)
|
||||
curl -H "Authorization: Bearer $TOKEN" \
|
||||
"$NEXTCLOUD_HOST/ocs/v2.php/cloud/capabilities?format=json" \
|
||||
-H "OCS-APIRequest: true"
|
||||
|
||||
# Test app API (may need patch - see oauth2-bearer-token-session-issue.md)
|
||||
curl -H "Authorization: Bearer $TOKEN" \
|
||||
"$NEXTCLOUD_HOST/apps/notes/api/v1/notes"
|
||||
```
|
||||
|
||||
### 4. Check Versions
|
||||
|
||||
```bash
|
||||
# MCP Server version
|
||||
uv run nextcloud-mcp-server --version
|
||||
|
||||
# Python version
|
||||
python3 --version
|
||||
|
||||
# Nextcloud version (check in admin panel)
|
||||
```
|
||||
|
||||
### 5. Open an Issue
|
||||
|
||||
If problems persist, open an issue on the [GitHub repository](https://github.com/cbcoutinho/nextcloud-mcp-server/issues) with:
|
||||
|
||||
- **Server logs** (with `--log-level debug`)
|
||||
- **Nextcloud version**
|
||||
- **OIDC app version** (if using OAuth)
|
||||
- **Error messages**
|
||||
- **Steps to reproduce**
|
||||
- **Environment details** (OS, Python version, Docker vs local)
|
||||
|
||||
---
|
||||
|
||||
## See Also
|
||||
|
||||
- **[OAuth Troubleshooting](oauth-troubleshooting.md)** - Dedicated OAuth troubleshooting guide
|
||||
- [OAuth Setup Guide](oauth-setup.md) - OAuth configuration
|
||||
- [OAuth Architecture](oauth-architecture.md) - How OAuth works
|
||||
- [Upstream Status](oauth-upstream-status.md) - Required patches and upstream PRs
|
||||
- [Configuration](configuration.md) - Environment variables
|
||||
- [Running the Server](running.md) - Server options
|
||||
@@ -0,0 +1,62 @@
|
||||
# WebDAV support
|
||||
|
||||
### WebDAV File System Tools
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `nc_webdav_list_directory` | List files and directories in any NextCloud path |
|
||||
| `nc_webdav_read_file` | Read file content (text files decoded, binary as base64) |
|
||||
| `nc_webdav_write_file` | Create or update files in NextCloud |
|
||||
| `nc_webdav_create_directory` | Create new directories |
|
||||
| `nc_webdav_delete_resource` | Delete files or directories |
|
||||
| `nc_webdav_move_resource` | Move or rename files and directories |
|
||||
| `nc_webdav_copy_resource` | Copy files and directories |
|
||||
|
||||
### WebDAV File System Access
|
||||
|
||||
The server provides complete file system access to your NextCloud instance, enabling you to:
|
||||
|
||||
- Browse any directory structure
|
||||
- Read and write files of any type
|
||||
- Create and delete directories
|
||||
- Manage your NextCloud files directly through LLM interactions
|
||||
|
||||
**Usage Examples:**
|
||||
|
||||
```python
|
||||
# List files in root directory
|
||||
await nc_webdav_list_directory("")
|
||||
|
||||
# Browse a specific folder
|
||||
await nc_webdav_list_directory("Documents/Projects")
|
||||
|
||||
# Read a text file
|
||||
content = await nc_webdav_read_file("Documents/readme.txt")
|
||||
|
||||
# Create a new directory
|
||||
await nc_webdav_create_directory("NewProject/docs")
|
||||
|
||||
# Write content to a file
|
||||
await nc_webdav_write_file("NewProject/docs/notes.md", "# My Notes\n\nContent here...")
|
||||
|
||||
# Delete a file or directory
|
||||
await nc_webdav_delete_resource("old_file.txt")
|
||||
|
||||
# Move or 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")
|
||||
|
||||
# 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")
|
||||
```
|
||||
+195
@@ -1,3 +1,198 @@
|
||||
# Nextcloud Instance
|
||||
NEXTCLOUD_HOST=
|
||||
|
||||
# ===== AUTHENTICATION MODE =====
|
||||
# Choose ONE of the following:
|
||||
|
||||
# Option 1: OAuth2/OIDC (RECOMMENDED - More Secure)
|
||||
# - Requires Nextcloud OIDC app installed and configured
|
||||
# - Admin must enable "Dynamic Client Registration" in OIDC app settings
|
||||
# - 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)
|
||||
NEXTCLOUD_OIDC_CLIENT_ID=
|
||||
NEXTCLOUD_OIDC_CLIENT_SECRET=
|
||||
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
|
||||
|
||||
# ===== ADR-004 PROGRESSIVE CONSENT CONFIGURATION =====
|
||||
# Enable Progressive Consent mode (dual OAuth flows)
|
||||
# When enabled: Flow 1 for client auth, Flow 2 for Nextcloud resource access
|
||||
# When disabled: Uses existing hybrid flow (backward compatible)
|
||||
|
||||
# MCP Server OAuth Client Configuration
|
||||
# The MCP server's own OAuth client credentials for Flow 2
|
||||
# If not set, will use dynamic client registration
|
||||
#MCP_SERVER_CLIENT_ID=
|
||||
#MCP_SERVER_CLIENT_SECRET=
|
||||
|
||||
# Allowed MCP Client IDs (comma-separated list)
|
||||
# Client IDs that are allowed to authenticate in Flow 1
|
||||
# Examples: claude-desktop,continue-dev,zed-editor
|
||||
#ALLOWED_MCP_CLIENTS=claude-desktop,continue-dev,zed-editor
|
||||
|
||||
# Token cache configuration for Token Broker Service
|
||||
# Cache TTL in seconds (default: 300 = 5 minutes)
|
||||
#TOKEN_CACHE_TTL=300
|
||||
# Early refresh threshold in seconds (default: 30)
|
||||
#TOKEN_CACHE_EARLY_REFRESH=30
|
||||
|
||||
# Option 2: Basic Authentication (LEGACY - Less Secure)
|
||||
# - Requires username and password
|
||||
# - Credentials stored in environment variables
|
||||
# - Use only for backward compatibility or if OAuth unavailable
|
||||
# - If these are set, OAuth mode is disabled
|
||||
NEXTCLOUD_USERNAME=
|
||||
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
|
||||
|
||||
# ============================================
|
||||
# Semantic Search & Vector Sync Configuration
|
||||
# ============================================
|
||||
# EXPERIMENTAL: Semantic search for Notes app (multi-app support planned)
|
||||
# Requires: Qdrant vector database + Ollama embedding service
|
||||
# Disabled by default
|
||||
|
||||
# Enable background vector indexing
|
||||
VECTOR_SYNC_ENABLED=false
|
||||
|
||||
# Document scan interval in seconds (default: 300 = 5 minutes)
|
||||
# How often to check for new/updated documents
|
||||
#VECTOR_SYNC_SCAN_INTERVAL=300
|
||||
|
||||
# Concurrent indexing workers (default: 3)
|
||||
# Number of parallel workers for embedding generation
|
||||
#VECTOR_SYNC_PROCESSOR_WORKERS=3
|
||||
|
||||
# Max queued documents (default: 10000)
|
||||
# Maximum documents waiting to be processed
|
||||
#VECTOR_SYNC_QUEUE_MAX_SIZE=10000
|
||||
|
||||
# ============================================
|
||||
# Qdrant Vector Database Configuration
|
||||
# ============================================
|
||||
# Choose ONE of three modes:
|
||||
# 1. In-memory mode (default): Set neither QDRANT_URL nor QDRANT_LOCATION
|
||||
# 2. Persistent local: Set QDRANT_LOCATION=/path/to/data
|
||||
# 3. Network mode: Set QDRANT_URL=http://qdrant:6333
|
||||
|
||||
# Network mode: URL to Qdrant service
|
||||
#QDRANT_URL=http://qdrant:6333
|
||||
|
||||
# Local mode: Path to store vectors (use :memory: for in-memory)
|
||||
#QDRANT_LOCATION=:memory:
|
||||
|
||||
# API key for network mode (optional)
|
||||
#QDRANT_API_KEY=
|
||||
|
||||
# Collection name (optional - auto-generated if not set)
|
||||
# Auto-generation format: {deployment-id}-{model-name}
|
||||
# Allows safe model switching and multi-server deployments
|
||||
#QDRANT_COLLECTION=nextcloud_content
|
||||
|
||||
# ============================================
|
||||
# Ollama Embedding Service Configuration
|
||||
# ============================================
|
||||
# Ollama endpoint for embeddings (if not set, uses SimpleEmbeddingProvider fallback)
|
||||
#OLLAMA_BASE_URL=http://ollama:11434
|
||||
|
||||
# Embedding model to use (default: nomic-embed-text, 768 dimensions)
|
||||
# Changing this creates a new collection (requires re-embedding all documents)
|
||||
#OLLAMA_EMBEDDING_MODEL=nomic-embed-text
|
||||
|
||||
# Verify SSL certificates (default: true)
|
||||
#OLLAMA_VERIFY_SSL=true
|
||||
|
||||
# ============================================
|
||||
# Document Chunking Configuration
|
||||
# ============================================
|
||||
# Configure how documents are split before embedding
|
||||
|
||||
# Words per chunk (default: 512)
|
||||
# Smaller chunks (256-384): More precise, less context, more storage
|
||||
# Larger chunks (768-1024): More context, less precise, less storage
|
||||
#DOCUMENT_CHUNK_SIZE=512
|
||||
|
||||
# Overlapping words between chunks (default: 50)
|
||||
# Recommended: 10-20% of chunk size
|
||||
# Preserves context across chunk boundaries
|
||||
#DOCUMENT_CHUNK_OVERLAP=50
|
||||
|
||||
@@ -0,0 +1,852 @@
|
||||
{
|
||||
"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 and as token exchange target",
|
||||
"enabled": true,
|
||||
"clientAuthenticatorType": "client-secret",
|
||||
"secret": "nextcloud-secret-change-in-production",
|
||||
"redirectUris": [],
|
||||
"webOrigins": [],
|
||||
"bearerOnly": false,
|
||||
"consentRequired": false,
|
||||
"standardFlowEnabled": false,
|
||||
"implicitFlowEnabled": false,
|
||||
"directAccessGrantsEnabled": false,
|
||||
"serviceAccountsEnabled": true,
|
||||
"authorizationServicesEnabled": true,
|
||||
"publicClient": false,
|
||||
"protocol": "openid-connect",
|
||||
"attributes": {
|
||||
"display.on.consent.screen": "false",
|
||||
"token.exchange.grant.enabled": "true",
|
||||
"client.token.exchange.standard.enabled": "true",
|
||||
"standard.token.exchange.enabled": "true"
|
||||
},
|
||||
"authorizationSettings": {
|
||||
"allowRemoteResourceManagement": true,
|
||||
"policyEnforcementMode": "ENFORCING",
|
||||
"resources": [
|
||||
{
|
||||
"name": "token-exchange",
|
||||
"type": "urn:keycloak:token-exchange",
|
||||
"ownerManagedAccess": false,
|
||||
"displayName": "Token Exchange",
|
||||
"attributes": {},
|
||||
"uris": [],
|
||||
"scopes": [
|
||||
{
|
||||
"name": "token-exchange"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"policies": [
|
||||
{
|
||||
"name": "allow-nextcloud-mcp-server-to-exchange",
|
||||
"description": "",
|
||||
"type": "client",
|
||||
"logic": "POSITIVE",
|
||||
"decisionStrategy": "UNANIMOUS",
|
||||
"config": {
|
||||
"clients": "[\"nextcloud-mcp-server\",\"nextcloud\"]"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "token-exchange-permission",
|
||||
"description": "",
|
||||
"type": "scope",
|
||||
"logic": "POSITIVE",
|
||||
"decisionStrategy": "AFFIRMATIVE",
|
||||
"config": {
|
||||
"resources": "[\"token-exchange\"]",
|
||||
"scopes": "[\"token-exchange\"]",
|
||||
"applyPolicies": "[\"allow-nextcloud-mcp-server-to-exchange\"]"
|
||||
}
|
||||
}
|
||||
],
|
||||
"scopes": [
|
||||
{
|
||||
"name": "token-exchange",
|
||||
"displayName": "Token Exchange"
|
||||
}
|
||||
],
|
||||
"decisionStrategy": "UNANIMOUS"
|
||||
},
|
||||
"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",
|
||||
"standard.token.exchange.enabled": "true"
|
||||
},
|
||||
"fullScopeAllowed": true,
|
||||
"nodeReRegistrationTimeout": -1,
|
||||
"protocolMappers": [
|
||||
{
|
||||
"name": "mcp-server-audience",
|
||||
"protocol": "openid-connect",
|
||||
"protocolMapper": "oidc-audience-mapper",
|
||||
"consentRequired": false,
|
||||
"config": {
|
||||
"included.client.audience": "nextcloud-mcp-server",
|
||||
"access.token.claim": "true",
|
||||
"id.token.claim": "false",
|
||||
"introspection.token.claim": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "nextcloud-audience",
|
||||
"protocol": "openid-connect",
|
||||
"protocolMapper": "oidc-audience-mapper",
|
||||
"consentRequired": false,
|
||||
"config": {
|
||||
"included.client.audience": "nextcloud",
|
||||
"access.token.claim": "true",
|
||||
"id.token.claim": "false",
|
||||
"introspection.token.claim": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
"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": "default-audience",
|
||||
"protocol": "openid-connect",
|
||||
"attributes": {
|
||||
"include.in.token.scope": "false",
|
||||
"display.on.consent.screen": "false",
|
||||
"gui.order": "",
|
||||
"consent.screen.text": ""
|
||||
},
|
||||
"protocolMappers": [
|
||||
{
|
||||
"name": "mcp-server-audience",
|
||||
"protocol": "openid-connect",
|
||||
"protocolMapper": "oidc-audience-mapper",
|
||||
"consentRequired": false,
|
||||
"config": {
|
||||
"included.client.audience": "nextcloud-mcp-server",
|
||||
"access.token.claim": "true",
|
||||
"id.token.claim": "false"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "mcp-url-audience",
|
||||
"protocol": "openid-connect",
|
||||
"protocolMapper": "oidc-audience-mapper",
|
||||
"consentRequired": false,
|
||||
"config": {
|
||||
"included.custom.audience": "http://localhost:8002",
|
||||
"access.token.claim": "true",
|
||||
"id.token.claim": "false"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"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",
|
||||
"default-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"
|
||||
]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,34 @@
|
||||
"""OAuth authentication components for Nextcloud MCP server."""
|
||||
|
||||
from .bearer_auth import BearerAuth
|
||||
from .client_registration import ensure_oauth_client, register_client
|
||||
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 .unified_verifier import UnifiedTokenVerifier
|
||||
|
||||
__all__ = [
|
||||
"BearerAuth",
|
||||
"UnifiedTokenVerifier",
|
||||
"register_client",
|
||||
"ensure_oauth_client",
|
||||
"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",
|
||||
]
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Bearer token authentication for httpx."""
|
||||
|
||||
from httpx import Auth, Request
|
||||
|
||||
|
||||
class BearerAuth(Auth):
|
||||
"""
|
||||
Bearer token authentication flow for httpx.
|
||||
|
||||
This auth class adds the Authorization: Bearer <token> header
|
||||
to all outgoing requests.
|
||||
"""
|
||||
|
||||
def __init__(self, token: str):
|
||||
"""
|
||||
Initialize bearer authentication.
|
||||
|
||||
Args:
|
||||
token: The bearer token to use for authentication
|
||||
"""
|
||||
self.token = token
|
||||
|
||||
def auth_flow(self, request: Request):
|
||||
"""
|
||||
Add Authorization header to the request.
|
||||
|
||||
Args:
|
||||
request: The outgoing HTTP request
|
||||
|
||||
Yields:
|
||||
The modified request with Authorization header
|
||||
"""
|
||||
request.headers["Authorization"] = f"Bearer {self.token}"
|
||||
yield request
|
||||
@@ -0,0 +1,420 @@
|
||||
"""Browser-based OAuth login routes for admin UI.
|
||||
|
||||
Separate from MCP OAuth flow - these routes establish browser sessions
|
||||
for accessing admin UI endpoints like /app.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
from base64 import urlsafe_b64encode
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import httpx
|
||||
import jwt
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||
|
||||
from nextcloud_mcp_server.auth.userinfo_routes import (
|
||||
_get_userinfo_endpoint,
|
||||
_query_idp_userinfo,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def oauth_login(request: Request) -> RedirectResponse | JSONResponse:
|
||||
"""Browser OAuth login endpoint - redirects to IdP for authentication.
|
||||
|
||||
This is separate from the MCP OAuth flow (/oauth/authorize).
|
||||
Creates a browser session with refresh token for admin UI access.
|
||||
|
||||
Query parameters:
|
||||
next: Optional URL to redirect to after login (default: /user/page)
|
||||
|
||||
Returns:
|
||||
302 redirect to IdP authorization endpoint
|
||||
"""
|
||||
oauth_ctx = request.app.state.oauth_context
|
||||
if not oauth_ctx:
|
||||
# BasicAuth mode - no login needed, redirect to app
|
||||
return RedirectResponse("/app", status_code=302)
|
||||
|
||||
storage = oauth_ctx["storage"]
|
||||
oauth_client = oauth_ctx["oauth_client"]
|
||||
oauth_config = oauth_ctx["config"]
|
||||
|
||||
# Debug: Log oauth_config contents
|
||||
logger.info(f"oauth_login called - oauth_config keys: {oauth_config.keys()}")
|
||||
logger.info(f"oauth_login called - client_id: {oauth_config.get('client_id')}")
|
||||
logger.info(f"oauth_login called - oauth_client: {oauth_client is not None}")
|
||||
|
||||
# Generate state for CSRF protection
|
||||
state = secrets.token_urlsafe(32)
|
||||
|
||||
# Build OAuth authorization URL
|
||||
mcp_server_url = oauth_config["mcp_server_url"]
|
||||
callback_uri = f"{mcp_server_url}/oauth/callback"
|
||||
|
||||
# Request only basic OIDC scopes for browser session
|
||||
# Note: Nextcloud app scopes (notes:read, etc.) are for MCP client access tokens,
|
||||
# not for the MCP server's own browser authentication
|
||||
scopes = "openid profile email offline_access"
|
||||
|
||||
# Generate PKCE values for ALL modes (both external and integrated IdP require PKCE)
|
||||
code_verifier = secrets.token_urlsafe(32)
|
||||
digest = hashlib.sha256(code_verifier.encode()).digest()
|
||||
code_challenge = urlsafe_b64encode(digest).decode().rstrip("=")
|
||||
|
||||
# Store code_verifier in session for retrieval during callback (using state as key)
|
||||
await storage.store_oauth_session(
|
||||
session_id=state, # Use state as session ID
|
||||
client_id="browser-ui",
|
||||
client_redirect_uri="/app",
|
||||
state=state,
|
||||
code_challenge=code_challenge,
|
||||
code_challenge_method="S256",
|
||||
mcp_authorization_code=code_verifier, # Store code_verifier here temporarily
|
||||
flow_type="browser",
|
||||
ttl_seconds=600, # 10 minutes
|
||||
)
|
||||
|
||||
if oauth_client:
|
||||
# External IdP mode (Keycloak)
|
||||
if not oauth_client.authorization_endpoint:
|
||||
await oauth_client.discover()
|
||||
|
||||
idp_params = {
|
||||
"client_id": oauth_client.client_id,
|
||||
"redirect_uri": callback_uri,
|
||||
"response_type": "code",
|
||||
"scope": scopes,
|
||||
"state": state,
|
||||
"code_challenge": code_challenge,
|
||||
"code_challenge_method": "S256",
|
||||
"prompt": "consent", # Ensure refresh token
|
||||
}
|
||||
|
||||
auth_url = f"{oauth_client.authorization_endpoint}?{urlencode(idp_params)}"
|
||||
logger.info(f"Redirecting to external IdP login: {auth_url.split('?')[0]}")
|
||||
else:
|
||||
# Integrated mode (Nextcloud OIDC)
|
||||
discovery_url = oauth_config.get("discovery_url")
|
||||
if not discovery_url:
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "server_error",
|
||||
"error_description": "OAuth discovery URL not configured",
|
||||
},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
# Fetch authorization endpoint
|
||||
async with httpx.AsyncClient() as http_client:
|
||||
response = await http_client.get(discovery_url)
|
||||
response.raise_for_status()
|
||||
discovery = response.json()
|
||||
authorization_endpoint = discovery["authorization_endpoint"]
|
||||
|
||||
# Replace internal Docker hostname with public URL
|
||||
public_issuer = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL")
|
||||
if public_issuer:
|
||||
from urllib.parse import urlparse as parse_url
|
||||
|
||||
internal_parsed = parse_url(oauth_config["nextcloud_host"])
|
||||
auth_parsed = parse_url(authorization_endpoint)
|
||||
|
||||
if auth_parsed.hostname == internal_parsed.hostname:
|
||||
public_parsed = parse_url(public_issuer)
|
||||
authorization_endpoint = (
|
||||
f"{public_parsed.scheme}://{public_parsed.netloc}{auth_parsed.path}"
|
||||
)
|
||||
|
||||
idp_params = {
|
||||
"client_id": oauth_config["client_id"],
|
||||
"redirect_uri": callback_uri,
|
||||
"response_type": "code",
|
||||
"scope": scopes,
|
||||
"state": state,
|
||||
"code_challenge": code_challenge,
|
||||
"code_challenge_method": "S256",
|
||||
"prompt": "consent", # Ensure refresh token
|
||||
}
|
||||
|
||||
# Debug: Log full parameters
|
||||
logger.info(f"Building Nextcloud OIDC auth URL with params: {idp_params}")
|
||||
|
||||
auth_url = f"{authorization_endpoint}?{urlencode(idp_params)}"
|
||||
logger.info(f"Redirecting to Nextcloud OIDC login: {auth_url}")
|
||||
|
||||
return RedirectResponse(auth_url, status_code=302)
|
||||
|
||||
|
||||
async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLResponse:
|
||||
"""Browser OAuth callback - IdP redirects here after authentication.
|
||||
|
||||
Exchanges authorization code for tokens, stores refresh token,
|
||||
sets session cookie, and redirects to original destination.
|
||||
|
||||
Query parameters:
|
||||
code: Authorization code from IdP
|
||||
state: State parameter
|
||||
error: Error code (if authorization failed)
|
||||
|
||||
Returns:
|
||||
302 redirect to next URL with session cookie
|
||||
"""
|
||||
# Check for errors
|
||||
error = request.query_params.get("error")
|
||||
if error:
|
||||
error_description = request.query_params.get(
|
||||
"error_description", "Authorization failed"
|
||||
)
|
||||
logger.error(f"OAuth login error: {error} - {error_description}")
|
||||
login_url = str(request.url_for("oauth_login"))
|
||||
return HTMLResponse(
|
||||
f"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>Login Failed</title></head>
|
||||
<body>
|
||||
<h1>Login Failed</h1>
|
||||
<p>Error: {error}</p>
|
||||
<p>{error_description}</p>
|
||||
<p><a href="{login_url}">Try again</a></p>
|
||||
</body>
|
||||
</html>
|
||||
""",
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Extract code and state
|
||||
code = request.query_params.get("code")
|
||||
state = request.query_params.get("state")
|
||||
|
||||
if not code or not state:
|
||||
return HTMLResponse(
|
||||
"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>Invalid Request</title></head>
|
||||
<body>
|
||||
<h1>Invalid Request</h1>
|
||||
<p>Missing code or state parameter</p>
|
||||
</body>
|
||||
</html>
|
||||
""",
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Get OAuth context
|
||||
oauth_ctx = request.app.state.oauth_context
|
||||
storage = oauth_ctx["storage"]
|
||||
oauth_client = oauth_ctx["oauth_client"]
|
||||
oauth_config = oauth_ctx["config"]
|
||||
|
||||
# Retrieve code_verifier from session storage (PKCE required for all modes)
|
||||
code_verifier = ""
|
||||
oauth_session = await storage.get_oauth_session(state)
|
||||
if oauth_session:
|
||||
# code_verifier was stored in mcp_authorization_code field
|
||||
code_verifier = oauth_session.get("mcp_authorization_code", "")
|
||||
# Clean up the temporary session
|
||||
# Note: We don't have delete_oauth_session method, but it will expire after TTL
|
||||
|
||||
# Exchange authorization code for tokens
|
||||
mcp_server_url = oauth_config["mcp_server_url"]
|
||||
callback_uri = f"{mcp_server_url}/oauth/callback"
|
||||
|
||||
try:
|
||||
if oauth_client:
|
||||
# External IdP mode (Keycloak)
|
||||
# Use PKCE if we have a code_verifier
|
||||
if not oauth_client.token_endpoint:
|
||||
await oauth_client.discover()
|
||||
|
||||
token_params = {
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"redirect_uri": callback_uri,
|
||||
"client_id": oauth_client.client_id,
|
||||
"client_secret": oauth_client.client_secret,
|
||||
}
|
||||
|
||||
# Add code_verifier if we have one (PKCE)
|
||||
if code_verifier:
|
||||
token_params["code_verifier"] = code_verifier
|
||||
|
||||
async with httpx.AsyncClient() as http_client:
|
||||
response = await http_client.post(
|
||||
oauth_client.token_endpoint,
|
||||
data=token_params,
|
||||
)
|
||||
response.raise_for_status()
|
||||
token_data = response.json()
|
||||
else:
|
||||
# Integrated mode (Nextcloud OIDC)
|
||||
discovery_url = oauth_config.get("discovery_url")
|
||||
async with httpx.AsyncClient() as http_client:
|
||||
response = await http_client.get(discovery_url)
|
||||
response.raise_for_status()
|
||||
discovery = response.json()
|
||||
token_endpoint = discovery["token_endpoint"]
|
||||
|
||||
token_params = {
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"redirect_uri": callback_uri,
|
||||
"client_id": oauth_config["client_id"],
|
||||
"client_secret": oauth_config["client_secret"],
|
||||
}
|
||||
|
||||
# Add code_verifier for PKCE (required by Nextcloud OIDC)
|
||||
if code_verifier:
|
||||
token_params["code_verifier"] = code_verifier
|
||||
|
||||
async with httpx.AsyncClient() as http_client:
|
||||
response = await http_client.post(
|
||||
token_endpoint,
|
||||
data=token_params,
|
||||
)
|
||||
response.raise_for_status()
|
||||
token_data = response.json()
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
error_body = (
|
||||
e.response.text if hasattr(e.response, "text") else str(e.response.content)
|
||||
)
|
||||
logger.error(
|
||||
f"Token exchange failed: HTTP {e.response.status_code} - {error_body}"
|
||||
)
|
||||
return HTMLResponse(
|
||||
f"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>Login Failed</title></head>
|
||||
<body>
|
||||
<h1>Login Failed</h1>
|
||||
<p>Failed to exchange authorization code for tokens</p>
|
||||
<p>HTTP {e.response.status_code}: {error_body}</p>
|
||||
</body>
|
||||
</html>
|
||||
""",
|
||||
status_code=500,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Token exchange failed: {e}")
|
||||
return HTMLResponse(
|
||||
f"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>Login Failed</title></head>
|
||||
<body>
|
||||
<h1>Login Failed</h1>
|
||||
<p>Failed to exchange authorization code for tokens</p>
|
||||
<p>Error: {e}</p>
|
||||
</body>
|
||||
</html>
|
||||
""",
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
refresh_token = token_data.get("refresh_token")
|
||||
id_token = token_data.get("id_token")
|
||||
|
||||
logger.info(f"Token exchange response keys: {token_data.keys()}")
|
||||
logger.info(f"Refresh token present: {refresh_token is not None}")
|
||||
logger.info(f"ID token present: {id_token is not None}")
|
||||
|
||||
# Decode ID token to get user info
|
||||
try:
|
||||
userinfo = jwt.decode(id_token, options={"verify_signature": False})
|
||||
user_id = userinfo.get("sub")
|
||||
username = userinfo.get("preferred_username") or userinfo.get("email")
|
||||
logger.info(f"Browser login successful: {username} (sub={user_id})")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to decode ID token: {e}")
|
||||
user_id = f"user-{secrets.token_hex(8)}"
|
||||
username = "unknown"
|
||||
|
||||
# Store refresh token (for background jobs ONLY)
|
||||
if refresh_token:
|
||||
logger.info(f"Storing refresh token for user_id: {user_id}")
|
||||
logger.info(f" State parameter (provisioning_client_id): {state[:16]}...")
|
||||
await storage.store_refresh_token(
|
||||
user_id=user_id,
|
||||
refresh_token=refresh_token,
|
||||
expires_at=None,
|
||||
flow_type="browser", # Browser-based login flow
|
||||
provisioning_client_id=state, # Store state for unified session lookup
|
||||
)
|
||||
logger.info(f"✓ Refresh token stored successfully for user_id: {user_id}")
|
||||
logger.info(
|
||||
f" Token can now be found via provisioning_client_id={state[:16]}..."
|
||||
)
|
||||
else:
|
||||
logger.warning("No refresh token in token response - cannot store session")
|
||||
|
||||
# Query and cache user profile (for browser UI display)
|
||||
access_token = token_data.get("access_token")
|
||||
if access_token:
|
||||
try:
|
||||
# Get the OAuth context to determine correct userinfo endpoint
|
||||
oauth_ctx = getattr(request.app.state, "oauth_context", {})
|
||||
userinfo_endpoint = await _get_userinfo_endpoint(oauth_ctx)
|
||||
|
||||
if userinfo_endpoint:
|
||||
# Query userinfo endpoint with fresh access token
|
||||
profile_data = await _query_idp_userinfo(
|
||||
access_token, userinfo_endpoint
|
||||
)
|
||||
|
||||
if profile_data:
|
||||
# Cache profile for browser UI (no token needed to display)
|
||||
await storage.store_user_profile(user_id, profile_data)
|
||||
logger.info(f"✓ User profile cached for {user_id}")
|
||||
else:
|
||||
logger.warning(f"Failed to query userinfo endpoint for {user_id}")
|
||||
else:
|
||||
logger.warning("Could not determine userinfo endpoint")
|
||||
except Exception as e:
|
||||
logger.error(f"Error caching user profile: {e}")
|
||||
# Continue anyway - profile cache is optional for browser UI
|
||||
|
||||
# Create response and set session cookie
|
||||
response = RedirectResponse("/app", status_code=302)
|
||||
response.set_cookie(
|
||||
key="mcp_session",
|
||||
value=user_id,
|
||||
max_age=86400 * 30, # 30 days
|
||||
httponly=True,
|
||||
secure=False, # Set to True in production with HTTPS
|
||||
samesite="lax",
|
||||
)
|
||||
|
||||
logger.info(f"Session cookie set for user: {username}")
|
||||
return response
|
||||
|
||||
|
||||
async def oauth_logout(request: Request) -> RedirectResponse:
|
||||
"""Browser OAuth logout - clears session cookie.
|
||||
|
||||
Query parameters:
|
||||
next: Optional URL to redirect to after logout (default: /oauth/login)
|
||||
|
||||
Returns:
|
||||
302 redirect with cleared session cookie
|
||||
"""
|
||||
next_url = request.query_params.get("next", "/oauth/login")
|
||||
|
||||
# TODO: Optionally revoke refresh token from storage
|
||||
# session_id = request.cookies.get("mcp_session")
|
||||
# if session_id:
|
||||
# await storage.delete_refresh_token(session_id)
|
||||
|
||||
response = RedirectResponse(next_url, status_code=302)
|
||||
response.delete_cookie("mcp_session")
|
||||
|
||||
logger.info("User logged out, session cookie cleared")
|
||||
return response
|
||||
@@ -0,0 +1,384 @@
|
||||
"""Dynamic client registration for Nextcloud OIDC."""
|
||||
|
||||
import datetime as dt
|
||||
import logging
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import anyio
|
||||
import httpx
|
||||
|
||||
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ClientInfo:
|
||||
"""Client registration information with RFC 7592 support."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
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 = None,
|
||||
registration_client_uri: str | None = None,
|
||||
):
|
||||
self.client_id = client_id
|
||||
self.client_secret = client_secret
|
||||
self.client_id_issued_at = client_id_issued_at
|
||||
self.client_secret_expires_at = client_secret_expires_at
|
||||
self.redirect_uris = redirect_uris
|
||||
self.registration_access_token = registration_access_token
|
||||
self.registration_client_uri = registration_client_uri
|
||||
|
||||
@property
|
||||
def is_expired(self) -> bool:
|
||||
"""Check if the client has expired."""
|
||||
return time.time() >= self.client_secret_expires_at
|
||||
|
||||
@property
|
||||
def expires_soon(self) -> bool:
|
||||
"""Check if client expires within 5 minutes."""
|
||||
return time.time() >= (self.client_secret_expires_at - 300)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Convert to dictionary for storage."""
|
||||
result = {
|
||||
"client_id": self.client_id,
|
||||
"client_secret": self.client_secret,
|
||||
"client_id_issued_at": self.client_id_issued_at,
|
||||
"client_secret_expires_at": self.client_secret_expires_at,
|
||||
"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
|
||||
def from_dict(cls, data: dict[str, Any]) -> "ClientInfo":
|
||||
"""Create from dictionary."""
|
||||
return cls(
|
||||
client_id=data["client_id"],
|
||||
client_secret=data["client_secret"],
|
||||
client_id_issued_at=data["client_id_issued_at"],
|
||||
client_secret_expires_at=data["client_secret_expires_at"],
|
||||
redirect_uris=data["redirect_uris"],
|
||||
registration_access_token=data.get("registration_access_token"),
|
||||
registration_client_uri=data.get("registration_client_uri"),
|
||||
)
|
||||
|
||||
|
||||
async def register_client(
|
||||
nextcloud_url: str,
|
||||
registration_endpoint: str,
|
||||
client_name: str = "Nextcloud MCP Server",
|
||||
redirect_uris: list[str] | None = None,
|
||||
scopes: str = "openid profile email",
|
||||
token_type: str | None = "Bearer",
|
||||
resource_url: str | None = None,
|
||||
) -> ClientInfo:
|
||||
"""
|
||||
Register a new OAuth client using RFC 7591 Dynamic Client Registration.
|
||||
|
||||
This function supports both Nextcloud OIDC and standard OIDC providers like Keycloak.
|
||||
|
||||
Args:
|
||||
nextcloud_url: Base URL of the OIDC provider
|
||||
registration_endpoint: Full URL to the registration endpoint
|
||||
client_name: Name of the client application
|
||||
redirect_uris: List of redirect URIs (default: http://localhost:8000/oauth/callback)
|
||||
scopes: Space-separated list of scopes to request
|
||||
token_type: Type of access tokens (default: "Bearer", supports "JWT" for Nextcloud).
|
||||
Set to None to omit this field (required for Keycloak and other standard providers).
|
||||
resource_url: OAuth 2.0 Protected Resource URL (RFC 9728) - used for token introspection authorization
|
||||
|
||||
Returns:
|
||||
ClientInfo with registration details
|
||||
|
||||
Raises:
|
||||
httpx.HTTPStatusError: If registration fails
|
||||
ValueError: If response is invalid
|
||||
|
||||
Note:
|
||||
The token_type parameter is a Nextcloud-specific extension and is not part of RFC 7591.
|
||||
Standard OIDC providers like Keycloak do not accept this field and will return a 400 error
|
||||
if it's included. Set token_type=None when registering with Keycloak or other standard providers.
|
||||
"""
|
||||
if redirect_uris is None:
|
||||
redirect_uris = ["http://localhost:8000/oauth/callback"]
|
||||
|
||||
client_metadata = {
|
||||
"client_name": client_name,
|
||||
"redirect_uris": redirect_uris,
|
||||
"token_endpoint_auth_method": "client_secret_post",
|
||||
"grant_types": ["authorization_code", "refresh_token"],
|
||||
"response_types": ["code"],
|
||||
"scope": scopes,
|
||||
}
|
||||
|
||||
# Add token_type if provided (Nextcloud-specific, not RFC 7591 standard)
|
||||
if token_type is not None:
|
||||
client_metadata["token_type"] = token_type
|
||||
|
||||
# Add resource_url if provided (RFC 9728)
|
||||
if resource_url:
|
||||
client_metadata["resource_url"] = resource_url
|
||||
|
||||
logger.info(f"Registering OAuth client with Nextcloud: {client_name}")
|
||||
logger.debug(f"Registration endpoint: {registration_endpoint}")
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
try:
|
||||
response = await client.post(
|
||||
registration_endpoint,
|
||||
json=client_metadata,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
client_info = response.json()
|
||||
logger.info(
|
||||
f"Successfully registered client: {client_info.get('client_id')}"
|
||||
)
|
||||
expires_at = dt.datetime.fromtimestamp(
|
||||
client_info.get("client_secret_expires_at")
|
||||
)
|
||||
logger.info(
|
||||
f"Client expires at: {expires_at} "
|
||||
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(
|
||||
client_id=client_info["client_id"],
|
||||
client_secret=client_info["client_secret"],
|
||||
client_id_issued_at=client_info.get(
|
||||
"client_id_issued_at", int(time.time())
|
||||
),
|
||||
client_secret_expires_at=client_info.get(
|
||||
"client_secret_expires_at", int(time.time()) + 3600
|
||||
),
|
||||
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:
|
||||
logger.error(f"Failed to register client: HTTP {e.response.status_code}")
|
||||
logger.error(f"Response: {e.response.text}")
|
||||
raise
|
||||
except KeyError as e:
|
||||
logger.error(f"Invalid response from registration endpoint: missing {e}")
|
||||
raise ValueError(f"Invalid registration response: missing {e}")
|
||||
|
||||
|
||||
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:
|
||||
"""
|
||||
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:
|
||||
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:
|
||||
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)
|
||||
"""
|
||||
|
||||
# Determine deletion endpoint
|
||||
if registration_client_uri:
|
||||
deletion_endpoint = registration_client_uri
|
||||
else:
|
||||
deletion_endpoint = f"{nextcloud_url}/apps/oidc/register/{client_id}"
|
||||
|
||||
logger.info(f"Deleting OAuth client: {client_id[:16]}...")
|
||||
logger.debug(f"Deletion endpoint: {deletion_endpoint}")
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as http_client:
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
# Prefer RFC 7592 Bearer token authentication
|
||||
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
|
||||
|
||||
# RFC 7592: Successful deletion returns 204 No Content
|
||||
if response.status_code == 204:
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
# Should not reach here, but return False if we do
|
||||
return False
|
||||
|
||||
|
||||
async def ensure_oauth_client(
|
||||
nextcloud_url: str,
|
||||
registration_endpoint: str,
|
||||
storage: RefreshTokenStorage,
|
||||
client_name: str = "Nextcloud MCP Server",
|
||||
redirect_uris: list[str] | None = None,
|
||||
scopes: str = "openid profile email",
|
||||
token_type: str = "Bearer",
|
||||
resource_url: str | None = None,
|
||||
) -> ClientInfo:
|
||||
"""
|
||||
Ensure OAuth client exists in SQLite storage.
|
||||
|
||||
This function:
|
||||
1. Checks for existing client credentials in SQLite storage
|
||||
2. Validates the credentials are not expired
|
||||
3. Registers a new client if needed (no stored credentials or expired)
|
||||
4. Saves the new client credentials to SQLite
|
||||
|
||||
Args:
|
||||
nextcloud_url: Base URL of the Nextcloud instance
|
||||
registration_endpoint: Full URL to the registration endpoint
|
||||
storage: RefreshTokenStorage instance for SQLite storage
|
||||
client_name: Name of the client application
|
||||
redirect_uris: List of redirect URIs
|
||||
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")
|
||||
resource_url: OAuth 2.0 Protected Resource URL (RFC 9728) - used for token introspection authorization
|
||||
|
||||
Returns:
|
||||
ClientInfo with valid credentials
|
||||
|
||||
Raises:
|
||||
httpx.HTTPStatusError: If registration fails
|
||||
ValueError: If response is invalid
|
||||
"""
|
||||
# Try to load existing client from SQLite
|
||||
client_data = await storage.get_oauth_client()
|
||||
if client_data:
|
||||
logger.info(
|
||||
f"Loaded OAuth client from SQLite: {client_data['client_id'][:16]}..."
|
||||
)
|
||||
return ClientInfo.from_dict(client_data)
|
||||
|
||||
# Register new client
|
||||
logger.info("Registering new OAuth client...")
|
||||
if resource_url:
|
||||
logger.info(f" with resource_url: {resource_url}")
|
||||
client_info = await register_client(
|
||||
nextcloud_url=nextcloud_url,
|
||||
registration_endpoint=registration_endpoint,
|
||||
client_name=client_name,
|
||||
redirect_uris=redirect_uris,
|
||||
scopes=scopes,
|
||||
token_type=token_type,
|
||||
resource_url=resource_url,
|
||||
)
|
||||
|
||||
# Save to SQLite storage
|
||||
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
|
||||
@@ -0,0 +1,239 @@
|
||||
"""
|
||||
MCP Client Registry for ADR-004 Progressive Consent Architecture.
|
||||
|
||||
This module manages the registry of allowed MCP clients that can authenticate
|
||||
via Flow 1. In production, this would integrate with Dynamic Client Registration
|
||||
(DCR) or a database of pre-registered clients.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MCPClientInfo:
|
||||
"""Information about a registered MCP client."""
|
||||
|
||||
client_id: str
|
||||
name: str
|
||||
redirect_uris: List[str]
|
||||
allowed_scopes: List[str]
|
||||
is_public: bool = True # Native clients are public (no client_secret)
|
||||
metadata: Optional[Dict] = None
|
||||
|
||||
|
||||
class ClientRegistry:
|
||||
"""
|
||||
Registry for MCP clients allowed to authenticate via Flow 1.
|
||||
|
||||
In production, this would:
|
||||
1. Support Dynamic Client Registration (DCR) per RFC 7591
|
||||
2. Integrate with IdP client registry
|
||||
3. Store client metadata in database
|
||||
4. Support client updates and revocation
|
||||
"""
|
||||
|
||||
def __init__(self, allow_dynamic_registration: bool = False):
|
||||
"""
|
||||
Initialize the client registry.
|
||||
|
||||
Args:
|
||||
allow_dynamic_registration: Whether to allow DCR for new clients
|
||||
"""
|
||||
self.allow_dynamic_registration = allow_dynamic_registration
|
||||
self._clients: Dict[str, MCPClientInfo] = {}
|
||||
self._load_static_clients()
|
||||
|
||||
def _load_static_clients(self):
|
||||
"""Load statically configured clients from environment."""
|
||||
# Load from ALLOWED_MCP_CLIENTS environment variable
|
||||
allowed_clients = os.getenv("ALLOWED_MCP_CLIENTS", "").strip()
|
||||
|
||||
if allowed_clients:
|
||||
# Parse comma-separated list
|
||||
for client_id in allowed_clients.split(","):
|
||||
client_id = client_id.strip()
|
||||
if client_id:
|
||||
# Create basic client info
|
||||
# In production, would load full metadata from database
|
||||
self._clients[client_id] = MCPClientInfo(
|
||||
client_id=client_id,
|
||||
name=self._get_client_name(client_id),
|
||||
redirect_uris=["http://localhost:*", "http://127.0.0.1:*"],
|
||||
allowed_scopes=["openid", "profile", "email", "mcp-server:api"],
|
||||
is_public=True,
|
||||
)
|
||||
logger.info(f"Registered static client: {client_id}")
|
||||
|
||||
# Add well-known clients if not explicitly configured
|
||||
if not self._clients:
|
||||
self._add_well_known_clients()
|
||||
|
||||
def _get_client_name(self, client_id: str) -> str:
|
||||
"""Get human-readable name for client_id."""
|
||||
known_names = {
|
||||
"claude-desktop": "Claude Desktop",
|
||||
"continue-dev": "Continue IDE Extension",
|
||||
"zed-editor": "Zed Editor",
|
||||
"vscode-mcp": "VS Code MCP Extension",
|
||||
"test-mcp-client": "Test MCP Client",
|
||||
}
|
||||
return known_names.get(client_id, client_id.replace("-", " ").title())
|
||||
|
||||
def _add_well_known_clients(self):
|
||||
"""Add well-known MCP clients for testing and development."""
|
||||
well_known = [
|
||||
MCPClientInfo(
|
||||
client_id="claude-desktop",
|
||||
name="Claude Desktop",
|
||||
redirect_uris=["http://localhost:*", "http://127.0.0.1:*"],
|
||||
allowed_scopes=["openid", "profile", "email", "mcp-server:api"],
|
||||
is_public=True,
|
||||
metadata={"vendor": "Anthropic"},
|
||||
),
|
||||
MCPClientInfo(
|
||||
client_id="test-mcp-client",
|
||||
name="Test MCP Client",
|
||||
redirect_uris=["http://localhost:*", "http://127.0.0.1:*"],
|
||||
allowed_scopes=["openid", "profile", "email", "mcp-server:api"],
|
||||
is_public=True,
|
||||
metadata={"purpose": "testing"},
|
||||
),
|
||||
]
|
||||
|
||||
for client in well_known:
|
||||
self._clients[client.client_id] = client
|
||||
logger.info(f"Registered well-known client: {client.client_id}")
|
||||
|
||||
def validate_client(
|
||||
self,
|
||||
client_id: str,
|
||||
redirect_uri: Optional[str] = None,
|
||||
scopes: Optional[List[str]] = None,
|
||||
) -> tuple[bool, Optional[str]]:
|
||||
"""
|
||||
Validate a client_id and optionally its redirect_uri and scopes.
|
||||
|
||||
Args:
|
||||
client_id: The client identifier to validate
|
||||
redirect_uri: Optional redirect URI to validate
|
||||
scopes: Optional list of scopes to validate
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, error_message)
|
||||
"""
|
||||
# Check if client exists
|
||||
client = self._clients.get(client_id)
|
||||
if not client:
|
||||
if self.allow_dynamic_registration:
|
||||
# In production, would attempt DCR here
|
||||
logger.info(f"Unknown client {client_id}, would attempt DCR")
|
||||
return True, None
|
||||
else:
|
||||
return False, f"Unknown client_id: {client_id}"
|
||||
|
||||
# Validate redirect_uri if provided
|
||||
if redirect_uri:
|
||||
if not self._validate_redirect_uri(client, redirect_uri):
|
||||
return False, f"Invalid redirect_uri for client {client_id}"
|
||||
|
||||
# Validate scopes if provided
|
||||
if scopes:
|
||||
invalid_scopes = set(scopes) - set(client.allowed_scopes)
|
||||
if invalid_scopes:
|
||||
return False, f"Invalid scopes for client {client_id}: {invalid_scopes}"
|
||||
|
||||
return True, None
|
||||
|
||||
def _validate_redirect_uri(self, client: MCPClientInfo, redirect_uri: str) -> bool:
|
||||
"""
|
||||
Validate redirect_uri against client's registered URIs.
|
||||
|
||||
Args:
|
||||
client: The client info
|
||||
redirect_uri: The URI to validate
|
||||
|
||||
Returns:
|
||||
True if valid, False otherwise
|
||||
"""
|
||||
# Parse the redirect URI
|
||||
from urllib.parse import urlparse
|
||||
|
||||
parsed = urlparse(redirect_uri)
|
||||
|
||||
# Check against registered patterns
|
||||
for pattern in client.redirect_uris:
|
||||
if "*" in pattern:
|
||||
# Handle wildcard port (localhost:*)
|
||||
pattern_base = pattern.replace(":*", "")
|
||||
if redirect_uri.startswith(pattern_base + ":"):
|
||||
# Validate it's localhost with a port
|
||||
if parsed.hostname in ["localhost", "127.0.0.1"]:
|
||||
return True
|
||||
elif redirect_uri == pattern:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def register_client(self, client_info: MCPClientInfo) -> bool:
|
||||
"""
|
||||
Register a new MCP client (DCR support).
|
||||
|
||||
Args:
|
||||
client_info: Client information to register
|
||||
|
||||
Returns:
|
||||
True if registered successfully
|
||||
"""
|
||||
if not self.allow_dynamic_registration:
|
||||
logger.warning(f"DCR disabled, cannot register {client_info.client_id}")
|
||||
return False
|
||||
|
||||
if client_info.client_id in self._clients:
|
||||
logger.warning(f"Client {client_info.client_id} already registered")
|
||||
return False
|
||||
|
||||
self._clients[client_info.client_id] = client_info
|
||||
logger.info(f"Dynamically registered client: {client_info.client_id}")
|
||||
|
||||
# In production, would persist to database
|
||||
return True
|
||||
|
||||
def get_client(self, client_id: str) -> Optional[MCPClientInfo]:
|
||||
"""
|
||||
Get client information.
|
||||
|
||||
Args:
|
||||
client_id: The client identifier
|
||||
|
||||
Returns:
|
||||
Client info if found, None otherwise
|
||||
"""
|
||||
return self._clients.get(client_id)
|
||||
|
||||
def list_clients(self) -> List[MCPClientInfo]:
|
||||
"""
|
||||
List all registered clients.
|
||||
|
||||
Returns:
|
||||
List of client information
|
||||
"""
|
||||
return list(self._clients.values())
|
||||
|
||||
|
||||
# Global registry instance
|
||||
_registry: Optional[ClientRegistry] = None
|
||||
|
||||
|
||||
def get_client_registry() -> ClientRegistry:
|
||||
"""Get the global client registry instance."""
|
||||
global _registry
|
||||
if _registry is None:
|
||||
# Check if DCR is enabled
|
||||
allow_dcr = os.getenv("ENABLE_DCR", "false").lower() == "true"
|
||||
_registry = ClientRegistry(allow_dynamic_registration=allow_dcr)
|
||||
return _registry
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user