@ansvar/eu-regulations-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.
Files changed (58) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +242 -0
  3. package/data/seed/ai-act.json +1026 -0
  4. package/data/seed/applicability/dora.json +92 -0
  5. package/data/seed/applicability/gdpr.json +74 -0
  6. package/data/seed/applicability/nis2.json +83 -0
  7. package/data/seed/cra.json +690 -0
  8. package/data/seed/cybersecurity-act.json +534 -0
  9. package/data/seed/dora.json +719 -0
  10. package/data/seed/gdpr.json +732 -0
  11. package/data/seed/mappings/iso27001-dora.json +106 -0
  12. package/data/seed/mappings/iso27001-gdpr.json +114 -0
  13. package/data/seed/mappings/iso27001-nis2.json +98 -0
  14. package/data/seed/nis2.json +492 -0
  15. package/dist/index.d.ts +3 -0
  16. package/dist/index.d.ts.map +1 -0
  17. package/dist/index.js +271 -0
  18. package/dist/index.js.map +1 -0
  19. package/dist/tools/applicability.d.ts +20 -0
  20. package/dist/tools/applicability.d.ts.map +1 -0
  21. package/dist/tools/applicability.js +42 -0
  22. package/dist/tools/applicability.js.map +1 -0
  23. package/dist/tools/article.d.ts +17 -0
  24. package/dist/tools/article.d.ts.map +1 -0
  25. package/dist/tools/article.js +29 -0
  26. package/dist/tools/article.js.map +1 -0
  27. package/dist/tools/compare.d.ts +18 -0
  28. package/dist/tools/compare.d.ts.map +1 -0
  29. package/dist/tools/compare.js +60 -0
  30. package/dist/tools/compare.js.map +1 -0
  31. package/dist/tools/definitions.d.ts +14 -0
  32. package/dist/tools/definitions.d.ts.map +1 -0
  33. package/dist/tools/definitions.js +26 -0
  34. package/dist/tools/definitions.js.map +1 -0
  35. package/dist/tools/list.d.ts +22 -0
  36. package/dist/tools/list.d.ts.map +1 -0
  37. package/dist/tools/list.js +67 -0
  38. package/dist/tools/list.js.map +1 -0
  39. package/dist/tools/map.d.ts +19 -0
  40. package/dist/tools/map.d.ts.map +1 -0
  41. package/dist/tools/map.js +44 -0
  42. package/dist/tools/map.js.map +1 -0
  43. package/dist/tools/search.d.ts +15 -0
  44. package/dist/tools/search.d.ts.map +1 -0
  45. package/dist/tools/search.js +62 -0
  46. package/dist/tools/search.js.map +1 -0
  47. package/package.json +70 -0
  48. package/scripts/build-db.ts +292 -0
  49. package/scripts/check-updates.ts +192 -0
  50. package/scripts/ingest-eurlex.ts +219 -0
  51. package/src/index.ts +294 -0
  52. package/src/tools/applicability.ts +84 -0
  53. package/src/tools/article.ts +61 -0
  54. package/src/tools/compare.ts +94 -0
  55. package/src/tools/definitions.ts +54 -0
  56. package/src/tools/list.ts +116 -0
  57. package/src/tools/map.ts +84 -0
  58. package/src/tools/search.ts +95 -0
@@ -0,0 +1,84 @@
1
+ import type { Database } from 'better-sqlite3';
2
+
3
+ export type Sector =
4
+ | 'financial'
5
+ | 'healthcare'
6
+ | 'energy'
7
+ | 'transport'
8
+ | 'digital_infrastructure'
9
+ | 'public_administration'
10
+ | 'manufacturing'
11
+ | 'other';
12
+
13
+ export interface ApplicabilityInput {
14
+ sector: Sector;
15
+ subsector?: string;
16
+ member_state?: string;
17
+ size?: 'sme' | 'large';
18
+ }
19
+
20
+ export interface ApplicableRegulation {
21
+ regulation: string;
22
+ confidence: 'definite' | 'likely' | 'possible';
23
+ basis: string | null;
24
+ notes: string | null;
25
+ }
26
+
27
+ export interface ApplicabilityResult {
28
+ entity: ApplicabilityInput;
29
+ applicable_regulations: ApplicableRegulation[];
30
+ }
31
+
32
+ export async function checkApplicability(
33
+ db: Database,
34
+ input: ApplicabilityInput
35
+ ): Promise<ApplicabilityResult> {
36
+ const { sector, subsector } = input;
37
+
38
+ // Query for matching rules - check both sector match and subsector match
39
+ let sql = `
40
+ SELECT DISTINCT
41
+ regulation,
42
+ confidence,
43
+ basis_article as basis,
44
+ notes
45
+ FROM applicability_rules
46
+ WHERE applies = 1
47
+ AND (
48
+ (sector = ? AND (subsector IS NULL OR subsector = ?))
49
+ OR (sector = ? AND subsector IS NULL)
50
+ )
51
+ ORDER BY
52
+ CASE confidence
53
+ WHEN 'definite' THEN 1
54
+ WHEN 'likely' THEN 2
55
+ WHEN 'possible' THEN 3
56
+ END,
57
+ regulation
58
+ `;
59
+
60
+ const rows = db.prepare(sql).all(sector, subsector || '', sector) as Array<{
61
+ regulation: string;
62
+ confidence: 'definite' | 'likely' | 'possible';
63
+ basis: string | null;
64
+ notes: string | null;
65
+ }>;
66
+
67
+ // Deduplicate by regulation, keeping highest confidence
68
+ const regulationMap = new Map<string, ApplicableRegulation>();
69
+ for (const row of rows) {
70
+ if (!regulationMap.has(row.regulation)) {
71
+ regulationMap.set(row.regulation, {
72
+ regulation: row.regulation,
73
+ confidence: row.confidence,
74
+ basis: row.basis,
75
+ notes: row.notes,
76
+ });
77
+ }
78
+ }
79
+
80
+ return {
81
+ entity: input,
82
+ applicable_regulations: Array.from(regulationMap.values()),
83
+ };
84
+ }
@@ -0,0 +1,61 @@
1
+ import type { Database } from 'better-sqlite3';
2
+
3
+ export interface GetArticleInput {
4
+ regulation: string;
5
+ article: string;
6
+ include_recitals?: boolean;
7
+ }
8
+
9
+ export interface Article {
10
+ regulation: string;
11
+ article_number: string;
12
+ title: string | null;
13
+ text: string;
14
+ chapter: string | null;
15
+ recitals: string[] | null;
16
+ cross_references: string[] | null;
17
+ }
18
+
19
+ export async function getArticle(
20
+ db: Database,
21
+ input: GetArticleInput
22
+ ): Promise<Article | null> {
23
+ const { regulation, article } = input;
24
+
25
+ const sql = `
26
+ SELECT
27
+ regulation,
28
+ article_number,
29
+ title,
30
+ text,
31
+ chapter,
32
+ recitals,
33
+ cross_references
34
+ FROM articles
35
+ WHERE regulation = ? AND article_number = ?
36
+ `;
37
+
38
+ const row = db.prepare(sql).get(regulation, article) as {
39
+ regulation: string;
40
+ article_number: string;
41
+ title: string | null;
42
+ text: string;
43
+ chapter: string | null;
44
+ recitals: string | null;
45
+ cross_references: string | null;
46
+ } | undefined;
47
+
48
+ if (!row) {
49
+ return null;
50
+ }
51
+
52
+ return {
53
+ regulation: row.regulation,
54
+ article_number: row.article_number,
55
+ title: row.title,
56
+ text: row.text,
57
+ chapter: row.chapter,
58
+ recitals: row.recitals ? JSON.parse(row.recitals) : null,
59
+ cross_references: row.cross_references ? JSON.parse(row.cross_references) : null,
60
+ };
61
+ }
@@ -0,0 +1,94 @@
1
+ import type { Database } from 'better-sqlite3';
2
+ import { searchRegulations } from './search.js';
3
+
4
+ export interface CompareInput {
5
+ topic: string;
6
+ regulations: string[];
7
+ }
8
+
9
+ export interface RegulationComparison {
10
+ regulation: string;
11
+ requirements: string[];
12
+ articles: string[];
13
+ timelines?: string;
14
+ }
15
+
16
+ export interface CompareResult {
17
+ topic: string;
18
+ regulations: RegulationComparison[];
19
+ key_differences?: string[];
20
+ }
21
+
22
+ /**
23
+ * Extract timeline mentions from text (e.g., "24 hours", "72 hours")
24
+ */
25
+ function extractTimelines(text: string): string | undefined {
26
+ const timelinePatterns = [
27
+ /(\d+)\s*hours?/gi,
28
+ /(\d+)\s*days?/gi,
29
+ /without\s+undue\s+delay/gi,
30
+ /immediately/gi,
31
+ ];
32
+
33
+ const matches: string[] = [];
34
+ for (const pattern of timelinePatterns) {
35
+ const found = text.match(pattern);
36
+ if (found) {
37
+ matches.push(...found);
38
+ }
39
+ }
40
+
41
+ return matches.length > 0 ? matches.join(', ') : undefined;
42
+ }
43
+
44
+ export async function compareRequirements(
45
+ db: Database,
46
+ input: CompareInput
47
+ ): Promise<CompareResult> {
48
+ const { topic, regulations } = input;
49
+
50
+ const comparisons: RegulationComparison[] = [];
51
+
52
+ for (const regulation of regulations) {
53
+ // Search for articles matching the topic in this regulation
54
+ const results = await searchRegulations(db, {
55
+ query: topic,
56
+ regulations: [regulation],
57
+ limit: 5,
58
+ });
59
+
60
+ // Get full article text for timeline extraction
61
+ const articles: string[] = [];
62
+ const requirements: string[] = [];
63
+ let combinedText = '';
64
+
65
+ for (const result of results) {
66
+ articles.push(result.article);
67
+ requirements.push(result.snippet.replace(/>>>/g, '').replace(/<<</g, ''));
68
+
69
+ // Get full text for timeline extraction
70
+ const fullArticle = db.prepare(`
71
+ SELECT text FROM articles
72
+ WHERE regulation = ? AND article_number = ?
73
+ `).get(regulation, result.article) as { text: string } | undefined;
74
+
75
+ if (fullArticle) {
76
+ combinedText += ' ' + fullArticle.text;
77
+ }
78
+ }
79
+
80
+ const timelines = extractTimelines(combinedText);
81
+
82
+ comparisons.push({
83
+ regulation,
84
+ requirements,
85
+ articles,
86
+ timelines,
87
+ });
88
+ }
89
+
90
+ return {
91
+ topic,
92
+ regulations: comparisons,
93
+ };
94
+ }
@@ -0,0 +1,54 @@
1
+ import type { Database } from 'better-sqlite3';
2
+
3
+ export interface DefinitionsInput {
4
+ term: string;
5
+ regulation?: string;
6
+ }
7
+
8
+ export interface Definition {
9
+ term: string;
10
+ regulation: string;
11
+ article: string;
12
+ definition: string;
13
+ related_terms?: string[];
14
+ }
15
+
16
+ export async function getDefinitions(
17
+ db: Database,
18
+ input: DefinitionsInput
19
+ ): Promise<Definition[]> {
20
+ const { term, regulation } = input;
21
+
22
+ let sql = `
23
+ SELECT
24
+ term,
25
+ regulation,
26
+ article,
27
+ definition
28
+ FROM definitions
29
+ WHERE term LIKE ?
30
+ `;
31
+
32
+ const params: string[] = [`%${term}%`];
33
+
34
+ if (regulation) {
35
+ sql += ` AND regulation = ?`;
36
+ params.push(regulation);
37
+ }
38
+
39
+ sql += ` ORDER BY regulation, term`;
40
+
41
+ const rows = db.prepare(sql).all(...params) as Array<{
42
+ term: string;
43
+ regulation: string;
44
+ article: string;
45
+ definition: string;
46
+ }>;
47
+
48
+ return rows.map(row => ({
49
+ term: row.term,
50
+ regulation: row.regulation,
51
+ article: row.article,
52
+ definition: row.definition,
53
+ }));
54
+ }
@@ -0,0 +1,116 @@
1
+ import type { Database } from 'better-sqlite3';
2
+
3
+ export interface ListInput {
4
+ regulation?: string;
5
+ }
6
+
7
+ export interface Chapter {
8
+ number: string;
9
+ title: string;
10
+ articles: string[];
11
+ }
12
+
13
+ export interface RegulationInfo {
14
+ id: string;
15
+ full_name: string;
16
+ celex_id: string;
17
+ effective_date: string | null;
18
+ article_count: number;
19
+ chapters?: Chapter[];
20
+ }
21
+
22
+ export interface ListResult {
23
+ regulations: RegulationInfo[];
24
+ }
25
+
26
+ export async function listRegulations(
27
+ db: Database,
28
+ input: ListInput
29
+ ): Promise<ListResult> {
30
+ const { regulation } = input;
31
+
32
+ if (regulation) {
33
+ // Get specific regulation with chapters
34
+ const regRow = db.prepare(`
35
+ SELECT id, full_name, celex_id, effective_date
36
+ FROM regulations
37
+ WHERE id = ?
38
+ `).get(regulation) as {
39
+ id: string;
40
+ full_name: string;
41
+ celex_id: string;
42
+ effective_date: string | null;
43
+ } | undefined;
44
+
45
+ if (!regRow) {
46
+ return { regulations: [] };
47
+ }
48
+
49
+ // Get articles grouped by chapter
50
+ const articles = db.prepare(`
51
+ SELECT article_number, title, chapter
52
+ FROM articles
53
+ WHERE regulation = ?
54
+ ORDER BY CAST(article_number AS INTEGER)
55
+ `).all(regulation) as Array<{
56
+ article_number: string;
57
+ title: string | null;
58
+ chapter: string | null;
59
+ }>;
60
+
61
+ // Group by chapter
62
+ const chapterMap = new Map<string, Chapter>();
63
+ for (const article of articles) {
64
+ const chapterKey = article.chapter || 'General';
65
+ if (!chapterMap.has(chapterKey)) {
66
+ chapterMap.set(chapterKey, {
67
+ number: chapterKey,
68
+ title: `Chapter ${chapterKey}`,
69
+ articles: [],
70
+ });
71
+ }
72
+ chapterMap.get(chapterKey)!.articles.push(article.article_number);
73
+ }
74
+
75
+ return {
76
+ regulations: [{
77
+ id: regRow.id,
78
+ full_name: regRow.full_name,
79
+ celex_id: regRow.celex_id,
80
+ effective_date: regRow.effective_date,
81
+ article_count: articles.length,
82
+ chapters: Array.from(chapterMap.values()),
83
+ }],
84
+ };
85
+ }
86
+
87
+ // List all regulations with article counts
88
+ const rows = db.prepare(`
89
+ SELECT
90
+ r.id,
91
+ r.full_name,
92
+ r.celex_id,
93
+ r.effective_date,
94
+ COUNT(a.rowid) as article_count
95
+ FROM regulations r
96
+ LEFT JOIN articles a ON a.regulation = r.id
97
+ GROUP BY r.id
98
+ ORDER BY r.id
99
+ `).all() as Array<{
100
+ id: string;
101
+ full_name: string;
102
+ celex_id: string;
103
+ effective_date: string | null;
104
+ article_count: number;
105
+ }>;
106
+
107
+ return {
108
+ regulations: rows.map(row => ({
109
+ id: row.id,
110
+ full_name: row.full_name,
111
+ celex_id: row.celex_id,
112
+ effective_date: row.effective_date,
113
+ article_count: row.article_count,
114
+ })),
115
+ };
116
+ }
@@ -0,0 +1,84 @@
1
+ import type { Database } from 'better-sqlite3';
2
+
3
+ export interface MapControlsInput {
4
+ framework: 'ISO27001';
5
+ control?: string;
6
+ regulation?: string;
7
+ }
8
+
9
+ export interface ControlMappingEntry {
10
+ regulation: string;
11
+ articles: string[];
12
+ coverage: 'full' | 'partial' | 'related';
13
+ notes: string | null;
14
+ }
15
+
16
+ export interface ControlMapping {
17
+ control_id: string;
18
+ control_name: string;
19
+ mappings: ControlMappingEntry[];
20
+ }
21
+
22
+ export async function mapControls(
23
+ db: Database,
24
+ input: MapControlsInput
25
+ ): Promise<ControlMapping[]> {
26
+ const { control, regulation } = input;
27
+
28
+ let sql = `
29
+ SELECT
30
+ control_id,
31
+ control_name,
32
+ regulation,
33
+ articles,
34
+ coverage,
35
+ notes
36
+ FROM control_mappings
37
+ WHERE 1=1
38
+ `;
39
+
40
+ const params: string[] = [];
41
+
42
+ if (control) {
43
+ sql += ` AND control_id = ?`;
44
+ params.push(control);
45
+ }
46
+
47
+ if (regulation) {
48
+ sql += ` AND regulation = ?`;
49
+ params.push(regulation);
50
+ }
51
+
52
+ sql += ` ORDER BY control_id, regulation`;
53
+
54
+ const rows = db.prepare(sql).all(...params) as Array<{
55
+ control_id: string;
56
+ control_name: string;
57
+ regulation: string;
58
+ articles: string;
59
+ coverage: 'full' | 'partial' | 'related';
60
+ notes: string | null;
61
+ }>;
62
+
63
+ // Group by control_id
64
+ const controlMap = new Map<string, ControlMapping>();
65
+
66
+ for (const row of rows) {
67
+ if (!controlMap.has(row.control_id)) {
68
+ controlMap.set(row.control_id, {
69
+ control_id: row.control_id,
70
+ control_name: row.control_name,
71
+ mappings: [],
72
+ });
73
+ }
74
+
75
+ controlMap.get(row.control_id)!.mappings.push({
76
+ regulation: row.regulation,
77
+ articles: JSON.parse(row.articles),
78
+ coverage: row.coverage,
79
+ notes: row.notes,
80
+ });
81
+ }
82
+
83
+ return Array.from(controlMap.values());
84
+ }
@@ -0,0 +1,95 @@
1
+ import type { Database } from 'better-sqlite3';
2
+
3
+ export interface SearchInput {
4
+ query: string;
5
+ regulations?: string[];
6
+ limit?: number;
7
+ }
8
+
9
+ export interface SearchResult {
10
+ regulation: string;
11
+ article: string;
12
+ title: string;
13
+ snippet: string;
14
+ relevance: number;
15
+ }
16
+
17
+ /**
18
+ * Escape special FTS5 query characters to prevent syntax errors.
19
+ * FTS5 uses double quotes for phrase queries and has special operators.
20
+ */
21
+ function escapeFts5Query(query: string): string {
22
+ // Remove characters that have special meaning in FTS5
23
+ // and wrap each word in double quotes for exact matching
24
+ return query
25
+ .replace(/['"]/g, '') // Remove quotes
26
+ .split(/\s+/)
27
+ .filter(word => word.length > 0)
28
+ .map(word => `"${word}"`)
29
+ .join(' ');
30
+ }
31
+
32
+ export async function searchRegulations(
33
+ db: Database,
34
+ input: SearchInput
35
+ ): Promise<SearchResult[]> {
36
+ const { query, regulations, limit = 10 } = input;
37
+
38
+ if (!query || query.trim().length === 0) {
39
+ return [];
40
+ }
41
+
42
+ const escapedQuery = escapeFts5Query(query);
43
+
44
+ if (!escapedQuery) {
45
+ return [];
46
+ }
47
+
48
+ // Build the SQL query with optional regulation filter
49
+ let sql = `
50
+ SELECT
51
+ articles_fts.regulation,
52
+ articles_fts.article_number as article,
53
+ articles_fts.title,
54
+ snippet(articles_fts, 3, '>>>', '<<<', '...', 32) as snippet,
55
+ bm25(articles_fts) as relevance
56
+ FROM articles_fts
57
+ WHERE articles_fts MATCH ?
58
+ `;
59
+
60
+ const params: (string | number)[] = [escapedQuery];
61
+
62
+ if (regulations && regulations.length > 0) {
63
+ const placeholders = regulations.map(() => '?').join(', ');
64
+ sql += ` AND articles_fts.regulation IN (${placeholders})`;
65
+ params.push(...regulations);
66
+ }
67
+
68
+ // Order by relevance (bm25 returns negative scores, more negative = more relevant)
69
+ sql += ` ORDER BY bm25(articles_fts)`;
70
+ sql += ` LIMIT ?`;
71
+ params.push(limit);
72
+
73
+ try {
74
+ const stmt = db.prepare(sql);
75
+ const rows = stmt.all(...params) as Array<{
76
+ regulation: string;
77
+ article: string;
78
+ title: string;
79
+ snippet: string;
80
+ relevance: number;
81
+ }>;
82
+
83
+ // Convert bm25 scores to positive values (higher = more relevant)
84
+ return rows.map(row => ({
85
+ ...row,
86
+ relevance: Math.abs(row.relevance),
87
+ }));
88
+ } catch (error) {
89
+ // If FTS5 query fails (e.g., syntax error), return empty results
90
+ if (error instanceof Error && error.message.includes('fts5')) {
91
+ return [];
92
+ }
93
+ throw error;
94
+ }
95
+ }