@perenia/mcp 0.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/README.md ADDED
@@ -0,0 +1,56 @@
1
+ # @perenia/mcp
2
+
3
+ MCP server for the [Perenia Partner API](https://api.perenia.ai/v1/docs) —
4
+ search and analyze ~3.5M French companies from Claude, Cursor, or any MCP
5
+ client. Runs locally over stdio; every call goes to `https://api.perenia.ai`
6
+ with your API key, so your plan's rate limits and quotas apply as usual.
7
+
8
+ ## Setup
9
+
10
+ You need a Perenia Partner API key (`pk_live_…`).
11
+
12
+ **Claude Code**
13
+
14
+ ```bash
15
+ claude mcp add perenia --env PERENIA_API_KEY=pk_live_… -- npx -y @perenia/mcp
16
+ ```
17
+
18
+ **Claude Desktop / other clients** (`claude_desktop_config.json` or equivalent):
19
+
20
+ ```json
21
+ {
22
+ "mcpServers": {
23
+ "perenia": {
24
+ "command": "npx",
25
+ "args": ["-y", "@perenia/mcp"],
26
+ "env": { "PERENIA_API_KEY": "pk_live_…" }
27
+ }
28
+ }
29
+ }
30
+ ```
31
+
32
+ Optional: `PERENIA_API_URL` overrides the API base URL (for local development
33
+ against a dev instance).
34
+
35
+ ## Tools
36
+
37
+ | Tool | What it does |
38
+ |---|---|
39
+ | `search_companies` | Text + filtered search (location, NAF activity, size, financials, financial-score grade, decision-maker age, geo radius). Compact summaries, paginated. |
40
+ | `get_company` | Full profile by SIREN, including multi-year financial history. |
41
+ | `get_company_officers` | Officer roster (names and roles — no personal data). |
42
+ | `get_network_neighbours` | Companies sharing an officer network (holdings, sister companies). |
43
+ | `list_facet_values` | Valid values + company counts for a categorical field, optionally scoped by filters — use it to discover filter values or get distributions. |
44
+
45
+ Data refreshes daily. Errors carry the API's error code; a `429` includes the
46
+ `Retry-After` hint (per-minute rate limit or daily quota — see your contract).
47
+
48
+ ## Development (this repo)
49
+
50
+ ```bash
51
+ npm -w @perenia/mcp test # in-memory MCP client ↔ server against an API stub
52
+ npm -w @perenia/mcp run build # emit dist/
53
+ PERENIA_API_KEY=… npm -w @perenia/mcp run dev
54
+ ```
55
+
56
+ Publishing: `npm publish -w @perenia/mcp` (runs `build` via prepublishOnly).
package/dist/client.js ADDED
@@ -0,0 +1,50 @@
1
+ /** Thin HTTP client for the Perenia Partner API. All MCP tools go through
2
+ * this — the server never talks to anything but the public /v1 surface, so
3
+ * per-key auth, rate limits, quotas, and metering apply exactly as they do
4
+ * for any other API consumer. */
5
+ export class PereniaApiError extends Error {
6
+ status;
7
+ code;
8
+ retryAfterSeconds;
9
+ constructor(status, code, message, retryAfterSeconds) {
10
+ super(message);
11
+ this.status = status;
12
+ this.code = code;
13
+ this.retryAfterSeconds = retryAfterSeconds;
14
+ this.name = 'PereniaApiError';
15
+ }
16
+ }
17
+ export class ApiClient {
18
+ opts;
19
+ constructor(opts) {
20
+ this.opts = opts;
21
+ }
22
+ async get(path, query) {
23
+ const qs = query && Object.keys(query).length ? `?${new URLSearchParams(query)}` : '';
24
+ return this.request('GET', `${path}${qs}`);
25
+ }
26
+ async post(path, body) {
27
+ return this.request('POST', path, body);
28
+ }
29
+ async request(method, path, body) {
30
+ const res = await fetch(`${this.opts.baseUrl}${path}`, {
31
+ method,
32
+ headers: {
33
+ authorization: `Bearer ${this.opts.apiKey}`,
34
+ ...(body !== undefined ? { 'content-type': 'application/json' } : {}),
35
+ },
36
+ body: body !== undefined ? JSON.stringify(body) : undefined,
37
+ });
38
+ const payload = await res.json().catch(() => undefined);
39
+ if (!res.ok) {
40
+ const err = payload?.error;
41
+ const code = err?.code ?? `http_${res.status}`;
42
+ let message = err?.message ?? `request failed with status ${res.status}`;
43
+ if (res.status === 401)
44
+ message = `${message} — check PERENIA_API_KEY`;
45
+ const retryAfter = res.headers.get('retry-after');
46
+ throw new PereniaApiError(res.status, code, message, retryAfter ? Number(retryAfter) : undefined);
47
+ }
48
+ return payload;
49
+ }
50
+ }
@@ -0,0 +1,90 @@
1
+ import { z } from 'zod';
2
+ /**
3
+ * Curated, flat, agent-friendly filter parameters, mapped onto the API's
4
+ * FilterState wire format. Deliberately a subset: the dimensions an agent can
5
+ * use well, with describe() strings doing the prompting.
6
+ */
7
+ export const searchFiltersShape = {
8
+ query: z.string().optional().describe('Full-text query over company names and activity descriptions'),
9
+ regions: z.array(z.string()).optional().describe("Region names, e.g. 'Occitanie' — values via list_facet_values('region_name')"),
10
+ departments: z.array(z.string()).optional().describe("Department codes, e.g. '31'"),
11
+ naf_sections: z.array(z.string()).optional().describe("NAF section letters, e.g. 'F' (construction)"),
12
+ naf_codes: z.array(z.string()).optional().describe("NAF/APE activity codes, e.g. '62.01Z' — values via list_facet_values('main_activity_code')"),
13
+ legal_type_codes: z.array(z.string()).optional().describe("INSEE legal-form codes, e.g. '5710' (SAS)"),
14
+ company_categories: z.array(z.string()).optional().describe('PME, ETI, or GE'),
15
+ employment_size_bins: z.array(z.string()).optional().describe("INSEE workforce bin codes — values via list_facet_values('employment_size_bin_code')"),
16
+ filing_status: z.array(z.enum(['disclosed', 'partial', 'none'])).optional().describe('Financial-filing disclosure status'),
17
+ in_collective_proceeding: z.boolean().optional().describe('true = only companies in an open procédure collective, false = exclude them'),
18
+ creation_year_min: z.number().int().optional(),
19
+ creation_year_max: z.number().int().optional(),
20
+ decision_maker_age_min: z.number().int().optional().describe('Min age (years) of the oldest primary decision-maker — succession signal'),
21
+ decision_maker_age_max: z.number().int().optional(),
22
+ turnover_min: z.number().optional().describe('Latest-filing turnover, EUR'),
23
+ turnover_max: z.number().optional(),
24
+ net_profit_min: z.number().optional(),
25
+ net_profit_max: z.number().optional(),
26
+ financial_score_min: z.number().optional().describe('Financial score percentile 0–100'),
27
+ financial_score_max: z.number().optional(),
28
+ financial_score_grades: z.array(z.enum(['A', 'B', 'C', 'D', 'E'])).optional(),
29
+ lat: z.number().optional().describe('With lng and radius_km: geographic radius filter'),
30
+ lng: z.number().optional(),
31
+ radius_km: z.number().optional(),
32
+ sort: z
33
+ .enum(['creation_date_desc', 'creation_date_asc', 'dm_age_desc', 'score_desc', 'score_asc'])
34
+ .optional()
35
+ .describe('Default: relevance'),
36
+ };
37
+ const filtersSchema = z.object(searchFiltersShape);
38
+ /** Map the flat tool params to the API's FilterState keys. */
39
+ export function toFilterState(f) {
40
+ const out = {};
41
+ if (f.query)
42
+ out.query = f.query;
43
+ if (f.regions?.length)
44
+ out.regions = f.regions;
45
+ if (f.departments?.length)
46
+ out.departments = f.departments;
47
+ if (f.naf_sections?.length)
48
+ out.nafSections = f.naf_sections;
49
+ if (f.naf_codes?.length)
50
+ out.nafCodes = f.naf_codes;
51
+ if (f.legal_type_codes?.length)
52
+ out.legalTypeCodes = f.legal_type_codes;
53
+ if (f.company_categories?.length)
54
+ out.companyCategories = f.company_categories;
55
+ if (f.employment_size_bins?.length)
56
+ out.employmentSizeBins = f.employment_size_bins;
57
+ if (f.filing_status?.length)
58
+ out.financialFilingStatus = f.filing_status;
59
+ if (f.in_collective_proceeding !== undefined)
60
+ out.collectiveProceeding = f.in_collective_proceeding ? 'in' : 'not';
61
+ if (f.creation_year_min != null)
62
+ out.creationYearMin = f.creation_year_min;
63
+ if (f.creation_year_max != null)
64
+ out.creationYearMax = f.creation_year_max;
65
+ if (f.decision_maker_age_min != null)
66
+ out.decisionMakerAgeMin = f.decision_maker_age_min;
67
+ if (f.decision_maker_age_max != null)
68
+ out.decisionMakerAgeMax = f.decision_maker_age_max;
69
+ if (f.turnover_min != null)
70
+ out.turnoverMin = f.turnover_min;
71
+ if (f.turnover_max != null)
72
+ out.turnoverMax = f.turnover_max;
73
+ if (f.net_profit_min != null)
74
+ out.netProfitMin = f.net_profit_min;
75
+ if (f.net_profit_max != null)
76
+ out.netProfitMax = f.net_profit_max;
77
+ if (f.financial_score_min != null)
78
+ out.financialScoreMin = f.financial_score_min;
79
+ if (f.financial_score_max != null)
80
+ out.financialScoreMax = f.financial_score_max;
81
+ if (f.financial_score_grades?.length)
82
+ out.financialScoreGrades = f.financial_score_grades;
83
+ if (f.lat != null && f.lng != null && f.radius_km != null) {
84
+ out.geoCenter = { lat: f.lat, lng: f.lng };
85
+ out.geoRadiusKm = f.radius_km;
86
+ }
87
+ if (f.sort)
88
+ out.sort = f.sort;
89
+ return out;
90
+ }
package/dist/index.js ADDED
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
+ import { ApiClient } from './client.js';
5
+ import { registerTools } from './tools.js';
6
+ // stdio transport: stdout is the protocol channel — diagnostics go to stderr.
7
+ const apiKey = process.env.PERENIA_API_KEY;
8
+ if (!apiKey) {
9
+ console.error('PERENIA_API_KEY is required (your pk_live_… Perenia Partner API key).');
10
+ process.exit(1);
11
+ }
12
+ const baseUrl = (process.env.PERENIA_API_URL ?? 'https://api.perenia.ai').replace(/\/$/, '');
13
+ const server = new McpServer({ name: 'perenia', version: '0.1.0' });
14
+ registerTools(server, new ApiClient({ baseUrl, apiKey }));
15
+ await server.connect(new StdioServerTransport());
16
+ console.error(`perenia-mcp ready (${baseUrl})`);
package/dist/tools.js ADDED
@@ -0,0 +1,100 @@
1
+ import { z } from 'zod';
2
+ import { PereniaApiError } from './client.js';
3
+ import { searchFiltersShape, toFilterState } from './filters.js';
4
+ const sirenParam = z.string().regex(/^\d{9}$/).describe('9-digit SIREN identifier');
5
+ const FACET_FIELDS = [
6
+ 'region_name',
7
+ 'department_code',
8
+ 'naf_section',
9
+ 'main_activity_code',
10
+ 'legal_type_code',
11
+ 'company_category',
12
+ 'employment_size_bin_code',
13
+ 'independence_proxy',
14
+ 'network_coverage',
15
+ 'financial_filing_status',
16
+ 'last_financial_score_grade',
17
+ 'has_open_collective_proceeding',
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 {
24
+ siren: c.siren,
25
+ name: c.name,
26
+ city: c.city_name,
27
+ region: c.region_name,
28
+ naf_code: c.main_activity_code,
29
+ activity: c.main_activity_label,
30
+ category: c.company_category,
31
+ employees: c.employment_size_bin_label,
32
+ creation_year: c.creation_year,
33
+ turnover: c.last_turnover,
34
+ score_grade: c.last_financial_score_grade,
35
+ max_decision_maker_age: c.max_primary_decision_maker_age_years,
36
+ in_collective_proceeding: c.has_open_collective_proceeding,
37
+ one_liner: c.one_liner,
38
+ url: c.url,
39
+ };
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
+ 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 — use get_company for full detail. Data refreshes daily.',
59
+ inputSchema: {
60
+ ...searchFiltersShape,
61
+ page: z.number().int().min(1).max(100).optional().describe('Default 1'),
62
+ per_page: z.number().int().min(1).max(50).optional().describe('Default 10'),
63
+ },
64
+ }, (args) => guarded(async () => {
65
+ const { page, per_page, ...filters } = args;
66
+ const res = (await api.post('/v1/companies/search', {
67
+ ...toFilterState(filters),
68
+ page: page ?? 1,
69
+ perPage: per_page ?? 10,
70
+ }));
71
+ return ok({ total: res.total, page: res.page, per_page: res.per_page, companies: res.data.map(trimSummary) });
72
+ }));
73
+ server.registerTool('get_company', {
74
+ description: 'Full profile of one company by SIREN: identity, location, activity descriptions, network counts, financial score, and multi-year financial history.',
75
+ inputSchema: { siren: sirenParam },
76
+ }, ({ siren }) => guarded(async () => ok(await api.get(`/v1/companies/${siren}`))));
77
+ server.registerTool('get_company_officers', {
78
+ description: 'Officer roster (directors, managers) of one company by SIREN. Names and roles only — no personal data.',
79
+ inputSchema: { siren: sirenParam },
80
+ }, ({ siren }) => guarded(async () => ok(await api.get(`/v1/companies/${siren}/officers`))));
81
+ server.registerTool('get_network_neighbours', {
82
+ description: 'Companies sharing an officer network with the given SIREN (up to 50) — subsidiaries, sister companies, holdings.',
83
+ inputSchema: { siren: sirenParam },
84
+ }, ({ siren }) => guarded(async () => {
85
+ const res = (await api.get(`/v1/companies/${siren}/neighbours`));
86
+ return ok({ neighbours: res.data.map(trimSummary) });
87
+ }));
88
+ server.registerTool('list_facet_values', {
89
+ description: 'All values (with company counts) of one categorical field, optionally scoped by the same filters as search_companies. Use it to discover valid filter values or get distributions.',
90
+ inputSchema: {
91
+ field: z.enum(FACET_FIELDS),
92
+ ...searchFiltersShape,
93
+ },
94
+ }, (args) => guarded(async () => {
95
+ const { field, ...filters } = args;
96
+ const state = toFilterState(filters);
97
+ const query = Object.keys(state).length ? { filters: JSON.stringify(state) } : undefined;
98
+ return ok(await api.get(`/v1/facets/${field}`, query));
99
+ }));
100
+ }
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@perenia/mcp",
3
+ "version": "0.1.0",
4
+ "description": "MCP server for the Perenia Partner API — search and analyze French companies from any MCP client",
5
+ "type": "module",
6
+ "license": "UNLICENSED",
7
+ "bin": {
8
+ "perenia-mcp": "dist/index.js"
9
+ },
10
+ "files": [
11
+ "dist",
12
+ "README.md"
13
+ ],
14
+ "engines": {
15
+ "node": ">=20.11.0"
16
+ },
17
+ "publishConfig": {
18
+ "access": "public"
19
+ },
20
+ "scripts": {
21
+ "build": "tsc -p .",
22
+ "dev": "tsx src/index.ts",
23
+ "typecheck": "tsc -p tsconfig.typecheck.json",
24
+ "test": "vitest run",
25
+ "prepublishOnly": "npm run build"
26
+ },
27
+ "dependencies": {
28
+ "@modelcontextprotocol/sdk": "^1.30.0",
29
+ "zod": "^3.25.1"
30
+ },
31
+ "devDependencies": {
32
+ "@types/node": "^20.14.0",
33
+ "tsx": "^4.19.0",
34
+ "typescript": "^5.5.0",
35
+ "vitest": "^4.1.8"
36
+ }
37
+ }