From 5efd4f3ffacac21f6db3bba2396911a5f28692d5 Mon Sep 17 00:00:00 2001 From: Damjan Becirovic Date: Thu, 6 Nov 2025 13:59:01 +0100 Subject: [PATCH] chore(mcp): tidy up ReadMe and some tool descriptions --- mcp_server/README.md | 44 +- .../pkg/tools/organizations/organizations.go | 3 +- .../pkg/tools/projects/projects.go.backup | 577 ------------------ mcp_server/pkg/tools/workflows/workflows.go | 1 + 4 files changed, 41 insertions(+), 584 deletions(-) delete mode 100644 mcp_server/pkg/tools/projects/projects.go.backup diff --git a/mcp_server/README.md b/mcp_server/README.md index ff002e400..749437a18 100644 --- a/mcp_server/README.md +++ b/mcp_server/README.md @@ -2,6 +2,34 @@ The `mcp_server` service is a Model Context Protocol (MCP) server implemented with [github.com/mark3labs/mcp-go](https://github.com/mark3labs/mcp-go). It exposes Semaphore workflow, pipeline, and job data to MCP-compatible clients. +## Configuration: + +### Claude Code: + +In terminal export the env var called MY_MCP_TOKEN with the value of the API token that should be used to connect to Semaphore MCP server. run the following command: + +claude mcp add semaphore https://mcp.semaphoreci.com/mcp \ + --scope user --transport http \ + --header "Authorization: Bearer $MY_MCP_TOKEN" + +Example prompt: "Help me figure out why have my test failed on Semaphore" + +### Codex: + +Open your ~/.codex/config.toml (if you’re using the CLI) or via the Codex IDE Extension in VS Code (Gear icon → MCP settings → Open config.toml) + +[mcp_servers.semaphore] +url = "https://mcp.semaphoreci.com/mcp" +bearer_token_env_var = "MY_MCP_TOKEN" +startup_timeout_sec = 30 +tool_timeout_sec = 300 + +In terminal export the env var called MY_MCP_TOKEN with the value of the API token that should be used to connect to Semaphore MCP server. + +You can then use Semaphore MCP in codex CLI by starting it in that same terminal session, or in VS Code codex extension by starting the VS Code from that terminal session with `code ` command. + +_Note_: Due to current limitations of Codex extension for VS Code, if you start VS Code in any other way except from the terminal session where MY_MCP_TOKEN env var has correct value, the Semaphore MCP server will not work. + ## Contributor Guide Refer to [`AGENTS.md`](AGENTS.md) for repository guidelines, project structure, and development workflows. @@ -11,15 +39,18 @@ Refer to [`AGENTS.md`](AGENTS.md) for repository guidelines, project structure, | Tool | Description | | ---- | ----------- | | `echo` | Returns the provided `message` verbatim (handy for smoke tests). | -| `workflows_list` | Lists workflows for a project using keyset pagination. | -| `pipelines_list` | Lists pipelines for a workflow, including state/result metadata. | -| `pipelines_describe` | Describes a pipeline and its blocks (optionally detailed). | +| `organizations_list` | Lists organizations that the user can access. | +| `projects_list` | List projects that belong to a specific organization. | +| `projects_search` | Search projects inside an organization by project name, repository URL, or description. | +| `workflows_search` | Search recent workflows for a project (most recent first). | +| `pipelines_list` | List pipelines associated with a workflow (most recent first). | +| `pipeline_jobs` | List jobs belonging to a specific pipeline. | | `jobs_describe` | Describes a job, surfacing agent details and lifecycle timestamps. | -| `jobs_logs` | Fetches job logs. Hosted jobs stream loghub events; self-hosted jobs mint a Loghub2 pull token. | +| `jobs_logs` | Fetches job logs. Hosted jobs stream loghub events; self-hosted jobs return a URL to fetch logs. | ## Requirements -- Go 1.24 (toolchain `go1.24.9` is configured in `go.mod`). +- Go 1.25 (toolchain `go1.25.2` is configured in `go.mod` and `Dockerfile`). - SSH access to `renderedtext/internal_api` for protobuf generation. ## Generating protobuf stubs @@ -44,6 +75,9 @@ The server dials internal gRPC services based on environment variables. Deployme | Job gRPC endpoint | `INTERNAL_API_URL_JOB`, `MCP_JOB_GRPC_ENDPOINT`, `JOBS_API_URL` | | Loghub gRPC endpoint (hosted logs) | `INTERNAL_API_URL_LOGHUB`, `MCP_LOGHUB_GRPC_ENDPOINT`, `LOGHUB_API_URL` | | Loghub2 gRPC endpoint (self-hosted logs) | `INTERNAL_API_URL_LOGHUB2`, `MCP_LOGHUB2_GRPC_ENDPOINT`, `LOGHUB2_API_URL` | +| RBAC gRPC endpoint | `INTERNAL_API_URL_RBAC`, `MCP_RBAC_GRPC_ENDPOINT` | +| Users gRPC endpoint | `INTERNAL_API_URL_USER`, `MCP_USER_GRPC_ENDPOINT` | +| Featurehub gRPC endpoint | `INTERNAL_API_URL_FEATURE`, `MCP_FEATURE_GRPC_ENDPOINT` | | Dial timeout | `MCP_GRPC_DIAL_TIMEOUT` (default `5s`) | | Call timeout | `MCP_GRPC_CALL_TIMEOUT` (default `15s`) | diff --git a/mcp_server/pkg/tools/organizations/organizations.go b/mcp_server/pkg/tools/organizations/organizations.go index 777df4034..18a78ce5f 100644 --- a/mcp_server/pkg/tools/organizations/organizations.go +++ b/mcp_server/pkg/tools/organizations/organizations.go @@ -30,7 +30,7 @@ const ( func fullDescription() string { return `List organizations available to the authenticated user. -This tool retrieves all organizations the user can access. The caller's user ID is derived from the X-Semaphore-User-ID header that the authentication layer injects into every request, so no additional arguments are required to identify the caller. +This tool retrieves all organizations the user can access. Use this as the first step when users ask questions like: - "Show me my organizations" @@ -67,7 +67,6 @@ Examples: Common Errors: - Empty list: User may not belong to any organizations (check authentication) - RPC failed: Organization service temporarily unavailable (retry after a few seconds) -- Missing header: Ensure the authentication proxy forwards X-Semaphore-User-ID Next Steps After This Call: - Store the organization_id you intend to use (for example in a local ".semaphore/org" file) so future requests can reference it quickly diff --git a/mcp_server/pkg/tools/projects/projects.go.backup b/mcp_server/pkg/tools/projects/projects.go.backup deleted file mode 100644 index dff9a16fe..000000000 --- a/mcp_server/pkg/tools/projects/projects.go.backup +++ /dev/null @@ -1,577 +0,0 @@ -package projects - -import ( - "context" - "fmt" - "sort" - "strings" - - "github.com/google/uuid" - "github.com/mark3labs/mcp-go/mcp" - "github.com/mark3labs/mcp-go/server" - projecthubpb "github.com/semaphoreio/semaphore/mcp_server/pkg/internal_api/projecthub" - repoipb "github.com/semaphoreio/semaphore/mcp_server/pkg/internal_api/repository_integrator" - "github.com/sirupsen/logrus" - - "github.com/semaphoreio/semaphore/mcp_server/pkg/internalapi" - "github.com/semaphoreio/semaphore/mcp_server/pkg/logging" - "github.com/semaphoreio/semaphore/mcp_server/pkg/tools/internal/shared" -) - -const ( - listToolName = "org.projects.list" - searchToolName = "org.projects.search" - defaultListLimit = 25 - maxListLimit = 200 - defaultSearchLimit = 20 - defaultSearchPages = 5 - maxSearchPages = 10 - searchPageSize = 100 -) - -// Register wires project tools into the MCP server. -func Register(s *server.MCPServer, api internalapi.Provider) { - if s == nil { - return - } - - s.AddTool(newListTool(), listHandler(api)) - s.AddTool(newSearchTool(), searchHandler(api)) -} - -func newListTool() mcp.Tool { - return mcp.NewTool( - listToolName, - mcp.WithDescription("List projects within an organization."), - mcp.WithString("organization_id", - mcp.Required(), - mcp.Description("Organization UUID whose projects should be listed."), - ), - mcp.WithString("user_id", - mcp.Description("Optional user UUID to scope results to member-accessible projects."), - ), - mcp.WithNumber("page", - mcp.Description("1-based page number."), - mcp.Min(1), - mcp.DefaultNumber(1), - ), - mcp.WithNumber("limit", - mcp.Description("Number of projects per page (1-200)."), - mcp.Min(1), - mcp.Max(maxListLimit), - mcp.DefaultNumber(defaultListLimit), - ), - mcp.WithString("mode", - mcp.Description("Response detail level (`summary` or `detailed`). Defaults to `summary`."), - ), - ) -} - -func newSearchTool() mcp.Tool { - return mcp.NewTool( - searchToolName, - mcp.WithDescription("Search projects within an organization by name, repository URL, or description."), - mcp.WithString("organization_id", - mcp.Required(), - mcp.Description("Organization UUID whose projects should be searched."), - ), - mcp.WithString("query", - mcp.Required(), - mcp.Description("Search query matched against project name, description, and repository URL."), - ), - mcp.WithString("user_id", - mcp.Description("Optional user UUID to scope search results to accessible projects."), - ), - mcp.WithNumber("limit", - mcp.Description("Maximum number of matches to return (1-50)."), - mcp.Min(1), - mcp.Max(50), - mcp.DefaultNumber(defaultSearchLimit), - ), - mcp.WithNumber("max_pages", - mcp.Description("Maximum number of paginated fetches to evaluate (1-10). Higher values explore more projects at the cost of latency."), - mcp.Min(1), - mcp.Max(maxSearchPages), - mcp.DefaultNumber(defaultSearchPages), - ), - mcp.WithString("mode", - mcp.Description("Response detail level (`summary` or `detailed`). Defaults to `summary`."), - ), - ) -} - -type listResult struct { - Projects []projectSummary `json:"projects"` - Page int `json:"page"` - TotalPages int `json:"totalPages"` - TotalEntries int `json:"totalEntries"` - HasMore bool `json:"hasMore"` -} - -type searchResult struct { - Projects []projectSearchEntry `json:"projects"` - TotalMatches int `json:"totalMatches"` - SearchedPages int `json:"searchedPages"` - MoreAvailable bool `json:"moreAvailable"` -} - -type projectSearchEntry struct { - projectSummary - MatchConfidence string `json:"matchConfidence"` - MatchedFields []string `json:"matchedFields,omitempty"` -} - -type projectSummary struct { - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description,omitempty"` - OrganizationID string `json:"organizationId,omitempty"` - OwnerID string `json:"ownerId,omitempty"` - CreatedAt string `json:"createdAt,omitempty"` - Visibility string `json:"visibility,omitempty"` - Repository repositorySummary `json:"repository"` - Details *projectDetails `json:"details,omitempty"` -} - -type repositorySummary struct { - URL string `json:"url,omitempty"` - Name string `json:"name,omitempty"` - DefaultBranch string `json:"defaultBranch,omitempty"` - PipelineFile string `json:"pipelineFile,omitempty"` - Integration string `json:"integrationType,omitempty"` - Public bool `json:"public"` - Connected bool `json:"connected"` - RepositoryID string `json:"repositoryId,omitempty"` - Owner string `json:"owner,omitempty"` - RunOnPresent bool `json:"runOnConfigured,omitempty"` - IntegrationURL string `json:"integrationUrl,omitempty"` -} - -type projectDetails struct { - CustomPermissions bool `json:"customPermissions,omitempty"` - SchedulerCount int `json:"schedulerCount,omitempty"` - TaskCount int `json:"taskCount,omitempty"` - DebugPermissions []string `json:"debugPermissions,omitempty"` - AttachPermissions []string `json:"attachPermissions,omitempty"` -} - -func listHandler(api internalapi.Provider) server.ToolHandlerFunc { - return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { - client := api.Projects() - if client == nil { - return mcp.NewToolResultError("project gRPC endpoint is not configured"), nil - } - - orgID, err := req.RequireString("organization_id") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - mode := strings.ToLower(strings.TrimSpace(req.GetString("mode", "summary"))) - if mode == "" { - mode = "summary" - } - if mode != "summary" && mode != "detailed" { - return mcp.NewToolResultError("mode must be either 'summary' or 'detailed'"), nil - } - includeDetails := mode == "detailed" - - page := req.GetInt("page", 1) - if page < 1 { - page = 1 - } - - limit := req.GetInt("limit", defaultListLimit) - if limit <= 0 { - limit = defaultListLimit - } else if limit > maxListLimit { - limit = maxListLimit - } - - request := &projecthubpb.ListRequest{ - Metadata: projectRequestMeta(orgID, strings.TrimSpace(req.GetString("user_id", ""))), - Pagination: &projecthubpb.PaginationRequest{ - Page: int32(page), - PageSize: int32(limit), - }, - } - - callCtx, cancel := context.WithTimeout(ctx, api.CallTimeout()) - defer cancel() - - resp, err := client.List(callCtx, request) - if err != nil { - logging.ForComponent("rpc"). - WithFields(logrus.Fields{ - "rpc": "project.List", - "organizationId": orgID, - "page": page, - "limit": limit, - "mode": mode, - }). - WithError(err). - Error("project list RPC failed") - return mcp.NewToolResultError(fmt.Sprintf("project list RPC failed: %v", err)), nil - } - - if err := shared.CheckProjectResponseMeta(resp.GetMetadata()); err != nil { - logging.ForComponent("rpc"). - WithField("rpc", "project.List"). - WithError(err). - Warn("project list returned non-OK status") - return mcp.NewToolResultError(err.Error()), nil - } - - pagination := resp.GetPagination() - totalPages := 0 - totalEntries := 0 - hasMore := false - if pagination != nil { - totalPages = int(pagination.GetTotalPages()) - totalEntries = int(pagination.GetTotalEntries()) - hasMore = int(pagination.GetPageNumber()) < totalPages - } - - projects := make([]projectSummary, 0, len(resp.GetProjects())) - for _, proj := range resp.GetProjects() { - projects = append(projects, summarizeProject(proj, includeDetails)) - } - - return mcp.NewToolResultStructuredOnly(listResult{ - Projects: projects, - Page: page, - TotalPages: totalPages, - TotalEntries: totalEntries, - HasMore: hasMore, - }), nil - } -} - -func searchHandler(api internalapi.Provider) server.ToolHandlerFunc { - return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { - client := api.Projects() - if client == nil { - return mcp.NewToolResultError("project gRPC endpoint is not configured"), nil - } - - orgID, err := req.RequireString("organization_id") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - rawQuery, err := req.RequireString("query") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - query := strings.TrimSpace(strings.ToLower(rawQuery)) - if query == "" { - return mcp.NewToolResultError("query must not be empty"), nil - } - - mode := strings.ToLower(strings.TrimSpace(req.GetString("mode", "summary"))) - if mode == "" { - mode = "summary" - } - if mode != "summary" && mode != "detailed" { - return mcp.NewToolResultError("mode must be either 'summary' or 'detailed'"), nil - } - includeDetails := mode == "detailed" - - limit := req.GetInt("limit", defaultSearchLimit) - if limit <= 0 { - limit = defaultSearchLimit - } else if limit > 50 { - limit = 50 - } - - maxPages := req.GetInt("max_pages", defaultSearchPages) - if maxPages <= 0 { - maxPages = defaultSearchPages - } else if maxPages > maxSearchPages { - maxPages = maxSearchPages - } - - userID := strings.TrimSpace(req.GetString("user_id", "")) - - type candidate struct { - summary projectSummary - score int - matchedFields []string - } - - candidates := make([]candidate, 0, limit*2) - totalMatches := 0 - searchedPages := 0 - moreAvailable := false - - for page := 1; page <= maxPages; page++ { - request := &projecthubpb.ListRequest{ - Metadata: projectRequestMeta(orgID, userID), - Pagination: &projecthubpb.PaginationRequest{ - Page: int32(page), - PageSize: searchPageSize, - }, - } - - callCtx, cancel := context.WithTimeout(ctx, api.CallTimeout()) - resp, err := client.List(callCtx, request) - cancel() - if err != nil { - logging.ForComponent("rpc"). - WithFields(logrus.Fields{ - "rpc": "project.List", - "organizationId": orgID, - "page": page, - "mode": mode, - "query": rawQuery, - }). - WithError(err). - Error("project list RPC failed during search") - return mcp.NewToolResultError(fmt.Sprintf("project search failed: %v", err)), nil - } - - if err := shared.CheckProjectResponseMeta(resp.GetMetadata()); err != nil { - logging.ForComponent("rpc"). - WithField("rpc", "project.List"). - WithError(err). - Warn("project search received non-OK status") - return mcp.NewToolResultError(err.Error()), nil - } - - searchedPages++ - - for _, proj := range resp.GetProjects() { - score, matched := scoreProjectMatch(proj, query) - if score == 0 || len(matched) == 0 { - continue - } - totalMatches++ - candidates = append(candidates, candidate{ - summary: summarizeProject(proj, includeDetails), - score: score, - matchedFields: matched, - }) - } - - pagination := resp.GetPagination() - if pagination == nil || int(pagination.GetPageNumber()) >= int(pagination.GetTotalPages()) { - moreAvailable = false - break - } - moreAvailable = true - } - - if len(candidates) == 0 { - return mcp.NewToolResultStructuredOnly(searchResult{ - Projects: []projectSearchEntry{}, - TotalMatches: 0, - SearchedPages: searchedPages, - MoreAvailable: moreAvailable, - }), nil - } - - sort.SliceStable(candidates, func(i, j int) bool { - if candidates[i].score == candidates[j].score { - return strings.Compare(candidates[i].summary.Name, candidates[j].summary.Name) < 0 - } - return candidates[i].score > candidates[j].score - }) - - if len(candidates) > limit { - candidates = candidates[:limit] - } - - results := make([]projectSearchEntry, 0, len(candidates)) - for _, cand := range candidates { - results = append(results, projectSearchEntry{ - projectSummary: cand.summary, - MatchConfidence: classifyConfidence(cand.score), - MatchedFields: cand.matchedFields, - }) - } - - return mcp.NewToolResultStructuredOnly(searchResult{ - Projects: results, - TotalMatches: totalMatches, - SearchedPages: searchedPages, - MoreAvailable: moreAvailable || totalMatches > len(results), - }), nil - } -} - -func projectRequestMeta(orgID, userID string) *projecthubpb.RequestMeta { - return &projecthubpb.RequestMeta{ - ApiVersion: "v1alpha", - Kind: "Project", - OrgId: strings.TrimSpace(orgID), - UserId: strings.TrimSpace(userID), - ReqId: uuid.NewString(), - } -} - -func summarizeProject(project *projecthubpb.Project, includeDetails bool) projectSummary { - if project == nil { - return projectSummary{} - } - - meta := project.GetMetadata() - spec := project.GetSpec() - var repoSummary repositorySummary - if spec != nil { - repoSummary = summarizeRepository(spec.GetRepository()) - } - - summary := projectSummary{ - ID: meta.GetId(), - Name: meta.GetName(), - Description: meta.GetDescription(), - OrganizationID: meta.GetOrgId(), - OwnerID: meta.GetOwnerId(), - CreatedAt: shared.FormatTimestamp(meta.GetCreatedAt()), - Visibility: normalizeProjectVisibility(spec), - Repository: repoSummary, - } - - if includeDetails { - var debugPerms, attachPerms []string - var schedulers, tasks int - var customPerms bool - if spec != nil { - debugPerms = normalizePermissionTypes(spec.GetDebugPermissions()) - attachPerms = normalizePermissionTypes(spec.GetAttachPermissions()) - schedulers = len(spec.GetSchedulers()) - tasks = len(spec.GetTasks()) - customPerms = spec.GetCustomPermissions() - } - details := projectDetails{ - CustomPermissions: customPerms, - SchedulerCount: schedulers, - TaskCount: tasks, - DebugPermissions: debugPerms, - AttachPermissions: attachPerms, - } - summary.Details = &details - } - - return summary -} - -func summarizeRepository(repo *projecthubpb.Project_Spec_Repository) repositorySummary { - if repo == nil { - return repositorySummary{} - } - - return repositorySummary{ - URL: repo.GetUrl(), - Name: repo.GetName(), - Owner: repo.GetOwner(), - DefaultBranch: repo.GetDefaultBranch(), - PipelineFile: repo.GetPipelineFile(), - Integration: normalizeIntegration(repo.GetIntegrationType()), - Public: repo.GetPublic(), - Connected: repo.GetConnected(), - RepositoryID: repo.GetId(), - RunOnPresent: repo.GetRunPresent() != nil, - } -} - -func normalizeProjectVisibility(spec *projecthubpb.Project_Spec) string { - if spec == nil { - return "" - } - value := spec.GetVisibility().String() - return normalizeEnumName(value, "Project_Spec_") -} - -func normalizeIntegration(integration repoipb.IntegrationType) string { - return normalizeEnumName(integration.String(), "IntegrationType_") -} - -func normalizePermissionTypes(perms []projecthubpb.Project_Spec_PermissionType) []string { - if len(perms) == 0 { - return nil - } - out := make([]string, 0, len(perms)) - for _, p := range perms { - out = append(out, normalizeEnumName(p.String(), "Project_Spec_")) - } - return out -} - -func normalizeEnumName(raw, prefix string) string { - raw = strings.TrimSpace(raw) - if raw == "" { - return "" - } - if strings.HasPrefix(raw, prefix) { - raw = strings.TrimPrefix(raw, prefix) - } - raw = strings.ReplaceAll(raw, "_", " ") - return strings.ToLower(raw) -} - -func scoreProjectMatch(project *projecthubpb.Project, query string) (int, []string) { - if project == nil { - return 0, nil - } - metadata := project.GetMetadata() - spec := project.GetSpec() - var repo *projecthubpb.Project_Spec_Repository - if spec != nil { - repo = spec.GetRepository() - } - - score := 0 - var matched []string - - name := strings.ToLower(strings.TrimSpace(metadata.GetName())) - description := strings.ToLower(strings.TrimSpace(metadata.GetDescription())) - repoURL := "" - repoName := "" - defaultBranch := "" - if repo != nil { - repoURL = strings.ToLower(strings.TrimSpace(repo.GetUrl())) - repoName = strings.ToLower(strings.TrimSpace(repo.GetName())) - defaultBranch = strings.ToLower(strings.TrimSpace(repo.GetDefaultBranch())) - } - - if name == query { - score += 6 - matched = append(matched, "name_exact") - } else if strings.Contains(name, query) { - score += 4 - matched = append(matched, "name") - } - - if desc := description; desc != "" && strings.Contains(desc, query) { - score += 2 - matched = append(matched, "description") - } - - if repoURL != "" && strings.Contains(repoURL, query) { - score += 3 - matched = append(matched, "repository_url") - } - - if repoName != "" && strings.Contains(repoName, query) { - score += 2 - matched = append(matched, "repository_name") - } - - if defaultBranch != "" && strings.Contains(defaultBranch, query) { - score++ - matched = append(matched, "default_branch") - } - - return score, matched -} - -func classifyConfidence(score int) string { - switch { - case score >= 6: - return "high" - case score >= 3: - return "medium" - default: - return "low" - } -} diff --git a/mcp_server/pkg/tools/workflows/workflows.go b/mcp_server/pkg/tools/workflows/workflows.go index 7d93af235..0216bf3a8 100644 --- a/mcp_server/pkg/tools/workflows/workflows.go +++ b/mcp_server/pkg/tools/workflows/workflows.go @@ -35,6 +35,7 @@ Use this when you need to answer: - "Who triggered the latest deployment workflow?" - organization_id: identify which organization’s project you are querying (required) +- project_id: identify which project to search workflows for (required) - branch: limit results to a specific branch (e.g., "main" or "release/*") - requester: filter by a specific requester (UUID, username, or automation handle) - my_workflows_only: when true (default), limit results to workflows triggered by the authenticated user