afpnews-mcp-server 1.2.0 → 1.3.0

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 CHANGED
@@ -1,6 +1,8 @@
1
1
  # afpnews-mcp
2
2
 
3
- MCP (Model Context Protocol) server that exposes [AFP](https://www.afp.com/) news content as tools for AI assistants. Works with any MCP-compatible client: Claude Code, Claude Desktop, Continue, Cursor, etc.
3
+ MCP (Model Context Protocol) server that exposes [AFP](https://www.afp.com/) news content as tools for AI assistants. Works with any MCP-compatible client.
4
+
5
+ The package can also be used as a library without MCP server glue via `afpnews-mcp-server/definitions`.
4
6
 
5
7
  ## Prerequisites
6
8
 
@@ -11,8 +13,8 @@ MCP (Model Context Protocol) server that exposes [AFP](https://www.afp.com/) new
11
13
  ## Setup
12
14
 
13
15
  ```bash
14
- git clone https://github.com/julesbonnard/afpnews-mcp.git
15
- cd afpnews-mcp
16
+ git clone https://github.com/julesbonnard/afpnews-mcp-server.git
17
+ cd afpnews-mcp-server
16
18
  pnpm install
17
19
  pnpm run build
18
20
  ```
@@ -25,39 +27,19 @@ APICORE_USERNAME=your-username
25
27
  APICORE_PASSWORD=your-password
26
28
  ```
27
29
 
28
- Or for stdio only, provide a serialized auth token instead:
29
-
30
- ```
31
- APICORE_AUTH_TOKEN={"accessToken":"...","refreshToken":"...","tokenExpires":1735689600000,"authType":"credentials"}
32
- ```
33
-
34
30
  ## Usage
35
31
 
36
32
  ### Stdio transport (default)
37
33
 
38
34
  For local MCP clients like Claude Code or Claude Desktop:
39
35
 
40
- ```bash
41
- pnpm run start
42
- ```
43
-
44
- Direct CLI call (same stdio behavior):
45
-
46
- ```bash
47
- APICORE_AUTH_TOKEN='{"accessToken":"...","refreshToken":"...","tokenExpires":1735689600000,"authType":"credentials"}' node build/index.js
48
- ```
49
-
50
- #### Claude Code
51
-
52
- Add to your Claude Code MCP settings:
53
-
54
36
  ```json
55
37
  {
56
38
  "mcpServers": {
57
39
  "afpnews": {
58
40
  "command": "node",
59
41
  "args": ["build/index.js"],
60
- "cwd": "/path/to/afpnews-mcp",
42
+ "cwd": "/absolute/path/to/afpnews-mcp-server",
61
43
  "env": {
62
44
  "APICORE_API_KEY": "your-api-key",
63
45
  "APICORE_USERNAME": "your-username",
@@ -76,6 +58,10 @@ For remote or multi-user deployments. Each session authenticates independently v
76
58
  MCP_TRANSPORT=http PORT=3000 pnpm run start
77
59
  ```
78
60
 
61
+ Notes:
62
+ - Keep `APICORE_API_KEY` set in the server environment (`.env` or runtime env).
63
+ - If you expose the server remotely, use HTTPS.
64
+
79
65
  ### Docker
80
66
 
81
67
  ```bash
@@ -84,18 +70,43 @@ docker build -t afpnews-mcp .
84
70
  docker run -e APICORE_API_KEY=your-api-key -p 3000:3000 afpnews-mcp
85
71
  ```
86
72
 
73
+ ### As a library (without MCP server dependency)
74
+
75
+ You can import pure definitions (tools, prompts, resources) and wire them into your own runtime:
76
+
77
+ ```ts
78
+ import { AFP_DEFINITIONS } from 'afpnews-mcp-server/definitions';
79
+
80
+ const { tools, prompts, resources } = AFP_DEFINITIONS;
81
+ ```
82
+
83
+ Or import each collection directly:
84
+
85
+ ```ts
86
+ import {
87
+ TOOL_DEFINITIONS,
88
+ PROMPT_DEFINITIONS,
89
+ RESOURCE_DEFINITIONS,
90
+ } from 'afpnews-mcp-server/definitions';
91
+ ```
92
+
93
+ Each definition is framework-agnostic:
94
+ - `tools`: `name`, `title`, `description`, `inputSchema`, `handler(apicore, args)`
95
+ - `prompts`: `name`, `title`, `description`, `argsSchema`, `handler(args)`
96
+ - `resources`: `name`, `uri`, `description`, `mimeType`, `handler()`
97
+
87
98
  ## Tools
88
99
 
89
100
  | Tool | Description |
90
101
  |------|-------------|
91
- | `search` | Search AFP articles with filters, presets, and full-text mode |
92
- | `get` | Get a full article by its UNO identifier |
93
- | `mlt` | Find similar articles (More Like This) from a UNO |
94
- | `list` | List facet values (topics, genres, countries) with frequency counts |
102
+ | `afp_search_articles` | Search AFP articles with filters, presets, and full-text mode |
103
+ | `afp_get_article` | Get a full article by its UNO identifier |
104
+ | `afp_find_similar` | Find similar articles (More Like This) from a UNO |
105
+ | `afp_list_facets` | List facet values (topics, genres, countries) with frequency counts |
95
106
 
96
107
  ### Search presets
97
108
 
98
- The `search` tool supports presets that apply predefined filters:
109
+ The `afp_search_articles` tool supports presets that apply predefined filters:
99
110
 
100
111
  - **`a-la-une`** — Top story (French, last 24h)
101
112
  - **`agenda`** — Upcoming events
@@ -108,7 +119,7 @@ The `search` tool supports presets that apply predefined filters:
108
119
 
109
120
  ### Full text
110
121
 
111
- By default, `search` returns excerpts (first 4 paragraphs). Set `fullText: true` to get the complete article body. Presets default to full text.
122
+ By default, `afp_search_articles` returns excerpts (first 4 paragraphs). Set `fullText: true` to get the complete article body. Presets default to full text.
112
123
 
113
124
  ## Prompts
114
125
 
@@ -133,6 +144,16 @@ pnpm run build
133
144
  pnpm test
134
145
  ```
135
146
 
147
+ ## Internal Architecture
148
+
149
+ - `src/tools/*.ts`, `src/prompts/*.ts`, `src/resources/*.ts` contain pure definitions.
150
+ - `src/tools/index.ts`, `src/prompts/index.ts`, `src/resources/index.ts` contain MCP registration glue.
151
+ - `src/definitions.ts` exports aggregated, server-agnostic definitions:
152
+ - `AFP_DEFINITIONS`
153
+ - `TOOL_DEFINITIONS`
154
+ - `PROMPT_DEFINITIONS`
155
+ - `RESOURCE_DEFINITIONS`
156
+
136
157
  ## License
137
158
 
138
159
  ISC
@@ -0,0 +1,188 @@
1
+ import { tool } from 'ai';
2
+ import { z } from 'zod';
3
+ import { ApiCore } from 'afpnews-api';
4
+ import { formatDocument, DEFAULT_FIELDS, GENRE_EXCLUSIONS, textContent, truncateIfNeeded, buildPaginationLine, } from './utils/format.js';
5
+ import { getTopicLabel } from './utils/topics.js';
6
+ import { DEFAULT_SEARCH_SIZE, DEFAULT_FACET_SIZE } from './utils/types.js';
7
+ const SEARCH_PRESET_VALUES = ['a-la-une', 'agenda', 'previsions', 'major-stories'];
8
+ const searchPresetEnum = z.enum(SEARCH_PRESET_VALUES);
9
+ const LIST_PRESET_VALUES = ['trending-topics'];
10
+ const listPresetEnum = z.enum(LIST_PRESET_VALUES);
11
+ const langEnum = z.enum(['en', 'fr', 'de', 'pt', 'es', 'ar']);
12
+ const SEARCH_PRESETS = {
13
+ 'a-la-une': {
14
+ product: ['news'],
15
+ lang: ['fr'],
16
+ slug: ['afp', 'actualites'],
17
+ dateFrom: 'now-1d',
18
+ size: 1,
19
+ genreid: GENRE_EXCLUSIONS,
20
+ },
21
+ agenda: {
22
+ product: ['news'],
23
+ size: 5,
24
+ genreid: ['afpattribute:Agenda'],
25
+ },
26
+ previsions: {
27
+ product: ['news'],
28
+ size: 5,
29
+ genreid: ['afpattribute:Program', 'afpedtype:TextProgram'],
30
+ },
31
+ 'major-stories': {
32
+ product: ['news'],
33
+ genreid: ['afpattribute:Article'],
34
+ },
35
+ };
36
+ function formatDocuments(documents, fullText) {
37
+ return documents.map((doc) => formatDocument(doc, fullText));
38
+ }
39
+ function formatErrorMessage(context, error, hint) {
40
+ const message = error instanceof Error ? error.message : String(error);
41
+ return `Error ${context}: ${message}. ${hint}`;
42
+ }
43
+ function contentToText(content) {
44
+ return content.map((c) => c.text).join('\n\n');
45
+ }
46
+ const searchParameters = z.object({
47
+ preset: searchPresetEnum.optional().describe('Optional preset that applies predefined AFP filters.'),
48
+ fullText: z.boolean().optional().describe('When true, returns the full article body. Default is false.'),
49
+ query: z.string().optional().describe('Keywords to search for (in the language specified by lang).'),
50
+ lang: langEnum.array().optional().describe("Language filter (e.g. ['en', 'fr'])."),
51
+ dateFrom: z.string().optional().describe("Start date (ISO or relative e.g. 'now-1d')."),
52
+ dateTo: z.string().optional().describe('End date (ISO format).'),
53
+ size: z.number().optional().describe('Number of results (default 10, max 1000).'),
54
+ sortOrder: z.enum(['asc', 'desc']).optional().describe("Sort order by date (default 'desc')."),
55
+ offset: z.number().optional().describe('Pagination offset.'),
56
+ country: z.string().array().optional().describe("Country code filter (e.g. ['fra', 'usa'])."),
57
+ slug: z.string().array().optional().describe('Topic/slug filter.'),
58
+ product: z
59
+ .enum(['news', 'factcheck', 'photo', 'video', 'multimedia', 'graphic', 'videographic'])
60
+ .array()
61
+ .optional()
62
+ .describe("Content type filter (default ['news', 'factcheck'])."),
63
+ });
64
+ const getArticleParameters = z.object({
65
+ uno: z.string().describe('The unique identifier (UNO) of the article.'),
66
+ });
67
+ const findSimilarParameters = z.object({
68
+ uno: z.string().describe('The UNO of the reference article.'),
69
+ lang: langEnum.describe('Language for results.'),
70
+ size: z.number().optional().describe('Number of similar articles (default 10).'),
71
+ });
72
+ const listFacetsParameters = z.object({
73
+ preset: listPresetEnum.optional().describe('Optional preset (trending-topics).'),
74
+ facet: z
75
+ .string()
76
+ .optional()
77
+ .describe("Facet to list (e.g. 'slug', 'genre', 'country'). Required when no preset."),
78
+ lang: langEnum.optional().describe('Language filter.'),
79
+ size: z.number().optional().describe('Number of facet values to return.'),
80
+ });
81
+ export function createAiTools(token) {
82
+ const apicore = new ApiCore();
83
+ apicore.token = token;
84
+ return {
85
+ afp_search_articles: tool({
86
+ description: `Search AFP news articles with filters and presets.
87
+ Args: preset (a-la-une|agenda|previsions|major-stories), fullText, query, lang, dateFrom, dateTo, size, sortOrder, offset,
88
+ country, slug, product.
89
+ Returns: markdown-formatted articles with headline, metadata and body.`,
90
+ parameters: searchParameters,
91
+ execute: async ({ preset, fullText = false, query, lang, dateFrom, dateTo, size = DEFAULT_SEARCH_SIZE, sortOrder = 'desc', offset, country, slug, product = ['news', 'factcheck'], }) => {
92
+ try {
93
+ let request = {
94
+ query,
95
+ lang,
96
+ product,
97
+ dateFrom,
98
+ dateTo,
99
+ size,
100
+ sortOrder,
101
+ startAt: offset,
102
+ country,
103
+ slug,
104
+ genreid: GENRE_EXCLUSIONS,
105
+ };
106
+ if (preset) {
107
+ request = { ...request, ...SEARCH_PRESETS[preset] };
108
+ fullText = true;
109
+ }
110
+ const { documents, count } = await apicore.search(request, [...DEFAULT_FIELDS]);
111
+ if (count === 0)
112
+ return 'No results found.';
113
+ const currentOffset = offset ?? 0;
114
+ const content = [
115
+ textContent(buildPaginationLine(documents.length, count, currentOffset)),
116
+ ...formatDocuments(documents, fullText),
117
+ ];
118
+ return contentToText(truncateIfNeeded(content));
119
+ }
120
+ catch (error) {
121
+ return formatErrorMessage('searching AFP articles', error, 'Check your query parameters and try again.');
122
+ }
123
+ },
124
+ }),
125
+ afp_get_article: tool({
126
+ description: 'Get a full AFP news article by its UNO identifier.',
127
+ parameters: getArticleParameters,
128
+ execute: async ({ uno }) => {
129
+ try {
130
+ const doc = await apicore.get(uno);
131
+ return formatDocument(doc, true).text;
132
+ }
133
+ catch (error) {
134
+ return formatErrorMessage(`fetching article "${uno}"`, error, 'Verify the UNO identifier is correct.');
135
+ }
136
+ },
137
+ }),
138
+ afp_find_similar: tool({
139
+ description: 'Find AFP articles similar to a given article (More Like This).',
140
+ parameters: findSimilarParameters,
141
+ execute: async ({ uno, lang, size }) => {
142
+ try {
143
+ const { documents, count } = await apicore.mlt(uno, lang, size);
144
+ if (count === 0)
145
+ return 'No similar articles found.';
146
+ const content = [textContent(`*Found ${count} similar articles.*`), ...formatDocuments(documents, false)];
147
+ return contentToText(truncateIfNeeded(content));
148
+ }
149
+ catch (error) {
150
+ return formatErrorMessage(`finding similar articles for "${uno}"`, error, 'Verify the UNO identifier is correct.');
151
+ }
152
+ },
153
+ }),
154
+ afp_list_facets: tool({
155
+ description: `List facet values and article counts. Use preset 'trending-topics' or specify a facet like 'slug',
156
+ 'genre', 'country'.`,
157
+ parameters: listFacetsParameters,
158
+ execute: async ({ preset, facet, lang, size }) => {
159
+ try {
160
+ const isTrendingTopics = preset === 'trending-topics';
161
+ const resolvedFacet = isTrendingTopics ? 'slug' : facet;
162
+ if (!resolvedFacet) {
163
+ return "Missing required parameter: facet (e.g. 'slug', 'genre', 'country'). Alternatively, use preset: 'trending-topics'.";
164
+ }
165
+ const params = isTrendingTopics
166
+ ? { langs: [lang ?? 'fr'], product: ['news'], dateFrom: 'now-1d' }
167
+ : lang
168
+ ? { langs: [lang] }
169
+ : {};
170
+ const resolvedSize = isTrendingTopics ? (size ?? DEFAULT_FACET_SIZE) : size;
171
+ const rawResult = await apicore.list(resolvedFacet, params, resolvedSize);
172
+ const results = rawResult?.keywords ?? rawResult ?? [];
173
+ if (results.length === 0)
174
+ return `No facet values found for "${resolvedFacet}".`;
175
+ const heading = isTrendingTopics ? 'Trending Topics' : `Facet: ${resolvedFacet}`;
176
+ const lines = results.map((item) => {
177
+ const label = isTrendingTopics ? (getTopicLabel(item.key) ?? item.key) : item.key;
178
+ return `- **${label}** — ${item.count} articles`;
179
+ });
180
+ return `## ${heading}\n\n${lines.join('\n')}`;
181
+ }
182
+ catch (error) {
183
+ return formatErrorMessage('listing facet values', error, 'Check that the facet name is valid.');
184
+ }
185
+ },
186
+ }),
187
+ };
188
+ }
@@ -0,0 +1,9 @@
1
+ import { TOOL_DEFINITIONS } from './tools/index.js';
2
+ import { PROMPT_DEFINITIONS } from './prompts/index.js';
3
+ import { RESOURCE_DEFINITIONS } from './resources/index.js';
4
+ export const AFP_DEFINITIONS = {
5
+ tools: TOOL_DEFINITIONS,
6
+ prompts: PROMPT_DEFINITIONS,
7
+ resources: RESOURCE_DEFINITIONS,
8
+ };
9
+ export { TOOL_DEFINITIONS, PROMPT_DEFINITIONS, RESOURCE_DEFINITIONS };
package/build/index.js CHANGED
@@ -15,38 +15,14 @@ function decodeBasicAuth(header) {
15
15
  password: decoded.substring(colon + 1)
16
16
  };
17
17
  }
18
- function parseAuthToken(tokenValue) {
19
- let parsed;
20
- try {
21
- parsed = JSON.parse(tokenValue);
22
- }
23
- catch {
24
- throw new Error('APICORE_AUTH_TOKEN must be a valid JSON object');
25
- }
26
- if (!parsed || typeof parsed !== 'object') {
27
- throw new Error('APICORE_AUTH_TOKEN must be a JSON object');
28
- }
29
- const token = parsed;
30
- if (typeof token.accessToken !== 'string' ||
31
- typeof token.refreshToken !== 'string' ||
32
- typeof token.tokenExpires !== 'number' ||
33
- (token.authType !== 'anonymous' && token.authType !== 'credentials')) {
34
- throw new Error('APICORE_AUTH_TOKEN must include accessToken, refreshToken, tokenExpires and authType');
35
- }
36
- return token;
37
- }
38
18
  export function resolveStdioAuthConfig(env = process.env) {
39
- const rawToken = env.APICORE_AUTH_TOKEN?.trim();
40
- if (rawToken) {
41
- return { mode: 'token', token: parseAuthToken(rawToken) };
42
- }
43
19
  const apiKey = env.APICORE_API_KEY?.trim();
44
20
  const username = env.APICORE_USERNAME?.trim();
45
21
  const password = env.APICORE_PASSWORD?.trim();
46
22
  if (!apiKey || !username || !password) {
47
- throw new Error('Missing stdio auth configuration: set APICORE_AUTH_TOKEN or APICORE_API_KEY + APICORE_USERNAME + APICORE_PASSWORD.');
23
+ throw new Error('Missing stdio auth configuration: set APICORE_API_KEY + APICORE_USERNAME + APICORE_PASSWORD.');
48
24
  }
49
- return { mode: 'credentials', apiKey, username, password };
25
+ return { apiKey, username, password };
50
26
  }
51
27
  async function startHttpServer() {
52
28
  const { default: express } = await import('express');
@@ -104,9 +80,7 @@ async function startHttpServer() {
104
80
  }
105
81
  async function startStdioServer() {
106
82
  const authConfig = resolveStdioAuthConfig();
107
- const server = authConfig.mode === 'token'
108
- ? await createServer(authConfig.token)
109
- : await createServer(authConfig.apiKey, authConfig.username, authConfig.password);
83
+ const server = await createServer(authConfig.apiKey, authConfig.username, authConfig.password);
110
84
  const transport = new StdioServerTransport();
111
85
  await server.connect(transport);
112
86
  console.error('MCP stdio server started');
@@ -0,0 +1,24 @@
1
+ import { z } from 'zod';
2
+ export const comprehensiveAnalysisPrompt = {
3
+ name: 'comprehensive-analysis',
4
+ title: 'Comprehensive analysis',
5
+ description: 'Perform an in-depth analysis on a specific topic',
6
+ argsSchema: {
7
+ query: z.string().describe("The topic or query to analyze (e.g. 'climate change', 'French elections')"),
8
+ },
9
+ handler: async ({ query }) => {
10
+ return {
11
+ messages: [{
12
+ role: 'user',
13
+ content: {
14
+ type: 'text',
15
+ text: `Perform an in-depth analysis on "${query}":
16
+ 1. Use afp_search_articles to find recent articles about "${query}" (size: 10).
17
+ 2. Use afp_find_similar on the most relevant article to find related coverage.
18
+ 3. Use afp_get_article to retrieve the full text of the most important articles.
19
+ 4. Synthesize the information from these articles to write a comprehensive analysis covering: key facts, timeline, different angles, and outlook.`,
20
+ },
21
+ }],
22
+ };
23
+ },
24
+ };
@@ -0,0 +1,21 @@
1
+ import { z } from 'zod';
2
+ export const countryNewsPrompt = {
3
+ name: 'country-news',
4
+ title: 'Country News',
5
+ description: 'News summary for a specific country',
6
+ argsSchema: {
7
+ country: z.string().describe("Country code (e.g. 'fra', 'usa', 'gbr')"),
8
+ lang: z.string().optional().describe("Language (e.g. 'en', 'fr'). Default: 'fr'"),
9
+ },
10
+ handler: async ({ country, lang = 'fr' }) => {
11
+ return {
12
+ messages: [{
13
+ role: 'user',
14
+ content: {
15
+ type: 'text',
16
+ text: `Use afp_search_articles to find recent news for country "${country}" (lang: ["${lang}"], country: ["${country}"], size: 15). Write a news summary for this country covering the main stories of the day.`,
17
+ },
18
+ }],
19
+ };
20
+ },
21
+ };
@@ -0,0 +1,22 @@
1
+ import { z } from 'zod';
2
+ export const dailyBriefingPrompt = {
3
+ name: 'daily-briefing',
4
+ title: 'Daily Briefing',
5
+ description: 'Generate a news briefing for today',
6
+ argsSchema: {
7
+ lang: z.string().optional().describe("Language (e.g. 'en', 'fr'). Default: 'fr'"),
8
+ },
9
+ handler: async ({ lang }) => {
10
+ const l = lang || 'fr';
11
+ const today = new Date().toISOString().split('T')[0];
12
+ return {
13
+ messages: [{
14
+ role: 'user',
15
+ content: {
16
+ type: 'text',
17
+ text: `Use the afp_search_articles tool to find today's most important news (dateFrom: "${today}", lang: ["${l}"], size: 15, sortOrder: "desc"). Then write a concise daily briefing summarizing the key stories, grouped by theme.`,
18
+ },
19
+ }],
20
+ };
21
+ },
22
+ };
@@ -0,0 +1,23 @@
1
+ import { z } from 'zod';
2
+ export const factcheckPrompt = {
3
+ name: 'factcheck',
4
+ title: 'Fact Check',
5
+ description: 'Verify facts about a specific topic',
6
+ argsSchema: {
7
+ query: z.string().describe("The topic or query to verify (e.g. 'climate change', 'French elections')"),
8
+ },
9
+ handler: async ({ query }) => {
10
+ return {
11
+ messages: [{
12
+ role: 'user',
13
+ content: {
14
+ type: 'text',
15
+ text: `Factcheck the following query: "${query}":
16
+ 1. Use afp_search_articles to find recent factchecks related to "${query}" (genreid:"afpattribute:FactcheckInvestigation") (size: 10).
17
+ 2. For each relevant factcheck, use afp_get_article to retrieve the full text.
18
+ 3. Summarize the findings, including: what is being claimed, what the factcheck verdict is, and the evidence provided.`,
19
+ },
20
+ }],
21
+ };
22
+ },
23
+ };
@@ -1,81 +1,19 @@
1
- import { z } from 'zod';
1
+ import { dailyBriefingPrompt } from './daily-briefing.js';
2
+ import { comprehensiveAnalysisPrompt } from './comprehensive-analysis.js';
3
+ import { factcheckPrompt } from './factcheck.js';
4
+ import { countryNewsPrompt } from './country-news.js';
5
+ export const PROMPT_DEFINITIONS = [
6
+ dailyBriefingPrompt,
7
+ comprehensiveAnalysisPrompt,
8
+ factcheckPrompt,
9
+ countryNewsPrompt,
10
+ ];
2
11
  export function registerPrompts({ server }) {
3
- server.registerPrompt("daily-briefing", {
4
- title: "Daily Briefing",
5
- description: "Generate a news briefing for today",
6
- argsSchema: {
7
- lang: z.string().optional().describe("Language (e.g. 'en', 'fr'). Default: 'fr'")
8
- }
9
- }, async ({ lang }) => {
10
- const l = lang || 'fr';
11
- const today = new Date().toISOString().split('T')[0];
12
- return {
13
- messages: [{
14
- role: 'user',
15
- content: {
16
- type: 'text',
17
- text: `Use the afp_search_articles tool to find today's most important news (dateFrom: "${today}", lang: ["${l}"], size: 15, sortOrder: "desc"). Then write a concise daily briefing summarizing the key stories, grouped by theme.`
18
- }
19
- }]
20
- };
21
- });
22
- server.registerPrompt("comprehensive-analysis", {
23
- title: "Comprehensive analysis",
24
- description: "Perform an in-depth analysis on a specific topic",
25
- argsSchema: {
26
- query: z.string().describe("The topic or query to analyze (e.g. 'climate change', 'French elections')")
27
- }
28
- }, async ({ query }) => {
29
- return {
30
- messages: [{
31
- role: 'user',
32
- content: {
33
- type: 'text',
34
- text: `Perform an in-depth analysis on "${query}":
35
- 1. Use afp_search_articles to find recent articles about "${query}" (size: 10).
36
- 2. Use afp_find_similar on the most relevant article to find related coverage.
37
- 3. Use afp_get_article to retrieve the full text of the most important articles.
38
- 4. Synthesize the information from these articles to write a comprehensive analysis covering: key facts, timeline, different angles, and outlook.`
39
- }
40
- }]
41
- };
42
- });
43
- server.registerPrompt("factcheck", {
44
- title: "Fact Check",
45
- description: "Verify facts about a specific topic",
46
- argsSchema: {
47
- query: z.string().describe("The topic or query to verify (e.g. 'climate change', 'French elections')")
48
- }
49
- }, async ({ query }) => {
50
- return {
51
- messages: [{
52
- role: 'user',
53
- content: {
54
- type: 'text',
55
- text: `Factcheck the following query: "${query}":
56
- 1. Use afp_search_articles to find recent factchecks related to "${query}" (genreid:"afpattribute:FactcheckInvestigation") (size: 10).
57
- 2. For each relevant factcheck, use afp_get_article to retrieve the full text.
58
- 3. Summarize the findings, including: what is being claimed, what the factcheck verdict is, and the evidence provided.`
59
- }
60
- }]
61
- };
62
- });
63
- server.registerPrompt("country-news", {
64
- title: "Country News",
65
- description: "News summary for a specific country",
66
- argsSchema: {
67
- country: z.string().describe("Country code (e.g. 'fra', 'usa', 'gbr')"),
68
- lang: z.string().optional().describe("Language (e.g. 'en', 'fr'). Default: 'fr'")
69
- }
70
- }, async ({ country, lang = 'fr' }) => {
71
- return {
72
- messages: [{
73
- role: 'user',
74
- content: {
75
- type: 'text',
76
- text: `Use afp_search_articles to find recent news for country "${country}" (lang: ["${lang}"], country: ["${country}"], size: 15). Write a news summary for this country covering the main stories of the day.`
77
- }
78
- }]
79
- };
80
- });
12
+ for (const prompt of PROMPT_DEFINITIONS) {
13
+ server.registerPrompt(prompt.name, {
14
+ title: prompt.title,
15
+ description: prompt.description,
16
+ argsSchema: prompt.argsSchema,
17
+ }, prompt.handler);
18
+ }
81
19
  }
@@ -1,14 +1,10 @@
1
- import { TOPICS } from '../utils/topics.js';
1
+ import { topicsResource } from './topics.js';
2
+ export const RESOURCE_DEFINITIONS = [topicsResource];
2
3
  export function registerResources({ server }) {
3
- server.registerResource("topics", "afp://topics", {
4
- description: "AFP Stories topic catalog — available sections by language (fr, en, de, pt, es, ar) with their identifiers",
5
- mimeType: "application/json"
6
- }, async () => {
7
- return {
8
- contents: [{
9
- uri: "afp://topics",
10
- text: JSON.stringify(TOPICS, null, 2)
11
- }]
12
- };
13
- });
4
+ for (const resource of RESOURCE_DEFINITIONS) {
5
+ server.registerResource(resource.name, resource.uri, {
6
+ description: resource.description,
7
+ mimeType: resource.mimeType,
8
+ }, resource.handler);
9
+ }
14
10
  }
@@ -0,0 +1,15 @@
1
+ import { TOPICS } from '../utils/topics.js';
2
+ export const topicsResource = {
3
+ name: 'topics',
4
+ uri: 'afp://topics',
5
+ description: 'AFP Stories topic catalog — available sections by language (fr, en, de, pt, es, ar) with their identifiers',
6
+ mimeType: 'application/json',
7
+ handler: async () => {
8
+ return {
9
+ contents: [{
10
+ uri: 'afp://topics',
11
+ text: JSON.stringify(TOPICS, null, 2),
12
+ }],
13
+ };
14
+ },
15
+ };
package/build/server.js CHANGED
@@ -3,29 +3,12 @@ import { ApiCore } from "afpnews-api";
3
3
  import { registerTools } from "./tools/index.js";
4
4
  import { registerResources } from "./resources/index.js";
5
5
  import { registerPrompts } from "./prompts/index.js";
6
- function isAuthToken(value) {
7
- if (!value || typeof value !== "object") {
8
- return false;
9
- }
10
- const token = value;
11
- return (typeof token.accessToken === "string" &&
12
- typeof token.refreshToken === "string" &&
13
- typeof token.tokenExpires === "number" &&
14
- (token.authType === "anonymous" || token.authType === "credentials"));
15
- }
16
- export async function createServer(apiKeyOrToken, username, password) {
17
- let apicore;
18
- if (isAuthToken(apiKeyOrToken)) {
19
- apicore = new ApiCore();
20
- apicore.token = apiKeyOrToken;
21
- }
22
- else {
23
- if (!apiKeyOrToken || !username || !password) {
24
- throw new Error("Missing authentication configuration. Provide either an AuthToken or APICORE_API_KEY, APICORE_USERNAME and APICORE_PASSWORD.");
25
- }
26
- apicore = new ApiCore({ apiKey: apiKeyOrToken });
27
- await apicore.authenticate({ username, password });
6
+ export async function createServer(apiKey, username, password) {
7
+ if (!apiKey || !username || !password) {
8
+ throw new Error("Missing authentication configuration. Provide APICORE_API_KEY, APICORE_USERNAME and APICORE_PASSWORD.");
28
9
  }
10
+ const apicore = new ApiCore({ apiKey });
11
+ await apicore.authenticate({ username, password });
29
12
  const server = new McpServer({
30
13
  name: "afpnews",
31
14
  version: "1.2.0",
@@ -0,0 +1,44 @@
1
+ import { z } from 'zod';
2
+ import { textContent, toolError, truncateIfNeeded } from '../utils/format.js';
3
+ import { formatDocuments, formatErrorMessage, langEnum, } from './shared.js';
4
+ export const afpFindSimilarTool = {
5
+ name: 'afp_find_similar',
6
+ title: 'Find Similar AFP Articles',
7
+ description: `Find AFP news articles similar to a given article (More Like This). Useful for exploring related coverage or finding follow-up stories.
8
+
9
+ Args:
10
+ - uno: The UNO of the reference article to find similar content for
11
+ - lang: Language for results (e.g. 'en', 'fr')
12
+ - size: Number of similar articles to return (default 10)
13
+
14
+ Returns:
15
+ Pagination summary, followed by markdown-formatted article excerpts:
16
+ - ## Headline
17
+ - *UNO | Published date | Lang | Genre*
18
+ - Article excerpt (first paragraphs)
19
+
20
+ Examples:
21
+ - Find similar articles in French: { uno: "NEWS-FR-123456-ABC", lang: "fr" }
22
+ - Get 5 similar English articles: { uno: "NEWS-EN-789", lang: "en", size: 5 }`,
23
+ inputSchema: {
24
+ uno: z.string().describe('The UNO of the reference article'),
25
+ lang: langEnum.describe("Language for results (e.g. 'en', 'fr')"),
26
+ size: z.number().optional().describe('Number of similar articles to return (default 10)'),
27
+ },
28
+ handler: async (apicore, { uno, lang, size }) => {
29
+ try {
30
+ const { documents, count } = await apicore.mlt(uno, lang, size);
31
+ if (count === 0) {
32
+ return { content: [textContent('No similar articles found.')] };
33
+ }
34
+ const content = [
35
+ textContent(`*Found ${count} similar articles.*`),
36
+ ...formatDocuments(documents, false),
37
+ ];
38
+ return { content: truncateIfNeeded(content) };
39
+ }
40
+ catch (error) {
41
+ return toolError(formatErrorMessage(`finding similar articles for "${uno}"`, error, 'Verify the UNO identifier is correct.'));
42
+ }
43
+ },
44
+ };
@@ -0,0 +1,32 @@
1
+ import { z } from 'zod';
2
+ import { formatDocument, toolError } from '../utils/format.js';
3
+ import { formatErrorMessage } from './shared.js';
4
+ export const afpGetArticleTool = {
5
+ name: 'afp_get_article',
6
+ title: 'Get AFP Article',
7
+ description: `Get a full AFP news article by its UNO identifier. Use this after searching to retrieve the complete text of a specific article.
8
+
9
+ Args:
10
+ - uno: The unique identifier (UNO) of the article (e.g. 'NEWS-FR-123456-ABC')
11
+
12
+ Returns:
13
+ Markdown-formatted full article:
14
+ - ## Headline
15
+ - *UNO | Published date | Lang | Genre*
16
+ - Complete article body
17
+
18
+ Examples:
19
+ - Get a specific article: { uno: "NEWS-FR-123456-ABC" }`,
20
+ inputSchema: {
21
+ uno: z.string().describe('The unique identifier (UNO) of the article'),
22
+ },
23
+ handler: async (apicore, { uno }) => {
24
+ try {
25
+ const doc = await apicore.get(uno);
26
+ return { content: [formatDocument(doc, true)] };
27
+ }
28
+ catch (error) {
29
+ return toolError(formatErrorMessage(`fetching article "${uno}"`, error, 'Verify the UNO identifier is correct.'));
30
+ }
31
+ },
32
+ };
@@ -1,237 +1,21 @@
1
- import { z } from 'zod';
2
- import { formatDocument, DEFAULT_FIELDS, GENRE_EXCLUSIONS, textContent, toolError, truncateIfNeeded, buildPaginationLine } from '../utils/format.js';
3
- import { getTopicLabel } from '../utils/topics.js';
4
- import { DEFAULT_SEARCH_SIZE, DEFAULT_FACET_SIZE } from '../utils/types.js';
5
- const SEARCH_PRESET_VALUES = ['a-la-une', 'agenda', 'previsions', 'major-stories'];
6
- const searchPresetEnum = z.enum(SEARCH_PRESET_VALUES);
7
- const LIST_PRESET_VALUES = ['trending-topics'];
8
- const listPresetEnum = z.enum(LIST_PRESET_VALUES);
9
- const langEnum = z.enum(['en', 'fr', 'de', 'pt', 'es', 'ar']);
10
- const READ_ONLY_ANNOTATIONS = {
11
- readOnlyHint: true,
12
- destructiveHint: false,
13
- idempotentHint: true,
14
- openWorldHint: true,
15
- };
16
- const SEARCH_PRESETS = {
17
- 'a-la-une': {
18
- product: ['news'],
19
- lang: ['fr'],
20
- slug: ['afp', 'actualites'],
21
- dateFrom: 'now-1d',
22
- size: 1,
23
- genreid: GENRE_EXCLUSIONS,
24
- },
25
- 'agenda': {
26
- product: ['news'],
27
- size: 5,
28
- genreid: ['afpattribute:Agenda'],
29
- },
30
- 'previsions': {
31
- product: ['news'],
32
- size: 5,
33
- genreid: ['afpattribute:Program', 'afpedtype:TextProgram'],
34
- },
35
- 'major-stories': {
36
- product: ['news'],
37
- genreid: ['afpattribute:Article'],
38
- },
39
- };
40
- function formatErrorMessage(context, error, hint) {
41
- const message = error instanceof Error ? error.message : String(error);
42
- return `Error ${context}: ${message}. ${hint}`;
43
- }
44
- function formatDocuments(documents, fullText) {
45
- return documents.map((doc) => formatDocument(doc, fullText));
46
- }
47
- export function registerTools({ server, apicore }) {
48
- server.registerTool("afp_search_articles", {
49
- title: "Search AFP News Articles",
50
- description: `Search AFP news articles with filters and presets. This is the primary query tool for all AFP news search use cases.
51
-
52
- Args:
53
- - preset: Optional predefined filter set (a-la-une, agenda, previsions, major-stories)
54
- - fullText: Return full article body (true) or excerpt only (false, default). Presets override to true.
55
- - query: Search keywords in the language specified by 'lang' (e.g. 'climate change')
56
- - lang: Article language filter (e.g. ['en', 'fr']). Use ['en'] for photos.
57
- - dateFrom/dateTo: Date range in ISO format (e.g. '2025-01-01') or relative ('now-1d')
58
- - size: Number of results (default 10, max 1000)
59
- - sortOrder: 'asc' or 'desc' by date (default 'desc')
60
- - offset: Pagination offset (number of results to skip)
61
- - country: Country code filter (e.g. ['fra', 'usa'])
62
- - slug: Topic/slug filter (e.g. ['economy', 'sports'])
63
- - product: Content type filter (default ['news', 'factcheck'])
64
-
65
- Returns:
66
- Pagination summary line, followed by markdown-formatted articles:
67
- - ## Headline
68
- - *UNO | Published date | Lang | Genre*
69
- - Article body (excerpt or full text)
70
-
71
- Examples:
72
- - Latest Ukraine news: { query: "Ukraine", lang: ["en"], size: 5 }
73
- - French front page: { preset: "a-la-une" }
74
- - Recent photos: { product: ["photo"], lang: ["en"], size: 5 }
75
- - Page 2 of results: { query: "economy", size: 10, offset: 10 }`,
76
- inputSchema: {
77
- preset: searchPresetEnum.optional().describe("Optional preset that applies predefined AFP filters. Available presets: a-la-une, agenda, previsions, major-stories."),
78
- fullText: z.boolean().optional().describe("When true, returns the full article body. Default is false. If omitted and a preset is used, fullText defaults to true."),
79
- query: z.string().optional().describe("List of keywords to search for in the news articles (e.g. 'climate change'), in the language specified by the 'lang' parameter. If not specified, the search will be performed in all languages. Do not use keywords in multiple languages."),
80
- lang: langEnum.array().optional().describe("Language of the news articles (e.g. 'en', 'fr'). Always use 'en' if you look for photos."),
81
- dateFrom: z.string().optional().describe("Start date for the search in ISO format (e.g. '2023-01-01') or relative (e.g. 'now-1d')"),
82
- dateTo: z.string().optional().describe("End date for the search in ISO format (e.g. '2023-12-31')"),
83
- size: z.number().optional().describe("Number of results to return (default 10, max 1000)"),
84
- sortOrder: z.enum(['asc', 'desc']).optional().describe("Sort order by date (default 'desc')"),
85
- offset: z.number().optional().describe("Offset for pagination (number of results to skip)"),
86
- country: z.string().array().optional().describe("Country filter (e.g. 'fra', 'usa')"),
87
- slug: z.string().array().optional().describe("Topic/slug filter (e.g. 'economy', 'sports')"),
88
- product: z.enum(['news', 'factcheck', 'photo', 'video', 'multimedia', 'graphic', 'videographic']).array().optional().describe("Content type filter (default ['news', 'factcheck'])")
89
- },
90
- annotations: READ_ONLY_ANNOTATIONS,
91
- }, async ({ preset, fullText = false, query, lang, dateFrom, dateTo, size = DEFAULT_SEARCH_SIZE, sortOrder = 'desc', offset, country, slug, product = ['news', 'factcheck'] }) => {
92
- try {
93
- let request = {
94
- query, lang, product, dateFrom, dateTo, size, sortOrder,
95
- startAt: offset, country, slug, genreid: GENRE_EXCLUSIONS,
96
- };
97
- if (preset) {
98
- request = { ...request, ...SEARCH_PRESETS[preset] };
99
- fullText = true;
100
- }
101
- const { documents, count } = await apicore.search(request, [...DEFAULT_FIELDS]);
102
- if (count === 0) {
103
- return { content: [textContent('No results found.')] };
104
- }
105
- const currentOffset = offset ?? 0;
106
- const content = [
107
- textContent(buildPaginationLine(documents.length, count, currentOffset)),
108
- ...formatDocuments(documents, fullText),
109
- ];
110
- return { content: truncateIfNeeded(content) };
111
- }
112
- catch (error) {
113
- return toolError(formatErrorMessage('searching AFP articles', error, 'Check your query parameters and try again.'));
114
- }
115
- });
116
- server.registerTool("afp_get_article", {
117
- title: "Get AFP Article",
118
- description: `Get a full AFP news article by its UNO identifier. Use this after searching to retrieve the complete text of a specific article.
119
-
120
- Args:
121
- - uno: The unique identifier (UNO) of the article (e.g. 'NEWS-FR-123456-ABC')
122
-
123
- Returns:
124
- Markdown-formatted full article:
125
- - ## Headline
126
- - *UNO | Published date | Lang | Genre*
127
- - Complete article body
128
-
129
- Examples:
130
- - Get a specific article: { uno: "NEWS-FR-123456-ABC" }`,
131
- inputSchema: {
132
- uno: z.string().describe("The unique identifier (UNO) of the article"),
133
- },
134
- annotations: READ_ONLY_ANNOTATIONS,
135
- }, async ({ uno }) => {
136
- try {
137
- const doc = await apicore.get(uno);
138
- return { content: [formatDocument(doc, true)] };
139
- }
140
- catch (error) {
141
- return toolError(formatErrorMessage(`fetching article "${uno}"`, error, 'Verify the UNO identifier is correct.'));
142
- }
143
- });
144
- server.registerTool("afp_find_similar", {
145
- title: "Find Similar AFP Articles",
146
- description: `Find AFP news articles similar to a given article (More Like This). Useful for exploring related coverage or finding follow-up stories.
147
-
148
- Args:
149
- - uno: The UNO of the reference article to find similar content for
150
- - lang: Language for results (e.g. 'en', 'fr')
151
- - size: Number of similar articles to return (default 10)
152
-
153
- Returns:
154
- Pagination summary, followed by markdown-formatted article excerpts:
155
- - ## Headline
156
- - *UNO | Published date | Lang | Genre*
157
- - Article excerpt (first paragraphs)
158
-
159
- Examples:
160
- - Find similar articles in French: { uno: "NEWS-FR-123456-ABC", lang: "fr" }
161
- - Get 5 similar English articles: { uno: "NEWS-EN-789", lang: "en", size: 5 }`,
162
- inputSchema: {
163
- uno: z.string().describe("The UNO of the reference article"),
164
- lang: langEnum.describe("Language for results (e.g. 'en', 'fr')"),
165
- size: z.number().optional().describe("Number of similar articles to return (default 10)"),
166
- },
167
- annotations: READ_ONLY_ANNOTATIONS,
168
- }, async ({ uno, lang, size }) => {
169
- try {
170
- const { documents, count } = await apicore.mlt(uno, lang, size);
171
- if (count === 0) {
172
- return { content: [textContent('No similar articles found.')] };
173
- }
174
- const content = [
175
- textContent(`*Found ${count} similar articles.*`),
176
- ...formatDocuments(documents, false),
177
- ];
178
- return { content: truncateIfNeeded(content) };
179
- }
180
- catch (error) {
181
- return toolError(formatErrorMessage(`finding similar articles for "${uno}"`, error, 'Verify the UNO identifier is correct.'));
182
- }
183
- });
184
- server.registerTool("afp_list_facets", {
185
- title: "List AFP Facet Values",
186
- description: `List facet values and their article counts. Use this to discover available topics, genres, or countries, or to get trending topics.
187
-
188
- Args:
189
- - preset: Optional preset (trending-topics) — overrides facet to 'slug' with last 24h news
190
- - facet: Facet to list (e.g. 'slug', 'genre', 'country'). Required when no preset is used.
191
- - lang: Language filter (e.g. 'en', 'fr')
192
- - size: Number of facet values to return
193
-
194
- Returns:
195
- Markdown-formatted list of facet values with article counts:
196
- - **Label or key** — N articles
197
-
198
- Examples:
199
- - Trending topics in French: { preset: "trending-topics" }
200
- - Trending topics in English: { preset: "trending-topics", lang: "en" }
201
- - List available genres: { facet: "genre" }
202
- - List countries: { facet: "country", size: 30 }`,
203
- inputSchema: {
204
- preset: listPresetEnum.optional().describe("Optional preset for list queries. Available preset: trending-topics."),
205
- facet: z.string().optional().describe("Facet to list (e.g. 'slug', 'genre', 'country'). Required when no preset is used."),
206
- lang: langEnum.optional().describe("Language filter (e.g. 'en', 'fr')"),
207
- size: z.number().optional().describe("Number of facet values to return"),
208
- },
209
- annotations: READ_ONLY_ANNOTATIONS,
210
- }, async ({ preset, facet, lang, size }) => {
211
- try {
212
- const isTrendingTopics = preset === 'trending-topics';
213
- const resolvedFacet = isTrendingTopics ? 'slug' : facet;
214
- if (!resolvedFacet) {
215
- return toolError("Missing required parameter: facet (e.g. 'slug', 'genre', 'country'). Alternatively, use preset: 'trending-topics'.");
216
- }
217
- const params = isTrendingTopics
218
- ? { langs: [lang ?? 'fr'], product: ['news'], dateFrom: 'now-1d' }
219
- : (lang ? { langs: [lang] } : {});
220
- const resolvedSize = isTrendingTopics ? (size ?? DEFAULT_FACET_SIZE) : size;
221
- const rawResult = await apicore.list(resolvedFacet, params, resolvedSize);
222
- const results = rawResult?.keywords ?? rawResult ?? [];
223
- if (results.length === 0) {
224
- return { content: [textContent(`No facet values found for "${resolvedFacet}".`)] };
225
- }
226
- const heading = isTrendingTopics ? 'Trending Topics' : `Facet: ${resolvedFacet}`;
227
- const lines = results.map((item) => {
228
- const label = isTrendingTopics ? (getTopicLabel(item.key) ?? item.key) : item.key;
229
- return `- **${label}** — ${item.count} articles`;
230
- });
231
- return { content: [textContent(`## ${heading}\n\n${lines.join('\n')}`)] };
232
- }
233
- catch (error) {
234
- return toolError(formatErrorMessage('listing facet values', error, "Check that the facet name is valid (e.g. 'slug', 'genre', 'country')."));
235
- }
236
- });
1
+ import { afpSearchArticlesTool } from './search-articles.js';
2
+ import { afpGetArticleTool } from './get-article.js';
3
+ import { afpFindSimilarTool } from './find-similar.js';
4
+ import { afpListFacetsTool } from './list-facets.js';
5
+ import { READ_ONLY_ANNOTATIONS } from './shared.js';
6
+ export const TOOL_DEFINITIONS = [
7
+ afpSearchArticlesTool,
8
+ afpGetArticleTool,
9
+ afpFindSimilarTool,
10
+ afpListFacetsTool,
11
+ ];
12
+ export function registerTools(ctx) {
13
+ for (const tool of TOOL_DEFINITIONS) {
14
+ ctx.server.registerTool(tool.name, {
15
+ title: tool.title,
16
+ description: tool.description,
17
+ inputSchema: tool.inputSchema,
18
+ annotations: READ_ONLY_ANNOTATIONS,
19
+ }, async (args) => tool.handler(ctx.apicore, args));
20
+ }
237
21
  }
@@ -0,0 +1,59 @@
1
+ import { z } from 'zod';
2
+ import { textContent, toolError } from '../utils/format.js';
3
+ import { getTopicLabel } from '../utils/topics.js';
4
+ import { DEFAULT_FACET_SIZE } from '../utils/types.js';
5
+ import { formatErrorMessage, langEnum, listPresetEnum, } from './shared.js';
6
+ export const afpListFacetsTool = {
7
+ name: 'afp_list_facets',
8
+ title: 'List AFP Facet Values',
9
+ description: `List facet values and their article counts. Use this to discover available topics, genres, or countries, or to get trending topics.
10
+
11
+ Args:
12
+ - preset: Optional preset (trending-topics) — overrides facet to 'slug' with last 24h news
13
+ - facet: Facet to list (e.g. 'slug', 'genre', 'country'). Required when no preset is used.
14
+ - lang: Language filter (e.g. 'en', 'fr')
15
+ - size: Number of facet values to return
16
+
17
+ Returns:
18
+ Markdown-formatted list of facet values with article counts:
19
+ - **Label or key** — N articles
20
+
21
+ Examples:
22
+ - Trending topics in French: { preset: "trending-topics" }
23
+ - Trending topics in English: { preset: "trending-topics", lang: "en" }
24
+ - List available genres: { facet: "genre" }
25
+ - List countries: { facet: "country", size: 30 }`,
26
+ inputSchema: {
27
+ preset: listPresetEnum.optional().describe('Optional preset for list queries. Available preset: trending-topics.'),
28
+ facet: z.string().optional().describe("Facet to list (e.g. 'slug', 'genre', 'country'). Required when no preset is used."),
29
+ lang: langEnum.optional().describe("Language filter (e.g. 'en', 'fr')"),
30
+ size: z.number().optional().describe('Number of facet values to return'),
31
+ },
32
+ handler: async (apicore, { preset, facet, lang, size }) => {
33
+ try {
34
+ const isTrendingTopics = preset === 'trending-topics';
35
+ const resolvedFacet = isTrendingTopics ? 'slug' : facet;
36
+ if (!resolvedFacet) {
37
+ return toolError("Missing required parameter: facet (e.g. 'slug', 'genre', 'country'). Alternatively, use preset: 'trending-topics'.");
38
+ }
39
+ const params = isTrendingTopics
40
+ ? { langs: [lang ?? 'fr'], product: ['news'], dateFrom: 'now-1d' }
41
+ : (lang ? { langs: [lang] } : {});
42
+ const resolvedSize = isTrendingTopics ? (size ?? DEFAULT_FACET_SIZE) : size;
43
+ const rawResult = await apicore.list(resolvedFacet, params, resolvedSize);
44
+ const results = rawResult?.keywords ?? rawResult ?? [];
45
+ if (results.length === 0) {
46
+ return { content: [textContent(`No facet values found for "${resolvedFacet}".`)] };
47
+ }
48
+ const heading = isTrendingTopics ? 'Trending Topics' : `Facet: ${resolvedFacet}`;
49
+ const lines = results.map((item) => {
50
+ const label = isTrendingTopics ? (getTopicLabel(item.key) ?? item.key) : item.key;
51
+ return `- **${label}** — ${item.count} articles`;
52
+ });
53
+ return { content: [textContent(`## ${heading}\n\n${lines.join('\n')}`)] };
54
+ }
55
+ catch (error) {
56
+ return toolError(formatErrorMessage('listing facet values', error, "Check that the facet name is valid (e.g. 'slug', 'genre', 'country')."));
57
+ }
58
+ },
59
+ };
@@ -0,0 +1,82 @@
1
+ import { z } from 'zod';
2
+ import { DEFAULT_FIELDS, GENRE_EXCLUSIONS, textContent, toolError, truncateIfNeeded, buildPaginationLine, } from '../utils/format.js';
3
+ import { DEFAULT_SEARCH_SIZE } from '../utils/types.js';
4
+ import { SEARCH_PRESETS, formatErrorMessage, formatDocuments, langEnum, searchPresetEnum, } from './shared.js';
5
+ export const afpSearchArticlesTool = {
6
+ name: 'afp_search_articles',
7
+ title: 'Search AFP News Articles',
8
+ description: `Search AFP news articles with filters and presets. This is the primary query tool for all AFP news search use cases.
9
+
10
+ Args:
11
+ - preset: Optional predefined filter set (a-la-une, agenda, previsions, major-stories)
12
+ - fullText: Return full article body (true) or excerpt only (false, default). Presets override to true.
13
+ - query: Search keywords in the language specified by 'lang' (e.g. 'climate change')
14
+ - lang: Article language filter (e.g. ['en', 'fr']). Use ['en'] for photos.
15
+ - dateFrom/dateTo: Date range in ISO format (e.g. '2025-01-01') or relative ('now-1d')
16
+ - size: Number of results (default 10, max 1000)
17
+ - sortOrder: 'asc' or 'desc' by date (default 'desc')
18
+ - offset: Pagination offset (number of results to skip)
19
+ - country: Country code filter (e.g. ['fra', 'usa'])
20
+ - slug: Topic/slug filter (e.g. ['economy', 'sports'])
21
+ - product: Content type filter (default ['news', 'factcheck'])
22
+
23
+ Returns:
24
+ Pagination summary line, followed by markdown-formatted articles:
25
+ - ## Headline
26
+ - *UNO | Published date | Lang | Genre*
27
+ - Article body (excerpt or full text)
28
+
29
+ Examples:
30
+ - Latest Ukraine news: { query: "Ukraine", lang: ["en"], size: 5 }
31
+ - French front page: { preset: "a-la-une" }
32
+ - Recent photos: { product: ["photo"], lang: ["en"], size: 5 }
33
+ - Page 2 of results: { query: "economy", size: 10, offset: 10 }`,
34
+ inputSchema: {
35
+ preset: searchPresetEnum.optional().describe('Optional preset that applies predefined AFP filters. Available presets: a-la-une, agenda, previsions, major-stories.'),
36
+ fullText: z.boolean().optional().describe('When true, returns the full article body. Default is false. If omitted and a preset is used, fullText defaults to true.'),
37
+ query: z.string().optional().describe("List of keywords to search for in the news articles (e.g. 'climate change'), in the language specified by the 'lang' parameter. If not specified, the search will be performed in all languages. Do not use keywords in multiple languages."),
38
+ lang: langEnum.array().optional().describe("Language of the news articles (e.g. 'en', 'fr'). Always use 'en' if you look for photos."),
39
+ dateFrom: z.string().optional().describe("Start date for the search in ISO format (e.g. '2023-01-01') or relative (e.g. 'now-1d')"),
40
+ dateTo: z.string().optional().describe("End date for the search in ISO format (e.g. '2023-12-31')"),
41
+ size: z.number().optional().describe('Number of results to return (default 10, max 1000)'),
42
+ sortOrder: z.enum(['asc', 'desc']).optional().describe("Sort order by date (default 'desc')"),
43
+ offset: z.number().optional().describe('Offset for pagination (number of results to skip)'),
44
+ country: z.string().array().optional().describe("Country filter (e.g. 'fra', 'usa')"),
45
+ slug: z.string().array().optional().describe("Topic/slug filter (e.g. 'economy', 'sports')"),
46
+ product: z.enum(['news', 'factcheck', 'photo', 'video', 'multimedia', 'graphic', 'videographic']).array().optional().describe("Content type filter (default ['news', 'factcheck'])"),
47
+ },
48
+ handler: async (apicore, { preset, fullText = false, query, lang, dateFrom, dateTo, size = DEFAULT_SEARCH_SIZE, sortOrder = 'desc', offset, country, slug, product = ['news', 'factcheck'] }) => {
49
+ try {
50
+ let request = {
51
+ query,
52
+ lang,
53
+ product,
54
+ dateFrom,
55
+ dateTo,
56
+ size,
57
+ sortOrder,
58
+ startAt: offset,
59
+ country,
60
+ slug,
61
+ genreid: GENRE_EXCLUSIONS,
62
+ };
63
+ if (preset) {
64
+ request = { ...request, ...SEARCH_PRESETS[preset] };
65
+ fullText = true;
66
+ }
67
+ const { documents, count } = await apicore.search(request, [...DEFAULT_FIELDS]);
68
+ if (count === 0) {
69
+ return { content: [textContent('No results found.')] };
70
+ }
71
+ const currentOffset = offset ?? 0;
72
+ const content = [
73
+ textContent(buildPaginationLine(documents.length, count, currentOffset)),
74
+ ...formatDocuments(documents, fullText),
75
+ ];
76
+ return { content: truncateIfNeeded(content) };
77
+ }
78
+ catch (error) {
79
+ return toolError(formatErrorMessage('searching AFP articles', error, 'Check your query parameters and try again.'));
80
+ }
81
+ },
82
+ };
@@ -0,0 +1,44 @@
1
+ import { z } from 'zod';
2
+ import { formatDocument, GENRE_EXCLUSIONS, } from '../utils/format.js';
3
+ const SEARCH_PRESET_VALUES = ['a-la-une', 'agenda', 'previsions', 'major-stories'];
4
+ export const searchPresetEnum = z.enum(SEARCH_PRESET_VALUES);
5
+ const LIST_PRESET_VALUES = ['trending-topics'];
6
+ export const listPresetEnum = z.enum(LIST_PRESET_VALUES);
7
+ export const langEnum = z.enum(['en', 'fr', 'de', 'pt', 'es', 'ar']);
8
+ export const READ_ONLY_ANNOTATIONS = {
9
+ readOnlyHint: true,
10
+ destructiveHint: false,
11
+ idempotentHint: true,
12
+ openWorldHint: true,
13
+ };
14
+ export const SEARCH_PRESETS = {
15
+ 'a-la-une': {
16
+ product: ['news'],
17
+ lang: ['fr'],
18
+ slug: ['afp', 'actualites'],
19
+ dateFrom: 'now-1d',
20
+ size: 1,
21
+ genreid: GENRE_EXCLUSIONS,
22
+ },
23
+ 'agenda': {
24
+ product: ['news'],
25
+ size: 5,
26
+ genreid: ['afpattribute:Agenda'],
27
+ },
28
+ 'previsions': {
29
+ product: ['news'],
30
+ size: 5,
31
+ genreid: ['afpattribute:Program', 'afpedtype:TextProgram'],
32
+ },
33
+ 'major-stories': {
34
+ product: ['news'],
35
+ genreid: ['afpattribute:Article'],
36
+ },
37
+ };
38
+ export function formatErrorMessage(context, error, hint) {
39
+ const message = error instanceof Error ? error.message : String(error);
40
+ return `Error ${context}: ${message}. ${hint}`;
41
+ }
42
+ export function formatDocuments(documents, fullText) {
43
+ return documents.map((doc) => formatDocument(doc, fullText));
44
+ }
package/package.json CHANGED
@@ -1,9 +1,13 @@
1
1
  {
2
2
  "name": "afpnews-mcp-server",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "",
5
5
  "main": "build/index.js",
6
6
  "type": "module",
7
+ "exports": {
8
+ ".": "./build/index.js",
9
+ "./definitions": "./build/definitions.js"
10
+ },
7
11
  "scripts": {
8
12
  "build": "tsc && chmod 755 build/index.js",
9
13
  "start": "node build/index.js",
@@ -32,4 +36,4 @@
32
36
  "typescript": "^5.9.3",
33
37
  "vitest": "^4.0.18"
34
38
  }
35
- }
39
+ }