Skip to content

feat(mcp): add Blossom media hosting tools - #557

Merged
tcheeric merged 4 commits into
mainfrom
feat/blossom-media-hosting
Sep 24, 2026
Merged

tcheeric merged 4 commits into
mainfrom
feat/blossom-media-hosting

Conversation

@tcheeric

Copy link
Copy Markdown
Owner

Summary

nostr-java-mcp had 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?

Tool Purpose
nostr_blossom_upload Re-host media from a URL, return the hosted URL
nostr_blossom_get Resolve a sha256 to a URL, size and type
nostr_blossom_list List the blobs a key has stored
nostr_blossom_delete Remove a blob from one server
nostr_blossom_get_servers Read a kind-10063 server list (BUD-03)
nostr_blossom_set_servers Publish a kind-10063 server list

Mirroring (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.java and BlobSource.java — they carry the only new trust boundary. Then ToolSurface.java for 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:

  • PublicHttpUrl refuses any URL whose host resolves to a loopback, link-local (including the 169.254.169.254 cloud metadata endpoint), site-local, any-local, multicast or IPv6 unique-local address — checking every resolved address, not just the first.
  • Neither HTTP client follows redirects. A redirect is the simplest way past an address check: the named URL resolves publicly and then points somewhere internal.
  • Transfers stop at 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 server argument. Servers the operator put in blossom.servers are exempt — configuring one is a person's decision, which is exactly what an agent-supplied URL is not. blossom.allow-private-hosts opts 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. WriteGuard gains authorizeWrite and signAs, 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 under write-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: deny server must still be able to answer "what have I uploaded".

Two things found against a real server, not read off the spec

  • blossom-server answers 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.
  • It keeps a replay cache of authorization event ids. A nostr event id is the hash of its contents, so two tokens for one action within a second were the same event and the second was refused 400 Auth event already used. Tokens now carry a random nonce. Two nostr_blossom_list calls in the same second were the worst case, a list token having no x tag to vary.

Breaking changes

None. New tools on the surface; existing behaviour is unchanged. WriteGuard.prepare is refactored to delegate to the new methods, with WriteGuardTest passing untouched.

Testing

  • Unit tests pass: mvn test — 196 in nostr-java-mcp, whole reactor green
  • Integration tests pass: mvn verify (requires Docker)

BlossomToolsIT runs against a real ghcr.io/hzrd149/blossom-server:4.4.1 container configured to require authorization on upload, delete and list, alongside nostr-rs-relay for 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 x tag (blob substitution), expired expiration, a list token 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

  1. Is URL-only the right call? It rules out the obvious use case of uploading a file the agent just produced. I think the filesystem-access alternative is worse, but it is the decision most worth challenging.
  2. blossom.allow-private-hosts is 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.
  3. Six tools is a 27% increase in the tool surface (22 → 28). Registration is gated on write policy, but all six appear whenever 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

  • PR title follows conventional commits: type(scope): description
  • Changes are focused and under 300 lines — no, this is ~3,900 lines. It is one feature with its tests and docs; splitting the protocol client from the tools that use it would produce a first PR that nothing calls.
  • Tests added/updated for new functionality
  • No new compiler warnings introduced
  • CHANGELOG.md updated (for user-facing changes)

Unrelated pre-existing failure, not addressed here: NostrMcpServerStdioIT.aHostCanSeeWhichIdentitiesTheServerHolds expects an empty keystore and fails on any machine with an identity in ~/.nostr-java/aliases. Confirmed by git stash that it fails identically on a clean checkout of main.

🤖 Generated with Claude Code

tcheeric and others added 4 commits August 31, 2026 23:35
.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>
@tcheeric
tcheeric merged commit b77a019 into main Sep 24, 2026
4 of 5 checks passed
@tcheeric
tcheeric deleted the feat/blossom-media-hosting branch September 24, 2026 13:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant