This guide explains how to extend and modify the CI/CD Security Platform.
- Architecture Overview
- Project Structure
- Development Setup
- Adding New Tools
- Adding New Handlers
- Adding MCP Resources
- Testing
- Type Definitions
- Code Style
- Build and Release
The platform follows a layered architecture:
┌─────────────────────────────────────────────────────────┐
│ Consumers │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ MCP Server │ │ CI/CD Agent │ │
│ │ (Claude Code) │ │ (CLI) │ │
│ └────────┬────────┘ └────────┬────────┘ │
│ │ │ │
│ └──────────┬───────────────┘ │
│ ▼ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ @cicd/shared │ │
│ │ ┌─────────┐ ┌──────────┐ ┌──────────────────┐ │ │
│ │ │Handlers │ │Validation│ │ HTTP Client │ │ │
│ │ └─────────┘ └──────────┘ └──────────────────┘ │ │
│ │ ┌─────────┐ ┌──────────┐ ┌──────────────────┐ │ │
│ │ │ Config │ │ Types │ │ Utils │ │ │
│ │ └─────────┘ └──────────┘ └──────────────────┘ │ │
│ └─────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ External Services │ │
│ │ Gitea │ Drone │ SonarQube │ D-Track │ Trivy │ │
│ └─────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
- Shared Library First: All business logic lives in
@cicd/shared - Thin Consumers: MCP Server and Agent are thin wrappers
- Single Source of Truth: Tool definitions are defined once
- Type Safety: TypeScript throughout with strict mode
ci-co/
├── shared/ # @cicd/shared - Core library
│ ├── src/
│ │ ├── index.ts # Public exports
│ │ ├── config.ts # Configuration loader
│ │ ├── handlers.ts # API handlers
│ │ ├── validation.ts # Input validation
│ │ ├── http.ts # HTTP utilities
│ │ ├── types.ts # Type definitions
│ │ └── index.test.ts # Tests
│ ├── package.json
│ └── tsconfig.json
│
├── mcp-server/ # MCP Server for Claude Code
│ ├── src/
│ │ ├── index.ts # Server entry point
│ │ ├── handlers.ts # Re-exported handlers
│ │ ├── handlers.test.ts # Handler tests
│ │ └── index.test.ts # Server tests
│ ├── package.json
│ └── tsconfig.json
│
├── cicd-agent/ # CLI Agent
│ ├── src/
│ │ ├── index.ts # CLI entry point
│ │ ├── tools.ts # Tool definitions
│ │ ├── tools.test.ts # Tool tests
│ │ └── index.test.ts # Agent tests
│ ├── package.json
│ └── tsconfig.json
│
├── scripts/ # Installation scripts
├── docs/ # Documentation
├── .github/ # GitHub workflows
├── docker-compose.yml # Infrastructure
├── package.json # Root workspace config
└── .env.example # Environment template
- Node.js >= 18.0.0
- npm >= 9.0.0
- Docker Desktop (for running services)
# Clone the repository
git clone https://github.com/KennethEhmsen/ci-co.git
cd ci-co
# Install dependencies (all workspaces)
npm install
# Build all packages
npm run build
# Run tests
npm test# Watch mode for shared library
cd shared && npm run dev
# In another terminal, watch the package you're working on
cd mcp-server && npm run dev
# or
cd cicd-agent && npm run dev# Lint all packages
npm run lint
# Fix lint issues
npm run lint:fix
# Format code
npm run format
# Check formatting
npm run format:checkTo add a new tool to the platform, follow these steps:
Edit shared/src/handlers.ts:
// =============================================================================
// Your New Integration
// =============================================================================
/**
* Fetch data from your new service
* @param param1 - Description of parameter
* @returns Promise with the response data
*/
export async function myServiceGetData(param1: string): Promise<MyServiceResponse> {
if (!config.myService.apiKey) {
throw new Error("MyService API key not configured. Set MY_SERVICE_API_KEY environment variable.");
}
return fetchJson(`${config.myService.url}/api/data/${param1}`, {
headers: { "Authorization": `Bearer ${config.myService.apiKey}` },
});
}Edit shared/src/index.ts:
// Add to exports
export {
// ... existing exports ...
myServiceGetData,
} from "./handlers.js";Edit cicd-agent/src/tools.ts:
// Add to toolHandlers map
const toolHandlers: Record<string, ToolHandler> = {
// ... existing handlers ...
// My Service
my_service_get_data: async (input) => myServiceGetData(input.param1 as string),
};
// Add to tools array
export const tools: Anthropic.Tool[] = [
// ... existing tools ...
// My Service Tools
{
name: "my_service_get_data",
description: "Fetch data from My Service",
input_schema: {
type: "object" as const,
properties: {
param1: {
type: "string",
description: "The parameter to fetch",
},
},
required: ["param1"],
},
},
];Edit mcp-server/src/index.ts:
// Import the handler
import { myServiceGetData } from "./handlers.js";
// Add tool definition to TOOLS array
const TOOLS = [
// ... existing tools ...
{
name: "my_service_get_data",
description: "Fetch data from My Service",
inputSchema: {
type: "object",
properties: {
param1: {
type: "string",
description: "The parameter to fetch",
},
},
required: ["param1"],
},
},
];
// Add handler in CallToolRequestSchema handler
case "my_service_get_data":
return myServiceGetData(args.param1 as string);Edit shared/src/config.ts:
export const config = {
// ... existing config ...
myService: {
url: process.env.MY_SERVICE_URL || "http://localhost:8090",
apiKey: process.env.MY_SERVICE_API_KEY || "",
},
};Update .env.example:
# My Service
MY_SERVICE_URL=http://localhost:8090
MY_SERVICE_API_KEY=your-api-keyCreate tests in the appropriate test files:
// shared/src/index.test.ts
describe("myServiceGetData", () => {
it("should fetch data successfully", async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ data: "test" }),
});
const result = await myServiceGetData("test-param");
expect(result).toEqual({ data: "test" });
});
});Edit shared/src/types.ts:
// =============================================================================
// My Service Types
// =============================================================================
export interface MyServiceResponse {
data: string;
metadata?: {
timestamp: string;
};
}Update docs/API.md with the new tool documentation.
Handlers are the core business logic that interact with external services.
/**
* Handler function template
*
* @param requiredParam - A required parameter
* @param optionalParam - An optional parameter with default
* @returns Promise resolving to the API response
* @throws Error if validation fails or API call fails
*/
export async function serviceActionName(
requiredParam: string,
optionalParam: string = "default"
): Promise<ResponseType> {
// 1. Validate inputs
const safeParam = validateInput(requiredParam);
if (!safeParam) {
throw new Error("Invalid parameter provided");
}
// 2. Check configuration
if (!config.service.apiKey) {
throw new Error("Service API key not configured.");
}
// 3. Make API call
try {
return await fetchJson(`${config.service.url}/api/endpoint`, {
method: "POST",
headers: {
"Authorization": `Bearer ${config.service.apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ param: safeParam }),
});
} catch (error: any) {
// 4. Handle errors gracefully
if (error.response) {
return { error: error.message, details: error.response };
}
throw error;
}
}Use the validation utilities from validation.ts:
import { validateSeverity, sanitizePath, sanitizeImageName } from "./validation.js";
// Path validation (removes path traversal attempts)
const safePath = sanitizePath(userPath);
// Image name validation (removes shell metacharacters)
const safeImage = sanitizeImageName(imageName);
// Severity validation (ensures valid severity levels)
const safeSeverity = validateSeverity(severity);MCP Resources provide read-only data to Claude Code.
Edit mcp-server/src/index.ts:
// Add to RESOURCES array
const RESOURCES = [
// ... existing resources ...
{
uri: "cicd://my-resource",
name: "My Resource",
description: "Description of what this resource provides",
mimeType: "application/json",
},
];
// Handle in ReadResourceRequestSchema handler
case "cicd://my-resource":
return {
contents: [{
uri: request.params.uri,
mimeType: "application/json",
text: JSON.stringify(await getMyResourceData(), null, 2),
}],
};Each package has its own test file(s):
shared/src/index.test.ts- Core library testsmcp-server/src/index.test.ts- Server integration testsmcp-server/src/handlers.test.ts- Handler unit testscicd-agent/src/index.test.ts- Agent testscicd-agent/src/tools.test.ts- Tool execution tests
# All packages
npm test
# With coverage
npm run test:coverage
# Single package
cd shared && npm test
cd mcp-server && npm test
cd cicd-agent && npm test
# Watch mode
npm test -- --watchimport { vi, describe, it, expect, beforeEach } from "vitest";
describe("myHandler", () => {
beforeEach(() => {
vi.resetAllMocks();
});
it("should handle successful response", async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ data: "success" }),
});
const result = await myHandler("param");
expect(result).toEqual({ data: "success" });
expect(fetch).toHaveBeenCalledWith(
expect.stringContaining("/api/endpoint"),
expect.any(Object)
);
});
it("should handle API errors", async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 404,
statusText: "Not Found",
});
await expect(myHandler("param")).rejects.toThrow("Not Found");
});
});vi.mock("node:util", async (importOriginal) => {
const original = await importOriginal() as any;
return {
...original,
promisify: (_fn: any) => {
return async (..._args: any[]) => {
const mockExecResult = (global as any).__mockExecResult;
if (mockExecResult?.error) {
throw mockExecResult.error;
}
return mockExecResult || { stdout: "{}", stderr: "" };
};
},
};
});
// In test
(global as any).__mockExecResult = {
stdout: JSON.stringify({ Results: [] }),
stderr: "",
};Edit shared/src/types.ts:
// =============================================================================
// Service Name Types
// =============================================================================
/**
* Response from the service API
*/
export interface ServiceResponse {
/** Unique identifier */
id: string;
/** Display name */
name: string;
/** Optional metadata */
metadata?: ServiceMetadata;
}
/**
* Metadata associated with a service response
*/
export interface ServiceMetadata {
/** When the data was created */
createdAt: string;
/** When the data was last updated */
updatedAt: string;
}import type { ServiceResponse } from "./types.js";
export async function getServiceData(): Promise<ServiceResponse> {
// Implementation
}The project uses ESLint with TypeScript support. Key rules:
- No explicit
any(warning) - No unused variables (error, except
_prefixed) - Consistent code formatting via Prettier
{
"semi": true,
"singleQuote": false,
"tabWidth": 2,
"trailingComma": "es5",
"printWidth": 100
}| Type | Convention | Example |
|---|---|---|
| Functions | camelCase, verb prefix | getUserData, createRepo |
| Variables | camelCase | userName, repoList |
| Constants | UPPER_SNAKE_CASE | DEFAULT_TIMEOUT, API_VERSION |
| Types/Interfaces | PascalCase | UserData, RepoResponse |
| Files | kebab-case or camelCase | handlers.ts, index.test.ts |
# Build all packages
npm run build
# Build specific package
cd shared && npm run buildUpdate version in all package.json files:
package.json(root)shared/package.jsonmcp-server/package.jsoncicd-agent/package.json
- Update
CHANGELOG.mdwith changes - Bump versions in all package.json files
- Commit:
git commit -m "Bump version to X.Y.Z" - Tag:
git tag vX.Y.Z - Push:
git push && git push --tags - GitHub will create a release automatically
- All tests pass (
npm test) - Lint passes (
npm run lint) - Build succeeds (
npm run build) - CHANGELOG.md updated
- Version numbers updated
- Documentation updated