diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 048ecf54..0209a863 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -53,7 +53,10 @@ jobs: - name: Run golangci-lint uses: golangci/golangci-lint-action@v9 with: - version: v2.13.0 + # Pin a release whose vendored golang.org/x/tools can decode the + # toolchain's export data. Older releases fail typecheck with + # "export data version N is greater than maximum supported version M". + version: v2.15.0 args: --timeout=30m --verbose --build-tags "netgo,timetzdata,exclude_graphdriver_btrfs,containers_image_openpgp" skip-cache: false - name: Lint Dockerfile diff --git a/AGENTS.md b/AGENTS.md index 433afb4d..6a8abca7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -312,7 +312,7 @@ Directory rules: ## CI Workflows (`.github/workflows/`) -- **lint.yml**: Go 1.27 + Bun web build + golangci-lint v2.13.0 + hadolint on `build/Dockerfile`. Triggers on `main`/`dev` PRs. +- **lint.yml**: Go 1.27 + Bun web build + golangci-lint v2.15.0 + hadolint on `build/Dockerfile`. Triggers on `main`/`dev` PRs. - **test.yml**: Go 1.27 + Bun web build; runs application tests on PostgreSQL and database tests on `sqlite3`, `turso`, `postgresql`, and `mysql`. Uses MySQL 8.0, Postgres 15, Redis 7, and rs3 services. Uploads coverage to Coveralls. - **govulncheck.yml**: runs the pinned `go tool govulncheck` (with the project build tags) to scan for known reachable vulnerabilities, writing the report to the job summary. Non-blocking (`continue-on-error`): findings are reported but do not fail the workflow. Triggers on `dev` push/PR. - **e2e.yml**, **image-build.yml**, **gh-pages.yml**: end-to-end, image build, docs deploy. diff --git a/README.md b/README.md index 85e06ce9..763e6700 100644 --- a/README.md +++ b/README.md @@ -49,12 +49,13 @@ The diagram below maps Sigma's main runtime layers, from client entry points to Sigma provides two integration paths for AI clients and external automation: -- Embedded MCP server: Sigma can expose registry management tools through the - existing server process. It is disabled by default and can be enabled through - the `mcp` configuration section. See [MCP Server](docs/mcp.md). +- `sigma-cli`: a single binary that operates a remote Sigma server over the REST + API. It covers namespaces, repositories, tags, artifacts, members, and users. + See [CLI & AI Agents](docs/cli.md). - External REST API skill: `tools/skill/sigma-api-operator` packages a reusable skill and `swagger.yaml` so CLI agents such as Claude Code or Copilot CLI can - discover Sigma APIs and operate resources through HTTP requests. + discover Sigma APIs and operate resources through HTTP requests, including the + areas the CLI does not wrap yet. ## Alternatives diff --git a/Readme_zh.md b/Readme_zh.md index 63e9d4d7..f2204df6 100644 --- a/Readme_zh.md +++ b/Readme_zh.md @@ -49,8 +49,8 @@ docker run --name sigma -p 3000:3000 --rm tosone/sigma:nightly-alpine Sigma 为 AI 客户端和外部自动化工具提供了两种集成方式: -- 内置 MCP server:Sigma 可以通过现有的 server 进程暴露镜像仓库管理工具。该能力默认关闭,可以通过 `mcp` 配置项启用。详细说明见 [MCP Server](docs/mcp.md)。 -- 外部 REST API skill:`tools/skill/sigma-api-operator` 打包了可复用的 skill 和 `swagger.yaml`,方便 Claude Code、Copilot CLI 等 CLI agent 发现 Sigma API,并通过 HTTP 请求操作资源。 +- `sigma-cli`:单个二进制,通过 REST API 操作远程 Sigma 服务端,覆盖命名空间、仓库、Tag、制品、成员和用户。详细说明见 [CLI 与 AI Agent](docs/cli.md)。 +- 外部 REST API skill:`tools/skill/sigma-api-operator` 打包了可复用的 skill 和 `swagger.yaml`,方便 Claude Code、Copilot CLI 等 CLI agent 发现 Sigma API,并通过 HTTP 请求操作资源,包括 CLI 尚未封装的接口。 ## Alternatives diff --git a/conf/config-compose.yaml b/conf/config-compose.yaml index 711e3cb8..d63e242b 100644 --- a/conf/config-compose.yaml +++ b/conf/config-compose.yaml @@ -177,14 +177,6 @@ auth: window: 5m delay: 1s -mcp: - enabled: false - path: /api/v1/mcp - transport: streamable_http - auditWrites: true - toolTimeout: 30s - maxRequestBody: 1048576 - audit: enabled: true cleanup: diff --git a/conf/config-dev.yaml b/conf/config-dev.yaml index c5cb85d9..90165f4a 100644 --- a/conf/config-dev.yaml +++ b/conf/config-dev.yaml @@ -192,14 +192,6 @@ auth: window: 5m delay: 1s -mcp: - enabled: false - path: /api/v1/mcp - transport: streamable_http - auditWrites: true - toolTimeout: 30s - maxRequestBody: 1048576 - audit: enabled: true recordListGet: false diff --git a/conf/config-full.yaml b/conf/config-full.yaml index a52a31c6..d4030685 100644 --- a/conf/config-full.yaml +++ b/conf/config-full.yaml @@ -201,14 +201,6 @@ auth: # fixed delay applied to every authentication attempt after maxFailures delay: 1s -mcp: - enabled: false - path: /api/v1/mcp - transport: streamable_http - auditWrites: true - toolTimeout: 30s - maxRequestBody: 1048576 - audit: # record completed authenticated business HTTP requests enabled: true diff --git a/conf/config.yaml b/conf/config.yaml index 653616a2..b0b2399d 100644 --- a/conf/config.yaml +++ b/conf/config.yaml @@ -221,15 +221,6 @@ auth: # inmemory | redis backend: inmemory -mcp: - # expose MCP management tools on the existing server port - enabled: false - path: /api/v1/mcp - transport: streamable_http - auditWrites: true - toolTimeout: 30s - maxRequestBody: 1048576 - audit: # record completed authenticated business HTTP requests enabled: true diff --git a/deploy/sigma/templates/configmap.yaml b/deploy/sigma/templates/configmap.yaml index 6f0a0c18..aa209cbc 100644 --- a/deploy/sigma/templates/configmap.yaml +++ b/deploy/sigma/templates/configmap.yaml @@ -127,13 +127,6 @@ data: hardDeleteAfterMonths: {{ .Values.config.audit.cleanup.hardDeleteAfterMonths }} interval: {{ .Values.config.audit.cleanup.interval }} batchSize: {{ .Values.config.audit.cleanup.batchSize }} - mcp: - enabled: {{ .Values.config.mcp.enabled }} - path: {{ .Values.config.mcp.path | quote }} - transport: {{ .Values.config.mcp.transport | quote }} - auditWrites: {{ .Values.config.mcp.auditWrites }} - toolTimeout: {{ .Values.config.mcp.toolTimeout }} - maxRequestBody: {{ .Values.config.mcp.maxRequestBody | int64 }} auth: anonymous: enabled: true diff --git a/deploy/sigma/values.yaml b/deploy/sigma/values.yaml index 83a71d75..2dd46cc7 100644 --- a/deploy/sigma/values.yaml +++ b/deploy/sigma/values.yaml @@ -214,13 +214,6 @@ config: hardDeleteAfterMonths: 6 interval: 168h batchSize: 1000 - mcp: - enabled: false - path: /api/v1/mcp - transport: streamable_http - auditWrites: true - toolTimeout: 30s - maxRequestBody: 1048576 auth: user: password: "" diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 54fd0a16..4640ef0d 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -6,7 +6,7 @@ const guideSidebar = [ items: [ { text: 'Quick Start', link: '/quickstart' }, { text: 'Configuration', link: '/configuration' }, - { text: 'MCP Server', link: '/mcp' }, + { text: 'CLI & AI Agents', link: '/cli' }, { text: 'Cosign', link: '/cosign' } ] }, @@ -26,7 +26,7 @@ const zhGuideSidebar = [ items: [ { text: '快速开始', link: '/zh/quickstart' }, { text: '配置', link: '/zh/configuration' }, - { text: 'MCP Server', link: '/zh/mcp' }, + { text: 'CLI 与 AI Agent', link: '/zh/cli' }, { text: 'Cosign', link: '/zh/cosign' } ] }, @@ -81,7 +81,7 @@ export default defineConfig({ nav: [ { text: 'Home', link: '/' }, { text: 'Docs', link: '/quickstart' }, - { text: 'MCP', link: '/mcp' } + { text: 'CLI', link: '/cli' } ], sidebar: guideSidebar, socialLinks: [ @@ -105,7 +105,7 @@ export default defineConfig({ nav: [ { text: '首页', link: '/zh/' }, { text: '文档', link: '/zh/quickstart' }, - { text: 'MCP', link: '/zh/mcp' } + { text: 'CLI', link: '/zh/cli' } ], sidebar: zhGuideSidebar, outline: { diff --git a/docs/cli.md b/docs/cli.md new file mode 100644 index 00000000..8548a1fe --- /dev/null +++ b/docs/cli.md @@ -0,0 +1,149 @@ +--- +title: CLI & AI Agents +--- + +# CLI & AI Agents + +Sigma exposes two ways to operate a remote server from a terminal or an AI +agent. Both talk to the same HTTP API, so anything you can do in the Sigma web +UI can be scripted. + +| Path | Best for | +| --- | --- | +| `sigma-cli` | Humans, CI pipelines, and shell-capable agents | +| `sigma-api-operator` skill | AI agents that need to discover APIs beyond what the CLI covers | + +There is no embedded MCP server. Agents that cannot execute commands should use +a thin HTTP client against the REST API described by the OpenAPI document. + +## Build the CLI + +```bash +make build-cli +./bin/sigma-cli --help +``` + +## Login + +`sigma-cli` stores Basic Auth credentials in `~/.sigma/cli-credentials.json` +with `0600` permissions. Override the location with `--credentials-file` or +`SIGMA_CREDENTIALS_FILE`. + +```bash +./bin/sigma-cli login \ + --server https://sigma.example.com \ + --username sigma \ + --password 'Admin@123' +``` + +Credentials can also be supplied without a credentials file through the +persistent flags or environment variables: + +```bash +export SIGMA_SERVER=https://sigma.example.com +export SIGMA_USERNAME=sigma +export SIGMA_PASSWORD='Admin@123' +``` + +Global flags: + +| Flag | Env | Default | +| --- | --- | --- | +| `--server` | `SIGMA_SERVER` | `http://localhost:3000` | +| `--credentials-file` | `SIGMA_CREDENTIALS_FILE` | `~/.sigma/cli-credentials.json` | +| `--timeout` | – | `30s` | +| `--debug` | – | `false` | + +## Command Reference + +### Namespace + +```bash +sigma-cli namespace list --page 1 --limit 10 +sigma-cli namespace get +sigma-cli namespace create library --visibility public --description "Base images" +sigma-cli namespace update --size-limit 107374182400 +sigma-cli namespace delete +``` + +### Repository + +```bash +sigma-cli repo list +sigma-cli repo get +sigma-cli repo create library/alpine --visibility public +sigma-cli repo update --description "Alpine images" +sigma-cli repo delete +``` + +### Tag + +```bash +sigma-cli tag list --type image +sigma-cli tag get +sigma-cli tag delete +``` + +### Artifact + +Artifact commands take namespace and repository **names** rather than IDs. + +```bash +sigma-cli artifact list +sigma-cli artifact get +sigma-cli artifact delete +``` + +### Member + +```bash +sigma-cli member list +sigma-cli member add --role reader +sigma-cli member update --role admin +sigma-cli member delete +``` + +### User + +```bash +sigma-cli user list --without-admin +sigma-cli user create --username alice --password '...' --email alice@example.com --role user +sigma-cli user update --role admin --status enabled +``` + +All commands print JSON on success and a formatted error body on failure, so +output composes with `jq`: + +```bash +sigma-cli namespace list --limit 100 | jq -r '.items[].name' +``` + +## AI Agents + +`tools/skill/sigma-api-operator` packages a skill for CLI agents such as Claude +Code or Copilot CLI: + +```text +tools/skill/sigma-api-operator/SKILL.md +tools/skill/sigma-api-operator/swagger.yaml +``` + +The skill instructs the agent to load the OpenAPI document before issuing +requests. This is required for resources the CLI does not wrap yet — builders, +daemons, webhooks, validators, code repositories, and tokens — which must be +called through the REST API. + +Refresh the packaged specification after changing handlers: + +```bash +make swagen +``` + +## Safety + +- Destructive commands (`delete`, credential and privilege changes) require + explicit confirmation by the agent. +- Prefer HTTPS for remote servers; Basic Auth credentials travel with every + request. +- Do not print passwords, tokens, private keys, or `Authorization` headers. +- Redact sensitive fields before surfacing non-2xx response bodies. diff --git a/docs/configuration.md b/docs/configuration.md index bf9365f8..d9788b24 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -206,14 +206,6 @@ auth: # fixed delay applied to every authentication attempt after maxFailures delay: 1s -mcp: - enabled: false - path: /api/v1/mcp - transport: streamable_http - auditWrites: true - toolTimeout: 30s - maxRequestBody: 1048576 - audit: # record completed authenticated business HTTP requests enabled: true diff --git a/docs/mcp.md b/docs/mcp.md deleted file mode 100644 index fa469543..00000000 --- a/docs/mcp.md +++ /dev/null @@ -1,143 +0,0 @@ ---- -title: MCP Server ---- - -Sigma can expose an embedded MCP server for AI clients and automation tools. -The MCP endpoint runs inside the existing `sigma server` process and is disabled by default. - -## What It Exposes - -The MCP server focuses on registry management metadata and controlled operations. -It does not expose OCI blob upload/download traffic. - -Current tools include: - -- System: `system-version-get`, `system-endpoint-get`, `system-config-summary-get` -- Namespace: `namespace-list`, `namespace-get`, `namespace-create`, `namespace-update`, `namespace-delete` -- Namespace members: `namespace-member-list`, `namespace-member-add`, `namespace-member-update`, `namespace-member-delete`, `namespace-member-self-get` -- Repository: `repository-list`, `repository-get`, `repository-create`, `repository-update`, `repository-delete` -- Tag: `tag-list`, `tag-get`, `tag-delete`, `tag-manifest-raw-get` -- Artifact: `artifact-list`, `artifact-get`, `artifact-delete` - -The read-only resources include: - -- `sigma://system/version` -- `sigma://system/capabilities` - -## Enable MCP - -MCP is configured through the `mcp` section: - -```yaml -mcp: - enabled: true - path: /api/v1/mcp - transport: streamable_http - auditWrites: true - toolTimeout: 30s - maxRequestBody: 1048576 -``` - -When deploying with Helm: - -```bash -helm upgrade --install sigma ./deploy/sigma \ - --set config.mcp.enabled=true -``` - -The endpoint is served by the existing server service: - -```text -https:///api/v1/mcp -``` - -## Authentication - -MCP uses Sigma Basic Auth. Every MCP request must include: - -```text -Authorization: Basic -``` - -For example: - -```bash -printf 'sigma:Admin@123' | base64 -``` - -Use the encoded value in the MCP client configuration: - -```json -{ - "mcpServers": { - "sigma": { - "url": "https:///api/v1/mcp", - "headers": { - "Authorization": "Basic " - } - } - } -} -``` - -Because Basic Auth sends credentials with each request, expose MCP only over HTTPS or through a TLS-terminating gateway. - -## Authorization - -Tools run as the authenticated Sigma user. -Sigma applies the same resource authorization model used by the HTTP API: - -- Root and admin users can access all MCP tools -- Namespace readers can read namespace-scoped resources -- Namespace managers can manage repositories, tags, and artifacts -- Namespace admins can manage namespace metadata and members - -Unauthorized calls return a tool error and do not execute the requested operation. - -## Auditing and Metrics - -Write operations are recorded in the audit table when `mcp.auditWrites` is enabled. -Audit records use: - -- `method`: `MCP` -- `path`: the MCP endpoint path -- `route`: the MCP tool name - -Sensitive fields such as `password`, `token`, `privateKey`, `ak`, `sk`, `secret`, and `authorization` are redacted before audit storage. - -Prometheus metrics are exposed through the existing `/metrics` endpoint: - -- `sigma_mcp_tool_calls_total` -- `sigma_mcp_tool_duration_seconds` -- `sigma_mcp_auth_failures_total` - -## Example Tool Arguments - -List repositories in a namespace: - -```json -{ - "namespace_id": "550e8400-e29b-41d4-a716-446655440000", - "page": 1, - "limit": 10 -} -``` - -Create a repository: - -```json -{ - "namespace_id": "550e8400-e29b-41d4-a716-446655440000", - "name": "library/alpine", - "description": "Alpine images" -} -``` - -Get a tag manifest: - -```json -{ - "repository_id": "550e8400-e29b-41d4-a716-446655440000", - "digest": "sha256:..." -} -``` diff --git a/docs/zh/cli.md b/docs/zh/cli.md new file mode 100644 index 00000000..4ead5195 --- /dev/null +++ b/docs/zh/cli.md @@ -0,0 +1,143 @@ +--- +title: CLI 与 AI Agent +--- + +# CLI 与 AI Agent + +Sigma 提供两种从终端或 AI agent 操作远程服务端的方式。两者都调用同一套 HTTP +API,因此 web 界面能做的事都可以脚本化。 + +| 方式 | 适用场景 | +| --- | --- | +| `sigma-cli` | 人、CI 流水线、可执行命令的 agent | +| `sigma-api-operator` skill | 需要访问 CLI 尚未覆盖的 API 的 AI agent | + +Sigma 不再内置 MCP server。无法执行命令的客户端应使用轻量 HTTP 客户端调用 +OpenAPI 文档描述的 REST API。 + +## 构建 CLI + +```bash +make build-cli +./bin/sigma-cli --help +``` + +## 登录 + +`sigma-cli` 将 Basic Auth 凭据保存在 `~/.sigma/cli-credentials.json`,权限为 +`0600`。可通过 `--credentials-file` 或 `SIGMA_CREDENTIALS_FILE` 修改位置。 + +```bash +./bin/sigma-cli login \ + --server https://sigma.example.com \ + --username sigma \ + --password 'Admin@123' +``` + +也可以不落盘,直接用全局参数或环境变量提供凭据: + +```bash +export SIGMA_SERVER=https://sigma.example.com +export SIGMA_USERNAME=sigma +export SIGMA_PASSWORD='Admin@123' +``` + +全局参数: + +| 参数 | 环境变量 | 默认值 | +| --- | --- | --- | +| `--server` | `SIGMA_SERVER` | `http://localhost:3000` | +| `--credentials-file` | `SIGMA_CREDENTIALS_FILE` | `~/.sigma/cli-credentials.json` | +| `--timeout` | – | `30s` | +| `--debug` | – | `false` | + +## 命令参考 + +### 命名空间 + +```bash +sigma-cli namespace list --page 1 --limit 10 +sigma-cli namespace get +sigma-cli namespace create library --visibility public --description "基础镜像" +sigma-cli namespace update --size-limit 107374182400 +sigma-cli namespace delete +``` + +### 仓库 + +```bash +sigma-cli repo list +sigma-cli repo get +sigma-cli repo create library/alpine --visibility public +sigma-cli repo update --description "Alpine 镜像" +sigma-cli repo delete +``` + +### Tag + +```bash +sigma-cli tag list --type image +sigma-cli tag get +sigma-cli tag delete +``` + +### 制品 + +制品相关命令使用命名空间和仓库的**名称**,而不是 ID。 + +```bash +sigma-cli artifact list +sigma-cli artifact get +sigma-cli artifact delete +``` + +### 成员 + +```bash +sigma-cli member list +sigma-cli member add --role reader +sigma-cli member update --role admin +sigma-cli member delete +``` + +### 用户 + +```bash +sigma-cli user list --without-admin +sigma-cli user create --username alice --password '...' --email alice@example.com --role user +sigma-cli user update --role admin --status enabled +``` + +所有命令成功时输出 JSON,失败时输出格式化后的错误响应体,因此可以直接和 +`jq` 组合使用: + +```bash +sigma-cli namespace list --limit 100 | jq -r '.items[].name' +``` + +## AI Agent + +`tools/skill/sigma-api-operator` 为 Claude Code、Copilot CLI 等 CLI agent 打包了 +一个 skill: + +```text +tools/skill/sigma-api-operator/SKILL.md +tools/skill/sigma-api-operator/swagger.yaml +``` + +该 skill 要求 agent 在发起请求前先加载 OpenAPI 文档。对于 CLI 尚未覆盖的资源 +——builder、daemon、webhook、validator、代码仓库和 token——必须通过 REST API +调用。 + +修改 handler 后刷新打包的接口文档: + +```bash +make swagen +``` + +## 安全建议 + +- 破坏性命令(`delete`、凭据与权限变更)必须由 agent 明确确认后执行。 +- 远程服务端优先使用 HTTPS;Basic Auth 凭据会随每个请求发送。 +- 不要打印密码、token、私钥或 `Authorization` 头。 +- 返回非 2xx 响应体时,先脱敏再展示给用户。 diff --git a/docs/zh/configuration.md b/docs/zh/configuration.md index 24d0e06d..10f94bd9 100644 --- a/docs/zh/configuration.md +++ b/docs/zh/configuration.md @@ -210,14 +210,6 @@ auth: # fixed delay applied to every authentication attempt after maxFailures delay: 1s -mcp: - enabled: false - path: /api/v1/mcp - transport: streamable_http - auditWrites: true - toolTimeout: 30s - maxRequestBody: 1048576 - audit: # record completed authenticated business HTTP requests enabled: true diff --git a/docs/zh/mcp.md b/docs/zh/mcp.md deleted file mode 100644 index ee4149d0..00000000 --- a/docs/zh/mcp.md +++ /dev/null @@ -1,141 +0,0 @@ ---- -title: MCP Server ---- - -Sigma 可以在现有 `sigma server` 进程中内嵌 MCP server,用于 AI 客户端和自动化工具访问 Sigma 的管理能力。 -MCP 默认关闭,需要显式开启。 - -## 暴露哪些能力 - -MCP server 只暴露管理面和元数据能力,不暴露 OCI Blob 上传/下载流量。 - -当前已支持的 tools: - -- 系统:`system-version-get`、`system-endpoint-get`、`system-config-summary-get` -- Namespace:`namespace-list`、`namespace-get`、`namespace-create`、`namespace-update`、`namespace-delete` -- Namespace 成员:`namespace-member-list`、`namespace-member-add`、`namespace-member-update`、`namespace-member-delete`、`namespace-member-self-get` -- Repository:`repository-list`、`repository-get`、`repository-create`、`repository-update`、`repository-delete` -- Tag:`tag-list`、`tag-get`、`tag-delete`、`tag-manifest-raw-get` -- Artifact:`artifact-list`、`artifact-get`、`artifact-delete` - -只读 resources: - -- `sigma://system/version` -- `sigma://system/capabilities` - -## 开启 MCP - -在配置文件中设置 `mcp`: - -```yaml -mcp: - enabled: true - path: /api/v1/mcp - transport: streamable_http - auditWrites: true - toolTimeout: 30s - maxRequestBody: 1048576 -``` - -使用 Helm 部署时: - -```bash -helm upgrade --install sigma ./deploy/sigma \ - --set config.mcp.enabled=true -``` - -MCP endpoint 复用现有 server service: - -```text -https:///api/v1/mcp -``` - -## 认证方式 - -MCP 使用 Sigma Basic Auth。每个 MCP 请求都必须携带: - -```text -Authorization: Basic -``` - -例如: - -```bash -printf 'sigma:Admin@123' | base64 -``` - -将编码后的值写入 MCP client 配置: - -```json -{ - "mcpServers": { - "sigma": { - "url": "https:///api/v1/mcp", - "headers": { - "Authorization": "Basic " - } - } - } -} -``` - -Basic Auth 会在每次请求中发送凭据,因此 MCP 必须通过 HTTPS 或具备 TLS 终止能力的网关暴露。 - -## 权限模型 - -MCP tool 会以认证后的 Sigma 用户身份执行,并复用 Sigma HTTP API 的资源权限模型: - -- Root 和 admin 用户可以访问所有 MCP tools -- Namespace reader 可以读取 namespace 范围内的资源 -- Namespace manager 可以管理 repository、tag 和 artifact -- Namespace admin 可以管理 namespace 元数据和成员 - -没有权限的调用会返回 tool error,不会执行对应操作。 - -## 审计和指标 - -当 `mcp.auditWrites` 开启时,写操作会记录到 audit 表。 -审计字段使用: - -- `method`: `MCP` -- `path`: MCP endpoint path -- `route`: MCP tool name - -写入审计前会过滤敏感字段,例如 `password`、`token`、`privateKey`、`ak`、`sk`、`secret`、`authorization`。 - -MCP 指标复用现有 `/metrics` endpoint 暴露: - -- `sigma_mcp_tool_calls_total` -- `sigma_mcp_tool_duration_seconds` -- `sigma_mcp_auth_failures_total` - -## Tool 参数示例 - -列出 namespace 下的 repositories: - -```json -{ - "namespace_id": "550e8400-e29b-41d4-a716-446655440000", - "page": 1, - "limit": 10 -} -``` - -创建 repository: - -```json -{ - "namespace_id": "550e8400-e29b-41d4-a716-446655440000", - "name": "library/alpine", - "description": "Alpine images" -} -``` - -获取 tag manifest: - -```json -{ - "repository_id": "550e8400-e29b-41d4-a716-446655440000", - "digest": "sha256:..." -} -``` diff --git a/go.mod b/go.mod index ea2819b3..dfd653d1 100644 --- a/go.mod +++ b/go.mod @@ -41,7 +41,6 @@ require ( github.com/jackc/pgx/v5 v5.11.0 github.com/jinzhu/copier v0.4.0 github.com/lmittmann/tint v1.2.1 - github.com/mark3labs/mcp-go v1.2.1 github.com/moby/buildkit v0.34.1 github.com/opencontainers/distribution-spec/specs-go v0.0.0-20260831232431-97274622c111 github.com/opencontainers/go-digest v1.0.0 @@ -289,7 +288,6 @@ require ( github.com/google/go-containerregistry v0.22.1 // indirect github.com/google/go-intervals v0.0.2 // indirect github.com/google/go-querystring v1.2.0 // indirect - github.com/google/jsonschema-go v0.4.2 // indirect github.com/google/licensecheck v0.3.1 // indirect github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 // indirect github.com/google/s2a-go v0.1.11 // indirect @@ -420,7 +418,6 @@ require ( github.com/rust-secure-code/go-rustaudit v0.0.0-20250226111315-e20ec32e963c // indirect github.com/sagikazarmark/locafero v0.12.0 // indirect github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d // indirect - github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 // indirect github.com/sassoftware/go-rpmutils v0.4.0 // indirect github.com/sassoftware/relic/v8 v8.2.0 // indirect github.com/scylladb/go-set v1.0.3-0.20200225121959-cc7b2070d91e // indirect @@ -483,7 +480,6 @@ require ( github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect - github.com/yosida95/uritemplate/v3 v3.0.2 // indirect github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect github.com/yuin/gopher-lua v1.1.2 // indirect github.com/zclconf/go-cty v1.19.0 // indirect diff --git a/go.sum b/go.sum index b0da007a..24875cc3 100644 --- a/go.sum +++ b/go.sum @@ -450,8 +450,6 @@ github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5Qvfr github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c= github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0= -github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= -github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/docker/cli v29.9.2+incompatible h1:trup/vUxx5uUo2t+87Cd9AmITR+DvKUe4H2tTtZN8FY= github.com/docker/cli v29.9.2+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk= @@ -781,8 +779,6 @@ github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJ github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= -github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/licensecheck v0.3.1 h1:QoxgoDkaeC4nFrtGN1jV7IPmDCHFNIVh54e5hSt6sPs= github.com/google/licensecheck v0.3.1/go.mod h1:ORkR35t/JjW+emNKtfJDII0zlciG9JgbT7SmsohlHmY= github.com/google/martian v2.1.0+incompatible h1:/CP5g8u/VJHijgedC/Legn3BAbAaWPgecwXBIDzw5no= @@ -1075,8 +1071,6 @@ github.com/magiconair/properties v1.18.12/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA= github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg= -github.com/mark3labs/mcp-go v1.2.1 h1:gd8VD3feMAVycwAM0x1nhv42Q1oLLjfOyowoUzHULB8= -github.com/mark3labs/mcp-go v1.2.1/go.mod h1:r2fW4o3wsoJ7IMsx1Wuq5xeP8PRGXPDfNveoGAYbb/s= github.com/maruel/natural v1.3.0 h1:VsmCsBmEyrR46RomtgHs5hbKADGRVtliHTyCOLFBpsg= github.com/maruel/natural v1.3.0/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= github.com/masahiro331/go-mvn-version v0.0.0-20250131095131-f4974fa13b8a h1:eLvAzVoRfHEOl64OxFhepPf3vj7SKvXY/tFc3BS0b7s= @@ -1590,8 +1584,6 @@ github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 h1:FnBeRrxr7OU4VvAz github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= -github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= -github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= github.com/ysmood/fetchup v0.3.0 h1:UhYz9xnLEVn2ukSuK3KCgcznWpHMdrmbsPpllcylyu8= diff --git a/pkg/app/helper.go b/pkg/app/helper.go index ccb336f5..e812581e 100644 --- a/pkg/app/helper.go +++ b/pkg/app/helper.go @@ -219,9 +219,6 @@ func genAuthzSkipper() authz.Skipper { if !(strings.HasPrefix(requestUri, "/v2/") || strings.HasPrefix(requestUri, consts.APIV1)) { // nolint: staticcheck return true } - if config.GetConfig().MCP.Enabled && requestUri == config.GetConfig().MCP.Path { - return true - } return slices.Contains(skipAuthzs, strings.ToLower(fmt.Sprintf("%s:%s", requestMethod, requestUri))) } } @@ -229,9 +226,6 @@ func genAuthzSkipper() authz.Skipper { func genAuditSkipper() audit.Skipper { return func(c *gin.Context) bool { requestPath := c.Request.URL.Path - if config.GetConfig().MCP.Enabled && requestPath == config.GetConfig().MCP.Path { - return true - } return !strings.HasPrefix(requestPath, "/v2/") && requestPath != "/v2" && !strings.HasPrefix(requestPath, consts.APIV1) } } diff --git a/pkg/app/server/server.go b/pkg/app/server/server.go index 7bd38b48..8161d104 100644 --- a/pkg/app/server/server.go +++ b/pkg/app/server/server.go @@ -37,7 +37,6 @@ import ( "github.com/go-sigma/sigma/pkg/infra/workq" "github.com/go-sigma/sigma/pkg/logger" "github.com/go-sigma/sigma/pkg/server/handlers" - mcpserver "github.com/go-sigma/sigma/pkg/server/mcp" ) // ServerConfig selects which optional subsystems Serve initializes; each @@ -109,10 +108,6 @@ func Serve(digCon *dig.Container) error { if err != nil { return err } - err = mcpserver.Register(digCon) - if err != nil { - return fmt.Errorf("failed to initialize mcp server: %v", err) - } httpServer := &http.Server{ Addr: consts.ServerPort, diff --git a/pkg/authz/resolver.go b/pkg/authz/resolver.go index a4a552e1..a67d3d21 100644 --- a/pkg/authz/resolver.go +++ b/pkg/authz/resolver.go @@ -88,13 +88,12 @@ func resolveV2(path string) (ResourceDescriptor, error) { return ResourceDescriptor{NamespaceName: firstSegment(repo), Resource: ResBlobUploads}, nil } - // Split into [repo...suffix, lastSegment] using the last "/". - idx := strings.LastIndex(rest, "/") - if idx < 0 { + // Split into [repo...suffix, lastSegment] using the last "/", where prefix + // keeps the repo plus the suffix marker. + prefix, ref, ok := strings.CutLast(rest, "/") + if !ok { return ResourceDescriptor{}, fmt.Errorf("unrecognized /v2 path: %s", path) } - ref := rest[idx+1:] - prefix := rest[:idx] // repo + suffix marker switch { case strings.HasSuffix(prefix, "/manifests"): diff --git a/pkg/config/configs_test.go b/pkg/config/configs_test.go index c1696ff3..52153913 100644 --- a/pkg/config/configs_test.go +++ b/pkg/config/configs_test.go @@ -130,34 +130,6 @@ http: require.Equal(t, 30*time.Second, cfg.HTTP.Timeout.IdleTimeout()) } -func TestConfigurationMCPWithDefaults(t *testing.T) { - var mcp ConfigurationMCP - mcp.WithDefaults() - require.False(t, mcp.Enabled) - require.Equal(t, "/api/v1/mcp", mcp.Path) - require.Equal(t, "streamable_http", mcp.Transport) - require.True(t, mcp.AuditWritesEnabled()) - require.Equal(t, 30*time.Second, mcp.ToolTimeout) - require.Equal(t, int64(1<<20), mcp.MaxRequestBody) - - disabled := false - mcp = ConfigurationMCP{ - Enabled: true, - Path: "/custom/mcp", - Transport: "sse", - AuditWrites: &disabled, - ToolTimeout: time.Minute, - MaxRequestBody: 2 << 20, - } - mcp.WithDefaults() - require.True(t, mcp.Enabled) - require.Equal(t, "/custom/mcp", mcp.Path) - require.Equal(t, "sse", mcp.Transport) - require.False(t, mcp.AuditWritesEnabled()) - require.Equal(t, time.Minute, mcp.ToolTimeout) - require.Equal(t, int64(2<<20), mcp.MaxRequestBody) -} - func TestConfigurationApplyEnvOverrides(t *testing.T) { t.Setenv("SIGMA_DATABASE_MYSQL_PASSWORD", "mysql-env") t.Setenv("SIGMA_DATABASE_POSTGRESQL_PASSWORD", "postgresql-env") diff --git a/pkg/config/configuration.go b/pkg/config/configuration.go index 8207bfd5..20d3a305 100644 --- a/pkg/config/configuration.go +++ b/pkg/config/configuration.go @@ -32,11 +32,6 @@ const ( defaultHTTPTimeoutWrite = time.Hour defaultHTTPTimeoutIdle = 2 * time.Minute - defaultMCPPath = "/api/v1/mcp" - defaultMCPTransport = "streamable_http" - defaultMCPToolTimeout = 30 * time.Second - defaultMCPMaxRequestBody = int64(1 << 20) // 1Mi - defaultAuthLoginRateLimitMaxFailures = 5 defaultAuthLoginRateLimitWindow = 5 * time.Minute defaultAuthLoginRateLimitDelay = time.Second @@ -70,7 +65,6 @@ type Configuration struct { Daemon ConfigurationDaemon `yaml:"daemon"` Auth ConfigurationAuth `yaml:"auth"` Audit ConfigurationAudit `yaml:"audit"` - MCP ConfigurationMCP `yaml:"mcp"` Trace ConfigurationTrace `yaml:"trace"` Analytics ConfigurationAnalytics `yaml:"analytics"` } @@ -83,48 +77,12 @@ func (c *Configuration) WithDefaults() { c.Namespace.WithDefaults() c.Daemon.WithDefaults() c.Audit.Cleanup.WithDefaults() - c.MCP.WithDefaults() c.WorkQueue.Inmemory.WithDefaults() c.Cache.WithDefaults() c.Locker.WithDefaults() c.Database.WithDefaults() } -// ConfigurationMCP configures the embedded MCP server. -type ConfigurationMCP struct { - Enabled bool `yaml:"enabled"` - Path string `yaml:"path"` - Transport string `yaml:"transport"` - AuditWrites *bool `yaml:"auditWrites"` - ToolTimeout time.Duration `yaml:"toolTimeout"` - MaxRequestBody int64 `yaml:"maxRequestBody"` -} - -// WithDefaults applies default values for MCP. -func (c *ConfigurationMCP) WithDefaults() { - if c.Path == "" { - c.Path = defaultMCPPath - } - if c.Transport == "" { - c.Transport = defaultMCPTransport - } - if c.AuditWrites == nil { - enabled := true - c.AuditWrites = &enabled - } - if c.ToolTimeout == 0 { - c.ToolTimeout = defaultMCPToolTimeout - } - if c.MaxRequestBody == 0 { - c.MaxRequestBody = defaultMCPMaxRequestBody - } -} - -// AuditWritesEnabled reports whether write MCP tool calls should be audited. -func (c ConfigurationMCP) AuditWritesEnabled() bool { - return c.AuditWrites == nil || *c.AuditWrites -} - // ConfigurationAudit configures HTTP audit recording. type ConfigurationAudit struct { Enabled *bool `yaml:"enabled"` diff --git a/pkg/logger/logger.go b/pkg/logger/logger.go index a8f4e9ef..8db8e588 100644 --- a/pkg/logger/logger.go +++ b/pkg/logger/logger.go @@ -66,15 +66,15 @@ func replaceLogAttr(_ []string, attr slog.Attr) slog.Attr { } func trimSourceFile(file string) string { - lastSlash := strings.LastIndex(file, "/") - if lastSlash < 0 { + dir, base, ok := strings.CutLast(file, "/") + if !ok { return file } - prevSlash := strings.LastIndex(file[:lastSlash], "/") - if prevSlash < 0 { + _, parent, ok := strings.CutLast(dir, "/") + if !ok { return file } - return file[prevSlash+1:] + return parent + "/" + base } type logWriter struct { diff --git a/pkg/server/mcp/audit.go b/pkg/server/mcp/audit.go deleted file mode 100644 index c0d52e8a..00000000 --- a/pkg/server/mcp/audit.go +++ /dev/null @@ -1,113 +0,0 @@ -// Copyright 2026 sigma -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package mcpserver - -import ( - "context" - "encoding/json/v2" - "log/slog" - "strings" - "time" - - "github.com/mark3labs/mcp-go/mcp" - - "github.com/go-sigma/sigma/pkg/dal/models" - repoaudit "github.com/go-sigma/sigma/pkg/dal/repository/audit" - "github.com/go-sigma/sigma/pkg/utils/uuid" -) - -var sensitiveKeys = map[string]struct{}{ - "ak": {}, - "authorization": {}, - "password": {}, - "privatekey": {}, - "scmpassword": {}, - "scmsshkey": {}, - "scmtoken": {}, - "secret": {}, - "sk": {}, - "token": {}, -} - -func auditToolCall( - ctx context.Context, - repo repoaudit.AuditRepository, - path string, - user *models.User, - toolName string, - request mcp.CallToolRequest, - startedAt time.Time, - statusCode int, -) { - - if repo == nil || user == nil { - return - } - clientIP, userAgent := clientInfoFromContext(ctx) - args := request.GetArguments() - query, err := json.Marshal(redactSensitive(args)) - if err != nil { - slog.Error("marshal mcp audit arguments failed", "err", err, "tool", toolName) - query = []byte("{}") - } - auditObj := &models.Audit{ - ID: uuid.NewV7String(), - UserID: user.ID, - Username: user.Username, - UserRole: user.Role.String(), - Method: "MCP", - Path: path, - Route: toolName, - Query: string(query), - StatusCode: statusCode, - ClientIP: clientIP, - UserAgent: userAgent, - LatencyMs: time.Since(startedAt).Milliseconds(), - } - if namespaceID := stringArgument(args, "namespace_id", "namespaceID"); namespaceID != "" { - auditObj.NamespaceID = &namespaceID - } - if e := repo.Create(ctx, auditObj); e != nil { - slog.Error("create mcp audit failed", "err", e, "tool", toolName) - } -} - -func redactSensitive(value any) any { - switch v := value.(type) { - case map[string]any: - result := make(map[string]any, len(v)) - for key, item := range v { - if _, ok := sensitiveKeys[normalizeKey(key)]; ok { - result[key] = "[REDACTED]" - continue - } - result[key] = redactSensitive(item) - } - return result - case []any: - result := make([]any, 0, len(v)) - for _, item := range v { - result = append(result, redactSensitive(item)) - } - return result - default: - return value - } -} - -func normalizeKey(key string) string { - replacer := strings.NewReplacer("_", "", "-", "", ".", "") - return strings.ToLower(replacer.Replace(key)) -} diff --git a/pkg/server/mcp/audit_test.go b/pkg/server/mcp/audit_test.go deleted file mode 100644 index d1f239b2..00000000 --- a/pkg/server/mcp/audit_test.go +++ /dev/null @@ -1,55 +0,0 @@ -// Copyright 2026 sigma -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package mcpserver - -import ( - "testing" - - "github.com/stretchr/testify/require" -) - -func TestRedactSensitive(t *testing.T) { - input := map[string]any{ - "username": "sigma", - "password": "secret", - "scm_token": "token", - "private_key": "key", - "nested": map[string]any{ - "authorization": "Basic value", - "value": "kept", - }, - "items": []any{ - map[string]any{"sk": "secret-key"}, - }, - } - - redacted, ok := redactSensitive(input).(map[string]any) - require.True(t, ok) - require.Equal(t, "sigma", redacted["username"]) - require.Equal(t, "[REDACTED]", redacted["password"]) - require.Equal(t, "[REDACTED]", redacted["scm_token"]) - require.Equal(t, "[REDACTED]", redacted["private_key"]) - - nested, ok := redacted["nested"].(map[string]any) - require.True(t, ok) - require.Equal(t, "[REDACTED]", nested["authorization"]) - require.Equal(t, "kept", nested["value"]) - - items, ok := redacted["items"].([]any) - require.True(t, ok) - item, ok := items[0].(map[string]any) - require.True(t, ok) - require.Equal(t, "[REDACTED]", item["sk"]) -} diff --git a/pkg/server/mcp/authorize.go b/pkg/server/mcp/authorize.go deleted file mode 100644 index 35d1ed4d..00000000 --- a/pkg/server/mcp/authorize.go +++ /dev/null @@ -1,55 +0,0 @@ -// Copyright 2026 sigma -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package mcpserver - -import ( - "context" - - "github.com/go-sigma/sigma/pkg/api/enums" - "github.com/go-sigma/sigma/pkg/dal/models" -) - -func (s *Server) authorizeNamespace(ctx context.Context, user *models.User, namespaceID string, auth enums.Auth) error { - allowed, err := s.authorizer.Namespace(ctx, *user, namespaceID, auth) - if err != nil { - return err - } - if !allowed { - return errForbidden - } - return nil -} - -func (s *Server) authorizeRepository(ctx context.Context, user *models.User, repositoryID string, auth enums.Auth) error { - allowed, err := s.authorizer.Repository(ctx, *user, repositoryID, auth) - if err != nil { - return err - } - if !allowed { - return errForbidden - } - return nil -} - -func (s *Server) authorizeTag(ctx context.Context, user *models.User, tagID string, auth enums.Auth) error { - allowed, err := s.authorizer.Tag(ctx, *user, tagID, auth) - if err != nil { - return err - } - if !allowed { - return errForbidden - } - return nil -} diff --git a/pkg/server/mcp/authorize_test.go b/pkg/server/mcp/authorize_test.go deleted file mode 100644 index beb0ed9b..00000000 --- a/pkg/server/mcp/authorize_test.go +++ /dev/null @@ -1,128 +0,0 @@ -// Copyright 2026 sigma -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package mcpserver - -import ( - "context" - "errors" - "testing" - - "github.com/stretchr/testify/require" - - "github.com/go-sigma/sigma/pkg/api/enums" - "github.com/go-sigma/sigma/pkg/authz" - "github.com/go-sigma/sigma/pkg/dal/models" -) - -func TestAuthorizeHelpers(t *testing.T) { - expectedErr := errors.New("lookup failed") - user := &models.User{ID: "user-1"} - tests := []struct { - name string - authorizer authz.Authorizer - invoke func(*Server) error - wantErr error - }{ - { - name: "namespace allowed", - authorizer: fakeAuthorizer{namespaceAllowed: true}, - invoke: func(server *Server) error { - return server.authorizeNamespace(t.Context(), user, "namespace-1", enums.AuthRead) - }, - }, - { - name: "namespace denied", - authorizer: fakeAuthorizer{}, - invoke: func(server *Server) error { - return server.authorizeNamespace(t.Context(), user, "namespace-1", enums.AuthRead) - }, - wantErr: errForbidden, - }, - { - name: "repository error", - authorizer: fakeAuthorizer{repositoryErr: expectedErr}, - invoke: func(server *Server) error { - return server.authorizeRepository(t.Context(), user, "repository-1", enums.AuthRead) - }, - wantErr: expectedErr, - }, - { - name: "repository allowed", - authorizer: fakeAuthorizer{repositoryAllowed: true}, - invoke: func(server *Server) error { - return server.authorizeRepository(t.Context(), user, "repository-1", enums.AuthManage) - }, - }, - { - name: "tag denied", - authorizer: fakeAuthorizer{}, - invoke: func(server *Server) error { return server.authorizeTag(t.Context(), user, "tag-1", enums.AuthRead) }, - wantErr: errForbidden, - }, - { - name: "tag allowed", - authorizer: fakeAuthorizer{tagAllowed: true}, - invoke: func(server *Server) error { return server.authorizeTag(t.Context(), user, "tag-1", enums.AuthAdmin) }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := tt.invoke(&Server{authorizer: tt.authorizer}) - if tt.wantErr == nil { - require.NoError(t, err) - } else { - require.ErrorIs(t, err, tt.wantErr) - } - }) - } -} - -type fakeAuthorizer struct { - namespaceAllowed bool - repositoryAllowed bool - tagAllowed bool - repositoryErr error -} - -var _ authz.Authorizer = fakeAuthorizer{} - -func (fakeAuthorizer) Authorize(context.Context, string, bool, string, string) (bool, error) { - return false, nil -} - -func (f fakeAuthorizer) Namespace(context.Context, models.User, string, enums.Auth) (bool, error) { - return f.namespaceAllowed, nil -} - -func (fakeAuthorizer) NamespaceRole(context.Context, models.User, string) (*enums.NamespaceRole, error) { - return nil, nil -} - -func (fakeAuthorizer) NamespacesRole(context.Context, models.User, []string) (map[string]*enums.NamespaceRole, error) { - return nil, nil -} - -func (f fakeAuthorizer) Repository(context.Context, models.User, string, enums.Auth) (bool, error) { - return f.repositoryAllowed, f.repositoryErr -} - -func (f fakeAuthorizer) Tag(context.Context, models.User, string, enums.Auth) (bool, error) { - return f.tagAllowed, nil -} - -func (fakeAuthorizer) Artifact(context.Context, models.User, string, enums.Auth) (bool, error) { - return false, nil -} diff --git a/pkg/server/mcp/business_tools_test.go b/pkg/server/mcp/business_tools_test.go deleted file mode 100644 index a7d50dbf..00000000 --- a/pkg/server/mcp/business_tools_test.go +++ /dev/null @@ -1,154 +0,0 @@ -// Copyright 2026 sigma -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package mcpserver - -import ( - "testing" - - "github.com/mark3labs/mcp-go/mcp" - "github.com/stretchr/testify/require" - "go.uber.org/mock/gomock" - - "github.com/go-sigma/sigma/pkg/api" - "github.com/go-sigma/sigma/pkg/dal/models" - artifactsmocks "github.com/go-sigma/sigma/pkg/service/artifacts/mocks" - namespacesmocks "github.com/go-sigma/sigma/pkg/service/namespaces/mocks" - repositoriesmocks "github.com/go-sigma/sigma/pkg/service/repositories/mocks" - tagsmocks "github.com/go-sigma/sigma/pkg/service/tags/mocks" -) - -func TestNamespaceTools(t *testing.T) { - ctrl := gomock.NewController(t) - namespaceSvc := namespacesmocks.NewMockNamespaceService(ctrl) - server := &Server{ - authorizer: fakeAuthorizer{namespaceAllowed: true}, - namespaceSvc: namespaceSvc, - } - ctx := withUser(t.Context(), &models.User{ID: "user-1"}) - - namespaceSvc.EXPECT(). - ListNamespaces(gomock.Any(), "user-1", nil, api.Pagination{}, api.Sortable{}). - Return([]*models.Namespace{{ID: "namespace-1"}}, int64(1), nil) - result, err := server.namespaceList(ctx, toolRequest(nil)) - require.NoError(t, err) - require.Equal(t, int64(1), result.(map[string]any)["total"]) - - request := api.PostNamespaceRequest{Name: "sigma"} - namespaceSvc.EXPECT(). - CreateNamespace(gomock.Any(), "user-1", request). - Return(&models.Namespace{ID: "namespace-1"}, nil) - result, err = server.namespaceCreate(ctx, toolRequest(map[string]any{"name": "sigma"})) - require.NoError(t, err) - require.Equal(t, map[string]string{"id": "namespace-1"}, result) - - namespaceSvc.EXPECT(). - DeleteNamespace(gomock.Any(), "user-1", "namespace-1"). - Return(nil) - result, err = server.namespaceDelete(ctx, toolRequest(map[string]any{"id": "namespace-1"})) - require.NoError(t, err) - require.Equal(t, okResponse(), result) -} - -func TestRepositoryTools(t *testing.T) { - ctrl := gomock.NewController(t) - repositorySvc := repositoriesmocks.NewMockRepositoryService(ctrl) - server := &Server{ - authorizer: fakeAuthorizer{namespaceAllowed: true, repositoryAllowed: true}, - repositorySvc: repositorySvc, - } - ctx := withUser(t.Context(), &models.User{ID: "user-1"}) - - request := api.CreateRepositoryRequest{NamespaceID: "namespace-1", Name: "sigma/demo"} - repositorySvc.EXPECT(). - CreateRepository(gomock.Any(), "user-1", request). - Return(&models.Repository{ID: "repository-1"}, nil) - result, err := server.repositoryCreate(ctx, toolRequest(map[string]any{ - "namespace_id": "namespace-1", - "name": "sigma/demo", - })) - require.NoError(t, err) - require.Equal(t, map[string]string{"id": "repository-1"}, result) - - repositorySvc.EXPECT(). - GetRepository(gomock.Any(), "repository-1"). - Return(&models.Repository{ID: "repository-1"}, nil) - result, err = server.repositoryGet(ctx, toolRequest(map[string]any{"id": "repository-1"})) - require.NoError(t, err) - require.Equal(t, &models.Repository{ID: "repository-1"}, result) -} - -func TestTagManifestRawGet(t *testing.T) { - ctrl := gomock.NewController(t) - tagSvc := tagsmocks.NewMockTagService(ctrl) - server := &Server{ - authorizer: fakeAuthorizer{repositoryAllowed: true}, - tagSvc: tagSvc, - } - ctx := withUser(t.Context(), &models.User{ID: "user-1"}) - - tagSvc.EXPECT(). - GetArtifactRaw(gomock.Any(), "sha256:digest"). - Return([]byte(`{"schemaVersion":2}`), nil) - result, err := server.tagManifestRawGet(ctx, toolRequest(map[string]any{ - "repository_id": "repository-1", - "digest": "sha256:digest", - })) - require.NoError(t, err) - require.Equal(t, map[string]string{"raw": `{"schemaVersion":2}`}, result) -} - -func TestArtifactTools(t *testing.T) { - ctrl := gomock.NewController(t) - repositorySvc := repositoriesmocks.NewMockRepositoryService(ctrl) - artifactSvc := artifactsmocks.NewMockArtifactService(ctrl) - server := &Server{ - authorizer: fakeAuthorizer{repositoryAllowed: true}, - repositorySvc: repositorySvc, - artifactSvc: artifactSvc, - } - ctx := withUser(t.Context(), &models.User{ID: "user-1"}) - - repositorySvc.EXPECT(). - GetRepositoryByName(gomock.Any(), "sigma/demo"). - Return(&models.Repository{ID: "repository-1"}, nil) - artifactSvc.EXPECT(). - GetArtifact(gomock.Any(), "sigma/demo", "sha256:digest"). - Return(&models.Artifact{ID: "artifact-1"}, nil) - result, err := server.artifactGet(ctx, toolRequest(map[string]any{ - "repository": "sigma/demo", - "digest": "sha256:digest", - })) - require.NoError(t, err) - require.Equal(t, &models.Artifact{ID: "artifact-1"}, result) - - repositorySvc.EXPECT(). - GetRepositoryByName(gomock.Any(), "sigma/demo"). - Return(&models.Repository{ID: "repository-1"}, nil) - artifactSvc.EXPECT(). - DeleteArtifact(gomock.Any(), "sigma/demo", "sha256:digest"). - Return(nil) - result, err = server.artifactDelete(ctx, toolRequest(map[string]any{ - "repository": "sigma/demo", - "digest": "sha256:digest", - })) - require.NoError(t, err) - require.Equal(t, okResponse(), result) -} - -func toolRequest(arguments map[string]any) mcp.CallToolRequest { - return mcp.CallToolRequest{ - Params: mcp.CallToolParams{Arguments: arguments}, - } -} diff --git a/pkg/server/mcp/context.go b/pkg/server/mcp/context.go deleted file mode 100644 index c7662c1f..00000000 --- a/pkg/server/mcp/context.go +++ /dev/null @@ -1,49 +0,0 @@ -// Copyright 2026 sigma -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package mcpserver - -import ( - "context" - - "github.com/go-sigma/sigma/pkg/dal/models" -) - -type contextKey string - -const ( - contextKeyUser contextKey = "sigma.mcp.user" - contextKeyClientIP contextKey = "sigma.mcp.client_ip" - contextKeyUserAgent contextKey = "sigma.mcp.user_agent" -) - -func withUser(ctx context.Context, user *models.User) context.Context { - return context.WithValue(ctx, contextKeyUser, user) -} - -func userFromContext(ctx context.Context) (*models.User, bool) { - user, ok := ctx.Value(contextKeyUser).(*models.User) - return user, ok && user != nil -} - -func withClientInfo(ctx context.Context, clientIP, userAgent string) context.Context { - ctx = context.WithValue(ctx, contextKeyClientIP, clientIP) - return context.WithValue(ctx, contextKeyUserAgent, userAgent) -} - -func clientInfoFromContext(ctx context.Context) (string, string) { - clientIP, _ := ctx.Value(contextKeyClientIP).(string) - userAgent, _ := ctx.Value(contextKeyUserAgent).(string) - return clientIP, userAgent -} diff --git a/pkg/server/mcp/errors.go b/pkg/server/mcp/errors.go deleted file mode 100644 index 04d9029f..00000000 --- a/pkg/server/mcp/errors.go +++ /dev/null @@ -1,61 +0,0 @@ -// Copyright 2026 sigma -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package mcpserver - -import ( - "errors" - - "github.com/go-sigma/sigma/pkg/server/errcode" -) - -var ( - errUnauthorized = errors.New("unauthorized") - errForbidden = errors.New("permission denied") -) - -func toolErrorMessage(err error) string { - if err == nil { - return "" - } - if errors.Is(err, errUnauthorized) { - return errUnauthorized.Error() - } - if errors.Is(err, errForbidden) { - return errForbidden.Error() - } - if e, ok := errcode.AsType[errcode.ErrCode](err); ok { - if e.Description != "" { - return e.Description - } - if e.Title != "" { - return e.Title - } - return e.Code - } - return "internal error" -} - -func statusCodeFromError(err error) int { - if err == nil { - return 200 - } - if errors.Is(err, errUnauthorized) || errors.Is(err, errForbidden) { - return 401 - } - if e, ok := errcode.AsType[errcode.ErrCode](err); ok { - return e.HTTPStatusCode - } - return 500 -} diff --git a/pkg/server/mcp/helpers_test.go b/pkg/server/mcp/helpers_test.go deleted file mode 100644 index 02f8afb7..00000000 --- a/pkg/server/mcp/helpers_test.go +++ /dev/null @@ -1,115 +0,0 @@ -// Copyright 2026 sigma -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package mcpserver - -import ( - "errors" - "net/http" - "testing" - - "github.com/mark3labs/mcp-go/mcp" - "github.com/stretchr/testify/require" - - "github.com/go-sigma/sigma/pkg/api" - "github.com/go-sigma/sigma/pkg/dal/models" - "github.com/go-sigma/sigma/pkg/server/errcode" -) - -func TestContextValues(t *testing.T) { - ctx := t.Context() - _, ok := userFromContext(ctx) - require.False(t, ok) - - user := &models.User{ID: "user-1"} - ctx = withUser(ctx, user) - got, ok := userFromContext(ctx) - require.True(t, ok) - require.Same(t, user, got) - - ctx = withClientInfo(ctx, "127.0.0.1", "sigma-test") - clientIP, userAgent := clientInfoFromContext(ctx) - require.Equal(t, "127.0.0.1", clientIP) - require.Equal(t, "sigma-test", userAgent) -} - -func TestToolErrorMapping(t *testing.T) { - tests := []struct { - name string - err error - wantText string - wantStatus int - }{ - {name: "nil", wantText: "", wantStatus: http.StatusOK}, - {name: "unauthorized", err: errUnauthorized, wantText: "unauthorized", wantStatus: http.StatusUnauthorized}, - {name: "forbidden", err: errForbidden, wantText: "permission denied", wantStatus: http.StatusUnauthorized}, - { - name: "typed error", - err: errcode.HTTPErrCodeBadRequest.Detail("invalid input"), - wantText: "invalid input", - wantStatus: http.StatusBadRequest, - }, - {name: "internal", err: errors.New("sensitive detail"), wantText: "internal error", wantStatus: http.StatusInternalServerError}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - require.Equal(t, tt.wantText, toolErrorMessage(tt.err)) - require.Equal(t, tt.wantStatus, statusCodeFromError(tt.err)) - }) - } -} - -func TestResponseHelpers(t *testing.T) { - page := 2 - limit := 20 - pagination := api.Pagination{Page: &page, Limit: &limit} - - response := listResponse([]string{"one"}, 1, pagination) - require.Equal(t, []string{"one"}, response["items"]) - require.Equal(t, int64(1), response["total"]) - require.Equal(t, pagination, response["pagination"]) - require.Equal(t, map[string]bool{"ok": true}, okResponse()) -} - -func TestToolArgumentHelpers(t *testing.T) { - req := mcp.CallToolRequest{ - Params: mcp.CallToolParams{ - Arguments: map[string]any{"name": "sigma", "count": float64(3)}, - }, - } - type arguments struct { - Name string `json:"name"` - Count int `json:"count"` - } - got, err := bindArguments[arguments](req) - require.NoError(t, err) - require.Equal(t, arguments{Name: "sigma", Count: 3}, got) - - invalid := mcp.CallToolRequest{Params: mcp.CallToolParams{Arguments: map[string]any{"count": "invalid"}}} - _, err = bindArguments[arguments](invalid) - require.Error(t, err) - - require.Equal(t, "second", stringArgument(map[string]any{ - "first": 123, - "second": "second", - }, "first", "second")) - require.Empty(t, stringArgument(nil, "missing")) -} - -func TestNormalizeKey(t *testing.T) { - require.Equal(t, "authorization", normalizeKey("Authorization")) - require.Equal(t, "privatekey", normalizeKey("private_key")) - require.Equal(t, "scmtoken", normalizeKey("scm-token")) -} diff --git a/pkg/server/mcp/metrics.go b/pkg/server/mcp/metrics.go deleted file mode 100644 index e0971b6c..00000000 --- a/pkg/server/mcp/metrics.go +++ /dev/null @@ -1,64 +0,0 @@ -// Copyright 2026 sigma -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package mcpserver - -import ( - "time" - - "github.com/prometheus/client_golang/prometheus" -) - -const ( - toolResultOK = "ok" - toolResultError = "error" -) - -var ( - mcpToolCallsTotal = prometheus.NewCounterVec( - prometheus.CounterOpts{ - Name: "sigma_mcp_tool_calls_total", - Help: "Total number of MCP tool calls.", - }, - []string{"tool", "result"}, - ) - mcpToolDuration = prometheus.NewHistogramVec( - prometheus.HistogramOpts{ - Name: "sigma_mcp_tool_duration_seconds", - Help: "MCP tool call duration in seconds.", - Buckets: prometheus.DefBuckets, - }, - []string{"tool"}, - ) - mcpAuthFailuresTotal = prometheus.NewCounterVec( - prometheus.CounterOpts{ - Name: "sigma_mcp_auth_failures_total", - Help: "Total number of MCP authentication failures.", - }, - []string{"reason"}, - ) -) - -func init() { - prometheus.MustRegister(mcpToolCallsTotal, mcpToolDuration, mcpAuthFailuresTotal) -} - -func recordToolCall(toolName, result string, startedAt time.Time) { - mcpToolCallsTotal.WithLabelValues(toolName, result).Inc() - mcpToolDuration.WithLabelValues(toolName).Observe(time.Since(startedAt).Seconds()) -} - -func recordAuthFailure(reason string) { - mcpAuthFailuresTotal.WithLabelValues(reason).Inc() -} diff --git a/pkg/server/mcp/resources.go b/pkg/server/mcp/resources.go deleted file mode 100644 index 16d82a0b..00000000 --- a/pkg/server/mcp/resources.go +++ /dev/null @@ -1,102 +0,0 @@ -// Copyright 2026 sigma -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package mcpserver - -import ( - "context" - "encoding/json/v2" - - "github.com/mark3labs/mcp-go/mcp" - mcpsdk "github.com/mark3labs/mcp-go/server" -) - -func (s *Server) registerResources(mcpServer *mcpsdk.MCPServer) { - mcpServer.AddResource( - mcp.NewResource( - "sigma://system/version", - "Sigma Version", - mcp.WithMIMEType("application/json"), - mcp.WithResourceDescription("Sigma build version information"), - ), - s.systemVersionResource, - ) - mcpServer.AddResource( - mcp.NewResource( - "sigma://system/capabilities", - "Sigma MCP Capabilities", - mcp.WithMIMEType("application/json"), - mcp.WithResourceDescription("The MCP tools exposed by Sigma"), - ), - s.capabilitiesResource, - ) -} - -func (s *Server) systemVersionResource(ctx context.Context, _ mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) { - version, err := s.systemsSvc.GetVersion(ctx) - if err != nil { - return nil, err - } - return jsonResource("sigma://system/version", version) -} - -func (s *Server) capabilitiesResource(_ context.Context, _ mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) { - capabilities := map[string]any{ - "tools": []string{ - "system-version-get", - "system-endpoint-get", - "system-config-summary-get", - "namespace-list", - "namespace-get", - "namespace-create", - "namespace-update", - "namespace-delete", - "namespace-member-list", - "namespace-member-add", - "namespace-member-update", - "namespace-member-delete", - "namespace-member-self-get", - "repository-list", - "repository-get", - "repository-create", - "repository-update", - "repository-delete", - "tag-list", - "tag-get", - "tag-delete", - "tag-manifest-raw-get", - "artifact-list", - "artifact-get", - "artifact-delete", - }, - "auth": map[string]any{ - "type": "basic", - }, - } - return jsonResource("sigma://system/capabilities", capabilities) -} - -func jsonResource(uri string, value any) ([]mcp.ResourceContents, error) { - data, err := json.Marshal(value) - if err != nil { - return nil, err - } - return []mcp.ResourceContents{ - mcp.TextResourceContents{ - URI: uri, - MIMEType: "application/json", - Text: string(data), - }, - }, nil -} diff --git a/pkg/server/mcp/response.go b/pkg/server/mcp/response.go deleted file mode 100644 index 8760330a..00000000 --- a/pkg/server/mcp/response.go +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright 2026 sigma -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package mcpserver - -import "github.com/go-sigma/sigma/pkg/api" - -func listResponse(items any, total int64, pagination api.Pagination) map[string]any { - return map[string]any{ - "items": items, - "total": total, - "pagination": pagination, - } -} - -func okResponse() map[string]bool { - return map[string]bool{"ok": true} -} diff --git a/pkg/server/mcp/server.go b/pkg/server/mcp/server.go deleted file mode 100644 index 2129ff71..00000000 --- a/pkg/server/mcp/server.go +++ /dev/null @@ -1,136 +0,0 @@ -// Copyright 2026 sigma -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package mcpserver - -import ( - "context" - "log/slog" - "net/http" - "strings" - - "github.com/gin-gonic/gin" - mcpsdk "github.com/mark3labs/mcp-go/server" - "go.uber.org/dig" - - "github.com/go-sigma/sigma/pkg/api/enums" - "github.com/go-sigma/sigma/pkg/authz" - "github.com/go-sigma/sigma/pkg/config" - "github.com/go-sigma/sigma/pkg/consts" - "github.com/go-sigma/sigma/pkg/dal/models" - repoaudit "github.com/go-sigma/sigma/pkg/dal/repository/audit" - "github.com/go-sigma/sigma/pkg/server/errcode" - svcartifacts "github.com/go-sigma/sigma/pkg/service/artifacts" - svcnamespaces "github.com/go-sigma/sigma/pkg/service/namespaces" - svcrepositories "github.com/go-sigma/sigma/pkg/service/repositories" - svcsystems "github.com/go-sigma/sigma/pkg/service/systems" - svctags "github.com/go-sigma/sigma/pkg/service/tags" - "github.com/go-sigma/sigma/pkg/utils" - "github.com/go-sigma/sigma/pkg/version" -) - -// Params declares dependencies needed to register the MCP server. -type Params struct { - dig.In - - Config *config.Configuration - Engine *gin.Engine - AuditRepository repoaudit.AuditRepository - Authorizer authz.Authorizer - SystemsSvc svcsystems.Service - NamespaceSvc svcnamespaces.Service - RepositorySvc svcrepositories.Service - TagSvc svctags.Service - ArtifactSvc svcartifacts.Service -} - -type Server struct { - config *config.Configuration - auditRepository repoaudit.AuditRepository - authorizer authz.Authorizer - systemsSvc svcsystems.Service - namespaceSvc svcnamespaces.Service - repositorySvc svcrepositories.Service - tagSvc svctags.Service - artifactSvc svcartifacts.Service -} - -// Register registers the embedded MCP HTTP endpoint when it is enabled. -func Register(digCon *dig.Container) error { - return digCon.Invoke(func(params Params) error { - if !params.Config.MCP.Enabled { - return nil - } - serverObj := &Server{ - config: params.Config, - auditRepository: params.AuditRepository, - authorizer: params.Authorizer, - systemsSvc: params.SystemsSvc, - namespaceSvc: params.NamespaceSvc, - repositorySvc: params.RepositorySvc, - tagSvc: params.TagSvc, - artifactSvc: params.ArtifactSvc, - } - handler := serverObj.newHTTPHandler() - params.Engine.Any(params.Config.MCP.Path, func(c *gin.Context) { - user, ok := utils.GetFromCtx[*models.User](c, consts.ContextUser) - if !ok || user == nil || user.Role == enums.UserRoleAnonymous { - recordAuthFailure("missing_user") - errcode.NewHTTPError(c, errcode.HTTPErrCodeUnauthorized) - return - } - if !strings.HasPrefix(c.GetHeader(consts.HeaderAuthorization), "Basic ") { - recordAuthFailure("non_basic_auth") - c.Header(consts.HeaderWWWAuthenticate, `Basic realm="sigma-mcp"`) - errcode.NewHTTPError(c, errcode.HTTPErrCodeUnauthorized, "MCP requires Basic Auth") - return - } - if params.Config.MCP.MaxRequestBody > 0 && c.Request.Body != nil { - c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, params.Config.MCP.MaxRequestBody) - } - ctx := withUser(c.Request.Context(), user) - ctx = withClientInfo(ctx, c.ClientIP(), c.Request.UserAgent()) - handler.ServeHTTP(c.Writer, c.Request.WithContext(ctx)) - }) - slog.Info("mcp server registered", "path", params.Config.MCP.Path) - return nil - }) -} - -func (s *Server) newHTTPHandler() http.Handler { - mcpServer := mcpsdk.NewMCPServer( - consts.AppName, - version.Version, - mcpsdk.WithToolCapabilities(true), - mcpsdk.WithResourceCapabilities(true, false), - mcpsdk.WithInstructions("Sigma MCP exposes registry management tools. Every request must use Sigma Basic Auth."), - ) - s.registerSystemTools(mcpServer) - s.registerNamespaceTools(mcpServer) - s.registerRepositoryTools(mcpServer) - s.registerTagTools(mcpServer) - s.registerArtifactTools(mcpServer) - s.registerResources(mcpServer) - return mcpsdk.NewStreamableHTTPServer( - mcpServer, - mcpsdk.WithStateLess(true), - mcpsdk.WithHTTPContextFunc(func(ctx context.Context, r *http.Request) context.Context { - if user, ok := userFromContext(r.Context()); ok { - ctx = withUser(ctx, user) - } - clientIP, userAgent := clientInfoFromContext(r.Context()) - return withClientInfo(ctx, clientIP, userAgent) - }), - ) -} diff --git a/pkg/server/mcp/tools.go b/pkg/server/mcp/tools.go deleted file mode 100644 index eeb04d9a..00000000 --- a/pkg/server/mcp/tools.go +++ /dev/null @@ -1,83 +0,0 @@ -// Copyright 2026 sigma -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package mcpserver - -import ( - "context" - "log/slog" - "time" - - "github.com/mark3labs/mcp-go/mcp" - mcpsdk "github.com/mark3labs/mcp-go/server" - - "github.com/go-sigma/sigma/pkg/server/errcode" -) - -type toolFunc func(context.Context, mcp.CallToolRequest) (any, error) - -func (s *Server) addTool(mcpServer *mcpsdk.MCPServer, name, description string, write bool, handler toolFunc) { - tool := mcp.NewTool(name, mcp.WithDescription(description), mcp.WithSchemaAdditionalProperties(true)) - tool.Annotations = mcp.ToolAnnotation{ - ReadOnlyHint: new(!write), - DestructiveHint: new(write), - OpenWorldHint: new(false), - } - mcpServer.AddTool(tool, s.wrapTool(name, write, handler)) -} - -func (s *Server) wrapTool(name string, write bool, handler toolFunc) mcpsdk.ToolHandlerFunc { - return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { - startedAt := time.Now() - user, ok := userFromContext(ctx) - if !ok { - recordToolCall(name, toolResultError, startedAt) - return mcp.NewToolResultError(errUnauthorized.Error()), nil - } - - ctx, cancel := context.WithTimeout(ctx, s.config.MCP.ToolTimeout) - defer cancel() - - data, err := handler(ctx, req) - statusCode := statusCodeFromError(err) - if write && s.config.MCP.AuditWritesEnabled() { - auditToolCall(ctx, s.auditRepository, s.config.MCP.Path, user, name, req, startedAt, statusCode) - } - if err != nil { - slog.Error("mcp tool failed", "tool", name, "user_id", user.ID, "err", err) - recordToolCall(name, toolResultError, startedAt) - return mcp.NewToolResultError(toolErrorMessage(err)), nil - } - - recordToolCall(name, toolResultOK, startedAt) - return mcp.NewToolResultStructuredOnly(map[string]any{"data": data}), nil - } -} - -func bindArguments[T any](req mcp.CallToolRequest) (T, error) { - var target T - if e := req.BindArguments(&target); e != nil { - return target, errcode.HTTPErrCodeBadRequest.Detail(e.Error()) - } - return target, nil -} - -func stringArgument(args map[string]any, names ...string) string { - for _, name := range names { - if val, ok := args[name].(string); ok { - return val - } - } - return "" -} diff --git a/pkg/server/mcp/tools_artifact.go b/pkg/server/mcp/tools_artifact.go deleted file mode 100644 index 2b7fc030..00000000 --- a/pkg/server/mcp/tools_artifact.go +++ /dev/null @@ -1,86 +0,0 @@ -// Copyright 2026 sigma -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package mcpserver - -import ( - "context" - - "github.com/mark3labs/mcp-go/mcp" - mcpsdk "github.com/mark3labs/mcp-go/server" - - "github.com/go-sigma/sigma/pkg/api" - "github.com/go-sigma/sigma/pkg/api/enums" -) - -func (s *Server) registerArtifactTools(mcpServer *mcpsdk.MCPServer) { - s.addTool(mcpServer, "artifact-list", "List repository artifacts", false, s.artifactList) - s.addTool(mcpServer, "artifact-get", "Get an artifact by repository name and digest", false, s.artifactGet) - s.addTool(mcpServer, "artifact-delete", "Delete an artifact by repository name and digest", true, s.artifactDelete) -} - -func (s *Server) artifactList(ctx context.Context, req mcp.CallToolRequest) (any, error) { - user, _ := userFromContext(ctx) - args, err := bindArguments[api.ListArtifactRequest](req) - if err != nil { - return nil, err - } - repositoryObj, err := s.repositorySvc.GetRepositoryByName(ctx, args.Repository) - if err != nil { - return nil, err - } - if e := s.authorizeRepository(ctx, user, repositoryObj.ID, enums.AuthRead); e != nil { - return nil, e - } - artifacts, total, err := s.artifactSvc.ListArtifacts(ctx, args) - if err != nil { - return nil, err - } - return listResponse(artifacts, total, args.Pagination), nil -} - -func (s *Server) artifactGet(ctx context.Context, req mcp.CallToolRequest) (any, error) { - user, _ := userFromContext(ctx) - args, err := bindArguments[api.GetArtifactRequest](req) - if err != nil { - return nil, err - } - repositoryObj, err := s.repositorySvc.GetRepositoryByName(ctx, args.Repository) - if err != nil { - return nil, err - } - if e := s.authorizeRepository(ctx, user, repositoryObj.ID, enums.AuthRead); e != nil { - return nil, e - } - return s.artifactSvc.GetArtifact(ctx, args.Repository, args.Digest) -} - -func (s *Server) artifactDelete(ctx context.Context, req mcp.CallToolRequest) (any, error) { - user, _ := userFromContext(ctx) - args, err := bindArguments[api.DeleteArtifactRequest](req) - if err != nil { - return nil, err - } - repositoryObj, err := s.repositorySvc.GetRepositoryByName(ctx, args.Repository) - if err != nil { - return nil, err - } - if e := s.authorizeRepository(ctx, user, repositoryObj.ID, enums.AuthManage); e != nil { - return nil, e - } - if e := s.artifactSvc.DeleteArtifact(ctx, args.Repository, args.Digest); e != nil { - return nil, e - } - return okResponse(), nil -} diff --git a/pkg/server/mcp/tools_namespace.go b/pkg/server/mcp/tools_namespace.go deleted file mode 100644 index 1aed280d..00000000 --- a/pkg/server/mcp/tools_namespace.go +++ /dev/null @@ -1,189 +0,0 @@ -// Copyright 2026 sigma -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package mcpserver - -import ( - "context" - - "github.com/mark3labs/mcp-go/mcp" - mcpsdk "github.com/mark3labs/mcp-go/server" - - "github.com/go-sigma/sigma/pkg/api" - "github.com/go-sigma/sigma/pkg/api/enums" -) - -func (s *Server) registerNamespaceTools(mcpServer *mcpsdk.MCPServer) { - s.addTool(mcpServer, "namespace-list", "List namespaces visible to the authenticated user", false, s.namespaceList) - s.addTool(mcpServer, "namespace-get", "Get a namespace by id", false, s.namespaceGet) - s.addTool(mcpServer, "namespace-create", "Create a namespace", true, s.namespaceCreate) - s.addTool(mcpServer, "namespace-update", "Update a namespace", true, s.namespaceUpdate) - s.addTool(mcpServer, "namespace-delete", "Delete a namespace", true, s.namespaceDelete) - s.addTool(mcpServer, "namespace-member-list", "List namespace members", false, s.namespaceMemberList) - s.addTool(mcpServer, "namespace-member-add", "Add a namespace member", true, s.namespaceMemberAdd) - s.addTool(mcpServer, "namespace-member-update", "Update a namespace member role", true, s.namespaceMemberUpdate) - s.addTool(mcpServer, "namespace-member-delete", "Delete a namespace member", true, s.namespaceMemberDelete) - s.addTool(mcpServer, "namespace-member-self-get", "Get authenticated user's namespace membership", false, s.namespaceMemberSelfGet) -} - -func (s *Server) namespaceList(ctx context.Context, req mcp.CallToolRequest) (any, error) { - user, _ := userFromContext(ctx) - args, err := bindArguments[api.ListNamespaceRequest](req) - if err != nil { - return nil, err - } - items, total, err := s.namespaceSvc.ListNamespaces(ctx, user.ID, args.Name, args.Pagination, args.Sortable) - if err != nil { - return nil, err - } - return listResponse(items, total, args.Pagination), nil -} - -func (s *Server) namespaceGet(ctx context.Context, req mcp.CallToolRequest) (any, error) { - user, _ := userFromContext(ctx) - args, err := bindArguments[api.GetNamespaceRequest](req) - if err != nil { - return nil, err - } - if e := s.authorizeNamespace(ctx, user, args.ID, enums.AuthRead); e != nil { - return nil, e - } - namespaceObj, repositoryCount, tagCount, err := s.namespaceSvc.GetNamespace(ctx, args.ID) - if err != nil { - return nil, err - } - return map[string]any{ - "namespace": namespaceObj, - "repository_count": repositoryCount, - "tag_count": tagCount, - }, nil -} - -func (s *Server) namespaceCreate(ctx context.Context, req mcp.CallToolRequest) (any, error) { - user, _ := userFromContext(ctx) - args, err := bindArguments[api.PostNamespaceRequest](req) - if err != nil { - return nil, err - } - namespaceObj, err := s.namespaceSvc.CreateNamespace(ctx, user.ID, args) - if err != nil { - return nil, err - } - return map[string]string{"id": namespaceObj.ID}, nil -} - -func (s *Server) namespaceUpdate(ctx context.Context, req mcp.CallToolRequest) (any, error) { - user, _ := userFromContext(ctx) - args, err := bindArguments[api.UpdateNamespaceRequest](req) - if err != nil { - return nil, err - } - if e := s.authorizeNamespace(ctx, user, args.ID, enums.AuthAdmin); e != nil { - return nil, e - } - if e := s.namespaceSvc.UpdateNamespace(ctx, user.ID, args.ID, args); e != nil { - return nil, e - } - return okResponse(), nil -} - -func (s *Server) namespaceDelete(ctx context.Context, req mcp.CallToolRequest) (any, error) { - user, _ := userFromContext(ctx) - args, err := bindArguments[api.DeleteNamespaceRequest](req) - if err != nil { - return nil, err - } - if e := s.authorizeNamespace(ctx, user, args.ID, enums.AuthAdmin); e != nil { - return nil, e - } - if e := s.namespaceSvc.DeleteNamespace(ctx, user.ID, args.ID); e != nil { - return nil, e - } - return okResponse(), nil -} - -func (s *Server) namespaceMemberList(ctx context.Context, req mcp.CallToolRequest) (any, error) { - user, _ := userFromContext(ctx) - args, err := bindArguments[api.ListNamespaceMemberRequest](req) - if err != nil { - return nil, err - } - if e := s.authorizeNamespace(ctx, user, args.NamespaceID, enums.AuthRead); e != nil { - return nil, e - } - members, total, err := s.namespaceSvc.ListNamespaceMembers(ctx, args.NamespaceID, args.Name, args.Pagination, args.Sortable) - if err != nil { - return nil, err - } - return listResponse(members, total, args.Pagination), nil -} - -func (s *Server) namespaceMemberAdd(ctx context.Context, req mcp.CallToolRequest) (any, error) { - user, _ := userFromContext(ctx) - args, err := bindArguments[api.AddNamespaceMemberRequest](req) - if err != nil { - return nil, err - } - if e := s.authorizeNamespace(ctx, user, args.NamespaceID, enums.AuthAdmin); e != nil { - return nil, e - } - member, err := s.namespaceSvc.AddNamespaceMember(ctx, user.ID, args.NamespaceID, args.UserID, args.Role) - if err != nil { - return nil, err - } - return map[string]string{"id": member.ID}, nil -} - -func (s *Server) namespaceMemberUpdate(ctx context.Context, req mcp.CallToolRequest) (any, error) { - user, _ := userFromContext(ctx) - args, err := bindArguments[api.UpdateNamespaceMemberRequest](req) - if err != nil { - return nil, err - } - if e := s.authorizeNamespace(ctx, user, args.NamespaceID, enums.AuthAdmin); e != nil { - return nil, e - } - if e := s.namespaceSvc.UpdateNamespaceMember(ctx, user.ID, args.NamespaceID, args.UserID, args.Role); e != nil { - return nil, e - } - return okResponse(), nil -} - -func (s *Server) namespaceMemberDelete(ctx context.Context, req mcp.CallToolRequest) (any, error) { - user, _ := userFromContext(ctx) - args, err := bindArguments[api.DeleteNamespaceMemberRequest](req) - if err != nil { - return nil, err - } - if e := s.authorizeNamespace(ctx, user, args.NamespaceID, enums.AuthAdmin); e != nil { - return nil, e - } - if e := s.namespaceSvc.DeleteNamespaceMember(ctx, user.ID, args.NamespaceID, args.UserID); e != nil { - return nil, e - } - return okResponse(), nil -} - -func (s *Server) namespaceMemberSelfGet(ctx context.Context, req mcp.CallToolRequest) (any, error) { - user, _ := userFromContext(ctx) - args, err := bindArguments[api.GetNamespaceMemberSelfRequest](req) - if err != nil { - return nil, err - } - member, err := s.namespaceSvc.GetNamespaceMember(ctx, args.NamespaceID, user.ID) - if err != nil { - return nil, err - } - return member, nil -} diff --git a/pkg/server/mcp/tools_repository.go b/pkg/server/mcp/tools_repository.go deleted file mode 100644 index 75d92549..00000000 --- a/pkg/server/mcp/tools_repository.go +++ /dev/null @@ -1,112 +0,0 @@ -// Copyright 2026 sigma -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package mcpserver - -import ( - "context" - - "github.com/mark3labs/mcp-go/mcp" - mcpsdk "github.com/mark3labs/mcp-go/server" - - "github.com/go-sigma/sigma/pkg/api" - "github.com/go-sigma/sigma/pkg/api/enums" -) - -func (s *Server) registerRepositoryTools(mcpServer *mcpsdk.MCPServer) { - s.addTool(mcpServer, "repository-list", "List repositories in a namespace", false, s.repositoryList) - s.addTool(mcpServer, "repository-get", "Get a repository by id", false, s.repositoryGet) - s.addTool(mcpServer, "repository-create", "Create a repository", true, s.repositoryCreate) - s.addTool(mcpServer, "repository-update", "Update a repository", true, s.repositoryUpdate) - s.addTool(mcpServer, "repository-delete", "Delete a repository", true, s.repositoryDelete) -} - -func (s *Server) repositoryList(ctx context.Context, req mcp.CallToolRequest) (any, error) { - user, _ := userFromContext(ctx) - args, err := bindArguments[api.ListRepositoryRequest](req) - if err != nil { - return nil, err - } - if e := s.authorizeNamespace(ctx, user, args.NamespaceID, enums.AuthRead); e != nil { - return nil, e - } - repositories, builders, total, err := s.repositorySvc.ListRepositories( - ctx, user.ID, args.NamespaceID, args.Name, args.Pagination, args.Sortable, - ) - if err != nil { - return nil, err - } - return listResponse(map[string]any{ - "repositories": repositories, - "builders": builders, - }, total, args.Pagination), nil -} - -func (s *Server) repositoryGet(ctx context.Context, req mcp.CallToolRequest) (any, error) { - user, _ := userFromContext(ctx) - args, err := bindArguments[api.GetRepositoryRequest](req) - if err != nil { - return nil, err - } - if e := s.authorizeRepository(ctx, user, args.ID, enums.AuthRead); e != nil { - return nil, e - } - return s.repositorySvc.GetRepository(ctx, args.ID) -} - -func (s *Server) repositoryCreate(ctx context.Context, req mcp.CallToolRequest) (any, error) { - user, _ := userFromContext(ctx) - args, err := bindArguments[api.CreateRepositoryRequest](req) - if err != nil { - return nil, err - } - if e := s.authorizeNamespace(ctx, user, args.NamespaceID, enums.AuthManage); e != nil { - return nil, e - } - repository, err := s.repositorySvc.CreateRepository(ctx, user.ID, args) - if err != nil { - return nil, err - } - return map[string]string{"id": repository.ID}, nil -} - -func (s *Server) repositoryUpdate(ctx context.Context, req mcp.CallToolRequest) (any, error) { - user, _ := userFromContext(ctx) - args, err := bindArguments[api.UpdateRepositoryRequest](req) - if err != nil { - return nil, err - } - if e := s.authorizeRepository(ctx, user, args.ID, enums.AuthManage); e != nil { - return nil, e - } - if e := s.repositorySvc.UpdateRepository(ctx, user.ID, args); e != nil { - return nil, e - } - return okResponse(), nil -} - -func (s *Server) repositoryDelete(ctx context.Context, req mcp.CallToolRequest) (any, error) { - user, _ := userFromContext(ctx) - args, err := bindArguments[api.DeleteRepositoryRequest](req) - if err != nil { - return nil, err - } - if e := s.authorizeRepository(ctx, user, args.ID, enums.AuthManage); e != nil { - return nil, e - } - if e := s.repositorySvc.DeleteRepository(ctx, user.ID, args.NamespaceID, args.ID); e != nil { - return nil, e - } - return okResponse(), nil -} diff --git a/pkg/server/mcp/tools_system.go b/pkg/server/mcp/tools_system.go deleted file mode 100644 index 305161df..00000000 --- a/pkg/server/mcp/tools_system.go +++ /dev/null @@ -1,71 +0,0 @@ -// Copyright 2026 sigma -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package mcpserver - -import ( - "context" - - "github.com/mark3labs/mcp-go/mcp" - mcpsdk "github.com/mark3labs/mcp-go/server" -) - -func (s *Server) registerSystemTools(mcpServer *mcpsdk.MCPServer) { - s.addTool(mcpServer, "system-version-get", "Get Sigma build version information", false, s.systemVersionGet) - s.addTool(mcpServer, "system-endpoint-get", "Get Sigma public HTTP endpoint", false, s.systemEndpointGet) - s.addTool(mcpServer, "system-config-summary-get", "Get a redacted Sigma configuration summary", false, s.systemConfigSummaryGet) -} - -func (s *Server) systemVersionGet(ctx context.Context, _ mcp.CallToolRequest) (any, error) { - return s.systemsSvc.GetVersion(ctx) -} - -func (s *Server) systemEndpointGet(ctx context.Context, _ mcp.CallToolRequest) (any, error) { - endpoint, err := s.systemsSvc.GetEndpoint(ctx) - if err != nil { - return nil, err - } - return map[string]string{"endpoint": endpoint}, nil -} - -func (s *Server) systemConfigSummaryGet(_ context.Context, _ mcp.CallToolRequest) (any, error) { - cfg := s.config - return map[string]any{ - "database": map[string]any{ - "type": cfg.Database.Type, - }, - "storage": map[string]any{ - "type": cfg.Storage.Type, - "redirect": cfg.Storage.Redirect, - }, - "proxy": map[string]any{ - "enabled": cfg.Proxy.Enabled, - }, - "daemon": map[string]any{ - "builder": cfg.Daemon.Builder.Enabled, - }, - "audit": map[string]any{ - "enabled": cfg.Audit.IsEnabled(), - "record_list_get": cfg.Audit.RecordListGet, - }, - "mcp": map[string]any{ - "enabled": cfg.MCP.Enabled, - "path": cfg.MCP.Path, - "transport": cfg.MCP.Transport, - "audit_writes": cfg.MCP.AuditWritesEnabled(), - "tool_timeout": cfg.MCP.ToolTimeout.String(), - "max_request_body": cfg.MCP.MaxRequestBody, - }, - }, nil -} diff --git a/pkg/server/mcp/tools_tag.go b/pkg/server/mcp/tools_tag.go deleted file mode 100644 index 1dd53f87..00000000 --- a/pkg/server/mcp/tools_tag.go +++ /dev/null @@ -1,96 +0,0 @@ -// Copyright 2026 sigma -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package mcpserver - -import ( - "context" - - "github.com/mark3labs/mcp-go/mcp" - mcpsdk "github.com/mark3labs/mcp-go/server" - - "github.com/go-sigma/sigma/pkg/api" - "github.com/go-sigma/sigma/pkg/api/enums" -) - -type tagManifestRawGetRequest struct { - RepositoryID string `json:"repository_id"` - Digest string `json:"digest"` -} - -func (s *Server) registerTagTools(mcpServer *mcpsdk.MCPServer) { - s.addTool(mcpServer, "tag-list", "List repository tags", false, s.tagList) - s.addTool(mcpServer, "tag-get", "Get a tag by id", false, s.tagGet) - s.addTool(mcpServer, "tag-delete", "Delete a tag by id", true, s.tagDelete) - s.addTool(mcpServer, "tag-manifest-raw-get", "Get raw manifest content by artifact digest", false, s.tagManifestRawGet) -} - -func (s *Server) tagList(ctx context.Context, req mcp.CallToolRequest) (any, error) { - user, _ := userFromContext(ctx) - args, err := bindArguments[api.ListTagRequest](req) - if err != nil { - return nil, err - } - if e := s.authorizeRepository(ctx, user, args.RepositoryID, enums.AuthRead); e != nil { - return nil, e - } - tags, total, err := s.tagSvc.ListTags(ctx, args.NamespaceID, args.RepositoryID, args.Name, args.Type, args.Pagination, args.Sortable) - if err != nil { - return nil, err - } - return listResponse(tags, total, args.Pagination), nil -} - -func (s *Server) tagGet(ctx context.Context, req mcp.CallToolRequest) (any, error) { - user, _ := userFromContext(ctx) - args, err := bindArguments[api.GetTagRequest](req) - if err != nil { - return nil, err - } - if e := s.authorizeTag(ctx, user, args.ID, enums.AuthRead); e != nil { - return nil, e - } - return s.tagSvc.GetTag(ctx, args.ID) -} - -func (s *Server) tagDelete(ctx context.Context, req mcp.CallToolRequest) (any, error) { - user, _ := userFromContext(ctx) - args, err := bindArguments[api.DeleteTagRequest](req) - if err != nil { - return nil, err - } - if e := s.authorizeTag(ctx, user, args.ID, enums.AuthManage); e != nil { - return nil, e - } - if e := s.tagSvc.DeleteTag(ctx, args.NamespaceID, args.RepositoryID, args.ID); e != nil { - return nil, e - } - return okResponse(), nil -} - -func (s *Server) tagManifestRawGet(ctx context.Context, req mcp.CallToolRequest) (any, error) { - user, _ := userFromContext(ctx) - args, err := bindArguments[tagManifestRawGetRequest](req) - if err != nil { - return nil, err - } - if e := s.authorizeRepository(ctx, user, args.RepositoryID, enums.AuthRead); e != nil { - return nil, e - } - raw, err := s.tagSvc.GetArtifactRaw(ctx, args.Digest) - if err != nil { - return nil, err - } - return map[string]string{"raw": string(raw)}, nil -} diff --git a/pkg/server/mcp/tools_test.go b/pkg/server/mcp/tools_test.go deleted file mode 100644 index d0191b71..00000000 --- a/pkg/server/mcp/tools_test.go +++ /dev/null @@ -1,154 +0,0 @@ -// Copyright 2026 sigma -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package mcpserver - -import ( - "context" - "errors" - "testing" - "time" - - "github.com/mark3labs/mcp-go/mcp" - "github.com/stretchr/testify/require" - "go.uber.org/mock/gomock" - - "github.com/go-sigma/sigma/pkg/api" - "github.com/go-sigma/sigma/pkg/api/enums" - "github.com/go-sigma/sigma/pkg/config" - "github.com/go-sigma/sigma/pkg/dal/models" - "github.com/go-sigma/sigma/pkg/server/errcode" - systemsmocks "github.com/go-sigma/sigma/pkg/service/systems/mocks" -) - -func TestWrapTool(t *testing.T) { - server := &Server{config: &config.Configuration{MCP: config.ConfigurationMCP{ToolTimeout: time.Second}}} - request := mcp.CallToolRequest{} - - t.Run("missing user", func(t *testing.T) { - result, err := server.wrapTool("test", false, func(context.Context, mcp.CallToolRequest) (any, error) { - t.Fatal("handler should not be called") - return nil, nil - })(t.Context(), request) - require.NoError(t, err) - require.True(t, result.IsError) - require.Equal(t, "unauthorized", toolResultText(t, result)) - }) - - t.Run("success", func(t *testing.T) { - ctx := withUser(t.Context(), &models.User{ID: "user-1"}) - result, err := server.wrapTool("test", false, func(context.Context, mcp.CallToolRequest) (any, error) { - return map[string]string{"status": "ok"}, nil - })(ctx, request) - require.NoError(t, err) - require.False(t, result.IsError) - require.Equal(t, map[string]any{"data": map[string]string{"status": "ok"}}, result.StructuredContent) - }) - - t.Run("handler error", func(t *testing.T) { - ctx := withUser(t.Context(), &models.User{ID: "user-1"}) - result, err := server.wrapTool("test", false, func(context.Context, mcp.CallToolRequest) (any, error) { - return nil, errcode.HTTPErrCodeBadRequest.Detail("invalid input") - })(ctx, request) - require.NoError(t, err) - require.True(t, result.IsError) - require.Equal(t, "invalid input", toolResultText(t, result)) - }) -} - -func TestResources(t *testing.T) { - resources, err := jsonResource("sigma://test", map[string]string{"status": "ok"}) - require.NoError(t, err) - require.Len(t, resources, 1) - content, ok := resources[0].(mcp.TextResourceContents) - require.True(t, ok) - require.Equal(t, "sigma://test", content.URI) - require.Equal(t, "application/json", content.MIMEType) - require.JSONEq(t, `{"status":"ok"}`, content.Text) - - server := &Server{} - resources, err = server.capabilitiesResource(t.Context(), mcp.ReadResourceRequest{}) - require.NoError(t, err) - content, ok = resources[0].(mcp.TextResourceContents) - require.True(t, ok) - require.Contains(t, content.Text, `"namespace-list"`) - require.Contains(t, content.Text, `"artifact-delete"`) - require.Contains(t, content.Text, `"type":"basic"`) -} - -func TestNewHTTPHandler(t *testing.T) { - server := &Server{ - config: &config.Configuration{ - MCP: config.ConfigurationMCP{ToolTimeout: time.Second}, - }, - } - - require.NotNil(t, server.newHTTPHandler()) -} - -func TestSystemTools(t *testing.T) { - ctrl := gomock.NewController(t) - service := systemsmocks.NewMockSystemsService(ctrl) - service.EXPECT().GetVersion(gomock.Any()).Return(api.GetSystemVersionResponse{Version: "1.0.0"}, nil) - service.EXPECT().GetEndpoint(gomock.Any()).Return("https://sigma.example.test", nil) - server := &Server{ - systemsSvc: service, - config: &config.Configuration{ - Database: config.ConfigurationDatabase{Type: enums.DatabasePostgresql}, - Storage: config.ConfigurationStorage{Type: enums.StorageTypeS3, Redirect: true}, - Proxy: config.ConfigurationProxy{Enabled: true}, - MCP: config.ConfigurationMCP{ - Enabled: true, - Path: "/api/v1/mcp", - Transport: "streamable_http", - ToolTimeout: time.Second, - MaxRequestBody: 1024, - }, - }, - } - - version, err := server.systemVersionGet(t.Context(), mcp.CallToolRequest{}) - require.NoError(t, err) - require.Equal(t, api.GetSystemVersionResponse{Version: "1.0.0"}, version) - - endpoint, err := server.systemEndpointGet(t.Context(), mcp.CallToolRequest{}) - require.NoError(t, err) - require.Equal(t, map[string]string{"endpoint": "https://sigma.example.test"}, endpoint) - - summary, err := server.systemConfigSummaryGet(t.Context(), mcp.CallToolRequest{}) - require.NoError(t, err) - summaryMap, ok := summary.(map[string]any) - require.True(t, ok) - require.Contains(t, summaryMap, "database") - require.Contains(t, summaryMap, "storage") - require.Contains(t, summaryMap, "mcp") -} - -func TestSystemEndpointGetError(t *testing.T) { - ctrl := gomock.NewController(t) - service := systemsmocks.NewMockSystemsService(ctrl) - expectedErr := errors.New("failed") - service.EXPECT().GetEndpoint(gomock.Any()).Return("", expectedErr) - - _, err := (&Server{systemsSvc: service}).systemEndpointGet(t.Context(), mcp.CallToolRequest{}) - require.ErrorIs(t, err, expectedErr) -} - -func toolResultText(t *testing.T, result *mcp.CallToolResult) string { - t.Helper() - require.NotEmpty(t, result.Content) - content, ok := mcp.AsTextContent(result.Content[0]) - require.True(t, ok) - return content.Text -} diff --git a/tools/skill/sigma-api-operator/SKILL.md b/tools/skill/sigma-api-operator/SKILL.md index d58ae8c7..35e70cfc 100644 --- a/tools/skill/sigma-api-operator/SKILL.md +++ b/tools/skill/sigma-api-operator/SKILL.md @@ -1,17 +1,61 @@ --- name: "sigma-api-operator" -description: "Operates Sigma resources through the current OpenAPI spec. Invoke when external CLI agents need to inspect or manage a Sigma server." +description: "Operates Sigma resources through sigma-cli, falling back to the Sigma OpenAPI spec. Invoke when external CLI agents need to inspect or manage a Sigma server." --- # Sigma API Operator Use this skill when an external CLI agent, such as Claude Code, Copilot CLI, or a -similar automation tool, needs to inspect or manage Sigma resources through the -Sigma HTTP API. +similar automation tool, needs to inspect or manage Sigma resources. + +There are two ways to talk to Sigma. Prefer the CLI; use the HTTP API only for +resources the CLI does not wrap. + +| Path | Use for | +| --- | --- | +| `sigma-cli` | namespaces, repositories, tags, artifacts, members, users | +| HTTP API + `swagger.yaml` | builders, daemons, webhooks, validators, code repositories, tokens | This skill is distributed with `swagger.yaml`, which describes the Sigma REST API surface available to the agent. +## Preferred: sigma-cli + +Prefer `sigma-cli` whenever it covers the operation. It handles authentication +from a credentials file, prints JSON, and keeps the agent away from hand-built +URLs. + +```bash +sigma-cli login --server "${SIGMA_SERVER}" --username "${SIGMA_USERNAME}" --password "${SIGMA_PASSWORD}" +``` + +Commands accept IDs (and, for artifacts, names) as positional arguments: + +```bash +sigma-cli namespace list --page 1 --limit 10 +sigma-cli namespace get +sigma-cli namespace create library --visibility public +sigma-cli repo list +sigma-cli repo create library/alpine +sigma-cli tag list +sigma-cli artifact list +sigma-cli member add --role reader +sigma-cli user list --without-admin +``` + +Every command prints JSON on success and a formatted error body on failure, so +output composes with `jq`: + +```bash +sigma-cli namespace list --limit 100 | jq -r '.items[].name' +``` + +Run `sigma-cli --help` to confirm the flags of a subcommand before +using it. Do not guess flags. + +If a resource is not listed in `sigma-cli --help`, it is not implemented in the +CLI yet. Use the HTTP API workflow below instead. + ## API Specification Before calling Sigma APIs, load the packaged OpenAPI document: @@ -20,12 +64,6 @@ Before calling Sigma APIs, load the packaged OpenAPI document: swagger.yaml ``` -When working from a newer Sigma source checkout, refresh the packaged document: - -```bash -go tool swag init --output tools/skill/sigma-api-operator --outputTypes yaml -``` - Use the OpenAPI document as the source of truth for: - Available paths and HTTP methods. @@ -103,7 +141,7 @@ Common resource prefixes: | Daemons | `/daemons/gc-*` | | Webhooks | `/webhooks/` and `/webhooks/{webhook_id}/logs` | | Validators | `/validators/cron`, `/validators/password`, `/validators/reference`, `/validators/regexp`, `/validators/tag` | -| Code repositories | `/coderepos/*` and `/{provider}/repos/coderepos/*` | +| Code repositories | `/coderepos/{provider}` | ## Connection Settings @@ -118,7 +156,8 @@ SIGMA_PASSWORD=... Normalize `SIGMA_SERVER` by trimming a trailing slash before building request URLs. -Sigma management APIs use Basic Auth. Send Basic Auth on each request: +`sigma-cli` reads these on its own; for raw HTTP calls, send Basic Auth on each +request: ```bash curl --fail-with-body --silent --show-error \ @@ -129,7 +168,9 @@ curl --fail-with-body --silent --show-error \ Do not print passwords, tokens, private keys, access keys, secret keys, or raw Authorization headers. -## REST Operation Workflow +## HTTP Operation Workflow + +Use this only when `sigma-cli` has no matching command. 1. Read `swagger.yaml`. 2. Find the operation that matches the user's resource and action. @@ -156,58 +197,80 @@ Authorization headers. ## Common Resource Workflows -Always confirm the exact path, method, and schema in `swagger.yaml` before -executing these examples. +The resources below are **not** implemented in `sigma-cli`, so call them over +HTTP. Always confirm the exact path, method, and schema in `swagger.yaml` before +executing an example. -List namespaces: +Garbage collection rules are namespace scoped. Read the current rule: ```bash curl --fail-with-body --silent --show-error \ --user "${SIGMA_USERNAME}:${SIGMA_PASSWORD}" \ --header "Accept: application/json" \ - "${SIGMA_SERVER}/api/v1/namespaces/?page=1&limit=10" + "${SIGMA_SERVER}/api/v1/daemons/gc-artifact/${NAMESPACE_ID}/" ``` -Create a namespace: +Update the rule, then start a run: ```bash curl --fail-with-body --silent --show-error \ --user "${SIGMA_USERNAME}:${SIGMA_PASSWORD}" \ --header "Accept: application/json" \ --header "Content-Type: application/json" \ - --request POST \ - --data '{"name":"library","visibility":"public"}' \ - "${SIGMA_SERVER}/api/v1/namespaces/" -``` + --request PUT \ + --data '{"cron_rule":"0 2 * * *","cron_enabled":true,"retention_day":7}' \ + "${SIGMA_SERVER}/api/v1/daemons/gc-artifact/${NAMESPACE_ID}/" -List repositories in a namespace: - -```bash curl --fail-with-body --silent --show-error \ --user "${SIGMA_USERNAME}:${SIGMA_PASSWORD}" \ - --header "Accept: application/json" \ - "${SIGMA_SERVER}/api/v1/namespaces/${NAMESPACE_ID}/repositories/?page=1&limit=10" -``` - -Create a repository: - -```bash -curl --fail-with-body --silent --show-error \ - --user "${SIGMA_USERNAME}:${SIGMA_PASSWORD}" \ - --header "Accept: application/json" \ --header "Content-Type: application/json" \ --request POST \ - --data '{"name":"library/alpine","description":"Alpine images"}' \ - "${SIGMA_SERVER}/api/v1/namespaces/${NAMESPACE_ID}/repositories/" + --data '{}' \ + "${SIGMA_SERVER}/api/v1/daemons/gc-artifact/${NAMESPACE_ID}/runners/" ``` -List tags: +List webhooks and their delivery logs: ```bash curl --fail-with-body --silent --show-error \ --user "${SIGMA_USERNAME}:${SIGMA_PASSWORD}" \ --header "Accept: application/json" \ - "${SIGMA_SERVER}/api/v1/namespaces/${NAMESPACE_ID}/tags/?repository_id=${REPOSITORY_ID}&page=1&limit=10" + "${SIGMA_SERVER}/api/v1/webhooks/?page=1&limit=10" + +curl --fail-with-body --silent --show-error \ + --user "${SIGMA_USERNAME}:${SIGMA_PASSWORD}" \ + --header "Accept: application/json" \ + "${SIGMA_SERVER}/api/v1/webhooks/${WEBHOOK_ID}/logs/?page=1&limit=10" +``` + +Validate a value before saving it. Validators only check input; they do not read +server state, and they return `204 No Content` on success: + +```bash +curl --fail-with-body --silent --show-error \ + --user "${SIGMA_USERNAME}:${SIGMA_PASSWORD}" \ + --header "Content-Type: application/json" \ + --request POST \ + --data '{"cron":"0 2 * * *"}' \ + "${SIGMA_SERVER}/api/v1/validators/cron" +``` + +List code repositories for a provider such as `github` or `gitlab`: + +```bash +curl --fail-with-body --silent --show-error \ + --user "${SIGMA_USERNAME}:${SIGMA_PASSWORD}" \ + --header "Accept: application/json" \ + "${SIGMA_SERVER}/api/v1/coderepos/${PROVIDER}?page=1&limit=10" +``` + +Read the current user's access token metadata: + +```bash +curl --fail-with-body --silent --show-error \ + --user "${SIGMA_USERNAME}:${SIGMA_PASSWORD}" \ + --header "Accept: application/json" \ + "${SIGMA_SERVER}/api/v1/tokens" ``` ## Safety Rules