@perenia/mcp 0.2.0 → 0.3.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/README.md CHANGED
@@ -37,7 +37,13 @@ against a dev instance).
37
37
  | Tool | What it does |
38
38
  |---|---|
39
39
  | `search_companies` | Text + filtered search (location, NAF activity, size, financials, financial-score grade, decision-maker age, geo radius). Compact summaries, paginated — or, with `columns`, rows holding exactly the requested columns (same ids as the web table's templates and CSV export). |
40
- | `list_columns` | The column catalogue for `search_companies` `columns`, grouped, with each column's kind and fields. No quota cost. |
40
+ | `get_companies` | Batch lookup of up to 100 SIRENs in one call, in the order given, as summaries or `columns` rows; reports the SIRENs not found. |
41
+ | `open_in_perenia` | Turns the same filters as `search_companies` into the URL of that search in the web app — the link to hand to a person. |
42
+ | `list_columns` | The column catalogue for `columns`, grouped, with each column's kind and fields. No quota cost. |
43
+ | `get_usage` | Today's quota for your key (used / limit / remaining / reset) and the per-minute rate limit. Every other tool result already ends with the same `quota` figures. |
44
+
45
+ Every result carries `perenia_url` per company (the company page in the app).
46
+ `PERENIA_APP_URL` overrides the app origin those links use.
41
47
  | `get_company` | Full profile by SIREN, including multi-year financial history. |
42
48
  | `get_company_officers` | Officer roster (names and roles — no personal data). |
43
49
  | `get_network_neighbours` | Companies sharing an officer network (holdings, sister companies). |
package/dist/client.js CHANGED
@@ -16,6 +16,8 @@ export class PereniaApiError extends Error {
16
16
  }
17
17
  export class ApiClient {
18
18
  opts;
19
+ /** Last X-Quota-* headers seen; null until an authenticated call returns. */
20
+ lastQuota = null;
19
21
  constructor(opts) {
20
22
  this.opts = opts;
21
23
  }
@@ -35,6 +37,7 @@ export class ApiClient {
35
37
  },
36
38
  body: body !== undefined ? JSON.stringify(body) : undefined,
37
39
  });
40
+ this.captureQuota(res.headers);
38
41
  const payload = await res.json().catch(() => undefined);
39
42
  if (!res.ok) {
40
43
  const err = payload?.error;
@@ -47,4 +50,11 @@ export class ApiClient {
47
50
  }
48
51
  return payload;
49
52
  }
53
+ captureQuota(headers) {
54
+ const used = Number(headers.get('x-quota-used'));
55
+ const limit = Number(headers.get('x-quota-limit'));
56
+ if (!headers.has('x-quota-used') || !Number.isFinite(used) || !Number.isFinite(limit))
57
+ return;
58
+ this.lastQuota = { used, limit, remaining: Math.max(0, limit - used) };
59
+ }
50
60
  }
package/dist/index.js CHANGED
@@ -10,7 +10,8 @@ if (!apiKey) {
10
10
  process.exit(1);
11
11
  }
12
12
  const baseUrl = (process.env.PERENIA_API_URL ?? 'https://api.perenia.ai').replace(/\/$/, '');
13
- const server = new McpServer({ name: 'perenia', version: '0.2.0' });
14
- registerTools(server, new ApiClient({ baseUrl, apiKey }));
13
+ const appUrl = (process.env.PERENIA_APP_URL ?? 'https://app.perenia.ai').replace(/\/$/, '');
14
+ const server = new McpServer({ name: 'perenia', version: '0.3.0' });
15
+ registerTools(server, new ApiClient({ baseUrl, apiKey }), { appUrl });
15
16
  await server.connect(new StdioServerTransport());
16
17
  console.error(`perenia-mcp ready (${baseUrl})`);
package/dist/tools.js CHANGED
@@ -16,11 +16,32 @@ const FACET_FIELDS = [
16
16
  'last_financial_score_grade',
17
17
  'has_open_collective_proceeding',
18
18
  ];
19
- /** Compact per-hit shape for search-style results: enough to reason and rank
20
- * with, small enough that a 25-hit page doesn't flood the agent's context.
21
- * Full detail is one get_company call away. */
22
- function trimSummary(c) {
23
- return {
19
+ const columnsParam = z
20
+ .array(z.string())
21
+ .min(1)
22
+ .max(40)
23
+ .optional()
24
+ .describe('Ordered column ids from list_columns, e.g. ["company", "turnover", "netProfit", "founded"]. When set, results are rows keyed by column id (siren first; a code column also carries <id>_label) instead of compact summaries — pick the columns you need for a table or a comparison.');
25
+ async function guarded(fn) {
26
+ try {
27
+ return await fn();
28
+ }
29
+ catch (err) {
30
+ if (err instanceof PereniaApiError) {
31
+ const retry = err.retryAfterSeconds != null ? ` (retry after ${err.retryAfterSeconds}s)` : '';
32
+ return { content: [{ type: 'text', text: `Perenia API error ${err.status} ${err.code}: ${err.message}${retry}` }], isError: true };
33
+ }
34
+ throw err;
35
+ }
36
+ }
37
+ export function registerTools(server, api, opts) {
38
+ const appUrl = opts.appUrl.replace(/\/$/, '');
39
+ const companyUrl = (siren) => `${appUrl}/company/${String(siren)}`;
40
+ /** Compact per-hit shape for search-style results: enough to reason and rank
41
+ * with, small enough that a 25-hit page doesn't flood the agent's context.
42
+ * Full detail is one get_company call away; `perenia_url` opens the company
43
+ * in the app for a person. */
44
+ const trimSummary = (c) => ({
24
45
  siren: c.siren,
25
46
  name: c.name,
26
47
  city: c.city_name,
@@ -36,34 +57,26 @@ function trimSummary(c) {
36
57
  in_collective_proceeding: c.has_open_collective_proceeding,
37
58
  one_liner: c.one_liner,
38
59
  url: c.url,
60
+ perenia_url: companyUrl(c.siren),
61
+ });
62
+ const withUrl = (row) => ({ ...row, perenia_url: companyUrl(row.siren) });
63
+ /** Every metered result ends with the daily quota as the API reported it
64
+ * on that very response, so the agent can pace itself without a call. */
65
+ const ok = (data) => {
66
+ const payload = api.lastQuota ? { ...data, quota: api.lastQuota } : data;
67
+ return { content: [{ type: 'text', text: JSON.stringify(payload, null, 1) }] };
68
+ };
69
+ const presentSearch = (res) => {
70
+ const paging = { total: res.total, page: res.page, per_page: res.per_page };
71
+ if (res.rows)
72
+ return { ...paging, columns: res.columns, rows: res.rows.map(withUrl) };
73
+ return { ...paging, companies: (res.data ?? []).map(trimSummary) };
39
74
  };
40
- }
41
- function ok(data) {
42
- return { content: [{ type: 'text', text: JSON.stringify(data, null, 1) }] };
43
- }
44
- async function guarded(fn) {
45
- try {
46
- return await fn();
47
- }
48
- catch (err) {
49
- if (err instanceof PereniaApiError) {
50
- const retry = err.retryAfterSeconds != null ? ` (retry after ${err.retryAfterSeconds}s)` : '';
51
- return { content: [{ type: 'text', text: `Perenia API error ${err.status} ${err.code}: ${err.message}${retry}` }], isError: true };
52
- }
53
- throw err;
54
- }
55
- }
56
- export function registerTools(server, api) {
57
75
  server.registerTool('search_companies', {
58
- description: 'Search ~3.5M French companies by text query and/or filters (location, activity, size, financials, decision-maker age, financial-score grade). Returns compact summaries, or — with `columns` — one row per company holding exactly the requested columns (ids from list_columns). Use get_company for full detail. Data refreshes daily.',
76
+ description: 'Search ~3.5M French companies by text query and/or filters (location, activity, size, financials, decision-maker age, financial-score grade). Returns compact summaries, or — with `columns` — one row per company holding exactly the requested columns (ids from list_columns). Each result carries `perenia_url` (the company in the app); use open_in_perenia to hand the whole search to a person. Use get_company for full detail. Data refreshes daily.',
59
77
  inputSchema: {
60
78
  ...searchFiltersShape,
61
- columns: z
62
- .array(z.string())
63
- .min(1)
64
- .max(40)
65
- .optional()
66
- .describe('Ordered column ids from list_columns, e.g. ["company", "turnover", "netProfit", "founded"]. When set, results are rows keyed by column id (siren first; a code column also carries <id>_label) instead of compact summaries — pick the columns you need for a table or a comparison.'),
79
+ columns: columnsParam,
67
80
  page: z.number().int().min(1).max(100).optional().describe('Default 1'),
68
81
  per_page: z.number().int().min(1).max(50).optional().describe('Default 10'),
69
82
  },
@@ -75,29 +88,42 @@ export function registerTools(server, api) {
75
88
  page: page ?? 1,
76
89
  perPage: per_page ?? 10,
77
90
  }));
78
- const paging = { total: res.total, page: res.page, per_page: res.per_page };
79
- if (res.rows)
80
- return ok({ ...paging, columns: res.columns, rows: res.rows });
81
- return ok({ ...paging, companies: (res.data ?? []).map(trimSummary) });
91
+ return ok(presentSearch(res));
82
92
  }));
83
- server.registerTool('list_columns', {
84
- description: 'The column catalogue accepted by search_companies `columns`, grouped (profile, location, people, financials, score, network) with each column\'s value kind and the fields it reads. Cheap: no quota cost.',
85
- inputSchema: {},
86
- }, () => guarded(async () => {
87
- const res = (await api.get('/v1/columns'));
88
- const groups = {};
89
- for (const { group, ...column } of res.columns)
90
- (groups[group] ??= []).push(column);
91
- return ok({ groups });
93
+ server.registerTool('get_companies', {
94
+ description: 'Batch lookup of up to 100 companies by SIREN in one call (one request against the quota). Returns them in the order given, as compact summaries or — with `columns` as rows, plus the SIRENs that were not found.',
95
+ inputSchema: {
96
+ sirens: z.array(sirenParam).min(1).max(100),
97
+ columns: columnsParam,
98
+ },
99
+ }, ({ sirens, columns }) => guarded(async () => {
100
+ const wanted = [...new Set(sirens)];
101
+ const res = (await api.post('/v1/companies/search', {
102
+ companyIds: wanted,
103
+ ...(columns ? { columns } : {}),
104
+ page: 1,
105
+ perPage: wanted.length,
106
+ }));
107
+ const found = presentSearch(res);
108
+ const items = (found.rows ?? found.companies ?? []);
109
+ const bySiren = new Map(items.map((item) => [String(item.siren), item]));
110
+ const ordered = wanted.flatMap((s) => (bySiren.has(s) ? [bySiren.get(s)] : []));
111
+ const missing = wanted.filter((s) => !bySiren.has(s));
112
+ return ok(found.rows
113
+ ? { found: ordered.length, missing, columns: found.columns, rows: ordered }
114
+ : { found: ordered.length, missing, companies: ordered });
92
115
  }));
93
116
  server.registerTool('get_company', {
94
- description: 'Full profile of one company by SIREN: identity, location, activity descriptions, network counts, financial score, and multi-year financial history.',
117
+ description: 'Full profile of one company by SIREN: identity, location, activity descriptions, network counts, financial score, and multi-year financial history. Includes `perenia_url` (the company page in the app).',
95
118
  inputSchema: { siren: sirenParam },
96
- }, ({ siren }) => guarded(async () => ok(await api.get(`/v1/companies/${siren}`))));
119
+ }, ({ siren }) => guarded(async () => {
120
+ const detail = (await api.get(`/v1/companies/${siren}`));
121
+ return ok(withUrl(detail));
122
+ }));
97
123
  server.registerTool('get_company_officers', {
98
124
  description: 'Officer roster (directors, managers) of one company by SIREN. Names and roles only — no personal data.',
99
125
  inputSchema: { siren: sirenParam },
100
- }, ({ siren }) => guarded(async () => ok(await api.get(`/v1/companies/${siren}/officers`))));
126
+ }, ({ siren }) => guarded(async () => ok((await api.get(`/v1/companies/${siren}/officers`)))));
101
127
  server.registerTool('get_network_neighbours', {
102
128
  description: 'Companies sharing an officer network with the given SIREN (up to 50) — subsidiaries, sister companies, holdings.',
103
129
  inputSchema: { siren: sirenParam },
@@ -115,6 +141,27 @@ export function registerTools(server, api) {
115
141
  const { field, ...filters } = args;
116
142
  const state = toFilterState(filters);
117
143
  const query = Object.keys(state).length ? { filters: JSON.stringify(state) } : undefined;
118
- return ok(await api.get(`/v1/facets/${field}`, query));
144
+ return ok((await api.get(`/v1/facets/${field}`, query)));
145
+ }));
146
+ server.registerTool('list_columns', {
147
+ description: 'The column catalogue accepted by search_companies / get_companies `columns`, grouped (profile, location, people, financials, score, network) with each column\'s value kind and the fields it reads. Cheap: no quota cost.',
148
+ inputSchema: {},
149
+ }, () => guarded(async () => {
150
+ const res = (await api.get('/v1/columns'));
151
+ const groups = {};
152
+ for (const { group, ...column } of res.columns)
153
+ (groups[group] ??= []).push(column);
154
+ return ok({ groups });
119
155
  }));
156
+ server.registerTool('open_in_perenia', {
157
+ description: 'Hand a search to a person: the same filters as search_companies become the URL of that search in the Perenia web app (live results, facets, export, lists). Returns the link — give it to the user. A geographic radius (lat/lng) has no URL form and is dropped.',
158
+ inputSchema: { ...searchFiltersShape },
159
+ }, (args) => guarded(async () => {
160
+ const res = (await api.post('/v1/links/search', toFilterState(args)));
161
+ return ok({ url: res.url });
162
+ }));
163
+ server.registerTool('get_usage', {
164
+ description: "This API key's daily quota: requests used today (UTC), the limit, what remains and when it resets, plus the per-minute rate limit. Every other tool result already ends with the same `quota` figures, so call this only when you have not made a call yet.",
165
+ inputSchema: {},
166
+ }, () => guarded(async () => ok((await api.get('/v1/usage')))));
120
167
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@perenia/mcp",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "MCP server for the Perenia Partner API — search and analyze French companies from any MCP client",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",