Compare commits
727 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bd09a1f361 | |||
| 08820782be | |||
| 936480ed9c | |||
| f4a01f37e1 | |||
| 76f3878b2a | |||
| a7b4cfe672 | |||
| bcdf3898b8 | |||
| b341fca408 | |||
| ac48e43a60 | |||
| 85c71802d2 | |||
| 7e2e8b2c8c | |||
| 183269bf8f | |||
| 47b6db5dc1 | |||
| 99ee32e691 | |||
| ffc64b855e | |||
| 2c51b1188b | |||
| 4392ae2a11 | |||
| fa9fec7269 | |||
| 9ac6e741d0 | |||
| 174278a9c3 | |||
| 419c80f80f | |||
| bf916f24aa | |||
| f01f64e8a4 | |||
| 8064aebb0f | |||
| dc1510a485 | |||
| 33db76696a | |||
| 4f5e4be5ec | |||
| 1c3781fa95 | |||
| 222fc1618c | |||
| 4013c25c68 | |||
| 0b475d5acb | |||
| 758586d666 | |||
| bdaec6a0c5 | |||
| 599b665963 | |||
| 9f86f86ec7 | |||
| c4cfb027f0 | |||
| 51c689c6a5 | |||
| e3e1cfc9f7 | |||
| e01b053f22 | |||
| c460f24e0d | |||
| 3380aa2f56 | |||
| aef852f73e | |||
| acc1b9c9e2 | |||
| 7d932b153d | |||
| 93a23846c4 | |||
| 0690088db2 | |||
| 08270d9455 | |||
| a8ce648566 | |||
| e3c7a138ed | |||
| 9ed9d570bd | |||
| fdc6d1232e | |||
| f86fd927e3 | |||
| 379bd12e1d | |||
| 00c0b15c2d | |||
| 17cb4cb25d | |||
| e1ffc2809c | |||
| 377b1e2332 | |||
| 05e196dedc | |||
| 389c98cc74 | |||
| 32c09c338d | |||
| 9f574432be | |||
| b29563630e | |||
| dd2627edda | |||
| 5a74cdca14 | |||
| 6151d92158 | |||
| ce4536cb2d | |||
| 65cf635e71 | |||
| ada1f57e59 | |||
| f9949c920c | |||
| 6d99728d9b | |||
| 6bb5e77c7a | |||
| 3890bf430f | |||
| 53aada6669 | |||
| da6268002e | |||
| bace73a6fe | |||
| 9ffa11cd62 | |||
| e2ba31985a | |||
| b534812cad | |||
| 450ebb2c8d | |||
| c2c80d732f | |||
| 3340b854c2 | |||
| cf73238caa | |||
| 12ced4a255 | |||
| 964740bb71 | |||
| 434c36ea57 | |||
| 831627b78e | |||
| 9662e77d87 | |||
| 2efe761e37 | |||
| 5b673d4715 | |||
| 27042f40d4 | |||
| 724d68694e | |||
| ee0505bcc1 | |||
| 3951260e91 | |||
| b682da9c8b | |||
| eb2b227b8a | |||
| 8916080262 | |||
| 52b3e857e4 | |||
| bc979b0e7d | |||
| b5bb1639d2 | |||
| 507fa69af8 | |||
| 9919b4eaf4 | |||
| 9523cbc9ef | |||
| 12ece90a87 | |||
| 58f5367b36 | |||
| f45250ad59 | |||
| 752221a869 | |||
| ec1e70f0ff | |||
| 8146fdfa91 | |||
| 246e90ec8a | |||
| d061418d44 | |||
| 3131ffb106 | |||
| d2f31b7b26 | |||
| 738302baa3 | |||
| f5c84bcded | |||
| b08e44f41d | |||
| 832698f668 | |||
| 03e9e9ba10 | |||
| 5d7f6fd59e | |||
| 2285960ec3 | |||
| 60bbeb36a8 | |||
| 3d4c538cc9 | |||
| a304ae3bdf | |||
| 5a0fd0f10f | |||
| 0e7286a535 | |||
| feccc92d01 | |||
| 2ea8f1d873 | |||
| deb1839be7 | |||
| 6831e4e414 | |||
| ae2029eca2 | |||
| bdbc22b30b | |||
| 808ca6218a | |||
| 48f69fc046 | |||
| 6b9547a240 | |||
| 3d4e29c9c9 | |||
| 812ff5bede | |||
| 189490c274 | |||
| 48966bcfe9 | |||
| 05d2496d99 | |||
| 0be2eacd99 | |||
| db137ebfbf | |||
| 082e31c86e | |||
| a0825aff4b | |||
| 0d0733e202 | |||
| beb8668260 | |||
| 0d6dd79e70 | |||
| d3ebcd2423 | |||
| ab644cdfde | |||
| a823bb95a6 | |||
| db1437e927 | |||
| 57b66155c7 | |||
| c3ce208c4c | |||
| 6d5a20d5c6 | |||
| 75f6e5e7a0 | |||
| b0fdda4a51 | |||
| e38814cb3b | |||
| 90fbaeeb30 | |||
| a0f1349fb4 | |||
| c29c6bde1d | |||
| 88c2b2350d | |||
| 939445166b | |||
| b69986aafa | |||
| c697d73c9b | |||
| eea197fd2d | |||
| b793c7a1c4 | |||
| f09dd94f2d | |||
| dddb7f69ef | |||
| b080171f17 | |||
| 1431e946cc | |||
| 8218db8ee2 | |||
| c550e622b0 | |||
| e13ee362b5 | |||
| 4fab77350f | |||
| cd3497a935 | |||
| b7e3244b79 | |||
| d7aca04dd6 | |||
| 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 |
+1
-2
@@ -1,8 +1,7 @@
|
||||
*
|
||||
|
||||
!pyproject.toml
|
||||
!poetry.lock
|
||||
!README.md
|
||||
!uv.lock
|
||||
|
||||
!nextcloud_mcp_server/
|
||||
!nextcloud_mcp_server/**/*.py
|
||||
|
||||
@@ -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@72f2c25fcb47643c292f7107632f7a47c1df5cd8 # v2
|
||||
uses: softprops/action-gh-release@6da8fa9354ddfdc4aeace5fc48d7f679b5214090 # v2.4.1
|
||||
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@c1e51972afc2121e065aed6d45c65596fe445f3f # v5
|
||||
with:
|
||||
# list of Docker images to use as base name for tags
|
||||
images: |
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
|
||||
- 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,29 @@
|
||||
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@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Configure Git
|
||||
run: |
|
||||
git config user.name "$GITHUB_ACTOR"
|
||||
git config user.email "$GITHUB_ACTOR@users.noreply.github.com"
|
||||
|
||||
- name: Run chart-releaser
|
||||
uses: helm/chart-releaser-action@v1.7.0
|
||||
env:
|
||||
CR_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
|
||||
@@ -0,0 +1,33 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- v*
|
||||
|
||||
jobs:
|
||||
pypi:
|
||||
name: Publish to PyPI
|
||||
runs-on: ubuntu-latest
|
||||
# Environment and permissions trusted publishing.
|
||||
environment:
|
||||
# Create this environment in the GitHub repository under Settings -> Environments
|
||||
name: pypi
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@85856786d1ce8acfbcc2f13a5f3fbd6b938f9f41 # v7.1.2
|
||||
- name: Install Python 3.11
|
||||
run: uv python install 3.11
|
||||
- name: Build
|
||||
run: uv build
|
||||
- name: Smoke test (wheel)
|
||||
run: uv run --isolated --no-project --with dist/*.whl nextcloud-mcp-server --help
|
||||
- name: Smoke test (source distribution)
|
||||
run: uv run --isolated --no-project --with dist/*.tar.gz nextcloud-mcp-server --help
|
||||
- name: Publish
|
||||
run: uv publish
|
||||
@@ -9,9 +9,9 @@ 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@bd01e18f51369d5a26f1651c3cb451d3417e3bba # v6
|
||||
uses: astral-sh/setup-uv@85856786d1ce8acfbcc2f13a5f3fbd6b938f9f41 # v7.1.2
|
||||
- name: Check format
|
||||
run: |
|
||||
uv run --frozen ruff format --diff
|
||||
@@ -24,14 +24,39 @@ jobs:
|
||||
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@40041ff1b97dbf152cd2361138c2b03fa29139df # v2.3.0
|
||||
uses: hoverkraft-tech/compose-action@3846bcd61da338e9eaaf83e7ed0234a12b099b72 # v2.4.1
|
||||
with:
|
||||
compose-file: "./docker-compose.yml"
|
||||
up-flags: "--build"
|
||||
|
||||
- name: Install the latest version of uv
|
||||
uses: astral-sh/setup-uv@bd01e18f51369d5a26f1651c3cb451d3417e3bba # v6
|
||||
uses: astral-sh/setup-uv@85856786d1ce8acfbcc2f13a5f3fbd6b938f9f41 # v7.1.2
|
||||
|
||||
- name: Install Playwright dependencies
|
||||
run: |
|
||||
uv run playwright install chromium --with-deps
|
||||
|
||||
- name: Wait for service to be ready
|
||||
run: |
|
||||
@@ -56,4 +81,4 @@ jobs:
|
||||
NEXTCLOUD_USERNAME: "admin"
|
||||
NEXTCLOUD_PASSWORD: "admin"
|
||||
run: |
|
||||
uv run --frozen python -m pytest
|
||||
uv run pytest -v --log-cli-level=INFO
|
||||
|
||||
@@ -1,2 +1,9 @@
|
||||
__pycache__/
|
||||
.coverage
|
||||
.env
|
||||
*.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Generated by pytest used to login users
|
||||
.nextcloud_oauth_*.json
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
[submodule "oidc"]
|
||||
path = third_party/oidc
|
||||
url = https://github.com/cbcoutinho/oidc
|
||||
[submodule "third_party/oidc"]
|
||||
path = third_party/oidc
|
||||
url = https://github.com/cbcoutinho/oidc
|
||||
+10
-3
@@ -1,13 +1,20 @@
|
||||
repos:
|
||||
- repo: https://github.com/commitizen-tools/commitizen
|
||||
rev: v4.8.2
|
||||
rev: v4.9.0
|
||||
hooks:
|
||||
- id: commitizen
|
||||
- id: commitizen-branch
|
||||
stages:
|
||||
- pre-push
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.12.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]
|
||||
|
||||
+301
@@ -1,3 +1,304 @@
|
||||
## 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
|
||||
|
||||
@@ -0,0 +1,414 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Development Commands
|
||||
|
||||
### Testing
|
||||
|
||||
The test suite is organized in layers for fast feedback:
|
||||
|
||||
```bash
|
||||
# FAST FEEDBACK (recommended for development)
|
||||
# Unit tests only - ~5 seconds
|
||||
uv run pytest tests/unit/ -v
|
||||
|
||||
# Smoke tests - critical path validation - ~30-60 seconds
|
||||
uv run pytest -m smoke -v
|
||||
|
||||
# INTEGRATION TESTS
|
||||
# Integration tests without OAuth - ~2-3 minutes
|
||||
uv run pytest -m "integration and not oauth" -v
|
||||
|
||||
# Full test suite - ~4-5 minutes
|
||||
uv run pytest
|
||||
|
||||
# OAuth tests only (slowest, requires Playwright) - ~3 minutes
|
||||
uv run pytest -m oauth -v
|
||||
|
||||
# COVERAGE
|
||||
# Run tests with coverage
|
||||
uv run pytest --cov
|
||||
|
||||
# LEGACY COMMANDS (still work)
|
||||
# Run all integration tests
|
||||
uv run pytest -m integration -v
|
||||
|
||||
# Skip integration tests
|
||||
uv run pytest -m "not integration" -v
|
||||
```
|
||||
|
||||
! Hint: If the tests are failing due to missing environment variables, then usually the correct .env has not been created or not correctly configured yet.
|
||||
|
||||
### Load Testing
|
||||
```bash
|
||||
# Run benchmark with default settings (10 workers, 30 seconds)
|
||||
uv run python -m tests.load.benchmark
|
||||
|
||||
# Quick test with custom concurrency and duration
|
||||
uv run python -m tests.load.benchmark --concurrency 20 --duration 60
|
||||
|
||||
# Extended load test (50 workers for 5 minutes)
|
||||
uv run python -m tests.load.benchmark -c 50 -d 300
|
||||
|
||||
# Export results to JSON for analysis
|
||||
uv run python -m tests.load.benchmark -c 20 -d 60 --output results.json
|
||||
|
||||
# Test OAuth server on port 8001
|
||||
uv run python -m tests.load.benchmark --url http://127.0.0.1:8001/mcp
|
||||
|
||||
# Verbose mode with detailed logging
|
||||
uv run python -m tests.load.benchmark -c 10 -d 30 --verbose
|
||||
```
|
||||
|
||||
**Load Testing Features:**
|
||||
- **Mixed workload** simulating realistic MCP usage (40% reads, 20% writes, 15% search, 25% other operations)
|
||||
- **Real-time progress** bar with live RPS and error counts
|
||||
- **Detailed metrics**:
|
||||
- Throughput (requests/second)
|
||||
- Latency percentiles (p50, p90, p95, p99)
|
||||
- Per-operation breakdown
|
||||
- Error rates and types
|
||||
- **Automatic cleanup** of test data
|
||||
- **JSON export** for CI/CD integration
|
||||
- **Server health checks** before starting
|
||||
|
||||
**Understanding Results:**
|
||||
- **Requests/Second (RPS)**: Higher is better. Expected baseline: 50-200 RPS for mixed workload
|
||||
- **Latency**:
|
||||
- p50 (median): Should be <100ms for most operations
|
||||
- p95: Should be <500ms
|
||||
- p99: Should be <1000ms
|
||||
- **Error Rate**: Should be <1% under normal load
|
||||
|
||||
**Common Bottlenecks:**
|
||||
1. Nextcloud backend API response times (most common)
|
||||
2. Database connection limits
|
||||
3. HTTP client connection pooling
|
||||
4. Network I/O between containers
|
||||
|
||||
### Code Quality
|
||||
```bash
|
||||
# Format and lint code
|
||||
uv run ruff check
|
||||
uv run ruff format
|
||||
|
||||
# Type checking
|
||||
# No explicit type checker configured - this is a Python project using ruff for linting
|
||||
```
|
||||
|
||||
### Running the Server
|
||||
```bash
|
||||
# Local development - load environment variables and run
|
||||
export $(grep -v '^#' .env | xargs)
|
||||
mcp run --transport sse nextcloud_mcp_server.app:mcp
|
||||
|
||||
# Docker development environment with Nextcloud instance
|
||||
docker-compose up
|
||||
|
||||
# After code changes, rebuild and restart the appropriate MCP server container:
|
||||
# For basic auth changes (most common) - uses admin credentials
|
||||
docker-compose up --build -d mcp
|
||||
|
||||
# For OAuth changes - uses OAuth authentication with JWT tokens
|
||||
docker-compose up --build -d mcp-oauth
|
||||
|
||||
# Build Docker image
|
||||
docker build -t nextcloud-mcp-server .
|
||||
```
|
||||
|
||||
**Important: MCP Server Containers**
|
||||
- **`mcp`** (port 8000): Uses basic auth with admin credentials. Use this for most development and testing.
|
||||
- **`mcp-oauth`** (port 8001): Uses OAuth authentication with JWT tokens. Use this when working on OAuth-specific features or tests.
|
||||
- JWT tokens are used for testing (faster validation, scopes embedded in token)
|
||||
- The server can handle both JWT and opaque tokens via the token verifier
|
||||
|
||||
### Environment Setup
|
||||
```bash
|
||||
# Install dependencies
|
||||
uv sync
|
||||
|
||||
# Install development dependencies
|
||||
uv sync --group dev
|
||||
```
|
||||
|
||||
### Database Inspection
|
||||
|
||||
**Docker Compose Database Credentials:**
|
||||
- Root user: `root` / password: `password`
|
||||
- App user: `nextcloud` / password: `password`
|
||||
- Database: `nextcloud`
|
||||
|
||||
**Common Database Commands:**
|
||||
```bash
|
||||
# Connect to database as root (most common for inspection)
|
||||
docker compose exec db mariadb -u root -ppassword nextcloud
|
||||
|
||||
# Check OAuth clients
|
||||
docker compose exec db mariadb -u root -ppassword nextcloud -e "SELECT id, name, token_type FROM oc_oidc_clients ORDER BY id DESC LIMIT 10;"
|
||||
|
||||
# Check OAuth client scopes
|
||||
docker compose exec db mariadb -u root -ppassword nextcloud -e "SELECT c.id, c.name, s.scope FROM oc_oidc_clients c LEFT JOIN oc_oidc_client_scopes s ON c.id = s.client_id WHERE c.name LIKE '%MCP%';"
|
||||
|
||||
# Check OAuth access tokens
|
||||
docker compose exec db mariadb -u root -ppassword nextcloud -e "SELECT id, client_id, user_id, created_at FROM oc_oidc_access_tokens ORDER BY created_at DESC LIMIT 10;"
|
||||
```
|
||||
|
||||
**Important Tables:**
|
||||
- `oc_oidc_clients` - OAuth client registrations (DCR clients)
|
||||
- `oc_oidc_client_scopes` - Client allowed scopes
|
||||
- `oc_oidc_access_tokens` - Issued access tokens
|
||||
- `oc_oidc_authorization_codes` - Authorization codes
|
||||
- `oc_oidc_registration_tokens` - RFC 7592 registration tokens for client management
|
||||
- `oc_oidc_redirect_uris` - Redirect URIs for each client
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
This is a Python MCP (Model Context Protocol) server that provides LLM integration with Nextcloud. The architecture follows a layered pattern:
|
||||
|
||||
### Core Components
|
||||
|
||||
- **`nextcloud_mcp_server/app.py`** - Main MCP server entry point using FastMCP framework
|
||||
- **`nextcloud_mcp_server/client/`** - HTTP client implementations for different Nextcloud APIs
|
||||
- **`nextcloud_mcp_server/server/`** - MCP tool/resource definitions that expose client functionality
|
||||
- **`nextcloud_mcp_server/controllers/`** - Business logic controllers (e.g., notes search)
|
||||
|
||||
### Client Architecture
|
||||
|
||||
- **`NextcloudClient`** - Main orchestrating client that manages all app-specific clients
|
||||
- **`BaseNextcloudClient`** - Abstract base class providing common HTTP functionality and retry logic
|
||||
- **App-specific clients**: `NotesClient`, `CalendarClient`, `ContactsClient`, `TablesClient`, `WebDAVClient`
|
||||
|
||||
### Server Integration
|
||||
|
||||
Each Nextcloud app has a corresponding server module that:
|
||||
1. Defines MCP tools using `@mcp.tool()` decorators
|
||||
2. Defines MCP resources using `@mcp.resource()` decorators
|
||||
3. Uses the context pattern to access the `NextcloudClient` instance
|
||||
|
||||
### Supported Nextcloud Apps
|
||||
|
||||
- **Notes** - Full CRUD operations and search
|
||||
- **Calendar** - CalDAV integration with events, recurring events, attendees, and **tasks (VTODO)**
|
||||
- **Calendar Operations**: List, create, delete calendars
|
||||
- **Event Operations**: Full CRUD, recurring events, attendees, reminders, bulk operations
|
||||
- **Task Operations (VTODO)**: Full CRUD for CalDAV tasks with:
|
||||
- Status tracking (NEEDS-ACTION, IN-PROCESS, COMPLETED, CANCELLED)
|
||||
- Priority levels (0-9, 1=highest, 9=lowest)
|
||||
- Due dates, start dates, completion tracking
|
||||
- Percent complete (0-100%)
|
||||
- Categories and filtering
|
||||
- Search across all calendars
|
||||
- **Note**: Calendar implementation uses caldav library's AsyncDavClient
|
||||
- **Contacts** - CardDAV integration with address book operations
|
||||
- **Tables** - Row-level operations on Nextcloud Tables
|
||||
- **WebDAV** - Complete file system access
|
||||
|
||||
### Key Patterns
|
||||
|
||||
1. **Environment-based configuration** - Uses `NextcloudClient.from_env()` to load credentials from environment variables
|
||||
2. **Async/await throughout** - All operations are async using httpx
|
||||
3. **Retry logic** - `@retry_on_429` decorator handles rate limiting
|
||||
4. **Context injection** - MCP context provides access to the authenticated client instance
|
||||
5. **Modular design** - Each Nextcloud app is isolated in its own client/server pair
|
||||
|
||||
### MCP Response Patterns
|
||||
|
||||
**CRITICAL: Never return raw `List[Dict]` from MCP tools - always wrap in Pydantic response models**
|
||||
|
||||
FastMCP serialization issue: raw lists get mangled into dicts with numeric string keys.
|
||||
|
||||
**Pattern:**
|
||||
1. Client methods return `List[Dict]` (raw data)
|
||||
2. MCP tools convert to Pydantic models and wrap in response object
|
||||
3. Response models inherit from `BaseResponse`, include `results` field + metadata
|
||||
|
||||
**Reference implementations:**
|
||||
- `SearchNotesResponse` in `nextcloud_mcp_server/models/notes.py:80`
|
||||
- `SearchFilesResponse` in `nextcloud_mcp_server/models/webdav.py:113`
|
||||
- Tool examples: `nextcloud_mcp_server/server/{notes,webdav}.py`
|
||||
|
||||
**Testing:** Extract `data["results"]` from MCP responses, not `data` directly.
|
||||
|
||||
### Testing Structure
|
||||
|
||||
The test suite follows a layered architecture for fast feedback:
|
||||
|
||||
```
|
||||
tests/
|
||||
├── unit/ # Fast unit tests (~5s total)
|
||||
│ ├── test_scope_decorator.py
|
||||
│ └── test_response_models.py
|
||||
├── smoke/ # Critical path tests (~30-60s)
|
||||
│ └── test_smoke.py
|
||||
├── integration/
|
||||
│ ├── client/ # Direct API layer tests
|
||||
│ │ ├── notes/
|
||||
│ │ ├── calendar/
|
||||
│ │ └── ...
|
||||
│ └── server/ # MCP tool layer tests
|
||||
│ ├── oauth/ # OAuth-specific tests (slow, ~3min)
|
||||
│ │ ├── test_oauth_core.py
|
||||
│ │ ├── test_scope_authorization.py
|
||||
│ │ └── ...
|
||||
│ ├── test_mcp.py
|
||||
│ └── ...
|
||||
└── load/ # Performance tests
|
||||
```
|
||||
|
||||
**Test Markers:**
|
||||
- `@pytest.mark.unit` - Fast unit tests with mocked dependencies
|
||||
- `@pytest.mark.integration` - Integration tests requiring Docker containers
|
||||
- `@pytest.mark.oauth` - OAuth tests requiring Playwright (slowest)
|
||||
- `@pytest.mark.smoke` - Critical path smoke tests
|
||||
|
||||
**Fixtures** in `tests/conftest.py` - Shared test setup and utilities
|
||||
- **Important**: Integration tests run against live Docker containers. After making code changes:
|
||||
- For basic auth tests: rebuild with `docker-compose up --build -d mcp`
|
||||
- For OAuth tests: rebuild with `docker-compose up --build -d mcp-oauth`
|
||||
|
||||
#### Testing Best Practices
|
||||
- **MANDATORY: Always run tests after implementing features or fixing bugs**
|
||||
- Run tests to completion before considering any task complete
|
||||
- If tests require modifications to pass, ask for permission before proceeding
|
||||
- **Rebuild the correct container** after code changes:
|
||||
- For basic auth tests (most common): `docker-compose up --build -d mcp`
|
||||
- For OAuth tests: `docker-compose up --build -d mcp-oauth`
|
||||
- **Use existing fixtures** from `tests/conftest.py` to avoid duplicate setup work:
|
||||
- `nc_mcp_client` - MCP client session for tool/resource testing (uses `mcp` container)
|
||||
- `nc_mcp_oauth_client` - MCP client session for OAuth testing (uses `mcp-oauth` container)
|
||||
- `nc_client` - Direct NextcloudClient for setup/cleanup operations
|
||||
- `temporary_note` - Creates and cleans up test notes automatically
|
||||
- `temporary_addressbook` - Creates and cleans up test address books
|
||||
- `temporary_contact` - Creates and cleans up test contacts
|
||||
- **Test specific functionality** after changes:
|
||||
- For Notes changes: `uv run pytest tests/server/test_mcp.py -k "notes" -v`
|
||||
- For specific API changes: `uv run pytest tests/client/notes/test_notes_api.py -v`
|
||||
- For OAuth changes: `uv run pytest tests/server/test_oauth*.py -v` (remember to rebuild `mcp-oauth` container)
|
||||
- **Avoid creating standalone test scripts** - use pytest with proper fixtures instead
|
||||
|
||||
#### Writing Mocked Unit Tests
|
||||
|
||||
For client-layer tests that verify response parsing logic, use mocked HTTP responses instead of real network calls:
|
||||
|
||||
**Pattern:**
|
||||
```python
|
||||
import httpx
|
||||
import pytest
|
||||
from nextcloud_mcp_server.client.notes import NotesClient
|
||||
from tests.conftest import create_mock_note_response
|
||||
|
||||
async def test_notes_api_get_note(mocker):
|
||||
"""Test that get_note correctly parses the API response."""
|
||||
# Create mock response using helper functions
|
||||
mock_response = create_mock_note_response(
|
||||
note_id=123,
|
||||
title="Test Note",
|
||||
content="Test content",
|
||||
category="Test",
|
||||
etag="abc123",
|
||||
)
|
||||
|
||||
# Mock the _make_request method
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
NotesClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
# Create client and test
|
||||
client = NotesClient(mock_client, "testuser")
|
||||
note = await client.get_note(note_id=123)
|
||||
|
||||
# Verify the response was parsed correctly
|
||||
assert note["id"] == 123
|
||||
assert note["title"] == "Test Note"
|
||||
# Verify the correct API endpoint was called
|
||||
mock_make_request.assert_called_once_with("GET", "/apps/notes/api/v1/notes/123")
|
||||
```
|
||||
|
||||
**Mock Response Helpers in `tests/conftest.py`:**
|
||||
- `create_mock_response()` - Generic HTTP response builder
|
||||
- `create_mock_note_response()` - Pre-configured note response
|
||||
- `create_mock_error_response()` - Error responses (404, 412, etc.)
|
||||
|
||||
**Benefits:**
|
||||
- ⚡ Fast execution (~0.1s vs minutes for integration tests)
|
||||
- 🔒 No Docker dependency
|
||||
- 🎯 Tests focus on response parsing logic
|
||||
- ♻️ Repeatable and deterministic
|
||||
|
||||
**When to use:**
|
||||
- Testing client methods that parse JSON responses
|
||||
- Testing error handling (404, 412, etc.)
|
||||
- Testing request parameter building
|
||||
|
||||
**When NOT to use (keep as integration tests):**
|
||||
- Complex protocol interactions (CalDAV, CardDAV, WebDAV)
|
||||
- Multi-component workflows (Notes + WebDAV attachments)
|
||||
- OAuth flows
|
||||
- End-to-end MCP tool testing
|
||||
|
||||
**Reference Implementation:**
|
||||
- See `tests/client/notes/test_notes_api.py` for complete examples
|
||||
- Mark unit tests with `pytestmark = pytest.mark.unit`
|
||||
- Run with: `uv run pytest tests/unit/ tests/client/notes/test_notes_api.py -v`
|
||||
|
||||
#### OAuth/OIDC Testing
|
||||
OAuth integration tests use **automated Playwright browser automation** to complete the OAuth flow programmatically.
|
||||
|
||||
**OAuth Testing Setup:**
|
||||
- **Main fixtures**: `nc_oauth_client`, `nc_mcp_oauth_client` - Use Playwright automation
|
||||
- **Shared OAuth Client**: All test users authenticate using a single OAuth client
|
||||
- **Created fresh for each test session** via Dynamic Client Registration (DCR)
|
||||
- Matches production MCP server behavior (one client, multiple user tokens)
|
||||
- Each user gets their own unique access token
|
||||
- **Automatic cleanup**: Client is registered at session start, deleted at session end (RFC 7592)
|
||||
- Implementation: `shared_oauth_client_credentials` fixture in `tests/conftest.py`
|
||||
- **Note**: Client deletion may fail due to Nextcloud middleware (logged as warning). This doesn't affect tests.
|
||||
- **Available fixtures**: `playwright_oauth_token`, `nc_oauth_client`, `nc_mcp_oauth_client`
|
||||
- **Multi-user fixtures**: `alice_oauth_token`, `bob_oauth_token`, `charlie_oauth_token`, `diana_oauth_token`
|
||||
- **Requirements**: `NEXTCLOUD_HOST`, `NEXTCLOUD_USERNAME`, `NEXTCLOUD_PASSWORD` environment variables
|
||||
- Uses `pytest-playwright-asyncio` for async Playwright fixtures
|
||||
- **Playwright configuration**: Use pytest CLI args like `--browser firefox --headed` to customize
|
||||
- **Install browsers**: `uv run playwright install firefox` (or `chromium`, `webkit`)
|
||||
|
||||
**Example Commands:**
|
||||
```bash
|
||||
# Run all OAuth tests with Playwright automation using Firefox
|
||||
uv run pytest tests/server/oauth/ --browser firefox -v
|
||||
|
||||
# Run specific OAuth test file with visible browser for debugging
|
||||
uv run pytest tests/server/oauth/test_oauth_core.py --browser firefox --headed -v
|
||||
|
||||
# Run with Chromium (default) - use -m oauth marker for all OAuth tests
|
||||
uv run pytest -m oauth -v
|
||||
```
|
||||
|
||||
**Test Environment:**
|
||||
- **Two MCP server containers are available:**
|
||||
- `mcp` (port 8000): Uses basic auth with admin credentials - for most testing
|
||||
- `mcp-oauth` (port 8001): Uses OAuth authentication - for OAuth-specific testing
|
||||
- Start OAuth MCP server: `docker-compose up --build -d mcp-oauth`
|
||||
- **Important**: When working on OAuth functionality, always rebuild `mcp-oauth` container, not `mcp`
|
||||
|
||||
**CI/CD Notes:**
|
||||
- Playwright tests run in CI/CD environments
|
||||
- Use Firefox browser in CI: `--browser firefox` (Chromium may have issues with localhost redirects)
|
||||
|
||||
### Configuration Files
|
||||
|
||||
- **`pyproject.toml`** - Python project configuration using uv for dependency management
|
||||
- **`.env`** (from `env.sample`) - Environment variables for Nextcloud connection
|
||||
- **`docker-compose.yml`** - Complete development environment with Nextcloud + database
|
||||
|
||||
## Integration testing with docker
|
||||
|
||||
### Nextcloud
|
||||
|
||||
- The `app` container is running nextcloud.
|
||||
- Use `docker compose exec app php occ ...` to get a list of available commands
|
||||
|
||||
### Mariadb
|
||||
|
||||
- The `db` container is running mariadb
|
||||
- Use `docker compose exec db mariadb -u [user] -p [password] [database]` to execute queries. Check the docker-compose file for credentials
|
||||
+7
-2
@@ -1,4 +1,7 @@
|
||||
FROM ghcr.io/astral-sh/uv:0.7.20-python3.11-alpine@sha256:5676209b5e686d60bfae32d9f656bf935a5613759860c447ed90a37a89192caa
|
||||
FROM ghcr.io/astral-sh/uv:0.9.5-python3.11-alpine@sha256:64ecec379ff82bea84b8a80c0b374f6392bcd54aa52f8c63c12f510f9c0b214d
|
||||
|
||||
# Install git (required for caldav dependency from git)
|
||||
RUN apk add --no-cache git
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -6,4 +9,6 @@ COPY . .
|
||||
|
||||
RUN uv sync --locked --no-dev
|
||||
|
||||
CMD ["/app/.venv/bin/mcp", "run", "--transport", "sse", "/app/nextcloud_mcp_server/server.py:mcp"]
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
ENTRYPOINT ["/app/.venv/bin/nextcloud-mcp-server", "--host", "0.0.0.0"]
|
||||
|
||||
@@ -1,144 +1,742 @@
|
||||
# Nextcloud MCP Server
|
||||
# Nextcloud MCP Server Helm Chart
|
||||
|
||||
[](https://github.com/cbcoutinho/nextcloud-mcp-server/pkgs/container/nextcloud-mcp-server)
|
||||
This Helm chart deploys the Nextcloud MCP (Model Context Protocol) Server on a Kubernetes cluster, enabling AI assistants to interact with your Nextcloud instance.
|
||||
|
||||
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.
|
||||
## Prerequisites
|
||||
|
||||
## Features
|
||||
|
||||
The server provides integration with multiple Nextcloud apps, enabling LLMs to interact with your Nextcloud data through a rich set of tools and resources.
|
||||
|
||||
## Supported Nextcloud Apps
|
||||
|
||||
| App | Support Status | Description |
|
||||
|-----|----------------|-------------|
|
||||
| **Notes** | ✅ Full Support | Create, read, update, delete, and search notes. Handle attachments via WebDAV. |
|
||||
| **Tables** | ⚠️ Row Operations | Read table schemas and perform CRUD operations on table rows. Table management not yet supported. |
|
||||
|
||||
## Available Tools
|
||||
|
||||
### Notes Tools
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `nc_get_note` | Get a specific note by ID |
|
||||
| `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 |
|
||||
|
||||
### 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 |
|
||||
|
||||
## Available Resources
|
||||
|
||||
| Resource | Description |
|
||||
|----------|-------------|
|
||||
| `nc://capabilities` | Access Nextcloud server capabilities |
|
||||
| `notes://settings` | Access Notes app settings |
|
||||
| `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.
|
||||
- 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
|
||||
|
||||
### Prerequisites
|
||||
### Quick Start with Basic Authentication
|
||||
|
||||
* Python 3.8+
|
||||
* Access to a Nextcloud instance
|
||||
```bash
|
||||
# Add the Helm repository
|
||||
helm repo add nextcloud-mcp https://cbcoutinho.github.io/nextcloud-mcp-server
|
||||
helm repo update
|
||||
|
||||
### Local Installation
|
||||
# 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
|
||||
```
|
||||
|
||||
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
|
||||
```
|
||||
### Using a values file
|
||||
|
||||
### Docker
|
||||
Create a `custom-values.yaml` file:
|
||||
|
||||
A pre-built Docker image is available: `ghcr.io/cbcoutinho/nextcloud-mcp-server`
|
||||
```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
|
||||
|
||||
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:
|
||||
### Key Configuration Parameters
|
||||
|
||||
```dotenv
|
||||
# .env
|
||||
NEXTCLOUD_HOST=https://your.nextcloud.instance.com
|
||||
NEXTCLOUD_USERNAME=your_nextcloud_username
|
||||
NEXTCLOUD_PASSWORD=your_nextcloud_app_password_or_login_password
|
||||
#### 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 URL for browser-accessible OAuth authorization endpoint (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, defaults to `nextcloud.host`. **Only used for authorization endpoints** that browsers must access. All server-to-server endpoints (token, JWKS, introspection, userinfo) use URLs from OIDC discovery without rewriting
|
||||
|
||||
#### 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` |
|
||||
|
||||
#### Data Storage
|
||||
|
||||
The `/app/data` directory is used for application data (token databases, Qdrant persistent storage, etc.). It is always mounted as writable to support the read-only root filesystem security context.
|
||||
|
||||
| Parameter | Description | Default |
|
||||
|-----------|-------------|---------|
|
||||
| `dataStorage.enabled` | Enable persistent storage for `/app/data` | `false` |
|
||||
| `dataStorage.size` | Size of data storage PVC | `1Gi` |
|
||||
| `dataStorage.storageClass` | Storage class (leave empty for default) | `""` |
|
||||
| `dataStorage.accessMode` | Access mode | `ReadWriteOnce` |
|
||||
| `dataStorage.existingClaim` | Use existing PVC | `""` |
|
||||
|
||||
**When to enable persistence:**
|
||||
- Multi-user basic auth with offline access (stores `tokens.db`)
|
||||
- Qdrant persistent mode (stores vector database)
|
||||
- Any feature requiring persistent app data
|
||||
|
||||
**When persistence is disabled:** Uses `emptyDir` (non-persistent, data lost on pod restart, but directory remains writable).
|
||||
|
||||
#### 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"
|
||||
```
|
||||
|
||||
* `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.
|
||||
#### Image Configuration
|
||||
|
||||
## Running the Server
|
||||
| Parameter | Description | Default |
|
||||
|-----------|-------------|---------|
|
||||
| `image.repository` | Container image repository | `ghcr.io/cbcoutinho/nextcloud-mcp-server` |
|
||||
| `image.pullPolicy` | Image pull policy | `IfNotPresent` |
|
||||
|
||||
### Locally
|
||||
**Note:** Image tag is automatically set to the chart's `appVersion` and cannot be overridden.
|
||||
|
||||
Ensure your environment variables are loaded, then run the server using `mcp run`:
|
||||
#### 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 with BM25 hybrid search by deploying a vector database (Qdrant) and embedding service (Ollama or OpenAI).
|
||||
|
||||
**Semantic Search Configuration:**
|
||||
|
||||
| Parameter | Description | Default |
|
||||
|-----------|-------------|---------|
|
||||
| `semanticSearch.enabled` | Enable semantic search and background vector synchronization | `false` |
|
||||
| `semanticSearch.scanInterval` | Scan interval in seconds | `3600` |
|
||||
| `semanticSearch.processorWorkers` | Number of concurrent processor workers | `3` |
|
||||
| `semanticSearch.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
|
||||
# Load environment variables from your .env file
|
||||
export $(grep -v '^#' .env | xargs)
|
||||
|
||||
# Run the server
|
||||
mcp run --transport sse nextcloud_mcp_server.server:mcp
|
||||
kubectl create secret generic nextcloud-credentials \
|
||||
--from-literal=username=myuser \
|
||||
--from-literal=password=mypassword
|
||||
```
|
||||
|
||||
The server will start, typically listening on `http://0.0.0.0:8000`.
|
||||
Then reference it in your values:
|
||||
|
||||
### Using Docker
|
||||
```yaml
|
||||
nextcloud:
|
||||
host: https://cloud.example.com
|
||||
|
||||
Mount your environment file when running the container:
|
||||
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
|
||||
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm ghcr.io/cbcoutinho/nextcloud-mcp-server:latest
|
||||
kubectl create secret generic nextcloud-oauth-creds \
|
||||
--from-literal=clientId=my-oauth-client-id \
|
||||
--from-literal=clientSecret=my-oauth-client-secret
|
||||
```
|
||||
|
||||
This will start the server and expose it on port 8000 of your local machine.
|
||||
Then reference it in your values:
|
||||
|
||||
## Usage
|
||||
```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 (only used for browser-accessible auth endpoint)
|
||||
|
||||
Once the server is running, you can connect to it using an MCP client like `uvx`. Add the server to your `uvx` configuration:
|
||||
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 (only used for browser-accessible auth endpoint)
|
||||
|
||||
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 semantic search
|
||||
semanticSearch:
|
||||
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
|
||||
semanticSearch:
|
||||
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
|
||||
semanticSearch:
|
||||
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
|
||||
uvx mcp add nextcloud-mcp http://localhost:8000 --default-transport sse
|
||||
# Update the repository
|
||||
helm repo update
|
||||
|
||||
# Upgrade with your custom values
|
||||
helm upgrade nextcloud-mcp nextcloud-mcp/nextcloud-mcp-server -f custom-values.yaml
|
||||
```
|
||||
|
||||
You can then interact with the server's tools and resources through your LLM interface connected to `uvx`.
|
||||
### To upgrade with new values:
|
||||
|
||||
## References:
|
||||
```bash
|
||||
helm upgrade nextcloud-mcp nextcloud-mcp/nextcloud-mcp-server \
|
||||
--set resources.limits.memory=1Gi
|
||||
```
|
||||
|
||||
- https://github.com/modelcontextprotocol/python-sdk
|
||||
## Uninstalling
|
||||
|
||||
## Contributing
|
||||
```bash
|
||||
helm uninstall nextcloud-mcp
|
||||
```
|
||||
|
||||
Contributions are welcome! Please feel free to submit issues or pull requests on the [GitHub repository](https://github.com/cbcoutinho/nextcloud-mcp-server).
|
||||
**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
|
||||
- For OAuth mode: Ensure MCP server can reach OIDC discovery endpoints (token, JWKS, introspection, userinfo URLs)
|
||||
- Check network policies and firewall rules
|
||||
- Note: Do not use internal Docker hostnames (like `http://app:80`) for `nextcloud.host` - use externally resolvable URLs
|
||||
|
||||
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 project is licensed under the AGPL-3.0 License. See the [LICENSE](./LICENSE) file for details.
|
||||
|
||||
[](https://mseep.ai/app/cbcoutinho-nextcloud-mcp-server)
|
||||
This chart is licensed under AGPL-3.0, consistent with the Nextcloud MCP Server project.
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euox pipefail
|
||||
|
||||
php /var/www/html/occ config:system:set trusted_domains 2 --value=host.docker.internal
|
||||
+69
@@ -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
|
||||
|
||||
+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
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euox pipefail
|
||||
|
||||
echo "Installing and configuring OIDC app for testing..."
|
||||
|
||||
# Check if development OIDC app is mounted at /opt/apps/oidc
|
||||
if [ -d /opt/apps/oidc ]; then
|
||||
echo "Development OIDC app found at /opt/apps/oidc"
|
||||
|
||||
# Remove any existing OIDC app in custom_apps (from app store or old symlink)
|
||||
if [ -e /var/www/html/custom_apps/oidc ]; then
|
||||
echo "Removing existing OIDC in custom_apps..."
|
||||
rm -rf /var/www/html/custom_apps/oidc
|
||||
fi
|
||||
|
||||
# Create symlink from custom_apps to the mounted development version
|
||||
# Per Nextcloud docs: apps outside server root need symlinks in server root
|
||||
echo "Creating symlink: custom_apps/oidc -> /opt/apps/oidc"
|
||||
ln -sf /opt/apps/oidc /var/www/html/custom_apps/oidc
|
||||
|
||||
echo "Enabling OIDC app from /opt/apps (development mode via symlink)"
|
||||
php /var/www/html/occ app:enable oidc
|
||||
elif [ -d /var/www/html/custom_apps/oidc ]; then
|
||||
echo "OIDC app directory found in custom_apps (already installed)"
|
||||
php /var/www/html/occ app:enable oidc
|
||||
else
|
||||
echo "OIDC app not found, installing from app store..."
|
||||
php /var/www/html/occ app:install oidc
|
||||
php /var/www/html/occ app:enable oidc
|
||||
fi
|
||||
|
||||
# Configure OIDC Identity Provider with dynamic client registration enabled
|
||||
php /var/www/html/occ config:app:set oidc dynamic_client_registration --value='true'
|
||||
php /var/www/html/occ config:app:set oidc proof_key_for_code_exchange --value=true --type=boolean
|
||||
php /var/www/html/occ config:app:set oidc default_token_type --value='jwt'
|
||||
|
||||
echo "OIDC app installed and configured successfully"
|
||||
+2
@@ -1,3 +1,5 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euox pipefail
|
||||
|
||||
php /var/www/html/occ app:enable tables
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
#!/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
|
||||
|
||||
patch -u /var/www/html/custom_apps/user_oidc/lib/User/Backend.php -i /docker-entrypoint-hooks.d/post-installation/0001-Fix-Bearer-token-authentication-causing-session-logo.patch
|
||||
@@ -0,0 +1,23 @@
|
||||
# Patterns to ignore when building packages.
|
||||
# This supports shell glob matching, relative path matching, and
|
||||
# negation (prefixed with !). Only one pattern per line.
|
||||
.DS_Store
|
||||
# Common VCS dirs
|
||||
.git/
|
||||
.gitignore
|
||||
.bzr/
|
||||
.bzrignore
|
||||
.hg/
|
||||
.hgignore
|
||||
.svn/
|
||||
# Common backup files
|
||||
*.swp
|
||||
*.bak
|
||||
*.tmp
|
||||
*.orig
|
||||
*~
|
||||
# Various IDEs
|
||||
.project
|
||||
.idea/
|
||||
*.tmproj
|
||||
.vscode/
|
||||
@@ -0,0 +1,23 @@
|
||||
apiVersion: v2
|
||||
name: nextcloud-mcp-server
|
||||
description: A Helm chart for Nextcloud MCP Server - enables AI assistants to interact with Nextcloud
|
||||
type: application
|
||||
version: 0.1.0
|
||||
appVersion: "0.21.0"
|
||||
keywords:
|
||||
- nextcloud
|
||||
- mcp
|
||||
- model-context-protocol
|
||||
- llm
|
||||
- ai
|
||||
- claude
|
||||
- webdav
|
||||
- caldav
|
||||
- carddav
|
||||
maintainers:
|
||||
- name: Chris Coutinho
|
||||
email: chris@coutinho.io
|
||||
home: https://github.com/cbcoutinho/nextcloud-mcp-server
|
||||
sources:
|
||||
- https://github.com/cbcoutinho/nextcloud-mcp-server
|
||||
icon: https://raw.githubusercontent.com/nextcloud/server/master/core/img/logo/logo.svg
|
||||
@@ -0,0 +1,742 @@
|
||||
# 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 URL for browser-accessible OAuth authorization endpoint (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, defaults to `nextcloud.host`. **Only used for authorization endpoints** that browsers must access. All server-to-server endpoints (token, JWKS, introspection, userinfo) use URLs from OIDC discovery without rewriting
|
||||
|
||||
#### 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` |
|
||||
|
||||
#### Data Storage
|
||||
|
||||
The `/app/data` directory is used for application data (token databases, Qdrant persistent storage, etc.). It is always mounted as writable to support the read-only root filesystem security context.
|
||||
|
||||
| Parameter | Description | Default |
|
||||
|-----------|-------------|---------|
|
||||
| `dataStorage.enabled` | Enable persistent storage for `/app/data` | `false` |
|
||||
| `dataStorage.size` | Size of data storage PVC | `1Gi` |
|
||||
| `dataStorage.storageClass` | Storage class (leave empty for default) | `""` |
|
||||
| `dataStorage.accessMode` | Access mode | `ReadWriteOnce` |
|
||||
| `dataStorage.existingClaim` | Use existing PVC | `""` |
|
||||
|
||||
**When to enable persistence:**
|
||||
- Multi-user basic auth with offline access (stores `tokens.db`)
|
||||
- Qdrant persistent mode (stores vector database)
|
||||
- Any feature requiring persistent app data
|
||||
|
||||
**When persistence is disabled:** Uses `emptyDir` (non-persistent, data lost on pod restart, but directory remains writable).
|
||||
|
||||
#### 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 with BM25 hybrid search by deploying a vector database (Qdrant) and embedding service (Ollama or OpenAI).
|
||||
|
||||
**Semantic Search Configuration:**
|
||||
|
||||
| Parameter | Description | Default |
|
||||
|-----------|-------------|---------|
|
||||
| `semanticSearch.enabled` | Enable semantic search and background vector synchronization | `false` |
|
||||
| `semanticSearch.scanInterval` | Scan interval in seconds | `3600` |
|
||||
| `semanticSearch.processorWorkers` | Number of concurrent processor workers | `3` |
|
||||
| `semanticSearch.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 (only used for browser-accessible auth endpoint)
|
||||
|
||||
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 (only used for browser-accessible auth endpoint)
|
||||
|
||||
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 semantic search
|
||||
semanticSearch:
|
||||
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
|
||||
semanticSearch:
|
||||
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
|
||||
semanticSearch:
|
||||
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
|
||||
- For OAuth mode: Ensure MCP server can reach OIDC discovery endpoints (token, JWKS, introspection, userinfo URLs)
|
||||
- Check network policies and firewall rules
|
||||
- Note: Do not use internal Docker hostnames (like `http://app:80`) for `nextcloud.host` - use externally resolvable URLs
|
||||
|
||||
2. **Authentication failures**
|
||||
- For basic auth: verify username/password are correct
|
||||
- For OAuth: check that OIDC app is properly configured
|
||||
|
||||
3. **OAuth persistence issues**
|
||||
- Verify PVC is bound: `kubectl get pvc`
|
||||
- Check storage class exists: `kubectl get storageclass`
|
||||
|
||||
4. **Resource constraints**
|
||||
- Increase memory limits if seeing OOM errors
|
||||
- Adjust CPU requests based on load
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **Secrets Management**: Consider using external secret management (e.g., Sealed Secrets, External Secrets Operator)
|
||||
2. **TLS**: Always use TLS/HTTPS for production deployments
|
||||
3. **Network Policies**: Restrict network access to necessary services only
|
||||
4. **RBAC**: Review and customize ServiceAccount permissions as needed
|
||||
5. **App Passwords**: For basic auth, use Nextcloud app passwords instead of main account passwords
|
||||
|
||||
## Support
|
||||
|
||||
- GitHub Issues: https://github.com/cbcoutinho/nextcloud-mcp-server/issues
|
||||
- Documentation: https://github.com/cbcoutinho/nextcloud-mcp-server#readme
|
||||
|
||||
## License
|
||||
|
||||
This chart is licensed under AGPL-3.0, consistent with the Nextcloud MCP Server project.
|
||||
@@ -0,0 +1,80 @@
|
||||
Thank you for installing {{ .Chart.Name }}!
|
||||
|
||||
Your Nextcloud MCP Server has been deployed in {{ .Values.auth.mode }} authentication mode.
|
||||
|
||||
1. Get the application URL by running these commands:
|
||||
{{- if .Values.ingress.enabled }}
|
||||
{{- range $host := .Values.ingress.hosts }}
|
||||
{{- range .paths }}
|
||||
http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}{{ .path }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- else if contains "NodePort" .Values.service.type }}
|
||||
export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "nextcloud-mcp-server.fullname" . }})
|
||||
export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}")
|
||||
echo http://$NODE_IP:$NODE_PORT
|
||||
{{- else if contains "LoadBalancer" .Values.service.type }}
|
||||
NOTE: It may take a few minutes for the LoadBalancer IP to be available.
|
||||
You can watch the status of by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "nextcloud-mcp-server.fullname" . }}'
|
||||
export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "nextcloud-mcp-server.fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}")
|
||||
echo http://$SERVICE_IP:{{ .Values.service.port }}
|
||||
{{- else if contains "ClusterIP" .Values.service.type }}
|
||||
export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "nextcloud-mcp-server.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}")
|
||||
export CONTAINER_PORT=$(kubectl get pod --namespace {{ .Release.Namespace }} $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}")
|
||||
echo "Visit http://127.0.0.1:8080 to use your MCP server"
|
||||
kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:$CONTAINER_PORT
|
||||
{{- end }}
|
||||
|
||||
2. Check the deployment status:
|
||||
kubectl --namespace {{ .Release.Namespace }} get pods -l "app.kubernetes.io/name={{ include "nextcloud-mcp-server.name" . }},app.kubernetes.io/instance={{ .Release.Name }}"
|
||||
|
||||
{{- if eq .Values.auth.mode "basic" }}
|
||||
|
||||
3. Basic Authentication Mode:
|
||||
{{- if .Values.auth.basic.existingSecret }}
|
||||
- Credentials: (using existing secret {{ .Values.auth.basic.existingSecret }})
|
||||
{{- else }}
|
||||
- Username: {{ .Values.auth.basic.username }}
|
||||
- Password: (stored in secret {{ include "nextcloud-mcp-server.basicAuthSecretName" . }})
|
||||
{{- end }}
|
||||
- Connected to: {{ .Values.nextcloud.host }}
|
||||
{{- else if eq .Values.auth.mode "oauth" }}
|
||||
|
||||
3. OAuth Authentication Mode:
|
||||
- Server URL: {{ include "nextcloud-mcp-server.mcpServerUrl" . }}
|
||||
- Issuer URL: {{ include "nextcloud-mcp-server.publicIssuerUrl" . }}
|
||||
- Connected to: {{ .Values.nextcloud.host }}
|
||||
{{- if .Values.auth.oauth.existingSecret }}
|
||||
- Using existing OAuth client secret: {{ .Values.auth.oauth.existingSecret }}
|
||||
{{- else if and .Values.auth.oauth.clientId .Values.auth.oauth.clientSecret }}
|
||||
- Using pre-registered OAuth client
|
||||
{{- else }}
|
||||
- Using Dynamic Client Registration (DCR)
|
||||
{{- end }}
|
||||
{{- if .Values.auth.oauth.persistence.enabled }}
|
||||
- OAuth client credentials are persisted in PVC: {{ include "nextcloud-mcp-server.oauthPvcName" . }}
|
||||
{{- end }}
|
||||
|
||||
IMPORTANT: OAuth mode is experimental and requires patches to the user_oidc app.
|
||||
See: https://github.com/cbcoutinho/nextcloud-mcp-server#authentication
|
||||
{{- end }}
|
||||
|
||||
{{- if .Values.documentProcessing.enabled }}
|
||||
|
||||
4. Document Processing:
|
||||
- Enabled: {{ .Values.documentProcessing.enabled }}
|
||||
- Default processor: {{ .Values.documentProcessing.defaultProcessor }}
|
||||
{{- if .Values.documentProcessing.unstructured.enabled }}
|
||||
- Unstructured API: {{ .Values.documentProcessing.unstructured.apiUrl }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
For more information and documentation:
|
||||
- GitHub: https://github.com/cbcoutinho/nextcloud-mcp-server
|
||||
- Documentation: https://github.com/cbcoutinho/nextcloud-mcp-server#readme
|
||||
|
||||
To upgrade this deployment:
|
||||
helm upgrade {{ .Release.Name }} nextcloud-mcp-server
|
||||
|
||||
To uninstall:
|
||||
helm uninstall {{ .Release.Name }}
|
||||
@@ -0,0 +1,146 @@
|
||||
{{/*
|
||||
Expand the name of the chart.
|
||||
*/}}
|
||||
{{- define "nextcloud-mcp-server.name" -}}
|
||||
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Create a default fully qualified app name.
|
||||
We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).
|
||||
If release name contains chart name it will be used as a full name.
|
||||
*/}}
|
||||
{{- define "nextcloud-mcp-server.fullname" -}}
|
||||
{{- if .Values.fullnameOverride }}
|
||||
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
|
||||
{{- else }}
|
||||
{{- $name := default .Chart.Name .Values.nameOverride }}
|
||||
{{- if contains $name .Release.Name }}
|
||||
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
|
||||
{{- else }}
|
||||
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Create chart name and version as used by the chart label.
|
||||
*/}}
|
||||
{{- define "nextcloud-mcp-server.chart" -}}
|
||||
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Common labels
|
||||
*/}}
|
||||
{{- define "nextcloud-mcp-server.labels" -}}
|
||||
helm.sh/chart: {{ include "nextcloud-mcp-server.chart" . }}
|
||||
{{ include "nextcloud-mcp-server.selectorLabels" . }}
|
||||
{{- if .Chart.AppVersion }}
|
||||
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
|
||||
{{- end }}
|
||||
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Selector labels
|
||||
*/}}
|
||||
{{- define "nextcloud-mcp-server.selectorLabels" -}}
|
||||
app.kubernetes.io/name: {{ include "nextcloud-mcp-server.name" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Create the name of the service account to use
|
||||
*/}}
|
||||
{{- define "nextcloud-mcp-server.serviceAccountName" -}}
|
||||
{{- if .Values.serviceAccount.create }}
|
||||
{{- default (include "nextcloud-mcp-server.fullname" .) .Values.serviceAccount.name }}
|
||||
{{- else }}
|
||||
{{- default "default" .Values.serviceAccount.name }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Create the name of the secret to use for basic auth
|
||||
*/}}
|
||||
{{- define "nextcloud-mcp-server.basicAuthSecretName" -}}
|
||||
{{- if .Values.auth.basic.existingSecret }}
|
||||
{{- .Values.auth.basic.existingSecret }}
|
||||
{{- else }}
|
||||
{{- include "nextcloud-mcp-server.fullname" . }}-basic-auth
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Create the name of the secret to use for OAuth
|
||||
*/}}
|
||||
{{- define "nextcloud-mcp-server.oauthSecretName" -}}
|
||||
{{- if .Values.auth.oauth.existingSecret }}
|
||||
{{- .Values.auth.oauth.existingSecret }}
|
||||
{{- else }}
|
||||
{{- include "nextcloud-mcp-server.fullname" . }}-oauth
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Create the name of the PVC to use for OAuth storage
|
||||
*/}}
|
||||
{{- define "nextcloud-mcp-server.oauthPvcName" -}}
|
||||
{{- if .Values.auth.oauth.persistence.existingClaim }}
|
||||
{{- .Values.auth.oauth.persistence.existingClaim }}
|
||||
{{- else }}
|
||||
{{- include "nextcloud-mcp-server.fullname" . }}-oauth-storage
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Return the appropriate MCP server port based on auth mode
|
||||
*/}}
|
||||
{{- define "nextcloud-mcp-server.port" -}}
|
||||
{{- if eq .Values.auth.mode "oauth" }}
|
||||
{{- .Values.auth.oauth.port }}
|
||||
{{- else }}
|
||||
{{- .Values.mcp.port }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Return the image tag
|
||||
*/}}
|
||||
{{- define "nextcloud-mcp-server.imageTag" -}}
|
||||
{{- .Values.image.tag | default .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,189 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ include "nextcloud-mcp-server.fullname" . }}
|
||||
labels:
|
||||
{{- include "nextcloud-mcp-server.labels" . | nindent 4 }}
|
||||
spec:
|
||||
{{- if not .Values.autoscaling.enabled }}
|
||||
replicas: {{ .Values.replicaCount }}
|
||||
{{- end }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "nextcloud-mcp-server.selectorLabels" . | nindent 6 }}
|
||||
template:
|
||||
metadata:
|
||||
annotations:
|
||||
checksum/secret: {{ include (print $.Template.BasePath "/secret.yaml") . | sha256sum }}
|
||||
{{- with .Values.podAnnotations }}
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
labels:
|
||||
{{- include "nextcloud-mcp-server.labels" . | nindent 8 }}
|
||||
{{- with .Values.podLabels }}
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
{{- with .Values.imagePullSecrets }}
|
||||
imagePullSecrets:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
serviceAccountName: {{ include "nextcloud-mcp-server.serviceAccountName" . }}
|
||||
securityContext:
|
||||
{{- toYaml .Values.podSecurityContext | nindent 8 }}
|
||||
{{- with .Values.initContainers }}
|
||||
initContainers:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
containers:
|
||||
- name: {{ .Chart.Name }}
|
||||
securityContext:
|
||||
{{- toYaml .Values.securityContext | nindent 12 }}
|
||||
image: "{{ .Values.image.repository }}:{{ include "nextcloud-mcp-server.imageTag" . }}"
|
||||
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
||||
args:
|
||||
- "--transport"
|
||||
- "{{ .Values.mcp.transport }}"
|
||||
{{- if eq .Values.auth.mode "oauth" }}
|
||||
- "--oauth"
|
||||
- "--port"
|
||||
- "{{ .Values.auth.oauth.port }}"
|
||||
- "--oauth-token-type"
|
||||
- "{{ .Values.auth.oauth.tokenType }}"
|
||||
{{- end }}
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: {{ include "nextcloud-mcp-server.port" . }}
|
||||
protocol: TCP
|
||||
env:
|
||||
# Nextcloud connection
|
||||
- name: NEXTCLOUD_HOST
|
||||
value: {{ .Values.nextcloud.host | quote }}
|
||||
{{- if eq .Values.auth.mode "basic" }}
|
||||
# Basic auth mode
|
||||
- name: NEXTCLOUD_USERNAME
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ include "nextcloud-mcp-server.basicAuthSecretName" . }}
|
||||
key: {{ .Values.auth.basic.usernameKey }}
|
||||
- name: NEXTCLOUD_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ include "nextcloud-mcp-server.basicAuthSecretName" . }}
|
||||
key: {{ .Values.auth.basic.passwordKey }}
|
||||
{{- else if eq .Values.auth.mode "oauth" }}
|
||||
# OAuth mode
|
||||
- name: NEXTCLOUD_MCP_SERVER_URL
|
||||
value: {{ include "nextcloud-mcp-server.mcpServerUrl" . | quote }}
|
||||
- name: NEXTCLOUD_PUBLIC_ISSUER_URL
|
||||
value: {{ include "nextcloud-mcp-server.publicIssuerUrl" . | quote }}
|
||||
- name: NEXTCLOUD_OIDC_CLIENT_STORAGE
|
||||
value: "/app/.oauth/nextcloud_oauth_client.json"
|
||||
- name: NEXTCLOUD_OIDC_SCOPES
|
||||
value: {{ .Values.auth.oauth.scopes | quote }}
|
||||
{{- if .Values.auth.oauth.clientId }}
|
||||
- name: NEXTCLOUD_OIDC_CLIENT_ID
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ include "nextcloud-mcp-server.oauthSecretName" . }}
|
||||
key: {{ .Values.auth.oauth.clientIdKey }}
|
||||
- name: NEXTCLOUD_OIDC_CLIENT_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ include "nextcloud-mcp-server.oauthSecretName" . }}
|
||||
key: {{ .Values.auth.oauth.clientSecretKey }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- if .Values.documentProcessing.enabled }}
|
||||
# Document processing
|
||||
- name: ENABLE_DOCUMENT_PROCESSING
|
||||
value: {{ .Values.documentProcessing.enabled | quote }}
|
||||
- name: DOCUMENT_PROCESSOR
|
||||
value: {{ .Values.documentProcessing.defaultProcessor | quote }}
|
||||
- name: PROGRESS_INTERVAL
|
||||
value: {{ .Values.documentProcessing.progressInterval | quote }}
|
||||
{{- if .Values.documentProcessing.unstructured.enabled }}
|
||||
- name: ENABLE_UNSTRUCTURED
|
||||
value: "true"
|
||||
- name: UNSTRUCTURED_API_URL
|
||||
value: {{ .Values.documentProcessing.unstructured.apiUrl | quote }}
|
||||
- name: UNSTRUCTURED_TIMEOUT
|
||||
value: {{ .Values.documentProcessing.unstructured.timeout | quote }}
|
||||
- name: UNSTRUCTURED_STRATEGY
|
||||
value: {{ .Values.documentProcessing.unstructured.strategy | quote }}
|
||||
- name: UNSTRUCTURED_LANGUAGES
|
||||
value: {{ .Values.documentProcessing.unstructured.languages | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.documentProcessing.tesseract.enabled }}
|
||||
- name: ENABLE_TESSERACT
|
||||
value: "true"
|
||||
{{- if .Values.documentProcessing.tesseract.cmd }}
|
||||
- name: TESSERACT_CMD
|
||||
value: {{ .Values.documentProcessing.tesseract.cmd | quote }}
|
||||
{{- end }}
|
||||
- name: TESSERACT_LANG
|
||||
value: {{ .Values.documentProcessing.tesseract.lang | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.documentProcessing.custom.enabled }}
|
||||
- name: ENABLE_CUSTOM_PROCESSOR
|
||||
value: "true"
|
||||
- name: CUSTOM_PROCESSOR_NAME
|
||||
value: {{ .Values.documentProcessing.custom.name | quote }}
|
||||
- name: CUSTOM_PROCESSOR_URL
|
||||
value: {{ .Values.documentProcessing.custom.url | quote }}
|
||||
{{- if .Values.documentProcessing.custom.apiKey }}
|
||||
- name: CUSTOM_PROCESSOR_API_KEY
|
||||
value: {{ .Values.documentProcessing.custom.apiKey | quote }}
|
||||
{{- end }}
|
||||
- name: CUSTOM_PROCESSOR_TIMEOUT
|
||||
value: {{ .Values.documentProcessing.custom.timeout | quote }}
|
||||
- name: CUSTOM_PROCESSOR_TYPES
|
||||
value: {{ .Values.documentProcessing.custom.types | quote }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- with .Values.extraEnv }}
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with .Values.extraEnvFrom }}
|
||||
envFrom:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
livenessProbe:
|
||||
{{- toYaml .Values.livenessProbe | nindent 12 }}
|
||||
readinessProbe:
|
||||
{{- toYaml .Values.readinessProbe | nindent 12 }}
|
||||
resources:
|
||||
{{- toYaml .Values.resources | nindent 12 }}
|
||||
volumeMounts:
|
||||
- name: tmp
|
||||
mountPath: /tmp
|
||||
{{- if and (eq .Values.auth.mode "oauth") .Values.auth.oauth.persistence.enabled }}
|
||||
- name: oauth-storage
|
||||
mountPath: /app/.oauth
|
||||
{{- end }}
|
||||
{{- with .Values.volumeMounts }}
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
volumes:
|
||||
- name: tmp
|
||||
emptyDir: {}
|
||||
{{- if and (eq .Values.auth.mode "oauth") .Values.auth.oauth.persistence.enabled }}
|
||||
- name: oauth-storage
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ include "nextcloud-mcp-server.oauthPvcName" . }}
|
||||
{{- end }}
|
||||
{{- with .Values.volumes }}
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.nodeSelector }}
|
||||
nodeSelector:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.affinity }}
|
||||
affinity:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.tolerations }}
|
||||
tolerations:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,32 @@
|
||||
{{- if .Values.autoscaling.enabled }}
|
||||
apiVersion: autoscaling/v2
|
||||
kind: HorizontalPodAutoscaler
|
||||
metadata:
|
||||
name: {{ include "nextcloud-mcp-server.fullname" . }}
|
||||
labels:
|
||||
{{- include "nextcloud-mcp-server.labels" . | nindent 4 }}
|
||||
spec:
|
||||
scaleTargetRef:
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
name: {{ include "nextcloud-mcp-server.fullname" . }}
|
||||
minReplicas: {{ .Values.autoscaling.minReplicas }}
|
||||
maxReplicas: {{ .Values.autoscaling.maxReplicas }}
|
||||
metrics:
|
||||
{{- if .Values.autoscaling.targetCPUUtilizationPercentage }}
|
||||
- type: Resource
|
||||
resource:
|
||||
name: cpu
|
||||
target:
|
||||
type: Utilization
|
||||
averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }}
|
||||
{{- end }}
|
||||
{{- if .Values.autoscaling.targetMemoryUtilizationPercentage }}
|
||||
- type: Resource
|
||||
resource:
|
||||
name: memory
|
||||
target:
|
||||
type: Utilization
|
||||
averageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,61 @@
|
||||
{{- if .Values.ingress.enabled -}}
|
||||
{{- $fullName := include "nextcloud-mcp-server.fullname" . -}}
|
||||
{{- $svcPort := .Values.service.port -}}
|
||||
{{- if and .Values.ingress.className (not (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion)) }}
|
||||
{{- if not (hasKey .Values.ingress.annotations "kubernetes.io/ingress.class") }}
|
||||
{{- $_ := set .Values.ingress.annotations "kubernetes.io/ingress.class" .Values.ingress.className}}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- if semverCompare ">=1.19-0" .Capabilities.KubeVersion.GitVersion -}}
|
||||
apiVersion: networking.k8s.io/v1
|
||||
{{- else if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}}
|
||||
apiVersion: networking.k8s.io/v1beta1
|
||||
{{- else -}}
|
||||
apiVersion: extensions/v1beta1
|
||||
{{- end }}
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: {{ $fullName }}
|
||||
labels:
|
||||
{{- include "nextcloud-mcp-server.labels" . | nindent 4 }}
|
||||
{{- with .Values.ingress.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
{{- if and .Values.ingress.className (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion) }}
|
||||
ingressClassName: {{ .Values.ingress.className }}
|
||||
{{- end }}
|
||||
{{- if .Values.ingress.tls }}
|
||||
tls:
|
||||
{{- range .Values.ingress.tls }}
|
||||
- hosts:
|
||||
{{- range .hosts }}
|
||||
- {{ . | quote }}
|
||||
{{- end }}
|
||||
secretName: {{ .secretName }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
rules:
|
||||
{{- range .Values.ingress.hosts }}
|
||||
- host: {{ .host | quote }}
|
||||
http:
|
||||
paths:
|
||||
{{- range .paths }}
|
||||
- path: {{ .path }}
|
||||
{{- if and .pathType (semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion) }}
|
||||
pathType: {{ .pathType }}
|
||||
{{- end }}
|
||||
backend:
|
||||
{{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }}
|
||||
service:
|
||||
name: {{ $fullName }}
|
||||
port:
|
||||
number: {{ $svcPort }}
|
||||
{{- else }}
|
||||
serviceName: {{ $fullName }}
|
||||
servicePort: {{ $svcPort }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,17 @@
|
||||
{{- if and (eq .Values.auth.mode "oauth") .Values.auth.oauth.persistence.enabled (not .Values.auth.oauth.persistence.existingClaim) }}
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: {{ include "nextcloud-mcp-server.fullname" . }}-oauth-storage
|
||||
labels:
|
||||
{{- include "nextcloud-mcp-server.labels" . | nindent 4 }}
|
||||
spec:
|
||||
accessModes:
|
||||
- {{ .Values.auth.oauth.persistence.accessMode }}
|
||||
{{- if .Values.auth.oauth.persistence.storageClass }}
|
||||
storageClassName: {{ .Values.auth.oauth.persistence.storageClass }}
|
||||
{{- end }}
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ .Values.auth.oauth.persistence.size }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,29 @@
|
||||
{{- if eq .Values.auth.mode "basic" }}
|
||||
{{- if not .Values.auth.basic.existingSecret }}
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: {{ include "nextcloud-mcp-server.fullname" . }}-basic-auth
|
||||
labels:
|
||||
{{- include "nextcloud-mcp-server.labels" . | nindent 4 }}
|
||||
type: Opaque
|
||||
data:
|
||||
{{ .Values.auth.basic.usernameKey }}: {{ .Values.auth.basic.username | b64enc | quote }}
|
||||
{{ .Values.auth.basic.passwordKey }}: {{ .Values.auth.basic.password | b64enc | quote }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
---
|
||||
{{- if eq .Values.auth.mode "oauth" }}
|
||||
{{- if and .Values.auth.oauth.clientId (not .Values.auth.oauth.existingSecret) }}
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: {{ include "nextcloud-mcp-server.fullname" . }}-oauth
|
||||
labels:
|
||||
{{- include "nextcloud-mcp-server.labels" . | nindent 4 }}
|
||||
type: Opaque
|
||||
data:
|
||||
{{ .Values.auth.oauth.clientIdKey }}: {{ .Values.auth.oauth.clientId | b64enc | quote }}
|
||||
{{ .Values.auth.oauth.clientSecretKey }}: {{ .Values.auth.oauth.clientSecret | b64enc | quote }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,19 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ include "nextcloud-mcp-server.fullname" . }}
|
||||
labels:
|
||||
{{- include "nextcloud-mcp-server.labels" . | nindent 4 }}
|
||||
{{- with .Values.service.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
type: {{ .Values.service.type }}
|
||||
ports:
|
||||
- port: {{ .Values.service.port }}
|
||||
targetPort: http
|
||||
protocol: TCP
|
||||
name: http
|
||||
selector:
|
||||
{{- include "nextcloud-mcp-server.selectorLabels" . | nindent 4 }}
|
||||
@@ -0,0 +1,13 @@
|
||||
{{- if .Values.serviceAccount.create -}}
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: {{ include "nextcloud-mcp-server.serviceAccountName" . }}
|
||||
labels:
|
||||
{{- include "nextcloud-mcp-server.labels" . | nindent 4 }}
|
||||
{{- with .Values.serviceAccount.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
automountServiceAccountToken: {{ .Values.serviceAccount.automount }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,268 @@
|
||||
# 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
|
||||
# Overrides the image tag whose default is the chart appVersion.
|
||||
tag: ""
|
||||
|
||||
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:
|
||||
# Port for OAuth MCP server (default: 8001)
|
||||
port: 8001
|
||||
# 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 basic auth mode
|
||||
port: 8000
|
||||
|
||||
# Document processing configuration (optional)
|
||||
documentProcessing:
|
||||
# Enable document processing (PDF, DOCX, images, etc.)
|
||||
enabled: false
|
||||
# Default processor: unstructured, tesseract, or custom
|
||||
defaultProcessor: "unstructured"
|
||||
# Progress reporting interval in seconds
|
||||
progressInterval: 10
|
||||
|
||||
# Unstructured.io processor
|
||||
unstructured:
|
||||
enabled: false
|
||||
# Unstructured API endpoint
|
||||
apiUrl: "http://unstructured:8000"
|
||||
# Request timeout in seconds
|
||||
timeout: 120
|
||||
# Parsing strategy: auto, fast, or hi_res
|
||||
strategy: "auto"
|
||||
# OCR languages (comma-separated ISO 639-3 codes)
|
||||
languages: "eng,deu"
|
||||
|
||||
# Tesseract processor (local OCR)
|
||||
tesseract:
|
||||
enabled: false
|
||||
# Path to tesseract executable (optional, auto-detected if in PATH)
|
||||
cmd: ""
|
||||
# OCR language (e.g., eng, deu, eng+deu for multiple)
|
||||
lang: "eng"
|
||||
|
||||
# Custom processor
|
||||
custom:
|
||||
enabled: false
|
||||
# Unique name for your processor
|
||||
name: "my_ocr"
|
||||
# Custom processor API endpoint
|
||||
url: ""
|
||||
# Optional API key for authentication
|
||||
apiKey: ""
|
||||
# Request timeout in seconds
|
||||
timeout: 60
|
||||
# Comma-separated MIME types your processor supports
|
||||
types: "application/pdf,image/jpeg,image/png"
|
||||
|
||||
serviceAccount:
|
||||
# Specifies whether a service account should be created
|
||||
create: true
|
||||
# Automatically mount a ServiceAccount's API credentials?
|
||||
automount: true
|
||||
# Annotations to add to the service account
|
||||
annotations: {}
|
||||
# The name of the service account to use.
|
||||
# If not set and create is true, a name is generated using the fullname template
|
||||
name: ""
|
||||
|
||||
podAnnotations: {}
|
||||
podLabels: {}
|
||||
|
||||
podSecurityContext:
|
||||
fsGroup: 2000
|
||||
|
||||
securityContext:
|
||||
capabilities:
|
||||
drop:
|
||||
- ALL
|
||||
readOnlyRootFilesystem: true
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1000
|
||||
|
||||
service:
|
||||
type: ClusterIP
|
||||
port: 8000
|
||||
# For OAuth mode, you may want to expose both ports
|
||||
oauthPort: 8001
|
||||
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
|
||||
+51
-9
@@ -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:1e4ec03d1b73af8e7a63137b8ef4820ac7d54c654a1e99eb76235f210f7f0a06
|
||||
image: docker.io/library/mariadb:lts@sha256:ae6119716edac6998ae85508431b3d2e666530ddf4e94c61a10710caec9b0f71
|
||||
restart: always
|
||||
command: --transaction-isolation=READ-COMMITTED
|
||||
volumes:
|
||||
@@ -17,24 +17,23 @@ 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:73734b014e53b3067916918b70718ca188c16895511a272a020c9a71084eecda
|
||||
image: docker.io/library/redis:alpine@sha256:59b6e694653476de2c992937ebe1c64182af4728e54bb49e9b7a6c26614d8933
|
||||
restart: always
|
||||
|
||||
app:
|
||||
image: nextcloud:31.0.6@sha256:588609d76b217cfd0feda653eea9894eeb12e612b327d2f1dcd38221ad242be0
|
||||
#user: www-data:www-data
|
||||
image: docker.io/library/nextcloud:32.0.1@sha256:1e4eae55eebe094cae6f9e7b6e0b4bccf4a4fe7b7e6f6f8f57010994b3b2ee42
|
||||
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
|
||||
volumes:
|
||||
- nextcloud:/var/www/html
|
||||
- ./app-hooks/post-installation:/docker-entrypoint-hooks.d/post-installation: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/oidc:/opt/apps/oidc:ro
|
||||
environment:
|
||||
- NEXTCLOUD_TRUSTED_DOMAINS=app
|
||||
- NEXTCLOUD_ADMIN_USER=admin
|
||||
@@ -43,16 +42,59 @@ services:
|
||||
- MYSQL_DATABASE=nextcloud
|
||||
- MYSQL_USER=nextcloud
|
||||
- MYSQL_HOST=db
|
||||
- REDIS_HOST=redis
|
||||
|
||||
recipes:
|
||||
image: docker.io/library/nginx:alpine@sha256:9dacca6749f2215cc3094f641c5b6662f7791e66a57ed034e806a7c48d51c18f
|
||||
restart: always
|
||||
volumes:
|
||||
- ./tests/fixtures/test_recipe.html:/usr/share/nginx/html/test_recipe.html:ro
|
||||
- ./tests/fixtures/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
|
||||
unstructured:
|
||||
image: downloads.unstructured.io/unstructured-io/unstructured-api:latest@sha256:a43ab55898599157fb0e0e097dabb8ecdd1d8e3df1ae5b67c6e15a136b171a6c
|
||||
restart: always
|
||||
ports:
|
||||
- 127.0.0.1:8002:8000
|
||||
# Unstructured API runs on port 8000 internally
|
||||
# We expose it on 8002 externally to avoid conflict
|
||||
profiles:
|
||||
- unstructured
|
||||
|
||||
mcp:
|
||||
build: .
|
||||
command: ["--transport", "streamable-http"]
|
||||
restart: always
|
||||
depends_on:
|
||||
- app
|
||||
ports:
|
||||
- 8000:8000
|
||||
- 127.0.0.1:8000:8000
|
||||
environment:
|
||||
- NEXTCLOUD_HOST=http://app:80
|
||||
- NEXTCLOUD_USERNAME=admin
|
||||
- NEXTCLOUD_PASSWORD=admin
|
||||
|
||||
mcp-oauth:
|
||||
build: .
|
||||
command: ["--transport", "streamable-http", "--oauth", "--port", "8001", "--oauth-token-type", "jwt"]
|
||||
restart: always
|
||||
depends_on:
|
||||
- app
|
||||
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_STORAGE=/app/.oauth/nextcloud_oauth_client.json
|
||||
- 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
|
||||
# No USERNAME/PASSWORD - will use OAuth with Dynamic Client Registration
|
||||
# Client credentials will be registered and stored in volume on first startup
|
||||
# JWT token type is used for testing (faster validation, scopes embedded in token)
|
||||
volumes:
|
||||
- oauth-client-storage:/app/.oauth
|
||||
|
||||
volumes:
|
||||
nextcloud:
|
||||
db:
|
||||
oauth-client-storage:
|
||||
|
||||
@@ -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,253 @@
|
||||
# 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 Storage and Callback Settings (optional)
|
||||
NEXTCLOUD_OIDC_CLIENT_STORAGE=.nextcloud_oauth_client.json
|
||||
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_OIDC_CLIENT_STORAGE` | ⚠️ Optional | `.nextcloud_oauth_client.json` | Path to store auto-registered client credentials |
|
||||
| `NEXTCLOUD_MCP_SERVER_URL` | ⚠️ Optional | `http://localhost:8000` | MCP server URL for OAuth callbacks |
|
||||
| `NEXTCLOUD_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). |
|
||||
|
||||
---
|
||||
|
||||
## 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)
|
||||
--oauth-storage-path TEXT Path to store OAuth client credentials
|
||||
(can also use
|
||||
NEXTCLOUD_OIDC_CLIENT_STORAGE env var)
|
||||
[default: .nextcloud_oauth_client.json]
|
||||
--mcp-server-url TEXT MCP server URL for OAuth callbacks (can
|
||||
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
|
||||
- Set appropriate file permissions on credential storage:
|
||||
```bash
|
||||
chmod 600 .nextcloud_oauth_client.json
|
||||
```
|
||||
|
||||
### 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,899 @@
|
||||
# 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 `.nextcloud_oauth_client.json` 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_CLIENT_STORAGE` | Path to persist DCR-registered credentials | `.nextcloud_oauth_client.json` |
|
||||
| `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. Storage File (Second Priority)
|
||||
└─ NEXTCLOUD_OIDC_CLIENT_STORAGE (.nextcloud_oauth_client.json)
|
||||
|
||||
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 `NEXTCLOUD_OIDC_CLIENT_STORAGE` (default: `.nextcloud_oauth_client.json`)
|
||||
- File has restrictive permissions (0600 - owner read/write only)
|
||||
- Credentials are reused on subsequent starts (no re-registration needed)
|
||||
- Storage file is 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: Storage file (second priority)
|
||||
# Save the JSON response to .nextcloud_oauth_client.json
|
||||
# Server will automatically load it 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. Delete `.nextcloud_oauth_client.json` 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,19 @@
|
||||
# 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 |
|
||||
|
||||
### 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,747 @@
|
||||
# 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 .nextcloud_oauth_*.json │
|
||||
│ │
|
||||
│ ✓ 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 `.nextcloud_oauth_client.json`
|
||||
- **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 `.nextcloud_oauth_client.json`
|
||||
|
||||
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 `.nextcloud_oauth_client.json`
|
||||
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
|
||||
|
||||
### 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 |
|
||||
| `NEXTCLOUD_OIDC_CLIENT_STORAGE` | Path for auto-registered credentials |
|
||||
|
||||
## 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,545 @@
|
||||
# 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 `.nextcloud_oauth_client.json`
|
||||
- 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
|
||||
|
||||
# Optional: Client storage path
|
||||
NEXTCLOUD_OIDC_CLIENT_STORAGE=.nextcloud_oauth_client.json
|
||||
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_OIDC_CLIENT_STORAGE` | ⚠️ Optional | `.nextcloud_oauth_client.json` | Client credentials storage path |
|
||||
| `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 .nextcloud_oauth_client.json
|
||||
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
|
||||
chmod 600 .nextcloud_oauth_client.json
|
||||
chmod 600 .env
|
||||
```
|
||||
|
||||
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:
|
||||
- ./oauth_client.json:/app/.nextcloud_oauth_client.json
|
||||
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,643 @@
|
||||
# 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) |
|
||||
| "Permission denied" on .nextcloud_oauth_client.json | File permissions issue | [File Permission Error](#file-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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### File Permission Error
|
||||
|
||||
**Error Message**:
|
||||
```
|
||||
Permission denied when reading/writing .nextcloud_oauth_client.json
|
||||
```
|
||||
|
||||
**Cause**: The server cannot access the OAuth client storage file.
|
||||
|
||||
**Solution**:
|
||||
|
||||
```bash
|
||||
# Check file permissions
|
||||
ls -la .nextcloud_oauth_client.json
|
||||
|
||||
# Fix file permissions (owner read/write only)
|
||||
chmod 600 .nextcloud_oauth_client.json
|
||||
|
||||
# Ensure directory is writable
|
||||
chmod 755 $(dirname .nextcloud_oauth_client.json)
|
||||
|
||||
# If file doesn't exist, ensure directory is writable
|
||||
mkdir -p $(dirname .nextcloud_oauth_client.json)
|
||||
```
|
||||
|
||||
For custom storage paths:
|
||||
```bash
|
||||
# Set custom path in .env
|
||||
NEXTCLOUD_OIDC_CLIENT_STORAGE=/path/to/custom/oauth_client.json
|
||||
|
||||
# Ensure directory exists and is writable
|
||||
mkdir -p $(dirname /path/to/custom/oauth_client.json)
|
||||
chmod 755 $(dirname /path/to/custom/oauth_client.json)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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,242 @@
|
||||
# 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**: `user_oidc` app
|
||||
|
||||
**Issue**: Bearer token authentication fails for app-specific APIs (Notes, Calendar, etc.) with `401 Unauthorized` errors, even though OCS APIs work correctly.
|
||||
|
||||
**Root Cause**: The `CORSMiddleware` in Nextcloud logs out sessions created by Bearer token authentication when CSRF tokens are missing, which breaks API requests.
|
||||
|
||||
**Solution**: Set the `app_api` session flag during Bearer token authentication to bypass CSRF checks.
|
||||
|
||||
**Upstream PR**: [nextcloud/user_oidc#1221](https://github.com/nextcloud/user_oidc/issues/1221)
|
||||
|
||||
**Workaround**: Manually apply the patch to `lib/User/Backend.php` in the `user_oidc` app
|
||||
|
||||
**Impact**:
|
||||
- ✅ **Works**: OCS APIs (`/ocs/v2.php/cloud/capabilities`)
|
||||
- ❌ **Requires Patch**: App APIs (`/apps/notes/api/`, `/apps/calendar/`, etc.)
|
||||
|
||||
**Files Modified**: `lib/User/Backend.php` in `user_oidc` app
|
||||
|
||||
**Patch Summary**:
|
||||
```php
|
||||
// Add before successful Bearer token authentication returns
|
||||
$this->session->set('app_api', true);
|
||||
```
|
||||
|
||||
This is added at lines ~243, ~310, ~315, and ~337 in `Backend.php`.
|
||||
|
||||
---
|
||||
|
||||
### 2. 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 |
|
||||
|----------|-----------|--------|----------|-------|
|
||||
| [user_oidc#1221](https://github.com/nextcloud/user_oidc/issues/1221) | `user_oidc` | 🟡 Open | High | Required for app-specific APIs |
|
||||
| [H2CK/oidc#584](https://github.com/H2CK/oidc/pull/584) | `oidc` | ✅ Merged | ~~Medium~~ | ✅ PKCE advertisement complete (v1.10.0+) |
|
||||
|
||||
## What Works Without Patches
|
||||
|
||||
The following functionality works **out of the box** without any patches:
|
||||
|
||||
✅ **OAuth Flow**:
|
||||
- OIDC discovery with full PKCE support (requires `oidc` app v1.10.0+)
|
||||
- Dynamic client registration
|
||||
- Authorization code flow with PKCE (S256 and plain methods)
|
||||
- Token exchange with code_verifier verification
|
||||
- Userinfo endpoint
|
||||
|
||||
✅ **MCP Server as Resource Server**:
|
||||
- Token validation via userinfo
|
||||
- Per-user client instances
|
||||
- Token caching
|
||||
|
||||
✅ **Nextcloud OCS APIs**:
|
||||
- Capabilities endpoint
|
||||
- All OCS-based APIs
|
||||
|
||||
## What Requires Patches
|
||||
|
||||
The following functionality requires upstream patches:
|
||||
|
||||
🟡 **App-Specific APIs** (Requires user_oidc#1221):
|
||||
- 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 these issues:
|
||||
|
||||
1. **Watch the upstream repositories**:
|
||||
- [nextcloud/user_oidc](https://github.com/nextcloud/user_oidc)
|
||||
- [nextcloud/oidc](https://github.com/nextcloud/oidc)
|
||||
|
||||
2. **Subscribe to specific issues**:
|
||||
- [user_oidc#1221](https://github.com/nextcloud/user_oidc/issues/1221) - Bearer token support
|
||||
|
||||
3. **Check Nextcloud release notes** for mentions of:
|
||||
- Bearer token authentication improvements
|
||||
- OIDC/OAuth enhancements
|
||||
- AppAPI compatibility
|
||||
|
||||
## 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-10-20
|
||||
|
||||
**Next Review**: When issue #1221 (Bearer token support) has activity
|
||||
@@ -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,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,556 @@
|
||||
# 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" when reading/writing OAuth client credentials file
|
||||
|
||||
**Cause:** The server cannot access the OAuth client storage file (default: `.nextcloud_oauth_client.json`).
|
||||
|
||||
**Solution:**
|
||||
|
||||
```bash
|
||||
# Check file permissions
|
||||
ls -la .nextcloud_oauth_client.json
|
||||
|
||||
# Fix file permissions (should be 0600 - owner read/write only)
|
||||
chmod 600 .nextcloud_oauth_client.json
|
||||
|
||||
# Ensure the directory is writable
|
||||
chmod 755 $(dirname .nextcloud_oauth_client.json)
|
||||
|
||||
# If the file doesn't exist, ensure the directory is writable so it can be created
|
||||
mkdir -p $(dirname .nextcloud_oauth_client.json)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 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")
|
||||
```
|
||||
+94
@@ -1,3 +1,97 @@
|
||||
# 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
|
||||
# - Optional: Pre-register client and provide credentials (otherwise auto-registers)
|
||||
NEXTCLOUD_OIDC_CLIENT_ID=
|
||||
NEXTCLOUD_OIDC_CLIENT_SECRET=
|
||||
NEXTCLOUD_OIDC_CLIENT_STORAGE=.nextcloud_oauth_client.json
|
||||
NEXTCLOUD_MCP_SERVER_URL=http://localhost:8000
|
||||
|
||||
# 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
|
||||
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
<!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://cbcoutinho.github.io/nextcloud-mcp-server/
|
||||
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/cbcoutinho/nextcloud-mcp-server">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>
|
||||
+6540
File diff suppressed because it is too large
Load Diff
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 load_or_register_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 .token_verifier import NextcloudTokenVerifier
|
||||
|
||||
__all__ = [
|
||||
"BearerAuth",
|
||||
"NextcloudTokenVerifier",
|
||||
"register_client",
|
||||
"load_or_register_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 @@
|
||||
"""Dynamic client registration for Nextcloud OIDC."""
|
||||
|
||||
import datetime as dt
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import anyio
|
||||
import httpx
|
||||
|
||||
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 = "Bearer",
|
||||
) -> ClientInfo:
|
||||
"""
|
||||
Register a new OAuth client with Nextcloud OIDC using dynamic client registration.
|
||||
|
||||
Args:
|
||||
nextcloud_url: Base URL of the Nextcloud instance
|
||||
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 to issue (default: "Bearer", also supports "JWT")
|
||||
|
||||
Returns:
|
||||
ClientInfo with registration details
|
||||
|
||||
Raises:
|
||||
httpx.HTTPStatusError: If registration fails
|
||||
ValueError: If response is invalid
|
||||
"""
|
||||
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,
|
||||
"token_type": token_type,
|
||||
}
|
||||
|
||||
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}")
|
||||
|
||||
|
||||
def load_client_from_file(storage_path: Path) -> ClientInfo | None:
|
||||
"""
|
||||
Load client credentials from storage file.
|
||||
|
||||
Args:
|
||||
storage_path: Path to the JSON file containing client credentials
|
||||
|
||||
Returns:
|
||||
ClientInfo if file exists and is valid, None otherwise
|
||||
"""
|
||||
if not storage_path.exists():
|
||||
logger.debug(f"Client storage file not found: {storage_path}")
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(storage_path, "r") as f:
|
||||
data = json.load(f)
|
||||
|
||||
client_info = ClientInfo.from_dict(data)
|
||||
|
||||
if client_info.is_expired:
|
||||
logger.warning(
|
||||
f"Stored client has expired (expired at {client_info.client_secret_expires_at})"
|
||||
)
|
||||
return None
|
||||
|
||||
logger.info(f"Loaded client from storage: {client_info.client_id[:16]}...")
|
||||
if client_info.expires_soon:
|
||||
logger.warning("Client expires soon (within 5 minutes)")
|
||||
|
||||
return client_info
|
||||
|
||||
except (json.JSONDecodeError, KeyError, ValueError) as e:
|
||||
logger.error(f"Failed to load client from file: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def save_client_to_file(client_info: ClientInfo, storage_path: Path):
|
||||
"""
|
||||
Save client credentials to storage file.
|
||||
|
||||
Args:
|
||||
client_info: Client information to save
|
||||
storage_path: Path to save the JSON file
|
||||
|
||||
Raises:
|
||||
OSError: If file cannot be written
|
||||
"""
|
||||
try:
|
||||
# Create directory if it doesn't exist
|
||||
storage_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Write client info
|
||||
with open(storage_path, "w") as f:
|
||||
json.dump(client_info.to_dict(), f, indent=2)
|
||||
|
||||
# Set restrictive permissions (owner read/write only)
|
||||
os.chmod(storage_path, 0o600)
|
||||
|
||||
logger.info(f"Saved client credentials to {storage_path}")
|
||||
|
||||
except OSError as e:
|
||||
logger.error(f"Failed to save client credentials: {e}")
|
||||
raise
|
||||
|
||||
|
||||
async def 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 load_or_register_client(
|
||||
nextcloud_url: str,
|
||||
registration_endpoint: str,
|
||||
storage_path: str | Path,
|
||||
client_name: str = "Nextcloud MCP Server",
|
||||
redirect_uris: list[str] | None = None,
|
||||
scopes: str = "openid profile email",
|
||||
token_type: str = "Bearer",
|
||||
) -> ClientInfo:
|
||||
"""
|
||||
Load client from storage or register a new one if not found/expired.
|
||||
|
||||
This function:
|
||||
1. Checks for existing client credentials in 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
|
||||
|
||||
Args:
|
||||
nextcloud_url: Base URL of the Nextcloud instance
|
||||
registration_endpoint: Full URL to the registration endpoint
|
||||
storage_path: Path to store client credentials
|
||||
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")
|
||||
|
||||
Returns:
|
||||
ClientInfo with valid credentials
|
||||
|
||||
Raises:
|
||||
httpx.HTTPStatusError: If registration fails
|
||||
ValueError: If response is invalid
|
||||
"""
|
||||
storage_path = Path(storage_path)
|
||||
|
||||
# Try to load existing client
|
||||
client_info = load_client_from_file(storage_path)
|
||||
if client_info:
|
||||
return client_info
|
||||
|
||||
# Register new client
|
||||
logger.info("Registering new OAuth client...")
|
||||
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,
|
||||
)
|
||||
|
||||
# Save to storage
|
||||
save_client_to_file(client_info, storage_path)
|
||||
|
||||
return client_info
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Helper functions for extracting OAuth context from MCP requests."""
|
||||
|
||||
import logging
|
||||
|
||||
from mcp.server.auth.provider import AccessToken
|
||||
from mcp.server.fastmcp import Context
|
||||
|
||||
from ..client import NextcloudClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_client_from_context(ctx: Context, base_url: str) -> NextcloudClient:
|
||||
"""
|
||||
Extract authenticated user context from MCP request and create NextcloudClient.
|
||||
|
||||
This function retrieves the OAuth access token from the MCP context,
|
||||
extracts the username from the token's resource field (where we stored it
|
||||
during token verification), and creates a NextcloudClient with bearer auth.
|
||||
|
||||
Args:
|
||||
ctx: MCP request context containing session info
|
||||
base_url: Nextcloud base URL
|
||||
|
||||
Returns:
|
||||
NextcloudClient configured with bearer token auth
|
||||
|
||||
Raises:
|
||||
AttributeError: If context doesn't contain expected OAuth session data
|
||||
ValueError: If username cannot be extracted from token
|
||||
"""
|
||||
try:
|
||||
# In Starlette with FastMCP OAuth, the authenticated user info is stored in request.user
|
||||
# The FastMCP auth middleware sets request.user to an AuthenticatedUser object
|
||||
# which contains the access_token
|
||||
if hasattr(ctx.request_context.request, "user") and hasattr(
|
||||
ctx.request_context.request.user, "access_token"
|
||||
):
|
||||
access_token: AccessToken = ctx.request_context.request.user.access_token
|
||||
logger.debug("Retrieved access token from request.user for OAuth request")
|
||||
else:
|
||||
logger.error(
|
||||
"OAuth authentication failed: No access token found in request"
|
||||
)
|
||||
raise AttributeError("No access token found in OAuth request context")
|
||||
|
||||
# Extract username from resource field (RFC 8707)
|
||||
# We stored the username here during token verification
|
||||
username = access_token.resource
|
||||
|
||||
if not username:
|
||||
logger.error("No username found in access token resource field")
|
||||
raise ValueError("Username not available in OAuth token context")
|
||||
|
||||
logger.debug(f"Creating OAuth NextcloudClient for user: {username}")
|
||||
|
||||
# Create client with bearer token
|
||||
return NextcloudClient.from_token(
|
||||
base_url=base_url, token=access_token.token, username=username
|
||||
)
|
||||
|
||||
except AttributeError as e:
|
||||
logger.error(f"Failed to extract OAuth context: {e}")
|
||||
logger.error("This may indicate the server is not running in OAuth mode")
|
||||
raise
|
||||
@@ -0,0 +1,343 @@
|
||||
"""Scope-based authorization for MCP tools."""
|
||||
|
||||
import logging
|
||||
from functools import wraps
|
||||
from typing import Callable
|
||||
|
||||
from mcp.server.auth.middleware.auth_context import get_access_token
|
||||
from mcp.server.auth.provider import AccessToken
|
||||
from mcp.server.fastmcp import Context
|
||||
from mcp.server.fastmcp.utilities.context_injection import find_context_parameter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ScopeAuthorizationError(Exception):
|
||||
"""Raised when a request lacks required scopes."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class InsufficientScopeError(ScopeAuthorizationError):
|
||||
"""Raised when request lacks required scopes (enables step-up auth).
|
||||
|
||||
This exception triggers a 403 response with WWW-Authenticate header
|
||||
containing the missing scopes, allowing clients to perform step-up
|
||||
authorization to obtain additional permissions.
|
||||
"""
|
||||
|
||||
def __init__(self, missing_scopes: list[str], message: str | None = None):
|
||||
self.missing_scopes = missing_scopes
|
||||
super().__init__(
|
||||
message or f"Missing required scopes: {', '.join(missing_scopes)}"
|
||||
)
|
||||
|
||||
|
||||
def require_scopes(*required_scopes: str):
|
||||
"""
|
||||
Decorator to require specific OAuth scopes for MCP tool execution.
|
||||
|
||||
This decorator:
|
||||
1. Stores scope requirements as function metadata (_required_scopes attribute)
|
||||
2. Checks that the access token contains all required scopes before execution
|
||||
3. Raises ScopeAuthorizationError if any required scope is missing
|
||||
|
||||
The stored metadata enables dynamic tool filtering - tools can be hidden from
|
||||
users who lack the necessary scopes.
|
||||
|
||||
Args:
|
||||
*required_scopes: Variable number of scope strings required (e.g., "notes:read", "notes:write")
|
||||
|
||||
Returns:
|
||||
Decorated function that checks scopes before execution
|
||||
|
||||
Example:
|
||||
```python
|
||||
@mcp.tool()
|
||||
@require_scopes("notes:read")
|
||||
async def nc_notes_get_note(ctx: Context, note_id: int):
|
||||
# This tool requires the notes:read scope
|
||||
...
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("notes:write")
|
||||
async def nc_notes_create_note(ctx: Context, ...):
|
||||
# This tool requires the notes:write scope
|
||||
...
|
||||
```
|
||||
|
||||
Raises:
|
||||
ScopeAuthorizationError: If required scopes are not present in the access token
|
||||
"""
|
||||
|
||||
def decorator(func: Callable):
|
||||
# Store scope requirements as function metadata for dynamic filtering
|
||||
func._required_scopes = list(required_scopes) # type: ignore
|
||||
|
||||
# Find which parameter receives the Context (FastMCP injects it by name)
|
||||
context_param_name = find_context_parameter(func)
|
||||
|
||||
@wraps(func)
|
||||
async def wrapper(*args, **kwargs):
|
||||
# Extract context from kwargs (where FastMCP injected it)
|
||||
ctx: Context | None = (
|
||||
kwargs.get(context_param_name) if context_param_name else None
|
||||
)
|
||||
|
||||
if ctx is None:
|
||||
# No context parameter found - likely BasicAuth mode
|
||||
# In BasicAuth mode, all operations are allowed
|
||||
logger.debug(
|
||||
f"No context parameter for {func.__name__} - allowing (BasicAuth mode)"
|
||||
)
|
||||
return await func(*args, **kwargs)
|
||||
|
||||
# Check if we're in OAuth mode (access token available)
|
||||
access_token: AccessToken | None = getattr(
|
||||
ctx.request_context, "access_token", None
|
||||
)
|
||||
|
||||
if access_token is None:
|
||||
# Not in OAuth mode (BasicAuth or no auth)
|
||||
# In BasicAuth mode, all operations are allowed
|
||||
logger.debug(
|
||||
f"No access token present for {func.__name__} - allowing (BasicAuth mode)"
|
||||
)
|
||||
return await func(*args, **kwargs)
|
||||
|
||||
# Extract scopes from access token
|
||||
token_scopes = set(access_token.scopes or [])
|
||||
required_scopes_set = set(required_scopes)
|
||||
|
||||
# Check if all required scopes are present
|
||||
missing_scopes = required_scopes_set - token_scopes
|
||||
if missing_scopes:
|
||||
error_msg = (
|
||||
f"Access denied to {func.__name__}: "
|
||||
f"Missing required scopes: {', '.join(sorted(missing_scopes))}. "
|
||||
f"Token has scopes: {', '.join(sorted(token_scopes)) if token_scopes else 'none'}"
|
||||
)
|
||||
logger.warning(error_msg)
|
||||
raise InsufficientScopeError(list(missing_scopes), error_msg)
|
||||
|
||||
# All required scopes present - allow execution
|
||||
logger.debug(
|
||||
f"Scope authorization passed for {func.__name__}: {required_scopes}"
|
||||
)
|
||||
return await func(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def get_access_token_scopes(ctx: Context | None = None) -> set[str]:
|
||||
"""
|
||||
Extract scopes from the authenticated user's access token.
|
||||
|
||||
This function uses MCP SDK's contextvar to access the token, which works
|
||||
across all request types including list_tools.
|
||||
|
||||
Args:
|
||||
ctx: FastMCP context object (unused, kept for compatibility)
|
||||
|
||||
Returns:
|
||||
Set of scope strings, empty set if no token or no scopes
|
||||
"""
|
||||
# Use MCP SDK's get_access_token() which uses contextvars
|
||||
# This works for all request types, including list_tools
|
||||
access_token: AccessToken | None = get_access_token()
|
||||
|
||||
if access_token is None:
|
||||
logger.debug("No access token found in auth context (likely BasicAuth mode)")
|
||||
return set()
|
||||
|
||||
scopes = set(access_token.scopes or [])
|
||||
logger.info(f"✅ Extracted scopes from access token: {scopes}")
|
||||
return scopes
|
||||
|
||||
|
||||
def check_scopes(ctx: Context, *required_scopes: str) -> tuple[bool, set[str]]:
|
||||
"""
|
||||
Check if the request context has all required scopes.
|
||||
|
||||
Utility function for manual scope checking without decorator.
|
||||
|
||||
Args:
|
||||
ctx: FastMCP context object
|
||||
*required_scopes: Variable number of required scope strings
|
||||
|
||||
Returns:
|
||||
Tuple of (has_all_scopes: bool, missing_scopes: set[str])
|
||||
|
||||
Example:
|
||||
```python
|
||||
async def my_tool(ctx: Context):
|
||||
has_scopes, missing = check_scopes(ctx, "notes:read", "notes:write")
|
||||
if not has_scopes:
|
||||
# Handle missing scopes
|
||||
...
|
||||
```
|
||||
"""
|
||||
token_scopes = get_access_token_scopes(ctx)
|
||||
|
||||
# If no access token, assume BasicAuth mode (all operations allowed)
|
||||
if not token_scopes and getattr(ctx.request_context, "access_token", None) is None:
|
||||
return True, set()
|
||||
|
||||
required_scopes_set = set(required_scopes)
|
||||
missing_scopes = required_scopes_set - token_scopes
|
||||
|
||||
return len(missing_scopes) == 0, missing_scopes
|
||||
|
||||
|
||||
def get_required_scopes(func: Callable) -> list[str]:
|
||||
"""
|
||||
Extract required scopes from a function decorated with @require_scopes.
|
||||
|
||||
Args:
|
||||
func: Function to check (may be decorated)
|
||||
|
||||
Returns:
|
||||
List of required scope strings, empty list if no scopes required
|
||||
|
||||
Example:
|
||||
```python
|
||||
@require_scopes("notes:read", "notes:write")
|
||||
async def my_tool():
|
||||
pass
|
||||
|
||||
scopes = get_required_scopes(my_tool) # ["notes:read", "notes:write"]
|
||||
```
|
||||
"""
|
||||
return getattr(func, "_required_scopes", [])
|
||||
|
||||
|
||||
def is_jwt_token() -> bool:
|
||||
"""
|
||||
Check if the current access token is in JWT format.
|
||||
|
||||
JWT tokens have 3 parts separated by dots (header.payload.signature).
|
||||
Opaque tokens are random strings without this structure.
|
||||
|
||||
Returns:
|
||||
True if current token is JWT format, False if opaque or no token
|
||||
"""
|
||||
access_token: AccessToken | None = get_access_token()
|
||||
|
||||
if access_token is None:
|
||||
logger.debug("No access token found - not JWT")
|
||||
return False
|
||||
|
||||
# JWT tokens have exactly 2 dots (3 parts)
|
||||
token_string = access_token.token
|
||||
is_jwt = "." in token_string and token_string.count(".") == 2
|
||||
|
||||
logger.debug(f"Token format check: is_jwt={is_jwt}")
|
||||
return is_jwt
|
||||
|
||||
|
||||
def has_required_scopes(func: Callable, user_scopes: set[str]) -> bool:
|
||||
"""
|
||||
Check if a user has all scopes required by a function.
|
||||
|
||||
Used for dynamic tool filtering - determines if a tool should be visible
|
||||
to a user based on their token scopes.
|
||||
|
||||
Args:
|
||||
func: Function decorated with @require_scopes
|
||||
user_scopes: Set of scopes the user possesses
|
||||
|
||||
Returns:
|
||||
True if user has all required scopes (or no scopes required), False otherwise
|
||||
|
||||
Example:
|
||||
```python
|
||||
@require_scopes("notes:write")
|
||||
async def create_note():
|
||||
pass
|
||||
|
||||
user_scopes = {"notes:read", "notes:write"}
|
||||
can_see = has_required_scopes(create_note, user_scopes) # True
|
||||
|
||||
limited_user_scopes = {"notes:read"}
|
||||
can_see = has_required_scopes(create_note, limited_user_scopes) # False
|
||||
```
|
||||
"""
|
||||
required = get_required_scopes(func)
|
||||
|
||||
# No scopes required → always allow
|
||||
if not required:
|
||||
return True
|
||||
|
||||
# Empty user_scopes but scopes required → deny
|
||||
if not user_scopes:
|
||||
return False
|
||||
|
||||
# Check if user has all required scopes
|
||||
return set(required).issubset(user_scopes)
|
||||
|
||||
|
||||
def discover_all_scopes(mcp) -> list[str]:
|
||||
"""
|
||||
Dynamically discover all OAuth scopes required by registered MCP tools.
|
||||
|
||||
This function inspects all registered tools and extracts their required scopes
|
||||
from the @require_scopes decorator metadata. It provides a single source of truth
|
||||
for available scopes based on the actual tool implementations.
|
||||
|
||||
Args:
|
||||
mcp: FastMCP instance with registered tools
|
||||
|
||||
Returns:
|
||||
Sorted list of unique scope strings, including base OIDC scopes
|
||||
|
||||
Example:
|
||||
```python
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
|
||||
mcp = FastMCP("My Server")
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("notes:read")
|
||||
async def get_notes():
|
||||
pass
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("notes:write")
|
||||
async def create_note():
|
||||
pass
|
||||
|
||||
scopes = discover_all_scopes(mcp)
|
||||
# Returns: ["notes:read", "notes:write", "openid", "profile", "email"]
|
||||
```
|
||||
|
||||
Note:
|
||||
- Base OIDC scopes (openid, profile, email) are always included
|
||||
- Scopes are deduplicated and sorted alphabetically
|
||||
- Only scopes from decorated tools are included
|
||||
- Must be called after tools are registered
|
||||
"""
|
||||
# Start with base OIDC scopes that are always required
|
||||
all_scopes = {"openid", "profile", "email"}
|
||||
|
||||
# Get all registered tools
|
||||
try:
|
||||
tools = mcp._tool_manager.list_tools()
|
||||
except AttributeError:
|
||||
logger.warning("FastMCP instance does not have _tool_manager attribute")
|
||||
return sorted(all_scopes)
|
||||
|
||||
# Extract scopes from each tool
|
||||
for tool in tools:
|
||||
# Get the original function (tools have a .fn attribute)
|
||||
func = getattr(tool, "fn", None)
|
||||
if func is None:
|
||||
continue
|
||||
|
||||
# Extract scopes using existing helper
|
||||
tool_scopes = get_required_scopes(func)
|
||||
all_scopes.update(tool_scopes)
|
||||
|
||||
# Return sorted list of unique scopes
|
||||
return sorted(all_scopes)
|
||||
@@ -0,0 +1,482 @@
|
||||
"""Token verification using Nextcloud OIDC userinfo endpoint."""
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import jwt
|
||||
from jwt import PyJWKClient
|
||||
from mcp.server.auth.provider import AccessToken, TokenVerifier
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class NextcloudTokenVerifier(TokenVerifier):
|
||||
"""
|
||||
Validates access tokens using JWT verification with JWKS or userinfo endpoint fallback.
|
||||
|
||||
This verifier supports both JWT and opaque tokens:
|
||||
1. For JWT tokens: Verifies signature with JWKS and extracts scopes from payload
|
||||
2. For opaque tokens: Falls back to userinfo endpoint validation
|
||||
3. Caches successful responses to avoid repeated API calls/verifications
|
||||
|
||||
JWT validation provides:
|
||||
- Faster validation (no HTTP call needed)
|
||||
- Direct scope extraction from token payload
|
||||
- Signature verification using JWKS
|
||||
|
||||
Userinfo fallback provides:
|
||||
- Support for opaque tokens
|
||||
- Backward compatibility
|
||||
- Additional validation layer
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
nextcloud_host: str,
|
||||
userinfo_uri: str,
|
||||
jwks_uri: str | None = None,
|
||||
issuer: str | None = None,
|
||||
introspection_uri: str | None = None,
|
||||
client_id: str | None = None,
|
||||
client_secret: str | None = None,
|
||||
cache_ttl: int = 3600,
|
||||
):
|
||||
"""
|
||||
Initialize the token verifier.
|
||||
|
||||
Args:
|
||||
nextcloud_host: Base URL of the Nextcloud instance (e.g., https://cloud.example.com)
|
||||
userinfo_uri: Full URL to the userinfo endpoint
|
||||
jwks_uri: Full URL to the JWKS endpoint (for JWT verification)
|
||||
issuer: Expected issuer claim value (for JWT verification)
|
||||
introspection_uri: Full URL to the introspection endpoint (for opaque tokens)
|
||||
client_id: OAuth client ID (required for introspection)
|
||||
client_secret: OAuth client secret (required for introspection)
|
||||
cache_ttl: Time-to-live for cached tokens in seconds (default: 3600)
|
||||
"""
|
||||
self.nextcloud_host = nextcloud_host.rstrip("/")
|
||||
self.userinfo_uri = userinfo_uri
|
||||
self.jwks_uri = jwks_uri
|
||||
self.issuer = issuer
|
||||
self.introspection_uri = introspection_uri
|
||||
self.client_id = client_id
|
||||
self.client_secret = client_secret
|
||||
self.cache_ttl = cache_ttl
|
||||
|
||||
# Cache: token -> (userinfo, expiry_timestamp)
|
||||
self._token_cache: dict[str, tuple[dict[str, Any], float]] = {}
|
||||
|
||||
# HTTP client for userinfo/introspection requests
|
||||
self._client = httpx.AsyncClient(timeout=10.0)
|
||||
|
||||
# PyJWKClient for JWT verification (lazy initialization)
|
||||
self._jwks_client: PyJWKClient | None = None
|
||||
if jwks_uri:
|
||||
logger.info(f"JWT verification enabled with JWKS URI: {jwks_uri}")
|
||||
self._jwks_client = PyJWKClient(jwks_uri, cache_keys=True)
|
||||
|
||||
# Introspection support
|
||||
if introspection_uri and client_id and client_secret:
|
||||
logger.info(f"Token introspection enabled: {introspection_uri}")
|
||||
elif introspection_uri:
|
||||
logger.warning(
|
||||
"Introspection URI provided but missing client credentials - introspection disabled"
|
||||
)
|
||||
|
||||
async def verify_token(self, token: str) -> AccessToken | None:
|
||||
"""
|
||||
Verify a bearer token using JWT verification, introspection, or userinfo endpoint.
|
||||
|
||||
This method:
|
||||
1. Checks the cache first for recent validations
|
||||
2. Attempts JWT verification if JWKS is configured and token looks like JWT
|
||||
3. Falls back to introspection for opaque tokens (if configured)
|
||||
4. Falls back to userinfo endpoint as last resort
|
||||
5. Returns AccessToken with username and scopes
|
||||
|
||||
Args:
|
||||
token: The bearer token to verify
|
||||
|
||||
Returns:
|
||||
AccessToken if valid, None if invalid or expired
|
||||
"""
|
||||
# Check cache first
|
||||
cached = self._get_cached_token(token)
|
||||
if cached:
|
||||
logger.debug("Token found in cache")
|
||||
return cached
|
||||
|
||||
# Try JWT verification first if enabled and token looks like JWT
|
||||
is_jwt_format = self._is_jwt_format(token)
|
||||
logger.debug(
|
||||
f"Token format check: is_jwt_format={is_jwt_format}, _jwks_client={self._jwks_client is not None}"
|
||||
)
|
||||
if self._jwks_client and is_jwt_format:
|
||||
logger.debug("Attempting JWT verification...")
|
||||
jwt_result = self._verify_jwt(token)
|
||||
if jwt_result:
|
||||
logger.info("Token validated via JWT verification")
|
||||
return jwt_result
|
||||
else:
|
||||
logger.warning("JWT verification failed, will try other methods")
|
||||
|
||||
# For opaque tokens, try introspection if available
|
||||
if self.introspection_uri and self.client_id and self.client_secret:
|
||||
logger.debug("Attempting token introspection...")
|
||||
try:
|
||||
introspection_result = await self._verify_via_introspection(token)
|
||||
if introspection_result:
|
||||
logger.info("Token validated via introspection")
|
||||
return introspection_result
|
||||
except Exception as e:
|
||||
logger.warning(f"Introspection failed: {e}")
|
||||
|
||||
# Fall back to userinfo endpoint validation (last resort)
|
||||
logger.debug("Attempting userinfo endpoint validation...")
|
||||
try:
|
||||
return await self._verify_via_userinfo(token)
|
||||
except Exception as e:
|
||||
logger.warning(f"Token verification failed: {e}")
|
||||
return None
|
||||
|
||||
def _is_jwt_format(self, token: str) -> bool:
|
||||
"""
|
||||
Check if token looks like a JWT (has 3 parts separated by dots).
|
||||
|
||||
Args:
|
||||
token: The token to check
|
||||
|
||||
Returns:
|
||||
True if token appears to be JWT format
|
||||
"""
|
||||
return "." in token and token.count(".") == 2
|
||||
|
||||
def _verify_jwt(self, token: str) -> AccessToken | None:
|
||||
"""
|
||||
Verify JWT token with signature validation using JWKS.
|
||||
|
||||
Args:
|
||||
token: The JWT token to verify
|
||||
|
||||
Returns:
|
||||
AccessToken if valid, None if invalid
|
||||
"""
|
||||
try:
|
||||
# Get signing key from JWKS
|
||||
signing_key = self._jwks_client.get_signing_key_from_jwt(token)
|
||||
|
||||
# Verify and decode JWT
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
signing_key.key,
|
||||
algorithms=["RS256"],
|
||||
issuer=self.issuer,
|
||||
options={
|
||||
"verify_signature": True,
|
||||
"verify_exp": True,
|
||||
"verify_iat": True,
|
||||
"verify_iss": True if self.issuer else False,
|
||||
"verify_aud": False, # Skip audience validation for Bearer tokens
|
||||
},
|
||||
)
|
||||
|
||||
logger.debug(f"JWT verified successfully for user: {payload.get('sub')}")
|
||||
logger.debug(f"Full JWT payload: {payload}")
|
||||
|
||||
# Extract username (sub claim)
|
||||
username = payload.get("sub")
|
||||
if not username:
|
||||
logger.error("No 'sub' claim found in JWT payload")
|
||||
return None
|
||||
|
||||
# Extract scopes from scope claim (space-separated string)
|
||||
scope_string = payload.get("scope", "")
|
||||
scopes = scope_string.split() if scope_string else []
|
||||
logger.debug(
|
||||
f"Extracted scopes from JWT - scope claim: '{scope_string}' -> scopes list: {scopes}"
|
||||
)
|
||||
|
||||
# Extract expiration
|
||||
exp = payload.get("exp")
|
||||
if not exp:
|
||||
logger.warning("No 'exp' claim in JWT, using default TTL")
|
||||
exp = int(time.time() + self.cache_ttl)
|
||||
|
||||
# Cache the result
|
||||
userinfo = {
|
||||
"sub": username,
|
||||
"scope": scope_string,
|
||||
**{k: v for k, v in payload.items() if k not in ["sub", "scope"]},
|
||||
}
|
||||
self._token_cache[token] = (userinfo, exp)
|
||||
|
||||
return AccessToken(
|
||||
token=token,
|
||||
client_id=payload.get("client_id", ""),
|
||||
scopes=scopes,
|
||||
expires_at=exp,
|
||||
resource=username, # Store username in resource field (RFC 8707)
|
||||
)
|
||||
|
||||
except jwt.ExpiredSignatureError:
|
||||
logger.info("JWT token has expired")
|
||||
return None
|
||||
except jwt.InvalidIssuerError as e:
|
||||
logger.warning(f"JWT issuer validation failed: {e}")
|
||||
return None
|
||||
except jwt.InvalidTokenError as e:
|
||||
logger.warning(f"JWT validation failed: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error during JWT verification: {e}")
|
||||
return None
|
||||
|
||||
async def _verify_via_introspection(self, token: str) -> AccessToken | None:
|
||||
"""
|
||||
Validate token by calling the introspection endpoint (RFC 7662).
|
||||
|
||||
This method validates opaque tokens and retrieves their scopes.
|
||||
|
||||
Args:
|
||||
token: The bearer token to introspect
|
||||
|
||||
Returns:
|
||||
AccessToken if active, None if inactive or invalid
|
||||
"""
|
||||
try:
|
||||
# Introspection requires client authentication
|
||||
response = await self._client.post(
|
||||
self.introspection_uri,
|
||||
data={"token": token},
|
||||
auth=(self.client_id, self.client_secret),
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
introspection_data = response.json()
|
||||
|
||||
# Check if token is active
|
||||
if not introspection_data.get("active", False):
|
||||
logger.info("Token introspection returned inactive=false")
|
||||
return None
|
||||
|
||||
logger.debug(
|
||||
f"Token introspected successfully for user: {introspection_data.get('sub')}"
|
||||
)
|
||||
|
||||
# Extract username
|
||||
username = introspection_data.get("sub") or introspection_data.get(
|
||||
"username"
|
||||
)
|
||||
if not username:
|
||||
logger.error("No username found in introspection response")
|
||||
return None
|
||||
|
||||
# Extract scopes (space-separated string)
|
||||
scope_string = introspection_data.get("scope", "")
|
||||
scopes = scope_string.split() if scope_string else []
|
||||
logger.debug(f"Extracted scopes from introspection: {scopes}")
|
||||
|
||||
# Extract expiration
|
||||
exp = introspection_data.get("exp")
|
||||
if exp:
|
||||
expiry = float(exp)
|
||||
else:
|
||||
logger.warning(
|
||||
"No 'exp' in introspection response, using default TTL"
|
||||
)
|
||||
expiry = time.time() + self.cache_ttl
|
||||
|
||||
# Cache the result
|
||||
cache_data = {
|
||||
"sub": username,
|
||||
"scope": scope_string,
|
||||
**{
|
||||
k: v
|
||||
for k, v in introspection_data.items()
|
||||
if k not in ["sub", "scope", "active"]
|
||||
},
|
||||
}
|
||||
self._token_cache[token] = (cache_data, expiry)
|
||||
|
||||
return AccessToken(
|
||||
token=token,
|
||||
client_id=introspection_data.get("client_id", ""),
|
||||
scopes=scopes,
|
||||
expires_at=int(expiry),
|
||||
resource=username,
|
||||
)
|
||||
|
||||
elif response.status_code in (400, 401, 403):
|
||||
logger.warning(
|
||||
f"Token introspection failed: HTTP {response.status_code}. "
|
||||
f"This may indicate: (1) Client credentials mismatch - trying to introspect "
|
||||
f"token issued to different OAuth client, (2) Expired client credentials, "
|
||||
f"(3) Invalid token. Will fall back to userinfo endpoint. "
|
||||
f"Response: {response.text[:200] if response.text else 'empty'}"
|
||||
)
|
||||
return None
|
||||
else:
|
||||
logger.warning(
|
||||
f"Unexpected response from introspection: {response.status_code}. "
|
||||
f"Response: {response.text[:200] if response.text else 'empty'}"
|
||||
)
|
||||
return None
|
||||
|
||||
except httpx.TimeoutException:
|
||||
logger.error("Timeout while introspecting token")
|
||||
return None
|
||||
except httpx.RequestError as e:
|
||||
logger.error(f"Network error while introspecting token: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error during token introspection: {e}")
|
||||
return None
|
||||
|
||||
async def _verify_via_userinfo(self, token: str) -> AccessToken | None:
|
||||
"""
|
||||
Validate token by calling the userinfo endpoint.
|
||||
|
||||
Args:
|
||||
token: The bearer token to verify
|
||||
|
||||
Returns:
|
||||
AccessToken if valid, None otherwise
|
||||
"""
|
||||
try:
|
||||
response = await self._client.get(
|
||||
self.userinfo_uri, headers={"Authorization": f"Bearer {token}"}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
userinfo = response.json()
|
||||
logger.debug(
|
||||
f"Token validated successfully for user: {userinfo.get('sub')}"
|
||||
)
|
||||
|
||||
# Cache the result
|
||||
expiry = time.time() + self.cache_ttl
|
||||
self._token_cache[token] = (userinfo, expiry)
|
||||
|
||||
# Create AccessToken with username in resource field (workaround for MCP SDK)
|
||||
username = userinfo.get("sub") or userinfo.get("preferred_username")
|
||||
if not username:
|
||||
logger.error("No username found in userinfo response")
|
||||
return None
|
||||
|
||||
return AccessToken(
|
||||
token=token,
|
||||
client_id="", # Not available from userinfo
|
||||
scopes=self._extract_scopes(userinfo),
|
||||
expires_at=int(expiry),
|
||||
resource=username, # Store username in resource field (RFC 8707)
|
||||
)
|
||||
|
||||
elif response.status_code in (400, 401, 403):
|
||||
logger.info(f"Token validation failed: HTTP {response.status_code}")
|
||||
return None
|
||||
else:
|
||||
logger.warning(
|
||||
f"Unexpected response from userinfo: {response.status_code}"
|
||||
)
|
||||
return None
|
||||
|
||||
except httpx.TimeoutException:
|
||||
logger.error("Timeout while validating token via userinfo endpoint")
|
||||
return None
|
||||
except httpx.RequestError as e:
|
||||
logger.error(f"Network error while validating token: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error during token validation: {e}")
|
||||
return None
|
||||
|
||||
def _get_cached_token(self, token: str) -> AccessToken | None:
|
||||
"""
|
||||
Retrieve a token from cache if not expired.
|
||||
|
||||
Args:
|
||||
token: The bearer token to look up
|
||||
|
||||
Returns:
|
||||
AccessToken if cached and valid, None otherwise
|
||||
"""
|
||||
if token not in self._token_cache:
|
||||
return None
|
||||
|
||||
userinfo, expiry = self._token_cache[token]
|
||||
|
||||
# Check if expired
|
||||
if time.time() >= expiry:
|
||||
logger.debug("Cached token expired, removing from cache")
|
||||
del self._token_cache[token]
|
||||
return None
|
||||
|
||||
# Return cached AccessToken
|
||||
username = userinfo.get("sub") or userinfo.get("preferred_username")
|
||||
return AccessToken(
|
||||
token=token,
|
||||
client_id="",
|
||||
scopes=self._extract_scopes(userinfo),
|
||||
expires_at=int(expiry),
|
||||
resource=username,
|
||||
)
|
||||
|
||||
def _extract_scopes(self, userinfo: dict[str, Any]) -> list[str]:
|
||||
"""
|
||||
Extract scopes from userinfo response.
|
||||
|
||||
First attempts to read actual scopes from the 'scope' field (RFC 8693).
|
||||
If not present, infers scopes from the claims present in the response.
|
||||
|
||||
Args:
|
||||
userinfo: The userinfo response dictionary
|
||||
|
||||
Returns:
|
||||
List of scopes (actual or inferred)
|
||||
"""
|
||||
# Try to get actual scopes from userinfo response (if OIDC provider includes it)
|
||||
scope_string = userinfo.get("scope")
|
||||
if scope_string:
|
||||
scopes = scope_string.split() if isinstance(scope_string, str) else []
|
||||
if scopes:
|
||||
logger.debug(
|
||||
f"Using actual scopes from userinfo: {scopes} (scope field present)"
|
||||
)
|
||||
return scopes
|
||||
|
||||
# Fallback: Infer scopes from claims present in response
|
||||
# This maintains backward compatibility with OIDC providers that don't
|
||||
# include the scope field in userinfo responses
|
||||
logger.debug(
|
||||
"No scope field in userinfo response, inferring scopes from claims"
|
||||
)
|
||||
scopes = ["openid"] # Always present
|
||||
|
||||
if "email" in userinfo:
|
||||
scopes.append("email")
|
||||
|
||||
if any(
|
||||
key in userinfo for key in ["name", "given_name", "family_name", "picture"]
|
||||
):
|
||||
scopes.append("profile")
|
||||
|
||||
if "roles" in userinfo:
|
||||
scopes.append("roles")
|
||||
|
||||
if "groups" in userinfo:
|
||||
scopes.append("groups")
|
||||
|
||||
logger.debug(f"Inferred scopes from userinfo claims: {scopes}")
|
||||
return scopes
|
||||
|
||||
def clear_cache(self):
|
||||
"""Clear the token cache."""
|
||||
self._token_cache.clear()
|
||||
logger.debug("Token cache cleared")
|
||||
|
||||
async def close(self):
|
||||
"""Cleanup resources."""
|
||||
await self._client.aclose()
|
||||
logger.debug("Token verifier closed")
|
||||
@@ -1,34 +1,59 @@
|
||||
import logging
|
||||
import os
|
||||
|
||||
from httpx import (
|
||||
AsyncBaseTransport,
|
||||
AsyncClient,
|
||||
AsyncHTTPTransport,
|
||||
Auth,
|
||||
BasicAuth,
|
||||
Request,
|
||||
Response,
|
||||
)
|
||||
import logging
|
||||
|
||||
from .notes import NotesClient
|
||||
from .webdav import WebDAVClient
|
||||
from .tables import TablesClient
|
||||
from ..controllers.notes_search import NotesSearchController
|
||||
from .calendar import CalendarClient
|
||||
from .contacts import ContactsClient
|
||||
from .cookbook import CookbookClient
|
||||
from .deck import DeckClient
|
||||
from .groups import GroupsClient
|
||||
from .notes import NotesClient
|
||||
from .sharing import SharingClient
|
||||
from .tables import TablesClient
|
||||
from .users import UsersClient
|
||||
from .webdav import WebDAVClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def log_request(request: Request):
|
||||
logger.info(
|
||||
async def log_request(request: Request):
|
||||
logger.debug(
|
||||
"Request event hook: %s %s - Waiting for content",
|
||||
request.method,
|
||||
request.url,
|
||||
)
|
||||
logger.info("Request body: %s", request.content)
|
||||
logger.info("Headers: %s", request.headers)
|
||||
logger.debug("Request body: %s", request.content)
|
||||
logger.debug("Headers: %s", request.headers)
|
||||
|
||||
|
||||
def log_response(response: Response):
|
||||
response.read() # Explicitly read the stream before accessing .text
|
||||
logger.info("Response [%s] %s", response.status_code, response.text)
|
||||
async def log_response(response: Response):
|
||||
await response.aread()
|
||||
logger.debug("Response [%s] %s", response.status_code, response.text)
|
||||
|
||||
|
||||
class AsyncDisableCookieTransport(AsyncBaseTransport):
|
||||
"""This Transport disable cookies from accumulating in the httpx AsyncClient
|
||||
|
||||
Thanks to: https://github.com/encode/httpx/issues/2992#issuecomment-2133258994
|
||||
"""
|
||||
|
||||
def __init__(self, transport: AsyncBaseTransport):
|
||||
self.transport = transport
|
||||
|
||||
async def handle_async_request(self, request: Request) -> Response:
|
||||
response = await self.transport.handle_async_request(request)
|
||||
response.headers.pop("set-cookie", None)
|
||||
return response
|
||||
|
||||
|
||||
class NextcloudClient:
|
||||
@@ -39,13 +64,23 @@ class NextcloudClient:
|
||||
self._client = AsyncClient(
|
||||
base_url=base_url,
|
||||
auth=auth,
|
||||
# event_hooks={"request": [log_request], "response": [log_response]},
|
||||
transport=AsyncDisableCookieTransport(AsyncHTTPTransport()),
|
||||
event_hooks={"request": [log_request], "response": [log_response]},
|
||||
)
|
||||
|
||||
# Initialize app clients
|
||||
self.notes = NotesClient(self._client, username)
|
||||
self.webdav = WebDAVClient(self._client, username)
|
||||
self.tables = TablesClient(self._client, username)
|
||||
self.calendar = CalendarClient(
|
||||
base_url, username, auth
|
||||
) # Uses AsyncDavClient internally
|
||||
self.contacts = ContactsClient(self._client, username)
|
||||
self.cookbook = CookbookClient(self._client, username)
|
||||
self.deck = DeckClient(self._client, username)
|
||||
self.users = UsersClient(self._client, username)
|
||||
self.groups = GroupsClient(self._client, username)
|
||||
self.sharing = SharingClient(self._client, username)
|
||||
|
||||
# Initialize controllers
|
||||
self._notes_search = NotesSearchController()
|
||||
@@ -60,6 +95,23 @@ class NextcloudClient:
|
||||
# Pass username to constructor
|
||||
return cls(base_url=host, username=username, auth=BasicAuth(username, password))
|
||||
|
||||
@classmethod
|
||||
def from_token(cls, base_url: str, token: str, username: str):
|
||||
"""Create NextcloudClient with OAuth bearer token.
|
||||
|
||||
Args:
|
||||
base_url: Nextcloud base URL
|
||||
token: OAuth access token
|
||||
username: Nextcloud username
|
||||
|
||||
Returns:
|
||||
NextcloudClient configured with bearer token authentication
|
||||
"""
|
||||
from ..auth import BearerAuth
|
||||
|
||||
logger.info(f"Creating NC Client for user '{username}' using OAuth token")
|
||||
return cls(base_url=base_url, username=username, auth=BearerAuth(token))
|
||||
|
||||
async def capabilities(self):
|
||||
response = await self._client.get(
|
||||
"/ocs/v2.php/cloud/capabilities",
|
||||
@@ -71,13 +123,14 @@ class NextcloudClient:
|
||||
|
||||
async def notes_search_notes(self, *, query: str):
|
||||
"""Search notes using token-based matching with relevance ranking."""
|
||||
all_notes = await self.notes.get_all_notes()
|
||||
return self._notes_search.search_notes(all_notes, query)
|
||||
all_notes = self.notes.get_all_notes()
|
||||
return await self._notes_search.search_notes(all_notes, query)
|
||||
|
||||
def _get_webdav_base_path(self) -> str:
|
||||
"""Helper to get the base WebDAV path for the authenticated user."""
|
||||
return f"/remote.php/dav/files/{self.username}"
|
||||
|
||||
async def close(self):
|
||||
"""Close the HTTP client."""
|
||||
"""Close the HTTP client and CalDAV client."""
|
||||
await self._client.aclose()
|
||||
await self.calendar.close()
|
||||
|
||||
@@ -1,12 +1,74 @@
|
||||
"""Base client for Nextcloud operations with shared authentication."""
|
||||
|
||||
from abc import ABC
|
||||
from httpx import AsyncClient
|
||||
import logging
|
||||
import time
|
||||
from abc import ABC
|
||||
from functools import wraps
|
||||
|
||||
from httpx import AsyncClient, HTTPStatusError, RequestError, codes
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def retry_on_429(func):
|
||||
"""This decorator handles the 429 response from REST APIs
|
||||
|
||||
The `func` is assumed to be a method that is similar to `httpx.Client.get`,
|
||||
and returns an `httpx.Response` object. In the case of `Too Many Requests` HTTP
|
||||
response, the function will wait for a couple of seconds and retry the request.
|
||||
"""
|
||||
|
||||
MAX_RETRIES = 5
|
||||
|
||||
@wraps(func)
|
||||
async def wrapper(*args, **kwargs):
|
||||
retries = 0
|
||||
|
||||
while retries < MAX_RETRIES:
|
||||
try:
|
||||
# Make GET API call
|
||||
retries += 1
|
||||
response = await func(*args, **kwargs)
|
||||
break
|
||||
|
||||
except HTTPStatusError as e:
|
||||
# If we get a '429 Client Error: Too Many Requests'
|
||||
# error we wait a couple of seconds and do a retry
|
||||
if e.response.status_code == codes.TOO_MANY_REQUESTS:
|
||||
logger.warning(
|
||||
f"429 Client Error: Too Many Requests, Number of attempts: {retries}"
|
||||
)
|
||||
time.sleep(5)
|
||||
elif e.response.status_code == 404:
|
||||
# 404 errors are often expected (e.g., checking if attachments exist)
|
||||
# Log as debug instead of warning
|
||||
logger.debug(
|
||||
f"HTTPStatusError {e.response.status_code}: {e}, Number of attempts: {retries}"
|
||||
)
|
||||
raise
|
||||
else:
|
||||
logger.warning(
|
||||
f"HTTPStatusError {e.response.status_code}: {e}, Number of attempts: {retries}"
|
||||
)
|
||||
raise
|
||||
except RequestError as e:
|
||||
logger.warning(
|
||||
f"RequestError {e.request.url}: {e}, Number of attempts: {retries}"
|
||||
)
|
||||
raise
|
||||
|
||||
# If for loop ends without break statement
|
||||
else:
|
||||
logger.warning("All API call retries failed")
|
||||
raise RuntimeError(
|
||||
f"Maximum number of retries ({MAX_RETRIES}) exceeded without success"
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
class BaseNextcloudClient(ABC):
|
||||
"""Base class for all Nextcloud app clients."""
|
||||
|
||||
@@ -24,6 +86,7 @@ class BaseNextcloudClient(ABC):
|
||||
"""Helper to get the base WebDAV path for the authenticated user."""
|
||||
return f"/remote.php/dav/files/{self.username}"
|
||||
|
||||
@retry_on_429
|
||||
async def _make_request(self, method: str, url: str, **kwargs):
|
||||
"""Common request wrapper with logging and error handling.
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,438 @@
|
||||
"""CardDAV client for NextCloud contacts operations."""
|
||||
|
||||
import logging
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
from pythonvCard4.vcard import Contact
|
||||
|
||||
from .base import BaseNextcloudClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ContactsClient(BaseNextcloudClient):
|
||||
"""Client for NextCloud CardDAV contact operations."""
|
||||
|
||||
def _get_carddav_base_path(self) -> str:
|
||||
"""Helper to get the base CardDAV path for contacts."""
|
||||
return f"/remote.php/dav/addressbooks/users/{self.username}"
|
||||
|
||||
async def list_addressbooks(self):
|
||||
"""List all available addressbooks for the user."""
|
||||
|
||||
carddav_path = self._get_carddav_base_path()
|
||||
|
||||
propfind_body = """<?xml version="1.0" encoding="utf-8"?>
|
||||
<d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/">
|
||||
<d:prop>
|
||||
<d:displayname/>
|
||||
<d:getctag />
|
||||
</d:prop>
|
||||
</d:propfind>"""
|
||||
|
||||
headers = {
|
||||
# "Depth": "0",
|
||||
"Content-Type": "application/xml",
|
||||
"Accept": "application/xml",
|
||||
}
|
||||
|
||||
response = await self._make_request(
|
||||
"PROPFIND", carddav_path, content=propfind_body, headers=headers
|
||||
)
|
||||
|
||||
ns = {"d": "DAV:"}
|
||||
|
||||
# logger.info(response.content)
|
||||
root = ET.fromstring(response.content)
|
||||
addressbooks = []
|
||||
for response_elem in root.findall(".//d:response", ns):
|
||||
href = response_elem.find(".//d:href", ns)
|
||||
if href is None:
|
||||
continue
|
||||
|
||||
href_text = href.text or ""
|
||||
if not href_text.endswith("/"):
|
||||
continue # Skip non-addressbook resources
|
||||
|
||||
# Extract addressbook name from href
|
||||
addressbook_name = href_text.rstrip("/").split("/")[-1]
|
||||
if not addressbook_name or addressbook_name == self.username:
|
||||
continue
|
||||
|
||||
# Get properties
|
||||
propstat = response_elem.find(".//d:propstat", ns)
|
||||
if propstat is None:
|
||||
continue
|
||||
|
||||
prop = propstat.find(".//d:prop", ns)
|
||||
if prop is None:
|
||||
continue
|
||||
|
||||
displayname_elem = prop.find(".//d:displayname", ns)
|
||||
displayname = (
|
||||
displayname_elem.text
|
||||
if displayname_elem is not None
|
||||
else addressbook_name
|
||||
)
|
||||
|
||||
getctag_elem = prop.find(".//d:getctag", ns)
|
||||
getctag = getctag_elem.text if getctag_elem is not None else None
|
||||
|
||||
addressbooks.append(
|
||||
{
|
||||
"name": addressbook_name,
|
||||
"display_name": displayname,
|
||||
"getctag": getctag,
|
||||
}
|
||||
)
|
||||
|
||||
logger.debug(f"Found {len(addressbooks)} addressbooks")
|
||||
return addressbooks
|
||||
|
||||
async def create_addressbook(self, *, name: str, display_name: str):
|
||||
"""Create a new addressbook."""
|
||||
carddav_path = self._get_carddav_base_path()
|
||||
url = f"{carddav_path}/{name}/"
|
||||
|
||||
prop_body = f"""<?xml version="1.0" encoding="utf-8"?>
|
||||
<d:mkcol xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:carddav">
|
||||
<d:set>
|
||||
<d:prop>
|
||||
<d:resourcetype>
|
||||
<d:collection/>
|
||||
<c:addressbook/>
|
||||
</d:resourcetype>
|
||||
<d:displayname>{display_name}</d:displayname>
|
||||
</d:prop>
|
||||
</d:set>
|
||||
</d:mkcol>"""
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/xml",
|
||||
}
|
||||
|
||||
await self._make_request("MKCOL", url, content=prop_body, headers=headers)
|
||||
|
||||
async def delete_addressbook(self, *, name: str):
|
||||
"""Delete an addressbook."""
|
||||
carddav_path = self._get_carddav_base_path()
|
||||
url = f"{carddav_path}/{name}/"
|
||||
await self._make_request("DELETE", url)
|
||||
|
||||
async def create_contact(self, *, addressbook: str, uid: str, contact_data: dict):
|
||||
"""Create a new contact."""
|
||||
carddav_path = self._get_carddav_base_path()
|
||||
url = f"{carddav_path}/{addressbook}/{uid}.vcf"
|
||||
|
||||
contact = Contact(fn=contact_data.get("fn"), uid=uid)
|
||||
if "email" in contact_data:
|
||||
contact.email = [{"value": contact_data["email"], "type": ["HOME"]}]
|
||||
if "tel" in contact_data:
|
||||
contact.tel = [{"value": contact_data["tel"], "type": ["HOME"]}]
|
||||
|
||||
vcard = contact.to_vcard()
|
||||
|
||||
headers = {
|
||||
"Content-Type": "text/vcard; charset=utf-8",
|
||||
"If-None-Match": "*",
|
||||
}
|
||||
|
||||
await self._make_request("PUT", url, content=vcard, headers=headers)
|
||||
|
||||
async def delete_contact(self, *, addressbook: str, uid: str):
|
||||
"""Delete a contact."""
|
||||
carddav_path = self._get_carddav_base_path()
|
||||
url = f"{carddav_path}/{addressbook}/{uid}.vcf"
|
||||
await self._make_request("DELETE", url)
|
||||
|
||||
async def update_contact(
|
||||
self, *, addressbook: str, uid: str, contact_data: dict, etag: str = ""
|
||||
):
|
||||
"""Update an existing contact while preserving all existing properties."""
|
||||
carddav_path = self._get_carddav_base_path()
|
||||
url = f"{carddav_path}/{addressbook}/{uid}.vcf"
|
||||
|
||||
# Get raw vCard content to preserve all properties including extended ones
|
||||
raw_vcard_content = ""
|
||||
if not etag:
|
||||
try:
|
||||
raw_vcard_content, current_etag = await self._get_raw_vcard(
|
||||
addressbook, uid
|
||||
)
|
||||
etag = current_etag
|
||||
except Exception:
|
||||
# Fall back to creating new vCard if we can't get existing
|
||||
logger.warning(
|
||||
f"Could not fetch existing vCard for {uid}, creating new"
|
||||
)
|
||||
raw_vcard_content = ""
|
||||
|
||||
# Create updated vCard preserving existing properties
|
||||
if raw_vcard_content:
|
||||
vcard_content = self._merge_vcard_properties(
|
||||
raw_vcard_content, contact_data, uid
|
||||
)
|
||||
else:
|
||||
# Fallback to creating new vCard if we couldn't get existing
|
||||
contact = Contact(fn=contact_data.get("fn"), uid=uid)
|
||||
if "email" in contact_data:
|
||||
contact.email = [{"value": contact_data["email"], "type": ["HOME"]}]
|
||||
if "tel" in contact_data:
|
||||
contact.tel = [{"value": contact_data["tel"], "type": ["HOME"]}]
|
||||
vcard_content = contact.to_vcard()
|
||||
|
||||
headers = {
|
||||
"Content-Type": "text/vcard; charset=utf-8",
|
||||
}
|
||||
if etag:
|
||||
headers["If-Match"] = etag
|
||||
|
||||
await self._make_request("PUT", url, content=vcard_content, headers=headers)
|
||||
|
||||
async def list_contacts(self, *, addressbook: str):
|
||||
"""List all available contacts for addressbook."""
|
||||
|
||||
carddav_path = self._get_carddav_base_path()
|
||||
|
||||
report_body = """<?xml version="1.0" encoding="utf-8"?>
|
||||
<card:addressbook-query xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">
|
||||
<d:prop>
|
||||
<d:getetag />
|
||||
<card:address-data />
|
||||
</d:prop>
|
||||
</card:addressbook-query>"""
|
||||
|
||||
headers = {
|
||||
"Depth": "1",
|
||||
"Content-Type": "application/xml",
|
||||
"Accept": "application/xml",
|
||||
}
|
||||
|
||||
response = await self._make_request(
|
||||
"REPORT",
|
||||
f"{carddav_path}/{addressbook}",
|
||||
content=report_body,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
ns = {"d": "DAV:", "card": "urn:ietf:params:xml:ns:carddav"}
|
||||
|
||||
# logger.info(response.text)
|
||||
root = ET.fromstring(response.content)
|
||||
contacts = []
|
||||
for response_elem in root.findall(".//d:response", ns):
|
||||
href = response_elem.find(".//d:href", ns)
|
||||
if href is None:
|
||||
logger.info("Skip missing href")
|
||||
continue
|
||||
|
||||
href_text = href.text or ""
|
||||
# logger.info("Href text: %s", href_text)
|
||||
# if not href_text.endswith("/"):
|
||||
# logger.info("# Skip non-addressbook resources")
|
||||
# continue
|
||||
|
||||
# Extract vcard id from href
|
||||
vcard_id = href_text.rstrip("/").split("/")[-1]
|
||||
if not vcard_id:
|
||||
logger.info("Skip missing vcard_id")
|
||||
continue
|
||||
vcard_id = vcard_id.replace(".vcf", "")
|
||||
|
||||
# Get properties
|
||||
propstat = response_elem.find(".//d:propstat", ns)
|
||||
if propstat is None:
|
||||
logger.info("Skip missing propstat")
|
||||
continue
|
||||
|
||||
prop = propstat.find(".//d:prop", ns)
|
||||
if prop is None:
|
||||
logger.info("Skip missing prop")
|
||||
continue
|
||||
|
||||
getetag_elem = prop.find(".//d:getetag", ns)
|
||||
getetag = getetag_elem.text if getetag_elem is not None else None
|
||||
|
||||
addressdata_elem = prop.find(".//card:address-data", ns)
|
||||
addressdata = (
|
||||
addressdata_elem.text if addressdata_elem is not None else None
|
||||
)
|
||||
if addressdata is None:
|
||||
logger.info("Skip missing addressdata")
|
||||
continue
|
||||
|
||||
contact = Contact.from_vcard(addressdata)
|
||||
|
||||
contacts.append(
|
||||
{
|
||||
"vcard_id": vcard_id,
|
||||
"getetag": getetag,
|
||||
"contact": {
|
||||
"fullname": contact.fn,
|
||||
"nickname": contact.nickname,
|
||||
"birthday": contact.bday,
|
||||
"email": contact.email,
|
||||
},
|
||||
"addressdata": addressdata,
|
||||
}
|
||||
)
|
||||
|
||||
logger.debug(f"Found {len(contacts)} contacts")
|
||||
return contacts
|
||||
|
||||
async def _get_raw_vcard(self, addressbook: str, uid: str) -> tuple[str, str]:
|
||||
"""Get raw vCard content for a contact without parsing."""
|
||||
carddav_path = self._get_carddav_base_path()
|
||||
url = f"{carddav_path}/{addressbook}/{uid}.vcf"
|
||||
|
||||
try:
|
||||
response = await self._make_request("GET", url)
|
||||
etag = response.headers.get("etag", "")
|
||||
return response.text, etag
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting raw vCard for {uid}: {e}")
|
||||
raise
|
||||
|
||||
def _merge_vcard_properties(
|
||||
self, raw_vcard: str, contact_data: dict, uid: str
|
||||
) -> str:
|
||||
"""Merge new contact data into existing raw vCard while preserving all properties."""
|
||||
try:
|
||||
# Instead of using pythonvCard4 which has formatting issues,
|
||||
# let's do a simple text-based merge to preserve exact formatting
|
||||
|
||||
# Start with the original vCard
|
||||
lines = raw_vcard.strip().split("\n")
|
||||
updated_lines = []
|
||||
|
||||
# Track what we've updated to avoid duplicates
|
||||
updated_properties = set()
|
||||
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
# Skip the END:VCARD line for now
|
||||
if line == "END:VCARD":
|
||||
continue
|
||||
|
||||
property_name = line.split(":")[0].split(";")[0]
|
||||
|
||||
# Handle updates for specific properties
|
||||
if property_name == "FN" and "fn" in contact_data:
|
||||
updated_lines.append(f"FN:{contact_data['fn']}")
|
||||
updated_properties.add("fn")
|
||||
elif property_name == "EMAIL" and "email" in contact_data:
|
||||
# Replace first email with new one, preserve others
|
||||
if "email" not in updated_properties:
|
||||
if isinstance(contact_data["email"], str):
|
||||
# Try to preserve the original format as much as possible
|
||||
if ";TYPE=" in line:
|
||||
type_part = line.split(";TYPE=")[1].split(":")[0]
|
||||
updated_lines.append(
|
||||
f"EMAIL;TYPE={type_part}:{contact_data['email']}"
|
||||
)
|
||||
else:
|
||||
updated_lines.append(f"EMAIL:{contact_data['email']}")
|
||||
updated_properties.add("email")
|
||||
else:
|
||||
# Keep additional emails unchanged
|
||||
updated_lines.append(line)
|
||||
elif property_name == "TEL" and "tel" in contact_data:
|
||||
# Similar handling for phone numbers
|
||||
if "tel" not in updated_properties:
|
||||
if isinstance(contact_data["tel"], str):
|
||||
if ";TYPE=" in line:
|
||||
type_part = line.split(";TYPE=")[1].split(":")[0]
|
||||
updated_lines.append(
|
||||
f"TEL;TYPE={type_part}:{contact_data['tel']}"
|
||||
)
|
||||
else:
|
||||
updated_lines.append(f"TEL:{contact_data['tel']}")
|
||||
updated_properties.add("tel")
|
||||
else:
|
||||
# Keep additional phone numbers unchanged
|
||||
updated_lines.append(line)
|
||||
elif property_name == "NOTE" and "note" in contact_data:
|
||||
updated_lines.append(f"NOTE:{contact_data['note']}")
|
||||
updated_properties.add("note")
|
||||
elif property_name == "NICKNAME" and "nickname" in contact_data:
|
||||
nickname_value = contact_data["nickname"]
|
||||
if isinstance(nickname_value, list):
|
||||
nickname_value = ",".join(nickname_value)
|
||||
updated_lines.append(f"NICKNAME:{nickname_value}")
|
||||
updated_properties.add("nickname")
|
||||
elif property_name == "BDAY" and "bday" in contact_data:
|
||||
updated_lines.append(f"BDAY:{contact_data['bday']}")
|
||||
updated_properties.add("bday")
|
||||
elif property_name == "CATEGORIES" and "categories" in contact_data:
|
||||
categories_value = contact_data["categories"]
|
||||
if isinstance(categories_value, list):
|
||||
categories_value = ",".join(categories_value)
|
||||
updated_lines.append(f"CATEGORIES:{categories_value}")
|
||||
updated_properties.add("categories")
|
||||
elif property_name == "ORG" and (
|
||||
"org" in contact_data or "organization" in contact_data
|
||||
):
|
||||
org_value = contact_data.get("org") or contact_data.get(
|
||||
"organization"
|
||||
)
|
||||
updated_lines.append(f"ORG:{org_value}")
|
||||
updated_properties.add("org")
|
||||
elif property_name == "TITLE" and "title" in contact_data:
|
||||
updated_lines.append(f"TITLE:{contact_data['title']}")
|
||||
updated_properties.add("title")
|
||||
else:
|
||||
# Keep all other properties unchanged (preserves all extended/custom fields)
|
||||
updated_lines.append(line)
|
||||
|
||||
# Add any new properties that weren't in the original vCard
|
||||
for key, value in contact_data.items():
|
||||
if key not in updated_properties:
|
||||
if key == "fn":
|
||||
updated_lines.append(f"FN:{value}")
|
||||
elif key == "email" and isinstance(value, str):
|
||||
updated_lines.append(f"EMAIL:{value}")
|
||||
elif key == "tel" and isinstance(value, str):
|
||||
updated_lines.append(f"TEL:{value}")
|
||||
elif key == "note":
|
||||
updated_lines.append(f"NOTE:{value}")
|
||||
elif key == "nickname":
|
||||
nickname_value = (
|
||||
value if isinstance(value, str) else ",".join(value)
|
||||
)
|
||||
updated_lines.append(f"NICKNAME:{nickname_value}")
|
||||
elif key == "bday":
|
||||
updated_lines.append(f"BDAY:{value}")
|
||||
elif key == "categories":
|
||||
categories_value = (
|
||||
value if isinstance(value, str) else ",".join(value)
|
||||
)
|
||||
updated_lines.append(f"CATEGORIES:{categories_value}")
|
||||
elif key in ["org", "organization"]:
|
||||
updated_lines.append(f"ORG:{value}")
|
||||
elif key == "title":
|
||||
updated_lines.append(f"TITLE:{value}")
|
||||
|
||||
# Add the END:VCARD line
|
||||
updated_lines.append("END:VCARD")
|
||||
|
||||
# Join all lines
|
||||
return "\n".join(updated_lines)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error merging vCard properties: {e}")
|
||||
# Fallback to creating basic vCard matching Nextcloud format
|
||||
basic_vcard = f"""BEGIN:VCARD
|
||||
VERSION:3.0
|
||||
UID:{uid}
|
||||
FN:{contact_data.get("fn", "Unknown")}"""
|
||||
|
||||
if "email" in contact_data:
|
||||
basic_vcard += f"\nEMAIL:{contact_data['email']}"
|
||||
if "tel" in contact_data:
|
||||
basic_vcard += f"\nTEL:{contact_data['tel']}"
|
||||
|
||||
basic_vcard += "\nEND:VCARD"
|
||||
return basic_vcard
|
||||
@@ -0,0 +1,250 @@
|
||||
"""Client for Nextcloud Cookbook app operations."""
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from httpx import Timeout
|
||||
|
||||
from .base import BaseNextcloudClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CookbookClient(BaseNextcloudClient):
|
||||
"""Client for Nextcloud Cookbook app operations."""
|
||||
|
||||
async def get_version(self) -> Dict[str, Any]:
|
||||
"""Get Cookbook app and API version."""
|
||||
response = await self._make_request("GET", "/apps/cookbook/api/version")
|
||||
return response.json()
|
||||
|
||||
async def get_config(self) -> Dict[str, Any]:
|
||||
"""Get current Cookbook app configuration."""
|
||||
response = await self._make_request("GET", "/apps/cookbook/api/v1/config")
|
||||
return response.json()
|
||||
|
||||
async def set_config(self, config: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Set Cookbook app configuration.
|
||||
|
||||
Args:
|
||||
config: Configuration dictionary with fields like:
|
||||
- folder: Recipe folder path
|
||||
- update_interval: Auto-rescan interval in minutes
|
||||
- print_image: Whether to print images with recipes
|
||||
- visibleInfoBlocks: Visible info blocks configuration
|
||||
|
||||
Returns:
|
||||
Response with status message
|
||||
"""
|
||||
response = await self._make_request(
|
||||
"POST", "/apps/cookbook/api/v1/config", json=config
|
||||
)
|
||||
return response.json()
|
||||
|
||||
async def reindex(self) -> str:
|
||||
"""Trigger a rescan of all recipes into the caching database.
|
||||
|
||||
Returns:
|
||||
Success message
|
||||
"""
|
||||
response = await self._make_request("POST", "/apps/cookbook/api/v1/reindex")
|
||||
return response.json()
|
||||
|
||||
async def list_recipes(self) -> List[Dict[str, Any]]:
|
||||
"""Get all recipes in the database.
|
||||
|
||||
Returns:
|
||||
List of recipe stubs with basic information
|
||||
"""
|
||||
response = await self._make_request("GET", "/apps/cookbook/api/v1/recipes")
|
||||
return response.json()
|
||||
|
||||
async def get_recipe(self, recipe_id: int) -> Dict[str, Any]:
|
||||
"""Get a single recipe by ID.
|
||||
|
||||
Args:
|
||||
recipe_id: The recipe ID
|
||||
|
||||
Returns:
|
||||
Full recipe data
|
||||
"""
|
||||
response = await self._make_request(
|
||||
"GET", f"/apps/cookbook/api/v1/recipes/{recipe_id}"
|
||||
)
|
||||
return response.json()
|
||||
|
||||
async def create_recipe(self, recipe_data: Dict[str, Any]) -> int:
|
||||
"""Create a new recipe.
|
||||
|
||||
Args:
|
||||
recipe_data: Recipe data following schema.org/Recipe format.
|
||||
Required: name
|
||||
Optional: description, ingredients, instructions, etc.
|
||||
|
||||
Returns:
|
||||
ID of the newly created recipe
|
||||
"""
|
||||
response = await self._make_request(
|
||||
"POST", "/apps/cookbook/api/v1/recipes", json=recipe_data
|
||||
)
|
||||
return response.json()
|
||||
|
||||
async def update_recipe(self, recipe_id: int, recipe_data: Dict[str, Any]) -> int:
|
||||
"""Update an existing recipe.
|
||||
|
||||
Args:
|
||||
recipe_id: The recipe ID to update
|
||||
recipe_data: Updated recipe data
|
||||
|
||||
Returns:
|
||||
ID of the updated recipe
|
||||
"""
|
||||
response = await self._make_request(
|
||||
"PUT", f"/apps/cookbook/api/v1/recipes/{recipe_id}", json=recipe_data
|
||||
)
|
||||
return response.json()
|
||||
|
||||
async def delete_recipe(self, recipe_id: int) -> str:
|
||||
"""Delete a recipe.
|
||||
|
||||
Args:
|
||||
recipe_id: The recipe ID to delete
|
||||
|
||||
Returns:
|
||||
Success message
|
||||
"""
|
||||
response = await self._make_request(
|
||||
"DELETE", f"/apps/cookbook/api/v1/recipes/{recipe_id}"
|
||||
)
|
||||
return response.json()
|
||||
|
||||
async def import_recipe(self, url: str) -> Dict[str, Any]:
|
||||
"""Import a recipe from a URL using schema.org metadata.
|
||||
|
||||
Args:
|
||||
url: URL of the recipe to import
|
||||
|
||||
Returns:
|
||||
Full imported recipe data
|
||||
"""
|
||||
logger.info(f"Importing recipe from URL: {url}")
|
||||
response = await self._make_request(
|
||||
"POST",
|
||||
"/apps/cookbook/api/v1/import",
|
||||
json={"url": url},
|
||||
timeout=Timeout(300.0),
|
||||
)
|
||||
return response.json()
|
||||
|
||||
async def get_recipe_image(self, recipe_id: int, size: str = "full") -> bytes:
|
||||
"""Get the main image of a recipe.
|
||||
|
||||
Args:
|
||||
recipe_id: The recipe ID
|
||||
size: Image size - "full", "thumb" (250px), or "thumb16" (16px)
|
||||
|
||||
Returns:
|
||||
Image bytes
|
||||
"""
|
||||
response = await self._make_request(
|
||||
"GET",
|
||||
f"/apps/cookbook/api/v1/recipes/{recipe_id}/image",
|
||||
params={"size": size},
|
||||
)
|
||||
return response.content
|
||||
|
||||
async def search_recipes(self, query: str) -> List[Dict[str, Any]]:
|
||||
"""Search for recipes by keywords, tags, and categories.
|
||||
|
||||
Args:
|
||||
query: Search string (URL-encoded, space/comma separated)
|
||||
|
||||
Returns:
|
||||
List of matching recipe stubs
|
||||
"""
|
||||
# URL encode the query
|
||||
from urllib.parse import quote
|
||||
|
||||
encoded_query = quote(query)
|
||||
response = await self._make_request(
|
||||
"GET", f"/apps/cookbook/api/v1/search/{encoded_query}"
|
||||
)
|
||||
return response.json()
|
||||
|
||||
async def list_categories(self) -> List[Dict[str, Any]]:
|
||||
"""Get all known categories.
|
||||
|
||||
Note: A category name of '*' indicates recipes with no category.
|
||||
|
||||
Returns:
|
||||
List of categories with recipe counts
|
||||
"""
|
||||
response = await self._make_request("GET", "/apps/cookbook/api/v1/categories")
|
||||
return response.json()
|
||||
|
||||
async def get_recipes_in_category(self, category: str) -> List[Dict[str, Any]]:
|
||||
"""Get all recipes in a specific category.
|
||||
|
||||
Args:
|
||||
category: Category name (use "_" for recipes with no category)
|
||||
|
||||
Returns:
|
||||
List of recipe stubs in the category
|
||||
"""
|
||||
from urllib.parse import quote
|
||||
|
||||
encoded_category = quote(category)
|
||||
response = await self._make_request(
|
||||
"GET", f"/apps/cookbook/api/v1/category/{encoded_category}"
|
||||
)
|
||||
return response.json()
|
||||
|
||||
async def rename_category(self, old_name: str, new_name: str) -> str:
|
||||
"""Rename a category.
|
||||
|
||||
Args:
|
||||
old_name: Current category name
|
||||
new_name: New category name
|
||||
|
||||
Returns:
|
||||
New category name
|
||||
"""
|
||||
from urllib.parse import quote
|
||||
|
||||
encoded_old_name = quote(old_name)
|
||||
response = await self._make_request(
|
||||
"PUT",
|
||||
f"/apps/cookbook/api/v1/category/{encoded_old_name}",
|
||||
json={"name": new_name},
|
||||
)
|
||||
return response.json()
|
||||
|
||||
async def list_keywords(self) -> List[Dict[str, Any]]:
|
||||
"""Get all known keywords/tags.
|
||||
|
||||
Returns:
|
||||
List of keywords with recipe counts
|
||||
"""
|
||||
response = await self._make_request("GET", "/apps/cookbook/api/v1/keywords")
|
||||
return response.json()
|
||||
|
||||
async def get_recipes_with_keywords(
|
||||
self, keywords: List[str]
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Get all recipes associated with certain keywords.
|
||||
|
||||
Args:
|
||||
keywords: List of keywords to filter by
|
||||
|
||||
Returns:
|
||||
List of recipe stubs matching the keywords
|
||||
"""
|
||||
from urllib.parse import quote
|
||||
|
||||
# Join keywords with commas
|
||||
keywords_str = ",".join(keywords)
|
||||
encoded_keywords = quote(keywords_str)
|
||||
response = await self._make_request(
|
||||
"GET", f"/apps/cookbook/api/v1/tags/{encoded_keywords}"
|
||||
)
|
||||
return response.json()
|
||||
@@ -0,0 +1,613 @@
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from nextcloud_mcp_server.client.base import BaseNextcloudClient
|
||||
from nextcloud_mcp_server.models.deck import (
|
||||
DeckACL,
|
||||
DeckAttachment,
|
||||
DeckBoard,
|
||||
DeckCard,
|
||||
DeckComment,
|
||||
DeckConfig,
|
||||
DeckLabel,
|
||||
DeckSession,
|
||||
DeckStack,
|
||||
)
|
||||
|
||||
|
||||
class DeckClient(BaseNextcloudClient):
|
||||
"""Client for Nextcloud Deck app operations."""
|
||||
|
||||
def _get_deck_headers(
|
||||
self, additional_headers: Optional[Dict[str, str]] = None
|
||||
) -> Dict[str, str]:
|
||||
"""Get standard headers required for Deck API calls."""
|
||||
headers = {"OCS-APIRequest": "true", "Content-Type": "application/json"}
|
||||
if additional_headers:
|
||||
headers.update(additional_headers)
|
||||
return headers
|
||||
|
||||
# Boards
|
||||
async def get_boards(
|
||||
self, details: bool = False, if_modified_since: Optional[str] = None
|
||||
) -> List[DeckBoard]:
|
||||
additional_headers = {}
|
||||
if if_modified_since:
|
||||
additional_headers["If-Modified-Since"] = if_modified_since
|
||||
headers = self._get_deck_headers(additional_headers)
|
||||
params = {"details": "true"} if details else {}
|
||||
response = await self._make_request(
|
||||
"GET", "/apps/deck/api/v1.0/boards", headers=headers, params=params
|
||||
)
|
||||
return [DeckBoard(**board) for board in response.json()]
|
||||
|
||||
async def create_board(self, title: str, color: str) -> DeckBoard:
|
||||
json_data = {"title": title, "color": color}
|
||||
headers = self._get_deck_headers()
|
||||
response = await self._make_request(
|
||||
"POST", "/apps/deck/api/v1.0/boards", json=json_data, headers=headers
|
||||
)
|
||||
return DeckBoard(**response.json())
|
||||
|
||||
async def get_board(self, board_id: int) -> DeckBoard:
|
||||
headers = self._get_deck_headers()
|
||||
response = await self._make_request(
|
||||
"GET", f"/apps/deck/api/v1.0/boards/{board_id}", headers=headers
|
||||
)
|
||||
return DeckBoard(**response.json())
|
||||
|
||||
async def update_board(
|
||||
self,
|
||||
board_id: int,
|
||||
title: Optional[str] = None,
|
||||
color: Optional[str] = None,
|
||||
archived: Optional[bool] = None,
|
||||
) -> None:
|
||||
json_data = {}
|
||||
if title is not None:
|
||||
json_data["title"] = title
|
||||
if color is not None:
|
||||
json_data["color"] = color
|
||||
if archived is not None:
|
||||
json_data["archived"] = archived
|
||||
headers = self._get_deck_headers()
|
||||
await self._make_request(
|
||||
"PUT",
|
||||
f"/apps/deck/api/v1.0/boards/{board_id}",
|
||||
json=json_data,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
async def delete_board(self, board_id: int) -> None:
|
||||
headers = self._get_deck_headers()
|
||||
await self._make_request(
|
||||
"DELETE", f"/apps/deck/api/v1.0/boards/{board_id}", headers=headers
|
||||
)
|
||||
|
||||
async def undo_delete_board(self, board_id: int) -> None:
|
||||
headers = self._get_deck_headers()
|
||||
await self._make_request(
|
||||
"POST",
|
||||
f"/apps/deck/api/v1.0/boards/{board_id}/undo_delete",
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
async def add_acl_rule(
|
||||
self,
|
||||
board_id: int,
|
||||
type: int,
|
||||
participant: str,
|
||||
permission_edit: bool,
|
||||
permission_share: bool,
|
||||
permission_manage: bool,
|
||||
) -> DeckACL:
|
||||
json_data = {
|
||||
"type": type,
|
||||
"participant": participant,
|
||||
"permissionEdit": permission_edit,
|
||||
"permissionShare": permission_share,
|
||||
"permissionManage": permission_manage,
|
||||
}
|
||||
headers = self._get_deck_headers()
|
||||
response = await self._make_request(
|
||||
"POST",
|
||||
f"/apps/deck/api/v1.0/boards/{board_id}/acl",
|
||||
json=json_data,
|
||||
headers=headers,
|
||||
)
|
||||
return DeckACL(**response.json())
|
||||
|
||||
async def update_acl_rule(
|
||||
self,
|
||||
board_id: int,
|
||||
acl_id: int,
|
||||
permission_edit: Optional[bool] = None,
|
||||
permission_share: Optional[bool] = None,
|
||||
permission_manage: Optional[bool] = None,
|
||||
) -> None:
|
||||
json_data = {}
|
||||
if permission_edit is not None:
|
||||
json_data["permissionEdit"] = permission_edit
|
||||
if permission_share is not None:
|
||||
json_data["permissionShare"] = permission_share
|
||||
if permission_manage is not None:
|
||||
json_data["permissionManage"] = permission_manage
|
||||
headers = self._get_deck_headers()
|
||||
await self._make_request(
|
||||
"PUT",
|
||||
f"/apps/deck/api/v1.0/boards/{board_id}/acl/{acl_id}",
|
||||
json=json_data,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
async def delete_acl_rule(self, board_id: int, acl_id: int) -> None:
|
||||
headers = self._get_deck_headers()
|
||||
await self._make_request(
|
||||
"DELETE",
|
||||
f"/apps/deck/api/v1.0/boards/{board_id}/acl/{acl_id}",
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
async def clone_board(
|
||||
self,
|
||||
board_id: int,
|
||||
with_cards: bool = False,
|
||||
with_assignments: bool = False,
|
||||
with_labels: bool = False,
|
||||
with_due_date: bool = False,
|
||||
move_cards_to_left_stack: bool = False,
|
||||
restore_archived_cards: bool = False,
|
||||
) -> DeckBoard:
|
||||
json_data = {
|
||||
"withCards": with_cards,
|
||||
"withAssignments": with_assignments,
|
||||
"withLabels": with_labels,
|
||||
"withDueDate": with_due_date,
|
||||
"moveCardsToLeftStack": move_cards_to_left_stack,
|
||||
"restoreArchivedCards": restore_archived_cards,
|
||||
}
|
||||
response = await self._make_request(
|
||||
"POST", f"/apps/deck/api/v1.0/boards/{board_id}/clone", json=json_data
|
||||
)
|
||||
return DeckBoard(**response.json())
|
||||
|
||||
# Stacks
|
||||
async def get_stacks(
|
||||
self, board_id: int, if_modified_since: Optional[str] = None
|
||||
) -> List[DeckStack]:
|
||||
additional_headers = {}
|
||||
if if_modified_since:
|
||||
additional_headers["If-Modified-Since"] = if_modified_since
|
||||
headers = self._get_deck_headers(additional_headers)
|
||||
response = await self._make_request(
|
||||
"GET", f"/apps/deck/api/v1.0/boards/{board_id}/stacks", headers=headers
|
||||
)
|
||||
return [DeckStack(**stack) for stack in response.json()]
|
||||
|
||||
async def get_archived_stacks(self, board_id: int) -> List[DeckStack]:
|
||||
response = await self._make_request(
|
||||
"GET", f"/apps/deck/api/v1.0/boards/{board_id}/stacks/archived"
|
||||
)
|
||||
return [DeckStack(**stack) for stack in response.json()]
|
||||
|
||||
async def get_stack(self, board_id: int, stack_id: int) -> DeckStack:
|
||||
response = await self._make_request(
|
||||
"GET", f"/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}"
|
||||
)
|
||||
return DeckStack(**response.json())
|
||||
|
||||
async def create_stack(self, board_id: int, title: str, order: int) -> DeckStack:
|
||||
json_data = {"title": title, "order": order}
|
||||
headers = self._get_deck_headers()
|
||||
response = await self._make_request(
|
||||
"POST",
|
||||
f"/apps/deck/api/v1.0/boards/{board_id}/stacks",
|
||||
json=json_data,
|
||||
headers=headers,
|
||||
)
|
||||
return DeckStack(**response.json())
|
||||
|
||||
async def update_stack(
|
||||
self,
|
||||
board_id: int,
|
||||
stack_id: int,
|
||||
title: Optional[str] = None,
|
||||
order: Optional[int] = None,
|
||||
) -> None:
|
||||
json_data = {}
|
||||
if title is not None:
|
||||
json_data["title"] = title
|
||||
if order is not None:
|
||||
json_data["order"] = order
|
||||
headers = self._get_deck_headers()
|
||||
await self._make_request(
|
||||
"PUT",
|
||||
f"/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}",
|
||||
json=json_data,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
async def delete_stack(self, board_id: int, stack_id: int) -> None:
|
||||
await self._make_request(
|
||||
"DELETE", f"/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}"
|
||||
)
|
||||
|
||||
# Cards
|
||||
async def get_card(self, board_id: int, stack_id: int, card_id: int) -> DeckCard:
|
||||
headers = self._get_deck_headers()
|
||||
response = await self._make_request(
|
||||
"GET",
|
||||
f"/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}/cards/{card_id}",
|
||||
headers=headers,
|
||||
)
|
||||
return DeckCard(**response.json())
|
||||
|
||||
async def create_card(
|
||||
self,
|
||||
board_id: int,
|
||||
stack_id: int,
|
||||
title: str,
|
||||
type: str = "plain",
|
||||
order: int = 999,
|
||||
description: Optional[str] = None,
|
||||
duedate: Optional[str] = None,
|
||||
) -> DeckCard:
|
||||
json_data = {
|
||||
"title": title,
|
||||
"type": type,
|
||||
"order": order,
|
||||
}
|
||||
if description is not None:
|
||||
json_data["description"] = description
|
||||
if duedate is not None:
|
||||
json_data["duedate"] = duedate
|
||||
headers = self._get_deck_headers()
|
||||
response = await self._make_request(
|
||||
"POST",
|
||||
f"/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}/cards",
|
||||
json=json_data,
|
||||
headers=headers,
|
||||
)
|
||||
return DeckCard(**response.json())
|
||||
|
||||
async def update_card(
|
||||
self,
|
||||
board_id: int,
|
||||
stack_id: int,
|
||||
card_id: int,
|
||||
title: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
type: Optional[str] = None,
|
||||
owner: Optional[str] = None,
|
||||
order: Optional[int] = None,
|
||||
duedate: Optional[str] = None,
|
||||
archived: Optional[bool] = None,
|
||||
done: Optional[str] = None,
|
||||
) -> None:
|
||||
# First, get the current card to use existing values for required fields
|
||||
current_card = await self.get_card(board_id, stack_id, card_id)
|
||||
|
||||
json_data = {}
|
||||
if title is not None:
|
||||
json_data["title"] = title
|
||||
if description is not None:
|
||||
json_data["description"] = description
|
||||
# Type is required by the API, use provided or keep current
|
||||
json_data["type"] = type if type is not None else current_card.type
|
||||
# Owner is required by the API, use provided or keep current
|
||||
json_data["owner"] = (
|
||||
owner
|
||||
if owner is not None
|
||||
else (
|
||||
current_card.owner
|
||||
if isinstance(current_card.owner, str)
|
||||
else current_card.owner.uid
|
||||
if hasattr(current_card.owner, "uid")
|
||||
else current_card.owner.primaryKey
|
||||
)
|
||||
)
|
||||
if order is not None:
|
||||
json_data["order"] = order
|
||||
if duedate is not None:
|
||||
json_data["duedate"] = duedate
|
||||
if archived is not None:
|
||||
json_data["archived"] = archived
|
||||
if done is not None:
|
||||
json_data["done"] = done
|
||||
headers = self._get_deck_headers()
|
||||
await self._make_request(
|
||||
"PUT",
|
||||
f"/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}/cards/{card_id}",
|
||||
json=json_data,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
async def delete_card(self, board_id: int, stack_id: int, card_id: int) -> None:
|
||||
headers = self._get_deck_headers()
|
||||
await self._make_request(
|
||||
"DELETE",
|
||||
f"/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}/cards/{card_id}",
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
async def archive_card(self, board_id: int, stack_id: int, card_id: int) -> None:
|
||||
await self._make_request(
|
||||
"PUT",
|
||||
f"/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}/cards/{card_id}/archive",
|
||||
)
|
||||
|
||||
async def unarchive_card(self, board_id: int, stack_id: int, card_id: int) -> None:
|
||||
await self._make_request(
|
||||
"PUT",
|
||||
f"/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}/cards/{card_id}/unarchive",
|
||||
)
|
||||
|
||||
async def assign_label_to_card(
|
||||
self, board_id: int, stack_id: int, card_id: int, label_id: int
|
||||
) -> None:
|
||||
json_data = {"labelId": label_id}
|
||||
await self._make_request(
|
||||
"PUT",
|
||||
f"/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}/cards/{card_id}/assignLabel",
|
||||
json=json_data,
|
||||
)
|
||||
|
||||
async def remove_label_from_card(
|
||||
self, board_id: int, stack_id: int, card_id: int, label_id: int
|
||||
) -> None:
|
||||
json_data = {"labelId": label_id}
|
||||
await self._make_request(
|
||||
"PUT",
|
||||
f"/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}/cards/{card_id}/removeLabel",
|
||||
json=json_data,
|
||||
)
|
||||
|
||||
async def assign_user_to_card(
|
||||
self, board_id: int, stack_id: int, card_id: int, user_id: str
|
||||
) -> None:
|
||||
json_data = {"userId": user_id}
|
||||
await self._make_request(
|
||||
"PUT",
|
||||
f"/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}/cards/{card_id}/assignUser",
|
||||
json=json_data,
|
||||
)
|
||||
|
||||
async def unassign_user_from_card(
|
||||
self, board_id: int, stack_id: int, card_id: int, user_id: str
|
||||
) -> None:
|
||||
json_data = {"userId": user_id}
|
||||
await self._make_request(
|
||||
"PUT",
|
||||
f"/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}/cards/{card_id}/unassignUser",
|
||||
json=json_data,
|
||||
)
|
||||
|
||||
async def reorder_card(
|
||||
self,
|
||||
board_id: int,
|
||||
stack_id: int,
|
||||
card_id: int,
|
||||
order: int,
|
||||
target_stack_id: int,
|
||||
) -> None:
|
||||
json_data = {"order": order, "stackId": target_stack_id}
|
||||
await self._make_request(
|
||||
"PUT",
|
||||
f"/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}/cards/{card_id}/reorder",
|
||||
json=json_data,
|
||||
)
|
||||
|
||||
# Labels
|
||||
async def get_label(self, board_id: int, label_id: int) -> DeckLabel:
|
||||
headers = self._get_deck_headers()
|
||||
response = await self._make_request(
|
||||
"GET",
|
||||
f"/apps/deck/api/v1.0/boards/{board_id}/labels/{label_id}",
|
||||
headers=headers,
|
||||
)
|
||||
return DeckLabel(**response.json())
|
||||
|
||||
async def create_label(self, board_id: int, title: str, color: str) -> DeckLabel:
|
||||
json_data = {"title": title, "color": color}
|
||||
headers = self._get_deck_headers()
|
||||
response = await self._make_request(
|
||||
"POST",
|
||||
f"/apps/deck/api/v1.0/boards/{board_id}/labels",
|
||||
json=json_data,
|
||||
headers=headers,
|
||||
)
|
||||
return DeckLabel(**response.json())
|
||||
|
||||
async def update_label(
|
||||
self,
|
||||
board_id: int,
|
||||
label_id: int,
|
||||
title: Optional[str] = None,
|
||||
color: Optional[str] = None,
|
||||
) -> None:
|
||||
json_data = {}
|
||||
if title is not None:
|
||||
json_data["title"] = title
|
||||
if color is not None:
|
||||
json_data["color"] = color
|
||||
await self._make_request(
|
||||
"PUT",
|
||||
f"/apps/deck/api/v1.0/boards/{board_id}/labels/{label_id}",
|
||||
json=json_data,
|
||||
)
|
||||
|
||||
async def delete_label(self, board_id: int, label_id: int) -> None:
|
||||
await self._make_request(
|
||||
"DELETE", f"/apps/deck/api/v1.0/boards/{board_id}/labels/{label_id}"
|
||||
)
|
||||
|
||||
# Attachments
|
||||
async def get_attachments(
|
||||
self, board_id: int, stack_id: int, card_id: int
|
||||
) -> List[DeckAttachment]:
|
||||
response = await self._make_request(
|
||||
"GET",
|
||||
f"/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}/cards/{card_id}/attachments",
|
||||
)
|
||||
return [DeckAttachment(**attachment) for attachment in response.json()]
|
||||
|
||||
async def get_attachment_file(
|
||||
self, board_id: int, stack_id: int, card_id: int, attachment_id: int
|
||||
) -> Any:
|
||||
# This endpoint returns the raw file, so we return the raw response content
|
||||
response = await self._make_request(
|
||||
"GET",
|
||||
f"/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}/cards/{card_id}/attachments/{attachment_id}",
|
||||
)
|
||||
return response.content
|
||||
|
||||
async def upload_attachment(
|
||||
self,
|
||||
board_id: int,
|
||||
stack_id: int,
|
||||
card_id: int,
|
||||
file_data: bytes,
|
||||
file_type: str = "file",
|
||||
) -> DeckAttachment:
|
||||
# The API expects binary data directly, not JSON
|
||||
headers = {"Content-Type": "application/octet-stream"}
|
||||
params = {"type": file_type}
|
||||
response = await self._make_request(
|
||||
"POST",
|
||||
f"/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}/cards/{card_id}/attachments",
|
||||
headers=headers,
|
||||
params=params,
|
||||
data=file_data,
|
||||
)
|
||||
return DeckAttachment(**response.json())
|
||||
|
||||
async def update_attachment(
|
||||
self,
|
||||
board_id: int,
|
||||
stack_id: int,
|
||||
card_id: int,
|
||||
attachment_id: int,
|
||||
file_data: bytes,
|
||||
file_type: str = "deck_file",
|
||||
) -> DeckAttachment:
|
||||
headers = {"Content-Type": "application/octet-stream"}
|
||||
params = {"type": file_type}
|
||||
response = await self._make_request(
|
||||
"PUT",
|
||||
f"/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}/cards/{card_id}/attachments/{attachment_id}",
|
||||
headers=headers,
|
||||
params=params,
|
||||
data=file_data,
|
||||
)
|
||||
return DeckAttachment(**response.json())
|
||||
|
||||
async def delete_attachment(
|
||||
self, board_id: int, stack_id: int, card_id: int, attachment_id: int
|
||||
) -> None:
|
||||
await self._make_request(
|
||||
"DELETE",
|
||||
f"/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}/cards/{card_id}/attachments/{attachment_id}",
|
||||
)
|
||||
|
||||
async def restore_attachment(
|
||||
self, board_id: int, stack_id: int, card_id: int, attachment_id: int
|
||||
) -> None:
|
||||
await self._make_request(
|
||||
"PUT",
|
||||
f"/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}/cards/{card_id}/attachments/{attachment_id}/restore",
|
||||
)
|
||||
|
||||
# OCS API Endpoints (Config, Comments, Sessions)
|
||||
async def get_config(self) -> DeckConfig:
|
||||
headers = {"OCS-APIRequest": "true", "Accept": "application/json"}
|
||||
response = await self._make_request(
|
||||
"GET", "/ocs/v2.php/apps/deck/api/v1.0/config", headers=headers
|
||||
)
|
||||
return DeckConfig(**response.json()["ocs"]["data"])
|
||||
|
||||
async def set_config_value(
|
||||
self, key: str, value: Any, board_id: Optional[int] = None
|
||||
) -> Any:
|
||||
path = f"/ocs/v2.php/apps/deck/api/v1.0/config/{key}"
|
||||
if board_id:
|
||||
path = f"/ocs/v2.php/apps/deck/api/v1.0/config/board:{board_id}:{key}"
|
||||
json_data = {"value": value}
|
||||
response = await self._make_request(
|
||||
"POST",
|
||||
path,
|
||||
json=json_data,
|
||||
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
|
||||
)
|
||||
return response.json()["ocs"]["data"]
|
||||
|
||||
async def get_comments(
|
||||
self, card_id: int, limit: int = 20, offset: int = 0
|
||||
) -> List[DeckComment]:
|
||||
params = {"limit": limit, "offset": offset}
|
||||
response = await self._make_request(
|
||||
"GET",
|
||||
f"/ocs/v2.php/apps/deck/api/v1.0/cards/{card_id}/comments",
|
||||
params=params,
|
||||
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
|
||||
)
|
||||
return [DeckComment(**comment) for comment in response.json()["ocs"]["data"]]
|
||||
|
||||
async def create_comment(
|
||||
self, card_id: int, message: str, parent_id: Optional[int] = None
|
||||
) -> DeckComment:
|
||||
json_data = {"message": message}
|
||||
if parent_id is not None:
|
||||
json_data["parentId"] = parent_id
|
||||
response = await self._make_request(
|
||||
"POST",
|
||||
f"/ocs/v2.php/apps/deck/api/v1.0/cards/{card_id}/comments",
|
||||
json=json_data,
|
||||
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
|
||||
)
|
||||
return DeckComment(**response.json()["ocs"]["data"])
|
||||
|
||||
async def update_comment(
|
||||
self, card_id: int, comment_id: int, message: str
|
||||
) -> DeckComment:
|
||||
json_data = {"message": message}
|
||||
response = await self._make_request(
|
||||
"PUT",
|
||||
f"/ocs/v2.php/apps/deck/api/v1.0/cards/{card_id}/comments/{comment_id}",
|
||||
json=json_data,
|
||||
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
|
||||
)
|
||||
return DeckComment(**response.json()["ocs"]["data"])
|
||||
|
||||
async def delete_comment(self, card_id: int, comment_id: int) -> None:
|
||||
await self._make_request(
|
||||
"DELETE",
|
||||
f"/ocs/v2.php/apps/deck/api/v1.0/cards/{card_id}/comments/{comment_id}",
|
||||
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
|
||||
)
|
||||
|
||||
async def create_session(self, board_id: int) -> DeckSession:
|
||||
json_data = {"boardId": board_id}
|
||||
response = await self._make_request(
|
||||
"PUT",
|
||||
"/ocs/v2.php/apps/deck/api/v1.0/session/create",
|
||||
json=json_data,
|
||||
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
|
||||
)
|
||||
return DeckSession(**response.json()["ocs"]["data"])
|
||||
|
||||
async def sync_session(self, board_id: int, token: str) -> None:
|
||||
json_data = {"boardId": board_id, "token": token}
|
||||
await self._make_request(
|
||||
"POST",
|
||||
"/ocs/v2.php/apps/deck/api/v1.0/session/sync",
|
||||
json=json_data,
|
||||
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
|
||||
)
|
||||
|
||||
async def close_session(self, board_id: int, token: str) -> None:
|
||||
json_data = {"boardId": board_id, "token": token}
|
||||
await self._make_request(
|
||||
"POST",
|
||||
"/ocs/v2.php/apps/deck/api/v1.0/session/close",
|
||||
json=json_data,
|
||||
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
|
||||
)
|
||||
@@ -0,0 +1,151 @@
|
||||
"""Nextcloud Groups API client."""
|
||||
|
||||
import logging
|
||||
from typing import List
|
||||
|
||||
from .base import BaseNextcloudClient, retry_on_429
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GroupsClient(BaseNextcloudClient):
|
||||
"""Client for Nextcloud Groups API operations."""
|
||||
|
||||
@retry_on_429
|
||||
async def search_groups(
|
||||
self,
|
||||
search: str | None = None,
|
||||
limit: int | None = None,
|
||||
offset: int | None = None,
|
||||
) -> List[str]:
|
||||
"""
|
||||
Search for groups on the Nextcloud server.
|
||||
|
||||
Args:
|
||||
search: Optional search string to filter groups
|
||||
limit: Optional limit for number of results
|
||||
offset: Optional offset for pagination
|
||||
|
||||
Returns:
|
||||
List of group IDs matching the search criteria
|
||||
"""
|
||||
params = {}
|
||||
if search is not None:
|
||||
params["search"] = search
|
||||
if limit is not None:
|
||||
params["limit"] = limit
|
||||
if offset is not None:
|
||||
params["offset"] = offset
|
||||
|
||||
response = await self._client.get(
|
||||
"/ocs/v2.php/cloud/groups",
|
||||
params=params,
|
||||
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
groups = data["ocs"]["data"].get("groups", [])
|
||||
return groups
|
||||
|
||||
@retry_on_429
|
||||
async def create_group(self, groupid: str) -> None:
|
||||
"""
|
||||
Create a new group.
|
||||
|
||||
Args:
|
||||
groupid: The group ID to create
|
||||
|
||||
Raises:
|
||||
HTTPStatusError: If the request fails (e.g., group already exists)
|
||||
"""
|
||||
response = await self._client.post(
|
||||
"/ocs/v2.php/cloud/groups",
|
||||
data={"groupid": groupid},
|
||||
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
|
||||
)
|
||||
response.raise_for_status()
|
||||
logger.info(f"Created group: {groupid}")
|
||||
|
||||
@retry_on_429
|
||||
async def delete_group(self, groupid: str) -> None:
|
||||
"""
|
||||
Delete a group.
|
||||
|
||||
Args:
|
||||
groupid: The group ID to delete
|
||||
|
||||
Raises:
|
||||
HTTPStatusError: If the request fails (e.g., group doesn't exist)
|
||||
"""
|
||||
response = await self._client.delete(
|
||||
f"/ocs/v2.php/cloud/groups/{groupid}",
|
||||
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
|
||||
)
|
||||
response.raise_for_status()
|
||||
logger.info(f"Deleted group: {groupid}")
|
||||
|
||||
@retry_on_429
|
||||
async def get_group_members(self, groupid: str) -> List[str]:
|
||||
"""
|
||||
Get members of a group.
|
||||
|
||||
Args:
|
||||
groupid: The group ID
|
||||
|
||||
Returns:
|
||||
List of usernames in the group
|
||||
"""
|
||||
response = await self._client.get(
|
||||
f"/ocs/v2.php/cloud/groups/{groupid}",
|
||||
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
users = data["ocs"]["data"].get("users", [])
|
||||
return users
|
||||
|
||||
@retry_on_429
|
||||
async def get_group_subadmins(self, groupid: str) -> List[str]:
|
||||
"""
|
||||
Get subadmins of a group.
|
||||
|
||||
Args:
|
||||
groupid: The group ID
|
||||
|
||||
Returns:
|
||||
List of usernames who are subadmins of the group
|
||||
"""
|
||||
response = await self._client.get(
|
||||
f"/ocs/v2.php/cloud/groups/{groupid}/subadmins",
|
||||
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
# The API returns data as a list or dict depending on results
|
||||
subadmins_data = data["ocs"]["data"]
|
||||
if isinstance(subadmins_data, list):
|
||||
return subadmins_data
|
||||
return []
|
||||
|
||||
@retry_on_429
|
||||
async def update_group_displayname(self, groupid: str, displayname: str) -> None:
|
||||
"""
|
||||
Update a group's display name.
|
||||
|
||||
Args:
|
||||
groupid: The group ID
|
||||
displayname: The new display name
|
||||
|
||||
Raises:
|
||||
HTTPStatusError: If the request fails
|
||||
"""
|
||||
response = await self._client.put(
|
||||
f"/ocs/v2.php/cloud/groups/{groupid}",
|
||||
data={"key": "displayname", "value": displayname},
|
||||
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
|
||||
)
|
||||
response.raise_for_status()
|
||||
logger.info(f"Updated group {groupid} displayname to: {displayname}")
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Client for Nextcloud Notes app operations."""
|
||||
|
||||
from typing import Dict, List, Any, Optional
|
||||
import logging
|
||||
from typing import Any, AsyncIterator, Dict, Optional
|
||||
|
||||
from .base import BaseNextcloudClient
|
||||
|
||||
@@ -16,10 +16,21 @@ class NotesClient(BaseNextcloudClient):
|
||||
response = await self._make_request("GET", "/apps/notes/api/v1/settings")
|
||||
return response.json()
|
||||
|
||||
async def get_all_notes(self) -> List[Dict[str, Any]]:
|
||||
"""Get all notes."""
|
||||
response = await self._make_request("GET", "/apps/notes/api/v1/notes")
|
||||
return response.json()
|
||||
async def get_all_notes(self) -> AsyncIterator[Dict[str, Any]]:
|
||||
"""Get all notes, yielding them one at a time."""
|
||||
cursor = ""
|
||||
|
||||
while True:
|
||||
response = await self._make_request(
|
||||
"GET",
|
||||
"/apps/notes/api/v1/notes",
|
||||
params={"chunkSize": 10, "chunkCursor": cursor},
|
||||
)
|
||||
for note in response.json():
|
||||
yield note
|
||||
if "X-Notes-Chunk-Cursor" not in response.headers:
|
||||
break
|
||||
cursor = response.headers["X-Notes-Chunk-Cursor"]
|
||||
|
||||
async def get_note(self, note_id: int) -> Dict[str, Any]:
|
||||
"""Get a specific note by ID."""
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
"""Nextcloud OCS Sharing API client for file/folder sharing operations."""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from .base import BaseNextcloudClient, retry_on_429
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SharingClient(BaseNextcloudClient):
|
||||
"""Client for Nextcloud OCS Sharing API operations."""
|
||||
|
||||
@retry_on_429
|
||||
async def create_share(
|
||||
self,
|
||||
path: str,
|
||||
share_with: str,
|
||||
share_type: int = 0,
|
||||
permissions: int = 1,
|
||||
) -> dict[str, Any]:
|
||||
"""Create a share for a file or folder.
|
||||
|
||||
Args:
|
||||
path: Path to file/folder to share (relative to user's files)
|
||||
share_with: Username (for user share) or group name (for group share)
|
||||
share_type: Share type (0=user, 1=group, 3=public link)
|
||||
permissions: Share permissions:
|
||||
- 1 = read
|
||||
- 2 = update
|
||||
- 4 = create
|
||||
- 8 = delete
|
||||
- 16 = share
|
||||
- 31 = all permissions
|
||||
Common combinations: 1 (read-only), 3 (read+update), 15 (read+update+create+delete)
|
||||
|
||||
Returns:
|
||||
Share data including share ID
|
||||
|
||||
Raises:
|
||||
HTTPStatusError: If the request fails
|
||||
"""
|
||||
response = await self._client.post(
|
||||
"/ocs/v2.php/apps/files_sharing/api/v1/shares",
|
||||
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
|
||||
data={
|
||||
"path": path,
|
||||
"shareType": share_type,
|
||||
"shareWith": share_with,
|
||||
"permissions": permissions,
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
# OCS API v2 uses HTTP-style status codes (200 for success)
|
||||
# OCS API v1 used custom codes (100 for success)
|
||||
ocs_status = data["ocs"]["meta"]["statuscode"]
|
||||
if ocs_status not in (100, 200):
|
||||
ocs_message = data["ocs"]["meta"].get("message", "Unknown error")
|
||||
raise RuntimeError(f"OCS API error (code {ocs_status}): {ocs_message}")
|
||||
|
||||
share_data = data["ocs"]["data"]
|
||||
|
||||
# Handle case where data might be an empty list on error
|
||||
if not share_data or (isinstance(share_data, list) and len(share_data) == 0):
|
||||
ocs_message = data["ocs"]["meta"].get("message", "Unknown error")
|
||||
raise RuntimeError(
|
||||
f"Share creation failed: {ocs_message} (status {ocs_status})"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Created share {share_data['id']}: {path} -> {share_with} "
|
||||
f"(type={share_type}, permissions={permissions})"
|
||||
)
|
||||
return share_data
|
||||
|
||||
@retry_on_429
|
||||
async def delete_share(self, share_id: int) -> None:
|
||||
"""Delete a share by its ID.
|
||||
|
||||
Args:
|
||||
share_id: The share ID to delete
|
||||
|
||||
Raises:
|
||||
HTTPStatusError: If the request fails
|
||||
"""
|
||||
response = await self._client.delete(
|
||||
f"/ocs/v2.php/apps/files_sharing/api/v1/shares/{share_id}",
|
||||
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
if data["ocs"]["meta"]["statuscode"] not in (100, 200):
|
||||
raise RuntimeError(
|
||||
f"OCS API error: {data['ocs']['meta'].get('message', 'Unknown error')}"
|
||||
)
|
||||
|
||||
logger.info(f"Deleted share {share_id}")
|
||||
|
||||
@retry_on_429
|
||||
async def get_share(self, share_id: int) -> dict[str, Any]:
|
||||
"""Get information about a specific share.
|
||||
|
||||
Args:
|
||||
share_id: The share ID
|
||||
|
||||
Returns:
|
||||
Share data
|
||||
|
||||
Raises:
|
||||
HTTPStatusError: If the request fails
|
||||
"""
|
||||
response = await self._client.get(
|
||||
f"/ocs/v2.php/apps/files_sharing/api/v1/shares/{share_id}",
|
||||
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
if data["ocs"]["meta"]["statuscode"] not in (100, 200):
|
||||
raise RuntimeError(
|
||||
f"OCS API error: {data['ocs']['meta'].get('message', 'Unknown error')}"
|
||||
)
|
||||
|
||||
share_data = data["ocs"]["data"]
|
||||
# The API returns a list with a single share, extract the first element
|
||||
if isinstance(share_data, list) and len(share_data) > 0:
|
||||
return share_data[0]
|
||||
return share_data
|
||||
|
||||
@retry_on_429
|
||||
async def list_shares(
|
||||
self, path: str | None = None, shared_with_me: bool = False
|
||||
) -> list[dict[str, Any]]:
|
||||
"""List shares.
|
||||
|
||||
Args:
|
||||
path: Optional path to filter shares for a specific file/folder
|
||||
shared_with_me: If True, list shares shared with the current user
|
||||
|
||||
Returns:
|
||||
List of share data
|
||||
|
||||
Raises:
|
||||
HTTPStatusError: If the request fails
|
||||
"""
|
||||
params = {}
|
||||
if path:
|
||||
params["path"] = path
|
||||
if shared_with_me:
|
||||
params["shared_with_me"] = "true"
|
||||
|
||||
response = await self._client.get(
|
||||
"/ocs/v2.php/apps/files_sharing/api/v1/shares",
|
||||
params=params,
|
||||
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
if data["ocs"]["meta"]["statuscode"] not in (100, 200):
|
||||
raise RuntimeError(
|
||||
f"OCS API error: {data['ocs']['meta'].get('message', 'Unknown error')}"
|
||||
)
|
||||
|
||||
# Handle both single share and list of shares
|
||||
shares_data = data["ocs"]["data"]
|
||||
if isinstance(shares_data, dict):
|
||||
return [shares_data]
|
||||
return shares_data if shares_data else []
|
||||
|
||||
@retry_on_429
|
||||
async def update_share(
|
||||
self, share_id: int, permissions: int | None = None
|
||||
) -> dict[str, Any]:
|
||||
"""Update a share's permissions.
|
||||
|
||||
Args:
|
||||
share_id: The share ID to update
|
||||
permissions: New permissions value (see create_share for values)
|
||||
|
||||
Returns:
|
||||
Updated share data
|
||||
|
||||
Raises:
|
||||
HTTPStatusError: If the request fails
|
||||
"""
|
||||
data = {}
|
||||
if permissions is not None:
|
||||
data["permissions"] = permissions
|
||||
|
||||
response = await self._client.put(
|
||||
f"/ocs/v2.php/apps/files_sharing/api/v1/shares/{share_id}",
|
||||
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
|
||||
data=data,
|
||||
)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
|
||||
if result["ocs"]["meta"]["statuscode"] not in (100, 200):
|
||||
raise RuntimeError(
|
||||
f"OCS API error: {result['ocs']['meta'].get('message', 'Unknown error')}"
|
||||
)
|
||||
|
||||
logger.info(f"Updated share {share_id}")
|
||||
return result["ocs"]["data"]
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Client for Nextcloud Tables app operations."""
|
||||
|
||||
from typing import Dict, List, Any, Optional
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from .base import BaseNextcloudClient
|
||||
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from nextcloud_mcp_server.client.base import BaseNextcloudClient
|
||||
from nextcloud_mcp_server.models.users import UserDetails
|
||||
|
||||
|
||||
class UsersClient(BaseNextcloudClient):
|
||||
"""Client for Nextcloud User API operations."""
|
||||
|
||||
def _get_user_headers(
|
||||
self, additional_headers: Optional[Dict[str, str]] = None
|
||||
) -> Dict[str, str]:
|
||||
"""Get standard headers required for User API calls."""
|
||||
headers = {"OCS-APIRequest": "true", "Accept": "application/json"}
|
||||
if additional_headers:
|
||||
headers.update(additional_headers)
|
||||
return headers
|
||||
|
||||
async def create_user(
|
||||
self,
|
||||
userid: str,
|
||||
password: Optional[str] = None,
|
||||
display_name: Optional[str] = None,
|
||||
email: Optional[str] = None,
|
||||
groups: Optional[List[str]] = None,
|
||||
subadmin_groups: Optional[List[str]] = None,
|
||||
quota: Optional[str] = None,
|
||||
language: Optional[str] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Create a new user on the Nextcloud server.
|
||||
"""
|
||||
data = {"userid": userid}
|
||||
if password is not None:
|
||||
data["password"] = password
|
||||
if display_name is not None:
|
||||
data["displayName"] = display_name
|
||||
if email is not None:
|
||||
data["email"] = email
|
||||
if groups is not None:
|
||||
for i, group in enumerate(groups):
|
||||
data[f"groups[{i}]"] = group
|
||||
if subadmin_groups is not None:
|
||||
for i, group in enumerate(subadmin_groups):
|
||||
data[f"subadmin[{i}]"] = group
|
||||
if quota is not None:
|
||||
data["quota"] = quota
|
||||
if language is not None:
|
||||
data["language"] = language
|
||||
|
||||
headers = self._get_user_headers()
|
||||
await self._make_request(
|
||||
"POST", "/ocs/v2.php/cloud/users", data=data, headers=headers
|
||||
)
|
||||
|
||||
async def search_users(
|
||||
self,
|
||||
search: Optional[str] = None,
|
||||
limit: Optional[int] = None,
|
||||
offset: Optional[int] = None,
|
||||
) -> List[str]:
|
||||
"""
|
||||
Retrieves a list of users from the Nextcloud server.
|
||||
"""
|
||||
params = {}
|
||||
if search is not None:
|
||||
params["search"] = search
|
||||
if limit is not None:
|
||||
params["limit"] = limit
|
||||
if offset is not None:
|
||||
params["offset"] = offset
|
||||
|
||||
headers = self._get_user_headers()
|
||||
response = await self._make_request(
|
||||
"GET", "/ocs/v2.php/cloud/users", params=params, headers=headers
|
||||
)
|
||||
# The v2 API returns JSON with users as a direct list under data.users
|
||||
data = response.json()["ocs"]["data"]
|
||||
return data.get("users", [])
|
||||
|
||||
async def get_user_details(self, userid: str) -> UserDetails:
|
||||
"""
|
||||
Retrieves information about a single user.
|
||||
"""
|
||||
headers = self._get_user_headers()
|
||||
response = await self._make_request(
|
||||
"GET", f"/ocs/v2.php/cloud/users/{userid}", headers=headers
|
||||
)
|
||||
return UserDetails(**response.json()["ocs"]["data"])
|
||||
|
||||
async def update_user_field(self, userid: str, key: str, value: str) -> None:
|
||||
"""
|
||||
Edits attributes related to a user.
|
||||
"""
|
||||
data = {"key": key, "value": value}
|
||||
headers = self._get_user_headers()
|
||||
await self._make_request(
|
||||
"PUT", f"/ocs/v2.php/cloud/users/{userid}", data=data, headers=headers
|
||||
)
|
||||
|
||||
async def get_editable_user_fields(self) -> List[str]:
|
||||
"""
|
||||
Gets the list of editable data fields for a user.
|
||||
"""
|
||||
headers = self._get_user_headers()
|
||||
response = await self._make_request(
|
||||
"GET", "/ocs/v2.php/cloud/user/fields", headers=headers
|
||||
)
|
||||
# The v2 API returns data as a direct list
|
||||
data = response.json()["ocs"]["data"]
|
||||
return data if isinstance(data, list) else []
|
||||
|
||||
async def disable_user(self, userid: str) -> None:
|
||||
"""
|
||||
Disables a user on the Nextcloud server.
|
||||
"""
|
||||
headers = self._get_user_headers()
|
||||
await self._make_request(
|
||||
"PUT", f"/ocs/v2.php/cloud/users/{userid}/disable", headers=headers
|
||||
)
|
||||
|
||||
async def enable_user(self, userid: str) -> None:
|
||||
"""
|
||||
Enables a user on the Nextcloud server.
|
||||
"""
|
||||
headers = self._get_user_headers()
|
||||
await self._make_request(
|
||||
"PUT", f"/ocs/v2.php/cloud/users/{userid}/enable", headers=headers
|
||||
)
|
||||
|
||||
async def delete_user(self, userid: str) -> None:
|
||||
"""
|
||||
Deletes a user from the Nextcloud server.
|
||||
"""
|
||||
headers = self._get_user_headers()
|
||||
await self._make_request(
|
||||
"DELETE", f"/ocs/v2.php/cloud/users/{userid}", headers=headers
|
||||
)
|
||||
|
||||
async def get_user_groups(self, userid: str) -> List[str]:
|
||||
"""
|
||||
Retrieves a list of groups the specified user is a member of.
|
||||
"""
|
||||
headers = self._get_user_headers()
|
||||
response = await self._make_request(
|
||||
"GET", f"/ocs/v2.php/cloud/users/{userid}/groups", headers=headers
|
||||
)
|
||||
# The v2 API returns groups as a direct list under data.groups
|
||||
data = response.json()["ocs"]["data"]
|
||||
return data.get("groups", [])
|
||||
|
||||
async def add_user_to_group(self, userid: str, groupid: str) -> None:
|
||||
"""
|
||||
Adds the specified user to the specified group.
|
||||
"""
|
||||
data = {"groupid": groupid}
|
||||
headers = self._get_user_headers()
|
||||
await self._make_request(
|
||||
"POST",
|
||||
f"/ocs/v2.php/cloud/users/{userid}/groups",
|
||||
data=data,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
async def remove_user_from_group(self, userid: str, groupid: str) -> None:
|
||||
"""
|
||||
Removes the specified user from the specified group.
|
||||
"""
|
||||
data = {"groupid": groupid}
|
||||
headers = self._get_user_headers()
|
||||
await self._make_request(
|
||||
"DELETE",
|
||||
f"/ocs/v2.php/cloud/users/{userid}/groups",
|
||||
data=data,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
async def promote_user_to_subadmin(self, userid: str, groupid: str) -> None:
|
||||
"""
|
||||
Makes a user the subadmin of a group.
|
||||
"""
|
||||
data = {"groupid": groupid}
|
||||
headers = self._get_user_headers()
|
||||
await self._make_request(
|
||||
"POST",
|
||||
f"/ocs/v2.php/cloud/users/{userid}/subadmins",
|
||||
data=data,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
async def demote_user_from_subadmin(self, userid: str, groupid: str) -> None:
|
||||
"""
|
||||
Removes the subadmin rights for the user specified from the group specified.
|
||||
"""
|
||||
data = {"groupid": groupid}
|
||||
headers = self._get_user_headers()
|
||||
await self._make_request(
|
||||
"DELETE",
|
||||
f"/ocs/v2.php/cloud/users/{userid}/subadmins",
|
||||
data=data,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
async def get_user_subadmin_groups(self, userid: str) -> List[str]:
|
||||
"""
|
||||
Returns the groups in which the user is a subadmin.
|
||||
"""
|
||||
headers = self._get_user_headers()
|
||||
response = await self._make_request(
|
||||
"GET", f"/ocs/v2.php/cloud/users/{userid}/subadmins", headers=headers
|
||||
)
|
||||
# The v2 API returns data as a direct list
|
||||
data = response.json()["ocs"]["data"]
|
||||
return data if isinstance(data, list) else []
|
||||
|
||||
async def resend_welcome_email(self, userid: str) -> None:
|
||||
"""
|
||||
Triggers the welcome email for this user again.
|
||||
"""
|
||||
headers = self._get_user_headers()
|
||||
await self._make_request(
|
||||
"POST", f"/ocs/v2.php/cloud/users/{userid}/welcome", headers=headers
|
||||
)
|
||||
@@ -1,8 +1,10 @@
|
||||
"""WebDAV client for Nextcloud file operations."""
|
||||
|
||||
import mimetypes
|
||||
from typing import Tuple, Dict, Any, Optional
|
||||
import logging
|
||||
import mimetypes
|
||||
import xml.etree.ElementTree as ET
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from httpx import HTTPStatusError
|
||||
|
||||
from .base import BaseNextcloudClient
|
||||
@@ -22,46 +24,39 @@ class WebDAVClient(BaseNextcloudClient):
|
||||
path_with_slash = path
|
||||
|
||||
webdav_path = f"{self._get_webdav_base_path()}/{path_with_slash.lstrip('/')}"
|
||||
logger.info(f"Deleting WebDAV resource: {webdav_path}")
|
||||
logger.debug(f"Deleting WebDAV resource: {webdav_path}")
|
||||
|
||||
headers = {"OCS-APIRequest": "true"}
|
||||
try:
|
||||
# First try a PROPFIND to verify resource exists
|
||||
propfind_headers = {"Depth": "0", "OCS-APIRequest": "true"}
|
||||
try:
|
||||
propfind_resp = await self._client.request(
|
||||
propfind_resp = await self._make_request(
|
||||
"PROPFIND", webdav_path, headers=propfind_headers
|
||||
)
|
||||
logger.info(
|
||||
f"Resource exists check (PROPFIND) status: {propfind_resp.status_code}"
|
||||
logger.debug(
|
||||
f"Resource exists check status: {propfind_resp.status_code}"
|
||||
)
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code == 404:
|
||||
logger.info(
|
||||
f"Resource '{webdav_path}' doesn't exist, no deletion needed."
|
||||
)
|
||||
logger.debug(f"Resource '{path}' doesn't exist, no deletion needed")
|
||||
return {"status_code": 404}
|
||||
# For other errors, continue with deletion attempt
|
||||
|
||||
# Proceed with deletion
|
||||
response = await self._client.delete(webdav_path, headers=headers)
|
||||
response.raise_for_status()
|
||||
logger.info(
|
||||
f"Successfully deleted WebDAV resource '{webdav_path}' (Status: {response.status_code})"
|
||||
)
|
||||
response = await self._make_request("DELETE", webdav_path, headers=headers)
|
||||
logger.debug(f"Successfully deleted WebDAV resource '{path}'")
|
||||
return {"status_code": response.status_code}
|
||||
|
||||
except HTTPStatusError as e:
|
||||
logger.warning(f"HTTP error deleting WebDAV resource '{webdav_path}': {e}")
|
||||
if e.response.status_code != 404:
|
||||
raise e
|
||||
else:
|
||||
logger.info(f"Resource '{webdav_path}' not found, no deletion needed.")
|
||||
if e.response.status_code == 404:
|
||||
logger.debug(f"Resource '{path}' not found, no deletion needed")
|
||||
return {"status_code": 404}
|
||||
else:
|
||||
logger.error(f"HTTP error deleting WebDAV resource '{path}': {e}")
|
||||
raise e
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Unexpected error deleting WebDAV resource '{webdav_path}': {e}"
|
||||
)
|
||||
logger.error(f"Unexpected error deleting WebDAV resource '{path}': {e}")
|
||||
raise e
|
||||
|
||||
async def cleanup_old_attachment_directory(
|
||||
@@ -73,10 +68,10 @@ class WebDAVClient(BaseNextcloudClient):
|
||||
f"Notes/{old_category_path_part}.attachments.{note_id}/"
|
||||
)
|
||||
|
||||
logger.info(f"Cleaning up old attachment directory: {old_attachment_dir_path}")
|
||||
logger.debug(f"Cleaning up old attachment directory: {old_attachment_dir_path}")
|
||||
try:
|
||||
delete_result = await self.delete_resource(path=old_attachment_dir_path)
|
||||
logger.info(f"Cleanup of old attachment directory result: {delete_result}")
|
||||
logger.debug(f"Cleanup result: {delete_result}")
|
||||
return delete_result
|
||||
except Exception as e:
|
||||
logger.error(f"Error during cleanup of old attachment directory: {e}")
|
||||
@@ -89,19 +84,15 @@ class WebDAVClient(BaseNextcloudClient):
|
||||
cat_path_part = f"{category}/" if category else ""
|
||||
attachment_dir_path = f"Notes/{cat_path_part}.attachments.{note_id}/"
|
||||
|
||||
logger.info(
|
||||
f"Attempting to delete attachment directory for note {note_id} in category '{category}' via WebDAV: {attachment_dir_path}"
|
||||
logger.debug(
|
||||
f"Cleaning up attachments for note {note_id} in category '{category}'"
|
||||
)
|
||||
try:
|
||||
delete_result = await self.delete_resource(path=attachment_dir_path)
|
||||
logger.info(
|
||||
f"WebDAV deletion for category '{category}' attachment directory: {delete_result}"
|
||||
)
|
||||
logger.debug(f"Cleanup result for note {note_id}: {delete_result}")
|
||||
return delete_result
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed during WebDAV deletion for category '{category}' attachment directory: {e}"
|
||||
)
|
||||
logger.error(f"Failed cleaning up attachments for note {note_id}: {e}")
|
||||
raise e
|
||||
|
||||
async def add_note_attachment(
|
||||
@@ -123,14 +114,7 @@ class WebDAVClient(BaseNextcloudClient):
|
||||
parent_dir_path = f"{webdav_base}/{parent_dir_webdav_rel_path}"
|
||||
attachment_path = f"{parent_dir_path}/{filename}"
|
||||
|
||||
logger.info(
|
||||
f"Uploading attachment for note {note_id} (category: '{category or ''}') to WebDAV path: {attachment_path}"
|
||||
)
|
||||
|
||||
# Log current auth settings
|
||||
logger.info(
|
||||
f"WebDAV auth settings - Username: {self.username}, Auth Type: {type(self._client.auth).__name__}"
|
||||
)
|
||||
logger.debug(f"Uploading attachment '{filename}' for note {note_id}")
|
||||
|
||||
if not mime_type:
|
||||
mime_type, _ = mimetypes.guess_type(filename)
|
||||
@@ -141,17 +125,13 @@ class WebDAVClient(BaseNextcloudClient):
|
||||
try:
|
||||
# First check if we can access WebDAV at all
|
||||
notes_dir_path = f"{webdav_base}/Notes"
|
||||
logger.info(f"Testing WebDAV access to Notes directory: {notes_dir_path}")
|
||||
|
||||
propfind_headers = {"Depth": "0", "OCS-APIRequest": "true"}
|
||||
notes_dir_response = await self._client.request(
|
||||
notes_dir_response = await self._make_request(
|
||||
"PROPFIND", notes_dir_path, headers=propfind_headers
|
||||
)
|
||||
|
||||
if notes_dir_response.status_code == 401:
|
||||
logger.error(
|
||||
"WebDAV authentication failed for Notes directory. Please verify WebDAV permissions."
|
||||
)
|
||||
logger.error("WebDAV authentication failed for Notes directory")
|
||||
raise HTTPStatusError(
|
||||
f"Authentication error accessing WebDAV Notes directory: {notes_dir_response.status_code}",
|
||||
request=notes_dir_response.request,
|
||||
@@ -162,37 +142,27 @@ class WebDAVClient(BaseNextcloudClient):
|
||||
f"Error accessing WebDAV Notes directory: {notes_dir_response.status_code}"
|
||||
)
|
||||
notes_dir_response.raise_for_status()
|
||||
else:
|
||||
logger.info(
|
||||
f"Successfully accessed WebDAV Notes directory (Status: {notes_dir_response.status_code})"
|
||||
)
|
||||
|
||||
# Ensure the parent directory exists using MKCOL
|
||||
logger.info(f"Ensuring attachments directory exists: {parent_dir_path}")
|
||||
mkcol_headers = {"OCS-APIRequest": "true"}
|
||||
mkcol_response = await self._client.request(
|
||||
mkcol_response = await self._make_request(
|
||||
"MKCOL", parent_dir_path, headers=mkcol_headers
|
||||
)
|
||||
|
||||
# MKCOL should return 201 Created or 405 Method Not Allowed (if directory already exists)
|
||||
if mkcol_response.status_code not in [201, 405]:
|
||||
logger.warning(
|
||||
logger.error(
|
||||
f"Unexpected status code {mkcol_response.status_code} when creating attachments directory"
|
||||
)
|
||||
mkcol_response.raise_for_status()
|
||||
else:
|
||||
logger.info(
|
||||
f"Created/verified directory: {parent_dir_path} (Status: {mkcol_response.status_code})"
|
||||
)
|
||||
|
||||
# Proceed with the PUT request
|
||||
logger.info(f"Putting attachment file to: {attachment_path}")
|
||||
response = await self._client.put(
|
||||
attachment_path, content=content, headers=headers
|
||||
response = await self._make_request(
|
||||
"PUT", attachment_path, content=content, headers=headers
|
||||
)
|
||||
response.raise_for_status()
|
||||
logger.info(
|
||||
f"Successfully uploaded attachment '{filename}' to note {note_id} (Status: {response.status_code})"
|
||||
logger.debug(
|
||||
f"Successfully uploaded attachment '{filename}' to note {note_id}"
|
||||
)
|
||||
return {"status_code": response.status_code}
|
||||
|
||||
@@ -216,29 +186,763 @@ class WebDAVClient(BaseNextcloudClient):
|
||||
attachment_dir_segment = f".attachments.{note_id}"
|
||||
attachment_path = f"{webdav_base}/Notes/{category_path_part}{attachment_dir_segment}/{filename}"
|
||||
|
||||
logger.info(
|
||||
f"Fetching attachment for note {note_id} (category: '{category or ''}') from WebDAV path: {attachment_path}"
|
||||
)
|
||||
logger.debug(f"Fetching attachment '{filename}' for note {note_id}")
|
||||
|
||||
try:
|
||||
response = await self._client.get(attachment_path)
|
||||
response = await self._make_request("GET", attachment_path)
|
||||
response.raise_for_status()
|
||||
|
||||
content = response.content
|
||||
mime_type = response.headers.get("content-type", "application/octet-stream")
|
||||
|
||||
logger.info(
|
||||
f"Successfully fetched attachment '{filename}' ({mime_type}, {len(content)} bytes)"
|
||||
logger.debug(
|
||||
f"Successfully fetched attachment '{filename}' ({len(content)} bytes)"
|
||||
)
|
||||
return content, mime_type
|
||||
|
||||
except HTTPStatusError as e:
|
||||
logger.error(
|
||||
f"HTTP error fetching attachment '{filename}' for note {note_id}: {e}"
|
||||
)
|
||||
if e.response.status_code == 404:
|
||||
logger.debug(f"Attachment '{filename}' not found for note {note_id}")
|
||||
else:
|
||||
logger.error(
|
||||
f"HTTP error fetching attachment '{filename}' for note {note_id}: {e}"
|
||||
)
|
||||
raise e
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Unexpected error fetching attachment '{filename}' for note {note_id}: {e}"
|
||||
)
|
||||
raise e
|
||||
|
||||
async def list_directory(self, path: str = "") -> List[Dict[str, Any]]:
|
||||
"""List files and directories in the specified path via WebDAV PROPFIND."""
|
||||
webdav_path = f"{self._get_webdav_base_path()}/{path.lstrip('/')}"
|
||||
if not webdav_path.endswith("/"):
|
||||
webdav_path += "/"
|
||||
|
||||
logger.debug(f"Listing directory: {path}")
|
||||
|
||||
propfind_body = """<?xml version="1.0"?>
|
||||
<d:propfind xmlns:d="DAV:">
|
||||
<d:prop>
|
||||
<d:displayname/>
|
||||
<d:getcontentlength/>
|
||||
<d:getcontenttype/>
|
||||
<d:getlastmodified/>
|
||||
<d:resourcetype/>
|
||||
</d:prop>
|
||||
</d:propfind>"""
|
||||
|
||||
headers = {"Depth": "1", "Content-Type": "text/xml", "OCS-APIRequest": "true"}
|
||||
|
||||
try:
|
||||
response = await self._make_request(
|
||||
"PROPFIND", webdav_path, content=propfind_body, headers=headers
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
# Parse the XML response
|
||||
root = ET.fromstring(response.content)
|
||||
items = []
|
||||
|
||||
# Skip the first response (the directory itself)
|
||||
responses = root.findall(".//{DAV:}response")[1:]
|
||||
|
||||
for response_elem in responses:
|
||||
href = response_elem.find(".//{DAV:}href")
|
||||
if href is None:
|
||||
continue
|
||||
|
||||
# Extract file/directory name from href
|
||||
href_text = href.text or ""
|
||||
name = href_text.rstrip("/").split("/")[-1]
|
||||
if not name:
|
||||
continue
|
||||
|
||||
# Get properties
|
||||
propstat = response_elem.find(".//{DAV:}propstat")
|
||||
if propstat is None:
|
||||
continue
|
||||
|
||||
prop = propstat.find(".//{DAV:}prop")
|
||||
if prop is None:
|
||||
continue
|
||||
|
||||
# Determine if it's a directory
|
||||
resourcetype = prop.find(".//{DAV:}resourcetype")
|
||||
is_directory = (
|
||||
resourcetype is not None
|
||||
and resourcetype.find(".//{DAV:}collection") is not None
|
||||
)
|
||||
|
||||
# Get other properties
|
||||
size_elem = prop.find(".//{DAV:}getcontentlength")
|
||||
size = (
|
||||
int(size_elem.text)
|
||||
if size_elem is not None and size_elem.text
|
||||
else 0
|
||||
)
|
||||
|
||||
content_type_elem = prop.find(".//{DAV:}getcontenttype")
|
||||
content_type = (
|
||||
content_type_elem.text if content_type_elem is not None else None
|
||||
)
|
||||
|
||||
modified_elem = prop.find(".//{DAV:}getlastmodified")
|
||||
modified = modified_elem.text if modified_elem is not None else None
|
||||
|
||||
items.append(
|
||||
{
|
||||
"name": name,
|
||||
"path": f"{path.rstrip('/')}/{name}" if path else name,
|
||||
"is_directory": is_directory,
|
||||
"size": size if not is_directory else None,
|
||||
"content_type": content_type,
|
||||
"last_modified": modified,
|
||||
}
|
||||
)
|
||||
|
||||
logger.debug(f"Found {len(items)} items in directory: {path}")
|
||||
return items
|
||||
|
||||
except HTTPStatusError as e:
|
||||
logger.error(f"HTTP error listing directory '{webdav_path}': {e}")
|
||||
raise e
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error listing directory '{webdav_path}': {e}")
|
||||
raise e
|
||||
|
||||
async def read_file(self, path: str) -> Tuple[bytes, str]:
|
||||
"""Read a file's content via WebDAV GET."""
|
||||
webdav_path = f"{self._get_webdav_base_path()}/{path.lstrip('/')}"
|
||||
|
||||
logger.debug(f"Reading file: {path}")
|
||||
|
||||
try:
|
||||
response = await self._make_request("GET", webdav_path)
|
||||
response.raise_for_status()
|
||||
|
||||
content = response.content
|
||||
content_type = response.headers.get(
|
||||
"content-type", "application/octet-stream"
|
||||
)
|
||||
|
||||
logger.debug(f"Successfully read file '{path}' ({len(content)} bytes)")
|
||||
return content, content_type
|
||||
|
||||
except HTTPStatusError as e:
|
||||
logger.error(f"HTTP error reading file '{path}': {e}")
|
||||
raise e
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error reading file '{path}': {e}")
|
||||
raise e
|
||||
|
||||
async def write_file(
|
||||
self, path: str, content: bytes, content_type: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Write content to a file via WebDAV PUT."""
|
||||
webdav_path = f"{self._get_webdav_base_path()}/{path.lstrip('/')}"
|
||||
|
||||
logger.debug(f"Writing file: {path}")
|
||||
|
||||
if not content_type:
|
||||
content_type, _ = mimetypes.guess_type(path)
|
||||
if not content_type:
|
||||
content_type = "application/octet-stream"
|
||||
|
||||
headers = {"Content-Type": content_type, "OCS-APIRequest": "true"}
|
||||
|
||||
try:
|
||||
response = await self._make_request(
|
||||
"PUT", webdav_path, content=content, headers=headers
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
logger.debug(f"Successfully wrote file '{path}'")
|
||||
return {"status_code": response.status_code}
|
||||
|
||||
except HTTPStatusError as e:
|
||||
logger.error(f"HTTP error writing file '{path}': {e}")
|
||||
raise e
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error writing file '{path}': {e}")
|
||||
raise e
|
||||
|
||||
async def create_directory(
|
||||
self, path: str, recursive: bool = False
|
||||
) -> Dict[str, Any]:
|
||||
"""Create a directory via WebDAV MKCOL."""
|
||||
webdav_path = f"{self._get_webdav_base_path()}/{path.lstrip('/')}"
|
||||
if not webdav_path.endswith("/"):
|
||||
webdav_path += "/"
|
||||
|
||||
logger.debug(f"Creating directory: {path}")
|
||||
|
||||
headers = {"OCS-APIRequest": "true"}
|
||||
|
||||
try:
|
||||
response = await self._make_request("MKCOL", webdav_path, headers=headers)
|
||||
response.raise_for_status()
|
||||
|
||||
logger.debug(f"Successfully created directory '{path}'")
|
||||
return {"status_code": response.status_code}
|
||||
|
||||
except HTTPStatusError as e:
|
||||
# Method Not Allowed - directory already exists
|
||||
if e.response.status_code == 405:
|
||||
logger.debug(f"Directory '{path}' already exists")
|
||||
return {"status_code": 405, "message": "Directory already exists"}
|
||||
|
||||
# File Conflict - parent directory does not exist
|
||||
if e.response.status_code == 409 and recursive:
|
||||
# Extract parent directory path
|
||||
path_parts = path.strip("/").split("/")
|
||||
if len(path_parts) > 1:
|
||||
parent_dir = "/".join(path_parts[:-1])
|
||||
logger.debug(
|
||||
f"Parent directory '{parent_dir}' doesn't exist, creating recursively"
|
||||
)
|
||||
await self.create_directory(parent_dir, recursive)
|
||||
# Now try to create the original directory again
|
||||
return await self.create_directory(path, recursive)
|
||||
else:
|
||||
# This shouldn't happen for single-level directories under root
|
||||
logger.error(f"409 conflict for single-level directory '{path}'")
|
||||
raise e
|
||||
|
||||
logger.error(f"HTTP error creating directory '{path}': {e}")
|
||||
raise e
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error creating directory '{path}': {e}")
|
||||
raise e
|
||||
|
||||
async def move_resource(
|
||||
self, source_path: str, destination_path: str, overwrite: bool = False
|
||||
) -> Dict[str, Any]:
|
||||
"""Move or rename a resource (file or directory) via WebDAV MOVE.
|
||||
|
||||
Args:
|
||||
source_path: The path of the file or directory to move
|
||||
destination_path: The new path for the file or directory
|
||||
overwrite: Whether to overwrite the destination if it exists
|
||||
|
||||
Returns:
|
||||
Dict with status_code and optional message
|
||||
"""
|
||||
source_webdav_path = f"{self._get_webdav_base_path()}/{source_path.lstrip('/')}"
|
||||
destination_webdav_path = (
|
||||
f"{self._get_webdav_base_path()}/{destination_path.lstrip('/')}"
|
||||
)
|
||||
|
||||
# Ensure paths have consistent trailing slashes for directories
|
||||
if source_path.endswith("/") and not destination_path.endswith("/"):
|
||||
destination_webdav_path += "/"
|
||||
elif not source_path.endswith("/") and destination_path.endswith("/"):
|
||||
source_webdav_path += "/"
|
||||
|
||||
logger.debug(f"Moving resource from '{source_path}' to '{destination_path}'")
|
||||
|
||||
headers = {
|
||||
"OCS-APIRequest": "true",
|
||||
"Destination": destination_webdav_path,
|
||||
"Overwrite": "T" if overwrite else "F",
|
||||
}
|
||||
|
||||
try:
|
||||
response = await self._make_request(
|
||||
"MOVE", source_webdav_path, headers=headers
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
logger.debug(
|
||||
f"Successfully moved resource from '{source_path}' to '{destination_path}'"
|
||||
)
|
||||
return {"status_code": response.status_code}
|
||||
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code == 404:
|
||||
logger.debug(f"Source resource '{source_path}' not found")
|
||||
return {"status_code": 404, "message": "Source resource not found"}
|
||||
elif e.response.status_code == 412:
|
||||
logger.debug(
|
||||
f"Destination '{destination_path}' already exists and overwrite is false"
|
||||
)
|
||||
return {
|
||||
"status_code": 412,
|
||||
"message": "Destination already exists and overwrite is false",
|
||||
}
|
||||
elif e.response.status_code == 409:
|
||||
logger.debug(
|
||||
f"Parent directory of destination '{destination_path}' doesn't exist"
|
||||
)
|
||||
return {
|
||||
"status_code": 409,
|
||||
"message": "Parent directory of destination doesn't exist",
|
||||
}
|
||||
logger.debug(
|
||||
f"Parent directory of destination '{destination_path}' doesn't exist"
|
||||
)
|
||||
return {
|
||||
"status_code": 409,
|
||||
"message": "Parent directory of destination doesn't exist",
|
||||
}
|
||||
else:
|
||||
logger.error(
|
||||
f"HTTP error moving resource from '{source_path}' to '{destination_path}': {e}"
|
||||
)
|
||||
raise e
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Unexpected error moving resource from '{source_path}' to '{destination_path}': {e}"
|
||||
)
|
||||
raise e
|
||||
|
||||
async def copy_resource(
|
||||
self, source_path: str, destination_path: str, overwrite: bool = False
|
||||
) -> Dict[str, Any]:
|
||||
"""Copy a resource (file or directory) via WebDAV COPY.
|
||||
|
||||
Args:
|
||||
source_path: The path of the file or directory to copy
|
||||
destination_path: The destination path for the copy
|
||||
overwrite: Whether to overwrite the destination if it exists
|
||||
|
||||
Returns:
|
||||
Dict with status_code and optional message
|
||||
"""
|
||||
source_webdav_path = f"{self._get_webdav_base_path()}/{source_path.lstrip('/')}"
|
||||
destination_webdav_path = (
|
||||
f"{self._get_webdav_base_path()}/{destination_path.lstrip('/')}"
|
||||
)
|
||||
|
||||
# Ensure paths have consistent trailing slashes for directories
|
||||
if source_path.endswith("/") and not destination_path.endswith("/"):
|
||||
destination_webdav_path += "/"
|
||||
elif not source_path.endswith("/") and destination_path.endswith("/"):
|
||||
source_webdav_path += "/"
|
||||
|
||||
logger.debug(f"Copying resource from '{source_path}' to '{destination_path}'")
|
||||
|
||||
headers = {
|
||||
"OCS-APIRequest": "true",
|
||||
"Destination": destination_webdav_path,
|
||||
"Overwrite": "T" if overwrite else "F",
|
||||
}
|
||||
|
||||
try:
|
||||
response = await self._make_request(
|
||||
"COPY", source_webdav_path, headers=headers
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
logger.debug(
|
||||
f"Successfully copied resource from '{source_path}' to '{destination_path}'"
|
||||
)
|
||||
return {"status_code": response.status_code}
|
||||
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code == 404:
|
||||
logger.debug(f"Source resource '{source_path}' not found")
|
||||
return {"status_code": 404, "message": "Source resource not found"}
|
||||
elif e.response.status_code == 412:
|
||||
logger.debug(
|
||||
f"Destination '{destination_path}' already exists and overwrite is false"
|
||||
)
|
||||
return {
|
||||
"status_code": 412,
|
||||
"message": "Destination already exists and overwrite is false",
|
||||
}
|
||||
elif e.response.status_code == 409:
|
||||
logger.debug(
|
||||
f"Parent directory of destination '{destination_path}' doesn't exist"
|
||||
)
|
||||
return {
|
||||
"status_code": 409,
|
||||
"message": "Parent directory of destination doesn't exist",
|
||||
}
|
||||
else:
|
||||
logger.error(
|
||||
f"HTTP error copying resource from '{source_path}' to '{destination_path}': {e}"
|
||||
)
|
||||
raise e
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Unexpected error copying resource from '{source_path}' to '{destination_path}': {e}"
|
||||
)
|
||||
raise e
|
||||
|
||||
async def search_files(
|
||||
self,
|
||||
scope: str = "",
|
||||
where_conditions: Optional[str] = None,
|
||||
properties: Optional[List[str]] = None,
|
||||
order_by: Optional[List[Tuple[str, str]]] = None,
|
||||
limit: Optional[int] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Search for files using WebDAV SEARCH method (RFC 5323).
|
||||
|
||||
Args:
|
||||
scope: Directory path to search in (empty string for user root)
|
||||
where_conditions: XML string for where clause conditions
|
||||
properties: List of property names to retrieve (defaults to basic set)
|
||||
order_by: List of (property, direction) tuples for sorting, e.g. [("getlastmodified", "descending")]
|
||||
limit: Maximum number of results to return
|
||||
|
||||
Returns:
|
||||
List of file/directory dictionaries with requested properties
|
||||
"""
|
||||
# Default properties if not specified
|
||||
if properties is None:
|
||||
properties = [
|
||||
"displayname",
|
||||
"getcontentlength",
|
||||
"getcontenttype",
|
||||
"getlastmodified",
|
||||
"resourcetype",
|
||||
"getetag",
|
||||
]
|
||||
|
||||
# Build the SEARCH request XML
|
||||
search_body = self._build_search_xml(
|
||||
scope=scope,
|
||||
where_conditions=where_conditions,
|
||||
properties=properties,
|
||||
order_by=order_by,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
# The SEARCH endpoint is at the dav root
|
||||
search_path = "/remote.php/dav/"
|
||||
|
||||
headers = {"Content-Type": "text/xml", "OCS-APIRequest": "true"}
|
||||
|
||||
logger.debug(f"Searching files in scope: {scope}")
|
||||
|
||||
try:
|
||||
response = await self._make_request(
|
||||
"SEARCH", search_path, content=search_body, headers=headers
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
# Parse the XML response
|
||||
results = self._parse_search_response(response.content, scope)
|
||||
|
||||
logger.debug(f"Search returned {len(results)} results")
|
||||
return results
|
||||
|
||||
except HTTPStatusError as e:
|
||||
logger.error(f"HTTP error during search: {e}")
|
||||
raise e
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error during search: {e}")
|
||||
raise e
|
||||
|
||||
def _build_search_xml(
|
||||
self,
|
||||
scope: str,
|
||||
where_conditions: Optional[str],
|
||||
properties: List[str],
|
||||
order_by: Optional[List[Tuple[str, str]]],
|
||||
limit: Optional[int],
|
||||
) -> str:
|
||||
"""Build the XML body for a SEARCH request."""
|
||||
# Construct the scope path
|
||||
username = self.username
|
||||
scope_path = f"/files/{username}"
|
||||
if scope:
|
||||
scope_path = f"{scope_path}/{scope.lstrip('/')}"
|
||||
|
||||
# Build property list
|
||||
prop_xml = "\n".join([self._property_to_xml(prop) for prop in properties])
|
||||
|
||||
# Build where clause
|
||||
where_xml = where_conditions if where_conditions else ""
|
||||
|
||||
# Build order by clause
|
||||
orderby_xml = ""
|
||||
if order_by:
|
||||
order_elements = []
|
||||
for prop, direction in order_by:
|
||||
prop_element = self._property_to_xml(prop)
|
||||
dir_element = (
|
||||
"<d:ascending/>"
|
||||
if direction.lower() == "ascending"
|
||||
else "<d:descending/>"
|
||||
)
|
||||
order_elements.append(f"<d:order>{prop_element}{dir_element}</d:order>")
|
||||
orderby_xml = "\n".join(order_elements)
|
||||
else:
|
||||
orderby_xml = ""
|
||||
|
||||
# Build limit clause
|
||||
limit_xml = (
|
||||
f"<d:limit><d:nresults>{limit}</d:nresults></d:limit>" if limit else ""
|
||||
)
|
||||
|
||||
# Construct the full SEARCH XML
|
||||
search_xml = f"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<d:searchrequest xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns" xmlns:nc="http://nextcloud.org/ns">
|
||||
<d:basicsearch>
|
||||
<d:select>
|
||||
<d:prop>
|
||||
{prop_xml}
|
||||
</d:prop>
|
||||
</d:select>
|
||||
<d:from>
|
||||
<d:scope>
|
||||
<d:href>{scope_path}</d:href>
|
||||
<d:depth>infinity</d:depth>
|
||||
</d:scope>
|
||||
</d:from>
|
||||
<d:where>
|
||||
{where_xml}
|
||||
</d:where>
|
||||
<d:orderby>
|
||||
{orderby_xml}
|
||||
</d:orderby>
|
||||
{limit_xml}
|
||||
</d:basicsearch>
|
||||
</d:searchrequest>"""
|
||||
|
||||
return search_xml
|
||||
|
||||
def _property_to_xml(self, prop: str) -> str:
|
||||
"""Convert a property name to its XML element."""
|
||||
# Handle properties with namespace prefixes
|
||||
if prop.startswith("{"):
|
||||
# Already a full namespace
|
||||
namespace_end = prop.index("}")
|
||||
namespace = prop[1:namespace_end]
|
||||
local_name = prop[namespace_end + 1 :]
|
||||
|
||||
# Map namespace URIs to prefixes
|
||||
ns_map = {
|
||||
"DAV:": "d",
|
||||
"http://owncloud.org/ns": "oc",
|
||||
"http://nextcloud.org/ns": "nc",
|
||||
}
|
||||
|
||||
prefix = ns_map.get(namespace, "d")
|
||||
return f"<{prefix}:{local_name}/>"
|
||||
else:
|
||||
# Guess namespace based on common properties
|
||||
if prop in [
|
||||
"displayname",
|
||||
"getcontentlength",
|
||||
"getcontenttype",
|
||||
"getlastmodified",
|
||||
"resourcetype",
|
||||
"getetag",
|
||||
"quota-available-bytes",
|
||||
"quota-used-bytes",
|
||||
]:
|
||||
return f"<d:{prop}/>"
|
||||
elif prop in [
|
||||
"fileid",
|
||||
"size",
|
||||
"permissions",
|
||||
"favorite",
|
||||
"tags",
|
||||
"owner-id",
|
||||
"owner-display-name",
|
||||
"share-types",
|
||||
"checksums",
|
||||
"comments-count",
|
||||
"comments-unread",
|
||||
]:
|
||||
return f"<oc:{prop}/>"
|
||||
else:
|
||||
# Assume nc namespace for newer properties
|
||||
return f"<nc:{prop}/>"
|
||||
|
||||
def _parse_search_response(
|
||||
self, xml_content: bytes, scope: str
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Parse the XML response from a SEARCH request."""
|
||||
root = ET.fromstring(xml_content)
|
||||
items = []
|
||||
|
||||
# Process each response element
|
||||
responses = root.findall(".//{DAV:}response")
|
||||
|
||||
for response_elem in responses:
|
||||
href = response_elem.find(".//{DAV:}href")
|
||||
if href is None:
|
||||
continue
|
||||
|
||||
# Extract file/directory path from href
|
||||
href_text = href.text or ""
|
||||
# Remove the /remote.php/dav/files/username/ prefix to get relative path
|
||||
path_parts = href_text.split("/files/")
|
||||
if len(path_parts) > 1:
|
||||
# Get the path after username
|
||||
path_after_user = "/".join(path_parts[1].split("/")[1:])
|
||||
relative_path = path_after_user.rstrip("/")
|
||||
else:
|
||||
relative_path = href_text.rstrip("/").split("/")[-1]
|
||||
|
||||
# Get properties
|
||||
propstat = response_elem.find(".//{DAV:}propstat")
|
||||
if propstat is None:
|
||||
continue
|
||||
|
||||
prop = propstat.find(".//{DAV:}prop")
|
||||
if prop is None:
|
||||
continue
|
||||
|
||||
# Build item dictionary
|
||||
item = {"path": relative_path, "href": href_text}
|
||||
|
||||
# Extract all properties
|
||||
for child in prop:
|
||||
tag = child.tag
|
||||
value = child.text
|
||||
|
||||
# Remove namespace from tag
|
||||
if "}" in tag:
|
||||
tag = tag.split("}", 1)[1]
|
||||
|
||||
# Handle special properties
|
||||
if tag == "resourcetype":
|
||||
item["is_directory"] = child.find(".//{DAV:}collection") is not None
|
||||
elif tag == "getcontentlength":
|
||||
item["size"] = int(value) if value else 0
|
||||
elif tag == "displayname":
|
||||
item["name"] = value
|
||||
elif tag == "getcontenttype":
|
||||
item["content_type"] = value
|
||||
elif tag == "getlastmodified":
|
||||
item["last_modified"] = value
|
||||
elif tag == "getetag":
|
||||
item["etag"] = value.strip('"') if value else None
|
||||
elif tag == "fileid":
|
||||
item["file_id"] = int(value) if value else None
|
||||
elif tag == "favorite":
|
||||
item["is_favorite"] = value == "1"
|
||||
elif tag == "permissions":
|
||||
item["permissions"] = value
|
||||
elif tag == "size":
|
||||
# oc:size includes folder sizes
|
||||
item["total_size"] = int(value) if value else 0
|
||||
else:
|
||||
# Store other properties as-is
|
||||
item[tag] = value
|
||||
|
||||
items.append(item)
|
||||
|
||||
return items
|
||||
|
||||
async def find_by_name(
|
||||
self, pattern: str, scope: str = "", limit: Optional[int] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Find files by name pattern using LIKE matching.
|
||||
|
||||
Args:
|
||||
pattern: Name pattern to search for (supports % wildcard)
|
||||
scope: Directory path to search in (empty string for user root)
|
||||
limit: Maximum number of results to return
|
||||
|
||||
Returns:
|
||||
List of matching files/directories
|
||||
|
||||
Examples:
|
||||
# Find all .txt files
|
||||
results = await find_by_name("%.txt")
|
||||
|
||||
# Find files starting with "report"
|
||||
results = await find_by_name("report%")
|
||||
"""
|
||||
where_conditions = f"""
|
||||
<d:like>
|
||||
<d:prop>
|
||||
<d:displayname/>
|
||||
</d:prop>
|
||||
<d:literal>{pattern}</d:literal>
|
||||
</d:like>
|
||||
"""
|
||||
|
||||
return await self.search_files(
|
||||
scope=scope, where_conditions=where_conditions, limit=limit
|
||||
)
|
||||
|
||||
async def find_by_type(
|
||||
self, mime_type: str, scope: str = "", limit: Optional[int] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Find files by MIME type.
|
||||
|
||||
Args:
|
||||
mime_type: MIME type to search for (supports % wildcard, e.g., "image/%")
|
||||
scope: Directory path to search in (empty string for user root)
|
||||
limit: Maximum number of results to return
|
||||
|
||||
Returns:
|
||||
List of matching files
|
||||
|
||||
Examples:
|
||||
# Find all images
|
||||
results = await find_by_type("image/%")
|
||||
|
||||
# Find all PDFs
|
||||
results = await find_by_type("application/pdf")
|
||||
"""
|
||||
where_conditions = f"""
|
||||
<d:like>
|
||||
<d:prop>
|
||||
<d:getcontenttype/>
|
||||
</d:prop>
|
||||
<d:literal>{mime_type}</d:literal>
|
||||
</d:like>
|
||||
"""
|
||||
|
||||
return await self.search_files(
|
||||
scope=scope, where_conditions=where_conditions, limit=limit
|
||||
)
|
||||
|
||||
async def list_favorites(
|
||||
self, scope: str = "", limit: Optional[int] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""List all favorite files.
|
||||
|
||||
Args:
|
||||
scope: Directory path to search in (empty string for user root)
|
||||
limit: Maximum number of results to return
|
||||
|
||||
Returns:
|
||||
List of favorite files/directories
|
||||
|
||||
Examples:
|
||||
# List all favorites
|
||||
results = await list_favorites()
|
||||
|
||||
# List favorites in a specific folder
|
||||
results = await list_favorites(scope="Documents")
|
||||
"""
|
||||
# Use REPORT method for favorites as it's more efficient
|
||||
# But we can also use SEARCH as fallback
|
||||
where_conditions = """
|
||||
<d:eq>
|
||||
<d:prop>
|
||||
<oc:favorite/>
|
||||
</d:prop>
|
||||
<d:literal>1</d:literal>
|
||||
</d:eq>
|
||||
"""
|
||||
|
||||
# Request favorite property
|
||||
properties = [
|
||||
"displayname",
|
||||
"getcontentlength",
|
||||
"getcontenttype",
|
||||
"getlastmodified",
|
||||
"resourcetype",
|
||||
"getetag",
|
||||
"fileid",
|
||||
"favorite",
|
||||
]
|
||||
|
||||
return await self.search_files(
|
||||
scope=scope,
|
||||
where_conditions=where_conditions,
|
||||
properties=properties,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
import logging.config
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
LOGGING_CONFIG = {
|
||||
"version": 1,
|
||||
"disable_existing_loggers": False,
|
||||
"handlers": {
|
||||
"default": {
|
||||
"class": "logging.StreamHandler",
|
||||
"formatter": "http",
|
||||
}
|
||||
},
|
||||
},
|
||||
"formatters": {
|
||||
"http": {
|
||||
"format": "%(levelname)s [%(asctime)s] %(name)s - %(message)s",
|
||||
"datefmt": "%Y-%m-%d %H:%M:%S",
|
||||
}
|
||||
},
|
||||
},
|
||||
"loggers": {
|
||||
"": {
|
||||
@@ -21,17 +24,97 @@ LOGGING_CONFIG = {
|
||||
},
|
||||
"httpx": {
|
||||
"handlers": ["default"],
|
||||
"level": "DEBUG",
|
||||
"level": "INFO",
|
||||
"propagate": False, # Prevent propagation to root logger
|
||||
},
|
||||
"httpcore": {
|
||||
"handlers": ["default"],
|
||||
"level": "DEBUG",
|
||||
"level": "INFO",
|
||||
"propagate": False, # Prevent propagation to root logger
|
||||
},
|
||||
"uvicorn": {
|
||||
"handlers": ["default"],
|
||||
"level": "INFO",
|
||||
"propagate": False,
|
||||
},
|
||||
"uvicorn.access": {
|
||||
"handlers": ["default"],
|
||||
"level": "INFO",
|
||||
"propagate": False,
|
||||
},
|
||||
"uvicorn.error": {
|
||||
"handlers": ["default"],
|
||||
"level": "INFO",
|
||||
"propagate": False,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def setup_logging():
|
||||
logging.config.dictConfig(LOGGING_CONFIG)
|
||||
|
||||
|
||||
# Document Processing Configuration
|
||||
|
||||
|
||||
def get_document_processor_config() -> dict[str, Any]:
|
||||
"""Get document processor configuration from environment.
|
||||
|
||||
Returns:
|
||||
Dict with processor configs:
|
||||
{
|
||||
"enabled": bool,
|
||||
"default_processor": str,
|
||||
"processors": {
|
||||
"unstructured": {...},
|
||||
"tesseract": {...},
|
||||
"custom": {...},
|
||||
}
|
||||
}
|
||||
"""
|
||||
config: dict[str, Any] = {
|
||||
"enabled": os.getenv("ENABLE_DOCUMENT_PROCESSING", "false").lower() == "true",
|
||||
"default_processor": os.getenv("DOCUMENT_PROCESSOR", "unstructured"),
|
||||
"processors": {},
|
||||
}
|
||||
|
||||
# Unstructured configuration
|
||||
if os.getenv("ENABLE_UNSTRUCTURED", "false").lower() == "true":
|
||||
config["processors"]["unstructured"] = {
|
||||
"api_url": os.getenv("UNSTRUCTURED_API_URL", "http://unstructured:8000"),
|
||||
"timeout": int(os.getenv("UNSTRUCTURED_TIMEOUT", "120")),
|
||||
"strategy": os.getenv("UNSTRUCTURED_STRATEGY", "auto"),
|
||||
"languages": [
|
||||
lang.strip()
|
||||
for lang in os.getenv("UNSTRUCTURED_LANGUAGES", "eng,deu").split(",")
|
||||
if lang.strip()
|
||||
],
|
||||
"progress_interval": int(os.getenv("PROGRESS_INTERVAL", "10")),
|
||||
}
|
||||
|
||||
# Tesseract configuration
|
||||
if os.getenv("ENABLE_TESSERACT", "false").lower() == "true":
|
||||
config["processors"]["tesseract"] = {
|
||||
"tesseract_cmd": os.getenv("TESSERACT_CMD"), # None = auto-detect
|
||||
"lang": os.getenv("TESSERACT_LANG", "eng"),
|
||||
}
|
||||
|
||||
# Custom processor (via HTTP API)
|
||||
if os.getenv("ENABLE_CUSTOM_PROCESSOR", "false").lower() == "true":
|
||||
custom_url = os.getenv("CUSTOM_PROCESSOR_URL")
|
||||
if custom_url:
|
||||
supported_types_str = os.getenv("CUSTOM_PROCESSOR_TYPES", "application/pdf")
|
||||
supported_types = {
|
||||
t.strip() for t in supported_types_str.split(",") if t.strip()
|
||||
}
|
||||
|
||||
config["processors"]["custom"] = {
|
||||
"name": os.getenv("CUSTOM_PROCESSOR_NAME", "custom"),
|
||||
"api_url": custom_url,
|
||||
"api_key": os.getenv("CUSTOM_PROCESSOR_API_KEY"),
|
||||
"timeout": int(os.getenv("CUSTOM_PROCESSOR_TIMEOUT", "60")),
|
||||
"supported_types": supported_types,
|
||||
}
|
||||
|
||||
return config
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Helper functions for accessing context in MCP tools."""
|
||||
|
||||
from mcp.server.fastmcp import Context
|
||||
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
|
||||
|
||||
def get_client(ctx: Context) -> NextcloudClient:
|
||||
"""
|
||||
Get the appropriate Nextcloud client based on authentication mode.
|
||||
|
||||
In BasicAuth mode, returns the shared client from lifespan context.
|
||||
In OAuth mode, creates a new client per-request using the OAuth context.
|
||||
|
||||
This function automatically detects the authentication mode by checking
|
||||
the type of the lifespan context.
|
||||
|
||||
Args:
|
||||
ctx: MCP request context
|
||||
|
||||
Returns:
|
||||
NextcloudClient configured for the current authentication mode
|
||||
|
||||
Raises:
|
||||
AttributeError: If context doesn't contain expected data
|
||||
|
||||
Example:
|
||||
```python
|
||||
@mcp.tool()
|
||||
async def my_tool(ctx: Context):
|
||||
client = get_client(ctx)
|
||||
return await client.capabilities()
|
||||
```
|
||||
"""
|
||||
lifespan_ctx = ctx.request_context.lifespan_context
|
||||
|
||||
# Try BasicAuth mode first (has 'client' attribute)
|
||||
if hasattr(lifespan_ctx, "client"):
|
||||
return lifespan_ctx.client
|
||||
|
||||
# OAuth mode (has 'nextcloud_host' attribute)
|
||||
if hasattr(lifespan_ctx, "nextcloud_host"):
|
||||
from nextcloud_mcp_server.auth import get_client_from_context
|
||||
|
||||
return get_client_from_context(ctx, lifespan_ctx.nextcloud_host)
|
||||
|
||||
# Unknown context type
|
||||
raise AttributeError(
|
||||
f"Lifespan context does not have 'client' or 'nextcloud_host' attribute. "
|
||||
f"Type: {type(lifespan_ctx)}"
|
||||
)
|
||||
@@ -1,13 +1,13 @@
|
||||
"""Controller for notes search functionality."""
|
||||
|
||||
from typing import List, Dict, Any
|
||||
from typing import Any, AsyncIterable, Dict, List
|
||||
|
||||
|
||||
class NotesSearchController:
|
||||
"""Handles notes search logic and scoring."""
|
||||
|
||||
def search_notes(
|
||||
self, notes: List[Dict[str, Any]], query: str
|
||||
async def search_notes(
|
||||
self, notes: AsyncIterable[Dict[str, Any]], query: str
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Search notes using token-based matching with relevance ranking.
|
||||
@@ -21,7 +21,7 @@ class NotesSearchController:
|
||||
return []
|
||||
|
||||
# Process and score each note
|
||||
for note in notes:
|
||||
async for note in notes:
|
||||
title_tokens, content_tokens = self._process_note_content(note)
|
||||
score = self._calculate_score(query_tokens, title_tokens, content_tokens)
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
"""Document processing plugins for extracting text from various file formats."""
|
||||
|
||||
from .base import DocumentProcessor, ProcessingResult, ProcessorError
|
||||
from .registry import ProcessorRegistry, get_registry
|
||||
|
||||
__all__ = [
|
||||
"DocumentProcessor",
|
||||
"ProcessingResult",
|
||||
"ProcessorError",
|
||||
"ProcessorRegistry",
|
||||
"get_registry",
|
||||
]
|
||||
@@ -0,0 +1,126 @@
|
||||
"""Abstract base class for document processing plugins."""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any, Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class ProcessingResult(BaseModel):
|
||||
"""Standardized result from any document processor."""
|
||||
|
||||
text: str
|
||||
"""Extracted text content"""
|
||||
|
||||
metadata: dict[str, Any]
|
||||
"""Processor-specific metadata"""
|
||||
|
||||
processor: str
|
||||
"""Name of processor that handled this (e.g., 'unstructured', 'tesseract')"""
|
||||
|
||||
success: bool = True
|
||||
"""Whether processing succeeded"""
|
||||
|
||||
error: Optional[str] = None
|
||||
"""Error message if processing failed"""
|
||||
|
||||
|
||||
class DocumentProcessor(ABC):
|
||||
"""Abstract base class for document processing plugins.
|
||||
|
||||
Document processors extract text from various file formats (PDF, DOCX, images, etc.).
|
||||
Each processor implements this interface and can be registered with the ProcessorRegistry.
|
||||
|
||||
Example:
|
||||
class MyProcessor(DocumentProcessor):
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "my_processor"
|
||||
|
||||
@property
|
||||
def supported_mime_types(self) -> set[str]:
|
||||
return {"application/pdf", "image/jpeg"}
|
||||
|
||||
async def process(self, content: bytes, content_type: str, **kwargs) -> ProcessingResult:
|
||||
# Extract text from content
|
||||
return ProcessingResult(text="...", metadata={}, processor=self.name)
|
||||
|
||||
async def health_check(self) -> bool:
|
||||
return True
|
||||
"""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def name(self) -> str:
|
||||
"""Unique identifier for this processor (e.g., 'unstructured', 'tesseract')."""
|
||||
pass
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def supported_mime_types(self) -> set[str]:
|
||||
"""Set of MIME types this processor can handle.
|
||||
|
||||
Examples: {"application/pdf", "image/jpeg", "image/png"}
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def process(
|
||||
self,
|
||||
content: bytes,
|
||||
content_type: str,
|
||||
filename: Optional[str] = None,
|
||||
options: Optional[dict[str, Any]] = None,
|
||||
progress_callback: Optional[
|
||||
Callable[[float, Optional[float], Optional[str]], Awaitable[None]]
|
||||
] = None,
|
||||
) -> ProcessingResult:
|
||||
"""Process a document and extract text.
|
||||
|
||||
Args:
|
||||
content: Document bytes
|
||||
content_type: MIME type of the document
|
||||
filename: Optional filename for format detection
|
||||
options: Processor-specific options (e.g., OCR language, strategy)
|
||||
progress_callback: Optional async callback for progress updates.
|
||||
Called as: await progress_callback(progress, total, message)
|
||||
- progress: Current progress value (monotonically increasing)
|
||||
- total: Optional total value (None if unknown)
|
||||
- message: Optional human-readable status message
|
||||
|
||||
Returns:
|
||||
ProcessingResult with extracted text and metadata
|
||||
|
||||
Raises:
|
||||
ProcessorError: If processing fails
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def health_check(self) -> bool:
|
||||
"""Check if processor is available and healthy.
|
||||
|
||||
Returns:
|
||||
True if processor is ready to use, False otherwise
|
||||
"""
|
||||
pass
|
||||
|
||||
def supports(self, content_type: str) -> bool:
|
||||
"""Check if this processor supports the given MIME type.
|
||||
|
||||
Args:
|
||||
content_type: MIME type (may include parameters like "application/pdf; charset=utf-8")
|
||||
|
||||
Returns:
|
||||
True if this processor can handle the type
|
||||
"""
|
||||
# Strip parameters from content type
|
||||
base_type = content_type.split(";")[0].strip().lower()
|
||||
return base_type in self.supported_mime_types
|
||||
|
||||
|
||||
class ProcessorError(Exception):
|
||||
"""Raised when document processing fails."""
|
||||
|
||||
pass
|
||||
@@ -0,0 +1,150 @@
|
||||
"""Generic HTTP API processor wrapper for custom document processing services."""
|
||||
|
||||
import logging
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from .base import DocumentProcessor, ProcessingResult, ProcessorError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CustomHTTPProcessor(DocumentProcessor):
|
||||
"""Generic HTTP API processor wrapper.
|
||||
|
||||
Allows integration with any custom document processing API that follows
|
||||
a simple request/response pattern. This makes it easy to integrate your
|
||||
own text extraction services without writing a full processor.
|
||||
|
||||
Expected API Contract:
|
||||
- POST request with file as multipart/form-data
|
||||
- Response: {"text": "extracted text", "metadata": {...}}
|
||||
|
||||
Example:
|
||||
processor = CustomHTTPProcessor(
|
||||
name="my_ocr",
|
||||
api_url="https://my-ocr-service.com/process",
|
||||
api_key="secret",
|
||||
supported_types={"application/pdf", "image/jpeg"},
|
||||
)
|
||||
result = await processor.process(pdf_bytes, "application/pdf")
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_url: str,
|
||||
api_key: Optional[str] = None,
|
||||
timeout: int = 60,
|
||||
supported_types: Optional[set[str]] = None,
|
||||
name: str = "custom",
|
||||
):
|
||||
"""Initialize custom HTTP processor.
|
||||
|
||||
Args:
|
||||
api_url: Your API endpoint (should accept POST with multipart/form-data)
|
||||
api_key: Optional API key for authentication (sent as Bearer token)
|
||||
timeout: Request timeout in seconds (default: 60)
|
||||
supported_types: MIME types your API supports
|
||||
name: Unique name for this processor (default: "custom")
|
||||
"""
|
||||
self.api_url = api_url
|
||||
self.api_key = api_key
|
||||
self.timeout = timeout
|
||||
self._name = name
|
||||
self._supported_types = supported_types or set()
|
||||
|
||||
logger.info(f"Initialized CustomHTTPProcessor: {name} -> {api_url}")
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def supported_mime_types(self) -> set[str]:
|
||||
return self._supported_types
|
||||
|
||||
async def process(
|
||||
self,
|
||||
content: bytes,
|
||||
content_type: str,
|
||||
filename: Optional[str] = None,
|
||||
options: Optional[dict[str, Any]] = None,
|
||||
progress_callback: Optional[
|
||||
Callable[[float, Optional[float], Optional[str]], Awaitable[None]]
|
||||
] = None,
|
||||
) -> ProcessingResult:
|
||||
"""Process via custom HTTP API.
|
||||
|
||||
Args:
|
||||
content: Document bytes
|
||||
content_type: MIME type
|
||||
filename: Optional filename
|
||||
options: Custom options (passed as form data to API)
|
||||
|
||||
Returns:
|
||||
ProcessingResult with extracted text and metadata
|
||||
|
||||
Raises:
|
||||
ProcessorError: If API call fails
|
||||
"""
|
||||
options = options or {}
|
||||
|
||||
# Prepare request
|
||||
files = {"file": (filename or "document", content, content_type)}
|
||||
headers = {}
|
||||
|
||||
if self.api_key:
|
||||
headers["Authorization"] = f"Bearer {self.api_key}"
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.post(
|
||||
self.api_url,
|
||||
files=files,
|
||||
headers=headers,
|
||||
data=options, # Pass options as form data
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
# Parse response
|
||||
result = response.json()
|
||||
text = result.get("text", "")
|
||||
metadata = result.get("metadata", {})
|
||||
|
||||
logger.debug(
|
||||
f"Custom processor '{self.name}' extracted {len(text)} characters"
|
||||
)
|
||||
|
||||
return ProcessingResult(
|
||||
text=text,
|
||||
metadata=metadata,
|
||||
processor=self.name,
|
||||
success=True,
|
||||
)
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"Custom processor '{self.name}' HTTP error: {e}")
|
||||
raise ProcessorError(f"API call failed: {str(e)}") from e
|
||||
except Exception as e:
|
||||
logger.error(f"Custom processor '{self.name}' failed: {e}")
|
||||
raise ProcessorError(f"Processing failed: {str(e)}") from e
|
||||
|
||||
async def health_check(self) -> bool:
|
||||
"""Check if custom API is available.
|
||||
|
||||
Returns:
|
||||
True if API responds with status < 500
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5) as client:
|
||||
# Try GET request to check availability
|
||||
response = await client.get(
|
||||
self.api_url,
|
||||
headers={"User-Agent": "nextcloud-mcp-server"},
|
||||
)
|
||||
return response.status_code < 500
|
||||
except Exception as e:
|
||||
logger.warning(f"Custom processor '{self.name}' health check failed: {e}")
|
||||
return False
|
||||
@@ -0,0 +1,171 @@
|
||||
"""Central registry for document processors."""
|
||||
|
||||
import logging
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any, Optional
|
||||
|
||||
from .base import DocumentProcessor, ProcessingResult, ProcessorError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ProcessorRegistry:
|
||||
"""Central registry for document processors.
|
||||
|
||||
Manages registration and routing of document processing requests to
|
||||
appropriate processors based on MIME types and priorities.
|
||||
|
||||
Example:
|
||||
registry = ProcessorRegistry()
|
||||
registry.register(UnstructuredProcessor(...), priority=10)
|
||||
registry.register(TesseractProcessor(...), priority=5)
|
||||
|
||||
# Auto-select processor based on MIME type
|
||||
result = await registry.process(pdf_bytes, "application/pdf")
|
||||
|
||||
# Force specific processor
|
||||
result = await registry.process(img_bytes, "image/png", processor_name="tesseract")
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._processors: dict[str, tuple[DocumentProcessor, int]] = {}
|
||||
self._priority_order: list[str] = []
|
||||
|
||||
def register(self, processor: DocumentProcessor, priority: int = 0):
|
||||
"""Register a document processor.
|
||||
|
||||
Args:
|
||||
processor: Processor instance to register
|
||||
priority: Higher priority processors are tried first (default: 0)
|
||||
"""
|
||||
name = processor.name
|
||||
|
||||
if name in self._processors:
|
||||
logger.warning(f"Processor '{name}' already registered, replacing")
|
||||
|
||||
self._processors[name] = (processor, priority)
|
||||
|
||||
# Update priority order
|
||||
if name in self._priority_order:
|
||||
self._priority_order.remove(name)
|
||||
|
||||
# Insert in priority order (higher priority first)
|
||||
inserted = False
|
||||
for i, existing_name in enumerate(self._priority_order):
|
||||
existing_priority = self._processors[existing_name][1]
|
||||
if priority > existing_priority:
|
||||
self._priority_order.insert(i, name)
|
||||
inserted = True
|
||||
break
|
||||
|
||||
if not inserted:
|
||||
self._priority_order.append(name)
|
||||
|
||||
logger.info(
|
||||
f"Registered processor: {name} "
|
||||
f"(priority={priority}, supports={len(processor.supported_mime_types)} types)"
|
||||
)
|
||||
|
||||
def get_processor(self, name: str) -> Optional[DocumentProcessor]:
|
||||
"""Get a processor by name.
|
||||
|
||||
Args:
|
||||
name: Processor name
|
||||
|
||||
Returns:
|
||||
DocumentProcessor instance or None if not found
|
||||
"""
|
||||
if name in self._processors:
|
||||
return self._processors[name][0]
|
||||
return None
|
||||
|
||||
def find_processor(self, content_type: str) -> Optional[DocumentProcessor]:
|
||||
"""Find the first processor that supports the given MIME type.
|
||||
|
||||
Processors are checked in priority order (highest priority first).
|
||||
|
||||
Args:
|
||||
content_type: MIME type to match
|
||||
|
||||
Returns:
|
||||
First matching processor or None
|
||||
"""
|
||||
for name in self._priority_order:
|
||||
processor = self._processors[name][0]
|
||||
if processor.supports(content_type):
|
||||
logger.debug(f"Found processor '{name}' for type '{content_type}'")
|
||||
return processor
|
||||
|
||||
logger.debug(f"No processor found for type '{content_type}'")
|
||||
return None
|
||||
|
||||
def list_processors(self) -> list[str]:
|
||||
"""List all registered processor names in priority order.
|
||||
|
||||
Returns:
|
||||
List of processor names (highest priority first)
|
||||
"""
|
||||
return list(self._priority_order)
|
||||
|
||||
async def process(
|
||||
self,
|
||||
content: bytes,
|
||||
content_type: str,
|
||||
filename: Optional[str] = None,
|
||||
processor_name: Optional[str] = None,
|
||||
options: Optional[dict[str, Any]] = None,
|
||||
progress_callback: Optional[
|
||||
Callable[[float, Optional[float], Optional[str]], Awaitable[None]]
|
||||
] = None,
|
||||
) -> ProcessingResult:
|
||||
"""Process a document using available processors.
|
||||
|
||||
Args:
|
||||
content: Document bytes
|
||||
content_type: MIME type
|
||||
filename: Optional filename for format detection
|
||||
processor_name: Force specific processor (or None for auto-select)
|
||||
options: Processing options passed to processor
|
||||
progress_callback: Optional async callback for progress updates
|
||||
|
||||
Returns:
|
||||
ProcessingResult with extracted text and metadata
|
||||
|
||||
Raises:
|
||||
ProcessorError: If no processor found or processing fails
|
||||
"""
|
||||
# Find processor
|
||||
if processor_name:
|
||||
processor = self.get_processor(processor_name)
|
||||
if not processor:
|
||||
raise ProcessorError(
|
||||
f"Processor '{processor_name}' not found. "
|
||||
f"Available: {', '.join(self.list_processors())}"
|
||||
)
|
||||
else:
|
||||
processor = self.find_processor(content_type)
|
||||
if not processor:
|
||||
raise ProcessorError(
|
||||
f"No processor found for type: {content_type}. "
|
||||
f"Registered processors: {', '.join(self.list_processors())}"
|
||||
)
|
||||
|
||||
logger.info(f"Processing with '{processor.name}' processor")
|
||||
|
||||
# Process
|
||||
return await processor.process(
|
||||
content, content_type, filename, options, progress_callback
|
||||
)
|
||||
|
||||
|
||||
# Global registry instance
|
||||
_registry = ProcessorRegistry()
|
||||
|
||||
|
||||
def get_registry() -> ProcessorRegistry:
|
||||
"""Get the global processor registry.
|
||||
|
||||
Returns:
|
||||
Singleton ProcessorRegistry instance
|
||||
"""
|
||||
return _registry
|
||||
@@ -0,0 +1,165 @@
|
||||
"""Document processor using Tesseract OCR (local)."""
|
||||
|
||||
import logging
|
||||
import shutil
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any, Optional
|
||||
|
||||
from .base import DocumentProcessor, ProcessingResult, ProcessorError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
import io
|
||||
|
||||
import pytesseract
|
||||
from PIL import Image
|
||||
|
||||
TESSERACT_AVAILABLE = True
|
||||
except ImportError:
|
||||
TESSERACT_AVAILABLE = False
|
||||
|
||||
|
||||
class TesseractProcessor(DocumentProcessor):
|
||||
"""Document processor using Tesseract OCR (local).
|
||||
|
||||
This processor runs OCR locally using the Tesseract engine, which is
|
||||
faster and more lightweight than cloud-based solutions but requires
|
||||
Tesseract to be installed on the system.
|
||||
|
||||
Requirements:
|
||||
- tesseract binary installed (e.g., apt install tesseract-ocr)
|
||||
- Python packages: pip install pytesseract pillow
|
||||
|
||||
Example:
|
||||
processor = TesseractProcessor(default_lang="eng+deu")
|
||||
result = await processor.process(image_bytes, "image/jpeg")
|
||||
"""
|
||||
|
||||
SUPPORTED_TYPES = {
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/tiff",
|
||||
"image/bmp",
|
||||
"image/gif",
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
tesseract_cmd: Optional[str] = None,
|
||||
default_lang: str = "eng",
|
||||
):
|
||||
"""Initialize Tesseract processor.
|
||||
|
||||
Args:
|
||||
tesseract_cmd: Path to tesseract executable (None = auto-detect)
|
||||
default_lang: Default OCR language (e.g., "eng", "deu", "eng+deu")
|
||||
|
||||
Raises:
|
||||
ProcessorError: If Tesseract or required packages not available
|
||||
"""
|
||||
if not TESSERACT_AVAILABLE:
|
||||
raise ProcessorError(
|
||||
"Tesseract processor requires: pip install pytesseract pillow"
|
||||
)
|
||||
|
||||
if tesseract_cmd:
|
||||
pytesseract.pytesseract.tesseract_cmd = tesseract_cmd
|
||||
elif not shutil.which("tesseract"):
|
||||
raise ProcessorError(
|
||||
"Tesseract not found in PATH. Install with: apt install tesseract-ocr"
|
||||
)
|
||||
|
||||
self.default_lang = default_lang
|
||||
logger.info(f"Initialized TesseractProcessor: lang={default_lang}")
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "tesseract"
|
||||
|
||||
@property
|
||||
def supported_mime_types(self) -> set[str]:
|
||||
return self.SUPPORTED_TYPES
|
||||
|
||||
async def process(
|
||||
self,
|
||||
content: bytes,
|
||||
content_type: str,
|
||||
filename: Optional[str] = None,
|
||||
options: Optional[dict[str, Any]] = None,
|
||||
progress_callback: Optional[
|
||||
Callable[[float, Optional[float], Optional[str]], Awaitable[None]]
|
||||
] = None,
|
||||
) -> ProcessingResult:
|
||||
"""Process image via Tesseract OCR.
|
||||
|
||||
Args:
|
||||
content: Image bytes
|
||||
content_type: Image MIME type
|
||||
filename: Optional filename
|
||||
options: Processing options:
|
||||
- lang: OCR language(s) (default: from init)
|
||||
- config: Tesseract config string
|
||||
|
||||
Returns:
|
||||
ProcessingResult with extracted text and metadata
|
||||
|
||||
Raises:
|
||||
ProcessorError: If OCR fails
|
||||
"""
|
||||
options = options or {}
|
||||
lang = options.get("lang", self.default_lang)
|
||||
config = options.get("config", "")
|
||||
|
||||
try:
|
||||
# Load image
|
||||
image = Image.open(io.BytesIO(content))
|
||||
|
||||
# Run OCR
|
||||
text = pytesseract.image_to_string(image, lang=lang, config=config)
|
||||
|
||||
# Get additional data for confidence scores
|
||||
data = pytesseract.image_to_data(
|
||||
image, lang=lang, output_type=pytesseract.Output.DICT
|
||||
)
|
||||
|
||||
# Calculate average confidence
|
||||
confidences = [c for c in data["conf"] if c != -1]
|
||||
avg_confidence = sum(confidences) / len(confidences) if confidences else 0
|
||||
|
||||
metadata = {
|
||||
"text_length": len(text),
|
||||
"language": lang,
|
||||
"image_size": image.size,
|
||||
"image_mode": image.mode,
|
||||
"confidence": round(avg_confidence, 2),
|
||||
"words_detected": len([c for c in data["conf"] if c != -1]),
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
f"Tesseract OCR completed: {len(text)} chars, "
|
||||
f"confidence={avg_confidence:.1f}%"
|
||||
)
|
||||
|
||||
return ProcessingResult(
|
||||
text=text.strip(),
|
||||
metadata=metadata,
|
||||
processor=self.name,
|
||||
success=True,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Tesseract processing failed: {e}")
|
||||
raise ProcessorError(f"OCR failed: {str(e)}") from e
|
||||
|
||||
async def health_check(self) -> bool:
|
||||
"""Check if Tesseract is available.
|
||||
|
||||
Returns:
|
||||
True if Tesseract is installed and working
|
||||
"""
|
||||
try:
|
||||
pytesseract.get_tesseract_version()
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
@@ -0,0 +1,310 @@
|
||||
"""Document processor using Unstructured.io API."""
|
||||
|
||||
import io
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any, Optional
|
||||
|
||||
import anyio
|
||||
import httpx
|
||||
|
||||
from .base import DocumentProcessor, ProcessingResult, ProcessorError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class UnstructuredProcessor(DocumentProcessor):
|
||||
"""Document processor using Unstructured.io API.
|
||||
|
||||
The Unstructured API provides document parsing capabilities for various formats
|
||||
including PDF, DOCX, images with OCR, and more.
|
||||
|
||||
API Documentation: https://docs.unstructured.io/api-reference/api-services/api-parameters
|
||||
"""
|
||||
|
||||
# Supported MIME types for Unstructured
|
||||
SUPPORTED_TYPES = {
|
||||
"application/pdf",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"application/msword",
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
"application/vnd.ms-powerpoint",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"application/vnd.ms-excel",
|
||||
"application/rtf",
|
||||
"text/rtf",
|
||||
"application/vnd.oasis.opendocument.text",
|
||||
"application/epub+zip",
|
||||
"message/rfc822",
|
||||
"application/vnd.ms-outlook",
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/tiff",
|
||||
"image/bmp",
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_url: str,
|
||||
timeout: int = 120,
|
||||
default_strategy: str = "auto",
|
||||
default_languages: Optional[list[str]] = None,
|
||||
progress_interval: int = 10,
|
||||
):
|
||||
"""Initialize Unstructured processor.
|
||||
|
||||
Args:
|
||||
api_url: Unstructured API endpoint
|
||||
timeout: Request timeout in seconds (default: 120)
|
||||
default_strategy: Default parsing strategy - "auto", "fast", or "hi_res"
|
||||
default_languages: Default OCR language codes (e.g., ["eng", "deu"])
|
||||
progress_interval: Seconds between progress updates (default: 10)
|
||||
"""
|
||||
self.api_url = api_url
|
||||
self.timeout = timeout
|
||||
self.default_strategy = default_strategy
|
||||
self.default_languages = default_languages or ["eng"]
|
||||
self.progress_interval = progress_interval
|
||||
|
||||
logger.info(
|
||||
f"Initialized UnstructuredProcessor: {api_url}, "
|
||||
f"strategy={default_strategy}, languages={self.default_languages}, "
|
||||
f"progress_interval={progress_interval}s"
|
||||
)
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "unstructured"
|
||||
|
||||
@property
|
||||
def supported_mime_types(self) -> set[str]:
|
||||
return self.SUPPORTED_TYPES
|
||||
|
||||
async def _run_progress_poller(
|
||||
self,
|
||||
stop_event: anyio.Event,
|
||||
progress_callback: Callable[
|
||||
[float, Optional[float], Optional[str]], Awaitable[None]
|
||||
],
|
||||
start_time: float,
|
||||
):
|
||||
"""Run progress poller that reports status every N seconds.
|
||||
|
||||
Args:
|
||||
stop_event: Event to signal when processing is complete
|
||||
progress_callback: Async callback to report progress
|
||||
start_time: Time when processing started (from time.time())
|
||||
"""
|
||||
logger.debug("Starting progress poller")
|
||||
while not stop_event.is_set():
|
||||
try:
|
||||
# Wait for the event to be set, with a timeout equal to progress_interval
|
||||
with anyio.fail_after(self.progress_interval):
|
||||
await stop_event.wait()
|
||||
# If wait() finished, the event was set (processing complete)
|
||||
break
|
||||
except TimeoutError:
|
||||
# Timeout occurred - time to send a progress update
|
||||
if not stop_event.is_set(): # Double-check in case of race condition
|
||||
elapsed = int(time.time() - start_time)
|
||||
message = (
|
||||
f"Processing document with unstructured... ({elapsed}s elapsed)"
|
||||
)
|
||||
try:
|
||||
await progress_callback(
|
||||
progress=float(elapsed),
|
||||
total=None, # Unknown total duration
|
||||
message=message,
|
||||
)
|
||||
logger.debug(f"Progress update sent: {elapsed}s elapsed")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to send progress update: {e}")
|
||||
logger.debug("Progress poller stopped")
|
||||
|
||||
async def _make_api_request(
|
||||
self,
|
||||
content: bytes,
|
||||
content_type: str,
|
||||
filename: Optional[str],
|
||||
strategy: str,
|
||||
languages: list[str],
|
||||
extract_image_block_types: Optional[list[str]],
|
||||
) -> ProcessingResult:
|
||||
"""Make the actual API request to Unstructured.
|
||||
|
||||
Args:
|
||||
content: Document bytes
|
||||
content_type: MIME type
|
||||
filename: Optional filename
|
||||
strategy: Processing strategy
|
||||
languages: OCR languages
|
||||
extract_image_block_types: Image element types to extract
|
||||
|
||||
Returns:
|
||||
ProcessingResult with extracted text and metadata
|
||||
|
||||
Raises:
|
||||
ProcessorError: If processing fails
|
||||
"""
|
||||
# Prepare multipart request
|
||||
files = {
|
||||
"files": (
|
||||
filename or "document",
|
||||
io.BytesIO(content),
|
||||
content_type or "application/octet-stream",
|
||||
)
|
||||
}
|
||||
|
||||
data = {
|
||||
"strategy": strategy,
|
||||
"languages": ",".join(languages),
|
||||
}
|
||||
|
||||
if extract_image_block_types:
|
||||
data["extract_image_block_types"] = ",".join(extract_image_block_types)
|
||||
|
||||
logger.debug(
|
||||
f"Processing with Unstructured API: strategy={strategy}, languages={languages}"
|
||||
)
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.post(
|
||||
f"{self.api_url}/general/v0/general",
|
||||
files=files,
|
||||
data=data,
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
# Parse response
|
||||
elements = response.json()
|
||||
|
||||
# Extract text and metadata
|
||||
texts = []
|
||||
element_types: dict[str, int] = {}
|
||||
|
||||
for element in elements:
|
||||
if "text" in element and element["text"]:
|
||||
texts.append(element["text"])
|
||||
|
||||
el_type = element.get("type", "unknown")
|
||||
element_types[el_type] = element_types.get(el_type, 0) + 1
|
||||
|
||||
parsed_text = "\n\n".join(texts)
|
||||
|
||||
metadata = {
|
||||
"element_count": len(elements),
|
||||
"text_length": len(parsed_text),
|
||||
"element_types": element_types,
|
||||
"strategy": strategy,
|
||||
"languages": languages,
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
f"Successfully processed: {len(elements)} elements, "
|
||||
f"{len(parsed_text)} characters"
|
||||
)
|
||||
|
||||
return ProcessingResult(
|
||||
text=parsed_text,
|
||||
metadata=metadata,
|
||||
processor=self.name,
|
||||
success=True,
|
||||
)
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"Unstructured API HTTP error: {e}")
|
||||
raise ProcessorError(f"HTTP error: {str(e)}") from e
|
||||
except Exception as e:
|
||||
logger.error(f"Unstructured API processing failed: {e}")
|
||||
raise ProcessorError(f"Processing failed: {str(e)}") from e
|
||||
|
||||
async def process(
|
||||
self,
|
||||
content: bytes,
|
||||
content_type: str,
|
||||
filename: Optional[str] = None,
|
||||
options: Optional[dict[str, Any]] = None,
|
||||
progress_callback: Optional[
|
||||
Callable[[float, Optional[float], Optional[str]], Awaitable[None]]
|
||||
] = None,
|
||||
) -> ProcessingResult:
|
||||
"""Process document via Unstructured API.
|
||||
|
||||
Args:
|
||||
content: Document bytes
|
||||
content_type: MIME type
|
||||
filename: Optional filename for format detection
|
||||
options: Processing options:
|
||||
- strategy: "auto", "fast", or "hi_res" (default: from init)
|
||||
- languages: List of language codes (default: from init)
|
||||
- extract_image_block_types: Types of image elements to extract
|
||||
progress_callback: Optional async callback for progress updates
|
||||
|
||||
Returns:
|
||||
ProcessingResult with extracted text and metadata
|
||||
|
||||
Raises:
|
||||
ProcessorError: If processing fails
|
||||
"""
|
||||
options = options or {}
|
||||
|
||||
# Extract options with defaults
|
||||
strategy = options.get("strategy", self.default_strategy)
|
||||
languages = options.get("languages", self.default_languages)
|
||||
extract_image_block_types = options.get("extract_image_block_types")
|
||||
|
||||
# If no progress callback, just make the request directly
|
||||
if progress_callback is None:
|
||||
return await self._make_api_request(
|
||||
content=content,
|
||||
content_type=content_type,
|
||||
filename=filename,
|
||||
strategy=strategy,
|
||||
languages=languages,
|
||||
extract_image_block_types=extract_image_block_types,
|
||||
)
|
||||
|
||||
# With progress callback: run API request + progress poller concurrently
|
||||
stop_event = anyio.Event()
|
||||
start_time = time.time()
|
||||
result = None
|
||||
|
||||
async def capture_result():
|
||||
nonlocal result
|
||||
try:
|
||||
result = await self._make_api_request(
|
||||
content=content,
|
||||
content_type=content_type,
|
||||
filename=filename,
|
||||
strategy=strategy,
|
||||
languages=languages,
|
||||
extract_image_block_types=extract_image_block_types,
|
||||
)
|
||||
finally:
|
||||
# Signal poller to stop after API request completes
|
||||
stop_event.set()
|
||||
|
||||
# Run both tasks concurrently using anyio task groups
|
||||
async with anyio.create_task_group() as tg:
|
||||
tg.start_soon(capture_result)
|
||||
tg.start_soon(
|
||||
self._run_progress_poller, stop_event, progress_callback, start_time
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
async def health_check(self) -> bool:
|
||||
"""Check if Unstructured API is available.
|
||||
|
||||
Returns:
|
||||
True if API is healthy, False otherwise
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5) as client:
|
||||
response = await client.get(f"{self.api_url}/healthcheck")
|
||||
return response.status_code == 200
|
||||
except Exception as e:
|
||||
logger.warning(f"Unstructured health check failed: {e}")
|
||||
return False
|
||||
@@ -0,0 +1,142 @@
|
||||
"""Pydantic models for structured MCP server responses."""
|
||||
|
||||
# Base models
|
||||
from .base import BaseResponse, IdResponse, StatusResponse
|
||||
|
||||
# Calendar models
|
||||
from .calendar import (
|
||||
AvailabilitySlot,
|
||||
BulkOperationResponse,
|
||||
BulkOperationResult,
|
||||
Calendar,
|
||||
CalendarEvent,
|
||||
CalendarEventSummary,
|
||||
CreateEventResponse,
|
||||
CreateMeetingResponse,
|
||||
DeleteEventResponse,
|
||||
FindAvailabilityResponse,
|
||||
ListCalendarsResponse,
|
||||
ListEventsResponse,
|
||||
ManageCalendarResponse,
|
||||
UpcomingEventsResponse,
|
||||
UpdateEventResponse,
|
||||
)
|
||||
|
||||
# Contacts models
|
||||
from .contacts import (
|
||||
AddressBook,
|
||||
Contact,
|
||||
ContactField,
|
||||
CreateAddressBookResponse,
|
||||
CreateContactResponse,
|
||||
DeleteAddressBookResponse,
|
||||
DeleteContactResponse,
|
||||
ListAddressBooksResponse,
|
||||
ListContactsResponse,
|
||||
UpdateContactResponse,
|
||||
)
|
||||
|
||||
# Notes models
|
||||
from .notes import (
|
||||
AppendContentResponse,
|
||||
CreateNoteResponse,
|
||||
DeleteNoteResponse,
|
||||
Note,
|
||||
NoteSearchResult,
|
||||
NotesSettings,
|
||||
SearchNotesResponse,
|
||||
UpdateNoteResponse,
|
||||
)
|
||||
|
||||
# Tables models
|
||||
from .tables import (
|
||||
CreateRowResponse,
|
||||
DeleteRowResponse,
|
||||
GetSchemaResponse,
|
||||
ListTablesResponse,
|
||||
ReadTableResponse,
|
||||
Table,
|
||||
TableColumn,
|
||||
TableRow,
|
||||
TableSchema,
|
||||
TableView,
|
||||
UpdateRowResponse,
|
||||
)
|
||||
|
||||
# WebDAV models
|
||||
from .webdav import (
|
||||
CopyResourceResponse,
|
||||
CreateDirectoryResponse,
|
||||
DeleteResourceResponse,
|
||||
DirectoryListing,
|
||||
FileInfo,
|
||||
MoveResourceResponse,
|
||||
ReadFileResponse,
|
||||
SearchFilesResponse,
|
||||
WriteFileResponse,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Base models
|
||||
"BaseResponse",
|
||||
"IdResponse",
|
||||
"StatusResponse",
|
||||
# Notes models
|
||||
"Note",
|
||||
"NoteSearchResult",
|
||||
"NotesSettings",
|
||||
"CreateNoteResponse",
|
||||
"UpdateNoteResponse",
|
||||
"DeleteNoteResponse",
|
||||
"AppendContentResponse",
|
||||
"SearchNotesResponse",
|
||||
# Calendar models
|
||||
"Calendar",
|
||||
"CalendarEvent",
|
||||
"CalendarEventSummary",
|
||||
"CreateEventResponse",
|
||||
"UpdateEventResponse",
|
||||
"DeleteEventResponse",
|
||||
"ListEventsResponse",
|
||||
"ListCalendarsResponse",
|
||||
"AvailabilitySlot",
|
||||
"FindAvailabilityResponse",
|
||||
"BulkOperationResult",
|
||||
"BulkOperationResponse",
|
||||
"CreateMeetingResponse",
|
||||
"UpcomingEventsResponse",
|
||||
"ManageCalendarResponse",
|
||||
# Contacts models
|
||||
"AddressBook",
|
||||
"Contact",
|
||||
"ContactField",
|
||||
"ListAddressBooksResponse",
|
||||
"ListContactsResponse",
|
||||
"CreateContactResponse",
|
||||
"UpdateContactResponse",
|
||||
"DeleteContactResponse",
|
||||
"CreateAddressBookResponse",
|
||||
"DeleteAddressBookResponse",
|
||||
# Tables models
|
||||
"Table",
|
||||
"TableColumn",
|
||||
"TableRow",
|
||||
"TableView",
|
||||
"TableSchema",
|
||||
"ListTablesResponse",
|
||||
"GetSchemaResponse",
|
||||
"ReadTableResponse",
|
||||
"CreateRowResponse",
|
||||
"UpdateRowResponse",
|
||||
"DeleteRowResponse",
|
||||
# WebDAV models
|
||||
"FileInfo",
|
||||
"DirectoryListing",
|
||||
"ReadFileResponse",
|
||||
"WriteFileResponse",
|
||||
"CreateDirectoryResponse",
|
||||
"DeleteResourceResponse",
|
||||
"MoveResourceResponse",
|
||||
"CopyResourceResponse",
|
||||
"SearchFilesResponse",
|
||||
]
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Base Pydantic models for common response patterns."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional, Union
|
||||
|
||||
from pydantic import BaseModel, Field, field_serializer
|
||||
|
||||
|
||||
def _utc_now() -> datetime:
|
||||
"""Generate UTC timestamp for responses."""
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class BaseResponse(BaseModel):
|
||||
"""Base response model for all MCP tool responses."""
|
||||
|
||||
success: bool = Field(
|
||||
default=True, description="Whether the operation was successful"
|
||||
)
|
||||
timestamp: datetime = Field(
|
||||
default_factory=_utc_now, description="Response timestamp"
|
||||
)
|
||||
|
||||
@field_serializer("timestamp")
|
||||
def serialize_timestamp(self, timestamp: datetime) -> str:
|
||||
"""Serialize timestamp to RFC3339 format for MCP compliance."""
|
||||
if timestamp.tzinfo is None:
|
||||
# If somehow we get a naive datetime, assume UTC
|
||||
timestamp = timestamp.replace(tzinfo=timezone.utc)
|
||||
# Use isoformat() which produces RFC3339 compliant format
|
||||
# For UTC times, replace '+00:00' with 'Z' as preferred by many systems
|
||||
iso_string = timestamp.isoformat()
|
||||
if iso_string.endswith("+00:00"):
|
||||
return iso_string[:-6] + "Z"
|
||||
return iso_string
|
||||
|
||||
|
||||
class IdResponse(BaseResponse):
|
||||
"""Response model for operations that return a new ID."""
|
||||
|
||||
id: Union[int, str] = Field(description="ID of the created or affected resource")
|
||||
|
||||
|
||||
class StatusResponse(BaseResponse):
|
||||
"""Response model for operations that return just a status."""
|
||||
|
||||
status_code: Optional[int] = Field(None, description="HTTP status code")
|
||||
message: Optional[str] = Field(None, description="Status message")
|
||||
@@ -0,0 +1,250 @@
|
||||
"""Pydantic models for Calendar app responses."""
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from .base import BaseResponse, StatusResponse
|
||||
|
||||
|
||||
class Calendar(BaseModel):
|
||||
"""Model for a Nextcloud calendar."""
|
||||
|
||||
name: str = Field(description="Calendar name/ID")
|
||||
display_name: str = Field(description="Calendar display name")
|
||||
description: Optional[str] = Field(None, description="Calendar description")
|
||||
color: Optional[str] = Field(None, description="Calendar color")
|
||||
href: Optional[str] = Field(None, description="Calendar DAV href")
|
||||
timezone: Optional[str] = Field(None, description="Calendar timezone")
|
||||
enabled: bool = Field(default=True, description="Whether calendar is enabled")
|
||||
ctag: Optional[str] = Field(None, description="Calendar tag for synchronization")
|
||||
|
||||
|
||||
class CalendarEventSummary(BaseModel):
|
||||
"""Model for calendar event summary (for lists)."""
|
||||
|
||||
uid: str = Field(description="Event UID")
|
||||
summary: str = Field(description="Event summary/title")
|
||||
start: str = Field(description="Event start datetime (ISO format)")
|
||||
end: Optional[str] = Field(None, description="Event end datetime (ISO format)")
|
||||
all_day: bool = Field(default=False, description="Whether event is all-day")
|
||||
location: Optional[str] = Field(None, description="Event location")
|
||||
description: Optional[str] = Field(None, description="Event description")
|
||||
categories: List[str] = Field(default_factory=list, description="Event categories")
|
||||
status: Optional[str] = Field(
|
||||
None, description="Event status (CONFIRMED, TENTATIVE, CANCELLED)"
|
||||
)
|
||||
|
||||
|
||||
class CalendarEvent(CalendarEventSummary):
|
||||
"""Model for a complete calendar event."""
|
||||
|
||||
created: Optional[str] = Field(None, description="Event creation datetime")
|
||||
last_modified: Optional[str] = Field(None, description="Last modification datetime")
|
||||
recurring: bool = Field(default=False, description="Whether event is recurring")
|
||||
recurrence_rule: Optional[str] = Field(None, description="RFC5545 recurrence rule")
|
||||
recurrence_end: Optional[str] = Field(None, description="Recurrence end date")
|
||||
attendees: List[str] = Field(
|
||||
default_factory=list, description="List of attendee email addresses"
|
||||
)
|
||||
organizer: Optional[str] = Field(None, description="Event organizer")
|
||||
priority: Optional[int] = Field(None, description="Event priority (1-9)")
|
||||
privacy: Optional[str] = Field(None, description="Event privacy level")
|
||||
url: Optional[str] = Field(None, description="Event URL")
|
||||
duration_minutes: Optional[int] = Field(
|
||||
None, description="Event duration in minutes"
|
||||
)
|
||||
reminder_minutes: Optional[int] = Field(
|
||||
None, description="Reminder time in minutes before event"
|
||||
)
|
||||
reminder_email: bool = Field(
|
||||
default=False, description="Whether to send email reminder"
|
||||
)
|
||||
color: Optional[str] = Field(None, description="Event color")
|
||||
etag: Optional[str] = Field(None, description="ETag for versioning")
|
||||
|
||||
|
||||
class CreateEventResponse(BaseResponse):
|
||||
"""Response model for event creation."""
|
||||
|
||||
event: CalendarEvent = Field(description="The created event")
|
||||
calendar_name: str = Field(
|
||||
description="Name of the calendar the event was created in"
|
||||
)
|
||||
|
||||
|
||||
class UpdateEventResponse(BaseResponse):
|
||||
"""Response model for event updates."""
|
||||
|
||||
event: CalendarEvent = Field(description="The updated event")
|
||||
calendar_name: str = Field(description="Name of the calendar the event belongs to")
|
||||
|
||||
|
||||
class DeleteEventResponse(StatusResponse):
|
||||
"""Response model for event deletion."""
|
||||
|
||||
deleted_uid: str = Field(description="UID of the deleted event")
|
||||
calendar_name: str = Field(
|
||||
description="Name of the calendar the event was deleted from"
|
||||
)
|
||||
|
||||
|
||||
class ListEventsResponse(BaseResponse):
|
||||
"""Response model for listing events."""
|
||||
|
||||
events: List[CalendarEventSummary] = Field(description="List of events")
|
||||
calendar_name: Optional[str] = Field(
|
||||
None, description="Calendar name (if filtered to one calendar)"
|
||||
)
|
||||
start_date: Optional[str] = Field(None, description="Start date filter applied")
|
||||
end_date: Optional[str] = Field(None, description="End date filter applied")
|
||||
total_found: int = Field(description="Total number of events found")
|
||||
|
||||
|
||||
class ListCalendarsResponse(BaseResponse):
|
||||
"""Response model for listing calendars."""
|
||||
|
||||
calendars: List[Calendar] = Field(description="List of available calendars")
|
||||
total_count: int = Field(description="Total number of calendars")
|
||||
|
||||
|
||||
class AvailabilitySlot(BaseModel):
|
||||
"""Model for an available time slot."""
|
||||
|
||||
start: str = Field(description="Slot start datetime (ISO format)")
|
||||
end: str = Field(description="Slot end datetime (ISO format)")
|
||||
duration_minutes: int = Field(description="Slot duration in minutes")
|
||||
date: str = Field(description="Date of the slot (YYYY-MM-DD)")
|
||||
|
||||
|
||||
class FindAvailabilityResponse(BaseResponse):
|
||||
"""Response model for finding availability."""
|
||||
|
||||
available_slots: List[AvailabilitySlot] = Field(
|
||||
description="List of available time slots"
|
||||
)
|
||||
duration_requested: int = Field(description="Requested duration in minutes")
|
||||
date_range_start: str = Field(description="Start date of search range")
|
||||
date_range_end: str = Field(description="End date of search range")
|
||||
attendees_checked: List[str] = Field(
|
||||
default_factory=list, description="Attendees checked for availability"
|
||||
)
|
||||
business_hours_only: bool = Field(
|
||||
description="Whether search was limited to business hours"
|
||||
)
|
||||
|
||||
|
||||
class BulkOperationResult(BaseModel):
|
||||
"""Model for bulk operation results."""
|
||||
|
||||
operation: str = Field(description="Operation performed (update, delete, move)")
|
||||
events_processed: int = Field(description="Number of events processed")
|
||||
events_successful: int = Field(
|
||||
description="Number of events successfully processed"
|
||||
)
|
||||
events_failed: int = Field(description="Number of events that failed processing")
|
||||
failed_events: List[str] = Field(
|
||||
default_factory=list, description="UIDs of events that failed"
|
||||
)
|
||||
errors: List[str] = Field(default_factory=list, description="Error messages")
|
||||
|
||||
|
||||
class BulkOperationResponse(BaseResponse):
|
||||
"""Response model for bulk operations."""
|
||||
|
||||
result: BulkOperationResult = Field(description="Bulk operation result")
|
||||
|
||||
|
||||
class CreateMeetingResponse(CreateEventResponse):
|
||||
"""Response model for meeting creation (same as event creation)."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class UpcomingEventsResponse(BaseResponse):
|
||||
"""Response model for upcoming events."""
|
||||
|
||||
events: List[CalendarEventSummary] = Field(description="List of upcoming events")
|
||||
days_ahead: int = Field(description="Number of days ahead searched")
|
||||
calendar_name: Optional[str] = Field(
|
||||
None, description="Calendar name (if filtered to one calendar)"
|
||||
)
|
||||
|
||||
|
||||
class ManageCalendarResponse(BaseResponse):
|
||||
"""Response model for calendar management operations."""
|
||||
|
||||
action: str = Field(description="Action performed (create, delete, update, list)")
|
||||
calendar: Optional[Calendar] = Field(None, description="Calendar that was affected")
|
||||
calendars: Optional[List[Calendar]] = Field(
|
||||
None, description="List of calendars (for list action)"
|
||||
)
|
||||
message: str = Field(description="Success message")
|
||||
|
||||
|
||||
# ============= Todo/Task Models =============
|
||||
|
||||
|
||||
class Todo(BaseModel):
|
||||
"""Model for a CalDAV todo/task (VTODO)."""
|
||||
|
||||
uid: str = Field(description="Todo UID")
|
||||
summary: str = Field(description="Todo summary/title")
|
||||
description: str = Field(default="", description="Todo description")
|
||||
status: str = Field(
|
||||
default="NEEDS-ACTION",
|
||||
description="Todo status: NEEDS-ACTION, IN-PROCESS, COMPLETED, CANCELLED",
|
||||
)
|
||||
priority: int = Field(
|
||||
default=0, description="Todo priority (0=undefined, 1=highest, 9=lowest)"
|
||||
)
|
||||
percent_complete: int = Field(default=0, description="Percentage complete (0-100)")
|
||||
due: Optional[str] = Field(None, description="Due date/time (ISO format)")
|
||||
dtstart: Optional[str] = Field(None, description="Start date/time (ISO format)")
|
||||
completed: Optional[str] = Field(
|
||||
None, description="Completion timestamp (ISO format)"
|
||||
)
|
||||
categories: str = Field(default="", description="Comma-separated categories")
|
||||
href: str = Field(default="", description="CalDAV href")
|
||||
etag: str = Field(default="", description="ETag for versioning")
|
||||
calendar_name: Optional[str] = Field(
|
||||
None, description="Calendar containing this todo"
|
||||
)
|
||||
calendar_display_name: Optional[str] = Field(
|
||||
None, description="Display name of calendar containing this todo"
|
||||
)
|
||||
|
||||
|
||||
class ListTodosResponse(BaseResponse):
|
||||
"""Response model for listing todos."""
|
||||
|
||||
todos: List[Todo] = Field(description="List of todos/tasks")
|
||||
calendar_name: Optional[str] = Field(
|
||||
None, description="Calendar name (if filtered to one calendar)"
|
||||
)
|
||||
total_count: int = Field(description="Total number of todos found")
|
||||
|
||||
|
||||
class CreateTodoResponse(BaseResponse):
|
||||
"""Response model for todo creation."""
|
||||
|
||||
todo: Todo = Field(description="The created todo")
|
||||
calendar_name: str = Field(
|
||||
description="Name of the calendar the todo was created in"
|
||||
)
|
||||
|
||||
|
||||
class UpdateTodoResponse(BaseResponse):
|
||||
"""Response model for todo updates."""
|
||||
|
||||
todo: Todo = Field(description="The updated todo")
|
||||
calendar_name: str = Field(description="Name of the calendar the todo belongs to")
|
||||
|
||||
|
||||
class DeleteTodoResponse(StatusResponse):
|
||||
"""Response model for todo deletion."""
|
||||
|
||||
deleted_uid: str = Field(description="UID of the deleted todo")
|
||||
calendar_name: str = Field(
|
||||
description="Name of the calendar the todo was deleted from"
|
||||
)
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Pydantic models for Contacts app responses."""
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from .base import BaseResponse, StatusResponse
|
||||
|
||||
|
||||
class AddressBook(BaseModel):
|
||||
"""Model for a Nextcloud address book."""
|
||||
|
||||
uri: str = Field(description="Address book URI")
|
||||
displayname: str = Field(description="Address book display name")
|
||||
description: Optional[str] = Field(None, description="Address book description")
|
||||
ctag: Optional[str] = Field(
|
||||
None, description="Address book tag for synchronization"
|
||||
)
|
||||
|
||||
|
||||
class ContactField(BaseModel):
|
||||
"""Model for a contact field (email, phone, etc.)."""
|
||||
|
||||
type: str = Field(description="Field type (e.g., 'email', 'phone', 'address')")
|
||||
value: str = Field(description="Field value")
|
||||
label: Optional[str] = Field(None, description="Field label (e.g., 'work', 'home')")
|
||||
preferred: bool = Field(
|
||||
default=False, description="Whether this is the preferred field of this type"
|
||||
)
|
||||
|
||||
|
||||
class Contact(BaseModel):
|
||||
"""Model for a Nextcloud contact."""
|
||||
|
||||
uid: str = Field(description="Contact UID")
|
||||
fn: str = Field(description="Full name (formatted name)")
|
||||
given_name: Optional[str] = Field(None, description="Given name")
|
||||
family_name: Optional[str] = Field(None, description="Family name")
|
||||
organization: Optional[str] = Field(None, description="Organization")
|
||||
title: Optional[str] = Field(None, description="Job title")
|
||||
emails: List[ContactField] = Field(
|
||||
default_factory=list, description="Email addresses"
|
||||
)
|
||||
phones: List[ContactField] = Field(
|
||||
default_factory=list, description="Phone numbers"
|
||||
)
|
||||
addresses: List[ContactField] = Field(default_factory=list, description="Addresses")
|
||||
urls: List[ContactField] = Field(default_factory=list, description="URLs")
|
||||
note: Optional[str] = Field(None, description="Notes")
|
||||
photo: Optional[str] = Field(None, description="Photo URL or base64 data")
|
||||
birthday: Optional[str] = Field(None, description="Birthday (ISO date format)")
|
||||
categories: List[str] = Field(
|
||||
default_factory=list, description="Contact categories"
|
||||
)
|
||||
custom_fields: Dict[str, Any] = Field(
|
||||
default_factory=dict, description="Custom fields"
|
||||
)
|
||||
etag: Optional[str] = Field(None, description="ETag for versioning")
|
||||
|
||||
@property
|
||||
def primary_email(self) -> Optional[str]:
|
||||
"""Get the primary email address."""
|
||||
if not self.emails:
|
||||
return None
|
||||
# Return preferred email if available, otherwise first email
|
||||
preferred = next(
|
||||
(email.value for email in self.emails if email.preferred), None
|
||||
)
|
||||
return preferred or self.emails[0].value
|
||||
|
||||
@property
|
||||
def primary_phone(self) -> Optional[str]:
|
||||
"""Get the primary phone number."""
|
||||
if not self.phones:
|
||||
return None
|
||||
# Return preferred phone if available, otherwise first phone
|
||||
preferred = next(
|
||||
(phone.value for phone in self.phones if phone.preferred), None
|
||||
)
|
||||
return preferred or self.phones[0].value
|
||||
|
||||
|
||||
class ListAddressBooksResponse(BaseResponse):
|
||||
"""Response model for listing address books."""
|
||||
|
||||
addressbooks: List[AddressBook] = Field(
|
||||
description="List of available address books"
|
||||
)
|
||||
total_count: int = Field(description="Total number of address books")
|
||||
|
||||
|
||||
class ListContactsResponse(BaseResponse):
|
||||
"""Response model for listing contacts."""
|
||||
|
||||
contacts: List[Contact] = Field(description="List of contacts")
|
||||
addressbook: str = Field(description="Address book name")
|
||||
total_count: int = Field(description="Total number of contacts")
|
||||
|
||||
|
||||
class CreateContactResponse(BaseResponse):
|
||||
"""Response model for contact creation."""
|
||||
|
||||
contact: Contact = Field(description="The created contact")
|
||||
addressbook: str = Field(description="Address book the contact was created in")
|
||||
|
||||
|
||||
class UpdateContactResponse(BaseResponse):
|
||||
"""Response model for contact updates."""
|
||||
|
||||
contact: Contact = Field(description="The updated contact")
|
||||
addressbook: str = Field(description="Address book the contact belongs to")
|
||||
|
||||
|
||||
class DeleteContactResponse(StatusResponse):
|
||||
"""Response model for contact deletion."""
|
||||
|
||||
deleted_uid: str = Field(description="UID of the deleted contact")
|
||||
addressbook: str = Field(description="Address book the contact was deleted from")
|
||||
|
||||
|
||||
class CreateAddressBookResponse(BaseResponse):
|
||||
"""Response model for address book creation."""
|
||||
|
||||
addressbook: AddressBook = Field(description="The created address book")
|
||||
|
||||
|
||||
class DeleteAddressBookResponse(StatusResponse):
|
||||
"""Response model for address book deletion."""
|
||||
|
||||
deleted_name: str = Field(description="Name of the deleted address book")
|
||||
@@ -0,0 +1,216 @@
|
||||
"""Pydantic models for Cookbook app responses."""
|
||||
|
||||
from typing import List, Optional, Union
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from .base import BaseResponse, IdResponse, StatusResponse
|
||||
|
||||
|
||||
class Nutrition(BaseModel):
|
||||
"""Nutrition information following schema.org/NutritionInformation."""
|
||||
|
||||
type: str = Field(
|
||||
default="NutritionInformation",
|
||||
alias="@type",
|
||||
description="Schema.org object type",
|
||||
)
|
||||
calories: Optional[str] = Field(None, description="Calories (e.g., '650 kcal')")
|
||||
carbohydrateContent: Optional[str] = Field(
|
||||
None, description="Carbohydrates (e.g., '300 g')"
|
||||
)
|
||||
cholesterolContent: Optional[str] = Field(
|
||||
None, description="Cholesterol (e.g., '10 g')"
|
||||
)
|
||||
fatContent: Optional[str] = Field(None, description="Fat (e.g., '45 g')")
|
||||
fiberContent: Optional[str] = Field(None, description="Fiber (e.g., '50 g')")
|
||||
proteinContent: Optional[str] = Field(None, description="Protein (e.g., '80 g')")
|
||||
saturatedFatContent: Optional[str] = Field(
|
||||
None, description="Saturated fat (e.g., '5 g')"
|
||||
)
|
||||
servingSize: Optional[str] = Field(
|
||||
None, description="Serving size description (e.g., 'One plate')"
|
||||
)
|
||||
sodiumContent: Optional[str] = Field(None, description="Sodium (e.g., '10 mg')")
|
||||
sugarContent: Optional[str] = Field(None, description="Sugar (e.g., '5 g')")
|
||||
transFatContent: Optional[str] = Field(None, description="Trans fat (e.g., '10 g')")
|
||||
unsaturatedFatContent: Optional[str] = Field(
|
||||
None, description="Unsaturated fat (e.g., '40 g')"
|
||||
)
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
|
||||
class RecipeStub(BaseModel):
|
||||
"""Stub of a recipe with basic information."""
|
||||
|
||||
id: str = Field(description="Recipe ID as string")
|
||||
recipe_id: int = Field(description="Recipe ID as integer (deprecated)")
|
||||
name: str = Field(description="Recipe name")
|
||||
keywords: Optional[str] = Field(default="", description="Comma-separated keywords")
|
||||
dateCreated: str = Field(description="Creation date (ISO8601)")
|
||||
dateModified: Optional[str] = Field(
|
||||
None, description="Last modified date (ISO8601)"
|
||||
)
|
||||
imageUrl: str = Field(default="", description="URL of the recipe image")
|
||||
imagePlaceholderUrl: str = Field(default="", description="URL of placeholder image")
|
||||
|
||||
|
||||
class Recipe(BaseModel):
|
||||
"""Full recipe following schema.org/Recipe specification."""
|
||||
|
||||
type: str = Field(default="Recipe", alias="@type", description="Schema.org type")
|
||||
id: Optional[str] = Field(None, description="Recipe ID")
|
||||
name: str = Field(description="Recipe name")
|
||||
description: str = Field(default="", description="Recipe description")
|
||||
url: str = Field(default="", description="Original recipe URL")
|
||||
image: str = Field(default="", description="URL of original recipe image")
|
||||
imageUrl: Optional[str] = Field(
|
||||
None, description="URL of the recipe image in Nextcloud"
|
||||
)
|
||||
imagePlaceholderUrl: Optional[str] = Field(
|
||||
None, description="URL of placeholder image"
|
||||
)
|
||||
keywords: str = Field(default="", description="Comma-separated keywords")
|
||||
dateCreated: Optional[str] = Field(None, description="Creation date (ISO8601)")
|
||||
dateModified: Optional[str] = Field(
|
||||
None, description="Last modified date (ISO8601)"
|
||||
)
|
||||
prepTime: Optional[str] = Field(None, description="Preparation time (ISO8601)")
|
||||
cookTime: Optional[str] = Field(None, description="Cooking time (ISO8601)")
|
||||
totalTime: Optional[str] = Field(None, description="Total time (ISO8601)")
|
||||
recipeYield: Union[int, str] = Field(default=1, description="Number of servings")
|
||||
recipeCategory: str = Field(default="", description="Recipe category")
|
||||
tool: List[str] = Field(default_factory=list, description="Required tools")
|
||||
recipeIngredient: List[str] = Field(
|
||||
default_factory=list, description="List of ingredients"
|
||||
)
|
||||
recipeInstructions: List[str] = Field(
|
||||
default_factory=list, description="Cooking instructions"
|
||||
)
|
||||
nutrition: Optional[Nutrition] = Field(None, description="Nutrition information")
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True, extra="allow")
|
||||
|
||||
|
||||
class Category(BaseModel):
|
||||
"""A recipe category."""
|
||||
|
||||
name: str = Field(description="Category name")
|
||||
recipe_count: int = Field(description="Number of recipes in category")
|
||||
|
||||
|
||||
class Keyword(BaseModel):
|
||||
"""A recipe keyword/tag."""
|
||||
|
||||
name: str = Field(description="Keyword name")
|
||||
recipe_count: int = Field(description="Number of recipes with this keyword")
|
||||
|
||||
|
||||
class VisibleInfoBlocks(BaseModel):
|
||||
"""Configuration for visible information blocks in the UI."""
|
||||
|
||||
preparation_time: Optional[bool] = Field(
|
||||
None, alias="preparation-time", description="Show preparation time"
|
||||
)
|
||||
cooking_time: Optional[bool] = Field(
|
||||
None, alias="cooking-time", description="Show cooking time"
|
||||
)
|
||||
total_time: Optional[bool] = Field(
|
||||
None, alias="total-time", description="Show total time"
|
||||
)
|
||||
nutrition_information: Optional[bool] = Field(
|
||||
None, alias="nutrition-information", description="Show nutrition info"
|
||||
)
|
||||
tools: Optional[bool] = Field(None, description="Show tools list")
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
|
||||
class CookbookConfig(BaseModel):
|
||||
"""Cookbook app configuration."""
|
||||
|
||||
folder: Optional[str] = Field(None, description="Recipe folder path")
|
||||
update_interval: Optional[int] = Field(
|
||||
None, description="Auto-rescan interval in minutes"
|
||||
)
|
||||
print_image: Optional[bool] = Field(None, description="Print images with recipes")
|
||||
visibleInfoBlocks: Optional[VisibleInfoBlocks] = Field(
|
||||
None, description="Visible info blocks configuration"
|
||||
)
|
||||
|
||||
|
||||
class APIVersion(BaseModel):
|
||||
"""API version information."""
|
||||
|
||||
epoch: int = Field(description="API epoch")
|
||||
major: int = Field(description="Major version")
|
||||
minor: int = Field(description="Minor version")
|
||||
|
||||
|
||||
class Version(BaseModel):
|
||||
"""Version information for Cookbook app and API."""
|
||||
|
||||
cookbook_version: List[int] = Field(description="Cookbook app version")
|
||||
api_version: APIVersion = Field(description="API version")
|
||||
|
||||
|
||||
# Response models for MCP tools
|
||||
|
||||
|
||||
class ImportRecipeResponse(BaseResponse):
|
||||
"""Response model for recipe import."""
|
||||
|
||||
recipe: Recipe = Field(description="The imported recipe")
|
||||
recipe_id: str = Field(description="ID of the imported recipe")
|
||||
|
||||
|
||||
class CreateRecipeResponse(IdResponse):
|
||||
"""Response model for recipe creation."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class UpdateRecipeResponse(IdResponse):
|
||||
"""Response model for recipe update."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class DeleteRecipeResponse(StatusResponse):
|
||||
"""Response model for recipe deletion."""
|
||||
|
||||
deleted_id: int = Field(description="ID of deleted recipe")
|
||||
|
||||
|
||||
class ListRecipesResponse(BaseResponse):
|
||||
"""Response model for listing recipes."""
|
||||
|
||||
recipes: List[RecipeStub] = Field(description="List of recipe stubs")
|
||||
total_count: int = Field(description="Total number of recipes")
|
||||
|
||||
|
||||
class SearchRecipesResponse(BaseResponse):
|
||||
"""Response model for recipe search."""
|
||||
|
||||
recipes: List[RecipeStub] = Field(description="Matching recipes")
|
||||
query: str = Field(description="Search query used")
|
||||
total_found: int = Field(description="Number of recipes found")
|
||||
|
||||
|
||||
class ListCategoriesResponse(BaseResponse):
|
||||
"""Response model for listing categories."""
|
||||
|
||||
categories: List[Category] = Field(description="List of categories")
|
||||
|
||||
|
||||
class ListKeywordsResponse(BaseResponse):
|
||||
"""Response model for listing keywords."""
|
||||
|
||||
keywords: List[Keyword] = Field(description="List of keywords")
|
||||
|
||||
|
||||
class ReindexResponse(StatusResponse):
|
||||
"""Response model for reindex operation."""
|
||||
|
||||
pass
|
||||
@@ -0,0 +1,268 @@
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
from .base import BaseResponse, StatusResponse
|
||||
|
||||
|
||||
class DeckUser(BaseModel):
|
||||
primaryKey: str
|
||||
uid: str
|
||||
displayname: str
|
||||
|
||||
|
||||
class DeckPermissions(BaseModel):
|
||||
PERMISSION_READ: bool
|
||||
PERMISSION_EDIT: bool
|
||||
PERMISSION_MANAGE: bool
|
||||
PERMISSION_SHARE: bool
|
||||
|
||||
|
||||
class DeckLabel(BaseModel):
|
||||
id: int
|
||||
title: str
|
||||
color: str
|
||||
boardId: Optional[int] = None
|
||||
cardId: Optional[int] = None
|
||||
|
||||
|
||||
class DeckACL(BaseModel):
|
||||
id: int
|
||||
participant: DeckUser
|
||||
type: int
|
||||
boardId: int
|
||||
permissionEdit: bool
|
||||
permissionShare: bool
|
||||
permissionManage: bool
|
||||
owner: bool
|
||||
|
||||
|
||||
class DeckBoardSettings(BaseModel):
|
||||
calendar: bool
|
||||
cardDetailsInModal: Optional[bool] = Field(default=None, alias="cardDetailsInModal")
|
||||
cardIdBadge: Optional[bool] = Field(default=None, alias="cardIdBadge")
|
||||
groupLimit: Optional[List[Dict[str, str]]] = Field(default=None, alias="groupLimit")
|
||||
notify_due: Optional[str] = Field(default=None, alias="notify-due")
|
||||
|
||||
|
||||
class DeckBoard(BaseModel):
|
||||
id: int
|
||||
title: str
|
||||
owner: DeckUser
|
||||
color: str
|
||||
archived: bool
|
||||
labels: List[DeckLabel]
|
||||
acl: List[DeckACL]
|
||||
permissions: DeckPermissions
|
||||
users: List[DeckUser]
|
||||
deletedAt: int
|
||||
lastModified: Optional[int] = None
|
||||
settings: Optional[DeckBoardSettings] = None
|
||||
etag: Optional[str] = Field(default=None, alias="ETag")
|
||||
|
||||
@field_validator("settings", mode="before")
|
||||
@classmethod
|
||||
def validate_settings(cls, v):
|
||||
# Handle case where API returns empty array instead of dict/null
|
||||
if isinstance(v, list) and len(v) == 0:
|
||||
return None
|
||||
return v
|
||||
|
||||
|
||||
class DeckAssignedUser(BaseModel):
|
||||
id: int
|
||||
participant: DeckUser
|
||||
cardId: int
|
||||
type: int
|
||||
|
||||
|
||||
class DeckCard(BaseModel):
|
||||
id: int
|
||||
title: str
|
||||
stackId: int
|
||||
type: str
|
||||
order: int
|
||||
archived: bool
|
||||
owner: Union[str, DeckUser] # Can be either string or user object
|
||||
description: Optional[str] = None
|
||||
duedate: Optional[datetime] = None
|
||||
done: Optional[datetime] = None
|
||||
lastModified: Optional[int] = None
|
||||
createdAt: Optional[int] = None
|
||||
labels: Optional[List[DeckLabel]] = None
|
||||
assignedUsers: Optional[List[Union[DeckUser, DeckAssignedUser]]] = None
|
||||
attachments: Optional[List[Any]] = None # Define a proper Attachment model later
|
||||
attachmentCount: Optional[int] = None
|
||||
deletedAt: Optional[int] = None
|
||||
commentsUnread: Optional[int] = None
|
||||
overdue: Optional[int] = None
|
||||
etag: Optional[str] = Field(default=None, alias="ETag")
|
||||
|
||||
@field_validator("owner", mode="before")
|
||||
@classmethod
|
||||
def validate_owner(cls, v):
|
||||
# Handle case where API returns user object instead of string
|
||||
if isinstance(v, dict):
|
||||
return v.get("uid", v.get("primaryKey", str(v)))
|
||||
return v
|
||||
|
||||
@field_validator("assignedUsers", mode="before")
|
||||
@classmethod
|
||||
def validate_assigned_users(cls, v):
|
||||
# Handle different formats of assigned users from the API
|
||||
if not v:
|
||||
return v
|
||||
|
||||
validated_users = []
|
||||
for user in v:
|
||||
if isinstance(user, dict):
|
||||
# Check if it's an assignment object with participant
|
||||
if "participant" in user:
|
||||
validated_users.append(user)
|
||||
# Check if it's a direct user object
|
||||
elif "uid" in user or "primaryKey" in user:
|
||||
validated_users.append(user)
|
||||
else:
|
||||
validated_users.append(user)
|
||||
|
||||
return validated_users
|
||||
|
||||
|
||||
class DeckStack(BaseModel):
|
||||
id: int
|
||||
title: str
|
||||
boardId: int
|
||||
order: int
|
||||
deletedAt: int
|
||||
lastModified: Optional[int] = None
|
||||
cards: Optional[List[DeckCard]] = None
|
||||
etag: Optional[str] = Field(default=None, alias="ETag")
|
||||
|
||||
|
||||
class DeckAttachmentExtendedData(BaseModel):
|
||||
filesize: int
|
||||
mimetype: str
|
||||
info: Dict[str, str]
|
||||
|
||||
|
||||
class DeckAttachment(BaseModel):
|
||||
id: int
|
||||
cardId: int
|
||||
type: str
|
||||
data: str
|
||||
lastModified: int
|
||||
createdAt: int
|
||||
createdBy: str
|
||||
deletedAt: int
|
||||
extendedData: DeckAttachmentExtendedData
|
||||
|
||||
|
||||
class DeckComment(BaseModel):
|
||||
id: int
|
||||
objectId: int
|
||||
message: str
|
||||
actorId: str
|
||||
actorType: str
|
||||
actorDisplayName: str
|
||||
creationDateTime: datetime
|
||||
mentions: List[Dict[str, str]]
|
||||
replyTo: Optional[Any] = None # Self-referencing, handle later if needed
|
||||
|
||||
|
||||
class DeckSession(BaseModel):
|
||||
token: str
|
||||
|
||||
|
||||
class DeckConfig(BaseModel):
|
||||
calendar: bool
|
||||
cardDetailsInModal: bool
|
||||
cardIdBadge: bool
|
||||
groupLimit: Optional[List[Dict[str, str]]] = None
|
||||
|
||||
|
||||
# Response Models for MCP Tools
|
||||
|
||||
|
||||
class ListBoardsResponse(BaseResponse):
|
||||
"""Response model for listing deck boards."""
|
||||
|
||||
boards: List[DeckBoard] = Field(description="List of deck boards")
|
||||
total: int = Field(description="Total number of boards")
|
||||
|
||||
|
||||
class CreateBoardResponse(BaseResponse):
|
||||
"""Response model for board creation."""
|
||||
|
||||
id: int = Field(description="The created board ID")
|
||||
title: str = Field(description="The created board title")
|
||||
color: str = Field(description="The created board color")
|
||||
|
||||
|
||||
class BoardOperationResponse(StatusResponse):
|
||||
"""Response model for board operations like update/delete."""
|
||||
|
||||
board_id: int = Field(description="ID of the affected board")
|
||||
|
||||
|
||||
# Stack Response Models
|
||||
|
||||
|
||||
class ListStacksResponse(BaseResponse):
|
||||
"""Response model for listing deck stacks."""
|
||||
|
||||
stacks: List[DeckStack] = Field(description="List of deck stacks")
|
||||
total: int = Field(description="Total number of stacks")
|
||||
|
||||
|
||||
class CreateStackResponse(BaseResponse):
|
||||
"""Response model for stack creation."""
|
||||
|
||||
id: int = Field(description="The created stack ID")
|
||||
title: str = Field(description="The created stack title")
|
||||
order: int = Field(description="The created stack order")
|
||||
|
||||
|
||||
class StackOperationResponse(StatusResponse):
|
||||
"""Response model for stack operations like update/delete."""
|
||||
|
||||
stack_id: int = Field(description="ID of the affected stack")
|
||||
board_id: int = Field(description="ID of the board containing the stack")
|
||||
|
||||
|
||||
# Card Response Models
|
||||
|
||||
|
||||
class CreateCardResponse(BaseResponse):
|
||||
"""Response model for card creation."""
|
||||
|
||||
id: int = Field(description="The created card ID")
|
||||
title: str = Field(description="The created card title")
|
||||
description: Optional[str] = Field(description="The created card description")
|
||||
stackId: int = Field(description="The stack ID the card belongs to")
|
||||
|
||||
|
||||
class CardOperationResponse(StatusResponse):
|
||||
"""Response model for card operations like update/delete."""
|
||||
|
||||
card_id: int = Field(description="ID of the affected card")
|
||||
stack_id: int = Field(description="ID of the stack containing the card")
|
||||
board_id: int = Field(description="ID of the board containing the card")
|
||||
|
||||
|
||||
# Label Response Models
|
||||
|
||||
|
||||
class CreateLabelResponse(BaseResponse):
|
||||
"""Response model for label creation."""
|
||||
|
||||
id: int = Field(description="The created label ID")
|
||||
title: str = Field(description="The created label title")
|
||||
color: str = Field(description="The created label color")
|
||||
|
||||
|
||||
class LabelOperationResponse(StatusResponse):
|
||||
"""Response model for label operations like update/delete."""
|
||||
|
||||
label_id: int = Field(description="ID of the affected label")
|
||||
board_id: int = Field(description="ID of the board containing the label")
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Pydantic models for Notes app responses."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from .base import BaseResponse, IdResponse, StatusResponse
|
||||
|
||||
|
||||
class Note(BaseModel):
|
||||
"""Model for a Nextcloud note."""
|
||||
|
||||
id: int = Field(description="Note ID")
|
||||
title: str = Field(description="Note title")
|
||||
content: str = Field(description="Note content in markdown")
|
||||
category: str = Field(default="", description="Note category")
|
||||
modified: int = Field(description="Unix timestamp of last modification")
|
||||
favorite: bool = Field(
|
||||
default=False, description="Whether note is marked as favorite"
|
||||
)
|
||||
etag: str = Field(description="ETag for versioning")
|
||||
readonly: bool = Field(default=False, description="Whether note is read-only")
|
||||
|
||||
@property
|
||||
def modified_datetime(self) -> datetime:
|
||||
"""Convert Unix timestamp to datetime."""
|
||||
return datetime.fromtimestamp(self.modified)
|
||||
|
||||
|
||||
class NoteSearchResult(BaseModel):
|
||||
"""Model for note search results (limited fields)."""
|
||||
|
||||
id: int = Field(description="Note ID")
|
||||
title: str = Field(description="Note title")
|
||||
category: str = Field(default="", description="Note category")
|
||||
score: Optional[float] = Field(None, description="Search relevance score")
|
||||
|
||||
|
||||
class NotesSettings(BaseModel):
|
||||
"""Model for Notes app settings."""
|
||||
|
||||
notesPath: str = Field(description="Path to notes directory")
|
||||
fileSuffix: str = Field(description="File suffix for notes")
|
||||
noteMode: str = Field(description="Note mode setting")
|
||||
|
||||
|
||||
class CreateNoteResponse(IdResponse):
|
||||
"""Response model for note creation."""
|
||||
|
||||
title: str = Field(description="The created note title")
|
||||
category: str = Field(description="The created note category")
|
||||
etag: str = Field(description="Current ETag for the created note")
|
||||
|
||||
|
||||
class UpdateNoteResponse(BaseResponse):
|
||||
"""Response model for note updates."""
|
||||
|
||||
id: int = Field(description="The updated note ID")
|
||||
title: str = Field(description="The updated note title")
|
||||
category: str = Field(description="The updated note category")
|
||||
etag: str = Field(description="Current ETag for the updated note")
|
||||
|
||||
|
||||
class DeleteNoteResponse(StatusResponse):
|
||||
"""Response model for note deletion."""
|
||||
|
||||
deleted_id: int = Field(description="ID of the deleted note")
|
||||
|
||||
|
||||
class AppendContentResponse(BaseResponse):
|
||||
"""Response model for appending content to a note."""
|
||||
|
||||
id: int = Field(description="The updated note ID")
|
||||
title: str = Field(description="The updated note title")
|
||||
category: str = Field(description="The updated note category")
|
||||
etag: str = Field(description="Current ETag for the updated note")
|
||||
|
||||
|
||||
class SearchNotesResponse(BaseResponse):
|
||||
"""Response model for note search."""
|
||||
|
||||
results: List[NoteSearchResult] = Field(description="Search results")
|
||||
query: str = Field(description="The search query used")
|
||||
total_found: int = Field(description="Total number of notes found")
|
||||
@@ -0,0 +1,142 @@
|
||||
"""Pydantic models for Tables app responses."""
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from .base import BaseResponse, IdResponse, StatusResponse
|
||||
|
||||
|
||||
class TableColumn(BaseModel):
|
||||
"""Model for a table column definition."""
|
||||
|
||||
id: int = Field(description="Column ID")
|
||||
title: str = Field(description="Column title")
|
||||
type: str = Field(description="Column type (text, number, datetime, etc.)")
|
||||
subtype: Optional[str] = Field(None, description="Column subtype")
|
||||
mandatory: bool = Field(default=False, description="Whether column is mandatory")
|
||||
description: Optional[str] = Field(None, description="Column description")
|
||||
text_default: Optional[str] = Field(None, description="Default text value")
|
||||
text_allowed_pattern: Optional[str] = Field(
|
||||
None, description="Allowed text pattern"
|
||||
)
|
||||
text_max_length: Optional[int] = Field(None, description="Maximum text length")
|
||||
number_default: Optional[float] = Field(None, description="Default number value")
|
||||
number_min: Optional[float] = Field(None, description="Minimum number value")
|
||||
number_max: Optional[float] = Field(None, description="Maximum number value")
|
||||
number_decimals: Optional[int] = Field(None, description="Number of decimal places")
|
||||
datetime_default: Optional[str] = Field(None, description="Default datetime value")
|
||||
selection_options: List[str] = Field(
|
||||
default_factory=list, description="Selection options"
|
||||
)
|
||||
selection_default: Optional[str] = Field(
|
||||
None, description="Default selection value"
|
||||
)
|
||||
|
||||
|
||||
class TableRow(BaseModel):
|
||||
"""Model for a table row."""
|
||||
|
||||
id: int = Field(description="Row ID")
|
||||
created_by: Optional[str] = Field(None, description="User who created the row")
|
||||
created_at: Optional[str] = Field(None, description="Row creation timestamp")
|
||||
last_edit_by: Optional[str] = Field(
|
||||
None, description="User who last edited the row"
|
||||
)
|
||||
last_edit_at: Optional[str] = Field(None, description="Last edit timestamp")
|
||||
data: Dict[int, Any] = Field(description="Row data keyed by column ID")
|
||||
|
||||
|
||||
class TableView(BaseModel):
|
||||
"""Model for a table view."""
|
||||
|
||||
id: int = Field(description="View ID")
|
||||
title: str = Field(description="View title")
|
||||
emoji: Optional[str] = Field(None, description="View emoji")
|
||||
description: Optional[str] = Field(None, description="View description")
|
||||
columns: List[int] = Field(
|
||||
default_factory=list, description="List of column IDs in this view"
|
||||
)
|
||||
sort: List[Dict[str, Any]] = Field(
|
||||
default_factory=list, description="Sort configuration"
|
||||
)
|
||||
filter: List[Dict[str, Any]] = Field(
|
||||
default_factory=list, description="Filter configuration"
|
||||
)
|
||||
|
||||
|
||||
class Table(BaseModel):
|
||||
"""Model for a Nextcloud table."""
|
||||
|
||||
id: int = Field(description="Table ID")
|
||||
title: str = Field(description="Table title")
|
||||
emoji: Optional[str] = Field(None, description="Table emoji")
|
||||
ownership: str = Field(description="Table ownership")
|
||||
owner_display_name: str = Field(description="Display name of table owner")
|
||||
created_by: Optional[str] = Field(None, description="User who created the table")
|
||||
created_at: Optional[str] = Field(None, description="Table creation timestamp")
|
||||
last_edit_by: Optional[str] = Field(
|
||||
None, description="User who last edited the table"
|
||||
)
|
||||
last_edit_at: Optional[str] = Field(None, description="Last edit timestamp")
|
||||
row_count: int = Field(default=0, description="Number of rows in the table")
|
||||
has_shares: bool = Field(default=False, description="Whether table is shared")
|
||||
archived: bool = Field(default=False, description="Whether table is archived")
|
||||
is_shared: bool = Field(
|
||||
default=False, description="Whether table is shared with current user"
|
||||
)
|
||||
on_share_permissions: Optional[Dict[str, Any]] = Field(
|
||||
None, description="Share permissions"
|
||||
)
|
||||
|
||||
|
||||
class TableSchema(BaseModel):
|
||||
"""Model for complete table schema including columns and views."""
|
||||
|
||||
table: Table = Field(description="Table information")
|
||||
columns: List[TableColumn] = Field(description="Table columns")
|
||||
views: List[TableView] = Field(description="Table views")
|
||||
|
||||
|
||||
class ListTablesResponse(BaseResponse):
|
||||
"""Response model for listing tables."""
|
||||
|
||||
tables: List[Table] = Field(description="List of available tables")
|
||||
total_count: int = Field(description="Total number of tables")
|
||||
|
||||
|
||||
class GetSchemaResponse(BaseResponse):
|
||||
"""Response model for getting table schema."""
|
||||
|
||||
table_schema: TableSchema = Field(description="Table schema information")
|
||||
|
||||
|
||||
class ReadTableResponse(BaseResponse):
|
||||
"""Response model for reading table rows."""
|
||||
|
||||
rows: List[TableRow] = Field(description="Table rows")
|
||||
table_id: int = Field(description="Table ID")
|
||||
total_count: Optional[int] = Field(
|
||||
None, description="Total number of rows (if known)"
|
||||
)
|
||||
offset: Optional[int] = Field(None, description="Offset used for pagination")
|
||||
limit: Optional[int] = Field(None, description="Limit used for pagination")
|
||||
|
||||
|
||||
class CreateRowResponse(IdResponse):
|
||||
"""Response model for row creation."""
|
||||
|
||||
row: TableRow = Field(description="The created row")
|
||||
table_id: int = Field(description="Table ID the row was created in")
|
||||
|
||||
|
||||
class UpdateRowResponse(BaseResponse):
|
||||
"""Response model for row updates."""
|
||||
|
||||
row: TableRow = Field(description="The updated row")
|
||||
|
||||
|
||||
class DeleteRowResponse(StatusResponse):
|
||||
"""Response model for row deletion."""
|
||||
|
||||
deleted_id: int = Field(description="ID of the deleted row")
|
||||
@@ -0,0 +1,41 @@
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class User(BaseModel):
|
||||
"""Model for creating a new user."""
|
||||
|
||||
userid: str
|
||||
password: Optional[str] = None
|
||||
displayName: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
groups: Optional[List[str]] = Field(default_factory=list)
|
||||
subadmin: Optional[List[str]] = Field(default_factory=list)
|
||||
quota: Optional[str] = None
|
||||
language: Optional[str] = None
|
||||
|
||||
|
||||
class UserDetails(BaseModel):
|
||||
"""Model for retrieving detailed user information."""
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
enabled: bool
|
||||
id: str
|
||||
quota: Union[str, Dict[str, Any]] # Can be string or quota object
|
||||
email: Optional[str] = None # Can be null
|
||||
displayname: str = Field(
|
||||
alias="display-name"
|
||||
) # Handle both displayname and display-name
|
||||
phone: Optional[str] = None
|
||||
address: Optional[str] = None
|
||||
website: Optional[str] = None
|
||||
twitter: Optional[str] = None
|
||||
groups: Optional[List[str]] = Field(default_factory=list)
|
||||
|
||||
|
||||
class Group(BaseModel):
|
||||
"""Model for a user group."""
|
||||
|
||||
id: str
|
||||
@@ -0,0 +1,121 @@
|
||||
"""Pydantic models for WebDAV responses."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from .base import BaseResponse, StatusResponse
|
||||
|
||||
|
||||
class FileInfo(BaseModel):
|
||||
"""Model for file/directory information."""
|
||||
|
||||
name: str = Field(description="File/directory name")
|
||||
path: str = Field(description="Full path")
|
||||
is_directory: bool = Field(description="Whether this is a directory")
|
||||
size: Optional[int] = Field(
|
||||
None, description="File size in bytes (None for directories)"
|
||||
)
|
||||
content_type: Optional[str] = Field(None, description="MIME content type")
|
||||
last_modified: Optional[str] = Field(
|
||||
None, description="Last modification time (ISO format)"
|
||||
)
|
||||
etag: Optional[str] = Field(None, description="ETag for versioning")
|
||||
file_id: Optional[int] = Field(None, description="Nextcloud file ID")
|
||||
is_favorite: Optional[bool] = Field(None, description="Whether file is favorited")
|
||||
|
||||
@property
|
||||
def last_modified_datetime(self) -> Optional[datetime]:
|
||||
"""Convert last modified string to datetime."""
|
||||
if not self.last_modified:
|
||||
return None
|
||||
try:
|
||||
return datetime.fromisoformat(self.last_modified.replace("Z", "+00:00"))
|
||||
except (ValueError, AttributeError):
|
||||
return None
|
||||
|
||||
|
||||
class DirectoryListing(BaseResponse):
|
||||
"""Response model for directory listings."""
|
||||
|
||||
path: str = Field(description="Directory path")
|
||||
files: List[FileInfo] = Field(description="Files and directories in the path")
|
||||
total_count: int = Field(description="Total number of items")
|
||||
directories_count: int = Field(description="Number of directories")
|
||||
files_count: int = Field(description="Number of files")
|
||||
total_size: int = Field(default=0, description="Total size of all files in bytes")
|
||||
|
||||
|
||||
class ReadFileResponse(BaseResponse):
|
||||
"""Response model for reading file contents."""
|
||||
|
||||
path: str = Field(description="File path")
|
||||
content: str = Field(description="File content (text or base64 for binary)")
|
||||
content_type: str = Field(description="MIME content type")
|
||||
size: int = Field(description="File size in bytes")
|
||||
encoding: Optional[str] = Field(
|
||||
None, description="Encoding used (e.g., 'base64' for binary files)"
|
||||
)
|
||||
etag: Optional[str] = Field(None, description="ETag for versioning")
|
||||
last_modified: Optional[str] = Field(None, description="Last modification time")
|
||||
|
||||
|
||||
class WriteFileResponse(StatusResponse):
|
||||
"""Response model for writing files."""
|
||||
|
||||
path: str = Field(description="File path that was written")
|
||||
size: Optional[int] = Field(None, description="Size of the written file")
|
||||
created: bool = Field(description="Whether a new file was created (vs overwritten)")
|
||||
|
||||
|
||||
class CreateDirectoryResponse(StatusResponse):
|
||||
"""Response model for directory creation."""
|
||||
|
||||
path: str = Field(description="Directory path that was created")
|
||||
created: bool = Field(
|
||||
description="Whether directory was created or already existed"
|
||||
)
|
||||
|
||||
|
||||
class DeleteResourceResponse(StatusResponse):
|
||||
"""Response model for resource deletion."""
|
||||
|
||||
path: str = Field(description="Path that was deleted")
|
||||
was_directory: bool = Field(
|
||||
description="Whether the deleted resource was a directory"
|
||||
)
|
||||
items_deleted: Optional[int] = Field(
|
||||
None, description="Number of items deleted (for directories)"
|
||||
)
|
||||
|
||||
|
||||
class MoveResourceResponse(StatusResponse):
|
||||
"""Response model for resource move/rename operations."""
|
||||
|
||||
source_path: str = Field(description="Original path of the resource")
|
||||
destination_path: str = Field(description="New path of the resource")
|
||||
overwrite: bool = Field(
|
||||
description="Whether the destination was overwritten if it existed"
|
||||
)
|
||||
|
||||
|
||||
class CopyResourceResponse(StatusResponse):
|
||||
"""Response model for resource copy operations."""
|
||||
|
||||
source_path: str = Field(description="Original path of the resource")
|
||||
destination_path: str = Field(description="Destination path for the copy")
|
||||
overwrite: bool = Field(
|
||||
description="Whether the destination was overwritten if it existed"
|
||||
)
|
||||
|
||||
|
||||
class SearchFilesResponse(BaseResponse):
|
||||
"""Response model for WebDAV search operations."""
|
||||
|
||||
results: List[FileInfo] = Field(description="Search results")
|
||||
total_found: int = Field(description="Total number of files found")
|
||||
scope: str = Field(description="The scope/path that was searched")
|
||||
filters_applied: Optional[dict] = Field(
|
||||
None, description="Filters that were applied to the search"
|
||||
)
|
||||
@@ -1,200 +0,0 @@
|
||||
# server.py
|
||||
import logging
|
||||
from nextcloud_mcp_server.config import setup_logging
|
||||
from contextlib import asynccontextmanager
|
||||
from dataclasses import dataclass
|
||||
from mcp.server.fastmcp import FastMCP, Context
|
||||
from collections.abc import AsyncIterator
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
|
||||
setup_logging()
|
||||
|
||||
|
||||
@dataclass
|
||||
class AppContext:
|
||||
client: NextcloudClient
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]:
|
||||
"""Manage application lifecycle with type-safe context"""
|
||||
# Initialize on startup
|
||||
logging.info("Creating Nextcloud client")
|
||||
client = NextcloudClient.from_env()
|
||||
logging.info("Client initialization wait complete.")
|
||||
try:
|
||||
yield AppContext(client=client)
|
||||
finally:
|
||||
# Cleanup on shutdown
|
||||
await client.close()
|
||||
|
||||
|
||||
# Create an MCP server
|
||||
mcp = FastMCP("Nextcloud MCP", lifespan=app_lifespan)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@mcp.resource("nc://capabilities")
|
||||
async def nc_get_capabilities():
|
||||
"""Get the Nextcloud Host capabilities"""
|
||||
ctx = (
|
||||
mcp.get_context()
|
||||
) # https://github.com/modelcontextprotocol/python-sdk/issues/244
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
return await client.capabilities()
|
||||
|
||||
|
||||
@mcp.resource("notes://settings")
|
||||
async def notes_get_settings():
|
||||
"""Get the Notes App settings"""
|
||||
ctx = (
|
||||
mcp.get_context()
|
||||
) # https://github.com/modelcontextprotocol/python-sdk/issues/244
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
return await client.notes.get_settings()
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_get_note(note_id: int, ctx: Context):
|
||||
"""Get user note using note id"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
return await client.notes.get_note(note_id)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_notes_create_note(title: str, content: str, category: str, ctx: Context):
|
||||
"""Create a new note"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
return await client.notes.create_note(
|
||||
title=title,
|
||||
content=content,
|
||||
category=category,
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_notes_update_note(
|
||||
note_id: int,
|
||||
etag: str,
|
||||
title: str | None,
|
||||
content: str | None,
|
||||
category: str | None,
|
||||
ctx: Context,
|
||||
):
|
||||
logger.info("Updating note %s", note_id)
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
return await client.notes.update(
|
||||
note_id=note_id,
|
||||
etag=etag,
|
||||
title=title,
|
||||
content=content,
|
||||
category=category,
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_notes_append_content(note_id: int, content: str, ctx: Context):
|
||||
"""Append content to an existing note with a clear separator"""
|
||||
logger.info("Appending content to note %s", note_id)
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
return await client.notes.append_content(note_id=note_id, content=content)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_notes_search_notes(query: str, ctx: Context):
|
||||
"""Search notes by title or content, returning only id, title, and category."""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
return await client.notes_search_notes(query=query)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_notes_delete_note(note_id: int, ctx: Context):
|
||||
logger.info("Deleting note %s", note_id)
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
return await client.notes.delete_note(note_id)
|
||||
|
||||
|
||||
# Tables tools
|
||||
@mcp.tool()
|
||||
async def nc_tables_list_tables(ctx: Context):
|
||||
"""List all tables available to the user"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
return await client.tables.list_tables()
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_tables_get_schema(table_id: int, ctx: Context):
|
||||
"""Get the schema/structure of a specific table including columns and views"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
return await client.tables.get_table_schema(table_id)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_tables_read_table(
|
||||
table_id: int,
|
||||
limit: int | None = None,
|
||||
offset: int | None = None,
|
||||
ctx: Context = None,
|
||||
):
|
||||
"""Read rows from a table with optional pagination"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
return await client.tables.get_table_rows(table_id, limit, offset)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_tables_insert_row(table_id: int, data: dict, ctx: Context):
|
||||
"""Insert a new row into a table.
|
||||
|
||||
Data should be a dictionary mapping column IDs to values, e.g. {1: "text", 2: 42}
|
||||
"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
return await client.tables.create_row(table_id, data)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_tables_update_row(row_id: int, data: dict, ctx: Context):
|
||||
"""Update an existing row in a table.
|
||||
|
||||
Data should be a dictionary mapping column IDs to new values, e.g. {1: "new text", 2: 99}
|
||||
"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
return await client.tables.update_row(row_id, data)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_tables_delete_row(row_id: int, ctx: Context):
|
||||
"""Delete a row from a table"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
return await client.tables.delete_row(row_id)
|
||||
|
||||
|
||||
@mcp.resource("nc://Notes/{note_id}/attachments/{attachment_filename}")
|
||||
async def nc_notes_get_attachment(note_id: int, attachment_filename: str):
|
||||
"""Get a specific attachment from a note"""
|
||||
ctx = mcp.get_context()
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
# Assuming a method get_note_attachment exists in the client
|
||||
# This method should return the raw content and determine the mime type
|
||||
content, mime_type = await client.webdav.get_note_attachment(
|
||||
note_id=note_id, filename=attachment_filename
|
||||
)
|
||||
return {
|
||||
"contents": [
|
||||
{
|
||||
# Use uppercase 'Notes' to match the decorator
|
||||
"uri": f"nc://Notes/{note_id}/attachments/{attachment_filename}",
|
||||
"mimeType": mime_type, # Client needs to determine this
|
||||
"data": content, # Return raw bytes/data
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def run():
|
||||
mcp.run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
logger.info("Starting now")
|
||||
mcp.run()
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user