Skip to content
Merged
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
10 changes: 10 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,13 @@ OTEL_SERVICE_NAME=riskkernel
RISKKERNEL_APPROVAL_DEFAULT_SAFE=true
# Optional: POST a JSON notification here when an approval becomes pending.
RISKKERNEL_APPROVAL_WEBHOOK=

# MCP gateway (tool governance). Set an upstream MCP server URL to enable it;
# point your MCP client at http://localhost:7070/mcp instead of the real server.
RISKKERNEL_MCP_UPSTREAM=
# Comma-separated tool allowlist (exact or glob); empty = allow all.
RISKKERNEL_MCP_ALLOWLIST=
# Comma-separated read-only tools (never require approval); others are gated.
RISKKERNEL_MCP_READONLY=
# Seconds a gated tools/call waits for a human decision (default 110).
RISKKERNEL_MCP_APPROVAL_TIMEOUT=110
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,5 +71,13 @@ surface is governed by [`COMPATIBILITY.md`](COMPATIBILITY.md).
`ApprovalGate`, `@governed_tool`. Lazy-imported framework adapters for LangChain
(callback handler), the Claude Agent SDK (PreToolUse hook), and the OpenAI Agents
SDK (RunHooks). Verified end-to-end against the daemon; CI on Python 3.9/3.12.
- **MCP gateway** — a JSON-RPC reverse proxy at `POST /mcp` in front of an upstream
MCP server. Forwards every method transparently; intercepts `tools/call` to
enforce a per-tool allowlist (exact or glob), classify read-only vs
side-effecting, route side-effecting tools through the approval gate (blocking,
bounded by `RISKKERNEL_MCP_APPROVAL_TIMEOUT`), and record an auditable
`tool_calls` row. Enabled by `RISKKERNEL_MCP_UPSTREAM`; allowlist/read-only via
`RISKKERNEL_MCP_ALLOWLIST` / `RISKKERNEL_MCP_READONLY`. Point your MCP client at
the gateway and governance is invisible to allowed, approved calls.

[Unreleased]: https://github.com/prashar32/riskkernel/commits/main
2 changes: 1 addition & 1 deletion cmd/riskkernel/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ func runServe(_ []string) error {
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()

srv := httpapi.New(cfg, deps.Gateway, deps.Runs, deps.Approvals, deps.Log)
srv := httpapi.New(cfg, deps.Gateway, deps.Runs, deps.Approvals, deps.MCP, deps.Log)
addr := fmt.Sprintf(":%d", cfg.Port)
return srv.Serve(ctx, addr)
}
Expand Down
11 changes: 11 additions & 0 deletions internal/app/bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"github.com/prashar32/riskkernel/internal/config"
"github.com/prashar32/riskkernel/internal/gateway"
"github.com/prashar32/riskkernel/internal/governor"
"github.com/prashar32/riskkernel/internal/mcp"
"github.com/prashar32/riskkernel/internal/otel"
"github.com/prashar32/riskkernel/internal/pricing"
"github.com/prashar32/riskkernel/internal/provider"
Expand All @@ -34,6 +35,7 @@ type Deps struct {
Store storage.Store
Tracer *otel.Tracer
Approvals *approval.Gate
MCP *mcp.Gateway // nil when no upstream is configured
}

// Close releases dependencies that hold resources (the tracer's buffered spans,
Expand Down Expand Up @@ -86,6 +88,14 @@ func Build(cfg *config.Config) (*Deps, error) {
}
gate := approval.NewGate(store, approval.Policy{DefaultSafe: cfg.Approval.DefaultSafe}, notifier, log)

var mcpGW *mcp.Gateway
if cfg.MCP.Upstream != "" {
mcpGW = mcp.New(cfg.MCP.Upstream, cfg.MCP.Allowlist, cfg.MCP.ReadOnly, gate, mgr, store,
time.Duration(cfg.MCP.ApprovalTimeoutSeconds)*time.Second, log)
log.Info("mcp gateway enabled", "upstream", cfg.MCP.Upstream,
"allowlist", len(cfg.MCP.Allowlist), "readonly", len(cfg.MCP.ReadOnly))
}

return &Deps{
Config: cfg,
Log: log,
Expand All @@ -96,6 +106,7 @@ func Build(cfg *config.Config) (*Deps, error) {
Store: store,
Tracer: tracer,
Approvals: gate,
MCP: mcpGW,
}, nil
}

Expand Down
56 changes: 56 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,29 @@ type Config struct {

// Approval configures the human-in-the-loop gate.
Approval ApprovalConfig

// MCP configures the MCP gateway (tool governance). Disabled unless an upstream
// MCP server URL is set.
MCP MCPConfig
}

// MCPConfig configures the MCP gateway: a JSON-RPC reverse proxy in front of an
// upstream MCP server that governs tools/call.
type MCPConfig struct {
// Upstream is the real MCP server's HTTP endpoint. Empty disables the gateway.
// Read from RISKKERNEL_MCP_UPSTREAM.
Upstream string
// Allowlist limits which tools may be called (exact name or glob). Empty means
// all tools are allowed. Read from RISKKERNEL_MCP_ALLOWLIST (comma-separated).
Allowlist []string
// ReadOnly names tools that are read-only and therefore never require approval.
// Everything else is treated as side-effecting. Read from
// RISKKERNEL_MCP_READONLY (comma-separated).
ReadOnly []string
// ApprovalTimeoutSeconds bounds how long a gated tools/call waits for a human.
// Read from RISKKERNEL_MCP_APPROVAL_TIMEOUT (default 110, under the server
// write timeout).
ApprovalTimeoutSeconds int
}

// ApprovalConfig configures the human-in-the-loop approval gate.
Expand Down Expand Up @@ -120,10 +143,43 @@ func Load() (*Config, error) {
DefaultSafe: envBoolDefault("RISKKERNEL_APPROVAL_DEFAULT_SAFE", true),
WebhookURL: os.Getenv("RISKKERNEL_APPROVAL_WEBHOOK"),
},
MCP: MCPConfig{
Upstream: os.Getenv("RISKKERNEL_MCP_UPSTREAM"),
Allowlist: splitList(os.Getenv("RISKKERNEL_MCP_ALLOWLIST")),
ReadOnly: splitList(os.Getenv("RISKKERNEL_MCP_READONLY")),
ApprovalTimeoutSeconds: envIntDefault("RISKKERNEL_MCP_APPROVAL_TIMEOUT", 110),
},
}
return cfg, nil
}

// splitList parses a comma-separated env value into a trimmed, non-empty slice.
func splitList(v string) []string {
if strings.TrimSpace(v) == "" {
return nil
}
var out []string
for _, part := range strings.Split(v, ",") {
if p := strings.TrimSpace(part); p != "" {
out = append(out, p)
}
}
return out
}

// envIntDefault parses an int env var, returning def when unset or invalid.
func envIntDefault(key string, def int) int {
v := strings.TrimSpace(os.Getenv(key))
if v == "" {
return def
}
n, err := strconv.Atoi(v)
if err != nil || n < 0 {
return def
}
return n
}

// envBoolDefault parses a boolean env var, returning def when unset. Accepts
// "true"/"false" (case-insensitive) and "1"/"0".
func envBoolDefault(key string, def bool) bool {
Expand Down
11 changes: 9 additions & 2 deletions internal/httpapi/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"github.com/prashar32/riskkernel/internal/config"
"github.com/prashar32/riskkernel/internal/gateway"
"github.com/prashar32/riskkernel/internal/httpx"
"github.com/prashar32/riskkernel/internal/mcp"
"github.com/prashar32/riskkernel/internal/runs"
"github.com/prashar32/riskkernel/internal/storage"
"github.com/prashar32/riskkernel/internal/version"
Expand All @@ -28,12 +29,14 @@ type Server struct {
gateway *gateway.Gateway
runs *runs.Manager
approvals *approval.Gate
mcp *mcp.Gateway
log *slog.Logger
}

// New constructs a Server.
func New(cfg *config.Config, gw *gateway.Gateway, mgr *runs.Manager, gate *approval.Gate, log *slog.Logger) *Server {
return &Server{cfg: cfg, gateway: gw, runs: mgr, approvals: gate, log: log}
func New(cfg *config.Config, gw *gateway.Gateway, mgr *runs.Manager, gate *approval.Gate,
mcpGW *mcp.Gateway, log *slog.Logger) *Server {
return &Server{cfg: cfg, gateway: gw, runs: mgr, approvals: gate, mcp: mcpGW, log: log}
}

// Handler returns the root HTTP handler with all routes mounted.
Expand All @@ -51,6 +54,10 @@ func (s *Server) Handler() http.Handler {
if s.gateway != nil {
s.gateway.Register(mux, s.requireAuth)
}
// MCP gateway (Surface 1, tool governance) — only when an upstream is set.
if s.mcp != nil {
s.mcp.Register(mux, s.requireAuth)
}

// Public /v1 contract routes (authenticated).
if s.runs != nil {
Expand Down
2 changes: 1 addition & 1 deletion internal/httpapi/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ func newTestServer(t *testing.T, token string) (*Server, *runs.Manager, *approva
log := slog.New(slog.NewTextHandler(io.Discard, nil))
mgr := runs.NewManager(governor.Budget{Tokens: 100000}).WithStore(store, log)
gate := approval.NewGate(store, approval.Policy{DefaultSafe: true}, nil, log)
srv := New(&config.Config{APIToken: token}, nil, mgr, gate, log)
srv := New(&config.Config{APIToken: token}, nil, mgr, gate, nil, log)
return srv, mgr, gate
}

Expand Down
Loading
Loading