Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ This Go-based MCP server acts as a bridge between AI applications and Collibra,
- [`discover_data_assets`](pkg/tools/discover_data_assets/) - Query available data assets using natural language. Note that this tool leverages Collibra AI and therefore consumes Collibra Units (CUs). **Requires:** `dgc.ai-copilot`
- [`get_assessment`](pkg/tools/get_assessment/) - Retrieve conducted assessment(s) from the Assessments application (these are not catalog assets). Direct lookup of a single assessment by name or UUID (or by its linked Assessment Review asset), or a filtered lookup combining name (partial), status, template, conducted asset, and a last-modified range (paginated)
- [`get_asset_details`](pkg/tools/get_asset_details/) - Retrieve detailed information about specific assets by UUID, including the asset's assignable attribute schema (every attribute it can hold, including empty ones)
- [`get_asset_operating_model`](pkg/tools/get_asset_operating_model/) - Look up the attributes and relations an asset type has in a given domain (`assetType` accepts a UUID, publicId or name and is required; `domain` accepts a UUID or name and is optional — without it the asset type's default model is returned): which attributes are required, read-only or rich text, which relations apply and which statuses can be set. Replaces `prepare_create_asset`
- [`get_business_term_data`](pkg/tools/get_business_term_data/) - Trace a business term back to its connected physical data assets
- [`get_column_semantics`](pkg/tools/get_column_semantics/) - Retrieve data attributes, measures, and business assets connected to a column
- [`get_data_quality_rule`](pkg/tools/get_dq_rule/) - Read the definition of a single DQ rule (monitor) on a job — its type, SQL, filter, tolerance and active/suppressed state. **Gated:** the `data-quality` capability (`--data-quality`)
Expand Down
75 changes: 75 additions & 0 deletions pkg/clients/get_asset_operating_model_client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package clients

import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
)

// ErrAssignmentNotFound is returned when no assignment exists for the asset type
// in the domain, i.e. the asset type is not allowed there.
var ErrAssignmentNotFound = errors.New("assignment not found")

// GetAssignmentForAssetTypeAndDomain returns the effective assignment for an
// asset of the given type created in the given domain, via
// GET /rest/2.0/assignments/assetType/{assetTypeId}/domain/{domainId}.
// Scope selection and inheritance are resolved server-side, so the result lists
// the attributes and relations that apply, including those inherited from traits
// and ancestor assignments.
func GetAssignmentForAssetTypeAndDomain(ctx context.Context, client *http.Client, assetTypeID, domainID string) (*PrepareCreateScopedAssignment, error) {
reqURL := fmt.Sprintf("/rest/2.0/assignments/assetType/%s/domain/%s", url.PathEscape(assetTypeID), url.PathEscape(domainID))
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
if err != nil {
return nil, fmt.Errorf("building get assignment request: %w", err)
}
req.Header.Set("Accept", "application/json")

resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("getting assignment for asset type and domain: %w", err)
}
defer func() { _ = resp.Body.Close() }()

if resp.StatusCode == http.StatusNotFound {
return nil, fmt.Errorf("no assignment found for asset type %q in domain %q: %w", assetTypeID, domainID, ErrAssignmentNotFound)
}
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("getting assignment for asset type and domain: status %d: %s", resp.StatusCode, string(body))
}

var raw rawScopedAssignment
if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil {
return nil, fmt.Errorf("decoding assignment response: %w", err)
}
return emitAssignmentCharacteristics(raw), nil
}

// ErrNoDefaultAssignment is returned when an asset type has assignments, but
// only scoped ones, so none applies without knowing the domain.
var ErrNoDefaultAssignment = errors.New("no default assignment")

// GetDefaultAssignmentForAssetType returns the assignment that applies to an
// asset type regardless of domain, via GET /rest/2.0/assignments/assetType/{id}.
// That endpoint returns one assignment per scope; the unscoped one is the
// default. It returns ErrAssignmentNotFound when the asset type has no
// assignments and ErrNoDefaultAssignment when it has only scoped ones.
func GetDefaultAssignmentForAssetType(ctx context.Context, client *http.Client, assetTypeID string) (*PrepareCreateScopedAssignment, error) {
raws, err := fetchRawAssignments(ctx, client, assetTypeID)
if err != nil {
return nil, err
}
if len(raws) == 0 {
return nil, fmt.Errorf("no assignment found for asset type %q: %w", assetTypeID, ErrAssignmentNotFound)
}
for _, raw := range raws {
if raw.Scope == nil {
return emitAssignmentCharacteristics(raw), nil
}
}
return nil, fmt.Errorf("asset type %q has only scoped assignments: %w", assetTypeID, ErrNoDefaultAssignment)
}
184 changes: 184 additions & 0 deletions pkg/clients/get_asset_operating_model_client_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
package clients

import (
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"

"github.com/collibra/chip/pkg/tools/testutil"
)

const (
operatingModelAssetTypeID = "00000000-0000-0000-0000-000000000011"
operatingModelDomainID = "00000000-0000-0000-0000-000000000022"
operatingModelPath = "/rest/2.0/assignments/assetType/" + operatingModelAssetTypeID + "/domain/" + operatingModelDomainID
)

func TestGetAssignmentForAssetTypeAndDomain_ShouldMapAttributesAndRelations(t *testing.T) {
var gotPath string
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"id": "assignment-1",
"assignedCharacteristicTypeReferences": [
{"assignedResourceReference": {"id": "attr-1", "name": "Definition", "resourceDiscriminator": "StringAttributeType"},
"assignedResourcePublicId": "Definition", "minimumOccurrences": 1, "maximumOccurrences": 1},
{"assignedResourceReference": {"id": "attr-2", "name": "Score", "resourceDiscriminator": "NumericAttributeType"},
"minimumOccurrences": 0, "readOnly": true},
{"assignedResourceReference": {"id": "rel-1", "resourceDiscriminator": "RelationType"},
"assignedResourcePublicId": "TermIsPartOf", "relationTypeDirection": "TO_TARGET",
"relationTypeRestriction": {"id": "type-9", "name": "Business Term"}}
],
"traitAssignmentInheritances": [
{"assignedCharacteristicTypeReferences": [
{"assignedResourceReference": {"id": "attr-3", "name": "Note", "resourceDiscriminator": "StringAttributeType"}}
]}
]
}`))
})
server := httptest.NewServer(mux)
defer server.Close()

got, err := GetAssignmentForAssetTypeAndDomain(t.Context(), testutil.NewClient(server), operatingModelAssetTypeID, operatingModelDomainID)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

if gotPath != operatingModelPath {
t.Errorf("expected path %q, got %q", operatingModelPath, gotPath)
}
if got.AssignmentID != "assignment-1" {
t.Errorf("expected assignment id assignment-1, got %q", got.AssignmentID)
}
definition, ok := attrByName(got.Attributes, "Definition")
if !ok || !definition.Required || definition.Kind != "StringAttributeType" || definition.AttributeTypePublicID != "Definition" {
t.Errorf("unexpected Definition attribute: %+v", definition)
}
if definition.Max == nil || *definition.Max != 1 {
t.Errorf("expected Definition max 1, got %v", definition.Max)
}
score, ok := attrByName(got.Attributes, "Score")
if !ok || !score.ReadOnly || score.Required {
t.Errorf("expected Score to be optional and read-only, got %+v", score)
}
if _, ok := attrByName(got.Attributes, "Note"); !ok {
t.Errorf("expected trait-inherited attribute Note, got %+v", got.Attributes)
}
rels := relsByID(got.Relations, "rel-1")
if len(rels) != 1 || rels[0].Direction != "TO_TARGET" || rels[0].TargetType == nil || rels[0].TargetType.ID != "type-9" {
t.Errorf("unexpected relations: %+v", got.Relations)
}
}

func TestGetAssignmentForAssetTypeAndDomain_ShouldReturnErrorWhenNotFound(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
defer server.Close()

_, err := GetAssignmentForAssetTypeAndDomain(t.Context(), testutil.NewClient(server), operatingModelAssetTypeID, operatingModelDomainID)
if !errors.Is(err, ErrAssignmentNotFound) {
t.Fatalf("expected ErrAssignmentNotFound, got %v", err)
}
}

func TestGetAssignmentForAssetTypeAndDomain_ShouldReturnErrorOnServerFailure(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte("boom"))
}))
defer server.Close()

_, err := GetAssignmentForAssetTypeAndDomain(t.Context(), testutil.NewClient(server), operatingModelAssetTypeID, operatingModelDomainID)
if err == nil || !strings.Contains(err.Error(), "status 500") {
t.Fatalf("expected status 500 error, got %v", err)
}
}

func TestGetAssignmentForAssetTypeAndDomain_ShouldReturnErrorOnMalformedBody(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("not json"))
}))
defer server.Close()

_, err := GetAssignmentForAssetTypeAndDomain(t.Context(), testutil.NewClient(server), operatingModelAssetTypeID, operatingModelDomainID)
if err == nil || !strings.Contains(err.Error(), "decoding") {
t.Fatalf("expected decoding error, got %v", err)
}
}

const defaultAssignmentPath = "/rest/2.0/assignments/assetType/" + operatingModelAssetTypeID

func serveDefaultAssignments(t *testing.T, body string) *httptest.Server {
t.Helper()
mux := http.NewServeMux()
mux.HandleFunc(defaultAssignmentPath, func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(body))
})
server := httptest.NewServer(mux)
t.Cleanup(server.Close)
return server
}

func TestGetDefaultAssignmentForAssetType_ShouldPickTheUnscopedAssignment(t *testing.T) {
server := serveDefaultAssignments(t, `[
{"id": "scoped", "scope": {"id": "scope-1", "name": "Scope"},
"assignedCharacteristicTypeReferences": [
{"assignedResourceReference": {"id": "attr-s", "name": "Scoped Only", "resourceDiscriminator": "StringAttributeType"}}]},
{"id": "default",
"assignedCharacteristicTypeReferences": [
{"assignedResourceReference": {"id": "attr-d", "name": "Definition", "resourceDiscriminator": "StringAttributeType"}, "minimumOccurrences": 1}]}
]`)

got, err := GetDefaultAssignmentForAssetType(t.Context(), testutil.NewClient(server), operatingModelAssetTypeID)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

if got.AssignmentID != "default" {
t.Errorf("expected the unscoped assignment, got %q", got.AssignmentID)
}
if _, ok := attrByName(got.Attributes, "Definition"); !ok {
t.Errorf("expected Definition, got %+v", got.Attributes)
}
if _, ok := attrByName(got.Attributes, "Scoped Only"); ok {
t.Errorf("scoped attribute must not bleed in, got %+v", got.Attributes)
}
}

func TestGetDefaultAssignmentForAssetType_ShouldFailWhenOnlyScopedAssignmentsExist(t *testing.T) {
server := serveDefaultAssignments(t, `[{"id": "scoped", "scope": {"id": "scope-1", "name": "Scope"}}]`)

_, err := GetDefaultAssignmentForAssetType(t.Context(), testutil.NewClient(server), operatingModelAssetTypeID)

if !errors.Is(err, ErrNoDefaultAssignment) {
t.Fatalf("expected ErrNoDefaultAssignment, got %v", err)
}
}

func TestGetDefaultAssignmentForAssetType_ShouldFailWhenThereAreNoAssignments(t *testing.T) {
server := serveDefaultAssignments(t, `[]`)

_, err := GetDefaultAssignmentForAssetType(t.Context(), testutil.NewClient(server), operatingModelAssetTypeID)

if !errors.Is(err, ErrAssignmentNotFound) {
t.Fatalf("expected ErrAssignmentNotFound, got %v", err)
}
}

func TestGetDefaultAssignmentForAssetType_ShouldReturnErrorOnServerFailure(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer server.Close()

_, err := GetDefaultAssignmentForAssetType(t.Context(), testutil.NewClient(server), operatingModelAssetTypeID)

if err == nil || errors.Is(err, ErrAssignmentNotFound) || errors.Is(err, ErrNoDefaultAssignment) {
t.Fatalf("expected a plain failure, got %v", err)
}
}
4 changes: 2 additions & 2 deletions pkg/tools/create_asset/tool.go
Original file line number Diff line number Diff line change
Expand Up @@ -116,9 +116,9 @@ func NewTool(collibraClient *http.Client) *chip.Tool[Input, Output] {
"Inputs accept human-friendly identifiers: assetType resolves from UUID, publicId, or display name; domain from UUID or display name; status from UUID or status name; attributes by name or typeId. " +
"Markdown in RICH_TEXT attribute values (e.g. 'Definition') is converted to HTML server-side so it renders correctly in Collibra. " +
"When allowDuplicate is false (the default), an existing asset with the same name in the same (assetType, domain) returns status=duplicate_found without writing. " +
"Attributes the assignment marks read-only (values Collibra calculates, such as a trust score) are rejected with status=validation_error and never need to be supplied; prepare_create_asset flags them with readOnly=true. " +
"Attributes the assignment marks read-only (values Collibra calculates, such as a trust score) are rejected with status=validation_error and never need to be supplied; get_asset_operating_model flags them with readOnly=true. " +
"Validation errors return suggestion-rich messages so the agent can self-correct. " +
"Calling prepare_create_asset first is optional — only needed when the agent wants to enumerate options or inspect a type's full attribute schema.",
"Calling get_asset_operating_model first is optional — only needed when the agent wants to inspect the attributes, relations and statuses available for an asset type in a domain.",
Handler: handler(collibraClient),
Permissions: []string{},
Annotations: &mcp.ToolAnnotations{ReadOnlyHint: false, DestructiveHint: chip.Ptr(false), IdempotentHint: false, OpenWorldHint: chip.Ptr(false)},
Expand Down
4 changes: 2 additions & 2 deletions pkg/tools/get_asset_details/tool.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ type Output struct {
// lets the caller tell an empty attribute apart from one that isn't valid at
// all — the GraphQL attribute lists only include attributes that have a value.
type AssignableAttribute struct {
Name string `json:"name" jsonschema:"the attribute type display name, e.g. Definition. This is a name only — to get the attribute type's UUID or publicId, call prepare_create_asset (attributeSchema[].attributeTypeId / .publicId)."`
Name string `json:"name" jsonschema:"the attribute type display name, e.g. Definition. This is a name only — to get the attribute type's UUID or publicId, call get_asset_operating_model (attributeSchema[].attributeTypeId / .publicId)."`
Required bool `json:"required" jsonschema:"whether the assignment requires this attribute to have a value"`
IsSet bool `json:"isSet" jsonschema:"true if the asset currently has a value for this attribute, false if it is empty but settable"`
}
Expand All @@ -55,7 +55,7 @@ func NewTool(collibraClient *http.Client, contextSpecsEnabled bool) *chip.Tool[I
return &chip.Tool[Input, Output]{
Name: "get_asset_details",
Title: "Get Asset Details",
Description: "Get detailed information about a specific asset by its UUID, including attributes, relations, tags, responsibilities (owners, stewards, and other role assignments), and metadata. Also returns assignableAttributes: every attribute type the asset can hold, with required and isSet flags — use this to tell an empty-but-settable attribute (e.g. an unset Definition) apart from one that isn't valid for the asset. Returns up to 100 attributes per type and supports cursor-based pagination for relations (50 per page). Attributes and relations here carry display names only — for an attribute or relation type's UUID or publicId, call prepare_create_asset (attributeSchema[]/relationTypes[]). Optionally executes a Context Specification against the asset and returns the generated YAML context (requires the context-specifications experimental feature).",
Description: "Get detailed information about a specific asset by its UUID, including attributes, relations, tags, responsibilities (owners, stewards, and other role assignments), and metadata. Also returns assignableAttributes: every attribute type the asset can hold, with required and isSet flags — use this to tell an empty-but-settable attribute (e.g. an unset Definition) apart from one that isn't valid for the asset. Returns up to 100 attributes per type and supports cursor-based pagination for relations (50 per page). Attributes and relations here carry display names only — for an attribute or relation type's UUID or publicId, call get_asset_operating_model (attributeSchema[]/relationTypes[]). Optionally executes a Context Specification against the asset and returns the generated YAML context (requires the context-specifications experimental feature).",
Handler: handler(collibraClient, contextSpecsEnabled),
Permissions: []string{},
Annotations: &mcp.ToolAnnotations{ReadOnlyHint: true, DestructiveHint: chip.Ptr(false), IdempotentHint: true, OpenWorldHint: chip.Ptr(false)},
Expand Down
79 changes: 79 additions & 0 deletions pkg/tools/get_asset_operating_model/resolve.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package get_asset_operating_model

import (
"context"
"fmt"
"net/http"
"strings"

"github.com/collibra/chip/pkg/clients"
"github.com/google/uuid"
)

// maxNameMatches caps how many name matches are fetched while looking for an
// exact one.
const maxNameMatches = 50

// resolveAssetType tries UUID, then publicId, then a case-insensitive exact name
// match, and returns the first strategy that succeeds.
func resolveAssetType(ctx context.Context, collibraClient *http.Client, value string) (*clients.PrepareCreateAssetType, error) {
if isUUID(value) {
if assetType, err := clients.GetAssetTypeByID(ctx, collibraClient, value); err == nil {
return assetType, nil
}
}
if assetType, err := clients.GetAssetTypeByPublicID(ctx, collibraClient, value); err == nil {
return assetType, nil
}
matches, _, err := clients.SearchAssetTypesByName(ctx, collibraClient, value, maxNameMatches)
if err != nil {
return nil, fmt.Errorf("searching for asset type %q: %w", value, err)
}
var exact []clients.PrepareCreateAssetType
for _, match := range matches {
if strings.EqualFold(match.Name, value) {
exact = append(exact, match)
}
}
switch len(exact) {
case 1:
return &exact[0], nil
case 0:
return nil, fmt.Errorf("no asset type matches %q", value)
default:
return nil, fmt.Errorf("asset type %q is ambiguous: %d exact matches, use its UUID or publicId", value, len(exact))
}
}

// resolveDomain tries UUID, then a case-insensitive exact name match. Domains
// have no publicId.
func resolveDomain(ctx context.Context, collibraClient *http.Client, value string) (*clients.PrepareCreateDomain, error) {
if isUUID(value) {
if domain, err := clients.GetDomainByID(ctx, collibraClient, value); err == nil {
return domain, nil
}
}
matches, _, err := clients.SearchDomainsByName(ctx, collibraClient, value, maxNameMatches)
if err != nil {
return nil, fmt.Errorf("searching for domain %q: %w", value, err)
}
var exact []clients.PrepareCreateDomain
for _, match := range matches {
if strings.EqualFold(match.Name, value) {
exact = append(exact, match)
}
}
switch len(exact) {
case 1:
return &exact[0], nil
case 0:
return nil, fmt.Errorf("no domain matches %q", value)
default:
return nil, fmt.Errorf("domain %q is ambiguous: %d exact matches, use its UUID", value, len(exact))
}
}

// isUUID reports whether s is a UUID in the canonical 8-4-4-4-12 form.
func isUUID(s string) bool {
return len(s) == 36 && uuid.Validate(s) == nil
}
Loading
Loading