sello 0.1.12 → 0.1.14
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 +36 -2
- package/dist/sdk/service.js +246 -85
- package/docs/sdk-quickstart.md +37 -0
- package/docs/sdk-security-audit.md +1 -0
- package/package.json +1 -1
- package/sdks/typescript/examples/mcp-minimal-server.ts +9 -17
- package/sdks/typescript/examples/mcp-tool-server.ts +13 -14
- package/sdks/typescript/src/sdk/service.ts +246 -85
package/README.md
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
<p align="center">
|
|
17
17
|
<a href="#try-it">Quickstart</a> ·
|
|
18
18
|
<a href="#add-sello-to-a-tool">Add Sello</a> ·
|
|
19
|
+
<a href="#add-sello-to-an-mcp-server">MCP</a> ·
|
|
19
20
|
<a href="sdks/README.md">SDKs</a> ·
|
|
20
21
|
<a href="#see-logged-actions">Actions</a> ·
|
|
21
22
|
<a href="#how-it-works">How It Works</a> ·
|
|
@@ -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,10 +189,10 @@ 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
|
|
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
|
-
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/). Live Rekor proof verification and production identity operations are still future work.
|
|
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.
|
|
162
196
|
|
|
163
197
|
## Core Terms
|
|
164
198
|
|
package/dist/sdk/service.js
CHANGED
|
@@ -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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
)
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
139
|
-
|
|
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
|
-
|
|
176
|
-
|
|
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.
|
|
194
|
-
|
|
215
|
+
actionOutputBytes: (options.canonicalizeError ?? canonicalErrorBytes)(
|
|
216
|
+
error,
|
|
195
217
|
),
|
|
196
|
-
resultStatus: "
|
|
218
|
+
resultStatus: "error",
|
|
197
219
|
});
|
|
198
|
-
resolved.onReceipt?.({ resultStatus: "
|
|
220
|
+
resolved.onReceipt?.({ resultStatus: "error", receipt, error });
|
|
199
221
|
await submit(resolved, receipt, base.timestamp);
|
|
200
|
-
|
|
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
|
|
486
|
-
if (
|
|
487
|
-
return
|
|
616
|
+
const fromRequest = authorizationTokenFromHeaders(request.headers);
|
|
617
|
+
if (fromRequest) {
|
|
618
|
+
return fromRequest;
|
|
488
619
|
}
|
|
620
|
+
}
|
|
489
621
|
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
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
|
-
|
|
500
|
-
|
|
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
|
|
package/docs/sdk-quickstart.md
CHANGED
|
@@ -97,6 +97,43 @@ 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
|
+
|
|
129
|
+
Or run the matching Python example:
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
python -m pip install ./sdks/python
|
|
133
|
+
python sdks/python/examples/quickstart_tool.py
|
|
134
|
+
npx --yes sello actions
|
|
135
|
+
```
|
|
136
|
+
|
|
100
137
|
For an MCP-shaped `tools/call` boundary, run:
|
|
101
138
|
|
|
102
139
|
```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,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.
|
|
39
|
-
"
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
(
|
|
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.
|
|
92
|
-
|
|
93
|
-
|
|
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(
|
|
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
|
}
|
|
@@ -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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
140
|
-
|
|
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
|
-
|
|
177
|
-
|
|
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.
|
|
195
|
-
|
|
216
|
+
actionOutputBytes: (options.canonicalizeError ?? canonicalErrorBytes)(
|
|
217
|
+
error,
|
|
196
218
|
),
|
|
197
|
-
resultStatus: "
|
|
219
|
+
resultStatus: "error",
|
|
198
220
|
});
|
|
199
|
-
resolved.onReceipt?.({ resultStatus: "
|
|
221
|
+
resolved.onReceipt?.({ resultStatus: "error", receipt, error });
|
|
200
222
|
await submit(resolved, receipt, base.timestamp);
|
|
201
|
-
|
|
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
|
|
487
|
-
if (
|
|
488
|
-
return
|
|
617
|
+
const fromRequest = authorizationTokenFromHeaders(request.headers);
|
|
618
|
+
if (fromRequest) {
|
|
619
|
+
return fromRequest;
|
|
489
620
|
}
|
|
621
|
+
}
|
|
490
622
|
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
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
|
-
|
|
501
|
-
|
|
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
|
|