refactor(server)!: remove the embedded MCP server

Sigma shipped three overlapping ways to operate a remote server: the
embedded MCP server, sigma-cli, and the sigma-api-operator skill. All
three wrapped the same HTTP API, yet each covered a different subset:
user CRUD existed only in the CLI, system-* only in MCP, and builders,
daemons, webhooks, validators, code repositories and tokens were
reachable only through raw HTTP. MCP was disabled by default and still
carried 1246 lines of production code plus 606 lines of tests to
maintain that partially overlapping surface.

Keep two surfaces instead: sigma-cli for execution and the skill for
API discovery.

- delete pkg/server/mcp and its mcp-go dependency, which also drops the
  jsonschema-go, jsonschema/v6 and uritemplate/v3 transitives
- drop ConfigurationMCP, its defaults and the authz/audit skipper
  bypasses that existed only to let the MCP endpoint bypass both
- remove the mcp section from conf/config*.yaml, docs/configuration.md,
  and the Helm chart values and configmap
- replace docs/mcp.md with docs/cli.md covering sigma-cli login, global
  flags, per-resource commands, jq composition and agent usage
- make sigma-cli the preferred path in the sigma-api-operator skill and
  rebuild its examples around the resources the CLI does not wrap yet,
  using the paths and request schemas from swagger.yaml

Lint was already red on dev for an unrelated reason: golangci-lint
v2.13.0 vendors golang.org/x/tools v0.49.0, which only decodes export
data version 4, so every import of a Go 1.27.2 standard library package
failed typecheck with "export data version 5 is greater than maximum
supported version 4". Bump to v2.15.0 (x/tools v0.52.0) and fix the two
modernize findings it reports for Go 1.27's strings.CutLast.

- .github/workflows/lint.yml: golangci-lint v2.13.0 -> v2.15.0, and
  document the export data constraint next to the pin
- pkg/authz/resolver.go, pkg/logger/logger.go: replace LastIndex slicing
  with strings.CutLast
- bump github.com/docker/cli v29.9.0 -> v29.9.1

BREAKING CHANGE: the `mcp` configuration section and the /api/v1/mcp
endpoint are gone. Use sigma-cli or the REST API for automation.

Verification:
- go build -tags "netgo,timetzdata,exclude_graphdriver_btrfs,containers_image_openpgp" ./...
- go vet -tags "netgo,timetzdata,exclude_graphdriver_btrfs,containers_image_openpgp" ./...
- golangci-lint v2.15.0 run --build-tags "netgo,timetzdata,exclude_graphdriver_btrfs,containers_image_openpgp" ./... (0 issues)
- golangci-lint config verify
- go test -tags "netgo,timetzdata,exclude_graphdriver_btrfs,containers_image_openpgp" ./pkg/authz/... ./pkg/logger/... ./cmd/cli/... ./pkg/app/... ./pkg/background/build/...
- go test -run TestConfiguration -tags "netgo,timetzdata,exclude_graphdriver_btrfs,containers_image_openpgp" ./pkg/config/...
- go mod tidy
- make swagen
- helm template sigma ./deploy/sigma
- bun run docs:build
- make build
- make build-cli
This commit is contained in:
Tosone committed 2026-10-21 19:33:07 +08:00
1 parent 940ab15dd4
commit 187f5c4d97
45 files changed
+418 -2352

No files matched your search

+4 -1
View File
@@ -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
+1 -1
View File
@@ -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.
+5 -4
View File
@@ -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
+2 -2
View File
@@ -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
-8
View File
@@ -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:
-8
View File
@@ -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
-8
View File
@@ -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
-9
View File
@@ -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
-7
View File
@@ -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
-7
View File
@@ -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: ""
+4 -4
View File
@@ -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: {
+149
View File
@@ -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 <namespace_id>
sigma-cli namespace create library --visibility public --description "Base images"
sigma-cli namespace update <namespace_id> --size-limit 107374182400
sigma-cli namespace delete <namespace_id>
```
### Repository
```bash
sigma-cli repo list <namespace_id>
sigma-cli repo get <namespace_id> <repository_id>
sigma-cli repo create <namespace_id> library/alpine --visibility public
sigma-cli repo update <namespace_id> <repository_id> --description "Alpine images"
sigma-cli repo delete <namespace_id> <repository_id>
```
### Tag
```bash
sigma-cli tag list <namespace_id> <repository_id> --type image
sigma-cli tag get <namespace_id> <repository_id> <tag_id>
sigma-cli tag delete <namespace_id> <repository_id> <tag_id>
```
### Artifact
Artifact commands take namespace and repository **names** rather than IDs.
```bash
sigma-cli artifact list <namespace_name> <repository_name>
sigma-cli artifact get <namespace_name> <repository_name> <digest>
sigma-cli artifact delete <namespace_name> <repository_name> <digest>
```
### Member
```bash
sigma-cli member list <namespace_id>
sigma-cli member add <namespace_id> <user_id> --role reader
sigma-cli member update <namespace_id> <user_id> --role admin
sigma-cli member delete <namespace_id> <user_id>
```
### 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 <user_id> --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.
-8
View File
@@ -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
-143
View File
@@ -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://<sigma-host>/api/v1/mcp
```
## Authentication
MCP uses Sigma Basic Auth. Every MCP request must include:
```text
Authorization: Basic <base64(username:password)>
```
For example:
```bash
printf 'sigma:Admin@123' | base64
```
Use the encoded value in the MCP client configuration:
```json
{
"mcpServers": {
"sigma": {
"url": "https://<sigma-host>/api/v1/mcp",
"headers": {
"Authorization": "Basic <base64(username:password)>"
}
}
}
}
```
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:..."
}
```
+143
View File
@@ -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 <namespace_id>
sigma-cli namespace create library --visibility public --description "基础镜像"
sigma-cli namespace update <namespace_id> --size-limit 107374182400
sigma-cli namespace delete <namespace_id>
```
### 仓库
```bash
sigma-cli repo list <namespace_id>
sigma-cli repo get <namespace_id> <repository_id>
sigma-cli repo create <namespace_id> library/alpine --visibility public
sigma-cli repo update <namespace_id> <repository_id> --description "Alpine 镜像"
sigma-cli repo delete <namespace_id> <repository_id>
```
### Tag
```bash
sigma-cli tag list <namespace_id> <repository_id> --type image
sigma-cli tag get <namespace_id> <repository_id> <tag_id>
sigma-cli tag delete <namespace_id> <repository_id> <tag_id>
```
### 制品
制品相关命令使用命名空间和仓库的**名称**,而不是 ID。
```bash
sigma-cli artifact list <namespace_name> <repository_name>
sigma-cli artifact get <namespace_name> <repository_name> <digest>
sigma-cli artifact delete <namespace_name> <repository_name> <digest>
```
### 成员
```bash
sigma-cli member list <namespace_id>
sigma-cli member add <namespace_id> <user_id> --role reader
sigma-cli member update <namespace_id> <user_id> --role admin
sigma-cli member delete <namespace_id> <user_id>
```
### 用户
```bash
sigma-cli user list --without-admin
sigma-cli user create --username alice --password '...' --email alice@example.com --role user
sigma-cli user update <user_id> --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 响应体时,先脱敏再展示给用户。
-8
View File
@@ -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
-141
View File
@@ -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://<sigma-host>/api/v1/mcp
```
## 认证方式
MCP 使用 Sigma Basic Auth。每个 MCP 请求都必须携带:
```text
Authorization: Basic <base64(username:password)>
```
例如:
```bash
printf 'sigma:Admin@123' | base64
```
将编码后的值写入 MCP client 配置:
```json
{
"mcpServers": {
"sigma": {
"url": "https://<sigma-host>/api/v1/mcp",
"headers": {
"Authorization": "Basic <base64(username:password)>"
}
}
}
}
```
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:..."
}
```
-4
View File
@@ -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
-8
View File
@@ -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=
-6
View File
@@ -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)
}
}
-5
View File
@@ -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,
+4 -5
View File
@@ -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"):
-28
View File
@@ -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")
-42
View File
@@ -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"`
+5 -5
View File
@@ -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 {
-113
View File
@@ -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))
}
-55
View File
@@ -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"])
}
-55
View File
@@ -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
}
-128
View File
@@ -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
}
-154
View File
@@ -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},
}
}
-49
View File
@@ -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
}
-61
View File
@@ -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
}
-115
View File
@@ -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"))
}
-64
View File
@@ -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()
}
-102
View File
@@ -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
}
-29
View File
@@ -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}
}
-136
View File
@@ -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)
}),
)
}
-83
View File
@@ -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 ""
}
-86
View File
@@ -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
}
-189
View File
@@ -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
}
-112
View File
@@ -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
}
-71
View File
@@ -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
}
-96
View File
@@ -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
}
-154
View File
@@ -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
}
+101 -38
View File
@@ -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 <namespace_id>
sigma-cli namespace create library --visibility public
sigma-cli repo list <namespace_id>
sigma-cli repo create <namespace_id> library/alpine
sigma-cli tag list <namespace_id> <repository_id>
sigma-cli artifact list <namespace_name> <repository_name>
sigma-cli member add <namespace_id> <user_id> --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 <resource> --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