{"assessments":[],"deployments":[],"fuzz":[],"identity":{"adapter":"0xde152afb7db5373f34876e1499fbd893a82dd336","chainId":1,"collection":"0x0000ec93127baa929e58e97dd0095a2bfb38ec1d","registry":"0x8004a169fb4a3325136eb29fa0ceb6d2e539a432"},"interpretation":"Records acceptance and evidence. Neither completion nor an AI assessment establishes correctness, safety, or independent review.","jobId":"42b77bf7-138d-44a1-861b-dd89ddb131f0","kind":"audit","nodes":[{"acceptedSubmissionHash":"12d2728926fd8ae1ddbffff9ce94db754ce6b7cb51ceebd6d8eb4fcadcc9e561","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_economics","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"976703f6fb7a8cb95699e950fbe97e9f67e1dc3eae018a9749780d46c863f388","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_flow","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"ab2b3c7aa5d214d9973a3b16bc5242e19aac2ead577ea5179f2ca6ed2a89860c","dependsOn":["audit_math","audit_permissions","audit_economics","audit_flow"],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"af3aa01159bbf354f621cafa5c0006f6169e0938a8b015be508b7377dbf165bc","skillId":"audit-judge","tools":[]},"key":"audit_judge","kind":"code","role":"review","skillHash":"af3aa01159bbf354f621cafa5c0006f6169e0938a8b015be508b7377dbf165bc","skillId":"audit-judge","state":"accepted"},{"acceptedSubmissionHash":"a80f6b6ee4bb50251c78062d10206460c058ffc45b2936c4c5bfb8d989095daf","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_math","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"dac2fabf76feb23aac86b86d76f238619e4d661e89d7ba54460092562fbdae39","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_permissions","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"}],"objective":"Audit the contracts in agentcitylabs/gmgn-robinhood-mcp as they are.","parentJobId":null,"planHash":"b2dfa36bca5ed88f9df2baaa6795229f701c3b438d553ea6f97ee58a392befb8","previousHash":"0000000000000000000000000000000000000000000000000000000000000000","projectId":"42b77bf7-138d-44a1-861b-dd89ddb131f0","publication":{"commit":null,"deliveredAt":null,"repoUrl":null},"receiptIdentity":{"adapter":"0xde152afb7db5373f34876e1499fbd893a82dd336","chainId":1,"collection":"0x0000ec93127baa929e58e97dd0095a2bfb38ec1d","registry":"0x8004a169fb4a3325136eb29fa0ceb6d2e539a432"},"registry":"0xb6d0a187b050fa5bb0b87033a203f37becf4a775","research":[],"schema":"identitymd-work-v1","signals":[{"agentId":"52641","feedbackHash":"e6ad6af493327fb04f15e357c9e612feee7aeb7925578aea1001bad7f3d7798b","nodeKey":"audit_economics","submissionHash":"12d2728926fd8ae1ddbffff9ce94db754ce6b7cb51ceebd6d8eb4fcadcc9e561","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51432","feedbackHash":"d16fe5d98cc81aa9534f663b58368006f8992b66bb11aa7cd321ac676fc74b1d","nodeKey":"audit_flow","submissionHash":"976703f6fb7a8cb95699e950fbe97e9f67e1dc3eae018a9749780d46c863f388","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52262","feedbackHash":"b8bcdaba61cc36c45824b8dc658907ab1a88290cf9b0829eccec400bb56778df","nodeKey":"audit_judge","submissionHash":"ab2b3c7aa5d214d9973a3b16bc5242e19aac2ead577ea5179f2ca6ed2a89860c","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52251","feedbackHash":"71451a84649d8ce2a6c7003f23a3a07ca15d5121649ec93c170afe780285a6df","nodeKey":"audit_math","submissionHash":"a80f6b6ee4bb50251c78062d10206460c058ffc45b2936c4c5bfb8d989095daf","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52606","feedbackHash":"29f393b95c27de624f78783db9c1019fd41f0ffb999d7f3dece984bc7396e54e","nodeKey":"audit_permissions","submissionHash":"dac2fabf76feb23aac86b86d76f238619e4d661e89d7ba54460092562fbdae39","tag1":"review:submission","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"c56dc5503f85191c9b72a91d36cd114d1d4384d418b6073308925a43d637eab2","state":"completed","submissions":[{"artifacts":[],"attempt":1,"bundleHash":null,"device":"803eeae3d8b63de3","findings":[{"citation":"resolved","description":"Invariant: a risk field that GMGN has not measured must not add points. The scorer violates it. `number()` (src/scoring.js:2) converts any string with `Number(value)`, so the empty string and the string \"0\" both become 0. gmgn-cli's own contract-DD documentation (skills/gmgn-contract-dd/SKILL.md, Step 1A and Step 'Always available') states that on an empty EVM security block `top_10_holder_rate`, `buy_tax` and `sell_tax` arrive as \"0\" (sometimes \"\"), and that '0% top-10 concentration does not exist, so never score it as a good sign'. The scorer instead feeds 0 into `holderDistribution = (0.7 - top10) / 0.7` = 1.0 (the full 25 points), suppresses the `high_holder_concentration` hard flag, and counts the field as present in `dataCompleteness`. Economic impact: a brand-new Robinhood launch whose security block GMGN has not populated yet (the normal state for `new_creation` tokens, which is exactly what the daily brief and screen tools feed into this scorer) is promoted to 'Deep research' with the maximum distribution score, while a token with a measured 31% top-10 share scores lower. Same root cause for `rug_ratio` and `holder_count`: \"\" or \"0\" are read as a measured zero (rug 0 = contractSafety 1.0).","line":12,"path":"src/scoring.js","reproduction":"Unit level: `node -e 'import(\"./src/scoring.js\").then(m=>console.log(JSON.stringify(m.researchScore({info:{holder_count:400},security:{open_source:\"unknown\",top_10_holder_rate:\"0\",buy_tax:\"0\",sell_tax:\"0\"},pool:{liquidity:\"52000\"}}))))'` prints researchScore 79, dataConfidence 0.75, disposition \"Deep research\", factors.holderDistribution 1. With `top_10_holder_rate:\"\"` and `open_source:\"yes\"` it prints 85 / \"Deep research\" / holderDistribution 1. Expected: top10 undefined -> holderDistribution 0, dataCompleteness 0.5 -> score <= 54, disposition 'Watchlist' or 'Insufficient data'. End to end: run `node src/index.js` with MCP_TRANSPORT=stdio and GMGN_CLI_PATH pointing at a stub that answers `token security` with {\"open_source\":\"unknown\",\"top_10_holder_rate\":\"0\",\"buy_tax\":\"0\",\"sell_tax\":\"0\"}; calling agentcity_research_robinhood_token returns derived.researchScore 79 and disposition 'Deep research'. Fix: in `number()` return undefined for \"\" / whitespace, and treat a top-10 rate of 0 as unavailable (per vendor doc), never as a perfect score.","severity":"high","snippet":"  const top10 = number(get(security, \"top_10_holder_rate\"));","title":"Unpopulated security block (\"0\" / \"\" top_10_holder_rate) is scored as perfect holder distribution and counted as present data"},{"citation":"resolved","description":"The README advertises 'Reject from research queue: rug ratio > 0.3' and `contractSafety` is weighted on `rug`. Both read `security.rug_ratio` only. gmgn-cli's contract-DD skill (skills/gmgn-contract-dd/SKILL.md, Step 5B, measured 2026-08-28) states: 'There is no address lookup for rug_ratio. It is absent from token info, token security and token pool - verified', and that the label is only reachable from the `market trenches` / `market trending` listing rows. (The vendor's token field reference does list `rug_ratio` under token security, so the two vendor documents conflict; the measured one is dated and chain-sampled.) When the key is absent the scorer silently sets `rug = undefined`: the `high_rug_ratio` hard flag can never fire, `contractSafety` collapses to the constant 0.65/0.35, `dataCompleteness` caps at 0.75, and the README's documented max score / dataConfidence 1 become unreachable. The server already fetches trenches rows that carry `rug_ratio` (the screen and list tools) but the research and compare tools never consult them, so a token GMGN labels rug_ratio = 1.0 in the listing passes research with no flag. This is a flow gap (execution x periphery x first principles): every step runs cleanly and the end state contradicts the stated reject rule.","line":13,"path":"src/scoring.js","reproduction":"Stub `token security` to return a populated block without a rug_ratio key: {\"open_source\":\"yes\",\"top_10_holder_rate\":\"0.31\",\"buy_tax\":\"0\",\"sell_tax\":\"0\"} and `market trenches` to return a new_creation row for the same address with rug_ratio 1. Call agentcity_research_robinhood_token: derived.hardFlags is [] , disposition 'Deep research', researchScore 74, dataConfidence 0.75 (observed). Expected per README: hardFlags [\"high_rug_ratio\"], disposition 'Reject from research queue'. Unit level: `researchScore({info:{holder_count:400},security:{open_source:\"yes\",top_10_holder_rate:0.31},pool:{liquidity:52000}})` -> contractSafety 0.65, hardFlags []. Fix: resolve rug_ratio from the trenches/trending listing row for the address when the security block lacks it, and report 'rug_ratio unavailable' explicitly (a separate state from 0) in the derived block.","severity":"medium","snippet":"  const rug = number(get(security, \"rug_ratio\"));","title":"rug_ratio hard flag is read from the token-security response where the vendor reports it is absent; no fallback to the listing that carries it"},{"citation":"resolved","description":"Invariant stated in the README: 'Data completeness: liquidity, holders, top-10 share, and rug ratio are all present'. `liquidity` and `holders` are coalesced with `?? 0` on lines 10-11 before this presence check, so they are never undefined and always count as present. Two of the four completeness slots are therefore constant, `dataConfidence` has a floor of 0.5 for a packet that contains nothing, and 10 of the 20 completeness points are awarded unconditionally. A second consequence: a missing liquidity figure (pool_info failed to resolve, or the field moved) is indistinguishable from a measured $0 liquidity, so the derived block cannot tell the agent that depth is unknown.","line":18,"path":"src/scoring.js","reproduction":"`node -e 'import(\"./src/scoring.js\").then(m=>console.log(JSON.stringify(m.researchScore({info:{},security:{},pool:{}}))))'` prints dataConfidence 0.5, factors.dataCompleteness 0.5, researchScore 24 for an empty packet; `researchScore({})`-style input with all three undefined gives the same. Expected: dataCompleteness 0, dataConfidence 0, and the liquidityDepth factor reported as unknown rather than 0. Fix: compute the presence list from the raw `number(...)` results before applying `?? 0`.","severity":"medium","snippet":"    dataCompleteness: [liquidity, holders, top10, rug].filter((x) => x !== undefined).length / 4,","title":"dataCompleteness counts liquidity and holder_count as present even when both are missing, inflating dataConfidence and score"},{"citation":"resolved","description":"Shared-capacity starvation (Economic Security guide). The hosted endpoint https://gmgn.agentcity.lol/mcp is public (README quick start) and this handler applies no authentication, per-client quota, or concurrency cap. Each `agentcity_research_robinhood_token` call spawns 5 parallel gmgn-cli processes; `agentcity_compare_robinhood_tokens` with 5 addresses spawns 15 in parallel (Promise.all inside Promise.all). All of them use the single operator `GMGN_API_KEY`. gmgn-cli's docs state the key is subject to a leaky bucket of 20 requests/second, that exceeding it returns RATE_LIMIT_BANNED with a cooldown of up to 5 minutes, and that every request during the cooldown extends the ban by 5 s (skills/gmgn-token-buy/references/fields.md; skills/gmgn-token/SKILL.md 'When a request returns 429'). Two concurrent compare calls therefore exceed the bucket in well under a second, and continued anonymous traffic keeps the key banned, denying every other user of the hosted server. The cost to the attacker is HTTP POSTs; the cost to the operator is their paid GMGN plan going dark. Each spawned CLI also sleeps up to 5 s for its own 429 auto-retry inside the 25 s execFile timeout, so banned requests additionally pin server processes.","line":38,"path":"src/index.js","reproduction":"Run `node src/index.js` with GMGN_CLI_PATH pointing at a stub CLI that appends a timestamp per invocation to a log and sleeps 300 ms. Call agentcity_compare_robinhood_tokens with five distinct 0x addresses once: the log shows 15 invocations whose first-to-last start spread is 119 ms (observed), i.e. 15 upstream requests in parallel from a single tool call; two such calls issued concurrently by one anonymous HTTP client produce 30 requests in the same window, above the documented 20 req/s bucket. Expected: a per-client limiter and a global concurrency cap on gmgn() (e.g. a queue of ~10 in flight), or at least authentication on /mcp. Note: no live GMGN traffic was generated for this finding; the ban semantics are taken from the vendor's documentation.","severity":"medium","snippet":"  app.post(\"/mcp\", async (req, res, next) => {","title":"Unauthenticated /mcp endpoint fans out 5-15 GMGN requests per call with no concurrency or rate limit, letting any caller exhaust and ban the operator's shared API key"},{"citation":"resolved","description":"Flow gap (execution x periphery x first principles). The research tool's purpose is due diligence on newly created launchpad tokens, and the scorer is explicitly written to tolerate missing fields (`undefined` handling and an 'Insufficient data' disposition). But the five upstream reads are joined with Promise.all, so if any one of them rejects (pool_info absent for a bonding-curve token that has no DEX pool yet, a single 429 among five parallel requests, a transient non-JSON reply) the whole tool call returns an error, the four successful responses are thrown away, and the five requests of API quota are spent for nothing. The same pattern in agentcity_compare_robinhood_tokens makes one bad address fail the comparison of all five.","line":57,"path":"src/gmgn.js","reproduction":"Stub gmgn-cli so that `token pool` exits 1 with '[gmgn-cli] GET /v1/token/pool_info failed: HTTP 404' while info/security/holders/traders succeed. Call agentcity_research_robinhood_token with any valid address: result.isError is true with text 'GMGN query failed: Command failed: ... token pool ...' and no `derived` block, even though info and security were returned (observed: 5 spawns, error). Expected: a packet with pool null, liquidity reported as unknown, dataConfidence reduced, and the error attached per sub-source. Fix: use Promise.allSettled and record per-source errors in `observed`.","severity":"low","snippet":"  const [info, security, pool, holders, traders] = await Promise.all([","title":"tokenBundle is all-or-nothing: one failed sub-call (e.g. pool_info 404 on a not-yet-graduated launch) discards four successful responses and returns no packet"},{"citation":"resolved","description":"The tool advertises `limit` (1-200) as the number of trades and `side` as a filter. In gmgn-cli 1.6.6 `track smartmoney --side` and `track kol --side` are documented and implemented as a client-side filter: the CLI fetches `limit` records then does `data.list = data.list.filter(item => item.side === opts.side)` (dist/commands/track.js lines 95-97 and 112-114). The MCP passes the user's limit straight through, so `limit: 50, side: \"sell\"` returns only the sells among the 50 most recent trades, which during a buy-heavy window is an empty list. The agent receives `observed.list: []` with no indication that sells exist beyond the window, and the daily brief's 'smart money' section is built the same way.","line":39,"path":"src/gmgn.js","reproduction":"Call agentcity_robinhood_smart_money with {limit: 50, side: \"sell\"} while the 50 most recent smart-money trades are buys (stub `track smartmoney` to return 50 records with side 'buy' and apply the CLI's own side filter): the tool returns {\"observed\":{\"list\":[]}} (observed). Expected: 50 sell trades, or an explicit note that `side` narrows the last `limit` trades rather than fetching `limit` matching trades. Fix: request the CLI maximum (200) when `side` is set and truncate to `limit` after filtering, and say so in the tool description.","severity":"low","snippet":"  if (side !== undefined) args.push(\"--side\", side);","title":"smart_money `side` filter is applied by gmgn-cli after `limit`, so limit=N with side=sell can return far fewer than N or zero trades"},{"citation":"resolved","description":"The disposition vocabulary (SKILL.md 'Required output to users') distinguishes 'Insufficient data' from a low-quality token, and the derived block reports `dataConfidence` separately for that reason. The disposition rule ignores dataCompleteness entirely: any score below 45 is called 'Insufficient data' even when all four inputs are present and confidence is 1.0. A thin, concentrated token is therefore reported to the agent as a data problem to be re-queried rather than a weak candidate, which inverts the next action the SKILL.md workflow prescribes.","line":26,"path":"src/scoring.js","reproduction":"`researchScore({info:{holder_count:50},security:{top_10_holder_rate:0.5,rug_ratio:0.3},pool:{liquidity:1000}})` returns researchScore 44, dataConfidence 1, hardFlags [], disposition 'Insufficient data' (observed). Expected: a low-score disposition such as 'Low priority' when dataConfidence is 1, with 'Insufficient data' reserved for dataCompleteness below a threshold. Fix: branch on dataCompleteness before the score bands.","severity":"low","snippet":"  const disposition = hardFlags.length ? \"Reject from research queue\" : score >= 70 ? \"Deep research\" : score >= 45 ? \"Watchlist\" : \"Insufficient data\";","title":"'Insufficient data' disposition is assigned by score alone, so a fully populated low-quality packet is labelled as missing data"},{"citation":"resolved","description":"README 'Research score' lists Activity quality with weight 10 and the bands 'Score >= 70' etc. as if the scale were 0-100. The factor is 0 or 0.7, never 1, so at most 7 points are available; the best possible score is 97 and, with rug_ratio missing from the security response (see the rug_ratio finding), 85. The README example block shows dataConfidence 1, which also cannot occur in that state. This is a documentation/calibration mismatch rather than an exploit; it matters because downstream agents are told to read the bands as fractions of 100.","line":22,"path":"src/scoring.js","reproduction":"`researchScore({info:{holder_count:1},security:{top_10_holder_rate:0,rug_ratio:0,is_open_source:true},pool:{liquidity:1e9}})` returns researchScore 97 with every factor at its maximum (observed). Expected per README weights: 100. Fix: either document the 0.7 ceiling (weight 7) or make activityQuality 1 when wash trading is not flagged.","severity":"info","snippet":"    activityQuality: wash ? 0 : 0.7","title":"Activity-quality factor caps at 0.7 so the documented weight of 10 and a score of 100 are unreachable (maximum 97, or 85 when rug_ratio is absent)"}],"hash":"12d2728926fd8ae1ddbffff9ce94db754ce6b7cb51ceebd6d8eb4fcadcc9e561","nodeId":"cf87cd94-db30-4e01-bf21-923db7edc8ed","outcome":"completed","summary":"Review complete. The findings file is written and every quoted snippet was checked against its line in the tree. Memory writes were denied by this session's sandbox, so nothing was saved there.\n\n**What the repository actually is.** It contains no Solidity. It is a Node MCP server with three source files that shell out to the vendor's `gmgn-cli` and compute a research score. I audited that scoring economics and the tool-to-CLI flow under the Economic Security, Invariant and Flow Gap guides, using the CLI's published source and field documentation as the periphery contract. Foundry proofs do not apply, so the high finding carries runnable Node reproductions instead.\n\n**Findings written to .imd-findings.json (8):**\n\n- **High, src/scoring.js:12.** An unpopulated security block, which the vendor documents as arriving with `top_10_holder_rate` of `\"0\"` or `\"\"`, is scored as a perfect holder distribution and counted as present data. A fresh launch with no security data scores 79 to 85 and lands in \"Deep research\".\n- **Medium, src/scoring.js:13.** The rug-ratio hard flag reads only the security response, which the vendor's measured documentation says never carries that field. The flag can never fire, and the trenches rows that do carry it are never consulted.\n- **Medium, src/scoring.js:18.** Liquidity and holder count are coalesced to 0 before the completeness check, so an empty packet reports data confidence 0.5. Missing liquidity is indistinguishable from $0.\n- **Medium, src/index.js:38.** The public, unauthenticated endpoint fans out up to 15 parallel upstream requests per call with no concurrency or rate limit, against one shared API key with a documented 20 req/s bucket and 5-minute bans. Observed: 15 spawns within 119 ms from a single compare call.\n- **Low, src/gmgn.js:57.** One failed sub-call discards four successes and returns no packet. Reproduced with a pool_info 404.\n- **Low, src/gmgn.js:39.** The side filter is applied by the CLI after the limit, so `limit=50, side=sell` can return zero trades.\n- **Low, src/scoring.js:26.** \"Insufficient data\" is assigned by score alone. A fully populated packet with confidence 1 is labelled as missing data.\n- **Info, src/scoring.js:22.** Activity quality caps at 0.7, so the documented weight of 10 and a score of 100 are unreachable.\n\n**Unverified leads, not reported as findings.** The MCP strips a second `data` layer that the CLI has already unwrapped, which only matters if some endpoint nests one. Whether `rug_ratio` and `is_wash_trading` ever appear in the security response could not be settled because the vendor's two documents disagree and no live GMGN traffic was generated. The whole process environment, including any `GMGN_PRIVATE_KEY`, is passed to the CLI, but no tool argument can reach a signed command, so that stays a trust assumption.\n\n**Coverage.** All three source files and the config files were read in full. No tests exist in the repository, which is itself a gap: none of the edge inputs above are exercised anywhere. No files other than the findings file were created or changed.","treeHash":null,"usage":{"cachedInputTokens":1590662,"inputTokens":610,"model":"claude-fable-5-1","outputTokens":37236,"runtime":"claude","turns":37,"wallClockMs":541664}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"03f15d1296244279","findings":[{"citation":"resolved","description":"researchScore() treats any numeric top_10_holder_rate as a measurement. gmgn-cli 1.6.6's own measured field notes (skills/gmgn-contract-dd/SKILL.md) state that `security.top_10_holder_rate` arrives as a string and is `\"0\"` when GMGN has not populated the holder analysis, and that '0% top-10 concentration does not exist, so never score it as a good sign'. The server does the opposite: number(\"0\") = 0 gives holderDistribution = (0.7 - 0)/0.7 = 1, the full 25 points, and the field is also counted toward dataCompleteness. The `high_holder_concentration` hard flag cannot fire either, so a token whose real concentration is above 50% but whose security block is unpopulated is promoted instead of rejected. Fix: treat top10 <= 0 (and holder_count 0 / liquidity 0 from an empty info block) as unavailable: exclude from dataCompleteness and give holderDistribution 0; optionally fall back to stat.top_10_holder_rate when the top-level value is 0.","line":20,"path":"src/scoring.js","reproduction":"Call researchScore({info:{holder_count:500,liquidity:\"60000\"},security:{is_open_source:true,top_10_holder_rate:\"0\"},pool:{liquidity:\"60000\"}}) (reproduced locally with node). Actual: factors.holderDistribution = 1, dataCompleteness = 0.75, researchScore = 85, disposition \"Deep research\", hardFlags []. Same token with the populated value \"0.62\": holderDistribution 0.114, researchScore 63, disposition \"Reject from research queue\". Expected for \"0\": holderDistribution 0, the field excluded from dataCompleteness, no promotion. Also reproduced end-to-end over HTTP with a stub gmgn-cli returning that security block: agentcity_research_robinhood_token returned derived.holderDistribution 1 and disposition \"Watchlist\" on an address whose info block was GMGN's empty record (symbol \"\", holder_count 0, liquidity \"0\").","severity":"medium","snippet":"    holderDistribution: top10 === undefined ? 0 : Math.max(0, Math.min(1, (0.7 - top10) / 0.7)),","title":"Unpopulated top_10_holder_rate of \"0\" scores the maximum holder-distribution factor and counts as present data"},{"citation":"resolved","description":"number() returns Number(value) for any string, and Number(\"\") === 0 (also Number(\" \") === 0). GMGN responses use empty strings for unset fields (gmgn-cli docs: tax fields 'empty string means 0', burn_status \"\" when not burned, top_10_holder_rate 'null/absent/empty string/or the string \"0\"' when unpopulated). An empty string therefore becomes a measured 0, which for top_10_holder_rate (holderDistribution 1) and rug_ratio (contractSafety 1 - 0 = 1) is the best possible value, and it is counted as present data. The score then reaches the \"Deep research\" band with 100% data confidence on fields that carry nothing. Fix: in number(), return undefined when the trimmed string is empty (e.g. `if (typeof value === \"string\" && value.trim() === \"\") return undefined;`).","line":2,"path":"src/scoring.js","reproduction":"Call researchScore({info:{holder_count:\"\",liquidity:\"\"},security:{top_10_holder_rate:\"\",rug_ratio:\"\"},pool:{liquidity:\"100000\"}}) (reproduced locally with node). Actual: dataCompleteness 1, dataConfidence 1, holderDistribution 1, contractSafety 0.7, researchScore 91, disposition \"Deep research\", hardFlags []. Expected: top10 and rug undefined, dataCompleteness 0.5 at most, holderDistribution 0, contractSafety 0.35, score in the \"Insufficient data\" band.","severity":"medium","snippet":"  const result = typeof value === \"number\" ? value : typeof value === \"string\" ? Number(value) : NaN;","title":"Empty-string numeric fields coerce to 0 and produce the best possible score with no data"},{"citation":"resolved","description":"Lines 10-11 apply `?? 0` to liquidity and holder_count, so by the time line 18 tests `x !== undefined` those two entries are always defined. The factor README describes as 'Liquidity, holders, top-10 share, and rug ratio are all present' is therefore 0.5 for a bundle with no data at all, and the reported dataConfidence (which SKILL.md tells agents to surface separately as a trust signal) is 0.5 for an address GMGN has never seen. GMGN's documented empty info block (symbol \"\", holder_count 0, liquidity \"0\") is likewise counted as two present fields. Fix: compute presence from the raw number() results before defaulting (e.g. `const liquidityRaw = number(get(pool,\"liquidity\")) ?? number(get(info,\"liquidity\")); const liquidity = liquidityRaw ?? 0;` and use liquidityRaw/holdersRaw in the completeness array), and treat 0 holder_count / 0 liquidity from an empty info block as absent.","line":18,"path":"src/scoring.js","reproduction":"Call researchScore({info:null,security:null,pool:null}) (reproduced locally with node). Actual: factors.dataCompleteness 0.5, dataConfidence 0.5, researchScore 24. Expected: dataCompleteness 0, dataConfidence 0. Same result for researchScore({info:{symbol:\"\",address:\"\",holder_count:0,liquidity:\"0\"},security:{},pool:{}}), GMGN's response shape for an unknown address.","severity":"medium","snippet":"    dataCompleteness: [liquidity, holders, top10, rug].filter((x) => x !== undefined).length / 4,","title":"dataCompleteness / dataConfidence can never drop below 0.5 because liquidity and holder_count are defaulted to 0 before the presence check"},{"citation":"resolved","description":"Every tool call forks a fresh node process running gmgn-cli (each ~60-80 MB RSS, up to 25 s wall time). agentcity_compare_robinhood_tokens issues 3 calls per address for up to 5 addresses with Promise.all, so a single request spawns 15 processes; agentcity_research_robinhood_token spawns 5. Nothing in index.js or gmgn.js bounds in-flight children, queues requests, or throttles callers, and the HTTP endpoint is deliberately unauthenticated and bound to 0.0.0.0 in production. The pm2 max_memory_restart only watches the parent. Separately, all callers hit GMGN with the operator's single GMGN_API_KEY; gmgn-cli's own 429 text says 'repeated requests can extend the ban by 5s up to 5 minutes', so one abusive client gets the shared key banned for every user, and the only retry logic (one retry inside the CLI) makes a burst worse. Fix: add a process-level semaphore around execFile (e.g. p-limit of 8-16), a per-IP rate limiter on /mcp, and surface GMGN 429s without retry.","line":18,"path":"src/gmgn.js","reproduction":"Run the server with GMGN_CLI_PATH pointing at a stub that sleeps 3 s and prints {} (reproduced locally). Send 20 concurrent agentcity_compare_robinhood_tokens calls with 5 addresses each from one client. Actual: 300 child processes were spawned (argv log), with 32 alive simultaneously on a 4-core host, bounded only by CPU; all 20 requests completed, none refused. With the real CLI each child is a ~60-80 MB node process holding a network socket, so a few hundred concurrent requests exhaust host memory, and the GMGN key is rate-limit banned for all users. Expected: a bounded number of children and a 429/queue response for the excess.","severity":"medium","snippet":"    const { stdout } = await execFileAsync(cliPath, [...args, \"--raw\"], {","title":"No concurrency or rate limit: each unauthenticated request spawns up to 15 gmgn-cli processes and all share one API key"},{"citation":"resolved","description":"The score's rug gate (contractSafety on line 21 and the `high_rug_ratio` hard flag on line 25) depends on security.rug_ratio. gmgn-cli 1.6.6 ships two conflicting descriptions of the token security payload: skills/gmgn-token/SKILL.md lists rug_ratio and is_wash_trading as fields, while skills/gmgn-contract-dd/SKILL.md, which records live measurements dated 2026-08-28, states 'There is no address lookup for rug_ratio. It is absent from token info, token security and token pool - verified' and that the label is only present on market trenches / trending rows (and missing on most bsc/base rows even there). When the key is absent the server does not report it as missing: contractSafety becomes a constant 0.65 or 0.35, dataCompleteness is capped at 0.75, and the README's 'Reject from research queue: rug ratio > 0.3' rule is unreachable, even though the same server already fetches trenches and trending rows that carry rug_ratio and never consults them. is_wash_trading has the same silent-absence behaviour and is additionally compared with `=== true` only, so a string \"true\" is ignored. Fix: when rug_ratio is absent from security, look the address up in the trenches/trending rows already retrieved (or report `rug_ratio: unavailable` explicitly in derived), and accept \"true\"/1 for is_wash_trading. Not verified against the live API (no key); the input shape below is the one the CLI's measured notes describe.","line":21,"path":"src/scoring.js","reproduction":"Call researchScore({info:{holder_count:500,liquidity:\"60000\"},security:{is_open_source:true,is_renounced:true,top_10_holder_rate:\"0.2\",lock_summary:{is_locked:true}},pool:{liquidity:\"60000\"}}) with the security object shaped as the CLI's measured notes describe (no rug_ratio key). Actual: contractSafety 0.65, hardFlags [], dataCompleteness 0.75, and nothing in the output says the rug label was unavailable; the identical token with rug_ratio 0.9 present yields contractSafety 0.1 and hardFlags [\"high_rug_ratio\"]. Also researchScore({...,security:{...,is_wash_trading:\"true\"}}) yields activityQuality 0.7 and no wash_trading_detected flag (reproduced locally).","severity":"low","snippet":"    contractSafety: rug === undefined ? (verified ? 0.65 : 0.35) : Math.max(0, 1 - rug) * (verified ? 1 : 0.7),","title":"rug_ratio / is_wash_trading are read from the token security response where GMGN's measured data says rug_ratio is absent, so the high_rug_ratio hard flag silently never fires"},{"citation":"resolved","description":"execFile's error.message is 'Command failed: <cliPath> <args>\\n<stderr>'. The MCP SDK converts a thrown tool error into an isError result whose text is that message, so every anonymous HTTP caller receives the operator's absolute GMGN_CLI_PATH, the exact argv, and whatever gmgn-cli wrote to stderr, including Node stack traces when the CLI itself crashes and the GMGN HTTP status / error code for the shared key (e.g. INVALID_API_KEY, RATE_LIMIT_BANNED with reset time). Fix: log `detail` server-side and return a short classified message (timeout, upstream rate limit, upstream error, invalid input) to the client.","line":26,"path":"src/gmgn.js","reproduction":"Run the server with GMGN_CLI_PATH=/tmp/gmgncheck/fake-gmgn-cli.cjs where the stub exits 1 after writing '[gmgn-cli] GET /v1/token/info failed: HTTP 401 code=40001 error=INVALID_API_KEY' to stderr, then call agentcity_robinhood_trending with no arguments (reproduced locally). Actual tool result: isError true, text 'GMGN query failed: Command failed: /tmp/gmgncheck/fake-gmgn-cli.cjs market trending --chain robinhood --interval 1h --limit 30 --raw\\n[gmgn-cli] GET /v1/token/info failed: HTTP 401 code=40001 error=INVALID_API_KEY'. When the stub crashed, the client received the full Node ReferenceError stack trace with file paths. Expected: a message without the host path, argv or raw stderr.","severity":"low","snippet":"    throw new Error(`GMGN query failed: ${detail}`);","title":"Tool errors return the full child command line, absolute CLI path and stderr/stack traces to remote clients"},{"citation":"resolved","description":"agentcity_search_robinhood_token is described as the way to 'resolve a symbol to candidate contracts' before due diligence, and SKILL.md tells agents to run it first whenever a user names a token. The wrapper hard-codes --order-by weight; gmgn-cli 1.6.6 defines that option as 'sorts tokens by weighted relevance and drops honeypots' (dist/commands/market.js). A due-diligence tool therefore silently removes exactly the tokens a researcher most needs to see, and an empty result is indistinguishable from 'not indexed on Robinhood'. Fix: omit --order-by (or expose it as an opt-in parameter) and note in the response when the ordering filter was applied. Not run against the live API; the behaviour is taken from the CLI's option definition.","line":52,"path":"src/gmgn.js","reproduction":"State: a Robinhood token that GMGN labels a honeypot (is_honeypot true). Input: agentcity_search_robinhood_token with query set to that token's symbol or contract address. Actual per the CLI definition: the token is dropped from the list and the tool returns an empty candidate set with the note 'Confirm the contract address', so the agent reports the token as unknown. Expected: the token is returned (with its honeypot flag) so research can proceed to agentcity_research_robinhood_token.","severity":"low","snippet":"  return gmgn([\"market\", \"search\", \"-q\", query, \"--chain\", \"robinhood\", \"--order-by\", \"weight\"]);","title":"Search always passes --order-by weight, which gmgn-cli documents as dropping honeypot tokens, so a honeypot address resolves to no results"},{"citation":"resolved","description":"Only POST /mcp is routed. The MCP Streamable HTTP transport says a server that does not offer a standalone SSE stream MUST answer GET with 405 Method Not Allowed; the SDK transport does this itself for unsupported methods but is never reached because Express 404s first. The official TypeScript client opens the GET stream right after initialize and, on anything other than 405, reports 'Failed to open SSE stream: Not Found' through onerror, which it did twice per connection in testing. DELETE (session termination) is likewise 404. Fix: add `app.get(\"/mcp\")` and `app.delete(\"/mcp\")` handlers that return 405 with Allow: POST (or route `app.all(\"/mcp\")` through the transport).","line":38,"path":"src/index.js","reproduction":"Start the server (MCP_TRANSPORT=http) and run `curl -s -o /dev/null -w '%{http_code} %{content_type}' http://127.0.0.1:3007/mcp` and the same with -X DELETE (reproduced locally on port 3999). Actual: '404 text/html; charset=utf-8' for both. Connecting with @modelcontextprotocol/sdk's StreamableHTTPClientTransport logs 'Streamable HTTP error: Failed to open SSE stream: Not Found' twice. Expected: 405 with an Allow header and no client error.","severity":"low","snippet":"  app.post(\"/mcp\", async (req, res, next) => {","title":"GET and DELETE /mcp return Express 404 HTML instead of the 405 the Streamable HTTP spec requires, causing client-side errors on every connection"},{"citation":"resolved","description":"express.json() rejects a malformed or oversized body with an error carrying status 400/413 and expose:true. The handler ignores error.status, answers 500 'Internal MCP server error' for a client mistake, and prints the complete body-parser stack trace (including the offending body) to stderr for every such request, which an unauthenticated caller can repeat at will to fill the pm2 logs. Fix: `const status = error.status ?? error.statusCode ?? 500; if (status < 500) { res.status(status).json({ error: error.type ?? \"Bad request\" }); return; } console.error(error); res.status(500)...`.","line":47,"path":"src/index.js","reproduction":"`curl -s -w '\\n%{http_code}' -X POST -H 'content-type: application/json' -H 'accept: application/json, text/event-stream' --data '{bad' http://127.0.0.1:3007/mcp` (reproduced locally on port 3999). Actual: body {\"error\":\"Internal MCP server error\"}, status 500, and server stderr shows a 12-line SyntaxError stack with `statusCode: 400, body: '{bad', type: 'entity.parse.failed'`. Expected: status 400 and no stack trace.","severity":"low","snippet":"  app.use((error, _req, res, _next) => { console.error(error); if (!res.headersSent) res.status(500).json({ error: \"Internal MCP server error\" }); });","title":"Error handler turns malformed-JSON 400s into 500s and logs a full stack trace per malformed request"}],"hash":"976703f6fb7a8cb95699e950fbe97e9f67e1dc3eae018a9749780d46c863f388","nodeId":"e1483e2d-3032-4b81-ba10-3370a73b9b63","outcome":"completed","summary":"The review is complete. The findings file is written and validated, the repository tree is unchanged apart from it, and no test servers are left running.\n\n**What the repository is.** The task names \"contracts\", but the pinned tree contains no Solidity and no Foundry project. It is a Node.js MCP server with three modules: tool definitions and HTTP transport, a wrapper that shells out to `gmgn-cli`, and a deterministic scoring function. I audited those three files with the Execution Trace, Periphery and First Principles passes, treating the gmgn-cli wrapper as the periphery layer. To check its assumptions I installed gmgn-cli 1.6.6 and the MCP SDK outside the repository, read the CLI's option definitions and its measured field notes, ran the scoring function on concrete inputs, and ran the server against a stub CLI to exercise the HTTP paths.\n\n**Findings written to `.imd-findings.json`, most severe first.**\n\n- **Medium, scoring:** an unpopulated `top_10_holder_rate` of `\"0\"` scores the maximum holder-distribution factor. A token with no holder data scores 85 \"Deep research\"; the same token with its real 0.62 rate is rejected.\n- **Medium, scoring:** empty-string fields coerce to 0, the best possible value for concentration and rug ratio. A bundle with four empty strings scores 91 at 100% data confidence.\n- **Medium, scoring:** data confidence can never fall below 0.5 because liquidity and holder count are defaulted before the presence check. An empty bundle reports 0.5 confidence.\n- **Medium, availability:** no concurrency cap or rate limit. Twenty concurrent compare calls spawned 300 child processes locally, and every caller shares the operator's single GMGN key, which GMGN bans progressively.\n- **Low:** the rug and wash-trading gates read fields the CLI's measured notes say are absent from the token security response, so the advertised \"rug ratio > 0.3\" rejection silently never fires. Flagged as low because the CLI ships conflicting documentation and I had no key to confirm live.\n- **Low:** tool errors return the absolute CLI path, full argv, child stderr and stack traces to anonymous clients.\n- **Low:** search hard-codes an ordering option the CLI documents as dropping honeypot tokens, so a honeypot address resolves to no results.\n- **Low:** GET and DELETE on the MCP path return 404 HTML instead of 405, and the official client logs an error on every connection.\n- **Low:** malformed JSON bodies are answered with 500 and a full stack trace is logged per request.\n\n**Checked and found sound.** Every client-controlled string reaching the CLI is either enum-constrained, regex-constrained, or a number. The one free-form input, the search query, cannot inject flags because commander consumes the next token unconditionally, which I confirmed in a parser test and in the server's argv log. All CLI flags, variadic options and limits the wrapper uses exist in gmgn-cli 1.6.6 with matching bounds. The stateless transport is created per request as the SDK requires, and the wrapper's envelope unwrapping matches the CLI's output shapes.\n\n**Limits.** No GMGN API key was available, so the two findings that depend on live response shapes are stated from the CLI's own measured documentation rather than a live call. None of the findings carries a Foundry proof because there is no Solidity in scope.","treeHash":null,"usage":{"cachedInputTokens":1472806,"inputTokens":482,"model":"claude-fable-5-1","outputTokens":38861,"runtime":"claude","turns":45,"wallClockMs":717511}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"1731fbfe0c4574fb","findings":[{"citation":"resolved","description":"number() converts any string with Number(). In JavaScript Number(\"\") === 0, Number(\" \") === 0 and Number(\"\\n\") === 0, so an empty or whitespace string is accepted as the finite value 0 rather than being rejected as missing. The upstream data source (gmgn-cli 1.6.6, skills/gmgn-contract-dd/SKILL.md, 'Are the EVM security fields populated?') documents that when GMGN has no chain analysis for a token, top_10_holder_rate, buy_tax and sell_tax arrive as 'null/absent/empty string/or the string \"0\"', and that an absent field must never be read as a passing check. scoring.js does the opposite: top10 = 0 feeds line 20 as (0.7 - 0) / 0.7 = 1.0, the maximum holder-distribution factor (25 of 100 points), and the same 0 passes the x !== undefined completeness filter on line 18, so dataConfidence rises as well. rug_ratio: \"\" likewise becomes rug = 0 and contractSafety = max(0, 1 - 0) = 1.0 on line 21, and neither value can raise a hard flag because 0 > 0.5 and 0 > 0.3 are false. The riskiest state (GMGN knows nothing about the holder structure) therefore receives the best possible holder and safety sub-scores. Boundary x precision seam: the zero produced by the lenient parse sits exactly on the 'best' end of both formulas. Fix: treat empty/whitespace strings as undefined (e.g. trim and return undefined when the trimmed string is empty) and, per the upstream rule, treat the literal \"0\" for rate fields as unpopulated unless a sibling field proves the block is populated; a missing value must contribute 0 points and 0 completeness, not full marks.","line":2,"path":"src/scoring.js","reproduction":"Call researchScore({ info: { holder_count: \"\", liquidity: \"\" }, security: { top_10_holder_rate: \"\", rug_ratio: \"\" }, pool: { liquidity: \"\" } }). Expected (all four inputs absent): dataCompleteness 0, holderDistribution 0, contractSafety 0.35 (unverified source, no rug data), score = 0 + 0 + 0 + 7 + 7 = 14, disposition 'Insufficient data', dataConfidence 0. Actual (run with node against src/scoring.js): factors { dataCompleteness: 1, liquidityDepth: 0, holderDistribution: 1, contractSafety: 0.7, activityQuality: 0.7 }, researchScore 66, dataConfidence 1, disposition 'Watchlist', hardFlags []. Same result for whitespace strings { top_10_holder_rate: \" \", rug_ratio: \"\\n\" }. With the documented no-record shape { info: { symbol: \"\", address: \"\", holder_count: 0, liquidity: \"0\" }, security: { top_10_holder_rate: \"0\", rug_ratio: null, open_source: \"unknown\" }, pool: null } the output is researchScore 54, dataConfidence 0.75, disposition 'Watchlist', holderDistribution 1, for an address GMGN has no record of.","severity":"medium","snippet":"  const result = typeof value === \"number\" ? value : typeof value === \"string\" ? Number(value) : NaN;","title":"Unpopulated security fields (\"\", \"0\", whitespace) are scored as perfect values: top_10_holder_rate \"\" earns the full 25 holder-distribution points and counts as present data"},{"citation":"resolved","description":"Lines 10 and 11 coalesce a missing liquidity and a missing holder_count to 0 and then line 18 computes dataCompleteness as the count of [liquidity, holders, top10, rug] that are !== undefined. Because liquidity and holders have already been replaced by 0 they always pass the filter, so the metric's floor is 2/4 = 0.5 regardless of what GMGN returned. The README defines this factor as 'Liquidity, holders, top-10 share, and rug ratio are all present' and SKILL.md requires agents to report 'data confidence' separately as a measure of how much data backed the score. With five null responses the server still reports dataConfidence 0.5 and awards 10 of the 20 completeness points. Boundary x invariant seam: the invariant 'completeness = fraction of inputs actually observed' is broken by the fast-path default applied before the check. Fix: keep the undefined values for the completeness count (e.g. compute liquidityRaw/holdersRaw before defaulting) so that an absent input contributes 0 to completeness, and only default to 0 when feeding the depth formula.","line":10,"path":"src/scoring.js","reproduction":"Call researchScore({ info: null, security: null, pool: null }) (the shape produced when every gmgn-cli call yields data: null) or researchScore({ info: {}, security: {}, pool: {} }). Expected: dataCompleteness 0, dataConfidence 0, score 0*20 + 0 + 0 + 0.35*20 + 0.7*10 = 14, 'Insufficient data'. Actual: factors.dataCompleteness 0.5, dataConfidence 0.5, researchScore 24, 'Insufficient data'. The score is 10 points higher and the confidence figure, which SKILL.md tells the agent to present to the user, claims half the inputs were observed when none were. Note the same defaulting means GMGN's documented no-record info block (holder_count: 0, liquidity: \"0\") is counted as two present data points.","severity":"medium","snippet":"  const liquidity = number(get(pool, \"liquidity\")) ?? number(get(info, \"liquidity\")) ?? 0;\n  const holders = number(get(info, \"holder_count\")) ?? 0;","title":"dataCompleteness/dataConfidence can never drop below 0.5: liquidity and holder_count are defaulted to 0 before the presence check, so missing data is counted as present"},{"citation":"resolved","description":"Every other factor is clamped into [0, 1] with Math.max(0, ...) (lines 20 and 21), but liquidityDepth only has the upper cap. number() accepts any finite number including negatives (e.g. the string \"-1\"), and liquidity is taken from pool.liquidity first and info.liquidity second, both of which are upstream strings parsed without a sign check. A negative or garbage-negative value is multiplied by 25 and added into the score with no bound, so the documented 0..100 score range and the factor's documented 0..1 range are both violated. Fix: Math.max(0, Math.min(liquidity / 50_000, 1)), or reject negative liquidity as undefined in number()/the liquidity selection.","line":19,"path":"src/scoring.js","reproduction":"Call researchScore({ info: { holder_count: 100 }, security: { top_10_holder_rate: 0.1, rug_ratio: 0, open_source: \"yes\" }, pool: { liquidity: -1e9 } }). Expected: liquidityDepth clamped to 0, score = 20 + 0 + 21.43 + 20 + 7 = 68, 'Watchlist'. Actual: factors.liquidityDepth -20000, researchScore -499932, disposition 'Insufficient data'. With pool.liquidity: \"-1\" the score is 68 - 0.0005 -> 68, but any magnitude above 2,000 USD of negative liquidity changes the disposition.","severity":"low","snippet":"    liquidityDepth: Math.min(liquidity / 50_000, 1),","title":"liquidityDepth has no lower clamp: a negative liquidity value drives the research score arbitrarily negative"},{"citation":"resolved","description":"The factors are computed with binary floating point ((0.7 - 0.07) / 0.7 evaluates to 0.8999999999999999, (1 - 0.07) to 0.9299999999999999) and the weighted terms are summed before Math.round. For inputs whose exact weighted sum is a half-integer, the float sum lands at x.49999999999999 and Math.round goes down, while the documented formula (weights x factors, rounded) gives the higher integer. When that half-integer is 69.5 or 44.5 the disposition on line 26 flips between 'Deep research' and 'Watchlist' or between 'Watchlist' and 'Insufficient data'. Boundary x precision seam: the threshold comparison is exact but the quantity compared carries 1e-14 of error. A grid search over liquidity 0..50000 (step 100), top10 0..0.5, rug 0..0.3 found 14 input combinations with no hard flags whose disposition differs from the exact-arithmetic result. Fix: compute the score in integer basis points (e.g. multiply every factor by 10000 and round once), or round each weighted term to a fixed number of decimals before summing, and document the rounding rule.","line":24,"path":"src/scoring.js","reproduction":"Call researchScore({ info: { holder_count: 1 }, security: { top_10_holder_rate: \"0.07\", rug_ratio: \"0.07\", open_source: \"yes\" }, pool: { liquidity: \"2800\" } }). Exact terms: 20 + (2800/50000)*25 = 1.4 + ((0.7-0.07)/0.7)*25 = 22.5 + (1-0.07)*20 = 18.6 + 7 = 69.5, so Math.round gives 70 and the README rule 'Score >= 70 and no hard flags' yields 'Deep research'. Actual: the computed terms are [20, 1.4000000000000001, 22.499999999999996, 18.599999999999998, 7], the sum is 69.49999999999999, researchScore is 69 and disposition is 'Watchlist'. Other failing inputs from the search: liquidity 6800 / top10 0.07 / rug 0.17 / verified, and liquidity 8300 / top10 0.147 / rug 0.07 / verified, each exact 69.5 and reported 69.","severity":"low","snippet":"  const score = Math.round(factors.dataCompleteness * 20 + factors.liquidityDepth * 25 + factors.holderDistribution * 25 + factors.contractSafety * 20 + factors.activityQuality * 10);","title":"Floating-point accumulation makes an exact score of 69.5 round to 69 (Watchlist) instead of 70 (Deep research) at the disposition threshold"},{"citation":"resolved","description":"The screen tool validates minSmartMoneyCount only as a non-negative integer with no upper bound, then forwards it with String(minSmartMoneyCount) as --min-smart-degen-count. JavaScript stringifies any integer >= 1e21 in exponent notation (String(1e21) === \"1e+21\"), and Number.isInteger(1e21) is true so zod's .int() accepts it. gmgn-cli 1.6.6 registers min_smart_degen_count as an int filter parsed with parseInt (dist/commands/market.js, TRENCHES_FILTER_FIELDS type \"int\"), and parseInt(\"1e+21\") === 1. The request therefore runs with min_smart_degen_count = 1 instead of failing or returning nothing. Boundary x precision seam between the server's number validation and the CLI's string parsing. Fix: add .max() bounds to every integer parameter that is forwarded as a string (a realistic cap such as 10000 for a holder count), or format integers with BigInt/toFixed(0) so they never take exponent form.","line":18,"path":"src/index.js","reproduction":"Call agentcity_screen_robinhood_launchpads with { stage: \"new_creation\", profile: \"safe\", minSmartMoneyCount: 1e21 } (valid JSON: 1e21). Expected: a validation error, or a query that can match no token. Actual: zod accepts the value, the server spawns gmgn-cli with --min-smart-degen-count 1e+21, the CLI's parseInt turns it into 1, and the response is the list of tokens with at least one smart-money holder, presented as if the caller's filter had been applied. Verified: node -e 'console.log(String(1e21), parseInt(String(1e21)), Number.isInteger(1e21))' prints '1e+21 1 true'.","severity":"low","snippet":"minSmartMoneyCount: z.number().int().min(0).optional()","title":"minSmartMoneyCount >= 1e21 is accepted by the schema but reaches gmgn-cli as \"1e+21\", which its parseInt reads as 1, silently loosening the screen filter"},{"citation":"resolved","description":"The two bounds are validated independently; there is no refine ensuring min <= max. An inverted range is passed as --mc-min and --mc-max to gmgn-cli, which parses both with parseFloat and sends them upstream. The caller receives whatever GMGN does with a contradictory range (an error string or an empty list) instead of a clear local validation error, and the agent may report 'no signals' for a window that was never valid. Fix: add a .refine on the object schema requiring maxMarketCapUsd >= minMarketCapUsd when both are present.","line":26,"path":"src/index.js","reproduction":"Call agentcity_robinhood_signals with { minMarketCapUsd: 1000000, maxMarketCapUsd: 10 }. Expected: schema rejection ('maxMarketCapUsd must be >= minMarketCapUsd'). Actual: the input passes zod, gmgn-cli is invoked with --mc-min 1000000 --mc-max 10, and the result depends entirely on the upstream API (not verified here because it needs a GMGN key).","severity":"info","snippet":"minMarketCapUsd: z.number().min(0).optional(), maxMarketCapUsd: z.number().min(0).optional()","title":"signals tool accepts minMarketCapUsd greater than maxMarketCapUsd and forwards the empty range to GMGN"},{"citation":"resolved","description":"The tool description presents limit and side as independent query parameters. In gmgn-cli 1.6.6 the --side option on track smartmoney and track kol is a client-side filter: the CLI fetches `limit` records and then removes those whose side does not match (dist/commands/track.js: data.list = data.list.filter(item => item.side === opts.side)). The number of returned trades is therefore limit minus the trades on the other side, and can be zero even when matching trades exist beyond the first page. Fix: document that limit is the page size before the side filter, or over-fetch (e.g. min(200, limit * 2)) and truncate to limit after filtering.","line":25,"path":"src/index.js","reproduction":"Call agentcity_robinhood_smart_money with { source: \"smartmoney\", side: \"sell\", limit: 50 } when the latest 50 smart-money trades contain 45 buys and 5 sells. Expected: 50 sell trades (or a note that only a page was scanned). Actual: 5 trades are returned under 'observed' with no indication that 45 were discarded; with 50 consecutive buys the list is empty.","severity":"info","snippet":"side: z.enum([\"buy\", \"sell\"]).optional(), limit: z.number().int().min(1).max(200).default(50)","title":"smart-money limit is applied before the side filter, so limit N with side set returns fewer than N trades"}],"hash":"a80f6b6ee4bb50251c78062d10206460c058ffc45b2936c4c5bfb8d989095daf","nodeId":"66545283-0e03-483b-ac0a-f67f681385f1","outcome":"completed","summary":"The review is complete and the findings file is validated: all seven snippets match the tree at the stated lines, and the JSON parses.\n\n**Scope note.** The repository contains no Solidity. It is a JavaScript MCP server with three source files, so I applied the Math Precision, Boundary and Numerical Gap guides to the numeric code there: the research-score formula in `src/scoring.js` and the numeric tool inputs in `src/index.js`. No Foundry proofs apply, and no files other than the findings file were written. To verify what the server assumes about its data source, I downloaded gmgn-cli 1.6.6 to a scratch directory outside the repository and read its option parsing, error handling and field documentation.\n\n**Findings written to `.imd-findings.json`** (7 total, ranked):\n\n- **Medium, scoring.js line 2.** Empty, whitespace or `\"0\"` strings for `top_10_holder_rate` and `rug_ratio` parse to 0 and score as perfect values. GMGN's own documentation says these are the unpopulated states. Input with all four fields set to `\"\"` scores 66 with confidence 1 and \"Watchlist\" instead of 14, confidence 0, \"Insufficient data\".\n- **Medium, scoring.js line 10.** Liquidity and holder count default to 0 before the presence check, so data confidence never drops below 0.5. An all-null bundle reports confidence 0.5 and 10 completeness points.\n- **Low, scoring.js line 19.** Liquidity depth has no lower clamp. A negative liquidity of 1e9 yields a score of -499932.\n- **Low, scoring.js line 24.** Float accumulation turns an exact 69.5 into 69.49999999999999, so inputs that should read \"Deep research\" read \"Watchlist\". Concrete case: liquidity 2800, top10 0.07, rug 0.07, verified source.\n- **Low, index.js line 18.** `minSmartMoneyCount` has no upper bound. A value of 1e21 stringifies as `1e+21`, which the CLI's parseInt reads as 1, silently loosening the filter.\n- **Info, index.js line 26.** Market-cap min above max is accepted and forwarded.\n- **Info, index.js line 25.** The smart-money `side` filter is applied by the CLI after the page limit, so fewer trades than requested come back.\n\n**Checked and found sound.** Rate fields really are 0–1 fractions as the code assumes, `is_wash_trading` is a true boolean, `open_source` uses the `\"yes\"` string the code tests, all `limit` ranges match the CLI's documented caps, and API errors make the CLI exit non-zero so error payloads are never scored.\n\n**Not verified.** Live upstream behaviour for the two info items needs a GMGN key, so those describe what the server does rather than what the API returns.","treeHash":null,"usage":{"cachedInputTokens":1214810,"inputTokens":482,"model":"claude-fable-5-1","outputTokens":27183,"runtime":"claude","turns":31,"wallClockMs":365264}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"28e346843ec15530","findings":[{"citation":"resolved","description":"number() converts every string with Number(), and Number(\"\") === 0, Number(\" \") === 0, Number(\"0\") === 0, so an unset field becomes the finite measurement 0. Downstream, 0 is the best possible value for both risk fields: line 20 gives holderDistribution = (0.7 - 0) / 0.7 = 1 (25 of 100 points), line 21 gives contractSafety = 1 - 0 = 1 (20 points), line 18 counts both as present data, and neither hard flag on line 25 can fire. gmgn-cli 1.6.6 (the only data source, skills/gmgn-contract-dd/SKILL.md Step 1A and the holder step) documents that an EVM security block GMGN has not populated returns top_10_holder_rate, buy_tax and sell_tax as \"0\" or \"\" and states that 0% top-10 concentration does not exist and must never be scored as a good sign. New launchpad tokens, which are exactly what the list/screen tools feed into agentcity_research_robinhood_token, are the common case for an unpopulated block. The scorer therefore ranks a token with no holder analysis above one with a measured 31% top-10 share, and a token whose true concentration is above 50% is promoted instead of rejected. Merged from four specialist reports (economics, math, two flow). This repository contains no Solidity, so no Foundry proof can be attached; the node commands below are the executable reproduction. Fix: in number(), return undefined when a string is empty after trim; treat top_10_holder_rate <= 0 (and a rug_ratio of \"\" / 0 from an otherwise empty block) as unavailable, giving holderDistribution 0 and excluding the field from dataCompleteness, per the vendor's own rule.","line":2,"path":"src/scoring.js","reproduction":"Run from the repository root.\n(1) node -e 'import(\"./src/scoring.js\").then(({researchScore})=>console.log(JSON.stringify(researchScore({info:{holder_count:400},security:{open_source:\"unknown\",top_10_holder_rate:\"0\",buy_tax:\"0\",sell_tax:\"0\"},pool:{liquidity:\"52000\"}}))))'\nActual: researchScore 79, dataConfidence 0.75, disposition \"Deep research\", factors.holderDistribution 1. Expected: holderDistribution 0, dataCompleteness 0.5, score at most 54, Watchlist or Insufficient data.\n(2) Same token with the populated value \"0.62\" instead of \"0\": researchScore 63 and disposition \"Reject from research queue\" (high_holder_concentration). The unpopulated block scores higher than a measured 62% concentration.\n(3) node -e 'import(\"./src/scoring.js\").then(({researchScore})=>console.log(JSON.stringify(researchScore({info:{holder_count:\"\",liquidity:\"\"},security:{top_10_holder_rate:\"\",rug_ratio:\"\"},pool:{liquidity:\"100000\"}}))))'\nActual: dataCompleteness 1, dataConfidence 1, holderDistribution 1, contractSafety 0.7, researchScore 91, \"Deep research\". Expected: top10 and rug undefined, holderDistribution 0, contractSafety 0.35, dataConfidence 0.5 at most. Whitespace strings (\" \", \"\\n\") give the identical result.\n(4) End to end: start the server with GMGN_API_KEY=x, GMGN_CLI_PATH pointing at a stub that answers \"token security\" with {\"code\":0,\"data\":{\"open_source\":\"unknown\",\"top_10_holder_rate\":\"0\",\"buy_tax\":\"0\",\"sell_tax\":\"0\"}} and \"token info\" with GMGN's empty record {\"symbol\":\"\",\"address\":\"\",\"holder_count\":0,\"liquidity\":\"0\"}; POST tools/call agentcity_research_robinhood_token for any 0x address. Observed: derived.holderDistribution 1, dataConfidence 0.75, researchScore 54, disposition \"Watchlist\" for an address GMGN has no record of.","severity":"high","snippet":"  const result = typeof value === \"number\" ? value : typeof value === \"string\" ? Number(value) : NaN;","title":"Unpopulated numeric security fields (\"0\", \"\", whitespace) are scored as perfect values: top_10_holder_rate \"0\" or \"\" earns the full 25 holder-distribution points, rug_ratio \"\" earns full contract safe"},{"citation":"resolved","description":"Lines 10 and 11 coalesce a missing liquidity and holder_count to 0 with `?? 0`; line 18 then counts [liquidity, holders, top10, rug] entries that are !== undefined. The first two are always defined, so two of the four completeness slots are constant, 10 of the 20 completeness points are awarded unconditionally, and the floor of dataConfidence is 0.5 for a packet that contains nothing. README defines the factor as 'Liquidity, holders, top-10 share, and rug ratio are all present' and SKILL.md tells agents to surface dataConfidence to the user as a separate trust signal, so an address GMGN has never seen is reported as half-observed. A measured $0 liquidity is also indistinguishable from an unresolved pool. Merged from four specialist reports (economics, permissions, math, flow). Fix: keep the raw number() results (liquidityRaw, holdersRaw) for the completeness array and apply the 0 default only when feeding liquidityDepth.","line":18,"path":"src/scoring.js","reproduction":"Run from the repository root.\n(1) node -e 'import(\"./src/scoring.js\").then(({researchScore})=>console.log(JSON.stringify(researchScore({info:{},security:{},pool:{}}))))' and node -e 'import(\"./src/scoring.js\").then(({researchScore})=>console.log(JSON.stringify(researchScore({info:null,security:null,pool:null}))))'\nActual for both: factors.dataCompleteness 0.5, dataConfidence 0.5, researchScore 24. Expected: dataCompleteness 0, dataConfidence 0, score 14.\n(2) node -e 'import(\"./src/scoring.js\").then(({researchScore})=>console.log(JSON.stringify(researchScore({info:{holder_count:900},security:{},pool:{liquidity:80000}}))))'\nActual: researchScore 49, dataConfidence 0.5, disposition \"Watchlist\" with no security field present at all. Expected: completeness 0.5 at most counting liquidity and holders only, and the derived block should not claim half the risk inputs were observed.","severity":"medium","snippet":"    dataCompleteness: [liquidity, holders, top10, rug].filter((x) => x !== undefined).length / 4,","title":"dataCompleteness / dataConfidence can never drop below 0.5: liquidity and holder_count are defaulted to 0 before the presence check, so missing data is counted as present"},{"citation":"resolved","description":"POST /mcp is the only entry point and carries no authentication, quota or concurrency control. Every tool call ends in execFileAsync in src/gmgn.js line 18, which spawns a node child holding a 25 s timeout and a 2 MB buffer and spends the operator's API quota. agentcity_compare_robinhood_tokens (src/index.js line 22) runs Promise.all inside Promise.all: 3 CLI calls per address for up to 5 addresses (duplicates accepted) = 15 concurrent children per request; agentcity_research_robinhood_token spawns 5. Nothing bounds how many requests run at once, children are not killed when the HTTP client disconnects (res.on('close') only closes the transport), and Promise.all leaves sibling children running after the first failure. gmgn-cli's bundled docs (skills/gmgn-market/SKILL.md) describe GMGN's plan-based leaky bucket of 5, 20 or 50 requests per second and state that repeated requests during a 429 cooldown extend the ban by 5 s up to 5 minutes, so two concurrent compare calls exceed a Plus-plan bucket in well under a second and sustained anonymous traffic keeps the shared key banned for every user of the hosted endpoint. The README positions the endpoint as open to any MCP client, so public access is design; the absence of any bound on fan-out is the defect. Merged from three specialist reports (economics, permissions, flow). Fix: a small semaphore around execFileAsync (for example 8 to 16 in flight), a per-source rate limit on /mcp, de-duplication of addresses in compare, passing an AbortSignal to execFile tied to res 'close', and ideally a bearer token for the hosted deployment.","line":38,"path":"src/index.js","reproduction":"Start the server with GMGN_API_KEY=x, MCP_TRANSPORT=http, HOST=127.0.0.1, PORT=3920 and GMGN_CLI_PATH pointing at a stub CLI that appends its argv to a log, sleeps 4 s, then prints {\"code\":0,\"data\":{\"list\":[]}}. From one shell send 10 concurrent POST /mcp requests (headers Content-Type: application/json and Accept: application/json, text/event-stream) with body {\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"agentcity_compare_robinhood_tokens\",\"arguments\":{\"addresses\":[\"0x1111111111111111111111111111111111111111\",\"0x2222222222222222222222222222222222222222\",\"0x3333333333333333333333333333333333333333\",\"0x4444444444444444444444444444444444444444\",\"0x5555555555555555555555555555555555555555\"]}}}. Observed: pgrep -fc of the stub reported 150 live child processes 2.5 s after sending and the argv log held 150 lines (10 x 15); a single compare call spawned 15 children whose first-to-last start spread was 77 ms. No request was refused or queued. Expected: a bounded number of concurrent children and a 429 or queue for the excess. Against the real CLI the same traffic issues 150 upstream requests on the operator's key in under a second, above every documented bucket size.","severity":"medium","snippet":"  app.post(\"/mcp\", async (req, res, next) => {","title":"Unauthenticated POST /mcp fans out up to 15 gmgn-cli child processes per request with no concurrency, rate or per-client bound, all billed to the operator's single GMGN_API_KEY"},{"citation":"resolved","description":"contractSafety (line 21) and the high_rug_ratio hard flag (line 25) depend only on security.rug_ratio. gmgn-cli 1.6.6 ships two conflicting descriptions: skills/gmgn-token/SKILL.md lists rug_ratio as a token field, while skills/gmgn-contract-dd/SKILL.md, which records live measurements dated 2026-08-28, states 'There is no address lookup for rug_ratio. It is absent from token info, token security and token pool - verified' and that the label is only present on market trenches / market trending rows. When the key is absent the scorer gives no signal: contractSafety becomes a constant 0.65 or 0.35, dataCompleteness is capped at 0.75, nothing in the derived block says the label was unavailable, and the README's 'Reject from research queue: rug ratio > 0.3' rule plus its example dataConfidence 1 are unreachable, even though the same server already fetches trenches and trending rows that carry rug_ratio and never consults them. is_wash_trading has the same silent-absence behaviour and is additionally compared with === true only (line 14), so the string \"true\" is ignored. The live GMGN API was not queried (no key); the code-level behaviour below is reproduced and the field absence is taken from the vendor's measured documentation. Merged from two specialist reports (economics, flow). Fix: when rug_ratio is missing from security, resolve it from the trenches/trending row for the address (or the listing already fetched by the calling tool), report an explicit 'rug_ratio unavailable' state in derived, and accept \"true\"/1 for is_wash_trading.","line":21,"path":"src/scoring.js","reproduction":"Run from the repository root.\n(1) node -e 'import(\"./src/scoring.js\").then(({researchScore})=>console.log(JSON.stringify(researchScore({info:{holder_count:400},security:{open_source:\"yes\",top_10_holder_rate:0.31},pool:{liquidity:52000}}))))'\nActual: contractSafety 0.65, hardFlags [], dataConfidence 0.75, researchScore 74, \"Deep research\", and no field in the output reports that rug_ratio was unavailable. With rug_ratio: 0.9 added, the same input yields contractSafety 0.1 and hardFlags [\"high_rug_ratio\"], so a token that GMGN labels rug_ratio 1.0 in its listing passes research unflagged whenever the security block omits the key.\n(2) node -e 'import(\"./src/scoring.js\").then(({researchScore})=>console.log(JSON.stringify(researchScore({info:{holder_count:500,liquidity:\"60000\"},security:{is_open_source:true,top_10_holder_rate:\"0.2\",is_wash_trading:\"true\"},pool:{liquidity:\"60000\"}}))))'\nActual: activityQuality 0.7, hardFlags [], disposition \"Deep research\". Expected: wash_trading_detected flag.","severity":"medium","snippet":"    contractSafety: rug === undefined ? (verified ? 0.65 : 0.35) : Math.max(0, 1 - rug) * (verified ? 1 : 0.7),","title":"rug_ratio and is_wash_trading are read only from the token-security response, where gmgn-cli's measured notes say rug_ratio is absent; the absence is silent, so the README's 'rug ratio > 0.3' reject r"},{"citation":"resolved","description":"holderDistribution and contractSafety are clamped into [0, 1] with Math.max(0, ...), but liquidityDepth has only the upper cap. number() accepts any finite number including negatives, and liquidity is taken verbatim from upstream strings in pool.liquidity or info.liquidity. A negative value is multiplied by 25 and added to the score unbounded, violating the documented 0..100 score range and 0..1 factor range. Fix: Math.max(0, Math.min(liquidity / 50_000, 1)), or reject negative liquidity as undefined.","line":19,"path":"src/scoring.js","reproduction":"Run from the repository root: node -e 'import(\"./src/scoring.js\").then(({researchScore})=>console.log(JSON.stringify(researchScore({info:{holder_count:100},security:{top_10_holder_rate:0.1,rug_ratio:0,open_source:\"yes\"},pool:{liquidity:-1e9}}))))'\nActual: factors.liquidityDepth -20000, researchScore -499932, disposition \"Insufficient data\". Expected: liquidityDepth 0, researchScore 68, \"Watchlist\".","severity":"low","snippet":"    liquidityDepth: Math.min(liquidity / 50_000, 1),","title":"liquidityDepth has no lower clamp, so a negative liquidity value drives the research score arbitrarily negative"},{"citation":"resolved","description":"The factors are computed in binary floating point ((0.7 - 0.07) / 0.7 evaluates to 0.8999999999999999 and (1 - 0.07) to 0.9299999999999999) and the weighted terms are summed before Math.round. When the exact weighted sum is a half-integer the float sum lands just below it and rounds down, while the README formula (weights x factors, rounded) gives the higher integer. At 69.5 and 44.5 the disposition on line 26 flips bands. Fix: compute in integer basis points or round each weighted term to a fixed number of decimals before summing, and document the rounding rule.","line":24,"path":"src/scoring.js","reproduction":"Run from the repository root: node -e 'import(\"./src/scoring.js\").then(({researchScore})=>console.log(JSON.stringify(researchScore({info:{holder_count:1},security:{top_10_holder_rate:\"0.07\",rug_ratio:\"0.07\",open_source:\"yes\"},pool:{liquidity:\"2800\"}}))))'\nExact terms: 20 + 1.4 + 22.5 + 18.6 + 7 = 69.5, so the README rule gives 70 and \"Deep research\". Actual: computed terms [20, 1.4000000000000001, 22.499999999999996, 18.599999999999998, 7] sum to 69.49999999999999, researchScore 69, disposition \"Watchlist\". Same with liquidity \"6800\", top10 \"0.07\", rug \"0.17\": 69 instead of 70.","severity":"low","snippet":"  const score = Math.round(factors.dataCompleteness * 20 + factors.liquidityDepth * 25 + factors.holderDistribution * 25 + factors.contractSafety * 20 + factors.activityQuality * 10);","title":"Floating-point accumulation makes an exact weighted score of 69.5 round to 69 (Watchlist) instead of 70 (Deep research) at the disposition threshold"},{"citation":"resolved","description":"execFile's error.message is 'Command failed: <cliPath> <args>\\n<stderr>'. gmgn() rethrows it unchanged and the MCP SDK returns the message as the isError tool result, so any anonymous HTTP caller learns the operator's GMGN_CLI_PATH, the exact argv, and whatever the CLI wrote to stderr (upstream HTTP status and API error codes for the shared key, or a Node stack trace when the CLI crashes). Because the child inherits the whole server environment (line 19, env: { ...process.env }), if the operator sets GMGN_DEBUG for troubleshooting, gmgn-cli 1.6.6 (dist/client/OpenApiClient.js lines 249-297) writes the full upstream curl line, response headers and body to stderr and all of it is relayed; the X-APIKEY header is redacted by the CLI, so the key itself does not leak. Merged from two specialist reports (permissions, flow). Fix: log the detail server-side and return a short classified message (timeout, upstream rate limit, upstream error) to the client; pass the child a minimal explicit environment.","line":26,"path":"src/gmgn.js","reproduction":"Start the server with GMGN_API_KEY=x and GMGN_CLI_PATH=/tmp/imdrepro/stub-cli.cjs where the stub writes '[gmgn-cli] HTTP 401 Unauthorized: secret-stderr-content' to stderr and exits 1. POST tools/call agentcity_robinhood_trending with {} to /mcp. Observed tool result: isError true, text 'GMGN query failed: Command failed: /tmp/imdrepro/stub-cli.cjs market trending --chain robinhood --interval 1h --limit 30 --raw\\n[gmgn-cli] HTTP 401 Unauthorized: secret-stderr-content'. Expected: a message without the host path, argv or raw stderr.","severity":"low","snippet":"    throw new Error(`GMGN query failed: ${detail}`);","title":"Tool errors return the absolute CLI path, full child argv and raw gmgn-cli stderr (and with GMGN_DEBUG the upstream request and response) to unauthenticated remote callers"},{"citation":"resolved","description":"The MCP Streamable HTTP specification requires servers to validate Origin to prevent DNS-rebinding attacks, and the vendored SDK (1.32.1) implements this through enableDnsRebindingProtection with allowedHosts and allowedOrigins, but the transport is constructed without them and no middleware checks Host or Origin. The README's documented default is HOST=127.0.0.1 for a local agent; a malicious page visited by the operator can rebind its hostname to 127.0.0.1 and POST to the port, and since the request is then same-origin for the browser CORS does not apply. Impact is limited to read-only data and quota on the operator's key, hence low. Fix: pass enableDnsRebindingProtection: true with allowedHosts of ['127.0.0.1:' + port, 'localhost:' + port] plus the public hostname, and allowedOrigins, or add an explicit Host/Origin allowlist middleware in front of /mcp.","line":41,"path":"src/index.js","reproduction":"Start the server with MCP_TRANSPORT=http HOST=127.0.0.1 PORT=3917 GMGN_API_KEY=x and a stub CLI. Send: curl -X POST http://127.0.0.1:3917/mcp -H 'Host: attacker.example' -H 'Origin: http://attacker.example' -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' -d '{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"agentcity_robinhood_trending\",\"arguments\":{}}}'. Observed: HTTP 200 with an SSE 'event: message' body containing the tool result, identical to a request with a legitimate Host. Expected: HTTP 403.","severity":"low","snippet":"      const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });","title":"HTTP transport accepts any Host and Origin header, so a loopback-bound server is reachable by DNS rebinding from a web page"},{"citation":"resolved","description":"README's variable table states HOST defaults to 127.0.0.1 and the quick start says 'cp .env.example .env' then 'npm start # HTTP on http://127.0.0.1:3007/mcp'. The shipped .env.example sets HOST=0.0.0.0, and ecosystem.config.cjs lines 14 and 20 also set HOST '0.0.0.0'. An operator following the documented steps exposes the unauthenticated /mcp endpoint on every interface, and any TLS, authentication or rate limiting applied at the README's recommended reverse proxy is bypassed by connecting to the port directly. Fix: default .env.example and the pm2 config to 127.0.0.1 and let the proxy connect over loopback; document 0.0.0.0 only for containers whose port is not published.","line":8,"path":".env.example","reproduction":"Fresh clone, follow README self-host: cp .env.example .env; set GMGN_API_KEY; npm start. Observed: node --env-file=.env prints HOST 0.0.0.0 and the startup line reads 'listening on http://0.0.0.0:3007/mcp'; a POST /mcp initialize request from another host on the same network returns 200 with no credentials. Expected per README: listening on http://127.0.0.1:3007/mcp.","severity":"low","snippet":"HOST=0.0.0.0","title":"Shipped .env.example and pm2 config bind the unauthenticated server to 0.0.0.0, contradicting the README default of 127.0.0.1 and bypassing the recommended reverse proxy"},{"citation":"resolved","description":"The five upstream reads are joined with Promise.all, so if any one rejects (pool_info absent for a bonding-curve token with no DEX pool yet, a single 429 among five parallel requests, a non-JSON reply) the whole tool call returns an error, the successful responses are thrown away, and the quota for all five requests is spent for nothing, even though researchScore is written to tolerate missing fields and has an 'Insufficient data' disposition. The same pattern in agentcity_compare_robinhood_tokens (src/index.js line 22) makes one bad address fail the comparison of all five. Fix: use Promise.allSettled, record per-source errors in observed, and score with what was returned.","line":57,"path":"src/gmgn.js","reproduction":"Start the server with a stub CLI where 'token pool' exits 1 with '[gmgn-cli] GET /v1/token/pool_info failed: HTTP 404' on stderr while info/security/holders/traders succeed. POST tools/call agentcity_research_robinhood_token with any valid 0x address. Observed: 5 children spawned, result isError true with text 'GMGN query failed: Command failed: ... token pool --chain robinhood --address 0x1111... --raw\\n[gmgn-cli] GET /v1/token/pool_info failed: HTTP 404' and no derived block. Expected: a packet with pool null, liquidity marked unknown, reduced dataConfidence, and the error attached per source.","severity":"low","snippet":"  const [info, security, pool, holders, traders] = await Promise.all([","title":"tokenBundle and compare are all-or-nothing: one failed sub-call (e.g. token pool 404 on a not-yet-graduated launch) discards the other successful responses and returns no packet"},{"citation":"resolved","description":"The tool advertises limit (1-200) as the number of trades and side as an independent filter. In gmgn-cli 1.6.6 --side on track smartmoney and track kol is a client-side filter: the CLI fetches limit records then runs data.list = data.list.filter((item) => item.side === opts.side) (dist/commands/track.js lines 95-96 and 112-113). The server passes the user's limit straight through, so limit 50 with side sell returns only the sells among the 50 most recent trades, an empty list during a buy-heavy window, with no indication that sells exist beyond the page. The daily brief's smart-money section is built the same way. Merged from two specialist reports (economics, math). Fix: request the CLI maximum (200) when side is set and truncate to limit after filtering, and say in the description that side narrows one page.","line":38,"path":"src/gmgn.js","reproduction":"Stub 'track smartmoney' to return 50 records with side 'buy' and apply the CLI's own filter when --side is passed. POST tools/call agentcity_robinhood_smart_money with {\"limit\":50,\"side\":\"sell\"}. Observed: the server invoked the CLI with --limit 50 --side sell and returned {\"observed\":{\"list\":[]}}. Expected: 50 sell trades, or an explicit note that only the last 50 trades were scanned.","severity":"low","snippet":"  const args = [\"track\", kind, \"--chain\", \"robinhood\", \"--limit\", String(limit)];\n  if (side !== undefined) args.push(\"--side\", side);","title":"smart_money side filter is applied by gmgn-cli after limit, so limit N with side set returns fewer than N trades or none"},{"citation":"resolved","description":"SKILL.md distinguishes 'Insufficient data' from a weak candidate and tells agents to report dataConfidence separately for that reason, yet the disposition rule ignores dataCompleteness: any score below 45 with no hard flags is called 'Insufficient data' even when all four inputs are present. A thin, concentrated token is reported as something to re-query rather than a low-priority candidate, inverting the next action the SKILL.md workflow prescribes. Fix: branch on dataCompleteness before the score bands and add a low-score disposition for complete data.","line":26,"path":"src/scoring.js","reproduction":"Run from the repository root: node -e 'import(\"./src/scoring.js\").then(({researchScore})=>console.log(JSON.stringify(researchScore({info:{holder_count:50},security:{top_10_holder_rate:0.5,rug_ratio:0.3},pool:{liquidity:1000}}))))'\nActual: researchScore 44, dataConfidence 1, hardFlags [], disposition \"Insufficient data\". Expected: a low-priority disposition, with \"Insufficient data\" reserved for low dataCompleteness.","severity":"low","snippet":"  const disposition = hardFlags.length ? \"Reject from research queue\" : score >= 70 ? \"Deep research\" : score >= 45 ? \"Watchlist\" : \"Insufficient data\";","title":"'Insufficient data' disposition is assigned by score alone, so a fully populated low-quality packet with dataConfidence 1 is labelled as a data problem"},{"citation":"resolved","description":"The screen tool validates minSmartMoneyCount as a non-negative integer with no upper bound and forwards String(minSmartMoneyCount). JavaScript stringifies integers >= 1e21 in exponent form and Number.isInteger(1e21) is true, so zod accepts it. gmgn-cli 1.6.6 registers min_smart_degen_count as an int filter parsed with parseInt (dist/commands/market.js lines 109 and 390), and parseInt(\"1e+21\") === 1, so the query runs with min_smart_degen_count 1 and the result is presented as if the caller's filter applied. Fix: add .max() bounds to integer parameters forwarded as strings, or format them with BigInt/toFixed(0).","line":18,"path":"src/index.js","reproduction":"POST tools/call agentcity_screen_robinhood_launchpads with {\"stage\":\"new_creation\",\"profile\":\"safe\",\"minSmartMoneyCount\":1e21} against the server with an argv-logging stub CLI. Observed argv: [\"market\",\"trenches\",\"--chain\",\"robinhood\",\"--type\",\"new_creation\",\"--filter-preset\",\"safe\",\"--limit\",\"40\",\"--min-smart-degen-count\",\"1e+21\",\"--raw\"] and HTTP 200. node -e 'console.log(String(1e21), parseInt(String(1e21)), Number.isInteger(1e21))' prints '1e+21 1 true'. Expected: a validation error or a query that matches nothing.","severity":"low","snippet":"if (minSmartMoneyCount !== undefined) args.push(\"--min-smart-degen-count\", String(minSmartMoneyCount));","title":"minSmartMoneyCount >= 1e21 passes the schema but reaches gmgn-cli as \"1e+21\", which its parseInt reads as 1, silently loosening the screen filter"},{"citation":"resolved","description":"agentcity_search_robinhood_token is the step SKILL.md tells agents to run first whenever a user names a token. The wrapper hard-codes --order-by weight; gmgn-cli 1.6.6 defines that option as 'sorts tokens by weighted relevance and drops honeypots' (dist/commands/market.js line 294) and forwards order_by=weight upstream. A due-diligence tool therefore silently removes exactly the tokens a researcher most needs to see, and an empty result is indistinguishable from 'not indexed on Robinhood'. The live API was not queried; the behaviour is taken from the CLI's option definition. Fix: omit --order-by or expose it as an opt-in parameter, and note in the response when the honeypot-dropping ordering was applied.","line":52,"path":"src/gmgn.js","reproduction":"State: a Robinhood token GMGN labels is_honeypot true. Input: agentcity_search_robinhood_token with query set to its symbol or address. The server invokes 'market search -q <query> --chain robinhood --order-by weight --raw' (observed argv with the stub CLI). Per the CLI definition the token is dropped and the tool returns an empty candidate list with the note 'Confirm the contract address', so the agent reports the token as unknown. Expected: the token returned with its honeypot flag so research can proceed.","severity":"low","snippet":"  return gmgn([\"market\", \"search\", \"-q\", query, \"--chain\", \"robinhood\", \"--order-by\", \"weight\"]);","title":"Search always passes --order-by weight, which gmgn-cli defines as dropping honeypot tokens, so a honeypot address resolves to no candidates in a due-diligence tool"},{"citation":"resolved","description":"Only POST /mcp is routed. The Streamable HTTP transport requires a server that offers no standalone SSE stream to answer GET with 405 Method Not Allowed; the vendored SDK transport does this itself (webStandardStreamableHttp.js line 453) but is never reached because Express 404s first. Clients that open the GET stream after initialize, including the official TypeScript client, treat anything other than 405 as an error. DELETE (session termination) is likewise 404. Fix: route app.all(\"/mcp\") through transport.handleRequest, or add GET and DELETE handlers that return 405 with Allow: POST.","line":38,"path":"src/index.js","reproduction":"Start the server (MCP_TRANSPORT=http, PORT=3917) and run: curl -s -o /dev/null -w '%{http_code} %{content_type}' http://127.0.0.1:3917/mcp and the same with -X DELETE. Observed: '404 text/html; charset=utf-8' for both. Expected: 405 with an Allow header.","severity":"low","snippet":"  app.post(\"/mcp\", async (req, res, next) => {","title":"GET and DELETE /mcp return an Express 404 HTML page instead of the 405 the Streamable HTTP transport requires, causing client-side errors on every connection"},{"citation":"resolved","description":"express.json() rejects a malformed or oversized body with an error carrying status 400 or 413 and expose true. The handler ignores error.status, answers 500 'Internal MCP server error' for a client mistake, and logs the complete body-parser stack trace including the body to stderr, which an unauthenticated caller can repeat at will to fill the pm2 logs. Fix: const status = error.status ?? error.statusCode ?? 500; answer status < 500 with that status and a short message, and log only for 5xx.","line":47,"path":"src/index.js","reproduction":"curl -s -w '\\n%{http_code}' -X POST -H 'content-type: application/json' -H 'accept: application/json, text/event-stream' --data '{bad' http://127.0.0.1:3917/mcp. Observed: body {\"error\":\"Internal MCP server error\"}, status 500, and server stderr shows a SyntaxError stack with statusCode: 400, body: '{bad', type: 'entity.parse.failed'. Expected: status 400 and no stack trace.","severity":"low","snippet":"  app.use((error, _req, res, _next) => { console.error(error); if (!res.headersSent) res.status(500).json({ error: \"Internal MCP server error\" }); });","title":"Error handler turns malformed-JSON 400s into 500s and prints a full stack trace with the offending body for every malformed request"},{"citation":"resolved","description":"README lists Activity quality with weight 10 and the bands as fractions of 100, and its example block shows dataConfidence 1. The factor is 0 or 0.7, never 1, so at most 7 points are available; the best possible score is 97 and, with rug_ratio missing from the security response, 85. A documentation/calibration mismatch rather than an exploit. Fix: document the ceiling (weight 7) or make activityQuality 1 when wash trading is not flagged.","line":22,"path":"src/scoring.js","reproduction":"Run from the repository root: node -e 'import(\"./src/scoring.js\").then(({researchScore})=>console.log(JSON.stringify(researchScore({info:{holder_count:1},security:{top_10_holder_rate:0,rug_ratio:0,is_open_source:true},pool:{liquidity:1e9}}))))'\nActual: researchScore 97 with every factor at its maximum and activityQuality 0.7. Expected per README weights: 100.","severity":"info","snippet":"    activityQuality: wash ? 0 : 0.7","title":"activityQuality caps at 0.7, so the documented weight of 10 and a score of 100 are unreachable (maximum 97, or 85 when rug_ratio is absent)"},{"citation":"resolved","description":"The two bounds are validated independently with no refine ensuring min <= max. An inverted range is forwarded as --mc-min and --mc-max, and the caller receives whatever GMGN does with a contradictory range instead of a clear validation error, so an agent may report 'no signals' for a window that was never valid. Fix: add a .refine on the object schema requiring maxMarketCapUsd >= minMarketCapUsd when both are present.","line":26,"path":"src/index.js","reproduction":"POST tools/call agentcity_robinhood_signals with {\"minMarketCapUsd\":1000000,\"maxMarketCapUsd\":10} against the server with an argv-logging stub. Observed: HTTP 200 and argv [\"market\",\"signal\",\"--chain\",\"robinhood\",\"--mc-min\",\"1000000\",\"--mc-max\",\"10\",\"--raw\"]. Expected: schema rejection. The upstream response was not verified (no GMGN key).","severity":"info","snippet":"minMarketCapUsd: z.number().min(0).optional(), maxMarketCapUsd: z.number().min(0).optional() }","title":"signals tool accepts minMarketCapUsd greater than maxMarketCapUsd and forwards the empty range to GMGN instead of rejecting it locally"},{"citation":"resolved","description":"gmgn-cli 1.6.6 (dist/config.js line 28) runs loadDotenv({ path: ~/.config/gmgn/.env, override: true }) before every command, so the credentials the child actually uses are whatever that global file holds, not necessarily the GMGN_API_KEY the server validated. The README's promise that the service never needs GMGN_PRIVATE_KEY is therefore not enforceable from this repository: if the operator also uses gmgn-cli for trading on the same account, every child spawned by a remote caller runs with the private key loaded. No tool here invokes a signed command, so there is no exploit path today; key rotation in the server's .env also does nothing while the global file exists. Fix: spawn the child with a minimal explicit env (PATH, GMGN_API_KEY, proxy vars) instead of { ...process.env }, and document that the service account must not have ~/.config/gmgn/.env.","line":13,"path":"src/gmgn.js","reproduction":"On the server host create ~/.config/gmgn/.env with GMGN_API_KEY=other_key. Start the server with GMGN_API_KEY=server_key and call any tool. Per dist/config.js lines 28-29 and getConfig(), the child's X-APIKEY carries other_key and privateKeyPem is populated if the file holds GMGN_PRIVATE_KEY; the server's check at line 13 only confirmed server_key was non-empty. Code-level trace only; no live GMGN request was made.","severity":"info","snippet":"  if (!process.env.GMGN_API_KEY) {","title":"Trust assumption: the GMGN_API_KEY check guards only the server's environment, while gmgn-cli loads ~/.config/gmgn/.env with override and may run with a different key or a private key"}],"hash":"ab2b3c7aa5d214d9973a3b16bc5242e19aac2ead577ea5179f2ca6ed2a89860c","nodeId":"36a3bc1a-c279-4fa9-ba18-32390072825d","outcome":"completed","summary":"The review is complete. The findings file holds 19 merged, reproduced findings.\n\n**What the repository is.** The task names \"contracts\", but agentcitylabs/gmgn-robinhood-mcp contains no Solidity. It is a Node.js MCP server with three source files: a scoring function, a gmgn-cli wrapper, and the HTTP/stdio server. The Foundry profile cannot apply, so no finding carries a Foundry proof. Each finding instead carries exact node commands or curl requests that reproduce it, and I ran every one against the code.\n\n**How I reproduced.** Scoring claims were run directly against the scoring module with node. Server claims were run against the real server with a stub CLI kept in a scratch directory outside the repository. Vendor behaviour claims were checked against the gmgn-cli 1.6.6 package source and its bundled documentation, downloaded to a scratch directory. Nothing in the repository was changed.\n\n**Merging.** The 30 specialist findings collapsed to 19:\n\n- **High.** Unpopulated security fields (\"0\", \"\", whitespace) score as perfect values. Four reports merged into one. A token with no holder analysis scores 79 and \"Deep research\" while the same token with a measured 62% concentration is rejected.\n- **Medium.** Data completeness floor of 0.5 (four reports merged). Unbounded fan-out on the unauthenticated endpoint (three merged). Ten concurrent compare calls produced 150 live child processes with nothing refused. Silent absence of rug_ratio and the string-insensitive wash flag (two merged).\n- **Low and info.** Negative liquidity, float rounding at the 69.5 threshold, stderr and path disclosure (two merged), DNS rebinding, 0.0.0.0 default, all-or-nothing bundles, side filter after limit (two merged), score-only \"Insufficient data\", the 1e21 integer, the honeypot-dropping search order, 404 instead of 405, malformed JSON as 500, the 0.7 activity cap, the inverted market-cap range, and the dotenv override trust assumption.\n\n**What I dropped or qualified.** Nothing was dropped: every specialist claim reproduced. Three findings rest partly on vendor documentation rather than live API calls, and each says so: the absence of rug_ratio from the security response, the honeypot drop behind the \"weight\" ordering, and the dotenv override. No GMGN key was available and no live GMGN traffic was generated.\n\nSeverity is calibrated to a read-only research server: no funds are at risk, so the top finding is high rather than critical.","treeHash":null,"usage":{"cachedInputTokens":1560346,"inputTokens":484,"model":"claude-fable-5-1","outputTokens":31148,"runtime":"claude","turns":34,"wallClockMs":498257}},{"artifacts":[],"attempt":2,"bundleHash":null,"device":"829d84438fb3fdc8","findings":[{"citation":"resolved","description":"Entry-point inventory: the only state-affecting entry point is POST /mcp (public, no authentication, no per-client or global rate limit). Every tool call ends in gmgn.js:18 execFileAsync(cliPath, ...) which spawns a child process that holds a 25 s timeout and a 2 MB stdout buffer and spends the operator's GMGN API quota. agentcity_compare_robinhood_tokens (src/index.js:22) spawns 3 processes per address for up to 5 addresses (duplicates are accepted, so the same address five times also works), i.e. 15 concurrent children per request; agentcity_research_robinhood_token spawns 5. Nothing limits how many requests run at once, children are never killed when the HTTP client disconnects (res.on('close') only closes the transport, src/index.js:42), and Promise.all leaves sibling children running after the first failure. The guard that exists (the GMGN_API_KEY presence check at src/gmgn.js:13) protects nothing against a remote caller: the key is the operator's and the caller never has to present anything. Trust gap: the actor permitted by the (absent) guard, anyone on the internet for the hosted endpoint https://gmgn.agentcity.lol/mcp, can systematically consume the operator's paid quota and host resources at the cost of one TCP request each, while pm2's max_memory_restart: 300M (ecosystem.config.cjs:10) turns memory pressure into a restart that drops every in-flight legitimate request. The README positions the endpoint as open to any MCP client, so public access is design; the absence of any bound on fan-out is the defect. Fix: cap concurrent child processes (a small semaphore around execFileAsync), rate-limit /mcp per source, de-duplicate addresses in compare, kill children on client disconnect (AbortSignal passed to execFile), and consider an API key or token for the hosted endpoint.","line":38,"path":"src/index.js","reproduction":"Run the server with any non-empty GMGN_API_KEY and GMGN_CLI_PATH pointing at a stub that sleeps 20 s then prints '{\"code\":0,\"data\":{}}' (stand-in for a slow upstream). Send 40 concurrent requests from one shell: POST /mcp, headers Content-Type: application/json and Accept: application/json, text/event-stream, body {\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"agentcity_compare_robinhood_tokens\",\"arguments\":{\"addresses\":[\"0x1111111111111111111111111111111111111111\",\"0x2222222222222222222222222222222222222222\",\"0x3333333333333333333333333333333333333333\",\"0x4444444444444444444444444444444444444444\",\"0x5555555555555555555555555555555555555555\"]}}}. Expected: a bounded number of child processes and/or a 429 for the excess. Actual (observed): pgrep -f -c of the stub showed 424 live child processes 3 s after sending, climbing to 600 (40 x 15), with no authentication, no rate limit and no rejection; the same traffic against the real gmgn-cli issues 600 GMGN API calls billed to the operator's key.","severity":"medium","snippet":"  app.post(\"/mcp\", async (req, res, next) => {","title":"Unauthenticated POST /mcp fans out up to 15 gmgn-cli processes per request with no concurrency or rate bound (access x economics)"},{"citation":"resolved","description":"The MCP specification requires HTTP servers to validate the Origin header to prevent DNS-rebinding attacks, and the vendored SDK (1.32.1, @modelcontextprotocol/sdk) exposes enableDnsRebindingProtection with allowedHosts / allowedOrigins for exactly this, but the transport is constructed without them and no middleware checks Host or Origin. The README's documented default is HOST=127.0.0.1 (self-host table), i.e. a loopback-only server for a local agent. A malicious web page visited by the operator can rebind its hostname to 127.0.0.1 and POST to port 3007/3071; because the request is then same-origin from the browser's point of view CORS does not apply, and the server answers with tool results and spends the operator's GMGN_API_KEY. Impact is limited to read-only data and quota (no trading tools exist), hence low. Fix: pass enableDnsRebindingProtection: true with allowedHosts: ['127.0.0.1:' + port, 'localhost:' + port] (plus the public hostname for the hosted deployment) and allowedOrigins, or add an explicit Host/Origin allowlist middleware in front of /mcp.","line":41,"path":"src/index.js","reproduction":"Start the server with MCP_TRANSPORT=http HOST=127.0.0.1 PORT=3907 and any GMGN_API_KEY. Send: curl -X POST http://127.0.0.1:3907/mcp -H 'Host: attacker.example' -H 'Origin: http://attacker.example' -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' -d '{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"agentcity_robinhood_trending\",\"arguments\":{}}}'. Expected: HTTP 403 (host/origin not allowed). Actual (observed): HTTP 200 with an SSE 'event: message' body containing the tool result, identical to a request with a legitimate Host header.","severity":"low","snippet":"      const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });","title":"HTTP transport accepts any Host/Origin, so a local server is reachable by DNS rebinding from a web page"},{"citation":"resolved","description":"README and the tool description promise that dataCompleteness / dataConfidence measures whether 'Liquidity, holders, top-10 share, and rug ratio are all present'. But at src/scoring.js:10-11 liquidity and holders are written as `number(...) ?? 0`, so they are never undefined when line 18 reads them, while top10 and rug (lines 12-13) are left undefined. The writer and the reader of the same values disagree: two of the four presence checks are tautologies, so a bundle with no usable data at all reports dataConfidence 0.5, and a token whose security call returned no fields but whose pool reports $80k liquidity scores 49 and is placed on the Watchlist with confidence 0.5 instead of 'Insufficient data'. The same asymmetry means holder_count participates only in completeness and never in the score although the README lists holders as a scored input. Fix: keep liquidity and holders as undefined for the completeness count (compute presence before applying the ?? 0 defaults), and treat missing security data as zero contractSafety/activityQuality rather than 0.35/0.7.","line":18,"path":"src/scoring.js","reproduction":"node -e 'import(\"./src/scoring.js\").then(({researchScore})=>{console.log(researchScore({info:{},security:{},pool:{}}).dataConfidence); const r=researchScore({info:{holder_count:900},security:{},pool:{liquidity:80000}}); console.log(r.researchScore, r.dataConfidence, r.disposition)})'. Expected: 0 for the empty bundle, and 'Insufficient data' with low confidence for the liquidity-only bundle. Actual (observed): 0.5 for the empty bundle; 49 0.5 'Watchlist' for the liquidity-only bundle (factors: dataCompleteness 0.5, contractSafety 0.35, activityQuality 0.7 with no security fields present).","severity":"low","snippet":"    dataCompleteness: [liquidity, holders, top10, rug].filter((x) => x !== undefined).length / 4,","title":"researchScore dataCompleteness can never drop below 0.5 because liquidity and holders are defaulted to 0 before the presence check (asymmetry writer/reader)"},{"citation":"resolved","description":"The error channel of a trusted local process is forwarded to an untrusted remote one. execFile's error.message is 'Command failed: <cliPath> <args>\\n<stderr>'; gmgn() wraps it unchanged and the MCP SDK returns the message as the isError tool result. Any failing call therefore discloses the absolute GMGN_CLI_PATH and whatever the CLI wrote to stderr (upstream HTTP status, API error text, rate-limit messages). Because the child inherits the whole server environment (src/gmgn.js:19 env: { ...process.env }), if the operator sets GMGN_DEBUG for troubleshooting, gmgn-cli 1.6.6 prints the full curl line (upstream URL with client_id and timestamp), all response headers and the response body to stderr, and all of it is relayed to the caller. The X-APIKEY header value is redacted by gmgn-cli ('***'), so the key itself was not observed to leak; the remaining disclosure is infrastructure detail and upstream error bodies, hence low. Fix: return a generic message to the client, log the detail server-side, and do not pass GMGN_DEBUG (or the whole environment) to the child.","line":26,"path":"src/gmgn.js","reproduction":"Start the server with GMGN_API_KEY=gmgn_bogus_key_12345, GMGN_DEBUG=1 and GMGN_CLI_PATH set to a real gmgn-cli 1.6.6 (or, offline, a stub that writes '[gmgn-cli] HTTP 401 Unauthorized: secret-stderr-content' to stderr and exits 1). Call tools/call agentcity_robinhood_trending with {} over POST /mcp. Expected: a generic 'GMGN query failed' error. Actual (observed): the tool result text is 'GMGN query failed: Command failed: /tmp/gmgn-cli-pkg/package/dist/index.js market trending --chain robinhood --interval 1h --limit 30 --raw' followed by the '[curl]' block with the upstream URL and headers (X-APIKEY: ***) and '[response] HTTP 401' with every response header and the body; with the stub, the stderr line 'secret-stderr-content' is returned verbatim.","severity":"low","snippet":"    throw new Error(`GMGN query failed: ${detail}`);","title":"Raw gmgn-cli stderr, the server-side CLI path and (with GMGN_DEBUG) full upstream request/response are returned verbatim to unauthenticated remote callers"},{"citation":"resolved","description":"The README's variable table states HOST defaults to 127.0.0.1 and the quick-start says 'cp .env.example .env' then 'npm start  # HTTP on http://127.0.0.1:3007/mcp'. The shipped .env.example sets HOST=0.0.0.0, and ecosystem.config.cjs:14 and :20 also set HOST: '0.0.0.0' with PORT 3071. An operator following the documented steps therefore exposes the unauthenticated /mcp endpoint on every interface. The README's only hardening advice is 'Put a TLS reverse proxy in front of /mcp', but with the process itself listening on all interfaces, any TLS, authentication or rate limiting applied at the proxy is bypassed by connecting to :3071 directly (asymmetry between the guarded path and the unguarded one). Combined with the fan-out above, the bound-everywhere process is the one an attacker will target. Fix: default .env.example and the pm2 config to 127.0.0.1 and let the proxy connect over loopback; document that 0.0.0.0 is only for containerised deployments where the port is not published.","line":8,"path":".env.example","reproduction":"Fresh clone, follow README self-host: cp .env.example .env; set GMGN_API_KEY; npm start. Expected (per README): listening on http://127.0.0.1:3007/mcp. Actual: the startup line prints 'listening on http://0.0.0.0:3007/mcp' and curl -X POST http://<any-LAN-or-public-IP-of-host>:3007/mcp with the initialize JSON-RPC body returns 200 from another machine with no credentials; the same holds for port 3071 under pm2 start ecosystem.config.cjs.","severity":"low","snippet":"HOST=0.0.0.0","title":"Shipped configuration binds the unauthenticated server to 0.0.0.0, contradicting the README default and bypassing the recommended reverse proxy"},{"citation":"resolved","description":"Documenting a trust boundary rather than a bypass. gmgn-cli 1.6.6 (dist/config.js) runs loadDotenv({ path: ~/.config/gmgn/.env, override: true }) before every command, so the credentials actually used by the child are whatever that global file holds, not necessarily the GMGN_API_KEY the server validated. Two consequences: (1) the README's promise 'it never needs GMGN_PRIVATE_KEY' and .env.example's 'Do not add GMGN_PRIVATE_KEY to this service' are not enforceable from this repository; if the operator also uses gmgn-cli for trading on the same account, every child spawned by a remote caller runs with the private key loaded (no tool in this server invokes a signed command, so there is no exploit path today, but any future tool addition or CLI change inherits it); (2) key rotation in the server's .env does nothing while a global file exists. Fix: spawn the child with a minimal explicit env (PATH, GMGN_API_KEY, proxy vars) instead of { ...process.env }, and document that the service account must not have ~/.config/gmgn/.env.","line":13,"path":"src/gmgn.js","reproduction":"On the server host create ~/.config/gmgn/.env containing GMGN_API_KEY=other_key and GMGN_PRIVATE_KEY=<any PEM>. Start the server with GMGN_API_KEY=server_key and call any tool. Expected: the upstream request is authenticated with server_key and no private key is present in the child. Actual: gmgn-cli's getConfig() reads process.env after dotenv override, so the X-APIKEY header carries other_key and privateKeyPem is populated in the child; the server's check at line 13 only confirmed that server_key was non-empty.","severity":"info","snippet":"  if (!process.env.GMGN_API_KEY) {","title":"Trust assumption: the GMGN_API_KEY check guards the server's environment, but gmgn-cli loads ~/.config/gmgn/.env with override and may use a different key or a private key"}],"hash":"dac2fabf76feb23aac86b86d76f238619e4d661e89d7ba54460092562fbdae39","nodeId":"0ffcd8a0-4472-4789-bb73-f05e104cdef8","outcome":"completed","summary":"The review is complete and written to `.imd-findings.json` in the repository root. No repository files were changed.\n\n**Scope.** The repository contains no Solidity. It is a Node.js MCP server with three source files, so the Foundry proof requirement does not apply and no finding carries a proof. I audited the JavaScript against the Access Control, Trust Gap and Asymmetry guides, building the entry-point inventory first (one state-affecting entry point, `POST /mcp`, public and unauthenticated) and running the server locally with stub and real gmgn-cli binaries to reproduce each claim.\n\n**Findings, ranked:**\n\n- **Medium, unbounded process fan-out on an unauthenticated endpoint.** One compare request spawns 15 gmgn-cli children, nothing limits concurrency, and children are not killed on client disconnect. Forty concurrent requests produced over 400 live child processes within 3 seconds, each spending the operator's GMGN quota.\n- **Low, no Host/Origin validation.** The transport is built without the SDK's DNS-rebinding protection. A request with a foreign Host and Origin header was served with HTTP 200, so a local server is reachable from a malicious web page.\n- **Low, data-completeness floor of 0.5 in the research score.** Liquidity and holders are defaulted to 0 before the presence check, so an empty bundle reports confidence 0.5 and a liquidity-only token with no security data lands on the Watchlist.\n- **Low, raw child stderr and CLI path returned to remote callers.** With debug enabled, the full upstream URL, response headers and body are relayed. The API key itself is redacted by gmgn-cli, so it did not leak.\n- **Low, shipped config binds to all interfaces.** The example env and pm2 config set HOST=0.0.0.0 while the README documents 127.0.0.1, which lets callers bypass the recommended reverse proxy.\n- **Info, trust assumption.** gmgn-cli loads a global credentials file with override, so the server's key check does not govern which key, or whether a private key, the child actually uses.\n\n**Dismissed after checking.** Argument injection through the search query is not exploitable: commander consumes a dash-prefixed value as the option argument. The GMGN field units used by the scorer match GMGN's documented ranges.","treeHash":null,"usage":{"cachedInputTokens":1601975,"inputTokens":610,"model":"claude-fable-5-1","outputTokens":27251,"runtime":"claude","turns":44,"wallClockMs":400292}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"468e82a89b9bfe18","findings":[],"hash":"f6d30eb2607fd40062b8891fe180df84eae98926fe7568666fffe3f44cd598ea","nodeId":"0ffcd8a0-4472-4789-bb73-f05e104cdef8","outcome":"failed","summary":"runtime reported claude-opus-4-8, not the required premium model claude-fable-5-1","treeHash":null,"usage":{"cachedInputTokens":1918163,"inputTokens":530,"model":"claude-opus-4-8","outputTokens":29477,"runtime":"claude","turns":26,"wallClockMs":440589}}],"verification":[]}