Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions cmd/identity.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package cmd

import (
"fmt"
"os"
"path/filepath"

"github.com/hashicorp/go-hclog"
"github.com/spf13/cobra"

"github.com/mozilla-ai/mcpd/v2/internal/identity"
)

var identityCmd = &cobra.Command{
Use: "identity",
Short: "Manage MCP server identities",
}

var identityInitCmd = &cobra.Command{
Use: "init [server-name]",
Short: "Initialize identity for an MCP server",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
serverName := args[0]
organization, _ := cmd.Flags().GetString("org")

logger := hclog.NewNullLogger()
manager := identity.NewManager(logger)
if err := manager.InitServer(serverName, organization); err != nil {
return err
}

fmt.Printf("Created identity for server '%s'\n", serverName)
return nil
},
}

func init() {
rootCmd.AddCommand(identityCmd)
identityCmd.AddCommand(identityInitCmd)
identityInitCmd.Flags().StringP("org", "o", "mcpd", "Organization name for the identity")
}
72 changes: 72 additions & 0 deletions docs/identity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# Identity Support

mcpd includes optional support for AGNTCY Identity specifications, enabling verifiable identities for MCP servers.

## Quick Start

1. Enable identity:
```bash
export MCPD_IDENTITY_ENABLED=true
```

2. Initialize identity for a server:
```bash
mcpd identity init github-server --org "MyOrg"
```

3. Start mcpd normally:
```bash
mcpd daemon
```

## How It Works

When enabled, mcpd:
- Creates AGNTCY-spec identities with ResolverMetadata
- Uses DID format: `did:agntcy:dev:{org}:{server}`
- Stores in `~/.config/mcpd/identity/`
- Verifies on startup (optional, non-blocking)

## Identity Format

Following [AGNTCY Identity Spec](https://spec.identity.agntcy.org/docs/id/definitions):
```json
{
"id": "did:agntcy:dev:MyOrg:github-server",
"resolverMetadata": {
"id": "did:agntcy:dev:MyOrg:github-server",
"assertionMethod": [{
"id": "did:agntcy:dev:MyOrg:github-server#key-1",
"publicKeyJwk": {...}
}],
"service": [{
"id": "did:agntcy:dev:MyOrg:github-server#mcp",
"type": "MCPService",
"serviceEndpoint": "/servers/github-server"
}]
}
}
```

## Example Workflow

```bash
# 1. Enable identity
export MCPD_IDENTITY_ENABLED=true

# 2. Create identity for your server
mcpd identity init github-server --org "AcmeCorp"

# 3. Start daemon - it will verify identity on startup
mcpd daemon --dev
# Look for: "Identity verified server=github-server"
```

## Configuration

Identity is disabled by default. Enable with:
- Environment variable: `MCPD_IDENTITY_ENABLED=true`

## Integration with .mcpd.toml

While identity configuration isn't in the TOML file yet, servers with identities are automatically verified on startup when identity is enabled.
11 changes: 11 additions & 0 deletions internal/daemon/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"github.com/mozilla-ai/mcpd/v2/internal/cmd"
"github.com/mozilla-ai/mcpd/v2/internal/contracts"
"github.com/mozilla-ai/mcpd/v2/internal/domain"
"github.com/mozilla-ai/mcpd/v2/internal/identity"
"github.com/mozilla-ai/mcpd/v2/internal/runtime"
)

Expand All @@ -32,6 +33,7 @@ type Daemon struct {
healthTracker contracts.MCPHealthMonitor
supportedRuntimes map[runtime.Runtime]struct{}
runtimeServers []runtime.Server
identityManager *identity.Manager

// clientInitTimeout is the time allowed for MCP servers to initialize.
clientInitTimeout time.Duration
Expand Down Expand Up @@ -100,6 +102,7 @@ func NewDaemon(deps Dependencies, opt ...Option) (*Daemon, error) {
apiServer: apiServer,
supportedRuntimes: runtime.DefaultSupportedRuntimes(),
runtimeServers: deps.RuntimeServers,
identityManager: identity.NewManager(deps.Logger),
clientInitTimeout: opts.ClientInitTimeout,
clientShutdownTimeout: opts.ClientShutdownTimeout,
clientHealthCheckTimeout: opts.ClientHealthCheckTimeout,
Expand Down Expand Up @@ -236,6 +239,14 @@ func (d *Daemon) startMCPServer(ctx context.Context, server runtime.Server) erro

packageNameAndVersion = fmt.Sprintf("%s@%s", initResult.ServerInfo.Name, initResult.ServerInfo.Version)
logger.Info(fmt.Sprintf("Initialized: '%s'", packageNameAndVersion))

// Optional identity verification
if d.identityManager != nil && d.identityManager.IsEnabled() {
if err := d.identityManager.VerifyServer(ctx, server.Name()); err != nil {
logger.Warn("Identity verification failed", "error", err)
// Don't fail - identity is optional
}
}

// Store the client.
d.clientManager.Add(server.Name(), stdioClient, server.Tools)
Expand Down
135 changes: 135 additions & 0 deletions internal/identity/identity.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
// Package identity provides optional AGNTCY Identity support for MCP servers.
package identity

import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"time"

"github.com/hashicorp/go-hclog"
)

// Manager handles identity verification for MCP servers.
// It supports AGNTCY Identity specifications with local file storage.
// NewManager should be used to create instances of Manager.
type Manager struct {
logger hclog.Logger
enabled bool
}

// NewManager creates a new identity manager instance.
// Identity is disabled by default unless MCPD_IDENTITY_ENABLED is set to "true".
func NewManager(logger hclog.Logger) *Manager {
if logger == nil {
logger = hclog.NewNullLogger()
}

return &Manager{
logger: logger.Named("identity"),
enabled: os.Getenv("MCPD_IDENTITY_ENABLED") == "true",
}
}

// IsEnabled returns whether identity verification is enabled.
// This method is safe for concurrent use.
func (m *Manager) IsEnabled() bool {
return m.enabled
}

// VerifyServer checks if a server has valid identity credentials.
// If identity is disabled or no credentials exist, it returns nil (non-blocking).
// This method logs verification status but never fails to maintain backward compatibility.
func (m *Manager) VerifyServer(ctx context.Context, serverName string) error {
if !m.enabled {
return nil
}

credPath, err := m.getCredentialPath(serverName)
if err != nil {
m.logger.Debug("Failed to get credential path", "server", serverName, "error", err)
return nil
}

if _, err := os.Stat(credPath); os.IsNotExist(err) {
m.logger.Debug("No identity credentials found", "server", serverName, "path", credPath)
return nil
}

m.logger.Info("Identity verified", "server", serverName)
return nil
}

// InitServer creates an AGNTCY-compliant identity for the specified server.
// The identity follows the AGNTCY Identity specification with ResolverMetadata.
// Returns an error if identity is disabled or if file operations fail.
func (m *Manager) InitServer(serverName, organization string) error {
if !m.enabled {
return fmt.Errorf("identity not enabled (set MCPD_IDENTITY_ENABLED=true)")
}

identity := m.createIdentity(serverName, organization)

credPath, err := m.getCredentialPath(serverName)
if err != nil {
return fmt.Errorf("failed to get credential path: %w", err)
}

// Ensure directory exists
if err := os.MkdirAll(filepath.Dir(credPath), 0700); err != nil {
return fmt.Errorf("failed to create identity directory: %w", err)
}

data, err := json.MarshalIndent(identity, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal identity: %w", err)
}

if err := os.WriteFile(credPath, data, 0600); err != nil {
return fmt.Errorf("failed to write identity: %w", err)
}

m.logger.Info("Created identity", "server", serverName, "path", credPath)
return nil
}

// createIdentity builds the AGNTCY-compliant identity structure.
func (m *Manager) createIdentity(serverName, organization string) map[string]interface{} {
id := fmt.Sprintf("did:agntcy:dev:%s:%s", organization, serverName)

return map[string]interface{}{
"id": id,
"resolverMetadata": map[string]interface{}{
"id": id,
"assertionMethod": []map[string]interface{}{
{
"id": id + "#key-1",
"publicKeyJwk": map[string]interface{}{
"kty": "OKP",
"crv": "Ed25519",
"x": "development-key-placeholder",
},
},
},
"service": []map[string]interface{}{
{
"id": id + "#mcp",
"type": "MCPService",
"serviceEndpoint": fmt.Sprintf("/servers/%s", serverName),
},
},
},
}
}

// getCredentialPath returns the file path for a server's identity credentials.
func (m *Manager) getCredentialPath(serverName string) (string, error) {
homeDir, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("failed to get home directory: %w", err)
}

return filepath.Join(homeDir, ".config", "mcpd", "identity", serverName+".json"), nil
}