Repository navigation
feat(mcp): add Blossom media hosting tools - #557
Merged
Merged
Conversation
.scratch/ and create-roadmap-project.sh are local working files that are not part of the published build.
The FrameSequencer fix had no test that could fail without it. The existing ordering tests either drive the wallet's own listener directly, or — as NostrJavaRelayClientEoseOrderingTest says in its own javadoc — SUPPLY the ordering the production path is supposed to guarantee. This one hands frames to handleTextMessage the way a real connection does, back to back with no sleep, and asserts the EOSE is handled after every EVENT that preceded it. The listener parks briefly on each EVENT, and that detail is the whole test. A listener that only appends to a list finishes faster than the race needs: bypassing the sequencer with an append-only listener still passed. Real handling parses and verifies a signature, and that window is exactly what an EOSE on its own thread jumps. With the park in place, bypassing the sequencer fails at 'EOSE handled at position 140 of 201, so 60 EVENT(s) that arrived first were handled after it'. Downstream this is imani-wallet #36: a wallet fetches its gift wraps once when it opens and never asks again, so losing the race shows a customer an empty wallet while their coupons sit on the relay. Not a tail case — a wallet with one coupon has exactly one EVENT, so it is always the first.
Nostr events carry URLs, not bytes. Blossom is where the bytes live: HTTP servers storing blobs addressed by sha256, authorized by a signed kind-24242 event rather than an account. Six tools cover BUD-01, -02, -03, -11 and -12, so an agent can host media and get back a URL to put in a note. Uploads take a URL, never a local file path. A tool that read the server's filesystem would let an agent put any readable file on a public CDN addressed by its hash, from which it cannot be recalled; that is not a capability careful prompting makes safe. The cost is that media must already be reachable over http(s). Because the server does the fetching, this is the module's only server-side request forgery surface. PublicHttpUrl refuses any URL whose host resolves to a loopback, link-local (including the 169.254.169.254 metadata endpoint), site-local, any-local, multicast or IPv6 unique-local address, checking every resolved address rather than the first. Neither HTTP client follows redirects, since a redirect is the simplest way past such a check. Transfers stop at nostr.mcp.blossom.max-blob-bytes, because a blob is buffered in memory to be hashed before its upload token can commit to it. Writes inherit the existing guards by construction. WriteGuard gains authorizeWrite and signAs, split so that a write which must gather something expensive before it can be signed still settles policy and rate limit first: an upload past its quota is refused before the fetch, not after. Listing signs outside the guard, since BUD-12 wants a token for what is still a read and a read-only server must be able to make one. Two behaviours found against blossom-server 4.4.1 rather than read off the spec. It answers every upload with "size": 0, so the upload tool reports the byte count it actually sent. It keeps a replay cache of authorization event ids, and since a nostr event id is the hash of its contents, two tokens for one action in the same second were the same event; tokens now carry a nonce. BlossomToolsIT runs against a real blossom-server container requiring authorization on upload, delete and list, so the round trip also proves the base64url token this module builds is one an independent implementation accepts. Four negative controls break one field each - wrong x tag, expired expiration, a list token replayed against upload, an unusable token - because a suite that only ever presents a correct token would pass against a server that ignored authorization entirely. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
New tools on the MCP surface, backward compatible, so a minor bump. Also updates the install snippet in the multi-relay guide, which DocumentationAccuracyTest pins to the version actually built. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
9 of 10 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
nostr-java-mcphad no way to handle media. Nostr events carry URLs, not bytes, and the blobs those URLs point at live on separate HTTP servers. Blossom is the protocol for those servers — hash-addressed blob storage authorized by a signed Nostr event rather than an account.This adds six tools covering BUD-01, -02, -03, -11 and -12, so an agent can upload media and get back a URL to put in a note, using the keys the server already holds.
Type of change
feat- New feature (non-breaking)What changed?
nostr_blossom_uploadnostr_blossom_getnostr_blossom_listnostr_blossom_deletenostr_blossom_get_serversnostr_blossom_set_serversMirroring (BUD-04), media optimization (BUD-05) and upload pre-flight (BUD-06) are deliberately out of scope.
Where to start reviewing:
nostr/mcp/blossom/PublicHttpUrl.javaandBlobSource.java— they carry the only new trust boundary. ThenToolSurface.javafor what gets registered under which policy.Two commits: the feature, then the release bump.
Uploads take a URL, never a local file path
This is the significant design decision. A tool that read the server's filesystem would let an agent put any readable file — an SSH key, a
.env, a customer database — onto a public CDN addressed by its hash, from which it cannot be recalled. That is not a capability careful prompting makes safe, so it does not exist. The cost is that media must already be reachable over http(s).The SSRF guard
Because the server does the fetching, this is the module's only server-side request forgery surface:
PublicHttpUrlrefuses any URL whose host resolves to a loopback, link-local (including the169.254.169.254cloud metadata endpoint), site-local, any-local, multicast or IPv6 unique-local address — checking every resolved address, not just the first.nostr.mcp.blossom.max-blob-bytes(16 MiB default), because a blob is buffered in memory to be hashed before its upload token can commit to it.The check also applies to the agent-supplied
serverargument. Servers the operator put inblossom.serversare exempt — configuring one is a person's decision, which is exactly what an agent-supplied URL is not.blossom.allow-private-hostsopts out for self-hosted and LAN deployments.Known ceiling, marked in code: the guard resolves the name and the HTTP client resolves it again, so DNS rebinding between the two is not closed. The byte cap bounds what that could be worth.
Write safety
Upload, delete and set-servers are unregistered under
write-policy: deny.WriteGuardgainsauthorizeWriteandsignAs, split so a write that must gather something expensive before it can be signed still settles policy and rate limit first — an upload past its quota is refused before the fetch, not after. Deleting requires the confirmation token underwrite-policy: confirm, and that token is signed at confirm time rather than preview, because a BUD-11 token expires in 300s and the confirm step exists so a human can take longer than that.Listing deliberately signs outside the guard: BUD-12 wants a token for what is still a read, and a
write-policy: denyserver must still be able to answer "what have I uploaded".Two things found against a real server, not read off the spec
blossom-serveranswers every upload with"size": 0. The upload tool reports the byte count it actually sent, since telling an agent a file it just uploaded is empty is worse than useless.400 Auth event already used. Tokens now carry a randomnonce. Twonostr_blossom_listcalls in the same second were the worst case, a list token having noxtag to vary.Breaking changes
None. New tools on the surface; existing behaviour is unchanged.
WriteGuard.prepareis refactored to delegate to the new methods, withWriteGuardTestpassing untouched.Testing
mvn test— 196 innostr-java-mcp, whole reactor greenmvn verify(requires Docker)BlossomToolsITruns against a realghcr.io/hzrd149/blossom-server:4.4.1container configured to require authorization on upload, delete and list, alongsidenostr-rs-relayfor the kind-10063 tools. That matters: the round trip also proves the base64url kind-24242 token this module builds is one an independent implementation accepts.Four of the eleven cases are negative controls that break one field each — wrong
xtag (blob substitution), expiredexpiration, alisttoken replayed against/upload, an unusable token. Without them the suite would pass against a server that ignored authorization entirely.Also smoke-tested through the shipped runnable jar over stdio: upload returned a working URL, and with the guard at its default the metadata endpoint was refused with
INVALID_ARGUMENT.Review focus
blossom.allow-private-hostsis a blunt instrument — it disables the address check entirely rather than allowing a specific host. That is what the integration test needs and what a LAN deployment needs, but a per-host allowlist would be safer.write-policy != deny, including when no server is configured, since an agent can name a server it discovered in someone's BUD-03 list.Checklist
type(scope): descriptionUnrelated pre-existing failure, not addressed here:
NostrMcpServerStdioIT.aHostCanSeeWhichIdentitiesTheServerHoldsexpects an empty keystore and fails on any machine with an identity in~/.nostr-java/aliases. Confirmed bygit stashthat it fails identically on a clean checkout ofmain.🤖 Generated with Claude Code