euparliamentmonitor 0.8.58 → 0.8.59
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 +2 -2
- package/package.json +2 -2
- package/scripts/config/article-horizons.js +8 -8
- package/scripts/mcp/fetch-proxy-server.d.ts +91 -0
- package/scripts/mcp/fetch-proxy-server.js +249 -0
- package/scripts/mcp/html-lang-patcher.d.ts +48 -0
- package/scripts/mcp/html-lang-patcher.js +138 -0
- package/scripts/mcp/imf-mcp-client.d.ts +5 -4
- package/scripts/mcp/imf-mcp-client.js +13 -5
- package/scripts/mcp/mcp-config-reader.d.ts +61 -0
- package/scripts/mcp/mcp-config-reader.js +143 -0
- package/scripts/validate-analysis-completeness.js +47 -2
package/README.md
CHANGED
|
@@ -492,8 +492,8 @@ EU Parliament Monitor implements **security-by-design** under the [Hack23 ISMS](
|
|
|
492
492
|
|
|
493
493
|
### Requirements
|
|
494
494
|
|
|
495
|
-
- **Node.js**
|
|
496
|
-
- **npm** 10 or higher (ships with Node.js
|
|
495
|
+
- **Node.js** 26 or higher
|
|
496
|
+
- **npm** 10 or higher (ships with Node.js 26)
|
|
497
497
|
- **Git**
|
|
498
498
|
|
|
499
499
|
### From source
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "euparliamentmonitor",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.59",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "European Parliament Intelligence Platform - Monitor political activity with systematic transparency",
|
|
6
6
|
"main": "scripts/index.js",
|
|
@@ -168,7 +168,7 @@
|
|
|
168
168
|
"vitest": "4.1.5"
|
|
169
169
|
},
|
|
170
170
|
"engines": {
|
|
171
|
-
"node": ">=
|
|
171
|
+
"node": ">=26"
|
|
172
172
|
},
|
|
173
173
|
"dependencies": {
|
|
174
174
|
"european-parliament-mcp-server": "1.2.21",
|
|
@@ -157,7 +157,7 @@ export const ARTICLE_HORIZONS = {
|
|
|
157
157
|
perspective: CATEGORY_PERSPECTIVE[ArticleCategory.WEEK_AHEAD],
|
|
158
158
|
timePeriod: CATEGORY_TIME_PERIOD[ArticleCategory.WEEK_AHEAD],
|
|
159
159
|
dataWindow: { direction: 'forward', days: 7, anchor: 'today' },
|
|
160
|
-
cadence: { cron: '0
|
|
160
|
+
cadence: { cron: '0 7 * * 5', description: 'Weekly — Friday 07:00 UTC' },
|
|
161
161
|
primaryFeeds: [...STANDARD_FEEDS],
|
|
162
162
|
mandatoryArtifacts: [...PROSPECTIVE_MANDATORY, A_FORWARD_PROJECTION],
|
|
163
163
|
optionalArtifacts: [A_EXEC_BRIEF],
|
|
@@ -171,7 +171,7 @@ export const ARTICLE_HORIZONS = {
|
|
|
171
171
|
perspective: CATEGORY_PERSPECTIVE[ArticleCategory.MONTH_AHEAD],
|
|
172
172
|
timePeriod: CATEGORY_TIME_PERIOD[ArticleCategory.MONTH_AHEAD],
|
|
173
173
|
dataWindow: { direction: 'forward', days: 30, anchor: 'today' },
|
|
174
|
-
cadence: { cron: '0
|
|
174
|
+
cadence: { cron: '0 8 1 * *', description: 'Monthly — 1st @ 08:00 UTC' },
|
|
175
175
|
primaryFeeds: [...STANDARD_FEEDS],
|
|
176
176
|
mandatoryArtifacts: [...PROSPECTIVE_MANDATORY, A_FORWARD_PROJECTION],
|
|
177
177
|
optionalArtifacts: [A_EXEC_BRIEF],
|
|
@@ -222,7 +222,7 @@ export const ARTICLE_HORIZONS = {
|
|
|
222
222
|
timePeriod: CATEGORY_TIME_PERIOD[ArticleCategory.WEEK_IN_REVIEW],
|
|
223
223
|
// ADR-006: D-8 → D-36 reporting window for roll-call publication delay.
|
|
224
224
|
dataWindow: { direction: 'backward', days: 28, anchor: 'today' },
|
|
225
|
-
cadence: { cron: '0
|
|
225
|
+
cadence: { cron: '0 9 * * 6', description: 'Weekly — Saturday 09:00 UTC' },
|
|
226
226
|
primaryFeeds: [...STANDARD_FEEDS, 'get_voting_records'],
|
|
227
227
|
mandatoryArtifacts: [...RETROSPECTIVE_MANDATORY],
|
|
228
228
|
optionalArtifacts: [A_EXEC_BRIEF],
|
|
@@ -236,7 +236,7 @@ export const ARTICLE_HORIZONS = {
|
|
|
236
236
|
perspective: CATEGORY_PERSPECTIVE[ArticleCategory.MONTH_IN_REVIEW],
|
|
237
237
|
timePeriod: CATEGORY_TIME_PERIOD[ArticleCategory.MONTH_IN_REVIEW],
|
|
238
238
|
dataWindow: { direction: 'backward', days: 30, anchor: 'today' },
|
|
239
|
-
cadence: { cron: '0
|
|
239
|
+
cadence: { cron: '0 10 28 * *', description: 'Monthly — 28th @ 10:00 UTC' },
|
|
240
240
|
primaryFeeds: [...STANDARD_FEEDS, 'get_voting_records'],
|
|
241
241
|
mandatoryArtifacts: [...RETROSPECTIVE_MANDATORY],
|
|
242
242
|
optionalArtifacts: [A_EXEC_BRIEF],
|
|
@@ -328,7 +328,7 @@ export const ARTICLE_HORIZONS = {
|
|
|
328
328
|
slug: 'breaking',
|
|
329
329
|
perspective: CATEGORY_PERSPECTIVE[ArticleCategory.BREAKING_NEWS],
|
|
330
330
|
dataWindow: { direction: 'point', anchor: 'today' },
|
|
331
|
-
cadence: { cron: '0 */
|
|
331
|
+
cadence: { cron: '0 */6 * * *', description: 'Every 6 hours' },
|
|
332
332
|
primaryFeeds: [...STANDARD_FEEDS],
|
|
333
333
|
mandatoryArtifacts: [
|
|
334
334
|
A_SIGNIFICANCE,
|
|
@@ -357,7 +357,7 @@ export const ARTICLE_HORIZONS = {
|
|
|
357
357
|
slug: 'committee-reports',
|
|
358
358
|
perspective: CATEGORY_PERSPECTIVE[ArticleCategory.COMMITTEE_REPORTS],
|
|
359
359
|
dataWindow: { direction: 'backward', days: 30, anchor: 'today' },
|
|
360
|
-
cadence: { cron: '0
|
|
360
|
+
cadence: { cron: '0 4 * * 1-5', description: 'Weekdays — Mon–Fri 04:00 UTC' },
|
|
361
361
|
primaryFeeds: [...STANDARD_FEEDS, 'get_committee_documents'],
|
|
362
362
|
mandatoryArtifacts: [...RETROSPECTIVE_MANDATORY],
|
|
363
363
|
optionalArtifacts: [A_EXEC_BRIEF],
|
|
@@ -370,7 +370,7 @@ export const ARTICLE_HORIZONS = {
|
|
|
370
370
|
slug: 'motions',
|
|
371
371
|
perspective: CATEGORY_PERSPECTIVE[ArticleCategory.MOTIONS],
|
|
372
372
|
dataWindow: { direction: 'backward', days: 30, anchor: 'today' },
|
|
373
|
-
cadence: { cron: '0
|
|
373
|
+
cadence: { cron: '0 6 * * 1-5', description: 'Weekdays — Mon–Fri 06:00 UTC' },
|
|
374
374
|
primaryFeeds: [...STANDARD_FEEDS, 'get_voting_records'],
|
|
375
375
|
mandatoryArtifacts: [...RETROSPECTIVE_MANDATORY],
|
|
376
376
|
optionalArtifacts: [A_EXEC_BRIEF],
|
|
@@ -383,7 +383,7 @@ export const ARTICLE_HORIZONS = {
|
|
|
383
383
|
slug: 'propositions',
|
|
384
384
|
perspective: CATEGORY_PERSPECTIVE[ArticleCategory.PROPOSITIONS],
|
|
385
385
|
dataWindow: { direction: 'forward', days: 90, anchor: 'today' },
|
|
386
|
-
cadence: { cron: '0
|
|
386
|
+
cadence: { cron: '0 5 * * 1-5', description: 'Weekdays — Mon–Fri 05:00 UTC' },
|
|
387
387
|
primaryFeeds: [...STANDARD_FEEDS, 'get_procedures'],
|
|
388
388
|
mandatoryArtifacts: [...PROSPECTIVE_MANDATORY],
|
|
389
389
|
optionalArtifacts: [A_PIPELINE_FORECAST, A_EXEC_BRIEF],
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/** JSON-RPC 2.0 request (minimal surface). */
|
|
2
|
+
export interface JsonRpcRequest {
|
|
3
|
+
jsonrpc: '2.0';
|
|
4
|
+
id: number | string | null;
|
|
5
|
+
method: string;
|
|
6
|
+
params?: Record<string, unknown>;
|
|
7
|
+
}
|
|
8
|
+
/** JSON-RPC 2.0 response (success). */
|
|
9
|
+
export interface JsonRpcSuccess {
|
|
10
|
+
jsonrpc: '2.0';
|
|
11
|
+
id: number | string | null;
|
|
12
|
+
result: unknown;
|
|
13
|
+
}
|
|
14
|
+
/** JSON-RPC 2.0 response (error). */
|
|
15
|
+
export interface JsonRpcError {
|
|
16
|
+
jsonrpc: '2.0';
|
|
17
|
+
id: number | string | null;
|
|
18
|
+
error: {
|
|
19
|
+
code: number;
|
|
20
|
+
message: string;
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
/** MCP tool-call content item. */
|
|
24
|
+
export interface McpContentItem {
|
|
25
|
+
type: 'text';
|
|
26
|
+
text: string;
|
|
27
|
+
}
|
|
28
|
+
/** MCP tool-call result envelope. */
|
|
29
|
+
export interface McpToolResult {
|
|
30
|
+
content: McpContentItem[];
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Returns `true` when `url` is allowed by the IMF-only fetch-proxy policy.
|
|
34
|
+
*
|
|
35
|
+
* Allowed: `https://dataservices.imf.org/REST/SDMX_3.0/...`
|
|
36
|
+
*
|
|
37
|
+
* @param url - Raw URL string to validate.
|
|
38
|
+
* @returns Whether the URL is permitted.
|
|
39
|
+
*/
|
|
40
|
+
export declare function isAllowedImfUrl(url: string): boolean;
|
|
41
|
+
/**
|
|
42
|
+
* Serialize a JSON-RPC response to a newline-terminated string.
|
|
43
|
+
*
|
|
44
|
+
* Uses `String.fromCharCode(10)` instead of `'\n'` so that inlined
|
|
45
|
+
* (minified) versions of this code remain safe in single-quoted strings
|
|
46
|
+
* (the AWF YAML serializer rejects bare newlines in entrypointArgs).
|
|
47
|
+
*
|
|
48
|
+
* @param obj - Serializable object.
|
|
49
|
+
* @returns `JSON.stringify(obj) + '\n'`
|
|
50
|
+
*/
|
|
51
|
+
export declare function toWire(obj: unknown): string;
|
|
52
|
+
/**
|
|
53
|
+
* Build a success response for the `initialize` handshake.
|
|
54
|
+
*
|
|
55
|
+
* @param id - Request id to echo.
|
|
56
|
+
* @returns JSON-RPC success with MCP 2024-11-05 capabilities.
|
|
57
|
+
*/
|
|
58
|
+
export declare function handleInitialize(id: number | string | null): JsonRpcSuccess;
|
|
59
|
+
/**
|
|
60
|
+
* Build the `tools/list` response advertising the single `fetch_url` tool.
|
|
61
|
+
*
|
|
62
|
+
* @param id - Request id to echo.
|
|
63
|
+
* @returns JSON-RPC success with the tool descriptor array.
|
|
64
|
+
*/
|
|
65
|
+
export declare function handleToolsList(id: number | string | null): JsonRpcSuccess;
|
|
66
|
+
/**
|
|
67
|
+
* Execute the `fetch_url` tool call.
|
|
68
|
+
*
|
|
69
|
+
* Only URLs matching the IMF SDMX 3.0 allowlist are permitted. Non-matching
|
|
70
|
+
* or malformed URLs receive a JSON-RPC error response; HTTP errors and network
|
|
71
|
+
* failures also surface as errors.
|
|
72
|
+
*
|
|
73
|
+
* @param id - Request id to echo.
|
|
74
|
+
* @param url - URL to fetch.
|
|
75
|
+
* @param fetchImpl - Injectable `fetch` implementation (defaults to global).
|
|
76
|
+
* @returns JSON-RPC success or error.
|
|
77
|
+
*/
|
|
78
|
+
export declare function handleFetchUrl(id: number | string | null, url: string | undefined, fetchImpl?: typeof fetch): Promise<JsonRpcSuccess | JsonRpcError>;
|
|
79
|
+
/**
|
|
80
|
+
* Run the fetch-proxy MCP server, reading JSON-RPC messages from `input` and
|
|
81
|
+
* writing responses to `output`.
|
|
82
|
+
*
|
|
83
|
+
* Does not resolve until the input stream closes.
|
|
84
|
+
*
|
|
85
|
+
* @param input - Readable stream to read JSON-RPC lines from (default: stdin).
|
|
86
|
+
* @param output - Writable stream to write responses to (default: stdout).
|
|
87
|
+
* @param fetchImpl - Injectable fetch (default: global fetch).
|
|
88
|
+
* @returns Promise that resolves when the input stream closes.
|
|
89
|
+
*/
|
|
90
|
+
export declare function runServer(input?: NodeJS.ReadableStream, output?: NodeJS.WritableStream, fetchImpl?: typeof fetch): Promise<void>;
|
|
91
|
+
//# sourceMappingURL=fetch-proxy-server.d.ts.map
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2024-2026 Hack23 AB
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
/**
|
|
4
|
+
* @module MCP/FetchProxyServer
|
|
5
|
+
* @description IMF-only MCP fetch-proxy server.
|
|
6
|
+
*
|
|
7
|
+
* Implements the Model Context Protocol (JSON-RPC 2.0 over stdio) with a
|
|
8
|
+
* single tool — `fetch_url` — that proxies HTTPS GET requests to the IMF
|
|
9
|
+
* SDMX 3.0 REST API at `https://dataservices.imf.org/REST/SDMX_3.0/`.
|
|
10
|
+
*
|
|
11
|
+
* ## Why this exists
|
|
12
|
+
*
|
|
13
|
+
* The Agent Workflow Firewall (AWF) runs a Squid proxy that blocks outbound
|
|
14
|
+
* HTTPS even to allowlisted domains such as `dataservices.imf.org`. This
|
|
15
|
+
* server is mounted as an MCP container in gh-aw workflows; because MCP
|
|
16
|
+
* containers run in a Docker network with direct outbound access (bypassing
|
|
17
|
+
* Squid), `fetch_url` can reach the IMF API while the main runner cannot.
|
|
18
|
+
*
|
|
19
|
+
* The server only allows calls to `https://dataservices.imf.org/REST/SDMX_3.0/`
|
|
20
|
+
* — all other URLs are rejected with an error message.
|
|
21
|
+
*
|
|
22
|
+
* ## Usage
|
|
23
|
+
*
|
|
24
|
+
* ```
|
|
25
|
+
* node scripts/mcp/fetch-proxy-server.js
|
|
26
|
+
* ```
|
|
27
|
+
*
|
|
28
|
+
* Or via `node -e <inlined-code>` in the gh-aw `entrypointArgs` (see
|
|
29
|
+
* `.github/workflows/shared/mcp/news-mcp-servers.md`).
|
|
30
|
+
*
|
|
31
|
+
* ## MCP tools exposed
|
|
32
|
+
*
|
|
33
|
+
* - `fetch_url` — fetches an IMF SDMX URL and returns its body as text.
|
|
34
|
+
*
|
|
35
|
+
* @author Hack23 AB
|
|
36
|
+
* @license Apache-2.0
|
|
37
|
+
*/
|
|
38
|
+
import * as readline from 'node:readline';
|
|
39
|
+
// ─── Constants ────────────────────────────────────────────────────────────────
|
|
40
|
+
const IMF_ALLOWED_HOSTNAME = 'dataservices.imf.org';
|
|
41
|
+
const IMF_ALLOWED_PATH_PREFIX = '/REST/SDMX_3.0/';
|
|
42
|
+
const IMF_ALLOWED_PROTOCOL = 'https:';
|
|
43
|
+
/** Per-request fetch timeout (ms). */
|
|
44
|
+
const FETCH_TIMEOUT_MS = 180_000;
|
|
45
|
+
// ─── Allowlist check ─────────────────────────────────────────────────────────
|
|
46
|
+
/**
|
|
47
|
+
* Returns `true` when `url` is allowed by the IMF-only fetch-proxy policy.
|
|
48
|
+
*
|
|
49
|
+
* Allowed: `https://dataservices.imf.org/REST/SDMX_3.0/...`
|
|
50
|
+
*
|
|
51
|
+
* @param url - Raw URL string to validate.
|
|
52
|
+
* @returns Whether the URL is permitted.
|
|
53
|
+
*/
|
|
54
|
+
export function isAllowedImfUrl(url) {
|
|
55
|
+
let parsed;
|
|
56
|
+
try {
|
|
57
|
+
parsed = new URL(url);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
return (parsed.protocol === IMF_ALLOWED_PROTOCOL &&
|
|
63
|
+
parsed.hostname === IMF_ALLOWED_HOSTNAME &&
|
|
64
|
+
(parsed.port === '' || parsed.port === '443') &&
|
|
65
|
+
parsed.username === '' &&
|
|
66
|
+
parsed.password === '' &&
|
|
67
|
+
parsed.pathname.startsWith(IMF_ALLOWED_PATH_PREFIX));
|
|
68
|
+
}
|
|
69
|
+
// ─── Transport helpers ───────────────────────────────────────────────────────
|
|
70
|
+
/**
|
|
71
|
+
* Serialize a JSON-RPC response to a newline-terminated string.
|
|
72
|
+
*
|
|
73
|
+
* Uses `String.fromCharCode(10)` instead of `'\n'` so that inlined
|
|
74
|
+
* (minified) versions of this code remain safe in single-quoted strings
|
|
75
|
+
* (the AWF YAML serializer rejects bare newlines in entrypointArgs).
|
|
76
|
+
*
|
|
77
|
+
* @param obj - Serializable object.
|
|
78
|
+
* @returns `JSON.stringify(obj) + '\n'`
|
|
79
|
+
*/
|
|
80
|
+
export function toWire(obj) {
|
|
81
|
+
return JSON.stringify(obj) + String.fromCharCode(10);
|
|
82
|
+
}
|
|
83
|
+
// ─── MCP handlers ────────────────────────────────────────────────────────────
|
|
84
|
+
/**
|
|
85
|
+
* Build a success response for the `initialize` handshake.
|
|
86
|
+
*
|
|
87
|
+
* @param id - Request id to echo.
|
|
88
|
+
* @returns JSON-RPC success with MCP 2024-11-05 capabilities.
|
|
89
|
+
*/
|
|
90
|
+
export function handleInitialize(id) {
|
|
91
|
+
return {
|
|
92
|
+
jsonrpc: '2.0',
|
|
93
|
+
id,
|
|
94
|
+
result: {
|
|
95
|
+
protocolVersion: '2024-11-05',
|
|
96
|
+
capabilities: { tools: {} },
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Build the `tools/list` response advertising the single `fetch_url` tool.
|
|
102
|
+
*
|
|
103
|
+
* @param id - Request id to echo.
|
|
104
|
+
* @returns JSON-RPC success with the tool descriptor array.
|
|
105
|
+
*/
|
|
106
|
+
export function handleToolsList(id) {
|
|
107
|
+
return {
|
|
108
|
+
jsonrpc: '2.0',
|
|
109
|
+
id,
|
|
110
|
+
result: {
|
|
111
|
+
tools: [
|
|
112
|
+
{
|
|
113
|
+
name: 'fetch_url',
|
|
114
|
+
description: 'Fetch an IMF SDMX URL and return its content',
|
|
115
|
+
inputSchema: {
|
|
116
|
+
type: 'object',
|
|
117
|
+
properties: {
|
|
118
|
+
url: {
|
|
119
|
+
type: 'string',
|
|
120
|
+
description: 'IMF SDMX URL to fetch',
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
required: ['url'],
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
],
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Execute the `fetch_url` tool call.
|
|
132
|
+
*
|
|
133
|
+
* Only URLs matching the IMF SDMX 3.0 allowlist are permitted. Non-matching
|
|
134
|
+
* or malformed URLs receive a JSON-RPC error response; HTTP errors and network
|
|
135
|
+
* failures also surface as errors.
|
|
136
|
+
*
|
|
137
|
+
* @param id - Request id to echo.
|
|
138
|
+
* @param url - URL to fetch.
|
|
139
|
+
* @param fetchImpl - Injectable `fetch` implementation (defaults to global).
|
|
140
|
+
* @returns JSON-RPC success or error.
|
|
141
|
+
*/
|
|
142
|
+
export async function handleFetchUrl(id, url, fetchImpl = globalThis.fetch) {
|
|
143
|
+
if (!url || !isAllowedImfUrl(url)) {
|
|
144
|
+
return {
|
|
145
|
+
jsonrpc: '2.0',
|
|
146
|
+
id,
|
|
147
|
+
error: {
|
|
148
|
+
code: -1,
|
|
149
|
+
message: `fetch_url only allows ${IMF_ALLOWED_PROTOCOL}//${IMF_ALLOWED_HOSTNAME}${IMF_ALLOWED_PATH_PREFIX} URLs`,
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
try {
|
|
154
|
+
const response = await fetchImpl(url, {
|
|
155
|
+
headers: { Accept: 'application/json' },
|
|
156
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
157
|
+
});
|
|
158
|
+
if (!response.ok) {
|
|
159
|
+
return {
|
|
160
|
+
jsonrpc: '2.0',
|
|
161
|
+
id,
|
|
162
|
+
error: { code: -1, message: `HTTP ${response.status} ${response.statusText}` },
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
const text = await response.text();
|
|
166
|
+
return {
|
|
167
|
+
jsonrpc: '2.0',
|
|
168
|
+
id,
|
|
169
|
+
result: { content: [{ type: 'text', text }] },
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
catch (err) {
|
|
173
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
174
|
+
return { jsonrpc: '2.0', id, error: { code: -1, message } };
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
// ─── Main server loop ─────────────────────────────────────────────────────────
|
|
178
|
+
/**
|
|
179
|
+
* Run the fetch-proxy MCP server, reading JSON-RPC messages from `input` and
|
|
180
|
+
* writing responses to `output`.
|
|
181
|
+
*
|
|
182
|
+
* Does not resolve until the input stream closes.
|
|
183
|
+
*
|
|
184
|
+
* @param input - Readable stream to read JSON-RPC lines from (default: stdin).
|
|
185
|
+
* @param output - Writable stream to write responses to (default: stdout).
|
|
186
|
+
* @param fetchImpl - Injectable fetch (default: global fetch).
|
|
187
|
+
* @returns Promise that resolves when the input stream closes.
|
|
188
|
+
*/
|
|
189
|
+
export function runServer(input = process.stdin, output = process.stdout, fetchImpl = globalThis.fetch) {
|
|
190
|
+
const send = (obj) => {
|
|
191
|
+
output.write(toWire(obj));
|
|
192
|
+
};
|
|
193
|
+
const rl = readline.createInterface({ input, terminal: false });
|
|
194
|
+
return new Promise((resolve) => {
|
|
195
|
+
rl.on('line', (line) => {
|
|
196
|
+
let requestId = null;
|
|
197
|
+
void (async () => {
|
|
198
|
+
try {
|
|
199
|
+
const msg = JSON.parse(line);
|
|
200
|
+
requestId = msg.id ?? null;
|
|
201
|
+
if (msg.method === 'initialize') {
|
|
202
|
+
send(handleInitialize(msg.id ?? null));
|
|
203
|
+
}
|
|
204
|
+
else if (msg.method === 'notifications/initialized') {
|
|
205
|
+
// No-op — notification, no response required.
|
|
206
|
+
}
|
|
207
|
+
else if (msg.method === 'tools/list') {
|
|
208
|
+
send(handleToolsList(msg.id ?? null));
|
|
209
|
+
}
|
|
210
|
+
else if (msg.method === 'tools/call') {
|
|
211
|
+
const params = msg.params;
|
|
212
|
+
if (params?.name === 'fetch_url') {
|
|
213
|
+
const url = params.arguments?.url;
|
|
214
|
+
const result = await handleFetchUrl(msg.id ?? null, url, fetchImpl);
|
|
215
|
+
send(result);
|
|
216
|
+
}
|
|
217
|
+
else {
|
|
218
|
+
send({
|
|
219
|
+
jsonrpc: '2.0',
|
|
220
|
+
id: msg.id ?? null,
|
|
221
|
+
result: { content: [{ type: 'text', text: 'unknown tool' }] },
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
else {
|
|
226
|
+
send({
|
|
227
|
+
jsonrpc: '2.0',
|
|
228
|
+
id: msg.id ?? null,
|
|
229
|
+
result: { content: [{ type: 'text', text: 'unknown method' }] },
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
catch (err) {
|
|
234
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
235
|
+
send({ jsonrpc: '2.0', id: requestId, error: { code: -1, message } });
|
|
236
|
+
}
|
|
237
|
+
})();
|
|
238
|
+
});
|
|
239
|
+
rl.on('close', resolve);
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
// ─── Entry point ─────────────────────────────────────────────────────────────
|
|
243
|
+
// Run when executed directly (not imported as a module).
|
|
244
|
+
if (process.argv[1] !== undefined &&
|
|
245
|
+
(process.argv[1].endsWith('fetch-proxy-server.js') ||
|
|
246
|
+
process.argv[1].endsWith('fetch-proxy-server.ts'))) {
|
|
247
|
+
void runServer();
|
|
248
|
+
}
|
|
249
|
+
//# sourceMappingURL=fetch-proxy-server.js.map
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/** Parameters for the HTML language patch operation. */
|
|
2
|
+
export interface HtmlLangPatchOptions {
|
|
3
|
+
/** BCP-47 language tag for the target locale, e.g. `"de"`, `"fr"`. */
|
|
4
|
+
readonly lang: string;
|
|
5
|
+
/** Text direction for the target locale: `"ltr"` or `"rtl"`. */
|
|
6
|
+
readonly langDir: 'ltr' | 'rtl';
|
|
7
|
+
/** Open Graph locale string, e.g. `"de_DE"`, `"ar_AR"`. */
|
|
8
|
+
readonly ogLocale: string;
|
|
9
|
+
/** Basename of the source English HTML file, e.g. `"2025-01-01-breaking.html"`. */
|
|
10
|
+
readonly enBasename: string;
|
|
11
|
+
/** Basename of the target language HTML file, e.g. `"2025-01-01-breaking-de.html"`. */
|
|
12
|
+
readonly langBasename: string;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Apply all language patches to the given HTML content string and return the
|
|
16
|
+
* patched string.
|
|
17
|
+
*
|
|
18
|
+
* Scope is intentionally narrow: only document-level lang/dir attributes,
|
|
19
|
+
* JSON-LD language fields, og:locale, and self-referential URL fields are
|
|
20
|
+
* rewritten. Body content is left untouched.
|
|
21
|
+
*
|
|
22
|
+
* @param content - Raw HTML file content.
|
|
23
|
+
* @param opts - Patch parameters.
|
|
24
|
+
* @returns Patched HTML content.
|
|
25
|
+
*/
|
|
26
|
+
export declare function patchHtmlContent(content: string, opts: HtmlLangPatchOptions): string;
|
|
27
|
+
/**
|
|
28
|
+
* Read an HTML file, apply language patches, and write the result back to the
|
|
29
|
+
* same file (in-place).
|
|
30
|
+
*
|
|
31
|
+
* @param filePath - Absolute path to the target HTML file.
|
|
32
|
+
* @param opts - Patch parameters.
|
|
33
|
+
* @param readFileImpl - Injectable read function (default: `fs.readFileSync`).
|
|
34
|
+
* @param writeFileImpl - Injectable write function (default: `fs.writeFileSync`).
|
|
35
|
+
* @throws If the file cannot be read or written.
|
|
36
|
+
*/
|
|
37
|
+
export declare function patchHtmlLang(filePath: string, opts: HtmlLangPatchOptions, readFileImpl?: (p: string, enc: BufferEncoding) => string, writeFileImpl?: (p: string, data: string, enc: BufferEncoding) => void): void;
|
|
38
|
+
/**
|
|
39
|
+
* CLI entry point. Positional argument order:
|
|
40
|
+
*
|
|
41
|
+
* ```
|
|
42
|
+
* node html-lang-patcher.js <filePath> <lang> <langDir> <ogLocale> <enBasename> <langBasename>
|
|
43
|
+
* ```
|
|
44
|
+
*
|
|
45
|
+
* @param argv - `process.argv` array (or equivalent for testing).
|
|
46
|
+
*/
|
|
47
|
+
export declare function runCli(argv?: string[]): void;
|
|
48
|
+
//# sourceMappingURL=html-lang-patcher.d.ts.map
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2024-2026 Hack23 AB
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
/**
|
|
4
|
+
* @module MCP/HtmlLangPatcher
|
|
5
|
+
* @description Patches structural HTML metadata in a copied English article
|
|
6
|
+
* file so that the file's language-specific markup matches the target locale.
|
|
7
|
+
*
|
|
8
|
+
* ## What this does
|
|
9
|
+
*
|
|
10
|
+
* When a new article is generated in English and then copied to language-
|
|
11
|
+
* specific placeholder files (e.g. `news/2025-01-01-breaking-de.html`),
|
|
12
|
+
* the copy still contains English-language metadata. This module rewrites
|
|
13
|
+
* only the metadata regions of the file (NOT the article body text, which
|
|
14
|
+
* is translated separately by an AI translation step):
|
|
15
|
+
*
|
|
16
|
+
* - `<html lang="en">` → `<html lang="<lang>">`
|
|
17
|
+
* - `<html dir="ltr|rtl">` → `<html dir="<langDir>">`
|
|
18
|
+
* - `<article lang="en">` → `<article lang="<lang>">`
|
|
19
|
+
* - JSON-LD `"inLanguage": "en"` → `"inLanguage": "<lang>"`
|
|
20
|
+
* - `<meta property="og:locale" content="...">` → target `ogLocale`
|
|
21
|
+
* - Self-referential URLs in `<link rel="canonical">` and
|
|
22
|
+
* `<meta property="og:url">` tags, and JSON-LD `@id`/`url` fields: replaces
|
|
23
|
+
* the English filename component with the language-specific filename.
|
|
24
|
+
* `rel="alternate"` / hreflang links are intentionally NOT rewritten.
|
|
25
|
+
*
|
|
26
|
+
* ## Usage
|
|
27
|
+
*
|
|
28
|
+
* ```typescript
|
|
29
|
+
* import { patchHtmlLang } from './html-lang-patcher.js';
|
|
30
|
+
*
|
|
31
|
+
* patchHtmlLang('/path/to/news/2025-01-01-breaking-de.html', {
|
|
32
|
+
* lang: 'de',
|
|
33
|
+
* langDir: 'ltr',
|
|
34
|
+
* ogLocale: 'de_DE',
|
|
35
|
+
* enBasename: '2025-01-01-breaking.html',
|
|
36
|
+
* langBasename: '2025-01-01-breaking-de.html',
|
|
37
|
+
* });
|
|
38
|
+
* ```
|
|
39
|
+
*
|
|
40
|
+
* Or use the lower-level {@link patchHtmlContent} to work with string content
|
|
41
|
+
* directly (without reading/writing files).
|
|
42
|
+
*
|
|
43
|
+
* @author Hack23 AB
|
|
44
|
+
* @license Apache-2.0
|
|
45
|
+
*/
|
|
46
|
+
import * as fs from 'node:fs';
|
|
47
|
+
// ─── Core logic (pure — operates on string content) ──────────────────────────
|
|
48
|
+
/**
|
|
49
|
+
* Apply all language patches to the given HTML content string and return the
|
|
50
|
+
* patched string.
|
|
51
|
+
*
|
|
52
|
+
* Scope is intentionally narrow: only document-level lang/dir attributes,
|
|
53
|
+
* JSON-LD language fields, og:locale, and self-referential URL fields are
|
|
54
|
+
* rewritten. Body content is left untouched.
|
|
55
|
+
*
|
|
56
|
+
* @param content - Raw HTML file content.
|
|
57
|
+
* @param opts - Patch parameters.
|
|
58
|
+
* @returns Patched HTML content.
|
|
59
|
+
*/
|
|
60
|
+
export function patchHtmlContent(content, opts) {
|
|
61
|
+
const { lang, langDir, ogLocale, enBasename, langBasename } = opts;
|
|
62
|
+
let c = content;
|
|
63
|
+
// 1. Document-level <html> and <article> lang/dir attributes
|
|
64
|
+
c = c.replace(/(<html\b[^>]*\s)lang="en"/, `$1lang="${lang}"`);
|
|
65
|
+
c = c.replace(/(<html\b[^>]*\s)dir="(?:ltr|rtl)"/, `$1dir="${langDir}"`);
|
|
66
|
+
c = c.replace(/(<article\b[^>]*\s)lang="en"/, `$1lang="${lang}"`);
|
|
67
|
+
// 2. JSON-LD inLanguage
|
|
68
|
+
c = c.replace(/("inLanguage"\s*:\s*")en(")/g, `$1${lang}$2`);
|
|
69
|
+
// 3. og:locale meta tag
|
|
70
|
+
c = c.replace(/(<meta\s+property="og:locale"\s+content=")[^"]*(")/g, `$1${ogLocale}$2`);
|
|
71
|
+
// 3b. Content-Language meta tag
|
|
72
|
+
c = c.replace(/(<meta\s+http-equiv="Content-Language"\s+content=")[^"]*(")/g, `$1${lang}$2`);
|
|
73
|
+
// 4. Self-referential URL fields.
|
|
74
|
+
// Restricted to rel="canonical" links and property="og:url" meta only —
|
|
75
|
+
// rel="alternate"/hreflang links are intentionally excluded.
|
|
76
|
+
const enEsc = enBasename.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
77
|
+
// 4a. <link rel="canonical" href="..."> (any attribute order; lookahead guards rel value)
|
|
78
|
+
c = c.replace(/(<link\b(?=[^>]*\brel="canonical")[^>]*\shref=")([^"]*)(")/g, (_, p1, p2, p3) => p1 + p2.replace(new RegExp(enEsc, 'g'), langBasename) + p3);
|
|
79
|
+
// 4b. <meta property="og:url" content="..."> (any attribute order)
|
|
80
|
+
c = c.replace(/(<meta\b(?=[^>]*\bproperty="og:url")[^>]*\scontent=")([^"]*)(")/g, (_, p1, p2, p3) => p1 + p2.replace(new RegExp(enEsc, 'g'), langBasename) + p3);
|
|
81
|
+
// 4c. JSON-LD @id, url, mainEntityOfPage fields
|
|
82
|
+
c = c.replace(/("(?:@id|url|mainEntityOfPage)"\s*:\s*")([^"]*)(")/g, (_, j1, j2, j3) => j1 + j2.replace(new RegExp(enEsc, 'g'), langBasename) + j3);
|
|
83
|
+
return c;
|
|
84
|
+
}
|
|
85
|
+
// ─── File I/O wrapper ─────────────────────────────────────────────────────────
|
|
86
|
+
/**
|
|
87
|
+
* Read an HTML file, apply language patches, and write the result back to the
|
|
88
|
+
* same file (in-place).
|
|
89
|
+
*
|
|
90
|
+
* @param filePath - Absolute path to the target HTML file.
|
|
91
|
+
* @param opts - Patch parameters.
|
|
92
|
+
* @param readFileImpl - Injectable read function (default: `fs.readFileSync`).
|
|
93
|
+
* @param writeFileImpl - Injectable write function (default: `fs.writeFileSync`).
|
|
94
|
+
* @throws If the file cannot be read or written.
|
|
95
|
+
*/
|
|
96
|
+
export function patchHtmlLang(filePath, opts, readFileImpl = fs.readFileSync, writeFileImpl = fs.writeFileSync) {
|
|
97
|
+
const original = readFileImpl(filePath, 'utf8');
|
|
98
|
+
const patched = patchHtmlContent(original, opts);
|
|
99
|
+
writeFileImpl(filePath, patched, 'utf8');
|
|
100
|
+
}
|
|
101
|
+
// ─── CLI entry point ──────────────────────────────────────────────────────────
|
|
102
|
+
/**
|
|
103
|
+
* CLI entry point. Positional argument order:
|
|
104
|
+
*
|
|
105
|
+
* ```
|
|
106
|
+
* node html-lang-patcher.js <filePath> <lang> <langDir> <ogLocale> <enBasename> <langBasename>
|
|
107
|
+
* ```
|
|
108
|
+
*
|
|
109
|
+
* @param argv - `process.argv` array (or equivalent for testing).
|
|
110
|
+
*/
|
|
111
|
+
export function runCli(argv = process.argv) {
|
|
112
|
+
const [, , filePath, lang, langDir, ogLocale, enBasename, langBasename] = argv;
|
|
113
|
+
if (!filePath || !lang || !langDir || !ogLocale || !enBasename || !langBasename) {
|
|
114
|
+
process.stderr.write('Usage: html-lang-patcher <filePath> <lang> <langDir> <ogLocale> <enBasename> <langBasename>' +
|
|
115
|
+
String.fromCharCode(10));
|
|
116
|
+
process.exit(1);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (langDir !== 'ltr' && langDir !== 'rtl') {
|
|
120
|
+
process.stderr.write(`Error: langDir must be "ltr" or "rtl", got "${langDir}"` + String.fromCharCode(10));
|
|
121
|
+
process.exit(1);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
patchHtmlLang(filePath, {
|
|
125
|
+
lang,
|
|
126
|
+
langDir: langDir,
|
|
127
|
+
ogLocale,
|
|
128
|
+
enBasename,
|
|
129
|
+
langBasename,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
// Run when executed directly
|
|
133
|
+
if (process.argv[1] !== undefined &&
|
|
134
|
+
(process.argv[1].endsWith('html-lang-patcher.js') ||
|
|
135
|
+
process.argv[1].endsWith('html-lang-patcher.ts'))) {
|
|
136
|
+
runCli();
|
|
137
|
+
}
|
|
138
|
+
//# sourceMappingURL=html-lang-patcher.js.map
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @module MCP/IMFMCPClient
|
|
3
3
|
* @description Native TypeScript IMF Data client — calls the IMF SDMX 3.0
|
|
4
|
-
* REST API at {@link https://dataservices.imf.org/REST/SDMX_3.0/}
|
|
5
|
-
*
|
|
4
|
+
* REST API at {@link https://dataservices.imf.org/REST/SDMX_3.0/} via the
|
|
5
|
+
* shared IMF-only `fetch-proxy` MCP gateway in gh-aw/AWF runs and direct
|
|
6
|
+
* `fetch()` in local/non-AWF contexts.
|
|
6
7
|
*
|
|
7
8
|
* Historical note: the first Wave-1 iteration delegated to the Python
|
|
8
9
|
* `c-cf/imf-data-mcp` MCP server. That dependency blocked Wave 0 rollout
|
|
@@ -220,8 +221,8 @@ export declare class IMFMCPClient {
|
|
|
220
221
|
}): Promise<MCPToolResult>;
|
|
221
222
|
/**
|
|
222
223
|
* Build a full URL and GET it as text, enforcing the client-wide timeout.
|
|
223
|
-
* Tries the MCP fetch-proxy gateway first (bypasses AWF Squid
|
|
224
|
-
* agentic workflow sandbox), then falls back to direct fetch.
|
|
224
|
+
* Tries the IMF-only MCP fetch-proxy gateway first (bypasses AWF Squid
|
|
225
|
+
* proxy in agentic workflow sandbox), then falls back to direct fetch.
|
|
225
226
|
*
|
|
226
227
|
* @param path - Path (already URL-encoded) to append to the base URL.
|
|
227
228
|
* @returns Response body (`text/*` or `application/*`) as a string.
|
|
@@ -476,8 +476,8 @@ export class IMFMCPClient {
|
|
|
476
476
|
// ─── private transport helpers ─────────────────────────────────────────────
|
|
477
477
|
/**
|
|
478
478
|
* Build a full URL and GET it as text, enforcing the client-wide timeout.
|
|
479
|
-
* Tries the MCP fetch-proxy gateway first (bypasses AWF Squid
|
|
480
|
-
* agentic workflow sandbox), then falls back to direct fetch.
|
|
479
|
+
* Tries the IMF-only MCP fetch-proxy gateway first (bypasses AWF Squid
|
|
480
|
+
* proxy in agentic workflow sandbox), then falls back to direct fetch.
|
|
481
481
|
*
|
|
482
482
|
* @param path - Path (already URL-encoded) to append to the base URL.
|
|
483
483
|
* @returns Response body (`text/*` or `application/*`) as a string.
|
|
@@ -487,8 +487,13 @@ export class IMFMCPClient {
|
|
|
487
487
|
*/
|
|
488
488
|
async _getText(path) {
|
|
489
489
|
const url = `${this._apiBaseUrl}${path.startsWith('/') ? path : `/${path}`}`;
|
|
490
|
-
// Strategy 1: MCP fetch-proxy gateway (bypasses AWF Squid proxy)
|
|
491
|
-
|
|
490
|
+
// Strategy 1: MCP fetch-proxy gateway (bypasses AWF Squid proxy).
|
|
491
|
+
// The API key is optional — the gateway adds the Authorization header only
|
|
492
|
+
// when the key is present. Without a key the request is sent unauthenticated,
|
|
493
|
+
// which is sufficient for local AWF container-to-container traffic (same
|
|
494
|
+
// Docker network). Requiring the key here caused IMF degraded mode whenever
|
|
495
|
+
// EP_MCP_GATEWAY_API_KEY extraction from mcp-config.json failed silently.
|
|
496
|
+
if (this._fetchProxyGatewayUrl) {
|
|
492
497
|
try {
|
|
493
498
|
const result = await this._fetchViaGateway(url);
|
|
494
499
|
if (result !== null)
|
|
@@ -525,6 +530,9 @@ export class IMFMCPClient {
|
|
|
525
530
|
* @internal
|
|
526
531
|
*/
|
|
527
532
|
async _fetchViaGateway(url) {
|
|
533
|
+
const gatewayUrl = this._fetchProxyGatewayUrl;
|
|
534
|
+
if (!gatewayUrl)
|
|
535
|
+
return null;
|
|
528
536
|
const rpcRequest = {
|
|
529
537
|
jsonrpc: '2.0',
|
|
530
538
|
id: Date.now(),
|
|
@@ -544,7 +552,7 @@ export class IMFMCPClient {
|
|
|
544
552
|
const controller = new AbortController();
|
|
545
553
|
const timer = setTimeout(() => controller.abort(), this._timeoutMs);
|
|
546
554
|
try {
|
|
547
|
-
const response = await this._fetchImpl(
|
|
555
|
+
const response = await this._fetchImpl(gatewayUrl, {
|
|
548
556
|
method: 'POST',
|
|
549
557
|
headers,
|
|
550
558
|
body: JSON.stringify(rpcRequest),
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/** Resolved gateway connection details. */
|
|
2
|
+
export interface GatewayConfig {
|
|
3
|
+
/** Raw API key (without "Bearer " prefix), or `undefined` if absent. */
|
|
4
|
+
readonly apiKey: string | undefined;
|
|
5
|
+
/** Gateway TCP port, or `undefined` if not present in the config. */
|
|
6
|
+
readonly port: number | undefined;
|
|
7
|
+
/** Gateway hostname/domain, or `undefined` if not present. */
|
|
8
|
+
readonly domain: string | undefined;
|
|
9
|
+
}
|
|
10
|
+
/** Internal shape of the JSON the gh-aw runtime writes. */
|
|
11
|
+
interface McpConfigJson {
|
|
12
|
+
gateway?: {
|
|
13
|
+
apiKey?: string;
|
|
14
|
+
port?: number | string;
|
|
15
|
+
domain?: string;
|
|
16
|
+
};
|
|
17
|
+
mcpServers?: Record<string, {
|
|
18
|
+
headers?: Record<string, string>;
|
|
19
|
+
}>;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Strip a leading `Bearer ` prefix (case-insensitive) from an auth string.
|
|
23
|
+
*
|
|
24
|
+
* @param raw - Raw header value that may include the prefix.
|
|
25
|
+
* @returns The bare token.
|
|
26
|
+
*/
|
|
27
|
+
export declare function stripBearerPrefix(raw: string): string;
|
|
28
|
+
/**
|
|
29
|
+
* Extract the API key from a parsed config object.
|
|
30
|
+
*
|
|
31
|
+
* Tries four locations in priority order (see module docblock).
|
|
32
|
+
*
|
|
33
|
+
* @param config - Parsed `mcp-config.json` object.
|
|
34
|
+
* @returns The extracted key (Bearer-prefix stripped), or `undefined`.
|
|
35
|
+
*/
|
|
36
|
+
export declare function extractApiKey(config: McpConfigJson): string | undefined;
|
|
37
|
+
/**
|
|
38
|
+
* Read and parse the gh-aw MCP config file, returning gateway connection
|
|
39
|
+
* details. Returns `undefined` fields when the config file is absent, the
|
|
40
|
+
* relevant fields are missing, or parsing fails.
|
|
41
|
+
*
|
|
42
|
+
* Never throws — all errors are silently swallowed and result in an empty
|
|
43
|
+
* config so that callers can always fall back to defaults.
|
|
44
|
+
*
|
|
45
|
+
* @param configPath - Absolute path to `mcp-config.json`.
|
|
46
|
+
* @param readFileImpl - Injectable file-read function (default: `fs.readFileSync`).
|
|
47
|
+
* Accepts the same `(path, encoding)` signature. Used for unit-test injection.
|
|
48
|
+
* @returns Parsed gateway config (fields may be `undefined`).
|
|
49
|
+
*/
|
|
50
|
+
export declare function readMcpConfig(configPath: string, readFileImpl?: (path: string, encoding: BufferEncoding) => string): GatewayConfig;
|
|
51
|
+
/**
|
|
52
|
+
* Resolve the canonical MCP config file path.
|
|
53
|
+
*
|
|
54
|
+
* Prefers `$GH_AW_MCP_CONFIG` env var, then falls back to
|
|
55
|
+
* `~/.copilot/mcp-config.json`.
|
|
56
|
+
*
|
|
57
|
+
* @returns Absolute config file path.
|
|
58
|
+
*/
|
|
59
|
+
export declare function resolveMcpConfigPath(): string;
|
|
60
|
+
export {};
|
|
61
|
+
//# sourceMappingURL=mcp-config-reader.d.ts.map
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2024-2026 Hack23 AB
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
/**
|
|
4
|
+
* @module MCP/McpConfigReader
|
|
5
|
+
* @description Reads the gh-aw MCP config JSON (`mcp-config.json`) and
|
|
6
|
+
* extracts the gateway API key and address (port + domain).
|
|
7
|
+
*
|
|
8
|
+
* ## Config format
|
|
9
|
+
*
|
|
10
|
+
* The gh-aw runtime writes a JSON file at
|
|
11
|
+
* `~/.copilot/mcp-config.json` (or `$GH_AW_MCP_CONFIG`) with the
|
|
12
|
+
* following shape (several optional paths across gh-aw versions):
|
|
13
|
+
*
|
|
14
|
+
* ```json
|
|
15
|
+
* {
|
|
16
|
+
* "gateway": {
|
|
17
|
+
* "apiKey": "<key>", // gh-aw <= v0.68 (legacy)
|
|
18
|
+
* "port": 8080,
|
|
19
|
+
* "domain": "host.docker.internal"
|
|
20
|
+
* },
|
|
21
|
+
* "mcpServers": {
|
|
22
|
+
* "european-parliament": {
|
|
23
|
+
* "headers": { "Authorization": "<key>" }
|
|
24
|
+
* },
|
|
25
|
+
* "fetch-proxy": {
|
|
26
|
+
* "headers": { "Authorization": "<key>" }
|
|
27
|
+
* }
|
|
28
|
+
* }
|
|
29
|
+
* }
|
|
30
|
+
* ```
|
|
31
|
+
*
|
|
32
|
+
* The API key is tried at four locations in priority order:
|
|
33
|
+
* 1. `gateway.apiKey` (gh-aw ≤ v0.68)
|
|
34
|
+
* 2. `mcpServers.european-parliament.headers.Authorization` (v0.69–v0.71)
|
|
35
|
+
* 3. `mcpServers.fetch-proxy.headers.Authorization` (v0.72+)
|
|
36
|
+
* 4. First `mcpServers[*].headers.Authorization` found (catch-all)
|
|
37
|
+
*
|
|
38
|
+
* @author Hack23 AB
|
|
39
|
+
* @license Apache-2.0
|
|
40
|
+
*/
|
|
41
|
+
import * as fs from 'node:fs';
|
|
42
|
+
import * as path from 'node:path';
|
|
43
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
44
|
+
/**
|
|
45
|
+
* Strip a leading `Bearer ` prefix (case-insensitive) from an auth string.
|
|
46
|
+
*
|
|
47
|
+
* @param raw - Raw header value that may include the prefix.
|
|
48
|
+
* @returns The bare token.
|
|
49
|
+
*/
|
|
50
|
+
export function stripBearerPrefix(raw) {
|
|
51
|
+
return raw.replace(/^bearer\s+/i, '');
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Extract the API key from a parsed config object.
|
|
55
|
+
*
|
|
56
|
+
* Tries four locations in priority order (see module docblock).
|
|
57
|
+
*
|
|
58
|
+
* @param config - Parsed `mcp-config.json` object.
|
|
59
|
+
* @returns The extracted key (Bearer-prefix stripped), or `undefined`.
|
|
60
|
+
*/
|
|
61
|
+
export function extractApiKey(config) {
|
|
62
|
+
const candidates = [
|
|
63
|
+
// Priority 1 — legacy gateway.apiKey
|
|
64
|
+
config.gateway?.apiKey,
|
|
65
|
+
// Priority 2 — EP MCP server header
|
|
66
|
+
config.mcpServers?.['european-parliament']?.headers?.['Authorization'],
|
|
67
|
+
// Priority 3 — fetch-proxy server header
|
|
68
|
+
config.mcpServers?.['fetch-proxy']?.headers?.['Authorization'],
|
|
69
|
+
];
|
|
70
|
+
for (const candidate of candidates) {
|
|
71
|
+
if (candidate && candidate.trim() !== '') {
|
|
72
|
+
return stripBearerPrefix(candidate.trim());
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
// Priority 4 — first server with a non-empty Authorization header
|
|
76
|
+
if (config.mcpServers) {
|
|
77
|
+
for (const server of Object.values(config.mcpServers)) {
|
|
78
|
+
const auth = server?.headers?.['Authorization'];
|
|
79
|
+
if (auth && auth.trim() !== '') {
|
|
80
|
+
return stripBearerPrefix(auth.trim());
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
// ─── Public API ──────────────────────────────────────────────────────────────
|
|
87
|
+
/**
|
|
88
|
+
* Read and parse the gh-aw MCP config file, returning gateway connection
|
|
89
|
+
* details. Returns `undefined` fields when the config file is absent, the
|
|
90
|
+
* relevant fields are missing, or parsing fails.
|
|
91
|
+
*
|
|
92
|
+
* Never throws — all errors are silently swallowed and result in an empty
|
|
93
|
+
* config so that callers can always fall back to defaults.
|
|
94
|
+
*
|
|
95
|
+
* @param configPath - Absolute path to `mcp-config.json`.
|
|
96
|
+
* @param readFileImpl - Injectable file-read function (default: `fs.readFileSync`).
|
|
97
|
+
* Accepts the same `(path, encoding)` signature. Used for unit-test injection.
|
|
98
|
+
* @returns Parsed gateway config (fields may be `undefined`).
|
|
99
|
+
*/
|
|
100
|
+
export function readMcpConfig(configPath, readFileImpl = fs.readFileSync) {
|
|
101
|
+
let raw;
|
|
102
|
+
try {
|
|
103
|
+
raw = readFileImpl(configPath, 'utf8');
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return { apiKey: undefined, port: undefined, domain: undefined };
|
|
107
|
+
}
|
|
108
|
+
let config;
|
|
109
|
+
try {
|
|
110
|
+
const parsed = JSON.parse(raw);
|
|
111
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
112
|
+
return { apiKey: undefined, port: undefined, domain: undefined };
|
|
113
|
+
}
|
|
114
|
+
config = parsed;
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
return { apiKey: undefined, port: undefined, domain: undefined };
|
|
118
|
+
}
|
|
119
|
+
const apiKey = extractApiKey(config);
|
|
120
|
+
const rawPort = config.gateway?.port;
|
|
121
|
+
const port = rawPort !== undefined && rawPort !== '' ? Number.parseInt(String(rawPort), 10) : undefined;
|
|
122
|
+
const parsedPort = port !== undefined && Number.isFinite(port) ? port : undefined;
|
|
123
|
+
const domain = config.gateway?.domain !== undefined && config.gateway.domain !== ''
|
|
124
|
+
? config.gateway.domain
|
|
125
|
+
: undefined;
|
|
126
|
+
return { apiKey, port: parsedPort, domain };
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Resolve the canonical MCP config file path.
|
|
130
|
+
*
|
|
131
|
+
* Prefers `$GH_AW_MCP_CONFIG` env var, then falls back to
|
|
132
|
+
* `~/.copilot/mcp-config.json`.
|
|
133
|
+
*
|
|
134
|
+
* @returns Absolute config file path.
|
|
135
|
+
*/
|
|
136
|
+
export function resolveMcpConfigPath() {
|
|
137
|
+
const envPath = process.env['GH_AW_MCP_CONFIG'];
|
|
138
|
+
if (envPath && envPath.trim() !== '')
|
|
139
|
+
return envPath.trim();
|
|
140
|
+
const home = process.env['HOME'] ?? process.env['USERPROFILE'] ?? '/home/runner';
|
|
141
|
+
return path.join(home, '.copilot', 'mcp-config.json');
|
|
142
|
+
}
|
|
143
|
+
//# sourceMappingURL=mcp-config-reader.js.map
|
|
@@ -88,6 +88,17 @@ const PLACEHOLDER_PATTERNS = [
|
|
|
88
88
|
/^TODO:/m,
|
|
89
89
|
];
|
|
90
90
|
|
|
91
|
+
// dataMode threshold reduction factors — when manifest.dataMode declares a
|
|
92
|
+
// degraded data availability state, line floors are multiplied by this factor.
|
|
93
|
+
// Structural checks (mermaid, WEP, Admiralty, SATs) are never reduced.
|
|
94
|
+
const DATA_MODE_REDUCTION = {
|
|
95
|
+
'full': 1.0,
|
|
96
|
+
'title-only': 0.75,
|
|
97
|
+
'degraded-imf': 0.85,
|
|
98
|
+
'degraded-voting': 0.85,
|
|
99
|
+
'minimal': 0.65,
|
|
100
|
+
};
|
|
101
|
+
|
|
91
102
|
const WEP_BAND_RE =
|
|
92
103
|
/\b(Almost Certain|Highly Likely|Likely|Roughly Even|Even Chance|About even|Unlikely|Highly Unlikely|Almost No Chance|WEP\s*:)\b/i;
|
|
93
104
|
|
|
@@ -182,6 +193,7 @@ function parseArgs(argv) {
|
|
|
182
193
|
json: false,
|
|
183
194
|
strict: false,
|
|
184
195
|
minLines: DEFAULT_MIN_LINES,
|
|
196
|
+
minLinesExplicit: false,
|
|
185
197
|
thresholdsPath: null,
|
|
186
198
|
};
|
|
187
199
|
for (let i = 0; i < args.length; i += 1) {
|
|
@@ -193,6 +205,7 @@ function parseArgs(argv) {
|
|
|
193
205
|
if (!Number.isFinite(n) || n < 1) usage(2);
|
|
194
206
|
// The flag may only RAISE the floor — never lower it below DEFAULT_MIN_LINES.
|
|
195
207
|
opts.minLines = Math.max(DEFAULT_MIN_LINES, n);
|
|
208
|
+
opts.minLinesExplicit = true;
|
|
196
209
|
i += 1;
|
|
197
210
|
} else if (a === '--thresholds') {
|
|
198
211
|
opts.thresholdsPath = args[i + 1];
|
|
@@ -472,6 +485,7 @@ function validateArtifact({
|
|
|
472
485
|
relativePath,
|
|
473
486
|
rules,
|
|
474
487
|
options,
|
|
488
|
+
dataModeReduction = 1.0,
|
|
475
489
|
}) {
|
|
476
490
|
const abs = path.join(runDir, relativePath);
|
|
477
491
|
const result = {
|
|
@@ -493,7 +507,16 @@ function validateArtifact({
|
|
|
493
507
|
result.lines = countLines(content);
|
|
494
508
|
|
|
495
509
|
const perFloor = rules.perArtifactFloors?.[relativePath] ?? null;
|
|
496
|
-
|
|
510
|
+
// dataMode reduction applies to per-artifact floors AND the default floor,
|
|
511
|
+
// but NOT to the CLI-provided --min-lines value. This preserves the contract
|
|
512
|
+
// that --min-lines can only raise floors, never lower them.
|
|
513
|
+
const baseFloor = perFloor != null ? perFloor : DEFAULT_MIN_LINES;
|
|
514
|
+
const reducedFloor = Math.max(1, Math.floor(baseFloor * dataModeReduction));
|
|
515
|
+
// When --min-lines is explicitly set, it acts as a hard minimum that the
|
|
516
|
+
// reduction cannot breach. When not set, use the reduced floor directly.
|
|
517
|
+
result.minLines = options.minLinesExplicit
|
|
518
|
+
? Math.max(options.minLines, reducedFloor)
|
|
519
|
+
: reducedFloor;
|
|
497
520
|
if (result.lines < result.minLines) {
|
|
498
521
|
result.issues.push(
|
|
499
522
|
`short:${result.lines}<${result.minLines}`,
|
|
@@ -935,6 +958,28 @@ function main() {
|
|
|
935
958
|
);
|
|
936
959
|
}
|
|
937
960
|
|
|
961
|
+
// ── Data-mode threshold adjustment (§dataMode) ────────────────────────
|
|
962
|
+
// When manifest.dataMode declares a degraded data availability state,
|
|
963
|
+
// the validator applies a line-floor reduction factor so that structurally
|
|
964
|
+
// constrained runs (missing full text, IMF unavailable, roll-call lag)
|
|
965
|
+
// can still pass Stage C without inflating thresholds that cannot be met
|
|
966
|
+
// with the available data. The reduction ONLY applies to line floors —
|
|
967
|
+
// structural requirements (mermaid, WEP, Admiralty, SATs) remain unchanged.
|
|
968
|
+
const dataMode = manifest.dataMode || 'full';
|
|
969
|
+
const dataModeReduction = DATA_MODE_REDUCTION[dataMode];
|
|
970
|
+
if (dataModeReduction === undefined) {
|
|
971
|
+
process.stderr.write(
|
|
972
|
+
`warning: manifest.dataMode="${dataMode}" is not a recognized value ` +
|
|
973
|
+
`(expected: ${Object.keys(DATA_MODE_REDUCTION).join(', ')}). Treating as "full".\n`,
|
|
974
|
+
);
|
|
975
|
+
}
|
|
976
|
+
const effectiveReduction = dataModeReduction ?? 1.0;
|
|
977
|
+
if (dataMode !== 'full' && dataModeReduction !== undefined) {
|
|
978
|
+
process.stderr.write(
|
|
979
|
+
`info: dataMode="${dataMode}" — applying ${Math.round((1 - effectiveReduction) * 100)}% line-floor reduction\n`,
|
|
980
|
+
);
|
|
981
|
+
}
|
|
982
|
+
|
|
938
983
|
const thresholdsJson = loadThresholds(opts.thresholdsPath);
|
|
939
984
|
let rules;
|
|
940
985
|
try {
|
|
@@ -953,7 +998,7 @@ function main() {
|
|
|
953
998
|
const mandatory = listMandatoryArtifacts(rules, manifestArtifacts, articleType);
|
|
954
999
|
|
|
955
1000
|
const results = mandatory.map((relativePath) =>
|
|
956
|
-
validateArtifact({ runDir, relativePath, rules, options: opts }),
|
|
1001
|
+
validateArtifact({ runDir, relativePath, rules, options: opts, dataModeReduction: effectiveReduction }),
|
|
957
1002
|
);
|
|
958
1003
|
|
|
959
1004
|
const forwardRegistryResult = validateForwardStatementsRegistryCoverage(runDir, articleType);
|