@mhingston5/conduit 1.1.5 → 1.1.7
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 +29 -1
- package/dist/index.js +413 -127
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/auth.cmd.ts +26 -14
- package/src/core/config.service.ts +27 -2
- package/src/core/execution.service.ts +5 -0
- package/src/core/policy.service.ts +5 -0
- package/src/core/request.controller.ts +32 -7
- package/src/gateway/auth.service.ts +55 -13
- package/src/gateway/gateway.service.ts +150 -65
- package/src/gateway/host.client.ts +65 -0
- package/src/gateway/upstream.client.ts +94 -26
- package/src/index.ts +13 -4
- package/src/sdk/sdk-generator.ts +66 -30
- package/src/transport/stdio.transport.ts +44 -3
- package/tests/__snapshots__/assets.test.ts.snap +45 -14
- package/tests/auth.service.test.ts +57 -0
- package/tests/code-mode-lite-gateway.test.ts +4 -4
- package/tests/config.service.test.ts +29 -1
- package/tests/gateway.service.test.ts +5 -5
- package/tests/routing.test.ts +7 -0
- package/tests/sdk/sdk-generator.test.ts +7 -7
- package/tests/upstream.transports.test.ts +117 -0
|
@@ -70,4 +70,61 @@ describe('AuthService', () => {
|
|
|
70
70
|
expect(headers2['Authorization']).toBe('Bearer cached-access');
|
|
71
71
|
expect(axios.post).toHaveBeenCalledTimes(1); // Still 1, not 2
|
|
72
72
|
});
|
|
73
|
+
|
|
74
|
+
it('should send JSON token refresh for Atlassian token endpoint', async () => {
|
|
75
|
+
const creds: any = {
|
|
76
|
+
type: 'oauth2',
|
|
77
|
+
clientId: 'id',
|
|
78
|
+
clientSecret: 'secret',
|
|
79
|
+
tokenUrl: 'https://auth.atlassian.com/oauth/token',
|
|
80
|
+
refreshToken: 'refresh',
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
(axios.post as any).mockResolvedValue({
|
|
84
|
+
data: {
|
|
85
|
+
access_token: 'new-access',
|
|
86
|
+
expires_in: 0,
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
await authService.getAuthHeaders(creds);
|
|
91
|
+
|
|
92
|
+
const [, body, config] = (axios.post as any).mock.calls[0];
|
|
93
|
+
expect(body).toMatchObject({
|
|
94
|
+
grant_type: 'refresh_token',
|
|
95
|
+
refresh_token: 'refresh',
|
|
96
|
+
client_id: 'id',
|
|
97
|
+
client_secret: 'secret',
|
|
98
|
+
});
|
|
99
|
+
expect(config.headers['Content-Type']).toBe('application/json');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('should include tokenParams and cache rotating refresh tokens', async () => {
|
|
103
|
+
const creds: any = {
|
|
104
|
+
type: 'oauth2',
|
|
105
|
+
clientId: 'id',
|
|
106
|
+
clientSecret: 'secret',
|
|
107
|
+
tokenUrl: 'https://auth.atlassian.com/oauth/token',
|
|
108
|
+
refreshToken: 'r1',
|
|
109
|
+
tokenRequestFormat: 'json',
|
|
110
|
+
tokenParams: { audience: 'api.atlassian.com' },
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
(axios.post as any)
|
|
114
|
+
.mockResolvedValueOnce({
|
|
115
|
+
data: { access_token: 'a1', expires_in: 0, refresh_token: 'r2' },
|
|
116
|
+
})
|
|
117
|
+
.mockResolvedValueOnce({
|
|
118
|
+
data: { access_token: 'a2', expires_in: 0 },
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
await authService.getAuthHeaders(creds);
|
|
122
|
+
await authService.getAuthHeaders(creds);
|
|
123
|
+
|
|
124
|
+
const firstBody = (axios.post as any).mock.calls[0][1];
|
|
125
|
+
expect(firstBody).toMatchObject({ refresh_token: 'r1', audience: 'api.atlassian.com' });
|
|
126
|
+
|
|
127
|
+
const secondBody = (axios.post as any).mock.calls[1][1];
|
|
128
|
+
expect(secondBody).toMatchObject({ refresh_token: 'r2', audience: 'api.atlassian.com' });
|
|
129
|
+
});
|
|
73
130
|
});
|
|
@@ -36,11 +36,11 @@ describe('GatewayService (Code Mode Lite)', () => {
|
|
|
36
36
|
});
|
|
37
37
|
|
|
38
38
|
describe('listToolPackages', () => {
|
|
39
|
-
it('should return registered tool packages', async () => {
|
|
39
|
+
it('should return registered tool packages including built-ins', async () => {
|
|
40
40
|
const packages = await gateway.listToolPackages();
|
|
41
|
-
expect(packages).toHaveLength(
|
|
42
|
-
expect(packages
|
|
43
|
-
expect(packages
|
|
41
|
+
expect(packages).toHaveLength(2); // conduit + mock-upstream
|
|
42
|
+
expect(packages.find(p => p.id === 'conduit')).toBeDefined();
|
|
43
|
+
expect(packages.find(p => p.id === 'mock-upstream')).toBeDefined();
|
|
44
44
|
});
|
|
45
45
|
});
|
|
46
46
|
|
|
@@ -81,6 +81,9 @@ upstreams:
|
|
|
81
81
|
clientSecret: my-secret
|
|
82
82
|
tokenUrl: http://token
|
|
83
83
|
refreshToken: my-refresh
|
|
84
|
+
tokenRequestFormat: json
|
|
85
|
+
tokenParams:
|
|
86
|
+
audience: api.atlassian.com
|
|
84
87
|
`);
|
|
85
88
|
|
|
86
89
|
vi.stubEnv('CONFIG_FILE', 'conduit.test.yaml');
|
|
@@ -92,10 +95,35 @@ upstreams:
|
|
|
92
95
|
clientId: 'my-id',
|
|
93
96
|
clientSecret: 'my-secret',
|
|
94
97
|
tokenUrl: 'http://token',
|
|
95
|
-
refreshToken: 'my-refresh'
|
|
98
|
+
refreshToken: 'my-refresh',
|
|
99
|
+
tokenRequestFormat: 'json',
|
|
100
|
+
tokenParams: { audience: 'api.atlassian.com' },
|
|
96
101
|
});
|
|
97
102
|
|
|
98
103
|
existsSpy.mockRestore();
|
|
99
104
|
readSpy.mockRestore();
|
|
100
105
|
});
|
|
106
|
+
|
|
107
|
+
it('should parse streamableHttp upstream correctly', () => {
|
|
108
|
+
const existsSpy = vi.spyOn(fs, 'existsSync').mockImplementation((p: any) => p.endsWith('conduit.test.yaml'));
|
|
109
|
+
const readSpy = vi.spyOn(fs, 'readFileSync').mockReturnValue(`
|
|
110
|
+
upstreams:
|
|
111
|
+
- id: atlassian
|
|
112
|
+
type: streamableHttp
|
|
113
|
+
url: https://mcp.atlassian.com/v1/sse
|
|
114
|
+
credentials:
|
|
115
|
+
type: bearer
|
|
116
|
+
bearerToken: test-token
|
|
117
|
+
`);
|
|
118
|
+
|
|
119
|
+
vi.stubEnv('CONFIG_FILE', 'conduit.test.yaml');
|
|
120
|
+
const configService = new ConfigService();
|
|
121
|
+
const upstreams = configService.get('upstreams');
|
|
122
|
+
expect(upstreams).toHaveLength(1);
|
|
123
|
+
expect(upstreams![0].type).toBe('streamableHttp');
|
|
124
|
+
expect((upstreams![0] as any).url).toBe('https://mcp.atlassian.com/v1/sse');
|
|
125
|
+
|
|
126
|
+
existsSpy.mockRestore();
|
|
127
|
+
readSpy.mockRestore();
|
|
128
|
+
});
|
|
101
129
|
});
|
|
@@ -32,17 +32,17 @@ describe('GatewayService', () => {
|
|
|
32
32
|
|
|
33
33
|
const tools = await gateway.discoverTools(context);
|
|
34
34
|
expect(tools.length).toBeGreaterThanOrEqual(3);
|
|
35
|
-
expect(tools.find(t => t.name === '
|
|
36
|
-
expect(tools.find(t => t.name === '
|
|
37
|
-
expect(tools.find(t => t.name === '
|
|
35
|
+
expect(tools.find(t => t.name === 'conduit__mcp_execute_typescript')).toBeDefined();
|
|
36
|
+
expect(tools.find(t => t.name === 'conduit__mcp_execute_python')).toBeDefined();
|
|
37
|
+
expect(tools.find(t => t.name === 'conduit__mcp_execute_isolate')).toBeDefined();
|
|
38
38
|
expect(tools.find(t => t.name === 'u1__t1')).toBeDefined();
|
|
39
39
|
expect(tools.find(t => t.name === 'u2__t2')).toBeDefined();
|
|
40
40
|
});
|
|
41
41
|
|
|
42
42
|
it('should return schema for built-in tools', async () => {
|
|
43
|
-
const schema = await gateway.getToolSchema('
|
|
43
|
+
const schema = await gateway.getToolSchema('conduit__mcp_execute_typescript', context);
|
|
44
44
|
expect(schema).toBeDefined();
|
|
45
|
-
expect(schema?.name).toBe('
|
|
45
|
+
expect(schema?.name).toBe('conduit__mcp_execute_typescript');
|
|
46
46
|
expect(schema?.inputSchema.required).toContain('code');
|
|
47
47
|
});
|
|
48
48
|
|
package/tests/routing.test.ts
CHANGED
|
@@ -21,6 +21,13 @@ describe('RequestController Routing', () => {
|
|
|
21
21
|
mockGatewayService = {
|
|
22
22
|
callTool: vi.fn(),
|
|
23
23
|
discoverTools: vi.fn().mockResolvedValue([]),
|
|
24
|
+
policyService: {
|
|
25
|
+
parseToolName: (name: string) => {
|
|
26
|
+
const idx = name.indexOf('__');
|
|
27
|
+
if (idx === -1) return { namespace: '', name };
|
|
28
|
+
return { namespace: name.substring(0, idx), name: name.substring(idx + 2) };
|
|
29
|
+
}
|
|
30
|
+
}
|
|
24
31
|
};
|
|
25
32
|
mockDenoExecutor = {
|
|
26
33
|
execute: vi.fn().mockResolvedValue({ stdout: 'deno', stderr: '', exitCode: 0 }),
|
|
@@ -43,7 +43,7 @@ describe('SDKGenerator', () => {
|
|
|
43
43
|
|
|
44
44
|
const code = generator.generateTypeScript(bindings, undefined, false);
|
|
45
45
|
|
|
46
|
-
expect(code).not.toContain('$raw');
|
|
46
|
+
expect(code).not.toContain('async $raw(name, args)');
|
|
47
47
|
});
|
|
48
48
|
|
|
49
49
|
it('should include JSDoc comments from descriptions', () => {
|
|
@@ -74,10 +74,10 @@ describe('SDKGenerator', () => {
|
|
|
74
74
|
const code = generator.generatePython(bindings);
|
|
75
75
|
|
|
76
76
|
expect(code).toContain('class _Tools:');
|
|
77
|
-
expect(code).toContain('self.github =
|
|
78
|
-
expect(code).toContain('
|
|
79
|
-
expect(code).toContain('self.slack =
|
|
80
|
-
expect(code).toContain('
|
|
77
|
+
expect(code).toContain('self.github = _github_Namespace');
|
|
78
|
+
expect(code).toContain('async def create_issue(self, args=None, **kwargs)'); // accepts dict or kwargs
|
|
79
|
+
expect(code).toContain('self.slack = _slack_Namespace');
|
|
80
|
+
expect(code).toContain('async def send_message(self, args=None, **kwargs)'); // accepts dict or kwargs
|
|
81
81
|
expect(code).toContain('tools = _Tools()');
|
|
82
82
|
});
|
|
83
83
|
|
|
@@ -88,8 +88,8 @@ describe('SDKGenerator', () => {
|
|
|
88
88
|
|
|
89
89
|
const code = generator.generatePython(bindings);
|
|
90
90
|
|
|
91
|
-
expect(code).toContain('async def raw(self, name, args)');
|
|
92
|
-
expect(code).toContain('await _internal_call_tool(normalized, args)');
|
|
91
|
+
expect(code).toContain('async def raw(self, name, args=None)');
|
|
92
|
+
expect(code).toContain('await _internal_call_tool(normalized, args or {})');
|
|
93
93
|
});
|
|
94
94
|
|
|
95
95
|
it('should inject allowlist when provided', () => {
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
|
|
3
|
+
const mcpClientMocks = {
|
|
4
|
+
connect: vi.fn(async () => undefined),
|
|
5
|
+
listTools: vi.fn(async () => ({ tools: [{ name: 'hello', description: 'hi', inputSchema: {} }] })),
|
|
6
|
+
callTool: vi.fn(async () => ({ content: [{ type: 'text', text: 'ok' }] })),
|
|
7
|
+
request: vi.fn(async () => ({ ok: true })),
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const transportMocks = {
|
|
11
|
+
streamableHttpCtor: vi.fn(),
|
|
12
|
+
sseCtor: vi.fn(),
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
vi.mock('@modelcontextprotocol/sdk/client/index.js', () => {
|
|
16
|
+
return {
|
|
17
|
+
Client: class {
|
|
18
|
+
connect = mcpClientMocks.connect;
|
|
19
|
+
listTools = mcpClientMocks.listTools;
|
|
20
|
+
callTool = mcpClientMocks.callTool;
|
|
21
|
+
request = mcpClientMocks.request;
|
|
22
|
+
constructor() {}
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
vi.mock('@modelcontextprotocol/sdk/client/streamableHttp.js', () => {
|
|
28
|
+
return {
|
|
29
|
+
StreamableHTTPClientTransport: class {
|
|
30
|
+
url: URL;
|
|
31
|
+
opts: any;
|
|
32
|
+
constructor(url: URL, opts: any) {
|
|
33
|
+
this.url = url;
|
|
34
|
+
this.opts = opts;
|
|
35
|
+
transportMocks.streamableHttpCtor(url, opts);
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
vi.mock('@modelcontextprotocol/sdk/client/sse.js', () => {
|
|
42
|
+
return {
|
|
43
|
+
SSEClientTransport: class {
|
|
44
|
+
url: URL;
|
|
45
|
+
opts: any;
|
|
46
|
+
constructor(url: URL, opts: any) {
|
|
47
|
+
this.url = url;
|
|
48
|
+
this.opts = opts;
|
|
49
|
+
transportMocks.sseCtor(url, opts);
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// Not used directly but imported by UpstreamClient
|
|
56
|
+
vi.mock('@modelcontextprotocol/sdk/client/stdio.js', () => {
|
|
57
|
+
return {
|
|
58
|
+
StdioClientTransport: class {},
|
|
59
|
+
};
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe('UpstreamClient (remote transports)', () => {
|
|
63
|
+
beforeEach(() => {
|
|
64
|
+
vi.clearAllMocks();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('uses Streamable HTTP client transport for type=streamableHttp', async () => {
|
|
68
|
+
const { UpstreamClient } = await import('../src/gateway/upstream.client.js');
|
|
69
|
+
|
|
70
|
+
const logger: any = { child: () => logger, debug: vi.fn(), info: vi.fn(), error: vi.fn() };
|
|
71
|
+
const authService: any = { getAuthHeaders: vi.fn(async () => ({ Authorization: 'Bearer t' })) };
|
|
72
|
+
const urlValidator: any = { validateUrl: vi.fn(async () => ({ valid: true })) };
|
|
73
|
+
|
|
74
|
+
const client = new UpstreamClient(
|
|
75
|
+
logger,
|
|
76
|
+
{ id: 'atl', type: 'streamableHttp', url: 'https://mcp.atlassian.com/v1/sse' } as any,
|
|
77
|
+
authService,
|
|
78
|
+
urlValidator
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
const res = await client.call({ jsonrpc: '2.0', id: '1', method: 'tools/list' } as any, { correlationId: 'c1' } as any);
|
|
82
|
+
|
|
83
|
+
expect(transportMocks.streamableHttpCtor).toHaveBeenCalled();
|
|
84
|
+
expect(urlValidator.validateUrl).toHaveBeenCalled();
|
|
85
|
+
expect(mcpClientMocks.connect).toHaveBeenCalled();
|
|
86
|
+
expect(mcpClientMocks.listTools).toHaveBeenCalled();
|
|
87
|
+
expect(res.result).toBeDefined();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('lazily creates SSE transport for type=sse and attaches auth headers', async () => {
|
|
91
|
+
const { UpstreamClient } = await import('../src/gateway/upstream.client.js');
|
|
92
|
+
|
|
93
|
+
const logger: any = { child: () => logger, debug: vi.fn(), info: vi.fn(), error: vi.fn() };
|
|
94
|
+
const authService: any = { getAuthHeaders: vi.fn(async () => ({ Authorization: 'Bearer t' })) };
|
|
95
|
+
const urlValidator: any = { validateUrl: vi.fn(async () => ({ valid: true })) };
|
|
96
|
+
|
|
97
|
+
const client = new UpstreamClient(
|
|
98
|
+
logger,
|
|
99
|
+
{
|
|
100
|
+
id: 'atl',
|
|
101
|
+
type: 'sse',
|
|
102
|
+
url: 'https://mcp.atlassian.com/v1/sse',
|
|
103
|
+
credentials: { type: 'bearer', bearerToken: 't' },
|
|
104
|
+
} as any,
|
|
105
|
+
authService,
|
|
106
|
+
urlValidator
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
await client.call({ jsonrpc: '2.0', id: '1', method: 'tools/list' } as any, { correlationId: 'c1' } as any);
|
|
110
|
+
|
|
111
|
+
expect(authService.getAuthHeaders).toHaveBeenCalled();
|
|
112
|
+
expect(transportMocks.sseCtor).toHaveBeenCalled();
|
|
113
|
+
const [, opts] = transportMocks.sseCtor.mock.calls[0];
|
|
114
|
+
expect(opts.requestInit.headers).toMatchObject({ Authorization: 'Bearer t' });
|
|
115
|
+
expect(mcpClientMocks.connect).toHaveBeenCalled();
|
|
116
|
+
});
|
|
117
|
+
});
|