viafrei 0.0.2 → 0.0.9

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.
@@ -0,0 +1,186 @@
1
+ import { StreamableHTTPError } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
2
+ import { EXIT } from './config.js';
3
+ const STATUS_TEXT = {
4
+ 400: 'Bad Request',
5
+ 401: 'Unauthorized',
6
+ 403: 'Forbidden',
7
+ 404: 'Not Found',
8
+ 405: 'Method Not Allowed',
9
+ 406: 'Not Acceptable',
10
+ 408: 'Request Timeout',
11
+ 410: 'Gone',
12
+ 413: 'Payload Too Large',
13
+ 415: 'Unsupported Media Type',
14
+ 429: 'Too Many Requests',
15
+ 500: 'Internal Server Error',
16
+ 502: 'Bad Gateway',
17
+ 503: 'Service Unavailable',
18
+ 504: 'Gateway Timeout'
19
+ };
20
+ const SYSCALL_TEXT = {
21
+ ECONNREFUSED: 'connection refused',
22
+ ENOTFOUND: 'host not found (DNS)',
23
+ EAI_AGAIN: 'DNS lookup failed',
24
+ ECONNRESET: 'connection reset by peer',
25
+ EHOSTUNREACH: 'host unreachable',
26
+ ENETUNREACH: 'network unreachable',
27
+ ETIMEDOUT: 'connection timed out',
28
+ EPIPE: 'connection closed while writing',
29
+ CERT_HAS_EXPIRED: 'the TLS certificate has expired',
30
+ UNABLE_TO_VERIFY_LEAF_SIGNATURE: 'the TLS certificate could not be verified',
31
+ DEPTH_ZERO_SELF_SIGNED_CERT: 'the TLS certificate is self-signed'
32
+ };
33
+ /** Collapse anything to a single readable line. */
34
+ function oneLine(text, limit = 200) {
35
+ const flattened = text.replace(/\s+/gu, ' ').trim();
36
+ return flattened.length > limit ? `${flattened.slice(0, limit - 1)}…` : flattened;
37
+ }
38
+ function errorCode(error) {
39
+ let current = error;
40
+ for (let depth = 0; depth < 5 && current !== null && typeof current === 'object'; depth += 1) {
41
+ const code = current.code;
42
+ if (typeof code === 'string') {
43
+ return code;
44
+ }
45
+ current = current.cause;
46
+ }
47
+ return undefined;
48
+ }
49
+ /** True when the failure is worth exactly one more attempt. */
50
+ export function isRetryable(error) {
51
+ if (error instanceof StreamableHTTPError) {
52
+ return error.code === 502 || error.code === 503 || error.code === 504;
53
+ }
54
+ const code = errorCode(error);
55
+ if (code === undefined) {
56
+ return false;
57
+ }
58
+ return ['ECONNRESET', 'ETIMEDOUT', 'EAI_AGAIN', 'EPIPE', 'UND_ERR_SOCKET', 'UND_ERR_CONNECT_TIMEOUT'].includes(code);
59
+ }
60
+ /** Raised by the fetch wrapper when our own timeout fired. */
61
+ export class RequestTimeoutError extends Error {
62
+ timeoutMs;
63
+ constructor(timeoutMs) {
64
+ super(`no answer within ${timeoutMs} ms`);
65
+ this.timeoutMs = timeoutMs;
66
+ this.name = 'RequestTimeoutError';
67
+ }
68
+ }
69
+ /**
70
+ * How many same-origin hops are a redirect, and how many are a loop.
71
+ *
72
+ * Defined here, next to the sentence that quotes it, and imported by the fetch
73
+ * wrapper that enforces it. It used to be defined in `src/fetch.ts` and typed
74
+ * out again as a literal in `redirectLine()`, which is a number in two places
75
+ * and therefore a number that can disagree with itself.
76
+ */
77
+ export const MAX_REDIRECTS = 5;
78
+ /**
79
+ * Raised by the fetch wrapper when a redirect was not followed.
80
+ *
81
+ * Every request carries the caller's `--header` values. A redirect is the
82
+ * server choosing where those go next, so the choice is made here instead: one
83
+ * origin is one trust boundary, and crossing it is refused out loud rather than
84
+ * followed quietly.
85
+ */
86
+ export class RedirectRefusedError extends Error {
87
+ reason;
88
+ from;
89
+ to;
90
+ httpStatus;
91
+ constructor(detail) {
92
+ super(`redirect not followed (${detail.reason})`);
93
+ this.name = 'RedirectRefusedError';
94
+ this.reason = detail.reason;
95
+ this.from = detail.from;
96
+ this.to = detail.to;
97
+ this.httpStatus = detail.status;
98
+ }
99
+ }
100
+ /**
101
+ * Turn any thrown value into one line plus an exit code.
102
+ *
103
+ * Three outcomes, and the distinction is the point: the endpoint answered and
104
+ * said no (REFUSED), the endpoint never answered (UNREACHABLE), or something
105
+ * else entirely (UNEXPECTED).
106
+ */
107
+ export function describeFailure(error, url) {
108
+ if (error instanceof RedirectRefusedError) {
109
+ return { line: redirectLine(error), exitCode: EXIT.REFUSED };
110
+ }
111
+ if (error instanceof StreamableHTTPError && error.code !== undefined && error.code < 0) {
112
+ // The SDK uses a negative code for "the endpoint answered, but not with
113
+ // MCP" - a login page, an HTML error, a proxy's own 200. There is no
114
+ // HTTP status here, so printing one (`HTTP -1 HTTP error`) invented a
115
+ // fact, and "refused" was wrong twice over: it answered, and it did not
116
+ // refuse.
117
+ return {
118
+ line: `viafrei: ${url} answered, but not with MCP - ${oneLine(stripSdkPrefix(error.message), 120)}. Is that the Streamable-HTTP endpoint (usually .../mcp), or is something in front of it answering instead?`,
119
+ exitCode: EXIT.REFUSED
120
+ };
121
+ }
122
+ if (error instanceof StreamableHTTPError && error.code !== undefined) {
123
+ const status = error.code;
124
+ const name = STATUS_TEXT[status] ?? 'HTTP error';
125
+ const detail = extractServerDetail(error.message);
126
+ const suffix = detail === undefined ? '' : ` - ${detail}`;
127
+ return {
128
+ line: `viafrei: ${url} refused the request: HTTP ${status} ${name}${suffix}`,
129
+ exitCode: EXIT.REFUSED,
130
+ status
131
+ };
132
+ }
133
+ if (error instanceof RequestTimeoutError) {
134
+ return {
135
+ line: `viafrei: ${url} did not answer within ${error.timeoutMs} ms - the endpoint may be down, or --timeout is too short`,
136
+ exitCode: EXIT.UNREACHABLE
137
+ };
138
+ }
139
+ const code = errorCode(error);
140
+ if (code !== undefined) {
141
+ const explanation = SYSCALL_TEXT[code] ?? code;
142
+ return {
143
+ line: `viafrei: cannot reach ${url}: ${explanation} (${code}) - check your network connection; --url only if you relay through a proxy`,
144
+ exitCode: EXIT.UNREACHABLE
145
+ };
146
+ }
147
+ const message = error instanceof Error ? error.message : String(error);
148
+ if (/fetch failed|network|socket/iu.test(message)) {
149
+ return {
150
+ line: `viafrei: cannot reach ${url}: ${oneLine(message)}`,
151
+ exitCode: EXIT.UNREACHABLE
152
+ };
153
+ }
154
+ return {
155
+ line: `viafrei: ${url}: ${oneLine(message)}`,
156
+ exitCode: EXIT.UNEXPECTED
157
+ };
158
+ }
159
+ /**
160
+ * The SDK wraps the response body into the error message. Keep a short, single
161
+ * line of it - it is often the only thing that says *why* - and drop the rest.
162
+ */
163
+ function extractServerDetail(message) {
164
+ const marker = 'Error POSTing to endpoint:';
165
+ const index = message.indexOf(marker);
166
+ const body = index === -1 ? message : message.slice(index + marker.length);
167
+ const trimmed = oneLine(body, 120);
168
+ if (trimmed === '' || trimmed === 'null' || trimmed === 'undefined') {
169
+ return undefined;
170
+ }
171
+ return trimmed;
172
+ }
173
+ /** One line for a redirect we chose not to follow, naming both ends. */
174
+ function redirectLine(error) {
175
+ if (error.reason === 'cross-origin') {
176
+ return `viafrei: ${error.from} answered HTTP ${error.httpStatus} redirecting to ${error.to} - a different origin, and this request carries the headers you gave me, so I did not follow it. Point --url at the final endpoint if that redirect is expected.`;
177
+ }
178
+ if (error.reason === 'too-many') {
179
+ return `viafrei: ${error.from} kept redirecting (more than ${MAX_REDIRECTS} hops, last ${error.to}) - that is a loop, not an endpoint.`;
180
+ }
181
+ return `viafrei: ${error.from} answered HTTP ${error.httpStatus} - a redirect with no Location to follow.`;
182
+ }
183
+ /** The SDK prefixes its own message; the user does not need our plumbing. */
184
+ function stripSdkPrefix(message) {
185
+ return message.replace(/^Streamable HTTP error:\s*/u, '');
186
+ }
@@ -0,0 +1,24 @@
1
+ import type { FetchLike } from '@modelcontextprotocol/sdk/shared/transport.js';
2
+ /**
3
+ * `fetch` with a timeout, exactly one retry, and an explicit redirect policy.
4
+ *
5
+ * Three deliberate decisions, all because getting them wrong is worse than the
6
+ * feature:
7
+ *
8
+ * - **The event stream is never timed out.** The standalone `GET` is a
9
+ * long-lived stream by design; a 30-second deadline on it would look like a
10
+ * flaky server every 30 seconds.
11
+ * - **A timeout is not retried.** One retry after a timeout doubles the worst
12
+ * case and hides a slow endpoint behind a longer wait. Retry covers the
13
+ * momentary failures (a reset socket, a 503 from a proxy in front of the
14
+ * server), not a server that is simply not answering.
15
+ * - **Redirects are followed by hand, and only within one origin.** Every
16
+ * request here carries whatever `--header` the user gave us, which is how an
17
+ * API key gets to the server. `redirect: 'follow'` would hand those headers
18
+ * to whatever the `Location` says - `fetch` drops `Authorization` across
19
+ * origins, but it does not drop `X-Api-Key`, and a server that can answer a
20
+ * redirect can choose the origin. So the redirect is resolved here: same
21
+ * origin is followed with the headers, a different origin is refused with one
22
+ * line naming both. Nobody's key travels somewhere they did not point it.
23
+ */
24
+ export declare function createFetch(timeoutMs: number): FetchLike;
package/dist/fetch.js ADDED
@@ -0,0 +1,120 @@
1
+ import { MAX_REDIRECTS, RedirectRefusedError, RequestTimeoutError, isRetryable } from './failure.js';
2
+ /** Statuses that mean "the hop in front of the server had a moment". */
3
+ const RETRY_STATUS = new Set([502, 503, 504]);
4
+ /** How long to wait before the single retry. */
5
+ const RETRY_DELAY_MS = 250;
6
+ const REDIRECT_STATUS = new Set([301, 302, 303, 307, 308]);
7
+ const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
8
+ const originOf = (url) => new URL(url).origin;
9
+ /**
10
+ * `fetch` with a timeout, exactly one retry, and an explicit redirect policy.
11
+ *
12
+ * Three deliberate decisions, all because getting them wrong is worse than the
13
+ * feature:
14
+ *
15
+ * - **The event stream is never timed out.** The standalone `GET` is a
16
+ * long-lived stream by design; a 30-second deadline on it would look like a
17
+ * flaky server every 30 seconds.
18
+ * - **A timeout is not retried.** One retry after a timeout doubles the worst
19
+ * case and hides a slow endpoint behind a longer wait. Retry covers the
20
+ * momentary failures (a reset socket, a 503 from a proxy in front of the
21
+ * server), not a server that is simply not answering.
22
+ * - **Redirects are followed by hand, and only within one origin.** Every
23
+ * request here carries whatever `--header` the user gave us, which is how an
24
+ * API key gets to the server. `redirect: 'follow'` would hand those headers
25
+ * to whatever the `Location` says - `fetch` drops `Authorization` across
26
+ * origins, but it does not drop `X-Api-Key`, and a server that can answer a
27
+ * redirect can choose the origin. So the redirect is resolved here: same
28
+ * origin is followed with the headers, a different origin is refused with one
29
+ * line naming both. Nobody's key travels somewhere they did not point it.
30
+ */
31
+ export function createFetch(timeoutMs) {
32
+ return async (url, init) => {
33
+ const method = (init?.method ?? 'GET').toUpperCase();
34
+ const isEventStream = method === 'GET';
35
+ /** One request, no redirect handling, with our timeout attached. */
36
+ const once = async (target, requestInit) => {
37
+ const withPolicy = { ...requestInit, redirect: 'manual' };
38
+ if (isEventStream) {
39
+ return fetch(target, withPolicy);
40
+ }
41
+ const timeout = AbortSignal.timeout(timeoutMs);
42
+ const signals = [timeout];
43
+ if (requestInit?.signal) {
44
+ signals.push(requestInit.signal);
45
+ }
46
+ try {
47
+ return await fetch(target, { ...withPolicy, signal: AbortSignal.any(signals) });
48
+ }
49
+ catch (error) {
50
+ if (timeout.aborted && requestInit?.signal?.aborted !== true) {
51
+ throw new RequestTimeoutError(timeoutMs);
52
+ }
53
+ throw error;
54
+ }
55
+ };
56
+ /** One request plus any same-origin redirects it asks for. */
57
+ const attempt = async () => {
58
+ let target = String(url);
59
+ let requestInit = init;
60
+ for (let hop = 0; hop <= MAX_REDIRECTS; hop += 1) {
61
+ const response = await once(target, requestInit);
62
+ if (!REDIRECT_STATUS.has(response.status)) {
63
+ return response;
64
+ }
65
+ const location = response.headers.get('location');
66
+ await response.body?.cancel().catch(() => undefined);
67
+ if (location === null || location.trim() === '') {
68
+ throw new RedirectRefusedError({
69
+ reason: 'no-location',
70
+ from: target,
71
+ to: '(none)',
72
+ status: response.status
73
+ });
74
+ }
75
+ const next = new URL(location, target);
76
+ if (next.origin !== originOf(target)) {
77
+ throw new RedirectRefusedError({
78
+ reason: 'cross-origin',
79
+ from: target,
80
+ to: next.origin,
81
+ status: response.status
82
+ });
83
+ }
84
+ // Same origin: the headers are already meant for this server, so
85
+ // following is safe. 303 (and the historical 301/302 on a POST)
86
+ // means "ask again with GET, without the body".
87
+ const dropsBody = response.status === 303 || ((response.status === 301 || response.status === 302) && method !== 'GET');
88
+ if (dropsBody) {
89
+ const { body: _body, ...rest } = requestInit ?? {};
90
+ requestInit = { ...rest, method: 'GET' };
91
+ }
92
+ target = next.toString();
93
+ }
94
+ throw new RedirectRefusedError({
95
+ reason: 'too-many',
96
+ from: String(url),
97
+ to: target,
98
+ status: 0
99
+ });
100
+ };
101
+ const canRetry = () => !isEventStream && init?.signal?.aborted !== true;
102
+ let response;
103
+ try {
104
+ response = await attempt();
105
+ }
106
+ catch (error) {
107
+ if (canRetry() && isRetryable(error)) {
108
+ await sleep(RETRY_DELAY_MS);
109
+ return attempt();
110
+ }
111
+ throw error;
112
+ }
113
+ if (canRetry() && RETRY_STATUS.has(response.status)) {
114
+ await response.body?.cancel().catch(() => undefined);
115
+ await sleep(RETRY_DELAY_MS);
116
+ return attempt();
117
+ }
118
+ return response;
119
+ };
120
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Programmatic entry point.
3
+ *
4
+ * The bridge is a command-line tool first (`npx viafrei`), but a client that
5
+ * wants to embed the relay - a desktop app shipping its own supervisor, say -
6
+ * should not have to spawn a process to do it.
7
+ */
8
+ export { startBridge, type BridgeHandle, type BridgeHooks } from './bridge.js';
9
+ export { DEFAULT_MCP_ORIGIN, DEFAULT_MCP_PATH, DEFAULT_MCP_URL, DEFAULT_TIMEOUT_MS, EXIT, TIMEOUT_ENV_VAR, URL_ENV_VAR, UsageError, helpText, parseOptions, type ExitCode, type Options } from './config.js';
10
+ export { describeFailure, RequestTimeoutError, type Failure } from './failure.js';
11
+ export { packageVersion } from './version.js';
package/dist/index.js ADDED
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Programmatic entry point.
3
+ *
4
+ * The bridge is a command-line tool first (`npx viafrei`), but a client that
5
+ * wants to embed the relay - a desktop app shipping its own supervisor, say -
6
+ * should not have to spawn a process to do it.
7
+ */
8
+ export { startBridge } from './bridge.js';
9
+ export { DEFAULT_MCP_ORIGIN, DEFAULT_MCP_PATH, DEFAULT_MCP_URL, DEFAULT_TIMEOUT_MS, EXIT, TIMEOUT_ENV_VAR, URL_ENV_VAR, UsageError, helpText, parseOptions } from './config.js';
10
+ export { describeFailure, RequestTimeoutError } from './failure.js';
11
+ export { packageVersion } from './version.js';
@@ -0,0 +1,5 @@
1
+ /**
2
+ * The version printed by `--version`, read from the package manifest that ships
3
+ * beside `dist/`. One number, one source: no constant to forget to bump.
4
+ */
5
+ export declare function packageVersion(): string;
@@ -0,0 +1,23 @@
1
+ import { readFileSync } from 'node:fs';
2
+ /**
3
+ * The version printed by `--version`, read from the package manifest that ships
4
+ * beside `dist/`. One number, one source: no constant to forget to bump.
5
+ */
6
+ export function packageVersion() {
7
+ for (const relative of ['../package.json', '../../package.json']) {
8
+ try {
9
+ const raw = readFileSync(new URL(relative, import.meta.url), 'utf8');
10
+ const parsed = JSON.parse(raw);
11
+ if (parsed !== null && typeof parsed === 'object') {
12
+ const { name, version } = parsed;
13
+ if (name === 'viafrei' && typeof version === 'string') {
14
+ return version;
15
+ }
16
+ }
17
+ }
18
+ catch {
19
+ // Try the next candidate.
20
+ }
21
+ }
22
+ return '0.0.0-unknown';
23
+ }
package/package.json CHANGED
@@ -1,8 +1,67 @@
1
1
  {
2
2
  "name": "viafrei",
3
- "version": "0.0.2",
4
- "description": "Reserved package name.",
5
- "license": "MIT",
6
- "main": "index.js",
7
- "files": ["index.js", "README.md"]
3
+ "version": "0.0.9",
4
+ "description": "stdio<->Streamable-HTTP bridge for the ViaFrei MCP server - German road, rail, parking, charging and fuel data for stdio-only MCP clients",
5
+ "keywords": [
6
+ "mcp",
7
+ "modelcontextprotocol",
8
+ "stdio",
9
+ "bridge",
10
+ "proxy",
11
+ "germany",
12
+ "transport",
13
+ "open-data"
14
+ ],
15
+ "homepage": "https://viafrei.de",
16
+ "bugs": "https://github.com/mavrovde/viafrei-bridge/issues",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/mavrovde/viafrei-bridge.git"
20
+ },
21
+ "license": "Apache-2.0",
22
+ "author": "ViaFrei",
23
+ "type": "module",
24
+ "bin": {
25
+ "viafrei": "dist/cli.js"
26
+ },
27
+ "exports": {
28
+ ".": {
29
+ "types": "./dist/index.d.ts",
30
+ "default": "./dist/index.js"
31
+ },
32
+ "./package.json": "./package.json"
33
+ },
34
+ "types": "./dist/index.d.ts",
35
+ "files": [
36
+ "dist",
37
+ "README.md",
38
+ "LICENSE",
39
+ "NOTICE",
40
+ "SOURCES.md",
41
+ "CHANGELOG.md"
42
+ ],
43
+ "engines": {
44
+ "node": ">=22"
45
+ },
46
+ "publishConfig": {
47
+ "access": "public",
48
+ "provenance": true
49
+ },
50
+ "scripts": {
51
+ "build": "tsc -p tsconfig.json",
52
+ "clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true});require('node:fs').rmSync('.test-build',{recursive:true,force:true})\"",
53
+ "test": "npm run build && tsc -p tsconfig.test.json && node --test --test-timeout=30000 .test-build/test/*.test.js",
54
+ "test:gate": "node scripts/check-tarball.test.mjs",
55
+ "test:leaks": "node scripts/check-leaks.test.mjs",
56
+ "check:tarball": "node scripts/check-tarball.mjs",
57
+ "check:leaks": "node scripts/check-leaks.mjs",
58
+ "rules:show": "node scripts/show-rules.mjs"
59
+ },
60
+ "dependencies": {
61
+ "@modelcontextprotocol/sdk": "^1.30.0"
62
+ },
63
+ "devDependencies": {
64
+ "@types/node": "^22.15.0",
65
+ "typescript": "^5.9.0"
66
+ }
8
67
  }
package/index.js DELETED
@@ -1,2 +0,0 @@
1
- "use strict";
2
- module.exports = { name: "viafrei", status: "reserved" };