@aiwerk/mcp-bridge 1.8.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +49 -0
- package/dist/src/config.js +1 -0
- package/dist/src/index.d.ts +6 -2
- package/dist/src/index.js +4 -1
- package/dist/src/mcp-router.d.ts +32 -0
- package/dist/src/mcp-router.js +228 -22
- package/dist/src/result-cache.d.ts +29 -0
- package/dist/src/result-cache.js +120 -0
- package/dist/src/standalone-server.js +22 -6
- package/dist/src/tool-resolution.d.ts +33 -0
- package/dist/src/tool-resolution.js +135 -0
- package/dist/src/transport-base.d.ts +8 -0
- package/dist/src/transport-base.js +20 -0
- package/dist/src/transport-sse.d.ts +2 -0
- package/dist/src/transport-sse.js +21 -6
- package/dist/src/transport-stdio.d.ts +1 -0
- package/dist/src/transport-stdio.js +20 -8
- package/dist/src/transport-streamable-http.d.ts +3 -0
- package/dist/src/transport-streamable-http.js +32 -14
- package/dist/src/types.d.ts +25 -0
- package/package.json +1 -1
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
const DEFAULT_MAX_ENTRIES = 100;
|
|
2
|
+
const DEFAULT_TTL_MS = 300_000;
|
|
3
|
+
function normalizeForStableJson(value, inArray) {
|
|
4
|
+
if (value === undefined || typeof value === "function" || typeof value === "symbol") {
|
|
5
|
+
return inArray ? null : undefined;
|
|
6
|
+
}
|
|
7
|
+
if (value === null || typeof value !== "object") {
|
|
8
|
+
return value;
|
|
9
|
+
}
|
|
10
|
+
if (Array.isArray(value)) {
|
|
11
|
+
return value.map((item) => normalizeForStableJson(item, true));
|
|
12
|
+
}
|
|
13
|
+
const obj = value;
|
|
14
|
+
const normalized = {};
|
|
15
|
+
const keys = Object.keys(obj).sort();
|
|
16
|
+
for (const key of keys) {
|
|
17
|
+
const normalizedValue = normalizeForStableJson(obj[key], false);
|
|
18
|
+
if (normalizedValue !== undefined) {
|
|
19
|
+
normalized[key] = normalizedValue;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return normalized;
|
|
23
|
+
}
|
|
24
|
+
export function stableStringify(value) {
|
|
25
|
+
const normalized = normalizeForStableJson(value, false);
|
|
26
|
+
const serialized = JSON.stringify(normalized);
|
|
27
|
+
return serialized === undefined ? "undefined" : serialized;
|
|
28
|
+
}
|
|
29
|
+
export function createResultCacheKey(server, tool, params) {
|
|
30
|
+
return `${server}:${tool}:${stableStringify(params)}`;
|
|
31
|
+
}
|
|
32
|
+
export class ResultCache {
|
|
33
|
+
maxEntries;
|
|
34
|
+
defaultTtlMs;
|
|
35
|
+
cacheTtl;
|
|
36
|
+
entries = new Map();
|
|
37
|
+
hits = 0;
|
|
38
|
+
misses = 0;
|
|
39
|
+
evictions = 0;
|
|
40
|
+
constructor(config = {}) {
|
|
41
|
+
this.maxEntries = config.maxEntries ?? DEFAULT_MAX_ENTRIES;
|
|
42
|
+
this.defaultTtlMs = config.defaultTtlMs ?? DEFAULT_TTL_MS;
|
|
43
|
+
this.cacheTtl = config.cacheTtl ?? {};
|
|
44
|
+
}
|
|
45
|
+
get(key) {
|
|
46
|
+
const entry = this.entries.get(key);
|
|
47
|
+
if (!entry) {
|
|
48
|
+
this.misses += 1;
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
if (entry.expiresAt <= Date.now()) {
|
|
52
|
+
this.entries.delete(key);
|
|
53
|
+
this.misses += 1;
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
this.entries.delete(key);
|
|
57
|
+
this.entries.set(key, entry);
|
|
58
|
+
this.hits += 1;
|
|
59
|
+
return entry.value;
|
|
60
|
+
}
|
|
61
|
+
set(key, value, ttlMs) {
|
|
62
|
+
const effectiveTtlMs = ttlMs ?? this.resolveTtlMsForKey(key);
|
|
63
|
+
const entry = {
|
|
64
|
+
value,
|
|
65
|
+
expiresAt: Date.now() + effectiveTtlMs
|
|
66
|
+
};
|
|
67
|
+
if (this.entries.has(key)) {
|
|
68
|
+
this.entries.delete(key);
|
|
69
|
+
}
|
|
70
|
+
this.entries.set(key, entry);
|
|
71
|
+
this.trimToCapacity();
|
|
72
|
+
}
|
|
73
|
+
invalidate(pattern) {
|
|
74
|
+
if (!pattern) {
|
|
75
|
+
const size = this.entries.size;
|
|
76
|
+
this.entries.clear();
|
|
77
|
+
return size;
|
|
78
|
+
}
|
|
79
|
+
let removed = 0;
|
|
80
|
+
for (const key of this.entries.keys()) {
|
|
81
|
+
const matches = typeof pattern === "string"
|
|
82
|
+
? key.includes(pattern)
|
|
83
|
+
: pattern.test(key);
|
|
84
|
+
if (matches) {
|
|
85
|
+
this.entries.delete(key);
|
|
86
|
+
removed += 1;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return removed;
|
|
90
|
+
}
|
|
91
|
+
stats() {
|
|
92
|
+
return {
|
|
93
|
+
hits: this.hits,
|
|
94
|
+
misses: this.misses,
|
|
95
|
+
evictions: this.evictions,
|
|
96
|
+
size: this.entries.size
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
resolveTtlMsForKey(key) {
|
|
100
|
+
const firstColon = key.indexOf(":");
|
|
101
|
+
const secondColon = key.indexOf(":", firstColon + 1);
|
|
102
|
+
if (firstColon === -1 || secondColon === -1) {
|
|
103
|
+
return this.defaultTtlMs;
|
|
104
|
+
}
|
|
105
|
+
const server = key.slice(0, firstColon);
|
|
106
|
+
const tool = key.slice(firstColon + 1, secondColon);
|
|
107
|
+
const override = this.cacheTtl[`${server}:${tool}`];
|
|
108
|
+
return override ?? this.defaultTtlMs;
|
|
109
|
+
}
|
|
110
|
+
trimToCapacity() {
|
|
111
|
+
while (this.entries.size > this.maxEntries) {
|
|
112
|
+
const oldestKey = this.entries.keys().next().value;
|
|
113
|
+
if (oldestKey === undefined) {
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
116
|
+
this.entries.delete(oldestKey);
|
|
117
|
+
this.evictions += 1;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
@@ -207,11 +207,24 @@ export class StandaloneServer {
|
|
|
207
207
|
type: "object",
|
|
208
208
|
properties: {
|
|
209
209
|
server: { type: "string", description: "Server name" },
|
|
210
|
-
action: { type: "string", description: "list | call | refresh | status" },
|
|
211
|
-
tool: { type: "string", description: "Tool name for action=call" },
|
|
212
|
-
params: { type: "object", description: "Tool arguments" }
|
|
210
|
+
action: { type: "string", description: "list | call | batch | refresh | status | intent | schema | promotions" },
|
|
211
|
+
tool: { type: "string", description: "Tool name for action=call/schema" },
|
|
212
|
+
params: { type: "object", description: "Tool arguments" },
|
|
213
|
+
calls: {
|
|
214
|
+
type: "array",
|
|
215
|
+
description: "Batch calls for action=batch",
|
|
216
|
+
items: {
|
|
217
|
+
type: "object",
|
|
218
|
+
properties: {
|
|
219
|
+
server: { type: "string" },
|
|
220
|
+
tool: { type: "string" },
|
|
221
|
+
params: { type: "object" }
|
|
222
|
+
},
|
|
223
|
+
required: ["server", "tool"]
|
|
224
|
+
}
|
|
225
|
+
}
|
|
213
226
|
},
|
|
214
|
-
required: [
|
|
227
|
+
required: []
|
|
215
228
|
}
|
|
216
229
|
}]
|
|
217
230
|
}
|
|
@@ -244,7 +257,10 @@ export class StandaloneServer {
|
|
|
244
257
|
error: { code: -32004, message: `Unknown tool: ${toolName}. In router mode, use the 'mcp' tool.` }
|
|
245
258
|
};
|
|
246
259
|
}
|
|
247
|
-
const
|
|
260
|
+
const dispatchParams = toolArgs.action === "batch"
|
|
261
|
+
? { ...(toolArgs.params ?? {}), calls: toolArgs.calls }
|
|
262
|
+
: toolArgs.params;
|
|
263
|
+
const result = await this.router.dispatch(toolArgs.server, toolArgs.action, toolArgs.tool, dispatchParams);
|
|
248
264
|
// Check if result is an error
|
|
249
265
|
if ("error" in result) {
|
|
250
266
|
return {
|
|
@@ -397,7 +413,7 @@ export class StandaloneServer {
|
|
|
397
413
|
async shutdown() {
|
|
398
414
|
this.logger.info("[mcp-bridge] Shutting down...");
|
|
399
415
|
if (this.router) {
|
|
400
|
-
await this.router.
|
|
416
|
+
await this.router.shutdown(this.config.shutdownTimeoutMs);
|
|
401
417
|
}
|
|
402
418
|
for (const [name, conn] of this.directConnections) {
|
|
403
419
|
try {
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export interface ToolResolutionCandidate {
|
|
2
|
+
server: string;
|
|
3
|
+
tool: string;
|
|
4
|
+
score: number;
|
|
5
|
+
suggested?: true;
|
|
6
|
+
}
|
|
7
|
+
export type ToolResolutionResult = {
|
|
8
|
+
server: string;
|
|
9
|
+
tool: string;
|
|
10
|
+
} | {
|
|
11
|
+
ambiguous: true;
|
|
12
|
+
message: string;
|
|
13
|
+
candidates: ToolResolutionCandidate[];
|
|
14
|
+
} | null;
|
|
15
|
+
export declare class ToolResolver {
|
|
16
|
+
private readonly basePriority;
|
|
17
|
+
private readonly toolsByName;
|
|
18
|
+
private readonly toolNamesByServer;
|
|
19
|
+
private readonly recentCalls;
|
|
20
|
+
constructor(serverOrder: string[]);
|
|
21
|
+
registerServerTools(server: string, tools: Array<{
|
|
22
|
+
name: string;
|
|
23
|
+
inputSchema: any;
|
|
24
|
+
}>): void;
|
|
25
|
+
removeServer(server: string): void;
|
|
26
|
+
resolve(toolName: string, params?: Record<string, unknown>, serverHint?: string): ToolResolutionResult;
|
|
27
|
+
recordCall(server: string, tool: string): void;
|
|
28
|
+
getKnownToolNames(): string[];
|
|
29
|
+
clear(): void;
|
|
30
|
+
private scoreCandidate;
|
|
31
|
+
private wasUsedRecently;
|
|
32
|
+
private computeParamMatch;
|
|
33
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
const RECENT_CALL_LIMIT = 5;
|
|
2
|
+
const BASE_PRIORITY_STEP = 0.1;
|
|
3
|
+
const BASE_PRIORITY_MIN = 0.1;
|
|
4
|
+
const RECENCY_BOOST = 0.3;
|
|
5
|
+
const PARAM_MATCH_WEIGHT = 0.2;
|
|
6
|
+
const AUTO_RESOLVE_DELTA = 0.15;
|
|
7
|
+
export class ToolResolver {
|
|
8
|
+
basePriority = new Map();
|
|
9
|
+
toolsByName = new Map();
|
|
10
|
+
toolNamesByServer = new Map();
|
|
11
|
+
recentCalls = [];
|
|
12
|
+
constructor(serverOrder) {
|
|
13
|
+
const reversed = [...serverOrder].reverse();
|
|
14
|
+
reversed.forEach((server, index) => {
|
|
15
|
+
const score = Math.max(1.0 - (index * BASE_PRIORITY_STEP), BASE_PRIORITY_MIN);
|
|
16
|
+
this.basePriority.set(server, score);
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
registerServerTools(server, tools) {
|
|
20
|
+
this.removeServer(server);
|
|
21
|
+
const names = new Set();
|
|
22
|
+
for (const tool of tools) {
|
|
23
|
+
if (!tool?.name)
|
|
24
|
+
continue;
|
|
25
|
+
const registered = {
|
|
26
|
+
server,
|
|
27
|
+
tool: tool.name,
|
|
28
|
+
inputSchema: tool.inputSchema
|
|
29
|
+
};
|
|
30
|
+
const existing = this.toolsByName.get(tool.name) ?? [];
|
|
31
|
+
existing.push(registered);
|
|
32
|
+
this.toolsByName.set(tool.name, existing);
|
|
33
|
+
names.add(tool.name);
|
|
34
|
+
}
|
|
35
|
+
this.toolNamesByServer.set(server, names);
|
|
36
|
+
}
|
|
37
|
+
removeServer(server) {
|
|
38
|
+
const previousNames = this.toolNamesByServer.get(server);
|
|
39
|
+
if (previousNames) {
|
|
40
|
+
for (const toolName of previousNames) {
|
|
41
|
+
const filtered = (this.toolsByName.get(toolName) ?? []).filter((entry) => entry.server !== server);
|
|
42
|
+
if (filtered.length === 0) {
|
|
43
|
+
this.toolsByName.delete(toolName);
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
this.toolsByName.set(toolName, filtered);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
this.toolNamesByServer.delete(server);
|
|
50
|
+
}
|
|
51
|
+
resolve(toolName, params, serverHint) {
|
|
52
|
+
const candidates = this.toolsByName.get(toolName) ?? [];
|
|
53
|
+
if (candidates.length === 0) {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
if (serverHint) {
|
|
57
|
+
const explicit = candidates.find((candidate) => candidate.server === serverHint);
|
|
58
|
+
if (!explicit) {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
return { server: explicit.server, tool: explicit.tool };
|
|
62
|
+
}
|
|
63
|
+
if (candidates.length === 1) {
|
|
64
|
+
return { server: candidates[0].server, tool: candidates[0].tool };
|
|
65
|
+
}
|
|
66
|
+
const scored = candidates
|
|
67
|
+
.map((candidate) => ({
|
|
68
|
+
...candidate,
|
|
69
|
+
score: this.scoreCandidate(candidate.server, candidate.inputSchema, params)
|
|
70
|
+
}))
|
|
71
|
+
.sort((a, b) => {
|
|
72
|
+
if (b.score !== a.score) {
|
|
73
|
+
return b.score - a.score;
|
|
74
|
+
}
|
|
75
|
+
return (this.basePriority.get(b.server) ?? BASE_PRIORITY_MIN) - (this.basePriority.get(a.server) ?? BASE_PRIORITY_MIN);
|
|
76
|
+
});
|
|
77
|
+
const first = scored[0];
|
|
78
|
+
const second = scored[1];
|
|
79
|
+
if (!second || (first.score - second.score) >= AUTO_RESOLVE_DELTA) {
|
|
80
|
+
return { server: first.server, tool: first.tool };
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
ambiguous: true,
|
|
84
|
+
message: `Multiple servers provide '${toolName}'. Please specify server=`,
|
|
85
|
+
candidates: scored.map((candidate, index) => ({
|
|
86
|
+
server: candidate.server,
|
|
87
|
+
tool: candidate.tool,
|
|
88
|
+
score: Number(candidate.score.toFixed(2)),
|
|
89
|
+
...(index === 0 ? { suggested: true } : {})
|
|
90
|
+
}))
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
recordCall(server, tool) {
|
|
94
|
+
this.recentCalls.push({ server, tool });
|
|
95
|
+
if (this.recentCalls.length > RECENT_CALL_LIMIT) {
|
|
96
|
+
this.recentCalls.shift();
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
getKnownToolNames() {
|
|
100
|
+
return [...this.toolsByName.keys()];
|
|
101
|
+
}
|
|
102
|
+
clear() {
|
|
103
|
+
this.toolsByName.clear();
|
|
104
|
+
this.toolNamesByServer.clear();
|
|
105
|
+
this.recentCalls.length = 0;
|
|
106
|
+
}
|
|
107
|
+
scoreCandidate(server, inputSchema, params) {
|
|
108
|
+
const base = this.basePriority.get(server) ?? BASE_PRIORITY_MIN;
|
|
109
|
+
const recency = this.wasUsedRecently(server) ? RECENCY_BOOST : 0;
|
|
110
|
+
const paramMatch = this.computeParamMatch(inputSchema, params) * PARAM_MATCH_WEIGHT;
|
|
111
|
+
return base + recency + paramMatch;
|
|
112
|
+
}
|
|
113
|
+
wasUsedRecently(server) {
|
|
114
|
+
return this.recentCalls.some((call) => call.server === server);
|
|
115
|
+
}
|
|
116
|
+
computeParamMatch(inputSchema, params) {
|
|
117
|
+
if (!params || typeof params !== "object") {
|
|
118
|
+
return 0;
|
|
119
|
+
}
|
|
120
|
+
const paramNames = Object.keys(params);
|
|
121
|
+
if (paramNames.length === 0) {
|
|
122
|
+
return 0;
|
|
123
|
+
}
|
|
124
|
+
const schemaProperties = inputSchema?.properties;
|
|
125
|
+
if (!schemaProperties || typeof schemaProperties !== "object") {
|
|
126
|
+
return 0;
|
|
127
|
+
}
|
|
128
|
+
const propertyNames = new Set(Object.keys(schemaProperties));
|
|
129
|
+
if (propertyNames.size === 0) {
|
|
130
|
+
return 0;
|
|
131
|
+
}
|
|
132
|
+
const matching = paramNames.filter((paramName) => propertyNames.has(paramName)).length;
|
|
133
|
+
return matching / paramNames.length;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
@@ -70,6 +70,14 @@ export declare function resolveEnvRecord(record: Record<string, string>, context
|
|
|
70
70
|
* @param extraEnv - Additional env vars to check before process.env
|
|
71
71
|
*/
|
|
72
72
|
export declare function resolveArgs(args: string[], extraEnv?: Record<string, string | undefined>, envFallback?: () => Record<string, string>): string[];
|
|
73
|
+
/**
|
|
74
|
+
* Resolve auth config into HTTP headers.
|
|
75
|
+
*/
|
|
76
|
+
export declare function resolveAuthHeaders(config: McpServerConfig, extraEnv?: Record<string, string | undefined>, envFallback?: () => Record<string, string>): Record<string, string>;
|
|
77
|
+
/**
|
|
78
|
+
* Resolve server headers and merge auth headers (auth takes precedence).
|
|
79
|
+
*/
|
|
80
|
+
export declare function resolveServerHeaders(config: McpServerConfig, extraEnv?: Record<string, string | undefined>, envFallback?: () => Record<string, string>): Record<string, string>;
|
|
73
81
|
/**
|
|
74
82
|
* Warn if a URL uses non-TLS HTTP to a remote (non-localhost) host.
|
|
75
83
|
*/
|
|
@@ -159,6 +159,26 @@ export function resolveEnvRecord(record, contextPrefix, extraEnv, envFallback) {
|
|
|
159
159
|
export function resolveArgs(args, extraEnv, envFallback) {
|
|
160
160
|
return args.map(arg => resolveEnvVars(arg, `arg "${arg}"`, extraEnv, envFallback));
|
|
161
161
|
}
|
|
162
|
+
/**
|
|
163
|
+
* Resolve auth config into HTTP headers.
|
|
164
|
+
*/
|
|
165
|
+
export function resolveAuthHeaders(config, extraEnv, envFallback) {
|
|
166
|
+
if (!config.auth)
|
|
167
|
+
return {};
|
|
168
|
+
if (config.auth.type === "bearer") {
|
|
169
|
+
const token = resolveEnvVars(config.auth.token, "auth token", extraEnv, envFallback);
|
|
170
|
+
return { Authorization: `Bearer ${token}` };
|
|
171
|
+
}
|
|
172
|
+
return resolveEnvRecord(config.auth.headers, "auth header", extraEnv, envFallback);
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Resolve server headers and merge auth headers (auth takes precedence).
|
|
176
|
+
*/
|
|
177
|
+
export function resolveServerHeaders(config, extraEnv, envFallback) {
|
|
178
|
+
const base = resolveEnvRecord(config.headers || {}, "header", extraEnv, envFallback);
|
|
179
|
+
const auth = resolveAuthHeaders(config, extraEnv, envFallback);
|
|
180
|
+
return { ...base, ...auth };
|
|
181
|
+
}
|
|
162
182
|
/**
|
|
163
183
|
* Warn if a URL uses non-TLS HTTP to a remote (non-localhost) host.
|
|
164
184
|
*/
|
|
@@ -4,6 +4,7 @@ export declare class SseTransport extends BaseTransport {
|
|
|
4
4
|
private endpointUrl;
|
|
5
5
|
private sseAbortController;
|
|
6
6
|
private resolvedHeaders;
|
|
7
|
+
private pendingRequestControllers;
|
|
7
8
|
protected get transportName(): string;
|
|
8
9
|
connect(): Promise<void>;
|
|
9
10
|
private _onEndpointReceived;
|
|
@@ -13,4 +14,5 @@ export declare class SseTransport extends BaseTransport {
|
|
|
13
14
|
sendRequest(request: McpRequest): Promise<McpResponse>;
|
|
14
15
|
private isSameOrigin;
|
|
15
16
|
disconnect(): Promise<void>;
|
|
17
|
+
shutdown(): Promise<void>;
|
|
16
18
|
}
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { nextRequestId } from "./types.js";
|
|
2
|
-
import { BaseTransport,
|
|
2
|
+
import { BaseTransport, resolveServerHeaders, warnIfNonTlsRemoteUrl } from "./transport-base.js";
|
|
3
3
|
export class SseTransport extends BaseTransport {
|
|
4
4
|
endpointUrl = null;
|
|
5
5
|
sseAbortController = null;
|
|
6
6
|
resolvedHeaders = null;
|
|
7
|
+
pendingRequestControllers = new Map();
|
|
7
8
|
get transportName() { return "SSE"; }
|
|
8
9
|
async connect() {
|
|
9
10
|
if (!this.config.url) {
|
|
@@ -11,7 +12,7 @@ export class SseTransport extends BaseTransport {
|
|
|
11
12
|
}
|
|
12
13
|
warnIfNonTlsRemoteUrl(this.config.url, this.logger);
|
|
13
14
|
// Resolve headers once and cache for all subsequent requests
|
|
14
|
-
this.resolvedHeaders =
|
|
15
|
+
this.resolvedHeaders = resolveServerHeaders(this.config);
|
|
15
16
|
if (this.sseAbortController) {
|
|
16
17
|
this.sseAbortController.abort();
|
|
17
18
|
}
|
|
@@ -36,7 +37,7 @@ export class SseTransport extends BaseTransport {
|
|
|
36
37
|
async startEventStream() {
|
|
37
38
|
if (!this.config.url)
|
|
38
39
|
return;
|
|
39
|
-
const base = this.resolvedHeaders ??
|
|
40
|
+
const base = this.resolvedHeaders ?? resolveServerHeaders(this.config);
|
|
40
41
|
const headers = { ...base, "Accept": "text/event-stream" };
|
|
41
42
|
try {
|
|
42
43
|
const response = await fetch(this.config.url, {
|
|
@@ -130,7 +131,7 @@ export class SseTransport extends BaseTransport {
|
|
|
130
131
|
if (!this.connected || !this.endpointUrl) {
|
|
131
132
|
throw new Error("SSE transport not connected or no endpoint URL");
|
|
132
133
|
}
|
|
133
|
-
const base = this.resolvedHeaders ??
|
|
134
|
+
const base = this.resolvedHeaders ?? resolveServerHeaders(this.config);
|
|
134
135
|
const headers = { ...base, "Content-Type": "application/json" };
|
|
135
136
|
const response = await fetch(this.endpointUrl, {
|
|
136
137
|
method: "POST",
|
|
@@ -150,21 +151,27 @@ export class SseTransport extends BaseTransport {
|
|
|
150
151
|
return new Promise((resolve, reject) => {
|
|
151
152
|
const requestTimeout = this.clientConfig.requestTimeoutMs || 60000;
|
|
152
153
|
const timeout = setTimeout(() => {
|
|
154
|
+
this.pendingRequestControllers.get(id)?.abort();
|
|
155
|
+
this.pendingRequestControllers.delete(id);
|
|
153
156
|
this.pendingRequests.delete(id);
|
|
154
157
|
reject(new Error(`Request timeout after ${requestTimeout}ms`));
|
|
155
158
|
}, requestTimeout);
|
|
156
159
|
this.pendingRequests.set(id, { resolve, reject, timeout });
|
|
157
|
-
const base = this.resolvedHeaders ??
|
|
160
|
+
const base = this.resolvedHeaders ?? resolveServerHeaders(this.config);
|
|
158
161
|
const headers = { ...base, "Content-Type": "application/json" };
|
|
162
|
+
const abortController = new AbortController();
|
|
163
|
+
this.pendingRequestControllers.set(id, abortController);
|
|
159
164
|
// The response arrives via the SSE stream (handleMessage), not from this fetch.
|
|
160
165
|
// The fetch only confirms the server accepted the request (HTTP 200).
|
|
161
166
|
// If the fetch fails, we reject immediately; otherwise we wait for the SSE stream.
|
|
162
167
|
fetch(this.endpointUrl, {
|
|
163
168
|
method: "POST",
|
|
164
169
|
headers,
|
|
165
|
-
body: JSON.stringify(requestWithId)
|
|
170
|
+
body: JSON.stringify(requestWithId),
|
|
171
|
+
signal: abortController.signal
|
|
166
172
|
})
|
|
167
173
|
.then((response) => {
|
|
174
|
+
this.pendingRequestControllers.delete(id);
|
|
168
175
|
if (!response.ok) {
|
|
169
176
|
clearTimeout(timeout);
|
|
170
177
|
this.pendingRequests.delete(id);
|
|
@@ -172,6 +179,7 @@ export class SseTransport extends BaseTransport {
|
|
|
172
179
|
}
|
|
173
180
|
})
|
|
174
181
|
.catch((error) => {
|
|
182
|
+
this.pendingRequestControllers.delete(id);
|
|
175
183
|
clearTimeout(timeout);
|
|
176
184
|
this.pendingRequests.delete(id);
|
|
177
185
|
reject(error);
|
|
@@ -197,6 +205,13 @@ export class SseTransport extends BaseTransport {
|
|
|
197
205
|
this.sseAbortController.abort();
|
|
198
206
|
this.sseAbortController = null;
|
|
199
207
|
}
|
|
208
|
+
for (const [, controller] of this.pendingRequestControllers) {
|
|
209
|
+
controller.abort();
|
|
210
|
+
}
|
|
211
|
+
this.pendingRequestControllers.clear();
|
|
200
212
|
this.rejectAllPending("Connection closed");
|
|
201
213
|
}
|
|
214
|
+
async shutdown() {
|
|
215
|
+
await this.disconnect();
|
|
216
|
+
}
|
|
202
217
|
}
|
|
@@ -15,6 +15,7 @@ export declare class StdioTransport extends BaseTransport {
|
|
|
15
15
|
private parseNewlineMessageFromBuffer;
|
|
16
16
|
private parseLspMessageFromBuffer;
|
|
17
17
|
disconnect(): Promise<void>;
|
|
18
|
+
shutdown(timeoutMs?: number): Promise<void>;
|
|
18
19
|
isConnected(): boolean;
|
|
19
20
|
private terminateProcessGracefully;
|
|
20
21
|
}
|
|
@@ -264,7 +264,20 @@ export class StdioTransport extends BaseTransport {
|
|
|
264
264
|
this.logger.debug("[mcp-bridge] Failed to send close notification during stdio disconnect");
|
|
265
265
|
}
|
|
266
266
|
}
|
|
267
|
-
await this.terminateProcessGracefully(activeProcess);
|
|
267
|
+
await this.terminateProcessGracefully(activeProcess, this.clientConfig.shutdownTimeoutMs ?? 5000);
|
|
268
|
+
if (this.process === activeProcess) {
|
|
269
|
+
this.process = null;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
this.rejectAllPending("Connection closed");
|
|
273
|
+
}
|
|
274
|
+
async shutdown(timeoutMs = this.clientConfig.shutdownTimeoutMs ?? 5000) {
|
|
275
|
+
this.isShuttingDown = true;
|
|
276
|
+
this.connected = false;
|
|
277
|
+
this.cleanupReconnectTimer();
|
|
278
|
+
const activeProcess = this.process;
|
|
279
|
+
if (activeProcess) {
|
|
280
|
+
await this.terminateProcessGracefully(activeProcess, timeoutMs);
|
|
268
281
|
if (this.process === activeProcess) {
|
|
269
282
|
this.process = null;
|
|
270
283
|
}
|
|
@@ -274,8 +287,8 @@ export class StdioTransport extends BaseTransport {
|
|
|
274
287
|
isConnected() {
|
|
275
288
|
return this.connected && this.process !== null;
|
|
276
289
|
}
|
|
277
|
-
async terminateProcessGracefully(proc) {
|
|
278
|
-
if (proc.exitCode !== null
|
|
290
|
+
async terminateProcessGracefully(proc, timeoutMs) {
|
|
291
|
+
if (proc.exitCode !== null)
|
|
279
292
|
return;
|
|
280
293
|
await new Promise((resolve) => {
|
|
281
294
|
let done = false;
|
|
@@ -292,21 +305,20 @@ export class StdioTransport extends BaseTransport {
|
|
|
292
305
|
const onExit = () => finish();
|
|
293
306
|
proc.once("exit", onExit);
|
|
294
307
|
try {
|
|
295
|
-
proc.kill("
|
|
308
|
+
proc.kill("SIGTERM");
|
|
296
309
|
}
|
|
297
310
|
catch {
|
|
298
311
|
finish();
|
|
299
312
|
return;
|
|
300
313
|
}
|
|
301
314
|
forceKillTimer = setTimeout(() => {
|
|
302
|
-
if (proc.exitCode === null
|
|
315
|
+
if (proc.exitCode === null) {
|
|
303
316
|
try {
|
|
304
|
-
proc.kill("
|
|
317
|
+
proc.kill("SIGKILL");
|
|
305
318
|
}
|
|
306
319
|
catch { /* ignore */ }
|
|
307
320
|
}
|
|
308
|
-
|
|
309
|
-
}, 2000);
|
|
321
|
+
}, Math.max(0, timeoutMs));
|
|
310
322
|
});
|
|
311
323
|
}
|
|
312
324
|
}
|
|
@@ -2,10 +2,13 @@ import { McpRequest, McpResponse } from "./types.js";
|
|
|
2
2
|
import { BaseTransport } from "./transport-base.js";
|
|
3
3
|
export declare class StreamableHttpTransport extends BaseTransport {
|
|
4
4
|
private sessionId?;
|
|
5
|
+
private resolvedHeaders;
|
|
6
|
+
private pendingRequestControllers;
|
|
5
7
|
protected get transportName(): string;
|
|
6
8
|
connect(): Promise<void>;
|
|
7
9
|
sendRequest(request: McpRequest): Promise<McpResponse>;
|
|
8
10
|
sendNotification(notification: any): Promise<void>;
|
|
9
11
|
private probeServer;
|
|
10
12
|
disconnect(): Promise<void>;
|
|
13
|
+
shutdown(): Promise<void>;
|
|
11
14
|
}
|