From 69b5f74dea19ee29526c89d3a3d015750dca0770 Mon Sep 17 00:00:00 2001 From: DavidBabinec Date: Thu, 23 Jul 2026 16:05:55 +0200 Subject: [PATCH] feat(ai): redesign settings and add MCP OAuth --- .../assets/pr/ai-settings-mcp-connections.jpg | Bin 0 -> 53681 bytes docs/assets/pr/ai-settings-providers.jpg | Bin 0 -> 44021 bytes docs/features/mcp-connectors.md | 257 +++--- docs/reference/architecture-tests.md | 2 +- server/ai/handlers/index.ts | 6 +- server/ai/mcp/auth.test.ts | 76 +- server/ai/mcp/auth.ts | 46 +- server/ai/mcp/connectors/store.test.ts | 105 ++- server/ai/mcp/connectors/store.ts | 64 +- server/ai/mcp/connectors/token.test.ts | 16 +- server/ai/mcp/connectors/token.ts | 50 +- server/ai/mcp/connectors/types.ts | 11 +- server/ai/mcp/e2e.test.ts | 20 +- server/ai/mcp/handlers/connectors.ts | 121 --- server/ai/mcp/handlers/management.ts | 132 +++ server/ai/mcp/handlers/oauthAuthorization.ts | 132 +++ server/ai/mcp/index.ts | 3 +- server/ai/mcp/oauth/handler.test.ts | 156 ++++ server/ai/mcp/oauth/handler.ts | 231 +++++ server/ai/mcp/oauth/protocol.ts | 123 +++ server/ai/mcp/oauth/rateLimit.ts | 11 + server/ai/mcp/oauth/schemas.ts | 78 ++ server/ai/mcp/oauth/store.test.ts | 181 ++++ server/ai/mcp/oauth/store.ts | 349 +++++++ server/ai/mcp/paths.ts | 9 + server/ai/mcp/publishTool.test.ts | 11 +- server/ai/mcp/transports/http.test.ts | 12 +- server/ai/mcp/transports/http.ts | 5 +- server/db/migrations-pg.ts | 49 + server/db/migrations-sqlite.ts | 49 + server/http.ts | 11 +- server/router.ts | 24 +- src/__tests__/ai/mcpConnectorsHandler.test.ts | 78 +- .../ai/mcpOAuthAuthorizationHandler.test.ts | 145 +++ src/__tests__/ai/providersTab.test.tsx | 80 +- .../ai-mcp-connectors-never-leak.test.ts | 10 +- .../architecture/no-plugin-tab-shells.test.ts | 13 +- src/admin/ai/api.ts | 60 +- .../AdminPageLayout.module.css | 14 + .../AdminPageLayout/AdminPageLayout.tsx | 53 +- src/admin/pages/ai/AiPage.module.css | 852 ++++++++++++++--- src/admin/pages/ai/AiPage.tsx | 117 ++- src/admin/pages/ai/AiSettingsListSection.tsx | 17 + .../pages/ai/McpOAuthAuthorizePage.module.css | 71 ++ src/admin/pages/ai/McpOAuthAuthorizePage.tsx | 131 +++ src/admin/pages/ai/ProviderMark.tsx | 36 + src/admin/pages/ai/assets/anthropic.svg | 4 + src/admin/pages/ai/assets/ollama.svg | 4 + src/admin/pages/ai/assets/openai.svg | 4 + src/admin/pages/ai/assets/openrouter.svg | 4 + src/admin/pages/ai/providerCatalog.ts | 60 ++ src/admin/pages/ai/tabs/DefaultsTab.tsx | 414 +++++---- src/admin/pages/ai/tabs/McpTab.module.css | 155 +++- src/admin/pages/ai/tabs/McpTab.test.tsx | 34 +- src/admin/pages/ai/tabs/McpTab.tsx | 785 ++++++++++------ src/admin/pages/ai/tabs/ProvidersTab.tsx | 857 +++++++++++------- src/admin/pages/ai/tabs/mcpCapabilities.ts | 54 ++ src/admin/router.tsx | 1 + src/core/ai/mcpConnectorSchemas.ts | 96 +- 59 files changed, 5000 insertions(+), 1489 deletions(-) create mode 100644 docs/assets/pr/ai-settings-mcp-connections.jpg create mode 100644 docs/assets/pr/ai-settings-providers.jpg delete mode 100644 server/ai/mcp/handlers/connectors.ts create mode 100644 server/ai/mcp/handlers/management.ts create mode 100644 server/ai/mcp/handlers/oauthAuthorization.ts create mode 100644 server/ai/mcp/oauth/handler.test.ts create mode 100644 server/ai/mcp/oauth/handler.ts create mode 100644 server/ai/mcp/oauth/protocol.ts create mode 100644 server/ai/mcp/oauth/rateLimit.ts create mode 100644 server/ai/mcp/oauth/schemas.ts create mode 100644 server/ai/mcp/oauth/store.test.ts create mode 100644 server/ai/mcp/oauth/store.ts create mode 100644 server/ai/mcp/paths.ts create mode 100644 src/__tests__/ai/mcpOAuthAuthorizationHandler.test.ts create mode 100644 src/admin/pages/ai/AiSettingsListSection.tsx create mode 100644 src/admin/pages/ai/McpOAuthAuthorizePage.module.css create mode 100644 src/admin/pages/ai/McpOAuthAuthorizePage.tsx create mode 100644 src/admin/pages/ai/ProviderMark.tsx create mode 100644 src/admin/pages/ai/assets/anthropic.svg create mode 100644 src/admin/pages/ai/assets/ollama.svg create mode 100644 src/admin/pages/ai/assets/openai.svg create mode 100644 src/admin/pages/ai/assets/openrouter.svg create mode 100644 src/admin/pages/ai/providerCatalog.ts create mode 100644 src/admin/pages/ai/tabs/mcpCapabilities.ts diff --git a/docs/assets/pr/ai-settings-mcp-connections.jpg b/docs/assets/pr/ai-settings-mcp-connections.jpg new file mode 100644 index 0000000000000000000000000000000000000000..b8cc1b88e1fddfa944203e9a1fef9d7b786037e2 GIT binary patch literal 53681 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 literal 0 HcmV?d00001 diff --git a/docs/assets/pr/ai-settings-providers.jpg b/docs/assets/pr/ai-settings-providers.jpg new file mode 100644 index 0000000000000000000000000000000000000000..4db233b41204f251c477b041164b8e131f4b8bc3 GIT binary patch literal 44021 zcmeFYcU05a_9z}@)Daa6y^l&qf*`%#QGrWIkX}Mjs+1(u0MhJ?)PVpQsZv5O(h?Gy zfRuzLgdPG?LQSX=I=sv+-*wmbcYkZW_ttvfzwXIe`JB4XK6UT2%h~wZcfbt;U431^ zxpM%(Ipz;=Mg}|rT>R<6#S1@Oym;Z_rArroX1&46digRd2ix_lH@G=?c(^&ZxcCG` zh57jJ3UG1#a_^VBV&alglDxt)_hlsRi%LjJ{0ZdTrAwDse`aN8Wo4J(5xo63)M0|IN>m@s+&?2Q@z*&p+Jj^+GM7nnujH<{Vz|LpP7 zKg-XZzsVwZ>-MkgoAM^ld~>Q=IbNMj0)m9z3D5={7oA%#Sql7IQP-hL(3KvV z`HKb%^bGK0tuA}Ak~Z_9VrwRFW?xxAd7Uv66B82!Z>aW=V;bsSmNkqzs>dy^UEB`JOw^ zQ^WVuKu8mIJ6t0z(pg7WOcNJkKJVNWzgAHCHpnnUxf=5M{_8G_!9YQAIFA!Ce_;-d zPY$xTT&cs^0eGiH*H~3y*zZGC!-e=I5v14Va~CJaI7>~`=Itg+u5fbs`xtV;$}J8%3kn+*}Gv<9*0Vx6u#-W%v{$XzPMWz z#u1kTV~4_>j=x)-?=k$gD`=Ts{-tZf3yoIp;-Ssx)ObbKzexnukM(CnQ|&*8$-hnQ z(|*LlBTr5Uv@nCF^eawflp^i&krQ=$c{c#ppTuM&J7JNbzi7M(E?gj4*NM)m1>E}{ zOSLOUaj>u8O&E%M55WB$4VQpZ-hZgrn`gLkFFt*Gq4r348NT> zkaa{~bcOyH5IT~?YbcG0U)K9Zn)oF58VuypwFt7&39}f=r6H3HBndjoEp>UEy9ViH zC1Dy*kTJ7KNfb?-BH`qdGXU_@)U^k)+F6?Nfm=Qee3O#f)RxR0JCycA`BhsbHLt@Aj_@qmY<-b0TouQi_mkSA`LzdqY?>hDU>_|4z}>gup9Seh^sLrkd2HZ#qzW1iGn2F5n(k^vMzwopdQriAOL|0J-3+sgMpvddzFrL&O3&C_m;pCPicqoHuqe7(RTZh& z?D3LOLt*>0gjEpxwyTPkmwSt>RyQOro>HF_kNW$&1=eG*|zIygEy&hGozX3^K?>zfp+Fs2Zq-f{>++{wKAfZy@!o2d6aCr(<1 zke&?Jg>6vz!3(oBT-$Xxx5z@9t5Ai;VsP&#eUkS(wRjnf7T>J5s5s_Ggyiz{IsMT8 z3^3A9jP%fe&Q`WJdVP1`{LYAEwEVmozq7cp(-<#UkJ;fXh^Pc>6Zq*DL0nd4j#sk= zRv2VR!ds{Q5of&mu1O50d<-lSFYqFnD#|ITl#9`Cei;~;`(sd z9SH{K2o#f9`OG^tAl~^n!j}KSfvl?1i_`RZ0mD4hBb?I&2y39!3=8e@*xVAw?$NN!XKe5ub8~5>Z?wp} zxj(P1lYm&-$q*~~fpzXds<4GTiWrr%Me~>pjBw6)sXTe~=$)oehue#{G*Wp%aDPbOS`+!^KSsbD^$Me{@_xf z@>{_>#2`t1lDN#LJej?4wOTL1)ZS8daZ$$<2MuU(yI1jsXH8nz1H-H-e5INsfPYu* zH1SnR*QN(uhwmsl7Y8JA|jA$KqO0$$ifW zG**VZGP3TPSDR)B?Rq!1_1)RiiYx$IhZq#YDFhk34u%nS5~Trv+hpc`jfw)trn#nW z&f5n)#5Jpyn&WUfN<%xfjni+sp4S~4(eIjl{o4D)(#0&1M~cb`-_y570gdC}1Fk)Z z`ONXF4Y<_NX4sucuV+(NN^bbfW*pU~k`*}?vSrl_Q8I^Vg+#7mH*`u2e`Q=MsS|C7 zn8Zv{t5-<gw6A(z6BB?=_b`rJ65&&CSp5bC^-BT4~A~5-LTa z5az2DK^B)Ej%#mt#(8QOzn8&G!r#?4kNSVQxR+2dYpI!C6oNrzn z%YjosVNrKt&y9!bAn(C6Ax0&1WwG%NS4U)=*QDfnE(YWecRPu3Mlqt))UxVOA8nK3 zNORjLt#>{w1&)&xDH~+Bm8!i(Rl$cnKXQF~`S>Bc=e^e%z=SRQ(~tw|qrGV{7~!gZ z+eQ4es!MEEB#9h=SrSVdU;57W?&K3m!_nn#CJ#xf%GpxfS7gN}LomGunsuZ+mi;}v z5;BZ>$krZut)$dGZvEUfx#RpQ;JhVgyKEcyIfbZ9Z z$b;|4lba#C@{CeT(pJMwM1r*GaMrg#k*T~^&rtmX91xOGdoFwe^ys>AYSGU3vUlTV zC7jA4WyoY(NLSkO8Q?q_MAy~GQ-9#hY7xR?KfLWEw zZUr2FbaiQvr&UaD6;Qyw2~65gq1UYR_LX?51oLu`vLhx0`+q9zFpPn55zSzdexVAC zu!D>dWv&beDB!u$0vU=qrd?ar%x%Mp^Q~=5AWX7&m}93aVAjd=9Kc|?gFIO&;fcc z`4_gC@~3Mmhs|7eipo-DZ`zYnOiYU>Bd@x$y@RW zY5Gai-J-RJR?c(EfljgWG$|;da^j6Y5cIvXG4k8l=AQqz%+P5i`|Wv?Z6Uykbwm z{sx7us;a8Ob|VS6#~A0Rp@)8ROix(+<13%FtdqNUN1DR$WY1@!xoBr zD0N@of9cB^pr=3LiBcspYhsZhUdy@4i7(IF9(=10XuSt zQ0;PxusqV9yy~rY4H0WpR8+A694GxOyH%=!F`enF`4OtW+%#c)MVVXaN5^Z4IF{#mkN1rxO zPP+c~-}B3b|0Mr28~@o1%sud*v+&=p0-!EuZ6VM!0eutnd(p6OLxXv#gAtC_*n1@a z1Nr4o+7uuIS%|6QvvQ8C#aTG*pU-XlV8su?+|MGz1skUfBf@ZLOHQHEMT{Q%_sBdv zmtAQ}Omp^CM1n);eTj1qtV!!bf!-8#={IfnH?lM{t_N>D1k1WFQ*M0u6Z(G(-jqLR zvL2yk?_dtE^2zCO7Ma6Kms>?;kmPg+x0j>8dQ1uptWq-itkV{=6W{zk-@#*ltsVs; zEsxc4K4ppp;YElb(=W8skL9jrKd($Cm&GeacVopb)ncPVD&g(Ic>MX7y|73#6KXI!*jwZ4|duWs`c#b{^H3Vl)b_J59*UrwzCS z+B@3N#e)1%0uf7WDIlajWkW2(X(+$i16i&j5ovMlCuAJ`F}a5SM3T$j2uY8d!(^$53wS8s-L_O>nXz&mzt7 ztJT5l*3SF8*ARsVPa;~3z~ z;KW;gm;eri<*pu@t7~4>l&-no*Nx}2qu}4XON@@iD{}0406*mWHWSt+?Ej#1an-!C zS8{a%_190Qzkw?DiDhRGS$k+=M-}2HT^QNm>Jr7P>E-xw39R`@l3%g_WJz!aw6}m; z$Fk%QvwFRnUS>mmj~>Jn*ypY$k03u-BxR~58$1Ea7#>3mOBgNM(zN(CMC4<7muKK{ z+10Rk!kovA;m^ydCsRv)ll7I($@j?g55yzK?U)h-Qm^!{*0v5v^uy^>>ohlQfBV!I zLy)E!ChsM$>6tBQyNDha025sFZhDNb-rUH$EM4}N-{&p5qb8aHt#(ul4Z5}iqFE+X zAgmVf>>ZUtCyvT@RIwuq{oT5LVd@7sJ=Z;ccjpJ8n!pjpk-@hkWe21is(?Dzd039z z$l;Y}RyYH6ba0T?Fh}b?D~KZ-TVZ#F-pEUx@vj0Z+nb9BkSaMM)U!iDCs73LBCj=m8k~cm7D%quUcr>{Xhmf#Ir9RTQ#J z*NO@$Ta^`TE1zE=arOjcE01W`L1TWUVVyqjXqj%j*V1arT`_m2J~L<-#+b%ZrAA`H zaTQz#O{n~(%=81J5d|nWw*i3^0Cm332J2NUp4%JX18poQjP^u?-YhzJJnB_iCzly) z*;DHi7vi4+2C{1C2YJd!Y@;%@e!I4_aKccpRanE78q(HfpF^9kTmg;9i#v!&nnZ_P zPu+&c?JSWdpGQiosSk!aM`-`%@P>Dc|88`3XgYuLAEIE*akudk9gXD9u0FXB~~yLJJVJR1UN_Rf+lY| zxc2SBi#UGyH-`lPxP+gT7H#M3-L#ptXg5fmqV}e)xR5#I?x0c{+*>YIj+~Yu+f=}a zwZBeZb1Xfv-t;_iTqS7Sed|4>{Kjha3}ESmWl!C0j(88A6%d_3KLBZFi-n1Ka{AQw zg&fH94*OaOqIx<=yS#3$O%zU|g(Y@-2}6Kmft4StP!e7Anb$5c&Vl|A4{s4OuOF1q zx-5akLjoqCn2jyP-!G1&6wkW4dk#+|&&Q;*9yO#GA60Or#l%y%!47e~>?1|3gu$FQ z-j$W-2>sLCLRyk=5#$nPD$eIzoy+QraNE&D2Tt-zJt8PR?3Nu>OS}hqAcLB64qEw` zW|ntXyOk5xnh`a&9axue1VM`A6Iv(C*`I=b;P>FM;M?S$X{P8{O57o z3N|62YtSQpIUCd!_2yNRw>dSG4nOjE_IJw)ss7Vm%n|Qz0FfTn-Jb{E z1mhd%q!l)x;2Gc+Q-Szj;qoCkC8dPy8tfaBeD~`6%YV5w$ud*hjZH0q2)td>| z66fB|ic0joFtht8-^8LcWxHNJDDDayx0tTjHi4`Uzi5tSeF>Rg=Ed>UQVes!%eXPC z7b!@?a7N79x2{*Y)Wt#m()DL{)K&A&<>Qsr~&8+@Qit3NO03 zj~6~zy&NLdap9ilJhU_>!yGda-uR94Q+*$e0TV@l@G|S4%BneS(JDImd%kN+auc~T zmICt7mRt4x6^A!631Ab3-oQ83&yb#)gHkT#!pB&&OOS6}P$n6&<+CgGUlaAO5$->> zm}^*iYH_N=`+2VG#5VhY`#@k}jl2JrZuUD^M1+!%8kiPy*PMNa2ogQ9nSZ;|^2_1X znj>SkVyMUb(?(1iutGv1Mi$}a2f&dA<=lzl8RiB8nMPMD zZ29UaW4RS@@am)P{TXrFct>Z)=byK$;9VB#_HuI@w_+v@3&`c^Ym4gdQ_ANM#PE_q zajgI2X;}fGV+$e>Xh`nhJp(*?a&W!BhDF>cTCDtebcBS??Jv*=n89~+V-vvvswvm$8W7B?(o-P9+mI7nGY`X0W7 zZURjZK4_s4t7ohr&U&YTr$$8zT?ICXJqv&QxP_6ZD>5ne8D|4B{FVbE5URXz33GrC z^DES46Unbn)zZ}+4POyMGL)X30d&i?g|(@Wi#8^)$6V=t(y@47baiA9*BQW5TFWxj zs(kSny=FO)jWYV|U!KQzqplqn=kJ@qYK){duk{n-jBC@_3Ma~KDaw$qHSF@`>2DdK zg@Xk+MY@j7(Du=z(&H*dO^upvxg{s>&FnY9mWrlk$wK|6X?`BHd14rHy8B-Vl?wwP zIzyYVo0()KY0zl1>WACQ{4s}N>?VN_a|fS=b0a{fMn7V;Q<_H*aGQ=w)Ap}l z|eB1S#ZZ`2(KDm;XAgcxJi3edl*~jG?OAs*6Wxa)i)jZDls*9AF8c90<^jq?F;VO zb(w#B8;!{D{0ZeQ)Q zL=YzI&=-I*c~mV^&}JRx7QDd@UNiG9x-!~CqH z1IF^D#q`@!Whn@8-i~^L_JhZiO9sR-#S9r)(|VrA6Kz@`GDg;t-Qr zjwqED*B>kyT!xI@dr>xTnueSXrD0|Us|F(nAvJ62n2|C0<{h_i`XvnsH#hPX&?F{hSt{|6u zl1UYT@KD3SjCnu%&QxlSIf6-Sq-FESyb7+uAXnc?lKO}t$)2niXG^L{h$>C;TUjFe zXls$j!)=>e%_a{t;2dPQKSx>k5;uE7ADP6xr0Dzuvj)uxd-nwtnBL{8fX=txu&__y4UzFxQ5XXa=cai^_wLffsf;HP$lv0UJbQ zKe9b7FZby$k;(o^y1eTHbR>+z)LAq(cC)du&2GOMZyvCT8J`|wIN!3y>(-=zczNVu zCU5_I_@VkTY*%^`Yr^vM7REl?V>(;=Xc-TLTyLZ_vd>m>VI}pV<<0;c(|yfeNs#+0 z!p;G_jO^NaHO-Je#!ZRH=$1Q>hw3^l9rS7k#2~u^37xlQGZ|2sCuWTWqXKWuz1?>p zcO0_qByPLsEVry&$XSuTrOI~^oq#a%g~3eTSWGl3!x6|>7wnJ9U$0cph^MjTZ6nEL zeGXBMZb#~Y$V~%mtXm@{45}H?G(ey3kX5f*LUo5TD5O5`@28_N#Nz z%p%%JzYEacAO(=Z`nCR*j{I-N8@~BVwSOgS@>)d*AE?s4fR`BUFK&^dKcYN{)AvTnq>mqg3kwH;tD;G(o$+a# zrOknuB^lC;YlC6sch9F+!ri5Lzkh!^OO~FE>!kW8TxUlbyJTyoeq9S(-TGX+{C&h| zd$lu=xYB87p)|b`g4b$1O#8;-LsIPJlM5h)9#>iCPWmBIWJOiMFw^a$#mo6V_FC1NBsn!;+bu5|uJZ)>W zVB=CRv_s|DUc|&tQ})7e*;!Q!#oZ-@he{Iq`fY}RGTC}GrRGV-nhD)aNDJturO9dL z9#W^_z+@@m4dKd>9~P-?OT}t0>K|l;68AP&C?B&4j8{ebx^cxLmO6>+SqJP%Nl-tJ z?bSrRZ4@Xo^+0Dx-B@?4v)ROI69|ub-WR?Z`E7m+EDx3`z!fxh&QC^6FCmZ*OF_e* zw?x)_ll|_XyS}FO+angSGj!EMOV_52ouJ(UYa6#Z68zxB(Cw6{0>qo^uLHe{t9_aG zpDXp(lgvmC-+1<5EEYE*n2_LJy}2dZ87K7zSrn*{x8rD!g~)y(1%vm|UBY|=>G%A5 zdW2KJwU~~mI+pTtd-b3vw>W!I13Ds`_-GMn0=g7s=a0d)!!`+Rf%tx47#NJ(-fx;(m z-ar1)m7v_T6>Sn)MDKAqFmxMpC|tU6DdIroz+!NQi1layO?pQa%)8I)mIbRV!C7No z$AUmsh35!9{hsjmJpld`6YGp&w| zScS&}70UOMgROc|mUw~R&rxdGD2&kB7V$J=cXh)`&+znNh4LJ*+ntP@Ko2OVsIXg) z`!Yxi%D3dzV3Xq^m`h{mIV)asw|HUyt-Iy!lkTdqL>2nZGoUZUSByzS1U{cT4#cP8iYP8TgBD75O1VfBJYI|ASxXq=_ce{eHtS)1eexDfWt$TCn{6Q4K3q2el!ob z4QhCnMyJI8w)JbVK8!r`CJjQP_9UqSSlkbEJHE*^fZz->iyCuMdBO{Nrr(>lR7p6S z2fUh`HSM3zkfvVIqseyngO%PCbuhZ*_qFB^W4>&1E!;E zJqSZVGxxp?w9I~e@C${*m{7-@o)f_pT3i1aU@eijA*39roj#)q?`Ury+!$VX%7)$E zVWLu}5{`WTCUe%bwIeg+3O++cWF%BpN;)#_5dIA_cw{xcK6#wsVgHfdpM%U>T&C;s z1MyTB@7H#celcXigu;nAX-7ci961z9hF&*ms0#m`=8vba8}#PFrH1F$(@?A)3fcSl zWDgjXAcEV-zBe3}?RK0qdj^2k>;%vnjcjStB~xoXrSZ^TH#_QUhnx6|1Aj!=w-Rfv zC<{wv2{B^sdaIlZ*Zo;BWMzy)zG69)533Zh7nIo<)nSNauN%Pw7Rk~eD+7_5RT<7PPlot`*2%`MwT^$h)c@Msz#eBh z+pbxkqoV5@8bHxk_^I3Z=_A2~sqI1?ltD?dinB-(2p71ElGWC7smq%)Nz~Oc-$=nw zZDKkg`g-p=B)v&9O99fJBmUCWgi>>~%Ml^y_t@h~8?mf`$yh^mQG<^`<-~Ns&rq>p zGN=WvGkitE{=qFT)>3rjTUW^r19`jqNl1MIhOB;7v+bJ8ZLO@}w7$}1Q3#)lTsK!0CltJ5hA)iG1#oBrWVK$&B zt;Uz1v(lr~ft5lwtk!!EO9dt;J$l~NVzxsLvX**&919k#VGeV^cE07A>H0oBF+KXC zm?>G5JX8PRO4Ov51}Zvx-UOT43#2)i9ROBGch2lhKyZn=?AGo@(P-9NFHh^#8s2iFr|sf7GdLZ!iZ7);O#(QF;+W9DXB zyeWeL>-+uSxxeX4>nfQ_cQqG9P7xhk1(|gDHY6n+mmq~u{(Y%)5vL_0R-#`?*FK3B$m{yOdize_Dr^T+nGlg|ychEhl1HDYKh)|>5npEyM z*>EFb*(7_d&n&D$G-SZuLg;LLO&>dlKodc`-yL50ceGJC{%>zO$FybS39*$M2`t zn~qR7iXTeq_-XotvLV(w4ClHdFHN5e+-fb6B^FmUp&EtSdsoe3OzqweaB{yIT#dBU zU`sGNthV^wCnNW;z}x=ypl@KN(_Tq~Gy3TqIUOaHX7>GUhy=DLNSt#oQ`Ix#l3p=W zjIO3>yxMmLFrx`>VAQ6O1uo8A+^iqm$095;BY*bVwhN` zPvp>(bQKnoqB541ytX@*&j zs|fXfu+sFdJHGH`Sypz*)5ZCP{!-s+G~ZN7g=v!C_c_x9aV*;aN;B)b{{~Cm@M&4F zpitqi+YD1#;nSD@OQ1xYyZo1|Kf>y1#nAf-AgCkwz2{NUHXc7LX)lHfJ5sFF3bd zXr~&|cQ}?N^!vf4P(l+q!hbeD%@wvrvFIn?wSSUKLr%&?Q?NAM(Ar}n?49Mj{T12evOA6!d(J0X&yV%WN-{nQynKRL&jNqq$CE$iymia_IcKDfxB zmh0FEnbS?hWh>QA!b6}*xvxHY$8rUB%4lJ4#kiTNect_}ovUOsMb3Ip_p2~*y zQ<>4e-HF5Ne2Ypl)~cGLnwh_Sg-f3 z7tqS}>lFpZv@%~*xI}w(5ds>Xcg0kSO@7L|a6vPt@vaR103tJ^_0&roXQeWBMU*Q? zY1kDjHs-ja+vOmiQV&qni-q?7RiHMY!OgkUp4=bIW7}HmYGua-9<~7uTm*NhIU95DB~EPW zG~vQCmG66vPpO4Ltp_{by7P42LhIVioqCf_w>5BB8jFMGm^m#j=`F;fD;gA%x&q&? zbzB~7-(=m9FDWtUM3#)#SIU4YxK3om-L>~j_U;Omsk%AunG;$tpYjr7=O8c;K??m>I-_>7%r%`L^g z5CPP6MbT!0Vd>&;oVxk7CGthNbzzGee&xELQlUZkv2^MbX5k|KTA?t1^s#vHGc)ib zyst%&L9^O$*p`FKp|-YGSgcB7A+U^}o+oHWO|*0RlygOtN2gU^24w@KQ|tL=S51-; zb1{(e(X|p_AhGb8`uPx^;X$OTZP}C8Z$f?OczdmZ4_clf4p-dYjChZ-?GLUyx4V8o zixNUWGBek;YyRLXURUh5==Y8Bc|QdL8b-YD_}8Me~l0wU|^7ijb8h~mDK`VVX11{YsX6vgHD!T`z8I2Y8YOl)o6{l})9To4b zf`R=0pg;%@D2Dk^h-JEA;2Vw?UbJ_{R#hQ7N`l#QO}o2&K%Eg*`-dGB5E-VZY5gMR zTD(EA&U~;U>vdx*U#T%+z9?>PZb#*D_u#?Ju;!~q?r#LN)L`Ee*t1zFTY>QKJqP$x zz9N}`!yiY1SFQD$lHkNRvIG_@ir-PL7PHXV=_z*qZenplsYs(3V7-&J&0dou!d3lO zdaniV@(}emJDNEgCIWQ3SK|Wvr^*bTrB#Wm4zHT%7kB7_1=-0FkbooO05D{7tEjM< zNJ^&o7RVTQhHBl0H-t5+b^jJ(+d}U}drLC?J6p2?b%YoPISJ zMJtq%m5>WZX<$(yx9F)LW}>po&mpyx*o9x7FJ353R>%9y!mow;Z9^ciy=nUp;8QOE zIiO6+9pzozW!jPDT>5IhbLHWcKgEN;`mf@)!wz^P+f7NbalwOcpH{%uB+cGpW}vj0 zV0~z{LxjWciobiOI_o$MoLHg@@9C(xY|2m?C67-Vw$|%|Ru;9~bc=%{f}sjAgS^zg>%1E!3&57_aY_b?08x;0MF~qr55B^#frmR<*0Ks7qJFlGCWe zy9rTz+s>9kK+Tad!dT*ThkhHYz7FivlN{mIf3fItTB^n>0q@z&CmQ(s8$(yZjv?*e z+m`+Nm(RcCav!qJ)I07(PJL1m8jx9vZ&^G8;A9Qe9X16a)sz^Le8X*!ZM+6oUGYfZ zxPNAxLXw>Za%IR~SbR_5=+T$TCnH;OK)iw(H2ML?*!Aaw8Ev;E<1zJT6TJ?DBTgh) zVmHaOQ20(6>PwP?3%X`?bfcI`?U62ynP`oF5aKeT*msHTwDpV3-k z5>dlAXoZ}#d-6W+3!=jfDKzYP=(1B)n7dX#ARcF(@hsx_V51to#B|OIm>-)brqfEo zfJ{$B-_UXr8M%Xskq$RsYy(-3`HxQcJGvk6Uvh1DmNF5?Z{9gnV&TcVU9QnN4`j`2 z@Ok3vMTElkb^;_6MKRDArszN<2qg^`je60*b89A+7BYCb4TVo++dOezLS zEZtXD>ADml${iZo^L4d+TC(_;oN8bF^2XETr(fv9zBH0E;^XwZ^9t7r_h_|>Yal`u zE723x9pRYsgOQf8nSG7910X!Q@?h;b3LP`;PSdc7N;FUQF7Sn_`a8H_BVU9r}^!6ISkarXfwkoPxt<3Y6xFu-#{K**_<$+>a#ACR5Cl1{IbHBc9I)3@f za#I353uTEzJB_@%_TX`AsnYb-j3_S*`FV0!F`d4~vU($WcW#1GE;2u{gu3|GI{ zSh+5qy2Nyj!CnRpyz?`JrcT2e_Y|y6yw}%r>@pk2E>0Z;F1XfQlBzfG&-IjBoSwok z>FyIHyVH@C!&Ib^q9CjC>W`;g=Nr3_qPA}>2h+=e{wCBGu4zQv<32Z;-4TB865k34 zR1D|8?3R_8cs0m1rC&B)u@k7Vu#nweSo`zS5i-2R9i>;qk9nHu+ zkvyRYEx!lss;Ux*Ae?doyno=Azk&V#`xE*8Y~o|kg|MV0Nv+>vq4t@@kHE1*OU}@i z7WfgM@bTBTrq%LQhjz@%^RD=Z&B#|9J#*IlGfvG$yICZQs~-zr752-t9X)Fi24Uh? zYWLPizYCDQ?rVS9qizX_x1L^Vk^L}q7mLGPc~-P#VIl|l8t+uLr|DLVe-f~IPHP?!u4|HU*o`_jJ9aWm6g z`We=Wd`;2AN56Fo^SbYLwXAM|8xtG zJ~n=Hd_BVP=3JQY_t5SARKUN0ibLg?Nhrrs{CC9mjBtSGsbhcYjb+f>HF~c%YIh}O zKp*_mJWghglV4tea*H=4hd(#`AZs9H{3IhsRH7-3C2vs0(Y6}fXoGw?bbr^ze9tt+ zZ>Ro;%Z4!dNA{uDcXyw1F@I|9CeKEOMEQjCq{L`rBz!1xrScUY-GJLL6rP-2CpT-D zvH#cRu0T@NE^?|bCeYzJOiPci!Y_9C6Vlf!aIg+P(V_~OO@6@?NGDv)8#|31Oq(mq zFv$Ep4q;F%A>J@C;32lR)D!tk%r;)$;#bLfaZhBN$$O&D>;iIetS)Wuy~!HWB0@tx z!SV1uaN6NPiOSJb$+~)>7H3CG@KQ7R#}dh3df0a+#DQd?VxGKY?zZ>V_Mn_T5UF3e z?<&;tgUPn;o&oaCPpZarS}qKHC@!w}pylfkKwZ-6QmV7}^^5>36i0d`XV3O=3i0Yy zp73lEH6xe5G-9=~q$|p8q+z~(ZL+dCvm8pUdN|Hq5JvwE$bZB(T$iJ^^}b)c@A8kB`oxRd$BFyr zVtM}&WBYs4{{Z>Vocs^&lS>oI;tk|=$lqxnKjID3f~bh;IL~Ay(>_!);9~Nf0m{@? z3<|A1bp_dk;IC6nGj{b+&g4}|ERZwl#wd2mPlQN|((MqAr(zi=g%`w^4?80g5@%L$ zZ0Sl~5uBm}m11cub&MruORTFuBa>%5slddy3!|iKI&mN?3b(^Lo{A)&6v`b)_jI(; zr_&R&0|T7elzsaNMLKY!QF5~#*>FRmF3wrox_e{yUQ}SZXbWpJm6W7^1F&MxG&D=` zF8NZ)8f&j?+}2zD$11n(5|k9bqBLZ0Y9PrTa7~YCx_jSG@k*`rJeb#X)DwfxOLR?$ zX;>efvR4TXRx>L(K6{(w|W?EqZ!{(z^C{}jF6IR*WFjZErY&1XtYe13u&Sp>lIkl z2jGt)I=<>LZL49I6~03-IiY!)W9{m*h-PRB5Piuo95Y4w@=u=P{F^#Qru+XHp!Brk z9CY&D9~2bd_q{knx0B|`BhFs{@^h^=i9a`|ze*~z0f__kKZe~B`p90d`MxKQVX|Zq zGeb2fi>+NsNU*qthh46vW%Wrs3LH6d6z!GkNp)VY9r!7#Kv&w=^+IDvp6{@_Y{Y}! zO>c^}dTS8kp4V`GE}kHcS<4HBV~!6si-I*@`7V46P0@Yv)>XBr4QL8?NuppviLWc~ z_}VM02@~>neARg;X{Y^?XMpD`o6pD8oSR0R4NNy&9i|S8#Ks=;;lAe1i}NynH6ZW7 z%?;~A1$7f3(GU}?yy!LNVf$`^6Shid<}BZ$%3#8bJwzr$^RZLqLr3hNyp$PmEs9d# zoyJ~`qji$~n7-}SkdJSu!B=zxMP%mp{U-u;hqxw`Sq-ok@vt{GWAZaPoO791b*BSO z9WlU{KI0*l0%P4JY9}LA#alk_eqtEv_8$C&7LgMbb4^(n3$mRKRGMD0U@h)5?8(-; zB=M+}`t{2^IL3m~Q_=x1Zrg8R;XTyiN%hp0bQHHQ$#S>|({)X18^B(qLOlw}VZ z@KYg?_=td(lcww)?Hv}Le=g(y!1vsGsklXSAfeMRPw(XgirPt4v?YnR#Q=5vE8!&P z4Djv@P<+GzxN-)dUq6;TF&>Y69yuGi8qonbe+D>uboOV78AEtdc;E3byXr51mQ$t| zaN8MR_#}!Eb;^BONP7a1Jpko7>6pK2@vp%E|Kle+Sro%3jlB)oPq5t z4{urM{#q`F&a^y5jQzO$0w$Q8Cp7)-Sv#*Xqr;XRz#xC+~_OURLt2@ zRYggDX$h6l8_4dkIPKQ_fgS?F(BbQwf>zQ;B_$SDA4pW8=vHNh)0g*SY3uv+`YR)0 zr~5m9Vu$DCd!~lqjWKT1Oh;&pf-f+s;DC8WjhMBJY?v+Wq*RA?8=n1x!ba%KJmc~!#O zRvkbGjw`iv!S0oHrz%WS^UWK%*~GpQ1Rs`O69n*`qz%(I7x0~3Pxm{mfOS|NzriBZp<(X{}YTi5W0x<^fYRBt)6L6P4oWD`H_G{HFT?=yZtHlzDk)RaL8 z^o_A|#_t2Ht%o+Wt*t(_Bojm20vXi}dp%$&!5PoqSxA>iE5?dX!+CXG$YT9gUg(5~ z+ewRb&lhBSYfv%fcGavcJ-=h%(4s0Dse#kfQ;i8j&RMP8H8(fhvBYlCTGYeBQ=DWyFYv5e{tU;|BajR$a_Bo(1BNW8gjvRzP~CgeaM7*9;A}{ z?!=vc*p4r3w#pB*gs4tFk0sirER-F-2-y({#s^sA-yWn?*0N#APTMla2I0x_hvAxS#d2 zowF(vN*^&P<<>62qYVX?)LU4#=sTk1jfA=`U{gL-!D|qTO^!D@r1q0AS43aCEnpc_1aTDMS*>dUMY08W?A4- z8l!aVdQE9I&@=JICC$JQZB@O+X%%~MXN86`D?^InW(tMN;GnL}4P=|&TkJtkb^D2$ zEDvkczOID?5iJ~2Y-s4b_AIV%!p>C7C8O4gEh zQ>&$e6rR+ldv>!oE-d!@%_q-yfgNYdRA{7~-%@C*i>UH1qj$>s=PqdoRHP`clC$_m zDm~sB1^0$w$JKpg*X<3BmUZUEKJ??fH%~WZjLf(_wv4+oh$51&XbmvJ(h}Ic=&vtI z0%XhwJ`5^Jehya=BfstuU6$@gVQ0bEEgZ(IUs+5^x$|LDqlii<#aV4j@)Ixs>z=pslH(dPjLuS@M_0@uwbUmNu8|1 zHmU+JBieixJU{-IIq7hLBki>5gr|H_CoS^ZWtqy*n4VoF#LKC$YuPjUxkokIL<9fx z%e@`qT|*z#R!GS_NHhl;LN+B0b z%{(rcVb0MI00PRZ2LBTeD61Hw-U@G*%V)KSOlG8mRkk4G-Ro{K%4cJ4g^HU+1>#~x ziG>Eh*ByXS`{8^4)g%ANoiD87m^e)!C+Fd9U$P~?Vg-dZ@|Xi*Cvr&6kt zuAb^3REE2CgoN(z95;kXqft6JDp_+?2d_d&s|!iuz#c{jbKOBj&?%i{vEX;S1DW|DAk!_F;u0Il$33=#PLM0 zE8dFkR^gBIOPVnLzN}M5?Q@N6v6|UJ_fkKUC!c%2QI!+H0jUWtX<%``$WskZ>q)kYI!gyiQG!}3~IGoI~( zpuqM&Sv+6Mye;ytItEd86)^H+EsNd5a(OtT;w*LzWl&rN|M%Y`LFSyh&jqyrH zu{)8S=$XcMXQsJ|SKO9$gjJplO&PK>ljbeQ3)eg*5nQ1M6+Ad>T7AxPN>eD@+NXLw z7bv*8G)3SK9qya1R~$M15OB4?sp#fRfYXbPl+IVBgJ#*hXjqv&7jcl#%UCKaUb3KS zz5a@h-}5e)=lZfJwtYSRqoO}s@!WoFa9ujAB?Ak=7r)~25z8P@iGm2#yITGvR)Gmn z6)cMYihZC1NOD#CDf@Kmsx$9w=C(%&?z@pS{CE}fQ{*c*jr|fKJLMn-WyEN(sw-?w zT)9qVK+oDR>l|3EISktfWi`-_=<{+XT+U-h2d}*)c{Nmz2IO%!mf0Dh;7UnFoeFwu z(JSce!X(Kj$A9pqw9*Vz7|J+sxrzPLACJGkLj=XToz2o*^Fo@RYDRl-sm`Go-Jj%_ zdT1xjW7)A%2&B5h&*g9s#HeiHN#oC~uV=n@uzcT#c^z|wSIUds&|X6(T$XJ;W9*&J zS+6EQdsSvHmXWvF_#!tb9)Uc^58(~S2;5UaJ=5nMgDLX5Q{j38xk0#w8vXb0(=fbr zAEa?e@Y{)eg~eRJ2%$AD-D#@!ItS6+!0@CWdB7WbprK}C>r?3twR)gnKF1^)Z0w&+ zb|0y^6yg}jQCHV8jsE6qh7mP-z1-msL4Ws~@LRFWMn&<$-+96=)4vo?V9C6$lw`nu zmiw+rz+*?+i~NBE)zh{XYe2mJ`$5O)bh<$UL{n;pxxe?ryW{nC#wsf`d+>@i6um5E z)ejOy2#f8m+efIU^HvVKnnRO1ueNf4(?(*%s&s=M1Gd=c*5dK+rRQwyB@*YSk^)|& z^d$&j32C?J@Pl;pje!c!fl8@yhZ||-Wkea1^F6Uy-KrgHGZa`2qVb3P<1J}gf+<5* z)?$OUBFhPZxcIdRxMGBybpN`9Rk~RgRA9VxY32;exrkb*zS1C}P(Y)M9KQ*4%Wd_u zoOCYsvQ-fBc=*i}&7FFek<7;XcXh1JY2B1cj=?D8h*|=bik~eFpf*EmB36!jE8bZ= z3$BpQf?zL^5NSy3IzmE0FyJ8jkowCO1M}nm6HlstOtpVxBpfr|C(iFkoW7(GS~S32 z)%|v{s5SN?9ieD|ev1ACgT7f5FCNunP%ce=Q_Mt%cqnomeihfY(w!p73zzQn92Fyy zJrhUm(I1(i8298^b#=ilv`|-?OV6BI)2|zAE|qALa$UsFDWK}hWO+k(?WB}FUBvA& z(2bA#M%ZKM*t9U~Yrpg0>fg4rkNK6a)IH#Gb}nRD!69j7WinOPiRr#*hBHPx)i?uB zPP6Y_QyFjZNVC{eE!*;gbY*3Nax|1=yDbezC@vuT2U8Mqt%is+hATrDnvvh< z8CXL_@s`Y4&X0nka_3bUF?n+unRpK$>@drvdewk(8g@N5m&?T%KRA`<2J;t_kgT+^ zo-el!vI*V*7a67}xu@KPoR*&Qj154sE7usjW9LlW+&0tJOPeYU1|?H1G-R~;aWE4!Xeo`pGE5&&P8d@2_(#<^N5V-YA8$IK5cY?&N?4iNI$pMCJ6(4eL zKx-GqD?>hOfeZoo?KFJ70|>0B*@clDo`OM@2}O5(n`%vBXBgf_?s|GH1_nCCj#+F8 z^W1$BaG=t&_M+Ido?ot^MO)%z8ZD^IGkqhk%!n{5of9BctW+xK^2B8YIed*ER&{oKTTb|3pZCq-Auib;q)G4ZL&(aQ9Y>FWI+$d6ou;q2) zS#E%2mEEw4%eIIWq`0-kHX+3AfaROg=gnfNR^e$yvGr#Q?Q{wNa`T>`t`LtzDh^7&3haYp%0VTbnsR)S%}}A%o9%Hu_7-No)V6SU-OR0QID7n!PPbGOY7PLF8jk|Gn!PkHHrjL6Wm;Uy0>VZXnES^T5oFUwP@ZW~l zdCMj%>X`+QIodSZKA@Li>7qie&5>V>88uC#7R)|5&(*4C6+*}K%S5)VG+Spm6MlY- zawzrK5gn13!#QYNg1M++IX4dUQoM!o1Rv`1=_JagH^Z@SGc(U`hKEkb{Hu? zT3+ES({Bg;W~Sa#Qwt4y%QKAb-f*{=YER&@#cWeX0LMYot)se@p8VuR$(>~X?!R~AB%iVitz8+hogBIKAqE{^}@%n9_(x|OK1$dZ>D+FdwmTfI{ zn(DEkk-U-|1VmBG6t~{~G^Q1g%+Lq2+Dg$_h$3<|(63r$qEWSPaR?CH!RLCNXg5r!x$l`vIeSo?Z~RJwf>c(# z>dLQvR%S_p<(=VTRw-+kaO_2EW_8r0>h zU$79!$9EiGbG=?vC!_UM^tsfSe0Sy9kO!Mrph_j8AKhGb_z5p$>eh%s!a{4)d7W-E zci6lJEu91Fn|wynqc>N@yc6@Q)yd{C0NVXJ9#)a_3t#h598s79q=UxKc6B_S=&3_w z43Aq3xVIp#Ga%aK1gyoq9#3o^YL5CIBcqR zBg3DYqHI}Z)N99|YJK|EwHD!r0 z)bhG5pS3Zm)?Q0+-0wWn+Ihvl;J6$@G8^&P$uZQFHq)|iI4r4f&H~gfHtY z4uJ0*ocJ8!j_lmnyIDST8_-rkP}X#j0SwbrcG!!v=4$T=p^0kDmB#0M2s5xNSC(N- zUt_h5Ail6rW>%D0+W~yTNF3Ws^{AEaXv+(2Xd3vbBR}It5l-e3C2Yw04nyapD1XX$ z>%jhbWwzj8Ntc}v6gclu8J)Fu(Pxsyx{pbBeNI!Y#U9LFqHIBMD~3=Z%+G0je(p0}DPoyL6Mbt>)Iba?|Bi&*Lq?m%7R4 zv_yU6d_BW6V)fgKS(Ie0+O-=+OKW%3yvgz)g&8TreM;Bk{!G8(hbesC`94Kg#4o6I zy8D!lq78udzsWxP=<3{Oc{yybf?kRHb!5R5^*c`>aJ8A*nCZL!AT?OoRF1BD6i;Nf z+I%gJY*@&e!*)yK!A4oX^8|@(BFR0#W7%hevhUQK8d9fdN(xQz5wr8cBiV;1a-k9(`| z;^u2bym2;c6Fv}FU#`{yqvJ41z3e;ly3MIQ<;KU_ZLOf8=GJ0H)|{Wc33UIPXHg*u zTBKNOX6n0DKkCpIJ)c<@HU=R|wgBon`(c*!yRnjm;7GL~yCkOvIfV1aw!t)Ha|7L4 zQA5&&VZw=q4g!MEF^MF8zi}hyv5Vl%jrym_I1hcdhl|G-%yJa9K3J5v^OPmvtWUg;M*IR>s*ULl%;s z$JZxoM=2(52Mj>9_kSM!FP}$eC5}inm4sWhP!U<0i+PLTDWS^?yHdKbTxRQq&ytPh zvq|B%4}ShL{qg>>_`}9r)9v2PbAP5su3XgIJ$!wCN_X(D^qBWAf7BuIM;-sj{GZDH zf7oY-w-4?Qox!PF3PT&7ER!%5A(t*Z+t~io%qsK1Jq2fM-elg!wzTbyg)Al-!*EHt zOw7nmaP+6+nY-i25z{Y8bCL4$x^yQBB+UqxktZnKKv2iMf2c`tL#cGCn;xZyd8%$IB@wpQc z(m>Og`8;W2;@)LLp5)YQbgC7=R>h}Tq+uT0ggG0CfE#fE;?$}*C(U1y%fDkC_b_ZX zypZ8iNk?4f`ZVX*^VGJot*R3gSoZ$gw&;_))r<*K3o0pcd;B-Q#v2d#Q1b0z- zL&~uYej)SywnUx$-_J3j2-p&Uy{P*`_n=@o6OHQj=@^!rkQGHg_~%qYy>+R~%* z8?G4p<|Nen)$kp3XyQCZd07o&TCBG9wG2 zaf0W}Oa0B(!(vmXdbbe~Tk@Vo0ii>k{mv^!+P5@P%{>*xBbvDre#QCQX0_75D>>l# zyqP`QsLwYxzO=cRm(Kw0-}vT_d^-694-YC5{Zi&s$A@mI>venDz5g;3{pUZ9R61>e ztS8*JO3hD{WX!7fCHN(|D9W|L0mML9@Tf96ARZKjenwsJ-L$q$3xlXk{VeXAkyg>^ z0!Rv8rucp?rM+ib$6Z!~tl2+U%{-F|#|qT=z$#JoL~9-U0fCK7{mV*iStlUAlH%$X zbBT#uS(RZLo2_~lKImkt<%LPUqE%4Dbm`D=@hB7eNs0u9lL{&sJvLm;L?m^m_oxbz z6QaCI8inGDQf9=F#adN0OO$VS?sysVDlPcUlWH7kQE@j4DWOuA)!N`-7$sHQ&OS%v z3EoECu$Y-=L06inXoyyX!}j=^=z+%N;z0@8EKt4pqV5@F?;cIcG3gNjyodpex5B{S zeybNnQ|{DyHlc-U z)n9=!Yj#be^oIOD%%w@|*hA(CB9}A3npZE8L$%Jp#m!@=%gtR+9zG6;(3KvWchO5? zI+`;K(>)V2FanN=@wBDwH+*x{V%1OM;wG>Bmoy6lv$E6iGe1^V**z#tx>|T&tn+>l zbW<5z<+U7W;KSYgu32K3AS91#=k?EZzPhuPhi=Ah^Y`F|R>+~DU}d3#&jd7SW^v?+ z`_Pjbv!)?MJn*&qJOO6a(i#p)GNU0786~QjZVt`lfz32=V}4xM14sx|fO=_U!i|4M zReH-lSh<1Dcj@V0ruGZBhrSesyh);r=5k}lTvL}kc~I53R`NT~$fr-^%YohT!p$v} z6C2sQdKkOUvDdz(6%K#f=D?PF5$Wu)Gwm&%)A{@RFSu|UHRX~!N7Y)dGM1PFT)Ti~ zpjn;S4r{mOixf4b4c$BIJ)__eh<9hdZ^H#`K?R5kwD5?-gY4G{ufT|=t&K%nCzg{X zRG{bF!-+(kdvzs7rW1|=G#>q;G<9jfA!M&Q!TEa1o{x@pV~^AY>}&s^!FL1EX6~C* zHeu(jg)?VV5n~VK6|LmA2B~)RAgu){{=>A=!)e8?7qQ!}tTtyUC#cK z4&E5GGI|0aq?gPE3T_$JL}LD2YugnfsI}cY6^=FT%mv_38Ox zwl@u|)>H{3Z)-NtxI>$S=u;l(IUZKk(;zN2Ebri_^}zt2WWSKkv~>tm)koI7q!sbj zCAm~4h3pX4vkK6V8OH*tzJp1Oc>h{TM{wOj_T=%17q*{IleKPUK=NezxbYi4$3(tQ zH*xDrQD*&IQwW-2T6?M4g>fCYHiEZ=nH2fb(IwfQTe0%g9X{pWzG+X)`!hTGraOI} z?E8t$nn2YyKw!Lw?HOc2WA_??)^~WZ?jL6LmwEl)r(q^;Bxdi^)0Y|B7)ASA>3b0K zi0S*+-6HS_hn5k07q0wy*a=3?ezs>wPo_Ow>q$|%`e&%Bu++3Cnt9?KUn68kXLtjq z(KYsKA53{YEgue2=wV2;5Aqz98l>Gzf|nsXO8YbHU|b~S(^2i9RF$9O>re&fE}KN( zgn75k=vD+X-hTSq`Eed-b~>Fm(z@u+YH;!7c9HZ|t6Qad*Hb$9*?oRReRD%zZJysP zh&?1AD-#(hv?Y>GaaQlw$KN!>;UM^l?6|x+wYOztxuRmfr6d(!I;~2LyUq@*!VWFf ztgAL(4kTo2Eolt=SZmN#V4U|5kGWz*iwl6)ERN1K%popcADwC1nr$~LHMlc8mno^z zl?ncl+&nZ}EUI*AXxj1(WFT%8YvMeaZa}0!F-WzV4a+kNxZ(Al=O=FG%MUmQgd*dv6+J!BHVc#pr>e*hFG}6(ClIP0cFx1;-u5OhE;*{ie+Vne0yt@ z!Vk2`30TV_jhY|5Ki>Sv!!G`HLexZ1QBUv0H0|saQ9l5QTF@dT&~KkImRoT|Co-U5PP8qa}@aw^5?^r_2v5%^%@26gDDxI`>3JXr zRWhoecIz32;2^UV;B@cHkSDpbR%k!fkvRRxxO}7W-D-{83@}1D&IP{>~F0 z#`UAzk3AH2E56Tc{`OZ*r@sAb$R z>BwR^8E0u5x=CUP{ej}9wq&6J2U*Q`%*nbAKS&HP%6xFGq>;I7UNn!0ByL}LIpY$kY}(YnS0<{Rks z&unjPq{{>-Wh@}UNTe}ES>_}#oxR9dk#u@YMZ>-pewNRjkpmgZ-S;OPeyGlVa&52S zEK0yBEO14$?$;FrvsGRT&7g6TEnEVP0N#iRK~7jWBKb0cz4Oks(SV%K*I_M<6_`3* zXhhyAcbN`HRELl2Vs}h4nk(yhfSI6mFaY5sSc-J&nQO zjhV*NOCr@420=iDNZ$&omz$fg(5eqrJEhazsDJ0#B>h_ZKwJ7-vRpe9%CQH}NOPWQAucWoBhwN~QC_+Y3KZ9%vRwm?eTF}LT z;b z1kovbzP?m=${1k-NBrj}$XO!ANa9-YJ9M(2xxofqyCY<&6jM5HH9a#jl4!9ZYvFS!8MQ(qm)eOKRDJ!gnZ+JR=P4?bKWlijBuQM`5{uu1PxY9j&vY(M zyYy{p-H`v__j1Zht+q$xs*LO2;x=tuQ&DLH_2Y*xEww| zWR<)&Ti{C5C|tohTc|nQZ)rR?b|4db_N`Mhz}TSn1F{Wq+t(z!^37xYyr|D+q>JX| z;>!AM4`36CXvC9_MUDxVAog?lFWtC+pUlu@9v3HZw;;yw6!to!drpgy?`kSSPih2) zjhZH+%{J{!{0|aRHO;y3-7<4v5mt^}yLdwq6gVO9v}Zr&L(JwWUI*yGbJ9bOE}#>({d z;I6c?Fzcziu_zf?pIVWip?(Yi(>1@le)tL^W)>UtSY03P-$Zs3Kh>eu8$$-~P=xjI z;lEwgz723VgCf>|fIf*h;5mijNk$ah9zEY*DK>0X9KYEDG7Het(_>c=2{bqt>w7V{ z>JsKtO&%>SGkuvQW%@APeN~woTGZGE%0)oDh?%uTR(DA z#;B_`KLBbL`=~#0D#nMA=dQgK?$(#;k~9B(dhGm5i1P$s>A5K;r+!Cuno<6i^QO?; zvu7eMn=v(D5om;tII-*g8MQ|1t>+p>jXthwmQpq_50veRN?(J)_>NUoXA>~{H1Mb=jD=;|U zXA}qx7`<-1S{A0Q%?$}tDsV%ceA_SAs-^8YK+X6LLsx^52flgL$f(nrVjU=~aE*0Y zznxe`YPi0|B5;L$R3~YA^XV2ABt=U3zmR%CsQdVvC?2_sK;0IEv2GO+g&BgfAJJ(IHvuZe({ z^4U`_C!n}65%_snO4rAzC$iSos+Mu`NlZLsjX!2;VIb)Kse-xmN+$*8q- z?D3=O>-PKw=WJ6W7MYgaHcdm2sU2Kj%lgL0 z>*vi{^J6NoF7X8jlxRT=G!X+x^FgD+3ZFq^Vif%H;+$`o(_2lW4Gl_n{AgdDSMA+i z1RnG+ZEG^J#m2N<#5kTyN~Rz5JQFmQ);z2X5s8zi&WilK^|~+|j>&%Oe>x)O@K!-zXkvipZbS9zJy4 z&GNxLwmRW*(QLBj=g6`ccQ{7|Tr{}Ujr%@}69_qDfP4X7?j1R%6?gl#xV%;WPRGwr zIt*G(-v2Ra#dZfIywT^wjNvojk+)u6=LQ-=d!kXwO*P1AL%TAdiXNAwM(TvON3KN{ zN}apx&t4t`DaLOv7C)6EbLv zSBl-X121lcW>>6VotSh;b?K%JDt+E-VovLznKvPHGf%}Y)J8XS5Z`qkixEq z`>kc`I6UV4DcLmm@PeE^Ax4%UMwpS^V3d!XIPE?>h!b2%MD~}0F2?2#aQ9o6D&LrH$TegYTyqpopz>XJ-dWIXJ z!CZ%-!Wjj{6J0au=T8UPhL9d6M#rizU(ed>q<%d$JeMlZIaBNxTbgWH-|sguzqJwK za8C&RcG*(@0~vJg%ak(&Vr}(4YpkKazdInkatZ@8Tec218ol6yG`N$^%lbl?!}s>G zn*)l?Cttd`ByVdcw{Qdpldy-2p<|fLXGdF7p0s+j?nTGLx9Lvmm2{JkXfw(Iw^b$Uvee_??{eLs3 zt#9^{5aSb`w57D|YSIrp3~(vn_s>jUo>kfggqLr+Bw{Oso0Y=f1g=*KVCA%#wD1&; z%zymks!*X4gIsQW=kdPc?04`tSPzjNFYk?v8yRDTfFYaBnofi!HMsu0|I5>)hUDx# zHIQnYYrFI>@^Piim-&w3E zEey=}W@%S6bH^~WN9SIFKzej|QriA6?~W)w7{F~lXgo16o5@+>O7v__#6sR5GSl~d zeR?Ei7nt{=v536au5h(Yh?u);fEdy>_#bga{>>izw~HM~KPMl2(`2CLWpaM$x~BtT zN2#ZOqw3q0WAR5F2Ub3=f4upm)j~mR%r=bKU;Uxg3>5B)PK<)q|of@O~YoA+HUusp1fxn^tw7$*_g!87%n>Rkzm*&tF{pvyg#*+Ve zbjTV~yfuJu|7*zv`-FLGrlo!huaaymcKj|MyEjm1c)x#r@kvM`O{KSX|IH;bK2uZZ zb&dz#qk2}XxRzJb0^_#sIq(tajavlU!9N&OlTpjEWzv(`={b+{w)`YO1l-%5%#7mO z4^!eW3dKcfFubY)GdaGnsyss&c!IXj*`bEWkRG7>I$MXew>27l+PT;F@n)4*2JPd` zwJCj_>`TV?>H3l7zw>N_sY+C;?ZSj+N2|S~Yh|u- zLTkG@6`d%xT+Re7S1o)I(TTXpRTjP7ztM^EU)5B7r#GuQVZs|KTb0@Q%ER+LMaMK0 zJ^iL&EARf-ibJSXda|XB9bYQ3XMS>9p-kxR#YD#bt#Ok@hsRx?B=S;T0~FT zfZz@987i>NjGLgUThbVx^#!N&+dJSSf*O09ODT{Pz5j(RmON_?swd#TPvCfG=PHIr zxY88YxPC}H-z{?;%j`?@-CX-La?VIt znmEjyBse52r+*6_Lx(MTMOQ6SUSGjR{kH17ZAl*_iZWnCJk9&s^9LV&C$xU;6x!|- zL--=A7G?;^vomv&|G3CMGCJ9(FX?V4E1D^#?OA{wm?tS`Dt5raE)7A;xP*#b+x0(4 z4qQZA{{#os;I*3vAW+#2w4JBj49bR~?&ZUrKhr$1A;V&s(`DZ17+U`9S{|3K4+GW$ zpSu2o>+n~`H0n>lCxnzPmwo)BPfEw)0U!JX=x-c~3!nalLvf$yUpW+}SC0Ihka5rZ zUkDlhr?P+Mo&0arXMeD2OgHbA#9Qi75NVoA*hTwS`Z@)bbl*qkU&b!)8)3>Xbsv<; zHLgoO{cooGw=YuexQ9K3o%ws=3XZbJVf#m)7JnYT_AVzWYhmA$%dB!&ZGG#|%8^=8 zNwE^L>HVa3R{T!Wp=yodGhR)bm=~|)QH`q&;IQY^(*SD^GxMJ6Hf~kCj#im+=J~G9 zQ!QF=LBv(HLbab(;ll^&A4>OWdxn)jZH|*oNgJMA0wLze2yf>Jt)g6&RV~(hXVD}z z!##!a_HpR)85pcHsERQ)qt*7HITqfI2Pw;%%MLk@PD~lEdjQsG7)6b}`PI!UX;Nr4 znksVDG+?zsswxk_+1Qa*b7fh-QJ6scBeHucj0<(EpCRp2pW=LG!#0a0m4f;zW1=F1 zMYYrBGY(!sMZe?kpD;$Z#-&9`oDaT}PYkxkh3@nsIU@5N^@bO%ZCg&)Ufa%EMbm;6 z5mLP)p#dYT)0)z(V@Y82xST)j>+uw0!CqmbI6Nicuv~?u*0HoVS6Y|EWw#z>1rdiP z&Ew9ZBaizAv(bWOEb?bJ-xu_0jsEmw43Ea#a?HkG@P#Y1dy`#rjcCI&EVvLg= zoY#~}+Lk2&NdfLMc<#A%1?{?OeoC*F3dy%_DHioLXjF>t(5_pKSo2boG@UDrGo~rk3**c;Ofg*N%1AJByTAO5 z+7#O>DKKV6g)6s?D|mqI?3K zCZ9WtZ2hNt>Kaa}TTSODIo!+L(W%?{e$&?UYr4^hRRa*kBg(#qyY_E7;ssb{q~VrHFlws(v_ z3{D7M25ilA!m{&nY(IFK4r(fd*#?VE7X;+56^IKFV#;A08oUm#{1FQR#Mq1NmhOtLql{j-zSccad$Crg;Ql$A_52-MlEIxi ztR1*a3P9LW@%yHtx->Ffw5J;5JfTso?W)|X#Gi1M=nj#qo=w6Ck3Eq#TEg$CE#m@A z3Iv$U-+6?b{_}$Wq+J`XNJ_Qy|dH*sLp?{@R6k=7VKjgZ*cbq?3~y6YN!jC#e^q z#+6!aJ6&msqg}TUnPzwauzX&lz@VrRjJwUcuzGh968#vmlsh`%)|K?FA48oxO8FD6 zJkMJ>l(+VHMaiMSd1_K~O+Hf&bn_t&2v|xtMocpw7YJXQpK;*|eIVB}a!w3}Q>Opr zP@VhdrTuGj68sBIC;cA6<>146yQ(HDI`@UI*(_hXV>cT^9eKBH&G>rW{%?q-5wE-; zC$Y8RCN1Xma6gT^p})DE$Fd1lmF1{$Y`+i^&WbX)Z|#RahW&YYNB+c1PuSh5qYayv zi_lkdv0g#qtJ5ho7^QbhX7;9$@HMs7_GF==>swZ8IzorzPecBGuw_28MAO%+2x)mr zu0Tz>H6wA&A-gb2lwVRI=5qzR_89`WbPlWt|Md4}Pm;aY;4d<{=XNaJ_ob*2JRhnF z>gXjAO)bV`oIL|pR8Oh&Pg6keoc`XVMj>vFXbxFST)zvbq&0Wg>YQ6$vXyB17U~f7 zP-pv3p!lzw*h1u3wv7)st6k3l%2qcqQgFCVgtVa8Wf|ZUQ8g%ZpKG#*sY#hAP zFvTLOBh`w15HFG-6hRn5fPh&1G4$`}8NIq(JzSjMQ7igzA|})rY1AKL6RapTRf5X8 zyI}TWa$n;a=TA)hFJN85&*w&SrcZ5B!EtRowJqZI)|wXMH9FxGXdhMd^3b{9q@ZGD z_`AQ~v+ovf6qyA&IoP4D&w#jb-oWLAs}tDEtCll@8VcpU_5=t(#^g!wg@MvHji1NSm8cj@ap}Q>7BIg@7B!*XB$oreVJS@ZLm}(2-v;q-XWlV$qXoeZEx0a zUYuOg8MzjF)w9x>Mqh0Z82-Q7JM&;Rv^|cizTSIledE^EQruTs)Y_tUE}d4%B}zj{ zQdL!3VhN#<*s7@3Qng(bTuVX`+Jr<9RBb`2hAKhqYht~XXmDTNyuSBG|9dlUrf=r? z|NPE5^F8a#`JK;~k6=Z=9R5Ydh;m(A_WrAWSLfcd5OVT+KsGA4v zqimiBb}8%rL~qemh~4&tCN-$Ha&ZxpYcJHCaHpFiEm3WZHc%s?5Eh@-8I4Nl8WWk|F)BezAW#q+Uwh+n@p4A5JwT)S_v>3Q1mm*;S%D>s<=YpT;(P z+BqSBjC6)tYYT%WXQ8e;P_qoMWsix)YjeS$JQFD+W3TsbH+(!swTyYnA`{ zgXYjtZ+(tf0O$$&xrog1(@WShANH6_Pc-ZRat-)K+;RJx&~}_(p_tbsRhbut4q{W$ zX80zdkJ&{@QK3tH#1`Rvorh(=st|+Fg!;UU?XK|UZG~4zH-!4DIn#(56j15$d;n9A ze0_}tqG;}$dxC}BR(9X@cH(uOvuF);s|&%8XR=ql_@s0B>DI9`ZKxM1Xn3!mKM*H4!MI`9JJR4-eA3NtYc>i^yCFBC_lMPn?Q!Z1Bk6V zTJAYio~l&AcXq$OiUX?{8rE_{7MPHN&gzKjkZM0g#x3~HwiZ}+Lzv;# zU8bZi)7b3N*6=q7dlWymN}jKy^7I9WjVzS-0t4f5>~7gz&yJQK3wcyr?|Xq$WKBX7 zM6Ic+Drs2L>O~Jk>R|~{fP^#uM^3wmaA z{2vz=9p-pF3;jw8t_j6bqsKO%60;?Q;U z{pQ~64TSt zOSy<=y+jN|7XgU>l*(a`NGiRZc-cl089(m#WAnY6$#FBR>jF3i8 zk5;Tg>lh*09C?>}09`v$t9N3GDlj2{>ddjxpCe~-1L6CnZ^0BZ7PQHe+Pf=2qXhS>SDoX;z(hAn_6=BI z?`Qx^)ITY3q{b;L#yAYe6w|1(1Agb0ycl~g(cVn29vIhF^14NE(v!9t5RlKOw$Z*x zIb-?WaW@+krP!+1%NELA$r?QW*)g?X>!gk{WyxT93=E@evlIoh37<>1>sSY?(FcY5rV=X{Rb9Q#hCwAMX@qUXV^)B$Y*&_gUD|y+qWFD!?q6< XI8@+JfkOoj6*yGje@Ow!KS%!#rUC+j literal 0 HcmV?d00001 diff --git a/docs/features/mcp-connectors.md b/docs/features/mcp-connectors.md index 3b2ee54db..46baeee8e 100644 --- a/docs/features/mcp-connectors.md +++ b/docs/features/mcp-connectors.md @@ -1,170 +1,185 @@ -# MCP Connectors +# MCP connections -MCP connectors let **external AI clients drive this Instatic instance** over the [Model Context Protocol](https://modelcontextprotocol.io). Instatic acts as an **MCP server**: a local client (Claude Code, Codex, Cursor) or a remote agent connects, lists the available tools, and operates the CMS — reading the site, editing page structure, and managing content — exactly the way the built-in AI panel does. +MCP connections let external AI clients operate an Instatic instance through the [Model Context Protocol](https://modelcontextprotocol.io). Instatic is the MCP server: clients list its capability-filtered tools, run headless reads, relay editing tools to the connection owner's open workspace, and explicitly publish completed drafts. -This is the mirror image of the **Providers** tab (`server/ai/credentials/`), which points Instatic's *own* agent outward at LLM providers. MCP connectors point inward: they let outside agents reach in. +This is the inverse of the **Providers** tab. Providers let Instatic call model APIs; MCP connections let outside clients call Instatic. -The server is implemented with the official `@modelcontextprotocol/sdk`. That package is banned everywhere else in the tree (the AI drivers hand-roll provider REST); it is allowed **only under `server/ai/mcp/`**, scoped by `ai-driver-isolation.test.ts`. +The wire server uses `@modelcontextprotocol/sdk`. That dependency remains allowed only under `server/ai/mcp/`; provider drivers continue to use their direct REST implementations. ---- +## Connection modes -## TL;DR +The UI exposes two credential lifecycles instead of a cosmetic local/remote switch: -- **Instatic is an MCP server.** One Streamable-HTTP endpoint at `/_instatic/mcp` serves both local and remote clients (local is just `localhost`). -- **Thin adapter over the existing tool engine.** No tool logic is duplicated. MCP is a new *caller* alongside the built-in agent and the plugin host; tool dispatch reuses `executeAiTool`. -- **Tool surface = the full catalog.** Server-resolved tools (content reads, `site_list_documents`, `site_read_styles`, and explicit `site_publish`) run headless — no editor needed. Every browser-execution tool the agent panel has is exposed too, **relayed to the matching open Site or Content workspace** — the single source of truth for edits. If that workspace is not open, its tools return a clear, scope-specific error; headless tools still work. -- **Draft, then publish.** Browser writes save the draft and never leak intermediate work to visitors. A connector with `ai.tools.write` + `pages.publish` calls `site_publish` once after its edit sequence; that server-side tool runs the canonical full-site pipeline and atomically swaps the rebuilt static slot. -- **Bearer-token auth, one secret per connector.** The token is shown once on creation and stored only as a SHA-256 hash. New tokens expire after 90 days by default; admins can choose a custom TTL or explicitly create a non-expiring token. Revocable. -- **Capability-gated.** A connector carries a granted capability subset; the same gate the built-in agent uses (`toolAllowedForCapabilities`) filters the toolset. An MCP caller can never invoke a tool the granting capabilities couldn't authorize over HTTP. -- **Privilege floor.** An admin can only grant capabilities they themselves hold. -- **Managed from the admin UI:** AI workspace → **MCP** tab. Minting a long-lived connector secret is step-up authenticated. +| Mode | Intended client | Authentication | Lifecycle | +|---|---|---|---| +| Hosted OAuth | Claude custom connectors and other remote MCP clients | OAuth authorization code with S256 PKCE | The client discovers the authorization server, dynamically registers, and sends the user to Instatic for consent. Access tokens last up to one hour; refresh tokens rotate; the grant expires after 90 days. | +| Personal access token | Claude Code, Codex, Cursor, local bridges, and clients that accept an explicit header | `Authorization: Bearer imcp_pat_…` | Created after step-up authentication, shown once, scoped to selected capabilities, configurable expiry, independently revocable. | ---- +Both modes resolve to the same persistent connection grant and the same `toolAllowedForCapabilities` gate. OAuth does not create a wider tool path. -## Architecture +The MCP endpoint is always: -``` -MCP client (Claude Code / Codex / remote agent) - │ JSON-RPC over Streamable HTTP - ▼ -server/router.ts → /_instatic/mcp (tryServeMcp) - │ -server/ai/mcp/transports/http.ts WebStandardStreamableHTTPServerTransport (Web Request/Response) - │ -server/ai/mcp/auth.ts Bearer token → connector → capability set (401 + WWW-Authenticate otherwise) - │ -server/ai/mcp/server.ts low-level SDK Server; tools filtered by capabilities - │ -server/ai/mcp/registry.ts AiTool registry → MCP tools (TypeBox inputSchema sent verbatim as JSON Schema) - │ -executeAiTool(...) / live editor bridge - ▼ -repositories (headless reads) / live editor store (browser tools) +```text +https:///_instatic/mcp ``` -### Module layout — `server/ai/mcp/` - -| File | Responsibility | -|---|---| -| `transports/http.ts` | Mounts the SDK's Web-standard Streamable-HTTP transport; stateless per request (`enableJsonResponse`). | -| `auth.ts` | Bearer resolution → `{ connectorId, userId, capabilities }`; spec-correct 401 with an RFC 9728 `resource_metadata` pointer. | -| `server.ts` | Builds a capability-scoped low-level `Server` (`ListTools` / `CallTool` handlers). Uses the low-level `Server`, not `McpServer.registerTool`, because the latter needs Zod (banned) — this lets the TypeBox `inputSchema` pass through verbatim. | -| `registry.ts` | Headless reads plus the browser-relayed site/content catalog, deduped by name and filtered by `toolAllowedForCapabilities`. | -| `tools/documentTools.ts` | `site_list_documents` — pages, templates, and visual components, headless from the DB. | -| `contentAuthorization.ts` | Re-checks own-vs-any connector grants against the target content row before a browser-relayed mutation. | -| `tools/styleTools.ts` | `site_read_styles` — the design system as a CSS stylesheet, headless from the DB. | -| `tools/publishTool.ts` | `site_publish` — explicit server-side full-site publish through `publishDraftSite`, including the Layer-A static slot and MCP audit metadata. | -| `editorBridge.ts` | Per-user, per-scope live workspace bridge registry + `createEditorBridgeStream`; browser tools route to the owner's matching Site or Content workspace. | -| `handlers/editorBridge.ts` | `GET /admin/api/ai/editor-bridge?scope=site|content` — the capability-gated NDJSON stream each workspace holds open. | -| `connectors/` | `types.ts` (server-only record), `token.ts` (generate + SHA-256 hash), `store.ts` (CRUD + `toConnectorView`). | -| `handlers/connectors.ts` | `/admin/api/ai/mcp/connectors` CRUD, gated by `ai.providers.manage`. | +Hosted clients run in their provider's infrastructure. They cannot reach `localhost`, a private LAN address, or an HTTP-only deployment. The MCP tab detects this and warns until Instatic's canonical public origin is HTTPS. ---- +## Hosted OAuth flow -## Tool surface +Instatic implements the MCP authorization-server surface directly: -MCP exposes the **full tool catalog** (deduped by name), capability-filtered. Tools fall in two execution classes: +| Endpoint | Purpose | +|---|---| +| `/.well-known/oauth-protected-resource` | RFC 9728 protected-resource metadata for the MCP resource. | +| `/.well-known/oauth-protected-resource/_instatic/mcp` | Path-aware protected-resource discovery alias. | +| `/.well-known/oauth-authorization-server` | Authorization-server metadata. | +| `/_instatic/oauth/register` | Dynamic client registration for public clients. | +| `/admin/ai/oauth/authorize` | Signed-in consent screen. | +| `/_instatic/oauth/token` | Form-encoded authorization-code and refresh-token exchange. | + +The authorization sequence is: + +```text +Hosted MCP client + → reads protected-resource and authorization-server metadata + → dynamically registers its exact callback URI + → opens /admin/ai/oauth/authorize with resource + state + S256 challenge + → user signs in, reviews the client, selects capabilities, and completes step-up + → Instatic redirects a one-time code to the registered callback + → client exchanges code + verifier for opaque access and refresh tokens + → client calls /_instatic/mcp with the access token +``` -**Single source of truth.** All page *editing* goes through the **live editor store** (browser tools, relayed to the open editor). There is deliberately **no** headless DB-mutating page-tree tool: an earlier `read_page_tree`/`mutate_page_tree` pair edited the DB directly, creating a second copy of each page with identical node ids that desynced from the open editor and got clobbered by its autosave (data loss). They were removed — structure editing uses the editor's browser tools, which the existing save-flush persists. +Security properties: -**Server-resolved — work with no workspace open:** -- Content reads — list/read collections, entries, data rows, media. -- `get_context({ entryId? })` — orientation in one call: whether the Site and Content workspace bridges are connected, which "everywhere"/post-type templates wrap pages, and the site name. Call it first if a browser tool returns an "open the workspace" error. -- `site_list_documents` — editable pages, templates, and visual components with document references, root node ids, template metadata, and summaries. Nothing is marked active/current because headless calls have no editor focus. -- `site_read_styles({ format?, className?, includeTokens? })` — the design system as a **CSS stylesheet**: design tokens (CSS custom properties) + every class/ambient rule, read straight from the DB via the publisher's emitters. `format:"summary"` returns a compact class catalog (selector + referenced token vars, no declarations) to scan first. Symmetric with reading pages as HTML / writing CSS via `site_apply_css`. Replaces the old snapshot-dependent `list_tokens`. -- `site_list_breakpoints` — configured viewport ids/labels/widths (the first is the base), so `site_render_snapshot` can target one deliberately. Headless version replaces the snapshot-dependent one. -- `site_publish` — deploys the **saved** draft. It requires `ai.tools.write` + `pages.publish`, calls `publishDraftSite` with the server's real uploads directory, rebuilds HTML/CSS/runtime assets into the inactive static slot, swaps it atomically, bumps the publish cache version, and records `source: "mcp"` plus the connector id in the publish audit event. +- Public clients only: no generated client secret is required or stored. +- Redirect URIs are registered exactly. HTTPS is required except for HTTP loopback callbacks used by native clients. +- Authorization codes expire after five minutes, are stored only as SHA-256 hashes, and can be consumed once. +- S256 PKCE, exact client id, callback, and MCP `resource` binding are checked during exchange. +- OAuth access tokens are opaque, stored only as hashes, bound to the exact MCP resource, and expire after at most one hour. +- Refresh tokens rotate on every use. Reuse of a rotated token revokes the connection and all of its tokens. +- OAuth grants expire after 90 days. Disconnecting the connection immediately invalidates its access and refresh tokens. +- The consent API requires `ai.providers.manage`; approval also requires a fresh step-up window. The approver cannot delegate a capability they do not hold. +- Dynamic-registration client names are self-declared. The consent screen surfaces the exact callback and tells the approver to verify that they initiated the request and recognize the address. +- The client-supplied `state` value is round-tripped on success and denial. Redirects are generated only from the callback already registered for that client. -Site and content writes deliberately do **not** call `site_publish` automatically. A multi-step agent edit can involve many tool calls; publishing each intermediate call would expose incomplete work, bypass the user's explicit deployment intent, and repeatedly run the expensive full-site pipeline. The client should finish and verify its draft changes, then call `site_publish` once when publication was requested. +### Claude Desktop / Claude custom connector -**Browser-relayed (via the live workspace bridge) — require the matching workspace:** -- Structure editing — `site_insert_html`, `site_replace_node_html`, `site_delete_node`, `site_move_node`, `site_duplicate_node`, `site_rename_node`, `site_update_node_props`. -- HTML/CSS authoring (`site_apply_css`, `site_assign_class`, `site_remove_class`), page lifecycle (`site_add_page`, …), design tokens (`site_set_color_tokens`, …), content CRUD (`content_create_document`, `content_set_document_field`, …), code assets, structure reads (`site_read_document`), and live-DOM reads (`site_render_snapshot`, `site_get_node_html`). -- These have no server implementation — their logic runs in the browser against the live workspace state. Site tools route to `SitePage`; content tools route to `ContentPage`. Image attachments (e.g. `site_render_snapshot`'s PNG) come back as MCP image content blocks. No matching workspace connected → a clear error asking the operator to open that workspace. -- `content_create_document` always creates a draft. Publication is a separate `content_set_document_status` call, which carries the content-publish capability gate; scheduled status uses the same explicit status tool. +1. Deploy Instatic at a public HTTPS origin and configure that origin through the normal server public-origin configuration. +2. In Instatic, open **AI → MCP** and copy the **Remote MCP URL**. +3. In Claude, open **Settings → Connectors**, add a custom connector, choose a name, and paste the URL. +4. Leave **OAuth Client ID** and **OAuth Client Secret** empty. Claude can dynamically register as a public PKCE client. +5. Choose **Connect**. The browser returns to Instatic, where the user selects capabilities and approves the connection. -## Live editor bridge +The resulting OAuth connection appears automatically under **Authorized connections**. No Instatic token is copied into Claude. -`server/ai/mcp/editorBridge.ts` keeps one bridge per `(userId, scope)` (newest connection for that scope wins). A connector can only reach **its own owner's** workspaces, while the owner's Site and Content pages may stay connected at the same time. +## Personal access tokens -``` -MCP browser-tool call Matching workspace (open in a browser) - │ executeAiTool(browser) │ useMcpWorkspaceBridge(scope, dispatcher) - ▼ ▼ -buildMcpServer → getEditorBridgeForUser(userId, tool.scope) - │ bridge.callBrowser(tool, input) → emits toolRequest ─────────────▶ Site or Content dispatcher - │ │ (live workspace) - ◀───────────── POST /admin/api/ai/tool-result ◀── postToolResult ◀───────┘ -``` +In **AI → MCP**, choose **Create access token**, name the device/client, choose an expiry and capabilities, then complete step-up. The plaintext `imcp_pat_…` token is returned once; only its SHA-256 hash is stored. -- Browser side: `useMcpWorkspaceBridge` opens the scope-qualified NDJSON stream, runs each `toolRequest` through the SAME Site or Content dispatcher as the built-in agent panel, and POSTs the result back. It reconnects with backoff. `SitePage` flushes pending draft changes before reporting a successful tool result, so a follow-up headless read or `site_publish` sees the persisted edit immediately; a failed save makes the MCP tool fail instead of silently publishing stale data. `ContentPage` registers its bridge whenever the workspace is mounted, independent of whether the AI panel is visible. -- Server side: reuses the chat bridge machinery wholesale — `createBridge` issues the `AiBrowserBridge`, `resolveBridgeToolResult` settles it from the existing `/admin/api/ai/tool-result` endpoint. +Claude Code example: -This is why an open editor (yours, or one the agent opens) unlocks the full editing surface without reimplementing any tool. +```sh +claude mcp add instatic --transport http http://localhost:3000/_instatic/mcp \ + --header "Authorization: Bearer imcp_pat_…" +``` ---- +Claude Desktop can also reach a local server through a local stdio bridge. The UI generates a ready-to-copy `mcp-remote` configuration that keeps the token in an environment variable instead of embedding it in the command arguments. -## Authentication +Personal tokens are deliberately not the hosted-connector setup path. The hosted Claude connector form does not provide a general custom-header field, and a cloud-hosted connector cannot reach a local-only endpoint. -Each connector has a bearer secret (`imcp_…`). The client sends `Authorization: Bearer `. The server hashes the presented token and looks up a non-revoked, non-expired connector, yielding its capability set. Missing/invalid/expired tokens get a `401` with `WWW-Authenticate: Bearer resource_metadata="…/.well-known/oauth-protected-resource"`. +## Architecture -Works today with Claude Code, Cursor, Claude.ai custom connectors, and custom remote agents. +```text +MCP client + │ Streamable HTTP + OAuth/PAT bearer + ▼ +server/router.ts + ├─ OAuth metadata / registration / token endpoints + └─ /_instatic/mcp + ▼ +server/ai/mcp/auth.ts + bearer → OAuth access token or personal token → connection capability set + ▼ +server/ai/mcp/server.ts + registry.ts + capability-filtered MCP tools + ▼ +executeAiTool(...) / live editor bridge + ├─ repositories and publisher for headless tools + └─ connection owner's open Site or Content workspace for browser tools +``` -Managed connector UIs that require an OAuth flow are not compatible with the current bearer-token implementation. +### Module layout ---- +| Module | Responsibility | +|---|---| +| `paths.ts` | Canonical MCP, OAuth, metadata, and consent paths. | +| `oauth/protocol.ts` | Issuer/resource construction, supported scopes, redirect validation, PKCE validation, safe callback generation. | +| `oauth/schemas.ts` | TypeBox schemas for dynamic registration and form/query parsing. | +| `oauth/handler.ts` | Public metadata, dynamic registration, and token endpoints with protocol-shaped errors and rate limits. | +| `oauth/store.ts` | Registered clients, hashed one-time codes, token issue/rotation, resource-bound access lookup. | +| `handlers/oauthAuthorization.ts` | Admin-session consent read/decision API, capability floor, and step-up gate. | +| `handlers/management.ts` | Connection overview, personal-token creation, and revoke/disconnect. | +| `connectors/store.ts` | Persistent connection grant and the token-free `toConnectionView` projection. | +| `connectors/token.ts` | Opaque secret generation, hashing, and PKCE challenge calculation. | +| `auth.ts` | Resolves OAuth access tokens or personal tokens to `{ connectorId, userId, capabilities }`; returns a discovery-aware 401 otherwise. | +| `transports/http.ts` | Stateless Web-standard Streamable HTTP transport. | +| `server.ts` / `registry.ts` | Low-level SDK server, TypeBox input schemas, catalog deduplication, and capability filtering. | +| `editorBridge.ts` | Per-user, per-scope live workspace bridge. | +| `tools/publishTool.ts` | Explicit canonical full-site publish with MCP audit metadata. | -## Connecting a client +## Tool execution model -Create a connector in **AI → MCP**, complete the step-up prompt if the session is not already fresh, choose its type and capabilities, then copy the token (shown once). +MCP exposes the full deduplicated tool catalog, filtered by the connection's capabilities. -**Local (Claude Code / Codex / Cursor):** +Server-resolved tools work without an editor open. They include content reads, `get_context`, `site_list_documents`, `site_read_styles`, `site_list_breakpoints`, and explicit `site_publish`. Publishing requires `ai.tools.write` plus `pages.publish`, runs the canonical full-site pipeline, swaps the static slot atomically, and records the connection id in the publish audit event. -```sh -claude mcp add instatic --transport http http://localhost:3000/_instatic/mcp \ - --header "Authorization: Bearer imcp_…" -``` +Browser tools run against the connection owner's live workspace. Site structure, HTML/CSS, page lifecycle, design-token, content mutation, code-asset, and live-DOM tools route to the matching open Site or Content workspace. If that workspace is not open, the tool returns a scope-specific error while headless tools remain available. -**Remote:** point the client at `https:///_instatic/mcp` and send the token as an `Authorization: Bearer` header. +There is intentionally no headless page-tree mutation path. The open editor store is the single source of truth for draft edits; a second DB mutation path would desynchronize node state and risk autosave overwrites. Successful relayed edits flush the draft before returning, so a following headless read or explicit publish sees the saved result. ---- +Writes remain drafts. Clients should finish and verify an edit sequence, then call `site_publish` once only when deployment was requested. ## Data model -`ai_mcp_connectors` (migration `018` plus additive expiry migration `019`, PG + SQLite parity): +`ai_mcp_connectors` remains the persistent owner/capability grant (migrations `018` and `019`): -| column | notes | +| Column | Notes | |---|---| -| `id`, `user_id`, `label` | owner + display name | -| `type` | `local` \| `remote` | -| `auth_mode` | `bearer` for every connector created by the current UI/API. The schema also accepts `oauth` as a reserved storage value, but no OAuth flow creates or authenticates those rows today. | -| `token_hash` | SHA-256 of the secret; never the plaintext. Unique. | -| `capabilities_json` | granted capability subset | -| `created_at`, `last_used_at`, `revoked_at` | lifecycle; revoked tokens fail auth | -| `expires_at` | token expiry; new tokens default to 90 days, `NULL` means explicitly non-expiring or grandfathered | - -The wire-safe `McpConnectorView` (the only HTTP-returned shape) includes `expiresAt` but never includes the hash — gated by `ai-mcp-connectors-never-leak.test.ts`. Create and revoke are audited (`ai.mcp_connector.created` / `ai.mcp_connector.revoked`). +| `id`, `user_id`, `label` | Connection identity and owner. | +| `auth_mode` | `bearer` for personal access tokens, `oauth` for hosted grants. | +| `token_hash` | Personal-token hash; `NULL` for OAuth connections. | +| `capabilities_json` | Granted capability subset. | +| `created_at`, `last_used_at`, `revoked_at`, `expires_at` | Shared lifecycle. | +| `type` | Legacy storage discriminator retained in the existing live schema; it is no longer exposed as a product choice or wire field. | ---- +Migration `021_mcp_oauth` adds, in both dialects: -## Capabilities +- `ai_mcp_oauth_clients`: dynamically registered public clients and exact callback lists; +- `ai_mcp_oauth_codes`: hashed, short-lived, one-time PKCE authorization codes; +- `ai_mcp_oauth_tokens`: hashed access/refresh tokens with kind, client, scope, resource, expiry, and revocation state. -Connector management is gated by `ai.providers.manage` (the AI-integrations admin surface), and connector creation additionally requires a fresh step-up window because it mints a long-lived delegated secret. A connector's granted capabilities flow straight into the existing tool gate: +The wire-safe `McpConnectionView` never includes a token or hash. Personal-token creation is the only response that contains a plaintext personal token. OAuth codes and tokens stay on the protocol endpoints and never appear in list/read APIs. -- mutating tools require `ai.tools.write`; -- page-tree edits require any of `site.structure.edit` / `site.content.edit` / `site.style.edit` / `pages.edit`; -- full-site deployment additionally requires `pages.publish`; -- own-vs-any content grants are re-checked against the target row before browser relay, so the owner's broader admin cookie cannot widen a restricted connector token; -- reads require any site/content read grant. +Create and manual revoke actions retain the existing `ai.mcp_connector.created` and `ai.mcp_connector.revoked` audit events, with the authentication mode included in create metadata. -An admin cannot grant a capability they do not hold (enforced in `handlers/connectors.ts`). +## Capability enforcement ---- +- Management and consent require `ai.providers.manage`. +- Token creation and OAuth approval require step-up authentication. +- Mutating tools require `ai.tools.write`. +- Page-tree edits require the matching site/page edit capabilities. +- Full-site deployment additionally requires `pages.publish`. +- Own-vs-any content permissions are checked again against the target row before browser relay. +- The approver can grant only capabilities they hold. ## Tests -- `server/ai/mcp/connectors/{token,store}.test.ts` — token hashing, expiry, and store CRUD. -- `server/ai/mcp/{registry,auth,server,contentAuthorization,transports/http}.test.ts` and `server/ai/mcp/tools/documentTools.test.ts` — capability filtering, headless document listing, bearer auth + 401, row ownership, scoped workspace relay, full MCP round-trip, HTTP handshake. -- `server/ai/mcp/publishTool.test.ts` — explicit MCP publish rebuilds and swaps the real static CSS/HTML slot and records connector audit metadata. -- `src/__tests__/ai/mcpConnectorsHandler.test.ts` — CRUD, step-up, privilege floor, capability gating. -- `src/__tests__/architecture/ai-mcp-connectors-never-leak.test.ts` — token never serialized. +- `server/ai/mcp/oauth/handler.test.ts` covers discovery metadata, public dynamic registration, and callback policy. +- `server/ai/mcp/oauth/store.test.ts` covers PKCE exchange, exact binding, one-time codes, access lookup, refresh rotation/reuse, and revocation. +- `server/ai/mcp/auth.test.ts` covers personal and OAuth bearer resolution plus the protected-resource challenge. +- `src/__tests__/ai/mcpOAuthAuthorizationHandler.test.ts` covers signed-in consent, capability selection, exact callback redirects, denial, and privilege floors. +- `src/__tests__/ai/mcpConnectorsHandler.test.ts` covers connection listing, personal-token creation, step-up, revoke, and privilege floors. +- `server/ai/mcp/e2e.test.ts`, `transports/http.test.ts`, and `publishTool.test.ts` cover the real MCP request flow and publish path. +- `src/__tests__/architecture/ai-mcp-connectors-never-leak.test.ts` gates the token-free connection projection. diff --git a/docs/reference/architecture-tests.md b/docs/reference/architecture-tests.md index 2c8d56a8d..4ca6a489c 100644 --- a/docs/reference/architecture-tests.md +++ b/docs/reference/architecture-tests.md @@ -173,7 +173,7 @@ See [docs/features/plugin-system.md](../features/plugin-system.md). | `ai-tool-input-object.test.ts` | Every provider-facing site/content tool schema has a plain `type: object` root with no root-level `anyOf`, `oneOf`, or `allOf`, matching Anthropic's tool-schema contract while keeping one cross-provider registry. | | `ai-tool-schema-ssot.test.ts` | Site write-tool input schemas live once in `src/core/ai/toolSchemas.ts` (re-exported from `@core/ai`). Every registered tool's `inputSchema` is the exact object from `@core/ai` (referential identity check); consumer modules (`writeTools.ts`, `executor.ts`, `tokenRunners.ts`) import from `@core/ai` and do not redeclare local copies. | | `ai-driver-isolation.test.ts` | Provider SDKs (`@anthropic-ai/claude-agent-sdk`, `@openai/agents`, `@openrouter/agent`), `zod`, and `@anthropic-ai/sdk` are banned repo-wide with no allowed callers. `@modelcontextprotocol/sdk` is **scoped**: allowed only under `server/ai/mcp/` (the MCP server), banned everywhere else (drivers + browser). Drivers talk directly to each provider's REST API over HTTP/SSE and pass TypeBox schemas through as JSON Schema. `src/` and `server/` are both scanned. | -| `ai-mcp-connectors-never-leak.test.ts` | The MCP connector wire projection (`toConnectorView`) and `McpConnectorViewSchema` never expose the token or its hash. Mirrors `ai-credentials-never-leak.test.ts`. | +| `ai-mcp-connectors-never-leak.test.ts` | The MCP connection wire projection (`toConnectionView`) and `McpConnectionViewSchema` never expose personal access tokens, OAuth tokens, or their hashes. Mirrors `ai-credentials-never-leak.test.ts`. | | `ai-driver-shared-helpers.test.ts` | Two cross-provider helpers must have exactly one source: (1) `parseToolArguments` is exported only from `server/ai/drivers/http/toolArgs.ts` — every driver imports it; private copies (`parseJsonOrEmpty`, etc.) are banned. (2) `SYSTEM_PROMPT_DYNAMIC_BOUNDARY` is declared only in `server/ai/runtime/types.ts` — prompt builders and drivers import it. Divergent copies silently produce different error handling or break prompt caching per provider. | | `ai-handlers-capability-gated.test.ts` | Every handler under `server/ai/handlers/` calls `requireCapability` or `requireAnyCapability` before doing work. Prevents unauthenticated access to AI endpoints. | | `ai-credentials-never-leak.test.ts` | AI handler response bodies do not contain credential ciphertext or raw `apiKey` fields. Handlers must project through `toCredentialView()` before serialising a `CredentialRecord`. | diff --git a/server/ai/handlers/index.ts b/server/ai/handlers/index.ts index d8722f62c..51a28205a 100644 --- a/server/ai/handlers/index.ts +++ b/server/ai/handlers/index.ts @@ -17,7 +17,8 @@ import { tryHandleAiCredentials } from './credentials' import { tryHandleAiConversations } from './conversations' import { tryHandleAiDefaults } from './defaults' import { tryHandleAiModels } from './models' -import { tryHandleAiMcpConnectors } from '../mcp/handlers/connectors' +import { tryHandleAiMcpManagement } from '../mcp/handlers/management' +import { tryHandleMcpOAuthAuthorization } from '../mcp/handlers/oauthAuthorization' import { tryHandleAiEditorBridge } from '../mcp/handlers/editorBridge' export function tryHandleAi( @@ -39,7 +40,8 @@ export function tryHandleAi( // generic credentials/:id route — both live inside the credentials // handler so the order is handled there. return ( - tryHandleAiMcpConnectors(req, db, pathname) ?? + tryHandleMcpOAuthAuthorization(req, db, url, pathname) ?? + tryHandleAiMcpManagement(req, db, pathname) ?? tryHandleAiEditorBridge(req, db, pathname) ?? tryHandleAiAudit(req, db, url, pathname) ?? tryHandleAiChat(req, db, pathname) ?? diff --git a/server/ai/mcp/auth.test.ts b/server/ai/mcp/auth.test.ts index 57842a30b..f6fe8f7c2 100644 --- a/server/ai/mcp/auth.test.ts +++ b/server/ai/mcp/auth.test.ts @@ -3,9 +3,18 @@ import { createSqliteClient } from '../../db/sqlite' import { sqliteMigrations } from '../../db/migrations-sqlite' import { runMigrations } from '../../db/runMigrations' import type { DbClient } from '../../db/client' -import { createConnector } from './connectors/store' -import { generateConnectorToken, hashConnectorToken } from './connectors/token' +import { createBearerConnection } from './connectors/store' +import { + generatePersonalAccessToken, + hashMcpSecret, + pkceChallengeForVerifier, +} from './connectors/token' import { resolveMcpAuth, unauthorizedResponse } from './auth' +import { + createOAuthAuthorizationGrant, + exchangeAuthorizationCode, + registerOAuthClient, +} from './oauth/store' async function freshDb(): Promise { const db = createSqliteClient(':memory:') @@ -22,10 +31,10 @@ beforeEach(async () => { db = await freshDb() }) describe('mcp auth', () => { it('resolves a valid bearer token to a connector + capabilities', async () => { - const token = generateConnectorToken() - await createConnector(db, { - userId: 'u1', label: 'L', type: 'remote', - capabilities: ['ai.chat', 'content.manage'], tokenHash: await hashConnectorToken(token), + const token = generatePersonalAccessToken() + await createBearerConnection(db, { + userId: 'u1', label: 'L', + capabilities: ['ai.chat', 'content.manage'], tokenHash: await hashMcpSecret(token), }) const req = new Request('http://x/_instatic/mcp', { headers: { Authorization: `Bearer ${token}` } }) const res = await resolveMcpAuth(req, db) @@ -47,9 +56,9 @@ describe('mcp auth', () => { }) it('rejects a revoked connector token', async () => { - const token = generateConnectorToken() - const rec = await createConnector(db, { - userId: 'u1', label: 'L', type: 'remote', capabilities: ['ai.chat'], tokenHash: await hashConnectorToken(token), + const token = generatePersonalAccessToken() + const rec = await createBearerConnection(db, { + userId: 'u1', label: 'L', capabilities: ['ai.chat'], tokenHash: await hashMcpSecret(token), }) await db`update ai_mcp_connectors set revoked_at = current_timestamp where id = ${rec.id}` const req = new Request('http://x/_instatic/mcp', { headers: { Authorization: `Bearer ${token}` } }) @@ -57,11 +66,11 @@ describe('mcp auth', () => { }) it('rejects an expired connector token', async () => { - const token = generateConnectorToken() + const token = generatePersonalAccessToken() // Create a connector that expired 1 second ago. const pastExpiry = new Date(Date.now() - 1000).toISOString() - const rec = await createConnector(db, { - userId: 'u1', label: 'L', type: 'remote', capabilities: ['ai.chat'], tokenHash: await hashConnectorToken(token), + const rec = await createBearerConnection(db, { + userId: 'u1', label: 'L', capabilities: ['ai.chat'], tokenHash: await hashMcpSecret(token), ttlDays: 90, }) // Backdate expires_at to a past timestamp to simulate expiry. @@ -71,9 +80,9 @@ describe('mcp auth', () => { }) it('accepts a grandfathered connector with NULL expires_at (non-expiring)', async () => { - const token = generateConnectorToken() - const rec = await createConnector(db, { - userId: 'u1', label: 'Legacy', type: 'remote', capabilities: ['ai.chat'], tokenHash: await hashConnectorToken(token), + const token = generatePersonalAccessToken() + const rec = await createBearerConnection(db, { + userId: 'u1', label: 'Legacy', capabilities: ['ai.chat'], tokenHash: await hashMcpSecret(token), }) // Simulate a pre-migration 019 row with no expiry set. await db`update ai_mcp_connectors set expires_at = null where id = ${rec.id}` @@ -82,8 +91,43 @@ describe('mcp auth', () => { expect(res.ok).toBe(true) }) + it('resolves a resource-bound OAuth access token through the same capability gate', async () => { + const verifier = 'p'.repeat(64) + const client = await registerOAuthClient(db, { + clientName: 'Claude', + redirectUris: ['https://claude.ai/api/mcp/auth_callback'], + }) + const { code } = await createOAuthAuthorizationGrant(db, { + userId: 'u1', + clientName: client.clientName, + capabilities: ['ai.chat', 'site.read'], + request: { + responseType: 'code', + clientId: client.clientId, + redirectUri: client.redirectUris[0]!, + codeChallenge: await pkceChallengeForVerifier(verifier), + codeChallengeMethod: 'S256', + scope: 'mcp offline_access', + resource: 'http://x/_instatic/mcp', + }, + }) + const tokens = await exchangeAuthorizationCode(db, { + code, + clientId: client.clientId, + redirectUri: client.redirectUris[0]!, + codeVerifier: verifier, + resource: 'http://x/_instatic/mcp', + }) + const req = new Request('http://x/_instatic/mcp', { + headers: { Authorization: `Bearer ${tokens!.accessToken}` }, + }) + const result = await resolveMcpAuth(req, db) + expect(result.ok).toBe(true) + if (result.ok) expect(result.capabilities).toEqual(['ai.chat', 'site.read']) + }) + it('builds a spec-correct 401 with a resource_metadata pointer', () => { - const r = unauthorizedResponse(new URL('http://x/_instatic/mcp')) + const r = unauthorizedResponse(new Request('http://x/_instatic/mcp')) expect(r.status).toBe(401) const wwwAuth = r.headers.get('WWW-Authenticate') ?? '' expect(wwwAuth).toContain('Bearer') diff --git a/server/ai/mcp/auth.ts b/server/ai/mcp/auth.ts index 1985e207c..8f01e0f69 100644 --- a/server/ai/mcp/auth.ts +++ b/server/ai/mcp/auth.ts @@ -1,16 +1,14 @@ /** - * MCP bearer auth. Resolves an incoming request to the connector that owns the - * presented token, yielding its user id + granted capabilities — which flow - * straight into the existing tool capability gate. - * - * Current auth is static bearer tokens. The 401 advertises a Protected - * Resource Metadata pointer (`WWW-Authenticate`) so MCP-aware clients receive - * a spec-shaped challenge even when the bearer token is missing or invalid. + * MCP bearer resolution. The bearer value may be a personal access token or + * a short-lived OAuth access token; both resolve to the same connector grant + * and capability set consumed by the tool gate. */ import type { DbClient } from '../../db/client' import type { CoreCapability } from '@core/capabilities' -import { findConnectorByTokenHash, touchConnectorLastUsed } from './connectors/store' -import { hashConnectorToken } from './connectors/token' +import { findConnectionByTokenHash, touchConnectorLastUsed } from './connectors/store' +import { hashMcpSecret } from './connectors/token' +import { findOAuthAccessGrant } from './oauth/store' +import { mcpProtectedResourceMetadataUrl, mcpResource, MCP_OAUTH_SCOPE } from './oauth/protocol' export type McpAuthResult = | { ok: true; connectorId: string; userId: string; capabilities: readonly CoreCapability[] } @@ -21,12 +19,18 @@ const BEARER_RE = /^Bearer\s+(.+)$/i export async function resolveMcpAuth(req: Request, db: DbClient): Promise { const match = BEARER_RE.exec((req.headers.get('Authorization') ?? '').trim()) if (!match) return { ok: false } - const connector = await findConnectorByTokenHash(db, await hashConnectorToken(match[1]!.trim())) + const token = match[1]!.trim() + const oauthGrant = token.startsWith('imcp_at_') + ? await findOAuthAccessGrant(db, token, mcpResource(req)) + : null + if (oauthGrant) { + stampLastUsed(db, oauthGrant.connectorId) + return { ok: true, ...oauthGrant } + } + + const connector = await findConnectionByTokenHash(db, await hashMcpSecret(token)) if (!connector || !connector.tokenHash) return { ok: false } - // Best-effort last-used stamp; never block the request on it. - void touchConnectorLastUsed(db, connector.id).catch((err) => { - console.error('[ai:mcp] failed to stamp connector last_used_at:', err) - }) + stampLastUsed(db, connector.id) return { ok: true, connectorId: connector.id, @@ -37,15 +41,21 @@ export async function resolveMcpAuth(req: Request, db: DbClient): Promise { + console.error('[ai:mcp] failed to stamp connector last_used_at:', err) + }) +} diff --git a/server/ai/mcp/connectors/store.test.ts b/server/ai/mcp/connectors/store.test.ts index ae5dd4b71..ab1362fbb 100644 --- a/server/ai/mcp/connectors/store.test.ts +++ b/server/ai/mcp/connectors/store.test.ts @@ -4,14 +4,14 @@ import { sqliteMigrations } from '../../../db/migrations-sqlite' import { runMigrations } from '../../../db/runMigrations' import type { DbClient } from '../../../db/client' import { - createConnector, + createBearerConnection, listConnectorsForUser, - findConnectorByTokenHash, + findConnectionByTokenHash, revokeConnector, touchConnectorLastUsed, - toConnectorView, + toConnectionView, } from './store' -import { hashConnectorToken } from './token' +import { hashMcpSecret } from './token' async function freshDb(): Promise { const db = createSqliteClient(':memory:') @@ -29,12 +29,11 @@ beforeEach(async () => { db = await freshDb() }) describe('connector store', () => { it('creates, lists, and projects to a token-free view', async () => { - const rec = await createConnector(db, { + const rec = await createBearerConnection(db, { userId: 'u1', label: 'Claude Code', - type: 'local', capabilities: ['ai.chat', 'content.manage'], - tokenHash: await hashConnectorToken('imcp_x'), + tokenHash: await hashMcpSecret('imcp_x'), }) expect(rec.label).toBe('Claude Code') expect(rec.capabilities).toEqual(['ai.chat', 'content.manage']) @@ -43,7 +42,7 @@ describe('connector store', () => { const list = await listConnectorsForUser(db, 'u1') expect(list).toHaveLength(1) - const view = toConnectorView(rec) + const view = toConnectionView(rec) expect(JSON.stringify(view)).not.toContain('tokenHash') expect(JSON.stringify(view)).not.toContain('token_hash') expect(view.revoked).toBe(false) @@ -51,30 +50,30 @@ describe('connector store', () => { }) it('finds an active connector by token hash and skips revoked', async () => { - const hash = await hashConnectorToken('imcp_y') - const rec = await createConnector(db, { - userId: 'u1', label: 'L', type: 'remote', capabilities: ['ai.chat'], tokenHash: hash, + const hash = await hashMcpSecret('imcp_y') + const rec = await createBearerConnection(db, { + userId: 'u1', label: 'L', capabilities: ['ai.chat'], tokenHash: hash, }) - const found = await findConnectorByTokenHash(db, hash) + const found = await findConnectionByTokenHash(db, hash) expect(found?.id).toBe(rec.id) expect(await revokeConnector(db, rec.id, 'u1')).toBe(true) - expect(await findConnectorByTokenHash(db, hash)).toBeNull() + expect(await findConnectionByTokenHash(db, hash)).toBeNull() // A second revoke is a no-op (already revoked). expect(await revokeConnector(db, rec.id, 'u1')).toBe(false) }) it('does not revoke another user\'s connector', async () => { - const rec = await createConnector(db, { - userId: 'u1', label: 'L', type: 'local', capabilities: ['ai.chat'], tokenHash: await hashConnectorToken('imcp_z'), + const rec = await createBearerConnection(db, { + userId: 'u1', label: 'L', capabilities: ['ai.chat'], tokenHash: await hashMcpSecret('imcp_z'), }) expect(await revokeConnector(db, rec.id, 'someone-else')).toBe(false) }) it('touches last_used_at', async () => { - const rec = await createConnector(db, { - userId: 'u1', label: 'L', type: 'local', capabilities: ['ai.chat'], tokenHash: await hashConnectorToken('imcp_w'), + const rec = await createBearerConnection(db, { + userId: 'u1', label: 'L', capabilities: ['ai.chat'], tokenHash: await hashMcpSecret('imcp_w'), }) expect(rec.lastUsedAt).toBeNull() await touchConnectorLastUsed(db, rec.id) @@ -84,45 +83,45 @@ describe('connector store', () => { // ── Expiry tests ──────────────────────────────────────────────────────── - it('a freshly created token is accepted by findConnectorByTokenHash (not yet expired)', async () => { - const hash = await hashConnectorToken('imcp_fresh') - await createConnector(db, { - userId: 'u1', label: 'Fresh', type: 'local', capabilities: ['ai.chat'], tokenHash: hash, + it('a freshly created token is accepted by findConnectionByTokenHash (not yet expired)', async () => { + const hash = await hashMcpSecret('imcp_fresh') + await createBearerConnection(db, { + userId: 'u1', label: 'Fresh', capabilities: ['ai.chat'], tokenHash: hash, }) // Default now = new Date() — the token expires 90 days from creation, so it is valid. - const found = await findConnectorByTokenHash(db, hash) + const found = await findConnectionByTokenHash(db, hash) expect(found).not.toBeNull() expect(found?.label).toBe('Fresh') }) - it('an expired token is rejected by findConnectorByTokenHash', async () => { - const hash = await hashConnectorToken('imcp_expired') - await createConnector(db, { - userId: 'u1', label: 'Expired', type: 'local', capabilities: ['ai.chat'], tokenHash: hash, + it('an expired token is rejected by findConnectionByTokenHash', async () => { + const hash = await hashMcpSecret('imcp_expired') + await createBearerConnection(db, { + userId: 'u1', label: 'Expired', capabilities: ['ai.chat'], tokenHash: hash, ttlDays: 30, }) // Inject a `now` 31 days in the future — past the 30-day TTL. const future = new Date(Date.now() + 31 * 24 * 60 * 60 * 1000) - const found = await findConnectorByTokenHash(db, hash, future) + const found = await findConnectionByTokenHash(db, hash, future) expect(found).toBeNull() }) it('a non-expired token is still accepted when now is before expires_at', async () => { - const hash = await hashConnectorToken('imcp_valid') - await createConnector(db, { - userId: 'u1', label: 'Valid', type: 'local', capabilities: ['ai.chat'], tokenHash: hash, + const hash = await hashMcpSecret('imcp_valid') + await createBearerConnection(db, { + userId: 'u1', label: 'Valid', capabilities: ['ai.chat'], tokenHash: hash, ttlDays: 30, }) // Inject a `now` 29 days in the future — still within the 30-day TTL. const soon = new Date(Date.now() + 29 * 24 * 60 * 60 * 1000) - const found = await findConnectorByTokenHash(db, hash, soon) + const found = await findConnectionByTokenHash(db, hash, soon) expect(found).not.toBeNull() }) - it('createConnector always sets a non-null expiresAt on the returned record', async () => { - const rec = await createConnector(db, { - userId: 'u1', label: 'E', type: 'local', capabilities: ['ai.chat'], - tokenHash: await hashConnectorToken('imcp_ttl'), + it('createBearerConnection always sets a non-null expiresAt on the returned record', async () => { + const rec = await createBearerConnection(db, { + userId: 'u1', label: 'E', capabilities: ['ai.chat'], + tokenHash: await hashMcpSecret('imcp_ttl'), }) expect(rec.expiresAt).not.toBeNull() // expiresAt should be approximately 90 days from now (within ±2 minutes). @@ -133,12 +132,12 @@ describe('connector store', () => { expect(delta).toBeLessThan(ninetyDays + 2 * 60 * 1000) }) - it('toConnectorView includes expiresAt (non-null for new tokens) and never tokenHash', async () => { - const rec = await createConnector(db, { - userId: 'u1', label: 'V', type: 'local', capabilities: ['ai.chat'], - tokenHash: await hashConnectorToken('imcp_view'), + it('toConnectionView includes expiresAt (non-null for new tokens) and never tokenHash', async () => { + const rec = await createBearerConnection(db, { + userId: 'u1', label: 'V', capabilities: ['ai.chat'], + tokenHash: await hashMcpSecret('imcp_view'), }) - const view = toConnectorView(rec) + const view = toConnectionView(rec) // expiresAt must be a non-null string for a freshly created token. expect(view.expiresAt).not.toBeNull() expect(typeof view.expiresAt).toBe('string') @@ -149,9 +148,9 @@ describe('connector store', () => { }) it('custom ttlDays is honoured', async () => { - const rec = await createConnector(db, { - userId: 'u1', label: 'Custom TTL', type: 'local', capabilities: ['ai.chat'], - tokenHash: await hashConnectorToken('imcp_custom'), + const rec = await createBearerConnection(db, { + userId: 'u1', label: 'Custom TTL', capabilities: ['ai.chat'], + tokenHash: await hashMcpSecret('imcp_custom'), ttlDays: 7, }) const delta = new Date(rec.expiresAt!).getTime() - Date.now() @@ -160,32 +159,32 @@ describe('connector store', () => { expect(delta).toBeLessThan(sevenDays + 2 * 60 * 1000) }) - it('createConnector with ttlDays: null creates a non-expiring token', async () => { - const hash = await hashConnectorToken('imcp_no_expiry') - const rec = await createConnector(db, { - userId: 'u1', label: 'No Expiry', type: 'local', capabilities: ['ai.chat'], + it('createBearerConnection with ttlDays: null creates a non-expiring token', async () => { + const hash = await hashMcpSecret('imcp_no_expiry') + const rec = await createBearerConnection(db, { + userId: 'u1', label: 'No Expiry', capabilities: ['ai.chat'], tokenHash: hash, ttlDays: null, }) // expiresAt must be null for an explicitly non-expiring token. expect(rec.expiresAt).toBeNull() - // findConnectorByTokenHash must accept the token even with `now` far in the future. + // findConnectionByTokenHash must accept the token even with `now` far in the future. const farFuture = new Date(Date.now() + 10 * 365 * 24 * 60 * 60 * 1000) // 10 years - const found = await findConnectorByTokenHash(db, hash, farFuture) + const found = await findConnectionByTokenHash(db, hash, farFuture) expect(found).not.toBeNull() expect(found?.expiresAt).toBeNull() }) it('a connector row with NULL expires_at (grandfathered) is accepted as non-expiring', async () => { - const hash = await hashConnectorToken('imcp_null_expiry') - const rec = await createConnector(db, { - userId: 'u1', label: 'Legacy', type: 'local', capabilities: ['ai.chat'], tokenHash: hash, + const hash = await hashMcpSecret('imcp_null_expiry') + const rec = await createBearerConnection(db, { + userId: 'u1', label: 'Legacy', capabilities: ['ai.chat'], tokenHash: hash, }) // Simulate a pre-migration 019 row by clearing expires_at. await db`update ai_mcp_connectors set expires_at = null where id = ${rec.id}` // NULL expires_at → non-expiring; the connector must still be accepted. - const found = await findConnectorByTokenHash(db, hash) + const found = await findConnectionByTokenHash(db, hash) expect(found).not.toBeNull() expect(found?.label).toBe('Legacy') expect(found?.expiresAt).toBeNull() diff --git a/server/ai/mcp/connectors/store.ts b/server/ai/mcp/connectors/store.ts index 204abdf9a..e116a8880 100644 --- a/server/ai/mcp/connectors/store.ts +++ b/server/ai/mcp/connectors/store.ts @@ -4,23 +4,23 @@ * as a JSON string (parsed back automatically on read — SQLite by the adapter, * Postgres by jsonb), matching the `writeJson` convention in other repos. * - * `toConnectorView` is the ONLY projection the HTTP layer may serialise: it + * `toConnectionView` is the ONLY projection the HTTP layer may serialise: it * drops `tokenHash` entirely. Gated by `ai-mcp-connectors-never-leak.test.ts`. */ import { nanoid } from 'nanoid' import type { DbClient } from '../../../db/client' import type { CoreCapability } from '@core/capabilities' -import type { McpConnectorView, McpConnectorType, McpAuthMode } from '@core/ai' +import type { McpConnectionView, McpAuthMode } from '@core/ai' import type { McpConnectorRecord } from './types' const DEFAULT_TTL_DAYS = 90 +export const OAUTH_GRANT_TTL_DAYS = 90 interface ConnectorRow { id: string user_id: string label: string - type: string - auth_mode: string + auth_mode: McpAuthMode token_hash: string | null // `_json` column → auto-parsed to an array on read (both dialects). capabilities_json: CoreCapability[] @@ -35,27 +35,25 @@ function rowToRecord(row: ConnectorRow): McpConnectorRecord { id: row.id, userId: row.user_id, label: row.label, - type: row.type as McpConnectorType, - authMode: row.auth_mode as McpAuthMode, + authMode: row.auth_mode, tokenHash: row.token_hash, capabilities: Array.isArray(row.capabilities_json) ? row.capabilities_json : [], createdAt: row.created_at, lastUsedAt: row.last_used_at, revokedAt: row.revoked_at, - // expires_at is set by createConnector for every new bearer token. + // expires_at is set for every new bearer token and OAuth grant. // A null value (pre-migration 019 rows / grandfathered connectors) means - // non-expiring — findConnectorByTokenHash already accepts NULL via + // non-expiring — findConnectionByTokenHash already accepts NULL via // `(expires_at is null or expires_at > ${now})`. expiresAt: row.expires_at, } } /** Wire-safe projection. Never exposes the token hash. */ -export function toConnectorView(rec: McpConnectorRecord): McpConnectorView { +export function toConnectionView(rec: McpConnectorRecord): McpConnectionView { return { id: rec.id, label: rec.label, - type: rec.type, authMode: rec.authMode, capabilities: [...rec.capabilities], createdAt: rec.createdAt, @@ -65,12 +63,11 @@ export function toConnectorView(rec: McpConnectorRecord): McpConnectorView { } } -export async function createConnector( +export async function createBearerConnection( db: DbClient, input: { userId: string label: string - type: McpConnectorType capabilities: readonly CoreCapability[] tokenHash: string /** @@ -96,20 +93,53 @@ export async function createConnector( created_at, expires_at ) values ( - ${id}, ${input.userId}, ${input.label}, ${input.type}, 'bearer', + ${id}, ${input.userId}, ${input.label}, 'local', 'bearer', ${input.tokenHash}, ${capabilitiesJson}, ${createdAt}, ${expiresAt} ) - returning id, user_id, label, type, auth_mode, token_hash, + returning id, user_id, label, auth_mode, token_hash, capabilities_json, created_at, last_used_at, revoked_at, expires_at ` if (!rows[0]) throw new Error('Connector insert did not persist') return rowToRecord(rows[0]) } +/** Create the persistent user/capability grant behind an OAuth connection. */ +export async function createOAuthConnection( + db: DbClient, + input: { + userId: string + label: string + capabilities: readonly CoreCapability[] + expiresAt?: Date + }, +): Promise { + const id = nanoid() + const capabilitiesJson = JSON.stringify(input.capabilities) + const createdAt = new Date() + const expiresAt = input.expiresAt ?? new Date( + createdAt.getTime() + OAUTH_GRANT_TTL_DAYS * 24 * 60 * 60 * 1000, + ) + + const { rows } = await db` + insert into ai_mcp_connectors ( + id, user_id, label, type, auth_mode, token_hash, capabilities_json, + created_at, expires_at + ) + values ( + ${id}, ${input.userId}, ${input.label}, 'remote', 'oauth', null, + ${capabilitiesJson}, ${createdAt}, ${expiresAt} + ) + returning id, user_id, label, auth_mode, token_hash, + capabilities_json, created_at, last_used_at, revoked_at, expires_at + ` + if (!rows[0]) throw new Error('OAuth connection insert did not persist') + return rowToRecord(rows[0]) +} + export async function listConnectorsForUser(db: DbClient, userId: string): Promise { const { rows } = await db` - select id, user_id, label, type, auth_mode, token_hash, + select id, user_id, label, auth_mode, token_hash, capabilities_json, created_at, last_used_at, revoked_at, expires_at from ai_mcp_connectors where user_id = ${userId} @@ -124,13 +154,13 @@ export async function listConnectorsForUser(db: DbClient, userId: string): Promi * @param now - Injection point for the current time; defaults to `new Date()`. * Pass a fixed Date in tests to simulate future/past times without waiting. */ -export async function findConnectorByTokenHash( +export async function findConnectionByTokenHash( db: DbClient, tokenHash: string, now: Date = new Date(), ): Promise { const { rows } = await db` - select id, user_id, label, type, auth_mode, token_hash, + select id, user_id, label, auth_mode, token_hash, capabilities_json, created_at, last_used_at, revoked_at, expires_at from ai_mcp_connectors where token_hash = ${tokenHash} diff --git a/server/ai/mcp/connectors/token.test.ts b/server/ai/mcp/connectors/token.test.ts index 744037b4c..efc852a4d 100644 --- a/server/ai/mcp/connectors/token.test.ts +++ b/server/ai/mcp/connectors/token.test.ts @@ -1,24 +1,24 @@ import { describe, expect, it } from 'bun:test' -import { generateConnectorToken, hashConnectorToken } from './token' +import { generatePersonalAccessToken, hashMcpSecret } from './token' describe('connector token', () => { it('generates a prefixed, url-safe token', () => { - const t = generateConnectorToken() - expect(t).toMatch(/^imcp_[A-Za-z0-9_-]{43}$/) + const t = generatePersonalAccessToken() + expect(t).toMatch(/^imcp_pat_[A-Za-z0-9_-]{43}$/) }) it('generates distinct tokens', () => { - expect(generateConnectorToken()).not.toBe(generateConnectorToken()) + expect(generatePersonalAccessToken()).not.toBe(generatePersonalAccessToken()) }) it('hashes deterministically and differs per token', async () => { - const a = generateConnectorToken() - expect(await hashConnectorToken(a)).toBe(await hashConnectorToken(a)) - expect(await hashConnectorToken(a)).not.toBe(await hashConnectorToken(generateConnectorToken())) + const a = generatePersonalAccessToken() + expect(await hashMcpSecret(a)).toBe(await hashMcpSecret(a)) + expect(await hashMcpSecret(a)).not.toBe(await hashMcpSecret(generatePersonalAccessToken())) }) it('produces a url-safe hash with no padding', async () => { - const h = await hashConnectorToken('imcp_example') + const h = await hashMcpSecret('imcp_example') expect(h).toMatch(/^[A-Za-z0-9_-]+$/) }) }) diff --git a/server/ai/mcp/connectors/token.ts b/server/ai/mcp/connectors/token.ts index db234c247..6ae247bb5 100644 --- a/server/ai/mcp/connectors/token.ts +++ b/server/ai/mcp/connectors/token.ts @@ -1,27 +1,39 @@ -/** - * Connector secrets. The plaintext is shown to the operator exactly once at - * creation; only its SHA-256 hash is persisted, so a database read can never - * yield a usable token. Auth (`../auth.ts`) hashes the presented bearer token - * and looks it up by hash. - * - * Uses Web Crypto (`crypto.getRandomValues` / `crypto.subtle`) — available in - * Bun without imports — so there is no Node `crypto` dependency. - */ -const TOKEN_BYTES = 32 // 32 random bytes → 43 base64url chars - -export function generateConnectorToken(): string { - const bytes = crypto.getRandomValues(new Uint8Array(TOKEN_BYTES)) - return `imcp_${toBase64Url(bytes)}` +/** Opaque MCP credentials. Only one-way SHA-256 hashes are persisted. */ +const TOKEN_BYTES = 32 + +export function generatePersonalAccessToken(): string { + return generateSecret('imcp_pat_') +} + +export function generateOAuthAuthorizationCode(): string { + return generateSecret('imcp_ac_') } -export async function hashConnectorToken(token: string): Promise { - const data = new TextEncoder().encode(token) +export function generateOAuthAccessToken(): string { + return generateSecret('imcp_at_') +} + +export function generateOAuthRefreshToken(): string { + return generateSecret('imcp_rt_') +} + +export async function hashMcpSecret(secret: string): Promise { + const data = new TextEncoder().encode(secret) const digest = await crypto.subtle.digest('SHA-256', data) return toBase64Url(new Uint8Array(digest)) } +export async function pkceChallengeForVerifier(verifier: string): Promise { + return hashMcpSecret(verifier) +} + +function generateSecret(prefix: string): string { + const bytes = crypto.getRandomValues(new Uint8Array(TOKEN_BYTES)) + return `${prefix}${toBase64Url(bytes)}` +} + function toBase64Url(bytes: Uint8Array): string { - let bin = '' - for (const b of bytes) bin += String.fromCharCode(b) - return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') + let binary = '' + for (const byte of bytes) binary += String.fromCharCode(byte) + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') } diff --git a/server/ai/mcp/connectors/types.ts b/server/ai/mcp/connectors/types.ts index 8d620196d..a81e5dbf5 100644 --- a/server/ai/mcp/connectors/types.ts +++ b/server/ai/mcp/connectors/types.ts @@ -1,19 +1,18 @@ /** * Server-only connector record. Mirrors the credentials store's split: this * type carries `tokenHash` and is NEVER serialised over HTTP. The wire-safe - * projection is `McpConnectorView` (from `@core/ai`), produced by - * `toConnectorView` in `./store`. + * projection is `McpConnectionView` (from `@core/ai`), produced by + * `toConnectionView` in `./store`. */ import type { CoreCapability } from '@core/capabilities' -import type { McpAuthMode, McpConnectorType } from '@core/ai' +import type { McpAuthMode } from '@core/ai' export interface McpConnectorRecord { readonly id: string readonly userId: string readonly label: string - readonly type: McpConnectorType readonly authMode: McpAuthMode - /** One-way hash of the secret. Null is reserved for future OAuth rows. */ + /** One-way hash of a personal access token. OAuth rows keep this null. */ readonly tokenHash: string | null readonly capabilities: readonly CoreCapability[] readonly createdAt: string @@ -21,7 +20,7 @@ export interface McpConnectorRecord { readonly revokedAt: string | null /** * ISO 8601 UTC timestamp when this token expires. - * Always non-null for tokens created via createConnector. + * Always non-null for personal access tokens created via createBearerConnection. * Null for grandfathered rows (pre-migration 019): treated as non-expiring. */ readonly expiresAt: string | null diff --git a/server/ai/mcp/e2e.test.ts b/server/ai/mcp/e2e.test.ts index 66b71c575..ebd0ebf63 100644 --- a/server/ai/mcp/e2e.test.ts +++ b/server/ai/mcp/e2e.test.ts @@ -15,8 +15,8 @@ import { sqliteMigrations } from '../../db/migrations-sqlite' import { runMigrations } from '../../db/runMigrations' import type { DbClient } from '../../db/client' import { handleMcpHttp } from './index' -import { createConnector } from './connectors/store' -import { generateConnectorToken, hashConnectorToken } from './connectors/token' +import { createBearerConnection } from './connectors/store' +import { generatePersonalAccessToken, hashMcpSecret } from './connectors/token' let db: DbClient let token: string @@ -28,11 +28,11 @@ beforeEach(async () => { insert into users (id, email, email_normalized, display_name, password_hash, role_id) values ('u1', 'u1@example.com', 'u1@example.com', 'User One', 'x', 'owner') ` - token = generateConnectorToken() - await createConnector(db, { - userId: 'u1', label: 'Claude Code', type: 'local', + token = generatePersonalAccessToken() + await createBearerConnection(db, { + userId: 'u1', label: 'Claude Code', capabilities: ['ai.chat', 'ai.tools.write', 'site.read', 'site.structure.edit', 'content.manage', 'data.system.tables.read'], - tokenHash: await hashConnectorToken(token), + tokenHash: await hashMcpSecret(token), }) }) @@ -90,11 +90,11 @@ describe('MCP end-to-end (stateless multi-request, real handler)', () => { }) it('a read-only connector sees reads but no write tools', async () => { - const readToken = generateConnectorToken() - await createConnector(db, { - userId: 'u1', label: 'RO', type: 'remote', + const readToken = generatePersonalAccessToken() + await createBearerConnection(db, { + userId: 'u1', label: 'RO', capabilities: ['ai.chat', 'site.read', 'content.manage', 'data.system.tables.read'], - tokenHash: await hashConnectorToken(readToken), + tokenHash: await hashMcpSecret(readToken), }) const req = (method: string, params: unknown) => new Request('http://localhost/_instatic/mcp', { diff --git a/server/ai/mcp/handlers/connectors.ts b/server/ai/mcp/handlers/connectors.ts deleted file mode 100644 index 8e89fa34b..000000000 --- a/server/ai/mcp/handlers/connectors.ts +++ /dev/null @@ -1,121 +0,0 @@ -/** - * MCP connector CRUD — `/admin/api/ai/mcp/connectors[/:id]`. - * - * GET /admin/api/ai/mcp/connectors → list (token-free views) - * POST /admin/api/ai/mcp/connectors → create (returns token ONCE) - * DELETE /admin/api/ai/mcp/connectors/:id → revoke - * - * Gated by `ai.providers.manage` — the same capability that governs the AI - * Providers tab; managing MCP connectors is the same "manage AI integrations" - * admin surface. The plaintext token is surfaced exactly once, in the create - * response; every other shape is the wire-safe `McpConnectorView`. - * - * A connector can only grant capabilities the creating admin actually holds — - * an admin cannot mint a connector more powerful than themselves. - */ -import { getErrorMessage } from '@core/utils/errorMessage' -import { CreateMcpConnectorBodySchema } from '@core/ai' -import { jsonResponse, readValidatedBody, badRequest } from '../../../http' -import { requireCapability, requireStepUp, userHasCapability } from '../../../auth/authz' -import type { DbClient } from '../../../db/client' -import { createAuditEvent } from '../../../repositories/audit' -import { - createConnector, - listConnectorsForUser, - revokeConnector, - toConnectorView, -} from '../connectors/store' -import { generateConnectorToken, hashConnectorToken } from '../connectors/token' - -const BASE = '/admin/api/ai/mcp/connectors' - -export function tryHandleAiMcpConnectors( - req: Request, - db: DbClient, - pathname: string, -): Promise | null { - if (pathname !== BASE && !pathname.startsWith(`${BASE}/`)) return null - return handle(req, db, pathname) -} - -async function handle(req: Request, db: DbClient, pathname: string): Promise { - if (pathname === BASE) { - if (req.method === 'GET') return handleList(req, db) - if (req.method === 'POST') return handleCreate(req, db) - return jsonResponse({ error: 'Method not allowed' }, { status: 405 }) - } - // /:id - if (req.method === 'DELETE') return handleRevoke(req, db, pathname.slice(`${BASE}/`.length)) - return jsonResponse({ error: 'Method not allowed' }, { status: 405 }) -} - -async function handleList(req: Request, db: DbClient): Promise { - const userOrResponse = await requireCapability(req, db, 'ai.providers.manage') - if (userOrResponse instanceof Response) return userOrResponse - const records = await listConnectorsForUser(db, userOrResponse.id) - return jsonResponse({ connectors: records.map(toConnectorView) }) -} - -async function handleCreate(req: Request, db: DbClient): Promise { - const userOrResponse = await requireCapability(req, db, 'ai.providers.manage') - if (userOrResponse instanceof Response) return userOrResponse - - const body = await readValidatedBody(req, CreateMcpConnectorBodySchema) - if (!body) return badRequest('Invalid request body.') - - // Privilege floor: a connector can only grant capabilities the creator holds. - const overreach = body.capabilities.filter((cap) => !userHasCapability(userOrResponse, cap)) - if (overreach.length > 0) { - return jsonResponse( - { error: `You cannot grant capabilities you don't hold: ${overreach.join(', ')}` }, - { status: 403 }, - ) - } - - // Connector tokens are long-lived delegated credentials. Creating one can - // grant the same high-impact capabilities as the current admin (including - // non-interactive full-site publish), so minting the secret requires the - // same fresh authentication window as other sensitive admin operations. - const stepUp = await requireStepUp(req, db, userOrResponse) - if (stepUp) return stepUp - - try { - const token = generateConnectorToken() - const record = await createConnector(db, { - userId: userOrResponse.id, - label: body.label, - type: body.type, - capabilities: body.capabilities, - tokenHash: await hashConnectorToken(token), - ttlDays: body.ttlDays, - }) - await createAuditEvent(db, { - actorUserId: userOrResponse.id, - action: 'ai.mcp_connector.created', - targetType: 'ai_mcp_connector', - targetId: record.id, - metadata: { label: record.label, type: record.type, capabilities: [...record.capabilities] }, - }) - return jsonResponse({ connector: toConnectorView(record), token }, { status: 201 }) - } catch (err) { - console.error('[ai:mcp] failed to create connector:', err) - return jsonResponse({ error: getErrorMessage(err, 'Failed to create connector.') }, { status: 500 }) - } -} - -async function handleRevoke(req: Request, db: DbClient, id: string): Promise { - const userOrResponse = await requireCapability(req, db, 'ai.providers.manage') - if (userOrResponse instanceof Response) return userOrResponse - if (!id) return badRequest('Missing connector id.') - - const revoked = await revokeConnector(db, id, userOrResponse.id) - if (!revoked) return jsonResponse({ error: 'Connector not found.' }, { status: 404 }) - - await createAuditEvent(db, { - actorUserId: userOrResponse.id, - action: 'ai.mcp_connector.revoked', - targetType: 'ai_mcp_connector', - targetId: id, - }) - return jsonResponse({ revoked: true }) -} diff --git a/server/ai/mcp/handlers/management.ts b/server/ai/mcp/handlers/management.ts new file mode 100644 index 000000000..5e1f20a06 --- /dev/null +++ b/server/ai/mcp/handlers/management.ts @@ -0,0 +1,132 @@ +/** Admin management API for MCP connections and personal access tokens. */ +import { getErrorMessage } from '@core/utils/errorMessage' +import { CreateMcpAccessTokenBodySchema } from '@core/ai' +import { jsonResponse, readValidatedBody, badRequest } from '../../../http' +import { requireCapability, requireStepUp, userHasCapability } from '../../../auth/authz' +import { expectedOrigin } from '../../../auth/security' +import type { DbClient } from '../../../db/client' +import { createAuditEvent } from '../../../repositories/audit' +import { + createBearerConnection, + listConnectorsForUser, + revokeConnector, + toConnectionView, +} from '../connectors/store' +import { generatePersonalAccessToken, hashMcpSecret } from '../connectors/token' +import { MCP_ENDPOINT_PATH } from '../paths' +import { isRemoteMcpEndpoint } from '../oauth/protocol' + +const CONNECTIONS_BASE = '/admin/api/ai/mcp/connections' +const ACCESS_TOKENS_PATH = '/admin/api/ai/mcp/access-tokens' + +export function tryHandleAiMcpManagement( + req: Request, + db: DbClient, + pathname: string, +): Promise | null { + if ( + pathname !== CONNECTIONS_BASE && + !pathname.startsWith(`${CONNECTIONS_BASE}/`) && + pathname !== ACCESS_TOKENS_PATH + ) { + return null + } + return handle(req, db, pathname) +} + +async function handle(req: Request, db: DbClient, pathname: string): Promise { + if (pathname === CONNECTIONS_BASE) { + return req.method === 'GET' + ? handleList(req, db) + : jsonResponse({ error: 'Method not allowed' }, { status: 405 }) + } + if (pathname === ACCESS_TOKENS_PATH) { + return req.method === 'POST' + ? handleCreateAccessToken(req, db) + : jsonResponse({ error: 'Method not allowed' }, { status: 405 }) + } + if (req.method === 'DELETE') { + return handleRevoke(req, db, pathname.slice(`${CONNECTIONS_BASE}/`.length)) + } + return jsonResponse({ error: 'Method not allowed' }, { status: 405 }) +} + +async function handleList(req: Request, db: DbClient): Promise { + const userOrResponse = await requireCapability(req, db, 'ai.providers.manage') + if (userOrResponse instanceof Response) return userOrResponse + const records = await listConnectorsForUser(db, userOrResponse.id) + const endpoint = `${expectedOrigin(req)}${MCP_ENDPOINT_PATH}` + return jsonResponse({ + connections: records.map(toConnectionView), + endpoint, + remoteAccess: isRemoteMcpEndpoint(endpoint) ? 'public-https' : 'local-only', + }) +} + +async function handleCreateAccessToken(req: Request, db: DbClient): Promise { + const userOrResponse = await requireCapability(req, db, 'ai.providers.manage') + if (userOrResponse instanceof Response) return userOrResponse + + const body = await readValidatedBody(req, CreateMcpAccessTokenBodySchema) + if (!body) return badRequest('Invalid request body.') + const label = body.label.trim() + if (!label) return badRequest('Label is required.') + + const capabilities = [...new Set(body.capabilities)] + if (userHasCapability(userOrResponse, 'ai.chat') && !capabilities.includes('ai.chat')) { + capabilities.push('ai.chat') + } + const overreach = capabilities.filter((capability) => !userHasCapability(userOrResponse, capability)) + if (overreach.length > 0) { + return jsonResponse( + { error: `You cannot grant capabilities you don't hold: ${overreach.join(', ')}` }, + { status: 403 }, + ) + } + + const stepUp = await requireStepUp(req, db, userOrResponse) + if (stepUp) return stepUp + + try { + const accessToken = generatePersonalAccessToken() + const record = await createBearerConnection(db, { + userId: userOrResponse.id, + label, + capabilities, + tokenHash: await hashMcpSecret(accessToken), + ttlDays: body.ttlDays, + }) + await createAuditEvent(db, { + actorUserId: userOrResponse.id, + action: 'ai.mcp_connector.created', + targetType: 'ai_mcp_connector', + targetId: record.id, + metadata: { + label: record.label, + authMode: record.authMode, + capabilities: [...record.capabilities], + }, + }) + return jsonResponse({ connection: toConnectionView(record), accessToken }, { status: 201 }) + } catch (err) { + console.error('[ai:mcp] failed to create access token:', err) + return jsonResponse({ error: getErrorMessage(err, 'Failed to create access token.') }, { status: 500 }) + } +} + +async function handleRevoke(req: Request, db: DbClient, id: string): Promise { + const userOrResponse = await requireCapability(req, db, 'ai.providers.manage') + if (userOrResponse instanceof Response) return userOrResponse + if (!id) return badRequest('Missing connection id.') + + const revoked = await revokeConnector(db, id, userOrResponse.id) + if (!revoked) return jsonResponse({ error: 'Connection not found.' }, { status: 404 }) + + await createAuditEvent(db, { + actorUserId: userOrResponse.id, + action: 'ai.mcp_connector.revoked', + targetType: 'ai_mcp_connector', + targetId: id, + }) + return jsonResponse({ revoked: true }) +} diff --git a/server/ai/mcp/handlers/oauthAuthorization.ts b/server/ai/mcp/handlers/oauthAuthorization.ts new file mode 100644 index 000000000..c56394aee --- /dev/null +++ b/server/ai/mcp/handlers/oauthAuthorization.ts @@ -0,0 +1,132 @@ +/** Admin-session consent surface for the MCP OAuth authorization-code flow. */ +import { getErrorMessage } from '@core/utils/errorMessage' +import { + DecideMcpOAuthAuthorizationBodySchema, + type McpOAuthAuthorizationRequest, +} from '@core/ai' +import { badRequest, jsonResponse, readValidatedBody } from '../../../http' +import { requireCapability, requireStepUp, userHasCapability } from '../../../auth/authz' +import type { DbClient } from '../../../db/client' +import { createAuditEvent } from '../../../repositories/audit' +import { createOAuthAuthorizationGrant, findOAuthClient } from '../oauth/store' +import { OAUTH_GRANT_TTL_DAYS } from '../connectors/store' +import { + isValidPkceChallenge, + mcpResource, + normalizeMcpScope, + oauthRedirect, +} from '../oauth/protocol' +import { parseOAuthAuthorizationRequest } from '../oauth/schemas' +import { MCP_OAUTH_AUTHORIZATION_API_PATH } from '../paths' + +export function tryHandleMcpOAuthAuthorization( + req: Request, + db: DbClient, + url: URL, + pathname: string, +): Promise | null { + if (pathname !== MCP_OAUTH_AUTHORIZATION_API_PATH) return null + if (req.method === 'GET') return handleRead(req, db, url) + if (req.method === 'POST') return handleDecision(req, db) + return Promise.resolve(jsonResponse({ error: 'Method not allowed' }, { status: 405 })) +} + +async function handleRead(req: Request, db: DbClient, url: URL): Promise { + const userOrResponse = await requireCapability(req, db, 'ai.providers.manage') + if (userOrResponse instanceof Response) return userOrResponse + const request = parseOAuthAuthorizationRequest(url.searchParams) + if (!request) return badRequest('Invalid OAuth authorization request.') + const resolved = await resolveAuthorizationRequest(req, db, request) + if (!resolved) return badRequest('The OAuth client, callback, scope, or PKCE challenge is invalid.') + return jsonResponse({ + clientName: resolved.clientName, + callbackUrl: request.redirectUri, + grantExpiresInDays: OAUTH_GRANT_TTL_DAYS, + request: resolved.request, + }) +} + +async function handleDecision(req: Request, db: DbClient): Promise { + const userOrResponse = await requireCapability(req, db, 'ai.providers.manage') + if (userOrResponse instanceof Response) return userOrResponse + const body = await readValidatedBody(req, DecideMcpOAuthAuthorizationBodySchema) + if (!body) return badRequest('Invalid authorization decision.') + const resolved = await resolveAuthorizationRequest(req, db, body.request) + if (!resolved) return badRequest('The OAuth authorization request is no longer valid.') + + if (body.decision === 'deny') { + return jsonResponse({ + redirectUrl: oauthRedirect(body.request.redirectUri, { + error: 'access_denied', + error_description: 'The resource owner denied the request.', + state: body.request.state, + }), + }) + } + + const capabilities = [...new Set(body.capabilities ?? [])] + if ( + userHasCapability(userOrResponse, 'ai.chat') && + !capabilities.includes('ai.chat') + ) { + capabilities.push('ai.chat') + } + if (capabilities.length === 0) return badRequest('Select at least one capability.') + const overreach = capabilities.filter((capability) => !userHasCapability(userOrResponse, capability)) + if (overreach.length > 0) { + return jsonResponse( + { error: `You cannot grant capabilities you don't hold: ${overreach.join(', ')}` }, + { status: 403 }, + ) + } + + const stepUp = await requireStepUp(req, db, userOrResponse) + if (stepUp) return stepUp + + try { + const { connection, code } = await createOAuthAuthorizationGrant(db, { + userId: userOrResponse.id, + clientName: resolved.clientName, + capabilities, + request: resolved.request, + }) + await createAuditEvent(db, { + actorUserId: userOrResponse.id, + action: 'ai.mcp_connector.created', + targetType: 'ai_mcp_connector', + targetId: connection.id, + metadata: { + label: connection.label, + authMode: connection.authMode, + clientId: resolved.request.clientId, + capabilities: [...connection.capabilities], + }, + }) + return jsonResponse({ + redirectUrl: oauthRedirect(resolved.request.redirectUri, { + code, + state: resolved.request.state, + }), + }) + } catch (err) { + console.error('[ai:mcp:oauth] authorization failed:', err) + return jsonResponse({ error: getErrorMessage(err, 'Failed to authorize MCP connection.') }, { status: 500 }) + } +} + +async function resolveAuthorizationRequest( + req: Request, + db: DbClient, + request: McpOAuthAuthorizationRequest, +): Promise<{ clientName: string; request: McpOAuthAuthorizationRequest } | null> { + const client = await findOAuthClient(db, request.clientId) + const scope = normalizeMcpScope(request.scope) + if ( + !client || !scope || !client.redirectUris.includes(request.redirectUri) || + !isValidPkceChallenge(request.codeChallenge) || + request.resource !== mcpResource(req) + ) { + return null + } + return { clientName: client.clientName, request: { ...request, scope } } +} diff --git a/server/ai/mcp/index.ts b/server/ai/mcp/index.ts index c88f6c51c..1fcc66c28 100644 --- a/server/ai/mcp/index.ts +++ b/server/ai/mcp/index.ts @@ -2,4 +2,5 @@ * MCP server module — Instatic as an MCP server. External MCP clients (Claude * Code, Codex, remote agents) connect here and drive the CMS tools. */ -export { handleMcpHttp, MCP_ENDPOINT_PATH } from './transports/http' +export { handleMcpHttp } from './transports/http' +export { MCP_ENDPOINT_PATH } from './paths' diff --git a/server/ai/mcp/oauth/handler.test.ts b/server/ai/mcp/oauth/handler.test.ts new file mode 100644 index 000000000..ec621354c --- /dev/null +++ b/server/ai/mcp/oauth/handler.test.ts @@ -0,0 +1,156 @@ +import { beforeEach, describe, expect, it } from 'bun:test' +import type { DbClient } from '../../../db/client' +import { sqliteMigrations } from '../../../db/migrations-sqlite' +import { runMigrations } from '../../../db/runMigrations' +import { createSqliteClient } from '../../../db/sqlite' +import { pkceChallengeForVerifier } from '../connectors/token' +import { + MCP_AUTHORIZATION_SERVER_METADATA_PATH, + MCP_OAUTH_REGISTER_PATH, + MCP_OAUTH_TOKEN_PATH, + MCP_PROTECTED_RESOURCE_METADATA_PATH, +} from '../paths' +import { tryHandleMcpOAuth } from './handler' +import { isRemoteMcpEndpoint } from './protocol' +import { + createOAuthAuthorizationGrant, + registerOAuthClient, +} from './store' + +async function freshDb(): Promise { + const db = createSqliteClient(':memory:') + await runMigrations(db, sqliteMigrations) + await db` + insert into users (id, email, email_normalized, display_name, password_hash, role_id) + values ('u1', 'u1@example.com', 'u1@example.com', 'User One', 'x', 'owner') + ` + return db +} + +function request(path: string, init?: RequestInit): Request { + return new Request(`https://cms.example.com${path}`, init) +} + +async function handle(req: Request, db: DbClient): Promise { + const response = await tryHandleMcpOAuth(req, db, new URL(req.url).pathname) + if (!response) throw new Error('OAuth handler returned null') + return response +} + +let db: DbClient +beforeEach(async () => { db = await freshDb() }) + +describe('MCP OAuth protocol endpoints', () => { + it('only marks non-local HTTPS endpoints as ready for hosted clients', () => { + expect(isRemoteMcpEndpoint('https://cms.example.com/_instatic/mcp')).toBe(true) + expect(isRemoteMcpEndpoint('http://cms.example.com/_instatic/mcp')).toBe(false) + expect(isRemoteMcpEndpoint('https://localhost:3000/_instatic/mcp')).toBe(false) + expect(isRemoteMcpEndpoint('https://192.168.1.5/_instatic/mcp')).toBe(false) + expect(isRemoteMcpEndpoint('https://instatic.internal/_instatic/mcp')).toBe(false) + }) + + it('advertises protected-resource and authorization-server metadata', async () => { + const protectedResource = await handle(request(MCP_PROTECTED_RESOURCE_METADATA_PATH), db) + expect(protectedResource.status).toBe(200) + expect(protectedResource.headers.get('Cache-Control')).toContain('max-age=300') + const resourceBody = await protectedResource.json() as Record + expect(resourceBody.resource).toBe('https://cms.example.com/_instatic/mcp') + expect(resourceBody.authorization_servers).toEqual(['https://cms.example.com']) + + const authorizationServer = await handle(request(MCP_AUTHORIZATION_SERVER_METADATA_PATH), db) + const serverBody = await authorizationServer.json() as Record + expect(serverBody.authorization_endpoint).toBe('https://cms.example.com/admin/ai/oauth/authorize') + expect(serverBody.token_endpoint).toBe('https://cms.example.com/_instatic/oauth/token') + expect(serverBody.registration_endpoint).toBe('https://cms.example.com/_instatic/oauth/register') + expect(serverBody.code_challenge_methods_supported).toEqual(['S256']) + expect(serverBody.token_endpoint_auth_methods_supported).toEqual(['none']) + }) + + it('dynamically registers a public Claude client', async () => { + const response = await handle(request(MCP_OAUTH_REGISTER_PATH, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + client_name: 'Claude', + redirect_uris: ['https://claude.ai/api/mcp/auth_callback'], + token_endpoint_auth_method: 'none', + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + }), + }), db) + expect(response.status).toBe(201) + expect(response.headers.get('Cache-Control')).toBe('no-store') + const body = await response.json() as Record + expect(body.client_id).toMatch(/^imcp_client_/) + expect(body.token_endpoint_auth_method).toBe('none') + expect(body.redirect_uris).toEqual(['https://claude.ai/api/mcp/auth_callback']) + }) + + it('rejects non-HTTPS redirects outside a loopback host', async () => { + const response = await handle(request(MCP_OAUTH_REGISTER_PATH, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + client_name: 'Unsafe client', + redirect_uris: ['http://attacker.example/callback'], + }), + }), db) + expect(response.status).toBe(400) + expect(await response.json()).toMatchObject({ error: 'invalid_redirect_uri' }) + }) + + it('exchanges a form-encoded authorization code without exposing cacheable credentials', async () => { + const verifier = 'k'.repeat(64) + const callback = 'https://claude.ai/api/mcp/auth_callback' + const resource = 'https://cms.example.com/_instatic/mcp' + const client = await registerOAuthClient(db, { + clientName: 'Claude', + redirectUris: [callback], + }) + const { code } = await createOAuthAuthorizationGrant(db, { + userId: 'u1', + clientName: client.clientName, + capabilities: ['site.read'], + request: { + responseType: 'code', + clientId: client.clientId, + redirectUri: callback, + codeChallenge: await pkceChallengeForVerifier(verifier), + codeChallengeMethod: 'S256', + scope: 'mcp offline_access', + resource, + }, + }) + const form = new URLSearchParams({ + grant_type: 'authorization_code', + code, + redirect_uri: callback, + client_id: client.clientId, + code_verifier: verifier, + resource, + }) + const response = await handle(request(MCP_OAUTH_TOKEN_PATH, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: form, + }), db) + expect(response.status).toBe(200) + expect(response.headers.get('Cache-Control')).toBe('no-store') + expect(response.headers.get('Pragma')).toBe('no-cache') + expect(await response.json()).toMatchObject({ + token_type: 'Bearer', + expires_in: 3600, + scope: 'mcp offline_access', + }) + }) + + it('rejects duplicate token parameters instead of resolving an ambiguous request', async () => { + const response = await handle(request(MCP_OAUTH_TOKEN_PATH, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: 'grant_type=refresh_token&grant_type=authorization_code', + }), db) + expect(response.status).toBe(400) + expect(await response.json()).toMatchObject({ error: 'invalid_request' }) + }) +}) diff --git a/server/ai/mcp/oauth/handler.ts b/server/ai/mcp/oauth/handler.ts new file mode 100644 index 000000000..14e7c69ef --- /dev/null +++ b/server/ai/mcp/oauth/handler.ts @@ -0,0 +1,231 @@ +import type { DbClient } from '../../../db/client' +import { clientIp } from '../../../auth/security' +import { + jsonResponse, + methodNotAllowed, + readTextBodyWithLimit, + readValidatedBody, + RequestBodyTooLargeError, +} from '../../../http' +import { + MCP_AUTHORIZATION_SERVER_METADATA_PATH, + MCP_PATH_PROTECTED_RESOURCE_METADATA_PATH, + MCP_OAUTH_REGISTER_PATH, + MCP_OAUTH_TOKEN_PATH, + MCP_PROTECTED_RESOURCE_METADATA_PATH, +} from '../paths' +import { + mcpOAuthEndpoints, + mcpResource, + MCP_OAUTH_SUPPORTED_SCOPES, + parseAllowedRedirectUri, +} from './protocol' +import { + OAuthClientRegistrationRequestSchema, + parseOAuthTokenRequest, + type OAuthClientRegistrationRequest, +} from './schemas' +import { + exchangeAuthorizationCode, + registerOAuthClient, + rotateRefreshToken, +} from './store' +import { oauthRegistrationRateLimit, oauthTokenRateLimit } from './rateLimit' + +const OAUTH_REQUEST_MAX_BYTES = 32 * 1024 + +export function tryHandleMcpOAuth( + req: Request, + db: DbClient, + pathname: string, +): Promise | Response | null { + if ( + pathname === MCP_PROTECTED_RESOURCE_METADATA_PATH || + pathname === MCP_PATH_PROTECTED_RESOURCE_METADATA_PATH + ) { + return req.method === 'GET' ? protectedResourceMetadata(req) : methodNotAllowed() + } + if (pathname === MCP_AUTHORIZATION_SERVER_METADATA_PATH) { + return req.method === 'GET' ? authorizationServerMetadata(req) : methodNotAllowed() + } + if (pathname === MCP_OAUTH_REGISTER_PATH) { + return req.method === 'POST' ? handleClientRegistration(req, db) : methodNotAllowed() + } + if (pathname === MCP_OAUTH_TOKEN_PATH) { + return req.method === 'POST' ? handleTokenRequest(req, db) : methodNotAllowed() + } + return null +} + +function protectedResourceMetadata(req: Request): Response { + const endpoints = mcpOAuthEndpoints(req) + return metadataResponse({ + resource: mcpResource(req), + resource_name: 'Instatic MCP', + authorization_servers: [endpoints.issuer], + scopes_supported: [...MCP_OAUTH_SUPPORTED_SCOPES], + bearer_methods_supported: ['header'], + }) +} + +function authorizationServerMetadata(req: Request): Response { + const endpoints = mcpOAuthEndpoints(req) + return metadataResponse({ + issuer: endpoints.issuer, + authorization_endpoint: endpoints.authorizationEndpoint, + token_endpoint: endpoints.tokenEndpoint, + registration_endpoint: endpoints.registrationEndpoint, + response_types_supported: ['code'], + grant_types_supported: ['authorization_code', 'refresh_token'], + code_challenge_methods_supported: ['S256'], + token_endpoint_auth_methods_supported: ['none'], + scopes_supported: [...MCP_OAUTH_SUPPORTED_SCOPES], + }) +} + +async function handleClientRegistration(req: Request, db: DbClient): Promise { + const limited = consumeRateLimit(req, oauthRegistrationRateLimit) + if (limited) return limited + + let body: OAuthClientRegistrationRequest | null + try { + body = await readValidatedBody(req, OAuthClientRegistrationRequestSchema, { + maxBytes: OAUTH_REQUEST_MAX_BYTES, + }) + } catch (err) { + if (err instanceof RequestBodyTooLargeError) { + return oauthError('invalid_client_metadata', 'Client registration is too large.', 400) + } + throw err + } + if (!body) return oauthError('invalid_client_metadata', 'Invalid client registration.', 400) + const clientName = body.client_name.trim() + if ( + !clientName || + (body.grant_types !== undefined && !body.grant_types.includes('authorization_code')) || + (body.response_types !== undefined && !body.response_types.includes('code')) || + body.grant_types?.some((grant) => grant !== 'authorization_code' && grant !== 'refresh_token') || + body.response_types?.some((responseType) => responseType !== 'code') + ) { + return oauthError('invalid_client_metadata', 'Only authorization_code with PKCE is supported.', 400) + } + + const redirectUris = [...new Set(body.redirect_uris)] + if (redirectUris.some((uri) => !parseAllowedRedirectUri(uri))) { + return oauthError( + 'invalid_redirect_uri', + 'Redirect URIs must use HTTPS, or HTTP on a loopback host.', + 400, + ) + } + + try { + const client = await registerOAuthClient(db, { + clientName, + redirectUris, + }) + return privateJsonResponse({ + client_id: client.clientId, + client_id_issued_at: client.clientIdIssuedAt, + client_name: client.clientName, + redirect_uris: [...client.redirectUris], + token_endpoint_auth_method: 'none', + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + }, { status: 201 }) + } catch (err) { + console.error('[ai:mcp:oauth] client registration failed:', err) + return oauthError('server_error', 'Client registration failed.', 500) + } +} + +async function handleTokenRequest(req: Request, db: DbClient): Promise { + const limited = consumeRateLimit(req, oauthTokenRateLimit) + if (limited) return limited + + const contentType = req.headers.get('content-type')?.toLowerCase() ?? '' + if (!contentType.startsWith('application/x-www-form-urlencoded')) { + return oauthError('invalid_request', 'The token endpoint requires form-encoded input.', 400) + } + if (req.headers.has('authorization')) { + return oauthError('invalid_client', 'This authorization server accepts public clients only.', 401) + } + + let params: URLSearchParams + try { + params = new URLSearchParams(await readTextBodyWithLimit(req, OAUTH_REQUEST_MAX_BYTES)) + } catch (err) { + if (err instanceof RequestBodyTooLargeError) { + return oauthError('invalid_request', 'The token request is too large.', 400) + } + return oauthError('invalid_request', 'Invalid token request.', 400) + } + const body = parseOAuthTokenRequest(params) + if (!body) { + const grantType = params.get('grant_type') + return grantType === 'authorization_code' || grantType === 'refresh_token' + ? oauthError('invalid_request', 'The token request is invalid.', 400) + : oauthError('unsupported_grant_type', 'The token grant is not supported.', 400) + } + if (body.resource !== mcpResource(req)) { + return oauthError('invalid_target', 'The requested resource does not match this MCP server.', 400) + } + + try { + const tokens = body.grant_type === 'authorization_code' + ? await exchangeAuthorizationCode(db, { + code: body.code, + clientId: body.client_id, + redirectUri: body.redirect_uri, + codeVerifier: body.code_verifier, + resource: body.resource, + }) + : await rotateRefreshToken(db, { + refreshToken: body.refresh_token, + clientId: body.client_id, + resource: body.resource, + scope: body.scope, + }) + + if (!tokens) return oauthError('invalid_grant', 'The authorization grant is invalid or expired.', 400) + return privateJsonResponse({ + access_token: tokens.accessToken, + token_type: 'Bearer', + expires_in: tokens.expiresIn, + refresh_token: tokens.refreshToken, + scope: tokens.scope, + }) + } catch (err) { + console.error('[ai:mcp:oauth] token exchange failed:', err) + return oauthError('server_error', 'Token exchange failed.', 500) + } +} + +function metadataResponse(body: unknown): Response { + return jsonResponse(body, { + headers: { 'Cache-Control': 'public, max-age=300' }, + }) +} + +function oauthError(error: string, description: string, status: number): Response { + return privateJsonResponse({ error, error_description: description }, { status }) +} + +function privateJsonResponse(body: unknown, init: ResponseInit = {}): Response { + const headers = new Headers(init.headers) + headers.set('Cache-Control', 'no-store') + headers.set('Pragma', 'no-cache') + return jsonResponse(body, { ...init, headers }) +} + +function consumeRateLimit( + req: Request, + limiter: { consume: (key: string) => { ok: boolean; retryAfterMs: number } }, +): Response | null { + const decision = limiter.consume(clientIp(req) ?? 'unknown') + if (decision.ok) return null + return privateJsonResponse({ error: 'temporarily_unavailable', error_description: 'Too many requests.' }, { + status: 429, + headers: { 'Retry-After': String(Math.max(1, Math.ceil(decision.retryAfterMs / 1000))) }, + }) +} diff --git a/server/ai/mcp/oauth/protocol.ts b/server/ai/mcp/oauth/protocol.ts new file mode 100644 index 000000000..6fa6b67aa --- /dev/null +++ b/server/ai/mcp/oauth/protocol.ts @@ -0,0 +1,123 @@ +import { expectedOrigin } from '../../../auth/security' +import { + MCP_AUTHORIZATION_SERVER_METADATA_PATH, + MCP_ENDPOINT_PATH, + MCP_OAUTH_AUTHORIZE_PATH, + MCP_OAUTH_REGISTER_PATH, + MCP_OAUTH_TOKEN_PATH, + MCP_PROTECTED_RESOURCE_METADATA_PATH, +} from '../paths' + +export const MCP_OAUTH_SCOPE = 'mcp' +export const MCP_OAUTH_OFFLINE_SCOPE = 'offline_access' +export const MCP_OAUTH_SUPPORTED_SCOPES = [MCP_OAUTH_SCOPE, MCP_OAUTH_OFFLINE_SCOPE] as const + +export function mcpIssuer(req: Request): string { + return expectedOrigin(req) +} + +export function mcpResource(req: Request): string { + return `${mcpIssuer(req)}${MCP_ENDPOINT_PATH}` +} + +export function mcpProtectedResourceMetadataUrl(req: Request): string { + return `${mcpIssuer(req)}${MCP_PROTECTED_RESOURCE_METADATA_PATH}` +} + +export function mcpOAuthEndpoints(req: Request) { + const issuer = mcpIssuer(req) + return { + issuer, + authorizationEndpoint: `${issuer}${MCP_OAUTH_AUTHORIZE_PATH}`, + tokenEndpoint: `${issuer}${MCP_OAUTH_TOKEN_PATH}`, + registrationEndpoint: `${issuer}${MCP_OAUTH_REGISTER_PATH}`, + authorizationServerMetadata: `${issuer}${MCP_AUTHORIZATION_SERVER_METADATA_PATH}`, + } +} + +export function normalizeMcpScope(raw: string | undefined): string | null { + const requested = new Set((raw ?? MCP_OAUTH_SCOPE).split(/\s+/).filter(Boolean)) + if (!requested.has(MCP_OAUTH_SCOPE)) return null + for (const scope of requested) { + if (!MCP_OAUTH_SUPPORTED_SCOPES.includes(scope as typeof MCP_OAUTH_SUPPORTED_SCOPES[number])) { + return null + } + } + return MCP_OAUTH_SUPPORTED_SCOPES.filter((scope) => requested.has(scope)).join(' ') +} + +export function isValidPkceChallenge(value: string): boolean { + return /^[A-Za-z0-9_-]{43}$/.test(value) +} + +export function isValidPkceVerifier(value: string): boolean { + return /^[A-Za-z0-9._~-]{43,128}$/.test(value) +} + +export function parseAllowedRedirectUri(raw: string): URL | null { + let url: URL + try { + url = new URL(raw) + } catch { + return null + } + if (url.username || url.password || url.hash) return null + if (url.protocol === 'https:') return url + if (url.protocol !== 'http:') return null + return isLoopbackHostname(url.hostname) ? url : null +} + +export function isRemoteMcpEndpoint(endpoint: string): boolean { + const url = new URL(endpoint) + return url.protocol === 'https:' && !isPrivateNetworkHostname(url.hostname) +} + +export function oauthRedirect( + redirectUri: string, + params: Record, +): string { + const url = new URL(redirectUri) + for (const [key, value] of Object.entries(params)) { + if (value !== undefined) url.searchParams.set(key, value) + } + return url.toString() +} + +function isLoopbackHostname(hostname: string): boolean { + const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, '') + return normalized === 'localhost' || normalized.endsWith('.localhost') || + normalized.startsWith('127.') || normalized === '::1' +} + +function isPrivateNetworkHostname(hostname: string): boolean { + const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, '') + if (isLoopbackHostname(normalized)) return true + if ( + (!normalized.includes('.') && !normalized.includes(':')) || normalized.endsWith('.local') || + normalized.endsWith('.internal') || normalized.endsWith('.lan') || + normalized.endsWith('.test') || normalized.endsWith('.example') || + normalized.endsWith('.invalid') + ) { + return true + } + + const octets = normalized.split('.').map(Number) + if (octets.length === 4 && octets.every((octet) => Number.isInteger(octet) && octet >= 0 && octet <= 255)) { + const [first, second] = octets as [number, number, number, number] + return first === 0 || first === 10 || first === 127 || first >= 224 || + (first === 100 && second >= 64 && second <= 127) || + (first === 169 && second === 254) || + (first === 172 && second >= 16 && second <= 31) || + (first === 192 && (second === 0 || second === 168)) || + (first === 198 && (second === 18 || second === 19 || second === 51)) || + (first === 203 && second === 0) + } + + if (normalized.includes(':')) { + if (normalized === '::' || normalized.startsWith('::ffff:')) return true + const firstHextet = parseInt(normalized.split(':')[0] ?? '', 16) + return (firstHextet >= 0xfc00 && firstHextet <= 0xfdff) || + (firstHextet >= 0xfe80 && firstHextet <= 0xfebf) + } + return false +} diff --git a/server/ai/mcp/oauth/rateLimit.ts b/server/ai/mcp/oauth/rateLimit.ts new file mode 100644 index 000000000..03c32796a --- /dev/null +++ b/server/ai/mcp/oauth/rateLimit.ts @@ -0,0 +1,11 @@ +import { RateLimiter } from '../../../auth/rateLimit' + +export const oauthRegistrationRateLimit = new RateLimiter({ + limit: 30, + windowMs: 60 * 60 * 1000, +}) + +export const oauthTokenRateLimit = new RateLimiter({ + limit: 60, + windowMs: 10 * 60 * 1000, +}) diff --git a/server/ai/mcp/oauth/schemas.ts b/server/ai/mcp/oauth/schemas.ts new file mode 100644 index 000000000..10e39ef54 --- /dev/null +++ b/server/ai/mcp/oauth/schemas.ts @@ -0,0 +1,78 @@ +import { Type, safeParseValue, type Static } from '@core/utils/typeboxHelpers' +import { + McpOAuthAuthorizationRequestSchema, + type McpOAuthAuthorizationRequest, +} from '@core/ai' + +export const OAuthClientRegistrationRequestSchema = Type.Object({ + client_name: Type.String({ minLength: 1, maxLength: 120 }), + redirect_uris: Type.Array(Type.String({ minLength: 1, maxLength: 4096 }), { + minItems: 1, + maxItems: 10, + }), + token_endpoint_auth_method: Type.Optional(Type.Literal('none')), + grant_types: Type.Optional(Type.Array(Type.String())), + response_types: Type.Optional(Type.Array(Type.String())), +}, { additionalProperties: true }) +export type OAuthClientRegistrationRequest = Static + +const AuthorizationCodeTokenRequestSchema = Type.Object({ + grant_type: Type.Literal('authorization_code'), + code: Type.String({ minLength: 1, maxLength: 2048 }), + redirect_uri: Type.String({ minLength: 1, maxLength: 4096 }), + client_id: Type.String({ minLength: 1, maxLength: 2048 }), + code_verifier: Type.String({ minLength: 43, maxLength: 128 }), + resource: Type.String({ minLength: 1, maxLength: 4096 }), +}, { additionalProperties: true }) +export type AuthorizationCodeTokenRequest = Static + +const RefreshTokenRequestSchema = Type.Object({ + grant_type: Type.Literal('refresh_token'), + refresh_token: Type.String({ minLength: 1, maxLength: 2048 }), + client_id: Type.String({ minLength: 1, maxLength: 2048 }), + resource: Type.String({ minLength: 1, maxLength: 4096 }), + scope: Type.Optional(Type.String({ minLength: 1, maxLength: 256 })), +}, { additionalProperties: true }) +export type RefreshTokenRequest = Static + +export type OAuthTokenRequest = AuthorizationCodeTokenRequest | RefreshTokenRequest + +export function parseOAuthTokenRequest(params: URLSearchParams): OAuthTokenRequest | null { + if (hasDuplicateParameters(params)) return null + const raw = Object.fromEntries(params.entries()) + const schema = raw.grant_type === 'authorization_code' + ? AuthorizationCodeTokenRequestSchema + : raw.grant_type === 'refresh_token' + ? RefreshTokenRequestSchema + : null + if (!schema) return null + const parsed = safeParseValue(schema, raw) + return parsed.ok ? parsed.value : null +} + +export function parseOAuthAuthorizationRequest( + params: URLSearchParams, +): McpOAuthAuthorizationRequest | null { + if (hasDuplicateParameters(params)) return null + const raw = { + responseType: params.get('response_type'), + clientId: params.get('client_id'), + redirectUri: params.get('redirect_uri'), + codeChallenge: params.get('code_challenge'), + codeChallengeMethod: params.get('code_challenge_method'), + scope: params.get('scope') ?? 'mcp', + resource: params.get('resource'), + ...(params.has('state') ? { state: params.get('state') } : {}), + } + const parsed = safeParseValue(McpOAuthAuthorizationRequestSchema, raw) + return parsed.ok ? parsed.value : null +} + +function hasDuplicateParameters(params: URLSearchParams): boolean { + const names = new Set() + for (const name of params.keys()) { + if (names.has(name)) return true + names.add(name) + } + return false +} diff --git a/server/ai/mcp/oauth/store.test.ts b/server/ai/mcp/oauth/store.test.ts new file mode 100644 index 000000000..fe6cc8830 --- /dev/null +++ b/server/ai/mcp/oauth/store.test.ts @@ -0,0 +1,181 @@ +import { beforeEach, describe, expect, it, spyOn } from 'bun:test' +import type { McpOAuthAuthorizationRequest } from '@core/ai' +import type { DbClient } from '../../../db/client' +import { sqliteMigrations } from '../../../db/migrations-sqlite' +import { runMigrations } from '../../../db/runMigrations' +import { createSqliteClient } from '../../../db/sqlite' +import { revokeConnector } from '../connectors/store' +import { pkceChallengeForVerifier } from '../connectors/token' +import { + createOAuthAuthorizationGrant, + exchangeAuthorizationCode, + findOAuthAccessGrant, + registerOAuthClient, + rotateRefreshToken, +} from './store' + +const RESOURCE = 'https://cms.example.com/_instatic/mcp' +const REDIRECT_URI = 'https://claude.ai/api/mcp/auth_callback' +const VERIFIER = 'a'.repeat(64) + +async function freshDb(): Promise { + const db = createSqliteClient(':memory:') + await runMigrations(db, sqliteMigrations) + await db` + insert into users (id, email, email_normalized, display_name, password_hash, role_id) + values ('u1', 'u1@example.com', 'u1@example.com', 'User One', 'x', 'owner') + ` + return db +} + +let db: DbClient +let request: McpOAuthAuthorizationRequest + +beforeEach(async () => { + db = await freshDb() + const client = await registerOAuthClient(db, { + clientName: 'Claude', + redirectUris: [REDIRECT_URI], + }) + request = { + responseType: 'code', + clientId: client.clientId, + redirectUri: REDIRECT_URI, + codeChallenge: await pkceChallengeForVerifier(VERIFIER), + codeChallengeMethod: 'S256', + scope: 'mcp offline_access', + resource: RESOURCE, + state: 'opaque-state', + } +}) + +describe('MCP OAuth grant store', () => { + it('exchanges a one-time PKCE code for resource-bound access and refresh tokens', async () => { + const { connection, code } = await createOAuthAuthorizationGrant(db, { + userId: 'u1', + clientName: 'Claude', + capabilities: ['ai.chat', 'site.read'], + request, + }) + + const tokens = await exchangeAuthorizationCode(db, { + code, + clientId: request.clientId, + redirectUri: request.redirectUri, + codeVerifier: VERIFIER, + resource: RESOURCE, + }) + expect(tokens?.accessToken).toMatch(/^imcp_at_/) + expect(tokens?.refreshToken).toMatch(/^imcp_rt_/) + expect(tokens?.expiresIn).toBeLessThanOrEqual(3600) + + const access = await findOAuthAccessGrant(db, tokens!.accessToken, RESOURCE) + expect(access).toEqual({ + connectorId: connection.id, + userId: 'u1', + capabilities: ['ai.chat', 'site.read'], + }) + expect(await findOAuthAccessGrant(db, tokens!.accessToken, 'https://other.example/mcp')).toBeNull() + + expect(await exchangeAuthorizationCode(db, { + code, + clientId: request.clientId, + redirectUri: request.redirectUri, + codeVerifier: VERIFIER, + resource: RESOURCE, + })).toBeNull() + }) + + it('rejects a code when the verifier, callback, client, or resource differs', async () => { + const { code } = await createOAuthAuthorizationGrant(db, { + userId: 'u1', + clientName: 'Claude', + capabilities: ['site.read'], + request, + }) + + expect(await exchangeAuthorizationCode(db, { + code, + clientId: request.clientId, + redirectUri: request.redirectUri, + codeVerifier: 'b'.repeat(64), + resource: RESOURCE, + })).toBeNull() + expect(await exchangeAuthorizationCode(db, { + code, + clientId: 'different-client', + redirectUri: request.redirectUri, + codeVerifier: VERIFIER, + resource: RESOURCE, + })).toBeNull() + expect(await exchangeAuthorizationCode(db, { + code, + clientId: request.clientId, + redirectUri: 'https://attacker.example/callback', + codeVerifier: VERIFIER, + resource: RESOURCE, + })).toBeNull() + expect(await exchangeAuthorizationCode(db, { + code, + clientId: request.clientId, + redirectUri: request.redirectUri, + codeVerifier: VERIFIER, + resource: 'https://attacker.example/mcp', + })).toBeNull() + }) + + it('rotates refresh tokens and invalidates the previous refresh token', async () => { + const warn = spyOn(console, 'warn').mockImplementation(() => {}) + const { code } = await createOAuthAuthorizationGrant(db, { + userId: 'u1', + clientName: 'Claude', + capabilities: ['site.read'], + request, + }) + const initial = await exchangeAuthorizationCode(db, { + code, + clientId: request.clientId, + redirectUri: request.redirectUri, + codeVerifier: VERIFIER, + resource: RESOURCE, + }) + const rotated = await rotateRefreshToken(db, { + refreshToken: initial!.refreshToken, + clientId: request.clientId, + resource: RESOURCE, + scope: 'mcp', + }) + expect(rotated?.accessToken).not.toBe(initial?.accessToken) + expect(rotated?.refreshToken).not.toBe(initial?.refreshToken) + expect(rotated?.scope).toBe('mcp') + + expect(await rotateRefreshToken(db, { + refreshToken: initial!.refreshToken, + clientId: request.clientId, + resource: RESOURCE, + })).toBeNull() + expect(await findOAuthAccessGrant(db, rotated!.accessToken, RESOURCE)).toBeNull() + expect(warn).toHaveBeenCalledTimes(1) + warn.mockRestore() + }) + + it('invalidates OAuth access when the connection is revoked', async () => { + const { connection, code } = await createOAuthAuthorizationGrant(db, { + userId: 'u1', + clientName: 'Claude', + capabilities: ['site.read'], + request, + }) + const tokens = await exchangeAuthorizationCode(db, { + code, + clientId: request.clientId, + redirectUri: request.redirectUri, + codeVerifier: VERIFIER, + resource: RESOURCE, + }) + expect(await findOAuthAccessGrant(db, tokens!.accessToken, RESOURCE)).not.toBeNull() + + expect(await revokeConnector(db, connection.id, 'u1')).toBe(true) + expect(await findOAuthAccessGrant(db, tokens!.accessToken, RESOURCE)).toBeNull() + }) +}) diff --git a/server/ai/mcp/oauth/store.ts b/server/ai/mcp/oauth/store.ts new file mode 100644 index 000000000..986894539 --- /dev/null +++ b/server/ai/mcp/oauth/store.ts @@ -0,0 +1,349 @@ +import { nanoid } from 'nanoid' +import type { CoreCapability } from '@core/capabilities' +import type { McpOAuthAuthorizationRequest } from '@core/ai' +import type { DbClient } from '../../../db/client' +import { createOAuthConnection } from '../connectors/store' +import type { McpConnectorRecord } from '../connectors/types' +import { + generateOAuthAccessToken, + generateOAuthAuthorizationCode, + generateOAuthRefreshToken, + hashMcpSecret, + pkceChallengeForVerifier, +} from '../connectors/token' +import { isValidPkceVerifier, normalizeMcpScope } from './protocol' + +const AUTHORIZATION_CODE_TTL_MS = 5 * 60 * 1000 +export const OAUTH_ACCESS_TOKEN_TTL_SECONDS = 60 * 60 + +interface OAuthClientRow { + client_id: string + client_name: string + redirect_uris_json: string[] + client_id_issued_at: number +} + +export interface OAuthClientRecord { + clientId: string + clientName: string + redirectUris: readonly string[] + clientIdIssuedAt: number +} + +interface AuthorizationCodeRow { + code_hash: string + connector_id: string + client_id: string + redirect_uri: string + code_challenge: string + scope: string + resource: string + expires_at: string + consumed_at: string | null + connector_expires_at: string | null + connector_revoked_at: string | null +} + +interface OAuthTokenRow { + id: string + connector_id: string + client_id: string + scope: string + resource: string + expires_at: string + revoked_at: string | null + connector_expires_at: string | null + connector_revoked_at: string | null +} + +interface OAuthAccessGrantRow { + connector_id: string + user_id: string + capabilities_json: CoreCapability[] +} + +export interface OAuthAccessGrant { + connectorId: string + userId: string + capabilities: readonly CoreCapability[] +} + +export interface OAuthTokenPair { + accessToken: string + refreshToken: string + scope: string + expiresIn: number +} + +function rowToClient(row: OAuthClientRow): OAuthClientRecord { + return { + clientId: row.client_id, + clientName: row.client_name, + redirectUris: Array.isArray(row.redirect_uris_json) ? row.redirect_uris_json : [], + clientIdIssuedAt: row.client_id_issued_at, + } +} + +export async function registerOAuthClient( + db: DbClient, + input: { clientName: string; redirectUris: readonly string[] }, +): Promise { + const clientId = `imcp_client_${nanoid(32)}` + const issuedAt = Math.floor(Date.now() / 1000) + const redirectUrisJson = JSON.stringify(input.redirectUris) + const { rows } = await db` + insert into ai_mcp_oauth_clients ( + client_id, client_name, redirect_uris_json, client_id_issued_at + ) + values (${clientId}, ${input.clientName}, ${redirectUrisJson}, ${issuedAt}) + returning client_id, client_name, redirect_uris_json, client_id_issued_at + ` + if (!rows[0]) throw new Error('OAuth client registration did not persist') + return rowToClient(rows[0]) +} + +export async function findOAuthClient(db: DbClient, clientId: string): Promise { + const { rows } = await db` + select client_id, client_name, redirect_uris_json, client_id_issued_at + from ai_mcp_oauth_clients + where client_id = ${clientId} + limit 1 + ` + return rows[0] ? rowToClient(rows[0]) : null +} + +export async function createOAuthAuthorizationGrant( + db: DbClient, + input: { + userId: string + clientName: string + capabilities: readonly CoreCapability[] + request: McpOAuthAuthorizationRequest + }, +): Promise<{ connection: McpConnectorRecord; code: string }> { + const code = generateOAuthAuthorizationCode() + const codeHash = await hashMcpSecret(code) + const expiresAt = new Date(Date.now() + AUTHORIZATION_CODE_TTL_MS) + + return db.transaction(async (tx) => { + const connection = await createOAuthConnection(tx, { + userId: input.userId, + label: input.clientName, + capabilities: input.capabilities, + }) + await tx` + insert into ai_mcp_oauth_codes ( + code_hash, connector_id, client_id, redirect_uri, code_challenge, + scope, resource, expires_at + ) + values ( + ${codeHash}, ${connection.id}, ${input.request.clientId}, + ${input.request.redirectUri}, ${input.request.codeChallenge}, + ${input.request.scope}, ${input.request.resource}, ${expiresAt} + ) + ` + return { connection, code } + }) +} + +export async function exchangeAuthorizationCode( + db: DbClient, + input: { + code: string + clientId: string + redirectUri: string + codeVerifier: string + resource: string + }, + now: Date = new Date(), +): Promise { + if (!isValidPkceVerifier(input.codeVerifier)) return null + const codeHash = await hashMcpSecret(input.code) + const challenge = await pkceChallengeForVerifier(input.codeVerifier) + + return db.transaction(async (tx) => { + const { rows } = await tx` + select c.code_hash, c.connector_id, c.client_id, c.redirect_uri, + c.code_challenge, c.scope, c.resource, c.expires_at, c.consumed_at, + g.expires_at as connector_expires_at, + g.revoked_at as connector_revoked_at + from ai_mcp_oauth_codes c + join ai_mcp_connectors g on g.id = c.connector_id + where c.code_hash = ${codeHash} + limit 1 + ` + const code = rows[0] + if ( + !code || code.consumed_at || code.connector_revoked_at || + new Date(code.expires_at).getTime() <= now.getTime() || + !code.connector_expires_at || new Date(code.connector_expires_at).getTime() <= now.getTime() || + code.client_id !== input.clientId || code.redirect_uri !== input.redirectUri || + code.resource !== input.resource || code.code_challenge !== challenge + ) { + return null + } + + const consumed = await tx` + update ai_mcp_oauth_codes + set consumed_at = current_timestamp + where code_hash = ${codeHash} and consumed_at is null + ` + if (consumed.rowCount !== 1) return null + + return issueTokenPair(tx, { + connectorId: code.connector_id, + clientId: code.client_id, + scope: code.scope, + resource: code.resource, + connectorExpiresAt: new Date(code.connector_expires_at), + now, + }) + }) +} + +export async function rotateRefreshToken( + db: DbClient, + input: { + refreshToken: string + clientId: string + resource: string + scope?: string + }, + now: Date = new Date(), +): Promise { + const tokenHash = await hashMcpSecret(input.refreshToken) + + return db.transaction(async (tx) => { + const { rows } = await tx` + select t.id, t.connector_id, t.client_id, t.scope, t.resource, + t.expires_at, t.revoked_at, + g.expires_at as connector_expires_at, + g.revoked_at as connector_revoked_at + from ai_mcp_oauth_tokens t + join ai_mcp_connectors g on g.id = t.connector_id + where t.token_hash = ${tokenHash} and t.kind = 'refresh' + limit 1 + ` + const token = rows[0] + const requestedScope = normalizeMcpScope(input.scope ?? token?.scope) + + // A rotated refresh token should never be presented again. Treat reuse as + // credential theft and revoke the entire grant, including access tokens + // minted from the replacement token. The client must re-authorize. + if (token?.revoked_at && !token.connector_revoked_at) { + await tx` + update ai_mcp_connectors + set revoked_at = current_timestamp + where id = ${token.connector_id} and revoked_at is null + ` + await tx` + update ai_mcp_oauth_tokens + set revoked_at = current_timestamp + where connector_id = ${token.connector_id} and revoked_at is null + ` + console.warn(`[ai:mcp:oauth] refresh-token reuse revoked connection ${token.connector_id}`) + return null + } + if ( + !token || token.revoked_at || token.connector_revoked_at || !requestedScope || + new Date(token.expires_at).getTime() <= now.getTime() || + !token.connector_expires_at || new Date(token.connector_expires_at).getTime() <= now.getTime() || + token.client_id !== input.clientId || token.resource !== input.resource || + !scopeIsSubset(requestedScope, token.scope) + ) { + return null + } + + const revoked = await tx` + update ai_mcp_oauth_tokens + set revoked_at = current_timestamp + where id = ${token.id} and revoked_at is null + ` + if (revoked.rowCount !== 1) return null + + return issueTokenPair(tx, { + connectorId: token.connector_id, + clientId: token.client_id, + scope: requestedScope, + resource: token.resource, + connectorExpiresAt: new Date(token.connector_expires_at), + now, + }) + }) +} + +export async function findOAuthAccessGrant( + db: DbClient, + token: string, + resource: string, + now: Date = new Date(), +): Promise { + const tokenHash = await hashMcpSecret(token) + const { rows } = await db` + select t.connector_id, g.user_id, g.capabilities_json + from ai_mcp_oauth_tokens t + join ai_mcp_connectors g on g.id = t.connector_id + where t.token_hash = ${tokenHash} + and t.kind = 'access' + and t.resource = ${resource} + and t.revoked_at is null + and t.expires_at > ${now} + and g.revoked_at is null + and g.expires_at > ${now} + limit 1 + ` + const row = rows[0] + if (!row) return null + return { + connectorId: row.connector_id, + userId: row.user_id, + capabilities: Array.isArray(row.capabilities_json) ? row.capabilities_json : [], + } +} + +async function issueTokenPair( + db: DbClient, + input: { + connectorId: string + clientId: string + scope: string + resource: string + connectorExpiresAt: Date + now: Date + }, +): Promise { + const accessToken = generateOAuthAccessToken() + const refreshToken = generateOAuthRefreshToken() + const accessExpiresAt = new Date(Math.min( + input.now.getTime() + OAUTH_ACCESS_TOKEN_TTL_SECONDS * 1000, + input.connectorExpiresAt.getTime(), + )) + const expiresIn = Math.max(1, Math.floor((accessExpiresAt.getTime() - input.now.getTime()) / 1000)) + + await db` + insert into ai_mcp_oauth_tokens ( + id, connector_id, client_id, kind, token_hash, scope, resource, expires_at + ) + values ( + ${nanoid()}, ${input.connectorId}, ${input.clientId}, 'access', + ${await hashMcpSecret(accessToken)}, ${input.scope}, ${input.resource}, ${accessExpiresAt} + ) + ` + await db` + insert into ai_mcp_oauth_tokens ( + id, connector_id, client_id, kind, token_hash, scope, resource, expires_at + ) + values ( + ${nanoid()}, ${input.connectorId}, ${input.clientId}, 'refresh', + ${await hashMcpSecret(refreshToken)}, ${input.scope}, ${input.resource}, + ${input.connectorExpiresAt} + ) + ` + + return { accessToken, refreshToken, scope: input.scope, expiresIn } +} + +function scopeIsSubset(requested: string, granted: string): boolean { + const grantedScopes = new Set(granted.split(/\s+/).filter(Boolean)) + return requested.split(/\s+/).filter(Boolean).every((scope) => grantedScopes.has(scope)) +} diff --git a/server/ai/mcp/paths.ts b/server/ai/mcp/paths.ts new file mode 100644 index 000000000..745982f9f --- /dev/null +++ b/server/ai/mcp/paths.ts @@ -0,0 +1,9 @@ +export const MCP_ENDPOINT_PATH = '/_instatic/mcp' +export const MCP_PROTECTED_RESOURCE_METADATA_PATH = '/.well-known/oauth-protected-resource' +export const MCP_PATH_PROTECTED_RESOURCE_METADATA_PATH = + '/.well-known/oauth-protected-resource/_instatic/mcp' +export const MCP_AUTHORIZATION_SERVER_METADATA_PATH = '/.well-known/oauth-authorization-server' +export const MCP_OAUTH_REGISTER_PATH = '/_instatic/oauth/register' +export const MCP_OAUTH_TOKEN_PATH = '/_instatic/oauth/token' +export const MCP_OAUTH_AUTHORIZE_PATH = '/admin/ai/oauth/authorize' +export const MCP_OAUTH_AUTHORIZATION_API_PATH = '/admin/api/ai/mcp/oauth/authorization' diff --git a/server/ai/mcp/publishTool.test.ts b/server/ai/mcp/publishTool.test.ts index 07ca62783..8c4e040b3 100644 --- a/server/ai/mcp/publishTool.test.ts +++ b/server/ai/mcp/publishTool.test.ts @@ -5,8 +5,8 @@ import { join } from 'node:path' import { createCapabilityTestHarness, type CapabilityTestHarness } from '../../../src/__tests__/helpers/capabilityHarness' import { getDraftSite, saveDraftSite } from '../../repositories/site' import { readArtefact, readStaticAsset } from '../../publish/staticArtefact' -import { createConnector } from './connectors/store' -import { generateConnectorToken, hashConnectorToken } from './connectors/token' +import { createBearerConnection } from './connectors/store' +import { generatePersonalAccessToken, hashMcpSecret } from './connectors/token' import { handleMcpHttp } from './transports/http' interface AuditRow { @@ -89,13 +89,12 @@ describe('site_publish MCP tool', () => { const userId = users[0]?.id if (!userId) throw new Error('owner user was not seeded') - const token = generateConnectorToken() - const connector = await createConnector(harness.db, { + const token = generatePersonalAccessToken() + const connector = await createBearerConnection(harness.db, { userId, label: 'Publish regression', - type: 'local', capabilities: ['ai.chat', 'ai.tools.write', 'pages.publish'], - tokenHash: await hashConnectorToken(token), + tokenHash: await hashMcpSecret(token), }) await callMcp(harness, uploadsDir, token, 'initialize', { protocolVersion: '2025-06-18', diff --git a/server/ai/mcp/transports/http.test.ts b/server/ai/mcp/transports/http.test.ts index fcba6e261..832c06a2f 100644 --- a/server/ai/mcp/transports/http.test.ts +++ b/server/ai/mcp/transports/http.test.ts @@ -3,8 +3,8 @@ import { createSqliteClient } from '../../../db/sqlite' import { sqliteMigrations } from '../../../db/migrations-sqlite' import { runMigrations } from '../../../db/runMigrations' import type { DbClient } from '../../../db/client' -import { createConnector } from '../connectors/store' -import { generateConnectorToken, hashConnectorToken } from '../connectors/token' +import { createBearerConnection } from '../connectors/store' +import { generatePersonalAccessToken, hashMcpSecret } from '../connectors/token' import { handleMcpHttp } from './http' function initBody() { @@ -38,10 +38,10 @@ beforeEach(async () => { insert into users (id, email, email_normalized, display_name, password_hash, role_id) values ('u1', 'u1@example.com', 'u1@example.com', 'User One', 'x', 'owner') ` - token = generateConnectorToken() - await createConnector(db, { - userId: 'u1', label: 'L', type: 'local', - capabilities: ['ai.chat', 'content.manage', 'site.read'], tokenHash: await hashConnectorToken(token), + token = generatePersonalAccessToken() + await createBearerConnection(db, { + userId: 'u1', label: 'L', + capabilities: ['ai.chat', 'content.manage', 'site.read'], tokenHash: await hashMcpSecret(token), }) }) diff --git a/server/ai/mcp/transports/http.ts b/server/ai/mcp/transports/http.ts index f3d5f11e9..b5c147740 100644 --- a/server/ai/mcp/transports/http.ts +++ b/server/ai/mcp/transports/http.ts @@ -16,8 +16,7 @@ import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/ import type { DbClient } from '../../../db/client' import { resolveMcpAuth, unauthorizedResponse } from '../auth' import { buildMcpServer } from '../server' - -export const MCP_ENDPOINT_PATH = '/_instatic/mcp' +import { MCP_ENDPOINT_PATH } from '../paths' interface McpHttpOptions { uploadsDir?: string @@ -32,7 +31,7 @@ export async function handleMcpHttp( if (url.pathname !== MCP_ENDPOINT_PATH) return null const auth = await resolveMcpAuth(req, db) - if (!auth.ok) return unauthorizedResponse(url) + if (!auth.ok) return unauthorizedResponse(req) const server = buildMcpServer({ db, diff --git a/server/db/migrations-pg.ts b/server/db/migrations-pg.ts index ba5397ec2..6fad59d68 100644 --- a/server/db/migrations-pg.ts +++ b/server/db/migrations-pg.ts @@ -1071,4 +1071,53 @@ export const pgMigrations: Migration[] = [ insert into site_sync_state (id, seq) values (1, 0); `, }, + { + // OAuth 2.1 authorization-code + PKCE support for hosted MCP clients. + // Connector rows remain the user/capability grant; these tables hold the + // dynamically registered public client, one-time authorization codes, and + // opaque access/refresh credentials for that grant. + id: '021_mcp_oauth', + sql: ` + create table if not exists ai_mcp_oauth_clients ( + client_id text primary key, + client_name text not null, + redirect_uris_json jsonb not null, + client_id_issued_at bigint not null, + created_at timestamptz not null default current_timestamp + ); + + create table if not exists ai_mcp_oauth_codes ( + code_hash text primary key, + connector_id text not null references ai_mcp_connectors(id) on delete cascade, + client_id text not null references ai_mcp_oauth_clients(client_id) on delete cascade, + redirect_uri text not null, + code_challenge text not null, + scope text not null, + resource text not null, + created_at timestamptz not null default current_timestamp, + expires_at timestamptz not null, + consumed_at timestamptz + ); + + create index if not exists ai_mcp_oauth_codes_connector_idx + on ai_mcp_oauth_codes (connector_id); + + create table if not exists ai_mcp_oauth_tokens ( + id text primary key, + connector_id text not null references ai_mcp_connectors(id) on delete cascade, + client_id text not null references ai_mcp_oauth_clients(client_id) on delete cascade, + kind text not null, + token_hash text not null unique, + scope text not null, + resource text not null, + created_at timestamptz not null default current_timestamp, + expires_at timestamptz not null, + revoked_at timestamptz, + constraint ai_mcp_oauth_tokens_kind_check check (kind in ('access', 'refresh')) + ); + + create index if not exists ai_mcp_oauth_tokens_connector_idx + on ai_mcp_oauth_tokens (connector_id); + `, + }, ] diff --git a/server/db/migrations-sqlite.ts b/server/db/migrations-sqlite.ts index f723e3d7a..40d5ba57b 100644 --- a/server/db/migrations-sqlite.ts +++ b/server/db/migrations-sqlite.ts @@ -1135,4 +1135,53 @@ export const sqliteMigrations: Migration[] = [ insert into site_sync_state (id, seq) values (1, 0); `, }, + { + // OAuth 2.1 authorization-code + PKCE support for hosted MCP clients. + // Connector rows remain the user/capability grant; these tables hold the + // dynamically registered public client, one-time authorization codes, and + // opaque access/refresh credentials for that grant. + id: '021_mcp_oauth', + sql: ` + create table if not exists ai_mcp_oauth_clients ( + client_id text primary key, + client_name text not null, + redirect_uris_json text not null, + client_id_issued_at integer not null, + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ','now')) + ); + + create table if not exists ai_mcp_oauth_codes ( + code_hash text primary key, + connector_id text not null references ai_mcp_connectors(id) on delete cascade, + client_id text not null references ai_mcp_oauth_clients(client_id) on delete cascade, + redirect_uri text not null, + code_challenge text not null, + scope text not null, + resource text not null, + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + expires_at text not null, + consumed_at text + ); + + create index if not exists ai_mcp_oauth_codes_connector_idx + on ai_mcp_oauth_codes (connector_id); + + create table if not exists ai_mcp_oauth_tokens ( + id text primary key, + connector_id text not null references ai_mcp_connectors(id) on delete cascade, + client_id text not null references ai_mcp_oauth_clients(client_id) on delete cascade, + kind text not null, + token_hash text not null unique, + scope text not null, + resource text not null, + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + expires_at text not null, + revoked_at text, + constraint ai_mcp_oauth_tokens_kind_check check (kind in ('access', 'refresh')) + ); + + create index if not exists ai_mcp_oauth_tokens_connector_idx + on ai_mcp_oauth_tokens (connector_id); + `, + }, ] diff --git a/server/http.ts b/server/http.ts index 4ce7a6e8f..a8e658554 100644 --- a/server/http.ts +++ b/server/http.ts @@ -84,7 +84,12 @@ async function readFormJsonField(req: Request, fieldName: string): Promise { - if (maxBytes < 1) throw new Error('readValidatedBody: maxBytes must be >= 1') + return JSON.parse(await readTextBodyWithLimit(req, maxBytes)) +} + +/** Read a UTF-8 request body with a hard streaming byte limit. */ +export async function readTextBodyWithLimit(req: Request, maxBytes: number): Promise { + if (maxBytes < 1) throw new Error('readTextBodyWithLimit: maxBytes must be >= 1') const contentLength = req.headers.get('content-length') if (contentLength) { const parsed = Number(contentLength) @@ -94,7 +99,7 @@ async function readJsonWithLimit(req: Request, maxBytes: number): Promise | Response | null { + return tryHandleMcpOAuth(req, runtime.db, pathname) +} + /** * AI runtime — provider-agnostic stack at `/admin/api/ai/*`. Handles chat * streams, browser bridge, credentials CRUD, conversation history, @@ -138,8 +152,8 @@ function tryServeAi(req: Request, runtime: ServerRuntime, url: URL, _pathname: s /** * MCP server endpoint (`/_instatic/mcp`). Authenticates per-connector via a - * bearer token (NOT the admin session cookie) and exposes the capability-gated - * CMS tool surface over the Model Context Protocol. Returns `null` for any + * OAuth or personal bearer token (NOT the admin session cookie) and exposes + * the capability-gated CMS tool surface over MCP. Returns `null` for any * other path so the dispatcher keeps walking. */ function tryServeMcp(req: Request, runtime: ServerRuntime, _url: URL, pathname: string): Promise | null { diff --git a/src/__tests__/ai/mcpConnectorsHandler.test.ts b/src/__tests__/ai/mcpConnectorsHandler.test.ts index 85a3afa4e..cda2b5dae 100644 --- a/src/__tests__/ai/mcpConnectorsHandler.test.ts +++ b/src/__tests__/ai/mcpConnectorsHandler.test.ts @@ -5,11 +5,12 @@ import { readJson, type CapabilityTestHarness, } from '../helpers/capabilityHarness' -import type { CreateMcpConnectorResult, McpConnectorList } from '@core/ai' +import type { CreateMcpAccessTokenResult, McpConnectionOverview } from '@core/ai' -const BASE = '/admin/api/ai/mcp/connectors' +const CONNECTIONS = '/admin/api/ai/mcp/connections' +const ACCESS_TOKENS = '/admin/api/ai/mcp/access-tokens' -describe('MCP connector handler', () => { +describe('MCP connection management handler', () => { let harness: CapabilityTestHarness let originalError: typeof console.error @@ -23,97 +24,98 @@ describe('MCP connector handler', () => { console.error = originalError }) - it('creates a connector and returns the token exactly once', async () => { + it('creates a personal access token and returns its plaintext exactly once', async () => { const cookie = await harness.setupOwner() - const res = await harness.ai(BASE, { + const res = await harness.ai(ACCESS_TOKENS, { method: 'POST', cookie, - json: { label: 'Claude Code', type: 'local', capabilities: ['ai.chat', 'content.manage'] }, + json: { label: 'Claude Code', capabilities: ['ai.chat', 'content.manage'] }, }) expect(res.status).toBe(201) - const created = await readJson(res) - expect(created.token).toMatch(/^imcp_/) - expect(created.connector.id).toBeTruthy() - expect(created.connector.revoked).toBe(false) + const created = await readJson(res) + expect(created.accessToken).toMatch(/^imcp_pat_/) + expect(created.connection.id).toBeTruthy() + expect(created.connection.authMode).toBe('bearer') + expect(created.connection.revoked).toBe(false) - // The token is never returned by the list endpoint. - const listRes = await harness.ai(BASE, { cookie }) + const listRes = await harness.ai(CONNECTIONS, { cookie }) expect(listRes.status).toBe(200) - const list = await readJson(listRes) - expect(list.connectors).toHaveLength(1) - expect(JSON.stringify(list)).not.toContain(created.token) + const overview = await readJson(listRes) + expect(overview.connections).toHaveLength(1) + expect(overview.endpoint).toBe('http://localhost/_instatic/mcp') + expect(overview.remoteAccess).toBe('local-only') + expect(JSON.stringify(overview)).not.toContain(created.accessToken) }) - it('requires fresh step-up authentication before minting a connector token', async () => { + it('requires fresh step-up authentication before minting a personal access token', async () => { await harness.setupOwner() const { cookie } = await harness.createRoleUser({ - name: 'Connector Manager', - slug: 'connector-manager', + name: 'Connection Manager', + slug: 'connection-manager', capabilities: ['ai.providers.manage', 'ai.chat'], }) - const res = await harness.ai(BASE, { + const res = await harness.ai(ACCESS_TOKENS, { method: 'POST', cookie, - json: { label: 'Sensitive token', type: 'local', capabilities: ['ai.chat'] }, + json: { label: 'Sensitive token', capabilities: ['ai.chat'] }, }) await expectStepUpRequired(res) }) - it('revokes a connector', async () => { + it('revokes a connection', async () => { const cookie = await harness.setupOwner() - const created = await readJson( - await harness.ai(BASE, { + const created = await readJson( + await harness.ai(ACCESS_TOKENS, { method: 'POST', cookie, - json: { label: 'L', type: 'remote', capabilities: ['ai.chat'] }, + json: { label: 'L', capabilities: ['ai.chat'] }, }), ) - const del = await harness.ai(`${BASE}/${created.connector.id}`, { method: 'DELETE', cookie }) + const del = await harness.ai(`${CONNECTIONS}/${created.connection.id}`, { method: 'DELETE', cookie }) expect(del.status).toBe(200) - const list = await readJson(await harness.ai(BASE, { cookie })) - expect(list.connectors[0].revoked).toBe(true) + const overview = await readJson(await harness.ai(CONNECTIONS, { cookie })) + expect(overview.connections[0].revoked).toBe(true) }) - it('404s revoking an unknown connector', async () => { + it('404s revoking an unknown connection', async () => { const cookie = await harness.setupOwner() - const del = await harness.ai(`${BASE}/does-not-exist`, { method: 'DELETE', cookie }) + const del = await harness.ai(`${CONNECTIONS}/does-not-exist`, { method: 'DELETE', cookie }) expect(del.status).toBe(404) }) - it('forbids connector management without ai.providers.manage', async () => { + it('forbids connection management without ai.providers.manage', async () => { await harness.setupOwner() const { cookie } = await harness.createRoleUser({ name: 'Editor', slug: 'editor', capabilities: ['ai.chat', 'content.manage'], }) - const res = await harness.ai(BASE, { + const res = await harness.ai(ACCESS_TOKENS, { method: 'POST', cookie, - json: { label: 'x', type: 'local', capabilities: ['ai.chat'] }, + json: { label: 'x', capabilities: ['ai.chat'] }, }) expect(res.status).toBe(403) }) it('refuses to grant capabilities the creator does not hold', async () => { await harness.setupOwner() - // A manager who can configure AI but holds no site-edit capability. const { cookie } = await harness.createRoleUser({ name: 'AI Manager', slug: 'ai-manager', capabilities: ['ai.providers.manage', 'ai.chat'], }) - const res = await harness.ai(BASE, { + const res = await harness.ai(ACCESS_TOKENS, { method: 'POST', cookie, - json: { label: 'overreach', type: 'remote', capabilities: ['site.structure.edit'] }, + json: { label: 'overreach', capabilities: ['site.structure.edit'] }, }) expect(res.status).toBe(403) }) - it('rejects an invalid body', async () => { + it('rejects an invalid access-token body', async () => { const cookie = await harness.setupOwner() - const res = await harness.ai(BASE, { + const res = await harness.ai(ACCESS_TOKENS, { method: 'POST', cookie, - json: { label: '', type: 'nope', capabilities: [] }, + json: { label: '', capabilities: [] }, }) expect(res.status).toBe(400) }) diff --git a/src/__tests__/ai/mcpOAuthAuthorizationHandler.test.ts b/src/__tests__/ai/mcpOAuthAuthorizationHandler.test.ts new file mode 100644 index 000000000..cf4eb379a --- /dev/null +++ b/src/__tests__/ai/mcpOAuthAuthorizationHandler.test.ts @@ -0,0 +1,145 @@ +import { beforeEach, describe, expect, it } from 'bun:test' +import type { + DecideMcpOAuthAuthorizationResult, + McpConnectionOverview, + McpOAuthAuthorizationRequest, + McpOAuthAuthorizationView, +} from '@core/ai' +import { pkceChallengeForVerifier } from '../../../server/ai/mcp/connectors/token' +import { registerOAuthClient } from '../../../server/ai/mcp/oauth/store' +import { + createCapabilityTestHarness, + readJson, + type CapabilityTestHarness, +} from '../helpers/capabilityHarness' + +const AUTHORIZATION = '/admin/api/ai/mcp/oauth/authorization' +const CONNECTIONS = '/admin/api/ai/mcp/connections' +const CALLBACK = 'https://claude.ai/api/mcp/auth_callback' +const VERIFIER = 'v'.repeat(64) + +function searchFor(request: McpOAuthAuthorizationRequest): string { + const params = new URLSearchParams({ + response_type: request.responseType, + client_id: request.clientId, + redirect_uri: request.redirectUri, + code_challenge: request.codeChallenge, + code_challenge_method: request.codeChallengeMethod, + scope: request.scope, + resource: request.resource, + }) + if (request.state) params.set('state', request.state) + return params.toString() +} + +describe('MCP OAuth authorization consent handler', () => { + let harness: CapabilityTestHarness + let request: McpOAuthAuthorizationRequest + + beforeEach(async () => { + harness = await createCapabilityTestHarness() + const client = await registerOAuthClient(harness.db, { + clientName: 'Claude Desktop', + redirectUris: [CALLBACK], + }) + request = { + responseType: 'code', + clientId: client.clientId, + redirectUri: CALLBACK, + codeChallenge: await pkceChallengeForVerifier(VERIFIER), + codeChallengeMethod: 'S256', + scope: 'mcp offline_access', + resource: 'http://localhost/_instatic/mcp', + state: 'client-state', + } + }) + + it('loads consent details and creates an OAuth connection after explicit approval', async () => { + const cookie = await harness.setupOwner() + const read = await harness.ai(`${AUTHORIZATION}?${searchFor(request)}`, { cookie }) + expect(read.status).toBe(200) + const view = await readJson(read) + expect(view.clientName).toBe('Claude Desktop') + expect(view.callbackUrl).toBe(CALLBACK) + expect(view.grantExpiresInDays).toBe(90) + + const approve = await harness.ai(AUTHORIZATION, { + method: 'POST', + cookie, + json: { + decision: 'approve', + request: view.request, + capabilities: ['site.read'], + }, + }) + expect(approve.status).toBe(200) + const result = await readJson(approve) + const redirect = new URL(result.redirectUrl) + expect(`${redirect.origin}${redirect.pathname}`).toBe(CALLBACK) + expect(redirect.searchParams.get('code')).toMatch(/^imcp_ac_/) + expect(redirect.searchParams.get('state')).toBe('client-state') + + const overview = await readJson(await harness.ai(CONNECTIONS, { cookie })) + expect(overview.connections).toHaveLength(1) + expect(overview.connections[0]).toMatchObject({ + label: 'Claude Desktop', + authMode: 'oauth', + capabilities: ['site.read', 'ai.chat'], + revoked: false, + }) + expect(JSON.stringify(overview)).not.toContain('imcp_ac_') + }) + + it('returns access_denied to the exact registered callback without creating a connection', async () => { + const cookie = await harness.setupOwner() + const denied = await harness.ai(AUTHORIZATION, { + method: 'POST', + cookie, + json: { decision: 'deny', request }, + }) + expect(denied.status).toBe(200) + const result = await readJson(denied) + const redirect = new URL(result.redirectUrl) + expect(`${redirect.origin}${redirect.pathname}`).toBe(CALLBACK) + expect(redirect.searchParams.get('error')).toBe('access_denied') + expect(redirect.searchParams.get('state')).toBe('client-state') + + const overview = await readJson(await harness.ai(CONNECTIONS, { cookie })) + expect(overview.connections).toHaveLength(0) + }) + + it('rejects a callback that was not registered for the client', async () => { + const cookie = await harness.setupOwner() + const tampered = { ...request, redirectUri: 'https://attacker.example/callback' } + const response = await harness.ai(AUTHORIZATION, { + method: 'POST', + cookie, + json: { decision: 'approve', request: tampered, capabilities: ['site.read'] }, + }) + expect(response.status).toBe(400) + }) + + it('rejects duplicate authorization parameters instead of choosing one', async () => { + const cookie = await harness.setupOwner() + const response = await harness.ai( + `${AUTHORIZATION}?${searchFor(request)}&state=other-state`, + { cookie }, + ) + expect(response.status).toBe(400) + }) + + it('refuses OAuth capabilities the approving user does not hold', async () => { + await harness.setupOwner() + const { cookie } = await harness.createRoleUser({ + name: 'AI Manager', + slug: 'oauth-manager', + capabilities: ['ai.providers.manage', 'ai.chat'], + }) + const response = await harness.ai(AUTHORIZATION, { + method: 'POST', + cookie, + json: { decision: 'approve', request, capabilities: ['site.structure.edit'] }, + }) + expect(response.status).toBe(403) + }) +}) diff --git a/src/__tests__/ai/providersTab.test.tsx b/src/__tests__/ai/providersTab.test.tsx index 1a3d2eca0..b95eee102 100644 --- a/src/__tests__/ai/providersTab.test.tsx +++ b/src/__tests__/ai/providersTab.test.tsx @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, mock } from 'bun:test' -import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { ProvidersTab } from '@admin/pages/ai/tabs/ProvidersTab' const originalFetch = globalThis.fetch @@ -28,33 +28,73 @@ describe('ProvidersTab', () => { it('derives credential authentication from the selected provider', async () => { mockEmptyCredentials() - render() - await waitFor(() => expect(screen.getByText('No credentials yet. Add one to start using AI features.')).toBeDefined()) + render( {}} />) + await waitFor(() => expect(screen.getByRole('heading', { name: 'Connect Anthropic' })).toBeDefined()) - fireEvent.click(screen.getByRole('button', { name: 'Add credential' })) - - const dialog = screen.getByRole('dialog', { name: 'Add AI credential' }) - expect(within(dialog).getByRole('combobox', { name: 'Provider' })).toBeDefined() - expect(within(dialog).queryByLabelText('Authentication')).toBeNull() - expect(within(dialog).getByLabelText('API key')).toBeDefined() + expect(screen.queryByRole('combobox', { name: 'Provider' })).toBeNull() + expect(screen.queryByLabelText('Authentication')).toBeNull() + expect(screen.getByLabelText('API key')).toBeDefined() + expect(screen.queryByRole('button', { name: 'Add' })).toBeNull() + expect(screen.queryByRole('heading', { name: 'Credentials' })).toBeNull() + expect(screen.queryByText('Secrets are encrypted at rest and never returned to the browser.')).toBeNull() }) it('keeps Ollama on the endpoint credential shape without an auth-mode choice', async () => { mockEmptyCredentials() - render() - await waitFor(() => expect(screen.getByText('No credentials yet. Add one to start using AI features.')).toBeDefined()) + render( {}} />) + await waitFor(() => expect(screen.getByRole('heading', { name: 'Connect Anthropic' })).toBeDefined()) + + fireEvent.click(screen.getByRole('button', { name: /Ollama Local models/ })) + + expect(screen.getByRole('heading', { name: 'Connect Ollama' })).toBeDefined() + expect(screen.queryByLabelText('Authentication')).toBeNull() + expect(screen.getByLabelText('Base URL')).toBeDefined() + expect(screen.getByLabelText(/Bearer token/)).toBeDefined() + expect(screen.queryByLabelText('API key')).toBeNull() + }) - fireEvent.click(screen.getByRole('button', { name: 'Add credential' })) + it('opens configured credentials in the detail inspector', async () => { + globalThis.fetch = mock(async (input: RequestInfo | URL) => { + const url = typeof input === 'string' ? input : input.toString() + if (url.endsWith('/admin/api/ai/credentials')) { + return json({ + credentials: [{ + id: 'cred-1', + providerId: 'anthropic', + authMode: 'apiKey', + displayLabel: 'Production Claude', + baseUrl: null, + keyFingerprintCurrent: true, + createdAt: '2026-07-13T10:00:00.000Z', + lastUsedAt: null, + }], + }) + } + if (url.includes('/admin/api/ai/providers/anthropic/models')) { + return json({ + models: [{ + id: 'claude-sonnet-4', + label: 'Claude Sonnet 4', + capabilities: { + toolCalling: true, + visionInput: true, + toolResultImages: true, + promptCache: true, + streaming: true, + }, + contextWindow: 200000, + }], + }) + } + throw new Error(`Unexpected fetch: ${url}`) + }) as typeof fetch - const dialog = screen.getByRole('dialog', { name: 'Add AI credential' }) - const provider = within(dialog).getByRole('combobox', { name: 'Provider' }) - fireEvent.click(provider.nextElementSibling as HTMLElement) - fireEvent.click(screen.getByRole('option', { name: 'Ollama (local)' })) + render( {}} />) - expect(within(dialog).queryByLabelText('Authentication')).toBeNull() - expect(within(dialog).getByLabelText('Base URL')).toBeDefined() - expect(within(dialog).getByLabelText('Bearer token (optional)')).toBeDefined() - expect(within(dialog).queryByLabelText('API key')).toBeNull() + await waitFor(() => expect(screen.getByRole('heading', { name: 'Production Claude' })).toBeDefined()) + await waitFor(() => expect(screen.getByText('Claude Sonnet 4')).toBeDefined()) + expect(screen.getByText('200K tokens')).toBeDefined() + expect(screen.getByRole('button', { name: 'Test connection' })).toBeDefined() }) }) diff --git a/src/__tests__/architecture/ai-mcp-connectors-never-leak.test.ts b/src/__tests__/architecture/ai-mcp-connectors-never-leak.test.ts index 0a4f2e09a..35ac47edf 100644 --- a/src/__tests__/architecture/ai-mcp-connectors-never-leak.test.ts +++ b/src/__tests__/architecture/ai-mcp-connectors-never-leak.test.ts @@ -2,7 +2,7 @@ * Architecture gate — MCP connector secrets never leak. * * Mirrors `ai-credentials-never-leak.test.ts`: the wire-safe projection - * (`toConnectorView`) and the wire schema (`McpConnectorViewSchema`) must not + * (`toConnectionView`) and the wire schema (`McpConnectionViewSchema`) must not * expose the token or its hash. A connector token is shown to the operator * exactly once, in the create response — never in any list/read shape. */ @@ -13,9 +13,9 @@ import { join } from 'node:path' const STORE = join(import.meta.dir, '../../../server/ai/mcp/connectors/store.ts') describe('MCP connector secrets never leak', () => { - it('toConnectorView does not reference the token hash', () => { + it('toConnectionView does not reference the token hash', () => { const src = readFileSync(STORE, 'utf8') - const start = src.indexOf('export function toConnectorView') + const start = src.indexOf('export function toConnectionView') expect(start).toBeGreaterThan(-1) const body = src.slice(start, src.indexOf('\n}', start) + 2) expect(body).not.toContain('tokenHash') @@ -23,8 +23,8 @@ describe('MCP connector secrets never leak', () => { }) it('the wire view schema has no token / tokenHash field', async () => { - const { McpConnectorViewSchema } = await import('@core/ai') - const keys = Object.keys(McpConnectorViewSchema.properties) + const { McpConnectionViewSchema } = await import('@core/ai') + const keys = Object.keys(McpConnectionViewSchema.properties) expect(keys).not.toContain('token') expect(keys).not.toContain('tokenHash') expect(keys).not.toContain('token_hash') diff --git a/src/__tests__/architecture/no-plugin-tab-shells.test.ts b/src/__tests__/architecture/no-plugin-tab-shells.test.ts index 026866f00..96b73e633 100644 --- a/src/__tests__/architecture/no-plugin-tab-shells.test.ts +++ b/src/__tests__/architecture/no-plugin-tab-shells.test.ts @@ -21,9 +21,7 @@ * §T.3 src/admin/pages/site/canvas/CanvasModeToggle.tsx — design/preview * segmented-control with icon-only tab buttons; predates the Tabs * primitive and uses its own compact CSS layout. - * §T.4 src/admin/pages/ai/AiPage.tsx — capability-gated Button row pattern - * mirroring §T.1. - * §T.5 src/admin/pages/content/components/ContentModeToggle/ContentModeToggle.tsx + * §T.4 src/admin/pages/content/components/ContentModeToggle/ContentModeToggle.tsx * — Write/Live segmented-control for the content workspace, mirroring * the §T.3 icon-only segmented-pill pattern. * @@ -74,7 +72,7 @@ function collectAllFiles(): string[] { /** §T.0 — every file under the Tabs primitive directory is allowed. */ const TABS_PRIMITIVE_DIR = join(PROJECT_ROOT, 'src/ui/components/Tabs') -/** §T.1–§T.5 — pre-existing custom tablist implementations. */ +/** §T.1–§T.4 — pre-existing custom tablist implementations. */ const EXACT_ALLOWLIST = new Set([ // §T.1 — capability-gated Button row in UsersPage predates the Tabs primitive. join(PROJECT_ROOT, 'src/admin/pages/users/UsersPage.tsx'), @@ -83,9 +81,7 @@ const EXACT_ALLOWLIST = new Set([ // §T.3 — icon-only design/preview toggle; compact fixed layout incompatible // with the full-width underline-indicator Tabs style. join(PROJECT_ROOT, 'src/admin/pages/site/canvas/CanvasModeToggle.tsx'), - // §T.4 — AiPage uses the same capability-gated Button row pattern as UsersPage. - join(PROJECT_ROOT, 'src/admin/pages/ai/AiPage.tsx'), - // §T.5 — Content workspace's Write/Live mode switch mirrors the §T.3 + // §T.4 — Content workspace's Write/Live mode switch mirrors the §T.3 // pattern: icon-only, compact fixed layout that the full-width Tabs // style cannot represent. join(PROJECT_ROOT, 'src/admin/pages/content/components/ContentModeToggle/ContentModeToggle.tsx'), @@ -182,8 +178,7 @@ describe('No-plugin-tab-shells — role="tablist" must come from the Tabs primit ` src/admin/pages/users/UsersPage.tsx (§T.1)\n` + ` src/admin/pages/account/AccountPage.tsx (§T.2)\n` + ` src/admin/pages/site/canvas/CanvasModeToggle.tsx (§T.3)\n` + - ` src/admin/pages/ai/AiPage.tsx (§T.4)\n` + - ` src/admin/pages/content/components/ContentModeToggle/ContentModeToggle.tsx (§T.5)`, + ` src/admin/pages/content/components/ContentModeToggle/ContentModeToggle.tsx (§T.4)`, ) }) }) diff --git a/src/admin/ai/api.ts b/src/admin/ai/api.ts index f567fc7c5..0eb0c165a 100644 --- a/src/admin/ai/api.ts +++ b/src/admin/ai/api.ts @@ -15,11 +15,15 @@ import { Type, type Static } from '@core/utils/typeboxHelpers' import { apiRequest, ApiError } from '@core/http' import { AiContentViewBlockSchema, - McpConnectorListSchema, - CreateMcpConnectorResultSchema, - type McpConnectorView, - type CreateMcpConnectorBody, - type CreateMcpConnectorResult, + McpConnectionOverviewSchema, + CreateMcpAccessTokenResultSchema, + McpOAuthAuthorizationViewSchema, + DecideMcpOAuthAuthorizationResultSchema, + type McpConnectionOverview, + type CreateMcpAccessTokenBody, + type CreateMcpAccessTokenResult, + type McpOAuthAuthorizationView, + type DecideMcpOAuthAuthorizationBody, } from '@core/ai' // --------------------------------------------------------------------------- @@ -421,26 +425,48 @@ export async function listAiAudit( } // --------------------------------------------------------------------------- -// MCP connectors — `/admin/api/ai/mcp/connectors`. Wire shapes are the shared -// TypeBox schemas from `@core/ai`; the plaintext token is returned only by -// `createMcpConnector` and is never persisted client-side. +// MCP connections + OAuth consent. Wire shapes are the shared TypeBox schemas +// from `@core/ai`; the plaintext personal access token is returned only by +// `createMcpAccessToken` and is never persisted client-side. // --------------------------------------------------------------------------- -const MCP_CONNECTORS_BASE = '/admin/api/ai/mcp/connectors' +const MCP_CONNECTIONS_BASE = '/admin/api/ai/mcp/connections' +const MCP_ACCESS_TOKENS_PATH = '/admin/api/ai/mcp/access-tokens' +const MCP_OAUTH_AUTHORIZATION_PATH = '/admin/api/ai/mcp/oauth/authorization' -export async function listMcpConnectors(signal?: AbortSignal): Promise { - const body = await apiRequest(MCP_CONNECTORS_BASE, { schema: McpConnectorListSchema, signal }) - return body.connectors +export async function getMcpConnectionOverview(signal?: AbortSignal): Promise { + return apiRequest(MCP_CONNECTIONS_BASE, { schema: McpConnectionOverviewSchema, signal }) } -export async function createMcpConnector(body: CreateMcpConnectorBody): Promise { - return apiRequest(MCP_CONNECTORS_BASE, { +export async function createMcpAccessToken( + body: CreateMcpAccessTokenBody, +): Promise { + return apiRequest(MCP_ACCESS_TOKENS_PATH, { method: 'POST', body, - schema: CreateMcpConnectorResultSchema, + schema: CreateMcpAccessTokenResultSchema, }) } -export async function revokeMcpConnector(id: string): Promise { - await apiRequest(`${MCP_CONNECTORS_BASE}/${encodeURIComponent(id)}`, { method: 'DELETE' }) +export async function revokeMcpConnection(id: string): Promise { + await apiRequest(`${MCP_CONNECTIONS_BASE}/${encodeURIComponent(id)}`, { method: 'DELETE' }) +} + +export async function getMcpOAuthAuthorization( + search: string, +): Promise { + return apiRequest(`${MCP_OAUTH_AUTHORIZATION_PATH}${search}`, { + schema: McpOAuthAuthorizationViewSchema, + }) +} + +export async function decideMcpOAuthAuthorization( + body: DecideMcpOAuthAuthorizationBody, +): Promise { + const result = await apiRequest(MCP_OAUTH_AUTHORIZATION_PATH, { + method: 'POST', + body, + schema: DecideMcpOAuthAuthorizationResultSchema, + }) + return result.redirectUrl } diff --git a/src/admin/layouts/AdminPageLayout/AdminPageLayout.module.css b/src/admin/layouts/AdminPageLayout/AdminPageLayout.module.css index ac1a5f062..65416a5cc 100644 --- a/src/admin/layouts/AdminPageLayout/AdminPageLayout.module.css +++ b/src/admin/layouts/AdminPageLayout/AdminPageLayout.module.css @@ -26,6 +26,10 @@ border-top-right-radius: 16px; } +.body[data-page-mode="workspace"] { + padding: var(--space-5xl) var(--space-6xl) var(--space-7xl); +} + .container { display: grid; gap: var(--space-6xl); @@ -33,6 +37,12 @@ margin: 0 auto; } +.container[data-page-mode="workspace"] { + display: block; + width: 100%; + min-height: 100%; +} + .header { display: grid; grid-template-columns: minmax(0, 1fr) auto; @@ -88,6 +98,10 @@ padding: var(--space-10xl) var(--space-3xl) var(--space-7xl); } + .body[data-page-mode="workspace"] { + padding: var(--space-2xl); + } + .header { grid-template-columns: 1fr; align-items: start; diff --git a/src/admin/layouts/AdminPageLayout/AdminPageLayout.tsx b/src/admin/layouts/AdminPageLayout/AdminPageLayout.tsx index b2b3c37a0..791d3dd18 100644 --- a/src/admin/layouts/AdminPageLayout/AdminPageLayout.tsx +++ b/src/admin/layouts/AdminPageLayout/AdminPageLayout.tsx @@ -6,10 +6,9 @@ * editor panels, the page canvas, and the SiteExplorer DnD context. * - AdminWorkspaceCanvasLayout — used by Content / Data / Media. Keeps the * canvas chrome without importing Site-only editor panels. - * - AdminPageLayout (this file) — used by Plugins, Users, Account, and - * plugin admin pages. Strips the canvas / sidebar / DnD chrome and - * renders a simple centered page body with a unified header (title, - * description, optional tabs and actions slots). + * - AdminPageLayout (this file) — used by Plugins, Users, Account, AI, and + * plugin admin pages. Strips the canvas / sidebar / DnD chrome and renders + * either a centered page with a unified header or a full-width workspace. * * Pick AdminWorkspaceCanvasLayout for non-site canvas workspaces. Pick this * layout when the page is a regular admin page (lists, forms, settings). @@ -53,8 +52,8 @@ const SettingsModal = lazy(() => interface AdminPageLayoutProps { /** Active section — drives the toolbar's nav highlight. */ workspace: AdminWorkspace - /** Page title rendered in the H1 of the page header. */ - title: string + /** Page title rendered in the H1 of the page header. Omit when children own the page heading. */ + title?: string /** Optional description shown under the title. */ description?: string /** @@ -86,6 +85,11 @@ interface AdminPageLayoutProps { * prop, no per-page skeleton markup. */ loading?: boolean + /** + * `workspace` removes the centered document header and uses the available + * canvas for a self-contained settings workspace with its own navigation. + */ + mode?: 'page' | 'workspace' /** Page body. */ children?: ReactNode } @@ -99,6 +103,7 @@ export function AdminPageLayout({ toolbarRightSlot, titleId, loading = false, + mode = 'page', children, }: AdminPageLayoutProps) { const currentUser = useCurrentAdminUser() @@ -141,24 +146,26 @@ export function AdminPageLayout({ rightSlot={toolbarRightSlot} /> -
-
-
-
-

{title}

- {description &&

{description}

} -
- {(tabs || actions) && ( -
- {tabs && ( -
{tabs}
- )} - {actions && ( -
{actions}
- )} +
+
+ {(title || description || tabs || actions) && ( +
+
+ {title &&

{title}

} + {description &&

{description}

}
- )} -
+ {(tabs || actions) && ( +
+ {tabs && ( +
{tabs}
+ )} + {actions && ( +
{actions}
+ )} +
+ )} +
+ )} {loading ? : children}
diff --git a/src/admin/pages/ai/AiPage.module.css b/src/admin/pages/ai/AiPage.module.css index f67a0a61c..992329750 100644 --- a/src/admin/pages/ai/AiPage.module.css +++ b/src/admin/pages/ai/AiPage.module.css @@ -1,221 +1,865 @@ -/* AI workspace page — Providers / Defaults / Audit tabs. - Same visual conventions as UsersPage: stacked sections, 28px gutters, - `--text-bright` for headings, `--text-subtle` for descriptions. */ +/* AI workspace shell */ -.body { +.workspace { display: grid; - gap: var(--space-6xl); + grid-template-columns: 184px minmax(0, 1fr); + gap: var(--space-7xl); + min-height: calc(100vh - 116px); } -.tabsRow { +.workspaceSidebar { display: flex; - flex-wrap: wrap; + flex-direction: column; + min-width: 0; +} + +.workspaceIdentity { + display: grid; gap: var(--space-s); + padding: 0 0 var(--space-4xl); } -.section { +.workspaceIdentity h1 { + margin: 0; + color: var(--text-bright); + font-size: var(--text-4xl); + line-height: 1; +} + +.workspaceIdentity p { + color: var(--text-subtle); + font-size: var(--text-m); + line-height: 1.5; +} + +.workspaceNavigation { display: grid; - gap: var(--space-2xl); + gap: var(--space-xs); } -.sectionHeader { +.workspaceNavigationButton { + min-width: 0; +} + +.workspaceContent { + min-width: 0; +} + +/* Provider identity assets */ + +.providerMark { + --provider-accent: var(--accent-3); + + display: inline-flex; + flex: none; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border-radius: var(--panel-radius); + background: linear-gradient( + to top, + color-mix(in srgb, var(--provider-accent) 24%, transparent), + color-mix(in srgb, var(--provider-accent) 8%, transparent) + ); + color: var(--provider-accent); +} + +.providerMark[data-size="sm"] { + width: 36px; + height: 36px; +} + +.providerMark[data-size="lg"] { + width: 60px; + height: 60px; + border-radius: var(--card-radius); +} + +.providerLogo { + width: 54%; + height: 54%; + background: var(--provider-accent); + mask-position: center; + mask-repeat: no-repeat; + mask-size: contain; +} + +.logoAnthropic { + mask-image: url("./assets/anthropic.svg"); +} + +.logoOpenai { + mask-image: url("./assets/openai.svg"); +} + +.logoOpenrouter { + mask-image: url("./assets/openrouter.svg"); +} + +.logoOllama { + mask-image: url("./assets/ollama.svg"); +} + +.providerMark[data-provider="anthropic"] { + --provider-accent: var(--accent-4); +} + +.providerMark[data-provider="openai"] { + --provider-accent: var(--accent-1); +} + +.providerMark[data-provider="openrouter"] { + --provider-accent: var(--accent-2); +} + +.providerMark[data-provider="ollama"], +.providerMark[data-provider="openai-compatible"] { + --provider-accent: var(--accent-3); +} + +/* Shared nested master-detail layout */ + +.settingsWorkspace { display: grid; - grid-template-columns: minmax(0, 1fr) auto; + grid-template-columns: 280px minmax(0, 1fr); + gap: var(--space-7xl); + min-height: 100%; +} + +.settingsBrowser { + display: flex; + flex-direction: column; + gap: var(--space-l); + min-width: 0; +} + +.settingsBrowserHeader { + display: flex; align-items: center; - gap: var(--space-3xl); + justify-content: space-between; + gap: var(--space-l); } -.sectionHeader h2 { +.settingsBrowserHeader h2 { margin: 0; color: var(--text-bright); - letter-spacing: 0; font-size: var(--text-2xl); - line-height: 1.25; + line-height: 1.2; } -.sectionHeader p, -.secondaryText { +.settingsBrowserError, +.settingsBrowserEmpty { margin: 0; color: var(--text-subtle); font-size: var(--text-s); line-height: 1.45; } -.emptyState { - padding: var(--space-7xl) var(--space-2xl); - text-align: center; +.settingsBrowserError { + color: var(--danger); +} + +.settingsBrowserSections { + display: grid; + gap: var(--space-3xl); +} + +.settingsListSection { + display: grid; + gap: var(--space-s); +} + +.settingsListSection h3, +.detailEyebrow { + margin: 0; color: var(--text-subtle); - background: var(--bg-surface-2); - border-radius: 16px; + font-size: var(--text-s); + font-weight: 700; + letter-spacing: 0.05em; + line-height: 1.3; + text-transform: uppercase; } -.credentialGrid { +.settingsList { display: grid; - grid-template-columns: 1fr; gap: var(--space-px); - background: var(--bg-surface); - border-radius: 16px; +} + +.settingsListItem { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + height: auto; + min-height: 44px; + padding: var(--space-3xs); + gap: var(--space-m); + border-radius: var(--card-radius); +} + +.settingsListItem:hover { + background: var(--overlay-5); +} + +.settingsListItem[data-active="true"] { + background: var(--overlay-10); +} + +.settingsListIdentity { + display: grid; + gap: var(--space-4xs); + min-width: 0; +} + +.settingsListLabel { overflow: hidden; + color: var(--text-bright); + font-size: var(--text-m); + font-weight: 600; + text-overflow: ellipsis; } -.credentialCard { +.settingsListMeta { + display: flex; + align-items: center; + gap: var(--space-xs); + overflow: hidden; + color: var(--text-subtle); + font-size: var(--text-s); + font-weight: 400; + text-overflow: ellipsis; +} + +.healthDot, +.warningDot { + display: inline-block; + flex: none; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--success); +} + +.warningDot { + background: var(--warning); +} + +.settingsDetailCanvas { + min-width: 0; +} + +.settingsItemIcon, +.settingsHeroIcon { + display: inline-flex; + flex: none; + align-items: center; + justify-content: center; + background: var(--bg-surface-2); + color: var(--text-muted); +} + +.settingsItemIcon { + width: 36px; + height: 36px; + border-radius: var(--panel-radius); +} + +.settingsHeroIcon { + width: 60px; + height: 60px; + border-radius: var(--card-radius); +} + +.settingsDetail { display: grid; - grid-template-columns: minmax(0, 1fr) auto; + gap: var(--space-5xl); + max-width: 780px; + margin: 0 auto; +} + +.settingsDetailHeader { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: var(--space-2xl); - padding: var(--space-2xl); - background: var(--bg-surface-2); + padding-bottom: var(--space-4xl); + border-bottom: 1px solid var(--overlay-10); } -.credentialIdentity { +.settingsDetailIdentity { display: grid; - gap: var(--space-3xs); + gap: var(--space-s); min-width: 0; } -.credentialLabel { +.settingsDetailIdentity h2 { + margin: 0; color: var(--text-bright); - font-size: var(--text-l); - font-weight: 500; - line-height: 1.3; + font-size: var(--text-4xl); + line-height: 1.1; } -.credentialMeta { +.settingsDetailIdentity p { + margin: 0; color: var(--text-subtle); - font-size: var(--text-s); - line-height: 1.4; + font-size: var(--text-m); + line-height: 1.5; +} + +.settingsDetailSection { + display: grid; + gap: var(--space-3xl); + padding-bottom: var(--space-4xl); + border-bottom: 1px solid var(--overlay-10); +} + +.settingsDetailSection:last-child { + border-bottom: 0; +} + +.settingsFormRow { + display: grid; + grid-template-columns: 150px minmax(0, 1fr); + align-items: start; + gap: var(--space-2xl); +} + +.settingsFormRow > label { + display: flex; + align-items: center; + min-height: 32px; + color: var(--text-muted); + font-size: var(--text-m); + font-weight: 600; +} + +.settingsFormRow > div { + display: grid; + gap: var(--space-xs); +} + +.settingsDetailActions { display: flex; flex-wrap: wrap; - gap: var(--space-s); align-items: center; + justify-content: flex-end; + gap: var(--space-xs); } -.credentialActions { +.credentialDetail, +.providerSetup { + display: grid; + gap: var(--space-5xl); + max-width: 980px; + margin: 0 auto; +} + +.credentialDetailHeader, +.credentialDetailIdentity, +.credentialTitleRow, +.detailSectionHeader { display: flex; + align-items: center; + gap: var(--space-l); +} + +.credentialDetailHeader { + justify-content: space-between; + padding-bottom: var(--space-4xl); + border-bottom: 1px solid var(--overlay-10); +} + +.credentialDetailIdentity { + min-width: 0; +} + +.credentialTitleRow { + flex-wrap: wrap; gap: var(--space-s); } -.statusBadge { +.credentialTitleRow h2, +.providerSetupHeader h2 { + margin: 0; + color: var(--text-bright); + font-size: var(--text-4xl); + line-height: 1.1; +} + +.credentialDetailIdentity p, +.providerSetupHeader p, +.detailSectionHeader p, +.credentialDangerZone p, +.providerSetupField p, +.removeDialogCopy { + margin: 0; + color: var(--text-subtle); + font-size: var(--text-m); + line-height: 1.5; +} + +.connectionStatus, +.connectionStatusWarning { display: inline-flex; align-items: center; - padding: var(--space-4xs) var(--space-s); + gap: var(--space-xs); + padding: var(--space-xs) var(--space-s); border-radius: var(--radius-sm); + background: var(--success-10); + color: var(--success-text); font-size: var(--text-xs); - font-weight: 500; - background: var(--bg-surface); - color: var(--text-subtle); + font-weight: 600; +} + +.connectionStatusWarning { + background: var(--warning-10); + color: var(--warning-text); } -.statusBadge.success { +.connectionResultSuccess, +.connectionResultError, +.detailInlineError { + margin: 0; + padding: var(--space-s) var(--space-l); + border-radius: var(--radius); + background: var(--success-10); color: var(--success-text); + font-size: var(--text-s); } -.statusBadge.warning { - color: var(--warning-text); +.connectionResultError, +.detailInlineError { + background: var(--danger-10); + color: var(--danger-text); } -.statusBadge.danger { - color: var(--danger); +.detailSection { + display: grid; + gap: var(--space-2xl); + padding-bottom: var(--space-4xl); + border-bottom: 1px solid var(--overlay-10); } -.testResult { - margin: 0; - font-size: var(--text-s); - color: var(--text-subtle); +.detailSectionHeader { + justify-content: space-between; + align-items: flex-start; } -.testResult.success { +.detailSectionHeader h3, +.providerSetupFormSection h3, +.defaultsSetupEmpty h3 { + margin: 0; color: var(--text-bright); + font-size: var(--text-xl); + font-weight: 600; + line-height: 1.3; } -.testResult.danger { - color: var(--danger); +.connectionDetails { + display: grid; + gap: var(--space-px); + margin: 0; } -.errorAlert { - padding: var(--space-l) var(--space-2xl); - border-radius: var(--radius); - background: var(--bg-surface-2); - color: var(--danger); +.connectionDetailRow { + display: grid; + grid-template-columns: minmax(140px, 1fr) minmax(0, 3fr); + gap: var(--space-2xl); + padding: var(--space-s) 0; +} + +.connectionDetailRow dt { + color: var(--text-subtle); font-size: var(--text-m); +} + +.connectionDetailRow dd { + min-width: 0; margin: 0; + overflow-wrap: anywhere; + color: var(--text-bright); + font-size: var(--text-m); +} + +.connectionDetailRow code, +.modelIdentity code { + font-family: var(--font-mono); + color: var(--text-muted); + font-size: var(--text-s); } -.defaultsGrid { +.modelsTable { display: grid; - grid-template-columns: 1fr; - gap: var(--space-l); } -.defaultRow { +.modelsTableHeader, +.modelsTableRow { display: grid; - grid-template-columns: minmax(120px, 1fr) minmax(0, 4fr) auto; + grid-template-columns: minmax(0, 2fr) minmax(120px, 1fr) minmax(100px, auto); align-items: center; - gap: var(--space-l); - padding: var(--space-2xl); - background: var(--bg-surface-2); - border-radius: 16px; + gap: var(--space-2xl); + min-height: 54px; + border-bottom: 1px solid var(--overlay-10); +} + +.modelsTableHeader { + min-height: 32px; + color: var(--text-subtle); + font-size: var(--text-s); + font-weight: 600; + letter-spacing: 0.03em; + text-transform: uppercase; } -.defaultScopeLabel { +.modelsTableRow { + color: var(--text); + font-size: var(--text-m); +} + +.modelsTableRow:last-child { + border-bottom: 0; +} + +.modelIdentity { + display: grid; + gap: var(--space-4xs); + min-width: 0; +} + +.modelIdentity strong { color: var(--text-bright); - font-size: var(--text-l); font-weight: 500; - text-transform: capitalize; } -.defaultActions { +.modelIdentity code { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.modelAvailability { + display: inline-flex; + align-items: center; + gap: var(--space-xs); + color: var(--text-muted); +} + +.detailEmpty { + margin: 0; + padding: var(--space-3xl) 0; + color: var(--text-subtle); + font-size: var(--text-m); +} + +.credentialDangerZone { + display: grid; + justify-items: start; + gap: var(--space-xs); +} + +.providerSetup { + max-width: 780px; +} + +.providerSetupHeader { display: flex; align-items: center; - justify-content: flex-end; - flex-wrap: wrap; - gap: var(--space-m); + gap: var(--space-2xl); + padding-bottom: var(--space-4xl); + border-bottom: 1px solid var(--overlay-10); } -.defaultActions .testResult { - flex-basis: 100%; - text-align: right; +.providerSetupHeader > div { + display: grid; + gap: var(--space-s); +} + +.providerSetupFormSection { + display: grid; + gap: var(--space-3xl); } -.dialogBody { +.providerSetupForm { display: grid; gap: var(--space-2xl); } -.dialogActions { +.providerSetupField { + display: grid; + grid-template-columns: 150px minmax(0, 1fr); + align-items: start; + gap: var(--space-2xl); +} + +.providerSetupField > label { + display: flex; + align-items: center; + gap: var(--space-xs); + min-height: 32px; + color: var(--text-muted); + font-size: var(--text-m); + font-weight: 600; +} + +.providerSetupField > label span { + color: var(--text-subtle); + font-size: var(--text-s); + font-weight: 400; +} + +.providerSetupField > div { + display: grid; + gap: var(--space-xs); +} + +.providerSetupActions { display: flex; justify-content: flex-end; - gap: var(--space-s); + padding-top: var(--space-s); } -/* Row layout for the credentials dialog — label on left, input on right. - Matches the existing form pattern in other admin dialogs that want a - compact, two-column read. */ -.dialogForm { +/* Shared section surfaces for Defaults, MCP, and Audit */ + +.section { display: grid; - gap: var(--space-m); + gap: var(--space-4xl); } -.dialogField { +.sectionHeader { display: grid; - grid-template-columns: 110px minmax(0, 1fr); + grid-template-columns: minmax(0, 1fr) auto; align-items: center; - gap: var(--space-l); + gap: var(--space-3xl); + padding-bottom: var(--space-4xl); + border-bottom: 1px solid var(--overlay-10); } -.dialogFieldLabel { +.sectionHeader > div { + display: grid; + gap: var(--space-xs); +} + +.sectionHeader h2 { + margin: 0; + color: var(--text-bright); + font-size: var(--text-4xl); + line-height: 1.1; +} + +.sectionHeader p { + margin: 0; color: var(--text-subtle); - font-size: var(--text-xs); - font-weight: 700; - line-height: 1.3; + font-size: var(--text-m); + line-height: 1.45; } -.dialogError { - grid-column: 1 / -1; +.emptyState { + padding: var(--space-7xl) var(--space-2xl); + text-align: center; + color: var(--text-subtle); + background: var(--bg-surface-2); + border-radius: var(--card-radius); +} + +.errorAlert { margin: 0; - color: var(--danger); + padding: var(--space-l) var(--space-2xl); + border-radius: var(--radius); + background: var(--danger-10); + color: var(--danger-text); + font-size: var(--text-m); +} + +.defaultsSetupEmpty { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3xl); + padding: var(--space-4xl); + border-radius: var(--card-radius); + background: var(--bg-surface-2); +} + +.defaultsSetupEmpty > div { + display: grid; + gap: var(--space-xs); +} + +.defaultsSetupEmpty p { + margin: 0; + color: var(--text-subtle); + font-size: var(--text-m); +} + +.defaultCurrentChoice { + display: flex; + align-items: center; + gap: var(--space-xs); + max-width: 100%; + margin-top: var(--space-s); + overflow: hidden; + color: var(--text-muted); font-size: var(--text-xs); - line-height: 1.3; + text-overflow: ellipsis; + white-space: nowrap; +} + +.defaultCurrentChoice .providerMark { + width: 20px; + height: 20px; +} + +.defaultStaleMessage { + margin: var(--space-s) 0 0; + color: var(--warning-text); + font-size: var(--text-xs); + line-height: 1.4; +} + +.defaultSaveStatus { + flex-basis: 100%; + color: var(--success-text); + font-size: var(--text-xs); + text-align: right; +} + +@media (max-width: 1180px) { + .workspace { + grid-template-columns: 168px minmax(0, 1fr); + gap: var(--space-5xl); + } + + .settingsWorkspace { + grid-template-columns: 248px minmax(0, 1fr); + gap: var(--space-5xl); + } + +} + +@media (max-width: 900px) { + .workspace { + grid-template-columns: 48px minmax(0, 1fr); + gap: var(--space-3xl); + } + + .workspaceIdentity { + padding: 0 0 var(--space-3xl); + text-align: center; + } + + .workspaceIdentity p, + .workspaceNavigationButton span { + display: none; + } + + .workspaceNavigationButton { + justify-content: center; + width: 32px; + padding: 0; + } + + .settingsWorkspace { + grid-template-columns: 240px minmax(0, 1fr); + gap: var(--space-3xl); + } + + .settingsBrowser { + padding: 0; + } + + .credentialDetailHeader, + .settingsDetailHeader, + .providerSetupHeader { + align-items: flex-start; + } + + .modelsTableHeader, + .modelsTableRow { + grid-template-columns: minmax(0, 2fr) minmax(100px, 1fr); + } + + .modelsTableHeader > :last-child, + .modelsTableRow > :last-child { + display: none; + } +} + +@media (max-width: 700px) { + .workspace { + grid-template-columns: 1fr; + min-height: 0; + } + + .workspaceSidebar { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: center; + gap: var(--space-2xl); + padding-bottom: var(--space-3xl); + } + + .workspaceIdentity { + padding: var(--space-l); + } + + .workspaceNavigation { + display: flex; + justify-content: flex-end; + overflow-x: auto; + } + + .workspaceNavigationButton { + width: 32px; + } + + .settingsWorkspace { + grid-template-columns: 1fr; + } + + .settingsBrowser { + padding-bottom: var(--space-5xl); + } + + .credentialDetailHeader, + .settingsDetailHeader, + .sectionHeader, + .defaultsSetupEmpty { + grid-template-columns: 1fr; + flex-direction: column; + align-items: flex-start; + } + + .credentialTitleRow h2, + .settingsDetailIdentity h2, + .providerSetupHeader h2, + .sectionHeader h2 { + font-size: var(--text-3xl); + } + + .connectionDetailRow, + .settingsFormRow, + .providerSetupField, + .settingsDetailHeader { + grid-template-columns: 1fr; + } + + .settingsDetailActions { + justify-content: flex-start; + } + + .defaultSaveStatus { + text-align: left; + } + + .modelsTableHeader, + .modelsTableRow { + grid-template-columns: 1fr; + gap: var(--space-xs); + padding: var(--space-s) 0; + } + + .modelsTableHeader { + display: none; + } } /* ── Audit tab ───────────────────────────────────────────────────────── */ @@ -226,12 +870,6 @@ gap: var(--space-l); } -.auditSinceLabel { - color: var(--text-subtle); - font-size: var(--text-xs); - white-space: nowrap; -} - .auditTotalsRow { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); diff --git a/src/admin/pages/ai/AiPage.tsx b/src/admin/pages/ai/AiPage.tsx index e73b54871..03e768290 100644 --- a/src/admin/pages/ai/AiPage.tsx +++ b/src/admin/pages/ai/AiPage.tsx @@ -2,82 +2,111 @@ * AiPage — `/admin/ai`. * * Capability-gated workspace for managing AI provider credentials, per-scope - * defaults, and (Phase 6) the AI usage audit log. - * - * Layout mirrors UsersPage — three tabs, each owning its own state. The - * page itself is thin: it figures out which tabs the current admin can - * see, loads minimal data, and delegates rendering to per-tab components. + * defaults, MCP connections, and the AI usage audit log. * * Capabilities consulted: - * - `ai.providers.manage` → Providers + Defaults tabs (CRUD) + * - `ai.providers.manage` → Providers, Defaults, and MCP connection management * - `ai.audit.read` → Audit tab (read site-wide usage) */ import { useState } from 'react' import { Button } from '@ui/components/Button' import { AdminPageLayout } from '@admin/layouts/AdminPageLayout' +import { useLocation } from '@admin/lib/routing' import { hasCapability } from '@admin/access' import { useCurrentAdminUser } from '@admin/sessionContext' +import { DatabaseSolidIcon } from 'pixel-art-icons/icons/database-solid' +import { Settings2SolidIcon } from 'pixel-art-icons/icons/settings-2-solid' +import { LinkIcon } from 'pixel-art-icons/icons/link' +import { ChartSolidIcon } from 'pixel-art-icons/icons/chart-solid' import { ProvidersTab } from './tabs/ProvidersTab' import { DefaultsTab } from './tabs/DefaultsTab' import { AuditTab } from './tabs/AuditTab' import { McpTab } from './tabs/McpTab' +import { McpOAuthAuthorizePage } from './McpOAuthAuthorizePage' import styles from './AiPage.module.css' -type Tab = 'providers' | 'defaults' | 'mcp' | 'audit' +type Section = 'providers' | 'defaults' | 'mcp' | 'audit' -const TAB_LABELS: Record = { +const SECTION_LABELS: Record = { providers: 'Providers', defaults: 'Defaults', - mcp: 'MCP', + mcp: 'MCP connections', audit: 'Audit', } +const SECTION_ICONS = { + providers: DatabaseSolidIcon, + defaults: Settings2SolidIcon, + mcp: LinkIcon, + audit: ChartSolidIcon, +} satisfies Record + export function AiPage() { + const location = useLocation() const currentUser = useCurrentAdminUser() const unrestricted = !currentUser const canManage = unrestricted || hasCapability(currentUser, 'ai.providers.manage') const canReadAudit = unrestricted || hasCapability(currentUser, 'ai.audit.read') - const availableTabs: Tab[] = [] - if (canManage) availableTabs.push('providers', 'defaults', 'mcp') - if (canReadAudit) availableTabs.push('audit') + const availableSections: Section[] = [] + if (canManage) availableSections.push('providers', 'defaults', 'mcp') + if (canReadAudit) availableSections.push('audit') - const [tab, setTab] = useState('providers') - const activeTab = availableTabs.includes(tab) ? tab : availableTabs[0] ?? 'providers' + const [section, setSection] = useState
('providers') + const activeSection = availableSections.includes(section) + ? section + : availableSections[0] ?? 'providers' - const tabs = ( -
- {availableTabs.map((item) => ( - - ))} -
- ) + if (location.pathname === '/admin/ai/oauth/authorize') { + return + } return ( - -
- {activeTab === 'providers' && } - {activeTab === 'defaults' && } - {activeTab === 'mcp' && } - {activeTab === 'audit' && } + +
+ + +
+ {activeSection === 'providers' && ( + setSection('defaults')} /> + )} + {activeSection === 'defaults' && ( + setSection('providers')} /> + )} + {activeSection === 'mcp' && } + {activeSection === 'audit' && } +
) diff --git a/src/admin/pages/ai/AiSettingsListSection.tsx b/src/admin/pages/ai/AiSettingsListSection.tsx new file mode 100644 index 000000000..6bc839df8 --- /dev/null +++ b/src/admin/pages/ai/AiSettingsListSection.tsx @@ -0,0 +1,17 @@ +import type { ReactNode } from 'react' +import styles from './AiPage.module.css' + +export function AiSettingsListSection({ + label, + children, +}: { + label: string + children: ReactNode +}) { + return ( +
+

{label}

+
{children}
+
+ ) +} diff --git a/src/admin/pages/ai/McpOAuthAuthorizePage.module.css b/src/admin/pages/ai/McpOAuthAuthorizePage.module.css new file mode 100644 index 000000000..481a82bbd --- /dev/null +++ b/src/admin/pages/ai/McpOAuthAuthorizePage.module.css @@ -0,0 +1,71 @@ +.pageBody { + display: grid; + gap: var(--space-2xl); + max-width: 880px; +} + +.clientCard, +.permissionsCard, +.stateCard { + display: grid; + gap: var(--space-l); + padding: var(--space-2xl); + border-radius: var(--panel-radius); + background: var(--bg-surface-2); +} + +.clientCard h2, +.permissionsCard h2, +.stateCard h2 { + margin: var(--space-3xs) 0 0; + color: var(--text-bright); + font-size: var(--text-2xl); + line-height: 1.3; +} + +.clientCard p, +.permissionsCard p, +.stateCard p { + margin: var(--space-s) 0 0; + color: var(--text-subtle); + font-size: var(--text-s); + line-height: 1.5; +} + +.clientCard code { + overflow-wrap: anywhere; + padding: var(--space-s) var(--space-m); + border-radius: var(--radius); + background: var(--bg-surface); + color: var(--text-bright); + font-family: var(--font-mono); + font-size: var(--text-xs); +} + +.eyebrow { + color: var(--text-subtle); + font-size: var(--text-xs); + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.clientCard .expiryNote { + padding: var(--space-s) var(--space-m); + border-radius: var(--radius); + background: var(--success-10); + color: var(--success-text); +} + +.clientCard .trustNote { + padding: var(--space-s) var(--space-m); + border-radius: var(--radius); + background: var(--warning-10); + color: var(--warning-text); +} + +.actions { + display: flex; + justify-content: flex-end; + gap: var(--space-s); +} diff --git a/src/admin/pages/ai/McpOAuthAuthorizePage.tsx b/src/admin/pages/ai/McpOAuthAuthorizePage.tsx new file mode 100644 index 000000000..9bd8bcb66 --- /dev/null +++ b/src/admin/pages/ai/McpOAuthAuthorizePage.tsx @@ -0,0 +1,131 @@ +import { useState } from 'react' +import { AdminPageLayout } from '@admin/layouts/AdminPageLayout' +import { useAsyncResource } from '@admin/lib/useAsyncResource' +import { useLocation, useNavigate } from '@admin/lib/routing' +import { useAuthenticatedAdminUser } from '@admin/sessionContext' +import { CapabilityPicker } from '@admin/shared/CapabilityPicker' +import { StepUpCancelledMessage, useStepUp } from '@admin/shared/StepUp' +import { Button } from '@ui/components/Button' +import { pushToast } from '@ui/components/Toast' +import { getErrorMessage } from '@core/utils/errorMessage' +import type { CoreCapability } from '@core/capabilities' +import { + decideMcpOAuthAuthorization, + getMcpOAuthAuthorization, +} from '../../ai/api' +import { + availableMcpCapabilityGroups, + defaultMcpReadCapabilities, +} from './tabs/mcpCapabilities' +import styles from './McpOAuthAuthorizePage.module.css' + +export function McpOAuthAuthorizePage() { + const location = useLocation() + const navigate = useNavigate() + const currentUser = useAuthenticatedAdminUser() + const { runStepUp } = useStepUp() + const groups = availableMcpCapabilityGroups(currentUser) + const [selected, setSelected] = useState>( + () => defaultMcpReadCapabilities(groups), + ) + const [busy, setBusy] = useState(false) + const { data, loading, error } = useAsyncResource( + () => getMcpOAuthAuthorization(location.search), + [location.search], + { fallbackError: 'This OAuth authorization request is invalid or has expired.' }, + ) + + async function decide(decision: 'approve' | 'deny') { + if (!data) return + setBusy(true) + try { + const body = { + decision, + request: data.request, + ...(decision === 'approve' ? { capabilities: [...selected] } : {}), + } as const + const redirectUrl = decision === 'approve' + ? await runStepUp(() => decideMcpOAuthAuthorization(body)) + : await decideMcpOAuthAuthorization(body) + window.location.assign(redirectUrl) + } catch (err) { + if (err instanceof Error && err.message === StepUpCancelledMessage) return + pushToast({ + kind: 'error', + title: decision === 'approve' ? 'Could not authorize connection' : 'Could not deny connection', + body: getErrorMessage(err, 'Unknown OAuth authorization error'), + }) + } finally { + setBusy(false) + } + } + + return ( + +
+ {loading &&
Loading authorization request…
} + {error && ( +
+

Connection request unavailable

+

{error}

+ +
+ )} + {data && ( + <> +
+
+ Requesting client +

{data.clientName}

+

+ After approval, Instatic sends a one-time authorization code to this registered callback: +

+
+ {data.callbackUrl} +

+ Client names are self-declared. Approve only if you started this connection and + recognize the callback address above. +

+

+ The connection expires after {data.grantExpiresInDays} days and can be disconnected at any time. +

+
+ +
+
+ Permissions +

Choose allowed capabilities

+

+ Read access is selected by default. Writes and publishing stay off until you explicitly enable them. +

+
+ +
+ +
+ + +
+ + )} +
+
+ ) +} diff --git a/src/admin/pages/ai/ProviderMark.tsx b/src/admin/pages/ai/ProviderMark.tsx new file mode 100644 index 000000000..83b9dcb02 --- /dev/null +++ b/src/admin/pages/ai/ProviderMark.tsx @@ -0,0 +1,36 @@ +import { CodeIcon } from 'pixel-art-icons/icons/code' +import type { ProviderId } from './providerCatalog' +import { cn } from '@ui/cn' +import styles from './AiPage.module.css' + +const LOGO_CLASS: Partial> = { + anthropic: styles.logoAnthropic, + openai: styles.logoOpenai, + openrouter: styles.logoOpenrouter, + ollama: styles.logoOllama, +} + +export function ProviderMark({ + providerId, + size = 'md', +}: { + providerId: ProviderId + size?: 'sm' | 'md' | 'lg' +}) { + const logoClass = LOGO_CLASS[providerId] + + return ( +
- {effectiveAuthMode === 'apiKey' && ( -
- + {provider.authMode === 'baseUrl' && ( +
+ +
setApiKey(e.currentTarget.value)} - placeholder={API_KEY_PLACEHOLDER[providerId] ?? 'sk-...'} - // Browsers ignore autoComplete="off" on password fields and - // inject the saved admin login. "new-password" suppresses that; - // the data-* attributes opt out of password-manager overlays. - autoComplete="new-password" - data-1p-ignore="true" - data-lpignore="true" - data-bwignore="true" - data-form-type="other" + id={baseUrlInputId} + value={baseUrl} + onChange={(event) => setBaseUrl(event.currentTarget.value)} + placeholder={baseUrlPlaceholder} required /> +

The root URL of the compatible API.

- )} +
+ )} - {effectiveAuthMode === 'baseUrl' && ( - <> -
- - setBaseUrl(e.currentTarget.value)} - placeholder={baseUrlPlaceholder} - required - /> -
-
- - setApiKey(e.currentTarget.value)} - placeholder="Leave blank if no auth" - // See the API key field above: "new-password" + data-* opt-outs - // stop the browser/password manager autofilling the admin login. - autoComplete="new-password" - data-1p-ignore="true" - data-lpignore="true" - data-bwignore="true" - data-form-type="other" - /> -
- - )} +
+ +
+ setApiKey(event.currentTarget.value)} + placeholder={API_KEY_PLACEHOLDER[provider.id] ?? 'Leave blank if no auth'} + autoComplete="new-password" + data-1p-ignore="true" + data-lpignore="true" + data-bwignore="true" + data-form-type="other" + required={provider.authMode === 'apiKey'} + /> +

{provider.authMode === 'apiKey' ? 'Stored encrypted and never displayed again.' : 'Leave blank when the endpoint does not require authentication.'}

+
+
- {error &&

{error}

} - - +
+ +
+ ) } diff --git a/src/admin/pages/ai/tabs/mcpCapabilities.ts b/src/admin/pages/ai/tabs/mcpCapabilities.ts new file mode 100644 index 000000000..e3dd98514 --- /dev/null +++ b/src/admin/pages/ai/tabs/mcpCapabilities.ts @@ -0,0 +1,54 @@ +import type { CmsCurrentUser } from '@core/persistence' +import type { CoreCapability } from '@core/capabilities' +import { hasCapability } from '@admin/access' +import type { CapabilityPickerGroup } from '@admin/shared/CapabilityPicker' + +export const MCP_CAPABILITY_GROUPS: readonly CapabilityPickerGroup[] = [ + { + title: 'Read', + capabilities: ['site.read', 'content.manage', 'data.custom.tables.read', 'data.system.tables.read', 'media.read'], + }, + { + title: 'Allow writes', + capabilities: ['ai.tools.write'], + }, + { + title: 'Site editing', + capabilities: ['site.structure.edit', 'site.content.edit', 'site.style.edit'], + }, + { + title: 'Pages', + capabilities: ['pages.edit', 'pages.publish'], + }, + { + title: 'Content', + capabilities: ['content.create', 'content.edit.own', 'content.edit.any', 'content.publish.own', 'content.publish.any'], + }, + { + title: 'Media', + capabilities: ['media.write', 'media.replace', 'media.delete'], + }, +] + +const READ_CAPABILITIES = new Set(MCP_CAPABILITY_GROUPS[0].capabilities) + +export function availableMcpCapabilityGroups( + currentUser: CmsCurrentUser | null, +): CapabilityPickerGroup[] { + return MCP_CAPABILITY_GROUPS + .map((group) => ({ + title: group.title, + capabilities: group.capabilities.filter( + (capability) => !currentUser || hasCapability(currentUser, capability), + ), + })) + .filter((group) => group.capabilities.length > 0) +} + +export function defaultMcpReadCapabilities( + groups: readonly CapabilityPickerGroup[], +): Set { + return new Set( + groups.flatMap((group) => group.capabilities).filter((capability) => READ_CAPABILITIES.has(capability)), + ) +} diff --git a/src/admin/router.tsx b/src/admin/router.tsx index b2834f987..b187bcbc4 100644 --- a/src/admin/router.tsx +++ b/src/admin/router.tsx @@ -61,6 +61,7 @@ export function AdminRoutes() { )} /> )} /> )} /> + )} /> )} /> - -export const McpAuthModeSchema = Type.Union([ +const McpAuthModeSchema = Type.Union([ Type.Literal('bearer'), Type.Literal('oauth'), ]) export type McpAuthMode = Static /** Closed enum over the capability vocabulary — bodies are validated, not free text. */ -const CapabilitySchema = Type.Union(CORE_CAPABILITIES.map((c) => Type.Literal(c))) +const CapabilitySchema = Type.Union(CORE_CAPABILITIES.map((capability) => Type.Literal(capability))) -/** Wire-safe projection — the only connector shape the HTTP layer returns. No token. */ -export const McpConnectorViewSchema = Type.Object({ +/** Wire-safe projection — the only persisted connection shape returned to the browser. */ +export const McpConnectionViewSchema = Type.Object({ id: Type.String(), label: Type.String(), - type: McpConnectorTypeSchema, authMode: McpAuthModeSchema, capabilities: Type.Array(CapabilitySchema), createdAt: Type.String(), @@ -38,35 +32,69 @@ export const McpConnectorViewSchema = Type.Object({ revoked: Type.Boolean(), expiresAt: Type.Union([Type.String(), Type.Null()]), }) -export type McpConnectorView = Static +export type McpConnectionView = Static + +const McpRemoteAccessSchema = Type.Union([ + Type.Literal('public-https'), + Type.Literal('local-only'), +]) +export type McpRemoteAccess = Static + +export const McpConnectionOverviewSchema = Type.Object({ + connections: Type.Array(McpConnectionViewSchema), + endpoint: Type.String(), + remoteAccess: McpRemoteAccessSchema, +}) +export type McpConnectionOverview = Static -export const CreateMcpConnectorBodySchema = Type.Object({ +export const CreateMcpAccessTokenBodySchema = Type.Object({ label: Type.String({ minLength: 1, maxLength: 120 }), - type: McpConnectorTypeSchema, capabilities: Type.Array(CapabilitySchema, { minItems: 1 }), /** * Token lifetime: * number → token expires that many days after creation (1–3650) - * null → no expiry (token never expires, explicit opt-in) + * null → no expiry (explicit opt-in) * omitted → server default (90 days) */ ttlDays: Type.Optional(Type.Union([Type.Integer({ minimum: 1, maximum: 3650 }), Type.Null()])), }) -export type CreateMcpConnectorBody = Static +export type CreateMcpAccessTokenBody = Static -/** Response to a successful create — carries the plaintext token EXACTLY ONCE. */ -export const CreateMcpConnectorResultSchema = Type.Object({ - connector: McpConnectorViewSchema, - token: Type.String(), +/** Response to a successful token creation — carries the plaintext token exactly once. */ +export const CreateMcpAccessTokenResultSchema = Type.Object({ + connection: McpConnectionViewSchema, + accessToken: Type.String(), }) -export type CreateMcpConnectorResult = Static +export type CreateMcpAccessTokenResult = Static -export const McpConnectorListSchema = Type.Object({ - connectors: Type.Array(McpConnectorViewSchema), +export const McpOAuthAuthorizationRequestSchema = Type.Object({ + responseType: Type.Literal('code'), + clientId: Type.String({ minLength: 1, maxLength: 2048 }), + redirectUri: Type.String({ minLength: 1, maxLength: 4096 }), + codeChallenge: Type.String({ minLength: 43, maxLength: 43 }), + codeChallengeMethod: Type.Literal('S256'), + scope: Type.String({ minLength: 1, maxLength: 256 }), + resource: Type.String({ minLength: 1, maxLength: 4096 }), + state: Type.Optional(Type.String({ maxLength: 2048 })), }) -export type McpConnectorList = Static +export type McpOAuthAuthorizationRequest = Static -export const RevokeMcpConnectorResultSchema = Type.Object({ - revoked: Type.Boolean(), +export const McpOAuthAuthorizationViewSchema = Type.Object({ + clientName: Type.String(), + callbackUrl: Type.String(), + grantExpiresInDays: Type.Integer(), + request: McpOAuthAuthorizationRequestSchema, +}) +export type McpOAuthAuthorizationView = Static + +export const DecideMcpOAuthAuthorizationBodySchema = Type.Object({ + decision: Type.Union([Type.Literal('approve'), Type.Literal('deny')]), + request: McpOAuthAuthorizationRequestSchema, + capabilities: Type.Optional(Type.Array(CapabilitySchema)), +}) +export type DecideMcpOAuthAuthorizationBody = Static + +export const DecideMcpOAuthAuthorizationResultSchema = Type.Object({ + redirectUrl: Type.String(), }) -export type RevokeMcpConnectorResult = Static +export type DecideMcpOAuthAuthorizationResult = Static