@aiwerk/mcp-bridge 2.0.0 → 2.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 +66 -1
- package/dist/bin/mcp-bridge.js +1 -1
- package/dist/src/index.d.ts +5 -3
- package/dist/src/index.js +3 -2
- package/dist/src/mcp-router.d.ts +4 -2
- package/dist/src/mcp-router.js +6 -2
- package/dist/src/oauth2-token-manager.d.ts +20 -0
- package/dist/src/oauth2-token-manager.js +98 -0
- package/dist/src/standalone-server.d.ts +1 -0
- package/dist/src/standalone-server.js +6 -2
- package/dist/src/transport-base.d.ts +4 -0
- package/dist/src/transport-base.js +34 -1
- package/dist/src/transport-sse.d.ts +8 -1
- package/dist/src/transport-sse.js +81 -32
- package/dist/src/transport-streamable-http.d.ts +8 -1
- package/dist/src/transport-streamable-http.js +135 -85
- package/dist/src/types.d.ts +8 -4
- package/dist/src/update-checker.d.ts +11 -0
- package/dist/src/update-checker.js +52 -4
- package/package.json +10 -2
- package/servers/imap-email/recipe.json +45 -0
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @aiwerk/mcp-bridge
|
|
2
2
|
|
|
3
|
-
[](https://github.com/AIWerk/mcp-bridge/actions/workflows/ci.yml)
|
|
4
4
|
[](https://www.npmjs.com/package/@aiwerk/mcp-bridge)
|
|
5
5
|
[](https://opensource.org/licenses/MIT)
|
|
6
6
|
|
|
@@ -17,6 +17,12 @@ Most AI agents connect to MCP servers one-by-one. With 10+ servers, that's 10+ c
|
|
|
17
17
|
- **Intent routing**: say what you need in plain language, the bridge finds the right tool
|
|
18
18
|
- **Schema compression**: tool descriptions compressed ~57%, full schema on demand
|
|
19
19
|
- **Security layer**: trust levels, tool deny/allow lists, result size limits
|
|
20
|
+
- **HTTP auth**: bearer token, custom headers, and **OAuth2 Client Credentials** with automatic token management
|
|
21
|
+
- **Result caching**: LRU cache with per-tool TTL overrides
|
|
22
|
+
- **Batch calls**: parallel multi-tool execution via `action=batch`
|
|
23
|
+
- **Multi-server resolution**: automatic tool disambiguation when multiple servers provide the same tool
|
|
24
|
+
- **Configurable retries**: exponential backoff for transient errors
|
|
25
|
+
- **Graceful shutdown**: clean process termination and connection cleanup
|
|
20
26
|
- **Direct mode**: all tools registered individually with automatic prefixing
|
|
21
27
|
- **3 transports**: stdio, SSE, streamable-http
|
|
22
28
|
- **Built-in catalog**: 14 pre-configured servers, install with one command
|
|
@@ -363,6 +369,35 @@ When `action="call"` is used without `server=`, mcp-bridge can resolve collision
|
|
|
363
369
|
| `sse` | `url`, `headers` | Remote SSE servers |
|
|
364
370
|
| `streamable-http` | `url`, `headers` | Modern HTTP-based servers |
|
|
365
371
|
|
|
372
|
+
### Authentication
|
|
373
|
+
|
|
374
|
+
SSE and streamable-HTTP transports support three auth methods:
|
|
375
|
+
|
|
376
|
+
**Bearer token:**
|
|
377
|
+
```json
|
|
378
|
+
{ "auth": { "type": "bearer", "token": "${MY_API_TOKEN}" } }
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
**Custom headers:**
|
|
382
|
+
```json
|
|
383
|
+
{ "auth": { "type": "header", "headers": { "X-API-Key": "${MY_KEY}" } } }
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
**OAuth2 Client Credentials** (automatic token management):
|
|
387
|
+
```json
|
|
388
|
+
{
|
|
389
|
+
"auth": {
|
|
390
|
+
"type": "oauth2",
|
|
391
|
+
"clientId": "${CLIENT_ID}",
|
|
392
|
+
"clientSecret": "${CLIENT_SECRET}",
|
|
393
|
+
"tokenUrl": "https://provider.com/oauth/token",
|
|
394
|
+
"scopes": ["read", "write"]
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
OAuth2 features: automatic token acquisition, caching with expiry-aware refresh, single-attempt 401 retry, env var substitution in credentials.
|
|
400
|
+
|
|
366
401
|
### Environment variables
|
|
367
402
|
|
|
368
403
|
Secrets go in `~/.mcp-bridge/.env` (chmod 600 on init):
|
|
@@ -459,6 +494,36 @@ const result = await router.dispatch("todoist", "call", "find-tasks", { query: "
|
|
|
459
494
|
└──────────────────────────────────────────────┘
|
|
460
495
|
```
|
|
461
496
|
|
|
497
|
+
## Security Limitations
|
|
498
|
+
|
|
499
|
+
The built-in security layer (trust levels, tool filters, result sanitization) provides **baseline protection** for common threats:
|
|
500
|
+
|
|
501
|
+
- Prompt injection patterns (known strings)
|
|
502
|
+
- Oversized responses
|
|
503
|
+
- Unauthorized tool access
|
|
504
|
+
|
|
505
|
+
**What it does NOT cover:**
|
|
506
|
+
- Unicode obfuscation / homoglyph attacks
|
|
507
|
+
- Sophisticated multi-step injection chains
|
|
508
|
+
- Content-level PII detection
|
|
509
|
+
|
|
510
|
+
For production deployments with high security requirements, consider adding an external content filtering layer (e.g., guardrails, PII redaction service) between the bridge and your application.
|
|
511
|
+
|
|
512
|
+
## Roadmap
|
|
513
|
+
|
|
514
|
+
| Status | Feature | Version |
|
|
515
|
+
|--------|---------|---------|
|
|
516
|
+
| ✅ | Smart Router v2 (intent, cache, batch, resolution) | 1.9.0 |
|
|
517
|
+
| ✅ | HTTP auth (bearer, headers) | 2.0.0 |
|
|
518
|
+
| ✅ | Configurable retries + graceful shutdown | 2.0.0 |
|
|
519
|
+
| ✅ | OAuth2 Client Credentials | 2.1.0 |
|
|
520
|
+
| 🔜 | Hosted bridge (bridge.aiwerk.ch) | planned |
|
|
521
|
+
| 🔜 | Remote catalog integration | planned |
|
|
522
|
+
| 🔜 | OpenTelemetry / Prometheus metrics | planned |
|
|
523
|
+
| 🔜 | PII redaction | planned |
|
|
524
|
+
|
|
525
|
+
See [docs/hosted-bridge-spec.md](docs/hosted-bridge-spec.md) for the hosted bridge architecture.
|
|
526
|
+
|
|
462
527
|
## Related
|
|
463
528
|
|
|
464
529
|
- **[@aiwerk/openclaw-mcp-bridge](https://github.com/AIWerk/openclaw-mcp-bridge)** — OpenClaw plugin wrapper (uses this package as core)
|
package/dist/bin/mcp-bridge.js
CHANGED
|
@@ -267,7 +267,7 @@ async function cmdServe(args, logger) {
|
|
|
267
267
|
process.exit(1);
|
|
268
268
|
}
|
|
269
269
|
// HTTP modes: require auth
|
|
270
|
-
if ((args.sse || args.http) && !config.http?.auth
|
|
270
|
+
if ((args.sse || args.http) && !config.http?.auth) {
|
|
271
271
|
logger.error("HTTP auth not configured. Set http.auth in config or use stdio mode.");
|
|
272
272
|
process.exit(1);
|
|
273
273
|
}
|
package/dist/src/index.d.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
export { BaseTransport, resolveEnvVars, resolveEnvRecord, resolveArgs, resolveAuthHeaders, resolveServerHeaders, warnIfNonTlsRemoteUrl } from "./transport-base.js";
|
|
1
|
+
export { BaseTransport, resolveEnvVars, resolveEnvRecord, resolveArgs, resolveAuthHeaders, resolveAuthHeadersAsync, resolveOAuth2Config, resolveServerHeaders, resolveServerHeadersAsync, warnIfNonTlsRemoteUrl, } from "./transport-base.js";
|
|
2
2
|
export { StdioTransport } from "./transport-stdio.js";
|
|
3
3
|
export { SseTransport } from "./transport-sse.js";
|
|
4
4
|
export { StreamableHttpTransport } from "./transport-streamable-http.js";
|
|
5
|
+
export { OAuth2TokenManager } from "./oauth2-token-manager.js";
|
|
6
|
+
export type { OAuth2Config } from "./oauth2-token-manager.js";
|
|
5
7
|
export { McpRouter } from "./mcp-router.js";
|
|
6
8
|
export type { RouterToolHint, RouterServerStatus, RouterDispatchResponse, RouterTransportRefs } from "./mcp-router.js";
|
|
7
9
|
export { ResultCache, createResultCacheKey, stableStringify } from "./result-cache.js";
|
|
@@ -15,6 +17,6 @@ export type { Logger, McpServerConfig, McpClientConfig, HttpAuthConfig, RetryCon
|
|
|
15
17
|
export { nextRequestId } from "./types.js";
|
|
16
18
|
export { pickRegisteredToolName } from "./tool-naming.js";
|
|
17
19
|
export { StandaloneServer } from "./standalone-server.js";
|
|
18
|
-
export { checkForUpdate, getUpdateNotice, runUpdate, resetNoticeFlag } from "./update-checker.js";
|
|
19
|
-
export type { UpdateInfo } from "./update-checker.js";
|
|
20
|
+
export { checkForUpdate, checkPluginUpdate, getUpdateNotice, runUpdate, resetNoticeFlag } from "./update-checker.js";
|
|
21
|
+
export type { UpdateInfo, PluginUpdateInfo } from "./update-checker.js";
|
|
20
22
|
export { filterServers, buildFilteredDescription } from "./smart-filter.js";
|
package/dist/src/index.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
// Core exports for @aiwerk/mcp-bridge
|
|
2
2
|
// Transport classes
|
|
3
|
-
export { BaseTransport, resolveEnvVars, resolveEnvRecord, resolveArgs, resolveAuthHeaders, resolveServerHeaders, warnIfNonTlsRemoteUrl } from "./transport-base.js";
|
|
3
|
+
export { BaseTransport, resolveEnvVars, resolveEnvRecord, resolveArgs, resolveAuthHeaders, resolveAuthHeadersAsync, resolveOAuth2Config, resolveServerHeaders, resolveServerHeadersAsync, warnIfNonTlsRemoteUrl, } from "./transport-base.js";
|
|
4
4
|
export { StdioTransport } from "./transport-stdio.js";
|
|
5
5
|
export { SseTransport } from "./transport-sse.js";
|
|
6
6
|
export { StreamableHttpTransport } from "./transport-streamable-http.js";
|
|
7
|
+
export { OAuth2TokenManager } from "./oauth2-token-manager.js";
|
|
7
8
|
// Router
|
|
8
9
|
export { McpRouter } from "./mcp-router.js";
|
|
9
10
|
// Result cache
|
|
@@ -21,6 +22,6 @@ export { pickRegisteredToolName } from "./tool-naming.js";
|
|
|
21
22
|
// Standalone server
|
|
22
23
|
export { StandaloneServer } from "./standalone-server.js";
|
|
23
24
|
// Update checker
|
|
24
|
-
export { checkForUpdate, getUpdateNotice, runUpdate, resetNoticeFlag } from "./update-checker.js";
|
|
25
|
+
export { checkForUpdate, checkPluginUpdate, getUpdateNotice, runUpdate, resetNoticeFlag } from "./update-checker.js";
|
|
25
26
|
// Smart filter
|
|
26
27
|
export { filterServers, buildFilteredDescription } from "./smart-filter.js";
|
package/dist/src/mcp-router.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { McpClientConfig, McpServerConfig, McpTransport, Logger } from "./types.js";
|
|
2
|
+
import { OAuth2TokenManager } from "./oauth2-token-manager.js";
|
|
2
3
|
type RouterErrorCode = "unknown_server" | "unknown_tool" | "connection_failed" | "mcp_error" | "invalid_params";
|
|
3
4
|
interface RouterBatchResult {
|
|
4
5
|
server: string;
|
|
@@ -92,9 +93,9 @@ export type RouterDispatchResponse = {
|
|
|
92
93
|
code?: number;
|
|
93
94
|
};
|
|
94
95
|
export interface RouterTransportRefs {
|
|
95
|
-
sse: new (config: McpServerConfig, clientConfig: McpClientConfig, logger: Logger, onReconnected?: () => Promise<void
|
|
96
|
+
sse: new (config: McpServerConfig, clientConfig: McpClientConfig, logger: Logger, onReconnected?: () => Promise<void>, tokenManager?: OAuth2TokenManager) => McpTransport;
|
|
96
97
|
stdio: new (config: McpServerConfig, clientConfig: McpClientConfig, logger: Logger, onReconnected?: () => Promise<void>) => McpTransport;
|
|
97
|
-
streamableHttp: new (config: McpServerConfig, clientConfig: McpClientConfig, logger: Logger, onReconnected?: () => Promise<void
|
|
98
|
+
streamableHttp: new (config: McpServerConfig, clientConfig: McpClientConfig, logger: Logger, onReconnected?: () => Promise<void>, tokenManager?: OAuth2TokenManager) => McpTransport;
|
|
98
99
|
}
|
|
99
100
|
export declare class McpRouter {
|
|
100
101
|
private readonly servers;
|
|
@@ -107,6 +108,7 @@ export declare class McpRouter {
|
|
|
107
108
|
private readonly maxBatchSize;
|
|
108
109
|
private readonly states;
|
|
109
110
|
private readonly toolResolver;
|
|
111
|
+
private readonly tokenManager;
|
|
110
112
|
private intentRouter;
|
|
111
113
|
private promotion;
|
|
112
114
|
constructor(servers: Record<string, McpServerConfig>, clientConfig: McpClientConfig, logger: Logger, transportRefs?: Partial<RouterTransportRefs>);
|
package/dist/src/mcp-router.js
CHANGED
|
@@ -9,6 +9,7 @@ import { isToolAllowed, processResult } from "./security.js";
|
|
|
9
9
|
import { AdaptivePromotion } from "./adaptive-promotion.js";
|
|
10
10
|
import { ResultCache, createResultCacheKey } from "./result-cache.js";
|
|
11
11
|
import { ToolResolver } from "./tool-resolution.js";
|
|
12
|
+
import { OAuth2TokenManager } from "./oauth2-token-manager.js";
|
|
12
13
|
const DEFAULT_IDLE_TIMEOUT_MS = 10 * 60 * 1000;
|
|
13
14
|
const DEFAULT_MAX_CONCURRENT = 5;
|
|
14
15
|
const DEFAULT_MAX_BATCH_SIZE = 10;
|
|
@@ -24,6 +25,7 @@ export class McpRouter {
|
|
|
24
25
|
maxBatchSize;
|
|
25
26
|
states = new Map();
|
|
26
27
|
toolResolver;
|
|
28
|
+
tokenManager;
|
|
27
29
|
intentRouter = null;
|
|
28
30
|
promotion = null;
|
|
29
31
|
constructor(servers, clientConfig, logger, transportRefs) {
|
|
@@ -46,6 +48,7 @@ export class McpRouter {
|
|
|
46
48
|
: null;
|
|
47
49
|
this.maxBatchSize = clientConfig.maxBatchSize ?? DEFAULT_MAX_BATCH_SIZE;
|
|
48
50
|
this.toolResolver = new ToolResolver(Object.keys(servers));
|
|
51
|
+
this.tokenManager = new OAuth2TokenManager(logger);
|
|
49
52
|
if (clientConfig.adaptivePromotion?.enabled) {
|
|
50
53
|
this.promotion = new AdaptivePromotion(clientConfig.adaptivePromotion, logger);
|
|
51
54
|
}
|
|
@@ -487,6 +490,7 @@ export class McpRouter {
|
|
|
487
490
|
this.intentRouter.clearIndex();
|
|
488
491
|
}
|
|
489
492
|
this.resultCache?.invalidate();
|
|
493
|
+
this.tokenManager.clear();
|
|
490
494
|
}
|
|
491
495
|
async ensureConnected(server) {
|
|
492
496
|
let state = this.states.get(server);
|
|
@@ -590,13 +594,13 @@ export class McpRouter {
|
|
|
590
594
|
this.resultCache?.invalidate(`${serverName}:`);
|
|
591
595
|
};
|
|
592
596
|
if (serverConfig.transport === "sse") {
|
|
593
|
-
return new this.transportRefs.sse(serverConfig, this.clientConfig, this.logger, onReconnected);
|
|
597
|
+
return new this.transportRefs.sse(serverConfig, this.clientConfig, this.logger, onReconnected, this.tokenManager);
|
|
594
598
|
}
|
|
595
599
|
if (serverConfig.transport === "stdio") {
|
|
596
600
|
return new this.transportRefs.stdio(serverConfig, this.clientConfig, this.logger, onReconnected);
|
|
597
601
|
}
|
|
598
602
|
if (serverConfig.transport === "streamable-http") {
|
|
599
|
-
return new this.transportRefs.streamableHttp(serverConfig, this.clientConfig, this.logger, onReconnected);
|
|
603
|
+
return new this.transportRefs.streamableHttp(serverConfig, this.clientConfig, this.logger, onReconnected, this.tokenManager);
|
|
600
604
|
}
|
|
601
605
|
throw new Error(`Unsupported transport: ${serverConfig.transport}`);
|
|
602
606
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { Logger } from "./types.js";
|
|
2
|
+
export interface OAuth2Config {
|
|
3
|
+
clientId: string;
|
|
4
|
+
clientSecret: string;
|
|
5
|
+
tokenUrl: string;
|
|
6
|
+
scopes?: string[];
|
|
7
|
+
audience?: string;
|
|
8
|
+
}
|
|
9
|
+
export declare class OAuth2TokenManager {
|
|
10
|
+
private readonly logger;
|
|
11
|
+
private readonly tokenCache;
|
|
12
|
+
private readonly inflight;
|
|
13
|
+
constructor(logger: Logger);
|
|
14
|
+
getToken(config: OAuth2Config): Promise<string>;
|
|
15
|
+
invalidate(tokenUrl: string, clientId: string): void;
|
|
16
|
+
clear(): void;
|
|
17
|
+
private makeKey;
|
|
18
|
+
private fetchToken;
|
|
19
|
+
private exchangeToken;
|
|
20
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
const DEFAULT_EXPIRES_IN_SECONDS = 3600;
|
|
2
|
+
const EXPIRY_BUFFER_SECONDS = 60;
|
|
3
|
+
export class OAuth2TokenManager {
|
|
4
|
+
logger;
|
|
5
|
+
tokenCache = new Map();
|
|
6
|
+
inflight = new Map();
|
|
7
|
+
constructor(logger) {
|
|
8
|
+
this.logger = logger;
|
|
9
|
+
}
|
|
10
|
+
async getToken(config) {
|
|
11
|
+
const key = this.makeKey(config.tokenUrl, config.clientId);
|
|
12
|
+
const now = Date.now();
|
|
13
|
+
const cached = this.tokenCache.get(key);
|
|
14
|
+
if (cached && cached.expiresAt > now) {
|
|
15
|
+
return cached.accessToken;
|
|
16
|
+
}
|
|
17
|
+
const existingInflight = this.inflight.get(key);
|
|
18
|
+
if (existingInflight) {
|
|
19
|
+
return existingInflight;
|
|
20
|
+
}
|
|
21
|
+
const requestPromise = this.fetchToken(config, cached)
|
|
22
|
+
.then((token) => {
|
|
23
|
+
this.tokenCache.set(key, token);
|
|
24
|
+
return token.accessToken;
|
|
25
|
+
})
|
|
26
|
+
.finally(() => {
|
|
27
|
+
this.inflight.delete(key);
|
|
28
|
+
});
|
|
29
|
+
this.inflight.set(key, requestPromise);
|
|
30
|
+
return requestPromise;
|
|
31
|
+
}
|
|
32
|
+
invalidate(tokenUrl, clientId) {
|
|
33
|
+
const key = this.makeKey(tokenUrl, clientId);
|
|
34
|
+
this.tokenCache.delete(key);
|
|
35
|
+
this.inflight.delete(key);
|
|
36
|
+
}
|
|
37
|
+
clear() {
|
|
38
|
+
this.tokenCache.clear();
|
|
39
|
+
this.inflight.clear();
|
|
40
|
+
}
|
|
41
|
+
makeKey(tokenUrl, clientId) {
|
|
42
|
+
return `${tokenUrl}::${clientId}`;
|
|
43
|
+
}
|
|
44
|
+
async fetchToken(config, cached) {
|
|
45
|
+
if (cached?.refreshToken) {
|
|
46
|
+
try {
|
|
47
|
+
return await this.exchangeToken(config, {
|
|
48
|
+
grant_type: "refresh_token",
|
|
49
|
+
refresh_token: cached.refreshToken,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
this.logger.warn("[mcp-bridge] OAuth2 refresh token exchange failed, falling back to client_credentials:", error);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return this.exchangeToken(config, {
|
|
57
|
+
grant_type: "client_credentials",
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
async exchangeToken(config, grant) {
|
|
61
|
+
const formData = new URLSearchParams();
|
|
62
|
+
formData.set("grant_type", grant.grant_type);
|
|
63
|
+
formData.set("client_id", config.clientId);
|
|
64
|
+
formData.set("client_secret", config.clientSecret);
|
|
65
|
+
if (grant.grant_type === "refresh_token") {
|
|
66
|
+
formData.set("refresh_token", grant.refresh_token);
|
|
67
|
+
}
|
|
68
|
+
if (config.scopes?.length) {
|
|
69
|
+
formData.set("scope", config.scopes.join(" "));
|
|
70
|
+
}
|
|
71
|
+
if (config.audience) {
|
|
72
|
+
formData.set("audience", config.audience);
|
|
73
|
+
}
|
|
74
|
+
const response = await fetch(config.tokenUrl, {
|
|
75
|
+
method: "POST",
|
|
76
|
+
headers: {
|
|
77
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
78
|
+
},
|
|
79
|
+
body: formData.toString(),
|
|
80
|
+
});
|
|
81
|
+
if (!response.ok) {
|
|
82
|
+
throw new Error(`OAuth2 token exchange failed: HTTP ${response.status}`);
|
|
83
|
+
}
|
|
84
|
+
const payload = (await response.json());
|
|
85
|
+
if (!payload.access_token) {
|
|
86
|
+
throw new Error("OAuth2 token exchange response missing access_token");
|
|
87
|
+
}
|
|
88
|
+
const expiresIn = Number.isFinite(payload.expires_in)
|
|
89
|
+
? Number(payload.expires_in)
|
|
90
|
+
: DEFAULT_EXPIRES_IN_SECONDS;
|
|
91
|
+
const expiresAt = Date.now() + Math.max(0, expiresIn - EXPIRY_BUFFER_SECONDS) * 1000;
|
|
92
|
+
return {
|
|
93
|
+
accessToken: payload.access_token,
|
|
94
|
+
expiresAt,
|
|
95
|
+
refreshToken: payload.refresh_token,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -4,6 +4,7 @@ import { pickRegisteredToolName } from "./tool-naming.js";
|
|
|
4
4
|
import { SseTransport } from "./transport-sse.js";
|
|
5
5
|
import { StdioTransport } from "./transport-stdio.js";
|
|
6
6
|
import { StreamableHttpTransport } from "./transport-streamable-http.js";
|
|
7
|
+
import { OAuth2TokenManager } from "./oauth2-token-manager.js";
|
|
7
8
|
/**
|
|
8
9
|
* Standalone MCP server that wraps the router.
|
|
9
10
|
* Implements the MCP protocol (initialize, tools/list, tools/call)
|
|
@@ -15,12 +16,14 @@ export class StandaloneServer {
|
|
|
15
16
|
router = null;
|
|
16
17
|
initialized = false;
|
|
17
18
|
lspMode = false;
|
|
19
|
+
tokenManager;
|
|
18
20
|
// Direct mode state
|
|
19
21
|
directTools = [];
|
|
20
22
|
directConnections = new Map();
|
|
21
23
|
constructor(config, logger) {
|
|
22
24
|
this.config = config;
|
|
23
25
|
this.logger = logger;
|
|
26
|
+
this.tokenManager = new OAuth2TokenManager(logger);
|
|
24
27
|
if (this.isRouterMode()) {
|
|
25
28
|
this.router = new McpRouter(config.servers || {}, config, logger);
|
|
26
29
|
}
|
|
@@ -400,11 +403,11 @@ export class StandaloneServer {
|
|
|
400
403
|
};
|
|
401
404
|
switch (serverConfig.transport) {
|
|
402
405
|
case "sse":
|
|
403
|
-
return new SseTransport(serverConfig, this.config, this.logger, onReconnected);
|
|
406
|
+
return new SseTransport(serverConfig, this.config, this.logger, onReconnected, this.tokenManager);
|
|
404
407
|
case "stdio":
|
|
405
408
|
return new StdioTransport(serverConfig, this.config, this.logger, onReconnected);
|
|
406
409
|
case "streamable-http":
|
|
407
|
-
return new StreamableHttpTransport(serverConfig, this.config, this.logger, onReconnected);
|
|
410
|
+
return new StreamableHttpTransport(serverConfig, this.config, this.logger, onReconnected, this.tokenManager);
|
|
408
411
|
default:
|
|
409
412
|
throw new Error(`Unsupported transport: ${serverConfig.transport}`);
|
|
410
413
|
}
|
|
@@ -424,6 +427,7 @@ export class StandaloneServer {
|
|
|
424
427
|
}
|
|
425
428
|
}
|
|
426
429
|
this.directConnections.clear();
|
|
430
|
+
this.tokenManager.clear();
|
|
427
431
|
this.logger.info("[mcp-bridge] Shutdown complete");
|
|
428
432
|
}
|
|
429
433
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { McpTransport, McpRequest, McpResponse, McpServerConfig, McpClientConfig, Logger, JsonRpcMessage } from "./types.js";
|
|
2
|
+
import type { OAuth2Config, OAuth2TokenManager } from "./oauth2-token-manager.js";
|
|
2
3
|
export type PendingRequest = {
|
|
3
4
|
resolve: (value: McpResponse) => void;
|
|
4
5
|
reject: (reason: Error) => void;
|
|
@@ -74,10 +75,13 @@ export declare function resolveArgs(args: string[], extraEnv?: Record<string, st
|
|
|
74
75
|
* Resolve auth config into HTTP headers.
|
|
75
76
|
*/
|
|
76
77
|
export declare function resolveAuthHeaders(config: McpServerConfig, extraEnv?: Record<string, string | undefined>, envFallback?: () => Record<string, string>): Record<string, string>;
|
|
78
|
+
export declare function resolveOAuth2Config(config: McpServerConfig, extraEnv?: Record<string, string | undefined>, envFallback?: () => Record<string, string>): OAuth2Config;
|
|
79
|
+
export declare function resolveAuthHeadersAsync(config: McpServerConfig, tokenManager: OAuth2TokenManager, extraEnv?: Record<string, string | undefined>, envFallback?: () => Record<string, string>): Promise<Record<string, string>>;
|
|
77
80
|
/**
|
|
78
81
|
* Resolve server headers and merge auth headers (auth takes precedence).
|
|
79
82
|
*/
|
|
80
83
|
export declare function resolveServerHeaders(config: McpServerConfig, extraEnv?: Record<string, string | undefined>, envFallback?: () => Record<string, string>): Record<string, string>;
|
|
84
|
+
export declare function resolveServerHeadersAsync(config: McpServerConfig, tokenManager: OAuth2TokenManager, extraEnv?: Record<string, string | undefined>, envFallback?: () => Record<string, string>): Promise<Record<string, string>>;
|
|
81
85
|
/**
|
|
82
86
|
* Warn if a URL uses non-TLS HTTP to a remote (non-localhost) host.
|
|
83
87
|
*/
|
|
@@ -169,7 +169,35 @@ export function resolveAuthHeaders(config, extraEnv, envFallback) {
|
|
|
169
169
|
const token = resolveEnvVars(config.auth.token, "auth token", extraEnv, envFallback);
|
|
170
170
|
return { Authorization: `Bearer ${token}` };
|
|
171
171
|
}
|
|
172
|
-
|
|
172
|
+
if (config.auth.type === "header") {
|
|
173
|
+
return resolveEnvRecord(config.auth.headers, "auth header", extraEnv, envFallback);
|
|
174
|
+
}
|
|
175
|
+
throw new Error("[mcp-bridge] OAuth2 auth requires async header resolution via resolveAuthHeadersAsync");
|
|
176
|
+
}
|
|
177
|
+
export function resolveOAuth2Config(config, extraEnv, envFallback) {
|
|
178
|
+
if (!config.auth || config.auth.type !== "oauth2") {
|
|
179
|
+
throw new Error("[mcp-bridge] resolveOAuth2Config called for non-oauth2 auth config");
|
|
180
|
+
}
|
|
181
|
+
const scopes = config.auth.scopes?.map((scope, index) => resolveEnvVars(scope, `oauth2 scope[${index}]`, extraEnv, envFallback));
|
|
182
|
+
return {
|
|
183
|
+
clientId: resolveEnvVars(config.auth.clientId, "oauth2 clientId", extraEnv, envFallback),
|
|
184
|
+
clientSecret: resolveEnvVars(config.auth.clientSecret, "oauth2 clientSecret", extraEnv, envFallback),
|
|
185
|
+
tokenUrl: resolveEnvVars(config.auth.tokenUrl, "oauth2 tokenUrl", extraEnv, envFallback),
|
|
186
|
+
...(scopes && scopes.length > 0 ? { scopes } : {}),
|
|
187
|
+
...(config.auth.audience
|
|
188
|
+
? { audience: resolveEnvVars(config.auth.audience, "oauth2 audience", extraEnv, envFallback) }
|
|
189
|
+
: {}),
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
export async function resolveAuthHeadersAsync(config, tokenManager, extraEnv, envFallback) {
|
|
193
|
+
if (!config.auth)
|
|
194
|
+
return {};
|
|
195
|
+
if (config.auth.type === "oauth2") {
|
|
196
|
+
const oauth2Config = resolveOAuth2Config(config, extraEnv, envFallback);
|
|
197
|
+
const token = await tokenManager.getToken(oauth2Config);
|
|
198
|
+
return { Authorization: `Bearer ${token}` };
|
|
199
|
+
}
|
|
200
|
+
return resolveAuthHeaders(config, extraEnv, envFallback);
|
|
173
201
|
}
|
|
174
202
|
/**
|
|
175
203
|
* Resolve server headers and merge auth headers (auth takes precedence).
|
|
@@ -179,6 +207,11 @@ export function resolveServerHeaders(config, extraEnv, envFallback) {
|
|
|
179
207
|
const auth = resolveAuthHeaders(config, extraEnv, envFallback);
|
|
180
208
|
return { ...base, ...auth };
|
|
181
209
|
}
|
|
210
|
+
export async function resolveServerHeadersAsync(config, tokenManager, extraEnv, envFallback) {
|
|
211
|
+
const base = resolveEnvRecord(config.headers || {}, "header", extraEnv, envFallback);
|
|
212
|
+
const auth = await resolveAuthHeadersAsync(config, tokenManager, extraEnv, envFallback);
|
|
213
|
+
return { ...base, ...auth };
|
|
214
|
+
}
|
|
182
215
|
/**
|
|
183
216
|
* Warn if a URL uses non-TLS HTTP to a remote (non-localhost) host.
|
|
184
217
|
*/
|
|
@@ -1,13 +1,20 @@
|
|
|
1
|
-
import { McpRequest, McpResponse } from "./types.js";
|
|
1
|
+
import { Logger, McpClientConfig, McpRequest, McpResponse, McpServerConfig } from "./types.js";
|
|
2
|
+
import { OAuth2TokenManager } from "./oauth2-token-manager.js";
|
|
2
3
|
import { BaseTransport } from "./transport-base.js";
|
|
3
4
|
export declare class SseTransport extends BaseTransport {
|
|
4
5
|
private endpointUrl;
|
|
5
6
|
private sseAbortController;
|
|
6
7
|
private resolvedHeaders;
|
|
7
8
|
private pendingRequestControllers;
|
|
9
|
+
private readonly tokenManager;
|
|
8
10
|
protected get transportName(): string;
|
|
11
|
+
constructor(config: McpServerConfig, clientConfig: McpClientConfig, logger: Logger, onReconnected?: () => Promise<void>, tokenManager?: OAuth2TokenManager);
|
|
9
12
|
connect(): Promise<void>;
|
|
10
13
|
private _onEndpointReceived;
|
|
14
|
+
private getBaseHeaders;
|
|
15
|
+
private refreshResolvedHeaders;
|
|
16
|
+
private invalidateOAuth2Token;
|
|
17
|
+
private fetchWithOAuthRetry;
|
|
11
18
|
private startEventStream;
|
|
12
19
|
private processEventLine;
|
|
13
20
|
sendNotification(notification: any): Promise<void>;
|
|
@@ -1,18 +1,23 @@
|
|
|
1
1
|
import { nextRequestId } from "./types.js";
|
|
2
|
-
import {
|
|
2
|
+
import { OAuth2TokenManager } from "./oauth2-token-manager.js";
|
|
3
|
+
import { BaseTransport, resolveOAuth2Config, resolveServerHeaders, resolveServerHeadersAsync, warnIfNonTlsRemoteUrl, } from "./transport-base.js";
|
|
3
4
|
export class SseTransport extends BaseTransport {
|
|
4
5
|
endpointUrl = null;
|
|
5
6
|
sseAbortController = null;
|
|
6
7
|
resolvedHeaders = null;
|
|
7
8
|
pendingRequestControllers = new Map();
|
|
9
|
+
tokenManager;
|
|
8
10
|
get transportName() { return "SSE"; }
|
|
11
|
+
constructor(config, clientConfig, logger, onReconnected, tokenManager) {
|
|
12
|
+
super(config, clientConfig, logger, onReconnected);
|
|
13
|
+
this.tokenManager = tokenManager ?? new OAuth2TokenManager(logger);
|
|
14
|
+
}
|
|
9
15
|
async connect() {
|
|
10
16
|
if (!this.config.url) {
|
|
11
17
|
throw new Error("SSE transport requires URL");
|
|
12
18
|
}
|
|
13
19
|
warnIfNonTlsRemoteUrl(this.config.url, this.logger);
|
|
14
|
-
|
|
15
|
-
this.resolvedHeaders = resolveServerHeaders(this.config);
|
|
20
|
+
await this.refreshResolvedHeaders();
|
|
16
21
|
if (this.sseAbortController) {
|
|
17
22
|
this.sseAbortController.abort();
|
|
18
23
|
}
|
|
@@ -24,7 +29,7 @@ export class SseTransport extends BaseTransport {
|
|
|
24
29
|
});
|
|
25
30
|
// Fire and forget the stream reader
|
|
26
31
|
this.startEventStream().catch((error) => {
|
|
27
|
-
if (error instanceof Error && error.name !==
|
|
32
|
+
if (error instanceof Error && error.name !== "AbortError") {
|
|
28
33
|
this.logger.error("[mcp-bridge] SSE stream error:", error.message);
|
|
29
34
|
this.scheduleReconnect();
|
|
30
35
|
}
|
|
@@ -34,16 +39,58 @@ export class SseTransport extends BaseTransport {
|
|
|
34
39
|
this.backoffDelay = this.clientConfig.reconnectIntervalMs || 30000;
|
|
35
40
|
}
|
|
36
41
|
_onEndpointReceived = null;
|
|
42
|
+
async getBaseHeaders(forceRefresh = false) {
|
|
43
|
+
if (this.config.auth?.type === "oauth2") {
|
|
44
|
+
if (forceRefresh) {
|
|
45
|
+
this.invalidateOAuth2Token();
|
|
46
|
+
}
|
|
47
|
+
return this.refreshResolvedHeaders();
|
|
48
|
+
}
|
|
49
|
+
if (!this.resolvedHeaders) {
|
|
50
|
+
this.resolvedHeaders = resolveServerHeaders(this.config, undefined, this.clientConfig.envFallback);
|
|
51
|
+
}
|
|
52
|
+
return this.resolvedHeaders;
|
|
53
|
+
}
|
|
54
|
+
async refreshResolvedHeaders() {
|
|
55
|
+
if (this.config.auth?.type === "oauth2") {
|
|
56
|
+
this.resolvedHeaders = await resolveServerHeadersAsync(this.config, this.tokenManager, undefined, this.clientConfig.envFallback);
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
this.resolvedHeaders = resolveServerHeaders(this.config, undefined, this.clientConfig.envFallback);
|
|
60
|
+
}
|
|
61
|
+
return this.resolvedHeaders;
|
|
62
|
+
}
|
|
63
|
+
invalidateOAuth2Token() {
|
|
64
|
+
if (this.config.auth?.type !== "oauth2") {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const oauth2Config = resolveOAuth2Config(this.config, undefined, this.clientConfig.envFallback);
|
|
68
|
+
this.tokenManager.invalidate(oauth2Config.tokenUrl, oauth2Config.clientId);
|
|
69
|
+
}
|
|
70
|
+
async fetchWithOAuthRetry(url, init) {
|
|
71
|
+
const response = await fetch(url, init);
|
|
72
|
+
if (response.status !== 401 || this.config.auth?.type !== "oauth2") {
|
|
73
|
+
return response;
|
|
74
|
+
}
|
|
75
|
+
this.logger.warn("[mcp-bridge] SSE request returned 401, invalidating OAuth2 token and retrying once");
|
|
76
|
+
const refreshedBase = await this.getBaseHeaders(true);
|
|
77
|
+
const retryHeaders = {
|
|
78
|
+
...refreshedBase,
|
|
79
|
+
...Object.fromEntries(Array.from(new Headers(init.headers).entries()).filter(([key]) => key.toLowerCase() !== "authorization")),
|
|
80
|
+
Authorization: refreshedBase.Authorization,
|
|
81
|
+
};
|
|
82
|
+
return fetch(url, { ...init, headers: retryHeaders });
|
|
83
|
+
}
|
|
37
84
|
async startEventStream() {
|
|
38
85
|
if (!this.config.url)
|
|
39
86
|
return;
|
|
40
|
-
const base = this.
|
|
41
|
-
const headers = { ...base,
|
|
87
|
+
const base = await this.getBaseHeaders();
|
|
88
|
+
const headers = { ...base, Accept: "text/event-stream" };
|
|
42
89
|
try {
|
|
43
|
-
const response = await
|
|
90
|
+
const response = await this.fetchWithOAuthRetry(this.config.url, {
|
|
44
91
|
method: "GET",
|
|
45
92
|
headers,
|
|
46
|
-
signal: this.sseAbortController?.signal
|
|
93
|
+
signal: this.sseAbortController?.signal,
|
|
47
94
|
});
|
|
48
95
|
if (!response.ok) {
|
|
49
96
|
throw new Error(`SSE connection failed: HTTP ${response.status}`);
|
|
@@ -60,7 +107,7 @@ export class SseTransport extends BaseTransport {
|
|
|
60
107
|
if (done)
|
|
61
108
|
break;
|
|
62
109
|
buffer += decoder.decode(value, { stream: true });
|
|
63
|
-
const lines = buffer.split(
|
|
110
|
+
const lines = buffer.split("\n");
|
|
64
111
|
buffer = lines.pop() || "";
|
|
65
112
|
for (const line of lines) {
|
|
66
113
|
this.processEventLine(line, state);
|
|
@@ -71,7 +118,7 @@ export class SseTransport extends BaseTransport {
|
|
|
71
118
|
this.scheduleReconnect();
|
|
72
119
|
}
|
|
73
120
|
catch (error) {
|
|
74
|
-
if (error instanceof Error && error.name ===
|
|
121
|
+
if (error instanceof Error && error.name === "AbortError")
|
|
75
122
|
return;
|
|
76
123
|
this.logger.error("SSE stream error:", error);
|
|
77
124
|
this.scheduleReconnect();
|
|
@@ -131,12 +178,12 @@ export class SseTransport extends BaseTransport {
|
|
|
131
178
|
if (!this.connected || !this.endpointUrl) {
|
|
132
179
|
throw new Error("SSE transport not connected or no endpoint URL");
|
|
133
180
|
}
|
|
134
|
-
const base = this.
|
|
181
|
+
const base = await this.getBaseHeaders();
|
|
135
182
|
const headers = { ...base, "Content-Type": "application/json" };
|
|
136
|
-
const response = await
|
|
183
|
+
const response = await this.fetchWithOAuthRetry(this.endpointUrl, {
|
|
137
184
|
method: "POST",
|
|
138
185
|
headers,
|
|
139
|
-
body: JSON.stringify(notification)
|
|
186
|
+
body: JSON.stringify(notification),
|
|
140
187
|
});
|
|
141
188
|
if (!response.ok) {
|
|
142
189
|
this.logger.warn(`[mcp-bridge] SSE notification got HTTP ${response.status}`);
|
|
@@ -157,33 +204,35 @@ export class SseTransport extends BaseTransport {
|
|
|
157
204
|
reject(new Error(`Request timeout after ${requestTimeout}ms`));
|
|
158
205
|
}, requestTimeout);
|
|
159
206
|
this.pendingRequests.set(id, { resolve, reject, timeout });
|
|
160
|
-
const base = this.resolvedHeaders ?? resolveServerHeaders(this.config);
|
|
161
|
-
const headers = { ...base, "Content-Type": "application/json" };
|
|
162
207
|
const abortController = new AbortController();
|
|
163
208
|
this.pendingRequestControllers.set(id, abortController);
|
|
164
209
|
// The response arrives via the SSE stream (handleMessage), not from this fetch.
|
|
165
210
|
// The fetch only confirms the server accepted the request (HTTP 200).
|
|
166
211
|
// If the fetch fails, we reject immediately; otherwise we wait for the SSE stream.
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
212
|
+
(async () => {
|
|
213
|
+
try {
|
|
214
|
+
const base = await this.getBaseHeaders();
|
|
215
|
+
const headers = { ...base, "Content-Type": "application/json" };
|
|
216
|
+
const response = await this.fetchWithOAuthRetry(this.endpointUrl, {
|
|
217
|
+
method: "POST",
|
|
218
|
+
headers,
|
|
219
|
+
body: JSON.stringify(requestWithId),
|
|
220
|
+
signal: abortController.signal,
|
|
221
|
+
});
|
|
222
|
+
this.pendingRequestControllers.delete(id);
|
|
223
|
+
if (!response.ok) {
|
|
224
|
+
clearTimeout(timeout);
|
|
225
|
+
this.pendingRequests.delete(id);
|
|
226
|
+
reject(new Error(`HTTP ${response.status}`));
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
catch (error) {
|
|
230
|
+
this.pendingRequestControllers.delete(id);
|
|
176
231
|
clearTimeout(timeout);
|
|
177
232
|
this.pendingRequests.delete(id);
|
|
178
|
-
reject(
|
|
233
|
+
reject(error);
|
|
179
234
|
}
|
|
180
|
-
})
|
|
181
|
-
.catch((error) => {
|
|
182
|
-
this.pendingRequestControllers.delete(id);
|
|
183
|
-
clearTimeout(timeout);
|
|
184
|
-
this.pendingRequests.delete(id);
|
|
185
|
-
reject(error);
|
|
186
|
-
});
|
|
235
|
+
})();
|
|
187
236
|
});
|
|
188
237
|
}
|
|
189
238
|
isSameOrigin(url) {
|
|
@@ -1,11 +1,18 @@
|
|
|
1
|
-
import { McpRequest, McpResponse } from "./types.js";
|
|
1
|
+
import { Logger, McpClientConfig, McpRequest, McpResponse, McpServerConfig } from "./types.js";
|
|
2
|
+
import { OAuth2TokenManager } from "./oauth2-token-manager.js";
|
|
2
3
|
import { BaseTransport } from "./transport-base.js";
|
|
3
4
|
export declare class StreamableHttpTransport extends BaseTransport {
|
|
4
5
|
private sessionId?;
|
|
5
6
|
private resolvedHeaders;
|
|
6
7
|
private pendingRequestControllers;
|
|
8
|
+
private readonly tokenManager;
|
|
7
9
|
protected get transportName(): string;
|
|
10
|
+
constructor(config: McpServerConfig, clientConfig: McpClientConfig, logger: Logger, onReconnected?: () => Promise<void>, tokenManager?: OAuth2TokenManager);
|
|
8
11
|
connect(): Promise<void>;
|
|
12
|
+
private getBaseHeaders;
|
|
13
|
+
private refreshResolvedHeaders;
|
|
14
|
+
private invalidateOAuth2Token;
|
|
15
|
+
private fetchWithOAuthRetry;
|
|
9
16
|
sendRequest(request: McpRequest): Promise<McpResponse>;
|
|
10
17
|
sendNotification(notification: any): Promise<void>;
|
|
11
18
|
private probeServer;
|
|
@@ -1,22 +1,69 @@
|
|
|
1
1
|
import { nextRequestId } from "./types.js";
|
|
2
|
-
import {
|
|
2
|
+
import { OAuth2TokenManager } from "./oauth2-token-manager.js";
|
|
3
|
+
import { BaseTransport, resolveOAuth2Config, resolveServerHeaders, resolveServerHeadersAsync, warnIfNonTlsRemoteUrl, } from "./transport-base.js";
|
|
3
4
|
export class StreamableHttpTransport extends BaseTransport {
|
|
4
5
|
sessionId;
|
|
5
6
|
resolvedHeaders = null;
|
|
6
7
|
pendingRequestControllers = new Map();
|
|
8
|
+
tokenManager;
|
|
7
9
|
get transportName() { return "streamable-http"; }
|
|
10
|
+
constructor(config, clientConfig, logger, onReconnected, tokenManager) {
|
|
11
|
+
super(config, clientConfig, logger, onReconnected);
|
|
12
|
+
this.tokenManager = tokenManager ?? new OAuth2TokenManager(logger);
|
|
13
|
+
}
|
|
8
14
|
async connect() {
|
|
9
15
|
if (!this.config.url) {
|
|
10
16
|
throw new Error("Streamable HTTP transport requires URL");
|
|
11
17
|
}
|
|
12
18
|
warnIfNonTlsRemoteUrl(this.config.url, this.logger);
|
|
13
|
-
|
|
14
|
-
this.resolvedHeaders = resolveServerHeaders(this.config);
|
|
19
|
+
await this.refreshResolvedHeaders();
|
|
15
20
|
await this.probeServer();
|
|
16
21
|
this.connected = true;
|
|
17
22
|
this.backoffDelay = this.clientConfig.reconnectIntervalMs || 30000;
|
|
18
23
|
this.logger.info(`[mcp-bridge] Streamable HTTP transport ready for ${this.config.url}`);
|
|
19
24
|
}
|
|
25
|
+
async getBaseHeaders(forceRefresh = false) {
|
|
26
|
+
if (this.config.auth?.type === "oauth2") {
|
|
27
|
+
if (forceRefresh) {
|
|
28
|
+
this.invalidateOAuth2Token();
|
|
29
|
+
}
|
|
30
|
+
return this.refreshResolvedHeaders();
|
|
31
|
+
}
|
|
32
|
+
if (!this.resolvedHeaders) {
|
|
33
|
+
this.resolvedHeaders = resolveServerHeaders(this.config, undefined, this.clientConfig.envFallback);
|
|
34
|
+
}
|
|
35
|
+
return this.resolvedHeaders;
|
|
36
|
+
}
|
|
37
|
+
async refreshResolvedHeaders() {
|
|
38
|
+
if (this.config.auth?.type === "oauth2") {
|
|
39
|
+
this.resolvedHeaders = await resolveServerHeadersAsync(this.config, this.tokenManager, undefined, this.clientConfig.envFallback);
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
this.resolvedHeaders = resolveServerHeaders(this.config, undefined, this.clientConfig.envFallback);
|
|
43
|
+
}
|
|
44
|
+
return this.resolvedHeaders;
|
|
45
|
+
}
|
|
46
|
+
invalidateOAuth2Token() {
|
|
47
|
+
if (this.config.auth?.type !== "oauth2") {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const oauth2Config = resolveOAuth2Config(this.config, undefined, this.clientConfig.envFallback);
|
|
51
|
+
this.tokenManager.invalidate(oauth2Config.tokenUrl, oauth2Config.clientId);
|
|
52
|
+
}
|
|
53
|
+
async fetchWithOAuthRetry(url, init) {
|
|
54
|
+
const response = await fetch(url, init);
|
|
55
|
+
if (response.status !== 401 || this.config.auth?.type !== "oauth2") {
|
|
56
|
+
return response;
|
|
57
|
+
}
|
|
58
|
+
this.logger.warn("[mcp-bridge] Streamable HTTP request returned 401, invalidating OAuth2 token and retrying once");
|
|
59
|
+
const refreshedBase = await this.getBaseHeaders(true);
|
|
60
|
+
const retryHeaders = {
|
|
61
|
+
...refreshedBase,
|
|
62
|
+
...Object.fromEntries(Array.from(new Headers(init.headers).entries()).filter(([key]) => key.toLowerCase() !== "authorization")),
|
|
63
|
+
Authorization: refreshedBase.Authorization,
|
|
64
|
+
};
|
|
65
|
+
return fetch(url, { ...init, headers: retryHeaders });
|
|
66
|
+
}
|
|
20
67
|
async sendRequest(request) {
|
|
21
68
|
if (!this.connected || !this.config.url) {
|
|
22
69
|
throw new Error("Streamable HTTP transport not connected");
|
|
@@ -34,108 +81,111 @@ export class StreamableHttpTransport extends BaseTransport {
|
|
|
34
81
|
}, requestTimeout);
|
|
35
82
|
this.pendingRequests.set(id, { resolve, reject, timeout });
|
|
36
83
|
this.pendingRequestControllers.set(id, abortController);
|
|
37
|
-
|
|
38
|
-
const headers = {
|
|
39
|
-
...base,
|
|
40
|
-
"Accept": "application/json, text/event-stream",
|
|
41
|
-
"Content-Type": "application/json"
|
|
42
|
-
};
|
|
43
|
-
if (this.sessionId) {
|
|
44
|
-
headers["mcp-session-id"] = this.sessionId;
|
|
45
|
-
}
|
|
46
|
-
fetch(this.config.url, {
|
|
47
|
-
method: "POST",
|
|
48
|
-
headers,
|
|
49
|
-
body: JSON.stringify(requestWithId),
|
|
50
|
-
signal: abortController.signal
|
|
51
|
-
})
|
|
52
|
-
.then(async (response) => {
|
|
53
|
-
this.pendingRequestControllers.delete(id);
|
|
54
|
-
const responseSessionId = response.headers.get("mcp-session-id");
|
|
55
|
-
if (responseSessionId) {
|
|
56
|
-
this.sessionId = responseSessionId;
|
|
57
|
-
}
|
|
58
|
-
if (!response.ok) {
|
|
59
|
-
clearTimeout(timeout);
|
|
60
|
-
this.pendingRequests.delete(id);
|
|
61
|
-
reject(new Error(`HTTP ${response.status}: ${response.statusText}`));
|
|
62
|
-
return;
|
|
63
|
-
}
|
|
84
|
+
(async () => {
|
|
64
85
|
try {
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
86
|
+
const base = await this.getBaseHeaders();
|
|
87
|
+
const headers = {
|
|
88
|
+
...base,
|
|
89
|
+
Accept: "application/json, text/event-stream",
|
|
90
|
+
"Content-Type": "application/json",
|
|
91
|
+
};
|
|
92
|
+
if (this.sessionId) {
|
|
93
|
+
headers["mcp-session-id"] = this.sessionId;
|
|
94
|
+
}
|
|
95
|
+
const response = await this.fetchWithOAuthRetry(this.config.url, {
|
|
96
|
+
method: "POST",
|
|
97
|
+
headers,
|
|
98
|
+
body: JSON.stringify(requestWithId),
|
|
99
|
+
signal: abortController.signal,
|
|
100
|
+
});
|
|
101
|
+
this.pendingRequestControllers.delete(id);
|
|
102
|
+
const responseSessionId = response.headers.get("mcp-session-id");
|
|
103
|
+
if (responseSessionId) {
|
|
104
|
+
this.sessionId = responseSessionId;
|
|
105
|
+
}
|
|
106
|
+
if (!response.ok) {
|
|
107
|
+
clearTimeout(timeout);
|
|
108
|
+
this.pendingRequests.delete(id);
|
|
109
|
+
reject(new Error(`HTTP ${response.status}: ${response.statusText}`));
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
try {
|
|
113
|
+
const contentType = response.headers.get("content-type") || "";
|
|
114
|
+
if (contentType.includes("text/event-stream")) {
|
|
115
|
+
const text = await response.text();
|
|
116
|
+
const lines = text.split("\n");
|
|
117
|
+
// SSE event boundary parsing: collect data lines, dispatch on empty line
|
|
118
|
+
let dataBuffer = [];
|
|
119
|
+
const dispatch = () => {
|
|
120
|
+
if (dataBuffer.length === 0)
|
|
121
|
+
return;
|
|
122
|
+
const data = dataBuffer.join("\n");
|
|
123
|
+
dataBuffer = [];
|
|
124
|
+
try {
|
|
125
|
+
this.handleMessage(JSON.parse(data));
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
// skip malformed events
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
let hasData = false;
|
|
132
|
+
for (const line of lines) {
|
|
133
|
+
const trimmed = line.trim();
|
|
134
|
+
if (trimmed.startsWith("data:")) {
|
|
135
|
+
dataBuffer.push(trimmed.substring(5).trimStart());
|
|
136
|
+
hasData = true;
|
|
137
|
+
}
|
|
138
|
+
else if (trimmed === "" && dataBuffer.length > 0) {
|
|
139
|
+
dispatch();
|
|
140
|
+
}
|
|
88
141
|
}
|
|
89
|
-
|
|
90
|
-
|
|
142
|
+
// Dispatch any trailing data (server may omit final empty line)
|
|
143
|
+
dispatch();
|
|
144
|
+
if (!hasData) {
|
|
145
|
+
throw new Error("No data lines in SSE response");
|
|
91
146
|
}
|
|
92
147
|
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
if (!hasData) {
|
|
96
|
-
throw new Error("No data lines in SSE response");
|
|
148
|
+
else {
|
|
149
|
+
this.handleMessage(await response.json());
|
|
97
150
|
}
|
|
98
151
|
}
|
|
99
|
-
|
|
100
|
-
|
|
152
|
+
catch (error) {
|
|
153
|
+
clearTimeout(timeout);
|
|
154
|
+
this.pendingRequests.delete(id);
|
|
155
|
+
reject(new Error("Failed to parse response: " + (error instanceof Error ? error.message : String(error))));
|
|
101
156
|
}
|
|
102
157
|
}
|
|
103
158
|
catch (error) {
|
|
159
|
+
this.pendingRequestControllers.delete(id);
|
|
104
160
|
clearTimeout(timeout);
|
|
105
161
|
this.pendingRequests.delete(id);
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
clearTimeout(timeout);
|
|
112
|
-
this.pendingRequests.delete(id);
|
|
113
|
-
if (error.name === 'TypeError' && error.message.includes('fetch')) {
|
|
114
|
-
this.logger.error("Connection error, scheduling reconnect:", error.message);
|
|
115
|
-
this.scheduleReconnect();
|
|
162
|
+
if (error instanceof Error && error.name === "TypeError" && error.message.includes("fetch")) {
|
|
163
|
+
this.logger.error("Connection error, scheduling reconnect:", error.message);
|
|
164
|
+
this.scheduleReconnect();
|
|
165
|
+
}
|
|
166
|
+
reject(error);
|
|
116
167
|
}
|
|
117
|
-
|
|
118
|
-
});
|
|
168
|
+
})();
|
|
119
169
|
});
|
|
120
170
|
}
|
|
121
171
|
async sendNotification(notification) {
|
|
122
172
|
if (!this.connected || !this.config.url) {
|
|
123
173
|
throw new Error("Streamable HTTP transport not connected");
|
|
124
174
|
}
|
|
125
|
-
const base = this.
|
|
175
|
+
const base = await this.getBaseHeaders();
|
|
126
176
|
const headers = {
|
|
127
177
|
...base,
|
|
128
|
-
|
|
129
|
-
"Content-Type": "application/json"
|
|
178
|
+
Accept: "application/json, text/event-stream",
|
|
179
|
+
"Content-Type": "application/json",
|
|
130
180
|
};
|
|
131
181
|
if (this.sessionId) {
|
|
132
182
|
headers["mcp-session-id"] = this.sessionId;
|
|
133
183
|
}
|
|
134
184
|
try {
|
|
135
|
-
const response = await
|
|
185
|
+
const response = await this.fetchWithOAuthRetry(this.config.url, {
|
|
136
186
|
method: "POST",
|
|
137
187
|
headers,
|
|
138
|
-
body: JSON.stringify(notification)
|
|
188
|
+
body: JSON.stringify(notification),
|
|
139
189
|
});
|
|
140
190
|
const responseSessionId = response.headers.get("mcp-session-id");
|
|
141
191
|
if (responseSessionId) {
|
|
@@ -146,7 +196,7 @@ export class StreamableHttpTransport extends BaseTransport {
|
|
|
146
196
|
}
|
|
147
197
|
}
|
|
148
198
|
catch (error) {
|
|
149
|
-
if (error instanceof Error && error.name ===
|
|
199
|
+
if (error instanceof Error && error.name === "TypeError" && error.message.includes("fetch")) {
|
|
150
200
|
this.logger.error("Connection error during notification, scheduling reconnect:", error.message);
|
|
151
201
|
this.scheduleReconnect();
|
|
152
202
|
}
|
|
@@ -157,11 +207,11 @@ export class StreamableHttpTransport extends BaseTransport {
|
|
|
157
207
|
if (!this.config.url)
|
|
158
208
|
return;
|
|
159
209
|
try {
|
|
160
|
-
const headers = this.
|
|
161
|
-
const optionsResponse = await
|
|
210
|
+
const headers = await this.getBaseHeaders();
|
|
211
|
+
const optionsResponse = await this.fetchWithOAuthRetry(this.config.url, { method: "OPTIONS", headers });
|
|
162
212
|
if (optionsResponse.ok)
|
|
163
213
|
return;
|
|
164
|
-
const headResponse = await
|
|
214
|
+
const headResponse = await this.fetchWithOAuthRetry(this.config.url, { method: "HEAD", headers });
|
|
165
215
|
if (!headResponse.ok) {
|
|
166
216
|
this.logger.warn(`[mcp-bridge] Streamable HTTP server probe: OPTIONS ${optionsResponse.status}, HEAD ${headResponse.status} (non-blocking, connection continues)`);
|
|
167
217
|
}
|
|
@@ -180,11 +230,11 @@ export class StreamableHttpTransport extends BaseTransport {
|
|
|
180
230
|
// Send DELETE request if we have a session to clean up
|
|
181
231
|
if (this.sessionId && this.config.url) {
|
|
182
232
|
try {
|
|
183
|
-
const base = this.
|
|
233
|
+
const base = await this.getBaseHeaders();
|
|
184
234
|
const headers = { ...base, "mcp-session-id": this.sessionId };
|
|
185
|
-
await
|
|
235
|
+
await this.fetchWithOAuthRetry(this.config.url, {
|
|
186
236
|
method: "DELETE",
|
|
187
|
-
headers
|
|
237
|
+
headers,
|
|
188
238
|
});
|
|
189
239
|
this.sessionId = undefined;
|
|
190
240
|
this.logger.info("Streamable HTTP session cleaned up");
|
package/dist/src/types.d.ts
CHANGED
|
@@ -10,6 +10,13 @@ export type HttpAuthConfig = {
|
|
|
10
10
|
} | {
|
|
11
11
|
type: "header";
|
|
12
12
|
headers: Record<string, string>;
|
|
13
|
+
} | {
|
|
14
|
+
type: "oauth2";
|
|
15
|
+
clientId: string;
|
|
16
|
+
clientSecret: string;
|
|
17
|
+
tokenUrl: string;
|
|
18
|
+
scopes?: string[];
|
|
19
|
+
audience?: string;
|
|
13
20
|
};
|
|
14
21
|
export interface RetryConfig {
|
|
15
22
|
maxAttempts?: number;
|
|
@@ -130,9 +137,6 @@ export interface McpServerConnection {
|
|
|
130
137
|
/** Bridge-level config loaded from ~/.mcp-bridge/config.json */
|
|
131
138
|
export interface BridgeConfig extends McpClientConfig {
|
|
132
139
|
http?: {
|
|
133
|
-
auth?:
|
|
134
|
-
type: "bearer";
|
|
135
|
-
token: string;
|
|
136
|
-
};
|
|
140
|
+
auth?: HttpAuthConfig;
|
|
137
141
|
};
|
|
138
142
|
}
|
|
@@ -6,11 +6,22 @@ export interface UpdateInfo {
|
|
|
6
6
|
updateCommand: string;
|
|
7
7
|
updateCommandParts: string[];
|
|
8
8
|
}
|
|
9
|
+
export interface PluginUpdateInfo {
|
|
10
|
+
pluginName: string;
|
|
11
|
+
currentVersion: string;
|
|
12
|
+
latestVersion: string;
|
|
13
|
+
updateAvailable: boolean;
|
|
14
|
+
}
|
|
9
15
|
/**
|
|
10
16
|
* Check npm registry for a newer version. Non-blocking, best-effort.
|
|
11
17
|
* Caches result for the lifetime of the process.
|
|
12
18
|
*/
|
|
13
19
|
export declare function checkForUpdate(logger: Logger): Promise<UpdateInfo>;
|
|
20
|
+
/**
|
|
21
|
+
* Check if a wrapper plugin (e.g. openclaw-mcp-bridge) has an update.
|
|
22
|
+
* The caller passes the installed plugin version; we check npm for the latest.
|
|
23
|
+
*/
|
|
24
|
+
export declare function checkPluginUpdate(pluginName: string, installedVersion: string, logger: Logger): Promise<PluginUpdateInfo>;
|
|
14
25
|
/**
|
|
15
26
|
* Build the notice string to inject into the first tool response.
|
|
16
27
|
* Returns empty string if no update or already delivered.
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { execFileSync, execFile } from "child_process";
|
|
2
2
|
import { PACKAGE_VERSION } from "./protocol.js";
|
|
3
3
|
const PACKAGE_NAME = "@aiwerk/mcp-bridge";
|
|
4
|
+
const PLUGIN_PACKAGE_NAME = "@aiwerk/openclaw-mcp-bridge";
|
|
4
5
|
let cachedUpdateInfo = null;
|
|
6
|
+
let cachedPluginUpdateInfo = null;
|
|
5
7
|
let noticeDelivered = false;
|
|
6
8
|
/**
|
|
7
9
|
* Check npm registry for a newer version. Non-blocking, best-effort.
|
|
@@ -42,16 +44,59 @@ export async function checkForUpdate(logger) {
|
|
|
42
44
|
}
|
|
43
45
|
return cachedUpdateInfo;
|
|
44
46
|
}
|
|
47
|
+
/**
|
|
48
|
+
* Check if a wrapper plugin (e.g. openclaw-mcp-bridge) has an update.
|
|
49
|
+
* The caller passes the installed plugin version; we check npm for the latest.
|
|
50
|
+
*/
|
|
51
|
+
export async function checkPluginUpdate(pluginName, installedVersion, logger) {
|
|
52
|
+
if (cachedPluginUpdateInfo)
|
|
53
|
+
return cachedPluginUpdateInfo;
|
|
54
|
+
try {
|
|
55
|
+
const latest = await npmViewVersionOf(pluginName, logger);
|
|
56
|
+
const updateAvailable = latest !== installedVersion && isNewer(latest, installedVersion);
|
|
57
|
+
cachedPluginUpdateInfo = {
|
|
58
|
+
pluginName,
|
|
59
|
+
currentVersion: installedVersion,
|
|
60
|
+
latestVersion: latest,
|
|
61
|
+
updateAvailable,
|
|
62
|
+
};
|
|
63
|
+
if (updateAvailable) {
|
|
64
|
+
logger.info(`[mcp-bridge] Plugin update available: ${pluginName} ${installedVersion} → ${latest}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
catch (err) {
|
|
68
|
+
logger.warn(`[mcp-bridge] Plugin version check failed: ${err instanceof Error ? err.message : err}`);
|
|
69
|
+
cachedPluginUpdateInfo = {
|
|
70
|
+
pluginName,
|
|
71
|
+
currentVersion: installedVersion,
|
|
72
|
+
latestVersion: installedVersion,
|
|
73
|
+
updateAvailable: false,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
return cachedPluginUpdateInfo;
|
|
77
|
+
}
|
|
45
78
|
/**
|
|
46
79
|
* Build the notice string to inject into the first tool response.
|
|
47
80
|
* Returns empty string if no update or already delivered.
|
|
48
81
|
*/
|
|
49
82
|
export function getUpdateNotice() {
|
|
50
|
-
|
|
83
|
+
const coreUpdate = cachedUpdateInfo?.updateAvailable;
|
|
84
|
+
const pluginUpdate = cachedPluginUpdateInfo?.updateAvailable;
|
|
85
|
+
if (noticeDelivered || (!coreUpdate && !pluginUpdate))
|
|
51
86
|
return "";
|
|
52
87
|
noticeDelivered = true;
|
|
53
|
-
|
|
54
|
-
|
|
88
|
+
const lines = ["\n\n---"];
|
|
89
|
+
if (coreUpdate) {
|
|
90
|
+
lines.push(`Core update: ${cachedUpdateInfo.currentVersion} → ${cachedUpdateInfo.latestVersion}`);
|
|
91
|
+
}
|
|
92
|
+
if (pluginUpdate) {
|
|
93
|
+
lines.push(`Plugin update: ${cachedPluginUpdateInfo.pluginName} ${cachedPluginUpdateInfo.currentVersion} → ${cachedPluginUpdateInfo.latestVersion}`);
|
|
94
|
+
}
|
|
95
|
+
const installCmd = cachedPluginUpdateInfo?.pluginName
|
|
96
|
+
? `openclaw plugins install ${cachedPluginUpdateInfo.pluginName}`
|
|
97
|
+
: cachedUpdateInfo.updateCommand;
|
|
98
|
+
lines.push(`Run: ${installCmd}`);
|
|
99
|
+
return lines.join("\n");
|
|
55
100
|
}
|
|
56
101
|
/**
|
|
57
102
|
* Reset the notice flag (for testing).
|
|
@@ -89,8 +134,11 @@ export async function runUpdate(logger) {
|
|
|
89
134
|
}
|
|
90
135
|
// --- helpers ---
|
|
91
136
|
function npmViewVersion(_logger) {
|
|
137
|
+
return npmViewVersionOf(PACKAGE_NAME, _logger);
|
|
138
|
+
}
|
|
139
|
+
function npmViewVersionOf(packageName, _logger) {
|
|
92
140
|
return new Promise((resolve, reject) => {
|
|
93
|
-
execFile("npm", ["view",
|
|
141
|
+
execFile("npm", ["view", packageName, "version"], { encoding: "utf-8", timeout: 10_000 }, (err, stdout) => {
|
|
94
142
|
if (err)
|
|
95
143
|
return reject(err);
|
|
96
144
|
const ver = (stdout ?? "").trim();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aiwerk/mcp-bridge",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.1.1",
|
|
4
4
|
"description": "Standalone MCP server that multiplexes multiple MCP servers into one interface",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/src/index.js",
|
|
@@ -45,13 +45,21 @@
|
|
|
45
45
|
"test": "node --import tsx --test tests/*.test.ts",
|
|
46
46
|
"typecheck": "tsc --noEmit",
|
|
47
47
|
"prepublishOnly": "npm run build",
|
|
48
|
-
"validate-recipe": "npx tsx bin/validate-recipe.ts"
|
|
48
|
+
"validate-recipe": "npx tsx bin/validate-recipe.ts",
|
|
49
|
+
"lint": "eslint src/",
|
|
50
|
+
"format": "prettier --write src/",
|
|
51
|
+
"format:check": "prettier --check src/"
|
|
49
52
|
},
|
|
50
53
|
"dependencies": {
|
|
51
54
|
"@sinclair/typebox": "^0.34.0"
|
|
52
55
|
},
|
|
53
56
|
"devDependencies": {
|
|
57
|
+
"@eslint/js": "^10.0.1",
|
|
54
58
|
"@types/node": "^22.0.0",
|
|
59
|
+
"@typescript-eslint/eslint-plugin": "^8.57.0",
|
|
60
|
+
"@typescript-eslint/parser": "^8.57.0",
|
|
61
|
+
"eslint": "^10.0.3",
|
|
62
|
+
"prettier": "^3.8.1",
|
|
55
63
|
"tsx": "^4.0.0",
|
|
56
64
|
"typescript": "^5.7.0"
|
|
57
65
|
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "imap-email",
|
|
3
|
+
"schemaVersion": 2,
|
|
4
|
+
"name": "IMAP Email",
|
|
5
|
+
"description": "Email tools for any IMAP/SMTP provider - list, read, search, send, manage emails",
|
|
6
|
+
"recipeVersion": "1.0.0",
|
|
7
|
+
"metadata": {
|
|
8
|
+
"category": "communication",
|
|
9
|
+
"tags": ["email", "imap", "smtp", "inbox"],
|
|
10
|
+
"country": "global",
|
|
11
|
+
"language": "en",
|
|
12
|
+
"homepage": "https://github.com/AIWerk/mcp-server-imap",
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"pricing": "free",
|
|
15
|
+
"maturity": "beta"
|
|
16
|
+
},
|
|
17
|
+
"transports": [
|
|
18
|
+
{
|
|
19
|
+
"type": "stdio",
|
|
20
|
+
"command": "npx",
|
|
21
|
+
"args": ["-y", "@aiwerk/mcp-server-imap"],
|
|
22
|
+
"env": {
|
|
23
|
+
"IMAP_HOST": "${IMAP_HOST}",
|
|
24
|
+
"IMAP_PORT": "${IMAP_PORT}",
|
|
25
|
+
"IMAP_USER": "${IMAP_USER}",
|
|
26
|
+
"IMAP_PASS": "${IMAP_PASS}",
|
|
27
|
+
"SMTP_HOST": "${SMTP_HOST}",
|
|
28
|
+
"SMTP_PORT": "${SMTP_PORT}",
|
|
29
|
+
"SMTP_SEND_ENABLED": "${SMTP_SEND_ENABLED}"
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
],
|
|
33
|
+
"auth": {
|
|
34
|
+
"required": true,
|
|
35
|
+
"type": "api-key",
|
|
36
|
+
"envVars": ["IMAP_HOST", "IMAP_USER", "IMAP_PASS"],
|
|
37
|
+
"instructions": "Set your IMAP server hostname, username (email), and password. For Gmail use an App Password. Set SMTP_SEND_ENABLED=true to enable email sending.",
|
|
38
|
+
"bootstrap": "env-only"
|
|
39
|
+
},
|
|
40
|
+
"capabilities": {
|
|
41
|
+
"toolCount": 10,
|
|
42
|
+
"toolNames": ["email_list", "email_read", "email_search", "email_folders", "email_move", "email_flag", "email_delete", "email_send", "email_reply", "email_attachment"],
|
|
43
|
+
"sideEffects": "external-write"
|
|
44
|
+
}
|
|
45
|
+
}
|