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.
- package/.serena/project.yml +57 -52
- package/README.md +5 -1
- package/bun.lock +17 -13
- package/package.json +10 -7
- package/src/__tests__/create-server.test.ts +44 -46
- package/src/__tests__/format-media.test.ts +8 -8
- package/src/__tests__/format.test.ts +3 -3
- package/src/__tests__/get-media-handler.test.ts +120 -0
- package/src/__tests__/get-media.test.ts +1 -1
- package/src/__tests__/list-facets.test.ts +52 -0
- package/src/__tests__/server.test.ts +24 -13
- package/src/__tests__/tool-defaults.test.ts +53 -0
- package/src/__tests__/types-media.test.ts +1 -1
- package/src/prompts/country-news.ts +1 -1
- package/src/tools/find-similar.ts +2 -2
- package/src/tools/get-article.ts +1 -1
- package/src/tools/get-media.ts +7 -5
- package/src/tools/index.ts +1 -1
- package/src/tools/list-facets.ts +19 -14
- package/src/tools/search-articles.ts +22 -12
- package/src/tools/search-media.ts +24 -56
- package/src/tools/shared.ts +2 -2
- package/src/utils/format-media.ts +67 -16
- package/src/utils/format.ts +74 -60
- package/src/utils/types.ts +1 -1
- package/tsconfig.json +1 -1
- package/tsconfig.test.json +9 -0
- package/docs/plans/2026-03-08-auth-refactor.md +0 -740
- package/docs/superpowers/plans/2026-03-17-bun-elysia-migration.md +0 -1067
- package/docs/superpowers/plans/2026-03-17-media-tools.md +0 -1259
- package/docs/superpowers/specs/2026-03-17-bun-elysia-migration-design.md +0 -220
- package/docs/superpowers/specs/2026-03-17-media-tools-design.md +0 -404
|
@@ -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
|
|
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('
|
|
78
|
-
expect(msg.text).not.toContain('
|
|
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.
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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]
|
|
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 '
|
|
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: {
|
|
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:
|
|
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) {
|
package/src/tools/get-article.ts
CHANGED
|
@@ -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)] };
|
package/src/tools/get-media.ts
CHANGED
|
@@ -47,7 +47,7 @@ const inputSchema = z.object({
|
|
|
47
47
|
|
|
48
48
|
type GetMediaInput = z.infer<typeof inputSchema>;
|
|
49
49
|
|
|
50
|
-
function formatFullMediaText(doc:
|
|
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
|
-
|
|
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
|
|
101
|
-
if (!
|
|
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));
|
package/src/tools/index.ts
CHANGED
|
@@ -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
|
|
35
|
+
async (args: Record<string, unknown>) => (tool.handler as any)(ctx.apicore, args),
|
|
36
36
|
);
|
|
37
37
|
}
|
|
38
38
|
}
|
package/src/tools/list-facets.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import type { ApiCore } from 'afpnews-api';
|
|
3
|
-
import { escapeCsvValue, textContent, toolError, truncateToLimit,
|
|
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:
|
|
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
|
|
60
|
-
const results: FacetResult[] =
|
|
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(
|
|
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(
|
|
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
|
|
89
|
-
|
|
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:
|
|
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. {
|
|
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
|
|
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. {
|
|
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: {
|
|
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:
|
|
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(
|
|
124
|
+
: [...new Set(outputFields)];
|
|
115
125
|
|
|
116
|
-
const { documents, count } = await apicore.search(request
|
|
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(
|
|
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,
|
|
2
|
+
import type { ApiCore, SearchQueryParams } from 'afpnews-api';
|
|
3
|
+
import { textContent, toolError, buildPaginationLine } from '../utils/format.js';
|
|
4
4
|
import {
|
|
5
|
-
|
|
6
|
-
|
|
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. {
|
|
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. {
|
|
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: {
|
|
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:
|
|
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
|
|
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 =
|
|
113
|
+
const docs = rawDocs.map(normalizeMediaDocument);
|
|
127
114
|
const currentOffset = offset ?? 0;
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
}
|
package/src/tools/shared.ts
CHANGED
|
@@ -72,7 +72,7 @@ export const GENRE_EXCLUSIONS = {
|
|
|
72
72
|
|
|
73
73
|
interface PresetOverrides {
|
|
74
74
|
class?: string[];
|
|
75
|
-
|
|
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
|
-
|
|
85
|
+
langs: ['fr'],
|
|
86
86
|
slug: ['afp', 'actualites'],
|
|
87
87
|
dateFrom: 'now-1d',
|
|
88
88
|
size: 1,
|