Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/mcp-proxy/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@arvoretech/mcp-proxy",
"version": "1.11.0",
"version": "1.12.0",
"description": "MCP Proxy Gateway - Intelligent proxy that reduces token usage by exposing only mcp_search and mcp_call",
"main": "dist/index.js",
"type": "module",
Expand Down
183 changes: 183 additions & 0 deletions packages/mcp-proxy/src/bridge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
import {
CallParamsSchema,
SearchParamsSchema,
SchemaParamsSchema,
type McpToolResult,
} from "./types.js";

export class BridgeServer {
private readonly server: McpServer;
private client: Client | null = null;
private clientPromise: Promise<Client> | null = null;
private readonly primaryUrl: string;

constructor(port: number) {
this.primaryUrl = `http://127.0.0.1:${port}/mcp`;
this.server = new McpServer({
name: "mcp-proxy-bridge",
version: "1.0.0",
});
this.setupTools();
}

private setupTools(): void {
this.server.registerTool(
"mcp_search",
{
title: "Search MCP Tools",
description:
"Discover available tools across all connected MCP servers. Returns a short list of relevant tools with refs and usage hints. Use this before mcp_call to find the right tool.",
inputSchema: {
query: SearchParamsSchema.shape.query,
limit: SearchParamsSchema.shape.limit,
},
},
async (params) => this.forward("mcp_search", params),
);

this.server.registerTool(
"mcp_call",
{
title: "Call MCP Tool",
description: [
"Execute a tool on an upstream MCP server. Use the ref from mcp_search results. Returns normalized, token-efficient output with pagination support.",
"",
"IMPORTANT — Output shaping behavior:",
"• By default (detail=false), the proxy STRIPS metadata fields (id, url, created_at, updated_at, etc.), TRUNCATES text fields to 500 chars, and LIMITS arrays to 5 items. This saves tokens but may hide important data.",
"• When detail=true, ALL fields are preserved (nothing is stripped), text fields are truncated at 1500 chars, and arrays are returned in full. Use this when you need complete data — e.g. thread messages, full API responses, or when default output seems incomplete.",
"",
"Rule of thumb: if the default call returns fewer items or less data than expected, retry with detail=true.",
].join("\n"),
inputSchema: {
ref: CallParamsSchema.shape.ref,
args: CallParamsSchema.shape.args,
page_cursor: CallParamsSchema.shape.page_cursor,
detail: CallParamsSchema.shape.detail,
},
},
async (params) => this.forward("mcp_call", params),
);

this.server.registerTool(
"mcp_schema",
{
title: "Get Tool Schema",
description:
"Get the full input schema for a tool. Use the ref from mcp_search results to see all parameters, types, and required fields before calling mcp_call.",
inputSchema: {
ref: SchemaParamsSchema.shape.ref,
},
},
async (params) => this.forward("mcp_schema", params),
);
}

private async ensureClient(): Promise<Client> {
if (this.client) return this.client;
if (this.clientPromise) return this.clientPromise;

this.clientPromise = (async () => {
const url = new URL(this.primaryUrl);
let client = new Client({
name: "mcp-proxy-bridge-client",
version: "1.0.0",
});

try {
const transport = new StreamableHTTPClientTransport(url);
await client.connect(transport);
} catch {
console.error("[bridge] StreamableHTTP failed, trying SSE...");
client = new Client({
name: "mcp-proxy-bridge-client",
version: "1.0.0",
});
const sseTransport = new SSEClientTransport(url);
await client.connect(sseTransport);
}

console.error(`[bridge] Connected to primary at ${this.primaryUrl}`);
this.client = client;
return client;
})().catch((err) => {
this.clientPromise = null;
throw err;
});

return this.clientPromise;
}

private async forward(
toolName: string,
args: Record<string, unknown>,
): Promise<McpToolResult> {
try {
const client = await this.ensureClient();
const result = await client.callTool({
name: toolName,
arguments: args,
});

if (result.content && Array.isArray(result.content)) {
return { content: result.content as McpToolResult["content"] };
}

return {
content: [{ type: "text", text: JSON.stringify(result) }],
};
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
console.error(`[bridge] Forward failed: ${msg}`);

this.client = null;

return {
content: [
{
type: "text",
text: JSON.stringify({
error: `Bridge forward failed: ${msg}`,
}),
},
],
};
}
}

async start(): Promise<void> {
const transport = new StdioServerTransport();
await this.server.connect(transport);
console.error(`[bridge] Stdio transport connected, forwarding to primary at ${this.primaryUrl}`);
}

async cleanup(): Promise<void> {
try {
if (this.client) {
await this.client.close();
this.client = null;
this.clientPromise = null;
}
} catch (error) {
console.error(
"[bridge] Error during cleanup:",
error instanceof Error ? error.message : error,
);
}
}

setupGracefulShutdown(): void {
const shutdown = async (signal: string): Promise<void> => {
console.error(`[bridge] Received ${signal}, shutting down...`);
await this.cleanup();
process.exit(0);
};

process.on("SIGINT", () => shutdown("SIGINT"));
process.on("SIGTERM", () => shutdown("SIGTERM"));
}
}
89 changes: 69 additions & 20 deletions packages/mcp-proxy/src/dashboard.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,33 @@
import { createServer, type Server } from "node:http";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import type { McpConnectorManager } from "./connector.js";
import type { ToolRegistry } from "./registry.js";
import type { AuditLogger } from "./logger.js";

function readVersion(): string {
try {
const dir = dirname(fileURLToPath(import.meta.url));
const pkg = JSON.parse(readFileSync(join(dir, "..", "package.json"), "utf-8"));
return pkg.version || "unknown";
} catch {
return "unknown";
}
}

export class Dashboard {
private server: Server | null = null;
private readonly version: string;

constructor(
private readonly connector: McpConnectorManager,
private readonly registry: ToolRegistry,
private readonly logger: AuditLogger,
private port: number = 9100
) {}
) {
this.version = readVersion();
}

start(): void {
this.server = createServer((req, res) => {
Expand Down Expand Up @@ -42,6 +58,7 @@ export class Dashboard {
private getData() {
const statuses = this.connector.getStatuses();
return {
version: this.version,
upstreams: statuses.map((s) => ({
...s,
tools: this.registry.getByProvider(s.name).map((t) => ({
Expand All @@ -65,15 +82,25 @@ export class Dashboard {
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:system-ui,-apple-system,sans-serif;background:#0f1117;color:#e1e4e8;padding:24px}
h1{font-size:1.4rem;margin-bottom:20px;color:#58a6ff}
.header{display:flex;align-items:baseline;gap:10px;margin-bottom:20px}
.header h1{font-size:1.4rem;color:#58a6ff}
.header .version{font-size:.8rem;color:#8b949e}
.grid{display:grid;gap:16px;grid-template-columns:repeat(auto-fill,minmax(400px,1fr))}
.card{background:#161b22;border:1px solid #30363d;border-radius:8px;padding:16px;overflow:hidden}
.card h2{font-size:1rem;margin-bottom:8px;display:flex;align-items:center;gap:8px}
.card{background:#161b22;border:1px solid #30363d;border-radius:8px;overflow:hidden}
.card-header{display:flex;align-items:center;gap:8px;padding:12px 16px;cursor:pointer;user-select:none}
.card-header:hover{background:#1c2129}
.card-header h2{font-size:1rem;flex:1;display:flex;align-items:center;gap:8px}
.card-header .tools-count{color:#8b949e;font-size:.8rem;font-weight:400}
.card-header .chevron{color:#8b949e;font-size:.75rem;transition:transform .2s}
.card-header .chevron.open{transform:rotate(90deg)}
.card-body{padding:0 16px 16px;display:none}
.card-body.open{display:block}
.badge{display:inline-block;padding:2px 8px;border-radius:12px;font-size:.75rem;font-weight:600}
.connected{background:#238636;color:#fff}
.idle{background:#30363d;color:#c9d1d9}
.error{background:#da3633;color:#fff}
.connecting{background:#d29922;color:#000}
.tools{margin-top:12px}
.connecting,.activating{background:#d29922;color:#000}
.tools{margin-top:8px}
.tool{background:#0d1117;border:1px solid #21262d;border-radius:4px;padding:8px 10px;margin-top:6px;font-size:.85rem}
.tool .name{color:#79c0ff;font-weight:600}
.tool .desc{color:#8b949e;margin-top:2px;font-size:.8rem}
Expand All @@ -91,25 +118,42 @@ h3{font-size:.85rem;color:#8b949e;margin-top:12px;margin-bottom:4px}
</style>
</head>
<body>
<h1>MCP Proxy Dashboard</h1>
<div class="header">
<h1>MCP Proxy Dashboard</h1>
<span class="version" id="version"></span>
</div>
<button class="refresh" onclick="load()">Refresh</button>
<div class="grid" id="grid"></div>
<div class="audit" id="audit"></div>
<script>
async function load(){
const r=await fetch('/api/status');
const d=await r.json();
const expanded=new Set();
function toggle(name){
if(expanded.has(name))expanded.delete(name);else expanded.add(name);
render(window._data);
}
function render(d){
if(!d)return;
window._data=d;
document.getElementById('version').textContent='v'+d.version;
const grid=document.getElementById('grid');
grid.innerHTML=d.upstreams.map(u=>\`
<div class="card">
<h2>\${esc(u.name)} <span class="badge \${u.status}">\${u.status}</span></h2>
<div class="meta">Transport: \${u.transport} | Tools: \${u.toolCount}</div>
\${u.tools.length?\`<h3>Tools</h3><div class="tools">\${u.tools.map(t=>\`
<div class="tool"><span class="name">\${esc(t.name)}</span><div class="desc">\${esc(t.description)}</div></div>
\`).join('')}</div>\`:''}
\${u.logs.length?\`<h3>Logs</h3><div class="logs">\${esc(u.logs.join('\\n'))}</div>\`:''}
</div>
\`).join('');
grid.innerHTML=d.upstreams.map(u=>{
const isOpen=expanded.has(u.name);
return \`<div class="card">
<div class="card-header" onclick="toggle('\${esc(u.name)}')">
<h2>\${esc(u.name)} <span class="badge \${u.status}">\${u.status}</span>
<span class="tools-count">\${u.toolCount} tools</span></h2>
<span class="chevron \${isOpen?'open':''}">&#9654;</span>
</div>
\${isOpen?\`<div class="card-body open">
<div class="meta">Transport: \${u.transport}</div>
\${u.error?\`<div class="error-msg">\${esc(u.error)}</div>\`:''}
\${u.tools.length?\`<h3>Tools</h3><div class="tools">\${u.tools.map(t=>\`
<div class="tool"><span class="name">\${esc(t.name)}</span><div class="desc">\${esc(t.description)}</div></div>
\`).join('')}</div>\`:''}
\${u.logs.length?\`<h3>Logs</h3><div class="logs">\${esc(u.logs.join('\\n'))}</div>\`:''}
</div>\`:''}
</div>\`;
}).join('');
const audit=document.getElementById('audit');
if(d.recentLogs.length){
audit.innerHTML=\`<h3>Recent Audit Log</h3><table>
Expand All @@ -122,6 +166,11 @@ async function load(){
</table>\`;
}
}
async function load(){
const r=await fetch('/api/status');
const d=await r.json();
render(d);
}
function esc(s){const d=document.createElement('div');d.textContent=s;return d.innerHTML}
load();setInterval(load,5000);
</script>
Expand Down
49 changes: 46 additions & 3 deletions packages/mcp-proxy/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,52 @@
#!/usr/bin/env node

import { McpProxyServer } from "./server.js";
import { BridgeServer } from "./bridge.js";
import { tryAcquireLock, readLock, releaseLock } from "./singleton.js";

const DEFAULT_SINGLETON_PORT = 9200;

function getSingletonPort(): number {
return parseInt(
process.env.MCP_PROXY_SINGLETON_PORT || String(DEFAULT_SINGLETON_PORT),
10,
);
}

try {
const server = McpProxyServer.fromEnvironment();
server.setupGracefulShutdown();
await server.start();
const port = getSingletonPort();
const acquired = tryAcquireLock(port);

if (acquired) {
console.error(`[singleton] PRIMARY mode (pid ${process.pid}, port ${port})`);
const server = McpProxyServer.fromEnvironment();

const originalCleanup = server.cleanup.bind(server);
server.cleanup = async () => {
try {
await originalCleanup();
} finally {
releaseLock();
}
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.

server.setupGracefulShutdown();
await server.start();
server.startHttpTransport(port);
} else {
const lock = readLock();
if (!lock) {
console.error("[singleton] Lock exists but unreadable, exiting");
process.exit(1);
}

console.error(
`[singleton] BRIDGE mode → primary at pid ${lock.pid}, port ${lock.port}`,
);
const bridge = new BridgeServer(lock.port);
bridge.setupGracefulShutdown();
await bridge.start();
}
} catch (error) {
console.error("Failed to start MCP Proxy Gateway:", error);
if (error instanceof Error && error.stack) {
Expand All @@ -23,4 +64,6 @@ export { OutputShaper } from "./output-shaper.js";
export { PaginationManager } from "./pagination.js";
export { AuditLogger } from "./logger.js";
export { Dashboard } from "./dashboard.js";
export { BridgeServer } from "./bridge.js";
export { tryAcquireLock, readLock, releaseLock } from "./singleton.js";
export * from "./types.js";
Loading
Loading