sello 0.1.13 → 0.1.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -16,6 +16,7 @@
16
16
  <p align="center">
17
17
  <a href="#try-it">Quickstart</a> &middot;
18
18
  <a href="#add-sello-to-a-tool">Add Sello</a> &middot;
19
+ <a href="#add-sello-to-an-mcp-server">MCP</a> &middot;
19
20
  <a href="sdks/README.md">SDKs</a> &middot;
20
21
  <a href="#see-logged-actions">Actions</a> &middot;
21
22
  <a href="#how-it-works">How It Works</a> &middot;
@@ -116,6 +117,39 @@ npx --yes sello init-demo
116
117
  npx --yes sello init-http-demo
117
118
  ```
118
119
 
120
+ ## Add Sello to an MCP Server
121
+
122
+ If your service exposes MCP tools, wrap the tool callback:
123
+
124
+ ```ts
125
+ import { sello } from "sello";
126
+
127
+ const receipts = sello.service();
128
+
129
+ server.registerTool(
130
+ "calendar.create_event",
131
+ { inputSchema: createEventInputSchema },
132
+ receipts.mcpTool("calendar.create_event", async (args) => {
133
+ const event = await calendar.events.create(args);
134
+ return {
135
+ content: [{ type: "text", text: event.id }],
136
+ };
137
+ }),
138
+ );
139
+ ```
140
+
141
+ Some MCP SDK versions call this method `tool` instead of `registerTool`; use the same callback slot either way.
142
+
143
+ `mcpTool` verifies the agent token before your callback runs, preserves the callback's return value, rethrows callback errors, and emits a receipt with action type `mcp.tools/call.<tool-name>`.
144
+
145
+ By default, Sello looks for an `Authorization: Bearer ...` token in common MCP context/header fields. If your transport stores tokens somewhere else, pass an extractor:
146
+
147
+ ```ts
148
+ receipts.mcpTool("calendar.create_event", handler, {
149
+ authorizationToken: ({ context }) => context.session.token,
150
+ });
151
+ ```
152
+
119
153
  ## See Logged Actions
120
154
 
121
155
  ```bash
@@ -155,7 +189,7 @@ Sello does not prove that the agent called every service it should have called,
155
189
  - [Protocol Walkthrough](docs/protocol-walkthrough.md): the primitive receipt loop for implementers.
156
190
  - [SPEC.md](SPEC.md): the Sello protocol draft.
157
191
  - [Notarized Agents paper](https://arxiv.org/abs/2606.04193): design rationale, threat model, and prior art.
158
- - [sdks/typescript/examples/mcp-minimal-server.ts](sdks/typescript/examples/mcp-minimal-server.ts): a small MCP-shaped integration.
192
+ - [sdks/typescript/examples/mcp-minimal-server.ts](sdks/typescript/examples/mcp-minimal-server.ts): a small MCP integration.
159
193
  - [docs/security-review.md](docs/security-review.md) and [docs/sdk-security-audit.md](docs/sdk-security-audit.md): current review notes.
160
194
 
161
195
  The TypeScript SDK is published on [npm](https://www.npmjs.com/package/sello) and lives in [`sdks/typescript/`](sdks/typescript/). The Python SDK is published on [PyPI](https://pypi.org/project/sello/) and lives in [`sdks/python/`](sdks/python/). Both SDKs support the same service-side `sello.service()` flow; Python uses the `@receipts.tool(...)` decorator. Live Rekor proof verification and production identity operations are still future work.
package/dist/cli/sello.js CHANGED
@@ -199,6 +199,29 @@ async function devCommand(args ) {
199
199
  console.log(`Local dev log: ${logPath}`);
200
200
  console.log(`Loaded ${devLogEntryCountLabel(loadedEntries)}.`);
201
201
  });
202
+ server.on("error", (error) => {
203
+ if (isPortInUse(error)) {
204
+ console.error(`sello: port ${port} is already in use.`);
205
+ console.error("");
206
+ console.error("Try:");
207
+ console.error(` sello dev --port ${port + 1}`);
208
+ console.error("");
209
+ console.error("Or stop the process using that port:");
210
+ console.error(` lsof -nP -iTCP:${port} -sTCP:LISTEN`);
211
+ process.exitCode = 1;
212
+ return;
213
+ }
214
+
215
+ if (isListenError(error)) {
216
+ const message = error instanceof Error ? error.message : String(error);
217
+ console.error(`sello: could not start dev server on port ${port}.`);
218
+ console.error(message);
219
+ process.exitCode = 1;
220
+ return;
221
+ }
222
+
223
+ throw error;
224
+ });
202
225
  }
203
226
 
204
227
  async function emitDemoCommand(args ) {
@@ -257,14 +280,7 @@ async function callHttpDemoCommand(args ) {
257
280
  );
258
281
  const url = readFlag(args, "--url") ?? "http://localhost:8790/calendar/events";
259
282
  const title = readFlag(args, "--title") ?? "Ship Sello";
260
- const response = await fetch(url, {
261
- method: "POST",
262
- headers: {
263
- authorization: `Bearer ${state.agentToken}`,
264
- "content-type": "application/json",
265
- },
266
- body: JSON.stringify({ title }),
267
- });
283
+ const response = await fetchHttpDemo(url, state.agentToken, title);
268
284
  const responseText = await response.text();
269
285
 
270
286
  if (!response.ok) {
@@ -283,6 +299,31 @@ async function callHttpDemoCommand(args ) {
283
299
  console.log(` ${actionViewerUrl(state)}`);
284
300
  }
285
301
 
302
+ async function fetchHttpDemo(
303
+ url ,
304
+ agentToken ,
305
+ title ,
306
+ ) {
307
+ try {
308
+ return await fetch(url, {
309
+ method: "POST",
310
+ headers: {
311
+ authorization: `Bearer ${agentToken}`,
312
+ "content-type": "application/json",
313
+ },
314
+ body: JSON.stringify({ title }),
315
+ });
316
+ } catch (error) {
317
+ if (isFetchFailed(error)) {
318
+ throw new TypeError(
319
+ `could not reach the HTTP demo route at ${url}.\n\nStart it in another terminal:\n node sello-http-route.mjs`,
320
+ );
321
+ }
322
+
323
+ throw error;
324
+ }
325
+ }
326
+
286
327
  function initDemoCommand(args ) {
287
328
  const output = readFlag(args, "--output") ?? "emit-receipt.mjs";
288
329
  const force = args.includes("--force");
@@ -787,6 +828,22 @@ function isRecord(value ) {
787
828
  return typeof value === "object" && value !== null && !Array.isArray(value);
788
829
  }
789
830
 
831
+ function isPortInUse(error ) {
832
+ return (
833
+ isRecord(error) &&
834
+ error.code === "EADDRINUSE" &&
835
+ error.syscall === "listen"
836
+ );
837
+ }
838
+
839
+ function isListenError(error ) {
840
+ return isRecord(error) && error.syscall === "listen";
841
+ }
842
+
843
+ function isFetchFailed(error ) {
844
+ return error instanceof TypeError && error.message === "fetch failed";
845
+ }
846
+
790
847
  function printHelp() {
791
848
  console.log(`Usage:
792
849
  sello dev [--port 8787] [--service service-id] [--dry-run]
@@ -41,6 +41,36 @@ import {
41
41
 
42
42
 
43
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
+
44
74
 
45
75
 
46
76
 
@@ -65,6 +95,11 @@ import {
65
95
 
66
96
 
67
97
 
98
+
99
+
100
+
101
+
102
+
68
103
 
69
104
 
70
105
 
@@ -114,90 +149,126 @@ export function createSelloService(input ) {
114
149
  return publisher;
115
150
  }
116
151
 
117
- return {
118
- tool (
119
- actionType ,
120
- handler ,
121
- options = {},
122
- ) {
123
- if (typeof actionType !== "string" || actionType.length === 0) {
124
- throw new TypeError("Sello action type must be a non-empty string");
125
- }
152
+ function wrapTool (
153
+ actionType ,
154
+ handler ,
155
+ options = {},
156
+ ) {
157
+ if (typeof actionType !== "string" || actionType.length === 0) {
158
+ throw new TypeError("Sello action type must be a non-empty string");
159
+ }
126
160
 
127
- return async (request ) => {
128
- const resolved = await config();
129
- const authorizationToken = resolveValue(
130
- options.authorizationToken ?? defaultAuthorizationToken,
131
- request,
132
- );
133
- const tokenIssuerPublicKey = await resolveTokenIssuerPublicKey(resolved.tokenIssuer);
134
- const verifiedToken = verifySelloJwsToken({
135
- authorizationToken,
136
- issuerPublicKey: tokenIssuerPublicKey,
161
+ return async (request ) => {
162
+ const resolved = await config();
163
+ const authorizationToken = resolveValue(
164
+ options.authorizationToken ?? defaultAuthorizationToken,
165
+ request,
166
+ );
167
+ const tokenIssuerPublicKey = await resolveTokenIssuerPublicKey(resolved.tokenIssuer);
168
+ const verifiedToken = verifySelloJwsToken({
169
+ authorizationToken,
170
+ issuerPublicKey: tokenIssuerPublicKey,
171
+ });
172
+ const selloLogs = selectSelloLogs(
173
+ verifiedToken.selloLogs,
174
+ resolved.fallbackSelloLogs,
175
+ resolved.log.logUrl,
176
+ );
177
+ const base = {
178
+ authorizationTokenBytes: verifiedToken.authorizationTokenBytes,
179
+ ownerHpkePublicKey: verifiedToken.ownerHpkePublicKey,
180
+ selloLogs,
181
+ serviceKid: resolved.serviceKid,
182
+ servicePrivateKey: resolved.servicePrivateKey,
183
+ serviceIdentifier: resolved.service,
184
+ logUrl: resolved.log.logUrl,
185
+ actionType,
186
+ actionInputBytes: (options.canonicalizeInput ?? canonicalJsonBytes)(request),
187
+ timestamp: resolved.now(),
188
+ };
189
+
190
+ if (options.isDenied && (await options.isDenied(request))) {
191
+ const response = options.deniedResponse
192
+ ? await options.deniedResponse(request)
193
+ : undefined;
194
+ const receipt = emitReceipt({
195
+ ...base,
196
+ actionOutputBytes: new Uint8Array(),
197
+ resultStatus: "denied",
137
198
  });
138
- const selloLogs = selectSelloLogs(
139
- verifiedToken.selloLogs,
140
- resolved.fallbackSelloLogs,
141
- resolved.log.logUrl,
142
- );
143
- const base = {
144
- authorizationTokenBytes: verifiedToken.authorizationTokenBytes,
145
- ownerHpkePublicKey: verifiedToken.ownerHpkePublicKey,
146
- selloLogs,
147
- serviceKid: resolved.serviceKid,
148
- servicePrivateKey: resolved.servicePrivateKey,
149
- serviceIdentifier: resolved.service,
150
- logUrl: resolved.log.logUrl,
151
- actionType,
152
- actionInputBytes: (options.canonicalizeInput ?? canonicalJsonBytes)(request),
153
- timestamp: resolved.now(),
154
- };
155
-
156
- if (options.isDenied && (await options.isDenied(request))) {
157
- const response = options.deniedResponse
158
- ? await options.deniedResponse(request)
159
- : undefined;
160
- const receipt = emitReceipt({
161
- ...base,
162
- actionOutputBytes: new Uint8Array(),
163
- resultStatus: "denied",
164
- });
165
- resolved.onReceipt?.({ resultStatus: "denied", receipt, response });
166
- await submit(resolved, receipt, base.timestamp);
167
-
168
- if (options.deniedResponse) {
169
- return response ;
170
- }
171
-
172
- throw new SelloDeniedError(receipt);
173
- }
199
+ resolved.onReceipt?.({ resultStatus: "denied", receipt, response });
200
+ await submit(resolved, receipt, base.timestamp);
174
201
 
175
- let response ;
176
- try {
177
- response = await handler(request);
178
- } catch (error) {
179
- const receipt = emitReceipt({
180
- ...base,
181
- actionOutputBytes: (options.canonicalizeError ?? canonicalErrorBytes)(
182
- error,
183
- ),
184
- resultStatus: "error",
185
- });
186
- resolved.onReceipt?.({ resultStatus: "error", receipt, error });
187
- await submit(resolved, receipt, base.timestamp);
188
- throw error;
202
+ if (options.deniedResponse) {
203
+ return response ;
189
204
  }
190
205
 
206
+ throw new SelloDeniedError(receipt);
207
+ }
208
+
209
+ let response ;
210
+ try {
211
+ response = await handler(request);
212
+ } catch (error) {
191
213
  const receipt = emitReceipt({
192
214
  ...base,
193
- actionOutputBytes: (options.canonicalizeOutput ?? canonicalJsonBytes)(
194
- response,
215
+ actionOutputBytes: (options.canonicalizeError ?? canonicalErrorBytes)(
216
+ error,
195
217
  ),
196
- resultStatus: "success",
218
+ resultStatus: "error",
197
219
  });
198
- resolved.onReceipt?.({ resultStatus: "success", receipt, response });
220
+ resolved.onReceipt?.({ resultStatus: "error", receipt, error });
199
221
  await submit(resolved, receipt, base.timestamp);
200
- return response;
222
+ throw error;
223
+ }
224
+
225
+ const receipt = emitReceipt({
226
+ ...base,
227
+ actionOutputBytes: (options.canonicalizeOutput ?? canonicalJsonBytes)(
228
+ response,
229
+ ),
230
+ resultStatus: "success",
231
+ });
232
+ resolved.onReceipt?.({ resultStatus: "success", receipt, response });
233
+ await submit(resolved, receipt, base.timestamp);
234
+ return response;
235
+ };
236
+ }
237
+
238
+ return {
239
+ tool (
240
+ actionType ,
241
+ handler ,
242
+ options = {},
243
+ ) {
244
+ return wrapTool(actionType, handler, options);
245
+ },
246
+ mcpTool (
247
+ name ,
248
+ handler ,
249
+ options = {},
250
+ ) {
251
+ if (typeof name !== "string" || name.length === 0) {
252
+ throw new TypeError("MCP tool name must be a non-empty string");
253
+ }
254
+
255
+ const wrapped = wrapTool (
256
+ options.actionType ?? `mcp.tools/call.${name}`,
257
+ async (invocation) => handler(invocation.arguments, invocation.context),
258
+ {
259
+ authorizationToken:
260
+ options.authorizationToken ?? defaultMcpAuthorizationToken,
261
+ canonicalizeInput:
262
+ options.canonicalizeInput ?? defaultMcpCanonicalizeInput,
263
+ canonicalizeOutput: options.canonicalizeOutput,
264
+ canonicalizeError: options.canonicalizeError,
265
+ isDenied: options.isDenied,
266
+ deniedResponse: options.deniedResponse,
267
+ },
268
+ );
269
+
270
+ return async (args , context ) => {
271
+ return await wrapped({ name, arguments: args, context });
201
272
  };
202
273
  },
203
274
  async flush() {
@@ -481,23 +552,113 @@ function selectSelloLogs(
481
552
  }
482
553
 
483
554
  function defaultAuthorizationToken(request ) {
555
+ const token = authorizationTokenFromUnknown(request);
556
+ if (token) {
557
+ return token;
558
+ }
559
+
560
+ throw new TypeError(
561
+ "Sello authorization token not found. Pass authorizationToken or include request.authorizationToken.",
562
+ );
563
+ }
564
+
565
+ function defaultMcpAuthorizationToken(
566
+ invocation ,
567
+ ) {
568
+ const fromContext = authorizationTokenFromUnknown(invocation.context);
569
+ if (fromContext) {
570
+ return fromContext;
571
+ }
572
+
573
+ throw new TypeError(
574
+ "Sello MCP authorization token not found. Pass authorizationToken or include an Authorization header in the MCP context.",
575
+ );
576
+ }
577
+
578
+ function defaultMcpCanonicalizeInput(
579
+ invocation ,
580
+ ) {
581
+ return canonicalJsonBytes({
582
+ method: "tools/call",
583
+ params: {
584
+ name: invocation.name,
585
+ arguments: invocation.arguments,
586
+ },
587
+ });
588
+ }
589
+
590
+ function authorizationTokenFromUnknown(value ) {
591
+ if (!isRecord(value)) {
592
+ return undefined;
593
+ }
594
+
595
+ const direct = value.authorizationToken ?? value.authorization;
596
+ if (typeof direct === "string" || direct instanceof Uint8Array) {
597
+ return stripBearer(direct);
598
+ }
599
+
600
+ const headers = value.headers;
601
+ const fromHeaders = authorizationTokenFromHeaders(headers);
602
+ if (fromHeaders) {
603
+ return fromHeaders;
604
+ }
605
+
606
+ const requestInfo = value.requestInfo;
607
+ if (isRecord(requestInfo)) {
608
+ const fromRequestInfo = authorizationTokenFromHeaders(requestInfo.headers);
609
+ if (fromRequestInfo) {
610
+ return fromRequestInfo;
611
+ }
612
+ }
613
+
614
+ const request = value.request;
484
615
  if (isRecord(request)) {
485
- const direct = request.authorizationToken ?? request.authorization;
486
- if (typeof direct === "string" || direct instanceof Uint8Array) {
487
- return stripBearer(direct);
616
+ const fromRequest = authorizationTokenFromHeaders(request.headers);
617
+ if (fromRequest) {
618
+ return fromRequest;
488
619
  }
620
+ }
489
621
 
490
- const headers = request.headers;
491
- if (isRecord(headers)) {
492
- const header = headers.authorization ?? headers.Authorization;
493
- if (typeof header === "string") {
494
- return stripBearer(header);
495
- }
622
+ const authInfo = value.authInfo;
623
+ if (isRecord(authInfo)) {
624
+ const token = authInfo.token ?? authInfo.accessToken ?? authInfo.access_token;
625
+ if (typeof token === "string" || token instanceof Uint8Array) {
626
+ return stripBearer(token);
496
627
  }
497
628
  }
498
629
 
499
- throw new TypeError(
500
- "Sello authorization token not found. Pass authorizationToken or include request.authorizationToken.",
630
+ return undefined;
631
+ }
632
+
633
+ function authorizationTokenFromHeaders(
634
+ headers ,
635
+ ) {
636
+ if (isHeadersLike(headers)) {
637
+ const header = headers.get("authorization") ?? headers.get("Authorization");
638
+ if (typeof header === "string") {
639
+ return stripBearer(header);
640
+ }
641
+ }
642
+
643
+ if (!isRecord(headers)) {
644
+ return undefined;
645
+ }
646
+
647
+ const header = headers.authorization ?? headers.Authorization;
648
+ if (typeof header === "string" || header instanceof Uint8Array) {
649
+ return stripBearer(header);
650
+ }
651
+
652
+ return undefined;
653
+ }
654
+
655
+ function isHeadersLike(
656
+ value ,
657
+ ) {
658
+ return (
659
+ typeof value === "object" &&
660
+ value !== null &&
661
+ typeof (value ).get === "function"
501
662
  );
502
663
  }
503
664
 
@@ -97,6 +97,35 @@ node --run example:tool
97
97
  node --run actions
98
98
  ```
99
99
 
100
+ For an MCP tool, wrap the callback you already register:
101
+
102
+ ```ts
103
+ import { sello } from "sello";
104
+
105
+ const receipts = sello.service();
106
+
107
+ server.registerTool(
108
+ "calendar.create_event",
109
+ { inputSchema: createEventInputSchema },
110
+ receipts.mcpTool("calendar.create_event", async (args) => {
111
+ const event = await calendar.events.create(args);
112
+ return {
113
+ content: [{ type: "text", text: event.id }],
114
+ };
115
+ }),
116
+ );
117
+ ```
118
+
119
+ Some MCP SDK versions call this method `tool` instead of `registerTool`; use the same callback slot either way.
120
+
121
+ `receipts.mcpTool(...)` uses action type `mcp.tools/call.<tool-name>` and hashes only the MCP method name, tool name, and arguments. It tries common MCP context/header locations for the bearer token. If your transport puts the token somewhere else, pass an extractor:
122
+
123
+ ```ts
124
+ receipts.mcpTool("calendar.create_event", handler, {
125
+ authorizationToken: ({ context }) => context.session.token,
126
+ });
127
+ ```
128
+
100
129
  Or run the matching Python example:
101
130
 
102
131
  ```bash
@@ -29,6 +29,7 @@ These notes cover the first Stripe-style SDK implementation pass. They are writt
29
29
  - Invalid tokens prevent the handler from running and emit no receipt.
30
30
  - Success, error, and denied paths emit receipts without including plaintext request or response bodies.
31
31
  - The wrapper uses the configured service identity and key for every receipt.
32
+ - `receipts.mcpTool(...)` reuses the same wrapper path. It defaults to hashing the MCP `tools/call` method, tool name, and arguments, while excluding bearer tokens and transport context from the action input hash.
32
33
 
33
34
  ## Phase 4: Logs And Action Viewing
34
35
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sello",
3
- "version": "0.1.13",
3
+ "version": "0.1.15",
4
4
  "description": "Reference implementation of the Sello protocol for service-signed AI agent receipts.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -1,8 +1,4 @@
1
- import {
2
- canonicalJsonBytes,
3
- sello,
4
- type SelloReceipts,
5
- } from "../src/index.ts";
1
+ import { sello, type SelloReceipts } from "../src/index.ts";
6
2
 
7
3
  export type MinimalMcpRequest = {
8
4
  headers: {
@@ -35,10 +31,13 @@ export type MinimalMcpResponse = {
35
31
  export function createCalendarMcpServer(
36
32
  receipts: SelloReceipts = sello.service(),
37
33
  ) {
38
- const createEvent = receipts.tool<MinimalMcpRequest, MinimalMcpResponse>(
39
- "mcp.tools/call.calendar.create_event",
40
- async (request) => {
41
- const args = request.body.params.arguments;
34
+ const createEvent = receipts.mcpTool<
35
+ MinimalMcpRequest["body"]["params"]["arguments"],
36
+ MinimalMcpResponse,
37
+ MinimalMcpRequest
38
+ >(
39
+ "calendar.create_event",
40
+ async (args, request) => {
42
41
  const title = readString(args.title, "title");
43
42
 
44
43
  return {
@@ -54,13 +53,6 @@ export function createCalendarMcpServer(
54
53
  },
55
54
  };
56
55
  },
57
- {
58
- canonicalizeInput: (request) => canonicalJsonBytes({
59
- method: request.body.method,
60
- params: request.body.params,
61
- }),
62
- canonicalizeOutput: (response) => canonicalJsonBytes(response),
63
- },
64
56
  );
65
57
 
66
58
  return {
@@ -79,7 +71,7 @@ export function createCalendarMcpServer(
79
71
  };
80
72
  }
81
73
 
82
- return await createEvent(request);
74
+ return await createEvent(request.body.params.arguments, request);
83
75
  },
84
76
 
85
77
  flush: () => receipts.flush(),
@@ -3,7 +3,6 @@
3
3
  import { pathToFileURL } from "node:url";
4
4
 
5
5
  import {
6
- canonicalJsonBytes,
7
6
  sello,
8
7
  type SdkSubmissionLog,
9
8
  type SelloReceipts,
@@ -76,7 +75,10 @@ export type McpToolServerExampleOptions = {
76
75
  export function createSelloMcpToolServer(receipts: SelloReceipts): SelloMcpToolServer {
77
76
  const tools = new Map<
78
77
  string,
79
- (request: McpHttpToolCall) => Promise<McpHttpToolResponse>
78
+ (
79
+ args: Record<string, unknown>,
80
+ request: McpHttpToolCall,
81
+ ) => Promise<McpHttpToolResponse>
80
82
  >();
81
83
 
82
84
  return {
@@ -88,23 +90,20 @@ export function createSelloMcpToolServer(receipts: SelloReceipts): SelloMcpToolS
88
90
  throw new TypeError(`MCP tool ${name} is already registered`);
89
91
  }
90
92
 
91
- const wrapped = receipts.tool<McpHttpToolCall, McpHttpToolResponse>(
92
- `mcp.tools/call.${name}`,
93
- async (request) => ({
93
+ const wrapped = receipts.mcpTool<
94
+ Record<string, unknown>,
95
+ McpHttpToolResponse,
96
+ McpHttpToolCall
97
+ >(
98
+ name,
99
+ async (args, request) => ({
94
100
  status: 200,
95
101
  body: {
96
102
  jsonrpc: "2.0",
97
103
  id: request.body.id,
98
- result: await handler(request.body.params.arguments),
104
+ result: await handler(args),
99
105
  },
100
106
  }),
101
- {
102
- canonicalizeInput: (request) => canonicalJsonBytes({
103
- method: request.body.method,
104
- params: request.body.params,
105
- }),
106
- canonicalizeOutput: (response) => canonicalJsonBytes(response.body),
107
- },
108
107
  );
109
108
 
110
109
  tools.set(name, wrapped);
@@ -120,7 +119,7 @@ export function createSelloMcpToolServer(receipts: SelloReceipts): SelloMcpToolS
120
119
  return jsonRpcError(request.body.id, -32601, "tool not found");
121
120
  }
122
121
 
123
- return await tool(request);
122
+ return await tool(request.body.params.arguments, request);
124
123
  },
125
124
  };
126
125
  }
@@ -200,6 +200,29 @@ async function devCommand(args: string[]): Promise<void> {
200
200
  console.log(`Local dev log: ${logPath}`);
201
201
  console.log(`Loaded ${devLogEntryCountLabel(loadedEntries)}.`);
202
202
  });
203
+ server.on("error", (error) => {
204
+ if (isPortInUse(error)) {
205
+ console.error(`sello: port ${port} is already in use.`);
206
+ console.error("");
207
+ console.error("Try:");
208
+ console.error(` sello dev --port ${port + 1}`);
209
+ console.error("");
210
+ console.error("Or stop the process using that port:");
211
+ console.error(` lsof -nP -iTCP:${port} -sTCP:LISTEN`);
212
+ process.exitCode = 1;
213
+ return;
214
+ }
215
+
216
+ if (isListenError(error)) {
217
+ const message = error instanceof Error ? error.message : String(error);
218
+ console.error(`sello: could not start dev server on port ${port}.`);
219
+ console.error(message);
220
+ process.exitCode = 1;
221
+ return;
222
+ }
223
+
224
+ throw error;
225
+ });
203
226
  }
204
227
 
205
228
  async function emitDemoCommand(args: string[]): Promise<void> {
@@ -258,14 +281,7 @@ async function callHttpDemoCommand(args: string[]): Promise<void> {
258
281
  );
259
282
  const url = readFlag(args, "--url") ?? "http://localhost:8790/calendar/events";
260
283
  const title = readFlag(args, "--title") ?? "Ship Sello";
261
- const response = await fetch(url, {
262
- method: "POST",
263
- headers: {
264
- authorization: `Bearer ${state.agentToken}`,
265
- "content-type": "application/json",
266
- },
267
- body: JSON.stringify({ title }),
268
- });
284
+ const response = await fetchHttpDemo(url, state.agentToken, title);
269
285
  const responseText = await response.text();
270
286
 
271
287
  if (!response.ok) {
@@ -284,6 +300,31 @@ async function callHttpDemoCommand(args: string[]): Promise<void> {
284
300
  console.log(` ${actionViewerUrl(state)}`);
285
301
  }
286
302
 
303
+ async function fetchHttpDemo(
304
+ url: string,
305
+ agentToken: string,
306
+ title: string,
307
+ ): Promise<Response> {
308
+ try {
309
+ return await fetch(url, {
310
+ method: "POST",
311
+ headers: {
312
+ authorization: `Bearer ${agentToken}`,
313
+ "content-type": "application/json",
314
+ },
315
+ body: JSON.stringify({ title }),
316
+ });
317
+ } catch (error) {
318
+ if (isFetchFailed(error)) {
319
+ throw new TypeError(
320
+ `could not reach the HTTP demo route at ${url}.\n\nStart it in another terminal:\n node sello-http-route.mjs`,
321
+ );
322
+ }
323
+
324
+ throw error;
325
+ }
326
+ }
327
+
287
328
  function initDemoCommand(args: string[]): void {
288
329
  const output = readFlag(args, "--output") ?? "emit-receipt.mjs";
289
330
  const force = args.includes("--force");
@@ -788,6 +829,22 @@ function isRecord(value: unknown): value is Record<string, unknown> {
788
829
  return typeof value === "object" && value !== null && !Array.isArray(value);
789
830
  }
790
831
 
832
+ function isPortInUse(error: unknown): boolean {
833
+ return (
834
+ isRecord(error) &&
835
+ error.code === "EADDRINUSE" &&
836
+ error.syscall === "listen"
837
+ );
838
+ }
839
+
840
+ function isListenError(error: unknown): boolean {
841
+ return isRecord(error) && error.syscall === "listen";
842
+ }
843
+
844
+ function isFetchFailed(error: unknown): boolean {
845
+ return error instanceof TypeError && error.message === "fetch failed";
846
+ }
847
+
791
848
  function printHelp(): void {
792
849
  console.log(`Usage:
793
850
  sello dev [--port 8787] [--service service-id] [--dry-run]
@@ -42,6 +42,36 @@ export type SelloToolOptions<Request, Response> = {
42
42
  deniedResponse?: (request: Request) => Response | Promise<Response>;
43
43
  };
44
44
 
45
+ export type SelloMcpToolInvocation<Args, Context = unknown> = {
46
+ name: string;
47
+ arguments: Args;
48
+ context: Context;
49
+ };
50
+
51
+ export type SelloMcpToolHandler<Args, Response, Context = unknown> = (
52
+ args: Args,
53
+ context: Context,
54
+ ) => Response | Promise<Response>;
55
+
56
+ export type SelloMcpToolOptions<Args, Response, Context = unknown> = {
57
+ actionType?: string;
58
+ authorizationToken?: SelloValueOrGetter<
59
+ SelloMcpToolInvocation<Args, Context>,
60
+ string | Uint8Array
61
+ >;
62
+ canonicalizeInput?: (
63
+ invocation: SelloMcpToolInvocation<Args, Context>,
64
+ ) => Uint8Array;
65
+ canonicalizeOutput?: (response: Response) => Uint8Array;
66
+ canonicalizeError?: (error: unknown) => Uint8Array;
67
+ isDenied?: (
68
+ invocation: SelloMcpToolInvocation<Args, Context>,
69
+ ) => boolean | Promise<boolean>;
70
+ deniedResponse?: (
71
+ invocation: SelloMcpToolInvocation<Args, Context>,
72
+ ) => Response | Promise<Response>;
73
+ };
74
+
45
75
  export type SelloServiceConfig = {
46
76
  service?: string;
47
77
  serviceKey?: ServiceKeyInput;
@@ -66,6 +96,11 @@ export type SelloReceipts = {
66
96
  handler: SelloToolHandler<Request, Response>,
67
97
  options?: SelloToolOptions<Request, Response>,
68
98
  ): SelloToolHandler<Request, Response>;
99
+ mcpTool<Args, Response, Context = unknown>(
100
+ name: string,
101
+ handler: SelloMcpToolHandler<Args, Response, Context>,
102
+ options?: SelloMcpToolOptions<Args, Response, Context>,
103
+ ): SelloMcpToolHandler<Args, Response, Context>;
69
104
  flush(): Promise<void>;
70
105
  };
71
106
 
@@ -115,90 +150,126 @@ export function createSelloService(input?: SelloServiceInput): SelloReceipts {
115
150
  return publisher;
116
151
  }
117
152
 
118
- return {
119
- tool<Request, Response>(
120
- actionType: string,
121
- handler: SelloToolHandler<Request, Response>,
122
- options: SelloToolOptions<Request, Response> = {},
123
- ): SelloToolHandler<Request, Response> {
124
- if (typeof actionType !== "string" || actionType.length === 0) {
125
- throw new TypeError("Sello action type must be a non-empty string");
126
- }
153
+ function wrapTool<Request, Response>(
154
+ actionType: string,
155
+ handler: SelloToolHandler<Request, Response>,
156
+ options: SelloToolOptions<Request, Response> = {},
157
+ ): SelloToolHandler<Request, Response> {
158
+ if (typeof actionType !== "string" || actionType.length === 0) {
159
+ throw new TypeError("Sello action type must be a non-empty string");
160
+ }
127
161
 
128
- return async (request: Request): Promise<Response> => {
129
- const resolved = await config();
130
- const authorizationToken = resolveValue(
131
- options.authorizationToken ?? defaultAuthorizationToken,
132
- request,
133
- );
134
- const tokenIssuerPublicKey = await resolveTokenIssuerPublicKey(resolved.tokenIssuer);
135
- const verifiedToken = verifySelloJwsToken({
136
- authorizationToken,
137
- issuerPublicKey: tokenIssuerPublicKey,
162
+ return async (request: Request): Promise<Response> => {
163
+ const resolved = await config();
164
+ const authorizationToken = resolveValue(
165
+ options.authorizationToken ?? defaultAuthorizationToken,
166
+ request,
167
+ );
168
+ const tokenIssuerPublicKey = await resolveTokenIssuerPublicKey(resolved.tokenIssuer);
169
+ const verifiedToken = verifySelloJwsToken({
170
+ authorizationToken,
171
+ issuerPublicKey: tokenIssuerPublicKey,
172
+ });
173
+ const selloLogs = selectSelloLogs(
174
+ verifiedToken.selloLogs,
175
+ resolved.fallbackSelloLogs,
176
+ resolved.log.logUrl,
177
+ );
178
+ const base = {
179
+ authorizationTokenBytes: verifiedToken.authorizationTokenBytes,
180
+ ownerHpkePublicKey: verifiedToken.ownerHpkePublicKey,
181
+ selloLogs,
182
+ serviceKid: resolved.serviceKid,
183
+ servicePrivateKey: resolved.servicePrivateKey,
184
+ serviceIdentifier: resolved.service,
185
+ logUrl: resolved.log.logUrl,
186
+ actionType,
187
+ actionInputBytes: (options.canonicalizeInput ?? canonicalJsonBytes)(request),
188
+ timestamp: resolved.now(),
189
+ };
190
+
191
+ if (options.isDenied && (await options.isDenied(request))) {
192
+ const response = options.deniedResponse
193
+ ? await options.deniedResponse(request)
194
+ : undefined;
195
+ const receipt = emitReceipt({
196
+ ...base,
197
+ actionOutputBytes: new Uint8Array(),
198
+ resultStatus: "denied",
138
199
  });
139
- const selloLogs = selectSelloLogs(
140
- verifiedToken.selloLogs,
141
- resolved.fallbackSelloLogs,
142
- resolved.log.logUrl,
143
- );
144
- const base = {
145
- authorizationTokenBytes: verifiedToken.authorizationTokenBytes,
146
- ownerHpkePublicKey: verifiedToken.ownerHpkePublicKey,
147
- selloLogs,
148
- serviceKid: resolved.serviceKid,
149
- servicePrivateKey: resolved.servicePrivateKey,
150
- serviceIdentifier: resolved.service,
151
- logUrl: resolved.log.logUrl,
152
- actionType,
153
- actionInputBytes: (options.canonicalizeInput ?? canonicalJsonBytes)(request),
154
- timestamp: resolved.now(),
155
- };
156
-
157
- if (options.isDenied && (await options.isDenied(request))) {
158
- const response = options.deniedResponse
159
- ? await options.deniedResponse(request)
160
- : undefined;
161
- const receipt = emitReceipt({
162
- ...base,
163
- actionOutputBytes: new Uint8Array(),
164
- resultStatus: "denied",
165
- });
166
- resolved.onReceipt?.({ resultStatus: "denied", receipt, response });
167
- await submit(resolved, receipt, base.timestamp);
168
-
169
- if (options.deniedResponse) {
170
- return response as Response;
171
- }
172
-
173
- throw new SelloDeniedError(receipt);
174
- }
200
+ resolved.onReceipt?.({ resultStatus: "denied", receipt, response });
201
+ await submit(resolved, receipt, base.timestamp);
175
202
 
176
- let response: Response;
177
- try {
178
- response = await handler(request);
179
- } catch (error) {
180
- const receipt = emitReceipt({
181
- ...base,
182
- actionOutputBytes: (options.canonicalizeError ?? canonicalErrorBytes)(
183
- error,
184
- ),
185
- resultStatus: "error",
186
- });
187
- resolved.onReceipt?.({ resultStatus: "error", receipt, error });
188
- await submit(resolved, receipt, base.timestamp);
189
- throw error;
203
+ if (options.deniedResponse) {
204
+ return response as Response;
190
205
  }
191
206
 
207
+ throw new SelloDeniedError(receipt);
208
+ }
209
+
210
+ let response: Response;
211
+ try {
212
+ response = await handler(request);
213
+ } catch (error) {
192
214
  const receipt = emitReceipt({
193
215
  ...base,
194
- actionOutputBytes: (options.canonicalizeOutput ?? canonicalJsonBytes)(
195
- response,
216
+ actionOutputBytes: (options.canonicalizeError ?? canonicalErrorBytes)(
217
+ error,
196
218
  ),
197
- resultStatus: "success",
219
+ resultStatus: "error",
198
220
  });
199
- resolved.onReceipt?.({ resultStatus: "success", receipt, response });
221
+ resolved.onReceipt?.({ resultStatus: "error", receipt, error });
200
222
  await submit(resolved, receipt, base.timestamp);
201
- return response;
223
+ throw error;
224
+ }
225
+
226
+ const receipt = emitReceipt({
227
+ ...base,
228
+ actionOutputBytes: (options.canonicalizeOutput ?? canonicalJsonBytes)(
229
+ response,
230
+ ),
231
+ resultStatus: "success",
232
+ });
233
+ resolved.onReceipt?.({ resultStatus: "success", receipt, response });
234
+ await submit(resolved, receipt, base.timestamp);
235
+ return response;
236
+ };
237
+ }
238
+
239
+ return {
240
+ tool<Request, Response>(
241
+ actionType: string,
242
+ handler: SelloToolHandler<Request, Response>,
243
+ options: SelloToolOptions<Request, Response> = {},
244
+ ): SelloToolHandler<Request, Response> {
245
+ return wrapTool(actionType, handler, options);
246
+ },
247
+ mcpTool<Args, Response, Context = unknown>(
248
+ name: string,
249
+ handler: SelloMcpToolHandler<Args, Response, Context>,
250
+ options: SelloMcpToolOptions<Args, Response, Context> = {},
251
+ ): SelloMcpToolHandler<Args, Response, Context> {
252
+ if (typeof name !== "string" || name.length === 0) {
253
+ throw new TypeError("MCP tool name must be a non-empty string");
254
+ }
255
+
256
+ const wrapped = wrapTool<SelloMcpToolInvocation<Args, Context>, Response>(
257
+ options.actionType ?? `mcp.tools/call.${name}`,
258
+ async (invocation) => handler(invocation.arguments, invocation.context),
259
+ {
260
+ authorizationToken:
261
+ options.authorizationToken ?? defaultMcpAuthorizationToken,
262
+ canonicalizeInput:
263
+ options.canonicalizeInput ?? defaultMcpCanonicalizeInput,
264
+ canonicalizeOutput: options.canonicalizeOutput,
265
+ canonicalizeError: options.canonicalizeError,
266
+ isDenied: options.isDenied,
267
+ deniedResponse: options.deniedResponse,
268
+ },
269
+ );
270
+
271
+ return async (args: Args, context: Context): Promise<Response> => {
272
+ return await wrapped({ name, arguments: args, context });
202
273
  };
203
274
  },
204
275
  async flush(): Promise<void> {
@@ -482,23 +553,113 @@ function selectSelloLogs(
482
553
  }
483
554
 
484
555
  function defaultAuthorizationToken(request: unknown): string | Uint8Array {
556
+ const token = authorizationTokenFromUnknown(request);
557
+ if (token) {
558
+ return token;
559
+ }
560
+
561
+ throw new TypeError(
562
+ "Sello authorization token not found. Pass authorizationToken or include request.authorizationToken.",
563
+ );
564
+ }
565
+
566
+ function defaultMcpAuthorizationToken(
567
+ invocation: SelloMcpToolInvocation<unknown, unknown>,
568
+ ): string | Uint8Array {
569
+ const fromContext = authorizationTokenFromUnknown(invocation.context);
570
+ if (fromContext) {
571
+ return fromContext;
572
+ }
573
+
574
+ throw new TypeError(
575
+ "Sello MCP authorization token not found. Pass authorizationToken or include an Authorization header in the MCP context.",
576
+ );
577
+ }
578
+
579
+ function defaultMcpCanonicalizeInput(
580
+ invocation: SelloMcpToolInvocation<unknown, unknown>,
581
+ ): Uint8Array {
582
+ return canonicalJsonBytes({
583
+ method: "tools/call",
584
+ params: {
585
+ name: invocation.name,
586
+ arguments: invocation.arguments,
587
+ },
588
+ });
589
+ }
590
+
591
+ function authorizationTokenFromUnknown(value: unknown): string | Uint8Array | undefined {
592
+ if (!isRecord(value)) {
593
+ return undefined;
594
+ }
595
+
596
+ const direct = value.authorizationToken ?? value.authorization;
597
+ if (typeof direct === "string" || direct instanceof Uint8Array) {
598
+ return stripBearer(direct);
599
+ }
600
+
601
+ const headers = value.headers;
602
+ const fromHeaders = authorizationTokenFromHeaders(headers);
603
+ if (fromHeaders) {
604
+ return fromHeaders;
605
+ }
606
+
607
+ const requestInfo = value.requestInfo;
608
+ if (isRecord(requestInfo)) {
609
+ const fromRequestInfo = authorizationTokenFromHeaders(requestInfo.headers);
610
+ if (fromRequestInfo) {
611
+ return fromRequestInfo;
612
+ }
613
+ }
614
+
615
+ const request = value.request;
485
616
  if (isRecord(request)) {
486
- const direct = request.authorizationToken ?? request.authorization;
487
- if (typeof direct === "string" || direct instanceof Uint8Array) {
488
- return stripBearer(direct);
617
+ const fromRequest = authorizationTokenFromHeaders(request.headers);
618
+ if (fromRequest) {
619
+ return fromRequest;
489
620
  }
621
+ }
490
622
 
491
- const headers = request.headers;
492
- if (isRecord(headers)) {
493
- const header = headers.authorization ?? headers.Authorization;
494
- if (typeof header === "string") {
495
- return stripBearer(header);
496
- }
623
+ const authInfo = value.authInfo;
624
+ if (isRecord(authInfo)) {
625
+ const token = authInfo.token ?? authInfo.accessToken ?? authInfo.access_token;
626
+ if (typeof token === "string" || token instanceof Uint8Array) {
627
+ return stripBearer(token);
497
628
  }
498
629
  }
499
630
 
500
- throw new TypeError(
501
- "Sello authorization token not found. Pass authorizationToken or include request.authorizationToken.",
631
+ return undefined;
632
+ }
633
+
634
+ function authorizationTokenFromHeaders(
635
+ headers: unknown,
636
+ ): string | Uint8Array | undefined {
637
+ if (isHeadersLike(headers)) {
638
+ const header = headers.get("authorization") ?? headers.get("Authorization");
639
+ if (typeof header === "string") {
640
+ return stripBearer(header);
641
+ }
642
+ }
643
+
644
+ if (!isRecord(headers)) {
645
+ return undefined;
646
+ }
647
+
648
+ const header = headers.authorization ?? headers.Authorization;
649
+ if (typeof header === "string" || header instanceof Uint8Array) {
650
+ return stripBearer(header);
651
+ }
652
+
653
+ return undefined;
654
+ }
655
+
656
+ function isHeadersLike(
657
+ value: unknown,
658
+ ): value is { get(name: string): string | null } {
659
+ return (
660
+ typeof value === "object" &&
661
+ value !== null &&
662
+ typeof (value as { get?: unknown }).get === "function"
502
663
  );
503
664
  }
504
665