afpnews-mcp-server 2.0.2 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "afpnews-mcp-server",
|
|
3
|
-
"version": "2.0
|
|
3
|
+
"version": "2.1.0",
|
|
4
4
|
"description": "",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"type": "module",
|
|
@@ -28,12 +28,12 @@
|
|
|
28
28
|
"jose": "^6.2.2"
|
|
29
29
|
},
|
|
30
30
|
"peerDependencies": {
|
|
31
|
-
"afpnews-api": "^2.
|
|
31
|
+
"afpnews-api": "^2.5.0",
|
|
32
32
|
"zod": "^4.3.6"
|
|
33
33
|
},
|
|
34
34
|
"devDependencies": {
|
|
35
35
|
"@types/node": "^25.5.2",
|
|
36
|
-
"afpnews-api": "^2.
|
|
36
|
+
"afpnews-api": "^2.5.0",
|
|
37
37
|
"zod": "^4.3.6"
|
|
38
38
|
}
|
|
39
39
|
}
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { describe, expect, it, mock } from 'bun:test';
|
|
2
|
+
import * as actualApi from 'afpnews-api';
|
|
2
3
|
|
|
3
4
|
describe('createServer', () => {
|
|
4
5
|
it('authenticates with provided credentials', async () => {
|
|
5
6
|
const authenticateMock = mock().mockResolvedValue(undefined);
|
|
6
7
|
|
|
7
8
|
mock.module('afpnews-api', () => ({
|
|
9
|
+
...actualApi,
|
|
8
10
|
ApiCore: class {
|
|
9
11
|
token?: unknown;
|
|
10
12
|
config?: unknown;
|
|
@@ -23,6 +25,7 @@ describe('createServer', () => {
|
|
|
23
25
|
it('passes baseUrl to ApiCore when provided', async () => {
|
|
24
26
|
let capturedConfig: unknown;
|
|
25
27
|
mock.module('afpnews-api', () => ({
|
|
28
|
+
...actualApi,
|
|
26
29
|
ApiCore: class {
|
|
27
30
|
token?: unknown;
|
|
28
31
|
constructor(config?: unknown) { capturedConfig = config; }
|
|
@@ -40,6 +43,7 @@ describe('createServer', () => {
|
|
|
40
43
|
it('does not set baseUrl on ApiCore when omitted', async () => {
|
|
41
44
|
let capturedConfig: unknown;
|
|
42
45
|
mock.module('afpnews-api', () => ({
|
|
46
|
+
...actualApi,
|
|
43
47
|
ApiCore: class {
|
|
44
48
|
token?: unknown;
|
|
45
49
|
constructor(config?: unknown) { capturedConfig = config; }
|
|
@@ -56,6 +60,7 @@ describe('createServer', () => {
|
|
|
56
60
|
|
|
57
61
|
it('throws on missing credentials', async () => {
|
|
58
62
|
mock.module('afpnews-api', () => ({
|
|
63
|
+
...actualApi,
|
|
59
64
|
ApiCore: class {
|
|
60
65
|
token?: unknown;
|
|
61
66
|
constructor() {}
|
|
@@ -27,6 +27,20 @@ export const FIXTURE_DOC_MINIMAL: AFPDocument = {
|
|
|
27
27
|
news: ['Only one paragraph.'],
|
|
28
28
|
};
|
|
29
29
|
|
|
30
|
+
export const FIXTURE_VIDEO_DOC: AFPDocument = {
|
|
31
|
+
uno: 'AFP-TEST-VID-001',
|
|
32
|
+
headline: 'Test Video Headline',
|
|
33
|
+
published: '2026-02-14T10:30:00Z',
|
|
34
|
+
lang: 'fr',
|
|
35
|
+
genre: 'STOCKSHOTS',
|
|
36
|
+
class: 'video',
|
|
37
|
+
news: [
|
|
38
|
+
'1. 00:00-00:12 Vue aérienne de la ville',
|
|
39
|
+
'2. 00:12-00:30 SOUNDBITE 1 - Jean Dupont, témoin',
|
|
40
|
+
'"Tout a commencé très vite"',
|
|
41
|
+
],
|
|
42
|
+
};
|
|
43
|
+
|
|
30
44
|
export function makeDocs(count: number): AFPDocument[] {
|
|
31
45
|
return Array.from({ length: count }, (_, i) => ({
|
|
32
46
|
uno: `AFP-TEST-${String(i + 1).padStart(3, '0')}`,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect } from 'bun:test';
|
|
2
|
-
import { formatDocument, MARKDOWN_API_FIELDS } from '../utils/format.js';
|
|
3
|
-
import { FIXTURE_DOC, FIXTURE_DOC_MINIMAL } from './fixtures.js';
|
|
2
|
+
import { formatDocument, formatFullArticle, formatShotList, MARKDOWN_API_FIELDS } from '../utils/format.js';
|
|
3
|
+
import { FIXTURE_DOC, FIXTURE_DOC_MINIMAL, FIXTURE_VIDEO_DOC } from './fixtures.js';
|
|
4
4
|
|
|
5
5
|
describe('formatDocument', () => {
|
|
6
6
|
it('returns { type: "text", text: string }', () => {
|
|
@@ -64,3 +64,36 @@ describe('MARKDOWN_API_FIELDS', () => {
|
|
|
64
64
|
expect(MARKDOWN_API_FIELDS).not.toContain('afpshortid');
|
|
65
65
|
});
|
|
66
66
|
});
|
|
67
|
+
|
|
68
|
+
describe('formatShotList', () => {
|
|
69
|
+
it('renders a timecoded shot list for a video document', () => {
|
|
70
|
+
const out = formatShotList(FIXTURE_VIDEO_DOC);
|
|
71
|
+
expect(out).toContain('## Shot list');
|
|
72
|
+
expect(out).toContain('1. [00:00-00:12] Vue aérienne de la ville');
|
|
73
|
+
expect(out).toContain('2. [00:12-00:30] Jean Dupont, témoin');
|
|
74
|
+
expect(out).toContain(' "Tout a commencé très vite"');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('returns null for non-video documents', () => {
|
|
78
|
+
expect(formatShotList(FIXTURE_DOC)).toBeNull();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('returns null for a video whose news has no parseable shot', () => {
|
|
82
|
+
expect(formatShotList({ class: 'video', news: ['no timecode here'] })).toBeNull();
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe('formatFullArticle', () => {
|
|
87
|
+
it('uses the shot list as body for a video document', () => {
|
|
88
|
+
const result = formatFullArticle(FIXTURE_VIDEO_DOC);
|
|
89
|
+
expect(result.text).toContain('## Shot list');
|
|
90
|
+
expect(result.text).toContain('[00:00-00:12] Vue aérienne de la ville');
|
|
91
|
+
expect(result.text).toContain('**Class:** video');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('keeps the plain text body for non-video documents', () => {
|
|
95
|
+
const result = formatFullArticle(FIXTURE_DOC);
|
|
96
|
+
expect(result.text).not.toContain('## Shot list');
|
|
97
|
+
expect(result.text).toContain('First paragraph of the article.');
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -6,7 +6,7 @@ import { registerTools } from '../tools/index.js';
|
|
|
6
6
|
import { registerResources } from '../resources/index.js';
|
|
7
7
|
import { registerPrompts } from '../prompts/index.js';
|
|
8
8
|
import type { ServerContext } from '../mcp-server.js';
|
|
9
|
-
import { FIXTURE_DOC, makeDocs } from './fixtures.js';
|
|
9
|
+
import { FIXTURE_DOC, FIXTURE_VIDEO_DOC, makeDocs } from './fixtures.js';
|
|
10
10
|
|
|
11
11
|
function createMockApicore() {
|
|
12
12
|
return {
|
|
@@ -256,6 +256,24 @@ describe('MCP integration', () => {
|
|
|
256
256
|
expect(text).not.toContain('**Country:**');
|
|
257
257
|
});
|
|
258
258
|
|
|
259
|
+
it('renders a timecoded shot list body for video documents', async () => {
|
|
260
|
+
apicore.get.mockResolvedValueOnce(FIXTURE_VIDEO_DOC);
|
|
261
|
+
const result = await client.callTool({ name: 'afp_get_article', arguments: { uno: 'AFP-TEST-VID-001' } });
|
|
262
|
+
const text = getText(result);
|
|
263
|
+
expect(text).toContain('## Shot list');
|
|
264
|
+
expect(text).toContain('1. [00:00-00:12] Vue aérienne de la ville');
|
|
265
|
+
expect(text).toContain('"Tout a commencé très vite"');
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it('returns the raw document as json when format=json', async () => {
|
|
269
|
+
apicore.get.mockResolvedValueOnce(FIXTURE_DOC);
|
|
270
|
+
const result = await client.callTool({ name: 'afp_get_article', arguments: { uno: 'AFP-TEST-001', format: 'json' } });
|
|
271
|
+
const text = getText(result);
|
|
272
|
+
const parsed = JSON.parse(text);
|
|
273
|
+
expect(parsed.uno).toBe('AFP-TEST-001');
|
|
274
|
+
expect(parsed.news).toEqual(FIXTURE_DOC.news);
|
|
275
|
+
});
|
|
276
|
+
|
|
259
277
|
it('returns isError on API failure', async () => {
|
|
260
278
|
apicore.get.mockRejectedValueOnce(new Error('Not found'));
|
|
261
279
|
const result = await client.callTool({ name: 'afp_get_article', arguments: { uno: 'BAD-UNO' } });
|
package/src/tools/get-article.ts
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import type { ApiCore } from 'afpnews-api';
|
|
3
|
-
import { formatFullArticle, toolError } from '../utils/format.js';
|
|
3
|
+
import { formatFullArticle, textContent, toolError } from '../utils/format.js';
|
|
4
4
|
import { formatErrorMessage, UNO_FORMAT_NOTE } from './shared.js';
|
|
5
5
|
|
|
6
6
|
const inputSchema = z.object({
|
|
7
7
|
uno: z.string().describe('The unique UNO identifier of the article'),
|
|
8
|
+
format: z
|
|
9
|
+
.enum(['markdown', 'json'])
|
|
10
|
+
.optional()
|
|
11
|
+
.describe('Output format: markdown (default, rendered article/shot list) or json (raw document, parse it yourself).'),
|
|
8
12
|
});
|
|
9
13
|
|
|
10
14
|
type GetArticleInput = z.infer<typeof inputSchema>;
|
|
@@ -37,11 +41,15 @@ Returns:
|
|
|
37
41
|
- Full article body (all paragraphs, no truncation)
|
|
38
42
|
|
|
39
43
|
Example:
|
|
40
|
-
{ uno: "newsml.afp.com.20260222T090659Z.doc-98hu39e" }
|
|
44
|
+
{ uno: "newsml.afp.com.20260222T090659Z.doc-98hu39e" }
|
|
45
|
+
{ uno: "newsml.afp.com.20260222T090659Z.doc-98hu39e", format: "json" }`,
|
|
41
46
|
inputSchema,
|
|
42
|
-
handler: async (apicore: Pick<ApiCore, 'get'>, { uno }: GetArticleInput) => {
|
|
47
|
+
handler: async (apicore: Pick<ApiCore, 'get'>, { uno, format = 'markdown' }: GetArticleInput) => {
|
|
43
48
|
try {
|
|
44
49
|
const doc = await apicore.get(uno);
|
|
50
|
+
if (format === 'json') {
|
|
51
|
+
return { content: [textContent(JSON.stringify(doc, null, 2))] };
|
|
52
|
+
}
|
|
45
53
|
return { content: [formatFullArticle(doc)] };
|
|
46
54
|
} catch (error) {
|
|
47
55
|
return toolError(formatErrorMessage(`fetching article "${uno}"`, error, 'Verify the UNO identifier is correct.'));
|
package/src/utils/format.ts
CHANGED
|
@@ -1,6 +1,31 @@
|
|
|
1
|
+
import { parseShotList } from 'afpnews-api';
|
|
1
2
|
import type { AFPDocument, TextContent } from './types.js';
|
|
2
3
|
import { EXCERPT_PARAGRAPH_COUNT, CHARACTER_LIMIT } from './types.js';
|
|
3
4
|
|
|
5
|
+
const VIDEO_CLASSES = new Set(['video', 'videography']);
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Render a video shot list (timecodes + descriptions + soundbite quotes) from
|
|
9
|
+
* the raw `news` lines. Returns `null` when the document is not a video or when
|
|
10
|
+
* no shot could be parsed, so callers can fall back to the plain text body.
|
|
11
|
+
*/
|
|
12
|
+
export function formatShotList(doc: unknown): string | null {
|
|
13
|
+
const d = doc as AFPDocument;
|
|
14
|
+
if (!VIDEO_CLASSES.has(String(d['class']))) return null;
|
|
15
|
+
|
|
16
|
+
const news = Array.isArray(d.news) ? d.news : [];
|
|
17
|
+
const shots = parseShotList(news.join('\n'));
|
|
18
|
+
if (shots.length === 0) return null;
|
|
19
|
+
|
|
20
|
+
const lines = shots.map((shot) => {
|
|
21
|
+
const head = `${shot.numero}. [${shot.start}-${shot.end}] ${shot.description}`.trimEnd();
|
|
22
|
+
const quotes = shot.citations.map((c) => ` "${c.text}"`);
|
|
23
|
+
return [head, ...quotes].join('\n');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
return `## Shot list\n\n${lines.join('\n')}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
4
29
|
export function truncationHint(remaining?: number): string {
|
|
5
30
|
const suffix = remaining != null && remaining > 0
|
|
6
31
|
? ` ${remaining} additional item(s) not returned.`
|
|
@@ -163,7 +188,7 @@ export function formatFullArticle(doc: unknown): TextContent {
|
|
|
163
188
|
if (flags) lines.push(flags);
|
|
164
189
|
|
|
165
190
|
const meta = lines.join('\n');
|
|
166
|
-
const body = (Array.isArray(d.news) ? d.news : []).join('\n\n');
|
|
191
|
+
const body = formatShotList(d) ?? (Array.isArray(d.news) ? d.news : []).join('\n\n');
|
|
167
192
|
|
|
168
193
|
return textContent(`## ${d.headline}\n\n${meta}\n\n---\n\n${body}`);
|
|
169
194
|
}
|