diff --git a/README.md b/README.md index cefaa43..d6948f0 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,8 @@ This Go-based MCP server acts as a bridge between AI applications and Collibra, - [`add_data_classification_match`](pkg/tools/add_data_classification_match/) - Associate a data class with an asset. **Requires:** `dgc.classify`, `dgc.catalog` - [`create_assessment`](pkg/tools/create_assessment/) - Conduct a new assessment from a template (given by name or UUID) in the Assessments application. Returns the template's (unanswered) questions to fill in afterward with `edit_assessment` — no separate prepare step needed. The owner and USER assignees accept a UUID, email, username, or full name - [`create_asset`](pkg/tools/create_asset/) - Create a new asset of any type. Resolves `assetType` (UUID, publicId, or display name), `domain` (UUID or name), `status` (UUID or name), and attributes (by name or typeId) server-side; converts Markdown to HTML for `RICH_TEXT` attributes; gates on duplicate-name (default `allowDuplicate: false`) +- [`create_community`](pkg/tools/create_community/) - Create a community, optionally nested under an existing `parentCommunity` (UUID or name). Confirm checkpoint: `confirm=false` (default) previews the community without creating it; `confirm=true` creates it. Requires permission to create communities +- [`create_domain`](pkg/tools/create_domain/) - Create a domain in an existing `community` with a given `domainType` (both accept a UUID or name; an unknown domain type returns the valid ones). Confirm checkpoint: `confirm=false` (default) previews the domain without creating it; `confirm=true` creates it. Requires permission to create domains in the community - [`create_data_quality_job`](pkg/tools/create_dq_job/) - Set up automated data-quality monitoring on a database table in Collibra, and start the first run. A data quality job profiles a table and watches it over time for problems — sudden row-count drops, spikes in null or empty values, duplicates, and other anomalies. Spans discovery (connection → dataSource → schema → table, one field per turn, or a catalog Table asset), preview, and create; optional tuning covers selected columns, monitors and adaptive-baseline settings, sample size, a row filter, a time slice, a recurring schedule, back-runs and notifications. `jobType` is PUSHDOWN (checks run inside the source database) or PULLUP (data is pulled into Spark). Confirm checkpoint: `confirm=false` (default) returns the exact request without creating anything; `confirm=true` creates and runs the job. **Gated:** the `data-quality` capability (`--data-quality`) - [`create_data_quality_rule`](pkg/tools/create_dq_rule/) - Create a data quality rule (monitor) on an existing DQ job. `monitorType` is `FREEFORM_SQL` (full SQL query) or `SIMPLE_SQL` (single-column check); defaults to active and not suppressed. Confirm checkpoint: `confirm=false` (default) returns a preview of the rule + SQL without creating; `confirm=true` creates. Uses the DQ monitoring API and requires permission to create rules on the target job. **Gated:** the `data-quality` capability (`--data-quality`) - [`deploy_data_quality_rule_template`](pkg/tools/deploy_dq_rule_template/) - Instantiate a rule template as concrete rules across one or more job/column targets (bulk). The DQ service resolves dialect-specific SQL and names each rule `{templateName}_{columnName}`. Confirm checkpoint: `confirm=false` (default) previews the template + targets without deploying; `confirm=true` deploys. Requires permission to deploy templates and create rules on the target jobs. **Gated:** the `data-quality` capability (`--data-quality`) diff --git a/pkg/clients/create_community_client.go b/pkg/clients/create_community_client.go new file mode 100644 index 0000000..f1ee94d --- /dev/null +++ b/pkg/clients/create_community_client.go @@ -0,0 +1,64 @@ +package clients + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" +) + +// CreateCommunityRequest is the request body for POST /rest/2.0/communities. +type CreateCommunityRequest struct { + Name string `json:"name"` + ParentID string `json:"parentId,omitempty"` + Description string `json:"description,omitempty"` +} + +// CreateCommunityResponse is the response from POST /rest/2.0/communities. +type CreateCommunityResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Parent *NamedIDRef `json:"parent,omitempty"` +} + +// NamedIDRef is a {id, name} reference to another resource, as nested in +// create responses. +type NamedIDRef struct { + ID string `json:"id"` + Name string `json:"name"` +} + +// CreateCommunity creates a new community via POST /rest/2.0/communities. +func CreateCommunity(ctx context.Context, client *http.Client, request CreateCommunityRequest) (*CreateCommunityResponse, error) { + var result CreateCommunityResponse + if err := postJSON(ctx, client, "/rest/2.0/communities", request, &result); err != nil { + return nil, fmt.Errorf("creating community: %w", err) + } + return &result, nil +} + +// postJSON POSTs body as JSON and decodes the response into out. Non-2xx +// responses surface Collibra's error envelope via executeCollibraRequest. +func postJSON(ctx context.Context, client *http.Client, path string, body, out any) error { + payload, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("marshaling request: %w", err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, path, bytes.NewReader(payload)) + if err != nil { + return fmt.Errorf("building request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + respBody, err := executeCollibraRequest(client, req) + if err != nil { + return err + } + if err := json.Unmarshal(respBody, out); err != nil { + return fmt.Errorf("decoding response: %w", err) + } + return nil +} diff --git a/pkg/clients/create_domain_client.go b/pkg/clients/create_domain_client.go new file mode 100644 index 0000000..310ece0 --- /dev/null +++ b/pkg/clients/create_domain_client.go @@ -0,0 +1,33 @@ +package clients + +import ( + "context" + "fmt" + "net/http" +) + +// CreateDomainRequest is the request body for POST /rest/2.0/domains. +type CreateDomainRequest struct { + Name string `json:"name"` + CommunityID string `json:"communityId"` + TypeID string `json:"typeId"` + Description string `json:"description,omitempty"` +} + +// CreateDomainResponse is the response from POST /rest/2.0/domains. +type CreateDomainResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Type *NamedIDRef `json:"type,omitempty"` + Community *NamedIDRef `json:"community,omitempty"` +} + +// CreateDomain creates a new domain via POST /rest/2.0/domains. +func CreateDomain(ctx context.Context, client *http.Client, request CreateDomainRequest) (*CreateDomainResponse, error) { + var result CreateDomainResponse + if err := postJSON(ctx, client, "/rest/2.0/domains", request, &result); err != nil { + return nil, fmt.Errorf("creating domain: %w", err) + } + return &result, nil +} diff --git a/pkg/tools/create_community/tool.go b/pkg/tools/create_community/tool.go new file mode 100644 index 0000000..e48f495 --- /dev/null +++ b/pkg/tools/create_community/tool.go @@ -0,0 +1,170 @@ +// Package create_community implements the create_community MCP tool: a single +// write tool that creates a Collibra community, optionally nested under an +// existing parent community. It is built around the confirm checkpoint of +// docs/TOOL_CONTRIBUTION_STANDARDS.md section 5: confirm=false (the default) +// returns a preview of everything that would be written and writes nothing; +// confirm=true performs the write. +package create_community + +import ( + "context" + "fmt" + "net/http" + "strings" + "unicode/utf8" + + "github.com/collibra/chip/pkg/chip" + "github.com/collibra/chip/pkg/clients" + "github.com/collibra/chip/pkg/tools/resolve" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// maxNameLength is the longest community name Collibra accepts. +const maxNameLength = 255 + +// OutputStatus is the overall outcome of a create_community call. +type OutputStatus string + +const ( + // StatusPreview means confirm was not set: nothing was written. + StatusPreview OutputStatus = "preview" + // StatusSuccess means the community was created. + StatusSuccess OutputStatus = "success" + // StatusValidationError means validation or name resolution failed before + // any write. + StatusValidationError OutputStatus = "validation_error" + // StatusError means Collibra rejected the write. + StatusError OutputStatus = "error" +) + +// Input is the tool's typed input. +type Input struct { + Name string `json:"name" jsonschema:"Required. Name of the new community, 1-255 characters. Collibra requires community names to be unique across the whole instance, e.g. 'Employee Wellness'."` + ParentCommunity string `json:"parentCommunity,omitempty" jsonschema:"Optional. The existing community to nest the new one under, as a UUID or the parent's exact display name (case-insensitive). Omit to create a top-level community."` + Description string `json:"description,omitempty" jsonschema:"Optional. Free-text description of the community."` + Confirm bool `json:"confirm,omitempty" jsonschema:"Safety checkpoint. false (the default) returns a PREVIEW of the community and writes nothing, so it can be reviewed with the user. Set true only after the user approved the preview."` +} + +// Preview echoes every field that confirm=true would write. +type Preview struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + ParentCommunityID string `json:"parentCommunityId,omitempty"` + ParentCommunityName string `json:"parentCommunityName,omitempty"` +} + +// CommunitySummary is the created community as Collibra returned it. +type CommunitySummary struct { + ID string `json:"id" jsonschema:"UUID of the newly created community."` + Name string `json:"name"` + Description string `json:"description,omitempty"` + ParentCommunityID string `json:"parentCommunityId,omitempty" jsonschema:"UUID of the parent community, when nested."` +} + +// Output is the typed response. +type Output struct { + Status OutputStatus `json:"status" jsonschema:"'preview' when confirm was not set (nothing written; review it, then call again with confirm=true); 'success' when the community was created; 'validation_error' for invalid or unresolvable inputs; 'error' when Collibra rejected the write."` + Message string `json:"message" jsonschema:"Human-readable summary."` + Preview *Preview `json:"preview,omitempty" jsonschema:"The community that would be created, when status is 'preview'."` + Community *CommunitySummary `json:"community,omitempty" jsonschema:"The created community, when status is 'success'."` +} + +// NewTool returns the registered tool. +func NewTool(collibraClient *http.Client) *chip.Tool[Input, Output] { + return &chip.Tool[Input, Output]{ + Name: "create_community", + Title: "Create Community", + Description: "Create a new Collibra community — the top-level organizational container that groups domains (a domain being a container for assets), " + + "for example 'Finance' or 'Employee Wellness'. A community can be nested under another community by passing parentCommunity, " + + "which accepts the parent's UUID or its exact display name, so there is no need to look the UUID up first. " + + "Use this to create a community; to create a domain inside a community use create_domain, and to create assets use create_asset. " + + "The name must be unique across all communities on the instance. " + + "Built around a confirm checkpoint: confirm=false (the default) returns a PREVIEW of exactly what would be created and writes nothing — show it to the user; " + + "call again with confirm=true to create it. " + + "Returns the new community's id on success. " + + "Requires permission to create communities (typically an administrator action); without it the write fails with a permission error. " + + "Example requests: 'create a community called Employee Wellness', 'add a sub-community Payroll under Finance', 'set up a new community for the marketing team'.", + Handler: handler(collibraClient), + Permissions: []string{}, + Annotations: &mcp.ToolAnnotations{ReadOnlyHint: false, DestructiveHint: chip.Ptr(false), IdempotentHint: false, OpenWorldHint: chip.Ptr(false)}, + } +} + +func handler(collibraClient *http.Client) chip.ToolHandlerFunc[Input, Output] { + return func(ctx context.Context, input Input) (Output, error) { + name := strings.TrimSpace(input.Name) + if msg := validateName(name); msg != "" { + return Output{Status: StatusValidationError, Message: msg}, nil + } + + var parent resolve.Ref + if raw := strings.TrimSpace(input.ParentCommunity); raw != "" { + var err error + parent, err = resolve.Community(ctx, collibraClient, "parentCommunity", raw) + if err != nil { + return Output{Status: StatusValidationError, Message: fmt.Sprintf("parentCommunity could not be resolved: %v", err)}, nil + } + } + + if !input.Confirm { + return previewOutput(name, input.Description, parent), nil + } + + resp, err := clients.CreateCommunity(ctx, collibraClient, clients.CreateCommunityRequest{ + Name: name, + ParentID: parent.ID, + Description: input.Description, + }) + if err != nil { + return Output{Status: StatusError, Message: fmt.Sprintf("Could not create community: %v", err)}, nil + } + return successOutput(resp, parent), nil + } +} + +func validateName(name string) string { + switch { + case name == "": + return "name is required: provide a community name of 1-255 characters." + case utf8.RuneCountInString(name) > maxNameLength: + return fmt.Sprintf("name is %d characters long; Collibra allows at most %d. Shorten it.", utf8.RuneCountInString(name), maxNameLength) + } + return "" +} + +func previewOutput(name, description string, parent resolve.Ref) Output { + return Output{ + Status: StatusPreview, + Message: fmt.Sprintf("Preview only — nothing was created. Will create community %q%s. "+ + "Review this with the user, then call again with confirm=true.", name, nestingSuffix(parent)), + Preview: &Preview{ + Name: name, + Description: description, + ParentCommunityID: parent.ID, + ParentCommunityName: parent.Name, + }, + } +} + +func successOutput(resp *clients.CreateCommunityResponse, parent resolve.Ref) Output { + summary := &CommunitySummary{ID: resp.ID, Name: resp.Name, Description: resp.Description} + if resp.Parent != nil { + summary.ParentCommunityID = resp.Parent.ID + parent.Name = resp.Parent.Name + } + return Output{ + Status: StatusSuccess, + Message: fmt.Sprintf("Created community %q (id %s)%s.", resp.Name, resp.ID, nestingSuffix(parent)), + Community: summary, + } +} + +func nestingSuffix(parent resolve.Ref) string { + switch { + case parent.Name != "": + return fmt.Sprintf(" under parent community %q", parent.Name) + case parent.ID != "": + return fmt.Sprintf(" under parent community %s", parent.ID) + } + return " as a top-level community" +} diff --git a/pkg/tools/create_community/tool_test.go b/pkg/tools/create_community/tool_test.go new file mode 100644 index 0000000..47a167e --- /dev/null +++ b/pkg/tools/create_community/tool_test.go @@ -0,0 +1,157 @@ +package create_community_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/collibra/chip/pkg/tools/create_community" + "github.com/collibra/chip/pkg/tools/testutil" +) + +const ( + financeID = "00000000-0000-0000-0000-000000000001" + payrollID = "00000000-0000-0000-0000-000000000002" + missingUUID = "00000000-0000-0000-0000-0000000000ff" +) + +// fakeDGC serves the community endpoints and records every write. +type fakeDGC struct { + communities []map[string]string + createCode int + posts []map[string]any +} + +func (f *fakeDGC) run(t *testing.T) func(ctx context.Context, in create_community.Input) create_community.Output { + t.Helper() + mux := http.NewServeMux() + mux.HandleFunc("GET /rest/2.0/communities", func(w http.ResponseWriter, r *http.Request) { + name := strings.ToLower(r.URL.Query().Get("name")) + var matches []map[string]string + for _, c := range f.communities { + if strings.Contains(strings.ToLower(c["name"]), name) { + matches = append(matches, c) + } + } + _ = json.NewEncoder(w).Encode(map[string]any{"results": matches, "total": len(matches)}) + }) + mux.HandleFunc("POST /rest/2.0/communities", func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + f.posts = append(f.posts, body) + if f.createCode != 0 { + w.WriteHeader(f.createCode) + _, _ = w.Write([]byte(`{"statusCode":409,"errorCode":"communityNameExists","userMessage":"A community with this name already exists."}`)) + return + } + resp := map[string]any{"id": "new-id", "name": body["name"], "description": body["description"]} + if pid, ok := body["parentId"].(string); ok { + resp["parent"] = map[string]string{"id": pid, "name": "Finance"} + } + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(resp) + }) + server := httptest.NewServer(mux) + t.Cleanup(server.Close) + tool := create_community.NewTool(testutil.NewClient(server)) + return func(ctx context.Context, in create_community.Input) create_community.Output { + out, err := tool.Handler(ctx, in) + if err != nil { + t.Fatalf("handler returned error: %v", err) + } + return out + } +} + +func newFake() *fakeDGC { + return &fakeDGC{communities: []map[string]string{ + {"id": financeID, "name": "Finance"}, + {"id": payrollID, "name": "Finance Payroll"}, + }} +} + +func TestPreviewDoesNotWrite(t *testing.T) { + f := newFake() + out := f.run(t)(context.Background(), create_community.Input{Name: " Wellness ", ParentCommunity: "finance", Description: "d"}) + if out.Status != create_community.StatusPreview { + t.Fatalf("status = %q (%s), want preview", out.Status, out.Message) + } + if len(f.posts) != 0 { + t.Fatalf("preview wrote: %v", f.posts) + } + want := create_community.Preview{Name: "Wellness", Description: "d", ParentCommunityID: financeID, ParentCommunityName: "Finance"} + if *out.Preview != want { + t.Errorf("preview = %+v, want %+v", *out.Preview, want) + } +} + +func TestConfirmCreatesNestedCommunity(t *testing.T) { + f := newFake() + out := f.run(t)(context.Background(), create_community.Input{Name: "Wellness", ParentCommunity: "Finance", Description: "d", Confirm: true}) + if out.Status != create_community.StatusSuccess { + t.Fatalf("status = %q (%s)", out.Status, out.Message) + } + if len(f.posts) != 1 || f.posts[0]["parentId"] != financeID || f.posts[0]["name"] != "Wellness" || f.posts[0]["description"] != "d" { + t.Errorf("unexpected write: %v", f.posts) + } + if out.Community.ID != "new-id" || out.Community.ParentCommunityID != financeID { + t.Errorf("unexpected summary: %+v", out.Community) + } +} + +func TestConfirmCreatesTopLevelCommunity(t *testing.T) { + f := newFake() + out := f.run(t)(context.Background(), create_community.Input{Name: "Wellness", Confirm: true}) + if out.Status != create_community.StatusSuccess { + t.Fatalf("status = %q (%s)", out.Status, out.Message) + } + if _, has := f.posts[0]["parentId"]; has { + t.Errorf("top-level community sent a parentId: %v", f.posts[0]) + } +} + +func TestParentUUIDPassesThroughWithoutLookup(t *testing.T) { + f := newFake() + out := f.run(t)(context.Background(), create_community.Input{Name: "Wellness", ParentCommunity: missingUUID, Confirm: true}) + if out.Status != create_community.StatusSuccess || f.posts[0]["parentId"] != missingUUID { + t.Fatalf("status = %q (%s), posts = %v", out.Status, out.Message, f.posts) + } +} + +func TestValidationErrorsNeverWrite(t *testing.T) { + tests := map[string]struct { + in create_community.Input + want string + }{ + "missing name": {create_community.Input{Name: " ", Confirm: true}, "name is required"}, + "name too long": {create_community.Input{Name: strings.Repeat("x", 256), Confirm: true}, "at most 255"}, + "unknown parent": {create_community.Input{Name: "W", ParentCommunity: "Nope", Confirm: true}, "no community matching"}, + "ambiguous parent": {create_community.Input{Name: "W", ParentCommunity: "Dup", Confirm: true}, "ambiguous"}, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + f := newFake() + f.communities = append(f.communities, + map[string]string{"id": "a", "name": "Dup"}, map[string]string{"id": "b", "name": "dup"}) + out := f.run(t)(context.Background(), tc.in) + if out.Status != create_community.StatusValidationError || !strings.Contains(out.Message, tc.want) { + t.Errorf("status = %q, message = %q; want validation_error containing %q", out.Status, out.Message, tc.want) + } + if len(f.posts) != 0 { + t.Errorf("wrote despite validation error: %v", f.posts) + } + }) + } +} + +func TestDownstreamErrorSurfacesCollibraMessage(t *testing.T) { + f := newFake() + f.createCode = http.StatusConflict + out := f.run(t)(context.Background(), create_community.Input{Name: "Wellness", Confirm: true}) + if out.Status != create_community.StatusError || !strings.Contains(out.Message, "already exists") { + t.Errorf("status = %q, message = %q", out.Status, out.Message) + } +} diff --git a/pkg/tools/create_domain/tool.go b/pkg/tools/create_domain/tool.go new file mode 100644 index 0000000..691abeb --- /dev/null +++ b/pkg/tools/create_domain/tool.go @@ -0,0 +1,186 @@ +// Package create_domain implements the create_domain MCP tool: a single write +// tool that creates a Collibra domain inside an existing community. It is +// built around the confirm checkpoint of docs/TOOL_CONTRIBUTION_STANDARDS.md +// section 5: confirm=false (the default) returns a preview of everything that +// would be written and writes nothing; confirm=true performs the write. +package create_domain + +import ( + "context" + "fmt" + "net/http" + "strings" + "unicode/utf8" + + "github.com/collibra/chip/pkg/chip" + "github.com/collibra/chip/pkg/clients" + "github.com/collibra/chip/pkg/tools/resolve" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// maxNameLength is the longest domain name Collibra accepts. +const maxNameLength = 255 + +// OutputStatus is the overall outcome of a create_domain call. +type OutputStatus string + +const ( + // StatusPreview means confirm was not set: nothing was written. + StatusPreview OutputStatus = "preview" + // StatusSuccess means the domain was created. + StatusSuccess OutputStatus = "success" + // StatusValidationError means validation or name resolution failed before + // any write. + StatusValidationError OutputStatus = "validation_error" + // StatusError means Collibra rejected the write. + StatusError OutputStatus = "error" +) + +// Input is the tool's typed input. +type Input struct { + Name string `json:"name" jsonschema:"Required. Name of the new domain, 1-255 characters. Must be unique within its community, e.g. 'Wellness Glossary'."` + Community string `json:"community" jsonschema:"Required. The existing community that will contain the domain, as a UUID or the community's exact display name (case-insensitive), e.g. 'Employee Wellness'."` + DomainType string `json:"domainType" jsonschema:"Required. The kind of domain, as a UUID or the domain type's exact display name (case-insensitive), e.g. 'Glossary' or 'Physical Data Dictionary'. The domain type decides which asset types the domain can hold. If the name does not match, the error lists the valid domain types."` + Description string `json:"description,omitempty" jsonschema:"Optional. Free-text description of the domain."` + Confirm bool `json:"confirm,omitempty" jsonschema:"Safety checkpoint. false (the default) returns a PREVIEW of the domain and writes nothing, so it can be reviewed with the user. Set true only after the user approved the preview."` +} + +// Preview echoes every field that confirm=true would write. +type Preview struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + CommunityID string `json:"communityId"` + CommunityName string `json:"communityName,omitempty"` + DomainTypeID string `json:"domainTypeId"` + DomainTypeName string `json:"domainTypeName,omitempty"` +} + +// DomainSummary is the created domain as Collibra returned it. +type DomainSummary struct { + ID string `json:"id" jsonschema:"UUID of the newly created domain."` + Name string `json:"name"` + Description string `json:"description,omitempty"` + CommunityID string `json:"communityId,omitempty"` + CommunityName string `json:"communityName,omitempty"` + DomainTypeID string `json:"domainTypeId,omitempty"` + DomainType string `json:"domainType,omitempty" jsonschema:"Display name of the domain type."` +} + +// Output is the typed response. +type Output struct { + Status OutputStatus `json:"status" jsonschema:"'preview' when confirm was not set (nothing written; review it, then call again with confirm=true); 'success' when the domain was created; 'validation_error' for invalid or unresolvable inputs; 'error' when Collibra rejected the write."` + Message string `json:"message" jsonschema:"Human-readable summary."` + Preview *Preview `json:"preview,omitempty" jsonschema:"The domain that would be created, when status is 'preview'."` + Domain *DomainSummary `json:"domain,omitempty" jsonschema:"The created domain, when status is 'success'."` +} + +// NewTool returns the registered tool. +func NewTool(collibraClient *http.Client) *chip.Tool[Input, Output] { + return &chip.Tool[Input, Output]{ + Name: "create_domain", + Title: "Create Domain", + Description: "Create a new Collibra domain — a container for assets that lives inside a community (a community being the top-level grouping of domains). " + + "The domainType (for example 'Glossary' for business terms, or 'Physical Data Dictionary' for tables and columns) decides which asset types the domain can hold. " + + "Both community and domainType accept a UUID or an exact display name, so there is no need to look the UUIDs up first; " + + "an unknown domainType returns the list of valid ones. " + + "Use this to create a domain; to create a community use create_community, and to create assets inside a domain use create_asset. " + + "The name must be unique within the community. " + + "Built around a confirm checkpoint: confirm=false (the default) returns a PREVIEW of exactly what would be created and writes nothing — show it to the user; " + + "call again with confirm=true to create it. " + + "Returns the new domain's id on success. " + + "Requires permission to create domains in the target community (typically an administrator action); without it the write fails with a permission error. " + + "Example requests: 'create a glossary domain called Wellness Terms in the Employee Wellness community', 'add a new domain for our data dictionary under Finance', 'I need somewhere to put business terms for HR'.", + Handler: handler(collibraClient), + Permissions: []string{}, + Annotations: &mcp.ToolAnnotations{ReadOnlyHint: false, DestructiveHint: chip.Ptr(false), IdempotentHint: false, OpenWorldHint: chip.Ptr(false)}, + } +} + +func handler(collibraClient *http.Client) chip.ToolHandlerFunc[Input, Output] { + return func(ctx context.Context, input Input) (Output, error) { + name := strings.TrimSpace(input.Name) + if msg := validate(name, input); msg != "" { + return Output{Status: StatusValidationError, Message: msg}, nil + } + + community, err := resolve.Community(ctx, collibraClient, "community", input.Community) + if err != nil { + return Output{Status: StatusValidationError, Message: fmt.Sprintf("community could not be resolved: %v", err)}, nil + } + domainType, err := resolve.DomainType(ctx, collibraClient, "domainType", input.DomainType) + if err != nil { + return Output{Status: StatusValidationError, Message: fmt.Sprintf("domainType could not be resolved: %v", err)}, nil + } + + if !input.Confirm { + return previewOutput(name, input.Description, community, domainType), nil + } + + resp, err := clients.CreateDomain(ctx, collibraClient, clients.CreateDomainRequest{ + Name: name, + CommunityID: community.ID, + TypeID: domainType.ID, + Description: input.Description, + }) + if err != nil { + return Output{Status: StatusError, Message: fmt.Sprintf("Could not create domain: %v", err)}, nil + } + return successOutput(resp), nil + } +} + +func validate(name string, input Input) string { + switch { + case name == "": + return "name is required: provide a domain name of 1-255 characters." + case utf8.RuneCountInString(name) > maxNameLength: + return fmt.Sprintf("name is %d characters long; Collibra allows at most %d. Shorten it.", utf8.RuneCountInString(name), maxNameLength) + case strings.TrimSpace(input.Community) == "": + return "community is required: pass the UUID or exact name of the community that will contain the domain." + case strings.TrimSpace(input.DomainType) == "": + return "domainType is required: pass a domain type UUID or exact name, e.g. 'Glossary'." + } + return "" +} + +func previewOutput(name, description string, community, domainType resolve.Ref) Output { + return Output{ + Status: StatusPreview, + Message: fmt.Sprintf("Preview only — nothing was created. Will create domain %q of type %s in community %s. "+ + "Review this with the user, then call again with confirm=true.", name, label(domainType), label(community)), + Preview: &Preview{ + Name: name, + Description: description, + CommunityID: community.ID, + CommunityName: community.Name, + DomainTypeID: domainType.ID, + DomainTypeName: domainType.Name, + }, + } +} + +func successOutput(resp *clients.CreateDomainResponse) Output { + summary := &DomainSummary{ID: resp.ID, Name: resp.Name, Description: resp.Description} + if resp.Community != nil { + summary.CommunityID = resp.Community.ID + summary.CommunityName = resp.Community.Name + } + if resp.Type != nil { + summary.DomainTypeID = resp.Type.ID + summary.DomainType = resp.Type.Name + } + return Output{ + Status: StatusSuccess, + Message: fmt.Sprintf("Created domain %q (id %s).", resp.Name, resp.ID), + Domain: summary, + } +} + +// label renders a resolved reference by name, falling back to its UUID when the +// caller supplied a UUID and no name was looked up. +func label(ref resolve.Ref) string { + if ref.Name != "" { + return fmt.Sprintf("%q", ref.Name) + } + return ref.ID +} diff --git a/pkg/tools/create_domain/tool_test.go b/pkg/tools/create_domain/tool_test.go new file mode 100644 index 0000000..0338f57 --- /dev/null +++ b/pkg/tools/create_domain/tool_test.go @@ -0,0 +1,130 @@ +package create_domain_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/collibra/chip/pkg/tools/create_domain" + "github.com/collibra/chip/pkg/tools/testutil" +) + +const ( + communityID = "00000000-0000-0000-0000-000000000001" + glossaryType = "00000000-0000-0000-0000-000000000010" + dictType = "00000000-0000-0000-0000-000000000011" +) + +// fakeDGC serves the lookup endpoints and records every domain write. +type fakeDGC struct { + posts []map[string]any +} + +func (f *fakeDGC) run(t *testing.T) func(in create_domain.Input) create_domain.Output { + t.Helper() + mux := http.NewServeMux() + mux.HandleFunc("GET /rest/2.0/communities", func(w http.ResponseWriter, r *http.Request) { + results := []map[string]string{} + if strings.Contains("employee wellness", strings.ToLower(r.URL.Query().Get("name"))) { + results = append(results, map[string]string{"id": communityID, "name": "Employee Wellness"}) + } + _ = json.NewEncoder(w).Encode(map[string]any{"results": results}) + }) + mux.HandleFunc("GET /rest/2.0/domainTypes", func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{"results": []map[string]string{ + {"id": glossaryType, "name": "Glossary"}, + {"id": dictType, "name": "Physical Data Dictionary"}, + }}) + }) + mux.HandleFunc("POST /rest/2.0/domains", func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + f.posts = append(f.posts, body) + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "new-id", "name": body["name"], + "community": map[string]string{"id": communityID, "name": "Employee Wellness"}, + "type": map[string]string{"id": glossaryType, "name": "Glossary"}, + }) + }) + server := httptest.NewServer(mux) + t.Cleanup(server.Close) + tool := create_domain.NewTool(testutil.NewClient(server)) + return func(in create_domain.Input) create_domain.Output { + out, err := tool.Handler(context.Background(), in) + if err != nil { + t.Fatalf("handler returned error: %v", err) + } + return out + } +} + +func TestPreviewResolvesNamesAndDoesNotWrite(t *testing.T) { + f := &fakeDGC{} + out := f.run(t)(create_domain.Input{Name: "Terms", Community: "employee wellness", DomainType: "glossary", Description: "d"}) + if out.Status != create_domain.StatusPreview { + t.Fatalf("status = %q (%s), want preview", out.Status, out.Message) + } + if len(f.posts) != 0 { + t.Fatalf("preview wrote: %v", f.posts) + } + want := create_domain.Preview{Name: "Terms", Description: "d", CommunityID: communityID, CommunityName: "Employee Wellness", DomainTypeID: glossaryType, DomainTypeName: "Glossary"} + if *out.Preview != want { + t.Errorf("preview = %+v, want %+v", *out.Preview, want) + } +} + +func TestConfirmCreatesDomainWithResolvedIDs(t *testing.T) { + f := &fakeDGC{} + out := f.run(t)(create_domain.Input{Name: "Terms", Community: "Employee Wellness", DomainType: "Glossary", Description: "d", Confirm: true}) + if out.Status != create_domain.StatusSuccess { + t.Fatalf("status = %q (%s)", out.Status, out.Message) + } + if len(f.posts) != 1 { + t.Fatalf("writes = %v, want exactly one", f.posts) + } + p := f.posts[0] + if p["communityId"] != communityID || p["typeId"] != glossaryType || p["name"] != "Terms" || p["description"] != "d" { + t.Errorf("unexpected write: %v", p) + } + if out.Domain.ID != "new-id" || out.Domain.DomainType != "Glossary" || out.Domain.CommunityName != "Employee Wellness" { + t.Errorf("unexpected summary: %+v", out.Domain) + } +} + +func TestUUIDsPassThrough(t *testing.T) { + f := &fakeDGC{} + out := f.run(t)(create_domain.Input{Name: "Terms", Community: communityID, DomainType: dictType, Confirm: true}) + if out.Status != create_domain.StatusSuccess || f.posts[0]["typeId"] != dictType || f.posts[0]["communityId"] != communityID { + t.Fatalf("status = %q (%s), posts = %v", out.Status, out.Message, f.posts) + } +} + +func TestValidationErrorsNeverWrite(t *testing.T) { + tests := map[string]struct { + in create_domain.Input + want string + }{ + "missing name": {create_domain.Input{Community: "c", DomainType: "t"}, "name is required"}, + "name too long": {create_domain.Input{Name: strings.Repeat("x", 256), Community: "c", DomainType: "t"}, "at most 255"}, + "missing community": {create_domain.Input{Name: "n", DomainType: "t"}, "community is required"}, + "missing domain type": {create_domain.Input{Name: "n", Community: "c"}, "domainType is required"}, + "unknown community": {create_domain.Input{Name: "n", Community: "Nope", DomainType: "Glossary", Confirm: true}, "no community matching"}, + "unknown domain type": {create_domain.Input{Name: "n", Community: "Employee Wellness", DomainType: "Nope", Confirm: true}, "Glossary, Physical Data Dictionary"}, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + f := &fakeDGC{} + out := f.run(t)(tc.in) + if out.Status != create_domain.StatusValidationError || !strings.Contains(out.Message, tc.want) { + t.Errorf("status = %q, message = %q; want validation_error containing %q", out.Status, out.Message, tc.want) + } + if len(f.posts) != 0 { + t.Errorf("wrote despite validation error: %v", f.posts) + } + }) + } +} diff --git a/pkg/tools/register.go b/pkg/tools/register.go index 02f680f..6b131bc 100644 --- a/pkg/tools/register.go +++ b/pkg/tools/register.go @@ -10,6 +10,8 @@ import ( "github.com/collibra/chip/pkg/tools/cancel_dq_job_run" "github.com/collibra/chip/pkg/tools/create_assessment" "github.com/collibra/chip/pkg/tools/create_asset" + "github.com/collibra/chip/pkg/tools/create_community" + "github.com/collibra/chip/pkg/tools/create_domain" "github.com/collibra/chip/pkg/tools/create_dq_job" "github.com/collibra/chip/pkg/tools/create_dq_rule" "github.com/collibra/chip/pkg/tools/delete_dq_job" @@ -98,6 +100,8 @@ func RegisterAll(server *chip.Server, client *http.Client, toolConfig *chip.Serv toolRegister(server, toolConfig, search_lineage_transformations.NewTool(client)) toolRegister(server, toolConfig, prepare_create_asset.NewTool(client)) toolRegister(server, toolConfig, create_asset.NewTool(client)) + toolRegister(server, toolConfig, create_community.NewTool(client)) + toolRegister(server, toolConfig, create_domain.NewTool(client)) toolRegister(server, toolConfig, edit_asset.NewTool(client)) // Registered WITHOUT an experimental flag, deviating from // docs/TOOL_CONTRIBUTION_STANDARDS.md 3.1: this resolver only feeds the diff --git a/pkg/tools/register_test.go b/pkg/tools/register_test.go index 0b56709..e8e42c5 100644 --- a/pkg/tools/register_test.go +++ b/pkg/tools/register_test.go @@ -256,3 +256,14 @@ func listTools(t *testing.T, cfg *chip.ServerToolConfig) []*mcp.Tool { } return result } + +// create_community and create_domain ship ungated, like create_asset: they are +// generally available write tools that sit beside it in the default surface. +func TestRegisterAll_CommunityAndDomainCreateToolsVisibleWithEmptyConfig(t *testing.T) { + names := listToolNames(t, &chip.ServerToolConfig{}) + for _, name := range []string{"create_community", "create_domain"} { + if !slices.Contains(names, name) { + t.Errorf("expected %q to be registered with an empty config; got tools=%v", name, names) + } + } +} diff --git a/pkg/tools/resolve/community.go b/pkg/tools/resolve/community.go new file mode 100644 index 0000000..13c7c50 --- /dev/null +++ b/pkg/tools/resolve/community.go @@ -0,0 +1,81 @@ +package resolve + +import ( + "context" + "fmt" + "net/http" + "strings" + + "github.com/collibra/chip/pkg/clients" + "github.com/google/uuid" +) + +// Ref is a resolved reference. Name is empty when the caller supplied a UUID, +// because a UUID passes through without a lookup. +type Ref struct { + ID string + Name string +} + +// Community resolves a community given as a UUID or a display name. A UUID +// passes through with no request; a name is matched exactly (case-insensitive) +// against the server-side substring search, and an ambiguous name is an error +// naming every candidate with its UUID. +func Community(ctx context.Context, client *http.Client, param, value string) (Ref, error) { + value = strings.TrimSpace(value) + if _, err := uuid.Parse(value); err == nil { + return Ref{ID: value}, nil + } + communities, err := clients.SearchCommunitiesByName(ctx, client, value, 50) + if err != nil { + return Ref{}, fmt.Errorf("resolving community %q: %w", value, err) + } + refs := make([]NamedRef, len(communities)) + for i, c := range communities { + refs[i] = NamedRef{ID: c.ID, Name: c.Name} + } + hints := Hints{ + NotFound: "Pass the community's UUID or its exact name.", + Ambiguity: fmt.Sprintf("pass the UUID in %s to disambiguate", param), + Candidates: "Similar communities", + } + return pickRef("community", value, refs, hints) +} + +// DomainType resolves a domain type given as a UUID or a display name. Domain +// types are a small enumerable set, so the full list is fetched and matched in +// memory; a UUID passes through with no request. +func DomainType(ctx context.Context, client *http.Client, param, value string) (Ref, error) { + value = strings.TrimSpace(value) + if _, err := uuid.Parse(value); err == nil { + return Ref{ID: value}, nil + } + types, err := clients.ListDomainTypes(ctx, client) + if err != nil { + return Ref{}, fmt.Errorf("resolving domain type %q: %w", value, err) + } + refs := make([]NamedRef, len(types)) + for i, t := range types { + refs[i] = NamedRef{ID: t.ID, Name: t.Name} + } + hints := Hints{ + NotFound: "Pass the domain type's UUID or its exact name.", + Ambiguity: fmt.Sprintf("pass the UUID in %s to disambiguate", param), + } + return pickRef("domain type", value, refs, hints) +} + +// pickRef reduces candidates to the single exact match and keeps its name so +// callers can echo it back. +func pickRef(label, value string, refs []NamedRef, hints Hints) (Ref, error) { + id, err := PickMatch(label, value, refs, hints) + if err != nil { + return Ref{}, err + } + for _, r := range refs { + if r.ID == id { + return Ref{ID: id, Name: r.Name}, nil + } + } + return Ref{ID: id}, nil +}