mcp-server-bitbucket 0.11.0 → 0.19.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.
- package/Dockerfile +9 -1
- package/dist/index.d.ts +16 -0
- package/dist/index.js +906 -139
- package/package.json +14 -12
- package/scripts/check-ca-bundles.cjs +27 -0
- package/smithery.yaml +10 -4
- package/src/auth.ts +330 -0
- package/src/client.ts +253 -123
- package/src/http-input.ts +36 -0
- package/src/index.ts +274 -13
- package/src/oauth-proxy.ts +298 -0
- package/src/prompts.ts +1 -1
- package/src/settings.ts +51 -10
- package/src/tools/commits.ts +65 -33
- package/src/tools/index.ts +0 -3
- package/src/tools/pipelines.ts +41 -0
- package/src/tools/source.ts +23 -0
- package/src/tools/tags.ts +21 -6
- package/src/types.ts +9 -0
- package/tests/auth.test.ts +277 -0
- package/tests/history.test.ts +284 -0
- package/tests/http-handler.test.ts +67 -0
- package/tests/http-input.test.ts +40 -0
- package/tests/network-resilience.test.ts +113 -0
- package/tests/oauth-proxy.test.ts +286 -0
- package/tests/settings.test.ts +110 -0
- package/tests/stdio.test.ts +20 -0
- package/tests/test-remote.mjs +22 -0
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the OAuth bearer protection of the HTTP transport.
|
|
3
|
+
*
|
|
4
|
+
* Tokens are signed with a locally generated key pair and verified against that
|
|
5
|
+
* same key, so nothing here touches the network or a real authorization server.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, it, expect, beforeAll, beforeEach } from 'vitest';
|
|
9
|
+
import { SignJWT, exportJWK, generateKeyPair, createLocalJWKSet, type JWTVerifyGetKey } from 'jose';
|
|
10
|
+
import {
|
|
11
|
+
authorizationServerMetadata,
|
|
12
|
+
defaultJwksUri,
|
|
13
|
+
getAuthConfig,
|
|
14
|
+
METADATA_PATHS,
|
|
15
|
+
metadataUrl,
|
|
16
|
+
protectedResourceMetadata,
|
|
17
|
+
resetAuthorizationServerMetadataCache,
|
|
18
|
+
verifyBearer,
|
|
19
|
+
wwwAuthenticate,
|
|
20
|
+
type AuthConfig,
|
|
21
|
+
} from '../src/auth.js';
|
|
22
|
+
|
|
23
|
+
const ISSUER = 'https://login.microsoftonline.com/2f340cb1-3fa1-4e6e-b876-98a0f3732499/v2.0';
|
|
24
|
+
const AUDIENCE = 'api://bitbucket-mcp';
|
|
25
|
+
const RESOURCE = 'https://bitbucket-mcp-server.example.com';
|
|
26
|
+
|
|
27
|
+
let privateKey: CryptoKey;
|
|
28
|
+
let jwks: JWTVerifyGetKey;
|
|
29
|
+
|
|
30
|
+
beforeAll(async () => {
|
|
31
|
+
const pair = await generateKeyPair('RS256', { extractable: true });
|
|
32
|
+
privateKey = pair.privateKey;
|
|
33
|
+
const publicJwk = await exportJWK(pair.publicKey);
|
|
34
|
+
publicJwk.kid = 'test-key';
|
|
35
|
+
publicJwk.alg = 'RS256';
|
|
36
|
+
jwks = createLocalJWKSet({ keys: [publicJwk] });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
function config(overrides: Partial<AuthConfig> = {}): AuthConfig {
|
|
40
|
+
return {
|
|
41
|
+
issuer: ISSUER,
|
|
42
|
+
audience: AUDIENCE,
|
|
43
|
+
resourceUrl: `${RESOURCE}/mcp`,
|
|
44
|
+
resourceIdentifier: `${RESOURCE}/mcp`,
|
|
45
|
+
proxyAuthorizationServerMetadata: true,
|
|
46
|
+
jwks,
|
|
47
|
+
...overrides,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function token(claims: Record<string, unknown> = {}, options: { expired?: boolean } = {}) {
|
|
52
|
+
const now = Math.floor(Date.now() / 1000);
|
|
53
|
+
return new SignJWT({ ...claims })
|
|
54
|
+
.setProtectedHeader({ alg: 'RS256', kid: 'test-key' })
|
|
55
|
+
.setIssuer((claims.iss as string) ?? ISSUER)
|
|
56
|
+
.setAudience((claims.aud as string) ?? AUDIENCE)
|
|
57
|
+
.setSubject((claims.sub as string) ?? 'user-123')
|
|
58
|
+
.setIssuedAt(options.expired ? now - 7200 : now)
|
|
59
|
+
.setExpirationTime(options.expired ? now - 3600 : now + 3600)
|
|
60
|
+
.sign(privateKey);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
describe('configuration', () => {
|
|
64
|
+
it('is disabled unless both issuer and audience are set', () => {
|
|
65
|
+
expect(getAuthConfig({}, jwks)).toBeNull();
|
|
66
|
+
expect(getAuthConfig({ MCP_OAUTH_ISSUER: ISSUER }, jwks)).toBeNull();
|
|
67
|
+
expect(getAuthConfig({ MCP_OAUTH_AUDIENCE: AUDIENCE }, jwks)).toBeNull();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('is enabled when both are set', () => {
|
|
71
|
+
const cfg = getAuthConfig(
|
|
72
|
+
{ MCP_OAUTH_ISSUER: ISSUER, MCP_OAUTH_AUDIENCE: AUDIENCE, MCP_PUBLIC_URL: `${RESOURCE}/` },
|
|
73
|
+
jwks
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
expect(cfg).not.toBeNull();
|
|
77
|
+
expect(cfg!.issuer).toBe(ISSUER);
|
|
78
|
+
// trailing slash trimmed, and the resource is the MCP endpoint itself
|
|
79
|
+
expect(cfg!.resourceUrl).toBe(`${RESOURCE}/mcp`);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('derives the Entra keys endpoint from the issuer', () => {
|
|
83
|
+
expect(defaultJwksUri(ISSUER)).toBe(
|
|
84
|
+
'https://login.microsoftonline.com/2f340cb1-3fa1-4e6e-b876-98a0f3732499/discovery/v2.0/keys'
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe('metadata', () => {
|
|
90
|
+
it('advertises the issuer as the authorization server', () => {
|
|
91
|
+
const meta = protectedResourceMetadata(config({ requiredScopes: ['mcp.access'] }));
|
|
92
|
+
|
|
93
|
+
expect(meta).toMatchObject({
|
|
94
|
+
// we serve the RFC 8414 document ourselves, so we are the advertised AS
|
|
95
|
+
resource: `${RESOURCE}/mcp`,
|
|
96
|
+
authorization_servers: [RESOURCE],
|
|
97
|
+
scopes_supported: ['mcp.access'],
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('advertises an overridden resource identifier when the URL cannot be used', () => {
|
|
102
|
+
// Entra rejects the canonical URL with AADSTS9010010 unless it matches the
|
|
103
|
+
// scopes, so against Entra the resource is the API's Application ID URI
|
|
104
|
+
const meta = protectedResourceMetadata(config({ resourceIdentifier: 'api://bitbucket-mcp' }));
|
|
105
|
+
|
|
106
|
+
expect(meta).toMatchObject({ resource: 'api://bitbucket-mcp' });
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('points at the issuer directly when not proxying its metadata', () => {
|
|
110
|
+
const meta = protectedResourceMetadata(config({ proxyAuthorizationServerMetadata: false }));
|
|
111
|
+
|
|
112
|
+
expect(meta).toMatchObject({ authorization_servers: [ISSUER] });
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('advertises the scopes clients must request when they differ from the validated ones', () => {
|
|
116
|
+
// Entra validates `scp: mcp.access` but the client has to ask for the full URI
|
|
117
|
+
const meta = protectedResourceMetadata(
|
|
118
|
+
config({ requiredScopes: ['mcp.access'], advertisedScopes: ['api://bitbucket-mcp/mcp.access'] })
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
expect(meta).toMatchObject({ scopes_supported: ['api://bitbucket-mcp/mcp.access'] });
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('accepts a token carrying any one of several accepted scopes', async () => {
|
|
125
|
+
const cfg = config({ requiredScopes: ['mcp.access', 'mcp.invoke'] });
|
|
126
|
+
|
|
127
|
+
expect((await verifyBearer(`Bearer ${await token({ scp: 'mcp.access' })}`, cfg)).ok).toBe(true);
|
|
128
|
+
expect((await verifyBearer(`Bearer ${await token({ roles: ['mcp.invoke'] })}`, cfg)).ok).toBe(true);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('points the WWW-Authenticate header at the metadata document', () => {
|
|
132
|
+
expect(wwwAuthenticate(config())).toContain(
|
|
133
|
+
`resource_metadata="${RESOURCE}/.well-known/oauth-protected-resource/mcp"`
|
|
134
|
+
);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('derives the metadata URL from the resource, without duplicating /mcp', () => {
|
|
138
|
+
expect(metadataUrl(config())).toBe(`${RESOURCE}/.well-known/oauth-protected-resource/mcp`);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('serves the metadata at both the path-aware and the bare well-known location', () => {
|
|
142
|
+
// RFC 9728 derives the path from the resource; some clients only try the bare one
|
|
143
|
+
expect(METADATA_PATHS).toEqual([
|
|
144
|
+
'/.well-known/oauth-protected-resource/mcp',
|
|
145
|
+
'/.well-known/oauth-protected-resource',
|
|
146
|
+
]);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe('token verification', () => {
|
|
151
|
+
it('accepts a valid token', async () => {
|
|
152
|
+
const result = await verifyBearer(`Bearer ${await token({ preferred_username: 'javier@simplekyc.com' })}`, config());
|
|
153
|
+
|
|
154
|
+
expect(result.ok).toBe(true);
|
|
155
|
+
if (result.ok) {
|
|
156
|
+
expect(result.subject).toBe('user-123');
|
|
157
|
+
expect(result.caller).toBe('javier@simplekyc.com');
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('rejects a missing header with 401', async () => {
|
|
162
|
+
const result = await verifyBearer(undefined, config());
|
|
163
|
+
|
|
164
|
+
expect(result).toMatchObject({ ok: false, status: 401, error: 'invalid_token' });
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('rejects a non-Bearer scheme', async () => {
|
|
168
|
+
const result = await verifyBearer('Basic dXNlcjpwYXNz', config());
|
|
169
|
+
|
|
170
|
+
expect(result).toMatchObject({ ok: false, status: 401 });
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('rejects a token for another audience', async () => {
|
|
174
|
+
const result = await verifyBearer(`Bearer ${await token({ aud: 'api://something-else' })}`, config());
|
|
175
|
+
|
|
176
|
+
expect(result).toMatchObject({ ok: false, status: 401, error: 'invalid_token' });
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('rejects a token from another issuer', async () => {
|
|
180
|
+
const result = await verifyBearer(`Bearer ${await token({ iss: 'https://evil.example.com' })}`, config());
|
|
181
|
+
|
|
182
|
+
expect(result).toMatchObject({ ok: false, status: 401 });
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('rejects an expired token', async () => {
|
|
186
|
+
const result = await verifyBearer(`Bearer ${await token({}, { expired: true })}`, config());
|
|
187
|
+
|
|
188
|
+
expect(result).toMatchObject({ ok: false, status: 401 });
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('rejects a tampered signature', async () => {
|
|
192
|
+
const valid = await token();
|
|
193
|
+
const tampered = `${valid.slice(0, -4)}AAAA`;
|
|
194
|
+
|
|
195
|
+
const result = await verifyBearer(`Bearer ${tampered}`, config());
|
|
196
|
+
|
|
197
|
+
expect(result).toMatchObject({ ok: false, status: 401 });
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
describe('scopes', () => {
|
|
202
|
+
it('accepts a delegated token carrying the required scope in scp', async () => {
|
|
203
|
+
const result = await verifyBearer(
|
|
204
|
+
`Bearer ${await token({ scp: 'mcp.access other.scope' })}`,
|
|
205
|
+
config({ requiredScopes: ['mcp.access'] })
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
expect(result.ok).toBe(true);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('accepts an application token carrying it in roles', async () => {
|
|
212
|
+
const result = await verifyBearer(
|
|
213
|
+
`Bearer ${await token({ roles: ['mcp.access'] })}`,
|
|
214
|
+
config({ requiredScopes: ['mcp.access'] })
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
expect(result.ok).toBe(true);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('answers 403 insufficient_scope when the scope is missing', async () => {
|
|
221
|
+
const result = await verifyBearer(
|
|
222
|
+
`Bearer ${await token({ scp: 'other.scope' })}`,
|
|
223
|
+
config({ requiredScopes: ['mcp.access'] })
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
expect(result).toMatchObject({ ok: false, status: 403, error: 'insufficient_scope' });
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
describe('authorization server metadata', () => {
|
|
232
|
+
// Entra serves no RFC 8414 document, which is where the connector flow died:
|
|
233
|
+
// the client read our resource metadata and then had nowhere to go.
|
|
234
|
+
const openIdConfiguration = {
|
|
235
|
+
issuer: ISSUER,
|
|
236
|
+
authorization_endpoint: 'https://login.microsoftonline.com/tenant/oauth2/v2.0/authorize',
|
|
237
|
+
token_endpoint: 'https://login.microsoftonline.com/tenant/oauth2/v2.0/token',
|
|
238
|
+
response_types_supported: ['code'],
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
beforeEach(() => resetAuthorizationServerMetadataCache());
|
|
242
|
+
|
|
243
|
+
it('republishes the issuer configuration, asserting PKCE support', async () => {
|
|
244
|
+
const fetchImpl = async () =>
|
|
245
|
+
new Response(JSON.stringify(openIdConfiguration), { status: 200 }) as Response;
|
|
246
|
+
|
|
247
|
+
const meta = await authorizationServerMetadata(config(), fetchImpl as typeof fetch);
|
|
248
|
+
|
|
249
|
+
expect(meta).toMatchObject({
|
|
250
|
+
issuer: ISSUER,
|
|
251
|
+
authorization_endpoint: openIdConfiguration.authorization_endpoint,
|
|
252
|
+
token_endpoint: openIdConfiguration.token_endpoint,
|
|
253
|
+
code_challenge_methods_supported: ['S256'],
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it('reads the issuer once and then serves from cache', async () => {
|
|
258
|
+
let calls = 0;
|
|
259
|
+
const fetchImpl = async () => {
|
|
260
|
+
calls += 1;
|
|
261
|
+
return new Response(JSON.stringify(openIdConfiguration), { status: 200 }) as Response;
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
await authorizationServerMetadata(config(), fetchImpl as typeof fetch);
|
|
265
|
+
await authorizationServerMetadata(config(), fetchImpl as typeof fetch);
|
|
266
|
+
|
|
267
|
+
expect(calls).toBe(1);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it('surfaces an unreachable issuer instead of serving a broken document', async () => {
|
|
271
|
+
const fetchImpl = async () => new Response('nope', { status: 500 }) as Response;
|
|
272
|
+
|
|
273
|
+
await expect(authorizationServerMetadata(config(), fetchImpl as typeof fetch)).rejects.toThrow(
|
|
274
|
+
/HTTP 500/
|
|
275
|
+
);
|
|
276
|
+
});
|
|
277
|
+
});
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
/** SPEC-2/3/4: resumable history, Git diff semantics and bounded recovery. */
|
|
2
|
+
import axios, { AxiosError, type AxiosAdapter, type InternalAxiosRequestConfig } from 'axios';
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { BitbucketClient, BitbucketError, resetClient } from '../src/client.js';
|
|
5
|
+
import { resetSettings } from '../src/settings.js';
|
|
6
|
+
import { handlers as commitTools } from '../src/tools/commits.js';
|
|
7
|
+
import { handlers as sourceTools } from '../src/tools/source.js';
|
|
8
|
+
|
|
9
|
+
vi.mock('../src/utils.js', async (importOriginal) => ({
|
|
10
|
+
...await importOriginal<typeof import('../src/utils.js')>(),
|
|
11
|
+
sleep: vi.fn(async () => undefined),
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
const ROOT = 'https://api.bitbucket.org/2.0/repositories/test-workspace/repo';
|
|
15
|
+
type Reply = { status?: number; data?: unknown; headers?: Record<string, string>; code?: string; message?: string };
|
|
16
|
+
let calls: InternalAxiosRequestConfig[];
|
|
17
|
+
let replies: Reply[];
|
|
18
|
+
const adapter: AxiosAdapter = async (config) => {
|
|
19
|
+
calls.push(config);
|
|
20
|
+
const reply = replies.shift() ?? { data: { values: [] } };
|
|
21
|
+
if (reply.code) throw new AxiosError(reply.message ?? 'network failure', reply.code, config);
|
|
22
|
+
const response = { status: reply.status ?? 200, data: reply.data, headers: reply.headers ?? {}, config, statusText: '' };
|
|
23
|
+
if (response.status >= 400) throw new AxiosError('request failed', undefined, config, undefined, response);
|
|
24
|
+
return response;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
vi.stubEnv('BITBUCKET_WORKSPACE', 'test-workspace');
|
|
29
|
+
vi.stubEnv('BITBUCKET_EMAIL', 'test@example.com');
|
|
30
|
+
vi.stubEnv('BITBUCKET_API_TOKEN', 'test-token');
|
|
31
|
+
vi.stubEnv('MAX_RETRIES', '2');
|
|
32
|
+
resetSettings();
|
|
33
|
+
resetClient();
|
|
34
|
+
calls = [];
|
|
35
|
+
replies = [];
|
|
36
|
+
axios.defaults.adapter = adapter;
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllEnvs(); });
|
|
40
|
+
|
|
41
|
+
describe('SPEC-2 resumable history', () => {
|
|
42
|
+
it('reports missing history instead of silently claiming an empty complete result, then remains usable', async () => {
|
|
43
|
+
replies = [{ status: 404 }, { data: { values: [{ hash: 'ok' }] } }, { status: 404 }];
|
|
44
|
+
await expect(commitTools.list_commits({ repo_slug: 'repo' })).rejects.toThrow(/not found.*404/i);
|
|
45
|
+
expect(await commitTools.list_commits({ repo_slug: 'repo' })).toMatchObject({ commits: [{ hash: 'ok' }], has_more: false });
|
|
46
|
+
expect(await new BitbucketClient().listCommits('repo')).toEqual([]);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('reaches old commits and retains cursor page size without dropping entries', async () => {
|
|
50
|
+
const next = ROOT + '/commits?cursor=old&pagelen=2';
|
|
51
|
+
replies = [{ data: { values: [{ hash: 'new' }], next } }, { data: { values: [{ hash: 'old1' }, { hash: 'old2' }] } }];
|
|
52
|
+
const client = new BitbucketClient();
|
|
53
|
+
const first = await client.listCommitsPage('repo', { limit: 2 });
|
|
54
|
+
expect(first).toMatchObject({ has_more: true, next_cursor: next });
|
|
55
|
+
const second = await client.listCommitsPage('repo', { cursor: first.next_cursor!, limit: 1 });
|
|
56
|
+
expect(second.values).toHaveLength(2);
|
|
57
|
+
expect(second.has_more).toBe(false);
|
|
58
|
+
expect(calls[1].url).toBe(next);
|
|
59
|
+
expect(calls[1].params).toBeUndefined();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it.each([
|
|
63
|
+
'https://evil.example/commits', ROOT.replace('https:', 'http:') + '/commits',
|
|
64
|
+
ROOT + '/refs/tags?page=2', ROOT.replace('https://', 'https://user:pass@') + '/commits',
|
|
65
|
+
ROOT + '/commits#fragment', ROOT.replace('api.bitbucket.org', 'api.bitbucket.org:444') + '/commits',
|
|
66
|
+
ROOT + '/other/../commits', ROOT + '/%2e%2e/repo/commits', ROOT + '/commits%2Fother',
|
|
67
|
+
ROOT + '/commits\\other', ROOT.replace('/repo', '/other') + '/commits',
|
|
68
|
+
])('rejects unsafe cursor %s before any request', async cursor => {
|
|
69
|
+
await expect(new BitbucketClient().listCommitsPage('repo', { cursor })).rejects.toThrow(/cursor/i);
|
|
70
|
+
expect(calls).toHaveLength(0);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('follows server next links for existing list clients until the requested limit', async () => {
|
|
74
|
+
replies = [{ data: { values: [{ hash: 'a' }], next: ROOT + '/commits?page=2' } },
|
|
75
|
+
{ data: { values: [{ hash: 'b' }, { hash: 'c' }, { hash: 'd' }] } }];
|
|
76
|
+
expect((await new BitbucketClient().listCommits('repo', { limit: 3 })).map(c => c.hash)).toEqual(['a', 'b', 'c']);
|
|
77
|
+
expect(calls).toHaveLength(2);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('rejects repeated pagination links instead of looping', async () => {
|
|
81
|
+
const next = ROOT + '/commits?page=2';
|
|
82
|
+
replies = [{ data: { values: [], next } }, { data: { values: [], next } }];
|
|
83
|
+
await expect(new BitbucketClient().listCommits('repo')).rejects.toThrow(/pagination/i);
|
|
84
|
+
expect(calls).toHaveLength(2);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe('SPEC-3 history operations', () => {
|
|
89
|
+
it.each(['', '.', '..', 'release/../main'])('rejects invalid refs %s before requesting', ref => {
|
|
90
|
+
expect(() => new BitbucketClient().getTag('repo', ref)).toThrow(/revision/i);
|
|
91
|
+
expect(calls).toHaveLength(0);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('returns file history and tag queries with explicit continuation', async () => {
|
|
95
|
+
const client = new BitbucketClient();
|
|
96
|
+
replies = [{ data: { values: [{ path: 'old.ts', commit: { hash: 'old' } }], next: ROOT + '/filehistory/release%2Fv1/src/a.ts?page=2' } }, { data: { values: [{ name: 'v2' }] } }];
|
|
97
|
+
const history = await client.getFileHistory('repo', 'src/a.ts', 'release/v1');
|
|
98
|
+
expect(history.values[0].path).toBe('old.ts');
|
|
99
|
+
expect(history.has_more).toBe(true);
|
|
100
|
+
expect(calls[0].url).toContain('/filehistory/release%2Fv1/src/a.ts');
|
|
101
|
+
const tags = await client.listTagsPage('repo', { query: 'name ~ "v2"', sort: '-name' });
|
|
102
|
+
expect(tags.values[0].name).toBe('v2');
|
|
103
|
+
expect(calls[1].params).toMatchObject({ q: 'name ~ "v2"', sort: '-name' });
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('preserves full file-history commit hashes without inventing absent metadata', async () => {
|
|
107
|
+
const hash = 'abc123'.repeat(6) + '1234';
|
|
108
|
+
replies = [{ data: { values: [{ path: 'old.ts', commit: { type: 'commit', hash, links: {} } }] } }];
|
|
109
|
+
expect(await sourceTools.get_file_history({ repo_slug: 'repo', path: 'src/a.ts' })).toEqual({
|
|
110
|
+
history: [{ path: 'old.ts', commit: { hash } }], has_more: false, next_cursor: null,
|
|
111
|
+
});
|
|
112
|
+
expect(calls).toHaveLength(1);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('serializes repeated include/exclude filters using Bitbucket syntax', async () => {
|
|
116
|
+
await new BitbucketClient().listCommitsPage('repo', { branch: 'main', include: ['release/v2', 'other'], exclude: ['v1', 'old'], path: 'src/a.ts' });
|
|
117
|
+
const query = new URL(axios.getUri(calls[0])).searchParams;
|
|
118
|
+
expect(query.getAll('include')).toEqual(['main', 'release/v2', 'other']);
|
|
119
|
+
expect(query.getAll('exclude')).toEqual(['v1', 'old']);
|
|
120
|
+
expect(query.has('include[]')).toBe(false);
|
|
121
|
+
expect(query.get('path')).toBe('src/a.ts');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('compares base to head without silently discarding files after 50', async () => {
|
|
125
|
+
replies = [{ data: { hash: 'a'.repeat(40) } }, { data: { hash: 'b'.repeat(40) } },
|
|
126
|
+
{ data: { values: Array.from({ length: 75 }, (_, i) => ({ new: { path: `${i}.ts` }, status: 'added', lines_added: 1 })), next: ROOT + '/diffstat/bbbbbbbbbbbb..aaaaaaaaaaaa?page=2' } }];
|
|
127
|
+
const result = await commitTools.compare_commits({ repo_slug: 'repo', base: 'aaaaaaaaaaaa', head: 'bbbbbbbbbbbb', limit: 100 });
|
|
128
|
+
expect(result.files).toHaveLength(75);
|
|
129
|
+
expect(result.has_more).toBe(true);
|
|
130
|
+
expect(calls[2].url).toContain('/diffstat/bbbbbbbbbbbb..aaaaaaaaaaaa');
|
|
131
|
+
expect(calls[2].params.topic).toBe(false);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it.each([
|
|
135
|
+
{ base: 'main', head: 'release/v2' },
|
|
136
|
+
{ base: 'aaaaaaa', head: 'bbbbbbb' },
|
|
137
|
+
])('resolves named and short refs before following canonical diffstat cursors: %j', async refs => {
|
|
138
|
+
const base = 'aaaaaaaaaaaa' + '1'.repeat(28);
|
|
139
|
+
const head = 'bbbbbbbbbbbb' + '2'.repeat(28);
|
|
140
|
+
const next = ROOT + '/diffstat/bbbbbbbbbbbb..aaaaaaaaaaaa?page=2&pagelen=1&topic=false';
|
|
141
|
+
replies = [{ data: { hash: base } }, { data: { hash: head } },
|
|
142
|
+
{ data: { values: [{ new: { path: 'first.ts' } }], next } },
|
|
143
|
+
{ data: { hash: base } }, { data: { hash: head } }, { data: { values: [{ new: { path: 'second.ts' } }] } }];
|
|
144
|
+
const client = new BitbucketClient();
|
|
145
|
+
const first = await client.compareCommitsPage('repo', refs.base, refs.head, { limit: 1 });
|
|
146
|
+
expect(calls[0].url).toBe('repositories/test-workspace/repo/commit/' + encodeURIComponent(refs.base));
|
|
147
|
+
expect(calls[1].url).toBe('repositories/test-workspace/repo/commit/' + encodeURIComponent(refs.head));
|
|
148
|
+
expect(calls[2].url).toContain('/diffstat/bbbbbbbbbbbb..aaaaaaaaaaaa');
|
|
149
|
+
expect(first.next_cursor).toBe(next);
|
|
150
|
+
const second = await client.compareCommitsPage('repo', refs.base, refs.head, { cursor: first.next_cursor! });
|
|
151
|
+
expect(second.values[0]).toMatchObject({ new: { path: 'second.ts' } });
|
|
152
|
+
expect(calls[5].url).toBe(next);
|
|
153
|
+
expect(second.has_more).toBe(false);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('resolves full hashes exactly before canonicalizing, and still rejects unrelated comparison cursors', async () => {
|
|
157
|
+
const base = 'A'.repeat(40);
|
|
158
|
+
const head = 'B'.repeat(40);
|
|
159
|
+
const next = ROOT + '/diffstat/bbbbbbbbbbbb..aaaaaaaaaaaa?page=2';
|
|
160
|
+
replies = [{ data: { hash: base } }, { data: { hash: head } }, { data: { values: [], next } },
|
|
161
|
+
{ data: { hash: base } }, { data: { hash: head } }, { data: { values: [] } },
|
|
162
|
+
{ data: { hash: base } }, { data: { hash: head } }];
|
|
163
|
+
const client = new BitbucketClient();
|
|
164
|
+
const first = await client.compareCommitsPage('repo', base, head);
|
|
165
|
+
await client.compareCommitsPage('repo', base, head, { cursor: first.next_cursor! });
|
|
166
|
+
expect(calls).toHaveLength(6);
|
|
167
|
+
expect(calls[0].url).toContain('/commit/' + base);
|
|
168
|
+
expect(calls[2].url).toContain('/diffstat/bbbbbbbbbbbb..aaaaaaaaaaaa');
|
|
169
|
+
await expect(client.compareCommitsPage('repo', base, head, { cursor: ROOT + '/diffstat/cccccccccccc..aaaaaaaaaaaa?page=2' })).rejects.toThrow(/cursor/i);
|
|
170
|
+
expect(calls).toHaveLength(8);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('does not turn an invalid full SHA into an existing shorter prefix', async () => {
|
|
174
|
+
const invalid = 'aaaaaaaaaaaa' + '0'.repeat(28);
|
|
175
|
+
replies = [{ status: 404 }];
|
|
176
|
+
await expect(new BitbucketClient().compareCommitsPage('repo', invalid, 'main')).rejects.toThrow(/not found.*404/i);
|
|
177
|
+
expect(calls).toHaveLength(1);
|
|
178
|
+
expect(calls[0].url).toContain('/commit/' + invalid);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('resolves hexadecimal branch names to their actual target commit', async () => {
|
|
182
|
+
replies = [{ data: { hash: 'c'.repeat(40) } }, { data: { hash: 'd'.repeat(40) } }, { data: { values: [] } }];
|
|
183
|
+
await new BitbucketClient().compareCommitsPage('repo', 'aaaaaaaaaaaa', 'bbbbbbbbbbbb');
|
|
184
|
+
expect(calls[0].url).toContain('/commit/aaaaaaaaaaaa');
|
|
185
|
+
expect(calls[1].url).toContain('/commit/bbbbbbbbbbbb');
|
|
186
|
+
expect(calls[2].url).toContain('/diffstat/dddddddddddd..cccccccccccc');
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('rejects a cursor if a named revision moved between comparison pages', async () => {
|
|
190
|
+
replies = [{ data: { hash: 'c'.repeat(40) } }, { data: { hash: 'b'.repeat(40) } }];
|
|
191
|
+
await expect(new BitbucketClient().compareCommitsPage('repo', 'main', 'release', {
|
|
192
|
+
cursor: ROOT + '/diffstat/bbbbbbbbbbbb..aaaaaaaaaaaa?page=2',
|
|
193
|
+
})).rejects.toThrow(/cursor/i);
|
|
194
|
+
expect(calls).toHaveLength(2);
|
|
195
|
+
expect(calls.every(call => call.url?.includes('/commit/'))).toBe(true);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('encodes slash refs, returns literal text diff, and exposes merge base and tag lookup', async () => {
|
|
199
|
+
const client = new BitbucketClient();
|
|
200
|
+
replies = [{ data: '"literal diff"' }, { data: { hash: 'ancestor' } }, { data: { name: 'release/v1' } }];
|
|
201
|
+
expect(await client.getCommitDiff('repo', 'base', 'feature/new', { path: 'a.ts' })).toBe('"literal diff"');
|
|
202
|
+
expect(calls[0].url).toContain('/diff/feature%2Fnew..base');
|
|
203
|
+
expect(calls[0].responseType).toBe('text');
|
|
204
|
+
expect(calls[0].params.topic).toBe(false);
|
|
205
|
+
expect((await client.getMergeBase('repo', 'base', 'head'))?.hash).toBe('ancestor');
|
|
206
|
+
expect((await client.getTag('repo', 'release/v1'))?.name).toBe('release/v1');
|
|
207
|
+
expect(calls[2].url).toContain('/refs/tags/release%2Fv1');
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('exposes clipped diff continuation including a single large file', async () => {
|
|
211
|
+
replies = [{ data: 'abcdef' }, { data: 'abcdef' }];
|
|
212
|
+
const first = await commitTools.get_commit_diff({ repo_slug: 'repo', base: 'a', head: 'b', max_chars: 3 });
|
|
213
|
+
expect(first).toMatchObject({ diff: 'abc', offset: 0, next_offset: 3, truncated: true, total_chars: 6 });
|
|
214
|
+
expect(await commitTools.get_commit_diff({ repo_slug: 'repo', base: 'a', head: 'b', offset: 3, max_chars: 3 }))
|
|
215
|
+
.toMatchObject({ diff: 'def', next_offset: null, truncated: false });
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
describe('SPEC-4 bounded resilient requests', () => {
|
|
220
|
+
it('does not retry generic bad-response failures such as response-size limits', async () => {
|
|
221
|
+
replies = [{ code: 'ERR_BAD_RESPONSE', message: 'maxContentLength size of 100 exceeded' }, { data: {} }];
|
|
222
|
+
await expect(new BitbucketClient().getRepository('repo')).rejects.toThrow(BitbucketError);
|
|
223
|
+
expect(calls).toHaveLength(1);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it.each([{ code: 'ECONNRESET' }, { code: 'ETIMEDOUT' }, { status: 503 }])('recovers reads from %j', async failure => {
|
|
227
|
+
replies = [failure, { data: { name: 'ok' } }];
|
|
228
|
+
expect(await new BitbucketClient().getRepository('repo')).toMatchObject({ name: 'ok' });
|
|
229
|
+
expect(calls).toHaveLength(2);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('does not replay an ambiguous write', async () => {
|
|
233
|
+
replies = [{ code: 'ECONNRESET' }, { data: {} }];
|
|
234
|
+
await expect(new BitbucketClient().createTag('repo', 'v1', 'main')).rejects.toThrow(BitbucketError);
|
|
235
|
+
expect(calls).toHaveLength(1);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('does not replay writes after server errors either', async () => {
|
|
239
|
+
replies = [{ status: 503 }, { data: {} }];
|
|
240
|
+
await expect(new BitbucketClient().createTag('repo', 'v1', 'main')).rejects.toThrow(BitbucketError);
|
|
241
|
+
expect(calls).toHaveLength(1);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('does not retry authorization failures', async () => {
|
|
245
|
+
replies = [{ status: 401 }];
|
|
246
|
+
await expect(new BitbucketClient().getRepository('repo')).rejects.toThrow(/401/);
|
|
247
|
+
expect(calls).toHaveLength(1);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('honors HTTP-date Retry-After', async () => {
|
|
251
|
+
const header = new Date(Date.now() + 3000).toUTCString();
|
|
252
|
+
replies = [{ status: 429, headers: { 'retry-after': header } }, { data: {} }];
|
|
253
|
+
expect(await new BitbucketClient().getRepository('repo')).toEqual({});
|
|
254
|
+
expect(calls).toHaveLength(2);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it('bounds total elapsed retry time and caps each attempt timeout', async () => {
|
|
258
|
+
vi.spyOn(performance, 'now').mockReturnValueOnce(0).mockReturnValueOnce(0).mockReturnValue(31000);
|
|
259
|
+
replies = [{ status: 503 }, { data: {} }];
|
|
260
|
+
await expect(new BitbucketClient().getRepository('repo')).rejects.toThrow(/budget/i);
|
|
261
|
+
expect(calls).toHaveLength(1);
|
|
262
|
+
expect(calls[0].timeout).toBe(30000);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it.each(['bad', '-2', 'nan', 'inf'])('handles malformed Retry-After %s', async header => {
|
|
266
|
+
replies = [{ status: 429, headers: { 'retry-after': header } }, { data: {} }];
|
|
267
|
+
expect(await new BitbucketClient().getRepository('repo')).toEqual({});
|
|
268
|
+
expect(calls).toHaveLength(2);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it('fails fast instead of sleeping beyond the retry budget', async () => {
|
|
272
|
+
replies = [{ status: 429, headers: { 'retry-after': '3600' } }];
|
|
273
|
+
await expect(new BitbucketClient().getRepository('repo')).rejects.toThrow(/budget/i);
|
|
274
|
+
expect(calls).toHaveLength(1);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('stays usable after retry exhaustion', async () => {
|
|
278
|
+
replies = [{ code: 'ECONNRESET' }, { code: 'ECONNRESET' }, { code: 'ECONNRESET' }, { data: { name: 'ok' } }];
|
|
279
|
+
const client = new BitbucketClient();
|
|
280
|
+
await expect(client.getRepository('repo')).rejects.toThrow(/Network/i);
|
|
281
|
+
expect(await client.getRepository('repo')).toMatchObject({ name: 'ok' });
|
|
282
|
+
expect(calls).toHaveLength(4);
|
|
283
|
+
});
|
|
284
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { Readable } from 'node:stream';
|
|
3
|
+
import { EventEmitter } from 'node:events';
|
|
4
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
5
|
+
|
|
6
|
+
const boundary = vi.hoisted(() => ({
|
|
7
|
+
verify: vi.fn(), forward: vi.fn(), register: vi.fn(), reads: 0,
|
|
8
|
+
}));
|
|
9
|
+
vi.mock('../src/auth.js', async (original) => ({
|
|
10
|
+
...await original<typeof import('../src/auth.js')>(),
|
|
11
|
+
getAuthConfig: () => ({ issuer: 'https://issuer.example', audience: 'mcp', resourceUrl: 'https://mcp.example/mcp' }),
|
|
12
|
+
verifyBearer: boundary.verify,
|
|
13
|
+
wwwAuthenticate: () => 'Bearer resource_metadata="https://mcp.example/.well-known/oauth-protected-resource"',
|
|
14
|
+
}));
|
|
15
|
+
vi.mock('../src/oauth-proxy.js', async (original) => ({
|
|
16
|
+
...await original<typeof import('../src/oauth-proxy.js')>(),
|
|
17
|
+
getProxyConfig: () => ({ origin: 'https://mcp.example' }),
|
|
18
|
+
resolveUpstreamEndpoints: async () => ({ token: 'https://issuer.example/token' }),
|
|
19
|
+
forwardTokenRequest: boundary.forward,
|
|
20
|
+
registerClient: boundary.register,
|
|
21
|
+
}));
|
|
22
|
+
import { createHttpHandler } from '../src/index.js';
|
|
23
|
+
|
|
24
|
+
async function request(url: string, body: Buffer[], authenticated = true) {
|
|
25
|
+
boundary.verify.mockResolvedValue(authenticated ? { ok: true } : {
|
|
26
|
+
ok: false, status: 401, error: 'invalid_token', description: 'Missing token',
|
|
27
|
+
});
|
|
28
|
+
const req = Readable.from((async function* () {
|
|
29
|
+
for (const chunk of body) { boundary.reads++; yield chunk; }
|
|
30
|
+
})()) as unknown as IncomingMessage;
|
|
31
|
+
Object.assign(req, { method: 'POST', url, headers: {}, socket: { remoteAddress: 'test' } });
|
|
32
|
+
const res = new EventEmitter() as ServerResponse;
|
|
33
|
+
let response = '';
|
|
34
|
+
Object.assign(res, {
|
|
35
|
+
statusCode: 200, setHeader: vi.fn(),
|
|
36
|
+
writeHead(status: number) { res.statusCode = status; return res; },
|
|
37
|
+
end(value = '') { response = value.toString(); res.emit('finish'); return res; },
|
|
38
|
+
});
|
|
39
|
+
await createHttpHandler()(req, res);
|
|
40
|
+
return { status: res.statusCode, body: response };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
beforeEach(() => { vi.clearAllMocks(); boundary.reads = 0; });
|
|
44
|
+
describe('real HTTP handler without a socket or upstream service', () => {
|
|
45
|
+
it('checks authentication before consuming an oversized MCP body', async () => {
|
|
46
|
+
expect((await request('/mcp', [Buffer.alloc(110 * 1024)], false)).status).toBe(401);
|
|
47
|
+
expect(boundary.reads).toBe(0);
|
|
48
|
+
});
|
|
49
|
+
it('bounds malformed MCP bytes before JSON parsing and SDK dispatch', async () => {
|
|
50
|
+
expect((await request('/mcp', [Buffer.alloc(110 * 1024, '!')])).status).toBe(413);
|
|
51
|
+
expect(boundary.verify).toHaveBeenCalledOnce();
|
|
52
|
+
});
|
|
53
|
+
it('does not forward an oversized OAuth token request', async () => {
|
|
54
|
+
expect((await request('/oauth/token', [Buffer.alloc(110 * 1024)])).status).toBe(413);
|
|
55
|
+
expect(boundary.forward).not.toHaveBeenCalled();
|
|
56
|
+
});
|
|
57
|
+
it('does not parse an oversized OAuth registration request', async () => {
|
|
58
|
+
expect((await request('/oauth/register', [Buffer.alloc(110 * 1024, '!')])).status).toBe(413);
|
|
59
|
+
expect(boundary.register).not.toHaveBeenCalled();
|
|
60
|
+
});
|
|
61
|
+
it('preserves OAuth token form bytes and upstream response shape', async () => {
|
|
62
|
+
boundary.forward.mockResolvedValue({ status: 200, contentType: 'application/json', body: '{"access_token":"synthetic"}' });
|
|
63
|
+
const result = await request('/oauth/token', [Buffer.from('code=a%2Bb&'), Buffer.from('grant_type=authorization_code')]);
|
|
64
|
+
expect(result).toEqual({ status: 200, body: '{"access_token":"synthetic"}' });
|
|
65
|
+
expect(boundary.forward).toHaveBeenCalledWith('code=a%2Bb&grant_type=authorization_code', undefined, 'https://issuer.example/token');
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { Readable } from 'node:stream';
|
|
3
|
+
import { parseMcpBody, readHttpBody } from '../src/http-input.js';
|
|
4
|
+
|
|
5
|
+
describe('HTTP input bounds before OAuth forwarding and SDK validation', () => {
|
|
6
|
+
it('preserves byte-exact OAuth form bodies across chunks', async () => {
|
|
7
|
+
const input = ['grant_type=refresh_token&', 'refresh_token=a%2Bb&scope=mcp'];
|
|
8
|
+
expect((await readHttpBody(Readable.from(input.map(x => Buffer.from(x))))).toString())
|
|
9
|
+
.toBe(input.join(''));
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('stops consuming chunked bodies above 100 KiB', async () => {
|
|
13
|
+
let consumed = 0;
|
|
14
|
+
async function* chunks() {
|
|
15
|
+
for (let i = 0; i < 5; i++) { consumed++; yield Buffer.alloc(40 * 1024); }
|
|
16
|
+
}
|
|
17
|
+
await expect(readHttpBody(chunks())).rejects.toMatchObject({ status: 413 });
|
|
18
|
+
expect(consumed).toBe(3);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('accepts a normal single MCP request without changing its arguments', () => {
|
|
22
|
+
const request = { jsonrpc: '2.0', id: 1, method: 'tools/call', params: {
|
|
23
|
+
name: 'list_repositories', arguments: { limit: 5 },
|
|
24
|
+
} };
|
|
25
|
+
expect(parseMcpBody(Buffer.from(JSON.stringify(request)))).toEqual(request);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('rejects a request with excessive nested tool-input elements before SDK dispatch', () => {
|
|
29
|
+
const request = { jsonrpc: '2.0', id: 1, method: 'tools/call', params: {
|
|
30
|
+
name: 'create_pull_request', arguments: { reviewers: Array.from({ length: 1001 }, () => ({})) },
|
|
31
|
+
} };
|
|
32
|
+
expect(() => parseMcpBody(Buffer.from(JSON.stringify(request))))
|
|
33
|
+
.toThrow(/elements/);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('rejects a JSON-RPC batch before dispatch', () => {
|
|
37
|
+
expect(() => parseMcpBody(Buffer.from('[{"jsonrpc":"2.0","id":1,"method":"ping"}]')))
|
|
38
|
+
.toThrow(/batch/i);
|
|
39
|
+
});
|
|
40
|
+
});
|