Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -229,3 +229,5 @@ data

# Project management documents (local only)
.project-management/
.scratch/
create-roadmap-project.sh
34 changes: 34 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
69 changes: 69 additions & 0 deletions docs/explanation/nostr-java-mcp-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 /<sha256>`, 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),
Expand Down
2 changes: 1 addition & 1 deletion docs/howto/multi-relay-publishing.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ send a private direct message.
<dependency>
<groupId>xyz.tcheeric</groupId>
<artifactId>nostr-java-api</artifactId>
<version>2.3.1</version>
<version>2.4.0</version>
</dependency>
```

Expand Down
40 changes: 40 additions & 0 deletions docs/howto/run-the-mcp-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down
2 changes: 1 addition & 1 deletion nostr-java-api/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<parent>
<groupId>xyz.tcheeric</groupId>
<artifactId>nostr-java</artifactId>
<version>2.3.1</version>
<version>2.4.0</version>
<relativePath>../pom.xml</relativePath>
</parent>

Expand Down
2 changes: 1 addition & 1 deletion nostr-java-client/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<parent>
<groupId>xyz.tcheeric</groupId>
<artifactId>nostr-java</artifactId>
<version>2.3.1</version>
<version>2.4.0</version>
<relativePath>../pom.xml</relativePath>
</parent>

Expand Down
Original file line number Diff line number Diff line change
@@ -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.
*
* <p><b>The failure this pins down.</b> {@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.</p>
*
* <p><b>Why it matters downstream.</b> 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.</p>
*
* <p><b>No sleep between frames, deliberately.</b> 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.</p>
*/
@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<String> 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.");
}
}
}
2 changes: 1 addition & 1 deletion nostr-java-core/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<parent>
<groupId>xyz.tcheeric</groupId>
<artifactId>nostr-java</artifactId>
<version>2.3.1</version>
<version>2.4.0</version>
<relativePath>../pom.xml</relativePath>
</parent>

Expand Down
2 changes: 1 addition & 1 deletion nostr-java-event/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<parent>
<groupId>xyz.tcheeric</groupId>
<artifactId>nostr-java</artifactId>
<version>2.3.1</version>
<version>2.4.0</version>
<relativePath>../pom.xml</relativePath>
</parent>

Expand Down
2 changes: 1 addition & 1 deletion nostr-java-identity/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<parent>
<groupId>xyz.tcheeric</groupId>
<artifactId>nostr-java</artifactId>
<version>2.3.1</version>
<version>2.4.0</version>
<relativePath>../pom.xml</relativePath>
</parent>

Expand Down
2 changes: 1 addition & 1 deletion nostr-java-mcp/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<parent>
<groupId>xyz.tcheeric</groupId>
<artifactId>nostr-java</artifactId>
<version>2.3.1</version>
<version>2.4.0</version>
<relativePath>../pom.xml</relativePath>
</parent>

Expand Down
Loading
Loading