@signet-auth/mcp 0.1.0 → 0.2.0

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.
@@ -1 +1,2 @@
1
1
  export { SigningTransport, type SigningTransportOptions, type Transport, type JSONRPCMessage } from './signing-transport.js';
2
+ export type { CompoundReceipt, SignetReceipt } from '@signet-auth/core';
@@ -1,4 +1,4 @@
1
- import { type SignetReceipt } from '@signet-auth/core';
1
+ import { type CompoundReceipt, type SignetReceipt } from '@signet-auth/core';
2
2
  export interface JSONRPCMessage {
3
3
  jsonrpc: '2.0';
4
4
  id?: string | number;
@@ -20,7 +20,9 @@ export interface Transport {
20
20
  export interface SigningTransportOptions {
21
21
  target?: string;
22
22
  transport?: string;
23
- onSign?: (receipt: SignetReceipt) => void;
23
+ responseTimeout?: number;
24
+ onReceipt?: (receipt: CompoundReceipt) => void;
25
+ onDispatch?: (receipt: SignetReceipt) => void;
24
26
  }
25
27
  export declare class SigningTransport implements Transport {
26
28
  private inner;
@@ -28,7 +30,9 @@ export declare class SigningTransport implements Transport {
28
30
  private signerName;
29
31
  private signerOwner;
30
32
  private opts;
33
+ private pendingRequests;
31
34
  constructor(inner: Transport, secretKey: string, signerName: string, signerOwner?: string, options?: SigningTransportOptions);
35
+ private _timeout;
32
36
  onclose?: () => void;
33
37
  onerror?: (error: Error) => void;
34
38
  onmessage?: (message: JSONRPCMessage, extra?: unknown) => void;
@@ -38,6 +42,4 @@ export declare class SigningTransport implements Transport {
38
42
  close(): Promise<void>;
39
43
  send(message: JSONRPCMessage, options?: unknown): Promise<void>;
40
44
  private isToolCall;
41
- private signToolCall;
42
- private injectSignet;
43
45
  }
@@ -1,23 +1,46 @@
1
- import { sign } from '@signet-auth/core';
1
+ import { sign, signCompound } from '@signet-auth/core';
2
2
  export class SigningTransport {
3
3
  inner;
4
4
  secretKey;
5
5
  signerName;
6
6
  signerOwner;
7
7
  opts;
8
+ pendingRequests = new Map();
8
9
  constructor(inner, secretKey, signerName, signerOwner, options) {
9
10
  this.inner = inner;
10
11
  this.secretKey = secretKey;
11
12
  this.signerName = signerName;
12
13
  this.signerOwner = signerOwner ?? '';
13
14
  this.opts = options ?? {};
15
+ const timeout = this.opts.responseTimeout ?? 30000;
14
16
  // Forward callbacks using lazy closures.
15
17
  // MCP SDK's Protocol.connect() sets our callbacks AFTER construction,
16
18
  // so these closures must read this.onclose/etc lazily at call time.
17
19
  this.inner.onclose = () => this.onclose?.();
18
20
  this.inner.onerror = (e) => this.onerror?.(e);
19
- this.inner.onmessage = (msg, extra) => this.onmessage?.(msg, extra);
21
+ this.inner.onmessage = (msg, extra) => {
22
+ // Check if this is a response to a pending tool call
23
+ if (msg.id !== undefined && this.pendingRequests.has(msg.id)) {
24
+ const pending = this.pendingRequests.get(msg.id);
25
+ clearTimeout(pending.timer);
26
+ this.pendingRequests.delete(msg.id);
27
+ const tsResponse = new Date().toISOString();
28
+ const responseContent = 'result' in msg ? msg.result : (msg.error ?? null);
29
+ try {
30
+ const receipt = signCompound(this.secretKey, pending.action, responseContent, this.signerName, this.signerOwner, pending.tsRequest, tsResponse);
31
+ this.opts.onReceipt?.(receipt);
32
+ }
33
+ catch (err) {
34
+ this.onerror?.(err instanceof Error ? err : new Error(String(err)));
35
+ }
36
+ }
37
+ // Always forward message to outer onmessage
38
+ this.onmessage?.(msg, extra);
39
+ };
40
+ // Store timeout for use in send()
41
+ this._timeout = timeout;
20
42
  }
43
+ _timeout;
21
44
  onclose;
22
45
  onerror;
23
46
  onmessage;
@@ -26,38 +49,49 @@ export class SigningTransport {
26
49
  this.inner.setProtocolVersion?.(v);
27
50
  };
28
51
  start() { return this.inner.start(); }
29
- close() { return this.inner.close(); }
52
+ async close() {
53
+ for (const entry of this.pendingRequests.values()) {
54
+ if (entry.timer)
55
+ clearTimeout(entry.timer);
56
+ }
57
+ this.pendingRequests.clear();
58
+ return this.inner.close();
59
+ }
30
60
  async send(message, options) {
31
- if (this.isToolCall(message)) {
32
- const receipt = this.signToolCall(message);
33
- this.injectSignet(message, receipt);
34
- this.opts.onSign?.(receipt);
61
+ if (this.isToolCall(message) && message.id !== undefined) {
62
+ const params = (message.params ?? {});
63
+ const action = {
64
+ tool: params.name ?? 'unknown',
65
+ params: params.arguments ?? {},
66
+ params_hash: '',
67
+ target: this.opts.target ?? 'unknown',
68
+ transport: this.opts.transport ?? 'stdio',
69
+ };
70
+ const tsRequest = new Date().toISOString();
71
+ // Sign v1 dispatch receipt with full params for server verification
72
+ try {
73
+ const dispatchReceipt = sign(this.secretKey, action, this.signerName, this.signerOwner);
74
+ // Deep-clone and inject into _meta._signet
75
+ const clonedParams = JSON.parse(JSON.stringify(message.params ?? {}));
76
+ if (!clonedParams._meta)
77
+ clonedParams._meta = {};
78
+ clonedParams._meta._signet = dispatchReceipt;
79
+ message.params = clonedParams;
80
+ this.opts.onDispatch?.(dispatchReceipt);
81
+ }
82
+ catch (err) {
83
+ this.onerror?.(err instanceof Error ? err : new Error(String(err)));
84
+ return;
85
+ }
86
+ const id = message.id;
87
+ const timer = setTimeout(() => {
88
+ this.pendingRequests.delete(id);
89
+ }, this._timeout);
90
+ this.pendingRequests.set(id, { action, tsRequest, timer });
35
91
  }
36
92
  return this.inner.send(message, options);
37
93
  }
38
94
  isToolCall(message) {
39
95
  return message.method === 'tools/call';
40
96
  }
41
- signToolCall(message) {
42
- const params = (message.params ?? {});
43
- const action = {
44
- tool: params.name ?? 'unknown',
45
- params: params.arguments ?? {},
46
- params_hash: '',
47
- target: this.opts.target ?? 'unknown',
48
- transport: this.opts.transport ?? 'stdio',
49
- };
50
- return sign(this.secretKey, action, this.signerName, this.signerOwner);
51
- }
52
- injectSignet(message, receipt) {
53
- // Deep-clone params to avoid mutating the original object
54
- const params = JSON.parse(JSON.stringify(message.params ?? {}));
55
- if (!params._meta)
56
- params._meta = {};
57
- params._meta._signet = {
58
- ...receipt,
59
- action: { ...receipt.action, params: null },
60
- };
61
- message.params = params;
62
- }
63
97
  }
@@ -1,78 +1,149 @@
1
1
  import { describe, it } from 'node:test';
2
2
  import assert from 'node:assert';
3
- import { generateKeypair } from '@signet-auth/core';
3
+ import { generateKeypair, verifyAny } from '@signet-auth/core';
4
4
  import { SigningTransport } from '../src/index.js';
5
- // Mock transport that records sent messages
5
+ // Mock transport that records sent messages and supports simulating responses
6
6
  class MockTransport {
7
7
  sent = [];
8
+ messageHandler;
8
9
  onclose;
9
10
  onerror;
10
- onmessage;
11
- sessionId;
11
+ // Capture the onmessage handler set by SigningTransport
12
+ set onmessage(handler) {
13
+ this.messageHandler = handler;
14
+ }
15
+ get onmessage() { return this.messageHandler; }
12
16
  async start() { }
13
17
  async close() { }
14
18
  async send(message) {
15
19
  this.sent.push(JSON.parse(JSON.stringify(message)));
16
20
  }
21
+ // Simulate a response from the server
22
+ simulateResponse(id, result) {
23
+ this.messageHandler?.({ jsonrpc: '2.0', id, result }, undefined);
24
+ }
25
+ simulateError(id, error) {
26
+ this.messageHandler?.({ jsonrpc: '2.0', id, error }, undefined);
27
+ }
17
28
  }
18
- describe('@signet/mcp SigningTransport', () => {
29
+ describe('@signet-auth/mcp SigningTransport v2', () => {
19
30
  const kp = generateKeypair();
20
- function createTransport() {
31
+ function createTransport(opts) {
21
32
  const mock = new MockTransport();
22
33
  const signing = new SigningTransport(mock, kp.secretKey, 'test-agent', 'owner', {
23
34
  target: 'mcp://test-server',
24
35
  transport: 'stdio',
36
+ ...opts,
25
37
  });
26
38
  return { mock, signing };
27
39
  }
28
- function toolCallMessage(name, args) {
40
+ function toolCallMessage(id, name, args) {
29
41
  return {
30
42
  jsonrpc: '2.0',
31
- id: 1,
43
+ id,
32
44
  method: 'tools/call',
33
45
  params: { name, arguments: args },
34
46
  };
35
47
  }
36
- it('signs tool call messages', async () => {
37
- const { mock, signing } = createTransport();
38
- await signing.send(toolCallMessage('echo', { message: 'hello' }));
39
- assert.strictEqual(mock.sent.length, 1);
40
- const sent = mock.sent[0];
41
- assert(sent.params._meta._signet, '_signet should be injected');
42
- assert(sent.params._meta._signet.sig.startsWith('ed25519:'));
43
- assert(sent.params._meta._signet.id.startsWith('rec_'));
48
+ it('signs compound receipt on response', async () => {
49
+ let receipt = null;
50
+ const { mock, signing } = createTransport({ onReceipt: (r) => { receipt = r; } });
51
+ await signing.send(toolCallMessage(1, 'echo', { message: 'hello' }));
52
+ mock.simulateResponse(1, { content: [{ type: 'text', text: 'hello' }] });
53
+ assert(receipt !== null, 'onReceipt should have been called');
54
+ const r = receipt;
55
+ assert.strictEqual(r.v, 2);
56
+ assert(r.id.startsWith('rec_'));
57
+ assert(r.sig.startsWith('ed25519:'));
58
+ assert.strictEqual(r.signer.name, 'test-agent');
59
+ assert.strictEqual(r.action.tool, 'echo');
44
60
  });
45
- it('passes through non-tool-call messages', async () => {
46
- const { mock, signing } = createTransport();
47
- const listMsg = { jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} };
61
+ it('compound receipt has response hash', async () => {
62
+ let receipt = null;
63
+ const { mock, signing } = createTransport({ onReceipt: (r) => { receipt = r; } });
64
+ await signing.send(toolCallMessage(2, 'search', { query: 'test' }));
65
+ mock.simulateResponse(2, { content: [{ type: 'text', text: 'results' }] });
66
+ assert(receipt !== null);
67
+ assert(receipt.response.content_hash.startsWith('sha256:'));
68
+ });
69
+ it('produces compound receipt on error response', async () => {
70
+ let receipt = null;
71
+ const { mock, signing } = createTransport({ onReceipt: (r) => { receipt = r; } });
72
+ await signing.send(toolCallMessage(3, 'fail_tool', {}));
73
+ mock.simulateError(3, { code: -32000, message: 'tool failed' });
74
+ assert(receipt !== null, 'onReceipt should fire even on error responses');
75
+ const r = receipt;
76
+ assert.strictEqual(r.v, 2);
77
+ assert(r.response.content_hash.startsWith('sha256:'));
78
+ });
79
+ it('passes through non-tool-call messages unchanged', async () => {
80
+ let receipt = null;
81
+ const { mock, signing } = createTransport({ onReceipt: (r) => { receipt = r; } });
82
+ const listMsg = { jsonrpc: '2.0', id: 4, method: 'tools/list', params: {} };
48
83
  await signing.send(listMsg);
49
84
  assert.strictEqual(mock.sent.length, 1);
50
85
  const sent = mock.sent[0];
51
- assert.strictEqual(sent.params._meta, undefined);
86
+ // No _meta injection in v2
87
+ const params = sent['params'];
88
+ assert.strictEqual(params?._meta, undefined);
89
+ // Simulate a response — should not produce a receipt since it wasn't a tool call
90
+ mock.simulateResponse(4, {});
91
+ assert.strictEqual(receipt, null, 'no receipt for non-tool-call responses');
52
92
  });
53
- it('receipt has correct tool name', async () => {
54
- const { mock, signing } = createTransport();
55
- await signing.send(toolCallMessage('github_create_issue', { title: 'bug' }));
56
- const signet = mock.sent[0].params._meta._signet;
57
- assert.strictEqual(signet.action.tool, 'github_create_issue');
93
+ it('does not fire onReceipt after timeout', async () => {
94
+ let receiptCount = 0;
95
+ const { mock, signing } = createTransport({
96
+ responseTimeout: 50,
97
+ onReceipt: () => { receiptCount++; },
98
+ });
99
+ await signing.send(toolCallMessage(5, 'slow_tool', {}));
100
+ // Wait longer than timeout before simulating response
101
+ await new Promise((resolve) => setTimeout(resolve, 100));
102
+ mock.simulateResponse(5, { content: [] });
103
+ assert.strictEqual(receiptCount, 0, 'no receipt after timeout');
104
+ });
105
+ it('close clears pending requests — no receipt after close', async () => {
106
+ let receiptCount = 0;
107
+ const { mock, signing } = createTransport({
108
+ onReceipt: () => { receiptCount++; },
109
+ });
110
+ await signing.send(toolCallMessage(6, 'some_tool', {}));
111
+ await signing.close();
112
+ mock.simulateResponse(6, { content: [] });
113
+ assert.strictEqual(receiptCount, 0, 'no receipt after close clears pending');
58
114
  });
59
- it('receipt params are null (hash-only)', async () => {
115
+ it('injects _meta._signet dispatch receipt into tool call message', async () => {
60
116
  const { mock, signing } = createTransport();
61
- await signing.send(toolCallMessage('echo', { data: 'secret' }));
62
- const signet = mock.sent[0].params._meta._signet;
63
- assert.strictEqual(signet.action.params, null);
64
- assert(signet.action.params_hash.startsWith('sha256:'));
117
+ await signing.send(toolCallMessage(7, 'echo', { message: 'hello' }));
118
+ assert.strictEqual(mock.sent.length, 1);
119
+ const params = mock.sent[0].params;
120
+ const meta = params._meta;
121
+ assert(meta !== undefined, '_meta should be present');
122
+ const signet = meta._signet;
123
+ assert(signet !== undefined, '_meta._signet should be present');
124
+ assert(signet.sig.startsWith('ed25519:'), 'sig should start with ed25519:');
125
+ assert.strictEqual(signet.v, 1);
65
126
  });
66
- it('onSign callback fires with receipt', async () => {
127
+ it('fires onDispatch callback at send time', async () => {
128
+ let dispatched = null;
67
129
  const mock = new MockTransport();
68
- let callbackReceipt = null;
69
130
  const signing = new SigningTransport(mock, kp.secretKey, 'test-agent', 'owner', {
70
- onSign: (r) => { callbackReceipt = r; },
131
+ target: 'mcp://test-server',
132
+ transport: 'stdio',
133
+ onDispatch: (r) => { dispatched = r; },
71
134
  });
72
- await signing.send(toolCallMessage('test', {}));
73
- assert(callbackReceipt !== null, 'callback should have been called');
74
- const r = callbackReceipt;
75
- assert(r.id.startsWith('rec_'));
76
- assert.strictEqual(r.signer.name, 'test-agent');
135
+ await signing.send(toolCallMessage(8, 'echo', { message: 'hello' }));
136
+ assert(dispatched !== null, 'onDispatch should have been called');
137
+ assert.strictEqual(dispatched.v, 1);
138
+ assert(dispatched.sig.startsWith('ed25519:'));
139
+ });
140
+ it('dispatch receipt is verifiable with correct key', async () => {
141
+ const { mock, signing } = createTransport();
142
+ await signing.send(toolCallMessage(9, 'echo', { message: 'hello' }));
143
+ const params = mock.sent[0].params;
144
+ const meta = params._meta;
145
+ const signet = meta._signet;
146
+ const valid = verifyAny(JSON.stringify(signet), kp.publicKey);
147
+ assert(valid, 'dispatch receipt should verify with the signer public key');
77
148
  });
78
149
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@signet-auth/mcp",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "MCP middleware for Signet cryptographic action receipts",
5
5
  "type": "module",
6
6
  "main": "dist/src/index.js",