afpnews-mcp-server 1.2.0 → 1.3.1
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 +51 -30
- package/build/definitions.js +9 -0
- package/build/index.js +3 -29
- package/build/prompts/comprehensive-analysis.js +24 -0
- package/build/prompts/country-news.js +21 -0
- package/build/prompts/daily-briefing.js +22 -0
- package/build/prompts/factcheck.js +23 -0
- package/build/prompts/index.js +17 -79
- package/build/resources/index.js +8 -12
- package/build/resources/topics.js +15 -0
- package/build/server.js +5 -22
- package/build/tools/find-similar.js +44 -0
- package/build/tools/get-article.js +32 -0
- package/build/tools/index.js +20 -236
- package/build/tools/list-facets.js +59 -0
- package/build/tools/search-articles.js +82 -0
- package/build/tools/shared.js +44 -0
- package/package.json +6 -2
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
|
|
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
|
-
| `
|
|
92
|
-
| `
|
|
93
|
-
| `
|
|
94
|
-
| `
|
|
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 `
|
|
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, `
|
|
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,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
|
|
23
|
+
throw new Error('Missing stdio auth configuration: set APICORE_API_KEY + APICORE_USERNAME + APICORE_PASSWORD.');
|
|
48
24
|
}
|
|
49
|
-
return {
|
|
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.
|
|
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
|
+
};
|
package/build/prompts/index.js
CHANGED
|
@@ -1,81 +1,19 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
}
|
|
9
|
-
}
|
|
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
|
}
|
package/build/resources/index.js
CHANGED
|
@@ -1,14 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { topicsResource } from './topics.js';
|
|
2
|
+
export const RESOURCE_DEFINITIONS = [topicsResource];
|
|
2
3
|
export function registerResources({ server }) {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
|
7
|
-
if (!
|
|
8
|
-
|
|
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
|
+
};
|
package/build/tools/index.js
CHANGED
|
@@ -1,237 +1,21 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
const
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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.
|
|
3
|
+
"version": "1.3.1",
|
|
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
|
+
}
|