mcpose 2.0.2 → 3.0.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.
Files changed (100) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +494 -37
  3. package/dist/backendClient.d.ts +5 -0
  4. package/dist/backendClient.d.ts.map +1 -1
  5. package/dist/backendClient.js +17 -9
  6. package/dist/backendClient.js.map +1 -1
  7. package/dist/core.d.ts +293 -28
  8. package/dist/core.d.ts.map +1 -1
  9. package/dist/core.js +969 -175
  10. package/dist/core.js.map +1 -1
  11. package/dist/delegation.d.ts +89 -0
  12. package/dist/delegation.d.ts.map +1 -0
  13. package/dist/delegation.js +175 -0
  14. package/dist/delegation.js.map +1 -0
  15. package/dist/eventStore.d.ts +12 -0
  16. package/dist/eventStore.d.ts.map +1 -1
  17. package/dist/eventStore.js +43 -5
  18. package/dist/eventStore.js.map +1 -1
  19. package/dist/hiddenTools.d.ts +42 -0
  20. package/dist/hiddenTools.d.ts.map +1 -0
  21. package/dist/hiddenTools.js +53 -0
  22. package/dist/hiddenTools.js.map +1 -0
  23. package/dist/index.d.ts +15 -6
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +7 -3
  26. package/dist/index.js.map +1 -1
  27. package/dist/mesh.d.ts +82 -0
  28. package/dist/mesh.d.ts.map +1 -0
  29. package/dist/mesh.js +141 -0
  30. package/dist/mesh.js.map +1 -0
  31. package/dist/middleware.d.ts +13 -0
  32. package/dist/middleware.d.ts.map +1 -1
  33. package/dist/middleware.js +27 -3
  34. package/dist/middleware.js.map +1 -1
  35. package/dist/proxyContext.d.ts +65 -5
  36. package/dist/proxyContext.d.ts.map +1 -1
  37. package/dist/proxyContext.js +39 -4
  38. package/dist/proxyContext.js.map +1 -1
  39. package/dist/rejection.d.ts +1 -1
  40. package/dist/rejection.d.ts.map +1 -1
  41. package/dist/rejection.js +1 -1
  42. package/dist/rejection.js.map +1 -1
  43. package/dist/sanitizeCatalog.d.ts +37 -0
  44. package/dist/sanitizeCatalog.d.ts.map +1 -0
  45. package/dist/sanitizeCatalog.js +86 -0
  46. package/dist/sanitizeCatalog.js.map +1 -0
  47. package/dist/sessionRegistry.d.ts +79 -0
  48. package/dist/sessionRegistry.d.ts.map +1 -0
  49. package/dist/sessionRegistry.js +83 -0
  50. package/dist/sessionRegistry.js.map +1 -0
  51. package/dist/telemetry.d.ts +29 -1
  52. package/dist/telemetry.d.ts.map +1 -1
  53. package/dist/testing.d.ts +18 -2
  54. package/dist/testing.d.ts.map +1 -1
  55. package/dist/testing.js +30 -8
  56. package/dist/testing.js.map +1 -1
  57. package/dist/version.d.ts +7 -0
  58. package/dist/version.d.ts.map +1 -0
  59. package/dist/version.js +7 -0
  60. package/dist/version.js.map +1 -0
  61. package/package.json +30 -14
  62. package/src/__tests__/_helpers.ts +64 -0
  63. package/src/__tests__/backendClient.test.ts +198 -0
  64. package/src/__tests__/core.delegation.test.ts +964 -0
  65. package/src/__tests__/core.edge.test.ts +314 -0
  66. package/src/__tests__/core.hiddenTools.predicate.test.ts +236 -0
  67. package/src/__tests__/core.localTools.test.ts +314 -0
  68. package/src/__tests__/core.mesh.test.ts +1182 -0
  69. package/src/__tests__/core.stripRequestMeta.test.ts +226 -0
  70. package/src/__tests__/core.stripResultMeta.test.ts +217 -0
  71. package/src/__tests__/core.test.ts +1019 -0
  72. package/src/__tests__/eventStore.test.ts +141 -0
  73. package/src/__tests__/mapToolResult.test.ts +101 -0
  74. package/src/__tests__/middleware.edge.test.ts +126 -0
  75. package/src/__tests__/middleware.test.ts +233 -0
  76. package/src/__tests__/proxyContext.test.ts +140 -0
  77. package/src/__tests__/sanitizeCatalog.test.ts +359 -0
  78. package/src/__tests__/startHttpProxy.lifecycle.test.ts +638 -0
  79. package/src/__tests__/startHttpProxy.registry.test.ts +409 -0
  80. package/src/__tests__/startHttpProxy.replay.test.ts +116 -0
  81. package/src/__tests__/startHttpProxy.security.test.ts +288 -0
  82. package/src/__tests__/startHttpProxy.test.ts +985 -0
  83. package/src/__tests__/testing.test.ts +250 -0
  84. package/src/__tests__/version.test.ts +12 -0
  85. package/src/backendClient.ts +83 -0
  86. package/src/core.ts +2042 -0
  87. package/src/delegation.ts +224 -0
  88. package/src/eventStore.ts +106 -0
  89. package/src/hiddenTools.ts +84 -0
  90. package/src/identity.ts +15 -0
  91. package/src/index.ts +63 -0
  92. package/src/mesh.ts +204 -0
  93. package/src/middleware.ts +101 -0
  94. package/src/proxyContext.ts +108 -0
  95. package/src/rejection.ts +37 -0
  96. package/src/sanitizeCatalog.ts +120 -0
  97. package/src/sessionRegistry.ts +142 -0
  98. package/src/telemetry.ts +46 -0
  99. package/src/testing.ts +174 -0
  100. package/src/version.ts +6 -0
@@ -0,0 +1,288 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import * as http from 'node:http';
3
+ import { startHttpProxy } from '../core.js';
4
+ import { makeMockBackend, getPort, closeServer } from './_helpers.js';
5
+
6
+ // ── Helpers ─────────────────────────────────────────────────────────────────
7
+
8
+ const INIT_BODY = JSON.stringify({
9
+ jsonrpc: '2.0',
10
+ id: 1,
11
+ method: 'initialize',
12
+ params: {
13
+ protocolVersion: '2024-11-05',
14
+ capabilities: {},
15
+ clientInfo: { name: 'test', version: '0.0.1' },
16
+ },
17
+ });
18
+
19
+ /**
20
+ * Initialize POST via raw `http.request`, which (unlike fetch) allows
21
+ * forging the `Host` header — the DNS-rebinding attack this suite verifies.
22
+ */
23
+ function rawInitialize(
24
+ port: number,
25
+ headers: Record<string, string> = {},
26
+ ): Promise<number> {
27
+ return new Promise<number>((resolve, reject) => {
28
+ const req = http.request(
29
+ {
30
+ host: '127.0.0.1',
31
+ port,
32
+ path: '/mcp',
33
+ method: 'POST',
34
+ agent: false,
35
+ headers: {
36
+ 'content-type': 'application/json',
37
+ accept: 'application/json, text/event-stream',
38
+ ...headers,
39
+ },
40
+ },
41
+ (res) => {
42
+ res.resume();
43
+ resolve(res.statusCode ?? 0);
44
+ },
45
+ );
46
+ req.on('error', reject);
47
+ req.end(INIT_BODY);
48
+ });
49
+ }
50
+
51
+ // ── The default bind ────────────────────────────────────────────────────────
52
+
53
+ describe('startHttpProxy() — binds loopback by default', () => {
54
+ it('listens on 127.0.0.1 when host is omitted', async () => {
55
+ const server = await startHttpProxy(
56
+ makeMockBackend(),
57
+ { name: 'test-server' },
58
+ { port: 0 },
59
+ );
60
+ try {
61
+ const addr = server.address();
62
+ expect(addr).not.toBeNull();
63
+ expect(typeof addr).toBe('object');
64
+ expect((addr as { address: string }).address).toBe('127.0.0.1');
65
+ } finally {
66
+ await closeServer(server);
67
+ }
68
+ });
69
+
70
+ it('binds a non-loopback address only when asked to', async () => {
71
+ const server = await startHttpProxy(
72
+ makeMockBackend(),
73
+ { name: 'test-server' },
74
+ { port: 0, host: '0.0.0.0', onError: () => {} },
75
+ );
76
+ try {
77
+ expect((server.address() as { address: string }).address).toBe('0.0.0.0');
78
+ } finally {
79
+ await closeServer(server);
80
+ }
81
+ });
82
+ });
83
+
84
+ // ── DNS-rebinding protection defaults ───────────────────────────────────────
85
+
86
+ describe('startHttpProxy() — DNS-rebinding protection defaults to enforcing on loopback', () => {
87
+ it('rejects a forged Host header with 403 by default', async () => {
88
+ const server = await startHttpProxy(
89
+ makeMockBackend(),
90
+ { name: 'test-server' },
91
+ { port: 0 },
92
+ );
93
+ try {
94
+ const status = await rawInitialize(getPort(server), {
95
+ host: 'evil.example:1234',
96
+ });
97
+ expect(status).toBe(403);
98
+ } finally {
99
+ await closeServer(server);
100
+ }
101
+ });
102
+
103
+ it('accepts the real loopback Host and port by default', async () => {
104
+ const server = await startHttpProxy(
105
+ makeMockBackend(),
106
+ { name: 'test-server' },
107
+ { port: 0 },
108
+ );
109
+ try {
110
+ const port = getPort(server);
111
+ expect(await rawInitialize(port, { host: `127.0.0.1:${port}` })).toBe(
112
+ 200,
113
+ );
114
+ expect(await rawInitialize(port, { host: `localhost:${port}` })).toBe(
115
+ 200,
116
+ );
117
+ } finally {
118
+ await closeServer(server);
119
+ }
120
+ });
121
+
122
+ it('rejects a cross-site Origin with 403 and accepts the loopback Origin', async () => {
123
+ const server = await startHttpProxy(
124
+ makeMockBackend(),
125
+ { name: 'test-server' },
126
+ { port: 0 },
127
+ );
128
+ try {
129
+ const port = getPort(server);
130
+ expect(
131
+ await rawInitialize(port, {
132
+ host: `127.0.0.1:${port}`,
133
+ origin: 'http://evil.example',
134
+ }),
135
+ ).toBe(403);
136
+ expect(
137
+ await rawInitialize(port, {
138
+ host: `127.0.0.1:${port}`,
139
+ origin: `http://127.0.0.1:${port}`,
140
+ }),
141
+ ).toBe(200);
142
+ } finally {
143
+ await closeServer(server);
144
+ }
145
+ });
146
+
147
+ it('can be disabled explicitly, restoring the pre-3.0.0 behavior', async () => {
148
+ const server = await startHttpProxy(
149
+ makeMockBackend(),
150
+ { name: 'test-server' },
151
+ { port: 0, enableDnsRebindingProtection: false },
152
+ );
153
+ try {
154
+ const status = await rawInitialize(getPort(server), {
155
+ host: 'evil.example:1234',
156
+ });
157
+ expect(status).toBe(200);
158
+ } finally {
159
+ await closeServer(server);
160
+ }
161
+ });
162
+
163
+ it('defaults to off for a non-loopback bind', async () => {
164
+ const server = await startHttpProxy(
165
+ makeMockBackend(),
166
+ { name: 'test-server' },
167
+ { port: 0, host: '0.0.0.0', onError: () => {} },
168
+ );
169
+ try {
170
+ const status = await rawInitialize(getPort(server), {
171
+ host: 'evil.example:1234',
172
+ });
173
+ expect(status).toBe(200);
174
+ } finally {
175
+ await closeServer(server);
176
+ }
177
+ });
178
+ });
179
+
180
+ // ── Explicit allowlists are verbatim ────────────────────────────────────────
181
+
182
+ describe('startHttpProxy() — explicit allowlists are used verbatim, never merged', () => {
183
+ it('an explicit allowedHosts replaces the derived loopback list', async () => {
184
+ const server = await startHttpProxy(
185
+ makeMockBackend(),
186
+ { name: 'test-server' },
187
+ { port: 0, allowedHosts: ['proxy.internal:8080'] },
188
+ );
189
+ try {
190
+ const port = getPort(server);
191
+ expect(await rawInitialize(port, { host: `127.0.0.1:${port}` })).toBe(
192
+ 403,
193
+ );
194
+ expect(await rawInitialize(port, { host: 'proxy.internal:8080' })).toBe(
195
+ 200,
196
+ );
197
+ } finally {
198
+ await closeServer(server);
199
+ }
200
+ });
201
+ });
202
+
203
+ // ── The non-loopback warning ────────────────────────────────────────────────
204
+
205
+ describe('startHttpProxy() — non-loopback bind without resolveIdentity warns once via onError', () => {
206
+ it('reports a startup warning for host 0.0.0.0 without resolveIdentity', async () => {
207
+ const onError = vi.fn();
208
+ const server = await startHttpProxy(
209
+ makeMockBackend(),
210
+ { name: 'test-server' },
211
+ { port: 0, host: '0.0.0.0', onError },
212
+ );
213
+ try {
214
+ expect(onError).toHaveBeenCalledOnce();
215
+ expect(String(onError.mock.calls[0]?.[0])).toContain('resolveIdentity');
216
+ } finally {
217
+ await closeServer(server);
218
+ }
219
+ });
220
+
221
+ it('stays quiet when resolveIdentity is configured', async () => {
222
+ const onError = vi.fn();
223
+ const server = await startHttpProxy(
224
+ makeMockBackend(),
225
+ { name: 'test-server' },
226
+ {
227
+ port: 0,
228
+ host: '0.0.0.0',
229
+ onError,
230
+ resolveIdentity: () => ({
231
+ sub: 'svc',
232
+ type: 'service',
233
+ roles: [],
234
+ claims: {},
235
+ resolvedAt: new Date().toISOString(),
236
+ source: 'custom',
237
+ }),
238
+ },
239
+ );
240
+ try {
241
+ expect(onError).not.toHaveBeenCalled();
242
+ } finally {
243
+ await closeServer(server);
244
+ }
245
+ });
246
+
247
+ it('warns when protection is explicitly enabled on a non-loopback bind with no allowlists (inert flag)', async () => {
248
+ const onError = vi.fn();
249
+ const server = await startHttpProxy(
250
+ makeMockBackend(),
251
+ { name: 'test-server' },
252
+ {
253
+ port: 0,
254
+ host: '0.0.0.0',
255
+ enableDnsRebindingProtection: true,
256
+ onError,
257
+ resolveIdentity: () => ({
258
+ sub: 'svc',
259
+ type: 'service',
260
+ roles: [],
261
+ claims: {},
262
+ resolvedAt: new Date().toISOString(),
263
+ source: 'custom',
264
+ }),
265
+ },
266
+ );
267
+ try {
268
+ expect(onError).toHaveBeenCalledOnce();
269
+ expect(String(onError.mock.calls[0]?.[0])).toContain('allowedHosts');
270
+ } finally {
271
+ await closeServer(server);
272
+ }
273
+ });
274
+
275
+ it('stays quiet for the default loopback bind', async () => {
276
+ const onError = vi.fn();
277
+ const server = await startHttpProxy(
278
+ makeMockBackend(),
279
+ { name: 'test-server' },
280
+ { port: 0, onError },
281
+ );
282
+ try {
283
+ expect(onError).not.toHaveBeenCalled();
284
+ } finally {
285
+ await closeServer(server);
286
+ }
287
+ });
288
+ });