From 38f88bf1042d48034bfada34afba06e19786f046 Mon Sep 17 00:00:00 2001 From: enjoyandlove Date: Wed, 3 Jun 2026 22:58:52 -0400 Subject: [PATCH 1/3] feat(mcp): add native resources, prompts, and structured output schemas --- .../public/downloads/gittensory-extension.zip | Bin 0 -> 20928 bytes packages/gittensory-mcp/bin/gittensory-mcp.js | 523 +++++++++++++++++- test/unit/mcp-discovery.test.ts | 179 ++++++ test/unit/mcp-miner-prompts.test.ts | 125 +++++ test/unit/mcp-output-schemas.test.ts | 78 +++ 5 files changed, 904 insertions(+), 1 deletion(-) create mode 100644 apps/gittensory-ui/public/downloads/gittensory-extension.zip create mode 100644 test/unit/mcp-discovery.test.ts create mode 100644 test/unit/mcp-miner-prompts.test.ts create mode 100644 test/unit/mcp-output-schemas.test.ts diff --git a/apps/gittensory-ui/public/downloads/gittensory-extension.zip b/apps/gittensory-ui/public/downloads/gittensory-extension.zip new file mode 100644 index 0000000000000000000000000000000000000000..86d1bc0c32b98d2eab630c8d7d3a9c950d58a4fb GIT binary patch literal 20928 zcmcIs&2J;gb(b9{4kihZg98LG64drC)|4$ar5Vi^S09+2)$UGqK9r#rupy5*EwV`U zs>!Cio6^WyA%8)R`B##Qj{$-l^B3fnV{XaS`Mvk5s;iq#O5=5AS2JSQtE%_?UcFb% z!>|762Y1%w&j)Ki(trI||Ni5(HTwA>9!B%xH2fyl?`COMs6jf-3-#H-=TE+Ve$;<* z_^kix^|LRYy;Qp@I4z1<-tBb8aZ%`Lo@STrXcmW&E>3l(qa+;a^Wc*^m6Z;Desu7% z_w3cne(#{i|Gqx>`>&t9KKQKv0uP>iaX>5gbfKcD((h>9IGw6o=lpMxp6RKIr|Jv0 zrw5hJ3IONf&6^(l{II^~!NO)hJkIn`rJ2g~Id&d~>L~~-w2IUy)A^~lcLQdGN0s{C z(W}=WvH#VW)(2*sCla$J4F~-0#5#MhwkIL^1~bkN)@pW0^lGN*MRZhkKsN zCT1P}=#TdX=}hP4BJ!Cbk|-~pKrQsNhzB5l$eT4h1@tv&7NvRq>gki`PRxenV-V(Z zA@s>2s+$HKJ<9-?m}P@P$iip?`WtuNIPAZ8cJ$@fpZ1SleRc4%fB5>~^JhQrA3ixc zI(SXG-}zN*d^-Dfe465Cj-MPq*|)HS)A>n%78UFLPW*{s1ePMDfA#tojvM|;<09`L zNA1yO`|+Er2iw;l(M+p1qxp0|N?mGNuSm0KtXqTAES>1*@tFob<0L(alB3f&59Rqf zD_1;Htq)4T{V8l6XR5}hPE4Awt^M6dCa>0+Ku)s^CXyQ;*=q9hn?DB8JkoCXxM>w1wl(=?kz zN&FlA#2QoUJWA$TC=hMTq6_e;xSZ*9qA?!wjNMHWxTf%^Dkc^cyLq_wX0s~@`! zK9GnjvPLjhv2d29MLI|m^#N@z9+Wqy!P7J^rqM*p zAhU`dEg9ds`!L+Ze_Mf-l;iWFzb0Ho6b^`XOxg9YloTv`aB7G#@@ih^x=98}nx5sP zjquE0&QA_?mZ#Gwc`_Klb+ZriWn$J}VPs*U^P<&b>BvmAzpsK|-3SPLZO{Y=HXg!5 zIcMIJz_EaUD1|ufUA250VhvjngRUmR*6yjsVcO4BqTV-;W`z#U64VXIqTjVBlq!B0DkK4xcTZrN` zD&R;WBpnxUql(<3hn3M7-q#m>#D2lK$s~=2ZgV62mUU0NrOUEjblvw6$Fx?jfc|WV zqvGG+z@VdxC@$0?JZ_xpFiMivF&mUwIi!iCn2`-Lo-+A5_qlwYusUS%dScSG<7}d>1CU5S*NdLI-CR!E;xl z8jhXvF_A3J#IZ#xITNa2iAc5#j2LPZ&?J8qrkWj_lE20M8`7y2Xf#2W*a*$TF7gSH z{;=iPz>KP-0W}YhN9U#cFB1aCX`~opZO|EQbN-%5s*F1j1G84i#+}2suhbIre$yys zx8=IJu7s;P#@M_>+1N3gXv@gsHaJ%!+Uh`Pmfa7X3 zNF$sG4sL*8CF^p#I$_o|#6W}&z-D8mZZvMjTHmk{Tg$Rdc<`1Rh;zUWRWIWxV&{;H zLr}0dONcywf2X1pxs@jNHdqeOqJ;A^#H|+6qx4ztl}Tup717 zU*~j$`8_bN1X|dV;I%3*Vg7U0<RG2= z`Zt4(6U(iH%_NCftH-;bbH3Ga+jVB3pba=8A%M#kA$l>M@Ssyy4x1R99!6O-$=M2n z^kS+r%n`yv{>a-lgJ*h{meBNR1u&uYay~iX4UA(j`;_7n@d|m`#G@B(<-$S{n^9_XVCIJShp;qu49_*^OpF)FC0s835(&!U^{Z;}O z>J9SC^?Jy~g%;{6NT}!2p&rFkJ!G9DUvXQ1Wc2e&rDw=0vbi<|p_&V4<~Z#t^;dEh zs|kNNui}JpOEp93c!oMP74C#`x4;rG~) z0A8Rf#}=G2Po>!90CSw5=Q$@`+nZZdVx%nIxMZp#e~$9tJk#|V8_Mi$a`4-0#dnEP z9CjV<>DgkmHOZ?%F4R;5t*4aLLDsE8qPo) zI8;(KlQO;#zqSm^m>8EkuiL8YC`xi&inxeTVKkFxQI>1(a79qY$RuRoK>c| zv~}`pJbwp8&h%mt(*`*U14{9->mgK)rcfnxd-L8Rx(1Qs;6w_}edA>MCTb7iB?B-~ zqJ85is$28XC?3RUUGzB_r#Yxm8-J#U!J9Ci4wCs$=eF8t#ISE}#eriydBdgP(qXRG68T%Cxrdc*-~Hns{CM;GYisoL7wGn# zM1!+&mSP83cs${rA64m_;`0Vnqn@g%YjncWi3-9F@f3>$)I#CjM3~LdFq-HvoxVVX zN7EvVhQsGE3XaHjTCIsZ+)xl5LPACf#&r1FI7ep274(s{+Q4$h;y6H`g`|#-W4D%c z`9mg@dIC19gk;mO8Jq6Z>?6w0U>`oL*t@r@S(o#!9lFUYbp2kBYeEC^O zowZiWsGTT*#|V@iY`Akqgo1+e#q5!m&0^c%Pz=+Rf6zXKB!=O)=mecn_q8W!bv*34ECNQ&q3=? z&9e9$ft1SEbaKOC#x3=h*zM#;({P%b!8V_s#>ued#K!iFPv~(NVjvJV8p3I2mpz^6 z0Zyc?AY7DdA2Cgsz)P7A`{XYV&Y>;NX$AwN=w}FGl1fvqR-$3gkCK(Ke^~<16wH=@ za^w=)hym|S7&DuxVn-1~GE<4~Pl^3gsy+tGC1drU8C^@FYe9!%$qR`vuys;K z9C1p{*8>N{E@sTNS`&3_#A1>X zh7Q@4P|6S-5}wRV18oSPosAc4THquajx{fk#OLtU5Qgsud2Z=;crAF~#QI5j@X=MP zS~V8rD5oV^l%JUWwuUTom11_c04!W4UEA9k7FBF99G)?rqA&$OhKnHQ9>{RdDAvM; z-=y)hg+@DRnEeA-k1B9ML~IE)2Yy*9vJz<5&TVy*Fqs4Md&5UFmUIIC` zi?N=$c-J{h7Geq>G7uu{C(%H+I>#UH>;*r0(;34<4o;(txh962qpge!4(72ugAGME zhX)@AT}Q3iAow_%%syd`EZ&OCVDzXm!9s>Z2JCOd=mb? zG5;|-^qv_|AGfQ>mPxtXF26@2#H&>OiS;B;gl1^Dcow4>oGb0Q^QP|#T*7d30%jN* z81uv0?AnEGX7FV?r2MZW?=}g9UAPRj-fF&tMyOv<&H%v+gu+39WNITE$yi4ALmW}~ zniTrOm;KrN1ldPFk473&7@TRjnq(>@b_%78L*&+#B8gqw1}&2#F3*ll-pJID{mExO zxlxK>hDi(j(P|#?6iDMNKuEUw?s87e1K=P~?*GZ)@CvQ{D=qdZ}8CBTb#3dT~$;0VWRRM1U9wM1>@aw~a3 zGp{$)*7oM6hun4LRv5vQl4#)d2&KIW&NhZfiIHY|N?FOJMWAkL(*1Z!2ThtJ%QWfb zd8{w`CWG&vpig*)>Na1PtHxZboEF3t8LdJNH5?YFQ7+%Fo!1P6_$$InY$%^kAXUnX zmrXPf6$;2H|FVPMb*&8Y+u}d|^$OKB`k@NjB$~z}^f$SJPL>+jCw-jh z=~jEtRrjb-5>PP&AA<5^tzMvtU?Qn0TyR$=K)G^|#eCnLW?0qnW>WP6sTLRPIF!I% z6|!=G;C^N(%aj-dxJVjq%2Ql0#634cEyv==xFgF~jsw1s5YUy>ejUVn7XUY)!$CUf z{FF+q-g8s2TYN_Vkg{@FjKsFYD)0msjnAQe{fjh%X%np_6jd9g!xT^qqQOZzoF_WK zTyAXyz3Or?t+0JQQ@r$q64!s}eDG%tYDNo7-EVxGqwPw@u`3%Vn zidyg}cW3X^U2l%vLJq}Oj{aesG)SDaIWo5;px2*)2SFoO=v>w;C8H8Hz`sD3f8xqY9>mbYIPGN!W)EwSIUqDABbl z2*tctQO!AS*n_<@9M^GUwVXiowJ&r$K81=t-1Km$k_ZK{ldK4ZL?oE7&>~Jro*Xmb2}aDjCaV}S;q@cVx8d5>6Y&|kqAi3 z6f?IR%xv!vH+}JHoA<7fy~8B8J*Cr!W|d!*hvy4bPcHD-tNftll3vm?eOtMIY_HvJ z$cqXZsRzI8qlb7h9h$sk)yoZ;{sv_yyx6eQnK`tP!cJ5?+(n{L;KVS_z4G7=`#K9s zwvQJW@j4&9Zs!ymM|SaCw1~U18z>1{-UD*9$ZO!}3q-SYG;*PdR^I8#j7e? zn=$b()L3My&nQCg1~c@FkQ)urJ^AkV(ff{gk)&9O(t&;9u!ffRoQrX%X#!Wi8@%Ey z6>pp2UMgQ6fO<@3?~m`yh?x`9o}9Xac(d`|BuxX#$pC7;q2TCt(UWxdK1=^$By z)%P94Fusoys4PV~g;bty=Xh+}=}^ugb9N+4R(Upnf}59Vk=+^^O>PaA3mi=XM(lIo zN;p@nW|_bH_#gi5(VwlY(GRtR%Qfv)11z!lk{RXnjh9ntfZ9~fl8ob#=S_`x8fktb zfJbn!S-9gX*Bb9MUu@vA8T%aXyk9HNedt^fLLnk4&gI6tubLx*aWkCb@ekMajT{gi zvdvE7ikXO2Q!BotKHHh{Emm-_3pf}Xy~P^VnjIcuZmVV*gLE@Wi&{0Sgd%M3aEsJ5 zTYDycv}k}N+|YbA9s>c3F5ajXg4h&gQ`~8!xXqvNhA02~nH276HNRpgwZdkNY&mg; z+}5z~CZK_}Xd}F?EyD1fKG5w8wX%jJFH}Hd(6Sn9@L6gH)H<_2Ja!P8;M<^%$}M-2J`~HwyC&fv=s(3oYkpz<#0i~ z?0T&4HyUs66*t975lPL}u1+8S?T#e+M6i%$Z)) zfc~-WL4Iw8jOSdd6O^3J;C|GZ)-#18ADtb=9kGGSd4=8+Zr~$(t-wRJ=B<2uoNFVB zg^We*!F7JaARm3&z|$kohL^*W8*U!8e%b6`!eXsr9=uE&=%pGhjz&!JwZc6Q%@-#f z(n6zq18DUQMj(sYi7$9C3*pd5GO}NUA`u9kMP`O^Bb4>vTOf!>2BjiZEX`3~P4R^?qxhW^O>i8hN|7z;ous(EHz_~=rj4$ne%DoZx3(W{Kf3o|o9m-o zt?epQDO&faG`WGmnhaW7o15nsuwW`b$~Ck}^v=~W2$~C-9`tDin(jG{P|xYQy5^F? z)7ISwV9UKNv@9PzqN2+7eGbC40-`C?_@zUy$ZKnBWqJ1`eb>%UBeYpjK4PAzZLA@s zOSHhu|8_T12foL3;O*~J2*%Bq2BFy-n=HJALKVL4VmxhA8w|D50lcHs0sR7%;fIsz zO~u%|%tcozq(Z3LGNr;wW%0p%R2G*ZO?~bv%JCFMaox5hHlsdw&C0IOWj-$<>>GTG zL_E5bm+YtlU(#HYyQxoCinhC&6ov8pPdIVf-A%2O%mL+fg z4Rvp+0^W>xt#Z0pGqu%FuN2Jm{&H8Y!mr--XR6U^N|gL6J$r+d>+y}Cs^(giy{%57$c=8+i7qa*o)Y7I zb2QjkbG$7=bn9>xgBQJFlQVq$7`+X`$m)M-5x%~d-bX10=!>x@y0Oq^eWVj|SIu!7 zI-{#`%j`uB6VFoMQ&$1fwQUMg3x00F?wfhMN=wyHdQ*nF>n#2j32=ll- zNvA3AxS@KVzrcs~YdxMP5#Bd=p{EI6IB*cl$&>bl$e;P(#L$e(W0v6m11kN#oU(}1 zRuM*{lkMmcA%FPQ_rCulP9~5~tSx>$AU!Srz~|9BKlMrVw~WdO|BY`MES+-0)cRW_ z<%UV{-?Dz~qMUcwsa?XTPp$XD%c=jnRn{!2zFD7HuWyz!f4Ni6T;xwZZ!72h c_6t9YJN)X8|AfYVi2wfaduwZdb4Z{54+NVq8~^|S literal 0 HcmV?d00001 diff --git a/packages/gittensory-mcp/bin/gittensory-mcp.js b/packages/gittensory-mcp/bin/gittensory-mcp.js index 8aa9d61d..91a1edcf 100755 --- a/packages/gittensory-mcp/bin/gittensory-mcp.js +++ b/packages/gittensory-mcp/bin/gittensory-mcp.js @@ -3,7 +3,7 @@ import { createHash } from "node:crypto"; import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { delimiter, dirname, join } from "node:path"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { z } from "zod"; import { buildBranchAnalysisPayload, collectLocalDiff, collectLocalBranchMetadata, probeLocalScorer, referenceScorePreviewExample, resolveScorePreviewCommand, sanitizeLocalScorerStatus, setupGuidanceForLocalScorer } from "../lib/local-branch.js"; @@ -480,6 +480,527 @@ server.registerTool( async (input) => toolResult("Gittensory base-agent public-safe PR packet.", await agentPreparePrPacket(input)), ); +// ── Output schemas for structured tool responses (#291) ────────────────────── + +const repoContextOutputSchema = { + type: "object", + properties: { + repoFullName: { type: "string" }, + lane: { type: "string" }, + primaryLanguage: { type: ["string", "null"] }, + openIssueCount: { type: "number" }, + openPrCount: { type: "number" }, + }, + additionalProperties: true, +}; + +const preflightOutputSchema = { + type: "object", + properties: { + status: { type: "string", enum: ["pass", "warn", "fail", "unknown"] }, + signals: { type: "array", items: { type: "object" } }, + summary: { type: "string" }, + }, + additionalProperties: true, +}; + +const decisionPackOutputSchema = { + type: "object", + properties: { + login: { type: "string" }, + decisions: { type: "array", items: { type: "object" } }, + cachedAt: { type: ["string", "null"] }, + }, + additionalProperties: true, +}; + +const localStatusOutputSchema = { + type: "object", + properties: { + apiUrl: { type: "string" }, + package: { type: "object", properties: { name: { type: "string" }, version: { type: "string" } }, additionalProperties: true }, + hasToken: { type: "boolean" }, + profile: { type: "object", additionalProperties: true }, + authLogin: { type: ["string", "null"] }, + sessionExpiresAt: { type: ["string", "null"] }, + sourceUploadDefault: { type: "boolean" }, + sourceUploadSupported: { type: "boolean" }, + git: { type: "object", additionalProperties: true }, + }, + additionalProperties: true, +}; + +const agentPlanOutputSchema = { + type: "object", + properties: { + login: { type: "string" }, + actions: { type: "array", items: { type: "object" } }, + topAction: { type: ["object", "null"] }, + }, + additionalProperties: true, +}; + +// Attach outputSchema to key tools via registerTool with zod output schemas. +// All other tools continue to return unschematized text+structured content. + +server.registerTool( + "gittensory_local_status_structured", + { + description: "Return local Gittensory MCP status with a validated structured output schema.", + inputSchema: { + cwd: z.string().optional(), + baseRef: z.string().optional(), + repoFullName: z.string().min(3).optional(), + }, + outputSchema: z.object({ + apiUrl: z.string(), + package: z.object({ name: z.string(), version: z.string() }), + hasToken: z.boolean(), + profile: z.record(z.unknown()), + authLogin: z.string().nullable(), + sessionExpiresAt: z.string().nullable(), + sourceUploadDefault: z.boolean(), + sourceUploadSupported: z.boolean(), + git: z.record(z.unknown()), + }), + }, + async (input) => { + let git = null; + try { + git = collectLocalBranchMetadata({ cwd: input.cwd ?? process.cwd(), baseRef: input.baseRef, repoFullName: input.repoFullName, login: "local" }); + } catch (error) { + git = { error: error instanceof Error ? error.message : "local_status_failed" }; + } + const data = { + apiUrl, + package: { name: packageName, version: packageVersion }, + hasToken: Boolean(getApiToken()), + profile: profilePublicState(activeProfileName), + authLogin: activeProfile.session?.login ?? null, + sessionExpiresAt: activeProfile.session?.expiresAt ?? null, + sourceUploadDefault: false, + sourceUploadSupported: false, + git: git ?? {}, + }; + return { content: [{ type: "text", text: `Gittensory local MCP status.\n\n${JSON.stringify(data, null, 2)}` }], structuredContent: data }; + }, +); + +// ── Resources: decision-pack, doctor, compatibility, changelog (#292) ───────── + +server.registerResource( + "gittensory_changelog", + "gittensory://changelog", + { + title: "Gittensory MCP Changelog", + description: "Current CHANGELOG.md for the installed gittensory-mcp package.", + mimeType: "text/markdown", + }, + async () => { + let text; + try { + text = readFileSync(changelogPath, "utf8"); + } catch { + text = "Changelog not available."; + } + return { contents: [{ uri: "gittensory://changelog", mimeType: "text/markdown", text }] }; + }, +); + +server.registerResource( + "gittensory_compatibility", + "gittensory://compatibility", + { + title: "Gittensory API Compatibility", + description: "Current API compatibility state: version, supported methods, and any deprecation notices.", + mimeType: "application/json", + }, + async () => { + let data; + try { + data = await apiGet(compatibilityPath); + } catch { + data = { status: "unavailable", currentApiVersion, packageVersion }; + } + return { contents: [{ uri: "gittensory://compatibility", mimeType: "application/json", text: JSON.stringify(data, null, 2) }] }; + }, +); + +server.registerResource( + "gittensory_decision_pack", + new ResourceTemplate("gittensory://decision-packs/{login}", { list: undefined }), + { + title: "Gittensory Decision Pack", + description: "Cached private contributor decision pack for a GitHub login. Requires authentication.", + mimeType: "application/json", + }, + async (uri, { login }) => { + const payload = await getDecisionPackWithCache(String(login)); + return { contents: [{ uri: uri.href, mimeType: "application/json", text: JSON.stringify(payload, null, 2) }] }; + }, +); + +// ── Miner planning prompts (#293) ───────────────────────────────────────────── + +server.registerPrompt( + "gittensory_miner_select_issue", + { + title: "Select Next Issue to Work On", + description: "Guide a contributor through selecting the best open issue to work on next, using Gittensory lane and duplicate signals. Advisory only — no GitHub writes.", + argsSchema: { + repoFullName: z.string().min(3).describe("Target repository in owner/repo format."), + login: z.string().min(1).describe("GitHub login of the contributor."), + }, + }, + ({ repoFullName, login }) => ({ + messages: [ + { + role: "user", + content: { + type: "text", + text: [ + `You are a Gittensory miner planning assistant for ${login} working on ${repoFullName}.`, + "", + "Your job is to help the contributor select the best open issue to work on next.", + "Use the gittensory_get_repo_context and gittensory_agent_plan_next_work tools to fetch lane and queue signals.", + "", + "Guidelines:", + "- Prefer issues that match the repo lane (feature, bug, docs, test, refactor, chore).", + "- Avoid issues with existing open PRs unless the contributor owns one of them.", + "- Flag duplicate or stale work before the contributor invests time.", + "- Summarize the top 3 candidate issues with a short rationale for each.", + "- Do not open, comment on, label, close, or modify any GitHub issue or PR.", + "- Do not predict reward amounts, payout estimates, or public scoreability rankings.", + "- Do not request wallet, hotkey, coldkey, private keys, or tokens.", + ].join("\n"), + }, + }, + ], + }), +); + +server.registerPrompt( + "gittensory_miner_draft_pr_packet", + { + title: "Draft PR Packet for Current Branch", + description: "Guide a contributor through preparing a public-safe PR packet for the current branch. Advisory only — no GitHub writes.", + argsSchema: { + repoFullName: z.string().min(3).describe("Target repository in owner/repo format."), + login: z.string().min(1).describe("GitHub login of the contributor."), + }, + }, + ({ repoFullName, login }) => ({ + messages: [ + { + role: "user", + content: { + type: "text", + text: [ + `You are a Gittensory miner planning assistant for ${login} working on ${repoFullName}.`, + "", + "Your job is to help the contributor prepare a public-safe PR packet for their current branch.", + "Use gittensory_preflight_current_branch or gittensory_prepare_pr_packet to gather branch signals.", + "", + "Guidelines:", + "- Draft a title, description, and label suggestions based on the diff metadata.", + "- Flag any preflight warnings (duplicate work, missing linked issue, test coverage gaps).", + "- Keep the draft public-safe: no private scoreability data, no raw trust scores.", + "- Present the draft for the contributor to review and edit before opening a PR.", + "- Do not open, comment on, label, close, or merge any GitHub PR.", + "- Do not predict reward amounts or publish scoring predictions.", + "- Do not request wallet, hotkey, coldkey, private keys, or tokens.", + ].join("\n"), + }, + }, + ], + }), +); + +server.registerPrompt( + "gittensory_miner_branch_preflight", + { + title: "Branch Preflight Check", + description: "Run a preflight check on the current branch and summarize blockers for the contributor. Advisory only.", + argsSchema: { + repoFullName: z.string().min(3).describe("Target repository in owner/repo format."), + login: z.string().min(1).describe("GitHub login of the contributor."), + }, + }, + ({ repoFullName, login }) => ({ + messages: [ + { + role: "user", + content: { + type: "text", + text: [ + `You are a Gittensory miner planning assistant for ${login} working on ${repoFullName}.`, + "", + "Your job is to run a branch preflight check and explain any blockers clearly.", + "Use gittensory_explain_local_blockers and gittensory_preflight_current_branch to fetch signals.", + "", + "Guidelines:", + "- List each blocker with a plain-language explanation and suggested remediation.", + "- Distinguish between hard blockers (will prevent merge) and soft warnings (worth fixing).", + "- Do not open, comment on, label, close, or merge any GitHub PR.", + "- Do not expose private scoreability details or raw trust scores in public-facing text.", + "- Do not request wallet, hotkey, coldkey, private keys, or tokens.", + ].join("\n"), + }, + }, + ], + }), +); + +server.registerPrompt( + "gittensory_miner_cleanup_first", + { + title: "Cleanup-First Planning", + description: "Help a contributor identify stale or low-value open PRs to close before opening new work. Advisory only.", + argsSchema: { + login: z.string().min(1).describe("GitHub login of the contributor."), + }, + }, + ({ login }) => ({ + messages: [ + { + role: "user", + content: { + type: "text", + text: [ + `You are a Gittensory miner planning assistant for ${login}.`, + "", + "Your job is to help the contributor identify stale or low-value open PRs to close or supersede before opening new work.", + "Use gittensory_get_decision_pack to fetch the contributor decision pack.", + "", + "Guidelines:", + "- List open PRs that are stale, duplicate, or conflicting with newer work.", + "- Suggest which to close, which to rebase, and which to keep open.", + "- Summarize the expected queue pressure impact of each decision.", + "- Do not close, comment on, label, or merge any GitHub PR autonomously.", + "- Do not predict reward amounts, payout estimates, or public scoring outcomes.", + "- Do not request wallet, hotkey, coldkey, private keys, or tokens.", + ].join("\n"), + }, + }, + ], + }), +); + +// ── Maintainer and repo-owner workflow prompts (#294) ───────────────────────── + +server.registerPrompt( + "gittensory_maintainer_queue_triage", + { + title: "Maintainer Queue Triage", + description: "Guide a maintainer through triaging the open PR queue using Gittensory signals. Advisory only — no GitHub writes.", + argsSchema: { + repoFullName: z.string().min(3).describe("Target repository in owner/repo format."), + }, + }, + ({ repoFullName }) => ({ + messages: [ + { + role: "user", + content: { + type: "text", + text: [ + `You are a Gittensory maintainer assistant for ${repoFullName}.`, + "", + "Your job is to help the maintainer triage the open PR queue.", + "Use gittensory_get_repo_context to fetch current lane and queue signals.", + "", + "Guidelines:", + "- Group PRs by: ready to review, needs changes, stale, duplicate.", + "- Flag PRs with missing linked issues, failing checks, or low-quality diffs.", + "- Suggest a review order based on lane fit and contributor history.", + "- Prepare review notes and questions for the maintainer to post manually.", + "- Do not post comments, approve, request changes, label, close, or merge any PR autonomously.", + "- Do not expose private scoreability details, raw trust scores, or private reviewer context.", + "- Do not request wallet, hotkey, coldkey, private keys, or tokens.", + ].join("\n"), + }, + }, + ], + }), +); + +server.registerPrompt( + "gittensory_maintainer_review_prep", + { + title: "Maintainer Review Preparation", + description: "Prepare a structured review packet for a specific PR. Advisory only — no GitHub writes.", + argsSchema: { + repoFullName: z.string().min(3).describe("Target repository in owner/repo format."), + pullNumber: z.string().min(1).describe("PR number to prepare a review for."), + }, + }, + ({ repoFullName, pullNumber }) => ({ + messages: [ + { + role: "user", + content: { + type: "text", + text: [ + `You are a Gittensory maintainer assistant for ${repoFullName}.`, + "", + `Your job is to prepare a structured review packet for PR #${pullNumber}.`, + "Use gittensory_preflight_pr or gittensory_explain_repo_decision to fetch relevant signals.", + "", + "Guidelines:", + "- Summarize the PR scope, changed files, and linked issue (if any).", + "- List preflight signals: lane fit, duplicate risk, test coverage, queue pressure.", + "- Draft review questions or change requests for the maintainer to post manually.", + "- Keep all output public-safe: no private scoreability data or raw trust scores.", + "- Do not post review comments, approve, request changes, label, close, or merge the PR.", + "- Do not request wallet, hotkey, coldkey, private keys, or tokens.", + ].join("\n"), + }, + }, + ], + }), +); + +server.registerPrompt( + "gittensory_maintainer_public_guidance", + { + title: "Maintainer Public Guidance Draft", + description: "Draft low-noise, public-safe guidance for a contributor based on their PR. Advisory only — no GitHub writes.", + argsSchema: { + repoFullName: z.string().min(3).describe("Target repository in owner/repo format."), + contributorLogin: z.string().min(1).describe("GitHub login of the contributor."), + }, + }, + ({ repoFullName, contributorLogin }) => ({ + messages: [ + { + role: "user", + content: { + type: "text", + text: [ + `You are a Gittensory maintainer assistant for ${repoFullName}.`, + "", + `Your job is to draft low-noise, public-safe guidance for contributor ${contributorLogin}.`, + "Use gittensory_get_repo_context for lane context.", + "", + "Guidelines:", + "- Draft a short, encouraging, actionable comment the maintainer can post manually.", + "- Focus on what the contributor should change, not on scoring or reward prediction.", + "- Keep the tone neutral and constructive — no compensation language.", + "- Do not mention trust scores, hotkeys, coldkeys, wallet addresses, reward estimates, or private reviewability.", + "- Do not post the comment autonomously — present it for the maintainer to review and post.", + "- Do not close, label, merge, or modify the PR autonomously.", + ].join("\n"), + }, + }, + ], + }), +); + +server.registerPrompt( + "gittensory_repo_owner_intake_readiness", + { + title: "Repo Owner Intake Readiness", + description: "Guide a repo owner through assessing contributor intake readiness using Gittensory signals. Advisory only.", + argsSchema: { + repoFullName: z.string().min(3).describe("Target repository in owner/repo format."), + }, + }, + ({ repoFullName }) => ({ + messages: [ + { + role: "user", + content: { + type: "text", + text: [ + `You are a Gittensory repo-owner assistant for ${repoFullName}.`, + "", + "Your job is to help the repo owner assess contributor intake readiness.", + "Use gittensory_get_repo_context to fetch lane and queue signals.", + "", + "Guidelines:", + "- Summarize current lane health: open issue count, PR queue pressure, merge rate.", + "- Flag gaps in the CONTRIBUTING.md, issue templates, or lane focus manifest.", + "- Recommend intake improvements the repo owner can make manually.", + "- Do not autonomously edit repo files, post comments, or open/close issues or PRs.", + "- Do not expose private scoreability data or raw trust scores publicly.", + "- Do not request wallet, hotkey, coldkey, private keys, or tokens.", + ].join("\n"), + }, + }, + ], + }), +); + +server.registerPrompt( + "gittensory_repo_owner_focus_manifest_review", + { + title: "Repo Owner Focus Manifest Review", + description: "Help a repo owner review and improve their focus manifest using Gittensory policy signals. Advisory only.", + argsSchema: { + repoFullName: z.string().min(3).describe("Target repository in owner/repo format."), + }, + }, + ({ repoFullName }) => ({ + messages: [ + { + role: "user", + content: { + type: "text", + text: [ + `You are a Gittensory repo-owner assistant for ${repoFullName}.`, + "", + "Your job is to help the repo owner review and improve their Gittensory focus manifest.", + "Use gittensory_get_repo_context to fetch current policy and lane signals.", + "", + "Guidelines:", + "- Identify gaps or inconsistencies in the focus manifest.", + "- Suggest improvements to label policy, contribution lanes, and readiness criteria.", + "- Draft an updated manifest section for the repo owner to review and apply manually.", + "- Do not autonomously push changes to the repo or open PRs.", + "- Do not expose private scoreability data or raw trust scores.", + "- Do not request wallet, hotkey, coldkey, private keys, or tokens.", + ].join("\n"), + }, + }, + ], + }), +); + +server.registerPrompt( + "gittensory_repo_owner_onboarding_pack", + { + title: "Repo Owner Onboarding Pack Planning", + description: "Help a repo owner plan and draft an onboarding pack for new contributors. Advisory only.", + argsSchema: { + repoFullName: z.string().min(3).describe("Target repository in owner/repo format."), + }, + }, + ({ repoFullName }) => ({ + messages: [ + { + role: "user", + content: { + type: "text", + text: [ + `You are a Gittensory repo-owner assistant for ${repoFullName}.`, + "", + "Your job is to help the repo owner plan and draft an onboarding pack for new contributors.", + "Use gittensory_get_repo_context to fetch lane and policy signals.", + "", + "Guidelines:", + "- Draft an onboarding overview: repo purpose, contribution lanes, good-first-issue guidance.", + "- Suggest CONTRIBUTING.md sections, issue templates, and label conventions to add or improve.", + "- Keep all content public-safe: no private scoreability, raw trust, or reward prediction.", + "- Present the draft for the repo owner to review and apply manually.", + "- Do not autonomously push changes, open PRs, or post comments.", + "- Do not request wallet, hotkey, coldkey, private keys, or tokens.", + ].join("\n"), + }, + }, + ], + }), +); + await server.connect(new StdioServerTransport()); async function runCli(args) { diff --git a/test/unit/mcp-discovery.test.ts b/test/unit/mcp-discovery.test.ts new file mode 100644 index 00000000..bab8324c --- /dev/null +++ b/test/unit/mcp-discovery.test.ts @@ -0,0 +1,179 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +const bin = join(process.cwd(), "packages/gittensory-mcp/bin/gittensory-mcp.js"); + +const FORBIDDEN_PUBLIC_TERMS = /wallet\s*[:=]\s*\S+|hotkey\s*[:=]\s*\S+|coldkey\s*[:=]\s*\S+|raw trust score is|your trust score|reward estimate is|estimated reward/i; + +let client: Client; +let transport: StdioClientTransport; +let configDir: string; + +async function connect() { + configDir = mkdtempSync(join(tmpdir(), "gittensory-discovery-")); + transport = new StdioClientTransport({ + command: "node", + args: [bin, "--stdio"], + env: { + ...process.env, + GITTENSORY_CONFIG_DIR: configDir, + GITTENSORY_API_TIMEOUT_MS: "1000", + }, + }); + client = new Client({ name: "discovery-test", version: "0.0.1" }); + await client.connect(transport); +} + +async function disconnect() { + await client.close().catch(() => undefined); + if (configDir) rmSync(configDir, { recursive: true, force: true }); +} + +describe("MCP resource discovery", () => { + beforeEach(connect); + afterEach(disconnect); + + it("discovers all expected resources", async () => { + const { resources } = await client.listResources(); + const uris = resources.map((r) => r.uri); + expect(uris).toContain("gittensory://changelog"); + expect(uris).toContain("gittensory://compatibility"); + }); + + it("resource descriptions do not expose forbidden public terms", async () => { + const { resources } = await client.listResources(); + for (const resource of resources) { + const text = [resource.name, resource.description ?? ""].join(" "); + expect(text).not.toMatch(FORBIDDEN_PUBLIC_TERMS); + } + }); + + it("can read the changelog resource without authentication", async () => { + const result = await client.readResource({ uri: "gittensory://changelog" }); + expect(result.contents).toHaveLength(1); + const content = result.contents[0]; + expect(content?.mimeType).toBe("text/markdown"); + if (!content || !("text" in content)) throw new Error("expected text content"); + expect(typeof content.text).toBe("string"); + expect(content.text).not.toMatch(FORBIDDEN_PUBLIC_TERMS); + }); + + it("can read the compatibility resource and get structured JSON", async () => { + const result = await client.readResource({ uri: "gittensory://compatibility" }); + expect(result.contents).toHaveLength(1); + const content = result.contents[0]; + expect(content?.mimeType).toBe("application/json"); + if (!content || !("text" in content)) throw new Error("expected text content"); + // Must be parseable JSON (either real API response or unavailable fallback). + expect(() => JSON.parse(content.text ?? "")).not.toThrow(); + }); + + it("decision-pack resource template is discoverable", async () => { + const { resourceTemplates } = await client.listResourceTemplates(); + const names = resourceTemplates.map((t) => t.name); + expect(names).toContain("gittensory_decision_pack"); + }); +}); + +describe("MCP prompt discovery", () => { + beforeEach(connect); + afterEach(disconnect); + + it("discovers all expected miner planning prompts", async () => { + const { prompts } = await client.listPrompts(); + const names = prompts.map((p) => p.name); + expect(names).toContain("gittensory_miner_select_issue"); + expect(names).toContain("gittensory_miner_draft_pr_packet"); + expect(names).toContain("gittensory_miner_branch_preflight"); + expect(names).toContain("gittensory_miner_cleanup_first"); + }); + + it("discovers all expected maintainer and repo-owner prompts", async () => { + const { prompts } = await client.listPrompts(); + const names = prompts.map((p) => p.name); + expect(names).toContain("gittensory_maintainer_queue_triage"); + expect(names).toContain("gittensory_maintainer_review_prep"); + expect(names).toContain("gittensory_maintainer_public_guidance"); + expect(names).toContain("gittensory_repo_owner_intake_readiness"); + expect(names).toContain("gittensory_repo_owner_focus_manifest_review"); + expect(names).toContain("gittensory_repo_owner_onboarding_pack"); + }); + + it("prompt descriptions do not expose forbidden public terms", async () => { + const { prompts } = await client.listPrompts(); + for (const prompt of prompts) { + const text = [prompt.name, prompt.description ?? ""].join(" "); + expect(text).not.toMatch(FORBIDDEN_PUBLIC_TERMS); + } + }); + + it("miner prompts require expected arguments", async () => { + const { prompts } = await client.listPrompts(); + + const selectIssue = prompts.find((p) => p.name === "gittensory_miner_select_issue"); + const argNames = selectIssue?.arguments?.map((a) => a.name) ?? []; + expect(argNames).toContain("repoFullName"); + expect(argNames).toContain("login"); + + const cleanupFirst = prompts.find((p) => p.name === "gittensory_miner_cleanup_first"); + const cleanupArgs = cleanupFirst?.arguments?.map((a) => a.name) ?? []; + expect(cleanupArgs).toContain("login"); + }); + + it("maintainer review prep prompt requires pullNumber and repoFullName arguments", async () => { + const { prompts } = await client.listPrompts(); + const reviewPrep = prompts.find((p) => p.name === "gittensory_maintainer_review_prep"); + const argNames = reviewPrep?.arguments?.map((a) => a.name) ?? []; + expect(argNames).toContain("repoFullName"); + expect(argNames).toContain("pullNumber"); + }); +}); + +describe("MCP prompt content safety", () => { + beforeEach(connect); + afterEach(disconnect); + + it("miner select-issue prompt text enforces no-write and no-credential boundaries", async () => { + const result = await client.getPrompt({ name: "gittensory_miner_select_issue", arguments: { repoFullName: "owner/repo", login: "dev" } }); + const text = result.messages.map((m) => (typeof m.content === "object" && "text" in m.content ? m.content.text : "")).join("\n"); + expect(text).toMatch(/do not open|do not.*comment|do not.*label|do not.*close|do not.*merge/i); + expect(text).toMatch(/do not request wallet|do not request.*hotkey|do not request.*coldkey/i); + expect(text).not.toMatch(FORBIDDEN_PUBLIC_TERMS); + }); + + it("miner cleanup-first prompt text enforces no-write boundary", async () => { + const result = await client.getPrompt({ name: "gittensory_miner_cleanup_first", arguments: { login: "dev" } }); + const text = result.messages.map((m) => (typeof m.content === "object" && "text" in m.content ? m.content.text : "")).join("\n"); + expect(text).toMatch(/do not close|do not.*comment|do not.*merge/i); + expect(text).not.toMatch(FORBIDDEN_PUBLIC_TERMS); + }); + + it("maintainer queue triage prompt enforces no-autonomous-write boundary", async () => { + const result = await client.getPrompt({ name: "gittensory_maintainer_queue_triage", arguments: { repoFullName: "owner/repo" } }); + const text = result.messages.map((m) => (typeof m.content === "object" && "text" in m.content ? m.content.text : "")).join("\n"); + expect(text).toMatch(/do not post|do not.*merge|do not.*label/i); + expect(text).toMatch(/do not expose.*private|no.*private scoreability|no.*raw trust/i); + expect(text).not.toMatch(FORBIDDEN_PUBLIC_TERMS); + }); + + it("maintainer public guidance prompt forbids compensation language and autonomous posting", async () => { + const result = await client.getPrompt({ name: "gittensory_maintainer_public_guidance", arguments: { repoFullName: "owner/repo", contributorLogin: "dev" } }); + const text = result.messages.map((m) => (typeof m.content === "object" && "text" in m.content ? m.content.text : "")).join("\n"); + expect(text).toMatch(/do not post.*autonomously|present it for/i); + expect(text).toMatch(/no compensation language/i); + expect(text).not.toMatch(FORBIDDEN_PUBLIC_TERMS); + }); + + it("repo-owner prompts forbid autonomous repo edits and private data exposure", async () => { + for (const name of ["gittensory_repo_owner_intake_readiness", "gittensory_repo_owner_focus_manifest_review", "gittensory_repo_owner_onboarding_pack"]) { + const result = await client.getPrompt({ name, arguments: { repoFullName: "owner/repo" } }); + const text = result.messages.map((m) => (typeof m.content === "object" && "text" in m.content ? m.content.text : "")).join("\n"); + expect(text).toMatch(/do not autonomously|do not.*push|present.*manually/i); + expect(text).not.toMatch(FORBIDDEN_PUBLIC_TERMS); + } + }); +}); diff --git a/test/unit/mcp-miner-prompts.test.ts b/test/unit/mcp-miner-prompts.test.ts new file mode 100644 index 00000000..79763b80 --- /dev/null +++ b/test/unit/mcp-miner-prompts.test.ts @@ -0,0 +1,125 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +const bin = join(process.cwd(), "packages/gittensory-mcp/bin/gittensory-mcp.js"); + +const FORBIDDEN_PATTERN = /wallet\s*[:=]\s*\S+|hotkey\s*[:=]\s*\S+|coldkey\s*[:=]\s*\S+|raw trust score is|your trust score|reward estimate is|estimated reward/i; + +let client: Client; +let transport: StdioClientTransport; +let configDir: string; + +async function connect() { + configDir = mkdtempSync(join(tmpdir(), "gittensory-miner-prompts-")); + transport = new StdioClientTransport({ + command: "node", + args: [bin, "--stdio"], + env: { + ...process.env, + GITTENSORY_CONFIG_DIR: configDir, + GITTENSORY_API_TIMEOUT_MS: "1000", + }, + }); + client = new Client({ name: "miner-prompt-test", version: "0.0.1" }); + await client.connect(transport); +} + +async function disconnect() { + await client.close().catch(() => undefined); + if (configDir) rmSync(configDir, { recursive: true, force: true }); +} + +function extractText(messages: Array<{ content: unknown }>): string { + return messages + .map((m) => (typeof m.content === "object" && m.content !== null && "text" in m.content ? (m.content as { text: string }).text : "")) + .join("\n"); +} + +describe("gittensory_miner_select_issue prompt", () => { + beforeEach(connect); + afterEach(disconnect); + + it("returns a user message with issue selection guidance", async () => { + const result = await client.getPrompt({ name: "gittensory_miner_select_issue", arguments: { repoFullName: "owner/repo", login: "dev" } }); + expect(result.messages).toHaveLength(1); + expect(result.messages[0]?.role).toBe("user"); + const text = extractText(result.messages); + expect(text).toContain("owner/repo"); + expect(text).toContain("dev"); + expect(text).toMatch(/select.*issue|issue.*select/i); + }); + + it("enforces the no-write human-approval boundary", async () => { + const result = await client.getPrompt({ name: "gittensory_miner_select_issue", arguments: { repoFullName: "owner/repo", login: "dev" } }); + const text = extractText(result.messages); + expect(text).toMatch(/do not open|do not.*comment|do not.*label|do not.*close|do not.*merge/i); + }); + + it("prohibits credential and scoring requests", async () => { + const result = await client.getPrompt({ name: "gittensory_miner_select_issue", arguments: { repoFullName: "owner/repo", login: "dev" } }); + const text = extractText(result.messages); + expect(text).toMatch(/do not request wallet|do not request.*hotkey|do not request.*coldkey/i); + expect(text).toMatch(/do not predict reward|do not predict.*scoring/i); + expect(text).not.toMatch(FORBIDDEN_PATTERN); + }); +}); + +describe("gittensory_miner_draft_pr_packet prompt", () => { + beforeEach(connect); + afterEach(disconnect); + + it("returns a user message with PR packet drafting guidance", async () => { + const result = await client.getPrompt({ name: "gittensory_miner_draft_pr_packet", arguments: { repoFullName: "owner/repo", login: "dev" } }); + const text = extractText(result.messages); + expect(text).toMatch(/draft|pr packet|pull request/i); + expect(text).toContain("owner/repo"); + }); + + it("enforces no-write and public-safe boundaries", async () => { + const result = await client.getPrompt({ name: "gittensory_miner_draft_pr_packet", arguments: { repoFullName: "owner/repo", login: "dev" } }); + const text = extractText(result.messages); + expect(text).toMatch(/do not open|do not.*merge/i); + expect(text).toMatch(/public.?safe|no private/i); + expect(text).not.toMatch(FORBIDDEN_PATTERN); + }); +}); + +describe("gittensory_miner_branch_preflight prompt", () => { + beforeEach(connect); + afterEach(disconnect); + + it("returns a user message with preflight guidance", async () => { + const result = await client.getPrompt({ name: "gittensory_miner_branch_preflight", arguments: { repoFullName: "owner/repo", login: "dev" } }); + const text = extractText(result.messages); + expect(text).toMatch(/blocker|preflight|remediation/i); + }); + + it("does not expose private scoreability in prompt text", async () => { + const result = await client.getPrompt({ name: "gittensory_miner_branch_preflight", arguments: { repoFullName: "owner/repo", login: "dev" } }); + const text = extractText(result.messages); + expect(text).not.toMatch(FORBIDDEN_PATTERN); + }); +}); + +describe("gittensory_miner_cleanup_first prompt", () => { + beforeEach(connect); + afterEach(disconnect); + + it("returns stale PR cleanup guidance", async () => { + const result = await client.getPrompt({ name: "gittensory_miner_cleanup_first", arguments: { login: "dev" } }); + const text = extractText(result.messages); + expect(text).toMatch(/stale|cleanup|close|supersede/i); + expect(text).toContain("dev"); + }); + + it("enforces no-autonomous-write boundary", async () => { + const result = await client.getPrompt({ name: "gittensory_miner_cleanup_first", arguments: { login: "dev" } }); + const text = extractText(result.messages); + expect(text).toMatch(/do not close.*autonomously|do not.*merge.*autonomously|do not.*comment.*autonomously/i); + expect(text).not.toMatch(FORBIDDEN_PATTERN); + }); +}); diff --git a/test/unit/mcp-output-schemas.test.ts b/test/unit/mcp-output-schemas.test.ts new file mode 100644 index 00000000..1b8cfa92 --- /dev/null +++ b/test/unit/mcp-output-schemas.test.ts @@ -0,0 +1,78 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +const bin = join(process.cwd(), "packages/gittensory-mcp/bin/gittensory-mcp.js"); + +let client: Client; +let transport: StdioClientTransport; +let configDir: string; + +async function connect() { + configDir = mkdtempSync(join(tmpdir(), "gittensory-schemas-")); + transport = new StdioClientTransport({ + command: "node", + args: [bin, "--stdio"], + env: { + ...process.env, + GITTENSORY_CONFIG_DIR: configDir, + GITTENSORY_API_TIMEOUT_MS: "1000", + }, + }); + client = new Client({ name: "schema-test", version: "0.0.1" }); + await client.connect(transport); +} + +async function disconnect() { + await client.close().catch(() => undefined); + if (configDir) rmSync(configDir, { recursive: true, force: true }); +} + +describe("MCP structured output schemas", () => { + beforeEach(connect); + afterEach(disconnect); + + it("gittensory_local_status_structured tool is discoverable", async () => { + const { tools } = await client.listTools(); + const tool = tools.find((t) => t.name === "gittensory_local_status_structured"); + expect(tool).toBeDefined(); + expect(tool?.description).toMatch(/local.*status|status.*structured/i); + }); + + it("gittensory_local_status_structured tool has an output schema", async () => { + const { tools } = await client.listTools(); + const tool = tools.find((t) => t.name === "gittensory_local_status_structured"); + expect(tool?.outputSchema).toBeDefined(); + const schema = tool?.outputSchema as Record; + expect(schema.type).toBe("object"); + const properties = schema.properties as Record; + expect(properties).toHaveProperty("apiUrl"); + expect(properties).toHaveProperty("hasToken"); + expect(properties).toHaveProperty("package"); + expect(properties).toHaveProperty("sourceUploadDefault"); + expect(properties).toHaveProperty("sourceUploadSupported"); + }); + + it("all existing tools remain discoverable and are not broken by schema additions", async () => { + const { tools } = await client.listTools(); + const names = tools.map((t) => t.name); + expect(names).toContain("gittensory_get_repo_context"); + expect(names).toContain("gittensory_preflight_pr"); + expect(names).toContain("gittensory_get_decision_pack"); + expect(names).toContain("gittensory_local_status"); + expect(names).toContain("gittensory_preflight_current_branch"); + expect(names).toContain("gittensory_agent_plan_next_work"); + expect(names).toContain("gittensory_agent_prepare_pr_packet"); + }); + + it("tools do not expose private or forbidden fields in their descriptions", async () => { + const { tools } = await client.listTools(); + for (const tool of tools) { + const text = tool.description ?? ""; + expect(text).not.toMatch(/wallet address|hotkey|coldkey|raw trust score|private scoreability ranking/i); + } + }); +}); From 639da183ddb617a49bf42932ee1bc1a4aceb8472 Mon Sep 17 00:00:00 2001 From: enjoyandlove Date: Wed, 3 Jun 2026 23:16:43 -0400 Subject: [PATCH 2/3] test(mcp): fix concatenated test files for miner prompts and output schemas --- test/unit/mcp-miner-prompts.test.ts | 122 --------------------------- test/unit/mcp-output-schemas.test.ts | 74 ---------------- 2 files changed, 196 deletions(-) diff --git a/test/unit/mcp-miner-prompts.test.ts b/test/unit/mcp-miner-prompts.test.ts index ffea4451..ac878d03 100644 --- a/test/unit/mcp-miner-prompts.test.ts +++ b/test/unit/mcp-miner-prompts.test.ts @@ -1,126 +1,4 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; -import { mkdtempSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; - -const bin = join(process.cwd(), "packages/gittensory-mcp/bin/gittensory-mcp.js"); - -const FORBIDDEN_PATTERN = /wallet\s*[:=]\s*\S+|hotkey\s*[:=]\s*\S+|coldkey\s*[:=]\s*\S+|raw trust score is|your trust score|reward estimate is|estimated reward/i; - -let client: Client; -let transport: StdioClientTransport; -let configDir: string; - -async function connect() { - configDir = mkdtempSync(join(tmpdir(), "gittensory-miner-prompts-")); - transport = new StdioClientTransport({ - command: "node", - args: [bin, "--stdio"], - env: { - ...process.env, - GITTENSORY_CONFIG_DIR: configDir, - GITTENSORY_API_TIMEOUT_MS: "1000", - }, - }); - client = new Client({ name: "miner-prompt-test", version: "0.0.1" }); - await client.connect(transport); -} - -async function disconnect() { - await client.close().catch(() => undefined); - if (configDir) rmSync(configDir, { recursive: true, force: true }); -} - -function extractText(messages: Array<{ content: unknown }>): string { - return messages - .map((m) => (typeof m.content === "object" && m.content !== null && "text" in m.content ? (m.content as { text: string }).text : "")) - .join("\n"); -} - -describe("gittensory_miner_select_issue prompt", () => { - beforeEach(connect); - afterEach(disconnect); - - it("returns a user message with issue selection guidance", async () => { - const result = await client.getPrompt({ name: "gittensory_miner_select_issue", arguments: { repoFullName: "owner/repo", login: "dev" } }); - expect(result.messages).toHaveLength(1); - expect(result.messages[0]?.role).toBe("user"); - const text = extractText(result.messages); - expect(text).toContain("owner/repo"); - expect(text).toContain("dev"); - expect(text).toMatch(/select.*issue|issue.*select/i); - }); - - it("enforces the no-write human-approval boundary", async () => { - const result = await client.getPrompt({ name: "gittensory_miner_select_issue", arguments: { repoFullName: "owner/repo", login: "dev" } }); - const text = extractText(result.messages); - expect(text).toMatch(/do not open|do not.*comment|do not.*label|do not.*close|do not.*merge/i); - }); - - it("prohibits credential and scoring requests", async () => { - const result = await client.getPrompt({ name: "gittensory_miner_select_issue", arguments: { repoFullName: "owner/repo", login: "dev" } }); - const text = extractText(result.messages); - expect(text).toMatch(/do not request wallet|do not request.*hotkey|do not request.*coldkey/i); - expect(text).toMatch(/do not predict reward|do not predict.*scoring/i); - expect(text).not.toMatch(FORBIDDEN_PATTERN); - }); -}); - -describe("gittensory_miner_draft_pr_packet prompt", () => { - beforeEach(connect); - afterEach(disconnect); - - it("returns a user message with PR packet drafting guidance", async () => { - const result = await client.getPrompt({ name: "gittensory_miner_draft_pr_packet", arguments: { repoFullName: "owner/repo", login: "dev" } }); - const text = extractText(result.messages); - expect(text).toMatch(/draft|pr packet|pull request/i); - expect(text).toContain("owner/repo"); - }); - - it("enforces no-write and public-safe boundaries", async () => { - const result = await client.getPrompt({ name: "gittensory_miner_draft_pr_packet", arguments: { repoFullName: "owner/repo", login: "dev" } }); - const text = extractText(result.messages); - expect(text).toMatch(/do not open|do not.*merge/i); - expect(text).toMatch(/public.?safe|no private/i); - expect(text).not.toMatch(FORBIDDEN_PATTERN); - }); -}); - -describe("gittensory_miner_branch_preflight prompt", () => { - beforeEach(connect); - afterEach(disconnect); - - it("returns a user message with preflight guidance", async () => { - const result = await client.getPrompt({ name: "gittensory_miner_branch_preflight", arguments: { repoFullName: "owner/repo", login: "dev" } }); - const text = extractText(result.messages); - expect(text).toMatch(/blocker|preflight|remediation/i); - }); - - it("does not expose private scoreability in prompt text", async () => { - const result = await client.getPrompt({ name: "gittensory_miner_branch_preflight", arguments: { repoFullName: "owner/repo", login: "dev" } }); - const text = extractText(result.messages); - expect(text).not.toMatch(FORBIDDEN_PATTERN); - }); -}); - -describe("gittensory_miner_cleanup_first prompt", () => { - beforeEach(connect); - afterEach(disconnect); - - it("returns stale PR cleanup guidance", async () => { - const result = await client.getPrompt({ name: "gittensory_miner_cleanup_first", arguments: { login: "dev" } }); - const text = extractText(result.messages); - expect(text).toMatch(/stale|cleanup|close|supersede/i); - expect(text).toContain("dev"); - }); - - it("enforces no-autonomous-write boundary", async () => { - const result = await client.getPrompt({ name: "gittensory_miner_cleanup_first", arguments: { login: "dev" } }); - const text = extractText(result.messages); - expect(text).toMatch(/do not close.*autonomously|do not.*merge.*autonomously|do not.*comment.*autonomously/i); - expect(text).not.toMatch(FORBIDDEN_PATTERN); import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import { describe, expect, it } from "vitest"; import { GittensoryMcp } from "../../src/mcp/server"; diff --git a/test/unit/mcp-output-schemas.test.ts b/test/unit/mcp-output-schemas.test.ts index 9e885508..d1473ef9 100644 --- a/test/unit/mcp-output-schemas.test.ts +++ b/test/unit/mcp-output-schemas.test.ts @@ -1,78 +1,4 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; -import { mkdtempSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; - -const bin = join(process.cwd(), "packages/gittensory-mcp/bin/gittensory-mcp.js"); - -let client: Client; -let transport: StdioClientTransport; -let configDir: string; - -async function connect() { - configDir = mkdtempSync(join(tmpdir(), "gittensory-schemas-")); - transport = new StdioClientTransport({ - command: "node", - args: [bin, "--stdio"], - env: { - ...process.env, - GITTENSORY_CONFIG_DIR: configDir, - GITTENSORY_API_TIMEOUT_MS: "1000", - }, - }); - client = new Client({ name: "schema-test", version: "0.0.1" }); - await client.connect(transport); -} - -async function disconnect() { - await client.close().catch(() => undefined); - if (configDir) rmSync(configDir, { recursive: true, force: true }); -} - -describe("MCP structured output schemas", () => { - beforeEach(connect); - afterEach(disconnect); - - it("gittensory_local_status_structured tool is discoverable", async () => { - const { tools } = await client.listTools(); - const tool = tools.find((t) => t.name === "gittensory_local_status_structured"); - expect(tool).toBeDefined(); - expect(tool?.description).toMatch(/local.*status|status.*structured/i); - }); - - it("gittensory_local_status_structured tool has an output schema", async () => { - const { tools } = await client.listTools(); - const tool = tools.find((t) => t.name === "gittensory_local_status_structured"); - expect(tool?.outputSchema).toBeDefined(); - const schema = tool?.outputSchema as Record; - expect(schema.type).toBe("object"); - const properties = schema.properties as Record; - expect(properties).toHaveProperty("apiUrl"); - expect(properties).toHaveProperty("hasToken"); - expect(properties).toHaveProperty("package"); - expect(properties).toHaveProperty("sourceUploadDefault"); - expect(properties).toHaveProperty("sourceUploadSupported"); - }); - - it("all existing tools remain discoverable and are not broken by schema additions", async () => { - const { tools } = await client.listTools(); - const names = tools.map((t) => t.name); - expect(names).toContain("gittensory_get_repo_context"); - expect(names).toContain("gittensory_preflight_pr"); - expect(names).toContain("gittensory_get_decision_pack"); - expect(names).toContain("gittensory_local_status"); - expect(names).toContain("gittensory_preflight_current_branch"); - expect(names).toContain("gittensory_agent_plan_next_work"); - expect(names).toContain("gittensory_agent_prepare_pr_packet"); - }); - - it("tools do not expose private or forbidden fields in their descriptions", async () => { - const { tools } = await client.listTools(); - for (const tool of tools) { - const text = tool.description ?? ""; - expect(text).not.toMatch(/wallet address|hotkey|coldkey|raw trust score|private scoreability ranking/i); import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import { describe, expect, it } from "vitest"; import { GittensoryMcp } from "../../src/mcp/server"; From 2a115963855743551d83bbc657ad23d32d538b1d Mon Sep 17 00:00:00 2001 From: enjoyandlove Date: Thu, 4 Jun 2026 07:15:03 -0400 Subject: [PATCH 3/3] fix(security): remove committed extension zip binary and gitignore the build artifact --- .gitignore | 1 + .../public/downloads/gittensory-extension.zip | Bin 20928 -> 0 bytes 2 files changed, 1 insertion(+) delete mode 100644 apps/gittensory-ui/public/downloads/gittensory-extension.zip diff --git a/.gitignore b/.gitignore index 47163a29..7cf61abc 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,4 @@ coverage/ *.tsbuildinfo site/.vitepress/cache/ !migrations/*.sql +apps/gittensory-ui/public/downloads/gittensory-extension.zip diff --git a/apps/gittensory-ui/public/downloads/gittensory-extension.zip b/apps/gittensory-ui/public/downloads/gittensory-extension.zip deleted file mode 100644 index 86d1bc0c32b98d2eab630c8d7d3a9c950d58a4fb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 20928 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