@yawlabs/mcp-connect 0.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.
Files changed (2) hide show
  1. package/dist/index.js +508 -0
  2. package/package.json +59 -0
package/dist/index.js ADDED
@@ -0,0 +1,508 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/config.ts
4
+ import { request } from "undici";
5
+
6
+ // src/logger.ts
7
+ function log(level, msg, data) {
8
+ const entry = JSON.stringify({ level, msg, ts: (/* @__PURE__ */ new Date()).toISOString(), ...data });
9
+ process.stderr.write(entry + "\n");
10
+ }
11
+
12
+ // src/config.ts
13
+ async function fetchConfig(apiUrl2, token2) {
14
+ const url = apiUrl2.replace(/\/$/, "") + "/api/connect/config";
15
+ const res = await request(url, {
16
+ method: "GET",
17
+ headers: {
18
+ Authorization: "Bearer " + token2,
19
+ Accept: "application/json"
20
+ },
21
+ headersTimeout: 1e4,
22
+ bodyTimeout: 1e4
23
+ });
24
+ if (res.statusCode === 401) {
25
+ throw new ConfigError("Invalid MCP_HOSTING_TOKEN \u2014 check your token at mcp.hosting", true);
26
+ }
27
+ if (res.statusCode === 403) {
28
+ throw new ConfigError("Access denied \u2014 your token may have expired", true);
29
+ }
30
+ if (res.statusCode !== 200) {
31
+ const body = await res.body.text().catch(() => "");
32
+ throw new ConfigError("Config fetch failed (HTTP " + res.statusCode + "): " + body, false);
33
+ }
34
+ const data = await res.body.json();
35
+ if (!data.servers || !Array.isArray(data.servers)) {
36
+ throw new ConfigError("Invalid config response from server", false);
37
+ }
38
+ log("info", "Config loaded", { serverCount: data.servers.length, version: data.configVersion });
39
+ return data;
40
+ }
41
+ var ConfigError = class extends Error {
42
+ constructor(message, fatal) {
43
+ super(message);
44
+ this.fatal = fatal;
45
+ this.name = "ConfigError";
46
+ }
47
+ fatal;
48
+ };
49
+
50
+ // src/server.ts
51
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
52
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
53
+ import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
54
+
55
+ // src/meta-tools.ts
56
+ var META_TOOLS = {
57
+ discover: {
58
+ name: "mcp_connect_discover",
59
+ description: "List all available MCP servers configured in your mcp.hosting account. Shows server names, namespaces, types, and whether they are currently active. Call this first to see what servers are available before activating them.",
60
+ inputSchema: {
61
+ type: "object",
62
+ properties: {}
63
+ },
64
+ annotations: {
65
+ title: "Discover MCP Servers",
66
+ readOnlyHint: true,
67
+ destructiveHint: false,
68
+ idempotentHint: true,
69
+ openWorldHint: false
70
+ }
71
+ },
72
+ activate: {
73
+ name: "mcp_connect_activate",
74
+ description: 'Activate an MCP server by its namespace. This connects to the server and makes its tools available. After activation, the tool list will update with the new tools prefixed by the namespace (e.g., "gh_create_issue" for namespace "gh").',
75
+ inputSchema: {
76
+ type: "object",
77
+ properties: {
78
+ server: {
79
+ type: "string",
80
+ description: 'The namespace of the server to activate (e.g., "gh", "slack", "stripe")'
81
+ }
82
+ },
83
+ required: ["server"]
84
+ },
85
+ annotations: {
86
+ title: "Activate MCP Server",
87
+ readOnlyHint: false,
88
+ destructiveHint: false,
89
+ idempotentHint: true,
90
+ openWorldHint: false
91
+ }
92
+ },
93
+ deactivate: {
94
+ name: "mcp_connect_deactivate",
95
+ description: "Deactivate an MCP server by its namespace. This disconnects from the server and removes its tools from the available tool list. Use this to free up context when you no longer need a server.",
96
+ inputSchema: {
97
+ type: "object",
98
+ properties: {
99
+ server: {
100
+ type: "string",
101
+ description: "The namespace of the server to deactivate"
102
+ }
103
+ },
104
+ required: ["server"]
105
+ },
106
+ annotations: {
107
+ title: "Deactivate MCP Server",
108
+ readOnlyHint: false,
109
+ destructiveHint: false,
110
+ idempotentHint: true,
111
+ openWorldHint: false
112
+ }
113
+ }
114
+ };
115
+ var META_TOOL_NAMES = /* @__PURE__ */ new Set([
116
+ META_TOOLS.discover.name,
117
+ META_TOOLS.activate.name,
118
+ META_TOOLS.deactivate.name
119
+ ]);
120
+
121
+ // src/proxy.ts
122
+ function buildToolList(activeConnections) {
123
+ const tools = [];
124
+ for (const meta of Object.values(META_TOOLS)) {
125
+ tools.push({
126
+ name: meta.name,
127
+ description: meta.description,
128
+ inputSchema: meta.inputSchema,
129
+ annotations: meta.annotations
130
+ });
131
+ }
132
+ for (const conn of activeConnections.values()) {
133
+ for (const tool of conn.tools) {
134
+ tools.push({
135
+ name: tool.namespacedName,
136
+ description: tool.description,
137
+ inputSchema: tool.inputSchema,
138
+ annotations: tool.annotations
139
+ });
140
+ }
141
+ }
142
+ return tools;
143
+ }
144
+ function buildToolRoutes(activeConnections) {
145
+ const routes = /* @__PURE__ */ new Map();
146
+ for (const conn of activeConnections.values()) {
147
+ for (const tool of conn.tools) {
148
+ routes.set(tool.namespacedName, {
149
+ namespace: conn.config.namespace,
150
+ originalName: tool.name
151
+ });
152
+ }
153
+ }
154
+ return routes;
155
+ }
156
+ async function routeToolCall(toolName, args, toolRoutes, activeConnections) {
157
+ const route = toolRoutes.get(toolName);
158
+ if (!route) {
159
+ return {
160
+ content: [
161
+ {
162
+ type: "text",
163
+ text: "Unknown tool: " + toolName + ". Use mcp_connect_discover to see available servers, then mcp_connect_activate to load tools."
164
+ }
165
+ ],
166
+ isError: true
167
+ };
168
+ }
169
+ const connection = activeConnections.get(route.namespace);
170
+ if (!connection || connection.status !== "connected") {
171
+ return {
172
+ content: [
173
+ {
174
+ type: "text",
175
+ text: 'Server "' + route.namespace + '" is no longer connected. Use mcp_connect_activate("' + route.namespace + '") to reconnect.'
176
+ }
177
+ ],
178
+ isError: true
179
+ };
180
+ }
181
+ try {
182
+ const result = await connection.client.callTool({
183
+ name: route.originalName,
184
+ arguments: args
185
+ });
186
+ return result;
187
+ } catch (err) {
188
+ log("error", "Tool call failed", { tool: toolName, namespace: route.namespace, error: err.message });
189
+ return {
190
+ content: [{ type: "text", text: "Error calling " + toolName + ": " + err.message }],
191
+ isError: true
192
+ };
193
+ }
194
+ }
195
+
196
+ // src/upstream.ts
197
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
198
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
199
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
200
+ var CONNECT_TIMEOUT = 15e3;
201
+ async function connectToUpstream(config) {
202
+ const client = new Client({ name: "mcp-connect", version: "0.1.0" }, { capabilities: {} });
203
+ let transport;
204
+ if (config.type === "local") {
205
+ if (!config.command) {
206
+ throw new Error("command is required for local servers");
207
+ }
208
+ transport = new StdioClientTransport({
209
+ command: config.command,
210
+ args: config.args ?? [],
211
+ env: { ...process.env, ...config.env },
212
+ stderr: "pipe"
213
+ });
214
+ } else {
215
+ if (!config.url) {
216
+ throw new Error("url is required for remote servers");
217
+ }
218
+ transport = new StreamableHTTPClientTransport(new URL(config.url));
219
+ }
220
+ const connectPromise = client.connect(transport);
221
+ const timeoutPromise = new Promise(
222
+ (_, reject) => setTimeout(() => reject(new Error("Connection timeout after " + CONNECT_TIMEOUT + "ms")), CONNECT_TIMEOUT)
223
+ );
224
+ await Promise.race([connectPromise, timeoutPromise]);
225
+ log("info", "Connected to upstream", { name: config.name, namespace: config.namespace, type: config.type });
226
+ const tools = await fetchToolsFromUpstream(client, config.namespace);
227
+ return {
228
+ config,
229
+ client,
230
+ transport,
231
+ tools,
232
+ status: "connected"
233
+ };
234
+ }
235
+ async function disconnectFromUpstream(connection) {
236
+ try {
237
+ await connection.client.close();
238
+ } catch (err) {
239
+ log("warn", "Error disconnecting from upstream", {
240
+ namespace: connection.config.namespace,
241
+ error: err.message
242
+ });
243
+ }
244
+ connection.status = "disconnected";
245
+ log("info", "Disconnected from upstream", { namespace: connection.config.namespace });
246
+ }
247
+ async function fetchToolsFromUpstream(client, namespace) {
248
+ const result = await client.listTools();
249
+ return (result.tools ?? []).map((tool) => ({
250
+ name: tool.name,
251
+ namespacedName: namespace + "_" + tool.name,
252
+ description: tool.description,
253
+ inputSchema: tool.inputSchema,
254
+ annotations: tool.annotations
255
+ }));
256
+ }
257
+
258
+ // src/server.ts
259
+ var POLL_INTERVAL = 6e4;
260
+ var ConnectServer = class {
261
+ constructor(apiUrl2, token2) {
262
+ this.apiUrl = apiUrl2;
263
+ this.token = token2;
264
+ this.server = new Server(
265
+ { name: "mcp-connect", version: "0.1.0" },
266
+ { capabilities: { tools: { listChanged: true } } }
267
+ );
268
+ this.setupHandlers();
269
+ }
270
+ apiUrl;
271
+ token;
272
+ server;
273
+ connections = /* @__PURE__ */ new Map();
274
+ config = null;
275
+ configVersion = null;
276
+ pollTimer = null;
277
+ toolRoutes = /* @__PURE__ */ new Map();
278
+ setupHandlers() {
279
+ this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
280
+ tools: buildToolList(this.connections)
281
+ }));
282
+ this.server.setRequestHandler(CallToolRequestSchema, async (request2) => {
283
+ const { name, arguments: args } = request2.params;
284
+ return this.handleToolCall(name, args ?? {});
285
+ });
286
+ }
287
+ async start() {
288
+ try {
289
+ await this.fetchAndApplyConfig();
290
+ } catch (err) {
291
+ if (err instanceof ConfigError && err.fatal) {
292
+ throw err;
293
+ }
294
+ log("warn", "Initial config fetch failed, starting with empty config", { error: err.message });
295
+ this.config = { servers: [], configVersion: "" };
296
+ }
297
+ const transport = new StdioServerTransport();
298
+ await this.server.connect(transport);
299
+ this.startPolling();
300
+ log("info", "mcp-connect started", {
301
+ apiUrl: this.apiUrl,
302
+ servers: this.config?.servers.length ?? 0
303
+ });
304
+ }
305
+ async handleToolCall(name, args) {
306
+ if (name === META_TOOLS.discover.name) {
307
+ return this.handleDiscover();
308
+ }
309
+ if (name === META_TOOLS.activate.name) {
310
+ return this.handleActivate(args.server);
311
+ }
312
+ if (name === META_TOOLS.deactivate.name) {
313
+ return this.handleDeactivate(args.server);
314
+ }
315
+ return routeToolCall(name, args, this.toolRoutes, this.connections);
316
+ }
317
+ handleDiscover() {
318
+ if (!this.config || this.config.servers.length === 0) {
319
+ return {
320
+ content: [
321
+ {
322
+ type: "text",
323
+ text: "No servers configured. Add servers at mcp.hosting to get started."
324
+ }
325
+ ]
326
+ };
327
+ }
328
+ const lines = ["Available MCP servers:\n"];
329
+ for (const server2 of this.config.servers) {
330
+ if (!server2.isActive) continue;
331
+ const connection = this.connections.get(server2.namespace);
332
+ const status = connection ? "ACTIVE (" + connection.tools.length + " tools)" : "available";
333
+ lines.push(" " + server2.namespace + " \u2014 " + server2.name + " [" + status + "] (" + server2.type + ")");
334
+ }
335
+ const inactive = this.config.servers.filter((s) => !s.isActive);
336
+ if (inactive.length > 0) {
337
+ lines.push("\nDisabled servers:");
338
+ for (const server2 of inactive) {
339
+ lines.push(" " + server2.namespace + " \u2014 " + server2.name + " (disabled in dashboard)");
340
+ }
341
+ }
342
+ const activeCount = this.connections.size;
343
+ const totalTools = Array.from(this.connections.values()).reduce((sum, c) => sum + c.tools.length, 0);
344
+ lines.push("\n" + activeCount + " active, " + totalTools + " tools loaded.");
345
+ lines.push('Use mcp_connect_activate({ server: "namespace" }) to activate a server.');
346
+ return { content: [{ type: "text", text: lines.join("\n") }] };
347
+ }
348
+ async handleActivate(namespace) {
349
+ if (!namespace) {
350
+ return {
351
+ content: [
352
+ { type: "text", text: "server namespace is required. Use mcp_connect_discover to see available servers." }
353
+ ],
354
+ isError: true
355
+ };
356
+ }
357
+ const existing = this.connections.get(namespace);
358
+ if (existing && existing.status === "connected") {
359
+ const toolNames = existing.tools.map((t) => t.namespacedName).join(", ");
360
+ return {
361
+ content: [
362
+ {
363
+ type: "text",
364
+ text: 'Server "' + namespace + '" is already active with ' + existing.tools.length + " tools: " + toolNames
365
+ }
366
+ ]
367
+ };
368
+ }
369
+ const serverConfig = this.config?.servers.find((s) => s.namespace === namespace && s.isActive);
370
+ if (!serverConfig) {
371
+ return {
372
+ content: [
373
+ {
374
+ type: "text",
375
+ text: 'Server "' + namespace + '" not found or disabled. Use mcp_connect_discover to see available servers.'
376
+ }
377
+ ],
378
+ isError: true
379
+ };
380
+ }
381
+ try {
382
+ const connection = await connectToUpstream(serverConfig);
383
+ this.connections.set(namespace, connection);
384
+ this.toolRoutes = buildToolRoutes(this.connections);
385
+ await this.server.sendToolListChanged();
386
+ const toolNames = connection.tools.map((t) => t.namespacedName).join(", ");
387
+ return {
388
+ content: [
389
+ {
390
+ type: "text",
391
+ text: 'Activated "' + namespace + '" \u2014 ' + connection.tools.length + " tools available: " + toolNames
392
+ }
393
+ ]
394
+ };
395
+ } catch (err) {
396
+ log("error", "Failed to activate upstream", { namespace, error: err.message });
397
+ return {
398
+ content: [{ type: "text", text: 'Failed to activate "' + namespace + '": ' + err.message }],
399
+ isError: true
400
+ };
401
+ }
402
+ }
403
+ async handleDeactivate(namespace) {
404
+ if (!namespace) {
405
+ return {
406
+ content: [{ type: "text", text: "server namespace is required." }],
407
+ isError: true
408
+ };
409
+ }
410
+ const connection = this.connections.get(namespace);
411
+ if (!connection) {
412
+ return {
413
+ content: [{ type: "text", text: 'Server "' + namespace + '" is not active.' }],
414
+ isError: true
415
+ };
416
+ }
417
+ await disconnectFromUpstream(connection);
418
+ this.connections.delete(namespace);
419
+ this.toolRoutes = buildToolRoutes(this.connections);
420
+ await this.server.sendToolListChanged();
421
+ return {
422
+ content: [{ type: "text", text: 'Deactivated "' + namespace + '". Tools removed.' }]
423
+ };
424
+ }
425
+ async fetchAndApplyConfig() {
426
+ const newConfig = await fetchConfig(this.apiUrl, this.token);
427
+ if (newConfig.configVersion === this.configVersion) {
428
+ return;
429
+ }
430
+ await this.reconcileConfig(newConfig);
431
+ this.config = newConfig;
432
+ this.configVersion = newConfig.configVersion;
433
+ }
434
+ async reconcileConfig(newConfig) {
435
+ const newNamespaces = new Set(newConfig.servers.map((s) => s.namespace));
436
+ let changed = false;
437
+ for (const [namespace, connection] of this.connections) {
438
+ const newServerConfig = newConfig.servers.find((s) => s.namespace === namespace);
439
+ if (!newServerConfig || !newServerConfig.isActive) {
440
+ log("info", "Server removed or disabled in config, deactivating", { namespace });
441
+ await disconnectFromUpstream(connection);
442
+ this.connections.delete(namespace);
443
+ changed = true;
444
+ continue;
445
+ }
446
+ const oldConfig = connection.config;
447
+ if (oldConfig.command !== newServerConfig.command || JSON.stringify(oldConfig.args) !== JSON.stringify(newServerConfig.args) || oldConfig.url !== newServerConfig.url || JSON.stringify(oldConfig.env) !== JSON.stringify(newServerConfig.env)) {
448
+ log("info", "Server config changed, deactivating stale connection", { namespace });
449
+ await disconnectFromUpstream(connection);
450
+ this.connections.delete(namespace);
451
+ changed = true;
452
+ }
453
+ }
454
+ if (changed) {
455
+ this.toolRoutes = buildToolRoutes(this.connections);
456
+ await this.server.sendToolListChanged();
457
+ }
458
+ }
459
+ startPolling() {
460
+ this.pollTimer = setInterval(async () => {
461
+ try {
462
+ await this.fetchAndApplyConfig();
463
+ } catch (err) {
464
+ log("warn", "Config poll failed", { error: err.message });
465
+ }
466
+ }, POLL_INTERVAL);
467
+ if (this.pollTimer.unref) {
468
+ this.pollTimer.unref();
469
+ }
470
+ }
471
+ async shutdown() {
472
+ log("info", "Shutting down mcp-connect");
473
+ if (this.pollTimer) {
474
+ clearInterval(this.pollTimer);
475
+ this.pollTimer = null;
476
+ }
477
+ const disconnects = Array.from(this.connections.values()).map((conn) => disconnectFromUpstream(conn));
478
+ await Promise.allSettled(disconnects);
479
+ this.connections.clear();
480
+ await this.server.close();
481
+ log("info", "mcp-connect shutdown complete");
482
+ }
483
+ };
484
+
485
+ // src/index.ts
486
+ var token = process.env.MCP_HOSTING_TOKEN;
487
+ var apiUrl = process.env.MCP_HOSTING_URL ?? "https://mcp.hosting";
488
+ if (!token) {
489
+ process.stderr.write(
490
+ '\n mcp-connect: MCP_HOSTING_TOKEN is required.\n\n 1. Create a token at https://mcp.hosting \u2192 Settings \u2192 API Tokens\n 2. Add it to your MCP client config:\n\n {\n "mcpServers": {\n "mcp-connect": {\n "command": "npx",\n "args": ["@yawlabs/mcp-connect"],\n "env": {\n "MCP_HOSTING_TOKEN": "mcp_pat_your_token_here"\n }\n }\n }\n }\n\n'
491
+ );
492
+ process.exit(1);
493
+ }
494
+ var server = new ConnectServer(apiUrl, token);
495
+ var shutdown = async () => {
496
+ await server.shutdown();
497
+ process.exit(0);
498
+ };
499
+ process.on("SIGTERM", shutdown);
500
+ process.on("SIGINT", shutdown);
501
+ server.start().catch((err) => {
502
+ if (err instanceof ConfigError && err.fatal) {
503
+ process.stderr.write("\n mcp-connect: " + err.message + "\n\n");
504
+ process.exit(1);
505
+ }
506
+ log("error", "Fatal startup error", { error: err.message });
507
+ process.exit(1);
508
+ });
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "@yawlabs/mcp-connect",
3
+ "version": "0.1.0",
4
+ "description": "MCP orchestrator — one install, all your MCP servers, managed from the cloud",
5
+ "license": "MIT",
6
+ "author": "Yaw Labs <contact@yaw.sh> (https://yaw.sh)",
7
+ "type": "module",
8
+ "bin": {
9
+ "mcp-connect": "./dist/index.js"
10
+ },
11
+ "files": [
12
+ "dist",
13
+ "!dist/**/*.test.*",
14
+ "README.md",
15
+ "LICENSE"
16
+ ],
17
+ "scripts": {
18
+ "build": "tsup",
19
+ "dev": "tsup --watch",
20
+ "test": "vitest run",
21
+ "lint": "biome check src/",
22
+ "lint:fix": "biome check --write src/",
23
+ "typecheck": "tsc --noEmit",
24
+ "test:ci": "npm run build && npm test",
25
+ "prepublishOnly": "npm run build",
26
+ "start": "node dist/index.js"
27
+ },
28
+ "dependencies": {
29
+ "@modelcontextprotocol/sdk": "^1.29.0",
30
+ "undici": "^7.8.0"
31
+ },
32
+ "devDependencies": {
33
+ "@biomejs/biome": "^1.9.4",
34
+ "@types/node": "^22.0.0",
35
+ "tsup": "^8.4.0",
36
+ "typescript": "^5.8.3",
37
+ "vitest": "^3.1.1"
38
+ },
39
+ "engines": {
40
+ "node": ">=18"
41
+ },
42
+ "keywords": [
43
+ "mcp",
44
+ "model-context-protocol",
45
+ "orchestrator",
46
+ "mcp-server",
47
+ "ai",
48
+ "mcp-connect",
49
+ "mcp-hosting"
50
+ ],
51
+ "repository": {
52
+ "type": "git",
53
+ "url": "git+https://github.com/YawLabs/mcp-connect.git"
54
+ },
55
+ "bugs": {
56
+ "url": "https://github.com/YawLabs/mcp-connect/issues"
57
+ },
58
+ "homepage": "https://mcp.hosting"
59
+ }