Portal MCP v0.8.5 turns the SQD Explorer into a data-first beta app that fits its host and is clearly opt-in, corrects fifteen kinds of case where a tool answered with something it should not have, and gives operators cost guardrails and an end-to-end trace of a single tool call.
Correctness
- A network name always means that network. Aliases were matched as substrings in both directions against tokens as short as
op,ethandbtc, soopbnbanswered with Optimism's chain head,ethereum-holeskywith Ethereum mainnet, andbtc-testnetwith Bitcoin mainnet, each with the evidence receipt recording the name that was asked for. Matching is now whole-name, a real dataset beats a nickname, and a network SQD does not carry returnsunknown_networkinstead of a neighbour's data. portal_get_time_seriesno longer answers metrics it does not compute. Nine of its fifteen metrics returned an all-zero series on EVM networks, markedresult_complete: truewith no empty buckets and a receipt hashing the zeros, so "success rate averaged 0%" was indistinguishable from a real answer. Each chain family now declares what it computes and anything outside that set is refused withunsupported_operation.- A wallet summary no longer reports a complete result over a section that failed.
_coverage.result_completewas keyed off pagination alone, so a summary whose transactions never arrived still reported complete beside its ownfailed_sections: ["transactions"]. It now requires the window to be covered and every section to have arrived, and names any that did not. - Maker rebates are no longer counted as fees collected. A Hyperliquid maker rebate arrives as a negative fee. The fills summary was fixed to sum fees signed;
portal_hyperliquid_get_analyticskept its own copy of the arithmetic and still summed absolute values, so a window whose makers were paid rebates was reported as one that collected fees, intotal_fees_usd,total_fees_formattedand the per-coinfees_usd. The second copy survived the first fix because it sat inside a streaming callback that only a live Portal window could reach; the per-fill arithmetic is now a plain function that six unit tests hold. - A summary asks for the fields it adds up. A summary that aggregates fields its field preset never requested answers with a confident zero rather than an error. Three tools did it. Trace summaries at
field_preset: "minimal"reportedtotal_value_ethof 0 and notop_sighashes;portal_evm_query_transactionsreportedtotal_gasof 0, because the minimal preset asks for hash, index, type, from, to and value and not gas; andportal_hyperliquid_query_fillswithinclude_pnl: falsereportedtotal_realized_pnlof 0. A summary now reads at least the standard field set, and asks forclosedPnlwhatever the flag says, because it replaces the rows rather than listing them. A listing still follows the flag. - One response gives one answer about its window. A Bitcoin fee series came back with
_coverage.window_complete: falsebesidegap_diagnostics.window_complete: true. The two fields share a name and answer different questions, the diagnostics about whether observed data covered the buckets and_coverageabout whether the analysed window can be proved to be the one that was asked for, and only_coveragewas reconciled against an estimated boundary. A downgrade on either now applies to both. The same series reportedexpected_buckets: 8besidereturned_buckets: 10; the fee series spans the blocks it scanned rather than exactlyduration, because dropping a scanned block would take its fees out of the buckets while leaving them in the window total, so coverage now reports the series it built. - A bounded scan says how much of the window it read.
_coverage.window_completedefaults to true, so a scan that stopped early reported the blocks it never opened as analysed. Four tools were affected: both Tron query tools,portal_evm_query_token_transfers, which stops as soon as the page is full and had reported a 5,000-block window as read after 100 blocks, andportal_evm_get_contract_deployment, which stops atmax_scan_blocksand had reported a capped search that found nothing as proof the contract was not there. - Long time-series windows are refused instead of run. The tool reads every block in the window, so
7don a two-second chain is 302,400 blocks: it went four minutes without answering and reached 1.9 GB of memory. Windows over 12,000 blocks are now refused with the block count, the bound, and a duration that fits on that chain. A window far over the bound by the chain's own block time is refused in milliseconds, before the two timestamp lookups that used to make the refusal itself take about ten seconds on Ethereum; a borderline window still waits for the exact block count. The limit is blocks rather than time, so24hstill works on Bitcoin and Ethereum and is refused on Base. - An oldest-first scan can be continued.
scan_order: "earliest"onportal_evm_query_logs,portal_evm_query_token_transfersandportal_evm_query_tracesansweredhas_more: truewith no cursor,_coverage.continuationclaimed a cursor or denied one at random,_orderingdescribed the page as the most recent matches, the answer said the newest blocks had been read, and a notice told the caller to page forward.portal_evm_query_transactionsalready had a working forward cursor; the other three now use the same offset-based one._pagination.has_moreis true exactly when a cursor is present,_coverage.continuationis derived from that cursor when the response is assembled rather than claimed by each tool, an oldest-first page says so in_orderingand in prose, and the cursor notice names its direction. A capped scan says whether a biggermax_scan_blocksor the cursor is the way to the rest. A live gate,test:scan-order, follows the cursor on all four tools and checks that the blocks, the coverage and the prose agree. - A window that was not read through says so. A backward scan stops once its page is full, and every tool that reads that way called the requested window complete regardless, so a page from the newest block alone reported the hour below it as read. The fetch now reports whether it reached the window start and
_coverage.window_completefollows it in the ten tools concerned. The answer discloses it too, cursor or no cursor, and a cappedportal_evm_get_contract_deploymentmiss says which blocks it searched instead of reading as a complete answer.result_completekeeps its own question, whether there is more to fetch: folding coverage into it would leave no field meaning "there is a next page" and make a deliberately trimmed window, such asportal_evm_get_contract_activityin fast mode, read as a paginated result. - Hyperliquid tools take Hyperliquid datasets and addresses only.
hyperliquid-mainnetis HyperEVM, an EVM chain, and the resolver handed it to the fills, analytics, OHLC and replica-command tools, which failed inside the Portal as a malformed request. They now refuse it with the unsupported-chain error every other tool gives and point at the EVM tools. Auser,builderorvault_addressfilter that is not an address was streamed as written and answered as a complete, empty window; it is rejected asinvalid_requestbefore any request is made. - Token amounts are formatted with the token's decimals or shown raw. Unfiltered
portal_evm_query_token_transfersformatted every amount with 18 decimals, so a USDC transfer of 4,238.51 read as 0.000000000004. The token list is consulted whenever there are rows to format, not only when the caller asked for token info, and a token the list does not know shows raw units with a notice instead of a wrong number. - The catalog finds the nicknames the resolver accepts.
portal_list_networkswithquery: "btc"returned nothing whilebtcresolved tobitcoin-mainnet. The catalog now matches and lists the server's own aliases beside the Portal's. - The time-series metric list says which chain family computes what.
portal_get_time_seriesaccepts fifteen metrics and each chain family computes a subset, so a client could be sent aftersuccess_rateon an EVM network and get anunsupported_operationit had no way to predict. Themetricdescription now names the metrics per family. That grows the tool's catalog entry by 72 tokens, past the 5% the catalog gate allows, so the token baseline was refreshed for it withnpm run baseline:catalog-tokens -- --note. mode: "fast"reports the window it read. Fast mode reads the newest 1,500 blocks of a longer EVM window on purpose, then bucketed them against the requested duration: a 6h window on Ethereum was called complete with an hour unread, and 24h failed as an incomplete bucket set. The slice is bucketed over its own span,_coverageandgap_diagnosticsmark the window incomplete, and a notice says how much was read and thatmode: "deep"reads all of it.portal_bitcoin_query_transactionssays when a page can outgrow the response budget. With inline inputs and outputs in full format, a page near the maximum of 25 exceeds the 50,000-byte response budget and fails closed with the limit to retry with; thelimitdescription now says so.
Operations
- Cost guardrails.
MCP_GUARDRAIL_MODEofoff(default),shadoworenforce, with per-class ceilings on scan blocks, window seconds and upstream bytes throughMCP_GUARDRAIL_<CLASS>_<LIMIT>. There are no numeric defaults, sooffandenforcewith nothing configured are the same server. Shadow counts what enforcing would cut without changing a response; enforce reports a capped scan through the existing partial-coverage path and refuses an over-cap window before any upstream call. Four Prometheus counters and a Grafana panel. - Traces, off unless asked for. Metrics say how often and how long; a trace says where the time went inside one call. Set
OTEL_EXPORTER_OTLP_ENDPOINTand one tool call arrives as one tree: the MCP request, the tool call, the wait for an admission slot, one span per Portal fetch attempt with its dataset, status, bytes and resend count, and the hashing pass that builds the evidence receipt. Atraceparenton the HTTP request or in the tool call's_metajoins the caller's trace, each Portal request carries atraceparentof its own so a Portal-side trace can join, and the JSON log lines carrytrace_idandspan_id. OpenTelemetry is an optional peer dependency, not a dependency: with the endpoint unset nothing is imported, allocated or sent, and the packages need not be installed. Span attributes carry no arguments, addresses, hashes, cursors or free text;MCP_OTEL_INCLUDE_ARGS=1adds the raw arguments and is documented as unsafe for production.
Highlights
- Beta, opt in: the Explorer is labelled Beta in the widget, in the resource description, and as
_app.stage. It stays off by default.MCP_APP_ENABLED=trueenables it for a deployment;?app=1and?app=0on the connection override that in either direction./healthreportsapp.enabled, the deployment's setting as the restarted process sees it, so turning it on or rolling it back can be confirmed without a tool call.docs/explorer-design.mdrecords what the app is held to, why, and how to turn it on and off again. - Fits the host: the inline card reports its exact content height, so hosts no longer pad it with blank space. Structural colours follow the host's MCP Apps style variables in light and dark with SQD design-system fallbacks, and full screen opens a two-column workspace.
- Data, not narrative: each result is headed by its subject (address, market, or network window) and the primary number leads the metric row. Receipt lines, context chips, caller-facing notices, and chart range sliders leave the view; the JSON keeps them.
- Explorer links and chain identity: addresses, transaction hashes, and blocks link to the public explorer for their network (Etherscan family, Solscan, mempool.space, Subscan, Hyperliquid, Tronscan). Network chips show the chain logo and display name from SQD network metadata, regenerated with
npm run sync:chains. - One chart engine: every chart the Explorer draws (line, area, bar, and candle) now renders through the same charting library the candle chart already used, replacing a second hand-written SVG renderer that had drifted from it in axis ticks, hover behaviour, gap handling, and palette. Sparse series keep their gaps: a missing bucket leaves a hole instead of a straight line drawn through it, because each unbroken run is drawn as its own series over a shared whitespace timeline that holds the empty slots open. Candle charts keep their fixed two-line readout (time and OHLCV, then fills, VWAP, size, and open or partial bucket state) instead of a floating tooltip, so hovering never resizes the chart, and labels render at native size in the terminal palette.
- The chart surface declares what it has:
src/app-ui/capabilities.tsstates what the charts actually implement (hover, crosshair, snap to data, zoom on the time axis, a legend that switches a series off, reset zoom, and range presets) and what they do not (no drag-a-box-to-zoom, no PNG export). The app reads it when deciding whether to draw a control, andtest:app-contractholds the server's tool contract against it, so a control cannot be promised to a model without existing. For two releases the contract offered a PNG export and a visual switch that were never built. - A follow-up no longer throws the view away: every render rebuilt the whole app, so asking a follow-up destroyed and recreated every chart, losing the zoom, the hidden series, and the scroll position, and flashing the panel on the way. A render whose payload and structure are unchanged now updates the header in place and leaves the charts running, so a pending follow-up dims the view rather than replacing it.
- A view file that can be reviewed:
src/app-ui/view.tshad grown to 3,157 lines holding the shell, both chart renderers, the tables, the panels, and every empty and error state, which made any change to one of them a diff against all of them. It is now eight modules with a one-way import graph (common,charts/range,charts/terminal,masthead,tables,panels,states, and a 167-lineviewentry point). Behaviour is unchanged: the layout baseline matched all 78 recorded cells before and after the split. - The Explorer's design decisions are written down:
docs/explorer-design.mdrecords what the app is held to, where each rule comes from, the two places it deliberately departs from the chart spec and why, and how a visual change is reviewed.scripts/app-ui-baselines/layout-<platform>.jsonlocks the box of 26 selectors across 78 fixture, viewport, and theme combinations and fails on a one-pixel move, naming the selector and both numbers; refresh it deliberately withnpm run baseline:app-ui.npm run contact-sheet:app-uirenders every recorded fixture at three widths in both themes for review, and the pull request template asks for both wheneversrc/app-ui/changes. - Short pages, full data: ranked and timeline panels show ten rows with one Show all control, tables page ten rows with search across every row, identifier cells link out, and missing values are blank rather than "n/a".
- Every action verified in a host: Load older, Back, Forward, JSON and CSV downloads through the host download request, Full receipt, row dialogs, explorer links, filter, sort, and Exit full screen are driven through the official AppBridge in
test:app-host. - Truthful Bitcoin fees:
portal_bitcoin_get_analyticssums fees in exact satoshis from inputs and outputs over the newest 36 blocks of the analyzed window, reports that block set with exact satoshi and BTC totals, and marks a sample-scoped scan in the answer, notices,_coverage.sections, execution notes, and receipt instead of presenting it as a window total. Thefees_btctime series now computes real per-bucket fees whose sum reconciles to the window total. Time-series and candle summaries declare their bucket alignment (anchored_to_latest_blockversusinterval_boundary) so they are not joined bucket for bucket by mistake. - Hyperliquid fill summaries:
portal_hyperliquid_query_fillswithresponse_format: "summary"returnedinternal_errorinstead of aggregates. Fill amounts are normalized to exact decimal text, so adding realized PnL built a string and the aggregate step then failed. Every summary aggregate now parses its amounts before adding them, and unparseable or missing values count as zero rather than producingnulltotals. The summary returns the fill count, unique traders and coins, volume, fees, realized PnL, the direction breakdown, and the top coins by volume alongside the usual_coverageand_paginationblocks. The same coercion covers the Bitcoin input, output, and transaction-size aggregates, which normalize amounts the same way.npm run test:unitcovers the aggregates andnpm run test:toolscalls the summary format against live Portal data. - The required check can now pass, and says so for the right reason: the offline gate had failed on every push, always on the same cell, because the Explorer layout baseline is recorded in the pinned Playwright image and the gate measured on a bare runner. Different fonts, every text box about ten percent wider, nothing in the markup changed. The gate was reporting a real difference, so neither re-recording it nor loosening the tolerance was the answer: the job runs in that image now, and
npm run test:workflow-pinsholds the image tag in the workflow and inbaseline:app-ui:ciagainst the locked Playwright version, so a dependency bump cannot re-font the comparison quietly. Two more environment assumptions went with it. The Docker image build died onERR_MODULE_NOT_FOUNDbecause a new bundler plugin was added to the build and not to the.dockerignoreallowlist, and MCPB packaging shelled out tozipandunzip, which that image does not carry; both now use the official MCPB CLI.npm run test:distributiongained two checks for the class: every filenpm run buildreaches has to be let into the image, and everything the offline gate runs may use node and npm and nothing else. - Fast, honest CI: pull requests run
npm run test:offline(build, Biome lint, typecheck, unit tests, and every suite that needs no Portal access) as the required check;npm run test:liveruns the Portal-dependent suites and reports without blocking. Amainpush publishesedgefrom the offline gate; a release tag runs the full matrix. Playwright's browser is cached between runs. - Lint, typecheck, and unit tests:
npm run lint,npm run typecheck, andnpm run test:unit(node --testonsrc/**/*.test.ts) cover timeframe parsing, exact decimals, signed cursors, address validation, coverage rules, Bitcoin fee accounting, and a wallet-summary characterisation on a recorded response. One formatting commit, listed in.git-blame-ignore-revs, brought the repository to Biome's format; generated files are excluded and the blanket rule overrides are gone. - Native Tron query tools:
portal_tron_query_transactions(native TRX transfers, TRC-10 transfers, smart-contract calls by contract and method, or any contract type, with inline logs and internal transactions) andportal_tron_query_logs(TVM event logs by contract and topics with event aliases, the parent transaction hash on every row, and inline decoding). Addresses may be given as Base58, 41-prefixed hex, or 20-byte hex and are converted to the form Portal expects in each position; results carry both hex and Base58, second-precision timestamps beside the raw millisecond values, and amounts labelled by what they actually are:amount_sunwithamount_trxon a native TRX transfer, andasset_amountin the asset's own base units on a TRC-10 transfer, which is never counted as TRX. Malformed addresses, wrong checksums, and filters that do not fit the chosen transaction kind are rejected before any Portal request. The catalog grows from 28 to 30 tools,tronis a toolset, andtest:data-integritychecks both tools against direct Portal rows. - EVM traces:
portal_evm_query_tracesreturns the execution traces under a transaction: internal calls, contract creations, self-destructs, and block rewards. Filter by trace type, caller, callee, 4-byte selector or method alias, deployer, or created contract, or passtransaction_hashwith its block to get the full internal call tree of one transaction. Portal's nestedactionandresultobjects are flattened into stable fields (call_from,call_to,call_sighash,value_eth,gas_used,created_contract_address,success), every row carries the parent transaction hash and a deterministic id built from that hash and the trace address, and rows sort by block, transaction index, then trace address. Scans are bounded with a continuation cursor and the usual coverage disclosure; unfiltered windows stay small on purpose because traces are the heaviest Portal table. The catalog grows from 30 to 31 tools, andtest:data-integritychecks a pinned transaction's traces against direct Portal rows. - A response-size baseline that can be refreshed:
scripts/quality-baseline-v0.7.9.jsonwas pinned to a release from long before this one and had to be edited by hand, so three tools failed the size gate simply because the data behind them grew. It is nowscripts/quality-baseline.json, written bynpm run baseline:quality -- --note "<why>", and it records the package version it was measured at, the capture time, the sample count, and the reason it was taken. Budgets are the measured median and p95 plus 10 percent headroom. The current baseline covers all 31 tools and the size gate passes with no failures. - Model-in-the-loop eval:
npm run eval:model-loophas a model answer 21 pinned questions (18 across EVM, Solana, Bitcoin, Substrate, Hyperliquid, and Tron at fixed blocks, slots, and timestamps, plus an unsupported network, a malformed address, and a future window) through the real server and grades the final answer. It records tool calls, tokens, and wall time per question, writes a JSON artifact and a Markdown summary, and fails under a 90% pass rate or a 20% rise in median tool calls over the previous runs.--model mockreplays the recorded reference calls to check the answers against live Portal data without an API key. A nightly workflow runs it withANTHROPIC_API_KEYand keeps the artifacts. - Fair admission per caller: tool admission now keys work by hashed connection (the address the trusted proxies observed with
MCP_TRUST_PROXY, otherwise the socket address; never stored or labelled). One caller holds at mostMCP_TOOL_CLIENT_WEIGHT_SHAREpercent of the weight budget (default 50) andMCP_TOOL_CLIENT_MAX_QUEUEqueued calls; over that it receives the retryableoverloadedresult withreason: client_sharewhile other callers keep flowing.mcp_tool_admission_active_by_familyand theclient_sharerejection reason are exported, and calls slower thanMCP_SLOW_REQUEST_MSlog one JSON line with admission wait and execution timings. - Public repository hygiene:
SECURITY.md(private reporting only),CONTRIBUTING.md,CODE_OF_CONDUCT.md, a pull request template that mirrors the offline gate, bug and feature issue templates,CODEOWNERS, andAGENTS.mdwith the build, test, and release commands..gitignorenames the local planning files it protects instead of ignoring every Markdown file.RELEASE_ASSURANCE.mdnow leads with the current contract and keeps a short history;scripts/README.mdlists every suite with its gate; the README and the directory submission packet describe the server as it is. - One-click Claude Desktop install: every release ships
sqd.mcpb, an MCP Bundle with the server, its production dependencies, and an optional "SQD Explorer (beta)" setting.npm run package:mcpbbuilds it from the local install with no network, validates the manifest with the official CLI, and fails above 15 MB;npm run test:mcpbunpacks and starts it in the offline gate. Directory Health checks the asset on the current release. - Toolsets: every tool belongs to one of nine toolsets (
discovery,convenience,evm,solana,bitcoin,substrate,hyperliquid,tron,debug).MCP_TOOLSETS(orMCP_TOOLSfor exact names) trims a deployment's catalog at registration time, so production can run without the debug tools;?toolsets=orX-MCP-Toolsetsnarrows a single HTTP connection and can never widen it. Prompts that name a disabled tool are not offered, the instructions follow the active set, andmcp_tool_client_calls_totalcarries the bounded active set. With nothing configuredtools/listis unchanged, which the catalog token gate checks. - Reviewable UI diffs: the generated Explorer bundle is no longer tracked in git.
npm run buildregenerates it, anddev,typecheck, and the source-importing test scripts rebuild it only when it is missing or older than its inputs, so a fresh clone works withnpm ci && npm run devand merges stop conflicting on a minified blob. - Third-party text is data: token-list names and symbols, pallet, call, event, program, and coin labels are listed in
_tool_contract.untrusted_fieldsand reach prose only cleaned and quoted (control, zero-width, and bidi characters removed, length capped, plain tickers such asUSDCunchanged). Structured fields keep the raw value byte for byte. The shared formatter and the error envelope clean every prose field once more, the Explorer renders values as text, and CSV export neutralises formula prefixes. The policy is written down inRELEASE_ASSURANCE.md. - Frugal catalog, measured:
npm run test:catalog-tokenscounts what every session pays fortools/list,prompts/list,resources/list, and the instructions, per tool and for both the App-disabled and App-enabled surfaces, and fails the offline gate when the total or any tool grows more than 5% overscripts/catalog-token-baseline.json. The shared output schema now leaves free-form blocks untyped with short descriptions (767 to 485 tokens per tool), and the per-tool "MCP APP" paragraph moved into the server instructions once. Measured witho200k_base: the App-disabled catalog fell from 42,578 to 34,682 tokens and the App-enabled catalog from 45,721 to 36,824. - Hardened HTTP transport: the server binds loopback by default and checks
HostandOriginon every route (loopback always passes, requests withoutOriginpass,MCP_ALLOWED_HOSTSandMCP_ALLOWED_ORIGINSextend the list,*switches a check off). A non-loopback bind without those lists logs a startup error. Header, request, and keep-alive timeouts and aMCP_MAX_BODY_BYTEScap (413 before parsing) bound every request./readyanswers 200 only once the dataset catalog has loaded and the latest Portal probe is fresh, withRetry-Afteron 503; the Docker image's health check uses it and setsMCP_BIND=0.0.0.0. - Traceable builds:
/healthand every tool result's_serverblock report the git commit the image was built from, and the image carries it as an OCI label. Docker Hublatestnow means the latestv*tag;mainpushes publishedgeandsha-<commit>instead. - Release from the changelog: pushing a
v*tag creates the GitHub release with the dated changelog section as its body and uploads the Gemini extension archive, alongside the registry publication and the Docker image, with no manual step in between. - One Node runtime: Node 22 across
.nvmrc,.mise.toml, the Dockerfile, every workflow, and theenginesfield. - Workflow supply chain: every GitHub Action is pinned to a full commit SHA with a version comment, checkouts drop their credentials, workflows start from empty permissions and grant per job, release images do not share a layer cache with edge builds, Renovate keeps the pins current, and
npm run test:workflow-pinsguards all of it. - Directory Health that means something: the daily check reads the Smithery registry API instead of a browser-rendered page, reports pending review queues without failing, and turns red only when a required listing regresses.
- A fair share the caller cannot help itself to: the admission key mixed in the client family, which is whatever the client calls itself in
clientInfo, so one connection could claim a share per name it declared. The key is now the connection alone, and the family stays on the metric label where it describes traffic instead of granting budget.MCP_TRUST_PROXYalso read the leftmostX-Forwarded-Forhop, the one part of that header a caller writes itself: a new value per request meant a new identity per request and no limit at all. Three things changed. The header is read only when the immediate peer is a trusted proxy, because the setting alone was never a trust boundary and a caller reaching the origin directly could still forge its own identity; loopback and private ranges are trusted by default andMCP_TRUSTED_PROXY_PREFIXESnames the proxies when they are elsewhere. The hop is counted from the right, andMCP_TRUST_PROXYaccepts the number of proxies in front of the server, so a header shorter than that chain falls back to the socket address. Addresses are canonicalised before hashing, so203.0.113.9,::ffff:203.0.113.9, and203.0.113.9:51234are one caller rather than three. - Prompts and guides follow the active toolset: the toolset selection lives in a scope that wraps tool registration, so anything asking whether a tool is active from a later callback saw the full catalog instead.
sqd://toolstherefore listed all 31 tools and routed callers through tools a trimmed deployment does not serve, and the investigation prompts andsqd://investigationsstill walked throughportal_evm_query_traceswith the EVM toolset switched off. The selection is now captured while registering and honoured afterwards: the guide lists, counts, starting points, and routes cover only registered tools, each investigation step names only the tools that are actually there and is dropped when none is, and a prompt is offered only when every step it cannot run without has a tool. The server instructions are built the same way: they named both Tron tools and four Explorer example tools unconditionally, and now name only what the selection registers and describe only the chain families it serves. Because these resources vary by connection, their cache hint isprivate, so a full-catalog client's copy is never replayed to a trimmed one. WithMCP_TOOLSETS=discovery,conveniencethe guide reports 7 tools of 7 registered, and no served surface names a tool that is missing: not the instructions, the tool guide's notes and routes, a tool entry's own advice, the investigation guide, or a rendered prompt. An investigation the connection cannot run is no longer published at all, and a prompt's network argument is built per connection: a Hyperliquid-only deployment used to suggestethereum-mainnetfor the market investigation, accept it, and render a Hyperliquid-only workflow beside it, so the first tool call failed against a dataset with no fills table. A network is now offered, and accepted, only when a tool that can query its chain is registered. A unit test extracts everyportal_*name from the instructions under nine selections, including an exact-nameMCP_TOOLSdeployment, which keeps every toolset while registering two tools and so defeated a check written against toolsets alone. - Truncated lists say so:
portal_resolve_entityranks its candidates and cuts the list tolimit, then reported the cut length asmatch_countunder the default coverage that declares a result complete, so a query matching 58 protocols answered as though 3 were all of them. Each resolver now returns how many candidates it matched. The answer, a notice,total_match_count, and_coverage.result_completeall state the truncation,_pagination.has_morestays false because there is no next page to request, and the caller is told to raise the limit or narrow the query. Pool resolution also bounds its own candidate search and skips a failed lookup, so its total counts what was searched rather than what exists; a bounded search now says so instead of reporting a complete result. A result that declares no continuation no longer draws the shared formatter's "continue with the cursor" sentence, which named a field the response does not carry. - Gates that actually gate: the repository had two lockfiles. CI installed from
package-lock.json, the Docker image installed frompnpm-lock.yaml, and nothing compared them, so the two trees drifted until a dependency difference broke the image build with every check green. There is now one lockfile: the image installs it withnpm ci, andnpm run test:lockfilesfails if a second lockfile appears, ifpackage.jsondrifts from it, or if the Dockerfile stops installing it. Three more gates were reporting success without checking:npm run test:workflow-pinsread a fixed seven lines after each checkout, so it failed a workflow that setpersist-credentials: falsebelow a longerwith:block and passed one that never set it at all; three rounds of review then holed its replacement three more times, each in a different way, so it no longer scans text at all. It parses each workflow as YAML and reads the step:usesmust be a 40-character SHA carrying a version comment, andactions/checkout(matched case-insensitively) must setpersist-credentialsto false as a key of its ownwith:mapping. Flow mappings, block scalars, comments, indentation, and line endings stop being the gate's problem. Twenty-four workflow fixtures, one per construct that fooled a previous version, now run before the real workflows do: twelve that must pass and twelve that must fail, so the gate is checked before it checks anything; CI skipped every check on a documentation-only pull request (the path filter is gone, and the gate reads those files anyway); and the Tron transaction summary assertions were nested inside anisErrorcheck, so an erroring summary skipped its own assertions instead of failing. - Real data everywhere: UI fixtures are recorded Portal responses (
scripts/record-app-fixtures.ts), andnpm run app:hostruns a local MCP Apps host with live tool calls for trying the Explorer.
Full Changelog: v0.8.4...v0.8.5