diff --git a/.env.example b/.env.example index 115f166..bb1143a 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 43e8fb9..d66f0b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/cmd/riskkernel/main.go b/cmd/riskkernel/main.go index 3a3f83e..3b4bc42 100644 --- a/cmd/riskkernel/main.go +++ b/cmd/riskkernel/main.go @@ -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) } diff --git a/internal/app/bootstrap.go b/internal/app/bootstrap.go index 9da7cf5..658f6a8 100644 --- a/internal/app/bootstrap.go +++ b/internal/app/bootstrap.go @@ -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" @@ -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, @@ -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, @@ -96,6 +106,7 @@ func Build(cfg *config.Config) (*Deps, error) { Store: store, Tracer: tracer, Approvals: gate, + MCP: mcpGW, }, nil } diff --git a/internal/config/config.go b/internal/config/config.go index e40d4e8..ca30a5a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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. @@ -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 { diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 076953c..d5b1628 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -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" @@ -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. @@ -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 { diff --git a/internal/httpapi/server_test.go b/internal/httpapi/server_test.go index 2a2a5ac..f71f44c 100644 --- a/internal/httpapi/server_test.go +++ b/internal/httpapi/server_test.go @@ -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 } diff --git a/internal/mcp/gateway.go b/internal/mcp/gateway.go new file mode 100644 index 0000000..15b8c2b --- /dev/null +++ b/internal/mcp/gateway.go @@ -0,0 +1,243 @@ +// Package mcp implements RiskKernel's MCP gateway: a JSON-RPC reverse proxy that +// sits in front of an upstream MCP server and governs tools/call. Every other MCP +// method is forwarded transparently; tools/call is intercepted to enforce a +// per-tool allowlist, route side-effecting tools through the deterministic +// approval gate, and record an auditable tool_call. Point your MCP client at this +// gateway instead of the real server — the governance is invisible to allowed, +// approved calls. +package mcp + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "log/slog" + "net/http" + "path" + "strings" + "time" + + "github.com/prashar32/riskkernel/internal/approval" + "github.com/prashar32/riskkernel/internal/id" + "github.com/prashar32/riskkernel/internal/runs" + "github.com/prashar32/riskkernel/internal/storage" +) + +// HeaderRunID groups MCP calls into a governed run (same header as the proxy). +const HeaderRunID = "X-RiskKernel-Run-Id" + +// Middleware wraps a handler (e.g. with auth). +type Middleware func(http.HandlerFunc) http.HandlerFunc + +// Gateway governs MCP tools/call in front of an upstream MCP server. +type Gateway struct { + upstream string + client *http.Client + allow []string // empty = allow all; exact name or glob + readonly map[string]bool + gate *approval.Gate + runs *runs.Manager + store storage.Store + log *slog.Logger + approvalTimeout time.Duration +} + +// New constructs an MCP gateway. upstream must be non-empty. +func New(upstream string, allowlist, readonly []string, gate *approval.Gate, + mgr *runs.Manager, store storage.Store, approvalTimeout time.Duration, log *slog.Logger) *Gateway { + ro := make(map[string]bool, len(readonly)) + for _, t := range readonly { + ro[t] = true + } + if approvalTimeout <= 0 { + approvalTimeout = 110 * time.Second + } + return &Gateway{ + upstream: strings.TrimRight(upstream, "/"), + client: &http.Client{Timeout: 130 * time.Second}, + allow: allowlist, + readonly: ro, + gate: gate, + runs: mgr, + store: store, + log: log, + approvalTimeout: approvalTimeout, + } +} + +// Register mounts the gateway at POST /mcp. +func (g *Gateway) Register(mux *http.ServeMux, mw Middleware) { + if mw == nil { + mw = func(h http.HandlerFunc) http.HandlerFunc { return h } + } + mux.HandleFunc("POST /mcp", mw(g.handle)) +} + +// jsonrpcRequest is the subset of a JSON-RPC message we inspect. +type jsonrpcRequest struct { + JSONRPC string `json:"jsonrpc"` + ID json.RawMessage `json:"id"` + Method string `json:"method"` + Params json.RawMessage `json:"params"` +} + +type toolsCallParams struct { + Name string `json:"name"` + Arguments map[string]any `json:"arguments"` +} + +func (g *Gateway) handle(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(io.LimitReader(r.Body, 10<<20)) + if err != nil { + http.Error(w, "read error", http.StatusBadRequest) + return + } + + // Only tools/call is governed; everything else is forwarded verbatim. + var req jsonrpcRequest + if json.Unmarshal(body, &req) != nil || req.Method != "tools/call" { + g.forward(w, r, body) + return + } + + var params toolsCallParams + _ = json.Unmarshal(req.Params, ¶ms) + tool := params.Name + + // 1) Allowlist (deterministic). + if !g.allowed(tool) { + g.log.Warn("mcp tool blocked by allowlist", "tool", tool) + writeRPCError(w, req.ID, -32001, "tool not allowed by policy: "+tool) + return + } + + run := g.resolveRun(r) + sideEffect := g.sideEffect(tool) + stepIdx := run.View().Usage.Loops + + // 2) Approval gate for side-effecting tools (blocks until resolved or timeout). + if sideEffect != "" { + ctx, cancel := context.WithTimeout(r.Context(), g.approvalTimeout) + defer cancel() + decision, _, aerr := g.gate.Request(ctx, approval.Request{ + RunID: run.ID, StepIndex: stepIdx, Tool: tool, + SideEffect: sideEffect, Arguments: params.Arguments, + }) + if aerr != nil { + g.recordToolCall(run.ID, stepIdx, tool, sideEffect, params.Arguments, "timeout") + writeRPCError(w, req.ID, -32002, "approval timed out or run cancelled for tool: "+tool) + return + } + if !decision.Approved { + g.recordToolCall(run.ID, stepIdx, tool, sideEffect, params.Arguments, "denied") + writeRPCError(w, req.ID, -32003, "approval denied for tool: "+tool) + return + } + } + + // 3) Forward to the real MCP server and record the (approved) call. + g.recordToolCall(run.ID, stepIdx, tool, sideEffect, params.Arguments, "approved") + g.forward(w, r, body) +} + +// allowed reports whether the tool passes the allowlist (empty = allow all). +func (g *Gateway) allowed(tool string) bool { + if len(g.allow) == 0 { + return true + } + for _, pat := range g.allow { + if pat == tool { + return true + } + if ok, err := path.Match(pat, tool); err == nil && ok { + return true + } + } + return false +} + +// sideEffect returns "" for read-only tools (no approval) and "tool" otherwise, +// so the approval policy (default-safe) decides whether to gate it. +func (g *Gateway) sideEffect(tool string) string { + if g.readonly[tool] { + return "" + } + return "tool" +} + +func (g *Gateway) resolveRun(r *http.Request) *runs.Run { + if rid := r.Header.Get(HeaderRunID); rid != "" { + return g.runs.GetOrCreate(rid) + } + return g.runs.Create(runs.CreateOptions{Name: "mcp"}) +} + +func (g *Gateway) recordToolCall(runID string, step int32, tool, sideEffect string, args map[string]any, status string) { + if g.store == nil { + return + } + err := g.store.AppendToolCall(context.Background(), storage.ToolCallRecord{ + ID: id.NewUUID(), RunID: runID, StepIndex: step, Tool: tool, + SideEffect: sideEffect, Arguments: args, Status: status, CreatedAt: time.Now(), + }) + if err != nil { + g.log.Error("persist tool call failed", "run", runID, "tool", tool, "err", err) + } +} + +// forward proxies the request body to the upstream MCP server and copies the +// response back (JSON or SSE). +func (g *Gateway) forward(w http.ResponseWriter, r *http.Request, body []byte) { + upReq, err := http.NewRequestWithContext(r.Context(), http.MethodPost, g.upstream, bytes.NewReader(body)) + if err != nil { + writeRPCError(w, nil, -32603, "internal error building upstream request") + return + } + // Forward content negotiation + MCP session headers. + copyHeader(upReq.Header, r.Header, "Content-Type", "Accept", "Mcp-Session-Id", "MCP-Protocol-Version") + if upReq.Header.Get("Content-Type") == "" { + upReq.Header.Set("Content-Type", "application/json") + } + + resp, err := g.client.Do(upReq) + if err != nil { + if errors.Is(r.Context().Err(), context.Canceled) { + return + } + writeRPCError(w, nil, -32603, "upstream MCP server unreachable: "+err.Error()) + return + } + defer resp.Body.Close() + + copyHeader(w.Header(), resp.Header, "Content-Type", "Mcp-Session-Id") + w.WriteHeader(resp.StatusCode) + _, _ = io.Copy(w, resp.Body) +} + +func copyHeader(dst, src http.Header, keys ...string) { + for _, k := range keys { + if v := src.Get(k); v != "" { + dst.Set(k, v) + } + } +} + +// writeRPCError writes a JSON-RPC 2.0 error response. +func writeRPCError(w http.ResponseWriter, id json.RawMessage, code int, message string) { + if len(id) == 0 { + id = json.RawMessage("null") + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) // JSON-RPC errors ride a 200 envelope + _ = json.NewEncoder(w).Encode(map[string]any{ + "jsonrpc": "2.0", + "id": id, + "error": map[string]any{ + "code": code, + "message": message, + "data": map[string]any{"source": "riskkernel"}, + }, + }) +} diff --git a/internal/mcp/gateway_test.go b/internal/mcp/gateway_test.go new file mode 100644 index 0000000..6f192de --- /dev/null +++ b/internal/mcp/gateway_test.go @@ -0,0 +1,167 @@ +package mcp + +import ( + "context" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/prashar32/riskkernel/internal/approval" + "github.com/prashar32/riskkernel/internal/governor" + "github.com/prashar32/riskkernel/internal/runs" + "github.com/prashar32/riskkernel/internal/storage" +) + +type discard struct{} + +func (discard) Write(p []byte) (int, error) { return len(p), nil } + +func reqCtx() context.Context { return context.Background() } + +func newTestGateway(t *testing.T, allowlist, readonly []string) (*Gateway, *int32) { + t.Helper() + var hits int32 + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&hits, 1) + _, _ = io.ReadAll(r.Body) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"ok"}]}}`)) + })) + t.Cleanup(upstream.Close) + + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "mcp.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = store.Close() }) + log := slog.New(slog.NewTextHandler(discard{}, nil)) + gate := approval.NewGate(store, approval.Policy{DefaultSafe: true}, nil, log) + mgr := runs.NewManager(governor.Budget{}).WithStore(store, log) + + g := New(upstream.URL, allowlist, readonly, gate, mgr, store, 5*time.Second, log) + return g, &hits +} + +func mcpReq(body string) *http.Request { + r := httptest.NewRequest(http.MethodPost, "/mcp", strings.NewReader(body)) + r.Header.Set(HeaderRunID, "test-run") + r.Header.Set("Content-Type", "application/json") + return r +} + +func rpcError(t *testing.T, w *httptest.ResponseRecorder) map[string]any { + t.Helper() + var resp map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v (body=%s)", err, w.Body.String()) + } + e, _ := resp["error"].(map[string]any) + return e +} + +func TestForwardsNonToolCall(t *testing.T) { + g, hits := newTestGateway(t, nil, nil) + w := httptest.NewRecorder() + g.handle(w, mcpReq(`{"jsonrpc":"2.0","id":1,"method":"tools/list"}`)) + if w.Code != http.StatusOK || *hits != 1 { + t.Fatalf("tools/list should forward: code=%d hits=%d", w.Code, *hits) + } +} + +func TestAllowlistBlocks(t *testing.T) { + g, hits := newTestGateway(t, []string{"safe_*"}, nil) + w := httptest.NewRecorder() + g.handle(w, mcpReq(`{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"danger_rm"}}`)) + if e := rpcError(t, w); e == nil || e["code"].(float64) != -32001 { + t.Fatalf("expected allowlist error, got %v", e) + } + if *hits != 0 { + t.Fatal("blocked tool must NOT reach upstream") + } +} + +func TestReadOnlyToolForwards(t *testing.T) { + g, hits := newTestGateway(t, nil, []string{"search"}) + w := httptest.NewRecorder() + g.handle(w, mcpReq(`{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"search","arguments":{"q":"x"}}}`)) + if w.Code != http.StatusOK || *hits != 1 { + t.Fatalf("read-only tool should forward without approval: code=%d hits=%d", w.Code, *hits) + } + if !strings.Contains(w.Body.String(), "ok") { + t.Errorf("response not forwarded: %s", w.Body.String()) + } +} + +func TestSideEffectingToolApproved(t *testing.T) { + g, hits := newTestGateway(t, nil, []string{"search"}) // "write" is NOT read-only + + done := make(chan *httptest.ResponseRecorder, 1) + go func() { + w := httptest.NewRecorder() + g.handle(w, mcpReq(`{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"write","arguments":{"path":"/x"}}}`)) + done <- w + }() + + id := waitPending(t, g) + if err := g.gate.Resolve(reqCtx(), id, true, "ok", "tester"); err != nil { + t.Fatal(err) + } + select { + case w := <-done: + if w.Code != http.StatusOK || *hits != 1 || !strings.Contains(w.Body.String(), "ok") { + t.Fatalf("approved tool should forward: code=%d hits=%d body=%s", w.Code, *hits, w.Body.String()) + } + case <-time.After(3 * time.Second): + t.Fatal("approved tools/call did not complete") + } +} + +func TestSideEffectingToolDenied(t *testing.T) { + g, hits := newTestGateway(t, nil, nil) + + done := make(chan *httptest.ResponseRecorder, 1) + go func() { + w := httptest.NewRecorder() + g.handle(w, mcpReq(`{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"deploy","arguments":{}}}`)) + done <- w + }() + + id := waitPending(t, g) + if err := g.gate.Resolve(reqCtx(), id, false, "no", "tester"); err != nil { + t.Fatal(err) + } + select { + case w := <-done: + if e := rpcError(t, w); e == nil || e["code"].(float64) != -32003 { + t.Fatalf("expected approval-denied error, got %v", e) + } + if *hits != 0 { + t.Fatal("denied tool must NOT reach upstream") + } + case <-time.After(3 * time.Second): + t.Fatal("denied tools/call did not complete") + } +} + +func waitPending(t *testing.T, g *Gateway) string { + t.Helper() + for i := 0; i < 200; i++ { + p, err := g.gate.Pending(reqCtx()) + if err != nil { + t.Fatal(err) + } + if len(p) == 1 { + return p[0].ID + } + time.Sleep(5 * time.Millisecond) + } + t.Fatal("pending approval never appeared") + return "" +}