afpnews-mcp-server 2.0.0 → 2.0.2

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.
@@ -0,0 +1,52 @@
1
+ import { describe, it, expect, mock } from 'bun:test';
2
+ import { afpListFacetsTool } from '../tools/list-facets.js';
3
+
4
+ describe('afpListFacetsTool', () => {
5
+ it('returns no-results message when list is empty', async () => {
6
+ const apicore = { list: mock().mockResolvedValue({ keywords: [] }) };
7
+
8
+ const result = await afpListFacetsTool.handler(apicore, { facet: 'slug' });
9
+
10
+ expect(result.content).toHaveLength(1);
11
+ expect(result.content[0]!.text).toContain('No facet values found');
12
+ });
13
+
14
+ it('applies AFP text defaults with explicit lang outside preset', async () => {
15
+ const apicore = { list: mock().mockResolvedValue({ keywords: [{ name: 'economy', count: 42 }] }) };
16
+
17
+ await afpListFacetsTool.handler(apicore, { facet: 'slug', lang: 'en' });
18
+
19
+ const [, params] = apicore.list.mock.calls[0]!;
20
+ expect(params).toEqual({ class: ['text'], provider: ['afp'], langs: ['en'], size: 10 });
21
+ });
22
+
23
+ it('adds truncation hint in json output when facet list is too large', async () => {
24
+ const keywords = Array.from({ length: 3000 }, (_, i) => ({ name: `topic-${i}-${'x'.repeat(30)}`, count: i + 1 }));
25
+ const apicore = { list: mock().mockResolvedValue({ keywords }) };
26
+
27
+ const result = await afpListFacetsTool.handler(apicore, { facet: 'slug', format: 'json' });
28
+
29
+ expect(result.content.length).toBeGreaterThan(1);
30
+ expect(result.content[1]!.text).toContain('Response truncated');
31
+ });
32
+
33
+ it('adds truncation hint in csv output when facet list is too large', async () => {
34
+ const keywords = Array.from({ length: 3000 }, (_, i) => ({ name: `topic-${i}-${'x'.repeat(30)}`, count: i + 1 }));
35
+ const apicore = { list: mock().mockResolvedValue({ keywords }) };
36
+
37
+ const result = await afpListFacetsTool.handler(apicore, { facet: 'slug', format: 'csv' });
38
+
39
+ expect(result.content.length).toBeGreaterThan(1);
40
+ expect(result.content[1]!.text).toContain('Response truncated');
41
+ });
42
+
43
+ it('adds truncation hint in markdown output when facet list is too large', async () => {
44
+ const keywords = Array.from({ length: 3000 }, (_, i) => ({ name: `topic-${i}-${'x'.repeat(30)}`, count: i + 1 }));
45
+ const apicore = { list: mock().mockResolvedValue({ keywords }) };
46
+
47
+ const result = await afpListFacetsTool.handler(apicore, { facet: 'slug' });
48
+
49
+ expect(result.content.length).toBeGreaterThan(1);
50
+ expect(result.content[1]!.text).toContain('Response truncated');
51
+ });
52
+ });
@@ -5,6 +5,7 @@ import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
5
5
  import { registerTools } from '../tools/index.js';
6
6
  import { registerResources } from '../resources/index.js';
7
7
  import { registerPrompts } from '../prompts/index.js';
8
+ import type { ServerContext } from '../mcp-server.js';
8
9
  import { FIXTURE_DOC, makeDocs } from './fixtures.js';
9
10
 
10
11
  function createMockApicore() {
@@ -12,7 +13,7 @@ function createMockApicore() {
12
13
  search: mock().mockResolvedValue({ documents: makeDocs(3), count: 3 }),
13
14
  get: mock().mockResolvedValue(makeDocs(1)[0]),
14
15
  mlt: mock().mockResolvedValue({ documents: makeDocs(2), count: 2 }),
15
- list: mock().mockResolvedValue([{ name: 'economy', count: 42 }]),
16
+ list: mock().mockResolvedValue({ keywords: [{ name: 'economy', count: 42 }] }),
16
17
  };
17
18
  }
18
19
 
@@ -20,10 +21,21 @@ function getText(result: any, index = 0): string {
20
21
  return (result.content[index] as { type: string; text: string }).text;
21
22
  }
22
23
 
24
+ function makeLargeDocs(count: number) {
25
+ return Array.from({ length: count }, (_, i) => ({
26
+ uno: `AFP-LARGE-${String(i + 1).padStart(4, '0')}`,
27
+ headline: `Article ${i + 1} ${'H'.repeat(180)}`,
28
+ published: '2026-02-14T10:00:00Z',
29
+ lang: 'fr',
30
+ genre: 'news',
31
+ news: [`Paragraph ${i + 1} ${'B'.repeat(700)}`],
32
+ }));
33
+ }
34
+
23
35
  async function setupServer() {
24
36
  const server = new McpServer({ name: 'test', version: '0.0.1' });
25
37
  const apicore = createMockApicore();
26
- const ctx = { server, apicore } as any;
38
+ const ctx = { server, apicore } as ServerContext;
27
39
 
28
40
  registerTools(ctx);
29
41
  registerResources(ctx);
@@ -74,8 +86,8 @@ describe('MCP integration', () => {
74
86
  const result = await client.callTool({ name: 'afp_search_articles', arguments: { query: 'test' } });
75
87
  // pagination line + 1 document
76
88
  const msg = result.content![1] as { type: string; text: string };
77
- expect(msg.text).toContain('Fourth paragraph wraps up.');
78
- expect(msg.text).not.toContain('Fifth paragraph is extra content.');
89
+ expect(msg.text).toContain('Second paragraph with more details.');
90
+ expect(msg.text).not.toContain('Third paragraph continues.');
79
91
  });
80
92
 
81
93
  it('returns full text when fullText=true', async () => {
@@ -93,7 +105,7 @@ describe('MCP integration', () => {
93
105
 
94
106
  const [request] = apicore.search.mock.calls.at(-1)!;
95
107
  expect(request.class).toEqual(['text']);
96
- expect(request.lang).toEqual(['fr']);
108
+ expect(request.langs).toEqual(['fr']);
97
109
  expect(request.slug).toEqual(['afp', 'actualites']);
98
110
  expect(request.dateFrom).toBe('now-1d');
99
111
  expect(request.size).toBe(1);
@@ -141,7 +153,8 @@ describe('MCP integration', () => {
141
153
  const result = await client.callTool({ name: 'afp_search_articles', arguments: { query: 'test' } });
142
154
  const pagination = result.content![0] as { type: string; text: string };
143
155
  expect(pagination.text).toContain('Showing 3 of 10 results');
144
- expect(pagination.text).toContain('offset=3');
156
+ expect(pagination.text).toContain('offset: 0');
157
+ expect(pagination.text).toContain('offset: 3'); // suggests next offset
145
158
  });
146
159
 
147
160
  it('returns isError on API failure', async () => {
@@ -188,20 +201,18 @@ describe('MCP integration', () => {
188
201
  });
189
202
 
190
203
  it('truncates json when exceeding character limit', async () => {
191
- const largeDocs = makeDocs(400);
204
+ const largeDocs = makeLargeDocs(400);
192
205
  apicore.search.mockResolvedValueOnce({ documents: largeDocs, count: 400 });
193
206
  const result = await client.callTool({ name: 'afp_search_articles', arguments: { query: 'test', format: 'json' } });
194
207
  const parsed = JSON.parse(getText(result));
195
208
  expect(parsed.truncated).toBe(true);
196
209
  expect(parsed.shown).toBeLessThan(400);
197
210
  expect(parsed.total).toBe(400);
198
- // Should have a truncation hint as second content block
199
- expect(result.content).toHaveLength(2);
200
- expect(getText(result, 1)).toContain('truncated');
211
+ expect(parsed.remaining).toBe(400 - parsed.shown);
201
212
  });
202
213
 
203
214
  it('truncates csv when exceeding character limit', async () => {
204
- const largeDocs = makeDocs(800);
215
+ const largeDocs = makeLargeDocs(800);
205
216
  apicore.search.mockResolvedValueOnce({ documents: largeDocs, count: 800 });
206
217
  const result = await client.callTool({ name: 'afp_search_articles', arguments: { query: 'test', format: 'csv' } });
207
218
  const lines = getText(result).split('\n');
@@ -323,7 +334,7 @@ describe('MCP integration', () => {
323
334
 
324
335
  const [facet, params] = apicore.list.mock.calls.at(-1)!;
325
336
  expect(facet).toBe('slug');
326
- expect(params).toMatchObject({ langs: ['fr'], class: ['text'], dateFrom: 'now-1d' });
337
+ expect(params).toMatchObject({ langs: ['fr'], class: ['text'], provider: ['afp'], dateFrom: 'now-1d' });
327
338
  });
328
339
 
329
340
  it('returns isError on API failure', async () => {
@@ -352,7 +363,7 @@ describe('MCP integration', () => {
352
363
  it('topics resource returns catalog', async () => {
353
364
  const result = await client.readResource({ uri: 'afp://topics' });
354
365
  expect(result.contents).toHaveLength(1);
355
- const parsed = JSON.parse(result.contents[0].text as string);
366
+ const parsed = JSON.parse((result.contents[0] as { text: string }).text);
356
367
  expect(parsed).toHaveProperty('fr');
357
368
  expect(parsed).toHaveProperty('en');
358
369
  });
@@ -0,0 +1,53 @@
1
+ import { describe, it, expect, mock } from 'bun:test';
2
+ import { afpSearchArticlesTool } from '../tools/search-articles.js';
3
+ import { afpSearchMediaTool } from '../tools/search-media.js';
4
+ import { afpListFacetsTool } from '../tools/list-facets.js';
5
+ import { FIXTURE_DOC } from './fixtures.js';
6
+
7
+ describe('tool defaults', () => {
8
+ it('afp_search_articles defaults to AFP French text documents', async () => {
9
+ const apicore = {
10
+ search: mock().mockResolvedValue({ documents: [FIXTURE_DOC], count: 1 }),
11
+ };
12
+
13
+ await afpSearchArticlesTool.handler(apicore, { query: 'test' });
14
+
15
+ const [request] = apicore.search.mock.calls[0]!;
16
+ expect(request.class).toEqual(['text']);
17
+ expect(request.provider).toEqual(['afp']);
18
+ expect(request.langs).toEqual(['fr']);
19
+ });
20
+
21
+ it('afp_search_media defaults to AFP provider only', async () => {
22
+ const apicore = {
23
+ search: mock().mockResolvedValue({
24
+ documents: [{
25
+ uno: 'MEDIA-001',
26
+ title: 'Photo',
27
+ caption: ['Caption'],
28
+ class: 'picture',
29
+ bagItem: [{ medias: [{ role: 'Thumbnail', href: 'https://example.com/thumb.jpg', width: 320, height: 213, type: 'Photo' }] }],
30
+ }],
31
+ count: 1,
32
+ }),
33
+ };
34
+
35
+ await afpSearchMediaTool.handler(apicore, { query: 'test', class: 'picture' });
36
+
37
+ const [request] = apicore.search.mock.calls[0]!;
38
+ expect(request.class).toEqual(['picture']);
39
+ expect(request.provider).toEqual(['afp']);
40
+ });
41
+
42
+ it('afp_list_facets defaults to AFP text documents', async () => {
43
+ const apicore = {
44
+ list: mock().mockResolvedValue({ keywords: [{ name: 'economy', count: 42 }] }),
45
+ };
46
+
47
+ await afpListFacetsTool.handler(apicore, { facet: 'slug' });
48
+
49
+ const [, params] = apicore.list.mock.calls[0]!;
50
+ expect(params.class).toEqual(['text']);
51
+ expect(params.provider).toEqual(['afp']);
52
+ });
53
+ });
@@ -1,4 +1,4 @@
1
- import { describe, it, expect } from 'vitest';
1
+ import { describe, it, expect } from 'bun:test';
2
2
  import type { AFPMediaDocument, MediaRendition, MediaRenditions, ImageContent, AnyContent, ToolSuccess } from '../utils/types.js';
3
3
 
4
4
  describe('AFPMediaDocument type', () => {
@@ -13,7 +13,7 @@ export const countryNewsPrompt = {
13
13
  role: 'user' as const,
14
14
  content: {
15
15
  type: 'text' as const,
16
- 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
+ text: `Use afp_search_articles to find recent news for country "${country}" (facets: { langs: ["${lang}"], country: ["${country}"] }, size: 15). Write a news summary for this country covering the main stories of the day.`,
17
17
  },
18
18
  }],
19
19
  }),
@@ -15,7 +15,7 @@ const inputSchema = z.object({
15
15
  lang: langEnum.describe("Language for results (e.g. 'en', 'fr')"),
16
16
  size: z.number().optional().describe('Number of similar articles to return (default 10)'),
17
17
  format: outputFormatEnum.optional().describe('Output format: markdown (default, with article excerpt), json (structured, no body), csv (tabular, no body).'),
18
- fields: docFieldEnum.array().optional().describe('Fields to include in json/csv output. Default: afpshortid, uno, headline, published, lang, genre.'),
18
+ fields: docFieldEnum.array().optional().describe('Fields to include in json/csv output. Default: uno, headline, lang, genre.'),
19
19
  });
20
20
 
21
21
  type FindSimilarInput = z.infer<typeof inputSchema>;
@@ -44,7 +44,7 @@ Examples:
44
44
  - Find similar articles in French: { uno: "newsml.afp.com.20260222T090659Z.doc-98hu39e", lang: "fr" }
45
45
  - Export similar as CSV: { uno: "newsml.afp.com.20260222T090659Z.doc-98hu39e", lang: "en", format: "csv", fields: ["uno", "headline"] }`,
46
46
  inputSchema,
47
- handler: async (apicore: ApiCore, { uno, lang, size, format = 'markdown', fields }: FindSimilarInput) => {
47
+ handler: async (apicore: Pick<ApiCore, 'mlt'>, { uno, lang, size, format = 'markdown', fields }: FindSimilarInput) => {
48
48
  try {
49
49
  const { documents, count } = await apicore.mlt(uno, lang, size);
50
50
  if (count === 0) {
@@ -39,7 +39,7 @@ Returns:
39
39
  Example:
40
40
  { uno: "newsml.afp.com.20260222T090659Z.doc-98hu39e" }`,
41
41
  inputSchema,
42
- handler: async (apicore: ApiCore, { uno }: GetArticleInput) => {
42
+ handler: async (apicore: Pick<ApiCore, 'get'>, { uno }: GetArticleInput) => {
43
43
  try {
44
44
  const doc = await apicore.get(uno);
45
45
  return { content: [formatFullArticle(doc)] };
@@ -47,7 +47,7 @@ const inputSchema = z.object({
47
47
 
48
48
  type GetMediaInput = z.infer<typeof inputSchema>;
49
49
 
50
- function formatFullMediaText(doc: any, renditions: MediaRenditions, note?: string): string {
50
+ function formatFullMediaText(doc: Record<string, unknown>, renditions: MediaRenditions, note?: string): string {
51
51
  const lines: string[] = [];
52
52
  if (doc.title) lines.push(`## ${doc.title}`);
53
53
  lines.push(`**UNO:** ${doc.uno}`);
@@ -57,7 +57,8 @@ function formatFullMediaText(doc: any, renditions: MediaRenditions, note?: strin
57
57
  if (doc.published) lines.push(`**Published:** ${doc.published}`);
58
58
  if (doc.country || doc.city) lines.push(`**Location:** ${[doc.city, doc.country].filter(Boolean).join(', ')}`);
59
59
  if (doc.urgency != null) lines.push(`**Urgency:** ${doc.urgency}`);
60
- if (doc.aspectRatios?.length) lines.push(`**Aspect:** ${doc.aspectRatios.join(', ')}`);
60
+ const aspectRatios = doc.aspectRatios as string[] | undefined;
61
+ if (aspectRatios?.length) lines.push(`**Aspect:** ${aspectRatios.join(', ')}`);
61
62
 
62
63
  const caption = Array.isArray(doc.caption) ? doc.caption[0] : doc.caption;
63
64
  if (caption) lines.push(`\n${caption}`);
@@ -93,14 +94,15 @@ Returns:
93
94
  - With embed: metadata + MCP image block (Claude can analyse the image)`,
94
95
  inputSchema,
95
96
  handler: async (
96
- apicore: ApiCore,
97
+ apicore: Pick<ApiCore, 'get'>,
97
98
  { uno, embed = false, rendition: requestedRendition = 'preview' }: GetMediaInput,
98
99
  ) => {
99
100
  try {
100
- const doc = await apicore.get(uno) as any;
101
- if (!doc) {
101
+ const raw = await apicore.get(uno);
102
+ if (!raw) {
102
103
  return toolError(`Media document not found: ${uno}`);
103
104
  }
105
+ const doc = raw as Record<string, unknown>;
104
106
 
105
107
  const renditions = extractRenditions(doc.bagItem ?? []);
106
108
  const metadataText = textContent(formatFullMediaText(doc, renditions));
@@ -32,7 +32,7 @@ export function registerTools(ctx: ServerContext) {
32
32
  inputSchema: tool.inputSchema,
33
33
  annotations: READ_ONLY_ANNOTATIONS,
34
34
  },
35
- async (args: Record<string, unknown>) => tool.handler(ctx.apicore, args as never),
35
+ async (args: Record<string, unknown>) => (tool.handler as any)(ctx.apicore, args),
36
36
  );
37
37
  }
38
38
  }
@@ -1,6 +1,6 @@
1
1
  import { z } from 'zod';
2
- import type { ApiCore } from 'afpnews-api';
3
- import { escapeCsvValue, textContent, toolError, truncateToLimit, TRUNCATION_HINT } from '../utils/format.js';
2
+ import type { ApiCore, SearchQueryParams } from 'afpnews-api';
3
+ import { escapeCsvValue, textContent, toolError, truncateToLimit, truncationHint } from '../utils/format.js';
4
4
  import {
5
5
  type FacetResult,
6
6
  formatErrorMessage,
@@ -42,7 +42,7 @@ Examples:
42
42
  - List available genres as CSV: { facet: "genre", format: "csv" }
43
43
  - List countries as JSON: { facet: "country", size: 30, format: "json" }`,
44
44
  inputSchema,
45
- handler: async (apicore: ApiCore, { preset, facet, lang, size, format = 'markdown' }: ListFacetsInput) => {
45
+ handler: async (apicore: Pick<ApiCore, 'list'>, { preset, facet, lang, size, format = 'markdown' }: ListFacetsInput) => {
46
46
  try {
47
47
  const isTrendingTopics = preset === 'trending-topics';
48
48
  const resolvedFacet = isTrendingTopics ? 'slug' : facet;
@@ -52,41 +52,46 @@ Examples:
52
52
  }
53
53
 
54
54
  const resolvedSize = size ?? 10;
55
- const params: Record<string, unknown> = isTrendingTopics
56
- ? { langs: [lang ?? 'fr'], class: ['text'], dateFrom: 'now-1d', size: resolvedSize }
57
- : { ...(lang ? { langs: [lang] } : {}), size: resolvedSize };
55
+ const params: SearchQueryParams = isTrendingTopics
56
+ ? { langs: [lang ?? 'fr'], class: ['text'], provider: ['afp'], dateFrom: 'now-1d', size: resolvedSize }
57
+ : { class: ['text'], provider: ['afp'], ...(lang ? { langs: [lang] } : {}), size: resolvedSize };
58
58
 
59
- const rawResult = await apicore.list(resolvedFacet, params as any, 1) as any;
60
- const results: FacetResult[] = rawResult?.keywords ?? rawResult ?? [];
59
+ const { keywords } = await apicore.list(resolvedFacet, params, 1);
60
+ const results: FacetResult[] = keywords.map(k => ({ name: k.name ?? '', count: k.count }));
61
61
 
62
62
  if (results.length === 0) {
63
63
  return { content: [textContent(`No facet values found for "${resolvedFacet}".`)] };
64
64
  }
65
65
 
66
66
  if (format === 'json') {
67
- const { text, truncated } = truncateToLimit(
67
+ const { text, truncated, remaining } = truncateToLimit(
68
68
  results,
69
69
  (slice) => JSON.stringify(slice, null, 2),
70
70
  );
71
71
  const content = [textContent(text)];
72
- if (truncated) content.push(textContent(TRUNCATION_HINT));
72
+ if (truncated) content.push(textContent(truncationHint(remaining)));
73
73
  return { content };
74
74
  }
75
75
 
76
76
  if (format === 'csv') {
77
77
  const rows = results.map(r => `${escapeCsvValue(r.name)},${r.count}`);
78
- const { text, truncated } = truncateToLimit(
78
+ const { text, truncated, remaining } = truncateToLimit(
79
79
  rows,
80
80
  (slice) => ['name,count', ...slice].join('\n'),
81
81
  );
82
82
  const content = [textContent(text)];
83
- if (truncated) content.push(textContent(TRUNCATION_HINT));
83
+ if (truncated) content.push(textContent(truncationHint(remaining)));
84
84
  return { content };
85
85
  }
86
86
 
87
87
  const heading = isTrendingTopics ? 'Trending Topics' : `Facet: ${resolvedFacet}`;
88
- const lines = results.map((item) => `- **${item.name}** ${item.count} articles`);
89
- return { content: [textContent(`## ${heading}\n\n${lines.join('\n')}`)] };
88
+ const { text, truncated, remaining } = truncateToLimit(
89
+ results,
90
+ (slice) => `## ${heading}\n\n${slice.map((item) => `- **${item.name}** — ${item.count} articles`).join('\n')}`,
91
+ );
92
+ const content = [textContent(text)];
93
+ if (truncated) content.push(textContent(truncationHint(remaining)));
94
+ return { content };
90
95
  } catch (error) {
91
96
  return toolError(formatErrorMessage('listing facet values', error, "Check that the facet name is valid (e.g. 'slug', 'genre', 'country')."));
92
97
  }
@@ -1,5 +1,5 @@
1
1
  import { z } from 'zod';
2
- import type { ApiCore } from 'afpnews-api';
2
+ import type { ApiCore, SearchQueryParams } from 'afpnews-api';
3
3
  import {
4
4
  MARKDOWN_API_FIELDS,
5
5
  textContent,
@@ -34,13 +34,13 @@ const reservedFacetKeys = new Set([
34
34
  const inputSchema = z.object({
35
35
  preset: searchPresetEnum.optional().describe('Optional preset that applies predefined AFP filters. Available presets: a-la-une, agenda, previsions, major-stories.'),
36
36
  format: outputFormatEnum.optional().describe('Output format: markdown (default, with article body), json (structured, no body), csv (tabular, no body).'),
37
- fields: docFieldEnum.array().optional().describe('Fields to include in json/csv output. Default: afpshortid, uno, headline, published, lang, genre.'),
37
+ fields: docFieldEnum.array().optional().describe('Fields to include in json/csv output. Default: uno, headline, lang, genre.'),
38
38
  fullText: z.boolean().optional().describe('When true, returns the full article body (markdown only). Default is false. Presets override to true.'),
39
39
  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."),
40
40
  size: z.number().optional().describe('Number of results to return (default 10, max 1000)'),
41
41
  sortOrder: z.enum(['asc', 'desc']).optional().describe("Sort order by date (default 'desc')"),
42
42
  offset: z.number().optional().describe('Offset for pagination (number of results to skip)'),
43
- 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 })."),
43
+ facets: z.record(z.string(), facetParamValueSchema).optional().describe("Facet filters passed to the AFP query (e.g. { langs: ['fr'], dateFrom: '2026-01-01', dateTo: '2026-01-31', country: ['usa'], genre: 'Papier général', urgency: 1 })."),
44
44
  }).strict().superRefine((value, ctx) => {
45
45
  for (const key of Object.keys(value.facets ?? {})) {
46
46
  if (reservedFacetKeys.has(key)) {
@@ -58,7 +58,9 @@ type SearchInput = z.infer<typeof inputSchema>;
58
58
  export const afpSearchArticlesTool = {
59
59
  name: 'afp_search_articles',
60
60
  title: 'Search AFP News Articles',
61
- description: `Search AFP news articles with filters and presets. This is the primary query tool for all AFP news search use cases.
61
+ description: `Search AFP news articles with filters and presets. This is the primary query tool for AFP news search.
62
+
63
+ Prefer this tool over afp_search_media unless the user explicitly asks for photos, videos, graphics, or motion design.
62
64
 
63
65
  ${UNO_FORMAT_NOTE}
64
66
 
@@ -72,30 +74,38 @@ Args:
72
74
  - size: Number of results (default 10, max 1000)
73
75
  - sortOrder: 'asc' or 'desc' by date (default 'desc')
74
76
  - offset: Pagination offset (number of results to skip)
75
- - 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 })
77
+ - facets: All facet filters as key/value pairs (e.g. { langs: ['en'], dateFrom: '2026-01-01', dateTo: '2026-01-31', country: ['usa'], genre: 'Papier général', urgency: 1 }).
78
+ Defaults: class=text, provider=afp, langs=fr. Override langs for other languages (e.g. { langs: ['en'] } or { langs: ['fr', 'en'] }).
79
+
80
+ Pagination:
81
+ Use \`offset\` to paginate through results (e.g. offset=10 to skip the first 10).
82
+ For large chronological scans, prefer narrowing \`dateFrom\`/\`dateTo\` ranges over high offsets.
83
+ Keep \`size\` small (10–20) for best performance.
76
84
 
77
85
  Returns:
78
86
  - markdown: Pagination summary line + formatted articles with headline, metadata, body
79
- - json: { total, shown, offset, documents: [...] } with selected fields
87
+ - json: { total, shown, offset, truncated, remaining, documents: [...] } with selected fields
80
88
  - csv: Header row + data rows with selected fields
81
89
 
82
90
  Examples:
83
- - Latest Ukraine news: { query: "Ukraine", facets: { lang: ["en"] }, size: 5 }
91
+ - Latest Ukraine news: { query: "Ukraine", facets: { langs: ["en"] }, size: 5 }
84
92
  - French front page: { preset: "a-la-une" }
85
93
  - Export metadata as CSV: { query: "economy", format: "csv", fields: ["uno", "headline", "country"] }`,
86
94
  inputSchema,
87
95
  handler: async (
88
- apicore: ApiCore,
96
+ apicore: Pick<ApiCore, 'search'>,
89
97
  { preset, format = 'markdown', fields, fullText = false, query, size = DEFAULT_SEARCH_SIZE, sortOrder = 'desc', offset, facets }: SearchInput,
90
98
  ) => {
91
99
  try {
92
100
  const facetFilters = {
93
101
  class: ['text'],
102
+ provider: ['afp'],
103
+ langs: ['fr'],
94
104
  genreid: GENRE_EXCLUSIONS,
95
105
  ...(facets ?? {}),
96
106
  };
97
107
 
98
- let request: Record<string, unknown> = {
108
+ let request: SearchQueryParams = {
99
109
  query,
100
110
  size,
101
111
  sortOrder,
@@ -111,9 +121,9 @@ Examples:
111
121
  const outputFields: string[] = fields ?? [...DEFAULT_OUTPUT_FIELDS];
112
122
  const apiFields = format === 'markdown'
113
123
  ? [...MARKDOWN_API_FIELDS]
114
- : [...new Set(['afpshortid', 'uno', ...outputFields])];
124
+ : [...new Set(outputFields)];
115
125
 
116
- const { documents, count } = await apicore.search(request as any, apiFields);
126
+ const { documents, count } = await apicore.search(request, apiFields);
117
127
  if (count === 0) {
118
128
  return { content: [textContent('No results found.')] };
119
129
  }
@@ -124,7 +134,7 @@ Examples:
124
134
  fields: outputFields,
125
135
  fullText: effectiveFullText,
126
136
  jsonMeta: { total: count, offset: currentOffset },
127
- markdownPrefix: [textContent(buildPaginationLine(documents.length, count, currentOffset))],
137
+ markdownPrefix: (shown) => [textContent(buildPaginationLine(shown, count, currentOffset))],
128
138
  });
129
139
  } catch (error) {
130
140
  return toolError(formatErrorMessage('searching AFP articles', error, 'Check your query parameters and try again.'));
@@ -1,13 +1,10 @@
1
1
  import { z } from 'zod';
2
- import type { ApiCore } from 'afpnews-api';
3
- import { textContent, toolError, TRUNCATION_HINT, buildPaginationLine } from '../utils/format.js';
2
+ import type { ApiCore, SearchQueryParams } from 'afpnews-api';
3
+ import { textContent, toolError, buildPaginationLine } from '../utils/format.js';
4
4
  import {
5
- formatMediaDocument,
6
- formatMediaDocumentsAsJson,
7
- formatMediaDocumentsAsCsv,
8
- extractRenditions,
5
+ formatMediaOutput,
6
+ normalizeMediaDocument,
9
7
  } from '../utils/format-media.js';
10
- import type { AFPMediaDocument } from '../utils/types.js';
11
8
  import { DEFAULT_SEARCH_SIZE } from '../utils/types.js';
12
9
  import {
13
10
  mediaClassEnum,
@@ -32,7 +29,7 @@ const inputSchema = z.object({
32
29
  sortOrder: z.enum(['asc', 'desc']).optional().describe("Sort order by date (default 'desc')"),
33
30
  format: outputFormatEnum.optional().describe('Output format: markdown (default), json, or csv'),
34
31
  facets: z.record(z.string(), facetParamValueSchema).optional().describe(
35
- "Additional AFP facet filters (e.g. { lang: ['fr'], country: ['fra'], dateFrom: '2026-01-01' })"
32
+ "Additional AFP facet filters (e.g. { langs: ['fr'], country: ['fra'], dateFrom: '2026-01-01' })"
36
33
  ),
37
34
  }).strict().superRefine((value, ctx) => {
38
35
  for (const key of Object.keys(value.facets ?? {})) {
@@ -48,29 +45,13 @@ const inputSchema = z.object({
48
45
 
49
46
  type SearchMediaInput = z.infer<typeof inputSchema>;
50
47
 
51
- function buildMediaDocument(raw: any): AFPMediaDocument {
52
- return {
53
- uno: raw.uno,
54
- title: raw.title,
55
- caption: Array.isArray(raw.caption) ? raw.caption[0] : raw.caption,
56
- creditLine: raw.creditLine,
57
- creator: raw.creator,
58
- country: raw.country,
59
- city: raw.city,
60
- published: raw.published,
61
- urgency: raw.urgency,
62
- class: raw.class,
63
- aspectRatios: raw.aspectRatios,
64
- advisory: raw.advisory,
65
- renditions: extractRenditions(raw.bagItem ?? []),
66
- };
67
- }
68
-
69
48
  export const afpSearchMediaTool = {
70
49
  name: 'afp_search_media',
71
50
  title: 'Search AFP Media (Photos, Videos, Graphics)',
72
51
  description: `Search AFP media documents: photos, videos, infographics, and motion design.
73
52
 
53
+ Use this tool only when the user explicitly asks for media assets. For general news retrieval, prefer afp_search_articles.
54
+
74
55
  Media classes:
75
56
  - picture: AFP photos. Captions are always in English — do not filter by lang, or use lang=en.
76
57
  - video: AFP video clips. No language constraint.
@@ -84,10 +65,15 @@ Args:
84
65
  - offset: Pagination offset
85
66
  - sortOrder: 'asc' or 'desc' (default 'desc')
86
67
  - format: markdown (default, with inline thumbnails), json (structured with rendition URLs), csv
87
- - facets: Additional AFP filters (e.g. { lang: ['fr'], country: ['fra'], dateFrom: '2026-01-01' })
68
+ - facets: Additional AFP filters (e.g. { langs: ['fr'], country: ['fra'], dateFrom: '2026-01-01' }).
69
+ Defaults: provider=afp. Override provider only when partner media is explicitly needed.
70
+
71
+ Pagination:
72
+ Use \`offset\` to paginate (e.g. offset=10 to skip the first 10).
73
+ Keep \`size\` small (10–20) for best performance.
88
74
 
89
75
  Returns (json):
90
- { total, shown, offset, truncated, documents: [{ uno, title, caption, creditLine, creator,
76
+ { total, shown, offset, truncated, remaining, documents: [{ uno, title, caption, creditLine, creator,
91
77
  country, city, published, urgency, class, aspectRatios, advisory,
92
78
  renditions: { thumbnail, preview, highdef } }] }
93
79
 
@@ -98,56 +84,38 @@ Rendition sizes:
98
84
 
99
85
  Examples:
100
86
  - AFP football photos: { class: "picture", query: "football" }
101
- - French infographics on economy: { class: "graphic", query: "économie", facets: { lang: ["fr"] } }
87
+ - French infographics on economy: { class: "graphic", query: "économie", facets: { langs: ["fr"] } }
102
88
  - All media on a topic: { query: "climate protest", format: "json" }
103
89
  - Export gallery CSV: { class: "picture", query: "Paris", format: "csv" }`,
104
90
  inputSchema,
105
91
  handler: async (
106
- apicore: ApiCore,
92
+ apicore: Pick<ApiCore, 'search'>,
107
93
  { class: mediaClass, query, size = DEFAULT_SEARCH_SIZE, offset, sortOrder = 'desc', format = 'markdown', facets }: SearchMediaInput,
108
94
  ) => {
109
95
  try {
110
96
  const classFilter = mediaClass ? [mediaClass] : ['picture', 'video', 'graphic', 'videography'];
111
- const request: Record<string, unknown> = {
97
+ const request: SearchQueryParams = {
112
98
  query,
113
99
  size,
114
100
  sortOrder,
115
101
  startAt: offset,
116
102
  class: classFilter,
103
+ provider: ['afp'],
117
104
  ...(facets ?? {}),
118
105
  };
119
106
 
120
- const { documents: rawDocs, count } = await apicore.search(request as any, [...MEDIA_API_FIELDS]);
107
+ const { documents: rawDocs, count } = await apicore.search(request, [...MEDIA_API_FIELDS]);
121
108
 
122
109
  if (count === 0) {
123
110
  return { content: [textContent('No results found.')] };
124
111
  }
125
112
 
126
- const docs = (rawDocs as any[]).map(buildMediaDocument);
113
+ const docs = rawDocs.map(normalizeMediaDocument);
127
114
  const currentOffset = offset ?? 0;
128
-
129
- if (format === 'json') {
130
- const { content, truncated } = formatMediaDocumentsAsJson(docs, { total: count, offset: currentOffset });
131
- const result = [content];
132
- if (truncated) result.push(textContent(TRUNCATION_HINT));
133
- return { content: result };
134
- }
135
-
136
- if (format === 'csv') {
137
- const { content, truncated } = formatMediaDocumentsAsCsv(docs);
138
- const result = [content];
139
- if (truncated) result.push(textContent(TRUNCATION_HINT));
140
- return { content: result };
141
- }
142
-
143
- // markdown
144
- const items = docs.map(formatMediaDocument);
145
- return {
146
- content: [
147
- textContent(buildPaginationLine(docs.length, count, currentOffset)),
148
- ...items,
149
- ],
150
- };
115
+ return formatMediaOutput(docs, format, {
116
+ jsonMeta: { total: count, offset: currentOffset },
117
+ markdownPrefix: (shown) => [textContent(buildPaginationLine(shown, count, currentOffset))],
118
+ });
151
119
  } catch (error) {
152
120
  return toolError(formatErrorMessage('searching AFP media', error, 'Check your query parameters and try again.'));
153
121
  }
@@ -72,7 +72,7 @@ export const GENRE_EXCLUSIONS = {
72
72
 
73
73
  interface PresetOverrides {
74
74
  class?: string[];
75
- lang?: string[];
75
+ langs?: string[];
76
76
  slug?: string[];
77
77
  dateFrom?: string;
78
78
  size?: number;
@@ -82,7 +82,7 @@ interface PresetOverrides {
82
82
  export const SEARCH_PRESETS: Record<SearchPreset, PresetOverrides> = {
83
83
  'a-la-une': {
84
84
  class: ['text'],
85
- lang: ['fr'],
85
+ langs: ['fr'],
86
86
  slug: ['afp', 'actualites'],
87
87
  dateFrom: 'now-1d',
88
88
  size: 1,