@mhingston5/conduit 1.0.0 → 1.1.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/README.md +25 -2
- package/conduit.yaml +6 -0
- package/package.json +1 -1
- package/src/core/config.service.ts +14 -2
- package/src/core/execution.service.ts +3 -3
- package/src/core/interfaces/app.config.ts +1 -0
- package/src/core/interfaces/middleware.interface.ts +2 -2
- package/src/core/logger.ts +8 -7
- package/src/core/middleware/auth.middleware.ts +6 -4
- package/src/core/middleware/error.middleware.ts +1 -1
- package/src/core/middleware/logging.middleware.ts +1 -1
- package/src/core/middleware/ratelimit.middleware.ts +1 -1
- package/src/core/ops.server.ts +1 -1
- package/src/core/request.controller.ts +49 -7
- package/src/core/security.service.ts +7 -2
- package/src/core/types.ts +1 -1
- package/src/executors/deno.executor.ts +1 -1
- package/src/executors/isolate.executor.ts +1 -1
- package/src/executors/pyodide.executor.ts +3 -3
- package/src/index.ts +20 -4
- package/src/sdk/index.ts +2 -1
- package/src/transport/stdio.transport.ts +116 -0
- package/tests/dynamic.tool.test.ts +24 -24
- package/tests/ops.server.test.ts +5 -1
- package/tests/routing.test.ts +2 -2
- package/tests/vscode_e2e.test.ts +91 -0
- package/tsup.config.ts +4 -4
package/README.md
CHANGED
|
@@ -6,17 +6,18 @@
|
|
|
6
6
|
|
|
7
7
|
<div align="center">
|
|
8
8
|
|
|
9
|
-
[](https://www.npmjs.com/package/@
|
|
9
|
+
[](https://www.npmjs.com/package/@mhingston5/conduit)
|
|
10
10
|
[](https://opensource.org/licenses/MIT)
|
|
11
11
|
[](https://www.typescriptlang.org/)
|
|
12
12
|
[](https://nodejs.org/)
|
|
13
13
|
[](https://modelcontextprotocol.io/)
|
|
14
|
+
[](https://modelcontextprotocol.io/server)
|
|
14
15
|
|
|
15
16
|
</div>
|
|
16
17
|
|
|
17
18
|
## What is Conduit?
|
|
18
19
|
|
|
19
|
-
Conduit is a **secure Code Mode execution substrate** for [MCP](https://modelcontextprotocol.io/) agents.
|
|
20
|
+
Conduit is a **secure Code Mode execution substrate** for [MCP](https://modelcontextprotocol.io/) agents. It functions as a **standard MCP server**, allowing native integration with clients like Claude Desktop or VS Code without extra adapters.
|
|
20
21
|
|
|
21
22
|
It lets agents:
|
|
22
23
|
- generate **real TypeScript or Python code**
|
|
@@ -94,6 +95,28 @@ Conduit runs the code, handles the tool call securely, and returns:
|
|
|
94
95
|
}
|
|
95
96
|
```
|
|
96
97
|
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
## Example usage with VS Code
|
|
101
|
+
|
|
102
|
+
```json
|
|
103
|
+
{
|
|
104
|
+
"mcpServers": {
|
|
105
|
+
"conduit": {
|
|
106
|
+
"type": "stdio",
|
|
107
|
+
"command": "npx",
|
|
108
|
+
"args": [
|
|
109
|
+
"-y",
|
|
110
|
+
"@mhingston5/conduit",
|
|
111
|
+
"--stdio"
|
|
112
|
+
],
|
|
113
|
+
"env": {}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
|
|
97
120
|
---
|
|
98
121
|
|
|
99
122
|
## How It Works (High Level)
|
package/conduit.yaml
ADDED
package/package.json
CHANGED
|
@@ -4,7 +4,12 @@ import fs from 'node:fs';
|
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import yaml from 'js-yaml';
|
|
6
6
|
|
|
7
|
+
// Silence dotenv logging
|
|
8
|
+
const originalWrite = process.stdout.write;
|
|
9
|
+
// @ts-ignore
|
|
10
|
+
process.stdout.write = () => true;
|
|
7
11
|
dotenv.config();
|
|
12
|
+
process.stdout.write = originalWrite;
|
|
8
13
|
|
|
9
14
|
import { AppConfig } from './interfaces/app.config.js';
|
|
10
15
|
|
|
@@ -64,6 +69,7 @@ export const ConfigSchema = z.object({
|
|
|
64
69
|
pyodideMaxPoolSize: z.number().default(3),
|
|
65
70
|
metricsUrl: z.string().default('http://127.0.0.1:9464/metrics'),
|
|
66
71
|
opsPort: z.number().optional(),
|
|
72
|
+
transport: z.enum(['socket', 'stdio']).default('socket'),
|
|
67
73
|
upstreams: z.array(UpstreamInfoSchema).default([]),
|
|
68
74
|
});
|
|
69
75
|
|
|
@@ -80,6 +86,8 @@ export class ConfigService {
|
|
|
80
86
|
nodeEnv: process.env.NODE_ENV,
|
|
81
87
|
logLevel: process.env.LOG_LEVEL,
|
|
82
88
|
metricsUrl: process.env.METRICS_URL,
|
|
89
|
+
ipcBearerToken: process.env.IPC_BEARER_TOKEN,
|
|
90
|
+
transport: process.argv.includes('--stdio') ? 'stdio' : undefined,
|
|
83
91
|
// upstreams: process.env.UPSTREAMS ? JSON.parse(process.env.UPSTREAMS) : undefined, // Removed per user request
|
|
84
92
|
};
|
|
85
93
|
|
|
@@ -101,8 +109,12 @@ export class ConfigService {
|
|
|
101
109
|
this.config = result.data as AppConfig;
|
|
102
110
|
|
|
103
111
|
// Default opsPort if not set
|
|
104
|
-
if (
|
|
105
|
-
|
|
112
|
+
if (this.config.opsPort === undefined) {
|
|
113
|
+
if (this.config.transport === 'stdio') {
|
|
114
|
+
this.config.opsPort = 0; // Random port for stdio to avoid conflicts
|
|
115
|
+
} else {
|
|
116
|
+
this.config.opsPort = this.config.port === 0 ? 0 : this.config.port + 1;
|
|
117
|
+
}
|
|
106
118
|
}
|
|
107
119
|
}
|
|
108
120
|
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import { Logger } from 'pino';
|
|
2
2
|
import { ExecutorRegistry } from './registries/executor.registry.js';
|
|
3
|
-
import { ResourceLimits } from './config.service.js';
|
|
3
|
+
import type { ResourceLimits } from './config.service.js';
|
|
4
4
|
import { GatewayService } from '../gateway/gateway.service.js';
|
|
5
5
|
import { SecurityService } from './security.service.js';
|
|
6
6
|
import { SDKGenerator, toToolBinding } from '../sdk/index.js';
|
|
7
7
|
import { ExecutionContext } from './execution.context.js';
|
|
8
8
|
import { ConduitError } from './types.js';
|
|
9
|
-
import { ExecutionResult } from './interfaces/executor.interface.js';
|
|
9
|
+
import type { ExecutionResult } from './interfaces/executor.interface.js';
|
|
10
10
|
|
|
11
|
-
export { ExecutionResult };
|
|
11
|
+
export type { ExecutionResult };
|
|
12
12
|
|
|
13
13
|
export class ExecutionService {
|
|
14
14
|
private logger: Logger;
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { ExecutionContext } from '../execution.context.js';
|
|
2
2
|
import { JSONRPCRequest, JSONRPCResponse } from '../types.js';
|
|
3
3
|
|
|
4
|
-
export type NextFunction = () => Promise<JSONRPCResponse>;
|
|
4
|
+
export type NextFunction = () => Promise<JSONRPCResponse | null>;
|
|
5
5
|
|
|
6
6
|
export interface Middleware {
|
|
7
7
|
handle(
|
|
8
8
|
request: JSONRPCRequest,
|
|
9
9
|
context: ExecutionContext,
|
|
10
10
|
next: NextFunction
|
|
11
|
-
): Promise<JSONRPCResponse>;
|
|
11
|
+
): Promise<JSONRPCResponse | null>;
|
|
12
12
|
}
|
package/src/core/logger.ts
CHANGED
|
@@ -54,11 +54,12 @@ export function createLogger(configService: ConfigService) {
|
|
|
54
54
|
correlationId: store?.correlationId,
|
|
55
55
|
};
|
|
56
56
|
},
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
options: {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
57
|
+
// In stdio mode, never use pino-pretty to avoid stdout pollution
|
|
58
|
+
transport: configService.get('transport') !== 'stdio' && configService.get('nodeEnv') === 'development'
|
|
59
|
+
? { target: 'pino-pretty', options: { colorize: true } }
|
|
60
|
+
: undefined,
|
|
61
|
+
}, configService.get('transport') === 'stdio'
|
|
62
|
+
? pino.destination(2) // Always write to stderr in stdio mode
|
|
63
|
+
: undefined
|
|
64
|
+
);
|
|
64
65
|
}
|
|
@@ -10,11 +10,13 @@ export class AuthMiddleware implements Middleware {
|
|
|
10
10
|
request: JSONRPCRequest,
|
|
11
11
|
context: ExecutionContext,
|
|
12
12
|
next: NextFunction
|
|
13
|
-
): Promise<JSONRPCResponse> {
|
|
13
|
+
): Promise<JSONRPCResponse | null> {
|
|
14
14
|
const providedToken = request.auth?.bearerToken || '';
|
|
15
|
+
const masterToken = this.securityService.getIpcToken();
|
|
15
16
|
|
|
16
|
-
|
|
17
|
-
const
|
|
17
|
+
// If no master token is set (stdio mode), treat all requests as master (auth disabled)
|
|
18
|
+
const isMaster = !masterToken || providedToken === masterToken;
|
|
19
|
+
const isSession = !isMaster && this.securityService.validateIpcToken(providedToken);
|
|
18
20
|
|
|
19
21
|
if (!isMaster && !isSession) {
|
|
20
22
|
return {
|
|
@@ -29,7 +31,7 @@ export class AuthMiddleware implements Middleware {
|
|
|
29
31
|
|
|
30
32
|
// Strict scoping for session tokens
|
|
31
33
|
if (isSession) {
|
|
32
|
-
const allowedMethods = ['mcp.discoverTools', 'mcp.callTool'];
|
|
34
|
+
const allowedMethods = ['initialize', 'notifications/initialized', 'mcp.discoverTools', 'mcp.callTool', 'ping'];
|
|
33
35
|
if (!allowedMethods.includes(request.method)) {
|
|
34
36
|
return {
|
|
35
37
|
jsonrpc: '2.0',
|
|
@@ -3,7 +3,7 @@ import { JSONRPCRequest, JSONRPCResponse, ConduitError } from '../types.js';
|
|
|
3
3
|
import { ExecutionContext } from '../execution.context.js';
|
|
4
4
|
|
|
5
5
|
export class ErrorHandlingMiddleware implements Middleware {
|
|
6
|
-
async handle(request: JSONRPCRequest, context: ExecutionContext, next: NextFunction): Promise<JSONRPCResponse> {
|
|
6
|
+
async handle(request: JSONRPCRequest, context: ExecutionContext, next: NextFunction): Promise<JSONRPCResponse | null> {
|
|
7
7
|
try {
|
|
8
8
|
return await next();
|
|
9
9
|
} catch (err: any) {
|
|
@@ -4,7 +4,7 @@ import { ExecutionContext } from '../execution.context.js';
|
|
|
4
4
|
import { metrics } from '../metrics.service.js';
|
|
5
5
|
|
|
6
6
|
export class LoggingMiddleware implements Middleware {
|
|
7
|
-
async handle(request: JSONRPCRequest, context: ExecutionContext, next: NextFunction): Promise<JSONRPCResponse> {
|
|
7
|
+
async handle(request: JSONRPCRequest, context: ExecutionContext, next: NextFunction): Promise<JSONRPCResponse | null> {
|
|
8
8
|
const { method, id } = request;
|
|
9
9
|
const childLogger = context.logger.child({ method, id });
|
|
10
10
|
context.logger = childLogger; // Update context logger for downstream
|
|
@@ -10,7 +10,7 @@ export class RateLimitMiddleware implements Middleware {
|
|
|
10
10
|
request: JSONRPCRequest,
|
|
11
11
|
context: ExecutionContext,
|
|
12
12
|
next: NextFunction
|
|
13
|
-
): Promise<JSONRPCResponse> {
|
|
13
|
+
): Promise<JSONRPCResponse | null> {
|
|
14
14
|
const providedToken = request.auth?.bearerToken;
|
|
15
15
|
// Use token if available, otherwise fallback to remote address from context
|
|
16
16
|
const rateLimitKey = providedToken || context.remoteAddress || 'unknown';
|
package/src/core/ops.server.ts
CHANGED
|
@@ -57,7 +57,7 @@ export class OpsServer {
|
|
|
57
57
|
|
|
58
58
|
async listen(): Promise<string> {
|
|
59
59
|
// Use explicit opsPort from config
|
|
60
|
-
const port = this.config.opsPort
|
|
60
|
+
const port = this.config.opsPort !== undefined ? this.config.opsPort : 3001;
|
|
61
61
|
try {
|
|
62
62
|
const address = await this.fastify.listen({ port, host: '0.0.0.0' });
|
|
63
63
|
this.logger.info({ address }, 'Ops server listening');
|
|
@@ -7,9 +7,11 @@ import { ExecutionService } from './execution.service.js';
|
|
|
7
7
|
|
|
8
8
|
import { Middleware } from './interfaces/middleware.interface.js';
|
|
9
9
|
|
|
10
|
-
import { ConduitError
|
|
10
|
+
import { ConduitError } from './types.js';
|
|
11
|
+
import type { JSONRPCRequest, JSONRPCResponse } from './types.js';
|
|
11
12
|
|
|
12
|
-
export { ConduitError
|
|
13
|
+
export { ConduitError };
|
|
14
|
+
export type { JSONRPCRequest, JSONRPCResponse };
|
|
13
15
|
|
|
14
16
|
export class RequestController {
|
|
15
17
|
private logger: Logger;
|
|
@@ -36,14 +38,14 @@ export class RequestController {
|
|
|
36
38
|
|
|
37
39
|
|
|
38
40
|
|
|
39
|
-
async handleRequest(request: JSONRPCRequest, context: ExecutionContext): Promise<JSONRPCResponse> {
|
|
41
|
+
async handleRequest(request: JSONRPCRequest, context: ExecutionContext): Promise<JSONRPCResponse | null> {
|
|
40
42
|
return this.executePipeline(request, context);
|
|
41
43
|
}
|
|
42
44
|
|
|
43
|
-
private async executePipeline(request: JSONRPCRequest, context: ExecutionContext): Promise<JSONRPCResponse> {
|
|
45
|
+
private async executePipeline(request: JSONRPCRequest, context: ExecutionContext): Promise<JSONRPCResponse | null> {
|
|
44
46
|
let index = -1;
|
|
45
47
|
|
|
46
|
-
const dispatch = async (i: number): Promise<JSONRPCResponse> => {
|
|
48
|
+
const dispatch = async (i: number): Promise<JSONRPCResponse | null> => {
|
|
47
49
|
if (i <= index) throw new Error('next() called multiple times');
|
|
48
50
|
index = i;
|
|
49
51
|
|
|
@@ -90,7 +92,7 @@ export class RequestController {
|
|
|
90
92
|
}
|
|
91
93
|
}
|
|
92
94
|
|
|
93
|
-
private async finalHandler(request: JSONRPCRequest, context: ExecutionContext): Promise<JSONRPCResponse> {
|
|
95
|
+
private async finalHandler(request: JSONRPCRequest, context: ExecutionContext): Promise<JSONRPCResponse | null> {
|
|
94
96
|
const { method, params, id } = request;
|
|
95
97
|
// Logging and metrics handled by middlewares now
|
|
96
98
|
|
|
@@ -99,6 +101,7 @@ export class RequestController {
|
|
|
99
101
|
// Or specific logic.
|
|
100
102
|
|
|
101
103
|
switch (method) {
|
|
104
|
+
case 'tools/list': // Standard MCP method name
|
|
102
105
|
case 'mcp.discoverTools':
|
|
103
106
|
return this.handleDiscoverTools(params, context, id);
|
|
104
107
|
case 'mcp.listToolPackages':
|
|
@@ -117,6 +120,12 @@ export class RequestController {
|
|
|
117
120
|
return this.handleExecutePython(params, context, id);
|
|
118
121
|
case 'mcp.executeIsolate':
|
|
119
122
|
return this.handleExecuteIsolate(params, context, id);
|
|
123
|
+
case 'initialize':
|
|
124
|
+
return this.handleInitialize(params, context, id);
|
|
125
|
+
case 'notifications/initialized':
|
|
126
|
+
return null; // Notifications don't get responses per MCP spec
|
|
127
|
+
case 'ping':
|
|
128
|
+
return { jsonrpc: '2.0', id, result: {} };
|
|
120
129
|
default:
|
|
121
130
|
// metrics.recordExecutionEnd is handled by LoggingMiddleware??
|
|
122
131
|
// Wait, if 404, LoggingMiddleware records execution end?
|
|
@@ -127,11 +136,19 @@ export class RequestController {
|
|
|
127
136
|
|
|
128
137
|
private async handleDiscoverTools(params: any, context: ExecutionContext, id: string | number): Promise<JSONRPCResponse> {
|
|
129
138
|
const tools = await this.gatewayService.discoverTools(context);
|
|
139
|
+
|
|
140
|
+
// Filter to only MCP-standard fields for compatibility with strict clients
|
|
141
|
+
const standardizedTools = tools.map(t => ({
|
|
142
|
+
name: t.name,
|
|
143
|
+
description: t.description,
|
|
144
|
+
inputSchema: t.inputSchema,
|
|
145
|
+
}));
|
|
146
|
+
|
|
130
147
|
return {
|
|
131
148
|
jsonrpc: '2.0',
|
|
132
149
|
id,
|
|
133
150
|
result: {
|
|
134
|
-
tools,
|
|
151
|
+
tools: standardizedTools,
|
|
135
152
|
},
|
|
136
153
|
};
|
|
137
154
|
}
|
|
@@ -244,6 +261,31 @@ export class RequestController {
|
|
|
244
261
|
};
|
|
245
262
|
}
|
|
246
263
|
|
|
264
|
+
private async handleInitialize(params: any, context: ExecutionContext, id: string | number): Promise<JSONRPCResponse> {
|
|
265
|
+
// Echo back the client's protocol version for compatibility, or use latest if not provided
|
|
266
|
+
const clientVersion = params?.protocolVersion || '2025-06-18';
|
|
267
|
+
return {
|
|
268
|
+
jsonrpc: '2.0',
|
|
269
|
+
id,
|
|
270
|
+
result: {
|
|
271
|
+
protocolVersion: clientVersion,
|
|
272
|
+
capabilities: {
|
|
273
|
+
tools: {
|
|
274
|
+
listChanged: true
|
|
275
|
+
},
|
|
276
|
+
resources: {
|
|
277
|
+
listChanged: true,
|
|
278
|
+
subscribe: true
|
|
279
|
+
}
|
|
280
|
+
},
|
|
281
|
+
serverInfo: {
|
|
282
|
+
name: 'conduit',
|
|
283
|
+
version: process.env.npm_package_version || '1.1.0'
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
247
289
|
private async handleExecuteIsolate(params: any, context: ExecutionContext, id: string | number): Promise<JSONRPCResponse> {
|
|
248
290
|
const { code, limits, allowedTools } = params;
|
|
249
291
|
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { Logger } from 'pino';
|
|
2
2
|
import { NetworkPolicyService } from './network.policy.service.js';
|
|
3
|
-
import { SessionManager
|
|
3
|
+
import { SessionManager } from './session.manager.js';
|
|
4
|
+
import type { Session } from './session.manager.js';
|
|
4
5
|
import { IUrlValidator } from './interfaces/url.validator.interface.js';
|
|
5
6
|
import crypto from 'node:crypto';
|
|
6
7
|
|
|
7
|
-
export { Session };
|
|
8
|
+
export type { Session };
|
|
8
9
|
|
|
9
10
|
export class SecurityService implements IUrlValidator {
|
|
10
11
|
private logger: Logger;
|
|
@@ -39,6 +40,10 @@ export class SecurityService implements IUrlValidator {
|
|
|
39
40
|
|
|
40
41
|
validateIpcToken(token: string): boolean {
|
|
41
42
|
// Fix Sev1: Use timing-safe comparison for sensitive tokens
|
|
43
|
+
if (!this.ipcToken) {
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
|
|
42
47
|
const expected = Buffer.from(this.ipcToken);
|
|
43
48
|
const actual = Buffer.from(token);
|
|
44
49
|
|
package/src/core/types.ts
CHANGED
|
@@ -12,7 +12,7 @@ import { resolveAssetPath } from '../core/asset.utils.js';
|
|
|
12
12
|
|
|
13
13
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
14
14
|
|
|
15
|
-
import { Executor, ExecutorConfig, ExecutionResult } from '../core/interfaces/executor.interface.js';
|
|
15
|
+
import type { Executor, ExecutorConfig, ExecutionResult } from '../core/interfaces/executor.interface.js';
|
|
16
16
|
|
|
17
17
|
export { ExecutionResult };
|
|
18
18
|
|
|
@@ -5,7 +5,7 @@ import { ResourceLimits } from '../core/config.service.js';
|
|
|
5
5
|
import { GatewayService } from '../gateway/gateway.service.js';
|
|
6
6
|
import { ConduitError } from '../core/types.js';
|
|
7
7
|
|
|
8
|
-
import { Executor, ExecutorConfig, ExecutionResult } from '../core/interfaces/executor.interface.js';
|
|
8
|
+
import type { Executor, ExecutorConfig, ExecutionResult } from '../core/interfaces/executor.interface.js';
|
|
9
9
|
|
|
10
10
|
export { ExecutionResult as IsolateExecutionResult };
|
|
11
11
|
|
|
@@ -9,7 +9,7 @@ import { resolveAssetPath } from '../core/asset.utils.js';
|
|
|
9
9
|
|
|
10
10
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
11
11
|
|
|
12
|
-
import { Executor, ExecutorConfig, ExecutionResult } from '../core/interfaces/executor.interface.js';
|
|
12
|
+
import type { Executor, ExecutorConfig, ExecutionResult } from '../core/interfaces/executor.interface.js';
|
|
13
13
|
|
|
14
14
|
export { ExecutionResult };
|
|
15
15
|
|
|
@@ -113,13 +113,13 @@ export class PyodideExecutor implements Executor {
|
|
|
113
113
|
const needed = this.maxPoolSize - this.pool.length;
|
|
114
114
|
if (needed <= 0) return;
|
|
115
115
|
|
|
116
|
-
console.
|
|
116
|
+
console.error(`Pre-warming ${needed} Pyodide workers...`);
|
|
117
117
|
const promises = [];
|
|
118
118
|
for (let i = 0; i < needed; i++) {
|
|
119
119
|
promises.push(this.createAndPoolWorker(limits));
|
|
120
120
|
}
|
|
121
121
|
await Promise.all(promises);
|
|
122
|
-
console.
|
|
122
|
+
console.error(`Pyodide pool pre-warmed with ${this.pool.length} workers.`);
|
|
123
123
|
}
|
|
124
124
|
|
|
125
125
|
private async createAndPoolWorker(limits: ConduitResourceLimits) {
|
package/src/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { ConfigService } from './core/config.service.js';
|
|
2
2
|
import { createLogger, loggerStorage } from './core/logger.js';
|
|
3
3
|
import { SocketTransport } from './transport/socket.transport.js';
|
|
4
|
+
import { StdioTransport } from './transport/stdio.transport.js';
|
|
4
5
|
import { OpsServer } from './core/ops.server.js';
|
|
5
6
|
import { ConcurrencyService } from './core/concurrency.service.js';
|
|
6
7
|
import { RequestController } from './core/request.controller.js';
|
|
@@ -14,14 +15,20 @@ import { ExecutorRegistry } from './core/registries/executor.registry.js';
|
|
|
14
15
|
import { ExecutionService } from './core/execution.service.js';
|
|
15
16
|
import { buildDefaultMiddleware } from './core/middleware/middleware.builder.js';
|
|
16
17
|
async function main() {
|
|
18
|
+
// console.error('DEBUG: Starting Conduit main...');
|
|
17
19
|
const configService = new ConfigService();
|
|
20
|
+
// console.error('DEBUG: Config loaded');
|
|
18
21
|
const logger = createLogger(configService);
|
|
19
22
|
|
|
20
23
|
const otelService = new OtelService(logger);
|
|
21
24
|
await otelService.start();
|
|
22
25
|
|
|
23
26
|
await loggerStorage.run({ correlationId: 'system' }, async () => {
|
|
24
|
-
|
|
27
|
+
// Disable auth for Stdio transport (implicitly trusted as it is spawned by the user)
|
|
28
|
+
const isStdio = configService.get('transport') === 'stdio';
|
|
29
|
+
const ipcToken = isStdio ? undefined : configService.get('ipcBearerToken');
|
|
30
|
+
|
|
31
|
+
const securityService = new SecurityService(logger, ipcToken!);
|
|
25
32
|
|
|
26
33
|
const gatewayService = new GatewayService(logger, securityService);
|
|
27
34
|
const upstreams = configService.get('upstreams') || [];
|
|
@@ -59,9 +66,18 @@ async function main() {
|
|
|
59
66
|
maxConcurrent: configService.get('maxConcurrent')
|
|
60
67
|
});
|
|
61
68
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
69
|
+
let transport: SocketTransport | StdioTransport;
|
|
70
|
+
let address: string;
|
|
71
|
+
|
|
72
|
+
if (configService.get('transport') === 'stdio') {
|
|
73
|
+
transport = new StdioTransport(logger, requestController, concurrencyService);
|
|
74
|
+
await transport.start();
|
|
75
|
+
address = 'stdio';
|
|
76
|
+
} else {
|
|
77
|
+
transport = new SocketTransport(logger, requestController, concurrencyService);
|
|
78
|
+
const port = configService.get('port');
|
|
79
|
+
address = await transport.listen({ port });
|
|
80
|
+
}
|
|
65
81
|
executionService.ipcAddress = address; // Update IPC address on ExecutionService instead of RequestController
|
|
66
82
|
|
|
67
83
|
// Pre-warm workers
|
package/src/sdk/index.ts
CHANGED
|
@@ -1,2 +1,3 @@
|
|
|
1
|
-
export {
|
|
1
|
+
export { toToolBinding, groupByNamespace } from './tool-binding.js';
|
|
2
|
+
export type { ToolBinding, SDKGeneratorOptions } from './tool-binding.js';
|
|
2
3
|
export { SDKGenerator } from './sdk-generator.js';
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { Logger } from 'pino';
|
|
2
|
+
import { RequestController } from '../core/request.controller.js';
|
|
3
|
+
import { JSONRPCRequest, ConduitError } from '../core/types.js';
|
|
4
|
+
import { ExecutionContext } from '../core/execution.context.js';
|
|
5
|
+
import { ConcurrencyService } from '../core/concurrency.service.js';
|
|
6
|
+
import { loggerStorage } from '../core/logger.js';
|
|
7
|
+
|
|
8
|
+
export class StdioTransport {
|
|
9
|
+
private logger: Logger;
|
|
10
|
+
private requestController: RequestController;
|
|
11
|
+
private concurrencyService: ConcurrencyService;
|
|
12
|
+
private buffer: string = '';
|
|
13
|
+
|
|
14
|
+
constructor(
|
|
15
|
+
logger: Logger,
|
|
16
|
+
requestController: RequestController,
|
|
17
|
+
concurrencyService: ConcurrencyService
|
|
18
|
+
) {
|
|
19
|
+
this.logger = logger;
|
|
20
|
+
this.requestController = requestController;
|
|
21
|
+
this.concurrencyService = concurrencyService;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async start(): Promise<void> {
|
|
25
|
+
this.logger.info('Starting Stdio transport');
|
|
26
|
+
|
|
27
|
+
process.stdin.setEncoding('utf8');
|
|
28
|
+
process.stdin.on('data', this.handleData.bind(this));
|
|
29
|
+
|
|
30
|
+
// Handle stream end if necessary, though usually main process exit handles this
|
|
31
|
+
process.stdin.on('end', () => {
|
|
32
|
+
this.logger.info('Stdin closed');
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
private handleData(chunk: string) {
|
|
37
|
+
this.buffer += chunk;
|
|
38
|
+
|
|
39
|
+
let pos: number;
|
|
40
|
+
while ((pos = this.buffer.indexOf('\n')) >= 0) {
|
|
41
|
+
const line = this.buffer.substring(0, pos).trim();
|
|
42
|
+
this.buffer = this.buffer.substring(pos + 1);
|
|
43
|
+
|
|
44
|
+
if (!line) continue;
|
|
45
|
+
|
|
46
|
+
this.processLine(line);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
private async processLine(line: string) {
|
|
51
|
+
let request: JSONRPCRequest;
|
|
52
|
+
try {
|
|
53
|
+
request = JSON.parse(line) as JSONRPCRequest;
|
|
54
|
+
} catch (err) {
|
|
55
|
+
this.logger.error({ err, line }, 'Failed to parse JSON-RPC request');
|
|
56
|
+
const errorResponse = {
|
|
57
|
+
jsonrpc: '2.0',
|
|
58
|
+
id: null,
|
|
59
|
+
error: {
|
|
60
|
+
code: -32700,
|
|
61
|
+
message: 'Parse error',
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
this.sendResponse(errorResponse);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const context = new ExecutionContext({
|
|
69
|
+
logger: this.logger,
|
|
70
|
+
remoteAddress: 'stdio',
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
await loggerStorage.run({ correlationId: context.correlationId }, async () => {
|
|
74
|
+
try {
|
|
75
|
+
const response = await this.concurrencyService.run(() =>
|
|
76
|
+
this.requestController.handleRequest(request, context)
|
|
77
|
+
);
|
|
78
|
+
// Don't send response for notifications (they return null)
|
|
79
|
+
if (response !== null) {
|
|
80
|
+
this.sendResponse(response);
|
|
81
|
+
}
|
|
82
|
+
} catch (err: any) {
|
|
83
|
+
if (err.name === 'QueueFullError') {
|
|
84
|
+
this.sendResponse({
|
|
85
|
+
jsonrpc: '2.0',
|
|
86
|
+
id: request.id,
|
|
87
|
+
error: {
|
|
88
|
+
code: ConduitError.ServerBusy,
|
|
89
|
+
message: 'Server busy'
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
} else {
|
|
93
|
+
this.logger.error({ err, requestId: request.id }, 'Request handling failed');
|
|
94
|
+
this.sendResponse({
|
|
95
|
+
jsonrpc: '2.0',
|
|
96
|
+
id: request.id,
|
|
97
|
+
error: {
|
|
98
|
+
code: ConduitError.InternalError,
|
|
99
|
+
message: 'Internal server error'
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private sendResponse(response: any) {
|
|
108
|
+
process.stdout.write(JSON.stringify(response) + '\n');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async close(): Promise<void> {
|
|
112
|
+
process.stdin.removeAllListeners();
|
|
113
|
+
// We don't close stdout/stdin as they are process-level
|
|
114
|
+
return Promise.resolve();
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -101,12 +101,12 @@ describe('Dynamic Tool Calling (E2E)', () => {
|
|
|
101
101
|
auth: { bearerToken: testToken }
|
|
102
102
|
}, context);
|
|
103
103
|
|
|
104
|
-
fs.appendFileSync(LOG_FILE, `Deno Stdout: ${response
|
|
105
|
-
fs.appendFileSync(LOG_FILE, `Deno Stderr: ${response
|
|
104
|
+
fs.appendFileSync(LOG_FILE, `Deno Stdout: ${response!.result?.stdout}\n`);
|
|
105
|
+
fs.appendFileSync(LOG_FILE, `Deno Stderr: ${response!.result?.stderr}\n`);
|
|
106
106
|
|
|
107
|
-
expect(response
|
|
108
|
-
expect(response
|
|
109
|
-
expect(response
|
|
107
|
+
expect(response!.error).toBeUndefined();
|
|
108
|
+
expect(response!.result.stdout).toContain('mock__hello');
|
|
109
|
+
expect(response!.result.stdout).toContain('Hello Deno');
|
|
110
110
|
}, 15000);
|
|
111
111
|
|
|
112
112
|
it('should allow Python to discover and call tools via SDK', async () => {
|
|
@@ -127,13 +127,13 @@ print(f"RESULT:{result}")
|
|
|
127
127
|
auth: { bearerToken: testToken }
|
|
128
128
|
}, context);
|
|
129
129
|
|
|
130
|
-
fs.appendFileSync(LOG_FILE, `Python Stdout: ${response
|
|
131
|
-
fs.appendFileSync(LOG_FILE, `Python Stderr: ${response
|
|
132
|
-
if (response
|
|
130
|
+
fs.appendFileSync(LOG_FILE, `Python Stdout: ${response!.result?.stdout}\n`);
|
|
131
|
+
fs.appendFileSync(LOG_FILE, `Python Stderr: ${response!.result?.stderr}\n`);
|
|
132
|
+
if (response!.error) fs.appendFileSync(LOG_FILE, `Python Error: ${JSON.stringify(response!.error)}\n`);
|
|
133
133
|
|
|
134
|
-
expect(response
|
|
135
|
-
expect(response
|
|
136
|
-
expect(response
|
|
134
|
+
expect(response!.error).toBeUndefined();
|
|
135
|
+
expect(response!.result.stdout).toContain('mock__hello');
|
|
136
|
+
expect(response!.result.stdout).toContain('Hello Python');
|
|
137
137
|
}, 25000);
|
|
138
138
|
|
|
139
139
|
it('should reject tools not in allowlist via $raw()', async () => {
|
|
@@ -159,12 +159,12 @@ print(f"RESULT:{result}")
|
|
|
159
159
|
auth: { bearerToken: testToken }
|
|
160
160
|
}, context);
|
|
161
161
|
|
|
162
|
-
fs.appendFileSync(LOG_FILE, `Allowlist Stdout: ${response
|
|
163
|
-
if (response
|
|
162
|
+
fs.appendFileSync(LOG_FILE, `Allowlist Stdout: ${response!.result?.stdout}\n`);
|
|
163
|
+
if (response!.error) fs.appendFileSync(LOG_FILE, `Allowlist Error: ${JSON.stringify(response!.error)}\n`);
|
|
164
164
|
|
|
165
|
-
expect(response
|
|
166
|
-
expect(response
|
|
167
|
-
expect(response
|
|
165
|
+
expect(response!.error).toBeUndefined();
|
|
166
|
+
expect(response!.result.stdout).toContain('REJECTED');
|
|
167
|
+
expect(response!.result.stdout).toContain('not in the allowlist');
|
|
168
168
|
}, 15000);
|
|
169
169
|
|
|
170
170
|
it('should allow tools matching wildcard pattern', async () => {
|
|
@@ -186,11 +186,11 @@ print(f"RESULT:{result}")
|
|
|
186
186
|
auth: { bearerToken: testToken }
|
|
187
187
|
}, context);
|
|
188
188
|
|
|
189
|
-
fs.appendFileSync(LOG_FILE, `Wildcard Stdout: ${response
|
|
190
|
-
if (response
|
|
189
|
+
fs.appendFileSync(LOG_FILE, `Wildcard Stdout: ${response!.result?.stdout}\n`);
|
|
190
|
+
if (response!.error) fs.appendFileSync(LOG_FILE, `Wildcard Error: ${JSON.stringify(response!.error)}\n`);
|
|
191
191
|
|
|
192
|
-
expect(response
|
|
193
|
-
expect(response
|
|
192
|
+
expect(response!.error).toBeUndefined();
|
|
193
|
+
expect(response!.result.stdout).toContain('Hello Wildcard');
|
|
194
194
|
}, 15000);
|
|
195
195
|
|
|
196
196
|
it('should allow isolated-vm to discover and call tools via typed SDK', async () => {
|
|
@@ -215,11 +215,11 @@ print(f"RESULT:{result}")
|
|
|
215
215
|
auth: { bearerToken: testToken }
|
|
216
216
|
}, context);
|
|
217
217
|
|
|
218
|
-
fs.appendFileSync(LOG_FILE, `Isolate Stdout: ${response
|
|
219
|
-
if (response
|
|
218
|
+
fs.appendFileSync(LOG_FILE, `Isolate Stdout: ${response!.result?.stdout}\n`);
|
|
219
|
+
if (response!.error) fs.appendFileSync(LOG_FILE, `Isolate Error: ${JSON.stringify(response!.error)}\n`);
|
|
220
220
|
|
|
221
|
-
expect(response
|
|
222
|
-
expect(response
|
|
221
|
+
expect(response!.error).toBeUndefined();
|
|
222
|
+
expect(response!.result.stdout).toContain('Isolate call done');
|
|
223
223
|
|
|
224
224
|
// Verify tool was called
|
|
225
225
|
expect(mockClient.call).toHaveBeenCalled();
|
package/tests/ops.server.test.ts
CHANGED
|
@@ -17,7 +17,11 @@ describe('OpsServer', () => {
|
|
|
17
17
|
let gatewayService: GatewayService;
|
|
18
18
|
|
|
19
19
|
beforeEach(() => {
|
|
20
|
-
configService = new ConfigService({
|
|
20
|
+
configService = new ConfigService({
|
|
21
|
+
port: 0,
|
|
22
|
+
opsPort: 0,
|
|
23
|
+
metricsUrl: 'http://127.0.0.1:0/metrics' // Force fallback by using invalid URL
|
|
24
|
+
} as any);
|
|
21
25
|
const securityService = new SecurityService(logger, 'test-token');
|
|
22
26
|
gatewayService = new GatewayService(logger, securityService);
|
|
23
27
|
const executorRegistry = new ExecutorRegistry();
|
package/tests/routing.test.ts
CHANGED
|
@@ -94,7 +94,7 @@ describe('RequestController Routing', () => {
|
|
|
94
94
|
|
|
95
95
|
expect(mockIsolateExecutor.execute).toHaveBeenCalled();
|
|
96
96
|
expect(mockDenoExecutor.execute).not.toHaveBeenCalled();
|
|
97
|
-
expect(result
|
|
97
|
+
expect(result!.result.stdout).toBe('isolate');
|
|
98
98
|
});
|
|
99
99
|
|
|
100
100
|
it('should route scripts with imports to DenoExecutor', async () => {
|
|
@@ -111,7 +111,7 @@ describe('RequestController Routing', () => {
|
|
|
111
111
|
|
|
112
112
|
expect(mockDenoExecutor.execute).toHaveBeenCalled();
|
|
113
113
|
expect(mockIsolateExecutor.execute).not.toHaveBeenCalled();
|
|
114
|
-
expect(result
|
|
114
|
+
expect(result!.result.stdout).toBe('deno');
|
|
115
115
|
});
|
|
116
116
|
|
|
117
117
|
it('should route scripts with exports to DenoExecutor', async () => {
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* E2E Test: Native Stdio Mode
|
|
3
|
+
*
|
|
4
|
+
* This test verifies that Conduit can be run in native Stdio mode (via --stdio flag),
|
|
5
|
+
* communicating directly over stdin/stdout.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, it, expect } from 'vitest';
|
|
8
|
+
import { spawn } from 'child_process';
|
|
9
|
+
import path from 'path';
|
|
10
|
+
|
|
11
|
+
describe('E2E: Native Stdio Mode', () => {
|
|
12
|
+
it('should start in stdio mode and discover tools', async () => {
|
|
13
|
+
const indexPath = path.resolve(__dirname, '../src/index.ts');
|
|
14
|
+
|
|
15
|
+
const child = spawn('npx', ['tsx', indexPath, '--stdio'], {
|
|
16
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
17
|
+
env: {
|
|
18
|
+
...process.env,
|
|
19
|
+
PATH: process.env.PATH,
|
|
20
|
+
PORT: '0', // Use random port for ops server to avoid conflicts
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const request = {
|
|
25
|
+
jsonrpc: '2.0',
|
|
26
|
+
id: '1',
|
|
27
|
+
method: 'mcp.discoverTools',
|
|
28
|
+
params: {},
|
|
29
|
+
// Use a dummy token, security service might reject if auth is enabled but
|
|
30
|
+
// the default config generates a random token.
|
|
31
|
+
// However, in this test we are spawning a fresh process, so we don't know the token.
|
|
32
|
+
// Wait, security service checks 'ipcBearerToken'.
|
|
33
|
+
// If we don't provide one, it generates random.
|
|
34
|
+
// We should provide one via env var so we can auth.
|
|
35
|
+
auth: { bearerToken: 'test-token' },
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// We need to inject the token into the spawned process env
|
|
39
|
+
child.kill();
|
|
40
|
+
|
|
41
|
+
// Restart with known token
|
|
42
|
+
const childWithAuth = spawn('npx', ['tsx', indexPath, '--stdio'], {
|
|
43
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
44
|
+
env: {
|
|
45
|
+
...process.env,
|
|
46
|
+
PATH: process.env.PATH,
|
|
47
|
+
PORT: '0',
|
|
48
|
+
IPC_BEARER_TOKEN: 'test-token'
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// Write request to process stdin
|
|
53
|
+
childWithAuth.stdin.write(JSON.stringify(request) + '\n');
|
|
54
|
+
|
|
55
|
+
const response = await new Promise<any>((resolve, reject) => {
|
|
56
|
+
let buffer = '';
|
|
57
|
+
childWithAuth.stdout.on('data', (chunk) => {
|
|
58
|
+
buffer += chunk.toString();
|
|
59
|
+
if (buffer.includes('\n')) {
|
|
60
|
+
const lines = buffer.split('\n');
|
|
61
|
+
for (const line of lines) {
|
|
62
|
+
if (!line.trim()) continue;
|
|
63
|
+
try {
|
|
64
|
+
const parsed = JSON.parse(line);
|
|
65
|
+
resolve(parsed);
|
|
66
|
+
return;
|
|
67
|
+
} catch (e) {
|
|
68
|
+
// ignore partial or non-json (though stdout should be pure json-rpc)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
childWithAuth.stderr.pipe(process.stderr);
|
|
75
|
+
|
|
76
|
+
childWithAuth.on('error', reject);
|
|
77
|
+
|
|
78
|
+
setTimeout(() => {
|
|
79
|
+
childWithAuth.kill();
|
|
80
|
+
reject(new Error('Timeout waiting for response'));
|
|
81
|
+
}, 30000);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
childWithAuth.kill();
|
|
85
|
+
|
|
86
|
+
expect(response.error).toBeUndefined();
|
|
87
|
+
expect(response.result).toBeDefined();
|
|
88
|
+
expect(response.result.tools).toBeInstanceOf(Array);
|
|
89
|
+
expect(response.id).toBe('1');
|
|
90
|
+
}, 35000);
|
|
91
|
+
});
|
package/tsup.config.ts
CHANGED
|
@@ -10,10 +10,10 @@ export default defineConfig({
|
|
|
10
10
|
splitting: false,
|
|
11
11
|
sourcemap: true,
|
|
12
12
|
clean: true,
|
|
13
|
-
loader: {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
},
|
|
13
|
+
// loader: {
|
|
14
|
+
// '.py': 'text',
|
|
15
|
+
// '.ts': 'text', // REMOVED: This was causing src/index.ts to be compiled as a string!
|
|
16
|
+
// },
|
|
17
17
|
// Ensure assets are included
|
|
18
18
|
// We can use the 'onSuccess' hook to copy them or just include them in the bundle
|
|
19
19
|
// But the spec says 'into dist/assets', which implies they should be separate files.
|