duoops 0.2.2 → 0.2.4

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.
@@ -0,0 +1,63 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+
4
+ import {ZoneMapping} from './types.js'
5
+
6
+ type ZoneData = {
7
+ carbon_intensity_url?: string;
8
+ pue?: number;
9
+ zone: string;
10
+ }
11
+
12
+ type CloudZones = Record<string, ZoneData>
13
+
14
+ export class ZoneMapper {
15
+ private awsZones: CloudZones | null = null
16
+ private gcpZones: CloudZones | null = null
17
+
18
+ constructor(private readonly dataDir: string = path.resolve(process.cwd(), 'data')) {}
19
+
20
+ getZoneMapping(provider: string, region: string): null | ZoneMapping {
21
+ const zones = this.loadZoneData(provider)
22
+ const data = zones[region]
23
+
24
+ if (!data) return null
25
+
26
+ return {
27
+ carbonIntensityUrl: data.carbon_intensity_url || '',
28
+ pue: data.pue || 1.2, // Default PUE fallback
29
+ zone: data.zone,
30
+ }
31
+ }
32
+
33
+ private loadJsonFile<T>(filename: string): T {
34
+ const filePath = path.join(this.dataDir, filename)
35
+ try {
36
+ const raw = fs.readFileSync(filePath, 'utf8')
37
+ return JSON.parse(raw) as T
38
+ } catch (error) {
39
+ console.error(`Failed to load data file ${filename}:`, error)
40
+ return {} as T
41
+ }
42
+ }
43
+
44
+ private loadZoneData(provider: string): CloudZones {
45
+ if (provider === 'gcp') {
46
+ if (!this.gcpZones) {
47
+ this.gcpZones = this.loadJsonFile<CloudZones>('gcp_zones.json')
48
+ }
49
+
50
+ return this.gcpZones
51
+ }
52
+
53
+ if (provider === 'aws') {
54
+ if (!this.awsZones) {
55
+ this.awsZones = this.loadJsonFile<CloudZones>('aws_zones.json')
56
+ }
57
+
58
+ return this.awsZones
59
+ }
60
+
61
+ return {}
62
+ }
63
+ }
@@ -0,0 +1,107 @@
1
+ /* eslint-disable camelcase */
2
+ import {McpServer} from '@modelcontextprotocol/sdk/server/mcp.js'
3
+ import {z} from 'zod'
4
+
5
+ import {BigQueryService} from '../lib/bigquery.js'
6
+
7
+ export function registerEmissionsTools(server: McpServer) {
8
+ const bq = new BigQueryService()
9
+
10
+ server.tool(
11
+ 'get_job_emissions',
12
+ {
13
+ job_id: z.number().describe('GitLab CI Job ID'),
14
+ project_id: z.number().optional().describe('GitLab Project ID (optional filter)'),
15
+ },
16
+ async ({job_id, project_id}) => {
17
+ const emissions = await bq.getJobEmissions(job_id, project_id)
18
+
19
+ if (!emissions) {
20
+ return {
21
+ content: [{text: `No emissions data found for job ${job_id}`, type: 'text'}],
22
+ isError: true,
23
+ }
24
+ }
25
+
26
+ return {
27
+ content: [{
28
+ text: JSON.stringify(emissions, null, 2),
29
+ type: 'text'
30
+ }]
31
+ }
32
+ }
33
+ )
34
+
35
+ server.tool(
36
+ 'get_pipeline_emissions',
37
+ {
38
+ pipeline_id: z.number().describe('GitLab Pipeline ID'),
39
+ project_id: z.number().optional().describe('GitLab Project ID (optional filter)'),
40
+ },
41
+ async ({pipeline_id, project_id}) => {
42
+ const result = await bq.getPipelineEmissions(pipeline_id, project_id)
43
+
44
+ if (result.jobs.length === 0) {
45
+ return {
46
+ content: [{text: `No emissions data found for pipeline ${pipeline_id}`, type: 'text'}],
47
+ isError: true,
48
+ }
49
+ }
50
+
51
+ const summary = `
52
+ Pipeline ${pipeline_id} Carbon Report:
53
+ Total Emissions: ${result.total_emissions_g.toFixed(3)} gCO2e
54
+ Total Energy: ${result.total_energy_kwh.toFixed(4)} kWh
55
+ Jobs Recorded: ${result.jobs.length}
56
+
57
+ Top Emitters:
58
+ ${result.jobs
59
+ .sort((a, b) => (b.total_emissions || 0) - (a.total_emissions || 0))
60
+ .slice(0, 3)
61
+ .map(j => `- Job ${j.job_id} (${j.machine_type}): ${j.total_emissions?.toFixed(3)} gCO2e`)
62
+ .join('\n')}
63
+ `
64
+
65
+ return {
66
+ content: [
67
+ {text: summary, type: 'text'},
68
+ {text: JSON.stringify(result, null, 2), type: 'text'} // Full JSON also provided
69
+ ]
70
+ }
71
+ }
72
+ )
73
+
74
+ server.tool(
75
+ 'get_project_emissions_summary',
76
+ {
77
+ limit: z.number().optional().default(10).describe('Number of recent jobs to return'),
78
+ project_id: z.number().describe('GitLab Project ID'),
79
+ },
80
+ async ({limit, project_id}) => {
81
+ const records = await bq.getProjectEmissionsSummary(project_id, limit)
82
+
83
+ if (records.length === 0) {
84
+ return {
85
+ content: [{text: `No emissions history found for project ${project_id}`, type: 'text'}],
86
+ isError: true,
87
+ }
88
+ }
89
+
90
+ const totalEmissions = records.reduce((sum, r) => sum + r.total_emissions, 0)
91
+ const avgEmissions = totalEmissions / records.length
92
+
93
+ const summary = `
94
+ Project ${project_id} Emissions Summary (Last ${records.length} jobs):
95
+ Average per job: ${avgEmissions.toFixed(3)} gCO2e
96
+ Total recent: ${totalEmissions.toFixed(3)} gCO2e
97
+
98
+ Recent Jobs:
99
+ ${records.map(r => `- ${r.created_at}: ${r.total_emissions.toFixed(3)} gCO2e (${r.machine_type})`).join('\n')}
100
+ `
101
+
102
+ return {
103
+ content: [{text: summary, type: 'text'}]
104
+ }
105
+ }
106
+ )
107
+ }
@@ -0,0 +1,72 @@
1
+ /* eslint-disable camelcase */
2
+ import {McpServer} from '@modelcontextprotocol/sdk/server/mcp.js'
3
+ import {z} from 'zod'
4
+
5
+ import {IntensityProvider} from '../lib/intensity.js'
6
+ import {PowerProfileRepository} from '../lib/power-profiles.js'
7
+ import {ZoneMapper} from '../lib/zone-mapper.js'
8
+
9
+ export function registerProfileTools(server: McpServer) {
10
+ const powerRepo = new PowerProfileRepository()
11
+ const zoneMapper = new ZoneMapper()
12
+ const intensityProvider = new IntensityProvider()
13
+
14
+ server.tool(
15
+ 'get_machine_profile',
16
+ {
17
+ machine_type: z.string().describe('Instance type name (e.g. e2-standard-4)'),
18
+ provider: z.enum(['aws', 'gcp']).describe('Cloud provider (aws or gcp)'),
19
+ },
20
+ async ({machine_type, provider}) => {
21
+ const profile = await powerRepo.getMachineProfile(provider, machine_type)
22
+
23
+ if (!profile) {
24
+ return {
25
+ content: [{text: `Unknown machine type: ${machine_type} for provider ${provider}`, type: 'text'}],
26
+ isError: true,
27
+ }
28
+ }
29
+
30
+ return {
31
+ content: [{
32
+ text: JSON.stringify(profile, null, 2),
33
+ type: 'text'
34
+ }]
35
+ }
36
+ }
37
+ )
38
+
39
+ server.tool(
40
+ 'get_region_carbon_intensity',
41
+ {
42
+ provider: z.enum(['aws', 'gcp']).describe('Cloud provider (aws or gcp)'),
43
+ region: z.string().describe('Cloud region (e.g. us-central1)'),
44
+ },
45
+ async ({provider, region}) => {
46
+ const mapping = zoneMapper.getZoneMapping(provider, region)
47
+
48
+ if (!mapping) {
49
+ return {
50
+ content: [{text: `Unknown region: ${region} for provider ${provider}`, type: 'text'}],
51
+ isError: true,
52
+ }
53
+ }
54
+
55
+ const intensity = await intensityProvider.getCarbonIntensity(mapping.zone)
56
+
57
+ const result = {
58
+ carbon_intensity_g_kwh: intensity,
59
+ grid_zone: mapping.zone,
60
+ pue: mapping.pue,
61
+ region,
62
+ }
63
+
64
+ return {
65
+ content: [{
66
+ text: JSON.stringify(result, null, 2),
67
+ type: 'text'
68
+ }]
69
+ }
70
+ }
71
+ )
72
+ }
@@ -0,0 +1,18 @@
1
+ {
2
+ "compilerOptions": {
3
+ "outDir": "./dist",
4
+ "rootDir": "./src",
5
+ "module": "NodeNext",
6
+ "moduleResolution": "NodeNext",
7
+ "target": "ES2022",
8
+ "sourceMap": true,
9
+ "declaration": true,
10
+ "strict": true,
11
+ "esModuleInterop": true,
12
+ "skipLibCheck": true,
13
+ "forceConsistentCasingInFileNames": true,
14
+ "resolveJsonModule": true
15
+ },
16
+ "include": ["src/**/*"],
17
+ "exclude": ["node_modules", "dist"]
18
+ }
@@ -669,5 +669,5 @@
669
669
  ]
670
670
  }
671
671
  },
672
- "version": "0.2.2"
672
+ "version": "0.2.4"
673
673
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "duoops",
3
3
  "description": "Toolset for Explainable and Sustainable CI on Gitlab.",
4
- "version": "0.2.2",
4
+ "version": "0.2.4",
5
5
  "author": "Younes Laaroussi",
6
6
  "bin": {
7
7
  "duoops": "./bin/run.js"
@@ -62,6 +62,7 @@
62
62
  "./bin",
63
63
  "./dist",
64
64
  "./data",
65
+ "./mcp-server",
65
66
  "./oclif.manifest.json",
66
67
  "./templates"
67
68
  ],