diff --git a/api/v1alpha1/mcpserver_types.go b/api/v1alpha1/mcpserver_types.go index 48d97fc3..2cfc1211 100644 --- a/api/v1alpha1/mcpserver_types.go +++ b/api/v1alpha1/mcpserver_types.go @@ -47,6 +47,15 @@ const ( ToolSideEffectDestructive ToolSideEffect = "destructive" ) +// +kubebuilder:validation:Enum=low;medium;high +type ToolRiskLevel string + +const ( + ToolRiskLevelLow ToolRiskLevel = "low" + ToolRiskLevelMedium ToolRiskLevel = "medium" + ToolRiskLevelHigh ToolRiskLevel = "high" +) + // +kubebuilder:validation:Enum=RollingUpdate;Recreate;Canary type RolloutStrategy string @@ -184,6 +193,7 @@ type ToolConfig struct { Description string `json:"description,omitempty"` RequiredTrust TrustLevel `json:"requiredTrust,omitempty"` SideEffect ToolSideEffect `json:"sideEffect"` + RiskLevel ToolRiskLevel `json:"riskLevel,omitempty"` Labels map[string]string `json:"labels,omitempty"` } diff --git a/config/crd/bases/mcpruntime.org_mcpservers.yaml b/config/crd/bases/mcpruntime.org_mcpservers.yaml index 0acfe913..ff7fdc2f 100644 --- a/config/crd/bases/mcpruntime.org_mcpservers.yaml +++ b/config/crd/bases/mcpruntime.org_mcpservers.yaml @@ -421,6 +421,12 @@ spec: - medium - high type: string + riskLevel: + enum: + - low + - medium + - high + type: string sideEffect: enum: - read diff --git a/docs/api.md b/docs/api.md index 5bdef345..950325c1 100644 --- a/docs/api.md +++ b/docs/api.md @@ -51,6 +51,7 @@ flowchart LR | **policy.mode** | `allow-list`, `observe` | `allow-list` enforces deny-by-default; `observe` keeps the decision path visible. | | **trust** | `low`, `medium`, `high` | Used on tools, grants, sessions. Effective trust = min(grant, session). | | **tool sideEffect** | `read`, `write`, `destructive` | Required on each listed tool. Grants must include the tool's side effect in `allowedSideEffects` before a tool call can pass. | +| **tool riskLevel** | `low`, `medium`, `high` | Optional informational catalog/audit badge. If omitted, the platform computes a default from trust and side effect. It does not gate calls. | | **rollout.strategy** | `RollingUpdate`, `Recreate`, `Canary` | Available on `spec.rollout`. | ### Validation rules in code @@ -102,10 +103,12 @@ spec: description: List invoices for a customer account. requiredTrust: low sideEffect: read + riskLevel: low - name: refund_invoice description: Issue a refund for an invoice. requiredTrust: high sideEffect: destructive + riskLevel: high rollout: strategy: Canary canaryReplicas: 1 diff --git a/docs/cli.md b/docs/cli.md index cd1d9caf..a5cb5bd6 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -289,11 +289,29 @@ mcp-runtime server policy inspect workspace-demo --namespace mcp-team-acme mcp-runtime server list mcp-runtime server get workspace-demo --namespace mcp-team-acme mcp-runtime server status --namespace mcp-team-acme +mcp-runtime server connect-config workspace-demo --namespace mcp-team-acme --client claude mcp-runtime server policy inspect workspace-demo --namespace mcp-team-acme mcp-runtime server delete workspace-demo mcp-runtime server generate --metadata-dir .mcp --output manifests/ ``` +--- + +## catalog + +**[User]** platform API only + +```bash +mcp-runtime catalog tools +mcp-runtime catalog tools --query invoice --risk high +mcp-runtime catalog tools --namespace mcp-team-acme --side-effect write +mcp-runtime catalog tool refund_invoice --server payments --output json +``` + +The catalog is visibility-only. It shows tools from visible servers with trust, +side effect, computed or declared risk, drift (`declared`, `ungoverned`, +`missing`), and copyable connect config. + ### Direct Kubernetes operations (--use-kube) [Admin] ```bash diff --git a/docs/internals/go-package-reference.md b/docs/internals/go-package-reference.md index 16a99bd5..c1300e09 100644 --- a/docs/internals/go-package-reference.md +++ b/docs/internals/go-package-reference.md @@ -172,6 +172,7 @@ Package v1alpha1 contains API Schema definitions for the MCP server resource. - [`type ToolConfig struct`](#api-types-type-toolconfig-struct) - [`func (in *ToolConfig) DeepCopy() *ToolConfig`](#api-types-func-in-toolconfig-deepcopy-toolconfig) - [`func (in *ToolConfig) DeepCopyInto(out *ToolConfig)`](#api-types-func-in-toolconfig-deepcopyinto-out-toolconfig) +- [`type ToolRiskLevel string`](#api-types-type-toolrisklevel-string) - [`type ToolRule struct`](#api-types-type-toolrule-struct) - [`func (in *ToolRule) DeepCopy() *ToolRule`](#api-types-func-in-toolrule-deepcopy-toolrule) - [`func (in *ToolRule) DeepCopyInto(out *ToolRule)`](#api-types-func-in-toolrule-deepcopyinto-out-toolrule) @@ -1284,6 +1285,7 @@ type ToolConfig struct { Description string `json:"description,omitempty"` RequiredTrust TrustLevel `json:"requiredTrust,omitempty"` SideEffect ToolSideEffect `json:"sideEffect"` + RiskLevel ToolRiskLevel `json:"riskLevel,omitempty"` Labels map[string]string `json:"labels,omitempty"` } ToolConfig describes one MCP tool exposed by a server. @@ -1307,6 +1309,18 @@ func (in *ToolConfig) DeepCopyInto(out *ToolConfig) ``` + +```text +type ToolRiskLevel string + +kubebuilder:validation:Enum=low;medium;high + +const ( + ToolRiskLevelLow ToolRiskLevel = "low" + ToolRiskLevelMedium ToolRiskLevel = "medium" + ToolRiskLevelHigh ToolRiskLevel = "high" +) +``` + ```text type ToolRule struct { @@ -1420,6 +1434,7 @@ _No package overview is documented._ - [`type ServerMetadata struct`](#metadata-helpers-type-servermetadata-struct) - [`type SessionConfig struct`](#metadata-helpers-type-sessionconfig-struct) - [`type ToolConfig struct`](#metadata-helpers-type-toolconfig-struct) +- [`type ToolRiskLevel string`](#metadata-helpers-type-toolrisklevel-string) - [`type ToolSideEffect string`](#metadata-helpers-type-toolsideeffect-string) - [`type TrustLevel string`](#metadata-helpers-type-trustlevel-string) @@ -1848,12 +1863,24 @@ type ToolConfig struct { Description string `yaml:"description,omitempty" json:"description,omitempty"` RequiredTrust TrustLevel `yaml:"requiredTrust,omitempty" json:"requiredTrust,omitempty"` SideEffect ToolSideEffect `yaml:"sideEffect" json:"sideEffect"` + RiskLevel ToolRiskLevel `yaml:"riskLevel,omitempty" json:"riskLevel,omitempty"` Labels map[string]string `yaml:"labels,omitempty" json:"labels,omitempty"` } ToolConfig describes one MCP tool exposed by a server. ``` + +```text +type ToolRiskLevel string + +const ( + ToolRiskLevelLow ToolRiskLevel = "low" + ToolRiskLevelMedium ToolRiskLevel = "medium" + ToolRiskLevelHigh ToolRiskLevel = "high" +) +``` + ```text type ToolSideEffect string @@ -4786,6 +4813,7 @@ _No package overview is documented._ - [`func (c *PlatformClient) ListGrants(ctx context.Context, namespace string) ([]sentinelaccess.GrantSummary, error)`](#cli-platform-api-func-c-platformclient-listgrants-ctx-context-context-namespace-string-sentinelaccess-grantsummary-error) - [`func (c *PlatformClient) ListNamespaces(ctx context.Context) ([]namespaceListItem, error)`](#cli-platform-api-func-c-platformclient-listnamespaces-ctx-context-context-namespacelistitem-error) - [`func (c *PlatformClient) ListRuntimeServers(ctx context.Context, namespace string) ([]ServerListItem, error)`](#cli-platform-api-func-c-platformclient-listruntimeservers-ctx-context-context-namespace-string-serverlistitem-error) +- [`func (c *PlatformClient) ListRuntimeTools(ctx context.Context, filters map[string]string) ([]RuntimeToolRow, error)`](#cli-platform-api-func-c-platformclient-listruntimetools-ctx-context-context-filters-map-string-string-runtimetoolrow-error) - [`func (c *PlatformClient) ListSessions(ctx context.Context, namespace string) ([]sentinelaccess.SessionSummary, error)`](#cli-platform-api-func-c-platformclient-listsessions-ctx-context-context-namespace-string-sentinelaccess-sessionsummary-error) - [`func (c *PlatformClient) ListTeamMembers(ctx context.Context, slug string) ([]TeamMembership, error)`](#cli-platform-api-func-c-platformclient-listteammembers-ctx-context-context-slug-string-teammembership-error) - [`func (c *PlatformClient) ListTeams(ctx context.Context) ([]Team, error)`](#cli-platform-api-func-c-platformclient-listteams-ctx-context-context-team-error) @@ -4797,9 +4825,11 @@ _No package overview is documented._ - [`func (c *PlatformClient) ValidateCredentials(ctx context.Context) error`](#cli-platform-api-func-c-platformclient-validatecredentials-ctx-context-context-error) - [`type PlatformUser struct`](#cli-platform-api-type-platformuser-struct) - [`type Principal struct`](#cli-platform-api-type-principal-struct) +- [`type RuntimeToolRow struct`](#cli-platform-api-type-runtimetoolrow-struct) - [`type ServerListItem struct`](#cli-platform-api-type-serverlistitem-struct) - [`type Team struct`](#cli-platform-api-type-team-struct) - [`type TeamMembership = platform.TeamMembership`](#cli-platform-api-type-teammembership-platform-teammembership) +- [`type ToolConfig struct`](#cli-platform-api-type-toolconfig-struct) ### Constants @@ -5025,6 +5055,12 @@ func (c *PlatformClient) ListRuntimeServers(ctx context.Context, namespace strin ``` + +```text +func (c *PlatformClient) ListRuntimeTools(ctx context.Context, filters map[string]string) ([]RuntimeToolRow, error) + +``` + ```text func (c *PlatformClient) ListSessions(ctx context.Context, namespace string) ([]sentinelaccess.SessionSummary, error) @@ -5105,11 +5141,33 @@ type Principal struct { ``` + +```text +type RuntimeToolRow struct { + ToolName string `json:"tool_name"` + Description string `json:"description,omitempty"` + ServerName string `json:"server_name"` + Namespace string `json:"namespace"` + TeamID string `json:"team_id,omitempty"` + EndpointURL string `json:"endpoint_url,omitempty"` + Declared bool `json:"declared"` + Live bool `json:"live"` + DriftStatus string `json:"drift_status"` + RequiredTrust string `json:"required_trust,omitempty"` + SideEffect string `json:"side_effect,omitempty"` + RiskLevel string `json:"risk_level,omitempty"` + Labels map[string]string `json:"labels,omitempty"` + ConnectConfig map[string]any `json:"connect_config,omitempty"` +} + +``` + ```text type ServerListItem struct { Name string `json:"name"` Namespace string `json:"namespace"` + TeamID string `json:"team_id,omitempty"` Image string `json:"image,omitempty"` ImageTag string `json:"imageTag,omitempty"` Description string `json:"description,omitempty"` @@ -5117,6 +5175,9 @@ type ServerListItem struct { Status string `json:"status"` Labels map[string]string `json:"labels"` Age string `json:"age"` + Endpoint string `json:"endpoint,omitempty"` + Tools []ToolConfig `json:"tools,omitempty"` + AccessJSON map[string]any `json:"access_json,omitempty"` } ServerListItem is one row from the platform API runtime servers list. @@ -5137,6 +5198,19 @@ type Team struct { ```text type TeamMembership = platform.TeamMembership + +``` + + +```text +type ToolConfig struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + RequiredTrust string `json:"requiredTrust,omitempty"` + SideEffect string `json:"sideEffect,omitempty"` + RiskLevel string `json:"riskLevel,omitempty"` + Labels map[string]string `json:"labels,omitempty"` +} ``` @@ -5703,6 +5777,7 @@ Package server owns routing for the server top-level command. ### Index +- [`func BuildConnectConfig(server platformapi.ServerListItem, clientName string) (map[string]any, error)`](#cli-server-func-buildconnectconfig-server-platformapi-serverlistitem-clientname-string-map-string-any-error) - [`func BuildImage(ctx context.Context, logger *zap.Logger, serverName, dockerfile, metadataFile, metadataDir, registryURL, tag, platform, contextDir string) error`](#cli-server-func-buildimage-ctx-context-context-logger-zap-logger-servername-dockerfile-metadatafile-metadatadir-registryurl-tag-platform-contextdir-string-error) - [`func DiscoverToolsFromServer(serverURL string) ([]string, error)`](#cli-server-func-discovertoolsfromserver-serverurl-string-string-error) - [`func New(runtime *core.Runtime) *cobra.Command`](#cli-server-func-new-runtime-core-runtime-cobra-command) @@ -5712,6 +5787,7 @@ Package server owns routing for the server top-level command. - [`func NewServerManager(kubectl *core.KubectlClient, logger *zap.Logger) *ServerManager`](#cli-server-func-newservermanager-kubectl-core-kubectlclient-logger-zap-logger-servermanager) - [`func (m *ServerManager) ApplyServerFromFile(file string) error`](#cli-server-func-m-servermanager-applyserverfromfile-file-string-error) - [`func (m *ServerManager) BindUseKubeFlag(cmd *cobra.Command)`](#cli-server-func-m-servermanager-bindusekubeflag-cmd-cobra-command) +- [`func (m *ServerManager) ConnectConfig(name, namespace, clientName, output string) error`](#cli-server-func-m-servermanager-connectconfig-name-namespace-clientname-output-string-error) - [`func (m *ServerManager) CreateServer(name, namespace, image, imageTag string) error`](#cli-server-func-m-servermanager-createserver-name-namespace-image-imagetag-string-error) - [`func (m *ServerManager) CreateServerFromFile(file string) error`](#cli-server-func-m-servermanager-createserverfromfile-file-string-error) - [`func (m *ServerManager) DeleteServer(name, namespace string) error`](#cli-server-func-m-servermanager-deleteserver-name-namespace-string-error) @@ -5719,7 +5795,7 @@ Package server owns routing for the server top-level command. - [`func (m *ServerManager) ExportServer(name, namespace, file string) error`](#cli-server-func-m-servermanager-exportserver-name-namespace-file-string-error) - [`func (m *ServerManager) GenerateManifests(metadataFile, metadataDir, outputDir string) error`](#cli-server-func-m-servermanager-generatemanifests-metadatafile-metadatadir-outputdir-string-error) - [`func (m *ServerManager) GetServer(name, namespace string) error`](#cli-server-func-m-servermanager-getserver-name-namespace-string-error) -- [`func (m *ServerManager) InitServer(name, metadataDir, image, imageTag, scope, policyMode, defaultDecision string, sessionRequired bool, port int32, tools, toolSpecs []string, force bool) error`](#cli-server-func-m-servermanager-initserver-name-metadatadir-image-imagetag-scope-policymode-defaultdecision-string-sessionrequired-bool-port-int32-tools-toolspecs-string-force-bool-error) +- [`func (m *ServerManager) InitServer(name, metadataDir, image, imageTag, scope, policyMode, defaultDecision string, sessionRequired bool, port int32, tools, toolSpecs []string, toolRisk string, force bool) error`](#cli-server-func-m-servermanager-initserver-name-metadatadir-image-imagetag-scope-policymode-defaultdecision-string-sessionrequired-bool-port-int32-tools-toolspecs-string-toolrisk-string-force-bool-error) - [`func (m *ServerManager) InspectServerPolicy(name, namespace string) error`](#cli-server-func-m-servermanager-inspectserverpolicy-name-namespace-string-error) - [`func (m *ServerManager) ListServers(namespace, team string) error`](#cli-server-func-m-servermanager-listservers-namespace-team-string-error) - [`func (m *ServerManager) Logger() *zap.Logger`](#cli-server-func-m-servermanager-logger-zap-logger) @@ -5730,6 +5806,11 @@ Package server owns routing for the server top-level command. ### Functions + +```text +func BuildConnectConfig(server platformapi.ServerListItem, clientName string) (map[string]any, error) +``` + ```text func BuildImage(ctx context.Context, logger *zap.Logger, serverName, dockerfile, metadataFile, metadataDir, registryURL, tag, platform, contextDir string) error @@ -5803,6 +5884,14 @@ func (m *ServerManager) BindUseKubeFlag(cmd *cobra.Command) ``` + +```text +func (m *ServerManager) ConnectConfig(name, namespace, clientName, output string) error + ConnectConfig prints client connection config for a platform-visible MCP + server. + +``` + ```text func (m *ServerManager) CreateServer(name, namespace, image, imageTag string) error @@ -5852,9 +5941,9 @@ func (m *ServerManager) GetServer(name, namespace string) error ``` - + ```text -func (m *ServerManager) InitServer(name, metadataDir, image, imageTag, scope, policyMode, defaultDecision string, sessionRequired bool, port int32, tools, toolSpecs []string, force bool) error +func (m *ServerManager) InitServer(name, metadataDir, image, imageTag, scope, policyMode, defaultDecision string, sessionRequired bool, port int32, tools, toolSpecs []string, toolRisk string, force bool) error ``` diff --git a/docs/publish-mcp-server.md b/docs/publish-mcp-server.md index bdbef7fa..2390ea58 100644 --- a/docs/publish-mcp-server.md +++ b/docs/publish-mcp-server.md @@ -177,10 +177,12 @@ servers: description: List invoices for a customer account. requiredTrust: low sideEffect: read + riskLevel: low - name: refund_invoice description: Issue a refund for an invoice. requiredTrust: high sideEffect: destructive + riskLevel: high ``` ### Metadata fields @@ -210,7 +212,7 @@ servers: - `namespace` The target namespace. - `tools` - Tool inventory for the platform catalog and policy authoring. Include each tool's description when the MCP server SDK exposes one through `tools/list`, and set `sideEffect` to `read`, `write`, or `destructive`. Tool side effects are required when a tool is listed. + Tool inventory for the platform catalog and policy authoring. Include each tool's description when the MCP server SDK exposes one through `tools/list`, and set `sideEffect` to `read`, `write`, or `destructive`. Tool side effects are required when a tool is listed. Optional `riskLevel` (`low`, `medium`, `high`) is informational for catalog and audit views; it does not change gateway authorization. - `auth`, `policy`, `session`, and `gateway` Governed request-path settings. `server init` writes `gateway.enabled: true`, allow-list/deny policy, and `session.required: true` so public tool calls go through the adapter/session path by default. Use `--policy-mode`, `--default-decision`, or `--session-required=false` to change those scaffolded values. Init omits platform-managed gateway wiring and auth/session header details unless you override them intentionally. diff --git a/examples/data-utility-mcp/.mcp/servers.yaml b/examples/data-utility-mcp/.mcp/servers.yaml index 4b6ecec6..41ff0114 100644 --- a/examples/data-utility-mcp/.mcp/servers.yaml +++ b/examples/data-utility-mcp/.mcp/servers.yaml @@ -15,3 +15,69 @@ servers: required: true gateway: enabled: true + tools: + - name: ping + description: Return a simple pong response. + requiredTrust: low + sideEffect: read + riskLevel: low + - name: echo + description: Echo the provided message. + requiredTrust: low + sideEffect: read + riskLevel: low + - name: reverse + description: Reverse the provided text. + requiredTrust: low + sideEffect: read + riskLevel: low + - name: add + description: Add two numeric values. + requiredTrust: low + sideEffect: read + riskLevel: low + - name: multiply + description: Multiply two numeric values. + requiredTrust: low + sideEffect: read + riskLevel: low + - name: upper + description: Uppercase the provided message. + requiredTrust: medium + sideEffect: read + riskLevel: medium + - name: lower + description: Lowercase the provided message. + requiredTrust: low + sideEffect: read + riskLevel: low + - name: slugify + description: Convert the provided message into a URL slug. + requiredTrust: low + sideEffect: read + riskLevel: low + - name: word_count + description: Count words in the provided message. + requiredTrust: low + sideEffect: read + riskLevel: low + - name: extract_keywords + description: Extract stable lowercase keywords from a short text sample. + requiredTrust: low + sideEffect: read + riskLevel: low + - name: summarize_numbers + description: Summarize numbers found in text. + requiredTrust: medium + sideEffect: read + riskLevel: medium + - name: now + description: Return the current UTC timestamp. + requiredTrust: low + sideEffect: read + riskLevel: low + - name: create_task + description: Create a deterministic task summary. + requiredTrust: low + sideEffect: write + riskLevel: medium diff --git a/examples/governed-agent/deploy/server.metadata.yaml b/examples/governed-agent/deploy/server.metadata.yaml index 62a41a3f..03933cf2 100644 --- a/examples/governed-agent/deploy/server.metadata.yaml +++ b/examples/governed-agent/deploy/server.metadata.yaml @@ -14,34 +14,42 @@ servers: description: Check that the governed workspace assistant is reachable. requiredTrust: low sideEffect: read + riskLevel: low - name: echo description: Echo a message for adapter and transport debugging. requiredTrust: low sideEffect: read + riskLevel: low - name: add description: Add two numeric values. requiredTrust: low sideEffect: read + riskLevel: low - name: upper description: Convert text to uppercase for normalization checks. requiredTrust: medium sideEffect: read + riskLevel: medium - name: lower description: Convert text to lowercase for normalization checks. requiredTrust: low sideEffect: read + riskLevel: low - name: slugify description: Convert a title or label into a URL-safe slug. requiredTrust: low sideEffect: read + riskLevel: low - name: create_task description: Create a deterministic task card summary. requiredTrust: low sideEffect: write + riskLevel: medium - name: draft_release_note description: Draft a compact release note from a change summary and impact. requiredTrust: low sideEffect: read + riskLevel: low prompts: - name: task_brief description: Draft a concise task brief from a goal. diff --git a/examples/mcpserver-path-based.yaml b/examples/mcpserver-path-based.yaml index cac321fb..8b4fbf29 100644 --- a/examples/mcpserver-path-based.yaml +++ b/examples/mcpserver-path-based.yaml @@ -21,34 +21,42 @@ spec: description: Check that the workspace assistant is reachable. requiredTrust: low sideEffect: read + riskLevel: low - name: echo description: Echo a message for adapter and transport debugging. requiredTrust: low sideEffect: read + riskLevel: low - name: add description: Add two numeric values. requiredTrust: low sideEffect: read + riskLevel: low - name: upper description: Convert text to uppercase for normalization checks. requiredTrust: medium sideEffect: read + riskLevel: medium - name: lower description: Convert text to lowercase for normalization checks. requiredTrust: low sideEffect: read + riskLevel: low - name: slugify description: Convert a title or label into a URL-safe slug. requiredTrust: low sideEffect: read + riskLevel: low - name: create_task description: Create a deterministic task card summary. requiredTrust: low sideEffect: write + riskLevel: medium - name: draft_release_note description: Draft a compact release note from a change summary and impact. requiredTrust: low sideEffect: read + riskLevel: low prompts: - name: hello description: Return a simple workspace assistant greeting. diff --git a/examples/text-analysis-mcp/.mcp/servers.yaml b/examples/text-analysis-mcp/.mcp/servers.yaml index fd578d80..b09aba5c 100644 --- a/examples/text-analysis-mcp/.mcp/servers.yaml +++ b/examples/text-analysis-mcp/.mcp/servers.yaml @@ -14,11 +14,14 @@ servers: description: Repeat the provided message a number of times. requiredTrust: low sideEffect: read + riskLevel: low - name: word_count description: Count the words in the provided message. requiredTrust: low sideEffect: read + riskLevel: low - name: extract_keywords description: Extract stable lowercase keywords from a short text sample. requiredTrust: low sideEffect: read + riskLevel: low diff --git a/examples/workspace-assistant-mcp/.mcp/servers.yaml b/examples/workspace-assistant-mcp/.mcp/servers.yaml index e1610e5a..a760578e 100644 --- a/examples/workspace-assistant-mcp/.mcp/servers.yaml +++ b/examples/workspace-assistant-mcp/.mcp/servers.yaml @@ -14,34 +14,42 @@ servers: description: Check that the workspace assistant is reachable. requiredTrust: low sideEffect: read + riskLevel: low - name: echo description: Echo a message for adapter and transport debugging. requiredTrust: low sideEffect: read + riskLevel: low - name: add description: Add two numeric values. requiredTrust: low sideEffect: read + riskLevel: low - name: upper description: Convert text to uppercase for normalization checks. requiredTrust: medium sideEffect: read + riskLevel: medium - name: lower description: Convert text to lowercase for normalization checks. requiredTrust: low sideEffect: read + riskLevel: low - name: slugify description: Convert a title or label into a URL-safe slug. requiredTrust: low sideEffect: read + riskLevel: low - name: create_task description: Create a deterministic task card summary. requiredTrust: low sideEffect: write + riskLevel: medium - name: draft_release_note description: Draft a compact release note from a change summary and impact. requiredTrust: low sideEffect: read + riskLevel: low prompts: - name: hello description: Return a simple workspace assistant greeting. diff --git a/internal/cli/catalog/catalog.go b/internal/cli/catalog/catalog.go new file mode 100644 index 00000000..0659be7f --- /dev/null +++ b/internal/cli/catalog/catalog.go @@ -0,0 +1,173 @@ +package catalog + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strings" + "text/tabwriter" + + "github.com/spf13/cobra" + "gopkg.in/yaml.v3" + + "mcp-runtime/internal/cli/core" + "mcp-runtime/internal/cli/platformapi" +) + +type Manager struct{} + +func New(_ *core.Runtime) *cobra.Command { + mgr := &Manager{} + cmd := &cobra.Command{ + Use: "catalog", + Short: "Search MCP Runtime catalogs", + Long: "Search MCP tools exposed by servers visible to the logged-in platform user.", + } + + var filters catalogFilters + toolsCmd := &cobra.Command{ + Use: "tools", + Short: "List tools across visible MCP servers", + RunE: func(cmd *cobra.Command, args []string) error { + return mgr.ListTools(filters) + }, + } + bindToolFilterFlags(toolsCmd, &filters) + + var detailFilters catalogFilters + toolCmd := &cobra.Command{ + Use: "tool [name]", + Short: "Show one tool from the catalog", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return mgr.GetTool(args[0], detailFilters) + }, + } + bindToolFilterFlags(toolCmd, &detailFilters) + + cmd.AddCommand(toolsCmd, toolCmd) + return cmd +} + +type catalogFilters struct { + Query string + Namespace string + Team string + Server string + Trust string + SideEffect string + Risk string + Drift string + Output string +} + +func bindToolFilterFlags(cmd *cobra.Command, filters *catalogFilters) { + cmd.Flags().StringVarP(&filters.Query, "query", "q", "", "Search text") + cmd.Flags().StringVar(&filters.Namespace, "namespace", "", "Namespace filter") + cmd.Flags().StringVar(&filters.Team, "team", "", "Team ID filter") + cmd.Flags().StringVar(&filters.Server, "server", "", "Server name filter") + cmd.Flags().StringVar(&filters.SideEffect, "side-effect", "", "Side effect filter: read, write, destructive") + cmd.Flags().StringVar(&filters.Trust, "trust", "", "Required trust filter: low, medium, high") + cmd.Flags().StringVar(&filters.Risk, "risk", "", "Risk filter: low, medium, high") + cmd.Flags().StringVar(&filters.Drift, "drift", "", "Drift filter: declared, ungoverned, missing") + cmd.Flags().StringVarP(&filters.Output, "output", "o", "table", "Output format: table, json, yaml") +} + +func (m *Manager) ListTools(filters catalogFilters) error { + client, err := platformapi.NewPlatformClient() + if err != nil { + return err + } + rows, err := client.ListRuntimeTools(context.Background(), filters.queryMap()) + if err != nil { + return err + } + return printToolRows(rows, filters.Output) +} + +func (m *Manager) GetTool(name string, filters catalogFilters) error { + name = strings.TrimSpace(name) + filtersMap := filters.queryMap() + if strings.TrimSpace(filters.Query) == "" { + filtersMap["query"] = name + } + client, err := platformapi.NewPlatformClient() + if err != nil { + return err + } + rows, err := client.ListRuntimeTools(context.Background(), filtersMap) + if err != nil { + return err + } + matched := make([]platformapi.RuntimeToolRow, 0, len(rows)) + for _, row := range rows { + if strings.EqualFold(row.ToolName, strings.TrimSpace(name)) { + if filters.Server != "" && row.ServerName != filters.Server { + continue + } + matched = append(matched, row) + } + } + if len(matched) == 0 { + return core.NewWithSentinel(nil, fmt.Sprintf("tool %q not found", name)) + } + return printToolRows(matched, filters.Output) +} + +func (f catalogFilters) queryMap() map[string]string { + return map[string]string{ + "query": f.Query, + "namespace": f.Namespace, + "team": f.Team, + "server": f.Server, + "trust": f.Trust, + "side_effect": f.SideEffect, + "risk": f.Risk, + "drift": f.Drift, + } +} + +func printToolRows(rows []platformapi.RuntimeToolRow, output string) error { + switch strings.ToLower(strings.TrimSpace(output)) { + case "", "table": + tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + _, _ = fmt.Fprintln(tw, "TOOL\tSERVER\tNAMESPACE\tTRUST\tSIDE_EFFECT\tRISK\tDRIFT") + for _, row := range rows { + _, _ = fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", + row.ToolName, + row.ServerName, + row.Namespace, + valueOrDash(row.RequiredTrust), + valueOrDash(row.SideEffect), + valueOrDash(row.RiskLevel), + valueOrDash(row.DriftStatus), + ) + } + return tw.Flush() + case "json": + data, err := json.MarshalIndent(map[string]any{"tools": rows}, "", " ") + if err != nil { + return err + } + _, err = os.Stdout.Write(append(data, '\n')) + return err + case "yaml": + data, err := yaml.Marshal(map[string]any{"tools": rows}) + if err != nil { + return err + } + _, err = os.Stdout.Write(data) + return err + default: + return core.NewWithSentinel(nil, "output must be table, json, or yaml") + } +} + +func valueOrDash(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "-" + } + return value +} diff --git a/internal/cli/platformapi/client.go b/internal/cli/platformapi/client.go index 55b473b6..abc3a02f 100644 --- a/internal/cli/platformapi/client.go +++ b/internal/cli/platformapi/client.go @@ -581,6 +581,7 @@ func httpAPIError(status int, body []byte) error { type ServerListItem struct { Name string `json:"name"` Namespace string `json:"namespace"` + TeamID string `json:"team_id,omitempty"` Image string `json:"image,omitempty"` ImageTag string `json:"imageTag,omitempty"` Description string `json:"description,omitempty"` @@ -588,12 +589,45 @@ type ServerListItem struct { Status string `json:"status"` Labels map[string]string `json:"labels"` Age string `json:"age"` + Endpoint string `json:"endpoint,omitempty"` + Tools []ToolConfig `json:"tools,omitempty"` + AccessJSON map[string]any `json:"access_json,omitempty"` } type serverListResponse struct { Servers []ServerListItem `json:"servers"` } +type ToolConfig struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + RequiredTrust string `json:"requiredTrust,omitempty"` + SideEffect string `json:"sideEffect,omitempty"` + RiskLevel string `json:"riskLevel,omitempty"` + Labels map[string]string `json:"labels,omitempty"` +} + +type RuntimeToolRow struct { + ToolName string `json:"tool_name"` + Description string `json:"description,omitempty"` + ServerName string `json:"server_name"` + Namespace string `json:"namespace"` + TeamID string `json:"team_id,omitempty"` + EndpointURL string `json:"endpoint_url,omitempty"` + Declared bool `json:"declared"` + Live bool `json:"live"` + DriftStatus string `json:"drift_status"` + RequiredTrust string `json:"required_trust,omitempty"` + SideEffect string `json:"side_effect,omitempty"` + RiskLevel string `json:"risk_level,omitempty"` + Labels map[string]string `json:"labels,omitempty"` + ConnectConfig map[string]any `json:"connect_config,omitempty"` +} + +type runtimeToolsResponse struct { + Tools []RuntimeToolRow `json:"tools"` +} + type runtimeServerApplyRequest struct { Name string `json:"name"` Namespace string `json:"namespace,omitempty"` @@ -696,6 +730,32 @@ func (c *PlatformClient) ListRuntimeServers(ctx context.Context, namespace strin return out.Servers, nil } +func (c *PlatformClient) ListRuntimeTools(ctx context.Context, filters map[string]string) ([]RuntimeToolRow, error) { + v := url.Values{} + for key, value := range filters { + if strings.TrimSpace(value) != "" { + v.Set(key, strings.TrimSpace(value)) + } + } + resp, err := c.do(ctx, http.MethodGet, "/runtime/tools", v.Encode(), nil) + if err != nil { + return nil, err + } + defer resp.Body.Close() + b, err := readBody(resp.Body) + if err != nil { + return nil, err + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, httpAPIError(resp.StatusCode, b) + } + var out runtimeToolsResponse + if err := json.Unmarshal(b, &out); err != nil { + return nil, err + } + return out.Tools, nil +} + func (c *PlatformClient) ApplyRuntimeServer(ctx context.Context, name, namespace string, spec mcpv1alpha1.MCPServerSpec) (ServerListItem, error) { return c.ApplyRuntimeServerWithScope(ctx, name, namespace, "", spec) } diff --git a/internal/cli/root/commands.go b/internal/cli/root/commands.go index b1224cc3..2c269422 100644 --- a/internal/cli/root/commands.go +++ b/internal/cli/root/commands.go @@ -9,6 +9,7 @@ import ( "mcp-runtime/internal/cli/admin" "mcp-runtime/internal/cli/auth" "mcp-runtime/internal/cli/bootstrap" + "mcp-runtime/internal/cli/catalog" "mcp-runtime/internal/cli/cluster" "mcp-runtime/internal/cli/core" "mcp-runtime/internal/cli/registry" @@ -25,6 +26,7 @@ func AddCommands(root *cobra.Command, logger *zap.Logger) { clusterMgr := cluster.DefaultClusterManager(logger) root.AddCommand(cluster.NewWithManager(clusterMgr)) + root.AddCommand(catalog.New(runtime)) root.AddCommand(registry.New(runtime)) root.AddCommand(server.New(runtime)) root.AddCommand(access.New(runtime)) diff --git a/internal/cli/root/commands_test.go b/internal/cli/root/commands_test.go index 8ad06877..52ca1ddd 100644 --- a/internal/cli/root/commands_test.go +++ b/internal/cli/root/commands_test.go @@ -14,6 +14,7 @@ func TestAddCommandsRegistersTopLevelCommands(t *testing.T) { want := []string{ "admin", + "catalog", "cluster", "registry", "server", diff --git a/internal/cli/server/manager.go b/internal/cli/server/manager.go index 70c57a91..18b29ad1 100644 --- a/internal/cli/server/manager.go +++ b/internal/cli/server/manager.go @@ -9,6 +9,7 @@ import ( "fmt" "os" "path/filepath" + "sort" "strconv" "strings" "text/tabwriter" @@ -60,7 +61,7 @@ func (m *ServerManager) requireKubectlForMutation() error { return nil } -func (m *ServerManager) InitServer(name, metadataDir, image, imageTag, scope, policyMode, defaultDecision string, sessionRequired bool, port int32, tools, toolSpecs []string, force bool) error { +func (m *ServerManager) InitServer(name, metadataDir, image, imageTag, scope, policyMode, defaultDecision string, sessionRequired bool, port int32, tools, toolSpecs []string, toolRisk string, force bool) error { name, err := validateManifestValue("name", name) if err != nil { return err @@ -152,7 +153,7 @@ func (m *ServerManager) InitServer(name, metadataDir, image, imageTag, scope, po Enabled: true, }, } - toolMetadata, err := initToolMetadata(tools, toolSpecs) + toolMetadata, err := initToolMetadata(tools, toolSpecs, toolRisk) if err != nil { return err } @@ -195,7 +196,11 @@ func (m *ServerManager) InitServer(name, metadataDir, image, imageTag, scope, po return nil } -func initToolMetadata(tools, toolSpecs []string) ([]metadata.ToolConfig, error) { +func initToolMetadata(tools, toolSpecs []string, toolRisk string) ([]metadata.ToolConfig, error) { + risk, err := normalizeMetadataRisk(toolRisk) + if err != nil { + return nil, err + } seen := map[string]struct{}{} out := make([]metadata.ToolConfig, 0, len(tools)) for _, tool := range tools { @@ -212,6 +217,7 @@ func initToolMetadata(tools, toolSpecs []string) ([]metadata.ToolConfig, error) Description: fmt.Sprintf("%s tool", tool), RequiredTrust: metadata.TrustLevelLow, SideEffect: metadata.ToolSideEffectRead, + RiskLevel: risk, }) } for _, spec := range toolSpecs { @@ -248,11 +254,25 @@ func initToolMetadata(tools, toolSpecs []string) ([]metadata.ToolConfig, error) Description: fmt.Sprintf("%s tool", name), RequiredTrust: trust, SideEffect: sideEffect, + RiskLevel: risk, }) } return out, nil } +func normalizeMetadataRisk(raw string) (metadata.ToolRiskLevel, error) { + raw = strings.ToLower(strings.TrimSpace(raw)) + if raw == "" { + return "", nil + } + switch metadata.ToolRiskLevel(raw) { + case metadata.ToolRiskLevelLow, metadata.ToolRiskLevelMedium, metadata.ToolRiskLevelHigh: + return metadata.ToolRiskLevel(raw), nil + default: + return "", core.NewWithSentinel(nil, fmt.Sprintf("invalid tool risk %q; must be low, medium, or high", raw)) + } +} + // Logger exposes the manager logger to foldered command packages. func (m *ServerManager) Logger() *zap.Logger { return m.logger @@ -363,6 +383,123 @@ func (m *ServerManager) GetServer(name, namespace string) error { return nil } +// ConnectConfig prints client connection config for a platform-visible MCP server. +func (m *ServerManager) ConnectConfig(name, namespace, clientName, output string) error { + name = strings.TrimSpace(name) + namespace = strings.TrimSpace(namespace) + if name == "" { + return core.NewWithSentinel(nil, "server name is required") + } + if m.useKube { + return core.NewWithSentinel(nil, "connect-config uses the platform API; omit --use-kube and run mcp-runtime auth login first") + } + plat, err := platformapi.NewPlatformClient() + if err != nil { + return err + } + items, err := plat.ListRuntimeServers(context.Background(), namespace) + if err != nil { + return err + } + for _, item := range items { + if item.Name != name { + continue + } + if namespace != "" && item.Namespace != namespace { + continue + } + config, err := BuildConnectConfig(item, clientName) + if err != nil { + return err + } + return printConnectConfig(config, output) + } + if namespace != "" { + return core.NewWithSentinel(nil, fmt.Sprintf("server %q not found in namespace %q", name, namespace)) + } + return core.NewWithSentinel(nil, fmt.Sprintf("server %q not found", name)) +} + +func BuildConnectConfig(server platformapi.ServerListItem, clientName string) (map[string]any, error) { + clientName = strings.ToLower(strings.TrimSpace(clientName)) + if clientName == "" { + clientName = "json" + } + serverName := strings.TrimSpace(server.Name) + if serverName == "" { + serverName = "mcp-server" + } + url := strings.TrimSpace(server.Endpoint) + if access := server.AccessJSON; len(access) > 0 { + if servers, ok := access["mcpServers"].(map[string]any); ok && len(servers) > 0 { + keys := make([]string, 0, len(servers)) + for key := range servers { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + serverName = key + entry, ok := servers[key].(map[string]any) + if !ok { + continue + } + value, ok := entry["url"].(string) + if !ok || strings.TrimSpace(value) == "" { + continue + } + url = strings.TrimSpace(value) + break + } + } + } + if url == "" { + return nil, core.NewWithSentinel(nil, fmt.Sprintf("server %q has no connect URL", serverName)) + } + switch clientName { + case "claude", "cursor", "json", "raw": + return map[string]any{ + "mcpServers": map[string]any{ + serverName: map[string]any{ + "type": "http", + "url": url, + }, + }, + }, nil + case "vscode", "vs-code": + return map[string]any{ + "servers": map[string]any{ + serverName: map[string]any{ + "type": "http", + "url": url, + }, + }, + }, nil + default: + return nil, core.NewWithSentinel(nil, "client must be claude, cursor, vscode, or json") + } +} + +func printConnectConfig(config map[string]any, output string) error { + switch strings.ToLower(strings.TrimSpace(output)) { + case "", "text", "json": + data, err := json.MarshalIndent(config, "", " ") + if err != nil { + return err + } + _, err = os.Stdout.Write(append(data, '\n')) + return err + case "yaml": + data, err := yaml.Marshal(config) + if err != nil { + return err + } + _, err = os.Stdout.Write(data) + return err + default: + return core.NewWithSentinel(nil, "output must be text, json, or yaml") + } +} + // CreateServer creates a new MCP server with the given parameters. func (m *ServerManager) CreateServer(name, namespace, image, imageTag string) error { if err := m.requireKubectlForMutation(); err != nil { @@ -817,6 +954,7 @@ func mergeDeployMetadata(spec *mcpv1alpha1.MCPServerSpec, src *metadata.ServerMe Description: tool.Description, RequiredTrust: mcpv1alpha1.TrustLevel(tool.RequiredTrust), SideEffect: mcpv1alpha1.ToolSideEffect(tool.SideEffect), + RiskLevel: mcpv1alpha1.ToolRiskLevel(tool.RiskLevel), Labels: copyStringMap(tool.Labels), }) } diff --git a/internal/cli/server/manager_test.go b/internal/cli/server/manager_test.go index f4ecea52..f5f068df 100644 --- a/internal/cli/server/manager_test.go +++ b/internal/cli/server/manager_test.go @@ -32,7 +32,7 @@ func TestInitServerCreatesMetadata(t *testing.T) { dir := filepath.Join(t.TempDir(), ".mcp") mgr := NewServerManager(core.NewTestKubectlClient(&core.MockExecutor{}), zap.NewNop()) - if err := mgr.InitServer("payments", dir, "", "v1", "tenant", "allow-list", "deny", true, 8088, []string{"add", "add", "echo"}, []string{"refund_invoice:high:destructive"}, false); err != nil { + if err := mgr.InitServer("payments", dir, "", "v1", "tenant", "allow-list", "deny", true, 8088, []string{"add", "add", "echo"}, []string{"refund_invoice:high:destructive"}, "", false); err != nil { t.Fatalf("InitServer() error = %v", err) } @@ -100,17 +100,35 @@ func TestInitServerCreatesMetadata(t *testing.T) { } } +func TestInitServerSetsToolRisk(t *testing.T) { + dir := filepath.Join(t.TempDir(), ".mcp") + mgr := NewServerManager(core.NewTestKubectlClient(&core.MockExecutor{}), zap.NewNop()) + + if err := mgr.InitServer("payments", dir, "", "latest", "tenant", "allow-list", "deny", true, 8088, []string{"add"}, []string{"refund_invoice:high:destructive"}, "high", false); err != nil { + t.Fatalf("InitServer() error = %v", err) + } + registry, err := metadata.LoadFromFile(filepath.Join(dir, "servers.yaml")) + if err != nil { + t.Fatalf("LoadFromFile() error = %v", err) + } + for _, tool := range registry.Servers[0].Tools { + if tool.RiskLevel != metadata.ToolRiskLevelHigh { + t.Fatalf("tool risk = %#v, want high for %#v", tool.RiskLevel, tool) + } + } +} + func TestInitServerAppendsAndRejectsDuplicate(t *testing.T) { dir := filepath.Join(t.TempDir(), ".mcp") mgr := NewServerManager(core.NewTestKubectlClient(&core.MockExecutor{}), zap.NewNop()) - if err := mgr.InitServer("one", dir, "", "latest", "tenant", "allow-list", "deny", true, 8088, nil, nil, false); err != nil { + if err := mgr.InitServer("one", dir, "", "latest", "tenant", "allow-list", "deny", true, 8088, nil, nil, "", false); err != nil { t.Fatalf("InitServer(one) error = %v", err) } - if err := mgr.InitServer("two", dir, "custom/two", "v2", "org", "allow-list", "deny", true, 9000, []string{"search"}, nil, false); err != nil { + if err := mgr.InitServer("two", dir, "custom/two", "v2", "org", "allow-list", "deny", true, 9000, []string{"search"}, nil, "", false); err != nil { t.Fatalf("InitServer(two) error = %v", err) } - err := mgr.InitServer("one", dir, "", "latest", "tenant", "allow-list", "deny", true, 8088, nil, nil, false) + err := mgr.InitServer("one", dir, "", "latest", "tenant", "allow-list", "deny", true, 8088, nil, nil, "", false) if err == nil || !strings.Contains(err.Error(), "--force") { t.Fatalf("duplicate error = %v, want --force guidance", err) } @@ -128,7 +146,7 @@ func TestInitServerUsesGovernanceFlags(t *testing.T) { dir := filepath.Join(t.TempDir(), ".mcp") mgr := NewServerManager(core.NewTestKubectlClient(&core.MockExecutor{}), zap.NewNop()) - if err := mgr.InitServer("payments", dir, "", "latest", "tenant", "observe", "allow", false, 8088, nil, nil, false); err != nil { + if err := mgr.InitServer("payments", dir, "", "latest", "tenant", "observe", "allow", false, 8088, nil, nil, "", false); err != nil { t.Fatalf("InitServer() error = %v", err) } @@ -149,11 +167,11 @@ func TestInitServerRejectsInvalidGovernanceFlags(t *testing.T) { dir := filepath.Join(t.TempDir(), ".mcp") mgr := NewServerManager(core.NewTestKubectlClient(&core.MockExecutor{}), zap.NewNop()) - err := mgr.InitServer("payments", dir, "", "latest", "tenant", "audit", "deny", true, 8088, nil, nil, false) + err := mgr.InitServer("payments", dir, "", "latest", "tenant", "audit", "deny", true, 8088, nil, nil, "", false) if err == nil || !strings.Contains(err.Error(), "invalid policy mode") { t.Fatalf("policy mode error = %v, want invalid policy mode", err) } - err = mgr.InitServer("payments", dir, "", "latest", "tenant", "allow-list", "maybe", true, 8088, nil, nil, false) + err = mgr.InitServer("payments", dir, "", "latest", "tenant", "allow-list", "maybe", true, 8088, nil, nil, "", false) if err == nil || !strings.Contains(err.Error(), "invalid default decision") { t.Fatalf("default decision error = %v, want invalid default decision", err) } @@ -163,10 +181,10 @@ func TestInitServerForceReplacesExisting(t *testing.T) { dir := filepath.Join(t.TempDir(), ".mcp") mgr := NewServerManager(core.NewTestKubectlClient(&core.MockExecutor{}), zap.NewNop()) - if err := mgr.InitServer("payments", dir, "payments", "v1", "tenant", "allow-list", "deny", true, 8088, []string{"add"}, nil, false); err != nil { + if err := mgr.InitServer("payments", dir, "payments", "v1", "tenant", "allow-list", "deny", true, 8088, []string{"add"}, nil, "", false); err != nil { t.Fatalf("InitServer() error = %v", err) } - if err := mgr.InitServer("payments", dir, "payments-v2", "v2", "tenant", "allow-list", "deny", true, 9090, []string{"echo"}, nil, true); err != nil { + if err := mgr.InitServer("payments", dir, "payments-v2", "v2", "tenant", "allow-list", "deny", true, 9090, []string{"echo"}, nil, "", true); err != nil { t.Fatalf("InitServer(force) error = %v", err) } registry, err := metadata.LoadFromFile(filepath.Join(dir, "servers.yaml")) @@ -186,7 +204,7 @@ func TestInitServerRejectsDuplicateToolSpec(t *testing.T) { dir := filepath.Join(t.TempDir(), ".mcp") mgr := NewServerManager(core.NewTestKubectlClient(&core.MockExecutor{}), zap.NewNop()) - err := mgr.InitServer("payments", dir, "", "latest", "tenant", "allow-list", "deny", true, 8088, []string{"add"}, []string{"add:high:write"}, false) + err := mgr.InitServer("payments", dir, "", "latest", "tenant", "allow-list", "deny", true, 8088, []string{"add"}, []string{"add:high:write"}, "", false) if err == nil || !strings.Contains(err.Error(), "duplicate tool metadata") { t.Fatalf("error = %v, want duplicate tool metadata", err) } @@ -196,7 +214,7 @@ func TestInitServerRejectsInvalidToolSpec(t *testing.T) { dir := filepath.Join(t.TempDir(), ".mcp") mgr := NewServerManager(core.NewTestKubectlClient(&core.MockExecutor{}), zap.NewNop()) - err := mgr.InitServer("payments", dir, "", "latest", "tenant", "allow-list", "deny", true, 8088, nil, []string{"refund_invoice:full:danger"}, false) + err := mgr.InitServer("payments", dir, "", "latest", "tenant", "allow-list", "deny", true, 8088, nil, []string{"refund_invoice:full:danger"}, "", false) if err == nil || !strings.Contains(err.Error(), "trust must be low, medium, or high") { t.Fatalf("error = %v, want trust validation", err) } diff --git a/internal/cli/server/server.go b/internal/cli/server/server.go index 051d5eb8..3e83e02c 100644 --- a/internal/cli/server/server.go +++ b/internal/cli/server/server.go @@ -50,6 +50,7 @@ For pushing images, use 'registry push'.`, var initPort int32 var initTools []string var initToolSpecs []string + var initToolRisk string var initForce bool var initFromServer string initCmd := &cobra.Command{ @@ -83,7 +84,7 @@ For pushing images, use 'registry push'.`, initTools = merged } } - return mgr.InitServer(args[0], initMetadataDir, initImage, initTag, initScope, initPolicyMode, initDefaultDecision, initSessionRequired, initPort, initTools, initToolSpecs, initForce) + return mgr.InitServer(args[0], initMetadataDir, initImage, initTag, initScope, initPolicyMode, initDefaultDecision, initSessionRequired, initPort, initTools, initToolSpecs, initToolRisk, initForce) }, } initCmd.Flags().StringVar(&initMetadataDir, "metadata-dir", ".mcp", "Directory where servers.yaml will be written") @@ -96,6 +97,7 @@ For pushing images, use 'registry push'.`, initCmd.Flags().Int32Var(&initPort, "port", defaultDeployPort(), "Container port") initCmd.Flags().StringArrayVar(&initTools, "tool", nil, "Tool name to add with read side-effect metadata; repeat for multiple tools") initCmd.Flags().StringArrayVar(&initToolSpecs, "tool-spec", nil, "Tool metadata as name:low|medium|high:read|write|destructive; repeat for mixed trust or side effects") + initCmd.Flags().StringVar(&initToolRisk, "tool-risk", "", "Informational risk level for seeded tools: low, medium, or high") initCmd.Flags().BoolVar(&initForce, "force", false, "Replace an existing metadata entry with the same server name") initCmd.Flags().StringVar(&initFromServer, "from-server", "", "Discover tools by calling tools/list on a running MCP server at this URL (e.g. http://localhost:8088); discovered names are merged with --tool flags") @@ -276,6 +278,21 @@ For pushing images, use 'registry push'.`, } statusCmd.Flags().StringVar(&statusNamespace, "namespace", core.NamespaceMCPServers, "Namespace to inspect") + var connectNamespace string + var connectClient string + var connectOutput string + connectCmd := &cobra.Command{ + Use: "connect-config [name]", + Short: "Print client connection config for an MCP server", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return mgr.ConnectConfig(args[0], connectNamespace, connectClient, connectOutput) + }, + } + connectCmd.Flags().StringVar(&connectNamespace, "namespace", core.NamespaceMCPServers, "Namespace") + connectCmd.Flags().StringVar(&connectClient, "client", "json", "Client config: claude, cursor, vscode, or json") + connectCmd.Flags().StringVarP(&connectOutput, "output", "o", "text", "Output format: text, json, or yaml") + var policyNamespace string policyCmd := &cobra.Command{ Use: "policy", @@ -298,7 +315,7 @@ For pushing images, use 'registry push'.`, } buildCmd.AddCommand(newBuildImageCmd(mgr.Logger())) - cmd.AddCommand(initCmd, listCmd, getCmd, createCmd, applyCmd, deployCmd, generateCmd, exportCmd, patchCmd, deleteCmd, logsCmd, statusCmd, policyCmd, buildCmd, newValidateCmd()) + cmd.AddCommand(initCmd, listCmd, getCmd, createCmd, applyCmd, deployCmd, generateCmd, exportCmd, patchCmd, deleteCmd, logsCmd, statusCmd, connectCmd, policyCmd, buildCmd, newValidateCmd()) return cmd } diff --git a/internal/cli/server/validate.go b/internal/cli/server/validate.go index 9824e03f..669fb3b4 100644 --- a/internal/cli/server/validate.go +++ b/internal/cli/server/validate.go @@ -16,6 +16,7 @@ import ( "mcp-runtime/internal/cli/core" "mcp-runtime/pkg/metadata" + "mcp-runtime/pkg/policy" ) type validateIssue struct { @@ -163,6 +164,35 @@ func validateServer(srv metadata.ServerMetadata) []validateIssue { hint: "Valid values: low, medium, high", }) } + + switch tool.RiskLevel { + case metadata.ToolRiskLevelLow, metadata.ToolRiskLevelMedium, metadata.ToolRiskLevelHigh, "": + default: + issues = append(issues, validateIssue{ + fatal: true, + message: fmt.Sprintf("server %q tool %q: invalid riskLevel %q", srv.Name, name, tool.RiskLevel), + hint: "Valid values: low, medium, high", + }) + } + + if tool.RiskLevel != "" { + trust := string(tool.RequiredTrust) + if trust == "" { + trust = string(metadata.TrustLevelLow) + } + computed := policy.NormalizeRiskLevel("", trust, string(tool.SideEffect)) + declared := strings.ToLower(string(tool.RiskLevel)) + if computed != "" && riskRank(declared) < riskRank(computed) { + issues = append(issues, validateIssue{ + fatal: false, + message: fmt.Sprintf( + "server %q tool %q: declared riskLevel %q is lower than computed risk %q from requiredTrust and sideEffect", + srv.Name, name, declared, computed, + ), + hint: fmt.Sprintf("Raise riskLevel to at least %q or adjust requiredTrust/sideEffect.", computed), + }) + } + } } if len(srv.Tools) == 0 { @@ -516,6 +546,19 @@ func loadMetadataForValidate(dir, file string) (*metadata.RegistryFile, string, return cfg, path, nil } +func riskRank(level string) int { + switch strings.ToLower(strings.TrimSpace(level)) { + case "high": + return 3 + case "medium": + return 2 + case "low": + return 1 + default: + return 0 + } +} + func joinToolNames(tools []metadata.ToolConfig) string { names := make([]string, len(tools)) for i, t := range tools { diff --git a/internal/cli/setup/platform/plan_flow_test.go b/internal/cli/setup/platform/plan_flow_test.go index 939e5868..20a68173 100644 --- a/internal/cli/setup/platform/plan_flow_test.go +++ b/internal/cli/setup/platform/plan_flow_test.go @@ -773,8 +773,9 @@ func TestSetupPlatformWithDeps_ExternalRegistry(t *testing.T) { Manifest: "config/ingress/overlays/http", Force: false, }, - RegistryManifest: "config/registry", - TLSEnabled: true, + RegistryManifest: "config/registry", + TLSEnabled: true, + InstallCertManager: true, } if err := setupPlatformWithDeps(zap.NewNop(), plan, deps); err != nil { @@ -855,9 +856,10 @@ func TestSetupPlatformWithDeps_InternalRegistryTLS(t *testing.T) { Manifest: "config/ingress/overlays/prod", Force: false, }, - RegistryManifest: "config/registry/overlays/tls", - TLSEnabled: true, - TestMode: true, + RegistryManifest: "config/registry/overlays/tls", + TLSEnabled: true, + TestMode: true, + InstallCertManager: true, } if err := setupPlatformWithDeps(zap.NewNop(), plan, deps); err != nil { @@ -951,8 +953,9 @@ func TestSetupPlatformWithDeps_ExternalRegistryTLS(t *testing.T) { Manifest: "config/ingress/overlays/prod", Force: false, }, - RegistryManifest: "config/registry/overlays/tls", - TLSEnabled: true, + RegistryManifest: "config/registry/overlays/tls", + TLSEnabled: true, + InstallCertManager: true, } if err := setupPlatformWithDeps(zap.NewNop(), plan, deps); err != nil { @@ -1033,9 +1036,10 @@ func TestSetupPlatformWithDeps_DiagnosticsOnRegistryWaitFailure(t *testing.T) { Manifest: "config/ingress/overlays/http", Force: false, }, - RegistryManifest: "config/registry", - TLSEnabled: true, - TestMode: true, + RegistryManifest: "config/registry", + TLSEnabled: true, + TestMode: true, + InstallCertManager: true, } if err := setupPlatformWithDeps(zap.NewNop(), plan, deps); err == nil { @@ -1099,8 +1103,9 @@ func TestSetupPlatformWithDeps_DiagnosticsOnOperatorWaitFailure(t *testing.T) { Manifest: "config/ingress/overlays/http", Force: false, }, - RegistryManifest: "config/registry/overlays/tls", - TLSEnabled: true, + RegistryManifest: "config/registry/overlays/tls", + TLSEnabled: true, + InstallCertManager: true, } if err := setupPlatformWithDeps(zap.NewNop(), plan, deps); err == nil { @@ -1166,8 +1171,9 @@ func TestSetupPlatformWithDeps_CRDCheckFailure(t *testing.T) { Manifest: "config/ingress/overlays/http", Force: false, }, - RegistryManifest: "config/registry/overlays/tls", - TLSEnabled: true, + RegistryManifest: "config/registry/overlays/tls", + TLSEnabled: true, + InstallCertManager: true, } if err := setupPlatformWithDeps(zap.NewNop(), plan, deps); err == nil { diff --git a/internal/operator/policy.go b/internal/operator/policy.go index 85a373da..d2a06895 100644 --- a/internal/operator/policy.go +++ b/internal/operator/policy.go @@ -110,6 +110,7 @@ func (r *MCPServerReconciler) renderGatewayPolicy(ctx context.Context, mcpServer Description: tool.Description, RequiredTrust: string(tool.RequiredTrust), SideEffect: string(tool.SideEffect), + RiskLevel: string(tool.RiskLevel), } if len(tool.Labels) > 0 { rendered.Labels = make(map[string]string, len(tool.Labels)) diff --git a/pkg/metadata/crd_generator.go b/pkg/metadata/crd_generator.go index c8043229..15b2a0fc 100644 --- a/pkg/metadata/crd_generator.go +++ b/pkg/metadata/crd_generator.go @@ -82,6 +82,7 @@ func GenerateCRD(server *ServerMetadata, outputPath string) error { Description: tool.Description, RequiredTrust: mcpv1alpha1.TrustLevel(tool.RequiredTrust), SideEffect: mcpv1alpha1.ToolSideEffect(tool.SideEffect), + RiskLevel: mcpv1alpha1.ToolRiskLevel(tool.RiskLevel), } if len(tool.Labels) > 0 { mcpTool.Labels = make(map[string]string, len(tool.Labels)) diff --git a/pkg/metadata/schema.go b/pkg/metadata/schema.go index 2715d9cd..68d938bb 100644 --- a/pkg/metadata/schema.go +++ b/pkg/metadata/schema.go @@ -43,6 +43,14 @@ const ( ToolSideEffectDestructive ToolSideEffect = "destructive" ) +type ToolRiskLevel string + +const ( + ToolRiskLevelLow ToolRiskLevel = "low" + ToolRiskLevelMedium ToolRiskLevel = "medium" + ToolRiskLevelHigh ToolRiskLevel = "high" +) + // +kubebuilder:validation:Enum=RollingUpdate;Recreate;Canary type RolloutStrategy string @@ -169,6 +177,7 @@ type ToolConfig struct { Description string `yaml:"description,omitempty" json:"description,omitempty"` RequiredTrust TrustLevel `yaml:"requiredTrust,omitempty" json:"requiredTrust,omitempty"` SideEffect ToolSideEffect `yaml:"sideEffect" json:"sideEffect"` + RiskLevel ToolRiskLevel `yaml:"riskLevel,omitempty" json:"riskLevel,omitempty"` Labels map[string]string `yaml:"labels,omitempty" json:"labels,omitempty"` } diff --git a/pkg/policy/evaluator.go b/pkg/policy/evaluator.go index 559e68f3..30be2e0f 100644 --- a/pkg/policy/evaluator.go +++ b/pkg/policy/evaluator.go @@ -29,6 +29,7 @@ type Decision struct { PolicyVersion string RequiredTrust string RequiredSideEffect string + RiskLevel string AdminTrust string ConsentedTrust string EffectiveTrust string @@ -54,6 +55,7 @@ func Authorize(policy *Document, request Request, now time.Time) Decision { if !IsToolCallMethod(request.RPCMethod) { return decision } + _, _, decision.RiskLevel = resolveToolMetadata(policyTools(policy), request.ToolName) if policyModeObserve(policy) { return decision } @@ -86,7 +88,7 @@ func Authorize(policy *Document, request Request, now time.Time) Decision { sessionFound = false } - requiredTrust, requiredSideEffect := resolveToolMetadata(tools, request.ToolName) + requiredTrust, requiredSideEffect, riskLevel := resolveToolMetadata(tools, request.ToolName) matchingGrants := matchingGrants(grants, identity) if len(matchingGrants) == 0 { return decideByDefault(policy, "no_matching_grant") @@ -105,6 +107,7 @@ func Authorize(policy *Document, request Request, now time.Time) Decision { Reason: "tool_side_effect_unknown", PolicyVersion: grant.policyVersion, RequiredTrust: grant.requiredTrust, + RiskLevel: riskLevel, } } if !grant.sideEffectAllowed { @@ -114,6 +117,7 @@ func Authorize(policy *Document, request Request, now time.Time) Decision { PolicyVersion: grant.policyVersion, RequiredTrust: grant.requiredTrust, RequiredSideEffect: requiredSideEffect, + RiskLevel: riskLevel, } } if grant.adminTrustRank == 0 { @@ -134,6 +138,7 @@ func Authorize(policy *Document, request Request, now time.Time) Decision { PolicyVersion: grant.policyVersion, RequiredTrust: grant.requiredTrust, RequiredSideEffect: requiredSideEffect, + RiskLevel: riskLevel, AdminTrust: RankToTrust(grant.adminTrustRank), ConsentedTrust: consentedTrust, EffectiveTrust: RankToTrust(effectiveRank), @@ -147,12 +152,20 @@ func Authorize(policy *Document, request Request, now time.Time) Decision { PolicyVersion: grant.policyVersion, RequiredTrust: grant.requiredTrust, RequiredSideEffect: requiredSideEffect, + RiskLevel: riskLevel, AdminTrust: RankToTrust(grant.adminTrustRank), ConsentedTrust: consentedTrust, EffectiveTrust: RankToTrust(effectiveRank), } } +func policyTools(policy *Document) []Tool { + if policy == nil { + return nil + } + return policy.Tools +} + type grantSelection struct { toolAllowed bool sideEffectAllowed bool @@ -269,17 +282,36 @@ func subjectMatchesTeam(humanID HumanID, agentID AgentID, teamID TeamID, identit return humanID != "" || agentID != "" || teamID != "" } -func resolveToolMetadata(tools []Tool, toolName ToolName) (string, string) { +func resolveToolMetadata(tools []Tool, toolName ToolName) (string, string, string) { requiredTrust := TrustLevelLow for _, tool := range tools { if tool.Name == toolName { if tool.RequiredTrust != "" { requiredTrust = NormalizeTrust(tool.RequiredTrust) } - return requiredTrust, NormalizeSideEffect(tool.SideEffect) + return requiredTrust, NormalizeSideEffect(tool.SideEffect), NormalizeRiskLevel(tool.RiskLevel, requiredTrust, tool.SideEffect) } } - return requiredTrust, "" + return requiredTrust, "", "" +} + +func NormalizeRiskLevel(risk, trust, sideEffect string) string { + switch strings.ToLower(strings.TrimSpace(risk)) { + case "low", "medium", "high": + return strings.ToLower(strings.TrimSpace(risk)) + } + trust = NormalizeTrust(trust) + sideEffect = NormalizeSideEffect(sideEffect) + switch { + case sideEffect == "destructive" || trust == TrustLevelHigh: + return "high" + case sideEffect == "write" || trust == TrustLevelMedium: + return "medium" + case sideEffect == "read" && trust == TrustLevelLow: + return "low" + default: + return "" + } } func policyVersionOrDefault(policy *Document, def string) string { diff --git a/pkg/policy/types.go b/pkg/policy/types.go index eb96b4f5..8a07d546 100644 --- a/pkg/policy/types.go +++ b/pkg/policy/types.go @@ -79,6 +79,7 @@ type Tool struct { Description string `json:"description,omitempty"` RequiredTrust string `json:"required_trust,omitempty"` SideEffect string `json:"side_effect,omitempty"` + RiskLevel string `json:"risk_level,omitempty"` Labels map[string]string `json:"labels,omitempty"` } @@ -114,3 +115,16 @@ type ToolAccess struct { Decision string `json:"decision,omitempty"` RequiredTrust string `json:"required_trust,omitempty"` } + +func ToolRiskLevel(policy *Document, toolName string) string { + if policy == nil || toolName == "" { + return "" + } + for _, tool := range policy.Tools { + if string(tool.Name) != toolName { + continue + } + return NormalizeRiskLevel(tool.RiskLevel, tool.RequiredTrust, tool.SideEffect) + } + return "" +} diff --git a/services/api/internal/runtimeapi/runtime_test.go b/services/api/internal/runtimeapi/runtime_test.go index 2ec67543..03b74726 100644 --- a/services/api/internal/runtimeapi/runtime_test.go +++ b/services/api/internal/runtimeapi/runtime_test.go @@ -21,6 +21,7 @@ import ( ktesting "k8s.io/client-go/testing" mcpv1alpha1 "mcp-runtime/api/v1alpha1" sentinelaccess "mcp-runtime/pkg/access" + "mcp-runtime/pkg/controlplane" "mcp-runtime/pkg/k8sclient" ) @@ -275,6 +276,121 @@ func TestRuntimeServersIncludesMCPServerInventory(t *testing.T) { } } +func TestRuntimeToolsCatalogScopesFiltersAndComputesRisk(t *testing.T) { + scheme := runtime.NewScheme() + if err := mcpv1alpha1.AddToScheme(scheme); err != nil { + t.Fatalf("AddToScheme: %v", err) + } + srv := &mcpv1alpha1.MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "payments", + Namespace: "mcp-team-acme", + CreationTimestamp: metav1.Now(), + }, + Spec: mcpv1alpha1.MCPServerSpec{ + TeamID: "acme", + Image: "payments:latest", + PublicPathPrefix: "payments", + Tools: []mcpv1alpha1.ToolConfig{ + { + Name: "refund_invoice", + Description: "Refund an invoice.", + RequiredTrust: mcpv1alpha1.TrustLevelHigh, + SideEffect: mcpv1alpha1.ToolSideEffectDestructive, + Labels: map[string]string{"domain": "billing"}, + }, + }, + }, + } + server := &RuntimeServer{ + k8sClients: &k8sclient.Clients{ + Dynamic: dynamicfake.NewSimpleDynamicClient(scheme, srv), + Clientset: kubernetesfake.NewSimpleClientset(), + }, + liveInventoryProbe: staticLiveInventoryProber{inventory: &liveInventory{ + Tools: []liveInventoryTool{ + {Name: "refund_invoice", Description: "Refund an invoice."}, + {Name: "lookup_invoice", Description: "Look up invoice status."}, + }, + }}, + } + waitForCachedInventory(t, server.liveInventory(), controlplane.ServerInfoFromMCPServer(*srv, controlplane.ServerDeploymentStatus{})) + + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, "/api/runtime/tools?namespace=mcp-team-acme&risk=high", nil) + request = request.WithContext(withPrincipal(request.Context(), principal{Role: roleAdmin, Subject: "admin-1"})) + server.HandleRuntimeTools(recorder, request) + if recorder.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body.String()) + } + var payload struct { + Tools []runtimeToolRow `json:"tools"` + } + if err := json.NewDecoder(recorder.Body).Decode(&payload); err != nil { + t.Fatalf("decode response: %v", err) + } + if len(payload.Tools) != 1 { + t.Fatalf("tools = %#v, want one high-risk declared tool", payload.Tools) + } + got := payload.Tools[0] + if got.ToolName != "refund_invoice" || got.RiskLevel != "high" || got.DriftStatus != "declared" || !got.Declared || !got.Live { + t.Fatalf("tool row = %#v", got) + } + if got.ConnectConfig == nil { + t.Fatalf("connect_config missing: %#v", got) + } + + recorder = httptest.NewRecorder() + request = httptest.NewRequest(http.MethodGet, "/api/runtime/tools?namespace=mcp-team-acme&drift=ungoverned", nil) + request = request.WithContext(withPrincipal(request.Context(), principal{Role: roleAdmin, Subject: "admin-1"})) + server.HandleRuntimeTools(recorder, request) + if recorder.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body.String()) + } + payload.Tools = nil + if err := json.NewDecoder(recorder.Body).Decode(&payload); err != nil { + t.Fatalf("decode response: %v", err) + } + if len(payload.Tools) != 1 || payload.Tools[0].ToolName != "lookup_invoice" || payload.Tools[0].DriftStatus != "ungoverned" { + t.Fatalf("ungoverned tools = %#v", payload.Tools) + } + if payload.Tools[0].RequiredTrust != "" || payload.Tools[0].RiskLevel != "" { + t.Fatalf("ungoverned tool risk metadata = %#v, want empty trust and risk", payload.Tools[0]) + } +} + +func TestHandleRuntimeToolsReturnsForbiddenForUnreadableNamespace(t *testing.T) { + scheme := runtime.NewScheme() + if err := mcpv1alpha1.AddToScheme(scheme); err != nil { + t.Fatalf("AddToScheme: %v", err) + } + server := &RuntimeServer{ + k8sClients: &k8sclient.Clients{ + Dynamic: dynamicfake.NewSimpleDynamicClient(scheme), + Clientset: kubernetesfake.NewSimpleClientset(), + }, + } + + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, "/api/runtime/tools?namespace=other-team", nil) + request = request.WithContext(withPrincipal(request.Context(), principal{Role: roleUser, Subject: "user-1"})) + server.HandleRuntimeTools(recorder, request) + if recorder.Code != http.StatusForbidden { + t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body.String()) + } + if !strings.Contains(recorder.Body.String(), "forbidden namespace") { + t.Fatalf("body = %s, want forbidden namespace", recorder.Body.String()) + } +} + +type staticLiveInventoryProber struct { + inventory *liveInventory +} + +func (p staticLiveInventoryProber) probe(context.Context, controlplane.ServerInfo) (*liveInventory, error) { + return p.inventory, nil +} + func TestPublicMCPEndpointHonorsPlatformDomain(t *testing.T) { t.Setenv("MCP_MCP_INGRESS_HOST", "") t.Setenv("MCP_PLATFORM_DOMAIN", "example.com") diff --git a/services/api/internal/runtimeapi/servers.go b/services/api/internal/runtimeapi/servers.go index 18bc439a..96ca3b48 100644 --- a/services/api/internal/runtimeapi/servers.go +++ b/services/api/internal/runtimeapi/servers.go @@ -24,6 +24,8 @@ import ( runtimeaccess "mcp-sentinel-api/internal/runtimeapi/access" ) +var errForbiddenNamespace = errors.New("forbidden namespace") + const ( defaultAnalyticsCredentialSourceSecretName = "mcp-sentinel-secrets" defaultAnalyticsCredentialSourceKey = "INGEST_API_KEYS" @@ -68,47 +70,22 @@ func (s *RuntimeServer) handleRuntimeServerList(w http.ResponseWriter, r *http.R defer cancel() namespace := strings.TrimSpace(r.URL.Query().Get("namespace")) - namespaces := []string{namespace} - adminAllNamespaces := false - if p.Role != roleAdmin { - if namespace == "" { - namespaces = catalogNamespacesForPrincipal(p) - } else if !principalCanReadNamespace(p, namespace) { - writeAPIError(w, http.StatusForbidden, "forbidden namespace") - return - } - } else if namespace == "" { - adminAllNamespaces = true - namespaces = []string{metav1.NamespaceAll} + servers, err := s.visibleServers(ctx, control, p, namespace) + if errors.Is(err, errForbiddenNamespace) { + writeAPIError(w, http.StatusForbidden, "forbidden namespace") + return } - if !adminAllNamespaces { - namespaces = dedupeNonEmptyStrings(namespaces) + if err != nil { + writeAPIError(w, http.StatusInternalServerError, "failed to list servers") + return } - if len(namespaces) == 0 && !adminAllNamespaces { + if len(servers) == 0 { writeJSON(w, http.StatusOK, map[string]interface{}{ "servers": []serverInfo{}, "publish_policy": s.publishPolicyStatusForPrincipal(ctx, p), }) return } - - servers := make([]controlplane.ServerInfo, 0) - for _, namespace := range namespaces { - if p.Role != roleAdmin && !principalCanReadNamespace(p, namespace) { - writeAPIError(w, http.StatusForbidden, "forbidden namespace") - return - } - - result, err := control.ListServers(ctx, namespace) - if err != nil { - writeAPIError(w, http.StatusInternalServerError, "failed to list servers") - return - } - if result.CRDError != nil && !apierrors.IsNotFound(result.CRDError) { - log.Printf("runtime servers: list MCPServers failed in namespace %q: %v", namespace, result.CRDError) - } - servers = append(servers, result.Servers...) - } sort.SliceStable(servers, func(i, j int) bool { if servers[i].Namespace != servers[j].Namespace { return servers[i].Namespace < servers[j].Namespace diff --git a/services/api/internal/runtimeapi/tools.go b/services/api/internal/runtimeapi/tools.go new file mode 100644 index 00000000..efbe9741 --- /dev/null +++ b/services/api/internal/runtimeapi/tools.go @@ -0,0 +1,291 @@ +package runtimeapi + +import ( + "context" + "errors" + "log" + "net/http" + "sort" + "strings" + "time" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + mcpv1alpha1 "mcp-runtime/api/v1alpha1" + "mcp-runtime/pkg/controlplane" +) + +type runtimeToolRow struct { + ToolName string `json:"tool_name"` + Description string `json:"description,omitempty"` + ServerName string `json:"server_name"` + Namespace string `json:"namespace"` + TeamID string `json:"team_id,omitempty"` + EndpointURL string `json:"endpoint_url,omitempty"` + Declared bool `json:"declared"` + Live bool `json:"live"` + DriftStatus string `json:"drift_status"` + RequiredTrust string `json:"required_trust,omitempty"` + SideEffect string `json:"side_effect,omitempty"` + RiskLevel string `json:"risk_level,omitempty"` + Labels map[string]string `json:"labels,omitempty"` + ConnectConfig map[string]any `json:"connect_config,omitempty"` +} + +// HandleRuntimeTools returns a scoped, filterable catalog of tools across visible MCP servers. +func (s *RuntimeServer) HandleRuntimeTools(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + w.Header().Set("allow", "GET") + writeAPIError(w, http.StatusMethodNotAllowed, "method_not_allowed") + return + } + control := s.controlPlane() + if control == nil { + writeAPIError(w, http.StatusServiceUnavailable, "kubernetes not available") + return + } + p, ok := principalFromContext(r.Context()) + if !ok { + writeAPIError(w, http.StatusUnauthorized, "unauthorized") + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + servers, err := s.visibleServers(ctx, control, p, strings.TrimSpace(r.URL.Query().Get("namespace"))) + if err != nil { + if errors.Is(err, errForbiddenNamespace) { + writeAPIError(w, http.StatusForbidden, "forbidden namespace") + return + } + writeAPIError(w, http.StatusInternalServerError, "failed to list servers") + return + } + rows := s.toolRowsFromServers(ctx, servers, r) + rows = filterRuntimeToolRows(rows, r) + sort.SliceStable(rows, func(i, j int) bool { + if rows[i].Namespace != rows[j].Namespace { + return rows[i].Namespace < rows[j].Namespace + } + if rows[i].ServerName != rows[j].ServerName { + return rows[i].ServerName < rows[j].ServerName + } + return rows[i].ToolName < rows[j].ToolName + }) + writeJSON(w, http.StatusOK, map[string]any{"tools": rows}) +} + +func (s *RuntimeServer) visibleServers(ctx context.Context, control *controlplane.Manager, p principal, namespace string) ([]controlplane.ServerInfo, error) { + namespaces := []string{namespace} + adminAllNamespaces := false + if p.Role != roleAdmin { + if namespace == "" { + namespaces = catalogNamespacesForPrincipal(p) + } else if !principalCanReadNamespace(p, namespace) { + return nil, errForbiddenNamespace + } + } else if namespace == "" { + adminAllNamespaces = true + namespaces = []string{metav1.NamespaceAll} + } + if !adminAllNamespaces { + namespaces = dedupeNonEmptyStrings(namespaces) + } + if len(namespaces) == 0 && !adminAllNamespaces { + return []controlplane.ServerInfo{}, nil + } + + servers := make([]controlplane.ServerInfo, 0) + for _, namespace := range namespaces { + if p.Role != roleAdmin && !principalCanReadNamespace(p, namespace) { + return nil, errForbiddenNamespace + } + result, err := control.ListServers(ctx, namespace) + if err != nil { + return nil, err + } + if result.CRDError != nil && !apierrors.IsNotFound(result.CRDError) { + log.Printf("runtime servers: list MCPServers failed in namespace %q: %v", namespace, result.CRDError) + } + servers = append(servers, result.Servers...) + } + return servers, nil +} + +func (s *RuntimeServer) toolRowsFromServers(ctx context.Context, servers []controlplane.ServerInfo, r *http.Request) []runtimeToolRow { + rows := make([]runtimeToolRow, 0) + cache := s.liveInventory() + for _, server := range servers { + info := serverInfoWithAccessJSON(server, r) + connectEndpoint := publicMCPConnectEndpoint(server.Endpoint, r) + declared := map[string]mcpv1alpha1.ToolConfig{} + for _, tool := range server.Tools { + name := strings.TrimSpace(tool.Name) + if name == "" { + continue + } + declared[name] = tool + } + + live := map[string]liveInventoryTool{} + haveLiveInventory := false + if cache != nil { + if inventory, _ := cache.getOrStart(ctx, server); inventory != nil { + haveLiveInventory = true + for _, tool := range inventory.Tools { + name := strings.TrimSpace(tool.Name) + if name != "" { + live[name] = tool + } + } + } + } + + seen := map[string]struct{}{} + for name, tool := range declared { + _, isLive := live[name] + drift := "declared" + if haveLiveInventory && !isLive { + drift = "missing" + } + rows = append(rows, runtimeToolRow{ + ToolName: name, + Description: strings.TrimSpace(tool.Description), + ServerName: server.Name, + Namespace: server.Namespace, + TeamID: server.TeamID, + EndpointURL: connectEndpoint, + Declared: true, + Live: isLive, + DriftStatus: drift, + RequiredTrust: string(defaultRuntimeToolTrust(tool.RequiredTrust)), + SideEffect: string(tool.SideEffect), + RiskLevel: computeRuntimeToolRisk(tool.RiskLevel, tool.RequiredTrust, tool.SideEffect), + Labels: copyRuntimeLabels(tool.Labels), + ConnectConfig: info.AccessJSON, + }) + seen[name] = struct{}{} + } + for name, tool := range live { + if _, ok := seen[name]; ok { + continue + } + rows = append(rows, runtimeToolRow{ + ToolName: name, + Description: strings.TrimSpace(tool.Description), + ServerName: server.Name, + Namespace: server.Namespace, + TeamID: server.TeamID, + EndpointURL: connectEndpoint, + Declared: false, + Live: true, + DriftStatus: "ungoverned", + ConnectConfig: info.AccessJSON, + }) + } + } + return rows +} + +func filterRuntimeToolRows(rows []runtimeToolRow, r *http.Request) []runtimeToolRow { + query := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("query"))) + if query == "" { + query = strings.ToLower(strings.TrimSpace(r.URL.Query().Get("q"))) + } + namespace := strings.TrimSpace(r.URL.Query().Get("namespace")) + team := strings.TrimSpace(r.URL.Query().Get("team")) + server := strings.TrimSpace(r.URL.Query().Get("server")) + trust := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("trust"))) + sideEffect := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("side_effect"))) + if sideEffect == "" { + sideEffect = strings.ToLower(strings.TrimSpace(r.URL.Query().Get("sideEffect"))) + } + risk := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("risk"))) + drift := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("drift"))) + + out := rows[:0] + for _, row := range rows { + if namespace != "" && row.Namespace != namespace { + continue + } + if team != "" && row.TeamID != team { + continue + } + if server != "" && row.ServerName != server { + continue + } + if trust != "" && strings.ToLower(row.RequiredTrust) != trust { + continue + } + if sideEffect != "" && strings.ToLower(row.SideEffect) != sideEffect { + continue + } + if risk != "" && strings.ToLower(row.RiskLevel) != risk { + continue + } + if drift != "" && strings.ToLower(row.DriftStatus) != drift { + continue + } + if query != "" && !strings.Contains(toolRowSearchText(row), query) { + continue + } + out = append(out, row) + } + return out +} + +func toolRowSearchText(row runtimeToolRow) string { + parts := []string{ + row.ToolName, + row.Description, + row.ServerName, + row.Namespace, + row.TeamID, + row.EndpointURL, + row.RequiredTrust, + row.SideEffect, + row.RiskLevel, + row.DriftStatus, + } + for key, value := range row.Labels { + parts = append(parts, key, value) + } + return strings.ToLower(strings.Join(parts, " ")) +} + +func defaultRuntimeToolTrust(value mcpv1alpha1.TrustLevel) mcpv1alpha1.TrustLevel { + if value == "" { + return mcpv1alpha1.TrustLevelLow + } + return value +} + +func computeRuntimeToolRisk(risk mcpv1alpha1.ToolRiskLevel, trust mcpv1alpha1.TrustLevel, sideEffect mcpv1alpha1.ToolSideEffect) string { + switch risk { + case mcpv1alpha1.ToolRiskLevelLow, mcpv1alpha1.ToolRiskLevelMedium, mcpv1alpha1.ToolRiskLevelHigh: + return string(risk) + } + trust = defaultRuntimeToolTrust(trust) + switch { + case sideEffect == mcpv1alpha1.ToolSideEffectDestructive || trust == mcpv1alpha1.TrustLevelHigh: + return string(mcpv1alpha1.ToolRiskLevelHigh) + case sideEffect == mcpv1alpha1.ToolSideEffectWrite || trust == mcpv1alpha1.TrustLevelMedium: + return string(mcpv1alpha1.ToolRiskLevelMedium) + case sideEffect == mcpv1alpha1.ToolSideEffectRead && trust == mcpv1alpha1.TrustLevelLow: + return string(mcpv1alpha1.ToolRiskLevelLow) + default: + return "" + } +} + +func copyRuntimeLabels(labels map[string]string) map[string]string { + if len(labels) == 0 { + return nil + } + out := make(map[string]string, len(labels)) + for key, value := range labels { + out[key] = value + } + return out +} diff --git a/services/api/routes.go b/services/api/routes.go index 1de643ea..d941b788 100644 --- a/services/api/routes.go +++ b/services/api/routes.go @@ -69,6 +69,9 @@ func (s *apiServer) registerRuntimeRoutes(mux *http.ServeMux) { mux.Handle("/api/runtime/servers", s.authOrPublicCatalog(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { runtimehandlers.HandleRuntimeServers(runtimeServer, w, r) }))) + mux.Handle("/api/runtime/tools", s.authOrPublicCatalog(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + runtimehandlers.HandleRuntimeTools(runtimeServer, w, r) + }))) mux.Handle("/api/runtime/servers/", s.auth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { runtimehandlers.HandleRuntimeServerItem(runtimeServer, w, r) }))) diff --git a/services/api/runtime/handlers.go b/services/api/runtime/handlers.go index 4261913c..53766980 100644 --- a/services/api/runtime/handlers.go +++ b/services/api/runtime/handlers.go @@ -16,6 +16,11 @@ func HandleRuntimeServers(server *runtimeapi.RuntimeServer, w http.ResponseWrite server.HandleRuntimeServers(w, r) } +// HandleRuntimeTools routes runtime tool catalog requests through the runtime API server. +func HandleRuntimeTools(server *runtimeapi.RuntimeServer, w http.ResponseWriter, r *http.Request) { + server.HandleRuntimeTools(w, r) +} + // HandleRuntimeServerItem routes runtime server item requests through the runtime API server. func HandleRuntimeServerItem(server *runtimeapi.RuntimeServer, w http.ResponseWriter, r *http.Request) { server.HandleRuntimeServerItem(w, r) diff --git a/services/mcp-gateway/main_test.go b/services/mcp-gateway/main_test.go index 1ed6d2f7..183b6ef6 100644 --- a/services/mcp-gateway/main_test.go +++ b/services/mcp-gateway/main_test.go @@ -522,6 +522,41 @@ func TestAuditPayloadIncludesLatencyMetadata(t *testing.T) { } } +func TestAuditPayloadIncludesToolRiskLevel(t *testing.T) { + t.Parallel() + + proxy := &gatewayServer{ + serverName: "example-server", + serverNamespace: "mcp-servers", + defaultPolicyVersion: "test-policy", + } + req := httptest.NewRequest(http.MethodPost, "http://proxy.example.com/mcp", strings.NewReader(`{"jsonrpc":"2.0"}`)) + policy := &policypkg.Document{ + Tools: []policypkg.Tool{{ + Name: "refund_invoice", + RequiredTrust: "high", + SideEffect: "destructive", + }}, + } + + payload := proxy.auditPayload( + req, + "/mcp", + "tools/call", + "refund_invoice", + identityContext{HumanID: "human-1"}, + policy, + policypkg.Decision{Allowed: true, Reason: "allowed", PolicyVersion: "test-policy"}, + http.StatusOK, + 1, + 2, + ) + + if got := payload["risk_level"]; got != "high" { + t.Fatalf("risk_level = %#v, want high", got) + } +} + func TestStartPolicyCacheRequiresConfiguredPolicyFile(t *testing.T) { t.Parallel() diff --git a/services/mcp-gateway/proxy.go b/services/mcp-gateway/proxy.go index 0f444722..8ef8dd4c 100644 --- a/services/mcp-gateway/proxy.go +++ b/services/mcp-gateway/proxy.go @@ -262,6 +262,9 @@ func (s *gatewayServer) auditPayload( if decision.RequiredSideEffect != "" { payload["required_side_effect"] = decision.RequiredSideEffect } + if riskLevel := policypkg.FirstNonEmpty(decision.RiskLevel, policypkg.ToolRiskLevel(policy, toolName)); riskLevel != "" { + payload["risk_level"] = riskLevel + } if decision.AdminTrust != "" { payload["admin_trust"] = decision.AdminTrust } diff --git a/services/ui/main.go b/services/ui/main.go index 6ebbfc84..d84802cd 100644 --- a/services/ui/main.go +++ b/services/ui/main.go @@ -1118,8 +1118,9 @@ func isPublicCatalogAPIRequest(r *http.Request, apiBase string) bool { if r.Method != http.MethodGet { return false } - expected := strings.TrimRight(normalizePathPrefix(apiBase), "/") + "/runtime/servers" - return strings.TrimRight(r.URL.Path, "/") == expected + expectedBase := strings.TrimRight(normalizePathPrefix(apiBase), "/") + "/runtime/" + path := strings.TrimRight(r.URL.Path, "/") + return path == expectedBase+"servers" || path == expectedBase+"tools" } func secureCookie(r *http.Request) bool { diff --git a/services/ui/main_test.go b/services/ui/main_test.go index f4d219ed..d51dcc06 100644 --- a/services/ui/main_test.go +++ b/services/ui/main_test.go @@ -563,7 +563,9 @@ func TestStaticMarkupBoundsLongActivityTables(t *testing.T) { t.Fatalf("expected long dashboard tables to use scroll-table, got %d", got) } for _, want := range []string{ - `placeholder="Search servers"`, + `placeholder="Search servers and tools"`, + `id="tool-catalog-body"`, + `id="tool-risk-filter"`, `class="analytics-tabset" data-analytics-tabset`, `id="governance-decisions" data-admin-only="true"`, `data-analytics-tab-target="governance-decision-audit"`, @@ -816,8 +818,8 @@ func TestAPIProxyAllowsAnonymousPublicCatalog(t *testing.T) { if got := r.Header.Get("authorization"); got != "" { t.Fatalf("authorization forwarded upstream: %q", got) } - if got := r.URL.Path; got != "/api/runtime/servers" { - t.Fatalf("path = %q, want /api/runtime/servers", got) + if got := r.URL.Path; got != "/api/runtime/servers" && got != "/api/runtime/tools" { + t.Fatalf("path = %q, want /api/runtime/servers or /api/runtime/tools", got) } if got := r.URL.Query().Get("namespace"); got != "mcp-servers-public" { t.Fatalf("namespace = %q, want mcp-servers-public", got) @@ -843,6 +845,15 @@ func TestAPIProxyAllowsAnonymousPublicCatalog(t *testing.T) { t.Fatal("anonymous public catalog request did not reach upstream") } + upstreamCalled = false + proxy.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "http://localhost:18080/api/runtime/tools", nil)) + if recorder.Code != http.StatusOK { + t.Fatalf("anonymous public tools status = %d, want %d; body=%s", recorder.Code, http.StatusOK, recorder.Body.String()) + } + if !upstreamCalled { + t.Fatal("anonymous public tools request did not reach upstream") + } + forbidden := httptest.NewRecorder() proxy.ServeHTTP(forbidden, httptest.NewRequest(http.MethodGet, "http://localhost:18080/api/runtime/servers?namespace=user-private", nil)) if forbidden.Code != http.StatusForbidden { diff --git a/services/ui/static/app.js b/services/ui/static/app.js index f2c488e4..3d1480d0 100644 --- a/services/ui/static/app.js +++ b/services/ui/static/app.js @@ -13,6 +13,7 @@ let userAPIKeysCache = []; let teamsCache = []; let teamMembersCache = []; let serversCache = []; +let toolsCatalogCache = []; let publishPolicyCache = null; let selectedServerKey = ""; let selectedServerEventsCache = []; @@ -27,6 +28,7 @@ let operationsDeploymentsCache = []; let userAPIKeyClearTimer = null; let serverSearchQuery = ""; let serverStatusFilter = "all"; +let toolRiskFilter = ""; let selectedOperationsServerKey = ""; let selectedUserAnalyticsServerKey = ""; let selectedTeamSlug = ""; @@ -1413,6 +1415,9 @@ async function loadServers() { serversCache = Array.isArray(data.servers) ? data.servers : []; publishPolicyCache = data.publish_policy || null; renderServers(); + loadToolCatalog() + .then(() => renderToolCatalog()) + .catch(() => renderToolCatalog("Error loading tools.")); scheduleServerLiveInventoryRefresh(); } catch (err) { if (isUnauthorizedError(err)) return; @@ -1423,6 +1428,19 @@ async function loadServers() { if (grid) { grid.innerHTML = '
| Tool | +Server | +Trust | +Side effect | +Risk | +Drift | +Connect | +
|---|---|---|---|---|---|---|
| Loading tools... | ||||||