DEV Community

Baris Sozen
Baris Sozen

Posted on

What It Actually Takes to Let an AI Agent Click 'Connect' Instead of Copying an API Key

Yesterday we shipped @hashlock-tech/mcp v0.6.0. The headline feature is a hosted remote MCP server with one-click OAuth. The less glamorous but more important part is what had to ship alongside it: rate limiting and an untrusted-input flag, both landed the same day as the OAuth work. This post is about why those three things are one release, not three.

The problem with "just run the server locally"

Until this release, using Hashlock's MCP tools meant installing the package, running the stdio server, and configuring credentials by hand. That's a fine workflow for a developer testing tool calls in Claude Desktop. It's a bad workflow for an agent that needs to onboard a new counterparty, or for a team that wants to point five different agent frameworks at the same settlement backend without redistributing a secret to each one.

A hosted endpoint fixes the distribution problem. It creates a new one: now the endpoint is public, and "public MCP server that can trigger on-chain settlement" is a phrase that should make you nervous.

One header does the heavy lifting

The one-click part of "one-click OAuth" comes down to a single HTTP header. When an unauthenticated client hits the endpoint, it gets a 401. Previously that 401 was a bare bearer challenge: useful to a human reading logs, useless to a client trying to self-serve.

As of this release, the 401 carries resource_metadata in the WWW-Authenticate header, per RFC 9728 (OAuth 2.0 Protected Resource Metadata). An MCP client that understands this can fetch the metadata document, discover the authorization server, dynamically register itself, and run the OAuth 2.1 + PKCE flow, all without a human copying an API key into a config file.

The pointer comes from an MCP_PUBLIC_URL environment variable. If it's unset, the header degrades gracefully back to the old bearer challenge rather than advertising a wrong origin. Small detail, but it's the difference between "breaks obviously" and "breaks silently by pointing clients at a URL that doesn't exist."

Why the security commits aren't optional extras

We could have shipped OAuth alone and called it a feature release. We didn't, because making the endpoint public changes the threat model in two specific ways.

First: an unauthenticated endpoint needs its own rate limit. The API already rate-limits per API key, but that protection only activates once a credential is presented. Before this release, nothing stood in front of the endpoint itself. The new limiter buckets by API key when one is sent, and by client IP otherwise, so callers behind a shared NAT don't all share one bucket. The IP is read from the right-most entry in X-Forwarded-For, not the left-most: the left-most value is exactly what a caller would spoof and rotate to defeat the limit, which is the same finding our API's existing limiter already accounts for. It's in-memory with an opportunistic sweep, which is correct for a single container and would need a shared store if this ever runs scaled out. Default ceiling is 240 requests/minute, configurable via MCP_RATE_LIMIT_MAX.

Second: counterparty chat needed an explicit untrusted-input flag, not just a code comment. Hashlock's negotiation flow lets counterparties exchange messages as part of a trade. The stdio server carried a warning about this in a source comment, which is exactly as useful as a warning nobody reads, because it never reaches the model. Thread messages are the one field in the entire protocol that an adversarial counterparty fully controls. Left unflagged, text smuggled into a chat message is a real prompt-injection vector: it could push an agent to accept terms it shouldn't, or worse, to claim a leg before the counterparty has actually funded theirs. Claiming early reveals the hash preimage, which lets a bad-faith counterparty take the other leg and refund their own, turning an atomic swap into a one-sided loss. The fix ships the warning in both the tool result and the tool description, so it reaches the model at the point where it matters instead of sitting in a comment only a human reads.

Both of these commits are co-authored by Claude Opus 5 (1M context) in the git history, worth naming because it's a concrete instance of an AI model doing security review on the settlement paths it's also capable of calling, not a hypothetical.

What changed under the hood

For anyone tracking the release mechanically: server.json's registry version moved from 1.5.1 to 1.6.0, and packages[0].version tracks package.json directly, which the registry publish workflow verifies before anything goes out. Before this release, npm and the MCP Registry both sat at 0.5.1, a version that predates the hosted server, the OAuth flow, the rate limiter, and the untrusted-input notice entirely. Anyone who installed from npm got the stdio server with none of these fixes, security-relevant ones included.

What this doesn't change

To be precise about scope: this release is protocol and transport infrastructure. It doesn't add a new chain. Hashlock's HTLC settlement is live end-to-end on Ethereum mainnet; Sui contracts are deployed and CLI-tested with gateway wiring in progress; Bitcoin is validated on signet with mainnet pending. The hosted MCP endpoint makes it easier to reach those settlement paths. It doesn't change which chains are live.

Where this leaves things

The honest framing here is "rails ready, trains coming." A hosted MCP endpoint with one-click OAuth removes a real piece of onboarding friction for agents that want to hold their own keys instead of trusting a custodian with them. It doesn't yet tell you how many agents are actually connecting through it. That's a metric we'll have real numbers on in future updates, not this one.

If you're building an agent that needs cross-chain settlement without a custodian in the loop: what's the actual blocker right now? Local server setup, OAuth support in your MCP client, or something further upstream, like counterparty discovery?


Docs: https://hashlock.markets/docs?utm_source=devto&utm_medium=blog&utm_campaign=2026-08-14-hosted-mcp-oauth
GitHub: https://github.com/Hashlock-Tech/hashlock-mcp?utm_source=devto&utm_medium=blog&utm_campaign=2026-08-14-hosted-mcp-oauth
npm: https://www.npmjs.com/package/@hashlock-tech/mcp
SSRN: https://papers.ssrn.com/sol3/papers.cfm?abstract_id=6712722

Top comments (1)

Collapse
 
mads_hansen_27b33ebfee4c9 profile image
Mads Hansen

Shipping the warning in the tool result is much better than leaving it in a source comment, but I would be careful calling that a security fix. An untrusted label is still an instruction to the same model that is reading the attacker-controlled text. Prompt injection can make the model ignore, reinterpret, or route around the warning.

For the preimage-sensitive path, the hard boundary should live below the model: a settlement state machine that independently verifies both legs, required confirmations, amounts/assets/chain IDs, expiry, and counterparty identity before claim is even an available transition. Any approval should be bound to the exact swap-state digest and revalidated at execution. Then adversarial thread messages become test fixtures: they may change what the model says, but must never make the dispatcher reveal a preimage from an invalid state.

The IP fallback also deserves deployment-specific tests. “Use the right-most X-Forwarded-For value” is safe only for a known proxy topology that overwrites/appends the header as expected. With multiple proxies it may identify the nearest proxy, while a misconfigured edge may preserve attacker-controlled values. Defining trusted proxy hops and testing spoofed/multi-hop headers is part of the limiter's security contract.