duoops 0.2.8 → 0.2.12

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.
@@ -6,7 +6,7 @@ import fs from 'node:fs';
6
6
  import path from 'node:path';
7
7
  import { fileURLToPath } from 'node:url';
8
8
  import { configManager } from '../../lib/config.js';
9
- import { detectGcpProject, enableApis, ensureCloudBuildServiceAccount, getActiveAccount, requireGcloud, validateProjectAccess } from '../../lib/gcloud.js';
9
+ import { detectGcpProject, enableApis, ensureBigQueryAccess, ensureCloudBuildServiceAccount, getActiveAccount, requireGcloud, validateProjectAccess } from '../../lib/gcloud.js';
10
10
  export default class McpDeploy extends Command {
11
11
  static description = 'Deploy the DuoOps MCP server to Google Cloud Run';
12
12
  static flags = {
@@ -75,6 +75,8 @@ export default class McpDeploy extends Command {
75
75
  }
76
76
  this.log(gray('\nConfiguring Cloud Build service account...'));
77
77
  ensureCloudBuildServiceAccount(gcpProject, (msg) => this.log(gray(msg)));
78
+ this.log(gray('\nConfiguring BigQuery access for MCP server...'));
79
+ ensureBigQueryAccess(gcpProject, (msg) => this.log(gray(msg)));
78
80
  const mcpServerDir = flags.source || path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../mcp-server');
79
81
  if (!fs.existsSync(path.join(mcpServerDir, 'Dockerfile'))) {
80
82
  this.error(`MCP server source not found at ${mcpServerDir}. Provide --source flag or run from the DuoOps repo.`);
@@ -4,4 +4,5 @@ export declare function detectGcpProject(): string | undefined;
4
4
  export declare function validateProjectAccess(project: string): void;
5
5
  export declare function enableApis(project: string, apis: string[], log: (msg: string) => void): void;
6
6
  export declare function getProjectNumber(project: string): string | undefined;
7
+ export declare function ensureBigQueryAccess(project: string, log: (msg: string) => void): void;
7
8
  export declare function ensureCloudBuildServiceAccount(project: string, log: (msg: string) => void): void;
@@ -75,6 +75,34 @@ export function getProjectNumber(project) {
75
75
  return undefined;
76
76
  }
77
77
  }
78
+ export function ensureBigQueryAccess(project, log) {
79
+ const projectNumber = getProjectNumber(project);
80
+ if (!projectNumber) {
81
+ log(' ? Could not determine project number, skipping BigQuery permissions');
82
+ return;
83
+ }
84
+ const computeSa = `${projectNumber}-compute@developer.gserviceaccount.com`;
85
+ const roles = [
86
+ 'roles/bigquery.user',
87
+ 'roles/bigquery.dataViewer',
88
+ ];
89
+ log(` Granting BigQuery permissions to ${computeSa}...`);
90
+ for (const role of roles) {
91
+ try {
92
+ execSync(`gcloud projects add-iam-policy-binding ${project} --member="serviceAccount:${computeSa}" --role="${role}" --quiet`, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
93
+ log(` ✓ ${role}`);
94
+ }
95
+ catch (error) {
96
+ const stderr = error.stderr || '';
97
+ if (stderr.includes('PERMISSION_DENIED')) {
98
+ log(` ? ${role} — no permission to grant`);
99
+ }
100
+ else {
101
+ log(` ? ${role} — failed: ${stderr.trim().split('\n')[0]}`);
102
+ }
103
+ }
104
+ }
105
+ }
78
106
  export function ensureCloudBuildServiceAccount(project, log) {
79
107
  const projectNumber = getProjectNumber(project);
80
108
  if (!projectNumber) {
@@ -5,17 +5,25 @@ import {NextFunction, Request, Response} from 'express'
5
5
  export const GITLAB_URL = process.env.GITLAB_URL || 'https://gitlab.com'
6
6
 
7
7
  export async function validateGitLabToken(token: string): Promise<boolean> {
8
+ try {
9
+ const response = await axios.get(`${GITLAB_URL}/oauth/token/info`, {
10
+ headers: {Authorization: `Bearer ${token}`},
11
+ validateStatus: (status) => status < 500,
12
+ })
13
+
14
+ if (response.status === 200) return true
15
+ } catch {/* ignore */}
16
+
8
17
  try {
9
18
  const response = await axios.get(`${GITLAB_URL}/api/v4/user`, {
10
- headers: {
11
- Authorization: `Bearer ${token}`,
12
- },
19
+ headers: {Authorization: `Bearer ${token}`},
20
+ validateStatus: (status) => status < 500,
13
21
  })
14
- return response.status === 200
15
- } catch (error) {
16
- console.error('GitLab token validation failed:', error)
17
- return false
18
- }
22
+
23
+ if (response.status === 200) return true
24
+ } catch {/* ignore */}
25
+
26
+ return false
19
27
  }
20
28
 
21
29
  export function authMiddleware(req: Request, res: Response, next: NextFunction) {
@@ -1,5 +1,6 @@
1
1
  import {McpServer} from '@modelcontextprotocol/sdk/server/mcp.js'
2
2
  import {SSEServerTransport} from '@modelcontextprotocol/sdk/server/sse.js'
3
+ import {StreamableHTTPServerTransport} from '@modelcontextprotocol/sdk/server/streamableHttp.js'
3
4
  import express from 'express'
4
5
 
5
6
  import {authMiddleware, oauthEndpoints} from './auth.js'
@@ -12,11 +13,11 @@ async function main() {
12
13
  version: '1.0.0',
13
14
  })
14
15
 
15
- // Register tools
16
16
  registerEmissionsTools(server)
17
17
  registerProfileTools(server)
18
18
 
19
19
  const app = express()
20
+ app.use(express.json())
20
21
 
21
22
  app.get('/health', (_req, res) => {
22
23
  res.json({service: 'duoops-mcp', status: 'ok', version: '1.0.0'})
@@ -24,30 +25,48 @@ async function main() {
24
25
 
25
26
  oauthEndpoints(app)
26
27
 
27
- // MCP SSE Transport setup
28
- let transport: SSEServerTransport
28
+ let sseTransport: SSEServerTransport
29
29
 
30
30
  app.get('/sse', authMiddleware, async (req, res) => {
31
- transport = new SSEServerTransport('/message', res)
32
- await server.connect(transport)
31
+ sseTransport = new SSEServerTransport('/message', res)
32
+ await server.connect(sseTransport)
33
33
  })
34
34
 
35
35
  app.post('/message', authMiddleware, async (req, res) => {
36
- if (!transport) {
36
+ if (!sseTransport) {
37
37
  res.sendStatus(400)
38
38
  return
39
39
  }
40
40
 
41
- await transport.handlePostMessage(req, res)
41
+ await sseTransport.handlePostMessage(req, res)
42
+ })
43
+
44
+ app.post('/mcp', authMiddleware, async (req, res) => {
45
+ const transport = new StreamableHTTPServerTransport({sessionIdGenerator: undefined})
46
+ res.on('close', () => {
47
+ transport.close()
48
+ })
49
+ await server.connect(transport)
50
+ await transport.handleRequest(req, res)
51
+ })
52
+
53
+ app.get('/mcp', authMiddleware, async (req, res) => {
54
+ const transport = new StreamableHTTPServerTransport({sessionIdGenerator: undefined})
55
+ res.on('close', () => {
56
+ transport.close()
57
+ })
58
+ await server.connect(transport)
59
+ await transport.handleRequest(req, res)
42
60
  })
43
61
 
44
- // Legacy/Simple HTTP endpoint (optional, if SSE not supported by some clients)
45
- // Currently we focus on SSE as per SDK standard for HTTP transport
62
+ app.delete('/mcp', async (_req, res) => {
63
+ res.status(405).send('Session termination not supported')
64
+ })
46
65
 
47
66
  const port = process.env.PORT || 8080
48
67
  app.listen(port, () => {
49
68
  console.log(`DuoOps MCP Server running on port ${port}`)
50
- console.log(`OAuth discovery at /.well-known/oauth-protected-resource`)
69
+ console.log(`Transports: SSE at /sse, HTTP Streamable at /mcp`)
51
70
  })
52
71
  }
53
72
 
@@ -23,18 +23,9 @@ export class BigQueryService {
23
23
 
24
24
  async getJobEmissions(jobId: number, projectId?: number): Promise<EmissionsRecord | null> {
25
25
  const query = `
26
- SELECT
27
- job_id,
28
- pipeline_id,
29
- gitlab_project_id as project_id,
30
- machine_type,
31
- region,
32
- total_emissions,
33
- (cpu_energy_kwh + ram_energy_kwh) as total_energy_kwh,
34
- created_at,
35
- runtime_seconds
26
+ SELECT *
36
27
  FROM \`${this.projectId}.${this.datasetId}.${this.tableId}\`
37
- WHERE job_id = @jobId
28
+ WHERE gitlab_job_id = @jobId
38
29
  ${projectId ? 'AND gitlab_project_id = @projectId' : ''}
39
30
  LIMIT 1
40
31
  `
@@ -48,75 +39,61 @@ export class BigQueryService {
48
39
  return rows.length > 0 ? (rows[0] as EmissionsRecord) : null
49
40
  }
50
41
 
51
- async getPipelineEmissions(pipelineId: number, projectId?: number): Promise<{
42
+ async getProjectEmissionsSummary(projectId: number, limit: number = 10): Promise<EmissionsRecord[]> {
43
+ const query = `
44
+ SELECT *
45
+ FROM \`${this.projectId}.${this.datasetId}.${this.tableId}\`
46
+ WHERE gitlab_project_id = @projectId
47
+ ORDER BY ingested_at DESC
48
+ LIMIT @limit
49
+ `
50
+
51
+ const options = {
52
+ params: {limit, projectId},
53
+ query,
54
+ }
55
+
56
+ const [rows] = await this.bigquery.query(options)
57
+ return rows as EmissionsRecord[]
58
+ }
59
+
60
+ async getProjectJobEmissions(projectId: number, limit: number = 20): Promise<{
52
61
  jobs: Partial<EmissionsRecord>[];
53
- pipeline_id: number;
54
62
  total_emissions_g: number;
55
63
  total_energy_kwh: number;
56
64
  }> {
57
65
  const query = `
58
- SELECT
59
- job_id,
60
- machine_type,
61
- total_emissions,
62
- (cpu_energy_kwh + ram_energy_kwh) as total_energy_kwh,
63
- runtime_seconds
66
+ SELECT *
64
67
  FROM \`${this.projectId}.${this.datasetId}.${this.tableId}\`
65
- WHERE pipeline_id = @pipelineId
66
- ${projectId ? 'AND gitlab_project_id = @projectId' : ''}
68
+ WHERE gitlab_project_id = @projectId
69
+ ORDER BY ingested_at DESC
70
+ LIMIT @limit
67
71
  `
68
72
 
69
73
  const options = {
70
- params: {pipelineId, projectId},
74
+ params: {limit, projectId},
71
75
  query,
72
76
  }
73
77
 
74
78
  const [rows] = await this.bigquery.query(options)
75
-
79
+
76
80
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
77
- const totalEmissions = rows.reduce((sum: number, row: any) => sum + (row.total_emissions || 0), 0)
81
+ const totalEmissions = rows.reduce((sum: number, row: any) => sum + (row.total_emissions_g || 0), 0)
78
82
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
79
- const totalEnergy = rows.reduce((sum: number, row: any) => sum + (row.total_energy_kwh || 0), 0)
83
+ const totalEnergy = rows.reduce((sum: number, row: any) => sum + (row.energy_kwh || 0), 0)
80
84
 
81
85
  return {
82
86
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
83
87
  jobs: rows.map((r: any) => ({
84
- job_id: r.job_id,
88
+ energy_kwh: r.energy_kwh,
89
+ gitlab_job_id: r.gitlab_job_id,
90
+ gitlab_job_name: r.gitlab_job_name,
85
91
  machine_type: r.machine_type,
86
92
  runtime_seconds: r.runtime_seconds,
87
- total_emissions: r.total_emissions,
88
- total_energy_kwh: r.total_energy_kwh
93
+ total_emissions_g: r.total_emissions_g,
89
94
  })),
90
- pipeline_id: pipelineId,
91
95
  total_emissions_g: totalEmissions,
92
96
  total_energy_kwh: totalEnergy
93
97
  }
94
98
  }
95
-
96
- async getProjectEmissionsSummary(projectId: number, limit: number = 10): Promise<EmissionsRecord[]> {
97
- const query = `
98
- SELECT
99
- job_id,
100
- pipeline_id,
101
- gitlab_project_id as project_id,
102
- machine_type,
103
- region,
104
- total_emissions,
105
- (cpu_energy_kwh + ram_energy_kwh) as total_energy_kwh,
106
- created_at,
107
- runtime_seconds
108
- FROM \`${this.projectId}.${this.datasetId}.${this.tableId}\`
109
- WHERE gitlab_project_id = @projectId
110
- ORDER BY created_at DESC
111
- LIMIT @limit
112
- `
113
-
114
- const options = {
115
- params: {limit, projectId},
116
- query,
117
- }
118
-
119
- const [rows] = await this.bigquery.query(options)
120
- return rows as EmissionsRecord[]
121
- }
122
99
  }
@@ -54,13 +54,14 @@ export interface CarbonResult {
54
54
  }
55
55
 
56
56
  export interface EmissionsRecord {
57
- created_at: string;
58
- job_id: number;
57
+ energy_kwh: number;
58
+ gitlab_job_id: number;
59
+ gitlab_job_name: string;
60
+ gitlab_project_id: number;
61
+ gitlab_user_name: string;
62
+ ingested_at: string;
59
63
  machine_type: string;
60
- pipeline_id: number;
61
- project_id: number;
62
64
  region: string;
63
65
  runtime_seconds: number;
64
- total_emissions: number;
65
- total_energy_kwh: number;
66
+ total_emissions_g: number;
66
67
  }
@@ -15,7 +15,7 @@ export function registerEmissionsTools(server: McpServer) {
15
15
  },
16
16
  async ({job_id, project_id}) => {
17
17
  const emissions = await bq.getJobEmissions(job_id, project_id)
18
-
18
+
19
19
  if (!emissions) {
20
20
  return {
21
21
  content: [{text: `No emissions data found for job ${job_id}`, type: 'text'}],
@@ -33,74 +33,76 @@ export function registerEmissionsTools(server: McpServer) {
33
33
  )
34
34
 
35
35
  server.tool(
36
- 'get_pipeline_emissions',
36
+ 'get_project_emissions_summary',
37
37
  {
38
- pipeline_id: z.number().describe('GitLab Pipeline ID'),
39
- project_id: z.number().optional().describe('GitLab Project ID (optional filter)'),
38
+ limit: z.number().optional().default(10).describe('Number of recent jobs to return'),
39
+ project_id: z.number().describe('GitLab Project ID'),
40
40
  },
41
- async ({pipeline_id, project_id}) => {
42
- const result = await bq.getPipelineEmissions(pipeline_id, project_id)
43
-
44
- if (result.jobs.length === 0) {
41
+ async ({limit, project_id}) => {
42
+ const records = await bq.getProjectEmissionsSummary(project_id, limit)
43
+
44
+ if (records.length === 0) {
45
45
  return {
46
- content: [{text: `No emissions data found for pipeline ${pipeline_id}`, type: 'text'}],
46
+ content: [{text: `No emissions history found for project ${project_id}. The measure CI component may not have run yet.`, type: 'text'}],
47
47
  isError: true,
48
48
  }
49
49
  }
50
50
 
51
+ const totalEmissions = records.reduce((sum, r) => sum + r.total_emissions_g, 0)
52
+ const avgEmissions = totalEmissions / records.length
53
+ const totalEnergy = records.reduce((sum, r) => sum + r.energy_kwh, 0)
54
+
51
55
  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
+ Project ${project_id} Emissions Summary (Last ${records.length} jobs):
57
+ Average per job: ${avgEmissions.toFixed(3)} gCO2e
58
+ Total recent: ${totalEmissions.toFixed(3)} gCO2e
59
+ Total energy: ${totalEnergy.toFixed(4)} kWh
56
60
 
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')}
61
+ Recent Jobs:
62
+ ${records.map(r => `- ${r.ingested_at} | ${r.gitlab_job_name} (${r.machine_type}): ${r.total_emissions_g.toFixed(3)} gCO2e, ${r.energy_kwh.toFixed(4)} kWh, ${r.runtime_seconds.toFixed(0)}s`).join('\n')}
63
63
  `
64
64
 
65
65
  return {
66
- content: [
67
- {text: summary, type: 'text'},
68
- {text: JSON.stringify(result, null, 2), type: 'text'} // Full JSON also provided
69
- ]
66
+ content: [{text: summary, type: 'text'}]
70
67
  }
71
68
  }
72
69
  )
73
70
 
74
71
  server.tool(
75
- 'get_project_emissions_summary',
72
+ 'get_project_job_emissions',
76
73
  {
77
- limit: z.number().optional().default(10).describe('Number of recent jobs to return'),
74
+ limit: z.number().optional().default(20).describe('Number of recent jobs to return'),
78
75
  project_id: z.number().describe('GitLab Project ID'),
79
76
  },
80
77
  async ({limit, project_id}) => {
81
- const records = await bq.getProjectEmissionsSummary(project_id, limit)
82
-
83
- if (records.length === 0) {
78
+ const result = await bq.getProjectJobEmissions(project_id, limit)
79
+
80
+ if (result.jobs.length === 0) {
84
81
  return {
85
- content: [{text: `No emissions history found for project ${project_id}`, type: 'text'}],
82
+ content: [{text: `No emissions data found for project ${project_id}`, type: 'text'}],
86
83
  isError: true,
87
84
  }
88
85
  }
89
86
 
90
- const totalEmissions = records.reduce((sum, r) => sum + r.total_emissions, 0)
91
- const avgEmissions = totalEmissions / records.length
92
-
93
87
  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
88
+ Project ${project_id} Carbon Report:
89
+ Total Emissions: ${result.total_emissions_g.toFixed(3)} gCO2e
90
+ Total Energy: ${result.total_energy_kwh.toFixed(4)} kWh
91
+ Jobs Recorded: ${result.jobs.length}
97
92
 
98
- Recent Jobs:
99
- ${records.map(r => `- ${r.created_at}: ${r.total_emissions.toFixed(3)} gCO2e (${r.machine_type})`).join('\n')}
93
+ Top Emitters:
94
+ ${result.jobs
95
+ .sort((a, b) => (b.total_emissions_g || 0) - (a.total_emissions_g || 0))
96
+ .slice(0, 5)
97
+ .map(j => `- Job ${j.gitlab_job_id} ${j.gitlab_job_name} (${j.machine_type}): ${j.total_emissions_g?.toFixed(3)} gCO2e`)
98
+ .join('\n')}
100
99
  `
101
100
 
102
101
  return {
103
- content: [{text: summary, type: 'text'}]
102
+ content: [
103
+ {text: summary, type: 'text'},
104
+ {text: JSON.stringify(result, null, 2), type: 'text'}
105
+ ]
104
106
  }
105
107
  }
106
108
  )
@@ -669,5 +669,5 @@
669
669
  ]
670
670
  }
671
671
  },
672
- "version": "0.2.8"
672
+ "version": "0.2.12"
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.8",
4
+ "version": "0.2.12",
5
5
  "author": "Younes Laaroussi",
6
6
  "bin": {
7
7
  "duoops": "./bin/run.js"