@coop-tech/mcp-sidecar 0.0.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 ADDED
@@ -0,0 +1,74 @@
1
+ # @coop-tech/mcp-sidecar
2
+
3
+ Connect MCP servers to [coop.tech](https://coop.tech) as sidecars.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install @coop-tech/mcp-sidecar
9
+ ```
10
+
11
+ ## Library usage
12
+
13
+ Connect an in-process MCP server:
14
+
15
+ ```ts
16
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
17
+ import { connectSidecar } from '@coop-tech/mcp-sidecar'
18
+
19
+ const server = new McpServer({ name: 'my-tools', version: '1.0.0' })
20
+ server.tool('analyze', 'Analyze data', { query: z.string() }, async ({ query }) => {
21
+ return { content: [{ type: 'text', text: 'result' }] }
22
+ })
23
+
24
+ await connectSidecar({
25
+ server,
26
+ token: process.env.COOP_TECH_TOKEN!,
27
+ description: 'Analyzes data',
28
+ })
29
+ ```
30
+
31
+ ## CLI usage
32
+
33
+ Spawn an external MCP server and connect it:
34
+
35
+ ```bash
36
+ # Using --command:
37
+ coop-mcp-sidecar --token YOUR_TOKEN --description "My server" --command "python my_server.py"
38
+
39
+ # Using -- separator:
40
+ coop-mcp-sidecar --token YOUR_TOKEN --description "My server" -- python my_server.py
41
+
42
+ # Using env var:
43
+ COOP_TECH_TOKEN=YOUR_TOKEN coop-mcp-sidecar --description "My server" -- python my_server.py
44
+ ```
45
+
46
+ ### CLI options
47
+
48
+ | Option | Description |
49
+ |--------|-------------|
50
+ | `--token <token>` | Personal access token (or `COOP_TECH_TOKEN` env var) |
51
+ | `--url <url>` | Server URL (default: `wss://coop.tech/mcp-sidecar`) |
52
+ | `--description <text>` | Description of the server (required) |
53
+ | `--instructions <text>` | Additional instructions for the agent |
54
+ | `--name <name>` | Override server name |
55
+ | `--version <version>` | Override server version |
56
+ | `--verbose` | Log full tool call arguments and results |
57
+ | `--quiet` | Suppress all logs |
58
+
59
+ ## API
60
+
61
+ ### `connectSidecar(config): Promise<Sidecar>`
62
+
63
+ Creates and connects a sidecar. Config options:
64
+
65
+ - **`token`** (required) - coop.tech personal access token
66
+ - **`description`** (required) - description of what the server does
67
+ - **`server`** - in-process `McpServer` instance (library mode)
68
+ - **`command`** / **`args`** / **`env`** / **`cwd`** - external MCP server (stdio mode)
69
+ - **`url`** - coop.tech WebSocket URL (default: `wss://coop.tech/mcp-sidecar`)
70
+ - **`name`** / **`version`** - override server name/version
71
+ - **`instructions`** - additional agent instructions
72
+ - **`logLevel`** - `'normal'` | `'verbose'` | `'quiet'`
73
+ - **`reconnect`** - auto-reconnect on disconnect (default: `true`)
74
+ - **`onConnect`** / **`onDisconnect`** / **`onError`** - lifecycle callbacks
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,106 @@
1
+ #!/usr/bin/env node
2
+ import { parseArgs } from 'node:util';
3
+ import { connectSidecar } from './index.js';
4
+ function printUsage() {
5
+ console.log(`Usage: coop-mcp-sidecar [options] [-- command [args...]]
6
+
7
+ Options:
8
+ --token <token> Personal access token (or set COOP_TECH_TOKEN env var)
9
+ --url <url> Server URL (default: wss://coop.tech/mcp-sidecar)
10
+ --command <cmd> MCP server command to spawn
11
+ --description <description> Description of what this server does (required)
12
+ --instructions <text> Additional instructions for the agent
13
+ --name <name> Override the server name reported to coop.tech
14
+ --version <version> Override the server version reported to coop.tech
15
+ --verbose Log full tool call arguments and results
16
+ --quiet Suppress all tool call and connection logs
17
+ --help Show this help message
18
+
19
+ Examples:
20
+ # Spawn an MCP server via stdio:
21
+ coop-mcp-sidecar --token YOUR_TOKEN --description "Manages database queries" --command "python my_server.py"
22
+
23
+ # Using -- separator for command with arguments:
24
+ coop-mcp-sidecar --token YOUR_TOKEN --description "Code search agent" -- claude mcp serve
25
+
26
+ # Override the server name and version:
27
+ coop-mcp-sidecar --token YOUR_TOKEN --description "My server" --name my-server --version 2.0.0 -- python my_server.py
28
+
29
+ # Using environment variable:
30
+ COOP_TECH_TOKEN=YOUR_TOKEN coop-mcp-sidecar --description "My server" -- python my_server.py`);
31
+ }
32
+ async function main() {
33
+ const { values, positionals } = parseArgs({
34
+ allowPositionals: true,
35
+ options: {
36
+ token: { type: 'string' },
37
+ url: { type: 'string' },
38
+ command: { type: 'string' },
39
+ description: { type: 'string' },
40
+ instructions: { type: 'string' },
41
+ name: { type: 'string' },
42
+ version: { type: 'string' },
43
+ verbose: { type: 'boolean' },
44
+ quiet: { type: 'boolean' },
45
+ help: { type: 'boolean', short: 'h' },
46
+ },
47
+ });
48
+ if (values.help) {
49
+ printUsage();
50
+ process.exit(0);
51
+ }
52
+ const token = values.token ?? process.env.COOP_TECH_TOKEN;
53
+ const url = values.url;
54
+ let command = values.command;
55
+ let commandArgs = [];
56
+ if (positionals.length > 0) {
57
+ command = positionals[0];
58
+ commandArgs = positionals.slice(1);
59
+ }
60
+ if (!token) {
61
+ console.error('Error: --token is required (or set COOP_TECH_TOKEN environment variable)');
62
+ process.exit(1);
63
+ }
64
+ if (!command) {
65
+ console.error('Error: --command is required (or use -- separator)');
66
+ printUsage();
67
+ process.exit(1);
68
+ }
69
+ const description = values.description;
70
+ if (!description) {
71
+ console.error('Error: --description is required');
72
+ printUsage();
73
+ process.exit(1);
74
+ }
75
+ const logLevel = values.quiet ? 'quiet' : values.verbose ? 'verbose' : 'normal';
76
+ if (logLevel !== 'quiet') {
77
+ console.log(`[coop-sidecar] Starting MCP server: ${command} ${commandArgs.join(' ')}`);
78
+ }
79
+ const sidecar = await connectSidecar({
80
+ token,
81
+ url,
82
+ command,
83
+ args: commandArgs,
84
+ description,
85
+ instructions: values.instructions,
86
+ name: values.name,
87
+ version: values.version,
88
+ logLevel,
89
+ reconnect: true,
90
+ onConnect: () => console.log('[coop-sidecar] Connected to coop.tech'),
91
+ onDisconnect: () => console.log('[coop-sidecar] Disconnected from coop.tech'),
92
+ onError: (err) => console.error('[coop-sidecar] Error:', err.message),
93
+ });
94
+ // Handle graceful shutdown
95
+ const shutdown = async () => {
96
+ console.log('\n[coop-sidecar] Shutting down...');
97
+ await sidecar.close();
98
+ process.exit(0);
99
+ };
100
+ process.on('SIGINT', () => { void shutdown(); });
101
+ process.on('SIGTERM', () => { void shutdown(); });
102
+ }
103
+ main().catch((err) => {
104
+ console.error('[coop-sidecar] Fatal error:', err);
105
+ process.exit(1);
106
+ });
@@ -0,0 +1,32 @@
1
+ export { Sidecar } from './sidecar.js';
2
+ export type { SidecarConfig } from './sidecar.js';
3
+ export type { ToolInfo } from './types.js';
4
+ import type { SidecarConfig } from './sidecar.js';
5
+ import { Sidecar } from './sidecar.js';
6
+ /**
7
+ * Connect an MCP server to coop.tech as a sidecar.
8
+ *
9
+ * @example Library mode (in-process McpServer):
10
+ * ```ts
11
+ * import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
12
+ * import { connectSidecar } from '@coop-tech/mcp-sidecar'
13
+ *
14
+ * const server = new McpServer({ name: 'my-tools', version: '1.0.0' })
15
+ * server.tool('analyze', 'Analyze data', { query: z.string() }, async ({ query }) => { ... })
16
+ *
17
+ * await connectSidecar({
18
+ * server,
19
+ * token: process.env.COOP_TECH_TOKEN!,
20
+ * })
21
+ * ```
22
+ *
23
+ * @example Stdio mode (spawn external process):
24
+ * ```ts
25
+ * await connectSidecar({
26
+ * command: 'python',
27
+ * args: ['my_mcp_server.py'],
28
+ * token: process.env.COOP_TECH_TOKEN!,
29
+ * })
30
+ * ```
31
+ */
32
+ export declare function connectSidecar(config: SidecarConfig): Promise<Sidecar>;
package/dist/index.js ADDED
@@ -0,0 +1,33 @@
1
+ export { Sidecar } from './sidecar.js';
2
+ import { Sidecar } from './sidecar.js';
3
+ /**
4
+ * Connect an MCP server to coop.tech as a sidecar.
5
+ *
6
+ * @example Library mode (in-process McpServer):
7
+ * ```ts
8
+ * import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
9
+ * import { connectSidecar } from '@coop-tech/mcp-sidecar'
10
+ *
11
+ * const server = new McpServer({ name: 'my-tools', version: '1.0.0' })
12
+ * server.tool('analyze', 'Analyze data', { query: z.string() }, async ({ query }) => { ... })
13
+ *
14
+ * await connectSidecar({
15
+ * server,
16
+ * token: process.env.COOP_TECH_TOKEN!,
17
+ * })
18
+ * ```
19
+ *
20
+ * @example Stdio mode (spawn external process):
21
+ * ```ts
22
+ * await connectSidecar({
23
+ * command: 'python',
24
+ * args: ['my_mcp_server.py'],
25
+ * token: process.env.COOP_TECH_TOKEN!,
26
+ * })
27
+ * ```
28
+ */
29
+ export async function connectSidecar(config) {
30
+ const sidecar = new Sidecar(config);
31
+ await sidecar.connect();
32
+ return sidecar;
33
+ }
@@ -0,0 +1,41 @@
1
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ export interface SidecarConfig {
3
+ token: string;
4
+ url?: string;
5
+ server?: McpServer;
6
+ command?: string;
7
+ args?: string[];
8
+ env?: Record<string, string>;
9
+ cwd?: string;
10
+ description: string;
11
+ name?: string;
12
+ version?: string;
13
+ instructions?: string;
14
+ logLevel?: 'normal' | 'verbose' | 'quiet';
15
+ reconnect?: boolean;
16
+ reconnectIntervalMs?: number;
17
+ heartbeatIntervalMs?: number;
18
+ onConnect?: () => void;
19
+ onDisconnect?: () => void;
20
+ onError?: (error: Error) => void;
21
+ }
22
+ export declare class Sidecar {
23
+ private config;
24
+ private logLevel;
25
+ private mcpClient;
26
+ private ws;
27
+ private heartbeatTimer;
28
+ private reconnectTimer;
29
+ private tools;
30
+ private serverName;
31
+ private serverVersion;
32
+ private closed;
33
+ constructor(config: SidecarConfig);
34
+ connect(): Promise<void>;
35
+ close(): Promise<void>;
36
+ private connectMcpServer;
37
+ private connectWebSocket;
38
+ private handleToolCall;
39
+ private startHeartbeat;
40
+ private stopHeartbeat;
41
+ }
@@ -0,0 +1,229 @@
1
+ import { createRequire } from 'node:module';
2
+ import WebSocket from 'ws';
3
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
4
+ import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
5
+ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
6
+ const require = createRequire(import.meta.url);
7
+ const { version: packageVersion } = require('../package.json');
8
+ const DEFAULT_URL = 'wss://coop.tech/mcp-sidecar';
9
+ const DEFAULT_RECONNECT_INTERVAL_MS = 5000;
10
+ const DEFAULT_HEARTBEAT_INTERVAL_MS = 30000;
11
+ function truncate(str, max) {
12
+ if (str.length <= max)
13
+ return str;
14
+ return str.slice(0, max) + '...';
15
+ }
16
+ export class Sidecar {
17
+ config;
18
+ logLevel;
19
+ mcpClient = null;
20
+ ws = null;
21
+ heartbeatTimer = null;
22
+ reconnectTimer = null;
23
+ tools = [];
24
+ serverName = '';
25
+ serverVersion = '';
26
+ closed = false;
27
+ constructor(config) {
28
+ if (!config.token)
29
+ throw new Error('token is required');
30
+ if (!config.server && !config.command)
31
+ throw new Error('Either server or command is required');
32
+ if (!config.description)
33
+ throw new Error('description is required');
34
+ this.config = {
35
+ ...config,
36
+ url: config.url ?? DEFAULT_URL,
37
+ reconnect: config.reconnect ?? true,
38
+ reconnectIntervalMs: config.reconnectIntervalMs ?? DEFAULT_RECONNECT_INTERVAL_MS,
39
+ heartbeatIntervalMs: config.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS,
40
+ };
41
+ this.logLevel = config.logLevel ?? 'normal';
42
+ }
43
+ async connect() {
44
+ // Step 1: Connect to MCP server and discover tools
45
+ await this.connectMcpServer();
46
+ // Step 2: Connect to coop.tech WebSocket
47
+ await this.connectWebSocket();
48
+ }
49
+ async close() {
50
+ this.closed = true;
51
+ this.stopHeartbeat();
52
+ if (this.reconnectTimer) {
53
+ clearTimeout(this.reconnectTimer);
54
+ this.reconnectTimer = null;
55
+ }
56
+ if (this.ws) {
57
+ this.ws.close(1000, 'Client closing');
58
+ this.ws = null;
59
+ }
60
+ if (this.mcpClient) {
61
+ await this.mcpClient.close().catch(() => { });
62
+ this.mcpClient = null;
63
+ }
64
+ }
65
+ async connectMcpServer() {
66
+ this.mcpClient = new Client({ name: 'coop-sidecar', version: packageVersion });
67
+ if (this.config.server) {
68
+ // Library mode: in-process McpServer
69
+ const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
70
+ await this.config.server.connect(serverTransport);
71
+ await this.mcpClient.connect(clientTransport);
72
+ }
73
+ else if (this.config.command) {
74
+ // Stdio mode: spawn external process
75
+ const transport = new StdioClientTransport({
76
+ command: this.config.command,
77
+ args: this.config.args,
78
+ env: this.config.env ? { ...process.env, ...this.config.env } : undefined,
79
+ cwd: this.config.cwd,
80
+ });
81
+ await this.mcpClient.connect(transport);
82
+ }
83
+ const mcpServerVersion = this.mcpClient.getServerVersion();
84
+ // Use config overrides or fall back to MCP server values
85
+ this.serverName = this.config.name ?? mcpServerVersion?.name ?? '';
86
+ this.serverVersion = this.config.version ?? mcpServerVersion?.version ?? '';
87
+ if (!this.serverName) {
88
+ throw new Error('MCP server must return a name in its initialize response (serverInfo.name), or provide a name via config');
89
+ }
90
+ // Discover tools
91
+ const result = await this.mcpClient.listTools();
92
+ this.tools = result.tools.map((t) => ({
93
+ name: t.name,
94
+ description: t.description,
95
+ inputSchema: t.inputSchema,
96
+ }));
97
+ if (this.logLevel !== 'quiet') {
98
+ console.log(`[coop-sidecar] Discovered ${this.tools.length} tool(s) from MCP server "${this.serverName}"`);
99
+ }
100
+ }
101
+ connectWebSocket() {
102
+ return new Promise((resolve, reject) => {
103
+ const ws = new WebSocket(this.config.url);
104
+ this.ws = ws;
105
+ let authenticated = false;
106
+ ws.on('open', () => {
107
+ // Send auth message
108
+ ws.send(JSON.stringify({
109
+ type: 'auth',
110
+ token: this.config.token,
111
+ serverName: this.serverName,
112
+ description: this.config.description,
113
+ instructions: this.config.instructions,
114
+ tools: this.tools,
115
+ version: this.serverVersion,
116
+ sdkVersion: packageVersion,
117
+ }));
118
+ });
119
+ ws.on('message', (rawData) => {
120
+ let msg;
121
+ try {
122
+ const text = typeof rawData === 'string' ? rawData : Buffer.from(rawData).toString();
123
+ msg = JSON.parse(text);
124
+ }
125
+ catch {
126
+ return;
127
+ }
128
+ if (msg.type === 'auth_ok') {
129
+ authenticated = true;
130
+ this.startHeartbeat();
131
+ if (this.logLevel !== 'quiet') {
132
+ console.log(`[coop-sidecar] Connected as "${this.serverName}" (id: ${msg.serverId})`);
133
+ }
134
+ this.config.onConnect?.();
135
+ resolve();
136
+ return;
137
+ }
138
+ if (msg.type === 'auth_error') {
139
+ const err = new Error(`Authentication failed: ${msg.message}`);
140
+ this.config.onError?.(err);
141
+ reject(err);
142
+ return;
143
+ }
144
+ if (msg.type === 'tool_call') {
145
+ void this.handleToolCall(msg);
146
+ return;
147
+ }
148
+ if (msg.type === 'pong') {
149
+ return;
150
+ }
151
+ });
152
+ ws.on('close', () => {
153
+ this.stopHeartbeat();
154
+ this.config.onDisconnect?.();
155
+ if (!this.closed && this.config.reconnect && authenticated) {
156
+ if (this.logLevel !== 'quiet') {
157
+ console.log(`[coop-sidecar] Disconnected. Reconnecting in ${this.config.reconnectIntervalMs}ms...`);
158
+ }
159
+ this.reconnectTimer = setTimeout(() => {
160
+ this.connectWebSocket().catch((err) => {
161
+ this.config.onError?.(err instanceof Error ? err : new Error(String(err)));
162
+ });
163
+ }, this.config.reconnectIntervalMs);
164
+ }
165
+ });
166
+ ws.on('error', (err) => {
167
+ this.config.onError?.(err);
168
+ if (!authenticated) {
169
+ reject(err);
170
+ }
171
+ });
172
+ });
173
+ }
174
+ async handleToolCall(msg) {
175
+ if (!this.mcpClient || !this.ws)
176
+ return;
177
+ const maxLen = this.logLevel === 'verbose' ? Infinity : 100;
178
+ if (this.logLevel !== 'quiet') {
179
+ const argsStr = truncate(JSON.stringify(msg.arguments), maxLen);
180
+ console.log(`[coop-sidecar] Tool call: ${msg.name} args=${argsStr}`);
181
+ }
182
+ try {
183
+ const result = await this.mcpClient.callTool({
184
+ name: msg.name,
185
+ arguments: msg.arguments,
186
+ });
187
+ const content = Array.isArray(result.content)
188
+ ? result.content
189
+ : [{ type: 'text', text: String(result.content) }];
190
+ if (this.logLevel !== 'quiet') {
191
+ const contentStr = truncate(JSON.stringify(content), maxLen);
192
+ console.log(`[coop-sidecar] Tool result: ${msg.name} isError=${Boolean(result.isError)} content=${contentStr}`);
193
+ }
194
+ this.ws.send(JSON.stringify({
195
+ type: 'tool_result',
196
+ requestId: msg.requestId,
197
+ result: { content, isError: result.isError },
198
+ }));
199
+ }
200
+ catch (error) {
201
+ const errorMessage = error instanceof Error ? error.message : String(error);
202
+ if (this.logLevel !== 'quiet') {
203
+ console.log(`[coop-sidecar] Tool error: ${msg.name} ${errorMessage}`);
204
+ }
205
+ this.ws?.send(JSON.stringify({
206
+ type: 'tool_result',
207
+ requestId: msg.requestId,
208
+ result: {
209
+ content: [{ type: 'text', text: `Error: ${errorMessage}` }],
210
+ isError: true,
211
+ },
212
+ }));
213
+ }
214
+ }
215
+ startHeartbeat() {
216
+ this.stopHeartbeat();
217
+ this.heartbeatTimer = setInterval(() => {
218
+ if (this.ws?.readyState === WebSocket.OPEN) {
219
+ this.ws.send(JSON.stringify({ type: 'ping' }));
220
+ }
221
+ }, this.config.heartbeatIntervalMs);
222
+ }
223
+ stopHeartbeat() {
224
+ if (this.heartbeatTimer) {
225
+ clearInterval(this.heartbeatTimer);
226
+ this.heartbeatTimer = null;
227
+ }
228
+ }
229
+ }
@@ -0,0 +1,47 @@
1
+ export interface AuthMessage {
2
+ type: 'auth';
3
+ token: string;
4
+ serverName: string;
5
+ description: string;
6
+ instructions?: string;
7
+ tools: ToolInfo[];
8
+ version: string;
9
+ sdkVersion: string;
10
+ }
11
+ export interface ToolResultMessage {
12
+ type: 'tool_result';
13
+ requestId: string;
14
+ result: {
15
+ content: Array<{
16
+ type: string;
17
+ text: string;
18
+ }>;
19
+ isError?: boolean;
20
+ };
21
+ }
22
+ export interface PingMessage {
23
+ type: 'ping';
24
+ }
25
+ export interface AuthOkMessage {
26
+ type: 'auth_ok';
27
+ serverId: string;
28
+ }
29
+ export interface AuthErrorMessage {
30
+ type: 'auth_error';
31
+ message: string;
32
+ }
33
+ export interface ToolCallMessage {
34
+ type: 'tool_call';
35
+ requestId: string;
36
+ name: string;
37
+ arguments: Record<string, unknown>;
38
+ }
39
+ export interface PongMessage {
40
+ type: 'pong';
41
+ }
42
+ export interface ToolInfo {
43
+ name: string;
44
+ description?: string;
45
+ inputSchema: Record<string, unknown>;
46
+ }
47
+ export type ServerMessage = AuthOkMessage | AuthErrorMessage | ToolCallMessage | PongMessage;
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@coop-tech/mcp-sidecar",
3
+ "version": "0.0.1",
4
+ "description": "Connect MCP servers to coop.tech as sidecars",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "bin": {
9
+ "coop-mcp-sidecar": "./dist/cli.js"
10
+ },
11
+ "files": [
12
+ "dist"
13
+ ],
14
+ "scripts": {
15
+ "build": "tsc",
16
+ "prepublishOnly": "pnpm build"
17
+ },
18
+ "peerDependencies": {
19
+ "@modelcontextprotocol/sdk": ">=1.9.0"
20
+ },
21
+ "dependencies": {
22
+ "ws": "8.19.0"
23
+ },
24
+ "devDependencies": {
25
+ "@modelcontextprotocol/sdk": "1.27.1",
26
+ "@types/ws": "8.18.1",
27
+ "typescript": "5.9.3"
28
+ },
29
+ "keywords": [
30
+ "mcp",
31
+ "sidecar",
32
+ "coop"
33
+ ],
34
+ "license": "MIT"
35
+ }