Building Custom Molecules
Molecules are discovery plugins that automatically sync data from external services into your SixDegree knowledge graph and provide MCP tools for AI agent interactions.
Overview
A molecule is a standalone process that:
- Discovers entities - Syncs data from external services (repositories, workflows, users, etc.)
- Creates relationships - Establishes connections between discovered entities
- Provides MCP tools - Gives AI agents capabilities to interact with external services
- Handles webhooks - Responds to real-time events from external services (optional)
Each molecule runs independently and communicates with the SixDegree platform via a standard protocol.
When to Build a Molecule
Build a custom molecule when you need to:
- Integrate a service not yet supported by existing molecules
- Discover custom internal resources or proprietary systems
- Implement specialized discovery logic for your domain
- Provide custom MCP tools for AI agent interactions
- Support a private or enterprise version of a service
Architecture
Dual Capabilities
Molecules support two distinct capabilities:
1. Discovery Capability
Discovers and syncs entities into your knowledge graph:
- Runs periodically (polling) or via webhooks
- Creates entities and relationships
- Updates when external data changes
- Targets a specific namespace
2. MCP Capability
Provides tools for AI agent interactions:
- Real-time operations (create issue, deploy, search)
- Read/write access to external services
- Available in chat interfaces
- Independent of discovery configuration
You can enable one or both capabilities per molecule.
Capability Design Principles
Understanding when to use Discovery vs MCP tools is critical for good molecule design.
Discovery (Ontology) — Slowly-Changing Structural Data
The ontology should contain structural entities that change infrequently and define the topology of your infrastructure:
- Organizations, projects, repositories, workspaces
- Users, groups, teams, roles
- Clusters, namespaces, services
- Channels, zones, policies
These entities are discovered periodically and cached. The AI agent queries the ontology to understand relationships and navigate the infrastructure graph.
Do NOT create MCP "list" tools for data that's already in the ontology. This creates redundancy and causes the AI to bypass the ontology's relationship graph.
MCP Tools — Dynamic Data and Actions
MCP tools should provide:
-
Live/Dynamic Data — Data that changes frequently and shouldn't be cached:
- Vulnerabilities, security issues
- Recent pipeline runs, workflow executions
- Active incidents, alerts
- Current metrics, logs
-
Actions — Operations that modify state:
- Trigger scans, runs, deployments
- Send messages, create tickets
- Approve/reject workflows
-
Deep Inspection — Detailed live data for specific entities:
get_projectwith live issue countsget_remediationadviceget_policyrules
Examples
| ❌ Redundant (Don't Do) | ✅ Appropriate |
|---|---|
list_projects | Discovery: Project entities in ontology |
list_channels | Discovery: Channel entities in ontology |
list_users | Discovery: User entities in ontology |
list_workspaces | Discovery: Workspace entities in ontology |
| — | list_pipelines — Recent/dynamic pipeline runs |
| — | search_vulnerabilities — Live vulnerability data |
| — | trigger_pipeline — Action |
| — | send_message — Action |
Why This Matters
When the AI uses a "list" tool instead of querying the ontology:
- Bypasses relationship traversal — The AI won't discover relationships like
SCANSorDEPLOYS_TOthat connect entities across tools - Breaks late tool disclosure — Tools are disclosed based on entity types discovered in the ontology; if the AI never traverses the ontology, relevant tools aren't disclosed
- Loses the graph — The AI gets a flat list instead of understanding the infrastructure topology
If the data is discovered into the ontology, don't provide a list tool for it. The ontology handles "what exists" — MCP tools handle "what's happening now" and "do something."
Molecule SDK
The Molecule SDK provides the foundation for building molecules:
- Go SDK -
github.com/sixdegree-ai/molecule-sdk/go/molecule(production-ready) - Python SDK - Coming soon
- TypeScript SDK - Planned
Installation
go get github.com/sixdegree-ai/molecule-sdk/go/molecule
Quick Start
1. Create Module
mkdir my-molecule
cd my-molecule
go mod init github.com/yourorg/my-molecule
go get github.com/sixdegree-ai/molecule-sdk/go/molecule
2. Implement Molecule
package main
import (
"context"
"encoding/json"
"github.com/sixdegree-ai/molecule-sdk/go/molecule"
"go.uber.org/zap"
)
const version = "1.0.0"
func main() {
molecule.Run(&MyMolecule{})
}
type MyMolecule struct {
*molecule.BaseMolecule
config *molecule.MoleculeConfig
logger *zap.Logger
}
// Discovery configuration schema
var discoveryConfigSchema = json.RawMessage(`{
"type": "object",
"required": ["api_key"],
"properties": {
"api_key": {
"type": "string",
"title": "API Key",
"description": "Your service API key",
"x-order": 1,
"x-sixdegree": {"widget": "secret"}
},
"base_url": {
"type": "string",
"title": "Base URL",
"description": "Service base URL",
"x-order": 2,
"default": "https://api.myservice.com"
}
}
}`)
// MCP configuration schema
var mcpConfigSchema = json.RawMessage(`{
"type": "object",
"required": ["api_key"],
"properties": {
"api_key": {
"type": "string",
"title": "API Key",
"description": "API key for MCP tools"
}
}
}`)
// Metadata returns molecule metadata
func (m *MyMolecule) Metadata() molecule.Metadata {
return molecule.Metadata{
ID: "my-molecule",
Name: "My Service Integration",
Description: "Discovers resources from My Service",
Version: version,
Author: "Your Name",
Capabilities: molecule.Capabilities{
Discovery: &molecule.CapabilityConfig{
ConfigSchema: discoveryConfigSchema,
},
MCP: &molecule.CapabilityConfig{
ConfigSchema: mcpConfigSchema,
},
},
}
}
// Initialize sets up the molecule with configuration
func (m *MyMolecule) Initialize(ctx context.Context, config *molecule.MoleculeConfig, logger *zap.Logger) error {
m.config = config
m.logger = logger
// Validate configuration
if m.config.Discovery != nil && m.config.Discovery.Settings["api_key"] == "" {
return fmt.Errorf("api_key is required for discovery")
}
return nil
}
// Discover performs discovery and returns entities
func (m *MyMolecule) Discover(ctx context.Context) (*molecule.DiscoveryResult, error) {
m.logger.Info("Starting discovery")
// Get configuration
apiKey := m.config.Discovery.Settings["api_key"].(string)
baseURL := m.config.Discovery.Settings["base_url"].(string)
// Connect to your service
client := NewMyServiceClient(baseURL, apiKey)
// Fetch resources
resources, err := client.ListResources(ctx)
if err != nil {
return nil, fmt.Errorf("failed to list resources: %w", err)
}
// Convert to entities
entities := make([]molecule.Entity, 0, len(resources))
for _, r := range resources {
entities = append(entities, molecule.Entity{
UID: fmt.Sprintf("resource_%s", r.ID),
Kind: "Resource",
Name: r.Name,
Namespace: m.config.Namespace,
Properties: map[string]interface{}{
"url": r.URL,
"status": r.Status,
"created_at": r.CreatedAt,
},
})
}
m.logger.Info("Discovery complete", zap.Int("entities", len(entities)))
return &molecule.DiscoveryResult{
Entities: entities,
}, nil
}
3. Build and Test
go build -o my-molecule
# Test discovery
./my-molecule discover --config config.yaml --dry-run
# Run molecule server
./my-molecule serve --port 8080
Configuration Schemas
Configuration schemas define what users need to provide to configure your molecule. They use JSON Schema with SixDegree-specific extensions.
Schema Structure
var discoveryConfigSchema = json.RawMessage(`{
"type": "object",
"required": ["token"],
"properties": {
"token": {
"type": "string",
"title": "Access Token",
"description": "Service access token with required permissions",
"x-order": 1,
"x-sixdegree": {
"widget": "secret"
}
},
"organizations": {
"type": "array",
"title": "Organizations",
"description": "Organizations to discover",
"x-order": 2,
"items": {
"type": "string"
}
},
"include_archived": {
"type": "boolean",
"title": "Include Archived",
"description": "Include archived resources",
"x-order": 3,
"default": false
}
}
}`)
SixDegree Extensions
Use x-sixdegree for custom UI widgets:
| Widget | Description | Example Use Case |
|---|---|---|
secret | Password/token input (masked) | API keys, tokens |
org-picker | Organization selector | GitHub/GitLab orgs |
repo-picker | Repository selector | Specific repos |
multiselect | Multiple choice selector | Scopes, permissions |
Field Ordering
Use x-order to control field display order in the UI (lower numbers appear first).
Dynamic Configuration
For advanced use cases, support environment-specific or dynamic configuration:
func (m *MyMolecule) Initialize(ctx context.Context, config *molecule.MoleculeConfig, logger *zap.Logger) error {
// Load from config
baseURL := config.Discovery.Settings["base_url"].(string)
// Override with environment variables if present
if envURL := os.Getenv("MY_SERVICE_URL"); envURL != "" {
baseURL = envURL
}
m.client = NewClient(baseURL)
return nil
}
Molecule Interface
Implement the Molecule interface:
type Molecule interface {
// Metadata returns molecule metadata and capabilities
Metadata() Metadata
// Initialize sets up the molecule with configuration
Initialize(ctx context.Context, config *MoleculeConfig, logger *zap.Logger) error
// Discover performs discovery (optional, only if discovery capability enabled)
Discover(ctx context.Context) (*DiscoveryResult, error)
// MCPTools returns available MCP tools (optional, only if MCP capability enabled)
MCPTools() []MCPTool
// HandleWebhook handles webhook events (optional)
HandleWebhook(ctx context.Context, event *WebhookEvent) error
}
Entities
Entities are the nodes in your knowledge graph. Each entity represents a discovered resource.
Entity Structure
type Entity struct {
UID string // Unique identifier within namespace
Kind string // Entity kind (type)
Name string // Display name
Namespace string // Target namespace
Properties map[string]interface{} // Entity properties
Relations []Relation // Relationships to other entities
}
Entity UIDs
Entity UIDs must be:
- Unique within the namespace - No collisions with other entities
- Stable across discoveries - Same resource = same UID
- Descriptive - Include type prefix (e.g.,
repo_123,workflow_xyz)
// Good UID patterns
UID: fmt.Sprintf("repo_%s", repo.ID)
UID: fmt.Sprintf("user_%s", user.Login)
UID: fmt.Sprintf("workflow_%s_%s", owner, workflowName)
// Avoid
UID: "123" // Not descriptive
UID: fmt.Sprintf("%d", time.Now().Unix()) // Not stable
Entity Kinds
Entity kinds define the type of entity. Use consistent naming:
// Good kind names
Kind: "Repository"
Kind: "Workflow"
Kind: "User"
Kind: "Service"
// Avoid
Kind: "REPOSITORY" // All caps
Kind: "repo" // Too abbreviated
Kind: "MyRepo" // Inconsistent
Entity Properties
Properties store entity attributes as key-value pairs:
Properties: map[string]interface{}{
"url": "https://github.com/org/repo",
"language": "Go",
"stars": 42,
"private": false,
"created_at": time.Now(),
"topics": []string{"api", "golang"},
}
Property Guidelines:
- Use snake_case for keys
- Store primitive types (string, int, bool, float)
- Use ISO 8601 for timestamps
- Keep property counts reasonable (fewer than 50 per entity)
Relationships
Relationships connect entities to create the knowledge graph.
Relation Structure
type Relation struct {
Type string // Relationship type
Target string // Target entity UID
Properties map[string]interface{} // Optional relationship properties
}
Relationship Types
Use clear, verb-based relationship types:
// Good relationship types
Type: "DEPENDS_ON"
Type: "DEPLOYS_TO"
Type: "CONTRIBUTES_TO"
Type: "OWNS"
Type: "TRIGGERS"
// Avoid
Type: "related_to" // Too generic
Type: "has" // Unclear direction
Creating Relationships
entities := []molecule.Entity{
{
UID: "repo_api-service",
Kind: "Repository",
Name: "api-service",
Namespace: m.config.Namespace,
Properties: map[string]interface{}{
"language": "Go",
},
Relations: []molecule.Relation{
{
Type: "DEPENDS_ON",
Target: "lib_logger",
Properties: map[string]interface{}{
"version": "v1.2.3",
},
},
{
Type: "DEPLOYS_TO",
Target: "service_api-prod",
},
},
},
{
UID: "lib_logger",
Kind: "Library",
Name: "logger",
Namespace: m.config.Namespace,
},
{
UID: "service_api-prod",
Kind: "Service",
Name: "API Production",
Namespace: m.config.Namespace,
},
}
Relationship Patterns
Ownership:
Relations: []molecule.Relation{{Type: "OWNS", Target: "user_john"}}
Dependencies:
Relations: []molecule.Relation{
{Type: "DEPENDS_ON", Target: "lib_database", Properties: map[string]interface{}{"version": "^2.0.0"}},
{Type: "DEPENDS_ON", Target: "lib_cache"},
}
Deployment:
Relations: []molecule.Relation{
{Type: "DEPLOYS_TO", Target: "env_production"},
{Type: "DEPLOYED_FROM", Target: "repo_api"},
}
Relationship Ownership
Relationships in SixDegree follow a clear ownership model:
1. Intra-Domain Relationships (Molecule Responsibility)
Molecules are fully responsible for creating relationships between entities within their own domain. These relationships are created directly during discovery:
// Snyk molecule creates SCANS relationships between its own entities
func (m *SnykMolecule) Discover(ctx context.Context) (*molecule.DiscoveryResult, error) {
// Discover projects
for _, project := range projects {
projectEntity := molecule.Entity{
UID: fmt.Sprintf("snyk_project_%s", project.ID),
Kind: "SnykProject",
// ...
}
// Create relationship to the organization (both are Snyk entities)
projectEntity.Relations = append(projectEntity.Relations, molecule.Relation{
Type: "BELONGS_TO",
Target: fmt.Sprintf("snyk_org_%s", project.OrgID),
})
entities = append(entities, projectEntity)
}
}
Examples of intra-domain relationships:
SnykProject→BELONGS_TO→SnykOrganizationK8sDeployment→RUNS_IN→K8sNamespaceGitHubRepository→OWNED_BY→GitHubOrganization
2. Cross-Molecule Rules (Suggested by Molecules)
Molecules can suggest default rules for creating relationships to entities from other molecules. These rules are defined in the molecule metadata and run when entities are created:
func defaultRules() []molecule.RuleDefinition {
return []molecule.RuleDefinition{
{
Name: "snyk-project-to-github-repo",
Description: "Link Snyk projects to GitHub repositories they scan",
Enabled: true,
Priority: 100,
Trigger: molecule.RuleTrigger{
On: "entity.created",
Match: molecule.RuleTriggerMatch{
Kind: "SnykProject",
},
},
Conditions: []molecule.RuleCondition{
{
Field: "spec.origin",
Operator: "regex",
Value: "^github\\.com/",
},
},
Actions: []molecule.RuleAction{
{
Type: "create_relation",
Config: map[string]interface{}{
"relation_type": "SCANS",
"target_kind": "GithubRepository",
"match_field": "spec.full_name",
"source_field": "spec.origin",
"transform": "strip_prefix:github.com/",
},
},
},
},
}
}
Examples of cross-molecule rules:
SnykProject→SCANS→GithubRepository(based on origin URL)VaultAuthMethod(kubernetes type) →AUTHENTICATES→K8sCluster(based on cluster name in path)ArgoApplication→DEPLOYS→GithubRepository(based on source repo URL)
Cross-molecule rules allow the knowledge graph to connect entities across different tools automatically. The molecule that "knows" about the relationship (e.g., Snyk knows which repos it scans) suggests the rule.
3. User-Defined Rules
Users can create custom rules to define relationships specific to their environment. These are configured in the platform UI or via the API:
# Example user-defined rule
apiVersion: sixdegree.ai/v1
kind: Rule
metadata:
name: team-ownership
namespace: production
spec:
description: "Link repositories to teams based on CODEOWNERS"
trigger:
on: entity.created
match:
kind: GithubRepository
conditions:
- field: spec.codeowners
operator: contains
value: "@myorg/platform-team"
actions:
- type: create_relation
config:
relation_type: OWNED_BY
target_kind: Team
target_name: "Platform Team"
Use cases for user-defined rules:
- Custom ownership mappings (repos → teams)
- Environment-specific relationships (services → infrastructure)
- Business logic relationships (services → cost centers)
- Compliance mappings (resources → compliance frameworks)
Relationship Resolution Order
When resolving relationships, the platform applies rules in this order:
- Direct relationships — Created by molecules during discovery (highest priority)
- Molecule-suggested rules — Default rules from molecule metadata
- User-defined rules — Custom rules configured by users
This ensures that explicit relationships from molecules take precedence, while rules fill in the connections that require cross-molecule knowledge or user-specific logic.
Configuration
Handle molecule configuration:
func (m *MyMolecule) Configure(config map[string]interface{}) error {
m.Config.Token = config["token"].(string)
m.Config.BaseURL = config["base_url"].(string)
return nil
}
Metadata
Provide molecule metadata:
func (m *MyMolecule) GetMetadata() *sdk.Metadata {
return &sdk.Metadata{
ID: "my-molecule",
Name: "My Service Integration",
Description: "Discovers resources from My Service",
Version: "1.0.0",
Author: "Your Name",
}
}
MCP Tools
MCP (Model Context Protocol) tools give AI agents capabilities to interact with external services in real-time.
MCPTools Method
Implement the MCPTools method to return available tools:
func (m *MyMolecule) MCPTools() []molecule.MCPTool {
return []molecule.MCPTool{
{
Name: "search_resources",
Description: "Search for resources in My Service",
InputSchema: json.RawMessage(`{
"type": "object",
"required": ["query"],
"properties": {
"query": {
"type": "string",
"description": "Search query"
},
"limit": {
"type": "integer",
"description": "Maximum results",
"default": 10
}
}
}`),
Handler: m.handleSearch,
},
{
Name: "create_resource",
Description: "Create a new resource",
InputSchema: json.RawMessage(`{
"type": "object",
"required": ["name"],
"properties": {
"name": {
"type": "string",
"description": "Resource name"
},
"description": {
"type": "string",
"description": "Resource description"
}
}
}`),
Handler: m.handleCreate,
},
}
}
Tool Handlers
Tool handlers process tool invocations from AI agents:
func (m *MyMolecule) handleSearch(ctx context.Context, input json.RawMessage) (interface{}, error) {
// Parse input
var params struct {
Query string `json:"query"`
Limit int `json:"limit"`
}
if err := json.Unmarshal(input, ¶ms); err != nil {
return nil, fmt.Errorf("invalid input: %w", err)
}
// Get configuration
apiKey := m.config.MCP.Settings["api_key"].(string)
client := NewMyServiceClient(apiKey)
// Perform search
results, err := client.Search(ctx, params.Query, params.Limit)
if err != nil {
return nil, fmt.Errorf("search failed: %w", err)
}
// Return structured response
return map[string]interface{}{
"results": results,
"count": len(results),
}, nil
}
func (m *MyMolecule) handleCreate(ctx context.Context, input json.RawMessage) (interface{}, error) {
var params struct {
Name string `json:"name"`
Description string `json:"description"`
}
if err := json.Unmarshal(input, ¶ms); err != nil {
return nil, fmt.Errorf("invalid input: %w", err)
}
apiKey := m.config.MCP.Settings["api_key"].(string)
client := NewMyServiceClient(apiKey)
resource, err := client.CreateResource(ctx, params.Name, params.Description)
if err != nil {
return nil, fmt.Errorf("creation failed: %w", err)
}
return map[string]interface{}{
"id": resource.ID,
"name": resource.Name,
"url": resource.URL,
"message": "Resource created successfully",
}, nil
}
Tool Design Best Practices
1. Clear Names and Descriptions:
// Good
Name: "create_github_issue"
Description: "Create a new issue in a GitHub repository"
// Avoid
Name: "do_thing"
Description: "Does something"
2. Detailed Input Schemas:
InputSchema: json.RawMessage(`{
"type": "object",
"required": ["repository", "title"],
"properties": {
"repository": {
"type": "string",
"description": "Repository in format owner/repo (e.g., sixdegree-ai/platform)"
},
"title": {
"type": "string",
"description": "Issue title (max 255 characters)"
},
"body": {
"type": "string",
"description": "Issue body (Markdown supported)"
},
"labels": {
"type": "array",
"description": "Labels to apply to the issue",
"items": {"type": "string"}
}
}
}`)
3. Useful Response Data:
// Return actionable information
return map[string]interface{}{
"id": issue.Number,
"title": issue.Title,
"url": issue.HTMLURL,
"state": issue.State,
"author": issue.User.Login,
}, nil
4. Error Handling:
func (m *MyMolecule) handleTool(ctx context.Context, input json.RawMessage) (interface{}, error) {
// Validate input
if err := validateInput(input); err != nil {
return nil, fmt.Errorf("invalid input: %w", err)
}
// Check permissions
if !m.hasPermission("write") {
return nil, fmt.Errorf("insufficient permissions: write access required")
}
// Perform operation with retries
var result interface{}
err := retry(ctx, 3, func() error {
var err error
result, err = m.performOperation(ctx, input)
return err
})
if err != nil {
return nil, fmt.Errorf("operation failed after retries: %w", err)
}
return result, nil
}
Webhooks (Optional)
Support real-time updates:
func (m *MyMolecule) HandleWebhook(event *sdk.WebhookEvent) error {
switch event.Type {
case "resource.created":
// Handle creation
case "resource.updated":
// Handle update
case "resource.deleted":
// Handle deletion
}
return nil
}
Testing
Unit Testing
Test discovery logic in isolation:
package main
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/zap/zaptest"
)
func TestDiscover(t *testing.T) {
// Create molecule
mol := &MyMolecule{}
// Mock configuration
config := &molecule.MoleculeConfig{
Namespace: "test",
Discovery: &molecule.CapabilitySettings{
Settings: map[string]interface{}{
"api_key": "test-key",
"base_url": "https://api.test.com",
},
},
}
// Initialize
logger := zaptest.NewLogger(t)
err := mol.Initialize(context.Background(), config, logger)
require.NoError(t, err)
// Mock HTTP client (use httptest)
// ... setup mock server ...
// Test discovery
ctx := context.Background()
result, err := mol.Discover(ctx)
require.NoError(t, err)
// Validate results
assert.NotEmpty(t, result.Entities)
assert.Equal(t, "Resource", result.Entities[0].Kind)
assert.NotEmpty(t, result.Entities[0].UID)
}
func TestMCPTools(t *testing.T) {
mol := &MyMolecule{}
// Test tool registration
tools := mol.MCPTools()
assert.NotEmpty(t, tools)
// Find specific tool
var searchTool *molecule.MCPTool
for _, tool := range tools {
if tool.Name == "search_resources" {
searchTool = &tool
break
}
}
require.NotNil(t, searchTool)
// Test tool invocation
input := json.RawMessage(`{"query": "test", "limit": 5}`)
result, err := searchTool.Handler(context.Background(), input)
require.NoError(t, err)
assert.NotNil(t, result)
}
Integration Testing with Mock Servers
Use the Doppelganger mock server for integration testing:
func TestWithMockServer(t *testing.T) {
// Start Doppelganger mock server
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Mock API responses
if r.URL.Path == "/resources" {
json.NewEncoder(w).Encode([]Resource{
{ID: "1", Name: "Test Resource"},
})
return
}
http.NotFound(w, r)
}))
defer mockServer.Close()
// Configure molecule to use mock server
config := &molecule.MoleculeConfig{
Discovery: &molecule.CapabilitySettings{
Settings: map[string]interface{}{
"base_url": mockServer.URL,
"api_key": "test-key",
},
},
}
// Run tests
// ...
}
Testing Webhooks
func TestWebhookHandling(t *testing.T) {
mol := &MyMolecule{}
event := &molecule.WebhookEvent{
Type: "resource.created",
Payload: json.RawMessage(`{
"id": "123",
"name": "New Resource"
}`),
}
err := mol.HandleWebhook(context.Background(), event)
require.NoError(t, err)
}
Best Practices
Error Handling
1. Use Structured Errors:
import "errors"
var (
ErrUnauthorized = errors.New("unauthorized: invalid credentials")
ErrNotFound = errors.New("resource not found")
ErrRateLimit = errors.New("rate limit exceeded")
)
func (m *MyMolecule) Discover(ctx context.Context) (*molecule.DiscoveryResult, error) {
if err := m.authenticate(); err != nil {
if errors.Is(err, ErrUnauthorized) {
return nil, fmt.Errorf("authentication failed: %w", err)
}
return nil, fmt.Errorf("unexpected error: %w", err)
}
// ...
}
2. Context Cancellation:
func (m *MyMolecule) Discover(ctx context.Context) (*molecule.DiscoveryResult, error) {
// Respect context cancellation
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
// Pass context to API calls
resources, err := m.client.ListResources(ctx)
if err != nil {
return nil, err
}
// ...
}
3. Retry Logic:
func retry(ctx context.Context, attempts int, fn func() error) error {
for i := 0; i < attempts; i++ {
err := fn()
if err == nil {
return nil
}
// Don't retry context errors
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return err
}
// Exponential backoff
if i < attempts-1 {
wait := time.Duration(math.Pow(2, float64(i))) * time.Second
select {
case <-time.After(wait):
case <-ctx.Done():
return ctx.Err()
}
}
}
return fmt.Errorf("failed after %d attempts", attempts)
}
Performance
1. Pagination:
func (m *MyMolecule) Discover(ctx context.Context) (*molecule.DiscoveryResult, error) {
var allEntities []molecule.Entity
page := 1
perPage := 100
for {
resources, hasMore, err := m.client.ListResourcesPaginated(ctx, page, perPage)
if err != nil {
return nil, err
}
for _, r := range resources {
allEntities = append(allEntities, m.convertToEntity(r))
}
if !hasMore {
break
}
page++
}
return &molecule.DiscoveryResult{Entities: allEntities}, nil
}
2. Concurrent Processing:
func (m *MyMolecule) Discover(ctx context.Context) (*molecule.DiscoveryResult, error) {
orgs := m.config.Discovery.Settings["organizations"].([]string)
// Process organizations concurrently
var wg sync.WaitGroup
resultChan := make(chan []molecule.Entity, len(orgs))
errChan := make(chan error, len(orgs))
for _, org := range orgs {
wg.Add(1)
go func(org string) {
defer wg.Done()
entities, err := m.discoverOrganization(ctx, org)
if err != nil {
errChan <- err
return
}
resultChan <- entities
}(org)
}
wg.Wait()
close(resultChan)
close(errChan)
// Collect errors
if len(errChan) > 0 {
return nil, <-errChan
}
// Collect results
var allEntities []molecule.Entity
for entities := range resultChan {
allEntities = append(allEntities, entities...)
}
return &molecule.DiscoveryResult{Entities: allEntities}, nil
}
3. Incremental Discovery:
func (m *MyMolecule) Discover(ctx context.Context) (*molecule.DiscoveryResult, error) {
// Check last discovery time
lastSync := m.getLastSyncTime()
// Only fetch resources modified since last sync
resources, err := m.client.ListResourcesModifiedSince(ctx, lastSync)
if err != nil {
return nil, err
}
entities := make([]molecule.Entity, 0, len(resources))
for _, r := range resources {
entities = append(entities, m.convertToEntity(r))
}
// Update last sync time
m.saveLastSyncTime(time.Now())
return &molecule.DiscoveryResult{Entities: entities}, nil
}
Logging
Use structured logging with appropriate levels:
func (m *MyMolecule) Discover(ctx context.Context) (*molecule.DiscoveryResult, error) {
m.logger.Info("Starting discovery",
zap.String("namespace", m.config.Namespace),
zap.Int("organizations", len(orgs)),
)
// Debug level for detailed info
m.logger.Debug("Fetching resources",
zap.String("org", org),
zap.Int("page", page),
)
// Warn for non-critical issues
if len(resources) == 0 {
m.logger.Warn("No resources found",
zap.String("org", org),
)
}
// Error for failures
if err != nil {
m.logger.Error("Discovery failed",
zap.Error(err),
zap.String("org", org),
)
return nil, err
}
m.logger.Info("Discovery complete",
zap.Int("entities", len(entities)),
zap.Duration("duration", time.Since(start)),
)
return result, nil
}
Packaging and Distribution
Dockerfile
Create a multi-stage Dockerfile for optimal image size:
# Build stage
FROM golang:1.24-alpine AS builder
WORKDIR /build
# Copy go modules
COPY go.mod go.sum ./
RUN go mod download
# Copy source
COPY . .
# Build with version information
ARG VERSION=dev
ARG COMMIT=unknown
RUN CGO_ENABLED=0 GOOS=linux go build \
-ldflags="-w -s -X main.version=${VERSION} -X main.commit=${COMMIT}" \
-o molecule \
.
# Runtime stage
FROM alpine:latest
# Add CA certificates for HTTPS
RUN apk --no-cache add ca-certificates
# Create non-root user
RUN addgroup -g 1000 molecule && \
adduser -D -u 1000 -G molecule molecule
WORKDIR /app
# Copy binary
COPY --from=builder /build/molecule /app/molecule
# Switch to non-root user
USER molecule
# Expose default port
EXPOSE 8080
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD ["/app/molecule", "health"]
ENTRYPOINT ["/app/molecule"]
CMD ["serve"]
Building
# Local build
docker build -t my-molecule:latest .
# Build with version
docker build \
--build-arg VERSION=1.0.0 \
--build-arg COMMIT=$(git rev-parse HEAD) \
-t my-molecule:1.0.0 \
.
# Multi-platform build
docker buildx build \
--platform linux/amd64,linux/arm64 \
-t my-molecule:1.0.0 \
--push \
.
Publishing to Registry
1. Tag Image
docker tag my-molecule:1.0.0 registry.sixdegree.ai/my-molecule:1.0.0
docker tag my-molecule:1.0.0 registry.sixdegree.ai/my-molecule:latest
2. Push to Registry
# Login to registry
docker login registry.sixdegree.ai
# Push images
docker push registry.sixdegree.ai/my-molecule:1.0.0
docker push registry.sixdegree.ai/my-molecule:latest
3. Create Molecule Manifest
Create molecule.yaml for registry metadata:
apiVersion: sixdegree.ai/v1
kind: MoleculeManifest
metadata:
name: my-molecule
version: 1.0.0
description: Discovers resources from My Service
author: Your Name
homepage: https://github.com/yourorg/my-molecule
license: Apache-2.0
spec:
image: registry.sixdegree.ai/my-molecule:1.0.0
capabilities:
discovery:
configSchema: ./config-schema-discovery.json
mcp:
configSchema: ./config-schema-mcp.json
documentation:
readme: ./README.md
examples:
- ./examples/basic-config.yaml
- ./examples/advanced-config.yaml
testing:
examples:
- ./test/fixtures/test-config.yaml
4. Register Molecule
# Using SixDegree CLI
degree molecule publish \
--manifest molecule.yaml \
--registry registry.sixdegree.ai
# Verify publication
degree molecule search my-molecule
Versioning
Follow semantic versioning (semver):
- MAJOR (1.0.0 → 2.0.0): Breaking changes to configuration or behavior
- MINOR (1.0.0 → 1.1.0): New features, backward compatible
- PATCH (1.0.0 → 1.0.1): Bug fixes, backward compatible
// Version in code
const version = "1.2.3"
// Set via build flags
var (
version = "dev"
commit = "unknown"
buildTime = "unknown"
)
func (m *MyMolecule) Metadata() molecule.Metadata {
return molecule.Metadata{
Version: version,
// ...
}
}
Release Process
-
Update Version:
# Update version in code
sed -i 's/const version = .*/const version = "1.1.0"/' main.go
# Update CHANGELOG.md
echo "## [1.1.0] - $(date +%Y-%m-%d)" >> CHANGELOG.md -
Run Tests:
go test ./... -
Build and Tag:
git tag v1.1.0
git push origin v1.1.0 -
Build Image:
docker build \
--build-arg VERSION=1.1.0 \
--build-arg COMMIT=$(git rev-parse HEAD) \
-t registry.sixdegree.ai/my-molecule:1.1.0 \
. -
Publish:
docker push registry.sixdegree.ai/my-molecule:1.1.0
degree molecule publish --manifest molecule.yaml
Complete Example: Project Management Molecule
Here's a complete example showing discovery, MCP tools, and webhooks:
package main
import (
"context"
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/sixdegree-ai/molecule-sdk/go/molecule"
"go.uber.org/zap"
)
const version = "1.0.0"
func main() {
molecule.Run(&ProjectMolecule{})
}
type ProjectMolecule struct {
*molecule.BaseMolecule
config *molecule.MoleculeConfig
client *ProjectClient
logger *zap.Logger
}
// Configuration schemas
var discoveryConfigSchema = json.RawMessage(`{
"type": "object",
"required": ["api_key", "workspace_id"],
"properties": {
"api_key": {
"type": "string",
"title": "API Key",
"description": "Project management API key",
"x-order": 1,
"x-sixdegree": {"widget": "secret"}
},
"workspace_id": {
"type": "string",
"title": "Workspace ID",
"description": "Workspace to discover projects from",
"x-order": 2
},
"include_archived": {
"type": "boolean",
"title": "Include Archived",
"description": "Include archived projects",
"x-order": 3,
"default": false
}
}
}`)
var mcpConfigSchema = json.RawMessage(`{
"type": "object",
"required": ["api_key"],
"properties": {
"api_key": {
"type": "string",
"title": "API Key",
"description": "API key for MCP tools"
}
}
}`)
// Metadata returns molecule metadata
func (m *ProjectMolecule) Metadata() molecule.Metadata {
return molecule.Metadata{
ID: "project-molecule",
Name: "Project Management Integration",
Description: "Discovers projects, tasks, and team members",
Version: version,
Author: "Your Organization",
Capabilities: molecule.Capabilities{
Discovery: &molecule.CapabilityConfig{
ConfigSchema: discoveryConfigSchema,
},
MCP: &molecule.CapabilityConfig{
ConfigSchema: mcpConfigSchema,
},
},
}
}
// Initialize sets up the molecule
func (m *ProjectMolecule) Initialize(ctx context.Context, config *molecule.MoleculeConfig, logger *zap.Logger) error {
m.config = config
m.logger = logger
// Initialize client for discovery
if config.Discovery != nil {
apiKey := config.Discovery.Settings["api_key"].(string)
m.client = NewProjectClient(apiKey)
}
// Initialize client for MCP
if config.MCP != nil {
apiKey := config.MCP.Settings["api_key"].(string)
if m.client == nil {
m.client = NewProjectClient(apiKey)
}
}
return nil
}
// Discover performs discovery
func (m *ProjectMolecule) Discover(ctx context.Context) (*molecule.DiscoveryResult, error) {
m.logger.Info("Starting discovery")
start := time.Now()
workspaceID := m.config.Discovery.Settings["workspace_id"].(string)
includeArchived := m.config.Discovery.Settings["include_archived"].(bool)
// Discover projects
projects, err := m.client.ListProjects(ctx, workspaceID, includeArchived)
if err != nil {
return nil, fmt.Errorf("failed to list projects: %w", err)
}
var entities []molecule.Entity
// Convert projects to entities
for _, proj := range projects {
entities = append(entities, molecule.Entity{
UID: fmt.Sprintf("project_%s", proj.ID),
Kind: "Project",
Name: proj.Name,
Namespace: m.config.Namespace,
Properties: map[string]interface{}{
"description": proj.Description,
"status": proj.Status,
"created_at": proj.CreatedAt,
"owner": proj.Owner,
"url": proj.URL,
},
})
// Discover tasks for each project
tasks, err := m.client.ListTasks(ctx, proj.ID)
if err != nil {
m.logger.Warn("Failed to list tasks",
zap.String("project", proj.ID),
zap.Error(err),
)
continue
}
// Convert tasks to entities
for _, task := range tasks {
taskEntity := molecule.Entity{
UID: fmt.Sprintf("task_%s", task.ID),
Kind: "Task",
Name: task.Title,
Namespace: m.config.Namespace,
Properties: map[string]interface{}{
"status": task.Status,
"priority": task.Priority,
"due_date": task.DueDate,
"assignee": task.Assignee,
"description": task.Description,
},
Relations: []molecule.Relation{
{
Type: "BELONGS_TO",
Target: fmt.Sprintf("project_%s", proj.ID),
},
},
}
// Add assignee relationship if present
if task.Assignee != "" {
taskEntity.Relations = append(taskEntity.Relations, molecule.Relation{
Type: "ASSIGNED_TO",
Target: fmt.Sprintf("user_%s", task.Assignee),
})
}
entities = append(entities, taskEntity)
}
}
m.logger.Info("Discovery complete",
zap.Int("entities", len(entities)),
zap.Duration("duration", time.Since(start)),
)
return &molecule.DiscoveryResult{
Entities: entities,
}, nil
}
// MCPTools returns available MCP tools
func (m *ProjectMolecule) MCPTools() []molecule.MCPTool {
return []molecule.MCPTool{
{
Name: "create_task",
Description: "Create a new task in a project",
InputSchema: json.RawMessage(`{
"type": "object",
"required": ["project_id", "title"],
"properties": {
"project_id": {
"type": "string",
"description": "Project ID"
},
"title": {
"type": "string",
"description": "Task title"
},
"description": {
"type": "string",
"description": "Task description"
},
"assignee": {
"type": "string",
"description": "User ID to assign the task to"
},
"priority": {
"type": "string",
"enum": ["low", "medium", "high"],
"description": "Task priority",
"default": "medium"
}
}
}`),
Handler: m.handleCreateTask,
},
{
Name: "search_tasks",
Description: "Search for tasks across all projects",
InputSchema: json.RawMessage(`{
"type": "object",
"required": ["query"],
"properties": {
"query": {
"type": "string",
"description": "Search query"
},
"status": {
"type": "string",
"enum": ["todo", "in_progress", "done"],
"description": "Filter by status"
}
}
}`),
Handler: m.handleSearchTasks,
},
}
}
func (m *ProjectMolecule) handleCreateTask(ctx context.Context, input json.RawMessage) (interface{}, error) {
var params struct {
ProjectID string `json:"project_id"`
Title string `json:"title"`
Description string `json:"description"`
Assignee string `json:"assignee"`
Priority string `json:"priority"`
}
if err := json.Unmarshal(input, ¶ms); err != nil {
return nil, fmt.Errorf("invalid input: %w", err)
}
task, err := m.client.CreateTask(ctx, params.ProjectID, params.Title, params.Description, params.Assignee, params.Priority)
if err != nil {
return nil, fmt.Errorf("failed to create task: %w", err)
}
return map[string]interface{}{
"id": task.ID,
"title": task.Title,
"status": task.Status,
"url": task.URL,
"message": "Task created successfully",
}, nil
}
func (m *ProjectMolecule) handleSearchTasks(ctx context.Context, input json.RawMessage) (interface{}, error) {
var params struct {
Query string `json:"query"`
Status string `json:"status"`
}
if err := json.Unmarshal(input, ¶ms); err != nil {
return nil, fmt.Errorf("invalid input: %w", err)
}
tasks, err := m.client.SearchTasks(ctx, params.Query, params.Status)
if err != nil {
return nil, fmt.Errorf("search failed: %w", err)
}
return map[string]interface{}{
"tasks": tasks,
"count": len(tasks),
}, nil
}
// HandleWebhook processes webhook events
func (m *ProjectMolecule) HandleWebhook(ctx context.Context, event *molecule.WebhookEvent) error {
m.logger.Info("Received webhook",
zap.String("type", event.Type),
)
switch event.Type {
case "task.created":
// Handle task creation
var task Task
if err := json.Unmarshal(event.Payload, &task); err != nil {
return fmt.Errorf("invalid payload: %w", err)
}
m.logger.Info("Task created via webhook", zap.String("task_id", task.ID))
case "task.updated":
// Handle task update
var task Task
if err := json.Unmarshal(event.Payload, &task); err != nil {
return fmt.Errorf("invalid payload: %w", err)
}
m.logger.Info("Task updated via webhook", zap.String("task_id", task.ID))
default:
m.logger.Warn("Unknown webhook event type", zap.String("type", event.Type))
}
return nil
}
// Client types (simplified)
type ProjectClient struct{ apiKey string }
func NewProjectClient(apiKey string) *ProjectClient { return &ProjectClient{apiKey: apiKey} }
func (c *ProjectClient) ListProjects(ctx context.Context, workspaceID string, includeArchived bool) ([]Project, error) { return nil, nil }
func (c *ProjectClient) ListTasks(ctx context.Context, projectID string) ([]Task, error) { return nil, nil }
func (c *ProjectClient) CreateTask(ctx context.Context, projectID, title, description, assignee, priority string) (*Task, error) { return nil, nil }
func (c *ProjectClient) SearchTasks(ctx context.Context, query, status string) ([]Task, error) { return nil, nil }
type Project struct {
ID string
Name string
Description string
Status string
CreatedAt time.Time
Owner string
URL string
}
type Task struct {
ID string
Title string
Description string
Status string
Priority string
DueDate time.Time
Assignee string
URL string
}
Troubleshooting
Common Issues
Discovery returns no entities:
- Check API credentials are valid
- Verify configuration schema matches settings
- Check API rate limits
- Enable debug logging:
m.logger.Debug(...)
MCP tools not appearing:
- Verify MCP capability is enabled in configuration
- Check MCPTools() method returns non-empty array
- Validate input schema is valid JSON Schema
- Check molecule server logs for errors
Webhook delivery failures:
- Verify webhook URL is accessible
- Check webhook signature validation
- Ensure HandleWebhook returns without error
- Review webhook payload format
Debugging
Enable debug logging:
# Set log level
export MOLECULE_LOG_LEVEL=debug
# Run molecule
./my-molecule serve
Test discovery without saving:
./my-molecule discover --config config.yaml --dry-run
Test MCP tools:
curl -X POST http://localhost:8080/mcp/tools/search_resources \
-H "Content-Type: application/json" \
-d '{"query": "test"}'
Deployment
Kubernetes Deployment
Deploy your molecule to Kubernetes:
apiVersion: apps/v1
kind: Deployment
metadata:
name: my-molecule
labels:
app: my-molecule
spec:
replicas: 1
selector:
matchLabels:
app: my-molecule
template:
metadata:
labels:
app: my-molecule
spec:
containers:
- name: molecule
image: registry.sixdegree.ai/my-molecule:1.0.0
ports:
- containerPort: 8080
env:
- name: MOLECULE_LOG_LEVEL
value: "info"
resources:
requests:
memory: "128Mi"
cpu: "100m"
limits:
memory: "512Mi"
cpu: "500m"
livenessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 10
periodSeconds: 30
readinessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 5
periodSeconds: 10
---
apiVersion: v1
kind: Service
metadata:
name: my-molecule
spec:
selector:
app: my-molecule
ports:
- protocol: TCP
port: 8080
targetPort: 8080
Configuration Management
Store sensitive configuration in Kubernetes secrets:
apiVersion: v1
kind: Secret
metadata:
name: my-molecule-secrets
type: Opaque
stringData:
api-key: "your-api-key"
---
apiVersion: v1
kind: ConfigMap
metadata:
name: my-molecule-config
data:
config.yaml: |
discovery:
enabled: true
settings:
base_url: "https://api.myservice.com"
workspace_id: "workspace-123"
mcp:
enabled: true
namespace: "production"
Mount secrets and config:
spec:
containers:
- name: molecule
volumeMounts:
- name: config
mountPath: /config
readOnly: true
- name: secrets
mountPath: /secrets
readOnly: true
env:
- name: API_KEY
valueFrom:
secretKeyRef:
name: my-molecule-secrets
key: api-key
volumes:
- name: config
configMap:
name: my-molecule-config
- name: secrets
secret:
secretName: my-molecule-secrets
Additional Resources
- Go SDK Documentation - Complete Go SDK reference
- Integration Guides - Real-world molecule examples
- Molecule SDK Repository - SDK source code and examples
- Molecules Repository - Official molecule implementations
Community and Support
- Discussions - Ask questions and share molecules
- Documentation - https://docs.sixdegree.ai