afpnews-mcp-server 2.0.0 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -133,3 +133,17 @@ read_only_memory_patterns: []
133
133
  # Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default)
134
134
  # This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings.
135
135
  line_ending:
136
+
137
+ # list of regex patterns for memories to completely ignore.
138
+ # Matching memories will not appear in list_memories or activate_project output
139
+ # and cannot be accessed via read_memory or write_memory.
140
+ # To access ignored memory files, use the read_file tool on the raw file path.
141
+ # Extends the list from the global configuration, merging the two lists.
142
+ # Example: ["_archive/.*", "_episodes/.*"]
143
+ ignored_memory_patterns: []
144
+
145
+ # advanced configuration option allowing to configure language server-specific options.
146
+ # Maps the language key to the options.
147
+ # Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available.
148
+ # No documentation on options means no options are available.
149
+ ls_specific_settings: {}
package/README.md CHANGED
@@ -138,7 +138,11 @@ The `afp_search_articles` tool supports presets that apply predefined filters:
138
138
 
139
139
  ### Full text
140
140
 
141
- By default, `afp_search_articles` returns excerpts (first 4 paragraphs). Set `fullText: true` to get the complete article body. Presets default to full text.
141
+ By default, `afp_search_articles` returns excerpts (first 2 paragraphs). Set `fullText: true` to get the complete article body. Presets default to full text.
142
+
143
+ ### Pagination
144
+
145
+ Use `offset` to paginate through results (e.g. `offset: 10` to skip the first 10). For large chronological scans, prefer narrowing `dateFrom`/`dateTo` ranges in `facets` over high offsets. Keep `size` small (10–20) for best performance.
142
146
 
143
147
  ## Prompts
144
148
 
package/bun.lock CHANGED
@@ -5,15 +5,19 @@
5
5
  "": {
6
6
  "name": "afpnews-mcp-server",
7
7
  "dependencies": {
8
- "@modelcontextprotocol/sdk": "^1.27.1",
9
- "afpnews-api": "^2.4.0",
8
+ "@modelcontextprotocol/sdk": "^1.29.0",
10
9
  "elysia": "^1.4.28",
11
10
  "elysia-rate-limit": "^4.5.1",
12
- "jose": "^6.2.1",
13
- "zod": "^4.3.6",
11
+ "jose": "^6.2.2",
14
12
  },
15
13
  "devDependencies": {
16
- "@types/node": "^25.5.0",
14
+ "@types/node": "^25.5.2",
15
+ "afpnews-api": "^2.4.1",
16
+ "zod": "^4.3.6",
17
+ },
18
+ "peerDependencies": {
19
+ "afpnews-api": "^2.4.0",
20
+ "zod": "^4.3.6",
17
21
  },
18
22
  },
19
23
  },
@@ -24,7 +28,7 @@
24
28
 
25
29
  "@hono/node-server": ["@hono/node-server@1.19.9", "", { "peerDependencies": { "hono": "4.11.9" } }, "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw=="],
26
30
 
27
- "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.27.1", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA=="],
31
+ "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="],
28
32
 
29
33
  "@sinclair/typebox": ["@sinclair/typebox@0.34.48", "", {}, "sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA=="],
30
34
 
@@ -38,13 +42,13 @@
38
42
 
39
43
  "@types/nearley": ["@types/nearley@2.11.5", "", {}, "sha512-dM7TrN0bVxGGXTYGx4YhGear8ysLO5SOuouAWM9oltjQ3m9oYa13qi8Z1DJp5zxVMPukvQdsrnZmgzpeuTSEQA=="],
40
44
 
41
- "@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
45
+ "@types/node": ["@types/node@25.5.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg=="],
42
46
 
43
47
  "@types/statuses": ["@types/statuses@2.0.6", "", {}, "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA=="],
44
48
 
45
49
  "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "3.0.1", "negotiator": "1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
46
50
 
47
- "afpnews-api": ["afpnews-api@2.4.0", "", { "dependencies": { "@types/btoa-lite": "1.0.2", "@types/moo": "0.5.10", "@types/nearley": "2.11.5", "@types/node": "25.2.3", "@types/statuses": "2.0.6", "btoa-lite": "1.0.0", "events": "3.3.0", "moo": "0.5.2", "nearley": "2.20.1", "statuses": "2.0.2", "zod": "4.3.6" } }, "sha512-VErqy0P5h2sjVjaSoIHF1uFsKx16PYKZUFBCCUmHsPdPM99lQhk0ClibPU7Wk9i34BtUiqLcQxrmTdCOj9ikpA=="],
51
+ "afpnews-api": ["afpnews-api@2.4.1", "", { "dependencies": { "@types/btoa-lite": "^1.0.2", "@types/moo": "^0.5.10", "@types/nearley": "^2.11.5", "@types/node": "^25.5.0", "@types/statuses": "^2.0.6", "btoa-lite": "^1.0.0", "events": "^3.3.0", "moo": "^0.5.3", "nearley": "^2.20.1", "statuses": "^2.0.2", "zod": "^4.3.6" } }, "sha512-A/1vZeq/qqLph11XYZbXqhs0qUSQ9zi/4YK2a+XVpn5iF6q1VXWABW19lL0X2mZM7RsdsEUW+rMIhDmHbdFsBw=="],
48
52
 
49
53
  "ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "3.1.3", "fast-uri": "3.1.0", "json-schema-traverse": "1.0.0", "require-from-string": "2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="],
50
54
 
@@ -156,7 +160,7 @@
156
160
 
157
161
  "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
158
162
 
159
- "jose": ["jose@6.2.1", "", {}, "sha512-jUaKr1yrbfaImV7R2TN/b3IcZzsw38/chqMpo2XJ7i2F8AfM/lA4G1goC3JVEwg0H7UldTmSt3P68nt31W7/mw=="],
163
+ "jose": ["jose@6.2.2", "", {}, "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ=="],
160
164
 
161
165
  "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
162
166
 
@@ -174,7 +178,7 @@
174
178
 
175
179
  "mime-types": ["mime-types@3.0.1", "", { "dependencies": { "mime-db": "1.54.0" } }, "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA=="],
176
180
 
177
- "moo": ["moo@0.5.2", "", {}, "sha512-iSAJLHYKnX41mKcJKjqvnAN9sf0LMDTXDEvFv+ffuRR9a1MIuXLjMNL6EsnDHSkKLTWNqQQ5uo61P4EbU4NU+Q=="],
181
+ "moo": ["moo@0.5.3", "", {}, "sha512-m2fmM2dDm7GZQsY7KK2cme8agi+AAljILjQnof7p1ZMDe6dQ4bdnSMx0cPppudoeNv5hEFQirN6u+O4fDE0IWA=="],
178
182
 
179
183
  "ms": ["ms@2.1.2", "", {}, "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="],
180
184
 
@@ -270,7 +274,7 @@
270
274
 
271
275
  "@tokenizer/inflate/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
272
276
 
273
- "afpnews-api/@types/node": ["@types/node@25.2.3", "", { "dependencies": { "undici-types": "7.16.0" } }, "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ=="],
277
+ "afpnews-api/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
274
278
 
275
279
  "body-parser/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
276
280
 
@@ -280,6 +284,8 @@
280
284
 
281
285
  "finalhandler/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
282
286
 
287
+ "nearley/moo": ["moo@0.5.2", "", {}, "sha512-iSAJLHYKnX41mKcJKjqvnAN9sf0LMDTXDEvFv+ffuRR9a1MIuXLjMNL6EsnDHSkKLTWNqQQ5uo61P4EbU4NU+Q=="],
288
+
283
289
  "router/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
284
290
 
285
291
  "send/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
@@ -288,8 +294,6 @@
288
294
 
289
295
  "@tokenizer/inflate/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
290
296
 
291
- "afpnews-api/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
292
-
293
297
  "body-parser/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
294
298
 
295
299
  "express/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "afpnews-mcp-server",
3
- "version": "2.0.0",
3
+ "version": "2.0.1",
4
4
  "description": "",
5
5
  "main": "src/index.ts",
6
6
  "type": "module",
@@ -12,25 +12,28 @@
12
12
  "dev": "bun --watch src/index.ts",
13
13
  "start": "bun src/index.ts",
14
14
  "typecheck": "tsc --noEmit",
15
- "test": "bun test src/__tests__/format.test.ts src/__tests__/format-media.test.ts src/__tests__/types-media.test.ts src/__tests__/index-auth.test.ts src/__tests__/server.test.ts src/__tests__/get-media.test.ts && bun test src/__tests__/create-server.test.ts"
15
+ "typecheck:test": "tsc --noEmit -p tsconfig.test.json",
16
+ "test": "bun test src/__tests__/",
17
+ "test:coverage": "bun test --coverage --coverage-reporter=text src/__tests__/",
18
+ "test:coverage:lcov": "bun test --coverage --coverage-reporter=text --coverage-reporter=lcov --coverage-dir coverage src/__tests__/"
16
19
  },
17
20
  "keywords": [],
18
21
  "author": "",
19
22
  "license": "ISC",
20
- "packageManager": "bun@1.3.10",
23
+ "packageManager": "bun@1.3.11",
21
24
  "dependencies": {
22
- "@modelcontextprotocol/sdk": "^1.27.1",
25
+ "@modelcontextprotocol/sdk": "^1.29.0",
23
26
  "elysia": "^1.4.28",
24
27
  "elysia-rate-limit": "^4.5.1",
25
- "jose": "^6.2.1"
28
+ "jose": "^6.2.2"
26
29
  },
27
30
  "peerDependencies": {
28
31
  "afpnews-api": "^2.4.0",
29
32
  "zod": "^4.3.6"
30
33
  },
31
34
  "devDependencies": {
32
- "@types/node": "^25.5.0",
33
- "afpnews-api": "^2.4.0",
35
+ "@types/node": "^25.5.2",
36
+ "afpnews-api": "^2.4.1",
34
37
  "zod": "^4.3.6"
35
38
  }
36
39
  }
@@ -1,74 +1,72 @@
1
- import { afterAll, beforeEach, describe, expect, it, mock } from 'bun:test';
2
-
3
- // Define shared mock functions at module scope
4
- const authenticateMock = mock();
5
- const apiCoreInstances: Array<{ token?: unknown; config?: unknown }> = [];
6
- const registerToolsMock = mock();
7
- const registerResourcesMock = mock();
8
- const registerPromptsMock = mock();
9
-
10
- // mock.module() is hoisted by bun:test before imports are resolved
11
- mock.module('afpnews-api', () => {
12
- class MockApiCore {
13
- token?: unknown;
14
- config?: unknown;
15
-
16
- constructor(config?: unknown) {
17
- this.config = config;
18
- apiCoreInstances.push(this);
19
- }
20
-
21
- authenticate = authenticateMock;
22
- }
23
-
24
- return { ApiCore: MockApiCore };
25
- });
26
-
27
- mock.module('../tools/index.js', () => ({ registerTools: registerToolsMock }));
28
- mock.module('../resources/index.js', () => ({ registerResources: registerResourcesMock }));
29
- mock.module('../prompts/index.js', () => ({ registerPrompts: registerPromptsMock }));
30
-
31
- // Restore module mocks after all tests in this file
32
- afterAll(() => mock.restore());
1
+ import { describe, expect, it, mock } from 'bun:test';
33
2
 
34
3
  describe('createServer', () => {
35
- beforeEach(() => {
36
- authenticateMock.mockReset();
37
- authenticateMock.mockResolvedValue(undefined);
38
- registerToolsMock.mockReset();
39
- registerResourcesMock.mockReset();
40
- registerPromptsMock.mockReset();
41
- apiCoreInstances.length = 0;
42
- });
43
-
44
4
  it('authenticates with provided credentials', async () => {
45
- // Dynamic import ensures mock.module() is active when the module is loaded
5
+ const authenticateMock = mock().mockResolvedValue(undefined);
6
+
7
+ mock.module('afpnews-api', () => ({
8
+ ApiCore: class {
9
+ token?: unknown;
10
+ config?: unknown;
11
+ constructor(config?: unknown) { this.config = config; }
12
+ authenticate = authenticateMock;
13
+ },
14
+ }));
15
+
46
16
  const { createServer } = await import('../mcp-server.js');
47
17
  await createServer({ apiKey: 'api-key', username: 'user', password: 'pass' });
48
18
 
49
- expect(apiCoreInstances).toHaveLength(1);
50
- expect(apiCoreInstances[0].config).toEqual({ apiKey: 'api-key' });
51
19
  expect(authenticateMock).toHaveBeenCalledWith({ username: 'user', password: 'pass' });
20
+ mock.restore();
52
21
  });
53
22
 
54
23
  it('passes baseUrl to ApiCore when provided', async () => {
24
+ let capturedConfig: unknown;
25
+ mock.module('afpnews-api', () => ({
26
+ ApiCore: class {
27
+ token?: unknown;
28
+ constructor(config?: unknown) { capturedConfig = config; }
29
+ authenticate = mock().mockResolvedValue(undefined);
30
+ },
31
+ }));
32
+
55
33
  const { createServer } = await import('../mcp-server.js');
56
34
  await createServer({ apiKey: 'api-key', username: 'user', password: 'pass', baseUrl: 'https://custom.api.com' });
57
35
 
58
- expect(apiCoreInstances[0].config).toEqual({ apiKey: 'api-key', baseUrl: 'https://custom.api.com' });
36
+ expect(capturedConfig).toEqual({ apiKey: 'api-key', baseUrl: 'https://custom.api.com' });
37
+ mock.restore();
59
38
  });
60
39
 
61
40
  it('does not set baseUrl on ApiCore when omitted', async () => {
41
+ let capturedConfig: unknown;
42
+ mock.module('afpnews-api', () => ({
43
+ ApiCore: class {
44
+ token?: unknown;
45
+ constructor(config?: unknown) { capturedConfig = config; }
46
+ authenticate = mock().mockResolvedValue(undefined);
47
+ },
48
+ }));
49
+
62
50
  const { createServer } = await import('../mcp-server.js');
63
51
  await createServer({ apiKey: 'api-key', username: 'user', password: 'pass' });
64
52
 
65
- expect(apiCoreInstances[0].config).not.toHaveProperty('baseUrl');
53
+ expect(capturedConfig).not.toHaveProperty('baseUrl');
54
+ mock.restore();
66
55
  });
67
56
 
68
57
  it('throws on missing credentials', async () => {
58
+ mock.module('afpnews-api', () => ({
59
+ ApiCore: class {
60
+ token?: unknown;
61
+ constructor() {}
62
+ authenticate = mock().mockResolvedValue(undefined);
63
+ },
64
+ }));
65
+
69
66
  const { createServer } = await import('../mcp-server.js');
70
67
  await expect(
71
68
  createServer({ apiKey: 'api-key', username: 'user', password: '' }),
72
69
  ).rejects.toThrow('Missing authentication');
70
+ mock.restore();
73
71
  });
74
72
  });
@@ -1,4 +1,4 @@
1
- import { describe, it, expect } from 'vitest';
1
+ import { describe, it, expect } from 'bun:test';
2
2
  import { extractRenditions, formatMediaDocument, formatMediaDocumentsAsJson, formatMediaDocumentsAsCsv, MEDIA_RENDITION_ROLE_MAP } from '../utils/format-media.js';
3
3
 
4
4
  // Fixture bagItem reprenant la structure réelle AFP
@@ -148,7 +148,7 @@ describe('formatMediaDocumentsAsCsv', () => {
148
148
  class: 'picture',
149
149
  renditions: { thumbnail: { href: 'https://example.com/t.jpg', width: 320, height: 213 } },
150
150
  }];
151
- const r = formatMediaDocumentsAsCsv(docs as any);
151
+ const r = formatMediaDocumentsAsCsv(docs);
152
152
  expect(r.content.text).toContain('newsml.test');
153
153
  expect(r.content.text).toContain('My Photo');
154
154
  expect(r.content.text).toContain('JANE / AFP');
@@ -161,13 +161,13 @@ describe('formatMediaDocumentsAsCsv', () => {
161
161
  caption: 'A caption, with "quotes"',
162
162
  renditions: {},
163
163
  }];
164
- const r = formatMediaDocumentsAsCsv(docs as any);
164
+ const r = formatMediaDocumentsAsCsv(docs);
165
165
  expect(r.content.text).toContain('"A caption, with ""quotes"""');
166
166
  });
167
167
 
168
168
  it('handles missing thumbnail href gracefully', () => {
169
169
  const docs = [{ uno: 'newsml.test', renditions: {} }];
170
- const r = formatMediaDocumentsAsCsv(docs as any);
170
+ const r = formatMediaDocumentsAsCsv(docs);
171
171
  expect(r.content.text).toContain('newsml.test');
172
172
  });
173
173
  });
@@ -179,13 +179,13 @@ describe('formatMediaDocumentsAsJson', () => {
179
179
  ];
180
180
 
181
181
  it('returns { content: TextContent, truncated: boolean }', () => {
182
- const r = formatMediaDocumentsAsJson(docs as any);
182
+ const r = formatMediaDocumentsAsJson(docs);
183
183
  expect(r.content.type).toBe('text');
184
184
  expect(typeof r.truncated).toBe('boolean');
185
185
  });
186
186
 
187
187
  it('output JSON contains total metadata and documents array', () => {
188
- const r = formatMediaDocumentsAsJson(docs as any, { total: 100, offset: 0 });
188
+ const r = formatMediaDocumentsAsJson(docs, { total: 100, offset: 0 });
189
189
  const parsed = JSON.parse(r.content.text);
190
190
  expect(parsed.total).toBe(100);
191
191
  expect(parsed.offset).toBe(0);
@@ -196,7 +196,7 @@ describe('formatMediaDocumentsAsJson', () => {
196
196
  });
197
197
 
198
198
  it('truncated flag is false when content is small', () => {
199
- const r = formatMediaDocumentsAsJson(docs as any, { total: 100, offset: 0 });
199
+ const r = formatMediaDocumentsAsJson(docs, { total: 100, offset: 0 });
200
200
  expect(r.truncated).toBe(false);
201
201
  });
202
202
 
@@ -208,7 +208,7 @@ describe('formatMediaDocumentsAsJson', () => {
208
208
  caption: 'A very long caption that also takes up considerable space in the JSON output here',
209
209
  renditions: { thumbnail: { href: `https://example.com/thumb-${i}.jpg`, width: 320, height: 213 } },
210
210
  }));
211
- const r = formatMediaDocumentsAsJson(largeDocs as any, { total: 500, offset: 0 });
211
+ const r = formatMediaDocumentsAsJson(largeDocs, { total: 500, offset: 0 });
212
212
  expect(r.truncated).toBe(true);
213
213
  const parsed = JSON.parse(r.content.text);
214
214
  expect(parsed.shown).toBeLessThan(500);
@@ -38,10 +38,10 @@ describe('formatDocument', () => {
38
38
  expect(result.text).not.toContain('Advisory:');
39
39
  });
40
40
 
41
- it('truncates to 4 paragraphs when fullText=false', () => {
41
+ it('truncates to 2 paragraphs when fullText=false', () => {
42
42
  const result = formatDocument(FIXTURE_DOC, false);
43
- expect(result.text).toContain('Fourth paragraph wraps up.');
44
- expect(result.text).not.toContain('Fifth paragraph is extra content.');
43
+ expect(result.text).toContain('Second paragraph with more details.');
44
+ expect(result.text).not.toContain('Third paragraph continues.');
45
45
  });
46
46
 
47
47
  it('includes all paragraphs when fullText=true', () => {
@@ -0,0 +1,120 @@
1
+ import { beforeEach, describe, expect, it, mock } from 'bun:test';
2
+ import { afpGetMediaTool } from '../tools/get-media.js';
3
+
4
+ const ORIGINAL_FETCH = globalThis.fetch;
5
+
6
+ function getText(result: any, index = 0): string {
7
+ return result.content[index]?.text ?? '';
8
+ }
9
+
10
+ describe('afpGetMediaTool.handler', () => {
11
+ beforeEach(() => {
12
+ globalThis.fetch = ORIGINAL_FETCH;
13
+ });
14
+
15
+ it('returns error when media document is missing', async () => {
16
+ const apicore = { get: mock().mockResolvedValue(undefined) };
17
+
18
+ const result = await afpGetMediaTool.handler(apicore, { uno: 'MISSING' });
19
+
20
+ expect('isError' in result && result.isError).toBe(true);
21
+ expect(getText(result)).toContain('Media document not found');
22
+ });
23
+
24
+ it('returns metadata only when embed=false', async () => {
25
+ const apicore = {
26
+ get: mock().mockResolvedValue({
27
+ uno: 'MEDIA-001',
28
+ title: 'Photo',
29
+ class: 'picture',
30
+ creditLine: 'AFP',
31
+ bagItem: [{ medias: [{ role: 'Thumbnail', href: 'https://example.com/thumb.jpg', width: 320, height: 200, type: 'Photo' }] }],
32
+ }),
33
+ };
34
+
35
+ const result = await afpGetMediaTool.handler(apicore, { uno: 'MEDIA-001' });
36
+
37
+ expect(result.content).toHaveLength(1);
38
+ expect(getText(result)).toContain('## Photo');
39
+ expect(getText(result)).toContain('thumbnail: https://example.com/thumb.jpg');
40
+ });
41
+
42
+ it('warns when trying to embed an SVG graphic', async () => {
43
+ const apicore = {
44
+ get: mock().mockResolvedValue({
45
+ uno: 'GRAPHIC-001',
46
+ title: 'Graphic',
47
+ class: 'graphic',
48
+ bagItem: [{ medias: [{ role: 'Preview', href: 'https://example.com/graphic.svg', width: 1200, height: 800, type: 'Graphic' }] }],
49
+ }),
50
+ };
51
+
52
+ const result = await afpGetMediaTool.handler(apicore, { uno: 'GRAPHIC-001', embed: true });
53
+
54
+ expect(result.content).toHaveLength(2);
55
+ expect(getText(result, 1)).toContain('SVG graphics cannot be embedded');
56
+ });
57
+
58
+ it('warns when no rendition is available for embedding', async () => {
59
+ const apicore = {
60
+ get: mock().mockResolvedValue({
61
+ uno: 'MEDIA-EMPTY',
62
+ title: 'Empty',
63
+ class: 'picture',
64
+ bagItem: [],
65
+ }),
66
+ };
67
+
68
+ const result = await afpGetMediaTool.handler(apicore, { uno: 'MEDIA-EMPTY', embed: true });
69
+
70
+ expect(result.content).toHaveLength(2);
71
+ expect(getText(result, 1)).toContain('no rendition available');
72
+ });
73
+
74
+ it('embeds a video thumbnail and adds the poster-frame note', async () => {
75
+ globalThis.fetch = mock().mockResolvedValue({
76
+ ok: true,
77
+ arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer,
78
+ headers: new Headers({ 'content-type': 'image/jpeg' }),
79
+ } as Response);
80
+
81
+ const apicore = {
82
+ get: mock().mockResolvedValue({
83
+ uno: 'VIDEO-001',
84
+ title: 'Video',
85
+ class: 'video',
86
+ bagItem: [{ medias: [{ role: 'Thumbnail', href: 'https://example.com/poster.jpg', width: 320, height: 180, type: 'Photo' }] }],
87
+ }),
88
+ };
89
+
90
+ const result = await afpGetMediaTool.handler(apicore, { uno: 'VIDEO-001', embed: true, rendition: 'highdef' });
91
+
92
+ expect(result.content).toHaveLength(2);
93
+ expect(getText(result)).toContain('poster frame only');
94
+ expect(result.content[1]).toEqual(expect.objectContaining({ type: 'image', mimeType: 'image/jpeg' }));
95
+ });
96
+
97
+ it('returns a warning when image fetch fails', async () => {
98
+ globalThis.fetch = mock().mockResolvedValue({
99
+ ok: false,
100
+ status: 403,
101
+ statusText: 'Forbidden',
102
+ headers: new Headers(),
103
+ } as Response);
104
+
105
+ const apicore = {
106
+ get: mock().mockResolvedValue({
107
+ uno: 'MEDIA-403',
108
+ title: 'Photo',
109
+ class: 'picture',
110
+ bagItem: [{ medias: [{ role: 'Preview', href: 'https://example.com/prev.jpg', width: 1200, height: 800, type: 'Photo' }] }],
111
+ }),
112
+ };
113
+
114
+ const result = await afpGetMediaTool.handler(apicore, { uno: 'MEDIA-403', embed: true });
115
+
116
+ expect(result.content).toHaveLength(2);
117
+ expect(getText(result, 1)).toContain('image embed failed');
118
+ expect(getText(result, 1)).toContain('HTTP 403');
119
+ });
120
+ });
@@ -1,4 +1,4 @@
1
- import { describe, it, expect } from 'vitest';
1
+ import { describe, it, expect } from 'bun:test';
2
2
  import { inferMimeType, selectRenditionForEmbed } from '../tools/get-media.js';
3
3
  import type { MediaRenditions } from '../utils/types.js';
4
4
 
@@ -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
+ });