mirror of
https://github.com/samanhappy/mcphub.git
synced 2025-12-24 02:39:19 -05:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
292876a991 | ||
|
|
d6a9146e27 | ||
|
|
1f3a6794ea | ||
|
|
c673afb97e |
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -76,7 +76,7 @@ jobs:
|
|||||||
|
|
||||||
# services:
|
# services:
|
||||||
# postgres:
|
# postgres:
|
||||||
# image: postgres:15
|
# image: pgvector/pgvector:pg17
|
||||||
# env:
|
# env:
|
||||||
# POSTGRES_PASSWORD: postgres
|
# POSTGRES_PASSWORD: postgres
|
||||||
# POSTGRES_DB: mcphub_test
|
# POSTGRES_DB: mcphub_test
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ version: "3.8"
|
|||||||
services:
|
services:
|
||||||
# PostgreSQL database for MCPHub configuration
|
# PostgreSQL database for MCPHub configuration
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:16-alpine
|
image: pgvector/pgvector:pg17-alpine
|
||||||
container_name: mcphub-postgres
|
container_name: mcphub-postgres
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: mcphub
|
POSTGRES_DB: mcphub
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ version: '3.8'
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:16
|
image: pgvector/pgvector:pg17
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: mcphub
|
POSTGRES_DB: mcphub
|
||||||
POSTGRES_USER: mcphub
|
POSTGRES_USER: mcphub
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ services:
|
|||||||
- mcphub-network
|
- mcphub-network
|
||||||
|
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:15-alpine
|
image: pgvector/pgvector:pg17
|
||||||
container_name: mcphub-postgres
|
container_name: mcphub-postgres
|
||||||
environment:
|
environment:
|
||||||
- POSTGRES_DB=mcphub
|
- POSTGRES_DB=mcphub
|
||||||
@@ -203,7 +203,7 @@ services:
|
|||||||
retries: 3
|
retries: 3
|
||||||
|
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:15-alpine
|
image: pgvector/pgvector:pg17
|
||||||
container_name: mcphub-postgres
|
container_name: mcphub-postgres
|
||||||
environment:
|
environment:
|
||||||
- POSTGRES_DB=mcphub
|
- POSTGRES_DB=mcphub
|
||||||
@@ -305,7 +305,7 @@ services:
|
|||||||
- mcphub-dev
|
- mcphub-dev
|
||||||
|
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:15-alpine
|
image: pgvector/pgvector:pg17
|
||||||
container_name: mcphub-postgres-dev
|
container_name: mcphub-postgres-dev
|
||||||
environment:
|
environment:
|
||||||
- POSTGRES_DB=mcphub
|
- POSTGRES_DB=mcphub
|
||||||
@@ -445,7 +445,7 @@ Add backup service to your `docker-compose.yml`:
|
|||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
backup:
|
backup:
|
||||||
image: postgres:15-alpine
|
image: pgvector/pgvector:pg17
|
||||||
container_name: mcphub-backup
|
container_name: mcphub-backup
|
||||||
environment:
|
environment:
|
||||||
- PGPASSWORD=${POSTGRES_PASSWORD}
|
- PGPASSWORD=${POSTGRES_PASSWORD}
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ Smart Routing requires additional setup compared to basic MCPHub usage:
|
|||||||
- ./mcp_settings.json:/app/mcp_settings.json
|
- ./mcp_settings.json:/app/mcp_settings.json
|
||||||
|
|
||||||
postgres:
|
postgres:
|
||||||
image: pgvector/pgvector:pg16
|
image: pgvector/pgvector:pg17
|
||||||
environment:
|
environment:
|
||||||
- POSTGRES_DB=mcphub
|
- POSTGRES_DB=mcphub
|
||||||
- POSTGRES_USER=mcphub
|
- POSTGRES_USER=mcphub
|
||||||
@@ -146,7 +146,7 @@ Smart Routing requires additional setup compared to basic MCPHub usage:
|
|||||||
spec:
|
spec:
|
||||||
containers:
|
containers:
|
||||||
- name: postgres
|
- name: postgres
|
||||||
image: pgvector/pgvector:pg16
|
image: pgvector/pgvector:pg17
|
||||||
env:
|
env:
|
||||||
- name: POSTGRES_DB
|
- name: POSTGRES_DB
|
||||||
value: mcphub
|
value: mcphub
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ Optional for Smart Routing:
|
|||||||
|
|
||||||
# Optional: PostgreSQL for Smart Routing
|
# Optional: PostgreSQL for Smart Routing
|
||||||
postgres:
|
postgres:
|
||||||
image: pgvector/pgvector:pg16
|
image: pgvector/pgvector:pg17
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: mcphub
|
POSTGRES_DB: mcphub
|
||||||
POSTGRES_USER: mcphub
|
POSTGRES_USER: mcphub
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ version: '3.8'
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:16
|
image: pgvector/pgvector:pg17
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: mcphub
|
POSTGRES_DB: mcphub
|
||||||
POSTGRES_USER: mcphub
|
POSTGRES_USER: mcphub
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ services:
|
|||||||
- mcphub-network
|
- mcphub-network
|
||||||
|
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:15-alpine
|
image: pgvector/pgvector:pg17
|
||||||
container_name: mcphub-postgres
|
container_name: mcphub-postgres
|
||||||
environment:
|
environment:
|
||||||
- POSTGRES_DB=mcphub
|
- POSTGRES_DB=mcphub
|
||||||
@@ -203,7 +203,7 @@ services:
|
|||||||
retries: 3
|
retries: 3
|
||||||
|
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:15-alpine
|
image: pgvector/pgvector:pg17
|
||||||
container_name: mcphub-postgres
|
container_name: mcphub-postgres
|
||||||
environment:
|
environment:
|
||||||
- POSTGRES_DB=mcphub
|
- POSTGRES_DB=mcphub
|
||||||
@@ -305,7 +305,7 @@ services:
|
|||||||
- mcphub-dev
|
- mcphub-dev
|
||||||
|
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:15-alpine
|
image: pgvector/pgvector:pg17
|
||||||
container_name: mcphub-postgres-dev
|
container_name: mcphub-postgres-dev
|
||||||
environment:
|
environment:
|
||||||
- POSTGRES_DB=mcphub
|
- POSTGRES_DB=mcphub
|
||||||
@@ -445,7 +445,7 @@ secrets:
|
|||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
backup:
|
backup:
|
||||||
image: postgres:15-alpine
|
image: pgvector/pgvector:pg17
|
||||||
container_name: mcphub-backup
|
container_name: mcphub-backup
|
||||||
environment:
|
environment:
|
||||||
- PGPASSWORD=${POSTGRES_PASSWORD}
|
- PGPASSWORD=${POSTGRES_PASSWORD}
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ description: '各种平台的详细安装说明'
|
|||||||
|
|
||||||
# 可选:用于智能路由的 PostgreSQL
|
# 可选:用于智能路由的 PostgreSQL
|
||||||
postgres:
|
postgres:
|
||||||
image: pgvector/pgvector:pg16
|
image: pgvector/pgvector:pg17
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: mcphub
|
POSTGRES_DB: mcphub
|
||||||
POSTGRES_USER: mcphub
|
POSTGRES_USER: mcphub
|
||||||
|
|||||||
@@ -375,6 +375,7 @@ const ServerForm = ({
|
|||||||
? {
|
? {
|
||||||
url: formData.url,
|
url: formData.url,
|
||||||
...(Object.keys(headers).length > 0 ? { headers } : {}),
|
...(Object.keys(headers).length > 0 ? { headers } : {}),
|
||||||
|
...(Object.keys(env).length > 0 ? { env } : {}),
|
||||||
...(oauthConfig ? { oauth: oauthConfig } : {}),
|
...(oauthConfig ? { oauth: oauthConfig } : {}),
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
@@ -978,6 +979,49 @@ const ServerForm = ({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className="flex justify-between items-center mb-2">
|
||||||
|
<label className="block text-gray-700 text-sm font-bold">
|
||||||
|
{t('server.envVars')}
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={addEnvVar}
|
||||||
|
className="bg-gray-200 hover:bg-gray-300 text-gray-700 font-medium py-1 px-2 rounded text-sm flex items-center justify-center min-w-[30px] min-h-[30px] btn-primary"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{envVars.map((envVar, index) => (
|
||||||
|
<div key={index} className="flex items-center mb-2">
|
||||||
|
<div className="flex items-center space-x-2 flex-grow">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={envVar.key}
|
||||||
|
onChange={(e) => handleEnvVarChange(index, 'key', e.target.value)}
|
||||||
|
className="shadow appearance-none border rounded py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline w-1/2 form-input"
|
||||||
|
placeholder={t('server.key')}
|
||||||
|
/>
|
||||||
|
<span className="flex items-center">:</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={envVar.value}
|
||||||
|
onChange={(e) => handleEnvVarChange(index, 'value', e.target.value)}
|
||||||
|
className="shadow appearance-none border rounded py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline w-1/2 form-input"
|
||||||
|
placeholder={t('server.value')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeEnvVar(index)}
|
||||||
|
className="bg-gray-200 hover:bg-gray-300 text-gray-700 font-medium py-1 px-2 rounded text-sm flex items-center justify-center min-w-[30px] min-h-[30px] ml-2 btn-danger"
|
||||||
|
>
|
||||||
|
-
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<div
|
<div
|
||||||
className="flex items-center justify-between cursor-pointer bg-gray-50 hover:bg-gray-100 p-3 rounded border border-gray-200"
|
className="flex items-center justify-between cursor-pointer bg-gray-50 hover:bg-gray-100 p-3 rounded border border-gray-200"
|
||||||
|
|||||||
@@ -73,6 +73,7 @@
|
|||||||
"postgres": "^3.4.7",
|
"postgres": "^3.4.7",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"typeorm": "^0.3.26",
|
"typeorm": "^0.3.26",
|
||||||
|
"undici": "^7.16.0",
|
||||||
"uuid": "^11.1.0"
|
"uuid": "^11.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
9
pnpm-lock.yaml
generated
9
pnpm-lock.yaml
generated
@@ -99,6 +99,9 @@ importers:
|
|||||||
typeorm:
|
typeorm:
|
||||||
specifier: ^0.3.26
|
specifier: ^0.3.26
|
||||||
version: 0.3.27(pg@8.16.3)(reflect-metadata@0.2.2)(ts-node@10.9.2(@swc/core@1.15.3)(@types/node@24.6.2)(typescript@5.9.2))
|
version: 0.3.27(pg@8.16.3)(reflect-metadata@0.2.2)(ts-node@10.9.2(@swc/core@1.15.3)(@types/node@24.6.2)(typescript@5.9.2))
|
||||||
|
undici:
|
||||||
|
specifier: ^7.16.0
|
||||||
|
version: 7.16.0
|
||||||
uuid:
|
uuid:
|
||||||
specifier: ^11.1.0
|
specifier: ^11.1.0
|
||||||
version: 11.1.0
|
version: 11.1.0
|
||||||
@@ -4431,6 +4434,10 @@ packages:
|
|||||||
undici-types@7.13.0:
|
undici-types@7.13.0:
|
||||||
resolution: {integrity: sha512-Ov2Rr9Sx+fRgagJ5AX0qvItZG/JKKoBRAVITs1zk7IqZGTJUwgUr7qoYBpWwakpWilTZFM98rG/AFRocu10iIQ==}
|
resolution: {integrity: sha512-Ov2Rr9Sx+fRgagJ5AX0qvItZG/JKKoBRAVITs1zk7IqZGTJUwgUr7qoYBpWwakpWilTZFM98rG/AFRocu10iIQ==}
|
||||||
|
|
||||||
|
undici@7.16.0:
|
||||||
|
resolution: {integrity: sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g==}
|
||||||
|
engines: {node: '>=20.18.1'}
|
||||||
|
|
||||||
universalify@2.0.1:
|
universalify@2.0.1:
|
||||||
resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==}
|
resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==}
|
||||||
engines: {node: '>= 10.0.0'}
|
engines: {node: '>= 10.0.0'}
|
||||||
@@ -8946,6 +8953,8 @@ snapshots:
|
|||||||
|
|
||||||
undici-types@7.13.0: {}
|
undici-types@7.13.0: {}
|
||||||
|
|
||||||
|
undici@7.16.0: {}
|
||||||
|
|
||||||
universalify@2.0.1: {}
|
universalify@2.0.1: {}
|
||||||
|
|
||||||
unpipe@1.0.0: {}
|
unpipe@1.0.0: {}
|
||||||
|
|||||||
@@ -24,7 +24,10 @@ export class BearerKeyDaoImpl extends JsonFileBaseDao implements BearerKeyDao {
|
|||||||
private async loadKeysWithMigration(): Promise<BearerKey[]> {
|
private async loadKeysWithMigration(): Promise<BearerKey[]> {
|
||||||
const settings = await this.loadSettings();
|
const settings = await this.loadSettings();
|
||||||
|
|
||||||
if (Array.isArray(settings.bearerKeys) && settings.bearerKeys.length > 0) {
|
// Treat an existing array (including an empty array) as already migrated.
|
||||||
|
// Otherwise, when there are no configured keys, we'd rewrite mcp_settings.json
|
||||||
|
// on every request, which also clears the global settings cache.
|
||||||
|
if (Array.isArray(settings.bearerKeys)) {
|
||||||
return settings.bearerKeys;
|
return settings.bearerKeys;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -325,7 +325,7 @@ export class MCPHubOAuthProvider implements OAuthClientProvider {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Saving OAuth tokens for server: ${this.serverName}`);
|
console.log(`Saving OAuth tokens: ${JSON.stringify(tokens)} for server: ${this.serverName}`);
|
||||||
|
|
||||||
const updatedConfig = await persistTokens(this.serverName, {
|
const updatedConfig = await persistTokens(this.serverName, {
|
||||||
accessToken: tokens.access_token,
|
accessToken: tokens.access_token,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
StreamableHTTPClientTransport,
|
StreamableHTTPClientTransport,
|
||||||
StreamableHTTPClientTransportOptions,
|
StreamableHTTPClientTransportOptions,
|
||||||
} from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
} from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
||||||
|
import { createFetchWithProxy, getProxyConfigFromEnv } from './proxy.js';
|
||||||
import { ServerInfo, ServerConfig, Tool } from '../types/index.js';
|
import { ServerInfo, ServerConfig, Tool } from '../types/index.js';
|
||||||
import { expandEnvVars, replaceEnvVars, getNameSeparator } from '../config/index.js';
|
import { expandEnvVars, replaceEnvVars, getNameSeparator } from '../config/index.js';
|
||||||
import config from '../config/index.js';
|
import config from '../config/index.js';
|
||||||
@@ -134,6 +135,10 @@ export const cleanupAllServers = (): void => {
|
|||||||
// Helper function to create transport based on server configuration
|
// Helper function to create transport based on server configuration
|
||||||
export const createTransportFromConfig = async (name: string, conf: ServerConfig): Promise<any> => {
|
export const createTransportFromConfig = async (name: string, conf: ServerConfig): Promise<any> => {
|
||||||
let transport;
|
let transport;
|
||||||
|
const env: Record<string, string> = {
|
||||||
|
...(process.env as Record<string, string>),
|
||||||
|
...replaceEnvVars(conf.env || {}),
|
||||||
|
};
|
||||||
|
|
||||||
if (conf.type === 'streamable-http') {
|
if (conf.type === 'streamable-http') {
|
||||||
const options: StreamableHTTPClientTransportOptions = {};
|
const options: StreamableHTTPClientTransportOptions = {};
|
||||||
@@ -152,6 +157,8 @@ export const createTransportFromConfig = async (name: string, conf: ServerConfig
|
|||||||
console.log(`OAuth provider configured for server: ${name}`);
|
console.log(`OAuth provider configured for server: ${name}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
options.fetch = createFetchWithProxy(getProxyConfigFromEnv(env));
|
||||||
|
|
||||||
transport = new StreamableHTTPClientTransport(new URL(conf.url || ''), options);
|
transport = new StreamableHTTPClientTransport(new URL(conf.url || ''), options);
|
||||||
} else if (conf.url) {
|
} else if (conf.url) {
|
||||||
// SSE transport
|
// SSE transport
|
||||||
@@ -174,13 +181,11 @@ export const createTransportFromConfig = async (name: string, conf: ServerConfig
|
|||||||
console.log(`OAuth provider configured for server: ${name}`);
|
console.log(`OAuth provider configured for server: ${name}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
options.fetch = createFetchWithProxy(getProxyConfigFromEnv(env));
|
||||||
|
|
||||||
transport = new SSEClientTransport(new URL(conf.url), options);
|
transport = new SSEClientTransport(new URL(conf.url), options);
|
||||||
} else if (conf.command && conf.args) {
|
} else if (conf.command && conf.args) {
|
||||||
// Stdio transport
|
// Stdio transport
|
||||||
const env: Record<string, string> = {
|
|
||||||
...(process.env as Record<string, string>),
|
|
||||||
...replaceEnvVars(conf.env || {}),
|
|
||||||
};
|
|
||||||
env['PATH'] = expandEnvVars(process.env.PATH as string) || '';
|
env['PATH'] = expandEnvVars(process.env.PATH as string) || '';
|
||||||
|
|
||||||
const systemConfigDao = getSystemConfigDao();
|
const systemConfigDao = getSystemConfigDao();
|
||||||
@@ -236,6 +241,8 @@ const callToolWithReconnect = async (
|
|||||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||||
try {
|
try {
|
||||||
const result = await serverInfo.client.callTool(toolParams, undefined, options || {});
|
const result = await serverInfo.client.callTool(toolParams, undefined, options || {});
|
||||||
|
// Check auth error
|
||||||
|
checkAuthError(result);
|
||||||
return result;
|
return result;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
// Check if error message starts with "Error POSTing to endpoint (HTTP 40"
|
// Check if error message starts with "Error POSTing to endpoint (HTTP 40"
|
||||||
@@ -825,6 +832,25 @@ export const addOrUpdateServer = async (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Check for authentication error in tool call result
|
||||||
|
function checkAuthError(result: any) {
|
||||||
|
if (Array.isArray(result.content) && result.content.length > 0) {
|
||||||
|
const text = result.content[0]?.text;
|
||||||
|
if (typeof text === 'string') {
|
||||||
|
let errorContent;
|
||||||
|
try {
|
||||||
|
errorContent = JSON.parse(text);
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore JSON parse errors and continue
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (errorContent.code === 401) {
|
||||||
|
throw new Error('Error POSTing to endpoint (HTTP 401 Unauthorized)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Close server client and transport
|
// Close server client and transport
|
||||||
function closeServer(name: string) {
|
function closeServer(name: string) {
|
||||||
const serverInfo = serverInfos.find((serverInfo) => serverInfo.name === name);
|
const serverInfo = serverInfos.find((serverInfo) => serverInfo.name === name);
|
||||||
|
|||||||
167
src/services/proxy.ts
Normal file
167
src/services/proxy.ts
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
/**
|
||||||
|
* HTTP/HTTPS proxy configuration utilities for MCP client transports.
|
||||||
|
*
|
||||||
|
* This module provides utilities to configure HTTP and HTTPS proxies when
|
||||||
|
* connecting to MCP servers. Proxies are configured by providing a custom
|
||||||
|
* fetch implementation that uses Node.js http/https agents with proxy support.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { FetchLike } from '@modelcontextprotocol/sdk/shared/transport.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration options for HTTP/HTTPS proxy settings.
|
||||||
|
*/
|
||||||
|
export interface ProxyConfig {
|
||||||
|
/**
|
||||||
|
* HTTP proxy URL (e.g., 'http://proxy.example.com:8080')
|
||||||
|
* Can include authentication: 'http://user:pass@proxy.example.com:8080'
|
||||||
|
*/
|
||||||
|
httpProxy?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTPS proxy URL (e.g., 'https://proxy.example.com:8443')
|
||||||
|
* Can include authentication: 'https://user:pass@proxy.example.com:8443'
|
||||||
|
*/
|
||||||
|
httpsProxy?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Comma-separated list of hosts that should bypass the proxy
|
||||||
|
* (e.g., 'localhost,127.0.0.1,.example.com')
|
||||||
|
*/
|
||||||
|
noProxy?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a fetch function that uses the specified proxy configuration.
|
||||||
|
*
|
||||||
|
* This function returns a fetch implementation that routes requests through
|
||||||
|
* the configured HTTP/HTTPS proxies using undici's ProxyAgent.
|
||||||
|
*
|
||||||
|
* Note: This function requires the 'undici' package to be installed.
|
||||||
|
* Install it with: npm install undici
|
||||||
|
*
|
||||||
|
* @param config - Proxy configuration options
|
||||||
|
* @returns A fetch-compatible function configured to use the specified proxies
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
export function createFetchWithProxy(config: ProxyConfig): FetchLike {
|
||||||
|
// If no proxy is configured, return the default fetch
|
||||||
|
if (!config.httpProxy && !config.httpsProxy) {
|
||||||
|
return fetch;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse no_proxy list
|
||||||
|
const noProxyList = parseNoProxy(config.noProxy);
|
||||||
|
|
||||||
|
return async (url: string | URL, init?: RequestInit): Promise<Response> => {
|
||||||
|
const targetUrl = typeof url === 'string' ? new URL(url) : url;
|
||||||
|
|
||||||
|
// Check if host should bypass proxy
|
||||||
|
if (shouldBypassProxy(targetUrl.hostname, noProxyList)) {
|
||||||
|
return fetch(url, init);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine which proxy to use based on protocol
|
||||||
|
const proxyUrl = targetUrl.protocol === 'https:' ? config.httpsProxy : config.httpProxy;
|
||||||
|
|
||||||
|
if (!proxyUrl) {
|
||||||
|
// No proxy configured for this protocol
|
||||||
|
return fetch(url, init);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use undici for proxy support if available
|
||||||
|
try {
|
||||||
|
// Dynamic import - undici is an optional peer dependency
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const undici = await import('undici' as any);
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const ProxyAgent = (undici as any).ProxyAgent;
|
||||||
|
const dispatcher = new ProxyAgent(proxyUrl);
|
||||||
|
|
||||||
|
return fetch(url, {
|
||||||
|
...init,
|
||||||
|
// @ts-expect-error - dispatcher is undici-specific
|
||||||
|
dispatcher,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// undici not available - throw error requiring installation
|
||||||
|
throw new Error(
|
||||||
|
'Proxy support requires the "undici" package. ' +
|
||||||
|
'Install it with: npm install undici\n' +
|
||||||
|
`Original error: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a NO_PROXY environment variable value into a list of patterns.
|
||||||
|
*/
|
||||||
|
function parseNoProxy(noProxy?: string): string[] {
|
||||||
|
if (!noProxy) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return noProxy
|
||||||
|
.split(',')
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter((item) => item.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a hostname should bypass the proxy based on NO_PROXY patterns.
|
||||||
|
*/
|
||||||
|
function shouldBypassProxy(hostname: string, noProxyList: string[]): boolean {
|
||||||
|
if (noProxyList.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hostnameLower = hostname.toLowerCase();
|
||||||
|
|
||||||
|
for (const pattern of noProxyList) {
|
||||||
|
const patternLower = pattern.toLowerCase();
|
||||||
|
|
||||||
|
// Exact match
|
||||||
|
if (hostnameLower === patternLower) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Domain suffix match (e.g., .example.com matches sub.example.com)
|
||||||
|
if (patternLower.startsWith('.') && hostnameLower.endsWith(patternLower)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Domain suffix match without leading dot
|
||||||
|
if (!patternLower.startsWith('.') && hostnameLower.endsWith('.' + patternLower)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Special case: "*" matches everything
|
||||||
|
if (patternLower === '*') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a ProxyConfig from environment variables.
|
||||||
|
*
|
||||||
|
* This function reads standard proxy environment variables:
|
||||||
|
* - HTTP_PROXY, http_proxy
|
||||||
|
* - HTTPS_PROXY, https_proxy
|
||||||
|
* - NO_PROXY, no_proxy
|
||||||
|
*
|
||||||
|
* Lowercase versions take precedence over uppercase versions.
|
||||||
|
*
|
||||||
|
* @returns A ProxyConfig object populated from environment variables
|
||||||
|
*/
|
||||||
|
export function getProxyConfigFromEnv(env: Record<string, string>): ProxyConfig {
|
||||||
|
return {
|
||||||
|
httpProxy: env.http_proxy || env.HTTP_PROXY,
|
||||||
|
httpsProxy: env.https_proxy || env.HTTPS_PROXY,
|
||||||
|
noProxy: env.no_proxy || env.NO_PROXY,
|
||||||
|
};
|
||||||
|
}
|
||||||
97
tests/dao/bearerKeyDao.test.ts
Normal file
97
tests/dao/bearerKeyDao.test.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import fs from 'fs';
|
||||||
|
import os from 'os';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
import { BearerKeyDaoImpl } from '../../src/dao/BearerKeyDao.js';
|
||||||
|
|
||||||
|
const writeSettings = (settingsPath: string, settings: unknown): void => {
|
||||||
|
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf8');
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('BearerKeyDaoImpl migration + settings caching behavior', () => {
|
||||||
|
let tmpDir: string;
|
||||||
|
let settingsPath: string;
|
||||||
|
let originalSettingsEnv: string | undefined;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mcphub-bearer-keys-'));
|
||||||
|
settingsPath = path.join(tmpDir, 'mcp_settings.json');
|
||||||
|
|
||||||
|
originalSettingsEnv = process.env.MCPHUB_SETTING_PATH;
|
||||||
|
process.env.MCPHUB_SETTING_PATH = settingsPath;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (originalSettingsEnv === undefined) {
|
||||||
|
delete process.env.MCPHUB_SETTING_PATH;
|
||||||
|
} else {
|
||||||
|
process.env.MCPHUB_SETTING_PATH = originalSettingsEnv;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
} catch {
|
||||||
|
// ignore cleanup errors
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not rewrite settings when bearerKeys exists as an empty array', async () => {
|
||||||
|
writeSettings(settingsPath, {
|
||||||
|
mcpServers: {},
|
||||||
|
users: [],
|
||||||
|
systemConfig: {
|
||||||
|
routing: {
|
||||||
|
enableBearerAuth: false,
|
||||||
|
bearerAuthKey: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
bearerKeys: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const writeSpy = jest.spyOn(fs, 'writeFileSync');
|
||||||
|
|
||||||
|
const dao = new BearerKeyDaoImpl();
|
||||||
|
const enabled1 = await dao.findEnabled();
|
||||||
|
const enabled2 = await dao.findEnabled();
|
||||||
|
|
||||||
|
expect(enabled1).toEqual([]);
|
||||||
|
expect(enabled2).toEqual([]);
|
||||||
|
|
||||||
|
// The DAO should NOT persist anything because bearerKeys already exists.
|
||||||
|
expect(writeSpy).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
writeSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('migrates legacy bearerAuthKey only once', async () => {
|
||||||
|
writeSettings(settingsPath, {
|
||||||
|
mcpServers: {},
|
||||||
|
users: [],
|
||||||
|
systemConfig: {
|
||||||
|
routing: {
|
||||||
|
enableBearerAuth: true,
|
||||||
|
bearerAuthKey: 'legacy-token',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// bearerKeys is intentionally missing to trigger migration
|
||||||
|
});
|
||||||
|
|
||||||
|
const writeSpy = jest.spyOn(fs, 'writeFileSync');
|
||||||
|
|
||||||
|
const dao = new BearerKeyDaoImpl();
|
||||||
|
|
||||||
|
const enabled1 = await dao.findEnabled();
|
||||||
|
expect(enabled1).toHaveLength(1);
|
||||||
|
expect(enabled1[0].token).toBe('legacy-token');
|
||||||
|
expect(enabled1[0].enabled).toBe(true);
|
||||||
|
|
||||||
|
const enabled2 = await dao.findEnabled();
|
||||||
|
expect(enabled2).toHaveLength(1);
|
||||||
|
expect(enabled2[0].token).toBe('legacy-token');
|
||||||
|
|
||||||
|
// One write for the migration, no further writes on subsequent reads.
|
||||||
|
expect(writeSpy).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
writeSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user