Skip to main content

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:

  1. Discovers entities - Syncs data from external services (repositories, workflows, users, etc.)
  2. Creates relationships - Establishes connections between discovered entities
  3. Provides MCP tools - Gives AI agents capabilities to interact with external services
  4. 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.

Don't Duplicate Ontology Data

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:

  1. 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
  2. Actions — Operations that modify state:

    • Trigger scans, runs, deployments
    • Send messages, create tickets
    • Approve/reject workflows
  3. Deep Inspection — Detailed live data for specific entities:

    • get_project with live issue counts
    • get_remediation advice
    • get_policy rules

Examples

❌ Redundant (Don't Do)✅ Appropriate
list_projectsDiscovery: Project entities in ontology
list_channelsDiscovery: Channel entities in ontology
list_usersDiscovery: User entities in ontology
list_workspacesDiscovery: 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:

  1. Bypasses relationship traversal — The AI won't discover relationships like SCANS or DEPLOYS_TO that connect entities across tools
  2. 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
  3. Loses the graph — The AI gets a flat list instead of understanding the infrastructure topology
Rule of Thumb

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:

WidgetDescriptionExample Use Case
secretPassword/token input (masked)API keys, tokens
org-pickerOrganization selectorGitHub/GitLab orgs
repo-pickerRepository selectorSpecific repos
multiselectMultiple choice selectorScopes, 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:

  • SnykProjectBELONGS_TOSnykOrganization
  • K8sDeploymentRUNS_INK8sNamespace
  • GitHubRepositoryOWNED_BYGitHubOrganization

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:

  • SnykProjectSCANSGithubRepository (based on origin URL)
  • VaultAuthMethod (kubernetes type) → AUTHENTICATESK8sCluster (based on cluster name in path)
  • ArgoApplicationDEPLOYSGithubRepository (based on source repo URL)
tip

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:

  1. Direct relationships — Created by molecules during discovery (highest priority)
  2. Molecule-suggested rules — Default rules from molecule metadata
  3. 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, &params); 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, &params); 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

  1. 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
  2. Run Tests:

    go test ./...
  3. Build and Tag:

    git tag v1.1.0
    git push origin v1.1.0
  4. 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 \
    .
  5. 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, &params); 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, &params); 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

Community and Support