afpnews-mcp-server 1.3.10 → 1.3.11

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.
@@ -6,19 +6,17 @@ export const comprehensiveAnalysisPrompt = {
6
6
  argsSchema: {
7
7
  query: z.string().describe("The topic or query to analyze (e.g. 'climate change', 'French elections')"),
8
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}":
9
+ handler: async ({ query }) => ({
10
+ messages: [{
11
+ role: 'user',
12
+ content: {
13
+ type: 'text',
14
+ text: `Perform an in-depth analysis on "${query}":
16
15
  1. Use afp_search_articles to find recent articles about "${query}" (size: 10).
17
16
  2. Use afp_find_similar on the most relevant article to find related coverage.
18
17
  3. Use afp_get_article to retrieve the full text of the most important articles.
19
18
  4. Synthesize the information from these articles to write a comprehensive analysis covering: key facts, timeline, different angles, and outlook.`,
20
- },
21
- }],
22
- };
23
- },
19
+ },
20
+ }],
21
+ }),
24
22
  };
@@ -7,15 +7,13 @@ export const countryNewsPrompt = {
7
7
  country: z.string().describe("Country code (e.g. 'fra', 'usa', 'gbr')"),
8
8
  lang: z.string().optional().describe("Language (e.g. 'en', 'fr'). Default: 'fr'"),
9
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
- },
10
+ handler: async ({ country, lang = 'fr' }) => ({
11
+ messages: [{
12
+ role: 'user',
13
+ content: {
14
+ type: 'text',
15
+ text: `Use afp_search_articles to find recent news for country "${country}" (facets: { lang: ["${lang}"], country: ["${country}"] }, size: 15). Write a news summary for this country covering the main stories of the day.`,
16
+ },
17
+ }],
18
+ }),
21
19
  };
@@ -14,7 +14,7 @@ export const dailyBriefingPrompt = {
14
14
  role: 'user',
15
15
  content: {
16
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.`,
17
+ text: `Use the afp_search_articles tool to find today's most important news (facets: { dateFrom: "${today}", lang: ["${l}"] }, size: 15, sortOrder: "desc"). Then write a concise daily briefing summarizing the key stories, grouped by theme.`,
18
18
  },
19
19
  }],
20
20
  };
@@ -6,18 +6,16 @@ export const factcheckPrompt = {
6
6
  argsSchema: {
7
7
  query: z.string().describe("The topic or query to verify (e.g. 'climate change', 'French elections')"),
8
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).
9
+ handler: async ({ query }) => ({
10
+ messages: [{
11
+ role: 'user',
12
+ content: {
13
+ type: 'text',
14
+ text: `Factcheck the following query: "${query}":
15
+ 1. Use afp_search_articles to find recent factchecks related to "${query}" (facets: { genreid: "afpattribute:FactcheckInvestigation" }, size: 10).
17
16
  2. For each relevant factcheck, use afp_get_article to retrieve the full text.
18
17
  3. Summarize the findings, including: what is being claimed, what the factcheck verdict is, and the evidence provided.`,
19
- },
20
- }],
21
- };
22
- },
18
+ },
19
+ }],
20
+ }),
23
21
  };
package/build/server.js CHANGED
@@ -1,8 +1,10 @@
1
+ import { createRequire } from 'node:module';
1
2
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
3
  import { ApiCore } from "afpnews-api";
3
4
  import { registerTools } from "./tools/index.js";
4
5
  import { registerResources } from "./resources/index.js";
5
6
  import { registerPrompts } from "./prompts/index.js";
7
+ const { version } = createRequire(import.meta.url)('../package.json');
6
8
  export async function createServer({ apiKey, username, password, baseUrl, }) {
7
9
  if (!apiKey || !username || !password) {
8
10
  throw new Error("Missing authentication configuration. Provide APICORE_API_KEY, APICORE_USERNAME and APICORE_PASSWORD.");
@@ -11,7 +13,7 @@ export async function createServer({ apiKey, username, password, baseUrl, }) {
11
13
  await apicore.authenticate({ username, password });
12
14
  const server = new McpServer({
13
15
  name: "afpnews",
14
- version: "1.3.9",
16
+ version,
15
17
  });
16
18
  const ctx = { server, apicore };
17
19
  registerTools(ctx);
@@ -1,6 +1,14 @@
1
1
  import { z } from 'zod';
2
- import { formatDocumentsAsJson, formatDocumentsAsCsv, textContent, toolError, truncateIfNeeded, } from '../utils/format.js';
3
- import { formatDocuments, formatErrorMessage, langEnum, outputFormatEnum, docFieldEnum, DEFAULT_OUTPUT_FIELDS, UNO_FORMAT_NOTE, } from './shared.js';
2
+ import { textContent, toolError, formatDocumentOutput } from '../utils/format.js';
3
+ import { DEFAULT_OUTPUT_FIELDS } from '../utils/types.js';
4
+ import { formatErrorMessage, langEnum, outputFormatEnum, docFieldEnum, UNO_FORMAT_NOTE, } from './shared.js';
5
+ const inputSchema = z.object({
6
+ uno: z.string().describe('The UNO of the reference article'),
7
+ lang: langEnum.describe("Language for results (e.g. 'en', 'fr')"),
8
+ size: z.number().optional().describe('Number of similar articles to return (default 10)'),
9
+ format: outputFormatEnum.optional().describe('Output format: markdown (default, with article excerpt), json (structured, no body), csv (tabular, no body).'),
10
+ fields: docFieldEnum.array().optional().describe('Fields to include in json/csv output. Default: afpshortid, uno, headline, published, lang, genre.'),
11
+ });
4
12
  export const afpFindSimilarTool = {
5
13
  name: 'afp_find_similar',
6
14
  title: 'Find Similar AFP Articles',
@@ -24,31 +32,18 @@ Returns:
24
32
  Examples:
25
33
  - Find similar articles in French: { uno: "newsml.afp.com.20260222T090659Z.doc-98hu39e", lang: "fr" }
26
34
  - Export similar as CSV: { uno: "newsml.afp.com.20260222T090659Z.doc-98hu39e", lang: "en", format: "csv", fields: ["uno", "headline"] }`,
27
- inputSchema: z.object({
28
- uno: z.string().describe('The UNO of the reference article'),
29
- lang: langEnum.describe("Language for results (e.g. 'en', 'fr')"),
30
- size: z.number().optional().describe('Number of similar articles to return (default 10)'),
31
- format: outputFormatEnum.optional().describe('Output format: markdown (default, with article excerpt), json (structured, no body), csv (tabular, no body).'),
32
- fields: docFieldEnum.array().optional().describe('Fields to include in json/csv output. Default: afpshortid, uno, headline, published, lang, genre.'),
33
- }),
35
+ inputSchema,
34
36
  handler: async (apicore, { uno, lang, size, format = 'markdown', fields }) => {
35
37
  try {
36
38
  const { documents, count } = await apicore.mlt(uno, lang, size);
37
39
  if (count === 0) {
38
40
  return { content: [textContent('No similar articles found.')] };
39
41
  }
40
- const outputFields = fields ?? DEFAULT_OUTPUT_FIELDS;
41
- if (format === 'json') {
42
- return { content: [formatDocumentsAsJson(documents, outputFields, { total: count })] };
43
- }
44
- if (format === 'csv') {
45
- return { content: [formatDocumentsAsCsv(documents, outputFields)] };
46
- }
47
- const content = [
48
- textContent(`*Found ${count} similar articles.*`),
49
- ...formatDocuments(documents, false),
50
- ];
51
- return { content: truncateIfNeeded(content) };
42
+ return formatDocumentOutput(documents, format, {
43
+ fields: fields ?? [...DEFAULT_OUTPUT_FIELDS],
44
+ jsonMeta: { total: count },
45
+ markdownPrefix: [textContent(`*Found ${count} similar articles.*`)],
46
+ });
52
47
  }
53
48
  catch (error) {
54
49
  return toolError(formatErrorMessage(`finding similar articles for "${uno}"`, error, 'Verify the UNO identifier is correct.'));
@@ -1,6 +1,9 @@
1
1
  import { z } from 'zod';
2
2
  import { formatFullArticle, toolError } from '../utils/format.js';
3
3
  import { formatErrorMessage, UNO_FORMAT_NOTE } from './shared.js';
4
+ const inputSchema = z.object({
5
+ uno: z.string().describe('The unique UNO identifier of the article'),
6
+ });
4
7
  export const afpGetArticleTool = {
5
8
  name: 'afp_get_article',
6
9
  title: 'Get AFP Article',
@@ -30,9 +33,7 @@ Returns:
30
33
 
31
34
  Example:
32
35
  { uno: "newsml.afp.com.20260222T090659Z.doc-98hu39e" }`,
33
- inputSchema: z.object({
34
- uno: z.string().describe('The unique UNO identifier of the article'),
35
- }),
36
+ inputSchema,
36
37
  handler: async (apicore, { uno }) => {
37
38
  try {
38
39
  const doc = await apicore.get(uno);
@@ -1,6 +1,13 @@
1
1
  import { z } from 'zod';
2
- import { textContent, toolError } from '../utils/format.js';
2
+ import { escapeCsvValue, textContent, toolError, truncateToLimit, TRUNCATION_HINT } from '../utils/format.js';
3
3
  import { formatErrorMessage, langEnum, listPresetEnum, outputFormatEnum, } from './shared.js';
4
+ const inputSchema = z.object({
5
+ preset: listPresetEnum.optional().describe('Optional preset for list queries. Available preset: trending-topics.'),
6
+ facet: z.string().optional().describe("Facet to list (e.g. 'slug', 'genre', 'country'). Required when no preset is used."),
7
+ lang: langEnum.optional().describe("Language filter (e.g. 'en', 'fr')"),
8
+ size: z.number().optional().describe('Number of facet values to return'),
9
+ format: outputFormatEnum.optional().describe('Output format: markdown (default), json, or csv.'),
10
+ });
4
11
  export const afpListFacetsTool = {
5
12
  name: 'afp_list_facets',
6
13
  title: 'List AFP Facet Values',
@@ -23,13 +30,7 @@ Examples:
23
30
  - Trending topics in English: { preset: "trending-topics", lang: "en" }
24
31
  - List available genres as CSV: { facet: "genre", format: "csv" }
25
32
  - List countries as JSON: { facet: "country", size: 30, format: "json" }`,
26
- inputSchema: z.object({
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
- format: outputFormatEnum.optional().describe('Output format: markdown (default), json, or csv.'),
32
- }),
33
+ inputSchema,
33
34
  handler: async (apicore, { preset, facet, lang, size, format = 'markdown' }) => {
34
35
  try {
35
36
  const isTrendingTopics = preset === 'trending-topics';
@@ -37,23 +38,29 @@ Examples:
37
38
  if (!resolvedFacet) {
38
39
  return toolError("Missing required parameter: facet (e.g. 'slug', 'genre', 'country'). Alternatively, use preset: 'trending-topics'.");
39
40
  }
41
+ const resolvedSize = size ?? 10;
40
42
  const params = isTrendingTopics
41
- ? { langs: [lang ?? 'fr'], product: ['news'], dateFrom: 'now-1d', size: size ?? 10 }
42
- : (lang ? { langs: [lang], size: size ?? 10 } : { size: size ?? 10 });
43
+ ? { langs: [lang ?? 'fr'], product: ['news'], dateFrom: 'now-1d', size: resolvedSize }
44
+ : { ...(lang ? { langs: [lang] } : {}), size: resolvedSize };
43
45
  const rawResult = await apicore.list(resolvedFacet, params, 1);
44
46
  const results = rawResult?.keywords ?? rawResult ?? [];
45
47
  if (results.length === 0) {
46
48
  return { content: [textContent(`No facet values found for "${resolvedFacet}".`)] };
47
49
  }
48
50
  if (format === 'json') {
49
- return { content: [textContent(JSON.stringify(results, null, 2))] };
51
+ const { text, truncated } = truncateToLimit(results, (slice) => JSON.stringify(slice, null, 2));
52
+ const content = [textContent(text)];
53
+ if (truncated)
54
+ content.push(textContent(TRUNCATION_HINT));
55
+ return { content };
50
56
  }
51
57
  if (format === 'csv') {
52
- const rows = results.map(r => {
53
- const name = /[",\n\r]/.test(r.name) ? `"${r.name.replaceAll('"', '""')}"` : r.name;
54
- return `${name},${r.count}`;
55
- });
56
- return { content: [textContent(['name,count', ...rows].join('\n'))] };
58
+ const rows = results.map(r => `${escapeCsvValue(r.name)},${r.count}`);
59
+ const { text, truncated } = truncateToLimit(rows, (slice) => ['name,count', ...slice].join('\n'));
60
+ const content = [textContent(text)];
61
+ if (truncated)
62
+ content.push(textContent(TRUNCATION_HINT));
63
+ return { content };
57
64
  }
58
65
  const heading = isTrendingTopics ? 'Trending Topics' : `Facet: ${resolvedFacet}`;
59
66
  const lines = results.map((item) => `- **${item.name}** — ${item.count} articles`);
@@ -1,7 +1,51 @@
1
1
  import { z } from 'zod';
2
- import { DEFAULT_FIELDS, GENRE_EXCLUSIONS, formatDocumentsAsJson, formatDocumentsAsCsv, 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, outputFormatEnum, docFieldEnum, DEFAULT_OUTPUT_FIELDS, UNO_FORMAT_NOTE, } from './shared.js';
2
+ import { MARKDOWN_API_FIELDS, textContent, toolError, buildPaginationLine, formatDocumentOutput, } from '../utils/format.js';
3
+ import { DEFAULT_SEARCH_SIZE, DEFAULT_OUTPUT_FIELDS } from '../utils/types.js';
4
+ import { SEARCH_PRESETS, GENRE_EXCLUSIONS, formatErrorMessage, searchPresetEnum, outputFormatEnum, docFieldEnum, UNO_FORMAT_NOTE, } from './shared.js';
5
+ const facetParamValueSchema = z.union([
6
+ z.string(),
7
+ z.number(),
8
+ z.string().array(),
9
+ z.number().array(),
10
+ z.object({
11
+ in: z.union([z.string().array(), z.number().array()]).optional(),
12
+ exclude: z.union([z.string().array(), z.number().array()]).optional(),
13
+ }).refine((value) => value.in !== undefined || value.exclude !== undefined, {
14
+ message: "Facet filter object must include either 'in' or 'exclude'.",
15
+ }),
16
+ ]);
17
+ const reservedFacetKeys = new Set([
18
+ 'preset',
19
+ 'format',
20
+ 'fields',
21
+ 'fullText',
22
+ 'query',
23
+ 'size',
24
+ 'sortOrder',
25
+ 'offset',
26
+ 'facets',
27
+ ]);
28
+ const inputSchema = z.object({
29
+ preset: searchPresetEnum.optional().describe('Optional preset that applies predefined AFP filters. Available presets: a-la-une, agenda, previsions, major-stories.'),
30
+ format: outputFormatEnum.optional().describe('Output format: markdown (default, with article body), json (structured, no body), csv (tabular, no body).'),
31
+ fields: docFieldEnum.array().optional().describe('Fields to include in json/csv output. Default: afpshortid, uno, headline, published, lang, genre.'),
32
+ fullText: z.boolean().optional().describe('When true, returns the full article body (markdown only). Default is false. Presets override to true.'),
33
+ query: z.string().optional().describe("List of keywords to search for in the news articles (e.g. 'climate change'). If not specified, the search will be performed in all languages."),
34
+ size: z.number().optional().describe('Number of results to return (default 10, max 1000)'),
35
+ sortOrder: z.enum(['asc', 'desc']).optional().describe("Sort order by date (default 'desc')"),
36
+ offset: z.number().optional().describe('Offset for pagination (number of results to skip)'),
37
+ facets: z.record(z.string(), facetParamValueSchema).optional().describe("Facet filters passed to the AFP query (e.g. { lang: ['fr'], dateFrom: '2026-01-01', dateTo: '2026-01-31', country: ['usa'], genre: 'Papier général', urgency: 1 })."),
38
+ }).strict().superRefine((value, ctx) => {
39
+ for (const key of Object.keys(value.facets ?? {})) {
40
+ if (reservedFacetKeys.has(key)) {
41
+ ctx.addIssue({
42
+ code: z.ZodIssueCode.custom,
43
+ path: ['facets', key],
44
+ message: `Facet key "${key}" is reserved and must be provided at top-level.`,
45
+ });
46
+ }
47
+ }
48
+ });
5
49
  export const afpSearchArticlesTool = {
6
50
  name: 'afp_search_articles',
7
51
  title: 'Search AFP News Articles',
@@ -15,15 +59,11 @@ Args:
15
59
  - fields: Fields to include in json/csv output (default: uno, headline, lang, genre).
16
60
  Available: uno, headline, lang, genre, afpshortid, published, status, signal, advisory, country, city, slug, product, revision, created.
17
61
  - fullText: Return full article body (true) or excerpt only (false, default). Only applies to markdown. Presets override to true.
18
- - query: Search keywords in the language specified by 'lang' (e.g. 'climate change')
19
- - lang: Article language filter (e.g. ['en', 'fr']). Use ['en'] for photos.
20
- - dateFrom/dateTo: Date range in ISO format (e.g. '2025-01-01') or relative ('now-1d')
62
+ - query: Search keywords (e.g. 'climate change')
21
63
  - size: Number of results (default 10, max 1000)
22
64
  - sortOrder: 'asc' or 'desc' by date (default 'desc')
23
65
  - offset: Pagination offset (number of results to skip)
24
- - country: Country code filter (e.g. ['fra', 'usa'])
25
- - slug: Topic/slug filter (e.g. ['economy', 'sports'])
26
- - product: Content type filter (default ['news', 'factcheck'])
66
+ - facets: All facet filters as key/value pairs (e.g. { lang: ['fr'], dateFrom: '2026-01-01', dateTo: '2026-01-31', country: ['usa'], genre: 'Papier général', urgency: 1 })
27
67
 
28
68
  Returns:
29
69
  - markdown: Pagination summary line + formatted articles with headline, metadata, body
@@ -31,64 +71,43 @@ Returns:
31
71
  - csv: Header row + data rows with selected fields
32
72
 
33
73
  Examples:
34
- - Latest Ukraine news: { query: "Ukraine", lang: ["en"], size: 5 }
74
+ - Latest Ukraine news: { query: "Ukraine", facets: { lang: ["en"] }, size: 5 }
35
75
  - French front page: { preset: "a-la-une" }
36
76
  - Export metadata as CSV: { query: "economy", format: "csv", fields: ["uno", "headline", "country"] }`,
37
- inputSchema: z.object({
38
- preset: searchPresetEnum.optional().describe('Optional preset that applies predefined AFP filters. Available presets: a-la-une, agenda, previsions, major-stories.'),
39
- format: outputFormatEnum.optional().describe('Output format: markdown (default, with article body), json (structured, no body), csv (tabular, no body).'),
40
- fields: docFieldEnum.array().optional().describe('Fields to include in json/csv output. Default: afpshortid, uno, headline, published, lang, genre.'),
41
- fullText: z.boolean().optional().describe('When true, returns the full article body (markdown only). Default is false. Presets override to true.'),
42
- 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."),
43
- lang: langEnum.array().optional().describe("Language of the news articles (e.g. 'en', 'fr'). Always use 'en' if you look for photos."),
44
- dateFrom: z.string().optional().describe("Start date for the search in ISO format (e.g. '2023-01-01') or relative (e.g. 'now-1d')"),
45
- dateTo: z.string().optional().describe("End date for the search in ISO format (e.g. '2023-12-31')"),
46
- size: z.number().optional().describe('Number of results to return (default 10, max 1000)'),
47
- sortOrder: z.enum(['asc', 'desc']).optional().describe("Sort order by date (default 'desc')"),
48
- offset: z.number().optional().describe('Offset for pagination (number of results to skip)'),
49
- country: z.string().array().optional().describe("Country filter (e.g. 'fra', 'usa')"),
50
- slug: z.string().array().optional().describe("Topic/slug filter (e.g. 'economy', 'sports')"),
51
- product: z.enum(['news', 'factcheck', 'photo', 'video', 'multimedia', 'graphic', 'videographic']).array().optional().describe("Content type filter (default ['news', 'factcheck'])"),
52
- }),
53
- handler: async (apicore, { preset, format = 'markdown', fields, fullText = false, query, lang, dateFrom, dateTo, size = DEFAULT_SEARCH_SIZE, sortOrder = 'desc', offset, country, slug, product = ['news', 'factcheck'] }) => {
77
+ inputSchema,
78
+ handler: async (apicore, { preset, format = 'markdown', fields, fullText = false, query, size = DEFAULT_SEARCH_SIZE, sortOrder = 'desc', offset, facets }) => {
54
79
  try {
80
+ const facetFilters = {
81
+ product: ['news', 'factcheck'],
82
+ genreid: GENRE_EXCLUSIONS,
83
+ ...(facets ?? {}),
84
+ };
55
85
  let request = {
56
86
  query,
57
- lang,
58
- product,
59
- dateFrom,
60
- dateTo,
61
87
  size,
62
88
  sortOrder,
63
89
  startAt: offset,
64
- country,
65
- slug,
66
- genreid: GENRE_EXCLUSIONS,
90
+ ...facetFilters,
67
91
  };
68
92
  if (preset) {
69
93
  request = { ...request, ...SEARCH_PRESETS[preset] };
70
94
  fullText = true;
71
95
  }
72
- const outputFields = fields ?? DEFAULT_OUTPUT_FIELDS;
96
+ const outputFields = fields ?? [...DEFAULT_OUTPUT_FIELDS];
73
97
  const apiFields = format === 'markdown'
74
- ? [...DEFAULT_FIELDS]
98
+ ? [...MARKDOWN_API_FIELDS]
75
99
  : [...new Set(['afpshortid', 'uno', ...outputFields])];
76
100
  const { documents, count } = await apicore.search(request, apiFields);
77
101
  if (count === 0) {
78
102
  return { content: [textContent('No results found.')] };
79
103
  }
80
104
  const currentOffset = offset ?? 0;
81
- if (format === 'json') {
82
- return { content: [formatDocumentsAsJson(documents, outputFields, { total: count, shown: documents.length, offset: currentOffset })] };
83
- }
84
- if (format === 'csv') {
85
- return { content: [formatDocumentsAsCsv(documents, outputFields)] };
86
- }
87
- const content = [
88
- textContent(buildPaginationLine(documents.length, count, currentOffset)),
89
- ...formatDocuments(documents, fullText),
90
- ];
91
- return { content: truncateIfNeeded(content) };
105
+ return formatDocumentOutput(documents, format, {
106
+ fields: outputFields,
107
+ fullText,
108
+ jsonMeta: { total: count, offset: currentOffset },
109
+ markdownPrefix: [textContent(buildPaginationLine(documents.length, count, currentOffset))],
110
+ });
92
111
  }
93
112
  catch (error) {
94
113
  return toolError(formatErrorMessage('searching AFP articles', error, 'Check your query parameters and try again.'));
@@ -1,7 +1,5 @@
1
1
  import { z } from 'zod';
2
- import { formatDocument, GENRE_EXCLUSIONS, } from '../utils/format.js';
3
- import { ALL_DOC_FIELDS, DEFAULT_OUTPUT_FIELDS } from '../utils/types.js';
4
- export { ALL_DOC_FIELDS, DEFAULT_OUTPUT_FIELDS };
2
+ import { ALL_DOC_FIELDS } from '../utils/types.js';
5
3
  // UNO format: newsml.afp.com.20260222T090659Z.doc-98hu39e
6
4
  // - timestamp: 20260222T090659Z → 2026-02-22 09:06:59 UTC
7
5
  // - afpshortid: the segment after "doc-" (e.g. 98hu39e)
@@ -23,6 +21,18 @@ export const READ_ONLY_ANNOTATIONS = {
23
21
  idempotentHint: true,
24
22
  openWorldHint: true,
25
23
  };
24
+ export const GENRE_EXCLUSIONS = {
25
+ exclude: [
26
+ 'afpgenre:Agenda',
27
+ 'afpattribute:Agenda',
28
+ 'afpattribute:Program',
29
+ 'afpattribute:TextProgram',
30
+ 'afpattribute:AdvisoryUpdate',
31
+ 'afpattribute:Advice',
32
+ 'afpattribute:SpecialAnnouncement',
33
+ 'afpattribute:PictureProgram',
34
+ ],
35
+ };
26
36
  export const SEARCH_PRESETS = {
27
37
  'a-la-une': {
28
38
  product: ['news'],
@@ -51,6 +61,3 @@ export function formatErrorMessage(context, error, hint) {
51
61
  const message = error instanceof Error ? error.message : String(error);
52
62
  return `Error ${context}: ${message}. ${hint}`;
53
63
  }
54
- export function formatDocuments(documents, fullText) {
55
- return documents.map((doc) => formatDocument(doc, fullText));
56
- }
@@ -1,5 +1,8 @@
1
1
  import { EXCERPT_PARAGRAPH_COUNT, CHARACTER_LIMIT } from './types.js';
2
- function escapeCsvValue(value) {
2
+ export const TRUNCATION_HINT = `\n\n---\n*Response truncated (exceeded ${CHARACTER_LIMIT} characters). Use a smaller \`size\` or add filters to reduce results.*`;
3
+ /** Fields requested from the API when rendering markdown output. */
4
+ export const MARKDOWN_API_FIELDS = ['uno', 'status', 'signal', 'advisory', 'headline', 'news', 'lang', 'genre'];
5
+ export function escapeCsvValue(value) {
3
6
  const str = Array.isArray(value) ? value.join('|') : String(value ?? '');
4
7
  if (/[",\n\r]/.test(str))
5
8
  return `"${str.replaceAll('"', '""')}"`;
@@ -9,27 +12,45 @@ export function pickDocFields(doc, fields) {
9
12
  const d = doc;
10
13
  return Object.fromEntries(fields.map(f => [f, d[f] ?? null]));
11
14
  }
12
- export function formatDocumentsAsJson(docs, fields, meta = {}) {
15
+ /**
16
+ * Truncate an array of items so the serialized output fits within CHARACTER_LIMIT.
17
+ * Uses binary search O(log n) when truncation is needed.
18
+ */
19
+ export function truncateToLimit(items, serialize) {
20
+ const full = serialize(items);
21
+ if (full.length <= CHARACTER_LIMIT) {
22
+ return { text: full, count: items.length, truncated: false };
23
+ }
24
+ let lo = 0;
25
+ let hi = items.length;
26
+ while (lo < hi) {
27
+ const mid = Math.ceil((lo + hi) / 2);
28
+ if (serialize(items.slice(0, mid)).length <= CHARACTER_LIMIT) {
29
+ lo = mid;
30
+ }
31
+ else {
32
+ hi = mid - 1;
33
+ }
34
+ }
35
+ return { text: serialize(items.slice(0, lo)), count: lo, truncated: true };
36
+ }
37
+ function formatDocumentsAsJsonInner(docs, fields, meta = {}) {
13
38
  const documents = docs.map(doc => pickDocFields(doc, fields));
14
- return textContent(JSON.stringify({ ...meta, documents }, null, 2));
39
+ const { text, count, truncated } = truncateToLimit(documents, (slice) => JSON.stringify({ ...meta, shown: slice.length, truncated: slice.length < documents.length, documents: slice }, null, 2));
40
+ return { content: textContent(text), truncated };
15
41
  }
16
- export function formatDocumentsAsCsv(docs, fields) {
42
+ function formatDocumentsAsCsvInner(docs, fields) {
17
43
  const rows = docs.map(doc => fields.map(f => escapeCsvValue(doc[f])).join(','));
18
- return textContent([fields.join(','), ...rows].join('\n'));
44
+ const header = fields.join(',');
45
+ const { text, truncated } = truncateToLimit(rows, (slice) => [header, ...slice].join('\n'));
46
+ return { content: textContent(text), truncated };
47
+ }
48
+ export function formatDocumentsAsJson(docs, fields, meta = {}) {
49
+ return formatDocumentsAsJsonInner(docs, fields, meta).content;
50
+ }
51
+ export function formatDocumentsAsCsv(docs, fields) {
52
+ return formatDocumentsAsCsvInner(docs, fields).content;
19
53
  }
20
- export const GENRE_EXCLUSIONS = {
21
- exclude: [
22
- 'afpgenre:Agenda',
23
- 'afpattribute:Agenda',
24
- 'afpattribute:Program',
25
- 'afpattribute:TextProgram',
26
- 'afpattribute:AdvisoryUpdate',
27
- 'afpattribute:Advice',
28
- 'afpattribute:SpecialAnnouncement',
29
- 'afpattribute:PictureProgram'
30
- ]
31
- };
32
- export const DEFAULT_FIELDS = ['uno', 'status', 'signal', 'advisory', 'headline', 'news', 'lang', 'genre'];
33
54
  export function formatDocument(doc, fullText = false) {
34
55
  const d = doc;
35
56
  const meta = [
@@ -47,8 +68,7 @@ export function formatDocument(doc, fullText = false) {
47
68
  const body = fullText
48
69
  ? paragraphs.join('\n\n')
49
70
  : paragraphs.slice(0, EXCERPT_PARAGRAPH_COUNT).join('\n\n');
50
- const text = `## ${d.headline}\n*${meta.join(' | ')}*\n\n${body}`;
51
- return { type: 'text', text };
71
+ return textContent(`## ${d.headline}\n*${meta.join(' | ')}*\n\n${body}`);
52
72
  }
53
73
  export function formatFullArticle(doc) {
54
74
  const d = doc;
@@ -67,7 +87,32 @@ export function formatFullArticle(doc) {
67
87
  lines.push(flags);
68
88
  const meta = lines.join('\n');
69
89
  const body = (Array.isArray(d.news) ? d.news : []).join('\n\n');
70
- return { type: 'text', text: `## ${d.headline}\n\n${meta}\n\n---\n\n${body}` };
90
+ return textContent(`## ${d.headline}\n\n${meta}\n\n---\n\n${body}`);
91
+ }
92
+ /**
93
+ * Unified output formatter for multi-document tool results.
94
+ * Handles json/csv/markdown branching in one place.
95
+ */
96
+ export function formatDocumentOutput(documents, format, opts) {
97
+ if (format === 'json') {
98
+ const { content, truncated } = formatDocumentsAsJsonInner(documents, opts.fields, opts.jsonMeta);
99
+ const result = [content];
100
+ if (truncated)
101
+ result.push(textContent(TRUNCATION_HINT));
102
+ return { content: result };
103
+ }
104
+ if (format === 'csv') {
105
+ const { content, truncated } = formatDocumentsAsCsvInner(documents, opts.fields);
106
+ const result = [content];
107
+ if (truncated)
108
+ result.push(textContent(TRUNCATION_HINT));
109
+ return { content: result };
110
+ }
111
+ const content = [
112
+ ...(opts.markdownPrefix ?? []),
113
+ ...documents.map(doc => formatDocument(doc, opts.fullText ?? false)),
114
+ ];
115
+ return { content: truncateIfNeeded(content) };
71
116
  }
72
117
  export function textContent(text) {
73
118
  return { type: 'text', text };
@@ -75,7 +120,7 @@ export function textContent(text) {
75
120
  export function toolError(message) {
76
121
  return {
77
122
  isError: true,
78
- content: [textContent(message)]
123
+ content: [textContent(message)],
79
124
  };
80
125
  }
81
126
  export function truncateIfNeeded(content) {
@@ -95,7 +140,7 @@ export function truncateIfNeeded(content) {
95
140
  truncated.push(item);
96
141
  accumulated += item.text.length;
97
142
  }
98
- truncated.push(textContent(`\n\n---\n*Response truncated (exceeded ${CHARACTER_LIMIT} characters). Use a smaller \`size\` or add filters to reduce results.*`));
143
+ truncated.push(textContent(TRUNCATION_HINT));
99
144
  return truncated;
100
145
  }
101
146
  export function buildPaginationLine(shown, total, offset) {
@@ -117,16 +117,3 @@ export const TOPICS = {
117
117
  { label: "معرض الصور", value: "ONLINE-NEWS-AR_PHO-GAL" },
118
118
  ],
119
119
  };
120
- export function getTopicLabel(value) {
121
- for (const topics of Object.values(TOPICS)) {
122
- const topic = topics.find(t => t.value === value);
123
- if (topic)
124
- return topic.label;
125
- }
126
- return undefined;
127
- }
128
- export function formatTopicList() {
129
- return Object.entries(TOPICS)
130
- .map(([lang, topics]) => `${lang}: ${topics.map(t => `${t.label} (${t.value})`).join(', ')}`)
131
- .join('\n');
132
- }
@@ -1,7 +1,6 @@
1
1
  export const EXCERPT_PARAGRAPH_COUNT = 4;
2
2
  export const CHARACTER_LIMIT = 25_000;
3
3
  export const DEFAULT_SEARCH_SIZE = 10;
4
- export const DEFAULT_FACET_SIZE = 20;
5
4
  export const ALL_DOC_FIELDS = [
6
5
  'afpshortid', 'uno', 'headline', 'published', 'lang', 'genre',
7
6
  'status', 'signal', 'advisory', 'country', 'city', 'slug', 'product', 'revision', 'created',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "afpnews-mcp-server",
3
- "version": "1.3.10",
3
+ "version": "1.3.11",
4
4
  "description": "",
5
5
  "main": "build/index.js",
6
6
  "type": "module",
@@ -8,11 +8,6 @@
8
8
  ".": "./build/index.js",
9
9
  "./definitions": "./build/definitions.js"
10
10
  },
11
- "scripts": {
12
- "build": "tsc && chmod 755 build/index.js",
13
- "start": "node build/index.js",
14
- "test": "vitest run"
15
- },
16
11
  "bin": {
17
12
  "search": "./build/index.js"
18
13
  },
@@ -22,7 +17,6 @@
22
17
  "keywords": [],
23
18
  "author": "",
24
19
  "license": "ISC",
25
- "packageManager": "pnpm@10.29.3",
26
20
  "dependencies": {
27
21
  "@modelcontextprotocol/sdk": "^1.26.0",
28
22
  "afpnews-api": "^2.4.0",
@@ -35,5 +29,11 @@
35
29
  "@types/node": "^25.2.3",
36
30
  "typescript": "^5.9.3",
37
31
  "vitest": "^4.0.18"
32
+ },
33
+ "scripts": {
34
+ "clean": "rm -rf build",
35
+ "build": "pnpm run clean && tsc && chmod 755 build/index.js",
36
+ "start": "node build/index.js",
37
+ "test": "vitest run"
38
38
  }
39
- }
39
+ }