From 1db7b8a10ebe8580b7d3b07259bc7a859af195d6 Mon Sep 17 00:00:00 2001 From: Andrey Gliznetsov Date: Tue, 3 Nov 2026 10:22:08 +0100 Subject: [PATCH 1/2] DEV-217538 feat: add edit_domain and edit_community tools Co-Authored-By: Claude Sonnet 5.5 --- README.md | 8 + pkg/clients/edit_container_client.go | 167 +++++++++++++ pkg/tools/edit_community/tool.go | 270 +++++++++++++++++++++ pkg/tools/edit_community/tool_test.go | 256 +++++++++++++++++++ pkg/tools/edit_domain/tool.go | 246 +++++++++++++++++++ pkg/tools/edit_domain/tool_test.go | 177 ++++++++++++++ pkg/tools/register.go | 4 + pkg/tools/resolve/container.go | 85 +++++++ pkg/tools/responsibility/responsibility.go | 117 +++++++++ 9 files changed, 1330 insertions(+) create mode 100644 pkg/clients/edit_container_client.go create mode 100644 pkg/tools/edit_community/tool.go create mode 100644 pkg/tools/edit_community/tool_test.go create mode 100644 pkg/tools/edit_domain/tool.go create mode 100644 pkg/tools/edit_domain/tool_test.go create mode 100644 pkg/tools/resolve/container.go create mode 100644 pkg/tools/responsibility/responsibility.go diff --git a/README.md b/README.md index cefaa43..4e300f1 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,14 @@ This Go-based MCP server acts as a bridge between AI applications and Collibra, - `set_owner` - set the owner by user UUID, email, username, or full name - `set_assignees` - replace the assignee list with the given users (UUID, email, username, or full name) and groups (UUID) - `set_visibility` - set whether the assessment is visible to everyone +- [`edit_community`](pkg/tools/edit_community/) - Edit an existing community (identified by name or UUID) via a list of typed operations, each validated and applied independently (`success` / `partial_success` / `error`): + - `set_name`, `set_description` - rename the community or change its description + - `set_parent` - move the community under another parent (name or UUID), or `makeTopLevel: true` to remove the parent; exactly one of the two is required, and the result is verified against the response + - `set_responsibility`, `remove_responsibility` - assign or unassign a user or group on a resource role (e.g. `Steward`); only directly-assigned responsibilities can be removed +- [`edit_domain`](pkg/tools/edit_domain/) - Edit an existing domain (identified by name or UUID) via a list of typed operations, each validated and applied independently (`success` / `partial_success` / `error`): + - `set_name`, `set_description` - rename the domain or change its description + - `set_community` - move the domain to a different community (name or UUID) + - `set_responsibility`, `remove_responsibility` - assign or unassign a user or group on a resource role (e.g. `Steward`); only directly-assigned responsibilities can be removed - [`edit_asset`](pkg/tools/edit_asset/) - Edit an existing asset via a list of typed operations: - `set_attribute`, `add_attribute`, `remove_attribute` - set an attribute value (creates if empty, updates if present), append an extra value to a multi-valued attribute, or clear one (e.g. `Definition`, `Note`) - `update_property` - rename the asset (`name`), change its `displayName`, or change its `statusId` (status name or UUID accepted) diff --git a/pkg/clients/edit_container_client.go b/pkg/clients/edit_container_client.go new file mode 100644 index 0000000..2e7626d --- /dev/null +++ b/pkg/clients/edit_container_client.go @@ -0,0 +1,167 @@ +package clients + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" +) + +// ContainerRef is a reference to a community or domain (id + name). +type ContainerRef struct { + ID string `json:"id"` + Name string `json:"name"` +} + +// DomainDetails is the slim view of a domain used by edit_domain and the name resolver. +type DomainDetails struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Community *ContainerRef `json:"community,omitempty"` +} + +// CommunityDetails is the slim view of a community used by edit_community and the +// name resolver. Parent is nil for a top-level community. +type CommunityDetails struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Parent *ContainerRef `json:"parent,omitempty"` +} + +// DomainPatch is the body for PATCH /rest/2.0/domains/{id}. Nil fields are +// left unchanged by Collibra. +type DomainPatch struct { + Name *string `json:"name,omitempty"` + Description *string `json:"description,omitempty"` + CommunityID *string `json:"communityId,omitempty"` +} + +// CommunityPatch is the body for PATCH /rest/2.0/communities/{id}. Nil fields +// are left unchanged by Collibra. ClearParent sends an explicit +// "parentId": null, which the API is supposed to treat as "make top-level". +type CommunityPatch struct { + Name *string + Description *string + ParentID *string + ClearParent bool +} + +// MarshalJSON emits only the fields that are set; ClearParent forces an +// explicit null parentId, which omitempty on a pointer could not express. +func (p CommunityPatch) MarshalJSON() ([]byte, error) { + body := map[string]any{} + if p.Name != nil { + body["name"] = *p.Name + } + if p.Description != nil { + body["description"] = *p.Description + } + if p.ParentID != nil { + body["parentId"] = *p.ParentID + } else if p.ClearParent { + body["parentId"] = nil + } + return json.Marshal(body) +} + +type domainList struct { + Results []DomainDetails `json:"results"` +} + +type communityList struct { + Results []CommunityDetails `json:"results"` +} + +// GetDomain fetches a domain via GET /rest/2.0/domains/{id}. +func GetDomain(ctx context.Context, client *http.Client, domainID string) (*DomainDetails, error) { + var out DomainDetails + if err := getJSON(ctx, client, "/rest/2.0/domains/"+url.PathEscape(domainID), &out); err != nil { + return nil, err + } + return &out, nil +} + +// SearchDomains returns domains whose name contains the given text (the API's +// substring match); callers narrow to an exact match. +func SearchDomains(ctx context.Context, client *http.Client, name string) ([]DomainDetails, error) { + var out domainList + if err := getJSON(ctx, client, "/rest/2.0/domains?limit=50&name="+url.QueryEscape(name), &out); err != nil { + return nil, err + } + return out.Results, nil +} + +// GetCommunity fetches a community via GET /rest/2.0/communities/{id}. +func GetCommunity(ctx context.Context, client *http.Client, communityID string) (*CommunityDetails, error) { + var out CommunityDetails + if err := getJSON(ctx, client, "/rest/2.0/communities/"+url.PathEscape(communityID), &out); err != nil { + return nil, err + } + return &out, nil +} + +// SearchCommunities returns communities whose name contains the given text +// (the API's substring match); callers narrow to an exact match. +func SearchCommunities(ctx context.Context, client *http.Client, name string) ([]CommunityDetails, error) { + var out communityList + if err := getJSON(ctx, client, "/rest/2.0/communities?limit=50&name="+url.QueryEscape(name), &out); err != nil { + return nil, err + } + return out.Results, nil +} + +// PatchDomain applies a partial update via PATCH /rest/2.0/domains/{id} and +// returns the domain as Collibra reports it afterwards. +func PatchDomain(ctx context.Context, client *http.Client, domainID string, patch DomainPatch) (*DomainDetails, error) { + var out DomainDetails + if err := patchJSON(ctx, client, "/rest/2.0/domains/"+url.PathEscape(domainID), patch, &out); err != nil { + return nil, fmt.Errorf("patch domain: %w", err) + } + return &out, nil +} + +// PatchCommunity applies a partial update via PATCH /rest/2.0/communities/{id} +// and returns the community as Collibra reports it afterwards. +func PatchCommunity(ctx context.Context, client *http.Client, communityID string, patch CommunityPatch) (*CommunityDetails, error) { + var out CommunityDetails + if err := patchJSON(ctx, client, "/rest/2.0/communities/"+url.PathEscape(communityID), patch, &out); err != nil { + return nil, fmt.Errorf("patch community: %w", err) + } + return &out, nil +} + +func patchJSON(ctx context.Context, client *http.Client, reqURL string, payload, out any) error { + body, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("marshaling request: %w", err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPatch, reqURL, bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("building request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("sending request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("reading response: %w", err) + } + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("status %d: %s", resp.StatusCode, string(respBody)) + } + if err := json.Unmarshal(respBody, out); err != nil { + return fmt.Errorf("decoding response: %w", err) + } + return nil +} diff --git a/pkg/tools/edit_community/tool.go b/pkg/tools/edit_community/tool.go new file mode 100644 index 0000000..c24a619 --- /dev/null +++ b/pkg/tools/edit_community/tool.go @@ -0,0 +1,270 @@ +// Package edit_community implements the edit_community MCP tool: a single +// entry point for renaming a community, changing its description, moving it +// under another parent (or to the top level), and managing its role +// assignments (responsibilities) via a typed list of operations. Each +// operation is validated independently and applied on its own, so one failing +// operation never blocks its valid siblings. +package edit_community + +import ( + "context" + "fmt" + "net/http" + "strings" + + "github.com/collibra/chip/pkg/chip" + "github.com/collibra/chip/pkg/clients" + "github.com/collibra/chip/pkg/tools/resolve" + "github.com/collibra/chip/pkg/tools/responsibility" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// OperationType enumerates the edits edit_community can perform. +type OperationType string + +const ( + OpSetName OperationType = "set_name" + OpSetDescription OperationType = "set_description" + OpSetParent OperationType = "set_parent" + OpSetResponsibility OperationType = "set_responsibility" + OpRemoveResponsibility OperationType = "remove_responsibility" +) + +// Input is the tool's typed input. +type Input struct { + Community string `json:"community" jsonschema:"Required. The community to edit — its UUID or its name (an exact, case-insensitive match; an ambiguous name returns the candidates with their UUIDs)."` + Operations []Operation `json:"operations" jsonschema:"Required. Non-empty list of operations to apply. Each operation is validated and applied independently. Each operation's type selects which additional fields are used (see Operation)."` +} + +// Operation is a discriminated union: the 'type' field selects which other +// fields are interpreted. Unused fields are ignored. +type Operation struct { + Type OperationType `json:"type" jsonschema:"Required. One of: set_name, set_description, set_parent, set_responsibility, remove_responsibility."` + + Value string `json:"value,omitempty" jsonschema:"For set_name: the new community name (must not be empty). For set_description: the new description (an empty string clears it)."` + + Parent string `json:"parent,omitempty" jsonschema:"For set_parent: the community to move this one under, given as its UUID or name. Provide exactly one of parent or makeTopLevel."` + MakeTopLevel bool `json:"makeTopLevel,omitempty" jsonschema:"For set_parent: true to remove the parent and make the community top-level. Provide exactly one of parent or makeTopLevel."` + + Role string `json:"role,omitempty" jsonschema:"For set_responsibility and remove_responsibility: the resource role name, e.g. 'Steward' or 'Owner'."` + UserID string `json:"userId,omitempty" jsonschema:"For set_responsibility and remove_responsibility: the user or group, given as a UUID, email address, username, or full name such as 'Jane Smith'. A full name shared by several users returns a per-operation error listing the candidates."` +} + +// OutputStatus summarises the result of the call. +type OutputStatus string + +const ( + StatusSuccess OutputStatus = "success" + StatusPartialSuccess OutputStatus = "partial_success" + StatusError OutputStatus = "error" +) + +// Output is the tool's typed output. +type Output struct { + Status OutputStatus `json:"status" jsonschema:"Overall status: success if every operation applied, partial_success if some succeeded and some failed, error if every operation failed."` + Results []OperationResult `json:"results" jsonschema:"Per-operation outcomes, in the same order as the input operations."` + Community *CommunitySummary `json:"community,omitempty" jsonschema:"The community's state after the edits. Present when the community could be re-read."` +} + +// CommunitySummary is the post-edit snapshot of the community. +type CommunitySummary struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + ParentID string `json:"parentId,omitempty"` + Parent string `json:"parent,omitempty"` +} + +// OperationResult is the outcome of a single operation. +type OperationResult struct { + Operation OperationType `json:"operation"` + Status string `json:"status" jsonschema:"'success' or 'error'."` + PreviousValue string `json:"previousValue,omitempty"` + NewValue string `json:"newValue,omitempty"` + Error string `json:"error,omitempty"` +} + +// NewTool returns the registered tool. +func NewTool(collibraClient *http.Client) *chip.Tool[Input, Output] { + return &chip.Tool[Input, Output]{ + Name: "edit_community", + Title: "Edit Community", + Description: "Edit an existing Collibra community (identified by UUID or name) by submitting a list of typed operations. " + + "Supported operations: " + + "set_name (rename the community); " + + "set_description (change the description; an empty value clears it); " + + "set_parent (move the community under a different parent community given as a UUID or name via 'parent', or pass makeTopLevel=true to remove the parent — exactly one of the two is required, neither is a validation error); " + + "set_responsibility (assign a user or group to a resource role such as 'Steward' or 'Owner'; the user can be a UUID, username, email address, or full name such as 'Jane Smith'); " + + "remove_responsibility (unassign a user or group from a role; removes only a responsibility assigned directly on the community, not one inherited from a parent). " + + "Each operation is validated independently and applied on its own: invalid or failing operations return per-operation errors while valid siblings still apply, yielding status=success, partial_success, or error. " + + "set_parent verifies the result: if Collibra reports success but the parent did not change, the operation is reported as an error. " + + "On completion the response includes the community's state after the edits.", + Handler: handler(collibraClient), + Permissions: []string{}, + Annotations: &mcp.ToolAnnotations{ReadOnlyHint: false, DestructiveHint: chip.Ptr(true), IdempotentHint: false, OpenWorldHint: chip.Ptr(false)}, + } +} + +func handler(collibraClient *http.Client) chip.ToolHandlerFunc[Input, Output] { + return func(ctx context.Context, input Input) (Output, error) { + if len(input.Operations) == 0 { + return Output{}, fmt.Errorf("operations must not be empty") + } + community, err := resolve.Community(ctx, collibraClient, input.Community, resolve.Hints{}) + if err != nil { + return Output{}, err + } + + // Validate every operation before any write, then apply the valid ones. + results := make([]OperationResult, len(input.Operations)) + for i, op := range input.Operations { + if verr := validateOperation(op); verr != nil { + results[i] = errorResult(op, verr) + continue + } + results[i] = executeOperation(ctx, collibraClient, community, op) + } + + out := Output{Status: overallStatus(results), Results: results} + out.Community = readCommunity(ctx, collibraClient, community.ID) + return out, nil + } +} + +// validateOperation runs the checks that need no request. +func validateOperation(op Operation) error { + switch op.Type { + case OpSetName: + if strings.TrimSpace(op.Value) == "" { + return fmt.Errorf("value is required for set_name and must not be empty") + } + case OpSetDescription: + // An empty value is a valid way to clear the description. + case OpSetParent: + hasParent := strings.TrimSpace(op.Parent) != "" + if hasParent == op.MakeTopLevel { + return fmt.Errorf("set_parent requires exactly one of parent (a community UUID or name) or makeTopLevel=true") + } + case OpSetResponsibility, OpRemoveResponsibility: + return responsibility.Check(op.Role, op.UserID) + default: + return fmt.Errorf("unknown operation type %q; expected one of set_name, set_description, set_parent, set_responsibility, remove_responsibility", op.Type) + } + return nil +} + +func executeOperation(ctx context.Context, client *http.Client, community clients.CommunityDetails, op Operation) OperationResult { + switch op.Type { + case OpSetName: + return patchField(ctx, client, community, op, community.Name, op.Value, clients.CommunityPatch{Name: chip.Ptr(op.Value)}) + case OpSetDescription: + return patchField(ctx, client, community, op, community.Description, op.Value, clients.CommunityPatch{Description: chip.Ptr(op.Value)}) + case OpSetParent: + return executeSetParent(ctx, client, community, op) + case OpSetResponsibility: + return executeSetResponsibility(ctx, client, community, op) + default: + return executeRemoveResponsibility(ctx, client, community, op) + } +} + +func patchField(ctx context.Context, client *http.Client, community clients.CommunityDetails, op Operation, previous, next string, patch clients.CommunityPatch) OperationResult { + if _, err := clients.PatchCommunity(ctx, client, community.ID, patch); err != nil { + return errorResult(op, err) + } + return OperationResult{Operation: op.Type, Status: "success", PreviousValue: previous, NewValue: next} +} + +func executeSetParent(ctx context.Context, client *http.Client, community clients.CommunityDetails, op Operation) OperationResult { + previous := parentName(community.Parent) + + if op.MakeTopLevel { + updated, err := clients.PatchCommunity(ctx, client, community.ID, clients.CommunityPatch{ClearParent: true}) + if err != nil { + return errorResult(op, err) + } + // Trust the response, not the status code: Collibra has answered 200 + // to a clear-parent request without clearing it (DEV-217539). + if updated.Parent != nil { + return errorResult(op, fmt.Errorf("the request succeeded but the community is still under parent %q; the platform did not clear the parent", updated.Parent.Name)) + } + return OperationResult{Operation: op.Type, Status: "success", PreviousValue: previous, NewValue: "(top-level)"} + } + + parent, err := resolve.Community(ctx, client, op.Parent, resolve.Hints{}) + if err != nil { + return errorResult(op, err) + } + if parent.ID == community.ID { + return errorResult(op, fmt.Errorf("a community cannot be its own parent")) + } + updated, err := clients.PatchCommunity(ctx, client, community.ID, clients.CommunityPatch{ParentID: chip.Ptr(parent.ID)}) + if err != nil { + return errorResult(op, err) + } + if updated.Parent == nil || updated.Parent.ID != parent.ID { + return errorResult(op, fmt.Errorf("the request succeeded but the community is not under parent %q afterwards", parent.Name)) + } + return OperationResult{Operation: op.Type, Status: "success", PreviousValue: previous, NewValue: parent.Name} +} + +func executeSetResponsibility(ctx context.Context, client *http.Client, community clients.CommunityDetails, op Operation) OperationResult { + id, err := responsibility.Set(ctx, client, target(community), op.Role, op.UserID) + if err != nil { + return errorResult(op, err) + } + return OperationResult{Operation: op.Type, Status: "success", NewValue: id} +} + +func executeRemoveResponsibility(ctx context.Context, client *http.Client, community clients.CommunityDetails, op Operation) OperationResult { + id, err := responsibility.Remove(ctx, client, target(community), op.Role, op.UserID) + if err != nil { + return errorResult(op, err) + } + return OperationResult{Operation: op.Type, Status: "success", PreviousValue: id} +} + +func target(community clients.CommunityDetails) responsibility.Target { + return responsibility.Target{Type: responsibility.ResourceCommunity, ID: community.ID} +} + +func parentName(parent *clients.ContainerRef) string { + if parent == nil { + return "(top-level)" + } + return parent.Name +} + +func errorResult(op Operation, err error) OperationResult { + return OperationResult{Operation: op.Type, Status: "error", Error: err.Error()} +} + +func overallStatus(results []OperationResult) OutputStatus { + successes := 0 + for _, r := range results { + if r.Status == "success" { + successes++ + } + } + switch successes { + case len(results): + return StatusSuccess + case 0: + return StatusError + default: + return StatusPartialSuccess + } +} + +// readCommunity re-reads the community for the post-edit snapshot; nil if unreadable. +func readCommunity(ctx context.Context, client *http.Client, id string) *CommunitySummary { + c, err := clients.GetCommunity(ctx, client, id) + if err != nil { + return nil + } + s := &CommunitySummary{ID: c.ID, Name: c.Name, Description: c.Description} + if c.Parent != nil { + s.ParentID, s.Parent = c.Parent.ID, c.Parent.Name + } + return s +} diff --git a/pkg/tools/edit_community/tool_test.go b/pkg/tools/edit_community/tool_test.go new file mode 100644 index 0000000..14ca74a --- /dev/null +++ b/pkg/tools/edit_community/tool_test.go @@ -0,0 +1,256 @@ +package edit_community_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/collibra/chip/pkg/clients" + tool "github.com/collibra/chip/pkg/tools/edit_community" + "github.com/collibra/chip/pkg/tools/testutil" +) + +const ( + peopleID = "cccccccc-0000-0000-0000-000000000001" + aiID = "cccccccc-0000-0000-0000-000000000002" + roleID = "bbbbbbbb-0000-0000-0000-000000000001" + userID = "aaaaaaaa-0000-0000-0000-000000000001" + respID = "eeeeeeee-0000-0000-0000-000000000001" + inheritID = "eeeeeeee-0000-0000-0000-000000000002" +) + +type stub struct { + communities map[string]*clients.CommunityDetails + patches []map[string]any + ignoreClear bool // simulate the platform silently ignoring parentId:null + created []clients.EditAssetCreateResponsibilityRequest + deleted []string + responsibilites []clients.Responsibility +} + +func newStub() *stub { + return &stub{communities: map[string]*clients.CommunityDetails{ + peopleID: {ID: peopleID, Name: "People", Description: "old", Parent: &clients.ContainerRef{ID: aiID, Name: "AI Community"}}, + aiID: {ID: aiID, Name: "AI Community"}, + }} +} + +func (s *stub) client(t *testing.T) *http.Client { + t.Helper() + mux := http.NewServeMux() + mux.HandleFunc("GET /rest/2.0/communities/{id}", func(w http.ResponseWriter, r *http.Request) { + c, ok := s.communities[r.PathValue("id")] + if !ok { + w.WriteHeader(http.StatusNotFound) + return + } + _ = json.NewEncoder(w).Encode(c) + }) + mux.HandleFunc("GET /rest/2.0/communities", func(w http.ResponseWriter, r *http.Request) { + needle := strings.ToLower(r.URL.Query().Get("name")) + var out []*clients.CommunityDetails + for _, c := range s.communities { + if strings.Contains(strings.ToLower(c.Name), needle) { + out = append(out, c) + } + } + _ = json.NewEncoder(w).Encode(map[string]any{"results": out}) + }) + mux.HandleFunc("PATCH /rest/2.0/communities/{id}", func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + s.patches = append(s.patches, body) + c := s.communities[r.PathValue("id")] + if v, ok := body["name"].(string); ok { + c.Name = v + } + if v, ok := body["description"].(string); ok { + c.Description = v + } + if v, ok := body["parentId"]; ok { + switch p := v.(type) { + case string: + c.Parent = &clients.ContainerRef{ID: p, Name: s.communities[p].Name} + case nil: + if !s.ignoreClear { + c.Parent = nil + } + } + } + _ = json.NewEncoder(w).Encode(c) + }) + mux.HandleFunc("GET /rest/2.0/roles", func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{"total": 1, "results": []clients.EditAssetRole{{ID: roleID, Name: "Steward"}}}) + }) + mux.HandleFunc("GET /rest/2.0/responsibilities", func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(clients.ResponsibilityPagedResponse{Results: s.responsibilites}) + }) + mux.HandleFunc("POST /rest/2.0/responsibilities", func(w http.ResponseWriter, r *http.Request) { + var req clients.EditAssetCreateResponsibilityRequest + _ = json.NewDecoder(r.Body).Decode(&req) + s.created = append(s.created, req) + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(clients.EditAssetResponsibility{ID: respID}) + }) + mux.HandleFunc("DELETE /rest/2.0/responsibilities/{id}", func(w http.ResponseWriter, r *http.Request) { + s.deleted = append(s.deleted, r.PathValue("id")) + w.WriteHeader(http.StatusNoContent) + }) + server := httptest.NewServer(mux) + t.Cleanup(server.Close) + return testutil.NewClient(server) +} + +func run(t *testing.T, s *stub, input tool.Input) tool.Output { + t.Helper() + out, err := tool.NewTool(s.client(t)).Handler(t.Context(), input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + return out +} + +func TestEditCommunity_RenameAndDescribe(t *testing.T) { + s := newStub() + out := run(t, s, tool.Input{Community: "people", Operations: []tool.Operation{ + {Type: tool.OpSetName, Value: "Staff"}, + {Type: tool.OpSetDescription, Value: "new"}, + }}) + if out.Status != tool.StatusSuccess { + t.Fatalf("status = %q: %+v", out.Status, out.Results) + } + if out.Community.Name != "Staff" || out.Community.Description != "new" { + t.Fatalf("snapshot = %+v", out.Community) + } + if out.Results[0].PreviousValue != "People" || out.Results[1].PreviousValue != "old" { + t.Fatalf("previous values = %+v", out.Results) + } +} + +func TestEditCommunity_SetParentByName(t *testing.T) { + s := newStub() + s.communities[peopleID].Parent = nil + out := run(t, s, tool.Input{Community: peopleID, Operations: []tool.Operation{{Type: tool.OpSetParent, Parent: "AI Community"}}}) + if out.Status != tool.StatusSuccess || out.Community.ParentID != aiID { + t.Fatalf("status=%q snapshot=%+v results=%+v", out.Status, out.Community, out.Results) + } +} + +func TestEditCommunity_MakeTopLevelSendsExplicitNull(t *testing.T) { + s := newStub() + out := run(t, s, tool.Input{Community: peopleID, Operations: []tool.Operation{{Type: tool.OpSetParent, MakeTopLevel: true}}}) + if out.Status != tool.StatusSuccess || out.Community.Parent != "" { + t.Fatalf("status=%q snapshot=%+v results=%+v", out.Status, out.Community, out.Results) + } + if v, ok := s.patches[0]["parentId"]; !ok || v != nil { + t.Fatalf("expected explicit parentId:null, got %v", s.patches[0]) + } +} + +// Collibra answers 200 to a clear-parent request without clearing it (DEV-217539). +func TestEditCommunity_MakeTopLevelSilentNoOpIsAnError(t *testing.T) { + s := newStub() + s.ignoreClear = true + out := run(t, s, tool.Input{Community: peopleID, Operations: []tool.Operation{{Type: tool.OpSetParent, MakeTopLevel: true}}}) + if out.Status != tool.StatusError || !strings.Contains(out.Results[0].Error, "still under parent") { + t.Fatalf("status=%q results=%+v", out.Status, out.Results) + } +} + +func TestEditCommunity_SetParentRequiresExactlyOne(t *testing.T) { + for name, op := range map[string]tool.Operation{ + "neither": {Type: tool.OpSetParent}, + "both": {Type: tool.OpSetParent, Parent: "AI Community", MakeTopLevel: true}, + } { + t.Run(name, func(t *testing.T) { + s := newStub() + out := run(t, s, tool.Input{Community: peopleID, Operations: []tool.Operation{op}}) + if out.Status != tool.StatusError || !strings.Contains(out.Results[0].Error, "exactly one") { + t.Fatalf("status=%q results=%+v", out.Status, out.Results) + } + if len(s.patches) != 0 { + t.Fatalf("nothing should be written, got %v", s.patches) + } + }) + } +} + +func TestEditCommunity_OwnParentRejected(t *testing.T) { + out := run(t, newStub(), tool.Input{Community: peopleID, Operations: []tool.Operation{{Type: tool.OpSetParent, Parent: peopleID}}}) + if out.Status != tool.StatusError || !strings.Contains(out.Results[0].Error, "own parent") { + t.Fatalf("results=%+v", out.Results) + } +} + +func TestEditCommunity_FailingOperationDoesNotBlockSiblings(t *testing.T) { + s := newStub() + out := run(t, s, tool.Input{Community: peopleID, Operations: []tool.Operation{ + {Type: tool.OpSetName, Value: " "}, + {Type: tool.OpSetDescription, Value: "new"}, + {Type: "bogus"}, + }}) + if out.Status != tool.StatusPartialSuccess { + t.Fatalf("status = %q: %+v", out.Status, out.Results) + } + if out.Results[0].Status != "error" || out.Results[1].Status != "success" || out.Results[2].Status != "error" { + t.Fatalf("results = %+v", out.Results) + } +} + +func TestEditCommunity_AmbiguousNameReturnsCandidates(t *testing.T) { + s := newStub() + s.communities["dup"] = &clients.CommunityDetails{ID: "dup", Name: "people"} + _, err := tool.NewTool(s.client(t)).Handler(t.Context(), tool.Input{Community: "People", Operations: []tool.Operation{{Type: tool.OpSetName, Value: "x"}}}) + if err == nil || !strings.Contains(err.Error(), "ambiguous") || !strings.Contains(err.Error(), "dup") { + t.Fatalf("err = %v", err) + } +} + +func TestEditCommunity_EmptyOperationsIsAnError(t *testing.T) { + if _, err := tool.NewTool(newStub().client(t)).Handler(t.Context(), tool.Input{Community: peopleID}); err == nil { + t.Fatal("expected error") + } +} + +func TestEditCommunity_SetResponsibilityUsesCommunityResourceType(t *testing.T) { + s := newStub() + out := run(t, s, tool.Input{Community: peopleID, Operations: []tool.Operation{{Type: tool.OpSetResponsibility, Role: "steward", UserID: userID}}}) + if out.Status != tool.StatusSuccess { + t.Fatalf("results = %+v", out.Results) + } + want := clients.EditAssetCreateResponsibilityRequest{RoleID: roleID, OwnerID: userID, ResourceID: peopleID, ResourceType: "Community"} + if len(s.created) != 1 || s.created[0] != want { + t.Fatalf("created = %+v, want %+v", s.created, want) + } +} + +func TestEditCommunity_RemoveResponsibility(t *testing.T) { + s := newStub() + s.responsibilites = []clients.Responsibility{ + {ID: inheritID, Role: &clients.ResourceRole{ID: roleID}, Owner: &clients.ResourceRef{ID: "other"}, BaseResource: &clients.ResourceRef{ID: aiID}}, + {ID: respID, Role: &clients.ResourceRole{ID: roleID}, Owner: &clients.ResourceRef{ID: userID}, BaseResource: &clients.ResourceRef{ID: peopleID}}, + } + out := run(t, s, tool.Input{Community: peopleID, Operations: []tool.Operation{ + {Type: tool.OpRemoveResponsibility, Role: "Steward", UserID: userID}, + {Type: tool.OpRemoveResponsibility, Role: "Steward", UserID: "aaaaaaaa-0000-0000-0000-000000000009"}, + }}) + if len(s.deleted) != 1 || s.deleted[0] != respID { + t.Fatalf("deleted = %v", s.deleted) + } + if out.Status != tool.StatusPartialSuccess || !strings.Contains(out.Results[1].Error, "no \"Steward\" responsibility") { + t.Fatalf("status=%q results=%+v", out.Status, out.Results) + } +} + +func TestEditCommunity_RemoveInheritedResponsibilityExplainsWhy(t *testing.T) { + s := newStub() + s.responsibilites = []clients.Responsibility{ + {ID: inheritID, Role: &clients.ResourceRole{ID: roleID}, Owner: &clients.ResourceRef{ID: userID}, BaseResource: &clients.ResourceRef{ID: aiID}}, + } + out := run(t, s, tool.Input{Community: peopleID, Operations: []tool.Operation{{Type: tool.OpRemoveResponsibility, Role: "Steward", UserID: userID}}}) + if len(s.deleted) != 0 || !strings.Contains(out.Results[0].Error, "inherited") { + t.Fatalf("deleted=%v results=%+v", s.deleted, out.Results) + } +} diff --git a/pkg/tools/edit_domain/tool.go b/pkg/tools/edit_domain/tool.go new file mode 100644 index 0000000..dae36e2 --- /dev/null +++ b/pkg/tools/edit_domain/tool.go @@ -0,0 +1,246 @@ +// Package edit_domain implements the edit_domain MCP tool: a single entry +// point for renaming a domain, changing its description, moving it to another +// community, and managing its role assignments (responsibilities) via a typed +// list of operations. Each operation is validated independently and applied on +// its own, so one failing operation never blocks its valid siblings. +package edit_domain + +import ( + "context" + "fmt" + "net/http" + "strings" + + "github.com/collibra/chip/pkg/chip" + "github.com/collibra/chip/pkg/clients" + "github.com/collibra/chip/pkg/tools/resolve" + "github.com/collibra/chip/pkg/tools/responsibility" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// OperationType enumerates the edits edit_domain can perform. +type OperationType string + +const ( + OpSetName OperationType = "set_name" + OpSetDescription OperationType = "set_description" + OpSetCommunity OperationType = "set_community" + OpSetResponsibility OperationType = "set_responsibility" + OpRemoveResponsibility OperationType = "remove_responsibility" +) + +// Input is the tool's typed input. +type Input struct { + Domain string `json:"domain" jsonschema:"Required. The domain to edit — its UUID or its name (an exact, case-insensitive match; an ambiguous name returns the candidates with their UUIDs)."` + Operations []Operation `json:"operations" jsonschema:"Required. Non-empty list of operations to apply. Each operation is validated and applied independently. Each operation's type selects which additional fields are used (see Operation)."` +} + +// Operation is a discriminated union: the 'type' field selects which other +// fields are interpreted. Unused fields are ignored. +type Operation struct { + Type OperationType `json:"type" jsonschema:"Required. One of: set_name, set_description, set_community, set_responsibility, remove_responsibility."` + + Value string `json:"value,omitempty" jsonschema:"For set_name: the new domain name (must not be empty). For set_description: the new description (an empty string clears it)."` + + Community string `json:"community,omitempty" jsonschema:"For set_community: the community to move the domain into, given as its UUID or name."` + + Role string `json:"role,omitempty" jsonschema:"For set_responsibility and remove_responsibility: the resource role name, e.g. 'Steward' or 'Owner'."` + UserID string `json:"userId,omitempty" jsonschema:"For set_responsibility and remove_responsibility: the user or group, given as a UUID, email address, username, or full name such as 'Jane Smith'. A full name shared by several users returns a per-operation error listing the candidates."` +} + +// OutputStatus summarises the result of the call. +type OutputStatus string + +const ( + StatusSuccess OutputStatus = "success" + StatusPartialSuccess OutputStatus = "partial_success" + StatusError OutputStatus = "error" +) + +// Output is the tool's typed output. +type Output struct { + Status OutputStatus `json:"status" jsonschema:"Overall status: success if every operation applied, partial_success if some succeeded and some failed, error if every operation failed."` + Results []OperationResult `json:"results" jsonschema:"Per-operation outcomes, in the same order as the input operations."` + Domain *DomainSummary `json:"domain,omitempty" jsonschema:"The domain's state after the edits. Present when the domain could be re-read."` +} + +// DomainSummary is the post-edit snapshot of the domain. +type DomainSummary struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + CommunityID string `json:"communityId,omitempty"` + Community string `json:"community,omitempty"` +} + +// OperationResult is the outcome of a single operation. +type OperationResult struct { + Operation OperationType `json:"operation"` + Status string `json:"status" jsonschema:"'success' or 'error'."` + PreviousValue string `json:"previousValue,omitempty"` + NewValue string `json:"newValue,omitempty"` + Error string `json:"error,omitempty"` +} + +// NewTool returns the registered tool. +func NewTool(collibraClient *http.Client) *chip.Tool[Input, Output] { + return &chip.Tool[Input, Output]{ + Name: "edit_domain", + Title: "Edit Domain", + Description: "Edit an existing Collibra domain (identified by UUID or name) by submitting a list of typed operations. " + + "Supported operations: " + + "set_name (rename the domain); " + + "set_description (change the description; an empty value clears it); " + + "set_community (move the domain to a different community, given as a UUID or name); " + + "set_responsibility (assign a user or group to a resource role such as 'Steward' or 'Owner'; the user can be a UUID, username, email address, or full name such as 'Jane Smith'); " + + "remove_responsibility (unassign a user or group from a role; removes only a responsibility assigned directly on the domain, not one inherited from its community). " + + "Each operation is validated independently and applied on its own: invalid or failing operations return per-operation errors while valid siblings still apply, yielding status=success, partial_success, or error. " + + "On completion the response includes the domain's state after the edits.", + Handler: handler(collibraClient), + Permissions: []string{}, + Annotations: &mcp.ToolAnnotations{ReadOnlyHint: false, DestructiveHint: chip.Ptr(true), IdempotentHint: false, OpenWorldHint: chip.Ptr(false)}, + } +} + +func handler(collibraClient *http.Client) chip.ToolHandlerFunc[Input, Output] { + return func(ctx context.Context, input Input) (Output, error) { + if len(input.Operations) == 0 { + return Output{}, fmt.Errorf("operations must not be empty") + } + domain, err := resolve.Domain(ctx, collibraClient, input.Domain, resolve.Hints{}) + if err != nil { + return Output{}, err + } + + // Validate every operation before any write, then apply the valid ones. + results := make([]OperationResult, len(input.Operations)) + for i, op := range input.Operations { + if verr := validateOperation(op); verr != nil { + results[i] = errorResult(op, verr) + continue + } + results[i] = executeOperation(ctx, collibraClient, domain, op) + } + + out := Output{Status: overallStatus(results), Results: results} + out.Domain = readDomain(ctx, collibraClient, domain.ID) + return out, nil + } +} + +// validateOperation runs the checks that need no request. +func validateOperation(op Operation) error { + switch op.Type { + case OpSetName: + if strings.TrimSpace(op.Value) == "" { + return fmt.Errorf("value is required for set_name and must not be empty") + } + case OpSetDescription: + // An empty value is a valid way to clear the description. + case OpSetCommunity: + if strings.TrimSpace(op.Community) == "" { + return fmt.Errorf("community is required for set_community (a UUID or name)") + } + case OpSetResponsibility, OpRemoveResponsibility: + return responsibility.Check(op.Role, op.UserID) + default: + return fmt.Errorf("unknown operation type %q; expected one of set_name, set_description, set_community, set_responsibility, remove_responsibility", op.Type) + } + return nil +} + +func executeOperation(ctx context.Context, client *http.Client, domain clients.DomainDetails, op Operation) OperationResult { + switch op.Type { + case OpSetName: + return patchField(ctx, client, domain, op, domain.Name, op.Value, clients.DomainPatch{Name: chip.Ptr(op.Value)}) + case OpSetDescription: + return patchField(ctx, client, domain, op, domain.Description, op.Value, clients.DomainPatch{Description: chip.Ptr(op.Value)}) + case OpSetCommunity: + return executeSetCommunity(ctx, client, domain, op) + case OpSetResponsibility: + return executeSetResponsibility(ctx, client, domain, op) + default: + return executeRemoveResponsibility(ctx, client, domain, op) + } +} + +func patchField(ctx context.Context, client *http.Client, domain clients.DomainDetails, op Operation, previous, next string, patch clients.DomainPatch) OperationResult { + if _, err := clients.PatchDomain(ctx, client, domain.ID, patch); err != nil { + return errorResult(op, err) + } + return OperationResult{Operation: op.Type, Status: "success", PreviousValue: previous, NewValue: next} +} + +func executeSetCommunity(ctx context.Context, client *http.Client, domain clients.DomainDetails, op Operation) OperationResult { + community, err := resolve.Community(ctx, client, op.Community, resolve.Hints{}) + if err != nil { + return errorResult(op, err) + } + updated, err := clients.PatchDomain(ctx, client, domain.ID, clients.DomainPatch{CommunityID: chip.Ptr(community.ID)}) + if err != nil { + return errorResult(op, err) + } + // Trust the response, not the status code: a no-op must not report success. + if updated.Community == nil || updated.Community.ID != community.ID { + return errorResult(op, fmt.Errorf("the request succeeded but the domain is not in community %q afterwards", community.Name)) + } + res := OperationResult{Operation: op.Type, Status: "success", NewValue: community.Name} + if domain.Community != nil { + res.PreviousValue = domain.Community.Name + } + return res +} + +func executeSetResponsibility(ctx context.Context, client *http.Client, domain clients.DomainDetails, op Operation) OperationResult { + id, err := responsibility.Set(ctx, client, target(domain), op.Role, op.UserID) + if err != nil { + return errorResult(op, err) + } + return OperationResult{Operation: op.Type, Status: "success", NewValue: id} +} + +func executeRemoveResponsibility(ctx context.Context, client *http.Client, domain clients.DomainDetails, op Operation) OperationResult { + id, err := responsibility.Remove(ctx, client, target(domain), op.Role, op.UserID) + if err != nil { + return errorResult(op, err) + } + return OperationResult{Operation: op.Type, Status: "success", PreviousValue: id} +} + +func target(domain clients.DomainDetails) responsibility.Target { + return responsibility.Target{Type: responsibility.ResourceDomain, ID: domain.ID} +} + +func errorResult(op Operation, err error) OperationResult { + return OperationResult{Operation: op.Type, Status: "error", Error: err.Error()} +} + +func overallStatus(results []OperationResult) OutputStatus { + successes := 0 + for _, r := range results { + if r.Status == "success" { + successes++ + } + } + switch successes { + case len(results): + return StatusSuccess + case 0: + return StatusError + default: + return StatusPartialSuccess + } +} + +// readDomain re-reads the domain for the post-edit snapshot; nil if unreadable. +func readDomain(ctx context.Context, client *http.Client, id string) *DomainSummary { + d, err := clients.GetDomain(ctx, client, id) + if err != nil { + return nil + } + s := &DomainSummary{ID: d.ID, Name: d.Name, Description: d.Description} + if d.Community != nil { + s.CommunityID, s.Community = d.Community.ID, d.Community.Name + } + return s +} diff --git a/pkg/tools/edit_domain/tool_test.go b/pkg/tools/edit_domain/tool_test.go new file mode 100644 index 0000000..f6ce104 --- /dev/null +++ b/pkg/tools/edit_domain/tool_test.go @@ -0,0 +1,177 @@ +package edit_domain_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/collibra/chip/pkg/clients" + tool "github.com/collibra/chip/pkg/tools/edit_domain" + "github.com/collibra/chip/pkg/tools/testutil" +) + +const ( + domainID = "dddddddd-0000-0000-0000-000000000001" + commAID = "cccccccc-0000-0000-0000-00000000000a" + commBID = "cccccccc-0000-0000-0000-00000000000b" + roleID = "bbbbbbbb-0000-0000-0000-000000000001" + userID = "aaaaaaaa-0000-0000-0000-000000000001" +) + +type stub struct { + domain clients.DomainDetails + patches []map[string]any + created []clients.EditAssetCreateResponsibilityRequest + noMove bool // simulate a PATCH that returns 200 without moving the domain +} + +func newStub() *stub { + return &stub{domain: clients.DomainDetails{ID: domainID, Name: "Wellness", Description: "old", Community: &clients.ContainerRef{ID: commAID, Name: "HR"}}} +} + +func (s *stub) client(t *testing.T) *http.Client { + t.Helper() + communities := map[string]clients.CommunityDetails{ + commAID: {ID: commAID, Name: "HR"}, + commBID: {ID: commBID, Name: "Employee Wellness"}, + } + mux := http.NewServeMux() + mux.HandleFunc("GET /rest/2.0/domains/{id}", func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(s.domain) + }) + mux.HandleFunc("GET /rest/2.0/domains", func(w http.ResponseWriter, r *http.Request) { + var out []clients.DomainDetails + if strings.Contains(strings.ToLower(s.domain.Name), strings.ToLower(r.URL.Query().Get("name"))) { + out = append(out, s.domain) + } + _ = json.NewEncoder(w).Encode(map[string]any{"results": out}) + }) + mux.HandleFunc("PATCH /rest/2.0/domains/{id}", func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + s.patches = append(s.patches, body) + if v, ok := body["name"].(string); ok { + s.domain.Name = v + } + if v, ok := body["description"].(string); ok { + s.domain.Description = v + } + if v, ok := body["communityId"].(string); ok && !s.noMove { + c := communities[v] + s.domain.Community = &clients.ContainerRef{ID: c.ID, Name: c.Name} + } + _ = json.NewEncoder(w).Encode(s.domain) + }) + mux.HandleFunc("GET /rest/2.0/communities/{id}", func(w http.ResponseWriter, r *http.Request) { + c, ok := communities[r.PathValue("id")] + if !ok { + w.WriteHeader(http.StatusNotFound) + return + } + _ = json.NewEncoder(w).Encode(c) + }) + mux.HandleFunc("GET /rest/2.0/communities", func(w http.ResponseWriter, r *http.Request) { + var out []clients.CommunityDetails + for _, c := range communities { + if strings.Contains(strings.ToLower(c.Name), strings.ToLower(r.URL.Query().Get("name"))) { + out = append(out, c) + } + } + _ = json.NewEncoder(w).Encode(map[string]any{"results": out}) + }) + mux.HandleFunc("GET /rest/2.0/roles", func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{"total": 1, "results": []clients.EditAssetRole{{ID: roleID, Name: "Steward"}}}) + }) + mux.HandleFunc("POST /rest/2.0/responsibilities", func(w http.ResponseWriter, r *http.Request) { + var req clients.EditAssetCreateResponsibilityRequest + _ = json.NewDecoder(r.Body).Decode(&req) + s.created = append(s.created, req) + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(clients.EditAssetResponsibility{ID: "new"}) + }) + server := httptest.NewServer(mux) + t.Cleanup(server.Close) + return testutil.NewClient(server) +} + +func run(t *testing.T, s *stub, input tool.Input) tool.Output { + t.Helper() + out, err := tool.NewTool(s.client(t)).Handler(t.Context(), input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + return out +} + +func TestEditDomain_RenameDescribeAndMove(t *testing.T) { + s := newStub() + out := run(t, s, tool.Input{Domain: "wellness", Operations: []tool.Operation{ + {Type: tool.OpSetName, Value: "Staff Wellness"}, + {Type: tool.OpSetDescription, Value: ""}, + {Type: tool.OpSetCommunity, Community: "Employee Wellness"}, + }}) + if out.Status != tool.StatusSuccess { + t.Fatalf("status = %q: %+v", out.Status, out.Results) + } + if out.Domain.Name != "Staff Wellness" || out.Domain.Description != "" || out.Domain.CommunityID != commBID { + t.Fatalf("snapshot = %+v", out.Domain) + } + if out.Results[2].PreviousValue != "HR" || out.Results[2].NewValue != "Employee Wellness" { + t.Fatalf("move result = %+v", out.Results[2]) + } +} + +func TestEditDomain_SilentNoOpMoveIsAnError(t *testing.T) { + s := newStub() + s.noMove = true + out := run(t, s, tool.Input{Domain: domainID, Operations: []tool.Operation{{Type: tool.OpSetCommunity, Community: commBID}}}) + if out.Status != tool.StatusError || !strings.Contains(out.Results[0].Error, "not in community") { + t.Fatalf("status=%q results=%+v", out.Status, out.Results) + } +} + +func TestEditDomain_FailingOperationDoesNotBlockSiblings(t *testing.T) { + s := newStub() + out := run(t, s, tool.Input{Domain: domainID, Operations: []tool.Operation{ + {Type: tool.OpSetCommunity}, + {Type: tool.OpSetCommunity, Community: "No Such Community"}, + {Type: tool.OpSetName, Value: "Renamed"}, + }}) + if out.Status != tool.StatusPartialSuccess { + t.Fatalf("status = %q: %+v", out.Status, out.Results) + } + if out.Results[0].Status != "error" || out.Results[1].Status != "error" || out.Results[2].Status != "success" { + t.Fatalf("results = %+v", out.Results) + } + if len(s.patches) != 1 { + t.Fatalf("only the valid rename should be written, got %v", s.patches) + } +} + +func TestEditDomain_SetResponsibilityUsesDomainResourceType(t *testing.T) { + s := newStub() + out := run(t, s, tool.Input{Domain: domainID, Operations: []tool.Operation{{Type: tool.OpSetResponsibility, Role: "Steward", UserID: userID}}}) + if out.Status != tool.StatusSuccess { + t.Fatalf("results = %+v", out.Results) + } + want := clients.EditAssetCreateResponsibilityRequest{RoleID: roleID, OwnerID: userID, ResourceID: domainID, ResourceType: "Domain"} + if len(s.created) != 1 || s.created[0] != want { + t.Fatalf("created = %+v, want %+v", s.created, want) + } +} + +func TestEditDomain_MissingDomainIsAnError(t *testing.T) { + s := newStub() + _, err := tool.NewTool(s.client(t)).Handler(t.Context(), tool.Input{Domain: "Nope", Operations: []tool.Operation{{Type: tool.OpSetName, Value: "x"}}}) + if err == nil || !strings.Contains(err.Error(), "no domain matching") { + t.Fatalf("err = %v", err) + } +} + +func TestEditDomain_EmptyOperationsIsAnError(t *testing.T) { + if _, err := tool.NewTool(newStub().client(t)).Handler(t.Context(), tool.Input{Domain: domainID}); err == nil { + t.Fatal("expected error") + } +} diff --git a/pkg/tools/register.go b/pkg/tools/register.go index 02f680f..f4fee7b 100644 --- a/pkg/tools/register.go +++ b/pkg/tools/register.go @@ -19,6 +19,8 @@ import ( "github.com/collibra/chip/pkg/tools/discover_data_assets" "github.com/collibra/chip/pkg/tools/edit_assessment" "github.com/collibra/chip/pkg/tools/edit_asset" + "github.com/collibra/chip/pkg/tools/edit_community" + "github.com/collibra/chip/pkg/tools/edit_domain" "github.com/collibra/chip/pkg/tools/find_dq_rules" "github.com/collibra/chip/pkg/tools/generate_dq_rule_sql" "github.com/collibra/chip/pkg/tools/get_assessment" @@ -99,6 +101,8 @@ func RegisterAll(server *chip.Server, client *http.Client, toolConfig *chip.Serv toolRegister(server, toolConfig, prepare_create_asset.NewTool(client)) toolRegister(server, toolConfig, create_asset.NewTool(client)) toolRegister(server, toolConfig, edit_asset.NewTool(client)) + toolRegister(server, toolConfig, edit_domain.NewTool(client)) + toolRegister(server, toolConfig, edit_community.NewTool(client)) // Registered WITHOUT an experimental flag, deviating from // docs/TOOL_CONTRIBUTION_STANDARDS.md 3.1: this resolver only feeds the // user-valued parameters of tools that are themselves ungated (the diff --git a/pkg/tools/resolve/container.go b/pkg/tools/resolve/container.go new file mode 100644 index 0000000..835aeb2 --- /dev/null +++ b/pkg/tools/resolve/container.go @@ -0,0 +1,85 @@ +package resolve + +import ( + "context" + "fmt" + "net/http" + "strings" + + "github.com/collibra/chip/pkg/clients" + "github.com/google/uuid" +) + +// Community resolves a community reference — a UUID or an exact +// (case-insensitive) name — to that community. A UUID is looked up directly so +// a missing one fails loudly; a name that matches several communities is an +// error listing each with its UUID and parent, never an arbitrary pick. +func Community(ctx context.Context, client *http.Client, value string, hints Hints) (clients.CommunityDetails, error) { + v := strings.TrimSpace(value) + if v == "" { + return clients.CommunityDetails{}, fmt.Errorf("a community is required (its UUID or name)") + } + if _, err := uuid.Parse(v); err == nil { + c, err := clients.GetCommunity(ctx, client, v) + if err != nil { + return clients.CommunityDetails{}, fmt.Errorf("community %s not found or unreadable: %w", v, err) + } + return *c, nil + } + + found, err := clients.SearchCommunities(ctx, client, v) + if err != nil { + return clients.CommunityDetails{}, fmt.Errorf("looking up community %q: %w", v, err) + } + byID := make(map[string]clients.CommunityDetails, len(found)) + candidates := make([]NamedRef, len(found)) + for i, c := range found { + byID[c.ID] = c + ctxText := "top-level" + if c.Parent != nil { + ctxText = "parent " + c.Parent.Name + } + candidates[i] = NamedRef{ID: c.ID, Name: c.Name, Ctx: ctxText} + } + id, err := PickMatch("community", v, candidates, hints) + if err != nil { + return clients.CommunityDetails{}, err + } + return byID[id], nil +} + +// Domain resolves a domain reference — a UUID or an exact (case-insensitive) +// name — to that domain, with the same contract as Community. +func Domain(ctx context.Context, client *http.Client, value string, hints Hints) (clients.DomainDetails, error) { + v := strings.TrimSpace(value) + if v == "" { + return clients.DomainDetails{}, fmt.Errorf("a domain is required (its UUID or name)") + } + if _, err := uuid.Parse(v); err == nil { + d, err := clients.GetDomain(ctx, client, v) + if err != nil { + return clients.DomainDetails{}, fmt.Errorf("domain %s not found or unreadable: %w", v, err) + } + return *d, nil + } + + found, err := clients.SearchDomains(ctx, client, v) + if err != nil { + return clients.DomainDetails{}, fmt.Errorf("looking up domain %q: %w", v, err) + } + byID := make(map[string]clients.DomainDetails, len(found)) + candidates := make([]NamedRef, len(found)) + for i, d := range found { + byID[d.ID] = d + ctxText := "" + if d.Community != nil { + ctxText = "community " + d.Community.Name + } + candidates[i] = NamedRef{ID: d.ID, Name: d.Name, Ctx: ctxText} + } + id, err := PickMatch("domain", v, candidates, hints) + if err != nil { + return clients.DomainDetails{}, err + } + return byID[id], nil +} diff --git a/pkg/tools/responsibility/responsibility.go b/pkg/tools/responsibility/responsibility.go new file mode 100644 index 0000000..161c934 --- /dev/null +++ b/pkg/tools/responsibility/responsibility.go @@ -0,0 +1,117 @@ +// Package responsibility holds the role-assignment logic shared by the edit +// tools for resources that carry responsibilities (domains, communities): +// assigning a user or group to a resource role, and removing that assignment. +// It reuses Collibra's generic /responsibilities endpoints, which take the +// resource type ("Domain", "Community", ...) alongside the resource id. +package responsibility + +import ( + "context" + "fmt" + "net/http" + "strings" + + "github.com/collibra/chip/pkg/clients" + "github.com/collibra/chip/pkg/tools/resolve" +) + +// Resource types accepted by the responsibilities API. +const ( + ResourceDomain = "Domain" + ResourceCommunity = "Community" +) + +// Target identifies the resource whose responsibilities are being edited. +type Target struct { + Type string + ID string +} + +// Check validates the inputs shared by set and remove, without any request. +func Check(role, userID string) error { + if strings.TrimSpace(role) == "" { + return fmt.Errorf("role is required (e.g. 'Steward', 'Owner')") + } + if strings.TrimSpace(userID) == "" { + return fmt.Errorf(`userId is required (a UUID, email address, username, or full name such as "Jane Smith")`) + } + return nil +} + +// Set assigns the user (or group, by UUID) to the named role on the target and +// returns the new responsibility's id. +func Set(ctx context.Context, client *http.Client, target Target, role, userID string) (string, error) { + roleID, err := resolveRole(ctx, client, role) + if err != nil { + return "", err + } + ownerID, err := resolve.UserID(ctx, client, userID, resolve.Hints{}) + if err != nil { + return "", err + } + created, err := clients.CreateResponsibility(ctx, client, clients.EditAssetCreateResponsibilityRequest{ + RoleID: roleID, + OwnerID: ownerID, + ResourceID: target.ID, + ResourceType: target.Type, + }) + if err != nil { + return "", err + } + return created.ID, nil +} + +// Remove deletes the responsibility of the given role and user defined +// directly on the target, returning its id. A responsibility inherited from a +// parent community is reported as such rather than deleted. +func Remove(ctx context.Context, client *http.Client, target Target, role, userID string) (string, error) { + roleID, err := resolveRole(ctx, client, role) + if err != nil { + return "", err + } + ownerID, err := resolve.UserID(ctx, client, userID, resolve.Hints{}) + if err != nil { + return "", err + } + responsibilities, err := clients.GetResponsibilities(ctx, client, target.ID) + if err != nil { + return "", fmt.Errorf("looking up responsibilities: %w", err) + } + + matchID, inheritedOnly := findDirect(responsibilities, target.ID, roleID, ownerID) + if matchID == "" { + if inheritedOnly { + return "", fmt.Errorf("the %q responsibility for %q is inherited from a parent community and cannot be removed from this resource; remove it where it is defined", role, userID) + } + return "", fmt.Errorf("no %q responsibility found for %q on this resource", role, userID) + } + if err := clients.DeleteResponsibility(ctx, client, matchID); err != nil { + return "", err + } + return matchID, nil +} + +func findDirect(all []clients.Responsibility, resourceID, roleID, ownerID string) (id string, inheritedOnly bool) { + for _, r := range all { + if r.Role == nil || r.Role.ID != roleID || r.Owner == nil || r.Owner.ID != ownerID { + continue + } + if r.BaseResource != nil && r.BaseResource.ID == resourceID { + return r.ID, false + } + inheritedOnly = true + } + return "", inheritedOnly +} + +func resolveRole(ctx context.Context, client *http.Client, name string) (string, error) { + roles, err := clients.ListRoles(ctx, client) + if err != nil { + return "", fmt.Errorf("listing roles: %w", err) + } + candidates := make([]resolve.NamedRef, len(roles)) + for i, r := range roles { + candidates[i] = resolve.NamedRef{ID: r.ID, Name: r.Name} + } + return resolve.PickMatch("role", name, candidates, resolve.Hints{Candidates: "Roles"}) +} From 833e24d6e4c1fdb00e3fd7b5eab3dd6ca87f742b Mon Sep 17 00:00:00 2001 From: Andrey Gliznetsov Date: Tue, 3 Nov 2026 10:28:19 +0100 Subject: [PATCH 2/2] docs: expand edit_domain and edit_community tool descriptions Co-Authored-By: Claude Sonnet 5.5 --- pkg/tools/edit_community/tool.go | 17 +++++++++++------ pkg/tools/edit_domain/tool.go | 20 +++++++++++++------- 2 files changed, 24 insertions(+), 13 deletions(-) diff --git a/pkg/tools/edit_community/tool.go b/pkg/tools/edit_community/tool.go index c24a619..7ce6310 100644 --- a/pkg/tools/edit_community/tool.go +++ b/pkg/tools/edit_community/tool.go @@ -32,7 +32,7 @@ const ( // Input is the tool's typed input. type Input struct { - Community string `json:"community" jsonschema:"Required. The community to edit — its UUID or its name (an exact, case-insensitive match; an ambiguous name returns the candidates with their UUIDs)."` + Community string `json:"community" jsonschema:"Required. The community to edit (a folder that groups domains and other communities) — its UUID or its name (an exact, case-insensitive match; an ambiguous name returns the candidates with their UUIDs), e.g. 'Finance'."` Operations []Operation `json:"operations" jsonschema:"Required. Non-empty list of operations to apply. Each operation is validated and applied independently. Each operation's type selects which additional fields are used (see Operation)."` } @@ -46,7 +46,7 @@ type Operation struct { Parent string `json:"parent,omitempty" jsonschema:"For set_parent: the community to move this one under, given as its UUID or name. Provide exactly one of parent or makeTopLevel."` MakeTopLevel bool `json:"makeTopLevel,omitempty" jsonschema:"For set_parent: true to remove the parent and make the community top-level. Provide exactly one of parent or makeTopLevel."` - Role string `json:"role,omitempty" jsonschema:"For set_responsibility and remove_responsibility: the resource role name, e.g. 'Steward' or 'Owner'."` + Role string `json:"role,omitempty" jsonschema:"For set_responsibility and remove_responsibility: the resource role name (a named duty a person or group holds on the resource, matched case-insensitively), e.g. 'Steward' or 'Owner'; an unknown name returns the available roles."` UserID string `json:"userId,omitempty" jsonschema:"For set_responsibility and remove_responsibility: the user or group, given as a UUID, email address, username, or full name such as 'Jane Smith'. A full name shared by several users returns a per-operation error listing the candidates."` } @@ -89,16 +89,21 @@ func NewTool(collibraClient *http.Client) *chip.Tool[Input, Output] { return &chip.Tool[Input, Output]{ Name: "edit_community", Title: "Edit Community", - Description: "Edit an existing Collibra community (identified by UUID or name) by submitting a list of typed operations. " + - "Supported operations: " + + Description: "Edit an existing Collibra community (a community is a top-level or nested folder that groups domains and other communities, for example a business unit or program) identified by its UUID or name. " + + "Use this to rename a community, change its description, move it under a different parent community or to the top level, or manage who is responsible for it. " + + "To edit a domain (the folder inside a community that holds assets) use edit_domain; to edit assets use edit_asset; this tool cannot create or delete communities. " + + "The community must already exist; look up its UUID or exact name first if you only have a vague reference. " + + "Submit a list of typed operations: " + "set_name (rename the community); " + "set_description (change the description; an empty value clears it); " + "set_parent (move the community under a different parent community given as a UUID or name via 'parent', or pass makeTopLevel=true to remove the parent — exactly one of the two is required, neither is a validation error); " + - "set_responsibility (assign a user or group to a resource role such as 'Steward' or 'Owner'; the user can be a UUID, username, email address, or full name such as 'Jane Smith'); " + + "set_responsibility (assign a user or group to a resource role, a named duty such as 'Steward' or 'Owner'; the user can be a UUID, username, email address, or full name such as 'Jane Smith'); " + "remove_responsibility (unassign a user or group from a role; removes only a responsibility assigned directly on the community, not one inherited from a parent). " + "Each operation is validated independently and applied on its own: invalid or failing operations return per-operation errors while valid siblings still apply, yielding status=success, partial_success, or error. " + "set_parent verifies the result: if Collibra reports success but the parent did not change, the operation is reported as an error. " + - "On completion the response includes the community's state after the edits.", + "On completion the response includes the community's state after the edits. " + + "This tool writes to Collibra immediately and requires permission to edit the community. " + + "Example requests: 'Rename the People community to Staff', 'Move the Wellness community under the HR community', 'Make the Wellness community top-level', 'Make Jane Smith the Owner of the Finance community'.", Handler: handler(collibraClient), Permissions: []string{}, Annotations: &mcp.ToolAnnotations{ReadOnlyHint: false, DestructiveHint: chip.Ptr(true), IdempotentHint: false, OpenWorldHint: chip.Ptr(false)}, diff --git a/pkg/tools/edit_domain/tool.go b/pkg/tools/edit_domain/tool.go index dae36e2..28fe32d 100644 --- a/pkg/tools/edit_domain/tool.go +++ b/pkg/tools/edit_domain/tool.go @@ -31,7 +31,7 @@ const ( // Input is the tool's typed input. type Input struct { - Domain string `json:"domain" jsonschema:"Required. The domain to edit — its UUID or its name (an exact, case-insensitive match; an ambiguous name returns the candidates with their UUIDs)."` + Domain string `json:"domain" jsonschema:"Required. The domain to edit (the folder inside a community that holds assets) — its UUID or its name (an exact, case-insensitive match; an ambiguous name returns the candidates with their UUIDs), e.g. 'Employee Wellness'."` Operations []Operation `json:"operations" jsonschema:"Required. Non-empty list of operations to apply. Each operation is validated and applied independently. Each operation's type selects which additional fields are used (see Operation)."` } @@ -44,7 +44,7 @@ type Operation struct { Community string `json:"community,omitempty" jsonschema:"For set_community: the community to move the domain into, given as its UUID or name."` - Role string `json:"role,omitempty" jsonschema:"For set_responsibility and remove_responsibility: the resource role name, e.g. 'Steward' or 'Owner'."` + Role string `json:"role,omitempty" jsonschema:"For set_responsibility and remove_responsibility: the resource role name (a named duty a person or group holds on the resource, matched case-insensitively), e.g. 'Steward' or 'Owner'; an unknown name returns the available roles."` UserID string `json:"userId,omitempty" jsonschema:"For set_responsibility and remove_responsibility: the user or group, given as a UUID, email address, username, or full name such as 'Jane Smith'. A full name shared by several users returns a per-operation error listing the candidates."` } @@ -87,15 +87,21 @@ func NewTool(collibraClient *http.Client) *chip.Tool[Input, Output] { return &chip.Tool[Input, Output]{ Name: "edit_domain", Title: "Edit Domain", - Description: "Edit an existing Collibra domain (identified by UUID or name) by submitting a list of typed operations. " + - "Supported operations: " + + Description: "Edit an existing Collibra domain (a domain is the folder inside a community that holds assets such as business terms or tables; communities are the top-level folders that group domains) identified by its UUID or name. " + + "Use this to rename a domain, change its description, move it to a different community, or manage who is responsible for it. " + + "To edit the assets inside a domain use edit_asset; to edit a community itself use edit_community; this tool cannot create or delete domains. " + + "The domain must already exist; look up its UUID or exact name first if you only have a vague reference. " + + "Submit a list of typed operations: " + "set_name (rename the domain); " + "set_description (change the description; an empty value clears it); " + - "set_community (move the domain to a different community, given as a UUID or name); " + - "set_responsibility (assign a user or group to a resource role such as 'Steward' or 'Owner'; the user can be a UUID, username, email address, or full name such as 'Jane Smith'); " + + "set_community (move the domain into a different community, given as a UUID or name); " + + "set_responsibility (assign a user or group to a resource role, a named duty such as 'Steward' or 'Owner'; the user can be a UUID, username, email address, or full name such as 'Jane Smith'); " + "remove_responsibility (unassign a user or group from a role; removes only a responsibility assigned directly on the domain, not one inherited from its community). " + "Each operation is validated independently and applied on its own: invalid or failing operations return per-operation errors while valid siblings still apply, yielding status=success, partial_success, or error. " + - "On completion the response includes the domain's state after the edits.", + "set_community verifies the result, so a request Collibra accepts without moving the domain is reported as an error. " + + "On completion the response includes the domain's state after the edits. " + + "This tool writes to Collibra immediately and requires permission to edit the domain. " + + "Example requests: 'Rename the Employee Wellness domain to Staff Wellness', 'Move the Churn Metrics domain into the Marketing community', 'Make Jane Smith the Steward of the HR Glossary domain', 'Update the description of the Customer Data domain'.", Handler: handler(collibraClient), Permissions: []string{}, Annotations: &mcp.ToolAnnotations{ReadOnlyHint: false, DestructiveHint: chip.Ptr(true), IdempotentHint: false, OpenWorldHint: chip.Ptr(false)},