@mhingston5/conduit 1.0.0 → 1.1.1

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 CHANGED
@@ -6,17 +6,18 @@
6
6
 
7
7
  <div align="center">
8
8
 
9
- [![npm version](https://badge.fury.io/js/@mhingston5%2Fconduit.svg)](https://www.npmjs.com/package/@mhingston/conduit)
9
+ [![npm version](https://badge.fury.io/js/@mhingston5%2Fconduit.svg)](https://www.npmjs.com/package/@mhingston5/conduit)
10
10
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
11
11
  [![TypeScript](https://img.shields.io/badge/TypeScript-5.9-blue.svg)](https://www.typescriptlang.org/)
12
12
  [![Node.js Version](https://img.shields.io/badge/node-24-brightgreen.svg)](https://nodejs.org/)
13
13
  [![MCP](https://img.shields.io/badge/MCP-Compatible-purple.svg)](https://modelcontextprotocol.io/)
14
+ [![Standard MCP Server](https://img.shields.io/badge/MCP-Server-blue.svg)](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)
@@ -152,6 +175,21 @@ By default, Conduit runs in **Permissive Mode** to allow easy exploration.
152
175
  - permissive mode for exploration
153
176
  - strict mode for production agents
154
177
 
178
+ ## Code Mode Prompting Guide
179
+
180
+ To get the most out of Conduit, you should guide your LLM (agent) to prefer multi-tool scripts over individual tool calls.
181
+
182
+ **Recommended System Prompt Additions:**
183
+ > You are equipped with a "Code Mode" execution environment via Conduit.
184
+ >
185
+ > - **Prefer `mcp.executeTypeScript`**: Use this for any task requiring multiple steps, data transformation, or logical branching.
186
+ > - **Use `tools.*` SDK**: Within your scripts, access all upstream tools via the `tools` namespace (e.g., `await tools.github.create_issue(...)`).
187
+ > - **Avoid JSON tool-calling overhead**: Instead of making 5 separate tool calls and waiting for 5 round-trips, write one script that orchestrates the entire operation.
188
+ > - **Data Transformation**: Perform loops, filters, and aggregations directly in your code rather than asking the user (or yourself) to process large datasets in the chat context.
189
+
190
+ Example of a high-efficiency prompt:
191
+ *"Scan the last 50 emails for invoices, total their amounts by currency, and create a single summary ticket in Jira if the total exceeds $1000."*
192
+
155
193
  ---
156
194
 
157
195
  ## Design Principles
package/conduit.yaml ADDED
@@ -0,0 +1,6 @@
1
+ logLevel: info
2
+ upstreams:
3
+ - id: filesystem
4
+ type: stdio
5
+ command: npx
6
+ args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mhingston5/conduit",
3
- "version": "1.0.0",
3
+ "version": "1.1.1",
4
4
  "type": "module",
5
5
  "description": "A secure Code Mode execution substrate for MCP agents",
6
6
  "main": "index.js",
@@ -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 (!this.config.opsPort) {
105
- this.config.opsPort = this.config.port === 0 ? 0 : this.config.port + 1;
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;
@@ -13,5 +13,6 @@ export interface AppConfig {
13
13
  pyodideMaxPoolSize: number;
14
14
  metricsUrl: string;
15
15
  opsPort?: number;
16
+ transport: 'socket' | 'stdio';
16
17
  upstreams?: UpstreamInfo[];
17
18
  }
@@ -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
  }
@@ -54,11 +54,12 @@ export function createLogger(configService: ConfigService) {
54
54
  correlationId: store?.correlationId,
55
55
  };
56
56
  },
57
- transport: configService.get('nodeEnv') === 'development' ? {
58
- target: 'pino-pretty',
59
- options: {
60
- colorize: true,
61
- }
62
- } : undefined,
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
- const isMaster = providedToken === this.securityService.getIpcToken();
17
- const isSession = this.securityService.validateIpcToken(providedToken) && !isMaster;
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';
@@ -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 || 3001;
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, JSONRPCRequest, JSONRPCResponse } from './types.js';
10
+ import { ConduitError } from './types.js';
11
+ import type { JSONRPCRequest, JSONRPCResponse } from './types.js';
11
12
 
12
- export { ConduitError, JSONRPCRequest, JSONRPCResponse };
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, Session } from './session.manager.js';
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
@@ -20,7 +20,7 @@ export interface JSONRPCRequest {
20
20
 
21
21
  export interface JSONRPCResponse {
22
22
  jsonrpc: '2.0';
23
- id: string | number;
23
+ id: string | number | null;
24
24
  result?: any;
25
25
  error?: {
26
26
  code: number;
@@ -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.info(`Pre-warming ${needed} Pyodide workers...`);
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.info(`Pyodide pool pre-warmed with ${this.pool.length} workers.`);
122
+ console.error(`Pyodide pool pre-warmed with ${this.pool.length} workers.`);
123
123
  }
124
124
 
125
125
  private async createAndPoolWorker(limits: ConduitResourceLimits) {
@@ -10,6 +10,66 @@ import { PolicyService, ToolIdentifier } from '../core/policy.service.js';
10
10
  import { Ajv } from 'ajv';
11
11
  import addFormats from 'ajv-formats';
12
12
 
13
+ const BUILT_IN_TOOLS: ToolSchema[] = [
14
+ {
15
+ name: 'mcp.executeTypeScript',
16
+ description: 'Executes TypeScript code in a secure sandbox with access to `tools.*` SDK.',
17
+ inputSchema: {
18
+ type: 'object',
19
+ properties: {
20
+ code: {
21
+ type: 'string',
22
+ description: 'The TypeScript code to execute.'
23
+ },
24
+ allowedTools: {
25
+ type: 'array',
26
+ items: { type: 'string' },
27
+ description: 'Optional list of tools the script is allowed to call (e.g. ["github.*"]).'
28
+ }
29
+ },
30
+ required: ['code']
31
+ }
32
+ },
33
+ {
34
+ name: 'mcp.executePython',
35
+ description: 'Executes Python code in a secure sandbox with access to `tools.*` SDK.',
36
+ inputSchema: {
37
+ type: 'object',
38
+ properties: {
39
+ code: {
40
+ type: 'string',
41
+ description: 'The Python code to execute.'
42
+ },
43
+ allowedTools: {
44
+ type: 'array',
45
+ items: { type: 'string' },
46
+ description: 'Optional list of tools the script is allowed to call (e.g. ["github.*"]).'
47
+ }
48
+ },
49
+ required: ['code']
50
+ }
51
+ },
52
+ {
53
+ name: 'mcp.executeIsolate',
54
+ description: 'Executes JavaScript code in a high-speed V8 isolate (no Deno/Node APIs).',
55
+ inputSchema: {
56
+ type: 'object',
57
+ properties: {
58
+ code: {
59
+ type: 'string',
60
+ description: 'The JavaScript code to execute.'
61
+ },
62
+ allowedTools: {
63
+ type: 'array',
64
+ items: { type: 'string' },
65
+ description: 'Optional list of tools the script is allowed to call.'
66
+ }
67
+ },
68
+ required: ['code']
69
+ }
70
+ }
71
+ ];
72
+
13
73
  export class GatewayService {
14
74
  private logger: Logger;
15
75
  private clients: Map<string, UpstreamClient> = new Map();
@@ -110,8 +170,13 @@ export class GatewayService {
110
170
  }
111
171
 
112
172
  const parsed = this.policyService.parseToolName(toolId);
173
+ const toolName = parsed.name; // Use a new variable for the un-namespaced name
174
+
175
+ // Check for built-in tools
176
+ const builtIn = BUILT_IN_TOOLS.find(t => t.name === toolId); // Compare with the full toolId
177
+ if (builtIn) return builtIn;
178
+
113
179
  const upstreamId = parsed.namespace;
114
- const toolName = parsed.name;
115
180
 
116
181
  // Ensure we have schemas for this upstream
117
182
  if (!this.schemaCache.get(upstreamId)) {
@@ -132,7 +197,7 @@ export class GatewayService {
132
197
  }
133
198
 
134
199
  async discoverTools(context: ExecutionContext): Promise<ToolSchema[]> {
135
- const allTools: ToolSchema[] = [];
200
+ const allTools: ToolSchema[] = [...BUILT_IN_TOOLS];
136
201
 
137
202
  for (const [id, client] of this.clients.entries()) {
138
203
  let tools = this.schemaCache.get(id);
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
- const securityService = new SecurityService(logger, configService.get('ipcBearerToken'));
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
- const transport = new SocketTransport(logger, requestController, concurrencyService);
63
- const port = configService.get('port');
64
- const address = await transport.listen({ port });
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 { ToolBinding, SDKGeneratorOptions, toToolBinding, groupByNamespace } from './tool-binding.js';
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.result?.stdout}\n`);
105
- fs.appendFileSync(LOG_FILE, `Deno Stderr: ${response.result?.stderr}\n`);
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.error).toBeUndefined();
108
- expect(response.result.stdout).toContain('mock__hello');
109
- expect(response.result.stdout).toContain('Hello Deno');
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.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`);
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.error).toBeUndefined();
135
- expect(response.result.stdout).toContain('mock__hello');
136
- expect(response.result.stdout).toContain('Hello Python');
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.result?.stdout}\n`);
163
- if (response.error) fs.appendFileSync(LOG_FILE, `Allowlist Error: ${JSON.stringify(response.error)}\n`);
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.error).toBeUndefined();
166
- expect(response.result.stdout).toContain('REJECTED');
167
- expect(response.result.stdout).toContain('not in the allowlist');
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.result?.stdout}\n`);
190
- if (response.error) fs.appendFileSync(LOG_FILE, `Wildcard Error: ${JSON.stringify(response.error)}\n`);
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.error).toBeUndefined();
193
- expect(response.result.stdout).toContain('Hello Wildcard');
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.result?.stdout}\n`);
219
- if (response.error) fs.appendFileSync(LOG_FILE, `Isolate Error: ${JSON.stringify(response.error)}\n`);
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.error).toBeUndefined();
222
- expect(response.result.stdout).toContain('Isolate call done');
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();
@@ -31,11 +31,21 @@ describe('GatewayService', () => {
31
31
  });
32
32
 
33
33
  const tools = await gateway.discoverTools(context);
34
- expect(tools).toHaveLength(2);
34
+ expect(tools.length).toBeGreaterThanOrEqual(3);
35
+ expect(tools.find(t => t.name === 'mcp.executeTypeScript')).toBeDefined();
36
+ expect(tools.find(t => t.name === 'mcp.executePython')).toBeDefined();
37
+ expect(tools.find(t => t.name === 'mcp.executeIsolate')).toBeDefined();
35
38
  expect(tools.find(t => t.name === 'u1__t1')).toBeDefined();
36
39
  expect(tools.find(t => t.name === 'u2__t2')).toBeDefined();
37
40
  });
38
41
 
42
+ it('should return schema for built-in tools', async () => {
43
+ const schema = await gateway.getToolSchema('mcp.executeTypeScript', context);
44
+ expect(schema).toBeDefined();
45
+ expect(schema?.name).toBe('mcp.executeTypeScript');
46
+ expect(schema?.inputSchema.required).toContain('code');
47
+ });
48
+
39
49
  it('should route tool calls to correct upstream', async () => {
40
50
  gateway.registerUpstream({ id: 'u1', url: 'http://u1' });
41
51
 
@@ -17,7 +17,11 @@ describe('OpsServer', () => {
17
17
  let gatewayService: GatewayService;
18
18
 
19
19
  beforeEach(() => {
20
- configService = new ConfigService({ port: 0 as any });
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();
@@ -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.result.stdout).toBe('isolate');
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.result.stdout).toBe('deno');
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
- '.py': 'text',
15
- '.ts': 'text', // We want the shim source as text
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.