@alfe.ai/news-mcp 0.2.5 → 0.2.7

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 ADDED
@@ -0,0 +1,25 @@
1
+ # @alfe.ai/news-mcp
2
+
3
+ Runtime-neutral stdio MCP server for metered APITube and NewsData search through
4
+ Alfe. Provider credentials and billing remain in `services/news`; the MCP reads
5
+ the agent's Alfe config and calls the typed Agent API client.
6
+
7
+ ## Tools
8
+
9
+ - `news_search` searches recent articles with an explicit bounded query and
10
+ optional provider, source, date, language, category, and result limit.
11
+ - `top_headlines` returns current headlines with optional provider filters.
12
+
13
+ The default provider is APITube. NewsData accepts at most 50 results and
14
+ `YYYY-MM-DD` date windows. Tool inputs mirror the service constraints, provider
15
+ results are runtime-validated and size-bounded, and provider/billing/API errors
16
+ surface as redacted MCP `isError` results.
17
+
18
+ ## Development
19
+
20
+ ```bash
21
+ pnpm --filter @alfe.ai/news-mcp lint
22
+ pnpm --filter @alfe.ai/news-mcp typecheck
23
+ pnpm --filter @alfe.ai/news-mcp test
24
+ pnpm --filter @alfe.ai/news-mcp build
25
+ ```
@@ -0,0 +1,273 @@
1
+ #!/usr/bin/env node
2
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
3
+ let node_fs = require("node:fs");
4
+ let node_module = require("node:module");
5
+ let node_url = require("node:url");
6
+ let _modelcontextprotocol_sdk_server_mcp_js = require("@modelcontextprotocol/sdk/server/mcp.js");
7
+ let _modelcontextprotocol_sdk_server_stdio_js = require("@modelcontextprotocol/sdk/server/stdio.js");
8
+ let _alfe_ai_config = require("@alfe.ai/config");
9
+ let _alfe_ai_agent_api_client = require("@alfe.ai/agent-api-client");
10
+ let zod = require("zod");
11
+ //#region src/boundary.ts
12
+ const MAX_QUERY_CHARS = 400;
13
+ const MAX_SOURCE_CHARS = 200;
14
+ const MAX_DATE_CHARS = 40;
15
+ const MAX_LANGUAGE_CHARS = 11;
16
+ const MAX_CATEGORY_CHARS = 60;
17
+ const MAX_TITLE_CHARS = 4096;
18
+ const MAX_URL_CHARS = 16384;
19
+ const MAX_RESULT_SOURCE_CHARS = 1024;
20
+ const MAX_PUBLISHED_AT_CHARS = 128;
21
+ const MAX_SNIPPET_CHARS = 16384;
22
+ const MAX_SENTIMENT_LABEL_CHARS = 128;
23
+ const MAX_ARTICLES = 100;
24
+ const MAX_TOOL_RESULT_BYTES = 2 * 1024 * 1024;
25
+ const newsProviderSchema = zod.z.enum(["apitube", "newsdata"]);
26
+ function boundedInputString(max, label) {
27
+ return zod.z.string().min(1).max(max).refine((value) => !hasControlCharacters(value), `${label} cannot contain control characters`);
28
+ }
29
+ const baseSearchSchema = zod.z.object({
30
+ query: boundedInputString(MAX_QUERY_CHARS, "Search query"),
31
+ provider: newsProviderSchema.default("apitube"),
32
+ source: boundedInputString(MAX_SOURCE_CHARS, "News source").optional(),
33
+ from: boundedInputString(MAX_DATE_CHARS, "Start date").optional(),
34
+ to: boundedInputString(MAX_DATE_CHARS, "End date").optional(),
35
+ language: boundedInputString(MAX_LANGUAGE_CHARS, "Language").regex(/^[a-z]{2,3}(,[a-z]{2,3}){0,2}$/iu).optional(),
36
+ category: boundedInputString(MAX_CATEGORY_CHARS, "Category").optional(),
37
+ limit: zod.z.coerce.number().int().min(1).max(MAX_ARTICLES).optional()
38
+ });
39
+ const searchInputShape = baseSearchSchema.shape;
40
+ const searchArgsSchema = baseSearchSchema.superRefine((value, context) => {
41
+ if (value.provider !== "newsdata") return;
42
+ if (value.limit !== void 0 && value.limit > 50) context.addIssue({
43
+ code: "custom",
44
+ path: ["limit"],
45
+ message: "NewsData supports at most 50 results"
46
+ });
47
+ for (const field of ["from", "to"]) {
48
+ const date = value[field];
49
+ if (date !== void 0 && !isNewsDataDate(date)) context.addIssue({
50
+ code: "custom",
51
+ path: [field],
52
+ message: "NewsData dates must use YYYY-MM-DD"
53
+ });
54
+ }
55
+ if (value.from !== void 0 && value.to !== void 0 && isNewsDataDate(value.from) && isNewsDataDate(value.to) && value.from > value.to) context.addIssue({
56
+ code: "custom",
57
+ path: ["to"],
58
+ message: "End date must not precede start date"
59
+ });
60
+ });
61
+ const baseHeadlinesSchema = zod.z.object({
62
+ provider: newsProviderSchema.default("apitube"),
63
+ category: boundedInputString(MAX_CATEGORY_CHARS, "Category").optional(),
64
+ source: boundedInputString(MAX_SOURCE_CHARS, "News source").optional(),
65
+ language: boundedInputString(MAX_LANGUAGE_CHARS, "Language").regex(/^[a-z]{2,3}(,[a-z]{2,3}){0,2}$/iu).optional(),
66
+ limit: zod.z.coerce.number().int().min(1).max(MAX_ARTICLES).optional()
67
+ });
68
+ const headlinesInputShape = baseHeadlinesSchema.shape;
69
+ const headlinesArgsSchema = baseHeadlinesSchema.superRefine((value, context) => {
70
+ if (value.provider === "newsdata" && value.limit !== void 0 && value.limit > 50) context.addIssue({
71
+ code: "custom",
72
+ path: ["limit"],
73
+ message: "NewsData supports at most 50 results"
74
+ });
75
+ });
76
+ const resultString = (max) => zod.z.string().max(max).refine((value) => !hasControlCharacters(value));
77
+ const articleUrlSchema = resultString(MAX_URL_CHARS).refine((value) => {
78
+ try {
79
+ const url = new URL(value);
80
+ return (url.protocol === "https:" || url.protocol === "http:") && !url.username && !url.password;
81
+ } catch {
82
+ return false;
83
+ }
84
+ }, "Article URL must be a safe HTTP(S) URL");
85
+ const sentimentSchema = zod.z.object({
86
+ score: zod.z.number().min(-1).max(1).optional(),
87
+ label: resultString(MAX_SENTIMENT_LABEL_CHARS).min(1).optional()
88
+ }).refine((value) => value.score !== void 0 || value.label !== void 0, { message: "Sentiment must contain a score or label" });
89
+ const newsResultSchema = zod.z.object({
90
+ provider: newsProviderSchema,
91
+ articles: zod.z.array(zod.z.object({
92
+ title: resultString(MAX_TITLE_CHARS).min(1),
93
+ url: articleUrlSchema,
94
+ source: resultString(MAX_RESULT_SOURCE_CHARS).min(1),
95
+ publishedAt: resultString(MAX_PUBLISHED_AT_CHARS).min(1).refine((value) => Number.isFinite(Date.parse(value)), "Invalid article timestamp"),
96
+ snippet: resultString(MAX_SNIPPET_CHARS),
97
+ sentiment: sentimentSchema.optional()
98
+ })).max(MAX_ARTICLES)
99
+ });
100
+ function normalizeNewsResult(value) {
101
+ const result = newsResultSchema.parse(value);
102
+ const encoded = JSON.stringify(result);
103
+ if (Buffer.byteLength(encoded, "utf8") > 2097152) throw new Error(`News result exceeds ${String(MAX_TOOL_RESULT_BYTES)} bytes`);
104
+ return result;
105
+ }
106
+ function encodeToolResult(value) {
107
+ const encoded = JSON.stringify(value);
108
+ if (Buffer.byteLength(encoded, "utf8") > 2097152) throw new Error(`News tool result exceeds ${String(MAX_TOOL_RESULT_BYTES)} bytes`);
109
+ return encoded;
110
+ }
111
+ function safeErrorMessage(error, secrets = []) {
112
+ let output = (error instanceof Error ? error.message : String(error)).slice(0, 4096).replace(/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]+/giu, "$1 [REDACTED]").replace(/\b(api[_-]?key|access[_-]?token|refresh[_-]?token|secret|password)\s*[=:]\s*[^\s,;]+/giu, "$1=[REDACTED]").replace(/https?:\/\/[^\s/@:]+:[^\s/@]+@/giu, "https://[REDACTED]@").replace(/\balfe_[A-Za-z0-9_-]{8,}/gu, "[REDACTED]");
113
+ for (const secret of secrets) if (secret.length >= 4) output = output.split(secret).join("[REDACTED]");
114
+ return flattenControls(output);
115
+ }
116
+ function isNewsDataDate(value) {
117
+ if (!/^\d{4}-\d{2}-\d{2}$/u.test(value)) return false;
118
+ const epoch = Date.parse(`${value}T00:00:00Z`);
119
+ return Number.isFinite(epoch) && new Date(epoch).toISOString().slice(0, 10) === value;
120
+ }
121
+ function hasControlCharacters(value) {
122
+ return Array.from(value).some((character) => {
123
+ const codePoint = character.codePointAt(0) ?? 0;
124
+ return codePoint < 32 || codePoint === 127;
125
+ });
126
+ }
127
+ function flattenControls(value) {
128
+ let output = "";
129
+ let previousWasControl = false;
130
+ for (const character of value) {
131
+ const codePoint = character.codePointAt(0) ?? 0;
132
+ const isControl = codePoint < 32 || codePoint === 127;
133
+ if (isControl) {
134
+ if (!previousWasControl) output += " ";
135
+ } else output += character;
136
+ previousWasControl = isControl;
137
+ }
138
+ return output;
139
+ }
140
+ //#endregion
141
+ //#region src/tools.ts
142
+ const providerSchema = newsProviderSchema.default("apitube").describe("News provider to query. APITube (default) covers 500K+ sources with sentiment.");
143
+ function ok(data) {
144
+ return { content: [{
145
+ type: "text",
146
+ text: encodeToolResult(data)
147
+ }] };
148
+ }
149
+ function fail(err) {
150
+ return {
151
+ content: [{
152
+ type: "text",
153
+ text: encodeToolResult({ error: safeErrorMessage(err) })
154
+ }],
155
+ isError: true
156
+ };
157
+ }
158
+ function registerTools(server, client) {
159
+ const register = server.registerTool.bind(server);
160
+ register("news_search", {
161
+ description: "Search recent news across a broad provider (APITube / NewsData). Returns normalized articles with title, url, source, publishedAt, snippet, and (APITube) sentiment.",
162
+ inputSchema: {
163
+ query: searchInputShape.query.describe("Search query (keywords, entities, topics)."),
164
+ provider: providerSchema,
165
+ source: searchInputShape.source.describe("Restrict to a single source/domain (e.g. reuters.com)."),
166
+ from: searchInputShape.from.describe("Earliest publish date, ISO-8601 (e.g. 2026-07-01)."),
167
+ to: searchInputShape.to.describe("Latest publish date, ISO-8601."),
168
+ language: searchInputShape.language.describe("ISO-639-1 language code (e.g. en)."),
169
+ category: searchInputShape.category.describe("News category (e.g. business, technology)."),
170
+ limit: searchInputShape.limit.describe("Max articles to return.")
171
+ }
172
+ }, async (rawArgs) => {
173
+ try {
174
+ const args = searchArgsSchema.parse(rawArgs);
175
+ return ok(normalizeNewsResult(await client.newsSearch({
176
+ query: args.query,
177
+ provider: args.provider,
178
+ source: args.source,
179
+ from: args.from,
180
+ to: args.to,
181
+ language: args.language,
182
+ category: args.category,
183
+ limit: args.limit
184
+ })));
185
+ } catch (err) {
186
+ return fail(err);
187
+ }
188
+ });
189
+ register("top_headlines", {
190
+ description: "Fetch current top headlines for a provider, optionally filtered by category, source, or language.",
191
+ inputSchema: {
192
+ provider: providerSchema,
193
+ category: headlinesInputShape.category.describe("News category (e.g. business, technology)."),
194
+ source: headlinesInputShape.source.describe("Restrict to a single source/domain."),
195
+ language: headlinesInputShape.language.describe("ISO-639-1 language code (e.g. en)."),
196
+ limit: headlinesInputShape.limit.describe("Max headlines to return.")
197
+ }
198
+ }, async (rawArgs) => {
199
+ try {
200
+ const args = headlinesArgsSchema.parse(rawArgs);
201
+ return ok(normalizeNewsResult(await client.newsHeadlines({
202
+ provider: args.provider,
203
+ category: args.category,
204
+ source: args.source,
205
+ language: args.language,
206
+ limit: args.limit
207
+ })));
208
+ } catch (err) {
209
+ return fail(err);
210
+ }
211
+ });
212
+ }
213
+ const SERVER_VERSION = validatePackageVersion((0, node_module.createRequire)(require("url").pathToFileURL(__filename).href)("../package.json").version);
214
+ function createServer(client) {
215
+ const server = new _modelcontextprotocol_sdk_server_mcp_js.McpServer({
216
+ name: "news-mcp-server",
217
+ version: SERVER_VERSION
218
+ });
219
+ registerTools(server, client);
220
+ return server;
221
+ }
222
+ async function startServer(client) {
223
+ const server = createServer(client ?? createConfiguredClient());
224
+ try {
225
+ await server.connect(new _modelcontextprotocol_sdk_server_stdio_js.StdioServerTransport());
226
+ log("News MCP server running (news_search, top_headlines)");
227
+ return server;
228
+ } catch (error) {
229
+ await server.close().catch(() => void 0);
230
+ throw error;
231
+ }
232
+ }
233
+ function isProcessEntrypoint(argvPath, metaUrl) {
234
+ try {
235
+ if (!argvPath) return false;
236
+ return (0, node_url.pathToFileURL)((0, node_fs.realpathSync)(argvPath)).href === (0, node_url.pathToFileURL)((0, node_fs.realpathSync)((0, node_url.fileURLToPath)(metaUrl))).href;
237
+ } catch {
238
+ return false;
239
+ }
240
+ }
241
+ function createConfiguredClient() {
242
+ const { apiKey, apiUrl } = (0, _alfe_ai_config.resolveConfig)();
243
+ return new _alfe_ai_agent_api_client.AgentApiClient({
244
+ apiKey,
245
+ apiUrl
246
+ });
247
+ }
248
+ function validatePackageVersion(value) {
249
+ if (typeof value !== "string" || !/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/u.test(value)) throw new Error("news-mcp package version is invalid");
250
+ return value;
251
+ }
252
+ function log(message) {
253
+ process.stderr.write(`[news-mcp] ${message}\n`);
254
+ }
255
+ if (isProcessEntrypoint(process.argv[1], require("url").pathToFileURL(__filename).href)) startServer().then((server) => {
256
+ let shutdownPromise;
257
+ const shutdown = () => {
258
+ if (shutdownPromise) return;
259
+ shutdownPromise = server.close().catch((error) => {
260
+ log(`Failed to close cleanly: ${safeErrorMessage(error)}`);
261
+ }).then(() => process.exit(0));
262
+ };
263
+ process.once("SIGTERM", shutdown);
264
+ process.once("SIGINT", shutdown);
265
+ }).catch((error) => {
266
+ log(`Fatal: ${safeErrorMessage(error)}`);
267
+ process.exitCode = 1;
268
+ });
269
+ //#endregion
270
+ exports.SERVER_VERSION = SERVER_VERSION;
271
+ exports.createServer = createServer;
272
+ exports.isProcessEntrypoint = isProcessEntrypoint;
273
+ exports.startServer = startServer;
@@ -0,0 +1,53 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+
3
+ //#region ../agent-api-client/dist/index.d.ts
4
+
5
+ /** One normalized article. `sentiment` is provider-shaped (APITube supplies it). */
6
+ interface NewsArticle {
7
+ title: string;
8
+ url: string;
9
+ source: string;
10
+ publishedAt: string;
11
+ snippet: string;
12
+ sentiment?: unknown;
13
+ }
14
+ /** Provider-agnostic result — the server normalizes every adapter to this. */
15
+ interface NewsResult {
16
+ articles: NewsArticle[];
17
+ provider: string;
18
+ }
19
+ //#endregion
20
+ //#region src/tools.d.ts
21
+
22
+ /**
23
+ * The subset of `AgentApiClient` this server needs. Declaring it as an
24
+ * interface (rather than importing the concrete class) keeps `registerTools`
25
+ * unit-testable with a stub and avoids constructing a real client in tests.
26
+ */
27
+ interface NewsClient {
28
+ newsSearch(params: {
29
+ query: string;
30
+ provider?: "apitube" | "newsdata";
31
+ source?: string;
32
+ from?: string;
33
+ to?: string;
34
+ language?: string;
35
+ category?: string;
36
+ limit?: number;
37
+ }): Promise<NewsResult>;
38
+ newsHeadlines(params?: {
39
+ provider?: "apitube" | "newsdata";
40
+ category?: string;
41
+ source?: string;
42
+ language?: string;
43
+ limit?: number;
44
+ }): Promise<NewsResult>;
45
+ }
46
+ //#endregion
47
+ //#region src/server.d.ts
48
+ declare const SERVER_VERSION: string;
49
+ declare function createServer(client: NewsClient): McpServer;
50
+ declare function startServer(client?: NewsClient): Promise<McpServer>;
51
+ declare function isProcessEntrypoint(argvPath: string | undefined, metaUrl: string): boolean;
52
+ //#endregion
53
+ export { SERVER_VERSION, createServer, isProcessEntrypoint, startServer };
package/dist/server.d.ts CHANGED
@@ -1 +1,53 @@
1
- export {};
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+
3
+ //#region ../agent-api-client/dist/index.d.ts
4
+
5
+ /** One normalized article. `sentiment` is provider-shaped (APITube supplies it). */
6
+ interface NewsArticle {
7
+ title: string;
8
+ url: string;
9
+ source: string;
10
+ publishedAt: string;
11
+ snippet: string;
12
+ sentiment?: unknown;
13
+ }
14
+ /** Provider-agnostic result — the server normalizes every adapter to this. */
15
+ interface NewsResult {
16
+ articles: NewsArticle[];
17
+ provider: string;
18
+ }
19
+ //#endregion
20
+ //#region src/tools.d.ts
21
+
22
+ /**
23
+ * The subset of `AgentApiClient` this server needs. Declaring it as an
24
+ * interface (rather than importing the concrete class) keeps `registerTools`
25
+ * unit-testable with a stub and avoids constructing a real client in tests.
26
+ */
27
+ interface NewsClient {
28
+ newsSearch(params: {
29
+ query: string;
30
+ provider?: "apitube" | "newsdata";
31
+ source?: string;
32
+ from?: string;
33
+ to?: string;
34
+ language?: string;
35
+ category?: string;
36
+ limit?: number;
37
+ }): Promise<NewsResult>;
38
+ newsHeadlines(params?: {
39
+ provider?: "apitube" | "newsdata";
40
+ category?: string;
41
+ source?: string;
42
+ language?: string;
43
+ limit?: number;
44
+ }): Promise<NewsResult>;
45
+ }
46
+ //#endregion
47
+ //#region src/server.d.ts
48
+ declare const SERVER_VERSION: string;
49
+ declare function createServer(client: NewsClient): McpServer;
50
+ declare function startServer(client?: NewsClient): Promise<McpServer>;
51
+ declare function isProcessEntrypoint(argvPath: string | undefined, metaUrl: string): boolean;
52
+ //#endregion
53
+ export { SERVER_VERSION, createServer, isProcessEntrypoint, startServer };
package/dist/server.js CHANGED
@@ -1,39 +1,155 @@
1
1
  #!/usr/bin/env node
2
+ import { createRequire } from "node:module";
3
+ import { realpathSync } from "node:fs";
4
+ import { fileURLToPath, pathToFileURL } from "node:url";
2
5
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
6
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
7
  import { resolveConfig } from "@alfe.ai/config";
5
8
  import { AgentApiClient } from "@alfe.ai/agent-api-client";
6
9
  import { z } from "zod";
10
+ //#region src/boundary.ts
11
+ const MAX_QUERY_CHARS = 400;
12
+ const MAX_SOURCE_CHARS = 200;
13
+ const MAX_DATE_CHARS = 40;
14
+ const MAX_LANGUAGE_CHARS = 11;
15
+ const MAX_CATEGORY_CHARS = 60;
16
+ const MAX_TITLE_CHARS = 4096;
17
+ const MAX_URL_CHARS = 16384;
18
+ const MAX_RESULT_SOURCE_CHARS = 1024;
19
+ const MAX_PUBLISHED_AT_CHARS = 128;
20
+ const MAX_SNIPPET_CHARS = 16384;
21
+ const MAX_SENTIMENT_LABEL_CHARS = 128;
22
+ const MAX_ARTICLES = 100;
23
+ const MAX_TOOL_RESULT_BYTES = 2 * 1024 * 1024;
24
+ const newsProviderSchema = z.enum(["apitube", "newsdata"]);
25
+ function boundedInputString(max, label) {
26
+ return z.string().min(1).max(max).refine((value) => !hasControlCharacters(value), `${label} cannot contain control characters`);
27
+ }
28
+ const baseSearchSchema = z.object({
29
+ query: boundedInputString(MAX_QUERY_CHARS, "Search query"),
30
+ provider: newsProviderSchema.default("apitube"),
31
+ source: boundedInputString(MAX_SOURCE_CHARS, "News source").optional(),
32
+ from: boundedInputString(MAX_DATE_CHARS, "Start date").optional(),
33
+ to: boundedInputString(MAX_DATE_CHARS, "End date").optional(),
34
+ language: boundedInputString(MAX_LANGUAGE_CHARS, "Language").regex(/^[a-z]{2,3}(,[a-z]{2,3}){0,2}$/iu).optional(),
35
+ category: boundedInputString(MAX_CATEGORY_CHARS, "Category").optional(),
36
+ limit: z.coerce.number().int().min(1).max(MAX_ARTICLES).optional()
37
+ });
38
+ const searchInputShape = baseSearchSchema.shape;
39
+ const searchArgsSchema = baseSearchSchema.superRefine((value, context) => {
40
+ if (value.provider !== "newsdata") return;
41
+ if (value.limit !== void 0 && value.limit > 50) context.addIssue({
42
+ code: "custom",
43
+ path: ["limit"],
44
+ message: "NewsData supports at most 50 results"
45
+ });
46
+ for (const field of ["from", "to"]) {
47
+ const date = value[field];
48
+ if (date !== void 0 && !isNewsDataDate(date)) context.addIssue({
49
+ code: "custom",
50
+ path: [field],
51
+ message: "NewsData dates must use YYYY-MM-DD"
52
+ });
53
+ }
54
+ if (value.from !== void 0 && value.to !== void 0 && isNewsDataDate(value.from) && isNewsDataDate(value.to) && value.from > value.to) context.addIssue({
55
+ code: "custom",
56
+ path: ["to"],
57
+ message: "End date must not precede start date"
58
+ });
59
+ });
60
+ const baseHeadlinesSchema = z.object({
61
+ provider: newsProviderSchema.default("apitube"),
62
+ category: boundedInputString(MAX_CATEGORY_CHARS, "Category").optional(),
63
+ source: boundedInputString(MAX_SOURCE_CHARS, "News source").optional(),
64
+ language: boundedInputString(MAX_LANGUAGE_CHARS, "Language").regex(/^[a-z]{2,3}(,[a-z]{2,3}){0,2}$/iu).optional(),
65
+ limit: z.coerce.number().int().min(1).max(MAX_ARTICLES).optional()
66
+ });
67
+ const headlinesInputShape = baseHeadlinesSchema.shape;
68
+ const headlinesArgsSchema = baseHeadlinesSchema.superRefine((value, context) => {
69
+ if (value.provider === "newsdata" && value.limit !== void 0 && value.limit > 50) context.addIssue({
70
+ code: "custom",
71
+ path: ["limit"],
72
+ message: "NewsData supports at most 50 results"
73
+ });
74
+ });
75
+ const resultString = (max) => z.string().max(max).refine((value) => !hasControlCharacters(value));
76
+ const articleUrlSchema = resultString(MAX_URL_CHARS).refine((value) => {
77
+ try {
78
+ const url = new URL(value);
79
+ return (url.protocol === "https:" || url.protocol === "http:") && !url.username && !url.password;
80
+ } catch {
81
+ return false;
82
+ }
83
+ }, "Article URL must be a safe HTTP(S) URL");
84
+ const sentimentSchema = z.object({
85
+ score: z.number().min(-1).max(1).optional(),
86
+ label: resultString(MAX_SENTIMENT_LABEL_CHARS).min(1).optional()
87
+ }).refine((value) => value.score !== void 0 || value.label !== void 0, { message: "Sentiment must contain a score or label" });
88
+ const newsResultSchema = z.object({
89
+ provider: newsProviderSchema,
90
+ articles: z.array(z.object({
91
+ title: resultString(MAX_TITLE_CHARS).min(1),
92
+ url: articleUrlSchema,
93
+ source: resultString(MAX_RESULT_SOURCE_CHARS).min(1),
94
+ publishedAt: resultString(MAX_PUBLISHED_AT_CHARS).min(1).refine((value) => Number.isFinite(Date.parse(value)), "Invalid article timestamp"),
95
+ snippet: resultString(MAX_SNIPPET_CHARS),
96
+ sentiment: sentimentSchema.optional()
97
+ })).max(MAX_ARTICLES)
98
+ });
99
+ function normalizeNewsResult(value) {
100
+ const result = newsResultSchema.parse(value);
101
+ const encoded = JSON.stringify(result);
102
+ if (Buffer.byteLength(encoded, "utf8") > 2097152) throw new Error(`News result exceeds ${String(MAX_TOOL_RESULT_BYTES)} bytes`);
103
+ return result;
104
+ }
105
+ function encodeToolResult(value) {
106
+ const encoded = JSON.stringify(value);
107
+ if (Buffer.byteLength(encoded, "utf8") > 2097152) throw new Error(`News tool result exceeds ${String(MAX_TOOL_RESULT_BYTES)} bytes`);
108
+ return encoded;
109
+ }
110
+ function safeErrorMessage(error, secrets = []) {
111
+ let output = (error instanceof Error ? error.message : String(error)).slice(0, 4096).replace(/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]+/giu, "$1 [REDACTED]").replace(/\b(api[_-]?key|access[_-]?token|refresh[_-]?token|secret|password)\s*[=:]\s*[^\s,;]+/giu, "$1=[REDACTED]").replace(/https?:\/\/[^\s/@:]+:[^\s/@]+@/giu, "https://[REDACTED]@").replace(/\balfe_[A-Za-z0-9_-]{8,}/gu, "[REDACTED]");
112
+ for (const secret of secrets) if (secret.length >= 4) output = output.split(secret).join("[REDACTED]");
113
+ return flattenControls(output);
114
+ }
115
+ function isNewsDataDate(value) {
116
+ if (!/^\d{4}-\d{2}-\d{2}$/u.test(value)) return false;
117
+ const epoch = Date.parse(`${value}T00:00:00Z`);
118
+ return Number.isFinite(epoch) && new Date(epoch).toISOString().slice(0, 10) === value;
119
+ }
120
+ function hasControlCharacters(value) {
121
+ return Array.from(value).some((character) => {
122
+ const codePoint = character.codePointAt(0) ?? 0;
123
+ return codePoint < 32 || codePoint === 127;
124
+ });
125
+ }
126
+ function flattenControls(value) {
127
+ let output = "";
128
+ let previousWasControl = false;
129
+ for (const character of value) {
130
+ const codePoint = character.codePointAt(0) ?? 0;
131
+ const isControl = codePoint < 32 || codePoint === 127;
132
+ if (isControl) {
133
+ if (!previousWasControl) output += " ";
134
+ } else output += character;
135
+ previousWasControl = isControl;
136
+ }
137
+ return output;
138
+ }
139
+ //#endregion
7
140
  //#region src/tools.ts
8
- /**
9
- * MCP tool registration for the News MCP server.
10
- *
11
- * Tools:
12
- * news_search → client.newsSearch(...) (POST /agent/news/search)
13
- * top_headlines → client.newsHeadlines(...) (POST /agent/news/headlines)
14
- *
15
- * Both take a strict zod input schema. `provider` is an ENUM
16
- * (`apitube | newsdata`) defaulting to `apitube` — never free-text, because a
17
- * typo'd provider server-side is an unpriceable product. All provider keys and
18
- * billing live server-side in `services/news`; this server only forwards a
19
- * typed body via `@alfe.ai/agent-api-client` and normalizes errors.
20
- *
21
- * Provider / billing / HTTP errors are surfaced as an `isError` MCP result
22
- * carrying the error text — never a fake success.
23
- */
24
- const providerSchema = z.enum(["apitube", "newsdata"]).default("apitube").describe("News provider to query. APITube (default) covers 500K+ sources with sentiment.");
141
+ const providerSchema = newsProviderSchema.default("apitube").describe("News provider to query. APITube (default) covers 500K+ sources with sentiment.");
25
142
  function ok(data) {
26
143
  return { content: [{
27
144
  type: "text",
28
- text: JSON.stringify(data)
145
+ text: encodeToolResult(data)
29
146
  }] };
30
147
  }
31
148
  function fail(err) {
32
- const message = err instanceof Error ? err.message : String(err);
33
149
  return {
34
150
  content: [{
35
151
  type: "text",
36
- text: JSON.stringify({ error: message })
152
+ text: encodeToolResult({ error: safeErrorMessage(err) })
37
153
  }],
38
154
  isError: true
39
155
  };
@@ -43,18 +159,19 @@ function registerTools(server, client) {
43
159
  register("news_search", {
44
160
  description: "Search recent news across a broad provider (APITube / NewsData). Returns normalized articles with title, url, source, publishedAt, snippet, and (APITube) sentiment.",
45
161
  inputSchema: {
46
- query: z.string().min(1).describe("Search query (keywords, entities, topics)."),
162
+ query: searchInputShape.query.describe("Search query (keywords, entities, topics)."),
47
163
  provider: providerSchema,
48
- source: z.string().optional().describe("Restrict to a single source/domain (e.g. reuters.com)."),
49
- from: z.string().optional().describe("Earliest publish date, ISO-8601 (e.g. 2026-07-01)."),
50
- to: z.string().optional().describe("Latest publish date, ISO-8601."),
51
- language: z.string().optional().describe("ISO-639-1 language code (e.g. en)."),
52
- category: z.string().optional().describe("News category (e.g. business, technology)."),
53
- limit: z.coerce.number().int().positive().max(100).optional().describe("Max articles to return.")
164
+ source: searchInputShape.source.describe("Restrict to a single source/domain (e.g. reuters.com)."),
165
+ from: searchInputShape.from.describe("Earliest publish date, ISO-8601 (e.g. 2026-07-01)."),
166
+ to: searchInputShape.to.describe("Latest publish date, ISO-8601."),
167
+ language: searchInputShape.language.describe("ISO-639-1 language code (e.g. en)."),
168
+ category: searchInputShape.category.describe("News category (e.g. business, technology)."),
169
+ limit: searchInputShape.limit.describe("Max articles to return.")
54
170
  }
55
- }, async (args) => {
171
+ }, async (rawArgs) => {
56
172
  try {
57
- return ok(await client.newsSearch({
173
+ const args = searchArgsSchema.parse(rawArgs);
174
+ return ok(normalizeNewsResult(await client.newsSearch({
58
175
  query: args.query,
59
176
  provider: args.provider,
60
177
  source: args.source,
@@ -63,7 +180,7 @@ function registerTools(server, client) {
63
180
  language: args.language,
64
181
  category: args.category,
65
182
  limit: args.limit
66
- }));
183
+ })));
67
184
  } catch (err) {
68
185
  return fail(err);
69
186
  }
@@ -72,68 +189,81 @@ function registerTools(server, client) {
72
189
  description: "Fetch current top headlines for a provider, optionally filtered by category, source, or language.",
73
190
  inputSchema: {
74
191
  provider: providerSchema,
75
- category: z.string().optional().describe("News category (e.g. business, technology)."),
76
- source: z.string().optional().describe("Restrict to a single source/domain."),
77
- language: z.string().optional().describe("ISO-639-1 language code (e.g. en)."),
78
- limit: z.coerce.number().int().positive().max(100).optional().describe("Max headlines to return.")
192
+ category: headlinesInputShape.category.describe("News category (e.g. business, technology)."),
193
+ source: headlinesInputShape.source.describe("Restrict to a single source/domain."),
194
+ language: headlinesInputShape.language.describe("ISO-639-1 language code (e.g. en)."),
195
+ limit: headlinesInputShape.limit.describe("Max headlines to return.")
79
196
  }
80
- }, async (args) => {
197
+ }, async (rawArgs) => {
81
198
  try {
82
- return ok(await client.newsHeadlines({
199
+ const args = headlinesArgsSchema.parse(rawArgs);
200
+ return ok(normalizeNewsResult(await client.newsHeadlines({
83
201
  provider: args.provider,
84
202
  category: args.category,
85
203
  source: args.source,
86
204
  language: args.language,
87
205
  limit: args.limit
88
- }));
206
+ })));
89
207
  } catch (err) {
90
208
  return fail(err);
91
209
  }
92
210
  });
93
211
  }
94
- //#endregion
95
- //#region src/server.ts
96
- /**
97
- * News MCP Server
98
- *
99
- * Standalone stdio MCP server for broad news search (APITube / NewsData behind
100
- * a `provider` arg). Runtime-agnostic — spawned via `npx -y @alfe.ai/news-mcp`
101
- * from an integration manifest, speaks MCP over stdio, so it runs under any
102
- * runtime that can spawn `npx`.
103
- *
104
- * Architecture:
105
- * Agent runtime ←(stdio/MCP)→ this server ←(HTTPS)→ services/news (metered)
106
- *
107
- * No provider credentials live on the box. At startup we self-fetch the agent's
108
- * `{ apiKey, apiUrl }` via `resolveConfig()` and call the metered `services/news`
109
- * Lambda through `AgentApiClient`. Provider keys + billing are server-side; the
110
- * Lambda is the metering chokepoint.
111
- */
112
- function log(msg) {
113
- process.stderr.write(`[news-mcp] ${msg}\n`);
114
- }
115
- async function main() {
116
- const { apiKey, apiUrl } = resolveConfig();
117
- const client = new AgentApiClient({
118
- apiKey,
119
- apiUrl
120
- });
212
+ const SERVER_VERSION = validatePackageVersion(createRequire(import.meta.url)("../package.json").version);
213
+ function createServer(client) {
121
214
  const server = new McpServer({
122
215
  name: "news-mcp-server",
123
- version: "0.1.0"
216
+ version: SERVER_VERSION
124
217
  });
125
218
  registerTools(server, client);
219
+ return server;
220
+ }
221
+ async function startServer(client) {
222
+ const server = createServer(client ?? createConfiguredClient());
223
+ try {
224
+ await server.connect(new StdioServerTransport());
225
+ log("News MCP server running (news_search, top_headlines)");
226
+ return server;
227
+ } catch (error) {
228
+ await server.close().catch(() => void 0);
229
+ throw error;
230
+ }
231
+ }
232
+ function isProcessEntrypoint(argvPath, metaUrl) {
233
+ try {
234
+ if (!argvPath) return false;
235
+ return pathToFileURL(realpathSync(argvPath)).href === pathToFileURL(realpathSync(fileURLToPath(metaUrl))).href;
236
+ } catch {
237
+ return false;
238
+ }
239
+ }
240
+ function createConfiguredClient() {
241
+ const { apiKey, apiUrl } = resolveConfig();
242
+ return new AgentApiClient({
243
+ apiKey,
244
+ apiUrl
245
+ });
246
+ }
247
+ function validatePackageVersion(value) {
248
+ if (typeof value !== "string" || !/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/u.test(value)) throw new Error("news-mcp package version is invalid");
249
+ return value;
250
+ }
251
+ function log(message) {
252
+ process.stderr.write(`[news-mcp] ${message}\n`);
253
+ }
254
+ if (isProcessEntrypoint(process.argv[1], import.meta.url)) startServer().then((server) => {
255
+ let shutdownPromise;
126
256
  const shutdown = () => {
127
- process.exit(0);
257
+ if (shutdownPromise) return;
258
+ shutdownPromise = server.close().catch((error) => {
259
+ log(`Failed to close cleanly: ${safeErrorMessage(error)}`);
260
+ }).then(() => process.exit(0));
128
261
  };
129
- for (const signal of ["SIGTERM", "SIGINT"]) process.on(signal, shutdown);
130
- const transport = new StdioServerTransport();
131
- await server.connect(transport);
132
- log("News MCP server running (news_search, top_headlines)");
133
- }
134
- main().catch((err) => {
135
- log(`Fatal: ${err instanceof Error ? err.message : String(err)}`);
136
- process.exit(1);
262
+ process.once("SIGTERM", shutdown);
263
+ process.once("SIGINT", shutdown);
264
+ }).catch((error) => {
265
+ log(`Fatal: ${safeErrorMessage(error)}`);
266
+ process.exitCode = 1;
137
267
  });
138
268
  //#endregion
139
- export {};
269
+ export { SERVER_VERSION, createServer, isProcessEntrypoint, startServer };
package/package.json CHANGED
@@ -1,15 +1,17 @@
1
1
  {
2
2
  "name": "@alfe.ai/news-mcp",
3
- "version": "0.2.5",
3
+ "version": "0.2.7",
4
4
  "description": "News MCP server — broad news search (APITube / NewsData) with sentiment, metered server-side via Alfe",
5
5
  "type": "module",
6
6
  "main": "./dist/server.js",
7
+ "types": "./dist/server.d.ts",
7
8
  "bin": {
8
9
  "news-mcp-server": "./dist/server.js"
9
10
  },
10
11
  "exports": {
11
12
  ".": {
12
13
  "types": "./dist/server.d.ts",
14
+ "require": "./dist/server.cjs",
13
15
  "import": "./dist/server.js"
14
16
  }
15
17
  },
@@ -19,8 +21,8 @@
19
21
  "dependencies": {
20
22
  "@modelcontextprotocol/sdk": "^1.29.0",
21
23
  "zod": "^4.0.5",
22
- "@alfe.ai/agent-api-client": "0.14.0",
23
- "@alfe.ai/config": "0.4.0"
24
+ "@alfe.ai/agent-api-client": "0.16.0",
25
+ "@alfe.ai/config": "0.4.1"
24
26
  },
25
27
  "license": "UNLICENSED",
26
28
  "homepage": "https://alfe.ai",