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 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/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/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/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-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-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."); + } + } +} 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/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 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