contextvm-sdk 0.1.1

Rust SDK for the ContextVM protocol — MCP over Nostr
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
---
title: Nostr Server Transport
description: A server-side component for exposing MCP servers over Nostr.
---

# Nostr Server Transport

The `NostrServerTransport` is the server-side counterpart to the [`NostrClientTransport`](/transports/nostr-client-transport). It allows an MCP server to expose its capabilities to the Nostr network, making them discoverable and usable by any Nostr-enabled client. Like the client transport, it implements the `Transport` interface from the `@modelcontextprotocol/sdk`.

## Overview

The `NostrServerTransport` is responsible for:

- Listening for incoming MCP requests from Nostr clients.
- Managing individual client sessions and their state (e.g., initialization, encryption).
- Handling request/response correlation to ensure responses are sent to the correct client.
- Sending responses and notifications back to clients over Nostr.
- Optionally announcing the server and its capabilities to the network for public discovery.

## `NostrServerTransportOptions`

The transport is configured via the `NostrServerTransportOptions` interface:

```typescript
export interface NostrServerTransportOptions extends BaseNostrTransportOptions {
  serverInfo?: ServerInfo;
  profileMetadata?: ProfileMetadata;
  /** @deprecated Use isAnnouncedServer instead. */
  isPublicServer?: boolean;
  isAnnouncedServer?: boolean;
  publishRelayList?: boolean;
  relayListUrls?: string[];
  bootstrapRelayUrls?: string[];
  allowedPublicKeys?: string[];
  /** Optional callback for dynamic public key authorization. Returns true to allow the pubkey. */
  isPubkeyAllowed?: (clientPubkey: string) => boolean | Promise<boolean>;
  /** List of capabilities that are excluded from public key whitelisting requirements */
  excludedCapabilities?: CapabilityExclusion[];
  /** Optional callback for dynamic capability exclusions. Returns true to bypass pubkey authorization. */
  isCapabilityExcluded?: (
    exclusion: CapabilityExclusion,
  ) => boolean | Promise<boolean>;
  /** Log level for the NostrServerTransport: 'debug' | 'info' | 'warn' | 'error' | 'silent' */
  logLevel?: LogLevel;
  /**
   * Whether to inject the client's public key into the _meta field of incoming messages.
   * @default false
   */
  injectClientPubkey?: boolean;
  /**
   * Whether to inject the inbound Nostr request event ID into the `_meta` field
   * of incoming request messages.
   * @default false
   */
  injectRequestEventId?: boolean;
}
```

- **`serverInfo`**: (Optional) Information about the server (`name`, `picture`, `website`) to be used in public announcements.
- **`profileMetadata`**: (Optional) NIP-01 `kind:0` metadata for the server profile. When provided, the transport publishes a signed `kind:0` event at startup as defined by CEP-23.
- **`isAnnouncedServer`**: (Optional) If `true`, the transport publishes public announcement events for relay-based discovery. Defaults to `false`.
- **`isPublicServer`**: (Deprecated) Legacy alias for `isAnnouncedServer`.
- **`publishRelayList`**: (Optional) If `true`, the transport publishes a NIP-65 relay list (`kind:10002`) even when `isAnnouncedServer` is `false`. Defaults to `true`.
- **`relayListUrls`**: (Optional) Explicit relay URLs to advertise in the published relay list. If omitted, the SDK derives them from the configured relay handler when possible.
- **`bootstrapRelayUrls`**: (Optional) Extra relays used only as publication targets for discoverability events such as `kind:11316` and `kind:10002`. These are not automatically advertised in the relay list.
- **`allowedPublicKeys`**: (Optional) A list of client public keys that are allowed to connect. If not provided, any client can connect.
- **`isPubkeyAllowed`**: (Optional) A dynamic authorization callback that receives a client public key and returns `true` to allow the connection. Can be async. When used with `allowedPublicKeys`, both checks must pass (AND logic).
- **`excludedCapabilities`**: (Optional) A list of capabilities that are excluded from public key whitelisting requirements. This allows certain operations from disallowed public keys, enhancing security policy flexibility while maintaining backward compatibility.
- **`isCapabilityExcluded`**: (Optional) A dynamic capability exclusion callback that receives a capability exclusion pattern and returns `true` to bypass pubkey authorization for that capability. Can be async. Evaluated after static `excludedCapabilities`.
- **`injectClientPubkey`**: (Optional) If `true`, the transport will inject the client's public key into the `_meta` field of requests passed to the underlying server. Defaults to `false`.
- **`injectRequestEventId`**: (Optional) If `true`, the transport will inject the inbound Nostr request event ID into the `_meta` field of incoming request messages. This enables middleware and tools to access the original Nostr event that triggered the request, including the event's pubkey and full event data through `getNostrRequestEvent()`. Defaults to `false`.

## CEP-23 Server Profile Publication

`serverInfo` and `profileMetadata` serve different purposes:

- **`serverInfo`** powers ContextVM discovery and initialize semantics.
- **`profileMetadata`** powers an optional Nostr social/profile identity via `kind:0`.

This separation matters because some servers want to be discoverable over ContextVM without maintaining a public social profile, while others want both.

### `ProfileMetadata`

The `profileMetadata` object is serialized as JSON and published as a NIP-01 `kind:0` event.

```typescript
export interface ProfileMetadata {
  name?: string;
  about?: string;
  picture?: string;
  banner?: string;
  website?: string;
  nip05?: string;
  lud16?: string;
  [key: string]: unknown;
}
```

### Publication behavior

- Publication is **opt-in** and only happens when `profileMetadata` is provided.
- `kind:0` publication is independent from `isAnnouncedServer`.
- A server can publish profile metadata even when it does **not** publish public announcement events.
- The profile event is sent through the same discoverability publication path as relay-list and announcement events, so `bootstrapRelayUrls` also help distribute profile metadata in local or non-WebSocket relay environments.

### Example: announced server with a public profile

```typescript
const transport = new NostrServerTransport({
  signer,
  relayHandler: relayPool,
  isAnnouncedServer: true,
  publishRelayList: true,
  profileMetadata: {
    name: 'My Awesome MCP Server',
    about: 'Public MCP provider on Nostr',
    picture: 'https://example.com/avatar.png',
    website: 'https://example.com',
    nip05: 'server@example.com',
  },
  serverInfo: {
    name: 'My Awesome MCP Server',
    website: 'https://example.com',
  },
});
```

### Example: private server with profile publication only

```typescript
const transport = new NostrServerTransport({
  signer,
  relayHandler: relayPool,
  isAnnouncedServer: false,
  profileMetadata: {
    name: 'Private Profile Server',
    about: 'Publishes a CEP-23 profile without public capability announcements',
    website: 'https://example.com/private-server',
  },
  bootstrapRelayUrls: ['wss://relay.damus.io'],
});
```

In this configuration, the server remains outside the public capability-announcement flow but still publishes a canonical Nostr profile that clients and operators can render.

### Capability Exclusion

The `CapabilityExclusion` interface allows you to define specific capabilities that bypass the public key whitelisting requirements:

```typescript
/**
 * Represents a capability exclusion pattern that can bypass whitelisting.
 * Can be either a method-only pattern (e.g., 'tools/list') or a method + name pattern (e.g., 'tools/call, get_weather').
 */
export interface CapabilityExclusion {
  /** The JSON-RPC method to exclude from whitelisting (e.g., 'tools/call', 'tools/list') */
  method: string;
  /** Optional capability name to specifically exclude (e.g., 'get_weather') */
  name?: string;
}
```

#### How Capability Exclusion Works

Capability exclusion provides fine-grained control over access by allowing specific operations to be performed even by clients that are not in the `allowedPublicKeys` list. This is useful for:

- Allowing public access to server discovery endpoints like `tools/list`
- Permitting specific tool calls from untrusted clients
- Maintaining backward compatibility with existing clients

#### Exclusion Patterns

- **Method-only exclusion**: `{ method: 'tools/list' }` - Excludes all calls to the `tools/list` method
- **Method + name exclusion**: `{ method: 'tools/call', name: 'add' }` - Excludes only the `add` tool from the `tools/call` method

## Dynamic Authorization

In addition to static configuration, you can provide dynamic authorization callbacks for more flexible access control policies.

### Dynamic Public Key Authorization

Use `isPubkeyAllowed` to implement runtime authorization logic:

```typescript
const transport = new NostrServerTransport({
  signer,
  relayHandler: relayPool,
  // Static allowlist (optional - can be used alone or with dynamic check)
  allowedPublicKeys: ['known-trusted-client'],
  // Dynamic authorization callback
  isPubkeyAllowed: async (clientPubkey) => {
    // Check against a database, external service, or custom logic
    const isAllowed = await checkDatabaseForAccess(clientPubkey);
    return isAllowed;
  },
});
```

When both `allowedPublicKeys` and `isPubkeyAllowed` are configured, a client must pass **both** checks (AND logic) to be authorized.

### Dynamic Capability Exclusions

Use `isCapabilityExcluded` to dynamically determine which capabilities bypass whitelisting:

```typescript
const transport = new NostrServerTransport({
  signer,
  relayHandler: relayPool,
  allowedPublicKeys: ['trusted-client'],
  // Static exclusions
  excludedCapabilities: [{ method: 'tools/list' }],
  // Dynamic exclusion callback - evaluated after static exclusions
  isCapabilityExcluded: async (exclusion) => {
    // Check if this specific capability should be public
    if (exclusion.method === 'tools/call' && exclusion.name === 'get_weather') {
      return await isWeatherServicePublic();
    }
    return false;
  },
});
```

The dynamic callback receives the exclusion pattern being checked and returns `true` to allow the capability without pubkey authorization.

### Combining Static and Dynamic Authorization

You can mix static and dynamic approaches for maximum flexibility:

```typescript
const transport = new NostrServerTransport({
  signer,
  relayHandler: relayPool,
  isAnnouncedServer: true,
  // Hardcoded trusted clients
  allowedPublicKeys: ['admin-pubkey', 'service-account-pubkey'],
  // Dynamic check for additional clients
  isPubkeyAllowed: async (clientPubkey) => {
    // Check subscription status in database
    const subscription = await db.subscriptions.findByPubkey(clientPubkey);
    return subscription?.isActive ?? false;
  },
  // Public capabilities anyone can use
  excludedCapabilities: [
    { method: 'tools/list' },
    { method: 'tools/call', name: 'get_status' },
  ],
  // Dynamic capability exclusions
  isCapabilityExcluded: async (exclusion) => {
    // Check feature flags for temporarily public capabilities
    if (exclusion.method === 'tools/call') {
      return await featureFlags.isToolPublic(exclusion.name);
    }
    return false;
  },
});
```

## Usage Example

Here's how to use the `NostrServerTransport` with an `McpServer` from the `@modelcontextprotocol/sdk`:

```typescript
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { NostrServerTransport } from '@contextvm/sdk';
import { PrivateKeySigner } from '@contextvm/sdk';
import { ApplesauceRelayPool } from '@contextvm/sdk';

// 1. Configure the signer and relay pool
const signer = new PrivateKeySigner('your-server-private-key');
const relayPool = new ApplesauceRelayPool(['wss://relay.damus.io']);

// 2. Create the McpServer instance
const mcpServer = new McpServer({
  name: 'demo-server',
  version: '1.0.0',
});

// Register your server's tools, resources, etc.
// mcpServer.tool(...);

// 3. Create the NostrServerTransport instance
const serverNostrTransport = new NostrServerTransport({
  signer: signer,
  relayHandler: relayPool,
  isAnnouncedServer: true,
  publishRelayList: true,
  bootstrapRelayUrls: ['wss://relay.damus.io', 'wss://nos.lol'],
  profileMetadata: {
    name: 'My Awesome MCP Server',
    about: 'Public MCP provider on Nostr',
    picture: 'https://example.com/avatar.png',
    website: 'https://example.com',
  },
  serverInfo: {
    name: 'My Awesome MCP Server',
    website: 'https://example.com',
  },
  allowedPublicKeys: ['trusted-client-key'], // Only allow specific clients
  excludedCapabilities: [
    { method: 'tools/list' }, // Allow any client to list available tools
    { method: 'tools/call', name: 'get_weather' }, // Allow any client to call get_weather tool
  ],
  injectClientPubkey: true, // Enable client public key injection
  injectRequestEventId: true, // Enable request event ID injection
});

// 4. Connect the server
await mcpServer.connect(serverNostrTransport);

console.log('MCP server is running and available on Nostr.');

// Keep the process running...
// To shut down: await mcpServer.close();
```

> **Note**: The `relayHandler` option also accepts a `string[]` of relay URLs, in which case an `ApplesauceRelayPool` will be created automatically. See the [Base Nostr Transport]/transports/base-nostr-transport documentation for details.

## CEP-15 Common Tool Schemas

If your server implements a shared tool contract defined by [CEP-15](/spec/ceps/cep-15), you can opt specific tools into common-schema publication.

Use `withCommonToolSchemas()` to decorate the transport before connecting the server:

```typescript
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import {
  ApplesauceRelayPool,
  NostrServerTransport,
  PrivateKeySigner,
  withCommonToolSchemas,
} from '@contextvm/sdk';

const signer = new PrivateKeySigner('your-server-private-key');
const relayPool = new ApplesauceRelayPool(['wss://relay.damus.io']);

const server = new McpServer({
  name: 'translation-server',
  version: '1.0.0',
});

server.registerTool(
  'translate_text',
  {
    description: 'Translate text between languages.',
    inputSchema: {
      type: 'object',
      properties: {
        text: { type: 'string' },
        target_language: { type: 'string' },
      },
      required: ['text', 'target_language'],
    },
    outputSchema: {
      type: 'object',
      properties: {
        translated_text: { type: 'string' },
      },
      required: ['translated_text'],
    },
  },
  async ({ text, target_language }) => ({
    content: [{ type: 'text', text: `Translated to ${target_language}: ${text}` }],
    structuredContent: {
      translated_text: `Translated to ${target_language}: ${text}`,
    },
  }),
);

const transport = withCommonToolSchemas(
  new NostrServerTransport({
    signer,
    relayHandler: relayPool,
    isAnnouncedServer: true,
  }),
  {
    tools: [{ name: 'translate_text' }],
  },
);

await server.connect(transport);
```

### What the SDK publishes automatically

For each opted-in tool, the SDK:

- computes the CEP-15 schema hash from the tool name plus normalized `inputSchema` and `outputSchema`;
- injects `_meta['io.contextvm/common-schema'].schemaHash` into `tools/list` responses;
- adds matching `i` and `k` tags to tools-list announcement events when the server is announced.

Use this for tools that are intended to match a shared public contract across providers. Bespoke tools should remain outside the common-schema configuration.

### Notes

- Apply `withCommonToolSchemas()` before `server.connect()` so direct responses and announcements stay aligned from the start.
- Tool `name` is part of the schema identity, so renaming a tool changes the resulting hash.
- If you provide an `outputSchema`, it participates in the hash and should remain stable across providers.
- Schemas used for hashing must be self-contained. Remote `$ref` values must be resolved before hashing.

For lower-level hashing and verification utilities, see [Common Tool Schemas](/ts-sdk/core/common-tool-schemas).

## How It Works

1.  **`start()`**: When `mcpServer.connect()` is called, the transport connects to the relays and subscribes to events targeting the server's public key. If `isAnnouncedServer` is `true`, it publishes public announcement events. Independently, if `publishRelayList` is enabled, it also publishes relay-list metadata. If `profileMetadata` is configured, it publishes a CEP-23 `kind:0` profile event.
2.  **Incoming Events**: The transport listens for events from clients. For each client, it maintains a `ClientSession`.
3.  **Request Handling**: When a valid request is received from an authorized client, the transport forwards it to the `McpServer`'s internal logic via the `onmessage` handler. It replaces the request's original ID with the unique Nostr event ID to prevent ID collisions between different clients.
    - If `injectClientPubkey` is enabled, the client's public key is injected into the request's `_meta` field before being passed to the server.
    - If `injectRequestEventId` is enabled, the inbound Nostr event ID is injected into `_meta.requestEventId`, allowing tools and middleware to retrieve the full event via `getNostrRequestEvent()`.
4.  **Response Handling**: When the `McpServer` sends a response, the transport's `send()` method is called. The transport looks up the original request details from the client's session, restores the original request ID, and sends the response back to the correct client, referencing the original event ID.
5.  **Discoverability publication**: Public announcement events (kinds 11316-11320) are controlled by `isAnnouncedServer`. Relay-list metadata (`kind:10002`) is controlled independently by `publishRelayList`. Profile metadata (`kind:0`) is controlled independently by `profileMetadata`.

## Relay List Discoverability

Servers can publish a NIP-65 relay list so clients can discover where the server is reachable.

### Default Behavior

- `isAnnouncedServer: true` enables public announcement publication
- `publishRelayList` defaults to `true` for both public and private servers
- if `relayListUrls` is omitted, the SDK derives advertised relays from the configured relay handler when possible
- `bootstrapRelayUrls` can be used to publish discoverability events to extra relays without advertising them as operational relays

### Why bootstrap relays exist

Operational relays and discoverability relays do not always need to be identical:

- **Operational relays** are where the server actually handles requests and responses
- **Bootstrap relays** are additional relays used to make the server easier to discover

This separation helps keep the published relay list focused while still improving network visibility.

## Discoverability event matrix

The transport now exposes three independent publication surfaces:

| Purpose                            | Event kind(s)   | Controlled by       |
| ---------------------------------- | --------------- | ------------------- |
| ContextVM capability announcements | `11316`-`11320` | `isAnnouncedServer` |
| Relay discoverability              | `10002`         | `publishRelayList`  |
| Nostr profile identity             | `0`             | `profileMetadata`   |

This allows release-time configurations such as:

- fully public servers that publish all three surfaces;
- private servers that only publish relay metadata;
- private or semi-private servers that publish a `kind:0` profile without publishing capability announcements.

## Session Management

The `NostrServerTransport` manages a session for each unique client public key. Each session tracks:

- If the client has completed the MCP initialization handshake.
- Whether the session is encrypted.
- A map of pending requests to correlate responses.
- The timestamp of the last activity, used for cleaning up inactive sessions.

## Security and Policy Flexibility

The capability exclusion feature provides enhanced security policy flexibility by allowing you to create a whitelist-based security model with specific exceptions. This approach is particularly useful for:

### Use Cases

1. **Public Discovery**: Allow any client to discover your server's capabilities via `tools/list` while restricting actual tool usage to authorized clients.

2. **Limited Public Access**: Permit specific, safe operations from untrusted clients while maintaining security for sensitive operations.

3. **Backward Compatibility**: Gradually introduce stricter security policies while maintaining compatibility with existing clients.

4. **Tiered Access**: Create different levels of access where certain capabilities are available to all clients, while others require explicit authorization.

## Client Public Key Injection

When the `injectClientPubkey` option is enabled, the transport injects the client's public key into the `_meta` field of requests passed to the underlying MCP server. This enables servers to access client identification information for authentication, authorization, and enhanced integration purposes.

### How It Works

1. When a request is received from a client, the transport extracts the client's public key from the Nostr event
2. The transport embeds the `clientPubkey` field in the message's `_meta` field
3. The modified request is then passed to the underlying server

The injected metadata follows this structure:

```json
{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/call",
  "params": {
    "name": "example_tool",
    "arguments": {}
  },
  "_meta": {
    "clientPubkey": "<client-public-key-hex>",
    "requestEventId": "<nostr-event-id-hex>"
  }
}
```

### Use Cases

- **Authentication**: Servers can verify client identity without additional protocol overhead
- **Authorization**: Implement per-client access controls based on public key
- **Logging**: Track client activity and usage patterns
- **Rate Limiting**: Apply rate limits on a per-client basis
- **Personalization**: Provide client-specific responses or data

## Request Event ID Injection

When the `injectRequestEventId` option is enabled, the transport injects the inbound Nostr request event ID into `_meta.requestEventId` on incoming request messages. Tool implementations can then use `getNostrRequestEvent()` to retrieve the full signed Nostr event, including the sender's pubkey and all event metadata.

### Accessing the Full Request Event Inside a Tool

```typescript
import {
  NostrServerTransport,
  PrivateKeySigner,
  ApplesauceRelayPool,
} from '@contextvm/sdk';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';

const signer = new PrivateKeySigner('your-server-private-key');
const relayPool = new ApplesauceRelayPool(['wss://relay.damus.io']);
const transport = new NostrServerTransport({
  signer,
  relayHandler: relayPool,
  injectRequestEventId: true,
});

const server = new McpServer({ name: 'demo-server', version: '1.0.0' });

server.registerTool(
  'whoami',
  {
    description: 'Returns the public key of the client that invoked this tool.',
    inputSchema: {},
  },
  async (_args, extra) => {
    const requestEventId = extra._meta?.requestEventId;
    if (requestEventId) {
      const requestEvent = transport.getNostrRequestEvent(requestEventId);
      if (requestEvent) {
        return {
          content: [
            {
              type: 'text',
              text: `Called by ${requestEvent.pubkey} at timestamp ${requestEvent.created_at}`,
            },
          ],
        };
      }
    }
    return {
      content: [{ type: 'text', text: 'unknown caller' }],
    };
  },
);

await server.connect(transport);
```

## Structured Tool Outputs

`NostrServerTransport` does not change the MCP tool result model, so structured outputs work the same way they do on any other MCP transport. This is especially useful when your server is meant for programmatic usage and clients should be able to depend on a stable result shape.

Define an `outputSchema` on the tool and return `structuredContent` from the handler:

```typescript
server.registerTool(
  'get_weather',
  {
    description: 'Get weather information for a city',
    inputSchema: z.object({
      city: z.string(),
      country: z.string(),
    }),
    outputSchema: z.object({
      temperature: z.object({
        celsius: z.number(),
        fahrenheit: z.number(),
      }),
      conditions: z.enum(['sunny', 'cloudy', 'rainy', 'stormy', 'snowy']),
      humidity: z.number().min(0).max(100),
    }),
  },
  async ({ city, country }) => {
    const structuredContent = {
      temperature: {
        celsius: 22,
        fahrenheit: 71.6,
      },
      conditions: 'sunny' as const,
      humidity: 45,
    };

    return {
      content: [
        {
          type: 'text',
          text: `Weather for ${city}, ${country}: ${structuredContent.temperature.celsius}°C and ${structuredContent.conditions}.`,
        },
      ],
      structuredContent,
    };
  },
);
```

Guidance:

- Use `structuredContent` for machine-readable output.
- Use `content` for human-readable output only.
- `content` does not need to duplicate `structuredContent`.
- If no human-readable output is needed, `content` can be `[]`.

## Next Steps

Now that you understand how the transports work, let's dive into the **[Signer](/signer/nostr-signer-interface)**, the component responsible for cryptographic signatures.