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.
- package/dist/commands/init.js +3 -2
- package/mcp-server/.dockerignore +3 -0
- package/mcp-server/Dockerfile +27 -0
- package/mcp-server/README.md +70 -0
- package/mcp-server/data/aws_machine_power_profiles.json +54 -0
- package/mcp-server/data/cpu_physical_specs.json +105 -0
- package/mcp-server/data/cpu_power_profiles.json +275 -0
- package/mcp-server/data/gcp_machine_power_profiles.json +1802 -0
- package/mcp-server/data/runtime-pue-mappings.json +183 -0
- package/mcp-server/package.json +30 -0
- package/mcp-server/src/auth.ts +73 -0
- package/mcp-server/src/index.ts +58 -0
- package/mcp-server/src/lib/bigquery.ts +122 -0
- package/mcp-server/src/lib/intensity.ts +33 -0
- package/mcp-server/src/lib/power-profiles.ts +102 -0
- package/mcp-server/src/lib/types.ts +66 -0
- package/mcp-server/src/lib/zone-mapper.ts +63 -0
- package/mcp-server/src/tools/emissions.ts +107 -0
- package/mcp-server/src/tools/profiles.ts +72 -0
- package/mcp-server/tsconfig.json +18 -0
- package/oclif.manifest.json +1 -1
- package/package.json +2 -1
|
@@ -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
|
+
}
|
package/oclif.manifest.json
CHANGED
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.
|
|
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
|
],
|