# Audit report

> Audit the contracts in agentcitylabs/gmgn-robinhood-mcp as they are.

| | |
|---|---|
| Repository | https://github.com/agentcitylabs/gmgn-robinhood-mcp.git |
| Commit | `3e0293f597a870219d9fb1fd9444d432fdc762eb` |
| Job | `42b77bf7-138d-44a1-861b-dd89ddb131f0` |
| Judged | 2026-10-13 20:27 UTC |
| Findings | 1 high · 3 medium · 12 low · 3 info |

Four agents audited the code as it is at `3e0293f`, each in one area (math, permissions, economics, control flow),
and a judge reproduced, merged and ranked what they found, then read the code once more itself. Nothing in the repository was changed or deployed.

## Findings

### 1. High: 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

`src/scoring.js:2`

```
  const result = typeof value === "number" ? value : typeof value === "string" ? Number(value) : NaN;
```

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.

**Reproduction**

Run from the repository root.
(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"}}))))'
Actual: 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.
(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.
(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"}}))))'
Actual: 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.
(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.

### 2. Medium: 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

`src/scoring.js:18`

```
    dataCompleteness: [liquidity, holders, top10, rug].filter((x) => x !== undefined).length / 4,
```

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.

**Reproduction**

Run from the repository root.
(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}))))'
Actual for both: factors.dataCompleteness 0.5, dataConfidence 0.5, researchScore 24. Expected: dataCompleteness 0, dataConfidence 0, score 14.
(2) node -e 'import("./src/scoring.js").then(({researchScore})=>console.log(JSON.stringify(researchScore({info:{holder_count:900},security:{},pool:{liquidity:80000}}))))'
Actual: 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.

### 3. Medium: 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

`src/index.js:38`

```
  app.post("/mcp", async (req, res, next) => {
```

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.

**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.

### 4. Medium: 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

`src/scoring.js:21`

```
    contractSafety: rug === undefined ? (verified ? 0.65 : 0.35) : Math.max(0, 1 - rug) * (verified ? 1 : 0.7),
```

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.

**Reproduction**

Run from the repository root.
(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}}))))'
Actual: 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.
(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"}}))))'
Actual: activityQuality 0.7, hardFlags [], disposition "Deep research". Expected: wash_trading_detected flag.

### 5. Low: liquidityDepth has no lower clamp, so a negative liquidity value drives the research score arbitrarily negative

`src/scoring.js:19`

```
    liquidityDepth: Math.min(liquidity / 50_000, 1),
```

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.

**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}}))))'
Actual: factors.liquidityDepth -20000, researchScore -499932, disposition "Insufficient data". Expected: liquidityDepth 0, researchScore 68, "Watchlist".

### 6. Low: Floating-point accumulation makes an exact weighted score of 69.5 round to 69 (Watchlist) instead of 70 (Deep research) at the disposition threshold

`src/scoring.js:24`

```
  const score = Math.round(factors.dataCompleteness * 20 + factors.liquidityDepth * 25 + factors.holderDistribution * 25 + factors.contractSafety * 20 + factors.activityQuality * 10);
```

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.

**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"}}))))'
Exact 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.

### 7. Low: 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

`src/gmgn.js:26`

```
    throw new Error(`GMGN query failed: ${detail}`);
```

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.

**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.

### 8. Low: HTTP transport accepts any Host and Origin header, so a loopback-bound server is reachable by DNS rebinding from a web page

`src/index.js:41`

```
      const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
```

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.

**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.

### 9. Low: 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

`.env.example:8`

```
HOST=0.0.0.0
```

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.

**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.

### 10. Low: 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

`src/gmgn.js:57`

```
  const [info, security, pool, holders, traders] = await Promise.all([
```

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.

**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.

### 11. Low: smart_money side filter is applied by gmgn-cli after limit, so limit N with side set returns fewer than N trades or none

`src/gmgn.js:38`

```
  const args = ["track", kind, "--chain", "robinhood", "--limit", String(limit)];
  if (side !== undefined) args.push("--side", side);
```

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.

**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.

### 12. Low: 'Insufficient data' disposition is assigned by score alone, so a fully populated low-quality packet with dataConfidence 1 is labelled as a data problem

`src/scoring.js:26`

```
  const disposition = hardFlags.length ? "Reject from research queue" : score >= 70 ? "Deep research" : score >= 45 ? "Watchlist" : "Insufficient data";
```

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.

**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}}))))'
Actual: researchScore 44, dataConfidence 1, hardFlags [], disposition "Insufficient data". Expected: a low-priority disposition, with "Insufficient data" reserved for low dataCompleteness.

### 13. Low: minSmartMoneyCount >= 1e21 passes the schema but reaches gmgn-cli as "1e+21", which its parseInt reads as 1, silently loosening the screen filter

`src/index.js:18`

```
if (minSmartMoneyCount !== undefined) args.push("--min-smart-degen-count", String(minSmartMoneyCount));
```

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).

**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.

### 14. Low: 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

`src/gmgn.js:52`

```
  return gmgn(["market", "search", "-q", query, "--chain", "robinhood", "--order-by", "weight"]);
```

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.

**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.

### 15. Low: 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

`src/index.js:38`

```
  app.post("/mcp", async (req, res, next) => {
```

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.

**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.

### 16. Low: Error handler turns malformed-JSON 400s into 500s and prints a full stack trace with the offending body for every malformed request

`src/index.js:47`

```
  app.use((error, _req, res, _next) => { console.error(error); if (!res.headersSent) res.status(500).json({ error: "Internal MCP server error" }); });
```

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.

**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.

### 17. Info: 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)

`src/scoring.js:22`

```
    activityQuality: wash ? 0 : 0.7
```

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.

**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}}))))'
Actual: researchScore 97 with every factor at its maximum and activityQuality 0.7. Expected per README weights: 100.

### 18. Info: signals tool accepts minMarketCapUsd greater than maxMarketCapUsd and forwards the empty range to GMGN instead of rejecting it locally

`src/index.js:26`

```
minMarketCapUsd: z.number().min(0).optional(), maxMarketCapUsd: z.number().min(0).optional() }
```

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.

**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).

### 19. Info: 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

`src/gmgn.js:13`

```
  if (!process.env.GMGN_API_KEY) {
```

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.

**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.

---

Judge's submission `ab2b3c7aa5d214d9973a3b16bc5242e19aac2ead577ea5179f2ca6ed2a89860c`, accepted on the IdentityMD network. Acceptance means the report met the job's checks;
it is not a guarantee that the code has no other defects.
