@alfe.ai/news-mcp 0.2.5 → 0.2.6
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 +25 -0
- package/dist/server.cjs +273 -0
- package/dist/server.d.cts +53 -0
- package/dist/server.d.ts +53 -1
- package/dist/server.js +205 -75
- package/package.json +5 -3
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
|
+
```
|
package/dist/server.cjs
ADDED
|
@@ -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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
162
|
+
query: searchInputShape.query.describe("Search query (keywords, entities, topics)."),
|
|
47
163
|
provider: providerSchema,
|
|
48
|
-
source:
|
|
49
|
-
from:
|
|
50
|
-
to:
|
|
51
|
-
language:
|
|
52
|
-
category:
|
|
53
|
-
limit:
|
|
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 (
|
|
171
|
+
}, async (rawArgs) => {
|
|
56
172
|
try {
|
|
57
|
-
|
|
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:
|
|
76
|
-
source:
|
|
77
|
-
language:
|
|
78
|
-
limit:
|
|
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 (
|
|
197
|
+
}, async (rawArgs) => {
|
|
81
198
|
try {
|
|
82
|
-
|
|
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
|
-
|
|
95
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
log(
|
|
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.
|
|
3
|
+
"version": "0.2.6",
|
|
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.
|
|
23
|
-
"@alfe.ai/config": "0.4.
|
|
24
|
+
"@alfe.ai/agent-api-client": "0.15.0",
|
|
25
|
+
"@alfe.ai/config": "0.4.1"
|
|
24
26
|
},
|
|
25
27
|
"license": "UNLICENSED",
|
|
26
28
|
"homepage": "https://alfe.ai",
|