feat(mcp): refine capability requests with MRTR - #419
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Greptile SummaryThis change adds a resumable capability-refinement experience for catalog misses, validates quote-ready briefs, and sends them to configured studios. The MCP flow was exercised through the real server dispatch path: a measured miss entered the consent flow, a decline completed without refinement or submission, and an accepted retry preserved the original query and intake details. Normal studio submission and quote polling also worked in the local HTTP interaction. However, quote polling misconstructs the URL when a studio returns an RFQ id containing URL-reserved characters, so the request can target the wrong quote resource. The normal per-studio POST and 201/202/200/404 handling was exercised and behaved as expected; only the reserved-character RFQ-id path was reproduced as broken. Confidence Score: 4/5Do not merge until quote-status URLs preserve returned RFQ ids as a single encoded path segment. The capability-refinement flow correctly retains state and requires consent before refinement or submission, but the reproduced quote-polling defect can direct requests to the wrong resource when a studio returns an identifier containing URL-reserved characters. Files Needing Attention: rust/crates/core/src/studios/mod.rs
What T-Rex did
|
| #[derive(Debug, Deserialize, JsonSchema)] | ||
| pub struct Params { | ||
| /// The capability the user wanted that no existing Pay provider covers. | ||
| #[schemars( | ||
| description = "The task or capability the user wanted that search_catalog/list_catalog found no usable provider for. Only call this after a real catalog miss and only when the user wants to commission a new one; do not call speculatively." | ||
| )] | ||
| pub query: String, | ||
| } |
There was a problem hiding this comment.
Pending RFQs cannot be polled without submitting another RFQ
After the tool returns a pending RFQ ID and tells the caller to check back later without resubmitting, the MCP input accepts only query. A later commission_capability call therefore constructs a new brief and submits a new RFQ before it polls, rather than retrieving the quote for the original RFQ ID. Expose an MCP-facing poll/retrieve operation that accepts the returned ID, or add a follow-up mode that polls an existing RFQ without calling submit_to_registry.
Artifacts
Two-call MCP pending-RFQ harness source
- Authored Python harness starts the Pay MCP stdio server and a local pending-quote studio, lists the tool schema, and performs two identical accepted commission calls; it verifies whether the second call retrieves or resubmits.
Two identical MCP calls created RFQ one and RFQ two
- Captured execution output shows the tool schema contains only `query`, then shows two identical calls POSTed two RFQs and each received exactly three quote polls, proving the second call resubmitted.
There was a problem hiding this comment.
Confirmed. A pending response currently tells the caller to check later, but the MCP surface has no read-only retrieval mode and a new call resubmits. This remains an open product/API blocker, separate from the session-state update.
EfeDurmaz16
left a comment
There was a problem hiding this comment.
The consent gate is properly ordered before any data collection or network send, the registry mirrors the skills.yaml pattern faithfully, and nothing beyond the documented brief fields leaves the machine. Five non-blocking hardening notes, none worth holding the PR for:
- commission_capability.rs:136 (and :173) - the model-supplied query is interpolated verbatim into the consent dialog; a length cap plus newline stripping keeps the trust boundary clean.
- commission_capability.rs:220 - a half-filled budget pair (amount without mint or vice versa) silently drops the ceiling from the RFQ, and an amount under 0.5 rounds to a 0 ceiling; rejecting the half-filled pair would be safer.
- studios/mod.rs:167 - non-2xx studio bodies are relayed untruncated into tool output; capping at a couple of KB keeps the verbatim-422 intent without the flooding vector.
- studios/mod.rs:128 (and :186) - default reqwest redirect policy replays the brief POST across 307/308 to any host or scheme; redirect(Policy::none()) closes the one silent https downgrade.
- studios/mod.rs:56 - a read error on an existing studios.yaml is treated as absent, overwritten with the default, and the RFQ goes to production scarce.sh; failing closed like the parse-error path would be safer (same corner exists in skills.yaml).
Verified by reading all six changed files at 05f8b47 with each finding independently re-verified; CI is green.
EfeDurmaz16
left a comment
There was a problem hiding this comment.
Re-approving at ecfa9c6 after the rename and the auto-elicit change. The auto-elicit path preserves the consent boundary: search_catalog routes through the same ask_consent elicitation and only runs the interview and submission on an explicit accept, with a clean text-hint fallback when the client lacks elicitation support. One note: a catalog miss now auto-opens the consent dialog with the model-constructed search query embedded in it, so the earlier follow-up about capping and delimiting the query in the dialog text got more relevant; still not blocking. The other four hardening notes from my previous review stand as tracked follow-ups.
| .user_agent(client_app.user_agent()) | ||
| .build() | ||
| .map_err(|e| Error::Config(format!("http client: {e}")))?; | ||
| let quote_url = format!("{}/{}/quote", rfq_url.trim_end_matches('/'), rfq_id); |
There was a problem hiding this comment.
Untrusted RFQ ID changes the quote endpoint
rfq_id is returned by the studio but is interpolated directly into the quote URL. An ID containing reserved URL characters can move the /quote suffix into a query string or otherwise alter the requested endpoint. The quote poll can therefore hit a different handler than rfqs/{id}/quote, producing incorrect quote status results or allowing a studio response to inject request parameters. Encode the ID as one URL path segment, or construct the URL with a path-segment API, before appending /quote.
Artifacts
- A Rust integration-test harness starts a local HTTP listener, calls the real poll_quote implementation, and records the received request target; the takeaway is that the test directly exercises the claimed URL-construction path.
- The benign harness run exited successfully and recorded `/api/v1/rfqs/rfq-123/quote`; the takeaway is that an ordinary ID reaches the intended quote endpoint.
- The malicious harness run exited successfully and recorded `/api/v1/rfqs/rfq-123?redirect=/admin/quote`; the takeaway is that an untrusted query delimiter changes the target queried.
There was a problem hiding this comment.
Confirmed. The current head still interpolates the studio-provided RFQ ID into the path. This remains an open blocker; it needs path-segment construction plus a reserved-character regression.
EfeDurmaz16
left a comment
There was a problem hiding this comment.
Re-approving at 35e78ee. The one-prompt redesign keeps the consent boundary intact: the wallet pubkey is read locally and fails fast, nothing is sent before an explicit elicitation Accept, decline exits cleanly, and the prompt copy discloses the wallet attribution and that nothing is charged before a quote is accepted. budget_from_usd resolves both of my earlier budget notes (single field, guarded, unit-tested), and the miss-detection gate is prompt-frequency tuning with bench evidence rather than a consent change. Still standing as follow-ups: capping the interpolated query in the pitch text, truncating relayed studio error bodies, redirect(Policy::none()) on the studio clients, and the studios.yaml read-error fail-open.
| if !path.exists() | ||
| || std::fs::read_to_string(&path) | ||
| .map(|raw| raw.trim().is_empty()) | ||
| .unwrap_or(true) | ||
| { | ||
| let cfg = Self::default(); | ||
| let _ = cfg.save(); // persist so the user can see/edit it | ||
| return Ok(cfg); |
There was a problem hiding this comment.
Studio configuration errors fall back to external submission
An existing unreadable or non-file ~/.config/pay/studios.yaml is treated as absent because read_to_string errors become true through unwrap_or(true), and the failed attempt to rewrite the default configuration is discarded. The resulting registry contains the shipped https://scarce.sh/api/v1/rfqs endpoint. request_capability accepts that non-empty registry and submits the user-approved brief there, so a local configuration failure silently becomes an external RFQ submission instead of failing closed. Propagate read and save errors for an existing configuration path; only seed the default when the path is genuinely absent.
Artifacts
Isolated HOME Rust registry harness source
- The authored narrow test creates missing and directory fixtures at studios.yaml, calls the real StudioRegistry::load path, and prints the returned registry; it demonstrates the exact fixture and assertions used.
Missing configuration baseline execution log
- The isolated-HOME Cargo test ran with no studios.yaml and returned the expected one-entry shipped scarce registry; it establishes the normal baseline.
Directory configuration failure-fixture execution log
- The isolated-HOME Cargo test ran with studios.yaml as a directory and still returned the shipped scarce endpoint while the directory remained; it confirms the fail-open defect.
There was a problem hiding this comment.
Resolved on the current branch in 8f3f3ed. Only a genuinely missing path seeds the default; read, save, empty-file, and parse failures now fail closed. The unreadable-directory and empty-file cases are covered.
| if !consume_once(state, &flow.id) { | ||
| return Ok(tool_error( | ||
| "This capability refinement was already submitted or attempted; start a new request instead of replaying it.", | ||
| ) | ||
| .into()); | ||
| } |
There was a problem hiding this comment.
Registry setup failure consumes resumable refinement
consume_once marks the flow as attempted before submit_refined loads the studio registry. If studios.yaml is malformed, empty, or unreadable, registry loading fails before any RFQ is submitted; after the user corrects the configuration, replaying the sealed refinement is rejected as already attempted. Consume the refinement only after registry setup succeeds, or preserve retry eligibility for failures that occur before submission.
Artifacts
Focused Rust test source for malformed registry replay
- Exact focused test source that uses an isolated HOME, causes a malformed-registry setup failure, corrects the registry, and replays the same sealed refinement state; it demonstrates the defect scenario.
Runtime capture of malformed registry failure and rejected replay
- Captured output of the focused Rust test, including command, working directory, exit code, malformed-registry response, corrected-config replay rejection, and zero RFQ POST count; it confirms the defect.
Paired runtime capture for corrected configuration replay
- Paired captured output retained under the required after filename, showing the corrected-config replay is rejected and no RFQ is submitted; it confirms configuration correction cannot recover the sealed refinement.
There was a problem hiding this comment.
Confirmed. Fixed in d04d771: registry load and non-empty validation now happen before consume_once, so local setup failures remain retryable while actual submission attempts stay replay-protected. Added a regression covering malformed, empty, valid, and replayed states.
Lets the MCP catalog surface a "no provider exists yet" miss into a studio commission: an elicitation-gated interview submits an RFQ to every registered studio (v0: hardcoded scarce.sh, overridable via ~/.config/pay/studios.yaml) and reports back a quote or pending status. search_catalog's empty-result guidance now names the tool without auto-invoking it. v0 only collects the fields scarce-studio's NewRfq already accepts (query/product/monetization/competition/budget_ceiling/buyer_npub); the richer cost-metrics interview lands once the studio side adds a brief object to schemas/rfq.json.
Drops "commission" from the pay-side capability-request flow to match the studio-side rename (scarce/studio#11): tool fn and file commission_capability -> request_capability, NewCommission -> NewCapabilityRequest, CommissionAmount -> BudgetAmount, and all consent/brief/error prose. search_catalog's miss-hint updated to point at the new name. Kept "commission-flow draft-00" as-is where it names the pre-existing design draft rather than the tool itself.
search_catalog now asks for consent itself when a query returns zero candidates, and on accept runs the same brief-interview + studio submission flow as request_capability inline, instead of only naming the tool in next_step text. Falls back to the old text hint when the connected client doesn't support elicitation. request_capability stays independently callable for direct asks.
search_catalog's consent gate keyed on empty candidates, which never happened: shared curation namespaces (solana-foundation/) gave every provider term credit and the provider-size bonus kept zero-match providers listed, so an air-quality API scored 96 on 'solana priority fee forecasts' and the offer could never fire. - scoring: namespace prefixes shared by 3+ providers earn no term credit; size bonus only applies to real matches; candidates carry a 'strong' fit flag (routing bench repinned 83.722 -> 83.117, top1 unchanged at 77.111 - the delta is pure noise listings) - gate: is_catalog_miss = hard miss, or specific query (>=4 meaningful terms) with only weak matches; vague weak-only searches get a next_step hint routing the model to request_capability instead of prompting (a bare no-strong gate would prompt on 56% of covered fuzzy searches, this fires on ~8% worst-case) - UX: consent + 6-field interview collapsed into one encouraging accept-to-send prompt with two optional fields (details, budget_usd); buyer identity is the Pay wallet's solana pubkey, never prompted (requires studio-side buyer_solana_pubkey support) - list_catalog next_step + server instructions route uncovered needs to search_catalog instead of dead-ending at 'no'
The copy users saw was the client model's own paraphrase ('Want me to
submit a capability request... I'd need your consent') — a chat-level
pre-ask before the tool ever ran, double-asking consent the elicitation
already owns, framed as paperwork.
- every model-facing string (server instructions, tool descriptions,
next_step hints) now says: call request_capability directly, never
pre-ask in chat — the tool's prompt IS the consent step
- framing seeded everywhere the model reads: a catalog gap is an
opportunity — a studio builds and deploys the API and the user
publishes and monetizes it — not a bureaucratic submission
- elicitation pitch upgraded to match: 'published under you, that other
agents pay to use; you found the demand; you can own the service and
monetize it'
… brief Rework the request_capability elicitation into a one-of-a-kind pitch, not a form: - Pitch is a short YC-style hook (<=256 chars, 3 lines): you found real demand, studios build & ship it, you publish and earn on every call. - Two free-form questions replace typed fields — "what do we want to build" and "what would you use today" — with examples and the why in the placeholders. No data-type constraints; answers are prose. - After Accept, the client's own local model (MCP sampling) structures the answers into the brief studios quote against, briefed by a new request_capability_brief.md skill (include_str!). Best-effort: raw answers ship if the client can't sample or times out. - In-flight status: rotating progress/logging notifications so the user sees what's happening during the sampling and submission stages instead of a frozen call. Progress token threaded from Meta through search_catalog too.
Co-authored-by: Ludo Galabru <ludo.galabru@solana.org> Signed-off-by: Ludo Galabru <ludo.galabru@solana.org>
Move reusable operator-session preparation and locking into pay-core, keep session state for the MCP process lifetime, and preserve x402 alternatives for unsupported session shapes. Co-authored-by: Ludo Galabru <ludo.galabru@solana.org> Signed-off-by: Ludo Galabru <ludo.galabru@solana.org>
c5ee3d9 to
08c7829
Compare
Validate the studio registry before consuming the sealed MRTR refinement so local setup errors remain retryable without weakening replay protection around network submission. Co-authored-by: Ludo Galabru <ludo.galabru@solana.org> Signed-off-by: Ludo Galabru <ludo.galabru@solana.org>
| .user_agent(client_app.user_agent()) | ||
| .build() | ||
| .map_err(|e| Error::Config(format!("http client: {e}")))?; | ||
| let quote_url = format!("{}/{}/quote", rfq_url.trim_end_matches('/'), rfq_id); |
There was a problem hiding this comment.
Quote polling interpolates the RFQ id into URL structure
rfq_id is inserted directly into the quote URL rather than encoded as a single path segment. A local HTTP interaction with the returned-style id rfq/a?probe=1 produced /rfqs/rfq/a?probe=1/quote: the slash changed the resource path and the question mark began a query. Quote polling can therefore request the wrong resource or send unintended query parameters when a studio returns an identifier with URL-reserved characters. Construct the URL with path-segment APIs, or percent-encode the id as exactly one segment before appending /quote.
Artifacts
Focused Rust RFQ registry and quote-polling HTTP contract test source
- A local TCP mock test submits RFQs to two studio-specific URLs and polls quote statuses, including a reserved-character RFQ id; it exercises the affected public functions directly.
RFQ contract test output before PR change
- The parent-revision test run completed with exit code 0, recorded 201 Created, 202 Accepted, 200 OK, and 404 Not Found behavior, and shows the reserved-character RFQ id was split into URL structure; the unsafe construction already existed before this PR.
RFQ contract test output after PR change
- The PR-head test run completed with exit code 0, preserved per-studio submission and normal polling behavior, and again shows the reserved-character RFQ id was split into URL structure; the quote-status path defect remains.
Summary
search_catalogon a measured miss; fail closed when the client lacks MRTR, form elicitation, or sampling-with-tools support.CapabilityBrief; integrity- and TTL-bind request state, replay-protect actual submission attempts, and keep local registry setup failures retryable.curlcalls. Session preparation and serialized credential state live inpay-coreand are shared with the payer proxy; x402 alternatives remain available for unsupported session shapes.rmcp3.1 while preserving request-associated payment approval flows.Test plan
cargo test --workspace -- --test-threads=1 --format=terse— 1,796 passed, 0 failed, 1 ignoredcargo fmt --all -- --checkcargo clippy --workspace --all-targets -- -D warningsCompatibility
Capability refinement requires MCP 2026-07-28 MRTR with form elicitation and sampling tools. Older clients receive an actionable unsupported-flow result and no RFQ is submitted. MCP session state is in-memory and renegotiates after process restart; explicit x402 selection remains supported.