viafrei 0.0.2 → 1.3.12
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/CHANGELOG.md +271 -0
- package/LICENSE +218 -0
- package/NOTICE +32 -0
- package/README.md +199 -1
- package/SOURCES.md +454 -0
- package/dist/bridge.d.ts +23 -0
- package/dist/bridge.js +242 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +113 -0
- package/dist/config.d.ts +59 -0
- package/dist/config.js +177 -0
- package/dist/failure.d.ts +61 -0
- package/dist/failure.js +186 -0
- package/dist/fetch.d.ts +24 -0
- package/dist/fetch.js +120 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +11 -0
- package/dist/version.d.ts +5 -0
- package/dist/version.js +23 -0
- package/package.json +64 -5
- package/index.js +0 -2
package/dist/bridge.js
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
|
2
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
3
|
+
import { isJSONRPCErrorResponse, isJSONRPCRequest, isJSONRPCResultResponse } from '@modelcontextprotocol/sdk/types.js';
|
|
4
|
+
import { EXIT } from './config.js';
|
|
5
|
+
import { createFetch } from './fetch.js';
|
|
6
|
+
import { describeFailure } from './failure.js';
|
|
7
|
+
/** JSON-RPC error code we return when the relay itself could not deliver. */
|
|
8
|
+
const RELAY_FAILED = -32011;
|
|
9
|
+
/**
|
|
10
|
+
* How many failures in a row, with nothing succeeding in between, mean the
|
|
11
|
+
* endpoint is gone rather than having a moment.
|
|
12
|
+
*
|
|
13
|
+
* An established session is allowed to wobble - a dropped event stream comes
|
|
14
|
+
* back, a proxy restarts. It is not allowed to be dead for ever in silence:
|
|
15
|
+
* this bridge is a child process of an MCP client, and a child that has stopped
|
|
16
|
+
* working must exit so the client can say so, not sit there warning.
|
|
17
|
+
*/
|
|
18
|
+
const DEAD_AFTER_CONSECUTIVE_FAILURES = 3;
|
|
19
|
+
/** The SDK's own words for "I have stopped trying to reopen the stream". */
|
|
20
|
+
const GAVE_UP_RECONNECTING = /Maximum reconnection attempts/iu;
|
|
21
|
+
/** HTTP statuses that mean the session this bridge holds no longer exists. */
|
|
22
|
+
const SESSION_GONE = new Set([404, 410]);
|
|
23
|
+
function readProtocolVersion(value) {
|
|
24
|
+
if (value !== null && typeof value === 'object') {
|
|
25
|
+
const version = value.protocolVersion;
|
|
26
|
+
if (typeof version === 'string') {
|
|
27
|
+
return version;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Pull whatever the server said about versions out of an `initialize` error.
|
|
34
|
+
* Servers differ; we look where they actually put it rather than insisting on
|
|
35
|
+
* one shape and reporting nothing when it is a different one.
|
|
36
|
+
*/
|
|
37
|
+
function readSupportedVersions(data) {
|
|
38
|
+
if (data === null || typeof data !== 'object') {
|
|
39
|
+
return [];
|
|
40
|
+
}
|
|
41
|
+
const record = data;
|
|
42
|
+
const candidates = [record['supported'], record['supportedVersions'], record['supported_versions'], record['protocolVersion']];
|
|
43
|
+
const versions = [];
|
|
44
|
+
for (const candidate of candidates) {
|
|
45
|
+
if (typeof candidate === 'string') {
|
|
46
|
+
versions.push(candidate);
|
|
47
|
+
}
|
|
48
|
+
else if (Array.isArray(candidate)) {
|
|
49
|
+
versions.push(...candidate.filter((entry) => typeof entry === 'string'));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return [...new Set(versions)];
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Relay every JSON-RPC message between a local stdio client and a remote
|
|
56
|
+
* Streamable-HTTP MCP endpoint, in both directions, unchanged.
|
|
57
|
+
*
|
|
58
|
+
* The bridge is deliberately a *message* relay and not a client/server pair
|
|
59
|
+
* that re-implements the protocol: it never has an opinion about a method it
|
|
60
|
+
* has not heard of, so a tool added on the server works here the same day
|
|
61
|
+
* without a release. The only messages it looks inside are `initialize` and its
|
|
62
|
+
* answer - because the session's protocol version has to end up on the HTTP
|
|
63
|
+
* headers, and because a version the server cannot speak deserves a sentence
|
|
64
|
+
* rather than a stack trace.
|
|
65
|
+
*/
|
|
66
|
+
export async function startBridge(options, hooks) {
|
|
67
|
+
const remote = new StreamableHTTPClientTransport(new URL(options.url), {
|
|
68
|
+
fetch: createFetch(options.timeoutMs),
|
|
69
|
+
requestInit: { headers: { ...options.headers } }
|
|
70
|
+
});
|
|
71
|
+
const input = process.stdin;
|
|
72
|
+
const local = new StdioServerTransport(input, process.stdout);
|
|
73
|
+
const pendingInitialize = new Map();
|
|
74
|
+
let initialized = false;
|
|
75
|
+
let closing = false;
|
|
76
|
+
let finished = false;
|
|
77
|
+
/**
|
|
78
|
+
* The SDK transport calls `onerror` *and* rejects the `send()` promise for
|
|
79
|
+
* the same failure, and `onerror` runs first. Counting the sends in flight
|
|
80
|
+
* lets the rejection path own that failure - it is the only one that knows
|
|
81
|
+
* which request failed and can answer the client - and leaves `onerror` for
|
|
82
|
+
* the failures nothing is waiting on, such as the event stream dropping.
|
|
83
|
+
*/
|
|
84
|
+
let sendsInFlight = 0;
|
|
85
|
+
/** Reset by anything arriving from the endpoint; see the constant above. */
|
|
86
|
+
let consecutiveFailures = 0;
|
|
87
|
+
const finish = (line, exitCode) => {
|
|
88
|
+
if (finished) {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
finished = true;
|
|
92
|
+
hooks.fatal(line, exitCode);
|
|
93
|
+
};
|
|
94
|
+
const close = async () => {
|
|
95
|
+
if (closing) {
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
closing = true;
|
|
99
|
+
await Promise.allSettled([remote.close(), local.close()]);
|
|
100
|
+
};
|
|
101
|
+
/** Tell the local client that a request could not be delivered. */
|
|
102
|
+
const replyRelayFailure = async (id, line) => {
|
|
103
|
+
try {
|
|
104
|
+
await local.send({ jsonrpc: '2.0', id, error: { code: RELAY_FAILED, message: line } });
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
// The local client is gone; the shutdown path below handles it.
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
// ---- local (stdio client) -> remote (Streamable HTTP) -------------------
|
|
111
|
+
local.onmessage = (message) => {
|
|
112
|
+
const isInitialize = isJSONRPCRequest(message) && message.method === 'initialize';
|
|
113
|
+
if (isInitialize && isJSONRPCRequest(message)) {
|
|
114
|
+
pendingInitialize.set(message.id, { requested: readProtocolVersion(message.params) });
|
|
115
|
+
}
|
|
116
|
+
sendsInFlight += 1;
|
|
117
|
+
void remote
|
|
118
|
+
.send(message)
|
|
119
|
+
.catch(async (error) => {
|
|
120
|
+
const failure = describeFailure(error, options.url);
|
|
121
|
+
if (isInitialize) {
|
|
122
|
+
// Nothing works from here, and the client is waiting on this
|
|
123
|
+
// one answer. Tell it, then say it once and stop.
|
|
124
|
+
if (isJSONRPCRequest(message)) {
|
|
125
|
+
await replyRelayFailure(message.id, failure.line);
|
|
126
|
+
}
|
|
127
|
+
finish(failure.line, failure.exitCode);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
hooks.warn(failure.line);
|
|
131
|
+
if (isJSONRPCRequest(message)) {
|
|
132
|
+
await replyRelayFailure(message.id, failure.line);
|
|
133
|
+
}
|
|
134
|
+
if (failure.status !== undefined && SESSION_GONE.has(failure.status)) {
|
|
135
|
+
// The server has forgotten this session. Nothing sent from
|
|
136
|
+
// here can work again, and the client cannot re-initialize
|
|
137
|
+
// through a bridge that is still pretending.
|
|
138
|
+
finish(`viafrei: ${options.url} no longer knows this session (HTTP ${failure.status}) - it expired or the server restarted; start the client's connection again`, EXIT.REFUSED);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
noteFailure(failure);
|
|
142
|
+
})
|
|
143
|
+
.finally(() => {
|
|
144
|
+
sendsInFlight -= 1;
|
|
145
|
+
});
|
|
146
|
+
};
|
|
147
|
+
// ---- remote (Streamable HTTP) -> local (stdio client) -------------------
|
|
148
|
+
remote.onmessage = (message) => {
|
|
149
|
+
// The endpoint is alive: whatever went wrong before is over.
|
|
150
|
+
consecutiveFailures = 0;
|
|
151
|
+
const id = 'id' in message ? message.id : undefined;
|
|
152
|
+
const pending = id === undefined ? undefined : pendingInitialize.get(id);
|
|
153
|
+
let fatalAfterRelay;
|
|
154
|
+
if (pending !== undefined && id !== undefined) {
|
|
155
|
+
pendingInitialize.delete(id);
|
|
156
|
+
if (isJSONRPCResultResponse(message)) {
|
|
157
|
+
const served = readProtocolVersion(message.result);
|
|
158
|
+
if (served !== undefined) {
|
|
159
|
+
remote.setProtocolVersion?.(served);
|
|
160
|
+
initialized = true;
|
|
161
|
+
if (pending.requested !== undefined && served !== pending.requested) {
|
|
162
|
+
hooks.warn(`viafrei: ${options.url} speaks MCP protocol ${served}, this client asked for ${pending.requested}; relaying the server's answer unchanged`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
else if (isJSONRPCErrorResponse(message)) {
|
|
167
|
+
const supported = readSupportedVersions(message.error.data);
|
|
168
|
+
const spoken = supported.length > 0 ? `the server speaks ${supported.join(', ')}` : `the server did not say which versions it speaks`;
|
|
169
|
+
fatalAfterRelay = `viafrei: ${options.url} rejected MCP protocol version ${pending.requested ?? '(unspecified)'}; ${spoken}`;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
void local.send(message).then(() => {
|
|
173
|
+
if (fatalAfterRelay !== undefined) {
|
|
174
|
+
finish(fatalAfterRelay, EXIT.PROTOCOL);
|
|
175
|
+
}
|
|
176
|
+
}, (error) => {
|
|
177
|
+
hooks.warn(`viafrei: could not write to the local client: ${error instanceof Error ? error.message : String(error)}`);
|
|
178
|
+
});
|
|
179
|
+
};
|
|
180
|
+
/**
|
|
181
|
+
* Count one failure the session survived - and stop if they stop stopping.
|
|
182
|
+
*
|
|
183
|
+
* Without this a permanently dead endpoint produced a warning per event and
|
|
184
|
+
* nothing else, for ever: the documented exit 3 never came, and the client
|
|
185
|
+
* kept a bridge that could not relay.
|
|
186
|
+
*/
|
|
187
|
+
const noteFailure = (failure) => {
|
|
188
|
+
consecutiveFailures += 1;
|
|
189
|
+
if (consecutiveFailures >= DEAD_AFTER_CONSECUTIVE_FAILURES) {
|
|
190
|
+
finish(`viafrei: ${options.url} has failed ${consecutiveFailures} times in a row with nothing succeeding in between - giving up. Last failure: ${failure.line.replace(/^viafrei: /u, '')}`, failure.exitCode);
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
remote.onerror = (error) => {
|
|
194
|
+
if (closing || sendsInFlight > 0) {
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
const failure = describeFailure(error, options.url);
|
|
198
|
+
if (!initialized) {
|
|
199
|
+
finish(failure.line, failure.exitCode);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
if (GAVE_UP_RECONNECTING.test(error.message)) {
|
|
203
|
+
// The SDK has run out of reconnection attempts. The session is up on
|
|
204
|
+
// paper and dead in fact; say which, once, and exit.
|
|
205
|
+
finish(`viafrei: lost the event stream from ${options.url} and could not reopen it - the endpoint is not answering any more`, EXIT.UNREACHABLE);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
// The session is up; a stream can drop and come back. Say so once per
|
|
209
|
+
// event, keep relaying - but keep count.
|
|
210
|
+
hooks.warn(failure.line);
|
|
211
|
+
noteFailure(failure);
|
|
212
|
+
};
|
|
213
|
+
remote.onclose = () => {
|
|
214
|
+
if (closing) {
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
finish(`viafrei: ${options.url} closed the session`, EXIT.UNREACHABLE);
|
|
218
|
+
};
|
|
219
|
+
local.onerror = (error) => {
|
|
220
|
+
hooks.warn(`viafrei: local stdio error: ${error.message}`);
|
|
221
|
+
};
|
|
222
|
+
local.onclose = () => {
|
|
223
|
+
if (closing) {
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
finish('', EXIT.OK);
|
|
227
|
+
};
|
|
228
|
+
/**
|
|
229
|
+
* Closing stdin is how an MCP client says goodbye, and the transport does
|
|
230
|
+
* not watch for it. Without this the bridge outlives its client: the
|
|
231
|
+
* standalone event stream keeps the event loop alive and the process hangs
|
|
232
|
+
* around holding a session open on the server.
|
|
233
|
+
*/
|
|
234
|
+
input.once('end', () => {
|
|
235
|
+
void close().finally(() => {
|
|
236
|
+
finish('', EXIT.OK);
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
await remote.start();
|
|
240
|
+
await local.start();
|
|
241
|
+
return { close };
|
|
242
|
+
}
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { realpathSync, writeSync } from 'node:fs';
|
|
3
|
+
import { pathToFileURL } from 'node:url';
|
|
4
|
+
import { startBridge } from './bridge.js';
|
|
5
|
+
import { EXIT, UsageError, helpText, parseOptions } from './config.js';
|
|
6
|
+
import { packageVersion } from './version.js';
|
|
7
|
+
/**
|
|
8
|
+
* Both writers are synchronous on purpose: the next thing that happens is
|
|
9
|
+
* usually `process.exit`, and a buffered write to a pipe is lost when it does.
|
|
10
|
+
* A failure message that does not survive the exit is not a failure message.
|
|
11
|
+
*/
|
|
12
|
+
/** One line to stdout - `--help`, `--version`. Never protocol traffic. */
|
|
13
|
+
function out(line) {
|
|
14
|
+
try {
|
|
15
|
+
writeSync(1, `${line}\n`);
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
// stdout is closed. Nothing to be done about it here.
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/** One line to stderr. stdout belongs to the protocol; diagnostics go here. */
|
|
22
|
+
function say(line) {
|
|
23
|
+
try {
|
|
24
|
+
writeSync(2, `${line}\n`);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
// stderr is closed. There is nowhere left to complain.
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
export async function main(argv = process.argv.slice(2)) {
|
|
31
|
+
const version = packageVersion();
|
|
32
|
+
let options;
|
|
33
|
+
try {
|
|
34
|
+
options = parseOptions(argv);
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
if (error instanceof UsageError) {
|
|
38
|
+
say(`viafrei: ${error.message}`);
|
|
39
|
+
process.exit(EXIT.USAGE);
|
|
40
|
+
}
|
|
41
|
+
throw error;
|
|
42
|
+
}
|
|
43
|
+
if (options.showHelp) {
|
|
44
|
+
out(helpText(version));
|
|
45
|
+
process.exit(EXIT.OK);
|
|
46
|
+
}
|
|
47
|
+
if (options.showVersion) {
|
|
48
|
+
out(version);
|
|
49
|
+
process.exit(EXIT.OK);
|
|
50
|
+
}
|
|
51
|
+
if (process.stdin.isTTY) {
|
|
52
|
+
say(`viafrei ${version}: speaking MCP over stdio, relaying to ${options.url}. This is meant to be started by an MCP client; "viafrei --help" explains the options.`);
|
|
53
|
+
}
|
|
54
|
+
let stopping = false;
|
|
55
|
+
const stop = (line, code) => {
|
|
56
|
+
if (stopping) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
stopping = true;
|
|
60
|
+
if (line !== '') {
|
|
61
|
+
say(line);
|
|
62
|
+
}
|
|
63
|
+
process.exit(code);
|
|
64
|
+
};
|
|
65
|
+
const handle = await startBridge(options, {
|
|
66
|
+
warn: say,
|
|
67
|
+
fatal: (line, code) => {
|
|
68
|
+
stop(line, code);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
for (const signal of ['SIGINT', 'SIGTERM']) {
|
|
72
|
+
process.on(signal, () => {
|
|
73
|
+
void handle.close().finally(() => {
|
|
74
|
+
process.exit(EXIT.OK);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Was this file the program the user started, rather than something imported?
|
|
81
|
+
*
|
|
82
|
+
* The entry point is compared BOTH as given and resolved through symlinks,
|
|
83
|
+
* because the only way anybody actually starts this package is the symlink npm
|
|
84
|
+
* writes on install: `node_modules/.bin/viafrei -> ../viafrei/dist/cli.js`.
|
|
85
|
+
* Node resolves a module's own URL through symlinks, so `argv[1]` is the link
|
|
86
|
+
* while `import.meta.url` is its target; comparing only the unresolved form
|
|
87
|
+
* made this false for every real invocation, and the process then loaded the
|
|
88
|
+
* file, ran nothing and exited 0 in silence - which is what `npx viafrei` did.
|
|
89
|
+
* Comparing only the resolved form is not enough either: under
|
|
90
|
+
* `--preserve-symlinks-main` the module URL is the link, so both forms are
|
|
91
|
+
* offered and a match on either is the answer.
|
|
92
|
+
*/
|
|
93
|
+
function isEntryPoint() {
|
|
94
|
+
const entry = process.argv[1];
|
|
95
|
+
if (entry === undefined) {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
const candidates = [pathToFileURL(entry).href];
|
|
99
|
+
try {
|
|
100
|
+
candidates.push(pathToFileURL(realpathSync(entry)).href);
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
// The entry point cannot be resolved on disk; the form given is all there is.
|
|
104
|
+
}
|
|
105
|
+
return candidates.includes(import.meta.url);
|
|
106
|
+
}
|
|
107
|
+
const invokedDirectly = isEntryPoint();
|
|
108
|
+
if (invokedDirectly || process.env['VIAFREI_FORCE_CLI'] === '1') {
|
|
109
|
+
main().catch((error) => {
|
|
110
|
+
say(`viafrei: ${error instanceof Error ? error.message : String(error)}`);
|
|
111
|
+
process.exit(EXIT.UNEXPECTED);
|
|
112
|
+
});
|
|
113
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration for the bridge: where it connects, with which headers, and how
|
|
3
|
+
* long it waits. Every value has exactly one definition here, so the default
|
|
4
|
+
* endpoint is never typed twice.
|
|
5
|
+
*/
|
|
6
|
+
/** The public ViaFrei MCP deployment. Written once. */
|
|
7
|
+
export declare const DEFAULT_MCP_ORIGIN = "https://mcp.viafrei.de";
|
|
8
|
+
/** The MCP endpoint path. Streamable HTTP only; SSE is deprecated and absent. */
|
|
9
|
+
export declare const DEFAULT_MCP_PATH = "/mcp";
|
|
10
|
+
/** The endpoint the bridge talks to unless told otherwise. */
|
|
11
|
+
export declare const DEFAULT_MCP_URL: string;
|
|
12
|
+
/** Environment variable that overrides the endpoint. */
|
|
13
|
+
export declare const URL_ENV_VAR = "VIAFREI_MCP_URL";
|
|
14
|
+
/** Environment variable that overrides the request timeout, in milliseconds. */
|
|
15
|
+
export declare const TIMEOUT_ENV_VAR = "VIAFREI_MCP_TIMEOUT_MS";
|
|
16
|
+
/** How long a single HTTP request may take before it is given up on. */
|
|
17
|
+
export declare const DEFAULT_TIMEOUT_MS = 30000;
|
|
18
|
+
/**
|
|
19
|
+
* Exit codes. They are part of the interface: a supervisor should be able to
|
|
20
|
+
* tell "your network is down" from "you typed the flag wrong" without parsing
|
|
21
|
+
* English.
|
|
22
|
+
*/
|
|
23
|
+
export declare const EXIT: {
|
|
24
|
+
/** Clean shutdown: the client closed stdin. */
|
|
25
|
+
readonly OK: 0;
|
|
26
|
+
/** Anything we did not anticipate. */
|
|
27
|
+
readonly UNEXPECTED: 1;
|
|
28
|
+
/** Bad flag, bad value, bad URL. */
|
|
29
|
+
readonly USAGE: 2;
|
|
30
|
+
/** The endpoint could not be reached at all (DNS, refused, timeout). */
|
|
31
|
+
readonly UNREACHABLE: 3;
|
|
32
|
+
/** The endpoint answered, and the answer was a refusal (an HTTP status). */
|
|
33
|
+
readonly REFUSED: 4;
|
|
34
|
+
/** The endpoint speaks a protocol version this client cannot use. */
|
|
35
|
+
readonly PROTOCOL: 5;
|
|
36
|
+
};
|
|
37
|
+
export type ExitCode = (typeof EXIT)[keyof typeof EXIT];
|
|
38
|
+
export interface Options {
|
|
39
|
+
/** The endpoint to relay to. */
|
|
40
|
+
url: string;
|
|
41
|
+
/** Extra HTTP headers sent with every request (for example an API key). */
|
|
42
|
+
headers: Record<string, string>;
|
|
43
|
+
/** Per-request timeout in milliseconds. The event stream is not timed out. */
|
|
44
|
+
timeoutMs: number;
|
|
45
|
+
/** Print the version and exit. */
|
|
46
|
+
showVersion: boolean;
|
|
47
|
+
/** Print the help text and exit. */
|
|
48
|
+
showHelp: boolean;
|
|
49
|
+
}
|
|
50
|
+
export declare class UsageError extends Error {
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Parse argv (without `node` and the script) plus the environment.
|
|
54
|
+
*
|
|
55
|
+
* Precedence is the one people expect: a flag beats an environment variable,
|
|
56
|
+
* an environment variable beats the built-in default.
|
|
57
|
+
*/
|
|
58
|
+
export declare function parseOptions(argv: readonly string[], env?: NodeJS.ProcessEnv): Options;
|
|
59
|
+
export declare function helpText(version: string): string;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration for the bridge: where it connects, with which headers, and how
|
|
3
|
+
* long it waits. Every value has exactly one definition here, so the default
|
|
4
|
+
* endpoint is never typed twice.
|
|
5
|
+
*/
|
|
6
|
+
/** The public ViaFrei MCP deployment. Written once. */
|
|
7
|
+
export const DEFAULT_MCP_ORIGIN = 'https://mcp.viafrei.de';
|
|
8
|
+
/** The MCP endpoint path. Streamable HTTP only; SSE is deprecated and absent. */
|
|
9
|
+
export const DEFAULT_MCP_PATH = '/mcp';
|
|
10
|
+
/** The endpoint the bridge talks to unless told otherwise. */
|
|
11
|
+
export const DEFAULT_MCP_URL = new URL(DEFAULT_MCP_PATH, DEFAULT_MCP_ORIGIN).toString();
|
|
12
|
+
/** Environment variable that overrides the endpoint. */
|
|
13
|
+
export const URL_ENV_VAR = 'VIAFREI_MCP_URL';
|
|
14
|
+
/** Environment variable that overrides the request timeout, in milliseconds. */
|
|
15
|
+
export const TIMEOUT_ENV_VAR = 'VIAFREI_MCP_TIMEOUT_MS';
|
|
16
|
+
/** How long a single HTTP request may take before it is given up on. */
|
|
17
|
+
export const DEFAULT_TIMEOUT_MS = 30_000;
|
|
18
|
+
/**
|
|
19
|
+
* Exit codes. They are part of the interface: a supervisor should be able to
|
|
20
|
+
* tell "your network is down" from "you typed the flag wrong" without parsing
|
|
21
|
+
* English.
|
|
22
|
+
*/
|
|
23
|
+
export const EXIT = {
|
|
24
|
+
/** Clean shutdown: the client closed stdin. */
|
|
25
|
+
OK: 0,
|
|
26
|
+
/** Anything we did not anticipate. */
|
|
27
|
+
UNEXPECTED: 1,
|
|
28
|
+
/** Bad flag, bad value, bad URL. */
|
|
29
|
+
USAGE: 2,
|
|
30
|
+
/** The endpoint could not be reached at all (DNS, refused, timeout). */
|
|
31
|
+
UNREACHABLE: 3,
|
|
32
|
+
/** The endpoint answered, and the answer was a refusal (an HTTP status). */
|
|
33
|
+
REFUSED: 4,
|
|
34
|
+
/** The endpoint speaks a protocol version this client cannot use. */
|
|
35
|
+
PROTOCOL: 5
|
|
36
|
+
};
|
|
37
|
+
export class UsageError extends Error {
|
|
38
|
+
}
|
|
39
|
+
/** Headers a caller may not set: they belong to the transport, not to the user. */
|
|
40
|
+
const RESERVED_HEADERS = new Set([
|
|
41
|
+
'content-type',
|
|
42
|
+
'accept',
|
|
43
|
+
'mcp-session-id',
|
|
44
|
+
'mcp-protocol-version',
|
|
45
|
+
'content-length',
|
|
46
|
+
'host'
|
|
47
|
+
]);
|
|
48
|
+
function parseHeader(raw) {
|
|
49
|
+
const separator = raw.indexOf(':');
|
|
50
|
+
if (separator < 1) {
|
|
51
|
+
throw new UsageError(`--header expects "Name: value", got ${JSON.stringify(raw)}`);
|
|
52
|
+
}
|
|
53
|
+
const name = raw.slice(0, separator).trim();
|
|
54
|
+
const value = raw.slice(separator + 1).trim();
|
|
55
|
+
if (name === '') {
|
|
56
|
+
throw new UsageError(`--header expects "Name: value", got ${JSON.stringify(raw)}`);
|
|
57
|
+
}
|
|
58
|
+
if (RESERVED_HEADERS.has(name.toLowerCase())) {
|
|
59
|
+
throw new UsageError(`--header ${name} is set by the transport and cannot be overridden`);
|
|
60
|
+
}
|
|
61
|
+
return [name, value];
|
|
62
|
+
}
|
|
63
|
+
function parseTimeout(raw, source) {
|
|
64
|
+
const value = Number(raw);
|
|
65
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
66
|
+
throw new UsageError(`${source} expects a positive number of milliseconds, got ${JSON.stringify(raw)}`);
|
|
67
|
+
}
|
|
68
|
+
return Math.floor(value);
|
|
69
|
+
}
|
|
70
|
+
function validateUrl(raw, source) {
|
|
71
|
+
let parsed;
|
|
72
|
+
try {
|
|
73
|
+
parsed = new URL(raw);
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
throw new UsageError(`${source} is not a valid URL: ${JSON.stringify(raw)}`);
|
|
77
|
+
}
|
|
78
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
79
|
+
throw new UsageError(`${source} must be http or https, got ${JSON.stringify(parsed.protocol)}`);
|
|
80
|
+
}
|
|
81
|
+
return parsed.toString();
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Parse argv (without `node` and the script) plus the environment.
|
|
85
|
+
*
|
|
86
|
+
* Precedence is the one people expect: a flag beats an environment variable,
|
|
87
|
+
* an environment variable beats the built-in default.
|
|
88
|
+
*/
|
|
89
|
+
export function parseOptions(argv, env = process.env) {
|
|
90
|
+
const options = {
|
|
91
|
+
url: DEFAULT_MCP_URL,
|
|
92
|
+
headers: {},
|
|
93
|
+
timeoutMs: DEFAULT_TIMEOUT_MS,
|
|
94
|
+
showVersion: false,
|
|
95
|
+
showHelp: false
|
|
96
|
+
};
|
|
97
|
+
const urlFromEnv = env[URL_ENV_VAR];
|
|
98
|
+
if (urlFromEnv !== undefined && urlFromEnv.trim() !== '') {
|
|
99
|
+
options.url = validateUrl(urlFromEnv.trim(), URL_ENV_VAR);
|
|
100
|
+
}
|
|
101
|
+
const timeoutFromEnv = env[TIMEOUT_ENV_VAR];
|
|
102
|
+
if (timeoutFromEnv !== undefined && timeoutFromEnv.trim() !== '') {
|
|
103
|
+
options.timeoutMs = parseTimeout(timeoutFromEnv.trim(), TIMEOUT_ENV_VAR);
|
|
104
|
+
}
|
|
105
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
106
|
+
const argument = argv[index];
|
|
107
|
+
const next = () => {
|
|
108
|
+
const value = argv[index + 1];
|
|
109
|
+
if (value === undefined || value.startsWith('--')) {
|
|
110
|
+
throw new UsageError(`${argument} expects a value`);
|
|
111
|
+
}
|
|
112
|
+
index += 1;
|
|
113
|
+
return value;
|
|
114
|
+
};
|
|
115
|
+
if (argument === '--help' || argument === '-h') {
|
|
116
|
+
options.showHelp = true;
|
|
117
|
+
}
|
|
118
|
+
else if (argument === '--version' || argument === '-V') {
|
|
119
|
+
options.showVersion = true;
|
|
120
|
+
}
|
|
121
|
+
else if (argument === '--url') {
|
|
122
|
+
options.url = validateUrl(next(), '--url');
|
|
123
|
+
}
|
|
124
|
+
else if (argument.startsWith('--url=')) {
|
|
125
|
+
options.url = validateUrl(argument.slice('--url='.length), '--url');
|
|
126
|
+
}
|
|
127
|
+
else if (argument === '--header' || argument === '-H') {
|
|
128
|
+
const [name, value] = parseHeader(next());
|
|
129
|
+
options.headers[name] = value;
|
|
130
|
+
}
|
|
131
|
+
else if (argument.startsWith('--header=')) {
|
|
132
|
+
const [name, value] = parseHeader(argument.slice('--header='.length));
|
|
133
|
+
options.headers[name] = value;
|
|
134
|
+
}
|
|
135
|
+
else if (argument === '--timeout') {
|
|
136
|
+
options.timeoutMs = parseTimeout(next(), '--timeout');
|
|
137
|
+
}
|
|
138
|
+
else if (argument.startsWith('--timeout=')) {
|
|
139
|
+
options.timeoutMs = parseTimeout(argument.slice('--timeout='.length), '--timeout');
|
|
140
|
+
}
|
|
141
|
+
else {
|
|
142
|
+
throw new UsageError(`unknown argument ${JSON.stringify(argument)} - run "viafrei --help" for the list`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return options;
|
|
146
|
+
}
|
|
147
|
+
export function helpText(version) {
|
|
148
|
+
return [
|
|
149
|
+
`viafrei ${version} - stdio<->Streamable-HTTP bridge for the ViaFrei MCP server`,
|
|
150
|
+
'',
|
|
151
|
+
'Usage:',
|
|
152
|
+
' npx viafrei [options]',
|
|
153
|
+
'',
|
|
154
|
+
'The bridge speaks MCP over stdio to whatever started it and relays every',
|
|
155
|
+
'message to a Streamable-HTTP MCP endpoint, in both directions. It holds no',
|
|
156
|
+
'data, writes nothing outside the OS temp directory and sends no telemetry.',
|
|
157
|
+
'',
|
|
158
|
+
'Options:',
|
|
159
|
+
` --url <url> endpoint to relay to (default ${DEFAULT_MCP_URL})`,
|
|
160
|
+
' --header "N: v" extra HTTP header, repeatable (for an API key)',
|
|
161
|
+
` --timeout <ms> per-request timeout (default ${DEFAULT_TIMEOUT_MS} ms)`,
|
|
162
|
+
' -V, --version print the version and exit',
|
|
163
|
+
' -h, --help print this text and exit',
|
|
164
|
+
'',
|
|
165
|
+
'Environment:',
|
|
166
|
+
` ${URL_ENV_VAR} same as --url`,
|
|
167
|
+
` ${TIMEOUT_ENV_VAR} same as --timeout`,
|
|
168
|
+
'',
|
|
169
|
+
'Exit codes:',
|
|
170
|
+
` ${EXIT.OK} clean shutdown ${EXIT.UNEXPECTED} unexpected error`,
|
|
171
|
+
` ${EXIT.USAGE} bad usage ${EXIT.UNREACHABLE} endpoint unreachable`,
|
|
172
|
+
` ${EXIT.REFUSED} endpoint refused ${EXIT.PROTOCOL} protocol version mismatch`,
|
|
173
|
+
'',
|
|
174
|
+
'Results carry an attribution line. Show it to the person reading the answer.',
|
|
175
|
+
'Documentation: https://viafrei.de'
|
|
176
|
+
].join('\n');
|
|
177
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { type ExitCode } from './config.js';
|
|
2
|
+
/**
|
|
3
|
+
* A transport failure, reduced to the one line a human needs and the exit code
|
|
4
|
+
* a supervisor needs.
|
|
5
|
+
*
|
|
6
|
+
* The rule this file exists to keep: the user of an MCP client never sees a
|
|
7
|
+
* stack trace from us. They see the URL we tried and what came back.
|
|
8
|
+
*/
|
|
9
|
+
export interface Failure {
|
|
10
|
+
/** One line. No newlines, no stack, names the URL and what happened. */
|
|
11
|
+
line: string;
|
|
12
|
+
/** What the process should exit with if this failure is fatal. */
|
|
13
|
+
exitCode: ExitCode;
|
|
14
|
+
/** HTTP status, when the endpoint answered at all. */
|
|
15
|
+
status?: number;
|
|
16
|
+
}
|
|
17
|
+
/** True when the failure is worth exactly one more attempt. */
|
|
18
|
+
export declare function isRetryable(error: unknown): boolean;
|
|
19
|
+
/** Raised by the fetch wrapper when our own timeout fired. */
|
|
20
|
+
export declare class RequestTimeoutError extends Error {
|
|
21
|
+
readonly timeoutMs: number;
|
|
22
|
+
constructor(timeoutMs: number);
|
|
23
|
+
}
|
|
24
|
+
export type RedirectRefusal = 'cross-origin' | 'no-location' | 'too-many';
|
|
25
|
+
/**
|
|
26
|
+
* How many same-origin hops are a redirect, and how many are a loop.
|
|
27
|
+
*
|
|
28
|
+
* Defined here, next to the sentence that quotes it, and imported by the fetch
|
|
29
|
+
* wrapper that enforces it. It used to be defined in `src/fetch.ts` and typed
|
|
30
|
+
* out again as a literal in `redirectLine()`, which is a number in two places
|
|
31
|
+
* and therefore a number that can disagree with itself.
|
|
32
|
+
*/
|
|
33
|
+
export declare const MAX_REDIRECTS = 5;
|
|
34
|
+
/**
|
|
35
|
+
* Raised by the fetch wrapper when a redirect was not followed.
|
|
36
|
+
*
|
|
37
|
+
* Every request carries the caller's `--header` values. A redirect is the
|
|
38
|
+
* server choosing where those go next, so the choice is made here instead: one
|
|
39
|
+
* origin is one trust boundary, and crossing it is refused out loud rather than
|
|
40
|
+
* followed quietly.
|
|
41
|
+
*/
|
|
42
|
+
export declare class RedirectRefusedError extends Error {
|
|
43
|
+
readonly reason: RedirectRefusal;
|
|
44
|
+
readonly from: string;
|
|
45
|
+
readonly to: string;
|
|
46
|
+
readonly httpStatus: number;
|
|
47
|
+
constructor(detail: {
|
|
48
|
+
reason: RedirectRefusal;
|
|
49
|
+
from: string;
|
|
50
|
+
to: string;
|
|
51
|
+
status: number;
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Turn any thrown value into one line plus an exit code.
|
|
56
|
+
*
|
|
57
|
+
* Three outcomes, and the distinction is the point: the endpoint answered and
|
|
58
|
+
* said no (REFUSED), the endpoint never answered (UNREACHABLE), or something
|
|
59
|
+
* else entirely (UNEXPECTED).
|
|
60
|
+
*/
|
|
61
|
+
export declare function describeFailure(error: unknown, url: string): Failure;
|