From 19fda28993968353b7eb09db2a63816cb91c86b0 Mon Sep 17 00:00:00 2001 From: tcheeric Date: Mon, 31 Aug 2026 23:35:17 +0100 Subject: [PATCH 1/4] chore: ignore local scratch and roadmap tooling .scratch/ and create-roadmap-project.sh are local working files that are not part of the published build. --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 261b37d3..2d0651d7 100644 --- a/.gitignore +++ b/.gitignore @@ -229,3 +229,5 @@ data # Project management documents (local only) .project-management/ +.scratch/ +create-roadmap-project.sh From d985bcfad1f96f28dcf69369ff90c4b7f3467bdc Mon Sep 17 00:00:00 2001 From: tcheeric Date: Wed, 2 Sep 2026 00:53:59 +0100 Subject: [PATCH 2/4] test: pin the frame ordering guarantee against a listener that does work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../NostrRelayClientFrameOrderingTest.java | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 nostr-java-client/src/test/java/nostr/client/springwebsocket/NostrRelayClientFrameOrderingTest.java diff --git a/nostr-java-client/src/test/java/nostr/client/springwebsocket/NostrRelayClientFrameOrderingTest.java b/nostr-java-client/src/test/java/nostr/client/springwebsocket/NostrRelayClientFrameOrderingTest.java new file mode 100644 index 00000000..7639673a --- /dev/null +++ b/nostr-java-client/src/test/java/nostr/client/springwebsocket/NostrRelayClientFrameOrderingTest.java @@ -0,0 +1,96 @@ +package nostr.client.springwebsocket; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketSession; +import nostr.event.message.ReqMessage; + +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.TimeUnit; + +import static org.awaitility.Awaitility.await; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * A listener must be handed EVENT frames before the EOSE that followed them on + * the wire. + * + *

The failure this pins down. {@code handleTextMessage} is called + * sequentially per connection, so frames ARRIVE in order. Dispatch used to hand + * each one to a fresh virtual thread with nothing sequencing them, so they were + * HANDLED in any order. Every caller ends its query on EOSE, so an EOSE handled + * before an EVENT that reached the socket first meant that event was simply + * absent from the result — silently, and differently on each call.

+ * + *

Why it matters downstream. A wallet fetches its gift-wrapped + * messages once when it opens and does not ask again. Losing that race shows + * the customer an empty wallet while their coupons sit on the relay. It is not + * a tail case: a wallet with one coupon has exactly one EVENT, so that event is + * always the first, and the race is the whole interaction. Recorded downstream + * as imani-wallet #36, where byte-identical queries returned zero or one + * non-deterministically.

+ * + *

No sleep between frames, deliberately. A delay lets the first + * frame's thread get far enough that a monitor is enough to order them, which + * is the case the unordered code already passed while failing in production. + * The frames go in back to back, which is how a relay sends its stored events + * and then its EOSE.

+ */ +@DisplayName("frames reach a listener in the order the relay sent them") +class NostrRelayClientFrameOrderingTest { + + /** Enough frames that an unordered dispatch loses the race reliably. */ + private static final int EVENTS = 200; + + @Test + @DisplayName("EOSE is handled after every EVENT that preceded it") + void eoseNeverOvertakesEarlierEvents() throws Exception { + WebSocketSession session = Mockito.mock(WebSocketSession.class); + Mockito.when(session.isOpen()).thenReturn(true); + + try (NostrRelayClient client = new NostrRelayClient(session, 1_000)) { + List handled = new CopyOnWriteArrayList<>(); + // Each EVENT does a little work before being recorded. Real handling + // parses and verifies a signature, which is exactly the window an EOSE + // on its own thread can jump; a listener that only appends to a list + // finishes too fast for the race to show. + client.subscribe(new ReqMessage("sub-order"), frame -> { + if (frame.startsWith("[\"EVENT\"")) { + java.util.concurrent.locks.LockSupport.parkNanos(200_000L); + } + handled.add(frame); + }, t -> { }, null); + + for (int i = 0; i < EVENTS; i++) { + client.handleTextMessage(session, + new TextMessage("[\"EVENT\",\"sub-order\",{\"id\":\"e" + i + "\"}]")); + } + client.handleTextMessage(session, new TextMessage("[\"EOSE\",\"sub-order\"]")); + + await().atMost(10, TimeUnit.SECONDS) + .until(() -> handled.stream().anyMatch(f -> f.startsWith("[\"EOSE\""))); + + int eoseAt = -1; + for (int i = 0; i < handled.size(); i++) { + if (handled.get(i).startsWith("[\"EOSE\"")) { + eoseAt = i; + break; + } + } + + assertTrue(eoseAt >= 0, "the EOSE was never handled"); + + // The whole contract in one number: everything sent before the EOSE must + // have been handed over before it. One EVENT landing after is one coupon + // a customer never sees. + assertEquals(EVENTS, eoseAt, + "EOSE was handled at position " + eoseAt + " of " + handled.size() + + ", so " + (EVENTS - eoseAt) + " EVENT(s) that arrived first were " + + "handled after it. A caller returning on EOSE drops those."); + } + } +} From 2d83d678a764c4026d19a5226f764204e2451279 Mon Sep 17 00:00:00 2001 From: tcheeric Date: Mon, 21 Sep 2026 20:30:03 +0100 Subject: [PATCH 3/4] feat(mcp): add Blossom media hosting tools 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) --- docs/explanation/nostr-java-mcp-spec.md | 69 +++ docs/howto/run-the-mcp-server.md | 40 ++ .../main/java/nostr/mcp/McpConfiguration.java | 56 ++ .../java/nostr/mcp/NostrMcpApplication.java | 22 +- .../nostr/mcp/blossom/BlobDescriptor.java | 64 ++ .../java/nostr/mcp/blossom/BlobSource.java | 200 +++++++ .../java/nostr/mcp/blossom/BlossomAuth.java | 112 ++++ .../java/nostr/mcp/blossom/BlossomClient.java | 262 ++++++++ .../nostr/mcp/blossom/BlossomServers.java | 162 +++++ .../java/nostr/mcp/blossom/BlossomVerb.java | 45 ++ .../java/nostr/mcp/blossom/PublicHttpUrl.java | 130 ++++ .../java/nostr/mcp/identity/SigningAlias.java | 81 +++ .../main/java/nostr/mcp/tool/BlobHash.java | 60 ++ .../nostr/mcp/tool/BlossomDeleteTool.java | 218 +++++++ .../nostr/mcp/tool/BlossomGetBlobTool.java | 144 +++++ .../java/nostr/mcp/tool/BlossomListTool.java | 157 +++++ .../nostr/mcp/tool/BlossomServerListTool.java | 173 ++++++ .../nostr/mcp/tool/BlossomSetServersTool.java | 97 +++ .../nostr/mcp/tool/BlossomUploadTool.java | 182 ++++++ .../main/java/nostr/mcp/tool/ToolFailure.java | 8 +- .../main/java/nostr/mcp/tool/ToolSurface.java | 19 +- .../main/java/nostr/mcp/write/WriteGuard.java | 101 ++-- .../test/java/nostr/mcp/HttpTransportIT.java | 7 +- .../java/nostr/mcp/McpConfigurationTest.java | 2 +- .../java/nostr/mcp/NostrMcpServerStdioIT.java | 6 + .../nostr/mcp/blossom/BlobSourceTest.java | 132 +++++ .../nostr/mcp/blossom/BlossomAuthTest.java | 136 +++++ .../nostr/mcp/blossom/BlossomClientTest.java | 258 ++++++++ .../nostr/mcp/blossom/BlossomServersTest.java | 115 ++++ .../nostr/mcp/blossom/PublicHttpUrlTest.java | 87 +++ .../nostr/mcp/blossom/StubHttpServer.java | 106 ++++ .../nostr/mcp/integration/BlossomToolsIT.java | 559 ++++++++++++++++++ .../nostr/mcp/integration/OllamaAgentIT.java | 18 +- .../mcp/tool/ToolSurfaceSecrecyTest.java | 7 +- .../java/nostr/mcp/tool/ToolSurfaceTest.java | 23 +- .../test/resources/blossom-server-config.yml | 43 ++ .../src/test/resources/tool-list-bound.txt | 6 + .../src/test/resources/tool-list-default.txt | 6 + .../tool-list-no-identity-mutation.txt | 6 + .../test/resources/tool-list-read-only.txt | 3 + 40 files changed, 3875 insertions(+), 47 deletions(-) create mode 100644 nostr-java-mcp/src/main/java/nostr/mcp/blossom/BlobDescriptor.java create mode 100644 nostr-java-mcp/src/main/java/nostr/mcp/blossom/BlobSource.java create mode 100644 nostr-java-mcp/src/main/java/nostr/mcp/blossom/BlossomAuth.java create mode 100644 nostr-java-mcp/src/main/java/nostr/mcp/blossom/BlossomClient.java create mode 100644 nostr-java-mcp/src/main/java/nostr/mcp/blossom/BlossomServers.java create mode 100644 nostr-java-mcp/src/main/java/nostr/mcp/blossom/BlossomVerb.java create mode 100644 nostr-java-mcp/src/main/java/nostr/mcp/blossom/PublicHttpUrl.java create mode 100644 nostr-java-mcp/src/main/java/nostr/mcp/identity/SigningAlias.java create mode 100644 nostr-java-mcp/src/main/java/nostr/mcp/tool/BlobHash.java create mode 100644 nostr-java-mcp/src/main/java/nostr/mcp/tool/BlossomDeleteTool.java create mode 100644 nostr-java-mcp/src/main/java/nostr/mcp/tool/BlossomGetBlobTool.java create mode 100644 nostr-java-mcp/src/main/java/nostr/mcp/tool/BlossomListTool.java create mode 100644 nostr-java-mcp/src/main/java/nostr/mcp/tool/BlossomServerListTool.java create mode 100644 nostr-java-mcp/src/main/java/nostr/mcp/tool/BlossomSetServersTool.java create mode 100644 nostr-java-mcp/src/main/java/nostr/mcp/tool/BlossomUploadTool.java create mode 100644 nostr-java-mcp/src/test/java/nostr/mcp/blossom/BlobSourceTest.java create mode 100644 nostr-java-mcp/src/test/java/nostr/mcp/blossom/BlossomAuthTest.java create mode 100644 nostr-java-mcp/src/test/java/nostr/mcp/blossom/BlossomClientTest.java create mode 100644 nostr-java-mcp/src/test/java/nostr/mcp/blossom/BlossomServersTest.java create mode 100644 nostr-java-mcp/src/test/java/nostr/mcp/blossom/PublicHttpUrlTest.java create mode 100644 nostr-java-mcp/src/test/java/nostr/mcp/blossom/StubHttpServer.java create mode 100644 nostr-java-mcp/src/test/java/nostr/mcp/integration/BlossomToolsIT.java create mode 100644 nostr-java-mcp/src/test/resources/blossom-server-config.yml diff --git a/docs/explanation/nostr-java-mcp-spec.md b/docs/explanation/nostr-java-mcp-spec.md index c6d6400e..d0a00b12 100644 --- a/docs/explanation/nostr-java-mcp-spec.md +++ b/docs/explanation/nostr-java-mcp-spec.md @@ -197,6 +197,12 @@ tool it cannot misuse, and the tool list itself tells the agent what this server | `nostr_remove_identity` | Forget a key, irreversibly (§6.3) | `alias`, `confirmationToken` | | `nostr_list_relays` | Configured relays and connection state | — | | `nostr_relay_info` | NIP-11 relay metadata | `relay` | +| `nostr_blossom_upload` | Re-host media from a URL on a Blossom server (§6.6) | `sourceUrl`, `server?`, `identity?` | +| `nostr_blossom_get` | Resolve a blob hash to a URL, size and type | `sha256`, `server?` | +| `nostr_blossom_list` | List the blobs a key has stored | `pubkey?`, `server?`, `identity?` | +| `nostr_blossom_delete` | Remove a blob from one server | `sha256`, `server?`, `identity?`, `confirmationToken?` | +| `nostr_blossom_get_servers` | Read a kind-10063 server list (BUD-03) | `pubkey?` | +| `nostr_blossom_set_servers` | Publish a kind-10063 server list | `servers`, `identity?` | ### 6.1 Long-lived subscriptions @@ -490,6 +496,69 @@ requires a bound server to administer anything: Both drive the same `IdentityStore` (§6.3), so there is one implementation of the lifecycle and two front doors to it. +### 6.6 Blossom media hosting + +Nostr events carry URLs, not bytes. [Blossom](https://github.com/hzrd149/blossom) is where +the bytes live: HTTP servers storing blobs addressed by sha256, authorized by a signed +kind-24242 event (BUD-11) rather than an account. Six tools cover BUD-01, -02, -03, -11 and +-12; mirroring (BUD-04), media optimization (BUD-05) and upload pre-flight (BUD-06) are not +implemented. + +**Uploads take a URL, never a file.** `nostr_blossom_upload` fetches `sourceUrl` and re-hosts +it. There is deliberately no local-path argument. 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. The capability is not one +that careful prompting makes safe, so it does not exist. The cost is that media must already +be reachable over http(s); the benefit is that the worst case is a public file being copied to +a public server. + +Because the server does the fetching, this is the module's only server-side request forgery +surface, and it is bounded in three ways: + +- **Address check.** `PublicHttpUrl` resolves the host and refuses if *any* resolved address + is loopback, link-local (including the `169.254.169.254` cloud metadata endpoint), + site-local, any-local, multicast or IPv6 unique-local. Every address is checked, not just + the first, since a name answering with one public and one private address is the cheapest + way past a check that stops at the first. +- **No redirects.** A redirect is the simplest way past an address check: the named URL + resolves publicly, then points at link-local. Following one safely would mean re-running the + guard at every hop, so the destination is reported to the agent instead. +- **Byte cap.** `blossom.max-blob-bytes` (16 MiB default). Blobs are buffered in memory + because BUD-11 requires the hash in the upload token and the hash is not known until the + last byte is read. + +The check applies to the agent-supplied `server` argument as well, since that is equally a URL +an agent chose. Servers named in `blossom.servers` are exempt: configuring one is a person's +decision. `blossom.allow-private-hosts` turns the check off for self-hosted and LAN +deployments. + +Known ceiling: the guard resolves the name and the HTTP client resolves it again, so DNS +rebinding between the two calls is not closed. Closing it needs an `HttpClient` with a pinned +resolver; the byte cap bounds what it could be worth. + +Upload, delete and set-servers are write tools — unregistered under `write-policy: deny`, and +every signed 24242 token passes through `WriteGuard.signWriteToken`, so it counts against +`limits.writes-per-minute` exactly as a published note does. Uploading is publishing. Deleting +requires the confirmation token under `write-policy: confirm`. + +Listing deliberately does not go through the write guard. BUD-12 wants a signed `list` token +even though listing is a read, and a `write-policy: deny` server must still be able to answer +"what have I uploaded". + +Two interoperability notes, both observed against `blossom-server` 4.4.1 rather than read off +the spec: + +- It answers every upload with `"size": 0`. The upload tool reports the byte count it actually + sent instead, since telling an agent a file it just uploaded is empty is worse than useless. +- It sends no `Content-Length` or `Content-Type` on `HEAD /`, which BUD-01 asks for. + `nostr_blossom_get` omits the size rather than reporting zero. +- It keeps a replay cache of authorization event ids and answers a reused one with + `400 Auth event already used`. A nostr event's id is the hash of its contents, so two tokens + for the same verb and blob built in the same second were byte-identical and the second request + failed. `BlossomAuth` therefore adds a random `nonce` tag. A list token carries no `x` tag to + vary, so two listings in one second were the worst case. Found by the integration test, not by + reading the spec. + ### 6.4 Resources and prompts - **Resources**: `nostr://identity/{alias}` (public key, npub, configured relays), diff --git a/docs/howto/run-the-mcp-server.md b/docs/howto/run-the-mcp-server.md index c8a03d41..f7371f0b 100644 --- a/docs/howto/run-the-mcp-server.md +++ b/docs/howto/run-the-mcp-server.md @@ -209,6 +209,43 @@ All settings are `nostr.mcp.*` system properties, or the same name in the enviro | `limits.max-subscriptions` | `20` | Open subscriptions allowed | | `limits.subscription-buffer` | `500` | Events held per subscription between reads | | `limits.subscription-idle-timeout` | `1h` | When an unread subscription is closed | +| `blossom.servers` | none | Comma-separated Blossom server URLs, most trusted first | +| `blossom.max-blob-bytes` | `16777216` | Largest blob the server will fetch and forward | +| `blossom.allow-private-hosts` | `false` | Let Blossom reach addresses that are not publicly routable | + +## Hosting media with Blossom + +[Blossom](https://github.com/hzrd149/blossom) servers store files addressed by their sha256 +hash, authorized by a signed Nostr event rather than an account. Point the server at one or +more and the agent can upload media and get back a URL to put in a note: + +``` +-Dnostr.mcp.blossom.servers=https://blossom.primal.net,https://cdn.satellite.earth +``` + +The tools are `nostr_blossom_upload`, `nostr_blossom_get`, `nostr_blossom_list`, +`nostr_blossom_delete`, and `nostr_blossom_get_servers` / `nostr_blossom_set_servers` for the +BUD-03 list a user publishes as kind 10063. Upload, delete and set-servers are write tools: +they disappear under `write-policy: deny` and count against `limits.writes-per-minute`. +Deleting needs confirming under `write-policy: confirm`. + +**Uploads take a URL, not a file.** `nostr_blossom_upload` fetches `sourceUrl` and re-hosts it. +There is deliberately no way to upload a local file: a tool that read the server's disk would +let an agent put any readable file on a public CDN, and no wording in a prompt makes that a +safe capability to hand out. The media has to be reachable over http(s) already. + +Because the server does the fetching, it refuses URLs that resolve anywhere but the public +internet — loopback, link-local (including the `169.254.169.254` cloud metadata endpoint) and +private ranges — and refuses to follow redirects, since a redirect is the simplest way past +that check. It also stops reading at `blossom.max-blob-bytes`, because a blob is held in +memory while its hash is computed. + +Set `blossom.allow-private-hosts=true` if you are running a Blossom server on your own machine +or LAN. It turns the address check off entirely, so only set it where the agent reaching the +rest of your network is acceptable. + +Servers listed in `blossom.servers` are exempt from that check: configuring one is a person's +decision, which is exactly what a URL an agent supplies is not. ## Limits worth knowing about @@ -227,6 +264,9 @@ These are properties of the underlying SDK and the protocol, not settings you ca - **The HTTP transport has no authentication.** See [Run it over HTTP](#run-it-over-http). - **Deletion is advisory.** NIP-09 asks relays to forget an event; it cannot compel them. Treat anything published as permanent, which is why `write-policy: confirm` is the default. +- **Deleting a blob frees one server.** A Blossom blob is addressed by its hash and may have + been copied anywhere, so `nostr_blossom_delete` removes it from the server named and nothing + else. It is not a way to unpublish something. ## Checking that a model can still use the tools diff --git a/nostr-java-mcp/src/main/java/nostr/mcp/McpConfiguration.java b/nostr-java-mcp/src/main/java/nostr/mcp/McpConfiguration.java index 7909005c..93172e61 100644 --- a/nostr-java-mcp/src/main/java/nostr/mcp/McpConfiguration.java +++ b/nostr-java-mcp/src/main/java/nostr/mcp/McpConfiguration.java @@ -33,6 +33,7 @@ public final class McpConfiguration { private static final String DEFAULT_KEYSTORE_TYPE = "os-keychain"; private static final int DEFAULT_WRITES_PER_MINUTE = 10; private static final String DEFAULT_TRANSPORT = "stdio"; + private static final long DEFAULT_MAX_BLOB_BYTES = 16L * 1024 * 1024; private static final String HTTP_TRANSPORT = "http"; private static final int DEFAULT_HTTP_PORT = 8080; @@ -240,6 +241,61 @@ public RateLimit writeRateLimit(java.time.Clock clock) { clock); } + /** + * The Blossom servers this deployment uses, most preferred first. + * + *

Empty by default. An agent can still name a server per call, but configuring them is + * what lets it upload without being told a URL, and a configured server is trusted in a way + * an agent-supplied one is not. + * + * @return the configured server URLs + */ + public List blossomServers() { + return commaSeparated("blossom.servers", List.of()); + } + + /** + * The largest blob this server will fetch and forward. + * + *

A blob is held in memory while its hash is computed, so this is the bound on what one + * upload can cost the process, not merely a policy about file sizes. + * + * @return the cap in bytes + */ + public long blossomMaxBlobBytes() { + // Clamped to what a single array can hold. A larger setting could not be honoured anyway, + // and silently truncating it would turn "4 GiB" into a 2 GiB buffer that takes the process + // out rather than a refusal the operator can see. + return Math.min(positiveLongOr("blossom.max-blob-bytes", DEFAULT_MAX_BLOB_BYTES), Integer.MAX_VALUE - 1L); + } + + /** + * Whether Blossom may fetch from addresses that are not publicly routable. + * + *

Off by default, and the default is the safe one: with it on, an agent can point the + * upload tool at anything this server can reach, including a cloud metadata endpoint. It + * exists because a self-hosted or LAN deployment is a real and reasonable thing to run. + * + * @return true when private addresses are permitted + */ + public boolean blossomAllowsPrivateHosts() { + return Boolean.parseBoolean(settingOr("blossom.allow-private-hosts", "false")); + } + + /** Reads a positive whole number, ignoring a value that makes no sense. */ + private static long positiveLongOr(String key, long fallback) { + String value = setting(key); + if (value == null || value.isBlank()) { + return fallback; + } + try { + long parsed = Long.parseLong(value.trim()); + return parsed > 0 ? parsed : fallback; + } catch (NumberFormatException e) { + return fallback; + } + } + public QueryLimits queryLimits() { QueryLimits defaults = QueryLimits.defaults(); return new QueryLimits( diff --git a/nostr-java-mcp/src/main/java/nostr/mcp/NostrMcpApplication.java b/nostr-java-mcp/src/main/java/nostr/mcp/NostrMcpApplication.java index 2ba6b6dd..81942fb3 100644 --- a/nostr-java-mcp/src/main/java/nostr/mcp/NostrMcpApplication.java +++ b/nostr-java-mcp/src/main/java/nostr/mcp/NostrMcpApplication.java @@ -7,6 +7,9 @@ import nostr.mcp.cli.KeyAdminCli; import nostr.mcp.identity.IdentityLifecycle; import nostr.mcp.identity.IdentityStore; +import nostr.mcp.blossom.BlobSource; +import nostr.mcp.blossom.BlossomServers; +import nostr.mcp.blossom.PublicHttpUrl; import nostr.mcp.identity.IdentityVault; import nostr.mcp.identity.KeySource; import nostr.mcp.identity.KeySources; @@ -84,7 +87,10 @@ public static void main(String[] args) throws InterruptedException { configuration.identityPolicy(), subscriptions, new McpDirectMessageService( - identityVault, relayPool, configuration.identitiesPermittedToDecrypt())); + identityVault, relayPool, configuration.identitiesPermittedToDecrypt()), + blossomServers(configuration), + new BlobSource( + publicHttpUrl(configuration), configuration.blossomMaxBlobBytes())); if (configuration.usesHttpTransport()) { serveOverHttp(configuration, registry, subscriptions); @@ -229,4 +235,18 @@ private static RelayConnection connectToRelay(String relayUri) throws IOExceptio throw new IOException("Could not connect to relay " + relayUri, e.getCause()); } } + + /** + * The Blossom servers this deployment uses. + * + * @param configuration the settings the server started with + * @return the configured servers, and the guard applied to any the agent names instead + */ + private static BlossomServers blossomServers(McpConfiguration configuration) { + return new BlossomServers(configuration.blossomServers(), publicHttpUrl(configuration)); + } + + private static PublicHttpUrl publicHttpUrl(McpConfiguration configuration) { + return new PublicHttpUrl(configuration.blossomAllowsPrivateHosts()); + } } diff --git a/nostr-java-mcp/src/main/java/nostr/mcp/blossom/BlobDescriptor.java b/nostr-java-mcp/src/main/java/nostr/mcp/blossom/BlobDescriptor.java new file mode 100644 index 00000000..311e78ac --- /dev/null +++ b/nostr-java-mcp/src/main/java/nostr/mcp/blossom/BlobDescriptor.java @@ -0,0 +1,64 @@ +package nostr.mcp.blossom; + +import com.fasterxml.jackson.databind.JsonNode; +import lombok.NonNull; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * What a Blossom server says about one stored blob. + * + *

BUD-02 fixes the five fields, and the {@code url} is the one that matters to a caller: it + * is the link that can be put in a note. The hash is carried alongside so the caller can check + * that the server stored what was sent rather than something it decided to re-encode. + * + * @param url where the blob can be fetched, extension included + * @param sha256 the blob's hash, lowercase hex + * @param size the blob's size in bytes + * @param type the blob's MIME type + * @param uploaded when the server stored it, as a unix timestamp + */ +public record BlobDescriptor(String url, String sha256, long size, String type, long uploaded) { + + private static final String FALLBACK_TYPE = "application/octet-stream"; + + /** + * Read a descriptor from a server's response. + * + *

Missing fields are tolerated rather than fatal. Servers add fields freely and some omit + * the ones BUD-02 calls for; refusing a response whose {@code uploaded} is absent would fail an + * upload that actually succeeded, which is the worst outcome available here. + * + * @param node the JSON object the server returned + * @return the descriptor it describes + */ + public static BlobDescriptor from(@NonNull JsonNode node) { + return new BlobDescriptor( + node.path("url").asText(""), + node.path("sha256").asText(""), + node.path("size").asLong(0), + text(node, "type", FALLBACK_TYPE), + node.path("uploaded").asLong(0)); + } + + /** + * Render this descriptor for a tool result. + * + * @return the fields, in the order a reader wants them + */ + public Map asStructuredContent() { + Map structured = new LinkedHashMap<>(); + structured.put("url", url); + structured.put("sha256", sha256); + structured.put("size", size); + structured.put("type", type); + structured.put("uploaded", uploaded); + return structured; + } + + private static String text(JsonNode node, String field, String fallback) { + String value = node.path(field).asText(""); + return value.isBlank() ? fallback : value; + } +} diff --git a/nostr-java-mcp/src/main/java/nostr/mcp/blossom/BlobSource.java b/nostr-java-mcp/src/main/java/nostr/mcp/blossom/BlobSource.java new file mode 100644 index 00000000..af495f34 --- /dev/null +++ b/nostr-java-mcp/src/main/java/nostr/mcp/blossom/BlobSource.java @@ -0,0 +1,200 @@ +package nostr.mcp.blossom; + +import lombok.NonNull; +import nostr.mcp.tool.ToolFailure; +import nostr.util.NostrUtil; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.security.NoSuchAlgorithmException; +import java.time.Duration; +import java.util.Locale; +import java.util.Optional; + +/** + * Fetches the bytes an agent asked to have uploaded. + * + *

This is the one place in the module where the server retrieves something an agent named, so + * it is where the request stops being a message and starts being an action taken on the server's + * own network. Two things bound it: {@link PublicHttpUrl} decides where it may go, and a byte + * cap decides how much it will hold. + * + *

The bytes are buffered rather than streamed because BUD-11 requires the blob's hash in the + * upload token, and the hash cannot be known until the last byte has been read. The cap is what + * makes buffering safe. + */ +public final class BlobSource { + + private static final Duration TIMEOUT = Duration.ofSeconds(30); + private static final String FALLBACK_TYPE = "application/octet-stream"; + + private final HttpClient httpClient; + private final PublicHttpUrl publicHttpUrl; + private final long maxBytes; + + /** + * @param publicHttpUrl the guard deciding which URLs may be fetched + * @param maxBytes the largest blob this server will carry + */ + public BlobSource(@NonNull PublicHttpUrl publicHttpUrl, long maxBytes) { + this( + HttpClient.newBuilder() + .followRedirects(HttpClient.Redirect.NEVER) + .connectTimeout(TIMEOUT) + .build(), + publicHttpUrl, + maxBytes); + } + + /** + * @param httpClient the client to fetch with, so a test need not reach the network + * @param publicHttpUrl the guard deciding which URLs may be fetched + * @param maxBytes the largest blob this server will carry + */ + public BlobSource( + @NonNull HttpClient httpClient, @NonNull PublicHttpUrl publicHttpUrl, long maxBytes) { + this.httpClient = httpClient; + this.publicHttpUrl = publicHttpUrl; + this.maxBytes = maxBytes; + } + + /** + * Fetch a blob by URL. + * + * @param sourceUrl where the media currently lives + * @return the bytes, their type and their hash + * @throws nostr.mcp.tool.ToolException when the URL is refused, unreachable, or too large + */ + public Blob fetch(@NonNull String sourceUrl) { + URI uri = publicHttpUrl.require("sourceUrl", sourceUrl); + HttpRequest request = HttpRequest.newBuilder(uri).timeout(TIMEOUT).GET().build(); + try { + HttpResponse response = + httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream()); + // The body is closed on every path, not only the one that reads it: a refused redirect + // and a non-2xx both throw before readCapped opens it, and an unclosed stream holds its + // connection until garbage collection. + try (InputStream body = response.body()) { + refuseRedirect(response, sourceUrl); + requireSuccess(response, sourceUrl); + byte[] bytes = readCapped(body, sourceUrl); + return new Blob(bytes, contentTypeOf(response), hashOf(bytes)); + } + } catch (IOException e) { + throw ToolFailure.BLOB_SERVER_UNREACHABLE.raise( + "Could not fetch " + sourceUrl + ": " + e.getMessage()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw ToolFailure.TIMEOUT.raise("Interrupted while fetching " + sourceUrl); + } + } + + /** + * Refuses to follow a redirect, rather than following it somewhere that was never checked. + * + *

A redirect is the simplest way past an address check: the URL the agent gives resolves + * publicly, and the server it reaches answers {@code 302 Location: http://169.254.169.254/}. + * Following it safely would mean re-running the guard at every hop, so the agent is handed the + * destination and can pass it back if it is a URL they actually meant. + */ + private void refuseRedirect(HttpResponse response, String sourceUrl) { + int status = response.statusCode(); + if (status < 300 || status >= 400) { + return; + } + String location = response.headers().firstValue("Location").orElse("(none given)"); + throw ToolFailure.INVALID_ARGUMENT.raise( + sourceUrl + + " redirects to " + + location + + ", and redirects are not followed because the destination was never checked. Pass" + + " the final URL instead."); + } + + private void requireSuccess(HttpResponse response, String sourceUrl) { + int status = response.statusCode(); + if (status < 200 || status >= 300) { + throw ToolFailure.BLOB_SERVER_UNREACHABLE.raise( + sourceUrl + " answered with HTTP " + status); + } + } + + /** + * Reads the body, stopping one byte past the cap. + * + *

Reading one byte more than is allowed is what distinguishes "exactly at the limit" from + * "over it" without trusting {@code Content-Length}, which a server is free to understate. + */ + private byte[] readCapped(InputStream body, String sourceUrl) throws IOException { + { + byte[] bytes = body.readNBytes(oneMoreThanTheCap()); + if (bytes.length > maxBytes) { + throw ToolFailure.INVALID_ARGUMENT.raise( + sourceUrl + + " is larger than this server will carry (" + + maxBytes + + " bytes). Raise nostr.mcp.blossom.max-blob-bytes to allow more."); + } + if (bytes.length == 0) { + throw ToolFailure.INVALID_ARGUMENT.raise(sourceUrl + " returned no content"); + } + return bytes; + } + } + + /** + * How many bytes to ask for: one past the cap, without overflowing. + * + *

Reading one more than is allowed is what tells "exactly at the limit" from "over it". + * Computing it as {@code maxBytes + 1} wraps negative at {@code Long.MAX_VALUE}, and casting + * a multi-gigabyte cap to {@code int} truncates it silently, so both ends are clamped here. + */ + private int oneMoreThanTheCap() { + return (int) Math.min(maxBytes, Integer.MAX_VALUE - 1L) + 1; + } + + private String contentTypeOf(HttpResponse response) { + return response + .headers() + .firstValue("Content-Type") + .map(type -> type.split(";")[0].trim().toLowerCase(Locale.ROOT)) + .filter(type -> !type.isEmpty()) + .orElse(FALLBACK_TYPE); + } + + private String hashOf(byte[] bytes) { + try { + return NostrUtil.bytesToHex(NostrUtil.sha256(bytes)); + } catch (NoSuchAlgorithmException e) { + throw ToolFailure.INVALID_ARGUMENT.raise("This JVM cannot compute SHA-256: " + e.getMessage()); + } + } + + /** + * A blob held in memory, on its way to a server. + * + * @param bytes the blob itself + * @param contentType its MIME type, as the source served it + * @param sha256 its hash, lowercase hex + */ + public record Blob(byte[] bytes, String contentType, String sha256) { + + /** + * @return how many bytes the blob is + */ + public int size() { + return bytes.length; + } + + /** + * @return the type, never null + */ + public String contentTypeOrDefault() { + return Optional.ofNullable(contentType).filter(type -> !type.isBlank()).orElse(FALLBACK_TYPE); + } + } +} diff --git a/nostr-java-mcp/src/main/java/nostr/mcp/blossom/BlossomAuth.java b/nostr-java-mcp/src/main/java/nostr/mcp/blossom/BlossomAuth.java new file mode 100644 index 00000000..bdd8bdcb --- /dev/null +++ b/nostr-java-mcp/src/main/java/nostr/mcp/blossom/BlossomAuth.java @@ -0,0 +1,112 @@ +package nostr.mcp.blossom; + +import lombok.NonNull; +import nostr.event.BaseTag; +import nostr.event.impl.GenericEvent; +import nostr.event.json.codec.BaseEventEncoder; +import nostr.mcp.tool.ToolFailure; + +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.time.Clock; +import java.util.ArrayList; +import java.util.Base64; +import java.util.HexFormat; +import java.util.List; +import java.util.Optional; + +/** + * The signed Nostr event a Blossom server accepts instead of a password. + * + *

BUD-11 authorizes every request with a kind-24242 event carrying the verb, an expiry and, + * for the endpoints that act on one blob, its hash. The event is the credential, so it is built + * as narrowly as the request needs: a token for uploading one blob cannot delete another, and it + * stops working shortly after it is made. + * + *

The encoding is the part worth being exact about. The header is base64url without padding, + * which is what BUD-11 specifies and what the reference client sends; standard base64 differs in + * two characters and a server rejects it with a 401 that says nothing useful. + */ +public final class BlossomAuth { + + /** The event kind BUD-11 reserves for authorization. */ + public static final int AUTHORIZATION_KIND = 24_242; + + private static final String VERB_TAG = "t"; + private static final String EXPIRATION_TAG = "expiration"; + private static final String HASH_TAG = "x"; + private static final String NONCE_TAG = "nonce"; + private static final int NONCE_BYTES = 8; + private static final String SCHEME = "Nostr "; + + /** How long a token stays valid. Long enough to upload a blob, short enough to be worthless if it leaks. */ + private static final long LIFETIME_SECONDS = 300; + + private final Clock clock; + private final SecureRandom nonces = new SecureRandom(); + + /** + * @param clock what "now" means when stamping and expiring a token + */ + public BlossomAuth(@NonNull Clock clock) { + this.clock = clock; + } + + /** + * Build the unsigned token for a request. + * + * @param verb the BUD-11 action: get, upload, list or delete + * @param blobHash the blob this token is limited to, or empty for a request about no one blob + * @return the event to sign + */ + public GenericEvent tokenFor(@NonNull BlossomVerb verb, @NonNull Optional blobHash) { + long now = clock.instant().getEpochSecond(); + List tags = new ArrayList<>(); + tags.add(BaseTag.create(VERB_TAG, verb.wireName())); + tags.add(BaseTag.create(EXPIRATION_TAG, Long.toString(now + LIFETIME_SECONDS))); + blobHash.ifPresent(hash -> tags.add(BaseTag.create(HASH_TAG, hash))); + tags.add(BaseTag.create(NONCE_TAG, newNonce())); + return GenericEvent.builder() + .kind(AUTHORIZATION_KIND) + .content(verb.intent()) + .createdAt(now) + .tags(tags) + .build(); + } + + /** + * A random value making every token a distinct event. + * + *

Without it, two tokens for the same verb and blob built in the same second are byte for + * byte identical, and a nostr event's id is the hash of its contents — so the second request + * carries an id the server has already seen. blossom-server keeps a replay cache and answers + * the second one {@code 400 Auth event already used}, which is correct of it: a token that can + * be presented twice is a token worth stealing. + * + *

It bites hardest where there is nothing else to vary. A list token carries no {@code x} + * tag, so two listings in the same second would otherwise be the same event. + */ + private String newNonce() { + byte[] bytes = new byte[NONCE_BYTES]; + nonces.nextBytes(bytes); + return HexFormat.of().formatHex(bytes); + } + + /** + * Render a signed token as the header value a server reads. + * + * @param signedToken the token, already signed + * @return the {@code Authorization} value, scheme included + * @throws nostr.mcp.tool.ToolException when the token cannot be serialised + */ + public String headerValue(@NonNull GenericEvent signedToken) { + if (!signedToken.isSigned()) { + throw ToolFailure.INVALID_ARGUMENT.raise("Refusing to send an unsigned Blossom token"); + } + String json = new BaseEventEncoder<>(signedToken).encode(); + return SCHEME + + Base64.getUrlEncoder() + .withoutPadding() + .encodeToString(json.getBytes(StandardCharsets.UTF_8)); + } +} diff --git a/nostr-java-mcp/src/main/java/nostr/mcp/blossom/BlossomClient.java b/nostr-java-mcp/src/main/java/nostr/mcp/blossom/BlossomClient.java new file mode 100644 index 00000000..018670c0 --- /dev/null +++ b/nostr-java-mcp/src/main/java/nostr/mcp/blossom/BlossomClient.java @@ -0,0 +1,262 @@ +package nostr.mcp.blossom; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.NonNull; +import nostr.mcp.tool.ToolFailure; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpRequest.BodyPublishers; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +/** + * Talks to a Blossom server over HTTP. + * + *

Separate from {@link nostr.mcp.directory.WellKnownJson}, which serves the same purpose for + * NIP-05 and NIP-11, because the two have almost nothing in common at the HTTP level: that one + * is GET-only, String-bodied and always JSON, and Blossom needs PUT with a binary body, HEAD for + * existence, DELETE, and an {@code Authorization} header on most of it. + * + *

Every failure becomes a {@link ToolFailure} rather than an {@code IOException}, and a + * refusal carries the server's {@code X-Reason} where it sent one. BUD-01 is explicit that + * {@code X-Reason} is for a human to read and must never be parsed for control flow, so it is + * passed through as prose and nothing here branches on it. + */ +public final class BlossomClient { + + private static final Duration TIMEOUT = Duration.ofSeconds(30); + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final String AUTHORIZATION = "Authorization"; + private static final String REASON_HEADER = "X-Reason"; + private static final String UPLOAD_HASH_HEADER = "X-SHA-256"; + + private final HttpClient httpClient; + + /** + * Uses a client that does not follow redirects. + * + *

BUD-01 lets a server redirect blob retrieval to a CDN, and following that would be + * convenient. It is also the way past {@link PublicHttpUrl}: the {@code server} argument is a + * URL an agent chose, so a host that passes the address check can answer {@code 302 Location: + * http://169.254.169.254/} and have this process fetch it and hand the result back. The JDK + * drops the {@code Authorization} header across a redirect, so the token does not leak, but + * the response body does. + * + *

Nothing is lost by refusing. A redirect on HEAD still means the blob is there, which is + * the only question that endpoint asks, and the authorized endpoints do not redirect. + */ + public BlossomClient() { + this( + HttpClient.newBuilder() + .followRedirects(HttpClient.Redirect.NEVER) + .connectTimeout(TIMEOUT) + .build()); + } + + /** + * @param httpClient the client to call with, so a test need not reach the network + */ + public BlossomClient(@NonNull HttpClient httpClient) { + this.httpClient = httpClient; + } + + /** + * Store a blob. + * + * @param server the server's base URL + * @param blob the bytes to store + * @param contentType the blob's MIME type + * @param sha256 the blob's hash, which the server may check before reading the body + * @param authorization the signed upload token + * @return what the server says it stored + * @throws nostr.mcp.tool.ToolException when the server could not be reached or refused + */ + public BlobDescriptor upload( + @NonNull String server, + byte @NonNull [] blob, + @NonNull String contentType, + @NonNull String sha256, + @NonNull String authorization) { + HttpRequest request = + HttpRequest.newBuilder(URI.create(server + "/upload")) + .header(AUTHORIZATION, authorization) + .header("Content-Type", contentType) + .header(UPLOAD_HASH_HEADER, sha256) + .timeout(TIMEOUT) + .PUT(BodyPublishers.ofByteArray(blob)) + .build(); + HttpResponse response = send(request, HttpResponse.BodyHandlers.ofString(), server); + requireSuccess(response, server, "store the blob"); + return BlobDescriptor.from(parse(response.body(), server)); + } + + /** + * Ask whether a server holds a blob, without downloading it. + * + * @param server the server's base URL + * @param sha256 the blob's hash + * @return the blob's size and type, or empty when the server does not hold it + * @throws nostr.mcp.tool.ToolException when the server could not be reached + */ + public Optional head(@NonNull String server, @NonNull String sha256) { + String url = BlossomServers.blobUrl(server, sha256); + HttpRequest request = + HttpRequest.newBuilder(URI.create(url)) + .timeout(TIMEOUT) + .method("HEAD", BodyPublishers.noBody()) + .build(); + HttpResponse response = send(request, HttpResponse.BodyHandlers.discarding(), server); + if (response.statusCode() == 404 || response.statusCode() == 410) { + return Optional.empty(); + } + // A redirect is the server pointing at a CDN, which answers the only question HEAD asks: + // the blob exists. The hash-addressed URL is still the one to hand back, since BUD-01 + // requires the redirect target to carry the same hash anyway. + if (!isRedirect(response.statusCode())) { + requireSuccess(response, server, "look up the blob"); + } + return Optional.of( + new BlobDescriptor( + url, + sha256, + contentLengthOf(response), + header(response, "Content-Type").orElse("application/octet-stream"), + 0)); + } + + /** + * List the blobs a public key has stored on a server. + * + * @param server the server's base URL + * @param pubkeyHex whose blobs to list + * @param authorization the signed list token + * @return the descriptors the server returned + * @throws nostr.mcp.tool.ToolException when the server could not be reached or refused + */ + public List list( + @NonNull String server, @NonNull String pubkeyHex, @NonNull String authorization) { + HttpRequest request = + HttpRequest.newBuilder(URI.create(server + "/list/" + pubkeyHex)) + .header(AUTHORIZATION, authorization) + .header("Accept", "application/json") + .timeout(TIMEOUT) + .GET() + .build(); + HttpResponse response = send(request, HttpResponse.BodyHandlers.ofString(), server); + requireSuccess(response, server, "list blobs"); + JsonNode body = parse(response.body(), server); + if (!body.isArray()) { + // Iterating a JSON object yields its values, which would turn an error envelope into a + // list of empty descriptors rather than something anyone could diagnose. + throw ToolFailure.BLOB_SERVER_REJECTED.raise( + server + " answered a listing with " + body.getNodeType() + " where an array was due"); + } + List descriptors = new ArrayList<>(); + body.forEach(node -> descriptors.add(BlobDescriptor.from(node))); + return List.copyOf(descriptors); + } + + /** + * Remove a blob from a server. + * + * @param server the server's base URL + * @param sha256 the blob's hash + * @param authorization the signed delete token + * @throws nostr.mcp.tool.ToolException when the server could not be reached or refused + */ + public void delete( + @NonNull String server, @NonNull String sha256, @NonNull String authorization) { + HttpRequest request = + HttpRequest.newBuilder(URI.create(BlossomServers.blobUrl(server, sha256))) + .header(AUTHORIZATION, authorization) + .timeout(TIMEOUT) + .DELETE() + .build(); + HttpResponse response = send(request, HttpResponse.BodyHandlers.ofString(), server); + if (response.statusCode() == 404) { + throw ToolFailure.BLOB_NOT_FOUND.raise(server + " does not hold a blob " + sha256); + } + requireSuccess(response, server, "delete the blob"); + } + + private HttpResponse send( + HttpRequest request, HttpResponse.BodyHandler handler, String server) { + try { + return httpClient.send(request, handler); + } catch (IOException e) { + throw ToolFailure.BLOB_SERVER_UNREACHABLE.raise( + "Could not reach " + server + ": " + e.getMessage()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw ToolFailure.TIMEOUT.raise("Interrupted while calling " + server); + } catch (IllegalArgumentException malformed) { + // The JDK's own parser throws this for a response it cannot make sense of, such as a + // Content-Length that is not a number, and it does so before any of this class's code + // sees the response. Uncaught it would leave the tool boundary as an uncoded exception, + // which is the one failure mode every other path here exists to avoid. + throw ToolFailure.BLOB_SERVER_REJECTED.raise( + server + " sent a response this client could not parse: " + malformed.getMessage()); + } + } + + /** + * Turns a refusal into something the agent can act on. + * + *

The status separates the two cases that matter: a 4xx means the request was wrong and + * repeating it will fail the same way, while a 5xx may be worth another try. The server's own + * reason is appended when it sent one, because "413" alone does not tell an agent whether to + * shrink the file or give up. + */ + private void requireSuccess(HttpResponse response, String server, String attempt) { + int status = response.statusCode(); + if (status >= 200 && status < 300) { + return; + } + String reason = header(response, REASON_HEADER).map(text -> ": " + text).orElse(""); + String detail = server + " refused to " + attempt + " (HTTP " + status + ")" + reason; + if (status >= 500) { + throw ToolFailure.BLOB_SERVER_UNREACHABLE.raise(detail); + } + throw ToolFailure.BLOB_SERVER_REJECTED.raise(detail); + } + + private JsonNode parse(String body, String server) { + try { + return MAPPER.readTree(body); + } catch (IOException e) { + throw ToolFailure.BLOB_SERVER_REJECTED.raise( + server + " answered with something that is not JSON: " + e.getMessage()); + } + } + + private Optional header(HttpResponse response, String name) { + return response.headers().firstValue(name); + } + + private boolean isRedirect(int status) { + return status >= 300 && status < 400; + } + + /** + * The Content-Length, treating anything unparseable as absent. + * + *

Servers omit this header routinely — blossom-server sends none on a HEAD — and a missing + * size is not worth failing a lookup over. The JDK rejects a malformed value before this is + * reached, so the catch here is for the cases it does not, such as a header this client never + * parsed itself. + */ + private long contentLengthOf(HttpResponse response) { + try { + return header(response, "Content-Length").map(Long::parseLong).orElse(0L); + } catch (NumberFormatException unparseable) { + return 0; + } + } +} diff --git a/nostr-java-mcp/src/main/java/nostr/mcp/blossom/BlossomServers.java b/nostr-java-mcp/src/main/java/nostr/mcp/blossom/BlossomServers.java new file mode 100644 index 00000000..ab6bff8e --- /dev/null +++ b/nostr-java-mcp/src/main/java/nostr/mcp/blossom/BlossomServers.java @@ -0,0 +1,162 @@ +package nostr.mcp.blossom; + +import lombok.NonNull; +import nostr.mcp.tool.ToolFailure; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Optional; +import java.util.Set; + +/** + * Which Blossom server a tool call acts on. + * + *

An agent that must invent a server URL will invent a plausible one, so the operator + * configures the servers this deployment uses and a call that names none gets the first of them. + * A call may still name a server, because the whole point of a hash-addressed network is that + * the same blob lives on several, and a user's own BUD-03 list is discovered at runtime rather + * than configured. + * + *

A named server is checked by {@link PublicHttpUrl}; a configured one is not. The difference + * is who chose it. Configuration is a person's decision, and a person may legitimately point + * this at a server on their own network. + */ +public final class BlossomServers { + + private final List configured; + private final PublicHttpUrl publicHttpUrl; + + /** + * @param configured the servers this deployment uses, most preferred first + * @param publicHttpUrl the guard applied to a server an agent names + */ + public BlossomServers(@NonNull List configured, @NonNull PublicHttpUrl publicHttpUrl) { + this.configured = configured.stream().map(BlossomServers::normalise).distinct().toList(); + this.publicHttpUrl = publicHttpUrl; + } + + /** + * Resolve the server a call should act on. + * + * @param requested the {@code server} argument, or empty when the call named none + * @return the server's base URL, without a trailing slash + * @throws nostr.mcp.tool.ToolException when none was named and none is configured + */ + public String resolve(@NonNull Optional requested) { + if (requested.isPresent()) { + String server = normalise(requested.get()); + if (!configured.contains(server)) { + publicHttpUrl.require("server", server); + } + return server; + } + return configured.stream() + .findFirst() + .orElseThrow( + () -> + ToolFailure.INVALID_ARGUMENT.raise( + "No Blossom server was given and none is configured. Pass 'server' with the" + + " server's URL, or set nostr.mcp.blossom.servers.")); + } + + /** + * Every server to try when looking for a blob that could be on any of them. + * + * @param requested the {@code server} argument, or empty to search all configured servers + * @return the servers to try, in preference order + * @throws nostr.mcp.tool.ToolException when none was named and none is configured + */ + public List resolveAll(@NonNull Optional requested) { + if (requested.isPresent()) { + return List.of(resolve(requested)); + } + if (configured.isEmpty()) { + return List.of(resolve(requested)); + } + return configured; + } + + /** + * The servers the operator configured, for a tool schema to name. + * + * @return the configured server URLs + */ + public List configured() { + return configured; + } + + /** + * Build the URL a blob is served from. + * + * @param server the server's base URL + * @param sha256 the blob's hash + * @return the blob's URL + */ + public static String blobUrl(@NonNull String server, @NonNull String sha256) { + return normalise(server) + "/" + sha256; + } + + /** + * Strip a trailing slash so two spellings of one server are one server. + * + *

{@code https://cdn.example.com/} and {@code https://cdn.example.com} would otherwise be + * different keys, and every path this class builds would grow a double slash for one of them. + */ + private static String normalise(String server) { + String trimmed = server.trim(); + while (trimmed.endsWith("/")) { + trimmed = trimmed.substring(0, trimmed.length() - 1); + } + return trimmed; + } + + /** + * Read the servers out of a BUD-03 list, keeping the order the author chose. + * + * @param serverUrls the values of the event's {@code server} tags + * @return the distinct servers, normalised + */ + public static List fromServerTags(@NonNull List serverUrls) { + Set ordered = new LinkedHashSet<>(); + serverUrls.stream() + .filter(url -> !url.isBlank()) + .map(BlossomServers::normalise) + .forEach(ordered::add); + return List.copyOf(ordered); + } + + /** + * Check that a server URL is well formed before it is published in a BUD-03 list. + * + *

Deliberately a syntax check and not {@link PublicHttpUrl}. Publishing a server list + * fetches nothing, so there is no request to forge, and applying the address check here would + * do two wrong things: stop an operator running Blossom on their own network from ever + * advertising it, and fail the whole publish on a transient DNS failure for any one URL. What + * matters is that readers of the event get something they can use. + * + * @param server the URL as written + * @return the normalised URL + * @throws nostr.mcp.tool.ToolException when it is not an http(s) URL with a host + */ + public static URI requireUsable(@NonNull String server) { + String normalised = normalise(server); + URI uri; + try { + uri = new URI(normalised); + } catch (URISyntaxException e) { + throw ToolFailure.INVALID_ARGUMENT.raise("'servers' is not a valid URL: " + server); + } + String scheme = uri.getScheme() == null ? "" : uri.getScheme().toLowerCase(Locale.ROOT); + if (!"http".equals(scheme) && !"https".equals(scheme)) { + throw ToolFailure.INVALID_ARGUMENT.raise( + "'servers' entries must be http or https URLs, but had '" + server + "'"); + } + if (uri.getHost() == null || uri.getHost().isBlank()) { + throw ToolFailure.INVALID_ARGUMENT.raise("'servers' entry has no host: " + server); + } + return uri; + } +} diff --git a/nostr-java-mcp/src/main/java/nostr/mcp/blossom/BlossomVerb.java b/nostr-java-mcp/src/main/java/nostr/mcp/blossom/BlossomVerb.java new file mode 100644 index 00000000..527160d8 --- /dev/null +++ b/nostr-java-mcp/src/main/java/nostr/mcp/blossom/BlossomVerb.java @@ -0,0 +1,45 @@ +package nostr.mcp.blossom; + +/** + * The actions BUD-11 lets a token authorize. + * + *

A server checks the verb against the endpoint it was sent to, so the verb is what stops an + * upload token from deleting anything. Each carries the sentence a person would be shown when + * asked to approve it, which BUD-11 requires the content to be. + */ +public enum BlossomVerb { + /** Read one blob. */ + GET("get", "Download blob"), + /** Store one blob. */ + UPLOAD("upload", "Upload blob"), + /** List the blobs a public key has stored. */ + LIST("list", "List blobs"), + /** Remove one blob. */ + DELETE("delete", "Delete blob"); + + private final String wireName; + private final String intent; + + BlossomVerb(String wireName, String intent) { + this.wireName = wireName; + this.intent = intent; + } + + /** + * The value the {@code t} tag carries. + * + * @return the verb as BUD-11 spells it + */ + public String wireName() { + return wireName; + } + + /** + * What this token is for, in words a person could approve. + * + * @return the event content + */ + public String intent() { + return intent; + } +} diff --git a/nostr-java-mcp/src/main/java/nostr/mcp/blossom/PublicHttpUrl.java b/nostr-java-mcp/src/main/java/nostr/mcp/blossom/PublicHttpUrl.java new file mode 100644 index 00000000..014d30c9 --- /dev/null +++ b/nostr-java-mcp/src/main/java/nostr/mcp/blossom/PublicHttpUrl.java @@ -0,0 +1,130 @@ +package nostr.mcp.blossom; + +import lombok.NonNull; +import nostr.mcp.tool.ToolFailure; + +import java.net.InetAddress; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.UnknownHostException; +import java.util.Locale; + +/** + * Refuses a URL that would make the server fetch something on its own network. + * + *

Every other tool in this module sends an agent's words to a relay. Blossom is the first + * that makes the server itself fetch a URL the agent chose, which turns the server into a proxy + * for whatever it can reach and the agent cannot: a metadata endpoint on a cloud instance, a + * database admin page on localhost, a printer on the office LAN. That is server-side request + * forgery, and it is the reason this class exists rather than a bare {@code URI.create}. + * + *

Applied to both the blob's source URL and to a server named in a tool argument. Servers the + * operator configured are not checked: those are a deliberate choice by a person, which is + * exactly what an agent-supplied URL is not. + */ +public final class PublicHttpUrl { + + private static final String HTTP = "http"; + private static final String HTTPS = "https"; + + private final boolean allowPrivateHosts; + + /** + * @param allowPrivateHosts whether to permit addresses that are not publicly routable, which a + * self-hosted or LAN deployment needs and a public one must not have + */ + public PublicHttpUrl(boolean allowPrivateHosts) { + this.allowPrivateHosts = allowPrivateHosts; + } + + /** + * Check a URL the agent supplied. + * + * @param name the argument it came from, so a refusal says which one to fix + * @param value the URL as written + * @return the parsed URL, safe to fetch + * @throws nostr.mcp.tool.ToolException when it is malformed, not HTTP, or not public + */ + public URI require(@NonNull String name, @NonNull String value) { + URI uri = parse(name, value); + requireHttpScheme(name, uri); + String host = uri.getHost(); + if (host == null || host.isBlank()) { + throw ToolFailure.INVALID_ARGUMENT.raise("'" + name + "' has no host: " + value); + } + if (!allowPrivateHosts) { + requirePubliclyRoutable(name, host); + } + return uri; + } + + private URI parse(String name, String value) { + try { + return new URI(value.trim()); + } catch (URISyntaxException e) { + throw ToolFailure.INVALID_ARGUMENT.raise("'" + name + "' is not a valid URL: " + value); + } + } + + private void requireHttpScheme(String name, URI uri) { + String scheme = uri.getScheme() == null ? "" : uri.getScheme().toLowerCase(Locale.ROOT); + if (!HTTP.equals(scheme) && !HTTPS.equals(scheme)) { + throw ToolFailure.INVALID_ARGUMENT.raise( + "'" + name + "' must be an http or https URL, but was '" + uri + "'"); + } + } + + /** + * Refuses a host that resolves anywhere but the public internet. + * + *

Every resolved address has to pass, not just the first. A name that answers with one + * public address and one loopback address is the cheapest way to defeat a check that stops at + * {@code getByName}. + * + *

ponytail: this resolves the name, and the HTTP client then resolves it again, so a name + * that changes answers between the two calls still gets through. Closing that needs an + * HttpClient with a pinned resolver; the cap on response size limits what it could be worth. + */ + private void requirePubliclyRoutable(String name, String host) { + InetAddress[] addresses; + try { + addresses = InetAddress.getAllByName(host); + } catch (UnknownHostException e) { + throw ToolFailure.INVALID_ARGUMENT.raise( + "'" + name + "' names a host that does not resolve: " + host); + } + for (InetAddress address : addresses) { + if (isPrivate(address)) { + throw ToolFailure.INVALID_ARGUMENT.raise( + "'" + + name + + "' resolves to " + + address.getHostAddress() + + ", which is not on the public internet. This server will not fetch from its own" + + " network. Set nostr.mcp.blossom.allow-private-hosts=true if that is intended."); + } + } + } + + /** + * Whether an address is one this server should refuse to fetch from. + * + *

{@code isSiteLocalAddress} covers 10/8, 172.16/12 and 192.168/16; + * {@code isLinkLocalAddress} covers 169.254/16, which is where cloud instance metadata lives + * and so the single most valuable target. IPv6 unique-local (fc00::/7) has no JDK predicate + * and is checked by its leading bits. + */ + private boolean isPrivate(InetAddress address) { + return address.isLoopbackAddress() + || address.isLinkLocalAddress() + || address.isSiteLocalAddress() + || address.isAnyLocalAddress() + || address.isMulticastAddress() + || isUniqueLocalIpv6(address); + } + + private boolean isUniqueLocalIpv6(InetAddress address) { + byte[] bytes = address.getAddress(); + return bytes.length == 16 && (bytes[0] & 0xFE) == 0xFC; + } +} diff --git a/nostr-java-mcp/src/main/java/nostr/mcp/identity/SigningAlias.java b/nostr-java-mcp/src/main/java/nostr/mcp/identity/SigningAlias.java new file mode 100644 index 00000000..7232c89d --- /dev/null +++ b/nostr-java-mcp/src/main/java/nostr/mcp/identity/SigningAlias.java @@ -0,0 +1,81 @@ +package nostr.mcp.identity; + +import lombok.NonNull; +import nostr.base.PublicKey; +import nostr.event.impl.GenericEvent; +import nostr.mcp.tool.ToolFailure; + +import java.util.Optional; + +/** + * Chooses which identity signs, refusing to guess. + * + *

Posting as the wrong account is public and irreversible, so where several identities exist + * and none is the default, the caller is asked rather than picked for. + * + *

Separate from {@link nostr.mcp.identity.IdentityVault} because the vault answers what it + * holds, and separate from {@code WriteGuard} because not everything that signs is a write: a + * Blossom listing needs a signed token to read, and a read-only server must still be able to + * make one. Both paths resolve the alias the same way, and a second copy of these messages + * would be a second place for them to drift. + */ +public final class SigningAlias { + + private SigningAlias() {} + + /** + * Resolve the alias to sign as. + * + * @param identityVault the keys this server holds + * @param requestedAlias the identity named by the caller, or empty to use the default + * @return the alias to sign as + * @throws nostr.mcp.tool.ToolException when the alias is unknown, or none was given and no + * default exists + */ + public static String resolve( + @NonNull IdentityVault identityVault, @NonNull Optional requestedAlias) { + if (requestedAlias.isPresent()) { + String alias = requestedAlias.get(); + if (identityVault.find(alias).isEmpty()) { + throw ToolFailure.IDENTITY_UNKNOWN.raise( + "No identity called '" + + alias + + "'. Available: " + + identityVault.list().stream().map(IdentitySummary::alias).toList()); + } + return alias; + } + return identityVault + .defaultAlias() + .orElseThrow( + () -> + ToolFailure.IDENTITY_AMBIGUOUS.raise( + identityVault.isEmpty() + ? "This server holds no identity to sign with. Create one with the" + + " command line: java -jar nostr-java-mcp.jar keygen " + : "This server holds several identities and none is the default, so" + + " signing would be a guess. Name one in the 'identity' argument:" + + identityVault.list().stream().map(IdentitySummary::alias).toList())); + } + + /** + * Stamp an event with an identity's public key and sign it. + * + *

The order matters and is easy to get wrong: the public key has to be set and the event + * re-serialised before signing, or the signature covers an event id that does not match the + * event that gets sent. + * + * @param identityVault the keys this server holds + * @param alias the identity to sign as + * @param event the unsigned event, which is mutated + * @return the same event, now signed + */ + public static GenericEvent sign( + @NonNull IdentityVault identityVault, @NonNull String alias, @NonNull GenericEvent event) { + PublicKey publicKey = identityVault.publicKeyOf(alias); + event.setPubKey(publicKey); + event.update(); + identityVault.signAs(alias, event); + return event; + } +} diff --git a/nostr-java-mcp/src/main/java/nostr/mcp/tool/BlobHash.java b/nostr-java-mcp/src/main/java/nostr/mcp/tool/BlobHash.java new file mode 100644 index 00000000..ccb5520c --- /dev/null +++ b/nostr-java-mcp/src/main/java/nostr/mcp/tool/BlobHash.java @@ -0,0 +1,60 @@ +package nostr.mcp.tool; + +import lombok.NonNull; +import nostr.mcp.argument.ToolArguments; + +import java.util.Locale; + +/** + * Reads a blob hash argument, insisting it is one. + * + *

Blossom addresses everything by sha256, so a malformed hash is the most likely argument + * mistake and the least informative to discover at the far end: a server answers 404 whether the + * hash is wrong or the blob is simply elsewhere. Checking here separates the two. + * + *

Uppercase hex is accepted and folded down. BUD-11 requires lowercase and a server will + * reject the other, but a model that has read a hash off a rendered page has no way to know that + * and refusing would be pedantry. + */ +final class BlobHash { + + private static final int SHA256_HEX_LENGTH = 64; + + private BlobHash() {} + + /** + * Read a required sha256 argument. + * + * @param arguments the call's arguments + * @param name the argument holding the hash + * @return the hash, lowercased + * @throws ToolException when it is absent or not a sha256 + */ + static String require(@NonNull ToolArguments arguments, @NonNull String name) { + String value = arguments.requireText(name).toLowerCase(Locale.ROOT); + if (value.length() != SHA256_HEX_LENGTH || !value.chars().allMatch(BlobHash::isHexDigit)) { + throw ToolFailure.INVALID_ARGUMENT.raise( + "'" + name + "' should be a 64-character sha256 hash in hex, but was '" + value + "'"); + } + return value; + } + + + /** + * How to describe the {@code server} argument, given what is configured. + * + *

With nothing configured the tools still work — an agent can name a server it found in + * someone's BUD-03 list — so they stay registered. Advertising "Omit for the first configured: + * []" would be a schema telling the model to omit an argument that is then required. + */ + static String describeServerArgument(java.util.List configured, String action) { + return configured.isEmpty() + ? "The Blossom server to " + action + ", as a full https:// URL. Required: this server has" + + " none configured." + : "The Blossom server to " + action + ". Omit for the first configured: " + configured; + } + + private static boolean isHexDigit(int character) { + return (character >= '0' && character <= '9') || (character >= 'a' && character <= 'f'); + } +} diff --git a/nostr-java-mcp/src/main/java/nostr/mcp/tool/BlossomDeleteTool.java b/nostr-java-mcp/src/main/java/nostr/mcp/tool/BlossomDeleteTool.java new file mode 100644 index 00000000..55486593 --- /dev/null +++ b/nostr-java-mcp/src/main/java/nostr/mcp/tool/BlossomDeleteTool.java @@ -0,0 +1,218 @@ +package nostr.mcp.tool; + +import io.modelcontextprotocol.spec.McpSchema.CallToolRequest; +import io.modelcontextprotocol.spec.McpSchema.CallToolResult; +import lombok.NonNull; +import nostr.event.impl.GenericEvent; +import nostr.mcp.argument.ToolArguments; +import nostr.mcp.blossom.BlossomAuth; +import nostr.mcp.blossom.BlossomClient; +import nostr.mcp.blossom.BlossomServers; +import nostr.mcp.blossom.BlossomVerb; +import nostr.mcp.write.WriteGuard; + +import java.security.SecureRandom; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Removes a blob from a Blossom server. + * + *

The only Blossom tool that destroys something, so under a confirming policy it previews + * first and acts only when handed back the token, exactly as a publishing tool does. The two-step + * is what turns a hallucinated deletion into a no-op: an agent that invented the intention will + * not follow through with the token issued for it. + * + *

It keeps its own pending map rather than reusing {@link WriteGuard}'s, which holds a signed + * event waiting for a relay. Generalising that to hold an arbitrary pending action would be a + * larger change than the twenty lines it would save, and this way the guard stays about + * publishing. + * + *

Worth knowing: deleting from one server removes nothing from any other. A blob is addressed + * by its hash and may have been mirrored anywhere, so this is not a way to unpublish something. + */ +public final class BlossomDeleteTool implements NostrTool { + + private static final String CONFIRMATION_ARGUMENT = "confirmationToken"; + private static final int TOKEN_BYTES = 16; + + private final Map pendingByToken = new ConcurrentHashMap<>(); + private final SecureRandom tokens = new SecureRandom(); + private final BlossomServers servers; + private final BlossomClient client; + private final BlossomAuth auth; + private final WriteGuard writeGuard; + + /** + * @param servers which server to delete from + * @param client how to ask it + * @param auth builds the delete token + * @param writeGuard applies the write policy and rate limit, and signs + */ + public BlossomDeleteTool( + @NonNull BlossomServers servers, + @NonNull BlossomClient client, + @NonNull BlossomAuth auth, + @NonNull WriteGuard writeGuard) { + this.servers = servers; + this.client = client; + this.auth = auth; + this.writeGuard = writeGuard; + } + + @Override + public String name() { + return "nostr_blossom_delete"; + } + + @Override + public String description() { + return "Delete a blob from a Blossom server by its sha256 hash. Removes it from that server" + + " only; copies on other servers are unaffected."; + } + + @Override + public Map inputSchema() { + Map properties = new LinkedHashMap<>(); + properties.put( + "sha256", Map.of("type", "string", "description", "The blob's sha256 hash, as lowercase hex.")); + properties.put( + "server", + Map.of( + "type", + "string", + "description", + BlobHash.describeServerArgument(servers.configured(), "delete from"))); + if (!writeGuard.bindsOneIdentity()) { + properties.put( + "identity", + Map.of("type", "string", "description", "Alias to delete as. Omit to use the default.")); + } + if (writeGuard.requiresConfirmation()) { + properties.put( + CONFIRMATION_ARGUMENT, + Map.of( + "type", + "string", + "description", + "Token from a previous preview. Omit to preview; supply it to delete.")); + } + return Map.of("type", "object", "properties", properties, "required", List.of()); + } + + @Override + public CallToolResult call(CallToolRequest request) { + try { + return deleteOrPreview(new ToolArguments(request.arguments())); + } catch (ToolException e) { + return e.asResult(); + } + } + + /** + * Runs whichever half of the conversation the caller is in. + * + *

The token decides, and when one is present the arguments are not read again: re-reading + * them would let the hash change between what was shown and what is deleted. + */ + private CallToolResult deleteOrPreview(ToolArguments arguments) { + Optional confirmation = arguments.text(CONFIRMATION_ARGUMENT); + if (confirmation.isPresent()) { + return deleteConfirmed(confirmation.get()); + } + String sha256 = BlobHash.require(arguments, "sha256"); + String server = servers.resolve(arguments.text("server")); + + String alias = writeGuard.authorizeWrite(arguments.text("identity")); + PendingDelete pending = new PendingDelete(sha256, server, alias); + + if (!writeGuard.requiresConfirmation()) { + return performed(pending); + } + String confirmationToken = newToken(); + pendingByToken.put(confirmationToken, pending); + return preview(confirmationToken, pending); + } + + private CallToolResult deleteConfirmed(String confirmationToken) { + PendingDelete pending = pendingByToken.remove(confirmationToken); + if (pending == null) { + throw ToolFailure.INVALID_ARGUMENT.raise( + "That confirmation token is not valid. It may already have been used, or the server may" + + " have restarted. Call this tool again without a token to get a fresh preview."); + } + return performed(pending); + } + + /** + * Signs the token and sends the request, in that order and at this moment. + * + *

The token is built here rather than at preview time because a BUD-11 token expires. The + * confirming policy exists so a person can be asked, and a person takes longer than a token + * lives; a header signed at preview would be stale by the time the answer came back, and the + * server would answer 401 with nothing to suggest that waiting was the problem. + * + *

Signing again costs no further quota. The write was authorized when it was previewed. + */ + private CallToolResult performed(PendingDelete pending) { + GenericEvent token = auth.tokenFor(BlossomVerb.DELETE, Optional.of(pending.sha256())); + writeGuard.signAs(pending.alias(), token); + client.delete(pending.server(), pending.sha256(), auth.headerValue(token)); + return CallToolResult.builder() + .structuredContent( + Map.of( + "sha256", pending.sha256(), + "server", pending.server(), + "identity", pending.alias(), + "deleted", true)) + .addTextContent("Deleted " + pending.sha256() + " from " + pending.server() + ".") + .build(); + } + + private CallToolResult preview(String confirmationToken, PendingDelete pending) { + return CallToolResult.builder() + .structuredContent( + Map.of( + "status", "awaiting-confirmation", + CONFIRMATION_ARGUMENT, confirmationToken, + "sha256", pending.sha256(), + "server", pending.server(), + "identity", pending.alias())) + .addTextContent( + "Nothing has been deleted yet. This would remove " + + pending.sha256() + + " from " + + pending.server() + + " as '" + + pending.alias() + + "'. Copies on other servers are unaffected. To go ahead, call this tool again" + + " with " + + CONFIRMATION_ARGUMENT + + "='" + + confirmationToken + + "'.") + .build(); + } + + private String newToken() { + byte[] bytes = new byte[TOKEN_BYTES]; + tokens.nextBytes(bytes); + return HexFormat.of().formatHex(bytes); + } + + /** + * A deletion that has been authorized and is waiting to be confirmed. + * + *

Holds what was described and not the token that will carry it out, because the token has + * a deadline and the description does not. + * + * @param sha256 the blob to remove + * @param server where to remove it from + * @param alias the identity authorizing it + */ + private record PendingDelete(String sha256, String server, String alias) {} +} diff --git a/nostr-java-mcp/src/main/java/nostr/mcp/tool/BlossomGetBlobTool.java b/nostr-java-mcp/src/main/java/nostr/mcp/tool/BlossomGetBlobTool.java new file mode 100644 index 00000000..88fbc202 --- /dev/null +++ b/nostr-java-mcp/src/main/java/nostr/mcp/tool/BlossomGetBlobTool.java @@ -0,0 +1,144 @@ +package nostr.mcp.tool; + +import io.modelcontextprotocol.spec.McpSchema.CallToolRequest; +import io.modelcontextprotocol.spec.McpSchema.CallToolResult; +import lombok.NonNull; +import nostr.mcp.argument.ToolArguments; +import nostr.mcp.blossom.BlobDescriptor; +import nostr.mcp.blossom.BlossomClient; +import nostr.mcp.blossom.BlossomServers; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * Finds which server holds a blob, and what it is. + * + *

Answers with a URL and the blob's size and type, never with its bytes. A tool result is + * read into a model's context, and a megabyte of image would be both useless there and ruinous + * to it; the URL is what a note needs anyway. + * + *

Asks each configured server in turn, because the same hash may be on any of them and a + * blob missing from the first is the normal case rather than a failure. + */ +public final class BlossomGetBlobTool implements NostrTool { + + private final BlossomServers servers; + private final BlossomClient client; + + /** + * @param servers which servers to look on + * @param client how to ask them + */ + public BlossomGetBlobTool(@NonNull BlossomServers servers, @NonNull BlossomClient client) { + this.servers = servers; + this.client = client; + } + + @Override + public String name() { + return "nostr_blossom_get"; + } + + @Override + public String description() { + return "Find a Blossom blob by its sha256 hash and return its URL, size and type. Returns a" + + " link, never the file's contents."; + } + + @Override + public Map inputSchema() { + return Map.of( + "type", + "object", + "properties", + Map.of( + "sha256", + Map.of("type", "string", "description", "The blob's sha256 hash, as lowercase hex."), + "server", + Map.of( + "type", + "string", + "description", + servers.configured().isEmpty() + ? "The Blossom server to look on, as a full https:// URL. Required: this" + + " server has none configured." + : "The Blossom server to look on. Omit to try all configured: " + + servers.configured())), + "required", + List.of("sha256")); + } + + @Override + public CallToolResult call(CallToolRequest request) { + try { + return find(new ToolArguments(request.arguments())); + } catch (ToolException e) { + return e.asResult(); + } + } + + /** + * Asks each server in turn, and keeps asking when one cannot answer. + * + *

A server being down is not evidence about the blob. Letting an unreachable first server + * end the search would report "could not reach server-one" while the blob sits on server-two, + * which is the failure this tool exists to avoid: the whole point of hash addressing is that + * the same blob may be on any of them. + * + *

Only when every server failed for a reason other than "not here" is that reported, since + * "no server holds it" would then be a claim nobody checked. + */ + private CallToolResult find(ToolArguments arguments) { + String sha256 = BlobHash.require(arguments, "sha256"); + List candidates = servers.resolveAll(arguments.text("server")); + List unreachable = new ArrayList<>(); + for (String server : candidates) { + try { + Optional found = client.head(server, sha256); + if (found.isPresent()) { + return describe(server, found.get()); + } + } catch (ToolException unusable) { + unreachable.add(server + " (" + unusable.getMessage() + ")"); + } + } + if (unreachable.size() == candidates.size()) { + throw ToolFailure.BLOB_SERVER_UNREACHABLE.raise( + "No server could be asked about " + sha256 + ": " + unreachable); + } + throw ToolFailure.BLOB_NOT_FOUND.raise( + "No blob " + + sha256 + + " on " + + candidates + + ". It may be on a server not listed here." + + (unreachable.isEmpty() ? "" : " These could not be asked: " + unreachable + ".")); + } + + /** + * Describes the size and type, when the server said anything about them. + * + *

BUD-01 asks a server to echo {@code Content-Length} and {@code Content-Type} on a HEAD, + * and blossom-server sends neither. Saying "0 bytes" because a header was missing would be a + * worse answer than not mentioning the size at all. + */ + private String describeSize(BlobDescriptor blob) { + if (blob.size() <= 0) { + return ""; + } + return " (" + blob.size() + " bytes, " + blob.type() + ")"; + } + + private CallToolResult describe(String server, BlobDescriptor blob) { + Map structured = new LinkedHashMap<>(blob.asStructuredContent()); + structured.put("server", server); + return CallToolResult.builder() + .structuredContent(structured) + .addTextContent("Found on " + server + ": " + blob.url() + describeSize(blob) + ".") + .build(); + } +} diff --git a/nostr-java-mcp/src/main/java/nostr/mcp/tool/BlossomListTool.java b/nostr-java-mcp/src/main/java/nostr/mcp/tool/BlossomListTool.java new file mode 100644 index 00000000..4e883c55 --- /dev/null +++ b/nostr-java-mcp/src/main/java/nostr/mcp/tool/BlossomListTool.java @@ -0,0 +1,157 @@ +package nostr.mcp.tool; + +import io.modelcontextprotocol.spec.McpSchema.CallToolRequest; +import io.modelcontextprotocol.spec.McpSchema.CallToolResult; +import lombok.NonNull; +import nostr.event.impl.GenericEvent; +import nostr.mcp.argument.NostrIdentifier; +import nostr.mcp.argument.ToolArguments; +import nostr.mcp.blossom.BlobDescriptor; +import nostr.mcp.blossom.BlossomAuth; +import nostr.mcp.blossom.BlossomClient; +import nostr.mcp.blossom.BlossomServers; +import nostr.mcp.blossom.BlossomVerb; +import nostr.mcp.identity.IdentityVault; +import nostr.mcp.identity.SigningAlias; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * Lists the blobs a key has stored on a server. + * + *

Listing is a read, but BUD-12 still wants a signed token for it, so this tool signs without + * going through the write guard. That is deliberate: a server configured {@code write-policy: + * deny} must still be able to answer "what have I uploaded", and refusing to sign a token that + * only reads would make a read-only server unable to read. + */ +public final class BlossomListTool implements NostrTool { + + private final BlossomServers servers; + private final BlossomClient client; + private final BlossomAuth auth; + private final IdentityVault identityVault; + + /** + * @param servers which server to list from + * @param client how to ask it + * @param auth builds the list token + * @param identityVault signs the token and resolves whose blobs are meant + */ + public BlossomListTool( + @NonNull BlossomServers servers, + @NonNull BlossomClient client, + @NonNull BlossomAuth auth, + @NonNull IdentityVault identityVault) { + this.servers = servers; + this.client = client; + this.auth = auth; + this.identityVault = identityVault; + } + + @Override + public String name() { + return "nostr_blossom_list"; + } + + @Override + public String description() { + return "List the blobs a public key has stored on a Blossom server. Defaults to this" + + " server's own identity."; + } + + /** + * The schema a host shows the model. + * + *

A bound server omits {@code identity}, and says nothing about one anywhere else either. + * The whole point of binding is that there is no name to give, and an argument with exactly + * one acceptable value is an invitation to pass a different one. + */ + @Override + public Map inputSchema() { + Map properties = new LinkedHashMap<>(); + properties.put( + "pubkey", + Map.of( + "type", + "string", + "description", + "Whose blobs to list, as hex or npub. Omit for this server's own key.")); + properties.put( + "server", + Map.of( + "type", + "string", + "description", + BlobHash.describeServerArgument(servers.configured(), "list from"))); + if (!identityVault.binding().isBound()) { + properties.put( + "identity", + Map.of( + "type", + "string", + "description", + "Alias whose key signs the list request. Omit to use the default.")); + } + return Map.of("type", "object", "properties", properties, "required", List.of()); + } + + @Override + public CallToolResult call(CallToolRequest request) { + try { + return list(new ToolArguments(request.arguments())); + } catch (ToolException e) { + return e.asResult(); + } + } + + private CallToolResult list(ToolArguments arguments) { + String alias = SigningAlias.resolve(identityVault, arguments.text("identity")); + String owner = resolveOwner(arguments, alias); + String server = servers.resolve(arguments.text("server")); + + GenericEvent token = auth.tokenFor(BlossomVerb.LIST, Optional.empty()); + SigningAlias.sign(identityVault, alias, token); + + List blobs = client.list(server, owner, auth.headerValue(token)); + return CallToolResult.builder() + .structuredContent( + Map.of( + "server", server, + "pubkey", owner, + "blobs", blobs.stream().map(BlobDescriptor::asStructuredContent).toList(), + "count", blobs.size())) + .addTextContent(summarise(server, blobs)) + .build(); + } + + /** + * Describes the listing, mentioning a total size only when the server gave one. + * + *

blossom-server reports {@code "size": 0} for every blob it lists, so summing them and + * saying "0 bytes in total" would state something false about files that plainly exist. + */ + private String summarise(String server, List blobs) { + if (blobs.isEmpty()) { + return "No blobs stored on " + server + " for that key."; + } + long bytes = blobs.stream().mapToLong(BlobDescriptor::size).sum(); + String count = blobs.size() + " blob" + (blobs.size() == 1 ? "" : "s") + " on " + server; + return bytes > 0 ? count + ", " + bytes + " bytes in total." : count + "."; + } + + /** + * Whose blobs to list. + * + *

Falls back to the signing identity rather than refusing, since "what have I uploaded" is + * the question this tool almost always answers and the key is already known. + */ + private String resolveOwner(ToolArguments arguments, String alias) { + return arguments + .text("pubkey") + .map(pubkey -> NostrIdentifier.publicKey("pubkey", pubkey).hex()) + .orElseGet(() -> identityVault.publicKeyOf(alias).toHexString()); + } +} diff --git a/nostr-java-mcp/src/main/java/nostr/mcp/tool/BlossomServerListTool.java b/nostr-java-mcp/src/main/java/nostr/mcp/tool/BlossomServerListTool.java new file mode 100644 index 00000000..246e8e8f --- /dev/null +++ b/nostr-java-mcp/src/main/java/nostr/mcp/tool/BlossomServerListTool.java @@ -0,0 +1,173 @@ +package nostr.mcp.tool; + +import io.modelcontextprotocol.spec.McpSchema.CallToolRequest; +import io.modelcontextprotocol.spec.McpSchema.CallToolResult; +import lombok.NonNull; +import nostr.event.filter.EventFilter; +import nostr.event.impl.GenericEvent; +import nostr.event.tag.GenericTag; +import nostr.mcp.argument.NostrIdentifier; +import nostr.mcp.argument.ToolArguments; +import nostr.mcp.blossom.BlossomServers; +import nostr.mcp.identity.IdentityVault; +import nostr.mcp.query.EventQuery; +import nostr.mcp.query.QueryLimits; +import nostr.mcp.query.QueryResult; + +import java.util.Comparator; +import java.util.List; +import java.util.Map; + +/** + * Reads which Blossom servers someone uses. + * + *

BUD-03 publishes this as a kind-10063 event, which makes a user's media hosting + * discoverable the same way their relays are. It is how an agent finds where to look for + * someone else's media, and where to put its own without the operator hard-coding a server. + * + *

Order is meaningful and preserved: the spec has the author list their most trusted server + * first, and clients are expected to try them in that order. + */ +public final class BlossomServerListTool implements NostrTool { + + /** The kind BUD-03 reserves for a user's server list. */ + public static final int SERVER_LIST_KIND = 10_063; + + private static final String SERVER_TAG = "server"; + + private final EventQuery eventQuery; + private final IdentityVault identityVault; + private final QueryLimits limits; + + /** + * @param eventQuery finds the list + * @param identityVault resolves whose list is meant when none is named + * @param limits the configured query bounds + */ + public BlossomServerListTool( + @NonNull EventQuery eventQuery, + @NonNull IdentityVault identityVault, + @NonNull QueryLimits limits) { + this.eventQuery = eventQuery; + this.identityVault = identityVault; + this.limits = limits; + } + + @Override + public String name() { + return "nostr_blossom_get_servers"; + } + + @Override + public String description() { + return "Read the Blossom media servers a public key publishes (BUD-03, kind 10063), most" + + " trusted first. Defaults to this server's own identity."; + } + + @Override + public Map inputSchema() { + return Map.of( + "type", + "object", + "properties", + Map.of( + "pubkey", + Map.of( + "type", + "string", + "description", + "Whose server list to read, as hex or npub. Omit for this server's own key.")), + "required", + List.of()); + } + + @Override + public CallToolResult call(CallToolRequest request) { + try { + return read(new ToolArguments(request.arguments())); + } catch (ToolException e) { + return e.asResult(); + } + } + + private CallToolResult read(ToolArguments arguments) { + String owner = resolveOwner(arguments); + QueryResult found = + eventQuery.run( + EventFilter.builder() + .kind(SERVER_LIST_KIND) + .author(owner) + .limit(limits.maxEventsPerQuery()) + .build(), + limits.maxEventsPerQuery(), + limits.queryTimeout()); + + return found.events().stream() + .max(Comparator.comparing(GenericEvent::getCreatedAt)) + .map(event -> describe(owner, serversIn(event))) + .orElseGet(() -> noList(owner, found)); + } + + /** + * Pulls the server URLs out of a list event, keeping the author's order. + * + *

Anything that is not a {@code server} tag is ignored rather than refused: BUD-03 leaves + * room for other tags, and a list carrying one should still be readable. + */ + private List serversIn(GenericEvent event) { + return BlossomServers.fromServerTags( + event.getTags().stream() + .filter(tag -> SERVER_TAG.equals(tag.getCode())) + .filter(GenericTag.class::isInstance) + .map(GenericTag.class::cast) + .map(GenericTag::getParams) + .filter(params -> !params.isEmpty()) + .map(params -> params.get(0)) + .toList()); + } + + private CallToolResult describe(String owner, List servers) { + return CallToolResult.builder() + .structuredContent( + Map.of("pubkey", owner, "servers", servers, "count", servers.size(), "found", true)) + .addTextContent( + servers.isEmpty() + ? "That key published a server list with no servers in it." + : "Blossom servers, most trusted first: " + String.join(", ", servers) + ".") + .build(); + } + + /** + * Reports an absent list, distinguishing it from a lookup that did not finish. + * + *

Most keys have never published one, which is a fact about the person rather than a + * failure. A timed-out query is not the same thing, and saying so stops an agent concluding + * somebody hosts nothing. + */ + private CallToolResult noList(String owner, QueryResult found) { + return CallToolResult.builder() + .structuredContent( + Map.of("pubkey", owner, "servers", List.of(), "count", 0, "found", false)) + .addTextContent( + found.timedOut() + ? "No server list arrived before the query timed out, so this key may have one." + : "This key published no Blossom server list on the configured relays.") + .build(); + } + + private String resolveOwner(ToolArguments arguments) { + return arguments + .text("pubkey") + .map(pubkey -> NostrIdentifier.publicKey("pubkey", pubkey).hex()) + .orElseGet( + () -> + identityVault + .defaultAlias() + .map(alias -> identityVault.publicKeyOf(alias).toHexString()) + .orElseThrow( + () -> + ToolFailure.IDENTITY_AMBIGUOUS.raise( + "Give a 'pubkey', since this server has no default identity whose" + + " server list could be meant."))); + } +} diff --git a/nostr-java-mcp/src/main/java/nostr/mcp/tool/BlossomSetServersTool.java b/nostr-java-mcp/src/main/java/nostr/mcp/tool/BlossomSetServersTool.java new file mode 100644 index 00000000..b36a4cfc --- /dev/null +++ b/nostr-java-mcp/src/main/java/nostr/mcp/tool/BlossomSetServersTool.java @@ -0,0 +1,97 @@ +package nostr.mcp.tool; + +import lombok.NonNull; +import nostr.event.BaseTag; +import nostr.event.impl.GenericEvent; +import nostr.mcp.argument.ToolArguments; +import nostr.mcp.blossom.BlossomServers; +import nostr.mcp.write.WriteGuard; + +import java.util.List; +import java.util.Map; + +/** + * Publishes which Blossom servers this identity uses. + * + *

A plain publishing tool, so it inherits confirmation, the rate limit and identity + * resolution from {@link nostr.mcp.tool.PublishingTool} rather than restating them. Kind 10063 + * is replaceable: publishing a list replaces the previous one outright, so the call carries + * every server the user wants listed, not just the new one. + * + *

Order is preserved because BUD-03 makes it meaningful — clients upload to the first server + * and search the rest in turn, so the order is the user's statement about which they trust. + */ +public final class BlossomSetServersTool extends PublishingTool { + + private static final String SERVER_TAG = "server"; + + private final BlossomServers servers; + + /** + * @param writeGuard the point every write passes through + * @param servers validates each URL before it is published + */ + public BlossomSetServersTool(@NonNull WriteGuard writeGuard, @NonNull BlossomServers servers) { + super(writeGuard); + this.servers = servers; + } + + @Override + public String name() { + return "nostr_blossom_set_servers"; + } + + @Override + public String description() { + return "Publish the list of Blossom media servers this identity uses (BUD-03, kind 10063)," + + " most trusted first. Replaces any previous list."; + } + + @Override + protected Map writeSpecificProperties() { + return Map.of( + "servers", + Map.of( + "type", "array", + "description", + "Full server URLs including https://, most trusted first. This replaces the" + + " existing list, so include every server that should remain.", + "items", Map.of("type", "string"))); + } + + @Override + protected List writeSpecificRequired() { + return List.of("servers"); + } + + @Override + protected GenericEvent buildEvent(ToolArguments arguments) { + List urls = arguments.texts("servers"); + if (urls.isEmpty()) { + throw ToolFailure.INVALID_ARGUMENT.raise( + "'servers' needs at least one server URL. To stop advertising any server, the list" + + " event has to be deleted rather than published empty."); + } + List tags = + BlossomServers.fromServerTags(urls).stream() + .peek(BlossomServers::requireUsable) + .map(url -> BaseTag.create(SERVER_TAG, url)) + .toList(); + return GenericEvent.builder() + .kind(BlossomServerListTool.SERVER_LIST_KIND) + .content("") + .createdAt(System.currentTimeMillis() / 1000) + .tags(tags) + .build(); + } + + @Override + protected String describeForPreview(GenericEvent event) { + return "Blossom servers, most trusted first:\n" + + event.getTags().stream() + .filter(tag -> SERVER_TAG.equals(tag.getCode())) + .map(tag -> " " + ((nostr.event.tag.GenericTag) tag).getParams().get(0)) + .reduce((first, second) -> first + "\n" + second) + .orElse(" (none)"); + } +} diff --git a/nostr-java-mcp/src/main/java/nostr/mcp/tool/BlossomUploadTool.java b/nostr-java-mcp/src/main/java/nostr/mcp/tool/BlossomUploadTool.java new file mode 100644 index 00000000..d95ca683 --- /dev/null +++ b/nostr-java-mcp/src/main/java/nostr/mcp/tool/BlossomUploadTool.java @@ -0,0 +1,182 @@ +package nostr.mcp.tool; + +import io.modelcontextprotocol.spec.McpSchema.CallToolRequest; +import io.modelcontextprotocol.spec.McpSchema.CallToolResult; +import lombok.NonNull; +import nostr.event.impl.GenericEvent; +import nostr.mcp.argument.ToolArguments; +import nostr.mcp.blossom.BlobDescriptor; +import nostr.mcp.blossom.BlobSource; +import nostr.mcp.blossom.BlossomAuth; +import nostr.mcp.blossom.BlossomClient; +import nostr.mcp.blossom.BlossomServers; +import nostr.mcp.blossom.BlossomVerb; +import nostr.mcp.write.WriteGuard; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * Copies media onto a Blossom server and hands back the URL. + * + *

This is the tool the module exists for: an agent has a link to media and needs it hosted + * somewhere the user controls, addressed by hash, ready to put in a note. + * + *

It takes a URL rather than a file because this server has no business reading the disk it + * runs on. An upload tool that accepted a path would let an agent put any readable file on a + * public CDN, and no amount of care in the prompt makes that a good capability to hand out. + * What the URL costs is that the media has to be reachable already; what it buys is that the + * worst case is a public file being copied to a public server. + * + *

Registered only where writing is allowed, and signed through the write guard, so an upload + * counts against the same rate limit as a published note. Uploading is publishing. + */ +public final class BlossomUploadTool implements NostrTool { + + private final BlossomServers servers; + private final BlossomClient client; + private final BlobSource blobSource; + private final BlossomAuth auth; + private final WriteGuard writeGuard; + + /** + * @param servers where the blob goes + * @param client how to put it there + * @param blobSource fetches the media, under guard + * @param auth builds the upload token + * @param writeGuard applies the write policy and rate limit, and signs + */ + public BlossomUploadTool( + @NonNull BlossomServers servers, + @NonNull BlossomClient client, + @NonNull BlobSource blobSource, + @NonNull BlossomAuth auth, + @NonNull WriteGuard writeGuard) { + this.servers = servers; + this.client = client; + this.blobSource = blobSource; + this.auth = auth; + this.writeGuard = writeGuard; + } + + @Override + public String name() { + return "nostr_blossom_upload"; + } + + @Override + public String description() { + return "Upload media to a Blossom server from a URL and return the hosted URL. The media must" + + " already be reachable over http(s); this cannot upload local files."; + } + + @Override + public Map inputSchema() { + Map properties = new LinkedHashMap<>(); + properties.put( + "sourceUrl", + Map.of( + "type", + "string", + "description", + "The http(s) URL the media is currently at. It is fetched and re-hosted.")); + properties.put( + "server", + Map.of( + "type", + "string", + "description", + BlobHash.describeServerArgument(servers.configured(), "upload to"))); + if (!writeGuard.bindsOneIdentity()) { + properties.put( + "identity", + Map.of("type", "string", "description", "Alias to upload as. Omit to use the default.")); + } + return Map.of("type", "object", "properties", properties, "required", List.of("sourceUrl")); + } + + @Override + public CallToolResult call(CallToolRequest request) { + try { + return upload(new ToolArguments(request.arguments())); + } catch (ToolException e) { + return e.asResult(); + } + } + + private CallToolResult upload(ToolArguments arguments) { + String sourceUrl = arguments.requireText("sourceUrl"); + String server = servers.resolve(arguments.text("server")); + + // Settle permission before fetching, not after. The token cannot be built until the blob is + // hashed, but a caller who is not allowed to upload should not be able to make this server + // pull a 16 MiB file into memory first. + String alias = writeGuard.authorizeWrite(arguments.text("identity")); + + BlobSource.Blob blob = blobSource.fetch(sourceUrl); + + GenericEvent token = auth.tokenFor(BlossomVerb.UPLOAD, Optional.of(blob.sha256())); + writeGuard.signAs(alias, token); + + BlobDescriptor stored = + client.upload( + server, + blob.bytes(), + blob.contentTypeOrDefault(), + blob.sha256(), + auth.headerValue(token)); + + return report(server, alias, blob, stored); + } + + /** + * The blob's size, preferring what we know over what the server said. + * + *

Not every server fills this in: blossom-server answers uploads with {@code "size": 0} + * whatever it stored. Passing that on would tell an agent a file it just uploaded is empty, + * and the byte count we sent is a fact we actually have. The server's figure wins when it + * gave one, since only it knows what it wrote. + */ + private long reportedSize(BlobSource.Blob blob, BlobDescriptor stored) { + return stored.size() > 0 ? stored.size() : blob.size(); + } + + /** + * Reports what was stored, and says so plainly if the server changed it. + * + *

BUD-02 forbids a server from modifying a blob, so a hash that comes back different means + * the URL does not point at what was sent. Reporting it rather than failing is the honest + * option: the upload did happen, and the caller needs the URL that actually works. + */ + private CallToolResult report( + String server, String alias, BlobSource.Blob blob, BlobDescriptor stored) { + boolean hashMatches = blob.sha256().equalsIgnoreCase(stored.sha256()) || stored.sha256().isBlank(); + Map structured = new LinkedHashMap<>(stored.asStructuredContent()); + structured.put("size", reportedSize(blob, stored)); + structured.put("server", server); + structured.put("identity", alias); + structured.put("hashMatches", hashMatches); + + StringBuilder summary = + new StringBuilder("Uploaded to ") + .append(server) + .append(" as '") + .append(alias) + .append("': ") + .append(stored.url()); + if (!hashMatches) { + summary + .append(". The server stored a different hash (") + .append(stored.sha256()) + .append(") than was sent (") + .append(blob.sha256()) + .append("), so it altered the file. Use the URL above, not the hash that was sent"); + } + return CallToolResult.builder() + .structuredContent(structured) + .addTextContent(summary.append('.').toString()) + .build(); + } +} diff --git a/nostr-java-mcp/src/main/java/nostr/mcp/tool/ToolFailure.java b/nostr-java-mcp/src/main/java/nostr/mcp/tool/ToolFailure.java index faa6418d..53818092 100644 --- a/nostr-java-mcp/src/main/java/nostr/mcp/tool/ToolFailure.java +++ b/nostr-java-mcp/src/main/java/nostr/mcp/tool/ToolFailure.java @@ -29,7 +29,13 @@ public enum ToolFailure { /** The named subscription does not exist or has been reaped. */ SUBSCRIPTION_UNKNOWN, /** The server already holds as many subscriptions as it allows. */ - SUBSCRIPTION_LIMIT_REACHED; + SUBSCRIPTION_LIMIT_REACHED, + /** No Blossom server could be reached to store or fetch a blob. */ + BLOB_SERVER_UNREACHABLE, + /** The Blossom server understood the request and refused it, with its reason in the message. */ + BLOB_SERVER_REJECTED, + /** No Blossom server holds a blob with that hash. */ + BLOB_NOT_FOUND; /** * Raise this failure from wherever it is detected. diff --git a/nostr-java-mcp/src/main/java/nostr/mcp/tool/ToolSurface.java b/nostr-java-mcp/src/main/java/nostr/mcp/tool/ToolSurface.java index f82aa79a..f814c831 100644 --- a/nostr-java-mcp/src/main/java/nostr/mcp/tool/ToolSurface.java +++ b/nostr-java-mcp/src/main/java/nostr/mcp/tool/ToolSurface.java @@ -5,6 +5,10 @@ import nostr.mcp.identity.IdentityLifecycle; import nostr.mcp.identity.IdentityPolicy; import nostr.mcp.identity.IdentityVault; +import nostr.mcp.blossom.BlobSource; +import nostr.mcp.blossom.BlossomAuth; +import nostr.mcp.blossom.BlossomClient; +import nostr.mcp.blossom.BlossomServers; import nostr.mcp.directory.Nip05Resolver; import nostr.mcp.directory.WellKnownJson; import nostr.mcp.query.EventQuery; @@ -61,9 +65,13 @@ public static NostrToolRegistry forServer( IdentityLifecycle identityLifecycle, @NonNull IdentityPolicy identityPolicy, @NonNull SubscriptionRegistry subscriptions, - @NonNull McpDirectMessageService directMessages) { + @NonNull McpDirectMessageService directMessages, + @NonNull BlossomServers blossomServers, + @NonNull BlobSource blobSource) { EventQuery eventQuery = new EventQuery(relayPool); WellKnownJson wellKnownJson = new WellKnownJson(); + BlossomClient blossomClient = new BlossomClient(); + BlossomAuth blossomAuth = new BlossomAuth(clock); NostrToolRegistry registry = new NostrToolRegistry() .register(new ListRelaysTool(relayDirectory, relayPool)) @@ -79,10 +87,17 @@ public static NostrToolRegistry forServer( .register(new GetContactsTool(eventQuery, identityVault, queryLimits)) .register( new ReadDirectMessagesTool( - directMessages, identityVault, eventQuery, queryLimits, clock)); + directMessages, identityVault, eventQuery, queryLimits, clock)) + .register(new BlossomGetBlobTool(blossomServers, blossomClient)) + .register(new BlossomListTool(blossomServers, blossomClient, blossomAuth, identityVault)) + .register(new BlossomServerListTool(eventQuery, identityVault, queryLimits)); if (writePolicy.allowsWriteTools()) { writeTools(writeGuard).forEach(registry::register); registry.register(new SendDirectMessageTool(directMessages, identityVault)); + registry.register( + new BlossomUploadTool(blossomServers, blossomClient, blobSource, blossomAuth, writeGuard)); + registry.register(new BlossomDeleteTool(blossomServers, blossomClient, blossomAuth, writeGuard)); + registry.register(new BlossomSetServersTool(writeGuard, blossomServers)); } if (registersAdministration(identityVault, identityPolicy, identityLifecycle)) { administrationTools(identityLifecycle, identityVault, identityPolicy).forEach(registry::register); diff --git a/nostr-java-mcp/src/main/java/nostr/mcp/write/WriteGuard.java b/nostr-java-mcp/src/main/java/nostr/mcp/write/WriteGuard.java index e93c9f3b..6382e15d 100644 --- a/nostr-java-mcp/src/main/java/nostr/mcp/write/WriteGuard.java +++ b/nostr-java-mcp/src/main/java/nostr/mcp/write/WriteGuard.java @@ -7,6 +7,7 @@ import nostr.client.relay.RelayPool; import nostr.event.impl.GenericEvent; import nostr.mcp.identity.IdentityVault; +import nostr.mcp.identity.SigningAlias; import nostr.mcp.tool.ToolFailure; import java.security.SecureRandom; @@ -67,18 +68,74 @@ public WriteGuard( * ambiguous, or the rate limit is exhausted */ public PendingWrite prepare(@NonNull GenericEvent event, Optional requestedAlias) { - refuseIfDenied(); - String alias = resolveIdentity(requestedAlias); - enforceRateLimit(alias); - GenericEvent signed = sign(event, alias); + String alias = signWriteToken(event, requestedAlias); PendingWrite pending = - new PendingWrite(newToken(), signed, alias); + new PendingWrite(newToken(), event, alias); if (policy.requiresConfirmation()) { pendingByToken.put(pending.token(), pending); } return pending; } + /** + * Sign something that acts on the world without going to a relay. + * + *

A Blossom upload is a write: it puts bytes on a public server under the user's key, and + * once they are there they are addressed by their hash and may be copied anywhere. So it + * passes the same three gates a published event does, and the only difference is that what + * comes back is a credential rather than a queued publication. + * + *

Existing here rather than in the Blossom package is the point. A tool that signed through + * the vault directly would silently skip the policy and the rate limit, and nothing would + * catch it; routing through the guard means a new kind of write inherits all three by + * construction, exactly as a new publishing tool does. + * + * @param event the unsigned event, which is mutated in place + * @param requestedAlias the identity to sign as, or empty to use the default + * @return the alias it was signed as + * @throws nostr.mcp.tool.ToolException when writing is denied, the identity is unknown or + * ambiguous, or the rate limit is exhausted + */ + public String signWriteToken(@NonNull GenericEvent event, Optional requestedAlias) { + String alias = authorizeWrite(requestedAlias); + signAs(alias, event); + return alias; + } + + /** + * Settle whether a write may happen at all, before doing the work it needs. + * + *

Split from signing because some writes have to gather something expensive first. A + * Blossom upload cannot build its token until it has fetched the blob and hashed it, so + * signing last would put an outbound fetch of up to the configured cap, buffered in memory, + * on the wrong side of the rate limit: a caller past its quota would still make this server + * pull the bytes before being refused. Taking the quota first makes the refusal free. + * + * @param requestedAlias the identity to write as, or empty to use the default + * @return the alias the write is authorized for + * @throws nostr.mcp.tool.ToolException when writing is denied, the identity is unknown or + * ambiguous, or the rate limit is exhausted + */ + public String authorizeWrite(Optional requestedAlias) { + refuseIfDenied(); + String alias = resolveIdentity(requestedAlias); + enforceRateLimit(alias); + return alias; + } + + /** + * Sign an event as an already-authorized identity. + * + *

Takes no further quota: the caller paid for this write in {@link #authorizeWrite}. It is + * also what lets a held write be re-signed later, which a token carrying an expiry needs. + * + * @param alias the identity to sign as, from {@link #authorizeWrite} + * @param event the unsigned event, which is mutated in place + */ + public void signAs(@NonNull String alias, @NonNull GenericEvent event) { + sign(event, alias); + } + /** * Publish an event the agent has confirmed. * @@ -195,35 +252,8 @@ private void refuseIfDenied() { } } - /** - * Chooses the identity to sign as, refusing to guess. - * - *

Posting as the wrong account is public and irreversible, so where several identities exist - * and none is the default, the tool asks rather than picking one. - */ private String resolveIdentity(Optional requestedAlias) { - if (requestedAlias.isPresent()) { - String alias = requestedAlias.get(); - if (identityVault.find(alias).isEmpty()) { - throw ToolFailure.IDENTITY_UNKNOWN.raise( - "No identity called '" - + alias - + "'. Available: " - + identityVault.list().stream().map(summary -> summary.alias()).toList()); - } - return alias; - } - return identityVault - .defaultAlias() - .orElseThrow( - () -> - ToolFailure.IDENTITY_AMBIGUOUS.raise( - identityVault.isEmpty() - ? "This server holds no identity to sign with. Create one with the" - + " command line: java -jar nostr-java-mcp.jar keygen " - : "This server holds several identities and none is the default, so" - + " signing would be a guess. Name one in the 'identity' argument:" - + identityVault.list().stream().map(summary -> summary.alias()).toList())); + return SigningAlias.resolve(identityVault, requestedAlias); } private void enforceRateLimit(String alias) { @@ -235,10 +265,7 @@ private void enforceRateLimit(String alias) { } private GenericEvent sign(GenericEvent event, String alias) { - event.setPubKey(identityVault.publicKeyOf(alias)); - event.update(); - identityVault.signAs(alias, event); - return event; + return SigningAlias.sign(identityVault, alias, event); } private String newToken() { diff --git a/nostr-java-mcp/src/test/java/nostr/mcp/HttpTransportIT.java b/nostr-java-mcp/src/test/java/nostr/mcp/HttpTransportIT.java index 40150bac..1b6cb71b 100644 --- a/nostr-java-mcp/src/test/java/nostr/mcp/HttpTransportIT.java +++ b/nostr-java-mcp/src/test/java/nostr/mcp/HttpTransportIT.java @@ -10,6 +10,9 @@ import nostr.client.relay.FakeRelay; import nostr.client.relay.RelayPool; import nostr.id.Identity; +import nostr.mcp.blossom.BlobSource; +import nostr.mcp.blossom.BlossomServers; +import nostr.mcp.blossom.PublicHttpUrl; import nostr.mcp.identity.IdentityBinding; import nostr.mcp.identity.IdentityPolicy; import nostr.mcp.identity.IdentityVault; @@ -165,7 +168,9 @@ pool, vault, policy, new RateLimit(100, Duration.ofMinutes(1), Clock.systemUTC() null, IdentityPolicy.fromConfiguredValue(null, policy), subscriptions, - new McpDirectMessageService(vault, pool, Set.of())); + new McpDirectMessageService(vault, pool, Set.of()), + new BlossomServers(List.of("https://cdn.example.com"), new PublicHttpUrl(false)), + new BlobSource(new PublicHttpUrl(false), 1024)); } private SubscriptionRegistry subscriptionsOf(RelayPool pool) { diff --git a/nostr-java-mcp/src/test/java/nostr/mcp/McpConfigurationTest.java b/nostr-java-mcp/src/test/java/nostr/mcp/McpConfigurationTest.java index 5b10a069..89dccdd8 100644 --- a/nostr-java-mcp/src/test/java/nostr/mcp/McpConfigurationTest.java +++ b/nostr-java-mcp/src/test/java/nostr/mcp/McpConfigurationTest.java @@ -23,7 +23,7 @@ class McpConfigurationTest { private static final Path GUIDE = Path.of("../docs/howto/run-the-mcp-server.md"); private static final Pattern SETTING_IN_CODE = - Pattern.compile("(?:setting|settingOr|commaSeparated|positiveIntOr|durationOr|relayList)\\(\"([a-z0-9.-]+)\""); + Pattern.compile("(?:setting|settingOr|commaSeparated|positiveIntOr|positiveLongOr|durationOr|relayList)\\(\"([a-z0-9.-]+)\""); private final List propertiesSet = new java.util.ArrayList<>(); diff --git a/nostr-java-mcp/src/test/java/nostr/mcp/NostrMcpServerStdioIT.java b/nostr-java-mcp/src/test/java/nostr/mcp/NostrMcpServerStdioIT.java index 04bcc2e9..993abf14 100644 --- a/nostr-java-mcp/src/test/java/nostr/mcp/NostrMcpServerStdioIT.java +++ b/nostr-java-mcp/src/test/java/nostr/mcp/NostrMcpServerStdioIT.java @@ -61,10 +61,16 @@ void aHostCanLaunchTheServerAndDiscoverItsTools() { "nostr_fetch_thread", "nostr_get_contacts", "nostr_read_direct_messages", + "nostr_blossom_get", + "nostr_blossom_list", + "nostr_blossom_get_servers", "nostr_publish_note", "nostr_publish_event", "nostr_update_profile", "nostr_send_direct_message", + "nostr_blossom_upload", + "nostr_blossom_delete", + "nostr_blossom_set_servers", "nostr_create_identity", "nostr_import_identity", "nostr_rename_identity", diff --git a/nostr-java-mcp/src/test/java/nostr/mcp/blossom/BlobSourceTest.java b/nostr-java-mcp/src/test/java/nostr/mcp/blossom/BlobSourceTest.java new file mode 100644 index 00000000..3120e3cc --- /dev/null +++ b/nostr-java-mcp/src/test/java/nostr/mcp/blossom/BlobSourceTest.java @@ -0,0 +1,132 @@ +package nostr.mcp.blossom; + +import nostr.mcp.tool.ToolException; +import nostr.mcp.tool.ToolFailure; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * The bounds on fetching something an agent named. + * + *

These tests use the permissive guard, because the stub server is on loopback. The refusals + * the strict guard makes are covered by {@link PublicHttpUrlTest}; what is left to prove here is + * the size cap and the redirect refusal, which are the two ways a URL that passes the address + * check can still cause trouble. + */ +class BlobSourceTest { + + private static final long CAP = 64; + + private final BlobSource source = new BlobSource(new PublicHttpUrl(true), CAP); + + // Verifies a blob is fetched with its type and hashed, since the hash is what the upload + // token must commit to before the bytes can be sent anywhere. + @Test + void aFetchedBlobCarriesItsTypeAndItsHash() { + try (StubHttpServer stub = + StubHttpServer.started( + exchange -> StubHttpServer.respond(exchange, 200, "hello", "image/png"))) { + + BlobSource.Blob blob = source.fetch(stub.baseUrl() + "/photo.png"); + + assertEquals("hello", new String(blob.bytes(), StandardCharsets.UTF_8)); + assertEquals("image/png", blob.contentType()); + assertEquals( + "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", blob.sha256()); + } + } + + // Verifies the charset parameter is dropped. "text/plain; charset=utf-8" as a stored MIME type + // makes a blob's type differ from the same blob uploaded elsewhere. + @Test + void aContentTypeLosesItsParameters() { + try (StubHttpServer stub = + StubHttpServer.started( + exchange -> StubHttpServer.respond(exchange, 200, "hi", "text/plain; charset=utf-8"))) { + + assertEquals("text/plain", source.fetch(stub.baseUrl() + "/note.txt").contentType()); + } + } + + // Verifies the cap holds. Bytes are buffered in memory to compute the hash, so without this + // an agent could name a URL that serves gigabytes and take the server down. + @Test + void aBlobLargerThanTheCapIsRefused() { + String tooBig = "x".repeat((int) CAP + 1); + try (StubHttpServer stub = + StubHttpServer.started( + exchange -> StubHttpServer.respond(exchange, 200, tooBig, "text/plain"))) { + + ToolException refused = + assertThrows(ToolException.class, () -> source.fetch(stub.baseUrl() + "/big.txt")); + + assertEquals(ToolFailure.INVALID_ARGUMENT, refused.getFailure()); + assertTrue(refused.getMessage().contains("larger than"), refused.getMessage()); + } + } + + // Verifies a blob exactly at the cap is allowed, because an off-by-one here silently refuses + // the largest file the operator said was fine. + @Test + void aBlobExactlyAtTheCapIsAllowed() { + String exact = "x".repeat((int) CAP); + try (StubHttpServer stub = + StubHttpServer.started( + exchange -> StubHttpServer.respond(exchange, 200, exact, "text/plain"))) { + + assertEquals(CAP, source.fetch(stub.baseUrl() + "/exact.txt").size()); + } + } + + // Verifies a redirect is refused rather than followed. A redirect is the simplest way past + // the address check: the named URL resolves publicly and then points at link-local. + @Test + void aRedirectIsRefusedRatherThanFollowed() { + try (StubHttpServer stub = + StubHttpServer.started( + exchange -> { + exchange.getResponseHeaders().add("Location", "http://169.254.169.254/"); + StubHttpServer.respond(exchange, 302, ""); + })) { + + ToolException refused = + assertThrows(ToolException.class, () -> source.fetch(stub.baseUrl() + "/sneaky")); + + assertEquals(ToolFailure.INVALID_ARGUMENT, refused.getFailure()); + assertTrue(refused.getMessage().contains("169.254.169.254"), refused.getMessage()); + } + } + + // Verifies an empty response is refused. Uploading zero bytes would store a blob whose hash + // is the hash of nothing, on every server, forever. + @Test + void anEmptyResponseIsRefused() { + try (StubHttpServer stub = + StubHttpServer.started(exchange -> StubHttpServer.respond(exchange, 200, ""))) { + + ToolException refused = + assertThrows(ToolException.class, () -> source.fetch(stub.baseUrl() + "/empty")); + + assertTrue(refused.getMessage().contains("no content"), refused.getMessage()); + } + } + + // Verifies a source that answers with an error is reported as unreachable rather than + // uploaded as if the error page were the media. + @Test + void aSourceThatErrorsIsNotUploaded() { + try (StubHttpServer stub = + StubHttpServer.started(exchange -> StubHttpServer.respond(exchange, 404, "nope"))) { + + ToolException failed = + assertThrows(ToolException.class, () -> source.fetch(stub.baseUrl() + "/missing")); + + assertEquals(ToolFailure.BLOB_SERVER_UNREACHABLE, failed.getFailure()); + } + } +} diff --git a/nostr-java-mcp/src/test/java/nostr/mcp/blossom/BlossomAuthTest.java b/nostr-java-mcp/src/test/java/nostr/mcp/blossom/BlossomAuthTest.java new file mode 100644 index 00000000..1145a35a --- /dev/null +++ b/nostr-java-mcp/src/test/java/nostr/mcp/blossom/BlossomAuthTest.java @@ -0,0 +1,136 @@ +package nostr.mcp.blossom; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import nostr.event.impl.GenericEvent; +import nostr.id.Identity; +import nostr.mcp.tool.ToolException; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Base64; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Pins the credential a Blossom server actually reads. + * + *

Everything else in this package fails loudly when it is wrong. This does not: a token with + * the wrong encoding, a missing tag or a stale expiry produces a 401 whose body says nothing, + * and the symptom looks identical to a misconfigured server. So the shape is asserted here, + * against the bytes that go on the wire rather than against the object that produced them. + */ +class BlossomAuthTest { + + private static final Instant NOW = Instant.parse("2026-09-21T12:00:00Z"); + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final String BLOB_HASH = + "b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553"; + + private final BlossomAuth auth = new BlossomAuth(Clock.fixed(NOW, ZoneOffset.UTC)); + + // Verifies the token carries what BUD-11 requires a server to check: the reserved kind, the + // verb matching the endpoint, an expiry in the future, and the blob the token is limited to. + @Test + void anUploadTokenCarriesTheVerbTheExpiryAndTheBlobHash() { + GenericEvent token = auth.tokenFor(BlossomVerb.UPLOAD, Optional.of(BLOB_HASH)); + + assertEquals(24_242, token.getKind()); + assertEquals("upload", tagValue(token, "t")); + assertEquals(BLOB_HASH, tagValue(token, "x")); + assertEquals(NOW.getEpochSecond(), token.getCreatedAt()); + assertTrue( + Long.parseLong(tagValue(token, "expiration")) > NOW.getEpochSecond(), + "the expiration must be in the future or every request is refused"); + assertFalse(token.getContent().isBlank(), "BUD-11 requires a human-readable intent"); + } + + // Verifies a list token carries no x tag. BUD-11 scopes a token to the blobs named by its x + // tags, so an x tag on a list request would narrow the listing to one blob. + @Test + void aListTokenIsNotLimitedToAnyBlob() { + GenericEvent token = auth.tokenFor(BlossomVerb.LIST, Optional.empty()); + + assertEquals("list", tagValue(token, "t")); + assertEquals("", tagValue(token, "x")); + } + + // Verifies two tokens made at the same instant are different events. A nostr event's id is the + // hash of its contents, so without a nonce two tokens for one verb and blob in the same second + // share an id, and a server with a replay cache refuses the second: blossom-server answers + // "400 Auth event already used". A list token has no x tag to vary, so two listings in one + // second are the worst case. + @Test + void twoTokensMadeAtTheSameInstantAreDifferentEvents() { + GenericEvent first = signed(auth.tokenFor(BlossomVerb.LIST, Optional.empty())); + GenericEvent second = signed(auth.tokenFor(BlossomVerb.LIST, Optional.empty())); + + assertEquals(first.getCreatedAt(), second.getCreatedAt(), "the clock is fixed, so this holds"); + assertNotEquals(first.getId(), second.getId(), "a replayable token"); + } + + // Verifies the header is base64url without padding, as BUD-11 specifies and the reference + // client sends. Standard base64 differs in two characters and a server rejects it with a bare + // 401, so decoding the header back into the event is the only check that proves interop. + @Test + void theHeaderIsBase64UrlOfTheSignedEvent() { + GenericEvent token = signed(auth.tokenFor(BlossomVerb.UPLOAD, Optional.of(BLOB_HASH))); + + String header = auth.headerValue(token); + + assertTrue(header.startsWith("Nostr "), header); + String encoded = header.substring("Nostr ".length()); + assertFalse(encoded.contains("+"), "base64url uses '-', not '+'"); + assertFalse(encoded.contains("/"), "base64url uses '_', not '/'"); + assertFalse(encoded.endsWith("="), "BUD-11 specifies no padding"); + + JsonNode decoded = decode(encoded); + assertEquals(24_242, decoded.get("kind").asInt()); + assertEquals(token.getId(), decoded.get("id").asText()); + assertFalse(decoded.get("sig").asText().isBlank(), "a server verifies the signature"); + } + + // Verifies an unsigned token never reaches a server. An unsigned event is not a credential, + // and sending one turns a programming mistake into a 401 that looks like a server problem. + @Test + void anUnsignedTokenIsRefusedBeforeItIsSent() { + GenericEvent unsigned = auth.tokenFor(BlossomVerb.DELETE, Optional.of(BLOB_HASH)); + + ToolException refused = assertThrows(ToolException.class, () -> auth.headerValue(unsigned)); + + assertTrue(refused.getMessage().contains("unsigned"), refused.getMessage()); + } + + private GenericEvent signed(GenericEvent token) { + Identity identity = Identity.generateRandomIdentity(); + token.setPubKey(identity.getPublicKey()); + token.update(); + identity.sign(token); + return token; + } + + private JsonNode decode(String encoded) { + byte[] json = Base64.getUrlDecoder().decode(encoded); + try { + return MAPPER.readTree(new String(json, StandardCharsets.UTF_8)); + } catch (Exception e) { + throw new AssertionError("the header did not decode to JSON", e); + } + } + + private String tagValue(GenericEvent event, String code) { + return event.getTags().stream() + .filter(tag -> code.equals(tag.getCode())) + .map(tag -> ((nostr.event.tag.GenericTag) tag).getParams().get(0)) + .findFirst() + .orElse(""); + } +} diff --git a/nostr-java-mcp/src/test/java/nostr/mcp/blossom/BlossomClientTest.java b/nostr-java-mcp/src/test/java/nostr/mcp/blossom/BlossomClientTest.java new file mode 100644 index 00000000..8f54ffe6 --- /dev/null +++ b/nostr-java-mcp/src/test/java/nostr/mcp/blossom/BlossomClientTest.java @@ -0,0 +1,258 @@ +package nostr.mcp.blossom; + +import nostr.mcp.tool.ToolException; +import nostr.mcp.tool.ToolFailure; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * What this client sends, and what it makes of what comes back. + * + *

Run against a real server on loopback rather than a mocked client, because the claims worth + * making here are about the wire: that the token reaches the {@code Authorization} header, that + * a 404 is a different outcome from a 500, and that a server's {@code X-Reason} survives into + * something the agent reads. + */ +class BlossomClientTest { + + private static final String DESCRIPTOR = + """ + {"url":"https://cdn.example.com/b167.pdf","sha256":"b167","size":184292,\ + "type":"application/pdf","uploaded":1725105921}"""; + + private final BlossomClient client = new BlossomClient(); + + // Verifies the signed token actually reaches the server, and the blob's bytes with it. A + // token built correctly but never attached fails exactly like a token built wrong. + @Test + void anUploadSendsTheTokenAndTheBytes() { + try (StubHttpServer stub = + StubHttpServer.started(exchange -> StubHttpServer.respond(exchange, 201, DESCRIPTOR))) { + + BlobDescriptor stored = + client.upload( + stub.baseUrl(), "hello".getBytes(StandardCharsets.UTF_8), "text/plain", "b167", "Nostr abc123"); + + StubHttpServer.Request sent = stub.lastRequest(); + assertEquals("PUT", sent.method()); + assertEquals("/upload", sent.path()); + assertEquals("Nostr abc123", sent.authorization()); + assertEquals("hello", new String(sent.body(), StandardCharsets.UTF_8)); + assertEquals("https://cdn.example.com/b167.pdf", stored.url()); + assertEquals(184292, stored.size()); + } + } + + // Verifies a refusal carries the server's own reason. "HTTP 413" alone does not tell an agent + // whether to shrink the file or stop trying; "Max allowed size is 100MB" does. + @Test + void aRefusalCarriesTheServersReason() { + try (StubHttpServer stub = + StubHttpServer.started( + exchange -> { + exchange.getResponseHeaders().add("X-Reason", "File too large. Max is 100MB."); + StubHttpServer.respond(exchange, 413, ""); + })) { + + ToolException refused = + assertThrows( + ToolException.class, + () -> client.upload(stub.baseUrl(), new byte[] {1}, "image/png", "b167", "Nostr t")); + + assertEquals(ToolFailure.BLOB_SERVER_REJECTED, refused.getFailure()); + assertTrue(refused.getMessage().contains("Max is 100MB"), refused.getMessage()); + } + } + + // Verifies a server fault is told apart from a bad request. A 5xx may be worth retrying and a + // 4xx never is, so collapsing them into one code would have the agent retry a refusal forever. + @Test + void aServerFaultIsUnreachableRatherThanRejected() { + try (StubHttpServer stub = + StubHttpServer.started(exchange -> StubHttpServer.respond(exchange, 503, ""))) { + + ToolException failed = + assertThrows( + ToolException.class, + () -> client.upload(stub.baseUrl(), new byte[] {1}, "image/png", "b167", "Nostr t")); + + assertEquals(ToolFailure.BLOB_SERVER_UNREACHABLE, failed.getFailure()); + } + } + + // Verifies a missing blob is an answer, not an error. "This server does not have it" is the + // normal result of looking across several servers for a blob that lives on one of them. + @Test + void aBlobThatIsNotThereReadsAsAbsentRatherThanFailing() { + try (StubHttpServer stub = + StubHttpServer.started(exchange -> StubHttpServer.respond(exchange, 404, ""))) { + + assertEquals(Optional.empty(), client.head(stub.baseUrl(), "b167")); + } + } + + // Verifies a present blob reports the size and type the server gave, which is the whole point + // of asking with HEAD rather than downloading it. + @Test + void aPresentBlobReportsItsSizeAndType() { + try (StubHttpServer stub = + StubHttpServer.started( + exchange -> StubHttpServer.respond(exchange, 200, "", "application/pdf"))) { + + BlobDescriptor found = client.head(stub.baseUrl(), "b167").orElseThrow(); + + assertEquals("application/pdf", found.type()); + assertEquals(stub.baseUrl() + "/b167", found.url()); + } + } + + // Verifies a listing parses into descriptors and is authorized, since BUD-12 requires a list + // token even though listing is a read. + @Test + void aListingIsAuthorizedAndParsed() { + try (StubHttpServer stub = + StubHttpServer.started( + exchange -> StubHttpServer.respond(exchange, 200, "[" + DESCRIPTOR + "]"))) { + + List blobs = client.list(stub.baseUrl(), "deadbeef", "Nostr listtoken"); + + assertEquals(1, blobs.size()); + assertEquals("Nostr listtoken", stub.lastRequest().authorization()); + assertEquals("/list/deadbeef", stub.lastRequest().path()); + } + } + + // Verifies deleting something that is already gone says so plainly, rather than reporting a + // generic refusal the agent would read as "try again". + @Test + void deletingAMissingBlobSaysItIsNotThere() { + try (StubHttpServer stub = + StubHttpServer.started(exchange -> StubHttpServer.respond(exchange, 404, ""))) { + + ToolException failed = + assertThrows( + ToolException.class, () -> client.delete(stub.baseUrl(), "b167", "Nostr deltoken")); + + assertEquals(ToolFailure.BLOB_NOT_FOUND, failed.getFailure()); + } + } + + // Verifies a descriptor missing its size still parses. blossom-server reports "size": 0 on + // every upload, so a parser that treated an absent or zero size as a failure would reject a + // real server's ordinary answer. + @Test + void aDescriptorWithNoUsableSizeStillParses() { + try (StubHttpServer stub = + StubHttpServer.started( + exchange -> + StubHttpServer.respond( + exchange, 201, "{\"url\":\"https://cdn.example.com/b167.txt\",\"sha256\":\"b167\",\"size\":0}"))) { + + BlobDescriptor stored = + client.upload(stub.baseUrl(), new byte[] {1}, "text/plain", "b167", "Nostr t"); + + assertEquals(0, stored.size()); + assertEquals("application/octet-stream", stored.type()); + assertEquals("https://cdn.example.com/b167.txt", stored.url()); + } + } + + // Verifies a redirect is not followed. The server argument is a URL an agent chose, so a host + // that passes the address check can answer 302 pointing at link-local and have this process + // fetch it. Confirmed against the JDK: it does forward the request and return the body, though + // it does drop the Authorization header on the way. + @Test + void aRedirectFromAServerIsNotFollowed() { + try (StubHttpServer internal = + StubHttpServer.started( + exchange -> StubHttpServer.respond(exchange, 200, "[{\"sha256\":\"leaked\"}]")); + StubHttpServer redirector = + StubHttpServer.started( + exchange -> { + exchange.getResponseHeaders().add("Location", "http://127.0.0.1:1/internal"); + StubHttpServer.respond(exchange, 302, ""); + })) { + + ToolException refused = + assertThrows( + ToolException.class, + () -> client.list(redirector.baseUrl(), "deadbeef", "Nostr listtoken")); + + assertEquals(ToolFailure.BLOB_SERVER_REJECTED, refused.getFailure(), refused.getMessage()); + assertEquals(0, internal.received().size(), "the redirect target was contacted"); + } + } + + // Verifies a redirect on HEAD still reports the blob as present. BUD-01 lets a server point at + // a CDN, and refusing to follow it must not turn "here it is, over there" into "not found". + @Test + void aRedirectOnHeadMeansTheBlobIsThere() { + try (StubHttpServer stub = + StubHttpServer.started( + exchange -> { + exchange.getResponseHeaders().add("Location", "https://cdn.example.com/b167"); + StubHttpServer.respond(exchange, 302, ""); + })) { + + BlobDescriptor found = client.head(stub.baseUrl(), "b167").orElseThrow(); + + assertEquals(stub.baseUrl() + "/b167", found.url()); + } + } + + // Verifies a response the JDK itself cannot parse becomes a code rather than an uncoded crash. + // A malformed Content-Length is rejected inside HttpClient.send, before any of this class's + // own parsing runs, so guarding only the Long.parseLong would have missed it entirely. + @Test + void aResponseTheClientCannotParseBecomesACode() { + try (StubHttpServer stub = + StubHttpServer.started( + exchange -> { + exchange.getResponseHeaders().add("Content-Length", "not-a-number"); + StubHttpServer.respond(exchange, 200, "", "image/png"); + })) { + + ToolException refused = + assertThrows(ToolException.class, () -> client.head(stub.baseUrl(), "b167")); + + assertEquals(ToolFailure.BLOB_SERVER_REJECTED, refused.getFailure(), refused.getMessage()); + } + } + + // Verifies a listing that is not an array is a diagnosable failure. Iterating a JSON object + // yields its values, silently turning an error envelope into a list of empty descriptors. + @Test + void aListingThatIsNotAnArrayIsRefused() { + try (StubHttpServer stub = + StubHttpServer.started( + exchange -> StubHttpServer.respond(exchange, 200, "{\"message\":\"nope\"}"))) { + + ToolException refused = + assertThrows( + ToolException.class, () -> client.list(stub.baseUrl(), "deadbeef", "Nostr t")); + + assertEquals(ToolFailure.BLOB_SERVER_REJECTED, refused.getFailure()); + } + } + + // Verifies a 204 counts as deleted. BUD-12 allows both 200 and 204, and treating the empty + // one as a failure would have the agent retry a delete that already happened. + @Test + void aDeleteWithNoBodySucceeds() { + try (StubHttpServer stub = + StubHttpServer.started(exchange -> StubHttpServer.respond(exchange, 204, ""))) { + + client.delete(stub.baseUrl(), "b167", "Nostr deltoken"); + + assertEquals("DELETE", stub.lastRequest().method()); + assertEquals("/b167", stub.lastRequest().path()); + } + } +} diff --git a/nostr-java-mcp/src/test/java/nostr/mcp/blossom/BlossomServersTest.java b/nostr-java-mcp/src/test/java/nostr/mcp/blossom/BlossomServersTest.java new file mode 100644 index 00000000..00f9161a --- /dev/null +++ b/nostr-java-mcp/src/test/java/nostr/mcp/blossom/BlossomServersTest.java @@ -0,0 +1,115 @@ +package nostr.mcp.blossom; + +import nostr.mcp.tool.ToolException; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** Which server a call acts on, and which URLs an agent is allowed to name. */ +class BlossomServersTest { + + private final BlossomServers servers = + new BlossomServers( + List.of("https://cdn.example.com/", "https://backup.example.com"), + new PublicHttpUrl(false)); + + // Verifies a call that names no server gets the first configured one, since the operator + // ordered them by preference and an agent should not have to know any URL to upload. + @Test + void aCallThatNamesNoServerUsesTheFirstConfiguredOne() { + assertEquals("https://cdn.example.com", servers.resolve(Optional.empty())); + } + + // Verifies the trailing slash is dropped, or every path built from it grows a double slash + // and two spellings of one server become two servers. + @Test + void aTrailingSlashIsNotPartOfTheServer() { + assertEquals("https://cdn.example.com", servers.resolve(Optional.of("https://cdn.example.com/"))); + assertEquals("https://cdn.example.com/b167", BlossomServers.blobUrl("https://cdn.example.com/", "b167")); + } + + // Verifies a server the agent names still has to be a public address. The server argument is + // as much an agent-supplied URL as the source is, and so is as much a way onto this network. + @Test + void aServerTheAgentNamesIsStillChecked() { + ToolException refused = + assertThrows( + ToolException.class, () -> servers.resolve(Optional.of("http://169.254.169.254"))); + + assertTrue(refused.getMessage().contains("not on the public internet"), refused.getMessage()); + } + + // Verifies a configured server bypasses the check, so an operator may point this at a server + // on their own network without also opening that network to the agent's choosing. + @Test + void aConfiguredServerIsNotSubjectToTheCheck() { + BlossomServers onLan = + new BlossomServers(List.of("http://192.168.1.50:3000"), new PublicHttpUrl(false)); + + assertEquals("http://192.168.1.50:3000", onLan.resolve(Optional.empty())); + assertEquals( + "http://192.168.1.50:3000", onLan.resolve(Optional.of("http://192.168.1.50:3000"))); + } + + // Verifies an unconfigured server with nothing named says what to do about it, rather than + // failing with a null the agent cannot interpret. + @Test + void withNothingConfiguredAndNothingNamedTheRefusalSaysWhatToDo() { + BlossomServers none = new BlossomServers(List.of(), new PublicHttpUrl(false)); + + ToolException refused = + assertThrows(ToolException.class, () -> none.resolve(Optional.empty())); + + assertTrue(refused.getMessage().contains("nostr.mcp.blossom.servers"), refused.getMessage()); + } + + // Verifies looking for a blob tries every configured server, since the point of a + // hash-addressed network is that the same blob may be on any of them. + @Test + void searchingTriesEveryConfiguredServer() { + assertEquals( + List.of("https://cdn.example.com", "https://backup.example.com"), + servers.resolveAll(Optional.empty())); + } + + // Verifies publishing a server list checks syntax, not reachability. This event is never + // fetched by this server, so there is no request to forge; applying the address check here + // would stop an operator advertising a Blossom server on their own network, and would fail a + // whole publish on a transient DNS failure for one URL. + @Test + void publishingAServerListAcceptsAPrivateAddress() { + assertEquals( + "http://blossom.lan:3000", + BlossomServers.requireUsable("http://blossom.lan:3000/").toString()); + assertEquals( + "http://192.168.1.50:3000", BlossomServers.requireUsable("http://192.168.1.50:3000").toString()); + } + + // Verifies it still refuses something no client could use, since the point of publishing the + // list is that other people can read it and fetch from it. + @Test + void publishingAServerListRefusesSomethingUnusable() { + assertThrows(ToolException.class, () -> BlossomServers.requireUsable("not a url")); + assertThrows(ToolException.class, () -> BlossomServers.requireUsable("ftp://example.com")); + assertThrows(ToolException.class, () -> BlossomServers.requireUsable("https://")); + } + + // Verifies a BUD-03 list keeps its order, which the spec makes significant: the author lists + // their most trusted server first and clients are expected to honour that. + @Test + void aPublishedServerListKeepsItsOrderAndDropsDuplicates() { + assertEquals( + List.of("https://first.example.com", "https://second.example.com"), + BlossomServers.fromServerTags( + List.of( + "https://first.example.com", + "https://second.example.com/", + "https://first.example.com", + ""))); + } +} diff --git a/nostr-java-mcp/src/test/java/nostr/mcp/blossom/PublicHttpUrlTest.java b/nostr-java-mcp/src/test/java/nostr/mcp/blossom/PublicHttpUrlTest.java new file mode 100644 index 00000000..db69494b --- /dev/null +++ b/nostr-java-mcp/src/test/java/nostr/mcp/blossom/PublicHttpUrlTest.java @@ -0,0 +1,87 @@ +package nostr.mcp.blossom; + +import nostr.mcp.tool.ToolException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * The check that keeps an agent from using this server as a way onto its own network. + * + *

Uses address literals rather than hostnames so the suite proves the predicate rather than + * the state of a DNS resolver, and so it passes with no network at all. + */ +class PublicHttpUrlTest { + + private final PublicHttpUrl guarded = new PublicHttpUrl(false); + + // Verifies the addresses that matter are refused. 169.254.169.254 is the cloud instance + // metadata endpoint and the single most valuable target here; the rest are the private + // ranges an office or cluster network is built from. + @ParameterizedTest + @ValueSource( + strings = { + "http://127.0.0.1/blob", + "http://localhost:3000/blob", + "http://169.254.169.254/latest/meta-data/", + "http://10.0.0.1/blob", + "http://172.16.0.1/blob", + "http://192.168.1.1/blob", + "http://[::1]/blob", + "http://[fc00::1]/blob", + "http://0.0.0.0/blob" + }) + void refusesAnAddressThatIsNotOnThePublicInternet(String url) { + ToolException refused = + assertThrows(ToolException.class, () -> guarded.require("sourceUrl", url)); + + assertTrue( + refused.getMessage().contains("not on the public internet"), + "should say why, so the agent stops retrying: " + refused.getMessage()); + } + + // Verifies a scheme that is not HTTP is refused before anything is resolved. file:// would + // otherwise let an agent read the server's own disk through the upload tool. + @ParameterizedTest + @ValueSource(strings = {"file:///etc/passwd", "ftp://example.com/blob", "gopher://example.com"}) + void refusesASchemeThatIsNotHttp(String url) { + ToolException refused = + assertThrows(ToolException.class, () -> guarded.require("sourceUrl", url)); + + assertTrue(refused.getMessage().contains("http or https"), refused.getMessage()); + } + + // Verifies a public address passes, since a guard that refuses everything is a broken tool + // rather than a safe one. + @Test + void acceptsAPublicAddress() { + assertEquals( + "http://93.184.216.34/photo.jpg", + guarded.require("sourceUrl", "http://93.184.216.34/photo.jpg").toString()); + } + + // Verifies the escape hatch works, which a self-hosted or LAN deployment needs and which the + // integration test depends on, since Testcontainers publishes on loopback. + @Test + void allowsPrivateAddressesWhenTheOperatorAsksFor() { + PublicHttpUrl permissive = new PublicHttpUrl(true); + + assertEquals( + "http://127.0.0.1:3000/blob", + permissive.require("sourceUrl", "http://127.0.0.1:3000/blob").toString()); + } + + // Verifies the refusal names the argument, because an agent given "invalid URL" with no + // subject will re-send the same call with a different field changed. + @Test + void aRefusalNamesTheArgumentItCameFrom() { + ToolException refused = + assertThrows(ToolException.class, () -> guarded.require("server", "not a url at all")); + + assertTrue(refused.getMessage().contains("server"), refused.getMessage()); + } +} diff --git a/nostr-java-mcp/src/test/java/nostr/mcp/blossom/StubHttpServer.java b/nostr-java-mcp/src/test/java/nostr/mcp/blossom/StubHttpServer.java new file mode 100644 index 00000000..49cd5936 --- /dev/null +++ b/nostr-java-mcp/src/test/java/nostr/mcp/blossom/StubHttpServer.java @@ -0,0 +1,106 @@ +package nostr.mcp.blossom; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; + +/** + * A real HTTP server on loopback, for the tests that need one. + * + *

The JDK ships one, so the alternative is mocking {@code HttpClient}, which would assert + * that this code calls the methods it calls rather than that it speaks HTTP. A stub server can + * be asked what it actually received, which is the only way to check the headers being sent. + */ +final class StubHttpServer implements AutoCloseable { + + private final HttpServer server; + private final List received = new ArrayList<>(); + + private StubHttpServer(HttpServer server) { + this.server = server; + } + + static StubHttpServer started(Consumer handler) { + try { + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + StubHttpServer stub = new StubHttpServer(server); + server.createContext( + "/", + exchange -> { + stub.received.add(Request.of(exchange)); + handler.accept(exchange); + }); + server.start(); + return stub; + } catch (IOException e) { + throw new UncheckedIOException("Could not start the stub server", e); + } + } + + String baseUrl() { + return "http://127.0.0.1:" + server.getAddress().getPort(); + } + + List received() { + return List.copyOf(received); + } + + Request lastRequest() { + return received.get(received.size() - 1); + } + + static void respond(HttpExchange exchange, int status, String body) { + respond(exchange, status, body, "application/json"); + } + + static void respond(HttpExchange exchange, int status, String body, String contentType) { + try { + byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", contentType); + exchange.sendResponseHeaders(status, bytes.length == 0 ? -1 : bytes.length); + if (bytes.length > 0) { + exchange.getResponseBody().write(bytes); + } + exchange.close(); + } catch (IOException e) { + throw new UncheckedIOException("Could not respond", e); + } + } + + @Override + public void close() { + server.stop(0); + } + + /** + * One request the stub saw. + * + * @param method the HTTP method + * @param path the path requested + * @param authorization the Authorization header, or empty string when absent + * @param body the request body + */ + record Request(String method, String path, String authorization, byte[] body) { + + static Request of(HttpExchange exchange) { + try { + return new Request( + exchange.getRequestMethod(), + exchange.getRequestURI().getPath(), + exchange.getRequestHeaders().getFirst("Authorization") == null + ? "" + : exchange.getRequestHeaders().getFirst("Authorization"), + exchange.getRequestBody().readAllBytes()); + } catch (IOException e) { + throw new UncheckedIOException("Could not read the request", e); + } + } + } +} diff --git a/nostr-java-mcp/src/test/java/nostr/mcp/integration/BlossomToolsIT.java b/nostr-java-mcp/src/test/java/nostr/mcp/integration/BlossomToolsIT.java new file mode 100644 index 00000000..b87817bd --- /dev/null +++ b/nostr-java-mcp/src/test/java/nostr/mcp/integration/BlossomToolsIT.java @@ -0,0 +1,559 @@ +package nostr.mcp.integration; + +import com.sun.net.httpserver.HttpServer; +import io.modelcontextprotocol.spec.McpSchema.CallToolRequest; +import io.modelcontextprotocol.spec.McpSchema.CallToolResult; +import io.modelcontextprotocol.spec.McpSchema.TextContent; +import nostr.client.relay.RelayPool; +import nostr.client.springwebsocket.NostrRelayClient; +import nostr.client.testing.RelayStoresEventsWaitStrategy; +import nostr.id.Identity; +import nostr.mcp.blossom.BlobSource; +import nostr.mcp.blossom.BlossomAuth; +import nostr.mcp.blossom.BlossomClient; +import nostr.mcp.blossom.BlossomServers; +import nostr.mcp.blossom.BlossomVerb; +import nostr.mcp.blossom.PublicHttpUrl; +import nostr.mcp.identity.IdentityBinding; +import nostr.mcp.identity.IdentityVault; +import nostr.mcp.identity.KeySource; +import nostr.mcp.identity.SigningAlias; +import nostr.mcp.query.EventQuery; +import nostr.mcp.query.QueryLimits; +import nostr.mcp.tool.BlossomDeleteTool; +import nostr.mcp.tool.BlossomGetBlobTool; +import nostr.mcp.tool.BlossomListTool; +import nostr.mcp.tool.BlossomServerListTool; +import nostr.mcp.tool.BlossomSetServersTool; +import nostr.mcp.tool.BlossomUploadTool; +import nostr.mcp.tool.ToolException; +import nostr.mcp.tool.ToolFailure; +import nostr.mcp.write.RateLimit; +import nostr.mcp.write.WriteGuard; +import nostr.mcp.write.WritePolicy; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; +import org.testcontainers.utility.MountableFile; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.Duration; +import java.util.HexFormat; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ExecutionException; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Runs the Blossom tools against a real Blossom server and a real relay. + * + *

The claim worth testing is interoperability, and nothing below the wire can make it. The + * server is configured to require authorization on both upload and list, so every assertion here + * also asserts that the kind-24242 token this module builds — its tags, its expiry, and in + * particular its base64url encoding — is one an independent implementation accepts. A stub we + * wrote would accept whatever we sent and prove none of that. + * + *

Private addresses are allowed because Testcontainers publishes on loopback. That is the + * configuration a self-hosted deployment uses; the refusals the default configuration makes are + * covered by {@code PublicHttpUrlTest}. + */ +@Testcontainers +class BlossomToolsIT { + + private static final String BLOB = "a small blob of media, uploaded by a test"; + private static final String BLOB_TYPE = "text/plain"; + + @Container + private static final GenericContainer BLOSSOM = + new GenericContainer<>(DockerImageName.parse("ghcr.io/hzrd149/blossom-server:4.4.1")) + .withCopyFileToContainer( + MountableFile.forClasspathResource("blossom-server-config.yml"), "/app/config.yml") + .withExposedPorts(3000) + .withStartupAttempts(3) + .waitingFor(Wait.forHttp("/").forStatusCode(200).withStartupTimeout(Duration.ofSeconds(60))); + + @Container + private static final GenericContainer RELAY = + new GenericContainer<>(DockerImageName.parse("scsibug/nostr-rs-relay:0.8.13")) + .withExposedPorts(8080) + .withStartupAttempts(5) + .waitingFor(new RelayStoresEventsWaitStrategy().withStartupTimeout(Duration.ofSeconds(20))); + + private static HttpServer mediaHost; + + /** + * Serves the blob the upload tool is asked to fetch, since it takes a URL rather than a file. + * + *

The body is derived from the path so that two tests asking for different paths get + * different blobs. Blossom addresses by hash and the container is shared across this class: if + * every test uploaded identical bytes they would all own one blob, and a test deleting "its" + * blob would find it still there, owned by another test. That failure is order-dependent and + * looks exactly like a bug in the delete path. + */ + @BeforeAll + static void startMediaHost() throws IOException { + mediaHost = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + mediaHost.createContext( + "/media", + exchange -> { + byte[] bytes = blobFor(exchange.getRequestURI().getPath()); + exchange.getResponseHeaders().add("Content-Type", BLOB_TYPE); + exchange.sendResponseHeaders(200, bytes.length); + exchange.getResponseBody().write(bytes); + exchange.close(); + }); + mediaHost.start(); + } + + /** The bytes served at a path: distinct per path, and known to the test so it can hash them. */ + private static byte[] blobFor(String path) { + return (BLOB + " [" + path + "]").getBytes(StandardCharsets.UTF_8); + } + + @AfterAll + static void stopMediaHost() { + mediaHost.stop(0); + } + + // Verifies the whole round trip against a server that demands a valid token at every step: + // upload from a URL, find the blob, see it listed, delete it, and find it gone. Run as one + // test because the steps share a blob, and asserting the delete means having uploaded it. + @Test + void mediaCanBeUploadedFoundListedAndDeleted() { + try (IdentityVault vault = vaultOf("personal"); + RelayPool pool = pool()) { + WriteGuard writeGuard = guard(pool, vault, WritePolicy.ALLOW); + BlossomServers servers = servers(); + BlossomClient client = new BlossomClient(); + BlossomAuth auth = new BlossomAuth(Clock.systemUTC()); + + CallToolResult uploaded = + new BlossomUploadTool(servers, client, blobSource(), auth, writeGuard) + .call( + new CallToolRequest( + "nostr_blossom_upload", Map.of("sourceUrl", mediaUrl("round-trip")))); + + assertFalse(Boolean.TRUE.equals(uploaded.isError()), textOf(uploaded)); + String sha256 = String.valueOf(structuredOf(uploaded).get("sha256")); + assertTrue( + Boolean.TRUE.equals(structuredOf(uploaded).get("hashMatches")), + "the server stored something other than what was sent: " + textOf(uploaded)); + assertTrue(String.valueOf(structuredOf(uploaded).get("url")).contains(sha256), textOf(uploaded)); + + // The upload reports the size we sent, because blossom-server answers every upload with + // "size": 0 and an agent told its own file is empty would draw the wrong conclusion. + assertEquals( + (long) blobFor("/media/round-trip").length, + Long.parseLong(String.valueOf(structuredOf(uploaded).get("size"))), + textOf(uploaded)); + + CallToolResult found = + new BlossomGetBlobTool(servers, client) + .call(new CallToolRequest("nostr_blossom_get", Map.of("sha256", sha256))); + + // Deliberately not asserting a size here: this server sends no Content-Length on a HEAD, + // so the honest answer is that the blob is there and this is its URL. + assertFalse(Boolean.TRUE.equals(found.isError()), textOf(found)); + assertTrue(String.valueOf(structuredOf(found).get("url")).contains(sha256), textOf(found)); + + CallToolResult listed = + new BlossomListTool(servers, client, auth, vault) + .call(new CallToolRequest("nostr_blossom_list", Map.of())); + + assertFalse(Boolean.TRUE.equals(listed.isError()), textOf(listed)); + assertTrue(listed.structuredContent().toString().contains(sha256), textOf(listed)); + + CallToolResult deleted = + new BlossomDeleteTool(servers, client, auth, writeGuard) + .call(new CallToolRequest("nostr_blossom_delete", Map.of("sha256", sha256))); + + assertFalse(Boolean.TRUE.equals(deleted.isError()), textOf(deleted)); + + CallToolResult gone = + new BlossomGetBlobTool(servers, client) + .call(new CallToolRequest("nostr_blossom_get", Map.of("sha256", sha256))); + + assertTrue(Boolean.TRUE.equals(gone.isError()), "the blob survived the delete: " + textOf(gone)); + assertTrue(textOf(gone).startsWith("BLOB_NOT_FOUND"), textOf(gone)); + } + } + + // Verifies a delete under a confirming policy does nothing until the token comes back, which + // is the whole point of the two-step: a hallucinated deletion is a no-op. + @Test + void anUnconfirmedDeleteRemovesNothing() { + try (IdentityVault vault = vaultOf("personal"); + RelayPool pool = pool()) { + BlossomServers servers = servers(); + BlossomClient client = new BlossomClient(); + BlossomAuth auth = new BlossomAuth(Clock.systemUTC()); + + String sha256 = + String.valueOf( + structuredOf( + new BlossomUploadTool( + servers, + client, + blobSource(), + auth, + guard(pool, vault, WritePolicy.ALLOW)) + .call( + new CallToolRequest( + "nostr_blossom_upload", Map.of("sourceUrl", mediaUrl("unconfirmed-delete"))))) + .get("sha256")); + + CallToolResult preview = + new BlossomDeleteTool(servers, client, auth, guard(pool, vault, WritePolicy.CONFIRM)) + .call(new CallToolRequest("nostr_blossom_delete", Map.of("sha256", sha256))); + + assertTrue(textOf(preview).contains("Nothing has been deleted yet"), textOf(preview)); + assertTrue( + new BlossomGetBlobTool(servers, client) + .call(new CallToolRequest("nostr_blossom_get", Map.of("sha256", sha256))) + .structuredContent() + .toString() + .contains(sha256), + "the preview deleted the blob"); + } + } + + // Verifies a server list makes the round trip through a real relay: published as kind 10063 + // and read back in the order it was written, which BUD-03 makes meaningful. + @Test + void aServerListCanBePublishedAndReadBack() { + try (IdentityVault vault = vaultOf("personal"); + RelayPool pool = pool()) { + List published = List.of("https://cdn.example.com", "https://backup.example.com"); + + CallToolResult set = + new BlossomSetServersTool(guard(pool, vault, WritePolicy.ALLOW), servers()) + .call(new CallToolRequest("nostr_blossom_set_servers", Map.of("servers", published))); + + assertFalse(Boolean.TRUE.equals(set.isError()), textOf(set)); + + CallToolResult read = + new BlossomServerListTool(new EventQuery(pool), vault, QueryLimits.defaults()) + .call(new CallToolRequest("nostr_blossom_get_servers", Map.of())); + + assertFalse(Boolean.TRUE.equals(read.isError()), textOf(read)); + assertEquals(published, structuredOf(read).get("servers"), textOf(read)); + } + } + + // Verifies an upload is refused where writing is denied. The tool is not registered at all on + // such a server, but the guard is what makes that true rather than a registration oversight. + @Test + void aReadOnlyServerWillNotUpload() { + try (IdentityVault vault = vaultOf("personal"); + RelayPool pool = pool()) { + CallToolResult refused = + new BlossomUploadTool( + servers(), + new BlossomClient(), + blobSource(), + new BlossomAuth(Clock.systemUTC()), + guard(pool, vault, WritePolicy.DENY)) + .call(new CallToolRequest("nostr_blossom_upload", Map.of("sourceUrl", mediaUrl("read-only")))); + + assertTrue(Boolean.TRUE.equals(refused.isError()), textOf(refused)); + assertTrue(textOf(refused).startsWith("WRITE_FORBIDDEN"), textOf(refused)); + } + } + + // --- Negative controls ------------------------------------------------------------------- + // + // Everything above proves a correct token is accepted. On its own that is weak evidence: a + // server that ignored authorization entirely would pass every one of those tests. These four + // deliberately break one field each and require the server to notice, which is what makes the + // passing cases mean something. + + // Verifies the server enforces the x tag. The token commits to one hash and the body is a + // different blob, which is exactly the substitution an attacker who captured a token would + // attempt. + @Test + void aTokenCommittingToADifferentBlobIsRefused() { + try (IdentityVault vault = vaultOf("personal")) { + byte[] body = "the blob actually sent".getBytes(StandardCharsets.UTF_8); + String otherHash = sha256Of("a completely different blob"); + + ToolException refused = + assertThrows( + ToolException.class, + () -> + new BlossomClient() + .upload( + blossomUri(), + body, + "text/plain", + sha256Of(new String(body, StandardCharsets.UTF_8)), + headerFor(vault, BlossomVerb.UPLOAD, Optional.of(otherHash), Clock.systemUTC()))); + + assertEquals(ToolFailure.BLOB_SERVER_REJECTED, refused.getFailure(), refused.getMessage()); + } + } + + // Verifies the server enforces the expiration tag, and therefore that ours is a NIP-40 tag it + // can read. A token that never expired would be a password that leaked permanently. + @Test + void anExpiredTokenIsRefused() { + try (IdentityVault vault = vaultOf("personal")) { + byte[] body = "blob behind an expired token".getBytes(StandardCharsets.UTF_8); + Clock longAgo = Clock.fixed(Instant.now().minus(Duration.ofDays(2)), ZoneOffset.UTC); + + ToolException refused = + assertThrows( + ToolException.class, + () -> + new BlossomClient() + .upload( + blossomUri(), + body, + "text/plain", + sha256Of(new String(body, StandardCharsets.UTF_8)), + headerFor( + vault, + BlossomVerb.UPLOAD, + Optional.of(sha256Of(new String(body, StandardCharsets.UTF_8))), + longAgo))); + + assertEquals(ToolFailure.BLOB_SERVER_REJECTED, refused.getFailure(), refused.getMessage()); + } + } + + // Verifies the server enforces the t tag, and so that a token minted to read cannot be + // replayed to write. Without this check a list token would be an upload token. + @Test + void aListTokenCannotBeUsedToUpload() { + try (IdentityVault vault = vaultOf("personal")) { + byte[] body = "blob behind a list token".getBytes(StandardCharsets.UTF_8); + String sha256 = sha256Of(new String(body, StandardCharsets.UTF_8)); + + ToolException refused = + assertThrows( + ToolException.class, + () -> + new BlossomClient() + .upload( + blossomUri(), + body, + "text/plain", + sha256, + headerFor(vault, BlossomVerb.LIST, Optional.of(sha256), Clock.systemUTC()))); + + assertEquals(ToolFailure.BLOB_SERVER_REJECTED, refused.getFailure(), refused.getMessage()); + } + } + + // Verifies a request carrying no usable token is refused. Asserts only that it fails, not + // which code: blossom-server answers an unparseable Authorization header with 500 rather than + // the 401 BUD-02 lists, and this module's own code cannot produce such a header anyway, so + // pinning the mapping here would pin someone else's bug. + @Test + void aRequestWithoutAUsableTokenIsRefused() { + byte[] body = "blob with no token".getBytes(StandardCharsets.UTF_8); + + ToolException refused = + assertThrows( + ToolException.class, + () -> + new BlossomClient() + .upload( + blossomUri(), + body, + "text/plain", + sha256Of(new String(body, StandardCharsets.UTF_8)), + "Nostr bm90LWEtdG9rZW4")); + + assertTrue( + refused.getFailure() == ToolFailure.BLOB_SERVER_REJECTED + || refused.getFailure() == ToolFailure.BLOB_SERVER_UNREACHABLE, + "an unusable token was not refused: " + refused.getMessage()); + } + + // --- Behaviour that only a real server shows --------------------------------------------- + + // Verifies re-uploading a blob the server already holds succeeds rather than erroring. BUD-02 + // allows 200 or 201 here, and treating the second one as a failure would make an agent's retry + // of a request that already worked look like a problem. + @Test + void uploadingTheSameMediaTwiceSucceedsBothTimes() { + try (IdentityVault vault = vaultOf("personal"); + RelayPool pool = pool()) { + BlossomUploadTool tool = + new BlossomUploadTool( + servers(), + new BlossomClient(), + blobSource(), + new BlossomAuth(Clock.systemUTC()), + guard(pool, vault, WritePolicy.ALLOW)); + CallToolRequest request = + new CallToolRequest("nostr_blossom_upload", Map.of("sourceUrl", mediaUrl("twice"))); + + CallToolResult first = tool.call(request); + CallToolResult second = tool.call(request); + + assertFalse(Boolean.TRUE.equals(first.isError()), textOf(first)); + assertFalse(Boolean.TRUE.equals(second.isError()), textOf(second)); + assertEquals( + structuredOf(first).get("sha256"), + structuredOf(second).get("sha256"), + "the same bytes produced two different hashes"); + } + } + + // Verifies the size cap stops a blob before it is ever offered to the server, since the bytes + // are buffered in this process to be hashed and the cap is what bounds that. + @Test + void aBlobOverTheCapNeverReachesTheServer() { + try (IdentityVault vault = vaultOf("personal"); + RelayPool pool = pool()) { + CallToolResult refused = + new BlossomUploadTool( + servers(), + new BlossomClient(), + new BlobSource(new PublicHttpUrl(true), 8), + new BlossomAuth(Clock.systemUTC()), + guard(pool, vault, WritePolicy.ALLOW)) + .call(new CallToolRequest("nostr_blossom_upload", Map.of("sourceUrl", mediaUrl("oversize")))); + + assertTrue(Boolean.TRUE.equals(refused.isError()), textOf(refused)); + assertTrue(textOf(refused).startsWith("INVALID_ARGUMENT"), textOf(refused)); + assertTrue(textOf(refused).contains("larger than"), textOf(refused)); + } + } + + // Verifies the address guard is what actually stops the upload, at its default setting, + // against a live server. Every other test here runs with it off because Testcontainers + // publishes on loopback, so without this case the default configuration is never exercised. + @Test + void theDefaultGuardRefusesMediaOnThisMachine() { + try (IdentityVault vault = vaultOf("personal"); + RelayPool pool = pool()) { + CallToolResult refused = + new BlossomUploadTool( + servers(), + new BlossomClient(), + new BlobSource(new PublicHttpUrl(false), 1024 * 1024), + new BlossomAuth(Clock.systemUTC()), + guard(pool, vault, WritePolicy.ALLOW)) + .call(new CallToolRequest("nostr_blossom_upload", Map.of("sourceUrl", mediaUrl("guarded")))); + + assertTrue(Boolean.TRUE.equals(refused.isError()), textOf(refused)); + assertTrue(textOf(refused).contains("not on the public internet"), textOf(refused)); + } + } + + /** + * Builds an authorization header directly, so a test can make one that is deliberately wrong. + * + *

The tools always build a correct token, which is what makes them unusable for a negative + * control: proving the server rejects a bad token means being able to make one. + */ + private String headerFor( + IdentityVault vault, BlossomVerb verb, Optional blobHash, Clock clock) { + BlossomAuth auth = new BlossomAuth(clock); + return auth.headerValue( + SigningAlias.sign(vault, "personal", auth.tokenFor(verb, blobHash))); + } + + private String sha256Of(String text) { + try { + return nostr.util.NostrUtil.bytesToHex( + nostr.util.NostrUtil.sha256(text.getBytes(StandardCharsets.UTF_8))); + } catch (java.security.NoSuchAlgorithmException e) { + throw new AssertionError(e); + } + } + + private BlossomServers servers() { + return new BlossomServers(List.of(blossomUri()), new PublicHttpUrl(true)); + } + + private BlobSource blobSource() { + return new BlobSource(new PublicHttpUrl(true), 1024 * 1024); + } + + private WriteGuard guard(RelayPool pool, IdentityVault vault, WritePolicy policy) { + return new WriteGuard( + pool, vault, policy, new RateLimit(100, Duration.ofMinutes(1), Clock.systemUTC())); + } + + private IdentityVault vaultOf(String alias) { + byte[] key = + HexFormat.of().parseHex(Identity.generateRandomIdentity().getPrivateKey().toHexString()); + return new IdentityVault( + new KeySource() { + @Override + public Map loadKeys(IdentityBinding binding) { + return Map.of(alias, key); + } + + @Override + public String type() { + return "test"; + } + }, + null); + } + + private RelayPool pool() { + return new RelayPool(List.of(relayUri()), BlossomToolsIT::connect); + } + + private static nostr.client.relay.RelayConnection connect(String relayUri) throws IOException { + try { + return new NostrRelayClient(relayUri, 30_000L); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException(e); + } catch (ExecutionException e) { + throw new IOException(e.getCause()); + } + } + + /** + * A URL serving media unique to one test, so no two tests share a blob hash. + * + * @param name what to make unique, normally the test's own name + */ + private static String mediaUrl(String name) { + return "http://127.0.0.1:" + mediaHost.getAddress().getPort() + "/media/" + name; + } + + private static String blossomUri() { + return "http://" + BLOSSOM.getHost() + ":" + BLOSSOM.getMappedPort(3000); + } + + private static String relayUri() { + return "ws://" + RELAY.getHost() + ":" + RELAY.getMappedPort(8080); + } + + @SuppressWarnings("unchecked") + private Map structuredOf(CallToolResult result) { + return (Map) result.structuredContent(); + } + + private String textOf(CallToolResult result) { + return result.content().stream() + .filter(TextContent.class::isInstance) + .map(TextContent.class::cast) + .map(TextContent::text) + .findFirst() + .orElse(""); + } +} diff --git a/nostr-java-mcp/src/test/java/nostr/mcp/integration/OllamaAgentIT.java b/nostr-java-mcp/src/test/java/nostr/mcp/integration/OllamaAgentIT.java index d7b74a77..416002a6 100644 --- a/nostr-java-mcp/src/test/java/nostr/mcp/integration/OllamaAgentIT.java +++ b/nostr-java-mcp/src/test/java/nostr/mcp/integration/OllamaAgentIT.java @@ -255,7 +255,23 @@ private static Stream requestsAndTheToolTheyNeed() { + " server under the alias 'adopted'.", "nostr_import_identity"), arguments("Rename my identity 'old-name' to 'new-name'.", "nostr_rename_identity"), arguments("From now on sign as 'project-bot' by default.", "nostr_set_default_identity"), - arguments("Save an encrypted backup of my key 'personal' to /tmp/backup.p12.", "nostr_export_identity_backup")); + arguments("Save an encrypted backup of my key 'personal' to /tmp/backup.p12.", "nostr_export_identity_backup"), + // The Blossom tools sit close to each other and closer still to the Nostr ones: a model + // asked to "share this picture" could reasonably reach for a publishing tool instead, so + // each is phrased the way someone would actually ask rather than by naming the protocol. + arguments("Upload the image at https://example.com/cat.png to my media server and give" + + " me the link.", "nostr_blossom_upload"), + arguments("Where can I download the file with hash" + + " b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553?", + "nostr_blossom_get"), + arguments("What files have I uploaded to my media server?", "nostr_blossom_list"), + arguments("Remove the file" + + " b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553 from my" + + " media server.", "nostr_blossom_delete"), + arguments("Which media servers does npub1abc use for their files?", + "nostr_blossom_get_servers"), + arguments("Tell the network that I host my media on https://cdn.example.com.", + "nostr_blossom_set_servers")); } private List toolsOf(McpSyncClient mcp) { diff --git a/nostr-java-mcp/src/test/java/nostr/mcp/tool/ToolSurfaceSecrecyTest.java b/nostr-java-mcp/src/test/java/nostr/mcp/tool/ToolSurfaceSecrecyTest.java index 57ba7545..2f26817b 100644 --- a/nostr-java-mcp/src/test/java/nostr/mcp/tool/ToolSurfaceSecrecyTest.java +++ b/nostr-java-mcp/src/test/java/nostr/mcp/tool/ToolSurfaceSecrecyTest.java @@ -5,6 +5,9 @@ import nostr.client.relay.FakeRelay; import nostr.client.relay.RelayPool; import nostr.id.Identity; +import nostr.mcp.blossom.BlobSource; +import nostr.mcp.blossom.BlossomServers; +import nostr.mcp.blossom.PublicHttpUrl; import nostr.mcp.identity.IdentitySummary; import nostr.mcp.identity.IdentityVault; import nostr.mcp.identity.KeySource; @@ -139,7 +142,9 @@ private List surfaceOf(IdentityVault vault, RelayPool pool) { new IdentityLifecycle(vault, new InMemoryStore()), IdentityPolicy.ALLOW, subscriptionRegistry(pool), - new McpDirectMessageService(vault, pool, java.util.Set.of(ALIAS))) + new McpDirectMessageService(vault, pool, java.util.Set.of(ALIAS)), + new BlossomServers(List.of("https://cdn.example.com"), new PublicHttpUrl(false)), + new BlobSource(new PublicHttpUrl(false), 1024)) .tools(); } diff --git a/nostr-java-mcp/src/test/java/nostr/mcp/tool/ToolSurfaceTest.java b/nostr-java-mcp/src/test/java/nostr/mcp/tool/ToolSurfaceTest.java index a904fc4d..7e78918a 100644 --- a/nostr-java-mcp/src/test/java/nostr/mcp/tool/ToolSurfaceTest.java +++ b/nostr-java-mcp/src/test/java/nostr/mcp/tool/ToolSurfaceTest.java @@ -2,6 +2,9 @@ import nostr.client.relay.RelayPool; import nostr.id.Identity; +import nostr.mcp.blossom.BlobSource; +import nostr.mcp.blossom.BlossomServers; +import nostr.mcp.blossom.PublicHttpUrl; import nostr.mcp.identity.IdentityBinding; import nostr.mcp.identity.IdentityLifecycle; import nostr.mcp.identity.IdentityPolicy; @@ -164,7 +167,9 @@ void aBackendThatCannotBeAdministeredOffersNoLifecycleTools() { null, IdentityPolicy.ALLOW, subscriptionRegistry(relayPool), - new McpDirectMessageService(vault, relayPool, java.util.Set.of())); + new McpDirectMessageService(vault, relayPool, java.util.Set.of()), + blossomServers(), + blobSource()); assertEquals(readGolden(NO_MUTATION_GOLDEN), String.join("\n", registry.registeredNames())); } @@ -192,7 +197,21 @@ private NostrToolRegistry surfaceOf( new IdentityLifecycle(vault, new InMemoryStore()), identityPolicy, subscriptionRegistry(relayPool), - new McpDirectMessageService(vault, relayPool, java.util.Set.of())); + new McpDirectMessageService(vault, relayPool, java.util.Set.of()), + blossomServers(), + blobSource()); + } + + /** + * A Blossom setup with one configured server, so the tools register and their schemas are + * stable. The surface does not depend on whether a server is reachable. + */ + private BlossomServers blossomServers() { + return new BlossomServers(List.of("https://cdn.example.com"), new PublicHttpUrl(false)); + } + + private BlobSource blobSource() { + return new BlobSource(new PublicHttpUrl(false), 1024); } private SubscriptionRegistry subscriptionRegistry(RelayPool relayPool) { diff --git a/nostr-java-mcp/src/test/resources/blossom-server-config.yml b/nostr-java-mcp/src/test/resources/blossom-server-config.yml new file mode 100644 index 00000000..ac8e52a9 --- /dev/null +++ b/nostr-java-mcp/src/test/resources/blossom-server-config.yml @@ -0,0 +1,43 @@ +# Minimal blossom-server config for the integration test. +# +# Authorization is required on upload, delete and list on purpose: that is what makes the +# test prove our kind-24242 token is one an independent implementation accepts, rather than +# just that the HTTP calls are shaped right. Discovery is off so the test never leaves the +# machine it runs on. +publicDomain: "" +databasePath: data/sqlite.db + +dashboard: + enabled: false + +discovery: + nostr: + enabled: false + relays: [] + upstream: + enabled: false + domains: [] + +storage: + backend: local + # Deleting drops ownership; without this the bytes linger until a prune runs, and a test + # asserting that a delete removed something would be asserting nothing. + removeWhenNoOwners: true + local: + dir: ./data/blobs + rules: + - type: "*" + expiration: 1 month + +upload: + enabled: true + requireAuth: true + requirePubkeyInRule: false + +list: + requireAuth: true + allowListOthers: true + +tor: + enabled: false + proxy: "" diff --git a/nostr-java-mcp/src/test/resources/tool-list-bound.txt b/nostr-java-mcp/src/test/resources/tool-list-bound.txt index a1e7931d..425019fe 100644 --- a/nostr-java-mcp/src/test/resources/tool-list-bound.txt +++ b/nostr-java-mcp/src/test/resources/tool-list-bound.txt @@ -10,7 +10,13 @@ nostr_unsubscribe nostr_fetch_thread nostr_get_contacts nostr_read_direct_messages +nostr_blossom_get +nostr_blossom_list +nostr_blossom_get_servers nostr_publish_note nostr_publish_event nostr_update_profile nostr_send_direct_message +nostr_blossom_upload +nostr_blossom_delete +nostr_blossom_set_servers diff --git a/nostr-java-mcp/src/test/resources/tool-list-default.txt b/nostr-java-mcp/src/test/resources/tool-list-default.txt index 3c59399c..13c6f0e7 100644 --- a/nostr-java-mcp/src/test/resources/tool-list-default.txt +++ b/nostr-java-mcp/src/test/resources/tool-list-default.txt @@ -10,10 +10,16 @@ nostr_unsubscribe nostr_fetch_thread nostr_get_contacts nostr_read_direct_messages +nostr_blossom_get +nostr_blossom_list +nostr_blossom_get_servers nostr_publish_note nostr_publish_event nostr_update_profile nostr_send_direct_message +nostr_blossom_upload +nostr_blossom_delete +nostr_blossom_set_servers nostr_create_identity nostr_import_identity nostr_rename_identity diff --git a/nostr-java-mcp/src/test/resources/tool-list-no-identity-mutation.txt b/nostr-java-mcp/src/test/resources/tool-list-no-identity-mutation.txt index a1e7931d..425019fe 100644 --- a/nostr-java-mcp/src/test/resources/tool-list-no-identity-mutation.txt +++ b/nostr-java-mcp/src/test/resources/tool-list-no-identity-mutation.txt @@ -10,7 +10,13 @@ nostr_unsubscribe nostr_fetch_thread nostr_get_contacts nostr_read_direct_messages +nostr_blossom_get +nostr_blossom_list +nostr_blossom_get_servers nostr_publish_note nostr_publish_event nostr_update_profile nostr_send_direct_message +nostr_blossom_upload +nostr_blossom_delete +nostr_blossom_set_servers diff --git a/nostr-java-mcp/src/test/resources/tool-list-read-only.txt b/nostr-java-mcp/src/test/resources/tool-list-read-only.txt index 3180b924..e300d7aa 100644 --- a/nostr-java-mcp/src/test/resources/tool-list-read-only.txt +++ b/nostr-java-mcp/src/test/resources/tool-list-read-only.txt @@ -10,3 +10,6 @@ nostr_unsubscribe nostr_fetch_thread nostr_get_contacts nostr_read_direct_messages +nostr_blossom_get +nostr_blossom_list +nostr_blossom_get_servers From 5b16655252797edffe05287e2558e965d879873e Mon Sep 17 00:00:00 2001 From: tcheeric Date: Mon, 21 Sep 2026 20:30:03 +0100 Subject: [PATCH 4/4] chore(release): bump to 2.4.0 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) --- CHANGELOG.md | 34 ++++++++++++++++++++++++++++ docs/howto/multi-relay-publishing.md | 2 +- nostr-java-api/pom.xml | 2 +- nostr-java-client/pom.xml | 2 +- nostr-java-core/pom.xml | 2 +- nostr-java-event/pom.xml | 2 +- nostr-java-identity/pom.xml | 2 +- nostr-java-mcp/pom.xml | 2 +- pom.xml | 2 +- 9 files changed, 42 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f8930366..8f8dc448 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,40 @@ The format is inspired by Keep a Changelog, and this project adheres to semantic ## [Unreleased] +## [2.4.0] - 2026-09-21 + +### Added +- Blossom media hosting in `nostr-java-mcp`. Nostr events carry URLs, not bytes; + [Blossom](https://github.com/hzrd149/blossom) is where the bytes live. 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: `nostr_blossom_upload` re-hosts media from a URL, `nostr_blossom_get` resolves a hash + to a link, `nostr_blossom_list` and `nostr_blossom_delete` manage what a key has stored, and + `nostr_blossom_get_servers` / `nostr_blossom_set_servers` read and publish the kind-10063 + server list. Mirroring (BUD-04), media optimization (BUD-05) and upload pre-flight (BUD-06) + are not implemented. +- Configuration: `nostr.mcp.blossom.servers`, `.max-blob-bytes` (16 MiB default) and + `.allow-private-hosts`. +- `WriteGuard.authorizeWrite` and `WriteGuard.signAs`, so a write that must gather something + expensive before it can be signed still settles policy and rate limit first. Blossom uploads + therefore count against `limits.writes-per-minute` exactly as a published note does. + +### Security +- 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 — an SSH key, a `.env` — on a public CDN addressed by its + hash, from which it cannot be recalled. This is a deliberate limitation, not an oversight: + media must already be reachable over http(s). +- Because the server does the fetching, `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 rather + than the first. It applies to the agent-supplied `server` argument as well as the source URL; + servers the operator configured are exempt. `allow-private-hosts` opts out for self-hosted + and LAN deployments. +- 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. +- Blossom authorization tokens carry a random `nonce`, so a token cannot be replayed. A nostr + event id is the hash of its contents, so tokens for the same action within one second would + otherwise be the same event. + ## [2.3.1] - 2026-08-31 ### Removed diff --git a/docs/howto/multi-relay-publishing.md b/docs/howto/multi-relay-publishing.md index 1aec96e6..c43303a3 100644 --- a/docs/howto/multi-relay-publishing.md +++ b/docs/howto/multi-relay-publishing.md @@ -10,7 +10,7 @@ send a private direct message. xyz.tcheeric nostr-java-api - 2.3.1 + 2.4.0 ``` diff --git a/nostr-java-api/pom.xml b/nostr-java-api/pom.xml index 836fa400..79233679 100644 --- a/nostr-java-api/pom.xml +++ b/nostr-java-api/pom.xml @@ -4,7 +4,7 @@ xyz.tcheeric nostr-java - 2.3.1 + 2.4.0 ../pom.xml diff --git a/nostr-java-client/pom.xml b/nostr-java-client/pom.xml index faf155bb..acdb8ce5 100644 --- a/nostr-java-client/pom.xml +++ b/nostr-java-client/pom.xml @@ -4,7 +4,7 @@ xyz.tcheeric nostr-java - 2.3.1 + 2.4.0 ../pom.xml diff --git a/nostr-java-core/pom.xml b/nostr-java-core/pom.xml index 933b082e..7ba8bb6b 100644 --- a/nostr-java-core/pom.xml +++ b/nostr-java-core/pom.xml @@ -4,7 +4,7 @@ xyz.tcheeric nostr-java - 2.3.1 + 2.4.0 ../pom.xml diff --git a/nostr-java-event/pom.xml b/nostr-java-event/pom.xml index 960e0f5f..af4ca2bb 100644 --- a/nostr-java-event/pom.xml +++ b/nostr-java-event/pom.xml @@ -4,7 +4,7 @@ xyz.tcheeric nostr-java - 2.3.1 + 2.4.0 ../pom.xml diff --git a/nostr-java-identity/pom.xml b/nostr-java-identity/pom.xml index d09879ed..7c257d5e 100644 --- a/nostr-java-identity/pom.xml +++ b/nostr-java-identity/pom.xml @@ -4,7 +4,7 @@ xyz.tcheeric nostr-java - 2.3.1 + 2.4.0 ../pom.xml diff --git a/nostr-java-mcp/pom.xml b/nostr-java-mcp/pom.xml index e841bca9..7d40545b 100644 --- a/nostr-java-mcp/pom.xml +++ b/nostr-java-mcp/pom.xml @@ -4,7 +4,7 @@ xyz.tcheeric nostr-java - 2.3.1 + 2.4.0 ../pom.xml diff --git a/pom.xml b/pom.xml index 21dae4b3..4101b78b 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ xyz.tcheeric nostr-java - 2.3.1 + 2.4.0 pom nostr-java